Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ dist/
.opencli/
.mcp.json
*.log
.idea/
6 changes: 4 additions & 2 deletions src/browser/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export class PlaywrightMCP {
}
}

async connect(opts: { timeout?: number } = {}): Promise<IPage> {
async connect(opts: { timeout?: number; cdpEndpoint?: string } = {}): Promise<IPage> {
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');
Expand All @@ -114,7 +114,9 @@ export class PlaywrightMCP {
return new Promise<Page>((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);
Expand Down
4 changes: 2 additions & 2 deletions src/clis/antigravity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/clis/antigravity/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
\`\`\`

## 全部指令一览
Expand Down
5 changes: 2 additions & 3 deletions src/clis/chatwise/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 2 additions & 3 deletions src/clis/chatwise/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

## 命令
Expand Down
4 changes: 2 additions & 2 deletions src/clis/codex/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/clis/codex/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@

## 配置指南

在你要运行命令的终端里导出环境变量
让 OpenCLI 连接到这个桌面应用
```bash
export OPENCLI_CODEX_CDP_ENDPOINT="http://127.0.0.1:9222"
opencli connect codex
```

## 核心指令
Expand Down
2 changes: 1 addition & 1 deletion src/clis/cursor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/clis/cursor/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
## 配置

```bash
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9226"
opencli connect cursor
```

## 命令
Expand Down
2 changes: 1 addition & 1 deletion src/clis/discord-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/clis/discord-app/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
## 配置

```bash
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9232"
opencli connect discord-app
```

## 命令
Expand Down
2 changes: 1 addition & 1 deletion src/clis/notion/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/clis/notion/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
## 配置

```bash
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9230"
opencli connect notion
```

## 命令
Expand Down
143 changes: 143 additions & 0 deletions src/connections.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('node:os')>('node:os');
return {
...actual,
homedir: () => TEST_HOME,
};
});

vi.mock('node:http', async () => {
const actual = await vi.importActual<typeof import('node:http')>('node:http');
return {
...actual,
get: httpGetMock,
};
});

vi.mock('node:https', async () => {
const actual = await vi.importActual<typeof import('node:https')>('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<string, number>) {
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');
});

});
Loading