Building Complete CLI Tools

Chapter 11 — Building Complete CLI Tools

There is a gap between "a script that works for me" and "a tool I can hand to a colleague." Closing that gap means robust option parsing, a helpful --help page, a --version flag, shell tab completion, and — for complex tools — a man-page stub. This chapter builds all of it.

1 — Long-Option Parsing with getopt

The shell built-in getopts handles short options only. For long options (--verbose, --output FILE) you need the external getopt (note: no s), which reorders and normalises the argument list before your loop sees it.

Two different tools: getopts (built-in, POSIX, short options only) vs getopt (external GNU utility, long options, requires -- sentinel). Always use the GNU version: getopt --version should say "util-linux". The BSD version shipped on macOS is limited — install GNU getopt via Homebrew if needed.
#!/usr/bin/env bash
set -euo pipefail

PROG="$(basename "$0")"
VERSION='1.2.0'

# Define accepted options
#   Short: -v -o FILE -q -h
#   Long:  --verbose --output FILE --quiet --help --version
_OPTS=$(getopt \
  --name    "$PROG" \
  --options 'vo:qh' \
  --longoptions 'verbose,output:,quiet,help,version' \
  -- "$@") || { usage; exit 1; }

# Replace positional parameters with the normalised list
eval set -- "$_OPTS"

# Defaults
VERBOSE=0
QUIET=0
OUTPUT=''

while true; do
  case "$1" in
    -v | --verbose)  VERBOSE=1;     shift   ;;
    -q | --quiet)    QUIET=1;       shift   ;;
    -o | --output)   OUTPUT="$2";  shift 2 ;;
    -h | --help)     usage;         exit 0  ;;
         --version)  version;       exit 0  ;;
    --)              shift;         break   ;;
    *)               usage;         exit 1  ;;
  esac
done

# Remaining positional arguments
ARGS=( "$@" )

After eval set -- "$_OPTS", getopt guarantees that option arguments are separate tokens, abbreviated options are expanded, and -- marks the end of options. Your while loop just needs to handle the canonical forms.

A purely manual alternative (no external tool)

When you cannot depend on GNU getopt being present, a manual while/case loop handles most real-world needs:

while [[ $# -gt 0 ]]; do
  case "$1" in
    -v | --verbose)  VERBOSE=1; shift ;;
    -o | --output)
      [[ $# -lt 2 ]] && { echo "$1 requires an argument" >&2; exit 1; }
      OUTPUT="$2"; shift 2 ;;
    --output=*)  OUTPUT="${1#--output=}"; shift ;;   # --output=FILE form
    -h | --help)    usage; exit 0 ;;
    --version)      version; exit 0 ;;
    --)             shift; break ;;
    -*)             echo "Unknown option: $1" >&2; exit 1 ;;
    *)              break ;;   # first non-option: stop parsing
  esac
done

2 — Generating --help Output

A good help page answers: what does the tool do, how do I call it, what are my options, and where can I find more. Keep it concise but complete.

Heredoc approach — simple and readable

usage() {
  cat <<EOF
Usage: $PROG [OPTIONS] FILE [FILE...]

Process one or more input FILEs and write results to stdout or --output.

Options:
  -o, --output FILE    Write output to FILE instead of stdout
  -v, --verbose        Show detailed progress
  -q, --quiet          Suppress all non-error output
  -h, --help           Show this help and exit
      --version        Show version and exit

Examples:
  $PROG input.txt
  $PROG --output result.txt *.log
  $PROG --verbose --output /dev/stderr data.csv

Exit codes:
  0  Success
  1  Usage error
  2  Input file not found
  3  Permission denied
EOF
}

Usage-string extraction — DRY alternative

Embed the usage string in a comment block at the top of the script, then extract it at runtime. This keeps the help in one place and makes it visible to grep without running the tool:

#!/usr/bin/env bash
# USAGE: mytool [OPTIONS] FILE [FILE...]
#
# Process input files.
#
# Options:
#   -o, --output FILE   Output destination
#   -v, --verbose       Verbose output
#   -h, --help          Show this help
#
PROG="$(basename "$0")"

