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.
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"
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
| Flag | Completes |
|---|---|
-W 'word1 word2' | Fixed word list |
-f | Filenames in current directory |
-d | Directories only |
-u | Usernames |
-g | Group names |
-c | Commands on PATH |
-A function | Function names |
-v | Shell 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
-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
helpcommand 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
--helpat the second position for any subcommand - For
getanddelete, 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-optionshidden 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