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 +476 -426
- package/dist/index.js +1 -1
- package/dist/native-host.js +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
// src/program.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
|
-
|
|
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/
|
|
46
|
-
var
|
|
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/
|
|
270
|
-
function
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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("
|
|
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
|
|
492
|
-
chrome-relay
|
|
493
|
-
chrome-relay
|
|
494
|
-
chrome-relay
|
|
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
|
-
|
|
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.
|
|
506
|
-
|
|
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("
|
|
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
|
|
559
|
-
chrome-relay
|
|
560
|
-
chrome-relay
|
|
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
|
-
|
|
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 (
|
|
567
|
-
const args =
|
|
568
|
-
|
|
569
|
-
|
|
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("
|
|
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
|
|
578
|
-
chrome-relay type "appended into already-focused element"
|
|
686
|
+
chrome-relay click-ax --tab 123 --node 456
|
|
579
687
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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 (
|
|
588
|
-
const args =
|
|
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.
|
|
591
|
-
|
|
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
|
-
|
|
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
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
|
|
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
|
-
|
|
896
|
-
|
|
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
|
-
|
|
914
|
-
chrome-relay
|
|
915
|
-
|
|
916
|
-
chrome-relay
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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
|
-
|
|
924
|
-
|
|
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
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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
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) {
|