/** * Shell-completion generation (bash, zsh, fish). * * Single source of truth: the declarative `flags`/`args` descriptors carried by * each `Command` subclass plus the registered subcommand table. {@link buildSpec} * walks that metadata — the same data `renderCommandBody` renders for `--help` — * and {@link generateCompletion} emits a self-contained completion script. Adding * a flag to a command's static `flags` therefore propagates into completions with * no edits here. * * Static candidates (enum `options`, the builtin tool list) are baked into the * script. A small set of flags resolve dynamic candidates (the live model * catalog and on-disk sessions) by calling back into ` __complete ` * — see `commands/complete.ts`. The flag→source mapping below is the only manual * knob and is keyed by flag name so it stays stable as flags are added. */ import type { ArgDescriptor, CliConfig, CommandCtor, FlagDescriptor } from "@oh-my-pi/pi-utils/cli"; import { BUILTIN_TOOL_NAMES } from "../tools/builtin-names"; export type Shell = "bash" | "zsh" | "fish"; /** How a flag/positional value should be completed. */ export type ValueSource = | { kind: "flag" } // boolean — takes no value | { kind: "value" } // takes a value with no completable candidates (e.g. integer, free text) | { kind: "enum"; values: readonly string[] } // static single value | { kind: "list"; values: readonly string[] } // static comma-separated list | { kind: "models"; multiple: boolean } // dynamic: live model catalog | { kind: "sessions" } // dynamic: on-disk sessions | { kind: "file" } | { kind: "dir" }; export interface CompletionFlag { /** Long name without the leading `--`. */ name: string; /** Short character without the leading `-`. */ char?: string; description: string; value: ValueSource; /** Flag may appear multiple times (oclif `multiple`). */ repeatable: boolean; } export interface CompletionArg { name: string; description: string; value: ValueSource; } export interface CompletionCommand { name: string; aliases: readonly string[]; description: string; flags: CompletionFlag[]; args: CompletionArg[]; } export interface CompletionSpec { bin: string; /** Flags/args of the default (no-subcommand) command. */ root: { flags: CompletionFlag[]; args: CompletionArg[] }; commands: CompletionCommand[]; } // --- Flag/arg value classification (the single manual mapping) ---------------- /** Single-value flags resolved against the live model catalog. */ const MODEL_FLAGS: Record = { model: true, smol: true, slow: true, plan: true }; /** Single-value flags resolved against on-disk sessions. */ const SESSION_FLAGS: Record = { resume: true, fork: true, session: true }; /** Flags whose value is a directory path. */ const DIR_FLAGS: Record = { "session-dir": true, "plugin-dir": true }; function flagValue(name: string, desc: FlagDescriptor): ValueSource { if (desc.kind === "boolean") return { kind: "flag" }; if (desc.options && desc.options.length > 0) return { kind: "enum", values: desc.options }; if (MODEL_FLAGS[name]) return { kind: "models", multiple: false }; if (name === "models") return { kind: "models", multiple: true }; if (SESSION_FLAGS[name]) return { kind: "sessions" }; if (name === "tools") return { kind: "list", values: BUILTIN_TOOL_NAMES }; if (DIR_FLAGS[name]) return { kind: "dir" }; if (desc.kind === "integer") return { kind: "value" }; return { kind: "file" }; } function argValue(desc: ArgDescriptor): ValueSource { if (desc.options && desc.options.length > 0) return { kind: "enum", values: desc.options }; return { kind: "file" }; } function buildFlags(Cmd: CommandCtor): CompletionFlag[] { const out: CompletionFlag[] = []; const flags = Cmd.flags ?? {}; for (const name in flags) { const desc = flags[name]; out.push({ name, char: desc.char, description: desc.description ?? "", value: flagValue(name, desc), repeatable: Boolean(desc.multiple), }); } return out; } function buildArgs(Cmd: CommandCtor): CompletionArg[] { const out: CompletionArg[] = []; const args = Cmd.args ?? {}; for (const name in args) { const desc = args[name]; out.push({ name, description: desc.description ?? "", value: argValue(desc) }); } return out; } /** * Build a {@link CompletionSpec} from loaded command classes. * * @param rootName Entry name of the default command (its flags become top-level * flags; it is excluded from the subcommand list). * @param aliasMap Canonical-name → aliases (merged from the registration table * and the command class's static `aliases`). */ export function buildSpec( config: CliConfig, rootName: string, aliasMap: Map, ): CompletionSpec { const commands: CompletionCommand[] = []; let root: CompletionSpec["root"] = { flags: [], args: [] }; for (const [name, Cmd] of config.commands) { const flags = buildFlags(Cmd); const args = buildArgs(Cmd); if (name === rootName) { root = { flags, args }; continue; } if (Cmd.hidden) continue; commands.push({ name, aliases: aliasMap.get(name) ?? [], description: Cmd.description ?? "", flags, args, }); } commands.sort((a, b) => a.name.localeCompare(b.name)); return { bin: config.bin, root, commands }; } // --- Shared helpers ----------------------------------------------------------- /** Every value source except a bare boolean flag consumes the following token. */ function takesValue(v: ValueSource): boolean { return v.kind !== "flag"; } /** All token forms (`name` + aliases) under which a subcommand can be invoked. */ function commandTokens(c: CompletionCommand): string[] { return [c.name, ...c.aliases]; } export function generateCompletion(shell: Shell, spec: CompletionSpec): string { switch (shell) { case "bash": return generateBash(spec); case "zsh": return generateZsh(spec); case "fish": return generateFish(spec); } } // --- bash --------------------------------------------------------------------- /** Escape for use inside a bash double-quoted `compgen -W "…"` word list. */ function bashWords(values: readonly string[]): string { return values.join(" ").replace(/"/g, '\\"'); } /** bash snippet that fills COMPREPLY for a flag value, then `return 0`. */ function bashValueBranch(bin: string, v: ValueSource): string { switch (v.kind) { case "flag": case "value": return "return 0"; case "enum": return `COMPREPLY=( $(compgen -W "${bashWords(v.values)}" -- "$cur") ); return 0`; case "list": return `_omp_comma "${bashWords(v.values)}"; return 0`; case "models": return v.multiple ? `_omp_comma "$(command ${bin} __complete models 2>/dev/null | cut -f1)"; return 0` : `COMPREPLY=( $(compgen -W "$(command ${bin} __complete models -- "$cur" 2>/dev/null | cut -f1)" -- "$cur") ); return 0`; case "sessions": return `COMPREPLY=( $(compgen -W "$(command ${bin} __complete sessions -- "$cur" 2>/dev/null | cut -f1)" -- "$cur") ); return 0`; case "file": return `COMPREPLY=( $(compgen -f -- "$cur") ); compopt -o filenames; return 0`; case "dir": return `COMPREPLY=( $(compgen -d -- "$cur") ); compopt -o filenames; return 0`; } } /** Build the `case "$prev" in …` arms for every value-taking flag in scope. */ function bashFlagCase(bin: string, flags: CompletionFlag[]): string { const lines: string[] = []; for (const f of flags) { if (!takesValue(f.value)) continue; const labels = [`--${f.name}`, ...(f.char ? [`-${f.char}`] : [])]; lines.push(`\t\t${labels.join("|")})\n\t\t\t${bashValueBranch(bin, f.value)}\n\t\t\t;;`); } return lines.join("\n"); } function bashFlagWords(flags: CompletionFlag[]): string { const words: string[] = []; for (const f of flags) { words.push(`--${f.name}`); if (f.char) words.push(`-${f.char}`); } return words.join(" "); } function generateBash(spec: CompletionSpec): string { const { bin } = spec; const parts: string[] = []; parts.push(`# bash completion for ${bin} — generated by \`${bin} completions bash\``); parts.push(""); // Comma-aware static/dynamic list completion helper. parts.push(`_omp_comma() { local words="$1" realcur prefix realcur="\${cur##*,}" prefix="\${cur%"$realcur"}" local -a matches matches=( $(compgen -W "$words" -- "$realcur") ) local i for (( i=0; i < \${#matches[@]}; i++ )); do matches[i]="$prefix\${matches[i]}"; done COMPREPLY=( "\${matches[@]}" ) compopt -o nospace 2>/dev/null }`); parts.push(""); // Root handler: top-level flags + subcommand names. const subTokens = spec.commands.flatMap(commandTokens).sort(); parts.push(`_omp_root() { case "$prev" in ${bashFlagCase(bin, spec.root.flags)} esac if [[ "$cur" == -* ]]; then COMPREPLY=( $(compgen -W "${bashFlagWords(spec.root.flags)}" -- "$cur") ) else COMPREPLY=( $(compgen -W "${bashWords(subTokens)} ${bashFlagWords(spec.root.flags)}" -- "$cur") ) fi }`); parts.push(""); // Per-subcommand handlers. for (const c of spec.commands) { const argEnum = c.args.find(a => a.value.kind === "enum"); const argWords = argEnum && argEnum.value.kind === "enum" ? bashWords(argEnum.value.values) : ""; const fileArg = c.args.some(a => a.value.kind === "file"); const elseBranch = argWords ? `COMPREPLY=( $(compgen -W "${argWords}" -- "$cur") )` : fileArg ? `COMPREPLY=( $(compgen -f -- "$cur") ); compopt -o filenames` : ":"; parts.push(`_omp_cmd_${bashFn(c.name)}() { case "$prev" in ${bashFlagCase(bin, c.flags)} esac if [[ "$cur" == -* ]]; then COMPREPLY=( $(compgen -W "${bashFlagWords(c.flags)}" -- "$cur") ) else ${elseBranch} fi }`); parts.push(""); } // Dispatcher. const dispatch: string[] = []; for (const c of spec.commands) { dispatch.push(`\t\t${commandTokens(c).join("|")})\n\t\t\t_omp_cmd_${bashFn(c.name)}\n\t\t\t;;`); } parts.push(`_omp() { local cur prev cmd i cur="\${COMP_WORDS[COMP_CWORD]}" prev="\${COMP_WORDS[COMP_CWORD-1]}" cmd="" for (( i=1; i < COMP_CWORD; i++ )); do case "\${COMP_WORDS[i]}" in -*) ;; *) cmd="\${COMP_WORDS[i]}"; break ;; esac done case "$cmd" in ${dispatch.join("\n")} *) _omp_root ;; esac } complete -F _omp ${bin}`); parts.push(""); return `${parts.join("\n")}\n`; } function bashFn(name: string): string { return name.replace(/[^A-Za-z0-9]/g, "_"); } // --- zsh ---------------------------------------------------------------------- /** Sanitize a description for embedding in a single-quoted zsh `_arguments` spec. */ function zshDesc(s: string): string { return s .replace(/'/g, "’") .replace(/\[/g, "(") .replace(/\]/g, ")") .replace(/[\r\n]+/g, " ") .replace(/:/g, " ") .trim(); } function zshAction(v: ValueSource): string { switch (v.kind) { case "flag": return ""; case "value": return ":value:"; case "enum": return `:value:(${v.values.join(" ")})`; case "list": return ":value:_omp_tools"; case "models": return v.multiple ? ":models:_omp_models_list" : ":model:_omp_call models"; case "sessions": return ":session:_omp_call sessions"; case "file": return ":file:_files"; case "dir": return ":dir:_files -/"; } } function zshFlagSpec(f: CompletionFlag): string { const body = `[${zshDesc(f.description)}]${zshAction(f.value)}`; if (f.char && f.repeatable) return `'*'{-${f.char},--${f.name}}'${body}'`; if (f.char) return `'(-${f.char} --${f.name})'{-${f.char},--${f.name}}'${body}'`; if (f.repeatable) return `'*--${f.name}${body}'`; return `'--${f.name}${body}'`; } function zshArgSpec(f: CompletionArg): string { switch (f.value.kind) { case "enum": return `':${f.name}:(${f.value.values.join(" ")})'`; default: return `':${f.name}:_files'`; } } function generateZsh(spec: CompletionSpec): string { const { bin } = spec; // The `:value:_omp_tools` action references this helper; bake its candidates // from the spec's `list` flag so the generator stays a pure function of its // input (bash/fish read `v.values` inline for the same reason). const listFlag = [...spec.root.flags, ...spec.commands.flatMap(c => c.flags)].find(f => f.value.kind === "list"); const toolNames = listFlag?.value.kind === "list" ? listFlag.value.values.join(" ") : ""; const parts: string[] = []; parts.push(`#compdef ${bin}`); parts.push(`# zsh completion for ${bin} — generated by \`${bin} completions zsh\``); parts.push(""); // Dynamic helpers (single source: ` __complete ` → valuedesc). parts.push(`_omp_call() { local kind=$1 local -a items local line for line in "\${(@f)$(command ${bin} __complete $kind -- "$PREFIX" 2>/dev/null)}"; do [[ -z $line ]] && continue items+=( "\${line//$'\\t'/:}" ) done _describe -t "$kind" "$kind" items } _omp_models_list() { local -a items local line for line in "\${(@f)$(command ${bin} __complete models 2>/dev/null)}"; do [[ -z $line ]] && continue items+=( "\${line%%$'\\t'*}" ) done _values -s , 'models' $items } _omp_tools() { _values -s , 'tools' ${toolNames} }`); parts.push(""); // Subcommand description table. const cmdRows = spec.commands.map(c => `\t\t'${c.name}:${zshDesc(c.description)}'`).join("\n"); parts.push(`_omp_commands() { local -a commands commands=( ${cmdRows} ) _describe -t commands 'command' commands }`); parts.push(""); // Per-subcommand argument functions. for (const c of spec.commands) { const specs = ["'(-h --help)'{-h,--help}'[Show help]'", ...c.flags.map(zshFlagSpec), ...c.args.map(zshArgSpec)]; parts.push(`_omp_cmd_${bashFn(c.name)}() { _arguments -s \\ ${specs.join(" \\\n\t\t")} }`); parts.push(""); } // Top-level dispatch. const aliasArms = spec.commands .map(c => `\t\t\t${commandTokens(c).join("|")}) _omp_cmd_${bashFn(c.name)} ;;`) .join("\n"); const rootSpecs = [ "'(-h --help)'{-h,--help}'[Show help]'", "'(-v --version)'{-v,--version}'[Show version]'", ...spec.root.flags.map(zshFlagSpec), "'1: :_omp_commands'", "'*::arg:->args'", ]; parts.push(`_omp() { local curcontext="$curcontext" state line typeset -A opt_args _arguments -C -s \\ ${rootSpecs.join(" \\\n\t\t")} case $state in args) case $line[1] in ${aliasArms} esac ;; esac } # Works both ways: autoloaded from $fpath (file named _omp) or eval'd from a # startup file. When autoloaded, funcstack[1] is _omp and we invoke it; when # sourced/eval'd we register it with compdef instead. if [ "$funcstack[1]" = "_omp" ]; then _omp "$@" else compdef _omp ${bin} fi`); parts.push(""); return `${parts.join("\n")}\n`; } // --- fish --------------------------------------------------------------------- function fishDesc(s: string): string { return s .replace(/'/g, "’") .replace(/[\r\n]+/g, " ") .trim(); } function fishValue(bin: string, v: ValueSource): string { switch (v.kind) { case "flag": return ""; case "value": return "-x"; case "enum": case "list": return `-x -a '${v.values.join(" ")}'`; case "models": return `-x -a '(command ${bin} __complete models -- (commandline -ct))'`; case "sessions": return `-x -a '(command ${bin} __complete sessions -- (commandline -ct))'`; case "file": return "-r -F"; case "dir": return "-x -a '(__fish_complete_directories (commandline -ct))'"; } } function fishFlagLine(bin: string, cond: string, f: CompletionFlag): string { const segs = [`complete -c ${bin}`, `-n '${cond}'`]; if (f.char) segs.push(`-s ${f.char}`); segs.push(`-l ${f.name}`); if (f.description) segs.push(`-d '${fishDesc(f.description)}'`); const val = fishValue(bin, f.value); if (val) segs.push(val); return segs.join(" "); } function generateFish(spec: CompletionSpec): string { const { bin } = spec; const lines: string[] = []; lines.push(`# fish completion for ${bin} — generated by \`${bin} completions fish\``); lines.push(""); const allTokens = spec.commands.flatMap(commandTokens); lines.push(`function __fish_omp_no_subcommand`); lines.push(`\tfor i in (commandline -opc)`); lines.push(`\t\tif contains -- $i ${allTokens.join(" ")}`); lines.push(`\t\t\treturn 1`); lines.push(`\t\tend`); lines.push(`\tend`); lines.push(`\treturn 0`); lines.push(`end`); lines.push(""); const rootCond = "__fish_omp_no_subcommand"; // Subcommand names. for (const c of spec.commands) { for (const token of commandTokens(c)) { lines.push(`complete -c ${bin} -f -n '${rootCond}' -a '${token}' -d '${fishDesc(c.description)}'`); } } lines.push(""); // Top-level flags. for (const f of spec.root.flags) { lines.push(fishFlagLine(bin, rootCond, f)); } lines.push(""); // Per-subcommand flags and positional args. for (const c of spec.commands) { const cond = `__fish_seen_subcommand_from ${commandTokens(c).join(" ")}`; for (const f of c.flags) { lines.push(fishFlagLine(bin, cond, f)); } // Positionals: fish conditions can't gate on position, so emit enum // candidates (if any) and otherwise a single file completion — never both, // and never duplicated across multiple file-typed positionals. const enumArgs = c.args.filter(a => a.value.kind === "enum"); if (enumArgs.length > 0) { for (const a of enumArgs) { if (a.value.kind !== "enum") continue; lines.push( `complete -c ${bin} -f -n '${cond}' -a '${a.value.values.join(" ")}' -d '${fishDesc(a.description)}'`, ); } } else if (c.args.some(a => a.value.kind === "file")) { lines.push(`complete -c ${bin} -F -n '${cond}'`); } } lines.push(""); return `${lines.join("\n")}\n`; }