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/content/noop.js b/extension/content/noop.js new file mode 100644 index 0000000..2698aae --- /dev/null +++ b/extension/content/noop.js @@ -0,0 +1,2 @@ +// Minimal content script — required by manifest to auto-grant host permissions. +// No actual logic needed; chrome.scripting.executeScript handles all injection. 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..d0c84c9 --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,42 @@ +{ + "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", + "scripting", + "tabs", + "cookies", + "activeTab", + "alarms" + ], + "host_permissions": [ + "" + ], + "content_scripts": [ + { + "matches": ["http://*/*", "https://*/*"], + "js": ["content/noop.js"], + "run_at": "document_idle" + } + ], + "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..2dcebde --- /dev/null +++ b/extension/src/background.ts @@ -0,0 +1,246 @@ +/** + * opencli Browser Bridge — Service Worker (background script). + * + * Connects to the opencli daemon via WebSocket, receives commands, + * dispatches them to Chrome APIs (scripting/tabs/cookies), returns results. + * + * IMPORTANT: In IIFE mode (non-module), all chrome.* API registrations + * must happen inside lifecycle events (onInstalled/onStartup), NOT at + * the top level, or the service worker registration will fail. + */ + +import type { Command, Result } from './protocol'; +import { DAEMON_WS_URL, WS_RECONNECT_DELAY } from './protocol'; +import * as cdp from './cdp'; + +let ws: WebSocket | null = null; +let reconnectTimer: ReturnType | null = null; + +// ─── 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'); + 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; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connect(); + }, WS_RECONNECT_DELAY); +} + +// ─── Lifecycle events ──────────────────────────────────────────────── +// All chrome.* API registrations must be in these listeners, not top-level. + +function initialize(): void { + 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); + 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 ───────────────────────────────────────────────── + +/** 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 && activeTab.url && !activeTab.url.startsWith('chrome://') && !activeTab.url.startsWith('chrome-extension://')) { + return activeTab.id; + } + + // Active tab is not debuggable (chrome:// or chrome-extension://), + // try to find any open web page tab + const allTabs = await chrome.tabs.query({ currentWindow: true }); + const webTab = allTabs.find(t => t.id && t.url && !t.url.startsWith('chrome://') && !t.url.startsWith('chrome-extension://')); + 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 + await new Promise((resolve) => { + 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(); + }, 30000); + }); + + 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) => t.url && !t.url.startsWith('chrome://') && !t.url.startsWith('chrome-extension://')) + .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) { + // Close by index + 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 } }; + } + // Close by tabId or active tab + 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 }; +} diff --git a/extension/src/cdp.ts b/extension/src/cdp.ts new file mode 100644 index 0000000..8d4409a --- /dev/null +++ b/extension/src/cdp.ts @@ -0,0 +1,68 @@ +/** + * 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')) { + 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; + +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..83f7391 --- /dev/null +++ b/extension/src/protocol.ts @@ -0,0 +1,48 @@ +/** + * opencli browser protocol — shared types between daemon, extension, and CLI. + * + * Only 4 actions. Everything else is just JS code sent via 'exec'. + */ + +export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies'; + +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; +} + +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}`; + +/** Reconnect delay for extension WebSocket (ms) */ +export const WS_RECONNECT_DELAY = 3000; +/** 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..e423014 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "browser", "web", "ai", - "playwright" + "browser" ], "author": "jackwener", "license": "Apache-2.0", @@ -45,11 +45,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..ca679a5 100644 --- a/src/browser.test.ts +++ b/src/browser.test.ts @@ -1,14 +1,6 @@ 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' }); @@ -57,220 +49,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 +69,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..d7810cf --- /dev/null +++ b/src/browser/daemon-client.ts @@ -0,0 +1,89 @@ +/** + * 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'; + tabId?: number; + code?: string; + url?: string; + op?: string; + index?: number; + domain?: string; +} + +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. + */ +export async function sendCommand( + action: DaemonCommand['action'], + params: Omit = {}, +): Promise { + const id = generateId(); + const command: DaemonCommand = { id, action, ...params }; + + const res = await fetch(`${DAEMON_URL}/command`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(command), + }); + + const result = (await res.json()) as DaemonResult; + + if (!result.ok) { + throw new Error(result.error ?? 'Daemon command failed'); + } + + return result.data; +} 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. - */ - -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; - } - - // 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; -} - -/** - * Chrome 144+ auto-discovery: read DevToolsActivePort file to get CDP endpoint. + * Daemon discovery — simplified from MCP server path discovery. * - * 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. + * Only needs to check if the daemon is running. No more file system + * scanning for @playwright/mcp locations. */ -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')); - } +import { isDaemonRunning } from './daemon-client.js'; - // 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')); - } +export { isDaemonRunning }; - 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 {} +/** + * Check daemon status and return connection info. + */ +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 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, - }; - } - - 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 = 'daemon-not-running' | 'extension-not-connected' | 'command-failed' | 'unknown'; -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}`); - } - - 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'; +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'); + } } diff --git a/src/browser/index.ts b/src/browser/index.ts index 1ad2f8b..adec262 100644 --- a/src/browser/index.ts +++ b/src/browser/index.ts @@ -7,14 +7,16 @@ 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'; -// Test-only helpers — exposed for unit tests +// 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 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__ = { @@ -22,10 +24,5 @@ export const __test__ = { extractTabEntries, diffTabIndexes, appendLimited, - buildMcpArgs, - buildMcpLaunchSpec, - findMcpServerPath, - resetMcpServerPathCache, - setMcpDiscoveryTestHooks, withTimeoutMs, }; diff --git a/src/browser/mcp.ts b/src/browser/mcp.ts index b0967fd..d40a9a8 100644 --- a/src/browser/mcp.ts +++ b/src/browser/mcp.ts @@ -1,312 +1,118 @@ /** - * 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; +// Re-export for __test__ compatibility +let _jsonRpcId = 0; 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', - }; + const id = ++_jsonRpcId; + return { id, message: JSON.stringify({ id, method, params }) }; } /** - * 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); - }); - }); - } + async connect(opts: { timeout?: number } = {}): Promise { + if (this._state === 'connected' && this._page) return this._page; + 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'); - private _rejectPendingRequests(error: Error): void { - const pending = [...this._pending.values()]; - this._pending.clear(); - for (const waiter of pending) waiter.reject(error); - } + this._state = 'connecting'; - 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 {} + try { + await this._ensureDaemon(); + this._page = new Page(); + this._state = 'connected'; + return this._page; + } catch (err) { + this._state = 'idle'; + throw err; } } - 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'); - - 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}`); - } - } - }); - - 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 }); - } - }); - - // 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); - - // 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 }); - }); - }); - } - - 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; + // We don't kill the daemon — it auto-exits on idle. + // Just clean up our reference. + this._page = null; + this._state = 'closed'; + } + + 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 tsx for .ts files, node for .js files + const runtime = isTs ? 'npx' : process.execPath; + const args = isTs ? ['tsx', daemonPath] : [daemonPath]; + + this._daemonProc = spawn(runtime, args, { + 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; + } + + // 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..2ef766b 100644 --- a/src/browser/page.ts +++ b/src/browser/page.ts @@ -1,139 +1,236 @@ /** - * 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.' - ); - } - - // 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; - } - - // --- High-level methods --- + /** Active tab ID, set after navigate and used in all subsequent commands */ + private _tabId: number | undefined; async goto(url: string): Promise { - await this.call('tools/call', { name: 'browser_navigate', arguments: { url } }); + const result = await sendCommand('navigate', { + url, + ...(this._tabId !== undefined ? { tabId: this._tabId } : {}), + }) 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 } }); + // Wrap function-style code: `() => { ... }` or `async () => { ... }` → IIFE + const trimmed = normalized.trim(); + const code = trimmed.startsWith('async') + ? `(${trimmed})()` + : trimmed.startsWith('function') || trimmed.startsWith('(') + ? `(${trimmed})()` + : trimmed; + return sendCommand('exec', { + code, + ...(this._tabId !== undefined ? { tabId: this._tabId } : {}), + }); } async snapshot(opts: { interactive?: boolean; compact?: boolean; maxDepth?: number; raw?: boolean } = {}): Promise { - const raw = await this.call('tools/call', { name: 'browser_snapshot', arguments: {} }); + // Use CDP Accessibility.getFullAXTree via exec + const code = ` + (async () => { + // Build a simplified accessibility tree from the DOM + function buildTree(node, depth = 0) { + if (depth > ${opts.maxDepth ?? 50}) 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); + })() + `; + const raw = await sendCommand('exec', { + code, + ...(this._tabId !== undefined ? { tabId: this._tabId } : {}), + }); 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._tabId !== undefined ? { tabId: this._tabId } : {}), + }); } 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._tabId !== undefined ? { tabId: this._tabId } : {}), + }); } 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._tabId !== undefined ? { tabId: this._tabId } : {}), + }); } 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._tabId !== undefined ? { tabId: this._tabId } : {}), + }); } } 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 } }); + // Use performance API to get network entries + 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._tabId !== undefined ? { tabId: this._tabId } : {}), + }); } 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 exec. + // Return empty for now — users should use networkRequests or evaluate. + 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' } }); + 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._tabId !== undefined ? { tabId: this._tabId } : {}), + }); } 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 +239,36 @@ 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._tabId !== undefined ? { tabId: this._tabId } : {}), + }); } async installInterceptor(pattern: string): Promise { - await this.evaluate(generateInterceptorJs(JSON.stringify(pattern), { - arrayName: '__opencli_xhr', - patchGuard: '__opencli_interceptor_patched', - })); + await sendCommand('exec', { + code: generateInterceptorJs(JSON.stringify(pattern), { + arrayName: '__opencli_xhr', + patchGuard: '__opencli_interceptor_patched', + }), + ...(this._tabId !== undefined ? { tabId: this._tabId } : {}), + }); } async getInterceptedRequests(): Promise { - const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr')); - return result || []; + const result = await sendCommand('exec', { + code: generateReadInterceptedJs('__opencli_xhr'), + ...(this._tabId !== undefined ? { tabId: this._tabId } : {}), + }); + return (result as any[]) || []; } } diff --git a/src/daemon.ts b/src/daemon.ts new file mode 100644 index 0000000..568eda7 --- /dev/null +++ b/src/daemon.ts @@ -0,0 +1,164 @@ +/** + * 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; + +// ─── 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 ?? '/'; + + if (req.method === 'GET' && url === '/status') { + jsonResponse(res, 200, { + ok: true, + extensionConnected: extensionWs?.readyState === WebSocket.OPEN, + pending: pending.size, + }); + return; + } + + if (req.method === 'POST' && url === '/command') { + resetIdleTimer(); + try { + const body = JSON.parse(await readBody(req)); + + 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 result = JSON.parse(data.toString()); + const p = pending.get(result.id); + if (p) { + clearTimeout(p.timer); + pending.delete(result.id); + p.resolve(result); + } + } 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 +process.on('SIGTERM', () => process.exit(0)); +process.on('SIGINT', () => process.exit(0)); 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 MISMATCH-style report when fingerprints differ', () => { + it('renders MISSING when daemon not 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: false, + extensionConnected: false, + issues: ['Daemon is not running.'], })); - 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('[MISSING] Daemon: not running'); + expect(text).toContain('[MISSING] Extension: not connected'); + expect(text).toContain('Daemon is not running.'); + }); + + it('renders extension not connected when daemon is running', () => { + const text = strip(renderBrowserDoctorReport({ + daemonRunning: true, + extensionConnected: false, + issues: ['Daemon is running but the Chrome extension is not connected.'], + })); + + 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. + * Test connectivity by attempting a real browser command. */ -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. - */ -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); + const status = await checkDaemonStatus(); - // Try to discover the token directly from the Chrome extension's localStorage - const extensionToken = discoverExtensionToken(); - - 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 report: DoctorReport = { + 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'}`); + } + + 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'); - } + // 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 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)); + // Extension status + const extIcon = report.extensionConnected ? chalk.green('[OK]') : chalk.yellow('[MISSING]'); + lines.push(`${extIcon} Extension: ${report.extensionConnected ? 'connected' : 'not connected'}`); - const extStatus: ReportStatus = !report.extensionToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK'; - lines.push(statusLine(extStatus, `Extension token (Chrome LevelDB): ${tokenSummary(report.extensionToken, report.extensionFingerprint)}`)); - - 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(''); - - // 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}`)); + for (const issue of report.issues) { + lines.push(chalk.dim(` • ${issue}`)); + } + } else if (report.daemonRunning && report.extensionConnected) { + lines.push('', chalk.green('Everything looks good!')); } + 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(); - } -} - -export function writeFileWithMkdir(filePath: string, content: string): void { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, content, 'utf-8'); -} - -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/steps/browser.ts b/src/pipeline/steps/browser.ts index 21302f1..d846f7f 100644 --- a/src/pipeline/steps/browser.ts +++ b/src/pipeline/steps/browser.ts @@ -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/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; + // Step 1: Check daemon + console.log(chalk.dim(' Checking daemon status...')); + const status = await checkDaemonStatus(); - if (!token) { - const extensionToken = discoverExtensionToken(); - const envToken = process.env[PLAYWRIGHT_TOKEN_ENV] ?? null; - - 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...')); - 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')); - } - console.log(); - console.log(` You can enter the token manually, or fix the above and re-run ${chalk.bold('opencli setup')}.`); - 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; + // 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`); } } - 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')); + // 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(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(); 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.`)); - } + // Step 3: Test connectivity + console.log(); + 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(' No files were changed.')); - } - 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.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); -}