@neta-art/cohub-cli 2.7.0 → 3.1.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
@@ -121,6 +121,55 @@ cohub -s <spaceId> spaces sessions rename <sessionId> "<new title>"
121
121
 
122
122
  Use `spaces prompt --session <sessionId>` to send to a Chat.
123
123
 
124
+ ## Boards
125
+
126
+ Board commands use the selected Space and support `-h` at every level:
127
+
128
+ ```bash
129
+ cohub boards -h
130
+ cohub boards inspect -h
131
+ cohub -s <spaceId> boards create boards/plan.board --title "Plan"
132
+ cohub -s <spaceId> boards inspect <boardId> --json
133
+ cohub -s <spaceId> boards capabilities <boardId>
134
+ cohub -s <spaceId> boards watch <boardId> --json
135
+ ```
136
+
137
+ Pass nodes, effects, and sequences as JSON when creating a Board. The path and
138
+ title stay explicit in the command:
139
+
140
+ ```bash
141
+ cohub -s <spaceId> boards create boards/plan.board \
142
+ --title "Plan" \
143
+ --input board-content.json
144
+ ```
145
+
146
+ Transactions are JSON objects without `boardId`; the bound Board supplies it.
147
+ `txId` is generated when omitted, while `baseVersion` must be provided in the
148
+ input or with `--base-version`:
149
+
150
+ ```json
151
+ {
152
+ "baseVersion": 3,
153
+ "operations": [
154
+ {
155
+ "type": "board.patch",
156
+ "payload": { "patch": { "title": "Updated plan" } }
157
+ }
158
+ ]
159
+ }
160
+ ```
161
+
162
+ ```bash
163
+ cohub -s <spaceId> boards validate <boardId> --input transaction.json
164
+ cat transaction.json | cohub -s <spaceId> boards apply <boardId> --input - --json
165
+ cohub -s <spaceId> boards play <boardId> <sequenceId>
166
+ cohub -s <spaceId> boards seek <boardId> <playbackId> 400
167
+ cohub -s <spaceId> boards stop <boardId> <playbackId>
168
+ ```
169
+
170
+ Pass `--tx-id` or `--command-id` when a script needs a stable idempotency key
171
+ across retries.
172
+
124
173
  ## Search
125
174
 
