@phnx-labs/agents-cli 1.15.0 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CHANGELOG.md +143 -39
  2. package/README.md +6 -6
  3. package/dist/commands/alias.js +2 -2
  4. package/dist/commands/browser-picker.d.ts +21 -0
  5. package/dist/commands/browser-picker.js +114 -0
  6. package/dist/commands/browser.js +793 -83
  7. package/dist/commands/cloud.js +8 -0
  8. package/dist/commands/commands.js +72 -22
  9. package/dist/commands/daemon.js +2 -2
  10. package/dist/commands/exec.js +70 -1
  11. package/dist/commands/hooks.js +71 -26
  12. package/dist/commands/mcp.js +81 -39
  13. package/dist/commands/plugins.js +224 -17
  14. package/dist/commands/prune.js +29 -1
  15. package/dist/commands/pull.js +3 -3
  16. package/dist/commands/repo.js +1 -1
  17. package/dist/commands/routines.js +2 -2
  18. package/dist/commands/secrets.js +154 -20
  19. package/dist/commands/sessions.js +62 -19
  20. package/dist/commands/{init.d.ts → setup.d.ts} +7 -6
  21. package/dist/commands/{init.js → setup.js} +22 -21
  22. package/dist/commands/skills.js +60 -19
  23. package/dist/commands/subagents.js +41 -13
  24. package/dist/commands/utils.d.ts +16 -0
  25. package/dist/commands/utils.js +32 -0
  26. package/dist/commands/view.js +78 -20
  27. package/dist/commands/workflows.d.ts +10 -0
  28. package/dist/commands/workflows.js +457 -0
  29. package/dist/index.d.ts +1 -1
  30. package/dist/index.js +48 -36
  31. package/dist/lib/agents.js +2 -2
  32. package/dist/lib/auto-pull-worker.js +2 -3
  33. package/dist/lib/auto-pull.js +2 -2
  34. package/dist/lib/browser/cdp.d.ts +7 -1
  35. package/dist/lib/browser/cdp.js +32 -1
  36. package/dist/lib/browser/chrome.d.ts +10 -0
  37. package/dist/lib/browser/chrome.js +41 -3
  38. package/dist/lib/browser/devices.d.ts +4 -0
  39. package/dist/lib/browser/devices.js +27 -0
  40. package/dist/lib/browser/drivers/local.js +22 -6
  41. package/dist/lib/browser/drivers/ssh.js +9 -2
  42. package/dist/lib/browser/input.d.ts +1 -0
  43. package/dist/lib/browser/input.js +3 -0
  44. package/dist/lib/browser/ipc.js +158 -23
  45. package/dist/lib/browser/profiles.d.ts +10 -2
  46. package/dist/lib/browser/profiles.js +122 -37
  47. package/dist/lib/browser/service.d.ts +91 -13
  48. package/dist/lib/browser/service.js +767 -132
  49. package/dist/lib/browser/types.d.ts +91 -3
  50. package/dist/lib/browser/types.js +16 -0
  51. package/dist/lib/cloud/rush.d.ts +28 -1
  52. package/dist/lib/cloud/rush.js +69 -14
  53. package/dist/lib/cloud/store.js +2 -2
  54. package/dist/lib/commands.d.ts +1 -15
  55. package/dist/lib/commands.js +11 -7
  56. package/dist/lib/daemon.js +2 -3
  57. package/dist/lib/doctor-diff.js +4 -4
  58. package/dist/lib/events.js +2 -2
  59. package/dist/lib/hooks.d.ts +11 -7
  60. package/dist/lib/hooks.js +138 -49
  61. package/dist/lib/migrate.d.ts +1 -1
  62. package/dist/lib/migrate.js +1237 -22
  63. package/dist/lib/models.js +2 -2
  64. package/dist/lib/permissions.d.ts +8 -66
  65. package/dist/lib/permissions.js +18 -18
  66. package/dist/lib/plugins.d.ts +94 -24
  67. package/dist/lib/plugins.js +702 -123
  68. package/dist/lib/pty-server.js +9 -10
  69. package/dist/lib/resource-patterns.d.ts +41 -0
  70. package/dist/lib/resource-patterns.js +82 -0
  71. package/dist/lib/resources/hooks.d.ts +5 -1
  72. package/dist/lib/resources/hooks.js +21 -4
  73. package/dist/lib/resources/index.d.ts +17 -0
  74. package/dist/lib/resources/index.js +7 -0
  75. package/dist/lib/resources/types.d.ts +1 -1
  76. package/dist/lib/resources/workflows.d.ts +24 -0
  77. package/dist/lib/resources/workflows.js +110 -0
  78. package/dist/lib/resources.d.ts +6 -1
  79. package/dist/lib/resources.js +12 -2
  80. package/dist/lib/rotate.js +3 -4
  81. package/dist/lib/session/active.d.ts +3 -0
  82. package/dist/lib/session/active.js +92 -6
  83. package/dist/lib/session/cloud.js +2 -2
  84. package/dist/lib/session/db.d.ts +18 -0
  85. package/dist/lib/session/db.js +109 -5
  86. package/dist/lib/session/discover.d.ts +6 -0
  87. package/dist/lib/session/discover.js +55 -29
  88. package/dist/lib/session/team-filter.js +2 -2
  89. package/dist/lib/shims.d.ts +4 -52
  90. package/dist/lib/shims.js +23 -15
  91. package/dist/lib/skills.js +6 -2
  92. package/dist/lib/sqlite.js +10 -4
  93. package/dist/lib/state.d.ts +101 -16
  94. package/dist/lib/state.js +179 -31
  95. package/dist/lib/subagents.d.ts +28 -0
  96. package/dist/lib/subagents.js +98 -1
  97. package/dist/lib/sync-manifest.d.ts +1 -1
  98. package/dist/lib/sync-manifest.js +3 -3
  99. package/dist/lib/teams/persistence.js +15 -5
  100. package/dist/lib/teams/registry.js +2 -2
  101. package/dist/lib/types.d.ts +75 -17
  102. package/dist/lib/types.js +3 -3
  103. package/dist/lib/usage.js +2 -2
  104. package/dist/lib/versions.d.ts +3 -0
  105. package/dist/lib/versions.js +158 -47
  106. package/dist/lib/workflows.d.ts +79 -0
  107. package/dist/lib/workflows.js +233 -0
  108. package/package.json +1 -5
  109. package/scripts/postinstall.js +60 -59
  110. package/dist/commands/fork.d.ts +0 -10
  111. package/dist/commands/fork.js +0 -146
