@jackwener/opencli 1.7.18 → 1.7.19

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 (95) hide show
  1. package/README.md +7 -8
  2. package/README.zh-CN.md +7 -8
  3. package/cli-manifest.json +305 -9
  4. package/clis/ctrip/ctrip.test.js +486 -1
  5. package/clis/ctrip/flight.js +136 -0
  6. package/clis/ctrip/hotel-search.js +132 -0
  7. package/clis/ctrip/utils.js +298 -0
  8. package/clis/google/search.js +16 -6
  9. package/clis/google-scholar/search.js +20 -5
  10. package/clis/google-scholar/search.test.js +35 -2
  11. package/clis/reddit/home.js +117 -0
  12. package/clis/reddit/home.test.js +127 -0
  13. package/clis/reddit/read.js +400 -54
  14. package/clis/reddit/read.test.js +315 -12
  15. package/clis/reddit/subreddit-info.js +117 -0
  16. package/clis/reddit/subreddit-info.test.js +163 -0
  17. package/clis/reddit/whoami.js +84 -0
  18. package/clis/reddit/whoami.test.js +105 -0
  19. package/clis/rednote/search.js +6 -2
  20. package/clis/twitter/bookmark-folder.js +3 -1
  21. package/clis/twitter/bookmarks.js +3 -1
  22. package/clis/twitter/followers.js +20 -5
  23. package/clis/twitter/followers.test.js +44 -0
  24. package/clis/twitter/following.js +36 -20
  25. package/clis/twitter/following.test.js +60 -8
  26. package/clis/twitter/likes.js +28 -13
  27. package/clis/twitter/likes.test.js +111 -1
  28. package/clis/twitter/list-add.js +128 -204
  29. package/clis/twitter/list-add.test.js +97 -1
  30. package/clis/twitter/list-tweets.js +13 -4
  31. package/clis/twitter/list-tweets.test.js +48 -0
  32. package/clis/twitter/lists.js +5 -2
  33. package/clis/twitter/post.js +23 -4
  34. package/clis/twitter/post.test.js +30 -0
  35. package/clis/twitter/profile.js +16 -8
  36. package/clis/twitter/profile.test.js +39 -0
  37. package/clis/twitter/reply.js +133 -10
  38. package/clis/twitter/reply.test.js +55 -0
  39. package/clis/twitter/search.js +188 -170
  40. package/clis/twitter/search.test.js +96 -258
  41. package/clis/twitter/shared.js +167 -16
  42. package/clis/twitter/shared.test.js +102 -1
  43. package/clis/twitter/timeline.js +3 -1
  44. package/clis/twitter/tweets.js +147 -51
  45. package/clis/twitter/tweets.test.js +238 -1
  46. package/clis/xiaohongshu/comments.js +23 -2
  47. package/clis/xiaohongshu/comments.test.js +63 -1
  48. package/clis/xiaohongshu/search.js +168 -13
  49. package/clis/xiaohongshu/search.test.js +82 -8
  50. package/clis/xueqiu/earnings-date.js +2 -2
  51. package/clis/xueqiu/kline.js +2 -2
  52. package/clis/xueqiu/utils.js +19 -0
  53. package/clis/xueqiu/utils.test.js +26 -0
  54. package/clis/zhihu/answer-detail.js +233 -0
  55. package/clis/zhihu/answer-detail.test.js +330 -0
  56. package/clis/zhihu/question.js +44 -10
  57. package/clis/zhihu/question.test.js +78 -1
  58. package/clis/zhihu/recommend.js +103 -0
  59. package/clis/zhihu/recommend.test.js +143 -0
  60. package/dist/src/browser/base-page.d.ts +3 -2
  61. package/dist/src/browser/base-page.test.js +2 -2
  62. package/dist/src/browser/cdp.js +3 -3
  63. package/dist/src/browser/page.d.ts +3 -2
  64. package/dist/src/browser/page.js +4 -4
  65. package/dist/src/browser/page.test.js +31 -0
  66. package/dist/src/browser/utils.d.ts +10 -0
  67. package/dist/src/browser/utils.js +37 -0
  68. package/dist/src/browser/utils.test.d.ts +1 -0
  69. package/dist/src/browser/utils.test.js +29 -0
  70. package/dist/src/cli-argv-preprocess.d.ts +37 -0
  71. package/dist/src/cli-argv-preprocess.js +131 -0
  72. package/dist/src/cli-argv-preprocess.test.d.ts +1 -0
  73. package/dist/src/cli-argv-preprocess.test.js +130 -0
  74. package/dist/src/cli.js +123 -86
  75. package/dist/src/cli.test.js +33 -28
  76. package/dist/src/commands/daemon.js +6 -7
  77. package/dist/src/doctor.js +15 -16
  78. package/dist/src/download/progress.js +15 -11
  79. package/dist/src/download/progress.test.d.ts +1 -0
  80. package/dist/src/download/progress.test.js +25 -0
  81. package/dist/src/execution.js +1 -3
  82. package/dist/src/execution.test.js +4 -16
  83. package/dist/src/help.d.ts +11 -0
  84. package/dist/src/help.js +46 -5
  85. package/dist/src/logger.js +8 -9
  86. package/dist/src/main.js +16 -0
  87. package/dist/src/output.js +4 -5
  88. package/dist/src/runtime-detect.d.ts +1 -1
  89. package/dist/src/runtime-detect.js +1 -1
  90. package/dist/src/runtime-detect.test.js +3 -2
  91. package/dist/src/tui.d.ts +0 -1
  92. package/dist/src/tui.js +9 -22
  93. package/dist/src/types.d.ts +3 -1
  94. package/dist/src/update-check.js +4 -5
  95. package/package.json +5 -4
