Skip to content

feat: add shell completion (#132) - #134

Merged
2000game merged 3 commits into
eqrm:mainfrom
bwl21:feat/132-shell-completion
Aug 24, 2026
Merged

2000game merged 3 commits into
eqrm:mainfrom
bwl21:feat/132-shell-completion

Conversation

@bwl21

@bwl21 bwl21 commented Aug 20, 2026 •

Copy link
Copy Markdown
Contributor

Tab completion by calling ct back, not by generating a script

Reworked from the generated-script approach. ct completion zsh|bash|fish now prints a ~15-line hook that hands the command line back to ct on every Tab; candidates are computed live from the Commander tree of the binary that is actually installed. This repo owns no shell syntax — the zsh/bash/fish dialects come from omelette.

Why not a generated script. It has to be re-emitted whenever a command changes, it needs a hand-written emitter per shell (232 of the previous 338 lines were exactly that), and it structurally cannot offer candidates that depend on the user's files.

What the delegation buys.

  • --env completes the environments this config repo declares in ct.envs.json
  • ct state rm <type> <key> completes registry types and the keys actually under management, following the --env/--state already on the line
  • --config, --state, --backup-dir complete from the filesystem
  • plus static parity: commands, nested subcommands, options, enumerated option and argument values

Completion is offline by construction. It reads local, non-secret files and nothing else — no client, no token store, no prepareEnv. Every source is wrapped so a missing, malformed or slow file yields no candidates rather than an error or a hang: a completion that spills a stack trace into the command line is worse than one that offers nothing. The test suite mocks the session module to throw, so any route to the network fails CI.

Why omelette over @pnpm/tabtab. tabtab is the better-featured library, but it reads its hook templates off disk at runtime (path.join(__dirname, 'templates', …)). In the bun build --compile binaries this project releases, __dirname resolves to the build machine's node_modules, so ct completion zsh works on the CI runner and fails with ENOENT on every user's machine — verified by compiling a probe and removing node_modules. Our smoke tests would not have caught it, because they run on a checkout that has node_modules. omelette inlines its hooks as strings and has zero dependencies, so the same code path works from npm, from dist/, and from a standalone binary with no node_modules in sight.

The trade-off is no per-candidate description text in zsh: omelette uses compadd, tabtab would use _describe. Working standalone binaries win that trade.

omelette's maintenance status, accepted deliberately. Last release 0.4.17 in September 2021, last commit January 2022 — effectively frozen. Accepted because it is ~350 lines of MIT-licensed, dependency-free CommonJS whose entire job is emitting three static hook strings, so the realistic failure mode is "never gains a feature", not "breaks". If it ever does break, vendoring it is an afternoon's work and the licence allows it. The reasoning is recorded in src/completion/shell.ts rather than only here.

<shell> stays an explicit required argument. zsh and bash receive the same hook (it branches on compdef/complete itself); only fish differs. Detecting the shell from $SHELL would be unreliable and wrong whenever someone generates for a different shell on purpose.

Also: README install docs rewritten for the hook-based flow. The /reference/ and /reports/ .gitignore lines are dropped — they belong to #133.

Verification

  • npm test — 828 passed, 5 skipped
  • npm run typecheck, npm run lint, npm run format:check, npm run build
  • node .github/scripts/docs-staleness.mjs — all pages current
  • hooks checked with bash -n, zsh -n and fish -n (fish 4.8.1)
  • driven end-to-end as live completions in bash and fish against a fixture config repo:
    ct plan --env <Tab> → dev prod, ct state rm --env dev campus <Tab> → the managed keys
  • exercised from a bun --compile binary run outside any node_modules

Closes #132

@bwl21
bwl21 marked this pull request as ready for review August 20, 2026 19:55
…ing a script (eqrm#132)

`ct completion zsh|bash|fish` prints a ~15-line hook that hands the command line
back to `ct` on every Tab; the candidates are computed live from the Commander
tree of the binary that is actually installed.

The per-shell dialect (zsh `compdef`/`compadd`, bash `complete`/`compgen`, fish
`complete -a`) comes from omelette, so this repo owns no shell syntax of its own
and nothing has to be re-emitted when a command is added.

Delegating at runtime is what makes dynamic candidates possible at all — a
generated script structurally cannot know them:

- `--env` completes the environments this config repo declares in `ct.envs.json`
- `ct state rm <type> <key>` completes the registry's types and the keys actually
  under management, following the `--env`/`--state` already on the line
- path-taking options (`--config`, `--state`, `--backup-dir`) complete from disk

Completion is offline by construction: it reads local, non-secret files and
nothing else — no client, no token store, no `prepareEnv`. Every source is
wrapped so a missing, malformed or slow file yields no candidates rather than an
error or a hang; a completion that spills a stack trace into the command line is
worse than one that offers nothing.

omelette over `@pnpm/tabtab`: tabtab reads its hook templates off disk at
runtime, which in the `bun build --compile` binaries this project releases
resolves to the *build machine's* `node_modules` and fails with ENOENT on every
user's machine (verified). omelette inlines its hooks as strings and has no
dependencies, so the same code path works from npm, from `dist/`, and from a
standalone binary with no `node_modules` in sight.

Claude-Session: https://claude.ai/code/session_018XbTXWQnBB5rgbXwJYRFHM
@2000game
2000game force-pushed the feat/132-shell-completion branch from e78e8f4 to 3555d9f Compare August 24, 2026 13:28
@2000game 2000game changed the title feat: add shell completion generation feat: add shell completion (#132) Aug 24, 2026
@2000game

Copy link
Copy Markdown
Member

Hey, ich habe hier direkt auf deinem Branch umgebaut statt lange zu kommentieren — sag Bescheid, wenn dir das zu weit ging. Dein ursprünglicher Commit e78e8f4 ist über das Force-Push-Ereignis in der Timeline weiter erreichbar.

Was mich am generierten Skript gestört hat

Von den 338 Zeilen in src/completion.ts waren 232 handgeschriebene Shell-Emitter, einer pro Shell. Die müssen wir dann dauerhaft pflegen, und sie müssen bei jeder Kommandoänderung neu ausgegeben werden. Der eigentliche Punkt ist aber: Ein statisches Skript kann prinzipiell nichts vervollständigen, was von den Dateien des Nutzers abhängt.

Jetzt gibt ct completion <shell> nur noch einen ~15-Zeilen-Hook aus, der bei jedem Tab auf ct zurückruft; die Kandidaten kommen live aus dem Commander-Baum. Shell-Dialekt-Zeilen in unserem Repo: 0. Und damit geht das hier:

ct plan --env <Tab>                  → dev prod          (aus ct.envs.json)
ct state rm --env dev campus <Tab>   → die tatsächlich verwalteten Keys

Warum omelette und nicht @pnpm/tabtab

tabtab ist die bessere Bibliothek, fällt hier aber aus einem Grund raus, der genau unsere Release-Pipeline trifft: Sie lädt ihre Hook-Templates zur Laufzeit von der Platte (path.join(__dirname, 'templates', …)). In den bun build --compile-Binaries zeigt __dirname auf die node_modules der Build-Maschine. ct completion zsh hätte also auf dem CI-Runner funktioniert und wäre bei jedem Nutzer mit ENOENT gestorben — und unsere Smoke-Tests hätten das nie gefangen, weil die auf einem Checkout mit node_modules laufen. Ich habe das mit einer kompilierten Probe nachgestellt, ist kein theoretisches Bedenken.

Preis dafür: keine Beschreibungstexte an den Kandidaten in zsh (omelette nutzt compadd, tabtab _describe). Funktionierende Standalone-Binaries waren mir wichtiger.

Und ja, omelette ist praktisch eingefroren — letztes Release September 2021, letzter Commit Januar 2022. Bewusst in Kauf genommen: ~350 Zeilen MIT, ohne Dependencies, deren ganzer Job das Ausgeben von drei statischen Hook-Strings ist. Der realistische Ausfallmodus ist "bekommt nie ein Feature", nicht "geht kaputt", und im Notfall ist Vendoring ein Nachmittag. Die Begründung steht im Code, nicht nur hier.

Kleinigkeiten: Die /reference/- und /reports/-Zeilen in .gitignore sind raus, die gehören zu #133. <shell> bleibt Pflichtargument — zsh und bash bekommen denselben Hook, der branched selbst, nur fish ist anders; $SHELL-Erkennung wäre unzuverlässig und genau dann falsch, wenn jemand absichtlich für eine andere Shell generiert.

fish habe ich installiert und die Completion dort auch wirklich durchgetrieben, nicht nur fish -n laufen lassen. Alles grün: 828 Tests, typecheck, lint, format:check, build, docs-staleness.

Schau bitte drüber, ob du das so mittragen kannst — vor allem beim Verzicht auf die Beschreibungstexte hätte ich gern deine Meinung.

…nd stock bash

Review of eqrm#134 turned up six ways the completion answers the wrong thing, or
nothing at all:

- `splitCompletionLine` sliced the last token off the line rather than the token
  the cursor is on, so Tab anywhere but at the end of the line completed the
  wrong word. Split at `fragment` instead, keeping the old shape-of-the-line
  behaviour as the fallback for the one case it is still needed: bash derives the
  index from `COMP_CWORD`, whose word breaks the hook's colon fudge does not
  fully account for, so it can arrive past the end of the line.
- `statePathFor` hardcoded the `ct-state.<env>.json` convention and ignored a
  profile's own `state` field, which `prepareEnv` does honour — so in any repo
  that overrides it, `ct state rm --env <e> <Tab>` offered nothing at all. Read
  the field offline, with the same precedence the commands use.
- `state rm` key candidates were not narrowed by the `<type>` already typed,
  so completion happily offered a key the command then refuses.
- omelette's bash branch calls two helpers from the `bash-completion` package.
  Stock macOS bash 3.2 — the one the README's `~/.bash_profile` line gets you —
  has neither, so every Tab printed two "command not found" lines. Prepend
  minimal stand-ins, defined only when the real ones are absent.
- `paths()` never expanded `~`, and the hooks turn off the shell's own filename
  fallback, so a tilde path completed to nothing.
- `ct adopt <type>` takes the same registry-typed argument as `ct state rm
  <type>` but had no entry in the dynamic table.

Claude-Session: https://claude.ai/code/session_018XbTXWQnBB5rgbXwJYRFHM
@2000game
2000game merged commit 6fe27a2 into eqrm:main Aug 24, 2026
3 checks passed
@bwl21
bwl21 deleted the feat/132-shell-completion branch August 26, 2026 15:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: add shell completion generation

2 participants