Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions apps/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,10 +311,11 @@ superdoc info ./contract.docx --pretty

## Input payload flags

- `--query-json`, `--query-file`
- `--address-json`, `--address-file`
- `--target-json`, `--target-file`
- `--at-json`, `--at-file` (for `create paragraph`)
- `--query-json`, `--query-file` (`find`, `lists list`)
- `--address-json`, `--address-file` (`get-node`, `lists get`)
- `--target-json` (mutation commands — no `--target-file` counterpart; use flat flags `--block-id`/`--start`/`--end` as alternative)
- `--input-json`, `--input-file` (`call`, `create paragraph`)
- `--at-json`, `--at-file` (`create paragraph`)

## Stdin support

Expand Down
79 changes: 66 additions & 13 deletions apps/cli/skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: Edit, query, and transform Word documents with the SuperDoc CLI v1

# SuperDoc CLI (v1)

Use SuperDoc CLI for DOCX work. Prefer canonical v1 commands.
Use SuperDoc CLI for DOCX work. Use v1 commands (canonical operations and their helper wrappers).
Do not default to legacy commands unless explicitly needed for v0-style bulk workflows.

Use `superdoc` if installed, or `npx @superdoc-dev/cli@latest` as a fallback.
Expand All @@ -28,12 +28,13 @@ Use `describe command` for per-command args and constraints.

```bash
superdoc open ./contract.docx
superdoc find --type text --pattern "termination"
superdoc query match --select-json '{"type":"text","pattern":"termination"}' --require exactlyOne
superdoc replace --target-json '{"kind":"text","blockId":"p1","range":{"start":0,"end":11}}' --text "expiration"
superdoc save --in-place
superdoc close
```

- Always use `query match` (not `find`) to discover mutation targets — it returns exact addresses with cardinality guarantees.
- After `open`, commands run against the active/default session when `<doc>` is omitted.
- Use `superdoc session list|set-default|save|close` for explicit session control.
- `close` on dirty state requires `--discard` or a prior `save`.
Expand All @@ -57,28 +58,80 @@ superdoc replace ./proposal.docx \

- In stateless mode (`<doc>` provided), mutating commands require `--out` unless using `--dry-run`.

### Safety: preview before apply

- Use `--dry-run` to preview any mutation without applying it.
- Use `--expected-revision <n>` with stateful mutations for optimistic concurrency checks.

## Common v1 Commands

- Search text/nodes: `find --type text --pattern "..."` or `find --query-json '{...}'`
- Replace text: `replace --target-json '{...}' --text "..."`
- Add/edit comments: `comments add|reply|edit|resolve|remove`
- Review tracked changes: `track-changes list|accept|reject|accept-all|reject-all`
### Query & inspect

- Search/browse content: `find --type text --pattern "..."` or `find --query-json '{...}'`
- Find mutation target: `query match --select-json '{...}' --require exactlyOne`
- Inspect blocks: `blocks list`, `get-node`, `get-node-by-id`
- Extract content: `get-text`, `get-markdown`, `get-html`
- Low-level direct invoke: `call <operationId> --input-json '{...}'`

### Mutate

- Replace text: `replace --target-json '{...}' --text "..."`
- Insert inline text: `insert --block-id <id> --offset <n> --value "..."`
- Delete text/node: `delete --target-json '{...}'`
- Delete blocks: `blocks delete`, `blocks delete-range`
- Batch mutations: `mutations apply --steps-json '[...]' --atomic true --change-mode direct`
- Create paragraph: `create paragraph --text "..."` (with optional `--at-json`)
- Create heading: `create heading --input-json '{"level":<n>,"text":"..."}'`

### Format

- Apply formatting: `format apply --block-id <id> --start <n> --end <n> --inline-json '{"bold":true}'`
- Shortcuts: `format bold`, `format italic`, `format underline`, `format strikethrough`

### Lists

- List items: `lists list`, `lists get`
- Insert list item: `lists insert --node-id <id> --position after --text "..."`
- Modify: `lists indent`, `lists outdent`, `lists set-level`, `lists set-type`, `lists convert-to-text`

### Comments

