bash
Writes, debugs, and hardens Bash shell scripts — quoting, arrays, strict mode, traps, argument parsing, and macOS/Linux portability. Use when writing or reviewing any script, one-l…
Iván
@ivangdavila
Install
$ openclaw skills install @ivangdavila/bashUser preferences live in ~/Clawic/data/bash/config.yaml (see Configuration); nothing else is stored on the user's machine. If you have data at an old location (~/bash/ or ~/clawic/bash/), move it to ~/Clawic/data/bash/.
When To Use
- Writing or reviewing any Bash beyond a one-liner: CI steps, deploy scripts, cron tasks, entrypoints, glue code
- Debugging scripts that break on spaces in filenames, fail silently, hang, or exit with the wrong code
- Hardening an existing script: strict mode, cleanup traps, argument parsing, re-runnability, portability
- Porting a script between macOS and Linux, or down to POSIX sh
- Deciding whether the task belongs in Bash at all (Core Rule 9)
- Not for POSIX-sh-only targets (dash, busybox, alpine
/bin/sh) — most patterns here are bashisms;portability.mdcovers the downgrade
Quick Reference
| Situation | Play |
|---|---|
| Breaks on spaces or hostile filenames | Quote every expansion, iterate with find -print0 + while IFS= read -r -d '' → quoting.md |
set -e missed a failure, cleanup never ran | The five blind spots (conditions, ||/&&, $( ), ! cmd, exit in a subshell) → errors.md |
| Pipeline "fails" but each command worked | Exit code 141 = SIGPIPE from an early-exit consumer; PIPESTATUS names the segment → errors.md |
| Wrong output and you cannot see why | PS4='+ ${BASH_SOURCE##*/}:${LINENO}: ' bash -x script → debugging.md |
| Command built from variables misfires | Build it as an array (cmd=(rsync -a); cmd+=(--dry-run); "${cmd[@]}"), never as a string → quoting.md |
Comparison wrong: [ vs [[, numeric vs lexical | [[ 10 < 9 ]] is TRUE (lexical); numbers belong in (( )) or -lt → conditionals.md |
| Runs fine by hand, fails from cron | Cron has no login shell: minimal PATH, no profile, $HOME as cwd, % means newline → cron.md |
| Works locally, fails in the CI runner | Each step is a fresh non-interactive shell; strict mode does not carry over → ci.md |
| Script takes minutes on a large file | Count forks: one external command per line is the cost — batch into awk/sort → performance.md |
unbound variable / bad substitution / ambiguous redirect | Symptom→cause chains → debugging.md |
| Must run on macOS stock bash or an old server | Version Floors below, then GNU-vs-BSD flags → portability.md |
Flags, --help, subcommands, usage exit codes | getopts with a silent optstring, then shift $((OPTIND-1)) → arguments.md |
| Redirection order, heredocs, one-instance locking | Redirections apply left to right before the command runs → redirection.md |
| Paths, globs, temp files, deletes that must be safe | Resolve once with cd … && pwd -P; write temp + mv → files.md |
| Parsing CSV/JSON/logs, choosing awk vs sed vs jq | Per-line and stateless → one awk pass; never grep JSON → text-processing.md |
| Background jobs, signals, timeouts, N in parallel | pid=$! then wait "$pid"; xargs -P for fan-out → processes.md |
| String surgery: defaults, trim, replace, basename | Builtin expansions, no forks → expansion.md |
| Lists, dictionaries, sets, counters | mapfile -t to load, declare -A for maps → arrays.md |
| Splitting into functions or a sourced library | main "$@" behind a BASH_SOURCE guard; scope is dynamic → functions.md |
| Prompts, confirmations, color, progress | Gate every one of them on [[ -t 1 ]] → interactive.md |
| Calling an API, webhook, or health check | curl exits 0 on a 500 — capture %{http_code} and branch → http.md |
| Untrusted input, secrets, temp-file races, sudo | Keep values as data, never as syntax → security.md |
| Adding tests, stubbing commands, lint in CI | bash -n, shellcheck, then bats with PATH stubs → testing.md |
| Anything else | Core Rules below, then reproduce with bash -x on the smallest input that still fails |
Each file above is one sub-job and is self-contained: read SKILL.md by default, open exactly one guide when the situation matches.
Core Rules
- Open every script with
#!/usr/bin/env bashandset -euo pipefail, then learn the-eholes (errors.md) instead of dropping strict mode — the holes are enumerable; silent failures are not. - Quote every expansion:
"$var","$(cmd)","${arr[@]}". An unquoted expansion is a deliberate act that carries a comment saying why. Word splitting plus globbing is Bash's #1 bug class (shellcheck SC2086). - Build commands as arrays, never as strings.
opts="--exclude '*.log'"; rsync $opts src dstpasses the quotes as literal characters;opts=(--exclude '*.log'); rsync "${opts[@]}" src dstpasses--excludeand*.logas two clean arguments. Conditional flags append:[[ $dry == 1 ]] && opts+=(--dry-run). - Run shellcheck before shipping, blocking at
lint_gateseverity. Suppress only with the code and a reason on the same line:# shellcheck disable=SC2086 -- flags must split. - Know your floor: macOS
/bin/bashis 3.2 forever (GPLv3 freeze). If the script uses anybash >=4.0feature (Version Floors), state the floor in a header comment and enforce it:((BASH_VERSINFO[0] >= 4)) || { echo "needs bash 4+" >&2; exit 1; }. - Never parse
ls. Iterate with globs orfind -print0: filenames may contain newlines, so NUL is the only delimiter a filename cannot contain. - Test the failure path before delivering: swap one command for
false, confirm the script stops, the trap fires, and the exit code is nonzero. A cleanup you never saw run is a cleanup you do not have. - Untrusted input never reaches
eval, arithmetic, or array subscripts:(( $userinput ))executes commands viaarr[$(cmd)]subscripts. Gate with a regex first:[[ $n =~ ^[0-9]+$ ]] || die "not a number: $n". - Past
rewrite_thresholdlines (default 100, the Google Shell Style Guide cutoff) or once you need nested data structures, rewrite in Python or similar. Bash orchestrates processes; it does not model data.
Script Skeleton
#!/usr/bin/env bash
# Requires bash >= 4.4 (inherit_errexit). Run: script.sh [-n] <target>
set -euo pipefail
shopt -s inherit_errexit 2>/dev/null || true # bash >=4.4: $(cmd) failures propagate
die() { printf '%s\n' "$*" >&2; exit 1; }
tmp=$(mktemp) || die "mktemp failed"
trap 'rm -f "$tmp"' EXIT # single-quoted: expands when it FIRES, not now
# fires on error and normal exit; kill -9 bypasses all traps
Quoting
"$var","$(cmd)","${arr[@]}"— always.$(cmd)strips ALL trailing newlines, not just one.- Single quotes are literal;
$'...'interprets escapes:$'\t',$'\r',$'\0'. - Filenames from variables get
--or./:rm -- "$f"survives a file named-rf. echo "$var"breaks when var is-n,-e, or has backslashes —printf '%s\n' "$var"never does.- Arguments to
ssh/su -c/bash -care re-parsed by the receiving shell — build them withprintf '%q '(quoting.md). ${arr[*]}joins with the first char of IFS into one word;"${arr[@]}"preserves elements. Joining is the only reason to write[*].
Version Floors
| Feature | Needs |
|---|---|
printf -v var, += append | bash >=3.1 |
declare -A, mapfile, ${var^^}/${var,,}, globstar, ;& fallthrough, |& | bash >=4.0 |
[[ -v var ]], shopt -s lastpipe, declare -g | bash >=4.2 |
${arr[-1]}, declare -n namerefs, wait -n | bash >=4.3 |
inherit_errexit, ${var@Q}, mapfile -d, empty "${arr[@]}" safe under set -u | bash >=4.4 |
EPOCHSECONDS/EPOCHREALTIME, SRANDOM (5.1) | bash >=5.0 |
macOS /bin/bash stays at 3.2. #!/usr/bin/env bash finds a Homebrew bash on PATH; #!/bin/bash never will. Check at runtime with BASH_VERSINFO, not by parsing bash --version.
Exit Codes
Formula: a code above 128 means killed by signal code − 128. Codes are mod 256 — exit 256 reports 0, exit -1 reports 255.
| Code | Meaning | First move |
|---|---|---|
| 1 | Generic failure — also (( expr )) evaluating to 0 | Read the last command, then the (( traps below |
| 2 | Shell syntax or builtin usage error | bash -n script locates it; conventionally also "wrong CLI usage" (arguments.md) |
| 126 | Found but not executable | chmod +x, or the shebang interpreter is not executable |
| 127 | Command not found | PATH (the cron classic), typo, or a missing shebang interpreter ("bad interpreter") |
| 130 | SIGINT (128+2) | User pressed Ctrl-C — propagate it, do not swallow it |
| 137 | SIGKILL (128+9) | OOM killer or kill -9; no trap ever ran, so cleanup did not happen |
| 141 | SIGPIPE (128+13) | A consumer (head, grep -q) closed the pipe early — usually success misread as failure |
| 143 | SIGTERM (128+15) | Orderly external stop (systemd, CI timeout) — trap it to clean up |
| 124 | GNU timeout expired (125 = timeout itself failed) | Raise the timeout or fix the hang (processes.md) |
| 255 | ssh transport error, and any exit with a negative or >255 value wrapped | Distinguish ssh's own failure from the remote command's |
Subshells and State
- Every pipe segment runs in a subshell:
cmd | while read -r x; do ((n++)); donelosesn. Fix:done < <(cmd), orshopt -s lastpipe(bash >=4.2, scripts only). ( )is a subshell,{ ...; }is the current shell —exitinside( )or$( )exits only that subshell.- Background jobs:
cmd & pid=$!thenwait "$pid"—waitreturns the job's exit code, your only way to check it. cdinside( )to visit a directory without having tocdback.
Robust Iteration
- Globs:
shopt -s nullglobfirst — otherwisefor f in *.txtin an empty dir runs once with the literal string*.txt. - Hostile filenames or recursion:
while IFS= read -r -d '' f; do ...; done < <(find . -name '*.log' -print0). - Lines of a file:
while IFS= read -r line; do ...; done < file—IFS=keeps leading whitespace,-rkeeps backslashes. A final line without a trailing newline is still skipped: append|| [[ -n $line ]]to the read. - Any command inside the loop that reads stdin (
ssh,ffmpeg,mysql) eats the rest of the input and the loop ends after one pass — passssh -nor redirect< /dev/null. - Split a string:
IFS=, read -ra fields <<< "$csv". Join:(IFS=,; echo "${arr[*]}")— the subshell keeps the IFS change local.
Output Gates
Before delivering any script, check:
- Every expansion quoted, or the unquoted one carries a comment saying why
- Commands with variable flags built as arrays, not concatenated strings
- shellcheck clean at
lint_gate, or each disable names its SC code and reason - Failure path exercised: injected
false, watched the trap fire and the exit code go nonzero - Bash floor stated in a header comment and matching
bash_floorif anybash >=4.0feature is used - No
eval; no unvalidated input inside(( ))or array subscripts - Re-runnable: a run that dies halfway leaves nothing half-written — temp file plus
mv,mkdir -p,rm -f - Destructive steps gated per
destructive_confirm; no secret can appear inset -xoutput orps
Configuration
User-dependent variables. Defaults apply until the user states a preference; store them in ~/Clawic/data/bash/config.yaml. Never interview the user — record a preference the moment it is stated.
| Variable | Type | Default | Effect |
|---|---|---|---|
| bash_floor | 3.2 | 4.4 | 5.x | 4.4 | Gates which Version Floors features may be used unguarded; 3.2 bans mapfile, declare -A, namerefs and emits the portable fallbacks instead |
| target_os | linux | macos | both | both | Picks GNU or BSD flag forms in every emitted command (sed -i, date, stat, readlink); both restricts to the intersection (portability.md) |
| strict_mode | set-euo | explicit-checks | set-euo | Chooses the Script Skeleton and which school reviews enforce (Where Experts Disagree) |
| lint_gate | error | warning | style | none | warning | Severity at or above which shellcheck findings block delivery (shellcheck -S <value>); Output Gates use it |
| rewrite_threshold | number (lines) | 100 | Length at which Core Rule 9 recommends another language |
| indent_style | 2-spaces | 4-spaces | tabs | 2-spaces | Formatting of emitted scripts and the shfmt -i value |
| destructive_confirm | bool | true | Emitted scripts guard deletes, overwrites, and remote pushes behind --yes or a dry-run pass |
Preference areas — customizable dimensions; a stated preference gets recorded in config.yaml and applied:
- Tooling: shellcheck/shfmt/bats availability, GNU coreutils on macOS (
gsed,gdate), jq vs python for JSON — affectstesting.mdgates and every parsing example - Conventions: function and variable naming, usage/help layout, log line format, script header content — affects
functions.mdandarguments.mdoutput - Platform: bash floor and OS mix, POSIX-sh-only targets, alpine images where
/bin/shis ash and bash may be absent — affectsportability.mdguidance - Safety posture: whether scripts may
sudo, dry-run first, banned constructs (eval,curl | sh,rm -rfon a variable) — affectssecurity.mdand destructive workflows - Runtime home: where the script actually runs unattended — cron, systemd timer, launchd, CI runner, container entrypoint — affects
cron.mdandci.mdadvice - Output: verbosity, color only when the output is a TTY, timestamps, quiet or machine-readable logging — affects
interactive.mdand logging helpers
Traps
| Trap | Why it fails | Do instead |
|---|---|---|
local out=$(cmd) | local returns 0, masking cmd's failure — set -e never fires | local out; out=$(cmd) |
((count++)) when count is 0 | expression evaluates to 0 → exit status 1 → set -e kills the script | count=$((count+1)) |
grep -q downstream under pipefail | early exit sends SIGPIPE upstream; producer dies with 141 (128+13) and the pipeline "fails" on success | capture first: out=$(cmd), then grep the variable |
rm -rf "$dir/" | empty/unset dir → rm -rf / | rm -rf "${dir:?}/" aborts if empty |
trap "rm -rf $tmp" EXIT (double quotes) | the body expands NOW, when tmp may still be empty — you registered rm -rf | single quotes: trap 'rm -rf "$tmp"' EXIT |
Checking $? after a log line | the echo overwrote it | rc=$? on the very next line |
which cmd to test existence | external, output format varies (SC2230) | command -v cmd >/dev/null |
cd "$dir" without a check | without -e, everything after runs in the wrong directory | cd "$dir" || exit 1 — habit survives scripts that lack -e |
sudo cmd > /root/out | the redirection is performed by YOUR shell before sudo runs — permission denied | cmd | sudo tee /root/out >/dev/null |
set -euo pipefail in a sourced library | it mutates the caller's shell and breaks their error handling | set options in executables only; libraries return codes (functions.md) |
Where Experts Disagree
set -e: the strict-mode school makes it mandatory; the Google Shell Style Guide school argues its exceptions (conditions,||, command substitution) make it false comfort and prefers explicit|| die. Boundary: short glue scripts → strict mode; sourced libraries and functions whose return codes callers inspect → explicit handling. Never mix philosophies in one file.- Bash vs POSIX sh: write sh only when the target set actually contains dash/busybox/alpine. "Portable by default" costs arrays,
[[ ]], andset -o pipefailfor hosts you may never meet. - Returning values from functions: print to stdout and capture (composable, costs a fork per call) vs write through a nameref or a documented global (no fork, couples caller and callee). Boundary: hot loops and large payloads → nameref (
bash >=4.3); everything else → stdout. - Long options: GNU
getopt(1)parses them but does not exist usably on macOS (BSD getopt has no long options); a hand-rolledwhile/caseloop is portable and you own the error messages. Boundary: Linux-only tooling →getopt; anything shipped to laptops → hand-rolled (arguments.md).
Related Skills
More Clawic skills, get them at https://clawic.com/skills/bash (install if the user confirms):
linux— when the bug is the system, not the script: permissions, cron daemon config, systemd units, OOMregex— when the=~pattern itself is the hard partgithub-actions— when the script lives in CI and the failure is workflow wiring, not shellcli-design— when the question is the tool's interface: subcommand layout, flag naming, help text contract
Part of Clawic, the verified skill library. Get this skill: https://clawic.com/skills/bash.
Related skills
linux
@ivangdavilaDebugs and hardens Linux hosts: permissions, disk full, OOM kills, stuck processes, systemd units, cron, networking, SSH, and boot failures. Use when a service starts by hand but fails at boot, a process ignores kill -9, df and du disagree, a box runs out of memory or inodes, sudo or ACLs deny access, SELinux blocks a write, a job works in the shell but not in cron, sshd rejects a key, an upgrade leaves packages half-configured, load is high while the CPU sits idle, or a host needs firewall rules, users, LVM, journald, kernel tuning, or a security baseline. Covers Debian/Ubuntu, RHEL/Fedora, Arch, and Alpine. Not for shell-script syntax (bash) or container build and runtime internals (docker).
NodeJS
@ivangdavilaBuilds, debugs, and hardens Node.js servers, CLIs, and npm packages: async, modules, streams, memory, and process lifecycle. Use when writing or reviewing code that runs on Node, when a process hangs or refuses to exit, leaks memory, gets OOM-killed, pins one CPU core at 100%, or dies on an unhandled rejection; when the error reads EADDRINUSE, EMFILE, ECONNRESET, ERR_MODULE_NOT_FOUND, ERR_REQUIRE_ESM, or "__dirname is not defined"; when import and require interop breaks, streams buffer everything in RAM, a server returns intermittent 502s behind a load balancer, or SIGTERM drops in-flight requests; when npm install, lockfiles, peer dependencies, workspaces, native module builds, or publishing misbehave; when a suite passes locally and fails in CI; or when containerizing, profiling, and shutting down a service cleanly. Not for browser-only JavaScript, TypeScript type-system design, or the Bun and Deno runtimes.
terraform
@ivangdavilaWrites, debugs, and refactors Terraform — HCL and modules, plan and apply failures, state surgery, drift, and provider pinning. Use when writing or reviewing HCL, when terraform plan or apply errors out, when a plan shows a permanent diff, an unexplained destroy, or "forces replacement", when renaming, importing, or moving resources between modules and states, when a state lock is stuck or state is lost or corrupted, when for_each fails because a value is not known until apply, when pinning providers or surviving a major version upgrade, or when wiring plan-on-PR, apply-on-merge, drift detection, and policy gates. Covers OpenTofu, tfstate, backends, workspaces, and infrastructure-as-code review. Not for choosing which cloud services to build — see aws, gcp, or azure.
Python
@ivangdavilaWrites, debugs, and reviews Python code — runtime traps, packaging, typing, async, tests, performance. Use when Python raises or misbehaves: ModuleNotFoundError, circular imports, AttributeError on None, UnboundLocalError, UnicodeDecodeError, mutable default arguments, `is` vs `==`, float rounding, naive vs aware datetimes, or a wrong answer with no exception; when pip, uv, poetry, venv, pyproject or a lockfile fight over dependencies, or a package installs but will not import; when threads, asyncio, multiprocessing or the GIL hang, deadlock, or leak memory; when pytest passes but should not, mocks patch the wrong module, or async tests never run; when mypy or pyright errors need clearing; when a script is slow, eats RAM, or gets OOM-killed; when subprocess calls hang or logs never appear; or when upgrading Python breaks the build. Not for library-specific problems — pandas, numpy, django, fastapi and flask have their own skills.
Kubernetes
@ivangdavilaDebugs Kubernetes workloads and reviews manifests: pods, probes, resources, rollouts, Services, storage, RBAC. Use when a pod is Pending, CrashLoopBackOff, ImagePullBackOff, OOMKilled or stuck Terminating, when a Service or Ingress serves nothing or returns 502/503/504, when cluster DNS is flaky, a rollout hangs or silently ships a broken version, an HPA refuses to scale, a PVC stays unbound, a node goes NotReady or a drain never finishes, when writing or reviewing YAML, Helm charts or kustomize overlays, when tuning requests, limits, QoS, probes and graceful shutdown, or when locking down RBAC, NetworkPolicy, Pod Security and Secrets. Covers kubectl triage, StatefulSets and PVCs, Jobs and CronJobs, autoscaling, admission webhooks, node drains and cluster upgrades. Not for building container images — that is `docker`.
nginx
@ivangdavilaConfigures and debugs nginx: reverse proxy, load balancing, SSL/TLS termination, caching, redirects, and static file serving. Use when writing or reviewing nginx.conf, server blocks, locations, upstreams, or proxy_pass, when a site behind nginx throws 502, 504, 413, 403, or a redirect loop, when WebSockets, SSE, or gRPC break through the proxy, when a certificate works in curl but warns in browsers, when requests hit the wrong location or the backend sees the wrong path, when tuning workers, buffers, gzip, or proxy cache, when rate limiting or blocking abuse, when proxying raw TCP/UDP, or when nginx runs in Docker or Kubernetes. Not for certificate issuance or renewal (ACME, Let's Encrypt) — that is the ssl skill.