@stll/folio-cli 0.0.1-placeholder.0 → 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 (45) hide show
  1. package/README.md +298 -0
  2. package/dist/bin.d.ts +1 -0
  3. package/dist/bin.js +30 -0
  4. package/dist/cli.d.ts +25 -0
  5. package/dist/cli.js +440 -0
  6. package/dist/document.d.ts +43 -0
  7. package/dist/document.js +127 -0
  8. package/dist/errors.d.ts +67 -0
  9. package/dist/errors.js +92 -0
  10. package/dist/execute-read.d.ts +64 -0
  11. package/dist/execute-read.js +220 -0
  12. package/dist/execute-write.d.ts +36 -0
  13. package/dist/execute-write.js +449 -0
  14. package/dist/flags.d.ts +38 -0
  15. package/dist/flags.js +102 -0
  16. package/dist/journal.d.ts +82 -0
  17. package/dist/journal.js +212 -0
  18. package/dist/lock.d.ts +55 -0
  19. package/dist/lock.js +157 -0
  20. package/dist/mcp.d.ts +28 -0
  21. package/dist/mcp.js +310 -0
  22. package/dist/output-file.d.ts +14 -0
  23. package/dist/output-file.js +60 -0
  24. package/dist/output.d.ts +33 -0
  25. package/dist/output.js +77 -0
  26. package/dist/package-parts.d.ts +24 -0
  27. package/dist/package-parts.js +172 -0
  28. package/dist/package.js +4 -0
  29. package/dist/preview-commands.d.ts +8 -0
  30. package/dist/preview-commands.js +226 -0
  31. package/dist/provenance.d.ts +17 -0
  32. package/dist/provenance.js +53 -0
  33. package/dist/registry.d.ts +69 -0
  34. package/dist/registry.js +255 -0
  35. package/dist/render.d.ts +35 -0
  36. package/dist/render.js +120 -0
  37. package/dist/roots.d.ts +21 -0
  38. package/dist/roots.js +57 -0
  39. package/dist/serve.d.ts +17 -0
  40. package/dist/serve.js +234 -0
  41. package/dist/sidecar.d.ts +52 -0
  42. package/dist/sidecar.js +156 -0
  43. package/dist/transaction.d.ts +55 -0
  44. package/dist/transaction.js +218 -0
  45. package/package.json +85 -3
