chrome-relay 0.5.8 → 0.5.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,57 +1,264 @@
1
1
  #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+
12
+ // ../protocol/dist/index.js
13
+ var dist_exports = {};
14
+ __export(dist_exports, {
15
+ CHROME_WEB_STORE_EXTENSION_ID: () => CHROME_WEB_STORE_EXTENSION_ID,
16
+ DEFAULT_EXTENSION_ID: () => DEFAULT_EXTENSION_ID,
17
+ DEFAULT_EXTENSION_IDS: () => DEFAULT_EXTENSION_IDS,
18
+ DEFAULT_HTTP_PORT: () => DEFAULT_HTTP_PORT,
19
+ LEGACY_DEV_EXTENSION_ID: () => LEGACY_DEV_EXTENSION_ID,
20
+ LOCAL_UNPACKED_EXTENSION_ID: () => LOCAL_UNPACKED_EXTENSION_ID,
21
+ NATIVE_HOST_NAME: () => NATIVE_HOST_NAME,
22
+ RelayError: () => RelayError,
23
+ TOOL_NAMES: () => TOOL_NAMES,
24
+ toBridgeError: () => toBridgeError
25
+ });
26
+ function toBridgeError(unknownErr, fallbackTool) {
27
+ if (unknownErr instanceof RelayError) {
28
+ const e = unknownErr.toBridgeError();
29
+ return fallbackTool && !e.tool ? { ...e, tool: fallbackTool } : e;
30
+ }
31
+ const message = unknownErr instanceof Error ? unknownErr.message : String(unknownErr);
32
+ return {
33
+ code: "internal_error",
34
+ message,
35
+ ...fallbackTool ? { tool: fallbackTool } : {}
36
+ };
37
+ }
38
+ var NATIVE_HOST_NAME, DEFAULT_HTTP_PORT, CHROME_WEB_STORE_EXTENSION_ID, LEGACY_DEV_EXTENSION_ID, LOCAL_UNPACKED_EXTENSION_ID, DEFAULT_EXTENSION_ID, DEFAULT_EXTENSION_IDS, TOOL_NAMES, RelayError;
39
+ var init_dist = __esm({
40
+ "../protocol/dist/index.js"() {
41
+ "use strict";
42
+ NATIVE_HOST_NAME = "dev.chrome_relay.native_host";
43
+ DEFAULT_HTTP_PORT = 12122;
44
+ CHROME_WEB_STORE_EXTENSION_ID = "cpdiapbifblhlcpnmlmfpgfjlacebokb";
45
+ LEGACY_DEV_EXTENSION_ID = "cdmmkpadhnpcfjljhgpdnnljhjafmhop";
46
+ LOCAL_UNPACKED_EXTENSION_ID = "cleiodnaklknhhfopegimjelfibjmbkc";
47
+ DEFAULT_EXTENSION_ID = CHROME_WEB_STORE_EXTENSION_ID;
48
+ DEFAULT_EXTENSION_IDS = [
49
+ CHROME_WEB_STORE_EXTENSION_ID,
50
+ LEGACY_DEV_EXTENSION_ID,
51
+ LOCAL_UNPACKED_EXTENSION_ID
52
+ ];
53
+ TOOL_NAMES = {
54
+ GET_WINDOWS_AND_TABS: "get_windows_and_tabs",
55
+ NAVIGATE: "chrome_navigate",
56
+ SWITCH_TAB: "chrome_switch_tab",
57
+ CLOSE_TABS: "chrome_close_tabs",
58
+ SCREENSHOT: "chrome_screenshot",
59
+ READ_PAGE: "chrome_read_page",
60
+ CLICK: "chrome_click_element",
61
+ FILL: "chrome_fill_or_select",
62
+ KEYBOARD: "chrome_keyboard",
63
+ TYPE: "chrome_type",
64
+ EVALUATE: "chrome_evaluate",
65
+ // §2.2 — viewport emulation (set/preset/clear share one tool, action via args.action)
66
+ VIEWPORT: "chrome_viewport",
67
+ // chrome_self_reload — calls chrome.runtime.reload() inside the extension.
68
+ // Lets the dev loop refresh the extension without manually clicking reload
69
+ // on chrome://extensions (Chrome blocks CDP attach on chrome:// pages).
70
+ SELF_RELOAD: "chrome_self_reload",
71
+ // §2.7c — console capture. Ring-buffer per tab; actions read/clear via args.
72
+ CONSOLE: "chrome_console",
73
+ // Workspaces — named Chrome windows for parallel agent work. Single tool
74
+ // with action: create | list | close. Every existing tool also accepts an
75
+ // optional workspaceName arg that routes ops into that workspace's window.
76
+ // (Was "chrome_group" pre-0.4.0; renamed because "group" collides with
77
+ // Chrome's own tab-group UI primitive, which is now exposed separately.)
78
+ WORKSPACE: "chrome_workspace",
79
+ // Tab groups — Chrome's native colored, collapsible folder of tabs inside
80
+ // a single window. Single tool with action: create | list | close | add | remove.
81
+ // Every existing tool also accepts an optional groupName arg that routes
82
+ // ops to a tab inside that tab-group.
83
+ GROUP: "chrome_group",
84
+ // §2.4 — accessibility tree. ~30× smaller than DOM serialization, more
85
+ // semantic. click_ax pairs with it: targets by backendDOMNodeId, no CSS.
86
+ AX: "chrome_ax",
87
+ CLICK_AX: "chrome_click_ax",
88
+ // §2.7a — network capture. Ring-buffer per tab; actions read/clear/har/body.
89
+ NETWORK: "chrome_network",
90
+ // Hover — dispatches mouseMoved at element center (or x,y) so :hover/
91
+ // :focus-within styles fire before a click or screencast frame is read.
92
+ HOVER: "chrome_hover",
93
+ // Screencast — wraps CDP Page.startScreencast / stopScreencast. SW buffers
94
+ // base64 JPEG frames per tab between start and stop. Paint-driven (catches
95
+ // CSS transitions, fade-ins, focus-ring motion) — at the cost of requiring
96
+ // the tab to be ACTIVE (Chrome doesn't paint backgrounded tabs). See
97
+ // docs/recording.md for the active-tab matrix.
98
+ SCREENCAST: "chrome_screencast"
99
+ };
100
+ RelayError = class extends Error {
101
+ code;
102
+ tool;
103
+ phase;
104
+ details;
105
+ retryable;
106
+ constructor(spec) {
107
+ super(spec.message);
108
+ this.name = "RelayError";
109
+ this.code = spec.code;
110
+ this.tool = spec.tool;
111
+ this.phase = spec.phase;
112
+ this.details = spec.details;
113
+ this.retryable = spec.retryable;
114
+ }
115
+ toBridgeError() {
116
+ return {
117
+ code: this.code,
118
+ message: this.message,
119
+ ...this.tool ? { tool: this.tool } : {},
120
+ ...this.phase ? { phase: this.phase } : {},
121
+ ...this.details ? { details: this.details } : {},
122
+ ...this.retryable !== void 0 ? { retryable: this.retryable } : {}
123
+ };
124
+ }
125
+ };
126
+ }
127
+ });
2
128
 
3
129
  // src/program.ts
4
130
  import { Command } from "commander";
5
- import { writeFileSync } from "fs";
6
131
 
7
132
  // src/index.ts
8
- var CHROME_RELAY_VERSION = true ? "0.5.8" : "0.0.0-dev";
133
+ var CHROME_RELAY_VERSION = true ? "0.5.10" : "0.0.0-dev";
9
134
 
10
- // src/install/install.ts
11
- import os from "os";
12
- import path from "path";
13
- import { chmod, mkdir, readFile, stat, writeFile } from "fs/promises";
14
- import { fileURLToPath } from "url";
135
+ // src/commands/shared.ts
136
+ init_dist();
15
137
 
