@coldtea/pr-lens-cli 0.8.1 → 0.10.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 (72) 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/analyze.d.ts.map +1 -1
  17. package/dist/commands/analyze.js +25 -6
  18. package/dist/commands/analyze.js.map +1 -1
  19. package/dist/commands/auth.d.ts +2 -0
  20. package/dist/commands/auth.d.ts.map +1 -1
  21. package/dist/commands/auth.js +1 -1
  22. package/dist/commands/auth.js.map +1 -1
  23. package/dist/commands/canvas.d.ts.map +1 -1
  24. package/dist/commands/canvas.js +29 -2
  25. package/dist/commands/canvas.js.map +1 -1
  26. package/dist/commands/comment.d.ts +1 -1
  27. package/dist/commands/comment.d.ts.map +1 -1
  28. package/dist/commands/comment.js +16 -2
  29. package/dist/commands/comment.js.map +1 -1
  30. package/dist/commands/render.d.ts +1 -1
  31. package/dist/commands/render.d.ts.map +1 -1
  32. package/dist/commands/render.js +9 -4
  33. package/dist/commands/render.js.map +1 -1
  34. package/dist/comment.d.ts +4 -0
  35. package/dist/comment.d.ts.map +1 -1
  36. package/dist/comment.js +193 -33
  37. package/dist/comment.js.map +1 -1
  38. package/dist/config-file.d.ts +7 -2
  39. package/dist/config-file.d.ts.map +1 -1
  40. package/dist/config-file.js +14 -2
  41. package/dist/config-file.js.map +1 -1
  42. package/dist/errors.d.ts +1 -1
  43. package/dist/errors.d.ts.map +1 -1
  44. package/dist/errors.js.map +1 -1
  45. package/dist/git.d.ts +17 -0
  46. package/dist/git.d.ts.map +1 -1
  47. package/dist/git.js +37 -2
  48. package/dist/git.js.map +1 -1
  49. package/dist/skill-content.generated.d.ts +1 -1
  50. package/dist/skill-content.generated.d.ts.map +1 -1
  51. package/dist/skill-content.generated.js +1 -1
  52. package/dist/skill-content.generated.js.map +1 -1
  53. package/dist/version.d.ts +1 -1
  54. package/dist/version.d.ts.map +1 -1
  55. package/dist/version.js +1 -1
  56. package/dist/version.js.map +1 -1
  57. package/package.json +3 -3
  58. package/src/canvas/api.ts +109 -0
  59. package/src/canvas/live.ts +523 -0
  60. package/src/canvas/registry.ts +2 -0
  61. package/src/cli.ts +1 -0
  62. package/src/commands/analyze.ts +30 -6
  63. package/src/commands/auth.ts +1 -1
  64. package/src/commands/canvas.ts +36 -2
  65. package/src/commands/comment.ts +18 -2
  66. package/src/commands/render.ts +18 -5
  67. package/src/comment.ts +249 -34
  68. package/src/config-file.ts +14 -2
  69. package/src/errors.ts +3 -0
  70. package/src/git.ts +38 -3
  71. package/src/skill-content.generated.ts +1 -1
  72. package/src/version.ts +1 -1
package/src/canvas/api.ts CHANGED
@@ -2,7 +2,9 @@ import { z } from "zod";
2
2
  import {
3
3
  assertNever,
4
4
  safeParseGraphDoc,
5
+ ViewerLook,
5
6
  type GraphDoc,
7
+ type LiveCommand,
6
8
  } from "@coldtea/pr-lens-schema";
7
9
 
8
10
  import { CLI_VERSION } from "../version.js";
@@ -87,6 +89,19 @@ const Refusal = z.discriminatedUnion("code", [
87
89
  z.object({ code: z.literal("TOO_LARGE"), message: z.string() }),
88
90
  z.object({ code: z.literal("UNAUTHENTICATED"), message: z.string() }),
89
91
  z.object({ code: z.literal("ALREADY_OWNED"), message: z.string() }),
92
+ z.object({ code: z.literal("LIVE_ENDED"), message: z.string() }),
93
+ z.object({
94
+ code: z.literal("UNKNOWN_PLACE"),
95
+ message: z.string(),
96
+ unknown: z.array(
97
+ z.object({ at: z.string(), kind: z.string(), id: z.string(), detail: z.string() }),
98
+ ),
99
+ valid: z.object({
100
+ components: z.array(z.string()),
101
+ messages: z.array(z.string()),
102
+ diagrams: z.array(z.string()),
103
+ }),
104
+ }),
90
105
  ]);
91
106
 
