@neta-art/cohub-cli 6.2.2 → 6.4.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,53 +157,57 @@ cohub -s <spaceId> spaces activity 365 --json
157
157
 
158
158
  ## Boards
159
159
 
160
- Board commands use the selected Space and support `-h` at every level:
160
+ Board targets accept a Board ID or a `.board` path. Every command supports `-h` and `--json`:
161
161
 
162
162
  ```bash
163
163
  cohub boards -h
164
- cohub boards inspect -h
165
- cohub -s <spaceId> boards create boards/plan.board --title "Plan"
166
- cohub -s <spaceId> boards inspect <boardId> --json
164
+ cohub -s <spaceId> boards inspect boards/plan.board --json
165
+ cohub -s <spaceId> boards items list <boardId>
166
+ cohub -s <spaceId> boards connections list <boardId>
167
167
  cohub -s <spaceId> boards capabilities <boardId>
168
168
  cohub -s <spaceId> boards watch <boardId> --json
169
169
  ```
170
170
 
171
- 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:
171
+ Use targeted reads for large Boards:
172
172
 
173
173
  ```bash
174
- cohub boards examples create > board-content.json
175
- cohub boards examples item geo > item.json
176
- cohub boards examples effect pulse > effect.json
177
- cohub boards examples composition fade > intro.json
178
- ```
179
-
180
- Inspect `boards capabilities --json` for supported Item types, animation channels, effect kinds, and coordinate spaces.
181
-
182
- ```bash
183
- cohub -s <spaceId> boards create boards/plan.board \
184
- --title "Plan" \
185
- --input board-content.json
174
+ cohub -s <spaceId> boards items get <boardId> <itemId> --json
175
+ cohub -s <spaceId> boards connections get <boardId> <connectionId> --json
176
+ cohub -s <spaceId> boards effects get <boardId> <effectId> --json
177
+ cohub -s <spaceId> boards compositions get <boardId> <compositionId> --json
186
178
  ```
187
179
 
188
- Generate editable semantic JSON templates instead of authoring storage transactions:
180
+ Create semantic JSON from an example, then apply it to one resource:
189
181
 
190
182
  ```bash
191
183
  cohub boards examples item text > item.json
192
- cohub boards items create <boardId> --input item.json
184
+ cohub -s <spaceId> boards items create <boardId> --input item.json
193
185
 
194
186
  cohub boards examples composition fade > intro.json
195
- cohub boards compositions apply <boardId> --input intro.json
187
+ cohub -s <spaceId> boards compositions apply <boardId> --input intro.json
188
+ ```
189
+
190
+ Apply related changes atomically with one request. The batch contains semantic commands, not a full Board snapshot:
196
191
 
197
- cohub boards examples effect pulse > effect.json
198
- cohub boards effects apply <boardId> --input effect.json
192
+ ```json
193
+ {"commands":[
194
+ {"type":"item.patch","itemId":"title","patch":{"props":{"text":"Updated"}}},
195
+ {"type":"connection.create","connection":{"id":"title-agent","source":{"itemId":"title"},"target":{"itemId":"agent"}}}
196
+ ]}
197
+ ```
198
+
199
+ ```bash
200
+ cohub boards examples batch basic > changes.json
201
+ cohub -s <spaceId> boards batch <boardId> --input changes.json --dry-run
202
+ cohub -s <spaceId> boards batch <boardId> --input changes.json
199
203
  ```
200
204
 
201
- Use `--mutation-id` or `--command-id` when a script needs a stable idempotency key across retries.
205
+ Use `--base-version` and `--mutation-id` for controlled, retry-safe scripts. Use `--dry-run` to validate without writing. Playback is grouped under `playback`:
202
206
 
203
207
  ```bash
204
- cohub -s <spaceId> boards play <boardId> <compositionId>
205
- cohub -s <spaceId> boards seek <boardId> <playbackId> 400
206
- cohub -s <spaceId> boards stop <boardId> <playbackId>
208
+ cohub -s <spaceId> boards playback play <boardId> <compositionId>
209
+ cohub -s <spaceId> boards playback seek <boardId> <playbackId> 400
210
+ cohub -s <spaceId> boards playback stop <boardId> <playbackId>
207
211
  ```
208
212
 
209
213
  ## Search
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Board image export for the CLI.
3
3
  *
4
- * Rendering runs through the same PixiJS card renderers the web editor uses (see
5
- * `@neta-art/cohub/board`), on a Canvas2D backend. Everything platform-specific
4
+ * The CLI and web editor share canonical Board geometry and card semantics. Each
5
+ * host selects the renderer primitive suited to its backend; this path uses
6
+ * Canvas2D. Everything platform-specific
6
7
  * lives here: fetching the document over HTTP, pulling image bytes out of the
7
8
  * space, and locating fonts on disk.
8
9
  */
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Board image export for the CLI.
3
3
  *
4
- * Rendering runs through the same PixiJS card renderers the web editor uses (see
5
- * `@neta-art/cohub/board`), on a Canvas2D backend. Everything platform-specific
4
+ * The CLI and web editor share canonical Board geometry and card semantics. Each
5
+ * host selects the renderer primitive suited to its backend; this path uses
6
+ * Canvas2D. Everything platform-specific
6
7
  * lives here: fetching the document over HTTP, pulling image bytes out of the
7
8
  * space, and locating fonts on disk.
8
9
  */
@@ -1,12 +1,14 @@
1
1
  import { registerBoardAnimationCommands } from "./boards/animation.js";
2
2
  import { registerBoardAppearanceCommands } from "./boards/appearance.js";
3
3
  import { registerBoardExampleCommands } from "./boards/examples.js";
4
+ import { registerBoardBatchCommand } from "./boards/batch.js";
5
+ import { registerBoardConnectionCommands } from "./boards/connections.js";
4
6
  import { registerBoardItemCommands } from "./boards/items.js";
5
- import { registerBoardNodeCommands } from "./boards/nodes.js";
6
7
  export function registerBoardDomainCommands(boards) {
7
8
  registerBoardExampleCommands(boards);
8
9
  registerBoardAppearanceCommands(boards);
9
- registerBoardNodeCommands(boards);
10
+ registerBoardBatchCommand(boards);
11
+ registerBoardConnectionCommands(boards);
10
12
  registerBoardItemCommands(boards);
11
13
  registerBoardAnimationCommands(boards);
12
14
  }
@@ -1,14 +1,14 @@
1
1
  import { parseBoardEffectInput, parseBoardCompositionInput, } from "@neta-art/cohub";
2
2
  import { BOARD_DOMAIN_INPUT_MAX_BYTES, readBoardJsonObject, } from "../../board-command-support.js";
3
3
  import { handleHttp, json, jsonRequested, table } from "../../output.js";
