@coldtea/pr-lens-cli 0.8.0 → 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.
- package/dist/canvas/api.d.ts +104 -1
- package/dist/canvas/api.d.ts.map +1 -1
- package/dist/canvas/api.js +48 -1
- package/dist/canvas/api.js.map +1 -1
- package/dist/canvas/live.d.ts +15 -0
- package/dist/canvas/live.d.ts.map +1 -0
- package/dist/canvas/live.js +417 -0
- package/dist/canvas/live.js.map +1 -0
- package/dist/canvas/registry.d.ts +8 -0
- package/dist/canvas/registry.d.ts.map +1 -1
- package/dist/canvas/registry.js +2 -0
- package/dist/canvas/registry.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/auth.d.ts +2 -0
- package/dist/commands/auth.d.ts.map +1 -1
- package/dist/commands/auth.js +1 -1
- package/dist/commands/auth.js.map +1 -1
- package/dist/commands/canvas.d.ts.map +1 -1
- package/dist/commands/canvas.js +29 -2
- package/dist/commands/canvas.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/skill-content.generated.d.ts +1 -1
- package/dist/skill-content.generated.d.ts.map +1 -1
- package/dist/skill-content.generated.js +1 -1
- package/dist/skill-content.generated.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/workspace.d.ts +1 -1
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +5 -5
- package/dist/workspace.js.map +1 -1
- package/package.json +3 -3
- package/src/canvas/api.ts +109 -0
- package/src/canvas/live.ts +523 -0
- package/src/canvas/registry.ts +2 -0
- package/src/cli.ts +1 -0
- package/src/commands/auth.ts +1 -1
- package/src/commands/canvas.ts +36 -2
- package/src/errors.ts +3 -0
- package/src/skill-content.generated.ts +1 -1
- package/src/version.ts +1 -1
- package/src/workspace.ts +4 -5
|
@@ -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
|
+
|
package/src/canvas/registry.ts
CHANGED
|
@@ -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
|
|
package/src/commands/auth.ts
CHANGED
|
@@ -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 => {
|
package/src/commands/canvas.ts
CHANGED
|
@@ -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
|
|
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