Skip to content

feat(cli): codegraph completions for zsh/bash/fish/powershell with auto-detected install paths#263

Open
HelgeSverre wants to merge 6 commits into
colbymchenry:mainfrom
HelgeSverre:feat/shell-completions
Open

feat(cli): codegraph completions for zsh/bash/fish/powershell with auto-detected install paths#263
HelgeSverre wants to merge 6 commits into
colbymchenry:mainfrom
HelgeSverre:feat/shell-completions

Conversation

@HelgeSverre
Copy link
Copy Markdown

Summary

Adds codegraph completions <shell> for zsh, bash, fish, and PowerShell. With --install, the command auto-detects the right location for the user's environment (oh-my-zsh, Homebrew, XDG, $PROFILE) and writes there — so the install is actually a one-shot, not "drop a file then go edit your dotfile" like every other CLI's completion installer.

codegraph completions zsh        --install   # → reports (detected: <tier>)
codegraph completions bash       --install
codegraph completions fish       --install
codegraph completions powershell --install

Without --install, the script goes to stdout — pipe it wherever you want.

Why

Every CLI that ships an --install for completions handles the path the same broken way: the docs tell you to redirect to ${fpath[1]}. On most systems ${fpath[1]} is /usr/local/share/zsh/site-functions or similar — root-owned. The "one-line install" silently requires sudo, or fails, or the user adds a custom dir to $fpath and remembers to do it again on every machine. gh, kubectl, helm, rustup, Jottacloud's docs — same brittle pattern.

This PR does detection properly. The priority order per shell:

Shell Tier 1 Tier 2 Tier 3
zsh oh-my-zsh ($ZSH/completions/) <prefix>/share/zsh/site-functions/ if writable ~/.zsh/completions/ (prints fpath hint)
bash <homebrew>/etc/bash_completion.d/ if writable XDG ~/.local/share/bash-completion/completions/
fish ~/.config/fish/completions/
powershell Standalone .ps1 in ~/.config/powershell/ (or ~/Documents/PowerShell/ on Windows) + idempotent dot-source line in $PROFILE

The installer reports (detected: <tier>) so the user can see which rule fired. Unknown shells (nushell, xonsh, etc.) exit non-zero with a hint, writes nothing.

What's in the PR

