@neta-art/cohub-cli 9.1.1 → 10.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,12 @@ export function parseBoardTime(value, name = "time") {
6
6
  return Math.round(Number(match[1]) * (UNITS[(match[2] ?? "").toLowerCase()] ?? 1) * 1000) / 1000;
7
7
  }
8
8
  export function parseBoardTimes(value, name = "--at", limit = 10_000) {
9
+ if (value.includes(",")) {
10
+ const entries = value.split(",");
11
+ if (entries.length > limit)
12
+ throw new Error(`${name}: frame count exceeds the limit of ${limit}`);
13
+ return entries.map((entry) => parseBoardTime(entry, name));
14
+ }
9
15
  const parts = value.split(":");
10
16
  if (parts.length === 1)
11
17
  return [parseBoardTime(value, name)];
@@ -0,0 +1,2 @@
1
+ import type { Command } from "commander";
2
+ export declare function registerBoardAuthoringCommands(boards: Command): void;
@@ -0,0 +1,90 @@
1
+ import { readBoardJsonObject } from "../../board-command-support.js";
2
+ import { loadBoardDocument, resolveBundledFonts } from "../../board-export.js";
3
+ import { boardPathTrack, boardSequencePatch, buildBoardScene, createBoardAnimationSampler, createNodeBoardRenderer, inspectBoardScene, measureBoardText, } from "../../board-kernel.js";
4
+ import { parseBoardTimes } from "../../board-time.js";
5
+ import { handleHttp, json as outJson } from "../../output.js";
6
+ import { resolveSpace } from "../../space.js";
7
+ export function registerBoardAuthoringCommands(boards) {
8
+ boards
9
+ .command("inspect <board>")
10
+ .description("Measure items and report visible layout hints without changing the Board")
11
+ .option("--animation <id>", "Animation to sample")
12
+ .option("--at <times>", "Time, comma-separated times, or start:end:step", "0")
13
+ .option("--items <ids>", "Limit measurements to comma-separated item ids")
14
+ .option("--json", "Output as JSON")
15
+ .action(async (target, options) => {
16
+ try {
17
+ const { document } = await loadBoardDocument(await resolveSpace(boards), target);
18
+ const times = parseBoardTimes(options.at, "--at", 100);
19
+ const sampler = options.animation ? createBoardAnimationSampler(document, options.animation) : null;
20
+ const renderer = await createNodeBoardRenderer({ fonts: resolveBundledFonts() });
21
+ try {
22
+ const ids = options.items ? new Set(options.items.split(",")) : null;
23
+ outJson({
24
+ samples: times.map((time) => {
25
+ const scene = sampler?.at(time).scene ?? buildBoardScene(document);
26
+ return {
27
+ time,
28
+ diagnostics: inspectBoardScene(scene),
29
+ items: scene.items
30
+ .filter((item) => !ids || ids.has(item.id))
31
+ .map((item) => ({
32
+ id: item.id,
33
+ bounds: item.frame,
34
+ ...(item.type === "text" ? { text: measureBoardText(item.props) } : {}),
35
+ })),
36
+ };
37
+ }),
38
+ });
39
+ }
40
+ finally {
41
+ renderer.destroy();
42
+ }
43
+ }
44
+ catch (cause) {
45
+ handleHttp(cause);
46
+ }
47
+ });
48
+ boards
49
+ .command("sequence <board>")
50
+ .description("Compile chapters to an apply-ready animation patch; does not write")
51
+ .requiredOption("-i, --input <file>", "JSON with a chapters array; use - for stdin")
52
+ .requiredOption("--animation <id>", "Parent animation id; recompiling replaces its tracks")
53
+ .action(async (target, options) => {
54
+ try {
55
+ const input = await readBoardJsonObject(options.input);
56
+ if (!Array.isArray(input.chapters) ||
57
+ input.chapters.some((chapter) => !chapter || typeof chapter !== "object" || typeof chapter.animation !== "string"))
58
+ throw new Error("Input needs a chapters array with animation IDs.");
59
+ const { document } = await loadBoardDocument(await resolveSpace(boards), target);
60
+ outJson(boardSequencePatch(document, options.animation, input.chapters));
61
+ }
62
+ catch (cause) {
63
+ handleHttp(cause);
64
+ }
65
+ });
66
+ boards
67
+ .command("path <target>")
68
+ .description("Sample a polyline into a position track; does not write")
69
+ .requiredOption("-i, --input <file>", "JSON with points, duration (ms), optional at, samples and orient")
70
+ .action(async (target, options) => {
71
+ try {
72
+ const input = await readBoardJsonObject(options.input);
73
+ if (!Array.isArray(input.points) || input.points.some((point) => !point || typeof point !== "object"))
74
+ throw new Error("Input needs a points array.");
75
+ if (typeof input.duration !== "number")
76
+ throw new Error("Input needs duration in milliseconds.");
77
+ if (input.orient !== undefined && typeof input.orient !== "boolean")
78
+ throw new Error("orient must be boolean.");
79
+ outJson(boardPathTrack(target, input.points, {
80
+ duration: input.duration,
81
+ ...(input.at === undefined ? {} : { at: Number(input.at) }),
82
+ ...(input.samples === undefined ? {} : { samples: Number(input.samples) }),
83
+ orient: input.orient,
84
+ }));
85
+ }
86
+ catch (cause) {
87
+ handleHttp(cause);
88
+ }
89
+ });
90
+ }
@@ -1,4 +1,5 @@
1
1
  import { json } from "../../output.js";
2
+ import { BOARD_SCRIPT_DRAWING_EXAMPLE } from "../../board-kernel.js";
2
3
  /**
3
4
  * Starter documents for `boards create -i` and patches for `boards apply -i`.
4
5
  * Every example is a valid Board patch; `boards.test.ts` checks that.
@@ -64,14 +65,11 @@ export const BOARD_EXAMPLES = {
64
65
  },
65
66
  },
66
67
  },
67
- sketch: {
68
- description: "A code-drawn item; `src` is a module in the Space exporting draw(ctx, frame).",
68
+ custom: {
69
+ description: "Custom Canvas content; use --module to print scripts/wave.js.",
69
70
  value: {
70
71
  items: {
71
- wave: { type: "sketch", position: { x: 0, y: 0 }, size: { width: 640, height: 360 }, props: { src: "sketches/wave.js", params: { amplitude: 60 } } },
72
- },
73
- animations: {
74
- loop: { duration: 4000, play: "always", tracks: {} },
72
+ wave: { type: "custom", position: { x: 0, y: 0 }, size: { width: 640, height: 360 }, script: { src: "scripts/wave.js", params: { amplitude: 60 } } },
75
73
  },
76
74
  },
77
75
  },
@@ -79,11 +77,18 @@ export const BOARD_EXAMPLES = {
79
77
  export function registerBoardExampleCommands(boards) {
80
78
  boards.command("examples [name]")
81
79
  .description("Print a starter Board document or list the examples")
80
+ .option("--module", "Print the custom example's JavaScript module")
82
81
  .addHelpText("after", `
83
82
  Examples are valid input for create and apply:
84
83
  cohub boards examples lesson > lesson.json
85
84
  cohub boards create lesson.board -i lesson.json`)
86
- .action((name) => {
85
+ .action((name, options) => {
86
+ if (options.module) {
87
+ if (name !== "custom")
88
+ throw new Error("--module requires the custom example.");
89
+ process.stdout.write(BOARD_SCRIPT_DRAWING_EXAMPLE);
90
+ return;
91
+ }
87
92
  if (!name) {
88
93
  for (const [key, example] of Object.entries(BOARD_EXAMPLES))
89
94
  console.log(`${key.padEnd(10)} ${example.description}`);
@@ -9,6 +9,7 @@ import { createClient, createRealtimeClient } from "../client.js";
9
9
  import { error, handleHttp, json as outJson, jsonRequested, ok, table } from "../output.js";
10
10
  import { resolveSpace } from "../space.js";
11
11
  import { registerBoardExampleCommands } from "./boards/examples.js";
12
+ import { registerBoardAuthoringCommands } from "./boards/authoring.js";
12
13
  export const parseJsonObject = parseBoardJsonObject;
13
14
  function withJson(command) {
14
15
  return command.option("--json", "Output as JSON");
@@ -86,7 +87,7 @@ function showPlayback(playback) {
86
87
  }
87
88
  /** One region flag at most: being told to pick beats guessing which one wins. */
88
89
  function parseExportRegion(options) {
89
- const chosen = [options.frame && "--frame", options.items && "--items", options.rect && "--rect"].filter(Boolean);
90
+ const chosen = [options.camera && "--camera", options.frame && "--frame", options.items && "--items", options.rect && "--rect"].filter(Boolean);
90
91
  if (chosen.length > 1)
91
92
  throw new Error(`Pick one region: ${chosen.join(", ")} cannot be combined.`);
92
93
  if (options.frame)
@@ -137,7 +138,9 @@ function registerExportCommand(boards) {
137
138
  withJson(boards.command("export <board>")
138
139
  .description("Render a Board to an image, or an animation to a frame sequence")
139
140
  .requiredOption("-o, --out <file>", "Output file; the extension picks the format. Sequences need %d, e.g. frames/%04d.png")
140
- .option("--at <time>", "A moment (12.5s) or a range start:end:step (0:10s:100ms)")
141
+ .option("--at <time>", "A moment, comma-separated moments, or start:end:step")
142
+ .option("--camera", "Follow the animation camera")
143
+ .option("--size <widthxheight>", "Camera viewport in pixels", "1600x900")
141
144
  .option("--animation <id>", "Animation the --at times refer to")
142
145
  .option("--frame <item-id>", "Export one frame as a page")
143
146
  .option("--items <ids>", "Comma-separated item ids")
@@ -158,9 +161,25 @@ Examples:
158
161
  cohub boards export plan.board --animation lecture --at 4.5s -o moment.png
159
162
  cohub boards export plan.board --animation lecture --at 0:6s:40ms -o frames/%04d.png`)
160
163
  .action(async (target, options) => {
164
+ const controller = new AbortController();
165
+ const cancel = () => controller.abort(new Error("Export cancelled."));
166
+ process.once("SIGINT", cancel);
161
167
  try {
162
168
  const out = options.out;
163
169
  const times = options.at ? parseBoardTimes(options.at) : undefined;
170
+ let camera;
171
+ if (options.camera) {
172
+ if (!options.animation || !times)
173
+ throw new Error("--camera needs --animation and --at.");
174
+ const size = /^(\d+)x(\d+)$/.exec(options.size ?? "1600x900");
175
+ if (!size?.[1] || !size[2])
176
+ throw new Error("--size must be widthxheight, e.g. 1600x900.");
177
+ camera = { width: parseNumber(size[1], "width", { min: 1, max: 8192 }), height: parseNumber(size[2], "height", { min: 1, max: 8192 }) };
178
+ }
179
+ const progress = (completed, total) => {
180
+ if (!jsonRequested(options) && (completed === total || completed % 30 === 0))
181
+ process.stderr.write(`Rendered ${completed}/${total} frames\n`);
182
+ };
164
183
  const pathVideoFormat = videoFormatFromPath(out);
165
184
  const requestedFormat = options.format?.toLowerCase();
166
185
  if (requestedFormat && ![...BOARD_EXPORT_FORMATS, ...BOARD_VIDEO_FORMATS].includes(requestedFormat))
@@ -183,6 +202,10 @@ Examples:
183
202
  const result = await runBoardVideoExport({
184
203
  spaceId: await resolveSpace(boards),
185
204
  target,
205
+ camera,
206
+ onProgress: progress,
207
+ signal: controller.signal,
208
+ force: options.force,
186
209
  region: parseExportRegion(options),
187
210
  times,
188
211
  ...(options.animation ? { animation: options.animation } : {}),
@@ -203,10 +226,25 @@ Examples:
203
226
  console.log(` ! ${warning}`);
204
227
  return;
205
228
  }
229
+ const written = [];
230
+ for (let index = 0; index < (times?.length ?? 1); index++) {
231
+ const path = sequence ? framePath(out, index) : out;
232
+ if (!options.force && existsSync(path))
233
+ throw new Error(`${path} already exists; use --force to replace it.`);
234
+ }
206
235
  const { runBoardExport } = await import("../board-export.js");
207
236
  const result = await runBoardExport({
208
237
  spaceId: await resolveSpace(boards),
209
238
  target,
239
+ camera,
240
+ onProgress: progress,
241
+ signal: controller.signal,
242
+ onFrame: async (frame, index) => {
243
+ const path = sequence ? framePath(out, index) : out;
244
+ await mkdir(dirname(path), { recursive: true });
245
+ await writeBoardOutput(path, frame.bytes, Boolean(options.force));
246
+ written.push({ path, time: frame.time, width: frame.width, height: frame.height, bytes: frame.bytes.length });
247
+ },
210
248
  region: parseExportRegion(options),
211
249
  ...(times ? { times } : {}),
212
250
  ...(options.animation ? { animation: options.animation } : {}),
@@ -220,18 +258,11 @@ Examples:
220
258
  });
221
259
  if (!result)
222
260
  return error("Nothing to export: the region contains no items.");
223
- const written = [];
224
- for (const [index, frame] of result.frames.entries()) {
225
- const path = sequence ? framePath(out, index) : out;
226
- await mkdir(dirname(path), { recursive: true });
227
- await writeBoardOutput(path, frame.bytes, Boolean(options.force));
228
- written.push({ path, time: frame.time, width: frame.width, height: frame.height, bytes: frame.bytes.length });
229
- }
230
261
  if (jsonRequested(options))
231
262
  return outJson({ files: written, warnings: result.warnings });
232
- const first = result.frames[0];
263
+ const first = written[0];
233
264
  if (first) {
234
- const what = sequence ? `${written.length} frames` : (first.format ?? "png").toUpperCase();
265
+ const what = sequence ? `${written.length} frames` : parseExportFormat(options, out).toUpperCase();
235
266
  ok(`Exported ${first.width}×${first.height} ${what} to ${sequence ? dirname(out) || "." : out}`);
236
267
  }
237
268
  for (const warning of result.warnings)
@@ -240,6 +271,9 @@ Examples:
240
271
  catch (cause) {
241
272
  handleHttp(cause);
242
273
  }
274
+ finally {
275
+ process.removeListener("SIGINT", cancel);
276
+ }
243
277
  });
244
278
  }
245
279
  // ─── Commands ────────────────────────────────────────────────────────────────
@@ -460,6 +494,7 @@ Presets: ${BOARD_PRESET_NAMES.join(", ")}
460
494
  });
461
495
  registerBoardExampleCommands(boards);
462
496
  registerExportCommand(boards);
497
+ registerBoardAuthoringCommands(boards);
463
498
  registerPlaybackCommands(boards);
464
499
  withJson(boards.command("watch <board>")
465
500
  .description("Stream Board changes and playback as they happen"))
@@ -1,5 +1,6 @@
1
1
  import { createClient } from "../client.js";
2
2
  import { formatLocalDateTime, handleHttp, json as outJson, jsonRequested, table } from "../output.js";
3
+ import { SESSION_STATUS_COLUMN } from "../session-status.js";
3
4
  function parseInteger(value, name, min) {
4
5
  const parsed = Number.parseInt(value, 10);
5
6
  if (!Number.isSafeInteger(parsed) || parsed < min) {
@@ -34,6 +35,7 @@ export function registerMe(program) {
34
35
  { key: "id", label: "ID" },
35
36
  { key: "spaceId", label: "Space" },
36
37
  { key: "title", label: "Title" },
38
+ SESSION_STATUS_COLUMN,
37
39
  { key: "totalMessages", label: "Messages" },
38
40
  { key: "createdAt", label: "Created" },
39
41
  ]);
@@ -0,0 +1,3 @@
1
+ import type { Command } from "commander";
2
+ export declare function parseDuration(name: string, value: string): number;
3
+ export declare function registerRooms(program: Command): void;
@@ -0,0 +1,232 @@
1
+ import { getAppByRef } from "../app-ref.js";
2
+ import { createClient } from "../client.js";
3
+ import { error, handleHttp, json as outJson, jsonRequested, ok, table, truncateText } from "../output.js";
4
+ const DURATION_RE = /^(\d+)(ms|s|m|h|d)?$/i;
5
+ const DURATION_UNITS = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 };
6
+ export function parseDuration(name, value) {
7
+ const match = DURATION_RE.exec(value.trim());
8
+ if (!match)
9
+ return error(`Invalid ${name}`, "Use a duration such as 90s, 10m or 2h.");
10
+ return Number(match[1]) * (DURATION_UNITS[(match[2] ?? "s").toLowerCase()] ?? 1_000);
11
+ }
12
+ function seconds(name, value, range) {
13
+ const result = Math.round(parseDuration(name, value) / 1000);
14
+ if (result < range[0] || result > range[1]) {
15
+ return error(`Invalid ${name}`, `Use a duration from ${formatSeconds(range[0])} to ${formatSeconds(range[1])}.`);
16
+ }
17
+ return result;
18
+ }
19
+ const formatSeconds = (value) => (value % 3600 === 0 ? `${value / 3600}h` : value % 60 === 0 ? `${value / 60}m` : `${value}s`);
20
+ const MINUTE = 60;
21
+ const DAY = 24 * 60 * MINUTE;
22
+ function parseJson(name, value) {
23
+ try {
24
+ return JSON.parse(value);
25
+ }
26
+ catch {
27
+ return error(`Invalid ${name}`, `${name} must be JSON, e.g. '{"text":"hi"}'.`);
28
+ }
29
+ }
30
+ async function readStdin() {
31
+ let input = "";
32
+ for await (const chunk of process.stdin)
33
+ input += chunk;
34
+ return input;
35
+ }
36
+ function parsePresence(value) {
37
+ const presence = parseJson("presence", value);
38
+ if (presence === null || (typeof presence === "object" && !Array.isArray(presence)))
39
+ return presence;
40
+ return error("Invalid presence", "presence must be a JSON object, or null to clear it.");
41
+ }
42
+ const short = (participantId) => participantId.slice(0, 8);
43
+ function printMembers(view) {
44
+ table(view.members.map((member) => ({
45
+ member: `${short(member.participantId)}${member.participantId === view.participantId ? " (you)" : ""}`,
46
+ kind: member.kind ?? "user",
47
+ model: member.model?.id ?? "",
48
+ presence: member.presence ? truncateText(JSON.stringify(member.presence), 60) : "",
49
+ })), [
50
+ { key: "member", label: "Member" },
51
+ { key: "kind", label: "Kind" },
52
+ { key: "model", label: "Model" },
53
+ { key: "presence", label: "Presence" },
54
+ ]);
55
+ }
56
+ function printView(view) {
57
+ console.log(`Room ${view.room.code} · ${view.room.id}`);
58
+ console.log(`You are ${view.participantId} · sequence ${view.sequence} · expires ${view.room.expiresAt}\n`);
59
+ printMembers(view);
60
+ }
61
+ const formatEvent = (event) => `#${event.sequence} ${event.type} from ${event.self ? "you" : short(event.sender.participantId)} ${JSON.stringify(event.data)}`;
62
+ function printEvents(roomId, page, options) {
63
+ if (page.truncated)
64
+ console.log("Some earlier events are no longer in the room history.");
65
+ if (page.events.length === 0)
66
+ console.log("No new events.");
67
+ for (const event of page.events)
68
+ console.log(formatEvent(event));
69
+ const filter = options.types.map((type) => ` --type '${type}'`).join("");
70
+ const wait = options.wait ? ` --wait ${options.wait}` : "";
71
+ console.log(`\nNext: cohub rooms events ${roomId} --after ${page.cursor}${filter}${wait}`);
72
+ }
73
+ const collect = (value, previous = []) => [...previous, value];
74
+ async function withClient(run) {
75
+ try {
76
+ return await run(createClient());
77
+ }
78
+ catch (e) {
79
+ return handleHttp(e);
80
+ }
81
+ }
82
+ export function registerRooms(program) {
83
+ const rooms = program
84
+ .command("rooms")
85
+ .description("Take part in App rooms: join, read and publish events, set presence")
86
+ .addHelpText("after", `
87
+ A room belongs to a published App. Join one with an invite from the App
88
+ (room.invite()), or create one for an App you manage. Your seat stays while
89
+ you use it and lapses after --idle without a call.
90
+
91
+ An Agent waits for the room in a background command and wakes when it returns:
92
+ cohub rooms join <invite> --presence '{"name":"Guide"}'
93
+ cohub rooms events <room> --type 'chat.*' --wait 10m
94
+ cohub rooms publish <room> chat.message '{"text":"Hello"}'
95
+ `);
96
+ rooms
97
+ .command("create")
98
+ .description("Create a room for an App you manage, with an invite and a link to it")
99
+ .requiredOption("--app <app>", "App: ID, URL, or username/space/app")
100
+ .option("--code <code>", "Room code people type or open with ?room=")
101
+ .option("--ttl <duration>", "Room lifetime, 1m to 24h (default 2h)")
102
+ .option("--max <count>", "Most members at once, 2 to 128 (default 16)")
103
+ .option("--seat-per-user", "Give each person one App seat across tabs")
104
+ .option("--json", "Output as JSON")
105
+ .action((opts) => withClient(async (client) => {
106
+ const app = await getAppByRef(client, opts.app);
107
+ const created = await client.rooms.create({
108
+ appId: app.app.id,
109
+ ...(opts.code ? { code: opts.code } : {}),
110
+ ...(opts.ttl ? { expiresInSeconds: seconds("ttl", opts.ttl, [MINUTE, DAY]) } : {}),
111
+ ...(opts.max ? { maxParticipants: Number(opts.max) } : {}),
112
+ ...(opts.seatPerUser ? { seatPerUser: true } : {}),
113
+ });
114
+ if (jsonRequested(opts))
115
+ return outJson(created);
116
+ console.log(`Room ${created.room.code} · ${created.room.id}`);
117
+ if (created.url)
118
+ console.log(`Open: ${created.url}`);
119
+ console.log(`Invite (until ${created.expiresAt}):\n${created.invite}\n`);
120
+ console.log(`Join: cohub rooms join <invite>`);
121
+ }));
122
+ rooms
123
+ .command("join <invite>")
124
+ .description("Take a seat in a room with an invite")
125
+ .option("--presence <json>", "What others see about you, e.g. '{\"name\":\"Guide\"}'")
126
+ .option("--idle <duration>", "Leave after this long without a call, 1m to 24h (default 30m)")
127
+ .option("--json", "Output as JSON")
128
+ .action((invite, opts) => withClient(async (client) => {
129
+ const view = await client.rooms.join({
130
+ invite,
131
+ ...(opts.presence !== undefined ? { presence: parsePresence(opts.presence) } : {}),
132
+ ...(opts.idle ? { idleSeconds: seconds("idle", opts.idle, [MINUTE, DAY]) } : {}),
133
+ });
134
+ if (jsonRequested(opts))
135
+ return outJson(view);
136
+ printView(view);
137
+ console.log(`\nWait for events: cohub rooms events ${view.room.id} --after ${view.sequence} --wait 10m`);
138
+ }));
139
+ rooms
140
+ .command("get <room>")
141
+ .description("Show the room, its members and your seat")
142
+ .option("--json", "Output as JSON")
143
+ .action((roomId, opts) => withClient(async (client) => {
144
+ const view = await client.rooms.get(roomId);
145
+ if (jsonRequested(opts))
146
+ return outJson(view);
147
+ printView(view);
148
+ }));
149
+ rooms
150
+ .command("events <room>")
151
+ .description("Read published events, oldest first; --wait holds until someone else publishes")
152
+ .option("--after <sequence>", "Only events after this sequence")
153
+ .option("--type <pattern>", "Event type to include, * matches any text; repeatable", collect)
154
+ .option("--wait <duration>", "Wait up to this long for a new event from someone else")
155
+ .option("--follow", "Keep printing events as they arrive")
156
+ .option("--limit <count>", "Most events to return")
157
+ .option("--json", "Output as JSON")
158
+ .action((roomId, opts) => withClient(async (client) => {
159
+ const types = opts.type ?? [];
160
+ const after = opts.after === undefined ? undefined : Number(opts.after);
161
+ if (after !== undefined && (!Number.isInteger(after) || after < 0))
162
+ return error("Invalid --after", "Use a sequence number.");
163
+ const limit = opts.limit === undefined ? undefined : Number(opts.limit);
164
+ if (opts.follow)
165
+ return follow(client, roomId, { after, types, json: jsonRequested(opts) });
166
+ const page = await client.rooms.events(roomId, {
167
+ after,
168
+ types,
169
+ ...(limit ? { limit } : {}),
170
+ ...(opts.wait ? { waitMs: parseDuration("wait", opts.wait) } : {}),
171
+ });
172
+ if (jsonRequested(opts))
173
+ return outJson(page);
174
+ printEvents(roomId, page, { types, wait: opts.wait });
175
+ }));
176
+ rooms
177
+ .command("publish <room> <type> [data]")
178
+ .description("Publish an event to everyone in the room; data is JSON, or - for stdin")
179
+ .option("--json", "Output as JSON")
180
+ .action((roomId, type, data, opts) => withClient(async (client) => {
181
+ const payload = data === undefined ? null : parseJson("data", data === "-" ? await readStdin() : data);
182
+ const result = await client.rooms.publish(roomId, type, payload);
183
+ if (jsonRequested(opts))
184
+ return outJson(result);
185
+ ok(`Published #${result.sequence} ${type}`);
186
+ }));
187
+ rooms
188
+ .command("presence <room> <json>")
189
+ .description("Set what others see about you; null clears it")
190
+ .option("--json", "Output as JSON")
191
+ .action((roomId, value, opts) => withClient(async (client) => {
192
+ const result = await client.rooms.setPresence(roomId, parsePresence(value));
193
+ if (jsonRequested(opts))
194
+ return outJson(result);
195
+ ok("Presence updated");
196
+ }));
197
+ rooms
198
+ .command("invite <room>")
199
+ .description("Create an invite to a room you are in")
200
+ .option("--ttl <duration>", "How long the invite admits people (default 10m)")
201
+ .option("--json", "Output as JSON")
202
+ .action((roomId, opts) => withClient(async (client) => {
203
+ const invite = await client.rooms.invite(roomId, opts.ttl ? { expiresInSeconds: seconds("ttl", opts.ttl, [MINUTE, DAY]) } : {});
204
+ if (jsonRequested(opts))
205
+ return outJson(invite);
206
+ console.log(`Invite (until ${invite.expiresAt}):\n${invite.invite}`);
207
+ }));
208
+ rooms
209
+ .command("leave <room>")
210
+ .description("Give up your seat")
211
+ .option("--json", "Output as JSON")
212
+ .action((roomId, opts) => withClient(async (client) => {
213
+ const result = await client.rooms.leave(roomId);
214
+ if (jsonRequested(opts))
215
+ return outJson(result);
216
+ ok(result.left ? "Left the room" : "You were not in this room");
217
+ }));
218
+ }
219
+ async function follow(client, roomId, input) {
220
+ const controller = new AbortController();
221
+ process.once("SIGINT", () => controller.abort());
222
+ let after = input.after ?? (await client.rooms.get(roomId)).sequence;
223
+ while (!controller.signal.aborted) {
224
+ const page = await client.rooms.events(roomId, { after, types: input.types, waitMs: 25_000, signal: controller.signal })
225
+ .catch((e) => (controller.signal.aborted ? null : Promise.reject(e)));
226
+ if (!page)
227
+ break;
228
+ for (const event of page.events)
229
+ console.log(input.json ? JSON.stringify(event) : formatEvent(event));
230
+ after = page.cursor;
231
+ }
232
+ }
@@ -4,6 +4,7 @@ import { readSentTurns, readSessionTurnOrigin, resolveCohubEnvironment } from "@
4
4
  import { uploadAvatarAsset, uploadChatImageAsset } from "../avatar.js";
5
5
  import { createClient } from "../client.js";
6
6
  import { table, json as outJson, jsonRequested, ok, error, handleHttp, formatEpochMs, truncateText } from "../output.js";
7
+ import { SESSION_STATUS_COLUMN } from "../session-status.js";
7
8
  import { resolveSpace } from "../space.js";
8
9
  import { uploadLocalFiles } from "../space-files/upload.js";
9
10
  import { registerSpaceCommerce } from "./space-commerce.js";
@@ -1329,6 +1330,7 @@ function registerSessions(spacesCmd) {
1329
1330
  table(result.sessions, [
1330
1331
  { key: "id", label: "ID" },
1331
1332
  { key: "title", label: "Title" },
1333
+ SESSION_STATUS_COLUMN,
1332
1334
  { key: "totalMessages", label: "Messages" },
1333
1335
  { key: "createdAt", label: "Created" },
1334
1336
  ]);
@@ -1376,6 +1378,7 @@ function registerSessions(spacesCmd) {
1376
1378
  table([result.session], [
1377
1379
  { key: "id", label: "ID" },
1378
1380
  { key: "title", label: "Title" },
1381
+ SESSION_STATUS_COLUMN,
1379
1382
  { key: "totalMessages", label: "Messages" },
1380
1383
  { key: "totalToolCalls", label: "Tool Calls" },
1381
1384
  { key: "createdAt", label: "Created" },
package/dist/index.js CHANGED
@@ -21,6 +21,7 @@ import { registerRuntime } from "./commands/runtime.js";
21
21
  import { registerTasks } from "./commands/tasks.js";
22
22
  import { registerDesktop, registerLegacyUi } from "./commands/desktop.js";
23
23
  import { registerApps } from "./commands/apps.js";
24
+ import { registerRooms } from "./commands/rooms.js";
24
25
  import { formatUnknownCommandError, resolveHelpPath } from "./help-path.js";
25
26
  const VERSION = (() => {
26
27
  try {
@@ -63,6 +64,7 @@ Common commands:
63
64
  cohub -s <space-id> public upload ./dist demo
64
65
  cohub -s <space-id> apps publish demo --file dist/index.html
65
66
  cohub desktop open <app-id> --call selection.get
67
+ cohub rooms join <invite>
66
68
  cohub -s <space-id> spaces commerce products list
67
69
  cohub models ls
68
70
  cohub models ls --model-type multimodal
@@ -100,6 +102,7 @@ registerReferrals(program);
100
102
  registerTasks(program);
101
103
  registerCronJobs(program);
102
104
  registerApps(program);
105
+ registerRooms(program);
103
106
  registerDesktop(program);
104
107
  registerLegacyUi(program);
105
108
  const argv = process.argv.slice(2);
@@ -2,7 +2,7 @@ import { access, stat } from "node:fs/promises";
2
2
  import { constants } from "node:fs";
3
3
  import { delimiter, join, resolve, win32 } from "node:path";
4
4
  import { JsonRpcProcess, record } from "./json-rpc.js";
5
- import { codexModelCatalog } from "./model-catalog.js";
5
+ import { codexModelCatalog, piModelCatalog } from "./model-catalog.js";
6
6
  import { downloadPublicImage } from "../safe-remote-image.js";
7
7
  export function harnessExecutableCandidates(binary, cwd, path, platform = process.platform, pathExt = process.env.PATHEXT) {
8
8
  const windows = platform === "win32";
@@ -68,11 +68,8 @@ export async function discoverHarnesses(harnesses, options, cwd) {
68
68
  try {
69
69
  if (harness === "pi") {
70
70
  const result = await rpc.request("get_available_models");
71
- for (const value of array(result.models)) {
72
- const model = record(value);
73
- if (model.id && model.provider)
74
- models.push({ harness, id: text(model.id), provider: text(model.provider), name: text(model.name) || text(model.id) });
75
- }
71
+ const state = await rpc.request("get_state");
72
+ models.push(...piModelCatalog(array(result.models), state));
76
73
  }
77
74
  else {
78
75
  await initializeCodex(rpc);
@@ -1,6 +1,14 @@
1
1
  import type { RuntimeCapabilities } from "@neta-art/cohub";
2
2
  import { type JsonRecord } from "./json-rpc.js";
3
3
  type Model = RuntimeCapabilities["models"][number];
4
- /** Codex's built-in catalog is not authoritative for an arbitrary custom provider. */
4
+ /**
5
+ * Publish only picker metadata, never provider credentials or request configuration.
6
+ * `state` is a fresh Pi's `get_state`: its model is what Pi runs when Cohub requests
7
+ * nothing, and its level is resolved and clamped for that model only. Pi's levels for
8
+ * other models live in its settings, which RPC does not expose; those models fall back
9
+ * to the picker's rule, and Cohub always sends the chosen level explicitly.
10
+ */
11
+ export declare function piModelCatalog(entries: unknown[], state: JsonRecord): Model[];
12
+ /** Use Codex's picker-visible model/list for every provider, including custom providers. */
5
13
  export declare function codexModelCatalog(configResponse: JsonRecord, entries: unknown[]): Model[];
6
14
  export {};