@neta-art/cohub-cli 3.12.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -157,72 +157,44 @@ cohub -s <spaceId> boards capabilities <boardId>
157
157
  cohub -s <spaceId> boards watch <boardId> --json
158
158
  ```
159
159
 
160
- Pass nodes, effects, and sequences as JSON when creating a Board. The path and
161
- title stay explicit in the command. Inspect `boards capabilities --json` for the
162
- supported node types, enums, and coordinate spaces.
163
-
164
- ```json
165
- {
166
- "nodes": [
167
- {
168
- "nodeId": "goal",
169
- "type": "geo",
170
- "parentId": null,
171
- "orderKey": null,
172
- "x": 80,
173
- "y": 80,
174
- "width": 240,
175
- "height": 120,
176
- "rotation": 0,
177
- "refKind": null,
178
- "refPath": null,
179
- "refUrl": null,
180
- "view": {},
181
- "style": {},
182
- "data": {
183
- "geo": "rectangle",
184
- "text": "Ship",
185
- "color": "green",
186
- "fillOpacity": 0.12
187
- }
188
- }
189
- ]
190
- }
160
+ Pass semantic items, effects, and compositions as JSON when creating a Board. The path and title stay explicit in the command. Generate editable templates instead of guessing fields:
161
+
162
+ ```bash
163
+ cohub boards examples create > board-content.json
164
+ cohub boards examples item geo > item.json
165
+ cohub boards examples effect pulse > effect.json
166
+ cohub boards examples composition fade > intro.json
191
167
  ```
192
168
 
169
+ Inspect `boards capabilities --json` for supported Item types, animation channels, effect kinds, and coordinate spaces.
170
+
193
171
  ```bash
194
172
  cohub -s <spaceId> boards create boards/plan.board \
195
173
  --title "Plan" \
196
174
  --input board-content.json
197
175
  ```
198
176
 
199
- Transactions are JSON objects without `boardId`; the bound Board supplies it.
200
- `txId` is generated when omitted, while `baseVersion` must be provided in the
201
- input or with `--base-version`:
202
-
203
- ```json
204
- {
205
- "baseVersion": 3,
206
- "operations": [
207
- {
208
- "type": "board.patch",
209
- "payload": { "patch": { "title": "Updated plan" } }
210
- }
211
- ]
212
- }
177
+ Generate editable semantic JSON templates instead of authoring storage transactions:
178
+
179
+ ```bash
180
+ cohub boards examples item text > item.json
181
+ cohub boards items create <boardId> --input item.json
182
+
183
+ cohub boards examples composition fade > intro.json
184
+ cohub boards compositions apply <boardId> --input intro.json
185
+
186
+ cohub boards examples effect pulse > effect.json
187
+ cohub boards effects apply <boardId> --input effect.json
213
188
  ```
214
189
 
190
+ Use `--mutation-id` or `--command-id` when a script needs a stable idempotency key across retries.
191
+
215
192
  ```bash
216
- cohub -s <spaceId> boards validate <boardId> --input transaction.json
217
- cat transaction.json | cohub -s <spaceId> boards apply <boardId> --input - --json
218
- cohub -s <spaceId> boards play <boardId> <sequenceId>
193
+ cohub -s <spaceId> boards play <boardId> <compositionId>
219
194
  cohub -s <spaceId> boards seek <boardId> <playbackId> 400
220
195
  cohub -s <spaceId> boards stop <boardId> <playbackId>
