@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
@@ -0,0 +1,93 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { decideProxy, hasProxyEnv } from './node-network.js';
4
+
5
+ describe('node network proxy decisions', () => {
6
+ it('detects common proxy env variables', () => {
7
+ expect(hasProxyEnv({ https_proxy: 'http://127.0.0.1:7897' })).toBe(true);
8
+ expect(hasProxyEnv({ HTTP_PROXY: 'http://proxy.example:8080' })).toBe(true);
9
+ expect(hasProxyEnv({})).toBe(false);
10
+ });
11
+
12
+ it('routes external https traffic through https_proxy', () => {
13
+ const decision = decideProxy(
14
+ new URL('https://www.v2ex.com/api/topics/latest.json'),
15
+ { https_proxy: 'http://127.0.0.1:7897' },
16
+ );
17
+
18
+ expect(decision).toEqual({
19
+ mode: 'proxy',
20
+ proxyUrl: 'http://127.0.0.1:7897',
21
+ });
22
+ });
23
+
24
+ it('falls back to HTTP_PROXY for https traffic when HTTPS_PROXY is absent', () => {
25
+ const decision = decideProxy(
26
+ new URL('https://www.v2ex.com/api/topics/latest.json'),
27
+ { HTTP_PROXY: 'http://127.0.0.1:7897' },
28
+ );
29
+
30
+ expect(decision).toEqual({
31
+ mode: 'proxy',
32
+ proxyUrl: 'http://127.0.0.1:7897',
33
+ });
34
+ });
35
+
36
+ it('bypasses proxies for loopback addresses', () => {
37
+ const env = { https_proxy: 'http://127.0.0.1:7897', http_proxy: 'http://127.0.0.1:7897' };
38
+
39
+ expect(decideProxy(new URL('http://127.0.0.1:19825/status'), env)).toEqual({ mode: 'direct' });
40
+ expect(decideProxy(new URL('http://localhost:19825/status'), env)).toEqual({ mode: 'direct' });
41
+ expect(decideProxy(new URL('http://[::1]:19825/status'), env)).toEqual({ mode: 'direct' });
42
+ });
43
+
44
+ it('honors NO_PROXY domain matches', () => {
45
+ const decision = decideProxy(
46
+ new URL('https://api.example.com/v1/items'),
47
+ {
48
+ https_proxy: 'http://127.0.0.1:7897',
49
+ no_proxy: '.example.com',
50
+ },
51
+ );
52
+
53
+ expect(decision).toEqual({ mode: 'direct' });
54
+ });
55
+
56
+ it('supports wildcard-style NO_PROXY subdomain entries', () => {
57
+ const decision = decideProxy(
58
+ new URL('https://api.example.com/v1/items'),
59
+ {
60
+ https_proxy: 'http://127.0.0.1:7897',
61
+ no_proxy: '*.example.com',
62
+ },
63
+ );
64
+
65
+ expect(decision).toEqual({ mode: 'direct' });
66
+ });
67
+
68
+ it('matches NO_PROXY entries that rely on the default URL port', () => {
69
+ const env = { https_proxy: 'http://127.0.0.1:7897', http_proxy: 'http://127.0.0.1:7897' };
70
+
71
+ expect(decideProxy(
72
+ new URL('https://example.com/'),
73
+ { ...env, NO_PROXY: 'example.com:443' },
74
+ )).toEqual({ mode: 'direct' });
75
+
76
+ expect(decideProxy(
77
+ new URL('http://example.com/health'),
78
+ { ...env, NO_PROXY: 'example.com:80' },
79
+ )).toEqual({ mode: 'direct' });
80
+ });
81
+
82
+ it('falls back to ALL_PROXY when protocol-specific settings are absent', () => {
83
+ const decision = decideProxy(
84
+ new URL('http://example.net/data'),
85
+ { ALL_PROXY: 'socks5://127.0.0.1:1080' },
86
+ );
87
+
88
+ expect(decision).toEqual({
89
+ mode: 'proxy',
90
+ proxyUrl: 'socks5://127.0.0.1:1080',
91
+ });
92
+ });
93
+ });
@@ -0,0 +1,213 @@
1
+ import { Agent, EnvHttpProxyAgent, fetch as undiciFetch, type Dispatcher } from 'undici';
2
+
3
+ const LOOPBACK_NO_PROXY_ENTRIES = ['127.0.0.1', 'localhost', '::1'];
4
+
5
+ type ProxyEnvKey =
6
+ | 'http_proxy'
7
+ | 'https_proxy'
8
+ | 'all_proxy'
9
+ | 'HTTP_PROXY'
10
+ | 'HTTPS_PROXY'
11
+ | 'ALL_PROXY';
12
+
13
+ const PROXY_ENV_BY_PROTOCOL: Record<'http:' | 'https:', ProxyEnvKey[]> = {
14
+ 'http:': ['http_proxy', 'HTTP_PROXY', 'all_proxy', 'ALL_PROXY'],
15
+ 'https:': ['https_proxy', 'HTTPS_PROXY', 'all_proxy', 'ALL_PROXY'],
16
+ };
17
+ const DEFAULT_PORT_BY_PROTOCOL: Record<'http:' | 'https:', string> = {
18
+ 'http:': '80',
19
+ 'https:': '443',
20
+ };
21
+
22
+ export interface ProxyDecision {
23
+ mode: 'direct' | 'proxy';
24
+ proxyUrl?: string;
25
+ }
26
+
27
+ interface NoProxyEntry {
28
+ host: string;
29
+ port?: string;
30
+ }
31
+
32
+ interface ProxyConfig {
33
+ httpProxy?: string;
34
+ httpsProxy?: string;
35
+ noProxy?: string;
36
+ noProxyEntries: NoProxyEntry[];
37
+ }
38
+
39
+ let installed = false;
40
+ const directDispatcher = new Agent();
41
+ const proxyDispatcherCache = new Map<string, Dispatcher>();
42
+ const nativeFetch = globalThis.fetch.bind(globalThis);
43
+
44
+ function readEnv(env: NodeJS.ProcessEnv, lower: string, upper: string): string | undefined {
45
+ const lowerValue = env[lower];
46
+ if (typeof lowerValue === 'string' && lowerValue.trim() !== '') return lowerValue;
47
+ const upperValue = env[upper];
48
+ if (typeof upperValue === 'string' && upperValue.trim() !== '') return upperValue;
49
+ return undefined;
50
+ }
51
+
52
+ function readProxyEnv(env: NodeJS.ProcessEnv, keys: ProxyEnvKey[]): string | undefined {
53
+ for (const key of keys) {
54
+ const value = env[key];
55
+ if (typeof value === 'string' && value.trim() !== '') return value;
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ function normalizeHostname(hostname: string): string {
61
+ return hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase();
62
+ }
63
+
64
+ function splitNoProxy(raw: string | undefined): string[] {
65
+ return (raw ?? '')
66
+ .split(/[,\s]+/)
67
+ .map((token) => token.trim())
68
+ .filter(Boolean);
69
+ }
70
+
71
+ function parseNoProxyEntry(entry: string): NoProxyEntry {
72
+ if (entry === '*') return { host: '*' };
73
+
74
+ const trimmed = entry.trim().replace(/^\*?\./, '');
75
+ if (trimmed.startsWith('[')) {
76
+ const end = trimmed.indexOf(']');
77
+ if (end !== -1) {
78
+ const host = trimmed.slice(1, end);
79
+ const rest = trimmed.slice(end + 1);
80
+ if (rest.startsWith(':')) return { host: normalizeHostname(host), port: rest.slice(1) };
81
+ return { host: normalizeHostname(host) };
82
+ }
83
+ }
84
+
85
+ const colonCount = (trimmed.match(/:/g) ?? []).length;
86
+ if (colonCount === 1) {
87
+ const [host, port] = trimmed.split(':');
88
+ return { host: normalizeHostname(host), port };
89
+ }
90
+
91
+ return { host: normalizeHostname(trimmed) };
92
+ }
93
+
94
+ function effectiveNoProxyEntries(env: NodeJS.ProcessEnv): NoProxyEntry[] {
95
+ const raw = readEnv(env, 'no_proxy', 'NO_PROXY');
96
+ const entries = splitNoProxy(raw).map(parseNoProxyEntry);
97
+ const seen = new Set(entries.map((entry) => `${entry.host}:${entry.port ?? ''}`));
98
+ for (const rawEntry of LOOPBACK_NO_PROXY_ENTRIES) {
99
+ const entry = parseNoProxyEntry(rawEntry);
100
+ const key = `${entry.host}:${entry.port ?? ''}`;
101
+ if (seen.has(key)) continue;
102
+ entries.push(entry);
103
+ seen.add(key);
104
+ }
105
+ return entries;
106
+ }
107
+
108
+ function serializeNoProxyEntry(entry: NoProxyEntry): string {
109
+ if (entry.host === '*') return '*';
110
+
111
+ const host = entry.host.includes(':') ? `[${entry.host}]` : entry.host;
112
+ return entry.port ? `${host}:${entry.port}` : host;
113
+ }
114
+
115
+ function effectiveNoProxyValue(entries: NoProxyEntry[]): string | undefined {
116
+ if (entries.length === 0) return undefined;
117
+
118
+ return entries.map(serializeNoProxyEntry).join(',');
119
+ }
120
+
121
+ function matchesNoProxyEntry(url: URL, entry: NoProxyEntry): boolean {
122
+ const { host, port } = entry;
123
+ if (host === '*') return true;
124
+
125
+ const hostname = normalizeHostname(url.hostname);
126
+ const urlPort = url.port || DEFAULT_PORT_BY_PROTOCOL[url.protocol as 'http:' | 'https:'] || undefined;
127
+ if (port && port !== urlPort) return false;
128
+ return hostname === host || hostname.endsWith(`.${host}`);
129
+ }
130
+
131
+ function resolveProxyConfig(env: NodeJS.ProcessEnv = process.env): ProxyConfig {
132
+ const noProxyEntries = effectiveNoProxyEntries(env);
133
+ return {
134
+ httpProxy: readProxyEnv(env, PROXY_ENV_BY_PROTOCOL['http:']),
135
+ httpsProxy: readProxyEnv(env, [
136
+ 'https_proxy',
137
+ 'HTTPS_PROXY',
138
+ 'http_proxy',
139
+ 'HTTP_PROXY',
140
+ 'all_proxy',
141
+ 'ALL_PROXY',
142
+ ]),
143
+ noProxy: effectiveNoProxyValue(noProxyEntries),
144
+ noProxyEntries,
145
+ };
146
+ }
147
+
148
+ function createProxyDispatcher(config: ProxyConfig): Dispatcher {
149
+ const cacheKey = JSON.stringify([
150
+ config.httpProxy ?? '',
151
+ config.httpsProxy ?? '',
152
+ config.noProxy ?? '',
153
+ ]);
154
+ const cached = proxyDispatcherCache.get(cacheKey);
155
+ if (cached) return cached;
156
+ const dispatcher = new EnvHttpProxyAgent({
157
+ httpProxy: config.httpProxy,
158
+ httpsProxy: config.httpsProxy,
159
+ noProxy: config.noProxy,
160
+ });
161
+ proxyDispatcherCache.set(cacheKey, dispatcher);
162
+ return dispatcher;
163
+ }
164
+
165
+ function resolveUrl(input: RequestInfo | URL): URL | null {
166
+ if (typeof input === 'string') return new URL(input);
167
+ if (input instanceof URL) return input;
168
+ if (typeof Request !== 'undefined' && input instanceof Request) return new URL(input.url);
169
+ return null;
170
+ }
171
+
172
+ export function hasProxyEnv(env: NodeJS.ProcessEnv = process.env): boolean {
173
+ const config = resolveProxyConfig(env);
174
+ return Boolean(config.httpProxy || config.httpsProxy);
175
+ }
176
+
177
+ export function decideProxy(url: URL, env: NodeJS.ProcessEnv = process.env): ProxyDecision {
178
+ const config = resolveProxyConfig(env);
179
+ if (config.noProxyEntries.some((entry) => matchesNoProxyEntry(url, entry))) {
180
+ return { mode: 'direct' };
181
+ }
182
+
183
+ const proxyUrl = url.protocol === 'https:' ? config.httpsProxy : config.httpProxy;
184
+ if (!proxyUrl) return { mode: 'direct' };
185
+ return { mode: 'proxy', proxyUrl };
186
+ }
187
+
188
+ export function getDispatcherForUrl(url: URL, env: NodeJS.ProcessEnv = process.env): Dispatcher {
189
+ const config = resolveProxyConfig(env);
190
+ if (!config.httpProxy && !config.httpsProxy) return directDispatcher;
191
+ return createProxyDispatcher(config);
192
+ }
193
+
194
+ export async function fetchWithNodeNetwork(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
195
+ const url = resolveUrl(input);
196
+ if (!url || !hasProxyEnv()) {
197
+ return nativeFetch(input, init);
198
+ }
199
+
200
+ return (await undiciFetch(input as Parameters<typeof undiciFetch>[0], {
201
+ ...init,
202
+ dispatcher: getDispatcherForUrl(url),
203
+ } as Parameters<typeof undiciFetch>[1])) as unknown as Response;
204
+ }
205
+
206
+ export function installNodeNetwork(): void {
207
+ if (installed) return;
208
+
209
+ globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => (
210
+ fetchWithNodeNetwork(input, init)
211
+ )) as typeof globalThis.fetch;
212
+ installed = true;
213
+ }
@@ -31,6 +31,7 @@ function createMockPage(overrides: Partial<IPage> = {}): IPage {
31
31
  installInterceptor: vi.fn(),
32
32
  getInterceptedRequests: vi.fn().mockResolvedValue([]),
33
33
  screenshot: vi.fn().mockResolvedValue(''),
34
+ waitForCapture: vi.fn().mockResolvedValue(undefined),
34
35
  ...overrides,
35
36
  };
36
37
  }
