feat: create/edit commands, apply-compatible output, better describe #6

Merged
michal merged 2 commits from feat/create-edit-commands into main 2026-02-22 16:40:36 +00:00
14 changed files with 921 additions and 144 deletions

View File

@@ -0,0 +1,114 @@
import { Command } from 'commander';
import type { ApiClient } from '../api-client.js';
import { resolveNameOrId } from './shared.js';
export interface CreateCommandDeps {
client: ApiClient;
log: (...args: unknown[]) => void;
}
function collect(value: string, prev: string[]): string[] {
return [...prev, value];
}
function parseEnvTemplate(entries: string[]): Array<{ name: string; description: string; isSecret: boolean }> {
return entries.map((entry) => {
const parts = entry.split(':');
if (parts.length < 2) {
throw new Error(`Invalid env-template format '${entry}'. Expected NAME:description[:isSecret]`);
}
return {
name: parts[0]!,
description: parts[1]!,
isSecret: parts[2] === 'true',
};
});
}
function parseEnvEntries(entries: string[]): Record<string, string> {
const result: Record<string, string> = {};
for (const entry of entries) {
const eqIdx = entry.indexOf('=');
if (eqIdx === -1) {
throw new Error(`Invalid env format '${entry}'. Expected KEY=value`);
}
result[entry.slice(0, eqIdx)] = entry.slice(eqIdx + 1);
}
return result;
}
export function createCreateCommand(deps: CreateCommandDeps): Command {
const { client, log } = deps;
const cmd = new Command('create')
.description('Create a resource (server, profile, project)');
// --- create server ---
cmd.command('server')
.description('Create an MCP server definition')
.argument('<name>', 'Server name (lowercase, hyphens allowed)')
.option('-d, --description <text>', 'Server description', '')
.option('--package-name <name>', 'NPM package name')
.option('--docker-image <image>', 'Docker image')
.option('--transport <type>', 'Transport type (STDIO, SSE, STREAMABLE_HTTP)', 'STDIO')
.option('--repository-url <url>', 'Source repository URL')
.option('--external-url <url>', 'External endpoint URL')
.option('--command <arg>', 'Command argument (repeat for multiple)', collect, [])
.option('--container-port <port>', 'Container port number')
.option('--replicas <count>', 'Number of replicas', '1')
.option('--env-template <entry>', 'Env template (NAME:description[:isSecret], repeat for multiple)', collect, [])
.action(async (name: string, opts) => {
const body: Record<string, unknown> = {
name,
description: opts.description,
transport: opts.transport,
replicas: parseInt(opts.replicas, 10),
};
if (opts.packageName) body.packageName = opts.packageName;
if (opts.dockerImage) body.dockerImage = opts.dockerImage;
if (opts.repositoryUrl) body.repositoryUrl = opts.repositoryUrl;
if (opts.externalUrl) body.externalUrl = opts.externalUrl;
if (opts.command.length > 0) body.command = opts.command;
if (opts.containerPort) body.containerPort = parseInt(opts.containerPort, 10);
if (opts.envTemplate.length > 0) body.envTemplate = parseEnvTemplate(opts.envTemplate);
const server = await client.post<{ id: string; name: string }>('/api/v1/servers', body);
log(`server '${server.name}' created (id: ${server.id})`);
});
// --- create profile ---
cmd.command('profile')
.description('Create a profile for an MCP server')
.argument('<name>', 'Profile name')
.requiredOption('--server <name-or-id>', 'Server name or ID')
.option('--permissions <perm>', 'Permission (repeat for multiple)', collect, [])
.option('--env <entry>', 'Environment override KEY=value (repeat for multiple)', collect, [])
.action(async (name: string, opts) => {
const serverId = await resolveNameOrId(client, 'servers', opts.server);
const body: Record<string, unknown> = {
name,
serverId,
};
if (opts.permissions.length > 0) body.permissions = opts.permissions;
if (opts.env.length > 0) body.envOverrides = parseEnvEntries(opts.env);
const profile = await client.post<{ id: string; name: string }>('/api/v1/profiles', body);
log(`profile '${profile.name}' created (id: ${profile.id})`);
});
// --- create project ---
cmd.command('project')
.description('Create a project')
.argument('<name>', 'Project name')
.option('-d, --description <text>', 'Project description', '')
.action(async (name: string, opts) => {
const project = await client.post<{ id: string; name: string }>('/api/v1/projects', {
name,
description: opts.description,
});
log(`project '${project.name}' created (id: ${project.id})`);
});
return cmd;
}

View File

