@neta-art/cohub-cli 3.10.2 → 3.12.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.
@@ -0,0 +1,110 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { boardNodeCreateOperation, boardNodeDeleteOperations, boardNodePatchOperation, createBoardConnection, createBoardNode, } from "@neta-art/cohub/board";
3
+ import { BOARD_DOMAIN_INPUT_MAX_BYTES, readBoardJsonObject, } from "../../board-command-support.js";
4
+ import { handleHttp } from "../../output.js";
5
+ import { resolvedBoard, showUpdated, withJson, } from "./context.js";
6
+ export function registerBoardNodeCommands(boards) {
7
+ const nodes = boards.command("nodes").description("Create and update Board nodes");
8
+ withJson(nodes.command("add <board>")
9
+ .description("Add a node")
10
+ .requiredOption("-i, --input <file>", "BoardNodeSpec JSON; use - for stdin")
11
+ .addHelpText("after", `
12
+ Frame x/y/width/height use Board world units. Draw points and arrow endpoints are also world input.
13
+ Minimal text node:
14
+ {"id":"title","type":"text","frame":{"x":120,"y":80,"width":320,"height":48},"text":"Launch plan"}`))
15
+ .action(async (target, options) => {
16
+ try {
17
+ const input = await readBoardJsonObject(options.input, BOARD_DOMAIN_INPUT_MAX_BYTES);
18
+ const node = createBoardNode(input);
19
+ const board = await resolvedBoard(boards, target);
20
+ showUpdated(await board.mutate({ build: () => [boardNodeCreateOperation(node)] }), options);
21
+ }
22
+ catch (cause) {
23
+ handleHttp(cause);
24
+ }
25
+ });
26
+ withJson(nodes.command("patch <board> <node-id>")
27
+ .description("Patch a node")
28
+ .requiredOption("-i, --input <file>", "BoardNodeInput field patch; use - for stdin")
29
+ .addHelpText("after", `
30
+ x/y are absolute Board world coordinates.
31
+ Move a node without changing its content:
32
+ {"x":160,"y":120}
33
+
34
+ Nested view, style, and data fields replace their complete stored object.`))
35
+ .action(async (target, nodeId, options) => {
36
+ try {
37
+ const patch = await readBoardJsonObject(options.input, BOARD_DOMAIN_INPUT_MAX_BYTES);
38
+ if ("nodeId" in patch)
39
+ throw new Error("node patch must not contain nodeId");
40
+ const board = await resolvedBoard(boards, target);
41
+ showUpdated(await board.mutate({
42
+ build: () => [boardNodePatchOperation(nodeId, patch)],
43
+ }), options);
44
+ }
45
+ catch (cause) {
46
+ handleHttp(cause);
47
+ }
48
+ });
49
+ withJson(nodes.command("remove <board> <node-id>")
50
+ .alias("rm")
51
+ .description("Remove a node and its connections"))
52
+ .action(async (target, nodeId, options) => {
53
+ try {
54
+ const board = await resolvedBoard(boards, target);
55
+ showUpdated(await board.mutate({
56
+ include: ["connections"],
57
+ build: (current) => boardNodeDeleteOperations(nodeId, current.connections),
58
+ }), options);
59
+ }
60
+ catch (cause) {
61
+ handleHttp(cause);
62
+ }
63
+ });
64
+ withJson(boards.command("connect <board> <source> <target>")
65
+ .description("Connect two Board nodes")
66
+ .option("--id <id>", "Connection id")
67
+ .option("--relation <relation>", "Relation type")
68
+ .option("--direction <direction>", "none, forward, backward, or both", "forward")
69
+ .option("--label <label>", "Connection label")
70
+ .option("--source-port <id>", "Source port id")
71
+ .option("--target-port <id>", "Target port id"))
72
+ .action(async (target, source, destination, options) => {
73
+ try {
74
+ const direction = options.direction ?? "forward";
75
+ if (!["none", "forward", "backward", "both"].includes(direction)) {
76
+ throw new Error("--direction must be none, forward, backward, or both");
77
+ }
78
+ const board = await resolvedBoard(boards, target);
79
+ const connection = createBoardConnection({
80
+ id: options.id ?? randomUUID(),
81
+ sourceNodeId: source,
82
+ targetNodeId: destination,
83
+ relation: options.relation,
84
+ direction: direction,
85
+ label: options.label,
86
+ sourcePortId: options.sourcePort,
87
+ targetPortId: options.targetPort,
88
+ });
89
+ showUpdated(await board.mutate({
90
+ build: () => [{ type: "connection.create", payload: { connection } }],
91
+ }), options);
92
+ }
93
+ catch (cause) {
94
+ handleHttp(cause);
95
+ }
96
+ });
97
+ withJson(boards.command("disconnect <board> <connection-id>")
98
+ .description("Remove a Board connection"))
99
+ .action(async (target, connectionId, options) => {
100
+ try {
101
+ const board = await resolvedBoard(boards, target);
102
+ showUpdated(await board.mutate({
103
+ build: () => [{ type: "connection.delete", payload: { connectionId } }],
104
+ }), options);
105
+ }
106
+ catch (cause) {
107
+ handleHttp(cause);
108
+ }
109
+ });
110
+ }
@@ -1,9 +1,10 @@
1
1
  import type { BoardInspectInput, BoardTransactionInput } from "@neta-art/cohub";
