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.

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

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)
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

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)

Editor keybindings

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

KeyAction
← / β†’Move cursor
↑ / ↓History navigation
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-CAt the prompt: clear the line. While a line runs: cancel it β€” pipeline stops, children die, prompt returns (exit 130)
Ctrl-DEOF β€” exit if line is empty
Tab$PATH complete at start-of-stage, file path elsewhere
EnterSubmit line

Next

  • Recipes β€” focused, end-to-end workflows.
  • Agent skills β€” skills install teaches Claude-based agents everything on this page.
  • Intro β€” back to the mental model.