@jackwener/opencli 1.5.2 → 1.5.4

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 (135) hide show
  1. package/.agents/skills/cross-project-adapter-migration/SKILL.md +3 -3
  2. package/.github/workflows/ci.yml +6 -7
  3. package/README.md +89 -235
  4. package/dist/browser/cdp.js +20 -1
  5. package/dist/browser/daemon-client.js +3 -2
  6. package/dist/browser/dom-helpers.d.ts +11 -0
  7. package/dist/browser/dom-helpers.js +42 -0
  8. package/dist/browser/dom-helpers.test.d.ts +1 -0
  9. package/dist/browser/dom-helpers.test.js +92 -0
  10. package/dist/browser/index.d.ts +0 -12
  11. package/dist/browser/index.js +0 -13
  12. package/dist/browser/mcp.js +4 -3
  13. package/dist/browser/page.d.ts +1 -0
  14. package/dist/browser/page.js +14 -1
  15. package/dist/browser.test.js +15 -11
  16. package/dist/build-manifest.d.ts +2 -3
  17. package/dist/build-manifest.js +75 -170
  18. package/dist/build-manifest.test.js +113 -88
  19. package/dist/cli-manifest.json +1199 -1106
  20. package/dist/clis/36kr/hot.js +1 -1
  21. package/dist/clis/36kr/search.js +1 -1
  22. package/dist/clis/_shared/common.d.ts +8 -0
  23. package/dist/clis/_shared/common.js +10 -0
  24. package/dist/clis/bloomberg/news.js +1 -1
  25. package/dist/clis/douban/utils.js +3 -6
  26. package/dist/clis/medium/utils.js +1 -1
  27. package/dist/clis/producthunt/browse.js +1 -1
  28. package/dist/clis/producthunt/hot.js +1 -1
  29. package/dist/clis/sinablog/utils.js +6 -7
  30. package/dist/clis/substack/utils.js +2 -2
  31. package/dist/clis/twitter/block.js +1 -1
  32. package/dist/clis/twitter/bookmark.js +1 -1
  33. package/dist/clis/twitter/delete.js +1 -1
  34. package/dist/clis/twitter/follow.js +1 -1
  35. package/dist/clis/twitter/followers.js +2 -2
  36. package/dist/clis/twitter/following.js +2 -2
  37. package/dist/clis/twitter/hide-reply.js +1 -1
  38. package/dist/clis/twitter/like.js +1 -1
  39. package/dist/clis/twitter/notifications.js +1 -1
  40. package/dist/clis/twitter/profile.js +1 -1
  41. package/dist/clis/twitter/reply-dm.js +1 -1
  42. package/dist/clis/twitter/reply.js +1 -1
  43. package/dist/clis/twitter/search.js +1 -1
  44. package/dist/clis/twitter/unblock.js +1 -1
  45. package/dist/clis/twitter/unbookmark.js +1 -1
  46. package/dist/clis/twitter/unfollow.js +1 -1
  47. package/dist/clis/xiaohongshu/comments.test.js +1 -0
  48. package/dist/clis/xiaohongshu/creator-note-detail.test.js +1 -0
  49. package/dist/clis/xiaohongshu/creator-notes.test.js +1 -0
  50. package/dist/clis/xiaohongshu/publish.test.js +1 -0
  51. package/dist/clis/xiaohongshu/search.test.js +1 -0
  52. package/dist/daemon.js +14 -3
  53. package/dist/download/index.js +39 -33
  54. package/dist/download/index.test.js +15 -1
  55. package/dist/execution.js +3 -2
  56. package/dist/external-clis.yaml +16 -0
  57. package/dist/main.js +2 -0
  58. package/dist/node-network.d.ts +10 -0
  59. package/dist/node-network.js +174 -0
  60. package/dist/node-network.test.d.ts +1 -0
  61. package/dist/node-network.test.js +55 -0
  62. package/dist/pipeline/executor.test.js +1 -0
  63. package/dist/pipeline/steps/download.test.js +1 -0
  64. package/dist/pipeline/steps/intercept.js +4 -5
  65. package/dist/serialization.js +6 -1
  66. package/dist/serialization.test.d.ts +1 -0
  67. package/dist/serialization.test.js +23 -0
  68. package/dist/types.d.ts +2 -0
  69. package/dist/utils.d.ts +2 -0
  70. package/dist/utils.js +4 -0
  71. package/docs/superpowers/plans/2026-03-28-perf-smart-wait.md +1143 -0
  72. package/docs/superpowers/specs/2026-03-28-perf-smart-wait-design.md +170 -0
  73. package/extension/dist/background.js +12 -5
  74. package/extension/manifest.json +2 -2
  75. package/extension/package-lock.json +2 -2
  76. package/extension/package.json +1 -1
  77. package/extension/src/background.ts +20 -6
  78. package/extension/src/protocol.ts +2 -1
  79. package/package.json +2 -1
  80. package/src/browser/cdp.ts +21 -0
  81. package/src/browser/daemon-client.ts +3 -2
  82. package/src/browser/dom-helpers.test.ts +100 -0
  83. package/src/browser/dom-helpers.ts +44 -0
  84. package/src/browser/index.ts +0 -15
  85. package/src/browser/mcp.ts +4 -3
  86. package/src/browser/page.ts +16 -0
  87. package/src/browser.test.ts +16 -12
  88. package/src/build-manifest.test.ts +117 -88
  89. package/src/build-manifest.ts +81 -180
  90. package/src/clis/36kr/hot.ts +1 -1
  91. package/src/clis/36kr/search.ts +1 -1
  92. package/src/clis/_shared/common.ts +11 -0
  93. package/src/clis/bloomberg/news.ts +1 -1
  94. package/src/clis/douban/utils.ts +3 -7
  95. package/src/clis/medium/utils.ts +1 -1
  96. package/src/clis/producthunt/browse.ts +1 -1
  97. package/src/clis/producthunt/hot.ts +1 -1
  98. package/src/clis/sinablog/utils.ts +6 -7
  99. package/src/clis/substack/utils.ts +2 -2
  100. package/src/clis/twitter/block.ts +1 -1
  101. package/src/clis/twitter/bookmark.ts +1 -1
  102. package/src/clis/twitter/delete.ts +1 -1
  103. package/src/clis/twitter/follow.ts +1 -1
  104. package/src/clis/twitter/followers.ts +2 -2
  105. package/src/clis/twitter/following.ts +2 -2
  106. package/src/clis/twitter/hide-reply.ts +1 -1
  107. package/src/clis/twitter/like.ts +1 -1
  108. package/src/clis/twitter/notifications.ts +1 -1
  109. package/src/clis/twitter/profile.ts +1 -1
  110. package/src/clis/twitter/reply-dm.ts +1 -1
  111. package/src/clis/twitter/reply.ts +1 -1
  112. package/src/clis/twitter/search.ts +1 -1
  113. package/src/clis/twitter/unblock.ts +1 -1
  114. package/src/clis/twitter/unbookmark.ts +1 -1
  115. package/src/clis/twitter/unfollow.ts +1 -1
  116. package/src/clis/xiaohongshu/comments.test.ts +1 -0
  117. package/src/clis/xiaohongshu/creator-note-detail.test.ts +1 -0
  118. package/src/clis/xiaohongshu/creator-notes.test.ts +1 -0
  119. package/src/clis/xiaohongshu/publish.test.ts +1 -0
  120. package/src/clis/xiaohongshu/search.test.ts +1 -0
  121. package/src/daemon.ts +16 -4
  122. package/src/download/index.test.ts +19 -1
  123. package/src/download/index.ts +50 -41
  124. package/src/execution.ts +3 -2
  125. package/src/external-clis.yaml +16 -0
  126. package/src/main.ts +3 -0
  127. package/src/node-network.test.ts +93 -0
  128. package/src/node-network.ts +213 -0
  129. package/src/pipeline/executor.test.ts +1 -0
  130. package/src/pipeline/steps/download.test.ts +1 -0
  131. package/src/pipeline/steps/intercept.ts +4 -5
  132. package/src/serialization.test.ts +26 -0
  133. package/src/serialization.ts +6 -1
  134. package/src/types.ts +2 -0
  135. package/src/utils.ts +5 -0
