@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,92 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { waitForCaptureJs, waitForSelectorJs } from './dom-helpers.js';
3
+ describe('waitForCaptureJs', () => {
4
+ it('returns a non-empty string', () => {
5
+ const code = waitForCaptureJs(1000);
6
+ expect(typeof code).toBe('string');
7
+ expect(code.length).toBeGreaterThan(0);
8
+ expect(code).toContain('__opencli_xhr');
9
+ expect(code).toContain('resolve');
10
+ expect(code).toContain('reject');
11
+ });
12
+ it('resolves "captured" when __opencli_xhr is populated before deadline', async () => {
13
+ const g = globalThis;
14
+ g.__opencli_xhr = [];
15
+ g.window = g; // stub window for Node eval
16
+ const code = waitForCaptureJs(1000);
17
+ const promise = eval(code);
18
+ g.__opencli_xhr.push({ data: 'test' });
19
+ await expect(promise).resolves.toBe('captured');
20
+ delete g.__opencli_xhr;
21
+ delete g.window;
22
+ });
23
+ it('rejects when __opencli_xhr stays empty past deadline', async () => {
24
+ const g = globalThis;
25
+ g.__opencli_xhr = [];
26
+ g.window = g;
27
+ const code = waitForCaptureJs(50); // 50ms timeout
28
+ const promise = eval(code);
29
+ await expect(promise).rejects.toThrow('No network capture within 0.05s');
30
+ delete g.__opencli_xhr;
31
+ delete g.window;
32
+ });
33
+ it('resolves immediately when __opencli_xhr already has data', async () => {
34
+ const g = globalThis;
35
+ g.__opencli_xhr = [{ data: 'already here' }];
36
+ g.window = g;
37
+ const code = waitForCaptureJs(1000);
38
+ await expect(eval(code)).resolves.toBe('captured');
39
+ delete g.__opencli_xhr;
40
+ delete g.window;
41
+ });
42
+ });
43
+ describe('waitForSelectorJs', () => {
44
+ it('returns a non-empty string', () => {
45
+ const code = waitForSelectorJs('#app', 1000);
46
+ expect(typeof code).toBe('string');
47
+ expect(code).toContain('#app');
48
+ expect(code).toContain('querySelector');
49
+ expect(code).toContain('MutationObserver');
50
+ });
51
+ it('resolves "found" immediately when selector already present', async () => {
52
+ const g = globalThis;
53
+ const fakeEl = { tagName: 'DIV' };
54
+ g.document = { querySelector: (_) => fakeEl };
55
+ const code = waitForSelectorJs('[data-testid="primaryColumn"]', 1000);
56
+ await expect(eval(code)).resolves.toBe('found');
57
+ delete g.document;
58
+ });
59
+ it('resolves "found" when selector appears after DOM mutation', async () => {
60
+ const g = globalThis;
61
+ let mutationCallback;
62
+ g.MutationObserver = class {
63
+ constructor(cb) { mutationCallback = cb; }
64
+ observe() { }
65
+ disconnect() { }
66
+ };
67
+ let calls = 0;
68
+ g.document = {
69
+ querySelector: (_) => (calls++ > 0 ? { tagName: 'DIV' } : null),
70
+ body: {},
71
+ };
72
+ const code = waitForSelectorJs('#app', 1000);
73
+ const promise = eval(code);
74
+ mutationCallback(); // simulate DOM mutation
75
+ await expect(promise).resolves.toBe('found');
76
+ delete g.document;
77
+ delete g.MutationObserver;
78
+ });
79
+ it('rejects when selector never appears within timeout', async () => {
80
+ const g = globalThis;
81
+ g.MutationObserver = class {
82
+ constructor(_cb) { }
83
+ observe() { }
84
+ disconnect() { }
85
+ };
86
+ g.document = { querySelector: (_) => null, body: {} };
87
+ const code = waitForSelectorJs('#missing', 50);
88
+ await expect(eval(code)).rejects.toThrow('Selector not found: #missing');
89
+ delete g.document;
90
+ delete g.MutationObserver;
91
+ });
92
+ });
@@ -11,15 +11,3 @@ export { isDaemonRunning } from './daemon-client.js';
11
11
  export { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
12
12
  export { generateStealthJs } from './stealth.js';
13
13
  export type { DomSnapshotOptions } from './dom-snapshot.js';
14
- import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
15
- import { isRetryableSettleError } from './page.js';
16
- import { withTimeoutMs } from '../runtime.js';
17
- export declare const __test__: {
18
- extractTabEntries: typeof extractTabEntries;
19
- diffTabIndexes: typeof diffTabIndexes;
20
- appendLimited: typeof appendLimited;
21
- withTimeoutMs: typeof withTimeoutMs;
22
- selectCDPTarget: (targets: import("./cdp.js").CDPTarget[]) => import("./cdp.js").CDPTarget | undefined;
23
- scoreCDPTarget: (target: import("./cdp.js").CDPTarget, preferredPattern?: RegExp) => number;
24
- isRetryableSettleError: typeof isRetryableSettleError;
25
- };
@@ -10,16 +10,3 @@ export { CDPBridge } from './cdp.js';
10
10
  export { isDaemonRunning } from './daemon-client.js';
11
11
  export { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
12
12
  export { generateStealthJs } from './stealth.js';
13
- import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
14
- import { __test__ as cdpTest } from './cdp.js';
15
- import { isRetryableSettleError } from './page.js';
16
- import { withTimeoutMs } from '../runtime.js';
17
- export const __test__ = {
18
- extractTabEntries,
19
- diffTabIndexes,
20
- appendLimited,
21
- withTimeoutMs,
22
- selectCDPTarget: cdpTest.selectCDPTarget,
23
- scoreCDPTarget: cdpTest.scoreCDPTarget,
24
- isRetryableSettleError,
25
- };
@@ -82,10 +82,11 @@ export class BrowserBridge {
82
82
  env: { ...process.env },
83
83
  });
84
84
  this._daemonProc.unref();
85
- // Wait for daemon to be ready AND extension to connect
85
+ // Wait for daemon to be ready AND extension to connect (exponential backoff)
86
+ const backoffs = [50, 100, 200, 400, 800, 1500, 3000];
86
87
  const deadline = Date.now() + timeoutMs;
87
- while (Date.now() < deadline) {
88
- await new Promise(resolve => setTimeout(resolve, 300));
88
+ for (let i = 0; Date.now() < deadline; i++) {
89
+ await new Promise(resolve => setTimeout(resolve, backoffs[Math.min(i, backoffs.length - 1)]));
89
90
  if (await isExtensionConnected())
90
91
  return;
91
92
  }
@@ -72,4 +72,5 @@ export declare class Page implements IPage {
72
72
  }): Promise<void>;
73
73
  installInterceptor(pattern: string): Promise<void>;
74
74
  getInterceptedRequests(): Promise<unknown[]>;
75
+ waitForCapture(timeout?: number): Promise<void>;
75
76
  }
