Writes, debugs, and hardens Bash shell scripts — quoting, arrays, strict mode, traps, argument parsing, and macOS/Linux portability. Use when writing or reviewing any script, one-liner, cron job, deploy script, container entrypoint, or CI step; when a script breaks on spaces in filenames, exits silently, ignores set -e, hangs, or returns the wrong exit code; when quoting, IFS, globs, arrays, heredocs, process substitution, trap, getopts, or mapfile misbehave; when shellcheck flags SC2086 and friends; when unbound variable, bad substitution, command not found, ambiguous redirect, or unexpected end of file show up; when a script works by hand but fails under cron, systemd, sudo, or a CI runner; or when porting between macOS bash 3.2, GNU/Linux, WSL, and POSIX sh. Not for interactive zsh or fish configuration, not for PowerShell, and not for host-level cron, systemd, or permission failures (linux).
---
name: Bash
slug: bash
version: 1.0.6
description: >-
Writes, debugs, and hardens Bash shell scripts — quoting, arrays, strict mode,
traps, argument parsing, and macOS/Linux portability. Use when writing or
reviewing any script, one-liner, cron job, deploy script, container entrypoint,
or CI step; when a script breaks on spaces in filenames, exits silently,
ignores set -e, hangs, or returns the wrong exit code; when quoting, IFS,
globs, arrays, heredocs, process substitution, trap, getopts, or mapfile
misbehave; when shellcheck flags SC2086 and friends; when unbound variable,
bad substitution, command not found, ambiguous redirect, or unexpected end of
file show up; when a script works by hand but fails under cron, systemd, sudo,
or a CI runner; or when porting between macOS bash 3.2, GNU/Linux, WSL, and
POSIX sh. Not for interactive zsh or fish configuration, not for PowerShell, and
not for host-level cron, systemd, or permission failures (linux).
homepage: https://clawic.com/skills/bash
changelog: "Display name shown correctly"
metadata:
clawdbot:
emoji: 🖥️
requires:
bins:
- bash
os:
- linux
- darwin
displayName: Bash
configPaths:
- ~/Clawic/data/bash/
---
User preferences live in `~/Clawic/data/bash/config.yaml` (see Configuration); nothing else is stored on the user's machine. If you have data at an old location (`~/bash/` or `~/clawic/bash/`), move it to `~/Clawic/data/bash/`.
## When To Use
- Writing or reviewing any Bash beyond a one-liner: CI steps, deploy scripts, cron tasks, entrypoints, glue code
- Debugging scripts that break on spaces in filenames, fail silently, hang, or exit with the wrong code
- Hardening an existing script: strict mode, cleanup traps, argument parsing, re-runnability, portability
- Porting a script between macOS and Linux, or down to POSIX sh
- Deciding whether the task belongs in Bash at all (Core Rule 9)
- Not for POSIX-sh-only targets (dash, busybox, alpine `/bin/sh`) — most patterns here are bashisms; `portability.md` covers the downgrade
## Quick Reference
| Situation | Play |
|-----------|------|
| Breaks on spaces or hostile filenames | Quote every expansion, iterate with `find -print0` + `while IFS= read -r -d ''` → `quoting.md` |
| `set -e` missed a failure, cleanup never ran | The five blind spots (conditions, `\|\|`/`&&`, `$( )`, `! cmd`, `exit` in a subshell) → `errors.md` |
| Pipeline "fails" but each command worked | Exit code 141 = SIGPIPE from an early-exit consumer; `PIPESTATUS` names the segment → `errors.md` |
| Wrong output and you cannot see why | `PS4='+ ${BASH_SOURCE##*/}:${LINENO}: ' bash -x script` → `debugging.md` |
| Command built from variables misfires | Build it as an array (`cmd=(rsync -a); cmd+=(--dry-run); "${cmd[@]}"`), never as a string → `quoting.md` |
| Comparison wrong: `[` vs `[[`, numeric vs lexical | `[[ 10 < 9 ]]` is TRUE (lexical); numbers belong in `(( ))` or `-lt` → `conditionals.md` |
| Runs fine by hand, fails from cron | Cron has no login shell: minimal PATH, no profile, `$HOME` as cwd, `%` means newline → `cron.md` |
| Works locally, fails in the CI runner | Each step is a fresh non-interactive shell; strict mode does not carry over → `ci.md` |
| Script takes minutes on a large file | Count forks: one external command per line is the cost — batch into awk/sort → `performance.md` |
| `unbound variable` / `bad substitution` / `ambiguous redirect` | Symptom→cause chains → `debugging.md` |
| Must run on macOS stock bash or an old server | Version Floors below, then GNU-vs-BSD flags → `portability.md` |
| Flags, `--help`, subcommands, usage exit codes | `getopts` with a silent optstring, then `shift $((OPTIND-1))` → `arguments.md` |
| Redirection order, heredocs, one-instance locking | Redirections apply left to right before the command runs → `redirection.md` |
| Paths, globs, temp files, deletes that must be safe | Resolve once with `cd … && pwd -P`; write temp + `mv` → `files.md` |
| Parsing CSV/JSON/logs, choosing awk vs sed vs jq | Per-line and stateless → one `awk` pass; never grep JSON → `text-processing.md` |
| Background jobs, signals, timeouts, N in parallel | `pid=$!` then `wait "$pid"`; `xargs -P` for fan-out → `processes.md` |
| String surgery: defaults, trim, replace, basename | Builtin expansions, no forks → `expansion.md` |
| Lists, dictionaries, sets, counters | `mapfile -t` to load, `declare -A` for maps → `arrays.md` |
| Splitting into functions or a sourced library | `main "$@"` behind a `BASH_SOURCE` guard; scope is dynamic → `functions.md` |
| Prompts, confirmations, color, progress | Gate every one of them on `[[ -t 1 ]]` → `interactive.md` |
| Calling an API, webhook, or health check | curl exits 0 on a 500 — capture `%{http_code}` and branch → `http.md` |
| Untrusted input, secrets, temp-file races, sudo | Keep values as data, never as syntax → `security.md` |
| Adding tests, stubbing commands, lint in CI | `bash -n`, shellcheck, then bats with PATH stubs → `testing.md` |
| Anything else | Core Rules below, then reproduce with `bash -x` on the smallest input that still fails |
Each file above is one sub-job and is self-contained: read SKILL.md by default, open exactly one guide when the situation matches.
## Core Rules
1. Open every script with `#!/usr/bin/env bash` and `set -euo pipefail`, then learn the `-e` holes (`errors.md`) instead of dropping strict mode — the holes are enumerable; silent failures are not.
2. Quote every expansion: `"$var"`, `"$(cmd)"`, `"${arr[@]}"`. An unquoted expansion is a deliberate act that carries a comment saying why. Word splitting plus globbing is Bash's #1 bug class (shellcheck SC2086).
3. Build commands as arrays, never as strings. `opts="--exclude '*.log'"; rsync $opts src dst` passes the quotes as literal characters; `opts=(--exclude '*.log'); rsync "${opts[@]}" src dst` passes `--exclude` and `*.log` as two clean arguments. Conditional flags append: `[[ $dry == 1 ]] && opts+=(--dry-run)`.
4. Run shellcheck before shipping, blocking at `lint_gate` severity. Suppress only with the code and a reason on the same line: `# shellcheck disable=SC2086 -- flags must split`.
5. Know your floor: macOS `/bin/bash` is 3.2 forever (GPLv3 freeze). If the script uses any `bash >=4.0` feature (Version Floors), state the floor in a header comment and enforce it: `((BASH_VERSINFO[0] >= 4)) || { echo "needs bash 4+" >&2; exit 1; }`.
6. Never parse `ls`. Iterate with globs or `find -print0`: filenames may contain newlines, so NUL is the only delimiter a filename cannot contain.
7. Test the failure path before delivering: swap one command for `false`, confirm the script stops, the trap fires, and the exit code is nonzero. A cleanup you never saw run is a cleanup you do not have.
8. Untrusted input never reaches `eval`, arithmetic, or array subscripts: `(( $userinput ))` executes commands via `arr[$(cmd)]` subscripts. Gate with a regex first: `[[ $n =~ ^[0-9]+$ ]] || die "not a number: $n"`.
9. Past `rewrite_threshold` lines (default 100, the Google Shell Style Guide cutoff) or once you need nested data structures, rewrite in Python or similar. Bash orchestrates processes; it does not model data.
## Script Skeleton
```bash
#!/usr/bin/env bash
# Requires bash >= 4.4 (inherit_errexit). Run: script.sh [-n] <target>
set -euo pipefail
shopt -s inherit_errexit 2>/dev/null || true # bash >=4.4: $(cmd) failures propagate
die() { printf '%s\n' "$*" >&2; exit 1; }
tmp=$(mktemp) || die "mktemp failed"
trap 'rm -f "$tmp"' EXIT # single-quoted: expands when it FIRES, not now
# fires on error and normal exit; kill -9 bypasses all traps
```
## Quoting
- `"$var"`, `"$(cmd)"`, `"${arr[@]}"` — always. `$(cmd)` strips ALL trailing newlines, not just one.
- Single quotes are literal; `$'...'` interprets escapes: `$'\t'`, `$'\r'`, `$'\0'`.
- Filenames from variables get `--` or `./`: `rm -- "$f"` survives a file named `-rf`.
- `echo "$var"` breaks when var is `-n`, `-e`, or has backslashes — `printf '%s\n' "$var"` never does.
- Arguments to `ssh`/`su -c`/`bash -c` are re-parsed by the receiving shell — build them with `printf '%q '` (`quoting.md`).
- `${arr[*]}` joins with the first char of IFS into one word; `"${arr[@]}"` preserves elements. Joining is the only reason to write `[*]`.
## Version Floors
| Feature | Needs |
|---------|-------|
| `printf -v var`, `+=` append | bash >=3.1 |
| `declare -A`, `mapfile`, `${var^^}`/`${var,,}`, `globstar`, `;&` fallthrough, `\|&` | bash >=4.0 |
| `[[ -v var ]]`, `shopt -s lastpipe`, `declare -g` | bash >=4.2 |
| `${arr[-1]}`, `declare -n` namerefs, `wait -n` | bash >=4.3 |
| `inherit_errexit`, `${var@Q}`, `mapfile -d`, empty `"${arr[@]}"` safe under `set -u` | bash >=4.4 |
| `EPOCHSECONDS`/`EPOCHREALTIME`, `SRANDOM` (5.1) | bash >=5.0 |
macOS `/bin/bash` stays at 3.2. `#!/usr/bin/env bash` finds a Homebrew bash on PATH; `#!/bin/bash` never will. Check at runtime with `BASH_VERSINFO`, not by parsing `bash --version`.
## Exit Codes
Formula: a code above 128 means killed by signal `code − 128`. Codes are mod 256 — `exit 256` reports 0, `exit -1` reports 255.
| Code | Meaning | First move |
|------|---------|-----------|
| 1 | Generic failure — also `(( expr ))` evaluating to 0 | Read the last command, then the `((` traps below |
| 2 | Shell syntax or builtin usage error | `bash -n script` locates it; conventionally also "wrong CLI usage" (`arguments.md`) |
| 126 | Found but not executable | `chmod +x`, or the shebang interpreter is not executable |
| 127 | Command not found | PATH (the cron classic), typo, or a missing shebang interpreter ("bad interpreter") |
| 130 | SIGINT (128+2) | User pressed Ctrl-C — propagate it, do not swallow it |
| 137 | SIGKILL (128+9) | OOM killer or `kill -9`; no trap ever ran, so cleanup did not happen |
| 141 | SIGPIPE (128+13) | A consumer (`head`, `grep -q`) closed the pipe early — usually success misread as failure |
| 143 | SIGTERM (128+15) | Orderly external stop (systemd, CI timeout) — trap it to clean up |
| 124 | GNU `timeout` expired (125 = timeout itself failed) | Raise the timeout or fix the hang (`processes.md`) |
| 255 | `ssh` transport error, and any `exit` with a negative or >255 value wrapped | Distinguish ssh's own failure from the remote command's |
## Subshells and State
- Every pipe segment runs in a subshell: `cmd | while read -r x; do ((n++)); done` loses `n`. Fix: `done < <(cmd)`, or `shopt -s lastpipe` (bash >=4.2, scripts only).
- `( )` is a subshell, `{ ...; }` is the current shell — `exit` inside `( )` or `$( )` exits only that subshell.
- Background jobs: `cmd & pid=$!` then `wait "$pid"` — `wait` returns the job's exit code, your only way to check it.
- `cd` inside `( )` to visit a directory without having to `cd` back.
## Robust Iteration
- Globs: `shopt -s nullglob` first — otherwise `for f in *.txt` in an empty dir runs once with the literal string `*.txt`.
- Hostile filenames or recursion: `while IFS= read -r -d '' f; do ...; done < <(find . -name '*.log' -print0)`.
- Lines of a file: `while IFS= read -r line; do ...; done < file` — `IFS=` keeps leading whitespace, `-r` keeps backslashes. A final line without a trailing newline is still skipped: append `|| [[ -n $line ]]` to the read.
- Any command inside the loop that reads stdin (`ssh`, `ffmpeg`, `mysql`) eats the rest of the input and the loop ends after one pass — pass `ssh -n` or redirect `< /dev/null`.
- Split a string: `IFS=, read -ra fields <<< "$csv"`. Join: `(IFS=,; echo "${arr[*]}")` — the subshell keeps the IFS change local.
## Output Gates
Before delivering any script, check:
- Every expansion quoted, or the unquoted one carries a comment saying why
- Commands with variable flags built as arrays, not concatenated strings
- shellcheck clean at `lint_gate`, or each disable names its SC code and reason
- Failure path exercised: injected `false`, watched the trap fire and the exit code go nonzero
- Bash floor stated in a header comment and matching `bash_floor` if any `bash >=4.0` feature is used
- No `eval`; no unvalidated input inside `(( ))` or array subscripts
- Re-runnable: a run that dies halfway leaves nothing half-written — temp file plus `mv`, `mkdir -p`, `rm -f`
- Destructive steps gated per `destructive_confirm`; no secret can appear in `set -x` output or `ps`
## Configuration
User-dependent variables. Defaults apply until the user states a preference; store them in `~/Clawic/data/bash/config.yaml`. Never interview the user — record a preference the moment it is stated.
| Variable | Type | Default | Effect |
|---|---|---|---|
| bash_floor | 3.2 \| 4.4 \| 5.x | 4.4 | Gates which Version Floors features may be used unguarded; `3.2` bans `mapfile`, `declare -A`, namerefs and emits the portable fallbacks instead |
| target_os | linux \| macos \| both | both | Picks GNU or BSD flag forms in every emitted command (`sed -i`, `date`, `stat`, `readlink`); `both` restricts to the intersection (`portability.md`) |
| strict_mode | set-euo \| explicit-checks | set-euo | Chooses the Script Skeleton and which school reviews enforce (Where Experts Disagree) |
| lint_gate | error \| warning \| style \| none | warning | Severity at or above which shellcheck findings block delivery (`shellcheck -S <value>`); Output Gates use it |
| rewrite_threshold | number (lines) | 100 | Length at which Core Rule 9 recommends another language |
| indent_style | 2-spaces \| 4-spaces \| tabs | 2-spaces | Formatting of emitted scripts and the `shfmt -i` value |
| destructive_confirm | bool | true | Emitted scripts guard deletes, overwrites, and remote pushes behind `--yes` or a dry-run pass |
Preference areas — customizable dimensions; a stated preference gets recorded in config.yaml and applied:
- **Tooling**: shellcheck/shfmt/bats availability, GNU coreutils on macOS (`gsed`, `gdate`), jq vs python for JSON — affects `testing.md` gates and every parsing example
- **Conventions**: function and variable naming, usage/help layout, log line format, script header content — affects `functions.md` and `arguments.md` output
- **Platform**: bash floor and OS mix, POSIX-sh-only targets, alpine images where `/bin/sh` is ash and bash may be absent — affects `portability.md` guidance
- **Safety posture**: whether scripts may `sudo`, dry-run first, banned constructs (`eval`, `curl | sh`, `rm -rf` on a variable) — affects `security.md` and destructive workflows
- **Runtime home**: where the script actually runs unattended — cron, systemd timer, launchd, CI runner, container entrypoint — affects `cron.md` and `ci.md` advice
- **Output**: verbosity, color only when the output is a TTY, timestamps, quiet or machine-readable logging — affects `interactive.md` and logging helpers
## Traps
| Trap | Why it fails | Do instead |
|------|-------------|------------|
| `local out=$(cmd)` | `local` returns 0, masking cmd's failure — `set -e` never fires | `local out; out=$(cmd)` |
| `((count++))` when count is 0 | expression evaluates to 0 → exit status 1 → `set -e` kills the script | `count=$((count+1))` |
| `grep -q` downstream under pipefail | early exit sends SIGPIPE upstream; producer dies with 141 (128+13) and the pipeline "fails" on success | capture first: `out=$(cmd)`, then grep the variable |
| `rm -rf "$dir/"` | empty/unset `dir` → `rm -rf /` | `rm -rf "${dir:?}/"` aborts if empty |
| `trap "rm -rf $tmp" EXIT` (double quotes) | the body expands NOW, when `tmp` may still be empty — you registered `rm -rf` | single quotes: `trap 'rm -rf "$tmp"' EXIT` |
| Checking `$?` after a log line | the `echo` overwrote it | `rc=$?` on the very next line |
| `which cmd` to test existence | external, output format varies (SC2230) | `command -v cmd >/dev/null` |
| `cd "$dir"` without a check | without `-e`, everything after runs in the wrong directory | `cd "$dir" \|\| exit 1` — habit survives scripts that lack `-e` |
| `sudo cmd > /root/out` | the redirection is performed by YOUR shell before sudo runs — permission denied | `cmd \| sudo tee /root/out >/dev/null` |
| `set -euo pipefail` in a sourced library | it mutates the caller's shell and breaks their error handling | set options in executables only; libraries return codes (`functions.md`) |
## Where Experts Disagree
- `set -e`: the strict-mode school makes it mandatory; the Google Shell Style Guide school argues its exceptions (conditions, `||`, command substitution) make it false comfort and prefers explicit `|| die`. Boundary: short glue scripts → strict mode; sourced libraries and functions whose return codes callers inspect → explicit handling. Never mix philosophies in one file.
- Bash vs POSIX sh: write sh only when the target set actually contains dash/busybox/alpine. "Portable by default" costs arrays, `[[ ]]`, and `set -o pipefail` for hosts you may never meet.
- Returning values from functions: print to stdout and capture (composable, costs a fork per call) vs write through a nameref or a documented global (no fork, couples caller and callee). Boundary: hot loops and large payloads → nameref (`bash >=4.3`); everything else → stdout.
- Long options: GNU `getopt(1)` parses them but does not exist usably on macOS (BSD getopt has no long options); a hand-rolled `while`/`case` loop is portable and you own the error messages. Boundary: Linux-only tooling → `getopt`; anything shipped to laptops → hand-rolled (`arguments.md`).
## Related Skills
More Clawic skills, get them at https://clawic.com/skills/bash (install if the user confirms):
- `linux` — when the bug is the system, not the script: permissions, cron daemon config, systemd units, OOM
- `regex` — when the `=~` pattern itself is the hard part
- `github-actions` — when the script lives in CI and the failure is workflow wiring, not shell
- `cli-design` — when the question is the tool's interface: subcommand layout, flag naming, help text contract
Part of [Clawic](https://clawic.com), the verified skill library. Get this skill: https://clawic.com/skills/bash.
don't have the plugin yet? install it then click "run inline in claude" again.
you write, debug, and harden bash shell scripts that need to work reliably across macOS, linux, and CI environments. use this skill when writing any non-trivial script (deploy, cron, entrypoint, glue code), when a script breaks on spaces in filenames or fails silently under set -e, when quoting or argument parsing goes wrong, or when porting between bash 3.2 (macOS stock), bash 4+, and POSIX sh. the skill teaches strict mode, safe quoting, traps, cleanup, array handling, and portability floors so you ship scripts that fail audibly, handle edge cases, and work everywhere you ship them.
runtime environment:
/bin/bash frozen at GPLv2), 4.4+ (typical linux), or 5.x (recent distributions). your script declares its floor via a header comment; runtime check gates features: ((BASH_VERSINFO[0] >= 4)) || { echo "needs bash 4+" >&2; exit 1; }./bin/bash 3.2), linux (GNU tooling, bash 4+), or both (intersect flags, avoid GNU-only forms). affects sed -i, date formats, stat, readlink, grep, awk invocations.% means newline), systemd timer, CI runner (fresh non-interactive shell per step, no inherited environment), container entrypoint, or sudo subprocess.external tools and connections:
shellcheck: static linter; runs locally. set lint gate to error, warning, style, or none (default warning). suppress findings only with the SC code and a reason: # shellcheck disable=SC2086 -- flags must split.shfmt: optional code formatter (if available on PATH). respects indent style (2-space, 4-space, tab).find, grep, awk, sed, sort, xargs, mktemp, read, trap, wait, printf, test. these are portable; GNU versions (gsed, gdate) available on macOS via Homebrew if your script requires them.%{http_code}.configuration file:
user preferences stored in ~/Clawic/data/bash/config.yaml (migrate any old ~/bash/ or ~/clawic/bash/ data here first). configuration keys: bash_floor (3.2, 4.4, 5.x; default 4.4), target_os (linux, macos, both; default both), strict_mode (set-euo or explicit-checks; default set-euo), lint_gate (error, warning, style, none; default warning), rewrite_threshold (default 100 lines), indent_style (2-spaces, 4-spaces, tabs; default 2-spaces), destructive_confirm (bool; default true).
context needed from you:
/bin/sh).for writing a new script:
start with the script skeleton: shebang #!/usr/bin/env bash, then set -euo pipefail, then optional shopt -s inherit_errexit (bash >=4.4 only). add a header comment stating the bash floor and the script's invocation (e.g., # Requires bash >= 4.4. Run: deploy.sh [-n] <env>).
define a die() function at the top that writes to stderr and exits 1: die() { printf '%s\n' "$*" >&2; exit 1; }. use it for all fatal errors instead of allowing silent or weird exit codes.
for cleanup (temp files, locks, background processes): immediately after variable setup, create a tmp=$(mktemp) || die "mktemp failed" and register a trap: trap 'rm -f "$tmp"' EXIT. use single quotes so the variable expands when the trap fires, not when you register it. if multiple cleanups needed, register one trap and call a cleanup function: trap cleanup EXIT; cleanup() { rm -f "$tmp"; [[ -n ${bg_pid:-} ]] && kill "$bg_pid" 2>/dev/null || true; }.
quote every variable expansion: "$var", "$(cmd)", "${arr[@]}". an unquoted expansion must have a comment explaining why (e.g., # intentional: flags must split). this blocks word splitting and globbing from turning spaces and wildcards into extra arguments.
for conditional flags or commands: build as an array, never a concatenated string. wrong: opts="--exclude '*.log'"; rsync $opts src dst (the quotes become literal characters). right: opts=(--exclude '*.log'); rsync "${opts[@]}" src dst. conditionally append: [[ $dry == 1 ]] && opts+=(--dry-run).
for argument parsing: use getopts with a silent optstring and a while loop, then shift to discard flags. example: while getopts 'nv' opt; do case $opt in n) dry=1;; v) verbose=1;; *) die "usage: script [-nv] target";; esac; done; shift $((OPTIND-1)). for long options, hand-roll a while loop over "$@" with a case statement (portable to macOS).
before iterating over files: set shopt -s nullglob to prevent a glob matching nothing from expanding to the literal string (e.g., for f in *.txt in an empty dir). for hostile filenames or recursion, use find -print0 and read with NUL delimiter: while IFS= read -r -d '' f; do ...; done < <(find . -name '*.log' -print0).
run shellcheck before shipping (exit code 1 if findings at your lint gate). suppress only with the SC code and a reason: # shellcheck disable=SC2086 -- flags must split. if shellcheck is not available, at minimum run bash -n script to check syntax.
test the failure path: inject false into one command, re-run, confirm the script stops immediately, the trap fires (if registered), and the exit code is nonzero. do not ship a cleanup you never saw run.
if the script runs destructively (delete, overwrite, remote push), gate it behind --yes or require a dry-run pass first. document the gate in usage and in any confirmation prompts.
for debugging a broken script:
reproduce the failure on the smallest input that still breaks. if it works by hand but fails under cron, systemd, or CI, compare the environments: cron has a minimal PATH (may not find your commands), no login shell (no ~/.bashrc), and $HOME may not be set. CI runners start a fresh shell per step with no inherited set options.
add strict mode if missing: set -euo pipefail. if the script already has set -e, check the five blind spots: conditions (if/while), ||/&& operators, command substitution $( ), ! cmd negation, and exit called inside a subshell. the set -e does not trap these; guard them explicitly or use set -o errtrace and an ERR trap.
enable debug output: PS4='+ ${BASH_SOURCE##*/}:${LINENO}: ' bash -x script to see every command and its source line before execution. inspect the output for unexpected variable values, unmatched quotes, or commands not running at all.
check for unquoted expansions: grep -n '\$[a-zA-Z_]' script | grep -v '"' | grep -v "'" | grep -v '#' to find likely candidates. unquoted variables split on IFS and glob, causing filenames with spaces to explode into multiple arguments.
if a command built from variables misfires, ensure it is an array: cmd=(rsync -a); cmd+=(--dry-run); "${cmd[@]}" is safe; cmd="rsync -a --dry-run"; $cmd is not (the quotes become data, not syntax).
check exit codes: $? holds the last exit code but is overwritten by any command (even echo). save immediately: rc=$?. for a pipeline, check PIPESTATUS: cmd1 | cmd2; [[ ${PIPESTATUS[0]} -eq 0 ]] && ... names each segment's exit code.
if the script hangs, add timeout to the top-level invocation: timeout 10 ./script stops after 10 seconds with exit 124. then identify which command is stuck: bisect with bash -x to see the last command before the hang.
for comparison bugs (e.g., [[ 10 < 9 ]] is TRUE because it is lexical, not numeric): use (( )) for arithmetic, [[ ]] with -lt/-le/-gt/-ge for numeric, and [[ ]] with </> only for lexical strings.
if the script runs on a remote host via SSH or in a container, ensure the shebang interpreter exists there and has the right version. test: ssh user@host /bin/bash --version or docker run image /bin/bash --version.
if a temp file or lock is left behind after crash, check the trap registration: is the trap single-quoted (expands at fire time) or double-quoted (expanded at registration, may be empty)? test it: tmp=$(mktemp); trap 'echo "firing with tmp=$tmp"' EXIT; exit 1 should print the temp filename, not tmp= empty.
for hardening an existing script:
add set -euo pipefail at the top if missing. if the script is a library sourced by others, set options only in executables; libraries should return codes and let callers decide error handling.
quote every expansion. scan for "$, "$(, "${ patterns and ensure they match. an unquoted expansion must have a comment. use printf instead of echo when the value might be -n, -e, or contain backslashes.
replace any ls parsing with find or globs (filenames can contain newlines and may parse wrong). iterate safely: while IFS= read -r -d '' f; do ...; done < <(find . -type f).
replace any bare cd with cd "$dir" || exit 1, or cd "$dir" || die "no such dir: $dir". a failed cd can cause subsequent commands to run in the wrong place.
add an ERR trap to call a cleanup function and exit nonzero: trap 'cleanup; exit 1' ERR. this fires on any unhandled command failure (when set -e fails to catch it).
for critical resources (temp files, locks, background processes), explicitly register cleanup in a trap. test by injecting false and confirming cleanup runs.
replace any hard-coded paths or assumptions about the environment. use cd "$target_dir" && pwd -P to resolve the real path, then build paths relative to it. do not assume $HOME or $PATH exist.
for commands that read stdin (ssh, mysql, ffmpeg), pass -n or redirect input from /dev/null if you are looping: while ...; do ssh -n user@host ...; done. otherwise the first ssh eats the rest of the loop input.
run shellcheck -S warning (or your lint gate) and fix all findings, or suppress each with # shellcheck disable=SC<code> -- reason. do not ignore warnings; they are usually right.
document the bash floor in a header comment and enforce it at runtime if using bash 4+ features: ((BASH_VERSINFO[0] >= 4)) || { echo "bash 4+ required" >&2; exit 1; }.
when to use strict mode (set -euo pipefail) vs explicit error checks:
||/&&, command substitution, ! cmd, exit in a subshell) are enumerable; learn them and handle them explicitly.-e in a sourced file; it mutates the caller's shell and can break their error handling.when a script breaks on spaces in filenames:
"$var", "$(cmd)", "${arr[@]}"). if a command must split on spaces (rare), add a comment saying why and leave it unquoted.find -print0 | while IFS= read -r -d '' instead of for, or use set -f to disable globbing and IFS= to disable splitting.when set -e does not catch a failure:
set -e is blind to: conditions in if/while/until, the right side of || or &&, the body of $( ) command substitution, ! cmd negation, and exit called inside a subshell or function called in a pipeline.if ! cmd; then die "cmd failed"; fi, or { local out; out=$(cmd) || die "cmd failed"; }, or refactor to avoid the blind spot.when a pipeline reports failure but each segment succeeded:
head, grep -q) closed the pipe. this is usually success misread as failure. capture the output first, then filter: out=$(cmd); grep pattern <<< "$out" instead of cmd | grep -q pattern.when a script runs fine by hand but fails under cron:
/usr/local/bin or your app directories), has no login shell (no ~/.bashrc or ~/.bash_profile), sets $HOME to the cron user's home, and treats % as a newline (requires escaping: \%).env -i PATH=/usr/bin:/bin HOME=/root bash script, or run-parts the script.when a script works locally but fails in a CI runner:
set options do not carry over from one step to the next.set -euo pipefail into each script, not the CI config. re-run the script locally in a fresh shell to debug: bash -c 'set -euo pipefail; source script; main'.when to use [[ ]] vs [ ] vs (( )):
[[ ]] for strings and patterns (bash only): [[ $str == pattern ]], [[ -f $file ]]. it does not split or glob.(( )) for arithmetic: (( x > 10 )), (( count++ )). variables are unquoted and numeric.[ ] only for POSIX sh compatibility; bash scripts should prefer [[ ]].</> inside [[ ]] compares strings: [[ 10 < 9 ]] is TRUE. numeric -lt/-le/-gt/-ge: [[ 10 -lt 9 ]] is FALSE.when untrusted input could reach unsafe contexts:
eval, arithmetic (( )), or array subscripts [...]. validate first with a regex: [[ $n =~ ^[0-9]+$ ]] || die "not a number: $n".when the script grows past 100 lines or needs nested data structures:
rewrite_threshold lines (default 100, aligns with Google Shell Style Guide), the cost of bash outweighs its benefit.**when porting to POSIX sh (dash, busybox, alpine `/bin/