@jackwener/opencli 0.4.2 → 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 (64) hide show
  1. package/CLI-CREATOR.md +10 -10
  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 +4 -0
  11. package/dist/cli-manifest.json +279 -3
  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/doctor.d.ts +50 -0
  34. package/dist/doctor.js +372 -0
  35. package/dist/doctor.test.d.ts +1 -0
  36. package/dist/doctor.test.js +114 -0
  37. package/dist/main.js +47 -5
  38. package/dist/output.test.d.ts +1 -0
  39. package/dist/output.test.js +20 -0
  40. package/dist/registry.d.ts +4 -0
  41. package/dist/registry.js +1 -0
  42. package/dist/runtime.d.ts +3 -1
  43. package/dist/runtime.js +2 -2
  44. package/package.json +2 -2
  45. package/src/browser.test.ts +51 -0
  46. package/src/browser.ts +318 -22
  47. package/src/build-manifest.ts +4 -0
  48. package/src/clis/boss/search.ts +196 -29
  49. package/src/clis/twitter/delete.ts +78 -0
  50. package/src/clis/twitter/followers.ts +119 -0
  51. package/src/clis/twitter/following.ts +105 -0
  52. package/src/clis/twitter/like.ts +74 -0
  53. package/src/clis/twitter/notifications.ts +119 -0
  54. package/src/clis/twitter/post.ts +68 -0
  55. package/src/clis/twitter/reply.ts +62 -0
  56. package/src/clis/v2ex/daily.ts +105 -0
  57. package/src/clis/v2ex/me.ts +103 -0
  58. package/src/clis/v2ex/notifications.ts +77 -0
  59. package/src/doctor.test.ts +133 -0
  60. package/src/doctor.ts +424 -0
  61. package/src/main.ts +47 -4
  62. package/src/output.test.ts +27 -0
  63. package/src/registry.ts +5 -0
  64. package/src/runtime.ts +2 -1
