ADR 0006: dynamic (install-once) shell completion
Date: 2026-06-30 Status: Accepted
Context
Section titled “Context”completion bash|zsh|fish generate a shell script by walking the Commander
tree and baking it into shell case-tables (subcommand names, option names,
which options take values, enum choices). Dynamic values — workspace IDs, env
aliases, etc. — were already delegated to the live binary via a hidden
__complete <provider> callback, but the structure of the tree was static.
That makes the installed script a point-in-time snapshot. Add a command or a
flag and the script is wrong until you re-run completion install. The script
also lives outside the repo (~/.local/share/bash-completion/...), so a
git pull that brings new commands never updates it — every machine needs a
manual refresh, and a locally-added command silently isn’t completed until you
remember to reinstall. Three separate shell drivers each re-implemented the
COMP_WORDS path-walk, so the staleness (and any walk bug) was tripled.
Alternatives considered
Section titled “Alternatives considered”- Auto-refresh on
git pullvia apost-merge/post-checkoutgit hook that re-runscompletion installwhen the CLI source changed. Rejected as the primary fix: it only covers the pull path (not locally-added commands), it’s machine-setup-dependent (needs the hook wired), and it automates a step that shouldn’t need to exist. Still viable as a zero-tab-latency convenience for static installs, but it doesn’t dissolve the problem. - Make dynamic the only mode. Rejected — it’s a breaking change for existing
installs and forces a per-
<Tab>subprocess on consumers who prefer zero-latency static completion. Additive is safer for a published package.
Decision
Section titled “Decision”Add a dynamic completion mode alongside static generation:
- A shared
resolveCompletions(program, words, cword, lookup, runProvider)insrc/lib/completion/resolve.tsis the single, authoritative resolver. It walks the liveCommandSpectree (samewalkProgramthe generators use) and returns candidates (subcommands / option names / enum choices / dynamic-provider values) plus a Cobra-style directive bitmask (bit 0 = the shell should fall back to file completion). - A hidden
__complete-line <cword> [words...]command feeds the live command line to that resolver and emits the wire protocol: onevalue\tdescriptionline per candidate, then a trailing\x1f:<directive>line (the\x1funit separator makes the directive unambiguous against real values). completion {bash,zsh,fish} --dynamic(andcompletion install --dynamic) emit a thin, tree-independent shim that calls__complete-lineon every<Tab>. Because the binary always knows its own current tree, the shim never goes stale — install once, ever.
Static generation and the legacy __complete <provider> callback are untouched,
so existing installed scripts keep working and a binary serves both old static
scripts and new dynamic shims.
Result / gotchas
Section titled “Result / gotchas”- Cost: one subprocess spawn per
<Tab>. This is the same cost already paid for dynamic values, now extended to the whole tree — no new latency class. It’s the modelgh,kubectl/Cobra,npm, and Click-based CLIs use. - Prefix filtering stays shell-side: the resolver returns the full candidate
set for the slot and each shell filters against the current token (
compgen,_describe, fish’s native-a). Descriptions ride the\tso zsh/fish can render them; bash drops them. - Fish file fallback is a no-op in dynamic mode — fish’s declarative
complete -acan’t switch to file completion from inside the helper. A path-valued positional that wants file completion should use static fish generation. bash/zsh honor theFiledirective (compgen -f/_files). - The resolver mirrors the static bash driver’s path-walk; both are covered by
tests/unit/completion-resolve.test.ts.