implementing-shell
Write and review Bash scripts — strict mode, argument parsing, usage messages, exit codes, DRY_RUN, dependency checks, ShellCheck clean. Use for hooks, CI helpers and setup scripts.
SKILL.mdplugins/sh-dev/skills/implementing-shell/SKILL.md
GitHubImplementing Shell Scripts
Target: $ARGUMENTS
Writes or reviews Bash scripts so they fail loudly, explain their usage, and pass ShellCheck. Test
them with testing-shell (BATS).
When to Use
- New or changed
*.sh: hooks, CI helpers, install/setup scripts, GitHub Action scripts - Reviewing a script that silently continues after errors or has no usage message
Do Not Use
- Logic that has outgrown shell (data structures, JSON transforms beyond
jq, >~200 lines) — move it to Python or another real language instead of hardening the script
Conventions
| Topic | Rule |
|---|---|
| Shebang | #!/usr/bin/env bash (or sh only when POSIX-portable and tested with dash) |
| Strict mode | set -euo pipefail for scripts that must stop on errors. Hooks that must never break the host tool use set -u and handle failures explicitly instead |
| Quoting | Quote every expansion: "$var", "$@", "$(cmd)" |
| Usage | usage() prints to stderr; -h/--help exits 0, a usage error exits 2 |
| Exit codes | 0 success · 1 runtime failure · 2 usage error; document any others in usage() |
| Dependencies | Check with command -v tool >/dev/null up front and say which tool is missing |
| Side effects | Honour DRY_RUN=1: print what would change, change nothing |
| Temp files | tmp=$(mktemp -d) plus trap 'rm -rf "$tmp"' EXIT |
| Output | Data to stdout, diagnostics to stderr, so callers can pipe the result |
| Untrusted input | Never eval; pass values as arguments, not interpolated into command strings |
Skeleton
#!/usr/bin/env bash
# do-thing.sh — one line on what it does.
set -euo pipefail
usage() {
cat >&2 <<'EOF'
Usage: do-thing.sh [--out FILE] TARGET
--out FILE write the report to FILE (default: stdout)
Env: DRY_RUN=1 prints actions without performing them.
EOF
}
die() { echo "error: $*" >&2; exit 1; }
out=""
while [ $# -gt 0 ]; do
case "$1" in
-h|--help) usage; exit 0 ;;
--out) [ $# -ge 2 ] || { usage; exit 2; }; out="$2"; shift 2 ;;
--) shift; break ;;
-*) usage; exit 2 ;;
*) break ;;
esac
done
[ $# -eq 1 ] || { usage; exit 2; }
target="$1"
command -v jq >/dev/null 2>&1 || die "jq is required"
run() { if [ "${DRY_RUN:-0}" = "1" ]; then echo "DRY_RUN: $*" >&2; else "$@"; fi; }
write_report() { printf 'processed %s\n' "$target" > "$1"; }
if [ -n "$out" ]; then run write_report "$out"; else echo "processed $target"; fi
ShellCheck
- Run
shellcheck path/to/script.shlocally and in CI; fix findings rather than disabling them. - When a disable is genuinely right, scope it to one line with a reason:
# shellcheck disable=SC2086 # word-splitting intended: FLAGS is a flag list - Source files ShellCheck can't follow:
# shellcheck source=lib/common.sh.
Review checklist
- Fails on the first error (or handles each failure explicitly), never continues on bad state
- Usage message matches the parser; usage errors exit 2
- All expansions quoted; no
eval; no parsing oflsoutput - Missing dependencies produce a clear message
DRY_RUN=1path exists for anything that writes, deletes or calls an API- ShellCheck clean; BATS tests cover each exit path (
testing-shell)