#!/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 = { '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'] }, '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).short || undefined, long: (opt as unknown as Record).long, description: opt.description, takesValue: (opt as unknown as Record).required || (opt as unknown as Record).optional || false, choices: (opt as unknown as Record).argChoices || undefined, negate: (opt as unknown as Record).negate || false, }; } function extractArgument(arg: Argument): ArgInfo { return { name: (arg as unknown as Record)._name ?? arg.name(), description: arg.description, required: (arg as unknown as Record).required, variadic: (arg as unknown as Record).variadic, choices: (arg as unknown as Record)._choices || undefined, }; } function extractCommand(cmd: Command): CmdInfo { const options = (cmd.options as Option[]) .filter((o) => { const long = (o as unknown as Record).long; // Skip --help and --version (handled globally) return long !== '--help' && long !== '--version'; }) .map(extractOption); const args = ((cmd as unknown as Record).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)._hidden ?? false, options, args, subcommands, }; } async function extractTree(): Promise { // 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', 'templates', 'projects', 'users', 'groups', 'rbac', 'prompts', 'promptrequests', 'serverattachments', 'all', ]; const ALIAS_ENTRIES: [string, string][] = [ ['server', 'servers'], ['srv', 'servers'], ['instance', 'instances'], ['inst', 'instances'], ['secret', 'secrets'], ['sec', 'secrets'], ['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'], ['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(); 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; } // 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 { 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); });