chrome-relay 0.3.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -5,7 +5,7 @@ import { Command } from "commander";
5
5
  import { writeFileSync } from "fs";
6
6
 
7
7
  // src/index.ts
8
- var CHROME_RELAY_VERSION = true ? "0.3.3" : "0.0.0-dev";
8
+ var CHROME_RELAY_VERSION = true ? "0.5.0" : "0.0.0-dev";
9
9
 
10
10
  // src/install/install.ts
11
11
  import os from "os";
@@ -154,7 +154,7 @@ async function callTool(name, args) {
154
154
  // src/program.ts
155
155
  function buildProgram() {
156
156
  const program = new Command();
157
- program.name("chrome-relay").description("Connect your local Chrome browser to coding agents through a local bridge.").version(CHROME_RELAY_VERSION).showHelpAfterError().option("--group <name>", "target the active tab of a named group window (works at top level too)").enablePositionalOptions().addHelpText(
157
+ 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(
158
158
  "after",
159
159
  `
160
160
 
@@ -197,12 +197,15 @@ Notes:
197
197
  }
198
198
  }
199
199
  function tabOpt(cmd) {
200
- return cmd.option("-t, --tab <id>", "target tab ID", (v) => Number(v)).option("--group <name>", "target the active tab of a named group window (see `chrome-relay group`)");
200
+ 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`)");
201
201
  }
202
202
  function baseArgs(opts) {
203
203
  const args = {};
204
204
  if (opts.tab !== void 0) args.tabId = opts.tab;
205
- const effectiveGroup = opts.group ?? program.opts().group;
205
+ const parentOpts = program.opts();
206
+ const effectiveWorkspace = opts.workspace ?? parentOpts.workspace;
207
+ const effectiveGroup = opts.group ?? parentOpts.group;
208
+ if (effectiveWorkspace) args.workspaceName = effectiveWorkspace;
206
209
  if (effectiveGroup) args.groupName = effectiveGroup;
207
210
  return args;
208
211
  }
@@ -468,35 +471,79 @@ Notes:
468
471
  args.node = opts.node;
469
472
  await run("chrome_click_ax", args);
470
473
  });
471
- const group = program.command("group").description("Manage named Chrome windows so multiple agents can drive separate windows.").addHelpText(
474
+ const workspace = program.command("workspace").description("Manage named Chrome windows so multiple agents can drive separate windows.").addHelpText(
472
475
  "after",
473
476
  `
474
477
 
475
478
  Examples:
476
- chrome-relay group create bidsmith-h01 --url https://reddit.com
477
- chrome-relay group list
478
- chrome-relay --group bidsmith-h01 navigate https://news.ycombinator.com
479
- chrome-relay --group bidsmith-h01 screenshot -o evidence.png
480
- chrome-relay group close bidsmith-h01
479
+ chrome-relay workspace create bidsmith-h01 --url https://reddit.com
480
+ chrome-relay workspace list
481
+ chrome-relay --workspace bidsmith-h01 navigate https://news.ycombinator.com
482
+ chrome-relay --workspace bidsmith-h01 screenshot -o evidence.png
483
+ chrome-relay workspace close bidsmith-h01
481
484
 
482
485
  Notes:
483
- Hard lifecycle: if you manually close the group's window, the next
484
- --group operation fails loudly until you run \`group close\` + \`group create\` again.
485
- If you pass both --tab and --group on the same command, --tab wins.
486
+ Hard lifecycle: if you manually close the workspace's window, the next
487
+ --workspace operation fails loudly until you run \`workspace close\` +
488
+ \`workspace create\` again.
489
+ Precedence on a single command: --tab > --group > --workspace.
486
490
  `
487
491
  );
488
- group.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) => {
492
+ 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) => {
489
493
  const args = { action: "create", name };
490
494
  if (opts.url) args.url = opts.url;
491
495
  if (opts.label) args.label = opts.label;
496
+ await run("chrome_workspace", args);
497
+ });
498
+ workspace.command("list").description("List all known workspaces + whether their window is still alive.").action(async () => {
499
+ await run("chrome_workspace", { action: "list" });
500
+ });
501
+ workspace.command("close <name>").description("Close the workspace's window (if alive) and remove the binding.").action(async (name) => {
502
+ await run("chrome_workspace", { action: "close", name });
503
+ });
504
+ const group = program.command("group").description("Manage Chrome tab-groups (the colored, collapsible folders inside one window).").addHelpText(
505
+ "after",
506
+ `
507
+
508
+ Examples:
509
+ chrome-relay group create research --tabs 123,456,789 --color cyan
510
+ chrome-relay group list
511
+ chrome-relay group add research --tabs 1011
512
+ chrome-relay group remove --tabs 456
513
+ chrome-relay --group research navigate https://news.ycombinator.com
514
+ chrome-relay group close research
515
+
516
+ Notes:
517
+ Tab-groups live inside ONE Chrome window. To open in a specific window,
518
+ pass --workspace W on \`group create\` (we'll route the underlying
519
+ chrome.tabs.group call there).
520
+ \`--group X navigate --new\` opens the new tab into the group's window AND
521
+ drops it inside the group.
522
+ Auto-pruned when the group's last tab is ungrouped or its window closes.
523
+ Colors: grey, blue, red, yellow, green, pink, purple, cyan, orange.
524
+ `
525
+ );
526
+ 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) => {
527
+ const args = { action: "create", name };
528
+ args.tabIds = String(opts.tabs).split(",").map((s) => Number(s.trim())).filter(Number.isFinite);
529
+ if (opts.color) args.color = opts.color;
530
+ if (opts.collapsed) args.collapsed = true;
492
531
  await run("chrome_group", args);
493
532
  });