usage() {
  # Print every line between '# USAGE:' and the first non-comment line
  sed -n '/^# USAGE:/,/^[^#]/{ /^[^#]/d; s/^# \?//; p }' "${BASH_SOURCE[0]}"
}

3 — --version and Exit Codes

version() {
  printf '%s %s\n' "$PROG" "$VERSION"
}

# Conventional exit codes (document them in --help)
readonly EX_OK=0          # success
readonly EX_USAGE=1       # bad arguments / options
readonly EX_NOINPUT=2     # input file missing
readonly EX_NOPERM=3      # permission denied
readonly EX_SOFTWARE=4    # internal error

# Consistent die() helper — always writes to stderr
die() {
  local code="${1}"; shift
  printf '%s: %s\n' "$PROG" "$*" >&2
  exit "$code"
}

# Usage:
# die "$EX_NOINPUT" "file not found: $file"
# die "$EX_USAGE"   "--output requires an argument"
Distinguish between usage errors (wrong arguments — print a usage hint and exit 1) and runtime errors (file not found, network timeout — print the error and exit with a specific code). Users and scripts calling your tool depend on predictable exit codes.

4 — Subcommand Dispatch

Tools that do more than one thing (like git or docker) use subcommands. The cleanest pattern keeps each subcommand in its own function and dispatches by name:

#!/usr/bin/env bash
# myapp — a tool with subcommands
set -euo pipefail
PROG="$(basename "$0")"

cmd_init()   { echo "Initialising..."; }
cmd_build()  { echo "Building...";     }
cmd_deploy() { echo "Deploying...";    }

cmd_help() {
  cat <<EOF
Usage: $PROG COMMAND [OPTIONS]

Commands:
  init     Initialise a new project
  build    Build the project
  deploy   Deploy to the configured target
  help     Show this help
EOF
}

# Dispatch — fail fast with a helpful message for unknown commands
COMMAND="${1:-help}"; shift || true

if declare -f "cmd_${COMMAND}" >/dev/null 2&1; then
  "cmd_${COMMAND}" "$@"
else
  printf '%s: unknown command: %s\n\n' "$PROG" "$COMMAND" >&2
  cmd_help >&2
  exit 1
fi

5 — Shell Tab Completion with bash_completion

Tab completion transforms a tool from something you look up into something you discover. The bash completion system calls a function you register via complete whenever the user presses Tab after your tool's name.

How it works

# The completion system sets these variables before calling your function:
#   COMP_WORDS  — array of all words on the current command line
#   COMP_CWORD  — index of the word currently being completed
#   COMPREPLY   — array your function fills with completion candidates

_mytool_complete() {
  local cur="${COMP_WORDS[$COMP_CWORD]}"
  local prev="${COMP_WORDS[$COMP_CWORD-1]}"

  # Complete option arguments
  case "$prev" in
    -o | --output)
      # Complete filenames
      COMPREPLY=( $(compgen -f -- "$cur") )
      return ;;
    --format)
      COMPREPLY=( $(compgen -W 'json csv tsv text' -- "$cur") )
      return ;;
  esac

  # Complete options and subcommands when the user typed a dash or nothing
  if [[ $cur == -* ]]; then
    COMPREPLY=( $(compgen -W \
      '--verbose --quiet --output --format --help --version' \
      -- "$cur") )
  else
    COMPREPLY=( $(compgen -W 'init build deploy help' -- "$cur") )
  fi
}

# Register the completion function for mytool
complete -F _mytool_complete mytool

Key compgen actions

FlagCompletes
-W 'word1 word2'Fixed word list
-fFilenames in current directory
-dDirectories only
-uUsernames
-gGroup names
-cCommands on PATH
-A functionFunction names
-vShell variable names

Distributing the completion file

# Users source this from ~/.bashrc, or you install it system-wide:
#   /etc/bash_completion.d/mytool       — system-wide (Debian/Ubuntu)
#   /usr/share/bash-completion/completions/mytool  — XDG location
#   ~/.local/share/bash-completion/completions/mytool  — per-user

