Skip to content

completion definition update #345

@toiroakr

Description

@toiroakr

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

  1. Embed the bin-sig + version header in bash/zsh/fish generators.
  2. 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/.
  3. 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.
  4. Add hidden __refresh-completion <shell> subcommand.
  5. Add the BG spawn into runMain. Skip when invoked under __complete/__refresh-completion/completion itself.
  6. Add WithCompletionOptions.cacheDir override.
  7. Document the upgrade flow, failure modes, and the difference between bash/zsh and fish install paths.
  8. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions