Skip to content

feat(cli): tab completion for bash, zsh and fish - #228

Merged
jvsena42 merged 3 commits into
mainfrom
feat/cli-shell-completion
Sep 4, 2026
Merged

feat(cli): tab completion for bash, zsh and fish#228
jvsena42 merged 3 commits into
mainfrom
feat/cli-shell-completion

Conversation

@jvsena42

@jvsena42 jvsena42 commented Sep 4, 2026

Copy link
Copy Markdown
Owner

loopky had no tab completion. It does now, for bash, zsh and fish.

eval "$(loopky completion bash)"                                 # in ~/.bashrc
loopky completion zsh > "${fpath[1]}/_loopky"
loopky completion fish > ~/.config/fish/completions/loopky.fish

Commands, subcommands, every flag, and the values of the flags with a closed set — --env,
--separator, the shell name. import completes filenames; so do --from-file and --qr-out.
The .deb and the Homebrew formula install all three for you.

Generated from one table, not checked in as three scripts

A completion cannot fail loudly. It offers a flag the parser refuses, or stays silent about one
that works, and the user shrugs and types it out — so three files that were right the day they
were written go wrong at the first flag added afterwards, with nothing reporting it.

CommandSurface.kt describes the commands, their options and what each option accepts; the three
generators read that and nothing else. CompletionTest holds it against Args.SWITCHES and
against the usage block in both directions, so a flag that nothing describes fails the build. The
.deb and the formula generate their copies by running the binary they are packaging, for the
same reason.

