@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.
- package/LICENSE +14 -0
- package/README.md +72 -0
- package/dist/assets/system-prompt.md +147 -0
- package/dist/client.mjs +54 -0
- package/dist/index.mjs +1366 -0
- package/package.json +45 -0
- package/src/assets/system-prompt.md +147 -0
- package/src/client.ts +70 -0
- package/src/commands/ask.ts +202 -0
- package/src/commands/clean.ts +18 -0
- package/src/index.ts +34 -0
- package/src/lib/commands/_types.ts +24 -0
- package/src/lib/commands/_utils.ts +38 -0
- package/src/lib/commands/back.ts +14 -0
- package/src/lib/commands/check.ts +14 -0
- package/src/lib/commands/click.ts +14 -0
- package/src/lib/commands/close.ts +17 -0
- package/src/lib/commands/dblclick.ts +14 -0
- package/src/lib/commands/download.ts +36 -0
- package/src/lib/commands/eval.ts +23 -0
- package/src/lib/commands/fill.ts +18 -0
- package/src/lib/commands/forward.ts +14 -0
- package/src/lib/commands/frame.ts +106 -0
- package/src/lib/commands/get.ts +95 -0
- package/src/lib/commands/hover.ts +14 -0
- package/src/lib/commands/is.ts +53 -0
- package/src/lib/commands/open.ts +15 -0
- package/src/lib/commands/press.ts +13 -0
- package/src/lib/commands/reload.ts +14 -0
- package/src/lib/commands/resources.ts +37 -0
- package/src/lib/commands/screenshot.ts +26 -0
- package/src/lib/commands/scroll.ts +30 -0
- package/src/lib/commands/select.ts +18 -0
- package/src/lib/commands/snapshot.ts +30 -0
- package/src/lib/commands/source.ts +23 -0
- package/src/lib/commands/styles.ts +63 -0
- package/src/lib/commands/tab.ts +102 -0
- package/src/lib/commands/type-text.ts +18 -0
- package/src/lib/commands/uncheck.ts +14 -0
- package/src/lib/commands/wait.ts +93 -0
- package/src/lib/commands.ts +202 -0
- package/src/lib/debug.ts +11 -0
- package/src/lib/paths.ts +118 -0
- package/src/lib/server.ts +94 -0
- package/src/lib/session.ts +92 -0
- 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 { };
|