Is it possible to make completions update automatically whenever a package is updated?
If possible, I'd like to avoid using postinstall scripts.
Background
politty currently emits static shell completion scripts (#136, replacing the thin-wrapper approach in #103). The static script embeds all subcommand/option/value-completion metadata, so TAB never spawns Node.
Trade-off: the script reflects the CLI structure at generation time. When the user upgrades mycli, any newly added subcommand/option/enum value is invisible to TAB until the user manually re-runs mycli completion <shell>.
Goal: regenerate automatically without postinstall (skipped under --ignore-scripts, security-sensitive in global installs, doesn't run under brew/aqua), without sacrificing per-TAB latency.
Survey of Node-ecosystem prior art
| Project |
TAB calls binary? |
Upgrade-freshness |
Postinstall? |
oclif plugin-autocomplete |
No (static cache) |
regenerated via plugin lifecycle hook + _setup indirection in rc |
yes |
| pnpm / wrangler / rustup / cargo / deno |
No (static) |
manual re-run |
no |
| gh / kubectl (Cobra) / npm / bun / yargs / omelette / tabtab |
Yes |
self-fresh |
no |
| citty / commander / cac / clipanion |
none |
n/a |
no |
Two camps: dynamic thin-wrapper (self-fresh but slow TAB) and static cache + plugin-lifecycle refresh (oclif's model, doesn't fit npm-direct distribution).
Cold-start measurements (Node 24, hyperfine)
Linux arm64 native (Docker)
|
4 CPU |
1 CPU |
0.25 CPU |
node -e '' |
12ms |
14ms |
63ms |
| node + politty import |
92ms |
89ms |
306ms |
politty __complete root |
84ms |
83ms |
323ms |
politty completion bash (full gen) |
174ms |
88ms |
363ms |
Cold-start on Node is essentially single-threaded: 1 CPU == 4 CPU. CPU time throttle (shared VPS / cramped CI / busy laptop) inflates everything ~3.5×.
Conclusion: regressing to the dynamic thin wrapper is unacceptable for users on slow / shared / CI environments — TAB on 0.25 CPU exceeds 300ms, 3× over Nielsen's 100ms "instant" threshold. Auto-refresh has to keep the static script fast and update it out-of-band.
(See #347 for an orthogonal P1+P2 bundle-splitting effort that targets the import-cost line.)
Final design — A.stat + C
A two-mechanism approach. They cooperate but are independently useful.
A.stat — rc loader checks binary mtime
The user's rc (~/.bashrc / ~/.zshrc) contains a small loader that politty emits:
__mycli_load_completion() {
local bin cache sig hdr
bin=$(command -v mycli) || return 0
cache="${XDG_CACHE_HOME:-$HOME/.cache}/mycli/completion.bash"
sig=$(stat -f '%m' "$bin" 2>/dev/null || stat -c '%Y' "$bin" 2>/dev/null) || return 0
hdr="# politty-bin-sig: $sig"
if [[ ! -f "$cache" ]] || ! head -5 "$cache" 2>/dev/null | grep -qF "$hdr"; then
mkdir -p "$(dirname "$cache")"
"$bin" completion bash > "$cache.tmp.$$" 2>/dev/null \
&& mv "$cache.tmp.$$" "$cache" \
|| { rm -f "$cache.tmp.$$" 2>/dev/null; return 0; }
fi
source "$cache"
}
__mycli_load_completion
unset -f __mycli_load_completion
- Signature: binary
mtime (seconds, integer). One stat syscall.
- Steady state: 1×
command -v + 1× stat + 1× head + 1× grep -F + 1× source (~1–2ms).
- Stale state: spawns the binary once to regenerate (~90ms good env, ~360ms throttled) — paid once per shell after an upgrade, not on every TAB.
- Failure modes: silent no-op (binary missing → empty PATH; stat fails; cache write fails — all
return 0).
- Cache location:
${XDG_CACHE_HOME:-$HOME/.cache}/<program>/completion.<shell>, overridable via WithCompletionOptions.
- Atomicity: write tmp + rename. Two shells racing → last writer wins, no corruption (identical content).
The static script's leading lines act as the marker:
# politty-completion-version: 1
# politty-bin-sig: 1735680000
# program: mycli
# program-version: 0.4.14
# shell: bash
C — opportunistic background refresh on CLI invocation
runMain fires-and-forgets a detached child process at the start of every normal CLI invocation:
spawn(execPath, [argv1, "__refresh-completion", shell], {
detached: true,
stdio: "ignore",
}).unref();
- Runs in a separate process so the user's command doesn't pay any extra time.
- The child does the same stat-compare + cache regeneration as A.stat. No-op if mtime unchanged.
- Combined with A.stat: by the time the user opens a new shell, the cache is already warm; A.stat just sources it.
- Implemented inside
runMain itself — transparent to user-CLI authors.
- Hidden subcommand
__refresh-completion <shell> is the entry point. Excluded from --help.
Fish — autoload self-refresh (different mechanism)
Fish doesn't use rc-based loaders; it autoloads $__fish_config_dir/completions/<program>.fish lazily. So in this design <program> completion install fish writes a self-rewriting file to that path:
function __mycli_refresh_completion --no-scope-shadowing
set -l bin (command -v mycli)
test -z "$bin"; and return
set -l sig (stat -f '%m' "$bin" 2>/dev/null; or stat -c '%Y' "$bin" 2>/dev/null)
test "$sig" = "1735680000"; and return
set -l target "$__fish_config_dir/completions/${PROGRAM}.fish"
"$bin" completion fish > "$target.tmp" 2>/dev/null
and mv "$target.tmp" "$target"
end
__mycli_refresh_completion
functions -e __mycli_refresh_completion
# ...complete -c <program>... lines below
- Self-rewriting: when the binary mtime changes, the next fish autoload triggers the function, which overwrites itself in place.
- The C path covers the "running fish session uses
mycli" case the same way — by the next autoload (next fish session) the file is already current.
Prototype validation
A standalone prototype at ~/politty-proto/ validates the full design end-to-end:
| Case |
bash |
zsh |
fish |
C |
| 1. cold start |
cache generated |
cache generated |
install writes |
BG creates cache |
| 2. warm (no change) |
mtime unchanged |
mtime unchanged |
mtime unchanged |
exit code clean |
| 3. binary mtime bumped |
regenerated, header updated |
regenerated |
self-rewrites |
next run refreshes |
| 4. binary missing |
silent no-op |
silent no-op |
file untouched |
— |
| A.stat + C synergy |
A.stat sources C-warmed cache without rewriting |
|
|
|
All 14 + 1 cases pass.
Rejected alternatives
- B. Self-checking static script: pays first-TAB latency in the user's typing flow.
- D. Ship pre-generated scripts in package: package-manager-path-dependent, doesn't capture runtime-registered subcommands.
- E. Hybrid static + dynamic-while-stale: doubles maintenance cost.
- F. Regress to dynamic thin wrapper: TAB ~320ms on throttled environments.
Implementation plan
- Embed the bin-sig + version header in bash/zsh/fish generators.
- Add
<program> completion install <shell> subcommand:
- bash/zsh: write the static script to the cache path. (Optional — primary install vector is the rc loader.)
- fish: write the self-rewriting autoload file to
$__fish_config_dir/completions/.
- Add
<program> completion <shell> --loader (or similar) to print just the rc snippet for bash/zsh, so users can eval "$(mycli completion bash --loader)" in their rc.
- Add hidden
__refresh-completion <shell> subcommand.
- Add the BG spawn into
runMain. Skip when invoked under __complete/__refresh-completion/completion itself.
- Add
WithCompletionOptions.cacheDir override.
- Document the upgrade flow, failure modes, and the difference between bash/zsh and fish install paths.
- Tests: port the prototype's bash/zsh/fish/C-path harnesses into the politty test suite.
Out of scope
- Distribution-channel-specific install hooks (Homebrew formula, aqua manifest, etc.) — can layer on top later.
- Changing the static-script body itself.
Related
Is it possible to make completions update automatically whenever a package is updated?
If possible, I'd like to avoid using postinstall scripts.
Background
politty currently emits static shell completion scripts (#136, replacing the thin-wrapper approach in #103). The static script embeds all subcommand/option/value-completion metadata, so TAB never spawns Node.
Trade-off: the script reflects the CLI structure at generation time. When the user upgrades
mycli, any newly added subcommand/option/enum value is invisible to TAB until the user manually re-runsmycli completion <shell>.Goal: regenerate automatically without
postinstall(skipped under--ignore-scripts, security-sensitive in global installs, doesn't run under brew/aqua), without sacrificing per-TAB latency.Survey of Node-ecosystem prior art
plugin-autocomplete_setupindirection in rcTwo camps: dynamic thin-wrapper (self-fresh but slow TAB) and static cache + plugin-lifecycle refresh (oclif's model, doesn't fit npm-direct distribution).
Cold-start measurements (Node 24, hyperfine)
Linux arm64 native (Docker)
node -e ''__completerootcompletion bash(full gen)Cold-start on Node is essentially single-threaded: 1 CPU == 4 CPU. CPU time throttle (shared VPS / cramped CI / busy laptop) inflates everything ~3.5×.
Conclusion: regressing to the dynamic thin wrapper is unacceptable for users on slow / shared / CI environments — TAB on 0.25 CPU exceeds 300ms, 3× over Nielsen's 100ms "instant" threshold. Auto-refresh has to keep the static script fast and update it out-of-band.
(See #347 for an orthogonal P1+P2 bundle-splitting effort that targets the import-cost line.)
Final design — A.stat + C
A two-mechanism approach. They cooperate but are independently useful.
A.stat — rc loader checks binary mtime
The user's rc (
~/.bashrc/~/.zshrc) contains a small loader that politty emits:mtime(seconds, integer). Onestatsyscall.command -v+ 1×stat+ 1×head+ 1×grep -F+ 1×source(~1–2ms).return 0).${XDG_CACHE_HOME:-$HOME/.cache}/<program>/completion.<shell>, overridable viaWithCompletionOptions.The static script's leading lines act as the marker:
C — opportunistic background refresh on CLI invocation
runMainfires-and-forgets a detached child process at the start of every normal CLI invocation:runMainitself — transparent to user-CLI authors.__refresh-completion <shell>is the entry point. Excluded from--help.Fish — autoload self-refresh (different mechanism)
Fish doesn't use rc-based loaders; it autoloads
$__fish_config_dir/completions/<program>.fishlazily. So in this design<program> completion install fishwrites a self-rewriting file to that path:mycli" case the same way — by the next autoload (next fish session) the file is already current.Prototype validation
A standalone prototype at
~/politty-proto/validates the full design end-to-end:All 14 + 1 cases pass.
Rejected alternatives
Implementation plan
<program> completion install <shell>subcommand:$__fish_config_dir/completions/.<program> completion <shell> --loader(or similar) to print just the rc snippet for bash/zsh, so users caneval "$(mycli completion bash --loader)"in their rc.__refresh-completion <shell>subcommand.runMain. Skip when invoked under__complete/__refresh-completion/completionitself.WithCompletionOptions.cacheDiroverride.Out of scope
Related
__completeintroduction