The existing warning only covered piping a self-match into kill. The quieter failure is reading your own shell's PID as "the app is running" and refusing to touch its config — which is exactly what happened while rotating webhooks in a closed VRCNext. Adds: always pass -a so a self-match is visible; prefer -ax (name only, never scans arguments) for "is it running?"; -A/--ignore-ancestors as the fix when -f is unavoidable; and why the [b]racket trick is weaker than it looks — the term can appear elsewhere on the same command line. Points at systemctl is-active for supervised units. cli-tooling.md gains a one-line orientation pointer rather than a copy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8.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".
The quieter failure is misreading the result. pgrep -f VRCNext prints a PID, you conclude
the app is running, and you refuse to edit its config — when the only match was your own shell.
Agents hit this constantly, because an agent's bash -c command line is enormous: it carries a
snapshot path, the whole eval '...' of the script, and every argument inside it.
Always pass -a. Bare pgrep prints naked PIDs, which cannot be sanity-checked; -a
prints the full command line, so a self-match is obvious on sight:
pgrep -af myapp # 3201245 /bin/bash -c ... eval 'pgrep -af myapp' ... ← that is you
Then pick the invocation that cannot match you in the first place:
pgrep -ax myapp # match the process NAME only; never scans arguments ← prefer this
pgrep -Af myapp # -A/--ignore-ancestors: excludes your own shell and its parents
pgrep -af 'my[a]pp' # the bracket trick: the regex no longer matches its own literal text
-xis the strongest and the default choice when you are asking "is this program running?". It compares against the command name, so the pattern is never tested against any argument list — including yours. Reach for-fonly when you must distinguish two instances of the same binary by their arguments.-Ais the right fix when-fis unavoidable, and it is robust where the bracket trick is not. Confirm it exists before relying on it in a script (pgrep --help | grep ancestors); it is present in procps-ng 4.0.6.- The bracket trick is weaker than it looks.
pgrep -f '[V]RCNext'can still match the calling shell, because the wordVRCNextmay appear elsewhere on that command line — such as in acd ~/.config/VRCNextearlier in the same script. The trick only defends against matching the pattern's own literal text, not against the term occurring anywhere else in the command being run.
Tip
For a service under systemd, don't use
pgrepat all — ask the supervisor, which has no self-match problem and distinguishes stopped from failed:systemctl --user is-active <unit>.
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.