diff --git a/CDP.md b/CDP.md
index a833d88..186cf3c 100644
--- a/CDP.md
+++ b/CDP.md
@@ -1,6 +1,6 @@
# Connecting OpenCLI via CDP (Remote/Headless Servers)
-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)**.
+If you cannot use the opencli Browser 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)**.
Because CDP binds to `localhost` by default for security reasons, accessing it from a remote server requires an additional networking tunnel.
diff --git a/CDP.zh-CN.md b/CDP.zh-CN.md
index 5641ed6..bf27409 100644
--- a/CDP.zh-CN.md
+++ b/CDP.zh-CN.md
@@ -1,6 +1,6 @@
# 通过 CDP 远程连接 OpenCLI (服务器/无头环境)
-如果你无法使用 Playwright MCP Bridge 浏览器扩展(例如:在无界面的远程服务器上运行 OpenCLI 时),OpenCLI 提供了备选方案:通过连接 **CDP (Chrome DevTools Protocol,即 Chrome 开发者工具协议)** 来直接控制本地 Chrome。
+如果你无法使用 opencli Browser Bridge 浏览器扩展(例如:在无界面的远程服务器上运行 OpenCLI 时),OpenCLI 提供了备选方案:通过连接 **CDP (Chrome DevTools Protocol,即 Chrome 开发者工具协议)** 来直接控制本地 Chrome。
出于安全考虑,CDP 默认仅绑定在 `localhost` 的本地端口。所以,若是想让**远程服务器**调用本地的 CDP 服务,我们需要依靠一层额外的网络隧道。
diff --git a/CLI-ELECTRON.md b/CLI-ELECTRON.md
index 5d752a1..ec298b9 100644
--- a/CLI-ELECTRON.md
+++ b/CLI-ELECTRON.md
@@ -8,7 +8,7 @@ Based on the successful automation of **Cursor**, **Codex**, **Antigravity**, **
## Core Concept
-Electron apps are essentially local Chromium browser instances. By exposing a debugging port (CDP — Chrome DevTools Protocol) at launch time, we can use Playwright to pierce through the UI layer, accessing and controlling all underlying state including React/Vue components and Shadow DOM.
+Electron apps are essentially local Chromium browser instances. By exposing a debugging port (CDP — Chrome DevTools Protocol) at launch time, we can use the Browser Bridge to pierce through the UI layer, accessing and controlling all underlying state including React/Vue components and Shadow DOM.
> **Note:** Not all desktop apps are Electron. WeChat (native Cocoa) and Feishu/Lark (custom Lark Framework) embed Chromium but do NOT expose CDP. For those apps, use the AppleScript + clipboard approach instead (see [Non-Electron Pattern](#non-electron-pattern-applescript)).
@@ -109,7 +109,7 @@ Core techniques:
## Pitfalls & Gotchas
1. **Port conflicts (EADDRINUSE)**: Only one app per port. Use unique ports: Codex=9222, ChatGPT=9224, Cursor=9226, ChatWise=9228, Notion=9230, Discord=9232
-2. **IPage abstraction**: OpenCLI wraps Playwright Page as `IPage` (`src/types.ts`). Use `page.pressKey()` and `page.evaluate()`, NOT `page.keyboard.press()`
+2. **IPage abstraction**: OpenCLI wraps the browser page as `IPage` (`src/types.ts`). Use `page.pressKey()` and `page.evaluate()`, NOT direct DOM APIs
3. **Timing**: Always add `await page.wait(0.5)` to `1.0` after DOM mutations. Returning too early disconnects prematurely
4. **AppleScript requires Accessibility**: Terminal app must be granted permission in System Settings → Privacy & Security → Accessibility
diff --git a/CLI-EXPLORER.md b/CLI-EXPLORER.md
index 73191ed..b619c05 100644
--- a/CLI-EXPLORER.md
+++ b/CLI-EXPLORER.md
@@ -9,12 +9,12 @@
---
-## AI Agent 开发者必读:用 Playwright MCP Bridge 探索
+## AI Agent 开发者必读:用浏览器探索
> [!CAUTION]
-> **你(AI Agent)必须通过 Playwright MCP Bridge 打开浏览器去访问目标网站!**
+> **你(AI Agent)必须通过浏览器打开目标网站去探索!**
> 不要只靠 `opencli explore` 命令或静态分析来发现 API。
-> 你拥有 Playwright MCP 工具,必须主动用它们浏览网页、观察网络请求、模拟用户交互。
+> 你拥有浏览器工具,必须主动用它们浏览网页、观察网络请求、模拟用户交互。
### 为什么?
@@ -36,7 +36,7 @@
| ❌ 错误做法 | ✅ 正确做法 |
|------------|------------|
-| 只用 `opencli explore` 命令,等结果自动出来 | 用 MCP Bridge 打开浏览器,主动浏览页面 |
+| 只用 `opencli explore` 命令,等结果自动出来 | 用浏览器工具打开页面,主动浏览 |
| 直接在代码里 `fetch(url)`,不看浏览器实际请求 | 先在浏览器中确认 API 可用,再写代码 |
| 页面打开后直接抓包,期望所有 API 都出现 | 模拟点击交互(展开评论/切换标签/加载更多) |
| 遇到 HTTP 200 但空数据就放弃 | 检查是否需要 Wbi 签名或 Cookie 鉴权 |
diff --git a/README.md b/README.md
index 71067a8..6ea5a6e 100644
--- a/README.md
+++ b/README.md
@@ -49,69 +49,27 @@ Turn ANY Electron application into a CLI tool! Recombine, script, and extend app
> **⚠️ Important**: Browser commands reuse your Chrome login session. You must be logged into the target website in Chrome before running commands. If you get empty data or errors, check your login status first.
-OpenCLI connects to your browser through the Playwright MCP Bridge extension.
-It prefers an existing local/global `@playwright/mcp` install and falls back to `npx -y @playwright/mcp@latest` automatically when no local MCP server is found.
+OpenCLI connects to your browser through a lightweight **Browser Bridge** Chrome Extension + micro-daemon (zero config, auto-start).
-### Playwright MCP Bridge Extension Setup
+### Browser Bridge Extension Setup
-1. Install **[Playwright MCP Bridge](https://chromewebstore.google.com/detail/playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm)** extension in Chrome.
-2. Run `opencli setup` — discovers the token, distributes it to your tools, and verifies connectivity:
+1. Install the **opencli Browser Bridge** extension in Chrome:
+ - Open `chrome://extensions`, enable **Developer mode** (top-right toggle)
+ - Click **Load unpacked**, select the `extension/` folder from this repo
+2. That's it! The daemon auto-starts when you run any browser command. No tokens, no manual configuration.
-```bash
-opencli setup
-```
-
-The interactive TUI will:
-- 🔍 Auto-discover `PLAYWRIGHT_MCP_EXTENSION_TOKEN` from Chrome (no manual copy needed)
-- ☑️ Show all detected tools (Codex, Cursor, Claude Code, Gemini CLI, etc.)
-- ✏️ Update only the files you select (Space to toggle, Enter to confirm)
-- 🔌 Auto-verify browser connectivity after writing configs
-
-> **Tip**: Use `opencli doctor` for ongoing diagnosis and maintenance:
+> **Tip**: Use `opencli doctor` for ongoing diagnosis:
> ```bash
-> opencli doctor # Read-only token & config diagnosis
-> opencli doctor --live # Also test live browser connectivity
-> opencli doctor --fix # Fix mismatched configs (interactive)
-> opencli doctor --fix -y # Fix all configs non-interactively
+> opencli doctor # Check extension + daemon connectivity
+> opencli doctor --live # Also test live browser commands
> ```
-**Alternative: CDP Mode (For Servers/Headless)**
-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.
-
-
-Manual setup (alternative)
-
-Add token to your MCP client config (e.g. Claude/Cursor):
-
-```json
-{
- "mcpServers": {
- "playwright": {
- "command": "npx",
- "args": ["-y", "@playwright/mcp@latest", "--extension"],
- "env": {
- "PLAYWRIGHT_MCP_EXTENSION_TOKEN": ""
- }
- }
- }
-}
-```
-
-Export in shell (e.g. `~/.zshrc`):
-
-```bash
-export PLAYWRIGHT_MCP_EXTENSION_TOKEN=""
-```
-
-
-
## Quick Start
### Install via npm (recommended)
```bash
npm install -g @jackwener/opencli
-opencli setup # One-time: configure Playwright MCP token
```
Then use directly:
@@ -297,15 +255,15 @@ npx vitest run tests/e2e/ # E2E tests
## Troubleshooting
-- **"Failed to connect to Playwright MCP Bridge"**
- - Ensure the Playwright MCP extension is installed and **enabled** in your running Chrome.
- - Restart the Chrome browser if you just installed the extension.
+- **"Extension not connected"**
+ - Ensure the opencli Browser Bridge extension is installed and **enabled** in `chrome://extensions`.
- **Empty data returns or 'Unauthorized' error**
- - Your login session in Chrome might have expired. Open a normal Chrome tab, navigate to the target site, and log in or refresh the page to prove you are human.
+ - Your login session in Chrome might have expired. Open a normal Chrome tab, navigate to the target site, and log in or refresh the page.
- **Node API errors**
- Make sure you are using Node.js >= 20. Some dependencies require modern Node APIs.
-- **Token issues**
- - Run `opencli doctor` to diagnose token configuration across all tools.
+- **Daemon issues**
+ - Check daemon status: `curl localhost:19825/status`
+ - View extension logs: `curl localhost:19825/logs`
## Releasing New Versions
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 45f9c72..5f3011d 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -50,69 +50,27 @@ CLI all electron!现在支持把所有 electron 应用 CLI 化,从而组合
> **⚠️ 重要**:大多数命令复用你的 Chrome 登录状态。运行命令前,你必须已在 Chrome 中打开目标网站并完成登录。如果获取到空数据或报错,请先检查你的浏览器登录状态。
-OpenCLI 通过 Playwright MCP Bridge 扩展与你的浏览器通信。
-它会优先复用本地或全局已安装的 `@playwright/mcp`,如果没有嗅探到可用 MCP server,则会自动回退到 `npx -y @playwright/mcp@latest` 启动。
+OpenCLI 通过轻量化的 **Browser Bridge** Chrome 扩展 + 微型 daemon 与浏览器通信(零配置,自动启动)。
-### Playwright MCP Bridge 扩展配置
+### Browser Bridge 扩展配置
-1. 安装 **[Playwright MCP Bridge](https://chromewebstore.google.com/detail/playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm)** 扩展
-2. 运行 `opencli setup` — 自动发现 Token、分发到各工具、验证连通性:
+1. 在 Chrome 中安装 **opencli Browser Bridge** 扩展:
+ - 打开 `chrome://extensions`,启用右上角的 **开发者模式**
+ - 点击 **加载已解压的扩展程序**,选择本仓库的 `extension/` 文件夹
+2. 完成!运行任何浏览器命令时 daemon 会自动启动。无需 token,无需手动配置。
-```bash
-opencli setup
-```
-
-交互式 TUI 会:
-- 🔍 从 Chrome 自动发现 `PLAYWRIGHT_MCP_EXTENSION_TOKEN`(无需手动复制)
-- ☑️ 显示所有支持的工具(Codex、Cursor、Claude Code、Gemini CLI 等)
-- ✏️ 只更新你选中的文件(空格切换,回车确认)
-- 🔌 完成后自动验证浏览器连通性
-
-> **Tip**:后续诊断和维护用 `opencli doctor`:
+> **Tip**:后续诊断用 `opencli doctor`:
> ```bash
-> opencli doctor # 只读 Token 与配置诊断
-> opencli doctor --live # 额外测试浏览器连通性
-> opencli doctor --fix # 修复不一致的配置(交互确认)
-> opencli doctor --fix -y # 无交互直接修复所有配置
+> opencli doctor # 检查扩展和 daemon 连通性
+> opencli doctor --live # 额外测试浏览器命令
> ```
-**备选方案:CDP 模式 (适用于服务器/无头环境)**
-如果你无法安装浏览器扩展(比如在远程无头服务器上运行 OpenCLI),你可以通过 SSH 隧道或反向代理,利用 CDP (Chrome DevTools Protocol) 连接到本地的 Chrome 浏览器。详细指南请参考 [CDP 连接教程](./CDP.zh-CN.md)。
-
-
-手动配置(备选方案)
-
-配置你的 MCP 客户端(如 Claude/Cursor 等):
-
-```json
-{
- "mcpServers": {
- "playwright": {
- "command": "npx",
- "args": ["-y", "@playwright/mcp@latest", "--extension"],
- "env": {
- "PLAYWRIGHT_MCP_EXTENSION_TOKEN": "<你的-token>"
- }
- }
- }
-}
-```
-
-在终端环境变量中导出(建议写进 `~/.zshrc`):
-
-```bash
-export PLAYWRIGHT_MCP_EXTENSION_TOKEN="<你的-token>"
-```
-
-
-
## 快速开始
### npm 全局安装(推荐)
```bash
npm install -g @jackwener/opencli
-opencli setup # 首次使用:配置 Playwright MCP token
```
直接使用:
@@ -280,16 +238,15 @@ opencli cascade https://api.example.com/data
## 常见问题排查
-- **"Failed to connect to Playwright MCP Bridge"** 报错
- - 确保你当前的 Chrome 已安装且**开启了** Playwright MCP Bridge 浏览器插件。
- - 如果是刚装完插件,需要重启 Chrome 浏览器。
+- **"Extension not connected" 报错**
+ - 确保你当前的 Chrome 已安装且**开启了** opencli Browser Bridge 扩展(在 `chrome://extensions` 中检查)。
- **返回空数据,或者报错 "Unauthorized"**
- - Chrome 里的登录态可能已经过期(甚至被要求过滑动验证码)。请打开当前 Chrome 页面,在新标签页重新手工登录或刷新该页面。
+ - Chrome 里的登录态可能已经过期。请打开当前 Chrome 页面,在新标签页重新手工登录或刷新该页面。
- **Node API 错误 (如 parseArgs, fs 等)**
- - 确保 Node.js 版本 `>= 20`。旧版不支持我们使用的现代核心库 API。
-- **Token 问题**
- - 运行 `opencli doctor` 诊断所有工具的 Token 配置状态。
- - 使用 `opencli doctor --live` 测试浏览器连通性。
+ - 确保 Node.js 版本 `>= 20`。
+- **Daemon 问题**
+ - 检查 daemon 状态:`curl localhost:19825/status`
+ - 查看扩展日志:`curl localhost:19825/logs`
## 版本发布
diff --git a/SKILL.md b/SKILL.md
index 2e2bd4c..ef87f84 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -3,7 +3,7 @@ name: opencli
description: "OpenCLI — Make any website or Electron App your CLI. Zero risk, AI-powered, reuse Chrome login. 80+ commands across 19 sites."
version: 0.7.3
author: jackwener
-tags: [cli, browser, web, mcp, playwright, bilibili, zhihu, twitter, github, v2ex, hackernews, reddit, xiaohongshu, xueqiu, youtube, boss, coupang, AI, agent]
+tags: [cli, browser, web, chrome-extension, cdp, bilibili, zhihu, twitter, github, v2ex, hackernews, reddit, xiaohongshu, xueqiu, youtube, boss, coupang, AI, agent]
---
# OpenCLI
@@ -12,7 +12,7 @@ tags: [cli, browser, web, mcp, playwright, bilibili, zhihu, twitter, github, v2e
> [!CAUTION]
> **AI Agent 必读:创建或修改任何适配器之前,你必须先阅读 [CLI-EXPLORER.md](./CLI-EXPLORER.md)!**
-> 该文档包含完整的 API 发现工作流(必须使用 Playwright MCP Bridge 浏览器探索)、5 级认证策略决策树、平台 SDK 速查表、`tap` 步骤调试流程、分页 API 模板、级联请求模式、以及常见陷阱。
+> 该文档包含完整的 API 发现工作流(必须使用浏览器探索)、5 级认证策略决策树、平台 SDK 速查表、`tap` 步骤调试流程、分页 API 模板、级联请求模式、以及常见陷阱。
> **本文件(SKILL.md)仅提供命令参考和简化模板,不足以正确开发适配器。**
## Install & Run
@@ -34,8 +34,8 @@ npm update -g @jackwener/opencli
Browser commands require:
1. Chrome browser running **(logged into target sites)**
-2. [Playwright MCP Bridge](https://chromewebstore.google.com/detail/playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm) extension installed
-3. Run `opencli setup` to auto-discover token and configure all tools
+2. **opencli Browser Bridge** Chrome extension installed (load `extension/` as unpacked in `chrome://extensions`)
+3. No further setup needed — the daemon auto-starts on first browser command
> **Note**: You must be logged into the target website in Chrome before running commands. Tabs opened during command execution are auto-closed afterwards.
@@ -227,7 +227,7 @@ opencli bilibili hot -v # Show each pipeline step and data flow
> [!IMPORTANT]
> **完整模式 — 在写任何代码之前,先阅读 [CLI-EXPLORER.md](./CLI-EXPLORER.md)。**
-> 它包含:① AI Agent 浏览器探索工作流(必须用 Playwright MCP 抓包验证 API)② 认证策略决策树 ③ 平台 SDK(如 Bilibili 的 `apiGet`/`fetchJson`)④ YAML vs TS 选择指南 ⑤ `tap` 步骤调试方法 ⑥ 级联请求模板 ⑦ 常见陷阱表。
+> 它包含:① AI Agent 浏览器探索工作流 ② 认证策略决策树 ③ 平台 SDK(如 Bilibili 的 `apiGet`/`fetchJson`)④ YAML vs TS 选择指南 ⑤ `tap` 步骤调试方法 ⑥ 级联请求模板 ⑦ 常见陷阱表。
> **下方仅为简化模板参考,直接使用极易踩坑。**
### YAML Pipeline (declarative, recommended)
@@ -374,16 +374,18 @@ ${{ index + 1 }}
| Variable | Default | Description |
|----------|---------|-------------|
+| `OPENCLI_DAEMON_PORT` | 19825 | Daemon listen port |
| `OPENCLI_BROWSER_CONNECT_TIMEOUT` | 30 | Browser connection timeout (sec) |
| `OPENCLI_BROWSER_COMMAND_TIMEOUT` | 45 | Command execution timeout (sec) |
| `OPENCLI_BROWSER_EXPLORE_TIMEOUT` | 120 | Explore timeout (sec) |
-| `PLAYWRIGHT_MCP_EXTENSION_TOKEN` | — | Auto-approve extension connection |
+| `OPENCLI_VERBOSE` | — | Show daemon/extension logs |
## Troubleshooting
| Issue | Solution |
|-------|----------|
| `npx not found` | Install Node.js: `brew install node` |
-| `Timed out connecting to browser` | 1) Chrome must be open 2) Install MCP Bridge extension and configure token |
+| `Extension not connected` | 1) Chrome must be open 2) Install opencli Browser Bridge extension |
| `Target page context` error | Add `navigate:` step before `evaluate:` in YAML |
-| Empty table data | Check if evaluate returns JSON string (MCP parsing) or data path is wrong |
+| Empty table data | Check if evaluate returns correct data path |
+| Daemon issues | `curl localhost:19825/status` to check, `curl localhost:19825/logs` for extension logs |
diff --git a/TESTING.md b/TESTING.md
index 59fd647..688441b 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -105,10 +105,10 @@ npx vitest src/
### 浏览器命令本地测试须知
-- 无 `PLAYWRIGHT_MCP_EXTENSION_TOKEN` 时,opencli 自动启动一个独立浏览器实例
+- opencli 通过 Browser Bridge 扩展连接已运行的 Chrome 浏览器
- `browser-public.test.ts` 使用 `tryBrowserCommand()`,站点反爬导致空数据时 warn + pass
- `browser-auth.test.ts` 验证 **graceful failure**(不 crash 不 hang 即通过)
-- 如需测试完整登录态,保持 Chrome 登录态 + 设置 `PLAYWRIGHT_MCP_EXTENSION_TOKEN`,手动跑对应测试
+- 如需测试完整登录态,保持 Chrome 登录态并安装 Browser Bridge 扩展,手动跑对应测试
---
@@ -202,12 +202,12 @@ steps:
## 浏览器模式
-opencli 根据 `PLAYWRIGHT_MCP_EXTENSION_TOKEN` 环境变量自动选择模式:
+opencli 通过 Browser Bridge 扩展连接浏览器:
-| 条件 | 模式 | MCP 参数 | 使用场景 |
-|---|---|---|---|
-| Token 已设置 | Extension 模式 | `--extension` | 本地用户,连接已登录的 Chrome |
-| Token 未设置 | Standalone 模式 | (无特殊 flag) | CI 或无扩展环境,自启浏览器 |
+| 条件 | 模式 | 使用场景 |
+|---|---|---|
+| 扩展已安装 | Extension 模式 | 本地用户,连接已登录的 Chrome |
+| 扩展未安装 | CLI 报错提示安装 | 需要安装 Browser Bridge 扩展 |
CI 中使用 `OPENCLI_BROWSER_EXECUTABLE_PATH` 指定真实 Chrome 路径:
diff --git a/extension/.gitignore b/extension/.gitignore
new file mode 100644
index 0000000..b947077
--- /dev/null
+++ b/extension/.gitignore
@@ -0,0 +1,2 @@
+node_modules/
+dist/
diff --git a/extension/icons/icon-128.png b/extension/icons/icon-128.png
new file mode 100644
index 0000000..4c6c5a1
Binary files /dev/null and b/extension/icons/icon-128.png differ
diff --git a/extension/icons/icon-16.png b/extension/icons/icon-16.png
new file mode 100644
index 0000000..b9d9e40
Binary files /dev/null and b/extension/icons/icon-16.png differ
diff --git a/extension/icons/icon-32.png b/extension/icons/icon-32.png
new file mode 100644
index 0000000..9c3b66e
Binary files /dev/null and b/extension/icons/icon-32.png differ
diff --git a/extension/icons/icon-48.png b/extension/icons/icon-48.png
new file mode 100644
index 0000000..e17cea9
Binary files /dev/null and b/extension/icons/icon-48.png differ
diff --git a/extension/manifest.json b/extension/manifest.json
new file mode 100644
index 0000000..c2a2d6c
--- /dev/null
+++ b/extension/manifest.json
@@ -0,0 +1,31 @@
+{
+ "manifest_version": 3,
+ "name": "opencli Browser Bridge",
+ "version": "0.1.0",
+ "description": "Bridge between opencli CLI and your browser — execute commands, read cookies, manage tabs.",
+ "permissions": [
+ "debugger",
+ "tabs",
+ "cookies",
+ "activeTab",
+ "alarms"
+ ],
+ "background": {
+ "service_worker": "dist/background.js",
+ "type": "module"
+ },
+ "icons": {
+ "16": "icons/icon-16.png",
+ "32": "icons/icon-32.png",
+ "48": "icons/icon-48.png",
+ "128": "icons/icon-128.png"
+ },
+ "action": {
+ "default_title": "opencli Browser Bridge",
+ "default_icon": {
+ "16": "icons/icon-16.png",
+ "32": "icons/icon-32.png"
+ }
+ },
+ "homepage_url": "https://github.com/jackwener/opencli"
+}
diff --git a/extension/package.json b/extension/package.json
new file mode 100644
index 0000000..253aec6
--- /dev/null
+++ b/extension/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "opencli-extension",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite build --watch",
+ "build": "vite build",
+ "typecheck": "tsc --noEmit"
+ },
+ "devDependencies": {
+ "@types/chrome": "^0.0.287",
+ "typescript": "^5.7.0",
+ "vite": "^6.0.0"
+ }
+}
diff --git a/extension/src/background.ts b/extension/src/background.ts
new file mode 100644
index 0000000..86577a2
--- /dev/null
+++ b/extension/src/background.ts
@@ -0,0 +1,293 @@
+/**
+ * opencli Browser Bridge — Service Worker (background script).
+ *
+ * Connects to the opencli daemon via WebSocket, receives commands,
+ * dispatches them to Chrome APIs (debugger/tabs/cookies), returns results.
+ */
+
+import type { Command, Result } from './protocol';
+import { DAEMON_WS_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol';
+import * as cdp from './cdp';
+
+let ws: WebSocket | null = null;
+let reconnectTimer: ReturnType | null = null;
+let reconnectAttempts = 0;
+
+// ─── Console log forwarding ──────────────────────────────────────────
+// Hook console.log/warn/error to forward logs to daemon via WebSocket.
+
+const _origLog = console.log.bind(console);
+const _origWarn = console.warn.bind(console);
+const _origError = console.error.bind(console);
+
+function forwardLog(level: 'info' | 'warn' | 'error', args: unknown[]): void {
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
+ try {
+ const msg = args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ');
+ ws.send(JSON.stringify({ type: 'log', level, msg, ts: Date.now() }));
+ } catch { /* don't recurse */ }
+}
+
+console.log = (...args: unknown[]) => { _origLog(...args); forwardLog('info', args); };
+console.warn = (...args: unknown[]) => { _origWarn(...args); forwardLog('warn', args); };
+console.error = (...args: unknown[]) => { _origError(...args); forwardLog('error', args); };
+
+// ─── WebSocket connection ────────────────────────────────────────────
+
+function connect(): void {
+ if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return;
+
+ try {
+ ws = new WebSocket(DAEMON_WS_URL);
+ } catch {
+ scheduleReconnect();
+ return;
+ }
+
+ ws.onopen = () => {
+ console.log('[opencli] Connected to daemon');
+ reconnectAttempts = 0; // Reset on successful connection
+ if (reconnectTimer) {
+ clearTimeout(reconnectTimer);
+ reconnectTimer = null;
+ }
+ };
+
+ ws.onmessage = async (event) => {
+ try {
+ const command = JSON.parse(event.data as string) as Command;
+ const result = await handleCommand(command);
+ ws?.send(JSON.stringify(result));
+ } catch (err) {
+ console.error('[opencli] Message handling error:', err);
+ }
+ };
+
+ ws.onclose = () => {
+ console.log('[opencli] Disconnected from daemon');
+ ws = null;
+ scheduleReconnect();
+ };
+
+ ws.onerror = () => {
+ ws?.close();
+ };
+}
+
+function scheduleReconnect(): void {
+ if (reconnectTimer) return;
+ reconnectAttempts++;
+ // Exponential backoff: 2s, 4s, 8s, 16s, ..., capped at 60s
+ const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY);
+ reconnectTimer = setTimeout(() => {
+ reconnectTimer = null;
+ connect();
+ }, delay);
+}
+
+// ─── Lifecycle events ────────────────────────────────────────────────
+
+let initialized = false;
+
+function initialize(): void {
+ if (initialized) return;
+ initialized = true;
+ chrome.alarms.create('keepalive', { periodInMinutes: 0.4 }); // ~24 seconds
+ cdp.registerListeners();
+ connect();
+ console.log('[opencli] Browser Bridge extension initialized');
+}
+
+chrome.runtime.onInstalled.addListener(() => {
+ initialize();
+});
+
+chrome.runtime.onStartup.addListener(() => {
+ initialize();
+});
+
+chrome.alarms.onAlarm.addListener((alarm) => {
+ if (alarm.name === 'keepalive') connect();
+});
+
+// ─── Command dispatcher ─────────────────────────────────────────────
+
+async function handleCommand(cmd: Command): Promise {
+ try {
+ switch (cmd.action) {
+ case 'exec':
+ return await handleExec(cmd);
+ case 'navigate':
+ return await handleNavigate(cmd);
+ case 'tabs':
+ return await handleTabs(cmd);
+ case 'cookies':
+ return await handleCookies(cmd);
+ case 'screenshot':
+ return await handleScreenshot(cmd);
+ default:
+ return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` };
+ }
+ } catch (err) {
+ return {
+ id: cmd.id,
+ ok: false,
+ error: err instanceof Error ? err.message : String(err),
+ };
+ }
+}
+
+// ─── Action handlers ─────────────────────────────────────────────────
+
+/** Check if a URL is a debuggable web page (not chrome:// or extension page) */
+function isWebUrl(url?: string): boolean {
+ if (!url) return false;
+ return !url.startsWith('chrome://') && !url.startsWith('chrome-extension://');
+}
+
+/** Resolve target tab: use specified tabId or fall back to active web page tab */
+async function resolveTabId(tabId?: number): Promise {
+ if (tabId !== undefined) return tabId;
+
+ // Try the active tab first
+ const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
+ if (activeTab?.id && isWebUrl(activeTab.url)) {
+ return activeTab.id;
+ }
+
+ // Active tab is not debuggable — try to find any open web page tab
+ const allTabs = await chrome.tabs.query({ currentWindow: true });
+ const webTab = allTabs.find(t => t.id && isWebUrl(t.url));
+ if (webTab?.id) {
+ await chrome.tabs.update(webTab.id, { active: true });
+ return webTab.id;
+ }
+
+ // No web tabs at all — create one
+ const newTab = await chrome.tabs.create({ url: 'about:blank', active: true });
+ if (!newTab.id) throw new Error('Failed to create new tab');
+ return newTab.id;
+}
+
+async function handleExec(cmd: Command): Promise {
+ if (!cmd.code) return { id: cmd.id, ok: false, error: 'Missing code' };
+ const tabId = await resolveTabId(cmd.tabId);
+ try {
+ const data = await cdp.evaluateAsync(tabId, cmd.code);
+ return { id: cmd.id, ok: true, data };
+ } catch (err) {
+ return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
+ }
+}
+
+async function handleNavigate(cmd: Command): Promise {
+ if (!cmd.url) return { id: cmd.id, ok: false, error: 'Missing url' };
+ const tabId = await resolveTabId(cmd.tabId);
+ await chrome.tabs.update(tabId, { url: cmd.url });
+
+ // Wait for page to finish loading, checking current status first to avoid race
+ await new Promise((resolve) => {
+ // Check if already complete (e.g. cached pages)
+ chrome.tabs.get(tabId).then(tab => {
+ if (tab.status === 'complete') { resolve(); return; }
+
+ const listener = (id: number, info: chrome.tabs.TabChangeInfo) => {
+ if (id === tabId && info.status === 'complete') {
+ chrome.tabs.onUpdated.removeListener(listener);
+ resolve();
+ }
+ };
+ chrome.tabs.onUpdated.addListener(listener);
+ // Timeout fallback
+ setTimeout(() => {
+ chrome.tabs.onUpdated.removeListener(listener);
+ resolve();
+ }, 15000);
+ });
+ });
+
+ const tab = await chrome.tabs.get(tabId);
+ return { id: cmd.id, ok: true, data: { title: tab.title, url: tab.url, tabId } };
+}
+
+async function handleTabs(cmd: Command): Promise {
+ switch (cmd.op) {
+ case 'list': {
+ const tabs = await chrome.tabs.query({});
+ const data = tabs
+ .filter((t) => isWebUrl(t.url))
+ .map((t, i) => ({
+ index: i,
+ tabId: t.id,
+ url: t.url,
+ title: t.title,
+ active: t.active,
+ }));
+ return { id: cmd.id, ok: true, data };
+ }
+ case 'new': {
+ const tab = await chrome.tabs.create({ url: cmd.url, active: true });
+ return { id: cmd.id, ok: true, data: { tabId: tab.id, url: tab.url } };
+ }
+ case 'close': {
+ if (cmd.index !== undefined) {
+ const tabs = await chrome.tabs.query({});
+ const target = tabs[cmd.index];
+ if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
+ await chrome.tabs.remove(target.id);
+ cdp.detach(target.id);
+ return { id: cmd.id, ok: true, data: { closed: target.id } };
+ }
+ const tabId = await resolveTabId(cmd.tabId);
+ await chrome.tabs.remove(tabId);
+ cdp.detach(tabId);
+ return { id: cmd.id, ok: true, data: { closed: tabId } };
+ }
+ case 'select': {
+ if (cmd.index === undefined && cmd.tabId === undefined)
+ return { id: cmd.id, ok: false, error: 'Missing index or tabId' };
+ if (cmd.tabId !== undefined) {
+ await chrome.tabs.update(cmd.tabId, { active: true });
+ return { id: cmd.id, ok: true, data: { selected: cmd.tabId } };
+ }
+ const tabs = await chrome.tabs.query({});
+ const target = tabs[cmd.index!];
+ if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
+ await chrome.tabs.update(target.id, { active: true });
+ return { id: cmd.id, ok: true, data: { selected: target.id } };
+ }
+ default:
+ return { id: cmd.id, ok: false, error: `Unknown tabs op: ${cmd.op}` };
+ }
+}
+
+async function handleCookies(cmd: Command): Promise {
+ const details: chrome.cookies.GetAllDetails = {};
+ if (cmd.domain) details.domain = cmd.domain;
+ if (cmd.url) details.url = cmd.url;
+ const cookies = await chrome.cookies.getAll(details);
+ const data = cookies.map((c) => ({
+ name: c.name,
+ value: c.value,
+ domain: c.domain,
+ path: c.path,
+ secure: c.secure,
+ httpOnly: c.httpOnly,
+ expirationDate: c.expirationDate,
+ }));
+ return { id: cmd.id, ok: true, data };
+}
+
+async function handleScreenshot(cmd: Command): Promise {
+ const tabId = await resolveTabId(cmd.tabId);
+ try {
+ const data = await cdp.screenshot(tabId, {
+ format: cmd.format,
+ quality: cmd.quality,
+ fullPage: cmd.fullPage,
+ });
+ return { id: cmd.id, ok: true, data };
+ } catch (err) {
+ return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
+ }
+}
diff --git a/extension/src/cdp.ts b/extension/src/cdp.ts
new file mode 100644
index 0000000..ac9fe38
--- /dev/null
+++ b/extension/src/cdp.ts
@@ -0,0 +1,125 @@
+/**
+ * CDP execution via chrome.debugger API.
+ *
+ * chrome.debugger only needs the "debugger" permission — no host_permissions.
+ * It can attach to any http/https tab. Avoid chrome:// and chrome-extension://
+ * tabs (resolveTabId in background.ts filters them).
+ */
+
+const attached = new Set();
+
+async function ensureAttached(tabId: number): Promise {
+ if (attached.has(tabId)) return;
+
+ try {
+ await chrome.debugger.attach({ tabId }, '1.3');
+ } catch (e: unknown) {
+ const msg = e instanceof Error ? e.message : String(e);
+ if (msg.includes('Another debugger is already attached')) {
+ try { await chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
+ try {
+ await chrome.debugger.attach({ tabId }, '1.3');
+ } catch {
+ throw new Error(`attach failed: ${msg}`);
+ }
+ } else {
+ throw new Error(`attach failed: ${msg}`);
+ }
+ }
+ attached.add(tabId);
+
+ try {
+ await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable');
+ } catch {
+ // Some pages may not need explicit enable
+ }
+}
+
+export async function evaluate(tabId: number, expression: string): Promise {
+ await ensureAttached(tabId);
+
+ const result = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
+ expression,
+ returnByValue: true,
+ awaitPromise: true,
+ }) as {
+ result?: { type: string; value?: unknown; description?: string; subtype?: string };
+ exceptionDetails?: { exception?: { description?: string }; text?: string };
+ };
+
+ if (result.exceptionDetails) {
+ const errMsg = result.exceptionDetails.exception?.description
+ || result.exceptionDetails.text
+ || 'Eval error';
+ throw new Error(errMsg);
+ }
+
+ return result.result?.value;
+}
+
+export const evaluateAsync = evaluate;
+
+/**
+ * Capture a screenshot via CDP Page.captureScreenshot.
+ * Returns base64-encoded image data.
+ */
+export async function screenshot(
+ tabId: number,
+ options: { format?: 'png' | 'jpeg'; quality?: number; fullPage?: boolean } = {},
+): Promise {
+ await ensureAttached(tabId);
+
+ const format = options.format ?? 'png';
+
+ // For full-page screenshots, get the full page dimensions first
+ if (options.fullPage) {
+ // Get full page metrics
+ const metrics = await chrome.debugger.sendCommand({ tabId }, 'Page.getLayoutMetrics') as {
+ contentSize?: { width: number; height: number };
+ cssContentSize?: { width: number; height: number };
+ };
+ const size = metrics.cssContentSize || metrics.contentSize;
+ if (size) {
+ // Set device metrics to full page size
+ await chrome.debugger.sendCommand({ tabId }, 'Emulation.setDeviceMetricsOverride', {
+ mobile: false,
+ width: Math.ceil(size.width),
+ height: Math.ceil(size.height),
+ deviceScaleFactor: 1,
+ });
+ }
+ }
+
+ try {
+ const params: Record = { format };
+ if (format === 'jpeg' && options.quality !== undefined) {
+ params.quality = Math.max(0, Math.min(100, options.quality));
+ }
+
+ const result = await chrome.debugger.sendCommand({ tabId }, 'Page.captureScreenshot', params) as {
+ data: string; // base64-encoded
+ };
+
+ return result.data;
+ } finally {
+ // Reset device metrics if we changed them for full-page
+ if (options.fullPage) {
+ await chrome.debugger.sendCommand({ tabId }, 'Emulation.clearDeviceMetricsOverride').catch(() => {});
+ }
+ }
+}
+
+export function detach(tabId: number): void {
+ if (!attached.has(tabId)) return;
+ attached.delete(tabId);
+ try { chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
+}
+
+export function registerListeners(): void {
+ chrome.tabs.onRemoved.addListener((tabId) => {
+ attached.delete(tabId);
+ });
+ chrome.debugger.onDetach.addListener((source) => {
+ if (source.tabId) attached.delete(source.tabId);
+ });
+}
diff --git a/extension/src/protocol.ts b/extension/src/protocol.ts
new file mode 100644
index 0000000..efce9c0
--- /dev/null
+++ b/extension/src/protocol.ts
@@ -0,0 +1,57 @@
+/**
+ * opencli browser protocol — shared types between daemon, extension, and CLI.
+ *
+ * 5 actions: exec, navigate, tabs, cookies, screenshot.
+ * Everything else is just JS code sent via 'exec'.
+ */
+
+export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot';
+
+export interface Command {
+ /** Unique request ID */
+ id: string;
+ /** Action type */
+ action: Action;
+ /** Target tab ID (omit for active tab) */
+ tabId?: number;
+ /** JS code to evaluate in page context (exec action) */
+ code?: string;
+ /** URL to navigate to (navigate action) */
+ url?: string;
+ /** Sub-operation for tabs: list, new, close, select */
+ op?: 'list' | 'new' | 'close' | 'select';
+ /** Tab index for tabs select/close */
+ index?: number;
+ /** Cookie domain filter */
+ domain?: string;
+ /** Screenshot format: png (default) or jpeg */
+ format?: 'png' | 'jpeg';
+ /** JPEG quality (0-100), only for jpeg format */
+ quality?: number;
+ /** Whether to capture full page (not just viewport) */
+ fullPage?: boolean;
+}
+
+export interface Result {
+ /** Matching request ID */
+ id: string;
+ /** Whether the command succeeded */
+ ok: boolean;
+ /** Result data on success */
+ data?: unknown;
+ /** Error message on failure */
+ error?: string;
+}
+
+/** Default daemon port */
+export const DAEMON_PORT = 19825;
+export const DAEMON_HOST = 'localhost';
+export const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`;
+export const DAEMON_HTTP_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
+
+/** Base reconnect delay for extension WebSocket (ms) */
+export const WS_RECONNECT_BASE_DELAY = 2000;
+/** Max reconnect delay (ms) */
+export const WS_RECONNECT_MAX_DELAY = 60000;
+/** Idle timeout before daemon auto-exits (ms) */
+export const DAEMON_IDLE_TIMEOUT = 5 * 60 * 1000;
diff --git a/extension/store-assets/screenshot-1280x800.png b/extension/store-assets/screenshot-1280x800.png
new file mode 100644
index 0000000..5bbc49f
Binary files /dev/null and b/extension/store-assets/screenshot-1280x800.png differ
diff --git a/extension/tsconfig.json b/extension/tsconfig.json
new file mode 100644
index 0000000..93294a5
--- /dev/null
+++ b/extension/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "outDir": "dist",
+ "rootDir": "src",
+ "declaration": false,
+ "types": ["chrome"]
+ },
+ "include": ["src"]
+}
diff --git a/extension/vite.config.ts b/extension/vite.config.ts
new file mode 100644
index 0000000..f7cd0ec
--- /dev/null
+++ b/extension/vite.config.ts
@@ -0,0 +1,18 @@
+import { defineConfig } from 'vite';
+import { resolve } from 'path';
+
+export default defineConfig({
+ build: {
+ outDir: 'dist',
+ emptyOutDir: true,
+ rollupOptions: {
+ input: resolve(__dirname, 'src/background.ts'),
+ output: {
+ entryFileNames: 'background.js',
+ format: 'es',
+ },
+ },
+ target: 'esnext',
+ minify: false,
+ },
+});
diff --git a/package-lock.json b/package-lock.json
index 6f9d4df..1217464 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,15 +13,16 @@
"chalk": "^5.3.0",
"cli-table3": "^0.6.5",
"commander": "^14.0.3",
- "js-yaml": "^4.1.0"
+ "js-yaml": "^4.1.0",
+ "ws": "^8.18.0"
},
"bin": {
"opencli": "dist/main.js"
},
"devDependencies": {
- "@playwright/mcp": "^0.0.68",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.13.10",
+ "@types/ws": "^8.5.13",
"tsx": "^4.19.3",
"typescript": "^5.8.2",
"vitest": "^4.1.0"
@@ -560,23 +561,6 @@
"url": "https://github.com/sponsors/Boshen"
}
},
- "node_modules/@playwright/mcp": {
- "version": "0.0.68",
- "resolved": "https://registry.npmjs.org/@playwright/mcp/-/mcp-0.0.68.tgz",
- "integrity": "sha512-oP9I9ghXKuQEBo4xaC7HgsS2gRTxyMzlBm3UEhYj4VqqrqbPQUX2shATPaNA/am9joBzq9v0OXISzeIgP+zmHA==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "playwright": "1.59.0-alpha-1771104257000",
- "playwright-core": "1.59.0-alpha-1771104257000"
- },
- "bin": {
- "playwright-mcp": "cli.js"
- },
- "engines": {
- "node": ">=18"
- }
- },
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz",
@@ -899,6 +883,16 @@
"undici-types": "~6.21.0"
}
},
+ "node_modules/@types/ws": {
+ "version": "8.18.1",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@vitest/expect": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz",
@@ -1563,6 +1557,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -1570,53 +1565,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
- "node_modules/playwright": {
- "version": "1.59.0-alpha-1771104257000",
- "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.0-alpha-1771104257000.tgz",
- "integrity": "sha512-6SCMMMJaDRsSqiKVLmb2nhtLES7iTYawTWWrQK6UdIGNzXi8lka4sLKRec3L4DnTWwddAvCuRn8035dhNiHzbg==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "playwright-core": "1.59.0-alpha-1771104257000"
- },
- "bin": {
- "playwright": "cli.js"
- },
- "engines": {
- "node": ">=18"
- },
- "optionalDependencies": {
- "fsevents": "2.3.2"
- }
- },
- "node_modules/playwright-core": {
- "version": "1.59.0-alpha-1771104257000",
- "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.0-alpha-1771104257000.tgz",
- "integrity": "sha512-YiXup3pnpQUCBMSIW5zx8CErwRx4K6O5Kojkw2BzJui8MazoMUDU6E3xGsb1kzFviEAE09LFQ+y1a0RhIJQ5SA==",
- "dev": true,
- "license": "Apache-2.0",
- "bin": {
- "playwright-core": "cli.js"
- },
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/playwright/node_modules/fsevents": {
- "version": "2.3.2",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
- "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
- }
- },
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
@@ -1805,6 +1753,7 @@
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
@@ -1846,6 +1795,7 @@
"integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@oxc-project/runtime": "0.115.0",
"lightningcss": "^1.32.0",
@@ -2017,6 +1967,27 @@
"engines": {
"node": ">=8"
}
+ },
+ "node_modules/ws": {
+ "version": "8.19.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
+ "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/package.json b/package.json
index 1dcfdb1..5752a36 100644
--- a/package.json
+++ b/package.json
@@ -32,8 +32,7 @@
"cli",
"browser",
"web",
- "ai",
- "playwright"
+ "ai"
],
"author": "jackwener",
"license": "Apache-2.0",
@@ -45,11 +44,12 @@
"chalk": "^5.3.0",
"cli-table3": "^0.6.5",
"commander": "^14.0.3",
- "js-yaml": "^4.1.0"
+ "js-yaml": "^4.1.0",
+ "ws": "^8.18.0"
},
"devDependencies": {
- "@playwright/mcp": "^0.0.68",
"@types/js-yaml": "^4.0.9",
+ "@types/ws": "^8.5.13",
"@types/node": "^22.13.10",
"tsx": "^4.19.3",
"typescript": "^5.8.2",
diff --git a/src/browser.test.ts b/src/browser.test.ts
index 31fba4e..dba6826 100644
--- a/src/browser.test.ts
+++ b/src/browser.test.ts
@@ -1,24 +1,7 @@
import { afterEach, describe, it, expect, vi } from 'vitest';
-import * as os from 'node:os';
-import * as path from 'node:path';
import { PlaywrightMCP, __test__ } from './browser/index.js';
-afterEach(() => {
- __test__.resetMcpServerPathCache();
- __test__.setMcpDiscoveryTestHooks();
- delete process.env.OPENCLI_MCP_SERVER_PATH;
-});
-
describe('browser helpers', () => {
- it('creates JSON-RPC requests with unique ids', () => {
- const first = __test__.createJsonRpcRequest('tools/call', { name: 'browser_tabs' });
- const second = __test__.createJsonRpcRequest('tools/call', { name: 'browser_snapshot' });
-
- expect(second.id).toBe(first.id + 1);
- expect(first.message).toContain(`"id":${first.id}`);
- expect(second.message).toContain(`"id":${second.id}`);
- });
-
it('extracts tab entries from string snapshots', () => {
const entries = __test__.extractTabEntries('Tab 0 https://example.com\nTab 1 Chrome Extension');
@@ -57,220 +40,9 @@ describe('browser helpers', () => {
expect(__test__.appendLimited('12345', '67890', 8)).toBe('34567890');
});
- it('builds extension MCP args in local mode (no CI)', () => {
- const savedCI = process.env.CI;
- delete process.env.CI;
- try {
- expect(__test__.buildMcpArgs({
- mcpPath: '/tmp/cli.js',
- executablePath: '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
- })).toEqual([
- '/tmp/cli.js',
- '--extension',
- '--executable-path',
- '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
- ]);
-
- expect(__test__.buildMcpArgs({
- mcpPath: '/tmp/cli.js',
- })).toEqual([
- '/tmp/cli.js',
- '--extension',
- ]);
- } finally {
- if (savedCI !== undefined) {
- process.env.CI = savedCI;
- } else {
- delete process.env.CI;
- }
- }
- });
-
- it('builds standalone MCP args in CI mode', () => {
- const savedCI = process.env.CI;
- process.env.CI = 'true';
- try {
- // CI mode: no --extension — browser launches in standalone headed mode
- expect(__test__.buildMcpArgs({
- mcpPath: '/tmp/cli.js',
- })).toEqual([
- '/tmp/cli.js',
- ]);
-
- expect(__test__.buildMcpArgs({
- mcpPath: '/tmp/cli.js',
- executablePath: '/usr/bin/chromium',
- })).toEqual([
- '/tmp/cli.js',
- '--executable-path',
- '/usr/bin/chromium',
- ]);
- } finally {
- if (savedCI !== undefined) {
- process.env.CI = savedCI;
- } else {
- delete process.env.CI;
- }
- }
- });
-
- it('builds a direct node launch spec when a local MCP path is available', () => {
- const savedCI = process.env.CI;
- delete process.env.CI;
- try {
- expect(__test__.buildMcpLaunchSpec({
- mcpPath: '/tmp/cli.js',
- executablePath: '/usr/bin/google-chrome',
- })).toEqual({
- command: 'node',
- args: ['/tmp/cli.js', '--extension', '--executable-path', '/usr/bin/google-chrome'],
- usedNpxFallback: false,
- });
- } finally {
- if (savedCI !== undefined) {
- process.env.CI = savedCI;
- } else {
- delete process.env.CI;
- }
- }
- });
-
- it('falls back to npx bootstrap when no MCP path is available', () => {
- const savedCI = process.env.CI;
- delete process.env.CI;
- try {
- expect(__test__.buildMcpLaunchSpec({
- mcpPath: null,
- })).toEqual({
- command: 'npx',
- args: ['-y', '@playwright/mcp@latest', '--extension'],
- usedNpxFallback: true,
- });
- } finally {
- if (savedCI !== undefined) {
- process.env.CI = savedCI;
- } else {
- delete process.env.CI;
- }
- }
- });
-
it('times out slow promises', async () => {
await expect(__test__.withTimeoutMs(new Promise(() => {}), 10, 'timeout')).rejects.toThrow('timeout');
});
-
- it('prefers OPENCLI_MCP_SERVER_PATH over discovered locations', () => {
- process.env.OPENCLI_MCP_SERVER_PATH = '/env/mcp/cli.js';
- const existsSync = vi.fn((candidate: any) => candidate === '/env/mcp/cli.js');
- const execSync = vi.fn();
- __test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync as any });
-
- expect(__test__.findMcpServerPath()).toBe('/env/mcp/cli.js');
- expect(execSync).not.toHaveBeenCalled();
- expect(existsSync).toHaveBeenCalledWith('/env/mcp/cli.js');
- });
-
- it('discovers global @playwright/mcp from the current Node runtime prefix', () => {
- const originalExecPath = process.execPath;
- const runtimeExecPath = '/opt/homebrew/Cellar/node/25.2.1/bin/node';
- const runtimeGlobalMcp = '/opt/homebrew/Cellar/node/25.2.1/lib/node_modules/@playwright/mcp/cli.js';
- Object.defineProperty(process, 'execPath', {
- value: runtimeExecPath,
- configurable: true,
- });
-
- const existsSync = vi.fn((candidate: any) => candidate === runtimeGlobalMcp);
- const execSync = vi.fn();
- __test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync as any });
-
- try {
- expect(__test__.findMcpServerPath()).toBe(runtimeGlobalMcp);
- expect(execSync).not.toHaveBeenCalled();
- expect(existsSync).toHaveBeenCalledWith(runtimeGlobalMcp);
- } finally {
- Object.defineProperty(process, 'execPath', {
- value: originalExecPath,
- configurable: true,
- });
- }
- });
-
- it('falls back to npm root -g when runtime prefix lookup misses', () => {
- const originalExecPath = process.execPath;
- const runtimeExecPath = '/opt/homebrew/Cellar/node/25.2.1/bin/node';
- const runtimeGlobalMcp = '/opt/homebrew/Cellar/node/25.2.1/lib/node_modules/@playwright/mcp/cli.js';
- const npmRootGlobal = '/Users/jakevin/.nvm/versions/node/v22.14.0/lib/node_modules';
- const npmGlobalMcp = '/Users/jakevin/.nvm/versions/node/v22.14.0/lib/node_modules/@playwright/mcp/cli.js';
- Object.defineProperty(process, 'execPath', {
- value: runtimeExecPath,
- configurable: true,
- });
-
- const existsSync = vi.fn((candidate: any) => candidate === npmGlobalMcp);
- const execSync = vi.fn((command: string) => {
- if (String(command).includes('npm root -g')) return `${npmRootGlobal}\n` as any;
- throw new Error(`unexpected command: ${String(command)}`);
- });
- __test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync as any });
-
- try {
- expect(__test__.findMcpServerPath()).toBe(npmGlobalMcp);
- expect(execSync).toHaveBeenCalledOnce();
- expect(existsSync).toHaveBeenCalledWith(runtimeGlobalMcp);
- expect(existsSync).toHaveBeenCalledWith(npmGlobalMcp);
- } finally {
- Object.defineProperty(process, 'execPath', {
- value: originalExecPath,
- configurable: true,
- });
- }
- });
-
- it('returns null when new global discovery paths are unavailable', () => {
- const originalExecPath = process.execPath;
- const runtimeExecPath = '/opt/homebrew/Cellar/node/25.2.1/bin/node';
- Object.defineProperty(process, 'execPath', {
- value: runtimeExecPath,
- configurable: true,
- });
-
- const existsSync = vi.fn(() => false);
- const execSync = vi.fn((command: string) => {
- if (String(command).includes('npm root -g')) return '/missing/global/node_modules\n' as any;
- throw new Error(`missing command: ${String(command)}`);
- });
- __test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync as any });
-
- try {
- expect(__test__.findMcpServerPath()).toBeNull();
- } finally {
- Object.defineProperty(process, 'execPath', {
- value: originalExecPath,
- configurable: true,
- });
- }
- });
-
- it('ignores non-server playwright cli paths discovered from fallback scans', () => {
- const wrongCli = '/root/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/lib/mcp/terminal/cli.js';
- const npxCacheBase = path.join(os.homedir(), '.npm', '_npx');
-
- const existsSync = vi.fn((candidate: any) => {
- const value = String(candidate);
- return value === npxCacheBase || value === wrongCli;
- });
-
- const execSync = vi.fn((command: string) => {
- if (String(command).includes('npm root -g')) return '/missing/global/node_modules\n' as any;
- if (String(command).includes('--package=@playwright/mcp which mcp-server-playwright')) return `${wrongCli}\n` as any;
- if (String(command).includes('which mcp-server-playwright')) return '' as any;
- if (String(command).includes(`find "${npxCacheBase}"`)) return `${wrongCli}\n` as any;
- throw new Error(`unexpected command: ${String(command)}`);
- });
- __test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync as any });
-
- expect(__test__.findMcpServerPath()).toBeNull();
- });
});
describe('PlaywrightMCP state', () => {
@@ -288,22 +60,20 @@ describe('PlaywrightMCP state', () => {
const mcp = new PlaywrightMCP();
await mcp.close();
- await expect(mcp.connect()).rejects.toThrow('Playwright MCP session is closed');
+ await expect(mcp.connect()).rejects.toThrow('Session is closed');
});
it('rejects connect() while already connecting', async () => {
const mcp = new PlaywrightMCP();
(mcp as any)._state = 'connecting';
- await expect(mcp.connect()).rejects.toThrow('Playwright MCP is already connecting');
+ await expect(mcp.connect()).rejects.toThrow('Already connecting');
});
it('rejects connect() while closing', async () => {
const mcp = new PlaywrightMCP();
(mcp as any)._state = 'closing';
- await expect(mcp.connect()).rejects.toThrow('Playwright MCP is closing');
+ await expect(mcp.connect()).rejects.toThrow('Session is closing');
});
-
-
});
diff --git a/src/browser/daemon-client.ts b/src/browser/daemon-client.ts
new file mode 100644
index 0000000..8d7b935
--- /dev/null
+++ b/src/browser/daemon-client.ts
@@ -0,0 +1,113 @@
+/**
+ * HTTP client for communicating with the opencli daemon.
+ *
+ * Provides a typed send() function that posts a Command and returns a Result.
+ */
+
+const DAEMON_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? '19825', 10);
+const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
+
+let _idCounter = 0;
+
+function generateId(): string {
+ return `cmd_${Date.now()}_${++_idCounter}`;
+}
+
+export interface DaemonCommand {
+ id: string;
+ action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot';
+ tabId?: number;
+ code?: string;
+ url?: string;
+ op?: string;
+ index?: number;
+ domain?: string;
+ format?: 'png' | 'jpeg';
+ quality?: number;
+ fullPage?: boolean;
+}
+
+export interface DaemonResult {
+ id: string;
+ ok: boolean;
+ data?: unknown;
+ error?: string;
+}
+
+/**
+ * Check if daemon is running.
+ */
+export async function isDaemonRunning(): Promise {
+ try {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), 2000);
+ const res = await fetch(`${DAEMON_URL}/status`, { signal: controller.signal });
+ clearTimeout(timer);
+ return res.ok;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Check if daemon is running AND the extension is connected.
+ */
+export async function isExtensionConnected(): Promise {
+ try {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), 2000);
+ const res = await fetch(`${DAEMON_URL}/status`, { signal: controller.signal });
+ clearTimeout(timer);
+ if (!res.ok) return false;
+ const data = await res.json() as { extensionConnected?: boolean };
+ return !!data.extensionConnected;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Send a command to the daemon and wait for a result.
+ * Retries up to 3 times with 500ms delay for transient failures.
+ */
+export async function sendCommand(
+ action: DaemonCommand['action'],
+ params: Omit = {},
+): Promise {
+ const id = generateId();
+ const command: DaemonCommand = { id, action, ...params };
+ const maxRetries = 3;
+
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
+ try {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), 30000);
+
+ const res = await fetch(`${DAEMON_URL}/command`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(command),
+ signal: controller.signal,
+ });
+ clearTimeout(timer);
+
+ const result = (await res.json()) as DaemonResult;
+
+ if (!result.ok) {
+ throw new Error(result.error ?? 'Daemon command failed');
+ }
+
+ return result.data;
+ } catch (err) {
+ const isRetryable = err instanceof TypeError // fetch network error
+ || (err instanceof Error && err.name === 'AbortError');
+ if (isRetryable && attempt < maxRetries) {
+ await new Promise(r => setTimeout(r, 500));
+ continue;
+ }
+ throw err;
+ }
+ }
+ // Unreachable — the loop always returns or throws
+ throw new Error('sendCommand: max retries exhausted');
+}
diff --git a/src/browser/discover.ts b/src/browser/discover.ts
index 1a51fb0..ea269c7 100644
--- a/src/browser/discover.ts
+++ b/src/browser/discover.ts
@@ -1,241 +1,27 @@
/**
- * MCP server path discovery and argument building.
+ * Daemon discovery — simplified from MCP server path discovery.
+ *
+ * Only needs to check if the daemon is running. No more file system
+ * scanning for @playwright/mcp locations.
*/
-import { execSync } from 'node:child_process';
-import { fileURLToPath } from 'node:url';
-import * as fs from 'node:fs';
-import * as os from 'node:os';
-import * as path from 'node:path';
-
-let _cachedMcpServerPath: string | null | undefined;
-let _existsSync = fs.existsSync;
-let _execSync = execSync;
-
-function isSupportedMcpEntrypoint(candidate: string): boolean {
- const normalized = candidate.replace(/\\/g, '/').toLowerCase();
- return normalized.endsWith('/@playwright/mcp/cli.js') ||
- normalized.endsWith('/mcp-server-playwright') ||
- normalized.endsWith('/mcp-server-playwright.js');
-}
-
-function resolveSupportedMcpPath(candidate: string | null | undefined): string | null {
- const trimmed = candidate?.trim();
- if (!trimmed || !_existsSync(trimmed)) return null;
- return isSupportedMcpEntrypoint(trimmed) ? trimmed : null;
-}
-
-export function resetMcpServerPathCache(): void {
- _cachedMcpServerPath = undefined;
-}
-
-export function setMcpDiscoveryTestHooks(input?: {
- existsSync?: typeof fs.existsSync;
- execSync?: typeof execSync;
-}): void {
- _existsSync = input?.existsSync ?? fs.existsSync;
- _execSync = input?.execSync ?? execSync;
-}
-
-export function findMcpServerPath(): string | null {
- if (_cachedMcpServerPath !== undefined) return _cachedMcpServerPath;
-
- const envMcp = process.env.OPENCLI_MCP_SERVER_PATH;
- if (envMcp && _existsSync(envMcp)) {
- _cachedMcpServerPath = envMcp;
- return _cachedMcpServerPath;
- }
-
- // Check local node_modules first (@playwright/mcp is the modern package)
- const localMcp = path.resolve('node_modules', '@playwright', 'mcp', 'cli.js');
- if (_existsSync(localMcp)) {
- _cachedMcpServerPath = localMcp;
- return _cachedMcpServerPath;
- }
+import { isDaemonRunning } from './daemon-client.js';
- // Check project-relative path
- const __dirname2 = path.dirname(fileURLToPath(import.meta.url));
- const projectMcp = path.resolve(__dirname2, '..', '..', 'node_modules', '@playwright', 'mcp', 'cli.js');
- if (_existsSync(projectMcp)) {
- _cachedMcpServerPath = projectMcp;
- return _cachedMcpServerPath;
- }
-
- // Check global npm/yarn locations derived from current Node runtime.
- const nodePrefix = path.resolve(path.dirname(process.execPath), '..');
- const globalNodeModules = path.join(nodePrefix, 'lib', 'node_modules');
- const globalMcp = path.join(globalNodeModules, '@playwright', 'mcp', 'cli.js');
- if (_existsSync(globalMcp)) {
- _cachedMcpServerPath = globalMcp;
- return _cachedMcpServerPath;
- }
-
- // Check npm global root directly.
- try {
- const npmRootGlobal = _execSync('npm root -g 2>/dev/null', {
- encoding: 'utf-8',
- timeout: 5000,
- }).trim();
- const npmGlobalMcp = path.join(npmRootGlobal, '@playwright', 'mcp', 'cli.js');
- if (npmRootGlobal && _existsSync(npmGlobalMcp)) {
- _cachedMcpServerPath = npmGlobalMcp;
- return _cachedMcpServerPath;
- }
- } catch {}
-
- // Check common locations
- const candidates = [
- path.join(os.homedir(), '.npm', '_npx'),
- path.join(os.homedir(), 'node_modules', '.bin'),
- '/usr/local/lib/node_modules',
- ];
-
- // Try npx resolution (legacy package name)
- try {
- const result = _execSync('npx -y --package=@playwright/mcp which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 10000 }).trim();
- const resolved = resolveSupportedMcpPath(result);
- if (resolved) {
- _cachedMcpServerPath = resolved;
- return _cachedMcpServerPath;
- }
- } catch {}
-
- // Try which
- try {
- const result = _execSync('which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 5000 }).trim();
- const resolved = resolveSupportedMcpPath(result);
- if (resolved) {
- _cachedMcpServerPath = resolved;
- return _cachedMcpServerPath;
- }
- } catch {}
-
- // Search in common npx cache
- for (const base of candidates) {
- if (!_existsSync(base)) continue;
- try {
- const found = _execSync(`find "${base}" -type f -path "*/@playwright/mcp/cli.js" 2>/dev/null | head -1`, { encoding: 'utf-8', timeout: 5000 }).trim();
- const resolved = resolveSupportedMcpPath(found);
- if (resolved) {
- _cachedMcpServerPath = resolved;
- return _cachedMcpServerPath;
- }
- } catch {}
- }
-
- _cachedMcpServerPath = null;
- return _cachedMcpServerPath;
-}
+export { isDaemonRunning };
/**
- * Chrome 144+ auto-discovery: read DevToolsActivePort file to get CDP endpoint.
- *
- * Starting with Chrome 144, users can enable remote debugging from
- * chrome://inspect#remote-debugging without any command-line flags.
- * Chrome writes the active port and browser GUID to a DevToolsActivePort file
- * in the user data directory, which we read to construct the WebSocket endpoint.
+ * Check daemon status and return connection info.
*/
-export function discoverChromeEndpoint(): string | null {
- const candidates: string[] = [];
-
- // User-specified Chrome data dir takes highest priority
- if (process.env.CHROME_USER_DATA_DIR) {
- candidates.push(path.join(process.env.CHROME_USER_DATA_DIR, 'DevToolsActivePort'));
- }
-
- // Standard Chrome/Edge user data dirs per platform
- if (process.platform === 'win32') {
- const localAppData = process.env.LOCALAPPDATA ?? path.join(os.homedir(), 'AppData', 'Local');
- candidates.push(path.join(localAppData, 'Google', 'Chrome', 'User Data', 'DevToolsActivePort'));
- candidates.push(path.join(localAppData, 'Microsoft', 'Edge', 'User Data', 'DevToolsActivePort'));
- } else if (process.platform === 'darwin') {
- candidates.push(path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome', 'DevToolsActivePort'));
- candidates.push(path.join(os.homedir(), 'Library', 'Application Support', 'Microsoft Edge', 'DevToolsActivePort'));
- } else {
- candidates.push(path.join(os.homedir(), '.config', 'google-chrome', 'DevToolsActivePort'));
- candidates.push(path.join(os.homedir(), '.config', 'chromium', 'DevToolsActivePort'));
- candidates.push(path.join(os.homedir(), '.config', 'microsoft-edge', 'DevToolsActivePort'));
- }
-
- for (const filePath of candidates) {
- try {
- const content = fs.readFileSync(filePath, 'utf-8').trim();
- const lines = content.split('\n');
- if (lines.length >= 2) {
- const port = parseInt(lines[0], 10);
- const browserPath = lines[1]; // e.g. /devtools/browser/
- if (port > 0 && browserPath.startsWith('/devtools/browser/')) {
- return `ws://127.0.0.1:${port}${browserPath}`;
- }
- }
- } catch {}
- }
- return null;
-}
-
-export function resolveCdpEndpoint(): { endpoint?: string; requestedCdp: boolean } {
- const envVal = process.env.OPENCLI_CDP_ENDPOINT;
- if (envVal === '1' || envVal?.toLowerCase() === 'true') {
- const autoDiscovered = discoverChromeEndpoint();
- return { endpoint: autoDiscovered ?? envVal, requestedCdp: true };
- }
-
- if (envVal) {
- return { endpoint: envVal, requestedCdp: true };
- }
-
- // Fallback to auto-discovery if not explicitly set
- const autoDiscovered = discoverChromeEndpoint();
- if (autoDiscovered) {
- return { endpoint: autoDiscovered, requestedCdp: true };
- }
-
- return { requestedCdp: false };
-}
-
-function buildRuntimeArgs(input?: { executablePath?: string | null; cdpEndpoint?: string }): string[] {
- const args: string[] = [];
-
- // Priority 1: CDP endpoint (remote Chrome debugging or local Auto-Discovery)
- if (input?.cdpEndpoint) {
- args.push('--cdp-endpoint', input.cdpEndpoint);
- return args;
- }
-
- // Priority 2: Extension mode (local Chrome with MCP Bridge extension)
- if (!process.env.CI) {
- args.push('--extension');
- }
-
- // CI/standalone mode: @playwright/mcp launches its own browser (headed by default).
- // xvfb provides a virtual display for headed mode in GitHub Actions.
- if (input?.executablePath) {
- args.push('--executable-path', input.executablePath);
- }
- return args;
-}
-
-export function buildMcpArgs(input: { mcpPath: string; executablePath?: string | null; cdpEndpoint?: string }): string[] {
- return [input.mcpPath, ...buildRuntimeArgs(input)];
-}
-
-export function buildMcpLaunchSpec(input: { mcpPath?: string | null; executablePath?: string | null; cdpEndpoint?: string }): {
- command: string;
- args: string[];
- usedNpxFallback: boolean;
-} {
- const runtimeArgs = buildRuntimeArgs(input);
- if (input.mcpPath) {
- return {
- command: 'node',
- args: [input.mcpPath, ...runtimeArgs],
- usedNpxFallback: false,
- };
+export async function checkDaemonStatus(): Promise<{
+ running: boolean;
+ extensionConnected: boolean;
+}> {
+ try {
+ const port = parseInt(process.env.OPENCLI_DAEMON_PORT ?? '19825', 10);
+ const res = await fetch(`http://127.0.0.1:${port}/status`);
+ const data = await res.json() as { ok: boolean; extensionConnected: boolean };
+ return { running: true, extensionConnected: data.extensionConnected };
+ } catch {
+ return { running: false, extensionConnected: false };
}
-
- return {
- command: 'npx',
- args: ['-y', '@playwright/mcp@latest', ...runtimeArgs],
- usedNpxFallback: true,
- };
}
diff --git a/src/browser/errors.ts b/src/browser/errors.ts
index db41fbb..3df5e44 100644
--- a/src/browser/errors.ts
+++ b/src/browser/errors.ts
@@ -1,105 +1,35 @@
/**
- * Browser connection error classification and formatting.
+ * Browser connection error helpers.
+ *
+ * Simplified — no more token/extension/CDP classification.
+ * The daemon architecture has a single failure mode: daemon not reachable or extension not connected.
*/
-import { createHash } from 'node:crypto';
-
-export type ConnectFailureKind = 'missing-token' | 'extension-timeout' | 'extension-not-installed' | 'mcp-init' | 'process-exit' | 'cdp-connection-failed' | 'unknown';
-
-export type ConnectFailureInput = {
- kind: ConnectFailureKind;
- timeout: number;
- hasExtensionToken: boolean;
- tokenFingerprint?: string | null;
- stderr?: string;
- exitCode?: number | null;
- rawMessage?: string;
-};
-
-export function getTokenFingerprint(token: string | undefined): string | null {
- if (!token) return null;
- return createHash('sha256').update(token).digest('hex').slice(0, 8);
-}
-
-export function formatBrowserConnectError(input: ConnectFailureInput): Error {
- const stderr = input.stderr?.trim();
- const suffix = stderr ? `\n\nMCP stderr:\n${stderr}` : '';
- const tokenHint = input.tokenFingerprint ? ` Token fingerprint: ${input.tokenFingerprint}.` : '';
-
- if (input.kind === 'cdp-connection-failed') {
- return new Error(
- `Failed to connect to remote Chrome via CDP endpoint.\n\n` +
- `Check if Chrome is running with remote debugging enabled (--remote-debugging-port=9222) or DevToolsActivePort is available under chrome://inspect#remote-debugging.\n` +
- `If you specified OPENCLI_CDP_ENDPOINT=1, auto-discovery might have failed.` +
- suffix,
- );
- }
-
- if (input.kind === 'missing-token') {
- return new Error(
- 'Failed to connect to Playwright MCP Bridge: PLAYWRIGHT_MCP_EXTENSION_TOKEN is not set.\n\n' +
- 'Without this token, Chrome will show a manual approval dialog for every new MCP connection. ' +
- 'Copy the token from the Playwright MCP Bridge extension and set it in BOTH your shell environment and MCP client config.' +
- suffix,
- );
- }
-
- if (input.kind === 'extension-not-installed') {
- return new Error(
- 'Failed to connect to Playwright MCP Bridge: the browser extension did not attach.\n\n' +
- 'Make sure Chrome is running and the "Playwright MCP Bridge" extension is installed and enabled. ' +
- 'If Chrome shows an approval dialog, click Allow.' +
- suffix,
- );
- }
-
- if (input.kind === 'extension-timeout') {
- const likelyCause = input.hasExtensionToken
- ? `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.`
- : 'PLAYWRIGHT_MCP_EXTENSION_TOKEN is not configured, so the extension may be waiting for manual approval.';
- return new Error(
- `Timed out connecting to Playwright MCP Bridge (${input.timeout}s).\n\n` +
- `${likelyCause} If a browser prompt is visible, click Allow.` +
- suffix,
- );
- }
-
- if (input.kind === 'mcp-init') {
- return new Error(`Failed to initialize Playwright MCP: ${input.rawMessage ?? 'unknown error'}${suffix}`);
+export type ConnectFailureKind = 'daemon-not-running' | 'extension-not-connected' | 'command-failed' | 'unknown';
+
+export function formatBrowserConnectError(kind: ConnectFailureKind, detail?: string): Error {
+ switch (kind) {
+ case 'daemon-not-running':
+ return new Error(
+ 'Cannot connect to opencli daemon.\n\n' +
+ 'The daemon should start automatically. If it doesn\'t, try:\n' +
+ ' node dist/daemon.js\n' +
+ 'Make sure port 19825 is available.' +
+ (detail ? `\n\n${detail}` : ''),
+ );
+ case 'extension-not-connected':
+ return new Error(
+ 'opencli Browser Bridge extension is not connected.\n\n' +
+ 'Please install the extension:\n' +
+ ' 1. Download from GitHub Releases\n' +
+ ' 2. Open chrome://extensions/ → Enable Developer Mode\n' +
+ ' 3. Click "Load unpacked" → select the extension folder\n' +
+ ' 4. Make sure Chrome is running' +
+ (detail ? `\n\n${detail}` : ''),
+ );
+ case 'command-failed':
+ return new Error(`Browser command failed: ${detail ?? 'unknown error'}`);
+ default:
+ return new Error(detail ?? 'Failed to connect to browser');
}
-
- if (input.kind === 'process-exit') {
- return new Error(
- `Playwright MCP process exited before the browser connection was established${input.exitCode == null ? '' : ` (code ${input.exitCode})`}.` +
- suffix,
- );
- }
-
- return new Error(input.rawMessage ?? 'Failed to connect to browser');
-}
-
-export function inferConnectFailureKind(args: {
- hasExtensionToken: boolean;
- stderr: string;
- rawMessage?: string;
- exited?: boolean;
- isCdpMode?: boolean;
-}): ConnectFailureKind {
- const haystack = `${args.rawMessage ?? ''}\n${args.stderr}`.toLowerCase();
-
- if (args.isCdpMode) {
- if (args.rawMessage?.startsWith('MCP init failed:')) return 'mcp-init';
- if (args.exited) return 'cdp-connection-failed';
- return 'cdp-connection-failed';
- }
-
- if (!args.hasExtensionToken)
- return 'missing-token';
- if (haystack.includes('extension connection timeout') || haystack.includes('playwright mcp bridge'))
- return 'extension-not-installed';
- if (args.rawMessage?.startsWith('MCP init failed:'))
- return 'mcp-init';
- if (args.exited)
- return 'process-exit';
- return 'extension-timeout';
}
diff --git a/src/browser/index.ts b/src/browser/index.ts
index 1ad2f8b..0c08f08 100644
--- a/src/browser/index.ts
+++ b/src/browser/index.ts
@@ -7,25 +7,19 @@
export { Page } from './page.js';
export { PlaywrightMCP } from './mcp.js';
-export { getTokenFingerprint, formatBrowserConnectError } from './errors.js';
-export type { ConnectFailureKind, ConnectFailureInput } from './errors.js';
-export { resolveCdpEndpoint } from './discover.js';
+export { isDaemonRunning } from './daemon-client.js';
+
+// Backward compatibility: getTokenFingerprint is no longer needed but kept as no-op export
+export function getTokenFingerprint(_token: string | undefined): string | null {
+ return null;
+}
-// Test-only helpers — exposed for unit tests
-import { createJsonRpcRequest } from './mcp.js';
import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
-import { buildMcpArgs, buildMcpLaunchSpec, findMcpServerPath, resetMcpServerPathCache, setMcpDiscoveryTestHooks } from './discover.js';
import { withTimeoutMs } from '../runtime.js';
export const __test__ = {
- createJsonRpcRequest,
extractTabEntries,
diffTabIndexes,
appendLimited,
- buildMcpArgs,
- buildMcpLaunchSpec,
- findMcpServerPath,
- resetMcpServerPathCache,
- setMcpDiscoveryTestHooks,
withTimeoutMs,
};
diff --git a/src/browser/mcp.ts b/src/browser/mcp.ts
index b0967fd..4378fec 100644
--- a/src/browser/mcp.ts
+++ b/src/browser/mcp.ts
@@ -1,312 +1,112 @@
/**
- * Playwright MCP process manager.
- * Handles lifecycle management, JSON-RPC communication, and browser session orchestration.
+ * Browser session manager — auto-spawns daemon and provides IPage.
+ *
+ * Replaces the old PlaywrightMCP class. Still exports as PlaywrightMCP
+ * for backward compatibility with main.ts and other consumers.
*/
import { spawn, type ChildProcess } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+import * as path from 'node:path';
+import * as fs from 'node:fs';
import type { IPage } from '../types.js';
-import { withTimeoutMs, DEFAULT_BROWSER_CONNECT_TIMEOUT } from '../runtime.js';
-import { PKG_VERSION } from '../version.js';
import { Page } from './page.js';
-import { getTokenFingerprint, formatBrowserConnectError, inferConnectFailureKind } from './errors.js';
-import { findMcpServerPath, buildMcpLaunchSpec, resolveCdpEndpoint } from './discover.js';
-import { extractTabIdentities, extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
+import { isDaemonRunning, isExtensionConnected } from './daemon-client.js';
-const STDERR_BUFFER_LIMIT = 16 * 1024;
-const INITIAL_TABS_TIMEOUT_MS = 1500;
-const TAB_CLEANUP_TIMEOUT_MS = 2000;
+const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension
export type PlaywrightMCPState = 'idle' | 'connecting' | 'connected' | 'closing' | 'closed';
-// JSON-RPC helpers
-let _nextId = 1;
-export function createJsonRpcRequest(method: string, params: Record = {}): { id: number; message: string } {
- const id = _nextId++;
- return {
- id,
- message: JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n',
- };
-}
+
/**
- * Playwright MCP process manager.
+ * Browser factory: manages daemon lifecycle and provides IPage instances.
+ *
+ * Kept as `PlaywrightMCP` class name for backward compatibility.
*/
export class PlaywrightMCP {
- private static _activeInsts: Set = new Set();
- private static _cleanupRegistered = false;
-
- private static _registerGlobalCleanup() {
- if (this._cleanupRegistered) return;
- this._cleanupRegistered = true;
- const cleanup = () => {
- for (const inst of this._activeInsts) {
- if (inst._proc && !inst._proc.killed) {
- try { inst._proc.kill('SIGKILL'); } catch {}
- }
- }
- };
- process.on('exit', cleanup);
- process.on('SIGINT', () => { cleanup(); process.exit(130); });
- process.on('SIGTERM', () => { cleanup(); process.exit(143); });
- }
-
- private _proc: ChildProcess | null = null;
- private _buffer = '';
- private _pending = new Map void; reject: (error: Error) => void }>();
- private _initialTabIdentities: string[] = [];
- private _closingPromise: Promise | null = null;
private _state: PlaywrightMCPState = 'idle';
-
private _page: Page | null = null;
+ private _daemonProc: ChildProcess | null = null;
get state(): PlaywrightMCPState {
return this._state;
}
- private _sendRequest(method: string, params: Record = {}): Promise {
- return new Promise((resolve, reject) => {
- if (!this._proc?.stdin?.writable) {
- reject(new Error('Playwright MCP process is not writable'));
- return;
- }
- const { id, message } = createJsonRpcRequest(method, params);
- this._pending.set(id, { resolve, reject });
- this._proc.stdin.write(message, (err) => {
- if (!err) return;
- this._pending.delete(id);
- reject(err);
- });
- });
- }
-
- private _rejectPendingRequests(error: Error): void {
- const pending = [...this._pending.values()];
- this._pending.clear();
- for (const waiter of pending) waiter.reject(error);
- }
-
- private _resetAfterFailedConnect(): void {
- const proc = this._proc;
- this._page = null;
- this._proc = null;
- this._buffer = '';
- this._initialTabIdentities = [];
- this._rejectPendingRequests(new Error('Playwright MCP connect failed'));
- PlaywrightMCP._activeInsts.delete(this);
- if (proc && !proc.killed) {
- try { proc.kill('SIGKILL'); } catch {}
- }
- }
-
async connect(opts: { timeout?: number } = {}): Promise {
if (this._state === 'connected' && this._page) return this._page;
- if (this._state === 'connecting') throw new Error('Playwright MCP is already connecting');
- if (this._state === 'closing') throw new Error('Playwright MCP is closing');
- if (this._state === 'closed') throw new Error('Playwright MCP session is closed');
+ if (this._state === 'connecting') throw new Error('Already connecting');
+ if (this._state === 'closing') throw new Error('Session is closing');
+ if (this._state === 'closed') throw new Error('Session is closed');
- const mcpPath = findMcpServerPath();
-
- PlaywrightMCP._registerGlobalCleanup();
- PlaywrightMCP._activeInsts.add(this);
this._state = 'connecting';
- const timeout = opts.timeout ?? DEFAULT_BROWSER_CONNECT_TIMEOUT;
-
- return new Promise((resolve, reject) => {
- const isDebug = process.env.DEBUG?.includes('opencli:mcp');
- const debugLog = (msg: string) => isDebug && console.error(`[opencli:mcp] ${msg}`);
- const { endpoint: cdpEndpoint, requestedCdp } = resolveCdpEndpoint();
- const useExtension = !requestedCdp;
- const extensionToken = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
- const tokenFingerprint = getTokenFingerprint(extensionToken);
- let stderrBuffer = '';
- let settled = false;
-
- const settleError = (kind: Parameters[0]['kind'], extra: { rawMessage?: string; exitCode?: number | null } = {}) => {
- if (settled) return;
- settled = true;
- this._state = 'idle';
- clearTimeout(timer);
- this._resetAfterFailedConnect();
- reject(formatBrowserConnectError({
- kind,
- timeout,
- hasExtensionToken: !!extensionToken,
- tokenFingerprint,
- stderr: stderrBuffer,
- exitCode: extra.exitCode,
- rawMessage: extra.rawMessage,
- }));
- };
-
- const settleSuccess = (pageToResolve: Page) => {
- if (settled) return;
- settled = true;
- this._state = 'connected';
- clearTimeout(timer);
- resolve(pageToResolve);
- };
-
- const timer = setTimeout(() => {
- debugLog('Connection timed out');
- settleError(inferConnectFailureKind({
- hasExtensionToken: !!extensionToken,
- stderr: stderrBuffer,
- isCdpMode: requestedCdp,
- }));
- }, timeout * 1000);
- const launchSpec = buildMcpLaunchSpec({
- mcpPath,
- executablePath: process.env.OPENCLI_BROWSER_EXECUTABLE_PATH,
- cdpEndpoint,
- });
- if (process.env.OPENCLI_VERBOSE) {
- console.error(`[opencli] Mode: ${requestedCdp ? 'CDP' : useExtension ? 'extension' : 'standalone'}`);
- if (useExtension) console.error(`[opencli] Extension token: fingerprint ${tokenFingerprint}`);
- if (launchSpec.usedNpxFallback) {
- console.error('[opencli] Playwright MCP not found locally; bootstrapping via npx @playwright/mcp@latest');
- }
- }
- debugLog(`Spawning ${launchSpec.command} ${launchSpec.args.join(' ')}`);
-
- this._proc = spawn(launchSpec.command, launchSpec.args, {
- stdio: ['pipe', 'pipe', 'pipe'],
- env: { ...process.env },
- });
-
- // Increase max listeners to avoid warnings
- this._proc.setMaxListeners(20);
- if (this._proc.stdout) this._proc.stdout.setMaxListeners(20);
-
- const page = new Page((method, params = {}) => this._sendRequest(method, params));
- this._page = page;
-
- this._proc.stdout?.on('data', (chunk: Buffer) => {
- this._buffer += chunk.toString();
- const lines = this._buffer.split('\n');
- this._buffer = lines.pop() ?? '';
- for (const line of lines) {
- if (!line.trim()) continue;
- debugLog(`RECV: ${line}`);
- try {
- const parsed = JSON.parse(line);
- if (typeof parsed?.id === 'number') {
- const waiter = this._pending.get(parsed.id);
- if (waiter) {
- this._pending.delete(parsed.id);
- waiter.resolve(parsed);
- }
- }
- } catch (e) {
- debugLog(`Parse error: ${e}`);
- }
- }
- });
+ try {
+ await this._ensureDaemon();
+ this._page = new Page();
+ this._state = 'connected';
+ return this._page;
+ } catch (err) {
+ this._state = 'idle';
+ throw err;
+ }
+ }
- this._proc.stderr?.on('data', (chunk: Buffer) => {
- const text = chunk.toString();
- stderrBuffer = appendLimited(stderrBuffer, text, STDERR_BUFFER_LIMIT);
- debugLog(`STDERR: ${text}`);
- });
- this._proc.on('error', (err) => {
- debugLog(`Subprocess error: ${err.message}`);
- this._rejectPendingRequests(new Error(`Playwright MCP process error: ${err.message}`));
- settleError('process-exit', { rawMessage: err.message });
- });
- this._proc.on('close', (code) => {
- debugLog(`Subprocess closed with code ${code}`);
- this._rejectPendingRequests(new Error(`Playwright MCP process exited before response${code == null ? '' : ` (code ${code})`}`));
- if (!settled) {
- settleError(inferConnectFailureKind({
- hasExtensionToken: !!extensionToken,
- stderr: stderrBuffer,
- exited: true,
- isCdpMode: requestedCdp,
- }), { exitCode: code });
- }
- });
+ async close(): Promise {
+ if (this._state === 'closed') return;
+ this._state = 'closing';
+ // We don't kill the daemon — it auto-exits on idle.
+ // Just clean up our reference.
+ this._page = null;
+ this._state = 'closed';
+ }
- // Initialize: send initialize request
- debugLog('Waiting for initialize response...');
- this._sendRequest('initialize', {
- protocolVersion: '2024-11-05',
- capabilities: {},
- clientInfo: { name: 'opencli', version: PKG_VERSION },
- }).then((resp: any) => {
- debugLog('Got initialize response');
- if (resp.error) {
- settleError(inferConnectFailureKind({
- hasExtensionToken: !!extensionToken,
- stderr: stderrBuffer,
- rawMessage: `MCP init failed: ${resp.error.message}`,
- isCdpMode: requestedCdp,
- }), { rawMessage: resp.error.message });
- return;
- }
-
- const initializedMsg = JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n';
- debugLog(`SEND: ${initializedMsg.trim()}`);
- this._proc?.stdin?.write(initializedMsg);
+ private async _ensureDaemon(): Promise {
+ if (await isDaemonRunning()) return;
+
+ // Find daemon relative to this file — works for both:
+ // npx tsx src/main.ts → src/browser/mcp.ts → src/daemon.ts
+ // node dist/main.js → dist/browser/mcp.js → dist/daemon.js
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
+ const parentDir = path.resolve(__dirname, '..');
+ const daemonTs = path.join(parentDir, 'daemon.ts');
+ const daemonJs = path.join(parentDir, 'daemon.js');
+ const isTs = fs.existsSync(daemonTs);
+ const daemonPath = isTs ? daemonTs : daemonJs;
+
+ if (process.env.OPENCLI_VERBOSE) {
+ console.error(`[opencli] Starting daemon (${isTs ? 'ts' : 'js'})...`);
+ }
- // Use tabs as a readiness probe and for tab cleanup bookkeeping.
- debugLog('Fetching initial tabs count...');
- withTimeoutMs(page.tabs(), INITIAL_TABS_TIMEOUT_MS, 'Timed out fetching initial tabs').then((tabs: any) => {
- debugLog(`Tabs response: ${typeof tabs === 'string' ? tabs : JSON.stringify(tabs)}`);
- this._initialTabIdentities = extractTabIdentities(tabs);
- settleSuccess(page);
- }).catch((err: Error) => {
- debugLog(`Tabs fetch error: ${err.message}`);
- settleSuccess(page);
- });
- }).catch((err: Error) => {
- debugLog(`Init promise rejected: ${err.message}`);
- settleError('mcp-init', { rawMessage: err.message });
- });
+ // Use the current runtime to spawn daemon — avoids slow npx resolution.
+ // If already running under tsx (dev), process.execPath is tsx's node.
+ // If running compiled (node dist/), process.execPath is node.
+ this._daemonProc = spawn(process.execPath, [daemonPath], {
+ detached: true,
+ stdio: 'ignore',
+ env: { ...process.env },
});
- }
+ this._daemonProc.unref();
+ // Wait for daemon to be ready AND extension to connect
+ const deadline = Date.now() + DAEMON_SPAWN_TIMEOUT;
+ while (Date.now() < deadline) {
+ await new Promise(resolve => setTimeout(resolve, 300));
+ if (await isExtensionConnected()) return;
+ }
- async close(): Promise {
- if (this._closingPromise) return this._closingPromise;
- if (this._state === 'closed') return;
- this._state = 'closing';
- this._closingPromise = (async () => {
- try {
- // Extension mode opens bridge/session tabs that we can clean up best-effort.
- if (this._page && this._proc && !this._proc.killed) {
- try {
- const tabs = await withTimeoutMs(this._page.tabs(), TAB_CLEANUP_TIMEOUT_MS, 'Timed out fetching tabs during cleanup');
- const tabEntries = extractTabEntries(tabs);
- const tabsToClose = diffTabIndexes(this._initialTabIdentities, tabEntries);
- for (const index of tabsToClose) {
- try { await this._page.closeTab(index); } catch {}
- }
- } catch {}
- }
- if (this._proc && !this._proc.killed) {
- this._proc.kill('SIGTERM');
- const exited = await new Promise((res) => {
- let done = false;
- const finish = (value: boolean) => {
- if (done) return;
- done = true;
- res(value);
- };
- this._proc?.once('exit', () => finish(true));
- setTimeout(() => finish(false), 3000);
- });
- if (!exited && this._proc && !this._proc.killed) {
- try { this._proc.kill('SIGKILL'); } catch {}
- }
- }
- } finally {
- this._rejectPendingRequests(new Error('Playwright MCP session closed'));
- this._page = null;
- this._proc = null;
- this._state = 'closed';
- PlaywrightMCP._activeInsts.delete(this);
- }
- })();
- return this._closingPromise;
+ // Daemon might be up but extension not connected — give a useful error
+ if (await isDaemonRunning()) {
+ throw new Error(
+ 'Daemon is running but the Browser Extension is not connected.\n' +
+ 'Please install and enable the opencli Browser Bridge extension in Chrome.',
+ );
+ }
+
+ throw new Error(
+ 'Failed to start opencli daemon. Try running manually:\n' +
+ ` node ${daemonPath}\n` +
+ 'Make sure port 19825 is available.',
+ );
}
}
diff --git a/src/browser/page.ts b/src/browser/page.ts
index 23abeb6..af9c7b7 100644
--- a/src/browser/page.ts
+++ b/src/browser/page.ts
@@ -1,139 +1,243 @@
/**
- * Page abstraction wrapping JSON-RPC calls to Playwright MCP.
+ * Page abstraction — implements IPage by sending commands to the daemon.
+ *
+ * All browser operations are ultimately 'exec' (JS evaluation via CDP)
+ * plus a few native Chrome Extension APIs (tabs, cookies, navigate).
+ *
+ * IMPORTANT: After goto(), we remember the tabId returned by the navigate
+ * action and pass it to all subsequent commands. This avoids the issue
+ * where resolveTabId() in the extension picks a chrome:// or
+ * chrome-extension:// tab that can't be debugged.
*/
import { formatSnapshot } from '../snapshotFormatter.js';
-import { normalizeEvaluateSource } from '../pipeline/template.js';
-import { generateInterceptorJs, generateReadInterceptedJs } from '../interceptor.js';
import type { IPage } from '../types.js';
-import { BrowserConnectError } from '../errors.js';
+import { sendCommand } from './daemon-client.js';
/**
- * Page abstraction wrapping JSON-RPC calls to Playwright MCP.
+ * Page — implements IPage by talking to the daemon via HTTP.
*/
export class Page implements IPage {
- constructor(private _request: (method: string, params?: Record) => Promise>) {}
-
- async call(method: string, params: Record = {}): Promise {
- const resp = await this._request(method, params);
- if (resp.error) throw new Error(`page.${method}: ${(resp.error as any).message ?? JSON.stringify(resp.error)}`);
- // Extract text content from MCP result
- const result = resp.result as any;
-
- if (result?.isError) {
- const errorText = result.content?.find((c: any) => c.type === 'text')?.text || 'Unknown MCP Error';
- throw new BrowserConnectError(
- errorText,
- 'Please check if the browser is running or if the Playwright MCP / CDP connection is configured correctly.'
- );
- }
-
- if (result?.content) {
- const textParts = result.content.filter((c: any) => c.type === 'text');
- if (textParts.length >= 1) {
- let text = textParts[textParts.length - 1].text; // Usually the main output is in the last text block
-
- // Some versions of the MCP return error text without the `isError` boolean flag
- if (typeof text === 'string' && text.trim().startsWith('### Error')) {
- throw new BrowserConnectError(
- text.trim(),
- 'Please check if the browser is running or if the Playwright MCP / CDP connection is configured correctly.'
- );
- }
+ /** Active tab ID, set after navigate and used in all subsequent commands */
+ private _tabId: number | undefined;
- // MCP browser_evaluate returns: "[JSON]\n### Ran Playwright code\n```js\n...\n```"
- // Strip the "### Ran Playwright code" suffix to get clean JSON
- const codeMarker = text.indexOf('### Ran Playwright code');
- if (codeMarker !== -1) {
- text = text.slice(0, codeMarker).trim();
- }
- // Also handle "### Result\n[JSON]" format (some MCP versions)
- const resultMarker = text.indexOf('### Result\n');
- if (resultMarker !== -1) {
- text = text.slice(resultMarker + '### Result\n'.length).trim();
- }
- try { return JSON.parse(text); } catch { return text; }
- }
- }
- return result;
+ /** Helper: spread tabId into command params if we have one */
+ private _tabOpt(): { tabId: number } | Record {
+ return this._tabId !== undefined ? { tabId: this._tabId } : {};
}
- // --- High-level methods ---
-
async goto(url: string): Promise {
- await this.call('tools/call', { name: 'browser_navigate', arguments: { url } });
+ const result = await sendCommand('navigate', {
+ url,
+ ...this._tabOpt(),
+ }) as { tabId?: number };
+ // Remember the tabId for subsequent exec calls
+ if (result?.tabId) {
+ this._tabId = result.tabId;
+ }
}
async evaluate(js: string): Promise {
- // Normalize IIFE format to function format expected by MCP browser_evaluate
- const normalized = normalizeEvaluateSource(js);
- return this.call('tools/call', { name: 'browser_evaluate', arguments: { function: normalized } });
+ const code = wrapForEval(js);
+ return sendCommand('exec', { code, ...this._tabOpt() });
}
async snapshot(opts: { interactive?: boolean; compact?: boolean; maxDepth?: number; raw?: boolean } = {}): Promise {
- const raw = await this.call('tools/call', { name: 'browser_snapshot', arguments: {} });
+ const maxDepth = Math.max(1, Math.min(Number(opts.maxDepth) || 50, 200));
+ const code = `
+ (async () => {
+ function buildTree(node, depth) {
+ if (depth > ${maxDepth}) return '';
+ const role = node.getAttribute?.('role') || node.tagName?.toLowerCase() || 'generic';
+ const name = node.getAttribute?.('aria-label') || node.getAttribute?.('alt') || node.textContent?.trim().slice(0, 80) || '';
+ const isInteractive = ['a', 'button', 'input', 'select', 'textarea'].includes(node.tagName?.toLowerCase()) || node.getAttribute?.('tabindex') != null;
+
+ ${opts.interactive ? 'if (!isInteractive && !node.children?.length) return "";' : ''}
+
+ let indent = ' '.repeat(depth);
+ let line = indent + role;
+ if (name) line += ' "' + name.replace(/"/g, '\\\\"') + '"';
+ if (node.tagName?.toLowerCase() === 'a' && node.href) line += ' [' + node.href + ']';
+ if (node.tagName?.toLowerCase() === 'input') line += ' [' + (node.type || 'text') + ']';
+
+ let result = line + '\\n';
+ if (node.children) {
+ for (const child of node.children) {
+ result += buildTree(child, depth + 1);
+ }
+ }
+ return result;
+ }
+ return buildTree(document.body, 0);
+ })()
+ `;
+ const raw = await sendCommand('exec', { code, ...this._tabOpt() });
if (opts.raw) return raw;
if (typeof raw === 'string') return formatSnapshot(raw, opts);
return raw;
}
async click(ref: string): Promise {
- await this.call('tools/call', { name: 'browser_click', arguments: { element: 'click target', ref } });
+ const safeRef = JSON.stringify(ref);
+ const code = `
+ (() => {
+ const ref = ${safeRef};
+ const el = document.querySelector('[data-ref="' + ref + '"]')
+ || document.querySelectorAll('a, button, input, [role="button"], [tabindex]')[parseInt(ref, 10) || 0];
+ if (!el) throw new Error('Element not found: ' + ref);
+ el.scrollIntoView({ behavior: 'instant', block: 'center' });
+ el.click();
+ return 'clicked';
+ })()
+ `;
+ await sendCommand('exec', { code, ...this._tabOpt() });
}
async typeText(ref: string, text: string): Promise {
- await this.call('tools/call', { name: 'browser_type', arguments: { element: 'type target', ref, text } });
+ const safeRef = JSON.stringify(ref);
+ const safeText = JSON.stringify(text);
+ const code = `
+ (() => {
+ const ref = ${safeRef};
+ const el = document.querySelector('[data-ref="' + ref + '"]')
+ || document.querySelectorAll('input, textarea, [contenteditable]')[parseInt(ref, 10) || 0];
+ if (!el) throw new Error('Element not found: ' + ref);
+ el.focus();
+ el.value = ${safeText};
+ el.dispatchEvent(new Event('input', { bubbles: true }));
+ el.dispatchEvent(new Event('change', { bubbles: true }));
+ return 'typed';
+ })()
+ `;
+ await sendCommand('exec', { code, ...this._tabOpt() });
}
async pressKey(key: string): Promise {
- await this.call('tools/call', { name: 'browser_press_key', arguments: { key } });
+ const code = `
+ (() => {
+ const el = document.activeElement || document.body;
+ el.dispatchEvent(new KeyboardEvent('keydown', { key: ${JSON.stringify(key)}, bubbles: true }));
+ el.dispatchEvent(new KeyboardEvent('keyup', { key: ${JSON.stringify(key)}, bubbles: true }));
+ return 'pressed';
+ })()
+ `;
+ await sendCommand('exec', { code, ...this._tabOpt() });
}
async wait(options: number | { text?: string; time?: number; timeout?: number }): Promise {
if (typeof options === 'number') {
- await this.call('tools/call', { name: 'browser_wait_for', arguments: { time: options } });
- } else {
- // Pass directly to native wait_for, which supports natively awaiting text strings without heavy DOM polling
- await this.call('tools/call', { name: 'browser_wait_for', arguments: options });
+ await new Promise(resolve => setTimeout(resolve, options * 1000));
+ return;
+ }
+ if (options.time) {
+ await new Promise(resolve => setTimeout(resolve, options.time! * 1000));
+ return;
+ }
+ if (options.text) {
+ const timeout = (options.timeout ?? 30) * 1000;
+ const code = `
+ new Promise((resolve, reject) => {
+ const deadline = Date.now() + ${timeout};
+ const check = () => {
+ if (document.body.innerText.includes(${JSON.stringify(options.text)})) return resolve('found');
+ if (Date.now() > deadline) return reject(new Error('Text not found: ' + ${JSON.stringify(options.text)}));
+ setTimeout(check, 200);
+ };
+ check();
+ })
+ `;
+ await sendCommand('exec', { code, ...this._tabOpt() });
}
}
async tabs(): Promise {
- return this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'list' } });
+ return sendCommand('tabs', { op: 'list' });
}
async closeTab(index?: number): Promise {
- await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'close', ...(index !== undefined ? { index } : {}) } });
+ await sendCommand('tabs', { op: 'close', ...(index !== undefined ? { index } : {}) });
}
async newTab(): Promise {
- await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'new' } });
+ await sendCommand('tabs', { op: 'new' });
}
async selectTab(index: number): Promise {
- await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'select', index } });
+ await sendCommand('tabs', { op: 'select', index });
}
async networkRequests(includeStatic: boolean = false): Promise {
- return this.call('tools/call', { name: 'browser_network_requests', arguments: { includeStatic } });
+ const code = `
+ (() => {
+ const entries = performance.getEntriesByType('resource');
+ return entries
+ ${includeStatic ? '' : '.filter(e => !["img", "font", "css", "script"].some(t => e.initiatorType === t))'}
+ .map(e => ({
+ url: e.name,
+ type: e.initiatorType,
+ duration: Math.round(e.duration),
+ size: e.transferSize || 0,
+ }));
+ })()
+ `;
+ return sendCommand('exec', { code, ...this._tabOpt() });
}
async consoleMessages(level: string = 'info'): Promise {
- return this.call('tools/call', { name: 'browser_console_messages', arguments: { level } });
+ // Console messages can't be retrospectively read via CDP Runtime.evaluate.
+ // Would need Runtime.consoleAPICalled event listener, which is not yet implemented.
+ if (process.env.OPENCLI_VERBOSE) {
+ console.error('[page] consoleMessages() not supported in lightweight mode — returning empty');
+ }
+ return [];
}
- async scroll(direction: string = 'down', _amount: number = 500): Promise {
- await this.call('tools/call', { name: 'browser_press_key', arguments: { key: direction === 'down' ? 'PageDown' : 'PageUp' } });
+ /**
+ * Capture a screenshot via CDP Page.captureScreenshot.
+ * @param options.format - 'png' (default) or 'jpeg'
+ * @param options.quality - JPEG quality 0-100
+ * @param options.fullPage - capture full scrollable page
+ * @param options.path - save to file path (returns base64 if omitted)
+ */
+ async screenshot(options: {
+ format?: 'png' | 'jpeg';
+ quality?: number;
+ fullPage?: boolean;
+ path?: string;
+ } = {}): Promise {
+ const base64 = await sendCommand('screenshot', {
+ format: options.format,
+ quality: options.quality,
+ fullPage: options.fullPage,
+ ...this._tabOpt(),
+ }) as string;
+
+ if (options.path) {
+ const fs = await import('node:fs');
+ const path = await import('node:path');
+ const dir = path.dirname(options.path);
+ fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(options.path, Buffer.from(base64, 'base64'));
+ }
+
+ return base64;
+ }
+
+ async scroll(direction: string = 'down', amount: number = 500): Promise {
+ const dx = direction === 'left' ? -amount : direction === 'right' ? amount : 0;
+ const dy = direction === 'up' ? -amount : direction === 'down' ? amount : 0;
+ await sendCommand('exec', {
+ code: `window.scrollBy(${dx}, ${dy})`,
+ ...this._tabOpt(),
+ });
}
async autoScroll(options: { times?: number; delayMs?: number } = {}): Promise {
const times = options.times ?? 3;
const delayMs = options.delayMs ?? 2000;
- const js = `
- async () => {
- const maxTimes = ${times};
- const maxWaitMs = ${delayMs};
- for (let i = 0; i < maxTimes; i++) {
+ const code = `
+ (async () => {
+ for (let i = 0; i < ${times}; i++) {
const lastHeight = document.body.scrollHeight;
window.scrollTo(0, lastHeight);
await new Promise(resolve => {
@@ -142,30 +246,60 @@ export class Page implements IPage {
if (document.body.scrollHeight > lastHeight) {
clearTimeout(timeoutId);
observer.disconnect();
- setTimeout(resolve, 100); // Small debounce for rendering
+ setTimeout(resolve, 100);
}
});
observer.observe(document.body, { childList: true, subtree: true });
- timeoutId = setTimeout(() => {
- observer.disconnect();
- resolve(null);
- }, maxWaitMs);
+ timeoutId = setTimeout(() => { observer.disconnect(); resolve(null); }, ${delayMs});
});
}
- }
+ })()
`;
- await this.evaluate(js);
+ await sendCommand('exec', { code, ...this._tabOpt() });
}
async installInterceptor(pattern: string): Promise {
- await this.evaluate(generateInterceptorJs(JSON.stringify(pattern), {
- arrayName: '__opencli_xhr',
- patchGuard: '__opencli_interceptor_patched',
- }));
+ const { generateInterceptorJs } = await import('../interceptor.js');
+ await sendCommand('exec', {
+ code: generateInterceptorJs(JSON.stringify(pattern), {
+ arrayName: '__opencli_xhr',
+ patchGuard: '__opencli_interceptor_patched',
+ }),
+ ...this._tabOpt(),
+ });
}
async getInterceptedRequests(): Promise {
- const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
- return result || [];
+ const { generateReadInterceptedJs } = await import('../interceptor.js');
+ const result = await sendCommand('exec', {
+ code: generateReadInterceptedJs('__opencli_xhr'),
+ ...this._tabOpt(),
+ });
+ return (result as any[]) || [];
}
}
+
+// ─── Helpers ─────────────────────────────────────────────────────────
+
+/**
+ * Wrap JS code for CDP Runtime.evaluate:
+ * - Already an IIFE `(...)()` → send as-is
+ * - Arrow/function literal → wrap as IIFE `(code)()`
+ * - `new Promise(...)` or raw expression → send as-is (expression)
+ */
+function wrapForEval(js: string): string {
+ const code = js.trim();
+ if (!code) return 'undefined';
+
+ // Already an IIFE: `(async () => { ... })()` or `(function() {...})()`
+ if (/^\([\s\S]*\)\s*\(.*\)\s*$/.test(code)) return code;
+
+ // Arrow function: `() => ...` or `async () => ...`
+ if (/^(async\s+)?(\([^)]*\)|[A-Za-z_]\w*)\s*=>/.test(code)) return `(${code})()`;
+
+ // Function declaration: `function ...` or `async function ...`
+ if (/^(async\s+)?function[\s(]/.test(code)) return `(${code})()`;
+
+ // Everything else: bare expression, `new Promise(...)`, etc. → evaluate directly
+ return code;
+}
diff --git a/src/clis/chatgpt/README.md b/src/clis/chatgpt/README.md
index 7d9b0d0..7d491e2 100644
--- a/src/clis/chatgpt/README.md
+++ b/src/clis/chatgpt/README.md
@@ -35,7 +35,7 @@ export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9224"
## How It Works
- **AppleScript mode**: Uses `osascript` and `pbcopy`/`pbpaste` for clipboard-based text transfer. No remote debugging port needed.
-- **CDP mode**: Connects via Playwright to the Electron renderer process for direct DOM manipulation.
+- **CDP mode**: Connects via Chrome DevTools Protocol to the Electron renderer process for direct DOM manipulation.
## Limitations
diff --git a/src/clis/chatgpt/README.zh-CN.md b/src/clis/chatgpt/README.zh-CN.md
index cf30a16..4a65698 100644
--- a/src/clis/chatgpt/README.zh-CN.md
+++ b/src/clis/chatgpt/README.zh-CN.md
@@ -35,7 +35,7 @@ export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9224"
## 工作原理
- **AppleScript 模式**:使用 `osascript` 和 `pbcopy`/`pbpaste` 进行剪贴板文本传输,无需远程调试端口。
-- **CDP 模式**:通过 Playwright 连接到 Electron 渲染进程,直接操作 DOM。
+- **CDP 模式**:通过 Chrome DevTools Protocol 连接到 Electron 渲染进程,直接操作 DOM。
## 限制
diff --git a/src/clis/neteasemusic/README.md b/src/clis/neteasemusic/README.md
new file mode 100644
index 0000000..88d027e
--- /dev/null
+++ b/src/clis/neteasemusic/README.md
@@ -0,0 +1,31 @@
+# NeteaseMusic Desktop Adapter (网易云音乐)
+
+Control **NeteaseMusic** (网易云音乐) from the terminal via Chrome DevTools Protocol (CDP). The app uses Chromium Embedded Framework (CEF).
+
+## Prerequisites
+
+Launch with remote debugging port:
+```bash
+/Applications/NeteaseMusic.app/Contents/MacOS/NeteaseMusic --remote-debugging-port=9234
+```
+
+## Setup
+
+```bash
+export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9234"
+```
+
+## Commands
+
+| Command | Description |
+|---------|-------------|
+| `neteasemusic status` | Check CDP connection |
+| `neteasemusic playing` | Current song info (title, artist, album) |
+| `neteasemusic play` | Play / Pause toggle |
+| `neteasemusic next` | Skip to next song |
+| `neteasemusic prev` | Go to previous song |
+| `neteasemusic search "query"` | Search songs, artists |
+| `neteasemusic playlist` | Show current playback queue |
+| `neteasemusic like` | Like / unlike current song |
+| `neteasemusic lyrics` | Get lyrics of current song |
+| `neteasemusic volume [0-100]` | Get or set volume |
diff --git a/src/clis/neteasemusic/README.zh-CN.md b/src/clis/neteasemusic/README.zh-CN.md
new file mode 100644
index 0000000..9b3e5ae
--- /dev/null
+++ b/src/clis/neteasemusic/README.zh-CN.md
@@ -0,0 +1,31 @@
+# 网易云音乐桌面端适配器
+
+通过 Chrome DevTools Protocol (CDP) 在终端中控制 **网易云音乐**。该应用基于 Chromium Embedded Framework (CEF)。
+
+## 前置条件
+
+通过远程调试端口启动:
+```bash
+/Applications/NeteaseMusic.app/Contents/MacOS/NeteaseMusic --remote-debugging-port=9234
+```
+
+## 配置
+
+```bash
+export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9234"
+```
+
+## 命令
+
+| 命令 | 说明 |
+|------|------|
+| `neteasemusic status` | 检查 CDP 连接 |
+| `neteasemusic playing` | 当前播放歌曲信息 |
+| `neteasemusic play` | 播放 / 暂停切换 |
+| `neteasemusic next` | 下一首 |
+| `neteasemusic prev` | 上一首 |
+| `neteasemusic search "关键词"` | 搜索歌曲 |
+| `neteasemusic playlist` | 显示当前播放列表 |
+| `neteasemusic like` | 喜欢 / 取消喜欢 |
+| `neteasemusic lyrics` | 获取当前歌词 |
+| `neteasemusic volume [0-100]` | 获取或设置音量 |
diff --git a/src/clis/neteasemusic/like.ts b/src/clis/neteasemusic/like.ts
new file mode 100644
index 0000000..1d162cc
--- /dev/null
+++ b/src/clis/neteasemusic/like.ts
@@ -0,0 +1,28 @@
+import { cli, Strategy } from '../../registry.js';
+import type { IPage } from '../../types.js';
+
+export const likeCommand = cli({
+ site: 'neteasemusic',
+ name: 'like',
+ description: 'Like/unlike the currently playing song',
+ domain: 'localhost',
+ strategy: Strategy.UI,
+ browser: true,
+ args: [],
+ columns: ['Status'],
+ func: async (page: IPage) => {
+ const result = await page.evaluate(`
+ (function() {
+ // The like/heart button in the player bar
+ const btn = document.querySelector('.m-playbar .icn-love, .m-playbar [class*="like"], .m-player [class*="love"], [data-action="like"]');
+ if (!btn) return 'Like button not found';
+
+ const wasLiked = btn.classList.contains('loved') || btn.classList.contains('active') || btn.getAttribute('data-liked') === 'true';
+ btn.click();
+ return wasLiked ? 'Unliked' : 'Liked';
+ })()
+ `);
+
+ return [{ Status: result }];
+ },
+});
diff --git a/src/clis/neteasemusic/lyrics.ts b/src/clis/neteasemusic/lyrics.ts
new file mode 100644
index 0000000..d7b3017
--- /dev/null
+++ b/src/clis/neteasemusic/lyrics.ts
@@ -0,0 +1,53 @@
+import { cli, Strategy } from '../../registry.js';
+import type { IPage } from '../../types.js';
+
+export const lyricsCommand = cli({
+ site: 'neteasemusic',
+ name: 'lyrics',
+ description: 'Get the lyrics of the currently playing song',
+ domain: 'localhost',
+ strategy: Strategy.UI,
+ browser: true,
+ args: [],
+ columns: ['Line'],
+ func: async (page: IPage) => {
+ // Try to open lyrics panel if not visible
+ await page.evaluate(`
+ (function() {
+ const btn = document.querySelector('.m-playbar .icn-lyric, [class*="lyric-btn"], [data-action="lyric"]');
+ if (btn) btn.click();
+ })()
+ `);
+
+ await page.wait(1);
+
+ const lyrics = await page.evaluate(`
+ (function() {
+ // Look for lyrics container
+ const selectors = [
+ '.m-lyric p, .m-lyric [class*="line"]',
+ '[class*="lyric-content"] p',
+ '.listlyric li',
+ '[class*="lyric"] [class*="line"]',
+ '.j-lyric p',
+ ];
+
+ for (const sel of selectors) {
+ const nodes = document.querySelectorAll(sel);
+ if (nodes.length > 0) {
+ return Array.from(nodes).map(n => (n.textContent || '').trim()).filter(l => l.length > 0);
+ }
+ }
+
+ // Fallback: try the body text for any lyrics-like content
+ return [];
+ })()
+ `);
+
+ if (lyrics.length === 0) {
+ return [{ Line: 'No lyrics found. Try opening the lyrics panel first.' }];
+ }
+
+ return lyrics.map((line: string) => ({ Line: line }));
+ },
+});
diff --git a/src/clis/neteasemusic/next.ts b/src/clis/neteasemusic/next.ts
new file mode 100644
index 0000000..73fe244
--- /dev/null
+++ b/src/clis/neteasemusic/next.ts
@@ -0,0 +1,30 @@
+import { cli, Strategy } from '../../registry.js';
+import type { IPage } from '../../types.js';
+
+export const nextCommand = cli({
+ site: 'neteasemusic',
+ name: 'next',
+ description: 'Skip to the next song',
+ domain: 'localhost',
+ strategy: Strategy.UI,
+ browser: true,
+ args: [],
+ columns: ['Status'],
+ func: async (page: IPage) => {
+ const clicked = await page.evaluate(`
+ (function() {
+ const btn = document.querySelector('.m-playbar .btnfwd, .m-playbar [class*="next"], .m-player .btn-next, [data-action="next"]');
+ if (btn) { btn.click(); return true; }
+ return false;
+ })()
+ `);
+
+ if (!clicked) {
+ // Fallback: Ctrl+Right is common next-track shortcut
+ await page.pressKey('Control+ArrowRight');
+ }
+
+ await page.wait(1);
+ return [{ Status: 'Skipped to next song' }];
+ },
+});
diff --git a/src/clis/neteasemusic/play.ts b/src/clis/neteasemusic/play.ts
new file mode 100644
index 0000000..ee98a0b
--- /dev/null
+++ b/src/clis/neteasemusic/play.ts
@@ -0,0 +1,30 @@
+import { cli, Strategy } from '../../registry.js';
+import type { IPage } from '../../types.js';
+
+export const playCommand = cli({
+ site: 'neteasemusic',
+ name: 'play',
+ description: 'Toggle play/pause for the current song',
+ domain: 'localhost',
+ strategy: Strategy.UI,
+ browser: true,
+ args: [],
+ columns: ['Status'],
+ func: async (page: IPage) => {
+ // Click the play/pause button or use Space key
+ const clicked = await page.evaluate(`
+ (function() {
+ const btn = document.querySelector('.m-playbar .btnp, .m-playbar [class*="play"], .m-player .btn-play, [data-action="play"]');
+ if (btn) { btn.click(); return true; }
+ return false;
+ })()
+ `);
+
+ if (!clicked) {
+ // Fallback: use Space key which is the universal play/pause shortcut
+ await page.pressKey('Space');
+ }
+
+ return [{ Status: 'Play/Pause toggled' }];
+ },
+});
diff --git a/src/clis/neteasemusic/playing.ts b/src/clis/neteasemusic/playing.ts
new file mode 100644
index 0000000..78144bb
--- /dev/null
+++ b/src/clis/neteasemusic/playing.ts
@@ -0,0 +1,62 @@
+import { cli, Strategy } from '../../registry.js';
+import type { IPage } from '../../types.js';
+
+export const playingCommand = cli({
+ site: 'neteasemusic',
+ name: 'playing',
+ description: 'Get the currently playing song info',
+ domain: 'localhost',
+ strategy: Strategy.UI,
+ browser: true,
+ args: [],
+ columns: ['Title', 'Artist', 'Album', 'Duration', 'Progress'],
+ func: async (page: IPage) => {
+ const info = await page.evaluate(`
+ (function() {
+ // NeteaseMusic player bar is at the bottom
+ const selectors = {
+ title: '.m-playbar .j-song .name, .m-playbar .song .name, [class*="playing"] .name, .m-player .name',
+ artist: '.m-playbar .j-song .by, .m-playbar .song .by, [class*="playing"] .artist, .m-player .by',
+ album: '.m-playbar .j-song .album, [class*="playing"] .album',
+ time: '.m-playbar .j-dur, .m-playbar .time, .m-player .time',
+ progress: '.m-playbar .barbg .rng, .m-playbar [role="progressbar"], .m-player [class*="progress"]',
+ };
+
+ function getText(sel) {
+ for (const s of sel.split(',')) {
+ const el = document.querySelector(s.trim());
+ if (el) return (el.textContent || el.innerText || '').trim();
+ }
+ return '';
+ }
+
+ const title = getText(selectors.title);
+ const artist = getText(selectors.artist);
+ const album = getText(selectors.album);
+ const time = getText(selectors.time);
+
+ // Try to get playback progress from the progress bar width
+ let progress = '';
+ const bar = document.querySelector('.m-playbar .barbg .rng, [class*="progress"] [class*="played"]');
+ if (bar) {
+ const style = bar.getAttribute('style') || '';
+ const match = style.match(/width:\\s*(\\d+\\.?\\d*)%/);
+ if (match) progress = match[1] + '%';
+ }
+
+ if (!title) {
+ // Fallback: try document title which often contains "songName - NeteaseMusic"
+ const docTitle = document.title;
+ if (docTitle && !docTitle.includes('NeteaseMusic')) {
+ return { Title: docTitle, Artist: '', Album: '', Duration: '', Progress: '' };
+ }
+ return { Title: 'No song playing', Artist: '—', Album: '—', Duration: '—', Progress: '—' };
+ }
+
+ return { Title: title, Artist: artist, Album: album, Duration: time, Progress: progress };
+ })()
+ `);
+
+ return [info];
+ },
+});
diff --git a/src/clis/neteasemusic/playlist.ts b/src/clis/neteasemusic/playlist.ts
new file mode 100644
index 0000000..323948e
--- /dev/null
+++ b/src/clis/neteasemusic/playlist.ts
@@ -0,0 +1,51 @@
+import { cli, Strategy } from '../../registry.js';
+import type { IPage } from '../../types.js';
+
+export const playlistCommand = cli({
+ site: 'neteasemusic',
+ name: 'playlist',
+ description: 'Show the current playback queue / playlist',
+ domain: 'localhost',
+ strategy: Strategy.UI,
+ browser: true,
+ args: [],
+ columns: ['Index', 'Title', 'Artist'],
+ func: async (page: IPage) => {
+ // Open the playlist panel (usually a button at the bottom bar)
+ await page.evaluate(`
+ (function() {
+ const btn = document.querySelector('.m-playbar .icn-list, .m-playbar [class*="playlist"], [data-action="playlist"], .m-playbar .btnlist');
+ if (btn) btn.click();
+ })()
+ `);
+
+ await page.wait(1);
+
+ const items = await page.evaluate(`
+ (function() {
+ const results = [];
+ // Playlist panel items
+ const rows = document.querySelectorAll('.m-playlist li, [class*="playlist-panel"] li, .listlyric li, .j-playlist li');
+
+ rows.forEach((row, i) => {
+ const nameEl = row.querySelector('.name, [class*="name"], a, span:first-child');
+ const artistEl = row.querySelector('.by, [class*="artist"], .ar');
+
+ const title = nameEl ? (nameEl.getAttribute('title') || nameEl.textContent || '').trim() : (row.textContent || '').trim();
+ const artist = artistEl ? (artistEl.textContent || '').trim() : '';
+
+ if (title && title.length > 0) {
+ results.push({ Index: i + 1, Title: title.substring(0, 80), Artist: artist });
+ }
+ });
+
+ return results;
+ })()
+ `);
+
+ if (items.length === 0) {
+ return [{ Index: 0, Title: 'Playlist is empty or panel not open', Artist: '—' }];
+ }
+ return items;
+ },
+});
diff --git a/src/clis/neteasemusic/prev.ts b/src/clis/neteasemusic/prev.ts
new file mode 100644
index 0000000..f08aad2
--- /dev/null
+++ b/src/clis/neteasemusic/prev.ts
@@ -0,0 +1,29 @@
+import { cli, Strategy } from '../../registry.js';
+import type { IPage } from '../../types.js';
+
+export const prevCommand = cli({
+ site: 'neteasemusic',
+ name: 'prev',
+ description: 'Go back to the previous song',
+ domain: 'localhost',
+ strategy: Strategy.UI,
+ browser: true,
+ args: [],
+ columns: ['Status'],
+ func: async (page: IPage) => {
+ const clicked = await page.evaluate(`
+ (function() {
+ const btn = document.querySelector('.m-playbar .btnbak, .m-playbar [class*="prev"], .m-player .btn-prev, [data-action="prev"]');
+ if (btn) { btn.click(); return true; }
+ return false;
+ })()
+ `);
+
+ if (!clicked) {
+ await page.pressKey('Control+ArrowLeft');
+ }
+
+ await page.wait(1);
+ return [{ Status: 'Went to previous song' }];
+ },
+});
diff --git a/src/clis/neteasemusic/search.ts b/src/clis/neteasemusic/search.ts
new file mode 100644
index 0000000..ae992b7
--- /dev/null
+++ b/src/clis/neteasemusic/search.ts
@@ -0,0 +1,58 @@
+import { cli, Strategy } from '../../registry.js';
+import type { IPage } from '../../types.js';
+
+export const searchCommand = cli({
+ site: 'neteasemusic',
+ name: 'search',
+ description: 'Search for songs, artists, albums, or playlists',
+ domain: 'localhost',
+ strategy: Strategy.UI,
+ browser: true,
+ args: [{ name: 'query', required: true, positional: true, help: 'Search query' }],
+ columns: ['Index', 'Title', 'Artist'],
+ func: async (page: IPage, kwargs: any) => {
+ const query = kwargs.query as string;
+
+ // Focus and fill the search box
+ await page.evaluate(`
+ (function(q) {
+ const input = document.querySelector('.m-search input, #srch, [class*="search"] input, input[type="search"]');
+ if (!input) throw new Error('Search input not found');
+ input.focus();
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
+ setter.call(input, q);
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ })(${JSON.stringify(query)})
+ `);
+
+ await page.pressKey('Enter');
+ await page.wait(2);
+
+ // Scrape results
+ const results = await page.evaluate(`
+ (function() {
+ const items = [];
+ // Song list items in search results
+ const rows = document.querySelectorAll('.srchsongst li, .m-table tbody tr, [class*="songlist"] [class*="item"], table tbody tr');
+
+ rows.forEach((row, i) => {
+ if (i >= 20) return;
+ const nameEl = row.querySelector('.sn, .name a, [class*="songName"], td:nth-child(2) a, b[title]');
+ const artistEl = row.querySelector('.ar, .artist, [class*="artist"], td:nth-child(4) a, td:nth-child(3) a');
+
+ const title = nameEl ? (nameEl.getAttribute('title') || nameEl.textContent || '').trim() : '';
+ const artist = artistEl ? (artistEl.getAttribute('title') || artistEl.textContent || '').trim() : '';
+
+ if (title) items.push({ Index: i + 1, Title: title, Artist: artist });
+ });
+
+ return items;
+ })()
+ `);
+
+ if (results.length === 0) {
+ return [{ Index: 0, Title: `No results for "${query}"`, Artist: '—' }];
+ }
+ return results;
+ },
+});
diff --git a/src/clis/neteasemusic/status.ts b/src/clis/neteasemusic/status.ts
new file mode 100644
index 0000000..78d8f33
--- /dev/null
+++ b/src/clis/neteasemusic/status.ts
@@ -0,0 +1,18 @@
+import { cli, Strategy } from '../../registry.js';
+import type { IPage } from '../../types.js';
+
+export const statusCommand = cli({
+ site: 'neteasemusic',
+ name: 'status',
+ description: 'Check CDP connection to NeteaseMusic Desktop',
+ domain: 'localhost',
+ strategy: Strategy.UI,
+ browser: true,
+ args: [],
+ columns: ['Status', 'Url', 'Title'],
+ func: async (page: IPage) => {
+ const url = await page.evaluate('window.location.href');
+ const title = await page.evaluate('document.title');
+ return [{ Status: 'Connected', Url: url, Title: title }];
+ },
+});
diff --git a/src/clis/neteasemusic/volume.ts b/src/clis/neteasemusic/volume.ts
new file mode 100644
index 0000000..bef900a
--- /dev/null
+++ b/src/clis/neteasemusic/volume.ts
@@ -0,0 +1,61 @@
+import { cli, Strategy } from '../../registry.js';
+import type { IPage } from '../../types.js';
+
+export const volumeCommand = cli({
+ site: 'neteasemusic',
+ name: 'volume',
+ description: 'Get or set the volume level (0-100)',
+ domain: 'localhost',
+ strategy: Strategy.UI,
+ browser: true,
+ args: [
+ { name: 'level', required: false, positional: true, help: 'Volume level 0-100 (omit to read current)' },
+ ],
+ columns: ['Status', 'Volume'],
+ func: async (page: IPage, kwargs: any) => {
+ const level = kwargs.level as string | undefined;
+
+ if (!level) {
+ // Read current volume
+ const vol = await page.evaluate(`
+ (function() {
+ const bar = document.querySelector('.m-playbar .vol .barbg .rng, [class*="volume"] [class*="progress"], [class*="volume"] [class*="played"]');
+ if (bar) {
+ const style = bar.getAttribute('style') || '';
+ const match = style.match(/width:\\s*(\\d+\\.?\\d*)%/);
+ if (match) return match[1];
+ }
+
+ const vol = document.querySelector('.m-playbar .j-vol, [class*="volume-value"]');
+ if (vol) return vol.textContent.trim();
+
+ return 'Unknown';
+ })()
+ `);
+
+ return [{ Status: 'Current', Volume: vol + '%' }];
+ }
+
+ // Set volume by clicking on the volume bar at the right position
+ const targetVol = Math.max(0, Math.min(100, parseInt(level, 10)));
+
+ await page.evaluate(`
+ (function(target) {
+ const bar = document.querySelector('.m-playbar .vol .barbg, [class*="volume-bar"], [class*="volume"] [class*="track"]');
+ if (!bar) return;
+
+ const rect = bar.getBoundingClientRect();
+ const x = rect.left + (rect.width * target / 100);
+ const y = rect.top + rect.height / 2;
+
+ bar.dispatchEvent(new MouseEvent('click', {
+ clientX: x,
+ clientY: y,
+ bubbles: true,
+ }));
+ })(${targetVol})
+ `);
+
+ return [{ Status: 'Set', Volume: targetVol + '%' }];
+ },
+});
diff --git a/src/daemon.ts b/src/daemon.ts
new file mode 100644
index 0000000..033a3a7
--- /dev/null
+++ b/src/daemon.ts
@@ -0,0 +1,217 @@
+/**
+ * opencli micro-daemon — HTTP + WebSocket bridge between CLI and Chrome Extension.
+ *
+ * Architecture:
+ * CLI → HTTP POST /command → daemon → WebSocket → Extension
+ * Extension → WebSocket result → daemon → HTTP response → CLI
+ *
+ * Lifecycle:
+ * - Auto-spawned by opencli on first browser command
+ * - Auto-exits after 5 minutes of idle
+ * - Listens on localhost:19825
+ */
+
+import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
+import { WebSocketServer, WebSocket } from 'ws';
+
+const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? '19825', 10);
+const IDLE_TIMEOUT = 5 * 60 * 1000; // 5 minutes
+
+// ─── State ───────────────────────────────────────────────────────────
+
+let extensionWs: WebSocket | null = null;
+const pending = new Map void;
+ reject: (error: Error) => void;
+ timer: ReturnType;
+}>();
+let idleTimer: ReturnType | null = null;
+
+// Extension log ring buffer
+interface LogEntry { level: string; msg: string; ts: number; }
+const LOG_BUFFER_SIZE = 200;
+const logBuffer: LogEntry[] = [];
+
+function pushLog(entry: LogEntry): void {
+ logBuffer.push(entry);
+ if (logBuffer.length > LOG_BUFFER_SIZE) logBuffer.shift();
+}
+
+// ─── Idle auto-exit ──────────────────────────────────────────────────
+
+function resetIdleTimer(): void {
+ if (idleTimer) clearTimeout(idleTimer);
+ idleTimer = setTimeout(() => {
+ console.error('[daemon] Idle timeout, shutting down');
+ process.exit(0);
+ }, IDLE_TIMEOUT);
+}
+
+// ─── HTTP Server ─────────────────────────────────────────────────────
+
+function readBody(req: IncomingMessage): Promise {
+ return new Promise((resolve, reject) => {
+ const chunks: Buffer[] = [];
+ req.on('data', (c: Buffer) => chunks.push(c));
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
+ req.on('error', reject);
+ });
+}
+
+function jsonResponse(res: ServerResponse, status: number, data: unknown): void {
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
+ res.end(JSON.stringify(data));
+}
+
+async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise {
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
+ if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
+
+ const url = req.url ?? '/';
+ const pathname = url.split('?')[0];
+
+ if (req.method === 'GET' && pathname === '/status') {
+ jsonResponse(res, 200, {
+ ok: true,
+ extensionConnected: extensionWs?.readyState === WebSocket.OPEN,
+ pending: pending.size,
+ });
+ return;
+ }
+
+ if (req.method === 'GET' && pathname === '/logs') {
+ const params = new URL(url, `http://localhost:${PORT}`).searchParams;
+ const level = params.get('level');
+ const filtered = level
+ ? logBuffer.filter(e => e.level === level)
+ : logBuffer;
+ jsonResponse(res, 200, { ok: true, logs: filtered });
+ return;
+ }
+
+ if (req.method === 'DELETE' && pathname === '/logs') {
+ logBuffer.length = 0;
+ jsonResponse(res, 200, { ok: true });
+ return;
+ }
+
+ if (req.method === 'POST' && url === '/command') {
+ resetIdleTimer();
+ try {
+ const body = JSON.parse(await readBody(req));
+ if (!body.id) {
+ jsonResponse(res, 400, { ok: false, error: 'Missing command id' });
+ return;
+ }
+
+ if (!extensionWs || extensionWs.readyState !== WebSocket.OPEN) {
+ jsonResponse(res, 503, { id: body.id, ok: false, error: 'Extension not connected. Please install the opencli Browser Bridge extension.' });
+ return;
+ }
+
+ const result = await new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ pending.delete(body.id);
+ reject(new Error('Command timeout (30s)'));
+ }, 30000);
+ pending.set(body.id, { resolve, reject, timer });
+ extensionWs!.send(JSON.stringify(body));
+ });
+
+ jsonResponse(res, 200, result);
+ } catch (err) {
+ jsonResponse(res, err instanceof Error && err.message.includes('timeout') ? 408 : 400, {
+ ok: false,
+ error: err instanceof Error ? err.message : 'Invalid request',
+ });
+ }
+ return;
+ }
+
+ jsonResponse(res, 404, { error: 'Not found' });
+}
+
+// ─── WebSocket for Extension ─────────────────────────────────────────
+
+const httpServer = createServer((req, res) => { handleRequest(req, res).catch(() => { res.writeHead(500); res.end(); }); });
+const wss = new WebSocketServer({ server: httpServer, path: '/ext' });
+
+wss.on('connection', (ws) => {
+ console.error('[daemon] Extension connected');
+ extensionWs = ws;
+
+ ws.on('message', (data) => {
+ try {
+ const msg = JSON.parse(data.toString());
+
+ // Handle log messages from extension
+ if (msg.type === 'log') {
+ const prefix = msg.level === 'error' ? '❌' : msg.level === 'warn' ? '⚠️' : '📋';
+ console.error(`${prefix} [ext] ${msg.msg}`);
+ pushLog({ level: msg.level, msg: msg.msg, ts: msg.ts ?? Date.now() });
+ return;
+ }
+
+ // Handle command results
+ const p = pending.get(msg.id);
+ if (p) {
+ clearTimeout(p.timer);
+ pending.delete(msg.id);
+ p.resolve(msg);
+ }
+ } catch {
+ // Ignore malformed messages
+ }
+ });
+
+ ws.on('close', () => {
+ console.error('[daemon] Extension disconnected');
+ if (extensionWs === ws) {
+ extensionWs = null;
+ // Reject all pending requests since the extension is gone
+ for (const [id, p] of pending) {
+ clearTimeout(p.timer);
+ p.reject(new Error('Extension disconnected'));
+ }
+ pending.clear();
+ }
+ });
+
+ ws.on('error', () => {
+ if (extensionWs === ws) extensionWs = null;
+ });
+});
+
+// ─── Start ───────────────────────────────────────────────────────────
+
+httpServer.listen(PORT, '127.0.0.1', () => {
+ console.error(`[daemon] Listening on http://127.0.0.1:${PORT}`);
+ resetIdleTimer();
+});
+
+httpServer.on('error', (err: NodeJS.ErrnoException) => {
+ if (err.code === 'EADDRINUSE') {
+ console.error(`[daemon] Port ${PORT} already in use — another daemon is likely running. Exiting.`);
+ process.exit(0);
+ }
+ console.error('[daemon] Server error:', err.message);
+ process.exit(1);
+});
+
+// Graceful shutdown
+function shutdown(): void {
+ // Reject all pending requests so CLI doesn't hang
+ for (const [, p] of pending) {
+ clearTimeout(p.timer);
+ p.reject(new Error('Daemon shutting down'));
+ }
+ pending.clear();
+ if (extensionWs) extensionWs.close();
+ httpServer.close();
+ process.exit(0);
+}
+
+process.on('SIGTERM', shutdown);
+process.on('SIGINT', shutdown);
diff --git a/src/doctor.test.ts b/src/doctor.test.ts
index 8f4b33b..fb080f1 100644
--- a/src/doctor.test.ts
+++ b/src/doctor.test.ts
@@ -1,223 +1,62 @@
import { describe, expect, it } from 'vitest';
-import {
- readTokenFromShellContent,
- renderBrowserDoctorReport,
- upsertShellToken,
- readTomlConfigToken,
- upsertTomlConfigToken,
- upsertJsonConfigToken,
-} from './doctor.js';
-
-describe('shell token helpers', () => {
- it('reads token from shell export', () => {
- expect(readTokenFromShellContent('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="abc123"\n')).toBe('abc123');
- });
-
- it('appends token export when missing', () => {
- const next = upsertShellToken('export PATH="/usr/bin"\n', 'abc123');
- expect(next).toContain('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="abc123"');
- });
-
- it('replaces token export when present', () => {
- const next = upsertShellToken('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="old"\n', 'new');
- expect(next).toContain('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="new"');
- expect(next).not.toContain('"old"');
- });
-});
-
-describe('toml token helpers', () => {
- it('reads token from playwright env section', () => {
- const content = `
-[mcp_servers.playwright.env]
-PLAYWRIGHT_MCP_EXTENSION_TOKEN = "abc123"
-`;
- expect(readTomlConfigToken(content)).toBe('abc123');
- });
-
- it('updates token inside existing env section', () => {
- const content = `
-[mcp_servers.playwright.env]
-PLAYWRIGHT_MCP_EXTENSION_TOKEN = "old"
-`;
- const next = upsertTomlConfigToken(content, 'new');
- expect(next).toContain('PLAYWRIGHT_MCP_EXTENSION_TOKEN = "new"');
- expect(next).not.toContain('"old"');
- });
-
- it('creates env section when missing', () => {
- const content = `
-[mcp_servers.playwright]
-type = "stdio"
-`;
- const next = upsertTomlConfigToken(content, 'abc123');
- expect(next).toContain('[mcp_servers.playwright.env]');
- expect(next).toContain('PLAYWRIGHT_MCP_EXTENSION_TOKEN = "abc123"');
- });
-});
-
-describe('json token helpers', () => {
- it('writes token into standard mcpServers config', () => {
- const next = upsertJsonConfigToken(JSON.stringify({
- mcpServers: {
- playwright: {
- command: 'npx',
- args: ['-y', '@playwright/mcp@latest', '--extension'],
- },
- },
- }), 'abc123');
- const parsed = JSON.parse(next);
- expect(parsed.mcpServers.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
- });
-
- it('writes token into opencode mcp config', () => {
- const next = upsertJsonConfigToken(JSON.stringify({
- $schema: 'https://opencode.ai/config.json',
- mcp: {
- playwright: {
- command: ['npx', '-y', '@playwright/mcp@latest', '--extension'],
- enabled: true,
- type: 'local',
- },
- },
- }), 'abc123');
- const parsed = JSON.parse(next);
- expect(parsed.mcp.playwright.environment.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
- });
-
- it('creates standard mcpServers format for empty file (not OpenCode)', () => {
- const next = upsertJsonConfigToken('', 'abc123');
- const parsed = JSON.parse(next);
- expect(parsed.mcpServers.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
- expect(parsed.mcp).toBeUndefined();
- });
-
- it('creates OpenCode format when filePath contains opencode', () => {
- const next = upsertJsonConfigToken('', 'abc123', '/home/user/.config/opencode/opencode.json');
- const parsed = JSON.parse(next);
- expect(parsed.mcp.playwright.environment.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
- expect(parsed.mcpServers).toBeUndefined();
- });
-
- it('creates standard format when filePath is claude.json', () => {
- const next = upsertJsonConfigToken('', 'abc123', '/home/user/.claude.json');
- const parsed = JSON.parse(next);
- expect(parsed.mcpServers.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
- });
-});
-
-describe('fish shell support', () => {
- it('generates fish set -gx syntax for fish config path', () => {
- const next = upsertShellToken('', 'abc123', '/home/user/.config/fish/config.fish');
- expect(next).toContain('set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "abc123"');
- expect(next).not.toContain('export');
- });
-
- it('replaces existing fish set line', () => {
- const content = 'set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "old"\n';
- const next = upsertShellToken(content, 'new', '/home/user/.config/fish/config.fish');
- expect(next).toContain('set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "new"');
- expect(next).not.toContain('"old"');
- });
-
- it('appends fish syntax to existing fish config', () => {
- const content = 'set -gx PATH /usr/bin\n';
- const next = upsertShellToken(content, 'abc123', '/home/user/.config/fish/config.fish');
- expect(next).toContain('set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "abc123"');
- expect(next).toContain('set -gx PATH /usr/bin');
- });
-
- it('uses export syntax for zshrc even with filePath', () => {
- const next = upsertShellToken('', 'abc123', '/home/user/.zshrc');
- expect(next).toContain('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="abc123"');
- expect(next).not.toContain('set -gx');
- });
-});
+import { renderBrowserDoctorReport } from './doctor.js';
describe('doctor report rendering', () => {
const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, '');
- it('renders OK-style report when tokens match', () => {
+ it('renders OK-style report when daemon and extension connected', () => {
const text = strip(renderBrowserDoctorReport({
- envToken: 'abc123',
- envFingerprint: 'fp1',
- extensionToken: 'abc123',
- extensionFingerprint: 'fp1',
- extensionInstalled: true,
- extensionBrowsers: ['Chrome'],
- shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'abc123', fingerprint: 'fp1' }],
- configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
- recommendedToken: 'abc123',
- recommendedFingerprint: 'fp1',
- warnings: [],
+ daemonRunning: true,
+ extensionConnected: true,
issues: [],
}));
- expect(text).toContain('[OK] Extension installed (Chrome)');
- expect(text).toContain('[OK] Environment token: configured (fp1)');
- expect(text).toContain('[OK] /tmp/mcp.json');
- expect(text).toContain('configured (fp1)');
+ expect(text).toContain('[OK] Daemon: running on port 19825');
+ expect(text).toContain('[OK] Extension: connected');
+ expect(text).toContain('Everything looks good!');
+ });
+
+ it('renders MISSING when daemon not running', () => {
+ const text = strip(renderBrowserDoctorReport({
+ daemonRunning: false,
+ extensionConnected: false,
+ issues: ['Daemon is not running.'],
+ }));
+
+ expect(text).toContain('[MISSING] Daemon: not running');
+ expect(text).toContain('[MISSING] Extension: not connected');
+ expect(text).toContain('Daemon is not running.');
});
- it('renders MISMATCH-style report when fingerprints differ', () => {
+ it('renders extension not connected when daemon is running', () => {
const text = strip(renderBrowserDoctorReport({
- envToken: 'abc123',
- envFingerprint: 'fp1',
- extensionToken: null,
- extensionFingerprint: null,
- extensionInstalled: false,
- extensionBrowsers: [],
- shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'def456', fingerprint: 'fp2' }],
- configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
- recommendedToken: 'abc123',
- recommendedFingerprint: 'fp1',
- warnings: [],
- issues: ['Detected inconsistent Playwright MCP tokens across env/config files.'],
+ daemonRunning: true,
+ extensionConnected: false,
+ issues: ['Daemon is running but the Chrome extension is not connected.'],
}));
- expect(text).toContain('[MISSING] Extension not installed in any browser');
- expect(text).toContain('[MISMATCH] Environment token: configured (fp1)');
- expect(text).toContain('[MISMATCH] /tmp/.zshrc');
- expect(text).toContain('configured (fp2)');
- expect(text).toContain('[MISMATCH] Recommended token fingerprint: fp1');
+ expect(text).toContain('[OK] Daemon: running on port 19825');
+ expect(text).toContain('[MISSING] Extension: not connected');
});
it('renders connectivity OK when live test succeeds', () => {
const text = strip(renderBrowserDoctorReport({
- envToken: 'abc123',
- envFingerprint: 'fp1',
- extensionToken: 'abc123',
- extensionFingerprint: 'fp1',
- extensionInstalled: true,
- extensionBrowsers: ['Chrome'],
- shellFiles: [],
- configs: [],
- recommendedToken: 'abc123',
- recommendedFingerprint: 'fp1',
+ daemonRunning: true,
+ extensionConnected: true,
connectivity: { ok: true, durationMs: 1234 },
- warnings: [],
issues: [],
}));
- expect(text).toContain('[OK] Browser connectivity: connected in 1.2s');
+ expect(text).toContain('[OK] Connectivity: connected in 1.2s');
});
- it('renders connectivity WARN when not tested', () => {
+ it('renders connectivity SKIP when not tested', () => {
const text = strip(renderBrowserDoctorReport({
- envToken: 'abc123',
- envFingerprint: 'fp1',
- extensionToken: 'abc123',
- extensionFingerprint: 'fp1',
- extensionInstalled: true,
- extensionBrowsers: ['Chrome'],
- shellFiles: [],
- configs: [],
- recommendedToken: 'abc123',
- recommendedFingerprint: 'fp1',
- warnings: [],
+ daemonRunning: true,
+ extensionConnected: true,
issues: [],
}));
- expect(text).toContain('[WARN] Browser connectivity: not tested (use --live)');
+ expect(text).toContain('[SKIP] Connectivity: not tested (use --live)');
});
});
-
diff --git a/src/doctor.ts b/src/doctor.ts
index 3e75210..f63ef42 100644
--- a/src/doctor.ts
+++ b/src/doctor.ts
@@ -1,47 +1,22 @@
-import * as fs from 'node:fs';
-import * as os from 'node:os';
-import * as path from 'node:path';
+/**
+ * opencli doctor — diagnose and fix browser connectivity.
+ *
+ * Simplified for the daemon-based architecture. No more token management,
+ * MCP path discovery, or config file scanning.
+ */
-import { createInterface } from 'node:readline/promises';
-import { stdin as input, stdout as output } from 'node:process';
import chalk from 'chalk';
-import type { IPage } from './types.js';
-import { PlaywrightMCP, getTokenFingerprint } from './browser/index.js';
+import { checkDaemonStatus } from './browser/discover.js';
+import { PlaywrightMCP } from './browser/index.js';
import { browserSession } from './runtime.js';
-const PLAYWRIGHT_SERVER_NAME = 'playwright';
-export const PLAYWRIGHT_TOKEN_ENV = 'PLAYWRIGHT_MCP_EXTENSION_TOKEN';
-const PLAYWRIGHT_EXTENSION_ID = 'mmlmfjhmonkocbjadbfplnigmagldckm';
-const TOKEN_LINE_RE = /^(\s*export\s+PLAYWRIGHT_MCP_EXTENSION_TOKEN=)(['"]?)([^'"\\\n]+)\2\s*$/m;
export type DoctorOptions = {
fix?: boolean;
yes?: boolean;
live?: boolean;
- shellRc?: string;
- configPaths?: string[];
- token?: string;
cliVersion?: string;
};
-export type ShellFileStatus = {
- path: string;
- exists: boolean;
- token: string | null;
- fingerprint: string | null;
-};
-
-export type McpConfigFormat = 'json' | 'toml';
-
-export type McpConfigStatus = {
- path: string;
- exists: boolean;
- format: McpConfigFormat;
- token: string | null;
- fingerprint: string | null;
- writable: boolean;
- parseError?: string;
-};
-
export type ConnectivityResult = {
ok: boolean;
error?: string;
@@ -50,463 +25,22 @@ export type ConnectivityResult = {
export type DoctorReport = {
cliVersion?: string;
- envToken: string | null;
- envFingerprint: string | null;
- extensionToken: string | null;
- extensionFingerprint: string | null;
- extensionInstalled: boolean;
- extensionBrowsers: string[];
- shellFiles: ShellFileStatus[];
- configs: McpConfigStatus[];
- recommendedToken: string | null;
- recommendedFingerprint: string | null;
+ daemonRunning: boolean;
+ extensionConnected: boolean;
connectivity?: ConnectivityResult;
- warnings: string[];
issues: string[];
};
-type ReportStatus = 'OK' | 'MISSING' | 'MISMATCH' | 'WARN';
-
-function colorLabel(status: ReportStatus): string {
- switch (status) {
- case 'OK': return chalk.green('[OK]');
- case 'MISSING': return chalk.red('[MISSING]');
- case 'MISMATCH': return chalk.yellow('[MISMATCH]');
- case 'WARN': return chalk.yellow('[WARN]');
- }
-}
-
-function statusLine(status: ReportStatus, text: string): string {
- return `${colorLabel(status)} ${text}`;
-}
-
-function tokenSummary(token: string | null, fingerprint: string | null): string {
- if (!token) return chalk.dim('missing');
- return `configured ${chalk.dim(`(${fingerprint})`)}`;
-}
-
-export function shortenPath(p: string): string {
- const home = os.homedir();
- return home && p.startsWith(home) ? '~' + p.slice(home.length) : p;
-}
-
-export function toolName(p: string): string {
- if (p.includes('.codex/')) return 'Codex';
- if (p.includes('.cursor/')) return 'Cursor';
- if (p.includes('.claude.json')) return 'Claude Code';
- if (p.includes('antigravity')) return 'Antigravity';
- if (p.includes('.gemini/settings')) return 'Gemini CLI';
- if (p.includes('opencode')) return 'OpenCode';
- if (p.includes('Claude/claude_desktop')) return 'Claude Desktop';
- if (p.includes('.vscode/')) return 'VS Code';
- if (p.includes('.mcp.json')) return 'Project MCP';
- if (p.includes('.zshrc') || p.includes('.bashrc') || p.includes('.profile')) return 'Shell';
- return '';
-}
-
-export function getDefaultShellRcPath(): string {
- const shell = process.env.SHELL ?? '';
- if (shell.endsWith('/bash')) return path.join(os.homedir(), '.bashrc');
- if (shell.endsWith('/fish')) return path.join(os.homedir(), '.config', 'fish', 'config.fish');
- return path.join(os.homedir(), '.zshrc');
-}
-
-function isFishConfig(filePath: string): boolean {
- return filePath.endsWith('config.fish') || filePath.includes('/fish/');
-}
-
-/** Detect if a JSON config file uses OpenCode's `mcp` format vs standard `mcpServers` */
-function isOpenCodeConfig(filePath: string): boolean {
- return filePath.includes('opencode');
-}
-
-export function getDefaultMcpConfigPaths(cwd: string = process.cwd()): string[] {
- const home = os.homedir();
- const candidates = [
- path.join(home, '.codex', 'config.toml'),
- path.join(home, '.codex', 'mcp.json'),
- path.join(home, '.cursor', 'mcp.json'),
- path.join(home, '.claude.json'),
- path.join(home, '.gemini', 'settings.json'),
- path.join(home, '.gemini', 'antigravity', 'mcp_config.json'),
- path.join(home, '.config', 'opencode', 'opencode.json'),
- path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'),
- path.join(home, '.config', 'Claude', 'claude_desktop_config.json'),
- path.join(cwd, '.cursor', 'mcp.json'),
- path.join(cwd, '.vscode', 'mcp.json'),
- path.join(cwd, '.opencode', 'opencode.json'),
- path.join(cwd, '.mcp.json'),
- ];
- return [...new Set(candidates)];
-}
-
-export function readTokenFromShellContent(content: string): string | null {
- const m = content.match(TOKEN_LINE_RE);
- return m?.[3] ?? null;
-}
-
-export function upsertShellToken(content: string, token: string, filePath?: string): string {
- if (filePath && isFishConfig(filePath)) {
- // Fish shell uses `set -gx` instead of `export`
- const fishLine = `set -gx ${PLAYWRIGHT_TOKEN_ENV} "${token}"`;
- const fishRe = /^\s*set\s+(-gx\s+)?PLAYWRIGHT_MCP_EXTENSION_TOKEN\s+.*/m;
- if (!content.trim()) return `${fishLine}\n`;
- if (fishRe.test(content)) return content.replace(fishRe, fishLine);
- return `${content.replace(/\s*$/, '')}\n${fishLine}\n`;
- }
- const nextLine = `export ${PLAYWRIGHT_TOKEN_ENV}="${token}"`;
- if (!content.trim()) return `${nextLine}\n`;
- if (TOKEN_LINE_RE.test(content)) return content.replace(TOKEN_LINE_RE, `$1"${
- token
- }"`);
- return `${content.replace(/\s*$/, '')}\n${nextLine}\n`;
-}
-
-function readJsonConfigToken(content: string): string | null {
- try {
- const parsed = JSON.parse(content);
- return readTokenFromJsonObject(parsed);
- } catch {
- return null;
- }
-}
-
-function readTokenFromJsonObject(parsed: any): string | null {
- const direct = parsed?.mcpServers?.[PLAYWRIGHT_SERVER_NAME]?.env?.[PLAYWRIGHT_TOKEN_ENV];
- if (typeof direct === 'string' && direct) return direct;
- const opencode = parsed?.mcp?.[PLAYWRIGHT_SERVER_NAME]?.environment?.[PLAYWRIGHT_TOKEN_ENV];
- if (typeof opencode === 'string' && opencode) return opencode;
- return null;
-}
-
-export function upsertJsonConfigToken(content: string, token: string, filePath?: string): string {
- const parsed = content.trim() ? JSON.parse(content) : {};
-
- // Determine format: use OpenCode format only if explicitly an opencode config,
- // or if the existing content already uses `mcp` key (not `mcpServers`)
- const useOpenCodeFormat = filePath
- ? isOpenCodeConfig(filePath)
- : (!parsed.mcpServers && parsed.mcp);
-
- if (useOpenCodeFormat) {
- parsed.mcp = parsed.mcp ?? {};
- parsed.mcp[PLAYWRIGHT_SERVER_NAME] = parsed.mcp[PLAYWRIGHT_SERVER_NAME] ?? {
- command: ['npx', '-y', '@playwright/mcp@latest', '--extension'],
- enabled: true,
- type: 'local',
- };
- parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment = parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment ?? {};
- parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment[PLAYWRIGHT_TOKEN_ENV] = token;
- } else {
- parsed.mcpServers = parsed.mcpServers ?? {};
- parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] ?? {
- command: 'npx',
- args: ['-y', '@playwright/mcp@latest', '--extension'],
- };
- parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env ?? {};
- parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env[PLAYWRIGHT_TOKEN_ENV] = token;
- }
- return `${JSON.stringify(parsed, null, 2)}\n`;
-}
-
-export function readTomlConfigToken(content: string): string | null {
- const sectionMatch = content.match(/\[mcp_servers\.playwright\.env\][\s\S]*?(?=\n\[|$)/);
- if (!sectionMatch) return null;
- const tokenMatch = sectionMatch[0].match(/^\s*PLAYWRIGHT_MCP_EXTENSION_TOKEN\s*=\s*"([^"\n]+)"/m);
- return tokenMatch?.[1] ?? null;
-}
-
-export function upsertTomlConfigToken(content: string, token: string): string {
- const envSectionRe = /(\[mcp_servers\.playwright\.env\][\s\S]*?)(?=\n\[|$)/;
- const tokenLine = `PLAYWRIGHT_MCP_EXTENSION_TOKEN = "${token}"`;
- if (envSectionRe.test(content)) {
- return content.replace(envSectionRe, (section) => {
- if (/^\s*PLAYWRIGHT_MCP_EXTENSION_TOKEN\s*=/m.test(section)) {
- return section.replace(/^\s*PLAYWRIGHT_MCP_EXTENSION_TOKEN\s*=.*$/m, tokenLine);
- }
- return `${section.replace(/\s*$/, '')}\n${tokenLine}\n`;
- });
- }
-
- const baseSectionRe = /(\[mcp_servers\.playwright\][\s\S]*?)(?=\n\[|$)/;
- if (baseSectionRe.test(content)) {
- return content.replace(baseSectionRe, (section) => `${section.replace(/\s*$/, '')}\n\n[mcp_servers.playwright.env]\n${tokenLine}\n`);
- }
-
- const prefix = content.trim() ? `${content.replace(/\s*$/, '')}\n\n` : '';
- return `${prefix}[mcp_servers.playwright]\ntype = "stdio"\ncommand = "npx"\nargs = ["-y", "@playwright/mcp@latest", "--extension"]\n\n[mcp_servers.playwright.env]\n${tokenLine}\n`;
-}
-
-export function fileExists(filePath: string): boolean {
- try {
- return fs.existsSync(filePath);
- } catch {
- return false;
- }
-}
-
-function canWrite(filePath: string): boolean {
- try {
- if (fileExists(filePath)) {
- fs.accessSync(filePath, fs.constants.W_OK);
- return true;
- }
- fs.accessSync(path.dirname(filePath), fs.constants.W_OK);
- return true;
- } catch {
- return false;
- }
-}
-
-function readConfigStatus(filePath: string): McpConfigStatus {
- const format: McpConfigFormat = filePath.endsWith('.toml') ? 'toml' : 'json';
- if (!fileExists(filePath)) {
- return { path: filePath, exists: false, format, token: null, fingerprint: null, writable: canWrite(filePath) };
- }
- try {
- const content = fs.readFileSync(filePath, 'utf-8');
- const token = format === 'toml' ? readTomlConfigToken(content) : readJsonConfigToken(content);
- return {
- path: filePath,
- exists: true,
- format,
- token,
- fingerprint: getTokenFingerprint(token ?? undefined),
- writable: canWrite(filePath),
- };
- } catch (error: any) {
- return {
- path: filePath,
- exists: true,
- format,
- token: null,
- fingerprint: null,
- writable: canWrite(filePath),
- parseError: error?.message ?? String(error),
- };
- }
-}
-
-/**
- * Dynamically enumerate Chrome profiles by scanning for 'Default' and 'Profile *'
- * directories across all browser base paths. Falls back to ['Default'] if none found.
- */
-function enumerateProfiles(baseDirs: string[]): string[] {
- const profiles = new Set();
- for (const base of baseDirs) {
- if (!fileExists(base)) continue;
- try {
- for (const entry of fs.readdirSync(base, { withFileTypes: true })) {
- if (!entry.isDirectory()) continue;
- if (entry.name === 'Default' || /^Profile \d+$/.test(entry.name)) {
- profiles.add(entry.name);
- }
- }
- } catch { /* permission denied, etc. */ }
- }
- return profiles.size > 0 ? [...profiles].sort() : ['Default'];
-}
-
-/**
- * Discover the auth token stored by the Playwright MCP Bridge extension
- * by scanning Chrome's LevelDB localStorage files directly.
- *
- * Reads LevelDB .ldb/.log files as raw binary and searches for the
- * extension ID near base64url token values. This works reliably across
- * platforms because LevelDB's internal encoding can split ASCII strings
- * like "auth-token" and the extension ID across byte boundaries, making
- * text-based tools like `strings` + `grep` unreliable.
- */
-export function discoverExtensionToken(): string | null {
- const home = os.homedir();
- const platform = os.platform();
- const bases: string[] = [];
-
- if (platform === 'darwin') {
- bases.push(
- path.join(home, 'Library', 'Application Support', 'Google', 'Chrome'),
- path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Dev'),
- path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Beta'),
- path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary'),
- path.join(home, 'Library', 'Application Support', 'Chromium'),
- path.join(home, 'Library', 'Application Support', 'Microsoft Edge'),
- );
- } else if (platform === 'linux') {
- bases.push(
- path.join(home, '.config', 'google-chrome'),
- path.join(home, '.config', 'google-chrome-unstable'),
- path.join(home, '.config', 'google-chrome-beta'),
- path.join(home, '.config', 'chromium'),
- path.join(home, '.config', 'microsoft-edge'),
- );
- } else if (platform === 'win32') {
- const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
- bases.push(
- path.join(appData, 'Google', 'Chrome', 'User Data'),
- path.join(appData, 'Google', 'Chrome Dev', 'User Data'),
- path.join(appData, 'Google', 'Chrome Beta', 'User Data'),
- path.join(appData, 'Microsoft', 'Edge', 'User Data'),
- );
- }
-
- const profiles = enumerateProfiles(bases);
- const tokenRe = /([A-Za-z0-9_-]{40,50})/;
-
- for (const base of bases) {
- for (const profile of profiles) {
- const dir = path.join(base, profile, 'Local Storage', 'leveldb');
- if (!fileExists(dir)) continue;
-
- const token = extractTokenViaBinaryRead(dir, tokenRe);
- if (token) return token;
- }
- }
-
- return null;
-}
-
-function extractTokenViaBinaryRead(dir: string, tokenRe: RegExp): string | null {
- // LevelDB fragments strings across byte boundaries, so we can't search
- // for the full extension ID or "auth-token" as contiguous ASCII. Instead,
- // search for a short prefix of the extension ID that reliably appears as
- // contiguous bytes, then scan a window around each match for a base64url
- // token value.
- //
- // Observed LevelDB layout near the auth-token entry:
- // ... auth-t ... 4,mmlmfjhPocbjadbfplnigmagldckm.7 ...
- // hqI86ncsD1QpcVcj-k9CyzTF-ieCQd_4KreZ_wy1WHA ...
- //
- // The extension ID prefix "mmlmfjh" appears ~44 bytes before the token.
- const extIdBuf = Buffer.from(PLAYWRIGHT_EXTENSION_ID);
- const extIdPrefix = Buffer.from(PLAYWRIGHT_EXTENSION_ID.slice(0, 7)); // "mmlmfjh"
-
- let files: string[];
- try {
- files = fs.readdirSync(dir)
- .filter(f => f.endsWith('.ldb') || f.endsWith('.log'))
- .map(f => path.join(dir, f));
- } catch { return null; }
-
- // Sort by mtime descending so we find the freshest token first
- files.sort((a, b) => {
- try { return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs; } catch { return 0; }
- });
-
- for (const file of files) {
- let data: Buffer;
- try { data = fs.readFileSync(file); } catch { continue; }
-
- // Quick check: file must contain at least the prefix
- if (data.indexOf(extIdPrefix) === -1) continue;
-
- // Strategy 1: scan after each occurrence of the extension ID prefix
- // for base64url tokens within a 500-byte window
- let idx = 0;
- while (true) {
- const pos = data.indexOf(extIdPrefix, idx);
- if (pos === -1) break;
-
- const scanStart = pos;
- const scanEnd = Math.min(data.length, pos + 500);
- const window = data.subarray(scanStart, scanEnd).toString('latin1');
- const m = window.match(tokenRe);
- if (m && validateBase64urlToken(m[1])) {
- // Make sure this isn't another extension ID that happens to match
- if (m[1] !== PLAYWRIGHT_EXTENSION_ID) return m[1];
- }
- idx = pos + 1;
- }
-
- // Strategy 2 (fallback): original approach using full extension ID + auth-token key
- const keyBuf = Buffer.from('auth-token');
- idx = 0;
- while (true) {
- const kp = data.indexOf(keyBuf, idx);
- if (kp === -1) break;
-
- const contextStart = Math.max(0, kp - 500);
- if (data.indexOf(extIdBuf, contextStart) !== -1 && data.indexOf(extIdBuf, contextStart) < kp) {
- const after = data.subarray(kp + keyBuf.length, kp + keyBuf.length + 200).toString('latin1');
- const m = after.match(tokenRe);
- if (m && validateBase64urlToken(m[1])) return m[1];
- }
- idx = kp + 1;
- }
- }
- return null;
-}
-
-function validateBase64urlToken(token: string): boolean {
- try {
- const b64 = token.replace(/-/g, '+').replace(/_/g, '/');
- const decoded = Buffer.from(b64, 'base64');
- return decoded.length >= 28 && decoded.length <= 36;
- } catch { return false; }
-}
-
-
-/**
- * Check whether the Playwright MCP Bridge extension is installed in any browser.
- * Scans Chrome/Chromium/Edge Extensions directories for the known extension ID.
- */
-export function checkExtensionInstalled(): { installed: boolean; browsers: string[] } {
- const home = os.homedir();
- const platform = os.platform();
- const browserDirs: Array<{ name: string; base: string }> = [];
-
- if (platform === 'darwin') {
- browserDirs.push(
- { name: 'Chrome', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome') },
- { name: 'Chrome Dev', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Dev') },
- { name: 'Chrome Beta', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Beta') },
- { name: 'Chrome Canary', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary') },
- { name: 'Chromium', base: path.join(home, 'Library', 'Application Support', 'Chromium') },
- { name: 'Edge', base: path.join(home, 'Library', 'Application Support', 'Microsoft Edge') },
- );
- } else if (platform === 'linux') {
- browserDirs.push(
- { name: 'Chrome', base: path.join(home, '.config', 'google-chrome') },
- { name: 'Chrome Dev', base: path.join(home, '.config', 'google-chrome-unstable') },
- { name: 'Chrome Beta', base: path.join(home, '.config', 'google-chrome-beta') },
- { name: 'Chromium', base: path.join(home, '.config', 'chromium') },
- { name: 'Edge', base: path.join(home, '.config', 'microsoft-edge') },
- );
- } else if (platform === 'win32') {
- const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
- browserDirs.push(
- { name: 'Chrome', base: path.join(appData, 'Google', 'Chrome', 'User Data') },
- { name: 'Chrome Dev', base: path.join(appData, 'Google', 'Chrome Dev', 'User Data') },
- { name: 'Chrome Beta', base: path.join(appData, 'Google', 'Chrome Beta', 'User Data') },
- { name: 'Edge', base: path.join(appData, 'Microsoft', 'Edge', 'User Data') },
- );
- }
-
- const profiles = enumerateProfiles(browserDirs.map(d => d.base));
- const foundBrowsers: string[] = [];
-
- for (const { name, base } of browserDirs) {
- for (const profile of profiles) {
- const extDir = path.join(base, profile, 'Extensions', PLAYWRIGHT_EXTENSION_ID);
- if (fileExists(extDir)) {
- foundBrowsers.push(name);
- break; // one match per browser is enough
- }
- }
- }
-
- return { installed: foundBrowsers.length > 0, browsers: [...new Set(foundBrowsers)] };
-}
-
/**
- * Test token connectivity by attempting a real MCP connection.
- * Connects, does the JSON-RPC handshake, and immediately closes.
+ * Test connectivity by attempting a real browser command.
*/
-export async function checkTokenConnectivity(opts?: { timeout?: number }): Promise {
- const timeout = opts?.timeout ?? 8;
+export async function checkConnectivity(opts?: { timeout?: number }): Promise {
const start = Date.now();
try {
const mcp = new PlaywrightMCP();
- await mcp.connect({ timeout });
+ const page = await mcp.connect({ timeout: opts?.timeout ?? 8 });
+ // Try a simple eval to verify end-to-end connectivity
+ await page.evaluate('1 + 1');
await mcp.close();
return { ok: true, durationMs: Date.now() - start };
} catch (err: any) {
@@ -515,215 +49,87 @@ export async function checkTokenConnectivity(opts?: { timeout?: number }): Promi
}
export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise {
- const envToken = process.env[PLAYWRIGHT_TOKEN_ENV] ?? null;
- const shellPath = opts.shellRc ?? getDefaultShellRcPath();
- const shellFiles: ShellFileStatus[] = [shellPath].map((filePath) => {
- if (!fileExists(filePath)) return { path: filePath, exists: false, token: null, fingerprint: null };
- const content = fs.readFileSync(filePath, 'utf-8');
- const token = readTokenFromShellContent(content);
- return { path: filePath, exists: true, token, fingerprint: getTokenFingerprint(token ?? undefined) };
- });
- const configPaths = opts.configPaths?.length ? opts.configPaths : getDefaultMcpConfigPaths();
- const configs = configPaths.map(readConfigStatus);
-
- // Try to discover the token directly from the Chrome extension's localStorage
- const extensionToken = discoverExtensionToken();
+ const status = await checkDaemonStatus();
- const allTokens = [
- opts.token ?? null,
- extensionToken,
- envToken,
- ...shellFiles.map(s => s.token),
- ...configs.map(c => c.token),
- ].filter((v): v is string => !!v);
- const uniqueTokens = [...new Set(allTokens)];
- const recommendedToken = opts.token ?? extensionToken ?? envToken ?? (uniqueTokens.length === 1 ? uniqueTokens[0] : null) ?? null;
-
- // Check extension installation
- const extInstall = checkExtensionInstalled();
-
- // Connectivity test (only when --live)
let connectivity: ConnectivityResult | undefined;
if (opts.live) {
- connectivity = await checkTokenConnectivity();
+ connectivity = await checkConnectivity();
+ }
+
+ const issues: string[] = [];
+ if (!status.running) {
+ issues.push('Daemon is not running. It should start automatically when you run an opencli browser command.');
+ }
+ if (status.running && !status.extensionConnected) {
+ issues.push(
+ 'Daemon is running but the Chrome extension is not connected.\n' +
+ 'Please install the opencli Browser Bridge extension:\n' +
+ ' 1. Download from GitHub Releases\n' +
+ ' 2. Open chrome://extensions/ → Enable Developer Mode\n' +
+ ' 3. Click "Load unpacked" → select the extension folder',
+ );
+ }
+ if (connectivity && !connectivity.ok) {
+ issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
}
- const report: DoctorReport = {
+ return {
cliVersion: opts.cliVersion,
- envToken,
- envFingerprint: getTokenFingerprint(envToken ?? undefined),
- extensionToken,
- extensionFingerprint: getTokenFingerprint(extensionToken ?? undefined),
- extensionInstalled: extInstall.installed,
- extensionBrowsers: extInstall.browsers,
- shellFiles,
- configs,
- recommendedToken,
- recommendedFingerprint: getTokenFingerprint(recommendedToken ?? undefined),
+ daemonRunning: status.running,
+ extensionConnected: status.extensionConnected,
connectivity,
- warnings: [],
- issues: [],
+ issues,
};
-
- if (!extInstall.installed) report.issues.push('Playwright MCP Bridge extension is not installed in any browser.');
- if (!envToken) report.issues.push(`Current environment is missing ${PLAYWRIGHT_TOKEN_ENV}.`);
- if (!shellFiles.some(s => s.token)) report.issues.push('Shell startup file does not export PLAYWRIGHT_MCP_EXTENSION_TOKEN.');
- if (!configs.some(c => c.token)) report.issues.push('No scanned MCP config currently contains a Playwright extension token.');
- if (uniqueTokens.length > 1) report.issues.push('Detected inconsistent Playwright MCP tokens across env/config files.');
- if (connectivity && !connectivity.ok) report.issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
- for (const config of configs) {
- if (config.parseError) report.warnings.push(`Could not parse ${config.path}: ${config.parseError}`);
- }
- if (!recommendedToken) {
- report.warnings.push('No token source found.');
- }
- return report;
}
export function renderBrowserDoctorReport(report: DoctorReport): string {
- const tokenFingerprints = [
- report.extensionFingerprint,
- report.envFingerprint,
- ...report.shellFiles.map(shell => shell.fingerprint),
- ...report.configs.filter(config => config.exists).map(config => config.fingerprint),
- ].filter((value): value is string => !!value);
- const uniqueFingerprints = [...new Set(tokenFingerprints)];
- const hasMismatch = uniqueFingerprints.length > 1;
const lines = [chalk.bold(`opencli v${report.cliVersion ?? 'unknown'} doctor`), ''];
- // CDP endpoint mode (for remote/server environments)
- const cdpEndpoint = process.env.OPENCLI_CDP_ENDPOINT;
- if (cdpEndpoint) {
- lines.push(statusLine('OK', `CDP endpoint: ${chalk.cyan(cdpEndpoint)}`));
- lines.push(chalk.dim(' → Remote Chrome mode: extension token not required'));
- lines.push('');
- return lines.join('\n');
- }
-
- const installStatus: ReportStatus = report.extensionInstalled ? 'OK' : 'MISSING';
- const installDetail = report.extensionInstalled
- ? `Extension installed (${report.extensionBrowsers.join(', ')})`
- : 'Extension not installed in any browser';
- lines.push(statusLine(installStatus, installDetail));
-
- const extStatus: ReportStatus = !report.extensionToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
- lines.push(statusLine(extStatus, `Extension token (Chrome LevelDB): ${tokenSummary(report.extensionToken, report.extensionFingerprint)}`));
+ // Daemon status
+ const daemonIcon = report.daemonRunning ? chalk.green('[OK]') : chalk.red('[MISSING]');
+ lines.push(`${daemonIcon} Daemon: ${report.daemonRunning ? 'running on port 19825' : 'not running'}`);
- const envStatus: ReportStatus = !report.envToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
- lines.push(statusLine(envStatus, `Environment token: ${tokenSummary(report.envToken, report.envFingerprint)}`));
-
- for (const shell of report.shellFiles) {
- const shellStatus: ReportStatus = !shell.token ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
- const tool = toolName(shell.path);
- const suffix = tool ? chalk.dim(` [${tool}]`) : '';
- lines.push(statusLine(shellStatus, `${shortenPath(shell.path)}${suffix}: ${tokenSummary(shell.token, shell.fingerprint)}`));
- }
- const existingConfigs = report.configs.filter(config => config.exists);
- const missingConfigCount = report.configs.length - existingConfigs.length;
- if (existingConfigs.length > 0) {
- for (const config of existingConfigs) {
- const parseSuffix = config.parseError ? chalk.red(` (parse error)`) : '';
- const configStatus: ReportStatus = config.parseError
- ? 'WARN'
- : !config.token
- ? 'MISSING'
- : hasMismatch
- ? 'MISMATCH'
- : 'OK';
- const tool = toolName(config.path);
- const suffix = tool ? chalk.dim(` [${tool}]`) : '';
- lines.push(statusLine(configStatus, `${shortenPath(config.path)}${suffix}: ${tokenSummary(config.token, config.fingerprint)}${parseSuffix}`));
- }
- } else {
- lines.push(statusLine('MISSING', 'MCP config: no existing config files found'));
- }
- if (missingConfigCount > 0) lines.push(chalk.dim(` Other scanned config locations not present: ${missingConfigCount}`));
- lines.push('');
+ // Extension status
+ const extIcon = report.extensionConnected ? chalk.green('[OK]') : chalk.yellow('[MISSING]');
+ lines.push(`${extIcon} Extension: ${report.extensionConnected ? 'connected' : 'not connected'}`);
- // Connectivity result
+ // Connectivity
if (report.connectivity) {
- const connStatus: ReportStatus = report.connectivity.ok ? 'OK' : 'WARN';
- const connDetail = report.connectivity.ok
- ? `Browser connectivity: connected in ${(report.connectivity.durationMs / 1000).toFixed(1)}s`
- : `Browser connectivity: failed (${report.connectivity.error ?? 'unknown'})`;
- lines.push(statusLine(connStatus, connDetail));
+ const connIcon = report.connectivity.ok ? chalk.green('[OK]') : chalk.red('[FAIL]');
+ const detail = report.connectivity.ok
+ ? `connected in ${(report.connectivity.durationMs / 1000).toFixed(1)}s`
+ : `failed (${report.connectivity.error ?? 'unknown'})`;
+ lines.push(`${connIcon} Connectivity: ${detail}`);
} else {
- lines.push(statusLine('WARN', 'Browser connectivity: not tested (use --live)'));
+ lines.push(`${chalk.dim('[SKIP]')} Connectivity: not tested (use --live)`);
}
- lines.push(statusLine(
- hasMismatch ? 'MISMATCH' : report.recommendedToken ? 'OK' : 'WARN',
- `Recommended token fingerprint: ${report.recommendedFingerprint ?? 'unavailable'}`,
- ));
if (report.issues.length) {
lines.push('', chalk.yellow('Issues:'));
- for (const issue of report.issues) lines.push(chalk.dim(` • ${issue}`));
- }
- if (report.warnings.length) {
- lines.push('', chalk.yellow('Warnings:'));
- for (const warning of report.warnings) lines.push(chalk.dim(` • ${warning}`));
- }
- return lines.join('\n');
-}
-
-async function confirmPrompt(question: string): Promise {
- const rl = createInterface({ input, output });
- try {
- const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
- return answer === 'y' || answer === 'yes';
- } finally {
- rl.close();
+ for (const issue of report.issues) {
+ lines.push(chalk.dim(` • ${issue}`));
+ }
+ } else if (report.daemonRunning && report.extensionConnected) {
+ lines.push('', chalk.green('Everything looks good!'));
}
-}
-export function writeFileWithMkdir(filePath: string, content: string): void {
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
- fs.writeFileSync(filePath, content, 'utf-8');
+ return lines.join('\n');
}
-export async function applyBrowserDoctorFix(report: DoctorReport, opts: DoctorOptions = {}): Promise {
- const token = opts.token ?? report.recommendedToken;
- if (!token) throw new Error('No Playwright MCP token is available to write. Provide --token first.');
- const fp = getTokenFingerprint(token);
-
- const plannedWrites: string[] = [];
- const shellPath = opts.shellRc ?? report.shellFiles[0]?.path ?? getDefaultShellRcPath();
- const shellStatus = report.shellFiles.find(s => s.path === shellPath);
- if (shellStatus?.fingerprint !== fp) plannedWrites.push(shellPath);
- for (const config of report.configs) {
- if (!config.writable) continue;
- if (config.fingerprint === fp) continue; // already correct
- plannedWrites.push(config.path);
- }
-
- if (plannedWrites.length === 0) {
- console.log(chalk.green('All config files are already up to date.'));
- return [];
- }
-
- if (!opts.yes) {
- const ok = await confirmPrompt(`Update ${plannedWrites.length} file(s) with Playwright MCP token fingerprint ${fp}?`);
- if (!ok) return [];
- }
-
- const written: string[] = [];
- if (plannedWrites.includes(shellPath)) {
- const shellBefore = fileExists(shellPath) ? fs.readFileSync(shellPath, 'utf-8') : '';
- writeFileWithMkdir(shellPath, upsertShellToken(shellBefore, token, shellPath));
- written.push(shellPath);
- }
-
- for (const config of report.configs) {
- if (!plannedWrites.includes(config.path)) continue;
- if (config.parseError) continue;
- const before = fileExists(config.path) ? fs.readFileSync(config.path, 'utf-8') : '';
- const next = config.format === 'toml'
- ? upsertTomlConfigToken(before, token)
- : upsertJsonConfigToken(before, token, config.path);
- writeFileWithMkdir(config.path, next);
- written.push(config.path);
- }
-
- process.env[PLAYWRIGHT_TOKEN_ENV] = token;
- return written;
-}
+// Backward compatibility exports (no-ops for things that no longer exist)
+export const PLAYWRIGHT_TOKEN_ENV = 'PLAYWRIGHT_MCP_EXTENSION_TOKEN';
+export function discoverExtensionToken(): string | null { return null; }
+export function checkExtensionInstalled(): { installed: boolean; browsers: string[] } { return { installed: false, browsers: [] }; }
+export function applyBrowserDoctorFix(): Promise { return Promise.resolve([]); }
+export function getDefaultShellRcPath(): string { return ''; }
+export function getDefaultMcpConfigPaths(): string[] { return []; }
+export function readTokenFromShellContent(_content: string): string | null { return null; }
+export function upsertShellToken(content: string): string { return content; }
+export function upsertJsonConfigToken(content: string): string { return content; }
+export function readTomlConfigToken(_content: string): string | null { return null; }
+export function upsertTomlConfigToken(content: string): string { return content; }
+export function shortenPath(p: string): string { return p; }
+export function toolName(_p: string): string { return ''; }
+export function fileExists(filePath: string): boolean { try { return require('node:fs').existsSync(filePath); } catch { return false; } }
+export function writeFileWithMkdir(_p: string, _c: string): void {}
+export async function checkTokenConnectivity(opts?: { timeout?: number }): Promise { return checkConnectivity(opts); }
diff --git a/src/main.ts b/src/main.ts
index e6bcd38..e3d89cf 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -119,36 +119,19 @@ program.command('cascade').description('Strategy cascade: find simplest working
});
program.command('doctor')
- .description('Diagnose Playwright MCP Bridge, token consistency, and Chrome remote debugging')
- .option('--fix', 'Apply suggested fixes to shell rc and detected MCP configs', false)
- .option('-y, --yes', 'Skip confirmation prompts when applying fixes', false)
- .option('--token ', 'Override token to write instead of auto-detecting')
+ .description('Diagnose opencli browser bridge connectivity')
.option('--live', 'Test browser connectivity (requires Chrome running)', false)
- .option('--shell-rc ', 'Shell startup file to update')
- .option('--mcp-config ', 'Comma-separated MCP config paths to scan/update')
.action(async (opts) => {
- const { runBrowserDoctor, renderBrowserDoctorReport, applyBrowserDoctorFix } = await import('./doctor.js');
- const configPaths = opts.mcpConfig ? String(opts.mcpConfig).split(',').map((s: string) => s.trim()).filter(Boolean) : undefined;
- const report = await runBrowserDoctor({ token: opts.token, live: opts.live, shellRc: opts.shellRc, configPaths, cliVersion: PKG_VERSION });
+ const { runBrowserDoctor, renderBrowserDoctorReport } = await import('./doctor.js');
+ const report = await runBrowserDoctor({ live: opts.live, cliVersion: PKG_VERSION });
console.log(renderBrowserDoctorReport(report));
- if (opts.fix) {
- const written = await applyBrowserDoctorFix(report, { fix: true, yes: opts.yes, token: opts.token, shellRc: opts.shellRc, configPaths });
- console.log();
- if (written.length > 0) {
- console.log(chalk.green('Updated files:'));
- for (const filePath of written) console.log(`- ${filePath}`);
- } else {
- console.log(chalk.yellow('No files were changed.'));
- }
- }
});
program.command('setup')
- .description('Interactive setup: configure Playwright MCP token across all detected tools')
- .option('--token ', 'Provide token directly instead of auto-detecting')
- .action(async (opts) => {
+ .description('Interactive setup: verify browser bridge connectivity')
+ .action(async () => {
const { runSetup } = await import('./setup.js');
- await runSetup({ cliVersion: PKG_VERSION, token: opts.token });
+ await runSetup({ cliVersion: PKG_VERSION });
});
program.command('completion')
diff --git a/src/pipeline/executor.test.ts b/src/pipeline/executor.test.ts
index 1b6217e..dcd1b67 100644
--- a/src/pipeline/executor.test.ts
+++ b/src/pipeline/executor.test.ts
@@ -26,6 +26,7 @@ function createMockPage(overrides: Partial = {}): IPage {
autoScroll: vi.fn(),
installInterceptor: vi.fn(),
getInterceptedRequests: vi.fn().mockResolvedValue([]),
+ screenshot: vi.fn().mockResolvedValue(''),
...overrides,
};
}
diff --git a/src/pipeline/steps/browser.ts b/src/pipeline/steps/browser.ts
index 21302f1..2d28958 100644
--- a/src/pipeline/steps/browser.ts
+++ b/src/pipeline/steps/browser.ts
@@ -4,7 +4,7 @@
*/
import type { IPage } from '../../types.js';
-import { render, normalizeEvaluateSource } from '../template.js';
+import { render } from '../template.js';
export async function stepNavigate(page: IPage | null, params: any, data: any, args: Record): Promise {
const url = render(params, { args, data });
@@ -52,7 +52,7 @@ export async function stepSnapshot(page: IPage | null, params: any, _data: any,
export async function stepEvaluate(page: IPage | null, params: any, data: any, args: Record): Promise {
const js = String(render(params, { args, data }));
- let result = await page!.evaluate(normalizeEvaluateSource(js));
+ let result = await page!.evaluate(js);
// MCP may return JSON as a string — auto-parse it
if (typeof result === 'string') {
const trimmed = result.trim();
diff --git a/src/pipeline/steps/intercept.ts b/src/pipeline/steps/intercept.ts
index b7ad0c7..26b60d9 100644
--- a/src/pipeline/steps/intercept.ts
+++ b/src/pipeline/steps/intercept.ts
@@ -3,7 +3,7 @@
*/
import type { IPage } from '../../types.js';
-import { render } from '../template.js';
+import { render, normalizeEvaluateSource } from '../template.js';
import { generateInterceptorJs, generateReadInterceptedJs } from '../../interceptor.js';
export async function stepIntercept(page: IPage | null, params: any, data: any, args: Record): Promise {
@@ -24,7 +24,6 @@ export async function stepIntercept(page: IPage | null, params: any, data: any,
await page!.goto(String(url));
} else if (trigger.startsWith('evaluate:')) {
const js = trigger.slice('evaluate:'.length);
- const { normalizeEvaluateSource } = await import('../template.js');
await page!.evaluate(normalizeEvaluateSource(render(js, { args, data }) as string));
} else if (trigger.startsWith('click:')) {
const ref = render(trigger.slice('click:'.length), { args, data });
diff --git a/src/setup.ts b/src/setup.ts
index df5f962..216ef00 100644
--- a/src/setup.ts
+++ b/src/setup.ts
@@ -1,205 +1,69 @@
/**
- * setup.ts — Interactive Playwright MCP token setup
+ * setup.ts — Interactive browser setup for opencli
*
- * Discovers the extension token, shows an interactive checkbox
- * for selecting which config files to update, and applies changes.
+ * Simplified for daemon-based architecture. No more token management.
+ * Just verifies daemon + extension connectivity.
*/
-import * as fs from 'node:fs';
+
import chalk from 'chalk';
-import { createInterface } from 'node:readline/promises';
-import { stdin as input, stdout as output } from 'node:process';
-import {
- type DoctorReport,
- PLAYWRIGHT_TOKEN_ENV,
- checkExtensionInstalled,
- checkTokenConnectivity,
- discoverExtensionToken,
- fileExists,
- getDefaultShellRcPath,
- runBrowserDoctor,
- shortenPath,
- toolName,
- upsertJsonConfigToken,
- upsertShellToken,
- upsertTomlConfigToken,
- writeFileWithMkdir,
-} from './doctor.js';
-import { getTokenFingerprint } from './browser/index.js';
-import { type CheckboxItem, checkboxPrompt } from './tui.js';
+import { checkDaemonStatus } from './browser/discover.js';
+import { checkConnectivity } from './doctor.js';
+import { PlaywrightMCP } from './browser/index.js';
export async function runSetup(opts: { cliVersion?: string; token?: string } = {}) {
console.log();
- console.log(chalk.bold(' opencli setup') + chalk.dim(' — Playwright MCP token configuration'));
+ console.log(chalk.bold(' opencli setup') + chalk.dim(' — browser bridge configuration'));
console.log();
- // Step 1: Discover token
- let token = opts.token ?? null;
-
- if (!token) {
- const extensionToken = discoverExtensionToken();
- const envToken = process.env[PLAYWRIGHT_TOKEN_ENV] ?? null;
+ // Step 1: Check daemon
+ console.log(chalk.dim(' Checking daemon status...'));
+ const status = await checkDaemonStatus();
- if (extensionToken && envToken && extensionToken === envToken) {
- token = extensionToken;
- console.log(` ${chalk.green('✓')} Token auto-discovered from Chrome extension`);
- console.log(` Fingerprint: ${chalk.bold(getTokenFingerprint(token) ?? 'unknown')}`);
- } else if (extensionToken) {
- token = extensionToken;
- console.log(` ${chalk.green('✓')} Token discovered from Chrome extension ` +
- chalk.dim(`(${getTokenFingerprint(token)})`));
- if (envToken && envToken !== extensionToken) {
- console.log(` ${chalk.yellow('!')} Environment has different token ` +
- chalk.dim(`(${getTokenFingerprint(envToken)})`));
- }
- } else if (envToken) {
- token = envToken;
- console.log(` ${chalk.green('✓')} Token from environment variable ` +
- chalk.dim(`(${getTokenFingerprint(token)})`));
- }
+ if (status.running) {
+ console.log(` ${chalk.green('✓')} Daemon is running on port 19825`);
} else {
- console.log(` ${chalk.green('✓')} Using provided token ` +
- chalk.dim(`(${getTokenFingerprint(token)})`));
+ console.log(` ${chalk.yellow('!')} Daemon is not running`);
+ console.log(chalk.dim(' The daemon starts automatically when you run a browser command.'));
+ console.log(chalk.dim(' Starting daemon now...'));
+
+ // Try to spawn daemon
+ const mcp = new PlaywrightMCP();
+ try {
+ await mcp.connect({ timeout: 5 });
+ await mcp.close();
+ console.log(` ${chalk.green('✓')} Daemon started successfully`);
+ } catch {
+ console.log(` ${chalk.yellow('!')} Could not start daemon automatically`);
+ }
}
- if (!token) {
- // Give precise diagnosis of why token scan failed
- const extInstall = checkExtensionInstalled();
-
- console.log(` ${chalk.red('✗')} Browser token scan failed\n`);
- if (!extInstall.installed) {
- console.log(chalk.dim(' Cause: Playwright MCP Bridge extension is not installed'));
- console.log(chalk.dim(' Fix: Install from https://chromewebstore.google.com/detail/'));
- console.log(chalk.dim(' playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm'));
- } else {
- console.log(chalk.dim(` Cause: Extension is installed (${extInstall.browsers.join(', ')}) but token not found in LevelDB`));
- console.log(chalk.dim(' Fix: 1) Open the extension popup and verify the token is generated'));
- console.log(chalk.dim(' 2) Close Chrome completely, then re-run setup'));
- }
+ // Step 2: Check extension
+ const statusAfter = await checkDaemonStatus();
+ if (statusAfter.extensionConnected) {
+ console.log(` ${chalk.green('✓')} Chrome extension connected`);
+ } else {
+ console.log(` ${chalk.red('✗')} Chrome extension not connected`);
console.log();
- console.log(` You can enter the token manually, or fix the above and re-run ${chalk.bold('opencli setup')}.`);
+ console.log(chalk.dim(' To install the opencli Browser Bridge extension:'));
+ console.log(chalk.dim(' 1. Download from GitHub Releases'));
+ console.log(chalk.dim(' 2. Open chrome://extensions/ → Enable Developer Mode'));
+ console.log(chalk.dim(' 3. Click "Load unpacked" → select the extension folder'));
+ console.log(chalk.dim(' 4. Make sure Chrome is running'));
console.log();
- const rl = createInterface({ input, output });
- const answer = await rl.question(' Token (press Enter to abort): ');
- rl.close();
- token = answer.trim();
- if (!token) {
- console.log(chalk.red('\n No token provided. Aborting.\n'));
- return;
- }
- }
-
- const fingerprint = getTokenFingerprint(token) ?? 'unknown';
- console.log();
-
- // Step 2: Scan all config locations
- const report = await runBrowserDoctor({ token, cliVersion: opts.cliVersion });
-
- // Step 3: Build checkbox items
- const items: CheckboxItem[] = [];
-
- // Shell file
- const shellPath = report.shellFiles[0]?.path ?? getDefaultShellRcPath();
- const shellStatus = report.shellFiles[0];
- const shellFp = shellStatus?.fingerprint;
- const shellOk = shellFp === fingerprint;
- const shellTool = toolName(shellPath) || 'Shell';
- items.push({
- label: padRight(shortenPath(shellPath), 50) + chalk.dim(` [${shellTool}]`),
- value: `shell:${shellPath}`,
- checked: !shellOk,
- status: shellOk ? `configured (${shellFp})` : shellFp ? `mismatch (${shellFp})` : 'missing',
- statusColor: shellOk ? 'green' : shellFp ? 'yellow' : 'red',
- });
-
- // Config files
- for (const config of report.configs) {
- const fp = config.fingerprint;
- const ok = fp === fingerprint;
- const tool = toolName(config.path);
- items.push({
- label: padRight(shortenPath(config.path), 50) + chalk.dim(tool ? ` [${tool}]` : ''),
- value: `config:${config.path}`,
- checked: false, // let user explicitly select which tools to configure
- status: ok ? `configured (${fp})` : !config.exists ? 'will create' : fp ? `mismatch (${fp})` : 'missing',
- statusColor: ok ? 'green' : 'yellow',
- });
- }
-
- // Step 4: Show interactive checkbox
- console.clear();
- const selected = await checkboxPrompt(items, {
- title: ` ${chalk.bold('opencli setup')} — token ${chalk.cyan(fingerprint)}`,
- });
-
- if (selected.length === 0) {
- console.log(chalk.dim(' No changes made.\n'));
return;
}
- // Step 5: Apply changes
- const written: string[] = [];
- let wroteShell = false;
-
- for (const sel of selected) {
- if (sel.startsWith('shell:')) {
- const p = sel.slice('shell:'.length);
- const before = fileExists(p) ? fs.readFileSync(p, 'utf-8') : '';
- writeFileWithMkdir(p, upsertShellToken(before, token, p));
- written.push(p);
- wroteShell = true;
- } else if (sel.startsWith('config:')) {
- const p = sel.slice('config:'.length);
- const config = report.configs.find(c => c.path === p);
- if (config && config.parseError) continue;
- const before = fileExists(p) ? fs.readFileSync(p, 'utf-8') : '';
- const format = config?.format ?? (p.endsWith('.toml') ? 'toml' : 'json');
- const next = format === 'toml' ? upsertTomlConfigToken(before, token) : upsertJsonConfigToken(before, token, p);
- writeFileWithMkdir(p, next);
- written.push(p);
- }
- }
-
- process.env[PLAYWRIGHT_TOKEN_ENV] = token;
-
- // Step 6: Summary
- if (written.length > 0) {
- console.log(chalk.green.bold(` ✓ Updated ${written.length} file(s):`));
- for (const p of written) {
- const tool = toolName(p);
- console.log(` ${chalk.dim('•')} ${shortenPath(p)}${tool ? chalk.dim(` [${tool}]`) : ''}`);
- }
- if (wroteShell) {
- console.log();
- console.log(chalk.cyan(` 💡 Run ${chalk.bold(`source ${shortenPath(shellPath)}`)} to apply token to current shell.`));
- }
- } else {
- console.log(chalk.yellow(' No files were changed.'));
- }
+ // Step 3: Test connectivity
console.log();
-
- // Step 7: Auto-verify browser connectivity
- console.log(chalk.dim(' Verifying browser connectivity...'));
- try {
- const result = await checkTokenConnectivity({ timeout: 5 });
- if (result.ok) {
- console.log(` ${chalk.green('✓')} Browser connected in ${(result.durationMs / 1000).toFixed(1)}s`);
- } else {
- console.log(` ${chalk.green('✓')} Token saved successfully.`);
- console.log(` ${chalk.yellow('!')} Browser connectivity test failed: ${result.error ?? 'unknown'}`);
- console.log(chalk.dim(' Token configuration is complete. To use opencli, make sure Chrome'));
- console.log(chalk.dim(' is running with the Playwright MCP Bridge extension enabled.'));
- console.log(chalk.dim(` Run ${chalk.bold('opencli doctor --live')} to re-test connectivity.`));
- }
- } catch {
- console.log(` ${chalk.green('✓')} Token saved successfully.`);
- console.log(` ${chalk.yellow('!')} Browser connectivity test skipped (Chrome may not be running).`);
- console.log(chalk.dim(' Token configuration is complete. Start Chrome to begin using opencli.'));
- console.log(chalk.dim(` Run ${chalk.bold('opencli doctor --live')} to re-test connectivity.`));
+ console.log(chalk.dim(' Testing browser connectivity...'));
+ const conn = await checkConnectivity({ timeout: 5 });
+ if (conn.ok) {
+ console.log(` ${chalk.green('✓')} Browser connected in ${(conn.durationMs / 1000).toFixed(1)}s`);
+ console.log();
+ console.log(chalk.green.bold(' ✓ Setup complete! You can now use opencli browser commands.'));
+ } else {
+ console.log(` ${chalk.yellow('!')} Connectivity test failed: ${conn.error ?? 'unknown'}`);
+ console.log(chalk.dim(` Run ${chalk.bold('opencli doctor --live')} to diagnose.`));
}
console.log();
}
-
-function padRight(s: string, n: number): string {
- const visible = s.replace(/\x1b\[[0-9;]*m/g, '');
- return visible.length >= n ? s : s + ' '.repeat(n - visible.length);
-}
diff --git a/src/types.ts b/src/types.ts
index 08075d9..5ee5af6 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -23,4 +23,5 @@ export interface IPage {
autoScroll(options?: { times?: number; delayMs?: number }): Promise;
installInterceptor(pattern: string): Promise;
getInterceptedRequests(): Promise;
+ screenshot(options?: { format?: 'png' | 'jpeg'; quality?: number; fullPage?: boolean; path?: string }): Promise;
}