Quickstart

Every crust command, shell-line and TypeScript-API side by side.

A tour of crust’s surface. Each block shows the shell line alongside the equivalent TypeScript API call — pick the one that fits the file you’re in.

At wide viewports the two panes sit side-by-side. On narrower screens use the bsh / ts tabs at the top of each block; your choice carries across the page.

Reading the blocks. A block that starts with a tool name — mock-server ./openapi.yaml -p4000, skills install, test-fixture fixtures/*.crust.ts — is a crust line, and it runs from your shell too: name the tool the way you would name any other command and keep its flags, as in crust mock-server ./openapi.yaml -p4000 or bunx crust skills install. The same line runs at the REPL, inside a .crust script, and under crust -c. Only the session builtins — cd, export, alias, unalias, source, dotenv — are REPL-only: they change state a one-shot process does not have, so crust cd /tmp is refused instead of quietly doing nothing.

Install & launch

curl -fsSL https://raw.githubusercontent.com/lariocpt/crust/main/install.sh | bash

Prefer npm? npm i -g @lariocpt/crust — a ~4KB launcher that verifies the published sha256 before the binary ever runs. Every release is listed on the package’s page.

# Interactive REPL
crust

# One-liner mode — runs through the full pipeline parser.
# Multi-line strings are fail-fast: crust stops at the first
# failing line and exits with ITS code.
crust -c 'range(0,99) | parallel 20 | GET :3000/health | expect 200 | stats'

# Script mode — same parser, same fail-fast exit codes.
# Blank lines and # comments are skipped, so a shebang works.
# Extra arguments are the script's positionals: $0 the path, $1.. the rest.
crust deploy-checks.crust
echo 'range(1,3) | filter (n => n > 1)' | crust

# Help / version
crust -h
crust -V

Sources

A source is anything that produces a Pipeline<T>. Globs, ranges, file tails, shell commands, and HTTP verbs all work as sources.

ls                                # any shell command — source if first stage
range(0, 9)                       # 0..9 inclusive — Pipeline<number>
**/*.ts                           # glob — Pipeline<string> of paths
src/*.{ts,tsx}                    # globs support **/*, ?, [abc]
tail app.log                      # last 10 lines, then done
tail -F app.log                   # follow mode: stream new lines forever
tail logs/*.log                   # glob → multi-file merge
tail -F api.log worker.log        # follow N files at once
GET https://api.example.com/v1    # Pipeline<Response>
GET :3000/health                  # localhost shorthand
read fixtures/*.json              # whole-file contents, one item per file
{"name": "Court"}                 # JSON literal — one parsed item
load 30s 100/s                    # paced ticks: 100/s for 30s (load runs)
load 10s 50/s, 30s 200/s          # ramp: comma-separated phases, one stream
stdin                             # piped stdin, line by line (alias: -)

read <path|glob> yields each matched file’s entire contents as one item — the fixture-folder source. A bare glob (fixtures/*.json) yields paths. A stage starting { or [ is a JSON-literal source: one parsed item (env-expanded), and invalid JSON is a hard error — it never falls back to shell.

stdin (alias -) streams whatever is piped into crust — docker logs -f app | crust -c 'stdin | grep ERROR' turns any command’s output into a pipeline source. (A bare cmd | crust without -c reads the pipe as a script, so data pipes always pair with -c.)

Transforms

Stream-in, stream-out stages. Shell pipes, TS lambdas, and per-item HTTP all work.

... | grep TODO                              # native line-buffered grep (-i/-v/-F; fancier flags → system grep)
... | (line => line.toUpperCase())           # TS lambda (maps every item)
... | filter (line => line.includes('ERR'))  # keep items whose predicate is truthy
... | tr '[:lower:]' '[:upper:]'             # standard pipes work
... | POST :3000/users                       # per-item POST (body = item)
... | DELETE :3000/users/:id                 # per-item DELETE
... | POST $BASE/api/things -H "authorization: Bearer $TOKEN"

Every http verb takes repeatable -H "Key: value" flags, and URLs, -H values, and JSON literals are $VAR/${VAR} env-expanded (lambda bodies are not — they’re JS, use process.env). The :port/path localhost shorthand works for all verbs, and parallel N upstream fans out non-GET verbs too.

Assertions, concurrency, stats, capture

expect, assert, parallel, stats, and capture are shell-line keywords with exact TS-API equivalents.

range(0, 999) | parallel 50 | GET :3000/health | expect 200 | stats

# expect takes an exact status or a class: 2xx | 3xx | 4xx | 5xx
GET :3000/health | expect 2xx

# assert: falsy FAILS the pipeline — and so does an empty upstream
sql "SELECT count(*)::int AS c FROM users" | assert (r => r.c === 1)

# stats --every N: windowed delta summaries + a {final: true} cumulative one
range(0, 5999) | parallel 50 | GET :3000/health | stats --every 5

# capture: write a value into process.env — every LATER line expands $THING_ID
{"n": "x"} | POST :3000/things | (r => r.json()) | capture THING_ID (t => t.id)
GET :3000/things/$THING_ID | expect 200

assert (x => expr) differs from a lambda (which maps), from filter (which drops falsy items and passes an empty result silently), and from expect (statuses only): a falsy result fails the pipeline, and an empty upstream also fails (“no items reached”) — so a SELECT that returns zero rows can’t silently pass.

capture NAME (fn) writes at run time while $VAR expands at parse time — chaining works across lines (REPL, -c scripts, .pipes files), never within one. A nullish captured value or an empty upstream fails the pipeline immediately. See API smoke tests for full CRUD chains.

parallel streams results in completion order, not input order — a deliberate contract so downstream windowed stats see a live stream. Sort downstream if you need input order.

Timing — time "label"

time is a prefix-only decorator (bash-style). Place it before the source to wrap the whole pipeline. Elapsed wall time + item count goes to stderr when the iterator drains — even if a downstream stage throws.

time "warmup" | range(0, 1000) | GET :3000/health
# stderr → [time] warmup: 412.3ms (1001 items)

Builtins

Builtins run in-process. They dispatch when the first token matches a builtin name and the line has no pipe (|), redirect (<, >), or sequencing (&, ;) operators.

cd <dir>            # cd, cd -, cd ~, cd ~/path
export FOO=bar      # set env var (multiple KEY=value pairs accepted)
FOO=bar             # the same line without the word — `FOO+=bar` appends
export              # list env
alias g=git         # define alias (also: alias g='git status')
alias               # list aliases
unalias g

source script.sh    # .sh runs in sh
source script.ts    # .ts/.js dynamically imported

history             # list this session's lines
jobs [-l] [%N]      # list background jobs — see Job control below
fg [%N]             # bring a job to the foreground and wait for it
bg [%N]             # resume a stopped job in the background
exit [code]
help

dotenv

Loads .env into the live session (process.env). overwrite is the default; --append keeps existing values.

dotenv                            # load ./.env, overwrite mode
dotenv .env.local
dotenv .env.local --append
dotenv status                     # load history + per-key origin
dotenv clear                      # restore pre-first-load snapshot

Built-in functions

Small helpers that work as both pipeline stages and one-shot sources.

echo hello | base64                           # aGVsbG8=
echo "QmVhcmVy" | base64 -d                   # Bearer
salt 32 base64                                # 32 random bytes, base64

jwt sign '{"sub":"42"}' --secret k            # eyJhbGciOiJIUzI1Ni...
echo eyJhbGciOiJIUzI1Ni... | jwt verify --secret k

sql "select id, email from users limit 5" | (r => r.email)

# block until a target answers (2xx or TCP connect) — exit 1 on timeout
wait :3001/health --timeout 40s --interval 2s
wait port:5432 --timeout 30s

bundle src/index.ts --outfile dist/app.js --minify

test-fixture

Runs .crust.ts fixture files against an HTTP service. Each file is a normal TS module that default-exports a fixture (or array of fixtures) with input and output objects.

test-fixture fixtures/*.crust.ts
test-fixture fixtures/users.crust.ts -o report.md
test-fixture 'fixtures/**/*.crust.ts' -j8 -o report.json
test-fixture stress.crust.ts -n1000 -j32 -o report.json

