feat: add claude and project CLI commands
Claude command manages .mcp.json files (generate from project, show, add, remove entries). Project command provides CRUD for projects with profile assignment management. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
158
src/cli/tests/commands/claude.test.ts
Normal file
158
src/cli/tests/commands/claude.test.ts
Normal file
@@ -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<typeof mockClient>;
|
||||
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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user