@stll/folio-cli
v0.4.1
Published
Command line and stdio MCP server for reading, reviewing, and redlining .docx files with folio: tracked-change edits, comments, and comparisons written atomically with version preconditions.
Maintainers
Readme
@stll/folio-cli
folio: a command line for reading and reviewing .docx files on disk. It
runs the @stll/folio-agents
tools over local files, so a script, a person at a terminal, and an agent see
the same blocks, ids, and results.
Part of stella, an open-source legal workspace.
Install
bun add -g @stll/folio-cli # or: npm install -g @stll/folio-cli
folio --helpNode.js 22 or later.
Reading
| Command | Tool | Prints |
| ---------- | ---------------------- | ------------------------------------------------------ |
| read | read_document | Body blocks with ids, text, and blockTextHash, paged |
| outline | get_document_outline | Heading outline with section handles |
| section | read_section | One section's blocks, by handle |
| stories | list_stories | Header, footer, footnote, and endnote story handles |
| story | read_story | One story's text, by handle |
| find | find_text | Exact matches with range handles and context |
| comments | read_comments | Comment threads with replies and resolved state |
| changes | read_changes | Pending tracked changes |
folio read contract.docx --max-blocks 50
folio find contract.docx --query "Termination" --whole-word
# the handle is copied from `folio outline`
folio section contract.docx --handle '{"type":"headingSection","story":"main","headingBlockId":"1A2B3C4D","headingTextHash":"h5f3a9c","headingLevel":1}'
folio comments contract.docx --filter open --output textEach command's flags are generated from its tool's input schema: a property
matchCase is --match-case; objects and arrays take JSON. --input passes
the whole argument object as JSON (inline, @args.json, or - for stdin), and
flags given with it override its fields. folio <command> --help lists them.
Reading never writes: not the file, not identifiers, not a cache.
Changing a document
| Command | Tool | Does |
| --------- | ------------------- | ------------------------------------------------------------ |
| suggest | suggest_changes | Applies a batch of edit operations as tracked changes |
| comment | add_comment | Comments on a block, optionally quoting text in it |
| reply | reply_comment | Replies to a comment thread |
| resolve | resolve_comment | Resolves or reopens a comment thread |
| accept | resolve_changes | Accepts tracked changes: --id <id> (repeatable) or --all |
| reject | resolve_changes | Rejects tracked changes: --id <id> (repeatable) or --all |
| compare | compare_documents | Diffs two files; with -o, writes their redline |
folio suggest contract.docx --input @ops.json --in-place --expect-version 9f2c…
folio comment contract.docx --block-id 1A2B3C4D --text "Confirm the rate." -o reviewed.docx --expect-version 9f2c…
folio accept contract.docx --all --in-place --expect-version 51ab…
folio compare signed.docx draft.docx -o redline.docx --expect-version 07de…suggest --input takes the operation batch: { "operations": [...] }, or the
bare array. The operation types and fields are those of suggest_changes in
@stll/folio-agents (folio suggest --help lists them); every operation's
targets come from a read of the same fileVersion. Edits are tracked changes;
--direct edits the text instead and must be asked for.
Two more operations are expanded against the file before the batch applies:
{ "type": "replaceAll", "find", "replace", "matchCase"?, "wholeWord"? }
replaces every match in the body, tables included, keeping each run's
formatting (refused when nothing matches); and
{ "type": "addComment", "comment", "blockId"?, "quote"? } comments on a
block, or with only quote on the one block containing that text
(ambiguous_target when several do).
Every change commits, so there is no separate save:
- Destination. Exactly one of
--in-placeor-o <path>, and only a.docxwhose name does not start with a dot and that is not inside a.foliodirectory (invalid_destination).-onever modifies the input and refuses a path that is the input (or, forcompare, the revised file) under another name: symlinks and hard links are resolved and files are compared by device and inode.-orefuses an existing file unless--overwriteis given with--expect-destination-version <fileVersion>of the file being replaced. - Preconditions. A change needs
--expect-version <fileVersion>from the read it was based on, and refuses (stale_version, exit 10) a file that changed since;--no-expect-versionskips the check and must be given explicitly. Under the write lease the file is hashed again right before the commit, so a change made during the edit is refused too. A batch lands whole or not at all: a stale target (stale_target, exit 10), an ambiguousfind(ambiguous_target, exit 2), or any other refused operation writes nothing, with the per-operation reasons inerror.details. - Atomic write. The new package is staged beside the destination, checked
(it reopens, every changed XML part is well formed, every relationship id a
changed part uses resolves, every internal target a changed
.relspart names exists), flushed to disk, and renamed over the destination. Whatever a write replaces (in place, or with--overwrite) is first copied to.folio/backups/<document name>/<fileVersion>.docx, flushed with its directory; the newest 20 per document are kept. A file with more than one hard link is not written in place. - Minimal saves. A change is written by patching the edited paragraphs
into the original package, leaving every other part's content as it was.
When that is not possible (a paragraph added or removed, styles or section
properties changed) the command refuses with
repack_requiredrather than silently rewriting the whole package;--allow-repackpermits it. The receipt reportssaveStrategy(selective,full-repackwith arepackReason, orredline) andchangedParts. The promise is part content, not identical ZIP bytes. - Write lease. A write holds
.<name>.docx.folio-lockbeside the file (pid, host, expiry, and a random token), created complete or not at all. An editor holding it with unsaved edits is asked to save and release first (see Saving from an editor); any other holder makes a write fail withlocked(exit 10) unless--force. Just before journaling and again before the rename, a write checks that the lock still carries its token, so a writer whose lease was taken over stops without writing. Reads ignore the lease. A lease whose process has exited, or that expired, is replaced; a lock that cannot be parsed counts as held until it is older than a lease. - Sidecar files. The lock, the stage, the journal, and backups are never
read or written through a symlink or a file with more than one hard link
(
unsafe_path, exit 8)..folioand its subdirectories are created with mode 0700; an existing.foliothat is a symlink or not a directory is refused, not followed. - Author and time. The author is
--author, elseFOLIO_AUTHOR, else gituser.name; with none, the command refuses. Every revision, comment, and reply of one transaction carries one UTC timestamp,--dateto fix it for reproducible output. (comparestamps its redline with the current time.)
Saving from an editor
folio save <file> --from <saved.docx> --expect-version <fileVersion> commits
a whole package a live editor serialized, with the same lease, recovery,
checks, backup, and journal as a tool call (tool: "editor_save", with
--surface and --save-strategy recorded). Unlike the tool commands it may
rewrite every part, so it is not an MCP tool. --owner names the lease holder.
An editor with unsaved edits holds the lease long-lived (acceptsFlush, 30 s,
renewed every 10 s) and watches for .<name>.docx.folio-flush-<id> requests.
A write that finds it writes a request and waits up to --flush-wait ms
(default 5000) for the editor to save under its lease (--lease-token) and
release; the write then runs on the saved version. An agent tool's
--expect-version from before the flush is accepted when the journal shows
the editor's save from exactly that version and every block it targets is a
package w14:paraId in both versions; its text-hash preconditions decide the
rest. A call targeting a text-derived id (blockIdSource: synthetic), or no
block, is refused with stale_version and re-reads. An editor that does not release in time is
treated by the ordinary lease rules. @stll/folio-cli/editor-lease exports
the editor side.
Journal, retries, and recovery
Each committed transaction appends a line to .folio/journal.jsonl beside the
destination (or --journal <path>): its txId, fromVersion, toVersion,
author, time, the operations, their receipts, and the command's receipt.
--tx-id <key> makes a change idempotent: running a committed transaction
again returns its original receipt with status: "replayed" and writes
nothing; reusing the key for a different request is refused
(transaction_conflict).
The journal line is written after the staged package validates and before the
rename. If a process dies between the two, the next write to that file
completes the rename only when the line names that file, the stage is a
regular single-link file holding the recorded version, it still parses as a
package, and the file is still what the transaction replaced; any other stage
is discarded (unlinked, never followed). Recovery is reported under
recovered, and the write's own version check runs against the file as
recovery left it.
Known limits
A process on the same machine that can rename directories inside the document's directory while a write runs can still race it: names are resolved, then re-checked (the handle's device and inode against the checked path, and the directory's real path just before the rename), but not held open across the whole transaction. The tool caller cannot exploit this; it matters only against another local actor with write access to the directory.
Rendering and preview
folio render contract.docx -o contract.pdf
folio render contract.docx -o page-3.png --page 3
folio serve contract.docxfolio render lays the document out with folio's own layout engine and
paints it with folio's painters, with no word processor involved: .pdf from
the PDF backend (every page, or --page n), .html from the DOM backend,
and .png by screenshotting that page in headless Chromium (page 1 unless
--page, --scale pixels per CSS pixel, default 2). Text is measured and
painted with the metric-compatible @fontsource faces the CLI installs. PNG
needs playwright-core and its Chromium, which the CLI does not install:
npm install playwright-core && npx playwright-core install chromium-o refuses an existing file unless --overwrite; the document is never
changed.
folio serve prints a URL and serves a read-only preview of the document on
127.0.0.1 until interrupted. The page re-renders when the file's version
changes: it watches the file and its .folio/journal.jsonl, so a change from
folio suggest, the MCP server, or any other program appears on its own. The
URL carries a random token; requests without it, with a Host other than
the loopback address, or with a method other than GET are refused. The server
never writes, not even a lock.
Output
Every command prints one envelope:
{ "ok": true, "data": { "path": "/abs/contract.docx", "fileVersion": "9f2c…", "result": {} } }
{ "ok": false, "error": { "code": "stale_version", "message": "…", "hint": "Re-read the document…" } }--output json (the default when stdout is not a terminal) prints the
envelope on stdout for success and failure alike. --output text (the default
on a terminal) prints a readable rendering, and failures as error: and
hint: lines on stderr.
| Exit | Meaning | | ---- | -------------------------------------------------------------------------- | | 0 | success | | 1 | unexpected internal error | | 2 | usage, input, or refused-operation error | | 6 | file, change, or comment not found | | 8 | path outside the allowed roots, or unsafe to write through | | 10 | conflict with current state (stale version, lock held, destination exists) |
Versions and identifiers
fileVersion is the lowercase hex SHA-256 of the file's bytes.
--expect-version <sha256> refuses the command (exit 10, stale_version)
unless the file still has that version.
A block id is the paragraph's own w14:paraId when it has one
(blockIdSource: "package"). A paragraph without one gets an id derived from
the file's bytes (blockIdSource: "synthetic"): stable across reads of the
same bytes, and written into the file as its w14:paraId by the first change,
so a paragraph keeps its id across edits. A signed package is never rewritten
for this; its id-less paragraphs keep ids derived from text and position,
valid only for the fileVersion they were read at.
read pages with --max-blocks; a page that stops early carries
nextCursor, which --cursor continues. A cursor is bound to the version it
was issued for, so a cursor from before an edit is refused rather than
continuing on different content.
Offsets in range handles (startOffset, endOffset) are UTF-16 code-unit
indices into the block text that read returns, the indexing JavaScript
strings use. A consumer that counts Unicode code points converts with
Array.from(text.slice(0, offset)).length.
MCP server
folio mcp serves the same tools over the Model Context Protocol on stdio:
the protocol owns stdout, diagnostics go to stderr.
folio mcp --root ~/contracts --author "Jane Doe"Claude Code, in a project's .mcp.json:
{
"mcpServers": {
"folio": {
"command": "npx",
"args": ["-y", "@stll/folio-cli", "mcp", "--root", "."],
"env": { "FOLIO_AUTHOR": "Jane Doe" }
}
}
}Codex, in ~/.codex/config.toml:
[mcp_servers.folio]
command = "npx"
args = ["-y", "@stll/folio-cli", "mcp", "--root", "/path/to/contracts"]
env = { FOLIO_AUTHOR = "Jane Doe" }A model is sent the tool list on every turn, so the server lists only the
frequent tools, with compact schemas: read_document, find_text,
read_comments, read_changes, suggest_changes, and add_comment. The
rest (get_document_outline, read_section, list_stories, read_story,
reply_comment, resolve_comment, resolve_changes, and
compare_documents) are reached through list_capabilities,
describe_capability (a tool's parameter outline, short guidance and an
example, listed tools included; detail: "full" for its full input schema),
and invoke_capability ({ capability, input, validate_only }).
Every tool is also callable by name. Each takes its folio-agents arguments
plus a file envelope:
| Field | Meaning |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| path | The .docx, absolute or relative to the first root |
| fileVersion | Required on every change (including compare_documents with a destination): the version the caller read; optional on reads |
| destination | Write the result to this .docx instead of changing path in place (not a dotfile, not inside .folio) |
| overwrite | Let destination replace an existing file; needs expectedDestinationVersion |
| expectedDestinationVersion | The version of the file destination replaces; it is backed up first |
| txId | Idempotency key, as --tx-id |
| allowRepack | As --allow-repack |
| mode | suggest_changes only: tracked (default) or direct |
Every path, including destination and compare_documents' revisedPath,
must resolve through symlinks inside an allowed root (--root, repeatable,
default the current directory), or the call is refused with outside_root.
The author comes from --author, FOLIO_AUTHOR, or git user.name when
the server starts; without one, reads work and changes are refused. The
server never takes over another holder's write lease. Tools that write are
annotated destructiveHint: true; there is no way to skip the version check.
One call returns at most 200 read_document blocks by default (up to
1,000 with maxBlocks), 100 find_text matches, and 256 KiB; a longer read
pages with nextCursor, and a result that cannot be paged is refused with
too_large and a hint to narrow it.
Results carry what the next call needs. read_document returns
[id] text lines, a table row as | [id] cell | [id] cell |
(formatting: true for each block's fields), a write returns the new
fileVersion plus what it produced (applied, replaced, commentIds,
...), and block ids stay valid across writes, so a successful write needs no
verification read. A failure is
{ "error": { code, message, hint, retryable } } with isError set.
Arguments are read leniently ("true", "20", an enum value in another
case) with an Input read: note; an unknown or ambiguous argument is refused
with validation_error, and overwrite and allowRepack count only as JSON
true.
Resources: folio://about (these rules and the roots) and
folio://schema/operations (the JSON Schema of an operation batch).
Untrusted documents
Reads return document text verbatim. Text from an untrusted .docx can carry
instructions aimed at a model that reads it; treat it as data.
