Skip to content
Closed
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
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,18 +35,18 @@ Turn ANY Electron application into a CLI tool! Recombine, script, and extend app
## Highlights

- **CLI All Electron** — CLI-ify apps like Antigravity Ultra! Now AI can control itself natively using cc/openclaw!
- **No popup window** — Run `opencli login` once to save your cookies, then add `--headless` to any command — silent background browser, no Chrome window stealing focus.
- **Account-safe** — Reuses Chrome's logged-in state; your credentials never leave the browser.
- **AI Agent ready** — `explore` discovers APIs, `synthesize` generates adapters, `cascade` finds auth strategies.
- **Self-healing setup** — `opencli setup` auto-discovers tokens; `opencli doctor` diagnoses config across 10+ tools; `--fix` repairs them all.
- **Dynamic Loader** — Simply drop `.ts` or `.yaml` adapters into the `clis/` folder for auto-registration.
- **Dual-Engine Architecture** — Supports both YAML declarative data pipelines and robust browser runtime TypeScript injections.

## Prerequisites

- **Node.js**: >= 18.0.0
- **Chrome** running **and logged into the target site** (e.g. bilibili.com, zhihu.com, xiaohongshu.com).

> **⚠️ 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.
> **Tip**: Don't want Chrome popping up every time? Run `opencli login` once after setup — it saves your session cookies. After that, add `--headless` to any command and it runs silently in the background with your full login state.

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.
Expand Down Expand Up @@ -119,11 +119,27 @@ Then use directly:
opencli list # See all commands
opencli list -f yaml # List commands as YAML
opencli hackernews top --limit 5 # Public API, no browser
opencli bilibili hot --limit 5 # Browser command
opencli bilibili hot --limit 5 # Browser command (uses Chrome extension)
opencli zhihu hot -f json # JSON output
opencli zhihu hot -f yaml # YAML output
```

### No-popup mode (headless + cookies)

By default, browser commands briefly open a Chrome tab. To run silently in the background:

```bash
# One-time: save your Chrome session cookies to ~/.opencli/session.json
opencli login

# Now all browser commands run headlessly — no popup, full login state
opencli --headless bilibili me
opencli --headless bilibili hot --limit 10
opencli --headless zhihu hot -f json
```

> Re-run `opencli login` if your session expires (typically after a few months).

### Install from source (for developers)

```bash
Expand Down
21 changes: 19 additions & 2 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ CLI all electron!现在支持把所有 electron 应用 CLI 化,从而组合
## 亮点

- **CLI All Electron** — 支持把所有 electron 应用(如 Antigravity Ultra)CLI 化,让 AI 控制自己!
- **不抢窗口** — 运行一次 `opencli login` 保存 Cookie,之后所有命令加上 `--headless` 即可后台静默执行,Chrome 窗口不再弹出。
- **多站点覆盖** — B站、知乎、小红书、Twitter、Reddit 等 19 个站点,80+ 命令
- **零风控** — 复用 Chrome 登录态,无需存储任何凭证
- **自修复配置** — `opencli setup` 自动发现 Token;`opencli doctor` 诊断 10+ 工具配置;`--fix` 一键修复
Expand All @@ -47,7 +48,7 @@ CLI all electron!现在支持把所有 electron 应用 CLI 化,从而组合
- **Node.js**: >= 18.0.0
- **Chrome** 浏览器正在运行,且**已登录目标网站**(如 bilibili.com、zhihu.com、xiaohongshu.com)

> **⚠️ 重要**:大多数命令复用你的 Chrome 登录状态。运行命令前,你必须已在 Chrome 中打开目标网站并完成登录。如果获取到空数据或报错,请先检查你的浏览器登录状态
> **Tip**:不想每次都弹出 Chrome 窗口?配置完成后运行一次 `opencli login` 保存登录态,之后所有命令加 `--headless` 即可后台静默运行,Cookie 完整保留

