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 +228 -16
- package/dist/index.js +1 -1
- package/package.json +1 -1
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.
|
|
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("--
|
|
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("--
|
|
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
|
|
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
|
|
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
|
|
477
|
-
chrome-relay
|
|
478
|
-
chrome-relay --
|
|
479
|
-
chrome-relay --
|
|
480
|
-
chrome-relay
|
|
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
|
|
484
|
-
--
|
|
485
|
-
|
|
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
|
-
|
|
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 +
|
|
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("
|
|
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