@jackwener/opencli 1.7.19 → 1.7.21

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 (88) hide show
  1. package/README.md +11 -9
  2. package/README.zh-CN.md +9 -10
  3. package/cli-manifest.json +239 -249
  4. package/clis/_shared/search-adapter.js +70 -0
  5. package/clis/boss/chatlist.js +96 -14
  6. package/clis/boss/chatlist.test.js +211 -0
  7. package/clis/boss/chatmsg.js +98 -24
  8. package/clis/boss/chatmsg.test.js +230 -0
  9. package/clis/boss/utils.js +240 -11
  10. package/clis/brave/search.js +80 -0
  11. package/clis/brave/search.test.js +76 -0
  12. package/clis/duckduckgo/search.js +131 -0
  13. package/clis/duckduckgo/search.test.js +128 -0
  14. package/clis/duckduckgo/suggest.js +45 -0
  15. package/clis/duckduckgo/suggest.test.js +66 -0
  16. package/clis/facebook/feed.js +301 -56
  17. package/clis/facebook/feed.test.js +169 -0
  18. package/clis/reddit/comment.js +0 -1
  19. package/clis/reddit/frontpage.js +0 -1
  20. package/clis/reddit/home.js +0 -1
  21. package/clis/reddit/popular.js +0 -1
  22. package/clis/reddit/read.js +0 -1
  23. package/clis/reddit/read.test.js +2 -2
  24. package/clis/reddit/save.js +0 -1
  25. package/clis/reddit/saved.js +0 -1
  26. package/clis/reddit/search.js +0 -1
  27. package/clis/reddit/subreddit-info.js +0 -1
  28. package/clis/reddit/subreddit.js +0 -1
  29. package/clis/reddit/subscribe.js +0 -1
  30. package/clis/reddit/upvote.js +0 -1
  31. package/clis/reddit/upvoted.js +0 -1
  32. package/clis/reddit/user-comments.js +0 -1
  33. package/clis/reddit/user-posts.js +0 -1
  34. package/clis/reddit/user.js +0 -1
  35. package/clis/reddit/whoami.js +0 -1
  36. package/clis/rednote/rednote.test.js +65 -0
  37. package/clis/rednote/search.js +11 -5
  38. package/clis/twitter/article.js +0 -1
  39. package/clis/twitter/bookmark-folder.js +5 -4
  40. package/clis/twitter/bookmark-folder.test.js +59 -1
  41. package/clis/twitter/bookmark-folders.js +0 -1
  42. package/clis/twitter/bookmarks.js +9 -4
  43. package/clis/twitter/bookmarks.test.js +205 -0
  44. package/clis/twitter/download.js +0 -1
  45. package/clis/twitter/followers.js +0 -1
  46. package/clis/twitter/following.js +0 -1
  47. package/clis/twitter/likes.js +0 -1
  48. package/clis/twitter/list-tweets.js +0 -1
  49. package/clis/twitter/lists.js +0 -1
  50. package/clis/twitter/notifications.js +0 -1
  51. package/clis/twitter/profile.js +0 -1
  52. package/clis/twitter/search.js +0 -1
  53. package/clis/twitter/thread.js +0 -1
  54. package/clis/twitter/timeline.js +0 -1
  55. package/clis/twitter/trending.js +0 -1
  56. package/clis/twitter/tweets.js +0 -1
  57. package/clis/xiaohongshu/search.js +34 -16
  58. package/clis/xiaohongshu/search.test.js +66 -11
  59. package/clis/yahoo/search.js +92 -0
  60. package/clis/yahoo/search.test.js +94 -0
  61. package/dist/src/browser/daemon-client.d.ts +1 -0
  62. package/dist/src/browser/daemon-client.js +3 -0
  63. package/dist/src/browser/daemon-client.test.js +20 -0
  64. package/dist/src/cli.js +8 -3
  65. package/dist/src/cli.test.js +1 -0
  66. package/dist/src/daemon-utils.d.ts +18 -0
  67. package/dist/src/daemon-utils.js +37 -0
  68. package/dist/src/daemon.d.ts +1 -1
  69. package/dist/src/daemon.js +44 -13
  70. package/dist/src/daemon.test.js +42 -1
  71. package/dist/src/electron-apps.js +0 -1
  72. package/dist/src/electron-apps.test.js +1 -0
  73. package/dist/src/external-clis.yaml +12 -3
  74. package/dist/src/external.d.ts +4 -0
  75. package/dist/src/external.js +3 -0
  76. package/dist/src/external.test.js +24 -1
  77. package/dist/src/help.d.ts +5 -1
  78. package/dist/src/help.js +4 -3
  79. package/dist/src/help.test.js +5 -1
  80. package/package.json +1 -1
  81. package/clis/notion/export.js +0 -32
  82. package/clis/notion/favorites.js +0 -85
  83. package/clis/notion/new.js +0 -35
  84. package/clis/notion/read.js +0 -31
  85. package/clis/notion/search.js +0 -47
  86. package/clis/notion/sidebar.js +0 -42
  87. package/clis/notion/status.js +0 -17
  88. package/clis/notion/write.js +0 -41