OpenCLI 通过 Playwright MCP Bridge 扩展与你的浏览器通信。
它会优先复用本地或全局已安装的 `@playwright/mcp`,如果没有嗅探到可用 MCP server,则会自动回退到 `npx -y @playwright/mcp@latest` 启动。
Expand Down Expand Up @@ -120,11 +121,27 @@ opencli setup # 首次使用:配置 Playwright MCP token
opencli list # 查看所有命令
opencli list -f yaml # 以 YAML 列出所有命令
opencli hackernews top --limit 5 # 公共 API,无需浏览器
opencli bilibili hot --limit 5 # 浏览器命令
opencli bilibili hot --limit 5 # 浏览器命令(使用 Chrome 扩展)
opencli zhihu hot -f json # JSON 输出
opencli zhihu hot -f yaml # YAML 输出
```

### 后台静默模式(无弹窗 + 有 Cookie)

默认情况下,浏览器命令会短暂弹出一个 Chrome 标签页。如需后台静默运行:

```bash
# 一次性操作:将 Chrome 登录态(Cookie)保存到 ~/.opencli/session.json
opencli login

# 之后所有浏览器命令都可以后台运行,无弹窗,且保持完整登录态
opencli --headless bilibili me
opencli --headless bilibili hot --limit 10
opencli --headless zhihu hot -f json
```

> 登录态过期后(通常几个月)重新运行 `opencli login` 即可。

### 从源码安装(面向开发者)

```bash
Expand Down
80 changes: 56 additions & 24 deletions src/browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,19 +62,11 @@ describe('browser helpers', () => {
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',
]);
})).toEqual({ args: ['/tmp/cli.js', '--extension', '--executable-path', '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe'], headless: false });

expect(__test__.buildMcpArgs({
mcpPath: '/tmp/cli.js',
})).toEqual([
'/tmp/cli.js',
'--extension',
]);
})).toEqual({ args: ['/tmp/cli.js', '--extension'], headless: false });
} finally {
if (savedCI !== undefined) {
process.env.CI = savedCI;
Expand All @@ -86,29 +78,69 @@ describe('browser helpers', () => {

it('builds standalone MCP args in CI mode', () => {
const savedCI = process.env.CI;
const savedHeadless = process.env.OPENCLI_HEADLESS;
const savedUserDataDir = process.env.OPENCLI_USER_DATA_DIR;
process.env.CI = 'true';
delete process.env.OPENCLI_HEADLESS;
delete process.env.OPENCLI_USER_DATA_DIR;
try {
// CI mode: no --extension — browser launches in standalone headed mode
// CI mode: no --extension — browser launches in standalone headed mode with persistent user data
const defaultDataDir = __test__.defaultUserDataDir();
expect(__test__.buildMcpArgs({
mcpPath: '/tmp/cli.js',
})).toEqual([
'/tmp/cli.js',
]);
})).toEqual({ args: ['/tmp/cli.js', '--user-data-dir', defaultDataDir], headless: false });

expect(__test__.buildMcpArgs({
mcpPath: '/tmp/cli.js',
executablePath: '/usr/bin/chromium',
})).toEqual([
'/tmp/cli.js',
'--executable-path',
'/usr/bin/chromium',
]);
})).toEqual({ args: ['/tmp/cli.js', '--executable-path', '/usr/bin/chromium', '--user-data-dir', defaultDataDir], headless: false });
} finally {
if (savedCI !== undefined) {
process.env.CI = savedCI;
} else {
delete process.env.CI;
}
if (savedCI !== undefined) process.env.CI = savedCI;
else delete process.env.CI;
if (savedHeadless !== undefined) process.env.OPENCLI_HEADLESS = savedHeadless;
else delete process.env.OPENCLI_HEADLESS;
if (savedUserDataDir !== undefined) process.env.OPENCLI_USER_DATA_DIR = savedUserDataDir;
else delete process.env.OPENCLI_USER_DATA_DIR;
}
});

it('builds headless MCP args without session file', () => {
const savedCI = process.env.CI;
const savedHeadless = process.env.OPENCLI_HEADLESS;
delete process.env.CI;
delete process.env.OPENCLI_HEADLESS;
try {
// headless: no --extension, no --user-data-dir; sessionFile: null disables session lookup
expect(__test__.buildMcpArgs({
mcpPath: '/tmp/cli.js',
headless: true,
sessionFile: null,
})).toEqual({ args: ['/tmp/cli.js', '--headless'], headless: true });
} finally {
if (savedCI !== undefined) process.env.CI = savedCI;
else delete process.env.CI;
if (savedHeadless !== undefined) process.env.OPENCLI_HEADLESS = savedHeadless;
else delete process.env.OPENCLI_HEADLESS;
}
});