@@ -44,6 +44,7 @@ function createMockPage(getCookies: IPage['getCookies']): IPage {
44
44
  installInterceptor: vi.fn(),
45
45
  getInterceptedRequests: vi.fn().mockResolvedValue([]),
46
46
  screenshot: vi.fn().mockResolvedValue(''),
47
+ waitForCapture: vi.fn().mockResolvedValue(undefined),
47
48
  };
48
49
  }
49
50
 
@@ -4,7 +4,6 @@
4
4
 
5
5
  import type { IPage } from '../../types.js';
6
6
  import { render, normalizeEvaluateSource } from '../template.js';
7
- import { generateInterceptorJs, generateReadInterceptedJs } from '../../interceptor.js';
8
7
 
9
8
  export async function stepIntercept(page: IPage | null, params: any, data: any, args: Record<string, any>): Promise<any> {
10
9
  const cfg = typeof params === 'object' ? params : {};
@@ -16,7 +15,7 @@ export async function stepIntercept(page: IPage | null, params: any, data: any,
16
15
  if (!capturePattern) return data;
17
16
 
18
17
  // Step 1: Inject fetch/XHR interceptor BEFORE trigger
19
- await page!.evaluate(generateInterceptorJs(JSON.stringify(capturePattern)));
18
+ await page!.installInterceptor(capturePattern);
20
19
 
21
20
  // Step 2: Execute the trigger action
22
21
  if (trigger.startsWith('navigate:')) {
@@ -32,11 +31,11 @@ export async function stepIntercept(page: IPage | null, params: any, data: any,
32
31
  await page!.scroll('down');
33
32
  }
34
33
 
35
- // Step 3: Wait a bit for network requests to fire
36
- await page!.wait(Math.min(timeout, 3));
34
+ // Step 3: Wait for network capture (event-driven, not fixed sleep)
35
+ await page!.waitForCapture(timeout);
37
36
 
38
37
  // Step 4: Retrieve captured data
39
- const matchingResponses = await page!.evaluate(generateReadInterceptedJs());
38
+ const matchingResponses = await page!.getInterceptedRequests();
40
39
 
41
40
  // Step 5: Select from response if specified
42
41
  let result = matchingResponses.length === 1 ? matchingResponses[0] :
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { CliCommand } from './registry.js';
3
+ import { Strategy } from './registry.js';
4
+ import { formatRegistryHelpText } from './serialization.js';
5
+
6
+ describe('formatRegistryHelpText', () => {
7
+ it('summarizes long choices lists so help text stays readable', () => {
8
+ const cmd: CliCommand = {
9
+ site: 'demo',
10
+ name: 'dynamic',
11
+ description: 'Demo command',
12
+ strategy: Strategy.PUBLIC,
13
+ browser: false,
14
+ args: [
15
+ {
16
+ name: 'field',
17
+ help: 'Field to use',
18
+ choices: ['all-fields', 'topic', 'title', 'author', 'publication-titles', 'year-published', 'doi'],
19
+ },
20
+ ],
21
+ columns: ['field'],
22
+ };
23
+
24
+ expect(formatRegistryHelpText(cmd)).toContain('--field: all-fields, topic, title, author, ... (+3 more)');
25
+ });
26
+ });
@@ -62,6 +62,11 @@ export function formatArgSummary(args: Arg[]): string {
62
62
  .join(' ');
63
63
  }
64
64
 
65
+ function summarizeChoices(choices: string[]): string {
66
+ if (choices.length <= 4) return choices.join(', ');
67
+ return `${choices.slice(0, 4).join(', ')}, ... (+${choices.length - 4} more)`;
68
+ }
69
+
65
70
  /** Generate the --help appendix showing registry metadata not exposed by Commander. */
66
71
  export function formatRegistryHelpText(cmd: CliCommand): string {
67
72
  const lines: string[] = [];
@@ -69,7 +74,7 @@ export function formatRegistryHelpText(cmd: CliCommand): string {
69
74
  for (const a of choicesArgs) {
70
75
  const prefix = a.positional ? `<${a.name}>` : `--${a.name}`;
71
76
  const def = a.default != null ? ` (default: ${a.default})` : '';
72
- lines.push(` ${prefix}: ${a.choices!.join(', ')}${def}`);
77
+ lines.push(` ${prefix}: ${summarizeChoices(a.choices!)}${def}`);
73
78
  }
74
79
  const meta: string[] = [];
75
80
  meta.push(`Strategy: ${strategyLabel(cmd)}`);
package/src/types.ts CHANGED
@@ -26,6 +26,7 @@ export interface SnapshotOptions {
26
26
 
27
27
  export interface WaitOptions {
28
28
  text?: string;
29
+ selector?: string; // wait until document.querySelector(selector) matches
29
30
  time?: number;
30
31
  timeout?: number;
31
32
  }
@@ -64,6 +65,7 @@ export interface IPage {
64
65
  autoScroll(options?: { times?: number; delayMs?: number }): Promise<void>;
65
66
  installInterceptor(pattern: string): Promise<void>;
66
67
  getInterceptedRequests(): Promise<any[]>;
68
+ waitForCapture(timeout?: number): Promise<void>;
67
69
  screenshot(options?: ScreenshotOptions): Promise<string>;
68
70
  closeWindow?(): Promise<void>;
69
71
  /** Returns the current page URL, or null if unavailable. */
package/src/utils.ts CHANGED
@@ -31,6 +31,11 @@ export async function mapConcurrent<T, R>(
31
31
  return results;
32
32
  }
33
33
 
34
+ /** Pause for the given number of milliseconds. */
35
+ export function sleep(ms: number): Promise<void> {
36
+ return new Promise(resolve => setTimeout(resolve, ms));
37
+ }
38
+
34
39
  /** Save a base64-encoded string to a file, creating parent directories as needed. */
35
40
  export async function saveBase64ToFile(base64: string, filePath: string): Promise<void> {
36
41
  const dir = path.dirname(filePath);