chrome-relay 0.5.8 → 0.5.9

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
@@ -2,16 +2,9 @@
2
2
 
3
3
  // src/program.ts
4
4
  import { Command } from "commander";
5
- import { writeFileSync } from "fs";
6
5
 
7
6
  // src/index.ts
8
- var CHROME_RELAY_VERSION = true ? "0.5.8" : "0.0.0-dev";
9
-
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";
7
+ var CHROME_RELAY_VERSION = true ? "0.5.9" : "0.0.0-dev";
15
8
 
16
9
  // ../protocol/dist/index.js
17
10
  var NATIVE_HOST_NAME = "dev.chrome_relay.native_host";
@@ -51,7 +44,128 @@ var RelayError = class extends Error {
51
44
  }
52
45
  };
53
46
 
47
+ // src/client/call.ts
48
+ var noticePrinted = false;
49
+ function emitNoticeOnce(notice) {
50
+ if (noticePrinted) return;
51
+ noticePrinted = true;
52
+ process.stderr.write(`[chrome-relay] ${notice}
53
+ `);
54
+ }
55
+ async function callToolWithMeta(name, args) {
56
+ const response = await fetch(`http://127.0.0.1:${DEFAULT_HTTP_PORT}/call`, {
57
+ method: "POST",
58
+ headers: { "content-type": "application/json" },
59
+ body: JSON.stringify({
60
+ name,
61
+ args
62
+ })
63
+ });
64
+ const payload = await response.json().catch(() => null);
65
+ const noticeString = payload?.notice ?? payload?.notices?.[0]?.message;
66
+ if (!response.ok) {
67
+ if (noticeString) emitNoticeOnce(noticeString);
68
+ throw rebuildError(payload, `Bridge request failed with ${response.status}`);
69
+ }
70
+ if (!payload?.ok) {
71
+ if (noticeString) emitNoticeOnce(noticeString);
72
+ throw rebuildError(payload, "Bridge call failed.");
73
+ }
74
+ if (noticeString) emitNoticeOnce(noticeString);
75
+ return { data: payload.data, notice: payload.notice, notices: payload.notices };
76
+ }
77
+ function rebuildError(payload, fallbackMessage) {
78
+ if (payload?.errorDetails) {
79
+ return new RelayError(payload.errorDetails);
80
+ }
81
+ return new Error(payload?.error || fallbackMessage);
82
+ }
83
+ async function callTool(name, args) {
84
+ const { data } = await callToolWithMeta(name, args);
85
+ return data;
86
+ }
87
+
88
+ // src/commands/shared.ts
89
+ function tabOpt(cmd) {
90
+ 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`)");
91
+ }
92
+ function makeBaseArgs(program) {
93
+ return function baseArgs(opts) {
94
+ const parentOpts = program.opts();
95
+ rejectIntraScopeConflict("subcommand", {
96
+ tab: opts.tab,
97
+ workspace: opts.workspace,
98
+ group: opts.group
99
+ });
100
+ rejectIntraScopeConflict("program-level", {
101
+ workspace: parentOpts.workspace,
102
+ group: parentOpts.group
103
+ });
104
+ if (opts.workspace && parentOpts.workspace && opts.workspace !== parentOpts.workspace) {
105
+ emitTargetOverride("workspace", parentOpts.workspace, opts.workspace);
106
+ }
107
+ if (opts.group && parentOpts.group && opts.group !== parentOpts.group) {
108
+ emitTargetOverride("group", parentOpts.group, opts.group);
109
+ }
110
+ if (opts.tab !== void 0 && (parentOpts.workspace || parentOpts.group)) {
111
+ const prior = parentOpts.workspace ? `workspace=${parentOpts.workspace}` : `group=${parentOpts.group}`;
112
+ emitTargetOverride("tab", prior, String(opts.tab));
113
+ }
114
+ const args = {};
115
+ if (opts.tab !== void 0) args.tabId = opts.tab;
116
+ const effectiveWorkspace = opts.workspace ?? parentOpts.workspace;
117
+ const effectiveGroup = opts.group ?? parentOpts.group;
118
+ if (opts.tab === void 0 && effectiveWorkspace) args.workspaceName = effectiveWorkspace;
119
+ if (opts.tab === void 0 && effectiveGroup) args.groupName = effectiveGroup;
120
+ return args;
121
+ };
122
+ }
123
+ function rejectIntraScopeConflict(scope, fields) {
124
+ const present = [];
125
+ if (fields.tab !== void 0) present.push("--tab");
126
+ if (fields.workspace) present.push("--workspace");
127
+ if (fields.group) present.push("--group");
128
+ if (present.length > 1) {
129
+ process.stderr.write(
130
+ `[chrome-relay] target_conflict: ${scope} flags ${present.join(" + ")} are mutually exclusive. Pass exactly one of --tab, --workspace, or --group on the same ${scope}.
131
+ `
132
+ );
133
+ process.exit(2);
134
+ }
135
+ }
136
+ function emitTargetOverride(kind, from, to) {
137
+ process.stderr.write(
138
+ `[chrome-relay] target_overridden: ${kind} ${from} \u2192 ${to} (subcommand-level overrides program-level)
139
+ `
140
+ );
141
+ }
142
+ async function runToolImpl(name, args) {
143
+ try {
144
+ const result = await callTool(name, args);
145
+ if (typeof result === "string") {
146
+ process.stdout.write(result + "\n");
147
+ } else {
148
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
149
+ }
150
+ } catch (error) {
151
+ if (error instanceof RelayError) {
152
+ process.stderr.write(error.message + "\n");
153
+ process.stderr.write(JSON.stringify({ relayError: error.toBridgeError() }, null, 2) + "\n");
154
+ } else {
155
+ process.stderr.write(
156
+ (error instanceof Error ? error.message : String(error)) + "\n"
157
+ );
158
+ }
159
+ process.exit(1);
160
+ }
161
+ }
162
+ var runTool = runToolImpl;
163
+
54
164
  // src/install/install.ts
165
+ import os from "os";
166
+ import path from "path";
167
+ import { chmod, mkdir, readFile, stat, writeFile } from "fs/promises";
168
+ import { fileURLToPath } from "url";
55
169
  var APP_DIR = path.join(os.homedir(), ".chrome-relay");
56
170
  var KNOWN_EXTENSION_IDS = [
57
171
  ["Chrome Web Store", CHROME_WEB_STORE_EXTENSION_ID],
@@ -155,49 +269,14 @@ async function runDoctor() {
155
269
  }
156
270
  }
157
271
 
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
272
  // src/release-notes.ts
200
273
  var RELEASE_NOTES = {
274
+ "0.5.9": [
275
+ "Internal refactor (code-quality-hardening PR 7): program.ts and tools.ts split into per-domain modules. Pure code motion, no behavior change.",
276
+ "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.",
277
+ "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.",
278
+ "All 355 tests still pass without modification \u2014 the dispatcher contract (runTool name dispatch) is unchanged."
279
+ ],
201
280
  "0.5.8": [
202
281
  "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
282
  "tabOpt(), makeBaseArgs(program), and runTool() are now importable from `./commands/shared.js`. program.ts dropped ~100 lines.",
@@ -273,104 +352,8 @@ function listReleaseNotesSince(since) {
273
352
  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
353
  }
275
354
 
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
- );
355
+ // src/commands/install-update.ts
356
+ function registerInstallUpdate(program) {
374
357
  program.command("install").description("Install and register the local Chrome Relay host.").action(async () => {
375
358
  await runInstall();
376
359
  });
@@ -459,8 +442,11 @@ Notes:
459
442
  changes
460
443
  }, null, 2) + "\n");
461
444
  });
462
- const run = runTool;
463
- const baseArgs = makeBaseArgs(program);
445
+ }
446
+
447
+ // src/commands/navigation.ts
448
+ function registerNavigation(ctx) {
449
+ const { program, baseArgs, run } = ctx;
464
450
  program.command("tabs [verb]").description("List open Chrome windows and tabs. (verb 'list' is accepted as alias)").action(async (verb) => {
465
451
  if (verb && verb !== "list") {
466
452
  process.stderr.write(`unknown tabs verb: ${verb}. Use 'tabs' or 'tabs list'.
@@ -495,59 +481,21 @@ Use "chrome-relay switch ${url}" to activate that tab, or "chrome-relay navigate
495
481
  if (opts.inactive) args.active = false;
496
482
  await run("chrome_navigate", args);
497
483
  });
