Stop Memorizing Flags: Programmable Completion in Zsh with compdef and zstyle
Zsh's programmable completion lets you teach TAB what your internal tools accept. A worked example with compdef, _arguments, and zstyle, plus latency and security trade-offs.
15 Jun 2026, 02:13 UTC

Every internal CLI tool eventually grows a pile of subcommands and flags that nobody remembers. The usual fix is a wiki page nobody reads. The better fix, if your team lives in zsh, is to teach the shell itself: zsh's programmable completion system lets you define exactly what TAB offers, per command, with declarative styling on top. This post walks through how the pieces fit — compinit, compdef, _arguments, and zstyle — and builds a working completion for a fictional deploy tool.
The moving parts, briefly
Zsh completion has three layers. First, compinit scans your $fpath for functions named _something and builds the completion registry; it runs once at shell startup. Second, compdef binds a completion function to a command name. Third, zstyle configures behavior — menu selection, fuzzy matching, grouping — without touching any function code.
Inside a completion function, two special variables tell you where the cursor is: $words is the command line split into words, and $CURRENT is the index of the word being completed. Most of the time you won't parse these by hand, because helper functions do it for you.
A worked example: completing a deploy tool
Say you have an internal tool ship with subcommands deploy, rollback, and status, and deploy takes --env (one of staging/prod) and --tag. Create a file named _ship in a directory on your $fpath (for example ~/.zsh/completions, added via fpath=(~/.zsh/completions $fpath) before compinit runs):
#compdef ship
_ship() {
local -a subcmds
subcmds=(
'deploy:deploy a build to an environment'
'rollback:roll back the last deploy'
'status:show current deploy status'
)
if (( CURRENT == 2 )); then
_describe 'subcommand' subcmds
return
fi
case $words[2] in
deploy)
_arguments \
'--env[target environment]:env:(staging prod)' \
'--tag[image tag]:tag:' \
'--dry-run[print actions without deploying]'
;;
rollback)
_arguments '--env[target environment]:env:(staging prod)'
;;
esac
}
_ship "$@"A few things worth noting. The #compdef ship tag on the first line lets compinit auto-register the file, so you don't need an explicit compdef call. _describe presents the subcommands with their help text in a labeled group. _arguments parses the option specs: each spec declares the flag, its help message, and — after the second colon — how to complete its argument. The (staging prod) list gives fixed candidates; an empty action after the colon means "no argument completion." Run this in your interactive shell (no special permissions needed) and then type ship deploy --env followed by TAB to check that staging and prod appear with descriptions.
Styling behavior with zstyle
The function defines what completes; zstyle defines how it feels. These go in your ~/.zshrc after compinit:
# Case-insensitive, then partial-word matching
zstyle ':completion:*' matcher-list \
'm:{a-z}={A-Z}' 'r:|[._-]=* r:|=*'
# Arrow-key menu when there are multiple candidates
zstyle ':completion:*' menu select
# Group candidates by type, with headers
zstyle ':completion:*' group-name ''
zstyle ':completion:*:descriptions' format '%F{yellow}-- %d --%f'The matcher-list entry is the highest-leverage line: the first rule makes ship dep<TAB> match deploy regardless of case, and the second lets anchors like dots and dashes act as wildcards, so rp can match roll-back-style names. Caveat: matcher-list semantics have shifted across zsh 5.x releases, so verify on your version rather than copying rules blindly from old blog posts — including this one. Run zsh --version first, and inspect active styles with zstyle -L.
Trade-offs and a security note
Two limitations matter in practice. First, latency: a completion function runs on every TAB press. If yours shells out to git or queries an API, completion in a large repo or on a slow network will stutter. Keep dynamic work cheap, cache results in a shell variable with a TTL, or fall back to static lists. Second, trust: compinit sources every _-prefixed file in $fpath. If a directory writable by other users is in that path, you've handed strangers code execution in your shell. compinit warns about insecure directories and skips them unless you pass -u; don't pass -u to silence the warning — fix the ownership instead. Check with print -l $fpath and confirm each directory is owned by you or root and not group/world-writable.
Verifying it works
In a clean shell: autoload -U compinit && compinit, then whence -w _ship should report the function as loaded, and compdef | grep ship (or compdef -d experiments in a scratch shell) confirms the binding. Then the real test: type ship TAB and see your three subcommands with their descriptions. If nothing happens, the usual culprits are the file not being named exactly _ship, the directory missing from $fpath at the time compinit ran, or a stale dump file — remove ~/.zcompdump* and re-run compinit.
The actionable takeaway: pick the one internal command your team mistypes most, write a 20-line _arguments-based completion for it, and drop it in a shared dotfiles repo. It's the rare documentation people actually use, because it shows up at the exact moment they need it.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.