Languages No. 101

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.

  • namebash-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

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.

~/.claude/skills/bash-scripting/SKILL.md

--- name: bash-scripting 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… license: MIT ---

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/sh means 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 --force flag, 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

SettingEffectTrap
set -eExit on unhandled non-zero statusIgnored inside if, while, &&/`
set -uUnset variables are errorsUse "${VAR:-}" for optional env vars; "${arr[@]}" on empty arrays errors in bash < 4.4
set -o pipefailPipeline fails if any stage failsgrep exits 1 on no match; head closing early can make the writer fail with SIGPIPE
set -EERR trap inherited by functions and subshellsNeeded 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" over echo "$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 or find -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 unset DIR deletes from /. set -u plus :? guards stop it.
  • Write files atomically: write to "$tmpdir/out" then mv into 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 flock on Linux (exec 9>/var/lock/x; flock -n 9 || die "running"); macOS has no flock by default — use mkdir "$lockdir" as an atomic lock.

Portability

NeedPortable approach
In-place sedsed -i.bak 's/a/b/' f && rm f.bak (works on GNU and BSD)
Script directorycd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd (no readlink -f on old macOS)
Date mathAvoid; GNU date -d and BSD date -v differ. Use Python if needed
Lowercasetr '[:upper:]' '[:lower:]' (${var,,} is bash 4+)
Associative arraysbash 4+ only; not on stock macOS
mapfile/readarraybash 4+ only; use a while read loop
grep -PGNU 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 -x behind a -v flag or DEBUG=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 3 so 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 73 lines · 3.5 KB
# 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.

esc