CLAUDE.md@src/cli · diff
git:20261009.668bf9e to git:20261009.2f18d0c
0 added, 5 removed. Audit A to A.
# src/cli — command surfaces
Each command is a kebab-case folder. `index.ts(x)` is the public surface: `export async function run(args: string[]): Promise<number>`. Sub-files for shared helpers; no cross-feature imports unless centrally exposed via `@core/*`.
## Adding a CLI command
Add a folder here, register it in the COMMANDS map in [index.ts](index.ts), add tests under `tests/cli/`, document in README and the root CLAUDE.md. If it emits OOXML, the file is `.tsx` and uses JSX components from `@core/jsx`.
## `raw` — the verbatim `--xml` channel
`docx raw insert/replace` takes `--xml` VERBATIM — never route it through
`decodeInlineEscapes`. XML is a machine format with its own escaping (` `
for a newline), and a literal `\n` inside an attribute value must survive.
This mirrors `--text-file` being the parser-free channel for prose;
`--xml-file PATH|-` is the bulk/stdin form. All gate AND splice logic lives in
[`@core/raw`](../core/raw/CLAUDE.md) (`prepareFragment`, `spliceBlocks`,
`proveAddressable`, the relationship trio); `cli/raw/commit.ts` is the
respond/save shell — RawError→fail translation, the baseline-diff schema gate
(validator dynamic-imported so non-validating paths never instantiate the
WASM), save, mint — so NOTHING is written when a gate fails. Routing:
a fragment whose roots are `<Relationship>` goes to the rels part
(`cli/raw/rels.ts`, no placement flags), an `--at rIdN`/`rels` locator
likewise; everything else is a body block. `docx validate` is a thin shell
over the same engine on a bare `Pkg` (no `Document` — it validates parts,
so building the body AST would be pure waste).
## `info` subcommands & the generated Agent Skill
`docx info` ([info/index.ts](info/index.ts)) dispatches reference emitters that take
no FILE: `schema`, `locators`, `skill`. They share one rule — **the binary is the
source of truth**, so the emitter is what a doc is rendered FROM, never the reverse
(`locators.ts` renders from `@core`'s `LOCATOR_FORMS`; `skill.ts` holds the canonical
`SKILL.md` text). The published [`skills/docx-cli/SKILL.md`](../../skills/docx-cli/SKILL.md)
is generated by `docx info skill`; a drift test in [tests/cli/info.test.ts](../../tests/cli/info.test.ts)
fails if the committed copy diverges, so regenerate (`docx info skill > skills/docx-cli/SKILL.md`)
after any change to `skill.ts`. Keep the skill thin and deferring to `<command> --help`
at runtime — like the other `info` verbs it needs only a unit test, no fixture/scenario.
## `upgrade` — the installer, embedded
[upgrade/index.ts](upgrade/index.ts) does NOT reimplement installation. It imports
`skills/docx-cli/scripts/install.sh` with `{ type: "text" }`, so Bun inlines the script
into `dist/index.js` AND into the `bun build --compile` executable; `upgrade` pipes that
embedded copy to `sh -s` (no temp file in a PATH directory, and install.sh's own
PREFIX-writability error survives instead of an EACCES from staging) with `PREFIX`,
`VERSION`, and `REQUIRE_CHECKSUM=1`. That keeps ONE implementation of the platform table,
download, and checksum logic (see the installer invariant in the root CLAUDE.md), and
means no network code lives in TypeScript. **Fetching a script and executing it is the
forbidden pattern; executing one compiled into the artifact is not** — never "improve"
this by downloading `install.sh` at runtime. `tests/cli/upgrade.test.ts` asserts the
embedded text equals the file on disk.
Install-method detection is `import.meta.path.startsWith("/$bunfs/")` (Bun's virtual FS
for a compiled single-file executable; `~BUN` on Windows). Standalone → self-replace via
install.sh's rename, which works over a running binary on POSIX. Package-manager install
→ print `bun add -g bun-docx` and change nothing. Windows → print the release URL, since
a running `.exe` cannot be replaced. A standalone binary the user RENAMED is refused
too: install.sh owns the platform→name rule and always writes `${PREFIX}/docx`, so
"upgrading" a `mydocx` would drop a SECOND file beside it (clobbering any unrelated
`docx` there) and leave the command the user actually runs stale — while we reported
success. The suite always runs from source, so only the package-manager branch is
reachable in-process; the standalone paths are covered by the compiled-binary test.
`upgrade` takes NO positionals. `docx upgrade v0.23.0` is the shape an agent reaches
for, and parseArgs would file it as a positional and silently upgrade to `latest` at
exit 0 — so a stray positional is a USAGE error pointing at `--to`.
`--to` is validated against a release-tag pattern before it reaches the installer: it is
interpolated into the release URL and curl NORMALIZES `..`, so an unconstrained value
would redirect both the binary and the `SHA256SUMS` it is checked against to another
repo — the checksum gate would then bless the attacker's own manifest. A bare `0.23.0`
(what `docx --version` prints) is normalized to `v0.23.0` rather than 404ing.
[install-script.d.ts](upgrade/install-script.d.ts) declares the `*.sh` module shape for
tsc (Bun itself needs nothing), colocated with its only consumer the way
`core/render/assets.d.ts` is.
## read-markdown structural annotations
`read` surfaces structural facts the GFM body can't show as `<!-- docx:TYPE … -->`
HTML comments, built via `formatNote` / `htmlAttr` in [read/annotations.ts](read/annotations.ts).
These are read-time VISIBILITY hints — the importer DROPS them all (the structure
survives edits in place, `read --ast` is lossless, and the authoring verbs manage
it), emitted **deviation-only** (only what differs from the document default).
Today: `docx:section` (rendered at the section's START, with an
`applies-to="pX..pY (below)"` scope on deviating sections), `docx:page`,
`docx:table` (`widths`/`borders` plus the formatting `tables format` authors —
`align`/`style`, and the row-level `repeat-header`/`row-heights` that have no GFM
row slot so they ride the table note keyed by `rR`), per-cell `docx:cell` hints
(`gridSpan`/`vMerge`/`shading`/`vAlign`/`borders`, plus a `halign` derived from a
uniform non-default cell-paragraph alignment — cell text alignment is otherwise
invisible in a GFM cell). A PLAIN EMPTY cell emits exactly one bare `<!-- tN:rRcC -->`
handle so `edit --at CELL` / `insert --at CELL` can fill it; an empty cell WITH
metadata emits its `docx:cell` note alone, never a duplicate bare handle.
**The bare handle is a PROMISE that a bare `--at` works, so it is emitted only for
a cell `resolveCellReference` would accept** (`isBareCellTarget` mirrors that gate:
no vMerge, no gridSpan, no spanning sibling anywhere in the row — in a merged row
physical cells stop lining up with logical columns — and the row must span the
table's full logical width, which is how a `<w:trPr><w:gridBefore/>`/`<w:gridAfter/>`
row is detected; those reject there too but the AST carries no field for them, so a
SHORT row is the signal they leave behind). An empty cell it refuses still gets an
address, or it would be unreachable from the read and the rejection hint would name
a locator nothing printed: it falls back to the explicit `<!-- tN:rRcC:pK -->`
paragraph locator, which `edit --at`/`insert --at` accept for ANY cell — including
a NOTED empty cell, where the paragraph locator rides in FRONT of the `docx:cell`
note (whose own leading token is the bare address `--at` just refused). So the
SHAPE of the printed locator tells the agent which form to use. A cell holding a
block-level `<w:sdt>` content control is no longer a hole here: the reader
descends into it, so the cell has real blocks and never reaches the bare-handle
branch (a cell holding `<w:customXml>`/`<w:altChunk>` still would — those stay
unmodeled). A block inside a content control also carries a
`content-control="<alias>"` pair on its `docx:p`/`docx:table` note, so an agent
editing there knows the change lands inside a control Word may have LOCKED. The
remaining annotations include a head `docx:track-changes on|off` that ALWAYS emits (unlike every other note it states
its default — a weak agent reads "no hint" as "unknown," not "off," and a wrong
tracking guess is high-cost, so the state is stated outright),
`docx:header`/`docx:footer` notes (led by the marginal's `hdrN`/`ftrN` id — the
handle `raw get --at` and `headers`/`footers set/clear --at` accept, the same id
`headers`/`footers list` reports; a per-section marginal also carries its `sN` as a
second bare token, a uniform one rides the head with the id alone. Content in a
`text` attr — so the importer's `docx:` drop can't re-inject it into the body —
with fields as `{page}`/`{date}`/… tokens; `type` attr only for `first`/`even`),
`docx:textbox tbxN anchor="pM" [wrap=… align=…]` … `docx:textbox-end tbxN`
bracketing a text box's story (rendered by `renderTextBoxes` right AFTER the
block that anchors it — a paragraph, or the whole table when the anchor sits in a
cell, since a GFM cell can't hold a nested block; the story's paragraphs carry
their real `tbxN:pK` locators, so an agent edits them like any paragraph; the
`anchor` attr explains a `pN` that prints nothing because it holds only the box;
a box nested in a box renders inside its parent's story), and `docx:list` on a numbered
list's FIRST run item (the numbering `docx lists set` authors): `start` when ≠ 1
and `format` when the glyph isn't `decimal` (`upper-roman`/`lower-alpha`/… — GFM
can't render a non-decimal ordered list, so the body stays `5.` and the hint
carries the real glyph) PLUS a `renders="i, ii, iii, …"` glyph preview (the
first three rendered glyphs from the start value) so a weak agent SEES how the
list renders and doesn't distrust the decimal body — verified in the
weak-agent harness: control agents twice re-tried or logged uncertainty about
whether `--format` took; the preview removed that friction. Or a bare `continues`
token when the run shares an earlier list's numId (a `--continue` link, lost on a
`read → create` rebuild). A
default decimal-from-1 list emits nothing; `--start` round-trips through the
markdown ordinal itself (`orderedOrdinal` seeds from `list.start`), so the note is
for what the ordinal can't show. Like `docx:p`, the note carries `pN` as its
leading token and replaces that item's bare locator. Section annotations render at the
section's START (where its content begins), not the sectPr's physical end, so each
reads right before the content it governs (`computeSectionStarts`/`renderSectionStart`
in `read/markdown.ts`). **`docx:section` is the one annotation that's deviation-only
in its ATTRIBUTES but not its presence: EVERY section emits a `<!-- docx:section sN -->`
marker — a contentless default-single-column section and the trailing mandatory
sectPr included — so the read consistently flags where each section begins (and the
`sN` an agent addresses); `cols`/`type`/`applies-to` appear only when they deviate.** A UNIFORM marginal (same across every section) rides the head
block instead; a marginal whose text DIFFERS by section renders at that section's
start. And
`docx:layout` on a tab-aligned paragraph that wraps — either inside a multi-column
section (tab stops wrap mid-line there) or a line whose trailing content sits on a
right-edge LEFT tab so a long value overflows the margin (the résumé
`San`/`Francisco` split). The latter warn names its cure: `edit --at pN --tabs
right` swaps the LEFT tab for a RIGHT tab flush at the margin (in `cli/edit/tabs.ts`,
emitted via `ParagraphOptions.tabs`), so the content right-aligns instead of
wrapping. Both are render-only breaks Markdown can't show. **`docx sections`
page-setup now CURES the right-edge-tab case for you**: changing margins/size
(doc-wide, or on a single-section doc) auto-applies that same `--tabs right` cure
to every fragile right-edge tab — calibrated to the OLD margins, it would wrap at
the new width — so the agent doesn't have to act on the hint (weak agents dismiss
it as "informational"). The hint still fires for cases page-setup can't reach (a
hand-set tab, a multi-section per-section edit).
The per-paragraph `docx:p` note carries `style`/`align` plus direct paragraph
spacing/indent (`space-before`/`space-after`/`line-spacing`/`indent-left`/
`indent-right`/`first-line`/`hanging`, in points/inches/multiples so an agent can
read a value and re-apply it through the matching `edit`/`insert` flag). Like
`docx:cell`, the note carries the locator as its leading token, so a paragraph
WITH a `docx:p` note does NOT also get the bare `<!-- pN -->` locator — that would
duplicate `pN`. Plain paragraphs (no note) keep the bare locator. An EMPTY paragraph prints NOTHING — with one exception: a paragraph holding only a manual `<w:br w:type="page"/>` prints its `docx:p` note with a bare `page-break` token, so a stray break (one stacked on a next-page section break shipped a blank page nobody could see) is visible and `delete --at pN` removes it. Printing every spacer's locator was tried and reverted: agents wrote the résumé's name into the first addressable line, a 1pt spacer, and deleted the real name paragraph. Every paragraph
shows its addressable `pN` exactly once, either bare or as the note's leading token.
Full contract in [src/core/markdown/CLAUDE.md](../core/markdown/CLAUDE.md). New
structural annotation? Use `formatNote`, keep it deviation-only, and remember the
importer won't reconstruct it — NO comment is parse-back, not even `docx:base`.
- ## Help leads with markdown; `--text` is the literal escape hatch
-
- The read view is Markdown, so the natural write-back is markdown — and it's the DEFAULT: `edit`/`insert` take the content as a POSITIONAL argument after the flags (`docx edit FILE --at p3 "Net **30** days"`), which `adoptPositionalMarkdown` (`cli/parse-helpers.ts`) folds into `values.markdown` before validation so every downstream check sees one path; `--markdown` stays as an alias. A second content positional is REFUSED (an unquoted phrase would otherwise silently truncate to its first word), as is positional + `--markdown` or positional + `--batch`. The `edit`/`insert` help (and the top-level one-liners) show the positional form FIRST, in the usage line and the examples, and describe `--text` as the LITERAL channel (`--text "**bold**"` writes the asterisks) for values Markdown would mangle or for a character span. Weak agents copy `**…**` from the read view straight into `--text`/`replace` and ship literal asterisks (the résumé b2-r3 run); three of five agents also read `edit --help` through `head -50`/`head -80`, so the contract sits in the first ten lines. Alignment/spacing/indent/tabs ride along with `--markdown` on both verbs (applied to every paragraph the source produces); only `--style` (and insert's `--list`) is refused next to it, because a `# heading` or list item owns those. `insert` also blends a new paragraph into its neighbor like Word's Enter key — font/size plus tabs, indent, alignment and spacing (`inheritParagraphLayout` in `core/insert/inherit.ts`) — unless the caller sets them; a heading anchor lends nothing, a list anchor lends no layout.
-
-
## Hyperlink/image edits emit audit comments under track-changes
OOXML has no `w:hyperlinkChange` / `w:drawingChange` element — Word silently bypasses tracking for hyperlink edits and image swaps. We compromise: when `<w:trackRevisions/>` is on, `hyperlinks add/replace/delete` and `images replace` each auto-emit a `[docx-cli] …` comment anchored to the affected span/run, attributed via the `--author` chain. The mutation itself stays silent (no fake `<w:ins>`/`<w:del>` — OOXML has no honest construct for it). The entry point is `new Comments(document).addAudit(anchor, { body, author, date })` (in [@core/comments](../core/comments/index.tsx)); the lower-level marker helpers `findContainingParagraph`, `findElementOffsetsInParagraph`, `addCommentMarkersAroundRun` live in [@core/comments/markers](../core/comments/markers.tsx). When track-changes is off, no comment is emitted.
`images delete` is the exception — deleting an image is honest content removal, so under tracking it wraps the drawing's run in a real `<w:del>` (accept removes it, reject restores it; the media part is kept until accept). It does **not** emit an audit comment. Replace stays audit-comment because swapping bytes has no tracked-change construct, but removal does.
## Script-specific font overrides
`--font-east-asia` and `--font-complex-script` are shared run-format flags for
edit/replace (including batch) and styles set/create. The latter overrides
`--font` for `w:cs` regardless of argv order. Style readback and AST expose
`fontEastAsia` / `fontComplexScript`; Markdown carries their explicit values
in `data-font-east-asia` / `data-font-complex-script` span attributes.