11 — Argument Parsing Internals: getopts, Subcommands & CLI Design
Positional Arguments: Internal Mechanics
bash
# ── Positional parameters: $1, $2, ..., $9, ${10}, ${10}+ ──
echo "$0" # script name (or the shell if sourced — use BASH_SOURCE[0] instead)
echo "$1" # first arg
echo "$2" # second arg
echo "${10}" # 10th arg — MUST use braces! ($10 is $1 followed by literal "0")
echo "$@" # all args, each separate (USE QUOTED: "$@")
echo "$*" # all args joined by IFS into one string
echo "$#" # count of args
# ── shift: remove args from the front ──
set -- a b c d e # set positional args (for demo)
echo "$@" # a b c d e
shift # remove $1 (a), shift rest left
echo "$@" # b c d e
shift 2 # remove $1 and $2 (b, c)
echo "$@" # d e
# ── Iterating all args ──
for arg in "$@"; do # ALWAYS quote "$@" — preserves args with spaces
echo "arg: $arg"
done
# ── Passing args to a function ──
process() {
echo "function sees $# args: $@"
}
process "$@" # pass the script's args to the function (quoted — safe)
getopts: The Built-In Option Parser
bash
#!/usr/bin/env bash
# ── getopts parses short options: -v, -o VALUE, -h ──
# It's a built-in (no subprocess), POSIX, and reliable (unlike external `getopt`).
verbose=false
output=""
count=0
# ── Syntax: getopts "OPTIONS" var ──
# Options string: each letter is an option. A letter followed by `:` takes an argument.
# Leading `:` enables silent error reporting (you handle errors with ? and :).
while getopts ":hvo:c:" opt; do
# :hvo:c: →
# h → flag (no argument)
# v → flag (no argument)
# o: → option with argument (OPTARG)
# c: → option with argument
# Leading : → silent mode (don't print error, set opt to ? or :)
case "$opt" in
h)
echo "Usage: $0 [-h] [-v] [-o FILE] [-c N]"; exit 0
;;
v) verbose=true ;;
o) output="$OPTARG" ;; # OPTARG: the argument value for -o
c) count="$OPTARG" ;;
\?) # ? — invalid option
echo "Invalid option: -$OPTARG" >&2
exit 1
;;
:) # : — missing argument
echo "Option -$OPTARG requires an argument." >&2
exit 1
;;
esac
done
shift $((OPTIND - 1)) # OPTIND: index of next arg. Shift to remove parsed options.
# After shift, "$@" contains only positional args (not options)
echo "verbose: $verbose"
echo "output: $output"
echo "count: $count"
echo "positional: $@"
getopts Internals: State Machine
bash
# ── How getopts works internally ──
# getopts maintains two global variables:
# OPTIND — index of the next arg to process (starts at 1)
# OPTARG — the argument value for options that take one (-o value → OPTARG="value")
# ── Each call to getopts processes ONE option ──
# It looks at $OPTIND in "$@", finds the next -flag, and:
# - Sets $opt to the flag letter
# - If the flag takes an arg (followed by :), sets $OPTARG to the value
# - Increments $OPTIND
# Returns 0 (success) if an option was found, 1 (failure) at end of options.
# ── -o value vs -ovalue (both work) ──
./script.sh -o output.txt # OPTARG="output.txt"
./script.sh -ooutput.txt # OPTARG="output.txt" (attached — also works)
# ── Combined short flags ──
./script.sh -vo output.txt # -v and -o output.txt (combined: -v is a flag, -o takes arg)
# getopts processes: v (flag), then o (with arg "output.txt")
# ── -- ends option parsing ──
./script.sh -v -- -o output.txt
# -v is parsed as a flag. -- stops option parsing.
# After shift: "$@" = "-o output.txt" (the -o is a POSITIONAL arg, not an option!)
# ── Options after positional args STOP parsing ──
./script.sh file.txt -v
# getopts stops at "file.txt" (first non-option). -v is NOT parsed — it's positional!
# This is a getopts limitation: options must come BEFORE positional args.
# Fix: use manual parsing (below) for intermixed args, or document the order.
Anti-Pattern: Forgetting shift After getopts
bash
# ❌ NAIVE — no shift, positional args include the options
while getopts "vo:" opt; do
case "$opt" in
v) verbose=true ;;
o) output="$OPTARG" ;;
esac
done
echo "positional: $@" # -v -o output.txt file1 file2 — still includes options!
# ✅ CORRECT — shift after the loop
while getopts "vo:" opt; do
case "$opt" in
v) verbose=true ;;
o) output="$OPTARG" ;;
esac
done
shift $((OPTIND - 1)) # remove parsed options, leave positional args
echo "positional: $@" # file1 file2 — options stripped
# ── Why OPTIND - 1 ──
# OPTIND is the index of the NEXT arg to process (after getopts stops).
# If 3 options were parsed (-v -o out.txt), OPTIND=4.
# shift $((4-1)) = shift 3 → removes -v, -o, out.txt, leaving positional args.
Long Options: Manual Parsing
bash
#!/usr/bin/env bash
# ── Manual parsing for long options (--verbose, --output FILE) ──
# More verbose than getopts, but supports --long-option.
verbose=false
output=""
input=""
count=0
while [[ $# -gt 0 ]]; do
case "$1" in
# ── Flags (no argument) ──
-v|--verbose)
verbose=true
shift
;;
-h|--help)
usage; exit 0
;;
# ── Options with argument: --output VALUE ──
-o|--output)
output="$2"
shift 2 # consume --output AND the value
;;
-i|--input)
input="$2"
shift 2
;;
-c|--count)
count="$2"
shift 2
;;
# ── Options with = syntax: --output=VALUE ──
--output=*)
output="${1#--output=}" # strip the --output= prefix
shift
;;
--input=*)
input="${1#--input=}"
shift
;;
# ── End of options ──
--)
shift # remove --, stop parsing
break
;;
# ── Unknown option ──
-*)
echo "Unknown option: $1" >&2
exit 1
;;
# ── Positional argument ──
*)
positionals+=("$1")
shift
;;
esac
done
echo "verbose: $verbose"
echo "output: $output"
echo "input: $input"
echo "count: $count"
echo "positionals: ${positionals[@]:-}"
Production Pattern: Full CLI with Subcommands
bash
#!/usr/bin/env bash
set -euo pipefail
readonly VERSION="1.0.0"
readonly SCRIPT_NAME=$(basename "$0")
# ── Usage ──
usage() {
cat <<EOF
$SCRIPT_NAME v$VERSION
Usage: $SCRIPT_NAME [OPTIONS] <command> [args...]
Commands:
build Build the project
test Run tests
deploy Deploy to environment
Options:
-h, --help Show this help
-v, --verbose Verbose output
--version Show version
Examples:
$SCRIPT_NAME build --output dist/
$SCRIPT_NAME test --coverage
$SCRIPT_NAME deploy --env prod --dry-run
EOF
}
# ── Global options ──
verbose=false
# ── Parse global options (before subcommand) ──
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage; exit 0 ;;
-v|--verbose) verbose=true; shift ;;
--version) echo "$VERSION"; exit 0 ;;
-*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
*) break ;; # first non-option is the subcommand
esac
done
# ── Subcommand dispatch ──
subcommand="${1:-}"
shift || true # remove subcommand, don't fail if no args
case "$subcommand" in
build) cmd_build "$@" ;;
test) cmd_test "$@" ;;
deploy) cmd_deploy "$@" ;;
""|help) usage ;;
*) echo "Unknown command: $subcommand" >&2; usage; exit 1 ;;
esac
# ── Subcommand implementations ──
cmd_build() {
local output="dist/"
while [[ $# -gt 0 ]]; do
case "$1" in
-o|--output) output="$2"; shift 2 ;;
--output=*) output="${1#--output=}"; shift ;;
-h|--help) echo "Usage: $SCRIPT_NAME build [-o DIR]"; return 0 ;;
*) echo "Unknown: $1" >&2; return 1 ;;
esac
done
$verbose && echo "Building to $output..."
# ... real build logic ...
}
cmd_test() {
local coverage=false
while [[ $# -gt 0 ]]; do
case "$1" in
--coverage) coverage=true; shift ;;
-h|--help) echo "Usage: $SCRIPT_NAME test [--coverage]"; return 0 ;;
*) echo "Unknown: $1" >&2; return 1 ;;
esac
done
$verbose && echo "Running tests (coverage: $coverage)..."
# ... real test logic ...
}
cmd_deploy() {
local env="staging" dry_run=false
while [[ $# -gt 0 ]]; do
case "$1" in
-e|--env) env="$2"; shift 2 ;;
--env=*) env="${1#--env=}"; shift ;;
--dry-run) dry_run=true; shift ;;
-h|--help) echo "Usage: $SCRIPT_NAME deploy [-e ENV] [--dry-run]"; return 0 ;;
*) echo "Unknown: $1" >&2; return 1 ;;
esac
done
$verbose && echo "Deploying to $env (dry-run: $dry_run)..."
# ... real deploy logic ...
}
Reading from stdin
bash
# ── Detect if stdin is a pipe (data) or terminal (interactive) ──
if [[ -t 0 ]]; then
# stdin is a terminal — no piped input
echo "No input on stdin. Use: $0 < file.txt OR cat file | $0"
input=""
else
# stdin is a pipe or file — read it
input=$(cat) # read ALL of stdin
# Or line by line:
# while IFS= read -r line; do ...; done
fi
# ── Common pattern: read from file OR stdin ──
# If a file is given as arg, read from it; otherwise read from stdin.
if [[ $# -gt 0 ]]; then
input_file="$1"
[[ -f "$input_file" ]] || { echo "not found: $input_file" >&2; exit 1; }
while IFS= read -r line; do
process "$line"
done < "$input_file"
else
while IFS= read -r line; do
process "$line"
done
fi
# ── Check if stdout is a terminal ──
if [[ -t 1 ]]; then
# stdout is a terminal — use colors
RED=$'\e[31m'
RESET=$'\e[0m'
else
# stdout is piped/redirected — no colors
RED=""
RESET=""
fi
echo "${RED}Error${RESET}: something went wrong"
Anti-Pattern: getopt (External Command)
bash
# ❌ AVOID — external getopt is unreliable across systems
# GNU getopt: supports long options, reorders args
# BSD getopt: different behavior, no long options
# The two are INCOMPATIBLE — scripts break across platforms.
getopt -o "vo:" -l "verbose,output:" -- "$@"
# GNU: works. BSD: might not. Different error handling. Unpredictable.
# ✅ USE getopts (built-in) for short options
# ✅ USE manual while/case parsing for long options
# ✅ USE argbash (code generator) for complex CLIs: brew install argbash
💡 Tips & Tricks
bash
# ── `--flag=value` extraction pattern ──
case "$1" in
--output=*) output="${1#--output=}" ;; # ${1#--output=} strips the prefix
--port=*) port="${1#--port=}" ;;
esac
# ── Re-parse with getopts (reset OPTIND) ──
# If you need to call getopts in a function (after a previous getopts loop):
parse_options() {
OPTIND=1 # MUST reset — getopts uses OPTIND globally
while getopts "vo:" opt; do
# ...
done
}
# ── Validate numeric args ──
validate_int() {
local var=$1
[[ "$var" =~ ^[0-9]+$ ]] || die "$var is not a positive integer"
}
count="${1:-}"
validate_int "$count"
# ── Accept multiple values for one flag (--include *.py --include *.sh) ──
includes=()
while [[ $# -gt 0 ]]; do
case "$1" in
--include) includes+=("$2"); shift 2 ;;
*) break ;;
esac
done
# ── Tab completion helper: output --help in a parseable format ──
# For bash completion, add this to ~/.bashrc:
# complete -F _my_script myscript
# _my_script() {
# local cur="${COMP_WORDS[COMP_CWORD]}"
# COMPREPLY=( $(compgen -W "build test deploy" -- "$cur") )
# }
⚠️ Edge Cases & Gotchas
bash
# ── getopts stops at first non-option ──
# ./script.sh file.txt -v → getopts doesn't see -v (stops at file.txt)
# Options must come BEFORE positional args (or use manual parsing).
# ── getopts only does short options ──
# No --verbose. Use manual parsing or argbash for long options.
# ── `shift` beyond $# errors ──
# shift with no args left: "shift: shift count out of range"
# Guard: [[ $# -gt 0 ]] && shift OR shift || true
# ── `$10` is `$1` followed by `0` ──
echo $10 # "$1" + "0" = "foo0" (if $1 is "foo")
echo ${10} # the actual 10th arg
# ── `$0` isn't always the script name ──
# If called via symlink: $0 is the symlink path (not the real script)
# If sourced: $0 is the shell (e.g., "bash")
# Use BASH_SOURCE[0] for the script's actual path.
# ── `--` stops option parsing ──
# ./script.sh -v -- -file.txt → -v is a flag, -file.txt is positional
# The -- is consumed by getopts, but in manual parsing you need to handle it.
# ── OPTIND is global ──
# If you call getopts in a function after a previous getopts, OPTIND is stale.
# Reset: OPTIND=1 (or OPTIND=0 in some versions — but 1 is standard)
🧠 Quick Quiz
Why does this fail to parse the --verbose flag?
bash
while getopts "v-:" opt; do
case "$opt" in
v) verbose=true ;;
-) echo "long option: $OPTARG" ;;
esac
done
Answer
getopts does not support long options. The - in "v-:" is treated as a regular short option named -, not as a prefix for long options. getopts only parses single-character options.
When you run ./script.sh --verbose, getopts sees - as the first character after --, and since - is in the optstring, it might match it — but OPTARG won't contain verbose in a useful way. This is a common misconception.
The correct approaches:
- Use manual
while/caseparsing for long options (as shown above). - Use external tools like
argbash(code generator) orgetopt(GNU, not portable). - Use short options only with
getoptsand document--verboseas not supported.
The lesson: getopts is for short options only (-v, -o VALUE). For --verbose, use manual while [[ $# -gt 0 ]]; do case "$1" in ... esac; done parsing.