# --timeout fails any fixture whose request runs longer (its own
# input.signal wins); --bail stops starting new fixtures on first failure
test-fixture 'gen/*.gen.crust.ts' -t5000 -b

For randomized inputs across iterations, see the Stress testing recipe.

test-pipes

Runs .pipes files — one shorthand fixture pipeline per line, # comments, sequential per file. A setup module (--setup, or a sibling <name>.setup.ts) has its default export awaited first to seed process.env.

test-pipes smoke.pipes
test-pipes 'tests/**/*.pipes' -b -t5000

# smoke.pipes:
# {"name":"Court"} | POST $BASE/api/buildings -H "authorization: Bearer $TOKEN" | expect 201
# sql "SELECT count(*)::int AS c FROM buildings" | assert (r => r.c === 1)

See the API smoke tests recipe for a full .pipes suite.

gen-fixtures

Generates the negative-test matrix (401/403/404 + per-field 400s and boundary violations with schema-valid base bodies) from an OpenAPI spec, as .crust.ts files runnable by test-fixture — plus capture-chained CRUD flow .pipes suites for qualifying collections.

gen-fixtures ./openapi.json
test-fixture 'tests/gen/*.gen.crust.ts' -j8
test-pipes tests/gen/flows/flows.gen.pipes    # generated CRUD flows

