Building Real CLI Tools with getopts

Parse flags properly, write usage that doubles as documentation, and give your script the interface conventions every other Unix tool follows.

advanced 17 min lesson hands-on task included

A script that reads $1 and $2 positionally is a script only its author can use. getopts is in every POSIX shell, needs no dependencies, and gives you the interface conventions users already know.


Topic 1: How getopts Works

while getopts ":vo:h" opt; do
    case $opt in
        v) verbose=1 ;;
        o) output=$OPTARG ;;
        h) usage; exit 0 ;;
        \?) echo "unknown option: -$OPTARG" >&2; usage >&2; exit 2 ;;
        :)  echo "option -$OPTARG requires an argument" >&2; exit 2 ;;
    esac
done
shift $(( OPTIND - 1 ))
# "$@" now holds only the non-option arguments

The option string:

  • A bare letter (v) is a flag.
  • A letter followed by : (o:) takes an argument, delivered in $OPTARG.
  • A leading : switches on silent error reporting — getopts stops printing its own messages and instead reports unknown options as \? and missing arguments as :. Always use it; it is the only way to control your own error output.

The two variables:

VariableHolds
$OPTARGThe argument to the current option
$OPTINDIndex of the next argument to process

shift $(( OPTIND - 1 )) discards everything getopts consumed, leaving the positional arguments in "$@". Forgetting that line is the single most common getopts bug — the flags stay in $@ and every downstream loop sees them as filenames.

What you get for free:

./tool -v -o out.txt file1 file2       # separate
./tool -vo out.txt file1 file2         # bundled
./tool -vooutput.txt file1             # attached argument
./tool -v -- -weird-filename           # -- ends option parsing

All four work without extra code. That is why getopts is worth using over hand-rolled case loops on $1.


Topic 2: A Complete Skeleton

#!/usr/bin/env bash
set -Eeuo pipefail

