crust-builtins

crust has two namespaces — tool builtins (mock-server, test-fixture, test-pipes, gen-fixtures, verify-web-links, logs, dotenv, skills) that need a line of their own, and registered functions (base64, salt, jwt, bundle, sql, wait) that are space-separated stages. What the upstream item means to each one, why bundle(5) is a shell syntax error, how to reach crypto/FS from a lambda, and that -h/--help answers with a usage screen without running anything.

crust-builtins answers the question every newcomer gets wrong in the first ten minutes: is this name a command, a stage, or a function? There are three answers and they are spelled almost identically.

The two namespaces

What it isWhere it runsArgument style
Tool builtinmock-server, test-fixture, test-pipes, gen-fixtures, verify-web-links, logs, dotenv, skillsdispatched before pipeline parsing, in-process, own linesubcommand style: logs tail -F app.log
Registered functionbase64, salt, jwt, bundle, sql, wait + anything you add with crust.fnan ordinary pipeline stage, or a source when nothing is upstreamspace-separated: salt 16, not salt(16)

cd, export, alias, source, exit, history and help are builtins too — the ones sh already has its own version of, which is why a redirect on those is left to sh. procs, range, load, read, tail and friends are sources, not builtins.

source is the only session builtin with its own argument grammar: source <file> [args…] — a .ts/.js file is imported; a .sh/.bash/.zsh file (or one whose first line is a #! shebang naming a shell) runs once in a child shell — its exported KEY=value delta comes back into the session, its exit code is the line’s, its side effects are real, but unsets don’t propagate and its cwd never comes back; anything else runs line-by-line in this session (aliases, exports and captures persist). An unmarked file that looks like multi-line shell, or that doesn’t parse as crust, is refused, never half-run. In the line-by-line form the args are the sourced file’s positional arguments ($1, $2, … — $0 is its own path), and the caller’s positionals are restored when the file ends; the shell form’s child never sees them — its $1… are its own shell’s, not the file’s:

# demo.sh does `export DEMO=one` and changes cwd; only the export survives
source ./demo.sh
export   # → … DEMO=one … — the env delta came back, the cwd did not
# line-by-line only: the args are the file's $1… ($0 its path)
source ./more.crust one two

From a shell, the two groups split again. The tool builtins are also argv subcommands — crust mock-server ./openapi.json -p4000, crust skills install — with the same flags as the line. The session builtins are not: cd, export, alias, unalias, source, dotenv, exit, history and help change state a one-shot process does not have, so crust cd /tmp exits 2 naming the builtin rather than quietly doing nothing. logs stays line-only too, because its argument is the rest of the line verbatim — a procs({...}) literal argv cannot carry without re-lexing it. Before both of those, each fell through to script mode and answered cannot read export at exit 127, which in a binary printing its own name reads as a broken installation.

The single most common mistake is writing the JavaScript form:

bundle(5)   →  sh: -c: line 1: syntax error near unexpected token `5'   (exit 2)

Parens are crust syntax only for lexer sources (range(0,9), read("…")). Everything else in a stage is a shell word list, and sh is the one that tells you so.

What the item means, per function

A registered function is called fn(item, ...lineArgs) mid-pipeline, so the first slot is your data and each function decides what to do with it:

FunctionAs a sourceMid-pipeline
base64 [-d|decode] [-f|--file <path>] [-o|--out <path>]encodes the literal argument — or a file’s bytes with --fileencodes (or decodes) each item; the item is data, never an option
salt [bytes] [hex|base64|base64url]one salt, size = the argumentone salt per item; the size is the argument trailing the item
jwt sign|verify|decode --secret <s>payload/token = the argumentthe item is the payload (sign) or the token (verify/decode)
sql "<query>" [params…]streams rowsthe query stays the one you wrote; the item binds as the first parameter when the line declares none
wait <target> [--timeout <dur>]waits for the argumentthe item is the target
bundle <entry> [--outfile…]bundles the argumenta string item is an entrypoint
base64 hello
echo aGk= | base64 -d
salt 32 base64
lines users.csv | salt 16
jwt sign '{"sub":"42"}' --secret k
sql "select id, email from users limit 5" | (r => r.email)
range(2,2) | (n => "beta") | sql "SELECT name FROM t WHERE name = ?" | assert (r => r.name === "beta")
wait :3001/health --timeout 30s
bundle src/index.ts --outfile dist/app.js --minify

That sql line is the whole contract in one example: the piped item binds as the query’s first parameter, it never becomes the query. wait failing to become ready exits 1, measured for :PORT, :PORT/path and port:PORT and for an expiring --timeout — see crust-cli for why there is no 124.

-h/--help answers at parse time

Each of the six functions answers -h/--help with its own usage screen and exit 0 — before any stage runs, so an upstream stage with side effects never runs: … | sql --help prints usage without making the request, and the touch upstream of it never happens (measured). The scan stops at --, so base64 -- --help encodes the flag, and a value that arrives through the pipe is data, never an option (printf '%s' --help | base64 encodes it). A crust.fn override of the name keeps its handler’s own argument handling — --help is just another argument to it.

base64 --help
  → usage: base64 [text] [-d|--decode] [-f|--file <path>] [-o|--out <path>]
    exit 0 — nothing ran

That is the PR #60 change. In 0.2.4 the flag is ordinary data — base64 --help prints LS1oZWxw.

A lambda cannot call them

Registered functions live in crust’s function table, not in the JS scope a lambda compiles into:

range(2,2) | (x => salt(4))   →  crust: salt is not defined   (exit 1)

That is not a dead end. A lambda body has Bun’s own globals, and an async lambda can await in it, so reach the underlying capability directly:

range(0,9) | (_ => crypto.getRandomValues(new Uint8Array(8)).toHex())
range(0,0) | (async _ => JSON.parse(await Bun.file("package.json").text()).name)

Write async on the lambda. crust awaits the result either way, but a bare await in a non-async body is a parse error in every released binary, so the keyword is what makes the line run everywhere. Prefer the registered function when it fits — salt 16 per item is shorter than the crypto line — and drop to a lambda when you need something crust doesn’t ship.

Session environment

A line of pure assignments is the session environment — it runs the same code export runs, so the two spellings cannot disagree:

FOO=bar
PATH=$PATH:/tmp/bin
CFLAGS+=$EXTRA_FLAGS
TOKEN='$literal'

FOO=bar used to be sh -c 'FOO=bar' — exit 0, value gone, and the next line expanded $FOO to nothing. Quoting is sh’s: '…' protects $, "…" expands it, and a backslash protects exactly one character, so FOO=C:\temp survives.

Two shapes are deliberately not session state:

  • FOO=bar cmd args is sh’s transient prefix — the variable lives for that one command — so the line goes to sh untouched and later lines see nothing.
  • FOO=$(cmd) is refused rather than run: its value would have to cross back out of a child process. The honest route is a pipeline — git rev-parse HEAD piped into a capture stage of that name. Quoting doesn’t change that, and neither does a pipe inside the substitution (FOO=$(date | head -1) is refused too, not quietly dropped); a $() anywhere else on a line stays sh’s, so echo "$(date)" still prints the date.

These are not shell commands

base64, sort, head, wc and friends exist as real binaries too. crust runs its own when the name is registered (base64 hello encodes the text, not a file), and sh’s when it is not (… | sort is /usr/bin/sort). Neither takes a redirect: crust refuses it and tells you what to write instead — with two different messages, because a function and a tool fail differently.

A registered function with a redirect is refused as a stage and pointed at a shell tail:

sql "select 1" > one.txt
  → crust: `> one.txt`: crust has no redirect except on a shell stage, whose
    text goes to sh as-is — a sql stage would read it as data. to save a
    pipeline, end the line with a shell stage instead: | cat > one.txt

The refusal also catches a stage the redirect hides. Stage patterns match the whole text, so range(1,3) > f stopped looking like a range the moment the tail was appended and fell through to sh -c, which answered with its own syntax error at exit 2 — a message with no way out in it. crust now strips the tail, sees what the stage was, and refuses:

range(1,3) > nums.txt
  → crust: `> nums.txt`: crust has no redirect except on a shell stage, whose
    text goes to sh as-is — a range stage would read it as data. to save a
    pipeline, end the line with a shell stage instead: | cat > nums.txt

Same for capture N > f, parallel 5 > f, expect 200 > f, stats > f, stdin > f and load 10s 100/s > f. grep is the deliberate exception: a grep stage’s redirect belongs to sh and works, which is why read **/*.log | grep ERROR > errors.txt is a supported way to save a filtered log.

base64 hello | cat > out.b64

A tool builtin is refused as a program, and the message prints the exact whole-invocation redirect that works:

verify-web-links --base-url https://example.com --json > report.json
  → crust: verify-web-links is a crust builtin, it runs in-process and cannot
    redirect. Redirect the whole invocation instead:
    crust -c 'verify-web-links --base-url https://example.com --json' > report.json
crust -c 'verify-web-links --base-url https://example.com --json' > report.json

Tools that produce a file themselves have a flag for it (-o / --outfile / --report); check that before reaching for >.

Traps

  • base64 in.ts encodes the path text, not the file — crust’s own function answers first. Measured on a file containing alpha\nbeta\n:
    base64 in.ts          → aW4udHM=              "in.ts"
    read in.ts | base64   → YWxwaGEKYmV0YQo=      "alpha\nbeta\n"   = /usr/bin/base64
    lines in.ts | base64  → YWxwaGE= / YmV0YQ==   once per line
    read yields the whole content, so it is the byte-exact form for text; lines splits it first, so per-line encodes are what you get. A pipeline decodes UTF-8 on the way, so it is not binary-safe: on a 26-byte PNG, read logo.png | base64 gives 77+9UE5H… where /usr/bin/base64 -w0 logo.png gives iVBORw0KGgo….
  • A file’s bytes are --file, and --out writes the answer. base64 --file logo.png reads bytes and skips the pipeline entirely; --out <path> puts the result in a file, which is what a redirect would have done if a function stage could take one. base64 --file logo.b64 -d --out logo-copy.png is the way back, byte for byte. Saying the path out loud: crust now prints a note: on stderr when the operand you typed is a file, and leaves stdout alone. These two flags are current on main; they are not in the published release this page’s grammar gate lints against, which is why they appear in prose here and not in a ```crust fence.
  • Options are what you typed, never what the item says. A mid-pipeline function used to scan every argument for -d, so data that read -d flipped the mode and the input disappeared: printf '%s' -d | crust -c 'stdin | base64' died with base64: missing input (exit 1). It is LWQ= now — the two characters, encoded. The flip side is that a typo’d flag is refused instead of encoded: base64 --desc hello printed LS1kZXNj and exit 0, and now exits 1 naming the options it does have.
  • Decoding checks its input. not base64!!! decoded to three replacement characters and exit 0, because Node’s base64 decoder skips characters it does not know. Anything outside A-Za-z0-9+/=, base64url’s - and _, and whitespace is now an error.
  • sort/head/wc are ambiguous by design. Reading … | sort as sh’s is correct; reading base64 hello as /usr/bin/base64 is not.
  • A redirect never belongs to crust. Either end the line with a shell stage (| cat > out.txt) or redirect the whole invocation from the shell.
  • fn(item, ...args) means the item is first, so a function that also takes arguments reads them after your data. That is where salt’s byte count and jwt’s operation have to sit.