@phnx-labs/agents-cli 1.18.3 → 1.18.5
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/CHANGELOG.md +89 -0
- package/README.md +14 -7
- package/dist/commands/browser.js +503 -132
- package/dist/commands/factory.js +13 -1
- package/dist/commands/rules.js +14 -0
- package/dist/commands/secrets.js +66 -11
- package/dist/lib/browser/cdp.js +7 -1
- package/dist/lib/browser/chrome.d.ts +1 -1
- package/dist/lib/browser/chrome.js +52 -26
- package/dist/lib/browser/devices.d.ts +11 -0
- package/dist/lib/browser/devices.js +14 -3
- package/dist/lib/browser/drivers/local.js +29 -2
- package/dist/lib/browser/drivers/ssh.js +82 -7
- package/dist/lib/browser/ipc.js +84 -9
- package/dist/lib/browser/profiles.d.ts +69 -3
- package/dist/lib/browser/profiles.js +184 -20
- package/dist/lib/browser/runtime-state.d.ts +117 -0
- package/dist/lib/browser/runtime-state.js +259 -0
- package/dist/lib/browser/service.d.ts +57 -10
- package/dist/lib/browser/service.js +477 -73
- package/dist/lib/browser/types.d.ts +67 -2
- package/dist/lib/browser/types.js +20 -0
- package/dist/lib/daemon.js +36 -3
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/help.js +30 -3
- package/dist/lib/secrets/bundles.d.ts +20 -0
- package/dist/lib/secrets/bundles.js +56 -0
- package/dist/lib/secrets/index.js +8 -8
- package/dist/lib/types.d.ts +16 -1
- package/dist/lib/version.d.ts +7 -0
- package/dist/lib/version.js +25 -0
- package/package.json +1 -1
package/dist/commands/browser.js
CHANGED
|
@@ -1,22 +1,64 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
|
-
import { listProfiles, getProfile, createProfile, deleteProfile, getProfileRuntimeDir, extractConfiguredPort, findFreeProfilePort, } from '../lib/browser/profiles.js';
|
|
3
|
+
import { listProfiles, getProfile, createProfile, deleteProfile, getProfileRuntimeDir, extractConfiguredPort, findFreeProfilePort, getEndpointPresets, } from '../lib/browser/profiles.js';
|
|
4
4
|
import { findBrowserPath, getPortOccupant } from '../lib/browser/chrome.js';
|
|
5
|
+
import { listProfileCacheDirs, removeProfileCache, listAllProfileSnapshots, } from '../lib/browser/runtime-state.js';
|
|
6
|
+
import { DEFAULT_VIEWPORT } from '../lib/browser/devices.js';
|
|
5
7
|
import { discoverBrowserWsUrl, verifyBrowserIdentity } from '../lib/browser/cdp.js';
|
|
6
8
|
import { parseTargetFilter } from '../lib/browser/service.js';
|
|
7
9
|
import { sendIPCRequest } from '../lib/browser/ipc.js';
|
|
8
10
|
import { browserTaskPicker } from './browser-picker.js';
|
|
9
11
|
import { isInteractiveTerminal } from './utils.js';
|
|
12
|
+
import { registerCommandGroups } from '../lib/help.js';
|
|
13
|
+
/**
|
|
14
|
+
* Resolve which browser task a command targets. Order:
|
|
15
|
+
* 1. `--task <name>` flag (explicit per-command override)
|
|
16
|
+
* 2. `$AGENTS_BROWSER_TASK` (set once at the start of an agent run)
|
|
17
|
+
*
|
|
18
|
+
* Each agent process has its own environment, so the env-var path is safe for
|
|
19
|
+
* parallel agents — they can't see each other's value.
|
|
20
|
+
*/
|
|
21
|
+
function resolveTaskName(opts) {
|
|
22
|
+
if (opts.task)
|
|
23
|
+
return opts.task;
|
|
24
|
+
const fromEnv = process.env.AGENTS_BROWSER_TASK;
|
|
25
|
+
if (fromEnv)
|
|
26
|
+
return fromEnv;
|
|
27
|
+
console.error('No task specified. Pass --task <name> or set AGENTS_BROWSER_TASK in your shell.');
|
|
28
|
+
console.error('Tip: T=$(agents browser start --profile <p>) && export AGENTS_BROWSER_TASK=$T');
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
// `-t` is taken by `--tab` on most commands, so `--task` is long-form only.
|
|
32
|
+
// Agents normally set $AGENTS_BROWSER_TASK once and never type this flag.
|
|
33
|
+
const TASK_OPTION_FLAG = '--task <name>';
|
|
34
|
+
const TASK_OPTION_DESC = 'Task name (defaults to $AGENTS_BROWSER_TASK)';
|
|
35
|
+
// Help groups — surfaces the actual mental model an agent follows
|
|
36
|
+
// ("open a session / drive the page / capture evidence / rare extras")
|
|
37
|
+
// instead of an alphabetical dump. Everything not listed falls into a
|
|
38
|
+
// trailing "Other commands" section automatically.
|
|
39
|
+
const BROWSER_HELP_GROUPS = [
|
|
40
|
+
{ title: 'Session lifecycle', names: ['start', 'done', 'status'] },
|
|
41
|
+
{
|
|
42
|
+
title: 'Drive the page',
|
|
43
|
+
names: ['navigate', 'tabs', 'screenshot', 'evaluate', 'click', 'type', 'press', 'wait'],
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
title: 'Capture evidence',
|
|
47
|
+
names: ['console', 'errors', 'requests', 'responsebody', 'record', 'logs'],
|
|
48
|
+
},
|
|
49
|
+
];
|
|
10
50
|
export function registerBrowserCommand(program) {
|
|
11
51
|
const browser = program
|
|
12
52
|
.command('browser')
|
|
13
53
|
.description('Browser automation via CDP');
|
|
14
54
|
registerProfilesCommands(browser);
|
|
15
55
|
registerTaskCommands(browser);
|
|
56
|
+
registerCommandGroups(browser, BROWSER_HELP_GROUPS);
|
|
16
57
|
}
|
|
17
58
|
export function registerBrowserSubcommands(program) {
|
|
18
59
|
registerProfilesCommands(program);
|
|
19
60
|
registerTaskCommands(program);
|
|
61
|
+
registerCommandGroups(program, BROWSER_HELP_GROUPS);
|
|
20
62
|
}
|
|
21
63
|
function registerProfilesCommands(browser) {
|
|
22
64
|
const profiles = browser
|
|
@@ -38,7 +80,10 @@ function registerProfilesCommands(browser) {
|
|
|
38
80
|
console.log('NAME'.padEnd(20) + 'BROWSER'.padEnd(12) + 'DESCRIPTION'.padEnd(38) + 'ENDPOINTS');
|
|
39
81
|
console.log('-'.repeat(92));
|
|
40
82
|
for (const p of allProfiles) {
|
|
41
|
-
const
|
|
83
|
+
const presets = getEndpointPresets(p);
|
|
84
|
+
const endpoints = Object.entries(presets)
|
|
85
|
+
.map(([name, ep]) => (name.startsWith('endpoint-') ? ep.target : `${name}=${ep.target}`))
|
|
86
|
+
.join(', ');
|
|
42
87
|
const desc = (p.description ?? '').slice(0, 36).padEnd(38);
|
|
43
88
|
console.log(p.name.padEnd(20) + (p.browser || '-').padEnd(12) + desc + endpoints);
|
|
44
89
|
}
|
|
@@ -47,7 +92,10 @@ function registerProfilesCommands(browser) {
|
|
|
47
92
|
console.log('NAME'.padEnd(20) + 'BROWSER'.padEnd(12) + 'ENDPOINTS');
|
|
48
93
|
console.log('-'.repeat(72));
|
|
49
94
|
for (const p of allProfiles) {
|
|
50
|
-
const
|
|
95
|
+
const presets = getEndpointPresets(p);
|
|
96
|
+
const endpoints = Object.entries(presets)
|
|
97
|
+
.map(([name, ep]) => (name.startsWith('endpoint-') ? ep.target : `${name}=${ep.target}`))
|
|
98
|
+
.join(', ');
|
|
51
99
|
console.log(p.name.padEnd(20) + (p.browser || '-').padEnd(12) + endpoints);
|
|
52
100
|
}
|
|
53
101
|
}
|
|
@@ -61,7 +109,7 @@ function registerProfilesCommands(browser) {
|
|
|
61
109
|
.option('-s, --secrets <bundle>', 'Secrets bundle to inject')
|
|
62
110
|
.option('-d, --description <text>', 'Profile description')
|
|
63
111
|
.option('--headless', 'Run in headless mode')
|
|
64
|
-
.option('--window <WxH>',
|
|
112
|
+
.option('--window <WxH>', `Window size in CSS pixels (default: ${DEFAULT_VIEWPORT.width}x${DEFAULT_VIEWPORT.height}, MacBook Pro 14")`)
|
|
65
113
|
.option('--position <X,Y>', 'Window position on screen, e.g. 80,80')
|
|
66
114
|
.option('--binary <path>', 'Absolute path to the browser/app binary (required with --browser custom)')
|
|
67
115
|
.option('--electron', 'Treat this profile as an Electron desktop app: never call Target.createTarget; bind to the visible window using --target-filter or the skip-invisible heuristic')
|
|
@@ -100,15 +148,16 @@ function registerProfilesCommands(browser) {
|
|
|
100
148
|
const freePort = await findFreeProfilePort();
|
|
101
149
|
endpoints = [`cdp://127.0.0.1:${freePort}`];
|
|
102
150
|
}
|
|
103
|
-
// Viewport is mandatory — default to
|
|
151
|
+
// Viewport is mandatory — default to MacBook Pro 14" (1512x982) if
|
|
152
|
+
// --window is not provided. See lib/browser/devices.ts DEFAULT_VIEWPORT.
|
|
104
153
|
let viewport = {
|
|
105
|
-
width:
|
|
106
|
-
height:
|
|
154
|
+
width: DEFAULT_VIEWPORT.width,
|
|
155
|
+
height: DEFAULT_VIEWPORT.height,
|
|
107
156
|
};
|
|
108
157
|
if (opts.window) {
|
|
109
158
|
const m = String(opts.window).match(/^(\d+)x(\d+)$/);
|
|
110
159
|
if (!m) {
|
|
111
|
-
console.error(
|
|
160
|
+
console.error(`--window must be WxH, e.g. ${DEFAULT_VIEWPORT.width}x${DEFAULT_VIEWPORT.height}`);
|
|
112
161
|
process.exit(1);
|
|
113
162
|
}
|
|
114
163
|
viewport.width = parseInt(m[1], 10);
|
|
@@ -167,9 +216,24 @@ function registerProfilesCommands(browser) {
|
|
|
167
216
|
console.log(`Target filter: ${profile.targetFilter}`);
|
|
168
217
|
if (profile.description)
|
|
169
218
|
console.log(`Description: ${profile.description}`);
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
219
|
+
const presets = getEndpointPresets(profile);
|
|
220
|
+
const defaultName = profile.defaultEndpoint && presets[profile.defaultEndpoint]
|
|
221
|
+
? profile.defaultEndpoint
|
|
222
|
+
: Object.keys(presets)[0];
|
|
223
|
+
console.log('Endpoints:');
|
|
224
|
+
for (const [presetName, preset] of Object.entries(presets)) {
|
|
225
|
+
const marker = presetName === defaultName ? ' (default)' : '';
|
|
226
|
+
const isLegacy = presetName.startsWith('endpoint-');
|
|
227
|
+
console.log(` - ${isLegacy ? preset.target : `${presetName}: ${preset.target}`}${marker}`);
|
|
228
|
+
if (preset.binary)
|
|
229
|
+
console.log(` binary: ${preset.binary}`);
|
|
230
|
+
if (preset.targetFilter)
|
|
231
|
+
console.log(` targetFilter: ${preset.targetFilter}`);
|
|
232
|
+
}
|
|
233
|
+
if (profile.viewport) {
|
|
234
|
+
const v = profile.viewport;
|
|
235
|
+
const pos = v.x !== undefined && v.y !== undefined ? ` @ ${v.x},${v.y}` : '';
|
|
236
|
+
console.log(`Viewport: ${v.width}×${v.height}${pos}`);
|
|
173
237
|
}
|
|
174
238
|
if (profile.secrets)
|
|
175
239
|
console.log(`Secrets: ${profile.secrets}`);
|
|
@@ -178,31 +242,32 @@ function registerProfilesCommands(browser) {
|
|
|
178
242
|
});
|
|
179
243
|
profiles
|
|
180
244
|
.command('delete <name>')
|
|
181
|
-
.description('Delete a browser profile')
|
|
182
|
-
.
|
|
245
|
+
.description('Delete a browser profile (drops YAML config + all cached runtime dirs)')
|
|
246
|
+
.option('--keep-cache', "Leave ~/.agents/.cache/browser/<name>* dirs in place (don't wipe chrome-data)")
|
|
247
|
+
.action(async (name, opts) => {
|
|
183
248
|
await deleteProfile(name);
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
249
|
+
// The composite naming change introduced multiple cache dirs per
|
|
250
|
+
// profile (`<name>`, `<name>@endpoint-0`, …). Sweep them all unless
|
|
251
|
+
// the user explicitly wants the chrome-data preserved (e.g. for
|
|
252
|
+
// re-import into a freshly-created profile of the same name).
|
|
253
|
+
let removed = 0;
|
|
254
|
+
if (!opts.keepCache) {
|
|
255
|
+
const cacheDirs = listProfileCacheDirs(name);
|
|
256
|
+
removed = cacheDirs.length;
|
|
257
|
+
for (const dir of cacheDirs) {
|
|
258
|
+
// `removeProfileCache` operates by profile-name; for the
|
|
259
|
+
// composite dirs we already have the absolute path. Use rmSync
|
|
260
|
+
// directly so we don't depend on naming round-trips.
|
|
261
|
+
try {
|
|
262
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
263
|
+
}
|
|
264
|
+
catch { /* ignore */ }
|
|
265
|
+
}
|
|
266
|
+
// The canonical wipe also covers the legacy dir if present.
|
|
267
|
+
removeProfileCache(name);
|
|
202
268
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
console.log(`Next: agents browser start --profile ${name} --url <url>`);
|
|
269
|
+
console.log(`Deleted profile: ${name}` +
|
|
270
|
+
(removed > 0 ? ` (and ${removed} cache dir${removed === 1 ? '' : 's'})` : ''));
|
|
206
271
|
});
|
|
207
272
|
profiles
|
|
208
273
|
.command('doctor <name>')
|
|
@@ -226,12 +291,31 @@ function registerProfilesCommands(browser) {
|
|
|
226
291
|
detail: err instanceof Error ? err.message : String(err),
|
|
227
292
|
});
|
|
228
293
|
}
|
|
229
|
-
// 2. Configured port
|
|
294
|
+
// 2. Configured port. For local cdp:// we check the local port. For
|
|
295
|
+
// ssh:// the port lives on a remote host — doctor's previous
|
|
296
|
+
// behavior was to lsof the LOCAL port number, which was both
|
|
297
|
+
// misleading and arbitrary (after the SSH-binds-locally change
|
|
298
|
+
// the local port now matches the remote, so a positive answer
|
|
299
|
+
// is plausible; but doctor still shouldn't report on remote
|
|
300
|
+
// state without an --remote-probe explicitly).
|
|
230
301
|
const port = extractConfiguredPort(profile);
|
|
231
302
|
let attachingToExistingBrowser = false;
|
|
303
|
+
const firstEndpointTarget = (() => {
|
|
304
|
+
const presets = getEndpointPresets(profile);
|
|
305
|
+
const first = Object.keys(presets)[0];
|
|
306
|
+
return first ? presets[first].target : undefined;
|
|
307
|
+
})();
|
|
308
|
+
const isSshEndpoint = firstEndpointTarget?.startsWith('ssh://') ?? false;
|
|
232
309
|
if (port === undefined) {
|
|
233
310
|
checks.push({ label: 'port', ok: true, detail: 'no port in endpoint' });
|
|
234
311
|
}
|
|
312
|
+
else if (isSshEndpoint) {
|
|
313
|
+
checks.push({
|
|
314
|
+
label: 'port',
|
|
315
|
+
ok: true,
|
|
316
|
+
detail: `${port} (remote on ${firstEndpointTarget}) — skipping local check`,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
235
319
|
else {
|
|
236
320
|
const occupant = getPortOccupant(port);
|
|
237
321
|
if (!occupant) {
|
|
@@ -302,7 +386,9 @@ function registerProfilesCommands(browser) {
|
|
|
302
386
|
checks.push({
|
|
303
387
|
label: 'onboarding',
|
|
304
388
|
ok: false,
|
|
305
|
-
detail: 'Local State is empty — run `agents browser
|
|
389
|
+
detail: 'Local State is empty — run `agents browser start --profile ' +
|
|
390
|
+
name +
|
|
391
|
+
'` and finish any first-run screens before automating',
|
|
306
392
|
});
|
|
307
393
|
}
|
|
308
394
|
}
|
|
@@ -310,7 +396,9 @@ function registerProfilesCommands(browser) {
|
|
|
310
396
|
checks.push({
|
|
311
397
|
label: 'onboarding',
|
|
312
398
|
ok: false,
|
|
313
|
-
detail: 'Not
|
|
399
|
+
detail: 'Not initialized yet — run `agents browser start --profile ' +
|
|
400
|
+
name +
|
|
401
|
+
'` and finish any first-run screens before automating',
|
|
314
402
|
});
|
|
315
403
|
}
|
|
316
404
|
}
|
|
@@ -322,63 +410,68 @@ function registerProfilesCommands(browser) {
|
|
|
322
410
|
if (!allOk)
|
|
323
411
|
process.exit(1);
|
|
324
412
|
});
|
|
325
|
-
profiles
|
|
326
|
-
.command('prime <name>')
|
|
327
|
-
.description('Launch the profile so you can complete first-run onboarding interactively')
|
|
328
|
-
.action(async (name) => {
|
|
329
|
-
const profile = await getProfile(name);
|
|
330
|
-
if (!profile) {
|
|
331
|
-
console.error(`Profile "${name}" not found`);
|
|
332
|
-
process.exit(1);
|
|
333
|
-
}
|
|
334
|
-
const response = await sendIPCRequest({
|
|
335
|
-
action: 'launch-profile',
|
|
336
|
-
profile: name,
|
|
337
|
-
});
|
|
338
|
-
if (!response.ok) {
|
|
339
|
-
console.error(response.error);
|
|
340
|
-
process.exit(1);
|
|
341
|
-
}
|
|
342
|
-
const pidLabel = response.pid ? `pid ${response.pid}` : 'attached';
|
|
343
|
-
console.log(`Launched "${name}" on port ${response.port} (${pidLabel}).`);
|
|
344
|
-
console.log('');
|
|
345
|
-
console.log('Finish any first-run / onboarding screens in the browser window');
|
|
346
|
-
console.log('(welcome, profile setup, default-browser prompt, sign-in, etc.).');
|
|
347
|
-
console.log('Once you reach a normal browsing surface, this profile is primed');
|
|
348
|
-
console.log('— its user-data-dir persists across runs, so you only do this once.');
|
|
349
|
-
console.log('');
|
|
350
|
-
console.log(`Next: agents browser start --profile ${name} --url <url>`);
|
|
351
|
-
});
|
|
352
413
|
}
|
|
353
414
|
function registerTaskCommands(browser) {
|
|
354
415
|
browser
|
|
355
416
|
.command('start')
|
|
356
417
|
.description('Start a browser task with a profile')
|
|
357
418
|
.requiredOption('-p, --profile <name>', 'Browser profile to use')
|
|
358
|
-
.option(
|
|
419
|
+
.option(TASK_OPTION_FLAG, 'Task name (auto-generated if omitted)')
|
|
420
|
+
.option('-e, --endpoint <name>', 'Endpoint preset (defaults to the profile\'s default)')
|
|
359
421
|
.option('-u, --url <url>', 'Open URL in first tab')
|
|
360
422
|
.action(async (opts) => {
|
|
423
|
+
// Pre-check the profile locally so we fail fast with a helpful error
|
|
424
|
+
// instead of round-tripping a generic "Profile not found" through the daemon.
|
|
425
|
+
const profile = await getProfile(opts.profile);
|
|
426
|
+
if (!profile) {
|
|
427
|
+
console.error(`Profile "${opts.profile}" not found.`);
|
|
428
|
+
const all = await listProfiles();
|
|
429
|
+
if (all.length > 0) {
|
|
430
|
+
console.error(`Available profiles: ${all.map((p) => p.name).join(', ')}`);
|
|
431
|
+
}
|
|
432
|
+
console.error(`Create one with: agents browser profiles create ${opts.profile} --browser <chrome|comet|chromium|brave|edge|custom>`);
|
|
433
|
+
process.exit(1);
|
|
434
|
+
}
|
|
435
|
+
// Pre-check the endpoint name too — same fail-fast rationale.
|
|
436
|
+
if (opts.endpoint) {
|
|
437
|
+
const presets = getEndpointPresets(profile);
|
|
438
|
+
if (!presets[opts.endpoint]) {
|
|
439
|
+
console.error(`Endpoint "${opts.endpoint}" not found on profile "${opts.profile}". ` +
|
|
440
|
+
`Available: ${Object.keys(presets).join(', ')}`);
|
|
441
|
+
process.exit(1);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
361
444
|
const response = await sendIPCRequest({
|
|
362
445
|
action: 'start',
|
|
363
446
|
profile: opts.profile,
|
|
364
447
|
taskName: opts.task,
|
|
365
448
|
url: opts.url,
|
|
449
|
+
endpoint: opts.endpoint,
|
|
366
450
|
});
|
|
367
451
|
if (!response.ok) {
|
|
368
452
|
console.error(response.error);
|
|
369
453
|
process.exit(1);
|
|
370
454
|
}
|
|
455
|
+
// stdout: just the resolved name, one line, no decoration. Lets callers do:
|
|
456
|
+
// export AGENTS_BROWSER_TASK=$(agents browser start --profile work)
|
|
457
|
+
console.log(response.task);
|
|
458
|
+
// stderr: human-friendly commentary so a TTY user still sees what happened.
|
|
459
|
+
// Shell substitution captures stdout only, so $(...) stays clean.
|
|
371
460
|
if (opts.url && response.tabId) {
|
|
372
|
-
console.
|
|
461
|
+
console.error(`Started task "${response.task}" with tab ${response.tabId}`);
|
|
373
462
|
}
|
|
374
463
|
else {
|
|
375
|
-
console.
|
|
464
|
+
console.error(`Started task "${response.task}"`);
|
|
376
465
|
}
|
|
466
|
+
console.error(`Tip: export AGENTS_BROWSER_TASK=${response.task}`);
|
|
467
|
+
console.error('Try: agents browser screenshot | agents browser console --level error');
|
|
377
468
|
});
|
|
378
469
|
browser
|
|
379
|
-
.command('done
|
|
470
|
+
.command('done')
|
|
380
471
|
.description('Complete a task and close its tabs')
|
|
381
|
-
.
|
|
472
|
+
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
473
|
+
.action(async (opts) => {
|
|
474
|
+
const task = resolveTaskName(opts);
|
|
382
475
|
const response = await sendIPCRequest({
|
|
383
476
|
action: 'done',
|
|
384
477
|
task,
|
|
@@ -390,9 +483,24 @@ function registerTaskCommands(browser) {
|
|
|
390
483
|
console.log(`Completed task: ${task}`);
|
|
391
484
|
});
|
|
392
485
|
browser
|
|
393
|
-
.command('stop
|
|
394
|
-
.description('Stop a browser task and close its tabs')
|
|
395
|
-
.
|
|
486
|
+
.command('stop')
|
|
487
|
+
.description('Stop a browser task and close its tabs; with --profile, detach the whole profile (close browser + drop cached connection)')
|
|
488
|
+
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
489
|
+
.option('-p, --profile <name>', 'Detach the whole profile (incl. composite "name@endpoint") instead of stopping a single task')
|
|
490
|
+
.action(async (opts) => {
|
|
491
|
+
if (opts.profile) {
|
|
492
|
+
const response = await sendIPCRequest({
|
|
493
|
+
action: 'stop',
|
|
494
|
+
profile: opts.profile,
|
|
495
|
+
});
|
|
496
|
+
if (!response.ok) {
|
|
497
|
+
console.error(response.error);
|
|
498
|
+
process.exit(1);
|
|
499
|
+
}
|
|
500
|
+
console.log(`Stopped profile: ${opts.profile}`);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
const task = resolveTaskName(opts);
|
|
396
504
|
const response = await sendIPCRequest({
|
|
397
505
|
action: 'stop',
|
|
398
506
|
task,
|
|
@@ -404,45 +512,53 @@ function registerTaskCommands(browser) {
|
|
|
404
512
|
console.log(`Stopped task: ${task}`);
|
|
405
513
|
});
|
|
406
514
|
browser
|
|
407
|
-
.command('navigate
|
|
515
|
+
.command('navigate')
|
|
408
516
|
.description('Navigate current tab to URL (creates tab if none exist)')
|
|
517
|
+
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
518
|
+
.requiredOption('--url <url>', 'URL to navigate to')
|
|
409
519
|
.option('-p, --profile <name>', 'Browser profile (optional if task is unique)')
|
|
410
|
-
.action(async (
|
|
520
|
+
.action(async (opts) => {
|
|
521
|
+
const task = resolveTaskName(opts);
|
|
411
522
|
const response = await sendIPCRequest({
|
|
412
523
|
action: 'navigate',
|
|
413
524
|
task,
|
|
414
|
-
url,
|
|
525
|
+
url: opts.url,
|
|
415
526
|
profile: opts.profile,
|
|
416
527
|
});
|
|
417
528
|
if (!response.ok) {
|
|
418
529
|
console.error(response.error);
|
|
419
530
|
process.exit(1);
|
|
420
531
|
}
|
|
421
|
-
console.log(`Navigated ${response.tabId} to ${url}`);
|
|
532
|
+
console.log(`Navigated ${response.tabId} to ${opts.url}`);
|
|
422
533
|
});
|
|
423
534
|
// Tab subcommand group
|
|
424
535
|
const tab = browser.command('tab').description('Manage tabs');
|
|
425
536
|
tab
|
|
426
|
-
.command('add
|
|
537
|
+
.command('add')
|
|
427
538
|
.description('Open URL in new tab (becomes current)')
|
|
539
|
+
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
540
|
+
.requiredOption('--url <url>', 'URL to open in the new tab')
|
|
428
541
|
.option('-p, --profile <name>', 'Browser profile')
|
|
429
|
-
.action(async (
|
|
542
|
+
.action(async (opts) => {
|
|
543
|
+
const task = resolveTaskName(opts);
|
|
430
544
|
const response = await sendIPCRequest({
|
|
431
545
|
action: 'tab-add',
|
|
432
546
|
task,
|
|
433
|
-
url,
|
|
547
|
+
url: opts.url,
|
|
434
548
|
profile: opts.profile,
|
|
435
549
|
});
|
|
436
550
|
if (!response.ok) {
|
|
437
551
|
console.error(response.error);
|
|
438
552
|
process.exit(1);
|
|
439
553
|
}
|
|
440
|
-
console.log(`Opened tab ${response.tabId}: ${url}`);
|
|
554
|
+
console.log(`Opened tab ${response.tabId}: ${opts.url}`);
|
|
441
555
|
});
|
|
442
556
|
tab
|
|
443
|
-
.command('focus <
|
|
557
|
+
.command('focus <tabId>')
|
|
444
558
|
.description('Switch to tab (by ID, prefix, or URL substring)')
|
|
445
|
-
.
|
|
559
|
+
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
560
|
+
.action(async (tabId, opts) => {
|
|
561
|
+
const task = resolveTaskName(opts);
|
|
446
562
|
const response = await sendIPCRequest({
|
|
447
563
|
action: 'tab-focus',
|
|
448
564
|
task,
|
|
@@ -455,9 +571,11 @@ function registerTaskCommands(browser) {
|
|
|
455
571
|
console.log(`Focused tab ${response.tabId}`);
|
|
456
572
|
});
|
|
457
573
|
tab
|
|
458
|
-
.command('close
|
|
574
|
+
.command('close [tabId]')
|
|
459
575
|
.description('Close tab(s) — omit tabId to close all')
|
|
460
|
-
.
|
|
576
|
+
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
577
|
+
.action(async (tabId, opts) => {
|
|
578
|
+
const task = resolveTaskName(opts);
|
|
461
579
|
const response = await sendIPCRequest({
|
|
462
580
|
action: 'tab-close',
|
|
463
581
|
task,
|
|
@@ -469,11 +587,13 @@ function registerTaskCommands(browser) {
|
|
|
469
587
|
}
|
|
470
588
|
console.log(tabId ? `Closed tab ${tabId}` : `Closed all tabs for ${task}`);
|
|
471
589
|
});
|
|
472
|
-
|
|
473
|
-
.command('
|
|
474
|
-
.description('List tabs for
|
|
590
|
+
browser
|
|
591
|
+
.command('tabs')
|
|
592
|
+
.description('List tabs open for the current task')
|
|
593
|
+
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
475
594
|
.option('--json', 'Output machine-readable JSON')
|
|
476
|
-
.action(async (
|
|
595
|
+
.action(async (opts) => {
|
|
596
|
+
const task = resolveTaskName(opts);
|
|
477
597
|
const response = await sendIPCRequest({
|
|
478
598
|
action: 'tab-list',
|
|
479
599
|
task,
|
|
@@ -503,27 +623,73 @@ function registerTaskCommands(browser) {
|
|
|
503
623
|
}
|
|
504
624
|
});
|
|
505
625
|
browser
|
|
506
|
-
.command('screenshot
|
|
507
|
-
.description('Take a screenshot')
|
|
508
|
-
.option(
|
|
509
|
-
.
|
|
626
|
+
.command('screenshot')
|
|
627
|
+
.description('Take a screenshot — auto-saved per task; --output only needed when you want a specific path')
|
|
628
|
+
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
629
|
+
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
630
|
+
.option('-o, --output <path>', 'Specific output path (otherwise auto-saved under sessions/<task>/)')
|
|
631
|
+
.option('-q, --quality <mode>', 'compressed (JPEG, capped at ~100 KB — default) or raw (PNG, pixel-faithful)', 'compressed')
|
|
632
|
+
.action(async (opts) => {
|
|
633
|
+
const task = resolveTaskName(opts);
|
|
634
|
+
if (opts.quality !== 'compressed' && opts.quality !== 'raw') {
|
|
635
|
+
console.error('--quality must be "compressed" or "raw"');
|
|
636
|
+
process.exit(1);
|
|
637
|
+
}
|
|
510
638
|
const response = await sendIPCRequest({
|
|
511
639
|
action: 'screenshot',
|
|
512
640
|
task,
|
|
513
|
-
tabId,
|
|
641
|
+
tabId: opts.tab,
|
|
514
642
|
path: opts.output,
|
|
643
|
+
quality: opts.quality,
|
|
515
644
|
});
|
|
516
645
|
if (!response.ok) {
|
|
517
646
|
console.error(response.error);
|
|
518
647
|
process.exit(1);
|
|
519
648
|
}
|
|
649
|
+
// stdout: just the path, so `P=$(agents browser screenshot)` works.
|
|
520
650
|
console.log(response.path);
|
|
651
|
+
// stderr: human commentary with size + dimensions, so an agent can
|
|
652
|
+
// see at a glance what was captured without `ls -l && file` round-trips.
|
|
653
|
+
const size = humanizeBytes(response.bytes);
|
|
654
|
+
const dims = response.width && response.height ? `${response.width}×${response.height}` : 'unknown size';
|
|
655
|
+
console.error(`Saved screenshot to ${response.path} (${size}, ${dims})`);
|
|
656
|
+
// When auto-saving (no --output), surface the directory once so the
|
|
657
|
+
// agent doesn't have to dirname() the path or guess where files land.
|
|
658
|
+
if (!opts.output && response.path) {
|
|
659
|
+
const dir = path.dirname(response.path);
|
|
660
|
+
console.error(`Tip: auto-saving to ${dir}. Pass --output <path> to choose a path.`);
|
|
661
|
+
}
|
|
521
662
|
});
|
|
522
663
|
browser
|
|
523
|
-
.command('evaluate
|
|
664
|
+
.command('evaluate')
|
|
524
665
|
.description('Evaluate JavaScript in current tab')
|
|
666
|
+
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
525
667
|
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
526
|
-
.
|
|
668
|
+
.option('-e, --expression <js>', 'JavaScript expression to evaluate')
|
|
669
|
+
.option('-f, --file <path>', 'Path to a .js file whose contents will be evaluated')
|
|
670
|
+
.action(async (opts) => {
|
|
671
|
+
const task = resolveTaskName(opts);
|
|
672
|
+
if (opts.expression && opts.file) {
|
|
673
|
+
console.error('Pass exactly one of --expression or --file');
|
|
674
|
+
process.exit(1);
|
|
675
|
+
}
|
|
676
|
+
let expression;
|
|
677
|
+
if (opts.file) {
|
|
678
|
+
try {
|
|
679
|
+
expression = fs.readFileSync(opts.file, 'utf8');
|
|
680
|
+
}
|
|
681
|
+
catch (err) {
|
|
682
|
+
console.error(`Cannot read --file ${opts.file}: ${err.message}`);
|
|
683
|
+
process.exit(1);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
else if (opts.expression) {
|
|
687
|
+
expression = opts.expression;
|
|
688
|
+
}
|
|
689
|
+
else {
|
|
690
|
+
console.error('Pass --expression <js> or --file <path>');
|
|
691
|
+
process.exit(1);
|
|
692
|
+
}
|
|
527
693
|
const response = await sendIPCRequest({
|
|
528
694
|
action: 'evaluate',
|
|
529
695
|
task,
|
|
@@ -536,6 +702,73 @@ function registerTaskCommands(browser) {
|
|
|
536
702
|
}
|
|
537
703
|
console.log(JSON.stringify(response.result, null, 2));
|
|
538
704
|
});
|
|
705
|
+
browser
|
|
706
|
+
.command('ps')
|
|
707
|
+
.description('List every browser/electron/tunnel process agents has tracked (alive or stale) — works without the daemon')
|
|
708
|
+
.option('--json', 'Output machine-readable JSON')
|
|
709
|
+
.action((opts) => {
|
|
710
|
+
const snapshots = listAllProfileSnapshots();
|
|
711
|
+
// Cross-check against what's actually listening locally so we can
|
|
712
|
+
// surface "port claimed by us but nothing is listening" (= leaked
|
|
713
|
+
// cache file) and "port listening but not in our records" (= someone
|
|
714
|
+
// else owns it; a new profile pointing here would collide).
|
|
715
|
+
const portOwners = new Map();
|
|
716
|
+
const conflicts = [];
|
|
717
|
+
for (const s of snapshots) {
|
|
718
|
+
const port = s.meta?.port;
|
|
719
|
+
if (!port)
|
|
720
|
+
continue;
|
|
721
|
+
const occupant = getPortOccupant(port);
|
|
722
|
+
if (!occupant) {
|
|
723
|
+
if (s.pidAlive || s.tunnelAlive) {
|
|
724
|
+
conflicts.push(`${s.name}: port ${port} marked active but nothing is listening`);
|
|
725
|
+
}
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
const ourPid = s.meta?.tunnelPid && s.meta.kind === 'tunnel'
|
|
729
|
+
? s.meta.tunnelPid
|
|
730
|
+
: s.meta?.pid;
|
|
731
|
+
if (ourPid && occupant.pid !== ourPid) {
|
|
732
|
+
conflicts.push(`${s.name}: port ${port} listened on by ${occupant.command} (pid ${occupant.pid}) but our record says pid ${ourPid}`);
|
|
733
|
+
}
|
|
734
|
+
portOwners.set(port, occupant);
|
|
735
|
+
}
|
|
736
|
+
if (opts.json) {
|
|
737
|
+
console.log(JSON.stringify({ snapshots, conflicts }, null, 2));
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
if (snapshots.length === 0) {
|
|
741
|
+
console.log('No tracked browser state. Run `agents browser start --profile <name>` to spawn one.');
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
console.log('PROFILE KIND PID TUNNEL PORT ALIVE TASKS OWNER');
|
|
745
|
+
console.log('-----------------------------------------------------------------------------------------------');
|
|
746
|
+
for (const s of snapshots) {
|
|
747
|
+
const kind = s.meta?.kind ?? '-';
|
|
748
|
+
const pid = s.meta?.pid ?? '-';
|
|
749
|
+
const tunnelPid = s.meta?.tunnelPid ?? '-';
|
|
750
|
+
const port = s.meta?.port ?? '-';
|
|
751
|
+
const alive = aliveLabel(s);
|
|
752
|
+
const owner = s.meta?.daemonPid
|
|
753
|
+
? `daemon${s.daemonAlive ? '' : '(dead)'}=${s.meta.daemonPid}`
|
|
754
|
+
: '-';
|
|
755
|
+
console.log(`${s.name.padEnd(40)} ${String(kind).padEnd(9)} ${String(pid).padEnd(6)} ${String(tunnelPid).padEnd(7)} ${String(port).padEnd(6)} ${alive.padEnd(6)} ${String(s.taskCount).padEnd(6)} ${owner}`);
|
|
756
|
+
}
|
|
757
|
+
if (conflicts.length > 0) {
|
|
758
|
+
console.log('');
|
|
759
|
+
console.log('Conflicts / leaks detected:');
|
|
760
|
+
for (const c of conflicts)
|
|
761
|
+
console.log(` - ${c}`);
|
|
762
|
+
console.log('');
|
|
763
|
+
console.log('Run `agents browser stop --profile <name>` to clean up a specific profile, or restart the daemon to trigger the orphan reaper.');
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
function aliveLabel(s) {
|
|
767
|
+
const k = s.meta?.kind;
|
|
768
|
+
if (k === 'tunnel')
|
|
769
|
+
return s.tunnelAlive ? 'yes' : 'stale';
|
|
770
|
+
return s.pidAlive ? 'yes' : 'stale';
|
|
771
|
+
}
|
|
539
772
|
browser
|
|
540
773
|
.command('status')
|
|
541
774
|
.description('Show running browser tasks')
|
|
@@ -749,13 +982,15 @@ function registerTaskCommands(browser) {
|
|
|
749
982
|
}
|
|
750
983
|
});
|
|
751
984
|
browser
|
|
752
|
-
.command('refs
|
|
985
|
+
.command('refs')
|
|
753
986
|
.description('Get DOM refs for interactive elements')
|
|
987
|
+
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
754
988
|
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
755
989
|
.option('--all', 'Include non-interactive elements')
|
|
756
990
|
.option('-l, --limit <n>', 'Max elements (default 500)', '500')
|
|
757
991
|
.option('--json', 'Output machine-readable JSON')
|
|
758
|
-
.action(async (
|
|
992
|
+
.action(async (opts) => {
|
|
993
|
+
const task = resolveTaskName(opts);
|
|
759
994
|
const response = await sendIPCRequest({
|
|
760
995
|
action: 'refs',
|
|
761
996
|
task,
|
|
@@ -779,10 +1014,12 @@ function registerTaskCommands(browser) {
|
|
|
779
1014
|
console.log(response.refs);
|
|
780
1015
|
});
|
|
781
1016
|
browser
|
|
782
|
-
.command('click <
|
|
1017
|
+
.command('click <ref>')
|
|
783
1018
|
.description('Click an element by ref')
|
|
1019
|
+
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
784
1020
|
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
785
|
-
.action(async (
|
|
1021
|
+
.action(async (ref, opts) => {
|
|
1022
|
+
const task = resolveTaskName(opts);
|
|
786
1023
|
const response = await sendIPCRequest({
|
|
787
1024
|
action: 'click',
|
|
788
1025
|
task,
|
|
@@ -796,17 +1033,25 @@ function registerTaskCommands(browser) {
|
|
|
796
1033
|
console.log('Clicked');
|
|
797
1034
|
});
|
|
798
1035
|
browser
|
|
799
|
-
.command('type <
|
|
1036
|
+
.command('type <ref>')
|
|
800
1037
|
.description('Type text into an element by ref')
|
|
1038
|
+
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
801
1039
|
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
1040
|
+
.requiredOption('--text <text>', 'Text to type (use quotes for spaces/special chars)')
|
|
802
1041
|
.option('--clear', 'Clear editor content before typing')
|
|
803
|
-
.action(async (
|
|
1042
|
+
.action(async (ref, opts) => {
|
|
1043
|
+
const task = resolveTaskName(opts);
|
|
1044
|
+
const refNum = parseInt(ref, 10);
|
|
1045
|
+
if (!Number.isFinite(refNum)) {
|
|
1046
|
+
console.error(`<ref> must be an integer, got: ${ref}`);
|
|
1047
|
+
process.exit(1);
|
|
1048
|
+
}
|
|
804
1049
|
const response = await sendIPCRequest({
|
|
805
1050
|
action: 'type',
|
|
806
1051
|
task,
|
|
807
1052
|
tabId: opts.tab,
|
|
808
|
-
ref:
|
|
809
|
-
text,
|
|
1053
|
+
ref: refNum,
|
|
1054
|
+
text: opts.text,
|
|
810
1055
|
clear: opts.clear,
|
|
811
1056
|
});
|
|
812
1057
|
if (!response.ok) {
|
|
@@ -816,10 +1061,12 @@ function registerTaskCommands(browser) {
|
|
|
816
1061
|
console.log('Typed');
|
|
817
1062
|
});
|
|
818
1063
|
browser
|
|
819
|
-
.command('press <
|
|
1064
|
+
.command('press <key>')
|
|
820
1065
|
.description('Press a key (Enter, Tab, Escape, etc)')
|
|
1066
|
+
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
821
1067
|
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
822
|
-
.action(async (
|
|
1068
|
+
.action(async (key, opts) => {
|
|
1069
|
+
const task = resolveTaskName(opts);
|
|
823
1070
|
const response = await sendIPCRequest({
|
|
824
1071
|
action: 'press',
|
|
825
1072
|
task,
|
|
@@ -833,10 +1080,12 @@ function registerTaskCommands(browser) {
|
|
|
833
1080
|
console.log('Pressed');
|
|
834
1081
|
});
|
|
835
1082
|
browser
|
|
836
|
-
.command('hover <
|
|
1083
|
+
.command('hover <ref>')
|
|
837
1084
|
.description('Hover over an element by ref')
|
|
1085
|
+
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
838
1086
|
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
839
|
-
.action(async (
|
|
1087
|
+
.action(async (ref, opts) => {
|
|
1088
|
+
const task = resolveTaskName(opts);
|
|
840
1089
|
const response = await sendIPCRequest({
|
|
841
1090
|
action: 'hover',
|
|
842
1091
|
task,
|
|
@@ -850,18 +1099,30 @@ function registerTaskCommands(browser) {
|
|
|
850
1099
|
console.log('Hovered');
|
|
851
1100
|
});
|
|
852
1101
|
browser
|
|
853
|
-
.command('scroll
|
|
854
|
-
.description('Scroll the page by pixel amount')
|
|
1102
|
+
.command('scroll')
|
|
1103
|
+
.description('Scroll the page by pixel amount (negatives scroll up/left)')
|
|
1104
|
+
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
855
1105
|
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
1106
|
+
.option('--dx <n>', 'Horizontal pixels (negative = left)', (v) => parseInt(v, 10), 0)
|
|
1107
|
+
.option('--dy <n>', 'Vertical pixels (negative = up)', (v) => parseInt(v, 10), 0)
|
|
856
1108
|
.option('-x, --at-x <x>', 'X coordinate to dispatch scroll from (default 0)', parseInt)
|
|
857
1109
|
.option('-y, --at-y <y>', 'Y coordinate to dispatch scroll from (default 0)', parseInt)
|
|
858
|
-
.action(async (
|
|
1110
|
+
.action(async (opts) => {
|
|
1111
|
+
const task = resolveTaskName(opts);
|
|
1112
|
+
if (!Number.isFinite(opts.dx) || !Number.isFinite(opts.dy)) {
|
|
1113
|
+
console.error('--dx and --dy must be integers');
|
|
1114
|
+
process.exit(1);
|
|
1115
|
+
}
|
|
1116
|
+
if (opts.dx === 0 && opts.dy === 0) {
|
|
1117
|
+
console.error('Pass --dx and/or --dy (at least one must be non-zero)');
|
|
1118
|
+
process.exit(1);
|
|
1119
|
+
}
|
|
859
1120
|
const response = await sendIPCRequest({
|
|
860
1121
|
action: 'scroll',
|
|
861
1122
|
task,
|
|
862
1123
|
tabId: opts.tab,
|
|
863
|
-
scrollX:
|
|
864
|
-
scrollY:
|
|
1124
|
+
scrollX: opts.dx,
|
|
1125
|
+
scrollY: opts.dy,
|
|
865
1126
|
scrollAtX: opts.atX,
|
|
866
1127
|
scrollAtY: opts.atY,
|
|
867
1128
|
});
|
|
@@ -872,8 +1133,9 @@ function registerTaskCommands(browser) {
|
|
|
872
1133
|
console.log('Scrolled');
|
|
873
1134
|
});
|
|
874
1135
|
browser
|
|
875
|
-
.command('upload
|
|
1136
|
+
.command('upload')
|
|
876
1137
|
.description('Upload file(s) — supports hidden file inputs, drag-drop targets, and OS chooser interception')
|
|
1138
|
+
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
877
1139
|
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
878
1140
|
.option('-r, --ref <n>', 'Ref of the upload target element (file input or drop zone)', (v) => parseInt(v, 10))
|
|
879
1141
|
.option('--trigger <n>', 'Ref of a button that opens the OS file chooser (Pattern C)', (v) => parseInt(v, 10))
|
|
@@ -881,7 +1143,8 @@ function registerTaskCommands(browser) {
|
|
|
881
1143
|
.option('--drop', 'Force drag-drop pattern even if ref is an <input type=file>')
|
|
882
1144
|
.option('--input', 'Force file-input pattern (DOM.setFileInputFiles)')
|
|
883
1145
|
.option('--timeout <ms>', 'Timeout for chooser interception (Pattern C)', (v) => parseInt(v, 10))
|
|
884
|
-
.action(async (
|
|
1146
|
+
.action(async (opts) => {
|
|
1147
|
+
const task = resolveTaskName(opts);
|
|
885
1148
|
const files = opts.file ?? [];
|
|
886
1149
|
if (files.length === 0) {
|
|
887
1150
|
console.error('--file <path> is required (repeat for multiple files)');
|
|
@@ -921,12 +1184,14 @@ function registerTaskCommands(browser) {
|
|
|
921
1184
|
// ─── Viewport & Device ───────────────────────────────────────────────────────
|
|
922
1185
|
const setCmd = browser.command('set').description('Set browser emulation options');
|
|
923
1186
|
setCmd
|
|
924
|
-
.command('viewport <
|
|
1187
|
+
.command('viewport <width> <height>')
|
|
925
1188
|
.description('Set viewport size')
|
|
1189
|
+
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
926
1190
|
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
927
1191
|
.option('-m, --mobile', 'Enable mobile emulation')
|
|
928
1192
|
.option('-s, --scale <factor>', 'Device scale factor', parseFloat)
|
|
929
|
-
.action(async (
|
|
1193
|
+
.action(async (width, height, opts) => {
|
|
1194
|
+
const task = resolveTaskName(opts);
|
|
930
1195
|
const response = await sendIPCRequest({
|
|
931
1196
|
action: 'set-viewport',
|
|
932
1197
|
task,
|
|
@@ -943,10 +1208,12 @@ function registerTaskCommands(browser) {
|
|
|
943
1208
|
console.log(`Viewport set to ${width}x${height}${opts.mobile ? ' (mobile)' : ''}`);
|
|
944
1209
|
});
|
|
945
1210
|
setCmd
|
|
946
|
-
.command('device <
|
|
1211
|
+
.command('device <device-name>')
|
|
947
1212
|
.description('Emulate a device (iPhone 14, iPad, MacBook Pro)')
|
|
1213
|
+
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
948
1214
|
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
949
|
-
.action(async (
|
|
1215
|
+
.action(async (deviceName, opts) => {
|
|
1216
|
+
const task = resolveTaskName(opts);
|
|
950
1217
|
const response = await sendIPCRequest({
|
|
951
1218
|
action: 'set-device',
|
|
952
1219
|
task,
|
|
@@ -971,12 +1238,14 @@ function registerTaskCommands(browser) {
|
|
|
971
1238
|
});
|
|
972
1239
|
// ─── Console & Errors ────────────────────────────────────────────────────────
|
|
973
1240
|
browser
|
|
974
|
-
.command('console
|
|
1241
|
+
.command('console')
|
|
975
1242
|
.description('Read console logs from a tab')
|
|
1243
|
+
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
976
1244
|
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
977
1245
|
.option('-l, --level <level>', 'Filter by level (log, info, warn, error)')
|
|
978
1246
|
.option('--clear', 'Clear logs after reading')
|
|
979
|
-
.action(async (
|
|
1247
|
+
.action(async (opts) => {
|
|
1248
|
+
const task = resolveTaskName(opts);
|
|
980
1249
|
const response = await sendIPCRequest({
|
|
981
1250
|
action: 'console',
|
|
982
1251
|
task,
|
|
@@ -999,11 +1268,13 @@ function registerTaskCommands(browser) {
|
|
|
999
1268
|
}
|
|
1000
1269
|
});
|
|
1001
1270
|
browser
|
|
1002
|
-
.command('errors
|
|
1271
|
+
.command('errors')
|
|
1003
1272
|
.description('Read page errors from a tab')
|
|
1273
|
+
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
1004
1274
|
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
1005
1275
|
.option('--clear', 'Clear errors after reading')
|
|
1006
|
-
.action(async (
|
|
1276
|
+
.action(async (opts) => {
|
|
1277
|
+
const task = resolveTaskName(opts);
|
|
1007
1278
|
const response = await sendIPCRequest({
|
|
1008
1279
|
action: 'errors',
|
|
1009
1280
|
task,
|
|
@@ -1029,12 +1300,14 @@ function registerTaskCommands(browser) {
|
|
|
1029
1300
|
});
|
|
1030
1301
|
// ─── Network ─────────────────────────────────────────────────────────────────
|
|
1031
1302
|
browser
|
|
1032
|
-
.command('requests
|
|
1303
|
+
.command('requests')
|
|
1033
1304
|
.description('Read captured network requests')
|
|
1305
|
+
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
1034
1306
|
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
1035
1307
|
.option('-f, --filter <text>', 'Filter URLs containing text')
|
|
1036
1308
|
.option('--clear', 'Clear requests after reading')
|
|
1037
|
-
.action(async (
|
|
1309
|
+
.action(async (opts) => {
|
|
1310
|
+
const task = resolveTaskName(opts);
|
|
1038
1311
|
const response = await sendIPCRequest({
|
|
1039
1312
|
action: 'requests',
|
|
1040
1313
|
task,
|
|
@@ -1058,12 +1331,50 @@ function registerTaskCommands(browser) {
|
|
|
1058
1331
|
}
|
|
1059
1332
|
});
|
|
1060
1333
|
browser
|
|
1061
|
-
.command('
|
|
1334
|
+
.command('logs <task>')
|
|
1335
|
+
.description('Read merged rush-app + rush-cli JSONL logs for a task')
|
|
1336
|
+
.option('--source <name>', 'Source to scope to: rush-app or rush-cli (default both)')
|
|
1337
|
+
.option('--lines <n>', 'Tail N entries (default 200; ignored when --since)', (v) => parseInt(v, 10))
|
|
1338
|
+
.option('--since <when>', 'Absolute timestamp or relative offset (e.g. 5m, 2h, 1d)')
|
|
1339
|
+
.option('--until <when>', 'Absolute timestamp or relative offset (e.g. 5m, 2h, 1d)')
|
|
1340
|
+
.option('--level <level>', 'Filter entries by level field')
|
|
1341
|
+
.option('--message <name>', 'Filter entries by exact message field')
|
|
1342
|
+
.option('--filter <text>', 'Filter entries whose JSON contains this substring')
|
|
1343
|
+
.option('-f, --follow', 'Follow mode (not yet implemented)')
|
|
1344
|
+
.action(async (task, opts) => {
|
|
1345
|
+
if (opts.follow) {
|
|
1346
|
+
process.stderr.write('follow mode not yet implemented; coming next pass\n');
|
|
1347
|
+
process.exit(1);
|
|
1348
|
+
}
|
|
1349
|
+
const response = await sendIPCRequest({
|
|
1350
|
+
action: 'getAppLogs',
|
|
1351
|
+
task,
|
|
1352
|
+
source: opts.source,
|
|
1353
|
+
lines: opts.lines,
|
|
1354
|
+
since: opts.since,
|
|
1355
|
+
until: opts.until,
|
|
1356
|
+
appLevel: opts.level,
|
|
1357
|
+
message: opts.message,
|
|
1358
|
+
filter: opts.filter,
|
|
1359
|
+
});
|
|
1360
|
+
if (!response.ok) {
|
|
1361
|
+
console.error(response.error);
|
|
1362
|
+
process.exit(1);
|
|
1363
|
+
}
|
|
1364
|
+
const entries = response.appLogs ?? [];
|
|
1365
|
+
for (const entry of entries) {
|
|
1366
|
+
console.log(JSON.stringify(entry));
|
|
1367
|
+
}
|
|
1368
|
+
});
|
|
1369
|
+
browser
|
|
1370
|
+
.command('responsebody <url-pattern>')
|
|
1062
1371
|
.description('Wait for and read a response body by URL pattern')
|
|
1372
|
+
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
1063
1373
|
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
1064
1374
|
.option('--timeout <ms>', 'Timeout in milliseconds', parseInt)
|
|
1065
1375
|
.option('--max-chars <n>', 'Max characters to return', parseInt)
|
|
1066
|
-
.action(async (
|
|
1376
|
+
.action(async (urlPattern, opts) => {
|
|
1377
|
+
const task = resolveTaskName(opts);
|
|
1067
1378
|
const response = await sendIPCRequest({
|
|
1068
1379
|
action: 'response-body',
|
|
1069
1380
|
task,
|
|
@@ -1080,8 +1391,9 @@ function registerTaskCommands(browser) {
|
|
|
1080
1391
|
});
|
|
1081
1392
|
// ─── Wait ────────────────────────────────────────────────────────────────────
|
|
1082
1393
|
browser
|
|
1083
|
-
.command('wait
|
|
1394
|
+
.command('wait')
|
|
1084
1395
|
.description('Wait for a condition')
|
|
1396
|
+
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
1085
1397
|
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
1086
1398
|
.option('--time <ms>', 'Wait for milliseconds')
|
|
1087
1399
|
.option('--selector <css>', 'Wait for CSS selector to appear')
|
|
@@ -1089,7 +1401,8 @@ function registerTaskCommands(browser) {
|
|
|
1089
1401
|
.option('--fn <js>', 'Wait for JS expression to return truthy')
|
|
1090
1402
|
.option('--state <state>', 'Wait for load state (domcontentloaded, load, networkidle)')
|
|
1091
1403
|
.option('--timeout <ms>', 'Timeout in milliseconds', parseInt)
|
|
1092
|
-
.action(async (
|
|
1404
|
+
.action(async (opts) => {
|
|
1405
|
+
const task = resolveTaskName(opts);
|
|
1093
1406
|
let waitType;
|
|
1094
1407
|
let waitValue;
|
|
1095
1408
|
if (opts.time) {
|
|
@@ -1132,11 +1445,13 @@ function registerTaskCommands(browser) {
|
|
|
1132
1445
|
});
|
|
1133
1446
|
// ─── Downloads ───────────────────────────────────────────────────────────────
|
|
1134
1447
|
browser
|
|
1135
|
-
.command('download
|
|
1448
|
+
.command('download')
|
|
1136
1449
|
.description('Set download directory for a task')
|
|
1450
|
+
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
1137
1451
|
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
1138
1452
|
.requiredOption('-p, --path <dir>', 'Download directory path')
|
|
1139
|
-
.action(async (
|
|
1453
|
+
.action(async (opts) => {
|
|
1454
|
+
const task = resolveTaskName(opts);
|
|
1140
1455
|
const response = await sendIPCRequest({
|
|
1141
1456
|
action: 'set-download-path',
|
|
1142
1457
|
task,
|
|
@@ -1149,11 +1464,58 @@ function registerTaskCommands(browser) {
|
|
|
1149
1464
|
}
|
|
1150
1465
|
console.log(`Download path set to ${opts.path}`);
|
|
1151
1466
|
});
|
|
1467
|
+
// ─── Recording ─────────────────────────────────────────────────────────────
|
|
1468
|
+
const record = browser.command('record').description('Record a video of the page');
|
|
1469
|
+
record
|
|
1470
|
+
.command('start')
|
|
1471
|
+
.description('Start recording — auto-saved under sessions/<task>/recordings/. Bounded by --fps, --duration, --max-mb.')
|
|
1472
|
+
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
1473
|
+
.option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
|
|
1474
|
+
.option('--fps <n>', 'Frames per second (1–30, default 5)', (v) => parseInt(v, 10))
|
|
1475
|
+
.option('--duration <sec>', 'Hard duration cap in seconds (default 60)', (v) => parseInt(v, 10))
|
|
1476
|
+
.option('--max-mb <mb>', 'Stop when output exceeds this many MB (default 25)', (v) => parseInt(v, 10))
|
|
1477
|
+
.action(async (opts) => {
|
|
1478
|
+
const task = resolveTaskName(opts);
|
|
1479
|
+
const response = await sendIPCRequest({
|
|
1480
|
+
action: 'record-start',
|
|
1481
|
+
task,
|
|
1482
|
+
tabId: opts.tab,
|
|
1483
|
+
fps: opts.fps,
|
|
1484
|
+
duration: opts.duration,
|
|
1485
|
+
maxMb: opts.maxMb,
|
|
1486
|
+
});
|
|
1487
|
+
if (!response.ok) {
|
|
1488
|
+
console.error(response.error);
|
|
1489
|
+
process.exit(1);
|
|
1490
|
+
}
|
|
1491
|
+
// stdout: path (for capture into a variable). stderr: human commentary.
|
|
1492
|
+
console.log(response.path);
|
|
1493
|
+
console.error(`Recording task "${task}" at ${response.fps} fps (cap ${response.durationCapSec}s / ${response.maxMb} MB) → ${response.path}`);
|
|
1494
|
+
console.error('Stop with: agents browser record stop');
|
|
1495
|
+
});
|
|
1496
|
+
record
|
|
1497
|
+
.command('stop')
|
|
1498
|
+
.description('Stop an in-progress recording')
|
|
1499
|
+
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
1500
|
+
.action(async (opts) => {
|
|
1501
|
+
const task = resolveTaskName(opts);
|
|
1502
|
+
const response = await sendIPCRequest({ action: 'record-stop', task });
|
|
1503
|
+
if (!response.ok) {
|
|
1504
|
+
console.error(response.error);
|
|
1505
|
+
process.exit(1);
|
|
1506
|
+
}
|
|
1507
|
+
console.log(response.path);
|
|
1508
|
+
const size = humanizeBytes(response.bytes);
|
|
1509
|
+
const seconds = ((response.durationMs ?? 0) / 1000).toFixed(1);
|
|
1510
|
+
console.error(`Saved recording to ${response.path} (${size}, ${seconds}s, stopped: ${response.stopReason})`);
|
|
1511
|
+
});
|
|
1152
1512
|
browser
|
|
1153
|
-
.command('waitdownload
|
|
1513
|
+
.command('waitdownload')
|
|
1154
1514
|
.description('Wait for a download to complete')
|
|
1515
|
+
.option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
|
|
1155
1516
|
.option('--timeout <ms>', 'Timeout in milliseconds', parseInt)
|
|
1156
|
-
.action(async (
|
|
1517
|
+
.action(async (opts) => {
|
|
1518
|
+
const task = resolveTaskName(opts);
|
|
1157
1519
|
const response = await sendIPCRequest({
|
|
1158
1520
|
action: 'wait-download',
|
|
1159
1521
|
task,
|
|
@@ -1180,6 +1542,15 @@ function formatAge(timestamp) {
|
|
|
1180
1542
|
const hours = Math.floor(minutes / 60);
|
|
1181
1543
|
return `${hours}h ago`;
|
|
1182
1544
|
}
|
|
1545
|
+
function humanizeBytes(n) {
|
|
1546
|
+
if (n === undefined)
|
|
1547
|
+
return 'unknown size';
|
|
1548
|
+
if (n < 1024)
|
|
1549
|
+
return `${n} B`;
|
|
1550
|
+
if (n < 1024 * 1024)
|
|
1551
|
+
return `${(n / 1024).toFixed(0)} KB`;
|
|
1552
|
+
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
1553
|
+
}
|
|
1183
1554
|
function formatDuration(ms) {
|
|
1184
1555
|
const seconds = Math.floor(ms / 1000);
|
|
1185
1556
|
if (seconds < 60)
|