@coldtea/pr-lens-cli 0.8.1 → 0.9.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.
Files changed (41) hide show
  1. package/dist/canvas/api.d.ts +104 -1
  2. package/dist/canvas/api.d.ts.map +1 -1
  3. package/dist/canvas/api.js +48 -1
  4. package/dist/canvas/api.js.map +1 -1
  5. package/dist/canvas/live.d.ts +15 -0
  6. package/dist/canvas/live.d.ts.map +1 -0
  7. package/dist/canvas/live.js +417 -0
  8. package/dist/canvas/live.js.map +1 -0
  9. package/dist/canvas/registry.d.ts +8 -0
  10. package/dist/canvas/registry.d.ts.map +1 -1
  11. package/dist/canvas/registry.js +2 -0
  12. package/dist/canvas/registry.js.map +1 -1
  13. package/dist/cli.d.ts.map +1 -1
  14. package/dist/cli.js +1 -0
  15. package/dist/cli.js.map +1 -1
  16. package/dist/commands/auth.d.ts +2 -0
  17. package/dist/commands/auth.d.ts.map +1 -1
  18. package/dist/commands/auth.js +1 -1
  19. package/dist/commands/auth.js.map +1 -1
  20. package/dist/commands/canvas.d.ts.map +1 -1
  21. package/dist/commands/canvas.js +29 -2
  22. package/dist/commands/canvas.js.map +1 -1
  23. package/dist/errors.d.ts +1 -1
  24. package/dist/errors.d.ts.map +1 -1
  25. package/dist/errors.js.map +1 -1
  26. package/dist/skill-content.generated.d.ts +1 -1
  27. package/dist/skill-content.generated.d.ts.map +1 -1
  28. package/dist/skill-content.generated.js +1 -1
  29. package/dist/skill-content.generated.js.map +1 -1
  30. package/dist/version.d.ts +1 -1
  31. package/dist/version.js +1 -1
  32. package/package.json +2 -2
  33. package/src/canvas/api.ts +109 -0
  34. package/src/canvas/live.ts +523 -0
  35. package/src/canvas/registry.ts +2 -0
  36. package/src/cli.ts +1 -0
  37. package/src/commands/auth.ts +1 -1
  38. package/src/commands/canvas.ts +36 -2
  39. package/src/errors.ts +3 -0
  40. package/src/skill-content.generated.ts +1 -1
  41. package/src/version.ts +1 -1
