Compare commits

...

14 Commits

Author SHA1 Message Date
jackwener 1f998e4ff0 feat(browser): migrate to self-hosted extension and remove token configuration 2026-03-18 11:49:15 +08:00
jackwener 36b06e9f32 test: cover global mcp discovery paths 2026-03-17 22:27:48 +08:00
Sheng-Yan, Zhang f0273f94bb fix: discover global @playwright/mcp in nvm/npm installs 2026-03-17 19:05:45 +08:00
jackwener 4c8c6e8be7 fix(twitter): migrate bookmarks to direct GraphQL
Release / release (push) Has been cancelled
chore: bump version to 0.7.10
2026-03-17 17:30:01 +08:00
jackwener 7b5bdfa7d5 fix(twitter): harden remaining twitter commands
Co-authored-by: Sheng-Yan, Zhang <yancode@qq.com>
2026-03-17 17:26:36 +08:00
jackwener 546c0b997a feat: Enhance setup output with token save confirmation and improved browser connectivity guidance. 2026-03-17 17:20:16 +08:00
jackwener 1e34e7e6d3 chore: bump version to 0.7.9 2026-03-17 17:11:13 +08:00
jackwener 9ae9eb3fc6 fix(twitter): rewrite timeline adapter to use direct GraphQL API
The previous implementation injected a fetch interceptor after page
navigation, but by that time the HomeTimeline API call had already
completed, resulting in 'no data captured' every time.

Rewrote to directly call Twitter's HomeTimeline GraphQL endpoint
(same pattern as profile.ts and thread.ts):
- Dynamic queryId resolution with hardcoded fallback
- Pagination support with cursor
- Filters out promoted content
- Returns structured tweet data (id, author, text, likes, retweets,
  replies, views, created_at, url)

Fixes #36
2026-03-17 17:11:02 +08:00
jackwener 612c0ab1af v0.7.8: P0 architecture refactor - split browser.ts, unified errors, strict mode
Release / release (push) Has been cancelled
2026-03-17 17:02:03 +08:00
jackwener 68840fc85c refactor: P0 architecture improvements
- Split browser.ts (700 lines) into src/browser/ module (page, mcp, errors, discover, tabs, index)
- Add unified error handling: CliError base class + logger module
- Enable TypeScript strict mode, fix 12 type errors
- Extract inline build scripts to scripts/clean-yaml.cjs and copy-yaml.cjs
- All 178 unit tests pass, build produces 83 entries across 19 sites
2026-03-17 17:01:51 +08:00
jackwener 8263a06a85 fix(completion): insert fpath before compinit in .zshrc
The postinstall script was appending the fpath line at the end of .zshrc,
but compinit (called earlier by oh-my-zsh or directly) would have already
finished scanning. This caused zsh completion to silently fail for most
users.

Now the script detects the first compinit / oh-my-zsh source line and
inserts the fpath entry before it, ensuring completion works immediately.
2026-03-17 16:53:07 +08:00
jackwener eb2c3fdf89 fix: support Chrome Dev and Chrome Beta browser variants
Add Chrome Dev and Chrome Beta profile paths to discoverExtensionToken()
and checkExtensionInstalled() across macOS, Linux, and Windows.