@@ -1,21 +1,6 @@
import { Command } from 'commander'; import { Command } from 'commander';
import type { ApiClient } from '../api-client.js'; import type { ApiClient } from '../api-client.js';
import { resolveResource, resolveNameOrId } from './shared.js';
const RESOURCE_ALIASES: Record<string, string> = {
server: 'servers',
srv: 'servers',
profile: 'profiles',
prof: 'profiles',
project: 'projects',
proj: 'projects',
instance: 'instances',
inst: 'instances',
};
function resolveResource(name: string): string {
const lower = name.toLowerCase();
return RESOURCE_ALIASES[lower] ?? lower;
}
export interface DeleteCommandDeps { export interface DeleteCommandDeps {
client: ApiClient; client: ApiClient;
@@ -32,18 +17,12 @@ export function createDeleteCommand(deps: DeleteCommandDeps): Command {
.action(async (resourceArg: string, idOrName: string) => { .action(async (resourceArg: string, idOrName: string) => {
const resource = resolveResource(resourceArg); const resource = resolveResource(resourceArg);
// Try to resolve name → ID for servers // Resolve name → ID for any resource type
let id = idOrName; let id: string;
if (resource === 'servers' && !idOrName.match(/^c[a-z0-9]{24}/)) { try {
try { id = await resolveNameOrId(client, resource, idOrName);
const servers = await client.get<Array<{ id: string; name: string }>>(`/api/v1/${resource}`); } catch {
const match = servers.find((s) => s.name === idOrName); id = idOrName; // Fall through with original
if (match) {
id = match.id;
}
} catch {
// Fall through with original id
}
} }
await client.delete(`/api/v1/${resource}/${id}`); await client.delete(`/api/v1/${resource}/${id}`);

View File

@@ -1,54 +1,166 @@
import { Command } from 'commander'; import { Command } from 'commander';
import { formatJson, formatYaml } from '../formatters/output.js'; import { formatJson, formatYaml } from '../formatters/output.js';
import { resolveResource, resolveNameOrId } from './shared.js';
import type { ApiClient } from '../api-client.js';
export interface DescribeCommandDeps { export interface DescribeCommandDeps {
client: ApiClient;
fetchResource: (resource: string, id: string) => Promise<unknown>; fetchResource: (resource: string, id: string) => Promise<unknown>;
fetchInspect?: (id: string) => Promise<unknown>; fetchInspect?: (id: string) => Promise<unknown>;
log: (...args: string[]) => void; log: (...args: string[]) => void;
} }
const RESOURCE_ALIASES: Record<string, string> = { function pad(label: string, width = 18): string {
server: 'servers', return label.padEnd(width);
srv: 'servers',
profile: 'profiles',
prof: 'profiles',
project: 'projects',
proj: 'projects',
instance: 'instances',
inst: 'instances',
};
function resolveResource(name: string): string {
const lower = name.toLowerCase();
return RESOURCE_ALIASES[lower] ?? lower;
} }
function formatDetail(obj: Record<string, unknown>, indent = 0): string { function formatServerDetail(server: Record<string, unknown>): string {
const pad = ' '.repeat(indent);
const lines: string[] = []; const lines: string[] = [];
lines.push(`=== Server: ${server.name} ===`);
lines.push(`${pad('Name:')}${server.name}`);
lines.push(`${pad('Transport:')}${server.transport ?? '-'}`);
lines.push(`${pad('Replicas:')}${server.replicas ?? 1}`);
if (server.dockerImage) lines.push(`${pad('Docker Image:')}${server.dockerImage}`);
if (server.packageName) lines.push(`${pad('Package:')}${server.packageName}`);
if (server.externalUrl) lines.push(`${pad('External URL:')}${server.externalUrl}`);
if (server.repositoryUrl) lines.push(`${pad('Repository:')}${server.repositoryUrl}`);
if (server.containerPort) lines.push(`${pad('Container Port:')}${server.containerPort}`);
if (server.description) lines.push(`${pad('Description:')}${server.description}`);
for (const [key, value] of Object.entries(obj)) { const command = server.command as string[] | null;
if (value === null || value === undefined) { if (command && command.length > 0) {
lines.push(`${pad}${key}: -`); lines.push('');
} else if (Array.isArray(value)) { lines.push('Command:');
if (value.length === 0) { lines.push(` ${command.join(' ')}`);
lines.push(`${pad}${key}: []`); }
} else if (typeof value[0] === 'object') {
lines.push(`${pad}${key}:`); const envTemplate = server.envTemplate as Array<{ name: string; description: string; isSecret?: boolean }> | undefined;
for (const item of value) { if (envTemplate && envTemplate.length > 0) {
lines.push(`${pad} - ${JSON.stringify(item)}`); lines.push('');
} lines.push('Environment Template:');
} else { const nameW = Math.max(6, ...envTemplate.map((e) => e.name.length)) + 2;
lines.push(`${pad}${key}: ${value.join(', ')}`); const descW = Math.max(12, ...envTemplate.map((e) => e.description.length)) + 2;
} lines.push(` ${'NAME'.padEnd(nameW)}${'DESCRIPTION'.padEnd(descW)}SECRET`);
} else if (typeof value === 'object') { for (const env of envTemplate) {
lines.push(`${pad}${key}:`); lines.push(` ${env.name.padEnd(nameW)}${env.description.padEnd(descW)}${env.isSecret ? 'yes' : 'no'}`);
lines.push(formatDetail(value as Record<string, unknown>, indent + 1));
} else {
lines.push(`${pad}${key}: ${String(value)}`);
} }
} }
lines.push('');
lines.push('Metadata:');
lines.push(` ${pad('ID:', 12)}${server.id}`);
if (server.createdAt) lines.push(` ${pad('Created:', 12)}${server.createdAt}`);
if (server.updatedAt) lines.push(` ${pad('Updated:', 12)}${server.updatedAt}`);
return lines.join('\n');
}
function formatInstanceDetail(instance: Record<string, unknown>, inspect?: Record<string, unknown>): string {
const lines: string[] = [];
lines.push(`=== Instance: ${instance.id} ===`);
lines.push(`${pad('Status:')}${instance.status}`);
lines.push(`${pad('Server ID:')}${instance.serverId}`);
lines.push(`${pad('Container ID:')}${instance.containerId ?? '-'}`);
lines.push(`${pad('Port:')}${instance.port ?? '-'}`);
const metadata = instance.metadata as Record<string, unknown> | undefined;
if (metadata && Object.keys(metadata).length > 0) {
lines.push('');
lines.push('Metadata:');
for (const [key, value] of Object.entries(metadata)) {
lines.push(` ${pad(key + ':', 16)}${String(value)}`);
}
}
if (inspect) {
lines.push('');
lines.push('Container:');
for (const [key, value] of Object.entries(inspect)) {
if (typeof value === 'object' && value !== null) {
lines.push(` ${key}: ${JSON.stringify(value)}`);
} else {
lines.push(` ${pad(key + ':', 16)}${String(value)}`);
}
}
}
lines.push('');
lines.push(` ${pad('ID:', 12)}${instance.id}`);
if (instance.createdAt) lines.push(` ${pad('Created:', 12)}${instance.createdAt}`);
if (instance.updatedAt) lines.push(` ${pad('Updated:', 12)}${instance.updatedAt}`);
return lines.join('\n');
}
function formatProfileDetail(profile: Record<string, unknown>): string {
const lines: string[] = [];
lines.push(`=== Profile: ${profile.name} ===`);
lines.push(`${pad('Name:')}${profile.name}`);
lines.push(`${pad('Server ID:')}${profile.serverId}`);
const permissions = profile.permissions as string[] | undefined;
if (permissions && permissions.length > 0) {
lines.push(`${pad('Permissions:')}${permissions.join(', ')}`);
}
const envOverrides = profile.envOverrides as Record<string, string> | undefined;
if (envOverrides && Object.keys(envOverrides).length > 0) {
lines.push('');
lines.push('Environment Overrides:');
const keyW = Math.max(4, ...Object.keys(envOverrides).map((k) => k.length)) + 2;
for (const [key, value] of Object.entries(envOverrides)) {
lines.push(` ${key.padEnd(keyW)}${value}`);
}
}
lines.push('');
lines.push('Metadata:');
lines.push(` ${pad('ID:', 12)}${profile.id}`);
if (profile.createdAt) lines.push(` ${pad('Created:', 12)}${profile.createdAt}`);
if (profile.updatedAt) lines.push(` ${pad('Updated:', 12)}${profile.updatedAt}`);
return lines.join('\n');
}
function formatProjectDetail(project: Record<string, unknown>): string {
const lines: string[] = [];
lines.push(`=== Project: ${project.name} ===`);
lines.push(`${pad('Name:')}${project.name}`);
if (project.description) lines.push(`${pad('Description:')}${project.description}`);
if (project.ownerId) lines.push(`${pad('Owner:')}${project.ownerId}`);
lines.push('');
lines.push('Metadata:');
lines.push(` ${pad('ID:', 12)}${project.id}`);
if (project.createdAt) lines.push(` ${pad('Created:', 12)}${project.createdAt}`);
if (project.updatedAt) lines.push(` ${pad('Updated:', 12)}${project.updatedAt}`);
return lines.join('\n');
}
function formatGenericDetail(obj: Record<string, unknown>): string {
const lines: string[] = [];
for (const [key, value] of Object.entries(obj)) {
if (value === null || value === undefined) {
lines.push(`${pad(key + ':')} -`);
} else if (Array.isArray(value)) {
if (value.length === 0) {
lines.push(`${pad(key + ':')} []`);
} else {
lines.push(`${key}:`);
for (const item of value) {
lines.push(` - ${typeof item === 'object' ? JSON.stringify(item) : String(item)}`);
}
}
} else if (typeof value === 'object') {
lines.push(`${key}:`);
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
lines.push(` ${pad(k + ':')}${String(v)}`);
}
} else {
lines.push(`${pad(key + ':')}${String(value)}`);
}
}
return lines.join('\n'); return lines.join('\n');
} }
@@ -56,16 +168,26 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command {
return new Command('describe') return new Command('describe')
.description('Show detailed information about a resource') .description('Show detailed information about a resource')
.argument('<resource>', 'resource type (server, profile, project, instance)') .argument('<resource>', 'resource type (server, profile, project, instance)')
.argument('<id>', 'resource ID') .argument('<id>', 'resource ID or name')
.option('-o, --output <format>', 'output format (detail, json, yaml)', 'detail') .option('-o, --output <format>', 'output format (detail, json, yaml)', 'detail')
.action(async (resourceArg: string, id: string, opts: { output: string }) => { .action(async (resourceArg: string, idOrName: string, opts: { output: string }) => {
const resource = resolveResource(resourceArg); const resource = resolveResource(resourceArg);
// Resolve name → ID
let id: string;
try {
id = await resolveNameOrId(deps.client, resource, idOrName);
} catch {
id = idOrName;
}
const item = await deps.fetchResource(resource, id) as Record<string, unknown>; const item = await deps.fetchResource(resource, id) as Record<string, unknown>;
// Enrich instances with container inspect data // Enrich instances with container inspect data
let inspect: Record<string, unknown> | undefined;
if (resource === 'instances' && deps.fetchInspect && item.containerId) { if (resource === 'instances' && deps.fetchInspect && item.containerId) {
try { try {
const inspect = await deps.fetchInspect(id); inspect = await deps.fetchInspect(id) as Record<string, unknown>;
item.containerInspect = inspect; item.containerInspect = inspect;
} catch { } catch {
// Container may not be available // Container may not be available
@@ -77,9 +199,23 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command {
} else if (opts.output === 'yaml') { } else if (opts.output === 'yaml') {
deps.log(formatYaml(item)); deps.log(formatYaml(item));
} else { } else {
const typeName = resource.replace(/s$/, '').charAt(0).toUpperCase() + resource.replace(/s$/, '').slice(1); // Visually clean sectioned output
deps.log(`--- ${typeName} ---`); switch (resource) {
deps.log(formatDetail(item)); case 'servers':
deps.log(formatServerDetail(item));
break;
case 'instances':
deps.log(formatInstanceDetail(item, inspect));
break;
case 'profiles':
deps.log(formatProfileDetail(item));
break;
case 'projects':
deps.log(formatProjectDetail(item));
break;
default:
deps.log(formatGenericDetail(item));
}
} }
}); });
} }

View File

@@ -0,0 +1,114 @@
import { Command } from 'commander';
import { writeFileSync, readFileSync, unlinkSync, mkdtempSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { execSync } from 'node:child_process';
import yaml from 'js-yaml';
import type { ApiClient } from '../api-client.js';
import { resolveResource, resolveNameOrId, stripInternalFields } from './shared.js';
export interface EditCommandDeps {
client: ApiClient;
log: (...args: unknown[]) => void;
/** Override for testing — return editor binary name. */
getEditor?: () => string;
/** Override for testing — simulate opening the editor. */
openEditor?: (filePath: string, editor: string) => void;
}
function getEditor(deps: EditCommandDeps): string {
if (deps.getEditor) return deps.getEditor();
return process.env.VISUAL ?? process.env.EDITOR ?? 'vi';
}
function openEditor(filePath: string, editor: string, deps: EditCommandDeps): void {
if (deps.openEditor) {
deps.openEditor(filePath, editor);
return;
}
execSync(`${editor} "${filePath}"`, { stdio: 'inherit' });
}
export function createEditCommand(deps: EditCommandDeps): Command {
const { client, log } = deps;
return new Command('edit')
.description('Edit a resource in your default editor (server, profile, project)')
.argument('<resource>', 'Resource type (server, profile, project)')
.argument('<name-or-id>', 'Resource name or ID')
.action(async (resourceArg: string, nameOrId: string) => {
const resource = resolveResource(resourceArg);
// Instances are immutable
if (resource === 'instances') {
log('Error: instances are immutable and cannot be edited.');
log('To change an instance, update the server definition and let reconciliation handle it.');
process.exitCode = 1;
return;
}
const validResources = ['servers', 'profiles', 'projects'];
if (!validResources.includes(resource)) {
log(`Error: unknown resource type '${resourceArg}'`);
process.exitCode = 1;
return;
}
// Resolve name → ID
const id = await resolveNameOrId(client, resource, nameOrId);
// Fetch current state
const current = await client.get<Record<string, unknown>>(`/api/v1/${resource}/${id}`);
// Strip read-only fields for editor
const editable = stripInternalFields(current);
// Serialize to YAML
const singular = resource.replace(/s$/, '');
const header = `# Editing ${singular}: ${nameOrId}\n# Save and close to apply changes. Clear the file to cancel.\n`;
const originalYaml = yaml.dump(editable, { lineWidth: 120, noRefs: true });
const content = header + originalYaml;
// Write to temp file
const tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-edit-'));
const tmpFile = join(tmpDir, `${singular}-${nameOrId}.yaml`);
writeFileSync(tmpFile, content, 'utf-8');
try {
// Open editor
const editor = getEditor(deps);
openEditor(tmpFile, editor, deps);
// Read back
const modified = readFileSync(tmpFile, 'utf-8');
// Strip comments for comparison
const modifiedClean = modified
.split('\n')
.filter((line) => !line.startsWith('#'))
.join('\n')
.trim();
if (!modifiedClean) {
log('Edit cancelled (empty file).');
return;
}
if (modifiedClean === originalYaml.trim()) {
log(`${singular} '${nameOrId}' unchanged.`);
return;
}
// Parse and apply
const updates = yaml.load(modifiedClean) as Record<string, unknown>;
await client.put(`/api/v1/${resource}/${id}`, updates);
log(`${singular} '${nameOrId}' updated.`);
} finally {
try {
unlinkSync(tmpFile);
} catch {
// Ignore cleanup errors
}
}
});
}

View File

@@ -2,6 +2,7 @@ import { Command } from 'commander';
import { formatTable } from '../formatters/table.js'; import { formatTable } from '../formatters/table.js';
import { formatJson, formatYaml } from '../formatters/output.js'; import { formatJson, formatYaml } from '../formatters/output.js';
import type { Column } from '../formatters/table.js'; import type { Column } from '../formatters/table.js';
import { resolveResource, stripInternalFields } from './shared.js';
export interface GetCommandDeps { export interface GetCommandDeps {
fetchResource: (resource: string, id?: string) => Promise<unknown[]>; fetchResource: (resource: string, id?: string) => Promise<unknown[]>;
@@ -37,22 +38,6 @@ interface InstanceRow {
port: number | null; port: number | null;
} }
const RESOURCE_ALIASES: Record<string, string> = {
server: 'servers',
srv: 'servers',
profile: 'profiles',
prof: 'profiles',
project: 'projects',
proj: 'projects',
instance: 'instances',
inst: 'instances',
};
function resolveResource(name: string): string {
const lower = name.toLowerCase();
return RESOURCE_ALIASES[lower] ?? lower;
}
const serverColumns: Column<ServerRow>[] = [ const serverColumns: Column<ServerRow>[] = [
{ header: 'NAME', key: 'name' }, { header: 'NAME', key: 'name' },
{ header: 'TRANSPORT', key: 'transport', width: 16 }, { header: 'TRANSPORT', key: 'transport', width: 16 },
@@ -100,21 +85,44 @@ function getColumnsForResource(resource: string): Column<Record<string, unknown>
} }
} }
/**
* Transform API response items into apply-compatible format.
* Strips internal fields and wraps in the resource key.
*/
function toApplyFormat(resource: string, items: unknown[]): Record<string, unknown[]> {
const cleaned = items.map((item) => {
const obj = stripInternalFields(item as Record<string, unknown>);
// For profiles: convert serverId → server (name) for apply compat
// We can't resolve the name here without an API call, so keep serverId
// but also remove it's not in the apply schema. Actually profiles use
// "server" (name) in apply format but serverId from API. Keep serverId
// since it can still be used with apply (the apply command resolves names).
return obj;
});
return { [resource]: cleaned };
}
export function createGetCommand(deps: GetCommandDeps): Command { export function createGetCommand(deps: GetCommandDeps): Command {
return new Command('get') return new Command('get')
.description('List resources (servers, profiles, projects, instances)') .description('List resources (servers, profiles, projects, instances)')
.argument('<resource>', 'resource type (servers, profiles, projects, instances)') .argument('<resource>', 'resource type (servers, profiles, projects, instances)')
.argument('[id]', 'specific resource ID') .argument('[id]', 'specific resource ID or name')
.option('-o, --output <format>', 'output format (table, json, yaml)', 'table') .option('-o, --output <format>', 'output format (table, json, yaml)', 'table')
.action(async (resourceArg: string, id: string | undefined, opts: { output: string }) => { .action(async (resourceArg: string, id: string | undefined, opts: { output: string }) => {
const resource = resolveResource(resourceArg); const resource = resolveResource(resourceArg);
const items = await deps.fetchResource(resource, id); const items = await deps.fetchResource(resource, id);
if (opts.output === 'json') { if (opts.output === 'json') {
deps.log(formatJson(items.length === 1 ? items[0] : items)); // Apply-compatible JSON wrapped in resource key
deps.log(formatJson(toApplyFormat(resource, items)));
} else if (opts.output === 'yaml') { } else if (opts.output === 'yaml') {
deps.log(formatYaml(items.length === 1 ? items[0] : items)); // Apply-compatible YAML wrapped in resource key
deps.log(formatYaml(toApplyFormat(resource, items)));
} else { } else {
if (items.length === 0) {
deps.log(`No ${resource} found.`);
return;
}
const columns = getColumnsForResource(resource); const columns = getColumnsForResource(resource);
deps.log(formatTable(items as Record<string, unknown>[], columns)); deps.log(formatTable(items as Record<string, unknown>[], columns));
} }

View File

@@ -25,19 +25,7 @@ export function createProjectCommand(deps: ProjectCommandDeps): Command {
const cmd = new Command('project') const cmd = new Command('project')
.alias('proj') .alias('proj')
.description('Project-specific actions (use "get projects" to list, "delete project" to remove)'); .description('Project-specific actions (create with "create project", list with "get projects")');
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 cmd
.command('profiles <id>') .command('profiles <id>')

View File

@@ -0,0 +1,42 @@
import type { ApiClient } from '../api-client.js';
export const RESOURCE_ALIASES: Record<string, string> = {
server: 'servers',
srv: 'servers',
profile: 'profiles',
prof: 'profiles',
project: 'projects',
proj: 'projects',
instance: 'instances',
inst: 'instances',
};
export function resolveResource(name: string): string {
const lower = name.toLowerCase();
return RESOURCE_ALIASES[lower] ?? lower;
}
/** Resolve a name-or-ID to an ID. CUIDs pass through; names are looked up. */
export async function resolveNameOrId(
client: ApiClient,
resource: string,
nameOrId: string,
): Promise<string> {
// CUIDs start with 'c' followed by 24+ alphanumeric chars
if (/^c[a-z0-9]{24}/.test(nameOrId)) {
return nameOrId;
}
const items = await client.get<Array<{ id: string; name: string }>>(`/api/v1/${resource}`);
const match = items.find((item) => item.name === nameOrId);
if (match) return match.id;
throw new Error(`${resource.replace(/s$/, '')} '${nameOrId}' not found`);
}
/** Strip internal/read-only fields from an API response to make it apply-compatible. */
export function stripInternalFields(obj: Record<string, unknown>): Record<string, unknown> {
const result = { ...obj };
for (const key of ['id', 'createdAt', 'updatedAt', 'version', 'ownerId']) {
delete result[key];
}
return result;
}

View File

@@ -8,6 +8,8 @@ import { createDescribeCommand } from './commands/describe.js';
import { createDeleteCommand } from './commands/delete.js'; import { createDeleteCommand } from './commands/delete.js';
import { createLogsCommand } from './commands/logs.js'; import { createLogsCommand } from './commands/logs.js';
import { createApplyCommand } from './commands/apply.js'; import { createApplyCommand } from './commands/apply.js';
import { createCreateCommand } from './commands/create.js';
import { createEditCommand } from './commands/edit.js';
import { createSetupCommand } from './commands/setup.js'; import { createSetupCommand } from './commands/setup.js';
import { createClaudeCommand } from './commands/claude.js'; import { createClaudeCommand } from './commands/claude.js';
import { createProjectCommand } from './commands/project.js'; import { createProjectCommand } from './commands/project.js';
@@ -16,6 +18,7 @@ import { createLoginCommand, createLogoutCommand } from './commands/auth.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';
import { loadCredentials } from './auth/index.js'; import { loadCredentials } from './auth/index.js';
import { resolveNameOrId } from './commands/shared.js';
export function createProgram(): Command { export function createProgram(): Command {
const program = new Command() const program = new Command()
@@ -46,15 +49,27 @@ export function createProgram(): Command {
const client = new ApiClient({ baseUrl, token: creds?.token ?? undefined }); const client = new ApiClient({ baseUrl, token: creds?.token ?? undefined });
const fetchResource = async (resource: string, id?: string): Promise<unknown[]> => { const fetchResource = async (resource: string, nameOrId?: string): Promise<unknown[]> => {
if (id) { if (nameOrId) {
let id: string;
try {
id = await resolveNameOrId(client, resource, nameOrId);
} catch {
id = nameOrId;
}
const item = await client.get(`/api/v1/${resource}/${id}`); const item = await client.get(`/api/v1/${resource}/${id}`);
return [item]; return [item];
} }
return client.get<unknown[]>(`/api/v1/${resource}`); return client.get<unknown[]>(`/api/v1/${resource}`);
}; };
const fetchSingleResource = async (resource: string, id: string): Promise<unknown> => { const fetchSingleResource = async (resource: string, nameOrId: string): Promise<unknown> => {
let id: string;
try {
id = await resolveNameOrId(client, resource, nameOrId);
} catch {
id = nameOrId;
}
return client.get(`/api/v1/${resource}/${id}`); return client.get(`/api/v1/${resource}/${id}`);
}; };
@@ -64,6 +79,7 @@ export function createProgram(): Command {
})); }));
program.addCommand(createDescribeCommand({ program.addCommand(createDescribeCommand({
client,
fetchResource: fetchSingleResource, fetchResource: fetchSingleResource,
fetchInspect: async (id: string) => client.get(`/api/v1/instances/${id}/inspect`), fetchInspect: async (id: string) => client.get(`/api/v1/instances/${id}/inspect`),
log: (...args) => console.log(...args), log: (...args) => console.log(...args),
@@ -79,6 +95,16 @@ export function createProgram(): Command {
log: (...args) => console.log(...args), log: (...args) => console.log(...args),
})); }));
program.addCommand(createCreateCommand({
client,
log: (...args) => console.log(...args),
}));
program.addCommand(createEditCommand({
client,
log: (...args) => console.log(...args),
}));
program.addCommand(createApplyCommand({ program.addCommand(createApplyCommand({
client, client,
log: (...args) => console.log(...args), log: (...args) => console.log(...args),

View File

@@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createCreateCommand } from '../../src/commands/create.js';
import type { ApiClient } from '../../src/api-client.js';
function mockClient(): ApiClient {
return {
get: vi.fn(async () => []),
post: vi.fn(async () => ({ id: 'new-id', name: 'test' })),
put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}),
} as unknown as ApiClient;
}
describe('create command', () => {
let client: ReturnType<typeof mockClient>;
let output: string[];
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
beforeEach(() => {
client = mockClient();
output = [];
});
describe('create server', () => {
it('creates a server with minimal flags', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['server', 'my-server'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/servers', expect.objectContaining({
name: 'my-server',
transport: 'STDIO',
replicas: 1,
}));
expect(output.join('\n')).toContain("server 'test' created");
});
it('creates a server with all flags', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'server', 'ha-mcp',
'-d', 'Home Assistant MCP',
'--docker-image', 'ghcr.io/ha-mcp:latest',
'--transport', 'STREAMABLE_HTTP',
'--external-url', 'http://localhost:8086/mcp',
'--container-port', '3000',
'--replicas', '2',
'--command', 'python',
'--command', '-c',
'--command', 'print("hello")',
'--env-template', 'API_KEY:API key:true',
'--env-template', 'BASE_URL:Base URL:false',
], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/servers', {
name: 'ha-mcp',
description: 'Home Assistant MCP',
dockerImage: 'ghcr.io/ha-mcp:latest',
transport: 'STREAMABLE_HTTP',
externalUrl: 'http://localhost:8086/mcp',
containerPort: 3000,
replicas: 2,
command: ['python', '-c', 'print("hello")'],
envTemplate: [
{ name: 'API_KEY', description: 'API key', isSecret: true },
{ name: 'BASE_URL', description: 'Base URL', isSecret: false },
],
});
});
it('defaults transport to STDIO', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['server', 'test'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/servers', expect.objectContaining({
transport: 'STDIO',
}));
});
});
describe('create profile', () => {
it('creates a profile resolving server name', async () => {
vi.mocked(client.get).mockResolvedValue([
{ id: 'srv-abc', name: 'ha-mcp' },
]);
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['profile', 'production', '--server', 'ha-mcp'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/profiles', expect.objectContaining({
name: 'production',
serverId: 'srv-abc',
}));
});
it('parses --env KEY=value entries', async () => {
vi.mocked(client.get).mockResolvedValue([
{ id: 'srv-1', name: 'test' },
]);
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'profile', 'dev',
'--server', 'test',
'--env', 'FOO=bar',
'--env', 'SECRET=s3cr3t',
], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/profiles', expect.objectContaining({
envOverrides: { FOO: 'bar', SECRET: 's3cr3t' },
}));
});
it('passes permissions', async () => {
vi.mocked(client.get).mockResolvedValue([
{ id: 'srv-1', name: 'test' },
]);
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'profile', 'admin',
'--server', 'test',
'--permissions', 'read',
'--permissions', 'write',
], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/profiles', expect.objectContaining({
permissions: ['read', 'write'],
}));
});
});
describe('create project', () => {
it('creates a project', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['project', '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 'test' created");
});
it('creates a project with no description', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['project', 'minimal'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', {
name: 'minimal',
description: '',
});
});
});
});