126
175
  Search Spaces, Chats, and prior turns:
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Board image export for the CLI.
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
6
+ * lives here: fetching the document over HTTP, pulling image bytes out of the
7
+ * space, and locating fonts on disk.
8
+ */
9
+ import { type BoardDocument, type BoardItem } from "@neta-art/cohub-board";
10
+ import { type BoardExportRegion } from "@neta-art/cohub-board/export";
11
+ import { type BoardHeadlessExportFormat, type BoardHeadlessFont, type BoardHeadlessRenderer, type BoardHeadlessTexture } from "@neta-art/cohub-board/headless";
12
+ export declare const BOARD_EXPORT_FORMATS: BoardHeadlessExportFormat[];
13
+ /** Infer the output format from the file extension, defaulting to PNG. */
14
+ export declare function formatFromPath(path: string): BoardHeadlessExportFormat;
15
+ /**
16
+ * Geist, as shipped to the browser.
17
+ *
18
+ * The board asks for the "Geist" family; registering the exact same font files
19
+ * the web app loads is what makes CLI output match the editor rather than
20
+ * substituting whatever sans-serif the host happens to have. Resolution is
21
+ * best-effort: if the font package is not installed the export still succeeds,
22
+ * falling back through the font stack.
23
+ */
24
+ export declare function resolveBundledFonts(): BoardHeadlessFont[];
25
+ export type BoardExportSource = {
26
+ document: BoardDocument;
27
+ boardId: string;
28
+ title: string | null;
29
+ };
30
+ /**
31
+ * Load a board document by board id or by the path of its `.board` file.
32
+ *
33
+ * A `.board` file is a manifest holding a board id, so both forms converge on
34
+ * the same inspect call — which is also what the web client does.
35
+ */
36
+ export declare function loadBoardDocument(spaceId: string, target: string): Promise<BoardExportSource>;
37
+ /**
38
+ * Fetch every image in `items`, keyed the way the renderers ask for it.
39
+ *
40
+ * Takes the planned items rather than the whole document so a partial export
41
+ * does not download the rest of the board. Failures are collected rather than
42
+ * thrown: one unreadable image should cost a placeholder and a warning, not the
43
+ * whole export. Downloads run concurrently but bounded, so a board with hundreds
44
+ * of images does not open hundreds of sockets.
45
+ */
46
+ export declare function loadBoardTextures(headless: BoardHeadlessRenderer, spaceId: string, items: BoardItem[], options?: {
47
+ concurrency?: number;
48
+ }): Promise<{
49
+ textures: Map<string, BoardHeadlessTexture>;
50
+ failed: string[];
51
+ omitted: string[];
52
+ }>;
53
+ export type BoardExportRunOptions = {
54
+ spaceId: string;
55
+ target: string;
56
+ region: BoardExportRegion;
57
+ scale: number;
58
+ padding?: number;
59
+ colorScheme: "dark" | "light";
60
+ background: "paper" | "transparent";
61
+ format: BoardHeadlessExportFormat;
62
+ quality?: number;
63
+ withImages: boolean;
64
+ };
65
+ export type BoardExportRunResult = {
66
+ bytes: Uint8Array;
67
+ width: number;
68
+ height: number;
69
+ scale: number;
70
+ itemCount: number;
71
+ format: BoardHeadlessExportFormat;
72
+ warnings: string[];
73
+ };
74
+ /** Render a board to image bytes. Returns null when the region is empty. */
75
+ export declare function runBoardExport(options: BoardExportRunOptions): Promise<BoardExportRunResult | null>;
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Board image export for the CLI.
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
6
+ * lives here: fetching the document over HTTP, pulling image bytes out of the
7
+ * space, and locating fonts on disk.
8
+ */
9
+ import { existsSync } from "node:fs";
10
+ import { createRequire } from "node:module";
11
+ import { dirname, join } from "node:path";
12
+ import { boardBootstrapToDocument, boardImageKeySource, imageAssetKey, isBoardPath, parseBoardManifest, } from "@neta-art/cohub-board";
13
+ import { planBoardExport, selectBoardExportAssets, } from "@neta-art/cohub-board/export";
14
+ import { createBoardHeadlessRenderer, exportBoardImageBytes, } from "@neta-art/cohub-board/headless";
15
+ import { createClient } from "./client.js";
16
+ export const BOARD_EXPORT_FORMATS = ["png", "jpeg", "webp"];
17
+ /** Infer the output format from the file extension, defaulting to PNG. */
18
+ export function formatFromPath(path) {
19
+ const lower = path.toLowerCase();
20
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg"))
21
+ return "jpeg";
22
+ if (lower.endsWith(".webp"))
23
+ return "webp";
24
+ return "png";
25
+ }
26
+ /**
27
+ * Geist, as shipped to the browser.
28
+ *
29
+ * The board asks for the "Geist" family; registering the exact same font files
30
+ * the web app loads is what makes CLI output match the editor rather than
31
+ * substituting whatever sans-serif the host happens to have. Resolution is
32
+ * best-effort: if the font package is not installed the export still succeeds,
33
+ * falling back through the font stack.
34
+ */
35
+ export function resolveBundledFonts() {
36
+ const require = createRequire(import.meta.url);
37
+ const fonts = [];
38
+ const candidates = [
39
+ { pkg: "@fontsource/geist", file: "geist-latin-500-normal.woff2", family: "Geist" },
40
+ { pkg: "@fontsource/geist", file: "geist-latin-400-normal.woff2", family: "Geist" },
41
+ { pkg: "@fontsource/geist", file: "geist-latin-600-normal.woff2", family: "Geist" },
42
+ { pkg: "@fontsource/geist-mono", file: "geist-mono-latin-400-normal.woff2", family: "Geist Mono" },
43
+ ];
44
+ for (const candidate of candidates) {
45
+ try {
46
+ const root = dirname(require.resolve(`${candidate.pkg}/package.json`));
47
+ const path = join(root, "files", candidate.file);
48
+ if (existsSync(path))
49
+ fonts.push({ path, family: candidate.family });
50
+ }
51
+ catch {
52
+ // Font package absent; the stack's system fallbacks cover it.
53
+ }
54
+ }
55
+ return fonts;
56
+ }
57
+ /**
58
+ * Load a board document by board id or by the path of its `.board` file.
59
+ *
60
+ * A `.board` file is a manifest holding a board id, so both forms converge on
61
+ * the same inspect call — which is also what the web client does.
62
+ */
63
+ export async function loadBoardDocument(spaceId, target) {
64
+ const client = createClient();
65
+ const boardId = isBoardPath(target)
66
+ ? await resolveManifestBoardId(spaceId, target)
67
+ : target;
68
+ const bootstrap = await client.space(spaceId).board(boardId).inspect({ include: ["nodes"] });
69
+ return {
70
+ document: boardBootstrapToDocument(bootstrap),
71
+ boardId: bootstrap.board.id,
72
+ title: bootstrap.board.title ?? null,
73
+ };
74
+ }
75
+ async function resolveManifestBoardId(spaceId, path) {
76
+ const file = await createClient().space(spaceId).files.read(path);
77
+ if (!("content" in file) || typeof file.content !== "string") {
78
+ throw new Error(`${path} is not a readable board manifest.`);
79
+ }
80
+ return parseBoardManifest(file.content).boardId;
81
+ }
82
+ /**
83
+ * Fetch every image in `items`, keyed the way the renderers ask for it.
84
+ *
85
+ * Takes the planned items rather than the whole document so a partial export
86
+ * does not download the rest of the board. Failures are collected rather than
87
+ * thrown: one unreadable image should cost a placeholder and a warning, not the
88
+ * whole export. Downloads run concurrently but bounded, so a board with hundreds
89
+ * of images does not open hundreds of sockets.
90
+ */
91
+ export async function loadBoardTextures(headless, spaceId, items, options = {}) {
92
+ const selection = selectBoardExportAssets(items, imageAssetKey);
93
+ const textures = new Map();
94
+ const failed = [];
95
+ const pending = [...selection.keys];
96
+ const concurrency = Math.max(1, Math.min(options.concurrency ?? 6, 16));
97
+ const client = createClient();
98
+ async function worker() {
99
+ for (;;) {
100
+ const key = pending.shift();
101
+ if (!key)
102
+ return;
103
+ const source = boardImageKeySource(key);
104
+ if (!source) {
105
+ failed.push(key);
106
+ continue;
107
+ }
108
+ try {
109
+ const { bytes, mimeType } = source.kind === "file"
110
+ ? await readSpaceFileBytes(client, spaceId, source.value)
111
+ : await readUrlBytes(source.value);
112
+ const texture = await headless.decodeImage(bytes, mimeType);
113
+ textures.set(key, texture);
114
+ }
115
+ catch {
116
+ failed.push(key);
117
+ }
118
+ }
119
+ }
120
+ await Promise.all(Array.from({ length: Math.min(concurrency, pending.length) }, worker));
121
+ return { textures, failed, omitted: selection.omittedKeys };
122
+ }
123
+ async function readSpaceFileBytes(client, spaceId, path) {
124
+ const { blob, mimeType } = await client.space(spaceId).files.download(path);
125
+ return { bytes: new Uint8Array(await blob.arrayBuffer()), mimeType };
126
+ }
127
+ async function readUrlBytes(url) {
128
+ const response = await fetch(url);
129
+ if (!response.ok)
130
+ throw new Error(`HTTP ${response.status}`);
131
+ return {
132
+ bytes: new Uint8Array(await response.arrayBuffer()),
133
+ mimeType: response.headers.get("content-type") ?? "image/png",
134
+ };
135
+ }
136
+ /** Render a board to image bytes. Returns null when the region is empty. */
137
+ export async function runBoardExport(options) {
138
+ const { document } = await loadBoardDocument(options.spaceId, options.target);
139
+ // Plan before fetching: a --frame / --items / --rect export should only pull
140
+ // the images it will actually draw, and an empty region should pull none.
141
+ const plan = planBoardExport({
142
+ document,
143
+ region: options.region,
144
+ scale: options.scale,
145
+ ...(options.padding === undefined ? {} : { padding: options.padding }),
146
+ });
147
+ if (!plan)
148
+ return null;
149
+ const headless = await createBoardHeadlessRenderer({ fonts: resolveBundledFonts() });
150
+ try {
151
+ const warnings = [];
152
+ let textures;
153
+ let omittedKeys = new Set();
154
+ if (options.withImages) {
155
+ const loaded = await loadBoardTextures(headless, options.spaceId, plan.items);
156
+ textures = loaded.textures;
157
+ omittedKeys = new Set(loaded.omitted);
158
+ if (loaded.failed.length > 0) {
159
+ warnings.push(`${loaded.failed.length} image${loaded.failed.length === 1 ? "" : "s"} could not be loaded: ${loaded.failed.slice(0, 3).join(", ")}${loaded.failed.length > 3 ? ", …" : ""}`);
160
+ }
161
+ if (loaded.omitted.length > 0) {
162
+ warnings.push(`${loaded.omitted.length} previews were drawn as placeholders to stay within the export texture limit.`);
163
+ }
164
+ }
165
+ const videoCount = plan.items.filter((item) => item.type === "video").length;
166
+ if (videoCount > 0) {
167
+ warnings.push(`${videoCount} video preview${videoCount === 1 ? " was" : "s were"} drawn as placeholders; headless video decoding is unavailable.`);
168
+ }
169
+ const result = exportBoardImageBytes(headless, document, {
170
+ region: options.region,
171
+ scale: options.scale,
172
+ padding: options.padding,
173
+ colorScheme: options.colorScheme,
174
+ background: options.background,
175
+ textures,
176
+ ...(options.withImages
177
+ ? {
178
+ assetKey: (item) => {
179
+ const key = imageAssetKey(item);
180
+ return key && omittedKeys.has(key) ? null : key;
181
+ },
182
+ }
183
+ : {}),
184
+ format: options.format,
185
+ quality: options.quality,
186
+ });
187
+ if (!result)
188
+ return null;
189
+ for (const warning of result.warnings) {
190
+ // Missing-image warnings are already reported above with their paths.
191
+ if (warning.kind === "images-missing" && options.withImages)
192
+ continue;
193
+ warnings.push(describeWarning(warning));
194
+ }
195
+ return {
196
+ bytes: result.bytes,
197
+ width: result.plan.width,
198
+ height: result.plan.height,
199
+ scale: result.plan.scale,
200
+ itemCount: result.plan.items.length,
201
+ format: result.format,
202
+ warnings,
203
+ };
204
+ }
205
+ finally {
206
+ headless.destroy();
207
+ }
208
+ }
209
+ function describeWarning(warning) {
210
+ if (warning.kind === "scale-clamped") {
211
+ return `Scale reduced from ${warning.requested}x to ${Number(warning.applied).toFixed(2)}x to stay within the size limit.`;
212
+ }
213
+ if (warning.kind === "images-missing") {
214
+ const keys = warning.keys;
215
+ return `${keys.length} image${keys.length === 1 ? "" : "s"} drawn as placeholders (use --images to fetch them).`;
216
+ }
217
+ if (warning.kind === "many-items") {
218
+ return `${warning.count} items exported.`;
219
+ }
220
+ return warning.kind;
221
+ }
package/dist/client.d.ts CHANGED
@@ -1,2 +1,3 @@
1
- import { CohubHttpClient } from "@neta-art/cohub";
1
+ import { CohubClient, CohubHttpClient } from "@neta-art/cohub";
2
2
  export declare function createClient(): CohubHttpClient;
