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.
Install & launch
curl -fsSL https://raw.githubusercontent.com/lariocpt/crust/main/install.sh | bash # 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.
crust deploy-checks.crust
echo 'range(1,3) | filter (n => n > 1)' | crust
# Help / version
crust -h
crust -V // Any .ts file run with bun has access to crust's globals
// bun script.ts
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)
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
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 // Programmatic β same effect from a script
import { loadDotenv } from "crust/dotenv";
await loadDotenv({ path: ".env.local", mode: "append" }); 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";
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 { startMockServer } from "crust/mockServer";
const handle = await startMockServer({
swagger: "./openapi.yaml",
port: 4000,
});
// ... 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)
Editor keybindings
The line editor runs in raw mode (TTY-only). Reading from a pipe also works β it submits each \n-terminated line.
| Key | Action |
|---|---|
β / β | Move cursor |
β / β | History navigation |
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-C | At the prompt: clear the line. While a line runs: cancel it β pipeline stops, children die, prompt returns (exit 130) |
Ctrl-D | EOF β exit if line is empty |
Tab | $PATH complete at start-of-stage, file path elsewhere |
Enter | Submit line |
Next
- Recipes β focused, end-to-end workflows.
- Agent skills β
skills installteaches Claude-based agents everything on this page. - Intro β back to the mental model.