- Add/reply: `comments add`, `comments reply`
- Read: `comments get`, `comments list`
- Edit/resolve/move: `comments edit`, `comments resolve`, `comments move`, `comments set-internal`
- Delete: `comments delete` (canonical) or `comments remove` (alias)

### Track changes

- List: `track-changes list`, `track-changes get`
- Decide: `track-changes accept`, `track-changes reject`, `track-changes accept-all`, `track-changes reject-all`

### History

- `history get`, `history undo`, `history redo`

### Low-level

- Direct invoke: `call <operationId> --input-json '{...}'` (JSON output only — `--pretty` is not supported)

## JSON/File Payload Flags

Use one of each pair (not both):
Not all `--*-file` variants are available on every command. Use `describe command <name>` to check.

Always supported alongside their `-json` counterpart (use one, not both):

| Flag pair | Available on |
|-----------|-------------|
| `--query-json` / `--query-file` | `find`, `lists list` |
| `--address-json` / `--address-file` | `get-node`, `lists get` |
| `--input-json` / `--input-file` | `call`, `create paragraph` |
| `--at-json` / `--at-file` | `create paragraph` |

- `--query-json` or `--query-file`
- `--target-json` or `--target-file`
- `--address-json` or `--address-file`
- `--input-json` or `--input-file` (for `call`)
`--target-json` is widely available on mutation commands but has **no** `--target-file` counterpart. Use flat flags (`--block-id`, `--start`, `--end`) as an alternative to `--target-json`.

## Output and Global Flags

- Default output is JSON envelope.
- Use `--pretty` for human-readable output.
- Use `--pretty` for human-readable output (not supported by `call`).
- Global flags: `--output <json|pretty>`, `--session <id>`, `--timeout-ms <n>`.
- `<doc>` can be `-` to read DOCX bytes from stdin.

Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/__tests__/host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ describe('CLI host mode', () => {
await invokeAndValidate('doc.comments.list', ['comments', 'list', docPath, '--include-resolved', 'false']);

await host.shutdown();
});
}, 15_000);

test('returns parse errors for malformed frames', async () => {
const stateDir = await mkdtemp(path.join(tmpdir(), 'superdoc-host-test-'));
Expand Down
25 changes: 25 additions & 0 deletions apps/cli/src/__tests__/lib/error-mapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,3 +274,28 @@ describe('mapFailedReceipt: plan-engine code passthrough', () => {
expect(result!.code).toBe('INVALID_ARGUMENT');
});
});

// ---------------------------------------------------------------------------
// textMutation: INVALID_INPUT ordering — plan-engine must win over adapter remap
// ---------------------------------------------------------------------------

