craft-sh
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.
SKILL.mdplugins/kernel/skills/craft-sh/SKILL.md
GitHubSkill: 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_originreads as English. A body you have to assemble does not. An embedded program is code. Anawkorsedinside 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-fand a file of its own.Early return. Never
else. Guard, return, carry on. Zeroelsereads downward, not sideways. Anelseis a function you have not named yet — it holds a second job, which is why it needed a second branch. Extract it and theelsedisappears on its own. Same forelif, once per arm. In a loop,continueis the early return.A condition is a named predicate.
herd_is_listeningbeatsnc -z 127.0.0.1 443. The name holds the meaning; the command is detail.Verbs act,
ensure_guards.install_vendorworks.ensure_site_is_securedmakes something true or stops. The prefix tells the reader which.Poll the oracle, not the report. Tools print
ERRORand succeed. Read the port, the file, a separate check. And a pipe answers for its last stage.cmd | headreportshead, 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.
--namewith no value leavesshift 2short and$#unchanged, so the loop never ends.Decide
set -ein 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.shellcheckpasses 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 andgitand 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.shtakes rules 2 and 1's length half, andtapertakes 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, notunit_targets— the call-site should read as the thing it gets.One voice.
step,note,fail— neverechoeverywhere. 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 |