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:
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user