498
- tabOpt(
499
- 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
- "after",
501
- `
502
-
503
- Examples:
504
- chrome-relay screenshot -o active-tab.png
505
- chrome-relay screenshot --tab 123456789 -o evidence.png
506
- chrome-relay screenshot --tab 123456789 --full -o full-page.png
507
- chrome-relay screenshot --tab 123456789 --bbox 0,0,1280,80 -o header.png
508
- chrome-relay screenshot --tab 123456789 --selector "header" -o header.png
509
- chrome-relay screenshot --tab 123456789 --selector ".card:nth-child(3)" --padding 8 -o card.png
510
-
511
- Region screenshots (--bbox / --selector) are ~10x cheaper in tokens than a
512
- full-tab screenshot when an agent only needs to see one component.
513
- `
514
- )
515
- ).action(async (opts) => {
516
- const args = {};
517
- Object.assign(args, baseArgs(opts));
518
- if (opts.full) args.fullPage = true;
519
- if (opts.bbox) args.bbox = opts.bbox;
520
- if (opts.selector) args.selector = opts.selector;
521
- if (typeof opts.padding === "number") args.padding = opts.padding;
522
- if (typeof opts.maxEdge === "number") args.maxEdge = opts.maxEdge;
523
- try {
524
- const result = await callTool("chrome_screenshot", args);
525
- if (opts.out && result && typeof result === "object") {
526
- const data = result.dataUrl ?? result.data;
527
- if (typeof data === "string") {
528
- const b64 = data.includes(",") ? data.split(",")[1] : data;
529
- writeFileSync(opts.out, Buffer.from(b64, "base64"));
530
- process.stdout.write(`Saved screenshot to ${opts.out}
531
- `);
532
- return;
533
- }
534
- }
535
- process.stdout.write(JSON.stringify(result, null, 2) + "\n");
536
- } catch (error) {
537
- process.stderr.write(
538
- (error instanceof Error ? error.message : String(error)) + "\n"
539
- );
540
- process.exit(1);
541
- }
484
+ program.command("switch <tabId>").description("Activate a tab by ID.").action(async (tabId) => {
485
+ await run("chrome_switch_tab", { tabId: Number(tabId) });
542
486
  });
543
- tabOpt(
544
- program.command("read").description("Extract page structure and interactive elements.").option("-i, --interactive", "return only interactive elements")
545
- ).action(async (opts) => {
546
- const args = {};
547
- Object.assign(args, baseArgs(opts));
548
- if (opts.interactive) args.interactiveOnly = true;
549
- await run("chrome_read_page", args);
487
+ program.command("close <tabIds...>").description("Close one or more tabs by ID.").action(async (tabIds) => {
488
+ await run("chrome_close_tabs", { tabIds: tabIds.map(Number) });
489
+ });
490
+ program.command("call <tool> [json]").description("Call any Chrome Relay tool with raw JSON args.").action(async (tool, json) => {
491
+ const args = json ? JSON.parse(json) : {};
492
+ await run(tool, args);
550
493
  });
494
+ }
495
+
496
+ // src/commands/input.ts
497
+ function registerInput(ctx) {
498
+ const { program, baseArgs, run } = ctx;
551
499
  tabOpt(
552
500
  program.command("click <selector>").description("Click an element by CSS selector.")
553
501
  ).action(async (selector, opts) => {
@@ -625,63 +573,87 @@ Notes:
625
573
  if (typeof opts.timeoutMs === "number") args.timeoutMs = opts.timeoutMs;
626
574
  await run("chrome_evaluate", args);
627
575
  });
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
- });
638
- const viewport = program.command("viewport").description("Emulate device viewport, DPR, mobile flag, touch, and user agent.").addHelpText(
639
- "after",
640
- `
576
+ tabOpt(
577
+ 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(
578
+ "after",
579
+ `
641
580
 
642
581
  Examples:
643
- chrome-relay viewport preset iphone-14 --tab 123
644
- chrome-relay viewport preset desktop-1440 --tab 123
645
- chrome-relay viewport set --tab 123 --width 414 --height 896 --mobile --dpr 3
646
- chrome-relay viewport clear --tab 123
647
- chrome-relay viewport list
582
+ chrome-relay hover --tab 123 'button[title="Install runner"]'
583
+ chrome-relay hover --tab 123 --x 1327 --y 771
648
584
 
649
- Notes:
650
- The override survives navigations within the tab but is wiped when the
651
- debugger detaches (e.g. another extension takes over). Closing the tab
652
- clears it. Re-run after detach if the page snaps back to its default size.
585
+ Use before screencast to capture hover-driven micro-states (button glow,
586
+ tooltip appearance, etc.) that a bare click would skip past too quickly.
653
587
  `
654
- );
655
- tabOpt(
656
- viewport.command("set").description("Apply explicit viewport dimensions.").requiredOption("--width <px>", "viewport width in CSS pixels", (v) => Number(v)).requiredOption("--height <px>", "viewport height in CSS pixels", (v) => Number(v)).option("--dpr <ratio>", "device pixel ratio (1, 2, 3...)", (v) => Number(v)).option("--mobile", "set the mobile flag (affects meta viewport interpretation)").option("--touch", "enable touch event emulation").option("--user-agent <ua>", "override the User-Agent header")
657
- ).action(async (opts) => {
658
- const args = { action: "set", width: opts.width, height: opts.height };
588
+ )
589
+ ).action(async (selector, opts) => {
590
+ const args = {};
659
591
  Object.assign(args, baseArgs(opts));
660
- if (opts.dpr !== void 0) args.dpr = opts.dpr;
661
- if (opts.mobile) args.mobile = true;
662
- if (opts.touch) args.hasTouch = true;
663
- if (opts.userAgent) args.userAgent = opts.userAgent;
664
- await run("chrome_viewport", args);
592
+ if (selector) args.selector = selector;
593
+ if (typeof opts.x === "number" && typeof opts.y === "number") {
594
+ args.x = opts.x;
595
+ args.y = opts.y;
596
+ }
597
+ await run("chrome_hover", args);
665
598
  });
599
+ }
600
+
601
+ // src/commands/capture.ts
602
+ import { writeFileSync } from "fs";
603
+ function registerCapture(ctx) {
604
+ const { program, baseArgs, run } = ctx;
666
605
  tabOpt(
667
- viewport.command("preset <name>").description("Apply a named device preset (iphone-14, pixel-7, desktop-1440, etc).")
668
- ).action(async (name, opts) => {
669
- const args = { action: "preset", name };
606
+ 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(
607
+ "after",
608
+ `
609
+
610
+ Examples:
611
+ chrome-relay screenshot -o active-tab.png
612
+ chrome-relay screenshot --tab 123456789 -o evidence.png
613
+ chrome-relay screenshot --tab 123456789 --full -o full-page.png
614
+ chrome-relay screenshot --tab 123456789 --bbox 0,0,1280,80 -o header.png
615
+ chrome-relay screenshot --tab 123456789 --selector "header" -o header.png
616
+ chrome-relay screenshot --tab 123456789 --selector ".card:nth-child(3)" --padding 8 -o card.png
617
+
618
+ Region screenshots (--bbox / --selector) are ~10x cheaper in tokens than a
619
+ full-tab screenshot when an agent only needs to see one component.
620
+ `
621
+ )
622
+ ).action(async (opts) => {
623
+ const args = {};
670
624
  Object.assign(args, baseArgs(opts));
671
- await run("chrome_viewport", args);
625
+ if (opts.full) args.fullPage = true;
626
+ if (opts.bbox) args.bbox = opts.bbox;
627
+ if (opts.selector) args.selector = opts.selector;
628
+ if (typeof opts.padding === "number") args.padding = opts.padding;
629
+ if (typeof opts.maxEdge === "number") args.maxEdge = opts.maxEdge;
630
+ try {
631
+ const result = await callTool("chrome_screenshot", args);
632
+ if (opts.out && result && typeof result === "object") {
633
+ const data = result.dataUrl ?? result.data;
634
+ if (typeof data === "string") {
635
+ const b64 = data.includes(",") ? data.split(",")[1] : data;
636
+ writeFileSync(opts.out, Buffer.from(b64, "base64"));
637
+ process.stdout.write(`Saved screenshot to ${opts.out}
638
+ `);
639
+ return;
640
+ }
641
+ }
642
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
643
+ } catch (error) {
644
+ process.stderr.write(
645
+ (error instanceof Error ? error.message : String(error)) + "\n"
646
+ );
647
+ process.exit(1);
648
+ }
672
649
  });
673
650
  tabOpt(
674
- viewport.command("clear").description("Drop the viewport override and return the tab to its native size.")
651
+ program.command("read").description("Extract page structure and interactive elements.").option("-i, --interactive", "return only interactive elements")
675
652
  ).action(async (opts) => {
676
- const args = { action: "clear" };
653
+ const args = {};
677
654
  Object.assign(args, baseArgs(opts));
678
- await run("chrome_viewport", args);
679
- });
680
- viewport.command("list").description("List available presets.").action(async () => {
681
- await run("chrome_viewport", { action: "list" });
682
- });
683
- program.command("self-reload").description("Restart the chrome-relay extension's service worker (picks up newly built code).").action(async () => {
684
- await run("chrome_self_reload", {});
655
+ if (opts.interactive) args.interactiveOnly = true;
656
+ await run("chrome_read_page", args);
685
657
  });
686
658
  tabOpt(
687
659
  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(
@@ -723,67 +695,273 @@ Notes:
723
695
  args.node = opts.node;
724
696
  await run("chrome_click_ax", args);
725
697
  });
726
- const workspace = program.command("workspace").description("Manage named Chrome windows so multiple agents can drive separate windows.").addHelpText(
698
+ const screencast = program.command("screencast").description("Record a tab via CDP (paint-driven). Requires an active tab.").addHelpText(
727
699
  "after",
728
700
  `
729
701
 
730
702
  Examples:
731
- chrome-relay workspace create bidsmith-h01 --url https://reddit.com
732
- chrome-relay workspace list
733
- chrome-relay --workspace bidsmith-h01 navigate https://news.ycombinator.com
734
- chrome-relay --workspace bidsmith-h01 screenshot -o evidence.png
735
- chrome-relay workspace close bidsmith-h01
736
-
737
- Notes:
738
- Hard lifecycle: if you manually close the workspace's window, the next
739
- --workspace operation fails loudly until you run \`workspace close\` +
740
- \`workspace create\` again.
741
- Precedence on a single command: --tab > --group > --workspace.
742
- `
743
- );
744
- workspace.command("create <name>").description("Open a new Chrome window and bind it to <name>.").option("--url <url>", "initial URL (default about:blank)").option("--label <label>", "human-readable description shown in popup/list").action(async (name, opts) => {
745
- const args = { action: "create", name };
746
- if (opts.url) args.url = opts.url;
747
- if (opts.label) args.label = opts.label;
748
- await run("chrome_workspace", args);
749
- });
750
- workspace.command("list").description("List all known workspaces + whether their window is still alive.").action(async () => {
751
- await run("chrome_workspace", { action: "list" });
752
- });
753
- workspace.command("close <name>").description("Close the workspace's window (if alive) and remove the binding.").action(async (name) => {
754
- await run("chrome_workspace", { action: "close", name });
755
- });
756
- const group = program.command("group").description("Manage Chrome tab-groups (the colored, collapsible folders inside one window).").addHelpText(
757
- "after",
758
- `
703
+ chrome-relay screencast start --tab 123 --quality 80 --max-width 900
704
+ # ... drive the interaction (hover, click, etc.) ...
705
+ chrome-relay screencast stop --tab 123 --out /tmp/recording
759
706
 
760
- Examples:
761
- chrome-relay group create research --tabs 123,456,789 --color cyan
762
- chrome-relay group list
763
- chrome-relay group add research --tabs 1011
764
- chrome-relay group remove --tabs 456
765
- chrome-relay --group research navigate https://news.ycombinator.com
766
- chrome-relay group close research
707
+ # The --out path becomes a directory of frame_NNNN.jpg files. If ffmpeg
708
+ # is on PATH and --gif is also passed, an animated GIF is written next to
709
+ # the frames at /tmp/recording.gif.
767
710
 
768
711
  Notes:
769
- Tab-groups live inside ONE Chrome window. To open in a specific window,
770
- pass --workspace W on \`group create\` (we'll route the underlying
771
- chrome.tabs.group call there).
772
- \`--group X navigate --new\` opens the new tab into the group's window AND
773
- drops it inside the group.
774
- Auto-pruned when the group's last tab is ungrouped or its window closes.
775
- Colors: grey, blue, red, yellow, green, pink, purple, cyan, orange.
712
+ Frames buffer in the extension service worker. A 10-second capture at
713
+ default settings (jpeg q=60, ~15fps, full viewport) lands ~2-3 MB.
714
+ Pass --max-width to downscale and lighten the buffer.
715
+ Each frame is base64 JPEG; the CLI decodes them when --out is given.
776
716
  `
777
717
  );
778
- group.command("create <name>").description("Group existing tabs into a new tab-group bound to <name>.").requiredOption("--tabs <ids>", "comma-separated tab IDs to group, e.g. 123,456,789").option("--color <color>", "grey | blue | red | yellow | green | pink | purple | cyan | orange").option("--collapsed", "create the group in its collapsed state").action(async (name, opts) => {
779
- const args = { action: "create", name };
780
- args.tabIds = String(opts.tabs).split(",").map((s) => Number(s.trim())).filter(Number.isFinite);
781
- if (opts.color) args.color = opts.color;
782
- if (opts.collapsed) args.collapsed = true;
783
- await run("chrome_group", args);
718
+ tabOpt(
719
+ 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))
720
+ ).action(async (opts) => {
721
+ const args = { action: "start" };
722
+ Object.assign(args, baseArgs(opts));
723
+ if (opts.format) args.format = opts.format;
724
+ if (typeof opts.quality === "number") args.quality = opts.quality;
725
+ if (typeof opts.maxWidth === "number") args.maxWidth = opts.maxWidth;
726
+ if (typeof opts.maxHeight === "number") args.maxHeight = opts.maxHeight;
727
+ if (typeof opts.everyNth === "number") args.everyNthFrame = opts.everyNth;
728
+ await run("chrome_screencast", args);
784
729
  });
785
- group.command("list").description("List all known tab-groups + their window/color/tabCount.").action(async () => {
786
- await run("chrome_group", { action: "list" });
730
+ tabOpt(
731
+ 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")
732
+ ).action(async (opts) => {
733
+ const args = { action: "stop" };
734
+ Object.assign(args, baseArgs(opts));
735
+ try {
736
+ const result = await callTool("chrome_screencast", args);
737
+ if (!opts.out) {
738
+ const { frames, ...summary } = result;
739
+ process.stdout.write(JSON.stringify({ ...summary, framesOmitted: frames.length, hint: "pass --out <dir> to save" }, null, 2) + "\n");
740
+ return;
741
+ }
742
+ const { mkdirSync, writeFileSync: wf, renameSync, unlinkSync } = await import("fs");
743
+ const path2 = await import("path");
744
+ const { createHash } = await import("crypto");
745
+ mkdirSync(opts.out, { recursive: true });
746
+ result.frames.forEach((f, i) => {
747
+ const name = `frame_${String(i + 1).padStart(4, "0")}.jpg`;
748
+ wf(path2.join(opts.out, name), Buffer.from(f.data, "base64"));
749
+ });
750
+ process.stdout.write(`Wrote ${result.frames.length} frames to ${opts.out}
751
+ `);
752
+ const dedupeOn = opts.dedupe !== false;
753
+ if (dedupeOn && result.frames.length > 1) {
754
+ const hashes = result.frames.map(
755
+ (f) => createHash("sha256").update(Buffer.from(f.data, "base64")).digest("hex")
756
+ );
757
+ const kept = [];
758
+ let prev = "";
759
+ hashes.forEach((h, i) => {
760
+ if (h !== prev) kept.push(i);
761
+ prev = h;
762
+ });
763
+ const dropped = result.frames.length - kept.length;
764
+ if (dropped > 0) {
765
+ for (let i = 0; i < result.frames.length; i++) {
766
+ const src = path2.join(opts.out, `frame_${String(i + 1).padStart(4, "0")}.jpg`);
767
+ try {
768
+ unlinkSync(src);
769
+ } catch {
770
+ }
771
+ }
772
+ kept.forEach((srcIdx, newIdx) => {
773
+ const tmp = path2.join(opts.out, `tmp_${String(newIdx + 1).padStart(4, "0")}.jpg`);
774
+ wf(tmp, Buffer.from(result.frames[srcIdx].data, "base64"));
775
+ });
776
+ kept.forEach((_, newIdx) => {
777
+ const tmp = path2.join(opts.out, `tmp_${String(newIdx + 1).padStart(4, "0")}.jpg`);
778
+ const final = path2.join(opts.out, `frame_${String(newIdx + 1).padStart(4, "0")}.jpg`);
779
+ renameSync(tmp, final);
780
+ });
781
+ process.stdout.write(`Deduped: dropped ${dropped} identical frames, ${kept.length} remain.
782
+ `);
783
+ } else {
784
+ process.stdout.write(`Deduped: no consecutive duplicates found.
785
+ `);
786
+ }
787
+ }
788
+ if (opts.gif || opts.mp4) {
789
+ const fps = typeof opts.fps === "number" ? opts.fps : 15;
790
+ const { spawnSync } = await import("child_process");
791
+ const which = spawnSync("which", ["ffmpeg"]);
792
+ if (which.status !== 0) {
793
+ process.stderr.write("[chrome-relay] ffmpeg not on PATH \u2014 skipping --gif/--mp4.\n");
794
+ return;
795
+ }
796
+ if (opts.gif) {
797
+ const gifOut = `${opts.out.replace(/\/$/, "")}.gif`;
798
+ const r = spawnSync("ffmpeg", [
799
+ "-y",
800
+ "-framerate",
801
+ String(fps),
802
+ "-i",
803
+ path2.join(opts.out, "frame_%04d.jpg"),
804
+ "-vf",
805
+ `fps=${fps},split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse`,
806
+ "-loop",
807
+ "0",
808
+ gifOut
809
+ ], { stdio: "inherit" });
810
+ if (r.status === 0) process.stdout.write(`Wrote ${gifOut}
811
+ `);
812
+ }
813
+ if (opts.mp4) {
814
+ const mp4Out = `${opts.out.replace(/\/$/, "")}.mp4`;
815
+ const r = spawnSync("ffmpeg", [
816
+ "-y",
817
+ "-framerate",
818
+ String(fps),
819
+ "-i",
820
+ path2.join(opts.out, "frame_%04d.jpg"),
821
+ "-c:v",
822
+ "libx264",
823
+ "-pix_fmt",
824
+ "yuv420p",
825
+ "-crf",
826
+ "20",
827
+ mp4Out
828
+ ], { stdio: "inherit" });
829
+ if (r.status === 0) process.stdout.write(`Wrote ${mp4Out}
830
+ `);
831
+ }
832
+ }
833
+ } catch (error) {
834
+ process.stderr.write(
835
+ (error instanceof Error ? error.message : String(error)) + "\n"
836
+ );
837
+ process.exit(1);
838
+ }
839
+ });
840
+ }
841
+
842
+ // src/commands/sessions.ts
843
+ function netFilterOpts(cmd) {
844
+ 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));
845
+ }
846
+ function netFilterArgs(opts) {
847
+ const a = {};
848
+ if (opts.filter) a.filter = opts.filter;
849
+ if (opts.status) a.status = opts.status;
850
+ if (opts.method) a.method = opts.method;
851
+ if (typeof opts.limit === "number") a.limit = opts.limit;
852
+ return a;
853
+ }
854
+ function registerSessions(ctx) {
855
+ const { program, baseArgs, run } = ctx;
856
+ const viewport = program.command("viewport").description("Emulate device viewport, DPR, mobile flag, touch, and user agent.").addHelpText(
857
+ "after",
858
+ `
859
+
860
+ Examples:
861
+ chrome-relay viewport preset iphone-14 --tab 123
862
+ chrome-relay viewport preset desktop-1440 --tab 123
863
+ chrome-relay viewport set --tab 123 --width 414 --height 896 --mobile --dpr 3
864
+ chrome-relay viewport clear --tab 123
865
+ chrome-relay viewport list
866
+
867
+ Notes:
868
+ The override survives navigations within the tab but is wiped when the
869
+ debugger detaches (e.g. another extension takes over). Closing the tab
870
+ clears it. Re-run after detach if the page snaps back to its default size.
871
+ `
872
+ );
873
+ tabOpt(
874
+ viewport.command("set").description("Apply explicit viewport dimensions.").requiredOption("--width <px>", "viewport width in CSS pixels", (v) => Number(v)).requiredOption("--height <px>", "viewport height in CSS pixels", (v) => Number(v)).option("--dpr <ratio>", "device pixel ratio (1, 2, 3...)", (v) => Number(v)).option("--mobile", "set the mobile flag (affects meta viewport interpretation)").option("--touch", "enable touch event emulation").option("--user-agent <ua>", "override the User-Agent header")
875
+ ).action(async (opts) => {
876
+ const args = { action: "set", width: opts.width, height: opts.height };
877
+ Object.assign(args, baseArgs(opts));
878
+ if (opts.dpr !== void 0) args.dpr = opts.dpr;
879
+ if (opts.mobile) args.mobile = true;
880
+ if (opts.touch) args.hasTouch = true;
881
+ if (opts.userAgent) args.userAgent = opts.userAgent;
882
+ await run("chrome_viewport", args);
883
+ });
884
+ tabOpt(
885
+ viewport.command("preset <name>").description("Apply a named device preset (iphone-14, pixel-7, desktop-1440, etc).")
886
+ ).action(async (name, opts) => {
887
+ const args = { action: "preset", name };
888
+ Object.assign(args, baseArgs(opts));
889
+ await run("chrome_viewport", args);
890
+ });
891
+ tabOpt(
892
+ viewport.command("clear").description("Drop the viewport override and return the tab to its native size.")
893
+ ).action(async (opts) => {
894
+ const args = { action: "clear" };
895
+ Object.assign(args, baseArgs(opts));
896
+ await run("chrome_viewport", args);
897
+ });
898
+ viewport.command("list").description("List available presets.").action(async () => {
899
+ await run("chrome_viewport", { action: "list" });
900
+ });
901
+ program.command("self-reload").description("Restart the chrome-relay extension's service worker (picks up newly built code).").action(async () => {
902
+ await run("chrome_self_reload", {});
903
+ });
904
+ const workspace = program.command("workspace").description("Manage named Chrome windows so multiple agents can drive separate windows.").addHelpText(
905
+ "after",
906
+ `
907
+
908
+ Examples:
909
+ chrome-relay workspace create bidsmith-h01 --url https://reddit.com
910
+ chrome-relay workspace list
911
+ chrome-relay --workspace bidsmith-h01 navigate https://news.ycombinator.com
912
+ chrome-relay --workspace bidsmith-h01 screenshot -o evidence.png
913
+ chrome-relay workspace close bidsmith-h01
914
+
915
+ Notes:
916
+ Hard lifecycle: if you manually close the workspace's window, the next
917
+ --workspace operation fails loudly until you run \`workspace close\` +
918
+ \`workspace create\` again.
919
+ Precedence on a single command: --tab > --group > --workspace.
920
+ `
921
+ );
922
+ workspace.command("create <name>").description("Open a new Chrome window and bind it to <name>.").option("--url <url>", "initial URL (default about:blank)").option("--label <label>", "human-readable description shown in popup/list").action(async (name, opts) => {
923
+ const args = { action: "create", name };
924
+ if (opts.url) args.url = opts.url;
925
+ if (opts.label) args.label = opts.label;
926
+ await run("chrome_workspace", args);
927
+ });
928
+ workspace.command("list").description("List all known workspaces + whether their window is still alive.").action(async () => {
929
+ await run("chrome_workspace", { action: "list" });
930
+ });
931
+ workspace.command("close <name>").description("Close the workspace's window (if alive) and remove the binding.").action(async (name) => {
932
+ await run("chrome_workspace", { action: "close", name });
933
+ });
934
+ const group = program.command("group").description("Manage Chrome tab-groups (the colored, collapsible folders inside one window).").addHelpText(
935
+ "after",
936
+ `
937
+
938
+ Examples:
939
+ chrome-relay group create research --tabs 123,456,789 --color cyan
940
+ chrome-relay group list
941
+ chrome-relay group add research --tabs 1011
942
+ chrome-relay group remove --tabs 456
943
+ chrome-relay --group research navigate https://news.ycombinator.com
944
+ chrome-relay group close research
945
+
946
+ Notes:
947
+ Tab-groups live inside ONE Chrome window. To open in a specific window,
948
+ pass --workspace W on \`group create\` (we'll route the underlying
949
+ chrome.tabs.group call there).
950
+ \`--group X navigate --new\` opens the new tab into the group's window AND
951
+ drops it inside the group.
952
+ Auto-pruned when the group's last tab is ungrouped or its window closes.
953
+ Colors: grey, blue, red, yellow, green, pink, purple, cyan, orange.
954
+ `
955
+ );
956
+ group.command("create <name>").description("Group existing tabs into a new tab-group bound to <name>.").requiredOption("--tabs <ids>", "comma-separated tab IDs to group, e.g. 123,456,789").option("--color <color>", "grey | blue | red | yellow | green | pink | purple | cyan | orange").option("--collapsed", "create the group in its collapsed state").action(async (name, opts) => {
957
+ const args = { action: "create", name };
958
+ args.tabIds = String(opts.tabs).split(",").map((s) => Number(s.trim())).filter(Number.isFinite);
959
+ if (opts.color) args.color = opts.color;
960
+ if (opts.collapsed) args.collapsed = true;
961
+ await run("chrome_group", args);
962
+ });
963
+ group.command("list").description("List all known tab-groups + their window/color/tabCount.").action(async () => {
964
+ await run("chrome_group", { action: "list" });
787
965
  });
788
966
  group.command("close <name>").description("Ungroup the tabs in <name> and remove the binding.").action(async (name) => {
789
967
  await run("chrome_group", { action: "close", name });
@@ -796,17 +974,6 @@ Notes:
796
974
  const tabIds = String(opts.tabs).split(",").map((s) => Number(s.trim())).filter(Number.isFinite);
797
975
  await run("chrome_group", { action: "remove", tabIds });
798
976
  });
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
977
  const network = tabOpt(netFilterOpts(
811
978
  program.command("network").description("Capture HTTP request/response metadata. Ring buffer, last 200 per tab.")
812
979
  )).addHelpText(
@@ -896,171 +1063,41 @@ Notes:
896
1063
  if (typeof opts.limit === "number") args.limit = opts.limit;
897
1064
  await run("chrome_console", args);
898
1065
  });
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
1066
+ }
907
1067
 
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(
1068
+ // src/program.ts
1069
+ function buildProgram() {
1070
+ const program = new Command();
1071
+ 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
1072
  "after",
924
1073
  `
925
1074
 
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.
1075
+ Common agent flow:
1076
+ chrome-relay tabs
1077
+ chrome-relay navigate --tab <tabId> "https://example.com"
1078
+ chrome-relay read --tab <tabId> -i
1079
+ chrome-relay click --tab <tabId> "<selector>"
1080
+ chrome-relay fill --tab <tabId> "<selector>" "value"
1081
+ chrome-relay type --tab <tabId> -s "<selector>" "text into rich editor"
1082
+ chrome-relay keys --tab <tabId> Enter
1083
+ chrome-relay js --tab <tabId> "return document.title"
1084
+ chrome-relay screenshot --tab <tabId> -o evidence.png
934
1085
 
935
1086
  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.
1087
+ navigate takes a URL. Use --tab to target an existing tab.
1088
+ Tools attach via CDP and run on backgrounded tabs without stealing focus.
940
1089
  `
941
1090
  );
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
- });
1091
+ const ctx = {
1092
+ program,
1093
+ baseArgs: makeBaseArgs(program),
1094
+ run: runTool
1095
+ };
1096
+ registerInstallUpdate(program);
1097
+ registerNavigation(ctx);
1098
+ registerInput(ctx);
1099
+ registerCapture(ctx);
1100
+ registerSessions(ctx);
1064
1101
  return program;
1065
1102
  }
1066
1103
 
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.9" : "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.9" : "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.9",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",