@jackwener/opencli 1.7.18 → 1.7.20

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 (120) hide show
  1. package/README.md +18 -17
  2. package/README.zh-CN.md +16 -18
  3. package/cli-manifest.json +311 -186
  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 +8 -4
  21. package/clis/twitter/bookmark-folder.test.js +59 -1
  22. package/clis/twitter/bookmarks.js +12 -4
  23. package/clis/twitter/bookmarks.test.js +205 -0
  24. package/clis/twitter/followers.js +20 -5
  25. package/clis/twitter/followers.test.js +44 -0
  26. package/clis/twitter/following.js +36 -20
  27. package/clis/twitter/following.test.js +60 -8
  28. package/clis/twitter/likes.js +28 -13
  29. package/clis/twitter/likes.test.js +111 -1
  30. package/clis/twitter/list-add.js +128 -204
  31. package/clis/twitter/list-add.test.js +97 -1
  32. package/clis/twitter/list-tweets.js +13 -4
  33. package/clis/twitter/list-tweets.test.js +48 -0
  34. package/clis/twitter/lists.js +5 -2
  35. package/clis/twitter/post.js +23 -4
  36. package/clis/twitter/post.test.js +30 -0
  37. package/clis/twitter/profile.js +16 -8
  38. package/clis/twitter/profile.test.js +39 -0
  39. package/clis/twitter/reply.js +133 -10
  40. package/clis/twitter/reply.test.js +55 -0
  41. package/clis/twitter/search.js +188 -170
  42. package/clis/twitter/search.test.js +96 -258
  43. package/clis/twitter/shared.js +167 -16
  44. package/clis/twitter/shared.test.js +102 -1
  45. package/clis/twitter/timeline.js +3 -1
  46. package/clis/twitter/tweets.js +147 -51
  47. package/clis/twitter/tweets.test.js +238 -1
  48. package/clis/xiaohongshu/comments.js +23 -2
  49. package/clis/xiaohongshu/comments.test.js +63 -1
  50. package/clis/xiaohongshu/search.js +168 -13
  51. package/clis/xiaohongshu/search.test.js +82 -8
  52. package/clis/xueqiu/earnings-date.js +2 -2
  53. package/clis/xueqiu/kline.js +2 -2
  54. package/clis/xueqiu/utils.js +19 -0
  55. package/clis/xueqiu/utils.test.js +26 -0
  56. package/clis/zhihu/answer-detail.js +233 -0
  57. package/clis/zhihu/answer-detail.test.js +330 -0
  58. package/clis/zhihu/question.js +44 -10
  59. package/clis/zhihu/question.test.js +78 -1
  60. package/clis/zhihu/recommend.js +103 -0
  61. package/clis/zhihu/recommend.test.js +143 -0
  62. package/dist/src/browser/base-page.d.ts +3 -2
  63. package/dist/src/browser/base-page.test.js +2 -2
  64. package/dist/src/browser/cdp.js +3 -3
  65. package/dist/src/browser/daemon-client.d.ts +1 -0
  66. package/dist/src/browser/daemon-client.js +3 -0
  67. package/dist/src/browser/daemon-client.test.js +20 -0
  68. package/dist/src/browser/page.d.ts +3 -2
  69. package/dist/src/browser/page.js +4 -4
  70. package/dist/src/browser/page.test.js +31 -0
  71. package/dist/src/browser/utils.d.ts +10 -0
  72. package/dist/src/browser/utils.js +37 -0
  73. package/dist/src/browser/utils.test.d.ts +1 -0
  74. package/dist/src/browser/utils.test.js +29 -0
  75. package/dist/src/cli-argv-preprocess.d.ts +37 -0
  76. package/dist/src/cli-argv-preprocess.js +131 -0
  77. package/dist/src/cli-argv-preprocess.test.d.ts +1 -0
  78. package/dist/src/cli-argv-preprocess.test.js +130 -0
  79. package/dist/src/cli.js +131 -89
  80. package/dist/src/cli.test.js +34 -28
  81. package/dist/src/commands/daemon.js +6 -7
  82. package/dist/src/daemon-utils.d.ts +18 -0
  83. package/dist/src/daemon-utils.js +37 -0
  84. package/dist/src/daemon.d.ts +1 -1
  85. package/dist/src/daemon.js +44 -13
  86. package/dist/src/daemon.test.js +42 -1
  87. package/dist/src/doctor.js +15 -16
  88. package/dist/src/download/progress.js +15 -11
  89. package/dist/src/download/progress.test.d.ts +1 -0
  90. package/dist/src/download/progress.test.js +25 -0
  91. package/dist/src/electron-apps.js +0 -1
  92. package/dist/src/electron-apps.test.js +1 -0
  93. package/dist/src/execution.js +1 -3
  94. package/dist/src/execution.test.js +4 -16
  95. package/dist/src/external-clis.yaml +12 -3
  96. package/dist/src/external.d.ts +4 -0
  97. package/dist/src/external.js +3 -0
  98. package/dist/src/external.test.js +24 -1
  99. package/dist/src/help.d.ts +16 -1
  100. package/dist/src/help.js +50 -8
  101. package/dist/src/help.test.js +5 -1
  102. package/dist/src/logger.js +8 -9
  103. package/dist/src/main.js +16 -0
  104. package/dist/src/output.js +4 -5
  105. package/dist/src/runtime-detect.d.ts +1 -1
  106. package/dist/src/runtime-detect.js +1 -1
  107. package/dist/src/runtime-detect.test.js +3 -2
  108. package/dist/src/tui.d.ts +0 -1
  109. package/dist/src/tui.js +9 -22
  110. package/dist/src/types.d.ts +3 -1
  111. package/dist/src/update-check.js +4 -5
  112. package/package.json +5 -4
  113. package/clis/notion/export.js +0 -32
  114. package/clis/notion/favorites.js +0 -85
  115. package/clis/notion/new.js +0 -35
  116. package/clis/notion/read.js +0 -31
  117. package/clis/notion/search.js +0 -47
  118. package/clis/notion/sidebar.js +0 -42
  119. package/clis/notion/status.js +0 -17
  120. package/clis/notion/write.js +0 -41