16
- // ../protocol/dist/index.js
17
- var NATIVE_HOST_NAME = "dev.chrome_relay.native_host";
18
- var DEFAULT_HTTP_PORT = 12122;
19
- var CHROME_WEB_STORE_EXTENSION_ID = "cpdiapbifblhlcpnmlmfpgfjlacebokb";
20
- var LEGACY_DEV_EXTENSION_ID = "cdmmkpadhnpcfjljhgpdnnljhjafmhop";
21
- var LOCAL_UNPACKED_EXTENSION_ID = "cleiodnaklknhhfopegimjelfibjmbkc";
22
- var DEFAULT_EXTENSION_IDS = [
23
- CHROME_WEB_STORE_EXTENSION_ID,
24
- LEGACY_DEV_EXTENSION_ID,
25
- LOCAL_UNPACKED_EXTENSION_ID
26
- ];
27
- var RelayError = class extends Error {
28
- code;
29
- tool;
30
- phase;
31
- details;
32
- retryable;
33
- constructor(spec) {
34
- super(spec.message);
35
- this.name = "RelayError";
36
- this.code = spec.code;
37
- this.tool = spec.tool;
38
- this.phase = spec.phase;
39
- this.details = spec.details;
40
- this.retryable = spec.retryable;
138
+ // src/client/call.ts
139
+ init_dist();
140
+ var noticePrinted = false;
141
+ function emitNoticeOnce(notice) {
142
+ if (noticePrinted) return;
143
+ noticePrinted = true;
144
+ process.stderr.write(`[chrome-relay] ${notice}
145
+ `);
146
+ }
147
+ async function callToolWithMeta(name, args) {
148
+ const response = await fetch(`http://127.0.0.1:${DEFAULT_HTTP_PORT}/call`, {
149
+ method: "POST",
150
+ headers: { "content-type": "application/json" },
151
+ body: JSON.stringify({
152
+ name,
153
+ args
154
+ })
155
+ });
156
+ const payload = await response.json().catch(() => null);
157
+ const noticeString = payload?.notice ?? payload?.notices?.[0]?.message;
158
+ if (!response.ok) {
159
+ if (noticeString) emitNoticeOnce(noticeString);
160
+ throw rebuildError(payload, `Bridge request failed with ${response.status}`);
41
161
  }
42
- toBridgeError() {
43
- return {
44
- code: this.code,
45
- message: this.message,
46
- ...this.tool ? { tool: this.tool } : {},
47
- ...this.phase ? { phase: this.phase } : {},
48
- ...this.details ? { details: this.details } : {},
49
- ...this.retryable !== void 0 ? { retryable: this.retryable } : {}
50
- };
162
+ if (!payload?.ok) {
163
+ if (noticeString) emitNoticeOnce(noticeString);
164
+ throw rebuildError(payload, "Bridge call failed.");
51
165
  }
52
- };
166
+ if (noticeString) emitNoticeOnce(noticeString);
167
+ return { data: payload.data, notice: payload.notice, notices: payload.notices };
168
+ }
169
+ function rebuildError(payload, fallbackMessage) {
170
+ if (payload?.errorDetails) {
171
+ return new RelayError(payload.errorDetails);
172
+ }
173
+ return new Error(payload?.error || fallbackMessage);
174
+ }
175
+ async function callTool(name, args) {
176
+ const { data } = await callToolWithMeta(name, args);
177
+ return data;
178
+ }
179
+
180
+ // src/commands/shared.ts
181
+ function tabOpt(cmd) {
182
+ return cmd.option("-t, --tab <id>", "target tab ID", (v) => Number(v)).option("--workspace <name>", "target the active tab in a named workspace window (see `chrome-relay workspace`)").option("--group <name>", "target the active tab in a named tab-group (see `chrome-relay group`)");
183
+ }
184
+ function makeBaseArgs(program) {
185
+ return function baseArgs(opts) {
186
+ const parentOpts = program.opts();
187
+ rejectIntraScopeConflict("subcommand", {
188
+ tab: opts.tab,
189
+ workspace: opts.workspace,
190
+ group: opts.group
191
+ });
192
+ rejectIntraScopeConflict("program-level", {
193
+ workspace: parentOpts.workspace,
194
+ group: parentOpts.group
195
+ });
196
+ if (opts.workspace && parentOpts.workspace && opts.workspace !== parentOpts.workspace) {
197
+ emitTargetOverride("workspace", parentOpts.workspace, opts.workspace);
198
+ }
199
+ if (opts.group && parentOpts.group && opts.group !== parentOpts.group) {
200
+ emitTargetOverride("group", parentOpts.group, opts.group);
201
+ }
202
+ if (opts.tab !== void 0 && (parentOpts.workspace || parentOpts.group)) {
203
+ const prior = parentOpts.workspace ? `workspace=${parentOpts.workspace}` : `group=${parentOpts.group}`;
204
+ emitTargetOverride("tab", prior, String(opts.tab));
205
+ }
206
+ const args = {};
207
+ if (opts.tab !== void 0) args.tabId = opts.tab;
208
+ const effectiveWorkspace = opts.workspace ?? parentOpts.workspace;
209
+ const effectiveGroup = opts.group ?? parentOpts.group;
210
+ if (opts.tab === void 0 && effectiveWorkspace) args.workspaceName = effectiveWorkspace;
211
+ if (opts.tab === void 0 && effectiveGroup) args.groupName = effectiveGroup;
212
+ return args;
213
+ };
214
+ }
215
+ function rejectIntraScopeConflict(scope, fields) {
216
+ const present = [];
217
+ if (fields.tab !== void 0) present.push("--tab");
218
+ if (fields.workspace) present.push("--workspace");
219
+ if (fields.group) present.push("--group");
220
+ if (present.length > 1) {
221
+ process.stderr.write(
222
+ `[chrome-relay] target_conflict: ${scope} flags ${present.join(" + ")} are mutually exclusive. Pass exactly one of --tab, --workspace, or --group on the same ${scope}.
223
+ `
224
+ );
225
+ process.exit(2);
226
+ }
227
+ }
228
+ function emitTargetOverride(kind, from, to) {
229
+ process.stderr.write(
230
+ `[chrome-relay] target_overridden: ${kind} ${from} \u2192 ${to} (subcommand-level overrides program-level)
231
+ `
232
+ );
233
+ }
234
+ async function runToolImpl(name, args) {
235
+ try {
236
+ const result = await callTool(name, args);
237
+ if (typeof result === "string") {
238
+ process.stdout.write(result + "\n");
239
+ } else {
240
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
241
+ }
242
+ } catch (error) {
243
+ if (error instanceof RelayError) {
244
+ process.stderr.write(error.message + "\n");
245
+ process.stderr.write(JSON.stringify({ relayError: error.toBridgeError() }, null, 2) + "\n");
246
+ } else {
247
+ process.stderr.write(
248
+ (error instanceof Error ? error.message : String(error)) + "\n"
249
+ );
250
+ }
251
+ process.exit(1);
252
+ }
253
+ }
254
+ var runTool = runToolImpl;
53
255
 
54
256
  // src/install/install.ts
257
+ init_dist();
258
+ import os from "os";
259
+ import path from "path";
260
+ import { chmod, mkdir, readFile, stat, writeFile } from "fs/promises";
261
+ import { fileURLToPath } from "url";
55
262
  var APP_DIR = path.join(os.homedir(), ".chrome-relay");
56
263
  var KNOWN_EXTENSION_IDS = [
57
264
  ["Chrome Web Store", CHROME_WEB_STORE_EXTENSION_ID],
@@ -155,49 +362,21 @@ async function runDoctor() {
155
362
  }
156
363
  }
157
364
 
158
- // src/client/call.ts
159
- var noticePrinted = false;
160
- function emitNoticeOnce(notice) {
161
- if (noticePrinted) return;
162
- noticePrinted = true;
163
- process.stderr.write(`[chrome-relay] ${notice}
164
- `);
165
- }
166
- async function callToolWithMeta(name, args) {
167
- const response = await fetch(`http://127.0.0.1:${DEFAULT_HTTP_PORT}/call`, {
168
- method: "POST",
169
- headers: { "content-type": "application/json" },
170
- body: JSON.stringify({
171
- name,
172
- args
173
- })
174
- });
175
- const payload = await response.json().catch(() => null);
176
- const noticeString = payload?.notice ?? payload?.notices?.[0]?.message;
177
- if (!response.ok) {
178
- if (noticeString) emitNoticeOnce(noticeString);
179
- throw rebuildError(payload, `Bridge request failed with ${response.status}`);
180
- }
181
- if (!payload?.ok) {
182
- if (noticeString) emitNoticeOnce(noticeString);
183
- throw rebuildError(payload, "Bridge call failed.");
184
- }
185
- if (noticeString) emitNoticeOnce(noticeString);
186
- return { data: payload.data, notice: payload.notice, notices: payload.notices };
187
- }
188
- function rebuildError(payload, fallbackMessage) {
189
- if (payload?.errorDetails) {
190
- return new RelayError(payload.errorDetails);
191
- }
192
- return new Error(payload?.error || fallbackMessage);
193
- }
194
- async function callTool(name, args) {
195
- const { data } = await callToolWithMeta(name, args);
196
- return data;
197
- }
198
-
199
365
  // src/release-notes.ts
200
366
  var RELEASE_NOTES = {
367
+ "0.5.10": [
368
+ "Direct /call target conflict enforcement. Third-party callers posting to /call with multiple loose target fields (tabId + workspaceName, etc.) now throw `target_conflict` instead of silently applying precedence. Matches the CLI rule the CLI itself enforced since 0.5.4.",
369
+ "All useful plain Error throws in extension handlers converted to RelayError(invalid_arguments). Affected tools: chrome_click_element, chrome_fill_or_select, chrome_keyboard, chrome_type, chrome_evaluate, chrome_switch_tab, chrome_close_tabs, chrome_viewport (preset name + width/height), chrome_workspace (create/close), chrome_group (create/close/add/remove), chrome_network (body without --request-id), chrome_hover (no selector or x,y), chrome_click_ax (no --node), and bbox parser. Agents can now branch on `errorDetails.code === 'invalid_arguments'` for all of these.",
370
+ "chrome_hover with a selector that doesn't match now throws RelayError(`element_not_found`) (was plain Error).",
371
+ "BEHAVIOR CHANGE \u2014 `chrome-relay screencast stop --gif/--mp4` is no longer best-effort when ffmpeg is missing. Old behavior printed 'skipping' and exited 0 (agent saw success but no GIF existed). New behavior throws `external_dependency_missing` with exit 1. Pass `--allow-missing-ffmpeg` to restore the legacy skip-with-warning behavior.",
372
+ "Tests: +17 in handler-strict.test.ts covering the conflict + 13 missing-arg paths. Total now 372."
373
+ ],
374
+ "0.5.9": [
375
+ "Internal refactor (code-quality-hardening PR 7): program.ts and tools.ts split into per-domain modules. Pure code motion, no behavior change.",
376
+ "CLI: packages/cli/src/program.ts shrank from 1041 \u2192 75 lines. Per-domain modules now live in packages/cli/src/commands/{install-update,navigation,input,capture,sessions}.ts.",
377
+ "Extension: apps/extension/src/browser/tools.ts shrank from 891 \u2192 34 lines. Per-domain handler modules live in apps/extension/src/browser/handlers/{target,navigation,input,capture,sessions}.ts.",
378
+ "All 355 tests still pass without modification \u2014 the dispatcher contract (runTool name dispatch) is unchanged."
379
+ ],
201
380
  "0.5.8": [
202
381
  "Internal refactor (code-quality-hardening PR 6, first cut): shared CLI helpers moved out of program.ts into packages/cli/src/commands/shared.ts.",
203
382
  "tabOpt(), makeBaseArgs(program), and runTool() are now importable from `./commands/shared.js`. program.ts dropped ~100 lines.",
@@ -273,104 +452,8 @@ function listReleaseNotesSince(since) {
273
452
  return Object.keys(RELEASE_NOTES).filter((v) => compareSemver(v, since) > 0).sort((a, b) => compareSemver(a, b)).map((version) => ({ version, bullets: RELEASE_NOTES[version] }));
274
453
  }
275
454
 
276
- // src/commands/shared.ts
277
- function tabOpt(cmd) {
278
- return cmd.option("-t, --tab <id>", "target tab ID", (v) => Number(v)).option("--workspace <name>", "target the active tab in a named workspace window (see `chrome-relay workspace`)").option("--group <name>", "target the active tab in a named tab-group (see `chrome-relay group`)");
279
- }
280
- function makeBaseArgs(program) {
281
- return function baseArgs(opts) {
282
- const parentOpts = program.opts();
283
- rejectIntraScopeConflict("subcommand", {
284
- tab: opts.tab,
285
- workspace: opts.workspace,
286
- group: opts.group
287
- });
288
- rejectIntraScopeConflict("program-level", {
289
- workspace: parentOpts.workspace,
290
- group: parentOpts.group
291
- });
292
- if (opts.workspace && parentOpts.workspace && opts.workspace !== parentOpts.workspace) {
293
- emitTargetOverride("workspace", parentOpts.workspace, opts.workspace);
294
- }
295
- if (opts.group && parentOpts.group && opts.group !== parentOpts.group) {
296
- emitTargetOverride("group", parentOpts.group, opts.group);
297
- }
298
- if (opts.tab !== void 0 && (parentOpts.workspace || parentOpts.group)) {
299
- const prior = parentOpts.workspace ? `workspace=${parentOpts.workspace}` : `group=${parentOpts.group}`;
300
- emitTargetOverride("tab", prior, String(opts.tab));
301
- }
302
- const args = {};
303
- if (opts.tab !== void 0) args.tabId = opts.tab;
304
- const effectiveWorkspace = opts.workspace ?? parentOpts.workspace;
305
- const effectiveGroup = opts.group ?? parentOpts.group;
306
- if (opts.tab === void 0 && effectiveWorkspace) args.workspaceName = effectiveWorkspace;
307
- if (opts.tab === void 0 && effectiveGroup) args.groupName = effectiveGroup;
308
- return args;
309
- };
310
- }
311
- function rejectIntraScopeConflict(scope, fields) {
312
- const present = [];
313
- if (fields.tab !== void 0) present.push("--tab");
314
- if (fields.workspace) present.push("--workspace");
315
- if (fields.group) present.push("--group");
316
- if (present.length > 1) {
317
- process.stderr.write(
318
- `[chrome-relay] target_conflict: ${scope} flags ${present.join(" + ")} are mutually exclusive. Pass exactly one of --tab, --workspace, or --group on the same ${scope}.
319
- `
320
- );
321
- process.exit(2);
322
- }
323
- }
324
- function emitTargetOverride(kind, from, to) {
325
- process.stderr.write(
326
- `[chrome-relay] target_overridden: ${kind} ${from} \u2192 ${to} (subcommand-level overrides program-level)
327
- `
328
- );
329
- }
330
- async function runTool(name, args) {
331
- try {
332
- const result = await callTool(name, args);
333
- if (typeof result === "string") {
334
- process.stdout.write(result + "\n");
335
- } else {
336
- process.stdout.write(JSON.stringify(result, null, 2) + "\n");
337
- }
338
- } catch (error) {
339
- if (error instanceof RelayError) {
340
- process.stderr.write(error.message + "\n");
341
- process.stderr.write(JSON.stringify({ relayError: error.toBridgeError() }, null, 2) + "\n");
342
- } else {
343
- process.stderr.write(
344
- (error instanceof Error ? error.message : String(error)) + "\n"
345
- );
346
- }
347
- process.exit(1);
348
- }
349
- }
350
-
351
- // src/program.ts
352
- function buildProgram() {
353
- const program = new Command();
354
- program.name("chrome-relay").description("Connect your local Chrome browser to coding agents through a local bridge.").version(CHROME_RELAY_VERSION).showHelpAfterError().option("--workspace <name>", "target the active tab in a named workspace window (works at top level too)").option("--group <name>", "target the active tab in a named tab-group (works at top level too)").enablePositionalOptions().addHelpText(
355
- "after",
356
- `
357
-
358
- Common agent flow:
359
- chrome-relay tabs
360
- chrome-relay navigate --tab <tabId> "https://example.com"
361
- chrome-relay read --tab <tabId> -i
362
- chrome-relay click --tab <tabId> "<selector>"
363
- chrome-relay fill --tab <tabId> "<selector>" "value"
364
- chrome-relay type --tab <tabId> -s "<selector>" "text into rich editor"
365
- chrome-relay keys --tab <tabId> Enter
366
- chrome-relay js --tab <tabId> "return document.title"
367
- chrome-relay screenshot --tab <tabId> -o evidence.png
368
-
369
- Notes:
370
- navigate takes a URL. Use --tab to target an existing tab.
371
- Tools attach via CDP and run on backgrounded tabs without stealing focus.
372
- `
373
- );
455
+ // src/commands/install-update.ts
456
+ function registerInstallUpdate(program) {
374
457
  program.command("install").description("Install and register the local Chrome Relay host.").action(async () => {
375
458
  await runInstall();
376
459
  });
@@ -459,8 +542,11 @@ Notes:
459
542
  changes
460
543
  }, null, 2) + "\n");
461
544
  });
462
- const run = runTool;
463
- const baseArgs = makeBaseArgs(program);
545
+ }
546
+
547
+ // src/commands/navigation.ts
548
+ function registerNavigation(ctx) {
549
+ const { program, baseArgs, run } = ctx;
464
550
  program.command("tabs [verb]").description("List open Chrome windows and tabs. (verb 'list' is accepted as alias)").action(async (verb) => {
465
551
  if (verb && verb !== "list") {
466
552
  process.stderr.write(`unknown tabs verb: ${verb}. Use 'tabs' or 'tabs list'.
@@ -495,6 +581,127 @@ Use "chrome-relay switch ${url}" to activate that tab, or "chrome-relay navigate
495
581
  if (opts.inactive) args.active = false;
496
582
  await run("chrome_navigate", args);
497
583
  });
584
+ program.command("switch <tabId>").description("Activate a tab by ID.").action(async (tabId) => {
585
+ await run("chrome_switch_tab", { tabId: Number(tabId) });
586
+ });
587
+ program.command("close <tabIds...>").description("Close one or more tabs by ID.").action(async (tabIds) => {
588
+ await run("chrome_close_tabs", { tabIds: tabIds.map(Number) });
589
+ });
590
+ program.command("call <tool> [json]").description("Call any Chrome Relay tool with raw JSON args.").action(async (tool, json) => {
591
+ const args = json ? JSON.parse(json) : {};
592
+ await run(tool, args);
593
+ });
594
+ }
595
+
596
+ // src/commands/input.ts
597
+ function registerInput(ctx) {
598
+ const { program, baseArgs, run } = ctx;
599
+ tabOpt(
600
+ program.command("click <selector>").description("Click an element by CSS selector.")
601
+ ).action(async (selector, opts) => {
602
+ const args = { selector };
603
+ Object.assign(args, baseArgs(opts));
604
+ await run("chrome_click_element", args);
605
+ });
606
+ tabOpt(
607
+ program.command("fill <selector> <value>").description("Fill an input or textarea.")
608
+ ).action(async (selector, value, opts) => {
609
+ const args = { selector, value };
610
+ Object.assign(args, baseArgs(opts));
611
+ await run("chrome_fill_or_select", args);
612
+ });
613
+ tabOpt(
614
+ program.command("keys <keys>").description("Press a single key or chord via trusted CDP input (e.g. Enter, Cmd+K).").addHelpText(
615
+ "after",
616
+ `
617
+
618
+ Examples:
619
+ chrome-relay keys Enter
620
+ chrome-relay keys Tab
621
+ chrome-relay keys Cmd+K
622
+ chrome-relay keys Shift+ArrowDown
623
+
624
+ For typing text into a field, use \`chrome-relay type\` instead.
625
+ `
626
+ )
627
+ ).action(async (keys, opts) => {
628
+ const args = { keys };
629
+ Object.assign(args, baseArgs(opts));
630
+ await run("chrome_keyboard", args);
631
+ });
632
+ tabOpt(
633
+ program.command("type <text>").description("Insert text via trusted CDP input. Works in contenteditable / Draft.js / Lexical.").option("-s, --selector <selector>", "focus this element first").addHelpText(
634
+ "after",
635
+ `
636
+
637
+ Examples:
638
+ chrome-relay type --selector "[data-testid=tweetTextarea_0]" "hello world"
639
+ chrome-relay type "appended into already-focused element"
640
+
641
+ When to pick which:
642
+ fill \u2014 plain <input>, <textarea>, <select>, React-controlled inputs (atomic write).
643
+ type \u2014 contenteditable, Draft.js, Lexical, ProseMirror (trusted text commit).
644
+ keys \u2014 Enter, Tab, Esc, arrows, modifier chords (single key press).
645
+ js \u2014 anything else.
646
+ `
647
+ )
648
+ ).action(async (text, opts) => {
649
+ const args = { text };
650
+ Object.assign(args, baseArgs(opts));
651
+ if (opts.selector) args.selector = opts.selector;
652
+ await run("chrome_type", args);
653
+ });
654
+ tabOpt(
655
+ program.command("js <code>").description("Evaluate JavaScript in the page MAIN world. Use `return` for the value.").option("--timeout-ms <ms>", "execution timeout in milliseconds (default 15000)", (v) => Number(v)).addHelpText(
656
+ "after",
657
+ `
658
+
659
+ Examples:
660
+ chrome-relay js "return document.title"
661
+ chrome-relay js "return await fetch('/api/me').then(r => r.json())"
662
+ chrome-relay js --tab 12345 "return document.querySelectorAll('article').length"
663
+
664
+ Notes:
665
+ Code is wrapped in an async IIFE. Top-level await works. Use \`return\` to send a value back.
666
+ Returned value is JSON-serialized. DOM nodes and functions become {}.
667
+ Runs in MAIN world: page globals, framework internals, and shadow roots are reachable.
668
+ `
669
+ )
670
+ ).action(async (code, opts) => {
671
+ const args = { code };
672
+ Object.assign(args, baseArgs(opts));
673
+ if (typeof opts.timeoutMs === "number") args.timeoutMs = opts.timeoutMs;
674
+ await run("chrome_evaluate", args);
675
+ });
676
+ tabOpt(
677
+ program.command("hover [selector]").description("Move the pointer over an element or coordinates. Fires :hover styles.").option("--x <px>", "explicit x coordinate (CSS pixels)", (v) => Number(v)).option("--y <px>", "explicit y coordinate (CSS pixels)", (v) => Number(v)).addHelpText(
678
+ "after",
679
+ `
680
+
681
+ Examples:
682
+ chrome-relay hover --tab 123 'button[title="Install runner"]'
683
+ chrome-relay hover --tab 123 --x 1327 --y 771
684
+
685
+ Use before screencast to capture hover-driven micro-states (button glow,
686
+ tooltip appearance, etc.) that a bare click would skip past too quickly.
687
+ `
688
+ )
689
+ ).action(async (selector, opts) => {
690
+ const args = {};
691
+ Object.assign(args, baseArgs(opts));
692
+ if (selector) args.selector = selector;
693
+ if (typeof opts.x === "number" && typeof opts.y === "number") {
694
+ args.x = opts.x;
695
+ args.y = opts.y;
696
+ }
697
+ await run("chrome_hover", args);
698
+ });
699
+ }
700
+
701
+ // src/commands/capture.ts
702
+ import { writeFileSync } from "fs";
703
+ function registerCapture(ctx) {
704
+ const { program, baseArgs, run } = ctx;
498
705
  tabOpt(
499
706
  program.command("screenshot").description("Capture a screenshot of any tab without activating it.").option("--full", "capture beyond the viewport (full page)").option("--bbox <rect>", "capture a region: 'x,y,width,height' (pixels)").option("--selector <css>", "capture the bounding box of a CSS selector").option("--padding <px>", "pixels of padding around --selector region", (v) => Number(v)).option("--max-edge <px>", "downscale so longer edge \u2264 this many pixels (no default; opt-in)", (v) => Number(v)).option("-o, --out <path>", "save image to path (base64 PNG decoded)").addHelpText(
500
707
  "after",
@@ -549,92 +756,217 @@ full-tab screenshot when an agent only needs to see one component.
549
756
  await run("chrome_read_page", args);
550
757
  });
551
758
  tabOpt(
552
- program.command("click <selector>").description("Click an element by CSS selector.")
553
- ).action(async (selector, opts) => {
554
- const args = { selector };
555
- Object.assign(args, baseArgs(opts));
556
- await run("chrome_click_element", args);
557
- });
558
- tabOpt(
559
- program.command("fill <selector> <value>").description("Fill an input or textarea.")
560
- ).action(async (selector, value, opts) => {
561
- const args = { selector, value };
562
- Object.assign(args, baseArgs(opts));
563
- await run("chrome_fill_or_select", args);
564
- });
565
- tabOpt(
566
- program.command("keys <keys>").description("Press a single key or chord via trusted CDP input (e.g. Enter, Cmd+K).").addHelpText(
759
+ program.command("ax").description("Extract the accessibility tree \u2014 ~30\xD7 smaller than `read` and more semantic.").option("-i, --interactive-only", "filter to actionable roles (button, link, textbox, ...)").option("--root <role>", "start from the first node matching this role (e.g. 'main')").option("--include-subframes", "walk subframes too (default: top frame only)").addHelpText(
567
760
  "after",
568
761
  `
569
762
 
570
763
  Examples:
571
- chrome-relay keys Enter
572
- chrome-relay keys Tab
573
- chrome-relay keys Cmd+K
574
- chrome-relay keys Shift+ArrowDown
764
+ chrome-relay ax --tab 123
765
+ chrome-relay ax --tab 123 --interactive-only
766
+ chrome-relay ax --tab 123 --root main --interactive-only
575
767
 
576
- For typing text into a field, use \`chrome-relay type\` instead.
768
+ Notes:
769
+ Each node carries an "id" \u2014 that's the backendDOMNodeId. Pass it to
770
+ \`chrome-relay click-ax --node <id>\` to click without a CSS selector.
577
771
  `
578
772
  )
579
- ).action(async (keys, opts) => {
580
- const args = { keys };
581
- Object.assign(args, baseArgs(opts));
582
- await run("chrome_keyboard", args);
773
+ ).action(async (opts) => {
774
+ const args = baseArgs(opts);
775
+ if (opts.interactiveOnly) args.interactiveOnly = true;
776
+ if (opts.root) args.rootRole = opts.root;
777
+ if (opts.includeSubframes) args.includeSubframes = true;
778
+ await run("chrome_ax", args);
583
779
  });
584
780
  tabOpt(
585
- program.command("type <text>").description("Insert text via trusted CDP input. Works in contenteditable / Draft.js / Lexical.").option("-s, --selector <selector>", "focus this element first").addHelpText(
781
+ program.command("click-ax").description("Click an element by its backendDOMNodeId from a previous `ax` call.").requiredOption("--node <id>", "backendDOMNodeId from `chrome-relay ax`", (v) => Number(v)).addHelpText(
586
782
  "after",
587
783
  `
588
784
 
589
785
  Examples:
590
- chrome-relay type --selector "[data-testid=tweetTextarea_0]" "hello world"
591
- chrome-relay type "appended into already-focused element"
786
+ chrome-relay click-ax --tab 123 --node 456
592
787
 
593
- When to pick which:
594
- fill \u2014 plain <input>, <textarea>, <select>, React-controlled inputs (atomic write).
595
- type \u2014 contenteditable, Draft.js, Lexical, ProseMirror (trusted text commit).
596
- keys \u2014 Enter, Tab, Esc, arrows, modifier chords (single key press).
597
- js \u2014 anything else.
788
+ Notes:
789
+ Throws explicitly if the node id is stale (page mutated since you called
790
+ \`ax\`). Re-run \`ax\` and pass the fresh id.
598
791
  `
599
792
  )
600
- ).action(async (text, opts) => {
601
- const args = { text };
793
+ ).action(async (opts) => {
794
+ const args = baseArgs(opts);
795
+ args.node = opts.node;
796
+ await run("chrome_click_ax", args);
797
+ });
798
+ const screencast = program.command("screencast").description("Record a tab via CDP (paint-driven). Requires an active tab.").addHelpText(
799
+ "after",
800
+ `
801
+
802
+ Examples:
803
+ chrome-relay screencast start --tab 123 --quality 80 --max-width 900
804
+ # ... drive the interaction (hover, click, etc.) ...
805
+ chrome-relay screencast stop --tab 123 --out /tmp/recording
806
+
807
+ # The --out path becomes a directory of frame_NNNN.jpg files. If ffmpeg
808
+ # is on PATH and --gif is also passed, an animated GIF is written next to
809
+ # the frames at /tmp/recording.gif.
810
+
811
+ Notes:
812
+ Frames buffer in the extension service worker. A 10-second capture at
813
+ default settings (jpeg q=60, ~15fps, full viewport) lands ~2-3 MB.
814
+ Pass --max-width to downscale and lighten the buffer.
815
+ Each frame is base64 JPEG; the CLI decodes them when --out is given.
816
+ `
817
+ );
818
+ tabOpt(
819
+ screencast.command("start").description("Begin screencast capture on a tab.").option("--format <fmt>", "jpeg | png (default jpeg)").option("--quality <n>", "jpeg quality 0-100 (default 80)", (v) => Number(v)).option("--max-width <px>", "downscale; aspect preserved", (v) => Number(v)).option("--max-height <px>", "downscale; aspect preserved", (v) => Number(v)).option("--every-nth <n>", "throttle: keep 1 in N frames (default 1)", (v) => Number(v))
820
+ ).action(async (opts) => {
821
+ const args = { action: "start" };
602
822
  Object.assign(args, baseArgs(opts));
603
- if (opts.selector) args.selector = opts.selector;
604
- await run("chrome_type", args);
823
+ if (opts.format) args.format = opts.format;
824
+ if (typeof opts.quality === "number") args.quality = opts.quality;
825
+ if (typeof opts.maxWidth === "number") args.maxWidth = opts.maxWidth;
826
+ if (typeof opts.maxHeight === "number") args.maxHeight = opts.maxHeight;
827
+ if (typeof opts.everyNth === "number") args.everyNthFrame = opts.everyNth;
828
+ await run("chrome_screencast", args);
829
+ });
830
+ tabOpt(
831
+ screencast.command("stop").description("Stop the screencast and emit frames (or write to disk).").option("-o, --out <dir>", "write frames as JPEGs into this directory (created if missing)").option("--gif", "after writing frames, ffmpeg them into <dir>.gif (requires ffmpeg on PATH)").option("--mp4", "after writing frames, ffmpeg them into <dir>.mp4 (requires ffmpeg on PATH)").option("--fps <n>", "assumed framerate when invoking ffmpeg (default 15)", (v) => Number(v)).option("--no-dedupe", "keep raw frames; default collapses consecutive identical frames via SHA-256").option("--allow-missing-ffmpeg", "with --gif/--mp4: skip ffmpeg step (and emit a warning) if ffmpeg isn't on PATH, instead of failing with external_dependency_missing")
832
+ ).action(async (opts) => {
833
+ const args = { action: "stop" };
834
+ Object.assign(args, baseArgs(opts));
835
+ try {
836
+ const result = await callTool("chrome_screencast", args);
837
+ if (!opts.out) {
838
+ const { frames, ...summary } = result;
839
+ process.stdout.write(JSON.stringify({ ...summary, framesOmitted: frames.length, hint: "pass --out <dir> to save" }, null, 2) + "\n");
840
+ return;
841
+ }
842
+ const { mkdirSync, writeFileSync: wf, renameSync, unlinkSync } = await import("fs");
843
+ const path2 = await import("path");
844
+ const { createHash } = await import("crypto");
845
+ mkdirSync(opts.out, { recursive: true });
846
+ result.frames.forEach((f, i) => {
847
+ const name = `frame_${String(i + 1).padStart(4, "0")}.jpg`;
848
+ wf(path2.join(opts.out, name), Buffer.from(f.data, "base64"));
849
+ });
850
+ process.stdout.write(`Wrote ${result.frames.length} frames to ${opts.out}
851
+ `);
852
+ const dedupeOn = opts.dedupe !== false;
853
+ if (dedupeOn && result.frames.length > 1) {
854
+ const hashes = result.frames.map(
855
+ (f) => createHash("sha256").update(Buffer.from(f.data, "base64")).digest("hex")
856
+ );
857
+ const kept = [];
858
+ let prev = "";
859
+ hashes.forEach((h, i) => {
860
+ if (h !== prev) kept.push(i);
861
+ prev = h;
862
+ });
863
+ const dropped = result.frames.length - kept.length;
864
+ if (dropped > 0) {
865
+ for (let i = 0; i < result.frames.length; i++) {
866
+ const src = path2.join(opts.out, `frame_${String(i + 1).padStart(4, "0")}.jpg`);
867
+ try {
868
+ unlinkSync(src);
869
+ } catch {
870
+ }
871
+ }
872
+ kept.forEach((srcIdx, newIdx) => {
873
+ const tmp = path2.join(opts.out, `tmp_${String(newIdx + 1).padStart(4, "0")}.jpg`);
874
+ wf(tmp, Buffer.from(result.frames[srcIdx].data, "base64"));
875
+ });
876
+ kept.forEach((_, newIdx) => {
877
+ const tmp = path2.join(opts.out, `tmp_${String(newIdx + 1).padStart(4, "0")}.jpg`);
878
+ const final = path2.join(opts.out, `frame_${String(newIdx + 1).padStart(4, "0")}.jpg`);
879
+ renameSync(tmp, final);
880
+ });
881
+ process.stdout.write(`Deduped: dropped ${dropped} identical frames, ${kept.length} remain.
882
+ `);
883
+ } else {
884
+ process.stdout.write(`Deduped: no consecutive duplicates found.
885
+ `);
886
+ }
887
+ }
888
+ if (opts.gif || opts.mp4) {
889
+ const fps = typeof opts.fps === "number" ? opts.fps : 15;
890
+ const { spawnSync } = await import("child_process");
891
+ const which = spawnSync("which", ["ffmpeg"]);
892
+ if (which.status !== 0) {
893
+ if (opts.allowMissingFfmpeg) {
894
+ process.stderr.write("[chrome-relay] ffmpeg not on PATH \u2014 skipping --gif/--mp4 (allow-missing-ffmpeg).\n");
895
+ return;
896
+ }
897
+ const { RelayError: RelayError2 } = await Promise.resolve().then(() => (init_dist(), dist_exports));
898
+ const err = new RelayError2({
899
+ code: "external_dependency_missing",
900
+ message: "chrome-relay screencast stop --gif/--mp4 requires ffmpeg on PATH. Install ffmpeg (brew install ffmpeg) or pass --allow-missing-ffmpeg to skip the stitch step with a warning.",
901
+ tool: "chrome_screencast",
902
+ phase: "ffmpeg_stitch",
903
+ details: { dependency: "ffmpeg", outputs: { gif: !!opts.gif, mp4: !!opts.mp4 } },
904
+ retryable: false
905
+ });
906
+ process.stderr.write(err.message + "\n");
907
+ process.stderr.write(JSON.stringify({ relayError: err.toBridgeError() }, null, 2) + "\n");
908
+ process.exit(1);
909
+ }
910
+ if (opts.gif) {
911
+ const gifOut = `${opts.out.replace(/\/$/, "")}.gif`;
912
+ const r = spawnSync("ffmpeg", [
913
+ "-y",
914
+ "-framerate",
915
+ String(fps),
916
+ "-i",
917
+ path2.join(opts.out, "frame_%04d.jpg"),
918
+ "-vf",
919
+ `fps=${fps},split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse`,
920
+ "-loop",
921
+ "0",
922
+ gifOut
923
+ ], { stdio: "inherit" });
924
+ if (r.status === 0) process.stdout.write(`Wrote ${gifOut}
925
+ `);
926
+ }
927
+ if (opts.mp4) {
928
+ const mp4Out = `${opts.out.replace(/\/$/, "")}.mp4`;
929
+ const r = spawnSync("ffmpeg", [
930
+ "-y",
931
+ "-framerate",
932
+ String(fps),
933
+ "-i",
934
+ path2.join(opts.out, "frame_%04d.jpg"),
935
+ "-c:v",
936
+ "libx264",
937
+ "-pix_fmt",
938
+ "yuv420p",
939
+ "-crf",
940
+ "20",
941
+ mp4Out
942
+ ], { stdio: "inherit" });
943
+ if (r.status === 0) process.stdout.write(`Wrote ${mp4Out}
944
+ `);
945
+ }
946
+ }
947
+ } catch (error) {
948
+ process.stderr.write(
949
+ (error instanceof Error ? error.message : String(error)) + "\n"
950
+ );
951
+ process.exit(1);
952
+ }
605
953
  });
606
- tabOpt(
607
- program.command("js <code>").description("Evaluate JavaScript in the page MAIN world. Use `return` for the value.").option("--timeout-ms <ms>", "execution timeout in milliseconds (default 15000)", (v) => Number(v)).addHelpText(
608
- "after",
609
- `
610
-
611
- Examples:
612
- chrome-relay js "return document.title"
613
- chrome-relay js "return await fetch('/api/me').then(r => r.json())"
614
- chrome-relay js --tab 12345 "return document.querySelectorAll('article').length"
954
+ }
615
955
 
616
- Notes:
617
- Code is wrapped in an async IIFE. Top-level await works. Use \`return\` to send a value back.
618
- Returned value is JSON-serialized. DOM nodes and functions become {}.
619
- Runs in MAIN world: page globals, framework internals, and shadow roots are reachable.
620
- `
621
- )
622
- ).action(async (code, opts) => {
623
- const args = { code };
624
- Object.assign(args, baseArgs(opts));
625
- if (typeof opts.timeoutMs === "number") args.timeoutMs = opts.timeoutMs;
626
- await run("chrome_evaluate", args);
627
- });
628
- program.command("switch <tabId>").description("Activate a tab by ID.").action(async (tabId) => {
629
- await run("chrome_switch_tab", { tabId: Number(tabId) });
630
- });
631
- program.command("close <tabIds...>").description("Close one or more tabs by ID.").action(async (tabIds) => {
632
- await run("chrome_close_tabs", { tabIds: tabIds.map(Number) });
633
- });
634
- program.command("call <tool> [json]").description("Call any Chrome Relay tool with raw JSON args.").action(async (tool, json) => {
635
- const args = json ? JSON.parse(json) : {};
636
- await run(tool, args);
637
- });
956
+ // src/commands/sessions.ts
957
+ function netFilterOpts(cmd) {
958
+ return cmd.option("--filter <substr>", "url substring filter").option("--status <bucket>", "ok | redirect | client_error | server_error | failed").option("--method <verb>", "exact method, e.g. POST").option("--limit <n>", "cap response length", (v) => Number(v));
959
+ }
960
+ function netFilterArgs(opts) {
961
+ const a = {};
962
+ if (opts.filter) a.filter = opts.filter;
963
+ if (opts.status) a.status = opts.status;
964
+ if (opts.method) a.method = opts.method;
965
+ if (typeof opts.limit === "number") a.limit = opts.limit;
966
+ return a;
967
+ }
968
+ function registerSessions(ctx) {
969
+ const { program, baseArgs, run } = ctx;
638
970
  const viewport = program.command("viewport").description("Emulate device viewport, DPR, mobile flag, touch, and user agent.").addHelpText(
639
971
  "after",
640
972
  `
@@ -683,46 +1015,6 @@ Notes:
683
1015
  program.command("self-reload").description("Restart the chrome-relay extension's service worker (picks up newly built code).").action(async () => {
684
1016
  await run("chrome_self_reload", {});
685
1017
  });
686
- tabOpt(
687
- program.command("ax").description("Extract the accessibility tree \u2014 ~30\xD7 smaller than `read` and more semantic.").option("-i, --interactive-only", "filter to actionable roles (button, link, textbox, ...)").option("--root <role>", "start from the first node matching this role (e.g. 'main')").option("--include-subframes", "walk subframes too (default: top frame only)").addHelpText(
688
- "after",
689
- `
690
-
691
- Examples:
692
- chrome-relay ax --tab 123
693
- chrome-relay ax --tab 123 --interactive-only
694
- chrome-relay ax --tab 123 --root main --interactive-only
695
-
696
- Notes:
697
- Each node carries an "id" \u2014 that's the backendDOMNodeId. Pass it to
698
- \`chrome-relay click-ax --node <id>\` to click without a CSS selector.
699
- `
700
- )
701
- ).action(async (opts) => {
702
- const args = baseArgs(opts);
703
- if (opts.interactiveOnly) args.interactiveOnly = true;
704
- if (opts.root) args.rootRole = opts.root;
705
- if (opts.includeSubframes) args.includeSubframes = true;
706
- await run("chrome_ax", args);
707
- });
708
- tabOpt(
709
- program.command("click-ax").description("Click an element by its backendDOMNodeId from a previous `ax` call.").requiredOption("--node <id>", "backendDOMNodeId from `chrome-relay ax`", (v) => Number(v)).addHelpText(
710
- "after",
711
- `
712
-
713
- Examples:
714
- chrome-relay click-ax --tab 123 --node 456
715
-
716
- Notes:
717
- Throws explicitly if the node id is stale (page mutated since you called
718
- \`ax\`). Re-run \`ax\` and pass the fresh id.
719
- `
720
- )
721
- ).action(async (opts) => {
722
- const args = baseArgs(opts);
723
- args.node = opts.node;
724
- await run("chrome_click_ax", args);
725
- });
726
1018
  const workspace = program.command("workspace").description("Manage named Chrome windows so multiple agents can drive separate windows.").addHelpText(
727
1019
  "after",
728
1020
  `
@@ -796,17 +1088,6 @@ Notes:
796
1088
  const tabIds = String(opts.tabs).split(",").map((s) => Number(s.trim())).filter(Number.isFinite);
797
1089
  await run("chrome_group", { action: "remove", tabIds });
798
1090
  });
799
- function netFilterOpts(cmd) {
800
- return cmd.option("--filter <substr>", "url substring filter").option("--status <bucket>", "ok | redirect | client_error | server_error | failed").option("--method <verb>", "exact method, e.g. POST").option("--limit <n>", "cap response length", (v) => Number(v));
801
- }
802
- function netFilterArgs(opts) {
803
- const a = {};
804
- if (opts.filter) a.filter = opts.filter;
805
- if (opts.status) a.status = opts.status;
806
- if (opts.method) a.method = opts.method;
807
- if (typeof opts.limit === "number") a.limit = opts.limit;
808
- return a;
809
- }
810
1091
  const network = tabOpt(netFilterOpts(
811
1092
  program.command("network").description("Capture HTTP request/response metadata. Ring buffer, last 200 per tab.")
812
1093
  )).addHelpText(
@@ -896,171 +1177,41 @@ Notes:
896
1177
  if (typeof opts.limit === "number") args.limit = opts.limit;
897
1178
  await run("chrome_console", args);
898
1179
  });
899
- tabOpt(
900
- program.command("hover [selector]").description("Move the pointer over an element or coordinates. Fires :hover styles.").option("--x <px>", "explicit x coordinate (CSS pixels)", (v) => Number(v)).option("--y <px>", "explicit y coordinate (CSS pixels)", (v) => Number(v)).addHelpText(
901
- "after",
902
- `
903
-
904
- Examples:
905
- chrome-relay hover --tab 123 'button[title="Install runner"]'
906
- chrome-relay hover --tab 123 --x 1327 --y 771
1180
+ }
907
1181
 
908
- Use before screencast to capture hover-driven micro-states (button glow,
909
- tooltip appearance, etc.) that a bare click would skip past too quickly.
910
- `
911
- )
912
- ).action(async (selector, opts) => {
913
- const args = {};
914
- Object.assign(args, baseArgs(opts));
915
- if (selector) args.selector = selector;
916
- if (typeof opts.x === "number" && typeof opts.y === "number") {
917
- args.x = opts.x;
918
- args.y = opts.y;
919
- }
920
- await run("chrome_hover", args);
921
- });
922
- const screencast = program.command("screencast").description("Record a tab via CDP (paint-driven). Requires an active tab.").addHelpText(
1182
+ // src/program.ts
1183
+ function buildProgram() {
1184
+ const program = new Command();
1185
+ program.name("chrome-relay").description("Connect your local Chrome browser to coding agents through a local bridge.").version(CHROME_RELAY_VERSION).showHelpAfterError().option("--workspace <name>", "target the active tab in a named workspace window (works at top level too)").option("--group <name>", "target the active tab in a named tab-group (works at top level too)").enablePositionalOptions().addHelpText(
923
1186
  "after",
924
1187
  `
925
1188
 
926
- Examples:
927
- chrome-relay screencast start --tab 123 --quality 80 --max-width 900
928
- # ... drive the interaction (hover, click, etc.) ...
929
- chrome-relay screencast stop --tab 123 --out /tmp/recording
930
-
931
- # The --out path becomes a directory of frame_NNNN.jpg files. If ffmpeg
932
- # is on PATH and --gif is also passed, an animated GIF is written next to
933
- # the frames at /tmp/recording.gif.
1189
+ Common agent flow:
1190
+ chrome-relay tabs
1191
+ chrome-relay navigate --tab <tabId> "https://example.com"
1192
+ chrome-relay read --tab <tabId> -i
1193
+ chrome-relay click --tab <tabId> "<selector>"
1194
+ chrome-relay fill --tab <tabId> "<selector>" "value"
1195
+ chrome-relay type --tab <tabId> -s "<selector>" "text into rich editor"
1196
+ chrome-relay keys --tab <tabId> Enter
1197
+ chrome-relay js --tab <tabId> "return document.title"
1198
+ chrome-relay screenshot --tab <tabId> -o evidence.png
934
1199
 
935
1200
  Notes:
936
- Frames buffer in the extension service worker. A 10-second capture at
937
- default settings (jpeg q=60, ~15fps, full viewport) lands ~2-3 MB.
938
- Pass --max-width to downscale and lighten the buffer.
939
- Each frame is base64 JPEG; the CLI decodes them when --out is given.
1201
+ navigate takes a URL. Use --tab to target an existing tab.
1202
+ Tools attach via CDP and run on backgrounded tabs without stealing focus.
940
1203
  `
941
1204
  );
942
- tabOpt(
943
- screencast.command("start").description("Begin screencast capture on a tab.").option("--format <fmt>", "jpeg | png (default jpeg)").option("--quality <n>", "jpeg quality 0-100 (default 80)", (v) => Number(v)).option("--max-width <px>", "downscale; aspect preserved", (v) => Number(v)).option("--max-height <px>", "downscale; aspect preserved", (v) => Number(v)).option("--every-nth <n>", "throttle: keep 1 in N frames (default 1)", (v) => Number(v))
944
- ).action(async (opts) => {
945
- const args = { action: "start" };
946
- Object.assign(args, baseArgs(opts));
947
- if (opts.format) args.format = opts.format;
948
- if (typeof opts.quality === "number") args.quality = opts.quality;
949
- if (typeof opts.maxWidth === "number") args.maxWidth = opts.maxWidth;
950
- if (typeof opts.maxHeight === "number") args.maxHeight = opts.maxHeight;
951
- if (typeof opts.everyNth === "number") args.everyNthFrame = opts.everyNth;
952
- await run("chrome_screencast", args);
953
- });
954
- tabOpt(
955
- screencast.command("stop").description("Stop the screencast and emit frames (or write to disk).").option("-o, --out <dir>", "write frames as JPEGs into this directory (created if missing)").option("--gif", "after writing frames, ffmpeg them into <dir>.gif").option("--mp4", "after writing frames, ffmpeg them into <dir>.mp4").option("--fps <n>", "assumed framerate when invoking ffmpeg (default 15)", (v) => Number(v)).option("--no-dedupe", "keep raw frames; default collapses consecutive identical frames via SHA-256")
956
- ).action(async (opts) => {
957
- const args = { action: "stop" };
958
- Object.assign(args, baseArgs(opts));
959
- try {
960
- const result = await callTool("chrome_screencast", args);
961
- if (!opts.out) {
962
- const { frames, ...summary } = result;
963
- process.stdout.write(JSON.stringify({ ...summary, framesOmitted: frames.length, hint: "pass --out <dir> to save" }, null, 2) + "\n");
964
- return;
965
- }
966
- const { mkdirSync, writeFileSync: writeFileSync2, renameSync, unlinkSync } = await import("fs");
967
- const path2 = await import("path");
968
- const { createHash } = await import("crypto");
969
- mkdirSync(opts.out, { recursive: true });
970
- result.frames.forEach((f, i) => {
971
- const name = `frame_${String(i + 1).padStart(4, "0")}.jpg`;
972
- writeFileSync2(path2.join(opts.out, name), Buffer.from(f.data, "base64"));
973
- });
974
- process.stdout.write(`Wrote ${result.frames.length} frames to ${opts.out}
975
- `);
976
- const dedupeOn = opts.dedupe !== false;
977
- if (dedupeOn && result.frames.length > 1) {
978
- const hashes = result.frames.map(
979
- (f) => createHash("sha256").update(Buffer.from(f.data, "base64")).digest("hex")
980
- );
981
- const kept = [];
982
- let prev = "";
983
- hashes.forEach((h, i) => {
984
- if (h !== prev) kept.push(i);
985
- prev = h;
986
- });
987
- const dropped = result.frames.length - kept.length;
988
- if (dropped > 0) {
989
- for (let i = 0; i < result.frames.length; i++) {
990
- const src = path2.join(opts.out, `frame_${String(i + 1).padStart(4, "0")}.jpg`);
991
- try {
992
- unlinkSync(src);
993
- } catch {
994
- }
995
- }
996
- kept.forEach((srcIdx, newIdx) => {
997
- const tmp = path2.join(opts.out, `tmp_${String(newIdx + 1).padStart(4, "0")}.jpg`);
998
- writeFileSync2(tmp, Buffer.from(result.frames[srcIdx].data, "base64"));
999
- });
1000
- kept.forEach((_, newIdx) => {
1001
- const tmp = path2.join(opts.out, `tmp_${String(newIdx + 1).padStart(4, "0")}.jpg`);
1002
- const final = path2.join(opts.out, `frame_${String(newIdx + 1).padStart(4, "0")}.jpg`);
1003
- renameSync(tmp, final);
1004
- });
1005
- process.stdout.write(`Deduped: dropped ${dropped} identical frames, ${kept.length} remain.
1006
- `);
1007
- } else {
1008
- process.stdout.write(`Deduped: no consecutive duplicates found.
1009
- `);
1010
- }
1011
- }
1012
- if (opts.gif || opts.mp4) {
1013
- const fps = typeof opts.fps === "number" ? opts.fps : 15;
1014
- const { spawnSync } = await import("child_process");
1015
- const which = spawnSync("which", ["ffmpeg"]);
1016
- if (which.status !== 0) {
1017
- process.stderr.write("[chrome-relay] ffmpeg not on PATH \u2014 skipping --gif/--mp4.\n");
1018
- return;
1019
- }
1020
- if (opts.gif) {
1021
- const gifOut = `${opts.out.replace(/\/$/, "")}.gif`;
1022
- const r = spawnSync("ffmpeg", [
1023
- "-y",
1024
- "-framerate",
1025
- String(fps),
1026
- "-i",
1027
- path2.join(opts.out, "frame_%04d.jpg"),
1028
- "-vf",
1029
- `fps=${fps},split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse`,
1030
- "-loop",
1031
- "0",
1032
- gifOut
1033
- ], { stdio: "inherit" });
1034
- if (r.status === 0) process.stdout.write(`Wrote ${gifOut}
1035
- `);
1036
- }
1037
- if (opts.mp4) {
1038
- const mp4Out = `${opts.out.replace(/\/$/, "")}.mp4`;
1039
- const r = spawnSync("ffmpeg", [
1040
- "-y",
1041
- "-framerate",
1042
- String(fps),
1043
- "-i",
1044
- path2.join(opts.out, "frame_%04d.jpg"),
1045
- "-c:v",
1046
- "libx264",
1047
- "-pix_fmt",
1048
- "yuv420p",
1049
- "-crf",
1050
- "20",
1051
- mp4Out
1052
- ], { stdio: "inherit" });
1053
- if (r.status === 0) process.stdout.write(`Wrote ${mp4Out}
1054
- `);
1055
- }
1056
- }
1057
- } catch (error) {
1058
- process.stderr.write(
1059
- (error instanceof Error ? error.message : String(error)) + "\n"
1060
- );
1061
- process.exit(1);
1062
- }
1063
- });
1205
+ const ctx = {
1206
+ program,
1207
+ baseArgs: makeBaseArgs(program),
1208
+ run: runTool
1209
+ };
1210
+ registerInstallUpdate(program);
1211
+ registerNavigation(ctx);
1212
+ registerInput(ctx);
1213
+ registerCapture(ctx);
1214
+ registerSessions(ctx);
1064
1215
  return program;
1065
1216
  }
1066
1217
 
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/index.ts
2
- var CHROME_RELAY_VERSION = true ? "0.5.8" : "0.0.0-dev";
2
+ var CHROME_RELAY_VERSION = true ? "0.5.10" : "0.0.0-dev";
3
3
  export {
4
4
  CHROME_RELAY_VERSION
5
5
  };
@@ -48,7 +48,7 @@ function toBridgeError(unknownErr, fallbackTool) {
48
48
  }
49
49
 
50
50
  // src/index.ts
51
- var CHROME_RELAY_VERSION = true ? "0.5.8" : "0.0.0-dev";
51
+ var CHROME_RELAY_VERSION = true ? "0.5.10" : "0.0.0-dev";
52
52
 
53
53
  // src/release-notes.ts
54
54
  function compareSemver(a, b) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chrome-relay",
3
- "version": "0.5.8",
3
+ "version": "0.5.10",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",