crust-cli

How to invoke crust and how to read what it answers — the four run modes, what each exit code actually means (0/1/2/127/130, and why there is no 124), --check as a linter including what it cannot catch, env files, the REPL's real keybindings, and --build-info as the release-comparison primitive.

crust-cli is the skill for the two questions CI always asks: did that line run, and why did it say that number. Everything here was measured against the binary, not inferred from its source.

Run modes

InvocationWhat happens
crust (stdin is a TTY)Interactive REPL.
crust (stdin is piped)Reads lines of crust from stdin to EOF and runs them like a script — fail-fast, same exit codes. stdin is the command source here, so a shell stage inside those lines sees EOF. Want data on stdin? Use -c.
crust run.crust [args…]Script mode: blank lines and # comments are skipped (so a #!/usr/bin/env crust shebang works), lines run in order, and the first failing line stops the file and becomes the process exit code. A line ending in \ continues on the next. The extension doesn’t matter. Extra arguments are the script’s positional arguments — $0 is the path, $1…$9 the rest, $# the count, $@/$* all of them joined — expanding wherever an env var does, and empty, never literal, outside script mode. A leading -- after the path is a separator, not an argument.
crust <tool> [args…]A tool builtin as a subcommand: crust skills install, crust mock-server openapi.json -p4000, crust test-fixture 'fixtures/*.crust.ts' -j8. Same flags as the REPL line; crust <tool> --help prints them. A file that exists wins when it is the only argument, so crust skills runs ./skills. The session builtins (cd, export, alias, source, dotenv) and logs are not reachable this way, and say so by name at exit 2 — a one-shot process has no session to change, and logs takes the rest of its line verbatim.
crust -c '<line>'One line. A shell stage inherits the process stdin, so docker logs app | crust -c 'grep ERROR' really does grep the logs. To feed a crust pipeline, name the source: docker logs -f app | crust -c 'stdin | grep ERROR'.
crust --check '<line>'Parse without running. Below.
crust --env-file <path> …Loads .env before any run mode and before init.ts.
crust -h / -VUsage / version, exit 0.
crust --build-infoBuild provenance as JSON on stdout (exit 0, nothing on stderr) — and exactly four keys, { "version", "gitCommit", "bun", "platform" }: the key set is the whole output, so a reader can rely on the shape. gitCommit is the commit the tag resolves to, stamped by the release workflow on the GitHub/npmjs plane; it is "dev" on builds from an unstamped tree — dev, local, and the LAN/Jenkins plane, whose version string already carries the short sha. Listed in --help beside -V. Comparing releases uses it.

Two argument mistakes are refused with exit 2 rather than guessed at, because a silently-dropped argument is how a green CI step runs the wrong test: -c given two lines as two arguments, and an unquoted pipeline — -c range(1,2) extra splits into three argv words. Several lines in one -c are fine: put a newline inside the one argument and they behave like a script.

