Files
mcpctl/scripts/generate-completions.ts
Michal 70bc7f1598 Merge origin/main into feat/agentic-teams
124 commits of main against 33 of agentic-teams. 22 files overlapped, 7
conflicted. Three needed real reconciliation rather than a side-pick:

**The `volumes` collision.** Both branches independently added a `volumes`
field to ContainerSpec meaning different things: main's is persistent storage
keyed by a server-derived `claimName` that deliberately outlives an instance;
mine is emptyDir scratch shared between an init container and the main
container. Worse, each emitted the pod-level `volumes` list from its own
helper, so spreading both would have silently kept only the last — a server
with a PVC would have lost it the moment a session scratch volume appeared.

Resolved by renaming the unreleased side: mine is now `scratchVolumes`, main's
keeps `volumes` since it is already shipped and in use. `buildPodVolumes` now
owns the whole pod-level list and emits both kinds; `buildPodExtras` is init
containers only. fsGroup/securityContext stays tied to the PVC case, because
an emptyDir is created writable by the container's own user and chowning it
is work with no purpose. Five tests pin the coexistence so a later change
cannot drop one kind again.

**The extracted permission mapper.** I moved `mapUrlToPermission` out of
main.ts into middleware/url-permission.ts so it could be tested; main kept it
inline and kept editing it. Kept the extraction and ported main's one change
across — the `/api/v1/audit/events/purge` mapping onto the existing
`audit-purge` operation. Verified rule-by-rule that the extracted version is a
superset: all 15 of main's rules plus the 5 agentic-teams ones.

**The NUL byte in create.ts.** Both branches fixed the same defect
independently, mine as `''` and main's as `'\0'`. Took main's.

The rest were one-liners: .gitignore comment wording, and an add/add on
proxy-long-running.test.ts where main's copy carries a progressive-delivery
test mine lacked — took main's in full.

Not changed, but worth flagging: main's generateDeploymentSpec hardcodes
`automountServiceAccountToken: false` while generatePodSpec honours
`spec.automountServiceAccountToken`. That asymmetry is main's own and predates
this merge, so it is left exactly as-is rather than altered inside a merge
commit.

Verified: 3138 tests pass (237 files), build clean, prisma schema valid.
Lint 809/80 vs 794/77 pre-merge — the delta is main's own debt; every file
touched during conflict resolution was checked and introduces no new errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
2026-09-06 23:40:04 +01:00

1215 lines
42 KiB
TypeScript

#!/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'
| 'available_agents'
| 'project_agents'
| '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',
'attach-agent.agent-name': 'available_agents',
'detach-agent.agent-name': 'project_agents',
'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', 'attach-agent', 'detach-agent', 'favourites',
]);
/** Commands that appear in BOTH project and non-project context. */
const PROJECT_SCOPED_COMMANDS = new Set([
'get', 'describe', 'delete', 'logs', 'create', 'edit', 'help', 'trace',
]);
/** 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', 'inference-tasks',
// Agentic teams
'epics', 'epictasks', 'tasks', 'repos', 'gitproviders', 'memberships', 'all',
];
const ALIAS_ENTRIES: [string, string][] = [
['server', 'servers'], ['srv', 'servers'],
['instance', 'instances'], ['inst', 'instances'],
['secret', 'secrets'], ['sec', 'secrets'],
['secretbackend', 'secretbackends'], ['sb', 'secretbackends'],
['llm', 'llms'], ['llms', 'llms'],
['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'],
// v5 shipped the inference queue under the short `task`/`tasks` aliases.
// Agentic-teams work items own those now (see shared.ts); the queue keeps
// only its explicit spellings plus an `itask` shorthand.
['inference-task', 'inference-tasks'], ['inference-tasks', 'inference-tasks'],
['itask', 'inference-tasks'], ['itasks', 'inference-tasks'],
// Agentic teams
['epic', 'epics'], ['epics', 'epics'],
['epictask', 'epictasks'], ['epictasks', 'epictasks'], ['et', 'epictasks'],
['task', 'tasks'], ['tasks', 'tasks'],
['repo', 'repos'], ['repos', 'repos'], ['repository', 'repos'], ['repositories', 'repos'],
['gitprovider', 'gitproviders'], ['gitproviders', 'gitproviders'],
['forge', 'gitproviders'], ['gp', 'gitproviders'],
['membership', 'memberships'], ['memberships', 'memberships'], ['team', 'memberships'],
['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('');
// attach-agent / detach-agent — the AgentProject team roster
emit("# attach-agent: show agents NOT yet on the team (only if no agent arg yet)");
emit("complete -c mcpctl -n \"__fish_seen_subcommand_from attach-agent; and __mcpctl_needs_agent_arg\" -a '(__mcpctl_available_agents)' -d 'Agent'");
emit('');
emit("# detach-agent: show agents ON the team (only if no agent arg yet)");
emit("complete -c mcpctl -n \"__fish_seen_subcommand_from detach-agent; and __mcpctl_needs_agent_arg\" -a '(__mcpctl_project_agents)' -d 'Agent'");
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(`# Agents already on the project team (for detach-agent)
function __mcpctl_project_agents
set -l proj (__mcpctl_get_project_value)
if test -z "$proj"
return
end
mcpctl --project $proj get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null
end
`);
emit(`# Agents NOT yet on the project team (for attach-agent)
function __mcpctl_available_agents
set -l proj (__mcpctl_get_project_value)
if test -z "$proj"
mcpctl get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null
return
end
set -l all (mcpctl get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null)
set -l joined (mcpctl --project $proj get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null)
for a in $all
if not contains -- $a $joined
echo $a
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 attach-agent/detach-agent already has an agent argument
function __mcpctl_needs_agent_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 # agent arg already present
end
end
if contains -- $tok attach-agent detach-agent
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;
}
// 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;
}
// chat-llm: first arg is LLM name
if (name === 'chat-llm') {
emit(` ${name})`);
emit(' if [[ $((cword - subcmd_pos)) -eq 1 ]]; then');
emit(' local names');
emit(' names=$(_mcpctl_resource_names "llms")');
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;
}
// attach-agent — the team roster, minus whoever already joined
if (name === 'attach-agent') {
emit(' attach-agent)');
emit(' if [[ $((cword - subcmd_pos)) -ne 1 ]]; then');
emit(` COMPREPLY=($(compgen -W "${optFlags}" -- "$cur"))`);
emit(' return');
emit(' fi');
emit(' local proj names all_agents proj_agents');
emit(' proj=$(_mcpctl_get_project_value)');
emit(' if [[ -n "$proj" ]]; then');
emit(" all_agents=$(mcpctl get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null)");
emit(" proj_agents=$(mcpctl --project \"$proj\" get agents -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null)");
emit(' names=$(comm -23 <(echo "$all_agents" | sort) <(echo "$proj_agents" | sort))');
emit(' else');
emit(' names=$(_mcpctl_resource_names "agents")');
emit(' fi');
emit(' COMPREPLY=($(compgen -W "$names" -- "$cur"))');
emit(' return ;;');
return;
}
// detach-agent — only agents already on the team
if (name === 'detach-agent') {
emit(' detach-agent)');
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 agents -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);
});