View File

@@ -1,18 +1,29 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { createDescribeCommand } from '../../src/commands/describe.js'; import { createDescribeCommand } from '../../src/commands/describe.js';
import type { DescribeCommandDeps } from '../../src/commands/describe.js'; import type { DescribeCommandDeps } from '../../src/commands/describe.js';
import type { ApiClient } from '../../src/api-client.js';
function mockClient(): ApiClient {
return {
get: vi.fn(async () => []),
post: vi.fn(async () => ({})),
put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}),
} as unknown as ApiClient;
}
function makeDeps(item: unknown = {}): DescribeCommandDeps & { output: string[] } { function makeDeps(item: unknown = {}): DescribeCommandDeps & { output: string[] } {
const output: string[] = []; const output: string[] = [];
return { return {
output, output,
client: mockClient(),
fetchResource: vi.fn(async () => item), fetchResource: vi.fn(async () => item),
log: (...args: string[]) => output.push(args.join(' ')), log: (...args: string[]) => output.push(args.join(' ')),
}; };
} }
describe('describe command', () => { describe('describe command', () => {
it('shows detailed server info', async () => { it('shows detailed server info with sections', async () => {
const deps = makeDeps({ const deps = makeDeps({
id: 'srv-1', id: 'srv-1',
name: 'slack', name: 'slack',
@@ -20,16 +31,22 @@ describe('describe command', () => {
packageName: '@slack/mcp', packageName: '@slack/mcp',
dockerImage: null, dockerImage: null,
envTemplate: [], envTemplate: [],
createdAt: '2025-01-01',
}); });
const cmd = createDescribeCommand(deps); const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'server', 'srv-1']); await cmd.parseAsync(['node', 'test', 'server', 'srv-1']);
expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1'); expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1');
const text = deps.output.join('\n'); const text = deps.output.join('\n');
expect(text).toContain('--- Server ---'); expect(text).toContain('=== Server: slack ===');
expect(text).toContain('name: slack'); expect(text).toContain('Name:');
expect(text).toContain('transport: STDIO'); expect(text).toContain('slack');
expect(text).toContain('dockerImage: -'); expect(text).toContain('Transport:');
expect(text).toContain('STDIO');
expect(text).toContain('Package:');
expect(text).toContain('@slack/mcp');
expect(text).toContain('Metadata:');
expect(text).toContain('ID:');
}); });
it('resolves resource aliases', async () => { it('resolves resource aliases', async () => {
@@ -55,31 +72,58 @@ describe('describe command', () => {
expect(deps.output[0]).toContain('name: slack'); expect(deps.output[0]).toContain('name: slack');
}); });
it('formats nested objects', async () => { it('shows profile with permissions and env overrides', async () => {
const deps = makeDeps({ const deps = makeDeps({
id: 'srv-1', id: 'p1',
name: 'slack', name: 'production',
metadata: { version: '1.0', nested: { deep: true } }, serverId: 'srv-1',
permissions: ['read', 'write'],
envOverrides: { FOO: 'bar', SECRET: 's3cr3t' },
createdAt: '2025-01-01',
}); });
const cmd = createDescribeCommand(deps); const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'server', 'srv-1']); await cmd.parseAsync(['node', 'test', 'profile', 'p1']);
const text = deps.output.join('\n'); const text = deps.output.join('\n');
expect(text).toContain('metadata:'); expect(text).toContain('=== Profile: production ===');
expect(text).toContain('version: 1.0'); expect(text).toContain('read, write');
expect(text).toContain('Environment Overrides:');
expect(text).toContain('FOO');
expect(text).toContain('bar');
}); });
it('formats arrays correctly', async () => { it('shows project detail', async () => {
const deps = makeDeps({ const deps = makeDeps({
id: 'srv-1', id: 'proj-1',
permissions: ['read', 'write'], name: 'my-project',
envTemplate: [], description: 'A test project',
ownerId: 'user-1',
createdAt: '2025-01-01',
}); });
const cmd = createDescribeCommand(deps); const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'server', 'srv-1']); await cmd.parseAsync(['node', 'test', 'project', 'proj-1']);
const text = deps.output.join('\n'); const text = deps.output.join('\n');
expect(text).toContain('permissions: read, write'); expect(text).toContain('=== Project: my-project ===');
expect(text).toContain('envTemplate: []'); expect(text).toContain('A test project');
expect(text).toContain('user-1');
});
it('shows instance detail with container info', async () => {
const deps = makeDeps({
id: 'inst-1',
serverId: 'srv-1',
status: 'RUNNING',
containerId: 'abc123',
port: 3000,
createdAt: '2025-01-01',
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'instance', 'inst-1']);
const text = deps.output.join('\n');
expect(text).toContain('=== Instance: inst-1 ===');
expect(text).toContain('RUNNING');
expect(text).toContain('abc123');
}); });
}); });

