Shell Scripting & Automation

Arguments, Options & Interactive Input

18 min Lesson 9 of 28

Arguments, Options & Interactive Input

A script that hard-codes its configuration is a script you cannot reuse. Every professional-grade shell tool — from kubectl to aws to your own deployment automation — accepts arguments that change its behavior at call time. In this lesson you will learn the full interface-design toolkit for Bash scripts: positional parameters, the getopts built-in for POSIX-style flags, the canonical usage-function pattern, and the read built-in for prompting a human operator. These four building blocks are enough to build CLIs indistinguishable from standard Unix tools.

Positional Parameters

When Bash invokes a script, every word after the script name lands in a numbered variable. $1 is the first argument, $2 the second, and so on. $0 is the script's own name. $# is the count of arguments. "$@" expands to all arguments as separate quoted words — always prefer this over $* when forwarding arguments, because it preserves whitespace inside individual values.

#!/usr/bin/env bash set -euo pipefail # $1 = environment (required) $2 = image tag (optional) ENVIRONMENT="${1:-}" IMAGE_TAG="${2:-latest}" # Fail fast with a clear message instead of a cryptic error later if [[ -z "$ENVIRONMENT" ]]; then echo "ERROR: environment argument is required" >&2 exit 1 fi echo "Deploying image ${IMAGE_TAG} to ${ENVIRONMENT}" # Shift removes $1 and renumbers the rest; useful after consuming known args shift echo "Remaining args after shift: $*" # Iterating over all arguments safely for arg in "$@"; do echo " arg: ${arg}" done

The ${1:-} form gives you an empty string when the argument is absent, so your -z check fires cleanly. The harder-failing form ${1:?message} makes Bash itself print the message and exit immediately — useful inside functions but can produce confusing output at the top level of a script.

Key idea — always validate before use: Never assume the caller passed the right number of arguments. Check early with a guard clause or a usage function, and exit with a non-zero code (typically 1 or 2) and a message to stderr. This is what every well-written Unix tool does, and it is what operators expect when they pipe your script into larger workflows.

The Usage Function Pattern

Every script that accepts arguments needs a usage function. It documents the interface, is printable on -h/--help, and is called automatically when validation fails. This is the pattern used by HashiCorp, GitHub Actions runner, and most open-source infrastructure tooling:

#!/usr/bin/env bash set -euo pipefail readonly SCRIPT_NAME="$(basename "$0")" usage() { cat <<EOF Usage: ${SCRIPT_NAME} [OPTIONS] <environment> Deploy the application to the specified environment. Arguments: environment Target environment: dev | staging | prod Options: -t, --tag TAG Docker image tag to deploy (default: latest) -n, --dry-run Print actions without executing them -v, --verbose Enable verbose output -h, --help Show this help message and exit Examples: ${SCRIPT_NAME} staging ${SCRIPT_NAME} -t v2.3.1 prod ${SCRIPT_NAME} --dry-run prod EOF } # Guard: print usage and exit 0 when -h/--help is the first arg if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then usage exit 0 fi

Notice cat <<EOF — a regular heredoc (not NOWDOC) so that ${SCRIPT_NAME} expands correctly inside the usage text. The usage function is the first thing a new operator reads; make it complete and accurate.

Parsing Flags with getopts