File Purpose
src/completions/index.ts Public API: emit(), parseShell(), installCompletions()
src/completions/introspect.ts Walks commander's Command tree → plain CommandDesc (decouples emitters from commander internals)
src/completions/zsh.ts _arguments / _values emitter
src/completions/bash.ts compgen / complete -F emitter
src/completions/fish.ts complete -c codegraph declarations
src/completions/powershell.ts Register-ArgumentCompleter -Native static script (clap_complete pattern)
src/completions/install.ts detectInstallTarget() per-shell tiers + idempotent $PROFILE append
src/bin/codegraph.ts Registers .command('completions <shell>')
__tests__/completions.test.ts 39 structural + detection-tier + help/version regression assertions
docker/smoke-completions/* End-to-end smoke harness (zsh/bash/fish/pwsh in a pinned container)
README.md New ### codegraph completions section under CLI Reference

Zero new dependencies. Uses commander's existing introspection API (cmd.options, cmd.registeredArguments, cmd.commands, cmd.aliases()).

Design calls worth flagging

zsh is structural-tested, not content-tested

zsh has no complete -C equivalent. Full content testing would require _main_complete running under a real ZLE widget context with a properly-set $compstate / $state / $opt_args — which scripts cannot manufacture. I tried a compadd shim against _codegraph_commands (the simplest helper, which uses _values); it silently captured nothing because _values short-circuits on missing context. PTY + expect is the only path to full testing, and that's flaky across zsh 5.7/5.8/5.9 and across TERM / locale / zle configurations — too unreliable for CI.

What clap_complete, oclif, click, and Commander.js itself all ship for zsh: structural-only. This PR matches that bar. The smoke verifies:

  • zsh -n parses the script
  • compinit registers _codegraph as an autoloadable completion
  • The file body executes far enough to define all per-subcommand helpers (_codegraph_init, _codegraph_query, etc.) — catches "script crashed mid-body"

If you want stronger zsh coverage in the future, the realistic path is zpty-based testing (what Fig uses) — but that's a separate engineering effort.

PowerShell follows the clap_complete static pattern

Not the cobra/gh pattern (which calls back into the binary at tab-press time via a hidden __complete subcommand). The clap_complete idiom is fully static: Register-ArgumentCompleter -Native, walk $commandAst.CommandElements into a semicolon-joined path string, switch on that string, emit [CompletionResult]::new(...) per candidate, filter by $wordToComplete.

The static pattern is testable non-interactively via TabExpansion2 — clean analog to fish's complete -C. Full content assertions for top-level subcommands, flags, and alias dispatch are in the smoke (docker/smoke-completions/test-powershell.sh).

--install for PowerShell modifies $PROFILE

Touching the user's $PROFILE is invasive. The reasoning for doing it anyway:

  1. The user explicitly invoked --install — modifying things is the contract.
  2. PowerShell has no completions directory at all; $PROFILE dot-source is the only standard load path.
  3. The appended line is one self-explanatory comment + dot-source. Easy to find and remove.
  4. Idempotent: a marker comment (# codegraph completions) is used to detect "already wired up" — re-running --install won't duplicate the line. Verified in the smoke harness.

Matches what oclif does. If you'd rather punt the $PROFILE edit to the user (write the .ps1 and tell them the line to add), I'm happy to change it.

Testing

# Unit tests (31 assertions, runs in <1s)
npx vitest run __tests__/completions.test.ts

# End-to-end smoke (Docker, opt-in — NOT wired into `npm test`)
npm run smoke:completions
# Expected final line: smoke: zsh bash fish powershell OK

The smoke harness:

  • npm packs the artifact and npm install -gs it inside a pinned Node 22 + zsh + bash + fish + pwsh 7.6.1 container
  • Tests against the actual published artifact, not the dev tree (catches packaging mistakes — wrong files: list, missing chmod, etc.)
  • Covers full content for bash (COMPREPLY-driven), fish (complete -C), and PowerShell (TabExpansion2); structural for zsh
  • Verifies oh-my-zsh tier-1 detection by creating a fake $ZSH dir mid-test
  • Verifies PowerShell --install idempotency on re-run
  • Verifies unsupported shells (nushell) exit non-zero with a clear message

The pwsh binary is pulled from PowerShell's GitHub releases (multi-arch tarball, pinned to 7.6.1) because Microsoft's Debian apt repo doesn't ship arm64. Image rebuilds in ~30 s cold, ~2 s warm. Adds ~250 MB to the image; not pushed anywhere, just used locally.

Isolation from the existing test suite

  • npm test behavior is unchanged. The smoke is opt-in via npm run smoke:completions.
  • vitest.config.ts untouched.
  • Everything Docker-related lives under docker/ which is excluded from npm pack by the existing files: ["dist","scripts","README.md"] allowlist — nothing new ships to npm consumers.
  • No new runtime or dev dependencies in package.json.

Verified

  • npx tsc --noEmit clean
  • npm run build succeeds
  • npx vitest run __tests__/completions.test.ts → 39/39 pass
  • npx vitest run → 681/682 pass (1 pre-existing watcher flake on Node 26; unrelated to this PR — git stash && vitest run __tests__/watcher.test.ts reproduces on master)
  • npm run smoke:completions → exit 0
  • Injection sanity test: deliberately remove complete -F _codegraph codegraph from the bash emitter, rebuild, rerun smoke → fails at the bash:registration assertion with exit 1. Restored.
  • Interactive pwsh test on macOS (PowerShell 7.7-preview): caught two bugs missed by all prior structural tests — duplicate --version at root (commander's auto-registered Option colliding with a hardcoded one) and missing -h/--help on subcommands (commander hides helpOption from cmd.options). Both fixed in commit cb2389e via a single helpOption injection in the introspect layer; commit 45b8c3b adds 8 new vitest assertions + per-shell smoke assertions that explicitly count occurrences (would have caught both bugs). Demonstrates the smoke harness's value end-to-end — and exactly the kind of bug class structural tests miss.

Out of scope (deliberately)

  • CI workflow — repo has no .github/workflows/. Adding one expands PR scope and is your call. The npm script is CI-ready when wanted.
  • Windows-native smoke — the Docker container is Linux. Windows pwsh codepath (process.platform === 'win32' branch in detectInstallTarget) is covered by unit tests with mocked process, but not end-to-end. Adding a Windows container is possible but expensive.
  • --uninstallnpm uninstall -g doesn't clean these files (and shouldn't — they survive the package removal and become inert). Easy follow-up if you want it.
  • PowerShell $PROFILE confirmation prompt — see "Design calls" above.
  • elvish / xonsh / nushell — only zsh/bash/fish/powershell are emitted. The other shells fail cleanly with "Unsupported shell" + the supported list. Drop in another emitter under src/completions/ if you want to add one later — the pattern is uniform.

Suggested CHANGELOG entry

### Added
- **Shell completions** (`codegraph completions <shell>`): generate Tab-completion
  scripts for zsh, bash, fish, and PowerShell (aliases: `pwsh`, `ps`). With
  `--install`, codegraph auto-detects the right location for the user's
  environment — oh-my-zsh's `$ZSH/completions/`, Homebrew's
  `share/zsh/site-functions/`, the XDG bash-completion path, fish's
  auto-discovered config dir, or PowerShell's `$PROFILE` (with idempotent
  dot-source line append). Unknown shells exit non-zero with a hint. The
  detected location is reported as `(detected: <tier>)` so users see which
  rule fired. Docker-based smoke harness (`npm run smoke:completions`)
  verifies the generated scripts work end-to-end in real shells.

Commits

This PR is 6 logically-distinct commits, reviewable in order:

  1. feat(cli): add codegraph completions <shell> for zsh/bash/fish — the emitter foundation
  2. test(completions): add docker-based end-to-end smoke test — the harness
  3. feat(completions): auto-detect install target + add PowerShell support — the detection tiers + pwsh
  4. docs(completions): expand README section with per-shell setup — usage docs
  5. fix(completions): inject --help/-h once, drop duplicate --version — bug found via interactive pwsh testing on macOS; fixed at introspect layer
  6. test(completions): regression coverage for --help/-h on subs and --version dedupe — vitest + smoke assertions that would have caught the bug

Happy to squash if you'd prefer a single commit on merge.

Currently codegraph has no Tab-completion: users typing
`codegraph i<TAB>` get nothing. Hand-written emitters walk
commander's command/option/argument tree and produce a static script
per shell, mirroring the approach used by sema-lisp (clap_complete)
and fedit (System.CommandLine hand-rolled).

- `codegraph completions <zsh|bash|fish>` prints the script to stdout
- `--install` writes to the standard per-shell location:
    zsh  → ~/.zsh/completions/_codegraph
    bash → ~/.local/share/bash-completion/completions/codegraph
    fish → ~/.config/fish/completions/codegraph.fish
- Argument-name heuristic infers file/directory hints (positional
  named `path` → _files; option value `<path>` → file completion)
- Aliases are routed to the canonical command function so e.g.
  `codegraph plugin<TAB>` works the same as `codegraph plugins<TAB>`
  (no aliases today, but the dispatcher is alias-aware)
- Zero new runtime dependencies — uses commander's existing
  introspection API (`cmd.options`, `cmd.registeredArguments`,
  `cmd.commands`)

PowerShell and elvish deferred — minimal demand for codegraph's
audience; can be added by dropping in another emitter under
src/completions/.

Tests: 16 structural assertions covering shell parsing, install
paths, per-shell output shape, and the alias-dispatch / value-hint
logic. Snapshot tests intentionally avoided since they break on
every CLI description tweak.
The 16 vitest tests in __tests__/completions.test.ts pin substrings
in the generated script but don't prove that the script actually
works once installed. A malformed _arguments spec parses fine,
autoloads fine, and silently produces zero completions — pure-text
assertions miss that whole class of bug.

This adds an opt-in smoke harness that:

- npm pack's the actual published artifact (catches packaging regressions)
- builds a pinned Node 22 + zsh + bash + fish container
- installs the tarball + runs `codegraph completions <shell> --install`
- drives real shell-completion machinery and asserts content:
  - bash:  sources the file, verifies `complete -F _codegraph codegraph`
           registered the function, then drives COMPREPLY assertions
           for top-level commands, subcommand flags, --path file hints,
           and global --help/--version
  - fish:  uses `complete -C "codegraph …"` (fish exposes the full
           completion path non-interactively) and asserts stdout
  - zsh:   structural — script parses (`zsh -n`), is registered by
           compinit (`whence -w _codegraph`), and a cross-section of
           per-subcommand helpers is defined after autoload

zsh content-testing is intentionally NOT done. `_values` and
`_arguments` require `_main_complete` running under a real ZLE widget
context (compstate, state, opt_args); scripts can't manufacture that,
so a `compadd` shim captures nothing — verified by a failed canary
attempt. PTY+expect is the only way to drive the full path and is too
flaky across zsh 5.7/5.8/5.9 for CI. Industry standard (clap_complete,
oclif, click, Commander.js itself) is structural-only for zsh; we
match that bar.

Isolation:
- Wired only via `npm run smoke:completions`. Not in `npm test`.
- vitest.config.ts unchanged (smoke count: 0).
- docker/ is excluded from npm pack by the existing `files:` allowlist
  ["dist","scripts","README.md"], so this adds no published surface.
- No new runtime or dev dependencies.

Verified end-to-end:
- Clean run: `smoke: zsh bash fish OK` (exit 0)
- Injection: removing the `complete -F` line from src/completions/bash.ts
  causes smoke to fail at the bash registration check with exit 1.
The previous --install logic wrote to a fixed path per shell, which
meant zsh users got a file at ~/.zsh/completions/_codegraph and a
follow-up "now edit ~/.zshrc to add this to your fpath" hint. That
matches what gh, kubectl, rustup, Jottacloud all do — universally
broken UX where the install isn't actually self-contained.

This adds real detection so --install picks a location that's
already on the shell's load path wherever possible:

  zsh:        $ZSH/completions (oh-my-zsh)  →
              <prefix>/share/zsh/site-functions if writable  →
              ~/.zsh/completions (fallback, with fpath hint)
  bash:       <homebrew>/etc/bash_completion.d if writable   →
              XDG ~/.local/share/bash-completion/completions
  fish:       ~/.config/fish/completions (already auto-discovered)
  powershell: standalone ~/.config/powershell/codegraph.ps1
              + idempotent dot-source line in $PROFILE

The installer reports `(detected: <tier>)` on every run so users see
which path won. Unknown shells (nushell, etc.) exit non-zero with a
hint instead of writing somewhere wrong.

PowerShell support follows the clap_complete static pattern:
Register-ArgumentCompleter -Native, walk $commandAst.CommandElements
into a semicolon-joined path, switch on that path, emit
[CompletionResult] entries filtered by $wordToComplete. Tested
non-interactively via TabExpansion2 inside pwsh -NoProfile — clean
analog to fish's complete -C.

Smoke harness extensions:
- Dockerfile pulls pwsh from PowerShell GitHub releases (multi-arch
  tarball; MS's Debian apt repo has no arm64). PowerShell 7.6.1 pinned.
- run.sh now parses the installer's output to find the path it
  actually wrote to — no longer assumes the fallback tier.
- test-powershell.sh dot-sources the script and uses TabExpansion2.
- test-zsh-ohmyzsh.sh creates a fake $ZSH dir to exercise tier-1.
- Idempotency check: re-running powershell --install must not append
  the $PROFILE line twice.
- Graceful-error check: `completions nushell` errors with "Unsupported
  shell" instead of writing somewhere wrong.

vitest: 31 tests (up from 16) — adds detectInstallTarget coverage
per shell, powershell emitter assertions, single-quote escape check.
The prior section covered the detection table but skipped the
practical follow-up: what to do after --install, when to restart the
shell, which shells need extra packages, what the fpath hint
actually looks like. Without these, users land on the section,
install, and then wonder why Tab does nothing.

Adds:
- "Restart your shell" callout next to the --install example
- Stdout/pipe example block for each shell (the "without --install"
  path was implicit before)
- Per-shell requirements: bash-completion install for bash, fpath
  snippet for zsh fallback tier (with note that tier 1/2 don't need
  it), pwsh 7.x vs 5.1 caveat
- PowerShell idempotency note moved into the detection table
Found via interactive pwsh testing on local macOS: `codegraph <TAB>`
listed `--version` twice and was missing `-h`. Root causes:

1. Commander 14's `.version()` registers `-V/--version` on
   `program.options`, which the introspector already walked — so my
   hand-added `--version` line in the powershell + bash emitters was
   a duplicate.

2. Commander does NOT expose `-h/--help` via `program.options`
   (helpOption sits behind internal machinery). My emitters relied on
   per-shell hardcoded lines that I only added at the root, meaning
   subcommand completions silently dropped --help.

Fix at the introspect layer: synthesize the help option once in
describeCommand() so every command (root + subs) sees it as a
regular option. All four emitters get it for free and the duplicate
hardcoded lines come out.

After fix, `codegraph <TAB>` in pwsh shows:
  --help / -h (new — was missing)
  --version / -V (no longer duplicated)
  13 subcommands
And `codegraph init -<TAB>` now correctly shows --help/-h alongside
the per-subcommand flags.

Verified via TabExpansion2 on local pwsh 7.7 (macOS) + full Docker
smoke (zsh bash fish powershell OK). 31 vitest tests still pass.
…rsion dedupe

cb2389e fixed two bugs found via interactive pwsh:
  1. duplicate --version at root (commander auto-registered + my hardcoded)
  2. missing -h/--help on subcommands (commander hides help from .options)

Existing assertions used set-membership checks (`contains "--help"`)
which would have passed even with the bugs present. This adds:

vitest (8 new assertions, now 39 total):
- zsh: count of `'(-h --help)'{-h,--help}` specs == root + every sub
- bash: every flag-completion compgen list (filtered by content, not
  by the surrounding command line) contains --help
- fish: count of `-s h -l help` declarations >= 4
- powershell: every switch arm contains both '--help' and '-h'
- powershell: --version appears exactly 2 times (one CompletionResult
  emission = 2 string literals); >2 means duplicated
- bash root flag list contains --version exactly once
- fish root contains `-l version` exactly once
- zsh `(-V --version)` pair appears exactly once

smoke (per-shell runtime assertions):
- bash: count -x --version in top-level COMPREPLY == 1; init subcommand
  COMPREPLY contains --help + -h
- fish: complete -C "codegraph init -" contains --help + -h
- powershell: init/--help, init/-h; top-level -h, -V; --version count
  via Where-Object pipeline == 1

Both layers catch both bug classes — vitest fast (~750 ms), smoke
slower but exercises the actual installed scripts in real shells.
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.

1 participant