@@ -1,6 +1,11 @@
1
- import { listProfiles, getProfile, createProfile, deleteProfile, } from '../lib/browser/profiles.js';
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { listProfiles, getProfile, createProfile, deleteProfile, getProfileRuntimeDir, extractConfiguredPort, findFreeProfilePort, } from '../lib/browser/profiles.js';
4
+ import { findBrowserPath, getPortOccupant } from '../lib/browser/chrome.js';
5
+ import { discoverBrowserWsUrl, verifyBrowserIdentity } from '../lib/browser/cdp.js';
2
6
  import { sendIPCRequest } from '../lib/browser/ipc.js';
3
- import { isValidTaskId } from '../lib/browser/types.js';
7
+ import { browserTaskPicker } from './browser-picker.js';
8
+ import { isInteractiveTerminal } from './utils.js';
4
9
  export function registerBrowserCommand(program) {
5
10
  const browser = program
6
11
  .command('browser')
@@ -27,11 +32,23 @@ function registerProfilesCommands(browser) {
27
32
  console.log('Create one with: agents browser profiles create <name> --endpoint <url>');
28
33
  return;
29
34
  }
30
- console.log('NAME'.padEnd(20) + 'BROWSER'.padEnd(12) + 'ENDPOINTS');
31
- console.log('-'.repeat(72));
32
- for (const p of allProfiles) {
33
- const endpoints = p.endpoints.join(', ');
34
- console.log(p.name.padEnd(20) + (p.browser || '-').padEnd(12) + endpoints);
35
+ const hasDescriptions = allProfiles.some(p => p.description);
36
+ if (hasDescriptions) {
37
+ console.log('NAME'.padEnd(20) + 'BROWSER'.padEnd(12) + 'DESCRIPTION'.padEnd(38) + 'ENDPOINTS');
38
+ console.log('-'.repeat(92));
39
+ for (const p of allProfiles) {
40
+ const endpoints = p.endpoints.join(', ');
41
+ const desc = (p.description ?? '').slice(0, 36).padEnd(38);
42
+ console.log(p.name.padEnd(20) + (p.browser || '-').padEnd(12) + desc + endpoints);
43
+ }
44
+ }
45
+ else {
46
+ console.log('NAME'.padEnd(20) + 'BROWSER'.padEnd(12) + 'ENDPOINTS');
47
+ console.log('-'.repeat(72));
48
+ for (const p of allProfiles) {
49
+ const endpoints = p.endpoints.join(', ');
50
+ console.log(p.name.padEnd(20) + (p.browser || '-').padEnd(12) + endpoints);
51
+ }
35
52
  }
36
53
  });
37
54
  const VALID_BROWSERS = ['chrome', 'comet', 'chromium', 'brave', 'edge'];
