flatbread-cli 0.0.0-stage → 0.2.0

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.
Files changed (3) hide show
  1. package/README.md +400 -3
  2. package/dist/flatbread.mjs +13027 -0
  3. package/package.json +33 -4
package/README.md CHANGED
@@ -1,3 +1,400 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # flatbread-cli
2
+
3
+ Reads a completed agent session off local disk, normalizes it into a
4
+ `FlatbreadTrace`, and sanitizes it for sharing. Nothing is uploaded unless you pass `--publish`
5
+ (or set `FLATBREAD_API_URL` and `FLATBREAD_API_TOKEN` yourself), and then only after the review is
6
+ confirmed. The commands still to come (`setup`, `blame`) are in `PLAN.md` §8.
7
+
8
+ ```bash
9
+ npm install -g flatbread-cli # installs the `flatbread` command (Node 20 or newer)
10
+ npx -y flatbread-cli --help # or run it without installing
11
+ ```
12
+
13
+ On npm since 2026-10-18 (0.1.0). **The npm package is `flatbread-cli`, not `flatbread`.** `flatbread` on npm is an unrelated
14
+ project, so `npx flatbread` runs somebody else's code. The command it installs is `flatbread`.
15
+
16
+ ## Commands
17
+
18
+ ```bash
19
+ flatbread parse --file <path.jsonl> # parse one session log
20
+ flatbread parse --latest # newest primary session under the projects dir
21
+
22
+ flatbread share # newest session: parse, scan, review, write a draft
23
+ flatbread share --file <path.jsonl> # a specific session
24
+ flatbread share --no-review # redact everything, no browser (non-interactive)
25
+ flatbread share --no-open # start the review server but do not launch a browser
26
+
27
+ flatbread share --publish # after review, upload to the signed-in site
28
+
29
+ flatbread login | logout | whoami # sign the CLI in to a site (device flow)
30
+
31
+ flatbread share --pr <url> # link the pull request this run belongs to (https)
32
+
33
+ flatbread view # write the Execution Brief and CAPSULE.md beside the newest artifact
34
+
35
+ flatbread pull <id-or-url> # print a published run's Context Capsule
36
+ flatbread pull <id-or-url> --out <f> # or write it to a file
37
+ flatbread mcp # stdio MCP server: flatbread_search_traces, flatbread_get_capsule
38
+
39
+ flatbread hook install # offer the share at the end of a Claude Code turn
40
+ flatbread hook status # where it is installed, and what it has seen
41
+ flatbread hook uninstall # remove only the flatbread handlers
42
+ ```
43
+
44
+ ## The Claude Code hook
45
+
46
+ `flatbread hook install` merges two handlers into `~/.claude/settings.json`
47
+ (`CLAUDE_CONFIG_DIR` moves it; `--settings <path>` picks any file; `--dry-run` prints the
48
+ merged block and writes nothing). Other tools' hooks in that file are left alone, a second
49
+ install changes nothing, and a settings file that does not parse is **refused** - the block
50
+ to paste is printed instead.
51
+
52
+ What the two handlers do is decided by what the harness allows, not by taste:
53
+
54
+ | Event | What it does | Why not more |
55
+ |---|---|---|
56
+ | `Stop` | prints ONE line offering the share, once per session, after two finished turns | it is the only event whose `systemMessage` reaches the human |
57
+ | `SessionEnd` | records that the session ended, and its reason | Claude Code **discards** a SessionEnd hook's output, so it cannot speak |
58
+
59
+ - The hook **never blocks and never continues a turn**: `decision: "block"` and
60
+ `additionalContext` would put the agent back to work, and exit 2 blocks a `Stop`, so
61
+ `hook notify` exits 0 on every path including its own internal errors. Diagnostics go to
62
+ stderr, which is the debug log (`claude --debug`), and they name every reason it stayed
63
+ quiet.
64
+ - `SessionEnd` asks for **no timeout**, because a per-hook timeout raises the shared
65
+ 1.5-second SessionEnd budget for every other hook in that file.
66
+ - The offer **claims nothing**. It does not say the run is unshared - the hook never reads
67
+ the store - and it cannot ask for a visibility, because a hook has no terminal. It names
68
+ the command, and the choice stays in the local review page.
69
+ - **An offer it cannot remember making is not made.** The "offered" record is written before
70
+ the line is printed; if that write fails the hook stays quiet rather than nag every turn.
71
+ - Turn it off with `FLATBREAD_HOOK_DISABLED=1`, or change when it speaks with
72
+ `FLATBREAD_HOOK_OFFER_AFTER=<turns>`. State lives in `~/.flatbread/hook-state.json`
73
+ (`FLATBREAD_HOME` moves it) and is capped at 50 sessions. Nothing is ever written into
74
+ your repository.
75
+
76
+ Two things the harness's own changelog says can break an install silently, both handled:
77
+
78
+ - **Exec form (`args`) landed in Claude Code 2.1.139.** An older version ignores `args` and
79
+ would run the interpreter with no arguments: a hook that never answers, costing every turn
80
+ the full Stop timeout. `hook install` reads `claude --version` and **refuses** on a version
81
+ it knows is too old; a version it could not read is reported as `NOT RETRIEVED`, never as
82
+ "fine".
83
+ - **`transcript_path` pointed at the wrong directory for resumed sessions before 2.1.72**, so
84
+ the offer requires the transcript to EXIST before it names it. A wrong path shows up as
85
+ silence with a reason, never as a command that fails when you paste it.
86
+
87
+ `hook install` also **probes before it writes**: it runs the exact command it is about to
88
+ install, with a synthetic event, and refuses to write one that does not exit 0 silently. That
89
+ closes the worst failure available — a hook that is installed, broken, and only visible under
90
+ `claude --debug`. `--no-probe` skips it for a machine where spawning is blocked.
91
+
92
+ One caveat worth knowing: Claude Code writes the transcript asynchronously, so the last
93
+ message of a turn may not be in the file yet. The offer names a command you run later, and the
94
+ trace is parsed then, so this only matters if you share within a second of the answer.
95
+
96
+ **It has never run under a real Claude Code**: this machine has none. Everything above is
97
+ proven by unit tests against recorded event payloads and by driving `hook notify` with the
98
+ event JSON on stdin. `HUMAN-TESTS.md` **A7** is the test a person has to run.
99
+
100
+ ## Persistence and publishing
101
+
102
+ After the review gate, `share` mints a slug and writes a `TraceRecord` into the local
103
+ store (`packages/cli/.tmp/store/<slug>.json`), then prints:
104
+
105
+ - the **slug** and its `/r/<slug>` URL
106
+ - a **manage token**, shown once. Only its sha256 hash is stored, so there is no
107
+ recovery path; a `private` record is unreachable without it.
108
+
109
+ `FLATBREAD_STORE_DIR` overrides the store location outright; `FLATBREAD_TMP_DIR`
110
+ moves the whole scratch directory (the store becomes `<tmp>/store`).
111
+
112
+ **Nothing is uploaded** unless you pass `--publish` with a login stored by
113
+ `flatbread login`, or set BOTH `FLATBREAD_API_URL` and `FLATBREAD_API_TOKEN`. The URL
114
+ and the token resolve independently, each falling back to the stored login, and a
115
+ STORED token is never offered to a site that did not issue it. Being signed in is not
116
+ permission to upload: that is what `--publish` is for. `packages/cli/src/publish.ts` is the only file in this repository
117
+ permitted to make an outbound request, the verify suite enforces that by name across
118
+ all four packages, and it refuses to send a token over plain `http` to anything but
119
+ loopback. A publish failure never invalidates the local record; it is reported.
120
+
121
+ Visibility is `private` unless a human raises it - in the review UI, or from the owner
122
+ panel on the site while signed in as the run's owner. There is no `--visibility` flag
123
+ and no API field for it: `--no-review` is non-interactive and must never publish.
124
+
125
+ `parse` writes nothing: it prints the run's summary and brief. `parse --save` writes the raw
126
+ (unredacted) trace to `packages/cli/.tmp/last-parse.json`, and `parse --out <file>` writes it there instead.
127
+ `share` writes `packages/cli/.tmp/last-share.json`. `--title <text>` sets the run's title (the review
128
+ page opens with it and can still change it; every secret in a typed title is redacted), and
129
+ `--commit [sha]` binds the run to a commit (bare = HEAD) and records whether the working tree was
130
+ clean against it.
131
+ `view` reads the newest artifact (share draft preferred over raw parse), builds the
132
+ deterministic Execution Brief with `buildExecutionBrief`, writes `last-brief.json` and
133
+ `CAPSULE.md`, and prints `open http://127.0.0.1:3000/r/local`. It does **not**
134
+ start Next: a hung dev server is worse than one honest instruction line. If the source
135
+ is a raw parse, or a share draft that kept flagged originals, it prints a NOTE - the
136
+ capsule quotes trace text verbatim and inherits whatever the source still contains.
137
+
138
+ `parse` and `share` record the git branch, HEAD commit and uncommitted file names when the
139
+ session's working directory is a git repository. Nothing else is read from git, the index is
140
+ never written, and a log that names a different branch than the checkout gets no commit.
141
+
142
+ ### Auto-capture: `flatbread setup`
143
+
144
+ `flatbread setup` turns on capture for this machine. It installs the Claude Code hook (as `hook
145
+ install` does) with one difference - the SessionEnd handler also starts `flatbread capture` for the
146
+ session that just ended - and, inside a git repository, a chained `post-commit` that records each
147
+ commit against the newest session of that repository (if that session moved in the last two hours).
148
+
149
+ Every capture goes to `~/.flatbread/bucket` (`FLATBREAD_HOME/bucket`): one record per session, with
150
+ EVERY redaction match redacted, visibility `private`, never uploaded. A capture is not a share; to
151
+ publish one, run `share --file` on its session, which opens the review. `capture.log` in the bucket
152
+ has one line per capture, including the ones that skipped or were refused.
153
+
154
+ The post-commit hook never replaces yours: an existing `post-commit` is kept as
155
+ `post-commit.pre-flatbread`, runs first with git's arguments, and its exit status is the hook's.
156
+ It runs after the commit exists, so no SHA changes. When the repository sets `core.hooksPath`
157
+ (husky, lefthook) nothing is written and the line to add is printed. When `core.hooksPath` is
158
+ set machine-wide (system or global config), the hook goes in the repository's own hooks
159
+ directory and setup says it fires only if that machine-wide hook runs it.
160
+
161
+ `setup --dry-run` writes nothing. `setup --uninstall` removes the handlers and the hook, and puts
162
+ a chained hook back byte for byte. `FLATBREAD_HOOK_DISABLED=1` stops every capture.
163
+
164
+ `flatbread search <words>` answers from this machine's shared runs, its captured runs, then the
165
+ site - the same text the MCP tool returns. Words are matched as typed, and by stem ("retry" finds
166
+ "retries" and "retrying"). `--local` leaves the site out; a site that did not answer is said, and
167
+ exits 1.
168
+
169
+ `pull` and `mcp` only read. A token from `flatbread login` goes only to the site that issued it;
170
+ a private run's share link (`?t=`) is sent as a header, never in a URL. Add the MCP server to
171
+ Claude Code with `claude mcp add flatbread -- flatbread mcp`.
172
+
173
+ ### Building the package
174
+
175
+ ```bash
176
+ npm run build -w flatbread-cli # bundles schema, store and zod into dist/flatbread.mjs
177
+ cd packages/cli && npm pack # flatbread-cli-<version>.tgz, the file npm publish uploads
178
+ ```
179
+
180
+ ## Verify
181
+
182
+ From the repo root:
183
+
184
+ ```bash
185
+ npm install
186
+ npx tsx packages/cli/src/index.ts parse --file packages/cli/test/fixtures/claude-code-sample.jsonl
187
+ npm run verify # every suite, no framework, no network
188
+ npm run typecheck
189
+ ```
190
+
191
+ `parse --latest` on a machine with no `~/.claude/projects` prints
192
+ `no sessions found in <dir>` and exits 0. To prove it without deleting anything:
193
+
194
+ ```bash
195
+ # macOS / Linux
196
+ CLAUDE_CONFIG_DIR=$(mktemp -d) npx tsx packages/cli/src/index.ts parse --latest
197
+ # PowerShell
198
+ $env:CLAUDE_CONFIG_DIR="$env:TEMP\no-claude-here"; npx tsx packages/cli/src/index.ts parse --latest
199
+ ```
200
+
201
+ Requires Node 20+. Nothing is compiled: `tsx` runs the TypeScript directly, and
202
+ `@flatbread/schema` is consumed as source through the workspace symlink.
203
+
204
+ ## Where sessions live
205
+
206
+ ```
207
+ <projects-dir>/<encoded-cwd>/<session-uuid>.jsonl
208
+ ```
209
+
210
+ - Projects dir is `$CLAUDE_CONFIG_DIR/projects` when that variable is set,
211
+ otherwise `path.join(os.homedir(), ".claude", "projects")`.
212
+ - Encoded cwd replaces every non-alphanumeric character with `-`. The encoding is
213
+ lossy, so it is only ever used in the encode direction, never decoded back.
214
+ - `agent-*.jsonl` and anything under a `subagents/` directory is a subagent
215
+ transcript and is never treated as a primary session.
216
+
217
+ ## Reading rules
218
+
219
+ The reader streams the file with `readline` and parses one line at a time. A
220
+ whole session is never handed to `JSON.parse`, so one corrupt byte cannot cost
221
+ the entire trace.
222
+
223
+ Three separate skip counters are reported, because they mean different things:
224
+
225
+ | counter | meaning |
226
+ | --- | --- |
227
+ | `malformed` | not JSON, not an object, or no string `type`. Corruption. |
228
+ | `known ignored type` | a type we know and deliberately drop (`system`, `progress`, `file-history-snapshot`, `queue-operation`, `pr-link`, …). |
229
+ | `unknown type` | a `type` this parser has never seen. **Watch this number** - it is how we find out the format moved. |
230
+
231
+ Blank lines are counted separately and are not treated as corruption.
232
+
233
+ Only `user` and `assistant` records produce steps. Only these fields are read:
234
+
235
+ - any record: `type`, `uuid`, `parentUuid`, `timestamp`, `sessionId`, `cwd`,
236
+ `gitBranch`, `version`, `isSidechain`
237
+ - `user`: `message.content` as a string, or as blocks of `text` / `tool_result`
238
+ (`tool_use_id`, `is_error`, `content`)
239
+ - `assistant`: `message.model`, `message.content[]` of `text` / `thinking` /
240
+ `tool_use` (`id`, `name`, `input`), `message.usage.{input_tokens,
241
+ output_tokens, cache_creation_input_tokens, cache_read_input_tokens}`
242
+
243
+ ## Normalization rules
244
+
245
+ - **Order.** File order is the base truth. Steps are re-sorted by `timestamp`
246
+ only when every step has a parseable one, and that sort is stable, so several
247
+ blocks inside one message keep their block order.
248
+ - **`user_prompt`.** A user message that is *not* only `tool_result` blocks. A
249
+ turn carrying both a prompt and tool results yields both kinds of step, with
250
+ the prompt placed at its first text block.
251
+ - **`tool_call`.** One step per `tool_use`. `content` is a one-line label built
252
+ only from input fields that are present (`command`, `file_path`, `pattern`,
253
+ `url`, `description`, `prompt`, `query`); the full input is on `tool.input`.
254
+ - **`tool_result`.** Matched to its `tool_use` by `tool_use_id`. The match sets
255
+ `tool.output` and `tool.isError` on the earlier `tool_call` step **and** emits
256
+ its own step. Unmatched results are counted and reported.
257
+ - **`error_recovery`.** A `tool_result` with `is_error: true` becomes exactly one
258
+ `error_recovery` step; it is *not* also emitted as `tool_result`, or every
259
+ failure would appear twice. Errors are never inferred from prose.
260
+ - **`filesChanged`.** Unique `file_path` (or `notebook_path`) values from
261
+ `Write` / `Edit` / `MultiEdit` / `NotebookEdit` / `Update` inputs, excluding
262
+ calls whose result came back `is_error: true` - a `Write` that raised `EACCES`
263
+ changed nothing. No path in the log means no file recorded; none is invented.
264
+ Unknown tool names contribute nothing here, because "did this write a file"
265
+ cannot be guessed from a name.
266
+ - **`diff`.** Only when the log actually carries one: an explicit `patch`
267
+ string, `old_string`/`new_string`, or the first entry of MultiEdit `edits`.
268
+ A `Write` has no old side, so it gets no diff.
269
+ - **Truncation.** Step `content` at 4000 chars, every string inside `tool.input`
270
+ at 2000 chars, both with `…[truncated]` appended. Limits live in
271
+ `@flatbread/schema` (`LIMITS`) so the web renderer agrees with the CLI.
272
+ - **`title`.** First real `user_prompt`, whitespace collapsed, max 120 chars.
273
+ Otherwise `"Untitled session"`.
274
+ - **`model`.** First `assistant.message.model`, otherwise `"unknown"`.
275
+ - **`visibility`.** Always `"private"` out of the parser.
276
+
277
+ ## Deliberate non-goals in this pass
278
+
279
+ No upload, no localhost review UI, no Next.js. Flatbread does not replay a trace onto
280
+ another codebase, and no copy should ever suggest it does.
281
+
282
+ ---
283
+
284
+ # Sharing: `flatbread share`
285
+
286
+ The command exists to put a human gate in front of the data. It never uploads
287
+ anything, and it cannot: there is no outbound request anywhere in this package,
288
+ and a test greps the sources to keep it that way.
289
+
290
+ ```
291
+ parse the session -> scan every string -> review in the browser -> write a sanitized draft
292
+ (you decide, per match)
293
+ ```
294
+
295
+ 1. Parse with the same code `parse` uses. Sharing changes nothing about parsing.
296
+ 2. Scan `title`, `summary`, `source.cwd`, `source.sourcePath`, every
297
+ `step.content`, every string inside `tool.input` (recursively), `tool.output`,
298
+ `diff.filePath` and `diff.patch`.
299
+ 3. Serve one page on `http://127.0.0.1:4321` (then 4322-4325 if busy).
300
+ 4. You tick any match to KEEP its original text; everything else is redacted.
301
+ 5. You pick visibility: `private` (default), `unlisted`, `public`.
302
+ 6. Confirm writes `packages/cli/.tmp/last-share.json`. Cancel writes nothing.
303
+
304
+ ## The redaction engine
305
+
306
+ Deterministic, local, no model. The same trace and the same choices always give a
307
+ byte-identical result, which is what makes the review meaningful: the artifact is
308
+ exactly what you approved.
309
+
310
+ Matched spans are replaced with a stable token such as `«REDACTED:aws_access_key»`.
311
+
312
+ | # | rule id | what it matches | note |
313
+ | --- | --- | --- | --- |
314
+ | 1 | `aws_access_key` | `AKIA[0-9A-Z]{16}` | |
315
+ | 2 | `private_key` | `-----BEGIN … PRIVATE KEY-----` … `-----END … PRIVATE KEY-----` | the whole block is one match |
316
+ | 3 | `github_token` | `gh[pousr]_[A-Za-z0-9_]{20,}` | |
317
+ | 4 | `anthropic_key` | `sk-ant-[A-Za-z0-9\-_]+` | before rule 5, so it cannot double-fire |
318
+ | 5 | `openai_key` | `sk-[A-Za-z0-9]{20,}` | |
319
+ | 6 | `slack_token` | `xox[baprs]-[A-Za-z0-9-]+` | |
320
+ | 7 | `jwt` | `eyJ….….…` | before rule 8 |
321
+ | 8 | `bearer_token` | `Bearer <value>` | keeps `Bearer `, redacts the value |
322
+ | 9 | `home_path` | `C:\Users\<name>`, `/Users/<name>`, `/home/<name>` | keeps the path shape, redacts the username; case-insensitive |
323
+ | 10 | `assigned_secret` | `(api_key\|apikey\|api-key\|secret\|password\|token\|passwd)\s*[:=]\s*\S+` | keeps the left-hand name, redacts the value; case-insensitive, because real config says `API_KEY=` |
324
+ | 11 | `secret_field_name` | any string whose FIELD NAME is a credential name | **evaluated first**, see below |
325
+
326
+ **Evaluation order is significant.** An earlier rule wins an overlap, so specific
327
+ patterns come before general ones and no span is ever rewritten twice. The order is
328
+ `secret_field_name` first, then rules 1-10 in the order above — the table is
329
+ numbered to match the spec, not the evaluation sequence.
330
+
331
+ Rule 11 going first is deliberate: a field literally named `password` or
332
+ `authorization` holds a credential in its entirety, so redacting the whole value
333
+ beats redacting the one recognisable substring inside it. The only cost is
334
+ reporting granularity — a Slack token in a field named `token` is reported once as
335
+ `secret_field_name` rather than as `slack_token`.
336
+
337
+ **Rule 11 is an addition beyond the ten text patterns**, and the only rule that
338
+ looks at where a string lives rather than at the string itself. An
339
+ `Authorization: Opaque a7f3c9e1d5b28046` value, or a nested
340
+ `env: { API_KEY: "9c8b7a…" }`, has no recognisable shape — an opaque credential
341
+ looks like nothing — so no text pattern can catch it. Field names that trigger it
342
+ are listed in `SECRET_FIELD_NAMES` in `src/redact.ts`; delete that set and the
343
+ rule id to remove the behaviour.
344
+
345
+ **`bearer_token` matches capital-B `Bearer` only**, as specified. RFC 7235 says the
346
+ scheme is case-insensitive, so a lowercase `bearer <opaque-value>` would be missed
347
+ by this rule; a JWT after it is still caught by rule 7, which runs earlier.
348
+
349
+ **The word "token" in prose is not a secret.** Rule 10 requires a `:` or `=` after
350
+ the name, so "Token usage is high" is untouched. There is a test for exactly that.
351
+
352
+ ### What the report does NOT contain
353
+
354
+ `last-share.json` holds the sanitized trace plus a `redaction` report. For every
355
+ match it records the id, rule, field path, offsets, replacement, whether it was
356
+ redacted, and the original's LENGTH — but **not the original text**. A report that
357
+ recorded every original would hand back every secret in the same file that just
358
+ removed them. A short `keptPreview` survives only for matches you chose to keep,
359
+ where the text is in the trace anyway.
360
+
361
+ ## The review page
362
+
363
+ One HTML document. Inline CSS, ~25 lines of inline JS, no CDN, no fonts, no
364
+ `<link>`, no `<iframe>`, served with `Content-Security-Policy: default-src 'none'`.
365
+ An offline machine can run the whole review.
366
+
367
+ - **Top:** title, model, step / tool-call / file / error counts, duration, and the
368
+ number of matches.
369
+ - **Main:** a `trace` block (title, summary, cwd, sourcePath) then every step, with
370
+ each matched span marked. The mark shows the replacement token by default and
371
+ the original when you tick KEEP.
372
+ - **Side:** every match with a rule label, a preview of the original capped at 24
373
+ characters, and the field path. `redact all` / `keep all` buttons, the visibility
374
+ radio, and Confirm / Cancel.
375
+
376
+ The page shows unredacted secrets when you tick KEEP, by design — you cannot
377
+ review what you cannot see. That is why it binds `127.0.0.1` only and is never
378
+ written to disk.
379
+
380
+ **The submitted checkbox means KEEP, not redact.** HTML only submits ticked boxes,
381
+ so a value lost in the browser has to mean "redact it". The sidebar still reads
382
+ "redact, on by default" via CSS, but the transport fails closed.
383
+
384
+ ## Verify the share path
385
+
386
+ ```bash
387
+ npm run verify # includes the redaction / review / share suites
388
+
389
+ # non-interactive, writes a draft with everything redacted
390
+ npx tsx packages/cli/src/index.ts share --file packages/cli/test/fixtures/secrets-sample.jsonl --no-review
391
+
392
+ # the real thing
393
+ npx tsx packages/cli/src/index.ts share --file packages/cli/test/fixtures/secrets-sample.jsonl
394
+ ```
395
+
396
+ `test/fixtures/secrets-sample.jsonl` carries one of every class: an AWS key id, an
397
+ Anthropic key, a GitHub token, a Slack token, a JWT in a Bearer header, a PEM
398
+ private key, `password=` on one line, Windows and Linux home paths, opaque values
399
+ in credential-named fields, and the prose "Token usage is high" that must survive.
400
+ It contains no real secret.