Four things not to undo

  • Nothing it offers touches the network. Completing a deck id after deck show would put a
    homeserver round trip on a keypress — a tab that hangs for a second on a good network and
    forever on the hourly-expired session (Every homeserver write dies on /session with a raw transport error, and publish reports it as "check your connection" #165), in the one place you cannot interrupt without
    losing the line. Deck ids are Operand.Opaque: zsh and fish name the word and offer nothing. A
    test asserts no script contains an address.
  • completion runs before Koin, beside update. Generating a static string must not depend
    on libpubkycore loading, so a host outside the shipped matrix can still install completions.
  • The update check is off for this verb. The script's documented home is a line in .bashrc,
    which puts this command in the path of every new shell; the check is awaited before exit, and a
    terminal that opens a second slower once a day is a bug nobody would trace back here.
  • Each script walks the line rather than counting words. loopky --env staging deck create is
    legal, and "the first one or two words" reads staging as the command. fish's own
    __fish_seen_subcommand_from has the same flaw one layer up — it matches a word anywhere on the
    line, an option's value included — so the fish script computes the command itself.

--yes and --force stay parseable and are deliberately not offered: nothing here prompts, so
advertising them would describe a confirmation step that does not exist. UNOFFERED_SWITCHES
records that as a decision the test can tell apart from an omission.

The installer only mentions the command. Enabling completions means writing to a shell rc file or
a system directory, and an installer that edits ~/.bashrc behind you is one you cannot cleanly
undo — on a box where the whole install is "copy one file", that is the wrong trade.

Verified by driving the shells, not by building

Full table in journeys/RESULTS.md. bash locally, zsh and fish in alpine:3.20 containers, since
neither is installed on this machine. Every case passed, including the two that motivated the walk:
loopky --env staging deck <TAB> and loopky card add abc --front x --ba<TAB>.

Driving zsh found a bug a green build could not. The script ends with _loopky "$@", which is
correct when zsh autoloads it out of $fpath — and wrong for the eval "$(loopky completion zsh)" install the same file documents, where it runs _loopky outside any completion context and
prints "command not found" for _describe on every new shell. It now branches on funcstack[1]
and registers with compdef on the sourced path; both documented install lines work as written.

CI gains a step that runs bash -n / zsh -n / fish -n over the three scripts and sources the
bash one — the one class of failure a Kotlin test cannot see, and the one that lands in a user's
.bashrc as a syntax complaint about a file nobody wrote.

./gradlew :cli:test :detektAll green. Reasoning in docs/Architecture.md §13.13; usage in
cli/README.md. No app UI is touched, so no journeys/*.xml applies.

🤖 Generated with Claude Code

https://claude.ai/code/session_0116NZ92Pe7aGQpqtiRkP4Ye

jvsena42 and others added 3 commits September 4, 2026 09:19
…command table

`loopky completion bash|zsh|fish` prints a completion script on stdout, for `eval` or
for a file in the directory the shell already searches.

Generated from one table (`CommandSurface.kt`) rather than checked in as three
scripts. A completion cannot fail loudly — it offers a flag the parser refuses, or
stays silent about one that works, and the user shrugs and types it out — so three
files that were right on the day they were written would go wrong at the first flag
added afterwards with nothing reporting it. `CompletionTest` holds the table against
`Args.SWITCHES` and against the usage block, in both directions, so a flag that
nothing describes fails the build instead.

Four things not to undo:

- **Nothing it offers touches the network.** Completing a deck id after `deck show`
  would put a homeserver round trip on a keypress — a tab that hangs for a second on
  a good network and forever on an expired session, in the one place the user cannot
  interrupt without losing the line. Deck ids are `Operand.Opaque`: zsh and fish name
  the word and offer nothing. A test asserts no script contains an address.
- **`completion` runs before Koin, beside `update`.** Generating a static string must
  not depend on `libpubkycore` loading, so a host outside the shipped matrix can still
  install completions.
- **The update check is off for this verb.** The script's documented home is a line in
  `.bashrc`, which puts this command in the path of every new shell; the check is
  awaited before exit, and a terminal that opens a second slower once a day is a bug
  nobody would trace back to a completion script.
- **Each script walks the line rather than counting words.** `loopky --env staging deck
  create` is legal, and taking "the first one or two words" reads `staging` as the
  command. fish's own `__fish_seen_subcommand_from` has the same flaw one layer up —
  it matches a word anywhere on the line, an option's *value* included — so the fish
  script computes the command itself.

`--yes` and `--force` stay parseable and are deliberately not offered: nothing here
prompts, so advertising them would describe a confirmation step that does not exist.
`UNOFFERED_SWITCHES` records that as a decision the test can tell from an omission.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0116NZ92Pe7aGQpqtiRkP4Ye
…pts parse

The `.deb` and the Homebrew formula now ship all three completions, **generated by
running the binary they are packaging** rather than copied from the tree — the scripts
describe one specific build's surface, and a checked-in copy would drift the first time
a flag is added.

The `curl` installer only *says* the command exists. Enabling completions means writing
to a shell rc file or a system directory, and an installer that edits `~/.bashrc` behind
you is one you cannot cleanly undo — on a box where the whole install is "copy one
file", that is the wrong trade.

CI syntax-checks all three, which is the one thing a unit test cannot do: `CompletionTest`
asserts what is *in* each script, and nothing in Kotlin notices an unbalanced quote or a
`case` missing its `esac`. That failure lands in the user's `.bashrc` as a syntax complaint
about a file nobody wrote. `-n` is parse-only, so the step needs no session and no network;
the bash script is additionally sourced and `complete -p` asserted, since a script that
parses can still define nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0116NZ92Pe7aGQpqtiRkP4Ye
Architecture §13.13 for the reasoning — why it is generated rather than shipped, why
nothing it offers reaches the network, why it runs before Koin with the update check
off, and why each script walks the line instead of counting words. §13.14/§13.15
renumbered.

`journeys/RESULTS.md` for the run: bash locally, zsh and fish in containers, driven
rather than built. That is what caught the zsh bug — the trailing `_loopky "$@"` is
right for an autoloaded `$fpath/_loopky` and wrong for the `eval` install the same file
documents, where it runs the function outside any completion context and prints
"command not found" for `_describe` on every new shell.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0116NZ92Pe7aGQpqtiRkP4Ye
@jvsena42
jvsena42 merged commit d3b7743 into main Sep 4, 2026
4 checks passed
@jvsena42
jvsena42 deleted the feat/cli-shell-completion branch September 4, 2026 12:44
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