2
2
  import type { Command } from "commander";
3
+ import { parseBoardJsonObject } from "../board-command-support.js";
3
4
  declare const INSPECT_SECTIONS: readonly ["nodes", "connections", "effects", "sequences", "clips", "playback"];
4
5
  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>>;
6
+ export declare const parseJsonObject: typeof parseBoardJsonObject;
7
+ export declare function readJsonObject(source: string, maxBytes?: number): Promise<Record<string, unknown>>;
7
8
  export declare function parseInspectSections(value?: string): InspectSection[] | undefined;
8
9
  export declare function parseViewport(value?: string): BoardInspectInput["viewport"];
9
10
  export declare function createTransactionInput(input: Record<string, unknown>, options: {
@@ -1,36 +1,14 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { readFile, writeFile } from "node:fs/promises";
2
+ import { BOARD_CREATE_INPUT_MAX_BYTES, BOARD_TRANSACTION_INPUT_MAX_BYTES, parseBoardJsonObject, readBoardJsonObject, resolveBoardId, writeBoardOutput, } from "../board-command-support.js";
3
3
  import { BOARD_EXPORT_FORMATS, formatFromPath, runBoardExport } from "../board-export.js";
4
+ import { registerBoardDomainCommands } from "./board-domain.js";
4
5
  import { createClient, createRealtimeClient } from "../client.js";
5
6
  import { error, handleHttp, json as outJson, jsonRequested, ok, table } from "../output.js";
6
7
  import { resolveSpace } from "../space.js";
7
8
  const INSPECT_SECTIONS = ["nodes", "connections", "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);
9
+ export const parseJsonObject = parseBoardJsonObject;
10
+ export async function readJsonObject(source, maxBytes = BOARD_TRANSACTION_INPUT_MAX_BYTES) {
11
+ return readBoardJsonObject(source, maxBytes);
34
12
  }
35
13
  function parseNumber(value, name, options = {}) {
36
14
  if (!value.trim())
@@ -116,6 +94,27 @@ function showBoard(result) {
116
94
  { key: "clips", label: "Clips" },
117
95
  ]);
118
96
  }
97
+ function showSummary(result) {
98
+ const background = result.board.metadata.appearance;
99
+ table([{
100
+ id: result.board.id,
101
+ title: result.board.title,
102
+ version: result.board.version,
103
+ ...result.counts,
104
+ background: background?.background?.kind ?? "default",
105
+ updatedAt: result.board.updatedAt,
106
+ }], [
107
+ { key: "id", label: "ID" },
108
+ { key: "title", label: "TITLE" },
109
+ { key: "version", label: "VERSION" },
110
+ { key: "nodes", label: "NODES" },
111
+ { key: "connections", label: "CONNECTIONS" },
112
+ { key: "effects", label: "EFFECTS" },
113
+ { key: "sequences", label: "SEQUENCES" },
114
+ { key: "background", label: "BACKGROUND" },
115
+ { key: "updatedAt", label: "UPDATED" },
116
+ ]);
117
+ }
119
118
  function showValidation(result) {
120
119
  table([{ valid: result.valid, diagnostics: result.diagnostics.length }], [
121
120
  { key: "valid", label: "Valid" },
@@ -145,16 +144,37 @@ function showPlayback(result) {
145
144
  function withJson(command) {
146
145
  return command.option("--json", "Output as JSON");
147
146
  }
147
+ function capabilityUnits(schema) {
148
+ const params = schema?.params;
149
+ if (!params || typeof params !== "object" || Array.isArray(params))
150
+ return "";
151
+ return Object.entries(params).flatMap(([field, value]) => {
152
+ if (!value || typeof value !== "object" || Array.isArray(value))
153
+ return [];
154
+ const meta = value;
155
+ const detail = [meta.coordinateSpace, meta.unit]
156
+ .filter((item) => typeof item === "string")
157
+ .join("/");
158
+ return detail ? [`${field}:${detail}`] : [];
159
+ }).join(", ");
160
+ }
148
161
  function registerTransactionCommand(boards, name) {
149
- withJson(boards.command(`${name} <board-id>`)
162
+ withJson(boards.command(`${name} <board>`)
150
163
  .description(name === "validate" ? "Validate a transaction" : "Apply a transaction")
151
164
  .requiredOption("-i, --input <file>", "Transaction JSON file; use - for stdin")
152
165
  .option("--tx-id <id>", "Override txId; generated when omitted")
153
- .option("--base-version <version>", "Override baseVersion"))
154
- .action(async (boardId, options) => {
166
+ .option("--base-version <version>", "Override baseVersion")
167
+ .addHelpText("after", `
168
+ Input example:
169
+ {"baseVersion":12,"operations":[{"type":"board.patch","payload":{"patch":{"title":"Launch plan"}}}]}
170
+
171
+ Prefer semantic commands such as boards background, nodes, effects, or sequences for common edits.`))
172
+ .action(async (target, options) => {
155
173
  try {
156
- const transaction = createTransactionInput(await readJsonObject(options.input), options);
157
- const board = createClient().space(resolveSpace(boards)).board(boardId);
174
+ const transaction = createTransactionInput(await readJsonObject(options.input, BOARD_TRANSACTION_INPUT_MAX_BYTES), options);
175
+ const spaceId = resolveSpace(boards);
176
+ const boardId = await resolveBoardId(spaceId, target);
177
+ const board = createClient().space(spaceId).board(boardId);
158
178
  const result = await board[name](transaction);
159
179
  if (jsonRequested(options))
160
180
  return outJson(result);
@@ -240,7 +260,8 @@ function registerExportCommand(boards) {
240
260
  .option("--background <mode>", "paper or transparent", "paper")
241
261
  .option("--format <format>", `Override format (${BOARD_EXPORT_FORMATS.join(", ")})`)
242
262
  .option("--quality <q>", "JPEG/WebP quality from 0 to 1", "0.92")
243
- .option("--no-images", "Skip image downloads and draw placeholders"))
263
+ .option("--no-images", "Skip image downloads and draw placeholders")
264
+ .option("--force", "Replace an existing output file"))
244
265
  .action(async (board, options) => {
245
266
  try {
246
267
  const out = options.out;
@@ -263,7 +284,7 @@ function registerExportCommand(boards) {
263
284
  if (!result) {
264
285
  return error("Nothing to export", "The selected region contains no items.");
265
286
  }
266
- await writeFile(out, result.bytes);
287
+ await writeBoardOutput(out, result.bytes, Boolean(options.force));
267
288
  if (jsonRequested(options)) {
268
289
  return outJson({
269
290
  path: out,
@@ -293,14 +314,26 @@ export function registerBoards(program) {
293
314
  withJson(boards.command("create <path>")
294
315
  .description("Create a Board")
295
316
  .option("--title <title>", "Board title")
296
- .option("-i, --input <file>", "Board content JSON file; use - for stdin"))
317
+ .option("--mutation-id <id>", "Stable id for safe retries")
318
+ .option("-i, --input <file>", "BoardCreateInput fields; use - for stdin")
319
+ .addHelpText("after", `
320
+ For normal use, create an empty Board and add content with boards nodes, effects, and sequences.
321
+ --input is intended for bulk creation and accepts BoardCreateInput fields except path and title.`))
297
322
  .action(async (path, options) => {
298
323
  try {
299
- const content = options.input ? await readJsonObject(options.input) : {};
324
+ const content = options.input
325
+ ? await readJsonObject(options.input, BOARD_CREATE_INPUT_MAX_BYTES)
326
+ : {};
300
327
  if ("path" in content || "title" in content) {
301
328
  throw new Error("create input must not contain path or title; use the command argument and --title");
302
329
  }
303
- const input = { ...content, path, ...(options.title ? { title: options.title } : {}) };
330
+ const input = {
331
+ ...content,
332
+ path,
333
+ mutationId: options.mutationId ??
334
+ (typeof content.mutationId === "string" ? content.mutationId : randomUUID()),
335
+ ...(options.title ? { title: options.title } : {}),
336
+ };
304
337
  const result = await createClient().space(resolveSpace(boards)).boards.create(input);
305
338
  if (jsonRequested(options))
306
339
  return outJson(result);
@@ -311,14 +344,20 @@ export function registerBoards(program) {
311
344
  handleHttp(cause);
312
345
  }
313
346
  });
314
- withJson(boards.command("inspect <board-id>")
347
+ withJson(boards.command("inspect <board>")
315
348
  .alias("get")
316
349
  .description("Inspect a Board")
317
350
  .option("--include <sections>", "Comma-separated nodes,connections,effects,sequences,clips,playback")
318
351
  .option("--viewport <rect>", "Viewport as x,y,width,height"))
319
- .action(async (boardId, options) => {
352
+ .action(async (target, options) => {
320
353
  try {
321
- const result = await createClient().space(resolveSpace(boards)).board(boardId).inspect({
354
+ const spaceId = resolveSpace(boards);
355
+ const boardId = await resolveBoardId(spaceId, target);
356
+ const board = createClient().space(spaceId).board(boardId);
357
+ if (!jsonRequested(options) && !options.include && !options.viewport) {
358
+ return showSummary(await board.summary());
359
+ }
360
+ const result = await board.inspect({
322
361
  include: parseInspectSections(options.include),
323
362
  viewport: parseViewport(options.viewport),
324
363
  });
@@ -330,21 +369,25 @@ export function registerBoards(program) {
330
369
  handleHttp(cause);
331
370
  }
332
371
  });
333
- withJson(boards.command("capabilities <board-id>")
372
+ withJson(boards.command("capabilities <board>")
334
373
  .description("Show supported capabilities"))
335
- .action(async (boardId, options) => {
374
+ .action(async (target, options) => {
336
375
  try {
337
- const result = await createClient().space(resolveSpace(boards)).board(boardId).capabilities();
376
+ const spaceId = resolveSpace(boards);
377
+ const boardId = await resolveBoardId(spaceId, target);
378
+ const result = await createClient().space(spaceId).board(boardId).capabilities();
338
379
  if (jsonRequested(options))
339
380
  return outJson(result);
340
381
  table(result.capabilities.map((capability) => ({
341
382
  ...capability,
342
383
  renderers: capability.renderers?.join(", ") ?? "",
384
+ coordinates: capabilityUnits(capability.schema),
343
385
  })), [
344
386
  { key: "kind", label: "Kind" },
345
387
  { key: "id", label: "ID" },
346
388
  { key: "version", label: "Version" },
347
389
  { key: "renderers", label: "Renderers" },
390
+ { key: "coordinates", label: "Coordinates / units" },
348
391
  { key: "digest", label: "Digest" },
349
392
  ]);
350
393
  const nodes = result.nodes;
@@ -371,16 +414,19 @@ export function registerBoards(program) {
371
414
  });
372
415
  registerTransactionCommand(boards, "validate");
373
416
  registerTransactionCommand(boards, "apply");
417
+ registerBoardDomainCommands(boards);
374
418
  registerExportCommand(boards);
375
- withJson(boards.command("play <board-id> <sequence-id>")
419
+ withJson(boards.command("play <board> <sequence-id>")
376
420
  .description("Start shared playback")
377
421
  .option("--position <time>", "Initial position in milliseconds")
378
422
  .option("--time-scale <scale>", "Playback speed from 0 to 4")
379
423
  .option("--seed <seed>", "Deterministic playback seed")
380
424
  .option("--command-id <id>", "Idempotency command ID"))
381
- .action(async (boardId, sequenceId, options) => {
425
+ .action(async (target, sequenceId, options) => {
382
426
  try {
383
- const result = await createClient().space(resolveSpace(boards)).board(boardId).play({
427
+ const spaceId = resolveSpace(boards);
428
+ const boardId = await resolveBoardId(spaceId, target);
429
+ const result = await createClient().space(spaceId).board(boardId).play({
384
430
  commandId: commandId(options),
385
431
  type: "play",
386
432
  sequenceId,
@@ -397,9 +443,11 @@ export function registerBoards(program) {
397
443
  handleHttp(cause);
398
444
  }
399
445
  });
400
- const playbackAction = (type) => async (boardId, playbackId, options) => {
446
+ const playbackAction = (type) => async (target, playbackId, options) => {
401
447
  try {
402
- const board = createClient().space(resolveSpace(boards)).board(boardId);
448
+ const spaceId = resolveSpace(boards);
449
+ const boardId = await resolveBoardId(spaceId, target);
450
+ const board = createClient().space(spaceId).board(boardId);
403
451
  const id = commandId(options);
404
452
  const result = type === "pause"
405
453
  ? await board.pause({ commandId: id, type: "pause", playbackId })
@@ -412,16 +460,18 @@ export function registerBoards(program) {
412
460
  handleHttp(cause);
413
461
  }
414
462
  };
415
- withJson(boards.command("pause <board-id> <playback-id>")
463
+ withJson(boards.command("pause <board> <playback-id>")
416
464
  .description("Pause playback")
417
465
  .option("--command-id <id>", "Idempotency command ID"))
418
466
  .action(playbackAction("pause"));
419
- withJson(boards.command("seek <board-id> <playback-id> <position>")
467
+ withJson(boards.command("seek <board> <playback-id> <position>")
420
468
  .description("Seek playback")
421
469
  .option("--command-id <id>", "Idempotency command ID"))
422
- .action(async (boardId, playbackId, position, options) => {
470
+ .action(async (target, playbackId, position, options) => {
423
471
  try {
424
- const result = await createClient().space(resolveSpace(boards)).board(boardId).seek({
472
+ const spaceId = resolveSpace(boards);
473
+ const boardId = await resolveBoardId(spaceId, target);
474
+ const result = await createClient().space(spaceId).board(boardId).seek({
425
475
  commandId: commandId(options),
426
476
  type: "seek",
427
477
  playbackId,
@@ -435,18 +485,32 @@ export function registerBoards(program) {
435
485
  handleHttp(cause);
436
486
  }
437
487
  });
438
- withJson(boards.command("stop <board-id> <playback-id>")
488
+ withJson(boards.command("stop <board> <playback-id>")
439
489
  .description("Stop playback")
440
490
  .option("--command-id <id>", "Idempotency command ID"))
441
491
  .action(playbackAction("stop"));
442
- withJson(boards.command("watch <board-id>")
492
+ withJson(boards.command("watch <board>")
443
493
  .description("Stream Board events"))
444
- .action((boardId, options) => {
494
+ .action(async (target, options) => {
445
495
  try {
446
- const board = createRealtimeClient().space(resolveSpace(boards)).board(boardId);
496
+ const spaceId = resolveSpace(boards);
497
+ const boardId = await resolveBoardId(spaceId, target);
498
+ const client = createRealtimeClient();
499
+ const board = client.space(spaceId).board(boardId);
447
500
  if (!jsonRequested(options))
448
501
  process.stderr.write(`Listening for Board ${boardId} events...\n`);
449
- board.subscribe({
502
+ const offConnection = client.onConnection((state) => {
503
+ if (jsonRequested(options)) {
504
+ process.stdout.write(`${JSON.stringify({ type: "connection", ...state })}\n`);
505
+ }
506
+ else {
507
+ const detail = state.state === "reconnecting" && state.attempt
508
+ ? ` (attempt ${state.attempt})`
509
+ : "";
510
+ process.stderr.write(`${state.state}${detail}\n`);
511
+ }
512
+ });
513
+ const offBoard = board.subscribe({
450
514
  event(event) {
451
515
  if (jsonRequested(options)) {
452
516
  process.stdout.write(`${JSON.stringify(event)}\n`);
@@ -463,6 +527,11 @@ export function registerBoards(program) {
463
527
  }
464
528
  },
465
529
  });
530
+ process.once("SIGINT", () => {
531
+ offBoard();
532
+ offConnection();
533
+ process.exit(0);
534
+ });
466
535
  }
467
536
  catch (cause) {
468
537
  handleHttp(cause);
@@ -1,8 +1,38 @@
1
1
  import { readFileSync } from "node:fs";
2
+ import { HttpError, } from "@neta-art/cohub";
2
3
  import { parseWorkRef, UI_COMMAND_DEFAULT_TIMEOUT_MS, UI_COMMAND_MAX_TIMEOUT_MS, } from "@neta-art/cohub";
3
4
  import { createClient } from "../client.js";
4
5
  import { error, handleHttp, json as outJson, jsonRequested, ok } from "../output.js";
5
6
  import { getWorkByRef } from "../work-ref.js";
7
+ const FILE_SCHEME = "file://";
8
+ const WORK_SCHEME = "work://";
9
+ function optionalSpaceId(command) {
10
+ let current = command;
11
+ while (current) {
12
+ const opts = current.opts();
13
+ if (typeof opts.space === "string" && opts.space.trim())
14
+ return opts.space.trim();
15
+ current = current.parent ?? null;
16
+ }
17
+ return process.env.COHUB_SPACE_ID?.trim() || undefined;
18
+ }
19
+ function parseFilePath(value) {
20
+ const path = value.slice(FILE_SCHEME.length).trim();
21
+ if (!path ||
22
+ path.startsWith("/") ||
23
+ path.includes("\0") ||
24
+ path.split("/").some((segment) => segment === "..") ||
25
+ path.includes("\\")) {
26
+ return error("Invalid file path", "Use a relative Space path after file://.");
27
+ }
28
+ return path;
29
+ }
30
+ function hasFileScheme(value) {
31
+ return value.toLowerCase().startsWith(FILE_SCHEME);
32
+ }
33
+ function hasWorkScheme(value) {
34
+ return value.toLowerCase().startsWith(WORK_SCHEME);
35
+ }
6
36
  function readCallInput(opts) {
7
37
  if (opts.data !== undefined && opts.input !== undefined) {
8
38
  return error("Conflicting input", "Use either --data or --input, not both.");
@@ -33,9 +63,11 @@ function parseTimeout(value) {
33
63
  return parsed;
34
64
  }
35
65
  async function resolveWorkTarget(client, ref) {
36
- const parsed = parseWorkRef(ref);
37
- const detail = await getWorkByRef(client, ref);
66
+ const normalized = hasWorkScheme(ref) ? ref.slice(WORK_SCHEME.length) : ref;
67
+ const parsed = parseWorkRef(normalized);
68
+ const detail = await getWorkByRef(client, normalized);
38
69
  return {
70
+ kind: "work",
39
71
  workId: detail.work.id,
40
72
  label: detail.work.slug,
41
73
  launch: {
@@ -44,6 +76,24 @@ async function resolveWorkTarget(client, ref) {
44
76
  },
45
77
  };
46
78
  }
79
+ async function resolvePreviewTarget(client, command, value) {
80
+ if (hasFileScheme(value))
81
+ return { kind: "file", path: parseFilePath(value) };
82
+ if (hasWorkScheme(value))
83
+ return resolveWorkTarget(client, value);
84
+ const spaceId = optionalSpaceId(command);
85
+ if (spaceId) {
86
+ try {
87
+ await client.space(spaceId).files.read(value);
88
+ return { kind: "file", path: value };
89
+ }
90
+ catch (cause) {
91
+ if (!(cause instanceof HttpError) || cause.status !== 404)
92
+ throw cause;
93
+ }
94
+ }
95
+ return resolveWorkTarget(client, value);
96
+ }
47
97
  function reportDispatch(record) {
48
98
  if (record.status !== "pending") {
49
99
  reportOutcome(record, Boolean(record.command.request));
@@ -53,7 +103,7 @@ function reportDispatch(record) {
53
103
  }
54
104
  function reportOutcome(record, called) {
55
105
  if (record.status === "applied") {
56
- ok(called ? "Work preview shown and method called" : "Work preview shown");
106
+ ok(called ? "Work preview shown and method called" : "Preview shown");
57
107
  if (record.result !== undefined) {
58
108
  console.log(typeof record.result === "string" ? record.result : JSON.stringify(record.result, null, 2));
59
109
  }
@@ -70,15 +120,17 @@ Commands reach only the frontend instance that originated the current chat,
70
120
  resolved from request provenance. Nothing else can be targeted.
71
121
 
72
122
  Examples:
73
- cohub ui preview <work-id>
123
+ cohub ui preview <work-or-file>
124
+ cohub ui preview file://src/main.ts
125
+ cohub ui preview work://alice/studio/launch
74
126
  cohub ui preview alice/studio/launch
75
127
  cohub ui preview https://cohub.live/alice/studio/w/launch?view=timeline
76
128
  cohub ui preview <work-id> --call selection.get
77
129
  cohub ui preview <work-id> --call board.focus --data '{"nodeId":"n1"}'
78
130
  `);
79
131
  const preview = ui
80
- .command("preview <work>")
81
- .description("Show a Work preview tab, optionally calling a method it exposes")
132
+ .command("preview <work-or-file>")
133
+ .description("Show a file or Work preview tab, optionally calling a Work method")
82
134
  .option("--call <method>", "Method the Work registered via client.work.surface.handle()")
83
135
  .option("--data <json>", "Inline JSON input for --call")
84
136
  .option("-i, --input <file>", "JSON input file for --call; use - for stdin")
@@ -98,19 +150,24 @@ Examples:
98
150
  const timeoutMs = parseTimeout(opts.timeoutMs);
99
151
  const client = createClient();
100
152
  try {
101
- const target = await resolveWorkTarget(client, work);
153
+ const target = await resolvePreviewTarget(client, preview, work);
154
+ if (target.kind === "file" && opts.call) {
155
+ return error("Unsupported option", "--call only applies to Work previews.");
156
+ }
102
157
  const request = opts.call
103
158
  ? { method: opts.call, ...(callInput === undefined ? {} : { input: callInput }) }
104
159
  : undefined;
105
160
  const input = {
106
161
  command: {
107
162
  type: "preview.show",
108
- preview: {
109
- kind: "work",
110
- workId: target.workId,
111
- label: target.label,
112
- ...(target.launch.search || target.launch.hash ? { launch: target.launch } : {}),
113
- },
163
+ preview: target.kind === "file"
164
+ ? target
165
+ : {
166
+ kind: "work",
167
+ workId: target.workId,
168
+ label: target.label,
169
+ ...(target.launch.search || target.launch.hash ? { launch: target.launch } : {}),
170
+ },
114
171
  ...(request ? { request } : {}),
115
172
  },
116
173
  ...(opts.client ? { targetClientId: opts.client } : {}),
@@ -133,6 +190,8 @@ Examples:
133
190
  });
134
191
  preview.addHelpText("after", `
135
192
  Notes:
193
+ - Use file:// and work:// to make the target explicit.
194
+ - A plain target checks the current Space for a file before resolving a Work.
136
195
  - Showing a preview is idempotent; repeating it re-activates the same tab.
137
196
  - --call waits for the Work to announce readiness, then invokes the method.
138
197
  - Which methods exist is up to the Work author.
@@ -0,0 +1,24 @@
1
+ export declare const REMOTE_IMAGE_MAX_BYTES: number;
2
+ export declare const REMOTE_IMAGE_TIMEOUT_MS = 15000;
3
+ type ResolvedAddress = {
4
+ address: string;
5
+ family: 4 | 6;
6
+ };
7
+ type Lookup = (hostname: string) => Promise<readonly ResolvedAddress[]>;
8
+ type RemoteResponse = {
9
+ status: number;
10
+ headers: Headers;
11
+ bytes: Uint8Array;
12
+ };
13
+ type Requester = (url: URL, address: ResolvedAddress, timeoutMs: number, maxBytes: number) => Promise<RemoteResponse>;
14
+ export type RemoteImageDownloadOptions = {
15
+ lookup?: Lookup;
16
+ requester?: Requester;
17
+ maxBytes?: number;
18
+ timeoutMs?: number;
19
+ };
20
+ export declare function downloadPublicImage(input: string, options?: RemoteImageDownloadOptions): Promise<{
21
+ bytes: Uint8Array;
22
+ mimeType: string;
23
+ }>;
24
+ export {};