From 1b8b8869951f28d4e816f662a839566599b4bffc Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 21 Feb 2026 04:55:45 +0000 Subject: [PATCH] feat: add get and describe commands with API client kubectl-style get (table/json/yaml) and describe commands for servers, profiles, projects, instances. ApiClient for daemon communication. 118 CLI tests passing. Co-Authored-By: Claude Opus 4.6 --- src/cli/src/api-client.ts | 91 ++++++++++++++++++ src/cli/src/commands/describe.ts | 74 ++++++++++++++ src/cli/src/commands/get.ts | 122 ++++++++++++++++++++++++ src/cli/src/index.ts | 31 ++++++ src/cli/tests/api-client.test.ts | 77 +++++++++++++++ src/cli/tests/commands/describe.test.ts | 85 +++++++++++++++++ src/cli/tests/commands/get.test.ts | 86 +++++++++++++++++ 7 files changed, 566 insertions(+) create mode 100644 src/cli/src/api-client.ts create mode 100644 src/cli/src/commands/describe.ts create mode 100644 src/cli/src/commands/get.ts create mode 100644 src/cli/tests/api-client.test.ts create mode 100644 src/cli/tests/commands/describe.test.ts create mode 100644 src/cli/tests/commands/get.test.ts diff --git a/src/cli/src/api-client.ts b/src/cli/src/api-client.ts new file mode 100644 index 0000000..76b6dca --- /dev/null +++ b/src/cli/src/api-client.ts @@ -0,0 +1,91 @@ +import http from 'node:http'; + +export interface ApiClientOptions { + baseUrl: string; + timeout?: number; +} + +export interface ApiResponse { + status: number; + data: T; +} + +export class ApiError extends Error { + constructor( + public readonly status: number, + public readonly body: string, + ) { + super(`API error ${status}: ${body}`); + this.name = 'ApiError'; + } +} + +function request(method: string, url: string, timeout: number, body?: unknown): Promise> { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const opts: http.RequestOptions = { + hostname: parsed.hostname, + port: parsed.port, + path: parsed.pathname + parsed.search, + method, + timeout, + headers: { 'Content-Type': 'application/json' }, + }; + + const req = http.request(opts, (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf-8'); + const status = res.statusCode ?? 0; + if (status >= 400) { + reject(new ApiError(status, raw)); + return; + } + try { + resolve({ status, data: JSON.parse(raw) as T }); + } catch { + resolve({ status, data: raw as unknown as T }); + } + }); + }); + req.on('error', reject); + req.on('timeout', () => { + req.destroy(); + reject(new Error(`Request to ${url} timed out`)); + }); + if (body !== undefined) { + req.write(JSON.stringify(body)); + } + req.end(); + }); +} + +export class ApiClient { + private baseUrl: string; + private timeout: number; + + constructor(opts: ApiClientOptions) { + this.baseUrl = opts.baseUrl.replace(/\/$/, ''); + this.timeout = opts.timeout ?? 10000; + } + + async get(path: string): Promise { + const res = await request('GET', `${this.baseUrl}${path}`, this.timeout); + return res.data; + } + + async post(path: string, body?: unknown): Promise { + const res = await request('POST', `${this.baseUrl}${path}`, this.timeout, body); + return res.data; + } + + async put(path: string, body?: unknown): Promise { + const res = await request('PUT', `${this.baseUrl}${path}`, this.timeout, body); + return res.data; + } + + async delete(path: string): Promise { + await request('DELETE', `${this.baseUrl}${path}`, this.timeout); + } +} diff --git a/src/cli/src/commands/describe.ts b/src/cli/src/commands/describe.ts new file mode 100644 index 0000000..a3a16e5 --- /dev/null +++ b/src/cli/src/commands/describe.ts @@ -0,0 +1,74 @@ +import { Command } from 'commander'; +import { formatJson, formatYaml } from '../formatters/output.js'; + +export interface DescribeCommandDeps { + fetchResource: (resource: string, id: string) => Promise; + log: (...args: string[]) => void; +} + +const RESOURCE_ALIASES: Record = { + server: 'servers', + srv: 'servers', + profile: 'profiles', + prof: 'profiles', + project: 'projects', + proj: 'projects', + instance: 'instances', + inst: 'instances', +}; + +function resolveResource(name: string): string { + const lower = name.toLowerCase(); + return RESOURCE_ALIASES[lower] ?? lower; +} + +function formatDetail(obj: Record, indent = 0): string { + const pad = ' '.repeat(indent); + const lines: string[] = []; + + for (const [key, value] of Object.entries(obj)) { + if (value === null || value === undefined) { + lines.push(`${pad}${key}: -`); + } else if (Array.isArray(value)) { + if (value.length === 0) { + lines.push(`${pad}${key}: []`); + } else if (typeof value[0] === 'object') { + lines.push(`${pad}${key}:`); + for (const item of value) { + lines.push(`${pad} - ${JSON.stringify(item)}`); + } + } else { + lines.push(`${pad}${key}: ${value.join(', ')}`); + } + } else if (typeof value === 'object') { + lines.push(`${pad}${key}:`); + lines.push(formatDetail(value as Record, indent + 1)); + } else { + lines.push(`${pad}${key}: ${String(value)}`); + } + } + + return lines.join('\n'); +} + +export function createDescribeCommand(deps: DescribeCommandDeps): Command { + return new Command('describe') + .description('Show detailed information about a resource') + .argument('', 'resource type (server, profile, project, instance)') + .argument('', 'resource ID') + .option('-o, --output ', 'output format (detail, json, yaml)', 'detail') + .action(async (resourceArg: string, id: string, opts: { output: string }) => { + const resource = resolveResource(resourceArg); + const item = await deps.fetchResource(resource, id); + + if (opts.output === 'json') { + deps.log(formatJson(item)); + } else if (opts.output === 'yaml') { + deps.log(formatYaml(item)); + } else { + const typeName = resource.replace(/s$/, '').charAt(0).toUpperCase() + resource.replace(/s$/, '').slice(1); + deps.log(`--- ${typeName} ---`); + deps.log(formatDetail(item as Record)); + } + }); +} diff --git a/src/cli/src/commands/get.ts b/src/cli/src/commands/get.ts new file mode 100644 index 0000000..6cee2ce --- /dev/null +++ b/src/cli/src/commands/get.ts @@ -0,0 +1,122 @@ +import { Command } from 'commander'; +import { formatTable } from '../formatters/table.js'; +import { formatJson, formatYaml } from '../formatters/output.js'; +import type { Column } from '../formatters/table.js'; + +export interface GetCommandDeps { + fetchResource: (resource: string, id?: string) => Promise; + log: (...args: string[]) => void; +} + +interface ServerRow { + id: string; + name: string; + transport: string; + packageName: string | null; + dockerImage: string | null; +} + +interface ProfileRow { + id: string; + name: string; + serverId: string; +} + +interface ProjectRow { + id: string; + name: string; + description: string; + ownerId: string; +} + +interface InstanceRow { + id: string; + serverId: string; + status: string; + containerId: string | null; + port: number | null; +} + +const RESOURCE_ALIASES: Record = { + server: 'servers', + srv: 'servers', + profile: 'profiles', + prof: 'profiles', + project: 'projects', + proj: 'projects', + instance: 'instances', + inst: 'instances', +}; + +function resolveResource(name: string): string { + const lower = name.toLowerCase(); + return RESOURCE_ALIASES[lower] ?? lower; +} + +const serverColumns: Column[] = [ + { header: 'NAME', key: 'name' }, + { header: 'TRANSPORT', key: 'transport', width: 16 }, + { header: 'PACKAGE', key: (r) => r.packageName ?? '-' }, + { header: 'IMAGE', key: (r) => r.dockerImage ?? '-' }, + { header: 'ID', key: 'id' }, +]; + +const profileColumns: Column[] = [ + { header: 'NAME', key: 'name' }, + { header: 'SERVER ID', key: 'serverId' }, + { header: 'ID', key: 'id' }, +]; + +const projectColumns: Column[] = [ + { header: 'NAME', key: 'name' }, + { header: 'DESCRIPTION', key: 'description', width: 40 }, + { header: 'OWNER', key: 'ownerId' }, + { header: 'ID', key: 'id' }, +]; + +const instanceColumns: Column[] = [ + { header: 'STATUS', key: 'status', width: 10 }, + { header: 'SERVER ID', key: 'serverId' }, + { header: 'PORT', key: (r) => r.port != null ? String(r.port) : '-', width: 6 }, + { header: 'CONTAINER', key: (r) => r.containerId ? r.containerId.slice(0, 12) : '-', width: 14 }, + { header: 'ID', key: 'id' }, +]; + +function getColumnsForResource(resource: string): Column>[] { + switch (resource) { + case 'servers': + return serverColumns as unknown as Column>[]; + case 'profiles': + return profileColumns as unknown as Column>[]; + case 'projects': + return projectColumns as unknown as Column>[]; + case 'instances': + return instanceColumns as unknown as Column>[]; + default: + return [ + { header: 'ID', key: 'id' as keyof Record }, + { header: 'NAME', key: 'name' as keyof Record }, + ]; + } +} + +export function createGetCommand(deps: GetCommandDeps): Command { + return new Command('get') + .description('List resources (servers, profiles, projects, instances)') + .argument('', 'resource type (servers, profiles, projects, instances)') + .argument('[id]', 'specific resource ID') + .option('-o, --output ', 'output format (table, json, yaml)', 'table') + .action(async (resourceArg: string, id: string | undefined, opts: { output: string }) => { + const resource = resolveResource(resourceArg); + const items = await deps.fetchResource(resource, id); + + if (opts.output === 'json') { + deps.log(formatJson(items.length === 1 ? items[0] : items)); + } else if (opts.output === 'yaml') { + deps.log(formatYaml(items.length === 1 ? items[0] : items)); + } else { + const columns = getColumnsForResource(resource); + deps.log(formatTable(items as Record[], columns)); + } + }); +} diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index a8b2b4f..345a623 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -3,6 +3,10 @@ import { Command } from 'commander'; import { APP_NAME, APP_VERSION } from '@mcpctl/shared'; import { createConfigCommand } from './commands/config.js'; import { createStatusCommand } from './commands/status.js'; +import { createGetCommand } from './commands/get.js'; +import { createDescribeCommand } from './commands/describe.js'; +import { ApiClient } from './api-client.js'; +import { loadConfig } from './config/index.js'; export function createProgram(): Command { const program = new Command() @@ -15,6 +19,33 @@ export function createProgram(): Command { program.addCommand(createConfigCommand()); program.addCommand(createStatusCommand()); + // Create API-backed commands + const config = loadConfig(); + const daemonUrl = program.opts().daemonUrl ?? config.daemonUrl; + const client = new ApiClient({ baseUrl: daemonUrl }); + + const fetchResource = async (resource: string, id?: string): Promise => { + if (id) { + const item = await client.get(`/api/v1/${resource}/${id}`); + return [item]; + } + return client.get(`/api/v1/${resource}`); + }; + + const fetchSingleResource = async (resource: string, id: string): Promise => { + return client.get(`/api/v1/${resource}/${id}`); + }; + + program.addCommand(createGetCommand({ + fetchResource, + log: (...args) => console.log(...args), + })); + + program.addCommand(createDescribeCommand({ + fetchResource: fetchSingleResource, + log: (...args) => console.log(...args), + })); + return program; } diff --git a/src/cli/tests/api-client.test.ts b/src/cli/tests/api-client.test.ts new file mode 100644 index 0000000..ce7576d --- /dev/null +++ b/src/cli/tests/api-client.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import http from 'node:http'; +import { ApiClient, ApiError } from '../src/api-client.js'; + +let server: http.Server; +let port: number; + +beforeAll(async () => { + server = http.createServer((req, res) => { + if (req.url === '/api/v1/servers' && req.method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify([{ id: 'srv-1', name: 'slack' }])); + } else if (req.url === '/api/v1/servers/srv-1' && req.method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ id: 'srv-1', name: 'slack', transport: 'STDIO' })); + } else if (req.url === '/api/v1/servers' && req.method === 'POST') { + const chunks: Buffer[] = []; + req.on('data', (c: Buffer) => chunks.push(c)); + req.on('end', () => { + const body = JSON.parse(Buffer.concat(chunks).toString()); + res.writeHead(201, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ id: 'srv-new', ...body })); + }); + } else if (req.url === '/api/v1/missing' && req.method === 'GET') { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Not found' })); + } else { + res.writeHead(404); + res.end(); + } + }); + + await new Promise((resolve) => { + server.listen(0, () => { + const addr = server.address(); + if (addr && typeof addr === 'object') { + port = addr.port; + } + resolve(); + }); + }); +}); + +afterAll(() => { + server.close(); +}); + +describe('ApiClient', () => { + it('performs GET request for list', async () => { + const client = new ApiClient({ baseUrl: `http://localhost:${port}` }); + const result = await client.get>('/api/v1/servers'); + expect(result).toEqual([{ id: 'srv-1', name: 'slack' }]); + }); + + it('performs GET request for single item', async () => { + const client = new ApiClient({ baseUrl: `http://localhost:${port}` }); + const result = await client.get<{ id: string; name: string }>('/api/v1/servers/srv-1'); + expect(result.name).toBe('slack'); + }); + + it('performs POST request', async () => { + const client = new ApiClient({ baseUrl: `http://localhost:${port}` }); + const result = await client.post<{ id: string; name: string }>('/api/v1/servers', { name: 'github' }); + expect(result.id).toBe('srv-new'); + expect(result.name).toBe('github'); + }); + + it('throws ApiError on 404', async () => { + const client = new ApiClient({ baseUrl: `http://localhost:${port}` }); + await expect(client.get('/api/v1/missing')).rejects.toThrow(ApiError); + }); + + it('throws on connection error', async () => { + const client = new ApiClient({ baseUrl: 'http://localhost:1' }); + await expect(client.get('/anything')).rejects.toThrow(); + }); +}); diff --git a/src/cli/tests/commands/describe.test.ts b/src/cli/tests/commands/describe.test.ts new file mode 100644 index 0000000..5162a88 --- /dev/null +++ b/src/cli/tests/commands/describe.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createDescribeCommand } from '../../src/commands/describe.js'; +import type { DescribeCommandDeps } from '../../src/commands/describe.js'; + +function makeDeps(item: unknown = {}): DescribeCommandDeps & { output: string[] } { + const output: string[] = []; + return { + output, + fetchResource: vi.fn(async () => item), + log: (...args: string[]) => output.push(args.join(' ')), + }; +} + +describe('describe command', () => { + it('shows detailed server info', async () => { + const deps = makeDeps({ + id: 'srv-1', + name: 'slack', + transport: 'STDIO', + packageName: '@slack/mcp', + dockerImage: null, + envTemplate: [], + }); + const cmd = createDescribeCommand(deps); + await cmd.parseAsync(['node', 'test', 'server', 'srv-1']); + + expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1'); + const text = deps.output.join('\n'); + expect(text).toContain('--- Server ---'); + expect(text).toContain('name: slack'); + expect(text).toContain('transport: STDIO'); + expect(text).toContain('dockerImage: -'); + }); + + it('resolves resource aliases', async () => { + const deps = makeDeps({ id: 'p1' }); + const cmd = createDescribeCommand(deps); + await cmd.parseAsync(['node', 'test', 'prof', 'p1']); + expect(deps.fetchResource).toHaveBeenCalledWith('profiles', 'p1'); + }); + + it('outputs JSON format', async () => { + const deps = makeDeps({ id: 'srv-1', name: 'slack' }); + const cmd = createDescribeCommand(deps); + await cmd.parseAsync(['node', 'test', 'server', 'srv-1', '-o', 'json']); + + const parsed = JSON.parse(deps.output[0] ?? ''); + expect(parsed.name).toBe('slack'); + }); + + it('outputs YAML format', async () => { + const deps = makeDeps({ id: 'srv-1', name: 'slack' }); + const cmd = createDescribeCommand(deps); + await cmd.parseAsync(['node', 'test', 'server', 'srv-1', '-o', 'yaml']); + expect(deps.output[0]).toContain('name: slack'); + }); + + it('formats nested objects', async () => { + const deps = makeDeps({ + id: 'srv-1', + name: 'slack', + metadata: { version: '1.0', nested: { deep: true } }, + }); + const cmd = createDescribeCommand(deps); + await cmd.parseAsync(['node', 'test', 'server', 'srv-1']); + + const text = deps.output.join('\n'); + expect(text).toContain('metadata:'); + expect(text).toContain('version: 1.0'); + }); + + it('formats arrays correctly', async () => { + const deps = makeDeps({ + id: 'srv-1', + permissions: ['read', 'write'], + envTemplate: [], + }); + const cmd = createDescribeCommand(deps); + await cmd.parseAsync(['node', 'test', 'server', 'srv-1']); + + const text = deps.output.join('\n'); + expect(text).toContain('permissions: read, write'); + expect(text).toContain('envTemplate: []'); + }); +}); diff --git a/src/cli/tests/commands/get.test.ts b/src/cli/tests/commands/get.test.ts new file mode 100644 index 0000000..c02a997 --- /dev/null +++ b/src/cli/tests/commands/get.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createGetCommand } from '../../src/commands/get.js'; +import type { GetCommandDeps } from '../../src/commands/get.js'; + +function makeDeps(items: unknown[] = []): GetCommandDeps & { output: string[] } { + const output: string[] = []; + return { + output, + fetchResource: vi.fn(async () => items), + log: (...args: string[]) => output.push(args.join(' ')), + }; +} + +describe('get command', () => { + it('lists servers in table format', async () => { + const deps = makeDeps([ + { id: 'srv-1', name: 'slack', transport: 'STDIO', packageName: '@slack/mcp', dockerImage: null }, + { id: 'srv-2', name: 'github', transport: 'SSE', packageName: null, dockerImage: 'ghcr.io/github-mcp' }, + ]); + const cmd = createGetCommand(deps); + await cmd.parseAsync(['node', 'test', 'servers']); + + expect(deps.fetchResource).toHaveBeenCalledWith('servers', undefined); + expect(deps.output[0]).toContain('NAME'); + expect(deps.output[0]).toContain('TRANSPORT'); + expect(deps.output.join('\n')).toContain('slack'); + expect(deps.output.join('\n')).toContain('github'); + }); + + it('resolves resource aliases', async () => { + const deps = makeDeps([]); + const cmd = createGetCommand(deps); + await cmd.parseAsync(['node', 'test', 'srv']); + expect(deps.fetchResource).toHaveBeenCalledWith('servers', undefined); + }); + + it('passes ID when provided', async () => { + const deps = makeDeps([{ id: 'srv-1', name: 'slack' }]); + const cmd = createGetCommand(deps); + await cmd.parseAsync(['node', 'test', 'servers', 'srv-1']); + expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1'); + }); + + it('outputs JSON format', async () => { + const deps = makeDeps([{ id: 'srv-1', name: 'slack' }]); + const cmd = createGetCommand(deps); + await cmd.parseAsync(['node', 'test', 'servers', '-o', 'json']); + + const parsed = JSON.parse(deps.output[0] ?? ''); + expect(parsed).toEqual({ id: 'srv-1', name: 'slack' }); + }); + + it('outputs YAML format', async () => { + const deps = makeDeps([{ id: 'srv-1', name: 'slack' }]); + const cmd = createGetCommand(deps); + await cmd.parseAsync(['node', 'test', 'servers', '-o', 'yaml']); + expect(deps.output[0]).toContain('name: slack'); + }); + + it('lists profiles with correct columns', async () => { + const deps = makeDeps([ + { id: 'p1', name: 'default', serverId: 'srv-1' }, + ]); + const cmd = createGetCommand(deps); + await cmd.parseAsync(['node', 'test', 'profiles']); + expect(deps.output[0]).toContain('NAME'); + expect(deps.output[0]).toContain('SERVER ID'); + }); + + it('lists instances with correct columns', async () => { + const deps = makeDeps([ + { id: 'inst-1', serverId: 'srv-1', status: 'RUNNING', containerId: 'abc123def456', port: 3000 }, + ]); + const cmd = createGetCommand(deps); + await cmd.parseAsync(['node', 'test', 'instances']); + expect(deps.output[0]).toContain('STATUS'); + expect(deps.output.join('\n')).toContain('RUNNING'); + }); + + it('shows no results message for empty list', async () => { + const deps = makeDeps([]); + const cmd = createGetCommand(deps); + await cmd.parseAsync(['node', 'test', 'servers']); + expect(deps.output[0]).toContain('No results'); + }); +});