That is also the rule a copied example depends on. Every bare tool name in a ```bash block on this site — mock-server ./openapi.json -p4000, skills list — is a crust line, so it runs at the REPL, in a script, or as crust -c '<line>'. Prefix it with crust and it runs from your shell the same way: crust mock-server ./openapi.json -p4000. On crust 0.2.4 and earlier the shell spelling was the mistake to avoid — every positional was a script path, so that line exited 2 and a lone tool name exited 127, which looks exactly like a missing binary. If crust <tool> reports cannot read <tool>, that is your crust, not a missing install.

Comparing releases

A tag is a release, and a docs-only tag still ships a fresh ~93 MB binary — v0.3.1 over v0.3.0 changed nothing but prose, and proving the two releases were code-identical used to mean a manual A/B of the two cached copies. As of v0.3.2 every binary answers for itself, and the npm launcher caches one directory per version, so the comparison is one --build-info per release:

~/.cache/crust/<verA>-linux-x64/crust --build-info
~/.cache/crust/<verB>-linux-x64/crust --build-info
git diff vA..vB   # in a checkout of the crust repo

Equal version + gitCommit is the same source tree — the stamp is the commit the tag resolves to — and git diff vA..vB shows exactly what (or nothing) changed: that is how a docs-only release proves itself as one, in one command per binary. The curl channel installs one binary into ~/.local/bin, so to keep two releases side by side, install the older one into its own directory with the installer’s own options (--version <verA> --dir /opt/<verA>) and the same two --build-info commands compare them. Builds from an unstamped tree — dev, local, and the LAN/Jenkins plane, whose version string already carries the short sha — report gitCommit: "dev".

Exit codes

CodeMeaningExamples
0Success.range(1,2)
1crust failed — every error crust itself raises.assert mismatch, a lambda that throws, expect 200 against a 500, wait: <target> not ready after Ns, a GET that can’t connect, read on a missing file, a refused empty stage, a builtin name that reached sh.
2Invocation or syntax error — nothing ran.crust --nonsense, --check with no line, --env-file on a missing file, crust x.crust extra, mock-server --bogus (which prints the valid flags), sh’s own parse errors on a pure-shell line (bundle(5)) — and, as of PR #60, the same parse error on any shell stage of a mixed line (named, and nothing ran; a bad stage N used to surface only when its child exited, after stages 1..N-1 had run).
the stage’s ownA shell stage’s exit code, propagated.range(1,3) | sh -c "exit 9" → 9
127Command not found in a shell stage, or an unreadable script file.nosuchbinary-xyz
130 / 143crust killed by SIGINT / SIGTERM.Ctrl-C during a long -c run

There is no crust exit 124. wait on a target that never answers exits 1 with crust: wait: <target> not ready after Ns. A 124 in your logs is timeout(1) — your harness — killing crust, so give wait, load and procs probes a generous outer timeout. The same caution is why nobody should trust $? read after a pipe: | head masks both the code and the error text.

One refusal costs people hours and is not a bug: a bare wait :3000 probes /, and readiness means a 2xx. A mock server or an API with no root route answers 404 forever, so the line fails after the full timeout against a server that is perfectly up. Ask for a health path (wait :3000/health) or a TCP connect (wait port:3000) — and crust now says which probe it ran.

The last command wins

This is the trap that turns a CI step green on a failure. A line made entirely of shell stages is handed to sh as one sh -c string, so its code is the last command’s — there is no pipefail:

ls /nope | wc -l      # 0 — wc succeeded
ls /nope              # 2

The moment a line contains a crust stage, crust owns the exit code and a failing shell stage propagates. So a gate must end on a crust stage:

wait :3000/health
range(1,99) | parallel 10 | GET http://localhost:3000/items | expect 200 | stats
GET http://localhost:3000/items | assert (x => x.length > 0)

Or ask sh to gate, explicitly:

sh -c 'set -o pipefail; ls /nope | wc -l'   # 2 — would be 0 without pipefail

--check: the linter, and its edges

crust --check '<line>' parses and runs nothing: no files opened, no processes spawned, no requests made. Exit 0 if every line parses, 1 with the error otherwise, printing ok: N line(s) parse. It splits on newlines and skips blank and # lines, so a whole fenced block goes in as one argument — which is how this site’s 339 examples and crust’s own docs are validated in CI.

It checks three things: crust’s grammar, a builtin-head line against that CLI’s flag spec, and every shell stage through sh -n — noexec, so cat > f under -n does not create f.

read /nonexistent/*.json | POST :3000/x

That one checks clean, on purpose: sources are lazy, so a missing path is not a parse error. Which is the whole edge — --check cannot see anything needing I/O or a live process. Not a missing fixture, not an unreachable URL, not a wrong output matcher key, not a semantic refusal at runtime (which is why --env-file and --check refuse to combine). “Checks clean” means parses, never will pass.

One more edge, for a CI that lints a docs file: $VAR is expanded from the real environment while the line is built, so a check proves the line parses for that environment. An unset variable inside a JSON literal makes the literal malformed before anything runs — --check '{"n":$N}' is a JSON parse error with N unset and ok with N=7 — while a URL, a header value or a read path is never parsed as JSON and checks clean whatever the variable holds. Lint docs lines with the environment the docs claim, or keep variables out of the literals you expect a stranger to check.

When it does fail it names the stage, because “syntax error” on a line with eight stages sends you to the wrong half of it:

crust: shell stage does not parse (stage: head -(): sh: -c: line 1: syntax error near unexpected token `('
  in: range(1,3) | head -(

The first line ends with your /bin/sh’s own first error line — in its own words and its own capitalisation, prefix included: bash-as-/bin/sh appends sh: -c: line 1: syntax error near unexpected token '(', dash (what CI usually has) ends it Syntax error: word unexpected (expecting ")"). crust captures the preflight’s stderr and keeps only that first line, passing its wording through unchanged — there is no third line — so grep for shell stage does not parse.

The runtime runs the same preflight before executing any stage of a mixed line (as of PR #60, which gave --check the preflight first and the exec path second; the 0.2.4 release had it on neither): a shell stage whose text does not parse exits 2 with the message above, and nothing has run. Measured pre-fix, touch f | base64 | head -( exited 2 and f existed — the error was in stage 3, and stage 1 ran first. A pure-shell line is one sh -c, which sh parses the whole line for before executing anything, so its own stderr stays the answer there — and 127 (command not found) is a runtime error the preflight cannot see.

Environment

HowScopeReaches sh stages?
crust --env-file <path> …one process, loaded before init.tsyes
dotenv <path> / dotenv --append / dotenv status / dotenv clearthe sessionyes
export FOO=barthe sessionyes
FOO=bar — the same line without the wordthe sessionyes
capture NAME stagewrites process.env.NAME from the stream, for later linesyes

FOO=bar runs the code export FOO=bar runs, so the two cannot drift; it used to be sh -c 'FOO=bar', which exited 0 and forgot the value. '…' protects a $, "…" expands one, a backslash protects one character (FOO=C:\temp survives), FOO+=bar appends, and A=1 B=2 assigns both. Two things stay sh’s on purpose: FOO=bar some-command is a transient prefix — the variable lives for that command only — and FOO=$(cmd) is refused, quoting or pipe inside it or not, because its value would have to cross back out of a child process. Run the command and capture it instead: git rev-parse HEAD | capture SHORT_SHA. Everywhere else a $( … ) is still sh’s, so echo "$(date)" prints the date.

export keeps the sh spellings — bare export and export -p list the environment — and refuses a word it doesn’t understand instead of skipping it silently: export A=1 -p used to set A, print nothing, and exit 0.

--env-file is loud about what it could not do: a missing file exits 2, and the “loaded” note goes to stderr so a -c pipeline’s stdout stays parseable. It exists to replace shell shims that failed silently.

The REPL

Verified in a real pty rather than read off the source, because two of the obvious guesses were wrong.

  • Editing: Ctrl-A / Ctrl-E home and end, arrow keys move (as do Home, End, Delete), Ctrl-K kill to end, Ctrl-U kill to start, Ctrl-W kill previous word, Ctrl-L repaint — the buffer survives it. Ctrl-B and Ctrl-F are not bound.
  • Tab completes a path mid-stage (ls sr + Tab → ls src/) and lists names from $PATH at the start of a stage. It does not know crust’s own builtins: verify-web- + Tab offers /usr/bin candidates and nothing useful.
  • History is up/down arrows, plain text at $XDG_DATA_HOME/crust/history (default ~/.local/share/crust/history), surviving restarts; history prints it numbered, and re-running a recalled line does not append a duplicate.
  • Ctrl-R searches that history while you type, zsh-style. The line you are typing stays put and a magenta (crust-search): line opens below it with your query and the newest entry containing it. Typing narrows the query, Backspace shortens it (each edit restarts at the newest match), another Ctrl-R steps one match further back. Matching is a case-insensitive substring, most recent first — not fuzzy.
  • Ending a search: Enter accepts — the match moves onto the prompt line, still editable, and the next Enter runs it. Arrow keys, Home and End accept the same way. Ctrl-G or Esc cancels and restores the pre-search draft byte-exact, and so does Ctrl-C, which does not clear the line while a search is live (that rule is for the normal prompt). Nothing matches — empty history, nothing containing the query, or a walk past the oldest entry — paints -- no match -- and Enter puts the query itself on the line rather than running nothing. Search never rewrites the history file, and at a … continuation prompt accepting or cancelling leaves the pending line above it intact.
  • Ctrl-C at the prompt just clears the line — at a … continuation prompt it aborts the whole pending buffer (nothing runs, nothing is stored). Mid-run it cancels: children are killed, the line reports 130, and the prompt comes back so the session continues. One item already produced by the source can still print after the repaint — cosmetic, the source is stopped.
  • Ctrl-D (or exit) leaves; the exit code is 0 unless you exit <code>.

To drive it non-interactively, remember crust checks for a TTY — so pace bytes through script:

( printf 'range(1,3)\n'; sleep 1; printf '\004' ) | script -qec 'crust' /dev/null

The REPL does backslash continuation: a single trailing \ drops to a … prompt and the buffer submits whole on a complete line. An unbalanced quote (", ', `) or an unclosed ( continues too — a pasted multi-line JSON literal, or a heredoc body inside a shell stage (the join keeps the real newlines), survives the Enter. The assembled entry is one line: one history item, ↑ recalls it whole, and the join agrees character for character with scripts, piped stdin and multi-line -c. The REPL is stricter on one edge: a single trailing \ continues there — a longer odd run (\\\) is already complete, so a mistype errors immediately instead of hanging.

Job control

cmd & is crust’s own background job: its own process group (so a signal reaches a forking sh’s whole tree), stdin /dev/null, output still on the terminal, and it outlives crust. %1 / % / %+ name jobs; a bare number names a job to fg/bg/jobs and a PID to kill.

LineWhat it does
jobs, jobs -lthe table; -l adds (pid …) and the code once there is one; an unknown option is refused, not ignored
fg [spec]resume (if stopped) and wait; the job’s own code becomes the line’s
bg [spec]resume a stopped job; an error if it was not stopped
wait %1the job wait — bare wait is the readiness stage, wait :3000/health
kill %1, kill -9 %1prints Sent SIGTERM to job 1 (pid …) — the send, not the death. kill -9 4242 is sh’s

Statuses are Running, Stopped, Done, Exit N, Terminated (128+signal), Failed (gone with no code). Finished jobs stay listed for the session, which is how wait %1 still reports the code of a job that ended minutes ago.

Do not assume a Ctrl-Z works wherever a shell’s would. It suspends a crust pipeline, a builtin, or a fg/wait (SIGSTOP → [2]+ Stopped sleep 12, prompt back, bg resumes the pipeline as one unit; Ctrl-C during fg/wait kills the job, 130) — but nothing happens during a foreground inherit-stdio child (vim, a bare sleep 20): the runtime ignores SIGTSTP and children inherit that ignore across exec, so the ^Z echoes and the child keeps running. Ctrl-C is the escape there. Running jobs are left alone at exit because outliving the session is what & means; stopped ones are resumed with a notice, since a stopped orphan is leaked until reboot.

Where crust’s globals exist

GET, range, parallel, load, procs, $ and crust.* — plus everything init.ts defines — exist wherever crust starts its runtime: the REPL, -c, piped stdin, a script file, ~/.config/crust/init.ts, and any .ts/.js file crust imports (source hooks/warmup.ts — inside it, load is simply there).

A file run by plain bun script.ts never starts crust, which is why GET is not defined there is not a bug. Import what you need from the modules, or run the file under crust. See Compiled binary vs npm module.

Auto-discovered stages

crust scans your global bun install at startup and turns opted-in packages into pipeline stages with no init.ts entry.

  • A package with a bin field and no crust field is skipped, so it keeps working as a plain shell command. Add "crust": { "stage": "default" } (or a named export) and it becomes a stage as well as a command.
  • Scoped packages are exposed after the / (@me/shout → shout). A name already registered by crust.fn in init.ts wins — discovery never overwrites explicit config.
  • The scan is cached at $CRUST_CACHE_DIR/globals.json (default ~/.cache/crust), keyed on the prefix package.json’s mtime. Change the install by hand and delete that file.
  • CRUST_DEBUG=1 prints every decision — skipping "x" — has bin field, name collision on …. That is where to look when a package you expected is simply not a stage.

Traps

  • Read $?, never after a pipe, and never trust a wrapper script that swallows argv. crust’s binary takes its own arguments and refuses the ones above; a wrapper that drops them reports success on a line that never ran.
  • --check ok is not “will pass” — it is “parses”. The gate you want is --check for the shape and a real run for the result.
  • A pure-shell line has no pipefail. End every CI line on a crust stage.
  • --env-file does not combine with --check, and a missing env file is exit 2 rather than a shrug.
  • 124 is yours, 127 might not be yours. 124 is always an outer timeout; 127 means a shell stage’s command was not found — or a script path crust could not read, which on 0.2.4 and earlier included a builtin’s name. Passing one is a command now, and it exits 2 with the tool’s own usage.