A shell check only runs where that script runs. A test travels with the repo, needs no extra tooling, and fails with a message naming the offending files. Enforcement is now a preference order: native linter rule > test > build script as fallback, with a worked example and a note on why a per-compile build hook usually is not worth the incremental-build cost. Propagated per language: rust (clippy has no file-length lint -> workspace root test), js-typescript (ESLint max-lines covers both natively, no test needed), go/python/csharp (linter for functions, test for files), bash (don't reimplement in the gate what a test can assert). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6.8 KiB
name, description
| name | description |
|---|---|
| bash | Use this skill when writing or modifying Bash/shell scripts, build pipelines, release scripts, or when running shell commands as an agent. Covers strict mode, shellcheck, quoting, the no-wait-loops rule, build-script structure, and destructive-command safety. |
Bash / Shell — Rules & Preferences
Extends coding-dev.md. Everything there applies; this adds shell specifics.
Bash is for orchestration — sequencing tools, wiring a build pipeline, gluing CLIs. Once a script grows real data structures, arithmetic, or parsing, it wants to be Python or a compiled tool instead.
1. Strict Mode & Linting
Every script starts with:
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
-eexit on error,-uerror on unset variable,-o pipefailso a failure mid-pipe is not masked by a successful final command.shellcheckmust be clean and belongs in the build gate. Do not silence a finding with a# shellcheck disable=without a comment saying why.- Format with
shfmt.
Important
set -edoes not fire insideif,&&,||, or command substitution used in a condition. Do not assume a failing command inside those aborts the script — check explicitly where it matters.
2. Quoting & Expansion
- Quote every expansion:
"$var","${arr[@]}","$(cmd)". Unquoted expansion word-splits and glob-expands, which is how a path with a space becomes two broken arguments. "${var:-default}"for defaults;"${var:?message}"for required values.- Use arrays for argument lists — never build a command as a string and
evalit. [[ ]]over[ ].$(…)over backticks.localevery variable inside a function.printfoverechofor anything with escapes or leading dashes.
3. Never Write Wait Loops
Caution
Do not write
until <check>; do sleep 2; doneorwhile ! <check>; do sleep …; done. They hang the terminal, and the only way out is an interrupt.
To wait for something:
- Run the work in the background and wait for its completion signal.
- To check external state, take a single sample per invocation rather than looping.
- If a tool has a real blocking wait (
--wait,wait-for,systemctl is-active --wait), use that instead of polling around it.
This mirrors the general events-over-polling preference: prefer a notification to a loop.
4. Never Filter Build Output
Repeating the global rule because shell is where it gets violated:
# WRONG
cargo build 2>&1 | grep -E "error|warning"
npm run build | tail -20
# RIGHT
cargo build
npm run build
Tee to a log if you need a copy — cmd 2>&1 | tee build.log preserves the full output — but
never grep/head/tail it away. Narrow the command instead when output is genuinely huge.
5. Destructive Commands
- Look at the target before deleting or overwriting it.
lsit,statit, read it. - Never
rm -rf "$VAR"without first asserting$VARis non-empty and is what you think:[[ -n "${BUILD_DIR:-}" && -d "$BUILD_DIR" ]] || { echo "refusing: BUILD_DIR unset" >&2; exit 1; } - A shared output directory belongs to nobody. If two scripts write to
dist/, neither mayrm -rfit — each cleans only its own outputs. - Write scratch files to a
mktemp -dand trap cleanup:tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT - Prefer a
--dry-runflag on anything that deploys, releases, or deletes — and implement it honestly.
pgrep -f matches its own shell
Warning
The command line containing the pattern is itself a process, so
pgrep -f foo.confmatches the shell running it. Piping that intokillkills the running script — which reads exactly like "the thing I was testing never happened".Write the pattern so it cannot match itself:
pgrep -f 'my[a]pp\.conf'
6. Build Script Structure
A build script is the project's quality gate, and it should be exhaustive enough to catch mistakes before a human does.
- Fixed action order regardless of flag order, with any failure aborting the rest:
bump → lint → build → artifacts → stop → deploy → start → commit → push → release - A skipped test fails the gate. Never let a skip report green.
- Print a summary with numbers, not just a verdict — counts of warnings, suppressions, and file-size violations. A suppression count that has grown is worth seeing even on a pass.
- Don't reimplement in the gate what a test can assert. Checks like file-size limits
belong in the test suite where they travel with the repo and fail with a useful message;
the build script is the fallback for projects with no suite to hang them on. See
coding-dev.md§4. - Keep per-step logs in a known directory so a failure can be read after the fact.
- Never bypass a failed gate by copying a binary or running the install step by hand. Fix the failing step; do not weaken the check to make it pass.
- Support
--dry-runfor the full release path. - Throttle heavy builds. Detect when something latency-sensitive is running and drop to
half the cores at
nice -n 10, announcing that it did so. Provide an override env var.
7. Paths & Portability
- Resolve the script's own directory rather than assuming the caller's
cwd:script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" - Prefer absolute paths in anything non-interactive.
- Entering a container resets the working directory — chain
cdinside the same command string:container-run -- bash -c "cd /path && make". - Don't hardcode
/tmpwhen the content must survive a cleanup;/var/tmppersists longer. command -v tool >/dev/null || { echo "tool required" >&2; exit 1; }before using an optional dependency. A silently skipped step is worse than a failure — a packaging step that quietly skips when its tool is missing will ship an incomplete release.
8. Secrets
- Read secrets from an environment file or secret store; never inline them in a script.
- Never
echoa secret, and never pass one as a command-line argument — argv is world-readable in the process table. Use an env var or stdin. - Never commit a file containing real credentials; commit an example with empty values.
9. Script Placement
Standalone utility scripts belong in a dedicated, version-controlled scripts directory,
symlinked into PATH rather than copied there. Never write a loose script directly into
~/.local/bin or another PATH directory — the source of truth should be one versioned
location. See cli-tooling.md §5 for the reasoning and the pattern.