Parsing Options and Building Command-Line Interfaces
Treat a shell script as a versioned interface: define options, operands, defaults, diagnostics, output modes, validation, and exit statuses before implementing side effects.
Learning objectives
By the end of this lesson
- Separate options, option arguments, operands, environment variables, and standard input.
- Parse portable short options with the Bash
getoptsbuiltin. - Implement a controlled long-option parser without
eval. - Validate values after parsing and use consistent usage and operational exit statuses.
- Design human-readable and machine-readable output modes.
1. A CLI is an API for humans and automation
Once a script is called from cron, CI, another script, documentation, or an incident runbook, its command-line interface becomes a compatibility surface. Names, defaults, output, exit statuses, and side effects should be intentional. A reliable CLI parses first, validates second, prints the effective plan, and mutates only after the input contract is complete.
flowchart TD
A["Raw argv and environment"] --> P["Parse options and operands"]
P --> U{"Help or version requested?"}
U -- yes --> H["Print requested information and exit 0"]
U -- no --> V["Validate required values and combinations"]
V --> D["Resolve defaults and precedence"]
D --> E["Build effective immutable plan"]
E --> R{"Dry-run?"}
R -- yes --> O["Print plan without mutation"]
R -- no --> X["Execute operations"]
X --> S["Return documented status and output"]Document whether repeated options override, append, or fail. Document whether options may appear after operands. Decide whether environment variables provide defaults or override explicit arguments. The most maintainable rule is usually: built-in default < configuration file < environment < explicit CLI option.
2. Use getopts for portable short options
getopts parses one-letter options from the current positional parameters. A colon after an option letter means that option requires an argument. A leading colon enables silent error mode so the script can produce its own diagnostics. OPTARG carries the option argument and OPTIND identifies the next unprocessed position.
usage() {
cat <<'USAGE'
Usage: deploy-plan.sh [-n] [-e ENV] [-r REPLICAS] SERVICE
-e ENV target environment: dev, staging, or prod
-r REPLICAS integer from 1 through 20
-n dry run
-h show help
USAGE
}
environment=dev
replicas=1
dry_run=false
while getopts ':e:r:nh' option; do
case $option in
e) environment=$OPTARG ;;
r) replicas=$OPTARG ;;
n) dry_run=true ;;
h) usage; exit 0 ;;
:) printf 'Option -%s requires an argument\n' "$OPTARG" >&2; usage >&2; exit 2 ;;
\?) printf 'Unknown option: -%s\n' "$OPTARG" >&2; usage >&2; exit 2 ;;
esac
done
shift "$((OPTIND - 1))"
(( $# == 1 )) || { usage >&2; exit 2; }
service=$1If the same shell calls a parsing function more than once, reset OPTIND=1 before the next getopts pass. Do not rely on GNU external getopt behavior when portability is required; it is a different command with a different interface.
3. Parse long options with a controlled loop
Bash getopts does not natively parse GNU-style long options. A manual loop is appropriate when the supported grammar is small and explicit. Handle both --name value and, only when useful, --name=value. Require -- before operands that begin with a dash.
environment=dev
replicas=1
format=text
dry_run=false
declare -a operands=()
while (( $# > 0 )); do
case $1 in
-e|--environment)
(( $# >= 2 )) || { printf '%s requires a value\n' "$1" >&2; exit 2; }
environment=$2
shift 2
;;
--environment=*) environment=${1#*=}; shift ;;
-r|--replicas)
(( $# >= 2 )) || { printf '%s requires a value\n' "$1" >&2; exit 2; }
replicas=$2
shift 2
;;
--replicas=*) replicas=${1#*=}; shift ;;
--format=*) format=${1#*=}; shift ;;
-n|--dry-run) dry_run=true; shift ;;
-h|--help) usage; exit 0 ;;
--) shift; operands+=("$@"); break ;;
-*) printf 'Unknown option: %s\n' "$1" >&2; exit 2 ;;
*) operands+=("$1"); shift ;;
esac
doneStoring shell-escaped output in a string and evaluating it creates a second code parser. Keep the original argument vector in arrays and pass it with "${array[@]}".
4. Parsing and validation are separate phases
A parser answers “which values did the caller provide?” Validation answers “are those values allowed together and safe for the intended operation?” Keep validation after parsing so errors are deterministic and no side effect occurs before the complete request is known.
validate_plan() {
[[ $environment =~ ^(dev|staging|prod)$ ]] || {
printf 'Invalid environment: %s\n' "$environment" >&2
return 2
}
[[ $replicas =~ ^[0-9]+$ ]] && (( replicas >= 1 && replicas <= 20 )) || {
printf 'Replicas must be an integer from 1 through 20\n' >&2
return 2
}
[[ $format == text || $format == json ]] || {
printf 'Format must be text or json\n' >&2
return 2
}
(( ${#operands[@]} == 1 )) || {
printf 'Exactly one SERVICE operand is required\n' >&2
return 2
}
service=${operands[0]}
[[ $service =~ ^[a-z][a-z0-9-]{2,31}$ ]] || {
printf 'Invalid service name: %s\n' "$service" >&2
return 2
}
}
validate_plan || exit $?Do not mix usage errors with operational failures. A malformed command line can return status 2, while a failed remote operation can return status 1 or a documented domain-specific value. Stable statuses let calling automation decide whether to retry, alert, or correct its invocation.
5. Separate diagnostics, data, and presentation
Human text can include labels and aligned columns. Machine output should have a documented schema and no progress messages mixed into standard output. Send diagnostics and logs to standard error. A --quiet flag suppresses nonessential human messages; it should not suppress errors.
emit_plan() {
if [[ $format == json ]]; then
jq -n \
--arg service "$service" \
--arg environment "$environment" \
--argjson replicas "$replicas" \
--argjson dry_run "$dry_run" \
'{service:$service, environment:$environment,
replicas:$replicas, dry_run:$dry_run}'
else
printf 'service=%s\n' "$service"
printf 'environment=%s\n' "$environment"
printf 'replicas=%d\n' "$replicas"
printf 'dry_run=%s\n' "$dry_run"
fi
}When JSON tooling is not guaranteed, either declare jq as a dependency or implement only text output. Hand-built JSON with printf is unsafe unless every string is encoded correctly.
6. Hands-on lab: build a deploy-plan CLI
The lab implements short and long options, validation, a dry-run plan, and text or JSON output. It performs no deployment.
lab="$HOME/devops-academy/linux/chapter15/lesson02"
rm -rf "$lab"
mkdir -p "$lab"
cd "$lab"
cat > deploy-plan.sh <<'SCRIPT'
#!/usr/bin/env bash
set -u
usage() {
cat <<'USAGE'
Usage: deploy-plan.sh [OPTIONS] SERVICE
-e, --environment ENV dev, staging, or prod (default: dev)
-r, --replicas N 1..20 (default: 1)
-n, --dry-run mark the plan as non-mutating
--format FORMAT text or json (default: text)
-h, --help show this help
USAGE
}
main() {
local environment=dev replicas=1 format=text dry_run=false service
local -a operands=()
while (( $# > 0 )); do
case $1 in
-e|--environment) (( $# >= 2 )) || { printf '%s requires a value\n' "$1" >&2; return 2; }; environment=$2; shift 2 ;;
--environment=*) environment=${1#*=}; shift ;;
-r|--replicas) (( $# >= 2 )) || { printf '%s requires a value\n' "$1" >&2; return 2; }; replicas=$2; shift 2 ;;
--replicas=*) replicas=${1#*=}; shift ;;
-n|--dry-run) dry_run=true; shift ;;
--format=*) format=${1#*=}; shift ;;
-h|--help) usage; return 0 ;;
--) shift; operands+=("$@"); break ;;
-*) printf 'Unknown option: %s\n' "$1" >&2; return 2 ;;
*) operands+=("$1"); shift ;;
esac
done
[[ $environment =~ ^(dev|staging|prod)$ ]] || { printf 'Invalid environment: %s\n' "$environment" >&2; return 2; }
[[ $replicas =~ ^[0-9]+$ ]] && (( replicas >= 1 && replicas <= 20 )) || { printf 'Replicas must be 1..20\n' >&2; return 2; }
[[ $format == text || $format == json ]] || { printf 'Format must be text or json\n' >&2; return 2; }
(( ${#operands[@]} == 1 )) || { usage >&2; return 2; }
service=${operands[0]}
[[ $service =~ ^[a-z][a-z0-9-]{2,31}$ ]] || { printf 'Invalid service: %s\n' "$service" >&2; return 2; }
if [[ $format == json ]]; then
command -v jq >/dev/null || { printf 'jq is required for JSON output\n' >&2; return 69; }
jq -n --arg service "$service" --arg environment "$environment" \
--argjson replicas "$replicas" --argjson dry_run "$dry_run" \
'{service:$service,environment:$environment,replicas:$replicas,dry_run:$dry_run}'
else
printf 'service=%s environment=%s replicas=%d dry_run=%s\n' \
"$service" "$environment" "$replicas" "$dry_run"
fi
}
main "$@"
SCRIPT
chmod u+x deploy-plan.sh
bash -n deploy-plan.sh
./deploy-plan.sh --environment staging --replicas=3 --dry-run payments-api
./deploy-plan.sh --format=json -e prod -r 5 orders-worker | jq -e '.replicas == 5'
if ./deploy-plan.sh --replicas zero api-service >/dev/null 2>&1; then
printf 'FAIL: invalid replicas accepted\n' >&2
exit 1
fi
printf 'PASS\n' Verification checklist
7. Common CLI design mistakes
“Options can be added without compatibility planning.”
Renaming defaults or changing output can break CI and runbooks even when the script still works interactively.
“Validation belongs inside the operation.”
Late validation can leave partial side effects. Build and validate the complete plan first.
“Standard output is for every message.”
Mixing logs with data breaks pipelines. Keep data on stdout and diagnostics on stderr.
“Dry-run means print the command string.”
A useful dry-run validates dependencies and displays the effective plan without performing mutations; it does not need to synthesize executable shell source.
8. Knowledge check
Question 1. What does shift "$((OPTIND - 1))" accomplish after getopts?
Question 2. Why should parsing and validation be separate?
Question 3. Why should machine-readable output exclude progress logs?
9. Summary
A Bash CLI is a versioned interface. Define the grammar, parse without evaluation, validate after parsing, establish precedence, use arrays for argument vectors, distinguish usage from operational failures, and keep data separate from diagnostics. A dry-run should expose the effective plan while preserving the no-mutation guarantee.
10. Further reading
- GNU Bash Reference Manual —
getopts. - GNU Bash Reference Manual — positional parameters.
- POSIX Utility Syntax Guidelines.
help getoptsandhelp shifton the target Bash version.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this address.