# The file itself contains only the function definition and the complete call.
# mytool.bash-completion:
_mytool_complete() { # … as above … }
complete -F _mytool_complete mytool
Dynamic completions: instead of a hard-coded -W word list, call your own tool to get completions at runtime. Many tools support a hidden --complete flag that prints valid values for a given context. This keeps the completion script and the tool in sync automatically.

6 — Dynamic Completions from the Tool Itself

# --complete-subcommands and --complete-options flags for self-describing tools
case "${1:-}" in
  --complete-subcommands)
    # Print all subcommand names, one per line
    declare -F | awk '/^declare -f cmd_/{ sub(/^declare -f cmd_/, ""); print }'
    exit 0 ;;
  --complete-options)
    printf '%s\n' '--verbose' '--quiet' '--output' '--format' '--help' '--version'
    exit 0 ;;
esac

# Completion script uses the tool itself to stay in sync
_mytool_complete() {
  local cur="${COMP_WORDS[$COMP_CWORD]}"
  local prev="${COMP_WORDS[$COMP_CWORD-1]}"

  if [[ $cur == -* ]]; then
    COMPREPLY=( $(compgen -W "$(mytool --complete-options)" -- "$cur") )
  else
    COMPREPLY=( $(compgen -W "$(mytool --complete-subcommands)" -- "$cur") )
  fi
}
complete -F _mytool_complete mytool

7 — Man-Page Stubs

A man page is the canonical reference. Writing full nroff/troff is painful; help2man generates a reasonable first draft from your --help and --version output. For a hand-crafted stub that covers most use cases:

#!/bin/sh
# Generate and preview a man page stub
# Install help2man: apt install help2man / brew install help2man

help2man --no-info --name 'process input files' \
  --output mytool.1 ./mytool

# Preview
man -l mytool.1

# Install
sudo cp mytool.1 /usr/local/share/man/man1/
sudo mandb

Minimal hand-written man stub format

.\" mytool.1 — minimal man page
.TH MYTOOL 1 "$(date '+%B %Y')"" "1.2.0" "User Commands"
.SH NAME
mytool \- process input files
.SH SYNOPSIS
.B mytool
[\fIOPTIONS\fR] \fIFILE\fR [\fIFILE\fR...]
.SH DESCRIPTION
Process one or more input files and write results to stdout.
.SH OPTIONS
.TP
\fB\-o\fR, \fB\-\-output\fR \fIFILE\fR
Write output to FILE instead of stdout.
.TP
\fB\-v\fR, \fB\-\-verbose\fR
Show detailed progress messages.
.TP
\fB\-h\fR, \fB\-\-help\fR
Display help and exit.
.SH EXIT STATUS
.TP
.B 0
Success.
.TP
.B 1
Usage error.
.SH AUTHOR
Your Name <you@example.com>

8 — Putting It All Together: A Reference Template

Here is a complete, production-ready CLI tool skeleton combining everything above:

#!/usr/bin/env bash
# =============================================================================
# mytool — one-line description
#
# USAGE: mytool [OPTIONS] FILE [FILE...]
#
# Options:
#   -o, --output FILE   Write output to FILE (default: stdout)
#   -v, --verbose       Increase verbosity
#   -q, --quiet         Suppress non-error output
#   -h, --help          Show this help and exit
#       --version       Show version and exit
# =============================================================================
set -euo pipefail

PROG="$(basename "${BASH_SOURCE[0]}")"
VERSION='1.0.0'

# ── Exit codes ────────────────────────────────────────────────────────────────
readonly EX_OK=0 EX_USAGE=1 EX_NOINPUT=2 EX_NOPERM=3

# ── Helpers ───────────────────────────────────────────────────────────────────
die()     { printf '%s: %s\n' "$PROG" "$*" >&2; exit "${_exit_code:-1}"; }
warn()    { (( QUIET )) || printf '%s: warning: %s\n' "$PROG" "$*" >&2; }
verbose() { (( VERBOSE )) && printf '%s\n' "$*" >&2 || true; }

