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