3
+ export declare function createRealtimeClient(): CohubClient;
package/dist/client.js CHANGED
@@ -1,11 +1,15 @@
1
- import { CohubHttpClient, readRequestSourceFromEnv } from "@neta-art/cohub";
1
+ import { CohubClient, CohubHttpClient, readRequestSourceFromEnv } from "@neta-art/cohub";
2
2
  import { clearAuthSession, resolveAccessToken } from "./auth.js";
3
+ const clientOptions = () => ({
4
+ getAccessToken: resolveAccessToken,
5
+ onUnauthorized: clearAuthSession,
6
+ requestSource: () => readRequestSourceFromEnv(process.env, { via: "cli" }) ?? {
7
+ via: "cli",
8
+ },
9
+ });
3
10
  export function createClient() {
4
- return new CohubHttpClient({
5
- getAccessToken: resolveAccessToken,
6
- onUnauthorized: clearAuthSession,
7
- requestSource: () => readRequestSourceFromEnv(process.env, { via: "cli" }) ?? {
8
- via: "cli",
9
- },
10
- });
11
+ return new CohubHttpClient(clientOptions());
12
+ }
13
+ export function createRealtimeClient() {
14
+ return new CohubClient(clientOptions());
11
15
  }
@@ -0,0 +1,14 @@
1
+ import type { BoardInspectInput, BoardTransactionInput } from "@neta-art/cohub";
2
+ import type { Command } from "commander";
3
+ declare const INSPECT_SECTIONS: readonly ["nodes", "effects", "sequences", "clips", "playback"];
4
+ type InspectSection = (typeof INSPECT_SECTIONS)[number];
5
+ export declare function parseJsonObject(text: string, source?: string): Record<string, unknown>;
6
+ export declare function readJsonObject(source: string): Promise<Record<string, unknown>>;
7
+ export declare function parseInspectSections(value?: string): InspectSection[] | undefined;
8
+ export declare function parseViewport(value?: string): BoardInspectInput["viewport"];
9
+ export declare function createTransactionInput(input: Record<string, unknown>, options: {
10
+ txId?: string;
11
+ baseVersion?: string;
12
+ }): BoardTransactionInput;
13
+ export declare function registerBoards(program: Command): Command;
14
+ export {};
@@ -0,0 +1,453 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import { BOARD_EXPORT_FORMATS, formatFromPath, runBoardExport } from "../board-export.js";
4
+ import { createClient, createRealtimeClient } from "../client.js";
5
+ import { error, handleHttp, json as outJson, jsonRequested, ok, table } from "../output.js";
6
+ import { resolveSpace } from "../space.js";
7
+ const INSPECT_SECTIONS = ["nodes", "effects", "sequences", "clips", "playback"];
8
+ function isObject(value) {
9
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
10
+ }
11
+ export function parseJsonObject(text, source = "input") {
12
+ if (!text.trim())
13
+ throw new Error(`${source} is empty`);
14
+ let value;
15
+ try {
16
+ value = JSON.parse(text);
17
+ }
18
+ catch (cause) {
19
+ throw new Error(`${source} must contain valid JSON`, { cause });
20
+ }
21
+ if (!isObject(value))
22
+ throw new Error(`${source} must contain a JSON object`);
23
+ return value;
24
+ }
25
+ async function readStdin() {
26
+ const chunks = [];
27
+ for await (const chunk of process.stdin)
28
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
29
+ return Buffer.concat(chunks).toString("utf8");
30
+ }
31
+ export async function readJsonObject(source) {
32
+ const text = source === "-" ? await readStdin() : await readFile(source, "utf8");
33
+ return parseJsonObject(text, source === "-" ? "stdin" : source);
34
+ }
35
+ function parseNumber(value, name, options = {}) {
36
+ if (!value.trim())
37
+ throw new Error(`${name} must be a finite number`);
38
+ const parsed = Number(value);
39
+ if (!Number.isFinite(parsed))
40
+ throw new Error(`${name} must be a finite number`);
41
+ if (options.integer && !Number.isSafeInteger(parsed))
42
+ throw new Error(`${name} must be an integer`);
43
+ if (options.min !== undefined && parsed < options.min)
44
+ throw new Error(`${name} must be at least ${options.min}`);
45
+ if (options.max !== undefined && parsed > options.max)
46
+ throw new Error(`${name} must be at most ${options.max}`);
47
+ return parsed;
48
+ }
49
+ export function parseInspectSections(value) {
50
+ if (!value)
51
+ return undefined;
52
+ const sections = value.split(",").map((item) => item.trim()).filter(Boolean);
53
+ const unknown = sections.filter((section) => !INSPECT_SECTIONS.includes(section));
54
+ if (unknown.length > 0)
55
+ throw new Error(`Unknown Board section: ${unknown.join(", ")}`);
56
+ return [...new Set(sections)];
57
+ }
58
+ export function parseViewport(value) {
59
+ if (!value)
60
+ return undefined;
61
+ const parts = value.split(",").map((part) => part.trim());
62
+ if (parts.length !== 4)
63
+ throw new Error("viewport must be x,y,width,height");
64
+ const [xValue, yValue, widthValue, heightValue] = parts;
65
+ if (xValue === undefined || yValue === undefined || widthValue === undefined || heightValue === undefined) {
66
+ throw new Error("viewport must be x,y,width,height");
67
+ }
68
+ const x = parseNumber(xValue, "viewport x");
69
+ const y = parseNumber(yValue, "viewport y");
70
+ const width = parseNumber(widthValue, "viewport width");
71
+ const height = parseNumber(heightValue, "viewport height");
72
+ if (width <= 0 || height <= 0)
73
+ throw new Error("viewport width and height must be greater than zero");
74
+ return { x, y, width, height };
75
+ }
76
+ export function createTransactionInput(input, options) {
77
+ if ("boardId" in input)
78
+ throw new Error("transaction input must not contain boardId");
79
+ if (!Array.isArray(input.operations))
80
+ throw new Error("transaction input must contain an operations array");
81
+ const rawBaseVersion = options.baseVersion ?? input.baseVersion;
82
+ if (rawBaseVersion === undefined)
83
+ throw new Error("baseVersion is required in input or --base-version");
84
+ const baseVersion = parseNumber(String(rawBaseVersion), "baseVersion", { min: 0, integer: true });
85
+ const rawTxId = options.txId ?? input.txId;
86
+ if (rawTxId !== undefined && (typeof rawTxId !== "string" || !rawTxId.trim())) {
87
+ throw new Error("txId must be a non-empty string");
88
+ }
89
+ return {
90
+ ...input,
91
+ txId: typeof rawTxId === "string" ? rawTxId : randomUUID(),
92
+ baseVersion,
93
+ operations: input.operations,
94
+ };
95
+ }
96
+ function showBoard(result) {
97
+ table([
98
+ {
99
+ id: result.board.id,
100
+ title: result.board.title,
101
+ version: result.board.version,
102
+ nodes: result.nodes.length,
103
+ effects: result.effects.length,
104
+ sequences: result.sequences.length,
105
+ clips: result.clips.length,
106
+ },
107
+ ], [
108
+ { key: "id", label: "ID" },
109
+ { key: "title", label: "Title" },
110
+ { key: "version", label: "Version" },
111
+ { key: "nodes", label: "Nodes" },
112
+ { key: "effects", label: "Effects" },
113
+ { key: "sequences", label: "Sequences" },
114
+ { key: "clips", label: "Clips" },
115
+ ]);
116
+ }
117
+ function showValidation(result) {
118
+ table([{ valid: result.valid, diagnostics: result.diagnostics.length }], [
119
+ { key: "valid", label: "Valid" },
120
+ { key: "diagnostics", label: "Diagnostics" },
121
+ ]);
122
+ if (result.diagnostics.length > 0) {
123
+ console.log();
124
+ table(result.diagnostics, [
125
+ { key: "severity", label: "Severity" },
126
+ { key: "code", label: "Code" },
127
+ { key: "path", label: "Path" },
128
+ { key: "message", label: "Message" },
129
+ ]);
130
+ }
131
+ console.log();
132
+ table([result.peakCost], Object.keys(result.peakCost).map((key) => ({ key, label: key })));
133
+ }
134
+ function showPlayback(result) {
135
+ table([result], [
136
+ { key: "playbackId", label: "Playback ID" },
137
+ { key: "sequenceId", label: "Sequence" },
138
+ { key: "status", label: "Status" },
139
+ { key: "position", label: "Position" },
140
+ { key: "timeScale", label: "Time Scale" },
141
+ ]);
142
+ }
143
+ function withJson(command) {
144
+ return command.option("--json", "Output as JSON");
145
+ }
146
+ function registerTransactionCommand(boards, name) {
147
+ withJson(boards.command(`${name} <board-id>`)
148
+ .description(name === "validate" ? "Validate a transaction" : "Apply a transaction")
149
+ .requiredOption("-i, --input <file>", "Transaction JSON file; use - for stdin")
150
+ .option("--tx-id <id>", "Override txId; generated when omitted")
151
+ .option("--base-version <version>", "Override baseVersion"))
152
+ .action(async (boardId, options) => {
153
+ try {
154
+ const transaction = createTransactionInput(await readJsonObject(options.input), options);
155
+ const board = createClient().space(resolveSpace(boards)).board(boardId);
156
+ const result = await board[name](transaction);
157
+ if (jsonRequested(options))
158
+ return outJson(result);
159
+ if (name === "validate")
160
+ showValidation(result);
161
+ else {
162
+ ok(`Board updated to version ${result.board.version}`);
163
+ showBoard(result);
164
+ }
165
+ }
166
+ catch (cause) {
167
+ handleHttp(cause);
168
+ }
169
+ });
170
+ }
171
+ function commandId(options) {
172
+ return options.commandId?.trim() || randomUUID();
173
+ }
174
+ /**
175
+ * Resolve the mutually exclusive region flags.
176
+ *
177
+ * Selecting more than one is rejected rather than silently ranked: "why did
178
+ * --items win over --frame" is a worse experience than being told to pick one.
179
+ */
180
+ function parseExportRegion(options) {
181
+ const chosen = [
182
+ options.frame ? "--frame" : null,
183
+ options.items ? "--items" : null,
184
+ options.rect ? "--rect" : null,
185
+ ].filter(Boolean);
186
+ if (chosen.length > 1) {
187
+ throw new Error(`Pick one region: ${chosen.join(", ")} cannot be combined`);
188
+ }
189
+ if (options.frame)
190
+ return { kind: "frame", id: options.frame };
191
+ if (options.items) {
192
+ const ids = options.items.split(",").map((id) => id.trim()).filter(Boolean);
193
+ if (ids.length === 0)
194
+ throw new Error("--items needs at least one node id");
195
+ return { kind: "items", ids };
196
+ }
197
+ if (options.rect) {
198
+ const rect = parseViewport(options.rect);
199
+ if (!rect)
200
+ throw new Error("--rect must be x,y,width,height");
201
+ return { kind: "rect", rect };
202
+ }
203
+ return { kind: "all" };
204
+ }
205
+ function parseExportFormat(options, outPath) {
206
+ if (!options.format)
207
+ return formatFromPath(outPath);
208
+ const format = options.format.toLowerCase();
209
+ if (!BOARD_EXPORT_FORMATS.includes(format)) {
210
+ throw new Error(`Unknown format "${options.format}"; use ${BOARD_EXPORT_FORMATS.join(", ")}`);
211
+ }
212
+ return format;
213
+ }
214
+ function parseColorMode(value) {
215
+ if (!value || value === "dark")
216
+ return "dark";
217
+ if (value === "light")
218
+ return "light";
219
+ throw new Error('--theme must be "dark" or "light"');
220
+ }
221
+ function parseBackground(value) {
222
+ if (!value || value === "paper")
223
+ return "paper";
224
+ if (value === "transparent")
225
+ return "transparent";
226
+ throw new Error('--background must be "paper" or "transparent"');
227
+ }
228
+ function registerExportCommand(boards) {
229
+ withJson(boards.command("export <board>")
230
+ .description("Render a Board to an image (board id or .board path)")
231
+ .requiredOption("-o, --out <file>", "Output file; extension selects the format")
232
+ .option("--scale <factor>", "Output pixels per world unit", "2")
233
+ .option("--padding <units>", "World-space padding around the content")
234
+ .option("--frame <node-id>", "Export a single frame as a page")
235
+ .option("--items <ids>", "Comma-separated node ids to export")
236
+ .option("--rect <rect>", "World rect as x,y,width,height")
237
+ .option("--theme <mode>", "dark or light", "dark")
238
+ .option("--background <mode>", "paper or transparent", "paper")
239
+ .option("--format <format>", `Override format (${BOARD_EXPORT_FORMATS.join(", ")})`)
240
+ .option("--quality <q>", "JPEG/WebP quality from 0 to 1", "0.92")
241
+ .option("--no-images", "Skip image downloads and draw placeholders"))
242
+ .action(async (board, options) => {
243
+ try {
244
+ const out = options.out;
245
+ if (!out)
246
+ throw new Error("--out is required");
247
+ const result = await runBoardExport({
248
+ spaceId: resolveSpace(boards),
249
+ target: board,
250
+ region: parseExportRegion(options),
251
+ scale: parseNumber(options.scale ?? "2", "scale", { min: 0.01, max: 16 }),
252
+ ...(options.padding === undefined
253
+ ? {}
254
+ : { padding: parseNumber(options.padding, "padding", { min: 0 }) }),
255
+ colorScheme: parseColorMode(options.theme),
256
+ background: parseBackground(options.background),
257
+ format: parseExportFormat(options, out),
258
+ quality: parseNumber(options.quality ?? "0.92", "quality", { min: 0, max: 1 }),
259
+ withImages: options.images !== false,
260
+ });
261
+ if (!result) {
262
+ return error("Nothing to export", "The selected region contains no items.");
263
+ }
264
+ await writeFile(out, result.bytes);
265
+ if (jsonRequested(options)) {
266
+ return outJson({
267
+ path: out,
268
+ width: result.width,
269
+ height: result.height,
270
+ scale: result.scale,
271
+ items: result.itemCount,
272
+ format: result.format,
273
+ bytes: result.bytes.length,
274
+ warnings: result.warnings,
275
+ });
276
+ }
277
+ ok(`Exported ${result.width}×${result.height} ${result.format.toUpperCase()} to ${out}`);
278
+ for (const warning of result.warnings)
279
+ console.log(` ! ${warning}`);
280
+ }
281
+ catch (cause) {
282
+ handleHttp(cause);
283
+ }
284
+ });
285
+ }
286
+ export function registerBoards(program) {
287
+ const boards = program
288
+ .command("boards")
289
+ .description("Inspect and update Boards")
290
+ .hook("preAction", () => { resolveSpace(boards); });
291
+ withJson(boards.command("create <path>")
292
+ .description("Create a Board")
293
+ .option("--title <title>", "Board title")
294
+ .option("-i, --input <file>", "Board content JSON file; use - for stdin"))
295
+ .action(async (path, options) => {
296
+ try {
297
+ const content = options.input ? await readJsonObject(options.input) : {};
298
+ if ("path" in content || "title" in content) {
299
+ throw new Error("create input must not contain path or title; use the command argument and --title");
300
+ }
301
+ const input = { ...content, path, ...(options.title ? { title: options.title } : {}) };
302
+ const result = await createClient().space(resolveSpace(boards)).boards.create(input);
303
+ if (jsonRequested(options))
304
+ return outJson(result);
305
+ ok(`Board created: ${result.board.id}`);
306
+ showBoard(result);
307
+ }
308
+ catch (cause) {
309
+ handleHttp(cause);
310
+ }
311
+ });
312
+ withJson(boards.command("inspect <board-id>")
313
+ .alias("get")
314
+ .description("Inspect a Board")
315
+ .option("--include <sections>", "Comma-separated nodes,effects,sequences,clips,playback")
316
+ .option("--viewport <rect>", "Viewport as x,y,width,height"))
317
+ .action(async (boardId, options) => {
318
+ try {
319
+ const result = await createClient().space(resolveSpace(boards)).board(boardId).inspect({
320
+ include: parseInspectSections(options.include),
321
+ viewport: parseViewport(options.viewport),
322
+ });
323
+ if (jsonRequested(options))
324
+ return outJson(result);
325
+ showBoard(result);
326
+ }
327
+ catch (cause) {
328
+ handleHttp(cause);
329
+ }
330
+ });
331
+ withJson(boards.command("capabilities <board-id>")
332
+ .description("Show supported capabilities"))
333
+ .action(async (boardId, options) => {
334
+ try {
335
+ const result = await createClient().space(resolveSpace(boards)).board(boardId).capabilities();
336
+ if (jsonRequested(options))
337
+ return outJson(result);
338
+ table(result.capabilities.map((capability) => ({
339
+ ...capability,
340
+ renderers: capability.renderers?.join(", ") ?? "",
341
+ })), [
342
+ { key: "kind", label: "Kind" },
343
+ { key: "id", label: "ID" },
344
+ { key: "version", label: "Version" },
345
+ { key: "renderers", label: "Renderers" },
346
+ { key: "digest", label: "Digest" },
347
+ ]);
348
+ }
349
+ catch (cause) {
350
+ handleHttp(cause);
351
+ }
352
+ });
353
+ registerTransactionCommand(boards, "validate");
354
+ registerTransactionCommand(boards, "apply");
355
+ registerExportCommand(boards);
356
+ withJson(boards.command("play <board-id> <sequence-id>")
357
+ .description("Start shared playback")
358
+ .option("--position <time>", "Initial position in milliseconds")
359
+ .option("--time-scale <scale>", "Playback speed from 0 to 4")
360
+ .option("--seed <seed>", "Deterministic playback seed")
361
+ .option("--command-id <id>", "Idempotency command ID"))
362
+ .action(async (boardId, sequenceId, options) => {
363
+ try {
364
+ const result = await createClient().space(resolveSpace(boards)).board(boardId).play({
365
+ commandId: commandId(options),
366
+ type: "play",
367
+ sequenceId,
368
+ shared: true,
369
+ ...(options.position === undefined ? {} : { position: parseNumber(options.position, "position", { min: 0 }) }),
370
+ ...(options.timeScale === undefined ? {} : { timeScale: parseNumber(options.timeScale, "timeScale", { min: Number.EPSILON, max: 4 }) }),
371
+ ...(options.seed ? { seed: options.seed } : {}),
372
+ });
373
+ if (jsonRequested(options))
374
+ return outJson(result);
375
+ showPlayback(result);
376
+ }
377
+ catch (cause) {
378
+ handleHttp(cause);
379
+ }
380
+ });
381
+ const playbackAction = (type) => async (boardId, playbackId, options) => {
382
+ try {
383
+ const board = createClient().space(resolveSpace(boards)).board(boardId);
384
+ const id = commandId(options);
385
+ const result = type === "pause"
386
+ ? await board.pause({ commandId: id, type: "pause", playbackId })
387
+ : await board.stop({ commandId: id, type: "stop", playbackId });
388
+ if (jsonRequested(options))
389
+ return outJson(result);
390
+ showPlayback(result);
391
+ }
392
+ catch (cause) {
393
+ handleHttp(cause);
394
+ }
395
+ };
396
+ withJson(boards.command("pause <board-id> <playback-id>")
397
+ .description("Pause playback")
398
+ .option("--command-id <id>", "Idempotency command ID"))
399
+ .action(playbackAction("pause"));
400
+ withJson(boards.command("seek <board-id> <playback-id> <position>")
401
+ .description("Seek playback")
402
+ .option("--command-id <id>", "Idempotency command ID"))
403
+ .action(async (boardId, playbackId, position, options) => {
404
+ try {
405
+ const result = await createClient().space(resolveSpace(boards)).board(boardId).seek({
406
+ commandId: commandId(options),
407
+ type: "seek",
408
+ playbackId,
409
+ position: parseNumber(position, "position", { min: 0 }),
410
+ });
411
+ if (jsonRequested(options))
412
+ return outJson(result);
413
+ showPlayback(result);
414
+ }
415
+ catch (cause) {
416
+ handleHttp(cause);
417
+ }
418
+ });
419
+ withJson(boards.command("stop <board-id> <playback-id>")
420
+ .description("Stop playback")
421
+ .option("--command-id <id>", "Idempotency command ID"))
422
+ .action(playbackAction("stop"));
423
+ withJson(boards.command("watch <board-id>")
424
+ .description("Stream Board events"))
425
+ .action((boardId, options) => {
426
+ try {
427
+ const board = createRealtimeClient().space(resolveSpace(boards)).board(boardId);
428
+ if (!jsonRequested(options))
429
+ process.stderr.write(`Listening for Board ${boardId} events...\n`);
430
+ board.subscribe({
431
+ event(event) {
432
+ if (jsonRequested(options)) {
433
+ process.stdout.write(`${JSON.stringify(event)}\n`);
434
+ return;
435
+ }
436
+ if (event.type === "board.transaction.applied") {
437
+ process.stdout.write(`version ${event.payload.version} transaction ${event.payload.txId} operations ${event.payload.operations.length}\n`);
438
+ }
439
+ else if (event.type === "board.playback.changed") {
440
+ process.stdout.write(`${event.payload.status} sequence ${event.payload.sequenceId} position ${event.payload.position}\n`);
441
+ }
442
+ else {
443
+ process.stdout.write(`awareness ${event.payload.actorName} ${event.payload.update.type}\n`);
444
+ }
445
+ },
446
+ });
447
+ }
448
+ catch (cause) {
449
+ handleHttp(cause);
450
+ }
451
+ });
452
+ return boards;
453
+ }
@@ -381,16 +381,30 @@ export function registerSpaces(program) {
381
381
  .command("ls")
382
382
  .alias("list")
383
383
  .description("List all spaces")
384
+ .option("--mine", "Only spaces you own")
385
+ .option("--pinned", "Only pinned spaces")
384
386
  .option("--json", "Output as JSON")
385
387
  .action(async (opts) => {
386
388
  const client = createClient();
387
389
  try {
388
- const items = await client.spaces.list();
390
+ const [items, me] = await Promise.all([
391
+ client.spaces.list(),
392
+ opts.mine ? client.user.getMe() : Promise.resolve(null),
393
+ ]);
394
+ const myUuid = me?.uuid ?? null;
395
+ const filtered = items.filter((item) => {
396
+ if (opts.mine && myUuid && item.userUuid !== myUuid)
397
+ return false;
398
+ if (opts.pinned && !item.isPinned)
399
+ return false;
400
+ return true;
401
+ });
389
402
  if (jsonRequested(opts))
390
- return outJson(items);
391
- table(items, [
403
+ return outJson(filtered);
404
+ table(filtered, [
392
405
  { key: "id", label: "ID" },
393
406
  { key: "name", label: "Name" },
407
+ { key: "isPinned", label: "Pinned" },
394
408
  { key: "createdAt", label: "Created" },
395
409
  ]);
396
410
  }
@@ -605,6 +619,33 @@ export function registerSpaces(program) {
605
619
  registerMods(spacesCmd);
606
620
  // ── spaces labels ──
607
621
  registerLabels(spacesCmd);
622
+ // ── spaces pin / unpin (user-scope label convenience) ──
623
+ spacesCmd
624
+ .command("pin <id>")
625
+ .description("Pin a space (add the Pinned user label)")
626
+ .action(async (id) => {
627
+ const client = createClient();
628
+ try {
629
+ await client.user.labels.patchResourceLabels("space", id.trim(), { addLabelRefs: ["Pinned"] });
630
+ ok("Space pinned");
631
+ }
632
+ catch (e) {
633
+ handleHttp(e);
634
+ }
635
+ });
636
+ spacesCmd
637
+ .command("unpin <id>")
638
+ .description("Unpin a space (remove the Pinned user label)")
639
+ .action(async (id) => {
640
+ const client = createClient();
641
+ try {
642
+ await client.user.labels.patchResourceLabels("space", id.trim(), { removeLabelRefs: ["Pinned"] });
643
+ ok("Space unpinned");
644
+ }
645
+ catch (e) {
646
+ handleHttp(e);
647
+ }
648
+ });
608
649
  // ── spaces commerce ──
609
650
  registerSpaceCommerce(spacesCmd);
610
651
  // ── spaces usage ──
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { Command } from "commander";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { registerAuth } from "./commands/auth.js";
5
+ import { registerBoards } from "./commands/boards.js";
5
6
  import { registerChannels } from "./commands/channels.js";
6
7
  import { registerCronJobs } from "./commands/cron-jobs.js";
7
8
  import { registerGenerations } from "./commands/generations.js";
@@ -48,6 +49,7 @@ Common commands:
48
49
  cohub -s <space-id> run -- git status
49
50
  cohub sandbox up ./my-project
50
51
  cohub search "release notes"
52
+ cohub -s <space-id> boards inspect <board-id>
51
53
  cohub -s <space-id> spaces sessions turns ls <session-id>
52
54
  cohub -s <space-id> spaces files ls
53
55
  cohub -s <space-id> works publish demo --file dist/index.html
@@ -61,6 +63,7 @@ Environment:
61
63
  ENV=dev Use the development Cohub environment
62
64
  `);
63
65
  registerAuth(program);
66
+ registerBoards(program);
64
67
  registerProfile(program);
65
68
  registerMe(program);
66
69
  registerPrompt(program);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neta-art/cohub-cli",
3
- "version": "2.7.0",
3
+ "version": "3.1.0",
4
4
  "description": "CLI for Cohub — spaces, sessions, and agent collaboration.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -18,7 +18,8 @@
18
18
  "@neta-art/generation": "^0.1.16",
19
19
  "commander": "^15.0.0",
20
20
  "sharp": "^0.35.3",
21
- "@neta-art/cohub": "2.15.0"
21
+ "@neta-art/cohub": "3.1.0",
22
+ "@neta-art/cohub-board": "0.2.0"
22
23
  },
23
24
  "publishConfig": {
24
25
  "access": "public"
@@ -27,6 +28,9 @@
27
28
  "@types/node": "^26.1.1",
28
29
  "typescript": "^7.0.2"
29
30
  },
31
+ "optionalDependencies": {
32
+ "@napi-rs/canvas": "^1.0.2"
33
+ },
30
34
  "scripts": {
31
35
  "build": "tsc -p tsconfig.build.json",
32
36
  "typecheck": "tsc -p tsconfig.json --noEmit"