Chapter 15Lesson 02~75 minutes

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.

CLI contractsgetoptsDry-run design

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 getopts builtin.
  • 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.

Command-line interface processing lifecycle
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=$1

If 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
done
Never reparse arguments with eval

Storing 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

Next lesson

Logging, Retries, Timeouts, and Idempotency

Make automation observable, bound external work, retry only transient failures, and converge systems toward desired state.

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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.