Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f998e4ff0 | |||
| 36b06e9f32 | |||
| f0273f94bb | |||
| 4c8c6e8be7 | |||
| 7b5bdfa7d5 | |||
| 546c0b997a | |||
| 1e34e7e6d3 | |||
| 9ae9eb3fc6 | |||
| 612c0ab1af | |||
| 68840fc85c | |||
| 8263a06a85 | |||
| eb2c3fdf89 | |||
| 43ed0ace59 | |||
| de962eb5fb |
@@ -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)
|
||||
- **认证方式**:Cookie?API Key?OAuth?浏览器自动化?
|
||||
- **数据源**:公开 API?GraphQL?页面抓取?
|
||||
- **输出字段**:每个命令返回哪些数据字段
|
||||
|
||||
### 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 Reddit(2026-03-16)
|
||||
|
||||
- **源项目**: `rdt-cli`(25 个 Python 命令)
|
||||
- **筛选结果**: 13 个高价值命令
|
||||
- **实现**: 7 个 YAML(read) + 6 个 TS(write)
|
||||
- **产出**: +11 文件,+767 行代码,Reddit 适配器从 4 → 15(+275%)
|
||||
|
||||
### twitter-cli → opencli Twitter(2026-03-16)
|
||||
|
||||
- **源项目**: `twitter-cli`(20+ Python 命令)
|
||||
- **筛选结果**: 11 个待实现
|
||||
- **策略**: Read 用 `Strategy.COOKIE` + GraphQL fetch,Write 用 `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
|
||||
```
|
||||
Generated
+25
-68
@@ -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
@@ -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"
|
||||
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 || [];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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]
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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] :
|
||||
|
||||
@@ -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
@@ -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
@@ -5,8 +5,7 @@
|
||||
"moduleResolution": "Node16",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": false,
|
||||
"strictNullChecks": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user