@@ -39,10 +56,12 @@ function registerProfilesCommands(browser) {
39
56
  .command('create <name>')
40
57
  .description('Create a new browser profile')
41
58
  .requiredOption('-b, --browser <type>', `Browser type: ${VALID_BROWSERS.join(', ')}`)
42
- .requiredOption('-e, --endpoint <url>', 'CDP endpoint URL (repeatable)', collect, [])
59
+ .option('-e, --endpoint <url>', 'CDP endpoint URL (repeatable; auto-assigned if omitted)', collect, [])
43
60
  .option('-s, --secrets <bundle>', 'Secrets bundle to inject')
44
61
  .option('-d, --description <text>', 'Profile description')
45
62
  .option('--headless', 'Run in headless mode')
63
+ .option('--window <WxH>', 'Window size, e.g. 1512x982')
64
+ .option('--position <X,Y>', 'Window position on screen, e.g. 80,80')
46
65
  .action(async (name, opts) => {
47
66
  if (!/^[a-z][a-z0-9-]*$/.test(name)) {
48
67
  console.error('Profile name must be lowercase alphanumeric with hyphens');
@@ -52,13 +71,43 @@ function registerProfilesCommands(browser) {
52
71
  console.error(`Invalid browser type. Must be one of: ${VALID_BROWSERS.join(', ')}`);
53
72
  process.exit(1);
54
73
  }
74
+ // Auto-assign a free port if no endpoint was provided
75
+ let endpoints = opts.endpoint;
76
+ if (endpoints.length === 0) {
77
+ const freePort = await findFreeProfilePort();
78
+ endpoints = [`cdp://127.0.0.1:${freePort}`];
79
+ }
80
+ // Viewport is mandatory — default to 1512x982 if --window is not provided
81
+ let viewport = {
82
+ width: 1512,
83
+ height: 982,
84
+ };
85
+ if (opts.window) {
86
+ const m = String(opts.window).match(/^(\d+)x(\d+)$/);
87
+ if (!m) {
88
+ console.error('--window must be WxH, e.g. 1512x982');
89
+ process.exit(1);
90
+ }
91
+ viewport.width = parseInt(m[1], 10);
92
+ viewport.height = parseInt(m[2], 10);
93
+ }
94
+ if (opts.position) {
95
+ const m = String(opts.position).match(/^(-?\d+),(-?\d+)$/);
96
+ if (!m) {
97
+ console.error('--position must be X,Y, e.g. 80,80');
98
+ process.exit(1);
99
+ }
100
+ viewport.x = parseInt(m[1], 10);
101
+ viewport.y = parseInt(m[2], 10);
102
+ }
55
103
  const profile = {
56
104
  name,
57
105
  description: opts.description,
58
106
  browser: opts.browser,
59
- endpoints: opts.endpoint,
107
+ endpoints,
60
108
  secrets: opts.secrets,
61
109
  chrome: opts.headless ? { headless: true } : undefined,
110
+ viewport,
62
111
  };
63
112
  await createProfile(profile);
64
113
  console.log(`Created profile: ${name}`);
@@ -66,12 +115,22 @@ function registerProfilesCommands(browser) {
66
115
  profiles
67
116
  .command('show <name>')
68
117
  .description('Show profile details')
69
- .action(async (name) => {
118
+ .option('--json', 'Output machine-readable JSON')
119
+ .action(async (name, opts) => {
70
120
  const profile = await getProfile(name);
71
121
  if (!profile) {
72
- console.error(`Profile "${name}" not found`);
122
+ if (opts.json) {
123
+ console.log(JSON.stringify({ ok: false, error: `Profile "${name}" not found` }));
124
+ }
125
+ else {
126
+ console.error(`Profile "${name}" not found`);
127
+ }
73
128
  process.exit(1);
74
129
  }
130
+ if (opts.json) {
131
+ console.log(JSON.stringify(profile, null, 2));
132
+ return;
133
+ }
75
134
  console.log(`Name: ${profile.name}`);
76
135
  console.log(`Browser: ${profile.browser}`);
77
136
  if (profile.description)
@@ -92,27 +151,211 @@ function registerProfilesCommands(browser) {
92
151
  await deleteProfile(name);
93
152
  console.log(`Deleted profile: ${name}`);
94
153
  });
154
+ profiles
155
+ .command('launch <name>')
156
+ .description('Start (or attach to) the profile\'s browser without creating a task')
157
+ .action(async (name) => {
158
+ const profile = await getProfile(name);
159
+ if (!profile) {
160
+ console.error(`Profile "${name}" not found`);
161
+ process.exit(1);
162
+ }
163
+ const response = await sendIPCRequest({
164
+ action: 'launch-profile',
165
+ profile: name,
166
+ });
167
+ if (!response.ok) {
168
+ console.error(response.error);
169
+ process.exit(1);
170
+ }
171
+ const pidLabel = response.pid ? `pid ${response.pid}` : 'attached';
172
+ console.log(`Launched "${name}" on port ${response.port} (${pidLabel})`);
173
+ console.log(`Next: agents browser start --profile ${name} --url <url>`);
174
+ });
175
+ profiles
176
+ .command('doctor <name>')
177
+ .description('Diagnose a browser profile: binary, port, user-data-dir, onboarding state')
178
+ .action(async (name) => {
179
+ const profile = await getProfile(name);
180
+ if (!profile) {
181
+ console.error(`Profile "${name}" not found`);
182
+ process.exit(1);
183
+ }
184
+ const checks = [];
185
+ // 1. Binary exists for declared browser type
186
+ try {
187
+ const binPath = findBrowserPath(profile.browser, profile.binary);
188
+ checks.push({ label: 'binary', ok: true, detail: binPath });
189
+ }
190
+ catch (err) {
191
+ checks.push({
192
+ label: 'binary',
193
+ ok: false,
194
+ detail: err instanceof Error ? err.message : String(err),
195
+ });
196
+ }
197
+ // 2. Configured port: free, or already serving the expected browser?
198
+ const port = extractConfiguredPort(profile);
199
+ let attachingToExistingBrowser = false;
200
+ if (port === undefined) {
201
+ checks.push({ label: 'port', ok: true, detail: 'no port in endpoint' });
202
+ }
203
+ else {
204
+ const occupant = getPortOccupant(port);
205
+ if (!occupant) {
206
+ checks.push({ label: 'port', ok: true, detail: `${port} is free` });
207
+ }
208
+ else {
209
+ try {
210
+ const { browser } = await discoverBrowserWsUrl(port);
211
+ verifyBrowserIdentity(browser, profile.browser, port);
212
+ checks.push({
213
+ label: 'port',
214
+ ok: true,
215
+ detail: `${port} serving ${browser} (pid ${occupant.pid})`,
216
+ });
217
+ attachingToExistingBrowser = true;
218
+ }
219
+ catch (err) {
220
+ const msg = err instanceof Error ? err.message : String(err);
221
+ checks.push({
222
+ label: 'port',
223
+ ok: false,
224
+ detail: `${port} taken by ${occupant.command} (pid ${occupant.pid}) — ${msg}`,
225
+ });
226
+ }
227
+ }
228
+ }
229
+ // 3. User-data-dir exists and is writable
230
+ const userDataDir = path.join(getProfileRuntimeDir(name), 'chrome-data');
231
+ try {
232
+ if (!fs.existsSync(userDataDir)) {
233
+ checks.push({
234
+ label: 'user-data-dir',
235
+ ok: true,
236
+ detail: `will be created at ${userDataDir}`,
237
+ });
238
+ }
239
+ else {
240
+ fs.accessSync(userDataDir, fs.constants.W_OK);
241
+ checks.push({ label: 'user-data-dir', ok: true, detail: userDataDir });
242
+ }
243
+ }
244
+ catch (err) {
245
+ checks.push({
246
+ label: 'user-data-dir',
247
+ ok: false,
248
+ detail: `${userDataDir} not writable: ${err instanceof Error ? err.message : err}`,
249
+ });
250
+ }
251
+ // 4. Onboarding heuristic — only meaningful when WE will launch the
252
+ // browser. When the configured port is already serving a debuggable
253
+ // browser, that browser owns its own user-data-dir and the priming
254
+ // status of our managed dir is irrelevant.
255
+ if (attachingToExistingBrowser) {
256
+ checks.push({
257
+ label: 'onboarding',
258
+ ok: true,
259
+ detail: 'n/a (attaching to existing browser)',
260
+ });
261
+ }
262
+ else {
263
+ const localStatePath = path.join(userDataDir, 'Local State');
264
+ if (fs.existsSync(localStatePath)) {
265
+ const size = fs.statSync(localStatePath).size;
266
+ if (size > 0) {
267
+ checks.push({ label: 'onboarding', ok: true, detail: 'Local State present' });
268
+ }
269
+ else {
270
+ checks.push({
271
+ label: 'onboarding',
272
+ ok: false,
273
+ detail: 'Local State is empty — run `agents browser profiles prime ' + name + '`',
274
+ });
275
+ }
276
+ }
277
+ else {
278
+ checks.push({
279
+ label: 'onboarding',
280
+ ok: false,
281
+ detail: 'Not primed yet — run `agents browser profiles prime ' + name + '`',
282
+ });
283
+ }
284
+ }
285
+ const allOk = checks.every((c) => c.ok);
286
+ for (const c of checks) {
287
+ const marker = c.ok ? 'OK ' : 'FAIL';
288
+ console.log(`${marker} ${c.label.padEnd(15)} ${c.detail}`);
289
+ }
290
+ if (!allOk)
291
+ process.exit(1);
292
+ });
293
+ profiles
294
+ .command('prime <name>')
295
+ .description('Launch the profile so you can complete first-run onboarding interactively')
296
+ .action(async (name) => {
297
+ const profile = await getProfile(name);
298
+ if (!profile) {
299
+ console.error(`Profile "${name}" not found`);
300
+ process.exit(1);
301
+ }
302
+ const response = await sendIPCRequest({
303
+ action: 'launch-profile',
304
+ profile: name,
305
+ });
306
+ if (!response.ok) {
307
+ console.error(response.error);
308
+ process.exit(1);
309
+ }
310
+ const pidLabel = response.pid ? `pid ${response.pid}` : 'attached';
311
+ console.log(`Launched "${name}" on port ${response.port} (${pidLabel}).`);
312
+ console.log('');
313
+ console.log('Finish any first-run / onboarding screens in the browser window');
314
+ console.log('(welcome, profile setup, default-browser prompt, sign-in, etc.).');
315
+ console.log('Once you reach a normal browsing surface, this profile is primed');
316
+ console.log('— its user-data-dir persists across runs, so you only do this once.');
317
+ console.log('');
318
+ console.log(`Next: agents browser start --profile ${name} --url <url>`);
319
+ });
95
320
  }
96
321
  function registerTaskCommands(browser) {
97
322
  browser
98
- .command('start [task]')
99
- .description('Start a browser task')
323
+ .command('start')
324
+ .description('Start a browser task with a profile')
100
325
  .requiredOption('-p, --profile <name>', 'Browser profile to use')
101
- .action(async (task, opts) => {
102
- if (task && !isValidTaskId(task)) {
103
- console.error('Task ID must be lowercase alphanumeric with hyphens');
104
- process.exit(1);
105
- }
326
+ .option('-t, --task <name>', 'Task name (auto-generated if omitted)')
327
+ .option('-u, --url <url>', 'Open URL in first tab')
328
+ .action(async (opts) => {
106
329
  const response = await sendIPCRequest({
107
330
  action: 'start',
108
331
  profile: opts.profile,
332
+ taskName: opts.task,
333
+ url: opts.url,
334
+ });
335
+ if (!response.ok) {
336
+ console.error(response.error);
337
+ process.exit(1);
338
+ }
339
+ if (opts.url && response.tabId) {
340
+ console.log(`Started task "${response.task}" with tab ${response.tabId}`);
341
+ }
342
+ else {
343
+ console.log(`Started task "${response.task}"`);
344
+ }
345
+ });
346
+ browser
347
+ .command('done <task>')
348
+ .description('Complete a task and close its tabs')
349
+ .action(async (task) => {
350
+ const response = await sendIPCRequest({
351
+ action: 'done',
109
352
  task,
110
353
  });
111
354
  if (!response.ok) {
112
355
  console.error(response.error);
113
356
  process.exit(1);
114
357
  }
115
- console.log(response.task);
358
+ console.log(`Completed task: ${task}`);
116
359
  });
117
360
  browser
118
361
  .command('stop <task>')
@@ -130,7 +373,7 @@ function registerTaskCommands(browser) {
130
373
  });
131
374
  browser
132
375
  .command('navigate <task> <url>')
133
- .description('Open a URL in the task window')
376
+ .description('Navigate current tab to URL (creates tab if none exist)')
134
377
  .option('-p, --profile <name>', 'Browser profile (optional if task is unique)')
135
378
  .action(async (task, url, opts) => {
136
379
  const response = await sendIPCRequest({
@@ -143,41 +386,48 @@ function registerTaskCommands(browser) {
143
386
  console.error(response.error);
144
387
  process.exit(1);
145
388
  }
146
- console.log(`Opened tab ${response.tabId}: ${url}`);
389
+ console.log(`Navigated ${response.tabId} to ${url}`);
147
390
  });
148
- browser
149
- .command('tabs [task]')
150
- .description('List open tabs')
151
- .option('-p, --profile <name>', 'Filter by profile')
152
- .action(async (task, opts) => {
391
+ // Tab subcommand group
392
+ const tab = browser.command('tab').description('Manage tabs');
393
+ tab
394
+ .command('add <task> <url>')
395
+ .description('Open URL in new tab (becomes current)')
396
+ .option('-p, --profile <name>', 'Browser profile')
397
+ .action(async (task, url, opts) => {
153
398
  const response = await sendIPCRequest({
154
- action: 'tabs',
399
+ action: 'tab-add',
155
400
  task,
401
+ url,
156
402
  profile: opts.profile,
157
403
  });
158
404
  if (!response.ok) {
159
405
  console.error(response.error);
160
406
  process.exit(1);
161
407
  }
162
- if (!response.tabs || response.tabs.length === 0) {
163
- console.log('No tabs open');
164
- return;
165
- }
166
- console.log('TASK'.padEnd(15) + 'TAB'.padEnd(12) + 'URL');
167
- console.log('-'.repeat(80));
168
- for (const tab of response.tabs) {
169
- const shortId = tab.id.slice(0, 8);
170
- console.log(tab.task.padEnd(15) +
171
- shortId.padEnd(12) +
172
- tab.url.slice(0, 55));
408
+ console.log(`Opened tab ${response.tabId}: ${url}`);
409
+ });
410
+ tab
411
+ .command('focus <task> <tabId>')
412
+ .description('Switch to tab (by ID, prefix, or URL substring)')
413
+ .action(async (task, tabId) => {
414
+ const response = await sendIPCRequest({
415
+ action: 'tab-focus',
416
+ task,
417
+ tabId,
418
+ });
419
+ if (!response.ok) {
420
+ console.error(response.error);
421
+ process.exit(1);
173
422
  }
423
+ console.log(`Focused tab ${response.tabId}`);
174
424
  });
175
- browser
425
+ tab
176
426
  .command('close <task> [tabId]')
177
- .description('Close tabs for a task')
427
+ .description('Close tab(s) omit tabId to close all')
178
428
  .action(async (task, tabId) => {
179
429
  const response = await sendIPCRequest({
180
- action: 'close',
430
+ action: 'tab-close',
181
431
  task,
182
432
  tabId,
183
433
  });
@@ -185,7 +435,40 @@ function registerTaskCommands(browser) {
185
435
  console.error(response.error);
186
436
  process.exit(1);
187
437
  }
188
- console.log(tabId ? `Closed tab ${tabId}` : `Closed all tabs for task ${task}`);
438
+ console.log(tabId ? `Closed tab ${tabId}` : `Closed all tabs for ${task}`);
439
+ });
440
+ tab
441
+ .command('list <task>')
442
+ .description('List tabs for a task')
443
+ .option('--json', 'Output machine-readable JSON')
444
+ .action(async (task, opts) => {
445
+ const response = await sendIPCRequest({
446
+ action: 'tab-list',
447
+ task,
448
+ });
449
+ if (!response.ok) {
450
+ if (opts.json) {
451
+ console.log(JSON.stringify({ ok: false, error: response.error }));
452
+ }
453
+ else {
454
+ console.error(response.error);
455
+ }
456
+ process.exit(1);
457
+ }
458
+ if (opts.json) {
459
+ console.log(JSON.stringify(response.tabs ?? [], null, 2));
460
+ return;
461
+ }
462
+ if (!response.tabs || response.tabs.length === 0) {
463
+ console.log('No tabs open');
464
+ return;
465
+ }
466
+ console.log('TAB'.padEnd(12) + 'URL');
467
+ console.log('-'.repeat(70));
468
+ for (const t of response.tabs) {
469
+ const current = t.current ? ' *' : '';
470
+ console.log(t.id.padEnd(12) + t.url.slice(0, 55) + current);
471
+ }
189
472
  });
190
473
  browser
191
474
  .command('screenshot <task> [tabId]')
@@ -205,13 +488,14 @@ function registerTaskCommands(browser) {
205
488
  console.log(response.path);
206
489
  });
207
490
  browser
208
- .command('evaluate <task> <tabId> <expression>')
209
- .description('Evaluate JavaScript in a tab')
210
- .action(async (task, tabId, expression) => {
491
+ .command('evaluate <task> <expression>')
492
+ .description('Evaluate JavaScript in current tab')
493
+ .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
494
+ .action(async (task, expression, opts) => {
211
495
  const response = await sendIPCRequest({
212
496
  action: 'evaluate',
213
497
  task,
214
- tabId,
498
+ tabId: opts.tab,
215
499
  expr: expression,
216
500
  });
217
501
  if (!response.ok) {
@@ -224,32 +508,101 @@ function registerTaskCommands(browser) {
224
508
  .command('status')
225
509
  .description('Show running browser tasks')
226
510
  .option('-p, --profile <name>', 'Filter by profile')
511
+ .option('--json', 'Output machine-readable JSON')
227
512
  .action(async (opts) => {
228
513
  const response = await sendIPCRequest({
229
514
  action: 'status',
230
515
  profile: opts.profile,
231
516
  });
232
517
  if (!response.ok) {
233
- console.error(response.error);
518
+ if (opts.json) {
519
+ console.log(JSON.stringify({ ok: false, error: response.error }));
520
+ }
521
+ else {
522
+ console.error(response.error);
523
+ }
234
524
  process.exit(1);
235
525
  }
236
- if (!response.profiles || response.profiles.length === 0) {
237
- console.log('No browser profiles running');
526
+ if (opts.json) {
527
+ console.log(JSON.stringify(response.profiles ?? [], null, 2));
238
528
  return;
239
529
  }
240
- for (const profile of response.profiles) {
241
- console.log(`\n${profile.name} (port ${profile.port}, pid ${profile.pid})`);
242
- if (profile.tasks.length === 0) {
243
- console.log(' No active tasks');
530
+ // Build flat list of tasks with profile context
531
+ const allTasks = [];
532
+ for (const profile of response.profiles || []) {
533
+ for (const task of profile.tasks) {
534
+ allTasks.push({ task, profile });
535
+ }
536
+ }
537
+ if (allTasks.length === 0) {
538
+ // Show recent history instead
539
+ const historyResponse = await sendIPCRequest({ action: 'history', limit: 5 });
540
+ if (historyResponse.ok && historyResponse.history && historyResponse.history.length > 0) {
541
+ console.log('No active tasks. Recent history:\n');
542
+ console.log('PROFILE'.padEnd(15) + 'TASK'.padEnd(18) + 'DOMAINS'.padEnd(22) + 'DURATION'.padEnd(10) + 'ENDED');
543
+ console.log('-'.repeat(75));
544
+ for (const h of historyResponse.history) {
545
+ const domains = h.domains?.slice(0, 2).join(', ') || '-';
546
+ const duration = formatDuration(h.endedAt - h.createdAt);
547
+ const ended = formatAge(h.endedAt);
548
+ console.log(h.profile.padEnd(15) +
549
+ h.name.padEnd(18) +
550
+ domains.slice(0, 20).padEnd(22) +
551
+ duration.padEnd(10) +
552
+ ended);
553
+ }
554
+ console.log('\nRun `browser history` for more.');
244
555
  }
245
556
  else {
246
- console.log(' TASK'.padEnd(17) + 'TABS'.padEnd(8) + 'CREATED');
247
- for (const task of profile.tasks) {
248
- const age = formatAge(task.createdAt);
249
- console.log(' ' +
250
- task.id.padEnd(15) +
251
- String(task.tabCount).padEnd(8) +
252
- age);
557
+ console.log('No browser tasks running');
558
+ }
559
+ return;
560
+ }
561
+ // Interactive picker for TTY, plain output otherwise
562
+ if (isInteractiveTerminal()) {
563
+ const picked = await browserTaskPicker({
564
+ message: 'Browser tasks:',
565
+ tasks: allTasks,
566
+ });
567
+ if (picked) {
568
+ // Show tab list for the selected task
569
+ const tabResponse = await sendIPCRequest({
570
+ action: 'tab-list',
571
+ task: picked.task.task.name,
572
+ });
573
+ if (tabResponse.ok && tabResponse.tabs) {
574
+ console.log(`\nTabs for ${picked.task.task.name}:`);
575
+ for (const tab of tabResponse.tabs) {
576
+ console.log(` ${tab.id} ${tab.url}`);
577
+ }
578
+ }
579
+ }
580
+ }
581
+ else {
582
+ // Non-interactive: simple table output
583
+ for (const profile of response.profiles || []) {
584
+ const portLabel = profile.configuredPort && profile.configuredPort !== profile.port
585
+ ? `port ${profile.port} (configured ${profile.configuredPort})`
586
+ : `port ${profile.port}`;
587
+ // pid 0 means the daemon attached to a browser we didn't launch — no
588
+ // tracked pid. Render it as "attached" rather than the literal 0.
589
+ const pidLabel = profile.pid ? `pid ${profile.pid}` : 'attached';
590
+ console.log(`\n${profile.name} (${portLabel}, ${pidLabel})`);
591
+ if (profile.tasks.length === 0) {
592
+ console.log(' No active tasks');
593
+ }
594
+ else {
595
+ console.log(' TASK'.padEnd(20) + 'TABS'.padEnd(6) + 'DOMAINS'.padEnd(25) + 'CREATED');
596
+ for (const task of profile.tasks) {
597
+ const age = formatAge(task.createdAt);
598
+ const name = task.name || task.id;
599
+ const domains = task.domains?.slice(0, 2).join(', ') || '-';
600
+ console.log(' ' +
601
+ name.padEnd(18) +
602
+ String(task.tabCount).padEnd(6) +
603
+ domains.slice(0, 23).padEnd(25) +
604
+ age);
605
+ }
253
606
  }
254
607
  }
255
608
  }
@@ -258,13 +611,19 @@ function registerTaskCommands(browser) {
258
611
  .command('tasks')
259
612
  .description('List all browser tasks')
260
613
  .option('-p, --profile <name>', 'Filter by profile')
614
+ .option('--json', 'Output machine-readable JSON')
261
615
  .action(async (opts) => {
262
616
  const response = await sendIPCRequest({
263
617
  action: 'status',
264
618
  profile: opts.profile,
265
619
  });
266
620
  if (!response.ok) {
267
- console.error(response.error);
621
+ if (opts.json) {
622
+ console.log(JSON.stringify({ ok: false, error: response.error }));
623
+ }
624
+ else {
625
+ console.error(response.error);
626
+ }
268
627
  process.exit(1);
269
628
  }
270
629
  const allTasks = [];
@@ -272,35 +631,102 @@ function registerTaskCommands(browser) {
272
631
  for (const task of profile.tasks) {
273
632
  allTasks.push({
274
633
  profile: profile.name,
275
- id: task.id,
634
+ name: task.name || task.id,
276
635
  tabs: task.tabCount,
636
+ domains: task.domains || [],
277
637
  created: task.createdAt,
278
638
  });
279
639
  }
280
640
  }
641
+ if (opts.json) {
642
+ console.log(JSON.stringify(allTasks, null, 2));
643
+ return;
644
+ }
281
645
  if (allTasks.length === 0) {
282
- console.log('No active tasks');
646
+ // Show recent history instead
647
+ const historyResponse = await sendIPCRequest({ action: 'history', limit: 5 });
648
+ if (historyResponse.ok && historyResponse.history && historyResponse.history.length > 0) {
649
+ console.log('No active tasks. Recent history:\n');
650
+ console.log('PROFILE'.padEnd(15) + 'TASK'.padEnd(18) + 'DOMAINS'.padEnd(22) + 'DURATION'.padEnd(10) + 'ENDED');
651
+ console.log('-'.repeat(75));
652
+ for (const h of historyResponse.history) {
653
+ const domains = h.domains?.slice(0, 2).join(', ') || '-';
654
+ const duration = formatDuration(h.endedAt - h.createdAt);
655
+ const ended = formatAge(h.endedAt);
656
+ console.log(h.profile.padEnd(15) +
657
+ h.name.padEnd(18) +
658
+ domains.slice(0, 20).padEnd(22) +
659
+ duration.padEnd(10) +
660
+ ended);
661
+ }
662
+ }
663
+ else {
664
+ console.log('No active tasks');
665
+ }
283
666
  return;
284
667
  }
285
- console.log('PROFILE'.padEnd(18) + 'TASK'.padEnd(15) + 'TABS'.padEnd(8) + 'CREATED');
286
- console.log('-'.repeat(55));
668
+ console.log('PROFILE'.padEnd(15) + 'TASK'.padEnd(18) + 'TABS'.padEnd(6) + 'DOMAINS'.padEnd(22) + 'CREATED');
669
+ console.log('-'.repeat(70));
287
670
  for (const t of allTasks) {
288
- console.log(t.profile.padEnd(18) +
289
- t.id.padEnd(15) +
290
- String(t.tabs).padEnd(8) +
671
+ const domains = t.domains.slice(0, 2).join(', ') || '-';
672
+ console.log(t.profile.padEnd(15) +
673
+ t.name.padEnd(18) +
674
+ String(t.tabs).padEnd(6) +
675
+ domains.slice(0, 20).padEnd(22) +
291
676
  formatAge(t.created));
292
677
  }
293
678
  });
294
679
  browser
295
- .command('refs <task> [tabId]')
680
+ .command('history')
681
+ .description('Show recent browser task history')
682
+ .option('-l, --limit <n>', 'Number of entries (default 10)', '10')
683
+ .option('--json', 'Output machine-readable JSON')
684
+ .action(async (opts) => {
685
+ const response = await sendIPCRequest({
686
+ action: 'history',
687
+ limit: parseInt(opts.limit, 10),
688
+ });
689
+ if (!response.ok) {
690
+ if (opts.json) {
691
+ console.log(JSON.stringify({ ok: false, error: response.error }));
692
+ }
693
+ else {
694
+ console.error(response.error);
695
+ }
696
+ process.exit(1);
697
+ }
698
+ if (opts.json) {
699
+ console.log(JSON.stringify(response.history ?? [], null, 2));
700
+ return;
701
+ }
702
+ if (!response.history || response.history.length === 0) {
703
+ console.log('No browser task history');
704
+ return;
705
+ }
706
+ console.log('PROFILE'.padEnd(15) + 'TASK'.padEnd(18) + 'DOMAINS'.padEnd(22) + 'DURATION'.padEnd(10) + 'ENDED');
707
+ console.log('-'.repeat(75));
708
+ for (const h of response.history) {
709
+ const domains = h.domains?.slice(0, 2).join(', ') || '-';
710
+ const duration = formatDuration(h.endedAt - h.createdAt);
711
+ const ended = formatAge(h.endedAt);
712
+ console.log(h.profile.padEnd(15) +
713
+ h.name.padEnd(18) +
714
+ domains.slice(0, 20).padEnd(22) +
715
+ duration.padEnd(10) +
716
+ ended);
717
+ }
718
+ });
719
+ browser
720
+ .command('refs <task>')
296
721
  .description('Get DOM refs for interactive elements')
722
+ .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
297
723
  .option('--all', 'Include non-interactive elements')
298
724
  .option('-l, --limit <n>', 'Max elements (default 500)', '500')
299
- .action(async (task, tabId, opts) => {
725
+ .action(async (task, opts) => {
300
726
  const response = await sendIPCRequest({
301
727
  action: 'refs',
302
728
  task,
303
- tabId,
729
+ tabId: opts.tab,
304
730
  interactive: !opts.all,
305
731
  limit: parseInt(opts.limit, 10),
306
732
  });
@@ -311,13 +737,14 @@ function registerTaskCommands(browser) {
311
737
  console.log(response.refs);
312
738
  });
313
739
  browser
314
- .command('click <task> <tabId> <ref>')
740
+ .command('click <task> <ref>')
315
741
  .description('Click an element by ref')
316
- .action(async (task, tabId, ref) => {
742
+ .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
743
+ .action(async (task, ref, opts) => {
317
744
  const response = await sendIPCRequest({
318
745
  action: 'click',
319
746
  task,
320
- tabId,
747
+ tabId: opts.tab,
321
748
  ref: parseInt(ref, 10),
322
749
  });
323
750
  if (!response.ok) {
@@ -327,13 +754,14 @@ function registerTaskCommands(browser) {
327
754
  console.log('Clicked');
328
755
  });
329
756
  browser
330
- .command('type <task> <tabId> <ref> <text>')
757
+ .command('type <task> <ref> <text>')
331
758
  .description('Type text into an element by ref')
332
- .action(async (task, tabId, ref, text) => {
759
+ .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
760
+ .action(async (task, ref, text, opts) => {
333
761
  const response = await sendIPCRequest({
334
762
  action: 'type',
335
763
  task,
336
- tabId,
764
+ tabId: opts.tab,
337
765
  ref: parseInt(ref, 10),
338
766
  text,
339
767
  });
@@ -344,13 +772,14 @@ function registerTaskCommands(browser) {
344
772
  console.log('Typed');
345
773
  });
346
774
  browser
347
- .command('press <task> <tabId> <key>')
775
+ .command('press <task> <key>')
348
776
  .description('Press a key (Enter, Tab, Escape, etc)')
349
- .action(async (task, tabId, key) => {
777
+ .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
778
+ .action(async (task, key, opts) => {
350
779
  const response = await sendIPCRequest({
351
780
  action: 'press',
352
781
  task,
353
- tabId,
782
+ tabId: opts.tab,
354
783
  key,
355
784
  });
356
785
  if (!response.ok) {
@@ -360,13 +789,14 @@ function registerTaskCommands(browser) {
360
789
  console.log('Pressed');
361
790
  });
362
791
  browser
363
- .command('hover <task> <tabId> <ref>')
792
+ .command('hover <task> <ref>')
364
793
  .description('Hover over an element by ref')
365
- .action(async (task, tabId, ref) => {
794
+ .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
795
+ .action(async (task, ref, opts) => {
366
796
  const response = await sendIPCRequest({
367
797
  action: 'hover',
368
798
  task,
369
- tabId,
799
+ tabId: opts.tab,
370
800
  ref: parseInt(ref, 10),
371
801
  });
372
802
  if (!response.ok) {
@@ -375,6 +805,275 @@ function registerTaskCommands(browser) {
375
805
  }
376
806
  console.log('Hovered');
377
807
  });
808
+ browser
809
+ .command('scroll <task> <deltaX> <deltaY>')
810
+ .description('Scroll the page by pixel amount')
811
+ .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
812
+ .option('-x, --at-x <x>', 'X coordinate to dispatch scroll from (default 0)', parseInt)
813
+ .option('-y, --at-y <y>', 'Y coordinate to dispatch scroll from (default 0)', parseInt)
814
+ .action(async (task, deltaX, deltaY, opts) => {
815
+ const response = await sendIPCRequest({
816
+ action: 'scroll',
817
+ task,
818
+ tabId: opts.tab,
819
+ scrollX: parseInt(deltaX, 10),
820
+ scrollY: parseInt(deltaY, 10),
821
+ scrollAtX: opts.atX,
822
+ scrollAtY: opts.atY,
823
+ });
824
+ if (!response.ok) {
825
+ console.error(response.error);
826
+ process.exit(1);
827
+ }
828
+ console.log('Scrolled');
829
+ });
830
+ // ─── Viewport & Device ───────────────────────────────────────────────────────
831
+ const setCmd = browser.command('set').description('Set browser emulation options');
832
+ setCmd
833
+ .command('viewport <task> <width> <height>')
834
+ .description('Set viewport size')
835
+ .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
836
+ .option('-m, --mobile', 'Enable mobile emulation')
837
+ .option('-s, --scale <factor>', 'Device scale factor', parseFloat)
838
+ .action(async (task, width, height, opts) => {
839
+ const response = await sendIPCRequest({
840
+ action: 'set-viewport',
841
+ task,
842
+ tabId: opts.tab,
843
+ width: parseInt(width, 10),
844
+ height: parseInt(height, 10),
845
+ mobile: opts.mobile,
846
+ deviceScaleFactor: opts.scale,
847
+ });
848
+ if (!response.ok) {
849
+ console.error(response.error);
850
+ process.exit(1);
851
+ }
852
+ console.log(`Viewport set to ${width}x${height}${opts.mobile ? ' (mobile)' : ''}`);
853
+ });
854
+ setCmd
855
+ .command('device <task> <device-name>')
856
+ .description('Emulate a device (iPhone 14, iPad, MacBook Pro)')
857
+ .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
858
+ .action(async (task, deviceName, opts) => {
859
+ const response = await sendIPCRequest({
860
+ action: 'set-device',
861
+ task,
862
+ tabId: opts.tab,
863
+ deviceName,
864
+ });
865
+ if (!response.ok) {
866
+ console.error(response.error);
867
+ process.exit(1);
868
+ }
869
+ console.log(`Device set to ${deviceName}`);
870
+ });
871
+ browser
872
+ .command('devices')
873
+ .description('List available device presets')
874
+ .action(async () => {
875
+ const { DEVICES } = await import('../lib/browser/devices.js');
876
+ console.log('Available devices:');
877
+ for (const [name, desc] of Object.entries(DEVICES)) {
878
+ console.log(` ${name.padEnd(16)} ${desc.width}x${desc.height} @${desc.deviceScaleFactor}x${desc.mobile ? ' (mobile)' : ''}`);
879
+ }
880
+ });
881
+ // ─── Console & Errors ────────────────────────────────────────────────────────
882
+ browser
883
+ .command('console <task>')
884
+ .description('Read console logs from a tab')
885
+ .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
886
+ .option('-l, --level <level>', 'Filter by level (log, info, warn, error)')
887
+ .option('--clear', 'Clear logs after reading')
888
+ .action(async (task, opts) => {
889
+ const response = await sendIPCRequest({
890
+ action: 'console',
891
+ task,
892
+ tabId: opts.tab,
893
+ level: opts.level,
894
+ clear: opts.clear,
895
+ });
896
+ if (!response.ok) {
897
+ console.error(response.error);
898
+ process.exit(1);
899
+ }
900
+ if (!response.logs || response.logs.length === 0) {
901
+ console.log('No console logs');
902
+ return;
903
+ }
904
+ for (const log of response.logs) {
905
+ const prefix = `[${log.level.toUpperCase()}]`.padEnd(8);
906
+ const loc = log.url ? ` (${log.url}${log.line ? `:${log.line}` : ''})` : '';
907
+ console.log(`${prefix} ${log.text}${loc}`);
908
+ }
909
+ });
910
+ browser
911
+ .command('errors <task>')
912
+ .description('Read page errors from a tab')
913
+ .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
914
+ .option('--clear', 'Clear errors after reading')
915
+ .action(async (task, opts) => {
916
+ const response = await sendIPCRequest({
917
+ action: 'errors',
918
+ task,
919
+ tabId: opts.tab,
920
+ clear: opts.clear,
921
+ });
922
+ if (!response.ok) {
923
+ console.error(response.error);
924
+ process.exit(1);
925
+ }
926
+ if (!response.errors || response.errors.length === 0) {
927
+ console.log('No errors');
928
+ return;
929
+ }
930
+ for (const err of response.errors) {
931
+ console.log(`[ERROR] ${err.message}`);
932
+ if (err.stack)
933
+ console.log(err.stack);
934
+ if (err.url)
935
+ console.log(` at ${err.url}${err.line ? `:${err.line}` : ''}`);
936
+ console.log();
937
+ }
938
+ });
939
+ // ─── Network ─────────────────────────────────────────────────────────────────
940
+ browser
941
+ .command('requests <task>')
942
+ .description('Read captured network requests')
943
+ .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
944
+ .option('-f, --filter <text>', 'Filter URLs containing text')
945
+ .option('--clear', 'Clear requests after reading')
946
+ .action(async (task, opts) => {
947
+ const response = await sendIPCRequest({
948
+ action: 'requests',
949
+ task,
950
+ tabId: opts.tab,
951
+ filter: opts.filter,
952
+ clear: opts.clear,
953
+ });
954
+ if (!response.ok) {
955
+ console.error(response.error);
956
+ process.exit(1);
957
+ }
958
+ if (!response.requests || response.requests.length === 0) {
959
+ console.log('No requests captured');
960
+ return;
961
+ }
962
+ console.log('METHOD'.padEnd(8) + 'STATUS'.padEnd(8) + 'URL');
963
+ console.log('-'.repeat(72));
964
+ for (const req of response.requests) {
965
+ const status = req.status ? String(req.status) : '...';
966
+ console.log(`${req.method.padEnd(8)}${status.padEnd(8)}${req.url.slice(0, 100)}`);
967
+ }
968
+ });
969
+ browser
970
+ .command('responsebody <task> <url-pattern>')
971
+ .description('Wait for and read a response body by URL pattern')
972
+ .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
973
+ .option('--timeout <ms>', 'Timeout in milliseconds', parseInt)
974
+ .option('--max-chars <n>', 'Max characters to return', parseInt)
975
+ .action(async (task, urlPattern, opts) => {
976
+ const response = await sendIPCRequest({
977
+ action: 'response-body',
978
+ task,
979
+ tabId: opts.tab,
980
+ urlPattern,
981
+ timeout: opts.timeout,
982
+ maxChars: opts.maxChars,
983
+ });
984
+ if (!response.ok) {
985
+ console.error(response.error);
986
+ process.exit(1);
987
+ }
988
+ console.log(response.body);
989
+ });
990
+ // ─── Wait ────────────────────────────────────────────────────────────────────
991
+ browser
992
+ .command('wait <task>')
993
+ .description('Wait for a condition')
994
+ .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
995
+ .option('--time <ms>', 'Wait for milliseconds')
996
+ .option('--selector <css>', 'Wait for CSS selector to appear')
997
+ .option('--url <pattern>', 'Wait for URL to match pattern')
998
+ .option('--fn <js>', 'Wait for JS expression to return truthy')
999
+ .option('--state <state>', 'Wait for load state (domcontentloaded, load, networkidle)')
1000
+ .option('--timeout <ms>', 'Timeout in milliseconds', parseInt)
1001
+ .action(async (task, opts) => {
1002
+ let waitType;
1003
+ let waitValue;
1004
+ if (opts.time) {
1005
+ waitType = 'time';
1006
+ waitValue = parseInt(opts.time, 10);
1007
+ }
1008
+ else if (opts.selector) {
1009
+ waitType = 'selector';
1010
+ waitValue = opts.selector;
1011
+ }
1012
+ else if (opts.url) {
1013
+ waitType = 'url';
1014
+ waitValue = opts.url;
1015
+ }
1016
+ else if (opts.fn) {
1017
+ waitType = 'function';
1018
+ waitValue = opts.fn;
1019
+ }
1020
+ else if (opts.state) {
1021
+ waitType = 'load';
1022
+ waitValue = opts.state;
1023
+ }
1024
+ else {
1025
+ console.error('One of --time, --selector, --url, --fn, or --state required');
1026
+ process.exit(1);
1027
+ }
1028
+ const response = await sendIPCRequest({
1029
+ action: 'wait',
1030
+ task,
1031
+ tabId: opts.tab,
1032
+ waitType,
1033
+ waitValue,
1034
+ timeout: opts.timeout,
1035
+ });
1036
+ if (!response.ok) {
1037
+ console.error(response.error);
1038
+ process.exit(1);
1039
+ }
1040
+ console.log('Wait condition met');
1041
+ });
1042
+ // ─── Downloads ───────────────────────────────────────────────────────────────
1043
+ browser
1044
+ .command('download <task>')
1045
+ .description('Set download directory for a task')
1046
+ .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
1047
+ .requiredOption('-p, --path <dir>', 'Download directory path')
1048
+ .action(async (task, opts) => {
1049
+ const response = await sendIPCRequest({
1050
+ action: 'set-download-path',
1051
+ task,
1052
+ tabId: opts.tab,
1053
+ downloadPath: opts.path,
1054
+ });
1055
+ if (!response.ok) {
1056
+ console.error(response.error);
1057
+ process.exit(1);
1058
+ }
1059
+ console.log(`Download path set to ${opts.path}`);
1060
+ });
1061
+ browser
1062
+ .command('waitdownload <task>')
1063
+ .description('Wait for a download to complete')
1064
+ .option('--timeout <ms>', 'Timeout in milliseconds', parseInt)
1065
+ .action(async (task, opts) => {
1066
+ const response = await sendIPCRequest({
1067
+ action: 'wait-download',
1068
+ task,
1069
+ timeout: opts.timeout,
1070
+ });
1071
+ if (!response.ok) {
1072
+ console.error(response.error);
1073
+ process.exit(1);
1074
+ }
1075
+ console.log(`Downloaded: ${response.downloadPath}`);
1076
+ });
378
1077
  }
379
1078
  function collect(val, memo) {
380
1079
  memo.push(val);
@@ -390,3 +1089,14 @@ function formatAge(timestamp) {
390
1089
  const hours = Math.floor(minutes / 60);
391
1090
  return `${hours}h ago`;
392
1091
  }
1092
+ function formatDuration(ms) {
1093
+ const seconds = Math.floor(ms / 1000);
1094
+ if (seconds < 60)
1095
+ return `${seconds}s`;
1096
+ const minutes = Math.floor(seconds / 60);
1097
+ if (minutes < 60)
1098
+ return `${minutes}m`;
1099
+ const hours = Math.floor(minutes / 60);
1100
+ const mm = minutes % 60;
1101
+ return mm ? `${hours}h ${mm}m` : `${hours}h`;
1102
+ }