package/README.md ADDED
@@ -0,0 +1,298 @@
1
+ # @stll/folio-cli
2
+
3
+ `folio`: a command line for reading and reviewing `.docx` files on disk. It
4
+ runs the [`@stll/folio-agents`](https://www.npmjs.com/package/@stll/folio-agents)
5
+ tools over local files, so a script, a person at a terminal, and an agent see
6
+ the same blocks, ids, and results.
7
+
8
+ Part of [stella](https://github.com/stella/stella), an open-source legal workspace.
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ bun add -g @stll/folio-cli # or: npm install -g @stll/folio-cli
14
+ folio --help
15
+ ```
16
+
17
+ Node.js 22 or later.
18
+
19
+ ## Reading
20
+
21
+ | Command | Tool | Prints |
22
+ | ---------- | ---------------------- | ------------------------------------------------------ |
23
+ | `read` | `read_document` | Body blocks with ids, text, and `blockTextHash`, paged |
24
+ | `outline` | `get_document_outline` | Heading outline with section handles |
25
+ | `section` | `read_section` | One section's blocks, by handle |
26
+ | `stories` | `list_stories` | Header, footer, footnote, and endnote story handles |
27
+ | `story` | `read_story` | One story's text, by handle |
28
+ | `find` | `find_text` | Exact matches with range handles and context |
29
+ | `comments` | `read_comments` | Comment threads with replies and resolved state |
30
+ | `changes` | `read_changes` | Pending tracked changes |
31
+
32
+ ```sh
33
+ folio read contract.docx --max-blocks 50
34
+ folio find contract.docx --query "Termination" --whole-word
35
+ # the handle is copied from `folio outline`
36
+ folio section contract.docx --handle '{"type":"headingSection","story":"main","headingBlockId":"1A2B3C4D","headingTextHash":"h5f3a9c","headingLevel":1}'
37
+ folio comments contract.docx --filter open --output text
38
+ ```
39
+
40
+ Each command's flags are generated from its tool's input schema: a property
41
+ `matchCase` is `--match-case`; objects and arrays take JSON. `--input` passes
42
+ the whole argument object as JSON (inline, `@args.json`, or `-` for stdin), and
43
+ flags given with it override its fields. `folio <command> --help` lists them.
44
+
45
+ Reading never writes: not the file, not identifiers, not a cache.
46
+
47
+ ## Changing a document
48
+
49
+ | Command | Tool | Does |
50
+ | --------- | ------------------- | ------------------------------------------------------------ |
51
+ | `suggest` | `suggest_changes` | Applies a batch of edit operations as tracked changes |
52
+ | `comment` | `add_comment` | Comments on a block, optionally quoting text in it |
53
+ | `reply` | `reply_comment` | Replies to a comment thread |
54
+ | `resolve` | `resolve_comment` | Resolves or reopens a comment thread |
55
+ | `accept` | `resolve_changes` | Accepts tracked changes: `--id <id>` (repeatable) or `--all` |
56
+ | `reject` | `resolve_changes` | Rejects tracked changes: `--id <id>` (repeatable) or `--all` |
57
+ | `compare` | `compare_documents` | Diffs two files; with `-o`, writes their redline |
58
+
59
+ ```sh
60
+ folio suggest contract.docx --input @ops.json --in-place --expect-version 9f2c…
61
+ folio comment contract.docx --block-id 1A2B3C4D --text "Confirm the rate." -o reviewed.docx --expect-version 9f2c…
62
+ folio accept contract.docx --all --in-place --expect-version 51ab…
63
+ folio compare signed.docx draft.docx -o redline.docx --expect-version 07de…
64
+ ```
65
+
66
+ `suggest --input` takes the operation batch: `{ "operations": [...] }`, or the
67
+ bare array. The operation types and fields are those of `suggest_changes` in
68
+ `@stll/folio-agents` (`folio suggest --help` lists them); every operation's
69
+ targets come from a read of the same `fileVersion`. Edits are tracked changes;
70
+ `--direct` edits the text instead and must be asked for.
71
+
72
+ Every change commits, so there is no separate save:
73
+
74
+ - **Destination.** Exactly one of `--in-place` or `-o <path>`, and only a
75
+ `.docx` whose name does not start with a dot and that is not inside a
76
+ `.folio` directory (`invalid_destination`). `-o` never modifies the input
77
+ and refuses a path that is the input (or, for `compare`, the revised file)
78
+ under another name: symlinks and hard links are resolved and files are
79
+ compared by device and inode. `-o` refuses an existing file unless
80
+ `--overwrite` is given with `--expect-destination-version <fileVersion>`
81
+ of the file being replaced.
82
+ - **Preconditions.** A change needs `--expect-version <fileVersion>` from the
83
+ read it was based on, and refuses (`stale_version`, exit 10) a file that
84
+ changed since; `--no-expect-version` skips the check and must be given
85
+ explicitly. Under the write lease the file is hashed again right before
86
+ the commit, so a change made during the edit is refused too. A batch lands whole
87
+ or not at all: a stale target (`stale_target`, exit 10), an ambiguous `find`
88
+ (`ambiguous_target`, exit 2), or any other refused operation writes nothing,
89
+ with the per-operation reasons in `error.details`.
90
+ - **Atomic write.** The new package is staged beside the destination, checked
91
+ (it reopens, every changed XML part is well formed, every relationship id a
92
+ changed part uses resolves, every internal target a changed `.rels` part
93
+ names exists), flushed to disk, and renamed over the destination. Whatever
94
+ a write replaces (in place, or with `--overwrite`) is first copied to
95
+ `.folio/backups/<document name>/<fileVersion>.docx`, flushed with its
96
+ directory; the newest 20 per document are kept. A file with more than one
97
+ hard link is not written in place.
98
+ - **Minimal saves.** A change is written by patching the edited paragraphs
99
+ into the original package, leaving every other part's content as it was.
100
+ When that is not possible (a paragraph added or removed, styles or section
101
+ properties changed) the command refuses with `repack_required` rather than
102
+ silently rewriting the whole package; `--allow-repack` permits it. The
103
+ receipt reports `saveStrategy` (`selective`, `full-repack` with a
104
+ `repackReason`, or `redline`) and `changedParts`. The promise is part
105
+ content, not identical ZIP bytes.
106
+ - **Write lease.** A write holds `.<name>.docx.folio-lock` beside the file
107
+ (pid, host, expiry, and a random token), created complete or not at all.
108
+ Another holder, such as an editor session, makes a write fail with
109
+ `locked` (exit 10) unless `--force`. Just before journaling and again
110
+ before the rename, a write checks that the lock still carries its token, so
111
+ a writer whose lease was taken over stops without writing. Reads ignore the
112
+ lease. A lease whose process has exited, or that expired, is replaced; a
113
+ lock that cannot be parsed counts as held until it is older than a lease.
114
+ - **Sidecar files.** The lock, the stage, the journal, and backups are never
115
+ read or written through a symlink or a file with more than one hard link
116
+ (`unsafe_path`, exit 8). `.folio` and its subdirectories are created with
117
+ mode 0700; an existing `.folio` that is a symlink or not a directory is
118
+ refused, not followed.
119
+ - **Author and time.** The author is `--author`, else `FOLIO_AUTHOR`, else git
120
+ `user.name`; with none, the command refuses. Every revision, comment, and
121
+ reply of one transaction carries one UTC timestamp, `--date` to fix it for
122
+ reproducible output. (`compare` stamps its redline with the current time.)
123
+
124
+ ### Journal, retries, and recovery
125
+
126
+ Each committed transaction appends a line to `.folio/journal.jsonl` beside the
127
+ destination (or `--journal <path>`): its `txId`, `fromVersion`, `toVersion`,
128
+ author, time, the operations, their receipts, and the command's receipt.
129
+
130
+ `--tx-id <key>` makes a change idempotent: running a committed transaction
131
+ again returns its original receipt with `status: "replayed"` and writes
132
+ nothing; reusing the key for a different request is refused
133
+ (`transaction_conflict`).
134
+
135
+ The journal line is written after the staged package validates and before the
136
+ rename. If a process dies between the two, the next write to that file
137
+ completes the rename only when the line names that file, the stage is a
138
+ regular single-link file holding the recorded version, it still parses as a
139
+ package, and the file is still what the transaction replaced; any other stage
140
+ is discarded (unlinked, never followed). Recovery is reported under
141
+ `recovered`, and the write's own version check runs against the file as
142
+ recovery left it.
143
+
144
+ ### Known limits
145
+
146
+ A process on the same machine that can rename directories inside the
147
+ document's directory while a write runs can still race it: names are
148
+ resolved, then re-checked (the handle's device and inode against the checked
149
+ path, and the directory's real path just before the rename), but not held
150
+ open across the whole transaction. The tool caller cannot exploit this; it
151
+ matters only against another local actor with write access to the
152
+ directory.
153
+
154
+ ## Rendering and preview
155
+
156
+ ```sh
157
+ folio render contract.docx -o contract.pdf
158
+ folio render contract.docx -o page-3.png --page 3
159
+ folio serve contract.docx
160
+ ```
161
+
162
+ `folio render` lays the document out with folio's own layout engine and
163
+ paints it with folio's painters, with no word processor involved: `.pdf` from
164
+ the PDF backend (every page, or `--page n`), `.html` from the DOM backend,
165
+ and `.png` by screenshotting that page in headless Chromium (page 1 unless
166
+ `--page`, `--scale` pixels per CSS pixel, default 2). Text is measured and
167
+ painted with the metric-compatible `@fontsource` faces the CLI installs. PNG
168
+ needs `playwright-core` and its Chromium, which the CLI does not install:
169
+
170
+ ```sh
171
+ npm install playwright-core && npx playwright-core install chromium
172
+ ```
173
+
174
+ `-o` refuses an existing file unless `--overwrite`; the document is never
175
+ changed.
176
+
177
+ `folio serve` prints a URL and serves a read-only preview of the document on
178
+ `127.0.0.1` until interrupted. The page re-renders when the file's version
179
+ changes: it watches the file and its `.folio/journal.jsonl`, so a change from
180
+ `folio suggest`, the MCP server, or any other program appears on its own. The
181
+ URL carries a random token; requests without it, with a `Host` other than
182
+ the loopback address, or with a method other than GET are refused. The server
183
+ never writes, not even a lock.
184
+
185
+ ## Output
186
+
187
+ Every command prints one envelope:
188
+
189
+ ```json
190
+ { "ok": true, "data": { "path": "/abs/contract.docx", "fileVersion": "9f2c…", "result": {} } }
191
+ { "ok": false, "error": { "code": "stale_version", "message": "…", "hint": "Re-read the document…" } }
192
+ ```
193
+
194
+ `--output json` (the default when stdout is not a terminal) prints the
195
+ envelope on stdout for success and failure alike. `--output text` (the default
196
+ on a terminal) prints a readable rendering, and failures as `error:` and
197
+ `hint:` lines on stderr.
198
+
199
+ | Exit | Meaning |
200
+ | ---- | -------------------------------------------------------------------------- |
201
+ | 0 | success |
202
+ | 1 | unexpected internal error |
203
+ | 2 | usage, input, or refused-operation error |
204
+ | 6 | file, change, or comment not found |
205
+ | 8 | path outside the allowed roots, or unsafe to write through |
206
+ | 10 | conflict with current state (stale version, lock held, destination exists) |
207
+
208
+ ## Versions and identifiers
209
+
210
+ `fileVersion` is the lowercase hex SHA-256 of the file's bytes.
211
+ `--expect-version <sha256>` refuses the command (exit 10, `stale_version`)
212
+ unless the file still has that version.
213
+
214
+ A block id is the paragraph's own `w14:paraId` when it has one
215
+ (`blockIdSource: "package"`). A paragraph without one gets an id derived from
216
+ its text and position (`blockIdSource: "synthetic"`): stable across reads of
217
+ the same bytes, but valid only for the `fileVersion` it was read at.
218
+
219
+ `read` pages with `--max-blocks`; a page that stops early carries
220
+ `nextCursor`, which `--cursor` continues. A cursor is bound to the version it
221
+ was issued for, so a cursor from before an edit is refused rather than
222
+ continuing on different content.
223
+
224
+ Offsets in range handles (`startOffset`, `endOffset`) are UTF-16 code-unit
225
+ indices into the block `text` that `read` returns, the indexing JavaScript
226
+ strings use. A consumer that counts Unicode code points converts with
227
+ `Array.from(text.slice(0, offset)).length`.
228
+
229
+ ## MCP server
230
+
231
+ `folio mcp` serves the same tools over the Model Context Protocol on stdio:
232
+ the protocol owns stdout, diagnostics go to stderr.
233
+
234
+ ```sh
235
+ folio mcp --root ~/contracts --author "Jane Doe"
236
+ ```
237
+
238
+ Claude Code, in a project's `.mcp.json`:
239
+
240
+ ```json
241
+ {
242
+ "mcpServers": {
243
+ "folio": {
244
+ "command": "npx",
245
+ "args": ["-y", "@stll/folio-cli", "mcp", "--root", "."],
246
+ "env": { "FOLIO_AUTHOR": "Jane Doe" }
247
+ }
248
+ }
249
+ }
250
+ ```
251
+
252
+ Codex, in `~/.codex/config.toml`:
253
+
254
+ ```toml
255
+ [mcp_servers.folio]
256
+ command = "npx"
257
+ args = ["-y", "@stll/folio-cli", "mcp", "--root", "/path/to/contracts"]
258
+ env = { FOLIO_AUTHOR = "Jane Doe" }
259
+ ```
260
+
261
+ Tools: `read_document`, `get_document_outline`, `read_section`,
262
+ `list_stories`, `read_story`, `find_text`, `read_comments`, `read_changes`,
263
+ `suggest_changes`, `add_comment`, `reply_comment`, `resolve_comment`,
264
+ `resolve_changes`, and `compare_documents`. Each takes its folio-agents
265
+ arguments plus a file envelope:
266
+
267
+ | Field | Meaning |
268
+ | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
269
+ | `path` | The `.docx`, absolute or relative to the first root |
270
+ | `fileVersion` | Required on every change (including `compare_documents` with a `destination`): the version the caller read; optional on reads |
271
+ | `destination` | Write the result to this `.docx` instead of changing `path` in place (not a dotfile, not inside `.folio`) |
272
+ | `overwrite` | Let `destination` replace an existing file; needs `expectedDestinationVersion` |
273
+ | `expectedDestinationVersion` | The version of the file `destination` replaces; it is backed up first |
274
+ | `txId` | Idempotency key, as `--tx-id` |
275
+ | `allowRepack` | As `--allow-repack` |
276
+ | `mode` | `suggest_changes` only: `tracked` (default) or `direct` |
277
+
278
+ Every path, including `destination` and `compare_documents`' `revisedPath`,
279
+ must resolve through symlinks inside an allowed root (`--root`, repeatable,
280
+ default the current directory), or the call is refused with `outside_root`.
281
+ The author comes from `--author`, `FOLIO_AUTHOR`, or git `user.name` when
282
+ the server starts; without one, reads work and changes are refused. The
283
+ server never takes over another holder's write lease. Tools that write are
284
+ annotated `destructiveHint: true`; there is no way to skip the version check.
285
+
286
+ One call returns at most 200 `read_document` blocks by default (up to
287
+ 1,000 with `maxBlocks`), 100 `find_text` matches, and 256 KiB; a longer read
288
+ pages with `nextCursor`, and a result that cannot be paged is refused with
289
+ `too_large` and a hint to narrow it. Results are the `{ ok, data | error }`
290
+ envelope as JSON text, with `isError` set on failures.
291
+
292
+ Resources: `folio://about` (these rules and the roots) and
293
+ `folio://schema/operations` (the JSON Schema of an operation batch).
294
+
295
+ ## Untrusted documents
296
+
297
+ Reads return document text verbatim. Text from an untrusted `.docx` can carry
298
+ instructions aimed at a model that reads it; treat it as data.
package/dist/bin.d.ts ADDED
@@ -0,0 +1 @@
1
+ export {}
package/dist/bin.js ADDED
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ import { runFolioCli } from "./cli.js";
3
+ //#region src/bin.ts
4
+ const readStdin = async () => {
5
+ const chunks = [];
6
+ for await (const chunk of process.stdin) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
7
+ return Buffer.concat(chunks).toString("utf8");
8
+ };
9
+ process.exitCode = await runFolioCli(process.argv.slice(2), {
10
+ stdout: (text) => {
11
+ process.stdout.write(text);
12
+ },
13
+ stderr: (text) => {
14
+ process.stderr.write(text);
15
+ },
16
+ readStdin,
17
+ isTTY: process.stdout.isTTY,
18
+ env: process.env,
19
+ cwd: process.cwd(),
20
+ stdio: {
21
+ input: process.stdin,
22
+ output: process.stdout
23
+ },
24
+ untilInterrupted: () => new Promise((resolve) => {
25
+ process.once("SIGINT", () => resolve());
26
+ process.once("SIGTERM", () => resolve());
27
+ })
28
+ });
29
+ //#endregion
30
+ export {};
package/dist/cli.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { Readable, Writable } from "node:stream";
2
+ //#region src/cli.d.ts
3
+ /** The process surface the command line reads from and writes to. */
4
+ type FolioCliIo = {
5
+ stdout: (text: string) => void;
6
+ stderr: (text: string) => void;
7
+ readStdin: () => Promise<string>;
8
+ /** Whether stdout is a terminal; picks the default output format. */
9
+ isTTY: boolean;
10
+ /** Environment for `FOLIO_AUTHOR`. */
11
+ env: Readonly<Record<string, string | undefined>>;
12
+ /** Directory whose git `user.name` is the last author fallback, and the default MCP root. */
13
+ cwd: string;
14
+ /** Byte streams `folio mcp` speaks the protocol over; absent, it refuses to start. */
15
+ stdio?: {
16
+ input: Readable;
17
+ output: Writable;
18
+ };
19
+ /** Resolves when the process is asked to stop; `folio serve` runs until then. */
20
+ untilInterrupted?: () => Promise<void>;
21
+ };
22
+ /** Run `folio` with the given arguments (without the executable); resolves to the exit code. */
23
+ declare const runFolioCli: (argv: readonly string[], io: FolioCliIo) => Promise<number>;
24
+ //#endregion
25
+ export { FolioCliIo, runFolioCli };