221
196
  ```
222
197
 
223
- Pass `--tx-id` or `--command-id` when a script needs a stable idempotency key
224
- across retries.
225
-
226
198
  ## Search
227
199
 
228
200
  Search Spaces, Chats, and prior turns:
@@ -9,7 +9,7 @@
9
9
  import { existsSync } from "node:fs";
10
10
  import { createRequire } from "node:module";
11
11
  import { dirname, join } from "node:path";
12
- import { boardBootstrapToDocument, boardImageKeySource, imageAssetKey, planBoardExport, selectBoardExportAssets, } from "@neta-art/cohub/board";
12
+ import { boardAuthoringSnapshotToDocument, boardImageKeySource, imageAssetKey, planBoardExport, selectBoardExportAssets, } from "@neta-art/cohub/board";
13
13
  import { createBoardHeadlessRenderer, exportBoardImageBytes, } from "@neta-art/cohub/board/headless";
14
14
  import { resolveBoardId } from "./board-command-support.js";
15
15
  import { createClient } from "./client.js";
@@ -64,11 +64,11 @@ export function resolveBundledFonts() {
64
64
  export async function loadBoardDocument(spaceId, target) {
65
65
  const client = createClient();
66
66
  const boardId = await resolveBoardId(spaceId, target);
67
- const bootstrap = await client.space(spaceId).board(boardId).inspect({ include: ["nodes"] });
67
+ const snapshot = await client.space(spaceId).board(boardId).authoring({ include: ["items", "connections"] });
68
68
  return {
69
- document: boardBootstrapToDocument(bootstrap),
70
- boardId: bootstrap.board.id,
71
- title: bootstrap.board.title ?? null,
69
+ document: boardAuthoringSnapshotToDocument(snapshot),
70
+ boardId: snapshot.board.id,
71
+ title: snapshot.board.title ?? null,
72
72
  };
73
73
  }
74
74
  /**
@@ -1,8 +1,12 @@
1
1
  import { registerBoardAnimationCommands } from "./boards/animation.js";
2
2
  import { registerBoardAppearanceCommands } from "./boards/appearance.js";
3
+ import { registerBoardExampleCommands } from "./boards/examples.js";
4
+ import { registerBoardItemCommands } from "./boards/items.js";
3
5
  import { registerBoardNodeCommands } from "./boards/nodes.js";
4
6
  export function registerBoardDomainCommands(boards) {
7
+ registerBoardExampleCommands(boards);
5
8
  registerBoardAppearanceCommands(boards);
6
9
  registerBoardNodeCommands(boards);
10
+ registerBoardItemCommands(boards);
7
11
  registerBoardAnimationCommands(boards);
8
12
  }
@@ -1,28 +1,18 @@
1
- import { randomUUID } from "node:crypto";
2
- import { BoardCameraFocusParamsSchema, boardEffectDeleteOperation, boardEffectUpsertOperation, boardSequenceDeleteOperation, boardSequenceUpsertOperation, } from "@neta-art/cohub/board";
1
+ import { parseBoardEffectInput, parseBoardCompositionInput, } from "@neta-art/cohub";
3
2
  import { BOARD_DOMAIN_INPUT_MAX_BYTES, readBoardJsonObject, } from "../../board-command-support.js";
4
3
  import { handleHttp, json, jsonRequested, table } from "../../output.js";
5
- import { finite, resolvedBoard, showUpdated, withJson, } from "./context.js";
6
- function rect(value) {
7
- const parts = value.split(",").map(Number);
8
- if (parts.length !== 4 || parts.some((part) => !Number.isFinite(part))) {
9
- throw new Error("--rect must be x,y,width,height");
10
- }
11
- const [x, y, width, height] = parts;
12
- if (width <= 0 || height <= 0)
13
- throw new Error("--rect width and height must be positive");
14
- return { x, y, width, height };
15
- }
4
+ import { mutateSemantic, resolvedBoard, showUpdated, withJson, } from "./context.js";
16
5
  export function registerBoardAnimationCommands(boards) {
17
6
  const effects = boards.command("effects").description("Manage Board effects");
18
7
  withJson(effects.command("list <board>").alias("ls").description("List effects"))
19
8
  .action(async (target, options) => {
20
9
  try {
21
10
  const board = await resolvedBoard(boards, target);
22
- const result = await board.inspect({ include: ["effects"] });
11
+ const result = await board.authoring({ include: ["effects"] });
12
+ const effects = result.effects ?? [];
23
13
  if (jsonRequested(options))
24
- return json(result.effects);
25
- table(result.effects, [
14
+ return json(effects);
15
+ table(effects, [
26
16
  { key: "id", label: "ID" },
27
17
  { key: "kind", label: "KIND" },
28
18
  { key: "enabled", label: "ENABLED" },
@@ -33,19 +23,21 @@ export function registerBoardAnimationCommands(boards) {
33
23
  handleHttp(cause);
34
24
  }
35
25
  });
36
- withJson(effects.command("upsert <board>")
37
- .description("Create or replace an effect")
26
+ withJson(effects.command("apply <board>")
27
+ .description("Atomically create or replace an effect")
38
28
  .requiredOption("-i, --input <file>", "Board effect JSON; use - for stdin")
39
29
  .addHelpText("after", `
