@jackwener/opencli 1.7.17 → 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 (118) hide show
  1. package/README.md +10 -8
  2. package/README.zh-CN.md +9 -8
  3. package/cli-manifest.json +585 -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/doubao/utils.js +17 -0
  9. package/clis/doubao/utils.test.js +61 -0
  10. package/clis/google/search.js +16 -6
  11. package/clis/google-scholar/search.js +20 -5
  12. package/clis/google-scholar/search.test.js +35 -2
  13. package/clis/reddit/home.js +117 -0
  14. package/clis/reddit/home.test.js +127 -0
  15. package/clis/reddit/read.js +400 -54
  16. package/clis/reddit/read.test.js +315 -12
  17. package/clis/reddit/reply.js +182 -0
  18. package/clis/reddit/reply.test.js +89 -0
  19. package/clis/reddit/subreddit-info.js +117 -0
  20. package/clis/reddit/subreddit-info.test.js +163 -0
  21. package/clis/reddit/whoami.js +84 -0
  22. package/clis/reddit/whoami.test.js +105 -0
  23. package/clis/rednote/comments.js +76 -0
  24. package/clis/rednote/download.js +59 -0
  25. package/clis/rednote/feed.js +95 -0
  26. package/clis/rednote/navigation.test.js +26 -0
  27. package/clis/rednote/note.js +68 -0
  28. package/clis/rednote/notifications.js +139 -0
  29. package/clis/rednote/rednote.test.js +157 -0
  30. package/clis/rednote/search.js +101 -0
  31. package/clis/rednote/user.js +55 -0
  32. package/clis/twitter/bookmark-folder.js +3 -1
  33. package/clis/twitter/bookmarks.js +3 -1
  34. package/clis/twitter/followers.js +20 -5
  35. package/clis/twitter/followers.test.js +44 -0
  36. package/clis/twitter/following.js +36 -20
  37. package/clis/twitter/following.test.js +60 -8
  38. package/clis/twitter/likes.js +28 -13
  39. package/clis/twitter/likes.test.js +111 -1
  40. package/clis/twitter/list-add.js +128 -204
  41. package/clis/twitter/list-add.test.js +97 -1
  42. package/clis/twitter/list-tweets.js +13 -4
  43. package/clis/twitter/list-tweets.test.js +48 -0
  44. package/clis/twitter/lists.js +5 -2
  45. package/clis/twitter/post.js +23 -4
  46. package/clis/twitter/post.test.js +30 -0
  47. package/clis/twitter/profile.js +16 -8
  48. package/clis/twitter/profile.test.js +39 -0
  49. package/clis/twitter/reply.js +133 -10
  50. package/clis/twitter/reply.test.js +55 -0
  51. package/clis/twitter/search.js +188 -170
  52. package/clis/twitter/search.test.js +96 -258
  53. package/clis/twitter/shared.js +167 -16
  54. package/clis/twitter/shared.test.js +102 -1
  55. package/clis/twitter/timeline.js +3 -1
  56. package/clis/twitter/tweets.js +147 -51
  57. package/clis/twitter/tweets.test.js +238 -1
  58. package/clis/xiaohongshu/comments.js +57 -26
  59. package/clis/xiaohongshu/comments.test.js +63 -1
  60. package/clis/xiaohongshu/download.js +32 -23
  61. package/clis/xiaohongshu/feed.js +23 -15
  62. package/clis/xiaohongshu/note-helpers.js +16 -6
  63. package/clis/xiaohongshu/note.js +26 -20
  64. package/clis/xiaohongshu/notifications.js +26 -19
  65. package/clis/xiaohongshu/search.js +201 -37
  66. package/clis/xiaohongshu/search.test.js +82 -8
  67. package/clis/xiaohongshu/user-helpers.js +13 -4
  68. package/clis/xiaohongshu/user-helpers.test.js +20 -0
  69. package/clis/xiaohongshu/user.js +9 -4
  70. package/clis/xueqiu/earnings-date.js +2 -2
  71. package/clis/xueqiu/kline.js +2 -2
  72. package/clis/xueqiu/utils.js +19 -0
  73. package/clis/xueqiu/utils.test.js +26 -0
  74. package/clis/youtube/transcript.js +28 -3
  75. package/clis/youtube/transcript.test.js +90 -1
  76. package/clis/zhihu/answer-detail.js +233 -0
  77. package/clis/zhihu/answer-detail.test.js +330 -0
  78. package/clis/zhihu/question.js +44 -10
  79. package/clis/zhihu/question.test.js +78 -1
  80. package/clis/zhihu/recommend.js +103 -0
  81. package/clis/zhihu/recommend.test.js +143 -0
  82. package/dist/src/browser/base-page.d.ts +3 -2
  83. package/dist/src/browser/base-page.test.js +2 -2
  84. package/dist/src/browser/cdp.js +3 -3
  85. package/dist/src/browser/page.d.ts +3 -2
  86. package/dist/src/browser/page.js +4 -4
  87. package/dist/src/browser/page.test.js +31 -0
  88. package/dist/src/browser/utils.d.ts +10 -0
  89. package/dist/src/browser/utils.js +37 -0
  90. package/dist/src/browser/utils.test.d.ts +1 -0
  91. package/dist/src/browser/utils.test.js +29 -0
  92. package/dist/src/cli-argv-preprocess.d.ts +37 -0
  93. package/dist/src/cli-argv-preprocess.js +131 -0
  94. package/dist/src/cli-argv-preprocess.test.d.ts +1 -0
  95. package/dist/src/cli-argv-preprocess.test.js +130 -0
  96. package/dist/src/cli.js +123 -86
  97. package/dist/src/cli.test.js +32 -22
  98. package/dist/src/commands/daemon.js +6 -7
  99. package/dist/src/doctor.js +21 -17
  100. package/dist/src/doctor.test.js +2 -0
  101. package/dist/src/download/progress.js +15 -11
  102. package/dist/src/download/progress.test.d.ts +1 -0
  103. package/dist/src/download/progress.test.js +25 -0
  104. package/dist/src/execution.js +1 -3
  105. package/dist/src/execution.test.js +4 -16
  106. package/dist/src/help.d.ts +11 -0
  107. package/dist/src/help.js +46 -5
  108. package/dist/src/logger.js +8 -9
  109. package/dist/src/main.js +16 -0
  110. package/dist/src/output.js +4 -5
  111. package/dist/src/runtime-detect.d.ts +1 -1
  112. package/dist/src/runtime-detect.js +1 -1
  113. package/dist/src/runtime-detect.test.js +3 -2
  114. package/dist/src/tui.d.ts +0 -1
  115. package/dist/src/tui.js +9 -22
  116. package/dist/src/types.d.ts +3 -1
  117. package/dist/src/update-check.js +4 -5
  118. package/package.json +5 -4
