feat: add claude and project CLI commands
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions

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:
Michal
2026-02-21 05:16:57 +00:00
parent 9c08faa8d2
commit a520b9ff47
5 changed files with 565 additions and 0 deletions

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

View File

@@ -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<typeof mockClient>;
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)');
});
});
});