40
- Minimal pulse effect:
41
- {"id":"pulse-title","target":{"type":"node","nodeId":"title"},"kind":"effects.pulse","kindVersion":1,"lifecycle":"when-visible","timeOrigin":"visible","seed":"pulse-title"}
30
+ Create a template:
31
+ cohub boards examples effect pulse > effect.json
42
32
 
43
- Run boards capabilities <board> to discover supported effect kinds.`))
33
+ Discover supported effect kinds:
34
+ cohub boards capabilities <board> --json`))
44
35
  .action(async (target, options) => {
45
36
  try {
46
- const effect = await readBoardJsonObject(options.input, BOARD_DOMAIN_INPUT_MAX_BYTES);
37
+ const input = await readBoardJsonObject(options.input, BOARD_DOMAIN_INPUT_MAX_BYTES);
38
+ const effect = parseBoardEffectInput(input);
47
39
  const board = await resolvedBoard(boards, target);
48
- showUpdated(await board.mutate({ build: () => [boardEffectUpsertOperation(effect)] }), options);
40
+ showUpdated(await mutateSemantic(board, [{ type: "effect.apply", effect }]), options);
49
41
  }
50
42
  catch (cause) {
51
43
  handleHttp(cause);
@@ -55,24 +47,36 @@ Run boards capabilities <board> to discover supported effect kinds.`))
55
47
  .action(async (target, effectId, options) => {
56
48
  try {
57
49
  const board = await resolvedBoard(boards, target);
58
- showUpdated(await board.mutate({ build: () => [boardEffectDeleteOperation(effectId)] }), options);
50
+ showUpdated(await mutateSemantic(board, [{ type: "effect.delete", effectId }]), options);
59
51
  }
60
52
  catch (cause) {
61
53
  handleHttp(cause);
62
54
  }
63
55
  });
64
- const sequences = boards.command("sequences").description("Manage Board sequences");
65
- withJson(sequences.command("list <board>").alias("ls").description("List sequences"))
56
+ const compositions = boards
57
+ .command("compositions")
58
+ .description("Manage atomic Board animation compositions");
59
+ withJson(compositions.command("list <board>").alias("ls").description("List compositions"))
66
60
  .action(async (target, options) => {
67
61
  try {
68
62
  const board = await resolvedBoard(boards, target);
69
- const result = await board.inspect({ include: ["sequences"] });
63
+ const result = await board.authoring({ include: ["compositions"] });
64
+ const compositions = result.compositions ?? [];
70
65
  if (jsonRequested(options))
71
- return json(result.sequences);
72
- table(result.sequences, [
66
+ return json(compositions);
67
+ table(compositions.map((composition) => ({
68
+ id: composition.id,
69
+ name: composition.name,
70
+ duration: composition.timeline.duration,
71
+ tracks: composition.timeline.tracks.length,
72
+ clips: composition.timeline.clips.length,
73
+ revision: composition.revision,
74
+ })), [
73
75
  { key: "id", label: "ID" },
74
76
  { key: "name", label: "NAME" },
75
77
  { key: "duration", label: "DURATION" },
78
+ { key: "tracks", label: "TRACKS" },
79
+ { key: "clips", label: "CLIPS" },
76
80
  { key: "revision", label: "REVISION" },
77
81
  ]);
78
82
  }
@@ -80,145 +84,64 @@ Run boards capabilities <board> to discover supported effect kinds.`))
80
84
  handleHttp(cause);
81
85
  }
82
86
  });
