Claude Skill

bash-scripting

Use when writing or hardening a shell script that must survive another machine — a CI step, install script, cron job, git hook, devcontainer entrypoint: strict-mode leaks, quoting/word-splitting, arrays, trap cleanup, bash-vs-POSIX portability, ShellCheck findings. NOT CI workflo

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies

#shell #bash

Virus-scanned Reviewed automatically before listing.

Full trust report

Download ericrisco-rsc-harness-skills_bash-scripting-953fef5.zip · 12 KB
Part of ericrisco/rsc-harness — 46 skills

Install

skills CLI npx skills add https://github.com/ericrisco/rsc-harness/tree/main/skills/bash-scripting
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install ericrisco-rsc-harness@llmmart
Git git clone https://github.com/ericrisco/rsc-harness.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole ericrisco/rsc-harness collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Bash scripting — scripts that survive a stranger's machine

You are the shell author who has been burned: by an unquoted "$@" that exploded a path with spaces, by rm -rf "$DIR/" where $DIR was empty, by a trap that fired twice, by a set -e that swore it caught errors and didn't. Write every script as if it will run in CI, in a container, and on a 2014 Mac with bash 3.2 — because eventually it will.

The first decision is not a line of code. It is: which shell am I targeting? That choice decides what you are allowed to write. Make it before the shebang.

The header you start every bash script with

#!/usr/bin/env bash
set -euo pipefail        # see "Strict mode, honestly" — this is a baseline, not a force field
IFS=$'\n\t'              # split words on newline/tab only, never on spaces
  • #!/usr/bin/env bash — find bash on PATH; do not hardcode /bin/bash, which is 3.2 on macOS and may not exist on some images.
  • set -e — exit on an uncaught non-zero command. Leaky (below), still worth having.
  • set -u — error on an unset variable, so a typo'd $OUPUT fails loud instead of expanding to empty.
  • set -o pipefail — a pipeline fails if any stage fails, not just the last. Bashism — not in POSIX sh.
  • IFS=$'\n\t' — stops the classic "unquoted expansion splits on every space" bug at the source.

Pick your shell first

set -o pipefail, arrays, [[ ]], and local are bashisms. If your shebang is #!/bin/sh you may not get bash — on Debian/Alpine sh is dash/busybox.

#!/usr/bin/env bash #!/bin/sh (POSIX)
Where it runs anywhere bash is installed every Unix; the only safe choice for an unknown box
You GAIN arrays, [[ ]], pipefail, local, ${var,,}, process substitution <(…) maximal portability, smaller deps
You LOSE nothing (if bash is guaranteed) all the above — POSIX sh has none of them
Lint with shellcheck script.sh shellcheck -s sh script.sh
Test with bash script.sh dash script.sh

Two traps to internalize: macOS /bin/bash is 3.2 (no declare -A, no ${var,,}, no mapfile); and bash 5.3 added ${ cmd; } / GLOBSORT / read -E that will not run on either of the above. Pick a floor and stay above it — the full bash-vs-POSIX feature matrix, bash 3.2 workarounds, dash/busybox gotchas, the 5.3 features to guard, and how to test one script under several shells are in references/portability.md.

Strict mode, honestly

set -e is not a force field. It is silently suppressed in three places, and people ship broken scripts because they trusted it:

  1. In an assignment with command substitution. local x=$(failing) — the assignment succeeds (exit status is the local/assignment, not the substitution).
  2. In a condition. Anything in if, while, &&, ||, or after ! is exempt by design — set -e would make if grep … unusable otherwise.
  3. Inside functions called in a condition: a failing line won't abort.

So: do not lean on set -e for control flow. Check what matters explicitly.

# Bad — set -e will NOT catch this; x is empty, script sails on
local x=$(curl -fsS "$url")

# Good — split declaration from assignment so the substitution's status is seen
local x
x=$(curl -fsS "$url") || { echo "fetch failed" >&2; return 1; }

trap 'echo "failed at line $LINENO" >&2' ERR gives you a breadcrumb on the uncaught failures set -e does catch. To deliberately ignore a non-zero exit, say so: cmd || true (and a comment why), never a bare unchecked cmd.

Quoting — the highest-value section

Unquoted expansions are the №1 cause of shell bugs and of the data-loss story everyone has heard. Quote every expansion unless you have a specific reason not to.