@@ -1,10 +1,14 @@
1
- import { afterEach, describe, it, expect, vi } from 'vitest';
2
- import { BrowserBridge, __test__, generateStealthJs } from './browser/index.js';
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { BrowserBridge, generateStealthJs } from './browser/index.js';
3
+ import { extractTabEntries, diffTabIndexes, appendLimited } from './browser/tabs.js';
4
+ import { withTimeoutMs } from './runtime.js';
5
+ import { __test__ as cdpTest } from './browser/cdp.js';
6
+ import { isRetryableSettleError } from './browser/page.js';
3
7
  import * as daemonClient from './browser/daemon-client.js';
4
8
 
5
9
  describe('browser helpers', () => {
6
10
  it('extracts tab entries from string snapshots', () => {
7
- const entries = __test__.extractTabEntries('Tab 0 https://example.com\nTab 1 Chrome Extension');
11
+ const entries = extractTabEntries('Tab 0 https://example.com\nTab 1 Chrome Extension');
8
12
 
9
13
  expect(entries).toEqual([
10
14
  { index: 0, identity: 'https://example.com' },
@@ -13,7 +17,7 @@ describe('browser helpers', () => {
13
17
  });
14
18
 
15
19
  it('extracts tab entries from MCP markdown format', () => {
16
- const entries = __test__.extractTabEntries(
20
+ const entries = extractTabEntries(
17
21
  '- 0: (current) [Playwright MCP extension](chrome-extension://abc/connect.html)\n- 1: [知乎 - 首页](https://www.zhihu.com/)'
18
22
  );
19
23
 
@@ -24,7 +28,7 @@ describe('browser helpers', () => {
24
28
  });
25
29
 
26
30
  it('closes only tabs that were opened during the session', () => {
27
- const tabsToClose = __test__.diffTabIndexes(
31
+ const tabsToClose = diffTabIndexes(
28
32
  ['https://example.com', 'Chrome Extension'],
29
33
  [
30
34
  { index: 0, identity: 'https://example.com' },
@@ -38,21 +42,21 @@ describe('browser helpers', () => {
38
42
  });
39
43
 
40
44
  it('keeps only the tail of stderr buffers', () => {
41
- expect(__test__.appendLimited('12345', '67890', 8)).toBe('34567890');
45
+ expect(appendLimited('12345', '67890', 8)).toBe('34567890');
42
46
  });
43
47
 
44
48
  it('times out slow promises', async () => {
45
- await expect(__test__.withTimeoutMs(new Promise(() => {}), 10, 'timeout')).rejects.toThrow('timeout');
49
+ await expect(withTimeoutMs(new Promise(() => {}), 10, 'timeout')).rejects.toThrow('timeout');
46
50
  });
47
51
 
48
52
  it('retries settle only for target-invalidated errors', () => {
49
- expect(__test__.isRetryableSettleError(new Error('{"code":-32000,"message":"Inspected target navigated or closed"}'))).toBe(true);
50
- expect(__test__.isRetryableSettleError(new Error('attach failed: target no longer exists'))).toBe(false);
51
- expect(__test__.isRetryableSettleError(new Error('malformed exec payload'))).toBe(false);
53
+ expect(isRetryableSettleError(new Error('{"code":-32000,"message":"Inspected target navigated or closed"}'))).toBe(true);
54
+ expect(isRetryableSettleError(new Error('attach failed: target no longer exists'))).toBe(false);
55
+ expect(isRetryableSettleError(new Error('malformed exec payload'))).toBe(false);
52
56
  });
53
57
 
54
58
  it('prefers the real Electron app target over DevTools and blank pages', () => {
55
- const target = __test__.selectCDPTarget([
59
+ const target = cdpTest.selectCDPTarget([
56
60
  {
57
61
  type: 'page',
58
62
  title: 'DevTools - localhost:9224',
@@ -79,7 +83,7 @@ describe('browser helpers', () => {
79
83
  it('honors OPENCLI_CDP_TARGET when multiple inspectable targets exist', () => {
80
84
  vi.stubEnv('OPENCLI_CDP_TARGET', 'codex');
81
85
 
82
- const target = __test__.selectCDPTarget([
86
+ const target = cdpTest.selectCDPTarget([
83
87
  {
84
88
  type: 'app',
85
89
  title: 'Cursor',
@@ -2,69 +2,8 @@ import { afterEach, describe, expect, it } from 'vitest';
2
2
  import * as fs from 'node:fs';
3
3
  import * as os from 'node:os';
4
4
  import * as path from 'node:path';
5
- import { parseTsArgsBlock, scanTs, shouldReplaceManifestEntry } from './build-manifest.js';
6
-
7
- describe('parseTsArgsBlock', () => {
8
- it('keeps args with nested choices arrays', () => {
9
- const args = parseTsArgsBlock(`
10
- {
11
- name: 'period',
12
- type: 'string',
13
- default: 'seven',
14
- help: 'Stats period: seven or thirty',
15
- choices: ['seven', 'thirty'],
16
- },
17
- `);
18
-
19
- expect(args).toEqual([
20
- {
21
- name: 'period',
22
- type: 'string',
23
- default: 'seven',
24
- required: false,
25
- positional: undefined,
26
- help: 'Stats period: seven or thirty',
27
- choices: ['seven', 'thirty'],
28
- },
29
- ]);
30
- });
31
-
32
- it('keeps hyphenated arg names from TS adapters', () => {
33
- const args = parseTsArgsBlock(`
34
- {
35
- name: 'tweet-url',
36
- help: 'Single tweet URL to download',
37
- },
38
- {
39
- name: 'download-images',
40
- type: 'boolean',
41
- default: false,
42
- help: 'Download images locally',
43
- },
44
- `);
45
-
46
- expect(args).toEqual([
47
- {
48
- name: 'tweet-url',
49
- type: 'str',
50
- default: undefined,
51
- required: false,
52
- positional: undefined,
53
- help: 'Single tweet URL to download',
54
- choices: undefined,
55
- },
56
- {
57
- name: 'download-images',
58
- type: 'boolean',
59
- default: false,
60
- required: false,
61
- positional: undefined,
62
- help: 'Download images locally',
63
- choices: undefined,
64
- },
65
- ]);
66
- });
67
- });
5
+ import { cli, getRegistry, Strategy } from './registry.js';
6
+ import { loadTsManifestEntries, shouldReplaceManifestEntry } from './build-manifest.js';
68
7
 
69
8
  describe('manifest helper rules', () => {
70
9
  const tempDirs: string[] = [];
@@ -127,43 +66,133 @@ describe('manifest helper rules', () => {
127
66
  const file = path.join(dir, 'utils.ts');
128
67
  fs.writeFileSync(file, `export function helper() { return 'noop'; }`);
129
68
 
130
- expect(scanTs(file, 'demo')).toBeNull();
69
+ return expect(loadTsManifestEntries(file, 'demo', async () => ({}))).resolves.toEqual([]);
131
70
  });
132
71
 
133
- it('keeps literal domain and navigateBefore for TS adapters', () => {
134
- const file = path.join(process.cwd(), 'src', 'clis', 'xueqiu', 'fund-holdings.ts');
135
- const entry = scanTs(file, 'xueqiu');
136
-
137
- expect(entry).toMatchObject({
138
- site: 'xueqiu',
139
- name: 'fund-holdings',
140
- domain: 'danjuanfunds.com',
141
- navigateBefore: 'https://danjuanfunds.com/my-money',
142
- type: 'ts',
143
- modulePath: 'xueqiu/fund-holdings.js',
144
- });
72
+ it('builds TS manifest entries from exported runtime commands', async () => {
73
+ const site = `manifest-hydrate-${Date.now()}`;
74
+ const key = `${site}/dynamic`;
75
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-manifest-'));
76
+ tempDirs.push(dir);
77
+ const file = path.join(dir, `${site}.ts`);
78
+ fs.writeFileSync(file, `export const command = cli({ site: '${site}', name: 'dynamic' });`);
79
+
80
+ const entries = await loadTsManifestEntries(file, site, async () => ({
81
+ command: cli({
82
+ site,
83
+ name: 'dynamic',
84
+ description: 'dynamic command',
85
+ strategy: Strategy.PUBLIC,
86
+ browser: false,
87
+ args: [
88
+ {
89
+ name: 'model',
90
+ required: true,
91
+ positional: true,
92
+ help: 'Choose a model',
93
+ choices: ['auto', 'thinking'],
94
+ default: '30',
95
+ },
96
+ ],
97
+ domain: 'localhost',
98
+ navigateBefore: 'https://example.com/session',
99
+ deprecated: 'legacy command',
100
+ replacedBy: 'opencli demo new',
101
+ }),
102
+ }));
103
+
104
+ expect(entries).toEqual([
105
+ {
106
+ site,
107
+ name: 'dynamic',
108
+ description: 'dynamic command',
109
+ domain: 'localhost',
110
+ strategy: 'public',
111
+ browser: false,
112
+ args: [
113
+ {
114
+ name: 'model',
115
+ type: 'str',
116
+ required: true,
117
+ positional: true,
118
+ help: 'Choose a model',
119
+ choices: ['auto', 'thinking'],
120
+ default: '30',
121
+ },
122
+ ],
123
+ type: 'ts',
124
+ modulePath: `${site}/${site}.js`,
125
+ navigateBefore: 'https://example.com/session',
126
+ deprecated: 'legacy command',
127
+ replacedBy: 'opencli demo new',
128
+ },
129
+ ]);
130
+
131
+ getRegistry().delete(key);
145
132
  });
146
133
 
147
- it('captures deprecated metadata for TS adapters', () => {
134
+ it('falls back to registry delta for side-effect-only cli modules', async () => {
135
+ const site = `manifest-side-effect-${Date.now()}`;
136
+ const key = `${site}/legacy`;
148
137
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-manifest-'));
149
138
  tempDirs.push(dir);
150
- const file = path.join(dir, 'legacy.ts');
151
- fs.writeFileSync(file, `
152
- import { cli } from '../../registry.js';
139
+ const file = path.join(dir, `${site}.ts`);
140
+ fs.writeFileSync(file, `cli({ site: '${site}', name: 'legacy' });`);
141
+
142
+ const entries = await loadTsManifestEntries(file, site, async () => {
153
143
  cli({
154
- site: 'demo',
144
+ site,
155
145
  name: 'legacy',
156
146
  description: 'legacy command',
157
147
  deprecated: 'legacy is deprecated',
158
148
  replacedBy: 'opencli demo new',
159
149
  });
160
- `);
161
-
162
- expect(scanTs(file, 'demo')).toMatchObject({
163
- site: 'demo',
164
- name: 'legacy',
165
- deprecated: 'legacy is deprecated',
166
- replacedBy: 'opencli demo new',
150
+ return {};
167
151
  });
152
+
153
+ expect(entries).toEqual([
154
+ {
155
+ site,
156
+ name: 'legacy',
157
+ description: 'legacy command',
158
+ strategy: 'cookie',
159
+ browser: true,
160
+ args: [],
161
+ type: 'ts',
162
+ modulePath: `${site}/${site}.js`,
163
+ deprecated: 'legacy is deprecated',
164
+ replacedBy: 'opencli demo new',
165
+ },
166
+ ]);
167
+
168
+ getRegistry().delete(key);
169
+ });
170
+
171
+ it('keeps every command a module exports instead of guessing by site', async () => {
172
+ const site = `manifest-multi-${Date.now()}`;
173
+ const screenKey = `${site}/screen`;
174
+ const statusKey = `${site}/status`;
175
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-manifest-'));
176
+ tempDirs.push(dir);
177
+ const file = path.join(dir, `${site}.ts`);
178
+ fs.writeFileSync(file, `export const screen = cli({ site: '${site}', name: 'screen' });`);
179
+
180
+ const entries = await loadTsManifestEntries(file, site, async () => ({
181
+ screen: cli({
182
+ site,
183
+ name: 'screen',
184
+ description: 'capture screen',
185
+ }),
186
+ status: cli({
187
+ site,
188
+ name: 'status',
189
+ description: 'show status',
190
+ }),
191
+ }));
192
+
193
+ expect(entries.map(entry => entry.name)).toEqual(['screen', 'status']);
194
+
195
+ getRegistry().delete(screenKey);
196
+ getRegistry().delete(statusKey);
168
197
  });
169
198
  });
@@ -14,6 +14,7 @@ import * as path from 'node:path';
14
14
  import { fileURLToPath, pathToFileURL } from 'node:url';
15
15
  import yaml from 'js-yaml';
16
16
  import { getErrorMessage } from './errors.js';
17
+ import { fullName, getRegistry, type CliCommand } from './registry.js';
17
18
 
18
19
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
20
  const CLIS_DIR = path.resolve(__dirname, 'clis');
@@ -52,116 +53,50 @@ import { type YamlCliDefinition, parseYamlArgs } from './yaml-schema.js';
52
53
 
53
54
  import { isRecord } from './utils.js';
54
55
 
55
-
56
- function extractBalancedBlock(
57
- source: string,
58
- startIndex: number,
59
- openChar: string,
60
- closeChar: string,
61
- ): string | null {
62
- let depth = 0;
63
- let quote: string | null = null;
64
- let escaped = false;
65
-
66
- for (let i = startIndex; i < source.length; i++) {
67
- const ch = source[i];
68
-
69
- if (quote) {
70
- if (escaped) {
71
- escaped = false;
72
- continue;
73
- }
74
- if (ch === '\\') {
75
- escaped = true;
76
- continue;
77
- }
78
- if (ch === quote) quote = null;
79
- continue;
80
- }
81
-
82
- if (ch === '"' || ch === '\'' || ch === '`') {
83
- quote = ch;
84
- continue;
85
- }
86
-
87
- if (ch === openChar) {
88
- depth++;
89
- } else if (ch === closeChar) {
90
- depth--;
91
- if (depth === 0) {
92
- return source.slice(startIndex + 1, i);
93
- }
94
- }
95
- }
96
-
97
- return null;
56
+ const CLI_MODULE_PATTERN = /\bcli\s*\(/;
57
+
58
+ function toManifestArgs(args: CliCommand['args']): ManifestEntry['args'] {
59
+ return args.map(arg => ({
60
+ name: arg.name,
61
+ type: arg.type ?? 'str',
62
+ default: arg.default,
63
+ required: !!arg.required,
64
+ positional: arg.positional || undefined,
65
+ help: arg.help ?? '',
66
+ choices: arg.choices,
67
+ }));
98
68
  }
99
69
 
100
- function extractTsArgsBlock(source: string): string | null {
101
- const argsMatch = source.match(/args\s*:/);
102
- if (!argsMatch || argsMatch.index === undefined) return null;
103
-
104
- const bracketIndex = source.indexOf('[', argsMatch.index);
105
- if (bracketIndex === -1) return null;
106
-
107
- return extractBalancedBlock(source, bracketIndex, '[', ']');
70
+ function toTsModulePath(filePath: string, site: string): string {
71
+ const baseName = path.basename(filePath, path.extname(filePath));
72
+ return `${site}/${baseName}.js`;
108
73
  }
109
74
 
110
- function parseInlineChoices(body: string): string[] | undefined {
111
- const choicesMatch = body.match(/choices\s*:\s*\[([^\]]*)\]/);
112
- if (!choicesMatch) return undefined;
113
-
114
- const values = choicesMatch[1]
115
- .split(',')
116
- .map(s => s.trim().replace(/^['"`]|['"`]$/g, ''))
117
- .filter(Boolean);
118
-
119
- return values.length > 0 ? values : undefined;
75
+ function isCliCommandValue(value: unknown, site: string): value is CliCommand {
76
+ return isRecord(value)
77
+ && typeof value.site === 'string'
78
+ && value.site === site
79
+ && typeof value.name === 'string'
80
+ && Array.isArray(value.args);
120
81
  }
121
82
 
122
- export function parseTsArgsBlock(argsBlock: string): ManifestEntry['args'] {
123
- const args: ManifestEntry['args'] = [];
124
- let cursor = 0;
125
-
126
- while (cursor < argsBlock.length) {
127
- const nameMatch = argsBlock.slice(cursor).match(/\{\s*name\s*:\s*['"`]([^'"`]+)['"`]/);
128
- if (!nameMatch || nameMatch.index === undefined) break;
129
-
130
- const objectStart = cursor + nameMatch.index;
131
- const body = extractBalancedBlock(argsBlock, objectStart, '{', '}');
132
- if (body == null) break;
133
-
134
- const typeMatch = body.match(/type\s*:\s*['"`](\w+)['"`]/);
135
- const defaultMatch = body.match(/default\s*:\s*([^,}]+)/);
136
- const requiredMatch = body.match(/required\s*:\s*(true|false)/);
137
- const helpMatch = body.match(/help\s*:\s*['"`]([^'"`]*)['"`]/);
138
- const positionalMatch = body.match(/positional\s*:\s*(true|false)/);
139
-
140
- let defaultVal: unknown = undefined;
141
- if (defaultMatch) {
142
- const raw = defaultMatch[1].trim();
143
- if (raw === 'true') defaultVal = true;
144
- else if (raw === 'false') defaultVal = false;
145
- else if (/^\d+$/.test(raw)) defaultVal = parseInt(raw, 10);
146
- else if (/^\d+\.\d+$/.test(raw)) defaultVal = parseFloat(raw);
147
- else defaultVal = raw.replace(/^['"`]|['"`]$/g, '');
148
- }
149
-
150
- args.push({
151
- name: nameMatch[1],
152
- type: typeMatch?.[1] ?? 'str',
153
- default: defaultVal,
154
- required: requiredMatch?.[1] === 'true',
155
- positional: positionalMatch?.[1] === 'true' || undefined,
156
- help: helpMatch?.[1] ?? '',
157
- choices: parseInlineChoices(body),
158
- });
159
-
160
- cursor = objectStart + body.length;
161
- if (cursor <= objectStart) break; // safety: prevent infinite loop
162
- }
163
-
164
- return args;
83
+ function toManifestEntry(cmd: CliCommand, modulePath: string): ManifestEntry {
84
+ return {
85
+ site: cmd.site,
86
+ name: cmd.name,
87
+ description: cmd.description ?? '',
88
+ domain: cmd.domain,
89
+ strategy: (cmd.strategy ?? 'public').toString().toLowerCase(),
90
+ browser: cmd.browser ?? true,
91
+ args: toManifestArgs(cmd.args),
92
+ columns: cmd.columns,
93
+ timeout: cmd.timeoutSeconds,
94
+ deprecated: cmd.deprecated,
95
+ replacedBy: cmd.replacedBy,
96
+ type: 'ts',
97
+ modulePath,
98
+ navigateBefore: cmd.navigateBefore,
99
+ };
165
100
  }
166
101
 
167
102
  function scanYaml(filePath: string, site: string): ManifestEntry | null {
@@ -199,83 +134,49 @@ function scanYaml(filePath: string, site: string): ManifestEntry | null {
199
134
  }
200
135
  }
201
136
 
202
- export function scanTs(filePath: string, site: string): ManifestEntry | null {
203
- // TS adapters self-register via cli() at import time.
204
- // We statically parse the source to extract metadata for the manifest stub.
205
- const baseName = path.basename(filePath, path.extname(filePath));
206
- const relativePath = `${site}/${baseName}.js`;
207
-
137
+ export async function loadTsManifestEntries(
138
+ filePath: string,
139
+ site: string,
140
+ importer: (moduleHref: string) => Promise<unknown> = moduleHref => import(moduleHref),
141
+ ): Promise<ManifestEntry[]> {
208
142
  try {
209
143
  const src = fs.readFileSync(filePath, 'utf-8');
210
144
 
211
145
  // Helper/test modules should not appear as CLI commands in the manifest.
212
- if (!/\bcli\s*\(/.test(src)) return null;
213
-
214
- const entry: ManifestEntry = {
215
- site,
216
- name: baseName,
217
- description: '',
218
- strategy: 'cookie',
219
- browser: true,
220
- args: [],
221
- type: 'ts',
222
- modulePath: relativePath,
223
- };
224
-
225
- // Extract description
226
- const descMatch = src.match(/description\s*:\s*['"`]([^'"`]*)['"`]/);
227
- if (descMatch) entry.description = descMatch[1];
228
-
229
- // Extract domain
230
- const domainMatch = src.match(/domain\s*:\s*['"`]([^'"`]*)['"`]/);
231
- if (domainMatch) entry.domain = domainMatch[1];
232
-
233
- // Extract strategy
234
- const stratMatch = src.match(/strategy\s*:\s*Strategy\.(\w+)/);
235
- if (stratMatch) entry.strategy = stratMatch[1].toLowerCase();
236
-
237
- // Extract browser: false (some adapters bypass browser entirely)
238
- const browserMatch = src.match(/browser\s*:\s*(true|false)/);
239
- if (browserMatch) entry.browser = browserMatch[1] === 'true';
240
- else entry.browser = entry.strategy !== 'public';
241
-
242
- // Extract columns
243
- const colMatch = src.match(/columns\s*:\s*\[([^\]]*)\]/);
244
- if (colMatch) {
245
- entry.columns = colMatch[1].split(',').map(s => s.trim().replace(/^['"`]|['"`]$/g, '')).filter(Boolean);
246
- }
247
-
248
- // Extract args array items: { name: '...', ... }
249
- const argsBlock = extractTsArgsBlock(src);
250
- if (argsBlock) {
251
- entry.args = parseTsArgsBlock(argsBlock);
252
- }
253
-
254
- // Extract navigateBefore: false / true / 'https://...'
255
- const navBoolMatch = src.match(/navigateBefore\s*:\s*(true|false)/);
256
- if (navBoolMatch) {
257
- entry.navigateBefore = navBoolMatch[1] === 'true';
258
- } else {
259
- const navStringMatch = src.match(/navigateBefore\s*:\s*['"`]([^'"`]+)['"`]/);
260
- if (navStringMatch) entry.navigateBefore = navStringMatch[1];
261
- }
262
-
263
- const deprecatedBoolMatch = src.match(/deprecated\s*:\s*(true|false)/);
264
- if (deprecatedBoolMatch) {
265
- entry.deprecated = deprecatedBoolMatch[1] === 'true';
266
- } else {
267
- const deprecatedStringMatch = src.match(/deprecated\s*:\s*['"`]([^'"`]+)['"`]/);
268
- if (deprecatedStringMatch) entry.deprecated = deprecatedStringMatch[1];
269
- }
270
-
271
- const replacedByMatch = src.match(/replacedBy\s*:\s*['"`]([^'"`]+)['"`]/);
272
- if (replacedByMatch) entry.replacedBy = replacedByMatch[1];
273
-
274
- return entry;
146
+ if (!CLI_MODULE_PATTERN.test(src)) return [];
147
+
148
+ const modulePath = toTsModulePath(filePath, site);
149
+ const registry = getRegistry();
150
+ const before = new Map(registry.entries());
151
+ const mod = await importer(pathToFileURL(filePath).href);
152
+
153
+ const exportedCommands = Object.values(isRecord(mod) ? mod : {})
154
+ .filter(value => isCliCommandValue(value, site));
155
+
156
+ const runtimeCommands = exportedCommands.length > 0
157
+ ? exportedCommands
158
+ : [...registry.entries()]
159
+ .filter(([key, cmd]) => {
160
+ if (cmd.site !== site) return false;
161
+ const previous = before.get(key);
162
+ return !previous || previous !== cmd;
163
+ })
164
+ .map(([, cmd]) => cmd);
165
+
166
+ const seen = new Set<string>();
167
+ return runtimeCommands
168
+ .filter((cmd) => {
169
+ const key = fullName(cmd);
170
+ if (seen.has(key)) return false;
171
+ seen.add(key);
172
+ return true;
173
+ })
174
+ .sort((a, b) => a.name.localeCompare(b.name))
175
+ .map(cmd => toManifestEntry(cmd, modulePath));
275
176
  } catch (err) {
276
177
  // If parsing fails, log a warning (matching scanYaml behaviour) and skip the entry.
277
178
  process.stderr.write(`Warning: failed to scan ${filePath}: ${getErrorMessage(err)}\n`);
278
- return null;
179
+ return [];
279
180
  }
280
181
  }
281
182
 
@@ -288,7 +189,7 @@ export function shouldReplaceManifestEntry(current: ManifestEntry, next: Manifes
288
189
  return current.type === 'yaml' && next.type === 'ts';
289
190
  }
290
191
 
291
- export function buildManifest(): ManifestEntry[] {
192
+ export async function buildManifest(): Promise<ManifestEntry[]> {
292
193
  const manifest = new Map<string, ManifestEntry>();
293
194
 
294
195
  if (fs.existsSync(CLIS_DIR)) {
@@ -313,8 +214,8 @@ export function buildManifest(): ManifestEntry[] {
313
214
  (file.endsWith('.ts') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts') && file !== 'index.ts') ||
314
215
  (file.endsWith('.js') && !file.endsWith('.d.js') && !file.endsWith('.test.js') && file !== 'index.js')
315
216
  ) {
316
- const entry = scanTs(filePath, site);
317
- if (entry) {
217
+ const entries = await loadTsManifestEntries(filePath, site);
218
+ for (const entry of entries) {
318
219
  const key = `${entry.site}/${entry.name}`;
319
220
  const existing = manifest.get(key);
320
221
  if (!existing || shouldReplaceManifestEntry(existing, entry)) {
@@ -332,8 +233,8 @@ export function buildManifest(): ManifestEntry[] {
332
233
  return [...manifest.values()];
333
234
  }
334
235
 
335
- function main(): void {
336
- const manifest = buildManifest();
236
+ async function main(): Promise<void> {
237
+ const manifest = await buildManifest();
337
238
  fs.mkdirSync(path.dirname(OUTPUT), { recursive: true });
338
239
  fs.writeFileSync(OUTPUT, JSON.stringify(manifest, null, 2));
339
240
 
@@ -367,5 +268,5 @@ function main(): void {
367
268
 
368
269
  const entrypoint = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null;
369
270
  if (entrypoint === import.meta.url) {
370
- main();
271
+ void main();
371
272
  }
@@ -60,7 +60,7 @@ cli({
60
60
 
61
61
  await page.installInterceptor('36kr.com/api');
62
62
  await page.goto(url);
63
- await page.wait(6);
63
+ await page.waitForCapture(6);
64
64
 
65
65
  // Scrape rendered article links from DOM (deduplicated)
66
66
  const domItems: any = await page.evaluate(`
@@ -24,7 +24,7 @@ cli({
24
24
 
25
25
  await page.installInterceptor('36kr.com/api');
26
26
  await page.goto(`https://www.36kr.com/search/articles/${query}`);
27
- await page.wait(6);
27
+ await page.waitForCapture(6);
28
28
 
29
29
  const domItems: any = await page.evaluate(`
30
30
  (() => {
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Shared utilities for CLI adapters.
3
+ */
4
+
5
+ /**
6
+ * Clamp a numeric value to [min, max].
7
+ * Matches the signature of lodash.clamp and Rust's clamp.
8
+ */
9
+ export function clamp(value: number, min: number, max: number): number {
10
+ return Math.max(min, Math.min(value, max));
11
+ }
@@ -23,7 +23,7 @@ cli({
23
23
 
24
24
  // Navigate and wait for the page to hydrate before extracting story data.
25
25
  await page.goto(url);
26
- await page.wait(5);
26
+ await page.wait({ selector: 'article', timeout: 5 });
27
27
 
28
28
  const loadStory = async () => page.evaluate(`(() => {
29
29
  const isRobot = /Are you a robot/i.test(document.title)