browser-attach 0.0.0-stage → 0.1.2

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.
@@ -0,0 +1,1451 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire as __attachCreateRequire } from 'node:module'; const require = __attachCreateRequire(import.meta.url);
3
+
4
+ // packages/bridge/dist/cli/main.js
5
+ import { parseArgs } from "node:util";
6
+ import { existsSync as existsSync5 } from "node:fs";
7
+ import { join as join7 } from "node:path";
8
+ import { homedir as homedir5 } from "node:os";
9
+
10
+ // packages/bridge/dist/cli/commands.js
11
+ import { readFileSync as readFileSync3, copyFileSync, existsSync as existsSync3, mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "node:fs";
12
+ import { basename, dirname as dirname3, extname, join as join4, resolve } from "node:path";
13
+ import { homedir as homedir4 } from "node:os";
14
+
15
+ // packages/bridge/dist/tool-descriptions.js
16
+ var Descriptions = {
17
+ snapshot: "Capture an interactables snapshot of the active tab. Returns refs you can use with click/type/hover/etc. is_new flags appear on elements that weren't in the previous snapshot.",
18
+ view: `Reading-order markdown view of the page (iframes included) mixing text and [eN] refs. The canonical "see this page" tool. Over the size budget it returns an outline, the part around the viewport and a file path holding the whole view (grep it: control lines carry their refs). ref: one element's subtree. offset: page through. Plain-text and JSON documents come back as raw text. Screenshots: browser_screenshot.`,
19
+ screenshot: "Screenshot of the visible viewport (JPEG, max 1024px wide), one element (ref: cropped) or the whole page (full). annotate draws each visible interactable's ref on the image and lists them, so a ref can be picked from the picture. Costs far more context than browser_view; use it when the text is not enough (icons, charts, canvas, layout). To click a point seen in it: x = origin.x + image x * scale (same for y), or click the ref of an annotated box. A background agent tab is brought to the front of the agent window first.",
20
+ find: "Search the page view for text or a regex (names, roles, values, text) and return the matching lines with their refs, a little context and the enclosing sections. Cheaper than a full view on large pages.",
21
+ navigate: "Navigate the tab to a URL and wait for it to load. Returns URL, title, the interactable count and the first interactables of the new page; call browser_view to read it.",
22
+ click: "Click an element by ref (preferred) or by x/y viewport point. Synthetic events; no CDP. Returns effects (mutations, focus, dialog), the page URL/title, and newly appeared interactables; a click that loads a new page waits for it (navigated:true).",
23
+ type: "Type into an input. Pass `text` for what to type. `clear:true` empties the field first; `submit:true` presses Enter after (a resulting page load is waited for). Returns effects and newly appeared interactables.",
24
+ press_key: "Press a keyboard key. Use for keys not typeable as text (Enter, Tab, ArrowDown, Escape, F5, \u2026). A resulting page load is waited for.",
25
+ select: "Pick option(s) from a <select> by ref or x/y. Pass option `values` (labels also match).",
26
+ hover: "Hover over an element (ref or x/y). Useful for revealing tooltips and menu drop-downs; returns the newly appeared interactables.",
27
+ upload_file: "Upload base64-encoded file(s) to an <input type=file> (ref, or x/y on its button/label). Provide payloads inline.",
28
+ wait_for: "Wait until a condition holds: text appears, ref exists, URL matches, or an outgoing request URL matches. Plain sleep if no condition is set.",
29
+ scroll: "Scroll the page. Relative (dx/dy), absolute (y), into-view (ref), or sweep mode (until:page_end with pauses for lazy load).",
30
+ tabs: "Tab management: list, switch, new, close. Tabs are scoped to this MCP session: new tabs open in a window of their own for this session, and every tool reaches only tabs this session opened (or tabs opened from them).",
31
+ network_log: "Get recent network entries (fetch + XHR) for the active tab. Captured via in-page monkey-patch.",
32
+ eval_js: "Evaluate a JS expression or statement body (use return) in the page MAIN world, which sees page JS variables. Use only for things the other tools can't do. On pages that forbid eval (strict CSP, Trusted Types) or sandboxed pages (raw files), it falls back to a user-script world: DOM only, no page JS variables; the result's world field says which ran. Errors thrown by the expression are reported.",
33
+ inspect: "Read fields (text, value, tag, role, html, attributes, \u2026) from an element by ref. Useful for checking state after an interaction.",
34
+ get_cookie: "Read cookies for a URL (defaults to active tab). Optionally filter by exact name.",
35
+ request: "Make an HTTP request from the page context (carries the page's cookies / auth). Use for API calls that need the user's session.",
36
+ batch: "Run a sequence of tool calls in one round trip. Stops on error by default, or on any tool that invalidates refs (navigate, tab switch)."
37
+ };
38
+
39
+ // packages/bridge/dist/paths.js
40
+ import { homedir } from "node:os";
41
+ import { join } from "node:path";
42
+ var env = () => process.env;
43
+ var home = () => env().HOME || homedir();
44
+ var cacheDir = () => env().BROWSER_ATTACH_CACHE_DIR || join(env().XDG_CACHE_HOME || join(home(), ".cache"), "browser-attach");
45
+ var configDir = () => env().BROWSER_ATTACH_CONFIG_DIR || join(env().XDG_CONFIG_HOME || join(home(), ".config"), "browser-attach");
46
+ var runtimeDir = () => env().BROWSER_ATTACH_RUNTIME_DIR || (env().XDG_RUNTIME_DIR ? join(env().XDG_RUNTIME_DIR, "browser-attach") : join(cacheDir(), "run"));
47
+ var configPath = () => join(configDir(), "config.json");
48
+ var socketPath = (port) => process.platform === "win32" ? `\\\\.\\pipe\\browser-attach-${(env().USERNAME || "user").replace(/[^\w.-]/g, "")}-${port}` : join(runtimeDir(), `daemon-${port}.sock`);
49
+ var logPath = (port) => join(cacheDir(), `daemon-${port}.log`);
50
+ var SESSION_NAME = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,63}$/;
51
+
52
+ // packages/bridge/dist/daemon/config.js
53
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
54
+ import { homedir as homedir2 } from "node:os";
55
+ import { dirname, join as join2 } from "node:path";
56
+ var NUMBERS = ["idleExitMs", "sessionTtlMs", "outMaxMB"];
57
+ var DEFAULTS = { idleExitMs: 2 * 36e5, sessionTtlMs: 30 * 6e4, outMaxMB: 200 };
58
+ function readConfig(path = configPath()) {
59
+ try {
60
+ const c = JSON.parse(readFileSync(path, "utf8"));
61
+ return c && typeof c === "object" ? c : {};
62
+ } catch {
63
+ return {};
64
+ }
65
+ }
66
+ function migrateLegacyConfig(path = configPath()) {
67
+ if (existsSync(path))
68
+ return false;
69
+ const legacy = join2(process.env.XDG_CONFIG_HOME || join2(process.env.HOME || homedir2(), ".config"), "jev-browser", "config.json");
70
+ if (!existsSync(legacy))
71
+ return false;
72
+ const old = readConfig(legacy), config = {};
73
+ for (const key of NUMBERS)
74
+ if (typeof old[key] === "number")
75
+ config[key] = old[key];
76
+ writeConfig(config, path);
77
+ return true;
78
+ }
79
+ function writeConfig(config, path = configPath()) {
80
+ mkdirSync(dirname(path), { recursive: true, mode: 448 });
81
+ const tmp = `${path}.${process.pid}.tmp`;
82
+ writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n", { mode: 384 });
83
+ renameSync(tmp, path);
84
+ }
85
+ function setConfig(config, assignment) {
86
+ const i = assignment.indexOf("=");
87
+ if (i < 1)
88
+ throw new Error(`expected KEY=VALUE, got ${JSON.stringify(assignment)}`);
89
+ const key = assignment.slice(0, i), value = assignment.slice(i + 1);
90
+ if (!NUMBERS.includes(key))
91
+ throw new Error(`unknown setting ${key}; settings: ${NUMBERS.join(", ")}`);
92
+ const n2 = Number(value);
93
+ if (!Number.isFinite(n2) || n2 < 0)
94
+ throw new Error(`${key} must be a number >= 0`);
95
+ return { ...config, [key]: n2 };
96
+ }
97
+
98
+ // packages/bridge/dist/cli/format.js
99
+ var s = (v) => typeof v === "string" ? v : "";
100
+ var q = (v) => JSON.stringify(s(v));
101
+ var n = (v) => typeof v === "number" ? v : void 0;
102
+ var fileLine = (path) => `\u2192 ${path}`;
103
+ var clip = (text, max) => text.length > max ? text.slice(0, max - 1) + "\u2026" : text;
104
+ function change(verb, r) {
105
+ const parts = [`${verb} ok`];
106
+ if (r.navigated)
107
+ parts.push(`navigated \u2192 ${s(r.url)} ${q(r.title)}`);
108
+ else if (r.same_document)
109
+ parts.push(`url \u2192 ${s(r.url)}`);
110
+ if (r.loading)
111
+ parts.push("still loading");
112
+ if (r.navigation_error)
113
+ parts.push(`navigation error ${s(r.navigation_error)}`);
114
+ if (Array.isArray(r.opened_tabs) && r.opened_tabs.length)
115
+ parts.push(`opened tab ${r.opened_tabs.join(", ")} (browser-attach tabs)`);
116
+ const e = r.effects;
117
+ if (e?.dialog_opened)
118
+ parts.push("a dialog opened");
119
+ else if (e && !r.navigated && !e.url_changed && !e.target_subtree_changed && e.added_nodes === 0 && e.removed_nodes === 0)
120
+ parts.push("no visible change");
121
+ if (typeof r.typed === "string" && verb.startsWith("type"))
122
+ parts.push(`typed ${q(clip(r.typed, 80))}`);
123
+ if (typeof r.target === "string")
124
+ parts.push(`hit ${r.target}`);
125
+ if (n(r.interactables) !== void 0)
126
+ parts.push(`${r.interactables} refs`);
127
+ const lines2 = [parts.join(" \xB7 ")];
128
+ const fresh = Array.isArray(r.new) ? r.new : [];
129
+ if (fresh.length || r.more_new)
130
+ lines2.push(`new: ${fresh.join(" \xB7 ")}${r.more_new ? ` (+${r.more_new}: browser-attach view)` : ""}`);
131
+ if (r.note)
132
+ lines2.push(`note: ${s(r.note)}`);
133
+ return lines2;
134
+ }
135
+ function view(r) {
136
+ const lines2 = [];
137
+ const size = [n(r.lines) !== void 0 ? `${r.lines} lines` : "", n(r.actions) !== void 0 ? `${r.actions} refs` : "", n(r.chars) !== void 0 ? `${r.chars.toLocaleString("en-US")} chars` : ""].filter(Boolean);
138
+ lines2.push([`${q(r.title)} ${s(r.url)}`, ...size].join(" \xB7 "));
139
+ if (r.from !== void 0)
140
+ lines2[0] += ` \xB7 lines ${r.from}-${r.to}${r.next_offset ? ` (next: --offset ${r.next_offset})` : ""}`;
141
+ if (r.outline) {
142
+ lines2.push("outline:");
143
+ for (const line of s(r.outline).split("\n"))
144
+ lines2.push(` ${line}`);
145
+ }
146
+ if (typeof r.content === "string") {
147
+ if (r.shown)
148
+ lines2.push(`lines ${r.shown[0]}-${r.shown[1]}:`);
149
+ lines2.push(r.content);
150
+ }
151
+ if (r.file)
152
+ lines2.push(fileLine(s(r.file)));
153
+ return { lines: lines2, file: s(r.file) || void 0 };
154
+ }
155
+ function shot(r) {
156
+ const image = r.image ?? {};
157
+ const origin = r.origin ?? { x: 0, y: 0 };
158
+ const off = (v) => v ? `${v} + ` : "";
159
+ const parts = [`shot ${image.width}x${image.height} ${r.mode}${r.ref ? ` ${s(r.ref)}` : ""}`, `${(n(image.bytes) ?? 0).toLocaleString("en-US")} bytes`];
160
+ if (r.mode === "full")
161
+ parts.push(`page ${r.pageHeight}px in ${r.segments} captures${r.truncated ? ", truncated" : ""}`);
162
+ if (r.clipped)
163
+ parts.push("clipped to the viewport");
164
+ parts.push(r.mode === "full" ? `scale ${r.scale} (page y = image y * ${r.scale}; scroll there before click --xy)` : `click --xy ${off(origin.x)}X*${r.scale},${off(origin.y)}Y*${r.scale} (viewport ${r.viewportWidth}x${r.viewportHeight})`);
165
+ parts.push(`${q(r.title)} ${s(r.url)}`);
166
+ const lines2 = [parts.join(" \xB7 ")];
167
+ const marks = Array.isArray(r.marks) ? r.marks : void 0;
168
+ if (marks) {
169
+ lines2.push(`${marks.length} refs drawn${r.moreMarks ? ` (+${r.moreMarks} not drawn)` : ""} (click <ref>):`);
170
+ for (const m of marks)
171
+ lines2.push(` ${m}`);
172
+ }
173
+ return { lines: lines2 };
174
+ }
175
+ function find(term, r) {
176
+ const lines2 = [`find ${q(term)}: ${r.matches} match${r.matches === 1 ? "" : "es"}${r.shown !== r.matches ? ` (showing ${r.shown})` : ""} in ${q(r.title)} \xB7 ${r.lines} lines`];
177
+ if (typeof r.results === "string" && r.results)
178
+ lines2.push(r.results);
179
+ if (r.hint)
180
+ lines2.push(s(r.hint));
181
+ if (r.file)
182
+ lines2.push(fileLine(s(r.file)));
183
+ return { lines: lines2, file: s(r.file) || void 0 };
184
+ }
185
+ function tabs(r) {
186
+ const list = r.tabs ?? [];
187
+ if (!list.length)
188
+ return ["no tabs in this session (browser-attach open <url>)"];
189
+ return list.map((t) => `${t.id === r.active ? "*" : " "} ${t.id} ${q(clip(s(t.title), 80))} ${s(t.url)}`);
190
+ }
191
+ function evalResult(r) {
192
+ if (r.file)
193
+ return { lines: [`eval: ${(n(r.chars) ?? 0).toLocaleString("en-US")} chars of JSON`, ...r.preview ? [`preview: ${clip(s(r.preview).replace(/\s+/g, " "), 300)}`] : [], fileLine(s(r.file))], file: s(r.file) };
194
+ const text = JSON.stringify(r.value);
195
+ return { lines: [text === void 0 ? "undefined" : text.length > 200 ? JSON.stringify(r.value, null, 2) : text] };
196
+ }
197
+ var NEXT = {
198
+ STALE_REF: "The page re-rendered: get fresh refs with browser-attach view or browser-attach find <text>.",
199
+ NOT_FOUND: "Get fresh refs with browser-attach view or browser-attach find <text>.",
200
+ NO_SESSION_TAB: "Open a tab first: browser-attach open <url>.",
201
+ TAB_NOT_OWNED: "List the tabs of this session: browser-attach tabs.",
202
+ UNCERTAIN_STATE: "Check the page with browser-attach view, then act again.",
203
+ EXTENSION_NOT_CONNECTED: "Open Chrome with the extension loaded; check with browser-attach status.",
204
+ EXTENSION_OUTDATED: "Reload the extension from the current build (chrome://extensions).",
205
+ QUEUE_TIMEOUT: "Earlier calls of this session are slow; check browser-attach sessions, or use another session with -s.",
206
+ QUEUE_FULL: "Wait for the queued calls of this session to finish.",
207
+ DAEMON_EXITED: "Check the page with browser-attach view before acting again."
208
+ };
209
+ var nextStep = (code) => NEXT[code];
210
+ var INFRA = /* @__PURE__ */ new Set(["EXTENSION_NOT_CONNECTED", "EXTENSION_OUTDATED", "PORT_IN_USE", "DAEMON_START_FAILED", "DAEMON_OUTDATED", "DAEMON_EXITED", "DAEMON_BUSY", "DAEMON_RESTARTED", "NO_DAEMON_ENTRY", "RPC_MISMATCH", "BRIDGE_DISCONNECT", "NOT_RUNNING"]);
211
+ function exitCode(code) {
212
+ if (code === "USAGE")
213
+ return 2;
214
+ if (INFRA.has(code))
215
+ return 3;
216
+ if (code === "TIMEOUT" || code === "QUEUE_TIMEOUT")
217
+ return 5;
218
+ return 1;
219
+ }
220
+
221
+ // packages/bridge/dist/cli/skill.js
222
+ var SKILL = `---
223
+ name: browser-attach
224
+ description: Drive the user's real Chrome (logged-in sessions) with the \`browser-attach\` CLI - open pages, read them, click and type. Use for any web interaction, or for reading pages that need the user's browser.
225
+ allowed-tools: Bash(browser-attach:*), Read, Grep
226
+ ---
227
+ # browser-attach: the user's Chrome from the shell
228
+
229
+ Each Claude Code session gets its own tabs automatically (each in its own Chrome window); other sessions cannot see them.
230
+
231
+ ## Loop
232
+ 1. \`browser-attach open <url>\` (new tab) or \`browser-attach goto <url>\` (current tab). Both print the page like \`view\`.
233
+ 2. Read the page: \`browser-attach view\`. Small pages print inline; big pages print an outline and a file path (\`\u2192 /path/view-0002-host.md\`).
234
+ Every control line in the file carries its ref, e.g. \`- button: "Submit" [e12]\`.
235
+ Find things with the **Grep tool** on that file (pattern \`Submit\`), or read a range with the **Read tool** (offset/limit; the outline gives line numbers).
236
+ Or ask browser-attach: \`browser-attach find Submit\`, \`browser-attach find --regex 'button: "(Save|Submit)'\`, \`browser-attach view --ref e40\` (one subtree).
237
+ 3. Act with refs: \`browser-attach click e12\`, \`browser-attach type e7 "text" --submit\`, \`browser-attach select e9 value\`, \`browser-attach press Enter\`.
238
+ Results are one to three lines: navigation, dialogs, up to 20 new elements. View again only when you need more.
239
+ 4. Refs go stale when the page re-renders: on \`STALE_REF\`, run \`browser-attach view\` or \`browser-attach find\` again.
240
+
241
+ ## Rules
242
+ - Use the Read/Grep tools on browser-attach's files. Don't pipe or redirect its output (\`| grep\`, \`> file\`), don't prefix environment variables (\`VAR=x browser-attach\`), don't \`cd\` first: each of these triggers a permission prompt.
243
+ - Screenshots only when the text is not enough (icons, charts, canvas, layout, "what does it look like"); \`view\` is cheaper and gives refs. \`browser-attach shot\` writes a JPEG of the viewport and prints its path; Read it to look.
244
+ \`shot --ref e12\`: just that element. \`shot --full\`: the whole page (stitched, at most 10 screens). \`shot --annotate\`: every visible control boxed with its ref, refs listed below the path; use it for icon-only buttons, then \`browser-attach click <ref>\`.
245
+ Without annotate, click a point seen in the image with \`browser-attach click --xy X,Y\` in CSS pixels: multiply image pixels by the scale the shot line prints (plus the printed offset for --ref crops).
246
+ - Upload local files: \`browser-attach upload e5 ./report.pdf\`.
247
+ - Subagents share your tabs; give a subagent its own with \`-s <name>\` on every command.
248
+ - \`browser-attach close\` when finished (closes this session's tabs). Errors print \`error CODE: message\` and the next step.
249
+
250
+ ## More
251
+ \`browser-attach help\`, \`browser-attach <command> --help\`. Exit codes: 0 ok, 1 tool error, 2 usage, 3 browser/daemon unavailable, 5 timeout.
252
+ `;
253
+
254
+ // packages/bridge/dist/cli/setup.js
255
+ import { cpSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
256
+ import { homedir as homedir3 } from "node:os";
257
+ import { dirname as dirname2, join as join3 } from "node:path";
258
+ import { fileURLToPath } from "node:url";
259
+ var home2 = () => process.env.HOME || process.env.USERPROFILE || homedir3();
260
+ function extensionDir() {
261
+ if (process.env.BROWSER_ATTACH_EXTENSION_DIR)
262
+ return process.env.BROWSER_ATTACH_EXTENSION_DIR;
263
+ if (process.platform === "win32")
264
+ return join3(process.env.LOCALAPPDATA || join3(home2(), "AppData", "Local"), "browser-attach", "extension");
265
+ return join3(process.env.XDG_DATA_HOME || join3(home2(), ".local", "share"), "browser-attach", "extension");
266
+ }
267
+ function packagedExtensionDir() {
268
+ const here = dirname2(fileURLToPath(import.meta.url));
269
+ return [join3(here, "extension"), join3(here, "..", "..", "..", "extension", "dist")].find((d) => existsSync2(join3(d, "manifest.json")));
270
+ }
271
+ function extensionVersion(dir) {
272
+ try {
273
+ return JSON.parse(readFileSync2(join3(dir, "manifest.json"), "utf8")).version;
274
+ } catch {
275
+ return void 0;
276
+ }
277
+ }
278
+ function copyExtension(src, dest) {
279
+ rmSync(dest, { recursive: true, force: true });
280
+ mkdirSync2(dest, { recursive: true });
281
+ cpSync(src, dest, { recursive: true, filter: (p) => !p.endsWith(".map") });
282
+ }
283
+ function permissionRules(platform = process.platform) {
284
+ const rules = ["Skill(browser-attach)", "Bash(browser-attach:*)", "Read(~/.cache/browser-attach/**)"];
285
+ if (platform === "win32")
286
+ rules.push("PowerShell(browser-attach *)");
287
+ return rules;
288
+ }
289
+ function mergePermissions(path, rules) {
290
+ let settings = {};
291
+ const exists = existsSync2(path);
292
+ if (exists) {
293
+ const text = readFileSync2(path, "utf8");
294
+ if (text.trim()) {
295
+ try {
296
+ const parsed = JSON.parse(text);
297
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
298
+ throw new Error("not a JSON object");
299
+ settings = parsed;
300
+ } catch (e) {
301
+ return { status: "invalid", added: [], detail: e.message };
302
+ }
303
+ }
304
+ }
305
+ const perms = settings.permissions;
306
+ if (perms !== void 0 && (!perms || typeof perms !== "object" || Array.isArray(perms)))
307
+ return { status: "invalid", added: [], detail: '"permissions" is not an object' };
308
+ const allow = perms?.allow;
309
+ if (allow !== void 0 && !Array.isArray(allow))
310
+ return { status: "invalid", added: [], detail: '"permissions.allow" is not an array' };
311
+ const current = allow ?? [];
312
+ const added = rules.filter((r) => !current.includes(r));
313
+ if (!added.length)
314
+ return { status: "unchanged", added };
315
+ settings.permissions = { ...perms, allow: [...current, ...added] };
316
+ mkdirSync2(dirname2(path), { recursive: true });
317
+ writeFileSync2(path, JSON.stringify(settings, null, 2) + "\n");
318
+ return { status: exists ? "updated" : "created", added };
319
+ }
320
+
321
+ // packages/bridge/dist/cli/commands.js
322
+ import { createInterface } from "node:readline/promises";
323
+ var CliError = class extends Error {
324
+ code;
325
+ constructor(code, message) {
326
+ super(message);
327
+ this.code = code;
328
+ }
329
+ };
330
+ var usage = (message) => new CliError("USAGE", message);
331
+ var str = (o, k) => typeof o[k] === "string" ? o[k] : void 0;
332
+ function int(o, k) {
333
+ const v = str(o, k);
334
+ if (v === void 0)
335
+ return void 0;
336
+ const n2 = Number(v);
337
+ if (!Number.isFinite(n2))
338
+ throw usage(`--${k} needs a number, got ${JSON.stringify(v)}`);
339
+ return n2;
340
+ }
341
+ function arg(args, i, name) {
342
+ const v = args[i];
343
+ if (v === void 0)
344
+ throw usage(`missing <${name}>`);
345
+ return v;
346
+ }
347
+ function xy(o) {
348
+ const v = str(o, "xy");
349
+ if (v === void 0)
350
+ return void 0;
351
+ const m = /^\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*$/.exec(v);
352
+ if (!m)
353
+ throw usage(`--xy takes X,Y in CSS pixels, got ${JSON.stringify(v)}`);
354
+ return { x: Number(m[1]), y: Number(m[2]) };
355
+ }
356
+ var tabbed = (ctx, input) => ctx.tab !== void 0 ? { ...input, tabId: ctx.tab } : input;
357
+ var outMode = (o) => o.inline ? "inline" : o.file ? "file" : void 0;
358
+ function target(args, o, verb) {
359
+ const point = xy(o);
360
+ if (point)
361
+ return point;
362
+ return { ref: arg(args, 0, `ref (or --xy X,Y for ${verb})`) };
363
+ }
364
+ async function viewAfter(ctx, o, tabId) {
365
+ const v = view(await ctx.call("view", tabId !== void 0 ? { tabId } : tabbed(ctx, {}), outMode(o)));
366
+ return { lines: v.lines, files: v.file ? [v.file] : [] };
367
+ }
368
+ var MIME = { ".pdf": "application/pdf", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".txt": "text/plain", ".csv": "text/csv", ".json": "application/json", ".html": "text/html", ".zip": "application/zip", ".doc": "application/msword", ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" };
369
+ var UPLOAD_MAX = 50 * 1024 * 1024;
370
+ var VIEW_OPTS = { inline: { type: "boolean" }, file: { type: "boolean" }, "no-view": { type: "boolean" } };
371
+ var COMMANDS = [
372
+ // ── page ──
373
+ {
374
+ name: "open",
375
+ group: "page",
376
+ usage: ["open <url> [--no-view] [--inline|--file]"],
377
+ summary: "Open a URL in a new tab of this session and print the page.",
378
+ tool: "navigate",
379
+ options: VIEW_OPTS,
380
+ example: "browser-attach open https://example.com",
381
+ async run(ctx, args, o) {
382
+ const url = arg(args, 0, "url");
383
+ const tab = await ctx.call("tabs", { action: "new" });
384
+ const nav = await ctx.call("navigate", { url, tabId: tab.tabId });
385
+ const head = change(`open tab ${tab.tabId}`, nav);
386
+ if (o["no-view"])
387
+ return { lines: head, result: nav };
388
+ const v = await viewAfter(ctx, o, tab.tabId);
389
+ return { ...v, lines: [head[0], ...v.lines], result: nav };
390
+ }
391
+ },
392
+ {
393
+ name: "goto",
394
+ group: "page",
395
+ usage: ["goto <url> [--no-view] [--inline|--file]"],
396
+ summary: "Navigate the current tab and print the page.",
397
+ tool: "navigate",
398
+ options: VIEW_OPTS,
399
+ async run(ctx, args, o) {
400
+ const nav = await ctx.call("navigate", tabbed(ctx, { url: arg(args, 0, "url") }));
401
+ const head = change("goto", nav);
402
+ if (o["no-view"])
403
+ return { lines: head, result: nav };
404
+ const v = await viewAfter(ctx, o);
405
+ return { ...v, lines: [head[0], ...v.lines], result: nav };
406
+ }
407
+ },
408
+ {
409
+ name: "back",
410
+ group: "page",
411
+ usage: ["back [--no-view]"],
412
+ summary: "Go back in the tab history and print the page.",
413
+ options: VIEW_OPTS,
414
+ async run(ctx, _a, o) {
415
+ return history(ctx, o, "history.back()", "back");
416
+ }
417
+ },
418
+ {
419
+ name: "reload",
420
+ group: "page",
421
+ usage: ["reload [--no-view]"],
422
+ summary: "Reload the current tab and print the page.",
423
+ options: VIEW_OPTS,
424
+ async run(ctx, _a, o) {
425
+ return history(ctx, o, "location.reload()", "reload");
426
+ }
427
+ },
428
+ {
429
+ name: "view",
430
+ group: "page",
431
+ usage: ["view [--ref eN] [--inline|--file] [--offset LINE] [--budget CHARS]"],
432
+ summary: "Read the page: inline when small, else an outline and a file whose lines carry their refs.",
433
+ tool: "view",
434
+ options: { ref: { type: "string" }, inline: { type: "boolean" }, file: { type: "boolean" }, offset: { type: "string" }, budget: { type: "string" } },
435
+ example: "browser-attach view --ref e40",
436
+ async run(ctx, _a, o) {
437
+ const r = await ctx.call("view", tabbed(ctx, { ref: str(o, "ref"), offset: int(o, "offset"), budget: int(o, "budget") }), outMode(o));
438
+ const v = view(r);
439
+ return { lines: v.lines, files: v.file ? [v.file] : [], result: r };
440
+ }
441
+ },
442
+ {
443
+ name: "find",
444
+ group: "page",
445
+ usage: ["find <text> [--ref eN] [--max N] [-C N]", "find --regex <pattern> [...]"],
446
+ summary: "Search the page view; matching lines with refs, context and enclosing sections.",
447
+ tool: "find",
448
+ options: { regex: { type: "string" }, ref: { type: "string" }, max: { type: "string" }, context: { type: "string", short: "C" } },
449
+ example: 'browser-attach find "Add to cart"',
450
+ async run(ctx, args, o) {
451
+ const regex = str(o, "regex"), text = regex === void 0 ? arg(args, 0, "text") : void 0;
452
+ const r = await ctx.call("find", tabbed(ctx, { text, regex, ref: str(o, "ref"), maxResults: int(o, "max"), context: int(o, "context") }));
453
+ const f = find(regex ?? text, r);
454
+ return { lines: f.lines, files: f.file ? [f.file] : [], result: r, exit: r.matches ? 0 : 1 };
455
+ }
456
+ },
457
+ {
458
+ name: "shot",
459
+ group: "page",
460
+ usage: ["shot [--ref eN] [--full] [--annotate] [--out PATH] [--width N] [--quality Q] [--png]"],
461
+ summary: "Screenshot to a file (Read it to look): the viewport, one element (--ref) or the whole page (--full); --annotate boxes every visible control with its ref.",
462
+ tool: "screenshot",
463
+ options: { ref: { type: "string" }, full: { type: "boolean" }, annotate: { type: "boolean" }, out: { type: "string" }, width: { type: "string" }, quality: { type: "string" }, png: { type: "boolean" } },
464
+ example: "browser-attach shot --annotate",
465
+ async run(ctx, _a, o) {
466
+ const quality = int(o, "quality");
467
+ const r = await ctx.call("screenshot", tabbed(ctx, {
468
+ ref: str(o, "ref"),
469
+ full: o.full ? true : void 0,
470
+ annotate: o.annotate ? true : void 0,
471
+ maxWidth: int(o, "width"),
472
+ // --quality takes 0.1-1 or a percentage.
473
+ quality: quality === void 0 ? void 0 : quality > 1 ? quality / 100 : quality,
474
+ format: o.png ? "png" : void 0
475
+ }));
476
+ const image = r.image;
477
+ if (!image?.file)
478
+ throw new CliError("SCREENSHOT_FAILED", "no screenshot file was written");
479
+ let path = image.file;
480
+ const out = str(o, "out");
481
+ if (out) {
482
+ path = resolve(out);
483
+ mkdirSync3(dirname3(path), { recursive: true });
484
+ copyFileSync(image.file, path);
485
+ }
486
+ const s2 = shot(r);
487
+ return { lines: [s2.lines[0], fileLine(path), ...s2.lines.slice(1)], files: [path], result: r };
488
+ }
489
+ },
490
+ {
491
+ name: "inspect",
492
+ group: "page",
493
+ usage: ["inspect <ref> [field...]"],
494
+ summary: "Read fields of an element (default: text value role name).",
495
+ tool: "inspect",
496
+ async run(ctx, args) {
497
+ const ref = arg(args, 0, "ref"), fields = args.length > 1 ? args.slice(1) : ["text", "value", "role", "name"];
498
+ const r = await ctx.call("inspect", tabbed(ctx, { ref, fields }));
499
+ const values = r.values ?? {};
500
+ return { lines: [`inspect ${ref}`, ...Object.entries(values).map(([k, v]) => `${k}: ${typeof v === "string" ? JSON.stringify(v.length > 500 ? v.slice(0, 499) + "\u2026" : v) : JSON.stringify(v)}`)], result: r };
501
+ }
502
+ },
503
+ {
504
+ name: "eval",
505
+ group: "page",
506
+ usage: ["eval <expression> [--await]", "eval --file script.js [--await]"],
507
+ summary: "Evaluate JavaScript in the page; big results go to a file.",
508
+ tool: "eval_js",
509
+ options: { await: { type: "boolean" }, file: { type: "string" }, inline: { type: "boolean" } },
510
+ async run(ctx, args, o) {
511
+ const file = str(o, "file");
512
+ const expression = file ? readFileSync3(resolve(file), "utf8") : arg(args, 0, "expression");
513
+ const r = await ctx.call("eval_js", tabbed(ctx, { expression, awaitPromise: o.await ? true : void 0 }), o.inline ? "inline" : void 0);
514
+ const e = evalResult(r);
515
+ return { lines: e.lines, files: e.file ? [e.file] : [], result: r };
516
+ }
517
+ },
518
+ {
519
+ name: "net",
520
+ group: "page",
521
+ usage: ["net [--since MS] [--limit N]"],
522
+ summary: "Recent fetch/XHR entries of the tab, written to a JSONL file.",
523
+ tool: "network_log",
524
+ options: { since: { type: "string" }, limit: { type: "string" } },
525
+ async run(ctx, _a, o) {
526
+ const r = await ctx.call("network_log", tabbed(ctx, { sinceMs: int(o, "since"), limit: int(o, "limit") }));
527
+ const last = (r.last ?? []).map((e) => ` ${e.method ?? ""} ${e.status ?? ""} ${e.url ?? ""}`.trimEnd());
528
+ return { lines: [`net: ${r.count} entries${last.length ? ", last:" : ""}`, ...last, ...r.file ? [fileLine(r.file)] : []], files: r.file ? [r.file] : [], result: r };
529
+ }
530
+ },
531
+ // ── act ──
532
+ {
533
+ name: "click",
534
+ group: "act",
535
+ usage: ["click <ref> [--right|--middle] [--double]", "click --xy X,Y"],
536
+ summary: "Click an element by ref (or a viewport point).",
537
+ tool: "click",
538
+ options: { xy: { type: "string" }, right: { type: "boolean" }, middle: { type: "boolean" }, double: { type: "boolean" } },
539
+ example: "browser-attach click e12",
540
+ async run(ctx, args, o) {
541
+ const t = target(args, o, "click");
542
+ const r = await ctx.call("click", tabbed(ctx, { ...t, button: o.right ? "right" : o.middle ? "middle" : void 0, double: o.double ? true : void 0 }));
543
+ return { lines: change(`click ${t.ref ?? `${t.x},${t.y}`}`, r), result: r };
544
+ }
545
+ },
546
+ {
547
+ name: "type",
548
+ group: "act",
549
+ usage: ["type <ref> <text> [--clear] [--submit]", "type <ref> --text-file PATH"],
550
+ summary: "Type into a field.",
551
+ tool: "type",
552
+ options: { clear: { type: "boolean" }, submit: { type: "boolean" }, "text-file": { type: "string" } },
553
+ example: 'browser-attach type e7 "hello world" --submit',
554
+ async run(ctx, args, o) {
555
+ const ref = arg(args, 0, "ref"), file = str(o, "text-file");
556
+ const text = file ? readFileSync3(resolve(file), "utf8") : arg(args, 1, "text");
557
+ const r = await ctx.call("type", tabbed(ctx, { ref, text, clear: o.clear ? true : void 0, submit: o.submit ? true : void 0 }));
558
+ return { lines: change(`type ${ref}`, r), result: r };
559
+ }
560
+ },
561
+ {
562
+ name: "press",
563
+ group: "act",
564
+ usage: ["press <key> [--mod ctrl,shift]"],
565
+ summary: "Press a key (Enter, Tab, ArrowDown, Escape, ...).",
566
+ tool: "press_key",
567
+ options: { mod: { type: "string" } },
568
+ async run(ctx, args, o) {
569
+ const key = arg(args, 0, "key"), modifiers = str(o, "mod")?.split(",").map((m) => m.trim()).filter(Boolean);
570
+ const r = await ctx.call("press_key", tabbed(ctx, { key, modifiers }));
571
+ return { lines: change(`press ${modifiers?.length ? modifiers.join("+") + "+" : ""}${key}`, r), result: r };
572
+ }
573
+ },
574
+ {
575
+ name: "select",
576
+ group: "act",
577
+ usage: ["select <ref> <value>..."],
578
+ summary: "Pick option(s) of a <select> (values or labels).",
579
+ tool: "select",
580
+ options: { xy: { type: "string" } },
581
+ async run(ctx, args, o) {
582
+ const point = xy(o), t = point ?? { ref: arg(args, 0, "ref") }, values = point ? args : args.slice(1);
583
+ if (!values.length)
584
+ throw usage("missing <value>");
585
+ const r = await ctx.call("select", tabbed(ctx, { ...t, values }));
586
+ return { lines: change(`select ${t.ref ?? "point"}`, r), result: r };
587
+ }
588
+ },
589
+ {
590
+ name: "hover",
591
+ group: "act",
592
+ usage: ["hover <ref>", "hover --xy X,Y"],
593
+ summary: "Hover an element (menus, tooltips).",
594
+ tool: "hover",
595
+ options: { xy: { type: "string" } },
596
+ async run(ctx, args, o) {
597
+ const t = target(args, o, "hover");
598
+ const r = await ctx.call("hover", tabbed(ctx, t));
599
+ return { lines: change(`hover ${t.ref ?? `${t.x},${t.y}`}`, r), result: r };
600
+ }
601
+ },
602
+ {
603
+ name: "upload",
604
+ group: "act",
605
+ usage: ["upload <ref> <path>..."],
606
+ summary: "Upload local files to an <input type=file>.",
607
+ tool: "upload_file",
608
+ options: { xy: { type: "string" } },
609
+ async run(ctx, args, o) {
610
+ const point = xy(o), t = point ?? { ref: arg(args, 0, "ref") }, paths = point ? args : args.slice(1);
611
+ if (!paths.length)
612
+ throw usage("missing <path>");
613
+ const files = paths.map((p) => {
614
+ const path = resolve(p);
615
+ let data;
616
+ try {
617
+ data = readFileSync3(path);
618
+ } catch (e) {
619
+ throw new CliError("FILE_NOT_FOUND", `cannot read ${path}: ${e.message}`);
620
+ }
621
+ if (data.length > UPLOAD_MAX)
622
+ throw new CliError("FILE_TOO_LARGE", `${path} is over ${UPLOAD_MAX / 1024 / 1024} MB`);
623
+ return { name: basename(path), data: data.toString("base64"), mimeType: MIME[extname(path).toLowerCase()] };
624
+ });
625
+ const r = await ctx.call("upload_file", tabbed(ctx, { ...t, files }));
626
+ return { lines: [`upload ok \xB7 ${r.count ?? files.length} file(s): ${(r.names ?? files.map((f) => f.name)).join(", ")}`], result: r };
627
+ }
628
+ },
629
+ {
630
+ name: "wait",
631
+ group: "act",
632
+ usage: ["wait [--text T] [--ref eN] [--url PATTERN] [--request PATTERN] [--ms N]"],
633
+ summary: "Wait for text, a ref, a URL or a request (plain sleep without a condition).",
634
+ tool: "wait_for",
635
+ options: { text: { type: "string" }, ref: { type: "string" }, url: { type: "string" }, request: { type: "string" }, ms: { type: "string" } },
636
+ async run(ctx, _a, o) {
637
+ const r = await ctx.call("wait_for", tabbed(ctx, { text: str(o, "text"), refExists: str(o, "ref"), urlPattern: str(o, "url"), requestPattern: str(o, "request"), timeoutMs: int(o, "ms") }));
638
+ return { lines: [`wait ${r.matched ?? "ok"}${r.elapsedMs !== void 0 ? ` after ${r.elapsedMs} ms` : ""}`], result: r };
639
+ }
640
+ },
641
+ {
642
+ name: "scroll",
643
+ group: "act",
644
+ usage: ["scroll [--dy N] [--dx N] [--y N] [--to eN] [--end]"],
645
+ summary: "Scroll the page, an element into view, or to the end (lazy loading).",
646
+ tool: "scroll",
647
+ options: { dy: { type: "string" }, dx: { type: "string" }, y: { type: "string" }, to: { type: "string" }, end: { type: "boolean" } },
648
+ async run(ctx, _a, o) {
649
+ const input = { dy: int(o, "dy") ?? (o.end || str(o, "to") || str(o, "y") || str(o, "dx") ? void 0 : 600), dx: int(o, "dx"), y: int(o, "y"), ref: str(o, "to"), until: o.end ? "page_end" : void 0 };
650
+ const r = await ctx.call("scroll", tabbed(ctx, input));
651
+ return { lines: [`scroll ${r.scrolled === false ? "did not move" : "ok"} \xB7 at ${r.scrollX ?? 0},${r.scrollY ?? 0}${r.steps !== void 0 ? ` \xB7 ${r.steps} steps` : ""}${r.stop_reason ? ` \xB7 ${r.stop_reason}` : ""}`], result: r };
652
+ }
653
+ },
654
+ // ── tabs ──
655
+ {
656
+ name: "tabs",
657
+ group: "tabs",
658
+ usage: ["tabs", "tabs new [url]", "tabs switch <id>", "tabs close [id]"],
659
+ summary: "List, open, switch or close this session's tabs.",
660
+ tool: "tabs",
661
+ async run(ctx, args) {
662
+ const sub = args[0] ?? "list";
663
+ if (sub === "list") {
664
+ const r = await ctx.call("tabs", { action: "list" });
665
+ return { lines: tabs(r), result: r };
666
+ }
667
+ if (sub === "new") {
668
+ const r = await ctx.call("tabs", { action: "new" });
669
+ if (!args[1])
670
+ return { lines: [`tab ${r.tabId} opened (current)`], result: r };
671
+ const nav = await ctx.call("navigate", { url: args[1], tabId: r.tabId });
672
+ return { lines: change(`tab ${r.tabId} opened`, nav), result: nav };
673
+ }
674
+ const id = sub === "close" && args[1] === void 0 ? void 0 : Number(arg(args, 1, "id"));
675
+ if (id !== void 0 && !Number.isInteger(id))
676
+ throw usage(`tab ids are numbers, got ${JSON.stringify(args[1])}`);
677
+ if (sub === "switch") {
678
+ const r = await ctx.call("tabs", { action: "switch", tabId: id });
679
+ return { lines: [`tab ${id} is current`], result: r };
680
+ }
681
+ if (sub === "close") {
682
+ const tabId = id ?? (await ctx.call("tabs", { action: "list" })).active;
683
+ if (tabId === void 0)
684
+ throw new CliError("NO_SESSION_TAB", "no current tab to close");
685
+ const r = await ctx.call("tabs", { action: "close", tabId });
686
+ return { lines: [`tab ${tabId} closed`], result: r };
687
+ }
688
+ throw usage(`unknown tabs action ${JSON.stringify(sub)} (list, new, switch, close)`);
689
+ }
690
+ },
691
+ // ── admin ──
692
+ {
693
+ name: "status",
694
+ group: "admin",
695
+ usage: ["status"],
696
+ summary: "Daemon, extension and this session.",
697
+ async run(ctx) {
698
+ let st = await ctx.request("status");
699
+ for (let i = 0; i < 6 && !st.extension.connected; i++) {
700
+ await new Promise((r) => setTimeout(r, 500));
701
+ st = await ctx.request("status");
702
+ }
703
+ const ext = st.extension, sessions = st.sessions, me = sessions.find((s2) => s2.name === ctx.session);
704
+ const lines2 = [
705
+ `daemon pid ${st.pid} \xB7 build ${st.build} \xB7 port ${st.port} \xB7 up ${Math.round(st.uptimeMs / 1e3)} s \xB7 ${sessions.length} session(s), ${st.clients} client(s), ${st.peers} MCP peer(s)`,
706
+ ext.connected ? `extension connected \xB7 v${ext.version ?? "?"} \xB7 agent protocol ${ext.agentProtocol ?? "none"}${ext.agentProtocol !== ext.expectedAgentProtocol ? ` (expected ${ext.expectedAgentProtocol}: reload the extension)` : ""}` : `extension not connected: open chrome://extensions, enable Developer mode, Load unpacked: ${extensionHint()}`,
707
+ `session ${ctx.session}: ${me ? `${me.tabs} tab(s), idle ${Math.round(me.idleMs / 1e3)} s${me.uncertain ? ", uncertain (view first)" : ""}` : "not open yet"}`,
708
+ `files ${st.outDir} \xB7 log ${st.log}`
709
+ ];
710
+ return { lines: lines2, result: st };
711
+ }
712
+ },
713
+ {
714
+ name: "sessions",
715
+ group: "admin",
716
+ usage: ["sessions"],
717
+ summary: "Sessions of the daemon with their tabs and idle time.",
718
+ async run(ctx) {
719
+ const list = await ctx.existing("sessions");
720
+ if (!list)
721
+ return { lines: ["daemon not running"], result: [] };
722
+ if (!list.length)
723
+ return { lines: ["no sessions"], result: list };
724
+ return { lines: list.map((s2) => `${s2.name === ctx.session ? "*" : " "} ${s2.name} \xB7 ${s2.kind} \xB7 ${s2.tabs} tab(s) \xB7 idle ${Math.round(s2.idleMs / 1e3)} s${s2.busy ? " \xB7 busy" : ""}${s2.queued ? ` \xB7 ${s2.queued} queued` : ""}`), result: list };
725
+ }
726
+ },
727
+ {
728
+ name: "close",
729
+ group: "admin",
730
+ usage: ["close [--all]"],
731
+ summary: "End this session (or all): close its tabs, delete its files.",
732
+ options: { all: { type: "boolean" } },
733
+ async run(ctx, _a, o) {
734
+ const r = await ctx.existing("session.close", o.all ? { all: true } : { name: ctx.session });
735
+ if (!r)
736
+ return { lines: ["daemon not running: nothing to close"], result: { closed: [] } };
737
+ return { lines: [r.closed.length ? `closed ${r.closed.join(", ")}` : `no session ${ctx.session}`], result: r };
738
+ }
739
+ },
740
+ {
741
+ name: "daemon",
742
+ group: "admin",
743
+ usage: ["daemon start|stop|restart|status", "daemon stop [--force] [--close-tabs]", "daemon logs [-n LINES]"],
744
+ summary: "Manage the background daemon (normally started automatically).",
745
+ options: { force: { type: "boolean" }, "close-tabs": { type: "boolean" }, lines: { type: "string", short: "n" } },
746
+ async run(ctx, args, o) {
747
+ const sub = args[0] ?? "status";
748
+ if (sub === "start") {
749
+ const h = await ctx.startDaemon();
750
+ return { lines: [`daemon running \xB7 pid ${h.pid} \xB7 build ${h.build} \xB7 port ${h.port}`], result: h };
751
+ }
752
+ if (sub === "stop" || sub === "restart") {
753
+ const stopped = await ctx.stopDaemon({ force: !!o.force, closeTabs: !!o["close-tabs"] });
754
+ if (sub === "stop")
755
+ return { lines: [stopped ? "daemon stopped" : "daemon not running"], result: { stopped } };
756
+ const h = await ctx.startDaemon();
757
+ return { lines: [`daemon restarted \xB7 pid ${h.pid} \xB7 build ${h.build}`], result: h };
758
+ }
759
+ if (sub === "status")
760
+ return COMMANDS.find((c) => c.name === "status").run(ctx, [], o);
761
+ if (sub === "logs") {
762
+ let text = "";
763
+ try {
764
+ text = readFileSync3(logPath(ctx.port), "utf8");
765
+ } catch {
766
+ return { lines: [`no log yet (${logPath(ctx.port)})`] };
767
+ }
768
+ const n2 = Math.max(1, Number(str(o, "lines") ?? 50) || 50);
769
+ return { lines: [...text.trimEnd().split("\n").slice(-n2), fileLine(logPath(ctx.port))], files: [logPath(ctx.port)] };
770
+ }
771
+ throw usage(`unknown daemon action ${JSON.stringify(sub)}`);
772
+ }
773
+ },
774
+ {
775
+ name: "config",
776
+ group: "admin",
777
+ usage: ["config show", "config set KEY=VALUE...", "config unset KEY..."],
778
+ summary: "Daemon configuration settings (idleExitMs, sessionTtlMs, outMaxMB).",
779
+ async run(ctx, args) {
780
+ const sub = args[0] ?? "show";
781
+ migrateLegacyConfig();
782
+ if (sub === "show")
783
+ return { lines: [`${configPath()}:`, JSON.stringify(readConfig(), null, 2)], result: readConfig() };
784
+ if (sub !== "set" && sub !== "unset")
785
+ throw usage(`unknown config action ${JSON.stringify(sub)}`);
786
+ if (args.length < 2)
787
+ throw usage(`config ${sub} needs at least one ${sub === "set" ? "KEY=VALUE" : "KEY"}`);
788
+ let c = readConfig();
789
+ try {
790
+ for (const a of args.slice(1))
791
+ c = setConfig(c, sub === "set" ? a : `${a}=`);
792
+ } catch (e) {
793
+ throw usage(e.message);
794
+ }
795
+ if (sub === "unset")
796
+ for (const a of args.slice(1))
797
+ delete c[a];
798
+ writeConfig(c);
799
+ const reloaded = await ctx.existing("config.reload").catch(() => null);
800
+ const keys = args.slice(1).map((a) => a.split("=")[0]);
801
+ return { lines: [`${sub === "set" ? "set" : "unset"} ${keys.join(", ")} in ${configPath()}${reloaded ? " (daemon reloaded)" : ""}`], result: { keys } };
802
+ }
803
+ },
804
+ {
805
+ name: "extension",
806
+ group: "admin",
807
+ usage: ["extension path"],
808
+ summary: "Folder to load in chrome://extensions (Load unpacked).",
809
+ async run(_ctx, args) {
810
+ const sub = args[0] ?? "path";
811
+ if (sub !== "path")
812
+ throw usage(`unknown extension action ${JSON.stringify(sub)}`);
813
+ const dir = extensionDir();
814
+ if (!existsSync3(join4(dir, "manifest.json")))
815
+ return { lines: [`${dir} (not installed yet: run browser-attach setup)`], files: [dir], result: { path: dir, installed: false }, exit: 1 };
816
+ return { lines: [dir], files: [dir], result: { path: dir, installed: true, version: extensionVersion(dir) } };
817
+ }
818
+ },
819
+ {
820
+ name: "setup",
821
+ group: "admin",
822
+ usage: ["setup [--yes] [--no-permissions] [--claude-dir DIR]"],
823
+ summary: "One-time, repeatable setup: skill, extension folder, Claude Code permissions.",
824
+ options: { yes: { type: "boolean", short: "y" }, "no-permissions": { type: "boolean" }, "claude-dir": { type: "string" } },
825
+ async run(_ctx, _a, o) {
826
+ const user = process.env.HOME || process.env.USERPROFILE || homedir4();
827
+ const claudeDir = str(o, "claude-dir") ? resolve(str(o, "claude-dir")) : join4(user, ".claude");
828
+ const lines2 = [];
829
+ let failed = false;
830
+ const step = (label, fn) => {
831
+ try {
832
+ lines2.push(`ok ${label}: ${fn()}`);
833
+ } catch (e) {
834
+ failed = true;
835
+ lines2.push(`FAIL ${label}: ${e.message}`);
836
+ }
837
+ };
838
+ step("skill", () => {
839
+ const dirs = [join4(claudeDir, "skills", "browser-attach"), join4(user, ".agents", "skills", "browser-attach")];
840
+ for (const d of dirs)
841
+ writeSkill(d);
842
+ return dirs.map((d) => join4(d, "SKILL.md")).join(", ");
843
+ });
844
+ step("extension", () => {
845
+ const src = packagedExtensionDir(), dest = extensionDir();
846
+ if (!src)
847
+ throw new Error("this install has no packaged extension (source checkout without a build: npm run build)");
848
+ copyExtension(src, dest);
849
+ return `v${extensionVersion(dest) ?? "?"} copied to ${dest}`;
850
+ });
851
+ if (o["no-permissions"])
852
+ lines2.push("skip permissions: --no-permissions");
853
+ else {
854
+ const path = join4(claudeDir, "settings.json"), rules = permissionRules();
855
+ if (!o.yes && process.stdin.isTTY && !await confirm(`Add ${rules.length} allow rules to ${path}?`))
856
+ lines2.push("skip permissions: declined");
857
+ else
858
+ step("permissions", () => {
859
+ const r = mergePermissions(path, rules);
860
+ if (r.status === "invalid") {
861
+ failed = true;
862
+ return `${path} not changed (${r.detail}); add by hand to permissions.allow: ${rules.join(", ")}`;
863
+ }
864
+ return r.status === "unchanged" ? `already in ${path}` : `${r.status} ${path} (+${r.added.length})`;
865
+ });
866
+ }
867
+ const dir = extensionDir();
868
+ lines2.push("", "Next:", " 1. Open chrome://extensions and turn on Developer mode (top right).", ` 2. Load unpacked, pick this folder: ${dir}`, " (after upgrading browser-attach, run setup again, then press Reload on the extension card)", ' 3. Optional: on the extension card, turn on "Allow User Scripts" (eval_js on strict-CSP pages).', " 4. Run: browser-attach status");
869
+ return { lines: lines2, files: [dir], result: { extension: dir, failed }, exit: failed ? 1 : 0 };
870
+ }
871
+ },
872
+ {
873
+ name: "skill",
874
+ group: "admin",
875
+ usage: ["skill install [--dir DIR]", "skill path", "skill show"],
876
+ summary: "Install the Claude Code skill (~/.claude/skills/browser-attach/SKILL.md).",
877
+ options: { dir: { type: "string" } },
878
+ async run(_ctx, args, o) {
879
+ const sub = args[0] ?? "path";
880
+ const dir = str(o, "dir") ? resolve(str(o, "dir")) : join4(process.env.HOME || homedir4(), ".claude", "skills", "browser-attach");
881
+ const path = join4(dir, "SKILL.md");
882
+ if (sub === "show")
883
+ return { lines: [SKILL.trimEnd()] };
884
+ if (sub === "path")
885
+ return { lines: [existsSync3(path) ? path : `${path} (not installed: browser-attach skill install)`], files: [path] };
886
+ if (sub === "install") {
887
+ writeSkill(dir);
888
+ return { lines: [`skill installed`, fileLine(path), 'Allow the skill, the CLI and its files once in ~/.claude/settings.json: permissions.allow += "Skill(browser-attach)", "Bash(browser-attach:*)", "Read(~/.cache/browser-attach/**)"'], files: [path] };
889
+ }
890
+ throw usage(`unknown skill action ${JSON.stringify(sub)}`);
891
+ }
892
+ }
893
+ ];
894
+ function writeSkill(dir) {
895
+ mkdirSync3(dir, { recursive: true });
896
+ writeFileSync3(join4(dir, "SKILL.md"), SKILL);
897
+ }
898
+ function extensionHint() {
899
+ const dir = extensionDir();
900
+ return existsSync3(join4(dir, "manifest.json")) ? dir : `${dir} (not installed yet: run browser-attach setup)`;
901
+ }
902
+ async function confirm(question) {
903
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
904
+ try {
905
+ return !/^n/i.test((await rl.question(`${question} [Y/n] `)).trim());
906
+ } finally {
907
+ rl.close();
908
+ }
909
+ }
910
+ async function history(ctx, o, expression, verb) {
911
+ await ctx.call("eval_js", tabbed(ctx, { expression: `setTimeout(() => ${expression}, 0), true` }));
912
+ await ctx.call("wait_for", tabbed(ctx, { timeoutMs: 1200 }));
913
+ if (o["no-view"])
914
+ return { lines: [`${verb} ok`] };
915
+ const v = await viewAfter(ctx, o);
916
+ return { ...v, lines: [`${verb} ok`, ...v.lines] };
917
+ }
918
+ var commandNamed = (name) => COMMANDS.find((c) => c.name === name);
919
+ var toolDescription = (c) => c.tool ? Descriptions[c.tool] : void 0;
920
+
921
+ // packages/bridge/dist/daemon/client.js
922
+ import { spawn } from "node:child_process";
923
+ import { existsSync as existsSync4, mkdirSync as mkdirSync4, openSync, closeSync } from "node:fs";
924
+ import { dirname as dirname5, join as join6 } from "node:path";
925
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
926
+
927
+ // packages/bridge/dist/daemon/rpc.js
928
+ import { connect } from "node:net";
929
+ var RpcFailure = class extends Error {
930
+ code;
931
+ short_term;
932
+ constructor(code, message, short_term = true) {
933
+ super(message);
934
+ this.code = code;
935
+ this.short_term = short_term;
936
+ }
937
+ };
938
+ function lines(socket, onLine) {
939
+ let buffer = "";
940
+ socket.setEncoding("utf8");
941
+ socket.on("data", (chunk) => {
942
+ buffer += chunk;
943
+ for (let i = buffer.indexOf("\n"); i >= 0; i = buffer.indexOf("\n")) {
944
+ const line = buffer.slice(0, i);
945
+ buffer = buffer.slice(i + 1);
946
+ if (!line.trim())
947
+ continue;
948
+ let value;
949
+ try {
950
+ value = JSON.parse(line);
951
+ } catch {
952
+ continue;
953
+ }
954
+ onLine(value);
955
+ }
956
+ });
957
+ }
958
+ var RpcClient = class _RpcClient {
959
+ socket;
960
+ next = 1;
961
+ pending = /* @__PURE__ */ new Map();
962
+ closed = false;
963
+ constructor(socket) {
964
+ this.socket = socket;
965
+ lines(socket, (value) => {
966
+ const r = value;
967
+ const p = this.pending.get(r.id);
968
+ if (!p)
969
+ return;
970
+ this.pending.delete(r.id);
971
+ if (r.ok)
972
+ p.resolve(r.result);
973
+ else
974
+ p.reject(new RpcFailure(r.error.code, r.error.message, r.error.short_term));
975
+ });
976
+ socket.on("close", () => {
977
+ this.closed = true;
978
+ for (const [id, p] of this.pending) {
979
+ this.pending.delete(id);
980
+ p.reject(new RpcFailure("DAEMON_EXITED", "The daemon exited during the call; the action may or may not have run. Check the page with view."));
981
+ }
982
+ });
983
+ socket.on("error", () => {
984
+ });
985
+ }
986
+ /** Connects to `path`; rejects with the socket error (ENOENT, ECONNREFUSED). */
987
+ static connect(path, timeoutMs = 2e3) {
988
+ return new Promise((resolve2, reject) => {
989
+ const socket = connect(path);
990
+ const timer = setTimeout(() => {
991
+ socket.destroy();
992
+ reject(Object.assign(new Error("connect timeout"), { code: "ETIMEDOUT" }));
993
+ }, timeoutMs);
994
+ socket.once("connect", () => {
995
+ clearTimeout(timer);
996
+ resolve2(new _RpcClient(socket));
997
+ });
998
+ socket.once("error", (e) => {
999
+ clearTimeout(timer);
1000
+ reject(e);
1001
+ });
1002
+ });
1003
+ }
1004
+ get isClosed() {
1005
+ return this.closed;
1006
+ }
1007
+ request(method, params = {}) {
1008
+ if (this.closed)
1009
+ return Promise.reject(new RpcFailure("DAEMON_EXITED", "The daemon connection is closed."));
1010
+ const id = this.next++;
1011
+ return new Promise((resolve2, reject) => {
1012
+ this.pending.set(id, { resolve: resolve2, reject });
1013
+ this.socket.write(JSON.stringify({ id, method, params }) + "\n");
1014
+ });
1015
+ }
1016
+ close() {
1017
+ this.socket.end();
1018
+ this.socket.destroySoon?.();
1019
+ }
1020
+ };
1021
+
1022
+ // packages/bridge/dist/daemon/version.js
1023
+ import { readFileSync as readFileSync4 } from "node:fs";
1024
+ import { dirname as dirname4, join as join5 } from "node:path";
1025
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
1026
+ var DAEMON_RPC = 1;
1027
+ var cached;
1028
+ function buildInfo() {
1029
+ if (cached)
1030
+ return cached;
1031
+ if (process.env.BROWSER_ATTACH_BUILD_ID)
1032
+ return cached = { build: process.env.BROWSER_ATTACH_BUILD_ID, version: "0.0.0" };
1033
+ const here = dirname4(fileURLToPath2(import.meta.url));
1034
+ for (const path of [join5(here, "build.json"), join5(here, "..", "build.json")]) {
1035
+ try {
1036
+ const b = JSON.parse(readFileSync4(path, "utf8"));
1037
+ if (typeof b.build === "string")
1038
+ return cached = { build: b.build, version: typeof b.version === "string" ? b.version : "0.0.0" };
1039
+ } catch {
1040
+ }
1041
+ }
1042
+ return cached = { build: "dev", version: "0.0.0" };
1043
+ }
1044
+
1045
+ // packages/bridge/dist/daemon/port.js
1046
+ import { execFileSync } from "node:child_process";
1047
+ import { readFileSync as readFileSync5, readdirSync, readlinkSync } from "node:fs";
1048
+ function windowsListeningPid(port) {
1049
+ let text = "";
1050
+ try {
1051
+ text = execFileSync("netstat", ["-ano", "-p", "TCP"], { encoding: "utf8", timeout: 5e3, windowsHide: true });
1052
+ } catch {
1053
+ return null;
1054
+ }
1055
+ for (const row of text.split("\n")) {
1056
+ const f = row.trim().split(/\s+/);
1057
+ if (f[0] === "TCP" && f[1]?.endsWith(`:${port}`) && f[3] === "LISTENING" && /^\d+$/.test(f[4] ?? ""))
1058
+ return Number(f[4]);
1059
+ }
1060
+ return null;
1061
+ }
1062
+ function listeningPid(port) {
1063
+ if (process.platform === "win32")
1064
+ return windowsListeningPid(port);
1065
+ const hex = port.toString(16).toUpperCase().padStart(4, "0");
1066
+ const inodes = /* @__PURE__ */ new Set();
1067
+ for (const table of ["/proc/net/tcp", "/proc/net/tcp6"]) {
1068
+ let text = "";
1069
+ try {
1070
+ text = readFileSync5(table, "utf8");
1071
+ } catch {
1072
+ continue;
1073
+ }
1074
+ for (const row of text.split("\n").slice(1)) {
1075
+ const f = row.trim().split(/\s+/);
1076
+ if (f[1]?.endsWith(`:${hex}`) && f[3] === "0A" && f[9])
1077
+ inodes.add(f[9]);
1078
+ }
1079
+ }
1080
+ if (!inodes.size)
1081
+ return null;
1082
+ let pids = [];
1083
+ try {
1084
+ pids = readdirSync("/proc").filter((p) => /^\d+$/.test(p));
1085
+ } catch {
1086
+ return null;
1087
+ }
1088
+ for (const pid of pids) {
1089
+ let fds = [];
1090
+ try {
1091
+ fds = readdirSync(`/proc/${pid}/fd`);
1092
+ } catch {
1093
+ continue;
1094
+ }
1095
+ for (const fd of fds) {
1096
+ try {
1097
+ const m = /^socket:\[(\d+)\]$/.exec(readlinkSync(`/proc/${pid}/fd/${fd}`));
1098
+ if (m && inodes.has(m[1]))
1099
+ return Number(pid);
1100
+ } catch {
1101
+ }
1102
+ }
1103
+ }
1104
+ return null;
1105
+ }
1106
+ function commandOf(pid) {
1107
+ if (process.platform === "win32") {
1108
+ try {
1109
+ return /^"([^"]+)"/.exec(execFileSync("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], { encoding: "utf8", timeout: 5e3, windowsHide: true }).trim())?.[1] ?? "";
1110
+ } catch {
1111
+ return "";
1112
+ }
1113
+ }
1114
+ try {
1115
+ return readFileSync5(`/proc/${pid}/cmdline`, "utf8").split("\0").filter(Boolean).join(" ").slice(0, 200);
1116
+ } catch {
1117
+ return "";
1118
+ }
1119
+ }
1120
+
1121
+ // packages/bridge/dist/daemon/client.js
1122
+ var EXIT_PORT_IN_USE = 10;
1123
+ var delay = (ms) => new Promise((r) => setTimeout(r, ms));
1124
+ function daemonEntry() {
1125
+ if (process.env.BROWSER_ATTACH_DAEMON_ENTRY)
1126
+ return process.env.BROWSER_ATTACH_DAEMON_ENTRY;
1127
+ const here = dirname5(fileURLToPath3(import.meta.url));
1128
+ for (const path of [join6(here, "bridge.mjs"), join6(here, "..", "index.js")])
1129
+ if (existsSync4(path))
1130
+ return path;
1131
+ throw new RpcFailure("NO_DAEMON_ENTRY", `Cannot find the daemon script next to ${here}; set BROWSER_ATTACH_DAEMON_ENTRY.`, false);
1132
+ }
1133
+ async function tryConnect(port) {
1134
+ try {
1135
+ return await RpcClient.connect(socketPath(port));
1136
+ } catch (e) {
1137
+ const code = e.code;
1138
+ if (code === "ENOENT" || code === "ECONNREFUSED" || code === "ETIMEDOUT")
1139
+ return null;
1140
+ throw e;
1141
+ }
1142
+ }
1143
+ async function startDaemon(port, timeoutMs) {
1144
+ mkdirSync4(runtimeDir(), { recursive: true, mode: 448 });
1145
+ mkdirSync4(cacheDir(), { recursive: true, mode: 448 });
1146
+ const out = openSync(logPath(port), "a", 384);
1147
+ let exit = null;
1148
+ try {
1149
+ const child = spawn(process.execPath, [daemonEntry(), "daemon", "--port", String(port)], { detached: true, stdio: ["ignore", out, out], env: process.env });
1150
+ child.on("exit", (code) => {
1151
+ exit = code ?? -1;
1152
+ });
1153
+ child.unref();
1154
+ } finally {
1155
+ closeSync(out);
1156
+ }
1157
+ const deadline = Date.now() + timeoutMs;
1158
+ let lostRace = 0;
1159
+ while (Date.now() < deadline) {
1160
+ await delay(100);
1161
+ const c = await tryConnect(port);
1162
+ if (c)
1163
+ return c;
1164
+ if (exit === EXIT_PORT_IN_USE) {
1165
+ if (++lostRace > 15)
1166
+ break;
1167
+ } else if (exit !== null)
1168
+ throw new RpcFailure("DAEMON_START_FAILED", `The daemon exited with code ${exit} while starting; see ${logPath(port)}.`, false);
1169
+ }
1170
+ if (exit === EXIT_PORT_IN_USE || await tryConnect(port) === null && listeningPid(port))
1171
+ throw portInUse(port);
1172
+ throw new RpcFailure("DAEMON_START_FAILED", `The daemon did not open its socket within ${Math.round(timeoutMs / 1e3)} s; see ${logPath(port)}.`, false);
1173
+ }
1174
+ function portInUse(port) {
1175
+ const pid = listeningPid(port);
1176
+ const who = pid ? `pid ${pid}${commandOf(pid) ? ` (${commandOf(pid)})` : ""}` : "another process";
1177
+ const ours = !!pid && /browser-attach|bridge\.mjs/.test(commandOf(pid));
1178
+ const hint = ours ? "That is another browser-attach daemon (a different install or user); stop it with `browser-attach daemon stop` there, then retry." : "If browser-attach runs on the other side of WSL/Windows, its daemon holds the port: the Chrome extension connects to one daemon only, so use browser-attach there or stop it with `browser-attach daemon stop`. Otherwise stop the program that holds it.";
1179
+ return new RpcFailure("PORT_IN_USE", `Port ${port} is held by ${who}. ${hint}`, false);
1180
+ }
1181
+ async function waitGone(port, timeoutMs = 5e3) {
1182
+ const deadline = Date.now() + timeoutMs;
1183
+ while (Date.now() < deadline) {
1184
+ const c = await tryConnect(port);
1185
+ if (!c)
1186
+ return;
1187
+ c.close();
1188
+ await delay(100);
1189
+ }
1190
+ }
1191
+ async function ensureDaemon(opts) {
1192
+ const { build, version } = buildInfo();
1193
+ const helloParams = { client: opts.client, version, build, rpc: DAEMON_RPC, session: opts.session, persistent: opts.persistent ?? true, cwd: process.cwd(), caller: { pid: process.ppid } };
1194
+ for (let attempt = 0; attempt < 2; attempt++) {
1195
+ const rpc = await tryConnect(opts.port) ?? await startDaemon(opts.port, opts.startTimeoutMs ?? 5e3);
1196
+ let hello;
1197
+ try {
1198
+ hello = await rpc.request("hello", helloParams);
1199
+ } catch (e) {
1200
+ if (!(e instanceof RpcFailure && e.code === "RPC_MISMATCH")) {
1201
+ rpc.close();
1202
+ throw e;
1203
+ }
1204
+ const st = await rpc.request("status").catch(() => ({ busy: true }));
1205
+ if (st.busy) {
1206
+ rpc.close();
1207
+ throw new RpcFailure("DAEMON_OUTDATED", "The running daemon is of an incompatible version and busy. Run `browser-attach daemon restart --force` (running tasks are lost).", false);
1208
+ }
1209
+ await rpc.request("shutdown", { restart: true }).catch(() => {
1210
+ });
1211
+ rpc.close();
1212
+ await waitGone(opts.port);
1213
+ opts.note?.("note: restarted an incompatible daemon");
1214
+ continue;
1215
+ }
1216
+ if (hello.build === build || build === "dev" || attempt > 0)
1217
+ return { rpc, hello };
1218
+ if (hello.busy) {
1219
+ opts.note?.(`note: the daemon is build ${hello.build}, this client ${build}; it restarts when idle (browser-attach daemon restart)`);
1220
+ return { rpc, hello };
1221
+ }
1222
+ await rpc.request("shutdown", { restart: true }).catch(() => {
1223
+ });
1224
+ rpc.close();
1225
+ await waitGone(opts.port);
1226
+ opts.note?.(`note: restarted the daemon (build ${hello.build} -> ${build})`);
1227
+ }
1228
+ throw new RpcFailure("DAEMON_START_FAILED", "Could not reach a daemon.", false);
1229
+ }
1230
+ var connectExisting = tryConnect;
1231
+
1232
+ // packages/bridge/dist/cli/main.js
1233
+ var DEFAULT_PORT = 17329;
1234
+ var GLOBAL = {
1235
+ session: { type: "string", short: "s" },
1236
+ tab: { type: "string", short: "t" },
1237
+ json: { type: "boolean" },
1238
+ quiet: { type: "boolean", short: "q" },
1239
+ port: { type: "string" },
1240
+ help: { type: "boolean", short: "h" }
1241
+ };
1242
+ var TAKES_VALUE = /* @__PURE__ */ new Set(["-s", "--session", "-t", "--tab", "--port"]);
1243
+ function resolveSession(flag, env2 = process.env) {
1244
+ if (flag)
1245
+ return flag;
1246
+ if (env2.BROWSER_ATTACH_SESSION)
1247
+ return env2.BROWSER_ATTACH_SESSION;
1248
+ const cc = env2.CLAUDE_CODE_SESSION_ID?.replace(/[^A-Za-z0-9]/g, "");
1249
+ if (cc)
1250
+ return `cc-${cc.slice(0, 8)}`;
1251
+ return "default";
1252
+ }
1253
+ function commandIndex(argv) {
1254
+ for (let i = 0; i < argv.length; i++) {
1255
+ const a = argv[i];
1256
+ if (a === "--")
1257
+ return i + 1 < argv.length ? i + 1 : -1;
1258
+ if (TAKES_VALUE.has(a)) {
1259
+ i++;
1260
+ continue;
1261
+ }
1262
+ if (!a.startsWith("-") || a === "-")
1263
+ return i;
1264
+ }
1265
+ return -1;
1266
+ }
1267
+ async function main(argv, io = { out: (s2) => {
1268
+ process.stdout.write(s2 + "\n");
1269
+ }, err: (s2) => {
1270
+ process.stderr.write(s2 + "\n");
1271
+ } }) {
1272
+ const at = commandIndex(argv);
1273
+ const name = at >= 0 ? argv[at] : void 0;
1274
+ if (!name || name === "help") {
1275
+ const topic = name === "help" ? argv[at + 1] : void 0;
1276
+ const cmd2 = topic ? commandNamed(topic) : void 0;
1277
+ if (topic && !cmd2) {
1278
+ io.err(`error USAGE: no command ${topic}`);
1279
+ return 2;
1280
+ }
1281
+ io.out(cmd2 ? commandHelp(cmd2) : overview());
1282
+ return name || argv.some((a) => a === "-h" || a === "--help") ? 0 : 2;
1283
+ }
1284
+ const cmd = commandNamed(name);
1285
+ if (!cmd) {
1286
+ io.err(`error USAGE: unknown command ${JSON.stringify(name)}. Commands: ${COMMANDS.map((c) => c.name).join(", ")} (browser-attach help)`);
1287
+ return 2;
1288
+ }
1289
+ let values, positionals;
1290
+ try {
1291
+ const parsed = parseArgs({ args: [...argv.slice(0, at), ...argv.slice(at + 1)], options: { ...GLOBAL, ...cmd.options }, allowPositionals: true, strict: true });
1292
+ values = parsed.values;
1293
+ positionals = parsed.positionals;
1294
+ } catch (e) {
1295
+ io.err(`error USAGE: ${e.message.replace(/\. To specify a positional argument.*$/s, "")}`);
1296
+ io.err(`usage: ${cmd.usage.map((u) => `browser-attach ${u}`).join("\n ")}`);
1297
+ return 2;
1298
+ }
1299
+ if (values.help) {
1300
+ io.out(commandHelp(cmd));
1301
+ return 0;
1302
+ }
1303
+ const portText = values.port ?? process.env.BROWSER_ATTACH_PORT ?? process.env.BROWSER_ATTACH_PORT;
1304
+ const port = portText === void 0 ? DEFAULT_PORT : Number(portText);
1305
+ if (!Number.isInteger(port) || port < 1024 || port > 65535) {
1306
+ io.err(`error USAGE: invalid port ${portText}`);
1307
+ return 2;
1308
+ }
1309
+ const session = resolveSession(values.session);
1310
+ if (!SESSION_NAME.test(session)) {
1311
+ io.err(`error USAGE: invalid session name ${JSON.stringify(session)} (A-Z a-z 0-9 . _ -, up to 64)`);
1312
+ return 2;
1313
+ }
1314
+ let tab;
1315
+ if (values.tab !== void 0) {
1316
+ tab = Number(values.tab);
1317
+ if (!Number.isInteger(tab)) {
1318
+ io.err(`error USAGE: -t takes a tab id, got ${JSON.stringify(values.tab)}`);
1319
+ return 2;
1320
+ }
1321
+ }
1322
+ let rpc = null, hello = null;
1323
+ const note = (line) => {
1324
+ if (!values.json && !values.quiet)
1325
+ io.err(line);
1326
+ };
1327
+ const connect2 = async () => {
1328
+ if (rpc && !rpc.isClosed)
1329
+ return rpc;
1330
+ const r = await ensureDaemon({ port, client: "cli", session, note });
1331
+ rpc = r.rpc;
1332
+ hello = r.hello;
1333
+ if (!hello.extension.connected)
1334
+ note("waiting for the Chrome extension to connect\u2026");
1335
+ return rpc;
1336
+ };
1337
+ const ctx = {
1338
+ port,
1339
+ session,
1340
+ tab,
1341
+ call: async (tool, input, out) => (await connect2()).request("call", { tool, input: clean(input), ...out ? { out } : {} }),
1342
+ request: async (method, params = {}) => (await connect2()).request(method, params),
1343
+ async existing(method, params = {}) {
1344
+ const c = await connectExisting(port);
1345
+ if (!c)
1346
+ return null;
1347
+ try {
1348
+ await c.request("hello", { client: "cli", rpc: DAEMON_RPC }).catch(() => {
1349
+ });
1350
+ return await c.request(method, params);
1351
+ } finally {
1352
+ c.close();
1353
+ }
1354
+ },
1355
+ async stopDaemon(params) {
1356
+ const c = await connectExisting(port);
1357
+ if (!c)
1358
+ return false;
1359
+ try {
1360
+ await c.request("shutdown", params);
1361
+ } finally {
1362
+ c.close();
1363
+ }
1364
+ for (let i = 0; i < 50 && await isRunning(port); i++)
1365
+ await new Promise((r) => setTimeout(r, 100));
1366
+ return true;
1367
+ },
1368
+ async startDaemon() {
1369
+ await connect2();
1370
+ return hello;
1371
+ }
1372
+ };
1373
+ try {
1374
+ const output = await cmd.run(ctx, positionals, values);
1375
+ print(output, values, io);
1376
+ return output.exit ?? 0;
1377
+ } catch (e) {
1378
+ const code = e instanceof CliError || e instanceof RpcFailure ? e.code : typeof e.code === "string" ? e.code : "INTERNAL";
1379
+ const message = e instanceof Error ? e.message : String(e);
1380
+ if (values.json)
1381
+ io.out(JSON.stringify({ ok: false, error: { code, message } }));
1382
+ else {
1383
+ io.err(`error ${code}: ${message}`);
1384
+ if (code === "USAGE")
1385
+ io.err(`usage: ${cmd.usage.map((u) => `browser-attach ${u}`).join("\n ")}`);
1386
+ else if (nextStep(code))
1387
+ io.err(`next: ${nextStep(code)}`);
1388
+ }
1389
+ return exitCode(code);
1390
+ } finally {
1391
+ rpc?.close();
1392
+ }
1393
+ }
1394
+ async function isRunning(port) {
1395
+ const c = await connectExisting(port);
1396
+ c?.close();
1397
+ return !!c;
1398
+ }
1399
+ var clean = (input) => Object.fromEntries(Object.entries(input).filter(([, v]) => v !== void 0));
1400
+ function print(output, values, io) {
1401
+ if (values.json) {
1402
+ io.out(JSON.stringify({ ok: true, summary: output.lines[0] ?? "", ...output.files?.length ? { file: output.files[0], files: output.files } : {}, result: output.result ?? null }));
1403
+ return;
1404
+ }
1405
+ if (values.quiet) {
1406
+ io.out(output.files?.[0] ?? output.lines[0] ?? "");
1407
+ return;
1408
+ }
1409
+ for (const line of output.lines)
1410
+ io.out(line);
1411
+ }
1412
+ var GROUPS = [["page", "Page"], ["act", "Act"], ["tabs", "Tabs"], ["admin", "Admin"]];
1413
+ function overview() {
1414
+ const width = Math.max(...COMMANDS.map((c) => c.name.length));
1415
+ const lines2 = [
1416
+ "browser-attach \u2014 drive the user's Chrome (via the extension) from the shell.",
1417
+ "usage: browser-attach [-s SESSION] [-t TAB] [--json] [--quiet] <command> [args] (browser-attach <command> --help)",
1418
+ ""
1419
+ ];
1420
+ for (const [group, title] of GROUPS) {
1421
+ lines2.push(`${title}:`);
1422
+ for (const c of COMMANDS.filter((x) => x.group === group))
1423
+ lines2.push(` ${c.name.padEnd(width)} ${c.summary}`);
1424
+ }
1425
+ lines2.push("", 'Output: a summary line, a few lines, and file paths after "\u2192 " (read them with the Read/Grep tools; every control line carries its [ref]).', 'Session: -s NAME, else $BROWSER_ATTACH_SESSION, else one per Claude Code session, else "default". Each session sees only its own tabs.', "Exit codes: 0 ok, 1 tool error, 2 usage, 3 browser/daemon unavailable, 5 timeout.");
1426
+ if (process.env.CLAUDECODE) {
1427
+ const skill = join7(process.env.HOME || homedir5(), ".claude", "skills", "browser-attach", "SKILL.md");
1428
+ lines2.push(`Agent skill: ${existsSync5(skill) ? skill : "not installed (browser-attach skill install)"}`);
1429
+ }
1430
+ return lines2.join("\n");
1431
+ }
1432
+ function commandHelp(c) {
1433
+ const lines2 = c.usage.map((u, i) => `${i ? " " : "usage: "}browser-attach ${u}`);
1434
+ lines2.push("", c.summary);
1435
+ const tool = toolDescription(c);
1436
+ if (tool)
1437
+ lines2.push("", `(${c.tool}) ${tool}`);
1438
+ if (c.example)
1439
+ lines2.push("", `example: ${c.example}`);
1440
+ lines2.push("", "global: -s SESSION, -t TAB (a tab of this session; default: current), --json, --quiet, --port N");
1441
+ return lines2.join("\n");
1442
+ }
1443
+
1444
+ // packages/bridge/dist/cli/bin.js
1445
+ main(process.argv.slice(2)).then((code) => {
1446
+ process.exitCode = code;
1447
+ }, (e) => {
1448
+ process.stderr.write(`error INTERNAL: ${e instanceof Error ? e.stack ?? e.message : String(e)}
1449
+ `);
1450
+ process.exitCode = 1;
1451
+ });