@@ -17,7 +17,6 @@ cli({
17
17
  domain: 'x.com',
18
18
  strategy: Strategy.COOKIE,
19
19
  browser: true,
20
- siteSession: 'persistent',
21
20
  args: [
22
21
  { name: 'limit', type: 'int', default: 20, help: 'Number of trends to show' },
23
22
  ],
@@ -221,7 +221,6 @@ cli({
221
221
  domain: 'x.com',
222
222
  strategy: Strategy.COOKIE,
223
223
  browser: true,
224
- siteSession: 'persistent',
225
224
  args: [
226
225
  { name: 'username', type: 'string', positional: true, help: 'Twitter screen name (with or without @). Defaults to the logged-in user when omitted.' },
227
226
  { name: 'limit', type: 'int', default: 20, help: 'Max tweets to return' },
@@ -6,7 +6,7 @@
6
6
  * Ref: https://github.com/jackwener/opencli/issues/10
7
7
  */
8
8
  import { cli, Strategy } from '@jackwener/opencli/registry';
9
- import { ArgumentError, AuthRequiredError } from '@jackwener/opencli/errors';
9
+ import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
10
10
  /**
11
11
  * Wait for search results or login wall using MutationObserver (max 5s).
12
12
  * Returns 'content' if note items appeared, 'login_wall' if login gate
@@ -60,6 +60,26 @@ export function stripXhsAuthorDateSuffix(value) {
60
60
  const stripped = text.replace(/\s*(?:\d{1,2}天前|\d+小时前|\d+分钟前|\d+秒前|刚刚|昨天|前天|\d+周前|\d+个月前|\d{1,2}-\d{1,2}|\d{4}-\d{1,2}-\d{1,2})$/u, '').trim();
61
61
  return stripped || text;
62
62
  }
63
+ /**
64
+ * `page.evaluate` may return either the raw IIFE value or a
65
+ * `{ session, data }` envelope depending on the browser-bridge version.
66
+ * Adapter code that called `Array.isArray(payload)` directly on the
67
+ * envelope silently received [] for every search. This helper normalizes
68
+ * both shapes so callers can keep their Array.isArray checks unchanged.
69
+ */
70
+ export function unwrapEvaluateResult(payload) {
71
+ if (payload && !Array.isArray(payload) && typeof payload === 'object' && 'session' in payload && 'data' in payload) {
72
+ return payload.data;
73
+ }
74
+ return payload;
75
+ }
76
+ function requireSearchRows(payload, phase) {
77
+ const rows = unwrapEvaluateResult(payload);
78
+ if (!Array.isArray(rows)) {
79
+ throw new CommandExecutionError(`Unexpected Xiaohongshu search ${phase} payload shape; expected an array of rows.`);
80
+ }
81
+ return rows;
82
+ }
63
83
  export function parseLimit(raw) {
64
84
  const parsed = Number(raw ?? 20);
65
85
  if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
@@ -267,7 +287,7 @@ export const command = cli({
267
287
  // Wait for search results to render (or login wall to appear).
268
288
  // Uses MutationObserver to resolve as soon as content appears,
269
289
  // instead of a fixed delay + blind retry.
270
- const waitResult = await page.evaluate(WAIT_FOR_CONTENT_JS);
290
+ const waitResult = unwrapEvaluateResult(await page.evaluate(WAIT_FOR_CONTENT_JS));
271
291
  if (waitResult === 'login_wall') {
272
292
  throw new AuthRequiredError('www.xiaohongshu.com', 'Xiaohongshu search results are blocked behind a login wall');
273
293
  }
@@ -275,25 +295,23 @@ export const command = cli({
275
295
  // layout, so scrolling to the bottom can evict the initially visible
276
296
  // note cards from the DOM and make extraction return [] even though the
277
297
  // browser rendered results correctly.
278
- const initialPayload = await page.evaluate(buildSearchExtractJs('www.xiaohongshu.com'));
279
- let payload = Array.isArray(initialPayload) ? initialPayload : [];
298
+ const initialPayload = requireSearchRows(await page.evaluate(buildSearchExtractJs('www.xiaohongshu.com')), 'initial extraction');
299
+ const payload = [...initialPayload];
280
300
  if (payload.length < limit) {
281
301
  // Scroll until enough rows are rendered or the lazy-load plateaus.
282
302
  // Replaces the previous fixed `autoScroll({ times: 2 })` which capped
283
303
  // extraction at ~13 notes regardless of `--limit` (#1471).
284
304
  await page.evaluate(buildScrollUntilJs(limit));
285
- const scrolledPayload = await page.evaluate(buildSearchExtractJs('www.xiaohongshu.com'));
286
- if (Array.isArray(scrolledPayload)) {
287
- const seen = new Set(payload.map((item) => item.url).filter(Boolean));
288
- for (const item of scrolledPayload) {
289
- if (item?.url && seen.has(item.url))
290
- continue;
291
- if (item?.url)
292
- seen.add(item.url);
293
- payload.push(item);
294
- if (payload.length >= limit)
295
- break;
296
- }
305
+ const scrolledPayload = requireSearchRows(await page.evaluate(buildSearchExtractJs('www.xiaohongshu.com')), 'post-scroll extraction');
306
+ const seen = new Set(payload.map((item) => item.url).filter(Boolean));
307
+ for (const item of scrolledPayload) {
308
+ if (item?.url && seen.has(item.url))
309
+ continue;
310
+ if (item?.url)
311
+ seen.add(item.url);
312
+ payload.push(item);
313
+ if (payload.length >= limit)
314
+ break;
297
315
  }
298
316
  }
299
317
  const data = payload;
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { getRegistry } from '@jackwener/opencli/registry';
3
3
  import { JSDOM } from 'jsdom';
4
- import { __test__, buildScrollUntilJs, noteIdToDate } from './search.js';
4
+ import { __test__, buildScrollUntilJs, noteIdToDate, unwrapEvaluateResult } from './search.js';
5
5
 
6
6
  function markVisible(el) {
7
7
  el.getBoundingClientRect = () => ({ width: 100, height: 100 });
@@ -57,24 +57,37 @@ describe('xiaohongshu search', () => {
57
57
  expect(page.evaluate).toHaveBeenCalledTimes(1);
58
58
  expect(page.autoScroll).not.toHaveBeenCalled();
59
59
  });
60
+ it('unwraps a browser-bridge envelope before handling login-wall wait result', async () => {
61
+ const cmd = getRegistry().get('xiaohongshu/search');
62
+ const page = createPageMock([
63
+ { session: 'site:xiaohongshu', data: 'login_wall' },
64
+ ]);
65
+
66
+ await expect(cmd.func(page, { query: '特斯拉', limit: 5 })).rejects.toMatchObject({
67
+ code: 'AUTH_REQUIRED',
68
+ message: expect.stringContaining('blocked behind a login wall'),
69
+ });
70
+ expect(page.evaluate).toHaveBeenCalledTimes(1);
71
+ });
60
72
  it('returns ranked results with search_result url and author_url preserved', async () => {
61
73
  const cmd = getRegistry().get('xiaohongshu/search');
62
74
  expect(cmd?.func).toBeTypeOf('function');
63
75
  const detailUrl = 'https://www.xiaohongshu.com/search_result/68e90be80000000004022e66?xsec_token=test-token&xsec_source=';
64
76
  const authorUrl = 'https://www.xiaohongshu.com/user/profile/635a9c720000000018028b40?xsec_token=user-token&xsec_source=pc_search';
77
+ const rows = [
78
+ {
79
+ title: '某鱼买FSD被坑了4万',
80
+ author: '随风',
81
+ likes: '261',
82
+ url: detailUrl,
83
+ author_url: authorUrl,
84
+ },
85
+ ];
65
86
  const page = createPageMock([
66
87
  // First evaluate: MutationObserver wait (content appeared)
67
88
  'content',
68
- // Second evaluate: initial DOM extraction (already enough results)
69
- [
70
- {
71
- title: '某鱼买FSD被坑了4万',
72
- author: '随风',
73
- likes: '261',
74
- url: detailUrl,
75
- author_url: authorUrl,
76
- },
77
- ],
89
+ // Second evaluate: initial DOM extraction (already enough results) through Browser Bridge envelope.
90
+ { session: 'site:xiaohongshu', data: rows },
78
91
  ]);
79
92
  const result = await cmd.func(page, { query: '特斯拉', limit: 1 });
80
93
  // Should only do one goto (the search page itself), no per-note detail navigation
@@ -91,6 +104,18 @@ describe('xiaohongshu search', () => {
91
104
  },
92
105
  ]);
93
106
  });
107
+ it('fails typed instead of silently returning [] for malformed extraction payloads', async () => {
108
+ const cmd = getRegistry().get('xiaohongshu/search');
109
+ const page = createPageMock([
110
+ 'content',
111
+ { session: 'site:xiaohongshu', data: { rows: [] } },
112
+ ]);
113
+
114
+ await expect(cmd.func(page, { query: '测试', limit: 1 })).rejects.toMatchObject({
115
+ code: 'COMMAND_EXEC',
116
+ message: expect.stringContaining('payload shape'),
117
+ });
118
+ });
94
119
  it('filters out results with no title and respects the limit', async () => {
95
120
  const cmd = getRegistry().get('xiaohongshu/search');
96
121
  expect(cmd?.func).toBeTypeOf('function');
@@ -135,6 +160,10 @@ describe('xiaohongshu search', () => {
135
160
  'content',
136
161
  // Second evaluate: initial extraction (no rows rendered)
137
162
  [],
163
+ // Third evaluate: scroll-until row count
164
+ 0,
165
+ // Fourth evaluate: post-scroll extraction (still no rows)
166
+ [],
138
167
  ]);
139
168
  const result = (await cmd.func(page, { query: '测试等待', limit: 5 }));
140
169
  expect(result).toHaveLength(0);
@@ -268,3 +297,29 @@ describe('noteIdToDate (ObjectID timestamp parsing)', () => {
268
297
  expect(noteIdToDate('https://www.xiaohongshu.com/search_result/000000000000000000000000')).toBe('');
269
298
  });
270
299
  });
300
+ describe('unwrapEvaluateResult (browser-bridge envelope normalization)', () => {
301
+ it('returns the raw array unchanged when payload is already an array', () => {
302
+ const arr = [{ title: 'a' }, { title: 'b' }];
303
+ expect(unwrapEvaluateResult(arr)).toBe(arr);
304
+ });
305
+ it('unwraps { session, data: [...] } envelope to the inner array', () => {
306
+ const arr = [{ title: 'a' }];
307
+ const env = { session: 'site:xiaohongshu:abc', data: arr };
308
+ expect(unwrapEvaluateResult(env)).toBe(arr);
309
+ });
310
+ it('unwraps primitive data from Browser Bridge envelopes', () => {
311
+ expect(unwrapEvaluateResult({ session: 'site:xiaohongshu:abc', data: 'login_wall' })).toBe('login_wall');
312
+ });
313
+ it('passes non-envelope objects through unchanged', () => {
314
+ const obj = { results: [], loginWall: true };
315
+ expect(unwrapEvaluateResult(obj)).toBe(obj);
316
+ });
317
+ it('handles null and undefined safely', () => {
318
+ expect(unwrapEvaluateResult(null)).toBe(null);
319
+ expect(unwrapEvaluateResult(undefined)).toBe(undefined);
320
+ });
321
+ it('unwraps non-array envelope data so callers can validate the payload shape', () => {
322
+ const env = { session: 'x', data: { not: 'an array' } };
323
+ expect(unwrapEvaluateResult(env)).toEqual({ not: 'an array' });
324
+ });
325
+ });
@@ -0,0 +1,92 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import {
3
+ emptySearchResults,
4
+ requireBoundedInteger,
5
+ requireRows,
6
+ requireSearchQuery,
7
+ runBrowserStep,
8
+ toHttpsUrl,
9
+ } from '../_shared/search-adapter.js';
10
+
11
+ function decodeYahooUrl(href) {
12
+ if (!href) return '';
13
+ if (href.indexOf('RU=') !== -1 && href.indexOf('/RK=') !== -1) {
14
+ var match = href.match(/RU=([^/]+)\/RK=/);
15
+ if (match && match[1]) {
16
+ try {
17
+ return toHttpsUrl(decodeURIComponent(match[1]), 'https://search.yahoo.com');
18
+ } catch {
19
+ return toHttpsUrl(href, 'https://search.yahoo.com');
20
+ }
21
+ }
22
+ }
23
+ return toHttpsUrl(href, 'https://search.yahoo.com');
24
+ }
25
+
26
+ function buildExtractorJs(limit) {
27
+ return `
28
+ (function() {
29
+ var results = [];
30
+ var seen = {};
31
+ var items = document.querySelectorAll('.algo');
32
+ for (var i = 0; i < items.length; i++) {
33
+ if (results.length >= ${limit}) break;
34
+ var el = items[i];
35
+ var h3 = el.querySelector('h3');
36
+ var linkEl = el.querySelector('.compTitle a');
37
+ var snippetEl = el.querySelector('.compText');
38
+ if (!h3 || !linkEl) continue;
39
+ var title = h3.textContent.trim();
40
+ var href = linkEl.getAttribute('href') || '';
41
+ var snippet = snippetEl ? snippetEl.textContent.trim() : '';
42
+ if (!title || !href || seen[href]) continue;
43
+ seen[href] = true;
44
+ results.push([title, href, snippet]);
45
+ }
46
+ return results;
47
+ })()`;
48
+ }
49
+
50
+ const command = cli({
51
+ site: 'yahoo',
52
+ name: 'search',
53
+ access: 'read',
54
+ description: 'Search Yahoo (powered by Bing)',
55
+ domain: 'search.yahoo.com',
56
+ strategy: Strategy.PUBLIC,
57
+ browser: true,
58
+ args: [
59
+ { name: 'keyword', positional: true, required: true, help: 'Search query' },
60
+ { name: 'limit', type: 'int', default: 7, help: 'Number of results per page (max 7)' },
61
+ { name: 'page', type: 'int', default: 1, help: 'Page number (1, 2, 3...). Yahoo returns ~7 results per page' },
62
+ ],
63
+ columns: ['rank', 'title', 'url', 'snippet'],
64
+ func: async (page, kwargs) => {
65
+ const limit = requireBoundedInteger(kwargs.limit, 7, 1, 7, '--limit');
66
+ const query = requireSearchQuery(kwargs.keyword);
67
+ const keyword = encodeURIComponent(query);
68
+ const pageNum = requireBoundedInteger(kwargs.page, 1, 1, 100, '--page');
69
+ var url = `https://search.yahoo.com/search?p=${keyword}`;
70
+ if (pageNum > 1) url += `&b=${(pageNum - 1) * 7 + 1}`;
71
+ await runBrowserStep('yahoo search navigation', () => page.goto(url));
72
+ try {
73
+ await page.wait({ selector: '.algo', timeout: 10 });
74
+ } catch {
75
+ await page.wait(3).catch(function() {});
76
+ }
77
+ const raw = await runBrowserStep('yahoo search extraction', () => page.evaluate(buildExtractorJs(limit)));
78
+ const results = requireRows(raw, 'yahoo search');
79
+ if (results.length === 0) {
80
+ throw emptySearchResults('Yahoo', query);
81
+ }
82
+ const rows = results
83
+ .map(function(r, index) {
84
+ return { rank: index + 1 + (pageNum - 1) * 7, title: r[0], url: decodeYahooUrl(r[1]), snippet: r[2] };
85
+ })
86
+ .filter((row) => row.url);
87
+ if (rows.length === 0) throw emptySearchResults('Yahoo', query);
88
+ return rows;
89
+ },
90
+ });
91
+
92
+ export const __test__ = { command };
@@ -0,0 +1,94 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+
3
+ const { __test__ } = await import('./search.js');
4
+ const command = __test__.command;
5
+
6
+ function createPageMock(evaluateResult = []) {
7
+ return {
8
+ goto: vi.fn().mockResolvedValue(undefined),
9
+ wait: vi.fn().mockResolvedValue(undefined),
10
+ evaluate: vi.fn().mockResolvedValue(evaluateResult),
11
+ };
12
+ }
13
+
14
+ describe('yahoo search', () => {
15
+ it('should register as a valid command', () => {
16
+ expect(command).toBeDefined();
17
+ expect(command.site).toBe('yahoo');
18
+ expect(command.name).toBe('search');
19
+ expect(command.access).toBe('read');
20
+ expect(command.browser).toBe(true);
21
+ expect(command.strategy).toBe('public');
22
+ expect(command.domain).toBe('search.yahoo.com');
23
+ });
24
+
25
+ it('should define keyword positional arg', () => {
26
+ const kwArg = command.args.find(a => a.name === 'keyword');
27
+ expect(kwArg).toBeDefined();
28
+ expect(kwArg.positional).toBe(true);
29
+ expect(kwArg.required).toBe(true);
30
+ });
31
+
32
+ it('should define limit arg with default 7', () => {
33
+ const limitArg = command.args.find(a => a.name === 'limit');
34
+ expect(limitArg).toBeDefined();
35
+ expect(limitArg.type).toBe('int');
36
+ expect(limitArg.default).toBe(7);
37
+ });
38
+
39
+ it('should define output columns', () => {
40
+ expect(command.columns).toContain('rank');
41
+ expect(command.columns).toContain('title');
42
+ expect(command.columns).toContain('url');
43
+ expect(command.columns).toContain('snippet');
44
+ });
45
+
46
+ it('rejects empty query, invalid limit, and invalid page before navigation', async () => {
47
+ const page = createPageMock();
48
+ await expect(command.func(page, { keyword: ' ', limit: 5 })).rejects.toMatchObject({ code: 'ARGUMENT' });
49
+ await expect(command.func(page, { keyword: 'opencli', limit: 8 })).rejects.toMatchObject({ code: 'ARGUMENT' });
50
+ await expect(command.func(page, { keyword: 'opencli', limit: 5, page: 0 })).rejects.toMatchObject({ code: 'ARGUMENT' });
51
+ expect(page.goto).not.toHaveBeenCalled();
52
+ });
53
+
54
+ it('decodes Yahoo redirect URLs and assigns listing rank', async () => {
55
+ const page = createPageMock({
56
+ session: 'site:yahoo',
57
+ data: [[
58
+ 'OpenCLI',
59
+ 'https://r.search.yahoo.com/_ylt=x/RU=https%3A%2F%2Fgithub.com%2Fjackwener%2FOpenCLI/RK=2/RS=x',
60
+ 'CLI browser tooling',
61
+ ]],
62
+ });
63
+
64
+ await expect(command.func(page, { keyword: 'opencli', limit: 1, page: 2 })).resolves.toEqual([{
65
+ rank: 8,
66
+ title: 'OpenCLI',
67
+ url: 'https://github.com/jackwener/OpenCLI',
68
+ snippet: 'CLI browser tooling',
69
+ }]);
70
+ });
71
+
72
+ it('drops decoded Yahoo redirect targets that are not http(s) URLs', async () => {
73
+ const page = createPageMock([
74
+ [
75
+ 'Bad redirect',
76
+ 'https://r.search.yahoo.com/_ylt=x/RU=javascript%3Aalert(1)/RK=2/RS=x',
77
+ 'should not be emitted',
78
+ ],
79
+ ]);
80
+
81
+ await expect(command.func(page, { keyword: 'opencli', limit: 1 })).rejects.toMatchObject({
82
+ code: 'EMPTY_RESULT',
83
+ });
84
+ });
85
+
86
+ it('fails typed instead of silently returning [] for malformed extraction payloads', async () => {
87
+ const page = createPageMock({ rows: [] });
88
+
89
+ await expect(command.func(page, { keyword: 'opencli', limit: 1 })).rejects.toMatchObject({
90
+ code: 'COMMAND_EXEC',
91
+ message: expect.stringContaining('payload shape'),
92
+ });
93
+ });
94
+ });
@@ -73,6 +73,7 @@ export interface DaemonStatus {
73
73
  profileDisconnected?: boolean;
74
74
  profiles?: BrowserProfileStatus[];
75
75
  pending: number;
76
+ commandResultUnknown?: number;
76
77
  memoryMB: number;
77
78
  port: number;
78
79
  }
@@ -105,6 +105,9 @@ async function sendCommandRaw(action, params) {
105
105
  });
106
106
  const result = (await res.json());
107
107
  if (!result.ok) {
108
+ if (result.errorCode === 'command_result_unknown') {
109
+ throw new BrowserCommandError(result.error ?? 'Browser command result is unknown', result.errorCode, result.errorHint);
110
+ }
108
111
  const isDuplicateCommandId = res.status === 409
109
112
  || (result.error ?? '').includes('Duplicate command id');
110
113
  if (isDuplicateCommandId && attempt < maxRetries) {
@@ -176,4 +176,24 @@ describe('daemon-client', () => {
176
176
  });
177
177
  expect(ids[0]).not.toBe(ids[1]);
178
178
  });
179
+ it('sendCommand does not retry command_result_unknown even when the message looks transient', async () => {
180
+ const fetchMock = vi.mocked(fetch);
181
+ fetchMock.mockResolvedValue({
182
+ ok: false,
183
+ status: 503,
184
+ json: () => Promise.resolve({
185
+ id: 'server',
186
+ ok: false,
187
+ errorCode: 'command_result_unknown',
188
+ error: 'Extension disconnected after command timeout',
189
+ errorHint: 'Inspect state before retrying.',
190
+ }),
191
+ });
192
+ await expect(sendCommand('exec', { code: 'window.__mutate = true' })).rejects.toMatchObject({
193
+ name: 'BrowserCommandError',
194
+ code: 'command_result_unknown',
195
+ hint: 'Inspect state before retrying.',
196
+ });
197
+ expect(fetchMock).toHaveBeenCalledTimes(1);
198
+ });
179
199
  });
package/dist/src/cli.js CHANGED
@@ -15,7 +15,7 @@ import { serializeCommand, formatArgSummary } from './serialization.js';
15
15
  import { render as renderOutput } from './output.js';
16
16
  import { PKG_VERSION } from './version.js';
17
17
  import { printCompletionScript } from './completion.js';
18
- import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled } from './external.js';
18
+ import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled, formatExternalCliLabel } from './external.js';
19
19
  import { registerAllCommands } from './commanderAdapter.js';
20
20
  import { classifyAdapter, formatRootAdapterHelpText, installCommanderNamespaceStructuredHelp, installStructuredHelp, leadingPositionalFromUsage, rootHelpData } from './help.js';
21
21
  import { EXIT_CODES, getErrorMessage, BrowserConnectError } from './errors.js';
@@ -3005,6 +3005,7 @@ cli({
3005
3005
  .action((opts) => {
3006
3006
  const rows = loadExternalClis().map((ext) => ({
3007
3007
  name: ext.name,
3008
+ package: ext.package ?? '',
3008
3009
  binary: ext.binary,
3009
3010
  installed: isBinaryInstalled(ext.binary),
3010
3011
  description: ext.description ?? '',
@@ -3013,7 +3014,7 @@ cli({
3013
3014
  }));
3014
3015
  renderOutput(rows, {
3015
3016
  fmt: opts.format,
3016
- columns: ['name', 'binary', 'installed', 'description', 'homepage', 'tags'],
3017
+ columns: ['name', 'package', 'binary', 'installed', 'description', 'homepage', 'tags'],
3017
3018
  title: 'opencli/external/list',
3018
3019
  source: 'opencli external list',
3019
3020
  });
@@ -3067,6 +3068,10 @@ cli({
3067
3068
  // Classification derives from each adapter's `domain` field — see classifyAdapter.
3068
3069
  // External CLIs are taken from the externalClis registry (passthrough binaries).
3069
3070
  const externalNames = externalClis.map(ext => ext.name);
3071
+ const externalHelpEntries = externalClis.map(ext => ({
3072
+ name: ext.name,
3073
+ label: formatExternalCliLabel(ext),
3074
+ }));
3070
3075
  const siteDomains = new Map();
3071
3076
  for (const [, cmd] of getRegistry()) {
3072
3077
  if (!siteDomains.has(cmd.site))
@@ -3080,7 +3085,7 @@ cli({
3080
3085
  else
3081
3086
  sites.push(site);
3082
3087
  }
3083
- const adapterGroups = { external: externalNames, apps, sites };
3088
+ const adapterGroups = { external: externalHelpEntries, apps, sites };
3084
3089
  const adapterNameSet = new Set([...externalNames, ...siteNames]);
3085
3090
  installCommanderNamespaceStructuredHelp(browser, { globalCommand: program, description: originalBrowserDescription });
3086
3091
  installCommanderNamespaceStructuredHelp(daemonCmd, { globalCommand: program, description: originalDaemonDescription });
@@ -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');
@@ -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
  }