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:
155
src/cli/src/commands/claude.ts
Normal file
155
src/cli/src/commands/claude.ts
Normal file
@@ -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<string, { command: string; args: string[]; env?: Record<string, string> }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <projectId>')
|
||||||
|
.description('Generate .mcp.json from a project configuration')
|
||||||
|
.option('-o, --output <path>', '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<McpConfig>(`/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>', '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 <name>')
|
||||||
|
.description('Add an MCP server entry to .mcp.json')
|
||||||
|
.requiredOption('-c, --command <cmd>', 'Command to run')
|
||||||
|
.option('-a, --args <args...>', 'Command arguments')
|
||||||
|
.option('-e, --env <key=value...>', 'Environment variables')
|
||||||
|
.option('-p, --path <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<string, string> } = {
|
||||||
|
command: opts.command,
|
||||||
|
args: opts.args ?? [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (opts.env && opts.env.length > 0) {
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
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 <name>')
|
||||||
|
.description('Remove an MCP server entry from .mcp.json')
|
||||||
|
.option('-p, --path <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;
|
||||||
|
}
|
||||||
129
src/cli/src/commands/project.ts
Normal file
129
src/cli/src/commands/project.ts
Normal file
@@ -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 <format>', 'Output format (table, json)', 'table')
|
||||||
|
.action(async (opts: { output: string }) => {
|
||||||
|
const projects = await client.get<Project[]>('/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 <name>')
|
||||||
|
.description('Create a new project')
|
||||||
|
.option('-d, --description <text>', 'Project description', '')
|
||||||
|
.action(async (name: string, opts: { description: string }) => {
|
||||||
|
const project = await client.post<Project>('/api/v1/projects', {
|
||||||
|
name,
|
||||||
|
description: opts.description,
|
||||||
|
});
|
||||||
|
log(`Project '${project.name}' created (id: ${project.id})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('delete <id>')
|
||||||
|
.alias('rm')
|
||||||
|
.description('Delete a project')
|
||||||
|
.action(async (id: string) => {
|
||||||
|
await client.delete(`/api/v1/projects/${id}`);
|
||||||
|
log(`Project '${id}' deleted.`);
|
||||||
|
});
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('show <id>')
|
||||||
|
.description('Show project details')
|
||||||
|
.action(async (id: string) => {
|
||||||
|
const project = await client.get<Project>(`/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<Profile[]>(`/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 <id>')
|
||||||
|
.description('List profiles assigned to a project')
|
||||||
|
.option('-o, --output <format>', 'Output format (table, json)', 'table')
|
||||||
|
.action(async (id: string, opts: { output: string }) => {
|
||||||
|
const profiles = await client.get<Profile[]>(`/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 <id>')
|
||||||
|
.description('Set the profiles assigned to a project')
|
||||||
|
.argument('<profileIds...>', '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;
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ import { createDescribeCommand } from './commands/describe.js';
|
|||||||
import { createInstanceCommands } from './commands/instances.js';
|
import { createInstanceCommands } from './commands/instances.js';
|
||||||
import { createApplyCommand } from './commands/apply.js';
|
import { createApplyCommand } from './commands/apply.js';
|
||||||
import { createSetupCommand } from './commands/setup.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 { ApiClient } from './api-client.js';
|
||||||
import { loadConfig } from './config/index.js';
|
import { loadConfig } from './config/index.js';
|
||||||
|
|
||||||
@@ -86,6 +88,16 @@ export function createProgram(): Command {
|
|||||||
log: (...args) => console.log(...args),
|
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;
|
return program;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
111
src/cli/tests/commands/project.test.ts
Normal file
111
src/cli/tests/commands/project.test.ts
Normal 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)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user