Completion
Generate shell tab-completion scripts for bash, zsh, and fish.
The completion plugin adds a completion <shell> subcommand that emits a
tab-completion script for bash, zsh, or fish. The strategy is
pure-static: the plugin walks your live command tree at run time and
prints a self-contained script — no hidden subcommand, no runtime callbacks,
no shell-out on TAB.
Usage
import { Crust } from "@crustjs/core";
import { completionPlugin } from "@crustjs/plugins";
import pkg from "../package.json";
const main = new Crust("my-cli")
.meta({ description: "My CLI" })
.use(completionPlugin({ version: pkg.version }))
.command("build", (cmd) =>
cmd
.meta({ description: "Build artifact" })
.flags({
target: { type: "string", choices: ["browser", "bun", "node"] },
})
.run(() => {}),
)
.run(() => {});
await main.execute();$ my-cli completion bash | head -1
# completion script for my-cli v1.0.0 — regenerate with: my-cli completion bashOptions
| Option | Type | Default | Description |
|---|---|---|---|
command | string | "completion" | Subcommand name. Override only if completion collides with an existing command. |
binName | string | root command's meta.name | Binary name embedded in generated scripts (the complete -F target, etc.). |
shells | Array<"bash" | "zsh" | "fish"> | ["bash", "zsh", "fish"] | Shells written by --output-dir. The CLI accepts any of these as <shell>. |
version | string | "0.0.0" | Free-form version string embedded in script headers. Pass pkg.version. |
Per-shell install
The runtime contract is the same in every shell: print to stdout, redirect into the shell's auto-discovery directory.
Bash
# Per-user (lazy-loaded by bash-completion ≥ 2.0)
mkdir -p ~/.local/share/bash-completion/completions
my-cli completion bash > ~/.local/share/bash-completion/completions/my-cliOr eval inline (shell-startup cost):
# in ~/.bashrc
eval "$(my-cli completion bash)"The generated script ships a Cobra-style fallback init shim, so it works on
systems that do not have the bash-completion package installed (macOS
default bash, Alpine, NixOS without the package).
Zsh
# Per-user — drop into a directory on $fpath
mkdir -p ~/.zsh/completions
my-cli completion zsh > ~/.zsh/completions/_my-cliThen in ~/.zshrc, before compinit:
fpath=(~/.zsh/completions $fpath)
autoload -Uz compinit && compinitOr eval inline:
# in ~/.zshrc, after `autoload -Uz compinit && compinit`
eval "$(my-cli completion zsh)"Fish
my-cli completion fish > ~/.config/fish/completions/my-cli.fishFish auto-loads files in ~/.config/fish/completions/ the first time you
invoke the matching command. No source line needed.
Or eval inline:
# in ~/.config/fish/config.fish
my-cli completion fish | sourcePackaging recipes
The generated filenames match every major packaging system's expectations
(<bin> for bash, _<bin> for zsh, <bin>.fish for fish), so no rename is
needed. The two installation patterns are runtime-invocation (run the
binary at install time) and pre-generated files (ship the artifacts in
the source tarball).
Homebrew
Modern formulae use the generate_completions_from_executable helper, which
runs the freshly-built binary at brew install time:
class MyCli < Formula
# ...
def install
bin.install "my-cli"
generate_completions_from_executable(bin/"my-cli", "completion")
end
endThe helper passes the shell name (bash/zsh/fish) as the trailing argv,
which matches the plugin's positional <shell> argument exactly.
Nix
installShellCompletion from installShellFiles accepts the artifacts
directly with no --cmd rename:
{ stdenv, installShellFiles }:
stdenv.mkDerivation {
# ...
nativeBuildInputs = [ installShellFiles ];
postInstall = lib.optionalString (stdenv.buildPlatform.canExecute stdenv.hostPlatform) ''
installShellCompletion --cmd my-cli \
--bash <($out/bin/my-cli completion bash) \
--zsh <($out/bin/my-cli completion zsh) \
--fish <($out/bin/my-cli completion fish)
'';
}The canExecute guard is required: under cross-compilation the host can't
run the target binary. Cross builds will skip completion install — see the
Cross-compile note below for the workaround.
AUR (Arch Linux)
Standard PKGBUILD pattern:
package() {
install -Dm755 my-cli "$pkgdir/usr/bin/my-cli"
install -Dm644 <("$pkgdir/usr/bin/my-cli" completion bash) \
"$pkgdir/usr/share/bash-completion/completions/my-cli"
"$pkgdir/usr/bin/my-cli" completion zsh \
> "$pkgdir/usr/share/zsh/site-functions/_my-cli"
"$pkgdir/usr/bin/my-cli" completion fish \
> "$pkgdir/usr/share/fish/vendor_completions.d/my-cli.fish"
}Debian
For bash-only completion (the common case in Debian), add a
debian/my-cli.bash-completion file via dh_bash-completion. The newer
dh-shell-completions helper supports zsh/fish too.
Cross-compile note (nixpkgs)
bun build --compile produces per-platform binaries. When nixpkgs builds
aarch64-darwin from x86_64-linux (and vice versa), the binary cannot run at
build time and the canExecute guard above skips completion install — your
aarch64-darwin users get no completion.
The fix is to ship pre-generated completion files in the npm tarball so
the derivation never needs to invoke the binary. Add a prepublishOnly
script:
// package.json
{
"scripts": {
"prepublishOnly": "bun run dist/my-cli.js completion bash --output-dir completions/",
},
"files": ["dist", "completions"],
}Then a Nix derivation can copy the files verbatim with no canExecute guard:
postInstall = ''
installShellCompletion \
--bash completions/my-cli \
--zsh completions/_my-cli \
--fish completions/my-cli.fish
'';Passing --output-dir <path> writes all configured shells in one
invocation (bash/zsh/fish by default; restrict via the shells option), with
canonical filenames the helpers consume directly.
Drift handling
A pure-static script is a snapshot of the command tree at the moment you generated it. After upgrading the CLI, regenerate:
my-cli completion bash > ~/.local/share/bash-completion/completions/my-cliEvery generated script's first line includes the binary name and version it
was built from, so users (and diff) can spot stale completion files at a
glance:
# completion script for my-cli v1.0.0 — regenerate with: my-cli completion bashFor installation paths that re-run the CLI on each login (eval "$(my-cli completion bash)" in ~/.bashrc), drift is impossible — the cost is paying
the CLI's startup time on every shell launch.
Limitations (v1)
- No dynamic value completion. Per-flag/per-arg
complete?:callbacks are intentionally deferred to a future minor bump. Adding them is non-breaking, so v1 ships pure-static today and grows into a hybrid later. Use the staticchoicesfield on flags and args (see@crustjs/core) for fixed enums today; that's enough for the majority of CLI flags. - No PowerShell. PowerShell completion is on the v2 roadmap.
--output-dirwrites every configured shell. Even if you invokemy-cli completion bash --output-dir foo/, the plugin writesfoo/my-cli,foo/_my-cli, andfoo/my-cli.fish. This matches the packaging-time use case where distributors want every artifact in one go. Use the print path (no--output-dir) when you want a single shell only.- Strict identifier and choice-value validation. Command names, flag
names, flag aliases, short flags, arg names, and
binNamemust match/^[A-Za-z0-9][A-Za-z0-9._-]*$/. Choice values (on flags and args) must match/^[A-Za-z0-9][A-Za-z0-9_.+:@/-]*$/— enough for things likeus-east-1,node@20, ortext/plain, but not whitespace, quotes, or shell metacharacters. The plugin throws atsetup()time with a clear error if any input falls outside this set, rather than silently mis-quote the generated script. Description text is free-form and goes through per-shell escaping; control characters (newlines, NUL, ESC, …) are scrubbed at the boundary so they cannot break out of comment lines or shell-quoted strings.