All checks were successful
CI/CD / typecheck (pull_request) Successful in 51s
CI/CD / lint (pull_request) Successful in 1m47s
CI/CD / test (pull_request) Successful in 1m3s
CI/CD / smoke (pull_request) Successful in 4m34s
CI/CD / build (pull_request) Successful in 3m50s
CI/CD / publish (pull_request) Has been skipped
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>
1072 lines
36 KiB
TypeScript
1072 lines
36 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'
|
|
| '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'] },
|
|
'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', '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'],
|
|
['secretbackend', 'secretbackends'], ['sb', 'secretbackends'],
|
|
['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;
|
|
}
|
|
|
|
// 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);
|
|
});
|