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 }); }); }); });