diff --git a/README.md b/README.md index faa4f52..6eda20a 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ A CLI tool that turns **any website** into a command-line interface — Bilibili > **⚠️ Important**: Browser commands reuse your Chrome login session. You must be logged into the target website in Chrome before running commands. If you get empty data or errors, check your login status first. OpenCLI connects to your browser through the Playwright MCP Bridge extension. +It prefers an existing local/global `@playwright/mcp` install and falls back to `npx -y @playwright/mcp@latest` automatically when no local MCP server is found. ### Playwright MCP Bridge Extension Setup diff --git a/README.zh-CN.md b/README.zh-CN.md index c994183..2e78999 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -43,6 +43,7 @@ OpenCLI 将任何网站变成命令行工具 — B站、知乎、小红书、Twi > **⚠️ 重要**:大多数命令复用你的 Chrome 登录状态。运行命令前,你必须已在 Chrome 中打开目标网站并完成登录。如果获取到空数据或报错,请先检查你的浏览器登录状态。 OpenCLI 通过 Playwright MCP Bridge 扩展与你的浏览器通信。 +它会优先复用本地或全局已安装的 `@playwright/mcp`,如果没有嗅探到可用 MCP server,则会自动回退到 `npx -y @playwright/mcp@latest` 启动。 ### Playwright MCP Bridge 扩展配置 diff --git a/src/browser.test.ts b/src/browser.test.ts index 2e0d03c..b8ce11e 100644 --- a/src/browser.test.ts +++ b/src/browser.test.ts @@ -112,6 +112,47 @@ describe('browser helpers', () => { } }); + 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'); }); diff --git a/src/browser/discover.ts b/src/browser/discover.ts index 0ee2b53..992acb6 100644 --- a/src/browser/discover.ts +++ b/src/browser/discover.ts @@ -111,16 +111,41 @@ export function findMcpServerPath(): string | null { return _cachedMcpServerPath; } -export function buildMcpArgs(input: { mcpPath: string; executablePath?: string | null }): string[] { - const args = [input.mcpPath]; +function buildRuntimeArgs(input?: { executablePath?: string | null }): string[] { + const args: string[] = []; if (!process.env.CI) { // Local: always connect to user's running Chrome via MCP Bridge extension 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) { + if (input?.executablePath) { args.push('--executable-path', input.executablePath); } return args; } + +export function buildMcpArgs(input: { mcpPath: string; executablePath?: string | null }): string[] { + return [input.mcpPath, ...buildRuntimeArgs(input)]; +} + +export function buildMcpLaunchSpec(input: { mcpPath?: string | null; executablePath?: string | null }): { + 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/index.ts b/src/browser/index.ts index 892a992..be17baf 100644 --- a/src/browser/index.ts +++ b/src/browser/index.ts @@ -13,7 +13,7 @@ export type { ConnectFailureKind, ConnectFailureInput } from './errors.js'; // Test-only helpers — exposed for unit tests import { createJsonRpcRequest } from './mcp.js'; import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js'; -import { buildMcpArgs, findMcpServerPath, resetMcpServerPathCache, setMcpDiscoveryTestHooks } from './discover.js'; +import { buildMcpArgs, buildMcpLaunchSpec, findMcpServerPath, resetMcpServerPathCache, setMcpDiscoveryTestHooks } from './discover.js'; import { withTimeoutMs } from '../runtime.js'; export const __test__ = { @@ -22,6 +22,7 @@ export const __test__ = { diffTabIndexes, appendLimited, buildMcpArgs, + buildMcpLaunchSpec, findMcpServerPath, resetMcpServerPathCache, setMcpDiscoveryTestHooks, diff --git a/src/browser/mcp.ts b/src/browser/mcp.ts index c7ee855..7003577 100644 --- a/src/browser/mcp.ts +++ b/src/browser/mcp.ts @@ -9,7 +9,7 @@ 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, buildMcpArgs } from './discover.js'; +import { findMcpServerPath, buildMcpLaunchSpec } from './discover.js'; import { extractTabIdentities, extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js'; const STDERR_BUFFER_LIMIT = 16 * 1024; @@ -105,7 +105,6 @@ export class PlaywrightMCP { if (this._state === 'closed') throw new Error('Playwright MCP session is closed'); const mcpPath = findMcpServerPath(); - if (!mcpPath) throw new Error('Playwright MCP server not found. Install: npm install -D @playwright/mcp'); PlaywrightMCP._registerGlobalCleanup(); PlaywrightMCP._activeInsts.add(this); @@ -154,17 +153,20 @@ export class PlaywrightMCP { })); }, timeout * 1000); - const mcpArgs = buildMcpArgs({ + const launchSpec = buildMcpLaunchSpec({ mcpPath, executablePath: process.env.OPENCLI_BROWSER_EXECUTABLE_PATH, }); if (process.env.OPENCLI_VERBOSE) { console.error(`[opencli] Mode: ${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 node ${mcpArgs.join(' ')}`); + debugLog(`Spawning ${launchSpec.command} ${launchSpec.args.join(' ')}`); - this._proc = spawn('node', mcpArgs, { + this._proc = spawn(launchSpec.command, launchSpec.args, { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env }, });