See the Generated negative fixtures recipe for the setup-module contract.

mock-server

Boots a Bun.serve instance that mocks every operation in an OpenAPI 3.x spec.

mock-server ./openapi.yaml -p4000
mock-server https://petstore3.swagger.io/api/v3/openapi.json -p4747
mock-server ./spec.json -p0 --host 127.0.0.1     # OS-assigned port
mock-server ./openapi.yaml -p4000 --stateful     # in-memory CRUD
mock-server ./openapi.yaml -p4000 --validate     # 422 on spec-violating requests
mock-server ./openapi.yaml -p4000 \
  --proxy http://localhost:8080 --report violations.ndjson      # conformance-audit a real backend

See the Mock server recipe for response-selection rules, stateful mode, and per-request log format.

Crawls a site from its sitemap, verifies every link is reachable, and optionally diffs Open Graph / meta tags against .crust.ts fixtures. Uses Bun’s HTMLRewriter for link and social-meta extraction.

verify-web-links https://example.com/sitemap.xml
verify-web-links https://example.com                 # auto-discover via robots.txt
verify-web-links https://example.com --fixtures site/*.meta.crust.ts

See the Verify web links recipe for the full flag list and fixture format.

Pipeline methods (TS)

The full surface of Pipeline<T> for any .ts file run by Bun:

Pipeline.of(arr | asyncIterable | ReadableStream)   // construction

pipeline
  .pipe(stage)            // fn(x)=>U | Pipeline | async iterable | PipelineStage
  .map(fn)                // per-item, async ok
  .filter(fn)
  .reduce(fn, init)       // terminal → Promise<A>
  .collect()              // terminal → Promise<T[]>
  .text()                 // terminal → Promise<string> (joined with \n)
  .lines()                // AsyncIterable<T> — for await
  .json<U>()              // terminal → Promise<U>
  .to(sink)               // terminal → calls sink(this)

Job control

cmd & is crust’s own background job — not an sh grandchild crust forgets about. It gets its own process group (so kill %N and Ctrl-Z reach a forking sh’s whole tree), stdin /dev/null (a background job must not steal keystrokes from the raw-mode editor), and output on the terminal, and it outlives crust. %1 numbers count up per session; % / %+ are the newest job; a bare number is a job to fg/bg/jobs and a PID to kill.

sleep 30 &            # [1] 4242 sleep 30 — the prompt comes straight back
jobs                  # [1]+  Running    sleep 30
jobs -l               # [1]+  Running    sleep 30  (pid 4242)
fg %1                 # wait for it — its exit code becomes the line's
bg %1                 # resume a stopped job; error if it was not stopped
kill %1               # Sent SIGTERM to job 1 (pid 4242)
wait %1               # the job wait — bare `wait` is the readiness stage
StatusMeans
Runningalive
Stoppedsuspended with Ctrl-Z
Doneexited 0
Exit Nexited N — never shown as a pass
Terminateddied from a signal — 143 = 128+SIGTERM, 130 = 128+SIGINT
Failedthe process is gone and no exit code ever arrived

Finished jobs stay listed for the session, which is what lets wait %1 still report the code of a job that ended minutes ago. jobs refuses an option it does not have (crust: jobs: -x is not an option) instead of ignoring it, because here the flags are the difference between two listings.

