Install
openclaw skills install @iliaal/compound-eng-linux-bash-scriptingDefensive Bash scripting for Linux: safe foundations, argument parsing, production patterns, ShellCheck compliance. Use when writing bash scripts, shell scripts, cron jobs, or CLI tools in bash.
openclaw skills install @iliaal/compound-eng-linux-bash-scriptingProduce bash scripts that pass shellcheck --enable=all and shfmt -d with zero warnings.
Target: GNU Bash 4.4+ on Linux. No macOS/BSD workarounds, no Windows paths, no POSIX-only restrictions.
#!/usr/bin/env bash
set -Eeuo pipefail
shopt -s inherit_errexit
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
trap 'printf "Error at %s:%d\n" "${BASH_SOURCE[0]}" "$LINENO" >&2' ERR
trap 'rm -rf -- "${_tmpdir:-}"' EXIT
-E propagates ERR traps into functionsinherit_errexit propagates errexit into $() command substitutionsSCRIPT_DIR, never the caller's cwd or git rev-parse --show-toplevel. A shared linter invoked from another project's git hook, a cron job, or a wrapper runs with someone else's cwd, so a caller-relative rules path resolves to a file that does not exist: the rule set loads empty, zero violations are found, exit 0. It is a silent no-op, not an error, and running it from inside its own repo passes for the wrong reason. Exercise it once from a scratch directory that is not the script's own tree_tmpdir=$(mktemp -d)main() { ... } with source guard: [[ "${BASH_SOURCE[0]}" == "$0" ]] && main "$@"; enables sourcing for testing10# before applying the sign; 10#-08 is invalid.local declarations from command substitutions.sh, GNU utility modes behave like syscall modes, or a configured fallback path is usable.Read the relevant reference before implementing the matching behavior:
${path%/*} not dirname, ${path##*/} not basename, ${var//old/new} not sed(( )) over expr; [[ =~ ]] over echo | grepval=$(cmd) once, reuse $valxargs -0 -P "$(nproc)" for parallel workdeclare -A map for lookups instead of repeated grep${var@Q} shell-quoted, ${var@U} uppercase, ${var@L} lowercasedeclare -n ref=varname nameref for indirect accesswait -n wait for any background job$EPOCHSECONDS, $EPOCHREALTIME: timestamps without forking datesed -i (no '' suffix), grep -P (PCRE support), readlink -f (canonical path)timeout 30s cmd to prevent automation hangssed -i replaces a symlink with a regular file: it writes a temp file and renames it over the path, so the two copies diverge later with no error. Use sed -i --follow-symlinks or edit the target by name, and check git status --short for T (typechange) after scripted editsRun shellcheck --enable=all script.sh. Key rules:
cd dir || exit${BASH_REMATCH[n]} not $n for regex capturesPre-commit: shellcheck *.sh && shfmt -i 2 -ci -d *.sh
Run shellcheck --enable=all and shfmt -d with zero warnings before declaring done. Test edge cases: empty input, missing files, spaces in paths.
If shellcheck or shfmt is not installed (command -v shellcheck fails), the check was skipped, not passed: report "static analysis not run: shellcheck unavailable" and fall back to bash -n for syntax only. A skipped linter is the same silent no-op as the empty rules file above.