@@ -349,18 +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
- }),
358
359
  expect.objectContaining({
359
360
  name: 'window',
360
361
  flags: '--window <mode>',
361
362
  takes_value: 'required',
362
363
  }),
363
364
  ]));
365
+ expect(data.usage).toBe('opencli browser <session> <command> [options]');
364
366
  expect(data.global_options).toEqual(expect.arrayContaining([
365
367
  expect.objectContaining({
366
368
  name: 'version',
@@ -373,21 +375,24 @@ describe('createProgram root help descriptions', () => {
373
375
  }),
374
376
  ]));
375
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).
376
381
  expect(click).toMatchObject({
377
- command: 'opencli browser click',
378
- usage: 'opencli browser click [target] [options]',
382
+ command: 'opencli browser <session> click',
383
+ usage: 'opencli browser <session> click [target] [options]',
379
384
  positionals: [{ name: 'target' }],
380
385
  });
381
386
  expect(click.command_options.map((option) => option.name)).toEqual(['role', 'name', 'label', 'text', 'testid', 'nth', 'tab']);
382
387
  const tabList = data.commands.find((cmd) => cmd.name === 'tab list');
383
388
  expect(tabList).toMatchObject({
384
- command: 'opencli browser tab list',
385
- usage: 'opencli browser tab list [options]',
389
+ command: 'opencli browser <session> tab list',
390
+ usage: 'opencli browser <session> tab list [options]',
386
391
  command_options: [],
387
392
  });
388
393
  const getText = data.commands.find((cmd) => cmd.name === 'get text');
389
394
  expect(getText).toMatchObject({
390
- command: 'opencli browser get text',
395
+ command: 'opencli browser <session> get text',
391
396
  positionals: [{ name: 'target' }],
392
397
  });
393
398
  expect(data.structured_help).toMatchObject({
@@ -411,8 +416,8 @@ describe('createProgram root help descriptions', () => {
411
416
  expect(data).toMatchObject({
412
417
  namespace: 'browser',
413
418
  group: 'tab',
414
- command: 'opencli browser tab',
415
- usage: 'opencli browser tab <command> [args] [options]',
419
+ command: 'opencli browser <session> tab',
420
+ usage: 'opencli browser <session> tab <command> [args] [options]',
416
421
  command_count: 4,
417
422
  });
418
423
  expect(data.commands.map((cmd) => cmd.name)).toEqual([
@@ -422,13 +427,15 @@ describe('createProgram root help descriptions', () => {
422
427
  'tab select',
423
428
  ]);
424
429
  expect(data.commands.find((cmd) => cmd.name === 'tab close')).toMatchObject({
425
- command: 'opencli browser tab close',
426
- usage: 'opencli browser tab close [targetId] [options]',
430
+ command: 'opencli browser <session> tab close',
431
+ usage: 'opencli browser <session> tab close [targetId] [options]',
427
432
  positionals: [{ name: 'targetId', help: 'Target tab/page identity returned by "browser open", "browser tab new", or "browser tab list"' }],
428
433
  });
429
- 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']);
430
437
  expect(data.structured_help).toMatchObject({
431
- usage: 'opencli browser tab --help -f yaml',
438
+ usage: 'opencli browser <session> tab --help -f yaml',
432
439
  });
433
440
  }
434
441
  finally {
@@ -447,15 +454,16 @@ describe('createProgram root help descriptions', () => {
447
454
  expect(data).toMatchObject({
448
455
  namespace: 'browser',
449
456
  name: 'click',
450
- command: 'opencli browser click',
451
- usage: 'opencli browser click [target] [options]',
457
+ command: 'opencli browser <session> click',
458
+ usage: 'opencli browser <session> click [target] [options]',
452
459
  positionals: [{ name: 'target' }],
453
460
  structured_help: {
454
- usage: 'opencli browser click --help -f yaml',
461
+ usage: 'opencli browser <session> click --help -f yaml',
455
462
  },
456
463
  });
457
464
  expect(data.command_options.map((option) => option.name)).toEqual(['role', 'name', 'label', 'text', 'testid', 'nth', 'tab']);
458
- 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']);
459
467
  expect(data.global_options.map((option) => option.name)).toContain('profile');
460
468
  }
461
469
  finally {
@@ -896,10 +904,12 @@ describe('browser tab targeting commands', () => {
896
904
  });
897
905
  it('requires an explicit session for browser commands', async () => {
898
906
  const program = createProgram('', '');
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.
899
910
  await program.parseAsync(['node', 'opencli', 'browser', 'state']);
900
911
  expect(mockBrowserConnect).not.toHaveBeenCalled();
901
- expect(stderrSpy.mock.calls.flat().join('')).toContain('--session <name> is required');
902
- expect(process.exitCode).toBeDefined();
912
+ expect(stderrSpy.mock.calls.flat().join('')).toContain('<session> is a required positional argument');
903
913
  });
904
914
  it('runs browser commands against an explicit session', async () => {
905
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';
@@ -14,6 +13,7 @@ import { aliasForContextId, loadProfileConfig } from './browser/profile.js';
14
13
  import { formatDaemonVersion, isDaemonStale, staleDaemonIssue } from './browser/daemon-version.js';
15
14
  import { findShadowedUserAdapters, formatAdapterShadowIssue } from './adapter-shadow.js';
16
15
  const DOCTOR_LIVE_TIMEOUT_SECONDS = 8;
16
+ const DOCTOR_SESSION = '__doctor__';
17
17
  /** Parse a semver string into [major, minor, patch]. Returns null on invalid input. */
18
18
  function parseSemver(v) {
19
19
  const parts = v.replace(/^v/, '').split('-')[0].split('.').map(Number);
@@ -50,7 +50,11 @@ export async function checkConnectivity(opts) {
50
50
  const start = Date.now();
51
51
  try {
52
52
  const bridge = new BrowserBridge();
53
- const page = await bridge.connect({ timeout: opts?.timeout ?? DOCTOR_LIVE_TIMEOUT_SECONDS });
53
+ const page = await bridge.connect({
54
+ timeout: opts?.timeout ?? DOCTOR_LIVE_TIMEOUT_SECONDS,
55
+ session: DOCTOR_SESSION,
56
+ surface: 'browser',
57
+ });
54
58
  try {
55
59
  // Try a simple eval to verify end-to-end connectivity.
56
60
  await page.evaluate('1 + 1');
@@ -163,13 +167,13 @@ export async function runBrowserDoctor(opts = {}) {
163
167
  };
164
168
  }
165
169
  export function renderBrowserDoctorReport(report) {
166
- const lines = [styleText('bold', `opencli v${report.cliVersion ?? 'unknown'} doctor`) + styleText('dim', ` (${getRuntimeLabel()})`), ''];
170
+ const lines = [`opencli v${report.cliVersion ?? 'unknown'} doctor` + ` (${getRuntimeLabel()})`, ''];
167
171
  // Daemon status
168
172
  const daemonIcon = report.daemonFlaky
169
- ? styleText('yellow', '[WARN]')
173
+ ? '[WARN]'
170
174
  : report.daemonStale
171
- ? styleText('yellow', '[WARN]')
172
- : report.daemonRunning ? styleText('green', '[OK]') : styleText('red', '[MISSING]');
175
+ ? '[WARN]'
176
+ : report.daemonRunning ? '[OK]' : '[MISSING]';
173
177
  const daemonLabel = report.daemonFlaky
174
178
  ? 'unstable (running during live check, then stopped)'
175
179
  : report.daemonRunning
@@ -180,47 +184,47 @@ export function renderBrowserDoctorReport(report) {
180
184
  lines.push(`${daemonIcon} Daemon: ${daemonLabel}`);
181
185
  // Extension status
182
186
  const extIcon = report.extensionFlaky || (report.extensionConnected && !report.extensionVersion)
183
- ? styleText('yellow', '[WARN]')
184
- : report.extensionConnected ? styleText('green', '[OK]') : styleText('yellow', '[MISSING]');
187
+ ? '[WARN]'
188
+ : report.extensionConnected ? '[OK]' : '[MISSING]';
185
189
  const extUpdateHint = report.extensionVersion && report.latestExtensionVersion && isNewerVersion(report.latestExtensionVersion, report.extensionVersion)
186
- ? styleText('yellow', ` → v${report.latestExtensionVersion} available`)
190
+ ? ` → v${report.latestExtensionVersion} available`
187
191
  : '';
188
192
  const extVersion = !report.extensionConnected
189
193
  ? ''
190
194
  : report.extensionVersion
191
- ? styleText('dim', ` (v${report.extensionVersion})`) + extUpdateHint
192
- : styleText('dim', ' (version unknown)');
195
+ ? ` (v${report.extensionVersion})` + extUpdateHint
196
+ : ' (version unknown)';
193
197
  const extLabel = report.extensionFlaky
194
198
  ? 'unstable (connected during live check, then disconnected)'
195
199
  : report.extensionConnected ? 'connected' : 'not connected';
196
200
  lines.push(`${extIcon} Extension: ${extLabel}${extVersion}`);
197
201
  if (report.profiles && report.profiles.length > 0) {
198
202
  const config = loadProfileConfig();
199
- lines.push('', styleText('bold', 'Profiles:'));
203
+ lines.push('', 'Profiles:');
200
204
  for (const profile of report.profiles) {
201
205
  const alias = aliasForContextId(config, profile.contextId);
202
206
  const aliasText = alias ? ` (${alias})` : '';
203
207
  const defaultText = config.defaultContextId === profile.contextId ? ', default' : '';
204
208
  const version = profile.extensionVersion ? `v${profile.extensionVersion}` : 'version unknown';
205
- lines.push(styleText('dim', ` • ${profile.contextId}${aliasText}: connected ${version}${defaultText}`));
209
+ lines.push(` • ${profile.contextId}${aliasText}: connected ${version}${defaultText}`);
206
210
  }
207
211
  }
208
212
  // Connectivity
209
213
  if (report.connectivity) {
210
- const connIcon = report.connectivity.ok ? styleText('green', '[OK]') : styleText('red', '[FAIL]');
214
+ const connIcon = report.connectivity.ok ? '[OK]' : '[FAIL]';
211
215
  const detail = report.connectivity.ok
212
216
  ? `connected in ${(report.connectivity.durationMs / 1000).toFixed(1)}s`
213
217
  : `failed (${report.connectivity.error ?? 'unknown'})`;
214
218
  lines.push(`${connIcon} Connectivity: ${detail}`);
215
219
  }
216
220
  if (report.issues.length) {
217
- lines.push('', styleText('yellow', 'Issues:'));
221
+ lines.push('', 'Issues:');
218
222
  for (const issue of report.issues) {
219
- lines.push(styleText('dim', ` • ${issue}`));
223
+ lines.push(` • ${issue}`);
220
224
  }
221
225
  }
222
226
  else if (report.daemonRunning && report.extensionConnected) {
223
- lines.push('', styleText('green', 'Everything looks good!'));
227
+ lines.push('', 'Everything looks good!');
224
228
  }
225
229
  return lines.join('\n');
226
230
  }
@@ -173,6 +173,8 @@ describe('doctor report rendering', () => {
173
173
  const closeWindow = vi.fn().mockResolvedValue(undefined);
174
174
  mockConnect.mockImplementationOnce(async (opts) => {
175
175
  timeoutSeen = opts?.timeout;
176
+ expect(opts?.session).toBe('__doctor__');
177
+ expect(opts?.surface).toBe('browser');
176
178
  return {
177
179
  evaluate: vi.fn().mockResolvedValue(2),
178
180
  closeWindow,
@@ -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);