@oomfware/cbr 0.1.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 (46) hide show
  1. package/LICENSE +14 -0
  2. package/README.md +72 -0
  3. package/dist/assets/system-prompt.md +147 -0
  4. package/dist/client.mjs +54 -0
  5. package/dist/index.mjs +1366 -0
  6. package/package.json +45 -0
  7. package/src/assets/system-prompt.md +147 -0
  8. package/src/client.ts +70 -0
  9. package/src/commands/ask.ts +202 -0
  10. package/src/commands/clean.ts +18 -0
  11. package/src/index.ts +34 -0
  12. package/src/lib/commands/_types.ts +24 -0
  13. package/src/lib/commands/_utils.ts +38 -0
  14. package/src/lib/commands/back.ts +14 -0
  15. package/src/lib/commands/check.ts +14 -0
  16. package/src/lib/commands/click.ts +14 -0
  17. package/src/lib/commands/close.ts +17 -0
  18. package/src/lib/commands/dblclick.ts +14 -0
  19. package/src/lib/commands/download.ts +36 -0
  20. package/src/lib/commands/eval.ts +23 -0
  21. package/src/lib/commands/fill.ts +18 -0
  22. package/src/lib/commands/forward.ts +14 -0
  23. package/src/lib/commands/frame.ts +106 -0
  24. package/src/lib/commands/get.ts +95 -0
  25. package/src/lib/commands/hover.ts +14 -0
  26. package/src/lib/commands/is.ts +53 -0
  27. package/src/lib/commands/open.ts +15 -0
  28. package/src/lib/commands/press.ts +13 -0
  29. package/src/lib/commands/reload.ts +14 -0
  30. package/src/lib/commands/resources.ts +37 -0
  31. package/src/lib/commands/screenshot.ts +26 -0
  32. package/src/lib/commands/scroll.ts +30 -0
  33. package/src/lib/commands/select.ts +18 -0
  34. package/src/lib/commands/snapshot.ts +30 -0
  35. package/src/lib/commands/source.ts +23 -0
  36. package/src/lib/commands/styles.ts +63 -0
  37. package/src/lib/commands/tab.ts +102 -0
  38. package/src/lib/commands/type-text.ts +18 -0
  39. package/src/lib/commands/uncheck.ts +14 -0
  40. package/src/lib/commands/wait.ts +93 -0
  41. package/src/lib/commands.ts +202 -0
  42. package/src/lib/debug.ts +11 -0
  43. package/src/lib/paths.ts +118 -0
  44. package/src/lib/server.ts +94 -0
  45. package/src/lib/session.ts +92 -0
  46. package/src/lib/snapshot.ts +351 -0
