@jackwener/opencli 0.4.1 → 0.4.3

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 (67) hide show
  1. package/CLI-CREATOR.md +103 -142
  2. package/LICENSE +28 -0
  3. package/README.md +113 -63
  4. package/README.zh-CN.md +114 -63
  5. package/SKILL.md +21 -4
  6. package/dist/browser.d.ts +21 -2
  7. package/dist/browser.js +269 -15
  8. package/dist/browser.test.d.ts +1 -0
  9. package/dist/browser.test.js +43 -0
  10. package/dist/build-manifest.js +66 -2
  11. package/dist/cli-manifest.json +905 -109
  12. package/dist/clis/boss/search.js +186 -30
  13. package/dist/clis/twitter/delete.d.ts +1 -0
  14. package/dist/clis/twitter/delete.js +73 -0
  15. package/dist/clis/twitter/followers.d.ts +1 -0
  16. package/dist/clis/twitter/followers.js +104 -0
  17. package/dist/clis/twitter/following.d.ts +1 -0
  18. package/dist/clis/twitter/following.js +90 -0
  19. package/dist/clis/twitter/like.d.ts +1 -0
  20. package/dist/clis/twitter/like.js +69 -0
  21. package/dist/clis/twitter/notifications.d.ts +1 -0
  22. package/dist/clis/twitter/notifications.js +109 -0
  23. package/dist/clis/twitter/post.d.ts +1 -0
  24. package/dist/clis/twitter/post.js +63 -0
  25. package/dist/clis/twitter/reply.d.ts +1 -0
  26. package/dist/clis/twitter/reply.js +57 -0
  27. package/dist/clis/v2ex/daily.d.ts +1 -0
  28. package/dist/clis/v2ex/daily.js +98 -0
  29. package/dist/clis/v2ex/me.d.ts +1 -0
  30. package/dist/clis/v2ex/me.js +99 -0
  31. package/dist/clis/v2ex/notifications.d.ts +1 -0
  32. package/dist/clis/v2ex/notifications.js +72 -0
  33. package/dist/clis/xiaohongshu/search.d.ts +5 -2
  34. package/dist/clis/xiaohongshu/search.js +35 -41
  35. package/dist/doctor.d.ts +50 -0
  36. package/dist/doctor.js +372 -0
  37. package/dist/doctor.test.d.ts +1 -0
  38. package/dist/doctor.test.js +114 -0
  39. package/dist/main.js +47 -5
  40. package/dist/output.test.d.ts +1 -0
  41. package/dist/output.test.js +20 -0
  42. package/dist/registry.d.ts +4 -0
  43. package/dist/registry.js +1 -0
  44. package/dist/runtime.d.ts +3 -1
  45. package/dist/runtime.js +2 -2
  46. package/package.json +2 -2
  47. package/src/browser.test.ts +51 -0
  48. package/src/browser.ts +318 -22
  49. package/src/build-manifest.ts +67 -2
  50. package/src/clis/boss/search.ts +196 -29
  51. package/src/clis/twitter/delete.ts +78 -0
  52. package/src/clis/twitter/followers.ts +119 -0
  53. package/src/clis/twitter/following.ts +105 -0
  54. package/src/clis/twitter/like.ts +74 -0
  55. package/src/clis/twitter/notifications.ts +119 -0
  56. package/src/clis/twitter/post.ts +68 -0
  57. package/src/clis/twitter/reply.ts +62 -0
  58. package/src/clis/v2ex/daily.ts +105 -0
  59. package/src/clis/v2ex/me.ts +103 -0
  60. package/src/clis/v2ex/notifications.ts +77 -0
  61. package/src/clis/xiaohongshu/search.ts +41 -44
  62. package/src/doctor.test.ts +133 -0
  63. package/src/doctor.ts +424 -0
  64. package/src/main.ts +47 -4
  65. package/src/output.test.ts +27 -0
  66. package/src/registry.ts +5 -0
  67. package/src/runtime.ts +2 -1
package/dist/browser.js CHANGED
@@ -1,13 +1,76 @@
1
1
  /**
2
- * Browser interaction via Playwright MCP Bridge extension.
3
- * Connects to an existing Chrome browser through the extension's stdio JSON-RPC.
2
+ * Browser interaction via Chrome DevTools Protocol.
3
+ * Connects to an existing Chrome browser through CDP auto-discovery or extension bridge.
4
4
  */
