Skill: Craft Sh
"A script you must read top to bottom is a script nobody reads."
Shell is no excuse. What makes a class readable makes a script readable.
The Shape
main at the top. main "$@" at the bottom. Everything between is detail.
main() {
read_arguments "$@"
locate_worktree
refuse_main_checkout
ensure_herd_serves
ensure_database_answers
point_env_at_site
report
}
Seven lines, the whole story. The test: can a stranger describe the script after reading only
main? If not, a name is wrong or a step does two jobs.
The Standard
One job per function, and few lines. If the name needs "and", split it.
Length is the signal. When a function grows, the verbosity has already started — that is the
moment another function is merited, not once it is unreadable. The test is the call site:
fetch_objects; check_out_ref; point_at_origin reads as English. A body you have to assemble does
not.
An embedded program is code. An awk or sed inside a single-quoted string obeys every rule
here — one job, named steps, early return. A string is not a reason to stop. Once it has more than
one job, give it -f and a file of its own.
Early return. Never else. Guard, return, carry on. Zero else reads downward, not sideways.
An else is a function you have not named yet — it holds a second job, which is why it needed
a second branch. Extract it and the else disappears on its own. Same for elif, once per arm.
In a loop, continue is the early return.
A condition is a named predicate. herd_is_listening beats nc -z 127.0.0.1 443. The name
holds the meaning; the command is detail.
Verbs act, ensure_ guards. install_vendor works. ensure_site_is_secured makes something
true or stops. The prefix tells the reader which.
Poll the oracle, not the report. Tools print ERROR and succeed. Read the port, the file, a
separate check.
And a pipe answers for its last stage. cmd | head reports head, so $? is not the
command's. The pipe you added to shorten the output is the one that hides the answer.
Guard every flag value. --name with no value leaves shift 2 short and $# unchanged, so
the loop never ends.
Decide set -e in writing. It is on, or the header says why not. Silence means you never
decided.
Comments are their own skill. See kernel:craft-comment — what earns a line, the taper,
and the space that means no comment was needed.
shellcheck passes on the machine you write on. Not optional there, and nothing checks it
here — a gate reaching for it would stop working on a host that has POSIX and git and nothing
else, which is the case every other rule about shipped code is written for.
So it binds where the tool is, and the tree grades what an exit code can hold: bin/shell.sh
takes rules 2 and 1's length half, and taper takes rule 8.
One name, one meaning. Every variable is global unless you say otherwise, so a name that
means two things is a bug waiting for a refactor.
A name says what it returns. unit_targets_file, not unit_targets — the call-site should
read as the thing it gets.
One voice. step, note, fail — never echo everywhere. One place to change how a script
speaks.
No bare exit 1. Document the codes. A caller cannot branch on "it broke".
Examples
One subject per file. Open the one the rule sent you to.
|
|
| shape |
predicates, guard clauses, ensure_ |
| oracles |
polling what a tool claims, guarding a flag value |
| voice |
one voice, traps |
| comments |
the taper, breathing |
| portability |
what is BSD-only, and what to write instead |
1---2name: craft-sh3description: Crafting a shell script that reads like prose. One story in main, one job per function, early returns, and the ways a shell lies to you.4---56# Skill: Craft Sh78> "A script you must read top to bottom is a script nobody reads."910Shell is no excuse. What makes a class readable makes a script readable.1112## The Shape1314`main` at the top. `main "$@"` at the bottom. Everything between is detail.1516```bash17main() {18 read_arguments "$@"19 locate_worktree20 refuse_main_checkout2122 ensure_herd_serves23 ensure_database_answers24 point_env_at_site25 report26}27```2829Seven lines, the whole story. The test: **can a stranger describe the script after reading only30`main`?** If not, a name is wrong or a step does two jobs.3132## The Standard33341. **One job per function, and few lines.** If the name needs "and", split it.35 **Length is the signal.** When a function grows, the verbosity has already started — that is the36 moment another function is merited, not once it is unreadable. The test is the call site:37 `fetch_objects; check_out_ref; point_at_origin` reads as English. A body you have to assemble does38 not.39 **An embedded program is code.** An `awk` or `sed` inside a single-quoted string obeys every rule40 here — one job, named steps, early return. A string is not a reason to stop. Once it has more than41 one job, give it `-f` and a file of its own.42432. **Early return. Never `else`.** Guard, return, carry on. Zero `else` reads downward, not sideways.44 **An `else` is a function you have not named yet** — it holds a second job, which is why it needed45 a second branch. Extract it and the `else` disappears on its own. Same for `elif`, once per arm.46 In a loop, `continue` is the early return.473. **A condition is a named predicate.** `herd_is_listening` beats `nc -z 127.0.0.1 443`. The name48 holds the meaning; the command is detail.494. **Verbs act, `ensure_` guards.** `install_vendor` works. `ensure_site_is_secured` makes something50 true or stops. The prefix tells the reader which.515. **Poll the oracle, not the report.** Tools print `ERROR` and succeed. Read the port, the file, a52 separate check.53 **And a pipe answers for its last stage.** `cmd | head` reports `head`, so `$?` is not the54 command's. The pipe you added to shorten the output is the one that hides the answer.556. **Guard every flag value.** `--name` with no value leaves `shift 2` short and `$#` unchanged, so56 the loop never ends.577. **Decide `set -e` in writing.** It is on, or the header says why not. Silence means you never58 decided.598. **Comments are their own skill.** See `kernel:craft-comment` — what earns a line, the taper,60 and the space that means no comment was needed.619. **`shellcheck` passes on the machine you write on.** Not optional there, and **nothing checks it62 here** — a gate reaching for it would stop working on a host that has POSIX and `git` and nothing63 else, which is the case every other rule about shipped code is written for.64 So it binds where the tool is, and the tree grades what an exit code can hold: `bin/shell.sh`65 takes rules 2 and 1's length half, and `taper` takes rule 8.6610. **One name, one meaning.** Every variable is global unless you say otherwise, so a name that67 means two things is a bug waiting for a refactor.6811. **A name says what it returns.** `unit_targets_file`, not `unit_targets` — the call-site should69 read as the thing it gets.7012. **One voice.** `step`, `note`, `fail` — never `echo` everywhere. One place to change how a script71 speaks.7213. **No bare `exit 1`.** Document the codes. A caller cannot branch on "it broke".7374## Examples7576One subject per file. Open the one the rule sent you to.7778| | |79|---|---|80| [shape](shape.md) | predicates, guard clauses, `ensure_` |81| [oracles](oracles.md) | polling what a tool claims, guarding a flag value |82| [voice](voice.md) | one voice, traps |83| [comments](comments.md) | the taper, breathing |84| [portability](portability.md) | what is BSD-only, and what to write instead |