package/dist/index.mjs ADDED
@@ -0,0 +1,1366 @@
1
+ #!/usr/bin/env node
2
+ import { argument, choice, command, constant, flag, formatDocPage, formatMessage, getDocPage, group, integer, message, object, option, or, parse, passThrough, string } from "@optique/core";
3
+ import { run } from "@optique/run";
4
+ import { spawn } from "node:child_process";
5
+ import { basename, join } from "node:path";
6
+ import { optional, withDefault } from "@optique/core/modifiers";
7
+ import { chromium } from "playwright";
8
+ import yoctoSpinner from "yocto-spinner";
9
+ import { chmod, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
10
+ import { randomUUID } from "node:crypto";
11
+ import { homedir } from "node:os";
12
+ import * as v from "valibot";
13
+ import { createServer } from "node:net";
14
+
15
+ //#region package.json
16
+ var version = "0.1.0";
17
+
18
+ //#endregion
19
+ //#region src/lib/commands/_types.ts
20
+ /** intentional, user-facing error thrown by command handlers */
21
+ var CommandError = class extends Error {
22
+ constructor(message) {
23
+ super(message);
24
+ this.name = "CommandError";
25
+ }
26
+ };
27
+
28
+ //#endregion
29
+ //#region src/lib/commands/back.ts
30
+ const schema$28 = object({ command: constant("back") });
31
+ const handler$28 = async (state) => {
32
+ const start = performance.now();
33
+ await state.page.goBack({ waitUntil: "domcontentloaded" });
34
+ const elapsed = ((performance.now() - start) / 1e3).toFixed(1);
35
+ return `navigated back to ${state.page.url()} (${elapsed}s)`;
36
+ };
37
+
38
+ //#endregion
39
+ //#region src/lib/snapshot.ts
40
+ /** roles that represent interactive elements — always get refs */
41
+ const INTERACTIVE_ROLES = new Set([
42
+ "button",
43
+ "link",
44
+ "textbox",
45
+ "checkbox",
46
+ "radio",
47
+ "combobox",
48
+ "listbox",
49
+ "menuitem",
50
+ "menuitemcheckbox",
51
+ "menuitemradio",
52
+ "option",
53
+ "searchbox",
54
+ "slider",
55
+ "spinbutton",
56
+ "switch",
57
+ "tab",
58
+ "treeitem"
59
+ ]);
60
+ /** content roles — get refs only when they have a name */
61
+ const CONTENT_ROLES = new Set([
62
+ "heading",
63
+ "cell",
64
+ "gridcell",
65
+ "columnheader",
66
+ "rowheader",
67
+ "listitem",
68
+ "article",
69
+ "region",
70
+ "main",
71
+ "navigation"
72
+ ]);
73
+ /** structural roles — stripped in compact mode when unnamed */
74
+ const STRUCTURAL_ROLES = new Set([
75
+ "generic",
76
+ "group",
77
+ "list",
78
+ "table",
79
+ "row",
80
+ "rowgroup",
81
+ "grid",
82
+ "treegrid",
83
+ "menu",
84
+ "menubar",
85
+ "toolbar",
86
+ "tablist",
87
+ "tree",
88
+ "directory",
89
+ "document",
90
+ "application",
91
+ "presentation",
92
+ "none"
93
+ ]);
94
+ const makeKey = (role, name) => `${role}:${name ?? ""}`;
95
+ const createRoleNameTracker = () => {
96
+ const counts = /* @__PURE__ */ new Map();
97
+ const refsByKey = /* @__PURE__ */ new Map();
98
+ return {
99
+ getNextIndex(role, name) {
100
+ const key = makeKey(role, name);
101
+ const index = counts.get(key) ?? 0;
102
+ counts.set(key, index + 1);
103
+ return index;
104
+ },
105
+ trackRef(role, name, ref) {
106
+ const key = makeKey(role, name);
107
+ const existing = refsByKey.get(key);
108
+ if (existing) existing.push(ref);
109
+ else refsByKey.set(key, [ref]);
110
+ },
111
+ getDuplicateKeys() {
112
+ const dupes = /* @__PURE__ */ new Set();
113
+ for (const [key, refs] of refsByKey) if (refs.length > 1) dupes.add(key);
114
+ return dupes;
115
+ }
116
+ };
117
+ };
118
+ const LINE_RE = /^(\s*-\s*)(\w+)(?:\s+"([^"]*)")?(.*)$/;
119
+ /**
120
+ * takes a snapshot of the page's accessibility tree and assigns element refs.
121
+ * @param page the Playwright page
122
+ * @param options snapshot options
123
+ * @returns object with the formatted snapshot text and the ref map
124
+ */
125
+ const takeSnapshot = async (page, options = {}) => {
126
+ const ariaTree = await (options.selector ? page.locator(options.selector) : page.locator("body")).ariaSnapshot();
127
+ const refs = {};
128
+ let refCounter = 0;
129
+ const tracker = createRoleNameTracker();
130
+ const nextRef = () => `e${++refCounter}`;
131
+ const lines = ariaTree.split("\n");
132
+ const output = [];
133
+ for (const line of lines) {
134
+ const match = line.match(LINE_RE);
135
+ if (!match) {
136
+ if (!options.interactive) {
137
+ const depth = Math.floor((line.search(/\S/) || 0) / 2);
138
+ output.push({
139
+ text: line,
140
+ depth,
141
+ hasRef: false,
142
+ hasContent: line.trim().length > 0
143
+ });
144
+ }
145
+ continue;
146
+ }
147
+ const [, prefix, role, name, suffix] = match;
148
+ const roleLower = role.toLowerCase();
149
+ const depth = Math.floor((prefix.search(/\S/) === -1 ? prefix.length : prefix.search(/\S/)) / 2);
150
+ if (options.depth !== void 0 && depth > options.depth) continue;
151
+ const isInteractive = INTERACTIVE_ROLES.has(roleLower);
152
+ const isContent = CONTENT_ROLES.has(roleLower);
153
+ const isStructural = STRUCTURAL_ROLES.has(roleLower);
154
+ if (options.interactive && !isInteractive) continue;
155
+ if (options.compact && isStructural && !name) continue;
156
+ const shouldHaveRef = isInteractive || isContent && !!name;
157
+ let refTag = "";
158
+ if (shouldHaveRef) {
159
+ const ref = nextRef();
160
+ const nth = tracker.getNextIndex(roleLower, name);
161
+ tracker.trackRef(roleLower, name, ref);
162
+ refs[ref] = {
163
+ role: roleLower,
164
+ name,
165
+ nth
166
+ };
167
+ refTag = ` [ref=${ref}]`;
168
+ }
169
+ const nthTag = shouldHaveRef && refs[`e${refCounter}`]?.nth ? ` [nth=${refs[`e${refCounter}`].nth}]` : "";
170
+ const reconstructed = name ? `${prefix}${role} "${name}"${refTag}${nthTag}${suffix}` : `${prefix}${role}${refTag}${suffix}`;
171
+ const hasInlineContent = !!suffix && suffix.includes(":") && !suffix.endsWith(":");
172
+ output.push({
173
+ text: reconstructed,
174
+ depth,
175
+ hasRef: shouldHaveRef,
176
+ hasContent: hasInlineContent || !!name
177
+ });
178
+ }
179
+ {
180
+ const dupes = tracker.getDuplicateKeys();
181
+ for (const [ref, entry] of Object.entries(refs)) {
182
+ const key = makeKey(entry.role, entry.name);
183
+ if (!dupes.has(key)) {
184
+ entry.nth = void 0;
185
+ const idx = output.findIndex((l) => l.text.includes(`[ref=${ref}]`));
186
+ if (idx !== -1) output[idx].text = output[idx].text.replace(/\s*\[nth=\d+\]/, "");
187
+ }
188
+ }
189
+ }
190
+ if (options.compact) return {
191
+ text: compactTree(output).map((l) => l.text).join("\n"),
192
+ refs
193
+ };
194
+ return {
195
+ text: output.map((l) => l.text).join("\n"),
196
+ refs
197
+ };
198
+ };
199
+ /**
200
+ * prunes branches from the tree that contain no refs.
201
+ * keeps: lines with refs, lines with inline content, and structural ancestors of ref-bearing lines.
202
+ */
203
+ const compactTree = (lines) => {
204
+ const keep = Array.from({ length: lines.length }, () => false);
205
+ for (let i = 0; i < lines.length; i++) if (lines[i].hasRef || lines[i].hasContent) keep[i] = true;
206
+ for (let i = 0; i < lines.length; i++) {
207
+ if (!keep[i]) continue;
208
+ const targetDepth = lines[i].depth;
209
+ for (let j = i - 1; j >= 0 && lines[j].depth < targetDepth; j--) if (!keep[j]) keep[j] = true;
210
+ }
211
+ return lines.filter((_, i) => keep[i]);
212
+ };
213
+ /**
214
+ * parses a ref string (e.g. "@e1", "ref=e1", "e1") into the bare ref id.
215
+ * @param input the ref string
216
+ * @returns the bare ref id or null if not a valid ref format
217
+ */
218
+ const parseRef = (input) => {
219
+ if (input.startsWith("@")) return input.slice(1);
220
+ if (input.startsWith("ref=")) return input.slice(4);
221
+ if (/^e\d+$/.test(input)) return input;
222
+ return null;
223
+ };
224
+ /**
225
+ * resolves a ref or CSS selector to a Playwright locator.
226
+ * @param page the Playwright page
227
+ * @param selectorOrRef a ref string (e.g. "@e1") or CSS selector
228
+ * @param refs the current ref map
229
+ * @returns the resolved Playwright locator
230
+ * @throws if a ref is provided but not found in the ref map
231
+ */
232
+ const resolveLocator = (page, selectorOrRef, refs) => {
233
+ const ref = parseRef(selectorOrRef);
234
+ if (ref) {
235
+ const entry = refs[ref];
236
+ if (!entry) throw new Error(`ref ${selectorOrRef} not found — run snapshot to refresh refs`);
237
+ let locator;
238
+ if (entry.name) locator = page.getByRole(entry.role, {
239
+ name: entry.name,
240
+ exact: true
241
+ });
242
+ else locator = page.getByRole(entry.role);
243
+ if (entry.nth !== void 0) locator = locator.nth(entry.nth);
244
+ return locator;
245
+ }
246
+ return page.locator(selectorOrRef);
247
+ };
248
+
249
+ //#endregion
250
+ //#region src/lib/commands/_utils.ts
251
+ /** resolves a ref or CSS selector to a Playwright locator */
252
+ const getLocator = (state, selectorOrRef) => {
253
+ return resolveLocator(state.page, selectorOrRef, state.refs);
254
+ };
255
+ /** formats a byte count into a human-readable string */
256
+ const formatBytes = (bytes) => {
257
+ if (bytes === 0) return "0 B";
258
+ const units = [
259
+ "B",
260
+ "KB",
261
+ "MB",
262
+ "GB"
263
+ ];
264
+ const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
265
+ const value = bytes / 1024 ** i;
266
+ return `${i === 0 ? value : value.toFixed(1)} ${units[i]}`;
267
+ };
268
+ /** extracts a filename from a URL, falling back to a generic name */
269
+ const filenameFromUrl = (url) => {
270
+ try {
271
+ const pathname = new URL(url).pathname;
272
+ const base = basename(pathname);
273
+ if (base && base !== "/" && !base.startsWith(".")) return base.split("?")[0];
274
+ } catch {}
275
+ return "download";
276
+ };
277
+
278
+ //#endregion
279
+ //#region src/lib/commands/check.ts
280
+ const schema$27 = object({
281
+ command: constant("check"),
282
+ selector: argument(string({ metavar: "SELECTOR" }))
283
+ });
284
+ const handler$27 = async (state, args) => {
285
+ await getLocator(state, args.selector).check();
286
+ return "checked";
287
+ };
288
+
289
+ //#endregion
290
+ //#region src/lib/commands/click.ts
291
+ const schema$26 = object({
292
+ command: constant("click"),
293
+ selector: argument(string({ metavar: "SELECTOR" }))
294
+ });
295
+ const handler$26 = async (state, args) => {
296
+ await getLocator(state, args.selector).click();
297
+ return "clicked";
298
+ };
299
+
300
+ //#endregion
301
+ //#region src/lib/commands/close.ts
302
+ const schema$25 = object({ command: constant("close") });
303
+ const handler$25 = async (state) => {
304
+ await state.page.close();
305
+ const pages = state.context.pages();
306
+ if (pages.length > 0) {
307
+ state.page = pages[0];
308
+ return "tab closed, switched to remaining tab";
309
+ }
310
+ return "browser closed";
311
+ };
312
+
313
+ //#endregion
314
+ //#region src/lib/commands/dblclick.ts
315
+ const schema$24 = object({
316
+ command: constant("dblclick"),
317
+ selector: argument(string({ metavar: "SELECTOR" }))
318
+ });
319
+ const handler$24 = async (state, args) => {
320
+ await getLocator(state, args.selector).dblclick();
321
+ return "double-clicked";
322
+ };
323
+
324
+ //#endregion
325
+ //#region src/lib/commands/download.ts
326
+ const schema$23 = object({
327
+ command: constant("download"),
328
+ url: argument(string({ metavar: "URL" }), { description: message`URL of the resource to download` }),
329
+ filename: optional(argument(string({ metavar: "FILENAME" }), { description: message`save as this filename` }))
330
+ });
331
+ const handler$23 = async (state, args) => {
332
+ const filename = args.filename ?? filenameFromUrl(args.url);
333
+ const start = performance.now();
334
+ const response = await state.page.request.get(args.url);
335
+ if (!response.ok()) throw new CommandError(`download failed: ${response.status()} ${response.statusText()}`);
336
+ const buffer = await response.body();
337
+ await writeFile(join(state.assetsDir, filename), buffer);
338
+ return `saved assets/${filename} (${formatBytes(buffer.length)}, ${((performance.now() - start) / 1e3).toFixed(1)}s)`;
339
+ };
340
+
341
+ //#endregion
342
+ //#region src/lib/commands/eval.ts
343
+ const schema$22 = object({
344
+ command: constant("eval"),
345
+ code: passThrough({
346
+ format: "greedy",
347
+ description: message`JavaScript code to evaluate`
348
+ })
349
+ });
350
+ const handler$22 = async (state, args) => {
351
+ const code = args.code.join(" ");
352
+ if (!code) throw new CommandError("missing code to evaluate");
353
+ const result = await state.page.evaluate(code);
354
+ if (result === void 0 || result === null) return;
355
+ return typeof result === "string" ? result : JSON.stringify(result, null, 2);
356
+ };
357
+
358
+ //#endregion
359
+ //#region src/lib/commands/fill.ts
360
+ const schema$21 = object({
361
+ command: constant("fill"),
362
+ selector: argument(string({ metavar: "SELECTOR" }), { description: message`input element to fill` }),
363
+ text: argument(string({ metavar: "TEXT" }), { description: message`text to fill in` })
364
+ });
365
+ const handler$21 = async (state, args) => {
366
+ await getLocator(state, args.selector).fill(args.text);
367
+ return "filled";
368
+ };
369
+
370
+ //#endregion
371
+ //#region src/lib/commands/forward.ts
372
+ const schema$20 = object({ command: constant("forward") });
373
+ const handler$20 = async (state) => {
374
+ const start = performance.now();
375
+ await state.page.goForward({ waitUntil: "domcontentloaded" });
376
+ const elapsed = ((performance.now() - start) / 1e3).toFixed(1);
377
+ return `navigated forward to ${state.page.url()} (${elapsed}s)`;
378
+ };
379
+
380
+ //#endregion
381
+ //#region src/lib/commands/frame.ts
382
+ const schema$19 = object({
383
+ command: constant("frame"),
384
+ subcommand: or(command("list", object({ kind: constant("list") }), { description: message`list all frames with IDs, URLs, and parent info` }), command("main", object({ kind: constant("main") }), { description: message`switch to the main frame` }), object({
385
+ kind: constant("switch"),
386
+ id: argument(string({ metavar: "FRAME_ID" }), { description: message`frame ref to switch to` })
387
+ }))
388
+ });
389
+ /**
390
+ * collects child frames from the page, assigns IDs (`f1`, `f2`, ...), and
391
+ * stores them in `state.frameRefs`. the main frame is excluded since
392
+ * `frame main` handles switching back to it.
393
+ */
394
+ const refreshFrameRefs = (state) => {
395
+ const mainPage = state.context.pages()[0];
396
+ const mainFrame = mainPage.mainFrame();
397
+ const childFrames = mainPage.frames().filter((f) => f !== mainFrame);
398
+ const map = {};
399
+ for (let i = 0; i < childFrames.length; i++) map[`f${i + 1}`] = childFrames[i];
400
+ state.frameRefs = map;
401
+ return map;
402
+ };
403
+ const formatFrameList = (refs, currentPage) => {
404
+ const mainFrame = currentPage.mainFrame();
405
+ const currentFrame = currentPage;
406
+ const lines = [];
407
+ {
408
+ const marker = currentFrame === mainFrame ? "* " : " ";
409
+ lines.push(`${marker}main: ${mainFrame.url()}`);
410
+ }
411
+ for (const [id, frame] of Object.entries(refs)) {
412
+ const marker = frame === currentFrame ? "* " : " ";
413
+ const name = frame.name() ? ` name="${frame.name()}"` : "";
414
+ let parentTag = "";
415
+ {
416
+ const parent = frame.parentFrame();
417
+ if (parent === mainFrame) parentTag = " (parent: main)";
418
+ else if (parent) {
419
+ const parentId = Object.entries(refs).find(([, f]) => f === parent)?.[0];
420
+ if (parentId) parentTag = ` (parent: ${parentId})`;
421
+ }
422
+ }
423
+ lines.push(`${marker}${id}: ${frame.url()}${name}${parentTag}`);
424
+ }
425
+ return lines.join("\n");
426
+ };
427
+ const handler$19 = async (state, args) => {
428
+ const sub = args.subcommand;
429
+ switch (sub.kind) {
430
+ case "list": return formatFrameList(refreshFrameRefs(state), state.page);
431
+ case "main":
432
+ state.page = state.context.pages()[0];
433
+ return "switched to main frame";
434
+ case "switch": {
435
+ const frame = state.frameRefs[sub.id];
436
+ if (!frame) throw new CommandError(`frame ${sub.id} not found — run \`browser frame list\` to list frames and get IDs`);
437
+ state.page = frame;
438
+ return `switched to frame ${sub.id}: ${frame.url()}`;
439
+ }
440
+ }
441
+ };
442
+
443
+ //#endregion
444
+ //#region src/lib/commands/get.ts
445
+ const schema$18 = object({
446
+ command: constant("get"),
447
+ subcommand: or(command("text", object({
448
+ kind: constant("text"),
449
+ selector: argument(string({ metavar: "SELECTOR" }))
450
+ }), { description: message`get inner text of an element` }), command("url", object({ kind: constant("url") }), { description: message`get the current page URL` }), command("title", object({ kind: constant("title") }), { description: message`get the current page title` }), command("html", object({
451
+ kind: constant("html"),
452
+ selector: argument(string({ metavar: "SELECTOR" }))
453
+ }), { description: message`get inner HTML of an element` }), command("value", object({
454
+ kind: constant("value"),
455
+ selector: argument(string({ metavar: "SELECTOR" }))
456
+ }), { description: message`get the value of an input field` }), command("attr", object({
457
+ kind: constant("attr"),
458
+ selector: argument(string({ metavar: "SELECTOR" })),
459
+ attribute: argument(string({ metavar: "ATTR" }))
460
+ }), { description: message`get an attribute of an element` }), command("count", object({
461
+ kind: constant("count"),
462
+ selector: argument(string({ metavar: "SELECTOR" }))
463
+ }), { description: message`count matching elements` }))
464
+ });
465
+ const handler$18 = async (state, args) => {
466
+ switch (args.subcommand.kind) {
467
+ case "text": return await getLocator(state, args.subcommand.selector).innerText();
468
+ case "url": return state.page.url();
469
+ case "title": return await state.page.title();
470
+ case "html": return await getLocator(state, args.subcommand.selector).innerHTML();
471
+ case "value": return await getLocator(state, args.subcommand.selector).inputValue();
472
+ case "attr": return await getLocator(state, args.subcommand.selector).getAttribute(args.subcommand.attribute) ?? "";
473
+ case "count": {
474
+ const count = await getLocator(state, args.subcommand.selector).count();
475
+ return String(count);
476
+ }
477
+ }
478
+ };
479
+
480
+ //#endregion
481
+ //#region src/lib/commands/hover.ts
482
+ const schema$17 = object({
483
+ command: constant("hover"),
484
+ selector: argument(string({ metavar: "SELECTOR" }))
485
+ });
486
+ const handler$17 = async (state, args) => {
487
+ await getLocator(state, args.selector).hover();
488
+ return "hovered";
489
+ };
490
+
491
+ //#endregion
492
+ //#region src/lib/commands/is.ts
493
+ const schema$16 = object({
494
+ command: constant("is"),
495
+ subcommand: or(command("visible", object({
496
+ kind: constant("visible"),
497
+ selector: argument(string({ metavar: "SELECTOR" }))
498
+ }), { description: message`check if an element is visible` }), command("enabled", object({
499
+ kind: constant("enabled"),
500
+ selector: argument(string({ metavar: "SELECTOR" }))
501
+ }), { description: message`check if an element is enabled` }), command("checked", object({
502
+ kind: constant("checked"),
503
+ selector: argument(string({ metavar: "SELECTOR" }))
504
+ }), { description: message`check if a checkbox is checked` }))
505
+ });
506
+ const handler$16 = async (state, args) => {
507
+ switch (args.subcommand.kind) {
508
+ case "visible": {
509
+ const visible = await getLocator(state, args.subcommand.selector).isVisible();
510
+ return String(visible);
511
+ }
512
+ case "enabled": {
513
+ const enabled = await getLocator(state, args.subcommand.selector).isEnabled();
514
+ return String(enabled);
515
+ }
516
+ case "checked": {
517
+ const checked = await getLocator(state, args.subcommand.selector).isChecked();
518
+ return String(checked);
519
+ }
520
+ }
521
+ };
522
+
523
+ //#endregion
524
+ //#region src/lib/commands/open.ts
525
+ const schema$15 = object({
526
+ command: constant("open"),
527
+ url: argument(string({ metavar: "URL" }))
528
+ });
529
+ const handler$15 = async (state, args) => {
530
+ const start = performance.now();
531
+ await state.page.goto(args.url, { waitUntil: "domcontentloaded" });
532
+ const elapsed = ((performance.now() - start) / 1e3).toFixed(1);
533
+ return `navigated to ${state.page.url()} (${elapsed}s)`;
534
+ };
535
+
536
+ //#endregion
537
+ //#region src/lib/commands/press.ts
538
+ const schema$14 = object({
539
+ command: constant("press"),
540
+ key: argument(string({ metavar: "KEY" }))
541
+ });
542
+ const handler$14 = async (state, args) => {
543
+ await state.page.keyboard.press(args.key);
544
+ return `pressed ${args.key}`;
545
+ };
546
+
547
+ //#endregion
548
+ //#region src/lib/commands/reload.ts
549
+ const schema$13 = object({ command: constant("reload") });
550
+ const handler$13 = async (state) => {
551
+ const start = performance.now();
552
+ await state.page.reload({ waitUntil: "domcontentloaded" });
553
+ const elapsed = ((performance.now() - start) / 1e3).toFixed(1);
554
+ return `reloaded ${state.page.url()} (${elapsed}s)`;
555
+ };
556
+
557
+ //#endregion
558
+ //#region src/lib/commands/resources.ts
559
+ const schema$12 = object({
560
+ command: constant("resources"),
561
+ typeFilter: optional(argument(string({ metavar: "TYPE" }), { description: message`filter by resource type (e.g. script, img)` }))
562
+ });
563
+ const handler$12 = async (state, args) => {
564
+ const entries = await state.page.evaluate(() => performance.getEntriesByType("resource").map((e) => {
565
+ const r = e;
566
+ return {
567
+ name: r.name,
568
+ type: r.initiatorType,
569
+ size: r.transferSize
570
+ };
571
+ }));
572
+ const filtered = args.typeFilter ? entries.filter((e) => e.type === args.typeFilter) : entries;
573
+ if (filtered.length === 0) return args.typeFilter ? `no resources of type "${args.typeFilter}"` : "no resources recorded";
574
+ return filtered.map((e) => {
575
+ const size = e.size > 0 ? ` (${formatBytes(e.size)})` : "";
576
+ return `[${e.type}] ${e.name}${size}`;
577
+ }).join("\n");
578
+ };
579
+
580
+ //#endregion
581
+ //#region src/lib/commands/screenshot.ts
582
+ const schema$11 = object({
583
+ command: constant("screenshot"),
584
+ name: optional(argument(string({ metavar: "NAME" }), { description: message`filename for the screenshot` })),
585
+ full: withDefault(option("--full", { description: message`capture the full scrollable page` }), false)
586
+ });
587
+ const handler$11 = async (state, args) => {
588
+ state.screenshotCounter++;
589
+ const name = args.name ?? `screenshot-${state.screenshotCounter}`;
590
+ const filename = name.endsWith(".png") ? name : `${name}.png`;
591
+ const filepath = join(state.screenshotDir, filename);
592
+ await state.page.screenshot({
593
+ path: filepath,
594
+ fullPage: args.full
595
+ });
596
+ return `screenshot saved to screenshots/${filename}`;
597
+ };
598
+
599
+ //#endregion
600
+ //#region src/lib/commands/scroll.ts
601
+ const schema$10 = object({
602
+ command: constant("scroll"),
603
+ direction: argument(choice(["up", "down"]), { description: message`scroll direction` }),
604
+ selector: optional(argument(string({ metavar: "SELECTOR" }), { description: message`element to scroll instead of the page` }))
605
+ });
606
+ const handler$10 = async (state, args) => {
607
+ const delta = args.direction === "down" ? 500 : -500;
608
+ if (args.selector) await getLocator(state, args.selector).evaluate((el, d) => el.scrollBy(0, d), delta);
609
+ else await state.page.mouse.wheel(0, delta);
610
+ return `scrolled ${args.direction}`;
611
+ };
612
+
613
+ //#endregion
614
+ //#region src/lib/commands/select.ts
615
+ const schema$9 = object({
616
+ command: constant("select"),
617
+ selector: argument(string({ metavar: "SELECTOR" }), { description: message`select element to target` }),
618
+ value: argument(string({ metavar: "VALUE" }), { description: message`option value to select` })
619
+ });
620
+ const handler$9 = async (state, args) => {
621
+ await getLocator(state, args.selector).selectOption(args.value);
622
+ return `selected ${args.value}`;
623
+ };
624
+
625
+ //#endregion
626
+ //#region src/lib/commands/snapshot.ts
627
+ const schema$8 = object({
628
+ command: constant("snapshot"),
629
+ interactive: withDefault(option("--interactive", { description: message`only show interactive elements` }), false),
630
+ compact: withDefault(option("--compact", { description: message`remove empty lines from output` }), false),
631
+ depth: optional(option("--depth", integer({ min: 0 }), { description: message`maximum tree depth` })),
632
+ selector: optional(option("--selector", string(), { description: message`scope to a subtree` }))
633
+ });
634
+ const handler$8 = async (state, args) => {
635
+ const result = await takeSnapshot(state.page, {
636
+ interactive: args.interactive || void 0,
637
+ compact: args.compact || void 0,
638
+ depth: args.depth ?? void 0,
639
+ selector: args.selector ?? void 0
640
+ });
641
+ state.refs = result.refs;
642
+ return result.text;
643
+ };
644
+
645
+ //#endregion
646
+ //#region src/lib/commands/source.ts
647
+ const schema$7 = object({
648
+ command: constant("source"),
649
+ selector: optional(argument(string({ metavar: "SELECTOR" }), { description: message`element to get HTML for, or full page if omitted` }))
650
+ });
651
+ const handler$7 = async (state, args) => {
652
+ if (args.selector) return await getLocator(state, args.selector).evaluate((el) => el.outerHTML);
653
+ return await state.page.content();
654
+ };
655
+
656
+ //#endregion
657
+ //#region src/lib/commands/styles.ts
658
+ const schema$6 = object({
659
+ command: constant("styles"),
660
+ selector: argument(string({ metavar: "SELECTOR" }), { description: message`element to inspect` }),
661
+ property: optional(argument(string({ metavar: "PROPERTY" }), { description: message`specific CSS property, or all if omitted` }))
662
+ });
663
+ const handler$6 = async (state, args) => {
664
+ if (args.property) return await getLocator(state, args.selector).evaluate((el, prop) => getComputedStyle(el).getPropertyValue(prop), args.property) || "(empty)";
665
+ const styles = await getLocator(state, args.selector).evaluate((el) => {
666
+ const cs = getComputedStyle(el);
667
+ const props = [
668
+ "color",
669
+ "background-color",
670
+ "font-family",
671
+ "font-size",
672
+ "font-weight",
673
+ "line-height",
674
+ "display",
675
+ "position",
676
+ "width",
677
+ "height",
678
+ "margin",
679
+ "padding",
680
+ "border",
681
+ "opacity",
682
+ "z-index"
683
+ ];
684
+ const result = {};
685
+ for (const p of props) {
686
+ const v = cs.getPropertyValue(p);
687
+ if (v) result[p] = v;
688
+ }
689
+ return result;
690
+ });
691
+ return Object.entries(styles).map(([k, v]) => `${k}: ${v}`).join("\n");
692
+ };
693
+
694
+ //#endregion
695
+ //#region src/lib/commands/tab.ts
696
+ const schema$5 = object({
697
+ command: constant("tab"),
698
+ subcommand: or(command("list", object({ kind: constant("list") }), { description: message`list open tabs` }), command("new", object({
699
+ kind: constant("new"),
700
+ url: optional(argument(string({ metavar: "URL" }), { description: message`URL to open in the new tab` }))
701
+ }), { description: message`open a new tab and switch to it` }), command("close", object({
702
+ kind: constant("close"),
703
+ index: optional(argument(integer({ min: 0 }), { description: message`tab index to close` }))
704
+ }), { description: message`close a tab` }), object({
705
+ kind: constant("switch"),
706
+ index: argument(integer({ min: 0 }), { description: message`tab index to switch to` })
707
+ }))
708
+ });
709
+ const handler$5 = async (state, args) => {
710
+ const sub = args.subcommand;
711
+ switch (sub.kind) {
712
+ case "list": return state.context.pages().map((p, i) => {
713
+ return `${p === state.page ? "* " : " "}${i}: ${p.url()}`;
714
+ }).join("\n");
715
+ case "new": {
716
+ const newPage = await state.context.newPage();
717
+ if (sub.url) await newPage.goto(sub.url, { waitUntil: "domcontentloaded" });
718
+ state.page = newPage;
719
+ return `opened new tab${sub.url ? ` at ${sub.url}` : ""}`;
720
+ }
721
+ case "close": {
722
+ const pages = state.context.pages();
723
+ if (sub.index !== void 0) {
724
+ if (sub.index >= pages.length) throw new CommandError(`invalid tab index: ${sub.index}`);
725
+ await pages[sub.index].close();
726
+ if (!state.context.pages().includes(state.page)) state.page = state.context.pages()[0];
727
+ } else {
728
+ await state.page.close();
729
+ state.page = state.context.pages()[0];
730
+ }
731
+ return "tab closed";
732
+ }
733
+ case "switch": {
734
+ const pages = state.context.pages();
735
+ if (sub.index < 0 || sub.index >= pages.length) throw new CommandError(`tab index out of range: ${sub.index} (${pages.length} tabs open)`);
736
+ state.page = pages[sub.index];
737
+ await state.page.bringToFront();
738
+ return `switched to tab ${sub.index}: ${state.page.url()}`;
739
+ }
740
+ }
741
+ };
742
+
743
+ //#endregion
744
+ //#region src/lib/commands/type-text.ts
745
+ const schema$4 = object({
746
+ command: constant("type"),
747
+ selector: argument(string({ metavar: "SELECTOR" }), { description: message`element to type into` }),
748
+ text: argument(string({ metavar: "TEXT" }), { description: message`text to type` })
749
+ });
750
+ const handler$4 = async (state, args) => {
751
+ await getLocator(state, args.selector).pressSequentially(args.text);
752
+ return "typed";
753
+ };
754
+
755
+ //#endregion
756
+ //#region src/lib/commands/uncheck.ts
757
+ const schema$3 = object({
758
+ command: constant("uncheck"),
759
+ selector: argument(string({ metavar: "SELECTOR" }))
760
+ });
761
+ const handler$3 = async (state, args) => {
762
+ await getLocator(state, args.selector).uncheck();
763
+ return "unchecked";
764
+ };
765
+
766
+ //#endregion
767
+ //#region src/lib/commands/wait.ts
768
+ const schema$2 = object({
769
+ command: constant("wait"),
770
+ subcommand: or(command("for", object({
771
+ kind: constant("for"),
772
+ selector: argument(string({ metavar: "SELECTOR" })),
773
+ timeout: withDefault(option("--timeout", integer({ min: 0 }), { description: message`milliseconds to wait` }), 5e3),
774
+ hidden: withDefault(option("--hidden", { description: message`wait for the element to disappear` }), false)
775
+ }), { description: message`wait for an element to appear` }), command("for-text", object({
776
+ kind: constant("for-text"),
777
+ text: argument(string({ metavar: "TEXT" })),
778
+ timeout: withDefault(option("--timeout", integer({ min: 0 }), { description: message`milliseconds to wait` }), 5e3),
779
+ hidden: withDefault(option("--hidden", { description: message`wait for the text to disappear` }), false)
780
+ }), { description: message`wait for text content to appear` }), command("for-url", object({
781
+ kind: constant("for-url"),
782
+ pattern: argument(string({ metavar: "URL_PATTERN" })),
783
+ timeout: withDefault(option("--timeout", integer({ min: 0 }), { description: message`milliseconds to wait` }), 5e3)
784
+ }), { description: message`wait for the URL to match a pattern` }))
785
+ });
786
+ const handler$2 = async (state, args) => {
787
+ const start = performance.now();
788
+ const sub = args.subcommand;
789
+ switch (sub.kind) {
790
+ case "for": {
791
+ const waitState = sub.hidden ? "hidden" : "visible";
792
+ await getLocator(state, sub.selector).waitFor({
793
+ state: waitState,
794
+ timeout: sub.timeout
795
+ });
796
+ const elapsed = ((performance.now() - start) / 1e3).toFixed(1);
797
+ return `element ${sub.selector} is ${waitState} (${elapsed}s)`;
798
+ }
799
+ case "for-text": {
800
+ const waitState = sub.hidden ? "hidden" : "visible";
801
+ await state.page.getByText(sub.text).waitFor({
802
+ state: waitState,
803
+ timeout: sub.timeout
804
+ });
805
+ const elapsed = ((performance.now() - start) / 1e3).toFixed(1);
806
+ return `text "${sub.text}" is ${waitState} (${elapsed}s)`;
807
+ }
808
+ case "for-url": {
809
+ await state.page.waitForURL(sub.pattern, { timeout: sub.timeout });
810
+ const elapsed = ((performance.now() - start) / 1e3).toFixed(1);
811
+ return `url matched ${sub.pattern} (${elapsed}s)`;
812
+ }
813
+ }
814
+ };
815
+
816
+ //#endregion
817
+ //#region src/lib/commands.ts
818
+ const navigation = group("navigation", or(command("open", schema$15, { description: message`navigate to a URL` }), command("back", schema$28, { description: message`go back in history` }), command("forward", schema$20, { description: message`go forward in history` }), command("reload", schema$13, { description: message`reload the current page` }), command("click", schema$26, { description: message`click an element` }), command("dblclick", schema$24, { description: message`double-click an element` }), command("fill", schema$21, { description: message`clear and fill an input field` }), command("type", schema$4, { description: message`type text character by character` }), command("press", schema$14, { description: message`press a keyboard key` })));
819
+ const querying = group("querying", or(command("hover", schema$17, { description: message`hover over an element` }), command("select", schema$9, { description: message`select a dropdown option` }), command("check", schema$27, { description: message`check a checkbox` }), command("uncheck", schema$3, { description: message`uncheck a checkbox` }), command("get", schema$18, { description: message`get page or element data` }), command("is", schema$16, { description: message`check element state` }), command("snapshot", schema$8, { description: message`get the accessibility tree` }), command("screenshot", schema$11, { description: message`take a screenshot` }), command("wait", schema$2, { description: message`wait for an element, text, or URL` })));
820
+ const inspection = group("inspection", or(command("scroll", schema$10, { description: message`scroll the page or a container` }), command("frame", schema$19, { description: message`list or switch frames` }), command("tab", schema$5, { description: message`list, open, switch, or close tabs` }), command("eval", schema$22, { description: message`evaluate JavaScript in the page` }), command("source", schema$7, { description: message`get page or element HTML source` }), command("resources", schema$12, { description: message`list loaded resources` }), command("styles", schema$6, { description: message`get computed styles for an element` }), command("download", schema$23, { description: message`download a resource to assets/` }), command("close", schema$25, { description: message`close the current tab` })));
821
+ const parser = or(navigation, querying, inspection);
822
+ /**
823
+ * creates a command handler that parses args and dispatches to the matching command.
824
+ * @param state mutable browser state shared across commands
825
+ * @returns a command handler compatible with the server
826
+ */
827
+ const createCommandHandler = (state) => async (args) => {
828
+ if (args[0] === "help" || args[0] === "--help") {
829
+ const page = getDocPage(parser, args.slice(1));
830
+ if (page) return {
831
+ ok: true,
832
+ data: formatDocPage("browser", page)
833
+ };
834
+ return {
835
+ ok: false,
836
+ error: `no help available`
837
+ };
838
+ }
839
+ if (state.spinner.isSpinning) state.spinner.text = ["browser", ...args].join(" ").replace(/\s+/g, " ");
840
+ const parsed = parse(parser, args);
841
+ if (!parsed.success) return {
842
+ ok: false,
843
+ error: formatMessage(parsed.error)
844
+ };
845
+ try {
846
+ let data;
847
+ switch (parsed.value.command) {
848
+ case "open":
849
+ data = await handler$15(state, parsed.value);
850
+ break;
851
+ case "back":
852
+ data = await handler$28(state);
853
+ break;
854
+ case "forward":
855
+ data = await handler$20(state);
856
+ break;
857
+ case "reload":
858
+ data = await handler$13(state);
859
+ break;
860
+ case "click":
861
+ data = await handler$26(state, parsed.value);
862
+ break;
863
+ case "dblclick":
864
+ data = await handler$24(state, parsed.value);
865
+ break;
866
+ case "fill":
867
+ data = await handler$21(state, parsed.value);
868
+ break;
869
+ case "type":
870
+ data = await handler$4(state, parsed.value);
871
+ break;
872
+ case "press":
873
+ data = await handler$14(state, parsed.value);
874
+ break;
875
+ case "hover":
876
+ data = await handler$17(state, parsed.value);
877
+ break;
878
+ case "select":
879
+ data = await handler$9(state, parsed.value);
880
+ break;
881
+ case "check":
882
+ data = await handler$27(state, parsed.value);
883
+ break;
884
+ case "uncheck":
885
+ data = await handler$3(state, parsed.value);
886
+ break;
887
+ case "get":
888
+ data = await handler$18(state, parsed.value);
889
+ break;
890
+ case "is":
891
+ data = await handler$16(state, parsed.value);
892
+ break;
893
+ case "snapshot":
894
+ data = await handler$8(state, parsed.value);
895
+ break;
896
+ case "screenshot":
897
+ data = await handler$11(state, parsed.value);
898
+ break;
899
+ case "wait":
900
+ data = await handler$2(state, parsed.value);
901
+ break;
902
+ case "scroll":
903
+ data = await handler$10(state, parsed.value);
904
+ break;
905
+ case "frame":
906
+ data = await handler$19(state, parsed.value);
907
+ break;
908
+ case "tab":
909
+ data = await handler$5(state, parsed.value);
910
+ break;
911
+ case "eval":
912
+ data = await handler$22(state, parsed.value);
913
+ break;
914
+ case "source":
915
+ data = await handler$7(state, parsed.value);
916
+ break;
917
+ case "resources":
918
+ data = await handler$12(state, parsed.value);
919
+ break;
920
+ case "styles":
921
+ data = await handler$6(state, parsed.value);
922
+ break;
923
+ case "download":
924
+ data = await handler$23(state, parsed.value);
925
+ break;
926
+ case "close":
927
+ data = await handler$25(state);
928
+ break;
929
+ }
930
+ return {
931
+ ok: true,
932
+ data
933
+ };
934
+ } catch (e) {
935
+ if (e instanceof CommandError) return {
936
+ ok: false,
937
+ error: e.message
938
+ };
939
+ return {
940
+ ok: false,
941
+ error: e instanceof Error ? e.message : String(e)
942
+ };
943
+ }
944
+ };
945
+
946
+ //#endregion
947
+ //#region src/lib/paths.ts
948
+ /**
949
+ * returns the cache directory for cbr.
950
+ * uses `$XDG_CACHE_HOME/cbr` if set, otherwise falls back to:
951
+ * - `~/.cache/cbr` on linux
952
+ * - `~/Library/Caches/cbr` on macos
953
+ * @returns the cache directory path
954
+ */
955
+ const getCacheDir = () => {
956
+ const xdgCache = process.env["XDG_CACHE_HOME"];
957
+ if (xdgCache) return join(xdgCache, "cbr");
958
+ const home = homedir();
959
+ if (process.platform === "darwin") return join(home, "Library", "Caches", "cbr");
960
+ return join(home, ".cache", "cbr");
961
+ };
962
+ /**
963
+ * returns the sessions directory within the cache.
964
+ * @returns the sessions directory path
965
+ */
966
+ const getSessionsDir = () => join(getCacheDir(), "sessions");
967
+ const PID_FILE = ".pid";
968
+ const PidSchema = v.pipe(v.string(), v.trim(), v.toNumber(), v.integer(), v.minValue(1));
969
+ /**
970
+ * creates a new session directory with a random UUID, PID lockfile, and subdirectories.
971
+ * @returns the path to the created session directory
972
+ */
973
+ const createSessionDir = async () => {
974
+ const sessionPath = join(getSessionsDir(), randomUUID());
975
+ await mkdir(sessionPath, { recursive: true });
976
+ await writeFile(join(sessionPath, PID_FILE), process.pid.toString());
977
+ await Promise.all([
978
+ mkdir(join(sessionPath, ".claude"), { recursive: true }),
979
+ mkdir(join(sessionPath, "bin"), { recursive: true }),
980
+ mkdir(join(sessionPath, "assets"), { recursive: true }),
981
+ mkdir(join(sessionPath, "screenshots"), { recursive: true }),
982
+ mkdir(join(sessionPath, "scratch"), { recursive: true })
983
+ ]);
984
+ return sessionPath;
985
+ };
986
+ /**
987
+ * removes a session directory.
988
+ * @param sessionPath the session directory path
989
+ */
990
+ const cleanupSessionDir = async (sessionPath) => {
991
+ await rm(sessionPath, {
992
+ recursive: true,
993
+ force: true
994
+ });
995
+ };
996
+ /**
997
+ * checks if a process with the given PID is running.
998
+ * @param pid the process ID to check
999
+ * @returns true if the process is running
1000
+ */
1001
+ const isProcessRunning = (pid) => {
1002
+ try {
1003
+ process.kill(pid, 0);
1004
+ return true;
1005
+ } catch {
1006
+ return false;
1007
+ }
1008
+ };
1009
+ /**
1010
+ * garbage collects orphaned session directories.
1011
+ * a session is orphaned if its PID file is missing or the process is no longer running.
1012
+ * this function is meant to be called fire-and-forget (errors are silently ignored).
1013
+ */
1014
+ const gcSessions = async () => {
1015
+ const sessionsDir = getSessionsDir();
1016
+ let entries;
1017
+ try {
1018
+ entries = await readdir(sessionsDir, { withFileTypes: true });
1019
+ } catch {
1020
+ return;
1021
+ }
1022
+ for (const entry of entries) {
1023
+ if (!entry.isDirectory()) continue;
1024
+ const sessionPath = join(sessionsDir, entry.name);
1025
+ const pidPath = join(sessionPath, PID_FILE);
1026
+ try {
1027
+ const pidContent = await readFile(pidPath, "utf-8");
1028
+ const result = v.safeParse(PidSchema, pidContent);
1029
+ if (!result.success || !isProcessRunning(result.output)) await rm(sessionPath, {
1030
+ recursive: true,
1031
+ force: true
1032
+ });
1033
+ } catch {
1034
+ await rm(sessionPath, {
1035
+ recursive: true,
1036
+ force: true
1037
+ }).catch(() => {});
1038
+ }
1039
+ }
1040
+ };
1041
+
1042
+ //#endregion
1043
+ //#region src/lib/debug.ts
1044
+ const debugEnabled = process.env.DEBUG === "1" || process.env.CBR_DEBUG === "1";
1045
+ /**
1046
+ * logs a debug message to stderr if DEBUG=1 or CBR_DEBUG=1.
1047
+ * @param message the message to log
1048
+ */
1049
+ const debug = (message) => {
1050
+ if (debugEnabled) console.error(`[debug] ${message}`);
1051
+ };
1052
+
1053
+ //#endregion
1054
+ //#region src/lib/server.ts
1055
+ const RequestSchema = v.object({
1056
+ id: v.string(),
1057
+ args: v.array(v.string())
1058
+ });
1059
+ /**
1060
+ * starts a JSON-over-Unix-socket server.
1061
+ * each connection handles a single newline-delimited JSON request,
1062
+ * dispatches to the handler, writes the response, and closes.
1063
+ * @param socketPath path to the Unix domain socket
1064
+ * @param handler function that processes commands and returns responses
1065
+ * @returns promise that resolves with the server instance once listening
1066
+ */
1067
+ const startServer = (socketPath, handler) => {
1068
+ return new Promise((resolve, reject) => {
1069
+ const server = createServer((socket) => {
1070
+ let data = "";
1071
+ socket.on("data", (chunk) => {
1072
+ data += chunk.toString();
1073
+ const newlineIndex = data.indexOf("\n");
1074
+ if (newlineIndex === -1) return;
1075
+ const line = data.slice(0, newlineIndex).trim();
1076
+ data = "";
1077
+ handleRequest(line, socket, handler);
1078
+ });
1079
+ socket.on("error", (err) => {
1080
+ debug(`socket error: ${err.message}`);
1081
+ });
1082
+ });
1083
+ server.on("error", reject);
1084
+ server.listen(socketPath, () => {
1085
+ debug(`server listening on ${socketPath}`);
1086
+ resolve(server);
1087
+ });
1088
+ });
1089
+ };
1090
+ const handleRequest = async (line, socket, handler) => {
1091
+ let parsed;
1092
+ try {
1093
+ parsed = JSON.parse(line);
1094
+ } catch {
1095
+ debug(`ignoring malformed JSON: ${line}`);
1096
+ socket.end();
1097
+ return;
1098
+ }
1099
+ const result = v.safeParse(RequestSchema, parsed);
1100
+ if (!result.success) {
1101
+ debug(`ignoring invalid request: ${line}`);
1102
+ socket.end();
1103
+ return;
1104
+ }
1105
+ const { id, args } = result.output;
1106
+ try {
1107
+ debug(`request: ${line}`);
1108
+ const result = await handler(args);
1109
+ const response = JSON.stringify({
1110
+ id,
1111
+ ...result
1112
+ });
1113
+ debug(`response: ${response}`);
1114
+ socket.end(response + "\n");
1115
+ } catch (err) {
1116
+ const message = err instanceof Error ? err.message : String(err);
1117
+ const response = JSON.stringify({
1118
+ id,
1119
+ ok: false,
1120
+ error: message
1121
+ });
1122
+ debug(`handler error: ${message}`);
1123
+ socket.end(response + "\n");
1124
+ }
1125
+ };
1126
+
1127
+ //#endregion
1128
+ //#region src/lib/session.ts
1129
+ /**
1130
+ * writes the per-session `.claude/settings.json` with permissions and hooks.
1131
+ * paths are baked in at generation time so the settings work regardless of cwd.
1132
+ * @param sessionPath the session directory path
1133
+ */
1134
+ const writeSessionSettings = async (sessionPath) => {
1135
+ const settings = {
1136
+ permissions: {
1137
+ allow: [
1138
+ "Read(assets/*)",
1139
+ "Read(screenshots/*)",
1140
+ "Read(scratch/*)",
1141
+ "Write(scratch/*)",
1142
+ "Glob(assets/*)",
1143
+ "Glob(screenshots/*)",
1144
+ "Glob(scratch/*)",
1145
+ "Grep(assets/*)",
1146
+ "Grep(screenshots/*)",
1147
+ "Grep(scratch/*)",
1148
+ "Bash(browser:*)",
1149
+ "Bash(ls:*)",
1150
+ "Bash(cat:*)",
1151
+ "Bash(head:*)",
1152
+ "Bash(tail:*)",
1153
+ "Bash(wc:*)",
1154
+ "Bash(file:*)",
1155
+ "Bash(find:*)",
1156
+ "Bash(tree:*)",
1157
+ "Bash(stat:*)",
1158
+ "Bash(du:*)",
1159
+ "Bash(mkdir:*)",
1160
+ "Bash(basename:*)",
1161
+ "Bash(dirname:*)",
1162
+ "Bash(realpath:*)",
1163
+ "Bash(awk:*)",
1164
+ "Bash(cut:*)",
1165
+ "Bash(diff:*)",
1166
+ "Bash(grep:*)",
1167
+ "Bash(jq:*)",
1168
+ "Bash(sed:*)",
1169
+ "Bash(sort:*)",
1170
+ "Bash(tr:*)",
1171
+ "Bash(uniq:*)",
1172
+ "Bash(xargs:*)",
1173
+ "Bash(paste:*)",
1174
+ "Bash(tee:*)",
1175
+ "Bash(column:*)",
1176
+ "WebSearch",
1177
+ "WebFetch"
1178
+ ],
1179
+ deny: ["*"]
1180
+ },
1181
+ hooks: { SessionStart: [{
1182
+ matcher: "",
1183
+ hooks: [{
1184
+ type: "command",
1185
+ command: `sh -c 'echo "export PATH=\\"${sessionPath}/bin:\\$PATH\\"" >> "$CLAUDE_ENV_FILE"'`
1186
+ }]
1187
+ }] }
1188
+ };
1189
+ await writeFile(join(sessionPath, ".claude", "settings.json"), JSON.stringify(settings, null, " ") + "\n");
1190
+ };
1191
+ /**
1192
+ * writes the `bin/browser` shim script that delegates to the bundled client.
1193
+ * @param sessionPath the session directory path
1194
+ * @param clientScriptPath absolute path to the bundled `dist/browser.mjs`
1195
+ * @param socketPath absolute path to the Unix domain socket
1196
+ */
1197
+ const writeBrowserShim = async (sessionPath, clientScriptPath, socketPath) => {
1198
+ const shimPath = join(sessionPath, "bin", "browser");
1199
+ await writeFile(shimPath, `#!/bin/sh\nexec node ${clientScriptPath} --socket ${socketPath} "$@"\n`);
1200
+ await chmod(shimPath, 493);
1201
+ };
1202
+
1203
+ //#endregion
1204
+ //#region src/commands/ask.ts
1205
+ const systemPromptPath = join(join(import.meta.dirname, "assets"), "system-prompt.md");
1206
+ const clientScriptPath = join(import.meta.dirname, "client.mjs");
1207
+ const schema$1 = object({
1208
+ command: constant("ask"),
1209
+ model: withDefault(option("-m", "--model", choice([
1210
+ "opus",
1211
+ "sonnet",
1212
+ "haiku"
1213
+ ]), { description: message`model to use` }), "sonnet"),
1214
+ headful: withDefault(flag("--headful", { description: message`show browser window (default: headless)` }), false),
1215
+ url: optional(option("--url", string(), { description: message`starting URL to navigate to` })),
1216
+ task: argument(string({ metavar: "TASK" }), { description: message`what to accomplish in the browser` })
1217
+ });
1218
+ /**
1219
+ * spawns Claude Code as a child process.
1220
+ * @param cwd working directory
1221
+ * @param args command arguments
1222
+ * @param contextPrompt additional context to append to the system prompt
1223
+ * @returns the child process
1224
+ */
1225
+ const spawnClaude = (cwd, args, contextPrompt) => {
1226
+ return spawn("claude", [
1227
+ "-p",
1228
+ args.task,
1229
+ "--no-session-persistence",
1230
+ "--model",
1231
+ args.model,
1232
+ "--system-prompt-file",
1233
+ systemPromptPath,
1234
+ "--append-system-prompt",
1235
+ contextPrompt
1236
+ ], {
1237
+ cwd,
1238
+ stdio: [
1239
+ "ignore",
1240
+ "pipe",
1241
+ "inherit"
1242
+ ],
1243
+ env: {
1244
+ ...process.env,
1245
+ CLAUDECODE: ""
1246
+ }
1247
+ });
1248
+ };
1249
+ /**
1250
+ * waits for a child process to exit, collecting its stdout.
1251
+ * @param child the child process
1252
+ * @returns promise that resolves with exit code and collected stdout
1253
+ */
1254
+ const waitForExit = (child) => {
1255
+ return new Promise((resolve, reject) => {
1256
+ const chunks = [];
1257
+ child.stdout?.on("data", (chunk) => {
1258
+ chunks.push(chunk);
1259
+ });
1260
+ child.on("close", (code) => resolve({
1261
+ code: code ?? 0,
1262
+ stdout: Buffer.concat(chunks).toString()
1263
+ }));
1264
+ child.on("error", (err) => reject(/* @__PURE__ */ new Error(`failed to summon claude: ${err}`)));
1265
+ });
1266
+ };
1267
+ /**
1268
+ * handles the ask command.
1269
+ * launches a browser, starts the IPC server, and spawns Claude Code.
1270
+ * @param args parsed command arguments
1271
+ */
1272
+ const handler$1 = async (args) => {
1273
+ gcSessions();
1274
+ const sessionPath = await createSessionDir();
1275
+ const socketPath = join(sessionPath, ".sock");
1276
+ let exitCode = 1;
1277
+ let claudeOutput = "";
1278
+ let browser;
1279
+ let claude;
1280
+ const onSignal = () => {
1281
+ if (claude) claude.kill("SIGTERM");
1282
+ spin.stop("interrupted");
1283
+ if (browser) browser.close().catch(() => {});
1284
+ cleanupSessionDir(sessionPath).finally(() => {
1285
+ process.exit(130);
1286
+ });
1287
+ };
1288
+ process.on("SIGINT", onSignal);
1289
+ process.on("SIGTERM", onSignal);
1290
+ const spin = yoctoSpinner({ text: args.headful ? "launching browser (headful)" : "launching browser" }).start();
1291
+ try {
1292
+ browser = await chromium.launch({ headless: !args.headful });
1293
+ const context = await browser.newContext();
1294
+ const page = await context.newPage();
1295
+ if (args.url) {
1296
+ spin.text = `navigating to ${args.url}`;
1297
+ await page.goto(args.url, { waitUntil: "domcontentloaded" });
1298
+ }
1299
+ const state = {
1300
+ context,
1301
+ page,
1302
+ refs: {},
1303
+ frameRefs: {},
1304
+ assetsDir: join(sessionPath, "assets"),
1305
+ screenshotDir: join(sessionPath, "screenshots"),
1306
+ screenshotCounter: 0,
1307
+ spinner: spin
1308
+ };
1309
+ spin.text = "starting session";
1310
+ const server = await startServer(socketPath, createCommandHandler(state));
1311
+ await writeBrowserShim(sessionPath, clientScriptPath, socketPath);
1312
+ await writeSessionSettings(sessionPath);
1313
+ const contextParts = [];
1314
+ if (args.url) contextParts.push(`The browser is already open at: ${args.url}`);
1315
+ const contextPrompt = contextParts.length > 0 ? contextParts.join("\n") : "";
1316
+ spin.text = "summoning claude";
1317
+ claude = spawnClaude(sessionPath, args, contextPrompt);
1318
+ const result = await waitForExit(claude);
1319
+ exitCode = result.code;
1320
+ claudeOutput = result.stdout;
1321
+ server.close();
1322
+ } finally {
1323
+ process.off("SIGINT", onSignal);
1324
+ process.off("SIGTERM", onSignal);
1325
+ spin.stop();
1326
+ if (browser) await browser.close().catch(() => {});
1327
+ await cleanupSessionDir(sessionPath);
1328
+ }
1329
+ if (claudeOutput) process.stdout.write(claudeOutput);
1330
+ process.exit(exitCode);
1331
+ };
1332
+
1333
+ //#endregion
1334
+ //#region src/commands/clean.ts
1335
+ const schema = object({ command: constant("clean") });
1336
+ /**
1337
+ * handles the clean command.
1338
+ * garbage collects orphaned session directories.
1339
+ * @param _args parsed command arguments
1340
+ */
1341
+ const handler = async (_args) => {
1342
+ await gcSessions();
1343
+ };
1344
+
1345
+ //#endregion
1346
+ //#region src/index.ts
1347
+ const result = run(or(command("ask", schema$1, { description: message`ask a question by browsing the web with Claude Code` }), command("clean", schema, { description: message`remove cached session data` })), {
1348
+ programName: "cbr",
1349
+ help: "both",
1350
+ version: {
1351
+ value: version,
1352
+ mode: "both"
1353
+ },
1354
+ brief: message`ask questions by browsing the web using Claude Code`
1355
+ });
1356
+ switch (result.command) {
1357
+ case "ask":
1358
+ await handler$1(result);
1359
+ break;
1360
+ case "clean":
1361
+ await handler(result);
1362
+ break;
1363
+ }
1364
+
1365
+ //#endregion
1366
+ export { };