From ct
Deep bash correctness — `set -euo pipefail` traps, IFS/word-splitting, trap inheritance, process substitution, array gotchas, version-gated features, ShellCheck "looks right but isn't" patterns. Load when writing or hardening non-trivial bash scripts (installers, CI, unattended ops). Skip for basic if/while/for, one-shot interactive commands, or zsh/fish/POSIX-sh work. Triggers on: "set -e not triggering", "pipefail", "trap ERR", "PIPESTATUS", "macOS bash 3.2", "shellcheck SC2086", "harden bash script", "robust bash".
How this skill is triggered — by the user, by Claude, or both
Slash command
/ct:bash-hardeningThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Concise correctness pointers for non-trivial bash. Surface the traps LLMs reliably miss when emitting "robust" scripts.
Concise correctness pointers for non-trivial bash. Surface the traps LLMs reliably miss when emitting "robust" scripts.
Assumes you can already write if/while/for, run chmod +x, and read a stack trace. This skill covers the correctness layer — the parts models gloss over: set -e exceptions, IFS, quoting, parameter expansion, trap inheritance, array gotchas, version differences.
Load when the task is:
set -euo pipefail or strict-mode hardeningDo NOT load for: trivial one-liners, interactive shell use, "what is $PATH", POSIX-sh-only constraints (dash/ash), zsh/fish — those don't need this skill.
set -euo pipefail — what each option missesset -e (errexit) does not trigger when the failing command is:
&& / || / ! chains. cmd1 && cmd2 — if cmd1 fails, script continues.if, while, until. if cmd; then ... masks cmd's failure.foo=$(cmd_that_fails) — exit status discarded. Use set -o errexit; foo=$(cmd) || exit or check $? immediately.cat <(cmd_that_fails) — exit silently discarded.set -E (errtrace) propagates the ERR trap into functions, command substitutions, subshells. Pair set -eE if you have an ERR trap — without -E, the trap fires only at top level.
set -u (nounset) edge cases:
${var:-default} and ${var-default} are safe under -u."${arr[@]}" of an empty array errors under -u in bash 4.3 and earlier (and was buggy through 5.0). Defensive form: "${arr[@]:+${arr[@]}}" or "${arr[@]+${arr[@]}}".arr=() — never assume declare -a arr is enough under -u.set -o pipefail:
false | true exits 0 (only last command's status counts).${PIPESTATUS[@]} immediately after the pipeline (resets on next command).(( expr )) and let return non-zero when the result is zero. Under set -e, (( count = 0 )) aborts the script. Defensive form: (( count = 0 )) || true or count=0 plain assignment.
local var=$(cmd) masks cmd's exit code — local itself returns 0. Same for declare, export, readonly. Split: local var; var=$(cmd).
Recommended top-of-script: set -Eeuo pipefail; IFS=$'\n\t'.
$' \t\n' (space, tab, newline). Unquoted expansions are split on any of these, then glob-expanded.IFS=$'\n\t' (Bash strict-mode idiom) defangs space-splitting — filenames with spaces survive for f in $files. Cost: any code that genuinely splits on spaces breaks.local IFS=... inside functions."${arr[*]}" joins elements with the first character of IFS (so with default IFS, a space). "${arr[@]}" keeps elements separate. Different operations, never interchangeable."$@" (quoted) preserves each positional arg as one word; $@ unquoted word-splits — same trap as $*.read honours IFS. while IFS= read -r line strips nothing — quoted-empty IFS preserves leading/trailing whitespace; -r disables backslash interpretation."$var", "${arr[@]}", "$(cmd)", "${var/old/new}". Unquoted = word-split + glob.[[ ]] does not word-split RHS — [[ $foo = bar ]] works without quotes around $foo. [ ] does word-split — quote or you'll get "too many arguments" on values with spaces or empty values.[[ $a = $b ]], $b is treated as a glob pattern. Quote to compare literally: [[ $a = "$b" ]]. Inside =~, quoting the RHS makes it literal — for regex, leave RHS unquoted (or store the pattern in a variable and reference unquoted).`cmd` — use $(cmd) instead. Nestable, no backslash-escape weirdness."~/file" is literal. Use "$HOME/file".<<EOF expands $var and $(cmd). <<'EOF' (quoted delimiter) treats body literally. <<-EOF strips leading tabs only (not spaces) — useful for indentation in scripts.[[ ]] vs [ ] vs (( ))[[ ]] — bash builtin. No word splitting on RHS, regex =~, glob match in ==/!= (don't quote pattern), short-circuit && / ||. Bash/ksh/zsh only — not POSIX.[ ] — equivalent to /usr/bin/test. Subject to word splitting; must quote vars. POSIX. Avoid -a/-o (deprecated, ambiguous) — chain with &&/||: [ -e "$f" ] && [ -r "$f" ].(( )) — arithmetic. Integer math, C-style (++, **, %, ternary). Variables auto-dereferenced — (( x = y + 1 )) not (( x = $y + 1 )). Returns non-zero when result is 0 — see set -e interaction above.${var:-default} — default if unset or empty.${var-default} — default if unset only (empty stays empty).${var:=default} — assign default if unset/empty.${var:?msg} — print msg to stderr and exit if unset/empty. Useful for required-arg checks.${var:+alt} — alt if set/non-empty (inverse of :-).${#var} — string length. ${#arr[@]} — array element count.${var:offset:length} — substring. Negative offset needs space or parens: ${var: -3} or ${var:(-3)}.${var#pat} — strip shortest prefix match. ${var##pat} — longest. (Mnemonic: # is at start of comments.)${var%pat} — strip shortest suffix. ${var%%pat} — longest. (Mnemonic: % is at the end.)${var/pat/repl} — replace first match. ${var//pat/repl} — replace all. ${var/#pat/repl} — match at start. ${var/%pat/repl} — match at end.${var^^} / ${var,,} — upper/lower (bash 4+). ${var^} / ${var,} — first char only.${!prefix*} / ${!prefix@} — names of variables matching prefix. ${!arr[@]} — array indices/keys.arr=(a b "c d"). Access: ${arr[0]}, all elements "${arr[@]}", indices "${!arr[@]}", count ${#arr[@]}.declare -A m; m[key]=val — bash 4.0+. macOS default bash is 3.2.57 (Apple stuck at last GPLv2 release) — brew install bash for 5+ or fall back to parallel indexed arrays.arr+=(d e). Single-element append: arr+=(x) not arr+=x (the latter concatenates to element 0)."${arr[@]}" — each element a separate word. "${arr[*]}" — single string joined by IFS[0]. Different.mapfile -t lines < file (or readarray) — read file into array, one line per element, -t trims newline. Bash 4+.arr=value assigns to index 0 of an existing array — silent footgun if you meant to overwrite.trap 'cmd' EXIT INT TERM HUP ERR. Last trap for a signal wins — they don't stack. To chain, read trap -p SIG and prepend.EXIT fires on any exit (clean, error, signal-after-handler). Idiomatic cleanup:
tmpdir=$(mktemp -d)
trap 'rm -rf -- "$tmpdir"' EXIT
ERR fires only when set -e would. Without set -E (errtrace), it does not fire inside functions, command substitutions, or subshells — even though the failing command is "in" the script.set -T (functrace) similarly inherits DEBUG and RETURN traps into functions.( cmd ), command substitution, pipeline segments) reset signal traps to their inherited disposition unless -E/-T set; INT/TERM are reset to default unless explicitly re-trapped.trap '' INT; critical_cmd; trap - INT (- restores default).<(cmd) — bash makes cmd's stdout available as a file path (/dev/fd/N or named FIFO). >(cmd) — same but for stdin. Not POSIX — bash/ksh/zsh only; don't use under #!/bin/sh.cat <(false) — cat reports success.cmd | while read x; do count=$((count+1)); done runs the loop in a subshell — count outside is unchanged. Rewrite as while read x; do ...; done < <(cmd) to keep the loop in the parent shell.shopt -s lastpipe (bash 4.2+, only when job control is off — i.e. non-interactive scripts) makes the last pipeline segment run in the parent.for f in *.txt iterates once with f="*.txt" if no .txt files exist.shopt -s nullglob — unmatched glob expands to nothing. Loop iterates zero times. Sane default for scripts.shopt -s failglob — unmatched glob is a hard error.shopt -s globstar — ** matches recursively. Bash 4+.shopt -s extglob — ?(...), *(...), +(...), @(...), !(...) extended patterns.shopt -s dotglob — globs match dotfiles.cmd | while read; do ...; done — loop runs in subshell; mutations don't escape. Fix: while read; do ...; done < <(cmd) or shopt -s lastpipe.eval "$user_input" — almost always wrong. Replace with arrays, declare -n namerefs (bash 4.3+), or restructure.[ "$a" = "$b" -a "$c" = "$d" ] — -a/-o are deprecated and ambiguous. Use [ "$a" = "$b" ] && [ "$c" = "$d" ] or [[ $a = $b && $c = $d ]].cd $dir; rm -rf * without quotes — word-splits and globs $dir. Quote: cd "$dir".A && B || C is not if-then-else. If A succeeds and B fails, C runs anyway. Use a real if.local var=$(cmd) — local masks cmd's exit. Split into two lines.echo -n / echo -e — non-portable (BSD echo differs). Use printf '%s' "$var" / printf '%s\n' "$var".find . -name '*.tmp' | xargs rm — breaks on filenames with spaces/newlines. Use find ... -print0 | xargs -0 rm or find ... -delete or find ... -exec rm {} +.$( ).LC_ALL=C sort (and grep, comm, uniq, awk) — deterministic byte-order, faster, avoids locale-dependent collation surprises (A vs a ordering, [a-z] not matching what you expect).LC_NUMERIC=C printf '%f' 1.5 — decimal point stays .. Without it, locales like de_DE use , and printf errors.#!/bin/bash vs #!/usr/bin/env bash — the latter follows $PATH, picks up brew install bash's 5.x on macOS instead of the 3.2.57 in /bin/bash.mapfile, no ${var^^}, no ** globstar, no &>>. Either require bash 4+ in your shebang/preamble or write to 3.2 floor.SC2086 — quote variable expansions to prevent globbing/word-splitting.SC2046 — quote command substitution to prevent same.SC2155 — declare-and-assign masks exit code (local x=$(cmd)).SC2015 — A && B || C is not if-then-else.SC2128 — using array name without index gets element 0 only.SC2178 — assigning string to array variable.SC2207 — use mapfile or array assignment instead of arr=( $(cmd) ).SC2034 — variable defined but never used (often a typo).shfmt -d for diff-only check.set -x — trace. Pair with PS4='+ ${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]:-main}: ' for richer output.Official Bash manual (gnu.org/software/bash/manual):
Greg's Wiki / BashFAQ (mywiki.wooledge.org):
set -e do what I expected?ShellCheck wiki — every SCxxxx has its own page at shellcheck.net/wiki/SCxxxx (e.g. SC2155, SC2015).
Before recommending a non-trivial bash pattern (strict-mode preamble, trap, array idiom, parameter-expansion trick):
shellcheck on the produced script and address every finding — silence only with an inline # shellcheck disable=SCxxxx plus a one-line reason.A bash script that passes ShellCheck is the floor, not the ceiling.
npx claudepluginhub pvillega/claude-templates --plugin ctShell 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.
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.
Provides core patterns and best practices for Bash 5.1+ script development: set -euo pipefail, trap error handling, argument parsing, variable usage, pure-bash string/array ops, and file operations.