@@ -15,7 +15,7 @@ import { wrapForEval } from './utils.js';
15
15
  import { saveBase64ToFile } from '../utils.js';
16
16
  import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
17
17
  import { generateStealthJs } from './stealth.js';
18
- import { clickJs, typeTextJs, pressKeyJs, waitForTextJs, scrollJs, autoScrollJs, networkRequestsJs, waitForDomStableJs, } from './dom-helpers.js';
18
+ import { clickJs, typeTextJs, pressKeyJs, waitForTextJs, waitForCaptureJs, waitForSelectorJs, scrollJs, autoScrollJs, networkRequestsJs, waitForDomStableJs, } from './dom-helpers.js';
19
19
  export function isRetryableSettleError(err) {
20
20
  const message = err instanceof Error ? err.message : String(err);
21
21
  return message.includes('Inspected target navigated or closed')
@@ -219,6 +219,12 @@ export class Page {
219
219
  await new Promise(resolve => setTimeout(resolve, options.time * 1000));
220
220
  return;
221
221
  }
222
+ if (options.selector) {
223
+ const timeout = (options.timeout ?? 10) * 1000;
224
+ const code = waitForSelectorJs(options.selector, timeout);
225
+ await sendCommand('exec', { code, ...this._cmdOpts() });
226
+ return;
227
+ }
222
228
  if (options.text) {
223
229
  const timeout = (options.timeout ?? 30) * 1000;
224
230
  const code = waitForTextJs(options.text, timeout);
@@ -302,5 +308,12 @@ export class Page {
302
308
  const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
303
309
  return Array.isArray(result) ? result : [];
304
310
  }
311
+ async waitForCapture(timeout = 10) {
312
+ const maxMs = timeout * 1000;
313
+ await sendCommand('exec', {
314
+ code: waitForCaptureJs(maxMs),
315
+ ...this._cmdOpts(),
316
+ });
317
+ }
305
318
  }
306
319
  // (End of file)
@@ -1,23 +1,27 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
- import { BrowserBridge, __test__, generateStealthJs } from './browser/index.js';
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
  describe('browser helpers', () => {
5
9
  it('extracts tab entries from string snapshots', () => {
6
- const entries = __test__.extractTabEntries('Tab 0 https://example.com\nTab 1 Chrome Extension');
10
+ const entries = extractTabEntries('Tab 0 https://example.com\nTab 1 Chrome Extension');
7
11
  expect(entries).toEqual([
8
12
  { index: 0, identity: 'https://example.com' },
9
13
  { index: 1, identity: 'Chrome Extension' },
10
14
  ]);
11
15
  });
12
16
  it('extracts tab entries from MCP markdown format', () => {
13
- const entries = __test__.extractTabEntries('- 0: (current) [Playwright MCP extension](chrome-extension://abc/connect.html)\n- 1: [知乎 - 首页](https://www.zhihu.com/)');
17
+ const entries = extractTabEntries('- 0: (current) [Playwright MCP extension](chrome-extension://abc/connect.html)\n- 1: [知乎 - 首页](https://www.zhihu.com/)');
14
18
  expect(entries).toEqual([
15
19
  { index: 0, identity: '(current) [Playwright MCP extension](chrome-extension://abc/connect.html)' },
16
20
  { index: 1, identity: '[知乎 - 首页](https://www.zhihu.com/)' },
17
21
  ]);
18
22
  });
19
23
  it('closes only tabs that were opened during the session', () => {
20
- const tabsToClose = __test__.diffTabIndexes(['https://example.com', 'Chrome Extension'], [
24
+ const tabsToClose = diffTabIndexes(['https://example.com', 'Chrome Extension'], [
21
25
  { index: 0, identity: 'https://example.com' },
22
26
  { index: 1, identity: 'Chrome Extension' },
23
27
  { index: 2, identity: 'https://target.example/page' },
@@ -26,18 +30,18 @@ describe('browser helpers', () => {
26
30
  expect(tabsToClose).toEqual([3, 2]);
27
31
  });
28
32
  it('keeps only the tail of stderr buffers', () => {
29
- expect(__test__.appendLimited('12345', '67890', 8)).toBe('34567890');
33
+ expect(appendLimited('12345', '67890', 8)).toBe('34567890');
30
34
  });
31
35
  it('times out slow promises', async () => {
32
- await expect(__test__.withTimeoutMs(new Promise(() => { }), 10, 'timeout')).rejects.toThrow('timeout');
36
+ await expect(withTimeoutMs(new Promise(() => { }), 10, 'timeout')).rejects.toThrow('timeout');
33
37
  });
34
38
  it('retries settle only for target-invalidated errors', () => {
35
- expect(__test__.isRetryableSettleError(new Error('{"code":-32000,"message":"Inspected target navigated or closed"}'))).toBe(true);
36
- expect(__test__.isRetryableSettleError(new Error('attach failed: target no longer exists'))).toBe(false);
37
- expect(__test__.isRetryableSettleError(new Error('malformed exec payload'))).toBe(false);
39
+ expect(isRetryableSettleError(new Error('{"code":-32000,"message":"Inspected target navigated or closed"}'))).toBe(true);
40
+ expect(isRetryableSettleError(new Error('attach failed: target no longer exists'))).toBe(false);
41
+ expect(isRetryableSettleError(new Error('malformed exec payload'))).toBe(false);
38
42
  });
39
43
  it('prefers the real Electron app target over DevTools and blank pages', () => {
40
- const target = __test__.selectCDPTarget([
44
+ const target = cdpTest.selectCDPTarget([
41
45
  {
42
46
  type: 'page',
43
47
  title: 'DevTools - localhost:9224',
@@ -61,7 +65,7 @@ describe('browser helpers', () => {
61
65
  });
62
66
  it('honors OPENCLI_CDP_TARGET when multiple inspectable targets exist', () => {
63
67
  vi.stubEnv('OPENCLI_CDP_TARGET', 'codex');
64
- const target = __test__.selectCDPTarget([
68
+ const target = cdpTest.selectCDPTarget([
65
69
  {
66
70
  type: 'app',
67
71
  title: 'Cursor',
@@ -36,11 +36,10 @@ export interface ManifestEntry {
36
36
  /** Pre-navigation control — see CliCommand.navigateBefore */
37
37
  navigateBefore?: boolean | string;
38
38
  }
39
- export declare function parseTsArgsBlock(argsBlock: string): ManifestEntry['args'];
40
- export declare function scanTs(filePath: string, site: string): ManifestEntry | null;
39
+ export declare function loadTsManifestEntries(filePath: string, site: string, importer?: (moduleHref: string) => Promise<unknown>): Promise<ManifestEntry[]>;
41
40
  /**
42
41
  * When both YAML and TS adapters exist for the same site/name,
43
42
  * prefer the TS version (it self-registers and typically has richer logic).
44
43
  */
45
44
  export declare function shouldReplaceManifestEntry(current: ManifestEntry, next: ManifestEntry): boolean;
46
- export declare function buildManifest(): ManifestEntry[];
45
+ export declare function buildManifest(): Promise<ManifestEntry[]>;
@@ -13,109 +13,52 @@ import * as path from 'node:path';
13
13
  import { fileURLToPath, pathToFileURL } from 'node:url';
14
14
  import yaml from 'js-yaml';
15
15
  import { getErrorMessage } from './errors.js';
16
+ import { fullName, getRegistry } from './registry.js';
16
17
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
18
  const CLIS_DIR = path.resolve(__dirname, 'clis');
18
19
  const OUTPUT = path.resolve(__dirname, '..', 'dist', 'cli-manifest.json');
19
20
  import { parseYamlArgs } from './yaml-schema.js';
20
21
  import { isRecord } from './utils.js';
21
- function extractBalancedBlock(source, startIndex, openChar, closeChar) {
22
- let depth = 0;
23
- let quote = null;
24
- let escaped = false;
25
- for (let i = startIndex; i < source.length; i++) {
26
- const ch = source[i];
27
- if (quote) {
28
- if (escaped) {
29
- escaped = false;
30
- continue;
31
- }
32
- if (ch === '\\') {
33
- escaped = true;
34
- continue;
35
- }
36
- if (ch === quote)
37
- quote = null;
38
- continue;
39
- }
40
- if (ch === '"' || ch === '\'' || ch === '`') {
41
- quote = ch;
42
- continue;
43
- }
44
- if (ch === openChar) {
45
- depth++;
46
- }
47
- else if (ch === closeChar) {
48
- depth--;
49
- if (depth === 0) {
50
- return source.slice(startIndex + 1, i);
51
- }
52
- }
53
- }
54
- return null;
22
+ const CLI_MODULE_PATTERN = /\bcli\s*\(/;
23
+ function toManifestArgs(args) {
24
+ return args.map(arg => ({
25
+ name: arg.name,
26
+ type: arg.type ?? 'str',
27
+ default: arg.default,
28
+ required: !!arg.required,
29
+ positional: arg.positional || undefined,
30
+ help: arg.help ?? '',
31
+ choices: arg.choices,
32
+ }));
55
33
  }
56
- function extractTsArgsBlock(source) {
57
- const argsMatch = source.match(/args\s*:/);
58
- if (!argsMatch || argsMatch.index === undefined)
59
- return null;
60
- const bracketIndex = source.indexOf('[', argsMatch.index);
61
- if (bracketIndex === -1)
62
- return null;
63
- return extractBalancedBlock(source, bracketIndex, '[', ']');
34
+ function toTsModulePath(filePath, site) {
35
+ const baseName = path.basename(filePath, path.extname(filePath));
36
+ return `${site}/${baseName}.js`;
64
37
  }
65
- function parseInlineChoices(body) {
66
- const choicesMatch = body.match(/choices\s*:\s*\[([^\]]*)\]/);
67
- if (!choicesMatch)
68
- return undefined;
69
- const values = choicesMatch[1]
70
- .split(',')
71
- .map(s => s.trim().replace(/^['"`]|['"`]$/g, ''))
72
- .filter(Boolean);
73
- return values.length > 0 ? values : undefined;
38
+ function isCliCommandValue(value, site) {
39
+ return isRecord(value)
40
+ && typeof value.site === 'string'
41
+ && value.site === site
42
+ && typeof value.name === 'string'
43
+ && Array.isArray(value.args);
74
44
  }
75
- export function parseTsArgsBlock(argsBlock) {
76
- const args = [];
77
- let cursor = 0;
78
- while (cursor < argsBlock.length) {
79
- const nameMatch = argsBlock.slice(cursor).match(/\{\s*name\s*:\s*['"`]([^'"`]+)['"`]/);
80
- if (!nameMatch || nameMatch.index === undefined)
81
- break;
82
- const objectStart = cursor + nameMatch.index;
83
- const body = extractBalancedBlock(argsBlock, objectStart, '{', '}');
84
- if (body == null)
85
- break;
86
- const typeMatch = body.match(/type\s*:\s*['"`](\w+)['"`]/);
87
- const defaultMatch = body.match(/default\s*:\s*([^,}]+)/);
88
- const requiredMatch = body.match(/required\s*:\s*(true|false)/);
89
- const helpMatch = body.match(/help\s*:\s*['"`]([^'"`]*)['"`]/);
90
- const positionalMatch = body.match(/positional\s*:\s*(true|false)/);
91
- let defaultVal = undefined;
92
- if (defaultMatch) {
93
- const raw = defaultMatch[1].trim();
94
- if (raw === 'true')
95
- defaultVal = true;
96
- else if (raw === 'false')
97
- defaultVal = false;
98
- else if (/^\d+$/.test(raw))
99
- defaultVal = parseInt(raw, 10);
100
- else if (/^\d+\.\d+$/.test(raw))
101
- defaultVal = parseFloat(raw);
102
- else
103
- defaultVal = raw.replace(/^['"`]|['"`]$/g, '');
104
- }
105
- args.push({
106
- name: nameMatch[1],
107
- type: typeMatch?.[1] ?? 'str',
108
- default: defaultVal,
109
- required: requiredMatch?.[1] === 'true',
110
- positional: positionalMatch?.[1] === 'true' || undefined,
111
- help: helpMatch?.[1] ?? '',
112
- choices: parseInlineChoices(body),
113
- });
114
- cursor = objectStart + body.length;
115
- if (cursor <= objectStart)
116
- break; // safety: prevent infinite loop
117
- }
118
- return args;
45
+ function toManifestEntry(cmd, modulePath) {
46
+ return {
47
+ site: cmd.site,
48
+ name: cmd.name,
49
+ description: cmd.description ?? '',
50
+ domain: cmd.domain,
51
+ strategy: (cmd.strategy ?? 'public').toString().toLowerCase(),
52
+ browser: cmd.browser ?? true,
53
+ args: toManifestArgs(cmd.args),
54
+ columns: cmd.columns,
55
+ timeout: cmd.timeoutSeconds,
56
+ deprecated: cmd.deprecated,
57
+ replacedBy: cmd.replacedBy,
58
+ type: 'ts',
59
+ modulePath,
60
+ navigateBefore: cmd.navigateBefore,
61
+ };
119
62
  }
120
63
  function scanYaml(filePath, site) {
121
64
  try {
@@ -150,82 +93,44 @@ function scanYaml(filePath, site) {
150
93
  return null;
151
94
  }
152
95
  }
153
- export function scanTs(filePath, site) {
154
- // TS adapters self-register via cli() at import time.
155
- // We statically parse the source to extract metadata for the manifest stub.
156
- const baseName = path.basename(filePath, path.extname(filePath));
157
- const relativePath = `${site}/${baseName}.js`;
96
+ export async function loadTsManifestEntries(filePath, site, importer = moduleHref => import(moduleHref)) {
158
97
  try {
159
98
  const src = fs.readFileSync(filePath, 'utf-8');
160
99
  // Helper/test modules should not appear as CLI commands in the manifest.
161
- if (!/\bcli\s*\(/.test(src))
162
- return null;
163
- const entry = {
164
- site,
165
- name: baseName,
166
- description: '',
167
- strategy: 'cookie',
168
- browser: true,
169
- args: [],
170
- type: 'ts',
171
- modulePath: relativePath,
172
- };
173
- // Extract description
174
- const descMatch = src.match(/description\s*:\s*['"`]([^'"`]*)['"`]/);
175
- if (descMatch)
176
- entry.description = descMatch[1];
177
- // Extract domain
178
- const domainMatch = src.match(/domain\s*:\s*['"`]([^'"`]*)['"`]/);
179
- if (domainMatch)
180
- entry.domain = domainMatch[1];
181
- // Extract strategy
182
- const stratMatch = src.match(/strategy\s*:\s*Strategy\.(\w+)/);
183
- if (stratMatch)
184
- entry.strategy = stratMatch[1].toLowerCase();
185
- // Extract browser: false (some adapters bypass browser entirely)
186
- const browserMatch = src.match(/browser\s*:\s*(true|false)/);
187
- if (browserMatch)
188
- entry.browser = browserMatch[1] === 'true';
189
- else
190
- entry.browser = entry.strategy !== 'public';
191
- // Extract columns
192
- const colMatch = src.match(/columns\s*:\s*\[([^\]]*)\]/);
193
- if (colMatch) {
194
- entry.columns = colMatch[1].split(',').map(s => s.trim().replace(/^['"`]|['"`]$/g, '')).filter(Boolean);
195
- }
196
- // Extract args array items: { name: '...', ... }
197
- const argsBlock = extractTsArgsBlock(src);
198
- if (argsBlock) {
199
- entry.args = parseTsArgsBlock(argsBlock);
200
- }
201
- // Extract navigateBefore: false / true / 'https://...'
202
- const navBoolMatch = src.match(/navigateBefore\s*:\s*(true|false)/);
203
- if (navBoolMatch) {
204
- entry.navigateBefore = navBoolMatch[1] === 'true';
205
- }
206
- else {
207
- const navStringMatch = src.match(/navigateBefore\s*:\s*['"`]([^'"`]+)['"`]/);
208
- if (navStringMatch)
209
- entry.navigateBefore = navStringMatch[1];
210
- }
211
- const deprecatedBoolMatch = src.match(/deprecated\s*:\s*(true|false)/);
212
- if (deprecatedBoolMatch) {
213
- entry.deprecated = deprecatedBoolMatch[1] === 'true';
214
- }
215
- else {
216
- const deprecatedStringMatch = src.match(/deprecated\s*:\s*['"`]([^'"`]+)['"`]/);
217
- if (deprecatedStringMatch)
218
- entry.deprecated = deprecatedStringMatch[1];
219
- }
220
- const replacedByMatch = src.match(/replacedBy\s*:\s*['"`]([^'"`]+)['"`]/);
221
- if (replacedByMatch)
222
- entry.replacedBy = replacedByMatch[1];
223
- return entry;
100
+ if (!CLI_MODULE_PATTERN.test(src))
101
+ return [];
102
+ const modulePath = toTsModulePath(filePath, site);
103
+ const registry = getRegistry();
104
+ const before = new Map(registry.entries());
105
+ const mod = await importer(pathToFileURL(filePath).href);
106
+ const exportedCommands = Object.values(isRecord(mod) ? mod : {})
107
+ .filter(value => isCliCommandValue(value, site));
108
+ const runtimeCommands = exportedCommands.length > 0
109
+ ? exportedCommands
110
+ : [...registry.entries()]
111
+ .filter(([key, cmd]) => {
112
+ if (cmd.site !== site)
113
+ return false;
114
+ const previous = before.get(key);
115
+ return !previous || previous !== cmd;
116
+ })
117
+ .map(([, cmd]) => cmd);
118
+ const seen = new Set();
119
+ return runtimeCommands
120
+ .filter((cmd) => {
121
+ const key = fullName(cmd);
122
+ if (seen.has(key))
123
+ return false;
124
+ seen.add(key);
125
+ return true;
126
+ })
127
+ .sort((a, b) => a.name.localeCompare(b.name))
128
+ .map(cmd => toManifestEntry(cmd, modulePath));
224
129
  }
225
130
  catch (err) {
226
131
  // If parsing fails, log a warning (matching scanYaml behaviour) and skip the entry.
227
132
  process.stderr.write(`Warning: failed to scan ${filePath}: ${getErrorMessage(err)}\n`);
228
- return null;
133
+ return [];
229
134
  }
230
135
  }
231
136
  /**
@@ -237,7 +142,7 @@ export function shouldReplaceManifestEntry(current, next) {
237
142
  return false;
238
143
  return current.type === 'yaml' && next.type === 'ts';
239
144
  }
240
- export function buildManifest() {
145
+ export async function buildManifest() {
241
146
  const manifest = new Map();
242
147
  if (fs.existsSync(CLIS_DIR)) {
243
148
  for (const site of fs.readdirSync(CLIS_DIR)) {
@@ -261,8 +166,8 @@ export function buildManifest() {
261
166
  }
262
167
  else if ((file.endsWith('.ts') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts') && file !== 'index.ts') ||
263
168
  (file.endsWith('.js') && !file.endsWith('.d.js') && !file.endsWith('.test.js') && file !== 'index.js')) {
264
- const entry = scanTs(filePath, site);
265
- if (entry) {
169
+ const entries = await loadTsManifestEntries(filePath, site);
170
+ for (const entry of entries) {
266
171
  const key = `${entry.site}/${entry.name}`;
267
172
  const existing = manifest.get(key);
268
173
  if (!existing || shouldReplaceManifestEntry(existing, entry)) {
@@ -278,8 +183,8 @@ export function buildManifest() {
278
183
  }
279
184
  return [...manifest.values()];
280
185
  }
281
- function main() {
282
- const manifest = buildManifest();
186
+ async function main() {
187
+ const manifest = await buildManifest();
283
188
  fs.mkdirSync(path.dirname(OUTPUT), { recursive: true });
284
189
  fs.writeFileSync(OUTPUT, JSON.stringify(manifest, null, 2));
285
190
  const yamlCount = manifest.filter(e => e.type === 'yaml').length;
@@ -311,5 +216,5 @@ function main() {
311
216
  }
312
217
  const entrypoint = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null;
313
218
  if (entrypoint === import.meta.url) {
314
- main();
219
+ void main();
315
220
  }