sdoc-editor-cli 0.9.3 → 0.10.2

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.
package/README.md CHANGED
@@ -1,625 +1,625 @@
1
- # sdoc-editor-cli
2
-
3
- `sdoc-editor-cli` is the preview-first command-line interface for inspecting,
4
- validating, creating, and safely changing Structured Doc Editor `.sdoc` and
5
- legacy `.tiptap.json` documents. It requires Node.js 22.22.2 or newer.
6
-
7
- The official package is published to the public npm registry as
8
- [`sdoc-editor-cli`](https://www.npmjs.com/package/sdoc-editor-cli). Its installed
9
- command is `sdoc`. The separate registry package named `sdoc` is unrelated.
10
-
11
- ## Install and run
12
-
13
- Try the current stable release without adding a project dependency:
14
-
15
- ```powershell
16
- npx sdoc-editor-cli@latest --help
17
- ```
18
-
19
- For a project-pinned version, install the official package as a development
20
- dependency and invoke it by its exact package name:
21
-
22
- ```powershell
23
- npm install --save-dev sdoc-editor-cli
24
- npx sdoc-editor-cli --version
25
- npx sdoc-editor-cli capabilities --json
26
- ```
27
-
28
- Update that dependency explicitly when the project is ready to adopt the
29
- current stable release:
30
-
31
- ```powershell
32
- npm install --save-dev sdoc-editor-cli@latest
33
- ```
34
-
35
- For repeatable CI or Agent automation, commit `package.json` and the lockfile,
36
- run `npm ci`, and add a project script:
37
-
38
- ```json
39
- {
40
- "scripts": {
41
- "sdoc": "sdoc"
42
- }
43
- }
44
- ```
45
-
46
- Pass CLI arguments after npm's `--` separator:
47
-
48
- ```powershell
49
- npm run sdoc -- capabilities --json
50
- ```
51
-
52
- `npm run` uses the project-local binary and fails when the dependency is
53
- missing; it does not fetch a command from the registry.
54
-
55
- > **Package name collision warning:** Use `sdoc-editor-cli` with `npx`. Do not
56
- > run `npx sdoc` or install the public package named `sdoc`; it is unrelated to
57
- > Structured Doc Editor.
58
-
59
- The CLI does not replace itself automatically. Use the explicit npm update
60
- command above, or let the owning project's dependency automation update its
61
- manifest and lockfile.
62
-
63
- ### Version-pinned GitHub Release fallback
64
-
65
- Every tagged release also attaches a versioned `sdoc-editor-cli-*.tgz` to
66
- [GitHub Releases](https://github.com/SWBaek/sdoc-editor/releases/latest). Use
67
- this path when an installation must be pinned to a downloaded release asset.
68
- After downloading the selected tarball, install it from the project that owns
69
- the dependency:
70
-
71
- ```powershell
72
- npm install --save-dev .\sdoc-editor-cli-X.Y.Z.tgz
73
- npx sdoc-editor-cli --version
74
- ```
75
-
76
- For frequent interactive use across projects, a global install provides the
77
- short `sdoc` command:
78
-
79
- ```powershell
80
- npm install --global sdoc-editor-cli
81
- sdoc --version
82
- ```
83
-
84
- The remaining examples use `sdoc` for readability. After a project-local
85
- install, replace that prefix with `npx sdoc-editor-cli`, or use the `npm run
86
- sdoc --` script above. A global installation can run `sdoc` directly.
87
-
88
- ## Help and output
89
-
90
- ```powershell
91
- sdoc --help
92
- sdoc help apply
93
- sdoc inspect --help
94
- ```
95
-
96
- JSON is the default and stable machine-readable output. `--json` states that
97
- choice explicitly. `--human` provides concise interactive output and is not a
98
- stable machine API; its wording and layout may change between releases. Never
99
- parse human output in an Agent or script. Success is written to stdout;
100
- structured errors are written to stderr.
101
-
102
- Every JSON success and failure is one line with the top-level contract
103
- `sdoc.cli.response/1`. The packaged schema is
104
- `dist/schemas/sdoc.cli.response.schema.json`. Consumers must ignore unknown
105
- fields: compatible updates may add fields, commands, operations, diagnostics,
106
- and capability values, while existing fields retain their documented meaning.
107
-
108
- ```json
109
- {"contract":"sdoc.cli.response/1","ok":true,"command":"validate","revision":"sha256:..."}
110
- ```
111
-
112
- Failures always include `category` and a non-empty `diagnostics` array:
113
-
114
- ```json
115
- {"contract":"sdoc.cli.response/1","ok":false,"category":"argument","diagnostics":[{"code":"CLI_MISSING_DOCUMENT","message":"..."}]}
116
- ```
117
-
118
- ## Commands
119
-
120
- ### `capabilities`
121
-
122
- Reports the installed CLI version, supported contracts and commands, semantic
123
- operation names, safety limits, read projections/catalog kinds, and built-in
124
- template IDs. It does not accept a document path. JSON is the default:
125
-
126
- ```powershell
127
- sdoc capabilities
128
- sdoc capabilities --json
129
- sdoc capabilities --human
130
- ```
131
-
132
- ```json
133
- {
134
- "contract": "sdoc.cli.response/1",
135
- "ok": true,
136
- "command": "capabilities",
137
- "cliVersion": "0.9.3",
138
- "contracts": {
139
- "document": "sdoc/1.0",
140
- "operations": "sdoc.operations/1",
141
- "read": "sdoc.read/1",
142
- "response": "sdoc.cli.response/1"
143
- },
144
- "commands": ["capabilities", "inspect", "validate", "apply", "rename-heading", "set-document-title", "create"],
145
- "projections": ["catalog", "target", "section", "document"],
146
- "catalogKinds": ["blocks", "outline", "references", "referenceables"],
147
- "builtInTemplateIds": ["builtin:blank", "builtin:technical-report", "builtin:design-specification", "builtin:verification-report"]
148
- }
149
- ```
150
-
151
- The actual result also includes `semanticOperations` and numeric `limits`;
152
- query it instead of hard-coding the installed package's capabilities.
153
-
154
- ### `inspect`
155
-
156
- Without `--projection`, returns the existing inspector shape: the SHA-256
157
- revision of the exact source bytes, metadata, outline, references,
158
- referenceable nodes, targetable blocks, and an optional selected target. The
159
- revision includes a UTF-8 BOM when present and changes after
160
- representation-only edits. Existing no-projection calls retain this behavior.
161
-
162
- ```powershell
163
- sdoc inspect document.sdoc --json
164
- sdoc inspect document.sdoc --target-id intro --json
165
- sdoc inspect document.sdoc --target-path /1/0 --json
166
- ```
167
-
168
- Use the returned `revision`, IDs, paths, node types, and digests to construct an
169
- operation request. Top-level `metadata` reports the current title, author,
170
- version, timestamps, and document setting overrides. `--target-path` uses a
171
- slash-delimited content path. For example, `/1/0` selects
172
- `doc.content[1].content[0]`. A selected result has this shape:
173
-
174
- ```json
175
- {
176
- "target": {
177
- "path": [1, 0],
178
- "node": { "type": "paragraph" },
179
- "digest": "sha256:...",
180
- "operationTarget": {
181
- "kind": "snapshot",
182
- "path": [1, 0],
183
- "nodeType": "paragraph",
184
- "digest": "sha256:..."
185
- }
186
- }
187
- }
188
- ```
189
-
190
- Copy `target.operationTarget` directly instead of assembling a snapshot
191
- target. Every `blocks[]` entry also includes its canonical `operationTarget`.
192
- Referenceable nodes receive an ID target; other blocks receive a snapshot
193
- target. A provisional ID is valid only for that inspected revision; applying
194
- it persists the ID.
195
-
196
- An explicit `--projection` selects the additive bounded `sdoc.read/1`
197
- contract. Projected JSON still uses `contract: "sdoc.cli.response/1"` as its
198
- top-level response discriminator and exposes the core discriminator separately
199
- as `readContract: "sdoc.read/1"`. The core `projection`, `revision`, `data`,
200
- `page`, and `budget` fields remain top-level. Validate request objects against
201
- `dist/schemas/sdoc.read.schema.json` when another host calls the shared core
202
- directly.
203
-
204
- ```powershell
205
- sdoc inspect document.sdoc --projection catalog --json
206
- sdoc inspect document.sdoc --projection catalog --catalog outline --limit 100 --json
207
- sdoc inspect document.sdoc --projection target --target-id intro --max-bytes 262144 --max-nodes 1000 --json
208
- sdoc inspect document.sdoc --projection section --target-path /0 --max-nodes 500 --human
209
- sdoc inspect document.sdoc --projection document --max-bytes 262144 --max-nodes 1000 --json
210
- sdoc inspect document.sdoc --projection document --cursor $nextCursor --json
211
- ```
212
-
213
- Projection option rules are strict and are checked before the document is
214
- read:
215
-
216
- | Projection | Target | Allowed read options |
217
- |---|---|---|
218
- | `catalog` | none | `--catalog`, `--limit`, `--cursor`, `--max-bytes`, `--max-summary-length`, `--expected-revision` |
219
- | `target` | exactly one of ID/path | `--max-bytes`, `--max-nodes`, `--expected-revision` |
220
- | `section` | exactly one of ID/path | `--cursor`, `--max-bytes`, `--max-nodes`, `--expected-revision` |
221
- | `document` | none | `--cursor`, `--max-bytes`, `--max-nodes`, `--expected-revision` |
222
-
223
- The catalog defaults to `blocks`. `--limit`, `--max-bytes`, `--max-nodes`,
224
- and `--max-summary-length` accept canonical positive base-10 integers. Read
225
- options other than legacy `--target-id` and `--target-path` require an explicit
226
- projection. Catalog, section, and document results can return
227
- `page.nextCursor`; pass it back with the same projection/query until
228
- `page.complete` is true. Cursors bind the exact source bytes and query scope.
229
- They are opaque integrity tokens, not authentication credentials.
230
-
231
- ### `validate`
232
-
233
- Checks the persisted document contract and semantic invariants without writing:
234
-
235
- ```powershell
236
- sdoc validate document.sdoc --json
237
- sdoc validate legacy.tiptap.json --human
238
- ```
239
-
240
- ### `apply`
241
-
242
- Reads a complete `sdoc.operations/1` request from a UTF-8 JSON file or stdin.
243
- Malformed UTF-8 is rejected before JSON parsing, locking, or document writes.
244
- A UTF-8 BOM and non-ASCII JSON content are accepted. Preview is the default;
245
- only `--write` can modify the named document.
246
-
247
- ```powershell
248
- sdoc apply document.sdoc --operations operations.json --json
249
- sdoc apply document.sdoc --operations operations.json --dry-run --json
250
- sdoc apply document.sdoc --operations operations.json --write --json
251
- Get-Content -Raw -Encoding utf8 operations.json |
252
- sdoc apply document.sdoc --operations - --write --json
253
- ```
254
-
255
- `--write` takes a sibling lock, re-reads the file, verifies its revision, and
256
- atomically replaces it. A no-op is not written. Do not combine `--write` and
257
- `--dry-run`.
258
-
259
- The sibling `<document>.lock` records structured owner metadata with a format
260
- version, process ID, random ownership token, hostname, and creation time. If a
261
- write finds an existing lock, the CLI reclaims it automatically only when all
262
- of these conditions are true:
263
-
264
- ```json
265
- {"version":1,"pid":1234,"token":"0123456789abcdef0123456789abcdef","hostname":"workstation","createdAt":"2026-08-06T12:00:00.000Z"}
266
- ```
267
-
268
- - the metadata is recognized and belongs to the current host;
269
- - the owner process can be conclusively shown to have exited; and
270
- - the lock is at least 60 seconds old.
271
-
272
- Recovery atomically moves the stale lock aside, verifies that its owner did not
273
- change during the move, and then retries normal exclusive acquisition. The
274
- document is re-read and its revision is checked only after the new lock is
275
- owned, and the CLI re-checks its ownership token before atomic publication.
276
-
277
- Live owners, owners on another host, and legacy, malformed, or unsupported
278
- metadata are never removed automatically. Wait for a known writer to finish.
279
- For an abandoned lock that cannot be reclaimed automatically, remove the
280
- `.lock` file manually only after confirming no writer is active on any reported
281
- host, then re-inspect the document and rebuild the operation from its current
282
- revision before retrying `--write`.
283
-
284
- ### `rename-heading`
285
-
286
- Convenience command for a single `renameHeading` operation:
287
-
288
- ```powershell
289
- $inspection = sdoc inspect document.sdoc --json | ConvertFrom-Json
290
- sdoc rename-heading document.sdoc --id intro --title "Updated heading" `
291
- --expected-revision $inspection.revision --json
292
- sdoc rename-heading document.sdoc --id intro --title "Updated heading" `
293
- --expected-revision $inspection.revision --write --json
294
- ```
295
-
296
- The preview and a later independent write can have different
297
- `outputRevision` values because each semantic change supplies a new
298
- `meta.modified` time. Always treat the write result as authoritative.
299
-
300
- ### `set-document-title`
301
-
302
- Convenience command for one `setDocumentTitle` operation. `--title` and
303
- `--expected-revision` are required. Without `--id`, the command changes only
304
- `meta.title`. With the persistent or provisional ID of an H1, it changes
305
- `meta.title` and that explicit title heading atomically. The CLI never guesses
306
- a title heading and never renames the file.
307
-
308
- ```powershell
309
- $inspection = sdoc inspect document.sdoc --json | ConvertFrom-Json
310
- sdoc set-document-title document.sdoc --title "Metadata title" `
311
- --expected-revision $inspection.revision --write --json
312
- $inspection = sdoc inspect document.sdoc --json | ConvertFrom-Json
313
- sdoc set-document-title document.sdoc --title "Updated document" --id title-h1 `
314
- --expected-revision $inspection.revision --write --json
315
- ```
316
-
317
- Use `--discard-formatting` only when replacing marked or non-text content in
318
- the selected H1 is intentional. It requires `--id`; without an H1 target there
319
- is no formatting to discard.
320
-
321
- ### `create`
322
-
323
- Creates a schema-valid `.sdoc` without overwriting an existing path. The
324
- default template is `builtin:blank`; the default title is the output filename.
325
-
326
- ```powershell
327
- sdoc create report.sdoc --title "Quarterly Report" --json
328
- sdoc create report.sdoc --template builtin:technical-report --dry-run --json
329
- sdoc create design.sdoc --template builtin:design-specification --json
330
- sdoc create verification.sdoc --template builtin:verification-report --json
331
- sdoc create report.sdoc --template .\templates\company-report.sdoc --json
332
- ```
333
-
334
- An explicit file template must be a valid UTF-8 JSON `.sdoc`; malformed UTF-8
335
- is rejected before a destination is created. A UTF-8 BOM is accepted. Creation
336
- removes persisted document identity and template-only metadata while
337
- preserving supported settings, node IDs, and links.
338
-
339
- ## Public schemas and operation contract
340
-
341
- The package includes:
342
-
343
- - `dist/schemas/sdoc.operations.schema.json`: draft-07 request schema
344
- - `dist/schemas/sdoc.read.schema.json`: draft-07 `sdoc.read/1` request union
345
- - `dist/schemas/sdoc.cli.response.schema.json`: additive CLI JSON response schema
346
- - `dist/schemas/sdoc.schema.json`: persisted document schema and reusable
347
- `anyNode` fragment
348
- - `dist/examples/operations/*.json`: one request for each semantic operation
349
-
350
- Repository copies live at `sdoc.operations.schema.json`,
351
- `sdoc.read.schema.json`, `sdoc.schema.json`,
352
- `cli/schemas/sdoc.cli.response.schema.json`, and `examples/operations/`.
353
-
354
- Every request has this envelope:
355
-
356
- ```json
357
- {
358
- "contract": "sdoc.operations/1",
359
- "expected": {
360
- "revision": "sha256:0000000000000000000000000000000000000000000000000000000000000000"
361
- },
362
- "operations": []
363
- }
364
- ```
365
-
366
- In published examples, the all-zero revision means "replace with the exact
367
- `revision` returned by `inspect`." The all-one snapshot digest means "replace
368
- with that block's exact `digest` returned by the same inspection." IDs ending
369
- in `-from-inspect` are also placeholders. These values are syntactically valid
370
- so schema tools can validate every example, but they are not usable against a
371
- real document until replaced.
372
-
373
- Do not add `expected.documentId` in CLI requests. Although inspection may
374
- report a document ID, the file-only CLI cannot establish a trusted external
375
- document identity and rejects that precondition as unverifiable. Revision is
376
- the CLI concurrency contract.
377
-
378
- ### Targets
379
-
380
- Referenceable `heading`, `image`, `table`, and `mathBlock` nodes use persistent
381
- or revision-scoped provisional IDs:
382
-
383
- ```json
384
- { "kind": "id", "id": "intro", "expectedType": "heading" }
385
- ```
386
-
387
- Other mutable blocks, including `paragraph`, `codeBlock`, and `diagram`, use a
388
- protected snapshot locator:
389
-
390
- ```json
391
- {
392
- "kind": "snapshot",
393
- "path": [1],
394
- "nodeType": "paragraph",
395
- "digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111"
396
- }
397
- ```
398
-
399
- Prefer the ready-to-use `operationTarget` returned by `inspect` over copying
400
- these fields manually. A `diagram` is snapshot-targeted; it is not a
401
- persistent-ID node.
402
-
403
- Snapshot targets and provisional IDs are revision-scoped. Re-inspect after any
404
- source-byte change. A batch resolves all targets before applying its first
405
- operation, so earlier operations cannot redirect later targets.
406
-
407
- Destinations are `{ "position": "before"|"after", "target": ... }` or
408
- `{ "position": "section-end", "target": ... }`. `section-end` targets a
409
- heading and appends inside that section.
410
-
411
- ### The 14 operations
412
-
413
- Each operation below has a complete file in `dist/examples/operations/`:
414
-
415
- | Operation | Required fields | Purpose |
416
- |---|---|---|
417
- | `renameHeading` | `target`, `title` | Rename a heading; optional `discardFormatting` permits replacing rich heading content |
418
- | `insertBlock` | `destination`, `block` | Insert a non-heading Tiptap block |
419
- | `insertSection` | `target`, `title` | Insert a child section by default, or a same-level sibling with `position: "before"|"after"`; optional `id` and `blocks` |
420
- | `replaceBlock` | `target`, `block` | Replace a block with the same node type while preserving identity |
421
- | `updateBlockAttrs` | `target`, `attrs` | Merge block attributes |
422
- | `moveBlock` | `target`, `destination` | Move a non-heading block |
423
- | `deleteBlock` | `target` | Delete a non-heading block |
424
- | `moveSection` | `target`, `destination` | Move a heading and its complete descendant section |
425
- | `deleteSection` | `target` | Delete a heading and its complete descendant section |
426
- | `setHeadingLevel` | `target`, `level` | Set a heading level and shift descendant headings by the same delta while preserving IDs |
427
- | `renameBlockId` | `target`, `newId` | Rename a heading or table ID and rewrite matching internal links |
428
- | `setDocumentTitle` | `title` | Set `meta.title`; optional `headingTarget` atomically updates an explicit H1 |
429
- | `updateDocumentMetadata` | `patch` | Set or remove (`null`) the allowed `author` and `version` fields |
430
- | `updateDocumentSettings` | `patch` | Set or remove (`null`) portable document setting overrides |
431
-
432
- Operations are validated and applied atomically as one batch. Headings must be
433
- moved or deleted with section operations. `created`, `modified`, document
434
- identity, template metadata, arbitrary metadata, and filenames cannot be
435
- changed by these operations. Portable settings are:
436
- `headingNumbering`, `headingDecoration`, `headingH1Color` through
437
- `headingH6Color`, `captionStyle`, `captionNumbering`, `equationNumbering`,
438
- `crossRefIncludeCaption`, `pdfScale`, `selfContained`, `slideBreakLevel`,
439
- `slideTransition`, and `showTitleSlide`. Local path settings
440
- `slideCssPath`, `htmlCssPath`, and `outputDir` are deliberately excluded.
441
-
442
- `insertSection` keeps its existing child behavior when `position` is omitted or
443
- set to `"child"`: the new heading is one level deeper and is appended at the
444
- target section boundary. Set `position` to `"before"` or `"after"` to insert a
445
- same-level sibling before the target heading or after its complete descendant
446
- section. This is the supported CLI route for building several peer H1 sections
447
- without editing raw JSON; the new heading receives the target heading's level
448
- and its requested persistent ID.
449
-
450
- `setHeadingLevel` changes an existing heading to level 1-6 without changing its
451
- persistent ID. Every descendant heading in that section moves by the same
452
- level delta, preserving the section's relative hierarchy; the operation is
453
- rejected with `INVALID_HEADING_LEVEL` or `SECTION_LEVEL_OUT_OF_RANGE` when the
454
- requested level or any resulting descendant level falls outside 1-6. Inspect
455
- again after the write to confirm the resulting outline paths and parentage.
456
-
457
- `renameBlockId` requires an existing ID target and a unique non-empty `newId`.
458
- It preserves the node and heading level, updates internal `#old-id` links in the
459
- same atomic batch, and rejects duplicate IDs. A newly assigned ID cannot be used
460
- as another target in that same request because all operation targets are
461
- resolved from the inspected input revision before mutation begins.
462
-
463
- ### One inspection, one atomic batch
464
-
465
- Do not inspect once per operation. One revision can guard a batch of up to 100
466
- operations. This example inspects once, prepares three document-level changes,
467
- previews them, and then writes the same batch:
468
-
469
- ```powershell
470
- $inspection = sdoc inspect document.sdoc --target-id title-h1 --json |
471
- ConvertFrom-Json
472
- $request = [ordered]@{
473
- contract = 'sdoc.operations/1'
474
- expected = @{ revision = $inspection.revision }
475
- operations = @(
476
- @{
477
- op = 'setDocumentTitle'
478
- title = 'Release Plan'
479
- headingTarget = $inspection.target.operationTarget
480
- }
481
- @{ op = 'updateDocumentMetadata'; patch = @{ author = 'Documentation Team'; version = '2.0' } }
482
- @{ op = 'updateDocumentSettings'; patch = @{ headingNumbering = $true; captionStyle = 'modern' } }
483
- )
484
- }
485
- $request | ConvertTo-Json -Depth 100 |
486
- Set-Content -Encoding utf8 operations.json
487
- sdoc apply document.sdoc --operations operations.json --json
488
- sdoc apply document.sdoc --operations operations.json --write --json
489
- ```
490
-
491
- All targets in a mixed content/metadata batch must come from that same
492
- inspection. Re-inspect after a successful write before preparing another
493
- batch; no re-inspection is needed between operations inside one batch.
494
-
495
- ### Supported node and target catalog
496
-
497
- The packaged `sdoc.schema.json` is authoritative. This concise catalog covers
498
- the operation-relevant node types and required attributes:
499
-
500
- | Nodes | Required attributes | Target kind and notes |
501
- |---|---|---|
502
- | `heading` | `attrs.level` (1-6) | ID target; rename with `renameHeading`, and move/delete as a complete section |
503
- | `paragraph`, `blockquote`, `bulletList`, `orderedList`, `taskList`, `taskItem` | None | Snapshot target |
504
- | `codeBlock` | None (`attrs.language` optional) | Snapshot target |
505
- | `table` | None | ID target |
506
- | `tableCell`, `tableHeader` | None | Snapshot target; span, width, and alignment attrs are optional |
507
- | `image` | None | ID target; a new `src`, when present, must be portable |
508
- | `mathBlock` | `attrs.latex` | ID target |
509
- | `diagram` | `attrs.language`, `attrs.code` | Snapshot target; stores source such as Mermaid, PlantUML, or D2 |
510
- | `horizontalRule`, `hardBreak`, `callout` | None | Snapshot target; callout `variant` is optional |
511
- | `listItem`, `tableRow` | None | Structural container, not an operation block target |
512
- | `text`, `mathInline` | `mathInline.attrs.latex` only | Inline content, not an operation target |
513
-
514
- `updateBlockAttrs` accepts only the attrs defined for that node type.
515
- `replaceBlock` must preserve the node type, while headings require the
516
- heading/section operations.
517
-
518
- Portable image assets use `./images/...`. Draw.io content is an `image` node
519
- whose `src` is under `./drawio/` and ends in `.drawio.svg`; it is not a
520
- `diagram` node. The CLI validates document structure and portable references
521
- but does not render diagrams, create or copy asset files, or fetch assets from
522
- the network.
523
-
524
- ### Diagram authoring and host rendering
525
-
526
- For CLI and AI-operator workflows, the source of truth is the `diagram` node:
527
- `diagram.attrs.language` plus `diagram.attrs.code`. A schema-valid node with
528
- those attributes is sufficient to author and preserve a diagram in `.sdoc`;
529
- the CLI owns structural validation and source preservation, not rendering.
530
-
531
- Rendering belongs to the Structured Doc Editor host/viewer:
532
-
533
- - Mermaid renders locally in the host.
534
- - PlantUML, D2, and Graphviz use the host's online preview path only after the
535
- user grants the required first-use consent.
536
- - If consent is declined or rendering is unavailable, the diagram source
537
- remains valid and preserved in the document.
538
-
539
- Do not install local D2, Graphviz, PlantUML, or other renderers merely because
540
- the CLI does not render a diagram. Local renderers are optional tools outside
541
- the CLI authoring contract; AI operators should create or update the diagram
542
- source node and leave rendering to the host unless the user explicitly asks
543
- for a separate local-renderer workflow.
544
-
545
- ## Legacy documents
546
-
547
- Legacy raw Tiptap JSON can be inspected and validated without an upgrade flag.
548
- Every mutation, including preview, requires `--upgrade-legacy`. Persisting the
549
- in-place envelope upgrade additionally requires `--write`:
550
-
551
- ```powershell
552
- sdoc apply legacy.tiptap.json --operations operations.json --upgrade-legacy --json
553
- sdoc apply legacy.tiptap.json --operations operations.json --upgrade-legacy --write --json
554
- ```
555
-
556
- This changes the named file in place to an SDOC envelope but does not rename
557
- its `.tiptap.json` extension. Back up or copy the file to a `.sdoc` path first
558
- when preserving the legacy filename matters.
559
-
560
- ## PowerShell automation
561
-
562
- Write non-ASCII JSON explicitly as UTF-8 and keep stdout separate from stderr:
563
-
564
- ```powershell
565
- $request | ConvertTo-Json -Depth 100 |
566
- Set-Content -Encoding utf8 operations.json
567
-
568
- $resultJson = sdoc apply document.sdoc --operations operations.json --json 2>error.json
569
- if ($LASTEXITCODE -ne 0) {
570
- $errorResult = Get-Content -Raw -Encoding utf8 error.json | ConvertFrom-Json
571
- throw "$($errorResult.diagnostics[0].code): $($errorResult.diagnostics[0].message)"
572
- }
573
- $result = $resultJson | ConvertFrom-Json
574
- ```
575
-
576
- ## Exit codes
577
-
578
- | Code | Meaning | Representative diagnostic |
579
- |---:|---|---|
580
- | 0 | Success | No diagnostic |
581
- | 2 | CLI argument or operation request error | `CLI_CONFLICTING_OPTIONS`, `CLI_MISSING_OPERATIONS` |
582
- | 3 | Document, template, invariant, or legacy-upgrade error | `LEGACY_UPGRADE_REQUIRED` |
583
- | 4 | Stale revision or precondition conflict | `STALE_REVISION` |
584
- | 5 | File I/O error | `CLI_READ_FAILED` |
585
-
586
- On failure, inspect `diagnostics[].code` rather than matching human-readable
587
- messages. The machine-readable `category` is one of `argument`, `document`,
588
- `conflict`, `io`, or `internal`. Categories describe the failure source and do
589
- not replace exit codes; in particular, an `IoError` is categorized as `io`
590
- while retaining its existing exit code.
591
-
592
- ## Diagnostic recovery
593
-
594
- Diagnostic messages are explanatory text, not a parsing contract. Automations
595
- must branch on `diagnostics[].code` from explicit `--json` output.
596
-
597
- | Diagnostic code(s) | Likely cause | Recovery |
598
- |---|---|---|
599
- | `CLI_UNKNOWN_*`, `CLI_MISSING_*`, `CLI_CONFLICTING_OPTIONS`, `CLI_OPTION_REQUIRES_ID` | Misspelled command/flag, omitted value, or incompatible/dependent flags | Run the command-specific `--help`, correct the invocation, and retry |
600
- | `CLI_INVALID_TARGET_PATH` | `--target-path` is not a slash-delimited non-negative integer path | Copy the path from `inspect.blocks[].path` and format it like `/1/0` |
601
- | `CLI_INVALID_POSITIVE_INTEGER`, `CLI_INVALID_PROJECTION`, `CLI_INVALID_CATALOG` | A projected read option has a malformed number or unsupported selector | Use `sdoc inspect --help` and pass a documented projection/catalog and canonical positive integer |
602
- | `CLI_PROJECTION_REQUIRED`, `CLI_PROJECTION_REQUIRES_TARGET`, `CLI_PROJECTION_FORBIDS_TARGET`, `CLI_PROJECTION_OPTION_NOT_SUPPORTED` | Projected read flags are missing their projection, target, or valid projection-specific combination | Follow the projection option matrix above; legacy targets remain valid only when no read-only flags are supplied |
603
- | `CLI_INVALID_UTF8` | Operation file or stdin bytes are not valid UTF-8 | Re-encode the complete JSON request as UTF-8 and retry; the document and sibling lock are untouched |
604
- | `CLI_INVALID_JSON`, `INVALID_OPERATION_REQUEST`, `INVALID_OPERATION` | Malformed operations JSON or a request that does not match `sdoc.operations/1` | Validate against the packaged operation schema; inspect `operationIndex` when present |
605
- | `MALFORMED_JSON`, `DOCUMENT_SCHEMA_INVALID`, `UNSUPPORTED_VERSION` | The input is not valid UTF-8 JSON, violates the document schema, or uses an unsupported SDOC version | Repair or migrate the source; do not force a write |
606
- | `LEGACY_UPGRADE_REQUIRED` | A legacy `.tiptap.json` mutation omitted the explicit upgrade flag | Re-run with `--upgrade-legacy`, preview first, then add `--write` if intended |
607
- | `STALE_REVISION` | The document bytes changed after inspection | Re-inspect the current file and rebuild the whole request from that revision |
608
- | `INVALID_READ_CURSOR`, `STALE_READ_CURSOR`, `READ_CURSOR_SCOPE_MISMATCH` | A cursor is malformed, the exact source bytes changed, or it belongs to another projection/query | Restart the projection from its first page using the current document; never edit or reuse a cursor across queries |
609
- | `PROJECTION_ITEM_TOO_LARGE` | The next complete catalog entry or subtree cannot fit the requested byte/node budget | Increase the reported limiting budget; projection pages never split a complete item |
610
- | `TARGET_NOT_FOUND`, `TARGET_NOT_BLOCK`, `TARGET_TYPE_MISMATCH`, `TARGET_DIGEST_MISMATCH` | A selected path/ID is absent, is not a block, changed type, or no longer matches its snapshot | Re-inspect and use the returned `operationTarget`; do not weaken the precondition |
611
- | `SECTION_OPERATION_REQUIRED`, `HEADING_TARGET_REQUIRED`, `SECTION_TARGET_REQUIRED`, `TITLE_H1_TARGET_REQUIRED` | A block operation was used for a heading, or a title target was not H1 | Use the matching heading/section operation and an inspected heading target |
612
- | `INVALID_HEADING_LEVEL`, `SECTION_LEVEL_OUT_OF_RANGE` | A requested heading level is outside 1-6, or shifting the section would push a descendant outside that range | Choose a valid target level that keeps every descendant heading within 1-6 |
613
- | `FORMATTED_HEADING` | Replacing a rich heading would discard marks or inline nodes | Preserve it, or explicitly use `discardFormatting` / `--discard-formatting` |
614
- | `ATTRIBUTE_NOT_ALLOWED`, `NODE_TYPE_CHANGE` | An attr is not allowed for the node, or replacement changes its type | Consult the node catalog/schema and keep replacements type-compatible |
615
- | `ID_RENAME_NOT_SUPPORTED`, `ID_RENAME_REQUIRES_EXISTING_ID`, `INVALID_NEW_ID` | An ID rename targeted an unsupported or provisional node, or supplied an invalid new ID | Target an inspected heading/table with an existing persistent ID and choose a unique non-reserved ID |
616
- | `NEW_NONPORTABLE_ASSET`, `NEW_DANGLING_REFERENCE`, `NEW_UNSAFE_LINK` | The batch introduces an invalid asset path, missing internal target, or unsafe link | Use `./images/...` or `./drawio/*.drawio.svg`, create referenced IDs, and use a safe URL |
617
- | `DUPLICATE_ID` | The document contains conflicting persistent IDs | Assign unique IDs before retrying |
618
- | `CLI_TEMPLATE_INVALID` | An explicit template has malformed UTF-8, invalid JSON, or violates the template contract | Repair or re-encode the template as UTF-8; creation leaves the destination absent |
619
- | `CLI_TARGET_EXISTS` | `create` would overwrite an existing file | Choose a new path; the CLI never overwrites during creation |
620
- | `CLI_LOCK_UNAVAILABLE` | Another writer holds the sibling lock, or its owner cannot be reclaimed safely | Wait for a known writer to finish. Remove an abandoned lock manually only after confirming no writer is active, then re-inspect before retrying |
621
- | `CLI_READ_FAILED`, `CLI_ATOMIC_WRITE_FAILED` | Filesystem access or atomic replacement failed | Check path, permissions, free space, and filesystem support; verify the file before retrying |
622
-
623
- Warnings such as `NONPORTABLE_ASSET`, `DANGLING_REFERENCE`, `UNSAFE_LINK`, and
624
- `LEGACY_FILE_EXTENSION_RETAINED` describe pre-existing or retained conditions.
625
- They do not authorize a later mutation to add or increase the same violation.
1
+ # sdoc-editor-cli
2
+
3
+ `sdoc-editor-cli` is the preview-first command-line interface for inspecting,
4
+ validating, creating, and safely changing Structured Doc Editor `.sdoc` and
5
+ legacy `.tiptap.json` documents. It requires Node.js 22.22.2 or newer.
6
+
7
+ The official package is published to the public npm registry as
8
+ [`sdoc-editor-cli`](https://www.npmjs.com/package/sdoc-editor-cli). Its installed
9
+ command is `sdoc`. The separate registry package named `sdoc` is unrelated.
10
+
11
+ ## Install and run
12
+
13
+ Try the current stable release without adding a project dependency:
14
+
15
+ ```powershell
16
+ npx sdoc-editor-cli@latest --help
17
+ ```
18
+
19
+ For a project-pinned version, install the official package as a development
20
+ dependency and invoke it by its exact package name:
21
+
22
+ ```powershell
23
+ npm install --save-dev sdoc-editor-cli
24
+ npx sdoc-editor-cli --version
25
+ npx sdoc-editor-cli capabilities --json
26
+ ```
27
+
28
+ Update that dependency explicitly when the project is ready to adopt the
29
+ current stable release:
30
+
31
+ ```powershell
32
+ npm install --save-dev sdoc-editor-cli@latest
33
+ ```
34
+
35
+ For repeatable CI or Agent automation, commit `package.json` and the lockfile,
36
+ run `npm ci`, and add a project script:
37
+
38
+ ```json
39
+ {
40
+ "scripts": {
41
+ "sdoc": "sdoc"
42
+ }
43
+ }
44
+ ```
45
+
46
+ Pass CLI arguments after npm's `--` separator:
47
+
48
+ ```powershell
49
+ npm run sdoc -- capabilities --json
50
+ ```
51
+
52
+ `npm run` uses the project-local binary and fails when the dependency is
53
+ missing; it does not fetch a command from the registry.
54
+
55
+ > **Package name collision warning:** Use `sdoc-editor-cli` with `npx`. Do not
56
+ > run `npx sdoc` or install the public package named `sdoc`; it is unrelated to
57
+ > Structured Doc Editor.
58
+
59
+ The CLI does not replace itself automatically. Use the explicit npm update
60
+ command above, or let the owning project's dependency automation update its
61
+ manifest and lockfile.
62
+
63
+ ### Version-pinned GitHub Release fallback
64
+
65
+ Every tagged release also attaches a versioned `sdoc-editor-cli-*.tgz` to
66
+ [GitHub Releases](https://github.com/SWBaek/sdoc-editor/releases/latest). Use
67
+ this path when an installation must be pinned to a downloaded release asset.
68
+ After downloading the selected tarball, install it from the project that owns
69
+ the dependency:
70
+
71
+ ```powershell
72
+ npm install --save-dev .\sdoc-editor-cli-X.Y.Z.tgz
73
+ npx sdoc-editor-cli --version
74
+ ```
75
+
76
+ For frequent interactive use across projects, a global install provides the
77
+ short `sdoc` command:
78
+
79
+ ```powershell
80
+ npm install --global sdoc-editor-cli
81
+ sdoc --version
82
+ ```
83
+
84
+ The remaining examples use `sdoc` for readability. After a project-local
85
+ install, replace that prefix with `npx sdoc-editor-cli`, or use the `npm run
86
+ sdoc --` script above. A global installation can run `sdoc` directly.
87
+
88
+ ## Help and output
89
+
90
+ ```powershell
91
+ sdoc --help
92
+ sdoc help apply
93
+ sdoc inspect --help
94
+ ```
95
+
96
+ JSON is the default and stable machine-readable output. `--json` states that
97
+ choice explicitly. `--human` provides concise interactive output and is not a
98
+ stable machine API; its wording and layout may change between releases. Never
99
+ parse human output in an Agent or script. Success is written to stdout;
100
+ structured errors are written to stderr.
101
+
102
+ Every JSON success and failure is one line with the top-level contract
103
+ `sdoc.cli.response/1`. The packaged schema is
104
+ `dist/schemas/sdoc.cli.response.schema.json`. Consumers must ignore unknown
105
+ fields: compatible updates may add fields, commands, operations, diagnostics,
106
+ and capability values, while existing fields retain their documented meaning.
107
+
108
+ ```json
109
+ {"contract":"sdoc.cli.response/1","ok":true,"command":"validate","revision":"sha256:..."}
110
+ ```
111
+
112
+ Failures always include `category` and a non-empty `diagnostics` array:
113
+
114
+ ```json
115
+ {"contract":"sdoc.cli.response/1","ok":false,"category":"argument","diagnostics":[{"code":"CLI_MISSING_DOCUMENT","message":"..."}]}
116
+ ```
117
+
118
+ ## Commands
119
+
120
+ ### `capabilities`
121
+
122
+ Reports the installed CLI version, supported contracts and commands, semantic
123
+ operation names, safety limits, read projections/catalog kinds, and built-in
124
+ template IDs. It does not accept a document path. JSON is the default:
125
+
126
+ ```powershell
127
+ sdoc capabilities
128
+ sdoc capabilities --json
129
+ sdoc capabilities --human
130
+ ```
131
+
132
+ ```json
133
+ {
134
+ "contract": "sdoc.cli.response/1",
135
+ "ok": true,
136
+ "command": "capabilities",
137
+ "cliVersion": "0.9.3",
138
+ "contracts": {
139
+ "document": "sdoc/1.0",
140
+ "operations": "sdoc.operations/1",
141
+ "read": "sdoc.read/1",
142
+ "response": "sdoc.cli.response/1"
143
+ },
144
+ "commands": ["capabilities", "inspect", "validate", "apply", "rename-heading", "set-document-title", "create"],
145
+ "projections": ["catalog", "target", "section", "document"],
146
+ "catalogKinds": ["blocks", "outline", "references", "referenceables"],
147
+ "builtInTemplateIds": ["builtin:blank", "builtin:technical-report", "builtin:design-specification", "builtin:verification-report"]
148
+ }
149
+ ```
150
+
151
+ The actual result also includes `semanticOperations` and numeric `limits`;
152
+ query it instead of hard-coding the installed package's capabilities.
153
+
154
+ ### `inspect`
155
+
156
+ Without `--projection`, returns the existing inspector shape: the SHA-256
157
+ revision of the exact source bytes, metadata, outline, references,
158
+ referenceable nodes, targetable blocks, and an optional selected target. The
159
+ revision includes a UTF-8 BOM when present and changes after
160
+ representation-only edits. Existing no-projection calls retain this behavior.
161
+
162
+ ```powershell
163
+ sdoc inspect document.sdoc --json
164
+ sdoc inspect document.sdoc --target-id intro --json
165
+ sdoc inspect document.sdoc --target-path /1/0 --json
166
+ ```
167
+
168
+ Use the returned `revision`, IDs, paths, node types, and digests to construct an
169
+ operation request. Top-level `metadata` reports the current title, author,
170
+ version, timestamps, and document setting overrides. `--target-path` uses a
171
+ slash-delimited content path. For example, `/1/0` selects
172
+ `doc.content[1].content[0]`. A selected result has this shape:
173
+
174
+ ```json
175
+ {
176
+ "target": {
177
+ "path": [1, 0],
178
+ "node": { "type": "paragraph" },
179
+ "digest": "sha256:...",
180
+ "operationTarget": {
181
+ "kind": "snapshot",
182
+ "path": [1, 0],
183
+ "nodeType": "paragraph",
184
+ "digest": "sha256:..."
185
+ }
186
+ }
187
+ }
188
+ ```
189
+
190
+ Copy `target.operationTarget` directly instead of assembling a snapshot
191
+ target. Every `blocks[]` entry also includes its canonical `operationTarget`.
192
+ Referenceable nodes receive an ID target; other blocks receive a snapshot
193
+ target. A provisional ID is valid only for that inspected revision; applying
194
+ it persists the ID.
195
+
196
+ An explicit `--projection` selects the additive bounded `sdoc.read/1`
197
+ contract. Projected JSON still uses `contract: "sdoc.cli.response/1"` as its
198
+ top-level response discriminator and exposes the core discriminator separately
199
+ as `readContract: "sdoc.read/1"`. The core `projection`, `revision`, `data`,
200
+ `page`, and `budget` fields remain top-level. Validate request objects against
201
+ `dist/schemas/sdoc.read.schema.json` when another host calls the shared core
202
+ directly.
203
+
204
+ ```powershell
205
+ sdoc inspect document.sdoc --projection catalog --json
206
+ sdoc inspect document.sdoc --projection catalog --catalog outline --limit 100 --json
207
+ sdoc inspect document.sdoc --projection target --target-id intro --max-bytes 262144 --max-nodes 1000 --json
208
+ sdoc inspect document.sdoc --projection section --target-path /0 --max-nodes 500 --human
209
+ sdoc inspect document.sdoc --projection document --max-bytes 262144 --max-nodes 1000 --json
210
+ sdoc inspect document.sdoc --projection document --cursor $nextCursor --json
211
+ ```
212
+
213
+ Projection option rules are strict and are checked before the document is
214
+ read:
215
+
216
+ | Projection | Target | Allowed read options |
217
+ |---|---|---|
218
+ | `catalog` | none | `--catalog`, `--limit`, `--cursor`, `--max-bytes`, `--max-summary-length`, `--expected-revision` |
219
+ | `target` | exactly one of ID/path | `--max-bytes`, `--max-nodes`, `--expected-revision` |
220
+ | `section` | exactly one of ID/path | `--cursor`, `--max-bytes`, `--max-nodes`, `--expected-revision` |
221
+ | `document` | none | `--cursor`, `--max-bytes`, `--max-nodes`, `--expected-revision` |
222
+
223
+ The catalog defaults to `blocks`. `--limit`, `--max-bytes`, `--max-nodes`,
224
+ and `--max-summary-length` accept canonical positive base-10 integers. Read
225
+ options other than legacy `--target-id` and `--target-path` require an explicit
226
+ projection. Catalog, section, and document results can return
227
+ `page.nextCursor`; pass it back with the same projection/query until
228
+ `page.complete` is true. Cursors bind the exact source bytes and query scope.
229
+ They are opaque integrity tokens, not authentication credentials.
230
+
231
+ ### `validate`
232
+
233
+ Checks the persisted document contract and semantic invariants without writing:
234
+
235
+ ```powershell
236
+ sdoc validate document.sdoc --json
237
+ sdoc validate legacy.tiptap.json --human
238
+ ```
239
+
240
+ ### `apply`
241
+
242
+ Reads a complete `sdoc.operations/1` request from a UTF-8 JSON file or stdin.
243
+ Malformed UTF-8 is rejected before JSON parsing, locking, or document writes.
244
+ A UTF-8 BOM and non-ASCII JSON content are accepted. Preview is the default;
245
+ only `--write` can modify the named document.
246
+
247
+ ```powershell
248
+ sdoc apply document.sdoc --operations operations.json --json
249
+ sdoc apply document.sdoc --operations operations.json --dry-run --json
250
+ sdoc apply document.sdoc --operations operations.json --write --json
251
+ Get-Content -Raw -Encoding utf8 operations.json |
252
+ sdoc apply document.sdoc --operations - --write --json
253
+ ```
254
+
255
+ `--write` takes a sibling lock, re-reads the file, verifies its revision, and
256
+ atomically replaces it. A no-op is not written. Do not combine `--write` and
257
+ `--dry-run`.
258
+
259
+ The sibling `<document>.lock` records structured owner metadata with a format
260
+ version, process ID, random ownership token, hostname, and creation time. If a
261
+ write finds an existing lock, the CLI reclaims it automatically only when all
262
+ of these conditions are true:
263
+
264
+ ```json
265
+ {"version":1,"pid":1234,"token":"0123456789abcdef0123456789abcdef","hostname":"workstation","createdAt":"2026-08-06T12:00:00.000Z"}
266
+ ```
267
+
268
+ - the metadata is recognized and belongs to the current host;
269
+ - the owner process can be conclusively shown to have exited; and
270
+ - the lock is at least 60 seconds old.
271
+
272
+ Recovery atomically moves the stale lock aside, verifies that its owner did not
273
+ change during the move, and then retries normal exclusive acquisition. The
274
+ document is re-read and its revision is checked only after the new lock is
275
+ owned, and the CLI re-checks its ownership token before atomic publication.
276
+
277
+ Live owners, owners on another host, and legacy, malformed, or unsupported
278
+ metadata are never removed automatically. Wait for a known writer to finish.
279
+ For an abandoned lock that cannot be reclaimed automatically, remove the
280
+ `.lock` file manually only after confirming no writer is active on any reported
281
+ host, then re-inspect the document and rebuild the operation from its current
282
+ revision before retrying `--write`.
283
+
284
+ ### `rename-heading`
285
+
286
+ Convenience command for a single `renameHeading` operation:
287
+
288
+ ```powershell
289
+ $inspection = sdoc inspect document.sdoc --json | ConvertFrom-Json
290
+ sdoc rename-heading document.sdoc --id intro --title "Updated heading" `
291
+ --expected-revision $inspection.revision --json
292
+ sdoc rename-heading document.sdoc --id intro --title "Updated heading" `
293
+ --expected-revision $inspection.revision --write --json
294
+ ```
295
+
296
+ The preview and a later independent write can have different
297
+ `outputRevision` values because each semantic change supplies a new
298
+ `meta.modified` time. Always treat the write result as authoritative.
299
+
300
+ ### `set-document-title`
301
+
302
+ Convenience command for one `setDocumentTitle` operation. `--title` and
303
+ `--expected-revision` are required. Without `--id`, the command changes only
304
+ `meta.title`. With the persistent or provisional ID of an H1, it changes
305
+ `meta.title` and that explicit title heading atomically. The CLI never guesses
306
+ a title heading and never renames the file.
307
+
308
+ ```powershell
309
+ $inspection = sdoc inspect document.sdoc --json | ConvertFrom-Json
310
+ sdoc set-document-title document.sdoc --title "Metadata title" `
311
+ --expected-revision $inspection.revision --write --json
312
+ $inspection = sdoc inspect document.sdoc --json | ConvertFrom-Json
313
+ sdoc set-document-title document.sdoc --title "Updated document" --id title-h1 `
314
+ --expected-revision $inspection.revision --write --json
315
+ ```
316
+
317
+ Use `--discard-formatting` only when replacing marked or non-text content in
318
+ the selected H1 is intentional. It requires `--id`; without an H1 target there
319
+ is no formatting to discard.
320
+
321
+ ### `create`
322
+
323
+ Creates a schema-valid `.sdoc` without overwriting an existing path. The
324
+ default template is `builtin:blank`; the default title is the output filename.
325
+
326
+ ```powershell
327
+ sdoc create report.sdoc --title "Quarterly Report" --json
328
+ sdoc create report.sdoc --template builtin:technical-report --dry-run --json
329
+ sdoc create design.sdoc --template builtin:design-specification --json
330
+ sdoc create verification.sdoc --template builtin:verification-report --json
331
+ sdoc create report.sdoc --template .\templates\company-report.sdoc --json
332
+ ```
333
+
334
+ An explicit file template must be a valid UTF-8 JSON `.sdoc`; malformed UTF-8
335
+ is rejected before a destination is created. A UTF-8 BOM is accepted. Creation
336
+ removes persisted document identity and template-only metadata while
337
+ preserving supported settings, node IDs, and links.
338
+
339
+ ## Public schemas and operation contract
340
+
341
+ The package includes:
342
+
343
+ - `dist/schemas/sdoc.operations.schema.json`: draft-07 request schema
344
+ - `dist/schemas/sdoc.read.schema.json`: draft-07 `sdoc.read/1` request union
345
+ - `dist/schemas/sdoc.cli.response.schema.json`: additive CLI JSON response schema
346
+ - `dist/schemas/sdoc.schema.json`: persisted document schema and reusable
347
+ `anyNode` fragment
348
+ - `dist/examples/operations/*.json`: one request for each semantic operation
349
+
350
+ Repository copies live at `sdoc.operations.schema.json`,
351
+ `sdoc.read.schema.json`, `sdoc.schema.json`,
352
+ `cli/schemas/sdoc.cli.response.schema.json`, and `examples/operations/`.
353
+
354
+ Every request has this envelope:
355
+
356
+ ```json
357
+ {
358
+ "contract": "sdoc.operations/1",
359
+ "expected": {
360
+ "revision": "sha256:0000000000000000000000000000000000000000000000000000000000000000"
361
+ },
362
+ "operations": []
363
+ }
364
+ ```
365
+
366
+ In published examples, the all-zero revision means "replace with the exact
367
+ `revision` returned by `inspect`." The all-one snapshot digest means "replace
368
+ with that block's exact `digest` returned by the same inspection." IDs ending
369
+ in `-from-inspect` are also placeholders. These values are syntactically valid
370
+ so schema tools can validate every example, but they are not usable against a
371
+ real document until replaced.
372
+
373
+ Do not add `expected.documentId` in CLI requests. Although inspection may
374
+ report a document ID, the file-only CLI cannot establish a trusted external
375
+ document identity and rejects that precondition as unverifiable. Revision is
376
+ the CLI concurrency contract.
377
+
378
+ ### Targets
379
+
380
+ Referenceable `heading`, `image`, `table`, and `mathBlock` nodes use persistent
381
+ or revision-scoped provisional IDs:
382
+
383
+ ```json
384
+ { "kind": "id", "id": "intro", "expectedType": "heading" }
385
+ ```
386
+
387
+ Other mutable blocks, including `paragraph`, `codeBlock`, and `diagram`, use a
388
+ protected snapshot locator:
389
+
390
+ ```json
391
+ {
392
+ "kind": "snapshot",
393
+ "path": [1],
394
+ "nodeType": "paragraph",
395
+ "digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111"
396
+ }
397
+ ```
398
+
399
+ Prefer the ready-to-use `operationTarget` returned by `inspect` over copying
400
+ these fields manually. A `diagram` is snapshot-targeted; it is not a
401
+ persistent-ID node.
402
+
403
+ Snapshot targets and provisional IDs are revision-scoped. Re-inspect after any
404
+ source-byte change. A batch resolves all targets before applying its first
405
+ operation, so earlier operations cannot redirect later targets.
406
+
407
+ Destinations are `{ "position": "before"|"after", "target": ... }` or
408
+ `{ "position": "section-end", "target": ... }`. `section-end` targets a
409
+ heading and appends inside that section.
410
+
411
+ ### The 14 operations
412
+
413
+ Each operation below has a complete file in `dist/examples/operations/`:
414
+
415
+ | Operation | Required fields | Purpose |
416
+ |---|---|---|
417
+ | `renameHeading` | `target`, `title` | Rename a heading; optional `discardFormatting` permits replacing rich heading content |
418
+ | `insertBlock` | `destination`, `block` | Insert a non-heading Tiptap block |
419
+ | `insertSection` | `target`, `title` | Insert a child section by default, or a same-level sibling with `position: "before"|"after"`; optional `id` and `blocks` |
420
+ | `replaceBlock` | `target`, `block` | Replace a block with the same node type while preserving identity |
421
+ | `updateBlockAttrs` | `target`, `attrs` | Merge block attributes |
422
+ | `moveBlock` | `target`, `destination` | Move a non-heading block |
423
+ | `deleteBlock` | `target` | Delete a non-heading block |
424
+ | `moveSection` | `target`, `destination` | Move a heading and its complete descendant section |
425
+ | `deleteSection` | `target` | Delete a heading and its complete descendant section |
426
+ | `setHeadingLevel` | `target`, `level` | Set a heading level and shift descendant headings by the same delta while preserving IDs |
427
+ | `renameBlockId` | `target`, `newId` | Rename a heading or table ID and rewrite matching internal links |
428
+ | `setDocumentTitle` | `title` | Set `meta.title`; optional `headingTarget` atomically updates an explicit H1 |
429
+ | `updateDocumentMetadata` | `patch` | Set or remove (`null`) the allowed `author` and `version` fields |
430
+ | `updateDocumentSettings` | `patch` | Set or remove (`null`) portable document setting overrides |
431
+
432
+ Operations are validated and applied atomically as one batch. Headings must be
433
+ moved or deleted with section operations. `created`, `modified`, document
434
+ identity, template metadata, arbitrary metadata, and filenames cannot be
435
+ changed by these operations. Portable settings are:
436
+ `headingNumbering`, `headingDecoration`, `headingH1Color` through
437
+ `headingH6Color`, `captionStyle`, `captionNumbering`, `equationNumbering`,
438
+ `crossRefIncludeCaption`, `pdfScale`, `selfContained`, `slideBreakLevel`,
439
+ `slideTransition`, and `showTitleSlide`. Local path settings
440
+ `slideCssPath`, `htmlCssPath`, and `outputDir` are deliberately excluded.
441
+
442
+ `insertSection` keeps its existing child behavior when `position` is omitted or
443
+ set to `"child"`: the new heading is one level deeper and is appended at the
444
+ target section boundary. Set `position` to `"before"` or `"after"` to insert a
445
+ same-level sibling before the target heading or after its complete descendant
446
+ section. This is the supported CLI route for building several peer H1 sections
447
+ without editing raw JSON; the new heading receives the target heading's level
448
+ and its requested persistent ID.
449
+
450
+ `setHeadingLevel` changes an existing heading to level 1-6 without changing its
451
+ persistent ID. Every descendant heading in that section moves by the same
452
+ level delta, preserving the section's relative hierarchy; the operation is
453
+ rejected with `INVALID_HEADING_LEVEL` or `SECTION_LEVEL_OUT_OF_RANGE` when the
454
+ requested level or any resulting descendant level falls outside 1-6. Inspect
455
+ again after the write to confirm the resulting outline paths and parentage.
456
+
457
+ `renameBlockId` requires an existing ID target and a unique non-empty `newId`.
458
+ It preserves the node and heading level, updates internal `#old-id` links in the
459
+ same atomic batch, and rejects duplicate IDs. A newly assigned ID cannot be used
460
+ as another target in that same request because all operation targets are
461
+ resolved from the inspected input revision before mutation begins.
462
+
463
+ ### One inspection, one atomic batch
464
+
465
+ Do not inspect once per operation. One revision can guard a batch of up to 100
466
+ operations. This example inspects once, prepares three document-level changes,
467
+ previews them, and then writes the same batch:
468
+
469
+ ```powershell
470
+ $inspection = sdoc inspect document.sdoc --target-id title-h1 --json |
471
+ ConvertFrom-Json
472
+ $request = [ordered]@{
473
+ contract = 'sdoc.operations/1'
474
+ expected = @{ revision = $inspection.revision }
475
+ operations = @(
476
+ @{
477
+ op = 'setDocumentTitle'
478
+ title = 'Release Plan'
479
+ headingTarget = $inspection.target.operationTarget
480
+ }
481
+ @{ op = 'updateDocumentMetadata'; patch = @{ author = 'Documentation Team'; version = '2.0' } }
482
+ @{ op = 'updateDocumentSettings'; patch = @{ headingNumbering = $true; captionStyle = 'modern' } }
483
+ )
484
+ }
485
+ $request | ConvertTo-Json -Depth 100 |
486
+ Set-Content -Encoding utf8 operations.json
487
+ sdoc apply document.sdoc --operations operations.json --json
488
+ sdoc apply document.sdoc --operations operations.json --write --json
489
+ ```
490
+
491
+ All targets in a mixed content/metadata batch must come from that same
492
+ inspection. Re-inspect after a successful write before preparing another
493
+ batch; no re-inspection is needed between operations inside one batch.
494
+
495
+ ### Supported node and target catalog
496
+
497
+ The packaged `sdoc.schema.json` is authoritative. This concise catalog covers
498
+ the operation-relevant node types and required attributes:
499
+
500
+ | Nodes | Required attributes | Target kind and notes |
501
+ |---|---|---|
502
+ | `heading` | `attrs.level` (1-6) | ID target; rename with `renameHeading`, and move/delete as a complete section |
503
+ | `paragraph`, `blockquote`, `bulletList`, `orderedList`, `taskList`, `taskItem` | None | Snapshot target |
504
+ | `codeBlock` | None (`attrs.language` optional) | Snapshot target |
505
+ | `table` | None | ID target |
506
+ | `tableCell`, `tableHeader` | None | Snapshot target; span, width, and alignment attrs are optional |
507
+ | `image` | None | ID target; a new `src`, when present, must be portable |
508
+ | `mathBlock` | `attrs.latex` | ID target |
509
+ | `diagram` | `attrs.language`, `attrs.code` | Snapshot target; stores source such as Mermaid, PlantUML, or D2 |
510
+ | `horizontalRule`, `hardBreak`, `callout` | None | Snapshot target; callout `variant` is optional |
511
+ | `listItem`, `tableRow` | None | Structural container, not an operation block target |
512
+ | `text`, `mathInline` | `mathInline.attrs.latex` only | Inline content, not an operation target |
513
+
514
+ `updateBlockAttrs` accepts only the attrs defined for that node type.
515
+ `replaceBlock` must preserve the node type, while headings require the
516
+ heading/section operations.
517
+
518
+ Portable image assets use `./images/...`. Draw.io content is an `image` node
519
+ whose `src` is under `./drawio/` and ends in `.drawio.svg`; it is not a
520
+ `diagram` node. The CLI validates document structure and portable references
521
+ but does not render diagrams, create or copy asset files, or fetch assets from
522
+ the network.
523
+
524
+ ### Diagram authoring and host rendering
525
+
526
+ For CLI and AI-operator workflows, the source of truth is the `diagram` node:
527
+ `diagram.attrs.language` plus `diagram.attrs.code`. A schema-valid node with
528
+ those attributes is sufficient to author and preserve a diagram in `.sdoc`;
529
+ the CLI owns structural validation and source preservation, not rendering.
530
+
531
+ Rendering belongs to the Structured Doc Editor host/viewer:
532
+
533
+ - Mermaid renders locally in the host.
534
+ - PlantUML, D2, and Graphviz use the host's online preview path only after the
535
+ user grants the required first-use consent.
536
+ - If consent is declined or rendering is unavailable, the diagram source
537
+ remains valid and preserved in the document.
538
+
539
+ Do not install local D2, Graphviz, PlantUML, or other renderers merely because
540
+ the CLI does not render a diagram. Local renderers are optional tools outside
541
+ the CLI authoring contract; AI operators should create or update the diagram
542
+ source node and leave rendering to the host unless the user explicitly asks
543
+ for a separate local-renderer workflow.
544
+
545
+ ## Legacy documents
546
+
547
+ Legacy raw Tiptap JSON can be inspected and validated without an upgrade flag.
548
+ Every mutation, including preview, requires `--upgrade-legacy`. Persisting the
549
+ in-place envelope upgrade additionally requires `--write`:
550
+
551
+ ```powershell
552
+ sdoc apply legacy.tiptap.json --operations operations.json --upgrade-legacy --json
553
+ sdoc apply legacy.tiptap.json --operations operations.json --upgrade-legacy --write --json
554
+ ```
555
+
556
+ This changes the named file in place to an SDOC envelope but does not rename
557
+ its `.tiptap.json` extension. Back up or copy the file to a `.sdoc` path first
558
+ when preserving the legacy filename matters.
559
+
560
+ ## PowerShell automation
561
+
562
+ Write non-ASCII JSON explicitly as UTF-8 and keep stdout separate from stderr:
563
+
564
+ ```powershell
565
+ $request | ConvertTo-Json -Depth 100 |
566
+ Set-Content -Encoding utf8 operations.json
567
+
568
+ $resultJson = sdoc apply document.sdoc --operations operations.json --json 2>error.json
569
+ if ($LASTEXITCODE -ne 0) {
570
+ $errorResult = Get-Content -Raw -Encoding utf8 error.json | ConvertFrom-Json
571
+ throw "$($errorResult.diagnostics[0].code): $($errorResult.diagnostics[0].message)"
572
+ }
573
+ $result = $resultJson | ConvertFrom-Json
574
+ ```
575
+
576
+ ## Exit codes
577
+
578
+ | Code | Meaning | Representative diagnostic |
579
+ |---:|---|---|
580
+ | 0 | Success | No diagnostic |
581
+ | 2 | CLI argument or operation request error | `CLI_CONFLICTING_OPTIONS`, `CLI_MISSING_OPERATIONS` |
582
+ | 3 | Document, template, invariant, or legacy-upgrade error | `LEGACY_UPGRADE_REQUIRED` |
583
+ | 4 | Stale revision or precondition conflict | `STALE_REVISION` |
584
+ | 5 | File I/O error | `CLI_READ_FAILED` |
585
+
586
+ On failure, inspect `diagnostics[].code` rather than matching human-readable
587
+ messages. The machine-readable `category` is one of `argument`, `document`,
588
+ `conflict`, `io`, or `internal`. Categories describe the failure source and do
589
+ not replace exit codes; in particular, an `IoError` is categorized as `io`
590
+ while retaining its existing exit code.
591
+
592
+ ## Diagnostic recovery
593
+
594
+ Diagnostic messages are explanatory text, not a parsing contract. Automations
595
+ must branch on `diagnostics[].code` from explicit `--json` output.
596
+
597
+ | Diagnostic code(s) | Likely cause | Recovery |
598
+ |---|---|---|
599
+ | `CLI_UNKNOWN_*`, `CLI_MISSING_*`, `CLI_CONFLICTING_OPTIONS`, `CLI_OPTION_REQUIRES_ID` | Misspelled command/flag, omitted value, or incompatible/dependent flags | Run the command-specific `--help`, correct the invocation, and retry |
600
+ | `CLI_INVALID_TARGET_PATH` | `--target-path` is not a slash-delimited non-negative integer path | Copy the path from `inspect.blocks[].path` and format it like `/1/0` |
601
+ | `CLI_INVALID_POSITIVE_INTEGER`, `CLI_INVALID_PROJECTION`, `CLI_INVALID_CATALOG` | A projected read option has a malformed number or unsupported selector | Use `sdoc inspect --help` and pass a documented projection/catalog and canonical positive integer |
602
+ | `CLI_PROJECTION_REQUIRED`, `CLI_PROJECTION_REQUIRES_TARGET`, `CLI_PROJECTION_FORBIDS_TARGET`, `CLI_PROJECTION_OPTION_NOT_SUPPORTED` | Projected read flags are missing their projection, target, or valid projection-specific combination | Follow the projection option matrix above; legacy targets remain valid only when no read-only flags are supplied |
603
+ | `CLI_INVALID_UTF8` | Operation file or stdin bytes are not valid UTF-8 | Re-encode the complete JSON request as UTF-8 and retry; the document and sibling lock are untouched |
604
+ | `CLI_INVALID_JSON`, `INVALID_OPERATION_REQUEST`, `INVALID_OPERATION` | Malformed operations JSON or a request that does not match `sdoc.operations/1` | Validate against the packaged operation schema; inspect `operationIndex` when present |
605
+ | `MALFORMED_JSON`, `DOCUMENT_SCHEMA_INVALID`, `UNSUPPORTED_VERSION` | The input is not valid UTF-8 JSON, violates the document schema, or uses an unsupported SDOC version | Repair or migrate the source; do not force a write |
606
+ | `LEGACY_UPGRADE_REQUIRED` | A legacy `.tiptap.json` mutation omitted the explicit upgrade flag | Re-run with `--upgrade-legacy`, preview first, then add `--write` if intended |
607
+ | `STALE_REVISION` | The document bytes changed after inspection | Re-inspect the current file and rebuild the whole request from that revision |
608
+ | `INVALID_READ_CURSOR`, `STALE_READ_CURSOR`, `READ_CURSOR_SCOPE_MISMATCH` | A cursor is malformed, the exact source bytes changed, or it belongs to another projection/query | Restart the projection from its first page using the current document; never edit or reuse a cursor across queries |
609
+ | `PROJECTION_ITEM_TOO_LARGE` | The next complete catalog entry or subtree cannot fit the requested byte/node budget | Increase the reported limiting budget; projection pages never split a complete item |
610
+ | `TARGET_NOT_FOUND`, `TARGET_NOT_BLOCK`, `TARGET_TYPE_MISMATCH`, `TARGET_DIGEST_MISMATCH` | A selected path/ID is absent, is not a block, changed type, or no longer matches its snapshot | Re-inspect and use the returned `operationTarget`; do not weaken the precondition |
611
+ | `SECTION_OPERATION_REQUIRED`, `HEADING_TARGET_REQUIRED`, `SECTION_TARGET_REQUIRED`, `TITLE_H1_TARGET_REQUIRED` | A block operation was used for a heading, or a title target was not H1 | Use the matching heading/section operation and an inspected heading target |
612
+ | `INVALID_HEADING_LEVEL`, `SECTION_LEVEL_OUT_OF_RANGE` | A requested heading level is outside 1-6, or shifting the section would push a descendant outside that range | Choose a valid target level that keeps every descendant heading within 1-6 |
613
+ | `FORMATTED_HEADING` | Replacing a rich heading would discard marks or inline nodes | Preserve it, or explicitly use `discardFormatting` / `--discard-formatting` |
614
+ | `ATTRIBUTE_NOT_ALLOWED`, `NODE_TYPE_CHANGE` | An attr is not allowed for the node, or replacement changes its type | Consult the node catalog/schema and keep replacements type-compatible |
615
+ | `ID_RENAME_NOT_SUPPORTED`, `ID_RENAME_REQUIRES_EXISTING_ID`, `INVALID_NEW_ID` | An ID rename targeted an unsupported or provisional node, or supplied an invalid new ID | Target an inspected heading/table with an existing persistent ID and choose a unique non-reserved ID |
616
+ | `NEW_NONPORTABLE_ASSET`, `NEW_DANGLING_REFERENCE`, `NEW_UNSAFE_LINK` | The batch introduces an invalid asset path, missing internal target, or unsafe link | Use `./images/...` or `./drawio/*.drawio.svg`, create referenced IDs, and use a safe URL |
617
+ | `DUPLICATE_ID` | The document contains conflicting persistent IDs | Assign unique IDs before retrying |
618
+ | `CLI_TEMPLATE_INVALID` | An explicit template has malformed UTF-8, invalid JSON, or violates the template contract | Repair or re-encode the template as UTF-8; creation leaves the destination absent |
619
+ | `CLI_TARGET_EXISTS` | `create` would overwrite an existing file | Choose a new path; the CLI never overwrites during creation |
620
+ | `CLI_LOCK_UNAVAILABLE` | Another writer holds the sibling lock, or its owner cannot be reclaimed safely | Wait for a known writer to finish. Remove an abandoned lock manually only after confirming no writer is active, then re-inspect before retrying |
621
+ | `CLI_READ_FAILED`, `CLI_ATOMIC_WRITE_FAILED` | Filesystem access or atomic replacement failed | Check path, permissions, free space, and filesystem support; verify the file before retrying |
622
+
623
+ Warnings such as `NONPORTABLE_ASSET`, `DANGLING_REFERENCE`, `UNSAFE_LINK`, and
624
+ `LEGACY_FILE_EXTENSION_RETAINED` describe pre-existing or retained conditions.
625
+ They do not authorize a later mutation to add or increase the same violation.