Help us improve
Share bugs, ideas, or general feedback.
From claude-mods
Provides defensive Bash scripting standards for production automation, CI scripts, and skill glue code. Covers strict mode, quoting, argument parsing, traps, safe tempfiles, and the stream-separation/exit-code contract.
npx claudepluginhub 0xdarkmatter/claude-mods --plugin claude-modsHow this skill is triggered — by the user, by Claude, or both
Slash command
/claude-mods:bash-opsWhen to use
Use when writing or reviewing any Bash/shell script — especially skill scripts, CI steps, and automation that must fail safely. Covers strict mode, quoting, argument parsing, traps/cleanup, safe tempfiles, the stream-separation + exit-code contract, and shellcheck.
This skill is limited to the following tools:
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
Defensive Bash for scripts that run unattended — CI steps, automation, and the
Writes and reviews defensive Bash scripts for production automation, CI/CD pipelines, system utilities, with strict error handling, portability, testing via Bats, and ShellCheck linting.
Generates defensive Bash scripts for production automation, CI/CD pipelines, and system utilities with POSIX compliance, error handling, testing, and modern features.
Shell script conventions, defensive patterns, and correctness rules: strict mode, quoting, portability, error handling, and common pitfalls. Invoke whenever task involves any interaction with shell scripts — writing, reviewing, debugging, or understanding .sh, .bash, .zsh files.
Share bugs, ideas, or general feedback.
Defensive Bash for scripts that run unattended — CI steps, automation, and the
scripts/ a skill ships. The goal: a script that fails loudly on the first
problem, never corrupts state, and emits parseable output.
This is the house standard for any shell script in this repo. The script contract
below is the same one enforced by
docs/SKILL-RESOURCE-PROTOCOL.md §2–§7 —
that protocol governs every skill resource, and its rules are bash rules. Treat
the two as one standard: the resource protocol decides what a skill script must
guarantee (streams, exit codes, help block); this skill teaches how to write
the Bash that delivers it. The canonical reference implementation is
skills/supply-chain-defense/scripts/preinstall-check.sh —
read it whenever you need a worked example of every rule here applied at once.
Reach for Python (and the python-cli-ops skill) when a script grows past
~100 lines, needs data structures (nested maps, JSON manipulation beyond a
jq filter), arithmetic beyond integers, or string processing with real parsing.
Bash excels at gluing processes together: launching tools, moving files,
checking conditions, wiring pipelines. The moment you find yourself simulating a
hash-of-hashes or doing float math, stop — that's Python's job. This mirrors
SKILL-RESOURCE-PROTOCOL §3, which expects .sh for shell glue and .py for logic.
#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'
Each flag earns its place — and each has a sharp edge:
| Flag | Buys you | The edge to know |
|---|---|---|
set -e | Abort on any unchecked non-zero command | Does not fire inside if/&&/` |
set -u | Error on unset variable expansion | "$@" and "${arr[@]}" on an empty array trip -u in old Bash; use "${arr[@]:-}" or guard with Bash 4.4+. |
set -o pipefail | A pipe fails if any stage fails, not just the last | Without it, `grep x file |
set -E | ERR trap inherits into functions/subshells/command-subs | Pair with a trap … ERR that reports $LINENO. |
IFS=$'\n\t' | Word-splitting only on newline/tab, never spaces | Filenames with spaces stop splitting into pieces. Unset/space-IFS is the #1 cause of "it worked until a path had a space". |
set -e is the contested one. Use the full set -Eeuo pipefail when every
unchecked failure should abort (most scripts). Drop to set -uo pipefail when the
script deliberately inspects exit codes itself (the resource-protocol exemplars do
this — they branch on registry exit codes, so a non-zero curl must not kill the
run). Decide consciously; don't cargo-cult either way.
→ Full treatment, ERR-trap recipes, and the set -e exemption rules:
references/strict-mode-and-traps.md.
Quote every expansion unless you have a specific, commented reason not to.
cp "$src" "$dst" # not cp $src $dst — breaks on spaces/globs
for f in "${files[@]}"; do … # not ${files[@]} — array stays element-safe
rm -- "$path" # -- ends options; $path starting with - is data
[[ -n "$x" ]] # [[ ]] doesn't word-split, but quote for habit
grep -- "$pattern" "$file"
$var undergoes word splitting (on IFS) then glob expansion.
A variable holding *.txt or a b becomes multiple args. This is the canonical
footgun."$@" (quoted) preserves arguments exactly; $@ and $* mangle them. Always
"$@" to forward args.-- before user/agent-supplied operands so a value like -rf is treated as
data, not flags.The resource protocol mandates --help with an EXAMPLES section and rejects
unknown flags with a USAGE error (exit 2). Use a while/case loop — it handles
GNU-style long flags (--json, --dry-run), which getopts cannot:
JSON=0; DRY_RUN=0; ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--json) JSON=1 ;;
--dry-run) DRY_RUN=1 ;;
-h|--help) usage; exit 0 ;;
--) shift; ARGS+=("$@"); break ;; # everything after -- is positional
-*) printf 'ERROR: unknown flag: %s (try --help)\n' "$1" >&2; exit 2 ;;
*) ARGS+=("$1") ;;
esac
shift
done
getopts is fine for short flags only (-v -o file) and is more compact there,
but it has no long-flag support and clusters awkwardly. Prefer the case loop for
anything agent-facing — it matches preinstall-check.sh exactly.
→ Both styles in full, value-taking flags, --flag=value, and validation:
references/argument-parsing.md.
Never leave a tempfile or half-written output behind. Create temp paths with
mktemp, register a cleanup trap immediately after, and write atomically.
tmp="$(mktemp)" || exit 1
cleanup() { rm -f "$tmp"; }
trap cleanup EXIT # fires on normal exit, error, and signals via EXIT
build_output >"$tmp" # write to temp
mv -- "$tmp" "$dst" # atomic rename — reader never sees a partial file
trap - EXIT; rm -f "$tmp" # (optional) disarm after successful move
trap cleanup EXIT is the workhorse — EXIT fires for normal exit, set -e
abort, and (in practice) INT/TERM if you let them propagate. Add explicit
trap cleanup INT TERM if you do signal handling yourself.mktemp -d for a temp directory; clean it with rm -rf -- "$tmpdir".tmp + mv (same filesystem). A reader sees either the old
file or the complete new one, never a truncated mid-write — exactly the
idempotency the resource protocol §6 requires.→ Signal handling, ERR-trap with line numbers, nested traps:
references/strict-mode-and-traps.md.
This is the load-bearing rule for any agent-facing script, lifted directly from
SKILL-RESOURCE-PROTOCOL §4–§5. Claude parses stdout; pollution breaks | jq.
--json, else plain/TSV.| Code | Meaning |
|---|---|
0 | success |
2 | usage (bad/missing args, unknown flag) |
3 | not found (input absent) |
4 | validation (input present but malformed) |
5 | precondition (missing dependency, wrong cwd) |
7 | unavailable (external resource down — advisory, not a real failure) |
10+ | domain signal — a non-error "finding" the caller branches on |
Code 10 is the workhorse for verifiers/scanners: "ran fine, found something."
Reserve 7 so a network blip never looks like a content failure. Print human
framing to stderr, the record to stdout:
printf '%s\t%s\n' "$name" "$status" # data → stdout
printf ' [ok] %s checked\n' "$name" >&2 # framing → stderr
→ The shipped assets/script-template.sh bakes this
contract in — copy it as the starting point for any new skill script.
Run shellcheck on every script; it catches the
quoting/word-splitting/set -e bugs above mechanically.
shellcheck script.sh # lint
shellcheck -x script.sh # follow sourced files
shfmt -i 2 -ci -w script.sh # format (2-space indent, indent switch-cases)
# shellcheck disable=SC2086 # word split intended.shellcheck **/*.sh should pass clean before merge.bash -n script.sh is a free syntax-only check (no execution) — run it in tests.| Footgun | Why it bites | Fix |
|---|---|---|
Unquoted $var | Word-split + glob expansion | "$var" always |
[ "$a" == "$b" ] | [ is POSIX test; == non-portable, no && grouping | [[ "$a" == "$b" ]] in Bash |
var=$(cmd) ; echo $? | $? is the assignment's status (always 0), not cmd's | cmd; rc=$? or check inline |
| `cmd | while read x; do total=$x; done` | while runs in a subshell; $total is lost after the pipe |
local x=$(cmd) under set -e | local returns 0, masking cmd failure | local x; x=$(cmd) on two lines |
echo "$x" for arbitrary data | echo mangles -n, -e, backslashes | printf '%s\n' "$x" |
for f in $(ls) | Splits on whitespace, breaks on spaces/newlines | for f in * or while IFS= read -r f |
pipefail + head shows 141 | Downstream closes pipe early (SIGPIPE) | Expected; tolerate 141 from truncating consumers |
→ Each footgun with a reproducer and the underlying mechanism:
references/footguns.md.
Bash is stable, but several common idioms are version-gated. macOS still ships
Bash 3.2 (2007, GPLv2); Linux/CI is usually Bash 5.x. If a script must run on
stock macOS, avoid the 4.x+ features below or guard with ((BASH_VERSINFO[0]>=4)).
| Feature | Introduced | Notes |
|---|---|---|
mapfile / readarray | Bash 4.0 | Read lines into an array. mapfile -t arr < file. -d '' (null-delimited) needs 4.4. |
Associative arrays (declare -A) | Bash 4.0 | Hash maps. Unavailable on macOS stock 3.2. |
${var,,} / ${var^^} (case conversion) | Bash 4.0 | Lowercase/uppercase expansion. |
&>> append-both-streams, ` | &` | Bash 4.0 |
${var@Q} (quote operator) | Bash 4.4 | Produces a re-input-safe quoted form. Also @U @L @E. |
wait -n (any child) | Bash 4.3 | Useful for bounded parallelism. |
local -n (nameref) | Bash 4.3 | Pass array/var by reference into a function. |
When in doubt, state the requirement in the first-comment-block (# Requires: bash 4+)
and check at startup: ((BASH_VERSINFO[0] >= 4)) || { echo "needs bash 4+" >&2; exit 5; }.
#!/usr/bin/env bash + first-comment-block contract (desc, Usage, Exit, Examples)set -Eeuo pipefail (or a deliberate set -uo pipefail) + IFS=$'\n\t'"$@" to forward args; -- before operandscase arg loop; --help exits 0 with EXAMPLES; unknown flag → exit 2trap cleanup EXIT + mktemp; atomic tmp+mv writesshellcheck clean; bash -n passes; chmod +x