readonly SCRIPT_NAME=${0##*/}
readonly VERSION="1.2.0"

# Defaults, overridable by environment then flags
verbose=0
output=""
dry_run=0
retries=${TOOL_RETRIES:-3}

usage() {
    cat <<EOF
${SCRIPT_NAME} ${VERSION} — sync build artifacts to a target host

USAGE
    ${SCRIPT_NAME} [OPTIONS] FILE...

OPTIONS
    -o FILE     write output to FILE (default: stdout)
    -r N        retry attempts on failure (default: ${retries})
    -n          dry run; print actions without performing them
    -v          verbose; repeat for more detail
    -h          show this help and exit
    -V          print version and exit

ENVIRONMENT
    TOOL_RETRIES    default for -r

EXAMPLES
    ${SCRIPT_NAME} -v -o report.txt build/*.log
    ${SCRIPT_NAME} -n build/app.log

EXIT STATUS
    0  success
    1  runtime failure
    2  usage error
EOF
}

die() { echo "${SCRIPT_NAME}: $*" >&2; exit 1; }

while getopts ":o:r:nvhV" opt; do
    case $opt in
        o) output=$OPTARG ;;
        r) retries=$OPTARG ;;
        n) dry_run=1 ;;
        v) (( verbose++ )) || true ;;
        h) usage; exit 0 ;;
        V) echo "${SCRIPT_NAME} ${VERSION}"; exit 0 ;;
        \?) echo "${SCRIPT_NAME}: unknown option -${OPTARG}" >&2; usage >&2; exit 2 ;;
        :)  echo "${SCRIPT_NAME}: -${OPTARG} requires an argument" >&2; exit 2 ;;
    esac
done
shift $(( OPTIND - 1 ))

# Validate what is left
(( $# > 0 )) || { echo "${SCRIPT_NAME}: no input files" >&2; usage >&2; exit 2; }
[[ $retries =~ ^[0-9]+$ ]] || die "-r must be a number, got: ${retries}"

for f in "$@"; do
    [[ -r $f ]] || die "cannot read: ${f}"
done

Three details worth copying:

  • -h exits 0 and prints to stdout; a usage error prints to stderr and exits 2. Help that was asked for is not an error. Users pipe --help into less; they cannot if it goes to stderr.
  • Exit 2 for usage errors is the long-standing Unix convention, distinct from 1 for runtime failure. It lets a caller distinguish “you invoked me wrong” from “the work failed”.
  • -v counted, not boolean. (( verbose++ )) lets -vv mean more detail, matching ssh -vvv.

Topic 3: Long Options

getopts handles short options only. Bash has no built-in long-option parser, and there are three honest choices.

1. Hand-rolled while/case — full control, more code:

while [[ $# -gt 0 ]]; do
    case $1 in
        -o|--output)   output=$2; shift 2 ;;
        --output=*)    output=${1#*=}; shift ;;
        -n|--dry-run)  dry_run=1; shift ;;
        -v|--verbose)  (( verbose++ )) || true; shift ;;
        -h|--help)     usage; exit 0 ;;
        --)            shift; break ;;
        -*)            echo "unknown option: $1" >&2; exit 2 ;;
        *)             break ;;
    esac
done

Note the --output=value case: users expect both spellings. And -- must break the loop so a file literally named -v can be passed.

2. GNU getopt(1) — the external program, not the builtin:

parsed=$(getopt -o o:nvh --long output:,dry-run,verbose,help -n "$SCRIPT_NAME" -- "$@") || exit 2
eval set -- "$parsed"

Handles long options and reorders arguments, but it is GNU-specific — macOS ships the BSD version, which does not support --long and silently behaves differently. Only use it if you control the platform.

3. Decide it is Python’s problem. Past a handful of options with interdependencies, argparse is a better tool than either of the above.


Topic 4: Subcommands

For anything with more than one verb, dispatch on $1 and forward the rest:

usage() {
    cat <<EOF
USAGE: ${SCRIPT_NAME} <command> [options]

COMMANDS
    deploy      push the current build to a target
    rollback    restore the previous release
    status      show what is currently deployed

Run '${SCRIPT_NAME} <command> -h' for command-specific help.
EOF
}

cmd_deploy() {
    local target="" 
    while getopts ":t:h" opt; do
        case $opt in
            t) target=$OPTARG ;;
            h) echo "usage: ${SCRIPT_NAME} deploy -t TARGET"; return 0 ;;
            \?) echo "deploy: unknown option -${OPTARG}" >&2; return 2 ;;
        esac
    done
    shift $(( OPTIND - 1 ))
    [[ -n $target ]] || { echo "deploy: -t is required" >&2; return 2; }
    echo "deploying to ${target}"
}

cmd_status()   { echo "status"; }
cmd_rollback() { echo "rollback"; }

main() {
    (( $# > 0 )) || { usage >&2; exit 2; }
    local subcommand=$1; shift

    case $subcommand in
        deploy|rollback|status) "cmd_${subcommand}" "$@" ;;
        -h|--help|help)         usage; exit 0 ;;
        *) echo "${SCRIPT_NAME}: unknown command '${subcommand}'" >&2; usage >&2; exit 2 ;;
    esac
}

main "$@"

Two things make this work:

  • OPTIND must be reset if you call getopts more than once in a run — local OPTIND=1 inside each cmd_* function does it, since local gives each invocation its own copy.
  • The case allow-list before dispatch. Calling "cmd_${subcommand}" directly on unvalidated input would let a user invoke any function in your script by name.

Topic 5: Interface Conventions Users Expect

ConventionWhy
-h/--help to stdout, exit 0So it can be piped
--versionScripts and humans both check it
Errors to stderrSo stdout stays parseable
Exit 0 success, 1 failure, 2 usageCallers branch on it
-- ends optionsThe only way to pass a file named -x
-n/--dry-run on anything destructiveUsers will trust the tool faster
Respect NO_COLORAn accessibility convention worth honouring
Detect a pipe with [[ -t 1 ]]Colour on a terminal, plain when piped
# Colour only when stdout is a terminal and NO_COLOR is unset
if [[ -t 1 && -z ${NO_COLOR:-} ]]; then
    readonly C_RED=$'\033[31m' C_RESET=$'\033[0m'
else
    readonly C_RED="" C_RESET=""
fi
echo "${C_RED}error${C_RESET}: something failed" >&2

[[ -t 1 ]] is true when fd 1 is a terminal. Without this check, escape codes end up in log files as ^[[31m, which is the reason so many CI logs are unreadable.

Making it installable:

chmod +x bin/deploy
sudo install -m 0755 bin/deploy /usr/local/bin/deploy

With no extension and a proper usage, it is now indistinguishable from any other tool on the box — which is the point.

Try it yourself: Add -h, --version, and [[ -t 1 ]] colour handling to a script you already use, then run ./script -h | cat and confirm the help still appears with no escape codes.

Common mistake: Omitting shift $(( OPTIND - 1 )). Everything appears to work while you test with flags only, then the first positional argument turns out to be -v and the loop tries to open it as a file.