Promptheus/rules53 rule sets · CC0Promptheus hub ↗

Language · Bash 5.3 · shellcheck 0.11 · shfmt 3.13 · bats-core 1.12

Bash / Shell

Strict mode, quoted vars, shellcheck-clean scripts.

bashshellscripting

Updated 5 Jul 2026 · CC0

AGENTS.mdrepo root

You 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 POSIX sh (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 a BASH_VERSINFO check.
  • Shebang states the contract: #!/usr/bin/env bash for Bash scripts, #!/bin/sh for strict POSIX. Never #!/bin/bash on macOS targets (ships Bash 3.2). Never claim sh then use [[ ]].
  • Linter: shellcheck 0.11.0 — CI-blocking, zero warnings. Disable a check only with a scoped, commented # shellcheck disable=SCxxxx on the exact line.
  • Formatter: shfmt 3.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 the GLOBSORT variable to control glob ordering. Otherwise keep $(...) for portability.
  • Runtime tools: mktemp, getopts (short flags) or manual case parse (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/ or scripts/ (no .sh extension on installed CLIs — extension leaks implementation); sourced libraries in lib/ with .sh and no shebang/exec bit.
  • Naming: lower_snake_case for functions and locals; UPPER_SNAKE_CASE only for exported/global env; declare constants readonly NAME=val (or declare -r) — note readonly has no -r flag. Private helpers prefixed _.
  • Every script opens with shebang, then a one-line purpose comment, then strict mode, then readonly config, then functions, then a main "$@" 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/do on the same line as if/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'
    
    -e exit on error, -u error on unset var, -o pipefail a pipe fails if any stage fails. Resetting IFS kills word-splitting-on-space surprises.
  • Know set -e's blind spots: it does NOT trigger inside if/while conditions, ||/&& chains, or (by default) command substitutions. Add shopt -s inherit_errexit (Bash 4.4+) so x=$(cmd) propagates a failing cmd under -e; without it a failed command substitution is silently swallowed. Still don't rely on -e as your only guard — check critical commands explicitly.
  • Cleanup via trap, set up immediately after creating the resource. EXIT alone does NOT reliably fire on an untrapped fatal signal (INT/TERM), and even when it does the script exits 0 instead of the correct 128+N. Trap the signals too:
    tmp=$(mktemp -d)
    cleanup() { rm -rf -- "${tmp}"; }
    trap 'rc=$?; cleanup; exit "$rc"' EXIT INT TERM
    
    Capturing $? first preserves the failing/128+N status; keep cleanup idempotent 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 real 130).
  • Opt out of -e intentionally, never accidentally: if ! some_cmd; then ... or some_cmd || true when a nonzero exit is expected. Capture status when you need it: set +e; foo; rc=$?; set -e.
  • Meaningful exit codes: 0 success, 1 general failure, 2 usage/CLI error, >2 for domain-specific conditions. exit explicitly 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 $var is 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 val into 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 [ ]/test in Bash: supports &&, ||, <, unquoted RHS safety, =~ regex, -v var. Use [ ] only in strict POSIX sh.
  • $(...) for command substitution, never backticks — nests cleanly, no backslash hell.
  • $(( )) for integer arithmetic; (( )) for arithmetic conditionals: if (( count > 3 )); then. Never expr. Never let.
  • 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, or mapfile -d ''.
  • printf over echo for anything with variables, flags, or escapes — echo behavior 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 in shellcheck and code review.
  • Capture command output into a local separately from declaration to preserve exit status (local x=$(cmd) masks cmd's failure):
    local out; out=$(cmd) || return 1
    
  • Validate arguments up front with a usage() that prints to stderr and exits 2:
    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; }. Never which.
  • Parse flags with getopts for short options; hand-roll a case loop for GNU-style long options. Support -- to end options.
  • Return status from functions with return N; reserve exit for main and 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. Set TMPDIR-respecting behavior by not hardcoding /tmp.
  • Guard rm -rf: never rm -rf "$dir/" when $dir could be empty (deletes /). Require the var non-empty and end options: rm -rf -- "${dir:?}". Prefer removing a mktemp -d path you own.
  • Test file conditions explicitly: [[ -f "$f" ]], [[ -d "$d" ]], [[ -r "$f" ]], [[ -s "$f" ]] (non-empty).
  • cd in 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 jq output 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, with bats-support + bats-assert helper libs; shellspec for BDD-style. Put tests in test/ 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 run to capture $status/$output/$lines. Isolate filesystem side effects in setup()/teardown() with a per-test mktemp -d.
  • Static analysis IS part of the test suite: shellcheck and shfmt -d (diff mode) run in CI and block merge. A script isn't "tested" until it's shellcheck-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 eval on untrusted input, ever. Avoid eval entirely; if you think you need it, you need an array or a case dispatch 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 $var containing * or ; rm -rf is a vulnerability, not just a bug.
  • curl | bash is forbidden in scripts you ship; if consuming remote scripts, download, checksum-verify, then run.
  • Secrets: read from env or a file with 0600 perms; never hardcode; never pass as CLI args (visible in ps//proc). Scrub from logs. Set umask 077 before writing sensitive temp files.
  • Set PATH explicitly at the top of privileged/cron scripts; don't inherit an attacker-controlled PATH.
  • 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 pipefail and IFS=$'\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.
  • local every function variable; validate args with a usage() exiting 2.
  • Prefer [[ ]], $(...), $(()), printf, parameter expansion over external processes.
  • Run shellcheck and shfmt -w before 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) / parsing ls → glob (for f in *.log) or find -print0 + read -d ''.
  • Backticks → $(...). [ ] in Bash → [[ ]]. expr/let$(()).
  • echo with flags/vars → printf.
  • cat file | grep, grep x | wc -l, echo "$x" | sedgrep x file, grep -c x file, "${x//.../...}".
  • which cmdcommand -v cmd.
  • Hardcoded /tmp/$$mktemp. Unguarded rm -rf "$dir/"rm -rf -- "${dir:?}".
  • eval "$input" → array/case dispatch.
  • 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> and shfmt -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/sh file, or vice versa; if the task needs arrays/[[ ]], upgrade the shebang to env bash and 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 sourceable main "$@" 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.

Back to top ↑