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.

VariableContents
COMP_WORDSArray of words on the current command line
COMP_CWORDIndex into COMP_WORDS of the word being completed
COMP_LINEThe entire current command line as a string
COMP_POINTCursor position (byte offset into COMP_LINE)
COMP_KEYKey that triggered completion (9 = Tab)
COMPREPLYArray you populate with completion candidates
curConventional local: ${COMP_WORDS[COMP_CWORD]} — the partial word being typed
prevConventional 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
The bash-completion package (bash-completion) is installed on virtually every modern Linux system. It provides: /usr/share/bash-completion/bash_completion — the loader and helper functions; /usr/share/bash-completion/completions/ — per-command completion files; ~/.local/share/bash-completion/completions/ — your own completions, auto-loaded without any source call once bash-completion is initialised.

2 — compgen: The Completion Generator

compgen is the workhorse. It generates lists of candidates from many sources and filters them against a prefix.

FlagGenerates
-W "list"Words from a space-separated list
-fFilenames (like default completion)
-dDirectories only
-cCommand names (anything on PATH)
-bShell built-in names
-kShell reserved words
-A functionFunction names defined in the shell
-A variableShell variable names
-A userUsernames from /etc/passwd
-A hostnameHostnames from /etc/hosts and ~/.ssh/known_hosts
-A serviceService names from /etc/services
-G "glob"Files matching a glob pattern
-F functionRun 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

HelperWhat it does
_init_completionSets cur, prev, words, cword; handles quoting and redirects
filedir [ext]Complete files/dirs, optionally filtered by extension; respects current quoting
_get_comp_words_by_refLike _init_completion but gives control over split characters
_count_argsCount positional arguments already on the line (excludes flags)
_have CMDReturn 0 if CMD is available on PATH
_longopt CMDAuto-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.conf and completes service names (the config file changes rarely — cache it for 60 s)
  • svc logs and svc tail additionally accept --follow, --lines N, --since TIMESTAMP
  • --lines completes the values 50 100 200 500 all
  • Global flag --config PATH completes 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 register
  • SUBCOMMAND_FN — name of a function that prints available subcommands (one per line); called once and cached for the session
  • FLAG_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