Shell Completion: How It Works and How to Write Your Own
Type systemctl res and press Tab and you get restart. Type git che and get checkout. Type mytool -- and get nothing, because nobody wrote a completion for it.
How it works
When you press Tab, the shell looks for a completion specification registered for that command. If one exists, it runs a function that produces candidate words. If not, it falls back to completing filenames, which is why an unknown tool offers your directory contents.
complete -p | head
complete -p git
complete -p systemctl
complete -p prints registered completions. On a typical system there are hundreds.
Where they live
ls /usr/share/bash-completion/completions/ | head
ls /etc/bash_completion.d/
The modern layout is one file per command in /usr/share/bash-completion/completions/, named after the command. That naming is not cosmetic: bash loads the file on demand the first time you complete that command, rather than sourcing everything at startup.
That is why a system with hundreds of completions still starts instantly.
Per user:
mkdir -p ~/.local/share/bash-completion/completions
# put a file named after the command there
No sourcing required if the naming is right.
Generate rather than write
Before writing anything, check whether the tool does it for you. Most modern CLI frameworks do.
kubectl completion bash > ~/.local/share/bash-completion/completions/kubectl
docker completion bash > ~/.local/share/bash-completion/completions/docker
gh completion -s bash > ~/.local/share/bash-completion/completions/gh
helm completion bash > ~/.local/share/bash-completion/completions/helm
# zsh
kubectl completion zsh > ~/.zfunc/_kubectl
Anything built with Cobra (Go), Click (Python), or clap (Rust) almost certainly has this. It generates a better completion than you would write, and it stays current with the tool.
Writing one for bash
# ~/.local/share/bash-completion/completions/deploy
_deploy() {
local cur prev words cword
_init_completion || return
local commands="build push rollback status logs"
local global_opts="--help --verbose --config --dry-run"
# complete the subcommand in first position
if (( cword == 1 )); then
COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
return
fi
# option arguments
case "$prev" in
--config)
_filedir yml
return
;;
--env)
COMPREPLY=( $(compgen -W "dev staging prod" -- "$cur") )
return
;;
esac
# per subcommand
case "${words[1]}" in
rollback)
COMPREPLY=( $(compgen -W "$(deploy list-releases 2>/dev/null)" -- "$cur") )
;;
logs)
COMPREPLY=( $(compgen -W "--follow --tail --since" -- "$cur") )
;;
*)
COMPREPLY=( $(compgen -W "$global_opts" -- "$cur") )
;;
esac
}
complete -F _deploy deploy
The pieces:
_init_completion comes from bash-completion and sets cur, prev, words, and cword for you, handling quoting and word splitting correctly. Doing this by hand is where homemade completions go wrong.
compgen -W "list" -- "$cur" filters a wordlist by what has been typed so far.
COMPREPLY is the array the shell reads.
_filedir yml completes filenames restricted to an extension, and it is another helper from bash-completion.
The dynamic case is the interesting one: $(deploy list-releases) calls your own tool to produce candidates. That is how git checkout completes branch names. Keep it fast, because it runs on every Tab press, and redirect stderr so an error does not print into the prompt.
source ~/.local/share/bash-completion/completions/deploy
deploy <TAB><TAB>
zsh
A different and considerably richer system. Not portable from bash.
#compdef deploy
# ~/.zfunc/_deploy
_deploy() {
local -a commands
commands=(
'build:Build the artifact'
'push:Push to the registry'
'rollback:Roll back to a previous release'
'status:Show current status'
'logs:Tail logs'
)
_arguments -C \
'(-h --help)'{-h,--help}'[Show help]' \
'--verbose[Verbose output]' \
'--config[Config file]:file:_files -g "*.yml"' \
'--env[Environment]:env:(dev staging prod)' \
'1: :->command' \
'*:: :->args'
case $state in
command)
_describe -t commands 'deploy command' commands
;;
args)
case $words[1] in
rollback)
_values 'release' $(deploy list-releases 2>/dev/null)
;;
esac
;;
esac
}
_deploy "$@"
# ~/.zshrc
fpath=(~/.zfunc $fpath)
autoload -Uz compinit && compinit
The 'build:Build the artifact' format gives descriptions next to each candidate, which is the main thing zsh completion offers over bash and the reason people prefer it.
If zsh startup feels slow, compinit is usually why. It should use its cache rather than rebuilding on every start:
autoload -Uz compinit
if [[ -n ~/.zcompdump(#qN.mh+24) ]]; then
compinit
else
compinit -C
fi
That rebuilds the cache once a day and skips the check otherwise. Our zsh, bash, and fish comparison covers the wider differences.
Testing
# reload after editing
source ~/.local/share/bash-completion/completions/deploy
# see what a function produces
COMP_WORDS=(deploy roll) COMP_CWORD=1 _deploy && echo "${COMPREPLY[@]}"
# zsh: clear the cache and reload
rm -f ~/.zcompdump && compinit
Worth the effort?
For a tool other people use, yes. Completion is a large part of whether a CLI feels finished, and it is the difference between remembering flags and discovering them.
For a personal script, generate it if your framework can and skip it otherwise. A twenty-line completion for a five-line script is not a good trade.
The middle option costs one line:
complete -W "start stop restart status" mytool
No function, no file. Static wordlist, in your .bashrc. For a simple tool with a handful of fixed subcommands, that is most of the benefit for none of the work.
Frequently Asked Questions
Why does Tab completion work for some commands and not others?
Because a completion script has to exist for that command. Without one the shell falls back to completing filenames, which is why an unknown tool offers your directory contents instead of its options. Many tools ship completions and many do not.
Where do bash completion scripts live?
System-wide in /usr/share/bash-completion/completions, with one file per command, and per-user in a directory you source from your bashrc. Files named after the command are loaded on demand rather than at shell startup, which keeps startup fast.
What is the difference between COMPREPLY and compgen?
compgen generates candidate words from a wordlist or a source such as filenames, and COMPREPLY is the array the shell reads to decide what to offer. A completion function typically calls compgen and assigns its output to COMPREPLY.
Can many tools generate their own completion scripts?
Yes, and it is the easiest route. Tools built with Cobra, Click, clap, and similar frameworks usually have a completion subcommand that emits a script for your shell, so you generate it once and source it rather than writing anything.
Why is zsh completion different from bash?
zsh has a much richer completion system with descriptions per option, grouping, and its own matching rules, so scripts are written against compdef and the _arguments helper rather than bash’s simpler COMPREPLY array. They are not portable between the two.
Does adding completions slow down my shell startup?
Bash loads completions on demand when the file is named after the command, so the cost is close to nothing. Sourcing many scripts directly from bashrc does slow startup, and zsh users should ensure compinit uses its cache rather than rebuilding on every start.