View File

@@ -0,0 +1,180 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { readFileSync, writeFileSync } from 'node:fs';
import yaml from 'js-yaml';
import { createEditCommand } from '../../src/commands/edit.js';
import type { ApiClient } from '../../src/api-client.js';
function mockClient(): ApiClient {
return {
get: vi.fn(async () => ({})),
post: vi.fn(async () => ({})),
put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}),
} as unknown as ApiClient;
}
describe('edit command', () => {
let client: ReturnType<typeof mockClient>;
let output: string[];
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
beforeEach(() => {
client = mockClient();
output = [];
});
it('fetches server, opens editor, applies changes on save', async () => {
// GET /api/v1/servers returns list for resolveNameOrId
vi.mocked(client.get).mockImplementation(async (path: string) => {
if (path === '/api/v1/servers') {
return [{ id: 'srv-1', name: 'ha-mcp' }];
}
// GET /api/v1/servers/srv-1 returns full server
return {
id: 'srv-1',
name: 'ha-mcp',
description: 'Old desc',
transport: 'STDIO',
replicas: 1,
createdAt: '2025-01-01',
updatedAt: '2025-01-01',
version: 1,
};
});
const cmd = createEditCommand({
client,
log,
getEditor: () => 'vi',
openEditor: (filePath) => {
// Simulate user editing the file
const content = readFileSync(filePath, 'utf-8');
const modified = content
.replace('Old desc', 'New desc')
.replace('replicas: 1', 'replicas: 3');
writeFileSync(filePath, modified, 'utf-8');
},
});
await cmd.parseAsync(['server', 'ha-mcp'], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/servers/srv-1', expect.objectContaining({
description: 'New desc',
replicas: 3,
}));
expect(output.join('\n')).toContain("server 'ha-mcp' updated");
});
it('detects no changes and skips PUT', async () => {
vi.mocked(client.get).mockImplementation(async (path: string) => {
if (path === '/api/v1/servers') return [{ id: 'srv-1', name: 'test' }];
return {
id: 'srv-1', name: 'test', description: '', transport: 'STDIO',
createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1,
};
});
const cmd = createEditCommand({
client,
log,
getEditor: () => 'vi',
openEditor: () => {
// Don't modify the file
},
});
await cmd.parseAsync(['server', 'test'], { from: 'user' });
expect(client.put).not.toHaveBeenCalled();
expect(output.join('\n')).toContain("unchanged");
});
it('handles empty file as cancel', async () => {
vi.mocked(client.get).mockImplementation(async (path: string) => {
if (path === '/api/v1/servers') return [{ id: 'srv-1', name: 'test' }];
return { id: 'srv-1', name: 'test', createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1 };
});
const cmd = createEditCommand({
client,
log,
getEditor: () => 'vi',
openEditor: (filePath) => {
writeFileSync(filePath, '', 'utf-8');
},
});
await cmd.parseAsync(['server', 'test'], { from: 'user' });
expect(client.put).not.toHaveBeenCalled();
expect(output.join('\n')).toContain('cancelled');
});
it('strips read-only fields from editor content', async () => {
vi.mocked(client.get).mockImplementation(async (path: string) => {
if (path === '/api/v1/servers') return [{ id: 'srv-1', name: 'test' }];
return {
id: 'srv-1', name: 'test', description: '', transport: 'STDIO',
createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1,
};
});
let editorContent = '';
const cmd = createEditCommand({
client,
log,
getEditor: () => 'vi',
openEditor: (filePath) => {
editorContent = readFileSync(filePath, 'utf-8');
},
});
await cmd.parseAsync(['server', 'test'], { from: 'user' });
// The editor content should NOT contain read-only fields
expect(editorContent).not.toContain('id:');
expect(editorContent).not.toContain('createdAt');
expect(editorContent).not.toContain('updatedAt');
expect(editorContent).not.toContain('version');
// But should contain editable fields
expect(editorContent).toContain('name:');
});
it('rejects edit instance with error message', async () => {
const cmd = createEditCommand({ client, log });
await cmd.parseAsync(['instance', 'inst-1'], { from: 'user' });
expect(client.get).not.toHaveBeenCalled();
expect(client.put).not.toHaveBeenCalled();
expect(output.join('\n')).toContain('immutable');
});
it('edits a profile', async () => {
vi.mocked(client.get).mockImplementation(async (path: string) => {
if (path === '/api/v1/profiles') return [{ id: 'prof-1', name: 'production' }];
return {
id: 'prof-1', name: 'production', serverId: 'srv-1',
permissions: ['read'], envOverrides: { FOO: 'bar' },
createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1,
};
});
const cmd = createEditCommand({
client,
log,
getEditor: () => 'vi',
openEditor: (filePath) => {
const content = readFileSync(filePath, 'utf-8');
const modified = content.replace('FOO: bar', 'FOO: baz');
writeFileSync(filePath, modified, 'utf-8');
},
});
await cmd.parseAsync(['profile', 'production'], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/profiles/prof-1', expect.objectContaining({
envOverrides: { FOO: 'baz' },
}));
});
});

