chrome-relay 0.5.7 → 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,7 +2,9 @@
2
2
 
3
3
  // src/program.ts
4
4
  import { Command } from "commander";
5
- import { writeFileSync } from "fs";
5
+
6
+ // src/index.ts
7
+ var CHROME_RELAY_VERSION = true ? "0.5.9" : "0.0.0-dev";
6
8
 
7
9
  // ../protocol/dist/index.js
8
10
  var NATIVE_HOST_NAME = "dev.chrome_relay.native_host";
@@ -42,8 +44,122 @@ var RelayError = class extends Error {
42
44
  }
43
45
  };
44
46
 
45
- // src/index.ts
46
- var CHROME_RELAY_VERSION = true ? "0.5.7" : "0.0.0-dev";
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;
47
163
 
48
164
  // src/install/install.ts
49
165
  import os from "os";
@@ -153,49 +269,19 @@ async function runDoctor() {
153
269
  }
154
270
  }
155
271
 
156
- // src/client/call.ts
157
- var noticePrinted = false;
158
- function emitNoticeOnce(notice) {
159
- if (noticePrinted) return;
160
- noticePrinted = true;
161
- process.stderr.write(`[chrome-relay] ${notice}
162
- `);
163
- }
164
- async function callToolWithMeta(name, args) {
165
- const response = await fetch(`http://127.0.0.1:${DEFAULT_HTTP_PORT}/call`, {
166
- method: "POST",
167
- headers: { "content-type": "application/json" },
168
- body: JSON.stringify({
169
- name,
170
- args
171
- })
172
- });
173
- const payload = await response.json().catch(() => null);
174
- const noticeString = payload?.notice ?? payload?.notices?.[0]?.message;
175
- if (!response.ok) {
176
- if (noticeString) emitNoticeOnce(noticeString);
177
- throw rebuildError(payload, `Bridge request failed with ${response.status}`);
178
- }
179
- if (!payload?.ok) {
180
- if (noticeString) emitNoticeOnce(noticeString);
181
- throw rebuildError(payload, "Bridge call failed.");
182
- }
183
- if (noticeString) emitNoticeOnce(noticeString);
184
- return { data: payload.data, notice: payload.notice, notices: payload.notices };
185
- }
186
- function rebuildError(payload, fallbackMessage) {
187
- if (payload?.errorDetails) {
188
- return new RelayError(payload.errorDetails);
189
- }
190
- return new Error(payload?.error || fallbackMessage);
191
- }
192
- async function callTool(name, args) {
193
- const { data } = await callToolWithMeta(name, args);
194
- return data;
195
- }
196
-
197
272
  // src/release-notes.ts
198
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
+ ],
280
+ "0.5.8": [
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.",
282
+ "tabOpt(), makeBaseArgs(program), and runTool() are now importable from `./commands/shared.js`. program.ts dropped ~100 lines.",
283
+ "No behavior change \u2014 all 355 tests still pass. Future PRs can split per-domain command groups (navigation, input, capture, sessions) into their own modules without churning helpers."
284
+ ],
199
285
  "0.5.7": [
200
286
  "`chrome-relay update` returns structured verification metadata (code-quality-hardening PR 5). Output now has `install: { attempted, packageManager, status, command }`, `binary: { path, reexeced }`, `releaseNotes: { source: 'current_process' | 'updated_binary', changes }`, and a `warnings[]` array.",
201
287
  "Surfaces the 'install said success but binary didn't change' failure mode (PATH mismatch, stale shim, cross-package-manager confusion) as `warnings[].code === 'update_not_verified'`. Agents can branch on it.",
@@ -266,29 +352,8 @@ function listReleaseNotesSince(since) {
266
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] }));
267
353
  }
268
354
 
