Complete subcommands, options and dynamic values with complete and compgen
A homegrown CLI tool only truly feels finished once the Tab key suggests subcommands, options and arguments, just like git or docker do. The Bash builtin complete together with compgen makes that possible without any external dependencies, from a simple word list up to suggestions loaded dynamically from a database or an API.
Table of Contents
- 1. Why your own completion raises a CLI tool's adoption
- 2. Basics: the complete builtin and compgen
- 3. Simple word-list completion for a homegrown tool
- 4. Completing subcommands: subcommand dispatch inside the completion function
- 5. Completing options and flags depending on context
- 6. Completing dynamic values: files, IDs from an API
- 7. Structuring and locally testing the completion script
- 8. Installation: where the completion file belongs
- 9. Zsh compatibility and the limits of Bash completion
- 10. Summary
- 11. FAQ
1. Why your own completion raises a CLI tool's adoption
A CLI tool without tab completion forces users to recall every subcommand and option from memory or from --help output, while tools like git, docker or kubectl show what is possible next with a single Tab press. That small investment in a completion function noticeably lowers the entry barrier for new users of a homegrown tool and reduces typos in long subcommand names or option flags.
Especially for internal tools used by an entire team, the one-time investment in a completion file pays off quickly, because every question avoided in chat or every documentation lookup skipped saves time. Bash completion is not an exotic feature here, it is a standard mechanism that almost every Linux distribution already ships and enables by default.
2. Basics: the complete builtin and compgen
The complete builtin registers a completion function for a specific command name, so Bash calls that registered function on every Tab press within that command instead of falling back to default filename completion. The function itself fills a special array called COMPREPLY with the suggestions, which Bash then displays or inserts directly on an unambiguous match.
compgen is the tool that filters a list of candidates within the completion function against the word fragment typed so far. The call compgen -W "list of words" "$cur", with a double dash marking the end of options in between, returns only the entries from the given word list that start with the current prefix, and is the building block almost every completion function is assembled from.
3. Simple word-list completion for a homegrown tool
The simplest starting point is a static list of allowed values, for instance the names of all subcommands of a tool called mstool. The completion function reads the currently typed word from COMP_WORDS at position COMP_CWORD, passes it to compgen -W together with the word list, and stores the result in COMPREPLY.
This basic structure, reading the current word, filtering with compgen, and assigning to COMPREPLY, shows up again in practically every Bash completion function, no matter how complex the actual suggestion logic eventually becomes. Once these three steps are internalized, they apply to any number of homegrown tools.
#!/usr/bin/env bash
# mstool-completion.bash -- simple word-list completion
_mstool_complete() {
local cur
cur="${COMP_WORDS[COMP_CWORD]}"
COMPREPLY=($(compgen -W "deploy status logs rollback" -- "$cur"))
}
complete -F _mstool_complete mstool
4. Completing subcommands: subcommand dispatch inside the completion function
Once a tool offers its own arguments per subcommand, for instance mstool deploy staging or mstool logs backend, the completion function itself has to figure out which subcommand was already typed in order to offer the right follow-up suggestions. That is done by reading COMP_WORDS[1], which already contains deploy at a point like mstool deploy <TAB>.
A case statement over that subcommand then branches into the matching suggestion list, exactly the way the tool itself internally distinguishes between its subcommands. This structure scales well, because every new subcommand in the tool only needs one extra case branch in the completion function, instead of rewriting the whole logic.
#!/usr/bin/env bash
# mstool-completion.bash -- subcommand dispatch
_mstool_complete() {
local cur subcommand
cur="${COMP_WORDS[COMP_CWORD]}"
subcommand="${COMP_WORDS[1]}"
if [[ "$COMP_CWORD" -eq 1 ]]; then
COMPREPLY=($(compgen -W "deploy status logs rollback" -- "$cur"))
return
fi
case "$subcommand" in
deploy)
COMPREPLY=($(compgen -W "staging production" -- "$cur"))
;;
logs)
COMPREPLY=($(compgen -W "backend frontend worker" -- "$cur"))
;;
esac
}
complete -F _mstool_complete mstool
5. Completing options and flags depending on context
Options like --env or --verbose can be added to the same case branch, with an extra check against the previous word fragment being useful to suggest environment names specifically after --env instead of arbitrary words. The previous word token lives in COMP_WORDS[COMP_CWORD-1] and can be evaluated with an additional case branch.
It matters to always check whether the current word fragment starts with a leading dash, to tell whether an option or a plain argument is being completed. If $cur starts with -, the function returns the list of available flags, otherwise the regular positional arguments like environment names or service names.
#!/usr/bin/env bash
# mstool-completion.bash -- context-dependent option suggestions
_mstool_complete() {
local cur prev
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
if [[ "$prev" == "--env" ]]; then
COMPREPLY=($(compgen -W "staging production local" -- "$cur"))
return
fi
if [[ "$cur" == -* ]]; then
COMPREPLY=($(compgen -W "--env --verbose --dry-run --help" -- "$cur"))
return
fi
COMPREPLY=($(compgen -W "deploy status logs rollback" -- "$cur"))
}
complete -F _mstool_complete mstool
6. Completing dynamic values: files, IDs from an API
Static word lists stop being enough once suggestions depend on runtime data, for instance valid deployment IDs from a database or service names from a running configuration. In that case the completion function calls the actual tool or a helper script in the background to fetch the current list, instead of maintaining a fixed list inside the script.
Such calls absolutely need to be fast, because they run in a blocking fashion on every Tab press, and a slow network lookup noticeably slows down using the tool. A short timeout and, where possible, a local cache of the last lookup prevent a hanging API from making completion unusable.
#!/usr/bin/env bash
# mstool-completion.bash -- dynamic values via a helper command
_mstool_complete() {
local cur subcommand
cur="${COMP_WORDS[COMP_CWORD]}"
subcommand="${COMP_WORDS[1]}"
if [[ "$subcommand" == "rollback" && "$COMP_CWORD" -eq 2 ]]; then
local ids
ids=$(timeout 1 mstool internal-list-deployment-ids 2>/dev/null)
COMPREPLY=($(compgen -W "$ids" -- "$cur"))
return
fi
}
complete -F _mstool_complete mstool
7. Structuring and locally testing the completion script
A completion file should contain only the _toolname_complete function and the closing complete call, with no side effects from merely sourcing the file, since it is potentially loaded automatically on every new shell start. Variables inside the function should consistently be declared with local, so they do not accidentally pollute the user's interactive shell environment.
To test it, sourcing the file in the current shell and then trying the command name followed by Tab is enough, without installing the file permanently. This fast iteration directly in the shell is considerably more efficient than opening a fresh shell every time to test a change to the completion logic.
#!/usr/bin/env bash
# Test locally without installing the file permanently
source ./mstool-completion.bash
# Then try in the terminal:
# mstool dep<TAB> -> deploy
# mstool deploy <TAB> -> staging production
# mstool --e<TAB> -> --env
8. Installation: where the completion file belongs
For permanent use, the completion file belongs in /etc/bash_completion.d/ or, on more modern distributions, in /usr/share/bash-completion/completions/, named exactly like the command itself, so mstool with no file extension. Both directories get read automatically by the central bash-completion infrastructure when an interactive shell starts, without every user having to source the file manually in their .bashrc.
If the bash-completion package is missing from the system, for instance on minimal server images, the file can alternatively be sourced directly from .bashrc. For distribution through a package manager, for instance via .deb or a Homebrew formula, the completion file belongs in the installation package so tab completion works right after installation.
#!/usr/bin/env bash
set -euo pipefail
readonly DEST="/usr/share/bash-completion/completions/mstool"
sudo install -m 644 mstool-completion.bash "$DEST"
echo "Installed to $DEST. Open a new shell or run 'source $DEST'."
9. Zsh compatibility and the limits of Bash completion
A completion function written with complete and compgen only runs in Bash. Zsh users who have bashcompinit enabled can usually load Bash completion scripts unchanged, but for a native Zsh experience with description text next to every suggestion, a dedicated Zsh completion script in the _toolname format is needed, using a completely different syntax.
For an internal tool that only ever runs in a Bash-centric environment like a Docker container or a standard server distribution, Bash completion alone is usually enough. For a publicly distributed CLI tool, it pays off to maintain both a Bash and a Zsh completion file from the start, since a noticeable share of macOS users run Zsh by default.
| Approach | Effort | Dynamic values | Install location |
|---|---|---|---|
| Static word list with compgen -W | Low | No | /usr/share/bash-completion/completions/ |
| Subcommand dispatch with case | Medium | No | /usr/share/bash-completion/completions/ |
| Dynamic values via helper command | Higher, watch performance | Yes | Same as above, with a timeout guard |
| Manual source in .bashrc | Low | Depends on the function | Personal .bashrc, not system-wide |
| Native Zsh _toolname script | High, own syntax | Yes, with description text | Zsh fpath directory |
Mironsoft
Shell automation, DevOps tooling and deployment infrastructure
Shell scripts that hold up in production?
We review existing Bash scripts, spot fragile patterns and replace them with robust Bash patterns: complete error handling, logging and safe parallelization for your deployment stack.
Code Review
ShellCheck analysis and manual review for critical Bash pattern violations.
Refactoring
Retrofitting error handling, logging and safe file operations.
CI Integration
Wiring ShellCheck and BATS into pipelines and building regression tests.
10. Summary
Bash Completion Scripts for Your Own CLI Tools: The Essentials at a Glance
Core idea
complete registers a function per command that fills the COMPREPLY array. compgen -W filters candidates against the current word prefix.
Subcommands
COMP_WORDS[1] holds the already-typed subcommand, a case statement returns the matching follow-up suggestions.
Dynamic values
For values from an API or database, call a helper script with a short timeout so completion never blocks.
Installation
Place the file at /usr/share/bash-completion/completions/toolname, named exactly like the command, no file extension.