View File

@@ -41,20 +41,30 @@ describe('get command', () => {
expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1'); expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1');
}); });
it('outputs JSON format', async () => { it('outputs apply-compatible JSON format', async () => {
const deps = makeDeps([{ id: 'srv-1', name: 'slack' }]); const deps = makeDeps([{ id: 'srv-1', name: 'slack', createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1 }]);
const cmd = createGetCommand(deps); const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'servers', '-o', 'json']); await cmd.parseAsync(['node', 'test', 'servers', '-o', 'json']);
const parsed = JSON.parse(deps.output[0] ?? ''); const parsed = JSON.parse(deps.output[0] ?? '');
expect(parsed).toEqual({ id: 'srv-1', name: 'slack' }); // Wrapped in resource key, internal fields stripped
expect(parsed).toHaveProperty('servers');
expect(parsed.servers[0].name).toBe('slack');
expect(parsed.servers[0]).not.toHaveProperty('id');
expect(parsed.servers[0]).not.toHaveProperty('createdAt');
expect(parsed.servers[0]).not.toHaveProperty('updatedAt');
expect(parsed.servers[0]).not.toHaveProperty('version');
}); });
it('outputs YAML format', async () => { it('outputs apply-compatible YAML format', async () => {
const deps = makeDeps([{ id: 'srv-1', name: 'slack' }]); const deps = makeDeps([{ id: 'srv-1', name: 'slack', createdAt: '2025-01-01' }]);
const cmd = createGetCommand(deps); const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'servers', '-o', 'yaml']); await cmd.parseAsync(['node', 'test', 'servers', '-o', 'yaml']);
expect(deps.output[0]).toContain('name: slack'); const text = deps.output[0];
expect(text).toContain('servers:');
expect(text).toContain('name: slack');
expect(text).not.toContain('id:');
expect(text).not.toContain('createdAt:');
}); });
it('lists profiles with correct columns', async () => { it('lists profiles with correct columns', async () => {
@@ -81,6 +91,6 @@ describe('get command', () => {
const deps = makeDeps([]); const deps = makeDeps([]);
const cmd = createGetCommand(deps); const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'servers']); await cmd.parseAsync(['node', 'test', 'servers']);
expect(deps.output[0]).toContain('No results'); expect(deps.output[0]).toContain('No servers found');
}); });
}); });