269
- // src/program.ts
270
- function buildProgram() {
271
- const program = new Command();
272
- 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(
273
- "after",
274
- `
275
-
276
- Common agent flow:
277
- chrome-relay tabs
278
- chrome-relay navigate --tab <tabId> "https://example.com"
279
- chrome-relay read --tab <tabId> -i
280
- chrome-relay click --tab <tabId> "<selector>"
281
- chrome-relay fill --tab <tabId> "<selector>" "value"
282
- chrome-relay type --tab <tabId> -s "<selector>" "text into rich editor"
283
- chrome-relay keys --tab <tabId> Enter
284
- chrome-relay js --tab <tabId> "return document.title"
285
- chrome-relay screenshot --tab <tabId> -o evidence.png
286
-
287
- Notes:
288
- navigate takes a URL. Use --tab to target an existing tab.
289
- Tools attach via CDP and run on backgrounded tabs without stealing focus.
290
- `
291
- );
355
+ // src/commands/install-update.ts
356
+ function registerInstallUpdate(program) {
292
357
  program.command("install").description("Install and register the local Chrome Relay host.").action(async () => {
293
358
  await runInstall();
294
359
  });
@@ -377,77 +442,11 @@ Notes:
377
442
  changes
378
443
  }, null, 2) + "\n");
379
444
  });
380
- async function run(name, args) {
381
- try {
382
- const result = await callTool(name, args);
383
- if (typeof result === "string") {
384
- process.stdout.write(result + "\n");
385
- } else {
386
- process.stdout.write(JSON.stringify(result, null, 2) + "\n");
387
- }
388
- } catch (error) {
389
- if (error instanceof RelayError) {
390
- process.stderr.write(error.message + "\n");
391
- process.stderr.write(JSON.stringify({ relayError: error.toBridgeError() }, null, 2) + "\n");
392
- } else {
393
- process.stderr.write(
394
- (error instanceof Error ? error.message : String(error)) + "\n"
395
- );
396
- }
397
- process.exit(1);
398
- }
399
- }
400
- function tabOpt(cmd) {
401
- 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`)");
402
- }
403
- function baseArgs(opts) {
404
- const parentOpts = program.opts();
405
- rejectIntraScopeConflict("subcommand", {
406
- tab: opts.tab,
407
- workspace: opts.workspace,
408
- group: opts.group
409
- });
410
- rejectIntraScopeConflict("program-level", {
411
- workspace: parentOpts.workspace,
412
- group: parentOpts.group
413
- });
414
- if (opts.workspace && parentOpts.workspace && opts.workspace !== parentOpts.workspace) {
415
- emitTargetOverride("workspace", parentOpts.workspace, opts.workspace);
416
- }
417
- if (opts.group && parentOpts.group && opts.group !== parentOpts.group) {
418
- emitTargetOverride("group", parentOpts.group, opts.group);
419
- }
420
- if (opts.tab !== void 0 && (parentOpts.workspace || parentOpts.group)) {
421
- const prior = parentOpts.workspace ? `workspace=${parentOpts.workspace}` : `group=${parentOpts.group}`;
422
- emitTargetOverride("tab", prior, String(opts.tab));
423
- }
424
- const args = {};
425
- if (opts.tab !== void 0) args.tabId = opts.tab;
426
- const effectiveWorkspace = opts.workspace ?? parentOpts.workspace;
427
- const effectiveGroup = opts.group ?? parentOpts.group;
428
- if (opts.tab === void 0 && effectiveWorkspace) args.workspaceName = effectiveWorkspace;
429
- if (opts.tab === void 0 && effectiveGroup) args.groupName = effectiveGroup;
430
- return args;
431
- }
432
- function rejectIntraScopeConflict(scope, fields) {
433
- const present = [];
434
- if (fields.tab !== void 0) present.push("--tab");
435
- if (fields.workspace) present.push("--workspace");
436
- if (fields.group) present.push("--group");
437
- if (present.length > 1) {
438
- process.stderr.write(
439
- `[chrome-relay] target_conflict: ${scope} flags ${present.join(" + ")} are mutually exclusive. Pass exactly one of --tab, --workspace, or --group on the same ${scope}.
440
- `
441
- );
442
- process.exit(2);
443
- }
444
- }
445
- function emitTargetOverride(kind, from, to) {
446
- process.stderr.write(
447
- `[chrome-relay] target_overridden: ${kind} ${from} \u2192 ${to} (subcommand-level overrides program-level)
448
- `
449
- );
450
- }
445
+ }
446
+
447
+ // src/commands/navigation.ts
448
+ function registerNavigation(ctx) {
449
+ const { program, baseArgs, run } = ctx;
451
450
  program.command("tabs [verb]").description("List open Chrome windows and tabs. (verb 'list' is accepted as alias)").action(async (verb) => {
452
451
  if (verb && verb !== "list") {
453
452
  process.stderr.write(`unknown tabs verb: ${verb}. Use 'tabs' or 'tabs list'.
@@ -482,28 +481,149 @@ Use "chrome-relay switch ${url}" to activate that tab, or "chrome-relay navigate
482
481
  if (opts.inactive) args.active = false;
483
482
  await run("chrome_navigate", args);
484
483
  });