92
107
  type Request = {
@@ -208,6 +223,17 @@ const refusal = (
208
223
  `${request.canvas ?? "that canvas"} belongs to another account on ${hostOf(api)}`,
209
224
  "the first claim wins, and somebody else's landed first",
210
225
  );
226
+ case "LIVE_ENDED":
227
+ return new PrLensCliError(
228
+ "LIVE_ENDED",
229
+ `the live session on ${request.canvas ?? "this canvas"} has ended`,
230
+ "pr-lens canvas open starts a new one and opens a tab for it",
231
+ );
232
+ case "UNKNOWN_PLACE":
233
+ return unknownPlaces(
234
+ error.unknown.map((place) => `${place.at}: "${place.id}" ${place.detail}`),
235
+ error.valid,
236
+ );
211
237
  case "INVALID_REQUEST":
212
238
  case "TOO_LARGE":
213
239
  return unavailable(api, status, error.message);
@@ -216,6 +242,31 @@ const refusal = (
216
242
  }
217
243
  };
218
244
 
245
+ /** The ids the canvas would have taken, so the next attempt can copy one rather than guess again. */
246
+ export type ValidPlaces = { components: readonly string[]; messages: readonly string[]; diagrams: readonly string[] };
247
+
248
+ const SHOWN_IDS = 40;
249
+
250
+ const idList = (label: string, ids: readonly string[]): string =>
251
+ ids.length === 0
252
+ ? ` ${label}: none`
253
+ : ` ${label}: ${ids.slice(0, SHOWN_IDS).join(", ")}${ids.length > SHOWN_IDS ? `, and ${ids.length - SHOWN_IDS} more` : ""}`;
254
+
255
+ export const unknownPlaces = (problems: readonly string[], valid: ValidPlaces): PrLensCliError =>
256
+ new PrLensCliError(
257
+ "LIVE_UNKNOWN_PLACE",
258
+ problems.length === 1
259
+ ? "1 id is not on the canvas"
260
+ : `${problems.length} ids are not on the canvas`,
261
+ [
262
+ ...problems,
263
+ "copy an id from these:",
264
+ idList("components", valid.components),
265
+ idList("messages", valid.messages),
266
+ idList("diagrams", valid.diagrams),
267
+ ].join("\n"),
268
+ );
269
+
219
270
  const call = async <T>(
220
271
  api: string,
221
272
  request: Request,
@@ -434,3 +485,61 @@ export const claimCanvas = (
434
485
  },
435
486
  Rotated,
436
487
  );
488
+
489
+ const LiveOpened = z.object({
490
+ session: z.string(),
491
+ url: z.string(),
492
+ expiresAt: z.string(),
493
+ });
494
+
495
+ const TAB_STATES = ["following", "stepped_out", "not_open"] as const;
496
+
497
+ const LiveSent = z.object({
498
+ seq: z.number().int(),
499
+ tab: z.enum(TAB_STATES),
500
+ });
501
+
502
+ const LookRead = z.discriminatedUnion("status", [
503
+ z.object({ status: z.literal("not_open") }),
504
+ z.object({ status: z.literal("seen"), seenAt: z.string(), look: ViewerLook }),
505
+ ]);
506
+
507
+ export type LiveOpened = z.infer<typeof LiveOpened>;
508
+ export type TabState = (typeof TAB_STATES)[number];
509
+ export type LookRead = z.infer<typeof LookRead>;
510
+
511
+ const livePath = (id: string, session?: string): string =>
512
+ session === undefined ? `${canvasPath(id)}/live` : `${canvasPath(id)}/live/${session}`;
513
+
514
+ /** A session is one tab, paired by the secret in the link this answers with. */
515
+ export const openLive = (
516
+ api: string,
517
+ id: string,
518
+ token: string,
519
+ ): Promise<LiveOpened> =>
520
+ call(api, { method: "POST", path: livePath(id), canvas: id, token }, LiveOpened);
521
+
522
+ export const sendLive = (
523
+ api: string,
524
+ id: string,
525
+ token: string,
526
+ session: string,
527
+ command: LiveCommand,
528
+ ): Promise<z.infer<typeof LiveSent>> =>
529
+ call(
530
+ api,
531
+ { method: "POST", path: livePath(id, session), canvas: id, token, body: command },
532
+ LiveSent,
533
+ );
534
+
535
+ export const readLook = (
536
+ api: string,
537
+ id: string,
538
+ token: string,
539
+ session: string,
540
+ ): Promise<LookRead> =>
541
+ call(
542
+ api,
543
+ { method: "GET", path: `${livePath(id, session)}/look`, canvas: id, token },
544
+ LookRead,
545
+ );
@@ -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