494
- group.command("list").description("List all known groups + whether their window is still alive.").action(async () => {
533
+ group.command("list").description("List all known tab-groups + their window/color/tabCount.").action(async () => {
495
534
  await run("chrome_group", { action: "list" });
496
535
  });
497
- group.command("close <name>").description("Close the group's window (if alive) and remove the binding.").action(async (name) => {
536
+ group.command("close <name>").description("Ungroup the tabs in <name> and remove the binding.").action(async (name) => {
498
537
  await run("chrome_group", { action: "close", name });
499
538
  });
539
+ group.command("add <name>").description("Add existing tabs to an existing tab-group.").requiredOption("--tabs <ids>", "comma-separated tab IDs to add").action(async (name, opts) => {
540
+ const tabIds = String(opts.tabs).split(",").map((s) => Number(s.trim())).filter(Number.isFinite);
541
+ await run("chrome_group", { action: "add", name, tabIds });
542
+ });
543
+ group.command("remove").description("Ungroup specific tabs (they remain open, just outside any tab-group).").requiredOption("--tabs <ids>", "comma-separated tab IDs to ungroup").action(async (opts) => {
544
+ const tabIds = String(opts.tabs).split(",").map((s) => Number(s.trim())).filter(Number.isFinite);
545
+ await run("chrome_group", { action: "remove", tabIds });
546
+ });
500
547
  function netFilterOpts(cmd) {
501
548
  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));
502
549
  }
@@ -597,6 +644,171 @@ Notes:
597
644
  if (typeof opts.limit === "number") args.limit = opts.limit;
598
645
  await run("chrome_console", args);
599
646
  });