484
+ program.command("switch <tabId>").description("Activate a tab by ID.").action(async (tabId) => {
485
+ await run("chrome_switch_tab", { tabId: Number(tabId) });
486
+ });
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);
493
+ });
494
+ }
495
+
496
+ // src/commands/input.ts
497
+ function registerInput(ctx) {
498
+ const { program, baseArgs, run } = ctx;
485
499
  tabOpt(
486
- 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
+ program.command("click <selector>").description("Click an element by CSS selector.")
501
+ ).action(async (selector, opts) => {
502
+ const args = { selector };
503
+ Object.assign(args, baseArgs(opts));
504
+ await run("chrome_click_element", args);
505
+ });
506
+ tabOpt(
507
+ program.command("fill <selector> <value>").description("Fill an input or textarea.")
508
+ ).action(async (selector, value, opts) => {
509
+ const args = { selector, value };
510
+ Object.assign(args, baseArgs(opts));
511
+ await run("chrome_fill_or_select", args);
512
+ });
513
+ tabOpt(
514
+ program.command("keys <keys>").description("Press a single key or chord via trusted CDP input (e.g. Enter, Cmd+K).").addHelpText(
487
515
  "after",
488
516
  `
489
517
 
490
518
  Examples:
491
- chrome-relay screenshot -o active-tab.png
492
- chrome-relay screenshot --tab 123456789 -o evidence.png
493
- chrome-relay screenshot --tab 123456789 --full -o full-page.png
494
- chrome-relay screenshot --tab 123456789 --bbox 0,0,1280,80 -o header.png
495
- chrome-relay screenshot --tab 123456789 --selector "header" -o header.png
496
- chrome-relay screenshot --tab 123456789 --selector ".card:nth-child(3)" --padding 8 -o card.png
519
+ chrome-relay keys Enter
520
+ chrome-relay keys Tab
521
+ chrome-relay keys Cmd+K
522
+ chrome-relay keys Shift+ArrowDown
497
523
 
498
- Region screenshots (--bbox / --selector) are ~10x cheaper in tokens than a
499
- full-tab screenshot when an agent only needs to see one component.
524
+ For typing text into a field, use \`chrome-relay type\` instead.
500
525
  `
501
526
  )
502
- ).action(async (opts) => {
503
- const args = {};
527
+ ).action(async (keys, opts) => {
528
+ const args = { keys };
529
+ Object.assign(args, baseArgs(opts));
530
+ await run("chrome_keyboard", args);
531
+ });
532
+ tabOpt(
533
+ 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(
534
+ "after",
535
+ `
536
+
537
+ Examples:
538
+ chrome-relay type --selector "[data-testid=tweetTextarea_0]" "hello world"
539
+ chrome-relay type "appended into already-focused element"
540
+
541
+ When to pick which:
542
+ fill \u2014 plain <input>, <textarea>, <select>, React-controlled inputs (atomic write).
543
+ type \u2014 contenteditable, Draft.js, Lexical, ProseMirror (trusted text commit).
544
+ keys \u2014 Enter, Tab, Esc, arrows, modifier chords (single key press).
545
+ js \u2014 anything else.
546
+ `
547
+ )
548
+ ).action(async (text, opts) => {
549
+ const args = { text };
504
550
  Object.assign(args, baseArgs(opts));
505
- if (opts.full) args.fullPage = true;
506
- if (opts.bbox) args.bbox = opts.bbox;
551
+ if (opts.selector) args.selector = opts.selector;
552
+ await run("chrome_type", args);
553
+ });
554
+ tabOpt(
555
+ 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(
556
+ "after",
557
+ `
558
+
559
+ Examples:
560
+ chrome-relay js "return document.title"
561
+ chrome-relay js "return await fetch('/api/me').then(r => r.json())"
562
+ chrome-relay js --tab 12345 "return document.querySelectorAll('article').length"
563
+
564
+ Notes:
565
+ Code is wrapped in an async IIFE. Top-level await works. Use \`return\` to send a value back.
566
+ Returned value is JSON-serialized. DOM nodes and functions become {}.
567
+ Runs in MAIN world: page globals, framework internals, and shadow roots are reachable.
568
+ `
569
+ )
570
+ ).action(async (code, opts) => {
571
+ const args = { code };
572
+ Object.assign(args, baseArgs(opts));
573
+ if (typeof opts.timeoutMs === "number") args.timeoutMs = opts.timeoutMs;
574
+ await run("chrome_evaluate", args);
575
+ });
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
+ `
580
+
581
+ Examples:
582
+ chrome-relay hover --tab 123 'button[title="Install runner"]'
583
+ chrome-relay hover --tab 123 --x 1327 --y 771
584
+
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.
587
+ `
588
+ )
589
+ ).action(async (selector, opts) => {
590
+ const args = {};
591
+ Object.assign(args, baseArgs(opts));
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);
598
+ });
599
+ }
600
+
601
+ // src/commands/capture.ts
602
+ import { writeFileSync } from "fs";
603
+ function registerCapture(ctx) {
604
+ const { program, baseArgs, run } = ctx;
605
+ tabOpt(
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 = {};
624
+ Object.assign(args, baseArgs(opts));
625
+ if (opts.full) args.fullPage = true;
626
+ if (opts.bbox) args.bbox = opts.bbox;
507
627
  if (opts.selector) args.selector = opts.selector;
508
628
  if (typeof opts.padding === "number") args.padding = opts.padding;
509
629
  if (typeof opts.maxEdge === "number") args.maxEdge = opts.maxEdge;
@@ -536,92 +656,203 @@ full-tab screenshot when an agent only needs to see one component.
536
656
  await run("chrome_read_page", args);
537
657
  });
