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 +501 -464
- package/dist/index.js +1 -1
- package/dist/native-host.js +1 -1
- package/package.json +1 -1
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.
|
|
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/
|
|
277
|
-
function
|
|
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
|
-
|
|
463
|
-
|
|
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
|
-
|
|
499
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
)
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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
|
|
644
|
-
chrome-relay
|
|
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
|
-
|
|
650
|
-
|
|
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
|
-
|
|
656
|
-
|
|
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 (
|
|
661
|
-
if (opts.
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
|
653
|
+
const args = {};
|
|
677
654
|
Object.assign(args, baseArgs(opts));
|
|
678
|
-
|
|
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
|
|
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
|
|
732
|
-
|
|
733
|
-
chrome-relay --
|
|
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
|
-
|
|
761
|
-
|
|
762
|
-
|
|
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
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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
|
-
|
|
786
|
-
|
|
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
|
-
|
|
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
|
-
|
|
909
|
-
|
|
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
|
-
|
|
927
|
-
chrome-relay
|
|
928
|
-
|
|
929
|
-
chrome-relay
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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
|
-
|
|
937
|
-
|
|
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
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
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
package/dist/native-host.js
CHANGED
|
@@ -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.
|
|
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) {
|