getopts is the POSIX-standard Bash built-in for parsing short options (-v, -t TAG). It handles option bundling (-vn), required arguments, and the -- end-of-options sentinel. It does not handle long options (--verbose) natively; for those, use a manual while/case loop or getopt (the external command with two t's). In practice, most production scripts at large companies use the manual pattern because it handles both short and long forms cleanly.

#!/usr/bin/env bash set -euo pipefail # --- defaults --- IMAGE_TAG="latest" DRY_RUN=false VERBOSE=false # --- getopts: short options only --- # A colon after a letter means that option requires an argument. # A leading colon enables silent error handling (no built-in error messages). while getopts ":t:nvh" opt; do case "$opt" in t) IMAGE_TAG="$OPTARG" ;; n) DRY_RUN=true ;; v) VERBOSE=true ;; h) usage; exit 0 ;; :) echo "ERROR: option -${OPTARG} requires an argument" >&2; exit 2 ;; \?) echo "ERROR: unknown option -${OPTARG}" >&2; exit 2 ;; esac done # After getopts, shift processed options away so $1 is now the first positional shift $(( OPTIND - 1 )) ENVIRONMENT="${1:?$(usage; echo 'ERROR: environment is required')}" echo "Tag=${IMAGE_TAG} DryRun=${DRY_RUN} Verbose=${VERBOSE} Env=${ENVIRONMENT}"
getopts parsing flow Script argv -t v2.3 -n prod while getopts reads one flag per iteration sets $opt + $OPTARG case -t IMAGE_TAG=$OPTARG case -n DRY_RUN=true case \? / : error + exit 2 shift $((OPTIND-1)) $1 = "prod" (positional) next flag
How getopts processes flags one at a time, dispatching to a case branch, then leaves positional arguments for use after the shift.

Handling Long Options with a while/case Loop

For scripts that will be used frequently by humans, long options (--dry-run, --tag) dramatically improve readability. The standard idiom is a manual while true; do case "$1" in ... esac; done loop:

#!/usr/bin/env bash set -euo pipefail IMAGE_TAG="latest" DRY_RUN=false VERBOSE=false while [[ $# -gt 0 ]]; do case "$1" in -t|--tag) IMAGE_TAG="${2:?--tag requires an argument}" shift 2 ;; -n|--dry-run) DRY_RUN=true shift ;; -v|--verbose) VERBOSE=true shift ;; -h|--help) usage exit 0 ;; --) shift # explicit end-of-options sentinel break ;; -*) echo "ERROR: unknown option: $1" >&2 usage exit 2 ;; *) break # first non-option argument; stop parsing flags ;; esac done ENVIRONMENT="${1:?ERROR: environment argument is required}" shift || true echo "Deploying ${IMAGE_TAG} to ${ENVIRONMENT} | dry-run=${DRY_RUN} verbose=${VERBOSE}"
Pro practice — exit code 2 for usage errors: POSIX convention is exit code 1 for runtime errors and exit code 2 for "incorrect usage" (wrong arguments, unknown flag). Many CI systems and shell scripts test $? and can distinguish the two. Always exit with 2 when the caller passed bad arguments, and print the usage or a pointer to --help.

Interactive Input with read

Automated pipelines should never require interactive input — but scripts run by a human operator (provisioning, database migrations, secret rotation) sometimes need a confirmation prompt or a value the script cannot derive itself. The read built-in handles this cleanly.

#!/usr/bin/env bash set -euo pipefail # Basic prompt — stores the answer in REPLY by default read -r -p "Enter target environment [dev/staging/prod]: " ENVIRONMENT # Read with a default value — press Enter to accept read -r -p "Image tag [latest]: " IMAGE_TAG IMAGE_TAG="${IMAGE_TAG:-latest}" # Read a password without echoing characters to the terminal read -r -s -p "Database password: " DB_PASSWORD echo "" # newline after hidden input # Read with a timeout — returns non-zero if time expires if ! read -r -t 30 -p "Continue with deployment? [y/N]: " CONFIRM; then echo "" echo "Timed out. Aborting." >&2 exit 1 fi # Guard confirmation with a regex match if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then echo "Deployment cancelled by operator." exit 0 fi echo "Proceeding: env=${ENVIRONMENT} tag=${IMAGE_TAG}"

Key read flags to know: -r disables backslash interpretation (always use it), -p prints a prompt, -s suppresses echoing (passwords), -t N sets a timeout in seconds, and -a reads whitespace-separated words into an array.

Production pitfall — interactive input in CI: When a script containing read runs inside a CI pipeline (GitHub Actions, Jenkins, GitLab CI), stdin is not a terminal and read immediately returns an empty string or times out. Always check [[ -t 0 ]] (is stdin a terminal?) before prompting, or provide a --yes / -y flag that skips all prompts for non-interactive use. Mixing interactive and non-interactive paths in the same script is the standard pattern used by tools like helm upgrade and kubectl apply.

Putting It All Together: A Production-Ready Script Skeleton

Combining everything from this lesson produces the skeleton used by real infrastructure scripts at big-tech companies. The pattern is: usage function, argument defaults, flag-parsing loop, positional validation, optional confirmation prompt, then the actual work:

#!/usr/bin/env bash # rollback.sh — Roll back a service to a previous image tag # Usage: ./rollback.sh [OPTIONS] <service> <tag> set -euo pipefail readonly SCRIPT_NAME="$(basename "$0")" usage() { cat <<EOF Usage: ${SCRIPT_NAME} [OPTIONS] <service> <tag> Roll back <service> to Docker image <tag> in the current cluster. Options: -n, --namespace NS Kubernetes namespace (default: default) -y, --yes Skip confirmation prompt (for CI/automation) -v, --verbose Enable verbose kubectl output -h, --help Show this help Examples: ${SCRIPT_NAME} api-server v1.9.2 ${SCRIPT_NAME} --namespace payments --yes api-server v1.9.2 EOF } # --- defaults ------------------------------------------------- NAMESPACE="default" YES=false VERBOSE=false # --- flag parsing --------------------------------------------- while [[ $# -gt 0 ]]; do case "$1" in -n|--namespace) NAMESPACE="${2:?--namespace requires a value}"; shift 2 ;; -y|--yes) YES=true; shift ;; -v|--verbose) VERBOSE=true; shift ;; -h|--help) usage; exit 0 ;; --) shift; break ;; -*) echo "ERROR: unknown option $1" >&2; usage; exit 2 ;; *) break ;; esac done # --- positional validation ------------------------------------ SERVICE="${1:?$(usage; echo 'ERROR: service argument is required')}" TAG="${2:?$(usage; echo 'ERROR: tag argument is required')}" # --- confirmation gate (skip in CI) -------------------------- if [[ "$YES" == false ]]; then read -r -p "Roll back ${SERVICE} to ${TAG} in namespace ${NAMESPACE}? [y/N]: " CONFIRM [[ "$CONFIRM" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; } fi # --- actual work ---------------------------------------------- KUBECTL_FLAGS=() [[ "$VERBOSE" == true ]] && KUBECTL_FLAGS+=(-v=6) echo "Rolling back ${SERVICE} to image tag ${TAG} ..." kubectl set image deployment/"${SERVICE}" \ "${SERVICE}=${SERVICE}:${TAG}" \ --namespace "${NAMESPACE}" \ "${KUBECTL_FLAGS[@]}" echo "Done. Monitor rollout with:" echo " kubectl rollout status deployment/${SERVICE} -n ${NAMESPACE}"

This skeleton — usage function, defaults, flag loop, positional guard, CI-aware confirmation, then work — is the template your team should copy for every new infrastructure script. It behaves correctly whether called by a human, a CI pipeline, or another script.