it('builds headless MCP args with session file and caps', () => {
const savedCI = process.env.CI;
const savedHeadless = process.env.OPENCLI_HEADLESS;
delete process.env.CI;
delete process.env.OPENCLI_HEADLESS;
try {
expect(__test__.buildMcpArgs({
mcpPath: '/tmp/cli.js',
headless: true,
sessionFile: '/tmp/session.json',
caps: ['storage'],
})).toEqual({ args: ['/tmp/cli.js', '--headless', '--isolated', '--storage-state', '/tmp/session.json', '--caps', 'storage'], headless: true });
} finally {
if (savedCI !== undefined) process.env.CI = savedCI;
else delete process.env.CI;
if (savedHeadless !== undefined) process.env.OPENCLI_HEADLESS = savedHeadless;
else delete process.env.OPENCLI_HEADLESS;
}
});

Expand Down
99 changes: 86 additions & 13 deletions src/browser/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,19 @@ export function findMcpServerPath(): string | null {
return _cachedMcpServerPath;
}

/** Default persistent browser data dir for CI standalone mode. */
export function defaultUserDataDir(): string {
return path.join(os.homedir(), '.opencli', 'browser-data');
}

/**
* Default path for saved browser session (cookies).
* Created by `opencli login`; loaded automatically in headless mode.
*/
export function defaultSessionFile(): string {
return path.join(os.homedir(), '.opencli', 'session.json');
}

