write-bash-scripts
Skill2 starsChanged 4 days agoMITView on GitHub
Write or review Bash scripts: errors, cleanup, arguments, readiness, and portability. Load for any .sh change, including build, test, entrypoint, and environment scripts.
In this repository40 other files it gives its agents.
- AGENTS.mdAGENTS.md
- .agents/skills/AGENTS.mdAGENTS.md
- attribute-ai-commitsSkill
- check-accessSkill
- choose-dependencySkill
- coordinate-landingSkill
- develop-featureSkill
- document-codeSkill
- git-conventionsSkill
- implement-featureSkill
- manage-versionsSkill
- monitor-ciSkill
- open-pull-requestSkill
- plan-client-server-boundarySkill
- plan-featureSkill
- plan-ui-designSkill
- prefer-small-solutionsSkill
- prepare-releaseSkill
- register-ai-agentSkill
- resolve-pr-feedbackSkill
- triage-issuesSkill
- use-a-novel-cliSkill
- use-discordSkill
- write-design-systemSkill
- write-dockerfilesSkill
- write-frontendSkill
- write-frontend-testsSkill
- write-github-actionsSkill
- write-go-kitSkill
- write-go-serviceSkill
- write-goSkill
- write-go-testsSkill
- write-infraSkill
- write-js-packageSkill
- write-openapiSkill
- write-platformSkill
- write-project-docsSkill
- write-protoSkill
- write-sqlSkill
- write-svelteSkill
The file
Show its rules and text
We split this skill into the rules it gives an agent
16 rules. About 0 are common, 4 are distinctive, and 12 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈4
Distinctive · estimate
This author's own choices. Worth a look.
12
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 42
Run bash -n <script> to syntax-check without executing:
- Distinctive: line 108
Every script that takes arguments validates them at the top, before doing any work:
- Very specific: line 117
Use printf (not echo) for error messages — echo behaviour with flags (-e, -n) differs between implementations.
- Very specific: line 119
Write error messages to stderr (>&2).
- Very specific: line 120
Use $0 for the script name, so the message stays correct however the script was invoked.
- Distinctive: line 126
Always double-quote variable expansions:
- Distinctive: line 150
Always wait for readiness before running tests or migrations against a service.
- Distinctive: line 168
Use a 30–60s timeout depending on how long the service takes to start (60s for standalone images that run migrations and key rotation …
- Very specific: line 208
Use ${VAR:=default} to set a default only when the variable is unset or empty.
- Very specific: line 237
Avoid timeout <N> <cmd> — absent on macOS without Homebrew coreutils.
- Very specific: line 239
Avoid readlink -f — absent on macOS.
- Very specific: line 271
Use printf '%s\n' over echo for portability.
- Very specific: line 276
Never build separator lines with tr ' ' '─'.
- Very specific: line 296
trap cleanup INT EXIT ERR: Use INT EXIT only; ERR fires the cleanup twice. - Very specific: line 299
echofor error messages: Use printf "...\n" >&2. - Very specific: line 300
Missing argument validation: Check $# at the top and print usage to stderr.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Bash Script Writing Skill
Shell scripts in A-Novel backend services live in scripts/ (developer tooling) or builds/
(container entrypoints). Read the relevant section for the task at hand; the conventions at the end
apply to every script.
Before writing or editing any script, read the existing scripts in scripts/ and builds/ to
understand the current patterns.
Out of Scope: Release / Publish Scripts
Do not write or revive a scripts/publish.sh or scripts/prepublish-version.sh for any
service or kit repo. The release flow — version bump (workspace-aware), commit, tag, push, and
GitHub Release — runs in CI in the release-core GitHub Action (a-novel-kit/workflows),
triggered from the repo's release workflow with a release-type selector (patch / minor / major).
It auto-detects whether the repo uses pnpm-workspace.yaml (recursive pnpm version) or not
(root-only) and refreshes doc refs via prepublish:doc (a-novel publish stamp). The bash
equivalents were deleted because each copy drifted independently (pnpm 10 → 11 broke two of
three at once); no local release command replaces them.
The security model for who can publish is server-side, not script-side: GitHub branch
protection on master AND tag protection on v* enforce "only X can release." The CLI's preflight
is UX — it surfaces a 403 before any local mutation — but it is not a boundary.
In-container release steps (Docker image builds, npm publish on a pushed tag) are out of scope too —
they live in the a-novel-kit/workflows/ composite GitHub Actions and run on CI, not on a developer
machine. Do not duplicate that logic in a bash script.
After Every Edit
Run bash -n <script> to syntax-check without executing:
bash -n scripts/my-script.shThen run the script in a safe context (local env, no production credentials) to verify runtime
behaviour. For test runs, always go through a-novel test (per the use-a-novel-cli rule) —
the bash test-runner scripts it replaced no longer exist. Never trigger the release workflow
to test: it cuts a real release (pushes a tag, creates a GitHub Release).
Structure of Every Script
#!/bin/bash
# One-line description of what the script does and when to run it.
# Usage note if the script takes arguments.
set -e # Exit immediately if any command returns non-zero.
# ... argument validation ...
# ... variable declarations ...
# ... trap registration ...
# ... body ...set -e is mandatory on every script. A failed command (a container that failed to build, say)
then stops execution immediately instead of continuing with broken state.
#!/bin/bash — always use bash, not /bin/sh. The scripts use bash-specific features
($OSTYPE, [[, process substitution) and must not silently degrade under sh.
Cleanup Traps
Scripts that start external processes (containers, servers) must register a cleanup trap that tears them down regardless of how the script exits:
APP_NAME="service-json-keys-test"
PODMAN_FILE="$PWD/builds/podman-compose.test.yaml"
cleanup() {
podman compose -p "${APP_NAME}" -f "${PODMAN_FILE}" down --volume
}
trap cleanup INT EXITKey rules:
- Register on
EXITandINTonly — notERR. Withset -e, a failing command exits the shell, which triggersEXIT; addingERRfires the cleanup twice and prints spurious errors. - Name the function
cleanup(notint_handleror similar) — the name describes what it does, not when it is called. - Register the trap before sourcing env files or starting any process, so cleanup is armed as early as possible.
- At the end of normal execution, let the
EXITtrap handle teardown — do not calldowntwice.
Argument Validation
Every script that takes arguments validates them at the top, before doing any work:
if [ $# -ne 1 ]; then
printf "Usage: %s <new-version>\n" "$0" >&2
exit 1
fi- Use
printf(notecho) for error messages —echobehaviour with flags (-e,-n) differs between implementations. - Write error messages to stderr (
>&2). - Use
$0for the script name, so the message stays correct however the script was invoked.
Quoting and Variable Safety
Always double-quote variable expansions:
# Wrong — breaks on spaces, glob-expands
sed -i -E "s|pattern|replace|g" $2
# Correct
sed -i -E "s|pattern|replace|g" "$2"Exceptions: a deliberately word-split variable (a space-separated list of Go packages passed as
separate arguments to gotestsum, say) stays unquoted, suppressed with
# shellcheck disable=SC2046:
# shellcheck disable=SC2046
go tool -modfile=gotestsum.mod gotestsum --format pkgname -- -count=1 -cover $PACKAGESService Readiness
podman compose up -d returns as soon as containers are created, not when services are healthy.
Always wait for readiness before running tests or migrations against a service.
Waiting for a container's HEALTHCHECK (database, gRPC server)
Poll podman inspect until the container's health status is healthy:
elapsed=0
until [ "$(podman inspect --format '{{.State.Health.Status}}' "${APP_NAME}_postgres-json-keys_1" 2>/dev/null)" = "healthy" ]; do
elapsed=$((elapsed + 1))
if [ "$elapsed" -ge 30 ]; then
printf "error: postgres container did not become healthy within 30s\n" >&2
exit 1
fi
sleep 1
done- Use a 30–60s timeout depending on how long the service takes to start (60s for standalone images that run migrations and key rotation before starting the server).
- The container name format is
${APP_NAME}_${service_name}_1, whereAPP_NAMEis the compose project name andservice_nameis the service key in the compose file. - Redirect
podman inspectstderr to/dev/null— the container might not exist yet at the first poll.
Waiting for an HTTP endpoint (REST server)
Poll the health endpoint with curl -sf (suppress progress, fail on HTTP error):
elapsed=0
until curl -sf "${REST_URL}/ping" > /dev/null 2>&1; do
elapsed=$((elapsed + 1))
if [ "$elapsed" -ge 60 ]; then
printf "error: REST service did not become ready within 60s\n" >&2
exit 1
fi
printf "Waiting for REST service on port %s... (%ds)\n" "${REST_PORT}" "$elapsed"
sleep 1
donecurl is available on Linux and macOS without installation. wget is an acceptable alternative
(wget -q --spider <url>) but is absent on macOS by default.
Environment Variables
Port and URL allocation is the a-novel daemon's job (a-novel run env <service>); the old
scripts/setup-env.sh files that hand-rolled random ports are gone. A script that genuinely needs
an env file uses the assign-if-unset pattern, so pre-exported values survive:
POSTGRES_PORT="${POSTGRES_PORT:=5432}"
export POSTGRES_PORT- Use
${VAR:=default}to set a default only when the variable is unset or empty. - Source env files with
. "$PWD/scripts/env.sh"(notbash scripts/env.sh), so the exported variables are visible in the calling shell.
Cross-Platform Portability
Scripts must work on both Linux (GNU tools) and macOS (BSD tools). The main divergence is sed:
case "$OSTYPE" in
darwin*|bsd*)
sed -i '' -E "s|pattern|replace|g" "$file"
;;
*)
sed -i -E "s|pattern|replace|g" "$file"
;;
esacGNU sed takes -i with no argument for in-place editing; BSD sed (macOS) requires -i ''.
$OSTYPE is reliable in bash on both platforms.
Other portability notes:
printfinstead ofechofor formatted output — its behaviour is consistent across platforms.[ ... ](POSIX test) instead of[[ ... ]]in conditionals unless bash-specific features (regex,&&,||inside test) are needed — both work in bash, but[states the intent.- Avoid
timeout <N> <cmd>— absent on macOS without Homebrew coreutils. Use a polling loop with an elapsed counter instead. - Avoid
readlink -f— absent on macOS. Userealpathif available, or compute absolute paths relative to$PWDor$0.
Output Formatting and Colors
The first question for any new user-facing script is "does this belong in the a-novel CLI
instead?" New tooling belongs in a Cobra subcommand under cli/internal/cli/ rather than a fresh
scripts/*.sh — Go gives us testable flag parsing, real error types, and consistent help output.
The stack's bash inventory shrunk to near-zero when sync-repos and bot-token became
a-novel core sync and a-novel core bot-comment. Only tiny shims (often consumed by CI), where a
Cobra command would outweigh the script itself, stay in bash.
For that rare bash, keep formatting minimal: plain printf, no shared helper library, and honor
NO_COLOR if you emit colors at all.
# Honor NO_COLOR up front; everything else can branch on $RED/$RESET etc.
if [ -z "${NO_COLOR:-}" ] && [ -t 1 ]; then
RED=$'\033[31m'; GREEN=$'\033[32m'; YELLOW=$'\033[33m'; RESET=$'\033[0m'
else
RED=""; GREEN=""; YELLOW=""; RESET=""
fi
printf "%s✓%s %s\n" "${GREEN}" "${RESET}" "service-json-keys updated"
printf "%s✗%s %s\n" "${RED}" "${RESET}" "remote unreachable" >&2Conventions:
- Errors and warnings go to stderr (
>&2). Successes and progress go to stdout. - Use
printf '%s\n'overechofor portability. - Colors: green = success, yellow = warning, red = error. Never set backgrounds; avoid bright-white and bright-black — they vanish on light or dark themes.
- Honor
NO_COLOR(set → never emit escapes). Don't gate onFORCE_COLORunless a caller asks for it — keep the scaffolding small. - Never build separator lines with
tr ' ' '─'.tris byte-oriented, so it corrupts the multi-byte UTF-8 sequence (0xE2 0x94 0x80) by replacing each space with only0xE2, and terminals render the invalid output as garbled??glyphs. Use a pure-bash loop,printf -v, or a pre-built constant string.
Long-lived interactive UX (rich progress, multi-phase banners, spinners) is the Go CLI's job. A bash
script growing a banner/log_step framework should move under a-novel instead.
Common Pitfalls
Most entries are stated in full in the sections above; this list is the review checklist.
- Missing
set -e. A failedpodman compose up --buildis then ignored, and the migrations and tests that follow run against nonexistent containers — a confusing "connection refused" instead of a clear build failure. - A command substitution used as a
caseor test word.set -enever sees its failure:case "$(gh api …)" inmatches an empty string and the script carries on. Assign it first,state="$(gh api …)", so a failed call stops the script. trap cleanup INT EXIT ERR. UseINT EXITonly;ERRfires the cleanup twice.- No readiness wait after
compose up -d. Poll for health before running tests or migrations. - Unquoted
$1/$2in function calls. Breaks on paths and version strings containing spaces. echofor error messages. Useprintf "...\n" >&2.- Missing argument validation. Check
$#at the top and print usage to stderr. - Running node twice for the same value. Cache the result of a repeated
node -p "require('./package.json').version"in a variable:VERSION="$(node -p "require('./package.json').version")" - A process per loop iteration. Each command substitution,
jq,grep, orgh apicall inside a loop forks a process, and an API call also spends rate limit. Run the tool once over the whole input and loop over its output. Resolve what a poll loop does not change, such as an instance name, before the loop starts. - Rows split on tabs. Tab is IFS whitespace, so
@tsvwithIFS=$'\t' readcollapses empty fields and shifts the rest left. Join a row's fields with the unit separator,join("\u001f"), and read them withIFS=$'\x1f' read -r a b c. - A recursive function in a pipeline. Each level forks a subshell. Walk the tree with a worklist array instead.
- Control flow hidden at the end of a line. A
continue,returnorexitafter a longecho … | teesits where nobody reads it. Give it its own line. A long loop body becomes a function with guard-clausereturns. sourceinstead of.. Both work in bash, but.is POSIX. Use.for consistency.tr ' ' '─'for separator lines. It corrupts the multi-byte UTF-8 character; build the rule with a pure-bash loop orprintf -v.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

