@jackwener/opencli 0.7.11 → 0.8.0

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/CDP.md ADDED
@@ -0,0 +1,103 @@
1
+ # Connecting OpenCLI via CDP (Remote/Headless Servers)
2
+
3
+ If you cannot use the Playwright MCP Bridge extension (e.g., in a remote headless server environment without a UI), OpenCLI provides an alternative: connecting directly to Chrome via **CDP (Chrome DevTools Protocol)**.
4
+
5
+ Because CDP binds to `localhost` by default for security reasons, accessing it from a remote server requires an additional networking tunnel.
6
+
7
+ This guide is broken down into three phases:
8
+ 1. **Preparation**: Start Chrome with CDP enabled locally.
9
+ 2. **Network Tunnels**: Expose that CDP port to your remote server using either **SSH Tunnels** or **Reverse Proxies**.
10
+ 3. **Execution**: Run OpenCLI on your server.
11
+
12
+ ---
13
+
14
+ ## Phase 1: Preparation (Local Machine)
15
+
16
+ First, you need to start a Chrome browser on your local machine with remote debugging enabled.
17
+
18
+ **macOS:**
19
+ ```bash
20
+ /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
21
+ --remote-debugging-port=9222 \
22
+ --user-data-dir="$HOME/chrome-debug-profile" \
23
+ --remote-allow-origins="*"
24
+ ```
25
+
26
+ **Linux:**
27
+ ```bash
28
+ google-chrome \
29
+ --remote-debugging-port=9222 \
30
+ --user-data-dir="$HOME/chrome-debug-profile" \
31
+ --remote-allow-origins="*"
32
+ ```
33
+
34
+ **Windows:**
35
+ ```cmd
36
+ "C:\Program Files\Google\Chrome\Application\chrome.exe" ^
37
+ --remote-debugging-port=9222 ^
38
+ --user-data-dir="%USERPROFILE%\chrome-debug-profile" ^
39
+ --remote-allow-origins="*"
40
+ ```
41
+
42
+ > **Note**: The `--remote-allow-origins="*"` flag is often required for modern Chrome versions to accept cross-origin CDP WebSocket connections (e.g. from reverse proxies like ngrok).
43
+
44
+ Once this browser instance opens, **log into the target websites you want to use** (e.g., bilibili.com, zhihu.com) so that the session contains the correct cookies.
45
+
46
+ ---
47
+
48
+ ## Phase 2: Remote Access Methods
49
+
50
+ Once CDP is running locally on port `9222`, you must securely expose this port to your remote server. Choose one of the two methods below depending on your network conditions.
51
+
52
+ ### Method A: SSH Tunnel (Recommended)
53
+
54
+ If your local machine has SSH access to the remote server, this is the most secure and straightforward method.
55
+
56
+ Run this command on your **Local Machine** to forward the remote server's port `9222` back to your local port `9222`:
57
+
58
+ ```bash
59
+ ssh -R 9222:localhost:9222 your-server-user@your-server-ip
60
+ ```
61
+
62
+ Leave this SSH session running in the background.
63
+
64
+ ### Method B: Reverse Proxy (ngrok / frp / socat)
65
+
66
+ If you cannot establish a direct SSH connection (e.g., due to NAT or firewalls), you can use an intranet penetration tool like `ngrok`.
67
+
68
+ Run this command on your **Local Machine** to expose your local port `9222` to the public internet securely via ngrok:
69
+
70
+ ```bash
71
+ ngrok http 9222
72
+ ```
73
+
74
+ This will print a forwarding URL, such as `https://abcdef.ngrok.app`. **Copy this URL**.
75
+
76
+ ---
77
+
78
+ ## Phase 3: Execution (Remote Server)
79
+
80
+ Now switch to your **Remote Server** where OpenCLI is installed.
81
+
82
+ Depending on the network tunnel method you chose in Phase 2, set the `OPENCLI_CDP_ENDPOINT` environment variable and run your commands.
83
+
84
+ ### If you used Method A (SSH Tunnel):
85
+
86
+ ```bash
87
+ export OPENCLI_CDP_ENDPOINT="http://localhost:9222"
88
+ opencli doctor # Verify connection
89
+ opencli bilibili hot --limit 5 # Test a command
90
+ ```
91
+
92
+ ### If you used Method B (Reverse Proxy like ngrok):
93
+
94
+ ```bash
95
+ # Use the URL you copied from ngrok earlier
96
+ export OPENCLI_CDP_ENDPOINT="https://abcdef.ngrok.app"
97
+ opencli doctor # Verify connection
98
+ opencli bilibili hot --limit 5 # Test a command
99
+ ```
100
+
101
+ > *Tip: OpenCLI automatically requests the `/json/version` HTTP endpoint to discover the underlying WebSocket URL if you provide a standard HTTP/HTTPS address.*
102
+
103
+ If you plan to use this setup frequently, you can persist the environment variable by adding the `export` line to your `~/.bashrc` or `~/.zshrc` on the server.
package/CDP.zh-CN.md ADDED
@@ -0,0 +1,103 @@
1
+ # 通过 CDP 远程连接 OpenCLI (服务器/无头环境)
2
+
3
+ 如果你无法使用 Playwright MCP Bridge 浏览器扩展(例如:在无界面的远程服务器上运行 OpenCLI 时),OpenCLI 提供了备选方案:通过连接 **CDP (Chrome DevTools Protocol,即 Chrome 开发者工具协议)** 来直接控制本地 Chrome。
4
+
5
+ 出于安全考虑,CDP 默认仅绑定在 `localhost` 的本地端口。所以,若是想让**远程服务器**调用本地的 CDP 服务,我们需要依靠一层额外的网络隧道。
6
+
7
+ 本指南将整个过程拆分为三个阶段:
8
+ 1. **阶段一:准备工作**(在本地启动允许 CDP 调试的 Chrome)。
9
+ 2. **阶段二:建立网络隧道**(通过 **SSH反向隧道** 或 **反向代理工具**,将本地的 CDP 端口暴露给服务器)。
10
+ 3. **阶段三:执行命令**(在服务器端运行 OpenCLI)。
11
+
12
+ ---
13
+
14
+ ## 阶段一:准备工作 (本地电脑)
15
+
16
+ 首先,你需要在你的本地电脑上,通过命令行参数启动一个开启了远程调试端口的 Chrome 实例。
17
+
18
+ **macOS:**
19
+ ```bash
20
+ /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
21
+ --remote-debugging-port=9222 \
22
+ --user-data-dir="$HOME/chrome-debug-profile" \
23
+ --remote-allow-origins="*"
24
+ ```
25
+
26
+ **Linux:**
27
+ ```bash
28
+ google-chrome \
29
+ --remote-debugging-port=9222 \
30
+ --user-data-dir="$HOME/chrome-debug-profile" \
31
+ --remote-allow-origins="*"
32
+ ```
33
+
34
+ **Windows:**
35
+ ```cmd
36
+ "C:\Program Files\Google\Chrome\Application\chrome.exe" ^
37
+ --remote-debugging-port=9222 ^
38
+ --user-data-dir="%USERPROFILE%\chrome-debug-profile" ^
39
+ --remote-allow-origins="*"
40
+ ```
41
+
42
+ > **注意**:此处增加的 `--remote-allow-origins="*"` 参数对于较新版本的 Chrome 来说通常是[必需的],以允许来自反向代理(如 ngrok)的跨域 WebSocket 连接请求。
43
+
44
+ 待这个新的浏览器实例打开后,**手工登录那些你打算使用的网站**(如 bilibili.com、zhihu.com 等),这可以让该浏览器的运行资料(Profile)保留上这些网站登录用的 Cookie。
45
+
46
+ ---
47
+
48
+ ## 阶段二:建立网络隧道
49
+
50
+ 现在你的本地已经有了一个监听在 `9222` 端口的 CDP 服务,接下来,选择以下任意一种方式将其实际暴露给你的远端服务器。
51
+
52
+ ### 方法 A:SSH 反向端口转发 (推荐)
53
+
54
+ 如果你的本地电脑可以直连远程服务器的 SSH,那么这是最简单且最安全的做法。
55
+
56
+ 在你的 **本地电脑** 终端上直接运行这条 ssh 命令,将远程服务器的 `9222` 端口反向映射回本地的 `9222` 端口:
57
+
58
+ ```bash
59
+ ssh -R 9222:localhost:9222 your-server-user@your-server-ip
60
+ ```
61
+
62
+ 保持此 SSH 会话在后台运行即可。
63
+
64
+ ### 方法 B:反向代理 / 内网穿透 (ngrok / frp / socat)
65
+
66
+ 如果因为 NAT 或防火墙等因素导致无法直连 SSH 服务器,你可以使用 `ngrok` 等内网穿透工具。
67
+
68
+ 在 **本地电脑** 运行 ngrok 将本地的 `9222` 端口暴露到公网:
69
+
70
+ ```bash
71
+ ngrok http 9222
72
+ ```
73
+
74
+ 此时终端里会打印出一段专属的转发 URL 地址(如:`https://abcdef.ngrok.app`)。**复制这一段 URL 地址备用**。
75
+
76
+ ---
77
+
78
+ ## 阶段三:执行命令 (远程服务器)
79
+
80
+ 现在,所有的准备工作已结束。请切换到你已安装好 OpenCLI 的 **远程服务器** 终端上。
81
+
82
+ 根据你在上方阶段二所选择的隧道方案,在终端中配置对应的 `OPENCLI_CDP_ENDPOINT` 环境变量:
83
+
84
+ ### 若使用 方法 A (SSH 反向隧道):
85
+
86
+ ```bash
87
+ export OPENCLI_CDP_ENDPOINT="http://localhost:9222"
88
+ opencli doctor # 查看并验证连接是否通畅
89
+ opencli bilibili hot --limit 5 # 执行目标命令
90
+ ```
91
+
92
+ ### 若使用 方法 B (Ngrok 等反向代理):
93
+
94
+ ```bash
95
+ # 将刚刚使用 ngrok 得到的地址填入这里
96
+ export OPENCLI_CDP_ENDPOINT="https://abcdef.ngrok.app"
97
+ opencli doctor # 查看并验证连接是否通畅
98
+ opencli bilibili hot --limit 5 # 执行目标命令
99
+ ```
100
+
101
+ > *Tip: 如果你填写的是一个普通 HTTP/HTTPS 的 URL 地址,OpenCLI 会自动尝试抓取该地址下的 `/json/version` 节点,来动态解析并连接真正底层依赖的 WebSocket 地址。*
102
+
103
+ 如果你想在此服务器上永久启用该配置,可以将对应的 `export` 语句追加进入你的 `~/.bashrc` 或 `~/.zshrc` 配置文件中。
package/README.md CHANGED
@@ -21,6 +21,7 @@ A CLI tool that turns **any website** into a command-line interface — Bilibili
21
21
  - [Built-in Commands](#built-in-commands)
22
22
  - [Output Formats](#output-formats)
23
23
  - [For AI Agents (Developer Guide)](#for-ai-agents-developer-guide)
24
+ - [Remote Chrome (Server/Headless)](#remote-chrome-serverheadless)
24
25
  - [Testing](#testing)
25
26
  - [Troubleshooting](#troubleshooting)
26
27
  - [Releasing New Versions](#releasing-new-versions)
@@ -69,6 +70,9 @@ The interactive TUI will:
69
70
  > opencli doctor --fix -y # Fix all configs non-interactively
70
71
  > ```
71
72
 
73
+ **Alternative: CDP Mode (For Servers/Headless)**
74
+ If you cannot install the browser extension (e.g. running OpenCLI on a remote headless server), you can connect OpenCLI to your local Chrome via CDP using SSH tunnels or reverse proxies. See the [CDP Connection Guide](./CDP.md) for detailed instructions.
75
+
72
76
  <details>
73
77
  <summary>Manual setup (alternative)</summary>
74
78
 
package/README.zh-CN.md CHANGED
@@ -21,6 +21,7 @@ OpenCLI 将任何网站变成命令行工具 — B站、知乎、小红书、Twi
21
21
  - [内置命令](#内置命令)
22
22
  - [输出格式](#输出格式)
23
23
  - [致 AI Agent(开发者指南)](#致-ai-agent开发者指南)
24
+ - [远程 Chrome(服务器/无头环境)](#远程-chrome服务器无头环境)
24
25
  - [常见问题排查](#常见问题排查)
25
26
  - [版本发布](#版本发布)
26
27
  - [License](#license)
@@ -68,6 +69,9 @@ opencli setup
68
69
  > opencli doctor --fix -y # 无交互直接修复所有配置
69
70
  > ```
70
71
 
72
+ **备选方案:CDP 模式 (适用于服务器/无头环境)**
73
+ 如果你无法安装浏览器扩展(比如在远程无头服务器上运行 OpenCLI),你可以通过 SSH 隧道或反向代理,利用 CDP (Chrome DevTools Protocol) 连接到本地的 Chrome 浏览器。详细指南请参考 [CDP 连接教程](./CDP.zh-CN.md)。
74
+
71
75
  <details>
72
76
  <summary>手动配置(备选方案)</summary>
73
77
 
@@ -9,13 +9,28 @@ export declare function setMcpDiscoveryTestHooks(input?: {
9
9
  execSync?: typeof execSync;
10
10
  }): void;
11
11
  export declare function findMcpServerPath(): string | null;
12
+ /**
13
+ * Chrome 144+ auto-discovery: read DevToolsActivePort file to get CDP endpoint.
14
+ *
15
+ * Starting with Chrome 144, users can enable remote debugging from
16
+ * chrome://inspect#remote-debugging without any command-line flags.
17
+ * Chrome writes the active port and browser GUID to a DevToolsActivePort file
18
+ * in the user data directory, which we read to construct the WebSocket endpoint.
19
+ */
20
+ export declare function discoverChromeEndpoint(): string | null;
21
+ export declare function resolveCdpEndpoint(): {
22
+ endpoint?: string;
23
+ requestedCdp: boolean;
24
+ };
12
25
  export declare function buildMcpArgs(input: {
13
26
  mcpPath: string;
14
27
  executablePath?: string | null;
28
+ cdpEndpoint?: string;
15
29
  }): string[];
16
30
  export declare function buildMcpLaunchSpec(input: {
17
31
  mcpPath?: string | null;
18
32
  executablePath?: string | null;
33
+ cdpEndpoint?: string;
19
34
  }): {
20
35
  command: string;
21
36
  args: string[];
@@ -98,13 +98,79 @@ export function findMcpServerPath() {
98
98
  _cachedMcpServerPath = null;
99
99
  return _cachedMcpServerPath;
100
100
  }
101
+ /**
102
+ * Chrome 144+ auto-discovery: read DevToolsActivePort file to get CDP endpoint.
103
+ *
104
+ * Starting with Chrome 144, users can enable remote debugging from
105
+ * chrome://inspect#remote-debugging without any command-line flags.
106
+ * Chrome writes the active port and browser GUID to a DevToolsActivePort file
107
+ * in the user data directory, which we read to construct the WebSocket endpoint.
108
+ */
109
+ export function discoverChromeEndpoint() {
110
+ const candidates = [];
111
+ // User-specified Chrome data dir takes highest priority
112
+ if (process.env.CHROME_USER_DATA_DIR) {
113
+ candidates.push(path.join(process.env.CHROME_USER_DATA_DIR, 'DevToolsActivePort'));
114
+ }
115
+ // Standard Chrome/Edge user data dirs per platform
116
+ if (process.platform === 'win32') {
117
+ const localAppData = process.env.LOCALAPPDATA ?? path.join(os.homedir(), 'AppData', 'Local');
118
+ candidates.push(path.join(localAppData, 'Google', 'Chrome', 'User Data', 'DevToolsActivePort'));
119
+ candidates.push(path.join(localAppData, 'Microsoft', 'Edge', 'User Data', 'DevToolsActivePort'));
120
+ }
121
+ else if (process.platform === 'darwin') {
122
+ candidates.push(path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome', 'DevToolsActivePort'));
123
+ candidates.push(path.join(os.homedir(), 'Library', 'Application Support', 'Microsoft Edge', 'DevToolsActivePort'));
124
+ }
125
+ else {
126
+ candidates.push(path.join(os.homedir(), '.config', 'google-chrome', 'DevToolsActivePort'));
127
+ candidates.push(path.join(os.homedir(), '.config', 'chromium', 'DevToolsActivePort'));
128
+ candidates.push(path.join(os.homedir(), '.config', 'microsoft-edge', 'DevToolsActivePort'));
129
+ }
130
+ for (const filePath of candidates) {
131
+ try {
132
+ const content = fs.readFileSync(filePath, 'utf-8').trim();
133
+ const lines = content.split('\n');
134
+ if (lines.length >= 2) {
135
+ const port = parseInt(lines[0], 10);
136
+ const browserPath = lines[1]; // e.g. /devtools/browser/<GUID>
137
+ if (port > 0 && browserPath.startsWith('/devtools/browser/')) {
138
+ return `ws://127.0.0.1:${port}${browserPath}`;
139
+ }
140
+ }
141
+ }
142
+ catch { }
143
+ }
144
+ return null;
145
+ }
146
+ export function resolveCdpEndpoint() {
147
+ const envVal = process.env.OPENCLI_CDP_ENDPOINT;
148
+ if (envVal === '1' || envVal?.toLowerCase() === 'true') {
149
+ const autoDiscovered = discoverChromeEndpoint();
150
+ return { endpoint: autoDiscovered ?? envVal, requestedCdp: true };
151
+ }
152
+ if (envVal) {
153
+ return { endpoint: envVal, requestedCdp: true };
154
+ }
155
+ // Fallback to auto-discovery if not explicitly set
156
+ const autoDiscovered = discoverChromeEndpoint();
157
+ if (autoDiscovered) {
158
+ return { endpoint: autoDiscovered, requestedCdp: true };
159
+ }
160
+ return { requestedCdp: false };
161
+ }
101
162
  function buildRuntimeArgs(input) {
102
163
  const args = [];
164
+ // Priority 1: CDP endpoint (remote Chrome debugging or local Auto-Discovery)
165
+ if (input?.cdpEndpoint) {
166
+ args.push('--cdp-endpoint', input.cdpEndpoint);
167
+ return args;
168
+ }
169
+ // Priority 2: Extension mode (local Chrome with MCP Bridge extension)
103
170
  if (!process.env.CI) {
104
- // Local: always connect to user's running Chrome via MCP Bridge extension
105
171
  args.push('--extension');
106
172
  }
107
- // CI: standalone mode @playwright/mcp launches its own browser (headed by default).
173
+ // CI/standalone mode: @playwright/mcp launches its own browser (headed by default).
108
174
  // xvfb provides a virtual display for headed mode in GitHub Actions.
109
175
  if (input?.executablePath) {
110
176
  args.push('--executable-path', input.executablePath);
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Browser connection error classification and formatting.
3
3
  */
4
- export type ConnectFailureKind = 'missing-token' | 'extension-timeout' | 'extension-not-installed' | 'mcp-init' | 'process-exit' | 'unknown';
4
+ export type ConnectFailureKind = 'missing-token' | 'extension-timeout' | 'extension-not-installed' | 'mcp-init' | 'process-exit' | 'cdp-connection-failed' | 'unknown';
5
5
  export type ConnectFailureInput = {
6
6
  kind: ConnectFailureKind;
7
7
  timeout: number;
@@ -18,4 +18,5 @@ export declare function inferConnectFailureKind(args: {
18
18
  stderr: string;
19
19
  rawMessage?: string;
20
20
  exited?: boolean;
21
+ isCdpMode?: boolean;
21
22
  }): ConnectFailureKind;
@@ -11,6 +11,12 @@ export function formatBrowserConnectError(input) {
11
11
  const stderr = input.stderr?.trim();
12
12
  const suffix = stderr ? `\n\nMCP stderr:\n${stderr}` : '';
13
13
  const tokenHint = input.tokenFingerprint ? ` Token fingerprint: ${input.tokenFingerprint}.` : '';
14
+ if (input.kind === 'cdp-connection-failed') {
15
+ return new Error(`Failed to connect to remote Chrome via CDP endpoint.\n\n` +
16
+ `Check if Chrome is running with remote debugging enabled (--remote-debugging-port=9222) or DevToolsActivePort is available under chrome://inspect#remote-debugging.\n` +
17
+ `If you specified OPENCLI_CDP_ENDPOINT=1, auto-discovery might have failed.` +
18
+ suffix);
19
+ }
14
20
  if (input.kind === 'missing-token') {
15
21
  return new Error('Failed to connect to Playwright MCP Bridge: PLAYWRIGHT_MCP_EXTENSION_TOKEN is not set.\n\n' +
16
22
  'Without this token, Chrome will show a manual approval dialog for every new MCP connection. ' +
@@ -42,6 +48,13 @@ export function formatBrowserConnectError(input) {
42
48
  }
43
49
  export function inferConnectFailureKind(args) {
44
50
  const haystack = `${args.rawMessage ?? ''}\n${args.stderr}`.toLowerCase();
51
+ if (args.isCdpMode) {
52
+ if (args.rawMessage?.startsWith('MCP init failed:'))
53
+ return 'mcp-init';
54
+ if (args.exited)
55
+ return 'cdp-connection-failed';
56
+ return 'cdp-connection-failed';
57
+ }
45
58
  if (!args.hasExtensionToken)
46
59
  return 'missing-token';
47
60
  if (haystack.includes('extension connection timeout') || haystack.includes('playwright mcp bridge'))
@@ -8,6 +8,7 @@ export { Page } from './page.js';
8
8
  export { PlaywrightMCP } from './mcp.js';
9
9
  export { getTokenFingerprint, formatBrowserConnectError } from './errors.js';
10
10
  export type { ConnectFailureKind, ConnectFailureInput } from './errors.js';
11
+ export { resolveCdpEndpoint } from './discover.js';
11
12
  import { createJsonRpcRequest } from './mcp.js';
12
13
  import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
13
14
  import { buildMcpArgs, buildMcpLaunchSpec, findMcpServerPath, resetMcpServerPathCache, setMcpDiscoveryTestHooks } from './discover.js';
@@ -7,6 +7,7 @@
7
7
  export { Page } from './page.js';
8
8
  export { PlaywrightMCP } from './mcp.js';
9
9
  export { getTokenFingerprint, formatBrowserConnectError } from './errors.js';
10
+ export { resolveCdpEndpoint } from './discover.js';
10
11
  // Test-only helpers — exposed for unit tests
11
12
  import { createJsonRpcRequest } from './mcp.js';
12
13
  import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
@@ -7,7 +7,7 @@ import { withTimeoutMs, DEFAULT_BROWSER_CONNECT_TIMEOUT } from '../runtime.js';
7
7
  import { PKG_VERSION } from '../version.js';
8
8
  import { Page } from './page.js';
9
9
  import { getTokenFingerprint, formatBrowserConnectError, inferConnectFailureKind } from './errors.js';
10
- import { findMcpServerPath, buildMcpLaunchSpec } from './discover.js';
10
+ import { findMcpServerPath, buildMcpLaunchSpec, resolveCdpEndpoint } from './discover.js';
11
11
  import { extractTabIdentities, extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
12
12
  const STDERR_BUFFER_LIMIT = 16 * 1024;
13
13
  const INITIAL_TABS_TIMEOUT_MS = 1500;
@@ -109,7 +109,8 @@ export class PlaywrightMCP {
109
109
  return new Promise((resolve, reject) => {
110
110
  const isDebug = process.env.DEBUG?.includes('opencli:mcp');
111
111
  const debugLog = (msg) => isDebug && console.error(`[opencli:mcp] ${msg}`);
112
- const useExtension = !!process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
112
+ const { endpoint: cdpEndpoint, requestedCdp } = resolveCdpEndpoint();
113
+ const useExtension = !requestedCdp;
113
114
  const extensionToken = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
114
115
  const tokenFingerprint = getTokenFingerprint(extensionToken);
115
116
  let stderrBuffer = '';
@@ -144,14 +145,16 @@ export class PlaywrightMCP {
144
145
  settleError(inferConnectFailureKind({
145
146
  hasExtensionToken: !!extensionToken,
146
147
  stderr: stderrBuffer,
148
+ isCdpMode: requestedCdp,
147
149
  }));
148
150
  }, timeout * 1000);
149
151
  const launchSpec = buildMcpLaunchSpec({
150
152
  mcpPath,
151
153
  executablePath: process.env.OPENCLI_BROWSER_EXECUTABLE_PATH,
154
+ cdpEndpoint,
152
155
  });
153
156
  if (process.env.OPENCLI_VERBOSE) {
154
- console.error(`[opencli] Mode: ${useExtension ? 'extension' : 'standalone'}`);
157
+ console.error(`[opencli] Mode: ${requestedCdp ? 'CDP' : useExtension ? 'extension' : 'standalone'}`);
155
158
  if (useExtension)
156
159
  console.error(`[opencli] Extension token: fingerprint ${tokenFingerprint}`);
157
160
  if (launchSpec.usedNpxFallback) {
@@ -210,6 +213,7 @@ export class PlaywrightMCP {
210
213
  hasExtensionToken: !!extensionToken,
211
214
  stderr: stderrBuffer,
212
215
  exited: true,
216
+ isCdpMode: requestedCdp,
213
217
  }), { exitCode: code });
214
218
  }
215
219
  });
@@ -226,6 +230,7 @@ export class PlaywrightMCP {
226
230
  hasExtensionToken: !!extensionToken,
227
231
  stderr: stderrBuffer,
228
232
  rawMessage: `MCP init failed: ${resp.error.message}`,
233
+ isCdpMode: requestedCdp,
229
234
  }), { rawMessage: resp.error.message });
230
235
  return;
231
236
  }
@@ -4,6 +4,7 @@
4
4
  import { formatSnapshot } from '../snapshotFormatter.js';
5
5
  import { normalizeEvaluateSource } from '../pipeline/template.js';
6
6
  import { generateInterceptorJs, generateReadInterceptedJs } from '../interceptor.js';
7
+ import { BrowserConnectError } from '../errors.js';
7
8
  /**
8
9
  * Page abstraction wrapping JSON-RPC calls to Playwright MCP.
9
10
  */
@@ -18,10 +19,18 @@ export class Page {
18
19
  throw new Error(`page.${method}: ${resp.error.message ?? JSON.stringify(resp.error)}`);
19
20
  // Extract text content from MCP result
20
21
  const result = resp.result;
22
+ if (result?.isError) {
23
+ const errorText = result.content?.find((c) => c.type === 'text')?.text || 'Unknown MCP Error';
24
+ throw new BrowserConnectError(errorText, 'Please check if the browser is running or if the Playwright MCP / CDP connection is configured correctly.');
25
+ }
21
26
  if (result?.content) {
22
27
  const textParts = result.content.filter((c) => c.type === 'text');
23
- if (textParts.length === 1) {
24
- let text = textParts[0].text;
28
+ if (textParts.length >= 1) {
29
+ let text = textParts[textParts.length - 1].text; // Usually the main output is in the last text block
30
+ // Some versions of the MCP return error text without the `isError` boolean flag
31
+ if (typeof text === 'string' && text.trim().startsWith('### Error')) {
32
+ throw new BrowserConnectError(text.trim(), 'Please check if the browser is running or if the Playwright MCP / CDP connection is configured correctly.');
33
+ }
25
34
  // MCP browser_evaluate returns: "[JSON]\n### Ran Playwright code\n```js\n...\n```"
26
35
  // Strip the "### Ran Playwright code" suffix to get clean JSON
27
36
  const codeMarker = text.indexOf('### Ran Playwright code');
@@ -27,80 +27,78 @@ cli({
27
27
  (async () => {
28
28
  const limit = ${limit};
29
29
  const typeFilter = '${optionType}'.toLowerCase();
30
- const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
31
- const headers = { 'X-CSRF-TOKEN': csrf };
32
30
 
31
+ // Wait for CSRF token to appear (Angular may inject it after initial render)
32
+ let csrf = '';
33
+ for (let i = 0; i < 10; i++) {
34
+ csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
35
+ if (csrf) break;
36
+ await new Promise(r => setTimeout(r, 500));
37
+ }
38
+ if (!csrf) return { error: 'no-csrf' };
39
+
40
+ const headers = { 'X-CSRF-TOKEN': csrf };
33
41
  const fields = [
34
42
  'baseSymbol','strikePrice','expirationDate','optionType',
35
43
  'lastPrice','volume','openInterest','volumeOpenInterestRatio','volatility',
36
44
  ].join(',');
37
45
 
38
- // Fetch extra rows when filtering by type since server-side filter may not work
46
+ // Fetch extra rows when filtering by type since server-side filter doesn't work
39
47
  const fetchLimit = typeFilter !== 'all' ? limit * 3 : limit;
40
- try {
41
- const url = '/proxies/core-api/v1/options/get?list=options.unusual_activity.stocks.us'
42
- + '&fields=' + fields
43
- + '&orderBy=volumeOpenInterestRatio&orderDir=desc'
44
- + '&raw=1&limit=' + fetchLimit;
45
48
 
46
- const resp = await fetch(url, { credentials: 'include', headers });
47
- if (resp.ok) {
49
+ // Try unusual_activity first, fall back to mostActive (unusual_activity is
50
+ // empty outside market hours)
51
+ const lists = [
52
+ 'options.unusual_activity.stocks.us',
53
+ 'options.mostActive.us',
54
+ ];
55
+
56
+ for (const list of lists) {
57
+ try {
58
+ const url = '/proxies/core-api/v1/options/get?list=' + list
59
+ + '&fields=' + fields
60
+ + '&orderBy=volumeOpenInterestRatio&orderDir=desc'
61
+ + '&raw=1&limit=' + fetchLimit;
62
+
63
+ const resp = await fetch(url, { credentials: 'include', headers });
64
+ if (!resp.ok) continue;
48
65
  const d = await resp.json();
49
66
  let items = d?.data || [];
50
- if (items.length > 0) {
51
- // Apply client-side type filter
52
- if (typeFilter !== 'all') {
53
- items = items.filter(i => {
54
- const t = ((i.raw || i).optionType || '').toLowerCase();
55
- return t === typeFilter;
56
- });
57
- }
58
- return items.slice(0, limit).map(i => {
59
- const r = i.raw || i;
60
- return {
61
- symbol: r.baseSymbol || r.symbol,
62
- type: r.optionType,
63
- strike: r.strikePrice,
64
- expiration: r.expirationDate,
65
- last: r.lastPrice,
66
- volume: r.volume,
67
- openInterest: r.openInterest,
68
- volOiRatio: r.volumeOpenInterestRatio,
69
- iv: r.volatility,
70
- };
67
+ if (items.length === 0) continue;
68
+
69
+ // Apply client-side type filter
70
+ if (typeFilter !== 'all') {
71
+ items = items.filter(i => {
72
+ const t = ((i.raw || i).optionType || '').toLowerCase();
73
+ return t === typeFilter;
71
74
  });
72
75
  }
73
- }
74
- } catch(e) {}
75
-
76
- // Fallback: parse from DOM table
77
- try {
78
- const rows = document.querySelectorAll('tr[data-ng-repeat], tbody tr');
79
- const results = [];
80
- for (const row of rows) {
81
- const cells = row.querySelectorAll('td');
82
- if (cells.length < 6) continue;
83
- const getText = (idx) => cells[idx]?.textContent?.trim() || null;
84
- results.push({
85
- symbol: getText(0),
86
- type: getText(1),
87
- strike: getText(2),
88
- expiration: getText(3),
89
- last: getText(4),
90
- volume: getText(5),
91
- openInterest: cells.length > 6 ? getText(6) : null,
92
- volOiRatio: cells.length > 7 ? getText(7) : null,
93
- iv: cells.length > 8 ? getText(8) : null,
76
+ return items.slice(0, limit).map(i => {
77
+ const r = i.raw || i;
78
+ return {
79
+ symbol: r.baseSymbol || r.symbol,
80
+ type: r.optionType,
81
+ strike: r.strikePrice,
82
+ expiration: r.expirationDate,
83
+ last: r.lastPrice,
84
+ volume: r.volume,
85
+ openInterest: r.openInterest,
86
+ volOiRatio: r.volumeOpenInterestRatio,
87
+ iv: r.volatility,
88
+ };
94
89
  });
95
- if (results.length >= limit) break;
96
- }
97
- return results;
98
- } catch(e) {
99
- return [];
90
+ } catch(e) {}
100
91
  }
92
+
93
+ return [];
101
94
  })()
102
95
  `);
103
- if (!data || !Array.isArray(data))
96
+ if (!data)
97
+ return [];
98
+ if (data.error === 'no-csrf') {
99
+ throw new Error('Could not extract CSRF token from barchart.com. Make sure you are logged in.');
100
+ }
101
+ if (!Array.isArray(data))
104
102
  return [];
105
103
  return data.slice(0, limit).map(r => ({
106
104
  symbol: r.symbol || '',
package/dist/doctor.js CHANGED
@@ -504,6 +504,14 @@ export function renderBrowserDoctorReport(report) {
504
504
  const uniqueFingerprints = [...new Set(tokenFingerprints)];
505
505
  const hasMismatch = uniqueFingerprints.length > 1;
506
506
  const lines = [chalk.bold(`opencli v${report.cliVersion ?? 'unknown'} doctor`), ''];
507
+ // CDP endpoint mode (for remote/server environments)
508
+ const cdpEndpoint = process.env.OPENCLI_CDP_ENDPOINT;
509
+ if (cdpEndpoint) {
510
+ lines.push(statusLine('OK', `CDP endpoint: ${chalk.cyan(cdpEndpoint)}`));
511
+ lines.push(chalk.dim(' → Remote Chrome mode: extension token not required'));
512
+ lines.push('');
513
+ return lines.join('\n');
514
+ }
507
515
  const installStatus = report.extensionInstalled ? 'OK' : 'MISSING';
508
516
  const installDetail = report.extensionInstalled
509
517
  ? `Extension installed (${report.extensionBrowsers.join(', ')})`
package/dist/engine.d.ts CHANGED
@@ -17,4 +17,4 @@ export declare function discoverClis(...dirs: string[]): Promise<void>;
17
17
  /**
18
18
  * Execute a CLI command. Handles lazy-loading of TS modules.
19
19
  */
20
- export declare function executeCommand(cmd: CliCommand, page: IPage | null, kwargs: Record<string, any>, debug?: boolean): Promise<any>;
20
+ export declare function executeCommand(cmd: CliCommand, page: IPage | null, rawKwargs: Record<string, any>, debug?: boolean): Promise<any>;