Bash Scripting skill for Claude Code
Shell scripts that fail loudly and clean up after. Claude starts every script from a hardened template with set -Eeuo pipefail, an ERR trap that names the failing line, temp-dir cleanup, getopts parsing and a dry-run mode. It knows where set -e silently does nothing, builds commands as arrays instead of strings, guards destructive steps against empty variables, and says when a task should be Python instead.
- name
bash-scripting - size194 lines · ≈1,900 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Writes and hardens shell scripts that fail loudly and run the same on Linux, macOS, and CI — strict mode with set -euo pipefail and its traps, correct quoting and arrays, trap-based cleanup, argument parsing, safe temp files, idempotent operations, and shellcheck-clean code, plus when to switch to Python. Use when writing a bash or sh script, reviewing CI or install scripts, fixing word-splitting or globbing bugs, making a script portable to macOS, or running shellcheck. Try asking
- Write a deploy script that syncs a folder to S3 with a dry-run flag
- Make this install script work on both macOS and Ubuntu
- Shellcheck is full of SC2086 warnings — fix this script properly
Use it when
- Writing a new bash or POSIX sh script
- Hardening CI, install, or container entrypoint scripts
- A script breaks on filenames with spaces or on macOS
- Cleaning up shellcheck findings
Skip it when
- Building a GitHub Actions workflow file — use github-actions
- Writing the Dockerfile itself rather than its entrypoint — use dockerfile-optimization
How to check the result
shellcheck -x reports nothing, bash -n passes, the usage and failure paths exit with the documented codes, and a second run changes nothing.
The common mistake it prevents
Trusting set -e inside functions called from if or && chains, or in local var=$(cmd), where failures are silently ignored and the script keeps going.
Done well means: shellcheck reports nothing, the script stops at the first unexpected
failure with a message naming what failed, every variable expansion is quoted, temp files
are cleaned up on any exit, rerunning the script is safe, and --help explains usage.
Ground rules
- Decide the interpreter first. Bash features (arrays,
[[ ]],local,pipefail) require#!/usr/bin/env bash.#!/bin/shmeans POSIX only (dash on Debian/Ubuntu). - Quote every expansion:
"$var","${arr[@]}","$(cmd)". Unquoted is the exception and needs a reason. - Scripts over ~150 lines, or that parse JSON, do arithmetic on floats, or need data structures beyond flat arrays, belong in Python (stdlib) instead.
- Destructive operations (
rm -rf, overwriting files, pushing, deploying) need an explicit confirmation or--forceflag, and guards against empty variables. - macOS ships bash 3.2 and BSD userland. If the script must run there, avoid bash 4+ features or require Homebrew bash explicitly.
Template
#!/usr/bin/env bash
# Purpose: one line describing what this does.
# Usage: ./sync-assets.sh [-n] [-v] <source-dir> <bucket>
set -Eeuo pipefail
shopt -s inherit_errexit 2>/dev/null || true # bash 4.4+: $(...) inherits -e
readonly SCRIPT_NAME="${0##*/}"
# Need the script dir? Assign, then mark readonly on its own line (SC2155):
# SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"; readonly SCRIPT_DIR
log() { printf '%s [%s] %s\n' "$(date -u +%H:%M:%S)" "$SCRIPT_NAME" "$*" >&2; }
die() { log "ERROR: $*"; exit 1; }
usage() {
cat <<EOF
Usage: $SCRIPT_NAME [-n] [-v] <source-dir> <bucket>
-n dry run: print actions without executing
-v verbose (set -x)
-h show this help
EOF
}
on_error() { log "failed at line $1: $2 (exit $3)"; }
trap 'on_error "$LINENO" "$BASH_COMMAND" "$?"' ERR
tmpdir="$(mktemp -d)"
cleanup() { rm -rf -- "$tmpdir"; }
trap cleanup EXIT
dry_run=0
while getopts ":nvh" opt; do
case "$opt" in
n) dry_run=1 ;;
v) set -x ;;
h) usage; exit 0 ;;
:) die "option -$OPTARG requires an argument" ;;
*) usage >&2; exit 2 ;;
esac
done
shift $((OPTIND - 1))
[[ $# -eq 2 ]] || { usage >&2; exit 2; }
src="$1"; bucket="$2"
[[ -d "$src" ]] || die "source dir not found: $src"
command -v aws >/dev/null || die "aws CLI not installed"
run() { if (( dry_run )); then log "DRY: $*"; else "$@"; fi; }
main() {
log "syncing $src -> s3://$bucket"
run aws s3 sync "$src" "s3://$bucket" --only-show-errors
log "done"
}
main "$@"
Exit codes: 0 success, 1 runtime failure, 2 usage error. Logs go to stderr so stdout stays clean for data.
Strict mode: what it does and where it lies
| Setting | Effect | Trap |
|---|---|---|
set -e | Exit on unhandled non-zero status | Ignored inside if, while, &&/` |
set -u | Unset variables are errors | Use "${VAR:-}" for optional env vars; "${arr[@]}" on empty arrays errors in bash < 4.4 |
set -o pipefail | Pipeline fails if any stage fails | grep exits 1 on no match; head closing early can make the writer fail with SIGPIPE |
set -E | ERR trap inherited by functions and subshells | Needed for the ERR trap to fire inside functions |
# set -e does not catch this: local masks the exit status
local out=$(failing_cmd) # wrong
local out; out=$(failing_cmd) # right
# Expected non-zero: say so explicitly
if ! grep -q "pattern" file; then log "no match"; fi
count=$(grep -c "x" file || true)
Quoting and arrays
files=( "$dir"/*.log ) # glob into an array; quote the directory part
[[ -e "${files[0]}" ]] || files=() # no matches: glob stays literal unless nullglob
for f in "${files[@]}"; do gzip -- "$f"; done
args=( --region "$region" --profile "$profile" )
[[ -n "${DRY_RUN:-}" ]] && args+=( --dryrun )
aws s3 cp "${args[@]}" "$src" "$dst" # build commands as arrays, never as strings
while IFS= read -r line; do # read lines safely (keeps spaces/backslashes)
printf '%s\n' "$line"
done < "$input"
find . -name '*.tmp' -print0 | while IFS= read -r -d '' f; do rm -- "$f"; done
printf '%s\n' "$x"overecho "$x"(echo mangles-n,-e, backslashes).--before filenames that may start with-.[[ ]]in bash: no word splitting inside, supports=~and&&.[ ]in POSIX sh.$(...)not backticks.$(( ))for integer math.- Never parse
ls. Use globs orfind -print0.
Safe destructive operations
: "${TARGET_DIR:?TARGET_DIR must be set}" # abort if empty or unset
[[ "$TARGET_DIR" != "/" && "$TARGET_DIR" == /srv/app/* ]] || die "refusing to delete $TARGET_DIR"
read -r -p "Delete $TARGET_DIR? [y/N] " reply
[[ "$reply" == [yY] ]] || die "aborted"
rm -rf -- "$TARGET_DIR"
rm -rf "$DIR/"*with an unsetDIRdeletes from/.set -uplus:?guards stop it.- Write files atomically: write to
"$tmpdir/out"thenmvinto place (same filesystem). - Idempotency:
mkdir -p,ln -sfn,grep -qxF "$line" file || echo "$line" >> file, check-before-create for cloud resources. - Lock against concurrent runs with
flockon Linux (exec 9>/var/lock/x; flock -n 9 || die "running"); macOS has noflockby default — usemkdir "$lockdir"as an atomic lock.
Portability
| Need | Portable approach |
|---|---|
| In-place sed | sed -i.bak 's/a/b/' f && rm f.bak (works on GNU and BSD) |
| Script directory | cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd (no readlink -f on old macOS) |
| Date math | Avoid; GNU date -d and BSD date -v differ. Use Python if needed |
| Lowercase | tr '[:upper:]' '[:lower:]' (${var,,} is bash 4+) |
| Associative arrays | bash 4+ only; not on stock macOS |
mapfile/readarray | bash 4+ only; use a while read loop |
grep -P | GNU only; use grep -E |
For the full GNU vs. BSD vs. POSIX difference list, read
references/portability.md when the script must run on
macOS, Alpine (busybox), or /bin/sh.
CI and container scripts
- Print the commands that matter (
set -xbehind a-vflag orDEBUG=1), never secrets. Turn tracing off around secret handling:{ set +x; } 2>/dev/null. - Pass secrets through env vars or files, not arguments (visible in
ps). curl -fsSL --retry 3so HTTP errors fail the script; verify downloaded checksums (sha256sum -c/shasum -a 256 -c).exec "$@"at the end of container entrypoints so signals reach the main process.
Workflow
- [ ] 1. Choose bash vs sh vs Python (size, data structures, portability target)
- [ ] 2. Start from the template: strict mode, traps, usage, arg parsing
- [ ] 3. Validate inputs and required tools up front
- [ ] 4. Guard destructive steps; add a dry-run mode
- [ ] 5. shellcheck until clean; fix, do not disable, unless a directive has a comment
- [ ] 6. Test the failure paths: missing args, missing tool, failing command mid-run
- [ ] 7. Run twice to prove idempotency
Verification
shellcheck -x -S style script.sh # -x follows sourced files
bash -n script.sh # syntax check
shfmt -d -i 2 script.sh # formatting, if shfmt is available
./script.sh -h; ./script.sh; echo "exit=$?" # usage and missing-arg path exits 2
Disable a shellcheck rule only inline with a reason:
# shellcheck disable=SC2086 # intentional word splitting of $FLAGS.
Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/portability.md
# Shell portability reference
Targets: GNU/Linux (bash 5, GNU coreutils), macOS (bash 3.2 at /bin/bash, zsh default
login shell, BSD userland), Alpine (busybox ash at /bin/sh), Debian/Ubuntu (/bin/sh is
dash).
## Bash version features
| Feature | Min bash | Stock macOS 3.2? |
|---|---|---|
| `[[ ]]`, arrays, `$(( ))`, `local` | 2.x/3.x | yes |
| `+=` on arrays/strings | 3.1 | yes |
| `=~` regex in `[[ ]]` | 3.0 (quote rules changed in 3.2) | yes |
| Associative arrays `declare -A` | 4.0 | no |
| `mapfile` / `readarray` | 4.0 | no |
| `${var,,}` / `${var^^}` case conversion | 4.0 | no |
| `|&`, `coproc`, `globstar` (`**`) | 4.0 | no |
| `${arr[@]}` on empty array under `set -u` OK | 4.4 | no (errors) |
| `inherit_errexit` | 4.4 | no |
| `wait -n` | 4.3 | no |
| `$EPOCHSECONDS`, `$EPOCHREALTIME` | 5.0 | no |
Empty-array-safe expansion for old bash under `set -u`: `${arr[@]+"${arr[@]}"}`.
## POSIX sh (dash, busybox ash) — not available
- Arrays (use `set --` positional params as the single array), `[[ ]]`, `local` (dash
supports it but POSIX does not), `function` keyword, `source` (use `.`), `==` in `[ ]`
(use `=`), `$'...'` strings (in POSIX 2024 but not universal), brace expansion `{1..5}`,
`pipefail` (dash added it recently; busybox has it; do not rely on it), `<<<` here-strings,
process substitution `<( )`, `read -a`, `echo -e`.
## GNU vs BSD utilities
| Task | GNU (Linux) | BSD (macOS) | Portable |
|---|---|---|---|
| In-place edit | `sed -i 's/a/b/' f` | `sed -i '' 's/a/b/' f` | `sed -i.bak ... && rm f.bak`, or write to temp + mv |
| Extended regex in sed | `sed -E` (also `-r`) | `sed -E` | `sed -E` |
| Date from string | `date -d '2025-01-01' +%s` | `date -j -f '%Y-%m-%d' 2025-01-01 +%s` | Python / avoid |
| Relative date | `date -d '-1 day'` | `date -v-1d` | Python / avoid |
| Resolve symlinks | `readlink -f` | `readlink -f` only on macOS 12.3+ | `cd "$(dirname ...)" && pwd -P` |
| File size | `stat -c %s f` | `stat -f %z f` | `wc -c < f` |
| mtime | `stat -c %Y f` | `stat -f %m f` | `find f -newer ref` comparisons |
| Checksum | `sha256sum` | `shasum -a 256` | test with `command -v` and branch |
| Base64 decode | `base64 -d` | `base64 -D` (newer accepts `-d`) | `openssl base64 -d` |
| Sort versions | `sort -V` | `sort -V` on recent macOS | avoid for critical paths |
| xargs no-run-if-empty | `xargs -r` | default behavior, no `-r` on old | guard input first |
| grep Perl regex | `grep -P` | not available | `grep -E` |
| Temp dir | `mktemp -d` | `mktemp -d` (needs template on very old) | `mktemp -d "${TMPDIR:-/tmp}/x.XXXXXX"` |
| Lock | `flock` | not installed | `mkdir lockdir` atomic lock + trap rmdir |
| timeout | `timeout 10 cmd` | not installed (coreutils `gtimeout`) | background + kill loop, or Python |
## Detecting the platform
```bash
case "$(uname -s)" in
Linux) sed_inplace() { sed -i "$@"; } ;;
Darwin) sed_inplace() { sed -i '' "$@"; } ;;
*) echo "unsupported OS" >&2; exit 1 ;;
esac
```
## Line endings and encodings
- Scripts with CRLF fail with `$'\r': command not found`. Fix with
`sed -i.bak 's/\r$//' script.sh` and add `*.sh text eol=lf` to `.gitattributes`.
- Set `LC_ALL=C` for byte-wise `sort`, `tr`, and `grep` behavior that matches across
machines; leave it unset when handling user-facing UTF-8 text.
## zsh gotchas when users source your script
- zsh arrays are 1-indexed and unquoted `$var` does not word-split. Scripts should
always run with an explicit bash shebang, not be sourced into zsh, unless written for it.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.