Skip to content

feat: native shell completions for the CLI (#231) - #235

Merged
matheuswhite merged 2 commits into
mainfrom
feat/231-shell-completions
Jul 30, 2026
Merged

feat: native shell completions for the CLI (#231)#235
matheuswhite merged 2 commits into
mainfrom
feat/231-shell-completions

Conversation

@matheuswhite

Copy link
Copy Markdown
Owner

Summary

Closes #231. scope completions <SHELL> prints a native completion script to stdout, so scope se + Tab completes to scope serial using the shell's own completion engine — nothing is drawn by the app.

Generated ahead of time by clap_complete for bash, zsh, fish, powershell and elvish.

Verified by hand, per shell

The issue requires Windows (powershell), macOS and Linux (bash, zsh, fish). Each was driven for real, not inspected:

shell how it was checked result
bash 3.2.57 (macOS system bash) source the script, call the registered function with COMP_WORDS/COMP_CWORD COMPREPLY=[serial]
bash 5.3.9 same COMPREPLY=[serial]
zsh 5.9 interactive zsh in a PTY, script on $fpath as _scope exactly as the README documents, real Tab line redrew as scope serial
fish 4.8.1 complete -C 'scope se' serial
PowerShell 7.5.4 dot-source the .ps1, TabExpansion2 serial

Two silent traps, both pinned by tests

  1. Cli needs an explicit #[command(name = "scope")]. clap_derive otherwise names the command after CARGO_PKG_NAME (scope-monitor), so the script emits #compdef scope-monitor and Tab never fires. Removing the attribute fails every_shell_emits_a_script_for_the_scope_binary.
  2. The arm is dispatched right after Cli::parse(), before the fallible-setup closure, and returns early. A completion script is evaluated on every shell start-up, so it must not read config.toml — one typo there would break the user's prompt rather than just scope — and must not reach the println!("See you later ^^") epilogue, which the shell would try to execute. Moving the arm inside the closure fails three tests, the real-zsh one included (the epilogue genuinely breaks completion, it is not a cosmetic issue).

The Commands::Completions { .. } => unreachable!() arm inside the closure exists only to keep the match exhaustive — so this placement cannot silently regress.

Deliberately static, not dynamic

clap_complete's unstable-dynamic API would allow completing values (live serial ports for scope serial <TAB>), but it is semver-exempt across two feature surfaces, its bash/fish hooks drop the filename fallback --tag-file needs, and live values would come from the USB-only list::usb_ports with no fallback — so /dev/ttyS0 and socat PTYs would complete to nothing. Live value completion is a follow-up, not part of closing v0.6.0.

Dependency cost: clap_complete adds exactly one crate; clap is bumped to its 4.5.20 floor in the manifest.

Tests

tests/completions.rs, 10 tests in two layers:

  • Portable script assertions (all three CI OSes, no shell needed): every shell emits a non-empty script naming scope and never scope-monitor; the script is the only thing on stdout; it covers every subcommand and global flag; an unknown or missing shell is an error with no partial script; and a malformed config.toml does not stop the script.
  • Real completion, presence-gated (skip: message, never a failure): bash, fish, zsh (PTY) and two PowerShell cases — including bare scope <TAB> listing the subcommands, which is the case a broken hook fails first.

CI covers bash + PowerShell on all three runners and zsh on macOS; fish skips there and is covered locally.

cargo test --locked --bin scope           # 239 passed
cargo test --locked --test completions    # 10 passed
cargo test --locked --test tui_e2e -- --test-threads=1   # 25 passed, 1 ignored
cargo test --locked --test release_security              # 6 passed
cargo fmt --check                         # clean; build warning-free

Docs

  • README → Installation → Shell completions: per-shell install instructions, including the two mistakes that fail silently (macOS bash reads ~/.bash_profile, not ~/.bashrc, and never reads the drop-in completions dir under bash 3.2; zsh needs fpath extended before compinit), plus PowerShell's $PROFILE paths and the 5.1 Restricted execution policy.
  • README → Commands table row, and a Troubleshooting entry.
  • scope completions --help repeats the install commands, so the README isn't needed on the target machine.
  • scope --help now describes every subcommand (they had no doc comments before) and carries a tip pointing at scope completions --help.

Known limitations

  • No install path sets completions up for the user (cargo install, curl|sh, irm|iex, .msi): they run one command and restart the shell. dist's include reaches the tarballs but not the .msi, and the installers copy only binaries — so shipping the scripts would help nobody on the advertised paths. The after_help tip is the mitigation.
  • Windows is verified through pwsh 7.5.4 on macOS, not on real Windows; the windows-latest job running these tests is what closes that gap — worth watching on this PR's first run.
  • ble is offered by Tab even though it is unimplemented (clap_complete emits hidden subcommands too); its description now says so.
  • scope serial <TAB> falls back to filenames, and elvish is advertised but exercised nowhere.

🤖 Generated with Claude Code

matheuswhite and others added 2 commits July 29, 2026 15:41
Closes #231. `scope completions <SHELL>` prints a completion script to stdout
for bash, zsh, fish, powershell or elvish, generated ahead of time by
clap_complete, so `scope se` + Tab completes to `scope serial` through the
shell's own completion engine.

Verified by hand on every shell the issue asks for: bash 3.2.57 (the macOS
system bash) and 5.3.9, zsh 5.9 (real Tab in a PTY, installed exactly as the
README documents), fish 4.8.1 and PowerShell 7.5.4.

Two constraints are not obvious and both fail silently, so both are pinned by
tests. First, `Cli` needs an explicit `#[command(name = "scope")]`: clap_derive
otherwise names the command after CARGO_PKG_NAME (`scope-monitor`), and the
script registers a completion for a command nobody runs. Second, the arm is
dispatched right after `Cli::parse()`, before the fallible-setup closure, and
returns early — a completion script is evaluated on every shell start-up, so it
must not read `config.toml` (one typo there would break the user's prompt
rather than just scope) and must not reach the `See you later ^^` epilogue,
which the shell would try to execute. With the arm inside the closure, three of
the new tests fail, the zsh one included.

Static generation on purpose, not the `unstable-dynamic` API: that feature is
semver-exempt, its bash and fish hooks drop the filename fallback that
`--tag-file` needs, and live port values would come from the USB-only
`list::usb_ports` with no fallback. Completing live serial ports is a follow-up.

The subcommands also gained the doc comments clap needs to describe them in
`--help` and in the scripts, and the root command an `after_help` tip pointing
at `scope completions --help` — the only hint installer users ever see, since
no install path sets completions up for them.

tests/completions.rs has two layers: portable assertions on the emitted script
(runs on all three CI OSes, and is what guards the two constraints above) and
real completion driven through bash, fish, zsh and PowerShell, each skipped
when its shell is absent so no runner fails for want of a shell.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The static completion scripts surface argument descriptions (zsh and fish show
them next to each candidate), which exposed that `-c/--capacity`, `-t/--tag-file`
and `-l/--latency` never had doc comments — they completed and helped with an
empty description. Neither did any positional: `serial <PORT> <BAUDRATE>`,
`rtt <TARGET> <CHANNEL_NUM>`, `ble <NAME_DEVICE> <MTU>` and `list --verbose`.

Describe all of them, including where each value actually comes from: capacity
and tag_file fall back to config.toml before their built-in default, tag_file is
used verbatim (no `~`/`$VAR` expansion), latency is in microseconds and 0 yields
instead of sleeping, and the RTT channel defaults to 0.

Also fix CLAUDE.md, which documented --latency in milliseconds. Every polling
loop treats it as microseconds (`plugin/engine.rs`, `graphics/headless.rs`,
`graphics/graphics_task.rs`, `interfaces/serial_if.rs`) except
`interfaces/rtt_if.rs::wait`, which uses `from_millis` — so with the default the
RTT loop sleeps 1000x longer than every other. The README already said
microseconds; the divergence in rtt_if is left alone here, since changing an
interface's polling rate is not a documentation change.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@matheuswhite
matheuswhite merged commit 512ebb0 into main Jul 30, 2026
13 checks passed
@matheuswhite
matheuswhite deleted the feat/231-shell-completions branch July 30, 2026 12:50
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.

Auto complete at CLI

1 participant