5
5
  import { spawn, execSync } from 'node:child_process';
6
+ import { createHash } from 'node:crypto';
7
+ import * as net from 'node:net';
6
8
  import { fileURLToPath } from 'node:url';
7
9
  import * as fs from 'node:fs';
8
10
  import * as os from 'node:os';
9
11
  import * as path from 'node:path';
10
12
  import { formatSnapshot } from './snapshotFormatter.js';
13
+ /**
14
+ * Chrome 144+ auto-discovery: read DevToolsActivePort file to get CDP endpoint.
15
+ *
16
+ * Starting with Chrome 144, users can enable remote debugging from
17
+ * chrome://inspect#remote-debugging without any command-line flags.
18
+ * Chrome writes the active port and browser GUID to a DevToolsActivePort file
19
+ * in the user data directory, which we read to construct the WebSocket endpoint.
20
+ *
21
+ * Priority: OPENCLI_CDP_ENDPOINT env > DevToolsActivePort auto-discovery > --extension fallback
22
+ */
23
+ /** Quick TCP port probe to verify Chrome is actually listening */
24
+ function isPortReachable(port, host = '127.0.0.1', timeoutMs = 800) {
25
+ return new Promise(resolve => {
26
+ const sock = net.createConnection({ port, host });
27
+ sock.setTimeout(timeoutMs);
28
+ sock.on('connect', () => { sock.destroy(); resolve(true); });
29
+ sock.on('error', () => resolve(false));
30
+ sock.on('timeout', () => { sock.destroy(); resolve(false); });
31
+ });
32
+ }
33
+ export async function discoverChromeEndpoint() {
34
+ const candidates = [];
35
+ // User-specified Chrome data dir takes highest priority
36
+ if (process.env.CHROME_USER_DATA_DIR) {
37
+ candidates.push(path.join(process.env.CHROME_USER_DATA_DIR, 'DevToolsActivePort'));
38
+ }
39
+ // Standard Chrome/Edge user data dirs per platform
40
+ if (process.platform === 'win32') {
41
+ const localAppData = process.env.LOCALAPPDATA ?? path.join(os.homedir(), 'AppData', 'Local');
42
+ candidates.push(path.join(localAppData, 'Google', 'Chrome', 'User Data', 'DevToolsActivePort'));
43
+ candidates.push(path.join(localAppData, 'Microsoft', 'Edge', 'User Data', 'DevToolsActivePort'));
44
+ }
45
+ else if (process.platform === 'darwin') {
46
+ candidates.push(path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome', 'DevToolsActivePort'));
47
+ candidates.push(path.join(os.homedir(), 'Library', 'Application Support', 'Microsoft Edge', 'DevToolsActivePort'));
48
+ }
49
+ else {
50
+ candidates.push(path.join(os.homedir(), '.config', 'google-chrome', 'DevToolsActivePort'));
51
+ candidates.push(path.join(os.homedir(), '.config', 'chromium', 'DevToolsActivePort'));
52
+ candidates.push(path.join(os.homedir(), '.config', 'microsoft-edge', 'DevToolsActivePort'));
53
+ }
54
+ for (const filePath of candidates) {
55
+ try {
56
+ const content = fs.readFileSync(filePath, 'utf-8').trim();
57
+ const lines = content.split('\n');
58
+ if (lines.length >= 2) {
59
+ const port = parseInt(lines[0], 10);
60
+ const browserPath = lines[1]; // e.g. /devtools/browser/<GUID>
61
+ if (port > 0 && browserPath.startsWith('/devtools/browser/')) {
62
+ const endpoint = `ws://127.0.0.1:${port}${browserPath}`;
63
+ // Verify the port is actually reachable (Chrome may have closed, leaving a stale file)
64
+ if (await isPortReachable(port)) {
65
+ return endpoint;
66
+ }
67
+ }
68
+ }
69
+ }
70
+ catch { }
71
+ }
72
+ return null;
73
+ }
11
74
  // Read version from package.json (single source of truth)
12
75
  const __browser_dirname = path.dirname(fileURLToPath(import.meta.url));
13
76
  const PKG_VERSION = (() => { try {
@@ -20,6 +83,67 @@ const EXTENSION_LOCK_TIMEOUT = parseInt(process.env.OPENCLI_EXTENSION_LOCK_TIMEO
20
83
  const EXTENSION_LOCK_POLL = parseInt(process.env.OPENCLI_EXTENSION_LOCK_POLL_INTERVAL ?? '1', 10);
21
84
  const CONNECT_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_CONNECT_TIMEOUT ?? '30', 10);
22
85
  const LOCK_DIR = path.join(os.tmpdir(), 'opencli-mcp-lock');
86
+ export function getTokenFingerprint(token) {
87
+ if (!token)
88
+ return null;
89
+ return createHash('sha256').update(token).digest('hex').slice(0, 8);
90
+ }
91
+ export function formatBrowserConnectError(input) {
92
+ const stderr = input.stderr?.trim();
93
+ const suffix = stderr ? `\n\nMCP stderr:\n${stderr}` : '';
94
+ const tokenHint = input.tokenFingerprint ? ` Token fingerprint: ${input.tokenFingerprint}.` : '';
95
+ if (input.mode === 'extension') {
96
+ if (input.kind === 'missing-token') {
97
+ return new Error('Failed to connect to Playwright MCP Bridge: PLAYWRIGHT_MCP_EXTENSION_TOKEN is not set.\n\n' +
98
+ 'Without this token, Chrome will show a manual approval dialog for every new MCP connection. ' +
99
+ 'Copy the token from the Playwright MCP Bridge extension and set it in BOTH your shell environment and MCP client config.' +
100
+ suffix);
101
+ }
102
+ if (input.kind === 'extension-not-installed') {
103
+ return new Error('Failed to connect to Playwright MCP Bridge: the browser extension did not attach.\n\n' +
104
+ 'Make sure Chrome is running and the "Playwright MCP Bridge" extension is installed and enabled. ' +
105
+ 'If Chrome shows an approval dialog, click Allow.' +
106
+ suffix);
107
+ }
108
+ if (input.kind === 'extension-timeout') {
109
+ const likelyCause = input.hasExtensionToken
110
+ ? `The most likely cause is that PLAYWRIGHT_MCP_EXTENSION_TOKEN does not match the token currently shown by the browser extension.${tokenHint} Re-copy the token from the extension and update BOTH your shell environment and MCP client config.`
111
+ : 'PLAYWRIGHT_MCP_EXTENSION_TOKEN is not configured, so the extension may be waiting for manual approval.';
112
+ return new Error(`Timed out connecting to Playwright MCP Bridge (${input.timeout}s).\n\n` +
113
+ `${likelyCause} If a browser prompt is visible, click Allow. You can also switch to Chrome remote debugging mode with OPENCLI_USE_CDP=1 as a fallback.` +
114
+ suffix);
115
+ }
116
+ }
117
+ if (input.mode === 'cdp' && input.kind === 'cdp-timeout') {
118
+ return new Error(`Timed out connecting to browser via CDP (${input.timeout}s).\n\n` +
119
+ 'Make sure Chrome is running and remote debugging is enabled at chrome://inspect#remote-debugging, or set OPENCLI_CDP_ENDPOINT explicitly.' +
120
+ suffix);
121
+ }
122
+ if (input.kind === 'mcp-init') {
123
+ return new Error(`Failed to initialize Playwright MCP: ${input.rawMessage ?? 'unknown error'}${suffix}`);
124
+ }
125
+ if (input.kind === 'process-exit') {
126
+ return new Error(`Playwright MCP process exited before the browser connection was established${input.exitCode == null ? '' : ` (code ${input.exitCode})`}.` +
127
+ suffix);
128
+ }
129
+ return new Error(input.rawMessage ?? 'Failed to connect to browser');
130
+ }
131
+ function inferConnectFailureKind(args) {
132
+ const haystack = `${args.rawMessage ?? ''}\n${args.stderr}`.toLowerCase();
133
+ if (args.mode === 'extension' && !args.hasExtensionToken)
134
+ return 'missing-token';
135
+ if (haystack.includes('extension connection timeout') || haystack.includes('playwright mcp bridge'))
136
+ return 'extension-not-installed';
137
+ if (args.rawMessage?.startsWith('MCP init failed:'))
138
+ return 'mcp-init';
139
+ if (args.exited)
140
+ return 'process-exit';
141
+ if (args.mode === 'extension')
142
+ return 'extension-timeout';
143
+ if (args.mode === 'cdp')
144
+ return 'cdp-timeout';
145
+ return 'unknown';
146
+ }
23
147
  // JSON-RPC helpers
24
148
  let _nextId = 1;
25
149
  function jsonRpcRequest(method, params = {}) {
@@ -227,6 +351,33 @@ export class Page {
227
351
  * Playwright MCP process manager.
228
352
  */
229
353
  export class PlaywrightMCP {
354
+ static _activeInsts = new Set();
355
+ static _cleanupRegistered = false;
356
+ static _registerGlobalCleanup() {
357
+ if (this._cleanupRegistered)
358
+ return;
359
+ this._cleanupRegistered = true;
360
+ const cleanup = () => {
361
+ for (const inst of this._activeInsts) {
362
+ if (inst._lockAcquired) {
363
+ try {
364
+ fs.rmdirSync(LOCK_DIR);
365
+ }
366
+ catch { }
367
+ inst._lockAcquired = false;
368
+ }
369
+ if (inst._proc && !inst._proc.killed) {
370
+ try {
371
+ inst._proc.kill('SIGKILL');
372
+ }
373
+ catch { }
374
+ }
375
+ }
376
+ };
377
+ process.on('exit', cleanup);
378
+ process.on('SIGINT', () => { cleanup(); process.exit(130); });
379
+ process.on('SIGTERM', () => { cleanup(); process.exit(143); });
380
+ }
230
381
  _proc = null;
231
382
  _buffer = '';
232
383
  _waiters = [];
@@ -239,15 +390,80 @@ export class PlaywrightMCP {
239
390
  const mcpPath = findMcpServerPath();
240
391
  if (!mcpPath)
241
392
  throw new Error('Playwright MCP server not found. Install: npm install -D @playwright/mcp');
393
+ // Connection priority:
394
+ // 1. OPENCLI_CDP_ENDPOINT env var → explicit CDP endpoint
395
+ // 2. OPENCLI_USE_CDP=1 → auto-discover via DevToolsActivePort
396
+ // 3. Default → --extension mode (Playwright MCP Bridge)
397
+ // Some anti-bot sites (e.g. BOSS Zhipin) detect CDP — use forceExtension to bypass.
398
+ const forceExt = opts.forceExtension || process.env.OPENCLI_FORCE_EXTENSION === '1';
399
+ let cdpEndpoint = null;
400
+ if (!forceExt) {
401
+ if (process.env.OPENCLI_CDP_ENDPOINT) {
402
+ cdpEndpoint = process.env.OPENCLI_CDP_ENDPOINT;
403
+ }
404
+ else if (process.env.OPENCLI_USE_CDP === '1') {
405
+ cdpEndpoint = await discoverChromeEndpoint();
406
+ }
407
+ }
242
408
  return new Promise((resolve, reject) => {
243
- const timer = setTimeout(() => reject(new Error(`Timed out connecting to browser (${timeout}s)`)), timeout * 1000);
244
- const mcpArgs = [mcpPath, '--extension'];
409
+ const isDebug = process.env.DEBUG?.includes('opencli:mcp');
410
+ const debugLog = (msg) => isDebug && console.error(`[opencli:mcp] ${msg}`);
411
+ const mode = cdpEndpoint ? 'cdp' : 'extension';
412
+ const extensionToken = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
413
+ const tokenFingerprint = getTokenFingerprint(extensionToken);
414
+ let stderrBuffer = '';
415
+ let settled = false;
416
+ const settleError = (kind, extra = {}) => {
417
+ if (settled)
418
+ return;
419
+ settled = true;
420
+ clearTimeout(timer);
421
+ reject(formatBrowserConnectError({
422
+ kind,
423
+ mode,
424
+ timeout,
425
+ hasExtensionToken: !!extensionToken,
426
+ tokenFingerprint,
427
+ stderr: stderrBuffer,
428
+ exitCode: extra.exitCode,
429
+ rawMessage: extra.rawMessage,
430
+ }));
431
+ };
432
+ const settleSuccess = (pageToResolve) => {
433
+ if (settled)
434
+ return;
435
+ settled = true;
436
+ clearTimeout(timer);
437
+ resolve(pageToResolve);
438
+ };
439
+ const timer = setTimeout(() => {
440
+ debugLog('Connection timed out');
441
+ settleError(inferConnectFailureKind({
442
+ mode,
443
+ hasExtensionToken: !!extensionToken,
444
+ stderr: stderrBuffer,
445
+ }));
446
+ }, timeout * 1000);
447
+ const mcpArgs = [mcpPath];
448
+ if (cdpEndpoint) {
449
+ mcpArgs.push('--cdp-endpoint', cdpEndpoint);
450
+ }
451
+ else {
452
+ mcpArgs.push('--extension');
453
+ }
454
+ if (process.env.OPENCLI_VERBOSE) {
455
+ console.error(`[opencli] CDP mode: ${cdpEndpoint ? `auto-discovered ${cdpEndpoint}` : 'fallback to --extension'}`);
456
+ if (mode === 'extension') {
457
+ console.error(`[opencli] Extension token: ${extensionToken ? `configured (fingerprint ${tokenFingerprint})` : 'missing'}`);
458
+ }
459
+ }
245
460
  if (process.env.OPENCLI_BROWSER_EXECUTABLE_PATH) {
246
461
  mcpArgs.push('--executablePath', process.env.OPENCLI_BROWSER_EXECUTABLE_PATH);
247
462
  }
463
+ debugLog(`Spawning node ${mcpArgs.join(' ')}`);
248
464
  this._proc = spawn('node', mcpArgs, {
249
465
  stdio: ['pipe', 'pipe', 'pipe'],
250
- env: { ...process.env, ...(process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN ? { PLAYWRIGHT_MCP_EXTENSION_TOKEN: process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN } : {}) },
466
+ env: { ...process.env },
251
467
  });
252
468
  // Increase max listeners to avoid warnings
253
469
  this._proc.setMaxListeners(20);
@@ -263,45 +479,82 @@ export class PlaywrightMCP {
263
479
  for (const line of lines) {
264
480
  if (!line.trim())
265
481
  continue;
482
+ debugLog(`RECV: ${line}`);
266
483
  try {
267
484
  const parsed = JSON.parse(line);
268
485
  const waiter = this._waiters.shift();
269
486
  if (waiter)
270
487
  waiter(parsed);
271
488
  }
272
- catch { }
489
+ catch (e) {
490
+ debugLog(`Parse error: ${e}`);
491
+ }
492
+ }
493
+ });
494
+ this._proc.stderr?.on('data', (chunk) => {
495
+ const text = chunk.toString();
496
+ stderrBuffer += text;
497
+ debugLog(`STDERR: ${text}`);
498
+ });
499
+ this._proc.on('error', (err) => {
500
+ debugLog(`Subprocess error: ${err.message}`);
501
+ settleError('process-exit', { rawMessage: err.message });
502
+ });
503
+ this._proc.on('close', (code) => {
504
+ debugLog(`Subprocess closed with code ${code}`);
505
+ if (!settled) {
506
+ settleError(inferConnectFailureKind({
507
+ mode,
508
+ hasExtensionToken: !!extensionToken,
509
+ stderr: stderrBuffer,
510
+ exited: true,
511
+ }), { exitCode: code });
273
512
  }
274
513
  });
275
- this._proc.stderr?.on('data', () => { });
276
- this._proc.on('error', (err) => { clearTimeout(timer); reject(err); });
277
514
  // Initialize: send initialize request
278
515
  const initMsg = jsonRpcRequest('initialize', {
279
516
  protocolVersion: '2024-11-05',
280
517
  capabilities: {},
281
518
  clientInfo: { name: 'opencli', version: PKG_VERSION },
282
519
  });
520
+ debugLog(`SEND: ${initMsg.trim()}`);
283
521
  this._proc.stdin?.write(initMsg);
284
522
  // Wait for initialize response, then send initialized notification
285
523
  const origRecv = () => new Promise((res) => { this._waiters.push(res); });
524
+ debugLog('Waiting for initialize response...');
286
525
  origRecv().then((resp) => {
526
+ debugLog('Got initialize response');
287
527
  if (resp.error) {
288
- clearTimeout(timer);
289
- reject(new Error(`MCP init failed: ${resp.error.message}`));
528
+ settleError(inferConnectFailureKind({
529
+ mode,
530
+ hasExtensionToken: !!extensionToken,
531
+ stderr: stderrBuffer,
532
+ rawMessage: `MCP init failed: ${resp.error.message}`,
533
+ }), { rawMessage: resp.error.message });
290
534
  return;
291
535
  }
292
- this._proc?.stdin?.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n');
536
+ const initializedMsg = JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n';
537
+ debugLog(`SEND: ${initializedMsg.trim()}`);
538
+ this._proc?.stdin?.write(initializedMsg);
293
539
  // Get initial tab count for cleanup
540
+ debugLog('Fetching initial tabs count...');
294
541
  page.tabs().then((tabs) => {
542
+ debugLog(`Tabs response: ${typeof tabs === 'string' ? tabs : JSON.stringify(tabs)}`);
295
543
  if (typeof tabs === 'string') {
296
544
  this._initialTabCount = (tabs.match(/Tab \d+/g) || []).length;
297
545
  }
298
546
  else if (Array.isArray(tabs)) {
299
547
  this._initialTabCount = tabs.length;
300
548
  }
301
- clearTimeout(timer);
302
- resolve(page);
303
- }).catch(() => { clearTimeout(timer); resolve(page); });
304
- }).catch((err) => { clearTimeout(timer); reject(err); });
549
+ settleSuccess(page);
550
+ }).catch((err) => {
551
+ debugLog(`Tabs fetch error: ${err.message}`);
552
+ settleSuccess(page);
553
+ });
554
+ }).catch((err) => {
555
+ debugLog(`Init promise rejected: ${err.message}`);
556
+ settleError('mcp-init', { rawMessage: err.message });
557
+ });
305
558
  });
306
559
  }
307
560
  async close() {
@@ -334,6 +587,7 @@ export class PlaywrightMCP {
334
587
  finally {
335
588
  this._page = null;
336
589
  this._releaseLock();
590
+ PlaywrightMCP._activeInsts.delete(this);
337
591
  }
338
592
  }
339
593
  async _acquireLock() {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { formatBrowserConnectError, getTokenFingerprint } from './browser.js';
3
+ describe('getTokenFingerprint', () => {
4
+ it('returns null for empty token', () => {
5
+ expect(getTokenFingerprint(undefined)).toBeNull();
6
+ });
7
+ it('returns stable short fingerprint for token', () => {
8
+ expect(getTokenFingerprint('abc123')).toBe('6ca13d52');
9
+ });
10
+ });
11
+ describe('formatBrowserConnectError', () => {
12
+ it('explains missing extension token clearly', () => {
13
+ const err = formatBrowserConnectError({
14
+ kind: 'missing-token',
15
+ mode: 'extension',
16
+ timeout: 30,
17
+ hasExtensionToken: false,
18
+ });
19
+ expect(err.message).toContain('PLAYWRIGHT_MCP_EXTENSION_TOKEN is not set');
20
+ expect(err.message).toContain('manual approval dialog');
21
+ });
22
+ it('mentions token mismatch as likely cause for extension timeout', () => {
23
+ const err = formatBrowserConnectError({
24
+ kind: 'extension-timeout',
25
+ mode: 'extension',
26
+ timeout: 30,
27
+ hasExtensionToken: true,
28
+ tokenFingerprint: 'deadbeef',
29
+ });
30
+ expect(err.message).toContain('does not match the token currently shown by the browser extension');
31
+ expect(err.message).toContain('deadbeef');
32
+ });
33
+ it('keeps CDP timeout guidance separate', () => {
34
+ const err = formatBrowserConnectError({
35
+ kind: 'cdp-timeout',
36
+ mode: 'cdp',
37
+ timeout: 30,
38
+ hasExtensionToken: false,
39
+ });
40
+ expect(err.message).toContain('via CDP');
41
+ expect(err.message).toContain('chrome://inspect#remote-debugging');
42
+ });
43
+ });
@@ -58,10 +58,10 @@ function scanYaml(filePath, site) {
58
58
  }
59
59
  function scanTs(filePath, site) {
60
60
  // TS adapters self-register via cli() at import time.
61
- // We record their module path for lazy dynamic import.
61
+ // We statically parse the source to extract metadata for the manifest stub.
62
62
  const baseName = path.basename(filePath, path.extname(filePath));
63
63
  const relativePath = `${site}/${baseName}.js`;
64
- return {
64
+ const entry = {
65
65
  site,
66
66
  name: baseName,
67
67
  description: '',
@@ -71,6 +71,70 @@ function scanTs(filePath, site) {
71
71
  type: 'ts',
72
72
  modulePath: relativePath,
73
73
  };
74
+ try {
75
+ const src = fs.readFileSync(filePath, 'utf-8');
76
+ // Extract description
77
+ const descMatch = src.match(/description\s*:\s*['"`]([^'"`]*)['"`]/);
78
+ if (descMatch)
79
+ entry.description = descMatch[1];
80
+ // Extract domain
81
+ const domainMatch = src.match(/domain\s*:\s*['"`]([^'"`]*)['"`]/);
82
+ if (domainMatch)
83
+ entry.domain = domainMatch[1];
84
+ // Extract strategy
85
+ const stratMatch = src.match(/strategy\s*:\s*Strategy\.(\w+)/);
86
+ if (stratMatch)
87
+ entry.strategy = stratMatch[1].toLowerCase();
88
+ // Extract browser: false (some adapters bypass browser entirely)
89
+ const browserMatch = src.match(/browser\s*:\s*(true|false)/);
90
+ if (browserMatch)
91
+ entry.browser = browserMatch[1] === 'true';
92
+ // Extract columns
93
+ const colMatch = src.match(/columns\s*:\s*\[([^\]]*)\]/);
94
+ if (colMatch) {
95
+ entry.columns = colMatch[1].split(',').map(s => s.trim().replace(/^['"`]|['"`]$/g, '')).filter(Boolean);
96
+ }
97
+ // Extract args array items: { name: '...', ... }
98
+ const argsBlockMatch = src.match(/args\s*:\s*\[([\s\S]*?)\]\s*,/);
99
+ if (argsBlockMatch) {
100
+ const argsBlock = argsBlockMatch[1];
101
+ const argRegex = /\{\s*name\s*:\s*['"`](\w+)['"`]([^}]*)\}/g;
102
+ let m;
103
+ while ((m = argRegex.exec(argsBlock)) !== null) {
104
+ const argName = m[1];
105
+ const body = m[2];
106
+ const typeMatch = body.match(/type\s*:\s*['"`](\w+)['"`]/);
107
+ const defaultMatch = body.match(/default\s*:\s*([^,}]+)/);
108
+ const requiredMatch = body.match(/required\s*:\s*(true|false)/);
109
+ const helpMatch = body.match(/help\s*:\s*['"`]([^'"`]*)['"`]/);
110
+ let defaultVal = undefined;
111
+ if (defaultMatch) {
112
+ const raw = defaultMatch[1].trim();
113
+ if (raw === 'true')
114
+ defaultVal = true;
115
+ else if (raw === 'false')
116
+ defaultVal = false;
117
+ else if (/^\d+$/.test(raw))
118
+ defaultVal = parseInt(raw, 10);
119
+ else if (/^\d+\.\d+$/.test(raw))
120
+ defaultVal = parseFloat(raw);
121
+ else
122
+ defaultVal = raw.replace(/^['"`]|['"`]$/g, '');
123
+ }
124
+ entry.args.push({
125
+ name: argName,
126
+ type: typeMatch?.[1] ?? 'str',
127
+ default: defaultVal,
128
+ required: requiredMatch?.[1] === 'true',
129
+ help: helpMatch?.[1] ?? '',
130
+ });
131
+ }
132
+ }
133
+ }
134
+ catch {
135
+ // If parsing fails, fall back to empty metadata — module will self-register at runtime
136
+ }
137
+ return entry;
74
138
  }
75
139
  // Main
76
140
  const manifest = [];