@@ -168,6 +168,7 @@ describe('createProgram root help descriptions', () => {
168
168
  expect(data.site_adapters.sites).toEqual(['bilibili']);
169
169
  expect(data.external_clis.count).toBeGreaterThanOrEqual(0);
170
170
  expect(Array.isArray(data.external_clis.clis)).toBe(true);
171
+ expect(Array.isArray(data.external_clis.display)).toBe(true);
171
172
  // Adapters must NOT leak into the core commands list
172
173
  const commandNames = data.commands.map((cmd) => cmd.name);
173
174
  expect(commandNames).not.toContain('bilibili');
@@ -349,20 +350,20 @@ describe('createProgram root help descriptions', () => {
349
350
  expect(data.command).toBe('opencli browser');
350
351
  expect(data.description).toBe('Browser control — navigate, click, type, extract, wait (no LLM needed)');
351
352
  expect(data.command_count).toBeGreaterThan(20);
353
+ // `--session` is now a hidden internal option; user-facing surface is the
354
+ // <session> positional declared via `.usage()`. Structured help drops
355
+ // hidden options, so namespace_options shouldn't expose it.
356
+ expect(data.namespace_options).not.toEqual(expect.arrayContaining([
357
+ expect.objectContaining({ name: 'session' }),
358
+ ]));
352
359
  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
360
  expect.objectContaining({
361
361
  name: 'window',
362
362
  flags: '--window <mode>',
363
363
  takes_value: 'required',
364
364
  }),
365
365
  ]));
366
+ expect(data.usage).toBe('opencli browser <session> <command> [options]');
366
367
  expect(data.global_options).toEqual(expect.arrayContaining([
367
368
  expect.objectContaining({
368
369
  name: 'version',
@@ -375,21 +376,24 @@ describe('createProgram root help descriptions', () => {
375
376
  }),
376
377
  ]));
377
378
  const click = data.commands.find((cmd) => cmd.name === 'click');
379
+ // Structured help command/usage paths include the <session> positional so
380
+ // agents construct the correct full invocation. `name` is the leaf
381
+ // identifier (placeholder positionals are stripped).
378
382
  expect(click).toMatchObject({
379
- command: 'opencli browser click',
380
- usage: 'opencli browser click [target] [options]',
383
+ command: 'opencli browser <session> click',
384
+ usage: 'opencli browser <session> click [target] [options]',
381
385
  positionals: [{ name: 'target' }],
382
386
  });
383
387
  expect(click.command_options.map((option) => option.name)).toEqual(['role', 'name', 'label', 'text', 'testid', 'nth', 'tab']);
384
388
  const tabList = data.commands.find((cmd) => cmd.name === 'tab list');
385
389
  expect(tabList).toMatchObject({
386
- command: 'opencli browser tab list',
387
- usage: 'opencli browser tab list [options]',
390
+ command: 'opencli browser <session> tab list',
391
+ usage: 'opencli browser <session> tab list [options]',
388
392
  command_options: [],
389
393
  });
390
394
  const getText = data.commands.find((cmd) => cmd.name === 'get text');
391
395
  expect(getText).toMatchObject({
392
- command: 'opencli browser get text',
396
+ command: 'opencli browser <session> get text',
393
397
  positionals: [{ name: 'target' }],
394
398
  });
395
399
  expect(data.structured_help).toMatchObject({
@@ -413,8 +417,8 @@ describe('createProgram root help descriptions', () => {
413
417
  expect(data).toMatchObject({
414
418
  namespace: 'browser',
415
419
  group: 'tab',
416
- command: 'opencli browser tab',
417
- usage: 'opencli browser tab <command> [args] [options]',
420
+ command: 'opencli browser <session> tab',
421
+ usage: 'opencli browser <session> tab <command> [args] [options]',
418
422
  command_count: 4,
419
423
  });
420
424
  expect(data.commands.map((cmd) => cmd.name)).toEqual([
@@ -424,13 +428,15 @@ describe('createProgram root help descriptions', () => {
424
428
  'tab select',
425
429
  ]);
426
430
  expect(data.commands.find((cmd) => cmd.name === 'tab close')).toMatchObject({
427
- command: 'opencli browser tab close',
428
- usage: 'opencli browser tab close [targetId] [options]',
431
+ command: 'opencli browser <session> tab close',
432
+ usage: 'opencli browser <session> tab close [targetId] [options]',
429
433
  positionals: [{ name: 'targetId', help: 'Target tab/page identity returned by "browser open", "browser tab new", or "browser tab list"' }],
430
434
  });
431
- expect(data.namespace_options.map((option) => option.name)).toEqual(['session', 'window']);
435
+ // session is now a hidden internal option (consumed from the <session> positional).
436
+ // namespace_options should only list user-facing options.
437
+ expect(data.namespace_options.map((option) => option.name)).toEqual(['window']);
432
438
  expect(data.structured_help).toMatchObject({
433
- usage: 'opencli browser tab --help -f yaml',
439
+ usage: 'opencli browser <session> tab --help -f yaml',
434
440
  });
435
441
  }
436
442
  finally {
@@ -449,15 +455,16 @@ describe('createProgram root help descriptions', () => {
449
455
  expect(data).toMatchObject({
450
456
  namespace: 'browser',
451
457
  name: 'click',
452
- command: 'opencli browser click',
453
- usage: 'opencli browser click [target] [options]',
458
+ command: 'opencli browser <session> click',
459
+ usage: 'opencli browser <session> click [target] [options]',
454
460
  positionals: [{ name: 'target' }],
455
461
  structured_help: {
456
- usage: 'opencli browser click --help -f yaml',
462
+ usage: 'opencli browser <session> click --help -f yaml',
457
463
  },
458
464
  });
459
465
  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']);
466
+ // session is hidden; only `window` surfaces as a namespace option.
467
+ expect(data.namespace_options.map((option) => option.name)).toEqual(['window']);
461
468
  expect(data.global_options.map((option) => option.name)).toContain('profile');
462
469
  }
463
470
  finally {
@@ -898,13 +905,12 @@ describe('browser tab targeting commands', () => {
898
905
  });
899
906
  it('requires an explicit session for browser commands', async () => {
900
907
  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
- });
908
+ // --session is now a hidden internal flag; commander no longer guards it.
909
+ // The action body throws via getBrowserSession(), surfacing the
910
+ // <session> positional in the error message.
911
+ await program.parseAsync(['node', 'opencli', 'browser', 'state']);
906
912
  expect(mockBrowserConnect).not.toHaveBeenCalled();
907
- expect(stderrSpy.mock.calls.flat().join('')).toContain("required option '--session <name>' not specified");
913
+ expect(stderrSpy.mock.calls.flat().join('')).toContain('<session> is a required positional argument');
908
914
  });
909
915
  it('runs browser commands against an explicit session', async () => {
910
916
  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) {
@@ -0,0 +1,18 @@
1
+ export declare const COMMAND_RESULT_UNKNOWN_CODE = "command_result_unknown";
2
+ export declare const COMMAND_RESULT_UNKNOWN_HINT = "Inspect the browser/session state before retrying. Do not blindly retry write commands such as navigate, click, type, or eval.";
3
+ export declare const PROFILE_DISCONNECTED_HINT = "Open that Chrome profile and make sure the OpenCLI extension is enabled, or choose another profile with opencli profile use <name>.";
4
+ export type DaemonFailureContract = {
5
+ message: string;
6
+ errorCode: string;
7
+ errorHint: string;
8
+ status: number;
9
+ countAsCommandResultUnknown: boolean;
10
+ };
11
+ export declare function commandResultUnknownMessage(action: string): string;
12
+ export declare function buildExtensionDisconnectFailure(input: {
13
+ contextId: string;
14
+ action: string;
15
+ dispatched: boolean;
16
+ }): DaemonFailureContract;
17
+ export declare function buildCommandDispatchFailure(contextId: string): DaemonFailureContract;
18
+ export declare function getResponseCorsHeaders(pathname: string, origin?: string): Record<string, string> | undefined;
@@ -0,0 +1,37 @@
1
+ export const COMMAND_RESULT_UNKNOWN_CODE = 'command_result_unknown';
2
+ export const COMMAND_RESULT_UNKNOWN_HINT = 'Inspect the browser/session state before retrying. Do not blindly retry write commands such as navigate, click, type, or eval.';
3
+ export const PROFILE_DISCONNECTED_HINT = 'Open that Chrome profile and make sure the OpenCLI extension is enabled, or choose another profile with opencli profile use <name>.';
4
+ export function commandResultUnknownMessage(action) {
5
+ return `Browser connection dropped after the ${action} command was dispatched; it may have completed.`;
6
+ }
7
+ export function buildExtensionDisconnectFailure(input) {
8
+ if (input.dispatched) {
9
+ return {
10
+ message: commandResultUnknownMessage(input.action),
11
+ errorCode: COMMAND_RESULT_UNKNOWN_CODE,
12
+ errorHint: COMMAND_RESULT_UNKNOWN_HINT,
13
+ status: 503,
14
+ countAsCommandResultUnknown: true,
15
+ };
16
+ }
17
+ return buildCommandDispatchFailure(input.contextId);
18
+ }
19
+ export function buildCommandDispatchFailure(contextId) {
20
+ return {
21
+ message: `Browser profile "${contextId}" disconnected before command dispatch`,
22
+ errorCode: 'profile_disconnected',
23
+ errorHint: PROFILE_DISCONNECTED_HINT,
24
+ status: 503,
25
+ countAsCommandResultUnknown: false,
26
+ };
27
+ }
28
+ export function getResponseCorsHeaders(pathname, origin) {
29
+ if (pathname !== '/ping')
30
+ return undefined;
31
+ if (!origin || !origin.startsWith('chrome-extension://'))
32
+ return undefined;
33
+ return {
34
+ 'Access-Control-Allow-Origin': origin,
35
+ Vary: 'Origin',
36
+ };
37
+ }
@@ -19,4 +19,4 @@
19
19
  * - Persistent — stays alive until explicit shutdown, SIGTERM, or uninstall
20
20
  * - Listens on localhost:19825
21
21
  */
22
- export declare function getResponseCorsHeaders(pathname: string, origin?: string): Record<string, string> | undefined;
22
+ export {};
@@ -27,9 +27,11 @@ import { log } from './logger.js';
27
27
  import { PKG_VERSION } from './version.js';
28
28
  import { DEFAULT_CONTEXT_ID } from './browser/profile.js';
29
29
  import { recordExtensionVersion } from './update-check.js';
30
+ import { buildCommandDispatchFailure, buildExtensionDisconnectFailure, getResponseCorsHeaders, } from './daemon-utils.js';
30
31
  const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
31
32
  const extensionProfiles = new Map();
32
33
  const pending = new Map();
34
+ let commandResultUnknownCount = 0;
33
35
  const LOG_BUFFER_SIZE = 200;
34
36
  const logBuffer = [];
35
37
  class DaemonCommandFailure extends Error {
@@ -110,7 +112,16 @@ function unregisterExtensionConnection(ws) {
110
112
  if (p.contextId !== contextId)
111
113
  continue;
112
114
  clearTimeout(p.timer);
113
- p.reject(new DaemonCommandFailure(`Browser profile "${contextId}" disconnected`, 'profile_disconnected', 'Open that Chrome profile and make sure the OpenCLI extension is enabled, or choose another profile with opencli profile use <name>.', 503));
115
+ const failure = buildExtensionDisconnectFailure({
116
+ contextId,
117
+ action: p.action,
118
+ dispatched: p.dispatched,
119
+ });
120
+ if (failure.countAsCommandResultUnknown) {
121
+ commandResultUnknownCount++;
122
+ log.warn(`[daemon] Command result unknown after extension disconnect (id=${id}, action=${p.action}, context=${contextId})`);
123
+ }
124
+ p.reject(new DaemonCommandFailure(failure.message, failure.errorCode, failure.errorHint, failure.status));
114
125
  pending.delete(id);
115
126
  }
116
127
  }
@@ -142,16 +153,6 @@ function jsonResponse(res, status, data, extraHeaders) {
142
153
  res.writeHead(status, { 'Content-Type': 'application/json', ...extraHeaders });
143
154
  res.end(JSON.stringify(data));
144
155
  }
145
- export function getResponseCorsHeaders(pathname, origin) {
146
- if (pathname !== '/ping')
147
- return undefined;
148
- if (!origin || !origin.startsWith('chrome-extension://'))
149
- return undefined;
150
- return {
151
- 'Access-Control-Allow-Origin': origin,
152
- Vary: 'Origin',
153
- };
154
- }
155
156
  async function handleRequest(req, res) {
156
157
  // ─── Security: Origin & custom-header check ──────────────────────
157
158
  // Block browser-based CSRF: browsers always send an Origin header on
@@ -219,6 +220,7 @@ async function handleRequest(req, res) {
219
220
  profileDisconnected: route.errorCode === 'profile_disconnected',
220
221
  profiles,
221
222
  pending: pending.size,
223
+ commandResultUnknown: commandResultUnknownCount,
222
224
  memoryMB: Math.round(mem.rss / 1024 / 1024 * 10) / 10,
223
225
  port: PORT,
224
226
  });
@@ -277,8 +279,37 @@ async function handleRequest(req, res) {
277
279
  pending.delete(body.id);
278
280
  reject(new Error(`Command timeout (${timeoutMs / 1000}s)`));
279
281
  }, timeoutMs);
280
- pending.set(body.id, { contextId: route.connection.contextId, resolve, reject, timer });
281
- route.connection.ws.send(JSON.stringify(body));
282
+ const entry = {
283
+ contextId: route.connection.contextId,
284
+ action: typeof body.action === 'string' ? body.action : 'unknown',
285
+ dispatched: false,
286
+ resolve,
287
+ reject,
288
+ timer,
289
+ };
290
+ pending.set(body.id, entry);
291
+ const failBeforeDispatch = (err) => {
292
+ if (pending.get(body.id) !== entry)
293
+ return;
294
+ const failure = buildCommandDispatchFailure(entry.contextId);
295
+ clearTimeout(timer);
296
+ pending.delete(body.id);
297
+ reject(new DaemonCommandFailure(failure.message, failure.errorCode, failure.errorHint, failure.status));
298
+ log.warn(`[daemon] Failed to dispatch command ${body.id}: ${err instanceof Error ? err.message : String(err)}`);
299
+ };
300
+ try {
301
+ route.connection.ws.send(JSON.stringify(body), (err) => {
302
+ if (err && !entry.dispatched)
303
+ failBeforeDispatch(err);
304
+ });
305
+ // Once ws accepts the frame, the command may execute even if the
306
+ // result is later lost; do not downgrade later disconnects to a
307
+ // pre-dispatch failure just because no result/ack has arrived yet.
308
+ entry.dispatched = true;
309
+ }
310
+ catch (err) {
311
+ failBeforeDispatch(err);
312
+ }
282
313
  });
283
314
  jsonResponse(res, 200, result);
284
315
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { getResponseCorsHeaders } from './daemon.js';
2
+ import { COMMAND_RESULT_UNKNOWN_CODE, COMMAND_RESULT_UNKNOWN_HINT, buildCommandDispatchFailure, buildExtensionDisconnectFailure, commandResultUnknownMessage, getResponseCorsHeaders, } from './daemon-utils.js';
3
3
  describe('getResponseCorsHeaders', () => {
4
4
  it('allows the Browser Bridge extension origin to read /ping', () => {
5
5
  expect(getResponseCorsHeaders('/ping', 'chrome-extension://abc123')).toEqual({
@@ -17,3 +17,44 @@ describe('getResponseCorsHeaders', () => {
17
17
  expect(getResponseCorsHeaders('/command', 'chrome-extension://abc123')).toBeUndefined();
18
18
  });
19
19
  });
20
+ describe('daemon command dispatch', () => {
21
+ it('uses a distinct command_result_unknown contract for ambiguous dispatched commands', () => {
22
+ expect(COMMAND_RESULT_UNKNOWN_CODE).toBe('command_result_unknown');
23
+ expect(commandResultUnknownMessage('navigate')).toContain('navigate command was dispatched');
24
+ expect(COMMAND_RESULT_UNKNOWN_HINT).toContain('Inspect the browser/session state');
25
+ expect(COMMAND_RESULT_UNKNOWN_HINT).toContain('Do not blindly retry write commands');
26
+ });
27
+ it('classifies dispatched extension disconnects as command_result_unknown', () => {
28
+ expect(buildExtensionDisconnectFailure({
29
+ contextId: 'work',
30
+ action: 'navigate',
31
+ dispatched: true,
32
+ })).toEqual({
33
+ message: 'Browser connection dropped after the navigate command was dispatched; it may have completed.',
34
+ errorCode: 'command_result_unknown',
35
+ errorHint: COMMAND_RESULT_UNKNOWN_HINT,
36
+ status: 503,
37
+ countAsCommandResultUnknown: true,
38
+ });
39
+ });
40
+ it('classifies pre-dispatch extension disconnects as profile_disconnected', () => {
41
+ expect(buildExtensionDisconnectFailure({
42
+ contextId: 'work',
43
+ action: 'navigate',
44
+ dispatched: false,
45
+ })).toMatchObject({
46
+ message: 'Browser profile "work" disconnected before command dispatch',
47
+ errorCode: 'profile_disconnected',
48
+ status: 503,
49
+ countAsCommandResultUnknown: false,
50
+ });
51
+ });
52
+ it('classifies ws.send dispatch failures as profile_disconnected', () => {
53
+ expect(buildCommandDispatchFailure('work')).toMatchObject({
54
+ message: 'Browser profile "work" disconnected before command dispatch',
55
+ errorCode: 'profile_disconnected',
56
+ status: 503,
57
+ countAsCommandResultUnknown: false,
58
+ });
59
+ });
60
+ });
@@ -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
+ });
@@ -12,7 +12,6 @@ export const builtinApps = {
12
12
  cursor: { port: 9226, processName: 'Cursor', bundleId: 'com.todesktop.runtime.Cursor', displayName: 'Cursor' },
13
13
  codex: { port: 9222, processName: 'Codex', bundleId: 'com.openai.codex', displayName: 'Codex' },
14
14
  chatwise: { port: 9228, processName: 'ChatWise', bundleId: 'com.chatwise.app', displayName: 'ChatWise' },
15
- notion: { port: 9230, processName: 'Notion', bundleId: 'notion.id', displayName: 'Notion' },
16
15
  'discord-app': { port: 9232, processName: 'Discord', bundleId: 'com.discord.app', displayName: 'Discord' },
17
16
  'doubao-app': { port: 9225, processName: 'Doubao', bundleId: 'com.volcengine.doubao', displayName: 'Doubao' },
18
17
  antigravity: {
@@ -23,6 +23,7 @@ describe('electron-apps registry', () => {
23
23
  });
24
24
  it('isElectronApp returns false for non-Electron sites', () => {
25
25
  expect(isElectronApp('bilibili')).toBe(false);
26
+ expect(isElectronApp('notion')).toBe(false);
26
27
  expect(isElectronApp('unknown-app')).toBe(false);
27
28
  });
28
29
  it('loadApps merges user config additively', () => {
@@ -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 === '')