usage() {
  sed -n '/^# USAGE:/,/^# ===/{/^# ===/d; s/^# \?//; p}' \
    "${BASH_SOURCE[0]}"
}
version() { printf '%s %s\n' "$PROG" "$VERSION"; }

# ── Argument parsing ──────────────────────────────────────────────────────────
VERBOSE=0
QUIET=0
OUTPUT=''

while [[ $# -gt 0 ]]; do
  case "$1" in
    -v|--verbose)  VERBOSE=1;          shift   ;;
    -q|--quiet)    QUIET=1;            shift   ;;
    -o|--output)   OUTPUT="${2:?--output requires FILE}"; shift 2 ;;
    --output=*)   OUTPUT="${1#--output=}"; shift   ;;
    -h|--help)     usage;              exit 0  ;;
    --version)     version;            exit 0  ;;
    --)            shift;             break   ;;
    -*)            _exit_code=$EX_USAGE die "unknown option: $1" ;;
    *)             break                        ;;
  esac
done

(( $# > 0 )) || { usage >&2; exit "$EX_USAGE"; }

# ── Output setup ──────────────────────────────────────────────────────────────
if [[ -n $OUTPUT ]]; then
  exec 1> "$OUTPUT"   # redirect stdout to file for the rest of the script
fi

# ── Main logic ────────────────────────────────────────────────────────────────
process_file() {
  local f="$1"
  [[ -f "$f" ]] || { _exit_code=$EX_NOINPUT die "file not found: $f"; }
  [[ -r "$f" ]] || { _exit_code=$EX_NOPERM  die "permission denied: $f"; }
  verbose "Processing: $f"
  # … actual work …
  cat "$f"
}

for file in "$@"; do
  process_file "$file"
done

Exercises

Exercise 1 — Robust option parser

Write a script csvcut.sh that accepts the following interface:

Usage: csvcut [OPTIONS] FILE [FILE...]

  -f, --fields LIST     Comma-separated field numbers or names (required)
  -d, --delimiter CHAR  Input delimiter (default: ,)
  -H, --no-header       Input has no header row
  -o, --output FILE     Write to FILE instead of stdout
  -h, --help
      --version

Use a manual while/case loop. Validate that --fields was provided; exit 1 with a usage hint if it was not. Actual CSV processing can be a stub (echo "Would process: $*") — focus on the option handling.

#!/usr/bin/env bash
# USAGE: csvcut [OPTIONS] FILE [FILE...]
#
#   -f, --fields LIST     Comma-separated field numbers (required)
#   -d, --delimiter CHAR  Input delimiter (default: ,)
#   -H, --no-header       Input has no header row
#   -o, --output FILE     Write to FILE instead of stdout
#   -h, --help            Show this help
#       --version         Show version
#
set -euo pipefail
PROG="$(basename "$0")"
VERSION='1.0.0'

usage()   { sed -n '/^# USAGE:/,/^#$/{ s/^# \?//; p }' "${BASH_SOURCE[0]}"; }
version() { printf '%s %s\n' "$PROG" "$VERSION"; }
die()     { printf '%s: %s\n' "$PROG" "$*" >&2; exit 1; }

FIELDS=''
DELIM=','
NO_HEADER=0
OUTPUT=''

while [[ $# -gt 0 ]]; do
  case "$1" in
    -f|--fields)     FIELDS="${2:?--fields requires LIST}";    shift 2 ;;
    --fields=*)      FIELDS="${1#--fields=}";                   shift   ;;
    -d|--delimiter)  DELIM="${2:?--delimiter requires CHAR}";   shift 2 ;;
    --delimiter=*)   DELIM="${1#--delimiter=}";                  shift   ;;
    -H|--no-header)  NO_HEADER=1;                               shift   ;;
    -o|--output)     OUTPUT="${2:?--output requires FILE}";      shift 2 ;;
    --output=*)      OUTPUT="${1#--output=}";                    shift   ;;
    -h|--help)       usage; exit 0                               ;;
    --version)       version; exit 0                             ;;
    --)              shift; break                                 ;;
    -*)              die "unknown option: $1"                     ;;
    *)               break                                         ;;
  esac