83
- withJson(sequences.command("get <board> <sequence-id>").description("Get a sequence and its clips"))
84
- .action(async (target, sequenceId, options) => {
87
+ withJson(compositions.command("get <board> <composition-id>").description("Get one complete composition"))
88
+ .action(async (target, compositionId, options) => {
85
89
  try {
86
90
  const board = await resolvedBoard(boards, target);
87
- const result = await board.inspect({ include: ["sequences", "clips"] });
88
- const sequence = result.sequences.find((item) => item.id === sequenceId);
89
- if (!sequence)
90
- throw new Error(`Sequence not found: ${sequenceId}`);
91
- const output = {
92
- sequence,
93
- clips: result.clips.filter((clip) => clip.sequenceId === sequenceId),
94
- };
91
+ const result = await board.authoring({ include: ["compositions"] });
92
+ const composition = (result.compositions ?? []).find((item) => item.id === compositionId);
93
+ if (!composition)
94
+ throw new Error(`Composition not found: ${compositionId}`);
95
95
  if (jsonRequested(options))
96
- return json(output);
97
- table([sequence], [
96
+ return json(composition);
97
+ table([{
98
+ id: composition.id,
99
+ name: composition.name,
100
+ duration: composition.timeline.duration,
101
+ tracks: composition.timeline.tracks.length,
102
+ clips: composition.timeline.clips.length,
103
+ revision: composition.revision,
104
+ }], [
98
105
  { key: "id", label: "ID" },
99
106
  { key: "name", label: "NAME" },
100
107
  { key: "duration", label: "DURATION" },
108
+ { key: "tracks", label: "TRACKS" },
109
+ { key: "clips", label: "CLIPS" },
101
110
  { key: "revision", label: "REVISION" },
102
111
  ]);
103
- table(output.clips, [
104
- { key: "id", label: "CLIP" },
105
- { key: "kind", label: "KIND" },
106
- { key: "start", label: "START" },
107
- { key: "duration", label: "DURATION" },
108
- ]);
109
112
  }
110
113
  catch (cause) {
111
114
  handleHttp(cause);
112
115
  }
113
116
  });
114
- withJson(sequences.command("upsert <board>")
115
- .description("Create or replace a sequence and clips")
116
- .requiredOption("-i, --input <file>", "{ sequence, clips } JSON; use - for stdin")
117
+ withJson(compositions.command("apply <board>")
118
+ .description("Atomically create or replace a composition")
119
+ .requiredOption("-i, --input <file>", "BoardComposition JSON; use - for stdin")
117
120
  .addHelpText("after", `
118
- Minimal sequence with one clip:
119
- {"sequence":{"id":"intro","name":"Intro","duration":1200,"seed":"intro"},"clips":[{"id":"reveal-title","kind":"text.reveal","kindVersion":1,"target":{"type":"node","nodeId":"title"},"start":0,"duration":600,"seed":"reveal-title"}]}
120
-
121
- Coordinate rules:
122
- node motion x/y and path points are Board-world offsets; camera.pan x/y are screen-pixel offsets.
121
+ Property changes use timeline.tracks with registered channels and keyframes.
122
+ Procedural behavior such as text reveal, particles, and camera focus uses timeline.clips.
123
+ Run boards capabilities to discover channels and clip schemas.
124
+ Create an editable template with:
125
+ cohub boards examples composition fade > intro.json
123
126
 
124
- Edit an existing sequence:
125
- cohub boards sequences get <board> intro --json > intro.json
126
- cohub boards sequences upsert <board> -i intro.json`))
127
+ Minimal fade composition:
128
+ {"id":"intro","name":"Intro","timeline":{"duration":800,"tracks":[{"id":"title-opacity","target":{"type":"item","itemId":"title"},"channel":"style.opacity","fill":"both","keyframes":[{"time":0,"value":0},{"time":800,"value":1,"easing":"ease-out-cubic"}]}],"clips":[],"markers":[]},"playback":{"loop":false,"endBehavior":"hold","reducedMotion":{"mode":"base"}}}`))
127
129
  .action(async (target, options) => {
128
130
  try {
129
131
  const input = await readBoardJsonObject(options.input, BOARD_DOMAIN_INPUT_MAX_BYTES);
132
+ const composition = parseBoardCompositionInput(input);
130
133
  const board = await resolvedBoard(boards, target);
131
- showUpdated(await board.mutate({ build: () => [boardSequenceUpsertOperation(input)] }), options);
132
- }
133
- catch (cause) {
134
- handleHttp(cause);
135
- }
136
- });
137
- withJson(sequences.command("camera-focus <board> <sequence-id>")
138
- .description("Add or replace a semantic camera focus clip")
139
- .option("--id <id>", "Stable clip id")
140
- .option("--node <id>", "Focus one node")
141
- .option("--nodes <ids>", "Focus comma-separated nodes")
142
- .option("--frame <id>", "Focus a frame")
143
- .option("--rect <rect>", "Board world rect as x,y,width,height")
144
- .requiredOption("--at <ms>", "Clip start time")
145
- .option("--duration <ms>", "Transition duration", "700")
146
- .option("--padding <px>", "Screen padding in CSS pixels", "32")
147
- .option("--fit <mode>", "contain or cover", "contain")
148
- .option("--min-zoom <zoom>", "Minimum zoom multiplier")
149
- .option("--max-zoom <zoom>", "Maximum zoom multiplier")
150
- .option("--easing <name>", "Easing", "ease-out-cubic")
151
- .addHelpText("after", `
152
- Examples:
153
- cohub boards sequences camera-focus plan.board intro --node hero --at 1200
154
- cohub boards sequences camera-focus plan.board intro --rect 120,80,640,360 --at 2000 --duration 800`))
155
- .action(async (target, sequenceId, options) => {
156
- try {
157
- const selected = [options.node, options.nodes, options.frame, options.rect].filter(Boolean);
158
- if (selected.length !== 1)
159
- throw new Error("Choose one of --node, --nodes, --frame, or --rect");
160
- const focus = options.node
161
- ? { type: "node", nodeId: options.node }
162
- : options.nodes
163
- ? { type: "nodes", nodeIds: options.nodes.split(",").map((id) => id.trim()).filter(Boolean) }
164
- : options.frame
165
- ? { type: "frame", frameId: options.frame }
166
- : { type: "rect", rect: rect(options.rect) };
167
- const params = BoardCameraFocusParamsSchema.parse({
168
- focus,
169
- fit: options.fit,
170
- padding: finite(options.padding, "padding"),
171
- ...(options.minZoom === undefined ? {} : { minZoom: finite(options.minZoom, "min zoom") }),
172
- ...(options.maxZoom === undefined ? {} : { maxZoom: finite(options.maxZoom, "max zoom") }),
173
- });
174
- const start = finite(options.at, "start");
175
- const duration = finite(options.duration, "duration");
176
- if (start < 0 || duration <= 0)
177
- throw new Error("start must be non-negative and duration must be positive");
178
- const clipId = options.id ?? randomUUID();
179
- const board = await resolvedBoard(boards, target);
180
- showUpdated(await board.mutate({
181
- include: ["sequences", "clips"],
182
- build(current) {
183
- const currentSequence = current.sequences.find((sequence) => sequence.id === sequenceId);
184
- if (!currentSequence)
185
- throw new Error(`Sequence not found: ${sequenceId}`);
186
- const { boardId: _boardId, revision: _revision, ...sequence } = currentSequence;
187
- const clips = current.clips
188
- .filter((clip) => clip.sequenceId === sequenceId && clip.id !== clipId)
189
- .map(({ sequenceId: _sequenceId, ...clip }) => clip);
190
- clips.push({
191
- id: clipId,
192
- kind: "camera.focus",
193
- kindVersion: 1,
194
- target: { type: "camera" },
195
- start,
196
- duration,
197
- layer: "screen",
198
- fill: "forwards",
199
- easing: options.easing,
200
- params,
201
- keyframes: [],
202
- assetRefs: [],
203
- seed: clipId,
204
- metadata: {},
205
- });
206
- return [boardSequenceUpsertOperation({
207
- sequence: { ...sequence, duration: Math.max(sequence.duration, start + duration) },
208
- clips,
209
- })];
210
- },
211
- }), options);
134
+ showUpdated(await mutateSemantic(board, [{ type: "composition.apply", composition }]), options);
212
135
  }
213
136
  catch (cause) {
214
137
  handleHttp(cause);
215
138
  }
216
139
  });
217
- withJson(sequences.command("delete <board> <sequence-id>").alias("rm").description("Delete a sequence and its clips"))
218
- .action(async (target, sequenceId, options) => {
140
+ withJson(compositions.command("delete <board> <composition-id>").alias("rm").description("Delete a composition"))
141
+ .action(async (target, compositionId, options) => {
219
142
  try {
220
143
  const board = await resolvedBoard(boards, target);
221
- showUpdated(await board.mutate({ build: () => [boardSequenceDeleteOperation(sequenceId)] }), options);
144
+ showUpdated(await mutateSemantic(board, [{ type: "composition.delete", compositionId }]), options);
222
145
  }
223
146
  catch (cause) {
224
147
  handleHttp(cause);
@@ -1,6 +1,6 @@
1
- import { BoardAppearanceSchema, DEFAULT_BOARD_APPEARANCE, boardAppearanceOperation, boardPlaybackPolicyOperation, boardTitleOperation, patchBoardAppearance, } from "@neta-art/cohub/board";
1
+ import { BoardAppearanceSchema, DEFAULT_BOARD_APPEARANCE, patchBoardAppearance, } from "@neta-art/cohub/board";
2
2
  import { handleHttp } from "../../output.js";
3
- import { finite, resolvedBoard, showUpdated, withJson, } from "./context.js";
3
+ import { finite, mutateSemantic, resolvedBoard, showUpdated, withJson, } from "./context.js";
4
4
  function appearanceFrom(metadata) {
5
5
  const parsed = BoardAppearanceSchema.safeParse(metadata.appearance);
6
6
  return parsed.success ? parsed.data : DEFAULT_BOARD_APPEARANCE;
@@ -10,7 +10,7 @@ export function registerBoardAppearanceCommands(boards) {
10
10
  .action(async (target, title, options) => {
11
11
  try {
12
12
  const board = await resolvedBoard(boards, target);
13
- showUpdated(await board.mutate({ build: () => [boardTitleOperation(title)] }), options);
13
+ showUpdated(await mutateSemantic(board, [{ type: "board.patch", patch: { title } }]), options);
14
14
  }
15
15
  catch (cause) {
16
16
  handleHttp(cause);
@@ -41,24 +41,24 @@ Examples:
41
41
  if (opacity < 0 || opacity > 1)
42
42
  throw new Error("opacity must be between 0 and 1");
43
43
  const board = await resolvedBoard(boards, target);
44
- const result = await board.mutate({
45
- build(current) {
46
- const appearance = appearanceFrom(current.board.metadata);
47
- const background = options.reset
48
- ? { kind: "solid" }
49
- : options.color
50
- ? { kind: "solid", color: options.color }
51
- : {
52
- kind: "image",
53
- imageUrl: options.image,
54
- fit: options.fit,
55
- position: options.position,
56
- opacity,
57
- color: appearance.background.color,
58
- };
59
- return [boardAppearanceOperation(patchBoardAppearance(appearance, { background }))];
60
- },
61
- });
44
+ const current = await board.summary();
45
+ const appearance = appearanceFrom(current.board.metadata);
46
+ const background = options.reset
47
+ ? { kind: "solid" }
48
+ : options.color
49
+ ? { kind: "solid", color: options.color }
50
+ : {
51
+ kind: "image",
52
+ imageUrl: options.image,
53
+ fit: options.fit,
54
+ position: options.position,
55
+ opacity,
56
+ color: appearance.background.color,
57
+ };
58
+ const result = await mutateSemantic(board, [{
59
+ type: "board.patch",
60
+ patch: { metadataPatch: { appearance: patchBoardAppearance(appearance, { background }) } },
61
+ }], { baseVersion: current.board.version });
62
62
  showUpdated(result, options);
63
63
  }
64
64
  catch (cause) {
@@ -67,24 +67,27 @@ Examples:
67
67
  });
68
68
  withJson(boards.command("playback-policy <board>")
69
69
  .description("Configure automatic Board playback")
70
- .option("--sequence <id>", "Sequence to play")
70
+ .option("--composition <id>", "Composition to play")
71
71
  .option("--delay <ms>", "Delay before playback", "0")
72
- .option("--loop", "Loop the sequence")
73
72
  .option("--clear", "Remove the playback policy"))
74
73
  .action(async (target, options) => {
75
74
  try {
76
- if (options.clear === Boolean(options.sequence))
77
- throw new Error("Use --sequence or --clear");
75
+ if (options.clear === Boolean(options.composition))
76
+ throw new Error("Use --composition or --clear");
78
77
  const delayMs = finite(options.delay, "delay");
79
78
  if (delayMs < 0)
80
79
  throw new Error("delay must be non-negative");
81
80
  const policy = options.clear
82
81
  ? null
83
- : { sequenceId: options.sequence, delayMs, loop: Boolean(options.loop) };
82
+ : { compositionId: options.composition, delayMs };
84
83
  const board = await resolvedBoard(boards, target);
85
- showUpdated(await board.mutate({
86
- build: (current) => [boardPlaybackPolicyOperation(current.board.metadata, policy)],
87
- }), options);
84
+ const current = await board.summary();
85
+ const patch = policy
86
+ ? { metadataPatch: { playback: policy } }
87
+ : { metadata: { ...current.board.metadata, playback: undefined } };
88
+ if (!policy)
89
+ delete patch.metadata.playback;
90
+ showUpdated(await mutateSemantic(board, [{ type: "board.patch", patch }], { baseVersion: current.board.version }), options);
88
91
  }
89
92
  catch (cause) {
90
93
  handleHttp(cause);
@@ -1,12 +1,28 @@
1
+ import type { BoardClient, BoardSemanticCommand } from "@neta-art/cohub";
1
2
  import type { Command } from "commander";
2
3
  export type JsonOptions = {
3
4
  json?: boolean;
4
5
  };
5
6
  export declare function withJson(command: Command): Command;
6
7
  export declare function finite(value: string | undefined, name: string, fallback?: number): number;
7
- export declare function resolvedBoard(boards: Command, target: string): Promise<import("@neta-art/cohub").BoardClient>;
8
+ export declare function resolvedBoard(boards: Command, target: string): Promise<BoardClient>;
9
+ /**
10
+ * Send semantic commands with one automatic version-conflict retry: a racing
11
+ * edit between our version read and the submit is transparently rebased (same
12
+ * commands, fresh baseVersion, same id — a retry that lands is idempotent).
13
+ * `options.baseVersion` lets an agent chain mutations without re-reading the
14
+ * board between writes.
15
+ */
16
+ export declare function mutateSemantic(board: BoardClient, commands: BoardSemanticCommand[], options?: {
17
+ baseVersion?: number;
18
+ mutationId?: string;
19
+ dryRun?: boolean;
20
+ }): Promise<import("@neta-art/cohub").BoardMutationReceipt>;
8
21
  export declare function showUpdated(result: {
9
22
  board: {
10
23
  version: number;
11
24
  };
25
+ status?: string;
26
+ outcome?: string;
27
+ replayed?: boolean;
12
28
  }, options: JsonOptions): void;
@@ -18,8 +18,45 @@ export async function resolvedBoard(boards, target) {
18
18
  const boardId = await resolveBoardId(spaceId, target);
19
19
  return createClient().space(spaceId).board(boardId);
20
20
  }
21
+ /**
22
+ * Send semantic commands with one automatic version-conflict retry: a racing
23
+ * edit between our version read and the submit is transparently rebased (same
24
+ * commands, fresh baseVersion, same id — a retry that lands is idempotent).
25
+ * `options.baseVersion` lets an agent chain mutations without re-reading the
26
+ * board between writes.
27
+ */
28
+ export async function mutateSemantic(board, commands, options = {}) {
29
+ const baseVersion = options.baseVersion ?? (await board.summary()).board.version;
30
+ const send = (version) => board.mutateSemantic({
31
+ mutationId: options.mutationId,
32
+ baseVersion: version,
33
+ dryRun: options.dryRun ?? false,
34
+ commands,
35
+ });
36
+ try {
37
+ return await send(baseVersion);
38
+ }
39
+ catch (cause) {
40
+ if (options.dryRun || !isVersionConflict(cause))
41
+ throw cause;
42
+ const retry = (await board.summary()).board.version;
43
+ return send(retry);
44
+ }
45
+ }
46
+ function isVersionConflict(error) {
47
+ return Boolean(error && typeof error === "object" &&
48
+ error.code === "VERSION_CONFLICT");
49
+ }
21
50
  export function showUpdated(result, options) {
22
51
  if (jsonRequested(options))
23
52
  return json(result);
24
- ok(`Board updated to version ${result.board.version}`);
53
+ if (result.outcome === "dry-run") {
54
+ ok(`Validated against Board version ${result.board.version}; no changes written`);
55
+ return;
56
+ }
57
+ if (result.outcome === "noop" || result.status === "validated") {
58
+ ok(`Board already matches the requested state at version ${result.board.version}`);
59
+ return;
60
+ }
61
+ ok(`Board updated to version ${result.board.version}${result.replayed ? " (replayed)" : ""}`);
25
62
  }
@@ -0,0 +1,4 @@
1
+ import type { Command } from "commander";
2
+ export declare const BOARD_EXAMPLE_KEYS: string[];
3
+ export declare function boardExample(kind: string, type?: string): unknown;
4
+ export declare function registerBoardExampleCommands(boards: Command): void;