@lariocpt/crust-mongo 0.0.0-stage → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,99 @@
1
+ ---
2
+ name: crust-logs
3
+ description: Search a live log stream with crust's `logs` session — it holds a tail/procs/shell source open, keeps a ring buffer, and re-runs every typed pipeline fragment over the buffered past and then the live stream; `json on` parses NDJSON at query time, `search` counts the buffer instantly, `buffer N` resizes without restarting. Use when tailing dev services or containers, when a grep workflow keeps losing history, or when you want to widen a filter over logs you already saw.
4
+ ---
5
+
6
+ # crust logs
7
+
8
+ ## What it is
9
+
10
+ `logs <source>` keeps the source open and a ring buffer (default 10,000 items)
11
+ behind it, so one query can look at the PAST you already streamed and stay
12
+ attached to the live firehose. Every line you type at `logs>` is an ordinary
13
+ crust pipeline fragment — the whole shell grammar is the query language.
14
+
15
+ ```crust
16
+ logs tail -n 0 -F app.log
17
+ logs procs({web: "bun run dev", api: "bun api.ts"})
18
+ logs docker logs -f my-container
19
+ logs --buffer 50000 tail -F big.log
20
+ ```
21
+
22
+ Boot prints what it holds: `logs: tail -n 0 -F app.log — buffering last 10000
23
+ items (`help` for commands, `exit` to leave)`.
24
+
25
+ ## A fragment runs TWICE
26
+
27
+ Once over the buffer snapshot, then over the live stream until Ctrl-C, past a
28
+ `-- live --` marker. So side effects fire twice: append with `>>`, never `>`,
29
+ and a `POST` fragment re-sends buffered matches.
30
+
31
+ - **Ctrl-C once** ends the live view *gracefully* — the stream finishes, so
32
+ terminal stages flush (a query ending in `stats` prints its summary right
33
+ then; `wc -l` prints its count). **Ctrl-C twice** hard-cancels a stuck query.
34
+ The session survives both.
35
+ - A query that fails on the retro pass prints its error and re-prompts. Errors
36
+ never kill the session or the held source.
37
+
38
+ ## Session commands
39
+
40
+ | command | what it does |
41
+ | --- | --- |
42
+ | `search <text>` | fixed substring over the BUFFER only — matches highlighted, and a count that always prints, zero included (`search: 5 matching item(s) of 36 buffered`). No live view, no Ctrl-C. For regex/case-folding/live, run a `grep` query |
43
+ | `json on` / `json off` | parse string items at QUERY time (see below) |
44
+ | `buffer` / `buffer N` | report usage (`buffer: 57/10000 items (pushed 57, evicted 0)`) or resize live, newest kept — widen a too-small window without losing the held source |
45
+ | `clear` | empty the buffer (`buffer cleared`) |
46
+ | `help` | the command list, including the double-run note |
47
+ | `exit` / Ctrl-D | tear the source down and leave (procs get SIGTERM→SIGKILL) |
48
+
49
+ Query errors print and re-prompt; up-arrow recalls earlier queries.
50
+
51
+ ## json on — and the silent zero it prevents
52
+
53
+ With `json off` (the default) items are the raw strings. A lambda that reads
54
+ fields therefore matches NOTHING, quietly: 50 buffered NDJSON lines, and
55
+ `filter (e => e.level >= 30) | wc -l` reported `0`. `json on` fixes that at
56
+ query time: a string item parsing to a JSON **object or array** reaches your
57
+ lambdas parsed; plain text, strings that parse to primitives (`"42"` stays a
58
+ string), and non-string items flow through unchanged and are counted out loud.
59
+ The ring stores raw items, so `json off` is a lossless revert.
60
+
61
+ When the buffer looks like NDJSON the session prints, once, on your first
62
+ *pipeline fragment* (not on `help` or `search`):
63
+
64
+ ```
65
+ hint: buffer looks like NDJSON — `json on` parses object lines at query time (`json off` reverts)
66
+ ```
67
+
68
+ It never auto-enables — changing the item type under a lambda you already typed
69
+ would be a false picture.
70
+
71
+ ## Item types by source
72
+
73
+ - `tail`/shell sources: plain **strings**.
74
+ - `procs({...})`: `{proc, stream, line}` **objects** — the session tells you so
75
+ at boot (`logs: items are {proc, stream, line} objects — try (l => l.line) or
76
+ filter (l => l.proc === "web")`). `(l => l.line)` extracts text,
77
+ `filter (l => l.proc === "web")` selects one process, and `search` still works
78
+ (it matches each item's rendered form).
79
+ - Pretty-printing is just another stage: `grep api_request | pino-pretty
80
+ --colorize --singleLine` (`--colorize` forces ANSI through the pipe; local
81
+ `node_modules/.bin` is on PATH). Normalize `procs` objects first.
82
+
83
+ ## Traps
84
+
85
+ - `logs` is interactive-only. No tty → exit 2 and a pointer:
86
+ `logs: interactive session needs a tty — for piped data use `cmd | crust -c 'stdin | …'``.
87
+ For piped data there is no session — build the pipeline directly.
88
+ - The `logs` line owns its line: `logs tail -F app.log | grep ERROR` refuses
89
+ (`logs is a crust builtin — it runs in-process, so sh cannot run it`) rather
90
+ than letting sh report `command not found`. Type the filter at `logs>`.
91
+ - A gate query (`count 1`, `expect 200`) runs against the BUFFER first — a
92
+ retro failure prints before a single live item arrives. That is the honest
93
+ order, but it means `logs`+gate reflects history, not just what follows.
94
+ - `search` is buffer-only and fixed-substring: it never sees live items and
95
+ never interprets regex.
96
+ - Ctrl-C at an idle `logs>` prompt is nothing: it prints `^C` and re-prompts,
97
+ the source keeps running. `exit` (or Ctrl-D) is the teardown — verified with a
98
+ `procs` source, including after a Ctrl-C: the group gets SIGTERM→SIGKILL and
99
+ no `sleep` survives the session.
@@ -0,0 +1,136 @@
1
+ ---
2
+ name: crust-mcp
3
+ description: Drive crust directly from an MCP-capable agent (OpenCode, Claude Code, Codex, Cursor) instead of a shell — `crust mcp` exposes every capability as a first-class tool: run_line, check_line, test_fixture/test_pipes, gen_fixtures, load_run, verify_web_links, plus lifecycle mock servers, supervised procs, and buffered log sessions that stay open across calls. Use when configuring crust as an MCP server, choosing which crust_* tool fits a task, or reasoning about what persists between tool calls and what does not.
4
+ ---
5
+
6
+ # crust over MCP
7
+
8
+ `crust mcp` speaks JSON-RPC 2.0 over stdio — one message per line. It is a
9
+ whole-process MODE, not a REPL builtin: the MCP host owns stdin and stdout.
10
+
11
+ ## Install & configure
12
+
13
+ `crust` must be on PATH (either channel works — same binary):
14
+
15
+ ```bash
16
+ curl -fsSL https://raw.githubusercontent.com/lariocpt/crust/main/install.sh | bash
17
+ # or: npm i -g @lariocpt/crust
18
+ ```
19
+
20
+ Then register it with the agent host — e.g. OpenCode / Claude Code style:
21
+
22
+ ```json
23
+ { "mcpServers": { "crust": { "command": "crust", "args": ["mcp"] } } }
24
+ ```
25
+
26
+ VS Code uses a DIFFERENT schema in `.vscode/mcp.json` (workspace) or the
27
+ user-level MCP settings — the `mcpServers` shape above silently fails there
28
+ ("command is required" or the server just never appears):
29
+
30
+ ```json
31
+ {
32
+ "servers": {
33
+ "crust": { "type": "stdio", "command": "/home/user/.local/bin/crust", "args": ["mcp"] }
34
+ }
35
+ }
36
+ ```
37
+
38
+ Two traps, both verified: (1) `"command"` must be the ABSOLUTE path — a
39
+ GUI-launched VS Code does not inherit the shell's PATH, so bare `"crust"`
40
+ dies `spawn crust ENOENT` if the launcher lives outside the default
41
+ lookup dirs; (2) VS Code's MCP support ships inside the Chat/Copilot Chat
42
+ extension — Code - OSS / VSCodium has no MCP host without an extension
43
+ (Cline, Roo Code or Continue each carry their own MCP settings).
44
+
45
+ Grammar lands automatically: the one-shot tools pass line/fixture text to a
46
+ fresh crust child untouched, so anything the lexer accepts (`--redirect`,
47
+ new builtins) is an MCP tool call the moment the installed binary has it —
48
+ no MCP-side change per feature.
49
+
50
+ Verify by hand — the transport is just lines on stdin:
51
+
52
+ ```bash
53
+ printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}\n{"jsonrpc":"2.0","method":"notifications/initialized"}\n{"jsonrpc":"2.0","id":2,"method":"tools/list"}\n' | crust mcp
54
+ ```
55
+
56
+ ## Two execution planes — what persists
57
+
58
+ | Plane | Tools | State |
59
+ | --- | --- | --- |
60
+ | **One-shot** | `crust_run_line`, `crust_check_line`, `crust_test_fixture`, `crust_test_pipes`, `crust_gen_fixtures`, `crust_verify_web_links`, `crust_load_run` | a fresh crust CHILD per call — `cwd`/`env` apply per call and NEVER carry over; pass them explicitly |
61
+ | **Lifecycle** | `crust_mock_server_start/stop/list/violations`, `crust_procs_start/stop`, `crust_logs_open/query/close` | held IN the server across calls; every held job dies when the server exits |
62
+
63
+ `crust_run_line` runs ordinary crust grammar — one line, `|`-stages, lambdas:
64
+
65
+ ```crust
66
+ range(1,5) | (n => n * n) | stats
67
+ ```
68
+
69
+ ## Tool classes and teardown tiers
70
+
71
+ The `initialize` handshake carries this taxonomy in `instructions` (0.3.6):
72
+
73
+ - **Static check** — `crust_check_line`: parses, runs nothing.
74
+ - **Execution** — `crust_run_line` and the test/gen tools: arbitrary stages,
75
+ real side effects. Target approval, credentials, writes and cleanup stay
76
+ the caller's responsibility.
77
+ - **Network/load generation** — `crust_load_run`, `crust_verify_web_links`:
78
+ bounded generation, approved non-production targets only.
79
+ - **Long-lived resources** — mock server, procs, log sessions: held by the
80
+ server process, die when it exits.
81
+
82
+ Teardown has three tiers and no tool crosses them: **local processes** are
83
+ killed whole (process group, grandchildren included); **mock sessions** close
84
+ (port released — `--state` files/DBs are application data and stay on disk);
85
+ **application data** is never touched — a container, row or queue a proc
86
+ created remotely survives its local teardown, releasing it is your job.
87
+
88
+ Transport contract: stdio JSON-RPC, one frame per line; one-shot tools spawn
89
+ a fresh crust child per call (`cwd`/`env` per call, nothing persists). A
90
+ refusal whose error string never appears in crust's own output (an
91
+ "unavailable tool" style host error) never reached the server — debug the
92
+ host's spawn command, not crust.
93
+
94
+ ## Honest failure — never a false pass over MCP
95
+
96
+ A nonzero child exit, a killed timeout, a failed `assert`, a mock validation
97
+ error is an **`isError` result** carrying the verbatim exit code and output.
98
+ Zero matches is answered with an explicit "zero items", never silence. Trust
99
+ `isError`; the text is evidence, not a verdict.
100
+
101
+ ## Picking the right tool
102
+
103
+ - Validate generated crust first — `crust_check_line` parses without running
104
+ (files and ports don't need to exist).
105
+ - `crust_procs_start` supervises dev services (env/after/ready/restart per
106
+ proc). Its stream is queried through `crust_logs_query` — items are
107
+ `{proc, stream, line}` objects. Gate readiness in a follow-up:
108
+
109
+ ```crust
110
+ wait :3000/health
111
+ ```
112
+
113
+ - `crust_logs_open` holds a tail/shell/`procs({…})` source with a ring buffer;
114
+ `crust_logs_query` runs a pipeline fragment over the buffered past, then
115
+ live for `live_ms` — `json: true` parses NDJSON items before the fragment:
116
+
117
+ ```crust
118
+ filter (l => l.proc === "api") | (l => l.line) | grep -i timeout
119
+ ```
120
+
121
+ - `crust_mock_server_start` defaults `port: 0` (OS-assigned) — always take
122
+ the returned `url`. `proxy: "<upstream>"` turns it into a conformance audit;
123
+ read findings with `crust_mock_server_violations`.
124
+ - `crust_load_run` composes the documented pipeline (`load … | parallel … |
125
+ … | stats | assert`); gates go in `assert` — one predicate, or an array of
126
+ them (0.3.6), one assert stage per gate. An `expect` inside `target` runs BEFORE
127
+ stats and suppresses the summary when it fails; a post-stats `assert`
128
+ failure is `isError` with the stats still included. A run that measured
129
+ nothing is refused (`empty`), never a green gate.
130
+
131
+ ## Limits, stated
132
+
133
+ - One tool call runs at a time (the interrupt bus is process-global).
134
+ - Output is tail-capped per stream and truncation is always announced.
135
+ - `crust_procs_stop` reports "teardown still in flight past 10s" honestly
136
+ when a child ignores SIGTERM — crust kills it on server exit.
@@ -0,0 +1,138 @@
1
+ ---
2
+ name: crust-mock-server
3
+ description: Mock an API from an OpenAPI spec with crust mock-server — example-first responses, stateful CRUD (in-memory or persisted to sqlite/postgres with --state, seedable with --seed), request validation with 422s, and validation-proxy mode that checks a REAL API's spec conformance. Use for mocking backends, frontend dev without a server, contract/conformance testing, or checking whether an API matches its openapi.json.
4
+ ---
5
+
6
+ # crust mock-server
7
+
8
+ One builtin, four modes, all driven by an OpenAPI 3.x spec (Swagger 2.0
9
+ auto-converts; URL or local json/yaml):
10
+
11
+ ```
12
+ mock-server ./openapi.json -p4000 # example-first mock
13
+ mock-server ./openapi.json -p4000 --stateful # + in-memory CRUD
14
+ mock-server ./openapi.json -p4000 --state ./m.sqlite # + persistent CRUD (implies --stateful)
15
+ mock-server ./openapi.json -p4000 --validate # + reject bad requests (422)
16
+ mock-server ./openapi.json -p4747 --proxy http://localhost:3001 # conformance proxy
17
+ ```
18
+
19
+ Builtins can't be piped — run the server in one terminal (or `procs()`), and
20
+ pipe against it from another line.
21
+
22
+ ## Mock + stateful
23
+
24
+ Responses prefer the spec's `example`/`examples`, then synthesize from the
25
+ schema (enum[0], format-aware strings, nested objects). Status pick:
26
+ 200 → 201 → other 2xx → `default`. Unknown path → 404; known path, wrong
27
+ method → 405.
28
+
29
+ `--stateful` pairs `/things` with `/things/{id}` automatically: POST stores
30
+ (id from body or a UUID), GET lists what you stored (shaped to the spec's
31
+ envelope), GET/{id} → 404 for unknown ids, PUT/PATCH shallow-merge, DELETE
32
+ → 204. Untouched collections keep serving spec examples. Default is
33
+ per-process memory; `--state` persists (below).
34
+
35
+ **Entity envelopes round-trip.** If the POST's 201 example wraps the entity
36
+ (`{"thing": {"id": …}}` — one object-valued prop, no top-level id), the mock
37
+ detects it: responses are wrapped with the real stored entity inside, request
38
+ bodies wrapped the same way (per the POST's request example) are unwrapped,
39
+ and the store keeps the BARE entity. So capturing a wrapped id works against
40
+ the mock:
41
+
42
+ ```crust
43
+ {"thing": {"name": "x"}} | POST :4000/api/things | assert (r => r.status === 201) | (r => r.json()) | capture TID (t => t.thing.id)
44
+ GET :4000/api/things/$TID | expect 200
45
+ ```
46
+
47
+ Flat specs capture `t.id` exactly as before. Ambiguous shapes (two
48
+ object-valued props, a top-level `id`) stay flat — deterministic, never
49
+ guessed.
50
+
51
+ ## Persistent state (`--state`) and seeding (`--seed`)
52
+
53
+ Both imply `--stateful` and exclude `--proxy`.
54
+
55
+ - `--state <path|url>` — bare path or `sqlite://` file, or
56
+ `postgres://`/`postgresql://` URL (other schemes exit 2). Survives
57
+ restarts; shared cross-process (reads hit the DB every request, writes are
58
+ single-statement upserts, last write wins). Boot line shows
59
+ `(state: sqlite)` / `(state: postgres)`.
60
+ - `--seed <file.json>` — `{ "/api/things": [ {…}, … ] }`; items without an
61
+ `id` get a uuid. EMPTY collections only: re-booting over a persistent
62
+ store never duplicates or clobbers. Unknown collection keys warn and boot
63
+ anyway; invalid file exits 1. Boot line shows `(seeded N)`.
64
+
65
+ Table contract (created idempotently; assert on it from pipelines):
66
+ `crust_mock_state(collection TEXT, id TEXT, doc TEXT|JSONB, updated_at, PRIMARY KEY (collection, id))`
67
+ where `doc` is the BARE entity as JSON. With the `sql` builtin
68
+ (`DATABASE_URL=sqlite://./m.sqlite` works — Bun.SQL speaks sqlite URLs;
69
+ rows stream one item each):
70
+
71
+ ```crust
72
+ sql "SELECT json_extract(doc, '$.name') AS name FROM crust_mock_state WHERE collection = '/api/things' AND id = '$TID'" | assert (r => r.name === "x")
73
+ ```
74
+
75
+ (postgres: `doc->>'name'` instead of `json_extract`.)
76
+
77
+ ## Failure injection (`PUT /__crust/override`) — mock mode only
78
+
79
+ Arm the mock to fail when the APP owns the request (no test-controlled
80
+ header possible): `PUT /__crust/override` with `{method, path, status,
81
+ body?, contentType?, times?, match?: {bodyContains}}`. Under `-j/--threads`
82
+ ALWAYS scope with `match.bodyContains` (a unique value the fixture put in
83
+ its own request body) + `times: 1` — unscoped overrides race sibling
84
+ fixtures. An armed override beats --validate/--stateful; `GET` lists,
85
+ `DELETE` clears. Never consulted in --proxy mode.
86
+
87
+ ## Request validation (`--validate`)
88
+
89
+ Spec-violating requests get **422** (not the API's own 400 — deliberately
90
+ distinguishable) with header `x-crust-validation: request` and body
91
+ `{error, violations: [{pointer, rule, message, expected?, received?, location}]}`.
92
+ Checks: JSON body (type/required/enum/format/pattern/lengths/ranges,
93
+ anyOf/oneOf/allOf), path + query params (coerced by declared type). The
94
+ governing rule: a schema the validator can't judge PASSES — it never
95
+ invents a violation (notably `additionalProperties: false` is not
96
+ enforced unless you add `--strict`). Composes with `--stateful`: an
97
+ invalid POST creates nothing.
98
+
99
+ `--strict` (implies `--validate`; composes with `--proxy`, both
100
+ directions) enforces a LITERAL `additionalProperties: false` at plain
101
+ object nodes only: allOf-merged objects, nodes with combinator siblings,
102
+ and patternProperties stay exempt — so it still never invents a
103
+ violation for spec composition idioms. Inside anyOf/oneOf branches an
104
+ extra key just fails that branch's selection.
105
+
106
+ ## Validation proxy (`--proxy <upstream>`) — spec-conformance testing
107
+
108
+ Forwards every request to the real upstream, returns the response
109
+ UNTOUCHED, and records spec violations out-of-band in BOTH directions:
110
+ request violations, undocumented response statuses, undocumented
111
+ content-types, response-schema mismatches (only where the spec has response
112
+ schemas), and `undocumented-operation` for paths the spec doesn't know
113
+ (still forwarded — a browsing frontend keeps working). Upstream down → 502,
114
+ not a violation. Mutually exclusive with `--stateful`.
115
+
116
+ The findings are pipeable — this is the conformance gate:
117
+
118
+ ```crust
119
+ GET :4747/__crust/violations | (r => r.json()) | (v => v.violations.filter(x => x.direction === "response")) | assert (a => a.length === 0)
120
+ ```
121
+
122
+ The `(r => r.json())` stage is not optional: an HTTP stage hands you the
123
+ response object, so reading `.violations` off it straight away is `undefined`
124
+ and the gate dies with `crust: undefined is not an object (evaluating 'v.violations.filter')` — which looks like a broken assert rather than a missing decode.
125
+
126
+ `DELETE /__crust/violations` clears between runs; `--report file.ndjson`
127
+ appends every violation as NDJSON for CI artifacts (requires `--proxy`).
128
+
129
+ Workflow: point your existing test suite's base URL at the proxy port, run
130
+ the suite, then assert zero response-direction violations. (Request-direction
131
+ violations are EXPECTED if your suite includes negative cases — filter by
132
+ direction.)
133
+
134
+ ## Exit codes
135
+
136
+ 0 clean shutdown (SIGINT/SIGTERM) · 1 runtime failure (spec load, bad seed
137
+ file) · 2 bad args (including `--proxy` + `--stateful`/`--state`/`--seed`,
138
+ an unsupported `--state` scheme, or `--report` without `--proxy`).
@@ -0,0 +1,209 @@
1
+ ---
2
+ name: crust-pipelines
3
+ description: Compose crust pipelines — sources (range, glob, read, tail, GET, load, procs, sql), transforms (TS lambdas, filter, HTTP verbs, parallel), assertions (expect, assert), and request chaining with capture. Use when writing or debugging crust one-liners, `crust -c` scripts, .crust script files, or .pipes lines that mix HTTP, SQL, and processes.
4
+ ---
5
+
6
+ # crust pipelines
7
+
8
+ crust is a Bun-powered pipeline runner where every command is a typed stream
9
+ (`Pipeline<T>`). Stages compose under `|`. Plain shell still works — a line
10
+ whose stages are all ordinary commands is handed to `sh -c` untouched.
11
+
12
+ Choose the lightest native stage that preserves correctness, observability,
13
+ and safety — not merely the shortest syntax (full statement: the
14
+ crust-api-testing skill's shared principle).
15
+
16
+ ## How a stage is classified (first token decides)
17
+
18
+ | You type | You get |
19
+ |---|---|
20
+ | `range(0, 9)` | numbers 0..9 inclusive, one item each |
21
+ | `**/*.ts` | glob source — file paths |
22
+ | `lines **/*.log` | one item per LINE across every match (sorted; zero matches = error). Use this for log mining — `read` gives whole files. Bare `lines` mid-pipeline splits upstream items |
23
+ | `read fixtures/*.json` | whole-file contents, one item per file (sorted; zero matches = error) — the request-body form: `read fixtures/*.json \| POST …` |
24
+ | `tail -F app.log` | native tail source (`-n N`, `-F` follow); the `-n N` cut is a bounded backward read, safe on huge files |
25
+ | `stdin` (alias `-`) | piped-stdin source, one item per line — `docker logs -f X \| crust -c 'stdin \| …'`. Source position only. Bare `cmd \| crust` treats stdin as a SCRIPT, so data pipes need `-c` |
26
+ | `grep ERROR` mid-pipeline | native line-buffered grep (`-i`/`-v`/`-F`, ONE pattern; per-line even on multi-line items) — follow streams don't stall on grep's 4KB pipe buffer. Combined/unknown flags, two positionals, any `$`, or GNU/JS-divergent regex bits (`\`, `[[:`, `{,`, `(?`) = exact system grep via sh; first-stage grep = file grep via sh |
27
+ | `load 30s 100/s` | paced load ticks (see the crust-load-testing skill) |
28
+ | `{"name": "x"}` | JSON-literal source — ONE parsed item (the request body). Invalid JSON is a hard error |
29
+ | `GET :3000/path` | HTTP. First stage: one `Response`. Mid-pipeline: per-item timed `{status, ms, url}` |
30
+ | `POST/PUT/PATCH/DELETE url` | per-item request; upstream item = body (objects auto-JSON) |
31
+ | `GET url --timeout 2s` | per-request timeout on any http stage (`ms`/`s`/`m`); timed paths yield `{status: 0, timedOut: true}`, plain verbs fail the pipeline; unknown `--flags` are errors |
32
+ | `GET url --redirect error\|manual\|follow` | 3xx handling (fetch's `redirect` init): `error` refuses a redirect — plain verbs fail the line, timed paths yield `{status: 0, redirectRefused: true}`; `manual` yields the 3xx so `expect 301` asserts it; `follow` is the default. Fixtures carry it as `input.redirect` |
33
+ | `(x => x * 2)` | TypeScript lambda, per item, async ok — MAPS, never drops (falsy results are emitted as-is) |
34
+ | `filter (l => l.ok)` | keeps items whose predicate is truthy; falsy (`0`, `""`, `null`…) drop; async ok; empty result passes |
35
+ | `assert (r => r.ok)` | falsy FAILS the pipeline; empty upstream also fails |
36
+ | `capture NAME (r => r.id)` | write value to `process.env.NAME` for later lines |
37
+ | `expect 201` / `expect 2xx` | status assertion, counts mismatches, fails at drain |
38
+ | `stats [--every N] [--out f.json]` | summary: count/rps/status histogram/p50/p95/p99 + failure tallies `timedOut`/`redirectRefused` (records, not exits — gate them) |
39
+ | `parallel N` | modifier: fan-out for the NEXT stage (http, lambda, or fn — anything else errors) |
40
+ | `sql "SELECT …" ?-params` | registered fn — `?` is the bind placeholder; rows stream one item each (needs `$DATABASE_URL`) |
41
+ | `range(1,1) \| sql "… WHERE id = ?"` | mid-pipeline, the upstream item BINDS as the first parameter when the line declares none (an explicit param wins); rows stream one item each in both positions |
42
+ | anything else | plain shell via `sh -c` |
43
+
44
+ `:3000/path` expands to `http://localhost:3000/path` everywhere.
45
+
46
+ ## HTTP result shapes — same syntax, different payloads
47
+
48
+ Similar HTTP syntax does NOT guarantee identical result shapes. Know which
49
+ shape a stage receives before reaching for `.json()` or gating results
50
+ (verified against crust v0.3.5):
51
+
52
+ | Context | What the stage emits | Authoring implication |
53
+ |---|---|---|
54
+ | First-stage ordinary verb (`GET :3000/x`) | a `Response` | body parsing is available: `(r => r.json())` |
55
+ | Mid-pipeline ordinary verb, non-GET (per-item, untimed) | a `Response` per item | same — bodies still parseable |
56
+ | Timed per-item path (mid-pipeline `GET` — with or without `parallel` — or `parallel N \| <any other verb>`) | `{status, ms, url}` records; **bodies drained**; network errors are `status: 0` records; `--timeout`/`--redirect error` outcomes are `{status: 0, timedOut/redirectRefused: true}` records | `.json()` does NOT exist on these; gate status and the failure tallies explicitly |
57
+
58
+ Canonical, version-pinned table: https://lariocpt.github.io/crust-website/docs/http-result-shapes/.
59
+
60
+ ## Completion is not success — three separate outcomes
61
+
62
+ 1. **Execution completed** — the pipeline ran without a fatal stage error (exit 0).
63
+ 2. **Request correctness passed** — explicit gates (`expect`, `assert`) accepted the request outcomes.
64
+ 3. **Performance passed** — explicit latency/throughput thresholds passed.
65
+
66
+ `stats` reports measurements; it is not itself a success gate. In timed/load
67
+ paths, timeouts, network failures, and redirect refusals are per-item
68
+ RECORDS (`status: 0` + a tally field), not exits — exit 0 alone is never
69
+ evidence that requests succeeded. Use an explicit expected-status gate
70
+ (`expect 200`/`expect 2xx`), and where stats is in play also check
71
+ `s.timedOut === 0` / `s.redirectRefused === 0`.
72
+
73
+ ## Env expansion — parse time, opt-in per position
74
+
75
+ `$VAR`/`${VAR}` expand from `process.env` in: URLs, whole `-H` header
76
+ strings, JSON literals, `stats --out` paths, and registered-fn args. They do
77
+ NOT expand inside lambda/`assert`/`capture` bodies (those are JS — use
78
+ `process.env.VAR`) or shell stages (sh does its own). In a script, the
79
+ **positional arguments** expand in those same positions — so `$1` inside a
80
+ `sql "…"` string is the script's `$1`, not a SQL bind: the bound-parameter
81
+ placeholder is `?`, and the trailing quoted args DO env-expand:
82
+ `sql "… WHERE id = ?" "$MY_ID"`.
83
+
84
+ `$(…)` command substitution lives in shell stages only: sh expands it once
85
+ at the head of a shell stage, and a `$` anywhere in a mid-pipeline stage
86
+ demotes it to sh, so `range(1,3) | grep $(echo 1)` still works. A `$(…)` in
87
+ a *crust* stage (a URL, a lambda, an argument) is **refused before anything
88
+ runs** — it used to build data from the literal text (`getaddrinfo ENOTFOUND
89
+ $(echo`) — so the named route is capture first: `date +%s | capture TS`,
90
+ then `GET :3000/?t=$TS`.
91
+
92
+ Inside a parenthesized lambda/`assert`/`capture` body, anything goes: the
93
+ tokenizer tracks paren depth and quotes, so literal `|`, `??`, single/double
94
+ quotes, and backticks (template literals) are all safe. In double-quoted
95
+ strings and JSON literals, `\"` escapes work.
96
+
97
+ ## Request chaining with capture
98
+
99
+ Each line parses right before it runs, so a capture on one line feeds `$VAR`
100
+ on every later line — the chaining contract for REPL, `-c` scripts, and
101
+ `.pipes` files:
102
+
103
+ ```crust
104
+ {"name": "Court"} | POST $BASE/api/buildings -H "authorization: Bearer $TOKEN" | assert (r => r.status === 201) | (r => r.json()) | capture BID (b => b.building.id)
105
+ GET $BASE/api/buildings/$BID -H "authorization: Bearer $TOKEN" | expect 200
106
+ ```
107
+
108
+ - Last item wins; omit the lambda to capture the item itself (objects are
109
+ JSON-stringified).
110
+ - A nullish captured value or empty upstream fails IMMEDIATELY — a typo'd
111
+ accessor dies on the capture line, not three lines later as a baffling
112
+ `""` expansion.
113
+ - Don't capture into `TOKEN`, `BASE`, or `PATH` unless you mean to overwrite
114
+ them.
115
+ - Capture is also the named route for command output you want downstream:
116
+ a `$(…)` in a crust stage is refused, so `date +%s | capture TS` followed
117
+ by `GET :3000/?t=$TS` is the round trip (sh's trailing newline is trimmed
118
+ off the captured value).
119
+
120
+ ## Verified one-liner patterns
121
+
122
+ ```crust
123
+ # log mining — `lines` yields one item per LINE, which is what filter wants
124
+ # (`read` yields whole FILES, so a filter would test the entire file at once)
125
+ lines **/*.log | grep ERROR | filter (l => !l.includes('healthcheck')) | wc -l
126
+
127
+ # lambda + shell mixing — items cross as lines (objects as JSON)
128
+ range(1, 5) | (n => n * n) | sort -rn
129
+
130
+ # HTTP fixture in one line: body | request | status | parsed-body assert
131
+ {"name": "x"} | POST :3000/users | assert (r => r.status === 201) | (r => r.json()) | assert (u => u.id > 0)
132
+
133
+ # DB verification — rows stream, assert sees each row object
134
+ sql "SELECT count(*)::int AS c FROM users WHERE email = ?" "a@b.c" | assert (r => r.c === 1)
135
+
136
+ # fan out any per-item work
137
+ read fixtures/*.json | parallel 8 | POST :3000/users | expect 2xx
138
+
139
+ # follow a log live — native grep emits each match immediately
140
+ tail -n 0 -F app.log | grep ERROR
141
+
142
+ # any command's output as a source (run under `crust -c`, data piped in)
143
+ stdin | (l => JSON.parse(l)) | filter (e => e.level >= 40) | (e => e.msg)
144
+ ```
145
+
146
+ To ITERATE on filters over one held live stream, use the `logs` builtin
147
+ (`logs tail -n 0 -F app.log`, `logs procs({…})`, `logs docker logs -f c`):
148
+ every line typed at its prompt is a fragment like the ones above, run over
149
+ a buffer of the recent past and then live; Ctrl-C once ends the view and
150
+ flushes terminal stages (bare `stats` prints there), `exit` leaves.
151
+
152
+ ## Traps
153
+
154
+ - A plain lambda MAPS — `(l => l.includes('ERROR'))` emits `true`/`false`
155
+ per item, and `(x => cond ? x : null)` prints literal `null` lines. To
156
+ drop items, use `filter (l => l.includes('ERROR'))`.
157
+ - `filter` passes an empty stream through silently; `assert` fails on EMPTY
158
+ upstream ("no items reached") — deliberate, it closes the
159
+ sql-returned-zero-rows silent pass. `expect` does not fail on empty either.
160
+ - `parallel` streams results in COMPLETION order, not input order.
161
+ - `parallel N` before anything except an http verb, lambda, or registered
162
+ fn is a parse error; so is a trailing `parallel N`.
163
+ - A glob source yields PATHS; `read <glob>` yields file CONTENTS. `POST`ing
164
+ a glob posts path strings.
165
+ - Builtins (`test-pipes`, `mock-server`, `logs`, …) cannot be piped. A builtin
166
+ name in a shell context — `mock-server spec.json | grep ok`, `range(0,2) |
167
+ test-fixture a.ts` — refuses naming the tool instead of letting sh answer
168
+ `command not found` for a tool crust runs in-process. Put the builtin on its
169
+ own line; `logs` takes its filters at the `logs>` prompt.
170
+ - No redirect on a crust stage. `<` / `>` mean something only on a SHELL
171
+ stage, whose text goes to `sh` verbatim: `… | cat > out.json` saves a
172
+ pipeline, `lines <path>` / `read <path>` feeds a file in. Anywhere else the
173
+ line is refused — an http stage used to swallow the operator (request ran,
174
+ nothing written, exit 0). A builtin carrying one refuses too, and the fix is
175
+ redirecting the whole call: `crust -c 'mock-server spec.json' > mock.log`.
176
+ - Native grep patterns are JS regexes: quoted `'a|b'` ALTERNATES (ERE)
177
+ where BRE grep matched the literal — use `grep -F 'a|b'` for the literal.
178
+ - `stdin` is single-shot per process: inside a bare `cmd | crust` script it
179
+ errors (the pipe was already consumed as the script itself).
180
+ - Shell stages get npm-run-style PATH: ancestor `node_modules/.bin` dirs
181
+ are prepended, so local tool binaries work bare (`| pino-pretty`,
182
+ `| tsc --noEmit`) — no `node_modules/.bin/` prefix, no global install.
183
+ - In `… | expect 200 | stats`, a failing expect throws at drain BEFORE stats
184
+ emits — gate status inside a stats assert instead if you need the summary.
185
+
186
+ ## Exit codes
187
+
188
+ Any stage throw → `crust: <message>` on stderr, line exits 1. `crust -c`,
189
+ `crust file.crust`, and piped stdin all run newline-separated lines through
190
+ the same fail-fast loop and STOP at the first non-zero — put warmup and
191
+ cleanup on their own lines. Blank lines and `#` comments are skipped, so a
192
+ `#!/usr/bin/env crust` shebang works in `.crust` files.
193
+
194
+ **Cleanup on a later line is happy-path cleanup, not guaranteed teardown.**
195
+ Under fail-fast, an earlier failure means that line never runs. When
196
+ failure-path cleanup is a requirement (containers, temp rows, mock servers),
197
+ use the verified lifecycle hook: `crust.onExit = async (code) => {…}` in
198
+ `init.ts` — it fires on every shutdown-path exit,
199
+ including the fail-fast failure path of `-c`, script, and stdin runs
200
+ (test-pinned by `tests/onexit.test.ts`), and receives the exit code. If a needed
201
+ guarantee is still missing (e.g. `kill -9`), document the limitation rather
202
+ than implying line placement solves it.
203
+
204
+ ## Version and evidence
205
+
206
+ Behavior here was verified against **crust v0.3.5** — result shapes and
207
+ failure-record semantics by source and through the compiled binary; keep
208
+ approved targets, credentials, and execution restrictions in repository
209
+ policy, not in this skill.