package/SKILL.md CHANGED
@@ -10,6 +10,11 @@ tags: [cli, browser, web, mcp, playwright, bilibili, zhihu, twitter, github, v2e
10
10
 
11
11
  > Make any website your CLI. Reuse Chrome login, zero risk, AI-powered discovery.
12
12
 
13
+ > [!CAUTION]
14
+ > **AI Agent 必读:创建或修改任何适配器之前,你必须先阅读 [CLI-CREATOR.md](./CLI-CREATOR.md)!**
15
+ > 该文档包含完整的 API 发现工作流(必须使用 Playwright MCP Bridge 浏览器探索)、5 级认证策略决策树、平台 SDK 速查表、`tap` 步骤调试流程、分页 API 模板、级联请求模式、以及常见陷阱。
16
+ > **本文件(SKILL.md)仅提供命令参考和简化模板,不足以正确开发适配器。**
17
+
13
18
  ## Install & Run
14
19
 
15
20
  ```bash
@@ -29,8 +34,8 @@ npm update -g @jackwener/opencli
29
34
 
30
35
  Browser commands require:
31
36
  1. Chrome browser running **(logged into target sites)**
32
- 2. [Playwright MCP Bridge](https://chromewebstore.google.com/detail/playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm) extension
33
- 3. Configure `PLAYWRIGHT_MCP_EXTENSION_TOKEN` (from extension settings) in your MCP config
37
+ 2. [Playwright MCP Bridge](https://chromewebstore.google.com/detail/playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm) extension (default connection mode)
38
+ 3. **Alternative**: Chrome 144+ CDP auto-discovery — set `OPENCLI_USE_CDP=1` (no extension needed)
34
39
 
35
40
  > **Note**: You must be logged into the target website in Chrome before running commands. Tabs opened during command execution are auto-closed afterwards.
36
41
 
@@ -129,6 +134,7 @@ opencli ctrip search --query "三亚" # 搜索目的地
129
134
  ```bash
130
135
  opencli list # List all commands
131
136
  opencli list --json # JSON output
137
+ opencli list -f yaml # YAML output
132
138
  opencli validate # Validate all CLI definitions
133
139
  opencli validate bilibili # Validate specific site
134
140
  ```
@@ -157,11 +163,14 @@ opencli verify <site/name> --smoke
157
163
 
158
164
  ## Output Formats
159
165
 
160
- All commands support `--format` / `-f`:
166
+ All built-in commands support `--format` / `-f` with `table`, `json`, `yaml`, `md`, and `csv`.
167
+ The `list` command supports the same formats and also keeps `--json` as a compatibility alias.
161
168
 
162
169
  ```bash
170
+ opencli list -f yaml # YAML command registry
163
171
  opencli bilibili hot -f table # Default: rich table
164
172
  opencli bilibili hot -f json # JSON (pipe to jq, feed to AI agent)
173
+ opencli bilibili hot -f yaml # YAML (readable structured output)
165
174
  opencli bilibili hot -f md # Markdown
166
175
  opencli bilibili hot -f csv # CSV
167
176
  ```
@@ -174,6 +183,11 @@ opencli bilibili hot -v # Show each pipeline step and data flow
174
183
 
175
184
  ## Creating Adapters
176
185
 
186
+ > [!IMPORTANT]
187
+ > **STOP — 在写任何代码之前,先阅读 [CLI-CREATOR.md](./CLI-CREATOR.md)。**
188
+ > 它包含:① AI Agent 浏览器探索工作流(必须用 Playwright MCP 抓包验证 API)② 认证策略决策树 ③ 平台 SDK(如 Bilibili 的 `apiGet`/`fetchJson`)④ YAML vs TS 选择指南 ⑤ `tap` 步骤调试方法 ⑥ 级联请求模板 ⑦ 常见陷阱表。
189
+ > **下方仅为简化模板参考,直接使用极易踩坑。**
190
+
177
191
  ### YAML Pipeline (declarative, recommended)
178
192
 
179
193
  Create `src/clis/<site>/<name>.yaml`:
@@ -322,6 +336,9 @@ ${{ index + 1 }}
322
336
  | `OPENCLI_BROWSER_COMMAND_TIMEOUT` | 45 | Command execution timeout (sec) |
323
337
  | `OPENCLI_BROWSER_EXPLORE_TIMEOUT` | 120 | Explore timeout (sec) |
324
338
  | `OPENCLI_EXTENSION_LOCK_TIMEOUT` | 120 | Extension lock timeout (sec) |
339
+ | `OPENCLI_CDP_ENDPOINT` | — | Manual CDP WebSocket endpoint (overrides auto-discovery) |
340
+ | `OPENCLI_USE_CDP` | — | Set to `1` to use Chrome 144+ CDP auto-discovery instead of extension |
341
+ | `OPENCLI_FORCE_EXTENSION` | — | Set to `1` to skip CDP and force extension mode |
325
342
  | `PLAYWRIGHT_MCP_EXTENSION_TOKEN` | — | Auto-approve extension connection |
326
343
 
327
344
  ## Troubleshooting
@@ -329,7 +346,7 @@ ${{ index + 1 }}
329
346
  | Issue | Solution |
330
347
  |-------|----------|
331
348
  | `npx not found` | Install Node.js: `brew install node` |
332
- | `Timed out connecting to browser` | 1) Chrome must be open 2) Install MCP Bridge extension 3) Click to approve |
349
+ | `Timed out connecting to browser` | 1) Chrome must be open 2) Enable remote debugging at `chrome://inspect#remote-debugging` or install MCP Bridge extension |
333
350
  | `Extension lock timed out` | Another opencli command is running; browser commands run serially |
334
351
  | `Target page context` error | Add `navigate:` step before `evaluate:` in YAML |
335
352
  | Empty table data | Check if evaluate returns JSON string (MCP parsing) or data path is wrong |
package/dist/browser.d.ts CHANGED
@@ -1,7 +1,21 @@
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
+ export declare function discoverChromeEndpoint(): Promise<string | null>;
6
+ type ConnectFailureKind = 'missing-token' | 'extension-timeout' | 'extension-not-installed' | 'cdp-timeout' | 'mcp-init' | 'process-exit' | 'unknown';
7
+ type ConnectFailureInput = {
8
+ kind: ConnectFailureKind;
9
+ mode: 'extension' | 'cdp';
10
+ timeout: number;
11
+ hasExtensionToken: boolean;
12
+ tokenFingerprint?: string | null;
13
+ stderr?: string;
14
+ exitCode?: number | null;
15
+ rawMessage?: string;
16
+ };
17
+ export declare function getTokenFingerprint(token: string | undefined): string | null;
18
+ export declare function formatBrowserConnectError(input: ConnectFailureInput): Error;
5
19
  import type { IPage } from './types.js';
6
20
  /**
7
21
  * Page abstraction wrapping JSON-RPC calls to Playwright MCP.
@@ -46,6 +60,9 @@ export declare class Page implements IPage {
46
60
  * Playwright MCP process manager.
47
61
  */
48
62
  export declare class PlaywrightMCP {
63
+ private static _activeInsts;
64
+ private static _cleanupRegistered;
65
+ private static _registerGlobalCleanup;
49
66
  private _proc;
50
67
  private _buffer;
51
68
  private _waiters;
@@ -54,8 +71,10 @@ export declare class PlaywrightMCP {
54
71
  private _page;
55
72
  connect(opts?: {
56
73
  timeout?: number;
74
+ forceExtension?: boolean;
57
75
  }): Promise<Page>;
58
76
  close(): Promise<void>;
59
77
  private _acquireLock;
60
78
  private _releaseLock;
61
79
  }
80
+ export {};
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
+ });
@@ -85,6 +85,10 @@ function scanTs(filePath, site) {
85
85
  const stratMatch = src.match(/strategy\s*:\s*Strategy\.(\w+)/);
86
86
  if (stratMatch)
87
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';
88
92
  // Extract columns
89
93
  const colMatch = src.match(/columns\s*:\s*\[([^\]]*)\]/);
90
94
  if (colMatch) {