538
658
  tabOpt(
539
- program.command("click <selector>").description("Click an element by CSS selector.")
540
- ).action(async (selector, opts) => {
541
- const args = { selector };
542
- Object.assign(args, baseArgs(opts));
543
- await run("chrome_click_element", args);
544
- });
545
- tabOpt(
546
- program.command("fill <selector> <value>").description("Fill an input or textarea.")
547
- ).action(async (selector, value, opts) => {
548
- const args = { selector, value };
549
- Object.assign(args, baseArgs(opts));
550
- await run("chrome_fill_or_select", args);
551
- });
552
- tabOpt(
553
- program.command("keys <keys>").description("Press a single key or chord via trusted CDP input (e.g. Enter, Cmd+K).").addHelpText(
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(
554
660
  "after",
555
661
  `
556
662
 
557
663
  Examples:
558
- chrome-relay keys Enter
559
- chrome-relay keys Tab
560
- chrome-relay keys Cmd+K
561
- chrome-relay keys Shift+ArrowDown
664
+ chrome-relay ax --tab 123
665
+ chrome-relay ax --tab 123 --interactive-only
666
+ chrome-relay ax --tab 123 --root main --interactive-only
562
667
 
563
- For typing text into a field, use \`chrome-relay type\` instead.
668
+ Notes:
669
+ Each node carries an "id" \u2014 that's the backendDOMNodeId. Pass it to
670
+ \`chrome-relay click-ax --node <id>\` to click without a CSS selector.
564
671
  `
565
672
  )
566
- ).action(async (keys, opts) => {
567
- const args = { keys };
568
- Object.assign(args, baseArgs(opts));
569
- await run("chrome_keyboard", args);
673
+ ).action(async (opts) => {
674
+ const args = baseArgs(opts);
675
+ if (opts.interactiveOnly) args.interactiveOnly = true;
676
+ if (opts.root) args.rootRole = opts.root;
677
+ if (opts.includeSubframes) args.includeSubframes = true;
678
+ await run("chrome_ax", args);
570
679
  });
571
680
  tabOpt(
572
- 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(
681
+ 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(
573
682
  "after",
574
683
  `
575
684
 
576
685
  Examples:
577
- chrome-relay type --selector "[data-testid=tweetTextarea_0]" "hello world"
578
- chrome-relay type "appended into already-focused element"
686
+ chrome-relay click-ax --tab 123 --node 456
579
687
 
580
- When to pick which:
581
- fill \u2014 plain <input>, <textarea>, <select>, React-controlled inputs (atomic write).
582
- type \u2014 contenteditable, Draft.js, Lexical, ProseMirror (trusted text commit).
583
- keys \u2014 Enter, Tab, Esc, arrows, modifier chords (single key press).
584
- js \u2014 anything else.
688
+ Notes:
689
+ Throws explicitly if the node id is stale (page mutated since you called
690
+ \`ax\`). Re-run \`ax\` and pass the fresh id.
585
691
  `
586
692
  )
587
- ).action(async (text, opts) => {
588
- const args = { text };
693
+ ).action(async (opts) => {
694
+ const args = baseArgs(opts);
695
+ args.node = opts.node;
696
+ await run("chrome_click_ax", args);
697
+ });
698
+ const screencast = program.command("screencast").description("Record a tab via CDP (paint-driven). Requires an active tab.").addHelpText(
699
+ "after",
700
+ `
701
+
702
+ Examples:
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
706
+
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.
710
+
711
+ Notes:
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.
716
+ `
717
+ );
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" };
589
722
  Object.assign(args, baseArgs(opts));
590
- if (opts.selector) args.selector = opts.selector;
591
- await run("chrome_type", args);
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);
729
+ });
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
+ }
592
839
  });
