Writing Bash Completion Scripts for Your Own CLI Tools
AI generated
$_
#!/
Bash · CLI Tooling · Developer Experience
Bash Completion Scripts for Your Own CLI Tools
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.

16 min read complete · compgen bash-completion · Subcommands

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.

11. FAQ: Bash Completion Scripts for Your Own CLI Tools: The Essentials at a Glance

1What exactly does the complete builtin do?
complete registers a function for a command name that Bash calls on every Tab press within that command, instead of falling back to default filename completion.
2What is compgen responsible for?
compgen filters a list of candidates against the currently typed word fragment and returns only the matching entries, which then land in COMPREPLY.
3How does the completion function know which subcommand was already typed?
Through COMP_WORDS[1], which already holds the value deploy at a point like tool deploy , evaluated inside a case statement.
4How do I only complete options when the current word starts with a dash?
With a check for whether $cur starts with -*, and in that case passing the list of flags instead of the regular arguments to compgen.
5How do I wire in dynamic suggestions from an API or database?
The completion function calls a helper script or the tool itself in the background to fetch the current list, guarded with a short timeout so a slow response never blocks completion.
6How do I test a completion function without installing it?
Load it with source ./my-completion.bash in the current shell and then try the command with Tab directly, without installing the file permanently.
7Where do I permanently install a Bash completion file?
In /usr/share/bash-completion/completions/ or /etc/bash_completion.d/, named exactly like the command with no file extension, so the bash-completion infrastructure loads it automatically.
8What if the bash-completion package is missing from the system?
Then the completion file can alternatively be sourced directly from the personal .bashrc, though only for that one user instead of system-wide.
9Does a Bash completion file also work in Zsh?
With bashcompinit enabled, usually yes, but without native Zsh features like description text next to every suggestion. A native experience needs a dedicated Zsh completion script.
10Why should variables inside the completion function be declared with local?
So they do not accidentally pollute the user's interactive shell environment, since the function runs in the context of the current shell on every Tab press.