done

[[ -n $FIELDS ]] || { usage >&2; die "--fields is required"; }
(( $# > 0 )) || { usage >&2; die "at least one FILE is required"; }

[[ -n $OUTPUT ]] && exec 1>"$OUTPUT"

printf 'fields=%s delim=%q no_header=%d files=%s\n' \
  "$FIELDS" "$DELIM" "$NO_HEADER" "$*"
# Real CSV processing would go here

Exercise 2 — Subcommand tool

Build a tool vaultctl with subcommands add, get, list, and delete that manages a simple key=value store in ~/.vaultctl/store. Requirements:

  • Each subcommand has its own --help
  • The top-level help command lists all subcommands with one-line descriptions
  • Unknown subcommands print an error and list the available ones
  • add KEY VALUE, get KEY, list, delete KEY
#!/usr/bin/env bash
set -euo pipefail
PROG="$(basename "$0")"
STORE_DIR="${HOME}/.vaultctl"
STORE="$STORE_DIR/store"
mkdir -p "$STORE_DIR"
chmod 700 "$STORE_DIR"
: "${STORE:?}"   # ensure path is non-empty

cmd_add() {
  [[ "${1:-}" == '--help' ]] && { echo "Usage: $PROG add KEY VALUE"; return; }
  local key="${1:?add requires KEY}" val="${2:?add requires VALUE}"
  # Remove existing key then append
  sed -i "/^${key}=/d" "$STORE" 2>/dev/null || true
  printf '%s=%s\n' "$key" "$val" >> "$STORE"
  chmod 600 "$STORE"
  echo "Stored: $key"
}

cmd_get() {
  [[ "${1:-}" == '--help' ]] && { echo "Usage: $PROG get KEY"; return; }
  local key="${1:?get requires KEY}"
  local val
  val=$(grep -m1 "^${key}=" "$STORE" 2>/dev/null || true)
  [[ -n $val ]] || { echo "key not found: $key" >&2; return 1; }
  printf '%s\n' "${val#*=}"
}

cmd_list() {
  [[ "${1:-}" == '--help' ]] && { echo "Usage: $PROG list"; return; }
  [[ -f $STORE ]] || { echo "(empty)"; return; }
  awk -F= '{ printf "%-20s %s\n", $1, $2 }' "$STORE"
}

cmd_delete() {
  [[ "${1:-}" == '--help' ]] && { echo "Usage: $PROG delete KEY"; return; }
  local key="${1:?delete requires KEY}"
  sed -i "/^${key}=/d" "$STORE" && echo "Deleted: $key"
}

cmd_help() {
  cat <<EOF
Usage: $PROG COMMAND [ARGS]

Commands:
  add    KEY VALUE   Store a key/value pair
  get    KEY         Retrieve a value by key
  list               List all stored keys
  delete KEY         Remove a key
  help               Show this help

Run '$PROG COMMAND --help' for command-specific help.
EOF
}

CMD="${1:-help}"; shift || true
if declare -f "cmd_${CMD}" >/dev/null 2&1; then
  "cmd_${CMD}" "$@"
else
  printf '%s: unknown command: %s\n\n' "$PROG" "$CMD" >&2
  cmd_help >&2
  exit 1
fi

Exercise 3 — Tab completion script

Write a bash completion file vaultctl.bash-completion for the vaultctl tool from Exercise 2. It should:

  • Complete subcommand names at the first position
  • Complete --help at the second position for any subcommand
  • For get and delete, complete available key names by reading ~/.vaultctl/store
# vaultctl.bash-completion
# Source in ~/.bashrc or install to /etc/bash_completion.d/

_vaultctl_complete() {
  local cur="${COMP_WORDS[$COMP_CWORD]}"
  local prev="${COMP_WORDS[$COMP_CWORD-1]}"
  local subcmd="${COMP_WORDS[1]:-}"
  local store="${HOME}/.vaultctl/store"

  # Position 1: complete subcommands
  if (( COMP_CWORD == 1 )); then
    COMPREPLY=( $(compgen -W 'add get list delete help' -- "$cur") )
    return
  fi

  # Position 2+: subcommand-specific completions
  case "$subcmd" in
    get|delete)
      # Complete key names from store file
      local keys=''
      [[ -f $store ]] && keys=$(awk -F= '{ print $1 }' "$store")
      COMPREPLY=( $(compgen -W "$keys --help" -- "$cur") ) ;;
    add)
      # After 'add KEY', nothing to complete (VALUE is free-form)
      (( COMP_CWORD == 2 )) &&
        COMPREPLY=( $(compgen -W '--help' -- "$cur") ) ;;
    list|help)
      COMPREPLY=( $(compgen -W '--help' -- "$cur") ) ;;
  esac
}