593
- tabOpt(
594
- 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(
595
- "after",
596
- `
597
-
598
- Examples:
599
- chrome-relay js "return document.title"
600
- chrome-relay js "return await fetch('/api/me').then(r => r.json())"
601
- chrome-relay js --tab 12345 "return document.querySelectorAll('article').length"
840
+ }
602
841
 
603
- Notes:
604
- Code is wrapped in an async IIFE. Top-level await works. Use \`return\` to send a value back.
605
- Returned value is JSON-serialized. DOM nodes and functions become {}.
606
- Runs in MAIN world: page globals, framework internals, and shadow roots are reachable.
607
- `
608
- )
609
- ).action(async (code, opts) => {
610
- const args = { code };
611
- Object.assign(args, baseArgs(opts));
612
- if (typeof opts.timeoutMs === "number") args.timeoutMs = opts.timeoutMs;
613
- await run("chrome_evaluate", args);
614
- });
615
- program.command("switch <tabId>").description("Activate a tab by ID.").action(async (tabId) => {
616
- await run("chrome_switch_tab", { tabId: Number(tabId) });
617
- });
618
- program.command("close <tabIds...>").description("Close one or more tabs by ID.").action(async (tabIds) => {
619
- await run("chrome_close_tabs", { tabIds: tabIds.map(Number) });
620
- });
621
- program.command("call <tool> [json]").description("Call any Chrome Relay tool with raw JSON args.").action(async (tool, json) => {
622
- const args = json ? JSON.parse(json) : {};
623
- await run(tool, args);
624
- });
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;
625
856
  const viewport = program.command("viewport").description("Emulate device viewport, DPR, mobile flag, touch, and user agent.").addHelpText(
626
857
  "after",
627
858
  `
@@ -670,46 +901,6 @@ Notes:
670
901
  program.command("self-reload").description("Restart the chrome-relay extension's service worker (picks up newly built code).").action(async () => {
671
902
  await run("chrome_self_reload", {});
672
903
  });
673
- tabOpt(
674
- 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(
675
- "after",
676
- `
677
-
678
- Examples:
679
- chrome-relay ax --tab 123
680
- chrome-relay ax --tab 123 --interactive-only
681
- chrome-relay ax --tab 123 --root main --interactive-only
682
-
683
- Notes:
684
- Each node carries an "id" \u2014 that's the backendDOMNodeId. Pass it to
685
- \`chrome-relay click-ax --node <id>\` to click without a CSS selector.
686
- `
687
- )
688
- ).action(async (opts) => {
689
- const args = baseArgs(opts);
690
- if (opts.interactiveOnly) args.interactiveOnly = true;
691
- if (opts.root) args.rootRole = opts.root;
692
- if (opts.includeSubframes) args.includeSubframes = true;
693
- await run("chrome_ax", args);
694
- });
695
- tabOpt(
696
- 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(
697
- "after",
698
- `
699
-
700
- Examples:
701
- chrome-relay click-ax --tab 123 --node 456
702
-
703
- Notes:
704
- Throws explicitly if the node id is stale (page mutated since you called
705
- \`ax\`). Re-run \`ax\` and pass the fresh id.
706
- `
707
- )
708
- ).action(async (opts) => {
709
- const args = baseArgs(opts);
710
- args.node = opts.node;
711
- await run("chrome_click_ax", args);
712
- });
713
904
  const workspace = program.command("workspace").description("Manage named Chrome windows so multiple agents can drive separate windows.").addHelpText(
714
905
  "after",
715
906
  `
@@ -783,17 +974,6 @@ Notes:
783
974
  const tabIds = String(opts.tabs).split(",").map((s) => Number(s.trim())).filter(Number.isFinite);
784
975
  await run("chrome_group", { action: "remove", tabIds });
785
976
  });
786
- function netFilterOpts(cmd) {
787
- 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));
788
- }
789
- function netFilterArgs(opts) {
790
- const a = {};
791
- if (opts.filter) a.filter = opts.filter;
792
- if (opts.status) a.status = opts.status;
793
- if (opts.method) a.method = opts.method;
794
- if (typeof opts.limit === "number") a.limit = opts.limit;
795
- return a;
796
- }
797
977
  const network = tabOpt(netFilterOpts(
798
978
  program.command("network").description("Capture HTTP request/response metadata. Ring buffer, last 200 per tab.")
799
979
  )).addHelpText(
@@ -883,171 +1063,41 @@ Notes:
883
1063
  if (typeof opts.limit === "number") args.limit = opts.limit;
884
1064
  await run("chrome_console", args);
885
1065
  });
886
- tabOpt(
887
- 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(
888
- "after",
889
- `
890
-
891
- Examples:
892
- chrome-relay hover --tab 123 'button[title="Install runner"]'
893
- chrome-relay hover --tab 123 --x 1327 --y 771
1066
+ }
894
1067
 
