Language · Bash 5.3 · shellcheck 0.11 · shfmt 3.13 · bats-core 1.12
Bash / Shell
Strict mode, quoted vars, shellcheck-clean scripts.
Updated 5 Jul 2026 · CC0
AGENTS.mdrepo rootYou write shell scripts that survive spaces in filenames, empty variables, and hostile input, and that pass shellcheck with zero warnings. "Good" here means: strict mode on, every expansion quoted, shellcheck-clean, shfmt-formatted, and a hard stop toward Python the moment logic outgrows shell.
Stack
- Interpreter: GNU Bash 5.3 (current stable) for scripts that use arrays,
[[ ]],local,mapfile. Target POSIXsh(dash) ONLY when portability demands it — and then no bashisms. Don't assume 5.3 on servers you don't control; gate 5.3-only syntax behind aBASH_VERSINFOcheck. - Shebang states the contract:
#!/usr/bin/env bashfor Bash scripts,#!/bin/shfor strict POSIX. Never#!/bin/bashon macOS targets (ships Bash 3.2). Never claimshthen use[[ ]]. - Linter:
shellcheck0.11.0 — CI-blocking, zero warnings. Disable a check only with a scoped, commented# shellcheck disable=SCxxxxon the exact line. - Formatter:
shfmt3.13.1 (mvdan/sh). Canonical invocation:shfmt -i 2 -ci -bn -sr -w. - Bash 5.3 niceties (use only when the script already requires
>= 5.3): non-forking command substitution${ cmd; }/ value-returning${| cmd; }, and theGLOBSORTvariable to control glob ordering. Otherwise keep$(...)for portability. - Runtime tools:
mktemp,getopts(short flags) or manualcaseparse (long flags),trap,printf. Prefer coreutils; document any GNU-only flag (e.g.sort -z,sed -i) if BSD/macOS is a target. - Not shell's job: JSON →
jq. YAML →yq. CSV/records →awk. HTTP with logic →curl+jq, or Python. Floating-point math →awk/bc, never$(()).
Project conventions
- Layout: executables in
bin/orscripts/(no.shextension on installed CLIs — extension leaks implementation); sourced libraries inlib/with.shand no shebang/exec bit. - Naming:
lower_snake_casefor functions and locals;UPPER_SNAKE_CASEonly for exported/global env; declare constantsreadonly NAME=val(ordeclare -r) — notereadonlyhas no-rflag. Private helpers prefixed_. - Every script opens with shebang, then a one-line purpose comment, then strict mode, then
readonlyconfig, then functions, then amain "$@"call guarded so the file is safely sourceable:main() { ... } if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then main "$@"; fi - Indent 2 spaces (enforced by
shfmt -i 2). Keep lines under ~100 cols.then/doon the same line asif/while. - Source libraries by absolute path derived from the script, never CWD:
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) source "${script_dir}/lib/log.sh"
Strict mode and error handling
- First executable line of every Bash script:
set -euo pipefail IFS=$'\n\t'-eexit on error,-uerror on unset var,-o pipefaila pipe fails if any stage fails. ResettingIFSkills word-splitting-on-space surprises. - Know
set -e's blind spots: it does NOT trigger insideif/whileconditions,||/&&chains, or (by default) command substitutions. Addshopt -s inherit_errexit(Bash 4.4+) sox=$(cmd)propagates a failingcmdunder-e; without it a failed command substitution is silently swallowed. Still don't rely on-eas your only guard — check critical commands explicitly. - Cleanup via
trap, set up immediately after creating the resource.EXITalone does NOT reliably fire on an untrapped fatal signal (INT/TERM), and even when it does the script exits0instead of the correct128+N. Trap the signals too:
Capturingtmp=$(mktemp -d) cleanup() { rm -rf -- "${tmp}"; } trap 'rc=$?; cleanup; exit "$rc"' EXIT INT TERM$?first preserves the failing/128+Nstatus; keepcleanupidempotent so a double-fire is harmless. For strict signal semantics, re-raise instead:trap 'cleanup; trap - INT; kill -INT $$' INT(reset the trap, then re-signal$$so the parent sees a real130). - Opt out of
-eintentionally, never accidentally:if ! some_cmd; then ...orsome_cmd || truewhen a nonzero exit is expected. Capture status when you need it:set +e; foo; rc=$?; set -e. - Meaningful exit codes:
0success,1general failure,2usage/CLI error,>2for domain-specific conditions.exitexplicitly at the end of error paths; document nonstandard codes. - Error messages go to stderr with the program name:
printf '%s: %s\n' "${0##*/}" "message" >&2.
Quoting and expansions (non-negotiable)
- Quote every expansion that could contain whitespace, glob chars, or be empty:
"$var","${arr[@]}","$(cmd)","${1}". Unquoted$varis a bug unless you provably need word-splitting or globbing (rare; comment it). - Array expansion:
"${arr[@]}"(each element a separate word, quoted) — never${arr[*]}unless you specifically want a single IFS-joined string."${#arr[@]}"for length. - Assignments don't word-split, but always quote for consistency and safety of downstream reuse.
- Default/guard with parameter expansion, not
if -z:"${VAR:-default}"(fallback),"${VAR:?must be set}"(fail with message if unset),"${VAR:+$VAR}"(use only if set). To add a conditional flag+value, never cram it into one word like"${VAR:+--flag $VAR}"— set it collapses--flag valinto a single argv token and emits a stray empty arg when unset. Build an array instead:args=(); [[ -n $VAR ]] && args+=(--flag "$VAR"); cmd "${args[@]}". - Strip/transform in-shell instead of spawning
sed/basename:"${path##*/}"(basename),"${path%/*}"(dirname),"${name%.*}"(strip ext),"${var//old/new}"(replace all). - End option parsing and protect against filenames starting with
-:rm -- "$file",cp -- "$src" "$dst".
Constructs: use the modern form
[[ ]]for tests, never[ ]/testin Bash: supports&&,||,<, unquoted RHS safety,=~regex,-v var. Use[ ]only in strict POSIXsh.$(...)for command substitution, never backticks — nests cleanly, no backslash hell.$(( ))for integer arithmetic;(( ))for arithmetic conditionals:if (( count > 3 )); then. Neverexpr. Neverlet.- Loop over arrays/lines with a real loop, never over
$(ls):for f in *.txt; do [[ -e "$f" ]] || continue; process "$f"; done # nullglob-safe check while IFS= read -r line; do process "$line"; done < "$file" mapfile -t lines < "$file" # Bash 4+, reads file into array - Read NUL-delimited for arbitrary filenames:
find . -type f -print0 | while IFS= read -r -d '' f; do ...; done, ormapfile -d ''. printfoverechofor anything with variables, flags, or escapes —echobehavior varies across shells/-e/-n.printf '%s\n' "$x".
Functions, args, and dependencies
- Every function declares its locals:
local name; local -a items; local -i count. Unlocalized vars leak into global scope — a top finding inshellcheckand code review. - Capture command output into a local separately from declaration to preserve exit status (
local x=$(cmd)maskscmd's failure):local out; out=$(cmd) || return 1 - Validate arguments up front with a
usage()that prints to stderr and exits2:usage() { printf 'Usage: %s <src> <dst>\n' "${0##*/}" >&2; exit 2; } [[ $# -eq 2 ]] || usage - Check a command exists before using it:
command -v jq >/dev/null 2>&1 || { printf 'jq required\n' >&2; exit 1; }. Neverwhich. - Parse flags with
getoptsfor short options; hand-roll acaseloop for GNU-style long options. Support--to end options. - Return status from functions with
return N; reserveexitformainand fatal top-level errors so functions stay composable.
Temp files and filesystem
- Always
mktemp, always with a trap:tmp=$(mktemp) || exit 1; trap 'rm -f -- "$tmp"' EXIT. Never hardcode/tmp/foo.$$(predictable, race-prone, TOCTOU). - Directories:
mktemp -d. SetTMPDIR-respecting behavior by not hardcoding/tmp. - Guard
rm -rf: neverrm -rf "$dir/"when$dircould be empty (deletes/). Require the var non-empty and end options:rm -rf -- "${dir:?}". Prefer removing amktemp -dpath you own. - Test file conditions explicitly:
[[ -f "$f" ]],[[ -d "$d" ]],[[ -r "$f" ]],[[ -s "$f" ]](non-empty). cdin scripts must be checked:cd -- "$dir" || exit 1. Prefer subshells( cd "$dir" && cmd )to avoid leaking CWD changes.
When to STOP and switch to Python
Shell is glue for processes and files. Rewrite in Python (or Go) when you hit any of:
- Nontrivial data structures: nested/associative data beyond one flat
declare -A, JSON/XML/YAML transformation with logic. - Arithmetic beyond integers, or any floating-point.
- More than ~100 lines, or 3+ levels of nested control flow, or you're building string-manipulation functions.
- You need real error objects, retries with backoff logic, unit tests with mocking, or maintainability by a team.
Signs you already went too far: parsing
jqoutput back into Bash to loop, building JSON by string-concatenation, emulating a hash-of-hashes. Stop and port it.
Testing
- Framework: bats-core 1.12.0 (
bats) for script/CLI tests, withbats-support+bats-asserthelper libs;shellspecfor BDD-style. Put tests intest/as*.bats. - Test observable behavior: exit codes, stdout, stderr, and side effects on files — not internals.
@test "fails with usage on missing args" { run ./bin/deploy [ "$status" -eq 2 ] [[ "$output" == *"Usage:"* ]] } - Use
runto capture$status/$output/$lines. Isolate filesystem side effects insetup()/teardown()with a per-testmktemp -d. - Static analysis IS part of the test suite:
shellcheckandshfmt -d(diff mode) run in CI and block merge. A script isn't "tested" until it'sshellcheck-clean. - For pure-logic functions, source the lib and call functions directly; keep such logic in
lib/(no side effects at source time) so it's unit-testable.
Security
- Never
evalon untrusted input, ever. Avoidevalentirely; if you think you need it, you need an array or acasedispatch instead. - Never pass unsanitized data into a shell: no
bash -c "$user_input", no unquoted var in a command that could inject. Pass data as arguments/stdin, not interpolated code. - Quote to prevent word-splitting AND glob injection — an unquoted
$varcontaining*or; rm -rfis a vulnerability, not just a bug. curl | bashis forbidden in scripts you ship; if consuming remote scripts, download, checksum-verify, then run.- Secrets: read from env or a file with
0600perms; never hardcode; never pass as CLI args (visible inps//proc). Scrub from logs. Setumask 077before writing sensitive temp files. - Set
PATHexplicitly at the top of privileged/cron scripts; don't inherit an attacker-controlledPATH. - Validate and whitelist external input with
[[ "$x" =~ ^[A-Za-z0-9_-]+$ ]]before using it in paths or commands.
Do
- Start every Bash script with
set -euo pipefailandIFS=$'\n\t'. - Quote every expansion:
"$var","${arr[@]}","$(cmd)". - Use arrays for lists of args/paths; build commands as arrays:
cmd=(rsync -a); cmd+=(--delete); "${cmd[@]}". mktemp+trap '... EXIT'for all temp state.localevery function variable; validate args with ausage()exiting2.- Prefer
[[ ]],$(...),$(()),printf, parameter expansion over external processes. - Run
shellcheckandshfmt -wbefore every commit; fix every warning. - State bash-vs-sh in the shebang and hold to it.
Avoid
- Unquoted expansions → quote them.
- Running without strict mode → add
set -euo pipefail. for x in $(ls)/ parsingls→ glob (for f in *.log) orfind -print0+read -d ''.- Backticks →
$(...).[ ]in Bash →[[ ]].expr/let→$(()). echowith flags/vars →printf.cat file | grep,grep x | wc -l,echo "$x" | sed→grep x file,grep -c x file,"${x//.../...}".which cmd→command -v cmd.- Hardcoded
/tmp/$$→mktemp. Unguardedrm -rf "$dir/"→rm -rf -- "${dir:?}". eval "$input"→ array/casedispatch.- Growing a 300-line Bash monster → port to Python.
When you code
- Make small, reviewable diffs; touch one script/concern per change. Preserve the existing shebang and style of the file.
- Before returning any script: run
shellcheck <file>andshfmt -d <file>and fix everything; if either isn't available, say so and state what you'd fix by inspection. - Never introduce a bashism into a
#!/bin/shfile, or vice versa; if the task needs arrays/[[ ]], upgrade the shebang toenv bashand note it. - Ask before: adding a new runtime dependency (
jq,yq, GNU-only flags on a BSD/macOS target), changing exit-code contract, or rewriting a script in another language. - If the task genuinely warrants Python (see the STOP criteria), say so plainly and propose it rather than delivering fragile shell.
- Output the full script (not a fragment) when creating a file, with the strict-mode header,
usage(), functions, and the sourceablemain "$@"guard in place.
Drop it in your repo
Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on Bash 5.3 · shellcheck 0.11 · shfmt 3.13 · bats-core 1.12.