From d73393cf5cf56716a1a758a476de1116ede982dc Mon Sep 17 00:00:00 2001 From: can1357 Date: Sat, 30 May 2026 21:32:03 +0200 Subject: [PATCH] feat(cli): added shell completions for bash, zsh, and fish - Added `omp completions ` command generating scripts from live command/flag metadata. - Added hidden `omp __complete` helper for dynamic model and session candidates. - Completions never drift from the CLI: flags, enums, and subcommands are derived from static descriptors. --- README.md | 15 + packages/coding-agent/CHANGELOG.md | 8 + packages/coding-agent/src/cli-commands.ts | 2 + .../coding-agent/src/cli/completion-gen.ts | 550 ++++++++++++++++++ .../coding-agent/src/commands/complete.ts | 66 +++ .../coding-agent/src/commands/completions.ts | 60 ++ .../coding-agent/test/cli/completions.test.ts | 228 ++++++++ 7 files changed, 929 insertions(+) create mode 100644 packages/coding-agent/src/cli/completion-gen.ts create mode 100644 packages/coding-agent/src/commands/complete.ts create mode 100644 packages/coding-agent/src/commands/completions.ts create mode 100644 packages/coding-agent/test/cli/completions.test.ts diff --git a/README.md b/README.md index 79b3d4777..fcf77bf70 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,21 @@ mise use -g github:can1357/oh-my-pi macOS · Linux · Windows · bun ≥ 1.3.14 +### Shell completions + +`omp` generates its own completion scripts for **bash**, **zsh**, and **fish** from the live command/flag metadata, so they never drift from the actual CLI. Subcommands, flags, and enum values complete statically; model names (`--model`, `--smol`, `--slow`, `--plan`) resolve against the bundled model catalog and `--resume` against your on-disk sessions. + +```sh +# zsh — add to ~/.zshrc (or write the output into a file on your $fpath) +eval "$(omp completions zsh)" + +# bash — add to ~/.bashrc +eval "$(omp completions bash)" + +# fish +omp completions fish > ~/.config/fish/completions/omp.fish +``` + ## Every tool, _benchmaxxed_. Edits that land on the first attempt. Reads that summarize files instead of dumping their content. Searches that return instantly. Pick any model — omp will get it right. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6f5ed99c4..1f5ec8759 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Added + +- Added an `omp completions ` command that prints a shell completion script generated from the live command/flag metadata, so completions never drift from the actual CLI. Subcommands, flags, and enum values complete statically; `--model`/`--smol`/`--slow`/`--plan` resolve against the bundled model catalog and `--resume` against on-disk sessions via a hidden `__complete` helper. + +### Fixed + +- Fixed the `read` tool description advertising `inspect_image` ("for visual analysis, call `inspect_image`") even when the `inspect_image` tool was disabled, which left the model hunting for a tool absent from its function list. The image section is now gated on `inspect_image.enabled`: when disabled it instead states that reading an image path returns the decoded image inline. + ## [15.6.0] - 2026-05-30 ### Added diff --git a/packages/coding-agent/src/cli-commands.ts b/packages/coding-agent/src/cli-commands.ts index c1d42a55b..8fa568001 100644 --- a/packages/coding-agent/src/cli-commands.ts +++ b/packages/coding-agent/src/cli-commands.ts @@ -17,6 +17,8 @@ export const commands: CommandEntry[] = [ { name: "auth-gateway", load: () => import("./commands/auth-gateway").then(m => m.default) }, { name: "agents", load: () => import("./commands/agents").then(m => m.default) }, { name: "commit", load: () => import("./commands/commit").then(m => m.default) }, + { name: "completions", load: () => import("./commands/completions").then(m => m.default) }, + { name: "__complete", load: () => import("./commands/complete").then(m => m.default) }, { name: "config", load: () => import("./commands/config").then(m => m.default) }, { name: "grep", load: () => import("./commands/grep").then(m => m.default) }, { name: "grievances", load: () => import("./commands/grievances").then(m => m.default) }, diff --git a/packages/coding-agent/src/cli/completion-gen.ts b/packages/coding-agent/src/cli/completion-gen.ts new file mode 100644 index 000000000..33e8b0458 --- /dev/null +++ b/packages/coding-agent/src/cli/completion-gen.ts @@ -0,0 +1,550 @@ +/** + * 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_TOOLS } from "../tools"; + +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: Object.keys(BUILTIN_TOOLS) }; + 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`; +} diff --git a/packages/coding-agent/src/commands/complete.ts b/packages/coding-agent/src/commands/complete.ts new file mode 100644 index 000000000..aae52d499 --- /dev/null +++ b/packages/coding-agent/src/commands/complete.ts @@ -0,0 +1,66 @@ +/** + * `omp __complete [-- ]` — dynamic completion candidates. + * + * Hidden helper invoked by the generated shell completion scripts to resolve + * values that can't be baked into the script: the live model catalog and + * on-disk sessions. Output is one `value\tdescription` line per candidate + * (tab-separated); shells that show descriptions parse the tab, bash uses the + * first field. The import surface is kept deliberately narrow so a TAB press + * doesn't pay for the full agent boot. + */ +import { type GeneratedProvider, getBundledModels, getBundledProviders } from "@oh-my-pi/pi-ai/models"; +import { Command } from "@oh-my-pi/pi-utils/cli"; +import { SessionManager } from "../session/session-manager"; + +export default class Complete extends Command { + static hidden = true; + static strict = false; + + async run(): Promise { + const argv = this.argv.filter(token => token !== "--"); + const kind = argv[0]; + const prefix = argv.length > 1 ? argv[argv.length - 1] : ""; + if (kind === "models") { + completeModels(prefix); + } else if (kind === "sessions") { + await completeSessions(prefix); + } + } +} + +/** Strip control chars that would corrupt the tab-separated line protocol. */ +function clean(text: string): string { + return text.replace(/[\t\r\n]+/g, " ").trim(); +} + +function completeModels(prefix: string): void { + const needle = prefix.toLowerCase(); + const seen = new Set(); + const lines: string[] = []; + for (const provider of getBundledProviders()) { + for (const model of getBundledModels(provider as GeneratedProvider)) { + // Offer both the fully-qualified `provider/id` and the bare `id` + // (matches the fuzzy resolution `--model` accepts). + const candidates = [`${model.provider}/${model.id}`, model.id]; + for (const candidate of candidates) { + if (seen.has(candidate)) continue; + seen.add(candidate); + if (needle && !candidate.toLowerCase().includes(needle)) continue; + lines.push(`${candidate}\t${model.provider}`); + } + } + } + lines.sort(); + if (lines.length > 0) process.stdout.write(`${lines.join("\n")}\n`); +} + +async function completeSessions(prefix: string): Promise { + const sessions = await SessionManager.list(process.cwd()); + const lines: string[] = []; + for (const session of sessions) { + if (prefix && !session.id.startsWith(prefix)) continue; + const label = clean(session.title ?? session.firstMessage ?? "").slice(0, 72); + lines.push(`${session.id}\t${label}`); + } + if (lines.length > 0) process.stdout.write(`${lines.join("\n")}\n`); +} diff --git a/packages/coding-agent/src/commands/completions.ts b/packages/coding-agent/src/commands/completions.ts new file mode 100644 index 000000000..66321b67f --- /dev/null +++ b/packages/coding-agent/src/commands/completions.ts @@ -0,0 +1,60 @@ +/** + * `omp completions ` — print a shell completion script. + * + * The script is derived entirely from the declarative command/flag metadata + * (see `cli/completion-gen.ts`), so it never drifts from the actual CLI surface. + */ +import { APP_NAME, VERSION } from "@oh-my-pi/pi-utils"; +import { Args, type CliConfig, Command, type CommandCtor } from "@oh-my-pi/pi-utils/cli"; +import { buildSpec, generateCompletion, type Shell } from "../cli/completion-gen"; +import { commands } from "../cli-commands"; + +/** Entry name of the default command whose flags become top-level completions. */ +const ROOT_COMMAND = "launch"; +const SHELLS = ["bash", "zsh", "fish"] as const; + +export default class Completions extends Command { + static description = "Print a shell completion script (bash, zsh, or fish)"; + + static args = { + shell: Args.string({ + description: "Target shell", + required: true, + options: SHELLS, + }), + }; + + static examples = [ + `# zsh — eval at startup, or write to a file in $fpath\n eval "$(${APP_NAME} completions zsh)"`, + `# bash\n eval "$(${APP_NAME} completions bash)"`, + `# fish\n ${APP_NAME} completions fish > ~/.config/fish/completions/${APP_NAME}.fish`, + ]; + + async run(): Promise { + const shell = this.argv[0]; + if (!isShell(shell)) { + process.stderr.write(`Usage: ${APP_NAME} completions <${SHELLS.join("|")}>\n`); + process.exitCode = 1; + return; + } + + // Load every command class so we can read its static flag/arg descriptors, + // and collect aliases from both the registration table and the class. + const loaded = await Promise.all(commands.map(async entry => ({ entry, Cmd: await entry.load() }))); + const map = new Map(); + const aliasMap = new Map(); + for (const { entry, Cmd } of loaded) { + map.set(entry.name, Cmd); + const merged = new Set([...(Cmd.aliases ?? []), ...(entry.aliases ?? [])]); + aliasMap.set(entry.name, [...merged]); + } + + const config: CliConfig = { bin: APP_NAME, version: VERSION, commands: map }; + const spec = buildSpec(config, ROOT_COMMAND, aliasMap); + process.stdout.write(generateCompletion(shell, spec)); + } +} + +function isShell(value: string | undefined): value is Shell { + return value === "bash" || value === "zsh" || value === "fish"; +} diff --git a/packages/coding-agent/test/cli/completions.test.ts b/packages/coding-agent/test/cli/completions.test.ts new file mode 100644 index 000000000..491279d85 --- /dev/null +++ b/packages/coding-agent/test/cli/completions.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from "bun:test"; +import * as path from "node:path"; +import type { CliConfig, CommandCtor } from "@oh-my-pi/pi-utils/cli"; +import { buildSpec, type CompletionSpec, generateCompletion } from "../../src/cli/completion-gen"; + +const repoRoot = path.resolve(import.meta.dir, "..", "..", "..", ".."); +const cliEntry = path.join(repoRoot, "packages", "coding-agent", "src", "cli.ts"); + +// A compact synthetic spec exercising every value-source kind and an aliased +// subcommand. The generators are pure functions of this shape, so pinning their +// output here defends the exact bytes each shell parses without booting the CLI. +const spec: CompletionSpec = { + bin: "omp", + root: { + flags: [ + { name: "model", description: "Model to use", value: { kind: "models", multiple: false }, repeatable: false }, + { name: "models", description: "Model list", value: { kind: "models", multiple: true }, repeatable: false }, + { + name: "thinking", + description: "Effort", + value: { kind: "enum", values: ["low", "high"] }, + repeatable: false, + }, + { name: "tools", description: "Tools", value: { kind: "list", values: ["read", "bash"] }, repeatable: false }, + { name: "resume", char: "r", description: "Resume", value: { kind: "sessions" }, repeatable: false }, + { name: "print", char: "p", description: "Print", value: { kind: "flag" }, repeatable: false }, + { name: "extension", char: "e", description: "Ext", value: { kind: "file" }, repeatable: true }, + { name: "session-dir", description: "Dir", value: { kind: "dir" }, repeatable: false }, + ], + args: [], + }, + commands: [ + { + name: "commit", + aliases: [], + description: "Commit", + flags: [{ name: "push", description: "Push", value: { kind: "flag" }, repeatable: false }], + args: [], + }, + { + name: "worktree", + aliases: ["wt"], + description: "Worktrees", + flags: [], + args: [{ name: "action", description: "Action", value: { kind: "enum", values: ["list", "clear"] } }], + }, + ], +}; + +describe("generateCompletion — bash", () => { + const out = generateCompletion("bash", spec); + + it("registers the dispatcher and resolves alias arms to the canonical handler", () => { + expect(out).toContain("complete -F _omp omp"); + expect(out).toContain("_omp_cmd_commit"); + // worktree + its alias dispatch to the same function + expect(out).toContain("worktree|wt)"); + }); + + it("completes enum, dynamic, and comma-list flag values by previous flag", () => { + expect(out).toContain('--thinking)\n\t\t\tCOMPREPLY=( $(compgen -W "low high"'); + expect(out).toContain('--model)\n\t\t\tCOMPREPLY=( $(compgen -W "$(command omp __complete models -- "$cur"'); + expect(out).toContain("--resume|-r)"); + expect(out).toContain("command omp __complete sessions"); + // static comma list routes through the comma-aware helper + expect(out).toContain('--tools)\n\t\t\t_omp_comma "read bash"'); + // multiple-value models flag also uses the comma helper + expect(out).toContain("--models)\n\t\t\t_omp_comma"); + }); + + it("offers subcommand names and root flags at the top level", () => { + expect(out).toMatch(/compgen -W "commit worktree wt [^"]*--model/); + }); + + it("completes a subcommand's positional enum and its own flags", () => { + expect(out).toContain("_omp_cmd_worktree()"); + expect(out).toContain('compgen -W "list clear"'); + expect(out).toContain("_omp_cmd_commit()"); + expect(out).toContain('compgen -W "--push"'); + }); +}); + +describe("generateCompletion — zsh", () => { + const out = generateCompletion("zsh", spec); + + it("emits the compdef header and dual-mode (autoload + eval) tail", () => { + expect(out.startsWith("#compdef omp")).toBe(true); + expect(out).toContain('if [ "$funcstack[1]" = "_omp" ]; then'); + expect(out).toContain("compdef _omp omp"); + }); + + it("maps value sources to the right _arguments actions", () => { + expect(out).toContain("'--model[Model to use]:model:_omp_call models'"); + expect(out).toContain("'--models[Model list]:models:_omp_models_list'"); + expect(out).toContain("'--thinking[Effort]:value:(low high)'"); + expect(out).toContain("'--tools[Tools]:value:_omp_tools'"); + expect(out).toContain("'(-r --resume)'{-r,--resume}'[Resume]:session:_omp_call sessions'"); + expect(out).toContain("'--session-dir[Dir]:dir:_files -/'"); + // repeatable short+long flag uses the `*{...}` form + expect(out).toContain("'*'{-e,--extension}'[Ext]:file:_files'"); + // the static tool list helper is baked + expect(out).toContain("_omp_tools() { _values -s , 'tools' read bash }"); + }); + + it("dispatches aliased subcommands and completes positional enums", () => { + expect(out).toContain("worktree|wt) _omp_cmd_worktree ;;"); + expect(out).toContain("':action:(list clear)'"); + }); +}); + +describe("generateCompletion — fish", () => { + const out = generateCompletion("fish", spec); + + it("declares the no-subcommand predicate over every command token", () => { + expect(out).toContain("function __fish_omp_no_subcommand"); + expect(out).toContain("if contains -- $i commit worktree wt"); + }); + + it("renders subcommand names, including aliases, with descriptions", () => { + expect(out).toContain("-a 'commit' -d 'Commit'"); + expect(out).toContain("-a 'wt' -d 'Worktrees'"); + }); + + it("maps value sources to fish completion args", () => { + expect(out).toContain("-l model -d 'Model to use' -x -a '(command omp __complete models -- (commandline -ct))'"); + expect(out).toContain("-l thinking -d 'Effort' -x -a 'low high'"); + expect(out).toContain("-l tools -d 'Tools' -x -a 'read bash'"); + expect(out).toContain("-s r -l resume -d 'Resume' -x -a '(command omp __complete sessions"); + // a bare boolean flag takes no value + expect(out).toContain("-s p -l print -d 'Print'"); + expect(out).not.toContain("-l print -d 'Print' -x"); + }); + + it("gates a positional enum on its subcommand", () => { + expect(out).toContain("-n '__fish_seen_subcommand_from worktree wt' -a 'list clear'"); + }); +}); + +describe("buildSpec", () => { + function fakeCmd(props: Partial): CommandCtor { + return props as unknown as CommandCtor; + } + + it("lifts the root command's flags and excludes root + hidden from subcommands", () => { + const config: CliConfig = { + bin: "omp", + version: "0", + commands: new Map([ + ["launch", fakeCmd({ hidden: true, flags: { model: { kind: "string" } }, args: {} })], + ["__complete", fakeCmd({ hidden: true, flags: {}, args: {} })], + ["config", fakeCmd({ description: "Cfg", flags: { json: { kind: "boolean" } }, args: {} })], + ]), + }; + const result = buildSpec(config, "launch", new Map([["config", ["c"]]])); + + expect(result.root.flags.map(f => f.name)).toContain("model"); + // hidden (__complete) and the root entry (launch) are both dropped + expect(result.commands.map(c => c.name)).toEqual(["config"]); + expect(result.commands[0]?.aliases).toEqual(["c"]); + }); + + it("classifies flag value sources from descriptor metadata", () => { + const config: CliConfig = { + bin: "omp", + version: "0", + commands: new Map([ + [ + "launch", + fakeCmd({ + hidden: true, + flags: { + model: { kind: "string" }, + thinking: { kind: "string", options: ["low", "high"] }, + "no-tools": { kind: "boolean" }, + "session-dir": { kind: "string" }, + }, + args: {}, + }), + ], + ]), + }; + const root = buildSpec(config, "launch", new Map()).root; + const byName = new Map(root.flags.map(f => [f.name, f.value.kind])); + expect(byName.get("model")).toBe("models"); + expect(byName.get("thinking")).toBe("enum"); + expect(byName.get("no-tools")).toBe("flag"); + expect(byName.get("session-dir")).toBe("dir"); + }); +}); + +describe("omp completions (integration / drift)", () => { + it("emits a zsh script reflecting the live command + flag surface", async () => { + const proc = Bun.spawn([process.execPath, cliEntry, "completions", "zsh"], { + cwd: repoRoot, + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, NO_COLOR: "1", PI_NO_TITLE: "1" }, + }); + const [stdout, , exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + expect(exitCode).toBe(0); + + // Real top-level flags from launch's static `flags` table. Flags with a + // short char render as `{-r,--resume}`, so only assert the bracket form for + // the long-only ones and check the char-paired form separately. + for (const flag of ["--model", "--thinking", "--mode", "--approval-mode", "--tools", "--no-tools"]) { + expect(stdout).toContain(`${flag}[`); + } + expect(stdout).toContain("{-r,--resume}"); + // Real enum option sets flow through unchanged. + expect(stdout).toContain(":value:(minimal low medium high xhigh)"); + expect(stdout).toContain(":value:(always-ask write yolo)"); + // Real subcommands present; dynamic callbacks wired. + expect(stdout).toContain("_omp_cmd_commit"); + expect(stdout).toContain("'completions:"); + // zsh routes single-value dynamic flags through the _omp_call action, which + // itself shells out to `omp __complete $kind`. + expect(stdout).toContain("_omp_call models"); + expect(stdout).toContain("_omp_call sessions"); + expect(stdout).toContain("command omp __complete $kind"); + // Hidden/default commands must NOT surface as completable subcommands. + expect(stdout).not.toContain("_omp_cmd_launch"); + expect(stdout).not.toContain("_omp_cmd___complete"); + }); +});