1070 lines
36 KiB
TypeScript
1070 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', '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<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);
|
||
|
|
});
|