View File

@@ -21,18 +21,6 @@ describe('project command', () => {
output = []; output = [];
}); });
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('profiles', () => { describe('profiles', () => {
it('lists profiles for a project', async () => { it('lists profiles for a project', async () => {
vi.mocked(client.get).mockResolvedValue([ vi.mocked(client.get).mockResolvedValue([

View File

@@ -19,9 +19,13 @@ describe('CLI command registration (e2e)', () => {
expect(commandNames).toContain('delete'); expect(commandNames).toContain('delete');
expect(commandNames).toContain('logs'); expect(commandNames).toContain('logs');
expect(commandNames).toContain('apply'); expect(commandNames).toContain('apply');
expect(commandNames).toContain('create');
expect(commandNames).toContain('edit');
expect(commandNames).toContain('setup'); expect(commandNames).toContain('setup');
expect(commandNames).toContain('claude'); expect(commandNames).toContain('claude');
expect(commandNames).toContain('project'); expect(commandNames).toContain('project');
expect(commandNames).toContain('backup');
expect(commandNames).toContain('restore');
}); });
it('instance command is removed (use get/delete/logs instead)', () => { it('instance command is removed (use get/delete/logs instead)', () => {
@@ -48,10 +52,10 @@ describe('CLI command registration (e2e)', () => {
expect(project).toBeDefined(); expect(project).toBeDefined();
const subcommands = project!.commands.map((c) => c.name()); const subcommands = project!.commands.map((c) => c.name());
expect(subcommands).toContain('create');
expect(subcommands).toContain('profiles'); expect(subcommands).toContain('profiles');
expect(subcommands).toContain('set-profiles'); expect(subcommands).toContain('set-profiles');
// list, show, delete are now top-level (get, describe, delete) // create is now top-level (mcpctl create project)
expect(subcommands).not.toContain('create');
expect(subcommands).not.toContain('list'); expect(subcommands).not.toContain('list');
expect(subcommands).not.toContain('show'); expect(subcommands).not.toContain('show');
expect(subcommands).not.toContain('delete'); expect(subcommands).not.toContain('delete');