complete -F _vaultctl_complete vaultctl

Exercise 4 — Self-contained tool skeleton

Using the reference template from section 8, create a complete tool lsrecent that:

  • Lists files modified within the last N hours (default: 24)
  • Accepts -n/--hours N, -s/--sort (name|size|time), -l/--long (show size and timestamp), -h/--help, --version
  • Takes an optional directory argument (default: current directory)
  • Includes an embedded usage-string comment and a completion-aware --complete-options hidden flag
#!/usr/bin/env bash
# USAGE: lsrecent [OPTIONS] [DIR]
#
# List files modified within the last N hours.
#
# Options:
#   -n, --hours N       Lookback window in hours (default: 24)
#   -s, --sort FIELD    Sort by: name, size, time (default: time)
#   -l, --long          Show size and modification timestamp
#   -h, --help          Show this help and exit
#       --version       Show version and exit
#
set -euo pipefail
PROG="$(basename "${BASH_SOURCE[0]}")"
VERSION='1.0.0'

usage()   { sed -n '/^# USAGE:/,/^#$/{ s/^# \?//; p }' "${BASH_SOURCE[0]}"; }
version() { printf '%s %s\n' "$PROG" "$VERSION"; }
die()     { printf '%s: %s\n' "$PROG" "$*" >&2; exit 1; }

# Hidden flag for completion
[[ "${1:-}" == '--complete-options' ]] && {
  printf '%s\n' '--hours' '--sort' '--long' '--help' '--version'; exit 0; }

HOURS=24
SORT='time'
LONG=0

while [[ $# -gt 0 ]]; do
  case "$1" in
    -n|--hours)    HOURS="${2:?--hours requires N}";    shift 2 ;;
    --hours=*)    HOURS="${1#--hours=}";               shift   ;;
    -s|--sort)     SORT="${2:?--sort requires FIELD}";  shift 2 ;;
    --sort=*)      SORT="${1#--sort=}";                 shift   ;;
    -l|--long)     LONG=1;                              shift   ;;
    -h|--help)     usage; exit 0                        ;;
    --version)     version; exit 0                      ;;
    --)            shift; break                          ;;
    -*)            die "unknown option: $1"              ;;
    *)             break                                  ;;
  esac
done

DIR="${1:-.}"
[[ -d $DIR ]] || die "not a directory: $DIR"
[[ $SORT =~ ^(name|size|time)$ ]] || die "--sort must be name, size, or time"
[[ $HOURS =~ ^[0-9]+$ ]] || die "--hours must be a positive integer"

# find uses minutes; convert hours
MINS=$(( HOURS * 60 ))

if (( LONG )); then
  # Print: size mtime path
  find "$DIR" -type f -mmin -${MINS} -print0 | \
    xargs -0 stat --format '%s %y %n' | \
    case $SORT in
      name) sort -k3  ;;
      size) sort -k1 -rn ;;
      time) sort -k2 -r  ;;
    esac
else
  case $SORT in
    name) find "$DIR" -type f -mmin -${MINS} | sort ;;
    size) find "$DIR" -type f -mmin -${MINS} -print0 |
            xargs -0 stat --format '%s %n' | sort -rn | awk '{ print $2 }' ;;
    time) find "$DIR" -type f -mmin -${MINS} -print0 |
            xargs -0 stat --format '%Y %n' | sort -rn | awk '{ print $2 }' ;;
  esac
fi