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/tstabs 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 incrust mock-server ./openapi.yaml -p4000orbunx crust skills install. The same line runs at the REPL, inside a.crustscript, and undercrust -c. Only the session builtins —cd,export,alias,unalias,source,dotenv— are REPL-only: they change state a one-shot process does not have, socrust cd /tmpis 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 // crust's globals live in crust's runtime — run the file through crust:
// crust -c 'source ./script.ts' (or load it from init.ts)
// A bare `bun script.ts` throws ReferenceError — nothing is injected
// into a plain Bun process that never started crust.
await range(0, 99)
.pipe(parallel(20, () => fetch("http://localhost:3000/health")))
.pipe(expectStage(200))
.to(stats()); 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: -) // Shell source via Bun.$
await $`ls`.text();
range(0, 9); // Pipeline<number>
glob("**/*.ts"); // Pipeline<string>
glob("src/*.{ts,tsx}");
tail("app.log", { lines: 10 }); // Pipeline<string> of lines
tail("app.log", { follow: true }); // ...streaming forever
tail("logs/*.log"); // glob → merged stream
tail(["api.log", "worker.log"]); // explicit multi-file
GET("https://api.example.com/v1"); // Pipeline<Response>
GET("http://localhost:3000/health");
readAll("fixtures/*.json"); // whole files, one item each
Pipeline.of([{ name: "Court" }]); // literal item
load([{ durMs: 30_000, rps: 100 }]); // paced LoadTick stream 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" // Lambdas and HTTP stages compose via .pipe()
pipeline
.pipe(s => s.toUpperCase())
.pipe(POST("http://localhost:3000/users"))
.pipe(DELETE("http://localhost:3000/users/:id"));
// Headers via RequestInit — no env expansion needed in TS
pipeline.pipe(POST(`${process.env.BASE}/api/things`, {
headers: { authorization: `Bearer ${process.env.TOKEN}` },
}));
// HTTP transforms auto-set content-type: application/json for objects.
// Strings → text. Buffer / Uint8Array → raw bytes. 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 await range(0, 999)
.pipe(parallel(50, () => fetch("http://localhost:3000/health")))
.pipe(expectStage(200))
.to(stats());
// expectStage matchers:
expectStage(200); // exact status
expectStage("2xx"); // class — "2xx" | "3xx" | "4xx" | "5xx"
expectStage(item => item.status < 300); // custom predicate
// On mismatch the pipeline rejects with ExpectError { item, index, matcher }
// capture's TS twin — items pass through, process.env.NAME is written
captureEnv("THING_ID", (t: { id: string }) => t.id); 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.
parallelstreams 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) await time("warmup",
range(0, 1000)
.pipe(GET("http://localhost:3000/health"))
).collect(); 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 // From init.ts you can mutate the live shell state
declare const crust: {
alias(name: string, cmd: string): void;
unalias(name: string): void;
fn(name: string, handler: (...args: any[]) => any): void;
prompt?: (cwd: string, gitBranch: string | null) => string;
onBeforeStart?: () => void | Promise<void>;
onExit?: (code: number) => void | Promise<void>;
onSignal(sig: "SIGINT" | "SIGTERM" | "SIGHUP" | "SIGUSR1" | "SIGUSR2",
handler: () => void | Promise<void>): void;
};
crust.alias("ll", "ls -la");
crust.alias("g", "git"); 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 // There is no library entry point for this one: `dotenv` is a shell-line
// builtin that mutates crust's own Context and history. From TS, read the
// file yourself — or run the line.
const env = await Bun.file(".env.local").text();
for (const line of env.split("\n")) {
const [k, ...rest] = line.split("=");
if (k && !process.env[k]) process.env[k] = rest.join("=");
} 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 // Same helpers via the TS API
await Pipeline.of(["hello"])
.pipe(s => Buffer.from(s).toString("base64"))
.collect();
// crust.fn / globally-installed npm packages auto-dispatch.
// Register your own:
crust.fn("wrap", (item, l, r) => `${l}${item}${r}`);
// Then in the shell: echo hi | wrap [ ] 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 // fixtures/users.crust.ts
export default {
name: "GET /users/42 returns Lario",
input: {
method: "GET",
url: "http://localhost:3000/users/42",
headers: async () => ({
Authorization: `Bearer ${await Bun.jwt.sign({ sub: "42" }, "k")}`,
}),
},
output: {
status: 200,
data: { id: 42, name: "Lario" },
headers: { "content-type": (v: string) => v.startsWith("application/json") },
},
}; 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) // smoke.setup.ts — awaited before the file's lines run
export default async () => {
process.env.BASE = "http://localhost:3000";
process.env.TOKEN = await loginAndGetToken();
}; 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 import { generateFixtures } from "crust/genFixtures/generate";
const { files, totalCases } = await generateFixtures({
swagger: "./openapi.json",
out: "tests/gen",
setup: "./tests/gen-setup.ts",
}); 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 // Programmatic — useful when embedding the mock in a test harness
import { loadSpec } from "crust/mockServer/loadSpec";
import { startServer } from "crust/mockServer/server";
const { spec } = await loadSpec("./openapi.yaml");
const handle = await startServer({ spec, port: 4000, stateful: true });
// ... run tests ...
await handle.stop(); See the Mock server recipe for response-selection rules, stateful mode, and per-request log format.
verify-web-links
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 // site/about.meta.crust.ts
export default {
url: "https://example.com/about",
meta: {
title: "About Us",
"og:title": "About Us",
"og:image": (u: string) => /\.(png|jpg)$/.test(u),
description: (d: string) => d.length > 50 && d.length < 160,
},
}; 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
| Status | Means |
|---|---|
Running | alive |
Stopped | suspended with Ctrl-Z |
Done | exited 0 |
Exit N | exited N — never shown as a pass |
Terminated | died from a signal — 143 = 128+SIGTERM, 130 = 128+SIGINT |
Failed | the 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.
| Key | Action |
|---|---|
← / → | Move cursor |
↑ / ↓ | History navigation — a continued line is one entry, recalled whole |
Home / Ctrl-A | Start of line |
End / Ctrl-E | End of line |
Backspace / Delete | Delete left / right |
Ctrl-W / Ctrl-U / Ctrl-K | Kill word / start-of-line / end-of-line |
Ctrl-L | Clear screen |
Ctrl-R | Incremental 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 line | Continue 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-Z | Suspend 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-C | At 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-D | EOF — exit if line is empty; ignored while a line is pending at a … prompt |
Tab | $PATH complete at start-of-stage, file path elsewhere |
Enter | Submit 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 installwrites what this page teaches into.agents/skills/, where Claude Code, Codex, Cursor and OpenCode pick it up. - Intro — back to the mental model.