Files
mcpctl/scripts/generate-completions.ts

1089 lines
36 KiB
TypeScript
Raw Permalink Normal View History

#!/usr/bin/env tsx
/**
* generate-completions.ts auto-generates shell completions from the commander.js command tree.
*
* Usage:
* tsx scripts/generate-completions.ts # print generated files to stdout
* tsx scripts/generate-completions.ts --write # write completions/ files
* tsx scripts/generate-completions.ts --check # exit 0 if files match, 1 if stale
*
* Requires `pnpm build` to have run first (workspace packages must be compiled).
*/
import { Command, type Option, type Argument } from 'commander';
import { readFileSync, writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
// ============================================================
// Configuration — hints for argument completions
// ============================================================
type CompletionHint =
| 'project_names'
| 'resource_types'
| 'resource_names'
| 'instance_names'
| 'available_servers'
| 'project_servers'
| 'file'
| { static: string[] };
/**
* Maps `command.argumentName` how to complete that argument.
* Anything not listed here gets no special completion (user types freeform).
*/
const COMPLETION_HINTS: Record<string, CompletionHint> = {
'console.project': 'project_names',
'get.resource': 'resource_types',
'get.id': 'resource_names',
'describe.resource': 'resource_types',
'describe.id': 'resource_names',
'delete.resource': 'resource_types',
'delete.id': 'resource_names',
'edit.resource': { static: ['servers', 'secrets', 'projects', 'groups', 'rbac', 'prompts', 'promptrequests', 'personalities'] },
'edit.name-or-id': 'resource_names',
'patch.resource': 'resource_types',
'patch.name': 'resource_names',
'approve.resource': { static: ['promptrequest'] },
'approve.name': 'resource_names',
'logs.name': 'instance_names',
'attach-server.server-name': 'available_servers',
'detach-server.server-name': 'project_servers',
'apply.file': 'file',
};
/** Options whose values are file paths (use -rF in fish, -f in bash). */
const FILE_OPTIONS = new Set([
'apply.file',
'backup.output',
'restore.input',
'create-prompt.content-file',
'create-promptrequest.content-file',
]);
/** Commands shown ONLY when --project/-p is on the command line. */
const PROJECT_ONLY_COMMANDS = new Set(['attach-server', 'detach-server']);
/** Commands that appear in BOTH project and non-project context. */
const PROJECT_SCOPED_COMMANDS = new Set([
'get', 'describe', 'delete', 'logs', 'create', 'edit', 'help',
]);
/** Completely hidden commands (never shown in completions). */
const NEVER_SHOW_COMMANDS = new Set(['mcp']);
/**
* Commands that follow the resource-type resource-name two-arg pattern.
* Used to generate guard functions.
*/
const RESOURCE_COMMANDS = ['get', 'describe', 'delete', 'edit', 'patch', 'approve'];
// ============================================================
// Command tree extraction
// ============================================================
interface CmdInfo {
name: string;
description: string;
hidden: boolean;
options: OptInfo[];
args: ArgInfo[];
subcommands: CmdInfo[];
}
interface OptInfo {
short?: string; // e.g. '-o'
long: string; // e.g. '--output'
description: string;
takesValue: boolean;
choices?: string[];
negate: boolean;
}
interface ArgInfo {
name: string;
description: string;
required: boolean;
variadic: boolean;
choices?: string[];
}
function extractOption(opt: Option): OptInfo {
return {
short: (opt as unknown as Record<string, string>).short || undefined,
long: (opt as unknown as Record<string, string>).long,
description: opt.description,
takesValue: (opt as unknown as Record<string, boolean>).required || (opt as unknown as Record<string, boolean>).optional || false,
choices: (opt as unknown as Record<string, string[] | undefined>).argChoices || undefined,
negate: (opt as unknown as Record<string, boolean>).negate || false,
};
}
function extractArgument(arg: Argument): ArgInfo {
return {
name: (arg as unknown as Record<string, string>)._name ?? arg.name(),
description: arg.description,
required: (arg as unknown as Record<string, boolean>).required,
variadic: (arg as unknown as Record<string, boolean>).variadic,
choices: (arg as unknown as Record<string, string[] | undefined>)._choices || undefined,
};
}
function extractCommand(cmd: Command): CmdInfo {
const options = (cmd.options as Option[])
.filter((o) => {
const long = (o as unknown as Record<string, string>).long;
// Skip --help and --version (handled globally)
return long !== '--help' && long !== '--version';
})
.map(extractOption);
const args = ((cmd as unknown as Record<string, Argument[]>).registeredArguments ?? [])
.map(extractArgument);
const subcommands = (cmd.commands as Command[])
.filter((sub) => sub.name() !== 'help') // skip commander's auto-generated help
.map(extractCommand)
// Re-add 'help' as a minimal command for completion purposes
if ((cmd.commands as Command[]).some((sub) => sub.name() === 'help')) {
subcommands.push({
name: 'help',
description: 'display help for command',
hidden: false,
options: [],
args: [],
subcommands: [],
});
}
return {
name: cmd.name(),
description: cmd.description(),
hidden: (cmd as unknown as Record<string, boolean>)._hidden ?? false,
options,
args,
subcommands,
};
}
async function extractTree(): Promise<CmdInfo> {
// createProgram() is safe to call — it reads config from disk (defaults if missing)
// and creates an ApiClient (no network calls).
const { createProgram } = await import('../src/cli/src/index.js') as { createProgram: () => Command };
const program = createProgram();
return extractCommand(program);
}
// ============================================================
// Resource aliases (mirrors RESOURCE_ALIASES from shared.ts)
// ============================================================
const CANONICAL_RESOURCES = [
'servers', 'instances', 'secrets', 'secretbackends', 'llms', 'agents', 'personalities', 'templates', 'projects',
'users', 'groups', 'rbac', 'prompts', 'promptrequests',
'serverattachments', 'proxymodels', 'all',
];
const ALIAS_ENTRIES: [string, string][] = [
['server', 'servers'], ['srv', 'servers'],
['instance', 'instances'], ['inst', 'instances'],
['secret', 'secrets'], ['sec', 'secrets'],
feat(mcpd): pluggable SecretBackend abstraction + OpenBao driver + migrate Why: API keys live in Postgres as plaintext JSON. A DB read exposes every credential in the system. Before centralising more secrets (LLM keys, etc.) we want to be able to point at an external KV store and drop DB access to sensitive rows. New model: - `SecretBackend` resource (CRUD + isDefault invariant) owns how a secret is stored. `Secret` gains `backendId` FK and `externalRef`. Reads/writes dispatch through a driver. - `plaintext` driver (near-noop, uses existing Secret.data column) is seeded as the `default` row at startup. Acts as trust root / bootstrap. - `openbao` driver (also HashiCorp Vault KV v2 compatible) talks plain HTTP, no SDK dependency. Auth via static token pulled from a plaintext-backed `Secret` through the injected SecretRefResolver. Caches resolved token. - `SecretMigrateService` moves secrets one-at-a-time: read → write dest → flip row → best-effort source delete. Interrupted runs are idempotent (skips secrets already on destination). CLI surface: - `mcpctl create|get|describe|delete secretbackend` + `--default` on create. - `mcpctl migrate secrets --from X --to Y [--names a,b] [--keep-source] [--dry-run]` - `apply -f` round-trips secretbackends (yaml/json multi-doc + grouped). - RBAC: `secretbackends` resource + `run:migrate-secrets` operation. - Fish + bash completions regenerated. docs/secret-backends.md covers the OpenBao policy, chicken-and-egg auth flow, and the migration semantics. Broke the circular dep (OpenBao needs SecretService to resolve its own token, SecretService needs SecretBackendService) with a deferred-resolver bridge in mcpd startup. 11 new driver unit tests; existing env-resolver/secret-route/ backup tests updated for the new service signatures. Full suite: 1792/1792. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:29:55 +01:00
['secretbackend', 'secretbackends'], ['sb', 'secretbackends'],
feat(mcpd): Llm resource — CRUD + CLI + apply Why: every client that wants an LLM (the agent, HTTP-mode mcplocal, Claude Code's STDIO mcplocal) today has to know the provider URL + key, and each user's ~/.mcpctl/config.json carries them. Centralising the catalogue on the server is the prerequisite for Phase 2 (mcpd proxies inference so credentials never leave the cluster). This phase adds the `Llm` resource and its CRUD surface — no proxy yet, no client pivot yet. Just enough to register what you have. Schema: - New `Llm` model: name/type/model/url/tier/description + {apiKeySecretId, apiKeySecretKey} FK pair. Reverse `llms` relation on Secret. - Provider types: anthropic | openai | deepseek | vllm | ollama | gemini-cli. - Tiers: fast | heavy. mcpd: - LlmRepository + LlmService + Zod validation schema + /api/v1/llms routes. - API surface exposes `apiKeyRef: {name, key}` — the service translates to/ from the FK pair so clients never deal in cuids. - `resolveApiKey(llmName)` reads through SecretService (which itself dispatches to the right SecretBackend). That's the hook Phase 2's inference proxy uses. - RBAC: added `'llms'` to RBAC_RESOURCES + resource alias. Standard view/create/edit/delete semantics. - Wired into main.ts (repo, service, routes). CLI: - `mcpctl create llm <name> --type X --model Y --tier fast|heavy --api-key-ref SECRET/KEY [--url ...] [--extra k=v ...]` - `mcpctl get|describe|delete llm` — standard resource verbs. - `mcpctl apply -f` with `kind: llm` (single- or multi-doc yaml/json). Applied after secrets, before servers — apiKeyRef resolves an existing Secret. - Shell completions regenerated. Tests: 11 service unit tests + 9 route tests (happy path, 404s, 409, validation). Full suite 1812/1812 (+20 from the 1792 Phase 0 baseline). TypeScript clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:28:43 +01:00
['llm', 'llms'], ['llms', 'llms'],
feat(agents): mcpctl chat REPL + agent CRUD + completions (Stage 5) This is the moment the user can actually talk to an agent end-to-end: mcpctl create llm qwen3-thinking --type openai --model qwen3-thinking \ --url http://litellm.nvidia-nim.svc.cluster.local:4000/v1 \ --api-key-ref litellm-key/API_KEY mcpctl create agent reviewer --llm qwen3-thinking --project mcpctl-dev \ --description "I review security design — ask me after each major change." mcpctl chat reviewer Pieces: * src/cli/src/commands/chat.ts (new) — REPL + one-shot. Streams the SSE endpoint and prints text deltas to stdout as they arrive; tool_call / tool_result events go to stderr in dim-style brackets so the chat output stays clean. LiteLLM-style flags (--temperature / --top-p / --top-k / --max-tokens / --seed / --stop / --allow-tool / --extra) layer over agent.defaultParams. In-REPL slash-commands: /set KEY VAL, /system <text>, /tools (list project's MCP servers), /clear (new thread), /save (PATCH agent.defaultParams = current overrides), /quit. * src/cli/src/commands/create.ts — `create agent` mirroring the llm pattern. Every yaml-applyable field has a corresponding flag (memory rule); --default-temperature / --default-top-p / --default-top-k / --default-max-tokens / --default-seed / --default-stop / --default-extra / --default-params-file all populate agent.defaultParams. * src/cli/src/commands/apply.ts — AgentSpecSchema accepts both `llm: qwen3-thinking` shorthand and `llm: { name: ... }` long form; runs after llms in the apply order so apiKey/llm references resolve. Round- trips with `get agent foo -o yaml | apply -f -` (memory rule). * src/cli/src/commands/get.ts — agentColumns (NAME, LLM, PROJECT, DESCRIPTION, ID); RESOURCE_KIND mapping for yaml export. * src/cli/src/commands/shared.ts — `agent`/`agents`/`thread`/`threads` added to RESOURCE_ALIASES. * src/cli/src/index.ts — wires createChatCommand into the program; passes the resolved baseUrl + token so chat can stream SSE without going through ApiClient (which only does buffered request/response). * completions/mcpctl.{fish,bash} regenerated. scripts/generate-completions.ts knows about agents (canonical + aliases) and emits a special-case `chat)` block that completes the first arg with `mcpctl get agents` names. tests/completions.test.ts: +9 new assertions covering agents in the resource list, chat in the commands list, --llm flag for create agent, agent-name completion for chat, etc. CLI suite: 430/430 (was 421). Completions --check is clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:02:38 +01:00
['agent', 'agents'], ['agents', 'agents'],
['personality', 'personalities'], ['personalities', 'personalities'],
['template', 'templates'], ['tpl', 'templates'],
['project', 'projects'], ['proj', 'projects'],
['user', 'users'],
['group', 'groups'],
['rbac', 'rbac'], ['rbac-definition', 'rbac'], ['rbac-binding', 'rbac'],
['prompt', 'prompts'], ['prompts', 'prompts'],
['promptrequest', 'promptrequests'], ['promptrequests', 'promptrequests'], ['pr', 'promptrequests'],
['serverattachment', 'serverattachments'], ['serverattachments', 'serverattachments'], ['sa', 'serverattachments'],
['proxymodel', 'proxymodels'], ['proxymodels', 'proxymodels'], ['pm', 'proxymodels'],
['all', 'all'],
];
const ALL_ALIASES = [...CANONICAL_RESOURCES, ...ALIAS_ENTRIES.map(([a]) => a)];
// Deduplicate (some canonicals are also aliases)
const ALL_ALIASES_UNIQUE = [...new Set(ALL_ALIASES)];
// ============================================================
// Fish completion generator
// ============================================================
function generateFish(root: CmdInfo): string {
const lines: string[] = [];
const emit = (s: string) => lines.push(s);
emit('# mcpctl fish completions — auto-generated by scripts/generate-completions.ts');
emit('# DO NOT EDIT MANUALLY — run: pnpm completions:generate');
emit('');
// --- Erase stale completions ---
emit('# Erase any stale completions from previous versions');
emit('complete -c mcpctl -e');
emit('');
// --- Categorize commands ---
const visibleCmds: CmdInfo[] = []; // shown without --project
const projectCmds: CmdInfo[] = []; // shown with --project
const projectOnlyCmds: CmdInfo[] = []; // ONLY shown with --project
for (const cmd of root.subcommands) {
if (NEVER_SHOW_COMMANDS.has(cmd.name)) continue;
if (PROJECT_ONLY_COMMANDS.has(cmd.name)) {
projectOnlyCmds.push(cmd);
projectCmds.push(cmd);
} else if (PROJECT_SCOPED_COMMANDS.has(cmd.name)) {
visibleCmds.push(cmd);
projectCmds.push(cmd);
} else if (!cmd.hidden) {
visibleCmds.push(cmd);
}
}
const visibleNames = visibleCmds.map((c) => c.name).join(' ');
const projectNames = projectCmds.map((c) => c.name).join(' ');
emit(`set -l commands ${visibleNames}`);
emit(`set -l project_commands ${projectNames}`);
emit('');
// --- Disable file completions by default ---
emit('# Disable file completions by default');
emit('complete -c mcpctl -f');
emit('');
// --- Global options ---
emit('# Global options');
emit("complete -c mcpctl -s v -l version -d 'Show version'");
for (const opt of root.options) {
const parts = ['complete -c mcpctl'];
if (opt.short) parts.push(`-s ${opt.short.replace('-', '')}`);
parts.push(`-l ${opt.long.replace(/^--/, '')}`);
parts.push(`-d '${esc(opt.description)}'`);
if (opt.long === '--project') {
parts.push(`-xa '(__mcpctl_project_names)'`);
} else if (opt.takesValue) {
if (opt.choices) {
parts.push(`-xa '${opt.choices.join(' ')}'`);
} else {
parts.push('-x');
}
}
emit(parts.join(' '));
}
emit("complete -c mcpctl -s h -l help -d 'Show help'");
emit('');
// --- Runtime helper functions ---
emit('# ---- Runtime helpers ----');
emit('');
emitFishHelpers(emit);
// --- Top-level command completions ---
emit('# Top-level commands (without --project)');
for (const cmd of visibleCmds) {
emit(`complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a ${cmd.name} -d '${esc(cmd.description)}'`);
}
emit('');
emit('# Project-scoped commands (with --project)');
for (const cmd of projectCmds) {
emit(`complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a ${cmd.name} -d '${esc(cmd.description)}'`);
}
emit('');
// --- Resource type completions ---
emit('# Resource types — only when resource type not yet selected');
// Group commands by their resource type hint
const resourceTypeCmds: string[] = [];
const staticTypeCmds: { cmds: string[]; types: string[] }[] = [];
for (const cmdName of RESOURCE_COMMANDS) {
const hint = COMPLETION_HINTS[`${cmdName}.resource`] ?? COMPLETION_HINTS[`${cmdName}.${getFirstArgName(root, cmdName)}`];
if (hint === 'resource_types') {
resourceTypeCmds.push(cmdName);
} else if (hint && typeof hint === 'object' && 'static' in hint) {
// Check if we already have a group with the same types
const key = hint.static.join(' ');
const existing = staticTypeCmds.find((g) => g.types.join(' ') === key);
if (existing) {
existing.cmds.push(cmdName);
} else {
staticTypeCmds.push({ cmds: [cmdName], types: hint.static });
}
}
}
if (resourceTypeCmds.length > 0) {
emit(`complete -c mcpctl -n "__fish_seen_subcommand_from ${resourceTypeCmds.join(' ')}; and __mcpctl_needs_resource_type" -a "$resources" -d 'Resource type'`);
}
for (const group of staticTypeCmds) {
emit(`complete -c mcpctl -n "__fish_seen_subcommand_from ${group.cmds.join(' ')}; and __mcpctl_needs_resource_type" -a '${group.types.join(' ')}' -d 'Resource type'`);
}
emit('');
// --- Resource name completions ---
const resourceNameCmds = RESOURCE_COMMANDS.filter((cmdName) => {
const cmd = root.subcommands.find((c) => c.name === cmdName);
if (!cmd) return false;
return cmd.args.some((a) => {
const hint = COMPLETION_HINTS[`${cmdName}.${a.name}`];
return hint === 'resource_names';
});
});
if (resourceNameCmds.length > 0) {
emit('# Resource names — after resource type is selected');
emit(`complete -c mcpctl -n "__fish_seen_subcommand_from ${resourceNameCmds.join(' ')}; and not __mcpctl_needs_resource_type" -a '(__mcpctl_resource_names)' -d 'Resource name'`);
emit('');
}
// --- Subcommand completions (config, create) ---
for (const cmd of root.subcommands) {
if (cmd.subcommands.length === 0) continue;
const subNames = cmd.subcommands.map((s) => s.name);
emit(`# ${cmd.name} subcommands`);
emit(`set -l ${cmd.name}_cmds ${subNames.join(' ')}`);
for (const sub of cmd.subcommands) {
emit(`complete -c mcpctl -n "__fish_seen_subcommand_from ${cmd.name}; and not __fish_seen_subcommand_from $${cmd.name}_cmds" -a ${sub.name} -d '${esc(sub.description)}'`);
}
emit('');
// Subcommand options
for (const sub of cmd.subcommands) {
if (sub.options.length === 0) continue;
emit(`# ${cmd.name} ${sub.name} options`);
for (const opt of sub.options) {
const parts = [`complete -c mcpctl -n "__mcpctl_subcmd_active ${cmd.name} ${sub.name}"`];
if (opt.short) parts.push(`-s ${opt.short.replace('-', '')}`);
parts.push(`-l ${opt.long.replace(/^--/, '')}`);
parts.push(`-d '${esc(opt.description)}'`);
if (opt.negate) {
// --no-X flags are boolean, no value
} else if (opt.takesValue) {
const fileKey = `${cmd.name}-${sub.name}.${opt.long.replace(/^--/, '')}`;
if (FILE_OPTIONS.has(fileKey)) {
parts.push('-rF');
} else if (opt.choices) {
parts.push(`-xa '${opt.choices.join(' ')}'`);
} else if (opt.long === '--project') {
parts.push(`-xa '(__mcpctl_project_names)'`);
} else {
parts.push('-x');
}
}
emit(parts.join(' '));
}
emit('');
}
}
// --- Per-command option completions (top-level commands without subcommands) ---
for (const cmd of root.subcommands) {
if (cmd.subcommands.length > 0) continue; // Handled above
if (cmd.options.length === 0) continue;
if (NEVER_SHOW_COMMANDS.has(cmd.name)) continue;
emit(`# ${cmd.name} options`);
for (const opt of cmd.options) {
const parts = [`complete -c mcpctl -n "__fish_seen_subcommand_from ${cmd.name}"`];
if (opt.short) parts.push(`-s ${opt.short.replace('-', '')}`);
parts.push(`-l ${opt.long.replace(/^--/, '')}`);
parts.push(`-d '${esc(opt.description)}'`);
if (opt.negate) {
// boolean flag
} else if (opt.takesValue) {
const fileKey = `${cmd.name}.${opt.long.replace(/^--/, '')}`;
if (FILE_OPTIONS.has(fileKey)) {
parts.push('-rF');
} else if (opt.choices) {
parts.push(`-xa '${opt.choices.join(' ')}'`);
} else if (opt.long === '--project') {
parts.push(`-xa '(__mcpctl_project_names)'`);
} else {
parts.push('-x');
}
}
emit(parts.join(' '));
}
emit('');
}
// --- Special argument completions ---
// logs: instance names
emit("# logs: takes a server/instance name");
emit("complete -c mcpctl -n \"__fish_seen_subcommand_from logs; and __mcpctl_needs_arg_for logs\" -a '(__mcpctl_instance_names)' -d 'Server name'");
emit('');
// console: project name
emit("# console: takes a project name");
emit("complete -c mcpctl -n \"__fish_seen_subcommand_from console; and __mcpctl_needs_arg_for console\" -a '(__mcpctl_project_names)' -d 'Project name'");
emit('');
// attach-server / detach-server
emit("# attach-server: show servers NOT in the project (only if no server arg yet)");
emit("complete -c mcpctl -n \"__fish_seen_subcommand_from attach-server; and __mcpctl_needs_server_arg\" -a '(__mcpctl_available_servers)' -d 'Server'");
emit('');
emit("# detach-server: show servers IN the project (only if no server arg yet)");
emit("complete -c mcpctl -n \"__fish_seen_subcommand_from detach-server; and __mcpctl_needs_server_arg\" -a '(__mcpctl_project_servers)' -d 'Server'");
emit('');
// apply: allow file completions for positional argument
emit("# apply: allow file completions for positional argument");
emit("complete -c mcpctl -n \"__fish_seen_subcommand_from apply\" -F");
emit('');
// help completions
emit('# help completions');
emit('complete -c mcpctl -n "__fish_seen_subcommand_from help" -a "$commands"');
return lines.join('\n') + '\n';
}
function emitFishHelpers(emit: (s: string) => void): void {
emit(`# Helper: check if --project or -p was given
function __mcpctl_has_project
set -l tokens (commandline -opc)
for i in (seq (count $tokens))
if test "$tokens[$i]" = "--project" -o "$tokens[$i]" = "-p"
return 0
end
end
return 1
end
`);
const aliasListStr = ALL_ALIASES_UNIQUE.join(' ');
const resCmdStr = RESOURCE_COMMANDS.join(' ');
emit(`# Resource type detection
set -l resources ${CANONICAL_RESOURCES.join(' ')}
function __mcpctl_needs_resource_type
set -l resource_aliases ${aliasListStr}
set -l tokens (commandline -opc)
set -l found_cmd false
for tok in $tokens
if $found_cmd
if contains -- $tok $resource_aliases
return 1 # resource type already present
end
end
if contains -- $tok ${resCmdStr}
set found_cmd true
end
end
if $found_cmd
return 0 # command found but no resource type yet
end
return 1
end
`);
emit(`# Map any resource alias to the canonical plural form for API calls
function __mcpctl_resolve_resource
switch $argv[1]`);
// Group aliases by canonical name
const groups = new Map<string, string[]>();
for (const canonical of CANONICAL_RESOURCES) {
groups.set(canonical, []);
}
for (const [alias, canonical] of ALIAS_ENTRIES) {
const g = groups.get(canonical) ?? [];
if (!g.includes(alias)) g.push(alias);
groups.set(canonical, g);
}
for (const [canonical, aliases] of groups) {
const allForms = [...new Set([...aliases, canonical])].join(' ');
emit(` case ${allForms};${' '.repeat(Math.max(1, 24 - allForms.length))}echo ${canonical}`);
}
emit(` case '*'; echo $argv[1]`);
emit(` end`);
emit(`end
`);
emit(`function __mcpctl_get_resource_type
set -l resource_aliases ${aliasListStr}
set -l tokens (commandline -opc)
set -l found_cmd false
for tok in $tokens
if $found_cmd
if contains -- $tok $resource_aliases
__mcpctl_resolve_resource $tok
return
end
end
if contains -- $tok ${resCmdStr}
set found_cmd true
end
end
end
`);
emit(`# Fetch resource names dynamically from the API
function __mcpctl_resource_names
set -l resource (__mcpctl_get_resource_type)
if test -z "$resource"
return
end
if test "$resource" = "instances"
mcpctl get instances -o json 2>/dev/null | jq -r '.[][].server.name' 2>/dev/null
else if test "$resource" = "prompts" -o "$resource" = "promptrequests"
mcpctl get $resource -A -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null
else
mcpctl get $resource -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null
end
end
`);
emit(`# Fetch project names for --project value
function __mcpctl_project_names
mcpctl get projects -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null
end
`);
emit(`# Helper: get the --project/-p value from the command line
function __mcpctl_get_project_value
set -l tokens (commandline -opc)
for i in (seq (count $tokens))
if test "$tokens[$i]" = "--project" -o "$tokens[$i]" = "-p"; and test $i -lt (count $tokens)
echo $tokens[(math $i + 1)]
return
end
end
end
`);
emit(`# Servers currently attached to the project (for detach-server)
function __mcpctl_project_servers
set -l proj (__mcpctl_get_project_value)
if test -z "$proj"
return
end
mcpctl --project $proj get servers -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null
end
`);
emit(`# Servers NOT attached to the project (for attach-server)
function __mcpctl_available_servers
set -l proj (__mcpctl_get_project_value)
if test -z "$proj"
mcpctl get servers -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null
return
end
set -l all (mcpctl get servers -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null)
set -l attached (mcpctl --project $proj get servers -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null)
for s in $all
if not contains -- $s $attached
echo $s
end
end
end
`);
emit(`# Instance names for logs
function __mcpctl_instance_names
mcpctl get instances -o json 2>/dev/null | jq -r '.[][].server.name' 2>/dev/null
end
`);
emit(`# Helper: check if a positional arg has been given for a specific command
function __mcpctl_needs_arg_for
set -l cmd $argv[1]
set -l tokens (commandline -opc)
set -l found false
for tok in $tokens
if $found
if not string match -q -- '-*' $tok
return 1 # arg already present
end
end
if test "$tok" = "$cmd"
set found true
end
end
if $found
return 0 # command found but no arg yet
end
return 1
end
`);
emit(`# Helper: check if attach-server/detach-server already has a server argument
function __mcpctl_needs_server_arg
set -l tokens (commandline -opc)
set -l found_cmd false
for tok in $tokens
if $found_cmd
if not string match -q -- '-*' $tok
return 1 # server arg already present
end
end
if contains -- $tok attach-server detach-server
set found_cmd true
end
end
if $found_cmd
return 0
end
return 1
end
`);
emit(`# Helper: check if a specific parent-child subcommand pair is active
function __mcpctl_subcmd_active
set -l parent $argv[1]
set -l child $argv[2]
set -l tokens (commandline -opc)
set -l found_parent false
for tok in $tokens
if $found_parent
if test "$tok" = "$child"
return 0
end
if not string match -q -- '-*' $tok
return 1 # different subcommand
end
end
if test "$tok" = "$parent"
set found_parent true
end
end
return 1
end
`);
}
// ============================================================
// Bash completion generator
// ============================================================
function generateBash(root: CmdInfo): string {
const lines: string[] = [];
const emit = (s: string) => lines.push(s);
emit('# mcpctl bash completions — auto-generated by scripts/generate-completions.ts');
emit('# DO NOT EDIT MANUALLY — run: pnpm completions:generate');
emit('');
// --- Categorize commands ---
const visibleCmds: CmdInfo[] = [];
const projectCmds: CmdInfo[] = [];
for (const cmd of root.subcommands) {
if (NEVER_SHOW_COMMANDS.has(cmd.name)) {
// Include mcp in case handler but not in top-level list
}
if (PROJECT_ONLY_COMMANDS.has(cmd.name)) {
projectCmds.push(cmd);
} else if (PROJECT_SCOPED_COMMANDS.has(cmd.name)) {
visibleCmds.push(cmd);
projectCmds.push(cmd);
} else if (!cmd.hidden) {
visibleCmds.push(cmd);
}
}
const visibleNames = visibleCmds.map((c) => c.name).join(' ');
const projectNames = projectCmds.map((c) => c.name).join(' ');
// Build global opts string from the commander tree
const globalOptParts: string[] = ['-v', '--version'];
for (const opt of root.options) {
if (opt.short) globalOptParts.push(opt.short);
globalOptParts.push(opt.long);
}
// Always include -h/--help
globalOptParts.push('-h', '--help');
emit('_mcpctl() {');
emit(' local cur prev words cword');
emit(' _init_completion || return');
emit('');
emit(` local commands="${visibleNames}"`);
emit(` local project_commands="${projectNames}"`);
emit(` local global_opts="${globalOptParts.join(' ')}"`);
emit(` local resources="${CANONICAL_RESOURCES.join(' ')}"`);
emit(` local resource_aliases="${ALL_ALIASES_UNIQUE.join(' ')}"`);
emit('');
// --- has_project check ---
emit(' # Check if --project/-p was given');
emit(' local has_project=false');
emit(' local i');
emit(' for ((i=1; i < cword; i++)); do');
emit(' if [[ "${words[i]}" == "--project" || "${words[i]}" == "-p" ]]; then');
emit(' has_project=true');
emit(' break');
emit(' fi');
emit(' done');
emit('');
// --- subcmd detection ---
emit(' # Find the first subcommand');
emit(' local subcmd=""');
emit(' local subcmd_pos=0');
emit(' for ((i=1; i < cword; i++)); do');
emit(' if [[ "${words[i]}" == "--project" || "${words[i]}" == "--daemon-url" || "${words[i]}" == "-p" ]]; then');
emit(' ((i++))');
emit(' continue');
emit(' fi');
emit(' if [[ "${words[i]}" != -* ]]; then');
emit(' subcmd="${words[i]}"');
emit(' subcmd_pos=$i');
emit(' break');
emit(' fi');
emit(' done');
emit('');
// --- resource_type detection ---
emit(' # Find the resource type after resource commands');
emit(' local resource_type=""');
emit(' if [[ -n "$subcmd_pos" ]] && [[ $subcmd_pos -gt 0 ]]; then');
emit(' for ((i=subcmd_pos+1; i < cword; i++)); do');
emit(' if [[ "${words[i]}" != -* ]] && [[ " $resource_aliases " == *" ${words[i]} "* ]]; then');
emit(' resource_type="${words[i]}"');
emit(' break');
emit(' fi');
emit(' done');
emit(' fi');
emit('');
// --- Helper functions ---
emit(' # Helper: get --project/-p value');
emit(' _mcpctl_get_project_value() {');
emit(' local i');
emit(' for ((i=1; i < cword; i++)); do');
emit(' if [[ "${words[i]}" == "--project" || "${words[i]}" == "-p" ]] && (( i+1 < cword )); then');
emit(' echo "${words[i+1]}"');
emit(' return');
emit(' fi');
emit(' done');
emit(' }');
emit('');
emit(' # Helper: fetch resource names');
emit(' _mcpctl_resource_names() {');
emit(' local rt="$1"');
emit(' if [[ -n "$rt" ]]; then');
emit(' if [[ "$rt" == "instances" ]]; then');
emit(" mcpctl get instances -o json 2>/dev/null | jq -r '.[][].server.name' 2>/dev/null");
emit(' else');
emit(" mcpctl get \"$rt\" -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null");
emit(' fi');
emit(' fi');
emit(' }');
emit('');
emit(' # Helper: find sub-subcommand (for config/create)');
emit(' _mcpctl_get_subcmd() {');
emit(' local parent_pos="$1"');
emit(' local i');
emit(' for ((i=parent_pos+1; i < cword; i++)); do');
emit(' if [[ "${words[i]}" != -* ]]; then');
emit(' echo "${words[i]}"');
emit(' return');
emit(' fi');
emit(' done');
emit(' }');
emit('');
// --- Option value completion ---
emit(' # If completing option values');
emit(' if [[ "$prev" == "--project" || "$prev" == "-p" ]]; then');
emit(' local names');
emit(" names=$(mcpctl get projects -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null)");
emit(' COMPREPLY=($(compgen -W "$names" -- "$cur"))');
emit(' return');
emit(' fi');
emit('');
// --- Case statement ---
emit(' case "$subcmd" in');
for (const cmd of root.subcommands) {
emitBashCase(emit, cmd, root);
}
// help command
emit(' help)');
emit(' COMPREPLY=($(compgen -W "$commands" -- "$cur"))');
emit(' return ;;');
emit(' esac');
emit('');
// --- Default (no subcommand) ---
emit(' # No subcommand yet — offer commands based on context');
emit(' if [[ -z "$subcmd" ]]; then');
emit(' if $has_project; then');
emit(' COMPREPLY=($(compgen -W "$project_commands $global_opts" -- "$cur"))');
emit(' else');
emit(' COMPREPLY=($(compgen -W "$commands $global_opts" -- "$cur"))');
emit(' fi');
emit(' fi');
emit('}');
emit('');
emit('complete -F _mcpctl mcpctl');
return lines.join('\n') + '\n';
}
function emitBashCase(emit: (s: string) => void, cmd: CmdInfo, root: CmdInfo): void {
const name = cmd.name;
// Collect all option flags for this command
const optFlags = bashOptFlags(cmd);
if (cmd.subcommands.length > 0) {
// Commands with subcommands (config, create)
const subNames = cmd.subcommands.map((s) => s.name);
emit(` ${name})`);
emit(` local ${name}_sub=$(_mcpctl_get_subcmd $subcmd_pos)`);
emit(` if [[ -z "$${name}_sub" ]]; then`);
emit(` COMPREPLY=($(compgen -W "${subNames.join(' ')} help" -- "$cur"))`);
emit(' else');
emit(` case "$${name}_sub" in`);
for (const sub of cmd.subcommands) {
const subOpts = bashOptFlags(sub);
emit(` ${sub.name})`);
emit(` COMPREPLY=($(compgen -W "${subOpts}" -- "$cur"))`);
emit(' ;;');
}
emit(' *)');
emit(' COMPREPLY=($(compgen -W "-h --help" -- "$cur"))');
emit(' ;;');
emit(' esac');
emit(' fi');
emit(' return ;;');
return;
}
// Resource-type commands (get, describe, delete, edit, patch, approve)
if (RESOURCE_COMMANDS.includes(name)) {
const hint = COMPLETION_HINTS[`${name}.resource`] ?? COMPLETION_HINTS[`${name}.${getFirstArgName(root, name)}`];
let resourceList = '$resources';
if (hint && typeof hint === 'object' && 'static' in hint) {
resourceList = hint.static.join(' ');
} else if (hint === 'resource_types') {
resourceList = '$resources';
}
emit(` ${name})`);
emit(' if [[ -z "$resource_type" ]]; then');
emit(` COMPREPLY=($(compgen -W "${resourceList} ${optFlags}" -- "$cur"))`);
emit(' else');
emit(' local names');
emit(' names=$(_mcpctl_resource_names "$resource_type")');
emit(` COMPREPLY=($(compgen -W "$names ${optFlags}" -- "$cur"))`);
emit(' fi');
emit(' return ;;');
return;
}
// logs: first arg is instance name
if (name === 'logs') {
emit(` ${name})`);
emit(' if [[ $((cword - subcmd_pos)) -eq 1 ]]; then');
emit(' local names');
emit(" names=$(mcpctl get instances -o json 2>/dev/null | jq -r '.[][].server.name' 2>/dev/null)");
emit(` COMPREPLY=($(compgen -W "$names ${optFlags}" -- "$cur"))`);
emit(' else');
emit(` COMPREPLY=($(compgen -W "${optFlags}" -- "$cur"))`);
emit(' fi');
emit(' return ;;');
return;
}
feat(agents): mcpctl chat REPL + agent CRUD + completions (Stage 5) This is the moment the user can actually talk to an agent end-to-end: mcpctl create llm qwen3-thinking --type openai --model qwen3-thinking \ --url http://litellm.nvidia-nim.svc.cluster.local:4000/v1 \ --api-key-ref litellm-key/API_KEY mcpctl create agent reviewer --llm qwen3-thinking --project mcpctl-dev \ --description "I review security design — ask me after each major change." mcpctl chat reviewer Pieces: * src/cli/src/commands/chat.ts (new) — REPL + one-shot. Streams the SSE endpoint and prints text deltas to stdout as they arrive; tool_call / tool_result events go to stderr in dim-style brackets so the chat output stays clean. LiteLLM-style flags (--temperature / --top-p / --top-k / --max-tokens / --seed / --stop / --allow-tool / --extra) layer over agent.defaultParams. In-REPL slash-commands: /set KEY VAL, /system <text>, /tools (list project's MCP servers), /clear (new thread), /save (PATCH agent.defaultParams = current overrides), /quit. * src/cli/src/commands/create.ts — `create agent` mirroring the llm pattern. Every yaml-applyable field has a corresponding flag (memory rule); --default-temperature / --default-top-p / --default-top-k / --default-max-tokens / --default-seed / --default-stop / --default-extra / --default-params-file all populate agent.defaultParams. * src/cli/src/commands/apply.ts — AgentSpecSchema accepts both `llm: qwen3-thinking` shorthand and `llm: { name: ... }` long form; runs after llms in the apply order so apiKey/llm references resolve. Round- trips with `get agent foo -o yaml | apply -f -` (memory rule). * src/cli/src/commands/get.ts — agentColumns (NAME, LLM, PROJECT, DESCRIPTION, ID); RESOURCE_KIND mapping for yaml export. * src/cli/src/commands/shared.ts — `agent`/`agents`/`thread`/`threads` added to RESOURCE_ALIASES. * src/cli/src/index.ts — wires createChatCommand into the program; passes the resolved baseUrl + token so chat can stream SSE without going through ApiClient (which only does buffered request/response). * completions/mcpctl.{fish,bash} regenerated. scripts/generate-completions.ts knows about agents (canonical + aliases) and emits a special-case `chat)` block that completes the first arg with `mcpctl get agents` names. tests/completions.test.ts: +9 new assertions covering agents in the resource list, chat in the commands list, --llm flag for create agent, agent-name completion for chat, etc. CLI suite: 430/430 (was 421). Completions --check is clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:02:38 +01:00
// chat: first arg is agent name
if (name === 'chat') {
emit(` ${name})`);
emit(' if [[ $((cword - subcmd_pos)) -eq 1 ]]; then');
emit(' local names');
emit(' names=$(_mcpctl_resource_names "agents")');
emit(` COMPREPLY=($(compgen -W "$names ${optFlags}" -- "$cur"))`);
emit(' else');
emit(` COMPREPLY=($(compgen -W "${optFlags}" -- "$cur"))`);
emit(' fi');
emit(' return ;;');
return;
}
// console: first arg is project name
if (name === 'console') {
emit(` ${name})`);
emit(' if [[ $((cword - subcmd_pos)) -eq 1 ]]; then');
emit(' local names');
emit(" names=$(mcpctl get projects -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null)");
emit(` COMPREPLY=($(compgen -W "$names ${optFlags}" -- "$cur"))`);
emit(' else');
emit(` COMPREPLY=($(compgen -W "${optFlags}" -- "$cur"))`);
emit(' fi');
emit(' return ;;');
return;
}
// attach-server
if (name === 'attach-server') {
emit(' attach-server)');
emit(' if [[ $((cword - subcmd_pos)) -ne 1 ]]; then return; fi');
emit(' local proj names all_servers proj_servers');
emit(' proj=$(_mcpctl_get_project_value)');
emit(' if [[ -n "$proj" ]]; then');
emit(" all_servers=$(mcpctl get servers -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null)");
emit(" proj_servers=$(mcpctl --project \"$proj\" get servers -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null)");
emit(' names=$(comm -23 <(echo "$all_servers" | sort) <(echo "$proj_servers" | sort))');
emit(' else');
emit(' names=$(_mcpctl_resource_names "servers")');
emit(' fi');
emit(' COMPREPLY=($(compgen -W "$names" -- "$cur"))');
emit(' return ;;');
return;
}
// detach-server
if (name === 'detach-server') {
emit(' detach-server)');
emit(' if [[ $((cword - subcmd_pos)) -ne 1 ]]; then return; fi');
emit(' local proj names');
emit(' proj=$(_mcpctl_get_project_value)');
emit(' if [[ -n "$proj" ]]; then');
emit(" names=$(mcpctl --project \"$proj\" get servers -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null)");
emit(' fi');
emit(' COMPREPLY=($(compgen -W "$names" -- "$cur"))');
emit(' return ;;');
return;
}
// apply: file completions
if (name === 'apply') {
emit(' apply)');
emit(` COMPREPLY=($(compgen -f -W "${optFlags}" -- "$cur"))`);
emit(' return ;;');
return;
}
// mcp: hidden but still handle
if (name === 'mcp') {
emit(' mcp)');
emit(` COMPREPLY=($(compgen -W "${optFlags}" -- "$cur"))`);
emit(' return ;;');
return;
}
// Generic command with options only
emit(` ${name})`);
emit(` COMPREPLY=($(compgen -W "${optFlags}" -- "$cur"))`);
emit(' return ;;');
}
/** Build the option flags string for a command (for bash COMPREPLY). */
function bashOptFlags(cmd: CmdInfo): string {
const parts: string[] = [];
for (const opt of cmd.options) {
if (opt.short) parts.push(opt.short);
parts.push(opt.long);
}
parts.push('-h', '--help');
return parts.join(' ');
}
// ============================================================
// Utilities
// ============================================================
/** Escape single quotes in fish strings. */
function esc(s: string): string {
return s.replace(/'/g, "\\'");
}
/** Get the first argument name for a command. */
function getFirstArgName(root: CmdInfo, cmdName: string): string {
const cmd = root.subcommands.find((c) => c.name === cmdName);
return cmd?.args[0]?.name ?? '';
}
// ============================================================
// Main
// ============================================================
async function main(): Promise<void> {
const mode = process.argv[2] ?? '';
let tree: CmdInfo;
try {
tree = await extractTree();
} catch (err) {
console.error('Failed to extract command tree from createProgram().');
console.error('Make sure workspace packages are built: pnpm build');
console.error(err);
process.exit(1);
}
const fishContent = generateFish(tree);
const bashContent = generateBash(tree);
const fishPath = join(ROOT, 'completions', 'mcpctl.fish');
const bashPath = join(ROOT, 'completions', 'mcpctl.bash');
if (mode === '--check') {
let stale = false;
try {
const currentFish = readFileSync(fishPath, 'utf-8');
if (currentFish !== fishContent) {
console.error('completions/mcpctl.fish is stale');
stale = true;
}
} catch {
console.error('completions/mcpctl.fish does not exist');
stale = true;
}
try {
const currentBash = readFileSync(bashPath, 'utf-8');
if (currentBash !== bashContent) {
console.error('completions/mcpctl.bash is stale');
stale = true;
}
} catch {
console.error('completions/mcpctl.bash does not exist');
stale = true;
}
if (stale) {
console.error('Run: pnpm completions:generate');
process.exit(1);
}
console.log('Completions are up to date.');
process.exit(0);
}
if (mode === '--write') {
writeFileSync(fishPath, fishContent);
writeFileSync(bashPath, bashContent);
console.log(`Wrote ${fishPath}`);
console.log(`Wrote ${bashPath}`);
process.exit(0);
}
// Default: print to stdout
console.log('=== completions/mcpctl.fish ===');
console.log(fishContent);
console.log('=== completions/mcpctl.bash ===');
console.log(bashContent);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});