@@ -0,0 +1,523 @@
1
+ import {
2
+ assertNever,
3
+ LiveCommand,
4
+ safeParseGraphDoc,
5
+ type GraphDoc,
6
+ type LiveRef,
7
+ type StepFocus,
8
+ type StepStage,
9
+ type View,
10
+ } from "@coldtea/pr-lens-schema";
11
+
12
+ import { readJsonFile } from "../io.js";
13
+ import { readGraphDoc } from "../document.js";
14
+ import type { Terminal } from "../terminal.js";
15
+ import { askToOpen } from "../commands/auth.js";
16
+ import { PrLensCliError, usageError } from "../errors.js";
17
+ import { parseOptions, readBoolean, readList, readString } from "../args.js";
18
+ import { openLive, readLook, sendLive, unknownPlaces, type TabState } from "./api.js";
19
+ import { readApi, settlePendingRotation, writeCredential } from "./write.js";
20
+ import {
21
+ ensureRegistryHome,
22
+ findBySource,
23
+ readRegistry,
24
+ REGISTRY_PATH,
25
+ selectCanvas,
26
+ updateRegistry,
27
+ type CanvasRegistry,
28
+ type Registered,
29
+ } from "./registry.js";
30
+
31
+ /**
32
+ * Live mode: the reader's own coding agent answers on a canvas open beside
33
+ * it. `open` pairs one browser tab; the other four talk to that tab through
34
+ * the app, which checks every id against the canvas before relaying anything.
35
+ */
36
+
37
+ type Env = Record<string, string | undefined>;
38
+
39
+ type Paired = { target: Registered; api: string; token: string; session: string };
40
+
41
+ /**
42
+ * The canvas a live command means: the one named, else the only one in the
43
+ * registry, else the only one with a tab paired. The last is what makes a
44
+ * checkout holding several canvases still need no flag while one is open.
45
+ */
46
+ type CanvasChoice = { kind: "drawing"; path: string } | { kind: "canvas"; ref: string } | { kind: "unnamed" };
47
+
48
+ const canvasOf = (values: { canvas?: unknown; drawing?: unknown }): CanvasChoice => {
49
+ const path = readString(values.drawing, "drawing");
50
+ const ref = readString(values.canvas, "canvas");
51
+ if (path !== undefined && ref !== undefined) throw usageError("pass --drawing or --canvas, not both");
52
+ if (path !== undefined) return { kind: "drawing", path };
53
+ return ref === undefined ? { kind: "unnamed" } : { kind: "canvas", ref };
54
+ };
55
+
56
+ const liveCanvas = (registry: CanvasRegistry, choice: CanvasChoice): Registered => {
57
+ switch (choice.kind) {
58
+ case "drawing": {
59
+ const found = findBySource(registry, choice.path);
60
+ if (found === undefined) throw notPushed(choice.path);
61
+ return found;
62
+ }
63
+ case "canvas":
64
+ return selectCanvas(registry, choice.ref);
65
+ case "unnamed":
66
+ break;
67
+ default:
68
+ return assertNever(choice, "Unhandled canvas choice");
69
+ }
70
+
71
+ const paired = Object.entries(registry.canvases).filter(([, entry]) => entry.live !== undefined);
72
+ const [only, ...more] = paired;
73
+ if (only !== undefined && more.length === 0) return { id: only[0], entry: only[1] };
74
+
75
+ return selectCanvas(registry, undefined);
76
+ };
77
+
78
+ const notPushed = (drawing: string): PrLensCliError =>
79
+ new PrLensCliError("CANVAS_UNREGISTERED", `${drawing} has not been pushed from this checkout`, "pr-lens canvas push puts it on a canvas first");
80
+
81
+ const paired = async (choice: CanvasChoice, apiFlag: unknown, terminal: Terminal, env: Env): Promise<Paired> => {
82
+ const api = readApi(apiFlag, env);
83
+ const selected = liveCanvas(await readRegistry(), choice);
84
+ const target = await settlePendingRotation(api, selected, terminal, env);
85
+
86
+ const session = target.entry.live?.session;
87
+ if (session === undefined)
88
+ throw new PrLensCliError(
89
+ "LIVE_UNOPENED",
90
+ `no tab is paired with ${target.id}`,
91
+ "pr-lens canvas open <drawing> opens one that follows your agent",
92
+ );
93
+
94
+ return { target, api, token: await writeCredential(target, env, api), session };
95
+ };
96
+
97
+ /** An ended session is forgotten here too, so the next command says so without asking the app. */
98
+ const forgetEnded = async (error: unknown, { target, session }: Paired, terminal: Terminal): Promise<never> => {
99
+ if (error instanceof PrLensCliError && error.code === "LIVE_ENDED")
100
+ await updateRegistry((current) => {
101
+ const entry = current.canvases[target.id];
102
+ if (entry?.live?.session !== session) return;
103
+ current.canvases[target.id] = { ...entry, live: undefined };
104
+ }, terminal);
105
+ throw error;
106
+ };
107
+
108
+ const send = async (pairing: Paired, command: LiveCommand, terminal: Terminal): Promise<TabState> =>
109
+ sendLive(pairing.api, pairing.target.id, pairing.token, pairing.session, command).then(
110
+ ({ tab }) => tab,
111
+ (error: unknown) => forgetEnded(error, pairing, terminal),
112
+ );
113
+
114
+ /**
115
+ * What was drawn, when this checkout still has it. The app checks every id
116
+ * again, so a document that has moved or gone only means the check happens
117
+ * there instead of here.
118
+ */
119
+ const drawnDocument = async ({ entry }: Registered): Promise<GraphDoc | undefined> => {
120
+ if (entry.source === undefined) return undefined;
121
+ return readGraphDoc(entry.source).catch((error: unknown) => {
122
+ if (error instanceof PrLensCliError) return undefined;
123
+ throw error;
124
+ });
125
+ };
126
+
127
+ type Afterword = "answer" | "show" | "fork";
128
+
129
+ const tellTab = (tab: TabState, after: Afterword, terminal: Terminal): void => {
130
+ switch (tab) {
131
+ case "following":
132
+ terminal.out(" the reader's tab is following along");
133
+ return;
134
+ case "stepped_out":
135
+ switch (after) {
136
+ case "answer":
137
+ terminal.out(" the reader stepped out of agent mode, so the answer is waiting in their Questions list");
138
+ return;
139
+ case "show":
140
+ terminal.out(" the reader stepped out of agent mode, so their canvas did not move");
141
+ return;
142
+ case "fork":
143
+ terminal.out(" the reader stepped out of agent mode; the drawing is there when they come back");
144
+ return;
145
+ default:
146
+ return assertNever(after, "Unhandled live command");
147
+ }
148
+ case "not_open":
149
+ terminal.out(" no tab has opened the link yet: pr-lens canvas open opens one");
150
+ return;
151
+ default:
152
+ return assertNever(tab, "Unhandled tab state");
153
+ }
154
+ };
155
+
156
+ const flatViews = (views: readonly View[]): View[] => views.flatMap((view) => [view, ...flatViews(view.children)]);
157
+
158
+ /** Every id the canvas answers to, spelled the way a live command spells it. */
159
+ type Places = {
160
+ components: Set<string>;
161
+ edges: Set<string>;
162
+ lanes: Set<string>;
163
+ views: Set<string>;
164
+ flows: Map<string, Set<string>>;
165
+ };
166
+
167
+ const placesOf = (doc: GraphDoc): Places => ({
168
+ components: new Set(doc.nodes.map((node) => node.id)),
169
+ edges: new Set(doc.edges.map((edge) => edge.id)),
170
+ lanes: new Set(doc.lanes.map((lane) => lane.id)),
171
+ views: new Set(flatViews(doc.views).map((view) => view.id)),
172
+ flows: new Map(doc.flows.map((flow) => [flow.id, new Set(flow.messages.map((message) => message.id))])),
173
+ });
174
+
175
+ const messageKeys = (places: Places): string[] =>
176
+ [...places.flows].flatMap(([flow, messages]) => [...messages].map((message) => `${flow}/${message}`));
177
+
178
+ const validOf = (places: Places) => ({
179
+ components: [...places.components],
180
+ messages: messageKeys(places),
181
+ diagrams: [...places.views, ...places.flows.keys()],
182
+ });
183
+
184
+ /** `flow/message`, or a message's own id when some flow has it. */
185
+ const hasMessage = (places: Places, id: string): boolean => {
186
+ const slash = id.indexOf("/");
187
+ const flow = slash === -1 ? undefined : places.flows.get(id.slice(0, slash));
188
+ if (flow?.has(id.slice(slash + 1)) === true) return true;
189
+ return [...places.flows.values()].some((messages) => messages.has(id));
190
+ };
191
+
192
+ const hasDiagram = (places: Places, id: string): boolean => {
193
+ const bare = id.replace(/^(view|flow):/, "");
194
+ return places.views.has(bare) || places.flows.has(bare);
195
+ };
196
+
197
+ const stageProblems = (places: Places, stage: StepStage | undefined, at: string): string[] => {
198
+ if (stage === undefined) return [];
199
+ switch (stage.kind) {
200
+ case "view":
201
+ return places.views.has(stage.view) ? [] : [`${at}.view: no view "${stage.view}"`];
202
+ case "flow":
203
+ return places.flows.has(stage.flow) ? [] : [`${at}.flow: no flow "${stage.flow}"`];
204
+ default:
205
+ return assertNever(stage, "Unhandled step stage");
206
+ }
207
+ };
208
+
209
+ const focusProblems = (places: Places, focus: StepFocus, at: string): string[] => {
210
+ switch (focus.kind) {
211
+ case "all":
212
+ return [];
213
+ case "selection": {
214
+ const missing = (field: string, ids: readonly string[], known: (id: string) => boolean, noun: string) =>
215
+ ids.flatMap((id, index) => (known(id) ? [] : [`${at}.${field}[${index}]: no ${noun} "${id}"`]));
216
+ return [
217
+ ...missing("nodes", focus.nodes, (id) => places.components.has(id), "component"),
218
+ ...missing("edges", focus.edges, (id) => places.edges.has(id), "edge"),
219
+ ...missing("lanes", focus.lanes, (id) => places.lanes.has(id), "lane"),
220
+ ...missing("messages", focus.messages, (id) => hasMessage(places, id), "message"),
221
+ ];
222
+ }
223
+ default:
224
+ return assertNever(focus, "Unhandled step focus");
225
+ }
226
+ };
227
+
228
+ const refProblem = (places: Places, ref: LiveRef, at: string): string[] => {
229
+ switch (ref.kind) {
230
+ case "component":
231
+ return places.components.has(ref.id) ? [] : [`${at}: no component "${ref.id}"`];
232
+ case "message":
233
+ return hasMessage(places, ref.id) ? [] : [`${at}: no message "${ref.id}"`];
234
+ case "diagram":
235
+ return hasDiagram(places, ref.id) ? [] : [`${at}: no view or flow "${ref.id}"`];
236
+ default:
237
+ return assertNever(ref.kind, "Unhandled live ref");
238
+ }
239
+ };
240
+
241
+ /** The ids a command names that the drawn document does not have, each with where it was named. */
242
+ const commandProblems = (places: Places, command: LiveCommand): string[] => {
243
+ switch (command.kind) {
244
+ case "answer":
245
+ return command.steps.flatMap((step, s) => [
246
+ ...stageProblems(places, step.stage, `steps[${s}].stage`),
247
+ ...focusProblems(places, step.focus, `steps[${s}].focus`),
248
+ ...step.paragraphs.flatMap((paragraph, p) =>
249
+ paragraph.parts.flatMap((part, i) =>
250
+ part.ref === undefined ? [] : refProblem(places, part.ref, `steps[${s}].paragraphs[${p}].parts[${i}].ref`),
251
+ ),
252
+ ),
253
+ ]);
254
+ case "show":
255
+ return [
256
+ ...stageProblems(places, command.stage, "stage"),
257
+ ...focusProblems(places, command.focus, "focus"),
258
+ ...(command.open === undefined || hasMessage(places, command.open.message)
259
+ ? []
260
+ : [`open.message: no message "${command.open.message}"`]),
261
+ ];
262
+ case "fork":
263
+ return command.subject.components.flatMap((id, index) =>
264
+ places.components.has(id) ? [] : [`subject.components[${index}]: no component "${id}"`],
265
+ );
266
+ default:
267
+ return assertNever(command, "Unhandled live command");
268
+ }
269
+ };
270
+
271
+ const checkLocally = (doc: GraphDoc | undefined, command: LiveCommand): void => {
272
+ if (doc === undefined) return;
273
+ const places = placesOf(doc);
274
+ const problems = commandProblems(places, command);
275
+ if (problems.length > 0) throw unknownPlaces(problems, validOf(places));
276
+ };
277
+
278
+ const parseCommand = (input: unknown, what: string): LiveCommand => {
279
+ const parsed = LiveCommand.safeParse(input);
280
+ if (parsed.success) return parsed.data;
281
+ throw new PrLensCliError(
282
+ "INVALID_DOCUMENT",
283
+ `${what} is not a live command the canvas takes`,
284
+ parsed.error.issues
285
+ .map((issue) => (issue.path.length === 0 ? issue.message : `${issue.path.join(".")}: ${issue.message}`))
286
+ .join("\n"),
287
+ );
288
+ };
289
+
290
+ const readStdin = async (): Promise<string> => {
291
+ const chunks: Buffer[] = [];
292
+ for await (const chunk of process.stdin) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
293
+ return Buffer.concat(chunks).toString("utf8");
294
+ };
295
+
296
+ const readAnswerInput = async (path: string): Promise<unknown> => {
297
+ if (path !== "-") return readJsonFile(path);
298
+ const text = await readStdin();
299
+ try {
300
+ return JSON.parse(text);
301
+ } catch (error) {
302
+ throw new PrLensCliError(
303
+ "UNREADABLE_FILE",
304
+ "the answer on stdin is not valid JSON",
305
+ error instanceof Error ? error.message : String(error),
306
+ );
307
+ }
308
+ };
309
+
310
+ /** The answer on its own, `{ question, steps }`, or already wrapped as a command. */
311
+ const asAnswer = (input: unknown): unknown =>
312
+ typeof input === "object" && input !== null && !("kind" in input) ? { kind: "answer", ...input } : input;
313
+
314
+ /** Like a bare push: one is the answer, and several are reported by the paths to pass. */
315
+ const onlyPushed = (registry: CanvasRegistry): Registered => {
316
+ const all = Object.entries(registry.canvases);
317
+ const [only, ...more] = all;
318
+ if (only === undefined || more.length === 0) return selectCanvas(registry, undefined);
319
+
320
+ const sources = all.flatMap(([, entry]) => (entry.source === undefined ? [] : [entry.source]));
321
+ throw usageError(
322
+ `${all.length} canvases in this checkout`,
323
+ sources.length === 0 ? "pass --canvas <id|name>" : `name the drawing to open: ${sources.join(", ")}`,
324
+ );
325
+ };
326
+
327
+ export const openLiveCommand = async (args: readonly string[], terminal: Terminal, env: Env): Promise<void> => {
328
+ const { values, positionals } = parseOptions(args, {
329
+ canvas: { type: "string" },
330
+ api: { type: "string" },
331
+ "no-browser": { type: "boolean" },
332
+ });
333
+ if (positionals.length > 1) throw usageError(`open takes one drawing, got ${positionals.length}`);
334
+
335
+ const api = readApi(values.api, env);
336
+ await ensureRegistryHome(terminal);
337
+ const registry = await readRegistry();
338
+ const [drawing] = positionals;
339
+ const ref = readString(values.canvas, "canvas");
340
+ const selected =
341
+ drawing !== undefined ? findBySource(registry, drawing) : ref !== undefined ? selectCanvas(registry, ref) : onlyPushed(registry);
342
+ if (selected === undefined) throw notPushed(drawing ?? "that drawing");
343
+
344
+ const target = await settlePendingRotation(api, selected, terminal, env);
345
+ const opened = await openLive(api, target.id, await writeCredential(target, env, api));
346
+
347
+ await updateRegistry((current) => {
348
+ const entry = current.canvases[target.id];
349
+ if (entry === undefined) return;
350
+ current.canvases[target.id] = { ...entry, live: { session: opened.session, expiresAt: opened.expiresAt } };
351
+ }, terminal);
352
+
353
+ const opening = readBoolean(values["no-browser"]) ? false : askToOpen(opened.url);
354
+ terminal.out(`✓ ${opening ? "opening" : "open"} ${opened.url}`);
355
+ terminal.out(" a tab opened with this link follows your agent; anyone with the plain view link sees the canvas as it is");
356
+ terminal.out(" the session ends after 2 hours with nothing sent to it");
357
+ };
358
+
359
+ export const answerCommand = async (args: readonly string[], terminal: Terminal, env: Env): Promise<void> => {
360
+ const { values, positionals } = parseOptions(args, {
361
+ canvas: { type: "string" },
362
+ drawing: { type: "string" },
363
+ api: { type: "string" },
364
+ });
365
+ const [path, ...more] = positionals;
366
+ if (path === undefined || more.length > 0)
367
+ throw usageError("answer takes one file holding the answer, or - to read it from stdin");
368
+
369
+ const command = parseCommand(asAnswer(await readAnswerInput(path)), path === "-" ? "the answer on stdin" : path);
370
+ if (command.kind !== "answer") throw usageError(`${path} holds a ${command.kind} command, not an answer`);
371
+
372
+ const pairing = await paired(canvasOf(values), values.api, terminal, env);
373
+ checkLocally(await drawnDocument(pairing.target), command);
374
+
375
+ const tab = await send(pairing, command, terminal);
376
+ terminal.out(`✓ answered on ${pairing.target.id} in ${command.steps.length} ${command.steps.length === 1 ? "step" : "steps"}`);
377
+ tellTab(tab, "answer", terminal);
378
+ };
379
+
380
+ type Stage = StepStage | undefined;
381
+
382
+ const stageFor = (doc: GraphDoc | undefined, id: string | undefined): Stage => {
383
+ if (id === undefined) return undefined;
384
+ if (id.startsWith("view:")) return { kind: "view", view: id.slice("view:".length) };
385
+ if (id.startsWith("flow:")) return { kind: "flow", flow: id.slice("flow:".length) };
386
+ if (doc === undefined) return { kind: "view", view: id };
387
+
388
+ const places = placesOf(doc);
389
+ if (places.views.has(id)) return { kind: "view", view: id };
390
+ if (places.flows.has(id)) return { kind: "flow", flow: id };
391
+ throw unknownPlaces([`--diagram: no view or flow "${id}"`], validOf(places));
392
+ };
393
+
394
+ /** Sorted into the selection a walkthrough step takes, by what the drawn document calls each id. */
395
+ const focusFor = (doc: GraphDoc | undefined, ids: readonly string[]): StepFocus => {
396
+ if (ids.length === 0) return { kind: "all" };
397
+ const places = doc === undefined ? undefined : placesOf(doc);
398
+ const focus = { kind: "selection" as const, lanes: new Array<string>(), nodes: new Array<string>(), edges: new Array<string>(), messages: new Array<string>() };
399
+ const unknown: string[] = [];
400
+
401
+ for (const id of ids) {
402
+ if (places === undefined || places.components.has(id)) focus.nodes.push(id);
403
+ else if (places.edges.has(id)) focus.edges.push(id);
404
+ else if (places.lanes.has(id)) focus.lanes.push(id);
405
+ else if (hasMessage(places, id)) focus.messages.push(id);
406
+ else unknown.push(`--focus: no component, message, edge or lane "${id}"`);
407
+ }
408
+ if (places !== undefined && unknown.length > 0) throw unknownPlaces(unknown, validOf(places));
409
+ return focus;
410
+ };
411
+
412
+ export const showCommand = async (args: readonly string[], terminal: Terminal, env: Env): Promise<void> => {
413
+ const { values, positionals } = parseOptions(args, {
414
+ canvas: { type: "string" },
415
+ drawing: { type: "string" },
416
+ api: { type: "string" },
417
+ focus: { type: "string", multiple: true },
418
+ diagram: { type: "string" },
419
+ open: { type: "string" },
420
+ });
421
+ if (positionals.length > 0) throw usageError(`show takes no positional arguments, got ${positionals.join(" ")}`);
422
+
423
+ const pairing = await paired(canvasOf(values), values.api, terminal, env);
424
+ const doc = await drawnDocument(pairing.target);
425
+ const stage = stageFor(doc, readString(values.diagram, "diagram"));
426
+ const open = readString(values.open, "open");
427
+ const command = parseCommand(
428
+ {
429
+ kind: "show",
430
+ ...(stage === undefined ? {} : { stage }),
431
+ focus: focusFor(doc, readList(values.focus, "focus") ?? []),
432
+ ...(open === undefined ? {} : { open: { message: open } }),
433
+ },
434
+ "show",
435
+ );
436
+ checkLocally(doc, command);
437
+
438
+ const tab = await send(pairing, command, terminal);
439
+ terminal.out(`✓ moved ${pairing.target.id}${open === undefined ? "" : ` and opened ${open}`}`);
440
+ tellTab(tab, "show", terminal);
441
+ };
442
+
443
+ /** A drawing of what is inside a component comes from the same repository, so it may borrow where the canvas came from. */
444
+ const withCanvasOrigin = (sketch: unknown, doc: GraphDoc | undefined): unknown => {
445
+ if (typeof sketch !== "object" || sketch === null || Array.isArray(sketch) || doc === undefined) return sketch;
446
+ return {
447
+ schemaVersion: doc.schemaVersion,
448
+ kind: "graph",
449
+ provenance: doc.provenance,
450
+ ...sketch,
451
+ };
452
+ };
453
+
454
+ export const forkCommand = async (args: readonly string[], terminal: Terminal, env: Env): Promise<void> => {
455
+ const { values, positionals } = parseOptions(args, {
456
+ canvas: { type: "string" },
457
+ drawing: { type: "string" },
458
+ api: { type: "string" },
459
+ from: { type: "string", multiple: true },
460
+ });
461
+ const [path, ...more] = positionals;
462
+ if (path === undefined || more.length > 0) throw usageError("fork takes one sketch, a graph document of what is inside");
463
+ const from = readList(values.from, "from") ?? [];
464
+ if (from.length === 0) throw usageError("fork needs --from <component>, the part the drawing hangs from");
465
+
466
+ const pairing = await paired(canvasOf(values), values.api, terminal, env);
467
+ const doc = await drawnDocument(pairing.target);
468
+
469
+ const sketch = safeParseGraphDoc(withCanvasOrigin(await readJsonFile(path), doc));
470
+ if (!sketch.ok)
471
+ throw new PrLensCliError(
472
+ "INVALID_DOCUMENT",
473
+ `${path} is not a valid graph document [${sketch.error.code}]`,
474
+ sketch.error.issues.map((issue) => (issue.path === "" ? issue.message : `${issue.path}: ${issue.message}`)).join("\n"),
475
+ );
476
+
477
+ const command = parseCommand({ kind: "fork", subject: { components: from }, sketch: sketch.value }, path);
478
+ checkLocally(doc, command);
479
+
480
+ const tab = await send(pairing, command, terminal);
481
+ terminal.out(`✓ drew inside ${from.join(", ")} on ${pairing.target.id}`);
482
+ tellTab(tab, "fork", terminal);
483
+ };
484
+
485
+ export const lookCommand = async (args: readonly string[], terminal: Terminal, env: Env): Promise<void> => {
486
+ const { values, positionals } = parseOptions(args, {
487
+ canvas: { type: "string" },
488
+ drawing: { type: "string" },
489
+ api: { type: "string" },
490
+ });
491
+ if (positionals.length > 0) throw usageError(`look takes no positional arguments, got ${positionals.join(" ")}`);
492
+
493
+ const pairing = await paired(canvasOf(values), values.api, terminal, env);
494
+ const read = await readLook(pairing.api, pairing.target.id, pairing.token, pairing.session).catch((error: unknown) =>
495
+ forgetEnded(error, pairing, terminal),
496
+ );
497
+
498
+ switch (read.status) {
499
+ case "not_open":
500
+ terminal.out(`no tab has reported yet: open the link pr-lens canvas open printed, or run it again to get a new one`);
501
+ return;
502
+ case "seen":
503
+ terminal.out(JSON.stringify(read.look, null, 2));
504
+ return;
505
+ default:
506
+ return assertNever(read, "Unhandled look");
507
+ }
508
+ };
509
+
510
+ export const LIVE_USAGE = ` pr-lens canvas open <drawing> open a tab that follows your coding agent: name the
511
+ .pr-lens/<drawing>/drawn.graph.json you pushed; its
512
+ session is kept in ${REGISTRY_PATH}
513
+ --no-browser print the link instead of opening it
514
+
515
+ pr-lens canvas answer <file|-> answer on the open canvas: { question, steps } as JSON
516
+ pr-lens canvas show move the open canvas
517
+ --focus <id> a component, message (flow/message), edge or lane; repeat for more
518
+ --diagram <view|flow> the diagram to show (default the opening one)
519
+ --open <flow/message> open that message's payload rail
520
+ pr-lens canvas fork <sketch.json> draw what is inside a component, under the canvas
521
+ --from <component> the part it hangs from; repeat for more
522
+ pr-lens canvas look what the reader is looking at and has selected, as JSON`;
523
+
@@ -45,6 +45,8 @@ const Entry = z.object({
45
45
  /** Next token of a rotation whose answer has not arrived yet, so it can be asked again. */
46
46
  pending: z.string().optional(),
47
47
  rev: z.number().int().nonnegative(),
48
+ /** The tab `canvas open` paired, which `answer`, `show`, `fork` and `look` talk to. */
49
+ live: z.object({ session: z.string(), expiresAt: z.string() }).optional(),
48
50
  });
