Build Context‑Aware Tab Completions for Your Custom CLI with Zsh’s Programmable Completion System
Zsh’s programmable completion lets you add context‑aware tab completions to any CLI. Learn how to write a minimal completion with _arguments, add dynamic data, debug with _complete_debug, and keep performance fast.
07 Nov 2025, 10:28 UTC

Why Tab Completion Still Matters
When you type git or kubectl and hit Tab, the shell instantly offers the right options, sub‑commands, or file names. This saves time, reduces typos, and is a core part of a productive terminal workflow. If you ship an internal tool or a new CLI, you’ll want the same level of polish without reinventing the wheel.
Zsh’s completion framework is mature, extensible, and already ships with hundreds of built‑in completions. The key is to write a small, declarative function that tells the shell how to complete your command. The rest—caching, styling, debugging—comes for free.
How Zsh Finds and Runs a Completion
Every completion is a shell function named _command (e.g., _git for git). The function is registered with compdef:
autoload -Uz compinit
compinit
compdef _mytool mytool
When you type mytool <Tab>, Zsh looks up mytool in the fpath array, finds _mytool, and executes it. The function uses helper utilities like _arguments, _files, and _values to describe the command’s syntax. The result is parsed into a list of matches that the completion widget presents.
Key points:
- fpath must contain a directory with your
_mytoolfile beforecompinitruns. - Compinit caches compiled definitions in
~/.zcompdumpfor fast startup. - Styles (e.g., case‑insensitive matching, menu selection) are set with
zstyleand apply globally or per‑command.
Writing a Minimal Custom Completion
Below is a compact example for a fictional tool mytool that supports two sub‑commands, deploy and rollback, each with a single file argument and a --verbose flag.
# ~/.zsh/completions/_mytool
# 1. Declare the function
_mytool() {
local -a subcmds
subcmds=(
'deploy[Deploy a new version]'
'rollback[Rollback to a previous release]'
)
# 2. Use _arguments to describe the syntax
_arguments \
'1:subcommand:->subcmd' \
'2:filename:_files' \
'--verbose[Enable verbose output]'
# 3. Handle the subcommand choice
case $state in
subcmd)
_describe 'subcommand' subcmds
;;
*)
;;
esac
}
# 4. Register the function
compdef _mytool mytool
Explanation of the key parts:
_argumentsdeclares the positional and flag structure. The first argument is a sub‑command; the second is a file.- The
->subcmdlabel tells the completion engine to jump to thesubcmdstate when completing the first argument. _filesautomatically offers file names for the second argument.- The
caseblock uses_describeto supply the list of sub‑commands.
Dynamic Completion: Pulling Data from an External Tool
Suppose mytool can list available deployment profiles via mytool list-profiles. You can expose those profiles as completions for the deploy sub‑command:
_mytool() {
local -a subcmds
subcmds=(
'deploy[Deploy a new version]'
'rollback[Rollback to a previous release]'
)
local -a profiles
profiles=( $(mytool list-profiles) )
_arguments \
'1:subcommand:->subcmd' \
'2::profile:->profile' \
'--verbose[Enable verbose output]'
case $state in
subcmd)
_describe 'subcommand' subcmds
;;
profile)
_describe 'profile' profiles
;;
*)
;;
esac
}
Here _describe is fed a dynamic array. Because the array is built at runtime, the completion list stays up‑to‑date with the tool’s state.
Debugging a Misbehaving Completion
When a completion doesn’t work, the _complete_debug widget is invaluable. Bind it to a key (default Ctrl‑X ?) and press it while the cursor is on the command you’re completing:
autoload -Uz _complete_debug
zle -N _complete_debug
It prints the current completion context, tags, and the list of matches. Verify that:
- The function name matches the command.
- The
statevariable is set correctly (e.g.,subcmd,profile). - Dynamic arrays are populated before
_describeis called.
Performance & Security Trade‑offs
Dynamic completions that invoke external commands can add noticeable latency, especially if the command takes >100 ms. Consider:
- Cache the output in a temporary file and refresh it on a schedule.
- Use the
zsh-asyncplugin to run the command asynchronously and update the completion once finished.
Because completion functions run in a restricted environment (no job control, limited parameter expansion), avoid side effects like modifying environment variables or launching interactive programs. Stick to read‑only operations.
Practical Checklist to Deploy Your Completion
- Place
_mytoolin~/.zsh/completionsand add that directory tofpathbeforecompinit. - Run
compinit -uto rebuild the cache if you add new completions. - Test by typing
mytool <Tab>and verifying the expected suggestions. - Use
_complete_debugif the suggestions are missing or incorrect. - Monitor latency; if the completion stalls, consider caching or async strategies.
Conclusion
Zsh’s programmable completion system is a powerful, low‑effort way to give your CLI a polished user experience. By writing concise _arguments‑based functions, you can expose static options, dynamic data, and file paths with minimal boilerplate. Remember to keep your completion functions side‑effect‑free, test them with _complete_debug, and cache external data for speed. Once set up, your users will enjoy a consistent, context‑aware workflow that rivals the best commercial tools.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.