| name | bash-code-quality |
| description | Defensive coding standards for bash scripts authored as deliverables. Apply when writing or editing any `.sh` file or a script whose first line is a bash shebang — even when the user does not ask for best practices explicitly.
|
Bash Code Quality
This skill defines defensive coding standards for bash scripts authored as deliverables — files that are committed to the repository, installed on user machines, or executed in CI/CD environments. It synthesises strict-mode discipline, safe quoting, error trapping, and proven recurring patterns into a single actionable reference.
When to Use This Skill
- Authoring or editing any
.sh file in the repository
- Creating or modifying scripts with a
#!/bin/bash or #!/usr/bin/env bash shebang
- Writing install, setup, or migration scripts
- Building hook helper scripts (pre-commit, post-merge, etc.)
- Creating CI/CD pipeline shell scripts
- Authoring utility or maintenance scripts committed to the repo
- Refactoring an existing bash function or script structure
- Reviewing a bash script for correctness before a PR
When NOT to Use This Skill
- Invoking the Bash tool inside a Claude session — that is governed by the bash-style reference (
references/bash-style.md), not this skill.
- Fish, zsh, or plain POSIX
sh scripts — the bash idioms here ([[ ]], arrays, mapfile, local) require bash 4+. POSIX-portable equivalents are noted where relevant but are not the recommended default.
- One-off throwaway commands that are not committed to the repository.
Relationship to Other Rules
In repositories with their own bash conventions (a project CLAUDE.md, a style guide, or a bash-style reference file), the project's rules take precedence over this skill whenever they conflict. Read the project's own rules first; this skill is the fallback when the project is silent.
Three precedence cases that come up most often:
&& in script bodies is fine. If the project forbids && chaining for Bash tool invocations (as bash-style.md does), that rule does not restrict && inside .sh script bodies — && is a normal bash short-circuit operator and is used freely in scripts.
$(...) in script bodies is fine. If the project forbids $(...) in git commit Bash-tool invocations, that does not restrict $(...) in script bodies — $(...) is the recommended form for command substitution inside scripts (preferred over backticks).
- Heredocs and multiple
-m flags are separate concerns. If the project requires multiple -m flags for git commit commands issued via the Bash tool, that does not apply to scripts that contain git commit calls — scripts may construct commit messages however they wish.
When in doubt, read the project's own bash references first; this skill is the fallback when the project is silent.
Core Defensive Principles
1. Strict Mode
Enable strict mode at the top of every script. It is the single most effective safeguard against silent failures.
#!/usr/bin/env bash
set -Eeuo pipefail
Flag breakdown:
-E — ERR traps are inherited by functions and subshells, so an error deep in a call stack still fires the top-level trap.
-e — exits the script immediately when any command returns a non-zero status.
-u — makes bash exit when a variable is referenced but has never been assigned; catches typos in variable names early.
-o pipefail — a pipeline fails if any command in it fails, not just the last one. Without this, false | true succeeds.
The shebang line should be #!/usr/bin/env bash (portable) or #!/bin/bash (direct). Never /bin/sh when bash features are used.
2. Quote Every Variable
Unquoted variable expansions undergo word splitting and pathname expansion. Both can corrupt filenames that contain spaces or glob characters.
cp $source $dest
cp "$source" "$dest"
: "${REQUIRED_VAR:?REQUIRED_VAR must be set and non-empty}"
Quote every $variable and ${expansion} unless you deliberately intend word splitting (rare). When in doubt, quote.
3. Use [[ ]] for Conditionals
[[ ]] is the bash compound conditional. It is safer than [ ]: no word splitting inside it, no quoting of variables required for tests, supports &&/|| natively, and handles empty strings correctly.
if [[ -f "$file" && -r "$file" ]]; then
content=$(<"$file")
fi
if [[ -z "${VAR:-}" ]]; then
echo "VAR is not set or empty" >&2
fi
Use [ ] only when explicitly targeting POSIX sh compatibility. In bash scripts, always prefer [[ ]].
4. Declare Function Locals
Variables assigned inside a function without local are global by default and visible in the caller's scope — this is a bash scoping rule, unrelated to set -u. Use local (or local -r for read-only) to confine variables to their function. set -u is a separate safeguard: it makes bash exit when referencing an unset variable, which helps catch typos in variable names.
validate_file() {
local -r file="$1"
local -r message="${2:-File not found: $file}"
if [[ ! -f "$file" ]]; then
echo "ERROR: $message" >&2
return 1
fi
}
Pitfall — local x="$(cmd)" masks exit codes (ShellCheck SC2155):
local result="$(some_command)"
local result
result="$(some_command)"
Always split local declaration from $(...) assignment. This also applies to declare.
5. Trap Errors and Clean Up
Register cleanup and error-reporting traps near the top of the script, right after set -Eeuo pipefail.
TMPDIR_WORK=""
cleanup() {
[[ -n "$TMPDIR_WORK" ]] && rm -rf -- "$TMPDIR_WORK"
}
trap 'echo "Error on line $LINENO" >&2' ERR
trap cleanup EXIT
- The
ERR trap fires whenever a command fails; log the line number to aid debugging.
- The
EXIT trap fires on all exits (normal, exit N, and error), making it the right place to remove temp files and release locks.
- Register
EXIT cleanup before creating the resource being cleaned up — if mktemp itself fails the trap still runs safely.
6. Use Arrays for Lists
Passing space-separated strings for lists is fragile: any element containing a space breaks iteration silently.
items="item one item two item three"
for item in $items; do
process "$item"
done
declare -a items=("item one" "item two" "item three")
for item in "${items[@]}"; do
process "$item"
done
mapfile -t lines < <(some_command)
Always expand arrays as "${arr[@]}" (double-quoted, at-sign). "${arr[*]}" joins elements with IFS, which is rarely what you want.
7. NUL-Safe File Iteration
for f in $(ls dir) and for f in $(find ...) both break on filenames containing spaces, newlines, or glob characters. Use NUL-delimited output from find combined with read -d ''.
while IFS= read -r -d '' f; do
process "$f"
done < <(find "$dir" -type f -name "*.sh" -print0)
Note: process substitution (<(...)) is used here and is perfectly fine inside script bodies. It is only forbidden for Bash tool invocations per the project's bash-style reference.
Recurring Patterns
Script Directory Detection
Lets a script reliably locate sibling files regardless of the caller's current working directory.
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
Why: $0 can be a symlink or a relative path. ${BASH_SOURCE[0]} resolves to the actual script file; pwd -P resolves any remaining symlinks in the directory path.
Safe Temporary Files
Create a temporary directory once and clean it up automatically on exit.
TMPDIR_WORK=$(mktemp -d)
trap 'rm -rf -- "$TMPDIR_WORK"' EXIT
TMPFILE="$TMPDIR_WORK/output.txt"
LOCKFILE="$TMPDIR_WORK/lock"
Why: mktemp -d creates a uniquely named directory that no other process can predict. All temp files live inside it, so a single rm -rf cleans everything. Hardcoding /tmp/foo creates race conditions and leaves residue on failure.
Argument Parsing
A case loop that handles short and long options, a value-bearing flag, separator --, unknown options, and a usage() function.
usage() {
cat <<EOF
Usage: $(basename "$0") [OPTIONS]
Options:
-v, --verbose Enable verbose output
-n, --dry-run Preview changes without applying them
-o, --output FILE Write output to FILE
-h, --help Show this help message
EOF
exit "${1:-0}"
}
VERBOSE=false
DRY_RUN=false
OUTPUT_FILE=""
while [[ $# -gt 0 ]]; do
case "$1" in
-v|--verbose) VERBOSE=true; shift ;;
-n|--dry-run) DRY_RUN=true; shift ;;
-o|--output) OUTPUT_FILE="$2"; shift 2 ;;
-h|--help) usage 0 ;;
--) shift; break ;;
-*) echo "ERROR: Unknown option: $1" >&2; usage 1 ;;
*) break ;;
esac
done
Why: the case approach handles combined short options and value-bearing flags cleanly without external tools.
Structured Logging
All log output goes to stderr so it does not pollute stdout pipelines. Debug output is gated by a DEBUG environment variable.
log_info() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] INFO: $*" >&2; }
log_warn() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] WARN: $*" >&2; }
log_error() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] ERROR: $*" >&2; }
log_debug() {
[[ "${DEBUG:-0}" == "1" ]] && echo "[$(date +'%Y-%m-%d %H:%M:%S')] DEBUG: $*" >&2
}
Why: structured levels make it easy to filter logs. Sending all diagnostics to stderr keeps stdout clean for machine-readable output.
Dependency Checks
Check for all required external commands up front. Aggregate missing dependencies into an array and report them all at once rather than dying on the first missing tool.
check_dependencies() {
local -a missing=()
local -a required=("git" "curl" "jq")
for cmd in "${required[@]}"; do
if ! command -v "$cmd" >/dev/null 2>&1; then
missing+=("$cmd")
fi
done
if [[ ${#missing[@]} -gt 0 ]]; then
log_error "Missing required commands: ${missing[*]}"
log_error "Install them and re-run this script."
return 1
fi
}
check_dependencies
Why: command -v is a shell built-in, always available, and is preferred over which (which is not guaranteed to exist or behave consistently across systems).
Idempotent Operations
Scripts should be safe to re-run. Check existing state before taking action.
ensure_directory() {
local -r dir="$1"
if [[ -d "$dir" ]]; then
log_debug "Directory already exists: $dir"
return 0
fi
mkdir -p "$dir"
log_info "Created directory: $dir"
}
ensure_config() {
local -r config_file="$1"
local -r default_value="$2"
if [[ -f "$config_file" ]]; then
log_debug "Config already exists: $config_file"
return 0
fi
printf '%s\n' "$default_value" > "$config_file"
log_info "Created config: $config_file"
}
Why: idempotent functions let users re-run the script after partial failures without manual cleanup.
Dry-Run Support
Wrap every destructive command in a run_cmd() function guarded by a DRY_RUN flag.
DRY_RUN="${DRY_RUN:-false}"
run_cmd() {
if [[ "$DRY_RUN" == "true" ]]; then
echo "[DRY RUN] Would execute: $*" >&2
return 0
fi
"$@"
}
run_cmd cp "$source" "$dest"
run_cmd rm -rf -- "$old_dir"
run_cmd chown "$owner:$group" "$target"
Why: lets users preview what a script would do before committing to irreversible changes.
Atomic File Writes
Write to a temporary file first, then rename into place. A rename within the same filesystem is atomic on Linux and macOS.
atomic_write() {
local -r target="$1"
local tmpfile
tmpfile=$(mktemp "$(dirname "$target")/.tmp.XXXXXX")
cat > "$tmpfile"
mv "$tmpfile" "$target"
}
generate_config | atomic_write "/etc/myapp/config.conf"
Why: without the temp-file rename pattern, a reader can see a half-written file if the script is interrupted mid-write.
Anti-Patterns to Avoid
- Backtick command substitution
`cmd` — use $(cmd) instead; it is readable, nestable, and consistent.
- Using
which to check for executables — use command -v cmd >/dev/null 2>&1; which is not a shell built-in and behaves inconsistently across systems.
- Parsing
ls output — use globs (for f in dir/*.sh) or NUL-safe find -print0 instead; ls output is not safely parseable.
for f in $(find ...) or for f in $(ls dir/) — breaks on filenames with spaces; use the while read -r -d '' / find -print0 pattern.
cat file | grep pattern — use grep pattern file; the useless cat adds a process and hides intent.
- Bare
rm -rf "$dir/" without a non-empty guard — always validate: [[ -n "$dir" ]] || { log_error "dir is empty"; exit 1; } before removing.
- Echoing structured or multi-line data with
echo — echo interprets -n, -e, and backslashes inconsistently across systems; additionally, data that begins with a dash (e.g., a value like -n or -e) will be silently misread as a flag rather than printed. Use printf '%s\n' "$data" instead.
- Hardcoding
/tmp/foo — other processes can predict and race this path; use mktemp or mktemp -d to create uniquely named temp resources.
- Using
local x="$(cmd)" — local always returns 0, masking the command's exit code; declare first, assign separately (see Principle 4).
Quick Checklist
Before declaring a bash script done, verify each item:
Optional Tooling
Running shellcheck script.sh locally can catch many of the issues this skill targets — unquoted variables, deprecated forms, SC2155 (local x="$(cmd)"), and more. It is a useful manual aid during development. Do not invoke it automatically and do not treat it as a required gate; it is a linter, not a substitute for the practices above.
This skill is non-mandatory but should be the default standard for any bash script you write. When the project provides its own bash guidance, follow that first and fall back to this skill for anything the project does not cover.