@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.
- package/CLI-CREATOR.md +10 -10
- package/LICENSE +28 -0
- package/README.md +113 -63
- package/README.zh-CN.md +114 -63
- package/SKILL.md +21 -4
- package/dist/browser.d.ts +21 -2
- package/dist/browser.js +269 -15
- package/dist/browser.test.d.ts +1 -0
- package/dist/browser.test.js +43 -0
- package/dist/build-manifest.js +4 -0
- package/dist/cli-manifest.json +279 -3
- package/dist/clis/boss/search.js +186 -30
- package/dist/clis/twitter/delete.d.ts +1 -0
- package/dist/clis/twitter/delete.js +73 -0
- package/dist/clis/twitter/followers.d.ts +1 -0
- package/dist/clis/twitter/followers.js +104 -0
- package/dist/clis/twitter/following.d.ts +1 -0
- package/dist/clis/twitter/following.js +90 -0
- package/dist/clis/twitter/like.d.ts +1 -0
- package/dist/clis/twitter/like.js +69 -0
- package/dist/clis/twitter/notifications.d.ts +1 -0
- package/dist/clis/twitter/notifications.js +109 -0
- package/dist/clis/twitter/post.d.ts +1 -0
- package/dist/clis/twitter/post.js +63 -0
- package/dist/clis/twitter/reply.d.ts +1 -0
- package/dist/clis/twitter/reply.js +57 -0
- package/dist/clis/v2ex/daily.d.ts +1 -0
- package/dist/clis/v2ex/daily.js +98 -0
- package/dist/clis/v2ex/me.d.ts +1 -0
- package/dist/clis/v2ex/me.js +99 -0
- package/dist/clis/v2ex/notifications.d.ts +1 -0
- package/dist/clis/v2ex/notifications.js +72 -0
- package/dist/doctor.d.ts +50 -0
- package/dist/doctor.js +372 -0
- package/dist/doctor.test.d.ts +1 -0
- package/dist/doctor.test.js +114 -0
- package/dist/main.js +47 -5
- package/dist/output.test.d.ts +1 -0
- package/dist/output.test.js +20 -0
- package/dist/registry.d.ts +4 -0
- package/dist/registry.js +1 -0
- package/dist/runtime.d.ts +3 -1
- package/dist/runtime.js +2 -2
- package/package.json +2 -2
- package/src/browser.test.ts +51 -0
- package/src/browser.ts +318 -22
- package/src/build-manifest.ts +4 -0
- package/src/clis/boss/search.ts +196 -29
- package/src/clis/twitter/delete.ts +78 -0
- package/src/clis/twitter/followers.ts +119 -0
- package/src/clis/twitter/following.ts +105 -0
- package/src/clis/twitter/like.ts +74 -0
- package/src/clis/twitter/notifications.ts +119 -0
- package/src/clis/twitter/post.ts +68 -0
- package/src/clis/twitter/reply.ts +62 -0
- package/src/clis/v2ex/daily.ts +105 -0
- package/src/clis/v2ex/me.ts +103 -0
- package/src/clis/v2ex/notifications.ts +77 -0
- package/src/doctor.test.ts +133 -0
- package/src/doctor.ts +424 -0
- package/src/main.ts +47 -4
- package/src/output.test.ts +27 -0
- package/src/registry.ts +5 -0
- 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.
|
|
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)
|
|
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
|
|
3
|
-
* Connects to an existing Chrome browser through
|
|
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
|
|
3
|
-
* Connects to an existing Chrome browser through
|
|
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
|
|
244
|
-
const
|
|
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
|
|
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
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
+
});
|
package/dist/build-manifest.js
CHANGED
|
@@ -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) {
|