/**
* Chrome 144+ auto-discovery: read DevToolsActivePort file to get CDP endpoint.
*
Expand Down Expand Up @@ -177,17 +190,44 @@ export function resolveCdpEndpoint(): { endpoint?: string; requestedCdp: boolean
return { requestedCdp: false };
}

function buildRuntimeArgs(input?: { executablePath?: string | null; cdpEndpoint?: string }): string[] {
function buildRuntimeArgs(input?: {
executablePath?: string | null;
cdpEndpoint?: string;
headless?: boolean;
/** Extra browser capabilities to enable (e.g. ['storage'] for browser_storage_state). */
caps?: string[];
/** @internal override for tests only — null disables user-data-dir */
userDataDir?: string | null;
/** @internal override for tests only — null disables session file lookup */
sessionFile?: string | null;
}): { args: string[]; headless: boolean } {
const args: string[] = [];
const headless = input?.headless || !!process.env.OPENCLI_HEADLESS;

// Priority 1: CDP endpoint (remote Chrome debugging or local Auto-Discovery)
if (input?.cdpEndpoint) {
if (headless) {
// Headless mode: standalone browser, no extension needed
args.push('--headless');
// Load saved session (cookies) if available — enables "no popup + has cookies" mode.
// Run `opencli login` once to save your browser session.
let sessionFile: string | null;
if (input?.sessionFile !== undefined) {
// Explicit override (test-only): trust the value as-is (null = disable)
sessionFile = input.sessionFile ?? null;
} else {
const candidate = process.env.OPENCLI_SESSION_FILE ?? defaultSessionFile();
sessionFile = candidate && fs.existsSync(candidate) ? candidate : null;
}
if (sessionFile) {
// --storage-state requires --isolated (uses browser.newContext() which supports storageState,
// vs the default launchPersistentContext() which does not).
args.push('--isolated', '--storage-state', sessionFile);
}
} else if (input?.cdpEndpoint) {
// CDP endpoint (remote Chrome debugging or local Auto-Discovery)
args.push('--cdp-endpoint', input.cdpEndpoint);
return args;
}

// Priority 2: Extension mode (local Chrome with MCP Bridge extension)
if (!process.env.CI) {
return { args, headless: false };
} else if (!process.env.CI) {
// Local: connect to user's running Chrome via MCP Bridge extension
args.push('--extension');
}

Expand All @@ -196,30 +236,63 @@ function buildRuntimeArgs(input?: { executablePath?: string | null; cdpEndpoint?
if (input?.executablePath) {
args.push('--executable-path', input.executablePath);
}
return args;
// Persist browser profile in CI standalone mode (skip for extension and headless modes).
// OPENCLI_USER_DATA_DIR env var overrides the default; userDataDir param is test-only.
const userDataDir = input?.userDataDir ?? process.env.OPENCLI_USER_DATA_DIR ?? (process.env.CI ? defaultUserDataDir() : null);
if (userDataDir) {
args.push('--user-data-dir', userDataDir);
}
// Enable additional browser capabilities (e.g. 'storage' for browser_storage_state tool).
if (input?.caps?.length) {
args.push('--caps', input.caps.join(','));
}
return { args, headless };
}

export function buildMcpArgs(input: { mcpPath: string; executablePath?: string | null; cdpEndpoint?: string }): string[] {
return [input.mcpPath, ...buildRuntimeArgs(input)];
export function buildMcpArgs(input: {
mcpPath: string;
executablePath?: string | null;
cdpEndpoint?: string;
headless?: boolean;
/** Extra browser capabilities to enable (e.g. ['storage'] for browser_storage_state). */
caps?: string[];
/** @internal override for tests only — null disables user data dir */
userDataDir?: string | null;
/** @internal override for tests only — null disables session file lookup */
sessionFile?: string | null;
}): { args: string[]; headless: boolean } {
const { args: runtimeArgs, headless } = buildRuntimeArgs(input);
return { args: [input.mcpPath, ...runtimeArgs], headless };
}

export function buildMcpLaunchSpec(input: { mcpPath?: string | null; executablePath?: string | null; cdpEndpoint?: string }): {
export function buildMcpLaunchSpec(input: {
mcpPath?: string | null;
executablePath?: string | null;
cdpEndpoint?: string;
headless?: boolean;
caps?: string[];
userDataDir?: string | null;
sessionFile?: string | null;
}): {
command: string;
args: string[];
usedNpxFallback: boolean;
headless: boolean;
} {
const runtimeArgs = buildRuntimeArgs(input);
const { args: runtimeArgs, headless } = buildRuntimeArgs(input);
if (input.mcpPath) {
return {
command: 'node',
args: [input.mcpPath, ...runtimeArgs],
usedNpxFallback: false,
headless,
};
}

return {
command: 'npx',
args: ['-y', '@playwright/mcp@latest', ...runtimeArgs],
usedNpxFallback: true,
headless,
};
}
4 changes: 3 additions & 1 deletion src/browser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export { resolveCdpEndpoint } from './discover.js';
// Test-only helpers — exposed for unit tests
import { createJsonRpcRequest } from './mcp.js';
import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
import { buildMcpArgs, buildMcpLaunchSpec, findMcpServerPath, resetMcpServerPathCache, setMcpDiscoveryTestHooks } from './discover.js';
import { buildMcpArgs, buildMcpLaunchSpec, findMcpServerPath, resetMcpServerPathCache, setMcpDiscoveryTestHooks, defaultUserDataDir, defaultSessionFile } from './discover.js';
import { withTimeoutMs } from '../runtime.js';

export const __test__ = {
Expand All @@ -27,5 +27,7 @@ export const __test__ = {
findMcpServerPath,
resetMcpServerPathCache,
setMcpDiscoveryTestHooks,
defaultUserDataDir,
defaultSessionFile,
withTimeoutMs,
};
11 changes: 8 additions & 3 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; headless?: boolean; caps?: 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 @@ -110,6 +110,7 @@ export class PlaywrightMCP {
PlaywrightMCP._activeInsts.add(this);
this._state = 'connecting';
const timeout = opts.timeout ?? DEFAULT_BROWSER_CONNECT_TIMEOUT;
const headless = opts.headless;

return new Promise<Page>((resolve, reject) => {
const isDebug = process.env.DEBUG?.includes('opencli:mcp');
Expand Down Expand Up @@ -159,10 +160,14 @@ export class PlaywrightMCP {
mcpPath,
executablePath: process.env.OPENCLI_BROWSER_EXECUTABLE_PATH,
cdpEndpoint,
headless,
caps: opts.caps,
});
const isHeadless = launchSpec.headless;
if (process.env.OPENCLI_VERBOSE) {
console.error(`[opencli] Mode: ${requestedCdp ? 'CDP' : useExtension ? 'extension' : 'standalone'}`);
if (useExtension) console.error(`[opencli] Extension token: fingerprint ${tokenFingerprint}`);
console.error(`[opencli] Mode: ${isHeadless ? 'headless' : requestedCdp ? 'CDP' : useExtension ? 'extension' : 'standalone'}`);
// Extension token is irrelevant in headless mode (--extension is not passed)
if (useExtension && !isHeadless) console.error(`[opencli] Extension token: fingerprint ${tokenFingerprint}`);
if (launchSpec.usedNpxFallback) {
console.error('[opencli] Playwright MCP not found locally; bootstrapping via npx @playwright/mcp@latest');
}
Expand Down
Loading