895
- Use before screencast to capture hover-driven micro-states (button glow,
896
- tooltip appearance, etc.) that a bare click would skip past too quickly.
897
- `
898
- )
899
- ).action(async (selector, opts) => {
900
- const args = {};
901
- Object.assign(args, baseArgs(opts));
902
- if (selector) args.selector = selector;
903
- if (typeof opts.x === "number" && typeof opts.y === "number") {
904
- args.x = opts.x;
905
- args.y = opts.y;
906
- }
907
- await run("chrome_hover", args);
908
- });
909
- 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(
910
1072
  "after",
911
1073
  `
912
1074
 
913
- Examples:
914
- chrome-relay screencast start --tab 123 --quality 80 --max-width 900
915
- # ... drive the interaction (hover, click, etc.) ...
916
- chrome-relay screencast stop --tab 123 --out /tmp/recording
917
-
918
- # The --out path becomes a directory of frame_NNNN.jpg files. If ffmpeg
919
- # is on PATH and --gif is also passed, an animated GIF is written next to
920
- # 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
921
1085
 
922
1086
  Notes:
923
- Frames buffer in the extension service worker. A 10-second capture at
924
- default settings (jpeg q=60, ~15fps, full viewport) lands ~2-3 MB.
925
- Pass --max-width to downscale and lighten the buffer.
926
- 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.
927
1089
  `
928
1090
  );
929
- tabOpt(
930
- 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))
931
- ).action(async (opts) => {
932
- const args = { action: "start" };
933
- Object.assign(args, baseArgs(opts));
934
- if (opts.format) args.format = opts.format;
935
- if (typeof opts.quality === "number") args.quality = opts.quality;
936
- if (typeof opts.maxWidth === "number") args.maxWidth = opts.maxWidth;
937
- if (typeof opts.maxHeight === "number") args.maxHeight = opts.maxHeight;
938
- if (typeof opts.everyNth === "number") args.everyNthFrame = opts.everyNth;
939
- await run("chrome_screencast", args);
940
- });
941
- tabOpt(
942
- 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")
943
- ).action(async (opts) => {
944
- const args = { action: "stop" };
945
- Object.assign(args, baseArgs(opts));
946
- try {
947
- const result = await callTool("chrome_screencast", args);
948
- if (!opts.out) {
949
- const { frames, ...summary } = result;
950
- process.stdout.write(JSON.stringify({ ...summary, framesOmitted: frames.length, hint: "pass --out <dir> to save" }, null, 2) + "\n");
951
- return;
952
- }
953
- const { mkdirSync, writeFileSync: writeFileSync2, renameSync, unlinkSync } = await import("fs");
954
- const path2 = await import("path");
955
- const { createHash } = await import("crypto");
956
- mkdirSync(opts.out, { recursive: true });
957
- result.frames.forEach((f, i) => {
958
- const name = `frame_${String(i + 1).padStart(4, "0")}.jpg`;
959
- writeFileSync2(path2.join(opts.out, name), Buffer.from(f.data, "base64"));
960
- });
961
- process.stdout.write(`Wrote ${result.frames.length} frames to ${opts.out}
962
- `);
963
- const dedupeOn = opts.dedupe !== false;
964
- if (dedupeOn && result.frames.length > 1) {
965
- const hashes = result.frames.map(
966
- (f) => createHash("sha256").update(Buffer.from(f.data, "base64")).digest("hex")
967
- );
968
- const kept = [];
969
- let prev = "";
970
- hashes.forEach((h, i) => {
971
- if (h !== prev) kept.push(i);
972
- prev = h;
973
- });
974
- const dropped = result.frames.length - kept.length;
975
- if (dropped > 0) {
976
- for (let i = 0; i < result.frames.length; i++) {
977
- const src = path2.join(opts.out, `frame_${String(i + 1).padStart(4, "0")}.jpg`);
978
- try {
979
- unlinkSync(src);
980
- } catch {
981
- }
982
- }
983
- kept.forEach((srcIdx, newIdx) => {
984
- const tmp = path2.join(opts.out, `tmp_${String(newIdx + 1).padStart(4, "0")}.jpg`);
985
- writeFileSync2(tmp, Buffer.from(result.frames[srcIdx].data, "base64"));
986
- });
987
- kept.forEach((_, newIdx) => {
988
- const tmp = path2.join(opts.out, `tmp_${String(newIdx + 1).padStart(4, "0")}.jpg`);
989
- const final = path2.join(opts.out, `frame_${String(newIdx + 1).padStart(4, "0")}.jpg`);
990
- renameSync(tmp, final);
991
- });
992
- process.stdout.write(`Deduped: dropped ${dropped} identical frames, ${kept.length} remain.
993
- `);
994
- } else {
995
- process.stdout.write(`Deduped: no consecutive duplicates found.
996
- `);
997
- }
998
- }
999
- if (opts.gif || opts.mp4) {
1000
- const fps = typeof opts.fps === "number" ? opts.fps : 15;
1001
- const { spawnSync } = await import("child_process");
1002
- const which = spawnSync("which", ["ffmpeg"]);
1003
- if (which.status !== 0) {
1004
- process.stderr.write("[chrome-relay] ffmpeg not on PATH \u2014 skipping --gif/--mp4.\n");
1005
- return;
1006
- }
1007
- if (opts.gif) {
1008
- const gifOut = `${opts.out.replace(/\/$/, "")}.gif`;
1009
- const r = spawnSync("ffmpeg", [
1010
- "-y",
1011
- "-framerate",
1012
- String(fps),
1013
- "-i",
1014
- path2.join(opts.out, "frame_%04d.jpg"),
1015
- "-vf",
1016
- `fps=${fps},split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse`,
1017
- "-loop",
1018
- "0",
1019
- gifOut
1020
- ], { stdio: "inherit" });
1021
- if (r.status === 0) process.stdout.write(`Wrote ${gifOut}
1022
- `);
1023
- }
1024
- if (opts.mp4) {
1025
- const mp4Out = `${opts.out.replace(/\/$/, "")}.mp4`;
1026
- const r = spawnSync("ffmpeg", [
1027
- "-y",
1028
- "-framerate",
1029
- String(fps),
1030
- "-i",
1031
- path2.join(opts.out, "frame_%04d.jpg"),
1032
- "-c:v",
1033
- "libx264",
1034
- "-pix_fmt",
1035
- "yuv420p",
1036
- "-crf",
1037
- "20",
1038
- mp4Out
1039
- ], { stdio: "inherit" });
1040
- if (r.status === 0) process.stdout.write(`Wrote ${mp4Out}
1041
- `);
1042
- }
1043
- }
1044
- } catch (error) {
1045
- process.stderr.write(
1046
- (error instanceof Error ? error.message : String(error)) + "\n"
1047
- );
1048
- process.exit(1);
1049
- }
1050
- });
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);
1051
1101
  return program;
1052
1102
  }
1053
1103
 
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/index.ts
2
- var CHROME_RELAY_VERSION = true ? "0.5.7" : "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.7" : "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.7",
3
+ "version": "0.5.9",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",