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.
This commit is contained in:
can1357
2026-05-30 21:32:03 +02:00
parent 725539aeeb
commit d73393cf5c
7 changed files with 929 additions and 0 deletions
+15
View File
@@ -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.
+8
View File
@@ -2,6 +2,14 @@
## [Unreleased]
### Added
- Added an `omp completions <bash|zsh|fish>` 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
@@ -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) },
@@ -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 `<bin> __complete <kind>`
* — 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<string, true> = { model: true, smol: true, slow: true, plan: true };
/** Single-value flags resolved against on-disk sessions. */
const SESSION_FLAGS: Record<string, true> = { resume: true, fork: true, session: true };
/** Flags whose value is a directory path. */
const DIR_FLAGS: Record<string, true> = { "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<string, readonly string[]>,
): 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: `<bin> __complete <kind>` → value<TAB>desc).
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`;
}
@@ -0,0 +1,66 @@
/**
* `omp __complete <kind> [-- <prefix>]` — 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<void> {
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<string>();
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<void> {
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`);
}
@@ -0,0 +1,60 @@
/**
* `omp completions <bash|zsh|fish>` — 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<void> {
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<string, CommandCtor>();
const aliasMap = new Map<string, readonly string[]>();
for (const { entry, Cmd } of loaded) {
map.set(entry.name, Cmd);
const merged = new Set<string>([...(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";
}
@@ -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>): 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<string, CommandCtor>([
["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<string, CommandCtor>([
[
"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");
});
});