kill %N reports the send, not the death. An sh -c that has not exec’d yet holds SIGTERM pending — measured: alive on the very next line, gone within 20 ms — and a job that traps the signal can outlive it, so jobs is what reports verified state. Only the %-operand form is crust’s: kill -9 4242 is still sh’s. Bare wait is a different word and stays one — the readiness stage (wait :3000/health --timeout 5s); waiting for all jobs is not implemented, and one name quietly meaning two things is not shipped either.

Ctrl-Z suspends what crust is waiting on — a crust pipeline, a builtin, a fg/wait — with SIGSTOP, prints the job’s jobs line and returns the prompt; bg picks a suspended pipeline back up as one unit, and Ctrl-C during fg/wait kills the job and returns 130.

range(1,3) | sleep 12              # Ctrl-Z, and the prompt returns:
# ^Z
# [2]+  Stopped    sleep 12

SIGSTOP rather than SIGTSTP is not a style choice: Bun’s runtime ignores SIGTSTP and a child it spawns inherits that ignore across exec. So during a foreground inherit-stdio child — a bare sleep 20, or vim, where crust hands the real terminal over — Ctrl-Z does nothing at all: the ^Z is echoed and the child keeps running. Ctrl-C is the escape (child dies, line 130). Real terminal handoff (setpgid + tcsetpgrp) needs FFI and is not in v0.2.

In a script, & works too and the child is deliberately kept on the event loop: an unref()d job let Bun decide the process was finished at the next pending await, so the rest of the file was skipped and crust exited 0. Stopped jobs are the one thing that does not survive the session untouched — crust resumes them on the way out and says so, because SIGSTOP survives reparenting and no shell resumes a process it did not stop.

Editor keybindings

The line editor runs in raw mode (TTY-only). Reading from a pipe also works — it submits each \n-terminated line.

No syntax highlighting, deliberately: a token pass over a 2000-character line measures 0.1 ms and colour bytes do not move the cursor, so the cost is not speed — it is that an unfinished token classifies as a shell stage, so a highlighter would repaint the line a different colour 4–5 times per line as you type it (and parse() throws on 3–11 % of prefixes). Highlight the file instead (crust run.crust) and validate the line with --check.

KeyAction
← / →Move cursor
↑ / ↓History navigation — a continued line is one entry, recalled whole
Home / Ctrl-AStart of line
End / Ctrl-EEnd of line
Backspace / DeleteDelete left / right
Ctrl-W / Ctrl-U / Ctrl-KKill word / start-of-line / end-of-line
Ctrl-LClear screen
Ctrl-RIncremental reverse history search on a magenta (crust-search): line that opens below the one you are typing — typing narrows the query, another Ctrl-R walks further back, Enter accepts (the match lands editable, the next Enter runs it), Ctrl-G / Esc / Ctrl-C cancel and restore your draft
\ at end of lineContinue the line: drops to a … prompt and the buffer submits whole on a complete line. An unbalanced quote or an unclosed ( continues too, so a pasted multi-line JSON literal works as-is
Ctrl-ZSuspend what the line is waiting on — a crust pipeline, a builtin, a fg/wait — as a job (SIGSTOP), print its jobs line, hand the prompt back; bg resumes it. During a foreground inherit-stdio child it does nothing at all (see Job control)
Ctrl-CAt the prompt: clear the line — at a … continuation prompt, abort the whole pending buffer (nothing runs, nothing is stored). While a line runs: cancel it — pipeline stops, children die, prompt returns (exit 130). During fg/wait %N it kills the job being waited on
Ctrl-DEOF — exit if line is empty; ignored while a line is pending at a … prompt
Tab$PATH complete at start-of-stage, file path elsewhere
EnterSubmit line — or, when it ends in a continuation (\, unbalanced quote or (), hold it and drop to the … prompt

Next

  • Recipes — focused, end-to-end workflows.
  • MCP server — the same capabilities as protocol tools: an MCP-capable agent (OpenCode, Claude Code, Codex, Cursor) drives crust directly, with mock servers and log sessions held open across calls.
  • Agent skills — skills install writes what this page teaches into .agents/skills/, where Claude Code, Codex, Cursor and OpenCode pick it up.
  • Intro — back to the mental model.