@xnng/browser-relay 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +445 -0
  3. package/docs/README.zh-CN.md +421 -0
  4. package/docs/benchmarks/browser-gaps-codex-ax.json +418 -0
  5. package/docs/benchmarks/browser-gaps-relay-after.json +655 -0
  6. package/docs/benchmarks/browser-gaps-relay-baseline.json +679 -0
  7. package/docs/benchmarks/browser-readiness-cost.json +106 -0
  8. package/docs/benchmarks/browser-runtime-balanced-headed.json +412 -0
  9. package/docs/benchmarks/browser-runtime-balanced-rtt50.json +412 -0
  10. package/docs/benchmarks/browser-runtime-balanced.json +412 -0
  11. package/docs/benchmarks/browser-runtime-rtt50.json +194 -0
  12. package/docs/benchmarks/browser-runtime.json +254 -0
  13. package/docs/benchmarks/browser-use-parity.json +54 -0
  14. package/docs/benchmarks/codex-extension-audit.json +131 -0
  15. package/docs/benchmarks/codex-native-protocol.md +69 -0
  16. package/docs/benchmarks/codex-native-replay.js +116 -0
  17. package/docs/benchmarks/codex-native-status.json +81 -0
  18. package/docs/benchmarks/codex-native.json +1003 -0
  19. package/docs/benchmarks/extension-sessions.png +0 -0
  20. package/docs/benchmarks/extension-tasks.png +0 -0
  21. package/docs/benchmarks/iframe-routing-regression.json +37 -0
  22. package/docs/browser-use-comparison.md +331 -0
  23. package/docs/browser-use-gap-audit.md +141 -0
  24. package/docs/browser-use-parity.md +105 -0
  25. package/docs/demo/intranet.html +103 -0
  26. package/docs/releases/v1.5.0.md +59 -0
  27. package/docs/releases/v1.5.1.md +16 -0
  28. package/docs/releases/v1.5.2.md +42 -0
  29. package/docs/releases/v1.5.3.md +33 -0
  30. package/docs/releases/v1.5.4.md +42 -0
  31. package/docs/releases/v1.6.0.md +16 -0
  32. package/docs/remote-control-hub.md +523 -0
  33. package/extension/activity.js +328 -0
  34. package/extension/automation.js +1839 -0
  35. package/extension/background.js +1854 -0
  36. package/extension/i18n.js +149 -0
  37. package/extension/icons/icon128.png +0 -0
  38. package/extension/icons/icon16.png +0 -0
  39. package/extension/icons/icon32.png +0 -0
  40. package/extension/icons/icon48.png +0 -0
  41. package/extension/manifest.json +47 -0
  42. package/extension/observations.js +109 -0
  43. package/extension/options.html +289 -0
  44. package/extension/options.js +269 -0
  45. package/extension/popup.html +74 -0
  46. package/extension/popup.js +105 -0
  47. package/extension/protocol.js +45 -0
  48. package/extension/remote-auth.js +18 -0
  49. package/extension/sessions.js +134 -0
  50. package/extension/snapshot.js +161 -0
  51. package/extension/task-groups.js +108 -0
  52. package/extension/tasks.js +186 -0
  53. package/extension/wait.js +89 -0
  54. package/hub/README.md +42 -0
  55. package/hub/package-lock.json +1544 -0
  56. package/hub/package.json +13 -0
  57. package/hub/src/rpc.js +41 -0
  58. package/hub/src/worker.js +322 -0
  59. package/hub/wrangler.example.toml +19 -0
  60. package/package.json +83 -0
  61. package/server/cdp-bridge.js +200 -0
  62. package/server/cli.js +1798 -0
  63. package/server/hub-server.js +258 -0
  64. package/server/install.js +250 -0
  65. package/server/mcp-server.js +504 -0
  66. package/server/npx-runner.js +96 -0
  67. package/server/relay-server.js +1356 -0
  68. package/server/remote-protocol.js +76 -0
  69. package/server/runtime-worker.js +166 -0
  70. package/server/script-runtime.js +189 -0
  71. package/server/sdk.js +307 -0
  72. package/server/service-state.js +103 -0
  73. package/server/snapshot.js +161 -0
  74. package/server/uninstall.js +61 -0
  75. package/server/windows-service-entry.js +58 -0
  76. package/server/windows-service.js +360 -0
  77. package/skills/browser-relay/SKILL.md +192 -0
  78. package/skills/browser-relay/references/legacy-api.md +163 -0
  79. package/skills/browser-relay/references/runtime.md +240 -0
