Skip to content

Script Structure & Argument Parsing

A well-structured script with proper argument parsing and a --help flag is the difference between a tool someone else can use and one that only you understand.

Learning Objectives

  • Write a complete, production-ready script header
  • Use set -euo pipefail and understand what each flag does
  • Parse arguments with getopts and manually with case
  • Write a usage() function and display it with --help
  • Handle required vs optional arguments correctly

The Standard Script Header

Every production script starts with this pattern:

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

# --- constants ---
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPT_NAME="$(basename "$0")"

# --- usage ---
usage() {
    cat << EOF
Usage: $SCRIPT_NAME [OPTIONS] <required_arg>

Description of what the script does.

Options:
  -h, --help        Show this help message
  -v, --verbose     Enable verbose output
  -o, --output DIR  Write output to DIR (default: ./output)

Examples:
  $SCRIPT_NAME input.txt
  $SCRIPT_NAME -v -o /tmp input.txt
EOF
}

# --- defaults ---
VERBOSE=false
OUTPUT_DIR="./output"

set -euo pipefail Explained

Flag Meaning Why it matters
-e Exit on error A failing command won't silently continue
-u Error on unset variables Typos in variable names become errors, not empty strings
-o pipefail Pipeline fails if any stage fails false | true returns non-zero, not zero
set -euo pipefail

# With -e: this exits the script instead of continuing
cp /nonexistent/file /tmp/  # script stops here

# With -u: this is an error instead of an empty string
echo "$TYPO_VAR"            # error: TYPO_VAR: unbound variable

# With pipefail: this returns a non-zero exit code
false | true
echo $?   # 1, not 0

-e can be surprising

Some commands legitimately return non-zero (e.g., grep returns 1 when no match is found). Guard these with || true or if: grep "pattern" file || true.


Argument Parsing with case

For simple scripts, manual case parsing is the clearest approach:

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

VERBOSE=false
OUTPUT=""

while [[ $# -gt 0 ]]; do
    case "$1" in
        -h|--help)
            usage
            exit 0
            ;;
        -v|--verbose)
            VERBOSE=true
            shift
            ;;
        -o|--output)
            OUTPUT="${2:?--output requires an argument}"
            shift 2
            ;;
        --)
            shift
            break
            ;;
        -*)
            echo "Unknown option: $1" >&2
            usage >&2
            exit 1
            ;;
        *)
            break
            ;;
    esac
done

# Remaining args are positional
if [[ $# -eq 0 ]]; then
    echo "Error: required argument missing" >&2
    usage >&2
    exit 1
fi

INPUT="$1"

getopts — POSIX Argument Parsing

getopts handles short options only (single letters):

while getopts ":hvo:" opt; do
    case "$opt" in
        h) usage; exit 0 ;;
        v) VERBOSE=true ;;
        o) OUTPUT="$OPTARG" ;;
        :) echo "Option -$OPTARG requires an argument" >&2; exit 1 ;;
        ?) echo "Unknown option: -$OPTARG" >&2; exit 1 ;;
    esac
done
shift $(( OPTIND - 1 ))

getopts vs manual case parsing

Use getopts for POSIX portability and when only short options are needed. Use manual case parsing when you need long options (--verbose, --output) without adding external dependencies.


The Complete Script Template

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

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPT_NAME="$(basename "$0")"
VERBOSE=false

usage() {
    cat << EOF
Usage: $SCRIPT_NAME [-h] [-v] <input>
EOF
}

log()  { "$VERBOSE" && echo "[$SCRIPT_NAME] $*" || true; }
die()  { echo "ERROR: $*" >&2; exit 1; }
warn() { echo "WARN: $*" >&2; }

parse_args() {
    while [[ $# -gt 0 ]]; do
        case "$1" in
            -h|--help)    usage; exit 0 ;;
            -v|--verbose) VERBOSE=true; shift ;;
            -*)           die "Unknown option: $1" ;;
            *)            break ;;
        esac
    done
    [[ $# -ge 1 ]] || die "Missing required argument. Run with --help."
    INPUT="$1"
}

main() {
    parse_args "$@"
    log "Processing: $INPUT"
    echo "Done."
}

main "$@"

Practice Exercises

Main (write a short script)

Write a script ~/scripts/template.sh that uses the full template above and accepts: - -v / --verbose flag - -o DIR / --output DIR option (defaults to /tmp) - One required positional argument (a filename to process)

The script should print the filename's line count when run normally, and additional details when verbose.

Stretch

  1. Add a --dry-run flag to your script that prints what would be done without doing it.
  2. Research BASH_SOURCE[0] vs $0. Why is BASH_SOURCE[0] preferred for finding the script's own directory?

Interview Questions

  1. What does set -euo pipefail do?
Show answer

-e exits immediately when any command returns non-zero. -u treats unset variables as errors. -o pipefail makes a pipeline fail if any command in it fails (not just the last). Together they catch most silent failure modes in bash scripts.

  1. Why use #!/usr/bin/env bash instead of #!/bin/bash?
Show answer

#!/bin/bash hardcodes the path to bash. On some systems (macOS with Homebrew, NixOS, some containers), bash is in a different location. #!/usr/bin/env bash searches $PATH for bash, making the script more portable across different environments.

  1. How do you handle grep returning exit code 1 (no matches) when set -e is active?
Show answer

Use grep "pattern" file || true to prevent the non-zero exit from stopping the script. Or use if grep "pattern" file; then ...; fi — the if construct does not trigger -e on failure. Or temporarily disable with set +e; grep ...; set -e.


day01-part1-functions | day02-part1-error-handling