49
51
 
50
52
  const Registry = z.object({
package/src/cli.ts CHANGED
@@ -26,6 +26,7 @@ const HELP = `pr-lens — review what actually matters
26
26
  pr-lens validate <file...> any PR Lens document, checked against the contract
27
27
  pr-lens export <graph.json> the merged state, as a map worth committing
28
28
  pr-lens canvas list | push | pull | claim | rotate | delete that document, kept on prlens.dev as a page and an embed
29
+ open | answer | show | fork | look your coding agent, answering on that page
29
30
  pr-lens auth login | status | logout sign this machine in, so the canvases it pushes are yours
30
31
  pr-lens skill diagram instructions for coding agents
31
32
 
@@ -72,7 +72,7 @@ const sleep = (ms: number): Promise<void> =>
72
72
  const hostOf = (api: string): string => new URL(api).host;
73
73
 
74
74
  /** True means asked, not opened (over SSH it "succeeds" on no screen), so the link is printed either way. */
75
- const askToOpen = (url: string): boolean => {
75
+ export const askToOpen = (url: string): boolean => {
76
76
  // The URL came off the wire: only http(s), and never through a shell
77
77
  // (hence `rundll32` over `cmd /c start`).
78
78
  const scheme = ((): string => {
@@ -36,6 +36,14 @@ import { drawings, WORKSPACE_DIR } from "../workspace.js";
36
36
  import { readToken, requireToken } from "../auth.js";
37
37
  import { claimCommand } from "../canvas/claim.js";
38
38
  import { deleteCommand } from "../canvas/delete.js";
39
+ import {
40
+ answerCommand,
41
+ forkCommand,
42
+ LIVE_USAGE,
43
+ lookCommand,
44
+ openLiveCommand,
45
+ showCommand,
46
+ } from "../canvas/live.js";
39
47
  import { parseOptions, readBoolean, readString } from "../args.js";
40
48
  import { PrLensCliError, usageError } from "../errors.js";
41
49
  import { DEFAULT_API, API_ENV, readApi, requireSameApi, requireWriteToken, settleRotation, settlePendingRotation, writeCredential } from "../canvas/write.js";
@@ -53,13 +61,18 @@ const SUBCOMMANDS = [
53
61
  "claim",
54
62
  "rotate",
55
63
  "delete",
64
+ "open",
65
+ "answer",
66
+ "show",
67
+ "fork",
68
+ "look",
56
69
  ] as const;
57
70
  type Subcommand = (typeof SUBCOMMANDS)[number];
58
71
 
59
72
  const isSubcommand = (value: string): value is Subcommand =>
60
73
  SUBCOMMANDS.some((subcommand) => subcommand === value);
61
74
 
62
- export const USAGE = `pr-lens canvas <list | push | pull | claim | rotate | delete> [options]
75
+ export const USAGE = `pr-lens canvas <list | push | pull | claim | rotate | delete | open | answer | show | fork | look> [options]
63
76
 
64
77
  Keeps a graph document on the PR Lens app as a canvas: a page anyone you share
65
78
  it with can read, and an SVG a README can embed. The write token lands in
@@ -91,6 +104,16 @@ in its fragment, so share the view link and keep the edit link to yourself.
91
104
  pr-lens canvas delete permanently delete the hosted canvas; keep local graph and SVG files
92
105
  --canvas <id|name> which canvas (default the checkout's only canvas)
93
106
 
107
+ Live mode puts your coding agent beside an open canvas: it answers there, with
108
+ the camera, the highlights and the links the page's own agent uses.
109
+
110
+ ${LIVE_USAGE}
111
+
112
+ Every live command takes the drawing it is about:
113
+ --drawing <path> the same drawn.graph.json you opened (open takes it as
114
+ its argument); left out, the checkout's only paired canvas
115
+ --canvas <id|name> a canvas by id or name, instead of a drawing
116
+
94
117
  --api <url> the PR Lens app (default $${API_ENV}, else ${DEFAULT_API})`;
95
118
 
96
119
  type CanvasRef = {
@@ -173,6 +196,7 @@ const recordPull = (current: CanvasRegistry, pull: PullRecord): Recorded => {
173
196
  ...(pending === undefined ? {} : { pending }),
174
197
  ...(kept === undefined ? {} : { writeToken: kept }),
175
198
  rev: pull.fetched?.rev ?? entry?.rev ?? 0,
199
+ ...(entry?.live === undefined ? {} : { live: entry.live }),
176
200
  };
177
201
 
178
202
  return imports
@@ -639,7 +663,7 @@ export const canvasCommand = async (
639
663
  const [name, ...rest] = args;
640
664
  if (name === undefined)
641
665
  throw usageError(
642
- "canvas needs a subcommand: list, push, pull, claim, rotate or delete",
666
+ "canvas needs a subcommand: list, push, pull, claim, rotate, delete, open, answer, show, fork or look",
643
667
  );
644
668
  if (!isSubcommand(name))
645
669
  throw usageError(`unknown canvas subcommand ${JSON.stringify(name)}`);
@@ -657,6 +681,16 @@ export const canvasCommand = async (
657
681
  return deleteCommand(rest, terminal, env);
658
682
  case "rotate":
659
683
  return rotate(rest, terminal, env);
684
+ case "open":
685
+ return openLiveCommand(rest, terminal, env);
686
+ case "answer":
687
+ return answerCommand(rest, terminal, env);
688
+ case "show":
689
+ return showCommand(rest, terminal, env);
690
+ case "fork":
691
+ return forkCommand(rest, terminal, env);
692
+ case "look":
693
+ return lookCommand(rest, terminal, env);
660
694
  default:
661
695
  return assertNever(name, "Unhandled canvas subcommand");
662
696
  }
package/src/errors.ts CHANGED
@@ -23,6 +23,9 @@ export type CliErrorCode =
23
23
  | "CANVAS_RATE_LIMITED"
24
24
  | "CANVAS_UNAVAILABLE"
25
25
  | "CANVAS_REGISTRY_EXPOSED"
26
+ | "LIVE_UNOPENED"
27
+ | "LIVE_ENDED"
28
+ | "LIVE_UNKNOWN_PLACE"
26
29
  | "AUTH_REQUIRED"
27
30
  | "MACHINE_REVOKED"
28
31
  | "APP_UNAVAILABLE";