Bad Good Why
rm $file rm "$file" space/glob in $file becomes multiple args (SC2086)
func $@ func "$@" "$@" preserves each arg verbatim; $@ re-splits them
x=$(cmd) … echo $x echo "$x" unquoted output word-splits and glob-expands
[ $x = y ] [ "$x" = y ] empty/spaced $x makes [ a syntax error
for f in $(ls) for f in ./* parsing ls breaks on spaces/newlines (SC2045)
rm -rf $DIR/ see below the disaster

The rm -rf disaster: if $DIR is unset/empty, rm -rf $DIR/ becomes rm -rf /. Guard the variable and quote it:

: "${DIR:?DIR must be set}"   # abort with a message if unset or empty
rm -rf "${DIR:?}"/           # belt and braces: fail rather than expand to /

Use [[ … ]] in bash (no word-splitting inside, supports =~, &&); use [ … ] in POSIX sh. [ a == b ] is a bashism — POSIX [ uses =.

Arrays, not space-split strings

The instant an argument list is built from a string, spaces betray you. Build it as an array and expand "${arr[@]}" (each element stays one argument).

# Bad — flags string splits wrong if any value contains a space
flags="--name my project --force"
docker run $flags image          # 5 args, "my" and "project" split apart

# Good — array; "${flags[@]}" expands to exactly 4 arguments
flags=(--name "my project" --force)
docker run "${flags[@]}" image

Iterating files: never parse ls. Use a glob, or find -print0 with a NUL-delimited read so even newlines in names are safe.

# Good — glob; the ./ prefix protects files named like "-rf"
for f in ./*.txt; do
  [ -e "$f" ] || continue       # guard the no-match case (glob stays literal)
  process "$f"
done

# Good — robust against spaces AND newlines in filenames
find . -name '*.log' -print0 | while IFS= read -r -d '' f; do
  process "$f"
done

macOS bash 3.2 has no mapfile/readarray; the find -print0 loop above is the portable way to collect names.

Cleanup with traps

A script that creates temp state must remove it even when interrupted. Register one EXIT trap right after you create the resource — EXIT fires on normal exit, on set -e abort, and after INT/TERM, so you don't need per-signal traps.

tmp=$(mktemp -d)                          # never a fixed /tmp/foo path (race + collision)
trap 'rm -rf "$tmp"' EXIT                 # one trap, covers every exit path

work_in "$tmp"

Make cleanup idempotent (rm -rf tolerates a missing dir) so a double-fire or a re-entry is harmless. Track background PIDs and reap them in the same trap:

server & srv_pid=$!
trap 'kill "$srv_pid" 2>/dev/null; rm -rf "$tmp"' EXIT

Input & variables

Pattern Use
: "${1:?usage: deploy <env>}" require an argument, abort with a message
env="${1:-staging}" default when omitted
readonly ROOT="$PWD" constants that must not be reassigned
local x (bash) function-scope a variable so it doesn't leak (not POSIX)

Option parsing uses getopts (POSIX, single-dash flags):

verbose=0; out=""
while getopts ":vo:" opt; do
  case "$opt" in
    v) verbose=1 ;;
    o) out="$OPTARG" ;;
    *) echo "usage: $0 [-v] [-o FILE]" >&2; exit 2 ;;
  esac
done
shift $((OPTIND - 1))

Use printf '%s\n' "$x" instead of echo "$x" for arbitrary data — echo's handling of -n/-e and backslashes is not portable.

Run ShellCheck — and fix, don't silence

ShellCheck v0.11.0 (2025-08) is the canonical static analyzer. Run it on every script; it catches most of the above before runtime.

shellcheck script.sh             # bash target
shellcheck -s sh script.sh       # verify POSIX-sh compliance

Codes worth memorizing: SC2086 (unquoted expansion → quote it), SC2046 (unquoted $(…) word-splits), SC2164 (cd without || exit), SC2155 (local x=$(cmd) masks the command's exit status — declare then assign).

Silence a finding only with a justified directive on the line directly above it, never project-wide:

# shellcheck disable=SC2086  # word-splitting is intentional: $flags is a flag list we control
some_cmd $flags
# shellcheck source=lib/common.sh   # resolve a dynamic `source` for cross-file analysis
. "$dir/common.sh"

Wire shellcheck into CI as a run: step — the workflow scaffolding around it belongs to ../github-actions/SKILL.md, the shell inside the step belongs here.

Anti-patterns

Anti-pattern Why it bites Do instead
for x in $(ls *.txt) breaks on spaces/newlines; SC2045 for x in ./*.txt; do [ -e "$x" ] \|\| continue
Unquoted $var / $@ word-split + glob; SC2086 always "$var" / "$@"
cd "$d"; rm -rf . if cd fails you rm the wrong dir; SC2164 cd "$d" \|\| exit 1
Trusting set -e for control flow leaks in assignments/conditions/functions check explicitly: cmd \|\| { …; exit 1; }
local x=$(cmd) masks cmd exit status; SC2155 local x; x=$(cmd) \|\| return 1
Bash 5.3 / declare -A in a 3.2 or sh target "command not found" on the user's box pick a floor; see references/portability.md
echo "$untrusted" for data non-portable -n/-e/backslash handling printf '%s\n' "$x"
[ a == b ] under #!/bin/sh == is a bashism; dash errors POSIX uses [ a = b ]
Fixed temp path /tmp/build race + collision + no cleanup tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
pipefail in a #!/bin/sh script not POSIX; dash ignores or errors use bash, or check pipeline status another way
Files (rsc-harness)
  • evals
    • cases.yaml 4.7 KB
      skill: bash-scripting
      
      should_trigger:
        - prompt: "Write an install script that downloads a tarball, verifies its checksum, untars it, and cleans up the temp dir even if it fails partway."
          why: "Core bundle of the skill: mktemp + EXIT trap cleanup + strict-mode header + quoted expansions. The 'cleanup even if it fails' clause is the trap requirement."
        - prompt: "My bash loop processes files fine until someone has a filename with a space, then it splits into two. What's wrong?"
          why: "The quoting/array/-print0 core. Word-splitting an unquoted expansion (SC2086) or parsing ls; the skill's highest-value section."
        - prompt: "This deploy script works on my laptop but in CI set -e doesn't catch the error and it keeps going. Why?"
          why: "Non-obvious — looks like a CI problem but is the errexit leak (assignment-with-substitution / condition context / functions). The skill owns the bash semantics, not the CI config."
        - prompt: "Make this script run on both macOS and Linux — it uses declare -A and ${var,,} and dies on the Mac."
          why: "Portability / macOS bash 3.2: associative arrays and case-folding don't exist on 3.2.57. Routes to references/portability.md workarounds."
        - prompt: "Fix all these ShellCheck SC2086 warnings correctly, not just by adding disable comments everywhere."
          why: "Non-obvious: the ask is to fix (quote the expansions), not silence. Tests that the skill teaches the correct directive discipline."
        - prompt: "Tinc un script que peta en CI però funciona al meu portàtil — el pots fer robust i portable?"
          why: "Catalan trigger. 'Peta en CI / funciona al portàtil' is the works-on-my-machine portability + strict-mode symptom the description names."
        - prompt: "My cleanup trap seems to fire twice and rm complains about a missing directory."
          why: "The double-fire trap + idempotent cleanup point. Specific symptom from the traps section."
      
      should_not_trigger:
        - prompt: "My GitHub Actions matrix build isn't caching node_modules between jobs. How do I configure the cache key?"
          route_to: "github-actions"
          why: "Workflow structure — runners, matrix, caching, secrets. The YAML and the workflow graph, not the shell inside a run: block. Explicit description boundary."
        - prompt: "Optimize my multi-stage Dockerfile so the RUN layers cache better and the final image is smaller."
          route_to: "docker"
          why: "Dockerfile semantics and layer hygiene, not the embedded script. Routes to docker."
        - prompt: "Design a retry-with-exponential-backoff policy and a structured error taxonomy for my microservice."
          route_to: "error-handling"
          why: "Cross-language defensive-design question. This skill is only the bash-specific expression (set -e pitfalls, traps, $?); the general taxonomy is error-handling."
        - prompt: "This bash data-processing script has gotten gnarly with nested awk and arrays — rewrite it in Python."
          route_to: "python"
          why: "A deliberate language-switch decision. Once the answer is 'do it in another language', it routes to that language's skill."
        - prompt: "Set up the Makefile and task orchestration so contributors can run lint/test/build with one command."
          route_to: "project-ops"
          why: "Orchestration-layer design, not a single script body. project-ops owns the task-runner structure; bash-scripting owns the script inside a target."
      
      capability:
        - scenario: "Harden this fragile deploy script and explain each change. It is: #!/bin/sh on line 1, then `DIR=$1; cd $DIR; for f in $(ls *.tar.gz); do tar xzf $f; done; rm -rf $DIR/build/*; declare -A envs` — it parses ls, has unquoted vars, cds without checking, no cleanup, uses a bash-5-only feature under a POSIX shebang, and there's no strict mode."
          must_include:
            - "Resolves the shebang/feature mismatch: declare -A is a bashism (and unavailable on macOS bash 3.2), so either switch the shebang to #!/usr/bin/env bash or drop the associative array — does not leave #!/bin/sh with bashisms inside."
            - "Adds a strict-mode header appropriate to the chosen shell (set -euo pipefail + IFS for bash; explains pipefail/arrays are unavailable if POSIX sh is chosen)."
            - "Quotes every expansion: \"$DIR\", \"$f\", and guards the destructive path with ${DIR:?} so an empty DIR can't become rm -rf /."
            - "Replaces `for f in $(ls *.tar.gz)` with a glob (`for f in ./*.tar.gz; do [ -e \"$f\" ] || continue`) or find -print0, killing the ls-parse and space/newline bug."
            - "Changes `cd $DIR` to `cd \"$DIR\" || exit 1` so a failed cd does not run the rest in the wrong directory (SC2164)."
            - "Adds trap-based cleanup with mktemp for any temp work (tmp=$(mktemp -d); trap 'rm -rf \"$tmp\"' EXIT), idempotent."
            - "States the result must pass `shellcheck` clean (and `shellcheck -s sh` if the POSIX path is chosen)."
      
    • README.md 954 B
      # Evals — bash-scripting
      
      These cases sanity-check the skill's routing and capability. There is no harness here; eyeball it. For each `should_trigger` prompt, read the `description` in `../SKILL.md` and confirm the symptoms (works-locally-fails-in-CI, filenames-with-spaces, leaky `set -e`, macOS bash 3.2, SC2086, the Catalan "peta en CI") plausibly fire the skill; for each `should_not_trigger` prompt, confirm the named `route_to` sibling (`github-actions`, `docker`, `error-handling`, `python`, `project-ops`) is the better owner and that the prompt is *about* that sibling's territory, not the shell body. The `capability` case is graded by hardening the given fragile deploy script and checking the result against its `must_include` rubric — the decisive test is that the produced script passes `shellcheck` clean (and `shellcheck -s sh` if the POSIX path is chosen), which `../scripts/verify.sh` runs for you against the skill's own snippets.
      
  • references
    • portability.md 3.9 KB
      # Portability: bash vs POSIX sh, and the macOS bash 3.2 reality
      
      Read this when a script must run somewhere you don't control: an unknown CI
      image, a colleague's Mac, an Alpine container, a busybox device. The question
      is always the same — **what is the lowest shell this will ever hit, and what
      does that shell forbid?**
      
      ## Feature matrix
      
      | Feature | bash 4+ | bash 3.2 (macOS `/bin/bash`) | POSIX `sh` (dash/busybox) |
      |---|---|---|---|
      | `set -o pipefail` | yes | yes | **no** (bashism) |
      | Indexed arrays `arr=(…)` / `"${arr[@]}"` | yes | yes | **no** |
      | Associative arrays `declare -A` | yes | **no** (3.2 lacks them) | **no** |
      | `[[ … ]]`, `=~`, `&&` inside test | yes | yes | **no** (use `[ … ]`) |
      | `local` in functions | yes | yes | **no** (not POSIX; dash/ksh have it as an extension) |
      | `${var,,}` / `${var^^}` case fold | yes | **no** | **no** |
      | `mapfile` / `readarray` | yes | **no** | **no** |
      | Process substitution `<(…)` | yes | yes | **no** |
      | `${ cmd; }` / `${\| cmd; }`, `GLOBSORT`, `read -E` | bash **5.3+** only | **no** | **no** |
      | `[ a == b ]` | tolerated | tolerated | **no** (use `=`) |
      | `echo -e` / `echo -n` | unreliable across shells — use `printf` everywhere |
      
      Rule of thumb: if the floor is "any Unix," target POSIX `sh` and give up arrays,
      `[[ ]]`, `pipefail`, and `local`. If you control the box and bash is present,
      target bash but assume **3.2** unless you've verified otherwise — that rules out
      associative arrays, `${var,,}`, and `mapfile`.
      
      ## macOS bash 3.2 workarounds
      
      Apple froze `/bin/bash` at **3.2.57** (pre-GPLv3); zsh is the default login
      shell since Catalina, but scripts still hit `/bin/bash`. Newer bash from
      Homebrew lives at `/opt/homebrew/bin/bash` and is NOT what a `#!/bin/bash`
      shebang gets. Portable substitutes:
      
      ```bash
      # No associative array (declare -A). Use a function + case, or parallel arrays.
      lookup() {
        case "$1" in
          dev)  echo "https://dev.example.com" ;;
          prod) echo "https://example.com" ;;
          *)    return 1 ;;
        esac
      }
      
      # No ${var,,} lowercasing. Use tr.
      lower=$(printf '%s' "$var" | tr '[:upper:]' '[:lower:]')
      
      # No mapfile. Read lines into an array with a loop (or use find -print0).
      lines=()
      while IFS= read -r line; do
        lines+=("$line")
      done < file.txt
      ```
      
      ## dash / busybox gotchas
      
      These bite POSIX-`sh` scripts that were only ever tested under bash:
      
      - `echo -e '\n'` prints the literal `-e` under dash. Use `printf '\n'`.
      - `==` inside `[ ]` is a bash extension; dash errors. Use `=`.
      - `source file` is a bashism; POSIX is `. file` (dot space).
      - `function name {` is a bashism; POSIX is `name() {`.
      - `local` works in dash and busybox ash as an extension, but is not in the
        POSIX spec — don't rely on it if "any POSIX sh" is the contract.
      - `${arr[@]}` / `(…)` arrays: dash has none. busybox ash has none.
      
      ## bash 5.3 features — guard or avoid
      
      Bash **5.3** (released 2025-07) added in-shell command substitution `${ cmd; }`
      and `${| cmd; }` (no fork; result in `REPLY`), `GLOBSORT`, `compgen` into a
      variable, `read -E`, and C23 conformance. None of these run on bash 3.2 or
      POSIX `sh`. If you must use one, gate it on the version and provide a fallback:
      
      ```bash
      if [ "${BASH_VERSINFO[0]:-0}" -ge 5 ] && [ "${BASH_VERSINFO[1]:-0}" -ge 3 ]; then
        : "use the 5.3 feature"
      else
        : "portable fallback"
      fi
      ```
      
      Otherwise, simply don't use them in anything that ships.
      
      ## How to test against more than your own shell
      
      ```bash
      shellcheck script.sh             # default: bash dialect
      shellcheck -s sh script.sh       # static check against POSIX sh
      dash ./script.sh                 # actually run under dash (apt install dash)
      bash --posix ./script.sh         # bash with POSIX-mode restrictions on
      docker run --rm -v "$PWD":/s -w /s alpine sh script.sh   # busybox ash
      ```
      
      Running under `dash` is the cheapest way to surface accidental bashisms that
      ShellCheck's `-s sh` mode misses. A script that passes `shellcheck -s sh` AND
      runs clean under `dash` is genuinely portable.
      
  • scripts
    • verify.sh 4.8 KB
      #!/usr/bin/env bash
      set -euo pipefail
      
      # ============================================================================
      # NAME
      #   verify.sh — bash-scripting self-lint gate
      #
      # USAGE
      #   ./verify.sh [TARGET_DIR]
      #   With no argument it lints the bash-scripting skill itself (the SKILL.md and
      #   references/*.md fenced ```bash snippets, plus any *.sh under the skill).
      #   Pass a TARGET_DIR to instead lint every *.sh under a project you are
      #   hardening with this skill.
      #
      # WHAT IT DOES
      #   1. Self-check  — extracts every ```bash fenced block from SKILL.md and
      #                    references/*.md, wraps each in a strict-mode header, and
      #                    runs shellcheck on it. This proves the skill never teaches
      #                    code that fails its own linter. Blocks explicitly marked
      #                    "# Bad" are illustrative wrong-on-purpose examples and are
      #                    skipped (only the "# Good" half of a Bad/Good pair lints).
      #   2. Script lint — runs shellcheck on every *.sh found under the target.
      #
      # GUARANTEES
      #   - Read-only: never writes to or fixes the target; only reads + temp files.
      #   - Graceful skip: if shellcheck is absent it prints a skip notice and EXITS 0
      #     (never a false failure in an environment that lacks the linter).
      #   - Clean exit on an empty/clean target: no scripts and no findings => exit 0.
      #   - Portable to stock macOS bash 3.2 (no mapfile, no associative arrays).
      #
      # EXIT CODES
      #   0  shellcheck absent (skipped), OR everything is clean.
      #   1  at least one real shellcheck finding — fix it before shipping.
      # ============================================================================
      
      RED=$'\033[31m'; YEL=$'\033[33m'; GRN=$'\033[32m'; RST=$'\033[0m'
      if [ -n "${NO_COLOR:-}" ]; then RED=""; YEL=""; GRN=""; RST=""; fi
      
      ok()   { printf '%s[ok]%s %s\n'   "$GRN" "$RST" "$*"; }
      warn() { printf '%s[skip]%s %s\n' "$YEL" "$RST" "$*" >&2; }
      bad()  { printf '%s[FAIL]%s %s\n' "$RED" "$RST" "$*" >&2; }
      
      # Resolve the skill root from this script's own location (scripts/ lives under it).
      SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
      SKILL_DIR=$(cd "$SCRIPT_DIR/.." && pwd)
      TARGET="${1:-$SKILL_DIR}"
      
      if ! command -v shellcheck >/dev/null 2>&1; then
        warn "shellcheck not installed (brew install shellcheck / https://github.com/koalaman/shellcheck) — skipping lint"
        exit 0
      fi
      
      FAILED=0
      TMP=$(mktemp -d)
      trap 'rm -rf "$TMP"' EXIT   # one idempotent EXIT trap — the pattern this skill teaches
      
      # Context codes that are noise on an isolated snippet (undefined vars/functions
      # defined elsewhere in the doc, missing shebang we synthesize, unresolved source,
      # unused vars in a fragment). These are excluded ONLY for extracted snippets,
      # never for real *.sh files.
      SNIPPET_EXCLUDE="SC2148,SC2154,SC2034,SC1090,SC1091,SC2317,SC2168,SC2030,SC2031"
      
      # extract_blocks FILE  — write each ```bash block to $TMP/<base>.NN.sh with a
      # strict-mode header. Blocks whose body contains a "# Bad" line are skipped.
      extract_blocks() {
        file="$1"
        base=$(basename "$file" .md)
        awk -v dir="$TMP" -v base="$base" '
          /^```bash$/ { inblk=1; n++; body=""; bad=0; next }
          /^```/      { if (inblk) {
                          inblk=0
                          if (!bad) {
                            f = sprintf("%s/%s.%02d.sh", dir, base, n)
                            printf "#!/usr/bin/env bash\nset -euo pipefail\n%s", body > f
                            close(f)
                            print f
                          }
                        }
                        next }
          inblk       { if ($0 ~ /# Bad/) bad=1; body = body $0 "\n" }
        ' "$file"
      }
      
      lint_one() { # lint_one FILE EXCLUDE
        if [ -n "$2" ]; then
          shellcheck --exclude="$2" "$1"
        else
          shellcheck "$1"
        fi
      }
      
      printf '\n=== Snippet self-check (%s) ===\n' "$SKILL_DIR"
      SNIPPET_FILES=""
      for src in "$SKILL_DIR/SKILL.md" "$SKILL_DIR"/references/*.md; do
        [ -e "$src" ] || continue
        while IFS= read -r snip; do
          [ -n "$snip" ] || continue
          SNIPPET_FILES="$SNIPPET_FILES $snip"
        done <<EOF
      $(extract_blocks "$src")
      EOF
      done
      
      if [ -z "${SNIPPET_FILES// /}" ]; then
        warn "no \`\`\`bash snippets found to check"
      else
        for snip in $SNIPPET_FILES; do
          if lint_one "$snip" "$SNIPPET_EXCLUDE"; then
            ok "snippet clean: $(basename "$snip")"
          else
            bad "snippet has findings: $(basename "$snip")"
            FAILED=1
          fi
        done
      fi
      
      printf '\n=== Script lint (%s) ===\n' "$TARGET"
      FOUND=0
      # find -print0 + NUL read: the space/newline-safe iteration this skill preaches.
      while IFS= read -r -d '' sh; do
        FOUND=1
        if shellcheck "$sh"; then
          ok "clean: $sh"
        else
          bad "findings: $sh"
          FAILED=1
        fi
      done < <(find "$TARGET" -type f -name '*.sh' ! -path "*/scripts/verify.sh" -print0)
      
      if [ "$FOUND" -eq 0 ]; then
        ok "no *.sh files under target (nothing to lint)"
      fi
      
      printf '\n=== Summary ===\n'
      if [ "$FAILED" -eq 0 ]; then
        ok "shellcheck clean"
      else
        bad "shellcheck findings present — fix before shipping"
      fi
      exit "$FAILED"
      
  • SKILL.md 9.9 KB
    ---
    name: bash-scripting
    description: "Use when writing or hardening a shell script that must survive another machine — a CI step, install script, cron job, git hook, devcontainer entrypoint: strict-mode leaks, quoting/word-splitting, arrays, trap cleanup, bash-vs-POSIX portability, ShellCheck findings. NOT CI workflow structure, runners, caching or matrix (that is `github-actions`)."
    tags: [bash, shell, shellcheck, posix, scripting]
    recommends: [github-actions, error-handling, docker, secure-coding]
    origin: risco
    ---
    
    # Bash scripting — scripts that survive a stranger's machine
    
    You are the shell author who has been burned: by an unquoted `"$@"` that
    exploded a path with spaces, by `rm -rf "$DIR/"` where `$DIR` was empty, by a
    `trap` that fired twice, by a `set -e` that swore it caught errors and didn't.
    Write every script as if it will run in CI, in a container, and on a 2014 Mac
    with `bash 3.2` — because eventually it will.
    
    The first decision is not a line of code. It is: **which shell am I targeting?**
    That choice decides what you are allowed to write. Make it before the shebang.
    
    ## The header you start every bash script with
    
    ```bash
    #!/usr/bin/env bash
    set -euo pipefail        # see "Strict mode, honestly" — this is a baseline, not a force field
    IFS=$'\n\t'              # split words on newline/tab only, never on spaces
    ```
    
    - `#!/usr/bin/env bash` — find bash on `PATH`; do not hardcode `/bin/bash`, which is **3.2** on macOS and may not exist on some images.
    - `set -e` — exit on an uncaught non-zero command. Leaky (below), still worth having.
    - `set -u` — error on an unset variable, so a typo'd `$OUPUT` fails loud instead of expanding to empty.
    - `set -o pipefail` — a pipeline fails if **any** stage fails, not just the last. Bashism — not in POSIX `sh`.
    - `IFS=$'\n\t'` — stops the classic "unquoted expansion splits on every space" bug at the source.
    
    ## Pick your shell first
    
    `set -o pipefail`, arrays, `[[ ]]`, and `local` are **bashisms**. If your shebang
    is `#!/bin/sh` you may not get bash — on Debian/Alpine `sh` is `dash`/busybox.
    
    | | `#!/usr/bin/env bash` | `#!/bin/sh` (POSIX) |
    |---|---|---|
    | Where it runs | anywhere bash is installed | every Unix; the only safe choice for an unknown box |
    | You GAIN | arrays, `[[ ]]`, `pipefail`, `local`, `${var,,}`, process substitution `<(…)` | maximal portability, smaller deps |
    | You LOSE | nothing (if bash is guaranteed) | all the above — POSIX `sh` has none of them |
    | Lint with | `shellcheck script.sh` | `shellcheck -s sh script.sh` |
    | Test with | `bash script.sh` | `dash script.sh` |
    
    Two traps to internalize: macOS `/bin/bash` is **3.2** (no `declare -A`,
    no `${var,,}`, no `mapfile`); and bash **5.3** added `${ cmd; }` /
    `GLOBSORT` / `read -E` that will not run on either of the above. Pick a floor
    and stay above it — the full bash-vs-POSIX feature matrix, `bash 3.2`
    workarounds, dash/busybox gotchas, the 5.3 features to guard, and how to test
    one script under several shells are in
    [`references/portability.md`](references/portability.md).
    
    ## Strict mode, honestly
    
    `set -e` is not a force field. It is silently suppressed in three places, and
    people ship broken scripts because they trusted it:
    
    1. **In an assignment with command substitution.** `local x=$(failing)` — the
       assignment succeeds (exit status is the `local`/assignment, not the substitution).
    2. **In a condition.** Anything in `if`, `while`, `&&`, `||`, or after `!` is
       exempt by design — `set -e` would make `if grep …` unusable otherwise.
    3. **Inside functions** called in a condition: a failing line won't abort.
    
    So: do not lean on `set -e` for control flow. Check what matters explicitly.
    
    ```bash
    # Bad — set -e will NOT catch this; x is empty, script sails on
    local x=$(curl -fsS "$url")
    
    # Good — split declaration from assignment so the substitution's status is seen
    local x
    x=$(curl -fsS "$url") || { echo "fetch failed" >&2; return 1; }
    ```
    
    `trap 'echo "failed at line $LINENO" >&2' ERR` gives you a breadcrumb on the
    uncaught failures `set -e` *does* catch. To deliberately ignore a non-zero exit,
    say so: `cmd || true` (and a comment why), never a bare unchecked `cmd`.
    
    ## Quoting — the highest-value section
    
    Unquoted expansions are the №1 cause of shell bugs and of the data-loss story
    everyone has heard. Quote every expansion unless you have a specific reason not to.
    
    | Bad | Good | Why |
    |---|---|---|
    | `rm $file` | `rm "$file"` | space/glob in `$file` becomes multiple args (SC2086) |
    | `func $@` | `func "$@"` | `"$@"` preserves each arg verbatim; `$@` re-splits them |
    | `x=$(cmd)` … `echo $x` | `echo "$x"` | unquoted output word-splits and glob-expands |
    | `[ $x = y ]` | `[ "$x" = y ]` | empty/spaced `$x` makes `[` a syntax error |
    | `for f in $(ls)` | `for f in ./*` | parsing `ls` breaks on spaces/newlines (SC2045) |
    | `rm -rf $DIR/` | see below | the disaster |
    
    The `rm -rf` disaster: if `$DIR` is unset/empty, `rm -rf $DIR/` becomes
    `rm -rf /`. Guard the variable *and* quote it:
    
    ```bash
    : "${DIR:?DIR must be set}"   # abort with a message if unset or empty
    rm -rf "${DIR:?}"/           # belt and braces: fail rather than expand to /
    ```
    
    Use `[[ … ]]` in bash (no word-splitting inside, supports `=~`, `&&`); use
    `[ … ]` in POSIX `sh`. `[ a == b ]` is a bashism — POSIX `[` uses `=`.
    
    ## Arrays, not space-split strings
    
    The instant an argument list is built from a string, spaces betray you. Build
    it as an array and expand `"${arr[@]}"` (each element stays one argument).
    
    ```bash
    # Bad — flags string splits wrong if any value contains a space
    flags="--name my project --force"
    docker run $flags image          # 5 args, "my" and "project" split apart
    
    # Good — array; "${flags[@]}" expands to exactly 4 arguments
    flags=(--name "my project" --force)
    docker run "${flags[@]}" image
    ```
    
    Iterating files: never parse `ls`. Use a glob, or `find -print0` with a
    NUL-delimited read so even newlines in names are safe.
    
    ```bash
    # Good — glob; the ./ prefix protects files named like "-rf"
    for f in ./*.txt; do
      [ -e "$f" ] || continue       # guard the no-match case (glob stays literal)
      process "$f"
    done
    
    # Good — robust against spaces AND newlines in filenames
    find . -name '*.log' -print0 | while IFS= read -r -d '' f; do
      process "$f"
    done
    ```
    
    macOS `bash 3.2` has no `mapfile`/`readarray`; the `find -print0` loop above is
    the portable way to collect names.
    
    ## Cleanup with traps
    
    A script that creates temp state must remove it even when interrupted. Register
    **one** `EXIT` trap right after you create the resource — `EXIT` fires on normal
    exit, on `set -e` abort, and after `INT`/`TERM`, so you don't need per-signal traps.
    
    ```bash
    tmp=$(mktemp -d)                          # never a fixed /tmp/foo path (race + collision)
    trap 'rm -rf "$tmp"' EXIT                 # one trap, covers every exit path
    
    work_in "$tmp"
    ```
    
    Make cleanup idempotent (`rm -rf` tolerates a missing dir) so a double-fire or a
    re-entry is harmless. Track background PIDs and reap them in the same trap:
    
    ```bash
    server & srv_pid=$!
    trap 'kill "$srv_pid" 2>/dev/null; rm -rf "$tmp"' EXIT
    ```
    
    ## Input & variables
    
    | Pattern | Use |
    |---|---|
    | `: "${1:?usage: deploy <env>}"` | require an argument, abort with a message |
    | `env="${1:-staging}"` | default when omitted |
    | `readonly ROOT="$PWD"` | constants that must not be reassigned |
    | `local x` (bash) | function-scope a variable so it doesn't leak (not POSIX) |
    
    Option parsing uses `getopts` (POSIX, single-dash flags):
    
    ```bash
    verbose=0; out=""
    while getopts ":vo:" opt; do
      case "$opt" in
        v) verbose=1 ;;
        o) out="$OPTARG" ;;
        *) echo "usage: $0 [-v] [-o FILE]" >&2; exit 2 ;;
      esac
    done
    shift $((OPTIND - 1))
    ```
    
    Use `printf '%s\n' "$x"` instead of `echo "$x"` for arbitrary data — `echo`'s
    handling of `-n`/`-e` and backslashes is not portable.
    
    ## Run ShellCheck — and fix, don't silence
    
    ShellCheck v0.11.0 (2025-08) is the canonical static analyzer. Run it on every
    script; it catches most of the above before runtime.
    
    ```bash
    shellcheck script.sh             # bash target
    shellcheck -s sh script.sh       # verify POSIX-sh compliance
    ```
    
    Codes worth memorizing: **SC2086** (unquoted expansion → quote it),
    **SC2046** (unquoted `$(…)` word-splits), **SC2164** (`cd` without `|| exit`),
    **SC2155** (`local x=$(cmd)` masks the command's exit status — declare then assign).
    
    Silence a finding only with a justified directive on the line directly above it,
    never project-wide:
    
    ```bash
    # shellcheck disable=SC2086  # word-splitting is intentional: $flags is a flag list we control
    some_cmd $flags
    # shellcheck source=lib/common.sh   # resolve a dynamic `source` for cross-file analysis
    . "$dir/common.sh"
    ```
    
    Wire `shellcheck` into CI as a `run:` step — the workflow scaffolding around it
    belongs to `../github-actions/SKILL.md`, the shell inside the step belongs here.
    
    ## Anti-patterns
    
    | Anti-pattern | Why it bites | Do instead |
    |---|---|---|
    | `for x in $(ls *.txt)` | breaks on spaces/newlines; SC2045 | `for x in ./*.txt; do [ -e "$x" ] \|\| continue` |
    | Unquoted `$var` / `$@` | word-split + glob; SC2086 | always `"$var"` / `"$@"` |
    | `cd "$d"; rm -rf .` | if `cd` fails you `rm` the wrong dir; SC2164 | `cd "$d" \|\| exit 1` |
    | Trusting `set -e` for control flow | leaks in assignments/conditions/functions | check explicitly: `cmd \|\| { …; exit 1; }` |
    | `local x=$(cmd)` | masks `cmd` exit status; SC2155 | `local x; x=$(cmd) \|\| return 1` |
    | Bash 5.3 / `declare -A` in a 3.2 or `sh` target | "command not found" on the user's box | pick a floor; see `references/portability.md` |
    | `echo "$untrusted"` for data | non-portable `-n`/`-e`/backslash handling | `printf '%s\n' "$x"` |
    | `[ a == b ]` under `#!/bin/sh` | `==` is a bashism; dash errors | POSIX uses `[ a = b ]` |
    | Fixed temp path `/tmp/build` | race + collision + no cleanup | `tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT` |
    | `pipefail` in a `#!/bin/sh` script | not POSIX; dash ignores or errors | use bash, or check pipeline status another way |
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related