Closes #30
2026-03-17 16:36:19 +08:00
jackwener 43ed0ace59 0.7.6
Release / release (push) Has been cancelled
2026-03-17 16:35:20 +08:00
jackwener de962eb5fb feat: support commands completion (#32)
Add full shell tab-completion for opencli, supporting Bash, Zsh, and Fish.

Co-authored-by: RinChanNOWWW <rin_chan_now@outlook.com>

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-17 16:33:19 +08:00
40 changed files with 2255 additions and 1481 deletions
@@ -0,0 +1,249 @@
---
name: cross-project-adapter-migration
description: "Cross-project CLI command migration workflow for opencli. Use when importing commands from external CLI projects (python/node) like rdt-cli, twitter-cli, etc. Covers: source analysis → gap matrix → batch migration → README/SKILL.md update."
---
# Cross-Project Adapter Migration
> 从外部 CLI 项目(Python/Node/Go 等)批量迁移命令到 opencli 的标准化流程。
## When to Use
- 用户说"把 xxx-cli 的命令迁移过来"
- 用户说"看看 xxx 项目有什么可以借鉴的"
- 用户说"对齐 xxx-cli 的功能"
- 在为新平台扩展 opencli 时,发现已有第三方 CLI 工具
## Prerequisites
- 熟悉 [CLI-EXPLORER.md](file:///Users/jakevin/code/opencli/CLI-EXPLORER.md)adapter 开发决策树)
- 熟悉 [SKILL.md](file:///Users/jakevin/code/opencli/SKILL.md)(命令参考 & 模板)
---
## Phase 1: 源项目分析
### 1.1 克隆 & 理解源项目
```bash
# 克隆源项目到 /tmp 做分析
git clone <source_repo_url> /tmp/<source-cli>
```
分析重点:
- **命令列表**:找到所有可用命令(查看 CLI 入口文件、help 输出或 README
- **认证方式**CookieAPI KeyOAuth?浏览器自动化?
- **数据源**:公开 APIGraphQL?页面抓取?
- **输出字段**:每个命令返回哪些数据字段
### 1.2 生成命令清单
列出源项目所有命令,包括:
| 命令 | 类型 | API/方法 | 输出字段 |
|------|------|---------|---------|
| `xxx feed` | Read | `GET /api/feed` | title, author, time |
| `xxx post` | Write | `POST /api/tweet` | status, id |
---
## Phase 2: 功能对比矩阵
### 2.1 查看 opencli 现有命令
```bash
ls src/clis/<site>/ # 查看已有适配器
opencli list | grep <site> # 确认已注册命令
```
### 2.2 生成对比矩阵
对每个源项目命令,标注三种状态:
| 功能 | 源项目 | opencli 现有 | 行动 |
|------|--------|-------------|------|
| feed | ✅ `xxx feed` | ❌ 无 | ✅ **新增** |
| search | ✅ `xxx search` | ✅ `search.ts` | ❌ 已有,跳过 |
| hot | ✅ `xxx hot` | ⚠️ `hot.yaml`(不完整) | ✅ **增强** |
| like | ✅ `xxx like` | ✅ `like.ts` | ❌ 已有,跳过 |
### 2.3 筛选迁移目标
去掉已有的、低价值的,保留高价值缺失命令,按 Read/Write 分类:
**筛选原则**
- ✅ 高使用频率的命令优先
- ✅ 已有但不完整的命令标记为"增强"
- ❌ 源项目特有但 opencli 架构不支持的功能(如需要持久化存储的)跳过
- ❌ 与现有功能完全重复的跳过
---
## Phase 3: 批量实现
> [!IMPORTANT]
> 实现前必须查阅 [CLI-EXPLORER.md](file:///Users/jakevin/code/opencli/CLI-EXPLORER.md) 确认策略选择。
### 3.1 选择实现方式
基于决策树分类:
| 类别 | 方式 | 适用条件 |
|------|------|---------|
| **Read + 简单 API** | YAML pipeline | 纯 fetch/select/map,无复杂 JS |
| **Read + GraphQL/分页/签名** | TypeScript adapter | 需要 JS 逻辑 |
| **Write 操作** | TypeScript + `Strategy.UI` | 点击/输入等 DOM 操作 |
| **Write + API** | TypeScript + `Strategy.COOKIE/HEADER` | 直接 POST API |
### 3.2 实现顺序
**先 Read 后 Write,先 YAML 后 TS**
1. **Phase A**: YAML Read 适配器(最快,通常每个 10-20 行)
2. **Phase B**: TS Read 适配器(需要 evaluate/intercept 的)
3. **Phase C**: TS Write 适配器(需 UI 自动化或 POST API
### 3.3 实现模板
#### YAML Read 适配器模板(Cookie 策略)
```yaml
site: <site>
name: <command>
description: <描述>
domain: www.<site>.com
strategy: cookie
browser: true
args:
limit:
type: int
default: 20
pipeline:
- navigate: https://www.<site>.com
- evaluate: |
(async () => {
const res = await fetch('<api_endpoint>', { credentials: 'include' });
const d = await res.json();
return (d.data?.items || []).map(item => ({
title: item.title,
// ... map source fields
}));
})()
- map:
rank: ${{ index + 1 }}
title: ${{ item.title }}
- limit: ${{ args.limit }}
columns: [rank, title]
```
#### TS Write 适配器模板(UI 策略)
```typescript
import { cli, Strategy } from '../../registry.js';
cli({
site: '<site>',
name: '<command>',
description: '<描述>',
strategy: Strategy.UI,
args: [{ name: 'target', required: true, help: '<参数说明>' }],
columns: ['status', 'message'],
func: async (page, kwargs) => {
await page.goto(`https://www.<site>.com/${kwargs.target}`);
await page.wait({ text: '<expected_text>', timeout: 10 });
// 获取 snapshot 找到目标按钮
const snapshot = await page.accessibility.snapshot();
// 点击按钮 ...
return [{ status: 'success', message: '<action> completed' }];
},
});
```
### 3.4 公共模式复用
迁移过程中如果发现多个适配器共享逻辑,考虑提取到 `src/<site>.ts` 工具文件:
```typescript
// src/<site>.ts
export async function fetchWithAuth(page, url) { ... }
export function parseItem(raw) { ... }
```
---
## Phase 4: 验证 & 发布
### 4.1 构建验证
```bash
npx tsc --noEmit # TypeScript 编译检查
opencli list | grep <site> # 确认所有命令已注册
```
### 4.2 运行验证(关键!)
每个新命令必须实际运行:
```bash
# Read 命令
opencli <site> <command> --limit 3 -f json
opencli <site> <command> --limit 3 -v # verbose 查看 pipeline
# Write 命令(谨慎!会实际操作)
opencli <site> <command> <test_target>
```
### 4.3 更新文档
迁移完成后必须更新以下文件:
1. **README.md** — 在对应平台区域添加新命令示例
2. **SKILL.md** — 在 Commands Reference 中添加新命令
### 4.4 提交 & 推送
```bash
git add -A
git commit -m "feat(<site>): migrate <N> commands from <source-cli>
- Phase A: <N> YAML adapters (read operations)
- Phase B: <N> TS adapters (write operations)
- Source: <source_repo_url>"
git push
```
---
## Checklist
- [ ] 源项目命令清单已生成
- [ ] 对比矩阵已确认,高价值缺失命令已筛选
- [ ] 用户确认迁移范围
- [ ] Phase A: YAML Read 适配器已完成
- [ ] Phase B: TS Read 适配器已完成
- [ ] Phase C: TS Write 适配器已完成
- [ ] `npx tsc --noEmit` 编译通过
- [ ] 所有新命令已实际运行验证
- [ ] README.md 已更新
- [ ] SKILL.md 已更新
- [ ] 已 commit + push
## 实战案例参考
### rdt-cli → opencli Reddit2026-03-16
- **源项目**: `rdt-cli`25 个 Python 命令)
- **筛选结果**: 13 个高价值命令
- **实现**: 7 个 YAMLread + 6 个 TSwrite
- **产出**: +11 文件,+767 行代码,Reddit 适配器从 4 → 15+275%
### twitter-cli → opencli Twitter2026-03-16
- **源项目**: `twitter-cli`20+ Python 命令)
- **筛选结果**: 11 个待实现
- **策略**: Read 用 `Strategy.COOKIE` + GraphQL fetchWrite 用 `Strategy.UI`
@@ -0,0 +1,54 @@
---
description: Migrate commands from an external CLI project into opencli adapters
---
// turbo-all
## Steps
1. Clone the source CLI project for analysis:
```bash
git clone <source_repo_url> /tmp/<source-cli>
```
2. Analyze source project: list all commands, auth method, API endpoints, and output fields.
3. Check existing opencli adapters for the target site:
```bash
ls src/clis/<site>/
opencli list | grep <site>
```
4. Generate a comparison matrix table (source commands vs opencli existing). Mark each as: ✅ **New** / ✅ **Enhance** / ❌ **Skip**. Ask user to confirm which commands to migrate.
5. Implement YAML Read adapters first (highest ROI, 10-20 lines each). Place files in `src/clis/<site>/<name>.yaml`.
6. Implement TS Read adapters for complex cases (GraphQL, pagination, signing). Place files in `src/clis/<site>/<name>.ts`.
7. Implement TS Write adapters using `Strategy.UI` or `Strategy.COOKIE`. Place files in `src/clis/<site>/<name>.ts`.
8. Verify build:
```bash
npx tsc --noEmit
```
9. Verify all commands are registered:
```bash
opencli list | grep <site>
```
10. Run each new command to verify it works:
```bash
opencli <site> <command> --limit 3 -f json
```
11. Update README.md with new command examples in the appropriate platform section.
12. Update SKILL.md Commands Reference with new commands.
13. Commit and push:
```bash
git add -A
git commit -m "feat(<site>): migrate <N> commands from <source-cli>"
git push
```
+25 -68
View File
@@ -1,13 +1,14 @@
{
"name": "@jackwener/opencli",
"version": "0.7.5",
"version": "0.7.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@jackwener/opencli",
"version": "0.7.5",
"license": "BSD-3-Clause",
"version": "0.7.10",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"chalk": "^5.3.0",
"cli-table3": "^0.6.5",
@@ -18,9 +19,9 @@
"opencli": "dist/main.js"
},
"devDependencies": {
"@playwright/mcp": "^0.0.68",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.13.10",
"opencli-mcp": "file:../opencli-mcp/packages/playwright-mcp",
"tsx": "^4.19.3",
"typescript": "^5.8.2",
"vitest": "^4.1.0"
@@ -29,6 +30,22 @@
"node": ">=18.0.0"
}
},
"../opencli-mcp/packages/playwright-mcp": {
"name": "opencli-mcp",
"version": "0.0.68",
"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/@colors/colors": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
@@ -559,23 +576,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",
@@ -1543,6 +1543,10 @@
],
"license": "MIT"
},
"node_modules/opencli-mcp": {
"resolved": "../opencli-mcp/packages/playwright-mcp",
"link": true
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -1571,53 +1575,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",
+5 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@jackwener/opencli",
"version": "0.7.5",
"version": "0.7.10",
"publishConfig": {
"access": "public"
},
@@ -17,9 +17,10 @@
"dev": "tsx src/main.ts",
"build": "tsc && npm run clean-yaml && npm run copy-yaml && npm run build-manifest",
"build-manifest": "node dist/build-manifest.js",
"clean-yaml": "node -e \"const{readdirSync:r,rmSync:d,existsSync:e,statSync:s}=require('fs'),p=require('path');function w(dir){if(!e(dir))return;for(const f of r(dir)){const fp=p.join(dir,f);s(fp).isDirectory()?w(fp):/\\.ya?ml$/.test(f)&&d(fp)}}w('dist/clis')\"",
"copy-yaml": "node -e \"const{readdirSync:r,copyFileSync:c,mkdirSync:m,existsSync:e,statSync:s}=require('fs'),p=require('path');function w(src,dst){if(!e(src))return;for(const f of r(src)){const sp=p.join(src,f),dp=p.join(dst,f);s(sp).isDirectory()?w(sp,dp):/\\.ya?ml$/.test(f)&&(m(p.dirname(dp),{recursive:!0}),c(sp,dp))}}w('src/clis','dist/clis')\"",
"clean-yaml": "node scripts/clean-yaml.cjs",
"copy-yaml": "node scripts/copy-yaml.cjs",
"start": "node dist/main.js",
"postinstall": "node scripts/postinstall.js || true",
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit",
"prepublishOnly": "npm run build",
@@ -46,9 +47,9 @@
"js-yaml": "^4.1.0"
},
"devDependencies": {
"@playwright/mcp": "^0.0.68",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.13.10",
"opencli-mcp": "file:../opencli-mcp/packages/playwright-mcp",
"tsx": "^4.19.3",
"typescript": "^5.8.2",
"vitest": "^4.1.0"
+19
View File
@@ -0,0 +1,19 @@
/**
* Clean YAML files from dist/clis/ before copying fresh ones.
*/
const { readdirSync, rmSync, existsSync, statSync } = require('fs');
const path = require('path');
function walk(dir) {
if (!existsSync(dir)) return;
for (const f of readdirSync(dir)) {
const fp = path.join(dir, f);
if (statSync(fp).isDirectory()) {
walk(fp);
} else if (/\.ya?ml$/.test(f)) {
rmSync(fp);
}
}
}
walk('dist/clis');
+21
View File
@@ -0,0 +1,21 @@
/**
* Copy YAML files from src/clis/ to dist/clis/.
*/
const { readdirSync, copyFileSync, mkdirSync, existsSync, statSync } = require('fs');
const path = require('path');
function walk(src, dst) {
if (!existsSync(src)) return;
for (const f of readdirSync(src)) {
const sp = path.join(src, f);
const dp = path.join(dst, f);
if (statSync(sp).isDirectory()) {
walk(sp, dp);
} else if (/\.ya?ml$/.test(f)) {
mkdirSync(path.dirname(dp), { recursive: true });
copyFileSync(sp, dp);
}
}
}
walk('src/clis', 'dist/clis');
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env node
/**
* postinstall script — automatically install shell completion files.
*
* Detects the user's default shell and writes the completion script to the
* standard system completion directory so that tab-completion works immediately
* after `npm install -g`.
*
* Supported shells: bash, zsh, fish.
*
* This script is intentionally plain Node.js (no TypeScript, no imports from
* the main source tree) so that it can run without a build step.
*/
import { mkdirSync, writeFileSync, existsSync, readFileSync, appendFileSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
// ── Completion script content ──────────────────────────────────────────────
const BASH_COMPLETION = `# Bash completion for opencli (auto-installed)
_opencli_completions() {
local cur words cword
_get_comp_words_by_ref -n : cur words cword
local completions
completions=$(opencli --get-completions --cursor "$cword" "\${words[@]:1}" 2>/dev/null)
COMPREPLY=( $(compgen -W "$completions" -- "$cur") )
__ltrim_colon_completions "$cur"
}
complete -F _opencli_completions opencli
`;
const ZSH_COMPLETION = `#compdef opencli
# Zsh completion for opencli (auto-installed)
_opencli() {
local -a completions
local cword=$((CURRENT - 1))
completions=(\${(f)"$(opencli --get-completions --cursor "$cword" "\${words[@]:1}" 2>/dev/null)"})
compadd -a completions
}
_opencli
`;
const FISH_COMPLETION = `# Fish completion for opencli (auto-installed)
complete -c opencli -f -a '(
set -l tokens (commandline -cop)
set -l cursor (count (commandline -cop))
opencli --get-completions --cursor $cursor $tokens[2..] 2>/dev/null
)'
`;
// ── Helpers ────────────────────────────────────────────────────────────────
function detectShell() {
const shell = process.env.SHELL || '';
if (shell.includes('zsh')) return 'zsh';
if (shell.includes('bash')) return 'bash';
if (shell.includes('fish')) return 'fish';
return null;
}
function ensureDir(dir) {
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
}
/**
* Ensure fpath contains the custom completions directory in .zshrc.
*
* Key detail: the fpath line MUST appear BEFORE the first `compinit` call,
* otherwise compinit won't scan our completions directory. This is critical
* for oh-my-zsh users (source $ZSH/oh-my-zsh.sh calls compinit internally).
*/
function ensureZshFpath(completionsDir, zshrcPath) {
const fpathLine = `fpath=(${completionsDir} $fpath)`;
const autoloadLine = `autoload -Uz compinit && compinit`;
const marker = '# opencli completion';
if (!existsSync(zshrcPath)) {
writeFileSync(zshrcPath, `${marker}\n${fpathLine}\n${autoloadLine}\n`, 'utf8');
return;
}
const content = readFileSync(zshrcPath, 'utf8');
// Already configured — nothing to do
if (content.includes(completionsDir)) {
return;
}
// Find the first line that triggers compinit (direct call or oh-my-zsh source)
const lines = content.split('\n');
let insertIdx = -1;
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
// Skip comment-only lines
if (trimmed.startsWith('#')) continue;
if (/compinit/.test(trimmed) || /source\s+.*oh-my-zsh\.sh/.test(trimmed)) {
insertIdx = i;
break;
}
}
if (insertIdx !== -1) {
// Insert fpath BEFORE the compinit / oh-my-zsh source line
lines.splice(insertIdx, 0, marker, fpathLine);
writeFileSync(zshrcPath, lines.join('\n'), 'utf8');
} else {
// No compinit found — append fpath + compinit at the end
let addition = `\n${marker}\n${fpathLine}\n${autoloadLine}\n`;
appendFileSync(zshrcPath, addition, 'utf8');
}
}
// ── Main ───────────────────────────────────────────────────────────────────
function main() {
// Skip in CI environments
if (process.env.CI || process.env.CONTINUOUS_INTEGRATION) {
return;
}
// Only install completion for global installs and npm link
const isGlobal = process.env.npm_config_global === 'true';
if (!isGlobal) {
return;
}
const shell = detectShell();
if (!shell) {
// Cannot determine shell; silently skip
return;
}
const home = homedir();
try {
switch (shell) {
case 'zsh': {
const completionsDir = join(home, '.zsh', 'completions');
const completionFile = join(completionsDir, '_opencli');
ensureDir(completionsDir);
writeFileSync(completionFile, ZSH_COMPLETION, 'utf8');
// Ensure fpath is set up in .zshrc
const zshrcPath = join(home, '.zshrc');
ensureZshFpath(completionsDir, zshrcPath);
console.log(`✓ Zsh completion installed to ${completionFile}`);
console.log(` Restart your shell or run: source ~/.zshrc`);
break;
}
case 'bash': {
// Try system-level first, fall back to user-level
const userCompDir = join(home, '.bash_completion.d');
const completionFile = join(userCompDir, 'opencli');
ensureDir(userCompDir);
writeFileSync(completionFile, BASH_COMPLETION, 'utf8');
// Ensure .bashrc sources the completion directory
const bashrcPath = join(home, '.bashrc');
if (existsSync(bashrcPath)) {
const content = readFileSync(bashrcPath, 'utf8');
if (!content.includes('.bash_completion.d/opencli')) {
appendFileSync(bashrcPath,
`\n# opencli completion\n[ -f "${completionFile}" ] && source "${completionFile}"\n`,
'utf8'
);
}
}
console.log(`✓ Bash completion installed to ${completionFile}`);
console.log(` Restart your shell or run: source ~/.bashrc`);
break;
}
case 'fish': {
const completionsDir = join(home, '.config', 'fish', 'completions');
const completionFile = join(completionsDir, 'opencli.fish');
ensureDir(completionsDir);
writeFileSync(completionFile, FISH_COMPLETION, 'utf8');
console.log(`✓ Fish completion installed to ${completionFile}`);
console.log(` Restart your shell to activate.`);
break;
}
}
} catch (err) {
// Completion install is best-effort; never fail the package install
if (process.env.OPENCLI_VERBOSE) {
console.error(`Warning: Could not install shell completion: ${err.message}`);
}
}
}
main();
+1 -1
View File
@@ -56,7 +56,7 @@ export async function wbiSign(
const mixinKey = getMixinKey(imgKey, subKey);
const wts = Math.floor(Date.now() / 1000);
const sorted: Record<string, string> = {};
const allParams = { ...params, wts: String(wts) };
const allParams: Record<string, any> = { ...params, wts: String(wts) };
for (const key of Object.keys(allParams).sort()) {
sorted[key] = String(allParams[key]).replace(/[!'()*]/g, '');
}
+100 -2
View File
@@ -1,5 +1,11 @@
import { describe, it, expect } from 'vitest';
import { PlaywrightMCP, __test__ } from './browser.js';
import { afterEach, describe, it, expect, vi } from 'vitest';
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', () => {
@@ -109,6 +115,98 @@ describe('browser helpers', () => {
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 opencli-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/opencli-mcp/packages/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/opencli-mcp/packages/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/opencli-mcp/packages/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,
});
}
});
});
describe('PlaywrightMCP state', () => {
-698
View File
@@ -1,698 +0,0 @@
/**
* Browser interaction via Playwright MCP Bridge extension.
* Connects to an existing Chrome browser through the extension.
*/
import { spawn, execSync, type ChildProcess } from 'node:child_process';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { formatSnapshot } from './snapshotFormatter.js';
import { PKG_VERSION } from './version.js';
import { normalizeEvaluateSource } from './pipeline/template.js';
import { generateInterceptorJs, generateReadInterceptedJs } from './interceptor.js';
import { withTimeoutMs, DEFAULT_BROWSER_CONNECT_TIMEOUT } from './runtime.js';
const STDERR_BUFFER_LIMIT = 16 * 1024;
const INITIAL_TABS_TIMEOUT_MS = 1500;
const TAB_CLEANUP_TIMEOUT_MS = 2000;
let _cachedMcpServerPath: string | null | undefined;
type ConnectFailureKind = 'missing-token' | 'extension-timeout' | 'extension-not-installed' | 'mcp-init' | 'process-exit' | 'unknown';
type PlaywrightMCPState = 'idle' | 'connecting' | 'connected' | 'closing' | 'closed';
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 === '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');
}
function inferConnectFailureKind(args: {
hasExtensionToken: boolean;
stderr: string;
rawMessage?: string;
exited?: boolean;
}): ConnectFailureKind {
const haystack = `${args.rawMessage ?? ''}\n${args.stderr}`.toLowerCase();
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';
}
// JSON-RPC helpers
let _nextId = 1;
function createJsonRpcRequest(method: string, params: Record<string, any> = {}): { id: number; message: string } {
const id = _nextId++;
return {
id,
message: JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n',
};
}
import type { IPage } from './types.js';
/**
* Page abstraction wrapping JSON-RPC calls to Playwright MCP.
*/
export class Page implements IPage {
constructor(private _request: (method: string, params?: Record<string, any>) => Promise<any>) {}
async call(method: string, params: Record<string, any> = {}): Promise<any> {
const resp = await this._request(method, params);
if (resp.error) throw new Error(`page.${method}: ${resp.error.message ?? JSON.stringify(resp.error)}`);
// Extract text content from MCP result
const result = resp.result;
if (result?.content) {
const textParts = result.content.filter((c: any) => c.type === 'text');
if (textParts.length === 1) {
let text = textParts[0].text;
// 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 ---
async goto(url: string): Promise<void> {
await this.call('tools/call', { name: 'browser_navigate', arguments: { url } });
}
async evaluate(js: string): Promise<any> {
// 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 } });
}
async snapshot(opts: { interactive?: boolean; compact?: boolean; maxDepth?: number; raw?: boolean } = {}): Promise<any> {
const raw = await this.call('tools/call', { name: 'browser_snapshot', arguments: {} });
if (opts.raw) return raw;
if (typeof raw === 'string') return formatSnapshot(raw, opts);
return raw;
}
async click(ref: string): Promise<void> {
await this.call('tools/call', { name: 'browser_click', arguments: { element: 'click target', ref } });
}
async typeText(ref: string, text: string): Promise<void> {
await this.call('tools/call', { name: 'browser_type', arguments: { element: 'type target', ref, text } });
}
async pressKey(key: string): Promise<void> {
await this.call('tools/call', { name: 'browser_press_key', arguments: { key } });
}
async wait(options: number | { text?: string; time?: number; timeout?: number }): Promise<void> {
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 });
}
}
async tabs(): Promise<any> {
return this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'list' } });
}
async closeTab(index?: number): Promise<void> {
await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'close', ...(index !== undefined ? { index } : {}) } });
}
async newTab(): Promise<void> {
await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'new' } });
}
async selectTab(index: number): Promise<void> {
await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'select', index } });
}
async networkRequests(includeStatic: boolean = false): Promise<any> {
return this.call('tools/call', { name: 'browser_network_requests', arguments: { includeStatic } });
}
async consoleMessages(level: string = 'info'): Promise<any> {
return this.call('tools/call', { name: 'browser_console_messages', arguments: { level } });
}
async scroll(direction: string = 'down', _amount: number = 500): Promise<void> {
await this.call('tools/call', { name: 'browser_press_key', arguments: { key: direction === 'down' ? 'PageDown' : 'PageUp' } });
}
async autoScroll(options: { times?: number; delayMs?: number } = {}): Promise<void> {
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 lastHeight = document.body.scrollHeight;
window.scrollTo(0, lastHeight);
await new Promise(resolve => {
let timeoutId;
const observer = new MutationObserver(() => {
if (document.body.scrollHeight > lastHeight) {
clearTimeout(timeoutId);
observer.disconnect();
setTimeout(resolve, 100); // Small debounce for rendering
}
});
observer.observe(document.body, { childList: true, subtree: true });
timeoutId = setTimeout(() => {
observer.disconnect();
resolve(null);
}, maxWaitMs);
});
}
}
`;
await this.evaluate(js);
}
async installInterceptor(pattern: string): Promise<void> {
await this.evaluate(generateInterceptorJs(JSON.stringify(pattern), {
arrayName: '__opencli_xhr',
patchGuard: '__opencli_interceptor_patched',
}));
}
async getInterceptedRequests(): Promise<any[]> {
const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
return result || [];
}
}
/**
* Playwright MCP process manager.
*/
export class PlaywrightMCP {
private static _activeInsts: Set<PlaywrightMCP> = 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<number, { resolve: (data: any) => void; reject: (error: Error) => void }>();
private _initialTabIdentities: string[] = [];
private _closingPromise: Promise<void> | null = null;
private _state: PlaywrightMCPState = 'idle';
private _page: Page | null = null;
get state(): PlaywrightMCPState {
return this._state;
}
private _sendRequest(method: string, params: Record<string, any> = {}): Promise<any> {
return new Promise<any>((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);
});
});
}
private _rejectPendingRequests(error: Error): void {
const pending = [...this._pending.values()];
this._pending.clear();
for (const waiter of pending) waiter.reject(error);
}
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 {}
}
}
async connect(opts: { timeout?: number } = {}): Promise<Page> {
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();
if (!mcpPath) throw new Error('Playwright MCP server not found. Install: npm install -D @playwright/mcp');
PlaywrightMCP._registerGlobalCleanup();
PlaywrightMCP._activeInsts.add(this);
this._state = 'connecting';
const timeout = opts.timeout ?? DEFAULT_BROWSER_CONNECT_TIMEOUT;
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 useExtension = !!process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
const extensionToken = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
const tokenFingerprint = getTokenFingerprint(extensionToken);
let stderrBuffer = '';
let settled = false;
const settleError = (kind: ConnectFailureKind, 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,
}));
}, timeout * 1000);
const mcpArgs = buildMcpArgs({
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}`);
}
debugLog(`Spawning node ${mcpArgs.join(' ')}`);
this._proc = spawn('node', mcpArgs, {
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,
}), { 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) => {
debugLog('Got initialize response');
if (resp.error) {
settleError(inferConnectFailureKind({
hasExtensionToken: !!extensionToken,
stderr: stderrBuffer,
rawMessage: `MCP init failed: ${resp.error.message}`,
}), { 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) => {
debugLog(`Tabs fetch error: ${err.message}`);
settleSuccess(page);
});
}).catch((err) => {
debugLog(`Init promise rejected: ${err.message}`);
settleError('mcp-init', { rawMessage: err.message });
});
});
}
async close(): Promise<void> {
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<boolean>((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;
}
}
function extractTabEntries(raw: any): Array<{ index: number; identity: string }> {
if (Array.isArray(raw)) {
return raw.map((tab: any, index: number) => ({
index,
identity: [
tab?.id ?? '',
tab?.url ?? '',
tab?.title ?? '',
tab?.name ?? '',
].join('|'),
}));
}
if (typeof raw === 'string') {
return raw
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.map(line => {
// Match actual Playwright MCP format: "- 0: (current) [title](url)" or "- 1: [title](url)"
const mcpMatch = line.match(/^-\s+(\d+):\s*(.*)$/);
if (mcpMatch) {
return {
index: parseInt(mcpMatch[1], 10),
identity: mcpMatch[2].trim() || `tab-${mcpMatch[1]}`,
};
}
// Legacy format: "Tab 0 ..."
const legacyMatch = line.match(/Tab\s+(\d+)\s*(.*)$/);
if (legacyMatch) {
return {
index: parseInt(legacyMatch[1], 10),
identity: legacyMatch[2].trim() || `tab-${legacyMatch[1]}`,
};
}
return null;
})
.filter((entry): entry is { index: number; identity: string } => entry !== null);
}
return [];
}
function extractTabIdentities(raw: any): string[] {
return extractTabEntries(raw).map(tab => tab.identity);
}
function diffTabIndexes(initialIdentities: string[], currentTabs: Array<{ index: number; identity: string }>): number[] {
if (initialIdentities.length === 0 || currentTabs.length === 0) return [];
const remaining = new Map<string, number>();
for (const identity of initialIdentities) {
remaining.set(identity, (remaining.get(identity) ?? 0) + 1);
}
const tabsToClose: number[] = [];
for (const tab of currentTabs) {
const count = remaining.get(tab.identity) ?? 0;
if (count > 0) {
remaining.set(tab.identity, count - 1);
continue;
}
tabsToClose.push(tab.index);
}
return tabsToClose.sort((a, b) => b - a);
}
function appendLimited(current: string, chunk: string, limit: number): string {
const next = current + chunk;
if (next.length <= limit) return next;
return next.slice(-limit);
}
function buildMcpArgs(input: { mcpPath: string; executablePath?: string | null }): string[] {
const args = [input.mcpPath];
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) {
args.push('--executable-path', input.executablePath);
}
return args;
}
export const __test__ = {
createJsonRpcRequest,
extractTabEntries,
diffTabIndexes,
appendLimited,
buildMcpArgs,
withTimeoutMs,
};
function findMcpServerPath(): string | null {
if (_cachedMcpServerPath !== undefined) return _cachedMcpServerPath;
const envMcp = process.env.OPENCLI_MCP_SERVER_PATH;
if (envMcp && fs.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 (fs.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 (fs.existsSync(projectMcp)) {
_cachedMcpServerPath = projectMcp;
return _cachedMcpServerPath;
}
// 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();
if (result && fs.existsSync(result)) {
_cachedMcpServerPath = result;
return _cachedMcpServerPath;
}
} catch {}
// Try which
try {
const result = execSync('which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 5000 }).trim();
if (result && fs.existsSync(result)) {
_cachedMcpServerPath = result;
return _cachedMcpServerPath;
}
} catch {}
// Search in common npx cache
for (const base of candidates) {
if (!fs.existsSync(base)) continue;
try {
const found = execSync(`find "${base}" -name "cli.js" -path "*playwright*mcp*" 2>/dev/null | head -1`, { encoding: 'utf-8', timeout: 5000 }).trim();
if (found) {
_cachedMcpServerPath = found;
return _cachedMcpServerPath;
}
} catch {}
}
_cachedMcpServerPath = null;
return _cachedMcpServerPath;
}
+126
View File
@@ -0,0 +1,126 @@
/**
* 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;
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 (opencli-mcp is the modern package)
const localMcp = path.resolve('node_modules', 'opencli-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', 'opencli-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, 'opencli-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, 'opencli-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=opencli-mcp which opencli-mcp 2>/dev/null', { encoding: 'utf-8', timeout: 10000 }).trim();
if (result && _existsSync(result)) {
_cachedMcpServerPath = result;
return _cachedMcpServerPath;
}
} catch {}
// Try which
try {
const result = _execSync('which opencli-mcp 2>/dev/null', { encoding: 'utf-8', timeout: 5000 }).trim();
if (result && _existsSync(result)) {
_cachedMcpServerPath = result;
return _cachedMcpServerPath;
}
} catch {}
// Search in common npx cache
for (const base of candidates) {
if (!_existsSync(base)) continue;
try {
const found = _execSync(`find "${base}" -name "cli.js" -path "*opencli*mcp*" 2>/dev/null | head -1`, { encoding: 'utf-8', timeout: 5000 }).trim();
if (found) {
_cachedMcpServerPath = found;
return _cachedMcpServerPath;
}
} catch {}
}
_cachedMcpServerPath = null;
return _cachedMcpServerPath;
}
export function buildMcpArgs(input: { mcpPath: string; executablePath?: string | null }): string[] {
const args = [input.mcpPath];
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) {
args.push('--executable-path', input.executablePath);
}
return args;
}
+65
View File
@@ -0,0 +1,65 @@
/**
* Browser connection error classification and formatting.
*/
export type ConnectFailureKind = 'extension-timeout' | 'extension-not-installed' | 'mcp-init' | 'process-exit' | 'unknown';
export type ConnectFailureInput = {
kind: ConnectFailureKind;
timeout: number;
stderr?: string;
exitCode?: number | null;
rawMessage?: string;
};
export function formatBrowserConnectError(input: ConnectFailureInput): Error {
const stderr = input.stderr?.trim();
const suffix = stderr ? `\n\nMCP stderr:\n${stderr}` : '';
if (input.kind === 'extension-not-installed') {
return new Error(
'Failed to connect to OpenCLI MCP Bridge: the browser extension did not attach.\n\n' +
'Make sure Chrome is running and the "OpenCLI MCP Bridge" extension is installed and enabled in Developer Mode.' +
suffix,
);
}
if (input.kind === 'extension-timeout') {
return new Error(
`Timed out connecting to OpenCLI MCP Bridge (${input.timeout}s).\n\n` +
`Make sure Chrome is running with the OpenCLI MCP Bridge extension enabled.` +
suffix,
);
}
if (input.kind === 'mcp-init') {
return new Error(`Failed to initialize OpenCLI MCP: ${input.rawMessage ?? 'unknown error'}${suffix}`);
}
if (input.kind === 'process-exit') {
return new Error(
`OpenCLI 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: {
stderr: string;
rawMessage?: string;
exited?: boolean;
}): ConnectFailureKind {
const haystack = `${args.rawMessage ?? ''}\n${args.stderr}`.toLowerCase();
if (haystack.includes('extension connection timeout') || haystack.includes('opencli mcp bridge') || 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';
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Browser module — public API re-exports.
*
* This barrel replaces the former monolithic browser.ts.
* External code should import from './browser/index.js' (or './browser.js' via Node resolution).
*/
export { Page } from './page.js';
export { PlaywrightMCP } from './mcp.js';
export { formatBrowserConnectError } from './errors.js';
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 { withTimeoutMs } from '../runtime.js';
export const __test__ = {
createJsonRpcRequest,
extractTabEntries,
diffTabIndexes,
appendLimited,
buildMcpArgs,
findMcpServerPath,
resetMcpServerPathCache,
setMcpDiscoveryTestHooks,
withTimeoutMs,
};
+298
View File
@@ -0,0 +1,298 @@
/**
* Playwright MCP process manager.
* Handles lifecycle management, JSON-RPC communication, and browser session orchestration.
*/
import { spawn, type ChildProcess } from 'node:child_process';
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 { formatBrowserConnectError, inferConnectFailureKind } from './errors.js';
import { findMcpServerPath, buildMcpArgs } from './discover.js';
import { extractTabIdentities, extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
const STDERR_BUFFER_LIMIT = 16 * 1024;
const INITIAL_TABS_TIMEOUT_MS = 1500;
const TAB_CLEANUP_TIMEOUT_MS = 2000;
export type PlaywrightMCPState = 'idle' | 'connecting' | 'connected' | 'closing' | 'closed';
// JSON-RPC helpers
let _nextId = 1;
export function createJsonRpcRequest(method: string, params: Record<string, unknown> = {}): { id: number; message: string } {
const id = _nextId++;
return {
id,
message: JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n',
};
}
/**
* Playwright MCP process manager.
*/
export class PlaywrightMCP {
private static _activeInsts: Set<PlaywrightMCP> = 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<number, { resolve: (data: any) => void; reject: (error: Error) => void }>();
private _initialTabIdentities: string[] = [];
private _closingPromise: Promise<void> | null = null;
private _state: PlaywrightMCPState = 'idle';
private _page: Page | null = null;
get state(): PlaywrightMCPState {
return this._state;
}
private _sendRequest(method: string, params: Record<string, unknown> = {}): Promise<any> {
return new Promise<any>((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);
});
});
}
private _rejectPendingRequests(error: Error): void {
const pending = [...this._pending.values()];
this._pending.clear();
for (const waiter of pending) waiter.reject(error);
}
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 {}
}
}
async connect(opts: { timeout?: number } = {}): 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');
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);
this._state = 'connecting';
const timeout = opts.timeout ?? DEFAULT_BROWSER_CONNECT_TIMEOUT;
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 useExtension = true; // Always true in dev config or local for opencli-mcp
let stderrBuffer = '';
let settled = false;
const settleError = (kind: Parameters<typeof formatBrowserConnectError>[0]['kind'], extra: { rawMessage?: string; exitCode?: number | null } = {}) => {
if (settled) return;
settled = true;
this._state = 'idle';
clearTimeout(timer);
this._resetAfterFailedConnect();
reject(formatBrowserConnectError({
kind,
timeout,
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({
stderr: stderrBuffer,
}));
}, timeout * 1000);
const mcpArgs = buildMcpArgs({
mcpPath,
executablePath: process.env.OPENCLI_BROWSER_EXECUTABLE_PATH,
});
if (process.env.OPENCLI_VERBOSE) {
console.error(`[opencli] Mode: extension`);
}
debugLog(`Spawning node ${mcpArgs.join(' ')}`);
this._proc = spawn('node', mcpArgs, {
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({
stderr: stderrBuffer,
exited: true,
}), { 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({
stderr: stderrBuffer,
rawMessage: `MCP init failed: ${resp.error.message}`,
}), { 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<void> {
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<boolean>((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;
}
}
+152
View File
@@ -0,0 +1,152 @@
/**
* Page abstraction wrapping JSON-RPC calls to Playwright MCP.
*/
import { formatSnapshot } from '../snapshotFormatter.js';
import { normalizeEvaluateSource } from '../pipeline/template.js';
import { generateInterceptorJs, generateReadInterceptedJs } from '../interceptor.js';
import type { IPage } from '../types.js';
/**
* Page abstraction wrapping JSON-RPC calls to Playwright MCP.
*/
export class Page implements IPage {
constructor(private _request: (method: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>>) {}
async call(method: string, params: Record<string, unknown> = {}): Promise<any> {
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?.content) {
const textParts = result.content.filter((c: any) => c.type === 'text');
if (textParts.length === 1) {
let text = textParts[0].text;
// 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 ---
async goto(url: string): Promise<void> {
await this.call('tools/call', { name: 'browser_navigate', arguments: { url } });
}
async evaluate(js: string): Promise<any> {
// 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 } });
}
async snapshot(opts: { interactive?: boolean; compact?: boolean; maxDepth?: number; raw?: boolean } = {}): Promise<any> {
const raw = await this.call('tools/call', { name: 'browser_snapshot', arguments: {} });
if (opts.raw) return raw;
if (typeof raw === 'string') return formatSnapshot(raw, opts);
return raw;
}
async click(ref: string): Promise<void> {
await this.call('tools/call', { name: 'browser_click', arguments: { element: 'click target', ref } });
}
async typeText(ref: string, text: string): Promise<void> {
await this.call('tools/call', { name: 'browser_type', arguments: { element: 'type target', ref, text } });
}
async pressKey(key: string): Promise<void> {
await this.call('tools/call', { name: 'browser_press_key', arguments: { key } });
}
async wait(options: number | { text?: string; time?: number; timeout?: number }): Promise<void> {
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 });
}
}
async tabs(): Promise<any> {
return this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'list' } });
}
async closeTab(index?: number): Promise<void> {
await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'close', ...(index !== undefined ? { index } : {}) } });
}
async newTab(): Promise<void> {
await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'new' } });
}
async selectTab(index: number): Promise<void> {
await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'select', index } });
}
async networkRequests(includeStatic: boolean = false): Promise<any> {
return this.call('tools/call', { name: 'browser_network_requests', arguments: { includeStatic } });
}
async consoleMessages(level: string = 'info'): Promise<any> {
return this.call('tools/call', { name: 'browser_console_messages', arguments: { level } });
}
async scroll(direction: string = 'down', _amount: number = 500): Promise<void> {
await this.call('tools/call', { name: 'browser_press_key', arguments: { key: direction === 'down' ? 'PageDown' : 'PageUp' } });
}
async autoScroll(options: { times?: number; delayMs?: number } = {}): Promise<void> {
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 lastHeight = document.body.scrollHeight;
window.scrollTo(0, lastHeight);
await new Promise(resolve => {
let timeoutId;
const observer = new MutationObserver(() => {
if (document.body.scrollHeight > lastHeight) {
clearTimeout(timeoutId);
observer.disconnect();
setTimeout(resolve, 100); // Small debounce for rendering
}
});
observer.observe(document.body, { childList: true, subtree: true });
timeoutId = setTimeout(() => {
observer.disconnect();
resolve(null);
}, maxWaitMs);
});
}
}
`;
await this.evaluate(js);
}
async installInterceptor(pattern: string): Promise<void> {
await this.evaluate(generateInterceptorJs(JSON.stringify(pattern), {
arrayName: '__opencli_xhr',
patchGuard: '__opencli_interceptor_patched',
}));
}
async getInterceptedRequests(): Promise<any[]> {
const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
return result || [];
}
}
+76
View File
@@ -0,0 +1,76 @@
/**
* Browser tab management helpers: extract, diff, and cleanup tab state.
*/
export function extractTabEntries(raw: unknown): Array<{ index: number; identity: string }> {
if (Array.isArray(raw)) {
return raw.map((tab: Record<string, unknown>, index: number) => ({
index,
identity: [
tab?.id ?? '',
tab?.url ?? '',
tab?.title ?? '',
tab?.name ?? '',
].join('|'),
}));
}
if (typeof raw === 'string') {
return raw
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.map(line => {
// Match actual Playwright MCP format: "- 0: (current) [title](url)" or "- 1: [title](url)"
const mcpMatch = line.match(/^-\s+(\d+):\s*(.*)$/);
if (mcpMatch) {
return {
index: parseInt(mcpMatch[1], 10),
identity: mcpMatch[2].trim() || `tab-${mcpMatch[1]}`,
};
}
// Legacy format: "Tab 0 ..."
const legacyMatch = line.match(/Tab\s+(\d+)\s*(.*)$/);
if (legacyMatch) {
return {
index: parseInt(legacyMatch[1], 10),
identity: legacyMatch[2].trim() || `tab-${legacyMatch[1]}`,
};
}
return null;
})
.filter((entry): entry is { index: number; identity: string } => entry !== null);
}
return [];
}
export function extractTabIdentities(raw: unknown): string[] {
return extractTabEntries(raw).map(tab => tab.identity);
}
export function diffTabIndexes(initialIdentities: string[], currentTabs: Array<{ index: number; identity: string }>): number[] {
if (initialIdentities.length === 0 || currentTabs.length === 0) return [];
const remaining = new Map<string, number>();
for (const identity of initialIdentities) {
remaining.set(identity, (remaining.get(identity) ?? 0) + 1);
}
const tabsToClose: number[] = [];
for (const tab of currentTabs) {
const count = remaining.get(tab.identity) ?? 0;
if (count > 0) {
remaining.set(tab.identity, count - 1);
continue;
}
tabsToClose.push(tab.index);
}
return tabsToClose.sort((a, b) => b - a);
}
export function appendLimited(current: string, chunk: string, limit: number): string {
const next = current + chunk;
if (next.length <= limit) return next;
return next.slice(-limit);
}
+201
View File
@@ -0,0 +1,201 @@
import { cli, Strategy } from '../../registry.js';
const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
const BOOKMARKS_QUERY_ID = 'Fy0QMy4q_aZCpkO0PnyLYw';
const FEATURES = {
rweb_video_screen_enabled: false,
profile_label_improvements_pcf_label_in_post_enabled: true,
responsive_web_profile_redirect_enabled: false,
rweb_tipjar_consumption_enabled: false,
verified_phone_label_enabled: false,
creator_subscriptions_tweet_preview_api_enabled: true,
responsive_web_graphql_timeline_navigation_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
premium_content_api_read_enabled: false,
communities_web_enable_tweet_community_results_fetch: true,
c9s_tweet_anatomy_moderator_badge_enabled: true,
articles_preview_enabled: true,
responsive_web_edit_tweet_api_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
view_counts_everywhere_api_enabled: true,
longform_notetweets_consumption_enabled: true,
responsive_web_twitter_article_tweet_consumption_enabled: true,
tweet_awards_web_tipping_enabled: false,
content_disclosure_indicator_enabled: true,
content_disclosure_ai_generated_indicator_enabled: true,
freedom_of_speech_not_reach_fetch_enabled: true,
standardized_nudges_misinfo: true,
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
longform_notetweets_rich_text_read_enabled: true,
longform_notetweets_inline_media_enabled: false,
responsive_web_enhance_cards_enabled: false,
};
interface BookmarkTweet {
id: string;
author: string;
name: string;
text: string;
likes: number;
retweets: number;
created_at: string;
url: string;
}
function buildBookmarksUrl(count: number, cursor?: string | null): string {
const vars: Record<string, any> = {
count,
includePromotedContent: false,
};
if (cursor) vars.cursor = cursor;
return `/i/api/graphql/${BOOKMARKS_QUERY_ID}/Bookmarks`
+ `?variables=${encodeURIComponent(JSON.stringify(vars))}`
+ `&features=${encodeURIComponent(JSON.stringify(FEATURES))}`;
}
function extractBookmarkTweet(result: any, seen: Set<string>): BookmarkTweet | null {
if (!result) return null;
const tw = result.tweet || result;
const legacy = tw.legacy || {};
if (!tw.rest_id || seen.has(tw.rest_id)) return null;
seen.add(tw.rest_id);
const user = tw.core?.user_results?.result;
const screenName = user?.legacy?.screen_name || user?.core?.screen_name || 'unknown';
const displayName = user?.legacy?.name || user?.core?.name || '';
const noteText = tw.note_tweet?.note_tweet_results?.result?.text;
return {
id: tw.rest_id,
author: screenName,
name: displayName,
text: noteText || legacy.full_text || '',
likes: legacy.favorite_count || 0,
retweets: legacy.retweet_count || 0,
created_at: legacy.created_at || '',
url: `https://x.com/${screenName}/status/${tw.rest_id}`,
};
}
function parseBookmarks(data: any, seen: Set<string>): { tweets: BookmarkTweet[]; nextCursor: string | null } {
const tweets: BookmarkTweet[] = [];
let nextCursor: string | null = null;
const instructions =
data?.data?.bookmark_timeline_v2?.timeline?.instructions
|| data?.data?.bookmark_timeline?.timeline?.instructions
|| [];
for (const inst of instructions) {
for (const entry of inst.entries || []) {
const content = entry.content;
if (content?.entryType === 'TimelineTimelineCursor' || content?.__typename === 'TimelineTimelineCursor') {
if (content.cursorType === 'Bottom' || content.cursorType === 'ShowMore') nextCursor = content.value;
continue;
}
if (entry.entryId?.startsWith('cursor-bottom-') || entry.entryId?.startsWith('cursor-showMore-')) {
nextCursor = content?.value || content?.itemContent?.value || nextCursor;
continue;
}
const direct = extractBookmarkTweet(content?.itemContent?.tweet_results?.result, seen);
if (direct) {
tweets.push(direct);
continue;
}
for (const item of content?.items || []) {
const nested = extractBookmarkTweet(item.item?.itemContent?.tweet_results?.result, seen);
if (nested) tweets.push(nested);
}
}
}
return { tweets, nextCursor };
}
cli({
site: 'twitter',
name: 'bookmarks',
description: 'Fetch Twitter/X bookmarks',
domain: 'x.com',
strategy: Strategy.COOKIE,
browser: true,
args: [
{ name: 'limit', type: 'int', default: 20 },
],
columns: ['author', 'text', 'likes', 'url'],
func: async (page, kwargs) => {
const limit = kwargs.limit || 20;
await page.goto('https://x.com');
await page.wait(3);
const ct0 = await page.evaluate(`() => {
return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
}`);
if (!ct0) throw new Error('Not logged into x.com (no ct0 cookie)');
const queryId = await page.evaluate(`async () => {
try {
const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json');
if (ghResp.ok) {
const data = await ghResp.json();
const entry = data['Bookmarks'];
if (entry && entry.queryId) return entry.queryId;
}
} catch {}
try {
const scripts = performance.getEntriesByType('resource')
.filter(r => r.name.includes('client-web') && r.name.endsWith('.js'))
.map(r => r.name);
for (const scriptUrl of scripts.slice(0, 15)) {
try {
const text = await (await fetch(scriptUrl)).text();
const re = /queryId:"([A-Za-z0-9_-]+)"[^}]{0,200}operationName:"Bookmarks"/;
const m = text.match(re);
if (m) return m[1];
} catch {}
}
} catch {}
return null;
}`) || BOOKMARKS_QUERY_ID;
const headers = JSON.stringify({
'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
'X-Csrf-Token': ct0,
'X-Twitter-Auth-Type': 'OAuth2Session',
'X-Twitter-Active-User': 'yes',
});
const allTweets: BookmarkTweet[] = [];
const seen = new Set<string>();
let cursor: string | null = null;
for (let i = 0; i < 5 && allTweets.length < limit; i++) {
const fetchCount = Math.min(100, limit - allTweets.length + 10);
const apiUrl = buildBookmarksUrl(fetchCount, cursor).replace(BOOKMARKS_QUERY_ID, queryId);
const data = await page.evaluate(`async () => {
const r = await fetch("${apiUrl}", { headers: ${headers}, credentials: 'include' });
return r.ok ? await r.json() : { error: r.status };
}`);
if (data?.error) {
if (allTweets.length === 0) throw new Error(`HTTP ${data.error}: Failed to fetch bookmarks. queryId may have expired.`);
break;
}
const { tweets, nextCursor } = parseBookmarks(data, seen);
allTweets.push(...tweets);
if (!nextCursor || nextCursor === cursor) break;
cursor = nextCursor;
}
return allTweets.slice(0, limit);
},
});
-85
View File
@@ -1,85 +0,0 @@
site: twitter
name: bookmarks
description: 获取 Twitter 书签列表
domain: x.com
browser: true
args:
limit:
type: int
default: 20
description: Number of bookmarks to return (default 20)
pipeline:
- navigate: https://x.com/i/bookmarks
- wait: 2
- evaluate: |
(async () => {
const ct0 = document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1];
if (!ct0) throw new Error('No ct0 cookie. Hint: Not logged into x.com.');
const bearer = decodeURIComponent('AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA');
const _h = {'Authorization':'Bearer '+bearer, 'X-Csrf-Token':ct0, 'X-Twitter-Auth-Type':'OAuth2Session', 'X-Twitter-Active-User':'yes'};
const count = Math.min(${{ args.limit }}, 100);
const variables = JSON.stringify({count, includePromotedContent: false});
const features = JSON.stringify({
rweb_video_screen_enabled: false, profile_label_improvements_pcf_label_in_post_enabled: true,
responsive_web_profile_redirect_enabled: false, rweb_tipjar_consumption_enabled: false,
verified_phone_label_enabled: false, creator_subscriptions_tweet_preview_api_enabled: true,
responsive_web_graphql_timeline_navigation_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
premium_content_api_read_enabled: false, communities_web_enable_tweet_community_results_fetch: true,
c9s_tweet_anatomy_moderator_badge_enabled: true,
articles_preview_enabled: true, responsive_web_edit_tweet_api_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
view_counts_everywhere_api_enabled: true, longform_notetweets_consumption_enabled: true,
responsive_web_twitter_article_tweet_consumption_enabled: true,
tweet_awards_web_tipping_enabled: false,
content_disclosure_indicator_enabled: true, content_disclosure_ai_generated_indicator_enabled: true,
freedom_of_speech_not_reach_fetch_enabled: true, standardized_nudges_misinfo: true,
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
longform_notetweets_rich_text_read_enabled: true, longform_notetweets_inline_media_enabled: false,
responsive_web_enhance_cards_enabled: false
});
const url = '/i/api/graphql/Fy0QMy4q_aZCpkO0PnyLYw/Bookmarks?variables=' + encodeURIComponent(variables) + '&features=' + encodeURIComponent(features);
const resp = await fetch(url, {headers: _h, credentials: 'include'});
if (!resp.ok) throw new Error('HTTP ' + resp.status + '. Hint: queryId may have changed.');
const d = await resp.json();
const instructions = d.data?.bookmark_timeline_v2?.timeline?.instructions || d.data?.bookmark_timeline?.timeline?.instructions || [];
let tweets = [], seen = new Set();
for (const inst of instructions) {
for (const entry of (inst.entries || [])) {
const r = entry.content?.itemContent?.tweet_results?.result;
if (!r) continue;
const tw = r.tweet || r;
const l = tw.legacy || {};
if (!tw.rest_id || seen.has(tw.rest_id)) continue;
seen.add(tw.rest_id);
const u = tw.core?.user_results?.result;
const nt = tw.note_tweet?.note_tweet_results?.result?.text;
const screenName = u?.legacy?.screen_name || u?.core?.screen_name;
tweets.push({
id: tw.rest_id,
author: screenName,
name: u?.legacy?.name || u?.core?.name,
url: 'https://x.com/' + (screenName || '_') + '/status/' + tw.rest_id,
text: nt || l.full_text || '',
likes: l.favorite_count,
retweets: l.retweet_count,
created_at: l.created_at
});
}
}
return tweets;
})()
- map:
author: ${{ item.author }}
text: ${{ item.text }}
likes: ${{ item.likes }}
url: ${{ item.url }}
- limit: ${{ args.limit }}
columns: [author, text, likes, url]
-1
View File
@@ -15,7 +15,6 @@ cli({
func: async (page: IPage | null, kwargs: any) => {
if (!page) throw new Error('Requires browser');
console.log(`Navigating to tweet: ${kwargs.url}`);
await page.goto(kwargs.url);
await page.wait(5); // Wait for tweet to load completely
+5 -16
View File
@@ -1,5 +1,4 @@
import { cli, Strategy } from '../../registry.js';
import * as fs from 'fs';
cli({
site: 'twitter',
@@ -37,8 +36,8 @@ cli({
await page.goto(`https://x.com/${targetUser}`);
await page.wait(3);
// 2. Inject interceptor for Followers GraphQL API (or user_flow.json)
await page.installInterceptor('graphql');
// 2. Inject interceptor for the followers GraphQL API
await page.installInterceptor('Followers');
// 3. Click the followers link inside the profile page
await page.evaluate(`() => {
@@ -53,24 +52,14 @@ cli({
// 4. Retrieve data from opencli's registered interceptors
const allRequests = await page.getInterceptedRequests();
const requestList = Array.isArray(allRequests) ? allRequests : [];
// Debug: Force dump all intercepted XHRs that match followers
if (!allRequests || allRequests.length === 0) {
console.log('No GraphQL requests captured by the interceptor backend.');
if (requestList.length === 0) {
return [];
}
console.log('Intercepted keys:', allRequests.map((r: any) => {
try {
const u = new URL(r.url); return u.pathname;
} catch (e) {
return r.url;
}
}));
const requests = allRequests.filter((r: any) => r.url.includes('Followers'));
const requests = requestList.filter((r: any) => r?.url?.includes('Followers'));
if (!requests || requests.length === 0) {
console.log('No specific Followers requests captured. Check keys printed above.');
return [];
}
+3 -5
View File
@@ -1,5 +1,4 @@
import { cli, Strategy } from '../../registry.js';
import * as fs from 'fs';
cli({
site: 'twitter',
@@ -53,15 +52,14 @@ cli({
// 4. Retrieve data from opencli's registered interceptors
const requests = await page.getInterceptedRequests();
const requestList = Array.isArray(requests) ? requests : [];
// Debug: Force dump all intercepted XHRs that match following
if (!requests || requests.length === 0) {
console.log('No Following requests captured by the interceptor backend.');
if (requestList.length === 0) {
return [];
}
let results: any[] = [];
for (const req of requests) {
for (const req of requestList) {
try {
let instructions = req.data?.data?.user?.result?.timeline?.timeline?.instructions;
if (!instructions) continue;
-1
View File
@@ -15,7 +15,6 @@ cli({
func: async (page: IPage | null, kwargs: any) => {
if (!page) throw new Error('Requires browser');
console.log(`Navigating to tweet: ${kwargs.url}`);
await page.goto(kwargs.url);
await page.wait(5); // Wait for tweet to load completely
+18 -9
View File
@@ -1,5 +1,4 @@
import { cli, Strategy } from '../../registry.js';
import * as fs from 'fs';
cli({
site: 'twitter',
@@ -13,13 +12,16 @@ cli({
],
columns: ['id', 'action', 'author', 'text', 'url'],
func: async (page, kwargs) => {
// Install the interceptor before loading the notifications page so we
// capture the initial timeline request triggered during page load.
await page.goto('https://x.com');
await page.wait(2);
await page.installInterceptor('NotificationsTimeline');
// 1. Navigate to notifications
await page.goto('https://x.com/notifications');
await page.wait(5);
// 2. Inject interceptor
await page.installInterceptor('NotificationsTimeline');
// 3. Trigger API by scrolling (if we need to load more)
await page.autoScroll({ times: 2, delayMs: 2000 });
@@ -28,6 +30,7 @@ cli({
if (!requests || requests.length === 0) return [];
let results: any[] = [];
const seen = new Set<string>();
for (const req of requests) {
try {
let instructions: any[] = [];
@@ -75,14 +78,16 @@ cli({
if (item.__typename === 'TimelineNotification') {
// Greet likes, retweet, mentions
text = item.rich_message?.text || item.message?.text || '';
author = item.template?.from_users?.[0]?.user_results?.result?.core?.screen_name || 'unknown';
const fromUser = item.template?.from_users?.[0]?.user_results?.result;
author = fromUser?.legacy?.screen_name || fromUser?.core?.screen_name || 'unknown';
urlStr = item.notification_url?.url || '';
actionText = item.notification_icon || 'Activity';
// If there's an attached tweet
const targetTweet = item.template?.target_objects?.[0]?.tweet_results?.result;
if (targetTweet) {
text += ' | ' + (targetTweet.legacy?.full_text || '');
const targetText = targetTweet.note_tweet?.note_tweet_results?.result?.text || targetTweet.legacy?.full_text || '';
text += text && targetText ? ' | ' + targetText : targetText;
if (!urlStr) {
urlStr = `https://x.com/i/status/${targetTweet.rest_id}`;
}
@@ -91,18 +96,22 @@ cli({
// Direct mention/reply
const tweet = item.tweet_result?.result;
author = tweet?.core?.user_results?.result?.legacy?.screen_name || 'unknown';
text = tweet?.legacy?.full_text || item.message?.text || '';
text = tweet?.note_tweet?.note_tweet_results?.result?.text || tweet?.legacy?.full_text || item.message?.text || '';
actionText = 'Mention/Reply';
urlStr = `https://x.com/i/status/${tweet?.rest_id}`;
} else if (item.__typename === 'Tweet') {
author = item.core?.user_results?.result?.legacy?.screen_name || 'unknown';
text = item.legacy?.full_text || '';
text = item.note_tweet?.note_tweet_results?.result?.text || item.legacy?.full_text || '';
actionText = 'Mention';
urlStr = `https://x.com/i/status/${item.rest_id}`;
}
const id = item.id || item.rest_id || entryId;
if (seen.has(id)) return;
seen.add(id);
results.push({
id: item.id || item.rest_id || entryId,
id,
action: actionText,
author: author,
text: text,
+14 -7
View File
@@ -13,14 +13,17 @@ cli({
],
columns: ['id', 'author', 'text', 'likes', 'views', 'url'],
func: async (page, kwargs) => {
// Install the interceptor before opening the target page so we don't miss
// the initial SearchTimeline request fired during hydration.
await page.goto('https://x.com');
await page.wait(2);
await page.installInterceptor('SearchTimeline');
// 1. Navigate to the search page
const q = encodeURIComponent(kwargs.query);
await page.goto(`https://x.com/search?q=${q}&f=top`);
await page.wait(5);
// 2. Inject XHR interceptor
await page.installInterceptor('SearchTimeline');
// 3. Trigger API by scrolling
await page.autoScroll({ times: 3, delayMs: 2000 });
@@ -29,11 +32,13 @@ cli({
if (!requests || requests.length === 0) return [];
let results: any[] = [];
const seen = new Set<string>();
for (const req of requests) {
try {
const insts = req.data.data.search_by_raw_query.search_timeline.timeline.instructions;
const addEntries = insts.find((i: any) => i.type === 'TimelineAddEntries');
if (!addEntries) continue;
const insts = req.data?.data?.search_by_raw_query?.search_timeline?.timeline?.instructions || [];
const addEntries = insts.find((i: any) => i.type === 'TimelineAddEntries')
|| insts.find((i: any) => i.entries && Array.isArray(i.entries));
if (!addEntries?.entries) continue;
for (const entry of addEntries.entries) {
if (!entry.entryId.startsWith('tweet-')) continue;
@@ -45,11 +50,13 @@ cli({
if (tweet.__typename === 'TweetWithVisibilityResults' && tweet.tweet) {
tweet = tweet.tweet;
}
if (!tweet.rest_id || seen.has(tweet.rest_id)) continue;
seen.add(tweet.rest_id);
results.push({
id: tweet.rest_id,
author: tweet.core?.user_results?.result?.legacy?.screen_name || 'unknown',
text: tweet.legacy?.full_text || '',
text: tweet.note_tweet?.note_tweet_results?.result?.text || tweet.legacy?.full_text || '',
likes: tweet.legacy?.favorite_count || 0,
views: tweet.views?.count || '0',
url: `https://x.com/i/status/${tweet.rest_id}`
+204 -36
View File
@@ -1,50 +1,218 @@
import { cli, Strategy } from '../../registry.js';
// ── Twitter GraphQL constants ──────────────────────────────────────────
const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
const HOME_TIMELINE_QUERY_ID = 'c-CzHF1LboFilMpsx4ZCrQ';
const FEATURES = {
rweb_video_screen_enabled: false,
profile_label_improvements_pcf_label_in_post_enabled: true,
rweb_tipjar_consumption_enabled: true,
verified_phone_label_enabled: false,
creator_subscriptions_tweet_preview_api_enabled: true,
responsive_web_graphql_timeline_navigation_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
premium_content_api_read_enabled: false,
communities_web_enable_tweet_community_results_fetch: true,
c9s_tweet_anatomy_moderator_badge_enabled: true,
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
responsive_web_grok_analyze_post_followups_enabled: true,
responsive_web_jetfuel_frame: false,
responsive_web_grok_share_attachment_enabled: true,
articles_preview_enabled: true,
responsive_web_edit_tweet_api_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
view_counts_everywhere_api_enabled: true,
longform_notetweets_consumption_enabled: true,
responsive_web_twitter_article_tweet_consumption_enabled: true,
tweet_awards_web_tipping_enabled: false,
responsive_web_grok_show_grok_translated_post: false,
responsive_web_grok_analysis_button_from_backend: false,
creator_subscriptions_quote_tweet_preview_enabled: false,
freedom_of_speech_not_reach_fetch_enabled: true,
standardized_nudges_misinfo: true,
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
longform_notetweets_rich_text_read_enabled: true,
longform_notetweets_inline_media_enabled: true,
responsive_web_grok_image_annotation_enabled: true,
responsive_web_enhance_cards_enabled: false,
};
// ── Pure functions (type-safe, testable) ───────────────────────────────
interface TimelineTweet {
id: string;
author: string;
text: string;
likes: number;
retweets: number;
replies: number;
views: number;
created_at: string;
url: string;
}
function buildHomeTimelineUrl(count: number, cursor?: string | null): string {
const vars: Record<string, any> = {
count,
includePromotedContent: false,
latestControlAvailable: true,
requestContext: 'launch',
withCommunity: true,
};
if (cursor) vars.cursor = cursor;
return `/i/api/graphql/${HOME_TIMELINE_QUERY_ID}/HomeTimeline`
+ `?variables=${encodeURIComponent(JSON.stringify(vars))}`
+ `&features=${encodeURIComponent(JSON.stringify(FEATURES))}`;
}
function extractTweet(result: any, seen: Set<string>): TimelineTweet | null {
if (!result) return null;
const tw = result.tweet || result;
const l = tw.legacy || {};
if (!tw.rest_id || seen.has(tw.rest_id)) return null;
seen.add(tw.rest_id);
const u = tw.core?.user_results?.result;
const screenName = u?.legacy?.screen_name || u?.core?.screen_name || 'unknown';
const noteText = tw.note_tweet?.note_tweet_results?.result?.text;
const views = tw.views?.count ? parseInt(tw.views.count, 10) : 0;
return {
id: tw.rest_id,
author: screenName,
text: noteText || l.full_text || '',
likes: l.favorite_count || 0,
retweets: l.retweet_count || 0,
replies: l.reply_count || 0,
views,
created_at: l.created_at || '',
url: `https://x.com/${screenName}/status/${tw.rest_id}`,
};
}
function parseHomeTimeline(data: any, seen: Set<string>): { tweets: TimelineTweet[]; nextCursor: string | null } {
const tweets: TimelineTweet[] = [];
let nextCursor: string | null = null;
const instructions =
data?.data?.home?.home_timeline_urt?.instructions || [];
for (const inst of instructions) {
for (const entry of inst.entries || []) {
const c = entry.content;
// Cursor entries
if (c?.entryType === 'TimelineTimelineCursor' || c?.__typename === 'TimelineTimelineCursor') {
if (c.cursorType === 'Bottom') nextCursor = c.value;
continue;
}
if (entry.entryId?.startsWith('cursor-bottom-')) {
nextCursor = c?.value || nextCursor;
continue;
}
// Single tweet entry
const tweetResult = c?.itemContent?.tweet_results?.result;
if (tweetResult) {
// Skip promoted content
if (c?.itemContent?.promotedMetadata) continue;
const tw = extractTweet(tweetResult, seen);
if (tw) tweets.push(tw);
continue;
}
// Conversation module (grouped tweets)
for (const item of c?.items || []) {
const nested = item.item?.itemContent?.tweet_results?.result;
if (nested) {
if (item.item?.itemContent?.promotedMetadata) continue;
const tw = extractTweet(nested, seen);
if (tw) tweets.push(tw);
}
}
}
}
return { tweets, nextCursor };
}
// ── CLI definition ────────────────────────────────────────────────────
cli({
site: 'twitter',
name: 'timeline',
description: 'Twitter Home Timeline',
description: 'Fetch Twitter Home Timeline',
domain: 'x.com',
strategy: Strategy.COOKIE,
browser: true,
args: [
{ name: 'limit', type: 'int', default: 20 },
],
columns: ['responseType', 'first'],
columns: ['id', 'author', 'text', 'likes', 'retweets', 'replies', 'views', 'created_at', 'url'],
func: async (page, kwargs) => {
await page.goto('https://x.com/home');
await page.wait(5);
// Inject the fetch interceptor manually to see exactly what happens
await page.evaluate(`
() => {
window.__intercept_data = [];
const origFetch = window.fetch;
window.fetch = async function(...args) {
let u = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || '';
const res = await origFetch.apply(this, args);
setTimeout(async () => {
try {
if (u.includes('HomeTimeline')) {
const clone = res.clone();
const j = await clone.json();
window.__intercept_data.push(j);
}
} catch(e) {}
}, 0);
return res;
};
const limit = kwargs.limit || 20;
// Navigate to x.com for cookie context
await page.goto('https://x.com');
await page.wait(3);
// Extract CSRF token
const ct0 = await page.evaluate(`() => {
return document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1] || null;
}`);
if (!ct0) throw new Error('Not logged into x.com (no ct0 cookie)');
// Dynamically resolve queryId
const queryId = await page.evaluate(`async () => {
try {
const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json');
if (ghResp.ok) {
const data = await ghResp.json();
const entry = data['HomeTimeline'];
if (entry && entry.queryId) return entry.queryId;
}
} catch {}
return null;
}`) || HOME_TIMELINE_QUERY_ID;
// Build auth headers
const headers = JSON.stringify({
'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
'X-Csrf-Token': ct0,
'X-Twitter-Auth-Type': 'OAuth2Session',
'X-Twitter-Active-User': 'yes',
});
// Paginate — fetch in browser, parse in TypeScript
const allTweets: TimelineTweet[] = [];
const seen = new Set<string>();
let cursor: string | null = null;
for (let i = 0; i < 5 && allTweets.length < limit; i++) {
const fetchCount = Math.min(40, limit - allTweets.length + 5); // over-fetch slightly for promoted filtering
const apiUrl = buildHomeTimelineUrl(fetchCount, cursor)
.replace(HOME_TIMELINE_QUERY_ID, queryId);
const data = await page.evaluate(`async () => {
const r = await fetch("${apiUrl}", { headers: ${headers}, credentials: 'include' });
return r.ok ? await r.json() : { error: r.status };
}`);
if (data?.error) {
if (allTweets.length === 0) throw new Error(`HTTP ${data.error}: Failed to fetch timeline. queryId may have expired.`);
break;
}
`);
// trigger scroll
for(let i=0; i<3; i++) {
await page.evaluate('() => window.scrollTo(0, document.body.scrollHeight)');
await page.wait(2);
const { tweets, nextCursor } = parseHomeTimeline(data, seen);
allTweets.push(...tweets);
if (!nextCursor || nextCursor === cursor) break;
cursor = nextCursor;
}
// extract
const data = await page.evaluate('() => window.__intercept_data');
if (!data || data.length === 0) return [{responseType: 'no data captured'}];
return [{responseType: `captured ${data.length} responses`, first: JSON.stringify(data[0]).substring(0,300)}];
}
return allTweets.slice(0, limit);
},
});
+8 -2
View File
@@ -25,9 +25,15 @@ pipeline:
credentials: 'include',
headers: { 'x-twitter-active-user': 'yes', 'x-csrf-token': csrfToken, 'authorization': 'Bearer ' + bearerToken }
});
if (!res.ok) throw new Error('HTTP ' + res.status + '. Hint: trending endpoint may require login or API shape changed.');
const data = await res.json();
const trends = data?.timeline?.instructions?.[1]?.addEntries?.entries || [];
return trends.filter(e => e.content?.timelineModule).flatMap(e => e.content.timelineModule.items || []).map(t => t?.item?.content?.trend).filter(Boolean);
const instructions = data?.timeline?.instructions || [];
const entries = instructions.flatMap(inst => inst?.addEntries?.entries || inst?.entries || []);
return entries
.filter(e => e.content?.timelineModule)
.flatMap(e => e.content.timelineModule.items || [])
.map(t => t?.item?.content?.trend)
.filter(Boolean);
})()
- map:
+129
View File
@@ -0,0 +1,129 @@
/**
* Shell tab-completion support for opencli.
*
* Provides:
* - Shell script generators for bash, zsh, and fish
* - Dynamic completion logic that returns candidates for the current cursor position
*/
import { getRegistry } from './registry.js';
import { CliError } from './errors.js';
// ── Dynamic completion logic ───────────────────────────────────────────────
/**
* Built-in (non-dynamic) top-level commands.
*/
const BUILTIN_COMMANDS = [
'list',
'validate',
'verify',
'explore',
'probe', // alias for explore
'synthesize',
'generate',
'cascade',
'doctor',
'setup',
'completion',
];
/**
* Return completion candidates given the current command-line words and cursor index.
*
* @param words - The argv after 'opencli' (words[0] is the first arg, e.g. site name)
* @param cursor - 1-based position of the word being completed (1 = first arg)
*/
export function getCompletions(words: string[], cursor: number): string[] {
// cursor === 1 → completing the first argument (site name or built-in command)
if (cursor <= 1) {
const sites = new Set<string>();
for (const [, cmd] of getRegistry()) {
sites.add(cmd.site);
}
return [...BUILTIN_COMMANDS, ...sites].sort();
}
const site = words[0];
// If the first word is a built-in command, no further completion
if (BUILTIN_COMMANDS.includes(site)) {
return [];
}
// cursor === 2 → completing the sub-command name under a site
if (cursor === 2) {
const subcommands: string[] = [];
for (const [, cmd] of getRegistry()) {
if (cmd.site === site) {
subcommands.push(cmd.name);
}
}
return subcommands.sort();
}
// cursor >= 3 → no further completion
return [];
}
// ── Shell script generators ────────────────────────────────────────────────
export function bashCompletionScript(): string {
return `# Bash completion for opencli
# Add to ~/.bashrc: eval "$(opencli completion bash)"
_opencli_completions() {
local cur words cword
_get_comp_words_by_ref -n : cur words cword
local completions
completions=$(opencli --get-completions --cursor "$cword" "\${words[@]:1}" 2>/dev/null)
COMPREPLY=( $(compgen -W "$completions" -- "$cur") )
__ltrim_colon_completions "$cur"
}
complete -F _opencli_completions opencli
`;
}
export function zshCompletionScript(): string {
return `# Zsh completion for opencli
# Add to ~/.zshrc: eval "$(opencli completion zsh)"
_opencli() {
local -a completions
local cword=$((CURRENT - 1))
completions=(\${(f)"$(opencli --get-completions --cursor "$cword" "\${words[@]:1}" 2>/dev/null)"})
compadd -a completions
}
compdef _opencli opencli
`;
}
export function fishCompletionScript(): string {
return `# Fish completion for opencli
# Add to ~/.config/fish/config.fish: opencli completion fish | source
complete -c opencli -f -a '(
set -l tokens (commandline -cop)
set -l cursor (count (commandline -cop))
opencli --get-completions --cursor $cursor $tokens[2..] 2>/dev/null
)'
`;
}
/**
* Print the completion script for the requested shell.
*/
export function printCompletionScript(shell: string): void {
switch (shell) {
case 'bash':
process.stdout.write(bashCompletionScript());
break;
case 'zsh':
process.stdout.write(zshCompletionScript());
break;
case 'fish':
process.stdout.write(fishCompletionScript());
break;
default:
throw new CliError('UNSUPPORTED_SHELL', `Unsupported shell: ${shell}. Supported: bash, zsh, fish`);
}
}
+12 -147
View File
@@ -2,135 +2,12 @@ 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');
});
});
describe('doctor report rendering', () => {
@@ -139,60 +16,51 @@ describe('doctor report rendering', () => {
it('renders OK-style report when tokens match', () => {
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 }],
shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'abc123' }],
configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', writable: true }],
recommendedToken: 'abc123',
recommendedFingerprint: 'fp1',
warnings: [],
issues: [],
}));
expect(text).toContain('[OK] Extension installed (Chrome)');
expect(text).toContain('[OK] Environment token: configured (fp1)');
expect(text).toContain('[OK] Environment token: configured');
expect(text).toContain('[OK] /tmp/mcp.json');
expect(text).toContain('configured (fp1)');
expect(text).toContain('configured');
});
it('renders MISMATCH-style report when fingerprints differ', () => {
it('renders MISSING-style report when components are not installed', () => {
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 }],
shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'def456' }],
configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', writable: true }],
recommendedToken: 'abc123',
recommendedFingerprint: 'fp1',
warnings: [],
issues: ['Detected inconsistent Playwright MCP tokens across env/config files.'],
issues: [],
}));
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('[OK] Environment token: configured');
expect(text).toContain('[OK] /tmp/.zshrc');
expect(text).toContain('configured');
expect(text).toContain('[OK] Token Configuration: Not required for OpenCLI MCP');
});
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',
connectivity: { ok: true, durationMs: 1234 },
warnings: [],
issues: [],
@@ -204,15 +72,12 @@ describe('doctor report rendering', () => {
it('renders connectivity WARN 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: [],
issues: [],
}));
+34 -177
View File
@@ -6,7 +6,7 @@ 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.js';
import { PlaywrightMCP } from './browser/index.js';
import { browserSession } from './runtime.js';
const PLAYWRIGHT_SERVER_NAME = 'playwright';
@@ -27,7 +27,6 @@ export type ShellFileStatus = {
path: string;
exists: boolean;
token: string | null;
fingerprint: string | null;
};
export type McpConfigFormat = 'json' | 'toml';
@@ -37,7 +36,6 @@ export type McpConfigStatus = {
exists: boolean;
format: McpConfigFormat;
token: string | null;
fingerprint: string | null;
writable: boolean;
parseError?: string;
};
@@ -51,15 +49,12 @@ 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;
connectivity?: ConnectivityResult;
warnings: string[];
issues: string[];
@@ -80,9 +75,9 @@ function statusLine(status: ReportStatus, text: string): string {
return `${colorLabel(status)} ${text}`;
}
function tokenSummary(token: string | null, fingerprint: string | null): string {
function tokenSummary(token: string | null): string {
if (!token) return chalk.dim('missing');
return `configured ${chalk.dim(`(${fingerprint})`)}`;
return `configured`;
}
export function shortenPath(p: string): string {
@@ -145,97 +140,7 @@ export function readTokenFromShellContent(content: string): string | null {
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 {
@@ -261,17 +166,17 @@ function canWrite(filePath: string): boolean {
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) };
return { path: filePath, exists: false, format, token: null, writable: canWrite(filePath) };
}
try {
const content = fs.readFileSync(filePath, 'utf-8');
const token = format === 'toml' ? readTomlConfigToken(content) : readJsonConfigToken(content);
// Deprecated token extraction.
const token = null;
return {
path: filePath,
exists: true,
format,
token,
fingerprint: getTokenFingerprint(token ?? undefined),
writable: canWrite(filePath),
};
} catch (error: any) {
@@ -280,7 +185,6 @@ function readConfigStatus(filePath: string): McpConfigStatus {
exists: true,
format,
token: null,
fingerprint: null,
writable: canWrite(filePath),
parseError: error?.message ?? String(error),
};
@@ -325,6 +229,8 @@ export function discoverExtensionToken(): string | null {
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'),
@@ -332,6 +238,8 @@ export function discoverExtensionToken(): string | null {
} 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'),
);
@@ -339,6 +247,8 @@ export function discoverExtensionToken(): string | null {
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'),
);
}
@@ -451,6 +361,8 @@ export function checkExtensionInstalled(): { installed: boolean; browsers: strin
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') },
@@ -458,6 +370,8 @@ export function checkExtensionInstalled(): { installed: boolean; browsers: strin
} 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') },
);
@@ -465,6 +379,8 @@ export function checkExtensionInstalled(): { installed: boolean; browsers: strin
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') },
);
}
@@ -506,10 +422,10 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
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 };
if (!fileExists(filePath)) return { path: filePath, exists: false, token: null };
const content = fs.readFileSync(filePath, 'utf-8');
const token = readTokenFromShellContent(content);
return { path: filePath, exists: true, token, fingerprint: getTokenFingerprint(token ?? undefined) };
return { path: filePath, exists: true, token };
});
const configPaths = opts.configPaths?.length ? opts.configPaths : getDefaultMcpConfigPaths();
const configs = configPaths.map(readConfigStatus);
@@ -539,44 +455,29 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
const report: DoctorReport = {
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),
connectivity,
warnings: [],
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 (!extInstall.installed) report.issues.push('OpenCLI MCP Bridge extension is not installed in any browser.');
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`), ''];
const installStatus: ReportStatus = report.extensionInstalled ? 'OK' : 'MISSING';
@@ -585,17 +486,17 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
: 'Extension not installed in any browser';
lines.push(statusLine(installStatus, installDetail));
const extStatus: ReportStatus = !report.extensionToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
lines.push(statusLine(extStatus, `Extension token (Chrome LevelDB): ${tokenSummary(report.extensionToken, report.extensionFingerprint)}`));
const extStatus: ReportStatus = 'OK';
lines.push(statusLine(extStatus, `Extension token (Chrome LevelDB): ${tokenSummary(report.extensionToken)}`));
const envStatus: ReportStatus = !report.envToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
lines.push(statusLine(envStatus, `Environment token: ${tokenSummary(report.envToken, report.envFingerprint)}`));
const envStatus: ReportStatus = 'OK';
lines.push(statusLine(envStatus, `Environment token: ${tokenSummary(report.envToken)}`));
for (const shell of report.shellFiles) {
const shellStatus: ReportStatus = !shell.token ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
const shellStatus: ReportStatus = '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)}`));
lines.push(statusLine(shellStatus, `${shortenPath(shell.path)}${suffix}: ${tokenSummary(shell.token)}`));
}
const existingConfigs = report.configs.filter(config => config.exists);
const missingConfigCount = report.configs.length - existingConfigs.length;
@@ -606,12 +507,10 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
? 'WARN'
: !config.token
? 'MISSING'
: hasMismatch
? 'MISMATCH'
: 'OK';
: '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}`));
lines.push(statusLine(configStatus, `${shortenPath(config.path)}${suffix}: ${tokenSummary(config.token)}${parseSuffix}`));
}
} else {
lines.push(statusLine('MISSING', 'MCP config: no existing config files found'));
@@ -631,8 +530,8 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
}
lines.push(statusLine(
hasMismatch ? 'MISMATCH' : report.recommendedToken ? 'OK' : 'WARN',
`Recommended token fingerprint: ${report.recommendedFingerprint ?? 'unavailable'}`,
'OK',
`Token Configuration: Not required for OpenCLI MCP`,
));
if (report.issues.length) {
lines.push('', chalk.yellow('Issues:'));
@@ -661,48 +560,6 @@ export function writeFileWithMkdir(filePath: string, content: string): void {
}
export async function applyBrowserDoctorFix(report: DoctorReport, opts: DoctorOptions = {}): Promise<string[]> {
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;
console.log(chalk.green('OpenCLI MCP Bridge does not require token configuration!'));
return [];
}
+9 -4
View File
@@ -14,6 +14,8 @@ import yaml from 'js-yaml';
import { type CliCommand, type InternalCliCommand, type Arg, Strategy, registerCommand } from './registry.js';
import type { IPage } from './types.js';
import { executePipeline } from './pipeline.js';
import { log } from './logger.js';
import { AdapterLoadError } from './errors.js';
/** Set of TS module paths that have been loaded */
const _loadedModules = new Set<string>();
@@ -84,7 +86,7 @@ function loadFromManifest(manifestPath: string, clisDir: string): void {
}
}
} catch (err: any) {
process.stderr.write(`Warning: failed to load manifest ${manifestPath}: ${err.message}\n`);
log.warn(`Failed to load manifest ${manifestPath}: ${err.message}`);
}
}
@@ -107,7 +109,7 @@ async function discoverClisFromFs(dir: string): Promise<void> {
) {
promises.push(
import(`file://${filePath}`).catch((err: any) => {
process.stderr.write(`Warning: failed to load module ${filePath}: ${err.message}\n`);
log.warn(`Failed to load module ${filePath}: ${err.message}`);
})
);
}
@@ -158,7 +160,7 @@ function registerYamlCli(filePath: string, defaultSite: string): void {
registerCommand(cmd);
} catch (err: any) {
process.stderr.write(`Warning: failed to load ${filePath}: ${err.message}\n`);
log.warn(`Failed to load ${filePath}: ${err.message}`);
}
}
@@ -180,7 +182,10 @@ export async function executeCommand(
await import(`file://${modulePath}`);
_loadedModules.add(modulePath);
} catch (err: any) {
throw new Error(`Failed to load adapter module ${modulePath}: ${err.message}`);
throw new AdapterLoadError(
`Failed to load adapter module ${modulePath}: ${err.message}`,
'Check that the adapter file exists and has no syntax errors.',
);
}
}
// After loading, the module's cli() call will have updated the registry
+48
View File
@@ -0,0 +1,48 @@
/**
* Unified error types for opencli.
*
* All errors thrown by the framework should extend CliError so that
* the top-level handler in main.ts can render consistent, helpful output.
*/
export class CliError extends Error {
/** Machine-readable error code (e.g. 'BROWSER_CONNECT', 'ADAPTER_LOAD') */
readonly code: string;
/** Human-readable hint on how to fix the problem */
readonly hint?: string;
constructor(code: string, message: string, hint?: string) {
super(message);
this.name = 'CliError';
this.code = code;
this.hint = hint;
}
}
export class BrowserConnectError extends CliError {
constructor(message: string, hint?: string) {
super('BROWSER_CONNECT', message, hint);
this.name = 'BrowserConnectError';
}
}
export class AdapterLoadError extends CliError {
constructor(message: string, hint?: string) {
super('ADAPTER_LOAD', message, hint);
this.name = 'AdapterLoadError';
}
}
export class CommandExecutionError extends CliError {
constructor(message: string, hint?: string) {
super('COMMAND_EXEC', message, hint);
this.name = 'CommandExecutionError';
}
}
export class ConfigError extends CliError {
constructor(message: string, hint?: string) {
super('CONFIG', message, hint);
this.name = 'ConfigError';
}
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Unified logging for opencli.
*
* All framework output (warnings, debug info, errors) should go through
* this module so that verbosity levels are respected consistently.
*/
import chalk from 'chalk';
function isVerbose(): boolean {
return !!process.env.OPENCLI_VERBOSE;
}
function isDebug(): boolean {
return !!process.env.DEBUG?.includes('opencli');
}
export const log = {
/** Informational message (always shown) */
info(msg: string): void {
process.stderr.write(`${chalk.blue('')} ${msg}\n`);
},
/** Warning (always shown) */
warn(msg: string): void {
process.stderr.write(`${chalk.yellow('⚠')} ${msg}\n`);
},
/** Error (always shown) */
error(msg: string): void {
process.stderr.write(`${chalk.red('✖')} ${msg}\n`);
},
/** Verbose output (only when OPENCLI_VERBOSE is set or -v flag) */
verbose(msg: string): void {
if (isVerbose()) {
process.stderr.write(`${chalk.dim('[verbose]')} ${msg}\n`);
}
},
/** Debug output (only when DEBUG includes 'opencli') */
debug(msg: string): void {
if (isDebug()) {
process.stderr.write(`${chalk.dim('[debug]')} ${msg}\n`);
}
},
/** Step-style debug (for pipeline steps, etc.) */
step(stepNum: number, total: number, op: string, preview: string = ''): void {
process.stderr.write(` ${chalk.dim(`[${stepNum}/${total}]`)} ${chalk.bold.cyan(op)}${preview}\n`);
},
/** Step result summary */
stepResult(summary: string): void {
process.stderr.write(` ${chalk.dim(`${summary}`)}\n`);
},
};
+39 -3
View File
@@ -11,9 +11,11 @@ import chalk from 'chalk';
import { discoverClis, executeCommand } from './engine.js';
import { type CliCommand, fullName, getRegistry, strategyLabel } from './registry.js';
import { render as renderOutput } from './output.js';
import { PlaywrightMCP } from './browser.js';
import { PlaywrightMCP } from './browser/index.js';
import { browserSession, DEFAULT_BROWSER_COMMAND_TIMEOUT, runWithTimeout } from './runtime.js';
import { PKG_VERSION } from './version.js';
import { getCompletions, printCompletionScript } from './completion.js';
import { CliError } from './errors.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -22,6 +24,27 @@ const USER_CLIS = path.join(os.homedir(), '.opencli', 'clis');
await discoverClis(BUILTIN_CLIS, USER_CLIS);
// ── Fast-path: handle --get-completions before commander parses ─────────
// Usage: opencli --get-completions --cursor <N> [word1 word2 ...]
const getCompIdx = process.argv.indexOf('--get-completions');
if (getCompIdx !== -1) {
const rest = process.argv.slice(getCompIdx + 1);
let cursor: number | undefined;
const words: string[] = [];
for (let i = 0; i < rest.length; i++) {
if (rest[i] === '--cursor' && i + 1 < rest.length) {
cursor = parseInt(rest[i + 1], 10);
i++; // skip the value
} else {
words.push(rest[i]);
}
}
if (cursor === undefined) cursor = words.length;
const candidates = getCompletions(words, cursor);
process.stdout.write(candidates.join('\n') + '\n');
process.exit(0);
}
const program = new Command();
program.name('opencli').description('Make any website your CLI. Zero setup. AI-powered.').version(PKG_VERSION);
@@ -128,6 +151,13 @@ program.command('setup')
await runSetup({ cliVersion: PKG_VERSION, token: opts.token });
});
program.command('completion')
.description('Output shell completion script')
.argument('<shell>', 'Shell type: bash, zsh, or fish')
.action((shell) => {
printCompletionScript(shell);
});
// ── Dynamic site commands ──────────────────────────────────────────────────
const registry = getRegistry();
@@ -183,8 +213,14 @@ for (const [, cmd] of registry) {
}
renderOutput(result, { fmt: actionOpts.format, columns: cmd.columns, title: `${cmd.site}/${cmd.name}`, elapsed: (Date.now() - startTime) / 1000, source: fullName(cmd) });
} catch (err: any) {
if (actionOpts.verbose && err.stack) { console.error(chalk.red(err.stack)); }
else { console.error(chalk.red(`Error: ${err.message ?? err}`)); }
if (err instanceof CliError) {
console.error(chalk.red(`Error [${err.code}]: ${err.message}`));
if (err.hint) console.error(chalk.yellow(`Hint: ${err.hint}`));
} else if (actionOpts.verbose && err.stack) {
console.error(chalk.red(err.stack));
} else {
console.error(chalk.red(`Error: ${err.message ?? err}`));
}
process.exitCode = 1;
}
});
+8 -7
View File
@@ -9,6 +9,7 @@ import { stepFetch } from './steps/fetch.js';
import { stepSelect, stepMap, stepFilter, stepSort, stepLimit } from './steps/transform.js';
import { stepIntercept } from './steps/intercept.js';
import { stepTap } from './steps/tap.js';
import { log } from '../logger.js';
export interface PipelineContext {
args?: Record<string, any>;
@@ -57,7 +58,7 @@ export async function executePipeline(
if (handler) {
data = await handler(page, params, data, args);
} else {
if (debug) process.stderr.write(` ${chalk.yellow('⚠')} Unknown step: ${op}\n`);
if (debug) log.warn(`Unknown step: ${op}`);
}
if (debug) debugStepResult(op, data);
@@ -73,21 +74,21 @@ function debugStepStart(stepNum: number, total: number, op: string, params: any)
} else if (params && typeof params === 'object' && !Array.isArray(params)) {
preview = ` (${Object.keys(params).join(', ')})`;
}
process.stderr.write(` ${chalk.dim(`[${stepNum}/${total}]`)} ${chalk.bold.cyan(op)}${preview}\n`);
log.step(stepNum, total, op, preview);
}
function debugStepResult(op: string, data: any): void {
if (data === null || data === undefined) {
process.stderr.write(` ${chalk.dim('(no data)')}\n`);
log.stepResult('(no data)');
} else if (Array.isArray(data)) {
process.stderr.write(` ${chalk.dim(`${data.length} items`)}\n`);
log.stepResult(`${data.length} items`);
} else if (typeof data === 'object') {
const keys = Object.keys(data).slice(0, 5);
process.stderr.write(` ${chalk.dim(`dict (${keys.join(', ')}${Object.keys(data).length > 5 ? '...' : ''})`)}\n`);
log.stepResult(`dict (${keys.join(', ')}${Object.keys(data).length > 5 ? '...' : ''})`);
} else if (typeof data === 'string') {
const p = data.slice(0, 60).replace(/\n/g, '\\n');
process.stderr.write(` ${chalk.dim(`"${p}${data.length > 60 ? '...' : ''}"`)}\n`);
log.stepResult(`"${p}${data.length > 60 ? '...' : ''}"`);
} else {
process.stderr.write(` ${chalk.dim(`${typeof data}`)}\n`);
log.stepResult(`${typeof data}`);
}
}
+18 -18
View File
@@ -6,53 +6,53 @@
import type { IPage } from '../../types.js';
import { render, normalizeEvaluateSource } from '../template.js';
export async function stepNavigate(page: IPage, params: any, data: any, args: Record<string, any>): Promise<any> {
export async function stepNavigate(page: IPage | null, params: any, data: any, args: Record<string, any>): Promise<any> {
const url = render(params, { args, data });
await page.goto(String(url));
await page!.goto(String(url));
return data;
}
export async function stepClick(page: IPage, params: any, data: any, args: Record<string, any>): Promise<any> {
await page.click(String(render(params, { args, data })).replace(/^@/, ''));
export async function stepClick(page: IPage | null, params: any, data: any, args: Record<string, any>): Promise<any> {
await page!.click(String(render(params, { args, data })).replace(/^@/, ''));
return data;
}
export async function stepType(page: IPage, params: any, data: any, args: Record<string, any>): Promise<any> {
export async function stepType(page: IPage | null, params: any, data: any, args: Record<string, any>): Promise<any> {
if (typeof params === 'object' && params) {
const ref = String(render(params.ref ?? '', { args, data })).replace(/^@/, '');
const text = String(render(params.text ?? '', { args, data }));
await page.typeText(ref, text);
if (params.submit) await page.pressKey('Enter');
await page!.typeText(ref, text);
if (params.submit) await page!.pressKey('Enter');
}
return data;
}
export async function stepWait(page: IPage, params: any, data: any, args: Record<string, any>): Promise<any> {
if (typeof params === 'number') await page.wait(params);
export async function stepWait(page: IPage | null, params: any, data: any, args: Record<string, any>): Promise<any> {
if (typeof params === 'number') await page!.wait(params);
else if (typeof params === 'object' && params) {
if ('text' in params) {
await page.wait({
await page!.wait({
text: String(render(params.text, { args, data })),
timeout: params.timeout
});
} else if ('time' in params) await page.wait(Number(params.time));
} else if (typeof params === 'string') await page.wait(Number(render(params, { args, data })));
} else if ('time' in params) await page!.wait(Number(params.time));
} else if (typeof params === 'string') await page!.wait(Number(render(params, { args, data })));
return data;
}
export async function stepPress(page: IPage, params: any, data: any, args: Record<string, any>): Promise<any> {
await page.pressKey(String(render(params, { args, data })));
export async function stepPress(page: IPage | null, params: any, data: any, args: Record<string, any>): Promise<any> {
await page!.pressKey(String(render(params, { args, data })));
return data;
}
export async function stepSnapshot(page: IPage, params: any, _data: any, _args: Record<string, any>): Promise<any> {
export async function stepSnapshot(page: IPage | null, params: any, _data: any, _args: Record<string, any>): Promise<any> {
const opts = (typeof params === 'object' && params) ? params : {};
return page.snapshot({ interactive: opts.interactive ?? false, compact: opts.compact ?? false, maxDepth: opts.max_depth, raw: opts.raw ?? false });
return page!.snapshot({ interactive: opts.interactive ?? false, compact: opts.compact ?? false, maxDepth: opts.max_depth, raw: opts.raw ?? false });
}
export async function stepEvaluate(page: IPage, params: any, data: any, args: Record<string, any>): Promise<any> {
export async function stepEvaluate(page: IPage | null, params: any, data: any, args: Record<string, any>): Promise<any> {
const js = String(render(params, { args, data }));
let result = await page.evaluate(normalizeEvaluateSource(js));
let result = await page!.evaluate(normalizeEvaluateSource(js));
// MCP may return JSON as a string — auto-parse it
if (typeof result === 'string') {
const trimmed = result.trim();
+8 -8
View File
@@ -6,7 +6,7 @@ import type { IPage } from '../../types.js';
import { render } from '../template.js';
import { generateInterceptorJs, generateReadInterceptedJs } from '../../interceptor.js';
export async function stepIntercept(page: IPage, params: any, data: any, args: Record<string, any>): Promise<any> {
export async function stepIntercept(page: IPage | null, params: any, data: any, args: Record<string, any>): Promise<any> {
const cfg = typeof params === 'object' ? params : {};
const trigger = cfg.trigger ?? '';
const capturePattern = cfg.capture ?? '';
@@ -16,28 +16,28 @@ export async function stepIntercept(page: IPage, params: any, data: any, args: R
if (!capturePattern) return data;
// Step 1: Inject fetch/XHR interceptor BEFORE trigger
await page.evaluate(generateInterceptorJs(JSON.stringify(capturePattern)));
await page!.evaluate(generateInterceptorJs(JSON.stringify(capturePattern)));
// Step 2: Execute the trigger action
if (trigger.startsWith('navigate:')) {
const url = render(trigger.slice('navigate:'.length), { args, data });
await page.goto(String(url));
await page!.goto(String(url));
} else if (trigger.startsWith('evaluate:')) {
const js = trigger.slice('evaluate:'.length);
const { normalizeEvaluateSource } = await import('../template.js');
await page.evaluate(normalizeEvaluateSource(render(js, { args, data }) as string));
await page!.evaluate(normalizeEvaluateSource(render(js, { args, data }) as string));
} else if (trigger.startsWith('click:')) {
const ref = render(trigger.slice('click:'.length), { args, data });
await page.click(String(ref).replace(/^@/, ''));
await page!.click(String(ref).replace(/^@/, ''));
} else if (trigger === 'scroll') {
await page.scroll('down');
await page!.scroll('down');
}
// Step 3: Wait a bit for network requests to fire
await page.wait(Math.min(timeout, 3));
await page!.wait(Math.min(timeout, 3));
// Step 4: Retrieve captured data
const matchingResponses = await page.evaluate(generateReadInterceptedJs());
const matchingResponses = await page!.evaluate(generateReadInterceptedJs());
// Step 5: Select from response if specified
let result = matchingResponses.length === 1 ? matchingResponses[0] :
+2 -2
View File
@@ -13,7 +13,7 @@ import type { IPage } from '../../types.js';
import { render } from '../template.js';
import { generateTapInterceptorJs } from '../../interceptor.js';
export async function stepTap(page: IPage, params: any, data: any, args: Record<string, any>): Promise<any> {
export async function stepTap(page: IPage | null, params: any, data: any, args: Record<string, any>): Promise<any> {
const cfg = typeof params === 'object' ? params : {};
const storeName = String(render(cfg.store ?? '', { args, data }));
const actionName = String(render(cfg.action ?? '', { args, data }));
@@ -96,5 +96,5 @@ export async function stepTap(page: IPage, params: any, data: any, args: Record<
}
`;
return page.evaluate(js);
return page!.evaluate(js);
}
+15 -178
View File
@@ -1,183 +1,21 @@
/**
* setup.ts Interactive Playwright MCP token setup
* setup.ts OpenCLI MCP token setup
*
* Discovers the extension token, shows an interactive checkbox
* for selecting which config files to update, and applies changes.
* OpenCLI MCP is now tokenless. This file simply informs the user
* that token configuration is no longer required.
*/
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.js';
import { type CheckboxItem, checkboxPrompt } from './tui.js';
import { checkTokenConnectivity } from './doctor.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(' — OpenCLI MCP configuration'));
console.log();
console.log(` ${chalk.green('✓')} Configuration complete.`);
console.log(` ${chalk.dim('OpenCLI MCP Bridge no longer requires token configuration.')}`);
console.log();
// Step 1: Discover token
let token = opts.token ?? null;
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)})`));
}
} else {
console.log(` ${chalk.green('✓')} Using provided token ` +
chalk.dim(`(${getTokenFingerprint(token)})`));
}
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;
}
}
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'));
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.`));
}
} else {
console.log(chalk.yellow(' No files were changed.'));
}
console.log();
// Step 7: Auto-verify browser connectivity
// Auto-verify browser connectivity
console.log(chalk.dim(' Verifying browser connectivity...'));
try {
const result = await checkTokenConnectivity({ timeout: 5 });
@@ -185,15 +23,14 @@ export async function runSetup(opts: { cliVersion?: string; token?: string } = {
console.log(` ${chalk.green('✓')} Browser connected in ${(result.durationMs / 1000).toFixed(1)}s`);
} else {
console.log(` ${chalk.yellow('!')} Browser connectivity test failed: ${result.error ?? 'unknown'}`);
console.log(chalk.dim(' Make sure Chrome is running with the extension enabled.'));
console.log(chalk.dim(' To use opencli, make sure Chrome is running with Developer Mode'));
console.log(chalk.dim(' and the OpenCLI MCP Bridge extension is enabled.'));
console.log(chalk.dim(` Run ${chalk.bold('opencli doctor --live')} to re-test connectivity.`));
}
} catch {
console.log(` ${chalk.yellow('!')} Could not verify connectivity (Chrome may not be running)`);
console.log(` ${chalk.yellow('!')} Browser connectivity test skipped (Chrome may not be running).`);
console.log(chalk.dim(' Start Chrome to begin using opencli.'));
console.log(chalk.dim(` Run ${chalk.bold('opencli doctor --live')} to re-test connectivity.`));
}
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);
}
+1 -2
View File
@@ -5,8 +5,7 @@
"moduleResolution": "Node16",
"outDir": "dist",
"rootDir": "src",
"strict": false,
"strictNullChecks": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
+2
View File
@@ -7,6 +7,7 @@ export default defineConfig({
test: {
name: 'unit',
include: ['src/**/*.test.ts'],
sequence: { groupOrder: 1 },
},
},
{
@@ -14,6 +15,7 @@ export default defineConfig({
name: 'e2e',
include: ['tests/**/*.test.ts'],
maxWorkers: 2,
sequence: { groupOrder: 2 },
},
},
],