diff --git a/src/cli/src/commands/claude.ts b/src/cli/src/commands/claude.ts new file mode 100644 index 0000000..6756037 --- /dev/null +++ b/src/cli/src/commands/claude.ts @@ -0,0 +1,155 @@ +import { Command } from 'commander'; +import { writeFileSync, readFileSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import type { ApiClient } from '../api-client.js'; + +interface McpConfig { + mcpServers: Record }>; +} + +export interface ClaudeCommandDeps { + client: ApiClient; + log: (...args: unknown[]) => void; +} + +export function createClaudeCommand(deps: ClaudeCommandDeps): Command { + const { client, log } = deps; + + const cmd = new Command('claude') + .description('Manage Claude MCP configuration (.mcp.json)'); + + cmd + .command('generate ') + .description('Generate .mcp.json from a project configuration') + .option('-o, --output ', 'Output file path', '.mcp.json') + .option('--merge', 'Merge with existing .mcp.json instead of overwriting') + .option('--stdout', 'Print to stdout instead of writing a file') + .action(async (projectId: string, opts: { output: string; merge?: boolean; stdout?: boolean }) => { + const config = await client.get(`/api/v1/projects/${projectId}/mcp-config`); + + if (opts.stdout) { + log(JSON.stringify(config, null, 2)); + return; + } + + const outputPath = resolve(opts.output); + let finalConfig = config; + + if (opts.merge && existsSync(outputPath)) { + try { + const existing = JSON.parse(readFileSync(outputPath, 'utf-8')) as McpConfig; + finalConfig = { + mcpServers: { + ...existing.mcpServers, + ...config.mcpServers, + }, + }; + } catch { + // If existing file is invalid, just overwrite + } + } + + writeFileSync(outputPath, JSON.stringify(finalConfig, null, 2) + '\n'); + const serverCount = Object.keys(finalConfig.mcpServers).length; + log(`Wrote ${outputPath} (${serverCount} server(s))`); + }); + + cmd + .command('show') + .description('Show current .mcp.json configuration') + .option('-p, --path ', 'Path to .mcp.json', '.mcp.json') + .action((opts: { path: string }) => { + const filePath = resolve(opts.path); + if (!existsSync(filePath)) { + log(`No .mcp.json found at ${filePath}`); + return; + } + const content = readFileSync(filePath, 'utf-8'); + try { + const config = JSON.parse(content) as McpConfig; + const servers = Object.entries(config.mcpServers ?? {}); + if (servers.length === 0) { + log('No MCP servers configured.'); + return; + } + log(`MCP servers in ${filePath}:\n`); + for (const [name, server] of servers) { + log(` ${name}`); + log(` command: ${server.command} ${server.args.join(' ')}`); + if (server.env) { + const envKeys = Object.keys(server.env); + log(` env: ${envKeys.join(', ')}`); + } + } + } catch { + log(`Invalid JSON in ${filePath}`); + } + }); + + cmd + .command('add ') + .description('Add an MCP server entry to .mcp.json') + .requiredOption('-c, --command ', 'Command to run') + .option('-a, --args ', 'Command arguments') + .option('-e, --env ', 'Environment variables') + .option('-p, --path ', 'Path to .mcp.json', '.mcp.json') + .action((name: string, opts: { command: string; args?: string[]; env?: string[]; path: string }) => { + const filePath = resolve(opts.path); + let config: McpConfig = { mcpServers: {} }; + + if (existsSync(filePath)) { + try { + config = JSON.parse(readFileSync(filePath, 'utf-8')) as McpConfig; + } catch { + // Start fresh + } + } + + const entry: { command: string; args: string[]; env?: Record } = { + command: opts.command, + args: opts.args ?? [], + }; + + if (opts.env && opts.env.length > 0) { + const env: Record = {}; + for (const pair of opts.env) { + const eqIdx = pair.indexOf('='); + if (eqIdx > 0) { + env[pair.slice(0, eqIdx)] = pair.slice(eqIdx + 1); + } + } + entry.env = env; + } + + config.mcpServers[name] = entry; + writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n'); + log(`Added '${name}' to ${filePath}`); + }); + + cmd + .command('remove ') + .description('Remove an MCP server entry from .mcp.json') + .option('-p, --path ', 'Path to .mcp.json', '.mcp.json') + .action((name: string, opts: { path: string }) => { + const filePath = resolve(opts.path); + if (!existsSync(filePath)) { + log(`No .mcp.json found at ${filePath}`); + return; + } + + try { + const config = JSON.parse(readFileSync(filePath, 'utf-8')) as McpConfig; + if (!(name in config.mcpServers)) { + log(`Server '${name}' not found in ${filePath}`); + return; + } + delete config.mcpServers[name]; + writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n'); + log(`Removed '${name}' from ${filePath}`); + } catch { + log(`Invalid JSON in ${filePath}`); + } + }); + + return cmd; +} diff --git a/src/cli/src/commands/project.ts b/src/cli/src/commands/project.ts new file mode 100644 index 0000000..275a111 --- /dev/null +++ b/src/cli/src/commands/project.ts @@ -0,0 +1,129 @@ +import { Command } from 'commander'; +import type { ApiClient } from '../api-client.js'; + +interface Project { + id: string; + name: string; + description: string; + ownerId: string; + createdAt: string; +} + +interface Profile { + id: string; + name: string; + serverId: string; +} + +export interface ProjectCommandDeps { + client: ApiClient; + log: (...args: unknown[]) => void; +} + +export function createProjectCommand(deps: ProjectCommandDeps): Command { + const { client, log } = deps; + + const cmd = new Command('project') + .alias('projects') + .alias('proj') + .description('Manage mcpctl projects'); + + cmd + .command('list') + .alias('ls') + .description('List all projects') + .option('-o, --output ', 'Output format (table, json)', 'table') + .action(async (opts: { output: string }) => { + const projects = await client.get('/api/v1/projects'); + if (opts.output === 'json') { + log(JSON.stringify(projects, null, 2)); + return; + } + if (projects.length === 0) { + log('No projects found.'); + return; + } + log('ID\tNAME\tDESCRIPTION'); + for (const p of projects) { + log(`${p.id}\t${p.name}\t${p.description || '-'}`); + } + }); + + cmd + .command('create ') + .description('Create a new project') + .option('-d, --description ', 'Project description', '') + .action(async (name: string, opts: { description: string }) => { + const project = await client.post('/api/v1/projects', { + name, + description: opts.description, + }); + log(`Project '${project.name}' created (id: ${project.id})`); + }); + + cmd + .command('delete ') + .alias('rm') + .description('Delete a project') + .action(async (id: string) => { + await client.delete(`/api/v1/projects/${id}`); + log(`Project '${id}' deleted.`); + }); + + cmd + .command('show ') + .description('Show project details') + .action(async (id: string) => { + const project = await client.get(`/api/v1/projects/${id}`); + log(`Name: ${project.name}`); + log(`ID: ${project.id}`); + log(`Description: ${project.description || '-'}`); + log(`Owner: ${project.ownerId}`); + log(`Created: ${project.createdAt}`); + + try { + const profiles = await client.get(`/api/v1/projects/${id}/profiles`); + if (profiles.length > 0) { + log('\nProfiles:'); + for (const p of profiles) { + log(` - ${p.name} (id: ${p.id})`); + } + } else { + log('\nNo profiles assigned.'); + } + } catch { + // Profiles endpoint may not be available + } + }); + + cmd + .command('profiles ') + .description('List profiles assigned to a project') + .option('-o, --output ', 'Output format (table, json)', 'table') + .action(async (id: string, opts: { output: string }) => { + const profiles = await client.get(`/api/v1/projects/${id}/profiles`); + if (opts.output === 'json') { + log(JSON.stringify(profiles, null, 2)); + return; + } + if (profiles.length === 0) { + log('No profiles assigned.'); + return; + } + log('ID\tNAME\tSERVER'); + for (const p of profiles) { + log(`${p.id}\t${p.name}\t${p.serverId}`); + } + }); + + cmd + .command('set-profiles ') + .description('Set the profiles assigned to a project') + .argument('', 'Profile IDs to assign') + .action(async (id: string, profileIds: string[]) => { + await client.put(`/api/v1/projects/${id}/profiles`, { profileIds }); + log(`Set ${profileIds.length} profile(s) for project '${id}'.`); + }); + + return cmd; +} diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index af3d6d5..16b1f01 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -8,6 +8,8 @@ import { createDescribeCommand } from './commands/describe.js'; import { createInstanceCommands } from './commands/instances.js'; import { createApplyCommand } from './commands/apply.js'; import { createSetupCommand } from './commands/setup.js'; +import { createClaudeCommand } from './commands/claude.js'; +import { createProjectCommand } from './commands/project.js'; import { ApiClient } from './api-client.js'; import { loadConfig } from './config/index.js'; @@ -86,6 +88,16 @@ export function createProgram(): Command { log: (...args) => console.log(...args), })); + program.addCommand(createClaudeCommand({ + client, + log: (...args) => console.log(...args), + })); + + program.addCommand(createProjectCommand({ + client, + log: (...args) => console.log(...args), + })); + return program; } diff --git a/src/cli/tests/commands/claude.test.ts b/src/cli/tests/commands/claude.test.ts new file mode 100644 index 0000000..59b5a59 --- /dev/null +++ b/src/cli/tests/commands/claude.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { writeFileSync, readFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createClaudeCommand } from '../../src/commands/claude.js'; +import type { ApiClient } from '../../src/api-client.js'; + +function mockClient(): ApiClient { + return { + get: vi.fn(async () => ({ + mcpServers: { + 'slack--default': { command: 'npx', args: ['-y', '@anthropic/slack-mcp'], env: { WORKSPACE: 'test' } }, + 'github--default': { command: 'npx', args: ['-y', '@anthropic/github-mcp'] }, + }, + })), + post: vi.fn(async () => ({})), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; +} + +describe('claude command', () => { + let client: ReturnType; + let output: string[]; + let tmpDir: string; + const log = (...args: unknown[]) => output.push(args.map(String).join(' ')); + + beforeEach(() => { + client = mockClient(); + output = []; + tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-claude-')); + }); + + describe('generate', () => { + it('generates .mcp.json from project config', async () => { + const outPath = join(tmpDir, '.mcp.json'); + const cmd = createClaudeCommand({ client, log }); + await cmd.parseAsync(['generate', 'proj-1', '-o', outPath], { from: 'user' }); + + expect(client.get).toHaveBeenCalledWith('/api/v1/projects/proj-1/mcp-config'); + const written = JSON.parse(readFileSync(outPath, 'utf-8')); + expect(written.mcpServers['slack--default']).toBeDefined(); + expect(output.join('\n')).toContain('2 server(s)'); + + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('prints to stdout with --stdout', async () => { + const cmd = createClaudeCommand({ client, log }); + await cmd.parseAsync(['generate', 'proj-1', '--stdout'], { from: 'user' }); + + expect(output[0]).toContain('mcpServers'); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('merges with existing .mcp.json', async () => { + const outPath = join(tmpDir, '.mcp.json'); + writeFileSync(outPath, JSON.stringify({ + mcpServers: { 'existing--server': { command: 'echo', args: [] } }, + })); + + const cmd = createClaudeCommand({ client, log }); + await cmd.parseAsync(['generate', 'proj-1', '-o', outPath, '--merge'], { from: 'user' }); + + const written = JSON.parse(readFileSync(outPath, 'utf-8')); + expect(written.mcpServers['existing--server']).toBeDefined(); + expect(written.mcpServers['slack--default']).toBeDefined(); + expect(output.join('\n')).toContain('3 server(s)'); + + rmSync(tmpDir, { recursive: true, force: true }); + }); + }); + + describe('show', () => { + it('shows servers in .mcp.json', () => { + const filePath = join(tmpDir, '.mcp.json'); + writeFileSync(filePath, JSON.stringify({ + mcpServers: { + 'slack': { command: 'npx', args: ['-y', '@anthropic/slack-mcp'], env: { TOKEN: 'x' } }, + }, + })); + + const cmd = createClaudeCommand({ client, log }); + cmd.parseAsync(['show', '-p', filePath], { from: 'user' }); + + expect(output.join('\n')).toContain('slack'); + expect(output.join('\n')).toContain('npx -y @anthropic/slack-mcp'); + expect(output.join('\n')).toContain('TOKEN'); + + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('handles missing file', () => { + const cmd = createClaudeCommand({ client, log }); + cmd.parseAsync(['show', '-p', join(tmpDir, 'nonexistent.json')], { from: 'user' }); + + expect(output.join('\n')).toContain('No .mcp.json found'); + rmSync(tmpDir, { recursive: true, force: true }); + }); + }); + + describe('add', () => { + it('adds a server entry', () => { + const filePath = join(tmpDir, '.mcp.json'); + const cmd = createClaudeCommand({ client, log }); + cmd.parseAsync(['add', 'my-server', '-c', 'npx', '-a', '-y', 'my-pkg', '-p', filePath], { from: 'user' }); + + const written = JSON.parse(readFileSync(filePath, 'utf-8')); + expect(written.mcpServers['my-server']).toEqual({ + command: 'npx', + args: ['-y', 'my-pkg'], + }); + + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('adds server with env vars', () => { + const filePath = join(tmpDir, '.mcp.json'); + const cmd = createClaudeCommand({ client, log }); + cmd.parseAsync(['add', 'my-server', '-c', 'node', '-e', 'KEY=val', 'SECRET=abc', '-p', filePath], { from: 'user' }); + + const written = JSON.parse(readFileSync(filePath, 'utf-8')); + expect(written.mcpServers['my-server'].env).toEqual({ KEY: 'val', SECRET: 'abc' }); + + rmSync(tmpDir, { recursive: true, force: true }); + }); + }); + + describe('remove', () => { + it('removes a server entry', () => { + const filePath = join(tmpDir, '.mcp.json'); + writeFileSync(filePath, JSON.stringify({ + mcpServers: { 'slack': { command: 'npx', args: [] }, 'github': { command: 'npx', args: [] } }, + })); + + const cmd = createClaudeCommand({ client, log }); + cmd.parseAsync(['remove', 'slack', '-p', filePath], { from: 'user' }); + + const written = JSON.parse(readFileSync(filePath, 'utf-8')); + expect(written.mcpServers['slack']).toBeUndefined(); + expect(written.mcpServers['github']).toBeDefined(); + expect(output.join('\n')).toContain("Removed 'slack'"); + + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('reports when server not found', () => { + const filePath = join(tmpDir, '.mcp.json'); + writeFileSync(filePath, JSON.stringify({ mcpServers: {} })); + + const cmd = createClaudeCommand({ client, log }); + cmd.parseAsync(['remove', 'nonexistent', '-p', filePath], { from: 'user' }); + + expect(output.join('\n')).toContain('not found'); + rmSync(tmpDir, { recursive: true, force: true }); + }); + }); +}); diff --git a/src/cli/tests/commands/project.test.ts b/src/cli/tests/commands/project.test.ts new file mode 100644 index 0000000..bcc93c0 --- /dev/null +++ b/src/cli/tests/commands/project.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createProjectCommand } from '../../src/commands/project.js'; +import type { ApiClient } from '../../src/api-client.js'; + +function mockClient(): ApiClient { + return { + get: vi.fn(async () => []), + post: vi.fn(async () => ({ id: 'proj-1', name: 'my-project' })), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; +} + +describe('project command', () => { + let client: ReturnType; + let output: string[]; + const log = (...args: unknown[]) => output.push(args.map(String).join(' ')); + + beforeEach(() => { + client = mockClient(); + output = []; + }); + + describe('list', () => { + it('shows no projects message when empty', async () => { + const cmd = createProjectCommand({ client, log }); + await cmd.parseAsync(['list'], { from: 'user' }); + expect(output.join('\n')).toContain('No projects found'); + }); + + it('shows project table', async () => { + vi.mocked(client.get).mockResolvedValue([ + { id: 'proj-1', name: 'dev', description: 'Dev project', ownerId: 'user-1', createdAt: '2025-01-01' }, + ]); + const cmd = createProjectCommand({ client, log }); + await cmd.parseAsync(['list'], { from: 'user' }); + expect(output.join('\n')).toContain('proj-1'); + expect(output.join('\n')).toContain('dev'); + }); + + it('outputs json', async () => { + vi.mocked(client.get).mockResolvedValue([{ id: 'proj-1', name: 'dev' }]); + const cmd = createProjectCommand({ client, log }); + await cmd.parseAsync(['list', '-o', 'json'], { from: 'user' }); + expect(output[0]).toContain('"id"'); + }); + }); + + describe('create', () => { + it('creates a project', async () => { + const cmd = createProjectCommand({ client, log }); + await cmd.parseAsync(['create', 'my-project', '-d', 'A test project'], { from: 'user' }); + expect(client.post).toHaveBeenCalledWith('/api/v1/projects', { + name: 'my-project', + description: 'A test project', + }); + expect(output.join('\n')).toContain("Project 'my-project' created"); + }); + }); + + describe('delete', () => { + it('deletes a project', async () => { + const cmd = createProjectCommand({ client, log }); + await cmd.parseAsync(['delete', 'proj-1'], { from: 'user' }); + expect(client.delete).toHaveBeenCalledWith('/api/v1/projects/proj-1'); + expect(output.join('\n')).toContain('deleted'); + }); + }); + + describe('show', () => { + it('shows project details', async () => { + vi.mocked(client.get).mockImplementation(async (url: string) => { + if (url.endsWith('/profiles')) return []; + return { id: 'proj-1', name: 'dev', description: 'Dev project', ownerId: 'user-1', createdAt: '2025-01-01' }; + }); + const cmd = createProjectCommand({ client, log }); + await cmd.parseAsync(['show', 'proj-1'], { from: 'user' }); + expect(output.join('\n')).toContain('Name: dev'); + expect(output.join('\n')).toContain('ID: proj-1'); + }); + }); + + describe('profiles', () => { + it('lists profiles for a project', async () => { + vi.mocked(client.get).mockResolvedValue([ + { id: 'prof-1', name: 'default', serverId: 'srv-1' }, + ]); + const cmd = createProjectCommand({ client, log }); + await cmd.parseAsync(['profiles', 'proj-1'], { from: 'user' }); + expect(client.get).toHaveBeenCalledWith('/api/v1/projects/proj-1/profiles'); + expect(output.join('\n')).toContain('default'); + }); + + it('shows empty message when no profiles', async () => { + const cmd = createProjectCommand({ client, log }); + await cmd.parseAsync(['profiles', 'proj-1'], { from: 'user' }); + expect(output.join('\n')).toContain('No profiles assigned'); + }); + }); + + describe('set-profiles', () => { + it('sets profiles for a project', async () => { + const cmd = createProjectCommand({ client, log }); + await cmd.parseAsync(['set-profiles', 'proj-1', 'prof-1', 'prof-2'], { from: 'user' }); + expect(client.put).toHaveBeenCalledWith('/api/v1/projects/proj-1/profiles', { + profileIds: ['prof-1', 'prof-2'], + }); + expect(output.join('\n')).toContain('2 profile(s)'); + }); + }); +});