From 0c89b884a7c7e1e0372b594fb73b21b9eba7961c Mon Sep 17 00:00:00 2001 From: Pleasurecruise <3196812536@qq.com> Date: Wed, 18 Mar 2026 20:05:50 +0000 Subject: [PATCH] feat: add desktop CDP connect/disconnect support Introduce managed CDP connections for desktop Electron apps: add a new connections module (src/connections.ts) to save, probe and resolve CDP endpoints, plus unit tests (src/connections.test.ts). Add CLI commands connect/disconnect in main.ts to persist endpoints and validate reachability, and update dynamic command execution to use resolved CDP endpoints for desktop sites. Propagate cdpEndpoint through runtime.browserSession and PlaywrightMCP.connect (src/runtime.ts, src/browser/mcp.ts). Update multiple README files to instruct using `opencli connect `, and ignore .idea in .gitignore. --- .gitignore | 1 + src/browser/mcp.ts | 6 +- src/clis/antigravity/README.md | 4 +- src/clis/antigravity/README.zh-CN.md | 4 +- src/clis/chatwise/README.md | 5 +- src/clis/chatwise/README.zh-CN.md | 5 +- src/clis/codex/README.md | 4 +- src/clis/codex/README.zh-CN.md | 4 +- src/clis/cursor/README.md | 2 +- src/clis/cursor/README.zh-CN.md | 2 +- src/clis/discord-app/README.md | 2 +- src/clis/discord-app/README.zh-CN.md | 2 +- src/clis/notion/README.md | 2 +- src/clis/notion/README.zh-CN.md | 2 +- src/connections.test.ts | 143 ++++++++++++++++++++++ src/connections.ts | 170 +++++++++++++++++++++++++++ src/main.ts | 116 ++++++++++++++++-- src/runtime.ts | 5 +- 18 files changed, 447 insertions(+), 32 deletions(-) create mode 100644 src/connections.test.ts create mode 100644 src/connections.ts diff --git a/.gitignore b/.gitignore index 4df6f4d..24d3e55 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist/ .opencli/ .mcp.json *.log +.idea/ \ No newline at end of file diff --git a/src/browser/mcp.ts b/src/browser/mcp.ts index b0967fd..96d8275 100644 --- a/src/browser/mcp.ts +++ b/src/browser/mcp.ts @@ -98,7 +98,7 @@ export class PlaywrightMCP { } } - async connect(opts: { timeout?: number } = {}): Promise { + async connect(opts: { timeout?: number; cdpEndpoint?: string } = {}): 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'); @@ -114,7 +114,9 @@ export class PlaywrightMCP { 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 resolved = resolveCdpEndpoint(); + const cdpEndpoint = opts.cdpEndpoint ?? resolved.endpoint; + const requestedCdp = Boolean(opts.cdpEndpoint) || resolved.requestedCdp; const useExtension = !requestedCdp; const extensionToken = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN; const tokenFingerprint = getTokenFingerprint(extensionToken); diff --git a/src/clis/antigravity/README.md b/src/clis/antigravity/README.md index 2c2aa7d..ded2ad0 100644 --- a/src/clis/antigravity/README.md +++ b/src/clis/antigravity/README.md @@ -18,10 +18,10 @@ Start the Antigravity desktop app with the Chrome DevTools `remote-debugging-por *(Note: Depending on your installation, the executable might be named differently, e.g., \`Antigravity\` instead of \`Electron\`.)* -Next, set the target port in your terminal session to tell OpenCLI where to connect: +Next, connect OpenCLI to the app: \`\`\`bash -export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9224" +opencli connect antigravity \`\`\` ## Available Commands diff --git a/src/clis/antigravity/README.zh-CN.md b/src/clis/antigravity/README.zh-CN.md index 6baa709..7209e92 100644 --- a/src/clis/antigravity/README.zh-CN.md +++ b/src/clis/antigravity/README.zh-CN.md @@ -21,10 +21,10 @@ CLI all electron!现在支持把所有 electron 应用 CLI 化,从而组合 *(注意:如果你打包的应用重命名过主构建,可能需要把 `Electron` 换成实际的可执行文件名,如 `Antigravity`)* -接下来,在你想执行 CLI 命令的另一个新终端板块里,声明要连入的本地调试端口环境变量: +接下来,在你想执行 CLI 命令的另一个新终端板块里,让 OpenCLI 连接到这个桌面应用: \`\`\`bash -export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9224" +opencli connect antigravity \`\`\` ## 全部指令一览 diff --git a/src/clis/chatwise/README.md b/src/clis/chatwise/README.md index e343c0d..5e356b4 100644 --- a/src/clis/chatwise/README.md +++ b/src/clis/chatwise/README.md @@ -7,14 +7,13 @@ Control the **ChatWise Desktop App** from the terminal via Chrome DevTools Proto 1. Install [ChatWise](https://chatwise.app/). 2. Launch with remote debugging port: ```bash - /Applications/ChatWise.app/Contents/MacOS/ChatWise \ - --remote-debugging-port=9228 + /Applications/ChatWise.app/Contents/MacOS/ChatWise --remote-debugging-port=9228 ``` ## Setup ```bash -export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9228" +opencli connect chatwise ``` ## Commands diff --git a/src/clis/chatwise/README.zh-CN.md b/src/clis/chatwise/README.zh-CN.md index 2da3915..8bb45bd 100644 --- a/src/clis/chatwise/README.zh-CN.md +++ b/src/clis/chatwise/README.zh-CN.md @@ -7,14 +7,13 @@ 1. 安装 [ChatWise](https://chatwise.app/)。 2. 通过远程调试端口启动: ```bash - /Applications/ChatWise.app/Contents/MacOS/ChatWise \ - --remote-debugging-port=9228 + /Applications/ChatWise.app/Contents/MacOS/ChatWise --remote-debugging-port=9228 ``` ## 配置 ```bash -export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9228" +opencli connect chatwise ``` ## 命令 diff --git a/src/clis/codex/README.md b/src/clis/codex/README.md index ef24abe..10aae27 100644 --- a/src/clis/codex/README.md +++ b/src/clis/codex/README.md @@ -14,9 +14,9 @@ Because Codex is built on Electron, OpenCLI can directly drive its internal UI, ## Setup -Export the CDP endpoint in your shell: +Connect OpenCLI to the app: ```bash -export OPENCLI_CODEX_CDP_ENDPOINT="http://127.0.0.1:9222" +opencli connect codex ``` ## Commands diff --git a/src/clis/codex/README.zh-CN.md b/src/clis/codex/README.zh-CN.md index 8ce5ede..f8eaf16 100644 --- a/src/clis/codex/README.zh-CN.md +++ b/src/clis/codex/README.zh-CN.md @@ -14,9 +14,9 @@ ## 配置指南 -在你要运行命令的终端里导出环境变量: +让 OpenCLI 连接到这个桌面应用: ```bash -export OPENCLI_CODEX_CDP_ENDPOINT="http://127.0.0.1:9222" +opencli connect codex ``` ## 核心指令 diff --git a/src/clis/cursor/README.md b/src/clis/cursor/README.md index a4eb5a4..43b6207 100644 --- a/src/clis/cursor/README.md +++ b/src/clis/cursor/README.md @@ -13,7 +13,7 @@ Control the **Cursor IDE** from the terminal via Chrome DevTools Protocol (CDP). ## Setup ```bash -export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9226" +opencli connect cursor ``` ## Commands diff --git a/src/clis/cursor/README.zh-CN.md b/src/clis/cursor/README.zh-CN.md index 41ca0d9..a332974 100644 --- a/src/clis/cursor/README.zh-CN.md +++ b/src/clis/cursor/README.zh-CN.md @@ -13,7 +13,7 @@ ## 配置 ```bash -export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9226" +opencli connect cursor ``` ## 命令 diff --git a/src/clis/discord-app/README.md b/src/clis/discord-app/README.md index 6bfbd90..4bd94ec 100644 --- a/src/clis/discord-app/README.md +++ b/src/clis/discord-app/README.md @@ -12,7 +12,7 @@ Launch with remote debugging port: ## Setup ```bash -export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9232" +opencli connect discord-app ``` ## Commands diff --git a/src/clis/discord-app/README.zh-CN.md b/src/clis/discord-app/README.zh-CN.md index 1ee4248..ebd19de 100644 --- a/src/clis/discord-app/README.zh-CN.md +++ b/src/clis/discord-app/README.zh-CN.md @@ -12,7 +12,7 @@ ## 配置 ```bash -export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9232" +opencli connect discord-app ``` ## 命令 diff --git a/src/clis/notion/README.md b/src/clis/notion/README.md index 6eb729c..51fee60 100644 --- a/src/clis/notion/README.md +++ b/src/clis/notion/README.md @@ -12,7 +12,7 @@ Launch with remote debugging port: ## Setup ```bash -export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9230" +opencli connect notion ``` ## Commands diff --git a/src/clis/notion/README.zh-CN.md b/src/clis/notion/README.zh-CN.md index b182efc..e59ad36 100644 --- a/src/clis/notion/README.zh-CN.md +++ b/src/clis/notion/README.zh-CN.md @@ -12,7 +12,7 @@ ## 配置 ```bash -export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9230" +opencli connect notion ``` ## 命令 diff --git a/src/connections.test.ts b/src/connections.test.ts new file mode 100644 index 0000000..e4b2a23 --- /dev/null +++ b/src/connections.test.ts @@ -0,0 +1,143 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CliError } from './errors.js'; + +const { TEST_HOME, httpGetMock, httpsGetMock } = vi.hoisted(() => ({ + TEST_HOME: '/tmp/opencli-connections-vitest', + httpGetMock: vi.fn(), + httpsGetMock: vi.fn(), +})); + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os'); + return { + ...actual, + homedir: () => TEST_HOME, + }; +}); + +vi.mock('node:http', async () => { + const actual = await vi.importActual('node:http'); + return { + ...actual, + get: httpGetMock, + }; +}); + +vi.mock('node:https', async () => { + const actual = await vi.importActual('node:https'); + return { + ...actual, + get: httpsGetMock, + }; +}); + +import { + getSavedSiteConnection, + loadConnections, + parseCdpPort, + probeCdpEndpoint, + removeSiteConnection, + resolveLiveSiteEndpoint, + saveSiteConnection, +} from './connections.js'; + +const CONNECTIONS_PATH = path.join(TEST_HOME, '.opencli', 'connections.json'); + +function mockRequest() { + return { + on: vi.fn().mockReturnThis(), + setTimeout: vi.fn().mockReturnThis(), + destroy: vi.fn(), + } as any; +} + +function mockProbeStatuses(statusByUrl: Record) { + const req = mockRequest(); + httpGetMock.mockImplementation((input: string | URL, cb: any) => { + cb({ statusCode: statusByUrl[String(input)] ?? 503, resume() {} }); + return req; + }); + return req; +} + +describe('connections', () => { + beforeEach(() => { + fs.rmSync(TEST_HOME, { recursive: true, force: true }); + delete process.env.OPENCLI_CDP_ENDPOINT; + httpGetMock.mockReset(); + httpsGetMock.mockReset(); + }); + + afterEach(() => { + delete process.env.OPENCLI_CDP_ENDPOINT; + vi.restoreAllMocks(); + }); + + it('saves and removes site connections', () => { + const saved = saveSiteConnection('chatwise', 'http://127.0.0.1:9228/'); + + expect(saved.endpoint).toBe('http://127.0.0.1:9228'); + expect(getSavedSiteConnection('chatwise')?.endpoint).toBe('http://127.0.0.1:9228'); + expect(loadConnections().cdp.chatwise?.endpoint).toBe('http://127.0.0.1:9228'); + expect(fs.existsSync(CONNECTIONS_PATH)).toBe(true); + + removeSiteConnection('chatwise'); + + expect(getSavedSiteConnection('chatwise')).toBeUndefined(); + expect(loadConnections().cdp.chatwise).toBeUndefined(); + }); + + it('validates CDP ports', () => { + expect(parseCdpPort('9222')).toBe(9222); + expect(() => parseCdpPort('abc')).toThrowError(CliError); + expect(() => parseCdpPort('70000')).toThrowError(CliError); + }); + + it('probes ws endpoints via the http json/version URL', async () => { + const req = mockRequest(); + httpGetMock.mockImplementation((input: string | URL, cb: any) => { + cb({ statusCode: 200, resume() {} }); + return req; + }); + + await expect(probeCdpEndpoint('ws://127.0.0.1:9222/devtools/browser/abc')).resolves.toBe(true); + expect(httpGetMock).toHaveBeenCalledTimes(1); + expect(String(httpGetMock.mock.calls[0][0])).toBe('http://127.0.0.1:9222/json/version'); + }); + + it('probes https endpoints with the https transport', async () => { + const req = mockRequest(); + httpsGetMock.mockImplementation((input: string | URL, cb: any) => { + cb({ statusCode: 204, resume() {} }); + return req; + }); + + await expect(probeCdpEndpoint('https://example.com:9222')).resolves.toBe(true); + expect(httpsGetMock).toHaveBeenCalledTimes(1); + expect(String(httpsGetMock.mock.calls[0][0])).toBe('https://example.com:9222/json/version'); + }); + + it('rejects unsupported endpoint protocols with a clear error', async () => { + await expect(probeCdpEndpoint('ftp://example.com')).rejects.toMatchObject({ + code: 'CONNECT_UNSUPPORTED_PROTOCOL', + }); + }); + + it('resolves a saved endpoint before falling back', async () => { + saveSiteConnection('chatwise', 'http://127.0.0.1:9555'); + mockProbeStatuses({ 'http://127.0.0.1:9555/json/version': 200 }); + + await expect(resolveLiveSiteEndpoint('chatwise')).resolves.toBe('http://127.0.0.1:9555'); + }); + + it('falls back to the env endpoint when the saved one is unavailable', async () => { + saveSiteConnection('chatwise', 'http://127.0.0.1:9555'); + process.env.OPENCLI_CDP_ENDPOINT = 'http://127.0.0.1:9666/'; + mockProbeStatuses({ 'http://127.0.0.1:9666/json/version': 200 }); + + await expect(resolveLiveSiteEndpoint('chatwise')).resolves.toBe('http://127.0.0.1:9666'); + }); + +}); diff --git a/src/connections.ts b/src/connections.ts new file mode 100644 index 0000000..3888e44 --- /dev/null +++ b/src/connections.ts @@ -0,0 +1,170 @@ +import * as fs from 'node:fs'; +import * as http from 'node:http'; +import * as https from 'node:https'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { CliError } from './errors.js'; +import type { CliCommand } from './registry.js'; + +export type SavedConnection = { + endpoint: string; + updatedAt: string; +}; + +type ConnectionsFile = { + version: 1; + cdp: Record; +}; + +export const DESKTOP_CDP_SITES: Record = { + antigravity: { defaultPort: 9224, appName: 'Antigravity' }, + codex: { defaultPort: 9222, appName: 'Codex' }, + cursor: { defaultPort: 9226, appName: 'Cursor' }, + 'discord-app': { defaultPort: 9232, appName: 'Discord' }, + notion: { defaultPort: 9230, appName: 'Notion' }, + chatwise: { defaultPort: 9228, appName: 'ChatWise' }, +}; + +const OPENCLI_DIR = path.join(os.homedir(), '.opencli'); +const CONNECTIONS_PATH = path.join(OPENCLI_DIR, 'connections.json'); + +function normalizeEndpoint(endpoint: string): string { + return endpoint.trim().replace(/\/+$/, ''); +} + +function toProbeUrl(endpoint: string): URL { + let url: URL; + try { + url = new URL(normalizeEndpoint(endpoint)); + } catch { + throw new CliError( + 'CONNECT_INVALID_ENDPOINT', + `Invalid CDP endpoint: ${endpoint}.`, + 'Use a full URL such as http://127.0.0.1:9222 or ws://127.0.0.1:9222/devtools/browser/.' + ); + } + + if (url.protocol === 'ws:') url.protocol = 'http:'; + else if (url.protocol === 'wss:') url.protocol = 'https:'; + else if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new CliError( + 'CONNECT_UNSUPPORTED_PROTOCOL', + `Unsupported CDP endpoint protocol: ${url.protocol}`, + 'Use an http(s) or ws(s) CDP endpoint.' + ); + } + + url.pathname = '/json/version'; + url.search = ''; + url.hash = ''; + return url; +} + +export function parseCdpPort(value: string): number { + const port = Number.parseInt(value, 10); + if (!Number.isInteger(port) || String(port) !== value.trim() || port < 1 || port > 65535) { + throw new CliError( + 'CONNECT_INVALID_PORT', + `Invalid CDP port: ${value}.`, + 'Provide an integer between 1 and 65535.' + ); + } + return port; +} + +export function isDesktopCdpSite(site: string): boolean { + return site in DESKTOP_CDP_SITES; +} + +export function isDesktopCdpCommand(cmd: CliCommand): boolean { + return cmd.browser === true && cmd.domain === 'localhost' && isDesktopCdpSite(cmd.site); +} + +export function loadConnections(): ConnectionsFile { + try { + const raw = fs.readFileSync(CONNECTIONS_PATH, 'utf-8'); + const parsed = JSON.parse(raw) as Partial; + return { + version: 1, + cdp: parsed.cdp ?? {}, + }; + } catch { + return { version: 1, cdp: {} }; + } +} + +function saveConnections(data: ConnectionsFile): void { + fs.mkdirSync(OPENCLI_DIR, { recursive: true }); + fs.writeFileSync(CONNECTIONS_PATH, JSON.stringify(data, null, 2) + '\n'); +} + +export function saveSiteConnection(site: string, endpoint: string): SavedConnection { + const data = loadConnections(); + const saved: SavedConnection = { + endpoint: normalizeEndpoint(endpoint), + updatedAt: new Date().toISOString(), + }; + data.cdp[site] = saved; + saveConnections(data); + return saved; +} + +export function removeSiteConnection(site: string): void { + const data = loadConnections(); + delete data.cdp[site]; + saveConnections(data); +} + +export function getSavedSiteConnection(site: string): SavedConnection | undefined { + return loadConnections().cdp[site]; +} + +export function defaultSiteEndpoint(site: string): string | undefined { + const meta = DESKTOP_CDP_SITES[site]; + if (!meta) return undefined; + return `http://127.0.0.1:${meta.defaultPort}`; +} + +export async function probeCdpEndpoint(endpoint: string, timeoutMs = 800): Promise { + const probeUrl = toProbeUrl(endpoint); + const transport = probeUrl.protocol === 'https:' ? https : http; + return new Promise((resolve) => { + const req = transport.get(probeUrl, (res) => { + res.resume(); + resolve((res.statusCode ?? 0) >= 200 && (res.statusCode ?? 0) < 300); + }); + req.on('error', () => resolve(false)); + req.setTimeout(timeoutMs, () => { + req.destroy(); + resolve(false); + }); + }); +} + +export async function resolveLiveSiteEndpoint(site: string): Promise { + const saved = getSavedSiteConnection(site)?.endpoint; + if (saved && await probeCdpEndpoint(saved)) return saved; + + const env = process.env.OPENCLI_CDP_ENDPOINT; + if (env && await probeCdpEndpoint(env)) return normalizeEndpoint(env); + + return undefined; +} + +export function buildDisconnectedStatusRow(cmd: CliCommand): Record { + const row: Record = {}; + const columns = cmd.columns ?? ['Status']; + + for (const column of columns) { + const lower = column.toLowerCase(); + if (lower === 'status') row[column] = 'Disconnected'; + else if (lower === 'detail') row[column] = `Run "opencli connect ${cmd.site}" after launching the app with remote debugging enabled.`; + else row[column] = ''; + } + + if (!columns.some((c) => c.toLowerCase() === 'status')) { + row.Status = 'Disconnected'; + } + + return row; +} diff --git a/src/main.ts b/src/main.ts index e6bcd38..05eb2ea 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,6 +16,18 @@ import { browserSession, DEFAULT_BROWSER_COMMAND_TIMEOUT, runWithTimeout } from import { PKG_VERSION } from './version.js'; import { getCompletions, printCompletionScript } from './completion.js'; import { CliError } from './errors.js'; +import { + DESKTOP_CDP_SITES, + buildDisconnectedStatusRow, + defaultSiteEndpoint, + isDesktopCdpCommand, + isDesktopCdpSite, + parseCdpPort, + probeCdpEndpoint, + removeSiteConnection, + resolveLiveSiteEndpoint, + saveSiteConnection, +} from './connections.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -158,6 +170,74 @@ program.command('completion') printCompletionScript(shell); }); +program.command('connect') + .description('Connect a desktop Electron app over CDP and save the endpoint for future commands') + .argument('', 'Desktop site name, e.g. chatwise or cursor') + .option('--port ', 'CDP port override') + .option('--endpoint ', 'CDP endpoint override, e.g. http://127.0.0.1:9228') + .action(async (site, opts) => { + try { + if (!isDesktopCdpSite(site)) { + throw new CliError( + 'CONNECT_UNSUPPORTED', + `Site "${site}" does not support managed CDP connections.`, + `Supported sites: ${Object.keys(DESKTOP_CDP_SITES).sort().join(', ')}` + ); + } + + const endpoint = opts.endpoint + ? String(opts.endpoint) + : `http://127.0.0.1:${opts.port ? parseCdpPort(String(opts.port)) : DESKTOP_CDP_SITES[site].defaultPort}`; + + const ok = await probeCdpEndpoint(endpoint); + if (!ok) { + const suggested = defaultSiteEndpoint(site); + throw new CliError( + 'CONNECT_FAILED', + `Could not connect to ${site} via CDP at ${endpoint}.`, + `Launch ${DESKTOP_CDP_SITES[site].appName} with remote debugging enabled${suggested ? ` (default ${suggested})` : ''} and retry.` + ); + } + + const saved = saveSiteConnection(site, endpoint); + console.log(`Connected ${site} -> ${saved.endpoint}`); + } catch (err: any) { + if (err instanceof CliError) { + console.error(chalk.red(`Error [${err.code}]: ${err.message}`)); + if (err.hint) console.error(chalk.yellow(`Hint: ${err.hint}`)); + } else { + console.error(chalk.red(`Error: ${err.message ?? err}`)); + } + process.exitCode = 1; + } + }); + +program.command('disconnect') + .description('Remove the saved CDP connection for a desktop Electron app') + .argument('', 'Desktop site name, e.g. chatwise or cursor') + .action((site) => { + try { + if (!isDesktopCdpSite(site)) { + throw new CliError( + 'DISCONNECT_UNSUPPORTED', + `Site "${site}" does not support managed CDP connections.`, + `Supported sites: ${Object.keys(DESKTOP_CDP_SITES).sort().join(', ')}` + ); + } + + removeSiteConnection(site); + console.log(`Disconnected ${site}.`); + } catch (err: any) { + if (err instanceof CliError) { + console.error(chalk.red(`Error [${err.code}]: ${err.message}`)); + if (err.hint) console.error(chalk.yellow(`Hint: ${err.hint}`)); + } else { + console.error(chalk.red(`Error: ${err.message ?? err}`)); + } + process.exitCode = 1; + } + }); + // ── Dynamic site commands ────────────────────────────────────────────────── const registry = getRegistry(); @@ -209,15 +289,35 @@ for (const [, cmd] of registry) { if (actionOpts.verbose) process.env.OPENCLI_VERBOSE = '1'; let result: any; if (cmd.browser) { - result = await browserSession(PlaywrightMCP, async (page) => { - // Cookie/header strategies require same-origin context for credentialed fetch. - // In CDP mode the active tab may be on an unrelated domain, causing CORS failures. - // Navigate to the command's domain first (mirrors cascade command behavior). - if ((cmd.strategy === Strategy.COOKIE || cmd.strategy === Strategy.HEADER) && cmd.domain) { - try { await page.goto(`https://${cmd.domain}`); await page.wait(2); } catch {} + if (isDesktopCdpCommand(cmd)) { + const cdpNotConnected = (hint: string): any => { + if (cmd.name === 'status') return [buildDisconnectedStatusRow(cmd)]; + throw new CliError('NOT_CONNECTED', `${cmd.site} is not connected.`, hint); + }; + const endpoint = await resolveLiveSiteEndpoint(cmd.site); + if (!endpoint) { + result = cdpNotConnected(`Launch the app with remote debugging enabled, then run "opencli connect ${cmd.site}".`); + } else { + try { + result = await browserSession(PlaywrightMCP, async (page) => { + return runWithTimeout(executeCommand(cmd, page, kwargs, actionOpts.verbose), { + timeout: cmd.timeoutSeconds ?? DEFAULT_BROWSER_COMMAND_TIMEOUT, + label: fullName(cmd), + }); + }, { cdpEndpoint: endpoint }); + } catch (err) { + if (actionOpts.verbose) console.error(chalk.yellow(`[Verbose] CDP session failed: ${err instanceof Error ? err.message : String(err)}`)); + result = cdpNotConnected(`The saved CDP endpoint could not be used. Relaunch the app with remote debugging enabled, then run "opencli connect ${cmd.site}" again.`); + } } - return runWithTimeout(executeCommand(cmd, page, kwargs, actionOpts.verbose), { timeout: cmd.timeoutSeconds ?? DEFAULT_BROWSER_COMMAND_TIMEOUT, label: fullName(cmd) }); - }); + } else { + result = await browserSession(PlaywrightMCP, async (page) => { + if ((cmd.strategy === Strategy.COOKIE || cmd.strategy === Strategy.HEADER) && cmd.domain) { + try { await page.goto(`https://${cmd.domain}`); await page.wait(2); } catch {} + } + return runWithTimeout(executeCommand(cmd, page, kwargs, actionOpts.verbose), { timeout: cmd.timeoutSeconds ?? DEFAULT_BROWSER_COMMAND_TIMEOUT, label: fullName(cmd) }); + }); + } } else { result = await executeCommand(cmd, null, kwargs, actionOpts.verbose); } if (actionOpts.verbose && (!result || (Array.isArray(result) && result.length === 0))) { console.error(chalk.yellow(`[Verbose] Warning: Command returned an empty result. If the website structural API changed or requires authentication, check the network or update the adapter.`)); diff --git a/src/runtime.ts b/src/runtime.ts index a9ae047..1b3b92e 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -34,17 +34,18 @@ export function withTimeoutMs(promise: Promise, timeoutMs: number, message /** Interface for browser factory (PlaywrightMCP or test mocks) */ export interface IBrowserFactory { - connect(opts?: { timeout?: number }): Promise; + connect(opts?: { timeout?: number; cdpEndpoint?: string }): Promise; close(): Promise; } export async function browserSession( BrowserFactory: new () => IBrowserFactory, fn: (page: IPage) => Promise, + connectOpts?: { cdpEndpoint?: string }, ): Promise { const mcp = new BrowserFactory(); try { - const page = await mcp.connect({ timeout: DEFAULT_BROWSER_CONNECT_TIMEOUT }); + const page = await mcp.connect({ timeout: DEFAULT_BROWSER_CONNECT_TIMEOUT, ...connectOpts }); return await fn(page); } finally { await mcp.close().catch(() => {});