4
- import { mutateSemantic, resolvedBoard, showUpdated, withJson, } from "./context.js";
4
+ import { mutateSemantic, readInput, readOptions, resolvedBoard, showUpdated, withJson, } from "./context.js";
5
5
  export function registerBoardAnimationCommands(boards) {
6
6
  const effects = boards.command("effects").description("Manage Board effects");
7
- withJson(effects.command("list <board>").alias("ls").description("List effects"))
7
+ readOptions(withJson(effects.command("list <board>").alias("ls").description("List effects")))
8
8
  .action(async (target, options) => {
9
9
  try {
10
10
  const board = await resolvedBoard(boards, target);
11
- const result = await board.authoring({ include: ["effects"] });
11
+ const result = await board.authoring(readInput(options, "effects"));
12
12
  const effects = result.effects ?? [];
13
13
  if (jsonRequested(options))
14
14
  return json(effects);
@@ -23,6 +23,27 @@ export function registerBoardAnimationCommands(boards) {
23
23
  handleHttp(cause);
24
24
  }
25
25
  });
26
+ withJson(effects.command("get <board> <effect-id>").description("Get one complete Board effect"))
27
+ .action(async (target, effectId, options) => {
28
+ try {
29
+ const board = await resolvedBoard(boards, target);
30
+ const result = await board.authoring({ include: ["effects"], effectIds: [effectId] });
31
+ const effect = result.effects?.[0];
32
+ if (!effect)
33
+ throw new Error(`Effect not found: ${effectId}`);
34
+ if (jsonRequested(options))
35
+ return json(effect);
36
+ table([effect], [
37
+ { key: "id", label: "ID" },
38
+ { key: "kind", label: "KIND" },
39
+ { key: "enabled", label: "ENABLED" },
40
+ { key: "layer", label: "LAYER" },
41
+ ]);
42
+ }
43
+ catch (cause) {
44
+ handleHttp(cause);
45
+ }
46
+ });
26
47
  withJson(effects.command("apply <board>")
27
48
  .description("Atomically create or replace an effect")
28
49
  .requiredOption("-i, --input <file>", "Board effect JSON; use - for stdin")
@@ -56,11 +77,11 @@ Discover supported effect kinds:
56
77
  const compositions = boards
57
78
  .command("compositions")
58
79
  .description("Manage atomic Board animation compositions");
59
- withJson(compositions.command("list <board>").alias("ls").description("List compositions"))
80
+ readOptions(withJson(compositions.command("list <board>").alias("ls").description("List compositions")))
60
81
  .action(async (target, options) => {
61
82
  try {
62
83
  const board = await resolvedBoard(boards, target);
63
- const result = await board.authoring({ include: ["compositions"] });
84
+ const result = await board.authoring(readInput(options, "compositions"));
64
85
  const compositions = result.compositions ?? [];
65
86
  if (jsonRequested(options))
66
87
  return json(compositions);
@@ -88,8 +109,8 @@ Discover supported effect kinds:
88
109
  .action(async (target, compositionId, options) => {
89
110
  try {
90
111
  const board = await resolvedBoard(boards, target);
91
- const result = await board.authoring({ include: ["compositions"] });
92
- const composition = (result.compositions ?? []).find((item) => item.id === compositionId);
112
+ const result = await board.authoring({ include: ["compositions"], compositionIds: [compositionId] });
113
+ const composition = result.compositions?.[0];
93
114
  if (!composition)
94
115
  throw new Error(`Composition not found: ${compositionId}`);
95
116
  if (jsonRequested(options))
@@ -0,0 +1,4 @@
1
+ import { type BoardSemanticCommand } from "@neta-art/cohub";
2
+ import type { Command } from "commander";
3
+ export declare function parseBatchCommands(input: Record<string, unknown>): BoardSemanticCommand[];
4
+ export declare function registerBoardBatchCommand(boards: Command): void;
@@ -0,0 +1,62 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { BoardSemanticCommandSchema } from "@neta-art/cohub";
3
+ import { BOARD_TRANSACTION_INPUT_MAX_BYTES, readBoardJsonObject, } from "../../board-command-support.js";
4
+ import { handleHttp, json, jsonRequested, ok } from "../../output.js";
5
+ import { mutateSemantic, resolvedBoard, withJson, } from "./context.js";
6
+ export function parseBatchCommands(input) {
7
+ if (!Array.isArray(input.commands) || input.commands.length === 0) {
8
+ throw new Error("batch input must contain a non-empty commands array");
9
+ }
10
+ return input.commands.map((command, index) => {
11
+ const parsed = BoardSemanticCommandSchema.safeParse(command);
12
+ if (!parsed.success) {
13
+ const issue = parsed.error.issues[0];
14
+ const path = issue?.path.length ? `.${issue.path.join(".")}` : "";
15
+ throw new Error(`commands[${index}]${path}: ${issue?.message ?? "invalid command"}`);
16
+ }
17
+ return parsed.data;
18
+ });
19
+ }
20
+ export function registerBoardBatchCommand(boards) {
21
+ withJson(boards.command("batch <board>")
22
+ .description("Apply an atomic batch of semantic Board changes")
23
+ .requiredOption("-i, --input <file>", "Batch JSON with a commands array; use - for stdin")
24
+ .option("--base-version <version>", "Expected Board version; defaults to latest")
25
+ .option("--mutation-id <id>", "Stable id for safe retries")
26
+ .option("--dry-run", "Validate the whole batch without writing")
27
+ .addHelpText("after", `
28
+ Input format:
29
+ {"commands":[{"type":"item.patch","itemId":"title","patch":{"props":{"text":"Updated"}}}]}
30
+
31
+ Commands are applied atomically. See boards capabilities for supported fields.`))
32
+ .action(async (target, options) => {
33
+ try {
34
+ const input = await readBoardJsonObject(options.input, BOARD_TRANSACTION_INPUT_MAX_BYTES);
35
+ const commands = parseBatchCommands(input);
36
+ const parsedVersion = options.baseVersion === undefined ? undefined : Number(options.baseVersion);
37
+ if (parsedVersion !== undefined && (!Number.isSafeInteger(parsedVersion) || parsedVersion < 0)) {
38
+ throw new Error("base version must be a non-negative integer");
39
+ }
40
+ const board = await resolvedBoard(boards, target);
41
+ const result = await mutateSemantic(board, commands, {
42
+ ...(parsedVersion === undefined ? {} : { baseVersion: parsedVersion }),
43
+ mutationId: options.mutationId?.trim() || randomUUID(),
44
+ dryRun: Boolean(options.dryRun),
45
+ });
46
+ if (jsonRequested(options))
47
+ return json(result);
48
+ if (result.outcome === "dry-run") {
49
+ ok(`Validated ${commands.length} changes; no changes written`);
50
+ }
51
+ else if (result.outcome === "noop" || result.status === "validated") {
52
+ ok(`Board already matches the requested ${commands.length} changes`);
53
+ }
54
+ else {
55
+ ok(`${result.replayed ? "Replayed" : "Applied"} ${commands.length} changes at Board version ${result.board.version}`);
56
+ }
57
+ }
58
+ catch (cause) {
59
+ handleHttp(cause);
60
+ }
61
+ });
62
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from "commander";
2
+ export declare function registerBoardConnectionCommands(boards: Command): void;
@@ -0,0 +1,95 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { createBoardConnection } from "@neta-art/cohub/board";
3
+ import { handleHttp, json, jsonRequested, table } from "../../output.js";
4
+ import { mutateSemantic, readInput, readOptions, resolvedBoard, showUpdated, withJson, } from "./context.js";
5
+ const columns = [
6
+ { key: "id", label: "ID" },
7
+ { key: "source", label: "SOURCE" },
8
+ { key: "target", label: "TARGET" },
9
+ { key: "relation", label: "RELATION" },
10
+ { key: "direction", label: "DIRECTION" },
11
+ { key: "label", label: "LABEL" },
12
+ ];
13
+ function rows(connections) {
14
+ return connections.map((connection) => ({
15
+ id: connection.id,
16
+ source: connection.source.itemId,
17
+ target: connection.target.itemId,
18
+ relation: connection.relation ?? "",
19
+ direction: connection.direction ?? "",
20
+ label: connection.label ?? "",
21
+ }));
22
+ }
23
+ export function registerBoardConnectionCommands(boards) {
24
+ const connections = boards.command("connections").description("Manage Board connections");
25
+ readOptions(withJson(connections.command("list <board>").alias("ls").description("List Board connections")))
26
+ .action(async (target, options) => {
27
+ try {
28
+ const board = await resolvedBoard(boards, target);
29
+ const result = await board.authoring(readInput(options, "connections"));
30
+ const value = result.connections ?? [];
31
+ if (jsonRequested(options))
32
+ return json(value);
33
+ table(rows(value), columns);
34
+ }
35
+ catch (cause) {
36
+ handleHttp(cause);
37
+ }
38
+ });
39
+ withJson(connections.command("get <board> <connection-id>").description("Get one complete Board connection"))
40
+ .action(async (target, connectionId, options) => {
41
+ try {
42
+ const board = await resolvedBoard(boards, target);
43
+ const result = await board.authoring({ include: ["connections"], connectionIds: [connectionId] });
44
+ const connection = result.connections?.[0];
45
+ if (!connection)
46
+ throw new Error(`Connection not found: ${connectionId}`);
47
+ if (jsonRequested(options))
48
+ return json(connection);
49
+ table(rows([connection]), columns);
50
+ }
51
+ catch (cause) {
52
+ handleHttp(cause);
53
+ }
54
+ });
55
+ withJson(connections.command("create <board> <source> <target>").description("Create a Board connection")
56
+ .option("--id <id>", "Connection id")
57
+ .option("--relation <relation>", "Relation type")
58
+ .option("--direction <direction>", "none, forward, backward, or both", "forward")
59
+ .option("--label <label>", "Connection label")
60
+ .option("--source-port <id>", "Source port id")
61
+ .option("--target-port <id>", "Target port id"))
62
+ .action(async (target, source, destination, options) => {
63
+ try {
64
+ const direction = options.direction ?? "forward";
65
+ if (!["none", "forward", "backward", "both"].includes(direction)) {
66
+ throw new Error("--direction must be none, forward, backward, or both");
67
+ }
68
+ const board = await resolvedBoard(boards, target);
69
+ const connection = createBoardConnection({
70
+ id: options.id ?? randomUUID(),
71
+ sourceItemId: source,
72
+ targetItemId: destination,
73
+ relation: options.relation,
74
+ direction: direction,
75
+ label: options.label,
76
+ sourcePortId: options.sourcePort,
77
+ targetPortId: options.targetPort,
78
+ });
79
+ showUpdated(await mutateSemantic(board, [{ type: "connection.create", connection }]), options);
80
+ }
81
+ catch (cause) {
82
+ handleHttp(cause);
83
+ }
84
+ });
85
+ withJson(connections.command("delete <board> <connection-id>").alias("rm").description("Delete a Board connection"))
86
+ .action(async (target, connectionId, options) => {
87
+ try {
88
+ const board = await resolvedBoard(boards, target);
89
+ showUpdated(await mutateSemantic(board, [{ type: "connection.delete", connectionId }]), options);
90
+ }
91
+ catch (cause) {
92
+ handleHttp(cause);
93
+ }
94
+ });
95
+ }
@@ -1,17 +1,20 @@
1
- import type { BoardClient, BoardSemanticCommand } from "@neta-art/cohub";
1
+ import type { BoardAuthoringReadInput, BoardClient, BoardSemanticCommand } from "@neta-art/cohub";
2
2
  import type { Command } from "commander";
3
3
  export type JsonOptions = {
4
4
  json?: boolean;
5
5
  };
6
+ export type ReadOptions = JsonOptions & {
7
+ ids?: string;
8
+ };
6
9
  export declare function withJson(command: Command): Command;
7
10
  export declare function finite(value: string | undefined, name: string, fallback?: number): number;
11
+ export declare function readInput(options: ReadOptions, section: NonNullable<BoardAuthoringReadInput["include"]>[number]): BoardAuthoringReadInput;
12
+ export declare function readOptions(command: Command, label?: string): Command;
8
13
  export declare function resolvedBoard(boards: Command, target: string): Promise<BoardClient>;
9
14
  /**
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
+ * Send semantic commands with one automatic version-conflict retry when the
16
+ * caller did not pin a base version. An explicit `baseVersion` is a strict
17
+ * optimistic-concurrency check and must fail instead of being rebased.
15
18
  */
16
19
  export declare function mutateSemantic(board: BoardClient, commands: BoardSemanticCommand[], options?: {
17
20
  baseVersion?: number;
@@ -13,20 +13,29 @@ export function finite(value, name, fallback) {
13
13
  throw new Error(`${name} must be a finite number`);
14
14
  return result;
15
15
  }
16
+ export function readInput(options, section) {
17
+ const ids = options.ids?.split(",").map((id) => id.trim()).filter(Boolean);
18
+ return {
19
+ include: [section],
20
+ ...(ids?.length ? { [`${section.slice(0, -1)}Ids`]: ids } : {}),
21
+ };
22
+ }
23
+ export function readOptions(command, label = "Comma-separated resource IDs") {
24
+ return command.option("--ids <ids>", label);
25
+ }
16
26
  export async function resolvedBoard(boards, target) {
17
27
  const spaceId = resolveSpace(boards);
18
28
  const boardId = await resolveBoardId(spaceId, target);
19
29
  return createClient().space(spaceId).board(boardId);
20
30
  }
21
31
  /**
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.
32
+ * Send semantic commands with one automatic version-conflict retry when the
33
+ * caller did not pin a base version. An explicit `baseVersion` is a strict
34
+ * optimistic-concurrency check and must fail instead of being rebased.
27
35
  */
28
36
  export async function mutateSemantic(board, commands, options = {}) {
29
- const baseVersion = options.baseVersion ?? (await board.summary()).board.version;
37
+ const pinnedVersion = options.baseVersion;
38
+ const baseVersion = pinnedVersion ?? (await board.summary()).board.version;
30
39
  const send = (version) => board.mutateSemantic({
31
40
  mutationId: options.mutationId,
32
41
  baseVersion: version,
@@ -37,7 +46,7 @@ export async function mutateSemantic(board, commands, options = {}) {
37
46
  return await send(baseVersion);
38
47
  }
39
48
  catch (cause) {
40
- if (options.dryRun || !isVersionConflict(cause))
49
+ if (options.dryRun || pinnedVersion !== undefined || !isVersionConflict(cause))
41
50
  throw cause;
42
51
  const retry = (await board.summary()).board.version;
43
52
  return send(retry);
@@ -1,11 +1,18 @@
1
1
  import { json } from "../../output.js";
2
- const frame = { x: 120, y: 80, width: 320, height: 48, rotation: 0 };
3
2
  const templates = {
3
+ "batch:basic": {
4
+ commands: [
5
+ { type: "board.patch", patch: { title: "Updated title" } },
6
+ { type: "item.patch", itemId: "title", patch: { props: { text: "Updated text" } } },
7
+ ],
8
+ },
4
9
  "create": {
5
10
  items: [{
6
11
  id: "title",
7
12
  type: "text",
8
- frame,
13
+ position: { x: 120, y: 80 },
14
+ size: { width: 320, height: 48 },
15
+ rotation: 0,
9
16
  props: { text: "Launch plan", fontSize: 32 },
10
17
  style: { color: "brand" },
11
18
  }],
@@ -13,73 +20,233 @@ const templates = {
13
20
  effects: [],
14
21
  compositions: [],
15
22
  },
23
+ "create:workflow": {
24
+ items: [
25
+ {
26
+ id: "request",
27
+ type: "text",
28
+ position: { x: 80, y: 190 },
29
+ size: { width: 180, height: 48 },
30
+ rotation: 0,
31
+ props: { text: "Request", fontSize: 28 },
32
+ style: { color: "brand" },
33
+ },
34
+ {
35
+ id: "agent",
36
+ type: "geo",
37
+ position: { x: 360, y: 150 },
38
+ size: { width: 240, height: 120 },
39
+ rotation: 0,
40
+ props: { shape: "rounded", text: "Agent" },
41
+ style: { color: "blue", fillOpacity: 0.08 },
42
+ },
43
+ {
44
+ id: "result",
45
+ type: "geo",
46
+ position: { x: 700, y: 150 },
47
+ size: { width: 240, height: 120 },
48
+ rotation: 0,
49
+ props: { shape: "rounded", text: "Result" },
50
+ style: { color: "green", fillOpacity: 0.08 },
51
+ },
52
+ ],
53
+ connections: [
54
+ { id: "request-agent", source: { itemId: "request" }, target: { itemId: "agent" }, relation: "triggers", label: "invoke" },
55
+ { id: "agent-result", source: { itemId: "agent" }, target: { itemId: "result" }, relation: "produces", label: "output" },
56
+ ],
57
+ effects: [],
58
+ compositions: [],
59
+ },
60
+ "create:media": {
61
+ items: [
62
+ {
63
+ id: "hero",
64
+ type: "image",
65
+ position: { x: 80, y: 100 },
66
+ size: { width: 640, height: 360 },
67
+ rotation: 0,
68
+ source: { kind: "space-file", path: "assets/hero.webp" },
69
+ props: {},
70
+ },
71
+ {
72
+ id: "demo-video",
73
+ type: "video",
74
+ position: { x: 760, y: 100 },
75
+ size: { width: 480, height: 270 },
76
+ rotation: 0,
77
+ source: { kind: "space-file", path: "assets/demo.mp4" },
78
+ props: {},
79
+ },
80
+ {
81
+ id: "soundtrack",
82
+ type: "audio",
83
+ position: { x: 80, y: 500 },
84
+ size: { width: 400, height: 96 },
85
+ rotation: 0,
86
+ source: { kind: "space-file", path: "assets/soundtrack.mp3" },
87
+ props: {},
88
+ },
89
+ {
90
+ id: "brief",
91
+ type: "file",
92
+ position: { x: 520, y: 500 },
93
+ size: { width: 320, height: 180 },
94
+ rotation: 0,
95
+ source: { kind: "space-file", path: "docs/brief.md" },
96
+ props: {},
97
+ },
98
+ ],
99
+ connections: [],
100
+ effects: [],
101
+ compositions: [],
102
+ },
103
+ "create:animation": {
104
+ items: [
105
+ {
106
+ id: "title",
107
+ type: "text",
108
+ position: { x: 120, y: 80 },
109
+ size: { width: 320, height: 48 },
110
+ rotation: 0,
111
+ props: { text: "Launch", fontSize: 32 },
112
+ style: { color: "brand" },
113
+ },
114
+ {
115
+ id: "stroke",
116
+ type: "draw",
117
+ rotation: 0,
118
+ props: { points: [{ x: 2, y: 98, p: 0.5 }, { x: 90, y: 2, p: 0.5 }, { x: 178, y: 98, p: 0.5 }] },
119
+ style: { color: "violet", strokeWidth: 4 },
120
+ },
121
+ ],
122
+ connections: [],
123
+ effects: [{
124
+ id: "pulse-title",
125
+ target: { type: "item", itemId: "title" },
126
+ kind: "effects.pulse",
127
+ kindVersion: 1,
128
+ lifecycle: "when-visible",
129
+ timeOrigin: "visible",
130
+ seed: "pulse-title",
131
+ params: { amount: 0.04, period: 1600 },
132
+ }],
133
+ compositions: [
134
+ {
135
+ id: "intro",
136
+ name: "Intro",
137
+ timeline: {
138
+ duration: 800,
139
+ tracks: [{
140
+ id: "title-opacity",
141
+ target: { type: "item", itemId: "title" },
142
+ channel: "style.opacity",
143
+ fill: "both",
144
+ keyframes: [{ time: 0, value: 0 }, { time: 800, value: 1, easing: "ease-out-cubic" }],
145
+ }],
146
+ },
147
+ },
148
+ {
149
+ id: "draw-stroke",
150
+ name: "Draw stroke",
151
+ timeline: {
152
+ duration: 900,
153
+ clips: [{
154
+ id: "reveal-stroke",
155
+ kind: "draw.reveal",
156
+ kindVersion: 1,
157
+ target: { type: "item", itemId: "stroke" },
158
+ start: 0,
159
+ duration: 900,
160
+ easing: "ease-out-cubic",
161
+ seed: "reveal-stroke",
162
+ }],
163
+ },
164
+ },
165
+ ],
166
+ },
16
167
  "item:text": {
17
168
  id: "title",
18
169
  type: "text",
19
- frame,
170
+ position: { x: 120, y: 80 },
171
+ size: { width: 320, height: 48 },
172
+ rotation: 0,
20
173
  props: { text: "Launch plan", fontSize: 32 },
21
174
  style: { color: "brand" },
22
175
  },
23
176
  "item:image": {
24
177
  id: "hero",
25
178
  type: "image",
26
- frame: { x: 120, y: 160, width: 640, height: 360, rotation: 0 },
179
+ position: { x: 120, y: 160 },
180
+ size: { width: 640, height: 360 },
181
+ rotation: 0,
27
182
  source: { kind: "space-file", path: "assets/hero.webp" },
28
183
  props: {},
29
184
  },
30
185
  "item:geo": {
31
186
  id: "goal",
32
187
  type: "geo",
33
- frame: { x: 120, y: 160, width: 280, height: 140, rotation: 0 },
188
+ position: { x: 120, y: 160 },
189
+ size: { width: 280, height: 140 },
190
+ rotation: 0,
34
191
  props: { shape: "rounded", text: "Ship" },
35
192
  style: { color: "green", fillOpacity: 0.12 },
36
193
  },
37
194
  "item:frame": {
38
195
  id: "scene",
39
196
  type: "frame",
40
- frame: { x: 80, y: 60, width: 960, height: 540, rotation: 0 },
197
+ position: { x: 80, y: 60 },
198
+ size: { width: 960, height: 540 },
199
+ rotation: 0,
41
200
  props: { label: "Scene 1" },
42
201
  style: { color: "neutral" },
43
202
  },
44
203
  "item:draw": {
45
204
  id: "stroke",
46
205
  type: "draw",
47
- frame: { x: 100, y: 100, width: 180, height: 100, rotation: 0 },
48
- props: { points: [{ x: 0, y: 80, p: 0.5 }, { x: 90, y: 10, p: 0.8 }, { x: 180, y: 70, p: 0.5 }] },
206
+ rotation: 0,
207
+ props: { points: [{ x: 102, y: 198, p: 0.5 }, { x: 190, y: 102, p: 0.5 }, { x: 278, y: 198, p: 0.5 }] },
49
208
  style: { color: "violet", strokeWidth: 4 },
50
209
  },
51
210
  "item:arrow": {
52
211
  id: "arrow",
53
212
  type: "arrow",
54
- frame: { x: 100, y: 100, width: 260, height: 100, rotation: 0 },
213
+ rotation: 0,
55
214
  props: { start: { x: 116, y: 150 }, end: { x: 344, y: 150 }, bend: 0, arrowStart: false, arrowEnd: true, label: "next" },
56
215
  style: { color: "brand", strokeWidth: 2.5 },
57
216
  },
58
217
  "item:video": {
59
218
  id: "demo-video",
60
219
  type: "video",
61
- frame: { x: 120, y: 160, width: 640, height: 360, rotation: 0 },
220
+ position: { x: 120, y: 160 },
221
+ size: { width: 640, height: 360 },
222
+ rotation: 0,
62
223
  source: { kind: "space-file", path: "assets/demo.mp4" },
63
224
  props: {},
64
225
  },
65
226
  "item:audio": {
66
227
  id: "soundtrack",
67
228
  type: "audio",
68
- frame: { x: 120, y: 540, width: 480, height: 96, rotation: 0 },
229
+ position: { x: 120, y: 540 },
230
+ size: { width: 480, height: 96 },
231
+ rotation: 0,
69
232
  source: { kind: "space-file", path: "assets/soundtrack.mp3" },
70
233
  props: {},
71
234
  },
72
235
  "item:file": {
73
236
  id: "brief",
74
237
  type: "file",
75
- frame: { x: 120, y: 160, width: 360, height: 220, rotation: 0 },
238
+ position: { x: 120, y: 160 },
239
+ size: { width: 360, height: 220 },
240
+ rotation: 0,
76
241
  source: { kind: "space-file", path: "docs/brief.md" },
77
242
  props: {},
78
243
  },
79
244
  "item:task": {
80
245
  id: "task",
81
246
  type: "task",
82
- frame: { x: 120, y: 160, width: 420, height: 240, rotation: 0 },
247
+ position: { x: 120, y: 160 },
248
+ size: { width: 420, height: 240 },
249
+ rotation: 0,
83
250
  props: { taskRunId: "task-run-id", snapshot: { taskType: "generation", status: "running", title: "Generate concept", artifactCount: 0, artifacts: [] } },
84
251
  },
85
252
  "effect:pulse": {
@@ -87,28 +254,20 @@ const templates = {
87
254
  target: { type: "item", itemId: "title" },
88
255
  kind: "effects.pulse",
89
256
  kindVersion: 1,
90
- enabled: true,
91
257
  lifecycle: "when-visible",
92
258
  timeOrigin: "visible",
93
- layer: "front",
94
259
  seed: "pulse-title",
95
260
  params: { amount: 0.04, period: 1600 },
96
- assetRefs: [],
97
- metadata: {},
98
261
  },
99
262
  "effect:float": {
100
263
  id: "float-hero",
101
264
  target: { type: "item", itemId: "hero" },
102
265
  kind: "effects.float",
103
266
  kindVersion: 1,
104
- enabled: true,
105
267
  lifecycle: "when-visible",
106
268
  timeOrigin: "visible",
107
- layer: "front",
108
269
  seed: "float-hero",
109
270
  params: { distance: 8, period: 2200 },
110
- assetRefs: [],
111
- metadata: {},
112
271
  },
113
272
  "composition:fade": {
114
273
  id: "intro",
@@ -119,63 +278,87 @@ const templates = {
119
278
  id: "title-opacity",
120
279
  target: { type: "item", itemId: "title" },
121
280
  channel: "style.opacity",
122
- channelVersion: 1,
123
- interpolation: "linear",
124
281
  fill: "both",
125
282
  keyframes: [
126
283
  { time: 0, value: 0 },
127
284
  { time: 800, value: 1, easing: "ease-out-cubic" },
128
285
  ],
129
- metadata: {},
130
286
  }],
131
- clips: [],
132
- markers: [],
133
287
  },
134
- playback: { loop: false, endBehavior: "hold", reducedMotion: { mode: "base" } },
135
- metadata: {},
136
288
  },
137
289
  "composition:reveal": {
138
290
  id: "reveal",
139
291
  name: "Reveal",
140
292
  timeline: {
141
- duration: 900,
142
- tracks: [{ id: "title-opacity", target: { type: "item", itemId: "title" }, channel: "style.opacity", channelVersion: 1, interpolation: "linear", fill: "both", keyframes: [{ time: 0, value: 0 }, { time: 600, value: 1 }], metadata: {} }],
143
- clips: [{ id: "reveal-title", kind: "text.reveal", kindVersion: 1, target: { type: "item", itemId: "title" }, start: 0, duration: 600, layer: "content", fill: "both", easing: "ease-out-cubic", params: { mode: "words" }, assetRefs: [], seed: "reveal-title", metadata: {} }],
144
- markers: [],
293
+ duration: 600,
294
+ clips: [{ id: "reveal-title", kind: "text.reveal", kindVersion: 1, target: { type: "item", itemId: "title" }, start: 0, duration: 600, fill: "both", easing: "ease-out-cubic", params: { mode: "words" }, seed: "reveal-title" }],
145
295
  },
146
- playback: { loop: false, endBehavior: "hold", reducedMotion: { mode: "base" } },
147
- metadata: {},
148
296
  },
149
297
  "composition:motion-path": {
150
298
  id: "motion",
151
299
  name: "Motion path",
152
300
  timeline: {
153
301
  duration: 1200,
154
- tracks: [],
155
- clips: [{ id: "move-hero", kind: "motion.path", kindVersion: 1, target: { type: "item", itemId: "hero" }, start: 0, duration: 1200, layer: "content", fill: "both", easing: "ease-in-out-cubic", params: { points: [{ x: 0, y: 0 }, { x: 160, y: -40 }, { x: 320, y: 0 }], orient: true }, assetRefs: [], seed: "move-hero", metadata: {} }],
156
- markers: [],
302
+ clips: [{ id: "move-hero", kind: "motion.path", kindVersion: 1, target: { type: "item", itemId: "hero" }, start: 0, duration: 1200, fill: "both", easing: "ease-in-out-cubic", params: { points: [{ x: 0, y: 0 }, { x: 160, y: -40 }, { x: 320, y: 0 }], orient: true }, seed: "move-hero" }],
303
+ },
304
+ },
305
+ "composition:draw-reveal": {
306
+ id: "draw-stroke",
307
+ name: "Draw stroke",
308
+ timeline: {
309
+ duration: 900,
310
+ clips: [{ id: "reveal-stroke", kind: "draw.reveal", kindVersion: 1, target: { type: "item", itemId: "stroke" }, start: 0, duration: 900, easing: "ease-out-cubic", seed: "reveal-stroke" }],
311
+ },
312
+ },
313
+ "composition:trail": {
314
+ id: "move-with-trail",
315
+ name: "Move with trail",
316
+ timeline: {
317
+ duration: 1200,
318
+ clips: [
319
+ { id: "move-hero", kind: "motion.path", kindVersion: 1, target: { type: "item", itemId: "hero" }, start: 0, duration: 1200, easing: "ease-in-out-cubic", fill: "both", params: { points: [{ x: 0, y: 0 }, { x: 320, y: 0 }] }, seed: "move-hero" },
320
+ { id: "trail-hero", kind: "effects.trail", kindVersion: 1, target: { type: "item", itemId: "hero" }, start: 0, duration: 1200, params: { alpha: 0.48 }, seed: "trail-hero" },
321
+ ],
322
+ },
323
+ },
324
+ "composition:impact": {
325
+ id: "impact",
326
+ name: "Impact",
327
+ timeline: {
328
+ duration: 500,
329
+ clips: [{ id: "impact-ring", kind: "effects.impact", kindVersion: 1, target: { type: "board" }, start: 0, duration: 500, layer: "front", params: { center: { x: 400, y: 300 }, radius: 120 }, seed: "impact-ring" }],
330
+ },
331
+ },
332
+ "composition:flash": {
333
+ id: "flash",
334
+ name: "Flash",
335
+ timeline: {
336
+ duration: 300,
337
+ clips: [{ id: "screen-flash", kind: "effects.flash", kindVersion: 1, target: { type: "board" }, start: 0, duration: 300, layer: "screen", params: { alpha: 0.3 }, seed: "screen-flash" }],
157
338
  },
158
- playback: { loop: false, endBehavior: "hold", reducedMotion: { mode: "base" } },
159
- metadata: {},
160
339
  },
161
340
  "composition:particles": {
162
341
  id: "celebrate",
163
342
  name: "Celebrate",
164
343
  timeline: {
165
344
  duration: 1000,
166
- tracks: [],
167
- clips: [{ id: "particles", kind: "effects.particles", kindVersion: 1, target: { type: "board" }, start: 0, duration: 1000, layer: "front", fill: "none", easing: "linear", params: { count: 420, bounds: { x: 0, y: 0, width: 800, height: 600 } }, assetRefs: [], seed: "particles", metadata: {} }],
168
- markers: [],
345
+ clips: [{ id: "particles", kind: "effects.particles", kindVersion: 1, target: { type: "board" }, start: 0, duration: 1000, layer: "front", params: { count: 420, bounds: { x: 0, y: 0, width: 800, height: 600 } }, seed: "particles" }],
346
+ },
347
+ playback: { endBehavior: "reset" },
348
+ },
349
+ "composition:camera-shake": {
350
+ id: "shake",
351
+ name: "Camera shake",
352
+ timeline: {
353
+ duration: 500,
354
+ clips: [{ id: "shake-camera", kind: "camera.shake", kindVersion: 1, target: { type: "camera" }, start: 0, duration: 500, layer: "screen", params: { amount: 8, frequency: 28 }, seed: "shake-camera" }],
169
355
  },
170
- playback: { loop: false, endBehavior: "reset", reducedMotion: { mode: "base" } },
171
- metadata: {},
172
356
  },
173
357
  "composition:camera-focus": {
174
358
  id: "tour",
175
359
  name: "Tour",
176
360
  timeline: {
177
361
  duration: 1000,
178
- tracks: [],
179
362
  clips: [{
180
363
  id: "focus-title",
181
364
  kind: "camera.focus",
@@ -187,14 +370,9 @@ const templates = {
187
370
  fill: "forwards",
188
371
  easing: "ease-out-cubic",
189
372
  params: { focus: { type: "item", itemId: "title" }, fit: "contain", padding: 32 },
190
- assetRefs: [],
191
373
  seed: "focus-title",
192
- metadata: {},
193
374
  }],
194
- markers: [],
195
375
  },
196
- playback: { loop: false, endBehavior: "hold", reducedMotion: { mode: "base" } },
197
- metadata: {},
198
376
  },
199
377
  };
200
378
  export const BOARD_EXAMPLE_KEYS = Object.keys(templates);
@@ -206,19 +384,28 @@ export function boardExample(kind, type) {
206
384
  return template;
207
385
  }
208
386
  export function registerBoardExampleCommands(boards) {
209
- boards.command("examples <kind> [type]")
387
+ boards.command("examples [kind] [type]")
210
388
  .description("Print editable semantic JSON templates")
389
+ .option("--list", "List available examples")
211
390
  .addHelpText("after", `
212
391
  Kinds:
213
- create
392
+ create [workflow|media|animation]
393
+ batch basic
214
394
  item text|image|video|audio|file|task|geo|frame|draw|arrow
215
395
  effect pulse|float
216
- composition fade|reveal|motion-path|particles|camera-focus
396
+ composition fade|reveal|draw-reveal|motion-path|trail|impact|flash|particles|camera-shake|camera-focus
217
397
 
218
398
  Examples:
399
+ cohub boards examples --list
400
+ cohub boards examples create workflow > board.json
401
+ cohub boards examples batch basic > changes.json
219
402
  cohub boards examples item text > item.json
220
403
  cohub boards examples composition fade > intro.json`)
221
- .action((kind, type) => {
404
+ .action((kind, type, options) => {
405
+ if (options.list)
406
+ return json(BOARD_EXAMPLE_KEYS);
407
+ if (!kind)
408
+ throw new Error("Example kind is required. Use --list to see available examples.");
222
409
  json(boardExample(kind, type));
223
410
  });
224
411
  }
@@ -1,8 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { BoardAuthoringItemSchema, BoardItemPatchSchema, } from "@neta-art/cohub";
3
+ import { computeArrowFrame, computeDrawBounds } from "@neta-art/cohub/board";
3
4
  import { BOARD_DOMAIN_INPUT_MAX_BYTES, readBoardJsonObject, } from "../../board-command-support.js";
4
5
  import { handleHttp, json, jsonRequested, ok, table } from "../../output.js";
5
- import { resolvedBoard, withJson, } from "./context.js";
6
+ import { readInput, readOptions, resolvedBoard, withJson, } from "./context.js";
6
7
  const baseVersion = (value, fallback) => {
7
8
  if (value === undefined)
8
9
  return fallback;
@@ -38,23 +39,39 @@ function mutationOptions(command) {
38
39
  .option("--mutation-id <id>", "Stable id for safe retries")
39
40
  .option("--dry-run", "Validate on the server (references, version, cascade) without writing");
40
41
  }
42
+ function itemLayout(item) {
43
+ if ("position" in item) {
44
+ const size = "size" in item ? item.size : undefined;
45
+ return { x: item.position.x, y: item.position.y, width: size?.width ?? "", height: size?.height ?? "" };
46
+ }
47
+ if (item.type === "draw") {
48
+ const points = item.props.points;
49
+ const strokeWidth = item.style?.strokeWidth ?? 4;
50
+ const bounds = computeDrawBounds(points, strokeWidth);
51
+ return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height };
52
+ }
53
+ const bounds = computeArrowFrame({
54
+ start: item.props.start,
55
+ end: item.props.end,
56
+ bend: item.props.bend,
57
+ size: item.style?.strokeWidth ?? 2.5,
58
+ });
59
+ return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height };
60
+ }
41
61
  export function registerBoardItemCommands(boards) {
42
62
  const items = boards.command("items").description("Author Board items with semantic JSON");
43
- withJson(items.command("list <board>").alias("ls").description("List semantic Board items"))
63
+ readOptions(withJson(items.command("list <board>").alias("ls").description("List semantic Board items")))
44
64
  .action(async (target, options) => {
45
65
  try {
46
66
  const board = await resolvedBoard(boards, target);
47
- const snapshot = await board.authoring();
67
+ const snapshot = await board.authoring(readInput(options, "items"));
48
68
  const items = snapshot.items ?? [];
49
69
  if (jsonRequested(options))
50
70
  return json(items);
51
71
  table(items.map((item) => ({
52
72
  id: item.id,
53
73
  type: item.type,
54
- x: item.frame.x,
55
- y: item.frame.y,
56
- width: item.frame.width,
57
- height: item.frame.height,
74
+ ...itemLayout(item),
58
75
  })), [
59
76
  { key: "id", label: "ID" },
60
77
  { key: "type", label: "TYPE" },
@@ -68,12 +85,39 @@ export function registerBoardItemCommands(boards) {
68
85
  handleHttp(cause);
69
86
  }
70
87
  });
88
+ withJson(items.command("get <board> <item-id>").description("Get one complete Board item"))
89
+ .action(async (target, itemId, options) => {
90
+ try {
91
+ const board = await resolvedBoard(boards, target);
92
+ const snapshot = await board.authoring({ include: ["items"], itemIds: [itemId] });
93
+ const item = snapshot.items?.[0];
94
+ if (!item)
95
+ throw new Error(`Item not found: ${itemId}`);
96
+ if (jsonRequested(options))
97
+ return json(item);
98
+ table([{
99
+ id: item.id,
100
+ type: item.type,
101
+ ...itemLayout(item),
102
+ }], [
103
+ { key: "id", label: "ID" },
104
+ { key: "type", label: "TYPE" },
105
+ { key: "x", label: "X" },
106
+ { key: "y", label: "Y" },
107
+ { key: "width", label: "WIDTH" },
108
+ { key: "height", label: "HEIGHT" },
109
+ ]);
110
+ }
111
+ catch (cause) {
112
+ handleHttp(cause);
113
+ }
114
+ });
71
115
  mutationOptions(items.command("create <board>")
72
116
  .description("Create one item")
73
117
  .requiredOption("-i, --input <file>", "Board Item JSON; use - for stdin")
74
118
  .addHelpText("after", `
75
119
  Minimal text item:
76
- {"id":"title","type":"text","frame":{"x":120,"y":80,"width":320,"height":48,"rotation":0},"props":{"text":"Launch plan","fontSize":32},"style":{"color":"brand"}}`))
120
+ {"id":"title","type":"text","position":{"x":120,"y":80},"size":{"width":320,"height":48},"props":{"text":"Launch plan","fontSize":32},"style":{"color":"brand"}}`))
77
121
  .action(async (target, options) => {
78
122
  try {
79
123
  const value = await readBoardJsonObject(options.input, BOARD_DOMAIN_INPUT_MAX_BYTES);
@@ -92,7 +136,7 @@ Minimal text item:
92
136
  .addHelpText("after", `
93
137
  Objects merge recursively, arrays replace, and null clears optional fields.
94
138
  Move and rename a text item without replacing its size or font:
95
- {"frame":{"x":160,"y":120},"props":{"text":"Updated"}}`))
139
+ {"position":{"x":160,"y":120},"props":{"text":"Updated"}}`))
96
140
  .action(async (target, itemId, options) => {
97
141
  try {
98
142
  const value = await readBoardJsonObject(options.input, BOARD_DOMAIN_INPUT_MAX_BYTES);
@@ -7,4 +7,11 @@ export declare function parseViewport(value?: string): {
7
7
  width: number;
8
8
  height: number;
9
9
  } | undefined;
10
+ export declare function showCreated(result: {
11
+ board: {
12
+ id: string;
13
+ title: string;
14
+ version: number;
15
+ };
16
+ }, path: string): void;
10
17
  export declare function registerBoards(program: Command): Command;
@@ -38,8 +38,9 @@ export function parseViewport(value) {
38
38
  throw new Error("viewport width and height must be greater than zero");
39
39
  return { x, y, width, height };
40
40
  }
41
- function showCreated(result) {
42
- table([result.board], [
41
+ export function showCreated(result, path) {
42
+ table([{ path, ...result.board }], [
43
+ { key: "path", label: "Path" },
43
44
  { key: "id", label: "ID" },
44
45
  { key: "title", label: "Title" },
45
46
  { key: "version", label: "Version" },
@@ -211,7 +212,7 @@ function registerExportCommand(boards) {
211
212
  export function registerBoards(program) {
212
213
  const boards = program
213
214
  .command("boards")
214
- .description("Inspect and update Boards")
215
+ .description("Inspect and update Boards by ID or .board path")
215
216
  .hook("preAction", () => { resolveSpace(boards); });
216
217
  withJson(boards.command("create <path>")
217
218
  .description("Create a Board")
@@ -240,9 +241,9 @@ Generate an editable seed:
240
241
  };
241
242
  const result = await createClient().space(resolveSpace(boards)).boards.create(input);
242
243
  if (jsonRequested(options))
243
- return outJson(result);
244
- ok(`Board created: ${result.board.id}`);
245
- showCreated(result);
244
+ return outJson({ ...result, path });
245
+ ok(`Board created: ${path}`);
246
+ showCreated(result, path);
246
247
  }
247
248
  catch (cause) {
248
249
  handleHttp(cause);
@@ -252,10 +253,13 @@ Generate an editable seed:
252
253
  .alias("get")
253
254
  .description("Show Board metadata and semantic resource counts")
254
255
  .addHelpText("after", `
255
- Inspect content with:
256
+ Inspect resources with:
256
257
  cohub boards items list <board> --json
258
+ cohub boards connections list <board> --json
257
259
  cohub boards effects list <board> --json
258
- cohub boards compositions list <board> --json`))
260
+ cohub boards compositions list <board> --json
261
+ Apply multiple changes atomically:
262
+ cohub boards batch <board> --input changes.json --dry-run`))
259
263
  .action(async (target, options) => {
260
264
  try {
261
265
  const spaceId = resolveSpace(boards);
@@ -273,6 +277,9 @@ Inspect content with:
273
277
  .description("Show authoring schemas and runtime capabilities")
274
278
  .addHelpText("after", `
275
279
  Use --json for complete Item, patch, mutation, effect, Composition, and create JSON Schemas.
280
+ Coordinates:
281
+ draw points and arrow start/end points are world-space.
282
+ Position and size are optional; Board geometry derives the persisted frame automatically.
276
283
  Use boards examples for editable starter JSON.`))
277
284
  .action(async (target, options) => {
278
285
  try {
@@ -314,7 +321,8 @@ Use boards examples for editable starter JSON.`))
314
321
  });
315
322
  registerBoardDomainCommands(boards);
316
323
  registerExportCommand(boards);
317
- withJson(boards.command("play <board> <composition-id>")
324
+ const playback = boards.command("playback").description("Control shared Board playback");
325
+ withJson(playback.command("play <board> <composition-id>")
318
326
  .description("Start shared playback")
319
327
  .option("--position <time>", "Initial position in milliseconds")
320
328
  .option("--time-scale <scale>", "Playback speed from 0 to 4")
@@ -358,11 +366,11 @@ Use boards examples for editable starter JSON.`))
358
366
  handleHttp(cause);
359
367
  }
360
368
  };
361
- withJson(boards.command("pause <board> <playback-id>")
369
+ withJson(playback.command("pause <board> <playback-id>")
362
370
  .description("Pause playback")
363
371
  .option("--command-id <id>", "Idempotency command ID"))
364
372
  .action(playbackAction("pause"));
365
- withJson(boards.command("seek <board> <playback-id> <position>")
373
+ withJson(playback.command("seek <board> <playback-id> <position>")
366
374
  .description("Seek playback")
367
375
  .option("--command-id <id>", "Idempotency command ID"))
368
376
  .action(async (target, playbackId, position, options) => {
@@ -383,7 +391,7 @@ Use boards examples for editable starter JSON.`))
383
391
  handleHttp(cause);
384
392
  }
385
393
  });
386
- withJson(boards.command("stop <board> <playback-id>")
394
+ withJson(playback.command("stop <board> <playback-id>")
387
395
  .description("Stop playback")
388
396
  .option("--command-id <id>", "Idempotency command ID"))
389
397
  .action(playbackAction("stop"));
package/dist/output.js CHANGED
@@ -75,6 +75,27 @@ function errorMessageFromBody(body) {
75
75
  return errorBody.message;
76
76
  return null;
77
77
  }
78
+ function diagnosticDetailFromBody(body) {
79
+ if (!body || typeof body !== "object")
80
+ return null;
81
+ const diagnostics = body.diagnostics;
82
+ if (!Array.isArray(diagnostics) || diagnostics.length === 0)
83
+ return null;
84
+ const lines = diagnostics.slice(0, 8).flatMap((item) => {
85
+ if (!item || typeof item !== "object")
86
+ return [];
87
+ const diagnostic = item;
88
+ if (typeof diagnostic.message !== "string" || !diagnostic.message.trim())
89
+ return [];
90
+ const path = typeof diagnostic.path === "string" ? diagnostic.path : "";
91
+ const message = diagnostic.message.replace(path ? `${path}: ` : "", "");
92
+ return [`${path ? `${path}: ` : ""}${message}`];
93
+ });
94
+ if (lines.length === 0)
95
+ return null;
96
+ const suffix = diagnostics.length > lines.length ? ` (+${diagnostics.length - lines.length} more)` : "";
97
+ return `${lines.join("; ")}${suffix}`;
98
+ }
78
99
  function debugErrorMetaFromBody(body) {
79
100
  if (!body || typeof body !== "object")
80
101
  return [];
@@ -139,7 +160,10 @@ export function handleHttp(e) {
139
160
  const presentation = errorPresentationFromHttpError(e);
140
161
  const message = presentation?.message ?? errorMessageFromBody(body) ?? (e instanceof Error ? e.message : String(e));
141
162
  const fetchDetail = fetchFailureDetail(e);
163
+ const diagnosticDetail = diagnosticDetailFromBody(body);
142
164
  const detailParts = [];
165
+ if (diagnosticDetail)
166
+ detailParts.push(diagnosticDetail);
143
167
  if (presentation?.detail)
144
168
  detailParts.push(presentation.detail);
145
169
  if (process.env.COHUB_DEBUG_ERRORS) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neta-art/cohub-cli",
3
- "version": "6.2.2",
3
+ "version": "6.4.0",
4
4
  "description": "CLI for Cohub — spaces, sessions, and agent collaboration.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -19,7 +19,7 @@
19
19
  "commander": "^15.0.0",
20
20
  "pixi.js": "^8.20.1",
21
21
  "sharp": "^0.35.4",
22
- "@neta-art/cohub": "8.5.1"
22
+ "@neta-art/cohub": "8.7.0"
23
23
  },
24
24
  "publishConfig": {
25
25
  "access": "public"
@@ -1,3 +0,0 @@
1
- import type { Command } from "commander";
2
- /** Connection shortcuts; Item authoring lives in ./items.ts. */
3
- export declare function registerBoardNodeCommands(boards: Command): void;
@@ -1,50 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
- import { createBoardConnection } from "@neta-art/cohub/board";
3
- import { mutateSemantic } from "./context.js";
4
- import { handleHttp } from "../../output.js";
5
- import { resolvedBoard, showUpdated, withJson, } from "./context.js";
6
- /** Connection shortcuts; Item authoring lives in ./items.ts. */
7
- export function registerBoardNodeCommands(boards) {
8
- withJson(boards.command("connect <board> <source> <target>")
9
- .description("Connect two Board items")
10
- .option("--id <id>", "Connection id")
11
- .option("--relation <relation>", "Relation type")
12
- .option("--direction <direction>", "none, forward, backward, or both", "forward")
13
- .option("--label <label>", "Connection label")
14
- .option("--source-port <id>", "Source port id")
15
- .option("--target-port <id>", "Target port id"))
16
- .action(async (target, source, destination, options) => {
17
- try {
18
- const direction = options.direction ?? "forward";
19
- if (!["none", "forward", "backward", "both"].includes(direction)) {
20
- throw new Error("--direction must be none, forward, backward, or both");
21
- }
22
- const board = await resolvedBoard(boards, target);
23
- const connection = createBoardConnection({
24
- id: options.id ?? randomUUID(),
25
- sourceItemId: source,
26
- targetItemId: destination,
27
- relation: options.relation,
28
- direction: direction,
29
- label: options.label,
30
- sourcePortId: options.sourcePort,
31
- targetPortId: options.targetPort,
32
- });
33
- showUpdated(await mutateSemantic(board, [{ type: "connection.create", connection }]), options);
34
- }
35
- catch (cause) {
36
- handleHttp(cause);
37
- }
38
- });
39
- withJson(boards.command("disconnect <board> <connection-id>")
40
- .description("Remove a Board connection"))
41
- .action(async (target, connectionId, options) => {
42
- try {
43
- const board = await resolvedBoard(boards, target);
44
- showUpdated(await mutateSemantic(board, [{ type: "connection.delete", connectionId }]), options);
45
- }
46
- catch (cause) {
47
- handleHttp(cause);
48
- }
49
- });
50
- }