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>
172 lines
6.8 KiB
Markdown
172 lines
6.8 KiB
Markdown
---
|
|
name: bash
|
|
description: >-
|
|
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`](skills-public/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](skills-public/code-languages/python.md) or a compiled tool instead.
|
|
|
|
---
|
|
|
|
## 1. Strict Mode & Linting
|
|
|
|
Every script starts with:
|
|
|
|
```bash
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
IFS=$'\n\t'
|
|
```
|
|
|
|
- `-e` exit on error, `-u` error on unset variable, `-o pipefail` so a failure mid-pipe is
|
|
not masked by a successful final command.
|
|
- **`shellcheck` must 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 -e` does **not** fire inside `if`, `&&`, `||`, 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 `eval` it.
|
|
- `[[ ]]` over `[ ]`. `$(…)` over backticks.
|
|
- `local` every variable inside a function.
|
|
- `printf` over `echo` for anything with escapes or leading dashes.
|
|
|
|
---
|
|
|
|
## 3. Never Write Wait Loops
|
|
|
|
> [!CAUTION]
|
|
> **Do not write `until <check>; do sleep 2; done` or `while ! <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:
|
|
|
|
```bash
|
|
# 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.** `ls` it, `stat` it, read it.
|
|
- Never `rm -rf "$VAR"` without first asserting `$VAR` is non-empty and is what you think:
|
|
```bash
|
|
[[ -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
|
|
may `rm -rf` it — each cleans only its own outputs.
|
|
- Write scratch files to a `mktemp -d` and trap cleanup:
|
|
```bash
|
|
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
|
|
```
|
|
- Prefer a `--dry-run` flag 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.conf`
|
|
> matches the shell running it. Piping that into `kill` kills 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](skills-public/coding-dev.md).
|
|
- 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-run` for 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`:
|
|
```bash
|
|
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
```
|
|
- Prefer absolute paths in anything non-interactive.
|
|
- **Entering a container resets the working directory** — chain `cd` inside the same command
|
|
string: `container-run -- bash -c "cd /path && make"`.
|
|
- Don't hardcode `/tmp` when the content must survive a cleanup; `/var/tmp` persists 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 `echo` a 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](skills-public/cli-tooling.md) for the reasoning and the pattern.
|