package/server/cli.js ADDED
@@ -0,0 +1,1798 @@
1
+ #!/usr/bin/env node
2
+ import { spawn, spawnSync, execSync } from "node:child_process";
3
+ import { chmodSync, existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { homedir, platform } from "node:os";
7
+ import { DEFAULT_REMOTE_HOST, parseRemoteDeviceId, remoteHttpBase } from "./remote-protocol.js";
8
+ import { runNpxSync } from "./npx-runner.js";
9
+ import { createTransport } from "./sdk.js";
10
+ import { createScriptRuntime } from "./script-runtime.js";
11
+ import { createInterface } from "node:readline";
12
+ import { inspectPosixServiceState, relayStartRemediation } from "./service-state.js";
13
+ import {
14
+ inspectWindowsTask,
15
+ runWindowsTaskCommand,
16
+ schtasksError,
17
+ windowsServicePaths,
18
+ } from "./windows-service.js";
19
+
20
+ const __dirname = dirname(fileURLToPath(import.meta.url));
21
+ const PKG_DIR = dirname(__dirname);
22
+ const EXTENSION_DIR = join(PKG_DIR, "extension");
23
+ const SKILL_DIR = join(PKG_DIR, "skills/browser-relay");
24
+ const SKILL_PATH = join(SKILL_DIR, "SKILL.md");
25
+ const LAUNCHD_LABEL = "org.browser-relay.service";
26
+ const PLIST_PATH = join(homedir(), `Library/LaunchAgents/${LAUNCHD_LABEL}.plist`);
27
+ const SYSTEMD_UNIT = "browser-relay";
28
+ const SYSTEMD_PATH = join(homedir(), ".config/systemd/user/browser-relay.service");
29
+ const RELAY_HOST = process.env.BROWSER_RELAY_HOST || "127.0.0.1";
30
+ const RELAY_PORT = process.env.BROWSER_RELAY_PORT || "18795";
31
+ const RELAY_URL = (process.env.BROWSER_RELAY_URL || `http://${RELAY_HOST}:${RELAY_PORT}`).replace(/\/+$/, "");
32
+ const HEALTH_URL = `${RELAY_URL}/`;
33
+ let remoteContext = null;
34
+ const sys = platform();
35
+ const WINDOWS_SERVICE_PATHS = windowsServicePaths();
36
+ const LOG_FILE = sys === "win32" ? WINDOWS_SERVICE_PATHS.stdoutLog : "/tmp/browser-relay.log";
37
+ const ERR_LOG_FILE = sys === "win32" ? WINDOWS_SERVICE_PATHS.stderrLog : "/tmp/browser-relay.error.log";
38
+
39
+ async function run() {
40
+ await import("./relay-server.js");
41
+ }
42
+
43
+ async function hub() {
44
+ await import("./hub-server.js");
45
+ }
46
+
47
+ function ensureInstalled() {
48
+ if (sys === "darwin" && existsSync(PLIST_PATH)) return true;
49
+ if (sys === "linux" && existsSync(SYSTEMD_PATH)) return true;
50
+ if (sys === "win32") {
51
+ const state = inspectWindowsTask();
52
+ if (state.checked && state.registered && state.owned) return true;
53
+ if (state.checked && state.registered && !state.owned) {
54
+ console.error("A task named BrowserRelay exists but is not managed by Browser Relay. Refusing to control it.");
55
+ }
56
+ if (!state.checked) console.error(state.error);
57
+ }
58
+ console.error("Background service not registered. Run: browser-relay install");
59
+ process.exit(1);
60
+ }
61
+
62
+ async function install() {
63
+ const mod = await import("./install.js");
64
+ try {
65
+ await mod.installService({ explicit: true, strict: true });
66
+ } catch (error) {
67
+ console.error(`Install failed: ${error instanceof Error ? error.message : String(error)}`);
68
+ process.exitCode = 1;
69
+ }
70
+ }
71
+
72
+ async function uninstall() {
73
+ const mod = await import("./uninstall.js");
74
+ try {
75
+ mod.uninstallService({ explicit: true, strict: true });
76
+ } catch (error) {
77
+ console.error(`Uninstall failed: ${error instanceof Error ? error.message : String(error)}`);
78
+ process.exitCode = 1;
79
+ }
80
+ }
81
+
82
+ function darwinDomain() {
83
+ return `gui/${process.getuid()}`;
84
+ }
85
+
86
+ async function start() {
87
+ ensureInstalled();
88
+ if (sys === "darwin") {
89
+ const domain = darwinDomain();
90
+ // Idempotent: bootout first (ignore "not found") then bootstrap.
91
+ // `launchctl load` is legacy; bootstrap is the modern API that actually
92
+ // works on macOS 13+.
93
+ try { execSync(`launchctl bootout ${domain} "${PLIST_PATH}" 2>/dev/null`); } catch {}
94
+ try { execSync(`launchctl bootstrap ${domain} "${PLIST_PATH}"`, { stdio: "inherit" }); }
95
+ catch (e) { console.error(`launchctl bootstrap failed: ${e.message}`); process.exit(1); }
96
+ console.log("Started. Check: browser-relay status");
97
+ } else if (sys === "linux") {
98
+ execSync(`systemctl --user start ${SYSTEMD_UNIT}`, { stdio: "inherit" });
99
+ console.log("Started. Check: browser-relay status");
100
+ } else if (sys === "win32") {
101
+ const result = runWindowsTaskCommand("run");
102
+ if (result.error || result.status !== 0) {
103
+ const relay = await probeRelayDebug();
104
+ if (!relay.ok) {
105
+ console.error(schtasksError("Task Scheduler start", result));
106
+ process.exitCode = 1;
107
+ return false;
108
+ }
109
+ console.log("Already running. Check: browser-relay status");
110
+ return true;
111
+ }
112
+ console.log("Started. Check: browser-relay status");
113
+ } else {
114
+ console.error(`'start' not supported on ${sys}. Run 'browser-relay' in foreground instead.`);
115
+ process.exit(1);
116
+ }
117
+ return true;
118
+ }
119
+
120
+ async function stop() {
121
+ if (sys === "darwin") {
122
+ try { execSync(`launchctl bootout ${darwinDomain()} "${PLIST_PATH}"`, { stdio: "inherit" }); }
123
+ catch { /* not loaded */ }
124
+ console.log("Stopped.");
125
+ } else if (sys === "linux") {
126
+ execSync(`systemctl --user stop ${SYSTEMD_UNIT}`, { stdio: "inherit" });
127
+ console.log("Stopped.");
128
+ } else if (sys === "win32") {
129
+ ensureInstalled();
130
+ const result = runWindowsTaskCommand("end");
131
+ let relay = null;
132
+ for (let i = 0; i < 5; i++) {
133
+ relay = await probeRelayDebug();
134
+ if (!relay.ok) break;
135
+ if (i < 4) await new Promise((resolveDelay) => setTimeout(resolveDelay, 250));
136
+ }
137
+ if (relay?.ok) {
138
+ console.error(result.error || result.status !== 0
139
+ ? schtasksError("Task Scheduler stop", result)
140
+ : "The scheduled task stopped, but a relay is still using the configured address. No unrelated process was terminated.");
141
+ process.exitCode = 1;
142
+ return false;
143
+ }
144
+ console.log(result.error || result.status !== 0 ? "Stopped (already inactive)." : "Stopped.");
145
+ } else {
146
+ console.error(`'stop' not supported on ${sys}.`);
147
+ process.exit(1);
148
+ }
149
+ return true;
150
+ }
151
+
152
+ async function restart() {
153
+ const stopped = await stop();
154
+ if (!stopped) return false;
155
+ return start();
156
+ }
157
+
158
+ async function fix() {
159
+ // Diagnose current state
160
+ try {
161
+ const res = await fetch(`${RELAY_URL}/api/debug`, { signal: AbortSignal.timeout(2000) });
162
+ if (res.ok) {
163
+ const data = await res.json();
164
+ console.log(`relay: running extension: ${data.connected ? "connected" : "not connected"} tabs: ${data.tabCount ?? 0}`);
165
+ }
166
+ } catch {
167
+ console.log("relay: not responding");
168
+ }
169
+
170
+ // Restart clears all stale session state
171
+ console.log("Restarting relay server...");
172
+ if (!await restart()) return;
173
+
174
+ // Poll until healthy
175
+ for (let i = 0; i < 20; i++) {
176
+ await new Promise((r) => setTimeout(r, 500));
177
+ try {
178
+ const res = await fetch(`${RELAY_URL}/`, { signal: AbortSignal.timeout(1000) });
179
+ if (res.ok) {
180
+ console.log("Done. Extension will reconnect automatically within a few seconds.");
181
+ return;
182
+ }
183
+ } catch { /* keep waiting */ }
184
+ }
185
+ console.error("Relay server did not come back up. Check logs: browser-relay logs");
186
+ process.exit(1);
187
+ }
188
+
189
+ function serviceState() {
190
+ if (sys === "darwin" || sys === "linux") {
191
+ return inspectPosixServiceState({
192
+ sys,
193
+ plistPath: PLIST_PATH,
194
+ systemdPath: SYSTEMD_PATH,
195
+ launchdLabel: LAUNCHD_LABEL,
196
+ systemdUnit: SYSTEMD_UNIT,
197
+ });
198
+ }
199
+
200
+ if (sys === "win32") {
201
+ const state = inspectWindowsTask();
202
+ const registered = state.checked && state.registered && state.owned;
203
+ return {
204
+ loaded: registered,
205
+ pid: null,
206
+ supported: true,
207
+ registered,
208
+ checked: state.checked,
209
+ error: state.error,
210
+ conflict: state.checked && state.registered && !state.owned,
211
+ };
212
+ }
213
+
214
+ return {
215
+ loaded: false,
216
+ pid: null,
217
+ supported: false,
218
+ registered: false,
219
+ checked: false,
220
+ error: null,
221
+ conflict: false,
222
+ };
223
+ }
224
+
225
+ async function status() {
226
+ const { loaded, pid, registered, checked, error, conflict } = serviceState();
227
+
228
+ const relay = await probeRelayDebug();
229
+ const healthy = relay.ok;
230
+ const daemonVersion = relay.ok ? relay.data.version ?? null : null;
231
+
232
+ const cliVersion = JSON.parse(readFileSync(join(PKG_DIR, "package.json"), "utf-8")).version;
233
+ const outdated = daemonVersion && daemonVersion !== cliVersion;
234
+ const serviceLabel = sys === "win32"
235
+ ? !checked ? "unknown (Task Scheduler query failed)" : conflict ? "name conflict (not managed)" : registered ? "registered (Task Scheduler)" : "not registered"
236
+ : !checked ? `unknown (${registered ? "definition present; state unavailable" : "state unavailable"})` : loaded ? "loaded" : registered ? "registered, not loaded" : "not registered";
237
+ console.log(`Service: ${serviceLabel}${pid ? ` (pid ${pid})` : ""}`);
238
+ if (!checked && error) {
239
+ console.log(`Service error: ${error}`);
240
+ if (!healthy) console.log("Next: Run the relay in this terminal: browser-relay");
241
+ }
242
+ console.log(`HTTP: ${healthy ? "responding" : "not responding"} (${HEALTH_URL})`);
243
+ console.log(`Version: cli ${cliVersion}, daemon ${daemonVersion ?? "unknown"}${outdated ? " ← outdated, run: browser-relay restart" : ""}`);
244
+ console.log(`Extension: ${EXTENSION_DIR}`);
245
+ console.log(`Logs: ${LOG_FILE}`);
246
+ process.exit(healthy && !conflict ? 0 : 1);
247
+ }
248
+
249
+ const AGENT_SKILL_ROOTS = [
250
+ ".agents/skills",
251
+ ".claude/skills",
252
+ ".codex/skills",
253
+ ".cursor/skills",
254
+ ".windsurf/skills",
255
+ ".gemini/skills",
256
+ ".copilot/skills",
257
+ ".config/opencode/skills",
258
+ ];
259
+
260
+ const SKILL_INSTALL_AGENTS = new Set(["codex", "claude-code", "universal"]);
261
+
262
+ function inspectInstalledSkills(shippedSkill) {
263
+ const installations = [];
264
+ for (const root of AGENT_SKILL_ROOTS) {
265
+ const installedPath = join(homedir(), root, "browser-relay/SKILL.md");
266
+ if (!existsSync(installedPath)) continue;
267
+ let statusValue = "unreadable";
268
+ try {
269
+ statusValue = readFileSync(installedPath, "utf-8") === shippedSkill ? "current" : "outdated";
270
+ } catch {}
271
+ installations.push({ path: installedPath, status: statusValue });
272
+ }
273
+ return installations;
274
+ }
275
+
276
+ function doctorHelp() {
277
+ console.log(`Usage:
278
+ browser-relay doctor [--json]
279
+
280
+ Runs read-only checks for the CLI package, Chrome extension, Agent Skill,
281
+ background service, relay HTTP endpoint, extension connection, tabs, and logs.
282
+ Warnings do not make the command fail. This command never installs or restarts anything.`);
283
+ }
284
+
285
+ function safeRelayUrl(value) {
286
+ try {
287
+ const url = new URL(value);
288
+ url.username = "";
289
+ url.password = "";
290
+ url.search = "";
291
+ url.hash = "";
292
+ return url.toString().replace(/\/$/, "");
293
+ } catch {
294
+ return "(invalid URL)";
295
+ }
296
+ }
297
+
298
+ function relayDebugUrl(value) {
299
+ const url = new URL(value);
300
+ if (url.username || url.password) throw new Error("embedded credentials are not supported");
301
+ url.pathname = `${url.pathname.replace(/\/+$/, "")}/api/debug`;
302
+ url.hash = "";
303
+ return url;
304
+ }
305
+
306
+ function doctorSummary(checks) {
307
+ return {
308
+ passed: checks.filter((check) => check.status === "pass").length,
309
+ warnings: checks.filter((check) => check.status === "warn").length,
310
+ failed: checks.filter((check) => check.status === "fail").length,
311
+ skipped: checks.filter((check) => check.status === "skip").length,
312
+ };
313
+ }
314
+
315
+ async function probeRelayDebug() {
316
+ let endpoint;
317
+ try {
318
+ endpoint = relayDebugUrl(RELAY_URL);
319
+ } catch (error) {
320
+ return {
321
+ ok: false,
322
+ message: error?.message === "embedded credentials are not supported"
323
+ ? "Relay URL userinfo is not supported; use a credential-free local relay URL"
324
+ : "Relay URL is invalid",
325
+ };
326
+ }
327
+
328
+ try {
329
+ const response = await fetch(endpoint, { signal: AbortSignal.timeout(2000) });
330
+ if (!response.ok) return { ok: false, message: `Relay returned HTTP ${response.status}` };
331
+ let data;
332
+ try {
333
+ data = await response.json();
334
+ } catch {
335
+ return { ok: false, message: "Relay returned invalid JSON" };
336
+ }
337
+ const valid = data
338
+ && typeof data === "object"
339
+ && !Array.isArray(data)
340
+ && data.ok === true
341
+ && typeof data.connected === "boolean"
342
+ && Number.isInteger(data.tabCount)
343
+ && data.tabCount >= 0
344
+ && (data.version === undefined || typeof data.version === "string");
345
+ if (!valid) {
346
+ return { ok: false, message: "Relay returned an invalid debug payload" };
347
+ }
348
+ return { ok: true, data };
349
+ } catch (error) {
350
+ const timedOut = error?.name === "TimeoutError" || error?.name === "AbortError";
351
+ return { ok: false, message: timedOut ? "Relay request timed out after 2 seconds" : "Relay is unreachable" };
352
+ }
353
+ }
354
+
355
+ async function doctor(args = []) {
356
+ const allowed = new Set(["--json", "-j", "--help", "-h"]);
357
+ const json = args.includes("--json") || args.includes("-j");
358
+ const invalid = args.find((arg) => !allowed.has(arg));
359
+ if (invalid) {
360
+ if (json) {
361
+ console.log(JSON.stringify({
362
+ ok: false,
363
+ version: 1,
364
+ code: "invalid_option",
365
+ option: invalid,
366
+ message: `Unknown doctor option: ${invalid}`,
367
+ }, null, 2));
368
+ process.exitCode = 2;
369
+ return;
370
+ }
371
+ console.error(`Unknown doctor option: ${invalid}`);
372
+ console.error("Usage: browser-relay doctor [--json]");
373
+ process.exitCode = 2;
374
+ return;
375
+ }
376
+ if (args.includes("--help") || args.includes("-h")) {
377
+ doctorHelp();
378
+ return;
379
+ }
380
+
381
+ const checks = [];
382
+ const add = (id, statusValue, message, details, remediation) => {
383
+ checks.push({
384
+ id,
385
+ status: statusValue,
386
+ message,
387
+ ...(details === undefined ? {} : { details }),
388
+ ...(remediation ? { remediation } : {}),
389
+ });
390
+ };
391
+
392
+ let pkg = null;
393
+ try {
394
+ pkg = JSON.parse(readFileSync(join(PKG_DIR, "package.json"), "utf-8"));
395
+ if (!pkg?.version) throw new Error("missing version");
396
+ const requiredNode = pkg.engines?.node ?? null;
397
+ const minimumMajor = Number(requiredNode?.match(/^>=\s*(\d+)/)?.[1]);
398
+ const currentMajor = Number(process.versions.node.split(".")[0]);
399
+ const supportedNode = !Number.isFinite(minimumMajor) || currentMajor >= minimumMajor;
400
+ add(
401
+ "runtime",
402
+ supportedNode ? "pass" : "fail",
403
+ supportedNode
404
+ ? `CLI ${pkg.version} is readable on Node ${process.version}`
405
+ : `Node ${process.version} does not satisfy ${requiredNode}`,
406
+ { nodeVersion: process.version, requiredNode, platform: sys, arch: process.arch },
407
+ supportedNode ? undefined : `Upgrade Node.js to ${requiredNode}`,
408
+ );
409
+ } catch {
410
+ add(
411
+ "runtime",
412
+ "fail",
413
+ "Package metadata is missing or invalid",
414
+ { platform: sys, arch: process.arch },
415
+ "Reinstall Browser Relay with: npm install -g @xnng/browser-relay",
416
+ );
417
+ }
418
+
419
+ const manifestPath = join(EXTENSION_DIR, "manifest.json");
420
+ try {
421
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
422
+ if (!manifest?.manifest_version) throw new Error("invalid manifest");
423
+ add("assets.extension", "pass", "Chrome extension manifest is readable", {
424
+ path: EXTENSION_DIR,
425
+ manifestPath,
426
+ manifestVersion: manifest.manifest_version,
427
+ });
428
+ } catch {
429
+ add(
430
+ "assets.extension",
431
+ "fail",
432
+ "Chrome extension manifest is missing or invalid",
433
+ { path: EXTENSION_DIR, manifestPath },
434
+ "Reinstall Browser Relay with: npm install -g @xnng/browser-relay",
435
+ );
436
+ }
437
+
438
+ try {
439
+ const shippedSkill = readFileSync(SKILL_PATH, "utf-8");
440
+ const installations = inspectInstalledSkills(shippedSkill);
441
+ const stale = installations.filter((item) => item.status !== "current");
442
+ add(
443
+ "assets.skill",
444
+ stale.length ? "warn" : "pass",
445
+ stale.length
446
+ ? `Bundled Agent Skill is readable; ${stale.length} installed copy needs attention`
447
+ : installations.length
448
+ ? `Bundled Agent Skill and ${installations.length} installed copy are current`
449
+ : "Bundled Agent Skill is readable; no global copy was detected",
450
+ {
451
+ path: SKILL_PATH,
452
+ installCommand: skillInstallCommand(),
453
+ installAgents: [...SKILL_INSTALL_AGENTS],
454
+ installations,
455
+ },
456
+ stale.length
457
+ ? "Run browser-relay skill to print the install command for all agents"
458
+ : undefined,
459
+ );
460
+ } catch {
461
+ add(
462
+ "assets.skill",
463
+ "fail",
464
+ "Bundled Agent Skill is missing or unreadable",
465
+ { path: SKILL_PATH },
466
+ "Reinstall Browser Relay with: npm install -g @xnng/browser-relay",
467
+ );
468
+ }
469
+
470
+ const safeUrl = safeRelayUrl(RELAY_URL);
471
+ const relay = await probeRelayDebug();
472
+ const customRelay = Boolean(
473
+ process.env.BROWSER_RELAY_URL || process.env.BROWSER_RELAY_HOST || process.env.BROWSER_RELAY_PORT,
474
+ );
475
+ let service = null;
476
+ if (customRelay) {
477
+ add(
478
+ "service.registration",
479
+ "skip",
480
+ "Local background service check skipped for a custom relay URL",
481
+ { platform: sys },
482
+ );
483
+ } else if (sys !== "darwin" && sys !== "linux" && sys !== "win32") {
484
+ add(
485
+ "service.registration",
486
+ "skip",
487
+ `Native background service is not supported on ${sys}; foreground mode is supported`,
488
+ { platform: sys },
489
+ );
490
+ } else {
491
+ service = serviceState();
492
+ if (sys === "win32" && service.conflict) {
493
+ add(
494
+ "service.registration",
495
+ "warn",
496
+ "A task named BrowserRelay exists but is not managed by Browser Relay",
497
+ { registered: false, conflict: true, task: "BrowserRelay" },
498
+ "Resolve the Task Scheduler name conflict before running browser-relay install",
499
+ );
500
+ } else if (sys === "win32" && service.registered) {
501
+ add(
502
+ "service.registration",
503
+ "pass",
504
+ "Background service is registered with Windows Task Scheduler",
505
+ { registered: true, task: "BrowserRelay" },
506
+ );
507
+ } else if (service.loaded) {
508
+ add(
509
+ "service.registration",
510
+ "pass",
511
+ "Background service is active",
512
+ { registered: service.registered, pid: service.pid },
513
+ );
514
+ } else {
515
+ add(
516
+ "service.registration",
517
+ "warn",
518
+ service.checked
519
+ ? service.registered
520
+ ? "Background service is registered but inactive"
521
+ : "Background service is not registered"
522
+ : service.error || "Background service state is unavailable",
523
+ {
524
+ registered: service.registered,
525
+ checked: service.checked,
526
+ ...(!service.checked && service.error ? { error: service.error } : {}),
527
+ },
528
+ relay.ok
529
+ ? undefined
530
+ : service.checked
531
+ ? service.registered
532
+ ? "Start it with: browser-relay start"
533
+ : "Optional: register it with: browser-relay install"
534
+ : "Start the relay in a terminal with: browser-relay",
535
+ );
536
+ }
537
+ }
538
+
539
+ if (relay.ok) {
540
+ add("relay.http", "pass", "Relay HTTP debug endpoint is healthy", {
541
+ url: safeUrl,
542
+ uptimeSeconds: relay.data.uptimeSeconds ?? null,
543
+ daemonVersion: relay.data.version ?? null,
544
+ });
545
+ } else {
546
+ add(
547
+ "relay.http",
548
+ "fail",
549
+ relay.message,
550
+ { url: safeUrl },
551
+ relayStartRemediation(service),
552
+ );
553
+ }
554
+
555
+ if (relay.ok) {
556
+ const daemonVersion = relay.data.version;
557
+ if (pkg?.version && daemonVersion === pkg.version) {
558
+ add("relay.version", "pass", `CLI and daemon versions match (${pkg.version})`, {
559
+ cliVersion: pkg.version,
560
+ daemonVersion,
561
+ });
562
+ } else {
563
+ add(
564
+ "relay.version",
565
+ "warn",
566
+ daemonVersion
567
+ ? `CLI ${pkg?.version ?? "unknown"} and daemon ${daemonVersion} differ`
568
+ : "Relay did not report its version",
569
+ { cliVersion: pkg?.version ?? null, daemonVersion: daemonVersion ?? null },
570
+ service?.loaded ? "Restart the background relay with: browser-relay restart" : "Restart the foreground relay process",
571
+ );
572
+ }
573
+
574
+ if (relay.data.connected === true) {
575
+ add("extension.connection", "pass", "Chrome extension is connected");
576
+ try {
577
+ const endpoint=relayDebugUrl(RELAY_URL);endpoint.pathname=endpoint.pathname.replace(/debug$/, 'capabilities');
578
+ const response=await fetch(endpoint,{signal:AbortSignal.timeout(2500)});
579
+ const info=await response.json();
580
+ if(!response.ok || info.protocolVersion!==2 || typeof info.runtimeId!=='string' || !info.features?.includes('read')) throw new Error(info.message || 'Running extension lacks the required reading protocol');
581
+ add('extension.protocol','pass',`Running Chrome executor ${info.extensionVersion}, protocol ${info.protocolVersion}`,{runtimeId:info.runtimeId,features:info.features});
582
+ if(info.extensionVersion!==pkg.version)add('extension.version','warn',`Running extension ${info.extensionVersion} differs from CLI ${pkg.version}`,undefined,`Reload the matching extension from: ${EXTENSION_DIR}`);
583
+ } catch(error) { add('extension.protocol','fail',`Running extension probe failed: ${error.message}`,undefined,`Reload the matching Browser Relay extension from: ${EXTENSION_DIR}`); }
584
+ } else {
585
+ add(
586
+ "extension.connection",
587
+ "warn",
588
+ relay.data.connected === false ? "Chrome extension is not connected" : "Relay did not report extension state",
589
+ undefined,
590
+ `Open Chrome and reload the unpacked extension from: ${EXTENSION_DIR}`,
591
+ );
592
+ }
593
+
594
+ if (typeof relay.data.tabCount === "number" && Number.isFinite(relay.data.tabCount)) {
595
+ const count = relay.data.tabCount;
596
+ add(
597
+ "tabs.attached",
598
+ count > 0 ? "pass" : "warn",
599
+ count > 0 ? `${count} Chrome tab${count === 1 ? " is" : "s are"} attached` : "No Chrome tabs are attached",
600
+ { count },
601
+ count > 0 ? undefined : "Open a normal Chrome page, then run: browser-relay tabs",
602
+ );
603
+ } else {
604
+ add(
605
+ "tabs.attached",
606
+ "warn",
607
+ "Relay did not report an attached tab count",
608
+ undefined,
609
+ "Check attached tabs with: browser-relay tabs",
610
+ );
611
+ }
612
+ } else {
613
+ add("relay.version", "skip", "Version check skipped because relay HTTP failed");
614
+ add("extension.connection", "skip", "Extension check skipped because relay HTTP failed");
615
+ add("tabs.attached", "skip", "Tab check skipped because relay HTTP failed");
616
+ }
617
+
618
+ if (customRelay) {
619
+ add("logs.access", "skip", "Local log check skipped for a custom relay URL");
620
+ } else if (sys === "darwin") {
621
+ const available = [LOG_FILE, ERR_LOG_FILE].filter((file) => existsSync(file));
622
+ add(
623
+ "logs.access",
624
+ available.length ? "pass" : "warn",
625
+ available.length ? `${available.length} local relay log file${available.length === 1 ? " is" : "s are"} available` : "Local relay logs have not been created yet",
626
+ { files: [LOG_FILE, ERR_LOG_FILE], available },
627
+ available.length ? undefined : "After starting the service, inspect logs with: browser-relay logs",
628
+ );
629
+ } else if (sys === "linux") {
630
+ const journal = spawnSync("journalctl", ["--user", "-u", SYSTEMD_UNIT, "-n", "1", "--no-pager"], {
631
+ encoding: "utf-8",
632
+ timeout: 1500,
633
+ });
634
+ const accessible = !journal.error && journal.status === 0;
635
+ add(
636
+ "logs.access",
637
+ accessible ? "pass" : "warn",
638
+ accessible ? "Relay logs are accessible through the user journal" : "The user journal is not currently accessible",
639
+ { command: `journalctl --user -u ${SYSTEMD_UNIT}` },
640
+ accessible ? undefined : "If running in the foreground, inspect the current terminal output",
641
+ );
642
+ } else if (sys === "win32") {
643
+ const available = [LOG_FILE, ERR_LOG_FILE].filter((file) => existsSync(file));
644
+ add(
645
+ "logs.access",
646
+ available.length ? "pass" : "warn",
647
+ available.length ? `${available.length} local relay log file${available.length === 1 ? " is" : "s are"} available` : "Local relay logs have not been created yet",
648
+ { files: [LOG_FILE, ERR_LOG_FILE], available },
649
+ available.length ? undefined : "After starting the service, inspect logs with: browser-relay logs",
650
+ );
651
+ } else {
652
+ add("logs.access", "skip", "Relay logs are available in the foreground terminal on this platform");
653
+ }
654
+
655
+ const summary = doctorSummary(checks);
656
+ const ok = summary.failed === 0;
657
+ const recommendations = [...new Set(checks.map((check) => check.remediation).filter(Boolean))];
658
+ const payload = {
659
+ ok,
660
+ version: 1,
661
+ platform: sys,
662
+ cliVersion: pkg?.version ?? null,
663
+ relayUrl: safeUrl,
664
+ checks,
665
+ summary,
666
+ recommendations,
667
+ };
668
+
669
+ if (json) {
670
+ console.log(JSON.stringify(payload, null, 2));
671
+ } else {
672
+ console.log("Browser Relay doctor");
673
+ console.log("");
674
+ for (const check of checks) console.log(`[${check.status.toUpperCase()}] ${check.message}`);
675
+ console.log("");
676
+ console.log(`Doctor: ${summary.passed} passed, ${summary.warnings} warnings, ${summary.failed} failed, ${summary.skipped} skipped`);
677
+ if (recommendations.length) {
678
+ console.log("");
679
+ console.log("Next steps:");
680
+ for (const recommendation of recommendations) console.log(` - ${recommendation}`);
681
+ }
682
+ }
683
+
684
+ if (!ok) process.exitCode = 1;
685
+ }
686
+
687
+ function logs() {
688
+ const available = [LOG_FILE, ERR_LOG_FILE].filter((file) => existsSync(file));
689
+ if (!available.length) {
690
+ console.error(`No service logs found at ${LOG_FILE} or ${ERR_LOG_FILE}. Start the service first.`);
691
+ process.exit(1);
692
+ }
693
+ const child = sys === "win32"
694
+ ? spawn("powershell.exe", [
695
+ "-NoLogo",
696
+ "-NoProfile",
697
+ "-NonInteractive",
698
+ "-Command",
699
+ "Get-Content -LiteralPath $args -Tail 100 -Wait",
700
+ ...available,
701
+ ], { stdio: "inherit", windowsHide: true })
702
+ : spawn("tail", ["-f", LOG_FILE, ERR_LOG_FILE], { stdio: "inherit" });
703
+ child.on("error", (error) => {
704
+ console.error(`Could not follow service logs: ${error.message}`);
705
+ process.exitCode = 1;
706
+ });
707
+ child.on("exit", (code) => process.exit(code ?? 0));
708
+ }
709
+
710
+ function path() {
711
+ console.log(EXTENSION_DIR);
712
+ }
713
+
714
+ function skillTargetPath(agent) {
715
+ if (agent === "claude-code") {
716
+ const claudeHome = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude");
717
+ return join(claudeHome, "skills/browser-relay/SKILL.md");
718
+ }
719
+ return join(homedir(), ".agents/skills/browser-relay/SKILL.md");
720
+ }
721
+
722
+ function parseSkillAgents(args) {
723
+ const agents = [];
724
+ for (let i = 0; i < args.length; i++) {
725
+ const arg = args[i];
726
+ if (arg === "--agent" || arg === "-a") {
727
+ const start = agents.length;
728
+ while (i + 1 < args.length && !args[i + 1].startsWith("-")) agents.push(args[++i]);
729
+ if (agents.length === start) return { error: `${arg} requires at least one agent` };
730
+ continue;
731
+ }
732
+ if (arg.startsWith("--agent=")) {
733
+ const value = arg.slice("--agent=".length);
734
+ if (!value) return { error: "--agent requires at least one agent" };
735
+ agents.push(value);
736
+ continue;
737
+ }
738
+ return { error: `Unknown skill option: ${arg}` };
739
+ }
740
+
741
+ const unique = [...new Set(agents)];
742
+ const unsupported = unique.filter((agent) => !SKILL_INSTALL_AGENTS.has(agent));
743
+ if (unsupported.length) {
744
+ return {
745
+ error: `Unsupported agent: ${unsupported.join(", ")}. Use codex, claude-code, or universal.`,
746
+ };
747
+ }
748
+ return { agents: unique };
749
+ }
750
+
751
+ function skillInstallCommand(agents = ["*"]) {
752
+ const targets = agents.map((agent) => agent === "*" ? '"*"' : agent).join(" ");
753
+ return `npx --yes skills add "${SKILL_DIR}" --global --yes --copy --agent ${targets}`;
754
+ }
755
+
756
+ function skillHelp() {
757
+ console.log(`Usage:
758
+ browser-relay skill Print the install command for all agents
759
+ browser-relay skill command [--agent <name>] Print an install command; defaults to all agents
760
+ browser-relay skill install --agent <name> Install/update and verify the bundled Skill
761
+ browser-relay skill path Print the bundled Skill directory
762
+ browser-relay skill help Show this help
763
+
764
+ Supported targets: codex, claude-code, universal. Pass multiple names after
765
+ --agent to install for more than one target. "universal" installs to the
766
+ standard ~/.agents/skills directory.
767
+
768
+ The install command uses the skills CLI in global, non-interactive copy mode,
769
+ then verifies every target SKILL.md. Exit codes: 0 success, 1 install or
770
+ verification failure, 2 invalid usage.`);
771
+ }
772
+
773
+ function skill(args = []) {
774
+ const subcommand = args[0];
775
+ if (args.includes("--help") || args.includes("-h") || subcommand === "help") {
776
+ skillHelp();
777
+ return;
778
+ }
779
+
780
+ if (subcommand === undefined) {
781
+ console.log(skillInstallCommand());
782
+ return;
783
+ }
784
+
785
+ if (subcommand === "command") {
786
+ const parsed = parseSkillAgents(args.slice(1));
787
+ if (parsed.error) {
788
+ console.error(parsed.error);
789
+ console.error("Usage: browser-relay skill command [--agent codex|claude-code|universal]");
790
+ process.exitCode = 2;
791
+ return;
792
+ }
793
+ console.log(skillInstallCommand(parsed.agents.length ? parsed.agents : undefined));
794
+ return;
795
+ }
796
+
797
+ if (subcommand === "path") {
798
+ if (args.length !== 1) {
799
+ console.error("Usage: browser-relay skill path");
800
+ process.exitCode = 2;
801
+ return;
802
+ }
803
+ console.log(SKILL_DIR);
804
+ return;
805
+ }
806
+
807
+ if (subcommand !== "install") {
808
+ console.error(`Unknown skill command: ${subcommand}`);
809
+ console.error("Usage: browser-relay skill [install|path|help]");
810
+ process.exitCode = 2;
811
+ return;
812
+ }
813
+
814
+ const parsed = parseSkillAgents(args.slice(1));
815
+ if (parsed.error) {
816
+ console.error(parsed.error);
817
+ console.error("Usage: browser-relay skill install --agent codex|claude-code|universal");
818
+ process.exitCode = 2;
819
+ return;
820
+ }
821
+ if (!parsed.agents.length) {
822
+ console.error("Missing required option: --agent <name>");
823
+ console.error("Use codex, claude-code, or universal.");
824
+ process.exitCode = 2;
825
+ return;
826
+ }
827
+
828
+ console.log(`Installing bundled Agent Skill from: ${SKILL_DIR}`);
829
+ console.log(`Targets: ${parsed.agents.join(", ")}`);
830
+ const installed = runNpxSync([
831
+ "--yes",
832
+ "skills",
833
+ "add",
834
+ SKILL_DIR,
835
+ "--global",
836
+ "--yes",
837
+ "--copy",
838
+ "--agent",
839
+ ...parsed.agents,
840
+ ], {
841
+ stdio: "inherit",
842
+ env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0" },
843
+ });
844
+
845
+ if (installed.error) {
846
+ console.error(`Could not run npx: ${installed.error.message}`);
847
+ process.exitCode = 1;
848
+ return;
849
+ }
850
+ if (installed.status !== 0) {
851
+ if (installed.signal) console.error(`The skills command was terminated by signal ${installed.signal}.`);
852
+ else console.error(`The skills command failed with exit code ${installed.status ?? "unknown"}.`);
853
+ process.exitCode = 1;
854
+ return;
855
+ }
856
+
857
+ let shippedSkill;
858
+ try {
859
+ shippedSkill = readFileSync(SKILL_PATH, "utf-8");
860
+ } catch {
861
+ console.error(`Bundled Skill is missing or unreadable: ${SKILL_PATH}`);
862
+ process.exitCode = 1;
863
+ return;
864
+ }
865
+ const skillFiles = (directory, prefix = '') => readdirSync(directory, {withFileTypes:true}).flatMap(entry => {
866
+ const relative = join(prefix,entry.name);
867
+ return entry.isDirectory() ? skillFiles(join(directory,entry.name),relative) : entry.isFile() ? [relative] : [];
868
+ });
869
+ const shippedFiles = skillFiles(SKILL_DIR);
870
+ const verification = parsed.agents.map((agent) => {
871
+ const target = skillTargetPath(agent);
872
+ let statusValue = "missing";
873
+ try {
874
+ statusValue = readFileSync(target, "utf-8") === shippedSkill ? "current" : "outdated";
875
+ if(statusValue === 'current' && !shippedFiles.every(file => readFileSync(join(SKILL_DIR,file)).equals(readFileSync(join(dirname(target),file))))) statusValue='outdated';
876
+ } catch { statusValue='missing'; }
877
+ return { agent, path: target, status: statusValue };
878
+ });
879
+ const failed = verification.filter((item) => item.status !== "current");
880
+ if (failed.length) {
881
+ console.error("The skills command exited successfully, but target verification failed:");
882
+ for (const item of failed) console.error(` ${item.agent}: ${item.status} (${item.path})`);
883
+ console.error(`Retry manually: ${skillInstallCommand(parsed.agents)}`);
884
+ process.exitCode = 1;
885
+ return;
886
+ }
887
+
888
+ console.log("");
889
+ console.log("Verified Agent Skill:");
890
+ for (const item of verification) console.log(` ${item.agent}: ${item.path}`);
891
+ }
892
+
893
+ function packageInfo() {
894
+ return JSON.parse(readFileSync(join(PKG_DIR, "package.json"), "utf-8"));
895
+ }
896
+
897
+ function npmCommand() {
898
+ return sys === "win32" ? "npm.cmd" : "npm";
899
+ }
900
+
901
+ function packageSpec(packageName, target) {
902
+ if (!target || target === "latest") return `${packageName}@latest`;
903
+ if (target.includes("/")) return target;
904
+ return `${packageName}@${target}`;
905
+ }
906
+
907
+ function updateHelp() {
908
+ console.log(`Usage:
909
+ browser-relay update [version-or-tag]
910
+
911
+ Examples:
912
+ browser-relay update
913
+ browser-relay update latest
914
+ browser-relay update 1.0.14
915
+
916
+ Installs the requested Browser Relay npm package globally, refreshes the
917
+ background service through postinstall, then prints status and follow-up hints.`);
918
+ }
919
+
920
+ async function update(args = []) {
921
+ if (args.includes("-h") || args.includes("--help")) {
922
+ updateHelp();
923
+ return;
924
+ }
925
+
926
+ const { positional } = parseArgs(args);
927
+ const pkg = packageInfo();
928
+ const target = positional[0] || "latest";
929
+ const spec = packageSpec(pkg.name, target);
930
+ const npm = npmCommand();
931
+
932
+ console.log(`Current: ${pkg.name}@${pkg.version}`);
933
+
934
+ const view = spawnSync(npm, ["view", spec, "version"], {
935
+ encoding: "utf-8",
936
+ stdio: ["ignore", "pipe", "pipe"],
937
+ });
938
+ if (view.status === 0) {
939
+ const latest = view.stdout.trim();
940
+ if (latest) console.log(`Target: ${pkg.name}@${latest}`);
941
+ } else {
942
+ const detail = (view.stderr || view.stdout || "").trim();
943
+ console.log(`Target: ${spec}`);
944
+ if (detail) console.log(`npm view warning: ${detail.split("\n")[0]}`);
945
+ }
946
+
947
+ console.log(`Running: ${npm} install -g ${spec}`);
948
+ const installed = spawnSync(npm, ["install", "-g", spec], { stdio: "inherit" });
949
+ if (installed.error) throw installed.error;
950
+ if (installed.status !== 0) process.exit(installed.status ?? 1);
951
+
952
+ console.log("");
953
+ console.log("Update complete.");
954
+ console.log("");
955
+ console.log("Status:");
956
+ const statusCmd = sys === "win32" ? "browser-relay.cmd" : "browser-relay";
957
+ const checked = spawnSync(statusCmd, ["status"], { stdio: "inherit" });
958
+ if (checked.error || checked.status !== 0) {
959
+ console.log("Status check did not complete cleanly. Run: browser-relay status");
960
+ }
961
+
962
+ console.log("");
963
+ console.log("Next steps:");
964
+ console.log(" - If Chrome asks for new extension permissions, accept them.");
965
+ console.log(" - If the extension does not reconnect, reload it at chrome://extensions.");
966
+ console.log(" - If you installed the agent skill, run browser-relay skill help and reinstall for the active agent.");
967
+ }
968
+
969
+ function info() {
970
+ console.log(`Browser Relay`);
971
+ console.log(`Extension: ${EXTENSION_DIR}`);
972
+ console.log(`Skill: ${SKILL_PATH}`);
973
+ console.log(`Health: ${HEALTH_URL}`);
974
+ console.log(``);
975
+ console.log(`Load the extension:`);
976
+ console.log(` 1. Open chrome://extensions`);
977
+ console.log(` 2. Enable Developer mode`);
978
+ console.log(` 3. Click "Load unpacked" and select:`);
979
+ console.log(` ${EXTENSION_DIR}`);
980
+ }
981
+
982
+ function help() {
983
+ console.log(`browser-relay — universal browser control for AI agents
984
+
985
+ Usage:
986
+ browser-relay [command]
987
+
988
+ Commands:
989
+ (no args) Run the relay server in foreground
990
+ run Same as no args
991
+ hub Run a local Browser Relay Hub for remote-control testing
992
+ start Start as a background service (launchd/systemd/Task Scheduler)
993
+ stop Stop the background service
994
+ restart Restart the background service
995
+ fix Restart and clear stale session state (run when tabs won't connect)
996
+ update Update the global npm package and refresh the service
997
+ status Show service + HTTP health
998
+ doctor Check the full install → extension → tab → Skill path
999
+ logs Tail the service logs
1000
+ path Print the Chrome extension directory
1001
+ skill Install, locate, or print the Agent Skill install command
1002
+ info Show extension path + usage hints
1003
+ install (Re)register the background service
1004
+ uninstall Unregister the background service
1005
+
1006
+ Browser commands:
1007
+ read Read main content; --ref selects a subtree, --cursor continues without rereading
1008
+ focus Bring the selected tab and window to the foreground
1009
+ claim Claim a tab with --session <task>
1010
+ release Release a tab owned by --session <task>
1011
+ handoff Transfer a tab with --session <owner> --to <receiver>
1012
+ session start | list | heartbeat | stop | complete (use --session, optional --label)
1013
+ capabilities Verify the running extension protocol and build
1014
+ observe Accessibility snapshot with actionable refs; --diff --session <id>
1015
+ actions Execute ordered actions from --file or --stdin; optional --async
1016
+ task Read a task by id; --cancel stops pending actions
1017
+ exec Run trusted JavaScript from --file/--stdin (browser SDK available)
1018
+ repl Persistent JavaScript over NDJSON stdin, one {code} object per line
1019
+ new-tab Open a new background tab
1020
+ close-tab Close an explicit tab id
1021
+ tabs List attached Chrome tabs
1022
+ snapshot Print annotated page text
1023
+ wait Wait for a CSS selector to attach or become visible
1024
+ console Print captured console, page error, and browser log entries
1025
+ network Print captured Network.* request/response/failure entries
1026
+ click Click an element by CSS selector
1027
+ type Type text into an input or focused element
1028
+ key Press a key or keyboard shortcut
1029
+ scroll Scroll the page
1030
+ screenshot Save a PNG screenshot
1031
+ eval Evaluate JavaScript in the page
1032
+ download-start Start a Chrome download from a URL
1033
+ downloads List Chrome downloads and recent download events
1034
+ api-help Show browser command examples
1035
+
1036
+ --help,-h Show this help
1037
+ --version Show version
1038
+
1039
+ Env vars:
1040
+ BROWSER_RELAY_URL Relay base URL (default http://127.0.0.1:18795)
1041
+ BROWSER_RELAY_HOST Bind address (default 127.0.0.1)
1042
+ BROWSER_RELAY_PORT HTTP + WS port (default 18795)
1043
+ BROWSER_RELAY_REMOTE_DEVICE_ID Capability generated by the extension for remote control
1044
+ BROWSER_RELAY_REMOTE_HOST Remote hub URL (default https://relay.linso.ai)
1045
+ BROWSER_RELAY_HUB_PORT Local hub port for 'browser-relay hub' (default 18796)
1046
+ `);
1047
+ }
1048
+
1049
+ async function version() {
1050
+ const pkg = JSON.parse(readFileSync(join(PKG_DIR, "package.json"), "utf-8"));
1051
+ console.log(pkg.version);
1052
+ }
1053
+
1054
+ const BOOLEAN_FLAGS = new Set([
1055
+ "async", "diff", "cancel", "allowFocus", "includeNodes",
1056
+ "base64", "clear", "double", "doubleClick", "fullPage", "json",
1057
+ "raw", "saveAs", "stdin", "submit",
1058
+ ]);
1059
+
1060
+ const SHORT_FLAGS = {
1061
+ j: "json",
1062
+ o: "output",
1063
+ s: "selector",
1064
+ t: "tab",
1065
+ };
1066
+
1067
+ function camelFlag(name) {
1068
+ return name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
1069
+ }
1070
+
1071
+ function parseArgs(args) {
1072
+ const flags = {};
1073
+ const positional = [];
1074
+
1075
+ for (let i = 0; i < args.length; i++) {
1076
+ const arg = args[i];
1077
+ if (arg === "--") {
1078
+ positional.push(...args.slice(i + 1));
1079
+ break;
1080
+ }
1081
+
1082
+ if (arg.startsWith("--")) {
1083
+ const eq = arg.indexOf("=");
1084
+ const rawName = eq === -1 ? arg.slice(2) : arg.slice(2, eq);
1085
+ const name = camelFlag(rawName);
1086
+ let value;
1087
+ if (eq !== -1) {
1088
+ value = arg.slice(eq + 1);
1089
+ } else if (BOOLEAN_FLAGS.has(name) || BOOLEAN_FLAGS.has(rawName)) {
1090
+ value = true;
1091
+ } else if (args[i + 1] && !args[i + 1].startsWith("-")) {
1092
+ value = args[++i];
1093
+ } else {
1094
+ value = true;
1095
+ }
1096
+ flags[name] = value;
1097
+ continue;
1098
+ }
1099
+
1100
+ if (arg.startsWith("-") && arg.length === 2 && SHORT_FLAGS[arg[1]]) {
1101
+ const name = SHORT_FLAGS[arg[1]];
1102
+ if (BOOLEAN_FLAGS.has(name)) {
1103
+ flags[name] = true;
1104
+ } else if (args[i + 1]) {
1105
+ flags[name] = args[++i];
1106
+ } else {
1107
+ flags[name] = true;
1108
+ }
1109
+ continue;
1110
+ }
1111
+
1112
+ positional.push(arg);
1113
+ }
1114
+
1115
+ return { flags, positional };
1116
+ }
1117
+
1118
+ function flagValue(flags, ...names) {
1119
+ for (const name of names) {
1120
+ const key = camelFlag(name);
1121
+ if (flags[key] !== undefined && flags[key] !== true) return flags[key];
1122
+ }
1123
+ return undefined;
1124
+ }
1125
+
1126
+ function flagBool(flags, ...names) {
1127
+ return names.some((name) => {
1128
+ const value = flags[camelFlag(name)];
1129
+ return value === true || value === "true" || value === "1" || value === "yes";
1130
+ });
1131
+ }
1132
+
1133
+ function requireValue(value, message) {
1134
+ if (value === undefined || value === null || value === "") {
1135
+ throw new Error(message);
1136
+ }
1137
+ return value;
1138
+ }
1139
+
1140
+ function readInput(flags, positional, optionName, label) {
1141
+ if (flagBool(flags, "stdin")) return readFileSync(0, "utf-8");
1142
+ const file = flagValue(flags, "file");
1143
+ if (file) return readFileSync(file, "utf-8");
1144
+ const direct = flagValue(flags, optionName);
1145
+ if (direct !== undefined) return String(direct);
1146
+ if (positional.length) return positional.join(" ");
1147
+ throw new Error(`${label} is required`);
1148
+ }
1149
+
1150
+ function tabIdFrom(flags) {
1151
+ return flagValue(flags, "tab", "tab-id", "tabId");
1152
+ }
1153
+
1154
+ function addParam(params, name, value) {
1155
+ if (value !== undefined && value !== null && value !== "") params.set(name, String(value));
1156
+ }
1157
+
1158
+ class RelayRequestError extends Error {
1159
+ constructor(payload, fallbackMessage) {
1160
+ super(errorMessage(payload, fallbackMessage));
1161
+ this.name = "RelayRequestError";
1162
+ this.payload = payload;
1163
+ this.code = payload?.code;
1164
+ this.status = payload?.status;
1165
+ }
1166
+ }
1167
+
1168
+ function errorMessage(payload, fallback = "Command failed") {
1169
+ const message = payload?.message || payload?.error || fallback;
1170
+ return payload?.code ? `${payload.code}: ${message}` : String(message);
1171
+ }
1172
+
1173
+ function fallbackErrorPayload(message, options = {}) {
1174
+ return {
1175
+ ok: false,
1176
+ code: options.code || "request_failed",
1177
+ error: message,
1178
+ message,
1179
+ status: options.status ?? 0,
1180
+ retryable: options.retryable === true,
1181
+ };
1182
+ }
1183
+
1184
+ // ---- Remote aliases: save a short name for a long Device ID so commands read
1185
+ // `--remote mymac` instead of `--remote-device-id br-<secret>`. ----
1186
+ const REMOTES_PATH = join(homedir(), ".browser-relay", "remotes.json");
1187
+
1188
+ function readRemotes() {
1189
+ try {
1190
+ if (!existsSync(REMOTES_PATH)) return {};
1191
+ const data = JSON.parse(readFileSync(REMOTES_PATH, "utf8"));
1192
+ return data && typeof data === "object" ? data : {};
1193
+ } catch { return {}; }
1194
+ }
1195
+
1196
+ function writeRemotes(remotes) {
1197
+ const dir = dirname(REMOTES_PATH);
1198
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
1199
+ if (sys !== "win32") chmodSync(dir, 0o700);
1200
+ writeFileSync(REMOTES_PATH, JSON.stringify(remotes, null, 2) + "\n", { mode: 0o600 });
1201
+ if (sys !== "win32") chmodSync(REMOTES_PATH, 0o600);
1202
+ }
1203
+
1204
+ function shortDeviceId(id) {
1205
+ return "(redacted)";
1206
+ }
1207
+
1208
+ function publicRemotes(remotes) {
1209
+ return Object.fromEntries(Object.entries(remotes).map(([alias, entry]) => [alias, {
1210
+ maskedDeviceId: shortDeviceId(entry?.deviceId),
1211
+ host: entry?.host,
1212
+ }]));
1213
+ }
1214
+
1215
+ function remoteCommand(args) {
1216
+ const sub = args[0];
1217
+ const { flags, positional } = parseArgs(args.slice(1));
1218
+ const remotes = readRemotes();
1219
+
1220
+ if (sub === "add" || sub === "set") {
1221
+ const [alias, deviceId] = positional;
1222
+ if (!alias || !deviceId) {
1223
+ console.error("Usage: browser-relay remote add <alias> <device-id> [--remote-host <url>]");
1224
+ process.exit(1);
1225
+ }
1226
+ parseRemoteDeviceId(deviceId); // validates format, throws on bad id
1227
+ const host = remoteHttpBase(flagValue(flags, "remote-host", "remoteHost") || DEFAULT_REMOTE_HOST);
1228
+ remotes[alias] = { deviceId, host };
1229
+ writeRemotes(remotes);
1230
+ console.log(`Saved remote "${alias}" → ${host}`);
1231
+ console.log(`Use it: browser-relay tabs --remote ${alias}`);
1232
+ return;
1233
+ }
1234
+
1235
+ if (sub === "rm" || sub === "remove" || sub === "delete") {
1236
+ const alias = positional[0];
1237
+ if (!alias) { console.error("Usage: browser-relay remote rm <alias>"); process.exit(1); }
1238
+ if (!remotes[alias]) { console.error(`No remote named "${alias}"`); process.exit(1); }
1239
+ delete remotes[alias];
1240
+ writeRemotes(remotes);
1241
+ console.log(`Removed remote "${alias}"`);
1242
+ return;
1243
+ }
1244
+
1245
+ if (sub === undefined || sub === "ls" || sub === "list") {
1246
+ if (flagBool(flags, "json")) return printData(publicRemotes(remotes), true);
1247
+ const names = Object.keys(remotes);
1248
+ if (!names.length) {
1249
+ console.log("No saved remotes. Add one: browser-relay remote add <alias> <device-id>");
1250
+ return;
1251
+ }
1252
+ for (const name of names) console.log(`${name}\t${shortDeviceId(remotes[name].deviceId)}\t${remotes[name].host}`);
1253
+ return;
1254
+ }
1255
+
1256
+ console.error(`Unknown "remote" subcommand: ${sub}. Try: add | ls | rm`);
1257
+ process.exit(1);
1258
+ }
1259
+
1260
+ function remoteContextFrom(flags) {
1261
+ const input = flagValue(flags, "remote-device-id", "remoteDeviceId", "remote") || process.env.BROWSER_RELAY_REMOTE_DEVICE_ID;
1262
+ if (!input) return null;
1263
+ let host = flagValue(flags, "remote-host", "remoteHost");
1264
+
1265
+ // A saved alias wins; otherwise the value must be a full `br-` capability.
1266
+ let remoteDeviceId = input;
1267
+ const entry = readRemotes()[input];
1268
+ if (entry) {
1269
+ remoteDeviceId = entry.deviceId;
1270
+ if (!host && entry.host) host = entry.host;
1271
+ } else if (!/^br-/.test(input)) {
1272
+ throw new Error(`Unknown remote alias "${input}". Save it with: browser-relay remote add ${input} <device-id>`);
1273
+ }
1274
+
1275
+ const parsed = parseRemoteDeviceId(remoteDeviceId);
1276
+ host = host || process.env.BROWSER_RELAY_REMOTE_HOST || DEFAULT_REMOTE_HOST;
1277
+ return { ...parsed, host: remoteHttpBase(host) };
1278
+ }
1279
+
1280
+ function wantsJson(args) {
1281
+ return args.includes("--json") || args.includes("-j");
1282
+ }
1283
+
1284
+ async function relayRequest(method, path, body, options = {}) {
1285
+ const request=createTransport({url:RELAY_URL,remoteDeviceId:remoteContext?.remoteDeviceId || '',remoteHost:remoteContext?.host});
1286
+ try { return await request(method,path,body,options); }
1287
+ catch (err) {
1288
+ const payload=err.payload || fallbackErrorPayload(err.message);
1289
+ if(payload.code==='transport_error') {
1290
+ payload.code=remoteContext?'remote_hub_unreachable':'relay_unreachable';
1291
+ payload.message=`Cannot reach Browser Relay${remoteContext?' Hub':''}: ${payload.message}. Run: browser-relay start`;
1292
+ }
1293
+ throw new RelayRequestError(payload,payload.message);
1294
+ }
1295
+ }
1296
+
1297
+ function printData(data, json) {
1298
+ if (json) {
1299
+ console.log(JSON.stringify(data, null, 2));
1300
+ return;
1301
+ }
1302
+ if (typeof data === "string") console.log(data);
1303
+ else console.log(JSON.stringify(data, null, 2));
1304
+ }
1305
+
1306
+ function printTabs(data, json) {
1307
+ if (json) return printData(data, true);
1308
+ const tabs = data?.tabs || [];
1309
+ if (!tabs.length) {
1310
+ console.log("No attached tabs.");
1311
+ return;
1312
+ }
1313
+ for (const tab of tabs) {
1314
+ console.log(`${tab.id}\t${tab.title || "(untitled)"}\t${tab.url || ""}`);
1315
+ }
1316
+ }
1317
+
1318
+ function printConsole(data, json) {
1319
+ if (json) return printData(data, true);
1320
+ const entries = data?.entries || [];
1321
+ if (!entries.length) {
1322
+ console.log("No console entries.");
1323
+ return;
1324
+ }
1325
+ for (const entry of entries) {
1326
+ const time = entry.receivedAt || "";
1327
+ const level = entry.level || "log";
1328
+ const text = entry.text || "";
1329
+ const where = entry.tabId ? ` ${entry.tabId}` : "";
1330
+ console.log(`[${time}] ${level}${where} ${text}`);
1331
+ }
1332
+ }
1333
+
1334
+ function printNetwork(data, json) {
1335
+ if (json) return printData(data, true);
1336
+ const entries = data?.entries || [];
1337
+ if (!entries.length) {
1338
+ console.log("No network entries.");
1339
+ return;
1340
+ }
1341
+ for (const entry of entries) {
1342
+ const time = entry.receivedAt || "";
1343
+ const type = entry.type || "network";
1344
+ const method = entry.method || entry.request?.method || "";
1345
+ const status = entry.status ?? entry.response?.status ?? "";
1346
+ const url = entry.url || entry.request?.url || entry.response?.url || "";
1347
+ const details = [method, status].filter((v) => v !== "").join(" ");
1348
+ console.log(`[${time}] ${type}${details ? ` ${details}` : ""} ${url}`);
1349
+ }
1350
+ }
1351
+
1352
+ function printDownloads(data, json) {
1353
+ if (json) return printData(data, true);
1354
+ const downloads = data?.downloads || [];
1355
+ if (!downloads.length) {
1356
+ console.log("No downloads.");
1357
+ } else {
1358
+ for (const item of downloads) {
1359
+ const size = item.totalBytes ? `${item.bytesReceived || 0}/${item.totalBytes}` : `${item.bytesReceived || 0}`;
1360
+ console.log(`${item.id}\t${item.state || ""}\t${size}\t${item.filename || ""}\t${item.url || ""}`);
1361
+ }
1362
+ }
1363
+ const events = data?.events || [];
1364
+ if (events.length) {
1365
+ console.log(`Recent events: ${events.length}`);
1366
+ }
1367
+ }
1368
+
1369
+ function ensureOk(data, json = false) {
1370
+ if (data?.ok !== false) return;
1371
+ if (json) {
1372
+ printData(data, true);
1373
+ process.exit(1);
1374
+ }
1375
+ throw new RelayRequestError(data, "Command failed");
1376
+ }
1377
+
1378
+ function printCliError(err, args = []) {
1379
+ if (wantsJson(args) && err?.payload) {
1380
+ printData(err.payload, true);
1381
+ process.exit(1);
1382
+ }
1383
+ console.error(err instanceof Error ? err.message : String(err));
1384
+ process.exit(1);
1385
+ }
1386
+
1387
+ async function browserApiCommand(cmd, args) {
1388
+ const { flags, positional } = parseArgs(args);
1389
+ const json = flagBool(flags, "json");
1390
+ remoteContext = remoteContextFrom(flags);
1391
+
1392
+ switch (cmd) {
1393
+ case 'read':
1394
+ case 'observe': {
1395
+ const result=await relayRequest('POST',cmd==='read'?'/api/read':'/api/observe',{tabId:tabIdFrom(flags),sessionId:flagValue(flags,'session'),mode:flagValue(flags,'mode'),fullPage:flagBool(flags,'full-page'),cursor:flagValue(flags,'cursor'),target:flagValue(flags,'ref')?{ref:flagValue(flags,'ref')}:undefined,diff:flagBool(flags,'diff'),includeNodes:flagBool(flags,'include-nodes'),maxLength:flagValue(flags,'max-length')?Number(flagValue(flags,'max-length')):undefined});
1396
+ ensureOk(result,json);
1397
+ if(json)return printData(result,true);
1398
+ if(result.snapshot!==undefined)console.log(`${result.title}\n${result.url}\n${result.snapshot}`);
1399
+ const shot=result.screenshot || (result.format==='png'?result:null);
1400
+ if(shot){const dest=flagValue(flags,'output');if(!dest)throw new Error('Use --output <png> or --json for image observations');writeFileSync(dest,Buffer.from(shot.data,'base64'));const {data,...metadata}=shot;console.log(`Saved screenshot: ${dest}`);printData(metadata,true)}
1401
+ if(result.truncated)console.log(`\n[More content: ${result.nextCursor ? '--cursor '+result.nextCursor : 'select a smaller subtree with --ref'}]`);
1402
+ for(const warning of result.warnings||[])console.log(`[${warning.code}] ${warning.message}`);
1403
+ return;
1404
+ }
1405
+ case 'capabilities': return printData(await relayRequest('GET','/api/capabilities'),true);
1406
+ case 'focus':
1407
+ case 'claim':
1408
+ case 'release':
1409
+ case 'handoff': return printData(await relayRequest('POST',`/api/tabs/${cmd}`,{tabId:requireValue(tabIdFrom(flags)||positional[0],'tab id is required'),sessionId:flagValue(flags,'session'),toSessionId:flagValue(flags,'to'),label:flagValue(flags,'label')}),true);
1410
+ case 'session': return printData(await relayRequest(positional[0]==='list'?'GET':'POST','/api/sessions',positional[0]==='list'?undefined:{sessionId:requireValue(flagValue(flags,'session'),'--session is required'),action:positional[0],label:flagValue(flags,'label')}),true);
1411
+ case 'actions': {
1412
+ const actions=JSON.parse(readInput(flags,positional,'actions','actions JSON'));
1413
+ const result=await relayRequest('POST','/api/actions',{tabId:tabIdFrom(flags),actions,observe:flagValue(flags,'observe')||'snapshot',async:flagBool(flags,'async'),timeoutMs:flagValue(flags,'timeout')?Number(flagValue(flags,'timeout')):undefined,maxLength:flagValue(flags,'max-length')?Number(flagValue(flags,'max-length')):undefined,sessionId:flagValue(flags,'session')});
1414
+ ensureOk(result,json);return printData(result,true);
1415
+ }
1416
+ case 'task': {
1417
+ const id=requireValue(positional[0],'task id is required');
1418
+ return printData(await relayRequest(flagBool(flags,'cancel')?'POST':'GET',`/api/tasks/${encodeURIComponent(id)}${flagBool(flags,'cancel')?'/cancel':''}?sessionId=${encodeURIComponent(flagValue(flags,'session')||'')}`,flagBool(flags,'cancel')?{sessionId:flagValue(flags,'session')}:undefined),true);
1419
+ }
1420
+ case 'new-tab':return printData(await relayRequest('POST','/api/tabs/create',{url:positional[0]||'about:blank',sessionId:flagValue(flags,'session')}),true);
1421
+ case 'close-tab':return printData(await relayRequest('POST','/api/tabs/close',{tabId:requireValue(tabIdFrom(flags)||positional[0],'tab id is required'),sessionId:flagValue(flags,'session')}),true);
1422
+ case 'exec':
1423
+ case 'repl': {
1424
+ const runtime=createScriptRuntime({request:relayRequest,browserSessionId:flagValue(flags,'session')});
1425
+ const execute=async(input)=>{
1426
+ const result=await runtime.execute(input);
1427
+ // A one-shot exec closes its runtime below. Drain cached output before
1428
+ // closing so it never returns a continuation that has already expired.
1429
+ if(cmd==='exec') {
1430
+ while(result.runtimeOutput?.nextCursor && !result.runtimeOutput.code) {
1431
+ const cursor=result.runtimeOutput.nextCursor;
1432
+ result.content.pop(); // replace this page's continuation control block
1433
+ const page=await runtime.execute({code:`readOutput(${JSON.stringify(cursor)})`});
1434
+ result.content.push(...page.content);
1435
+ result.isError ||= page.isError;
1436
+ result.runtimeOutput=page.runtimeOutput;
1437
+ }
1438
+ }
1439
+ if(cmd==='repl')console.log(JSON.stringify(result));
1440
+ else if(json)printData(result,true);
1441
+ else for(const item of result.content) {
1442
+ if(item.type==='text')console.log(item.text);
1443
+ else {const dest=flagValue(flags,'output');if(!dest)throw new Error('Use --output <png> or --json to return script images');writeFileSync(dest,Buffer.from(item.data,'base64'));console.log(`Saved screenshot: ${dest}`);}
1444
+ }
1445
+ if(result.isError)process.exitCode=1;
1446
+ };
1447
+ try {
1448
+ if(cmd==='exec')await execute({code:readInput(flags,positional,'code','JavaScript code'),timeoutMs:flagValue(flags,'timeout')?Number(flagValue(flags,'timeout')):30000});
1449
+ else {const lines=createInterface({input:process.stdin});for await(const line of lines){if(!line.trim())continue;try{await execute(JSON.parse(line));}catch(error){console.log(JSON.stringify({isError:true,error:error.message}));}}}
1450
+ } finally {await runtime.close();}
1451
+ return;
1452
+ }
1453
+ case "debug": {
1454
+ return printData(await relayRequest("GET", "/api/debug"), true);
1455
+ }
1456
+ case "tabs":
1457
+ case "list": {
1458
+ return printTabs(await relayRequest("GET", "/api/tabs"), json);
1459
+ }
1460
+ case "console": {
1461
+ const params = new URLSearchParams();
1462
+ addParam(params, "tabId", tabIdFrom(flags));
1463
+ addParam(params, "level", flagValue(flags, "level"));
1464
+ addParam(params, "limit", flagValue(flags, "limit"));
1465
+ if (flagBool(flags, "clear")) params.set("clear", "true");
1466
+ const qs = params.toString();
1467
+ return printConsole(await relayRequest("GET", `/api/console${qs ? `?${qs}` : ""}`), json);
1468
+ }
1469
+ case "network": {
1470
+ if (flagBool(flags, "clear")) {
1471
+ const data = await relayRequest("POST", "/api/network/clear", {
1472
+ tabId: tabIdFrom(flags),
1473
+ type: flagValue(flags, "type"),
1474
+ method: flagValue(flags, "method"),
1475
+ status: flagValue(flags, "status"),
1476
+ requestId: flagValue(flags, "request-id", "requestId"),
1477
+ url: flagValue(flags, "url"),
1478
+ });
1479
+ ensureOk(data, json);
1480
+ if (json) return printData(data, true);
1481
+ console.log(`Cleared ${data.cleared || 0} network entries.`);
1482
+ return;
1483
+ }
1484
+ const params = new URLSearchParams();
1485
+ addParam(params, "tabId", tabIdFrom(flags));
1486
+ addParam(params, "type", flagValue(flags, "type"));
1487
+ addParam(params, "method", flagValue(flags, "method"));
1488
+ addParam(params, "status", flagValue(flags, "status"));
1489
+ addParam(params, "requestId", flagValue(flags, "request-id", "requestId"));
1490
+ addParam(params, "url", flagValue(flags, "url"));
1491
+ addParam(params, "limit", flagValue(flags, "limit"));
1492
+ const qs = params.toString();
1493
+ return printNetwork(await relayRequest("GET", `/api/network${qs ? `?${qs}` : ""}`), json);
1494
+ }
1495
+ case "navigate":
1496
+ case "go":
1497
+ case "open": {
1498
+ const url = requireValue(flagValue(flags, "url") || positional[0], "url is required");
1499
+ const data = await relayRequest("POST", "/api/navigate", { url, tabId: tabIdFrom(flags) });
1500
+ ensureOk(data, json);
1501
+ if (json) return printData(data, true);
1502
+ console.log(`${data.title || "(untitled)"}\n${data.url || url}`);
1503
+ return;
1504
+ }
1505
+ case "snapshot": {
1506
+ const params = new URLSearchParams();
1507
+ addParam(params, "tabId", tabIdFrom(flags));
1508
+ addParam(params, "format", flagValue(flags, "format") || "text");
1509
+ addParam(params, "maxLength", flagValue(flags, "max-length", "maxLength"));
1510
+ const qs = params.toString();
1511
+ const data = await relayRequest("GET", `/api/snapshot${qs ? `?${qs}` : ""}`);
1512
+ ensureOk(data, json);
1513
+ if (json) return printData(data, true);
1514
+ console.log(data.html ?? data.snapshot ?? "");
1515
+ return;
1516
+ }
1517
+ case "wait": {
1518
+ const selector = requireValue(flagValue(flags, "selector") || positional.join(" "), "selector is required");
1519
+ const timeoutMs = flagValue(flags, "timeout", "timeout-ms", "timeoutMs");
1520
+ const pollMs = flagValue(flags, "poll", "poll-ms", "pollMs");
1521
+ const data = await relayRequest("POST", "/api/wait", {
1522
+ selector,
1523
+ state: flagValue(flags, "state") || "visible",
1524
+ timeoutMs: timeoutMs === undefined ? undefined : Number(timeoutMs),
1525
+ pollMs: pollMs === undefined ? undefined : Number(pollMs),
1526
+ tabId: tabIdFrom(flags),
1527
+ });
1528
+ ensureOk(data, json);
1529
+ if (json) return printData(data, true);
1530
+ console.log(`Matched ${data.state}: ${data.selector} (${data.elapsedMs}ms, ${data.attempts} attempt${data.attempts === 1 ? "" : "s"})`);
1531
+ return;
1532
+ }
1533
+ case "click": {
1534
+ const selector = requireValue(flagValue(flags, "selector") || positional.join(" "), "selector is required");
1535
+ const data = await relayRequest("POST", "/api/click", {
1536
+ selector,
1537
+ tabId: tabIdFrom(flags),
1538
+ doubleClick: flagBool(flags, "double", "double-click", "doubleClick"),
1539
+ button: flagValue(flags, "button"),
1540
+ });
1541
+ ensureOk(data, json);
1542
+ if (json) return printData(data, true);
1543
+ console.log(`Clicked: ${data.elementText || selector}`);
1544
+ return;
1545
+ }
1546
+ case "type": {
1547
+ const text = readInput(flags, positional, "text", "text");
1548
+ const data = await relayRequest("POST", "/api/type", {
1549
+ text,
1550
+ selector: flagValue(flags, "selector"),
1551
+ tabId: tabIdFrom(flags),
1552
+ clear: flagBool(flags, "clear"),
1553
+ submit: flagBool(flags, "submit"),
1554
+ });
1555
+ ensureOk(data, json);
1556
+ if (json) return printData(data, true);
1557
+ console.log("Typed.");
1558
+ return;
1559
+ }
1560
+ case "key": {
1561
+ const combo = requireValue(flagValue(flags, "key", "combo") || positional.join("+"), "key or combo is required");
1562
+ const data = await relayRequest("POST", "/api/key", {
1563
+ combo,
1564
+ tabId: tabIdFrom(flags),
1565
+ ctrl: flagBool(flags, "ctrl", "control"),
1566
+ alt: flagBool(flags, "alt", "option"),
1567
+ shift: flagBool(flags, "shift"),
1568
+ meta: flagBool(flags, "meta", "cmd", "command"),
1569
+ text: flagValue(flags, "text"),
1570
+ });
1571
+ ensureOk(data, json);
1572
+ if (json) return printData(data, true);
1573
+ console.log(`Pressed: ${combo}`);
1574
+ return;
1575
+ }
1576
+ case "scroll": {
1577
+ const direction = flagValue(flags, "direction") || positional[0] || "down";
1578
+ const amount = flagValue(flags, "amount");
1579
+ const data = await relayRequest("POST", "/api/scroll", {
1580
+ direction,
1581
+ amount: amount === undefined ? undefined : Number(amount),
1582
+ tabId: tabIdFrom(flags),
1583
+ });
1584
+ ensureOk(data, json);
1585
+ if (json) return printData(data, true);
1586
+ console.log(`Scrolled: ${data.direction || direction}`);
1587
+ return;
1588
+ }
1589
+ case "screenshot": {
1590
+ const output = flagValue(flags, "output") || positional[0];
1591
+ const params = new URLSearchParams();
1592
+ addParam(params, "tabId", tabIdFrom(flags));
1593
+ if (flagBool(flags, "full-page", "fullPage")) params.set("fullPage", "true");
1594
+ const data = await relayRequest("GET", `/api/screenshot?${params.toString()}`);
1595
+ ensureOk(data, json);
1596
+ const buf = Buffer.from(data.data || "", "base64");
1597
+ if (json) return printData({ ...data, bytes: buf.length }, true);
1598
+ if (flagBool(flags, "base64")) {
1599
+ console.log(data.data || "");
1600
+ return;
1601
+ }
1602
+ if (flagBool(flags, "raw")) {
1603
+ process.stdout.write(buf);
1604
+ return;
1605
+ }
1606
+ if (!output) throw new Error("output file is required. Usage: browser-relay screenshot /tmp/page.png");
1607
+ writeFileSync(output, buf);
1608
+ console.log(`Saved screenshot: ${output} (${buf.length} bytes)`);
1609
+ return;
1610
+ }
1611
+ case "eval": {
1612
+ const expression = readInput(flags, positional, "expression", "expression");
1613
+ const data = await relayRequest("POST", "/api/eval", { expression, tabId: tabIdFrom(flags) });
1614
+ ensureOk(data, json);
1615
+ if (json) return printData(data, true);
1616
+ if (data.exceptionDetails) {
1617
+ console.error(JSON.stringify(data.exceptionDetails, null, 2));
1618
+ process.exit(1);
1619
+ }
1620
+ const result = data.result || {};
1621
+ if ("value" in result) {
1622
+ if (typeof result.value === "string") console.log(result.value);
1623
+ else console.log(JSON.stringify(result.value, null, 2));
1624
+ } else {
1625
+ console.log(JSON.stringify(result, null, 2));
1626
+ }
1627
+ return;
1628
+ }
1629
+ case "download": {
1630
+ const selector = requireValue(flagValue(flags, "selector") || positional.join(" "), "selector is required");
1631
+ const data = await relayRequest("POST", "/api/download", { selector, tabId: tabIdFrom(flags) });
1632
+ ensureOk(data, json);
1633
+ if (json) return printData(data, true);
1634
+ if (!data.found) throw new Error(`Element not found: ${selector}`);
1635
+ console.log(data.url || "");
1636
+ return;
1637
+ }
1638
+ case "download-start": {
1639
+ const url = requireValue(flagValue(flags, "url") || positional[0], "url is required");
1640
+ const data = await relayRequest("POST", "/api/download/start", {
1641
+ url,
1642
+ filename: flagValue(flags, "filename", "output"),
1643
+ saveAs: flagBool(flags, "save-as", "saveAs"),
1644
+ conflictAction: flagValue(flags, "conflict-action", "conflictAction"),
1645
+ });
1646
+ ensureOk(data, json);
1647
+ if (json) return printData(data, true);
1648
+ console.log(`Started download: ${data.downloadId ?? data.id}`);
1649
+ return;
1650
+ }
1651
+ case "downloads": {
1652
+ if (flagBool(flags, "clear")) {
1653
+ const data = await relayRequest("POST", "/api/downloads/clear", {});
1654
+ ensureOk(data, json);
1655
+ if (json) return printData(data, true);
1656
+ console.log(`Cleared ${data.cleared || 0} download events.`);
1657
+ return;
1658
+ }
1659
+ const params = new URLSearchParams();
1660
+ addParam(params, "id", flagValue(flags, "id"));
1661
+ addParam(params, "state", flagValue(flags, "state"));
1662
+ addParam(params, "url", flagValue(flags, "url"));
1663
+ addParam(params, "filename", flagValue(flags, "filename"));
1664
+ addParam(params, "query", flagValue(flags, "query"));
1665
+ addParam(params, "limit", flagValue(flags, "limit"));
1666
+ const qs = params.toString();
1667
+ return printDownloads(await relayRequest("GET", `/api/downloads${qs ? `?${qs}` : ""}`), json);
1668
+ }
1669
+ default:
1670
+ throw new Error(`Unknown command: ${cmd}`);
1671
+ }
1672
+ }
1673
+
1674
+ function apiHelp() {
1675
+ console.log(`Browser operation commands:
1676
+ tabs List attached Chrome tabs
1677
+ debug Show relay diagnostics
1678
+ console [--tab id] Print captured console/page errors
1679
+ network [--tab id] Print captured network events
1680
+ navigate <url> [--tab id] Navigate an attached tab
1681
+ snapshot [--tab id] Print annotated page text
1682
+ wait <selector> Wait for a selector (visible by default)
1683
+ click <selector> Click a CSS selector
1684
+ type <text> Type text into the focused element
1685
+ key <key|combo> Press a key or combo (Enter, Escape, Control+L)
1686
+ scroll [down|up|top|bottom] Scroll the page
1687
+ screenshot <file.png> Save a PNG screenshot
1688
+ eval <js> Evaluate JavaScript in the page
1689
+ download <selector> Print src/href for an element
1690
+ download-start <url> Start a Chrome download
1691
+ downloads [--limit n] List Chrome downloads and recent events
1692
+
1693
+ Common flags:
1694
+ --tab, -t <id> Target tab id from 'browser-relay tabs'
1695
+ --json, -j Print JSON response
1696
+ --remote-device-id <id> Control a browser that enabled External Control in the extension
1697
+ --remote-host <url> Remote hub URL (default https://relay.linso.ai)
1698
+ --level <level> Filter console entries by level
1699
+ --limit <n> Limit console entries
1700
+ --selector, -s <css> Selector for wait/click/type/download
1701
+ --state <attached|visible> Wait condition (default: visible)
1702
+ --timeout <ms> Wait timeout (default: 5000, max: 20000)
1703
+ --poll <ms> Wait polling interval (default: 100)
1704
+ --filename <path> Suggested download filename/path
1705
+ --save-as Ask Chrome to show the save-as dialog
1706
+ --conflict-action <action> uniquify, overwrite, or prompt
1707
+ --stdin Read text/expression from stdin
1708
+
1709
+ Examples:
1710
+ browser-relay tabs
1711
+ browser-relay console --limit 50
1712
+ browser-relay network --type response --status 500 --limit 20
1713
+ browser-relay snapshot --tab t_A7k2Pm9QxL --max-length 20000
1714
+ browser-relay wait 'button[type=submit]' --state visible --timeout 10000
1715
+ browser-relay click 'button[type=submit]'
1716
+ browser-relay type 'hello world' --selector 'input[name=q]' --clear --submit
1717
+ browser-relay key Control+L
1718
+ browser-relay download-start https://example.com/file.pdf --filename files/file.pdf
1719
+ browser-relay downloads --limit 20
1720
+ browser-relay screenshot /tmp/page.png --full-page
1721
+ browser-relay eval --stdin < script.js
1722
+ `);
1723
+ }
1724
+
1725
+ const cmd = process.argv[2];
1726
+
1727
+ switch (cmd) {
1728
+ case undefined:
1729
+ case "run":
1730
+ await run();
1731
+ break;
1732
+ case "hub":
1733
+ await hub();
1734
+ break;
1735
+ case "start": await start(); break;
1736
+ case "stop": await stop(); break;
1737
+ case "restart": await restart(); break;
1738
+ case "fix": await fix(); break;
1739
+ case "update": await update(process.argv.slice(3)); break;
1740
+ case "status": await status(); break;
1741
+ case "doctor": await doctor(process.argv.slice(3)); break;
1742
+ case "logs": logs(); break;
1743
+ case "path": path(); break;
1744
+ case "skill": skill(process.argv.slice(3)); break;
1745
+ case "info": info(); break;
1746
+ case "install": await install(); break;
1747
+ case "uninstall": await uninstall(); break;
1748
+ case "remote": remoteCommand(process.argv.slice(3)); break;
1749
+ case "tabs":
1750
+ case "read":
1751
+ case "capabilities":
1752
+ case "focus":
1753
+ case "claim":
1754
+ case "release":
1755
+ case "handoff":
1756
+ case "session":
1757
+ case "observe":
1758
+ case "actions":
1759
+ case "task":
1760
+ case "exec":
1761
+ case "repl":
1762
+ case "new-tab":
1763
+ case "close-tab":
1764
+ case "list":
1765
+ case "console":
1766
+ case "network":
1767
+ case "debug":
1768
+ case "navigate":
1769
+ case "go":
1770
+ case "open":
1771
+ case "snapshot":
1772
+ case "wait":
1773
+ case "click":
1774
+ case "type":
1775
+ case "key":
1776
+ case "scroll":
1777
+ case "screenshot":
1778
+ case "eval":
1779
+ case "download":
1780
+ case "download-start":
1781
+ case "downloads":
1782
+ try { await browserApiCommand(cmd, process.argv.slice(3)); }
1783
+ catch (err) { printCliError(err, process.argv.slice(3)); }
1784
+ break;
1785
+ case "-h":
1786
+ case "--help":
1787
+ case "help":
1788
+ help(); break;
1789
+ case "api-help":
1790
+ apiHelp(); break;
1791
+ case "-v":
1792
+ case "--version":
1793
+ await version(); break;
1794
+ default:
1795
+ console.error(`Unknown command: ${cmd}`);
1796
+ help();
1797
+ process.exit(1);
1798
+ }