describe('mapInvokeError: textMutation INVALID_INPUT ordering', () => {
test('plan-engine INVALID_INPUT passes through verbatim for text mutations', () => {
const error = Object.assign(new Error('step schema invalid'), {
code: 'INVALID_INPUT',
details: {
stepIndex: 0,
operation: 'text.rewrite',
remediation: 'Fix the step payload.',
},
});

const result = mapInvokeError('format.inline.apply' as any, error);
expect(result).toBeInstanceOf(CliError);
// Must preserve INVALID_INPUT — not remap to INVALID_ARGUMENT
expect(result.code).toBe('INVALID_INPUT');
expect(result.details).toMatchObject({
details: { stepIndex: 0, operation: 'text.rewrite' },
});
});
});
20 changes: 18 additions & 2 deletions apps/cli/src/lib/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import { readFile, writeFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { Editor } from 'superdoc/super-editor';
import { BLANK_DOCX_BASE64 } from '@superdoc/super-editor/blank-docx';
import { getDocumentApiAdapters } from '@superdoc/super-editor/document-api-adapters';
import { markdownToPmDoc } from '@superdoc/super-editor/markdown';

import type { DocumentApi } from '@superdoc/document-api';
import { createDocumentApi, type DocumentApi } from '@superdoc/document-api';
import { createCliDomEnvironment } from './dom-environment';
import type { CollaborationProfile } from './collaboration';
import { createCollaborationRuntime } from './collaboration';
Expand Down Expand Up @@ -65,6 +66,21 @@ export interface FileOutputMeta {
byteLength: number;
}

function bindCurrentDocumentApi(editor: Editor): EditorWithDoc {
const editorWithDoc = editor as EditorWithDoc;

// `superdoc/super-editor` resolves to the published dist bundle, which can
// lag the source-backed document-api contract used by the CLI tests. Shadow
// the bundled getter with a source-backed DocumentApi so runtime behavior and
// response validation stay on the same contract version.
Object.defineProperty(editorWithDoc, 'doc', {
configurable: true,
value: createDocumentApi(getDocumentApiAdapters(editor)),
});

return editorWithDoc;
}

function toUint8Array(data: unknown): Uint8Array {
if (data instanceof Uint8Array) return data;
if (data instanceof ArrayBuffer) return new Uint8Array(data);
Expand Down Expand Up @@ -217,7 +233,7 @@ export async function openDocument(
}
}

const editorWithDoc = editor as EditorWithDoc;
const editorWithDoc = bindCurrentDocumentApi(editor);

return {
editor: editorWithDoc,
Expand Down
9 changes: 8 additions & 1 deletion apps/cli/src/lib/error-mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,14 @@ function mapTextMutationError(operationId: CliExposedOperationId, error: unknown
const message = extractErrorMessage(error);
const details = extractErrorDetails(error);

// Plan-engine errors pass through with original code and structured details
// For direct text-mutation commands, adapter INVALID_INPUT errors reflect CLI
// payload-shape issues (for example flat-flag shortcuts that did not
// normalize into a canonical target), so present them as INVALID_ARGUMENT.
if (code === 'INVALID_INPUT') {
return new CliError('INVALID_ARGUMENT', message, { operationId, details });
}

// Other plan-engine errors pass through with original code and structured details.
const planEngineError = tryMapPlanEngineError(operationId, error, code);
if (planEngineError) return planEngineError;

Expand Down
10 changes: 0 additions & 10 deletions packages/document-api/scripts/check-contract-parity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,16 +112,6 @@ function createNoopAdapters(): DocumentApiAdapters {
},
}),
},
format: {
apply: () => ({
success: true,
resolution: {
target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 1 } },
range: { from: 1, to: 2 },
text: 'x',
},
}),
},
trackChanges: {
list: () => ({ evaluatedRevision: '', total: 0, items: [], page: { limit: 50, offset: 0, returned: 0 } }),
get: ({ id }) => ({
Expand Down
9 changes: 3 additions & 6 deletions packages/document-api/src/authorities/authorities.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { MutationOptions } from '../write/write.js';
import { normalizeMutationOptions } from '../write/write.js';
import { DocumentApiValidationError } from '../errors.js';
import { assertTargetPresent } from '../validation-primitives.js';
import type {
AuthoritiesAddress,
AuthorityEntryAddress,
Expand Down Expand Up @@ -51,9 +52,7 @@ export type AuthoritiesAdapter = AuthoritiesApi;
// ---------------------------------------------------------------------------

function validateAuthoritiesTarget(target: unknown, operationName: string): asserts target is AuthoritiesAddress {
if (target === undefined || target === null) {
throw new DocumentApiValidationError('INVALID_TARGET', `${operationName} requires a target.`);
}
assertTargetPresent(target, operationName);
const t = target as Record<string, unknown>;
if (t.kind !== 'block' || t.nodeType !== 'tableOfAuthorities' || typeof t.nodeId !== 'string') {
throw new DocumentApiValidationError(
Expand All @@ -65,9 +64,7 @@ function validateAuthoritiesTarget(target: unknown, operationName: string): asse
}

function validateAuthorityEntryTarget(target: unknown, operationName: string): asserts target is AuthorityEntryAddress {
if (target === undefined || target === null) {
throw new DocumentApiValidationError('INVALID_TARGET', `${operationName} requires a target.`);
}
assertTargetPresent(target, operationName);
const t = target as Record<string, unknown>;
if (t.kind !== 'inline' || t.nodeType !== 'authorityEntry') {
throw new DocumentApiValidationError(
Expand Down
14 changes: 3 additions & 11 deletions packages/document-api/src/authorities/authorities.types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { InlineAnchor } from '../types/base.js';
import type { TextTarget } from '../types/address.js';
import type { ReceiptFailure } from '../types/receipt.js';
import type { AdapterMutationFailure } from '../types/adapter-result.js';
import type { DiscoveryOutput } from '../types/discovery.js';
import type { TocCreateLocation } from '../toc/toc.types.js';

Expand Down Expand Up @@ -163,21 +163,13 @@ export interface AuthoritiesMutationSuccess {
success: true;
authorities: AuthoritiesAddress;
}
export interface AuthoritiesMutationFailure {
success: false;
failure: ReceiptFailure;
}
export type AuthoritiesMutationResult = AuthoritiesMutationSuccess | AuthoritiesMutationFailure;
export type AuthoritiesMutationResult = AuthoritiesMutationSuccess | AdapterMutationFailure;

export interface AuthorityEntryMutationSuccess {
success: true;
entry: AuthorityEntryAddress;
}
export interface AuthorityEntryMutationFailure {
success: false;
failure: ReceiptFailure;
}
export type AuthorityEntryMutationResult = AuthorityEntryMutationSuccess | AuthorityEntryMutationFailure;
export type AuthorityEntryMutationResult = AuthorityEntryMutationSuccess | AdapterMutationFailure;

// ---------------------------------------------------------------------------
// List results
Expand Down
5 changes: 2 additions & 3 deletions packages/document-api/src/bookmarks/bookmarks.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { MutationOptions } from '../write/write.js';
import { normalizeMutationOptions } from '../write/write.js';
import { DocumentApiValidationError } from '../errors.js';
import { assertTargetPresent } from '../validation-primitives.js';
import type {
BookmarkAddress,
BookmarkGetInput,
Expand Down Expand Up @@ -32,9 +33,7 @@ export type BookmarksAdapter = BookmarksApi;
// ---------------------------------------------------------------------------

function validateBookmarkTarget(target: unknown, operationName: string): asserts target is BookmarkAddress {
if (target === undefined || target === null) {
throw new DocumentApiValidationError('INVALID_TARGET', `${operationName} requires a target.`);
}
assertTargetPresent(target, operationName);

const t = target as Record<string, unknown>;
if (t.kind !== 'entity' || t.entityType !== 'bookmark' || typeof t.name !== 'string') {
Expand Down
9 changes: 2 additions & 7 deletions packages/document-api/src/bookmarks/bookmarks.types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Position } from '../types/base.js';
import type { TextTarget } from '../types/address.js';
import type { ReceiptFailure } from '../types/receipt.js';
import type { AdapterMutationFailure } from '../types/adapter-result.js';
import type { DiscoveryOutput } from '../types/discovery.js';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -80,12 +80,7 @@ export interface BookmarkMutationSuccess {
bookmark: BookmarkAddress;
}

export interface BookmarkMutationFailure {
success: false;
failure: ReceiptFailure;
}

export type BookmarkMutationResult = BookmarkMutationSuccess | BookmarkMutationFailure;
export type BookmarkMutationResult = BookmarkMutationSuccess | AdapterMutationFailure;

// ---------------------------------------------------------------------------
// List result
Expand Down
5 changes: 2 additions & 3 deletions packages/document-api/src/captions/captions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { MutationOptions } from '../write/write.js';
import { normalizeMutationOptions } from '../write/write.js';
import { DocumentApiValidationError } from '../errors.js';
import { assertTargetPresent } from '../validation-primitives.js';
import type {
CaptionAddress,
CaptionListInput,
Expand Down Expand Up @@ -35,9 +36,7 @@ export type CaptionsAdapter = CaptionsApi;
// ---------------------------------------------------------------------------

function validateCaptionTarget(target: unknown, operationName: string): asserts target is CaptionAddress {
if (target === undefined || target === null) {
throw new DocumentApiValidationError('INVALID_TARGET', `${operationName} requires a target.`);
}
assertTargetPresent(target, operationName);

const t = target as Record<string, unknown>;
if (t.kind !== 'block' || t.nodeType !== 'paragraph' || typeof t.nodeId !== 'string') {
Expand Down
Loading
Loading