647
+ tabOpt(
648
+ 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(
649
+ "after",
650
+ `
651
+
652
+ Examples:
653
+ chrome-relay hover --tab 123 'button[title="Install runner"]'
654
+ chrome-relay hover --tab 123 --x 1327 --y 771
655
+
656
+ Use before screencast to capture hover-driven micro-states (button glow,
657
+ tooltip appearance, etc.) that a bare click would skip past too quickly.
658
+ `
659
+ )
660
+ ).action(async (selector, opts) => {
661
+ const args = {};
662
+ Object.assign(args, baseArgs(opts));
663
+ if (selector) args.selector = selector;
664
+ if (typeof opts.x === "number" && typeof opts.y === "number") {
665
+ args.x = opts.x;
666
+ args.y = opts.y;
667
+ }
668
+ await run("chrome_hover", args);
669
+ });
670
+ const screencast = program.command("screencast").description("Record a tab via CDP (paint-driven). Requires an active tab.").addHelpText(
671
+ "after",
672
+ `
673
+
674
+ Examples:
675
+ chrome-relay screencast start --tab 123 --quality 80 --max-width 900
676
+ # ... drive the interaction (hover, click, etc.) ...
677
+ chrome-relay screencast stop --tab 123 --out /tmp/recording
678
+
679
+ # The --out path becomes a directory of frame_NNNN.jpg files. If ffmpeg
680
+ # is on PATH and --gif is also passed, an animated GIF is written next to
681
+ # the frames at /tmp/recording.gif.
682
+
683
+ Notes:
684
+ Frames buffer in the extension service worker. A 10-second capture at
685
+ default settings (jpeg q=60, ~15fps, full viewport) lands ~2-3 MB.
686
+ Pass --max-width to downscale and lighten the buffer.
687
+ Each frame is base64 JPEG; the CLI decodes them when --out is given.
688
+ `
689
+ );
690
+ tabOpt(
691
+ 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))
692
+ ).action(async (opts) => {
693
+ const args = { action: "start" };
694
+ Object.assign(args, baseArgs(opts));
695
+ if (opts.format) args.format = opts.format;
696
+ if (typeof opts.quality === "number") args.quality = opts.quality;
697
+ if (typeof opts.maxWidth === "number") args.maxWidth = opts.maxWidth;
698
+ if (typeof opts.maxHeight === "number") args.maxHeight = opts.maxHeight;
699
+ if (typeof opts.everyNth === "number") args.everyNthFrame = opts.everyNth;
700
+ await run("chrome_screencast", args);
701
+ });
702
+ tabOpt(
703
+ 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")
704
+ ).action(async (opts) => {
705
+ const args = { action: "stop" };
706
+ Object.assign(args, baseArgs(opts));
707
+ try {
708
+ const result = await callTool("chrome_screencast", args);
709
+ if (!opts.out) {
710
+ const { frames, ...summary } = result;
711
+ process.stdout.write(JSON.stringify({ ...summary, framesOmitted: frames.length, hint: "pass --out <dir> to save" }, null, 2) + "\n");
712
+ return;
713
+ }
714
+ const { mkdirSync, writeFileSync: writeFileSync2, renameSync, unlinkSync } = await import("fs");
715
+ const path2 = await import("path");
716
+ const { createHash } = await import("crypto");
717
+ mkdirSync(opts.out, { recursive: true });
718
+ result.frames.forEach((f, i) => {
719
+ const name = `frame_${String(i + 1).padStart(4, "0")}.jpg`;
720
+ writeFileSync2(path2.join(opts.out, name), Buffer.from(f.data, "base64"));
721
+ });
722
+ process.stdout.write(`Wrote ${result.frames.length} frames to ${opts.out}
723
+ `);
724
+ const dedupeOn = opts.dedupe !== false;
725
+ if (dedupeOn && result.frames.length > 1) {
726
+ const hashes = result.frames.map(
727
+ (f) => createHash("sha256").update(Buffer.from(f.data, "base64")).digest("hex")
728
+ );
729
+ const kept = [];
730
+ let prev = "";
731
+ hashes.forEach((h, i) => {
732
+ if (h !== prev) kept.push(i);
733
+ prev = h;
734
+ });
735
+ const dropped = result.frames.length - kept.length;
736
+ if (dropped > 0) {
737
+ for (let i = 0; i < result.frames.length; i++) {
738
+ const src = path2.join(opts.out, `frame_${String(i + 1).padStart(4, "0")}.jpg`);
739
+ try {
740
+ unlinkSync(src);
741
+ } catch {
742
+ }
743
+ }
744
+ kept.forEach((srcIdx, newIdx) => {
745
+ const tmp = path2.join(opts.out, `tmp_${String(newIdx + 1).padStart(4, "0")}.jpg`);
746
+ writeFileSync2(tmp, Buffer.from(result.frames[srcIdx].data, "base64"));
747
+ });
748
+ kept.forEach((_, newIdx) => {
749
+ const tmp = path2.join(opts.out, `tmp_${String(newIdx + 1).padStart(4, "0")}.jpg`);
750
+ const final = path2.join(opts.out, `frame_${String(newIdx + 1).padStart(4, "0")}.jpg`);
751
+ renameSync(tmp, final);
752
+ });
753
+ process.stdout.write(`Deduped: dropped ${dropped} identical frames, ${kept.length} remain.
754
+ `);
755
+ } else {
756
+ process.stdout.write(`Deduped: no consecutive duplicates found.
757
+ `);
758
+ }
759
+ }
760
+ if (opts.gif || opts.mp4) {
761
+ const fps = typeof opts.fps === "number" ? opts.fps : 15;
762
+ const { spawnSync } = await import("child_process");
763
+ const which = spawnSync("which", ["ffmpeg"]);
764
+ if (which.status !== 0) {
765
+ process.stderr.write("[chrome-relay] ffmpeg not on PATH \u2014 skipping --gif/--mp4.\n");
766
+ return;
767
+ }
768
+ if (opts.gif) {
769
+ const gifOut = `${opts.out.replace(/\/$/, "")}.gif`;
770
+ const r = spawnSync("ffmpeg", [
771
+ "-y",
772
+ "-framerate",
773
+ String(fps),
774
+ "-i",
775
+ path2.join(opts.out, "frame_%04d.jpg"),
776
+ "-vf",
777
+ `fps=${fps},split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse`,
778
+ "-loop",
779
+ "0",
780
+ gifOut
781
+ ], { stdio: "inherit" });
782
+ if (r.status === 0) process.stdout.write(`Wrote ${gifOut}
783
+ `);
784
+ }
785
+ if (opts.mp4) {
786
+ const mp4Out = `${opts.out.replace(/\/$/, "")}.mp4`;
787
+ const r = spawnSync("ffmpeg", [
788
+ "-y",
789
+ "-framerate",
790
+ String(fps),
791
+ "-i",
792
+ path2.join(opts.out, "frame_%04d.jpg"),
793
+ "-c:v",
794
+ "libx264",
795
+ "-pix_fmt",
796
+ "yuv420p",
797
+ "-crf",
798
+ "20",
799
+ mp4Out
800
+ ], { stdio: "inherit" });
801
+ if (r.status === 0) process.stdout.write(`Wrote ${mp4Out}
802
+ `);
803
+ }
804
+ }
805
+ } catch (error) {
806
+ process.stderr.write(
807
+ (error instanceof Error ? error.message : String(error)) + "\n"
808
+ );
809
+ process.exit(1);
810
+ }
811
+ });
600
812
  return program;
601
813
  }
602
814
 
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/index.ts
2
- var CHROME_RELAY_VERSION = true ? "0.3.3" : "0.0.0-dev";
2
+ var CHROME_RELAY_VERSION = true ? "0.5.0" : "0.0.0-dev";
3
3
  export {
4
4
  CHROME_RELAY_VERSION
5
5
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chrome-relay",
3
- "version": "0.3.3",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",