The Bash Completion System
Chapter 11 — The Bash Completion System
Tab completion transforms a CLI tool from something you have to memorise into something you can explore. Bash's programmable completion system lets you attach arbitrary logic to any command: list subcommands, filter flags by context, query live data, or reflect a config file. This chapter builds a complete mental model of how the system works, then shows you how to write completions that are fast, correct, and genuinely useful.
1 — How Bash Completion Works
When you press Tab, Bash calls the completion function registered for the current command. That function reads context variables, calls compgen to generate candidate strings, and writes the results into COMPREPLY. Bash then either inserts the single match or displays the list.
| Variable | Contents |
|---|---|
COMP_WORDS | Array of words on the current command line |
COMP_CWORD | Index into COMP_WORDS of the word being completed |
COMP_LINE | The entire current command line as a string |
COMP_POINT | Cursor position (byte offset into COMP_LINE) |
COMP_KEY | Key that triggered completion (9 = Tab) |
COMPREPLY | Array you populate with completion candidates |
cur | Conventional local: ${COMP_WORDS[COMP_CWORD]} — the partial word being typed |
prev | Conventional local: ${COMP_WORDS[COMP_CWORD-1]} — the word before cur |
# Minimal completion function skeleton _my_tool_complete() { local cur="${COMP_WORDS[COMP_CWORD]}" local prev="${COMP_WORDS[COMP_CWORD-1]}" # Populate COMPREPLY with matching candidates # compgen -W "list of words" -- "$cur" # The -- "$cur" suffix filters the list to words starting with $cur COMPREPLY=( $(compgen -W "start stop restart status help" -- "$cur") ) } # Register the function for the command complete -F _my_tool_complete my_tool # Now: my_tool st→ start status stop
2 — compgen: The Completion Generator
compgen is the workhorse. It generates lists of candidates from many sources and filters them against a prefix.
| Flag | Generates |
|---|---|
-W "list" | Words from a space-separated list |
-f | Filenames (like default completion) |
-d | Directories only |
-c | Command names (anything on PATH) |
-b | Shell built-in names |
-k | Shell reserved words |
-A function | Function names defined in the shell |
-A variable | Shell variable names |
-A user | Usernames from /etc/passwd |
-A hostname | Hostnames from /etc/hosts and ~/.ssh/known_hosts |
-A service | Service names from /etc/services |
-G "glob" | Files matching a glob pattern |
-F function | Run a shell function; use its output as candidates |
-X "pattern" | Filter: exclude candidates matching the pattern |
-P "prefix" | Prepend prefix to each candidate |
-S "suffix" | Append suffix to each candidate |
# Generate words matching a prefix compgen -W "alpha beta gamma delta" -- "ga" # gamma # All .conf files in /etc compgen -G "/etc/*.conf" -- "/etc/sy" # /etc/sysctl.conf # Filenames only (no directories) compgen -f -X -- "" # only .log files # Users whose name starts with "a" compgen -A user -- "a" # Add a trailing = to option names (useful for --opt=VALUE style) compgen -W "--format --output --verbose" -S "=" -- "--fo" # --format= # Combine sources with printf COMPREPLY=( $( compgen -W "--help --version" -- "$cur" compgen -f -- "$cur" ) )
3 — The complete Built-in
complete registers what should happen when Tab is pressed after a specific command. The most important forms:
# -F FUNCTION — call a function to generate completions complete -F _my_tool_complete my_tool # -W "words" — complete from a fixed word list (no function needed) complete -W "start stop restart status" service # -f — complete filenames (restore default file completion) complete -f cat # -d — complete directories only complete -d cd # -c — complete command names complete -c which # -A user — complete usernames complete -A user su chown # -o options modify completion behaviour complete -F _my_complete -o default my_tool # -o default : fall back to default filename completion if COMPREPLY is empty # -o nospace : don't append a space after completing a single match # -o filenames: treat results as filenames (apply quoting, add trailing /) # -o nosort : don't sort COMPREPLY before displaying # -o bashdefault: fall back to bash-default completion, not just filename # Show all registered completions complete -p # Remove a completion complete -r my_tool
4 — A Realistic Multi-Level Completion
Most CLI tools have subcommands with their own flags. The pattern is: check which word position we are in, dispatch to subcommand-specific logic based on what subcommand has already been typed.
#!/usr/bin/env bash # Completion for a hypothetical 'deploy' tool # # Usage: # deploy [--env ENV] [--dry-run] SUBCOMMAND [ARGS] # # Subcommands: # app --name NAME --version VER # db migrate | rollback | status # cert renew --domain DOMAIN _deploy_complete() { local cur="${COMP_WORDS[COMP_CWORD]}" local prev="${COMP_WORDS[COMP_CWORD-1]}" local words=("${COMP_WORDS[@]}") # ── Global flags ──────────────────────────────────────────────── local global_flags="--env --dry-run --help --version" # ── Handle value completions for flags ────────────────────────── case "$prev" in --env|-e) COMPREPLY=( $(compgen -W "staging production canary" -- "$cur") ) return ;; --name) # Complete app names from a live source local apps apps=$(deploy list-apps 2>/dev/null) COMPREPLY=( $(compgen -W "$apps" -- "$cur") ) return ;; --domain) # Complete known domains from ~/.deploy/domains local domains domains=$(cat "${HOME}/.deploy/domains" 2>/dev/null) COMPREPLY=( $(compgen -W "$domains" -- "$cur") ) return ;; esac # ── Find which subcommand (if any) has been typed ──────────────── local subcommand="" local i for (( i=1; i < COMP_CWORD; i++ )); do case "${words[i]}" in app|db|cert) subcommand="${words[i]}"; break ;; esac done # ── Dispatch based on subcommand ───────────────────────────────── case "$subcommand" in "") # No subcommand yet — offer subcommands + global flags COMPREPLY=( $(compgen -W "app db cert $global_flags" -- "$cur") ) ;; app) COMPREPLY=( $(compgen -W "--name --version --strategy" -- "$cur") ) ;; db) COMPREPLY=( $(compgen -W "migrate rollback status --target" -- "$cur") ) ;; cert) COMPREPLY=( $(compgen -W "renew list revoke --domain --dry-run" -- "$cur") ) ;; esac } complete -F _deploy_complete -o nosort deploy
5 — The bash-completion Helper Library
The bash-completion package provides helper functions that handle edge cases you would otherwise have to solve yourself — word splitting with quotes, cursor inside a quoted word, and others. Always use them when writing completions that will be distributed.
# Load the helpers — this is a no-op if already loaded if [[ -f /usr/share/bash-completion/bash_completion ]]; then source /usr/share/bash-completion/bash_completion fi _my_tool_complete() { local cur prev words cword # _init_completion handles: # - setting cur, prev, words, cword correctly # - returning early if we should not complete (inside a redirect, etc.) # - dealing with quoted words containing spaces _init_completion || return # _get_comp_words_by_ref is an alternative that fills named variables # _get_comp_words_by_ref -n "=:" cur prev words cword # The -n "=:" means "do not split on = or :" # filedir — smarter than compgen -f, respects current quoting # filedir → all files and dirs # filedir log → only .log files and dirs case "$prev" in --config|-c) filedir '@(conf|cfg|ini)' # extglob pattern return ;; --output|-o) filedir # any file return ;; --log-dir) filedir -d # directories only return ;; esac COMPREPLY=( $(compgen -W "--config --output --log-dir --verbose --help" \ -- "$cur") ) } complete -F _my_tool_complete -o default my_tool
Key helper functions from bash-completion
| Helper | What it does |
|---|---|
_init_completion | Sets cur, prev, words, cword; handles quoting and redirects |
filedir [ext] | Complete files/dirs, optionally filtered by extension; respects current quoting |
_get_comp_words_by_ref | Like _init_completion but gives control over split characters |
_count_args | Count positional arguments already on the line (excludes flags) |
_have CMD | Return 0 if CMD is available on PATH |
_longopt CMD | Auto-generate completions from CMD --help output (many tools) |
6 — Dynamic Completions from Live Data
The most useful completions query real state. The key discipline is speed: if a completion takes more than ~200 ms the user notices. Use caching for data that changes slowly.
#!/usr/bin/env bash # Completing from live data with a TTL cache # ── Cache helper ───────────────────────────────────────────────── _cached_compgen() { # Usage: _cached_compgen CACHE_KEY TTL_SECS COMMAND [ARGS...] # Runs COMMAND, caches output in /tmp, regenerates after TTL_SECS local key="$1" ttl="$2"; shift 2 local cache="/tmp/.completion_cache_${key}" if [[ -f "$cache" ]]; then local age age=$(( EPOCHSECONDS - $(stat -c '%Y' "$cache") )) (( age < ttl )) && { cat "$cache"; return; } fi # Run the command; write to cache in background so Tab feels instant "$@" 2>/dev/null | tee "$cache" } # ── Docker container completion ─────────────────────────────────── _docker_containers() { _cached_compgen docker_containers 30 \ docker ps --format '{{.Names}}' } _docker_images() { _cached_compgen docker_images 60 \ docker images --format '{{.Repository}}:{{.Tag}}' } _myapp_complete() { local cur prev words cword _init_completion || return case "$prev" in --container) COMPREPLY=( $(compgen -W "$(_docker_containers)" -- "$cur") ) return ;; --image) COMPREPLY=( $(compgen -W "$(_docker_images)" -- "$cur") ) return ;; esac COMPREPLY=( $(compgen -W "--container --image --help" -- "$cur") ) } complete -F _myapp_complete myapp # ── Git branch completion (no caching needed — git is fast) ─────── _git_branches() { git branch --format '%(refname:short)' 2>/dev/null } _git_remote_branches() { git branch -r --format '%(refname:short)' 2>/dev/null | \ sed 's|origin/||' } _my_deploy_complete() { local cur prev words cword _init_completion || return case "$prev" in --branch|-b) COMPREPLY=( $(compgen -W "$(_git_branches)" -- "$cur") ) return ;; esac COMPREPLY=( $(compgen -W "--branch --env --dry-run --help" -- "$cur") ) } complete -F _my_deploy_complete my_deploy
7 — Installing and Distributing Completions
# ── Drop-in directory (auto-loaded, no source needed) ──────────── # System-wide: completions loaded for all users /usr/share/bash-completion/completions/my_tool # Per-user: takes precedence over system completions ${HOME}/.local/share/bash-completion/completions/my_tool # Both directories are scanned when bash-completion initialises. # The file must be named exactly after the command, no extension. # ── Makefile install target ─────────────────────────────────────── # PREFIX ?= /usr/local # install-completion: # install -d $(PREFIX)/share/bash-completion/completions # install -m644 completions/my_tool \ # $(PREFIX)/share/bash-completion/completions/my_tool # ── Lazy loading (completion-on-demand) ────────────────────────── # For large completion files, register a stub that sources the real # file on first Tab press. After sourcing, re-runs completion. _my_tool_loader() { # Remove the stub complete -r my_tool # Source the real completion file source /usr/local/share/my_tool/completions.bash # Re-trigger completion on the current word if [[ -n "${BASH_COMPLETION_VERSINFO:-}" ]]; then local cur; cur="${COMP_WORDS[COMP_CWORD]}" COMPREPLY=( $(compgen -W "" -- "$cur") ) fi # Call the real completion function that was just loaded _my_tool_complete } # Register the loader stub instead of the real function complete -F _my_tool_loader my_tool # ── Sourcing in .bashrc (fallback for systems without bash-completion) # if ! shopt -oq posix; then # [ -f /etc/bash_completion ] && source /etc/bash_completion # [ -f ~/.bash_completion ] && source ~/.bash_completion # fi
8 — Completion for Subcommand-Style Tools
Tools structured like git, kubectl, or cargo — with many subcommands each having their own flags — benefit from a table-driven approach that scales without repetition.
#!/usr/bin/env bash # Table-driven completion for a tool with many subcommands # Each subcommand's flags are stored in an associative array. declare -A _CLI_FLAGS _CLI_FLAGS[""]="--config --verbose --quiet --help --version" _CLI_FLAGS[build]="--target --release --debug --features --no-default-features" _CLI_FLAGS[test]="--filter --timeout --parallel --coverage" _CLI_FLAGS[deploy]="--env --branch --strategy --dry-run --force" _CLI_FLAGS[config]="get set unset list" _CLI_FLAGS[logs]="--tail --follow --since --level --service" _cli_get_subcommand() { # Scan COMP_WORDS[1..COMP_CWORD-1] for a known subcommand local i for (( i=1; i < COMP_CWORD; i++ )); do [[ -v _CLI_FLAGS["${COMP_WORDS[i]}"] ]] && { printf '%s' "${COMP_WORDS[i]}" return } done } _cli_complete() { local cur prev words cword _init_completion || return local subcmd subcmd=$(_cli_get_subcommand) if [[ -z "$subcmd" ]]; then # No subcommand yet — offer global flags + all subcommands local subcmds subcmds="${!_CLI_FLAGS[*]}" subcmds="${subcmds//[[:space:]]$'\t'}" # remove empty key COMPREPLY=( $(compgen \ -W "${_CLI_FLAGS[""]} ${!_CLI_FLAGS[*]}" \ -- "$cur") ) else # Subcommand is known — offer its flags COMPREPLY=( $(compgen \ -W "${_CLI_FLAGS[$subcmd]:-} ${_CLI_FLAGS[""]}" \ -- "$cur") ) fi } complete -F _cli_complete -o nosort cli
9 — Debugging Completions
# ── See what complete is registered for a command ──────────────── complete -p git # complete -o bashdefault -o default -o nospace -F __git_wrap__git_main git # ── Manually call a completion function ────────────────────────── # Simulate pressing Tab after "deploy --e" COMP_WORDS=(deploy --e) COMP_CWORD=1 COMP_LINE="deploy --e" COMP_POINT="${#COMP_LINE}" _deploy_complete printf '%s\n' "${COMPREPLY[@]}" # ── Trace execution ─────────────────────────────────────────────── # Set xtrace in just the completion function to see what it does _deploy_complete() { set -x # ... function body ... set +x } 2>/tmp/completion_debug.log # ── FIGNORE: exclude file suffixes from completion ──────────────── FIGNORE=".o:.pyc:.class" # these extensions never appear in file completion # ── COMP_WORDBREAKS: characters that split words ────────────────── # Default: " \t\n\"'><=;|&(:" # If your tool uses : in identifiers (e.g. kubectl resource:name), # remove : from COMP_WORDBREAKS: COMP_WORDBREAKS="${COMP_WORDBREAKS//:/}"
Exercises
Exercise 1 — Complete a backup tool
Write a completion function for the following tool:
backup.sh [OPTIONS] SOURCE DEST
Options:
--compress (-z) : gzip | bzip2 | xz | none
--exclude (-e) : glob pattern (can be given multiple times)
--dry-run : no flag value
--log-file (-l) : path to a log file
--retain (-r) : number of days (integer)
--help
Completion rules: --compress should offer the four named
values; --log-file should complete filenames with
filedir; --exclude should offer
common glob patterns (*.tmp *.log .git node_modules);
SOURCE and DEST are positional — both should
complete directories; all flag completions should be context-sensitive
(no duplication of already-present flags).
Use _init_completion.
#!/usr/bin/env bash # completions/backup.sh _backup_complete() { local cur prev words cword _init_completion || return # ── Flag value completions ─────────────────────────────────────── case "$prev" in --compress|-z) COMPREPLY=( $(compgen -W "gzip bzip2 xz none" -- "$cur") ) return ;; --log-file|-l) filedir return ;; --exclude|-e) COMPREPLY=( $(compgen -W "'*.tmp' '*.log' '.git' 'node_modules' '*.pyc' '__pycache__'" \ -- "$cur") ) return ;; --retain|-r) COMPREPLY=( $(compgen -W "7 14 30 60 90" -- "$cur") ) return ;; esac # ── Count positional args already on the line ──────────────────── local positionals=0 local i for (( i=1; i < cword; i++ )); do [[ "${words[i]}" == -* ]] && continue # Skip values of flags that take an argument [[ "${words[i-1]}" =~ ^(--compress|-z|--log-file|-l|--exclude|-e|--retain|-r)$ ]] \ && continue (( positionals++ )) done # ── Positional args: SOURCE and DEST are directories ───────────── if (( positionals < 2 )) && [[ "$cur" != -* ]]; then filedir -d return fi # ── Collect already-used non-repeatable flags ───────────────────── local used=" ${words[*]} " local all_flags="--compress -z --exclude -e --dry-run --log-file -l --retain -r --help" local -a available=() local flag for flag in $all_flags; do # Allow --exclude to be repeated; suppress others once used [[ $flag == "--exclude" || $flag == "-e" ]] || \ [[ "$used" != *" ${flag} "* ]] && available+=("$flag") done COMPREPLY=( $(compgen -W "${available[*]}" -- "$cur") ) } complete -F _backup_complete -o default backup.sh backup
Exercise 2 — Dynamic completion from a config file
Your tool svc manages services defined in
~/.config/svc/services.conf, one service name per line,
with optional # comments. Write a completion function that:
- Supports subcommands:
start stop restart status logs tail - After a subcommand that takes a service name, reads
~/.config/svc/services.confand completes service names (the config file changes rarely — cache it for 60 s) svc logsandsvc tailadditionally accept--follow,--lines N,--since TIMESTAMP--linescompletes the values50 100 200 500 all- Global flag
--config PATHcompletes with filedir
#!/usr/bin/env bash # completions/svc _svc_services() { local conf="${HOME}/.config/svc/services.conf" local cache="/tmp/.svc_services_cache" if [[ -f "$cache" ]]; then local age age=$(( EPOCHSECONDS - $(stat -c '%Y' "$cache" 2>/dev/null || echo 0) )) (( age < 60 )) && { cat "$cache"; return; } fi [[ -f "$conf" ]] || return grep -v '^[[:space:]]*#' "$conf" | grep -v '^[[:space:]]*$' | tee "$cache" } _svc_complete() { local cur prev words cword _init_completion || return local subcmds="start stop restart status logs tail" local log_flags="--follow --lines --since" # Global flag values case "$prev" in --config) filedir; return ;; --lines) COMPREPLY=( $(compgen -W "50 100 200 500 all" -- "$cur") ) return ;; --since) COMPREPLY=( $(compgen -W "1h 6h 24h yesterday today" -- "$cur") ) return ;; esac # Find subcommand local subcmd="" local i for (( i=1; i < cword; i++ )); do [[ " ${subcmds} " == *" ${words[i]} "* ]] && { subcmd="${words[i]}"; break } done if [[ -z "$subcmd" ]]; then COMPREPLY=( $(compgen -W "$subcmds --config --help" -- "$cur") ) return fi # Service name position (word after subcommand) local service_typed=0 for (( i=1; i < cword; i++ )); do [[ "${words[i]}" == "$subcmd" ]] && { [[ "${words[i+1]:-}" != -* && -n "${words[i+1]:-}" ]] \ && service_typed=1 break } done if (( ! service_typed )) && [[ "$cur" != -* ]]; then local svcs; svcs=$(_svc_services) COMPREPLY=( $(compgen -W "$svcs" -- "$cur") ) return fi case "$subcmd" in logs|tail) COMPREPLY=( $(compgen -W "$log_flags" -- "$cur") ) ;; *) COMPREPLY=() ;; esac } complete -F _svc_complete -o nosort svc
Exercise 3 — Completion generator for git-style tools
Write a reusable function _make_subcmd_completion TOOL_NAME
SUBCOMMAND_FN FLAG_ARRAY_NAME that installs a complete function for
any tool with subcommands, given:
TOOL_NAME— the command name to registerSUBCOMMAND_FN— name of a function that prints available subcommands (one per line); called once and cached for the sessionFLAG_ARRAY_NAME— name of an associative array mapping subcommand names to their flag strings (empty key = global flags)
The generated completion function must: offer global flags when no
subcommand is typed, offer subcommand-specific flags after a known
subcommand, and fall back to -o default for unknown subcommands.
Demonstrate by using it to add completions for a fictional
proj tool with subcommands build test lint run.
#!/usr/bin/env bash # lib/completion_factory.sh _make_subcmd_completion() { local tool="$1" local subcmd_fn="$2" local flags_arr="$3" # Validate [[ $tool =~ ^[a-zA-Z_][a-zA-Z0-9_-]*$ ]] || { echo "bad tool name" >&2; return 1; } [[ $subcmd_fn =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]] || { echo "bad function name" >&2; return 1; } [[ $flags_arr =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]] || { echo "bad array name" >&2; return 1; } # Generate a session-scoped subcommand cache variable name local cache_var="__compcache_${tool//-/_}" # Emit the completion function eval " _${tool//-/_}_complete() { local cur prev words cword _init_completion || return # Populate subcommand cache on first call if [[ -z \"\${${cache_var}:-}\" ]]; then ${cache_var}=\$( ${subcmd_fn} ) fi local _subcmds=\"\${${cache_var}}\" # Find typed subcommand local _subcmd=\"\" i for (( i=1; i < cword; i++ )); do if printf '%s\n' \$_subcmds | grep -qxF \"\${words[i]}\"; then _subcmd=\"\${words[i]}\"; break fi done # Reference the flags associative array declare -n _flags=${flags_arr} if [[ -z \"\$_subcmd\" ]]; then COMPREPLY=( \$(compgen -W \"\${_flags[\"\"]:+\${_flags[\"\"]}} \${_subcmds}\" -- \"\$cur\") ) else local _sf=\"\${_flags[\"\$_subcmd\"]:-}\" local _gf=\"\${_flags[\"\"]:+\${_flags[\"\"]}}\" COMPREPLY=( \$(compgen -W \"\${_sf} \${_gf}\" -- \"\$cur\") ) fi } complete -F _${tool//-/_}_complete -o default ${tool} " } # ── Demo: proj tool ────────────────────────────────────────────── declare -A PROJ_FLAGS PROJ_FLAGS[""]="--config --verbose --quiet --help" PROJ_FLAGS[build]="--target --release --debug --watch" PROJ_FLAGS[test]="--filter --coverage --watch --bail" PROJ_FLAGS[lint]="--fix --strict --format" PROJ_FLAGS[run]="--port --host --reload --env" _proj_subcommands() { printf '%s\n' build test lint run } _make_subcmd_completion proj _proj_subcommands PROJ_FLAGS
Exercise 4 — Complete a custom SSH wrapper
Write completion for a script sshjump HOST [COMMAND] that
SSH's through a jump host to a target. The host list comes from two sources:
~/.ssh/config (extract Host lines, excluding
patterns with * or ?) and
~/.config/sshjump/hosts (plain list, one per line, with
comments). Merge and deduplicate both sources, cache for 120 seconds.
After the host argument is satisfied, COMMAND should complete
with remote command names — offer a fixed set of useful ones
(bash htop journalctl systemctl top) plus whatever is on the
local PATH filtered to single-word commands (use compgen -c).
#!/usr/bin/env bash # completions/sshjump _sshjump_hosts() { local cache="/tmp/.sshjump_hosts_cache" if [[ -f "$cache" ]]; then local age age=$(( EPOCHSECONDS - $(stat -c '%Y' "$cache" 2>/dev/null || echo 0) )) (( age < 120 )) && { cat "$cache"; return; } fi { # From ~/.ssh/config: Host lines without wildcards [[ -f "${HOME}/.ssh/config" ]] && \ awk '/^[Hh]ost[[:space:]]/{ for(i=2;i<=NF;i++) if($i !~ /[*?]/) print $i }' "${HOME}/.ssh/config" # From ~/.config/sshjump/hosts [[ -f "${HOME}/.config/sshjump/hosts" ]] && \ grep -v '^[[:space:]]*\(#\|$\)' "${HOME}/.config/sshjump/hosts" } | sort -u | tee "$cache" } _sshjump_complete() { local cur prev words cword _init_completion || return # Count non-flag positional args already typed local positionals=0 local i for (( i=1; i < cword; i++ )); do [[ "${words[i]}" == -* ]] || (( positionals++ )) done case "$positionals" in 0) # Completing HOST local hosts; hosts=$(_sshjump_hosts) COMPREPLY=( $(compgen -W "$hosts" -- "$cur") ) ;; 1) # Completing COMMAND local well_known="bash htop journalctl systemctl top ps df du tail grep" COMPREPLY=( $(compgen -W "$well_known" -- "$cur" compgen -c -- "$cur") ) # Deduplicate local -A seen local -a uniq=() local item for item in "${COMPREPLY[@]}"; do [[ -v seen["$item"] ]] || { uniq+=("$item"); seen["$item"]=1; } done COMPREPLY=("${uniq[@]}") ;; *) COMPREPLY=() ;; esac } complete -F _sshjump_complete -o default sshjump