@@ -349,20 +349,20 @@ describe('createProgram root help descriptions', () => {
349
349
  expect(data.command).toBe('opencli browser');
350
350
  expect(data.description).toBe('Browser control — navigate, click, type, extract, wait (no LLM needed)');
351
351
  expect(data.command_count).toBeGreaterThan(20);
352
+ // `--session` is now a hidden internal option; user-facing surface is the
353
+ // <session> positional declared via `.usage()`. Structured help drops
354
+ // hidden options, so namespace_options shouldn't expose it.
355
+ expect(data.namespace_options).not.toEqual(expect.arrayContaining([
356
+ expect.objectContaining({ name: 'session' }),
357
+ ]));
352
358
  expect(data.namespace_options).toEqual(expect.arrayContaining([
353
- expect.objectContaining({
354
- name: 'session',
355
- flags: '--session <name>',
356
- takes_value: 'required',
357
- required: true,
358
- help: expect.stringContaining('required'),
359
- }),
360
359
  expect.objectContaining({
361
360
  name: 'window',
362
361
  flags: '--window <mode>',
363
362
  takes_value: 'required',
364
363
  }),
365
364
  ]));
365
+ expect(data.usage).toBe('opencli browser <session> <command> [options]');
366
366
  expect(data.global_options).toEqual(expect.arrayContaining([
367
367
  expect.objectContaining({
368
368
  name: 'version',
@@ -375,21 +375,24 @@ describe('createProgram root help descriptions', () => {
375
375
  }),
376
376
  ]));
377
377
  const click = data.commands.find((cmd) => cmd.name === 'click');
378
+ // Structured help command/usage paths include the <session> positional so
379
+ // agents construct the correct full invocation. `name` is the leaf
380
+ // identifier (placeholder positionals are stripped).
378
381
  expect(click).toMatchObject({
379
- command: 'opencli browser click',
380
- usage: 'opencli browser click [target] [options]',
382
+ command: 'opencli browser <session> click',
383
+ usage: 'opencli browser <session> click [target] [options]',
381
384
  positionals: [{ name: 'target' }],
382
385
  });
383
386
  expect(click.command_options.map((option) => option.name)).toEqual(['role', 'name', 'label', 'text', 'testid', 'nth', 'tab']);
384
387
  const tabList = data.commands.find((cmd) => cmd.name === 'tab list');
385
388
  expect(tabList).toMatchObject({
386
- command: 'opencli browser tab list',
387
- usage: 'opencli browser tab list [options]',
389
+ command: 'opencli browser <session> tab list',
390
+ usage: 'opencli browser <session> tab list [options]',
388
391
  command_options: [],
389
392
  });
390
393
  const getText = data.commands.find((cmd) => cmd.name === 'get text');
391
394
  expect(getText).toMatchObject({
392
- command: 'opencli browser get text',
395
+ command: 'opencli browser <session> get text',
393
396
  positionals: [{ name: 'target' }],
394
397
  });
395
398
  expect(data.structured_help).toMatchObject({
@@ -413,8 +416,8 @@ describe('createProgram root help descriptions', () => {
413
416
  expect(data).toMatchObject({
414
417
  namespace: 'browser',
415
418
  group: 'tab',
416
- command: 'opencli browser tab',
417
- usage: 'opencli browser tab <command> [args] [options]',
419
+ command: 'opencli browser <session> tab',
420
+ usage: 'opencli browser <session> tab <command> [args] [options]',
418
421
  command_count: 4,
419
422
  });
420
423
  expect(data.commands.map((cmd) => cmd.name)).toEqual([
@@ -424,13 +427,15 @@ describe('createProgram root help descriptions', () => {
424
427
  'tab select',
425
428
  ]);
426
429
  expect(data.commands.find((cmd) => cmd.name === 'tab close')).toMatchObject({
427
- command: 'opencli browser tab close',
428
- usage: 'opencli browser tab close [targetId] [options]',
430
+ command: 'opencli browser <session> tab close',
431
+ usage: 'opencli browser <session> tab close [targetId] [options]',
429
432
  positionals: [{ name: 'targetId', help: 'Target tab/page identity returned by "browser open", "browser tab new", or "browser tab list"' }],
430
433
  });
431
- expect(data.namespace_options.map((option) => option.name)).toEqual(['session', 'window']);
434
+ // session is now a hidden internal option (consumed from the <session> positional).
435
+ // namespace_options should only list user-facing options.
436
+ expect(data.namespace_options.map((option) => option.name)).toEqual(['window']);
432
437
  expect(data.structured_help).toMatchObject({
433
- usage: 'opencli browser tab --help -f yaml',
438
+ usage: 'opencli browser <session> tab --help -f yaml',
434
439
  });
435
440
  }
436
441
  finally {
@@ -449,15 +454,16 @@ describe('createProgram root help descriptions', () => {
449
454
  expect(data).toMatchObject({
450
455
  namespace: 'browser',
451
456
  name: 'click',
452
- command: 'opencli browser click',
453
- usage: 'opencli browser click [target] [options]',
457
+ command: 'opencli browser <session> click',
458
+ usage: 'opencli browser <session> click [target] [options]',
454
459
  positionals: [{ name: 'target' }],
455
460
  structured_help: {
456
- usage: 'opencli browser click --help -f yaml',
461
+ usage: 'opencli browser <session> click --help -f yaml',
457
462
  },
458
463
  });
459
464
  expect(data.command_options.map((option) => option.name)).toEqual(['role', 'name', 'label', 'text', 'testid', 'nth', 'tab']);
460
- expect(data.namespace_options.map((option) => option.name)).toEqual(['session', 'window']);
465
+ // session is hidden; only `window` surfaces as a namespace option.
466
+ expect(data.namespace_options.map((option) => option.name)).toEqual(['window']);
461
467
  expect(data.global_options.map((option) => option.name)).toContain('profile');
462
468
  }
463
469
  finally {
@@ -898,13 +904,12 @@ describe('browser tab targeting commands', () => {
898
904
  });
899
905
  it('requires an explicit session for browser commands', async () => {
900
906
  const program = createProgram('', '');
901
- program.exitOverride((err) => { throw err; });
902
- program.commands.find(cmd => cmd.name() === 'browser')?.exitOverride((err) => { throw err; });
903
- await expect(program.parseAsync(['node', 'opencli', 'browser', 'state'])).rejects.toMatchObject({
904
- code: 'commander.missingMandatoryOptionValue',
905
- });
907
+ // --session is now a hidden internal flag; commander no longer guards it.
908
+ // The action body throws via getBrowserSession(), surfacing the
909
+ // <session> positional in the error message.
910
+ await program.parseAsync(['node', 'opencli', 'browser', 'state']);
906
911
  expect(mockBrowserConnect).not.toHaveBeenCalled();
907
- expect(stderrSpy.mock.calls.flat().join('')).toContain("required option '--session <name>' not specified");
912
+ expect(stderrSpy.mock.calls.flat().join('')).toContain('<session> is a required positional argument');
908
913
  });
909
914
  it('runs browser commands against an explicit session', async () => {
910
915
  const program = createProgram('', '');
@@ -4,7 +4,6 @@
4
4
  * opencli daemon stop — graceful shutdown
5
5
  * opencli daemon restart — graceful shutdown, then start a fresh daemon
6
6
  */
7
- import { styleText } from 'node:util';
8
7
  import { fetchDaemonStatus, requestDaemonShutdown } from '../browser/daemon-client.js';
9
8
  import { restartDaemon } from '../browser/daemon-lifecycle.js';
10
9
  import { formatDuration } from '../download/progress.js';
@@ -14,18 +13,18 @@ import { formatDaemonVersion, isDaemonStale } from '../browser/daemon-version.js
14
13
  export async function daemonStatus() {
15
14
  const status = await fetchDaemonStatus();
16
15
  if (!status) {
17
- console.log(`Daemon: ${styleText('dim', 'not running')}`);
16
+ console.log('Daemon: not running');
18
17
  return;
19
18
  }
20
19
  const extensionLabel = !status.extensionConnected
21
- ? styleText('yellow', 'disconnected')
20
+ ? 'disconnected'
22
21
  : status.extensionVersion
23
- ? `${styleText('green', 'connected')} ${styleText('dim', `(v${status.extensionVersion})`)}`
24
- : `${styleText('yellow', 'connected')} ${styleText('dim', '(version unknown)')}`;
22
+ ? `connected (v${status.extensionVersion})`
23
+ : 'connected (version unknown)';
25
24
  const daemonVersion = formatDaemonVersion(status);
26
25
  const stale = isDaemonStale(status, PKG_VERSION);
27
- console.log(`Daemon: ${stale ? styleText('yellow', 'stale') : styleText('green', 'running')} (PID ${status.pid})`);
28
- console.log(`Version: ${daemonVersion}${stale ? styleText('yellow', ` (CLI v${PKG_VERSION}; run: opencli daemon restart)`) : ''}`);
26
+ console.log(`Daemon: ${stale ? 'stale' : 'running'} (PID ${status.pid})`);
27
+ console.log(`Version: ${daemonVersion}${stale ? ` (CLI v${PKG_VERSION}; run: opencli daemon restart)` : ''}`);
29
28
  console.log(`Uptime: ${formatDuration(Math.round(status.uptime * 1000))}`);
30
29
  console.log(`Extension: ${extensionLabel}`);
31
30
  if (status.profiles && status.profiles.length > 0) {
@@ -3,7 +3,6 @@
3
3
  *
4
4
  * Simplified for the daemon-based architecture.
5
5
  */
6
- import { styleText } from 'node:util';
7
6
  import { DEFAULT_DAEMON_PORT } from './constants.js';
8
7
  import { BrowserBridge } from './browser/index.js';
9
8
  import { getDaemonHealth } from './browser/daemon-client.js';
@@ -168,13 +167,13 @@ export async function runBrowserDoctor(opts = {}) {
168
167
  };
169
168
  }
170
169
  export function renderBrowserDoctorReport(report) {
171
- const lines = [styleText('bold', `opencli v${report.cliVersion ?? 'unknown'} doctor`) + styleText('dim', ` (${getRuntimeLabel()})`), ''];
170
+ const lines = [`opencli v${report.cliVersion ?? 'unknown'} doctor` + ` (${getRuntimeLabel()})`, ''];
172
171
  // Daemon status
173
172
  const daemonIcon = report.daemonFlaky
174
- ? styleText('yellow', '[WARN]')
173
+ ? '[WARN]'
175
174
  : report.daemonStale
176
- ? styleText('yellow', '[WARN]')
177
- : report.daemonRunning ? styleText('green', '[OK]') : styleText('red', '[MISSING]');
175
+ ? '[WARN]'
176
+ : report.daemonRunning ? '[OK]' : '[MISSING]';
178
177
  const daemonLabel = report.daemonFlaky
179
178
  ? 'unstable (running during live check, then stopped)'
180
179
  : report.daemonRunning
@@ -185,47 +184,47 @@ export function renderBrowserDoctorReport(report) {
185
184
  lines.push(`${daemonIcon} Daemon: ${daemonLabel}`);
186
185
  // Extension status
187
186
  const extIcon = report.extensionFlaky || (report.extensionConnected && !report.extensionVersion)
188
- ? styleText('yellow', '[WARN]')
189
- : report.extensionConnected ? styleText('green', '[OK]') : styleText('yellow', '[MISSING]');
187
+ ? '[WARN]'
188
+ : report.extensionConnected ? '[OK]' : '[MISSING]';
190
189
  const extUpdateHint = report.extensionVersion && report.latestExtensionVersion && isNewerVersion(report.latestExtensionVersion, report.extensionVersion)
191
- ? styleText('yellow', ` → v${report.latestExtensionVersion} available`)
190
+ ? ` → v${report.latestExtensionVersion} available`
192
191
  : '';
193
192
  const extVersion = !report.extensionConnected
194
193
  ? ''
195
194
  : report.extensionVersion
196
- ? styleText('dim', ` (v${report.extensionVersion})`) + extUpdateHint
197
- : styleText('dim', ' (version unknown)');
195
+ ? ` (v${report.extensionVersion})` + extUpdateHint
196
+ : ' (version unknown)';
198
197
  const extLabel = report.extensionFlaky
199
198
  ? 'unstable (connected during live check, then disconnected)'
200
199
  : report.extensionConnected ? 'connected' : 'not connected';
201
200
  lines.push(`${extIcon} Extension: ${extLabel}${extVersion}`);
202
201
  if (report.profiles && report.profiles.length > 0) {
203
202
  const config = loadProfileConfig();
204
- lines.push('', styleText('bold', 'Profiles:'));
203
+ lines.push('', 'Profiles:');
205
204
  for (const profile of report.profiles) {
206
205
  const alias = aliasForContextId(config, profile.contextId);
207
206
  const aliasText = alias ? ` (${alias})` : '';
208
207
  const defaultText = config.defaultContextId === profile.contextId ? ', default' : '';
209
208
  const version = profile.extensionVersion ? `v${profile.extensionVersion}` : 'version unknown';
210
- lines.push(styleText('dim', ` • ${profile.contextId}${aliasText}: connected ${version}${defaultText}`));
209
+ lines.push(` • ${profile.contextId}${aliasText}: connected ${version}${defaultText}`);
211
210
  }
212
211
  }
213
212
  // Connectivity
214
213
  if (report.connectivity) {
215
- const connIcon = report.connectivity.ok ? styleText('green', '[OK]') : styleText('red', '[FAIL]');
214
+ const connIcon = report.connectivity.ok ? '[OK]' : '[FAIL]';
216
215
  const detail = report.connectivity.ok
217
216
  ? `connected in ${(report.connectivity.durationMs / 1000).toFixed(1)}s`
218
217
  : `failed (${report.connectivity.error ?? 'unknown'})`;
219
218
  lines.push(`${connIcon} Connectivity: ${detail}`);
220
219
  }
221
220
  if (report.issues.length) {
222
- lines.push('', styleText('yellow', 'Issues:'));
221
+ lines.push('', 'Issues:');
223
222
  for (const issue of report.issues) {
224
- lines.push(styleText('dim', ` • ${issue}`));
223
+ lines.push(` • ${issue}`);
225
224
  }
226
225
  }
227
226
  else if (report.daemonRunning && report.extensionConnected) {
228
- lines.push('', styleText('green', 'Everything looks good!'));
227
+ lines.push('', 'Everything looks good!');
229
228
  }
230
229
  return lines.join('\n');
231
230
  }
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * Download progress display: terminal progress bars, status updates.
3
3
  */
4
- import { styleText } from 'node:util';
5
4
  /**
6
5
  * Format bytes as human-readable string (KB, MB, GB).
7
6
  */
@@ -35,33 +34,38 @@ export function formatDuration(ms) {
35
34
  * Create a simple progress bar for terminal display.
36
35
  */
37
36
  export function createProgressBar(filename, index, total) {
38
- const prefix = styleText('dim', `[${index + 1}/${total}]`);
37
+ const prefix = `[${index + 1}/${total}]`;
39
38
  const truncatedName = filename.length > 40 ? filename.slice(0, 37) + '...' : filename;
40
39
  return {
41
40
  update(current, totalBytes, label) {
42
- const percent = totalBytes > 0 ? Math.round((current / totalBytes) * 100) : 0;
41
+ const percent = clampPercent(totalBytes > 0 ? Math.round((current / totalBytes) * 100) : 0);
43
42
  const bar = createBar(percent);
44
43
  const size = totalBytes > 0 ? formatBytes(totalBytes) : '';
45
44
  const extra = label ? ` ${label}` : '';
46
45
  process.stderr.write(`\r${prefix} ${truncatedName} ${bar} ${percent}% ${size}${extra}`);
47
46
  },
48
47
  complete(success, message) {
49
- const icon = success ? styleText('green', '✓') : styleText('red', '✗');
50
- const msg = message ? ` ${styleText('dim', message)}` : '';
48
+ const icon = success ? '✓' : '✗';
49
+ const msg = message ? ` ${message}` : '';
51
50
  process.stderr.write(`\r${prefix} ${icon} ${truncatedName}${msg}\n`);
52
51
  },
53
52
  fail(error) {
54
- process.stderr.write(`\r${prefix} ${styleText('red', '')} ${truncatedName} ${styleText('red', error)}\n`);
53
+ process.stderr.write(`\r${prefix} ✗ ${truncatedName} ${error}\n`);
55
54
  },
56
55
  };
57
56
  }
57
+ function clampPercent(percent) {
58
+ if (!Number.isFinite(percent))
59
+ return 0;
60
+ return Math.max(0, Math.min(100, percent));
61
+ }
58
62
  /**
59
63
  * Create a progress bar string.
60
64
  */
61
65
  function createBar(percent, width = 20) {
62
66
  const filled = Math.round((percent / 100) * width);
63
67
  const empty = width - filled;
64
- return styleText('cyan', '█'.repeat(filled)) + styleText('dim', '░'.repeat(empty));
68
+ return '█'.repeat(filled) + '░'.repeat(empty);
65
69
  }
66
70
  /**
67
71
  * Multi-file download progress tracker.
@@ -98,19 +102,19 @@ export class DownloadProgressTracker {
98
102
  const elapsed = formatDuration(Date.now() - this.startTime);
99
103
  const parts = [];
100
104
  if (this.completed > 0) {
101
- parts.push(styleText('green', `${this.completed} downloaded`));
105
+ parts.push(`${this.completed} downloaded`);
102
106
  }
103
107
  if (this.skipped > 0) {
104
- parts.push(styleText('yellow', `${this.skipped} skipped`));
108
+ parts.push(`${this.skipped} skipped`);
105
109
  }
106
110
  if (this.failed > 0) {
107
- parts.push(styleText('red', `${this.failed} failed`));
111
+ parts.push(`${this.failed} failed`);
108
112
  }
109
113
  return `${parts.join(', ')} in ${elapsed}`;
110
114
  }
111
115
  finish() {
112
116
  if (this.verbose) {
113
- process.stderr.write(`\n${styleText('bold', 'Download complete:')} ${this.getSummary()}\n`);
117
+ process.stderr.write(`\nDownload complete: ${this.getSummary()}\n`);
114
118
  }
115
119
  }
116
120
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,25 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { createProgressBar } from './progress.js';
3
+ describe('download progress display', () => {
4
+ afterEach(() => {
5
+ vi.restoreAllMocks();
6
+ });
7
+ it('clamps percentages above 100 to keep the progress bar renderable', () => {
8
+ const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
9
+ const progress = createProgressBar('file.bin', 0, 1);
10
+ expect(() => progress.update(150, 100)).not.toThrow();
11
+ expect(write).toHaveBeenCalledWith(expect.stringContaining('100%'));
12
+ });
13
+ it('clamps negative percentages to zero', () => {
14
+ const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
15
+ const progress = createProgressBar('file.bin', 0, 1);
16
+ expect(() => progress.update(-10, 100)).not.toThrow();
17
+ expect(write).toHaveBeenCalledWith(expect.stringContaining('0%'));
18
+ });
19
+ it('renders zero percent when the total size is unknown', () => {
20
+ const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
21
+ const progress = createProgressBar('file.bin', 0, 1);
22
+ expect(() => progress.update(50, 0)).not.toThrow();
23
+ expect(write).toHaveBeenCalledWith(expect.stringContaining('0%'));
24
+ });
25
+ });
@@ -467,9 +467,7 @@ function normalizeBooleanOption(name, raw) {
467
467
  function resolveKeepTab(siteSession, rawOption) {
468
468
  if (siteSession === 'persistent')
469
469
  return true;
470
- return normalizeBooleanOption('--keep-tab', rawOption)
471
- ?? normalizeBooleanOption('OPENCLI_KEEP_TAB', process.env.OPENCLI_KEEP_TAB)
472
- ?? false;
470
+ return normalizeBooleanOption('--keep-tab', rawOption) ?? false;
473
471
  }
474
472
  function normalizeWindowMode(name, raw) {
475
473
  if (raw === undefined || raw === '')
@@ -370,13 +370,11 @@ describe('executeCommand — non-browser timeout', () => {
370
370
  expect(closeWindow).toHaveBeenCalledTimes(1);
371
371
  vi.restoreAllMocks();
372
372
  });
373
- it('skips closeWindow when OPENCLI_KEEP_TAB=true (success path)', async () => {
373
+ it('skips closeWindow when --keep-tab=true (success path)', async () => {
374
374
  const closeWindow = vi.fn().mockResolvedValue(undefined);
375
375
  const mockPage = { closeWindow };
376
376
  vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
377
377
  vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
378
- const prev = process.env.OPENCLI_KEEP_TAB;
379
- process.env.OPENCLI_KEEP_TAB = 'true';
380
378
  try {
381
379
  const cmd = cli({
382
380
  site: 'test-execution',
@@ -386,24 +384,18 @@ describe('executeCommand — non-browser timeout', () => {
386
384
  strategy: Strategy.PUBLIC,
387
385
  func: async () => [{ ok: true }],
388
386
  });
389
- await executeCommand(cmd, {});
387
+ await executeCommand(cmd, {}, false, { keepTab: 'true' });
390
388
  expect(closeWindow).not.toHaveBeenCalled();
391
389
  }
392
390
  finally {
393
- if (prev === undefined)
394
- delete process.env.OPENCLI_KEEP_TAB;
395
- else
396
- process.env.OPENCLI_KEEP_TAB = prev;
397
391
  vi.restoreAllMocks();
398
392
  }
399
393
  });
400
- it('skips closeWindow when OPENCLI_KEEP_TAB=true (failure path)', async () => {
394
+ it('skips closeWindow when --keep-tab=true (failure path)', async () => {
401
395
  const closeWindow = vi.fn().mockResolvedValue(undefined);
402
396
  const mockPage = { closeWindow };
403
397
  vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
404
398
  vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
405
- const prev = process.env.OPENCLI_KEEP_TAB;
406
- process.env.OPENCLI_KEEP_TAB = 'true';
407
399
  try {
408
400
  const cmd = cli({
409
401
  site: 'test-execution',
@@ -413,14 +405,10 @@ describe('executeCommand — non-browser timeout', () => {
413
405
  strategy: Strategy.PUBLIC,
414
406
  func: async () => { throw new Error('adapter failure'); },
415
407
  });
416
- await expect(executeCommand(cmd, {})).rejects.toThrow('adapter failure');
408
+ await expect(executeCommand(cmd, {}, false, { keepTab: 'true' })).rejects.toThrow('adapter failure');
417
409
  expect(closeWindow).not.toHaveBeenCalled();
418
410
  }
419
411
  finally {
420
- if (prev === undefined)
421
- delete process.env.OPENCLI_KEEP_TAB;
422
- else
423
- process.env.OPENCLI_KEEP_TAB = prev;
424
412
  vi.restoreAllMocks();
425
413
  }
426
414
  });
@@ -47,6 +47,17 @@ export interface RootAdapterGroups {
47
47
  sites: readonly string[];
48
48
  }
49
49
  export declare function formatRootAdapterHelpText(groups: RootAdapterGroups): string;
50
+ /**
51
+ * Extracts a positional placeholder that should appear immediately after this
52
+ * command's name in user-facing path strings. Reads the leading positional
53
+ * (e.g. `<session>`) from a `.usage()` override; commands without a positional
54
+ * override return `null` so the path stays as-is.
55
+ *
56
+ * Example: `browser` declares `.usage('<session> <command> [options]')`,
57
+ * so `commanderPath(browserClickCmd)` becomes
58
+ * `['opencli', 'browser', '<session>', 'click']`.
59
+ */
60
+ export declare function leadingPositionalFromUsage(command: Command): string | null;
50
61
  export declare function commanderNamespaceHelpData(namespaceRoot: Command, opts?: {
51
62
  globalCommand?: Command;
52
63
  description?: string;
package/dist/src/help.js CHANGED
@@ -175,13 +175,42 @@ function compactCommanderOptions(options) {
175
175
  .map(compactCommanderOption)
176
176
  .filter((option) => option !== null);
177
177
  }
178
+ /**
179
+ * Extracts a positional placeholder that should appear immediately after this
180
+ * command's name in user-facing path strings. Reads the leading positional
181
+ * (e.g. `<session>`) from a `.usage()` override; commands without a positional
182
+ * override return `null` so the path stays as-is.
183
+ *
184
+ * Example: `browser` declares `.usage('<session> <command> [options]')`,
185
+ * so `commanderPath(browserClickCmd)` becomes
186
+ * `['opencli', 'browser', '<session>', 'click']`.
187
+ */
188
+ export function leadingPositionalFromUsage(command) {
189
+ const usage = command._usage;
190
+ if (!usage)
191
+ return null;
192
+ const match = usage.match(/^\s*(<[^>]+>)/);
193
+ return match ? match[1] : null;
194
+ }
178
195
  function commanderPath(command) {
179
196
  const parts = [];
180
197
  let current = command;
181
198
  while (current) {
182
199
  const name = current.name();
183
- if (name)
200
+ if (name) {
184
201
  parts.push(name);
202
+ // If this command declares a leading-positional usage override AND we
203
+ // have already collected a child name below it, the positional must
204
+ // appear between this command and the child (i.e. before the names
205
+ // already collected). parts is in reverse order, so push to the end.
206
+ const positional = leadingPositionalFromUsage(current);
207
+ if (positional && parts.length > 1) {
208
+ // We collected child names first (reverse order). Move them up by one
209
+ // and put the positional at index `parts.length - 2` so reverse()
210
+ // places it between this command and the first child name.
211
+ parts.splice(parts.length - 1, 0, positional);
212
+ }
213
+ }
185
214
  current = current.parent;
186
215
  }
187
216
  return parts.reverse();
@@ -189,7 +218,10 @@ function commanderPath(command) {
189
218
  function commandPathFromRoot(namespaceRoot, command) {
190
219
  const rootPath = commanderPath(namespaceRoot);
191
220
  const commandPath = commanderPath(command);
192
- return commandPath.slice(rootPath.length);
221
+ // Strip placeholder positional segments (e.g. `<session>`) from the relative
222
+ // name so agents can still address subcommands by their leaf name. Display
223
+ // paths in `command` / `usage` still include the placeholders.
224
+ return commandPath.slice(rootPath.length).filter(part => !/^<.+>$/.test(part));
193
225
  }
194
226
  function collectLeafCommands(command) {
195
227
  if (command.commands.length === 0)
@@ -232,10 +264,19 @@ export function commanderNamespaceHelpData(namespaceRoot, opts = {}) {
232
264
  const leaves = collectLeafCommands(namespaceRoot)
233
265
  .filter(command => command !== namespaceRoot)
234
266
  .sort((a, b) => commandPathFromRoot(namespaceRoot, a).join(' ').localeCompare(commandPathFromRoot(namespaceRoot, b).join(' ')));
267
+ // Respect commander's `.usage()` override (e.g. `<session> <command> [options]`
268
+ // on `browser`); fall back to the generic `<command> [args] [options]` form.
269
+ // Read the private `_usage` field directly because `.usage()` returns the
270
+ // auto-generated form if no override was set.
271
+ const commandPath = commanderPath(namespaceRoot).join(' ');
272
+ const usageOverride = namespaceRoot._usage;
273
+ const usage = usageOverride
274
+ ? `${commandPath} ${usageOverride}`
275
+ : `${commandPath} <command> [args] [options]`;
235
276
  return {
236
277
  namespace: namespaceRoot.name(),
237
- command: commanderPath(namespaceRoot).join(' '),
238
- usage: `${commanderPath(namespaceRoot).join(' ')} <command> [args] [options]`,
278
+ command: commandPath,
279
+ usage,
239
280
  description: opts.description ?? namespaceRoot.description(),
240
281
  command_count: leaves.length,
241
282
  commands: leaves.map(command => compactCommanderCommand(namespaceRoot, command, opts)),
@@ -243,7 +284,7 @@ export function commanderNamespaceHelpData(namespaceRoot, opts = {}) {
243
284
  ...(opts.globalCommand ? { global_options: compactCommanderOptions(opts.globalCommand.options) } : {}),
244
285
  structured_help: {
245
286
  formats: ['yaml', 'json'],
246
- usage: `${commanderPath(namespaceRoot).join(' ')} --help -f yaml`,
287
+ usage: `${commandPath} --help -f yaml`,
247
288
  },
248
289
  };
249
290
  }
@@ -4,35 +4,34 @@
4
4
  * All framework output (warnings, debug info, errors) should go through
5
5
  * this module so that verbosity levels are respected consistently.
6
6
  */
7
- import { styleText } from 'node:util';
8
7
  function isVerbose() {
9
8
  return !!process.env.OPENCLI_VERBOSE;
10
9
  }
11
10
  export const log = {
12
11
  /** Informational message (always shown) */
13
12
  info(msg) {
14
- process.stderr.write(`${styleText('blue', 'ℹ')} ${msg}\n`);
13
+ process.stderr.write(`ℹ ${msg}\n`);
15
14
  },
16
15
  /** Lightweight status line for adapter progress updates */
17
16
  status(msg) {
18
- process.stderr.write(`${styleText('dim', msg)}\n`);
17
+ process.stderr.write(`${msg}\n`);
19
18
  },
20
19
  /** Positive completion/status line without the heavier info prefix */
21
20
  success(msg) {
22
- process.stderr.write(`${styleText('green', msg)}\n`);
21
+ process.stderr.write(`${msg}\n`);
23
22
  },
24
23
  /** Warning (always shown) */
25
24
  warn(msg) {
26
- process.stderr.write(`${styleText('yellow', '⚠')} ${msg}\n`);
25
+ process.stderr.write(`⚠ ${msg}\n`);
27
26
  },
28
27
  /** Error (always shown) */
29
28
  error(msg) {
30
- process.stderr.write(`${styleText('red', '✖')} ${msg}\n`);
29
+ process.stderr.write(`✖ ${msg}\n`);
31
30
  },
32
31
  /** Verbose output (shown when -v flag or OPENCLI_VERBOSE is set) */
33
32
  verbose(msg) {
34
33
  if (isVerbose()) {
35
- process.stderr.write(`${styleText('dim', '[verbose]')} ${msg}\n`);
34
+ process.stderr.write(`[verbose] ${msg}\n`);
36
35
  }
37
36
  },
38
37
  /** Alias for verbose output. */
@@ -41,10 +40,10 @@ export const log = {
41
40
  },
42
41
  /** Step-style debug (for pipeline steps, etc.) */
43
42
  step(stepNum, total, op, preview = '') {
44
- process.stderr.write(` ${styleText('dim', `[${stepNum}/${total}]`)} ${styleText(['bold', 'cyan'], op)}${preview}\n`);
43
+ process.stderr.write(` [${stepNum}/${total}] ${op}${preview}\n`);
45
44
  },
46
45
  /** Step result summary */
47
46
  stepResult(summary) {
48
- process.stderr.write(` ${styleText('dim', `→ ${summary}`)}\n`);
47
+ process.stderr.write(` ${summary}\n`);
49
48
  },
50
49
  };
package/dist/src/main.js CHANGED
@@ -139,5 +139,21 @@ if (getCompIdx !== -1) {
139
139
  process.stdout.write(candidates.join('\n') + '\n');
140
140
  process.exit(EXIT_CODES.SUCCESS);
141
141
  }
142
+ // Rewrite `opencli browser <session> <subcommand> ...` so commander (which
143
+ // can't combine a parent positional with subcommand dispatch) sees the internal
144
+ // `--session <name>` flag form. Also refuses the retired `opencli browser
145
+ // --session foo ...` user form with a friendly usage error.
146
+ const { rewriteBrowserArgv, BrowserSessionArgvError } = await import('./cli-argv-preprocess.js');
147
+ try {
148
+ const rewritten = rewriteBrowserArgv(process.argv.slice(2));
149
+ process.argv.splice(2, process.argv.length - 2, ...rewritten);
150
+ }
151
+ catch (err) {
152
+ if (err instanceof BrowserSessionArgvError) {
153
+ process.stderr.write(`error: ${err.message}\n`);
154
+ process.exit(EXIT_CODES.GENERIC_ERROR);
155
+ }
156
+ throw err;
157
+ }
142
158
  await emitHook('onStartup', { command: '__startup__', args: {} });
143
159
  runCli(BUILTIN_CLIS, USER_CLIS);