Shell
Purpose
Write shell scripts that fail loudly instead of silently, handle paths with spaces, and clean up after themselves. Most shell bugs are one missing quote or one unchecked exit code.
When to Use
- Writing or reviewing Bash scripts.
- Building CI steps, deployment scripts, or developer tooling.
- Hardening a script that "works on my machine".
- Deciding whether a task has outgrown shell entirely.
Capabilities
- Strict mode and error handling that actually stops the script.
- Safe quoting, arrays, and
IFS handling.
- Argument parsing and usage output.
- Cleanup via
trap, temp file discipline, and idempotence.
- Portability between Bash, POSIX
sh, and macOS's older toolchain.
Inputs
- The script, its invocation context (CI, cron, interactive), and target shells.
- Whether it must run on macOS (BSD utilities) as well as Linux (GNU).
Outputs
- A script passing
shellcheck -x with no warnings.
- Deterministic exit codes and a usage message.
- Cleanup guaranteed on every exit path.
Workflow
- Start strict —
set -Eeuo pipefail and an IFS you control.
- Parse arguments explicitly — Validate required inputs and print usage on error.
- Quote everything — Every variable expansion is
"$var" unless you have a specific reason otherwise.
- Register cleanup —
trap before you create the first temporary resource.
- Gate — Run ShellCheck; run the script under
bash -n and, where practical, with set -x in a dry run.
Best Practices
set -e alone does not catch failures inside pipelines. pipefail is what makes a | b fail when a does.
- Unquoted
$var splits on whitespace and glob-expands. This is the root cause of most "it broke on a filename with a space" bugs.
- Use
mktemp -d and remove it in a trap — never hardcode /tmp/mything.
- Prefer
[[ ]] over [ ] in Bash; it does not word-split and supports pattern matching.
- Check that required commands exist up front rather than failing halfway through.
- When a script exceeds roughly 100 lines or needs data structures, rewrite it in Python. Shell is glue, not a language for logic.
Examples
Script skeleton:
#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'
readonly SCRIPT_NAME="${0##*/}"
usage() {
cat <<USAGE
Usage: ${SCRIPT_NAME} --source DIR --dest DIR [--dry-run]
--source DIR Directory to sync from (required)
--dest DIR Directory to sync to (required)
--dry-run Print actions without performing them
USAGE
}
die() { printf '%s: %s\n' "$SCRIPT_NAME" "$1" >&2; exit 1; }
main() {
local source="" dest="" dry_run=0
while (($# > 0)); do
case "$1" in
--source) source="${2:-}"; shift 2 ;;
--dest) dest="${2:-}"; shift 2 ;;
--dry-run) dry_run=1; shift ;;
-h|--help) usage; exit 0 ;;
*) usage >&2; die "unknown argument: $1" ;;
esac
done
[[ -n "$source" && -n "$dest" ]] || { usage >&2; die "missing required argument"; }
[[ -d "$source" ]] || die "source not a directory: $source"
command -v rsync >/dev/null || die "rsync is required but not installed"
local workdir
workdir="$(mktemp -d)"
trap 'rm -rf "$workdir"' EXIT
local -a flags=(--archive --delete)
((dry_run)) && flags+=(--dry-run)
rsync "${flags[@]}" "$source/" "$dest/"
}
main "$@"
Notes
set -E propagates the ERR trap into functions and subshells; without it, error traps silently do not fire where you expect.
- macOS ships BSD
sed, date, and readlink, which differ from GNU. If the script must run on both, either avoid them or depend on coreutils.
shellcheck -x follows sourced files; the plain invocation does not.
1---2name: shell3description: Use when writing Bash scripts or command-line automation. Covers strict mode, safe quoting, argument parsing, trap-based cleanup, portability, and ShellCheck-clean scripts.4---56# Shell78## Purpose910Write shell scripts that fail loudly instead of silently, handle paths with spaces, and clean up after themselves. Most shell bugs are one missing quote or one unchecked exit code.1112## When to Use1314- Writing or reviewing Bash scripts.15- Building CI steps, deployment scripts, or developer tooling.16- Hardening a script that "works on my machine".17- Deciding whether a task has outgrown shell entirely.1819## Capabilities2021- Strict mode and error handling that actually stops the script.22- Safe quoting, arrays, and `IFS` handling.23- Argument parsing and usage output.24- Cleanup via `trap`, temp file discipline, and idempotence.25- Portability between Bash, POSIX `sh`, and macOS's older toolchain.2627## Inputs2829- The script, its invocation context (CI, cron, interactive), and target shells.30- Whether it must run on macOS (BSD utilities) as well as Linux (GNU).3132## Outputs3334- A script passing `shellcheck -x` with no warnings.35- Deterministic exit codes and a usage message.36- Cleanup guaranteed on every exit path.3738## Workflow39401. **Start strict** — `set -Eeuo pipefail` and an `IFS` you control.412. **Parse arguments explicitly** — Validate required inputs and print usage on error.423. **Quote everything** — Every variable expansion is `"$var"` unless you have a specific reason otherwise.434. **Register cleanup** — `trap` before you create the first temporary resource.445. **Gate** — Run ShellCheck; run the script under `bash -n` and, where practical, with `set -x` in a dry run.4546## Best Practices4748- `set -e` alone does not catch failures inside pipelines. `pipefail` is what makes `a | b` fail when `a` does.49- Unquoted `$var` splits on whitespace and glob-expands. This is the root cause of most "it broke on a filename with a space" bugs.50- Use `mktemp -d` and remove it in a trap — never hardcode `/tmp/mything`.51- Prefer `[[ ]]` over `[ ]` in Bash; it does not word-split and supports pattern matching.52- Check that required commands exist up front rather than failing halfway through.53- When a script exceeds roughly 100 lines or needs data structures, rewrite it in Python. Shell is glue, not a language for logic.5455## Examples5657**Script skeleton:**5859```bash60#!/usr/bin/env bash61set -Eeuo pipefail62IFS=$'\n\t'6364readonly SCRIPT_NAME="${0##*/}"6566usage() {67 cat <<USAGE68Usage: ${SCRIPT_NAME} --source DIR --dest DIR [--dry-run]6970 --source DIR Directory to sync from (required)71 --dest DIR Directory to sync to (required)72 --dry-run Print actions without performing them73USAGE74}7576die() { printf '%s: %s\n' "$SCRIPT_NAME" "$1" >&2; exit 1; }7778main() {79 local source="" dest="" dry_run=08081 while (($# > 0)); do82 case "$1" in83 --source) source="${2:-}"; shift 2 ;;84 --dest) dest="${2:-}"; shift 2 ;;85 --dry-run) dry_run=1; shift ;;86 -h|--help) usage; exit 0 ;;87 *) usage >&2; die "unknown argument: $1" ;;88 esac89 done9091 [[ -n "$source" && -n "$dest" ]] || { usage >&2; die "missing required argument"; }92 [[ -d "$source" ]] || die "source not a directory: $source"93 command -v rsync >/dev/null || die "rsync is required but not installed"9495 local workdir96 workdir="$(mktemp -d)"97 trap 'rm -rf "$workdir"' EXIT9899 local -a flags=(--archive --delete)100 ((dry_run)) && flags+=(--dry-run)101102 rsync "${flags[@]}" "$source/" "$dest/"103}104105main "$@"106```107108## Notes109110- `set -E` propagates the `ERR` trap into functions and subshells; without it, error traps silently do not fire where you expect.111- macOS ships BSD `sed`, `date`, and `readlink`, which differ from GNU. If the script must run on both, either avoid them or depend on `coreutils`.112- `shellcheck -x` follows sourced files; the plain invocation does not.