01 · Building Full CLI Tools¶
A script that only works when called exactly one way isn't a tool, it's a
trap. This module covers what turns a script into something other people
(including future you) can pick up and use: consistent flag parsing,
--help text that actually helps, and getopts as the standard way to
parse short options in POSIX-compatible shells.
The shape of a real CLI tool¶
#!/usr/bin/env bash
# deploy.sh — example of a well-shaped CLI tool
set -euo pipefail
PROG=$(basename "$0")
VERSION="1.2.0"
usage() {
cat <<EOF
Usage: $PROG [OPTIONS] <target>
Deploy the application to a target environment.
Arguments:
target environment name (staging|production)
Options:
-e, --env FILE load environment variables from FILE
-f, --force skip confirmation prompts
-v, --verbose print detailed progress
-h, --help show this help and exit
--version show version and exit
Examples:
$PROG staging
$PROG --force --env prod.env production
EOF
}
A predictable usage() function is the single biggest readability win you
can add to a script. Every flag, every argument, one example — that's the
convention this module builds toward.
Manual flag parsing (simple cases)¶
For a handful of long-only flags, a plain while/case loop is often
enough and needs no extra library:
verbose=false
force=false
while [[ $# -gt 0 ]]; do
case "$1" in
--verbose) verbose=true; shift ;;
--force) force=true; shift ;;
--help) usage; exit 0 ;;
--) shift; break ;; # explicit end-of-options marker
-*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
*) break ;; # first positional argument
esac
done
target="${1:-}"
This scales poorly once you need combined short flags (-vf) or flags
with attached values (-e prod.env) — that's where getopts earns its
keep.
getopts deep dive¶
getopts is the POSIX-standard way to parse short options and is built
into sh, bash, and zsh. The option string encodes which flags take
an argument: a letter followed by : means "this flag consumes the next
word as its value."
#!/usr/bin/env bash
set -euo pipefail
env_file=""
force=false
verbose=false
# "e:fvh" — 'e' takes a value (the ':'), f/v/h are boolean switches
while getopts ":e:fvh" opt; do
case "$opt" in
e) env_file="$OPTARG" ;; # OPTARG holds the value after -e
f) force=true ;;
v) verbose=true ;;
h) usage; exit 0 ;;
\?) echo "Invalid option: -$OPTARG" >&2; usage; exit 1 ;;
:) echo "Option -$OPTARG requires an argument" >&2; exit 1 ;;
esac
done
shift $((OPTIND - 1)) # drop parsed options, leaving positional args in $@
target="${1:-}"
[[ -z "$target" ]] && { echo "Error: <target> is required" >&2; usage; exit 1; }
Key details:
- The leading
:in":e:fvh"switchesgetoptsinto "silent error" mode, so you handle bad flags yourself via the\?and:cases instead of it printing its own message. OPTINDtracks the index of the next argument to process; resetting the positional parameters withshift $((OPTIND - 1))is what makes$1,$2, ... refer to the real positional arguments afterward.getoptsonly understands short options (-e) and bundled short flags (-vf=-v -f); it has no built-in notion of--long-options.
Combining getopts with long options¶
A common pattern is to translate long flags to short ones before the
getopts loop runs, giving you both without a third-party library:
args=()
for arg in "$@"; do
case "$arg" in
--env) args+=("-e") ;;
--force) args+=("-f") ;;
--verbose) args+=("-v") ;;
--help) args+=("-h") ;;
*) args+=("$arg") ;;
esac
done
set -- "${args[@]}" # replace $@ with the translated argument list
while getopts ":e:fvh" opt; do
# ... same as above
:
done
Argument-parsing frameworks¶
For larger tools, hand-rolled parsing gets unwieldy. Two common approaches:
docopt-style — write the usage() text first, generate the parser
from it (via a language binding), so help text and behavior can never
drift apart.
Reusable parse function — define an associative array of flag specs and loop over it once, keeping every script's parsing logic identical:
declare -A OPTS=( [force]=false [verbose]=false [env]="" )
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--force) OPTS[force]=true; shift ;;
--verbose) OPTS[verbose]=true; shift ;;
--env) OPTS[env]="$2"; shift 2 ;;
*) POSITIONAL+=("$1"); shift ;;
esac
done
}
POSITIONAL=()
parse_args "$@"
set -- "${POSITIONAL[@]}"
This is worth extracting into a shared lib/args.sh once you maintain
more than two or three CLI tools, so every tool parses flags the same way.
Exit codes and standard streams¶
A well-behaved CLI tool follows two conventions that make it composable with other tools:
# errors and diagnostics go to stderr, not stdout
echo "Error: config file not found" >&2
# exit codes communicate success/failure to calling scripts and CI
exit 0 # success
exit 1 # generic failure
exit 2 # usage error (bad arguments) — a common convention
How It Actually Works¶
Standard streams are not shell concepts, they're kernel bookkeeping: every
process starts life with file descriptors 0, 1, and 2 already open (stdin,
stdout, stderr), inherited from whatever fork()'d it, pointing at
whatever the parent had them pointed at — a terminal device, a pipe, or a
regular file. A well-behaved CLI tool writing normal output to fd 1 and
diagnostics to fd 2 is purely a convention, but it's one the kernel and
shell both silently support: it's the reason tool 2>/dev/null can discard
just error chatter without touching real output, because the two streams
are genuinely separate descriptors, not a single stream tagged by severity.
Exit codes as a structured contract (0 = success, distinct nonzero values
per failure category) work precisely because, as covered elsewhere, that's
literally all the kernel preserves across wait() — a single 8-bit integer
— so any richer error information a well-designed CLI wants to convey has
to either be encoded into that narrow range, printed to stderr as text, or
written to a separate structured-output stream (like --json output on
stdout) for the calling script to parse.
Detecting whether output is going to a terminal (to decide on color/progress
bars) is done via isatty(3)/[ -t 1 ], which asks the kernel directly
whether a given file descriptor refers to a TTY device — this is how tools
know to suppress ANSI color codes automatically the moment their output is
redirected into a pipe or file, since a regular file or pipe fails that
check.
Cheat sheet¶
| Pattern | Purpose |
|---|---|
while [[ $# -gt 0 ]]; do case "$1" in ... |
manual parsing for long-only flags |
getopts ":e:fvh" opt |
POSIX short-option parsing, e: = takes a value |
$OPTARG |
value attached to the current getopts flag |
$OPTIND |
index of the next unparsed argument |
shift $((OPTIND - 1)) |
drop parsed flags, leaving positionals in $@ |
-- |
conventional "end of options" marker |
exit 2 |
usage-error convention (vs. 1 for general failure) |
Exercise¶
Write greet.sh, a CLI tool that accepts -n NAME (required), -g GREETING
(optional, default "Hello"), and -h/--help. Parse the flags with
getopts, print a proper usage() block on -h or on missing -n, and
have the tool print "$GREETING, $NAME!" on success. Test it with
./greet.sh -n Alice, ./greet.sh -n Bob -g Hi, and ./greet.sh (which
should fail with a usage error and exit code 2).