Writing Bash Completion Scripts with complete and compgen
Give your ops CLIs tab completion for subcommands, flags, and dynamic values. A practical guide to complete and compgen, with AI doing the boilerplate.
- #bash
- #python
- #cli
A CLI without tab completion feels broken once you are used to one that has it. I noticed this the day I shipped an internal deploy tool with a dozen subcommands and watched a teammate type deploy and hit Tab expecting suggestions. Nothing happened. He went to read the help text instead, which is exactly the friction good completion eliminates.
Bash completion looks arcane, but the core of it is two builtins — complete and compgen — plus a couple of magic variables. Once they click, you can add completion to any script in an afternoon. And since the boilerplate is repetitive, it is a great task to let an AI draft while you review the logic.
How Bash completion actually works
When you press Tab, Bash looks up a completion function registered for that command with complete. It calls your function, which inspects the current command line through three variables and writes its suggestions into an array called COMPREPLY. Bash then displays them.
The three variables you care about:
COMP_WORDS— an array of the words on the line.COMP_CWORD— the index of the word the cursor is on.${COMP_WORDS[COMP_CWORD]}— the partial word being completed.
That is the whole model. Everything else is producing the right list of candidates.
A minimal completion for subcommands
Say our tool is deploytool with subcommands plan, apply, and rollback. The simplest completion offers those three:
_deploytool() {
local cur="${COMP_WORDS[COMP_CWORD]}"
local subcommands="plan apply rollback"
COMPREPLY=( $(compgen -W "$subcommands" -- "$cur") )
}
complete -F _deploytool deploytool
compgen -W "list" -- "$cur" is the workhorse: it filters the word list down to the entries that start with whatever the user has typed so far. Source this file (or drop it in /etc/bash_completion.d/) and deploytool <Tab> now suggests the three subcommands.
Context-aware completion
Real tools need different completions depending on which subcommand you are in. Use COMP_WORDS[1] to branch:
_deploytool() {
local cur="${COMP_WORDS[COMP_CWORD]}"
local sub="${COMP_WORDS[1]}"
# First word: suggest subcommands
if [[ $COMP_CWORD -eq 1 ]]; then
COMPREPLY=( $(compgen -W "plan apply rollback" -- "$cur") )
return
fi
# Subsequent words depend on the subcommand
case "$sub" in
apply)
COMPREPLY=( $(compgen -W "--env --dry-run --force" -- "$cur") )
;;
rollback)
COMPREPLY=( $(compgen -W "--to-revision --env" -- "$cur") )
;;
esac
}
complete -F _deploytool deploytool
Now deploytool apply --<Tab> offers the flags that make sense for apply, and rollback offers its own set.
Completing dynamic values
The real power comes from generating candidates at completion time. Suppose --env should complete from a live list of environments. Run a command and feed its output to compgen:
_deploytool() {
local cur="${COMP_WORDS[COMP_CWORD]}"
local prev="${COMP_WORDS[COMP_CWORD-1]}"
if [[ $prev == "--env" ]]; then
local envs
envs=$(deploytool list-envs 2>/dev/null)
COMPREPLY=( $(compgen -W "$envs" -- "$cur") )
return
fi
# ... fall through to static completion
}
prev lets you react to the flag the user just typed. Here, after --env, we shell out to get the real environment names. Keep these commands fast — they run on every Tab press.
Pro Tip: Cache slow lookups. If your dynamic source takes more than a fraction of a second, completion feels laggy. Write the result to a tempfile with a short TTL and read from the cache when it is fresh.
Completing filenames and built-in types
compgen has built-in actions so you do not reinvent filename completion:
# Files only
COMPREPLY=( $(compgen -f -- "$cur") )
# Directories only
COMPREPLY=( $(compgen -d -- "$cur") )
# Hostnames from known_hosts, etc.
COMPREPLY=( $(compgen -A hostname -- "$cur") )
Mixing these with -W word lists covers most flag-plus-file patterns you will hit.
Generating completions from a Python CLI
If your tool is actually a Python CLI built with Click or Typer, you usually do not hand-write any of this — those frameworks emit completion scripts for you:
# Click / Typer apps
_DEPLOYTOOL_COMPLETE=bash_source deploytool > /etc/bash_completion.d/deploytool
I mention this because the best Bash completion is sometimes no Bash completion at all. If you are starting fresh, building the CLI in a framework that generates completion saves you this entire file.
Handling the IFS and quoting gotchas
The COMPREPLY=( $(compgen ...) ) idiom has a hidden flaw: word-splitting happens on the default IFS, so any candidate containing a space gets split into two suggestions. For filenames with spaces this produces garbage. The robust form uses mapfile to split on newlines only:
_deploytool() {
local cur="${COMP_WORDS[COMP_CWORD]}"
local IFS=$'\n'
mapfile -t COMPREPLY < <(compgen -W "plan apply rollback" -- "$cur")
}
mapfile -t reads each line into an array element, and setting IFS=$'\n' ensures only newlines split. For most static word lists the simpler form is fine, but the moment your candidates can contain spaces, switch to this pattern.
Installing completions so they load automatically
A completion is useless if nobody sources it. There are two standard homes. For system-wide installation, drop the file in the completion directory and it loads for every shell:
sudo cp deploytool-completion.bash /etc/bash_completion.d/deploytool
For per-user installs, source it from ~/.bashrc:
echo 'source ~/.local/share/bash-completion/deploytool.bash' >> ~/.bashrc
I prefer shipping the completion with the tool and adding a deploytool completion install subcommand that writes it to the right place, so users never have to know the conventions. That subcommand just emits the function text and copies it.
Letting AI write the boilerplate
Completion functions are repetitive and easy to get subtly wrong — perfect work for an AI assistant acting as a fast junior engineer. I will describe my subcommands and flags to ChatGPT or GitHub Copilot and ask for a complete -F function with per-subcommand flag handling.
It is fast and usually correct, but I review every line before I trust it, because a completion function runs arbitrary commands on every Tab press in the user’s shell. I check that:
- Dynamic completions do not run anything slow or destructive — never put a state-changing command in a completion path.
- The function fails quietly (
2>/dev/null) so a broken backend does not spew errors mid-typing. - No real secrets ended up in the script. A completion helper has no business holding credentials, and I never paste real ones into the prompt to give context.
I keep that checklist in the prompt workspace and run anything that shells out through the code review dashboard first.
Conclusion
Bash completion comes down to a function that reads COMP_WORDS/COMP_CWORD, builds candidates with compgen -W, and writes them to COMPREPLY, all registered with complete -F. Branch on the subcommand for context, shell out for dynamic values (cached if slow), and let your CLI framework generate the script when it can. Hand the boilerplate to an AI, but review every command it puts in a completion path — that code runs in your teammates’ shells.
More in the Bash and Python automation category. Reusable starters are in the prompt library, and curated sets are in the prompt packs.
Download the Free 500-Prompt DevOps AI Toolkit
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.