Your team ships an internal CLI called snap. It creates named snapshots of some resource, lists them, deletes them, restores them. Every engineer on the team types snap <TAB> a hundred times a week and gets nothing back, because nobody wrote the completion function. So they type the subcommand out. They copy snapshot names from snap list output. They hit snap delete production-2026-09-21 and get “no such snapshot” back — off by a day. They do this every day, forever, because a completion function feels like magic — some artifact bundled with the tool by the original author, not a fifty-line shell function anyone on the team could write.
Zsh’s completion system — compsys, documented in zshcompsys(1) — is the one subsystem the overview argued was worth switching for on its own. This post walks through writing a completion function for a made-up snap CLI from an empty file to a working script that handles subcommand dispatch, contextual arguments, and dynamic name completion driven by the tool’s own output. The techniques generalize to every internal CLI your team has ever pretended not to need this for.
This is the fourth and final post in the small zsh series. Read the overview for why bother; the glob-qualifier and parameter-expansion posts for the two syntactic features that complement this one. All examples run under an unmodified zsh with autoload -Uz compinit; compinit in .zshrc — the default setup any framework distribution already has.
The CLI we’re completing for
Assume snap takes this shape:
snap create <name> [--tag=<tag>] [--verbose]
snap list [--filter=<pattern>]
snap delete <name> [--force]
snap restore <name>
snap help [<subcommand>]
createtakes a positional name (a new snapshot ID the user is inventing), optional--tag(a category), and optional--verbose.listtakes an optional--filterthat accepts a glob.deleteandrestoretake a name that must already exist — this is where dynamic completion pays off.helptakes an optional subcommand name for context-specific help.
We’ll build the completion function in six steps. At the end there’s a “what this post does not tell you” section on the pieces I left out — nested subcommands, caching, zstyle, and the reasons the bash-completion shim rarely feels right.
Step 1: the file, the shebang-that-isn’t, and compdef
Zsh’s completion loader (compinit) scans $fpath for files whose name matches _<command>. For our snap CLI the file is called _snap. It has no shebang — it’s sourced by compinit, not executed — and it starts with a #compdef directive that tells compinit which command it completes for:
#compdef snap
# _snap — zsh completion function for the snap CLI.
# Loaded lazily via compinit; put this file on $fpath.
_snap() {
_arguments '1: :(create list delete restore help)'
}
_snap "$@"
Save that as _snap in ~/.zsh/completions/, add that directory to $fpath before compinit runs:
# In .zshrc, BEFORE compinit:
fpath=(~/.zsh/completions $fpath)
autoload -Uz compinit
compinit
Restart the shell (or rm ~/.zcompdump; compinit to force a reload), then snap <TAB> offers the five subcommands. That’s the smallest useful completion function.
The _arguments builtin does most of the work. Its argument grammar takes some getting used to, so we’ll unpack it piece by piece.
Step 2: subcommand dispatch with _arguments
The 1: :(...) from step 1 says “the first positional argument is one of these literal words.” That’s fine for the top level, but each subcommand needs its own completion — create accepts different flags from list. _arguments supports this via the ->STATE action, which tells the completion system to jump into a case branch named STATE after matching:
#compdef snap
_snap() {
local -a subcommands
subcommands=(
'create:take a new snapshot'
'list:show existing snapshots'
'delete:remove a snapshot'
'restore:roll back to a snapshot'
'help:show help for a subcommand'
)
_arguments -C \
'1: :->subcommand' \
'*:: :->subcommand_args'
case $state in
subcommand)
_describe -t commands 'snap subcommand' subcommands
;;
subcommand_args)
case $words[1] in
create) _snap_create ;;
list) _snap_list ;;
delete) _snap_delete ;;
restore) _snap_restore ;;
help) _snap_help ;;
esac
;;
esac
}
Three things to notice:
-Ctells_argumentsto keep the shell’s$CURRENT,$words, and related variables consistent when it recurses. Every real completion function passes-C; without it, subcommand dispatch breaks in ways that look like completion just not working.'1: :->subcommand'— the leading1:says “position 1,” the middle:is the description slot (empty), and->subcommandsets$statetosubcommand. The colons look weird until you notice every_argumentsspec has the same shape:<position>:<description>:<action>.'*:: :->subcommand_args'— the*::means “all remaining arguments, and hand them to the next completion in the recursive call.” This is what makes subcommand-specific completion possible: once the user has typed a subcommand and moved past it, the remaining words become the “argv” for the subcommand’s own completion function.
_describe is a helper that turns an array of name:description pairs into a completion menu with help text visible next to each entry. It’s the thing that makes zsh’s completion feel documented — every candidate can carry a one-line explanation.
Step 3: per-subcommand completion functions
Each _snap_<subcommand> is its own _arguments call. This is where the language gets expressive:
_snap_create() {
_arguments \
'--tag=[category tag]:tag name:(prod staging dev)' \
'--verbose[print detailed progress]' \
':snapshot name:'
}
_snap_list() {
_arguments \
'--filter=[glob pattern to filter by]:pattern:'
}
_snap_delete() {
_arguments \
'--force[skip confirmation]' \
':snapshot name:_snap_names'
}
_snap_restore() {
_arguments \
':snapshot name:_snap_names'
}
_snap_help() {
_arguments \
':subcommand:(create list delete restore)'
}
Reading --tag=[category tag]:tag name:(prod staging dev):
--tag=— the flag name, and the=says “the value follows an equals sign,” so--tag=prodcompletes but--tag prod(space-separated) would need--tag+[...]instead.[category tag]— the help text shown when the user hovers over the flag.:tag name:— the argument slot has a description (“tag name”) and no explicit action for what values to accept.(prod staging dev)— the actual action: complete from a literal list of three strings.
Compact, but every part is doing work. _arguments’s reference documentation (zshcompsys(1), Utility Functions section) is dense; reading five real completion functions from /usr/share/zsh/site-functions/ teaches the syntax faster than reading the manual.
For --verbose there’s no = and no value, so it’s a plain flag. [print detailed progress] is the help text.
For the positional :snapshot name: we’ve written no action, so zsh falls back to file completion. That’s fine for create (the user is inventing a name and file completion suggests nothing useful, but doesn’t get in the way). It’s not fine for delete and restore, which is what the next step fixes.
Step 4: dynamic completion — driving from the tool’s own output
The interesting one is _snap_names. delete and restore shouldn’t offer file completion — they should offer the names of snapshots that actually exist. If snap list --raw prints one name per line, we can drive completion from its output:
_snap_names() {
local -a names
names=(${(f)"$(snap list --raw 2>/dev/null)"})
_describe 'snapshot' names
}
Three moves in one function:
${(f)"$(snap list --raw)"}— the parameter-expansion flag from the previous post that splits captured output into an array of lines. Nowhile readloop, noIFSgymnastics.2>/dev/null— dropped errors. Ifsnapisn’t installed or the daemon isn’t running, we don’t want error text splattered into the completion menu. Silent-fail is the right default here._describe 'snapshot' names— feeds the array into the completion menu. No description column this time (we could add one ifsnap list --rawprintedname:descriptionper line, and it often should).
Now snap delete <TAB> calls out to the actual snap CLI, gets the current list, and offers it as tab-completion candidates. This is the technique that makes kubectl get pods <TAB> list actual pod names, docker exec <TAB> list actual container names, and git checkout <TAB> list actual branches — every one of those is a small function like _snap_names, doing exactly this.
The design lesson embedded here: make your CLI produce a machine-parseable list mode (--raw, --porcelain, --format=names, whatever). If it exists, the completion function is five lines. If it doesn’t, someone (probably you) will parse the human-readable output with sed, and the completion will break the next time the human-readable output changes.
Step 5: putting the whole file together
Combining steps 1-4:
#compdef snap
_snap_names() {
local -a names
names=(${(f)"$(snap list --raw 2>/dev/null)"})
_describe 'snapshot' names
}
_snap_create() {
_arguments \
'--tag=[category tag]:tag name:(prod staging dev)' \
'--verbose[print detailed progress]' \
':snapshot name:'
}
_snap_list() {
_arguments \
'--filter=[glob pattern to filter by]:pattern:'
}
_snap_delete() {
_arguments \
'--force[skip confirmation]' \
':snapshot name:_snap_names'
}
_snap_restore() {
_arguments \
':snapshot name:_snap_names'
}
_snap_help() {
_arguments \
':subcommand:(create list delete restore)'
}
_snap() {
local -a subcommands
subcommands=(
'create:take a new snapshot'
'list:show existing snapshots'
'delete:remove a snapshot'
'restore:roll back to a snapshot'
'help:show help for a subcommand'
)
local state
_arguments -C \
'1: :->subcommand' \
'*:: :->subcommand_args'
case $state in
subcommand)
_describe -t commands 'snap subcommand' subcommands
;;
subcommand_args)
case $words[1] in
create) _snap_create ;;
list) _snap_list ;;
delete) _snap_delete ;;
restore) _snap_restore ;;
help) _snap_help ;;
esac
;;
esac
}
_snap "$@"
Sixty-two lines. That’s the whole thing — subcommand dispatch, flag completion with help text, positional completion, dynamic name completion, and per-subcommand routing.
Step 6: installing, reloading, debugging
Installation:
mkdir -p ~/.zsh/completions
mv _snap ~/.zsh/completions/
Ensure .zshrc has ~/.zsh/completions on $fpath before the compinit call:
fpath=(~/.zsh/completions $fpath)
autoload -Uz compinit
compinit
Reloading during development is the part most tutorials skip. compinit caches its findings in ~/.zcompdump, so editing _snap doesn’t take effect until you either wait for the cache to rebuild or force it:
# Force compinit to rebuild the cache.
rm -f ~/.zcompdump*
compinit
# Or in one line, without restarting the shell:
unfunction _snap 2>/dev/null; autoload -U _snap
Debugging when the completion doesn’t fire: bindkey '^Xh' _complete_help. That binds Ctrl-X h to the completion-help widget, which prints the completion function that would be called at the current cursor position. Type snap create <cursor> and hit Ctrl-X h; zsh tells you it would call _snap_create and lists the currently-active _arguments spec. If it says _default instead of _snap_create, the completion isn’t loaded — check $fpath and compinit.
The other debugging technique is _message, a helper that displays an arbitrary string in the completion menu instead of candidates. Sprinkle _message "reached _snap_create" into a function and you get instant feedback about whether your subcommand dispatch is routing correctly.
What this post does not tell you
- It does not tell you every
_argumentsspec form. The reference (zshcompsys(1), Completion System) documents at least ten operators I did not cover:+arg(arguments that repeat),!arg(arguments to hide from completion but recognize when typed),(-)(mutual exclusion),::arg::action(optional positional), and more. The set above covers 80% of real completion functions; when you hit the other 20%, read_git,_docker, or_kubectlfor prior art. - It does not tell you how to handle command hierarchies more than one level deep.
git remote add <name> <url>is two levels of subcommand (git → remote → add). The pattern generalizes —_git_remotedispatches to_git_remote_add, and so on — but the recursion adds ceremony. Look at_gitin/usr/share/zsh/site-functions/for the shape. - It does not tell you how to cache dynamic completion output. Every time
snap delete <TAB>fires,_snap_namesshells out tosnap list --raw. For a fast command this is fine; for one that takes a couple of seconds this is unbearable._cache_invalidand_retrieve_cache/_store_cacheare the built-in helpers; they use a per-command cache keyed by a policy function you write. The Kubernetes and AWS completions use this pattern extensively. - It does not tell you the exact behavior of
zstyle. Zsh’s completion appearance and behavior — grouped output, colored candidates, menu selection with arrow keys, case-insensitive matching — are controlled byzstylerules the user puts in.zshrc. Every framework distribution ships a starter set. If your completion works but “doesn’t feel right,” the fix is usually azstyleline, not a change to your completion function. - It does not tell you bash-completion functions can be reused. They cannot, directly. If a tool ships only a
bash-completionscript, you’ll either write the zsh completion yourself or installbashcompinit, which is zsh’s compatibility shim. Bashcompinit’s completions rarely feel as good as a native zsh one; if the tool matters, write the native one.
The distillation
Zsh’s completion system is a small library plus a convention. The library is _arguments, _describe, and _message; the convention is one file named _command on $fpath per command you want to complete for. Once you’ve written one — sixty-two lines, most of it copy-pasteable from an existing completion — the rest of the tools your team ships get their completions for free, because you’ll have internalized the shape.
Pick one internal tool at your job that everyone types the same three commands into every day and doesn’t have completion for. Write _thatname. Ship it in your team’s dotfiles repo. Watch the volume of typos and copy-pastes fall the next week. This is the concrete, measurable payoff of the subsystem the overview argued was worth switching shells for on its own — and once you’ve written one, you won’t understand how you lived without them.