Extending Zsh Completions for Custom CLI Tools Without the Framework Bloat
Write a focused Zsh completion for your custom CLI in 15 minutes — no framework required. Covers compinit loading, _arguments specs, debugging with Alt+h, and when to switch to dynamic dispatch.
17 May 2026, 15:52 UTC

The Problem: Tab Completion That Actually Knows Your Tool
You've built a CLI tool with subcommands, flags, and environment-specific options. Your team types deploy --env=staging --dry-run dozens of times a day. Without completion, they memorize flags or grep --help. With a heavy framework like oh-my-zsh, you pull in hundreds of files for a feature that lives in Zsh's core. The middle ground — writing a focused completion function — takes about 15 minutes and zero dependencies.
How Zsh Completion Loads
When compinit runs (usually in .zshrc), it scans $fpath for files starting with an underscore. Each file becomes a completion function for the command matching its suffix. A file named _deploy handles completion for the deploy command. The file must be world-readable and contain a #compdef deploy line — miss either, and Zsh silently falls back to file completion.
Completion functions run in a restricted environment: no interactive I/O, limited builtins. They populate the reply array or call compadd directly. Errors are swallowed, which makes debugging feel like guessing until you know the inspection tools.
Building a Minimal Completion for a Real CLI
Take a deploy tool with three subcommands (build, push, rollback), an --env flag taking staging or production, and a boolean --dry-run. Create ~/.zfunc/_deploy:
#compdef deploy
_arguments \
"*: :(build push rollback)" \
"--env=[environment]: :(staging production)" \
"--dry-run"
Add the directory to fpath before compinit in .zshrc:
fpath=(~/.zfunc $fpath)
autoload -Uz compinit
compinit
Now deploy --<Tab> offers --env= and --dry-run; deploy <Tab> lists subcommands; deploy --env=<Tab> completes staging and production. The _arguments spec is declarative: each quoted string describes one option or positional argument. The *: pattern matches the command position, and the colon after --env= signals a required value with the following list as candidates.
When Declarative Specs Break Down
_arguments works well for flat flag structures. Nested subcommands with different flags per subcommand (think kubectl or aws) quickly become brittle. The usual escape hatch is _call_function with dynamic dispatch: inspect $words[$CURRENT] to decide which subcommand-specific function to invoke. That moves logic into shell functions you control, at the cost of more code and manual maintenance when the CLI evolves.
Third-party frameworks (zsh-completions, fzf-tab) layer on top by adding directories to $fpath or replacing the completion widget. They don't replace the core engine. If you only need completions for a handful of internal tools, writing them directly avoids the framework's update cadence and configuration surface.
Debugging and Performance Gotchas
Since Zsh 5.8, press Alt+h on a command line to invoke _complete_help — it prints the parsed _arguments spec for the current command. Invaluable for verifying what the engine actually sees.
List every registered completion with print -l ${(k)_comps}. Inspect active zstyle patterns with zstyle -L ':completion:*'.
Caching (zstyle ':completion:*' use-cache on) speeds up slow completions that shell out to external commands (kubectl, aws). The trade-off: stale results until you run compinit -C or delete ~/.zcompcache. The completion system is single-threaded and blocks the prompt; a completion that scans a large directory or calls a remote API will visibly lag the shell. Profile with ZSH_TRACE_COMPLETION=1 time deploy --<Tab>.
Actionable Next Step
Pick one internal CLI your team uses daily. Create ~/.zfunc/_<tool> with a #compdef line and an _arguments spec covering its top-level flags and subcommands. Add ~/.zfunc to fpath before compinit, reload the shell, and verify with Alt+h. If the spec grows past ~30 lines or needs per-subcommand flags, refactor to _call_function dispatch. You'll have a maintainable, dependency-free completion that loads in milliseconds.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.