Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b19b3ebe9 | |||
| d60e1cf43d | |||
| 1cd1253d46 | |||
| 7869bdb2ca | |||
| 2e93ac6e63 | |||
| de0d74bf62 | |||
| a9e0ca648f | |||
| bebc7aa35e | |||
| 061fba100d | |||
| e364ec6b9c | |||
| 765eb56c99 | |||
| 5f72770eff | |||
| 3017ca78aa | |||
| 7e68e19f0d | |||
| 4ceb3314fe | |||
| 5f0cce7b22 | |||
| 284c96133b | |||
| eac17b361e | |||
| aa33262ef7 | |||
| a0b2df1448 | |||
| fc7245f9f6 | |||
| 88bcd814ee | |||
| 2fd7272559 |
@@ -26,6 +26,17 @@ jobs:
|
||||
- name: Type check
|
||||
run: npx tsc --noEmit
|
||||
|
||||
# Build before the manifest drift gate: adapter modules import
|
||||
# @jackwener/opencli/* through package exports, which resolve to dist/.
|
||||
# A fresh release checkout has no dist/ until the full build runs.
|
||||
- name: Build package and verify cli-manifest.json is up-to-date
|
||||
run: |
|
||||
npm run build
|
||||
if ! git diff --exit-code -- cli-manifest.json; then
|
||||
echo "::error::cli-manifest.json drift detected at release time. Run 'npm run build' locally and commit the result before tagging."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install extension dependencies
|
||||
run: npm ci
|
||||
working-directory: extension
|
||||
@@ -40,7 +51,7 @@ jobs:
|
||||
|
||||
- name: Create extension ZIP
|
||||
run: |
|
||||
EXT_VERSION=$(node -p "require('./extension/package.json').version")
|
||||
EXT_VERSION=$(jq -r .version extension/package.json)
|
||||
cd extension-package
|
||||
zip -r ../opencli-extension-v${EXT_VERSION}.zip .
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
### Features
|
||||
|
||||
* **observation** — add trace artifact primitives, `browser console`, `browser network --since/--follow/--failed`, and adapter `--trace=retain-on-failure` for failure-retained browser evidence.
|
||||
* **autofix** — retire `OPENCLI_DIAGNOSTIC`; adapter repair now uses `--trace retain-on-failure`, trace `summary.md`, and error-envelope trace metadata.
|
||||
* **browser** — `bind` attaches `bound:*` workspaces to user-owned Chrome tabs without taking over window lifecycle; `sessions` reports `idleMsRemaining: null` for bound workspaces because they do not schedule idle close timers. ([#1169](https://github.com/jackwener/opencli/issues/1169), [#929](https://github.com/jackwener/opencli/issues/929))
|
||||
* **browser lifecycle** — owned browser workspaces now lease tabs inside a shared dedicated automation container instead of owning one Chrome window per workspace; lease state is persisted for MV3 service-worker reconciliation and idle cleanup is backed by alarms.
|
||||
* **web read** — make page extraction render-aware: same-origin iframe content is merged into the Markdown source, `--wait-for` can wait inside main/iframe documents, `--wait-until networkidle` waits for captured requests to settle, and `--diagnose` reports frames, empty containers, and API-like XHRs for shell/AJAX pages.
|
||||
|
||||
@@ -21,7 +21,7 @@ It also works as a **CLI hub** for local tools such as `gh`, `docker`, and other
|
||||
- **Desktop App Control** — Drive Electron apps (Cursor, Codex, ChatGPT, Notion, etc.) directly from the terminal via CDP.
|
||||
- **Browser Automation for AI Agents** — Install the `opencli-adapter-author` skill, and your AI agent can operate any website: navigate, click, type, extract, screenshot — all through your logged-in Chrome session.
|
||||
- **Multi-profile Browser Bridge** — Install the extension in each Chrome profile you want to use, then route commands with `--profile`, `OPENCLI_PROFILE`, or `opencli profile use`.
|
||||
- **Website → CLI** — Turn any website into a deterministic CLI: 90+ pre-built adapters, or write your own with the `opencli-adapter-author` skill + `opencli browser verify`.
|
||||
- **Website → CLI** — Turn any website into a deterministic CLI: 100+ site surfaces are already registered, or write your own with the `opencli-adapter-author` skill + `opencli browser verify`.
|
||||
- **Account-safe** — Reuses Chrome/Chromium logged-in state; your credentials never leave the browser.
|
||||
- **AI Agent ready** — One skill takes you from site recon through API discovery, field decoding, adapter writing, and verification.
|
||||
- **CLI Hub** — Discover, auto-install, and passthrough commands to any external CLI (gh, docker, obsidian, etc).
|
||||
@@ -89,6 +89,18 @@ Use OpenCLI directly when you want a reliable command instead of a live browser
|
||||
- `opencli external register mycli` exposes a local CLI through the same discovery surface.
|
||||
- `opencli doctor` helps diagnose browser connectivity.
|
||||
|
||||
## Extending OpenCLI
|
||||
|
||||
If you want to add your own commands, start with the [Extending OpenCLI guide](./docs/guide/extending-opencli.md). README keeps this short; the guide covers the directory layout, source-control model, and install commands.
|
||||
|
||||
| Need | Recommended path |
|
||||
|------|------------------|
|
||||
| Keep personal website commands in your own Git repo | `opencli plugin create` + `opencli plugin install file://...` |
|
||||
| Quickly draft a private local adapter | `opencli browser init <site>/<command>` in `~/.opencli/clis/` |
|
||||
| Modify an official adapter locally | `opencli adapter eject <site>` + `opencli adapter reset <site>` |
|
||||
| Publish or install third-party commands | `opencli plugin install github:user/repo` |
|
||||
| Wrap an existing local binary | `opencli external register <name>` |
|
||||
|
||||
## For AI Agents
|
||||
|
||||
OpenCLI's browser commands are designed to be used by AI Agents — not run manually. Install skills into your AI agent (Claude Code, Cursor, etc.), and the agent operates websites on your behalf using your logged-in Chrome session.
|
||||
@@ -162,7 +174,7 @@ When the site you need is not yet covered, use the `opencli-adapter-author` skil
|
||||
2. Discover the right endpoint — network inspection, initial state, bundle search, token trace, or interceptor fallback.
|
||||
3. Decide the auth strategy — `PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`.
|
||||
4. Decode response fields and design output columns.
|
||||
5. `opencli browser init <site>/<name>` → write adapter → `opencli browser verify <site>/<name>`.
|
||||
5. `opencli browser analyze <url>` for one-shot recon, then `opencli browser init <site>/<name>` → write adapter → `opencli browser verify <site>/<name>`.
|
||||
6. Persist site knowledge to `~/.opencli/sites/<site>/` so the next adapter for the same site is faster.
|
||||
|
||||
### CLI Hub and desktop adapters
|
||||
@@ -193,7 +205,6 @@ OpenCLI is not only for websites. It can also:
|
||||
| `OPENCLI_CDP_ENDPOINT` | — | Chrome DevTools Protocol endpoint for remote browser or Electron apps |
|
||||
| `OPENCLI_CDP_TARGET` | — | Filter CDP targets by URL substring (e.g. `detail.1688.com`) |
|
||||
| `OPENCLI_VERBOSE` | `false` | Enable verbose logging (`-v` flag also works) |
|
||||
| `OPENCLI_DIAGNOSTIC` | `false` | Set to `1` to capture structured diagnostic context on failures |
|
||||
| `DEBUG_SNAPSHOT` | — | Set to `1` for DOM snapshot debug output |
|
||||
|
||||
`--focus` works for both `opencli browser *` and browser-backed adapter commands. `--live` is mainly for adapter commands: browser subcommands already keep the automation lease open until you run `opencli browser close` or the idle timeout expires.
|
||||
@@ -249,6 +260,7 @@ To load the source Browser Bridge extension:
|
||||
| **1688** | `search` `item` `assets` `download` `store` |
|
||||
| **gitee** | `trending` `search` `user` |
|
||||
| **gemini** | `new` `ask` `image` `deep-research` `deep-research-result` |
|
||||
| **claude** | `ask` `send` `new` `status` `read` `history` `detail` |
|
||||
| **yuanbao** | `new` `ask` |
|
||||
| **notebooklm** | `status` `list` `open` `current` `get` `history` `summary` `note-list` `notes-get` `source-list` `source-get` `source-fulltext` `source-guide` |
|
||||
| **spotify** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` |
|
||||
@@ -265,7 +277,7 @@ To load the source Browser Bridge extension:
|
||||
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` |
|
||||
| **xiaoyuzhou** | `auth*` `podcast*` `podcast-episodes*` `episode*` `download*` `transcript*` |
|
||||
|
||||
90+ adapters in total — **[→ see all supported sites & commands](./docs/adapters/index.md)**
|
||||
100+ site surfaces in total — **[→ see all supported sites & commands](./docs/adapters/index.md)**
|
||||
|
||||
`*` `opencli xiaoyuzhou podcast`, `podcast-episodes`, `episode`, `download`, and `transcript` require local Xiaoyuzhou credentials in `~/.opencli/xiaoyuzhou.json`.
|
||||
|
||||
@@ -395,10 +407,10 @@ Before writing any adapter code, read the [`opencli-adapter-author` skill](./ski
|
||||
- Recon the site and pick a pattern (SPA / SSR / JSONP / Token / Streaming).
|
||||
- Discover the right endpoint via `opencli browser network`, `eval`, or the interceptor fallback.
|
||||
- Decide auth strategy (`PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`).
|
||||
- Decode response fields, design columns, scaffold with `opencli browser init`.
|
||||
- Run `opencli browser analyze <url>` for one-shot recon, decode response fields, design columns, scaffold with `opencli browser init`.
|
||||
- Verify with `opencli browser verify <site>/<name>` before shipping.
|
||||
|
||||
Adapters you write outside the repo live at `~/.opencli/clis/<site>/<name>.js`. Site knowledge (endpoints, field maps, fixtures) accumulates in `~/.opencli/sites/<site>/` so the next adapter for the same site starts from context instead of zero.
|
||||
For long-lived personal commands that should live in your own Git repo, use a local plugin instead; see [Extending OpenCLI](./docs/guide/extending-opencli.md). Quick private adapters can still live at `~/.opencli/clis/<site>/<name>.js`. Site knowledge (endpoints, field maps, fixtures) accumulates in `~/.opencli/sites/<site>/` so the next adapter for the same site starts from context instead of zero.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
+19
-7
@@ -10,7 +10,7 @@
|
||||
|
||||
OpenCLI 可以用同一套 CLI 做三类事情:
|
||||
|
||||
- **直接使用现成适配器**:B站、知乎、小红书、Twitter/X、Reddit、HackerNews 等 [90+ 站点](#内置命令) 开箱即用。
|
||||
- **直接使用现成适配器**:B站、知乎、小红书、Twitter/X、Reddit、HackerNews 等 [100+ 站点](#内置命令) 开箱即用。
|
||||
- **让 AI Agent 操作任意网站**:在你的 AI Agent(Claude Code、Cursor 等)中安装 `opencli-adapter-author` skill,Agent 就能用你的已登录浏览器导航、点击、输入、提取任意网页内容。
|
||||
- **把新网站写成 CLI**:用 `opencli browser` 原语 + `opencli-adapter-author` skill,从站点侦察、API 发现、字段解码到 `opencli browser verify` 一条龙。
|
||||
|
||||
@@ -20,7 +20,7 @@ OpenCLI 可以用同一套 CLI 做三类事情:
|
||||
|
||||
- **桌面应用控制** — 通过 CDP 直接在终端驱动 Electron 应用(Cursor、Codex、ChatGPT、Notion 等)。
|
||||
- **AI Agent 浏览器自动化** — 安装 `opencli-adapter-author` skill,你的 AI Agent 就能操作任意网站:导航、点击、输入、提取、截图——全部通过你的已登录 Chrome 会话完成。
|
||||
- **网站 → CLI** — 把任何网站变成确定性 CLI:90+ 内置适配器,或用 `opencli-adapter-author` skill + `opencli browser verify` 自己写。
|
||||
- **网站 → CLI** — 把任何网站变成确定性 CLI:100+ 站点能力已注册,或用 `opencli-adapter-author` skill + `opencli browser verify` 自己写。
|
||||
- **账号安全** — 复用 Chrome/Chromium 登录态,凭证永远不会离开浏览器。
|
||||
- **面向 AI Agent** — 一个 skill 带你走完站点侦察、API 发现、字段解码、适配器编写、验证的全流程。
|
||||
- **CLI 枢纽** — 统一发现、自动安装、纯透传任何外部 CLI(gh、docker、obsidian 等)。
|
||||
@@ -70,9 +70,21 @@ opencli bilibili hot --limit 5
|
||||
|
||||
- `opencli list` 查看当前所有命令
|
||||
- `opencli <site> <command>` 调用内置或生成好的适配器
|
||||
- `opencli register mycli` 把本地 CLI 接入同一发现入口
|
||||
- `opencli external register mycli` 把本地 CLI 接入同一发现入口
|
||||
- `opencli doctor` 处理浏览器连通性问题
|
||||
|
||||
## 扩展 OpenCLI
|
||||
|
||||
如果你想新增自己的命令,先看 [扩展 OpenCLI](./docs/zh/guide/extending-opencli.md)。README 只保留入口;目录结构、源码管理方式和安装命令放在文档里。
|
||||
|
||||
| 需求 | 推荐路径 |
|
||||
|------|----------|
|
||||
| 把个人网站命令放在自己的 Git repo | `opencli plugin create` + `opencli plugin install file://...` |
|
||||
| 快速写一个本机私人 adapter | `opencli browser init <site>/<command>`,放在 `~/.opencli/clis/` |
|
||||
| 本地修改官方 adapter | `opencli adapter eject <site>` + `opencli adapter reset <site>` |
|
||||
| 发布或安装第三方命令 | `opencli plugin install github:user/repo` |
|
||||
| 包装已有本机 binary | `opencli external register <name>` |
|
||||
|
||||
## 给 AI Agent
|
||||
|
||||
OpenCLI 的 browser 命令是给 AI Agent 用的——不是手动执行的。把 skill 安装到你的 AI Agent(Claude Code、Cursor 等)中,Agent 就能用你的已登录 Chrome 会话替你操作网站。
|
||||
@@ -146,7 +158,7 @@ Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自
|
||||
2. 发现目标 endpoint——network 精读、initial state、bundle 搜索、token 溯源,或 interceptor 兜底
|
||||
3. 定认证策略——`PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`
|
||||
4. 字段解码 + 设计输出列
|
||||
5. `opencli browser init <site>/<name>` → 写适配器 → `opencli browser verify <site>/<name>`
|
||||
5. `opencli browser analyze <url>` 一步侦察,再 `opencli browser init <site>/<name>` → 写适配器 → `opencli browser verify <site>/<name>`
|
||||
6. 把站点知识沉到 `~/.opencli/sites/<site>/`,下次写同站点的其他命令直接吃缓存
|
||||
|
||||
### CLI 枢纽与桌面端适配器
|
||||
@@ -176,7 +188,6 @@ OpenCLI 不只是网站 CLI,还可以:
|
||||
| `OPENCLI_CDP_ENDPOINT` | — | Chrome DevTools Protocol 端点,用于远程浏览器或 Electron 应用 |
|
||||
| `OPENCLI_CDP_TARGET` | — | 按 URL 子串过滤 CDP target(如 `detail.1688.com`) |
|
||||
| `OPENCLI_VERBOSE` | `false` | 启用详细日志(`-v` 也可以) |
|
||||
| `OPENCLI_DIAGNOSTIC` | `false` | 设为 `1` 时在失败时输出结构化诊断上下文 |
|
||||
| `DEBUG_SNAPSHOT` | — | 设为 `1` 输出 DOM 快照调试信息 |
|
||||
|
||||
`--focus` 同时适用于 `opencli browser *` 和浏览器型 adapter 命令。`--live` 主要是给 adapter 命令用的:`browser` 子命令本来就会一直保留 automation window,直到你手动执行 `opencli browser close` 或等空闲超时。
|
||||
@@ -289,6 +300,7 @@ npm link
|
||||
| **1688** | `search` `item` `assets` `download` `store` | 浏览器 |
|
||||
| **gitee** | `trending` `search` `user` | 公开 / 浏览器 |
|
||||
| **gemini** | `new` `ask` `image` `deep-research` `deep-research-result` | 浏览器 |
|
||||
| **claude** | `ask` `send` `new` `status` `read` `history` `detail` | 浏览器 |
|
||||
| **spotify** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` | OAuth API |
|
||||
| **notebooklm** | `status` `list` `open` `current` `get` `history` `summary` `note-list` `notes-get` `source-list` `source-get` `source-fulltext` `source-guide` | 浏览器 |
|
||||
| **36kr** | `news` `hot` `search` `article` | 公开 / 浏览器 |
|
||||
@@ -306,7 +318,7 @@ npm link
|
||||
| **douyin** | `videos` `publish` `drafts` `draft` `delete` `stats` `profile` `update` `hashtag` `location` `activities` `collections` | 浏览器 |
|
||||
| **yuanbao** | `new` `ask` | 浏览器 |
|
||||
|
||||
90+ 适配器 — **[→ 查看完整命令列表](./docs/adapters/index.md)**
|
||||
100+ 站点能力 — **[→ 查看完整命令列表](./docs/adapters/index.md)**
|
||||
|
||||
`*` `opencli xiaoyuzhou podcast`、`podcast-episodes`、`episode`、`download`、`transcript` 需要本地小宇宙凭证:`~/.opencli/xiaoyuzhou.json`。
|
||||
|
||||
@@ -493,7 +505,7 @@ opencli plugin uninstall my-tool # 卸载
|
||||
- 侦察站点,选定 pattern(SPA / SSR / JSONP / Token / Streaming)
|
||||
- 用 `opencli browser network`、`eval`、interceptor 等找到目标 endpoint
|
||||
- 定认证策略(`PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`)
|
||||
- 字段解码、设计 columns、`opencli browser init` 生成骨架
|
||||
- 先用 `opencli browser analyze <url>` 一步侦察,再字段解码、设计 columns、`opencli browser init` 生成骨架
|
||||
- 交付前用 `opencli browser verify <site>/<name>` 验证
|
||||
|
||||
在仓库外写的私有适配器放到 `~/.opencli/clis/<site>/<name>.js`;每个站点的 endpoint、字段映射、抓包样本会累积在 `~/.opencli/sites/<site>/`,下次写同站点的其他命令可以直接复用。
|
||||
|
||||
+325
-1
@@ -3859,6 +3859,208 @@
|
||||
"sourceFile": "chatwise/send.js",
|
||||
"navigateBefore": true
|
||||
},
|
||||
{
|
||||
"site": "claude",
|
||||
"name": "ask",
|
||||
"description": "Send a prompt to Claude and get the response",
|
||||
"domain": "claude.ai",
|
||||
"strategy": "cookie",
|
||||
"browser": true,
|
||||
"args": [
|
||||
{
|
||||
"name": "prompt",
|
||||
"type": "str",
|
||||
"required": true,
|
||||
"positional": true,
|
||||
"help": "Prompt to send"
|
||||
},
|
||||
{
|
||||
"name": "timeout",
|
||||
"type": "int",
|
||||
"default": 120,
|
||||
"required": false,
|
||||
"help": "Max seconds to wait for response"
|
||||
},
|
||||
{
|
||||
"name": "new",
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"required": false,
|
||||
"help": "Start a new chat before sending"
|
||||
},
|
||||
{
|
||||
"name": "model",
|
||||
"type": "str",
|
||||
"default": "sonnet",
|
||||
"required": false,
|
||||
"help": "Model to use: sonnet, opus, or haiku",
|
||||
"choices": [
|
||||
"sonnet",
|
||||
"opus",
|
||||
"haiku"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "think",
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"required": false,
|
||||
"help": "Enable Adaptive thinking"
|
||||
},
|
||||
{
|
||||
"name": "file",
|
||||
"type": "str",
|
||||
"required": false,
|
||||
"help": "Attach a file (image, PDF, text) with the prompt"
|
||||
}
|
||||
],
|
||||
"columns": [
|
||||
"response"
|
||||
],
|
||||
"timeout": 180,
|
||||
"type": "js",
|
||||
"modulePath": "claude/ask.js",
|
||||
"sourceFile": "claude/ask.js",
|
||||
"navigateBefore": false
|
||||
},
|
||||
{
|
||||
"site": "claude",
|
||||
"name": "detail",
|
||||
"description": "Open a Claude conversation by ID and read its messages",
|
||||
"domain": "claude.ai",
|
||||
"strategy": "cookie",
|
||||
"browser": true,
|
||||
"args": [
|
||||
{
|
||||
"name": "id",
|
||||
"type": "str",
|
||||
"required": true,
|
||||
"positional": true,
|
||||
"help": "Conversation ID (UUID from /chat/<id>)"
|
||||
}
|
||||
],
|
||||
"columns": [
|
||||
"Index",
|
||||
"Role",
|
||||
"Text"
|
||||
],
|
||||
"type": "js",
|
||||
"modulePath": "claude/detail.js",
|
||||
"sourceFile": "claude/detail.js",
|
||||
"navigateBefore": false
|
||||
},
|
||||
{
|
||||
"site": "claude",
|
||||
"name": "history",
|
||||
"description": "List conversation history from Claude /recents",
|
||||
"domain": "claude.ai",
|
||||
"strategy": "cookie",
|
||||
"browser": true,
|
||||
"args": [
|
||||
{
|
||||
"name": "limit",
|
||||
"type": "int",
|
||||
"default": 20,
|
||||
"required": false,
|
||||
"help": "Max conversations to show"
|
||||
}
|
||||
],
|
||||
"columns": [
|
||||
"Index",
|
||||
"Id",
|
||||
"Title",
|
||||
"Url"
|
||||
],
|
||||
"type": "js",
|
||||
"modulePath": "claude/history.js",
|
||||
"sourceFile": "claude/history.js",
|
||||
"navigateBefore": false
|
||||
},
|
||||
{
|
||||
"site": "claude",
|
||||
"name": "new",
|
||||
"description": "Start a new conversation in Claude",
|
||||
"domain": "claude.ai",
|
||||
"strategy": "cookie",
|
||||
"browser": true,
|
||||
"args": [],
|
||||
"columns": [
|
||||
"Status"
|
||||
],
|
||||
"type": "js",
|
||||
"modulePath": "claude/new.js",
|
||||
"sourceFile": "claude/new.js",
|
||||
"navigateBefore": false
|
||||
},
|
||||
{
|
||||
"site": "claude",
|
||||
"name": "read",
|
||||
"description": "Read the current Claude conversation",
|
||||
"domain": "claude.ai",
|
||||
"strategy": "cookie",
|
||||
"browser": true,
|
||||
"args": [],
|
||||
"columns": [
|
||||
"Index",
|
||||
"Role",
|
||||
"Text"
|
||||
],
|
||||
"type": "js",
|
||||
"modulePath": "claude/read.js",
|
||||
"sourceFile": "claude/read.js",
|
||||
"navigateBefore": false
|
||||
},
|
||||
{
|
||||
"site": "claude",
|
||||
"name": "send",
|
||||
"description": "Send a prompt to Claude without waiting for the response",
|
||||
"domain": "claude.ai",
|
||||
"strategy": "cookie",
|
||||
"browser": true,
|
||||
"args": [
|
||||
{
|
||||
"name": "prompt",
|
||||
"type": "str",
|
||||
"required": true,
|
||||
"positional": true,
|
||||
"help": "Prompt to send"
|
||||
},
|
||||
{
|
||||
"name": "new",
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"required": false,
|
||||
"help": "Start a new chat before sending"
|
||||
}
|
||||
],
|
||||
"columns": [
|
||||
"Status",
|
||||
"SubmittedBy",
|
||||
"InjectedText"
|
||||
],
|
||||
"type": "js",
|
||||
"modulePath": "claude/send.js",
|
||||
"sourceFile": "claude/send.js",
|
||||
"navigateBefore": false
|
||||
},
|
||||
{
|
||||
"site": "claude",
|
||||
"name": "status",
|
||||
"description": "Check Claude page availability and login state",
|
||||
"domain": "claude.ai",
|
||||
"strategy": "cookie",
|
||||
"browser": true,
|
||||
"args": [],
|
||||
"columns": [
|
||||
"Status",
|
||||
"Login",
|
||||
"Url"
|
||||
],
|
||||
"type": "js",
|
||||
"modulePath": "claude/status.js",
|
||||
"sourceFile": "claude/status.js",
|
||||
"navigateBefore": false
|
||||
},
|
||||
{
|
||||
"site": "cnki",
|
||||
"name": "search",
|
||||
@@ -8490,6 +8692,59 @@
|
||||
"modulePath": "imdb/trending.js",
|
||||
"sourceFile": "imdb/trending.js"
|
||||
},
|
||||
{
|
||||
"site": "instagram",
|
||||
"name": "collection-create",
|
||||
"description": "Create a new Instagram saved-posts collection (folder)",
|
||||
"domain": "www.instagram.com",
|
||||
"strategy": "cookie",
|
||||
"browser": true,
|
||||
"args": [
|
||||
{
|
||||
"name": "name",
|
||||
"type": "str",
|
||||
"required": true,
|
||||
"positional": true,
|
||||
"help": "Name of the collection to create"
|
||||
}
|
||||
],
|
||||
"columns": [
|
||||
"status",
|
||||
"collectionId",
|
||||
"collectionName",
|
||||
"mediaCount"
|
||||
],
|
||||
"type": "js",
|
||||
"modulePath": "instagram/collection-create.js",
|
||||
"sourceFile": "instagram/collection-create.js",
|
||||
"navigateBefore": "https://www.instagram.com"
|
||||
},
|
||||
{
|
||||
"site": "instagram",
|
||||
"name": "collection-delete",
|
||||
"description": "Delete an Instagram saved-posts collection (folder) by name or id",
|
||||
"domain": "www.instagram.com",
|
||||
"strategy": "cookie",
|
||||
"browser": true,
|
||||
"args": [
|
||||
{
|
||||
"name": "target",
|
||||
"type": "str",
|
||||
"required": true,
|
||||
"positional": true,
|
||||
"help": "Collection name (case-insensitive) or numeric collection_id"
|
||||
}
|
||||
],
|
||||
"columns": [
|
||||
"status",
|
||||
"collectionId",
|
||||
"collectionName"
|
||||
],
|
||||
"type": "js",
|
||||
"modulePath": "instagram/collection-delete.js",
|
||||
"sourceFile": "instagram/collection-delete.js",
|
||||
"navigateBefore": "https://www.instagram.com"
|
||||
},
|
||||
{
|
||||
"site": "instagram",
|
||||
"name": "comment",
|
||||
@@ -8876,7 +9131,7 @@
|
||||
{
|
||||
"site": "instagram",
|
||||
"name": "saved",
|
||||
"description": "Get your saved Instagram posts",
|
||||
"description": "Get your saved Instagram posts (optionally from a specific collection)",
|
||||
"domain": "www.instagram.com",
|
||||
"strategy": "cookie",
|
||||
"browser": true,
|
||||
@@ -8887,6 +9142,12 @@
|
||||
"default": 20,
|
||||
"required": false,
|
||||
"help": "Number of saved posts"
|
||||
},
|
||||
{
|
||||
"name": "collection",
|
||||
"type": "str",
|
||||
"required": false,
|
||||
"help": "Collection name (case-insensitive). Omit for the default \"All posts\" feed."
|
||||
}
|
||||
],
|
||||
"columns": [
|
||||
@@ -17343,6 +17604,37 @@
|
||||
"sourceFile": "weibo/comments.js",
|
||||
"navigateBefore": "https://weibo.com"
|
||||
},
|
||||
{
|
||||
"site": "weibo",
|
||||
"name": "favorites",
|
||||
"description": "我的微博收藏列表",
|
||||
"domain": "weibo.com",
|
||||
"strategy": "cookie",
|
||||
"browser": true,
|
||||
"args": [
|
||||
{
|
||||
"name": "limit",
|
||||
"type": "int",
|
||||
"default": 20,
|
||||
"required": false,
|
||||
"help": "数量(最多50)"
|
||||
}
|
||||
],
|
||||
"columns": [
|
||||
"author",
|
||||
"text",
|
||||
"time",
|
||||
"source",
|
||||
"likes",
|
||||
"comments",
|
||||
"reposts",
|
||||
"url"
|
||||
],
|
||||
"type": "js",
|
||||
"modulePath": "weibo/favorites.js",
|
||||
"sourceFile": "weibo/favorites.js",
|
||||
"navigateBefore": "https://weibo.com"
|
||||
},
|
||||
{
|
||||
"site": "weibo",
|
||||
"name": "feed",
|
||||
@@ -17460,6 +17752,38 @@
|
||||
"sourceFile": "weibo/post.js",
|
||||
"navigateBefore": "https://weibo.com"
|
||||
},
|
||||
{
|
||||
"site": "weibo",
|
||||
"name": "publish",
|
||||
"description": "Publish a new Weibo post immediately",
|
||||
"domain": "weibo.com",
|
||||
"strategy": "ui",
|
||||
"browser": true,
|
||||
"args": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"positional": true,
|
||||
"help": "Weibo text content (max 2000 chars)"
|
||||
},
|
||||
{
|
||||
"name": "images",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"help": "Image paths, comma-separated, max 9 (jpg/png/gif/webp)"
|
||||
}
|
||||
],
|
||||
"columns": [
|
||||
"status",
|
||||
"message",
|
||||
"text"
|
||||
],
|
||||
"type": "js",
|
||||
"modulePath": "weibo/publish.js",
|
||||
"sourceFile": "weibo/publish.js",
|
||||
"navigateBefore": true
|
||||
},
|
||||
{
|
||||
"site": "weibo",
|
||||
"name": "search",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AuthRequiredError, EmptyResultError, SelectorError } from '@jackwener/opencli/errors';
|
||||
import { AuthRequiredError, EmptyResultError, selectorError } from '@jackwener/opencli/errors';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
/**
|
||||
* band mentions — Show Band notifications where you were @mentioned.
|
||||
@@ -52,7 +52,7 @@ cli({
|
||||
await page.wait(0.5);
|
||||
}
|
||||
if (!bellReady) {
|
||||
throw new SelectorError('button._btnWidgetIcon', 'Notification bell not found. The Band.us UI may have changed.');
|
||||
throw selectorError('button._btnWidgetIcon', 'Notification bell not found. The Band.us UI may have changed.');
|
||||
}
|
||||
// Poll until a capture containing result_data.news arrives, up to maxSecs seconds.
|
||||
// getInterceptedRequests() clears the array on each call, so captures are accumulated
|
||||
@@ -80,7 +80,7 @@ cli({
|
||||
return true;
|
||||
}`);
|
||||
if (!bellClicked) {
|
||||
throw new SelectorError('button._btnWidgetIcon', 'Notification bell disappeared before click. The Band.us UI may have changed.');
|
||||
throw selectorError('button._btnWidgetIcon', 'Notification bell disappeared before click. The Band.us UI may have changed.');
|
||||
}
|
||||
const requests = await waitForOneCapture();
|
||||
// Find the get_news response (has result_data.news); get_news_count responses do not.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { AuthRequiredError, CommandExecutionError, EmptyResultError, SelectorError } from '@jackwener/opencli/errors';
|
||||
import { AuthRequiredError, CommandExecutionError, EmptyResultError, selectorError } from '@jackwener/opencli/errors';
|
||||
import { apiGet, resolveBvid } from './utils.js';
|
||||
cli({
|
||||
site: 'bilibili',
|
||||
@@ -23,7 +23,7 @@ cli({
|
||||
return state?.videoData?.cid;
|
||||
})()`);
|
||||
if (!cid) {
|
||||
throw new SelectorError('videoData.cid', '无法在页面中提取到当前视频的 CID,请检查页面是否正常加载。');
|
||||
throw selectorError('videoData.cid', '无法在页面中提取到当前视频的 CID,请检查页面是否正常加载。');
|
||||
}
|
||||
// 3. 在 Node 端使用 apiGet 获取带 Wbi 签名的字幕列表
|
||||
// 之前纯靠 evaluate 里的 fetch 会失败,因为 B 站 /wbi/ 开头的接口强校验 w_rid,未签名直接被风控返回 403 HTML
|
||||
|
||||
+3
-3
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { requirePage, navigateToChat, findFriendByUid, clickCandidateInList, typeAndSendMessage, } from './utils.js';
|
||||
import { EmptyResultError, SelectorError } from '@jackwener/opencli/errors';
|
||||
import { EmptyResultError, selectorError } from '@jackwener/opencli/errors';
|
||||
cli({
|
||||
site: 'boss',
|
||||
name: 'send',
|
||||
@@ -30,12 +30,12 @@ cli({
|
||||
const friendName = friend.name || '候选人';
|
||||
const clicked = await clickCandidateInList(page, numericUid);
|
||||
if (!clicked) {
|
||||
throw new SelectorError('聊天列表中的用户', '请确认聊天列表中有此人');
|
||||
throw selectorError('聊天列表中的用户', '请确认聊天列表中有此人');
|
||||
}
|
||||
await page.wait({ time: 2 });
|
||||
const sent = await typeAndSendMessage(page, kwargs.text);
|
||||
if (!sent) {
|
||||
throw new SelectorError('消息输入框', '聊天页面 UI 可能已改变');
|
||||
throw selectorError('消息输入框', '聊天页面 UI 可能已改变');
|
||||
}
|
||||
await page.wait({ time: 1 });
|
||||
return [{ status: '✅ 发送成功', detail: `已向 ${friendName} 发送: ${kwargs.text}` }];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { SelectorError } from '@jackwener/opencli/errors';
|
||||
import { selectorError } from '@jackwener/opencli/errors';
|
||||
export const askCommand = cli({
|
||||
site: 'chatwise',
|
||||
name: 'ask',
|
||||
@@ -43,7 +43,7 @@ export const askCommand = cli({
|
||||
})(${JSON.stringify(text)})
|
||||
`);
|
||||
if (!injected)
|
||||
throw new SelectorError('ChatWise input element');
|
||||
throw selectorError('ChatWise input element');
|
||||
await page.wait(0.5);
|
||||
await page.pressKey('Enter');
|
||||
// Poll for response
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { SelectorError } from '@jackwener/opencli/errors';
|
||||
import { selectorError } from '@jackwener/opencli/errors';
|
||||
export const modelCommand = cli({
|
||||
site: 'chatwise',
|
||||
name: 'model',
|
||||
@@ -58,7 +58,7 @@ export const modelCommand = cli({
|
||||
})(${JSON.stringify(desiredModel)})
|
||||
`);
|
||||
if (!opened)
|
||||
throw new SelectorError('ChatWise model selector');
|
||||
throw selectorError('ChatWise model selector');
|
||||
await page.wait(0.5);
|
||||
// Find and click the target model in the dropdown
|
||||
const found = await page.evaluate(`
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { SelectorError } from '@jackwener/opencli/errors';
|
||||
import { selectorError } from '@jackwener/opencli/errors';
|
||||
export const sendCommand = cli({
|
||||
site: 'chatwise',
|
||||
name: 'send',
|
||||
@@ -36,7 +36,7 @@ export const sendCommand = cli({
|
||||
})(${JSON.stringify(text)})
|
||||
`);
|
||||
if (!injected)
|
||||
throw new SelectorError('ChatWise input element');
|
||||
throw selectorError('ChatWise input element');
|
||||
await page.wait(0.5);
|
||||
await page.pressKey('Enter');
|
||||
return [
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import {
|
||||
CLAUDE_DOMAIN, CLAUDE_URL, ensureOnClaude, selectModel, setAdaptiveThinking,
|
||||
sendMessage, sendWithFile, getBubbleCount, waitForResponse, parseBoolFlag, withRetry,
|
||||
ensureClaudeComposer, requireNonEmptyPrompt, requirePositiveInt,
|
||||
} from './utils.js';
|
||||
|
||||
export const askCommand = cli({
|
||||
site: 'claude',
|
||||
name: 'ask',
|
||||
description: 'Send a prompt to Claude and get the response',
|
||||
domain: CLAUDE_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
navigateBefore: false,
|
||||
timeoutSeconds: 180,
|
||||
args: [
|
||||
{ name: 'prompt', positional: true, required: true, help: 'Prompt to send' },
|
||||
{ name: 'timeout', type: 'int', default: 120, help: 'Max seconds to wait for response' },
|
||||
{ name: 'new', type: 'boolean', default: false, help: 'Start a new chat before sending' },
|
||||
{ name: 'model', default: 'sonnet', choices: ['sonnet', 'opus', 'haiku'], help: 'Model to use: sonnet, opus, or haiku' },
|
||||
{ name: 'think', type: 'boolean', default: false, help: 'Enable Adaptive thinking' },
|
||||
{ name: 'file', help: 'Attach a file (image, PDF, text) with the prompt' },
|
||||
],
|
||||
columns: ['response'],
|
||||
|
||||
func: async (page, kwargs) => {
|
||||
const prompt = requireNonEmptyPrompt(kwargs.prompt, 'claude ask');
|
||||
const timeoutSeconds = requirePositiveInt(
|
||||
Number(kwargs.timeout ?? 120),
|
||||
'claude ask --timeout',
|
||||
'Example: opencli claude ask "hello" --timeout 120',
|
||||
);
|
||||
const timeoutMs = timeoutSeconds * 1000;
|
||||
const wantThink = parseBoolFlag(kwargs.think);
|
||||
|
||||
if (parseBoolFlag(kwargs.new)) {
|
||||
await page.goto(CLAUDE_URL);
|
||||
await page.wait(3);
|
||||
} else {
|
||||
const navigated = await ensureOnClaude(page);
|
||||
if (navigated) {
|
||||
// Workspace was recycled; try to resume the most recent
|
||||
// conversation instead of starting a new one.
|
||||
await page.evaluate(`(() => {
|
||||
var link = document.querySelector('a[href*="/chat/"]');
|
||||
if (link) link.click();
|
||||
})()`);
|
||||
await page.wait(2);
|
||||
}
|
||||
}
|
||||
|
||||
await page.wait(2);
|
||||
await withRetry(() => ensureClaudeComposer(page, 'Claude ask requires a visible composer on the current page.'));
|
||||
|
||||
// Model selector is only available on the new-chat page, not inside
|
||||
// an existing conversation. Skip it when we resumed a prior thread.
|
||||
const currentUrl = await page.evaluate('window.location.href') || '';
|
||||
const inConversation = currentUrl.includes('/chat/');
|
||||
const modelExplicit = kwargs.__opencliOptionSources?.model === 'cli';
|
||||
|
||||
const wantModel = kwargs.model || 'sonnet';
|
||||
if (inConversation && modelExplicit) {
|
||||
throw new ArgumentError(
|
||||
`Cannot switch to ${wantModel} model inside an existing conversation.`,
|
||||
'Re-run with --new to start a fresh chat before selecting a model.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!inConversation) {
|
||||
const modelResult = await withRetry(() => selectModel(page, wantModel));
|
||||
if (!modelResult?.ok) {
|
||||
if (modelResult?.upgrade) {
|
||||
throw new ArgumentError(
|
||||
`${wantModel} model requires a paid Claude plan.`,
|
||||
'Pick --model sonnet or --model haiku, or upgrade your account.',
|
||||
);
|
||||
}
|
||||
throw new CommandExecutionError(`Could not switch to ${wantModel} model`);
|
||||
}
|
||||
if (modelResult?.toggled) await page.wait(0.5);
|
||||
}
|
||||
|
||||
const thinkResult = await withRetry(() => setAdaptiveThinking(page, wantThink));
|
||||
if (!thinkResult?.ok && wantThink) {
|
||||
throw new CommandExecutionError('Could not enable Adaptive thinking');
|
||||
}
|
||||
if (thinkResult?.toggled) await page.wait(0.5);
|
||||
|
||||
if (kwargs.file) {
|
||||
const baseline = await withRetry(() => getBubbleCount(page));
|
||||
try {
|
||||
const fileResult = await sendWithFile(page, kwargs.file, prompt);
|
||||
if (fileResult && !fileResult.ok) {
|
||||
throw new CommandExecutionError(fileResult.reason || 'Failed to attach file');
|
||||
}
|
||||
} catch (err) {
|
||||
// SPA navigates after send; "Promise was collected" means send succeeded
|
||||
if (!String(err?.message || err).includes('Promise was collected')) throw err;
|
||||
}
|
||||
await page.wait(3);
|
||||
const result = await waitForResponse(page, baseline, prompt, timeoutMs);
|
||||
if (!result) {
|
||||
throw new EmptyResultError(
|
||||
'claude ask',
|
||||
`No Claude response appeared within ${timeoutSeconds}s. Re-run with a higher --timeout if the model is still generating.`,
|
||||
);
|
||||
}
|
||||
return [{ response: result }];
|
||||
}
|
||||
|
||||
const baseline = await withRetry(() => getBubbleCount(page));
|
||||
const sendResult = await withRetry(() => sendMessage(page, prompt));
|
||||
if (!sendResult?.ok) {
|
||||
throw new CommandExecutionError(sendResult?.reason || 'Failed to send message');
|
||||
}
|
||||
|
||||
const result = await waitForResponse(page, baseline, prompt, timeoutMs);
|
||||
if (!result) {
|
||||
throw new EmptyResultError(
|
||||
'claude ask',
|
||||
`No Claude response appeared within ${timeoutSeconds}s. Re-run with a higher --timeout if the model is still generating.`,
|
||||
);
|
||||
}
|
||||
return [{ response: result }];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
|
||||
const {
|
||||
mockEnsureOnClaude,
|
||||
mockEnsureClaudeComposer,
|
||||
mockSelectModel,
|
||||
mockSetAdaptiveThinking,
|
||||
mockSendMessage,
|
||||
mockSendWithFile,
|
||||
mockGetBubbleCount,
|
||||
mockWaitForResponse,
|
||||
mockParseBoolFlag,
|
||||
mockRequireNonEmptyPrompt,
|
||||
mockRequirePositiveInt,
|
||||
mockWithRetry,
|
||||
} = vi.hoisted(() => ({
|
||||
mockEnsureOnClaude: vi.fn(),
|
||||
mockEnsureClaudeComposer: vi.fn(),
|
||||
mockSelectModel: vi.fn(),
|
||||
mockSetAdaptiveThinking: vi.fn(),
|
||||
mockSendMessage: vi.fn(),
|
||||
mockSendWithFile: vi.fn(),
|
||||
mockGetBubbleCount: vi.fn(),
|
||||
mockWaitForResponse: vi.fn(),
|
||||
mockParseBoolFlag: vi.fn((v) => v === true || v === 'true'),
|
||||
mockRequireNonEmptyPrompt: vi.fn((v) => String(v ?? '')),
|
||||
mockRequirePositiveInt: vi.fn((v) => Number(v)),
|
||||
mockWithRetry: vi.fn(async (fn) => fn()),
|
||||
}));
|
||||
|
||||
vi.mock('./utils.js', () => ({
|
||||
CLAUDE_DOMAIN: 'claude.ai',
|
||||
CLAUDE_URL: 'https://claude.ai/new',
|
||||
ensureOnClaude: mockEnsureOnClaude,
|
||||
ensureClaudeComposer: mockEnsureClaudeComposer,
|
||||
selectModel: mockSelectModel,
|
||||
setAdaptiveThinking: mockSetAdaptiveThinking,
|
||||
sendMessage: mockSendMessage,
|
||||
sendWithFile: mockSendWithFile,
|
||||
getBubbleCount: mockGetBubbleCount,
|
||||
waitForResponse: mockWaitForResponse,
|
||||
parseBoolFlag: mockParseBoolFlag,
|
||||
requireNonEmptyPrompt: mockRequireNonEmptyPrompt,
|
||||
requirePositiveInt: mockRequirePositiveInt,
|
||||
withRetry: mockWithRetry,
|
||||
}));
|
||||
|
||||
import { askCommand } from './ask.js';
|
||||
|
||||
describe('claude ask basic flow', () => {
|
||||
const page = {
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockResolvedValue('https://claude.ai/new'),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
page.evaluate.mockResolvedValue('https://claude.ai/new');
|
||||
mockEnsureOnClaude.mockResolvedValue(false);
|
||||
mockEnsureClaudeComposer.mockResolvedValue({ isLoggedIn: true, hasComposer: true });
|
||||
mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
|
||||
mockSetAdaptiveThinking.mockResolvedValue({ ok: true, toggled: false });
|
||||
mockSendMessage.mockResolvedValue({ ok: true });
|
||||
mockSendWithFile.mockResolvedValue({ ok: true });
|
||||
mockGetBubbleCount.mockResolvedValue(0);
|
||||
mockWaitForResponse.mockResolvedValue('hello there');
|
||||
mockRequireNonEmptyPrompt.mockImplementation((v) => String(v ?? ''));
|
||||
mockRequirePositiveInt.mockImplementation((v) => Number(v));
|
||||
});
|
||||
|
||||
it('returns the assistant response on a fresh chat', async () => {
|
||||
const rows = await askCommand.func(page, {
|
||||
prompt: 'hi',
|
||||
timeout: 120,
|
||||
new: false,
|
||||
model: 'sonnet',
|
||||
think: false,
|
||||
});
|
||||
|
||||
expect(rows).toEqual([{ response: 'hello there' }]);
|
||||
expect(mockSendMessage).toHaveBeenCalledWith(page, 'hi');
|
||||
expect(mockWaitForResponse).toHaveBeenCalledWith(page, 0, 'hi', 120000);
|
||||
});
|
||||
|
||||
it('navigates to /new when --new is set', async () => {
|
||||
await askCommand.func(page, {
|
||||
prompt: 'hi',
|
||||
timeout: 120,
|
||||
new: true,
|
||||
model: 'sonnet',
|
||||
think: false,
|
||||
});
|
||||
|
||||
expect(page.goto).toHaveBeenCalledWith('https://claude.ai/new');
|
||||
expect(mockEnsureOnClaude).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws EmptyResultError when waitForResponse yields nothing', async () => {
|
||||
mockWaitForResponse.mockResolvedValue(null);
|
||||
|
||||
await expect(askCommand.func(page, {
|
||||
prompt: 'hi',
|
||||
timeout: 60,
|
||||
new: false,
|
||||
model: 'sonnet',
|
||||
think: false,
|
||||
})).rejects.toThrow(EmptyResultError);
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError when send fails', async () => {
|
||||
mockSendMessage.mockResolvedValue({ ok: false, reason: 'composer not found' });
|
||||
|
||||
await expect(askCommand.func(page, {
|
||||
prompt: 'hi',
|
||||
timeout: 120,
|
||||
new: false,
|
||||
model: 'sonnet',
|
||||
think: false,
|
||||
})).rejects.toThrow(/composer not found/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('claude ask --model handling', () => {
|
||||
const page = {
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockEnsureOnClaude.mockResolvedValue(false);
|
||||
mockSetAdaptiveThinking.mockResolvedValue({ ok: true, toggled: false });
|
||||
mockSendMessage.mockResolvedValue({ ok: true });
|
||||
mockGetBubbleCount.mockResolvedValue(0);
|
||||
mockWaitForResponse.mockResolvedValue('reply');
|
||||
});
|
||||
|
||||
it('rejects --model opus on free tier with usage-error guidance', async () => {
|
||||
page.evaluate.mockResolvedValue('https://claude.ai/new');
|
||||
mockSelectModel.mockResolvedValue({ ok: false, upgrade: true });
|
||||
|
||||
await expect(askCommand.func(page, {
|
||||
prompt: 'hi',
|
||||
timeout: 120,
|
||||
new: false,
|
||||
model: 'opus',
|
||||
think: false,
|
||||
})).rejects.toMatchObject(new ArgumentError(
|
||||
'opus model requires a paid Claude plan.',
|
||||
'Pick --model sonnet or --model haiku, or upgrade your account.',
|
||||
));
|
||||
});
|
||||
|
||||
it('skips model selection inside an existing conversation', async () => {
|
||||
page.evaluate.mockResolvedValue('https://claude.ai/chat/abc-123');
|
||||
|
||||
const rows = await askCommand.func(page, {
|
||||
prompt: 'continue',
|
||||
timeout: 120,
|
||||
new: false,
|
||||
model: 'sonnet',
|
||||
think: false,
|
||||
});
|
||||
|
||||
expect(rows).toEqual([{ response: 'reply' }]);
|
||||
expect(mockSelectModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails fast when --model is explicit inside an existing conversation', async () => {
|
||||
page.evaluate.mockResolvedValue('https://claude.ai/chat/abc-123');
|
||||
|
||||
await expect(askCommand.func(page, {
|
||||
prompt: 'continue',
|
||||
timeout: 120,
|
||||
new: false,
|
||||
model: 'opus',
|
||||
think: false,
|
||||
__opencliOptionSources: { model: 'cli' },
|
||||
})).rejects.toMatchObject(new ArgumentError(
|
||||
'Cannot switch to opus model inside an existing conversation.',
|
||||
'Re-run with --new to start a fresh chat before selecting a model.',
|
||||
));
|
||||
|
||||
expect(mockSelectModel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('claude ask --think', () => {
|
||||
const page = {
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockResolvedValue('https://claude.ai/new'),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockEnsureOnClaude.mockResolvedValue(false);
|
||||
mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
|
||||
mockSendMessage.mockResolvedValue({ ok: true });
|
||||
mockGetBubbleCount.mockResolvedValue(0);
|
||||
mockWaitForResponse.mockResolvedValue('reply');
|
||||
});
|
||||
|
||||
it('toggles Adaptive thinking when --think is set', async () => {
|
||||
mockSetAdaptiveThinking.mockResolvedValue({ ok: true, toggled: true });
|
||||
|
||||
await askCommand.func(page, {
|
||||
prompt: 'reason carefully',
|
||||
timeout: 120,
|
||||
new: false,
|
||||
model: 'sonnet',
|
||||
think: true,
|
||||
});
|
||||
|
||||
expect(mockSetAdaptiveThinking).toHaveBeenCalledWith(page, true);
|
||||
});
|
||||
|
||||
it('throws when --think requested but toggle fails', async () => {
|
||||
mockSetAdaptiveThinking.mockResolvedValue({ ok: false });
|
||||
|
||||
await expect(askCommand.func(page, {
|
||||
prompt: 'reason carefully',
|
||||
timeout: 120,
|
||||
new: false,
|
||||
model: 'sonnet',
|
||||
think: true,
|
||||
})).rejects.toThrow(/Adaptive thinking/);
|
||||
});
|
||||
|
||||
it('does not throw when --think is false and toggle returns ok=false', async () => {
|
||||
mockSetAdaptiveThinking.mockResolvedValue({ ok: false });
|
||||
|
||||
await expect(askCommand.func(page, {
|
||||
prompt: 'hi',
|
||||
timeout: 120,
|
||||
new: false,
|
||||
model: 'sonnet',
|
||||
think: false,
|
||||
})).resolves.toEqual([{ response: 'reply' }]);
|
||||
});
|
||||
|
||||
it('fails fast when prompt validation rejects an empty prompt', async () => {
|
||||
mockRequireNonEmptyPrompt.mockImplementation(() => {
|
||||
throw new ArgumentError('claude ask prompt cannot be empty');
|
||||
});
|
||||
|
||||
await expect(askCommand.func(page, {
|
||||
prompt: '',
|
||||
timeout: 120,
|
||||
new: false,
|
||||
model: 'sonnet',
|
||||
think: false,
|
||||
})).rejects.toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('fails fast when timeout validation rejects a non-positive value', async () => {
|
||||
mockRequirePositiveInt.mockImplementation(() => {
|
||||
throw new ArgumentError('claude ask --timeout must be a positive integer');
|
||||
});
|
||||
|
||||
await expect(askCommand.func(page, {
|
||||
prompt: 'hi',
|
||||
timeout: 0,
|
||||
new: false,
|
||||
model: 'sonnet',
|
||||
think: false,
|
||||
})).rejects.toThrow(ArgumentError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('claude ask --file', () => {
|
||||
const page = {
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockResolvedValue('https://claude.ai/new'),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockEnsureOnClaude.mockResolvedValue(false);
|
||||
mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
|
||||
mockSetAdaptiveThinking.mockResolvedValue({ ok: true, toggled: false });
|
||||
mockSendWithFile.mockResolvedValue({ ok: true });
|
||||
mockGetBubbleCount.mockResolvedValue(3);
|
||||
mockWaitForResponse.mockResolvedValue('the image shows a cat');
|
||||
mockEnsureClaudeComposer.mockResolvedValue({ isLoggedIn: true, hasComposer: true });
|
||||
mockRequireNonEmptyPrompt.mockImplementation((v) => String(v ?? ''));
|
||||
mockRequirePositiveInt.mockImplementation((v) => Number(v));
|
||||
});
|
||||
|
||||
it('routes through sendWithFile and captures baseline before sending', async () => {
|
||||
const rows = await askCommand.func(page, {
|
||||
prompt: 'describe this',
|
||||
timeout: 120,
|
||||
new: false,
|
||||
model: 'sonnet',
|
||||
think: false,
|
||||
file: '/tmp/cat.png',
|
||||
});
|
||||
|
||||
expect(rows).toEqual([{ response: 'the image shows a cat' }]);
|
||||
expect(mockGetBubbleCount).toHaveBeenCalledTimes(1);
|
||||
expect(mockSendWithFile).toHaveBeenCalledWith(page, '/tmp/cat.png', 'describe this');
|
||||
expect(mockSendMessage).not.toHaveBeenCalled();
|
||||
expect(mockWaitForResponse).toHaveBeenCalledWith(page, 3, 'describe this', 120000);
|
||||
});
|
||||
|
||||
it('surfaces file upload failure as CommandExecutionError', async () => {
|
||||
mockSendWithFile.mockResolvedValue({ ok: false, reason: 'file preview did not appear' });
|
||||
|
||||
await expect(askCommand.func(page, {
|
||||
prompt: 'describe this',
|
||||
timeout: 120,
|
||||
new: false,
|
||||
model: 'sonnet',
|
||||
think: false,
|
||||
file: '/tmp/cat.png',
|
||||
})).rejects.toThrow(/file preview did not appear/);
|
||||
});
|
||||
|
||||
it('absorbs "Promise was collected" SPA navigation error after send', async () => {
|
||||
mockSendWithFile.mockRejectedValue(new Error('Promise was collected'));
|
||||
|
||||
const rows = await askCommand.func(page, {
|
||||
prompt: 'describe this',
|
||||
timeout: 120,
|
||||
new: false,
|
||||
model: 'sonnet',
|
||||
think: false,
|
||||
file: '/tmp/cat.png',
|
||||
});
|
||||
|
||||
expect(rows).toEqual([{ response: 'the image shows a cat' }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ArgumentError, AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
|
||||
const {
|
||||
mockEnsureOnClaude,
|
||||
mockEnsureClaudeComposer,
|
||||
mockEnsureClaudeLogin,
|
||||
mockSendMessage,
|
||||
mockParseBoolFlag,
|
||||
mockRequireNonEmptyPrompt,
|
||||
mockGetVisibleMessages,
|
||||
mockGetConversationList,
|
||||
mockRequirePositiveInt,
|
||||
mockRequireConversationId,
|
||||
mockWithRetry,
|
||||
} = vi.hoisted(() => ({
|
||||
mockEnsureOnClaude: vi.fn(),
|
||||
mockEnsureClaudeComposer: vi.fn(),
|
||||
mockEnsureClaudeLogin: vi.fn(),
|
||||
mockSendMessage: vi.fn(),
|
||||
mockParseBoolFlag: vi.fn((v) => v === true || v === 'true'),
|
||||
mockRequireNonEmptyPrompt: vi.fn((v) => String(v ?? '')),
|
||||
mockGetVisibleMessages: vi.fn(),
|
||||
mockGetConversationList: vi.fn(),
|
||||
mockRequirePositiveInt: vi.fn((v) => Number(v)),
|
||||
mockRequireConversationId: vi.fn((v) => String(v ?? '').trim()),
|
||||
mockWithRetry: vi.fn(async (fn) => fn()),
|
||||
}));
|
||||
|
||||
vi.mock('./utils.js', () => ({
|
||||
CLAUDE_DOMAIN: 'claude.ai',
|
||||
CLAUDE_URL: 'https://claude.ai/new',
|
||||
ensureOnClaude: mockEnsureOnClaude,
|
||||
ensureClaudeComposer: mockEnsureClaudeComposer,
|
||||
ensureClaudeLogin: mockEnsureClaudeLogin,
|
||||
sendMessage: mockSendMessage,
|
||||
parseBoolFlag: mockParseBoolFlag,
|
||||
requireNonEmptyPrompt: mockRequireNonEmptyPrompt,
|
||||
getVisibleMessages: mockGetVisibleMessages,
|
||||
getConversationList: mockGetConversationList,
|
||||
requirePositiveInt: mockRequirePositiveInt,
|
||||
requireConversationId: mockRequireConversationId,
|
||||
withRetry: mockWithRetry,
|
||||
}));
|
||||
|
||||
import { sendCommand } from './send.js';
|
||||
import { newCommand } from './new.js';
|
||||
import { readCommand } from './read.js';
|
||||
import { historyCommand } from './history.js';
|
||||
import { detailCommand } from './detail.js';
|
||||
|
||||
describe('claude command-level fail-fast contracts', () => {
|
||||
const page = {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockEnsureOnClaude.mockResolvedValue(false);
|
||||
mockEnsureClaudeComposer.mockResolvedValue({ isLoggedIn: true, hasComposer: true });
|
||||
mockEnsureClaudeLogin.mockResolvedValue({ isLoggedIn: true });
|
||||
mockSendMessage.mockResolvedValue({ ok: true });
|
||||
mockRequireNonEmptyPrompt.mockImplementation((v) => String(v ?? ''));
|
||||
mockGetVisibleMessages.mockResolvedValue([{ Index: 0, Role: 'assistant', Text: 'hi' }]);
|
||||
mockGetConversationList.mockResolvedValue([{ Index: 1, Id: 'abc', Title: 'Hi', Url: 'https://claude.ai/chat/abc' }]);
|
||||
mockRequirePositiveInt.mockImplementation((v) => Number(v));
|
||||
mockRequireConversationId.mockImplementation((v) => String(v ?? '').trim());
|
||||
});
|
||||
|
||||
it('send rejects empty prompt via ArgumentError', async () => {
|
||||
mockRequireNonEmptyPrompt.mockImplementation(() => {
|
||||
throw new ArgumentError('claude send prompt cannot be empty');
|
||||
});
|
||||
|
||||
await expect(sendCommand.func(page, { prompt: '', new: false })).rejects.toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('send surfaces auth failure from composer readiness', async () => {
|
||||
mockEnsureClaudeComposer.mockRejectedValue(new AuthRequiredError('claude.ai', 'Claude send requires a logged-in Claude session.'));
|
||||
|
||||
await expect(sendCommand.func(page, { prompt: 'hi', new: false })).rejects.toThrow(AuthRequiredError);
|
||||
});
|
||||
|
||||
it('new no longer false-succeeds on login wall', async () => {
|
||||
mockEnsureClaudeComposer.mockRejectedValue(new AuthRequiredError('claude.ai', 'Claude new requires a logged-in Claude session with a visible composer.'));
|
||||
|
||||
await expect(newCommand.func(page)).rejects.toThrow(AuthRequiredError);
|
||||
});
|
||||
|
||||
it('read throws EmptyResultError instead of a placeholder row', async () => {
|
||||
mockGetVisibleMessages.mockResolvedValue([]);
|
||||
|
||||
await expect(readCommand.func(page)).rejects.toThrow(EmptyResultError);
|
||||
});
|
||||
|
||||
it('history rejects invalid --limit values instead of silently coercing them', async () => {
|
||||
mockRequirePositiveInt.mockImplementation(() => {
|
||||
throw new ArgumentError('claude history --limit must be a positive integer');
|
||||
});
|
||||
|
||||
await expect(historyCommand.func(page, { limit: 0 })).rejects.toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('history throws EmptyResultError on an empty /recents page', async () => {
|
||||
mockGetConversationList.mockResolvedValue([]);
|
||||
|
||||
await expect(historyCommand.func(page, { limit: 20 })).rejects.toThrow(EmptyResultError);
|
||||
});
|
||||
|
||||
it('detail rejects a missing conversation id', async () => {
|
||||
mockRequireConversationId.mockImplementation(() => {
|
||||
throw new ArgumentError('claude detail requires a conversation id');
|
||||
});
|
||||
|
||||
await expect(detailCommand.func(page, { id: '' })).rejects.toThrow(ArgumentError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { CLAUDE_DOMAIN, getVisibleMessages, ensureClaudeLogin, requireConversationId } from './utils.js';
|
||||
|
||||
export const detailCommand = cli({
|
||||
site: 'claude',
|
||||
name: 'detail',
|
||||
description: 'Open a Claude conversation by ID and read its messages',
|
||||
domain: CLAUDE_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'id', positional: true, required: true, help: 'Conversation ID (UUID from /chat/<id>)' },
|
||||
],
|
||||
columns: ['Index', 'Role', 'Text'],
|
||||
|
||||
func: async (page, kwargs) => {
|
||||
const id = requireConversationId(kwargs.id);
|
||||
|
||||
await page.goto(`https://claude.ai/chat/${id}`);
|
||||
await page.wait(4);
|
||||
await ensureClaudeLogin(page, 'Claude detail requires a logged-in Claude session.');
|
||||
|
||||
const messages = await getVisibleMessages(page);
|
||||
if (messages.length > 0) return messages;
|
||||
throw new EmptyResultError('claude detail', `No visible Claude messages were found for conversation ${id}.`);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { CLAUDE_DOMAIN, getConversationList, ensureClaudeLogin, requirePositiveInt } from './utils.js';
|
||||
|
||||
export const historyCommand = cli({
|
||||
site: 'claude',
|
||||
name: 'history',
|
||||
description: 'List conversation history from Claude /recents',
|
||||
domain: CLAUDE_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Max conversations to show' },
|
||||
],
|
||||
columns: ['Index', 'Id', 'Title', 'Url'],
|
||||
|
||||
func: async (page, kwargs) => {
|
||||
const limit = requirePositiveInt(
|
||||
Number(kwargs.limit ?? 20),
|
||||
'claude history --limit',
|
||||
'Example: opencli claude history --limit 20',
|
||||
);
|
||||
const conversations = await getConversationList(page);
|
||||
await ensureClaudeLogin(page, 'Claude history requires a logged-in Claude session.');
|
||||
if (conversations.length === 0) {
|
||||
throw new EmptyResultError('claude history', 'No Claude conversation history was visible on /recents.');
|
||||
}
|
||||
return conversations.slice(0, limit);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { CLAUDE_DOMAIN, CLAUDE_URL, ensureClaudeComposer } from './utils.js';
|
||||
|
||||
export const newCommand = cli({
|
||||
site: 'claude',
|
||||
name: 'new',
|
||||
description: 'Start a new conversation in Claude',
|
||||
domain: CLAUDE_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
navigateBefore: false,
|
||||
args: [],
|
||||
columns: ['Status'],
|
||||
|
||||
func: async (page) => {
|
||||
await page.goto(CLAUDE_URL);
|
||||
await page.wait(2);
|
||||
await ensureClaudeComposer(page, 'Claude new requires a logged-in Claude session with a visible composer.');
|
||||
return [{ Status: 'New chat started' }];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { CLAUDE_DOMAIN, ensureOnClaude, getVisibleMessages, ensureClaudeLogin } from './utils.js';
|
||||
|
||||
export const readCommand = cli({
|
||||
site: 'claude',
|
||||
name: 'read',
|
||||
description: 'Read the current Claude conversation',
|
||||
domain: CLAUDE_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
navigateBefore: false,
|
||||
args: [],
|
||||
columns: ['Index', 'Role', 'Text'],
|
||||
|
||||
func: async (page) => {
|
||||
await ensureOnClaude(page);
|
||||
await page.wait(3);
|
||||
await ensureClaudeLogin(page, 'Claude read requires a logged-in Claude session.');
|
||||
const messages = await getVisibleMessages(page);
|
||||
if (messages.length > 0) return messages;
|
||||
throw new EmptyResultError('claude read', 'No visible Claude messages were found in the current conversation.');
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { CLAUDE_DOMAIN, CLAUDE_URL, ensureOnClaude, sendMessage, parseBoolFlag, withRetry, ensureClaudeComposer, requireNonEmptyPrompt } from './utils.js';
|
||||
|
||||
export const sendCommand = cli({
|
||||
site: 'claude',
|
||||
name: 'send',
|
||||
description: 'Send a prompt to Claude without waiting for the response',
|
||||
domain: CLAUDE_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
navigateBefore: false,
|
||||
args: [
|
||||
{ name: 'prompt', positional: true, required: true, help: 'Prompt to send' },
|
||||
{ name: 'new', type: 'boolean', default: false, help: 'Start a new chat before sending' },
|
||||
],
|
||||
columns: ['Status', 'SubmittedBy', 'InjectedText'],
|
||||
|
||||
func: async (page, kwargs) => {
|
||||
const prompt = requireNonEmptyPrompt(kwargs.prompt, 'claude send');
|
||||
|
||||
if (parseBoolFlag(kwargs.new)) {
|
||||
await page.goto(CLAUDE_URL);
|
||||
await page.wait(3);
|
||||
} else {
|
||||
await ensureOnClaude(page);
|
||||
await page.wait(2);
|
||||
}
|
||||
await withRetry(() => ensureClaudeComposer(page, 'Claude send requires a visible composer on the current page.'));
|
||||
|
||||
const sendResult = await withRetry(() => sendMessage(page, prompt));
|
||||
if (!sendResult?.ok) {
|
||||
throw new CommandExecutionError(sendResult?.reason || 'Failed to send message');
|
||||
}
|
||||
return [{
|
||||
Status: 'Success',
|
||||
SubmittedBy: sendResult.method || 'send-button',
|
||||
InjectedText: prompt,
|
||||
}];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { CLAUDE_DOMAIN, ensureOnClaude, getPageState } from './utils.js';
|
||||
|
||||
export const statusCommand = cli({
|
||||
site: 'claude',
|
||||
name: 'status',
|
||||
description: 'Check Claude page availability and login state',
|
||||
domain: CLAUDE_DOMAIN,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
navigateBefore: false,
|
||||
args: [],
|
||||
columns: ['Status', 'Login', 'Url'],
|
||||
|
||||
func: async (page) => {
|
||||
await ensureOnClaude(page);
|
||||
const state = await getPageState(page);
|
||||
return [{
|
||||
Status: state.hasComposer ? 'Connected' : 'Page not ready',
|
||||
Login: state.isLoggedIn ? 'Yes' : 'No',
|
||||
Url: state.url,
|
||||
}];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,440 @@
|
||||
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
|
||||
export const CLAUDE_DOMAIN = 'claude.ai';
|
||||
export const CLAUDE_URL = 'https://claude.ai/new';
|
||||
export const COMPOSER_SELECTOR = '[data-testid="chat-input"]';
|
||||
export const MESSAGE_SELECTOR = '.font-claude-response';
|
||||
export const MODEL_DROPDOWN_SELECTOR = '[data-testid="model-selector-dropdown"]';
|
||||
|
||||
const MODEL_DISPLAY_NAMES = {
|
||||
sonnet: 'Sonnet 4.6',
|
||||
opus: 'Opus 4.7',
|
||||
haiku: 'Haiku 4.5',
|
||||
};
|
||||
|
||||
export async function isOnClaude(page) {
|
||||
const url = await page.evaluate('window.location.href').catch(() => '');
|
||||
if (typeof url !== 'string' || !url) return false;
|
||||
try {
|
||||
const h = new URL(url).hostname;
|
||||
return h === CLAUDE_DOMAIN || h.endsWith(`.${CLAUDE_DOMAIN}`);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureOnClaude(page) {
|
||||
if (await isOnClaude(page)) return false;
|
||||
await page.goto(CLAUDE_URL);
|
||||
await page.wait(3);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function getPageState(page) {
|
||||
return page.evaluate(`(() => {
|
||||
var composer = document.querySelector('${COMPOSER_SELECTOR}');
|
||||
var userMenu = document.querySelector('[data-testid="user-menu-button"]');
|
||||
return {
|
||||
url: window.location.href,
|
||||
title: document.title,
|
||||
hasComposer: !!composer,
|
||||
isLoggedIn: !!userMenu,
|
||||
};
|
||||
})()`);
|
||||
}
|
||||
|
||||
export async function ensureClaudeLogin(page, message = 'Claude requires a logged-in browser session.') {
|
||||
const state = await getPageState(page);
|
||||
if (!state.isLoggedIn) {
|
||||
throw new AuthRequiredError(CLAUDE_DOMAIN, message);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export async function ensureClaudeComposer(page, message = 'Claude composer is not available on the current page.') {
|
||||
const state = await ensureClaudeLogin(page, message);
|
||||
if (!state.hasComposer) {
|
||||
throw new CommandExecutionError(message);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export function requireNonEmptyPrompt(prompt, commandName) {
|
||||
const text = String(prompt ?? '').trim();
|
||||
if (!text) {
|
||||
throw new ArgumentError(
|
||||
`${commandName} prompt cannot be empty`,
|
||||
`Example: opencli ${commandName} "hello"`,
|
||||
);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export function requirePositiveInt(value, flagLabel, hint) {
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
throw new ArgumentError(`${flagLabel} must be a positive integer`, hint);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function requireConversationId(value) {
|
||||
const id = String(value ?? '').trim();
|
||||
if (!id) {
|
||||
throw new ArgumentError(
|
||||
'claude detail requires a conversation id',
|
||||
'Example: opencli claude detail 123e4567-e89b-12d3-a456-426614174000',
|
||||
);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function getVisibleMessages(page) {
|
||||
const result = await page.evaluate(`(() => {
|
||||
var nodes = document.querySelectorAll('[data-testid="user-message"], ${MESSAGE_SELECTOR}');
|
||||
var rows = [];
|
||||
Array.from(nodes).forEach(function(el) {
|
||||
var isUser = el.getAttribute('data-testid') === 'user-message';
|
||||
var raw = (el.innerText || '').trim();
|
||||
if (!isUser) {
|
||||
var parts = raw.split(/\\n\\n+/);
|
||||
while (parts.length > 1 && /^(Thought|View)\\b/i.test(parts[0])) parts.shift();
|
||||
raw = parts.join('\\n\\n').trim();
|
||||
}
|
||||
if (raw) rows.push({ role: isUser ? 'user' : 'assistant', text: raw });
|
||||
});
|
||||
return rows;
|
||||
})()`);
|
||||
if (!Array.isArray(result)) return [];
|
||||
return result.map(function(r, i) { return { Index: i, Role: r.role, Text: r.text }; });
|
||||
}
|
||||
|
||||
export async function getConversationList(page) {
|
||||
if (!(await isOnClaude(page)) || !(await page.evaluate('window.location.href') || '').includes('/recents')) {
|
||||
await page.goto('https://claude.ai/recents');
|
||||
await page.wait(3);
|
||||
}
|
||||
const items = await page.evaluate(`(() => {
|
||||
var links = Array.from(document.querySelectorAll('a[href*="/chat/"]'));
|
||||
return links.map(function(link, i) {
|
||||
var href = link.getAttribute('href') || '';
|
||||
var idMatch = href.match(/\\/chat\\/([a-f0-9-]+)/);
|
||||
return {
|
||||
Index: i + 1,
|
||||
Id: idMatch ? idMatch[1] : href,
|
||||
Title: (link.innerText || '').trim().split('\\n')[0].trim() || '(untitled)',
|
||||
Url: href.startsWith('http') ? href : ('https://claude.ai' + href),
|
||||
};
|
||||
});
|
||||
})()`);
|
||||
return Array.isArray(items) ? items : [];
|
||||
}
|
||||
|
||||
export async function selectModel(page, modelName) {
|
||||
const display = MODEL_DISPLAY_NAMES[String(modelName).toLowerCase()];
|
||||
if (!display) return { ok: false };
|
||||
|
||||
const opened = await page.evaluate(`(() => {
|
||||
var trigger = document.querySelector('${MODEL_DROPDOWN_SELECTOR}');
|
||||
if (!trigger) return { ok: false };
|
||||
var label = trigger.getAttribute('aria-label') || '';
|
||||
if (label.indexOf(${JSON.stringify(display)}) >= 0) {
|
||||
return { ok: true, toggled: false };
|
||||
}
|
||||
trigger.click();
|
||||
return { ok: true, opened: true };
|
||||
})()`);
|
||||
|
||||
if (!opened?.ok) return opened;
|
||||
if (!opened.opened) return opened;
|
||||
|
||||
await page.wait(0.6);
|
||||
|
||||
return page.evaluate(`(() => {
|
||||
var items = Array.from(document.querySelectorAll('div[role="menuitemradio"]'));
|
||||
var target = items.find(function(el) { return (el.innerText || '').indexOf(${JSON.stringify(display)}) >= 0; });
|
||||
if (!target) return { ok: false };
|
||||
// Free-tier locked options carry an inline "Upgrade" button next to the label.
|
||||
var upgrade = target.querySelector('button');
|
||||
if (upgrade && (upgrade.innerText || '').toLowerCase().indexOf('upgrade') >= 0) {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
return { ok: false, upgrade: true };
|
||||
}
|
||||
var alreadySelected = target.getAttribute('aria-checked') === 'true';
|
||||
if (!alreadySelected) target.click();
|
||||
else document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
return { ok: true, toggled: !alreadySelected };
|
||||
})()`);
|
||||
}
|
||||
|
||||
export async function setAdaptiveThinking(page, enabled) {
|
||||
const opened = await page.evaluate(`(() => {
|
||||
var trigger = document.querySelector('${MODEL_DROPDOWN_SELECTOR}');
|
||||
if (!trigger) return { ok: false };
|
||||
trigger.click();
|
||||
return { ok: true };
|
||||
})()`);
|
||||
if (!opened?.ok) return { ok: false };
|
||||
|
||||
await page.wait(0.6);
|
||||
|
||||
return page.evaluate(`(() => {
|
||||
var items = Array.from(document.querySelectorAll('div[role="menuitem"]'));
|
||||
var target = items.find(function(el) { return (el.innerText || '').indexOf('Adaptive thinking') >= 0; });
|
||||
if (!target) {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
return { ok: false };
|
||||
}
|
||||
var isActive = target.getAttribute('aria-checked') === 'true';
|
||||
if (${enabled} !== isActive) target.click();
|
||||
else document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
return { ok: true, toggled: ${enabled} !== isActive };
|
||||
})()`);
|
||||
}
|
||||
|
||||
export async function sendMessage(page, prompt) {
|
||||
const promptJson = JSON.stringify(prompt);
|
||||
const composerReady = await page.evaluate(`(() => {
|
||||
var box = document.querySelector('${COMPOSER_SELECTOR}');
|
||||
if (!box) return false;
|
||||
box.focus();
|
||||
// ProseMirror editors hold content in nested <p>; clear via Range/delete
|
||||
// rather than .value or textContent, which the editor won't notice.
|
||||
var sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
var range = document.createRange();
|
||||
range.selectNodeContents(box);
|
||||
sel.addRange(range);
|
||||
document.execCommand('delete', false);
|
||||
return true;
|
||||
})()`);
|
||||
if (!composerReady) return { ok: false, reason: 'composer not found' };
|
||||
|
||||
let typedNatively = false;
|
||||
if (page.nativeType) {
|
||||
try {
|
||||
await page.nativeType(prompt);
|
||||
typedNatively = true;
|
||||
} catch (err) {
|
||||
const msg = String(err?.message || err);
|
||||
if (!msg.includes('Unknown action') && !msg.includes('not supported')) throw err;
|
||||
}
|
||||
}
|
||||
if (!typedNatively) {
|
||||
await page.evaluate(`(() => {
|
||||
var box = document.querySelector('${COMPOSER_SELECTOR}');
|
||||
if (!box) return;
|
||||
box.focus();
|
||||
document.execCommand('insertText', false, ${promptJson});
|
||||
})()`);
|
||||
}
|
||||
|
||||
await page.wait(1.2);
|
||||
|
||||
return page.evaluate(`(() => {
|
||||
var ariaCandidates = [
|
||||
'button[aria-label="Send Message"]',
|
||||
'button[aria-label="Send message"]',
|
||||
'button[aria-label="Send"]',
|
||||
'button[aria-label*="Send"]',
|
||||
];
|
||||
for (var i = 0; i < ariaCandidates.length; i++) {
|
||||
var btn = document.querySelector(ariaCandidates[i]);
|
||||
if (btn && !btn.disabled) { btn.click(); return { ok: true }; }
|
||||
}
|
||||
// Fallback: rightmost enabled button with an svg in the composer container.
|
||||
var box = document.querySelector('${COMPOSER_SELECTOR}');
|
||||
if (box) {
|
||||
var c = box.parentElement;
|
||||
for (var hop = 0; hop < 6 && c; hop++) {
|
||||
var btns = Array.from(c.querySelectorAll('button')).filter(function(b) { return !b.disabled && b.querySelector('svg'); });
|
||||
if (btns.length) { btns[btns.length - 1].click(); return { ok: true, method: 'fallback' }; }
|
||||
c = c.parentElement;
|
||||
}
|
||||
}
|
||||
var box2 = document.querySelector('${COMPOSER_SELECTOR}');
|
||||
if (box2) {
|
||||
box2.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
|
||||
return { ok: true, method: 'enter' };
|
||||
}
|
||||
return { ok: false, reason: 'send button not found' };
|
||||
})()`);
|
||||
}
|
||||
|
||||
export async function getBubbleCount(page) {
|
||||
const count = await page.evaluate(`(() => {
|
||||
return document.querySelectorAll('${MESSAGE_SELECTOR}').length;
|
||||
})()`);
|
||||
return count || 0;
|
||||
}
|
||||
|
||||
export async function waitForResponse(page, baselineCount, prompt, timeoutMs) {
|
||||
const startTime = Date.now();
|
||||
let lastText = '';
|
||||
let stableCount = 0;
|
||||
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
await page.wait(3);
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await page.evaluate(`(() => {
|
||||
var bubbles = document.querySelectorAll('${MESSAGE_SELECTOR}');
|
||||
// Adaptive thinking renders "Thought process" labels at the top
|
||||
// of the response (often duplicated for the expand/collapse widget).
|
||||
// Strip them so the row value is the actual answer text.
|
||||
var texts = Array.from(bubbles).map(function(b) {
|
||||
var raw = (b.innerText || '').trim();
|
||||
// Drop leading paragraphs that are widget labels:
|
||||
// "Thought process" / "Thought for Xs" — Adaptive thinking expand widget
|
||||
// "View uploaded image" / "View attachment" — file thumbnail label
|
||||
// These render twice (collapsed + expanded) and are followed by a blank line.
|
||||
var parts = raw.split(/\\n\\n+/);
|
||||
while (parts.length > 1 && /^(Thought|View)\\b/i.test(parts[0])) parts.shift();
|
||||
return parts.join('\\n\\n').trim();
|
||||
}).filter(Boolean);
|
||||
return {
|
||||
count: texts.length,
|
||||
last: texts[texts.length - 1] || '',
|
||||
streaming: !!document.querySelector('[data-is-streaming="true"]'),
|
||||
};
|
||||
})()`);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!result) continue;
|
||||
|
||||
const candidate = result.last;
|
||||
if (!candidate || candidate === prompt.trim()) continue;
|
||||
if (result.count <= baselineCount) continue;
|
||||
if (result.streaming) {
|
||||
lastText = candidate;
|
||||
stableCount = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (candidate === lastText) {
|
||||
stableCount++;
|
||||
if (stableCount >= 3) return candidate;
|
||||
} else {
|
||||
stableCount = 0;
|
||||
lastText = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return lastText || null;
|
||||
}
|
||||
|
||||
async function waitForFilePreview(page, fileName) {
|
||||
for (let attempt = 0; attempt < 12; attempt++) {
|
||||
await page.wait(1);
|
||||
const ready = await page.evaluate(`(() => {
|
||||
// Claude renders attachments as data-testid="file-thumbnail" cards with
|
||||
// a sibling Remove button. Either signal indicates the file took.
|
||||
if (document.querySelector('[data-testid="file-thumbnail"]')) return true;
|
||||
var removeBtn = Array.from(document.querySelectorAll('button'))
|
||||
.find(function(b) { return (b.getAttribute('aria-label') || '') === 'Remove'; });
|
||||
return !!removeBtn;
|
||||
})()`);
|
||||
if (ready) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function sendWithFile(page, filePath, prompt) {
|
||||
const fs = await import('node:fs');
|
||||
const path = await import('node:path');
|
||||
const absPath = path.default.resolve(filePath);
|
||||
|
||||
if (!fs.default.existsSync(absPath)) {
|
||||
return { ok: false, reason: `File not found: ${absPath}` };
|
||||
}
|
||||
|
||||
const stats = fs.default.statSync(absPath);
|
||||
if (stats.size > 30 * 1024 * 1024) {
|
||||
return { ok: false, reason: `File too large (${(stats.size / 1024 / 1024).toFixed(1)} MB). Max: 30 MB` };
|
||||
}
|
||||
|
||||
const fileName = path.default.basename(absPath);
|
||||
|
||||
let uploaded = false;
|
||||
if (page.setFileInput) {
|
||||
try {
|
||||
// Upload via CDP so the file content does not cross the daemon body
|
||||
// limit, then trigger React's controlled onChange manually because
|
||||
// CDP assigns .files without firing the synthetic event React listens for.
|
||||
await page.setFileInput([absPath], 'input[data-testid="file-upload"]');
|
||||
const fired = await page.evaluate(`(() => {
|
||||
var inp = document.querySelector('input[data-testid="file-upload"]');
|
||||
if (!inp) return { ok: false, reason: 'file input not found' };
|
||||
var propsKey = Object.keys(inp).find(function(k) { return k.startsWith('__reactProps$'); });
|
||||
if (propsKey && typeof inp[propsKey].onChange === 'function') {
|
||||
inp[propsKey].onChange({ target: { files: inp.files } });
|
||||
return { ok: true, via: 'react' };
|
||||
}
|
||||
inp.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return { ok: true, via: 'native' };
|
||||
})()`);
|
||||
if (!fired?.ok) return fired;
|
||||
uploaded = true;
|
||||
} catch (err) {
|
||||
const msg = String(err?.message || err);
|
||||
if (!msg.includes('Unknown action') && !msg.includes('not supported') && !msg.includes('Not allowed')) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!uploaded) {
|
||||
const content = fs.default.readFileSync(absPath);
|
||||
const base64 = content.toString('base64');
|
||||
const fallbackResult = await page.evaluate(`(async () => {
|
||||
var binary = atob('${base64}');
|
||||
var bytes = new Uint8Array(binary.length);
|
||||
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
|
||||
var file = new File([bytes], ${JSON.stringify(fileName)});
|
||||
var dt = new DataTransfer();
|
||||
dt.items.add(file);
|
||||
|
||||
var inp = document.querySelector('input[data-testid="file-upload"]');
|
||||
if (!inp) return { ok: false, reason: 'file input not found' };
|
||||
|
||||
var propsKey = Object.keys(inp).find(function(k) { return k.startsWith('__reactProps$'); });
|
||||
if (!propsKey || typeof inp[propsKey].onChange !== 'function') {
|
||||
return { ok: false, reason: 'React onChange not found' };
|
||||
}
|
||||
|
||||
inp.files = dt.files;
|
||||
inp[propsKey].onChange({ target: { files: inp.files } });
|
||||
return { ok: true };
|
||||
})()`);
|
||||
if (fallbackResult && !fallbackResult.ok) return fallbackResult;
|
||||
}
|
||||
|
||||
const ready = await waitForFilePreview(page, fileName);
|
||||
if (!ready) return { ok: false, reason: 'file preview did not appear' };
|
||||
|
||||
return sendMessage(page, prompt);
|
||||
}
|
||||
|
||||
// Retries on CDP "Promise was collected" errors caused by Claude SPA route changes.
|
||||
export async function withRetry(fn, retries = 2) {
|
||||
for (let i = 0; i <= retries; i++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
const msg = String(err?.message || err);
|
||||
if (i < retries && msg.includes('Promise was collected')) {
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseBoolFlag(value) {
|
||||
if (typeof value === 'boolean') return value;
|
||||
return String(value ?? '').trim().toLowerCase() === 'true';
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ArgumentError } from '@jackwener/opencli/errors';
|
||||
import { parseBoolFlag, sendWithFile, selectModel, requireConversationId, requireNonEmptyPrompt, requirePositiveInt } from './utils.js';
|
||||
|
||||
describe('claude parseBoolFlag', () => {
|
||||
it('returns booleans unchanged', () => {
|
||||
expect(parseBoolFlag(true)).toBe(true);
|
||||
expect(parseBoolFlag(false)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats only "true" string (case-insensitive) as true', () => {
|
||||
expect(parseBoolFlag('true')).toBe(true);
|
||||
expect(parseBoolFlag('TRUE')).toBe(true);
|
||||
expect(parseBoolFlag('1')).toBe(false);
|
||||
expect(parseBoolFlag('yes')).toBe(false);
|
||||
expect(parseBoolFlag('')).toBe(false);
|
||||
expect(parseBoolFlag(null)).toBe(false);
|
||||
expect(parseBoolFlag(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('claude argument helpers', () => {
|
||||
it('rejects blank prompts', () => {
|
||||
expect(() => requireNonEmptyPrompt(' ', 'claude ask')).toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('rejects non-positive integers for numeric flags', () => {
|
||||
expect(() => requirePositiveInt(0, 'claude ask --timeout')).toThrow(ArgumentError);
|
||||
expect(() => requirePositiveInt(-1, 'claude history --limit')).toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('rejects missing conversation ids', () => {
|
||||
expect(() => requireConversationId(' ')).toThrow(ArgumentError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('claude sendWithFile', () => {
|
||||
const tempDirs = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
while (tempDirs.length) {
|
||||
fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('prefers page.setFileInput, then sends after preview appears', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-claude-'));
|
||||
tempDirs.push(dir);
|
||||
const filePath = path.join(dir, 'cat.png');
|
||||
fs.writeFileSync(filePath, 'fake');
|
||||
|
||||
const page = {
|
||||
nativeType: vi.fn().mockResolvedValue(undefined),
|
||||
setFileInput: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn()
|
||||
.mockResolvedValueOnce({ ok: true, via: 'react' }) // React onChange fired after setFileInput
|
||||
.mockResolvedValueOnce(true) // waitForFilePreview hit
|
||||
.mockResolvedValueOnce(true) // composer ready
|
||||
.mockResolvedValueOnce({ ok: true }), // send button click
|
||||
};
|
||||
|
||||
const result = await sendWithFile(page, filePath, 'describe this');
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(page.setFileInput).toHaveBeenCalledWith([filePath], 'input[data-testid="file-upload"]');
|
||||
expect(page.nativeType).toHaveBeenCalledWith('describe this');
|
||||
});
|
||||
|
||||
it('returns file-not-found when path does not exist', async () => {
|
||||
const page = { setFileInput: vi.fn(), evaluate: vi.fn(), wait: vi.fn() };
|
||||
const result = await sendWithFile(page, '/no/such/file.png', 'hi');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toContain('File not found');
|
||||
});
|
||||
|
||||
it('rejects oversized files before any upload attempt', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-claude-'));
|
||||
tempDirs.push(dir);
|
||||
const filePath = path.join(dir, 'big.bin');
|
||||
fs.writeFileSync(filePath, Buffer.alloc(31 * 1024 * 1024));
|
||||
|
||||
const page = { setFileInput: vi.fn(), evaluate: vi.fn(), wait: vi.fn() };
|
||||
const result = await sendWithFile(page, filePath, 'hi');
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toMatch(/too large/);
|
||||
expect(page.setFileInput).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('claude selectModel', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('rejects unknown model keys without touching the page', async () => {
|
||||
const page = { evaluate: vi.fn() };
|
||||
|
||||
const result = await selectModel(page, 'gpt5');
|
||||
|
||||
expect(result).toEqual({ ok: false });
|
||||
expect(page.evaluate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns toggled=false when the dropdown already shows the requested model', async () => {
|
||||
const page = {
|
||||
evaluate: vi.fn().mockResolvedValueOnce({ ok: true, toggled: false }),
|
||||
wait: vi.fn(),
|
||||
};
|
||||
|
||||
const result = await selectModel(page, 'sonnet');
|
||||
|
||||
expect(result).toEqual({ ok: true, toggled: false });
|
||||
expect(page.wait).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens the dropdown and clicks the matching radio', async () => {
|
||||
const page = {
|
||||
evaluate: vi.fn()
|
||||
.mockResolvedValueOnce({ ok: true, opened: true })
|
||||
.mockResolvedValueOnce({ ok: true, toggled: true }),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const result = await selectModel(page, 'haiku');
|
||||
|
||||
expect(result).toEqual({ ok: true, toggled: true });
|
||||
expect(page.evaluate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('flags upgrade-required when picking a paid model on free tier', async () => {
|
||||
const page = {
|
||||
evaluate: vi.fn()
|
||||
.mockResolvedValueOnce({ ok: true, opened: true })
|
||||
.mockResolvedValueOnce({ ok: false, upgrade: true }),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const result = await selectModel(page, 'opus');
|
||||
|
||||
expect(result).toEqual({ ok: false, upgrade: true });
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { SelectorError } from '@jackwener/opencli/errors';
|
||||
import { selectorError } from '@jackwener/opencli/errors';
|
||||
export const askCommand = cli({
|
||||
site: 'codex',
|
||||
name: 'ask',
|
||||
@@ -34,7 +34,7 @@ export const askCommand = cli({
|
||||
})(${JSON.stringify(text)})
|
||||
`);
|
||||
if (!injected)
|
||||
throw new SelectorError('Codex input element');
|
||||
throw selectorError('Codex input element');
|
||||
await page.wait(0.5);
|
||||
await page.pressKey('Enter');
|
||||
// Poll for new content
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { SelectorError } from '@jackwener/opencli/errors';
|
||||
import { selectorError } from '@jackwener/opencli/errors';
|
||||
export const sendCommand = cli({
|
||||
site: 'codex',
|
||||
name: 'send',
|
||||
@@ -28,7 +28,7 @@ export const sendCommand = cli({
|
||||
})(${JSON.stringify(textToInsert)})
|
||||
`);
|
||||
if (!injected)
|
||||
throw new SelectorError('Codex Composer input element');
|
||||
throw selectorError('Codex Composer input element');
|
||||
// Wait for the UI to register the input
|
||||
await page.wait(0.5);
|
||||
// Simulate Enter key to submit
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { SelectorError } from '@jackwener/opencli/errors';
|
||||
import { selectorError } from '@jackwener/opencli/errors';
|
||||
export const askCommand = cli({
|
||||
site: 'cursor',
|
||||
name: 'ask',
|
||||
@@ -28,7 +28,7 @@ export const askCommand = cli({
|
||||
return true;
|
||||
})(${JSON.stringify(text)})`);
|
||||
if (!injected)
|
||||
throw new SelectorError('Cursor input element');
|
||||
throw selectorError('Cursor input element');
|
||||
await page.wait(0.5);
|
||||
await page.pressKey('Enter');
|
||||
// Poll until a new assistant message appears or timeout
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { SelectorError } from '@jackwener/opencli/errors';
|
||||
import { selectorError } from '@jackwener/opencli/errors';
|
||||
export const composerCommand = cli({
|
||||
site: 'cursor',
|
||||
name: 'composer',
|
||||
@@ -27,7 +27,7 @@ export const composerCommand = cli({
|
||||
return true;
|
||||
})(${JSON.stringify(textToInsert)})`);
|
||||
if (!typed) {
|
||||
throw new SelectorError('Cursor Composer input element', 'Could not find Cursor Composer input element after pressing Cmd+I.');
|
||||
throw selectorError('Cursor Composer input element', 'Could not find Cursor Composer input element after pressing Cmd+I.');
|
||||
}
|
||||
await page.wait(0.5);
|
||||
await page.pressKey('Enter');
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { SelectorError } from '@jackwener/opencli/errors';
|
||||
import { selectorError } from '@jackwener/opencli/errors';
|
||||
export const sendCommand = cli({
|
||||
site: 'cursor',
|
||||
name: 'send',
|
||||
@@ -24,7 +24,7 @@ export const sendCommand = cli({
|
||||
return true;
|
||||
})(${JSON.stringify(textToInsert)})`);
|
||||
if (!injected) {
|
||||
throw new SelectorError('Cursor Composer input element');
|
||||
throw selectorError('Cursor Composer input element');
|
||||
}
|
||||
// Submit the command. In Cursor, Enter usually submits the chat.
|
||||
await page.wait(0.5);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { cli } from '@jackwener/opencli/registry';
|
||||
cli({
|
||||
site: 'instagram',
|
||||
name: 'collection-create',
|
||||
description: 'Create a new Instagram saved-posts collection (folder)',
|
||||
domain: 'www.instagram.com',
|
||||
args: [
|
||||
{
|
||||
name: 'name',
|
||||
required: true,
|
||||
positional: true,
|
||||
help: 'Name of the collection to create',
|
||||
},
|
||||
],
|
||||
columns: ['status', 'collectionId', 'collectionName', 'mediaCount'],
|
||||
pipeline: [
|
||||
{ navigate: 'https://www.instagram.com' },
|
||||
{ evaluate: `(async () => {
|
||||
const name = \${{ args.name | json }};
|
||||
if (!name || !String(name).trim()) {
|
||||
throw new Error('Collection name cannot be empty');
|
||||
}
|
||||
const trimmed = String(name).trim();
|
||||
const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '';
|
||||
if (!csrf) {
|
||||
throw new Error('csrftoken cookie missing - make sure you are logged in to Instagram');
|
||||
}
|
||||
const fd = new FormData();
|
||||
fd.append('name', trimmed);
|
||||
fd.append('module_name', 'collection_create');
|
||||
const res = await fetch('https://www.instagram.com/api/v1/collections/create/', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'X-IG-App-ID': '936619743392459',
|
||||
'X-CSRFToken': csrf,
|
||||
},
|
||||
body: fd,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error('Failed to create collection: HTTP ' + res.status + (body ? ' - ' + body.slice(0, 200) : ''));
|
||||
}
|
||||
const d = await res.json();
|
||||
if (d?.status && d.status !== 'ok') {
|
||||
throw new Error('Instagram returned non-ok status: ' + JSON.stringify(d).slice(0, 300));
|
||||
}
|
||||
return [{
|
||||
status: 'Created',
|
||||
collectionId: String(d?.collection_id ?? ''),
|
||||
collectionName: String(d?.collection_name ?? trimmed),
|
||||
mediaCount: d?.collection_media_count ?? 0,
|
||||
}];
|
||||
})()
|
||||
` },
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { cli } from '@jackwener/opencli/registry';
|
||||
cli({
|
||||
site: 'instagram',
|
||||
name: 'collection-delete',
|
||||
description: 'Delete an Instagram saved-posts collection (folder) by name or id',
|
||||
domain: 'www.instagram.com',
|
||||
args: [
|
||||
{
|
||||
name: 'target',
|
||||
required: true,
|
||||
positional: true,
|
||||
help: 'Collection name (case-insensitive) or numeric collection_id',
|
||||
},
|
||||
],
|
||||
columns: ['status', 'collectionId', 'collectionName'],
|
||||
pipeline: [
|
||||
{ navigate: 'https://www.instagram.com' },
|
||||
{ evaluate: `(async () => {
|
||||
const target = \${{ args.target | json }};
|
||||
if (!target || !String(target).trim()) {
|
||||
throw new Error('Collection target (name or id) cannot be empty');
|
||||
}
|
||||
const raw = String(target).trim();
|
||||
const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '';
|
||||
if (!csrf) {
|
||||
throw new Error('csrftoken cookie missing - make sure you are logged in to Instagram');
|
||||
}
|
||||
const headers = { 'X-IG-App-ID': '936619743392459' };
|
||||
|
||||
// Resolve name -> id via /collections/list/. Always go through this path so we can
|
||||
// surface an explicit error on duplicate names or unknown names instead of relying
|
||||
// on a 404.
|
||||
const listRes = await fetch('https://www.instagram.com/api/v1/collections/list/?collection_types=%5B%22MEDIA%22%5D', {
|
||||
credentials: 'include',
|
||||
headers,
|
||||
});
|
||||
if (!listRes.ok) {
|
||||
throw new Error('Failed to list collections: HTTP ' + listRes.status + ' - make sure you are logged in to Instagram');
|
||||
}
|
||||
const listData = await listRes.json();
|
||||
const collections = listData?.items || [];
|
||||
const isNumericId = /^\\d{6,}$/.test(raw);
|
||||
let id = '';
|
||||
let resolvedName = '';
|
||||
if (isNumericId) {
|
||||
const hit = collections.find((c) => String(c?.collection_id) === raw);
|
||||
if (!hit) {
|
||||
throw new Error('Collection id not found in your account: ' + raw);
|
||||
}
|
||||
id = String(hit.collection_id);
|
||||
resolvedName = String(hit.collection_name || '');
|
||||
} else {
|
||||
const wanted = raw.toLowerCase();
|
||||
const matches = collections.filter((c) => String(c?.collection_name || '').trim().toLowerCase() === wanted);
|
||||
if (matches.length === 0) {
|
||||
const names = collections.map((c) => c?.collection_name).filter(Boolean);
|
||||
throw new Error('Collection not found: ' + raw + '. Available: ' + (names.length ? names.join(', ') : '(none)'));
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
const ids = matches.map((c) => c.collection_id).join(', ');
|
||||
throw new Error('Multiple collections share the name "' + raw + '" (ids: ' + ids + '). Pass the numeric collection_id explicitly to disambiguate.');
|
||||
}
|
||||
id = String(matches[0].collection_id);
|
||||
resolvedName = String(matches[0].collection_name || raw);
|
||||
}
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('module_name', 'collection_settings');
|
||||
const res = await fetch('https://www.instagram.com/api/v1/collections/' + encodeURIComponent(id) + '/delete/', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { ...headers, 'X-CSRFToken': csrf },
|
||||
body: fd,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error('Failed to delete collection: HTTP ' + res.status + (body ? ' - ' + body.slice(0, 200) : ''));
|
||||
}
|
||||
const d = await res.json().catch(() => ({}));
|
||||
if (d?.status && d.status !== 'ok') {
|
||||
throw new Error('Instagram returned non-ok status: ' + JSON.stringify(d).slice(0, 300));
|
||||
}
|
||||
return [{
|
||||
status: 'Deleted',
|
||||
collectionId: id,
|
||||
collectionName: resolvedName,
|
||||
}];
|
||||
})()
|
||||
` },
|
||||
],
|
||||
});
|
||||
+21
-7
@@ -2,23 +2,37 @@ import { cli } from '@jackwener/opencli/registry';
|
||||
cli({
|
||||
site: 'instagram',
|
||||
name: 'saved',
|
||||
description: 'Get your saved Instagram posts',
|
||||
description: 'Get your saved Instagram posts (optionally from a specific collection)',
|
||||
domain: 'www.instagram.com',
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Number of saved posts' },
|
||||
{ name: 'collection', help: 'Collection name (case-insensitive). Omit for the default "All posts" feed.' },
|
||||
],
|
||||
columns: ['index', 'user', 'caption', 'likes', 'comments', 'type'],
|
||||
pipeline: [
|
||||
{ navigate: 'https://www.instagram.com' },
|
||||
{ evaluate: `(async () => {
|
||||
const limit = \${{ args.limit }};
|
||||
const res = await fetch(
|
||||
'https://www.instagram.com/api/v1/feed/saved/posts/',
|
||||
{
|
||||
credentials: 'include',
|
||||
headers: { 'X-IG-App-ID': '936619743392459' }
|
||||
const collectionArg = \${{ args.collection | json }};
|
||||
const headers = { 'X-IG-App-ID': '936619743392459' };
|
||||
const opts = { credentials: 'include', headers };
|
||||
|
||||
let endpoint = 'https://www.instagram.com/api/v1/feed/saved/posts/';
|
||||
if (collectionArg && String(collectionArg).trim()) {
|
||||
const wanted = String(collectionArg).trim().toLowerCase();
|
||||
const listRes = await fetch('https://www.instagram.com/api/v1/collections/list/?collection_types=%5B%22MEDIA%22%2C%22ALL_MEDIA_AUTO_COLLECTION%22%5D', opts);
|
||||
if (!listRes.ok) throw new Error('Failed to list collections: HTTP ' + listRes.status + ' - make sure you are logged in to Instagram');
|
||||
const listData = await listRes.json();
|
||||
const collections = listData?.items || [];
|
||||
const match = collections.find((c) => String(c?.collection_name || '').trim().toLowerCase() === wanted);
|
||||
if (!match) {
|
||||
const names = collections.map((c) => c?.collection_name).filter(Boolean);
|
||||
throw new Error('Collection not found: ' + collectionArg + '. Available: ' + (names.length ? names.join(', ') : '(none)'));
|
||||
}
|
||||
);
|
||||
endpoint = 'https://www.instagram.com/api/v1/feed/collection/' + encodeURIComponent(match.collection_id) + '/posts/';
|
||||
}
|
||||
|
||||
const res = await fetch(endpoint, opts);
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status + ' - make sure you are logged in to Instagram');
|
||||
const data = await res.json();
|
||||
return (data?.items || []).slice(0, limit).map((item, i) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AuthRequiredError, SelectorError } from '@jackwener/opencli/errors';
|
||||
import { AuthRequiredError, selectorError } from '@jackwener/opencli/errors';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
cli({
|
||||
site: 'twitter',
|
||||
@@ -49,7 +49,7 @@ cli({
|
||||
return false;
|
||||
}`);
|
||||
if (!clicked) {
|
||||
throw new SelectorError('Twitter followers link', 'Twitter may have changed the layout.');
|
||||
throw selectorError('Twitter followers link', 'Twitter may have changed the layout.');
|
||||
}
|
||||
await page.waitForCapture(5);
|
||||
// 4. Scroll to trigger pagination API calls
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { getSelfUid } from './utils.js';
|
||||
|
||||
const DEFAULT_LIMIT = 20;
|
||||
const MAX_LIMIT = 50;
|
||||
|
||||
function parsePositiveInt(value, name, defaultValue) {
|
||||
const raw = value ?? defaultValue;
|
||||
const number = Number(raw);
|
||||
if (!Number.isInteger(number) || number <= 0) {
|
||||
throw new ArgumentError(`weibo favorites ${name} must be a positive integer`);
|
||||
}
|
||||
if (number > MAX_LIMIT) {
|
||||
throw new ArgumentError(`weibo favorites ${name} must be <= ${MAX_LIMIT}`);
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
function parseFavoriteCard(card, favUrl) {
|
||||
const raw = String(card?.text ?? '');
|
||||
const lines = raw.split('\n');
|
||||
|
||||
let author = '';
|
||||
let time = '';
|
||||
let source = '';
|
||||
let content = '';
|
||||
let likes = '0';
|
||||
let comments = '0';
|
||||
let reposts = '0';
|
||||
|
||||
for (const line of lines) {
|
||||
const t = line.trim();
|
||||
if (!t || t === '添加') continue;
|
||||
|
||||
if (!time && /\d+小时前|\d+分钟前|\d+秒前|昨天|前天|\d{1,2}:\d{2}/.test(t)) {
|
||||
time = t;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (t.startsWith('来自')) {
|
||||
source = t;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (content) {
|
||||
const n = Number.parseInt(t, 10);
|
||||
if (!Number.isNaN(n) && n > 0 && n < 1_000_000 && t === String(n)) {
|
||||
if (likes === '0') likes = t;
|
||||
else if (comments === '0') comments = t;
|
||||
else if (reposts === '0') reposts = t;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!author && t.length < 40) {
|
||||
author = t;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!content && author) {
|
||||
content = t;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (content) content += ` ${t}`;
|
||||
}
|
||||
|
||||
if (!content || !author) return null;
|
||||
|
||||
return {
|
||||
author,
|
||||
text: content.substring(0, 300),
|
||||
time,
|
||||
source,
|
||||
likes,
|
||||
comments,
|
||||
reposts,
|
||||
url: card?.url || favUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function dedupeFavorites(items, favUrl) {
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const item of items) {
|
||||
const key = item.url && item.url !== favUrl
|
||||
? item.url
|
||||
: `${item.author}\n${item.text}\n${item.time}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'weibo',
|
||||
name: 'favorites',
|
||||
description: '我的微博收藏列表',
|
||||
domain: 'weibo.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 20, help: '数量(最多50)' },
|
||||
],
|
||||
columns: ['author', 'text', 'time', 'source', 'likes', 'comments', 'reposts', 'url'],
|
||||
func: async (page, kwargs) => {
|
||||
const limit = parsePositiveInt(kwargs.limit, 'limit', DEFAULT_LIMIT);
|
||||
|
||||
await page.goto('https://weibo.com');
|
||||
await page.wait(2);
|
||||
|
||||
const uid = await getSelfUid(page);
|
||||
|
||||
const favUrl = 'https://www.weibo.com/u/page/fav/' + uid;
|
||||
|
||||
await page.goto(favUrl);
|
||||
await page.wait(4);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await page.evaluate('() => window.scrollBy(0, 800)');
|
||||
await page.wait(1);
|
||||
}
|
||||
|
||||
const rawData = await page.evaluate(`
|
||||
(() => {
|
||||
const scrollers = document.querySelectorAll('.wbpro-scroller-item, .vue-recycle-scroller__item-view');
|
||||
const out = [];
|
||||
for (const s of scrollers) {
|
||||
// Use textContent to preserve newlines, then split by \n
|
||||
const bodyEl = s.querySelector('[class*="_body_"]') || s.querySelector('.wbpro-item-body') || s;
|
||||
// innerText preserves newlines between block elements (unlike textContent)
|
||||
const rawText = bodyEl.innerText || s.innerText || '';
|
||||
|
||||
let postUrl = '';
|
||||
const anchors = s.querySelectorAll('a[href]');
|
||||
for (const a of anchors) {
|
||||
const m = String(a.href).match(/weibo\\.com\\/(\\d+)\\/([a-zA-Z0-9]+)/);
|
||||
if (m) { postUrl = 'https://weibo.com/' + m[1] + '/' + m[2]; break; }
|
||||
}
|
||||
|
||||
if (rawText.length > 20) out.push({ text: rawText, url: postUrl });
|
||||
if (out.length >= ${limit}) break;
|
||||
}
|
||||
return out;
|
||||
})()
|
||||
`);
|
||||
|
||||
if (!Array.isArray(rawData) || rawData.length === 0) {
|
||||
throw new EmptyResultError('weibo favorites', 'No favorites were visible on the favorites page');
|
||||
}
|
||||
|
||||
const items = rawData
|
||||
.map(card => parseFavoriteCard(card, favUrl))
|
||||
.filter(Boolean);
|
||||
|
||||
const uniqueItems = dedupeFavorites(items, favUrl);
|
||||
if (uniqueItems.length === 0) {
|
||||
throw new CommandExecutionError('Failed to parse visible Weibo favorites');
|
||||
}
|
||||
return uniqueItems.slice(0, limit);
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = {
|
||||
parseFavoriteCard,
|
||||
parsePositiveInt,
|
||||
dedupeFavorites,
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
|
||||
import './favorites.js';
|
||||
|
||||
function makePage(evaluateResults = []) {
|
||||
const queue = [...evaluateResults];
|
||||
const evaluate = vi.fn(async (script) => {
|
||||
if (String(script).includes('window.scrollBy')) return undefined;
|
||||
return queue.length ? queue.shift() : [];
|
||||
});
|
||||
|
||||
return {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate,
|
||||
};
|
||||
}
|
||||
|
||||
describe('weibo favorites command', () => {
|
||||
const getCommand = () => getRegistry().get('weibo/favorites');
|
||||
|
||||
it('registers as a JS adapter and parses visible favorites', async () => {
|
||||
const command = getCommand();
|
||||
expect(command?.func).toBeTypeOf('function');
|
||||
|
||||
const page = makePage([
|
||||
'123456',
|
||||
[
|
||||
{
|
||||
text: [
|
||||
'作者A',
|
||||
'昨天 12:00',
|
||||
'来自 iPhone',
|
||||
'这是一条收藏微博',
|
||||
'12',
|
||||
'3',
|
||||
'2',
|
||||
].join('\n'),
|
||||
url: 'https://weibo.com/123/AbCd1',
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const result = await command.func(page, { limit: 10 });
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
author: '作者A',
|
||||
text: '这是一条收藏微博',
|
||||
time: '昨天 12:00',
|
||||
source: '来自 iPhone',
|
||||
likes: '12',
|
||||
comments: '3',
|
||||
reposts: '2',
|
||||
url: 'https://weibo.com/123/AbCd1',
|
||||
},
|
||||
]);
|
||||
expect(page.goto).toHaveBeenCalledWith('https://weibo.com');
|
||||
expect(page.goto).toHaveBeenCalledWith('https://www.weibo.com/u/page/fav/123456');
|
||||
});
|
||||
|
||||
it('throws AuthRequiredError when uid cannot be resolved', async () => {
|
||||
const command = getCommand();
|
||||
const page = makePage([null, null]);
|
||||
|
||||
await expect(command.func(page, { limit: 10 })).rejects.toBeInstanceOf(AuthRequiredError);
|
||||
});
|
||||
|
||||
it('validates limit before navigation', async () => {
|
||||
const command = getCommand();
|
||||
const page = makePage();
|
||||
|
||||
await expect(command.func(page, { limit: 0 })).rejects.toBeInstanceOf(ArgumentError);
|
||||
await expect(command.func(page, { limit: 51 })).rejects.toBeInstanceOf(ArgumentError);
|
||||
expect(page.goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws EmptyResultError when no favorite cards are visible', async () => {
|
||||
const command = getCommand();
|
||||
const page = makePage(['123456', []]);
|
||||
|
||||
await expect(command.func(page, { limit: 10 })).rejects.toBeInstanceOf(EmptyResultError);
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError when visible cards cannot be parsed', async () => {
|
||||
const command = getCommand();
|
||||
const page = makePage(['123456', [{ text: '添加\n昨天', url: '' }]]);
|
||||
|
||||
await expect(command.func(page, { limit: 10 })).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('deduplicates repeated cards and applies the requested limit', async () => {
|
||||
const command = getCommand();
|
||||
const rawCard = {
|
||||
text: '作者A\n内容A',
|
||||
url: 'https://weibo.com/123/AbCd1',
|
||||
};
|
||||
const page = makePage([
|
||||
'123456',
|
||||
[
|
||||
rawCard,
|
||||
rawCard,
|
||||
{ text: '作者B\n内容B', url: 'https://weibo.com/123/AbCd2' },
|
||||
],
|
||||
]);
|
||||
|
||||
const result = await command.func(page, { limit: 1 });
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].author).toBe('作者A');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Weibo publish — post a new Weibo update via browser UI automation.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Navigate to weibo.com and wait for the feed
|
||||
* 2. Check login state (getSelfUid)
|
||||
* 3. Click "发微博" button to open the inline compose editor
|
||||
* 4. Wait for textarea editor to appear
|
||||
* 5. Fill text content via CDP type
|
||||
* 6. Optionally upload images via CDP setFileInput
|
||||
* 7. Click the publish button
|
||||
* 8. Poll for success/failure feedback
|
||||
*
|
||||
* Usage:
|
||||
* opencli weibo publish "Hello from OpenCLI! #opencli" # publishes immediately
|
||||
* opencli weibo publish "Check this out" --images /path/a.jpg,/path/b.jpg
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { getSelfUid } from './utils.js';
|
||||
|
||||
const MAX_IMAGES = 9;
|
||||
const UPLOAD_POLL_MS = 1500;
|
||||
const UPLOAD_TIMEOUT_MS = 30_000;
|
||||
const COMPOSE_POLL_MS = 300;
|
||||
const COMPOSE_TIMEOUT_MS = 10_000;
|
||||
const SUBMIT_POLL_MS = 500;
|
||||
const SUBMIT_TIMEOUT_MS = 20_000;
|
||||
const SUPPORTED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
|
||||
|
||||
// Weibo PC UI selectors
|
||||
const TEXTAREA_SELECTOR = 'textarea._input_13iqr_8';
|
||||
const FILE_INPUT_SELECTOR = 'input[type="file"][class*="_file_"]';
|
||||
|
||||
function validateText(text) {
|
||||
const t = String(text ?? '').trim();
|
||||
if (!t) throw new ArgumentError('weibo publish text cannot be empty');
|
||||
if (t.length > 2000) throw new ArgumentError('weibo publish text exceeds 2000 characters');
|
||||
return t;
|
||||
}
|
||||
|
||||
function validateImagePaths(raw) {
|
||||
if (!raw) return [];
|
||||
const paths = raw.split(',').map(s => s.trim()).filter(Boolean);
|
||||
if (paths.length > MAX_IMAGES) {
|
||||
throw new ArgumentError(`Too many images: ${paths.length} (max ${MAX_IMAGES})`);
|
||||
}
|
||||
return paths.map(p => {
|
||||
const absPath = path.resolve(p);
|
||||
const ext = path.extname(absPath).toLowerCase();
|
||||
if (!SUPPORTED_EXTENSIONS.has(ext)) {
|
||||
throw new ArgumentError(`Unsupported image format "${ext}". Supported: jpg, png, gif, webp`);
|
||||
}
|
||||
const stat = fs.statSync(absPath, { throwIfNoEntry: false });
|
||||
if (!stat || !stat.isFile()) {
|
||||
throw new ArgumentError(`Not a valid file: ${absPath}`);
|
||||
}
|
||||
return absPath;
|
||||
});
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'weibo',
|
||||
name: 'publish',
|
||||
description: 'Publish a new Weibo post immediately',
|
||||
domain: 'weibo.com',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{
|
||||
name: 'text',
|
||||
type: 'string',
|
||||
required: true,
|
||||
positional: true,
|
||||
help: 'Weibo text content (max 2000 chars)',
|
||||
},
|
||||
{
|
||||
name: 'images',
|
||||
type: 'string',
|
||||
required: false,
|
||||
help: `Image paths, comma-separated, max ${MAX_IMAGES} (jpg/png/gif/webp)`,
|
||||
},
|
||||
],
|
||||
columns: ['status', 'message', 'text'],
|
||||
func: async (page, kwargs) => {
|
||||
if (!page) throw new CommandExecutionError('Browser session required for weibo publish');
|
||||
|
||||
const text = validateText(kwargs.text);
|
||||
const absPaths = validateImagePaths(kwargs.images);
|
||||
|
||||
// Step 1: Navigate to weibo.com and wait for feed to load
|
||||
await page.goto('https://weibo.com', { waitUntil: 'load', settleMs: 2000 });
|
||||
await page.wait({ time: 2 });
|
||||
|
||||
// Step 2: Check login
|
||||
try {
|
||||
await getSelfUid(page);
|
||||
} catch (err) {
|
||||
if (err instanceof AuthRequiredError) throw err;
|
||||
throw new CommandExecutionError('Not logged into Weibo. Please login at weibo.com in your Chrome browser.');
|
||||
}
|
||||
|
||||
// Step 3: Click "发微博" button to open inline compose editor
|
||||
const clickResult = await page.evaluate(`
|
||||
() => {
|
||||
const visible = el => !!el && el.offsetParent !== null && !el.disabled;
|
||||
const buttons = document.querySelectorAll('button[title="发微博"], button[title="写微博"]');
|
||||
for (const btn of buttons) {
|
||||
if (visible(btn)) {
|
||||
btn.click();
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
return { ok: false, message: 'Could not find 发微博 button' };
|
||||
}
|
||||
`);
|
||||
if (!clickResult?.ok) {
|
||||
throw new CommandExecutionError(clickResult?.message ?? 'Could not open compose editor.');
|
||||
}
|
||||
|
||||
// Step 4: Wait for the textarea editor to appear (visible, not just in DOM)
|
||||
let editorFound = false;
|
||||
for (let i = 0; i < Math.ceil(COMPOSE_TIMEOUT_MS / COMPOSE_POLL_MS); i++) {
|
||||
const result = await page.evaluate(`
|
||||
() => {
|
||||
const ta = document.querySelector('textarea._input_13iqr_8');
|
||||
if (!ta) return { found: false };
|
||||
const visible = ta.offsetParent !== null;
|
||||
return { found: true, visible, rectTop: visible ? ta.getBoundingClientRect().top : -1 };
|
||||
}
|
||||
`);
|
||||
if (result?.found && result.visible && result.rectTop >= 0) {
|
||||
editorFound = true;
|
||||
break;
|
||||
}
|
||||
await page.wait({ time: COMPOSE_POLL_MS / 1000 });
|
||||
}
|
||||
if (!editorFound) {
|
||||
throw new CommandExecutionError('Weibo compose editor did not appear');
|
||||
}
|
||||
|
||||
// Step 5: Upload images first (before text to avoid editor reset)
|
||||
if (absPaths.length > 0) {
|
||||
if (!page.setFileInput) {
|
||||
throw new CommandExecutionError('Browser extension does not support file upload. Please update the extension.');
|
||||
}
|
||||
|
||||
// Find the file input
|
||||
const fileInputFound = await page.evaluate(`
|
||||
() => {
|
||||
const input = document.querySelector('input[type="file"][class*="_file_"]');
|
||||
return !!input;
|
||||
}
|
||||
`);
|
||||
if (!fileInputFound) {
|
||||
throw new CommandExecutionError('Could not find image file input on Weibo compose page. UI may have changed.');
|
||||
}
|
||||
|
||||
await page.setFileInput(absPaths, FILE_INPUT_SELECTOR);
|
||||
|
||||
// Wait for upload to complete
|
||||
let uploadResult = null;
|
||||
for (let i = 0; i < Math.ceil(UPLOAD_TIMEOUT_MS / UPLOAD_POLL_MS); i++) {
|
||||
await page.wait({ time: UPLOAD_POLL_MS / 1000 });
|
||||
uploadResult = await page.evaluateWithArgs(`
|
||||
(() => {
|
||||
const expectedCount = expected;
|
||||
const uploading = document.querySelector('[class*="upload"], [class*="progress"]');
|
||||
if (uploading && uploading.offsetParent !== null) return null;
|
||||
const pics = document.querySelectorAll('img[class*="pic"], [class*="imgItem"], [class*="picture"] img');
|
||||
if (pics.length >= expectedCount) return { ok: true, count: pics.length };
|
||||
return null;
|
||||
})()
|
||||
`, { expected: absPaths.length });
|
||||
if (uploadResult !== null) break;
|
||||
}
|
||||
|
||||
if (!uploadResult?.ok) {
|
||||
throw new CommandExecutionError(uploadResult?.message ?? 'Image upload did not complete before timeout');
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: Insert text using native DOM setter (preserves Weibo internal state)
|
||||
// IMPORTANT: Using nativeSetter preserves the textarea's reactive/internal state.
|
||||
// Direct ta.value= assignment bypasses Weibo's Vue reactivity and causes "undefined" content.
|
||||
const insertResult = await page.evaluateWithArgs(`
|
||||
(() => {
|
||||
const ta = document.querySelector('textarea._input_13iqr_8');
|
||||
if (!ta || ta.offsetParent === null) return { ok: false, message: 'textarea not visible' };
|
||||
ta.focus();
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
|
||||
if (nativeSetter) {
|
||||
nativeSetter.call(ta, textContent);
|
||||
} else {
|
||||
ta.value = textContent;
|
||||
}
|
||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
ta.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return { ok: true, valueLength: ta.value.length };
|
||||
})()
|
||||
`, { textContent: text });
|
||||
|
||||
if (!insertResult?.ok) {
|
||||
throw new CommandExecutionError(insertResult?.message ?? 'Could not insert text.');
|
||||
}
|
||||
|
||||
|
||||
// Step 7: Click the send button inside the compose editor
|
||||
// Try 发送 first (compose editor's submit), then 发布 (fallback)
|
||||
await page.wait({ time: 0.5 });
|
||||
const publishResult = await page.evaluate(`
|
||||
() => {
|
||||
const visible = el => !!el && el.offsetParent !== null && !el.disabled;
|
||||
const labels = ['发送', '发布'];
|
||||
for (const label of labels) {
|
||||
const allBtns = document.querySelectorAll('button, [role="button"]');
|
||||
for (const btn of allBtns) {
|
||||
const t = (btn.innerText || btn.textContent || '').trim();
|
||||
if (t === label && visible(btn)) {
|
||||
btn.click();
|
||||
return { ok: true, label };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ok: false, message: 'Could not find send button' };
|
||||
}
|
||||
`);
|
||||
if (!publishResult?.ok) {
|
||||
throw new CommandExecutionError(publishResult?.message ?? 'Could not click publish.');
|
||||
}
|
||||
|
||||
// Step 8: Wait for success/failure result
|
||||
let finalResult = null;
|
||||
for (let i = 0; i < Math.ceil(SUBMIT_TIMEOUT_MS / SUBMIT_POLL_MS); i++) {
|
||||
await page.wait({ time: SUBMIT_POLL_MS / 1000 });
|
||||
finalResult = await page.evaluateWithArgs(`
|
||||
(() => {
|
||||
const successMarkers = ['发布成功', '已发布', '发送成功'];
|
||||
const errorMarkers = ['发布失败', '发送失败', '内容违规', '请稍后再试', '频繁'];
|
||||
for (const el of document.querySelectorAll('*')) {
|
||||
if (el.children.length > 3) continue;
|
||||
const txt = (el.innerText || '').trim();
|
||||
if (!txt || txt.length > 100) continue;
|
||||
for (const m of successMarkers) {
|
||||
if (txt.includes(m) && (txt.includes('成功') || txt.includes('微博'))) {
|
||||
return { ok: true, message: txt };
|
||||
}
|
||||
}
|
||||
for (const m of errorMarkers) {
|
||||
if (txt.includes(m)) {
|
||||
return { ok: false, message: txt };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})()
|
||||
`, { maxIterations: Math.ceil(SUBMIT_TIMEOUT_MS / SUBMIT_POLL_MS), currentIndex: i });
|
||||
if (finalResult !== null) break;
|
||||
}
|
||||
|
||||
if (!finalResult) {
|
||||
throw new CommandExecutionError('Publish button clicked but result was unclear. Check Weibo manually.');
|
||||
}
|
||||
|
||||
if (!finalResult.ok) {
|
||||
throw new CommandExecutionError(finalResult.message || 'Weibo publish failed');
|
||||
}
|
||||
|
||||
return [{
|
||||
status: 'success',
|
||||
message: finalResult.message || 'Published successfully',
|
||||
text,
|
||||
}];
|
||||
},
|
||||
});
|
||||
|
||||
export const __test__ = {
|
||||
validateText,
|
||||
validateImagePaths,
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
statSync: vi.fn((p) => {
|
||||
if (String(p).includes('missing')) return undefined;
|
||||
return { isFile: () => !String(p).includes('directory') };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('node:path', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
resolve: vi.fn((p) => `/abs/${p}`),
|
||||
extname: vi.fn((p) => {
|
||||
const m = String(p).match(/\.[^.]+$/);
|
||||
return m ? m[0] : '';
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import './publish.js';
|
||||
|
||||
function makePage({ evaluateResults = [], evaluateWithArgsResults = [], overrides = {} } = {}) {
|
||||
const evaluate = vi.fn();
|
||||
for (const result of evaluateResults) {
|
||||
evaluate.mockResolvedValueOnce(result);
|
||||
}
|
||||
evaluate.mockResolvedValue({ ok: true });
|
||||
|
||||
const evaluateWithArgs = vi.fn();
|
||||
for (const result of evaluateWithArgsResults) {
|
||||
evaluateWithArgs.mockResolvedValueOnce(result);
|
||||
}
|
||||
evaluateWithArgs.mockResolvedValue(null);
|
||||
|
||||
return {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate,
|
||||
evaluateWithArgs,
|
||||
setFileInput: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('weibo publish command', () => {
|
||||
const getCommand = () => getRegistry().get('weibo/publish');
|
||||
|
||||
it('publishes a text-only post when the UI reports success', async () => {
|
||||
const command = getCommand();
|
||||
const page = makePage({
|
||||
evaluateResults: [
|
||||
'123456',
|
||||
{ ok: true },
|
||||
{ found: true, visible: true, rectTop: 100 },
|
||||
{ ok: true, label: '发送' },
|
||||
],
|
||||
evaluateWithArgsResults: [
|
||||
{ ok: true, valueLength: 5 },
|
||||
{ ok: true, message: '发送成功' },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await command.func(page, { text: 'hello' });
|
||||
|
||||
expect(result).toEqual([{ status: 'success', message: '发送成功', text: 'hello' }]);
|
||||
expect(page.goto).toHaveBeenCalledWith('https://weibo.com', { waitUntil: 'load', settleMs: 2000 });
|
||||
});
|
||||
|
||||
it('uploads up to nine images before publishing', async () => {
|
||||
const command = getCommand();
|
||||
const page = makePage({
|
||||
evaluateResults: [
|
||||
'123456',
|
||||
{ ok: true },
|
||||
{ found: true, visible: true, rectTop: 100 },
|
||||
true,
|
||||
{ ok: true, label: '发送' },
|
||||
],
|
||||
evaluateWithArgsResults: [
|
||||
{ ok: true, count: 2 },
|
||||
{ ok: true, valueLength: 11 },
|
||||
{ ok: true, message: '发送成功' },
|
||||
],
|
||||
});
|
||||
|
||||
await command.func(page, { text: 'with images', images: 'a.png,b.webp' });
|
||||
|
||||
expect(page.setFileInput).toHaveBeenCalledWith(
|
||||
['/abs/a.png', '/abs/b.webp'],
|
||||
'input[type="file"][class*="_file_"]',
|
||||
);
|
||||
});
|
||||
|
||||
it('maps auth failures to AuthRequiredError', async () => {
|
||||
const command = getCommand();
|
||||
const page = makePage({ evaluateResults: [null, null] });
|
||||
|
||||
await expect(command.func(page, { text: 'hello' })).rejects.toBeInstanceOf(AuthRequiredError);
|
||||
});
|
||||
|
||||
it('validates text and image arguments before navigation', async () => {
|
||||
const command = getCommand();
|
||||
const page = makePage();
|
||||
|
||||
await expect(command.func(page, { text: ' ' })).rejects.toBeInstanceOf(ArgumentError);
|
||||
await expect(command.func(page, { text: 'hi', images: 'a.bmp' })).rejects.toBeInstanceOf(ArgumentError);
|
||||
await expect(command.func(page, { text: 'hi', images: 'missing.png' })).rejects.toBeInstanceOf(ArgumentError);
|
||||
await expect(command.func(page, { text: 'hi', images: '1.png,2.png,3.png,4.png,5.png,6.png,7.png,8.png,9.png,10.png' })).rejects.toBeInstanceOf(ArgumentError);
|
||||
expect(page.goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError when compose cannot be opened', async () => {
|
||||
const command = getCommand();
|
||||
const page = makePage({
|
||||
evaluateResults: ['123456', { ok: false, message: 'Could not find 发微博 button' }],
|
||||
});
|
||||
|
||||
await expect(command.func(page, { text: 'hello' })).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError when upload readiness is not proven', async () => {
|
||||
const command = getCommand();
|
||||
const page = makePage({
|
||||
evaluateResults: [
|
||||
'123456',
|
||||
{ ok: true },
|
||||
{ found: true, visible: true, rectTop: 100 },
|
||||
true,
|
||||
],
|
||||
evaluateWithArgsResults: [null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null],
|
||||
});
|
||||
|
||||
await expect(command.func(page, { text: 'hello', images: 'a.png' })).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('throws CommandExecutionError when publish result is unclear or failed', async () => {
|
||||
const command = getCommand();
|
||||
const page = makePage({
|
||||
evaluateResults: [
|
||||
'123456',
|
||||
{ ok: true },
|
||||
{ found: true, visible: true, rectTop: 100 },
|
||||
{ ok: true, label: '发送' },
|
||||
],
|
||||
evaluateWithArgsResults: [
|
||||
{ ok: true, valueLength: 5 },
|
||||
{ ok: false, message: '内容违规' },
|
||||
],
|
||||
});
|
||||
|
||||
await expect(command.func(page, { text: 'hello' })).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('does not treat editor close as positive publish proof', async () => {
|
||||
const command = getCommand();
|
||||
const page = makePage({
|
||||
evaluateResults: [
|
||||
'123456',
|
||||
{ ok: true },
|
||||
{ found: true, visible: true, rectTop: 100 },
|
||||
{ ok: true, label: '发送' },
|
||||
],
|
||||
evaluateWithArgsResults: [
|
||||
{ ok: true, valueLength: 5 },
|
||||
null,
|
||||
],
|
||||
});
|
||||
|
||||
await expect(command.func(page, { text: 'hello' })).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
|
||||
const submitScript = page.evaluateWithArgs.mock.calls.at(-1)[0];
|
||||
expect(submitScript).not.toContain('Editor closed after publish');
|
||||
expect(submitScript).toContain('发布成功');
|
||||
});
|
||||
});
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
import { AuthRequiredError, SelectorError } from '@jackwener/opencli/errors';
|
||||
import { AuthRequiredError, selectorError } from '@jackwener/opencli/errors';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { normalizeNumericId } from './utils.js';
|
||||
function buildChatUrl(itemId, peerUserId) {
|
||||
@@ -105,7 +105,7 @@ cli({
|
||||
throw new AuthRequiredError('www.goofish.com', 'Xianyu chat requires a logged-in browser session');
|
||||
}
|
||||
if (!state?.can_input) {
|
||||
throw new SelectorError('闲鱼聊天输入框', '未找到可用的聊天输入框,请确认该会话页已正确加载');
|
||||
throw selectorError('闲鱼聊天输入框', '未找到可用的聊天输入框,请确认该会话页已正确加载');
|
||||
}
|
||||
if (!text) {
|
||||
return [{
|
||||
@@ -123,7 +123,7 @@ cli({
|
||||
}
|
||||
const sent = await page.evaluate(buildSendMessageEvaluate(text));
|
||||
if (!sent?.ok) {
|
||||
throw new SelectorError('闲鱼发送按钮', `消息发送失败:${sent?.reason || 'unknown-reason'}`);
|
||||
throw selectorError('闲鱼发送按钮', `消息发送失败:${sent?.reason || 'unknown-reason'}`);
|
||||
}
|
||||
await page.wait(1);
|
||||
return [{
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import { AuthRequiredError, EmptyResultError, SelectorError } from '@jackwener/opencli/errors';
|
||||
import { AuthRequiredError, EmptyResultError, selectorError } from '@jackwener/opencli/errors';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { normalizeNumericId } from './utils.js';
|
||||
function buildItemUrl(itemId) {
|
||||
@@ -127,7 +127,7 @@ cli({
|
||||
throw new EmptyResultError('xianyu item', 'Xianyu item detail is blocked by verification or risk control');
|
||||
}
|
||||
if (result?.error === 'mtop-not-ready') {
|
||||
throw new SelectorError('window.lib.mtop', '闲鱼页面未完成初始化,无法调用商品详情接口');
|
||||
throw selectorError('window.lib.mtop', '闲鱼页面未完成初始化,无法调用商品详情接口');
|
||||
}
|
||||
if (!result || typeof result !== 'object') {
|
||||
throw new EmptyResultError('xianyu item', '闲鱼商品详情接口未返回有效数据');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { AuthRequiredError, EmptyResultError, SelectorError } from '@jackwener/opencli/errors';
|
||||
import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import { __test__ } from './item.js';
|
||||
import './item.js';
|
||||
@@ -49,8 +49,8 @@ describe('xianyu item command', () => {
|
||||
const page = createPageMock({ error: 'blocked' });
|
||||
await expect(command.func(page, { item_id: '1040754408976' })).rejects.toBeInstanceOf(EmptyResultError);
|
||||
});
|
||||
it('keeps SelectorError for true mtop initialization failures', async () => {
|
||||
it('keeps SELECTOR code for true mtop initialization failures', async () => {
|
||||
const page = createPageMock({ error: 'mtop-not-ready' });
|
||||
await expect(command.func(page, { item_id: '1040754408976' })).rejects.toBeInstanceOf(SelectorError);
|
||||
await expect(command.func(page, { item_id: '1040754408976' })).rejects.toMatchObject({ code: 'SELECTOR' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
# Self-Repair Protocol — Design Document
|
||||
|
||||
**Authors**: @opus0, @codex-mini0
|
||||
**Date**: 2026-04-07
|
||||
**Status**: Approved
|
||||
**Authors**: @opus0, @codex-mini0
|
||||
|
||||
**Date**: 2026-04-07
|
||||
|
||||
**Status**: Approved, updated for trace-based repair
|
||||
|
||||
**Supersedes**: `designs/autofix-incident-repair.md` (PR #863, deferred to Phase 2)
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
When an AI agent uses `opencli <site> <command>` and the command fails (site changed DOM, API, or response schema), the agent should **automatically repair the adapter and retry** — without human intervention or pre-written spec files.
|
||||
When an AI agent uses `opencli <site> <command>` and the command fails because the site changed DOM, API, or response schema, the agent should automatically repair the adapter and retry without human intervention or pre-written spec files.
|
||||
|
||||
### Why the simpler approach
|
||||
From first principles, the agent needs five things:
|
||||
|
||||
The previous design (PR #863) required pre-authoring `command-specs.json` with verify checks, safety profiles, and failure taxonomy before any command could be repaired. This created a chicken-and-egg problem: you can only repair commands you've already written specs for.
|
||||
1. The failing command it just ran.
|
||||
2. The structured error envelope from stderr.
|
||||
3. The adapter source path.
|
||||
4. Browser runtime evidence: actions, page state, network, console, screenshot.
|
||||
5. A verify oracle: re-run the same command.
|
||||
|
||||
From first principles, the agent already has everything it needs:
|
||||
1. **The failing command** — it just ran it
|
||||
2. **The error output** — stdout/stderr
|
||||
3. **The adapter source** — resolved via `RepairContext.adapter.sourcePath`
|
||||
4. **Diagnostic context** — DOM snapshot, network requests (via `OPENCLI_DIAGNOSTIC=1`)
|
||||
5. **A verify oracle** — re-run the same command
|
||||
|
||||
No spec file needed. The command itself is the spec.
|
||||
The command itself is the spec. The trace artifact is the evidence channel.
|
||||
|
||||
---
|
||||
|
||||
@@ -30,31 +30,34 @@ No spec file needed. The command itself is the spec.
|
||||
|
||||
### Core Protocol
|
||||
|
||||
```
|
||||
```text
|
||||
Agent runs: opencli <site> <command> [args...]
|
||||
→ Command succeeds → continue task
|
||||
→ Command fails →
|
||||
1. Re-run with OPENCLI_DIAGNOSTIC=1 to collect RepairContext
|
||||
2. Read adapter source from RepairContext.adapter.sourcePath
|
||||
3. Analyze: error code + DOM snapshot + network requests → root cause
|
||||
4. Edit the adapter file at RepairContext.adapter.sourcePath
|
||||
5. Retry the original command
|
||||
6. If still failing → repeat (max 3 rounds)
|
||||
7. If 3 rounds exhausted → report failure, do not loop further
|
||||
-> Command succeeds -> continue task
|
||||
-> Command fails ->
|
||||
1. Re-run with --trace retain-on-failure to collect a trace artifact
|
||||
2. Read trace.summaryPath from the error envelope
|
||||
3. Read adapterSourcePath from summary.md front matter
|
||||
4. Analyze: error code + failed network + console + state/action timeline -> root cause
|
||||
5. Edit the adapter file at adapterSourcePath
|
||||
6. Retry the original command
|
||||
7. If still failing -> repeat (max 3 rounds)
|
||||
8. If 3 rounds exhausted -> report failure, do not loop further
|
||||
```
|
||||
|
||||
### Scope Constraint
|
||||
|
||||
**Only modify the adapter file identified by `RepairContext.adapter.sourcePath`.**
|
||||
Only modify the adapter file identified by `adapterSourcePath` in trace `summary.md` front matter.
|
||||
|
||||
The diagnostic resolves the actual editable source path at runtime — it may be:
|
||||
- `clis/<site>/*.js` — repo-local adapters (dev/source checkout)
|
||||
- `~/.opencli/clis/<site>/*.js` — user-local adapters (npm install scenario)
|
||||
That path may be:
|
||||
|
||||
The agent must use the path from the diagnostic, not guess a repo-relative path. This is critical for npm-installed users where `clis/` is not in the repo.
|
||||
- `clis/<site>/*.js` — repo-local adapters in a source checkout
|
||||
- `~/.opencli/clis/<site>/*.js` — user-local adapters in npm install scenarios
|
||||
|
||||
**Never modify:**
|
||||
- `src/**` — core runtime (npm package, requires version release)
|
||||
The agent must use the trace summary path, not guess a repo-relative path. This matters for npm-installed users where `clis/` may not be in the working directory.
|
||||
|
||||
Never modify:
|
||||
|
||||
- `src/**` — core runtime
|
||||
- `extension/**` — browser extension
|
||||
- `autoresearch/**` — research infrastructure
|
||||
- `tests/**` — test files
|
||||
@@ -62,8 +65,6 @@ The agent must use the path from the diagnostic, not guess a repo-relative path.
|
||||
|
||||
### When NOT to Self-Repair
|
||||
|
||||
The agent should recognize non-repairable failures and stop:
|
||||
|
||||
| Signal | Meaning | Action |
|
||||
|--------|---------|--------|
|
||||
| Auth/login error | Not logged into site in Chrome | Tell user to log in, don't modify code |
|
||||
@@ -74,63 +75,59 @@ The agent should recognize non-repairable failures and stop:
|
||||
|
||||
### Retry Budget
|
||||
|
||||
- **Max 3 repair rounds per command failure**
|
||||
- Each round: diagnose → edit adapter → retry command
|
||||
- If the error is identical after a repair attempt, the fix didn't work — try a different approach
|
||||
- After 3 rounds, stop and report what was tried
|
||||
- Max 3 repair rounds per command failure.
|
||||
- Each round: trace -> edit adapter -> retry command.
|
||||
- If the error is identical after a repair attempt, the fix didn't work. Try a different approach.
|
||||
- After 3 rounds, stop and report what was tried.
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### What Already Exists
|
||||
|
||||
| Component | Status | Location |
|
||||
|-----------|--------|----------|
|
||||
| Diagnostic output (RepairContext) | ✅ Done | `src/diagnostic.ts` |
|
||||
| Diagnostic wiring in execution | ✅ Done | `src/execution.ts` |
|
||||
| Error taxonomy (CliError codes) | ✅ Done | `src/errors.ts` |
|
||||
| Adapter source resolution | ✅ Done | `src/diagnostic.ts:resolveAdapterSourcePath` |
|
||||
| Trace artifact output | Done | `src/observation/` |
|
||||
| Error envelope trace metadata | Done | `src/errors.ts`, `src/execution.ts` |
|
||||
| Adapter source resolution | Done | `src/adapter-source.ts` |
|
||||
| AutoFix skill protocol | Done | `skills/opencli-autofix/SKILL.md` |
|
||||
|
||||
### What's New (This Design)
|
||||
### Delivery Mechanism
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| `skills/opencli-autofix/SKILL.md` (renamed from `opencli-repair`) | AutoFix skill with safety boundaries, sourcePath-based scope, 3-round limit. The primary delivery mechanism for the self-repair protocol. |
|
||||
| `skills/opencli-usage/SKILL.md` (updated) | Self-Repair section for discoverability |
|
||||
The `opencli-autofix` skill is the portable self-repair protocol. Any AI agent can load this skill to get the workflow.
|
||||
|
||||
### Delivery mechanism
|
||||
No separate diagnostic env var is required. The runtime has two control axes:
|
||||
|
||||
The `opencli-autofix` skill is the portable self-repair protocol. Any AI agent — regardless of framework, provider, or working directory — can load this skill to get the full autofix workflow. It is not tied to any specific agent framework or repo location.
|
||||
|
||||
- **No new runtime code** — the diagnostic infrastructure already exists
|
||||
- **No CLAUDE.md dependency** — the skill is the protocol, not a repo-local file
|
||||
```text
|
||||
-v / OPENCLI_VERBOSE human-readable logs
|
||||
--trace off|on|retain-on-failure machine-readable browser evidence artifact
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## The AutoFix Protocol (in the skill)
|
||||
## The AutoFix Protocol
|
||||
|
||||
The `opencli-autofix` skill instructs agents:
|
||||
|
||||
1. When `opencli <site> <command>` fails, **don't just report the error**
|
||||
2. Re-run with `OPENCLI_DIAGNOSTIC=1` to get structured context
|
||||
3. Parse the RepairContext (error code, adapter source, DOM snapshot)
|
||||
4. Read and fix the adapter at `RepairContext.adapter.sourcePath`
|
||||
5. Retry the original command
|
||||
6. If the retry passes, ask whether to file an upstream GitHub issue for `jackwener/OpenCLI`
|
||||
7. If approved and `gh` is available, file the issue using a structured summary
|
||||
8. Max 3 repair rounds, then stop
|
||||
1. When `opencli <site> <command>` fails, don't just report the error.
|
||||
2. Re-run with `--trace retain-on-failure`.
|
||||
3. Read the error envelope `trace.summaryPath`.
|
||||
4. Parse `summary.md` front matter for `adapterSourcePath`.
|
||||
5. Read and fix the adapter at that exact path.
|
||||
6. Retry the original command.
|
||||
7. If the retry passes, ask whether to file an upstream GitHub issue for `jackwener/OpenCLI`.
|
||||
8. If approved and `gh` is available, file the issue using a structured summary.
|
||||
9. Max 3 repair rounds, then stop.
|
||||
|
||||
---
|
||||
|
||||
## Relationship to PR #863
|
||||
|
||||
PR #863 (spec/runner/incident framework) is **not needed for Phase 1**. It becomes useful later as a "hardening layer":
|
||||
PR #863 (spec/runner/incident framework) is not needed for Phase 1. It becomes useful later as a hardening layer:
|
||||
|
||||
- **Phase 1 (now)**: Self-Repair via `opencli-autofix` skill — agent repairs on the fly
|
||||
- **Phase 2 (later)**: High-frequency failures get hardened into `command-specs.json` for offline regression testing and CI
|
||||
- Phase 1: self-repair via `opencli-autofix` skill and trace artifacts.
|
||||
- Phase 2: high-frequency failures get hardened into command specs for offline regression testing and CI.
|
||||
|
||||
The spec/runner framework is the "asset layer" — it turns ad-hoc repairs into reusable, verifiable test cases. But it's not the entry point.
|
||||
The spec/runner framework is the asset layer. It turns ad-hoc repairs into reusable tests, but it is not the entry point.
|
||||
|
||||
---
|
||||
|
||||
@@ -143,11 +140,12 @@ No new commands. No new scripts. The agent loads the `opencli-autofix` skill and
|
||||
opencli weibo hot --limit 5 -f json
|
||||
|
||||
# If it fails, the agent automatically:
|
||||
# 1. Runs OPENCLI_DIAGNOSTIC=1 opencli weibo hot --limit 5 -f json 2>diag.json
|
||||
# 2. Reads the diagnostic context
|
||||
# 3. Fixes the adapter at RepairContext.adapter.sourcePath
|
||||
# 4. Retries: opencli weibo hot --limit 5 -f json
|
||||
# 5. If retry passes, asks whether to file an upstream issue
|
||||
# 6. If approved, runs `gh issue create --repo jackwener/OpenCLI ...`
|
||||
# 7. Continues with the task
|
||||
# 1. Runs opencli weibo hot --limit 5 -f json --trace retain-on-failure 2>trace-error.yaml
|
||||
# 2. Reads trace.summaryPath from trace-error.yaml
|
||||
# 3. Reads adapterSourcePath from summary.md
|
||||
# 4. Fixes the adapter at adapterSourcePath
|
||||
# 5. Retries: opencli weibo hot --limit 5 -f json
|
||||
# 6. If retry passes, asks whether to file an upstream issue
|
||||
# 7. If approved, runs `gh issue create --repo jackwener/OpenCLI ...`
|
||||
# 8. Continues with the task
|
||||
```
|
||||
|
||||
@@ -33,6 +33,7 @@ export default defineConfig({
|
||||
{ text: 'Browser Bridge', link: '/guide/browser-bridge' },
|
||||
{ text: 'Troubleshooting', link: '/guide/troubleshooting' },
|
||||
{ text: 'Add an Electron App CLI', link: '/guide/electron-app-cli' },
|
||||
{ text: 'Extending OpenCLI', link: '/guide/extending-opencli' },
|
||||
{ text: 'Plugins', link: '/guide/plugins' },
|
||||
],
|
||||
},
|
||||
@@ -79,6 +80,7 @@ export default defineConfig({
|
||||
{ text: '1688', link: '/adapters/browser/1688' },
|
||||
{ text: 'Gitee', link: '/adapters/browser/gitee' },
|
||||
{ text: 'Gemini', link: '/adapters/browser/gemini' },
|
||||
{ text: 'Claude', link: '/adapters/browser/claude' },
|
||||
{ text: 'Yuanbao', link: '/adapters/browser/yuanbao' },
|
||||
{ text: 'NotebookLM', link: '/adapters/browser/notebooklm' },
|
||||
{ text: 'WeRead', link: '/adapters/browser/weread' },
|
||||
@@ -193,6 +195,7 @@ export default defineConfig({
|
||||
{ text: '安装', link: '/zh/guide/installation' },
|
||||
{ text: 'Browser Bridge', link: '/zh/guide/browser-bridge' },
|
||||
{ text: '给新 Electron 应用生成 CLI', link: '/zh/guide/electron-app-cli' },
|
||||
{ text: '扩展 OpenCLI', link: '/zh/guide/extending-opencli' },
|
||||
{ text: '插件', link: '/zh/guide/plugins' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Claude
|
||||
|
||||
**Mode**: Browser · **Domain**: `claude.ai`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli claude ask <prompt>` | Send a prompt and get the response |
|
||||
| `opencli claude send <prompt>` | Send a prompt without waiting for the response |
|
||||
| `opencli claude new` | Start a new conversation |
|
||||
| `opencli claude status` | Check login state and page availability |
|
||||
| `opencli claude read` | Read the current conversation |
|
||||
| `opencli claude history` | List recent conversations from `/recents` |
|
||||
| `opencli claude detail <id>` | Open a conversation by ID and read its messages |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Ask a question
|
||||
opencli claude ask "explain quicksort in 3 sentences"
|
||||
|
||||
# Start a new chat before asking
|
||||
opencli claude ask "hello" --new
|
||||
|
||||
# Pick the model (default: sonnet; opus is paid-tier)
|
||||
opencli claude ask "quick summary" --model haiku
|
||||
|
||||
# Enable Adaptive thinking
|
||||
opencli claude ask "prove that sqrt(2) is irrational" --think
|
||||
|
||||
# Attach a file (image / PDF / text, up to ~1 MB raw)
|
||||
opencli claude ask "describe this image" --file ./photo.png
|
||||
|
||||
# Combine modes
|
||||
opencli claude ask "what does this PDF cover?" --file ./paper.pdf --think --new
|
||||
|
||||
# Custom timeout (default: 120s)
|
||||
opencli claude ask "write a long essay" --timeout 240
|
||||
|
||||
# JSON output
|
||||
opencli claude ask "hello" -f json
|
||||
```
|
||||
|
||||
### Options (ask)
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `<prompt>` | The message to send (required, positional) |
|
||||
| `--timeout` | Wait timeout in seconds (default: 120) |
|
||||
| `--new` | Start a new chat before sending (default: false) |
|
||||
| `--model` | Model to use: `sonnet`, `opus`, or `haiku` (default: sonnet) |
|
||||
| `--think` | Enable Adaptive thinking (default: false) |
|
||||
| `--file` | Attach a file (image, PDF, text) with the prompt |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running with [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
- Logged in to [claude.ai](https://claude.ai)
|
||||
|
||||
## Caveats
|
||||
|
||||
- This adapter drives the Claude web UI in the browser, not an API
|
||||
- `claude read` queries the current automation tab; pair it with `--live` on the prior command (or chain after `claude detail <id>`) so the tab stays on the conversation between invocations
|
||||
- `--model opus` requires a paid Claude plan; on a free-tier account the adapter surfaces a usage error rather than silently falling back
|
||||
- The default Sonnet 4.6 model uses Adaptive thinking by default; `--think` is the explicit switch but Claude may still invoke thinking for complex prompts even when not requested
|
||||
- Adaptive-thinking and file-thumbnail widgets render duplicated label paragraphs (`Thought process` / `View uploaded image`) at the top of the response; these are stripped automatically so the row value is the actual answer
|
||||
- File upload is constrained by the daemon HTTP body limit (1 MB; `src/daemon.ts:152`); files up to ~700 KB raw work reliably, larger files (e.g. high-res images) may fail with `ECONNRESET`
|
||||
- Long responses (code, essays) may need a higher `--timeout`
|
||||
@@ -12,7 +12,9 @@
|
||||
| `opencli instagram explore` | Discover trending posts |
|
||||
| `opencli instagram followers` | List user's followers |
|
||||
| `opencli instagram following` | List user's following |
|
||||
| `opencli instagram saved` | Get your saved posts |
|
||||
| `opencli instagram saved` | Get your saved posts (or one collection) |
|
||||
| `opencli instagram collection-create` | Create a new saved-posts collection |
|
||||
| `opencli instagram collection-delete` | Delete a saved-posts collection by name or id |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
@@ -33,13 +35,30 @@ opencli instagram explore --limit 20
|
||||
opencli instagram followers nasa --limit 20
|
||||
opencli instagram following nasa --limit 20
|
||||
|
||||
# Get your saved posts
|
||||
# Get your saved posts (default "All posts" feed)
|
||||
opencli instagram saved --limit 10
|
||||
|
||||
# Get posts from a specific collection (case-insensitive name match)
|
||||
opencli instagram saved --collection inspiration --limit 10
|
||||
|
||||
# Create a new saved-posts collection
|
||||
opencli instagram collection-create "design refs"
|
||||
|
||||
# Delete a collection by name (or by numeric id, e.g. 17853899493659567)
|
||||
opencli instagram collection-delete "design refs"
|
||||
|
||||
# JSON output
|
||||
opencli instagram profile nasa -f json
|
||||
```
|
||||
|
||||
### Notes on collections
|
||||
|
||||
- `instagram saved` without `--collection` returns the unsegmented "All posts" bucket (same as the original behaviour).
|
||||
- With `--collection <name>` it resolves the name to an id via `/api/v1/collections/list/`, then fetches `/api/v1/feed/collection/{id}/posts/`. Match is case-insensitive after trimming. An unknown name throws an error that lists the available names.
|
||||
- `instagram collection-create <name>` calls `POST /api/v1/collections/create/` with a multipart `name` field. Instagram silently accepts duplicate names — the API just returns a new `collection_id` each time, so dedupe client-side if you care.
|
||||
- `instagram collection-delete <name-or-id>` calls `POST /api/v1/collections/{id}/delete/`. Pass either a case-insensitive collection name or a numeric `collection_id`. If the name resolves to multiple collections (e.g. duplicates from `collection-create`), the adapter throws and lists the candidate ids so you can disambiguate by passing the id explicitly. Unknown names list the available collections in the error message.
|
||||
- Saving an existing post directly into a named collection in one shot is not exposed by the web app's documented endpoints (`/api/v1/web/save/{pk}/save/` only writes to "All posts"). Use `instagram save` first, then move the post in the UI, or extend with the `/api/v1/collections/{id}/edit/` mutation.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running and **logged into** instagram.com
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
| `opencli weibo user` | 用户信息 |
|
||||
| `opencli weibo me` | 我的信息 |
|
||||
| `opencli weibo post` | 发微博 |
|
||||
| `opencli weibo favorites` | 我的微博收藏列表 |
|
||||
| `opencli weibo publish` | 通过网页 UI 直接发布微博,支持最多 9 张图片 |
|
||||
| `opencli weibo comments` | 微博评论 |
|
||||
|
||||
## Usage Examples
|
||||
@@ -34,6 +36,15 @@ opencli weibo feed --type following --limit 10
|
||||
|
||||
# Verbose mode
|
||||
opencli weibo hot -v
|
||||
|
||||
# Favorites
|
||||
opencli weibo favorites --limit 20
|
||||
|
||||
# Publish text (executes immediately)
|
||||
opencli weibo publish "Hello from OpenCLI"
|
||||
|
||||
# Publish text with images (executes immediately)
|
||||
opencli weibo publish "Hello with images" --images /path/a.jpg,/path/b.png
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -37,6 +37,7 @@ Run `opencli list` for the live registry.
|
||||
| **[chaoxing](./browser/chaoxing.md)** | `assignments` `exams` | 🔐 Browser |
|
||||
| **[grok](./browser/grok.md)** | `ask` `image` | 🔐 Browser |
|
||||
| **[gemini](./browser/gemini.md)** | `new` `ask` `image` `deep-research` `deep-research-result` | 🔐 Browser |
|
||||
| **[claude](./browser/claude.md)** | `ask` `send` `new` `status` `read` `history` `detail` | 🔐 Browser |
|
||||
| **[maimai](./browser/maimai.md)** | `search-talents` | 🔐 Browser |
|
||||
| **[yuanbao](./browser/yuanbao.md)** | `new` `ask` | 🔐 Browser |
|
||||
| **[notebooklm](./browser/notebooklm.md)** | `status` `list` `open` `current` `get` `source-list` `source-get` `source-fulltext` `source-guide` `history` `note-list` `notes-get` `summary` | 🔐 Browser |
|
||||
|
||||
+1
-1
@@ -87,7 +87,7 @@ OpenCLI occupies a specific niche in the browser automation ecosystem. This guid
|
||||
- **Zero LLM cost** — No tokens consumed at runtime. Run 10,000 times for free.
|
||||
- **Deterministic output** — Same command always returns the same schema. Pipeable, scriptable, CI-friendly.
|
||||
- **Speed** — Adapter commands return in seconds, not minutes.
|
||||
- **Broad platform coverage** — 87+ sites spanning global platforms (Reddit, HackerNews, Twitter, YouTube) and Chinese platforms (Bilibili, Zhihu, Xiaohongshu, Douban, Weibo) with adapters that understand local anti-bot patterns.
|
||||
- **Broad platform coverage** — 100+ registered site surfaces spanning global platforms (Reddit, HackerNews, Twitter, YouTube) and Chinese platforms (Bilibili, Zhihu, Xiaohongshu, Douban, Weibo) with adapters that understand local anti-bot patterns.
|
||||
- **Desktop app control** — CDP adapters for Cursor, Codex, Notion, ChatGPT, Discord, and more.
|
||||
- **Easy to extend** — Drop a `.js` adapter into the `clis/` folder for auto-registration. Contributing a new site adapter is straightforward.
|
||||
|
||||
|
||||
@@ -11,10 +11,11 @@ From a new site URL to a passing `opencli browser verify` — one skill, one set
|
||||
# skills/opencli-adapter-author/SKILL.md
|
||||
|
||||
# 2. Reconnaissance
|
||||
opencli browser open https://example.com
|
||||
opencli browser wait time 3
|
||||
opencli browser network # inspect XHR / fetch calls
|
||||
opencli browser state # extract __INITIAL_STATE__ / __NEXT_DATA__
|
||||
opencli browser analyze https://example.com
|
||||
# Fallback primitives when analyze says deeper inspection is needed:
|
||||
# opencli browser open https://example.com
|
||||
# opencli browser network # inspect XHR / fetch calls
|
||||
# opencli browser state # extract __INITIAL_STATE__ / __NEXT_DATA__
|
||||
|
||||
# 3. Scaffold + verify
|
||||
opencli browser init <site>/<name>
|
||||
@@ -30,6 +31,7 @@ See [skills/opencli-adapter-author/SKILL.md](https://github.com/jackwener/opencl
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `opencli doctor` | Sanity check: bridge, Chrome, signals |
|
||||
| `opencli browser analyze <url>` | One-shot site recon: anti-bot, pattern, nearest adapter, next step |
|
||||
| `opencli browser open <url>` | Open a tab in the Chrome session |
|
||||
| `opencli browser network` | List recent XHR / fetch calls |
|
||||
| `opencli browser state` | Page state: URL, title, interactive elements |
|
||||
|
||||
+144
-83
@@ -1,103 +1,164 @@
|
||||
# Architecture
|
||||
|
||||
OpenCLI is built on a **Dual-Engine Architecture** that supports both declarative pipelines and programmatic TypeScript adapters.
|
||||
OpenCLI is a command surface that sits on top of four major subsystems:
|
||||
|
||||
## High-Level Architecture
|
||||
1. command discovery and registry
|
||||
2. execution and formatting
|
||||
3. browser / daemon / CDP connectivity
|
||||
4. adapter, plugin, and external CLI integration
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ opencli CLI │
|
||||
│ (Commander.js entry point) │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ Engine Layer │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌────────────┐ │
|
||||
│ │ Registry │ │ Dynamic │ │ Output │ │
|
||||
│ │ (commands) │ │ Loader │ │ Formatter │ │
|
||||
│ └──────────────┘ └──────────────┘ └────────────┘ │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ Adapter Layer │
|
||||
│ ┌─────────────────┐ ┌──────────────────────────┐ │
|
||||
│ │ Pipeline │ │ TypeScript Adapters │ │
|
||||
│ │ (declarative) │ │ (browser/desktop/AI) │ │
|
||||
│ └─────────────────┘ └──────────────────────────┘ │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ Connection Layer │
|
||||
│ ┌─────────────────┐ ┌──────────────────────────┐ │
|
||||
│ │ Browser Bridge │ │ CDP (Chrome DevTools) │ │
|
||||
│ │ (Extension+WS) │ │ (Electron apps) │ │
|
||||
│ └─────────────────┘ └──────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
## Runtime Shape
|
||||
|
||||
```text
|
||||
opencli CLI
|
||||
├─ command discovery / registry
|
||||
├─ execution / output
|
||||
├─ browser runtime
|
||||
│ ├─ Browser Bridge extension
|
||||
│ ├─ local daemon
|
||||
│ └─ direct CDP path
|
||||
├─ adapter loading
|
||||
│ ├─ built-in site adapters
|
||||
│ ├─ generated adapters
|
||||
│ └─ pipeline-backed adapters
|
||||
├─ plugin loading
|
||||
└─ external CLI passthrough
|
||||
```
|
||||
|
||||
## Core Modules
|
||||
|
||||
### Registry (`src/registry.ts`)
|
||||
Central command registry. All adapters register their commands via the `cli()` function with metadata: site, name, description, domain, strategy, args, columns.
|
||||
### CLI Surface
|
||||
|
||||
### Discovery (`src/discovery.ts`)
|
||||
CLI discovery and manifest loading. Discovers commands from TypeScript adapter files, parses pipelines, and registers them into the central registry.
|
||||
- `src/main.ts` — process entrypoint
|
||||
- `src/cli.ts` — top-level command tree and built-in command groups
|
||||
- `src/completion.ts` / `src/completion-fast.ts` — shell completion
|
||||
|
||||
### Execution (`src/execution.ts`)
|
||||
Command execution: argument validation, lazy loading of adapter modules, and executing the appropriate handler function.
|
||||
### Discovery, Registry, Execution
|
||||
|
||||
### Commander Adapter (`src/commanderAdapter.ts`)
|
||||
Bridges the Registry commands to Commander.js subcommands. Handles positional args, named options, browser session wiring, and output formatting. Isolates all Commander-specific logic so the core is framework-agnostic.
|
||||
- `src/discovery.ts` — discovers built-in adapters, generated adapters, plugins, and manifests
|
||||
- `src/registry.ts` — central command registry
|
||||
- `src/registry-api.ts` — adapter-facing registration helpers
|
||||
- `src/execution.ts` — argument validation, lazy loading, and command execution
|
||||
- `src/commanderAdapter.ts` — bridges registry metadata into Commander subcommands
|
||||
- `src/output.ts` — `table`, `json`, `yaml`, `md`, `csv` formatting
|
||||
- `src/serialization.ts` — registry and manifest serialization helpers
|
||||
|
||||
### Browser (`src/browser.ts`)
|
||||
Manages connections to Chrome via the Browser Bridge WebSocket daemon. Handles JSON-RPC messaging, tab management, and extension/standalone mode switching.
|
||||
### Browser and Runtime
|
||||
|
||||
### Pipeline (`src/pipeline/`)
|
||||
The pipeline engine. Processes declarative steps:
|
||||
- **fetch** — HTTP requests with cookie/header strategies
|
||||
- **map** — Data transformation with template expressions
|
||||
- **limit** — Result truncation
|
||||
- **filter** — Conditional filtering
|
||||
- **download** — Media download support
|
||||
- `src/runtime.ts` — shared command runtime and target resolution
|
||||
- `src/daemon.ts` — lifecycle and bridge behavior for the local daemon
|
||||
- `src/doctor.ts` — browser bridge diagnostics
|
||||
- `src/observation/` — trace artifacts, redaction, and structured runtime evidence
|
||||
- `src/interceptor.ts` — interception helpers for browser-backed strategies
|
||||
- `src/browser/` — Browser Bridge connection and browser-side primitives
|
||||
|
||||
### Output (`src/output.ts`)
|
||||
Unified output formatting: `table`, `json`, `yaml`, `md`, `csv`.
|
||||
### Pipeline Engine
|
||||
|
||||
## Authentication Strategies
|
||||
- `src/pipeline/executor.ts` — pipeline execution
|
||||
- `src/pipeline/template.ts` — template expansion
|
||||
- `src/pipeline/transform.ts` — transform helpers
|
||||
- `src/pipeline/steps/` — concrete steps such as:
|
||||
- `fetch`
|
||||
- `download`
|
||||
- `browser`
|
||||
- `intercept`
|
||||
- `tap`
|
||||
- `transform`
|
||||
|
||||
OpenCLI uses a 3-tier authentication strategy:
|
||||
### Adapter and Extension Surfaces
|
||||
|
||||
| Strategy | How It Works | When to Use |
|
||||
|----------|-------------|-------------|
|
||||
| `public` | Direct HTTP fetch, no auth | Public APIs (HackerNews, BBC) |
|
||||
| `cookie` | Reuse Chrome cookies via Browser Bridge | Logged-in sites (Bilibili, Zhihu) |
|
||||
| `header` | Custom auth headers | API-key based services |
|
||||
| `intercept` | Network request interception | GraphQL/XHR capture (Twitter) |
|
||||
| `ui` | DOM interaction via accessibility snapshot | Desktop apps, write operations |
|
||||
- `clis/` — built-in site adapters
|
||||
- `src/plugin.ts` / `src/plugin-manifest.ts` / `src/plugin-scaffold.ts` — plugin install, metadata, scaffold
|
||||
- `src/external.ts` / `src/external-clis.yaml` — external CLI passthrough and installable tools
|
||||
- `src/electron-apps.ts` — desktop / Electron app support
|
||||
|
||||
## Directory Structure
|
||||
## Command Sources
|
||||
|
||||
OpenCLI merges commands from multiple places into one registry:
|
||||
|
||||
| Source | Location | Examples |
|
||||
|---|---|---|
|
||||
| Built-in adapters | `clis/` | `twitter`, `bilibili`, `reddit`, `chatgpt-app` |
|
||||
| Generated / local adapters | `~/.opencli/clis/` | user-authored adapters |
|
||||
| Plugins | `~/.opencli/plugins/` | community-contributed commands |
|
||||
| External CLIs | `src/external-clis.yaml` + local registrations | `gh`, `docker`, `vercel` |
|
||||
|
||||
The user sees one unified command tree through `opencli list`.
|
||||
|
||||
## Connectivity Modes
|
||||
|
||||
### Browser Bridge mode
|
||||
|
||||
Primary path for browser-backed commands:
|
||||
|
||||
```text
|
||||
opencli process
|
||||
↔ local daemon
|
||||
↔ Browser Bridge extension
|
||||
↔ logged-in Chrome / Chromium
|
||||
```
|
||||
src/
|
||||
├── main.ts # Entry point
|
||||
├── cli.ts # Commander.js CLI setup + built-in commands
|
||||
├── commanderAdapter.ts # Registry → Commander bridge
|
||||
├── discovery.ts # CLI discovery, manifest loading
|
||||
├── execution.ts # Arg validation, command execution
|
||||
├── registry.ts # Command registry
|
||||
├── serialization.ts # Command serialization helpers
|
||||
├── runtime.ts # Browser session & timeout management
|
||||
├── browser/ # Browser Bridge connection
|
||||
├── output.ts # Output formatting
|
||||
├── doctor.ts # Diagnostic tool
|
||||
├── pipeline/ # Pipeline engine
|
||||
│ ├── runner.ts
|
||||
│ ├── template.ts
|
||||
│ ├── transform.ts
|
||||
│ └── steps/
|
||||
│ ├── fetch.ts
|
||||
│ ├── map.ts
|
||||
│ ├── limit.ts
|
||||
│ ├── filter.ts
|
||||
│ └── download.ts
|
||||
└── clis/ # Site adapters
|
||||
├── twitter/
|
||||
├── reddit/
|
||||
├── bilibili/
|
||||
├── cursor/
|
||||
└── ...
|
||||
```
|
||||
|
||||
This path is used for:
|
||||
|
||||
- cookie-backed websites
|
||||
- browser automation primitives
|
||||
- interactive browser verification
|
||||
|
||||
### Direct CDP mode
|
||||
|
||||
Used when OpenCLI talks directly to a Chrome or Electron debugging endpoint through `OPENCLI_CDP_ENDPOINT`.
|
||||
|
||||
Typical uses:
|
||||
|
||||
- remote Chrome
|
||||
- headless Chrome
|
||||
- Electron desktop adapters
|
||||
|
||||
## Authentication / Access Strategies
|
||||
|
||||
OpenCLI currently uses these access strategies:
|
||||
|
||||
| Strategy | Purpose |
|
||||
|---|---|
|
||||
| `public` | direct fetch with no login |
|
||||
| `cookie` | reuse browser session cookies |
|
||||
| `header` | custom authenticated headers |
|
||||
| `intercept` | capture the app's own network responses |
|
||||
| `ui` | DOM / accessibility driven interaction |
|
||||
|
||||
The key distinction is operational:
|
||||
|
||||
- `public`, `header` favor direct network access
|
||||
- `cookie`, `intercept`, `ui` depend on a live browser or desktop surface
|
||||
|
||||
## High-Risk Change Zones
|
||||
|
||||
Changes in these files usually affect broad command behavior:
|
||||
|
||||
- `src/cli.ts`
|
||||
- `src/commanderAdapter.ts`
|
||||
- `src/discovery.ts`
|
||||
- `src/execution.ts`
|
||||
- `src/runtime.ts`
|
||||
- `src/daemon.ts`
|
||||
- `src/plugin.ts`
|
||||
- `src/external.ts`
|
||||
- `src/pipeline/**`
|
||||
|
||||
These areas deserve targeted tests first, then broader validation when the change crosses module boundaries.
|
||||
|
||||
## Mental Model
|
||||
|
||||
The simplest accurate model is:
|
||||
|
||||
1. OpenCLI discovers command definitions.
|
||||
2. It registers them into one command registry.
|
||||
3. It resolves each invocation through execution + runtime.
|
||||
4. It reaches the target through one of:
|
||||
- network fetch
|
||||
- Browser Bridge
|
||||
- direct CDP
|
||||
- external CLI passthrough
|
||||
5. It formats the result into a stable output surface.
|
||||
|
||||
That is the architecture to preserve when refactoring.
|
||||
|
||||
@@ -17,7 +17,7 @@ npm run build
|
||||
|
||||
# 4. Run a few checks
|
||||
npx tsc --noEmit
|
||||
npm test
|
||||
npm run build
|
||||
|
||||
# 5. Link globally (optional, for testing `opencli` command)
|
||||
npm link
|
||||
@@ -98,11 +98,12 @@ chore: bump vitest to v4
|
||||
|
||||
1. Create a feature branch: `git checkout -b feat/mysite-trending`
|
||||
2. Make your changes and add tests when relevant
|
||||
3. Run the checks:
|
||||
3. Run the smallest check set that matches your change:
|
||||
```bash
|
||||
npx tsc --noEmit # Type check
|
||||
npm test # Default local gate: unit + extension + adapter
|
||||
npm run test:adapter # Adapter-only project (optional while iterating on adapters)
|
||||
npm run build # Ensure dist stays healthy
|
||||
npx vitest run src/<target>.test.ts
|
||||
npm test # Broader local gate when shared runtime changes justify it
|
||||
```
|
||||
4. Commit using conventional commit format
|
||||
5. Push and open a PR
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
# Documentation Audit — 2026-05
|
||||
|
||||
This document reviews the current long-form docs, README surfaces, and developer guides in `opencli`. It focuses on stale facts, internal contradictions, and documentation structure that now causes drift.
|
||||
|
||||
## Scope
|
||||
|
||||
- `README.md`
|
||||
- `README.zh-CN.md`
|
||||
- `docs/`
|
||||
- `skills/` references that are linked from user-facing docs
|
||||
|
||||
## Executive View
|
||||
|
||||
The docs are usable, but they are drifting in four visible ways:
|
||||
|
||||
1. **Hard-coded counts and feature claims are stale.**
|
||||
2. **Developer docs describe an older architecture and older test layout.**
|
||||
3. **English and Chinese docs are no longer updated with the same rigor.**
|
||||
4. **Some pages still describe deleted concepts or old workflows.**
|
||||
|
||||
The highest-value work is:
|
||||
|
||||
1. Fix the stale facts in `README*`, `docs/index.md`, `docs/zh/index.md`, and `docs/guide/getting-started.md`.
|
||||
2. Rewrite `docs/developer/testing.md` and `docs/developer/architecture.md` against current `main`.
|
||||
3. Make English and Chinese entry docs derive from the same source-of-truth checklist.
|
||||
4. Stop writing command/adapters counts by hand unless they are generated.
|
||||
|
||||
## Priority 0 — Clearly stale or incorrect
|
||||
|
||||
### 1. Adapter / site counts are stale across multiple entry points
|
||||
|
||||
Affected files:
|
||||
|
||||
- `README.md`
|
||||
- `README.zh-CN.md`
|
||||
- `docs/guide/getting-started.md`
|
||||
- `docs/comparison.md`
|
||||
|
||||
Current problems:
|
||||
|
||||
- `README.md` and `README.zh-CN.md` still say `90+` adapters.
|
||||
- `docs/guide/getting-started.md` still says `87+` pre-built adapters.
|
||||
- `docs/comparison.md` still says `87+` sites.
|
||||
|
||||
Current reality:
|
||||
|
||||
- `node dist/src/main.js list --format json | jq 'map(.site) | unique | length'` returns `106`.
|
||||
|
||||
Why this matters:
|
||||
|
||||
- These are the first pages people read.
|
||||
- The mismatch is easy to notice and weakens trust in the rest of the docs.
|
||||
- These values will keep drifting if we maintain them manually.
|
||||
|
||||
Recommended fix:
|
||||
|
||||
- Replace all hard-coded counts with one of:
|
||||
- `100+`
|
||||
- `100+ sites`
|
||||
- `over 100 registered sites`
|
||||
- Best option: generate this number into docs at release time or avoid explicit counts entirely.
|
||||
|
||||
### 2. `docs/developer/testing.md` is materially out of date
|
||||
|
||||
Affected file:
|
||||
|
||||
- `docs/developer/testing.md`
|
||||
|
||||
Current problems:
|
||||
|
||||
- It says adapter tests live in `clis/**/*.test.{ts,js}`.
|
||||
- The file examples name adapter tests such as:
|
||||
- `clis/zhihu/download.test.ts`
|
||||
- `clis/twitter/timeline.test.ts`
|
||||
- `clis/reddit/read.test.ts`
|
||||
- `clis/bilibili/dynamic.test.ts`
|
||||
- Those files do not exist.
|
||||
- It says E2E coverage is `5` files.
|
||||
- Current reality is `11` E2E files.
|
||||
- It presents `npm test` as the main local gate, while current team rule is to prefer the smallest sufficient test set instead of default full-suite runs.
|
||||
|
||||
Current reality from the repo:
|
||||
|
||||
- `find src -name '*.test.ts' | wc -l` → `60`
|
||||
- `find clis -iregex '.*\\.test\\.(ts|js)$' | wc -l` → `0`
|
||||
- `find tests/e2e -name '*.test.ts' | wc -l` → `11`
|
||||
- `find tests/smoke -name '*.test.ts' | wc -l` → `1`
|
||||
|
||||
Why this matters:
|
||||
|
||||
- This page is the main developer testing contract.
|
||||
- A new contributor following it will get the wrong mental model of the test layout.
|
||||
- It encourages a heavier default test habit than the team currently wants.
|
||||
|
||||
Recommended fix:
|
||||
|
||||
- Rewrite the page from current files, not from remembered structure.
|
||||
- Separate:
|
||||
- `fast local checks`
|
||||
- `targeted validation`
|
||||
- `full CI coverage`
|
||||
- Remove nonexistent adapter test examples.
|
||||
- Add a short rule:
|
||||
- local default = smallest sufficient validation
|
||||
- full-suite = broader refactor, shared runtime changes, or CI
|
||||
|
||||
### 3. `docs/developer/architecture.md` describes an older system shape
|
||||
|
||||
Affected file:
|
||||
|
||||
- `docs/developer/architecture.md`
|
||||
|
||||
Current problems:
|
||||
|
||||
- It refers to `src/browser.ts`, but that file does not exist.
|
||||
- The directory structure block says `src/clis/`, but adapters live at top-level `clis/`.
|
||||
- The architecture diagram is too simplified for the current system and omits important pieces such as:
|
||||
- `daemon.ts`
|
||||
- `external.ts`
|
||||
- `plugin.ts`
|
||||
- `electron-apps.ts`
|
||||
- update check / diagnostics / runtime detection paths
|
||||
- It says “3-tier authentication strategy” but lists `5` strategies.
|
||||
|
||||
Why this matters:
|
||||
|
||||
- This is the page people read to understand the project.
|
||||
- Once architecture docs are stale, all deeper docs become harder to trust.
|
||||
|
||||
Recommended fix:
|
||||
|
||||
- Rewrite this page around current modules:
|
||||
- command discovery and registry
|
||||
- execution
|
||||
- browser / daemon bridge
|
||||
- external CLI integration
|
||||
- plugin system
|
||||
- desktop / CDP path
|
||||
- pipeline engine
|
||||
- Replace the static tree with a curated module map that matches current filenames.
|
||||
- Change “3-tier” to a neutral label like `authentication strategies`.
|
||||
|
||||
### 4. Home pages still mention deleted concepts
|
||||
|
||||
Affected files:
|
||||
|
||||
- `docs/index.md`
|
||||
- `docs/zh/index.md`
|
||||
|
||||
Current problems:
|
||||
|
||||
- Both home pages say:
|
||||
- `explore`
|
||||
- `synthesize`
|
||||
- `cascade`
|
||||
- `docs/developer/ai-workflow.md` explicitly says those commands do not exist and that the skill drives the loop.
|
||||
|
||||
Why this matters:
|
||||
|
||||
- The home page is currently teaching a product vocabulary that the actual CLI does not have.
|
||||
- This creates immediate confusion for users who go from docs to terminal.
|
||||
|
||||
Recommended fix:
|
||||
|
||||
- Replace those phrases with current concepts:
|
||||
- `browser primitives`
|
||||
- `adapter-authoring skill`
|
||||
- `verify loop`
|
||||
- Keep the homepage aligned with `docs/developer/ai-workflow.md`.
|
||||
|
||||
### 5. Chinese getting-started page lists a deleted built-in command
|
||||
|
||||
Affected file:
|
||||
|
||||
- `docs/zh/guide/getting-started.md`
|
||||
|
||||
Current problem:
|
||||
|
||||
- It says built-in commands include `list、explore、validate...`
|
||||
- `explore` is not a current built-in command.
|
||||
|
||||
Why this matters:
|
||||
|
||||
- This is a hard user-facing error.
|
||||
|
||||
Recommended fix:
|
||||
|
||||
- Replace the example list with current built-ins such as:
|
||||
- `list`
|
||||
- `validate`
|
||||
- `verify`
|
||||
- `browser`
|
||||
- `doctor`
|
||||
- `plugin`
|
||||
- `adapter`
|
||||
|
||||
## Priority 1 — Inconsistent or incomplete
|
||||
|
||||
### 6. Installation pages are inconsistent about runtime support and update flow
|
||||
|
||||
Affected files:
|
||||
|
||||
- `README.md`
|
||||
- `README.zh-CN.md`
|
||||
- `docs/guide/installation.md`
|
||||
- `docs/zh/guide/installation.md`
|
||||
|
||||
Current problems:
|
||||
|
||||
- `README.md` says Node `>= 21` or Bun `>= 1.0`.
|
||||
- `docs/guide/installation.md` and `docs/zh/guide/installation.md` only mention Node.
|
||||
- `README.md` documents skill refresh on update.
|
||||
- `docs/zh/guide/installation.md` only documents package update and omits skills refresh.
|
||||
|
||||
Why this matters:
|
||||
|
||||
- Entry docs should agree on install prerequisites and upgrade procedure.
|
||||
|
||||
Recommended fix:
|
||||
|
||||
- Pick one official runtime support statement and reuse it everywhere.
|
||||
- If Bun is supported, add it consistently to guide pages.
|
||||
- Mirror the post-update skill refresh guidance in the install/update guides.
|
||||
|
||||
### 7. README and docs still use top-level tables and examples that will drift by hand
|
||||
|
||||
Affected files:
|
||||
|
||||
- `README.md`
|
||||
- `README.zh-CN.md`
|
||||
|
||||
Current problems:
|
||||
|
||||
- The “Built-in Commands” section is manually curated and already partially selective.
|
||||
- The surrounding copy still frames it like a broad current snapshot.
|
||||
|
||||
Why this matters:
|
||||
|
||||
- Manual command snapshots go stale quickly in a repo with active adapter growth.
|
||||
|
||||
Recommended fix:
|
||||
|
||||
- Reframe the section as:
|
||||
- “Representative built-in commands”
|
||||
- “Sample sites”
|
||||
- Keep `opencli list` and `docs/adapters/index.md` as the full registry surface.
|
||||
|
||||
### 8. `docs/comparison.md` contains stale scale claims
|
||||
|
||||
Affected file:
|
||||
|
||||
- `docs/comparison.md`
|
||||
|
||||
Current problem:
|
||||
|
||||
- It still says `87+` sites.
|
||||
|
||||
Why this matters:
|
||||
|
||||
- Comparison pages shape market positioning.
|
||||
- Stale numbers make the project look less maintained than it is.
|
||||
|
||||
Recommended fix:
|
||||
|
||||
- Remove exact numbers from comparison copy unless they are generated.
|
||||
|
||||
## Priority 2 — Structural drift risks
|
||||
|
||||
### 9. English and Chinese docs are drifting independently
|
||||
|
||||
Most visible examples:
|
||||
|
||||
- `docs/index.md` and `docs/zh/index.md` both kept the deleted `explore / synthesize / cascade` language.
|
||||
- `docs/zh/guide/getting-started.md` contains a stale built-in command example that should have been caught by parity review.
|
||||
- `README.md` and `README.zh-CN.md` both carry the same stale adapter count.
|
||||
|
||||
Why this keeps happening:
|
||||
|
||||
- We have mirrored content with no explicit parity checklist.
|
||||
- Updates land in one place and rely on memory for the rest.
|
||||
|
||||
Recommended fix:
|
||||
|
||||
- Introduce a small doc parity checklist for any change that touches:
|
||||
- `README.md`
|
||||
- `README.zh-CN.md`
|
||||
- `docs/index.md`
|
||||
- `docs/zh/index.md`
|
||||
- `docs/guide/*`
|
||||
- `docs/zh/guide/*`
|
||||
- Add one PR checklist item:
|
||||
- “Did this change require an English/Chinese mirror update?”
|
||||
|
||||
### 10. Core product pages mix generated facts with narrative copy
|
||||
|
||||
Examples:
|
||||
|
||||
- command counts
|
||||
- site counts
|
||||
- test counts
|
||||
- lists of built-in commands
|
||||
|
||||
Why this matters:
|
||||
|
||||
- Numbers and command inventories drift faster than narrative guidance.
|
||||
|
||||
Recommended fix:
|
||||
|
||||
- For fast-changing facts:
|
||||
- generate them
|
||||
- or generalize them
|
||||
- Reserve hand-written docs for:
|
||||
- mental models
|
||||
- workflows
|
||||
- constraints
|
||||
- trade-offs
|
||||
|
||||
## Suggested rewrite order
|
||||
|
||||
### Pass 1 — Fix trust-breaking errors
|
||||
|
||||
1. `README.md`
|
||||
2. `README.zh-CN.md`
|
||||
3. `docs/index.md`
|
||||
4. `docs/zh/index.md`
|
||||
5. `docs/guide/getting-started.md`
|
||||
6. `docs/zh/guide/getting-started.md`
|
||||
7. `docs/comparison.md`
|
||||
|
||||
### Pass 2 — Rebuild the technical source-of-truth pages
|
||||
|
||||
1. `docs/developer/testing.md`
|
||||
2. `docs/developer/architecture.md`
|
||||
3. `docs/guide/installation.md`
|
||||
4. `docs/zh/guide/installation.md`
|
||||
|
||||
### Pass 3 — Prevent the next round of drift
|
||||
|
||||
1. Add a docs parity checklist to PR workflow.
|
||||
2. Remove exact counts from hand-written copy unless generated.
|
||||
3. Decide which pages are authoritative for:
|
||||
- install
|
||||
- browser bridge
|
||||
- testing
|
||||
- architecture
|
||||
- AI workflow
|
||||
|
||||
## Concrete edits I would make next
|
||||
|
||||
### Small fast edits
|
||||
|
||||
- Replace all `87+` / `90+` claims with `100+`.
|
||||
- Remove `explore / synthesize / cascade` from both home pages.
|
||||
- Remove `explore` from `docs/zh/guide/getting-started.md`.
|
||||
- Align install docs on Node/Bun support and skill refresh.
|
||||
|
||||
### Medium rewrites
|
||||
|
||||
- Rewrite `docs/developer/testing.md` from current filesystem state.
|
||||
- Rewrite `docs/developer/architecture.md` from current module boundaries.
|
||||
|
||||
### Process fix
|
||||
|
||||
- Add a lightweight “doc drift” checklist to PRs that touch command surface, runtime support, testing strategy, or adapter discovery.
|
||||
|
||||
## Bottom line
|
||||
|
||||
The docs do not need a ground-up rewrite. They need a focused trust repair pass on entry pages, then a source-of-truth rebuild for testing and architecture, then a small process change so counts and mirrored pages stop drifting.
|
||||
+118
-216
@@ -1,255 +1,157 @@
|
||||
# Testing Guide
|
||||
|
||||
> 面向开发者和 AI Agent 的测试参考手册。
|
||||
> 面向开发者和 AI Agent 的当前测试参考手册。
|
||||
|
||||
## 目录
|
||||
## 测试结构
|
||||
|
||||
- [测试架构](#测试架构)
|
||||
- [当前覆盖范围](#当前覆盖范围)
|
||||
- [本地运行测试](#本地运行测试)
|
||||
- [如何添加新测试](#如何添加新测试)
|
||||
- [CI/CD 流水线](#cicd-流水线)
|
||||
- [浏览器模式](#浏览器模式)
|
||||
- [站点兼容性](#站点兼容性)
|
||||
OpenCLI 当前测试主要分成四类:
|
||||
|
||||
---
|
||||
| 类别 | 位置 | 当前规模 | 主要用途 |
|
||||
|---|---|---:|---|
|
||||
| 单元测试 | `src/**/*.test.ts` | 60 | 核心运行时、命令层、浏览器桥、输出、插件、诊断 |
|
||||
| E2E 测试 | `tests/e2e/*.test.ts` | 11 | 真实 CLI 入口、公开站点、浏览器命令、管理命令、输出格式 |
|
||||
| 烟雾测试 | `tests/smoke/*.test.ts` | 1 | 外部 API 与注册完整性健康检查 |
|
||||
| 步骤级测试 | `src/pipeline/steps/*.test.ts` | 已包含在单元测试内 | pipeline step 行为与边界情况 |
|
||||
|
||||
## 测试架构
|
||||
当前仓库里没有独立的 `clis/**/*.test.{ts,js}` adapter 测试树。adapter 相关验证主要分布在:
|
||||
|
||||
测试分为三层,全部使用 **vitest** 运行:
|
||||
- `tests/e2e/`
|
||||
- `src/commanderAdapter.test.ts`
|
||||
- `src/registry.test.ts`
|
||||
- `src/execution.test.ts`
|
||||
- `src/validate.ts` / `opencli validate`
|
||||
|
||||
```text
|
||||
tests/
|
||||
├── e2e/ # E2E 集成测试(子进程运行真实 CLI)
|
||||
│ ├── helpers.ts # runCli() / parseJsonOutput() 共享工具
|
||||
│ ├── public-commands.test.ts # 公开 API 命令
|
||||
│ ├── browser-public.test.ts # 浏览器命令(公开数据)
|
||||
│ ├── browser-auth.test.ts # 需登录命令(graceful failure)
|
||||
│ ├── management.test.ts # 管理命令(list / validate / verify / help)
|
||||
│ └── output-formats.test.ts # 输出格式校验
|
||||
├── smoke/
|
||||
│ └── api-health.test.ts # 外部 API、adapter 定义、命令注册健康检查
|
||||
src/
|
||||
├── **/*.test.ts # 核心单元测试(`unit` project)
|
||||
clis/
|
||||
└── **/*.test.{ts,js} # adapter tests(`adapter` project)
|
||||
```
|
||||
## 本地默认策略
|
||||
|
||||
| 层 | 位置 | 当前文件数 | 运行方式 | 用途 |
|
||||
|---|---|---:|---|---|
|
||||
| 单元测试 | `src/**/*.test.ts`(排除 `clis/**`) | - | `npm test` | 内部模块、pipeline、runtime |
|
||||
| Adapter 测试 | `clis/**/*.test.{ts,js}` | - | `npm test` / `npm run test:adapter` | adapter 命令与数据归一化 |
|
||||
| E2E 测试 | `tests/e2e/*.test.ts` | 5 | `npx vitest run tests/e2e/` | 真实 CLI 命令执行 |
|
||||
| 烟雾测试 | `tests/smoke/*.test.ts` | 1 | `npx vitest run tests/smoke/` | 外部 API 与注册完整性 |
|
||||
本地默认跑最小充分验证,不要先跑全量。
|
||||
|
||||
---
|
||||
推荐顺序:
|
||||
|
||||
## 当前覆盖范围
|
||||
1. 改动命令文案、输出格式、参数解析:
|
||||
- 跑对应单元测试
|
||||
- 跑一条真实 CLI 命令做 spot check
|
||||
2. 改动 adapter 发现、注册、验证逻辑:
|
||||
- 跑 `src/registry.test.ts`
|
||||
- 跑 `src/execution.test.ts`
|
||||
- 跑 `opencli validate`
|
||||
3. 改动 browser / daemon / runtime:
|
||||
- 跑对应 `src/*test.ts`
|
||||
- 必要时补一条 `tests/e2e/*` 或手动 `opencli browser ...` 验证
|
||||
4. 改动共享底层、跨多个模块、或 merge 前需要更高信心:
|
||||
- 再扩大到 `npm test`
|
||||
|
||||
### 单元测试与 Adapter 测试
|
||||
|
||||
| 领域 | 文件 |
|
||||
|---|---|
|
||||
| 核心运行时与输出 | `src/browser.test.ts`, `src/browser/dom-snapshot.test.ts`, `src/build-manifest.test.ts`, `src/capabilityRouting.test.ts`, `src/doctor.test.ts`, `src/engine.test.ts`, `src/interceptor.test.ts`, `src/output.test.ts`, `src/plugin.test.ts`, `src/registry.test.ts`, `src/snapshotFormatter.test.ts` |
|
||||
| pipeline 与下载 | `src/download/index.test.ts`, `src/pipeline/executor.test.ts`, `src/pipeline/template.test.ts`, `src/pipeline/transform.test.ts` |
|
||||
| 聚焦 adapter 逻辑 | `clis/zhihu/download.test.ts`, `clis/twitter/timeline.test.ts`, `clis/reddit/read.test.ts`, `clis/bilibili/dynamic.test.ts` |
|
||||
|
||||
这些测试覆盖的重点包括:
|
||||
|
||||
- Browser Bridge、DOM snapshot、interceptor、capability routing
|
||||
- manifest 生成、命令发现、插件安装与注册表
|
||||
- 输出格式渲染与 snapshot formatting
|
||||
- pipeline 模板求值、执行器与变换步骤
|
||||
- 各站点 adapter 的数据归一化、参数处理与容错逻辑
|
||||
|
||||
### E2E 测试(5 个文件)
|
||||
|
||||
| 文件 | 当前覆盖范围 |
|
||||
|---|---|
|
||||
| `tests/e2e/public-commands.test.ts` | `bloomberg`、`apple-podcasts`、`hackernews`、`v2ex`、`xiaoyuzhou`、`google suggest` 等公开命令 |
|
||||
| `tests/e2e/browser-public.test.ts` | `bbc`、`bloomberg`、`bilibili`、`weibo`、`zhihu`、`reddit`、`twitter`、`xueqiu`、`reuters`、`youtube`、`smzdm`、`boss`、`ctrip`、`coupang`、`xiaohongshu`、`google`、`yahoo-finance`、`v2ex daily` |
|
||||
| `tests/e2e/browser-auth.test.ts` | `bilibili`、`twitter`、`v2ex`、`xueqiu`、`linux-do`、`xiaohongshu` 的需登录命令 graceful failure |
|
||||
| `tests/e2e/management.test.ts` | `list`、`validate`、`verify`、`--version`、`--help`、unknown command |
|
||||
| `tests/e2e/output-formats.test.ts` | `json` / `yaml` / `csv` / `md` 输出格式校验 |
|
||||
|
||||
### 烟雾测试(1 个文件)
|
||||
|
||||
| 文件 | 当前覆盖范围 |
|
||||
|---|---|
|
||||
| `tests/smoke/api-health.test.ts` | `hackernews`、`v2ex` 公开 API 可用性,`validate` 全量 adapter 校验,以及命令注册表基础完整性 |
|
||||
|
||||
### 快速核对命令
|
||||
|
||||
需要刷新测试清单时,直接以仓库文件为准:
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
find src -name '*.test.ts' | sort
|
||||
find tests/e2e -name '*.test.ts' | sort
|
||||
find tests/smoke -name '*.test.ts' | sort
|
||||
# 类型检查
|
||||
npx tsc --noEmit
|
||||
|
||||
# 编译产物
|
||||
npm run build
|
||||
|
||||
# 跑一个目标测试文件
|
||||
npx vitest run src/<target>.test.ts
|
||||
|
||||
# 全量 vitest projects
|
||||
npm run test:all
|
||||
|
||||
# E2E
|
||||
npm run test:e2e
|
||||
|
||||
# 适配器注册 / schema 校验
|
||||
node dist/src/main.js validate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 本地运行测试
|
||||
|
||||
### 前置条件
|
||||
如果你明确要跑 adapter project,也可以执行:
|
||||
|
||||
```bash
|
||||
npm ci # 安装依赖
|
||||
npm run build # 编译(E2E / smoke 测试需要 dist/src/main.js)
|
||||
```
|
||||
|
||||
### 运行命令
|
||||
|
||||
```bash
|
||||
# 默认本地测试口径(unit + extension + adapter)
|
||||
npm test
|
||||
|
||||
# 只跑 adapter project
|
||||
npm run test:adapter
|
||||
|
||||
# 全部 E2E 测试(会真实调用外部 API / 浏览器)
|
||||
npx vitest run tests/e2e/
|
||||
|
||||
# 全部 smoke 测试
|
||||
npx vitest run tests/smoke/
|
||||
|
||||
# 单个测试文件
|
||||
npx vitest run clis/apple-podcasts/commands.test.ts
|
||||
npx vitest run tests/e2e/management.test.ts
|
||||
|
||||
# 全部测试
|
||||
npx vitest run
|
||||
|
||||
# watch 模式(开发时推荐)
|
||||
npx vitest src/
|
||||
```
|
||||
|
||||
### 浏览器命令本地测试须知
|
||||
## 当前 E2E 文件
|
||||
|
||||
- opencli 通过 Browser Bridge 扩展连接已运行的 Chrome 浏览器
|
||||
- E2E 测试通过 `tests/e2e/helpers.ts` 里的 `runCli()` 调用已构建的 `dist/src/main.js`
|
||||
- `browser-public.test.ts` 使用 `tryBrowserCommand()`,站点反爬或地域限制导致空数据时会 warn + pass
|
||||
- `browser-auth.test.ts` 验证 **graceful failure**,重点是不 crash、不 hang、错误信息可控
|
||||
- 如需测试完整登录态,保持 Chrome 登录态并安装 Browser Bridge 扩展,再手动运行对应测试
|
||||
- 对依赖具体 host 页面上下文的 browser adapter,除了单测外,还应手动验证真实命令,并把必要的 target host 约束写进 adapter docs / troubleshooting
|
||||
- 对会主动导航页面的 browser commands,手动验证时优先串行执行;多个 CLI 进程同时连到同一个 CDP target 可能互相覆盖导航,制造假的 adapter 故障
|
||||
当前 `tests/e2e/` 包含:
|
||||
|
||||
---
|
||||
- `browser-auth.test.ts`
|
||||
- `browser-public.test.ts`
|
||||
- `cli.test.ts`
|
||||
- `extension-bridge.test.ts`
|
||||
- `formats.test.ts`
|
||||
- `list.test.ts`
|
||||
- `management.test.ts`
|
||||
- `public-commands.test.ts`
|
||||
- `recovery.test.ts`
|
||||
- `remote-chrome.test.ts`
|
||||
- `tab-targeting.test.ts`
|
||||
|
||||
## 如何添加新测试
|
||||
如果这个列表变化,以仓库文件为准:
|
||||
|
||||
### 新增 Adapter(如 `clis/producthunt/trending.ts`)
|
||||
|
||||
1. 根据 adapter 类型,在对应测试文件补一个 `it()` block
|
||||
|
||||
```typescript
|
||||
// 如果 browser: false(公开 API)→ tests/e2e/public-commands.test.ts
|
||||
it('producthunt trending returns data', async () => {
|
||||
const { stdout, code } = await runCli(['producthunt', 'trending', '--limit', '3', '-f', 'json']);
|
||||
expect(code).toBe(0);
|
||||
const data = parseJsonOutput(stdout);
|
||||
expect(Array.isArray(data)).toBe(true);
|
||||
expect(data.length).toBeGreaterThanOrEqual(1);
|
||||
expect(data[0]).toHaveProperty('title');
|
||||
}, 30_000);
|
||||
```bash
|
||||
find tests/e2e -name '*.test.ts' | sort
|
||||
```
|
||||
|
||||
```typescript
|
||||
// 如果 browser: true 但可公开访问 → tests/e2e/browser-public.test.ts
|
||||
it('producthunt trending returns data', async () => {
|
||||
const data = await tryBrowserCommand(['producthunt', 'trending', '--limit', '3', '-f', 'json']);
|
||||
expectDataOrSkip(data, 'producthunt trending');
|
||||
}, 60_000);
|
||||
## 当前值得优先覆盖的区域
|
||||
|
||||
以下改动最容易引入回归:
|
||||
|
||||
- `src/cli.ts`
|
||||
- `src/commanderAdapter.ts`
|
||||
- `src/discovery.ts`
|
||||
- `src/execution.ts`
|
||||
- `src/runtime.ts`
|
||||
- `src/daemon.ts`
|
||||
- `src/plugin.ts`
|
||||
- `src/external.ts`
|
||||
- `src/pipeline/**`
|
||||
|
||||
这类改动优先补:
|
||||
|
||||
- 精准单元测试
|
||||
- 一条真实 CLI 验证路径
|
||||
- 必要时再扩大到 `npm test`
|
||||
|
||||
## 手动验证建议
|
||||
|
||||
文档或命令面改动后,优先做 2 到 4 条真实命令 spot check,例如:
|
||||
|
||||
```bash
|
||||
node dist/src/main.js --help
|
||||
node dist/src/main.js list --format json
|
||||
node dist/src/main.js plugin --help
|
||||
node dist/src/main.js doctor --help
|
||||
```
|
||||
|
||||
```typescript
|
||||
// 如果 browser: true 且需登录 → tests/e2e/browser-auth.test.ts
|
||||
it('producthunt me fails gracefully without login', async () => {
|
||||
await expectGracefulAuthFailure(['producthunt', 'me', '-f', 'json'], 'producthunt me');
|
||||
}, 60_000);
|
||||
浏览器相关改动再补:
|
||||
|
||||
```bash
|
||||
node dist/src/main.js browser --help
|
||||
node dist/src/main.js browser tab list
|
||||
```
|
||||
|
||||
### 新增管理命令(如 `opencli export`)
|
||||
## CI 角色
|
||||
|
||||
在 `tests/e2e/management.test.ts` 添加测试;如果新命令会影响输出格式,也同步补 `tests/e2e/output-formats.test.ts`。
|
||||
CI 负责更大范围的回归信心,本地负责最快闭环。
|
||||
|
||||
### 新增内部模块
|
||||
适合交给 CI 的内容:
|
||||
|
||||
在对应源码旁创建 `*.test.ts`,优先和被测模块放在同一目录下,便于发现与维护。
|
||||
- 更大的命令面回归
|
||||
- 多环境差异
|
||||
- E2E 稳定性
|
||||
- smoke 检查
|
||||
|
||||
### 决策流程图
|
||||
适合本地优先做的内容:
|
||||
|
||||
```text
|
||||
新增功能 → 是内部模块? → 是 → src/ 下加 *.test.ts
|
||||
↓ 否
|
||||
是 CLI 命令? → browser: false? → tests/e2e/public-commands.test.ts
|
||||
↓ true
|
||||
公开数据? → tests/e2e/browser-public.test.ts
|
||||
↓ 需登录
|
||||
tests/e2e/browser-auth.test.ts
|
||||
```
|
||||
- 参数解析
|
||||
- 输出格式
|
||||
- 注册与发现
|
||||
- 文档相关命令行为
|
||||
- 共享模块的小范围回归
|
||||
|
||||
---
|
||||
## 更新这份文档的规则
|
||||
|
||||
## CI/CD 流水线
|
||||
当以下任一项变化时,顺手更新此页:
|
||||
|
||||
### `ci.yml`
|
||||
|
||||
| Job | 触发条件 | 内容 |
|
||||
|---|---|---|
|
||||
| `build` | push/PR 到 `main`,`dev` | `tsc --noEmit` + `npm run build` |
|
||||
| `unit-test` | push/PR 到 `main`,`dev` | Node `22` 运行 `unit + extension` tests,按 `2` shard 并行 |
|
||||
| `adapter-test` | push/PR 到 `main`,`dev` | Node `22` 单独运行 `adapter` project |
|
||||
| `smoke-test` | `schedule` 或 `workflow_dispatch` | 安装真实 Chrome,`xvfb-run` 执行 `tests/smoke/` |
|
||||
|
||||
### `e2e-headed.yml`
|
||||
|
||||
| Job | 触发条件 | 内容 |
|
||||
|---|---|---|
|
||||
| `e2e-headed` | push/PR 到 `main`,`dev`,或手动触发 | 安装真实 Chrome,`xvfb-run` 执行 `tests/e2e/` |
|
||||
|
||||
E2E 与 smoke 都使用 `./.github/actions/setup-chrome` 准备真实 Chrome。
|
||||
|
||||
### Sharding
|
||||
|
||||
CI 里的 `unit-test` job 使用 vitest shard,只切 `unit + extension`,避免和独立的 `adapter-test` job 重复:
|
||||
|
||||
::: v-pre
|
||||
```yaml
|
||||
strategy:
|
||||
matrix:
|
||||
shard: [1, 2]
|
||||
steps:
|
||||
- run: npx vitest run --project unit --project extension --reporter=verbose --shard=${{ matrix.shard }}/2
|
||||
```
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## 浏览器模式
|
||||
|
||||
opencli 通过 Browser Bridge 扩展连接浏览器:
|
||||
|
||||
| 条件 | 模式 | 使用场景 |
|
||||
|---|---|---|
|
||||
| 扩展已安装 / 已连接 | Extension 模式 | 本地用户,连接已登录的 Chrome |
|
||||
| 无扩展 token | CLI 自行拉起浏览器 | CI、无登录态或纯自动化场景 |
|
||||
|
||||
CI 通过 `./.github/actions/setup-chrome` 准备真实 Chrome,再直接执行测试。
|
||||
|
||||
---
|
||||
|
||||
## 站点兼容性
|
||||
|
||||
GitHub Actions 的美国 runner 上,部分站点会因为地域限制、登录要求或反爬而返回空数据。当前 E2E 对这些场景采用 warn + pass 策略,避免偶发站点限制把整条 CI 打红。
|
||||
|
||||
| 站点 | CI 表现 | 常见原因 |
|
||||
|---|---|---|
|
||||
| `hackernews`、`bbc`、`v2ex`、`bloomberg` | 通常返回数据 | 公开接口或公开页面 |
|
||||
| `yahoo-finance`、`google` | 通常返回数据 | 页面公开,但仍可能受限流影响 |
|
||||
| `bilibili`、`zhihu`、`weibo`、`xiaohongshu`、`xueqiu` | 容易空数据 | 地域限制、反爬、登录要求 |
|
||||
| `reddit`、`twitter`、`youtube` | 容易空数据 | 登录态、cookie、机器人检测 |
|
||||
| `smzdm`、`boss`、`ctrip`、`coupang`、`linux-do` | 结果波动较大 | 地域限制、风控或页面结构变动 |
|
||||
|
||||
> 如果需要更稳定的浏览器 E2E 结果,优先使用具备目标站点网络可达性的 self-hosted runner。
|
||||
- `tests/e2e/` 文件列表
|
||||
- 默认本地测试命令
|
||||
- `package.json` 测试脚本
|
||||
- 共享运行时的高风险模块
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# Extending OpenCLI
|
||||
|
||||
OpenCLI has five extension paths. Pick the path based on where you want the source code to live and how you want commands to be shared.
|
||||
|
||||
| Goal | Use | Source location | Command surface |
|
||||
|------|-----|-----------------|-----------------|
|
||||
| Build a personal website command in your own Git repo | Local plugin | Your project directory, symlinked into `~/.opencli/plugins/` | `opencli <plugin> <command>` |
|
||||
| Quickly draft a private adapter on this machine | User adapter | `~/.opencli/clis/<site>/<command>.js` | `opencli <site> <command>` |
|
||||
| Edit an official adapter locally | Adapter override | `~/.opencli/clis/<site>/` | `opencli <site> <command>` |
|
||||
| Publish or install third-party commands | Plugin | Git repo, installed into `~/.opencli/plugins/` | `opencli <plugin> <command>` |
|
||||
| Wrap an existing local binary | External CLI | `~/.opencli/external-clis.yaml` | `opencli <tool> ...` |
|
||||
|
||||
## Personal commands in your own Git repo
|
||||
|
||||
Use a local plugin when you want the code to stay in a normal project directory and be managed by Git.
|
||||
|
||||
```bash
|
||||
opencli plugin create my-cnn
|
||||
cd my-cnn
|
||||
git init
|
||||
opencli plugin install file://$(pwd)
|
||||
opencli my-cnn hello
|
||||
```
|
||||
|
||||
`plugin install file://...` creates a symlink under `~/.opencli/plugins/`. Your source files stay in your project directory, so edits and commits happen there.
|
||||
|
||||
This is the recommended path for custom commands you own long-term.
|
||||
|
||||
## Private adapters in `~/.opencli/clis`
|
||||
|
||||
Use a user adapter when you want the fastest local adapter loop and do not need a separate project directory.
|
||||
|
||||
```bash
|
||||
opencli browser init cnn/top
|
||||
# edit ~/.opencli/clis/cnn/top.js
|
||||
opencli browser verify cnn/top
|
||||
opencli cnn top
|
||||
```
|
||||
|
||||
User adapters are loaded from:
|
||||
|
||||
```text
|
||||
~/.opencli/clis/<site>/<command>.js
|
||||
```
|
||||
|
||||
This path is convenient for quick local automation. For code you want to version, review, or share, prefer a plugin.
|
||||
|
||||
If the command takes required positional args and no fixture exists yet, seed the first verify run explicitly:
|
||||
|
||||
```bash
|
||||
opencli browser verify instagram/collection-create --write-fixture --seed-args opencli-verify
|
||||
opencli browser verify example/detail --write-fixture --seed-args '["https://example.com/item/1", "--limit", 3]'
|
||||
```
|
||||
|
||||
`--seed-args` is only used when the fixture has no `args`. Once the fixture is written, `opencli browser verify` reads args from `~/.opencli/sites/<site>/verify/<command>.json`.
|
||||
|
||||
## Local overrides for official adapters
|
||||
|
||||
Use `adapter eject` when you want to customize an existing official adapter.
|
||||
|
||||
```bash
|
||||
opencli adapter eject twitter
|
||||
# edit ~/.opencli/clis/twitter/*.js
|
||||
opencli adapter reset twitter
|
||||
```
|
||||
|
||||
Files in `~/.opencli/clis/<site>/<command>.js` override packaged adapters with the same `site/command` on this machine. `opencli browser verify <site>/<command>` also runs the local override, so a passing local verify does not prove that the packaged adapter was changed.
|
||||
|
||||
The packaged `cli-manifest.json` only describes bundled adapters. User adapters are discovered at runtime and do not need manifest entries.
|
||||
|
||||
After copying a local fix into the repository for a PR, remove the local copy or run `opencli adapter reset <site>` after merge. Otherwise the local file keeps shadowing future package updates. `opencli doctor` warns when it detects this shadowing.
|
||||
|
||||
## Plugins for sharing commands
|
||||
|
||||
Plugins are third-party command packages. They can be installed from GitHub, any git-cloneable URL, or a local directory.
|
||||
|
||||
```bash
|
||||
opencli plugin install github:user/opencli-plugin-my-tool
|
||||
opencli plugin install https://github.com/user/opencli-plugin-my-tool
|
||||
opencli plugin install file:///absolute/path/to/plugin
|
||||
opencli plugin list
|
||||
opencli plugin update --all
|
||||
opencli plugin uninstall my-tool
|
||||
```
|
||||
|
||||
Each plugin directory is scanned for `.ts` and `.js` command files. TypeScript plugins are transpiled during install.
|
||||
|
||||
See [Plugins](./plugins.md) for manifest fields, TypeScript examples, update behavior, and monorepo publishing.
|
||||
|
||||
## Multiple custom sites in one repo
|
||||
|
||||
For a Git-hosted plugin collection, declare sub-plugins in `opencli-plugin.json` and install from GitHub:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"cnn": { "path": "packages/cnn" },
|
||||
"reuters": { "path": "packages/reuters" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
opencli plugin install github:user/opencli-plugins
|
||||
opencli plugin install github:user/opencli-plugins/cnn
|
||||
```
|
||||
|
||||
For local development, install each sub-plugin directory directly:
|
||||
|
||||
```bash
|
||||
opencli plugin install file:///absolute/path/opencli-plugins/packages/cnn
|
||||
opencli plugin install file:///absolute/path/opencli-plugins/packages/reuters
|
||||
```
|
||||
|
||||
Local `file://` installs expect the target directory itself to be a valid plugin with command files. For a monorepo root, push it to GitHub and install it with the GitHub monorepo flow.
|
||||
|
||||
## External CLI passthrough
|
||||
|
||||
Use external CLI registration when the command already exists as a binary on your machine and you want it available through `opencli`.
|
||||
|
||||
```bash
|
||||
opencli external register my-tool \
|
||||
--binary my-tool \
|
||||
--install "npm i -g my-tool" \
|
||||
--desc "My internal CLI"
|
||||
|
||||
opencli my-tool --help
|
||||
```
|
||||
|
||||
External CLIs pass stdio and exit codes through to the underlying binary.
|
||||
@@ -13,7 +13,7 @@ OpenCLI turns **any website** or **Electron app** into a command-line interface
|
||||
|
||||
- **Desktop App Control** — Drive Electron apps (Cursor, Codex, ChatGPT, Notion, etc.) directly from the terminal via CDP.
|
||||
- **Browser Automation** — `browser` gives AI agents direct browser control: click, type, extract, screenshot — fully scriptable.
|
||||
- **Website → CLI** — Turn any website into a deterministic CLI: 87+ pre-built adapters, or author your own with the `opencli-adapter-author` skill.
|
||||
- **Website → CLI** — Turn any website into a deterministic CLI: 100+ site surfaces are already registered, or author your own with the `opencli-adapter-author` skill.
|
||||
- **Account-safe** — Reuses Chrome's logged-in state; your credentials never leave the browser.
|
||||
- **AI Agent ready** — `opencli browser *` primitives (`open` / `network` / `state` / `eval` / `init` / `verify`) drive the adapter-authoring loop.
|
||||
- **Zero LLM cost** — No tokens consumed at runtime. Run 10,000 times and pay nothing.
|
||||
@@ -74,6 +74,7 @@ The completion includes:
|
||||
|
||||
- [Installation details](/guide/installation)
|
||||
- [Browser Bridge setup](/guide/browser-bridge)
|
||||
- [Extending OpenCLI — custom commands, plugins, and external CLIs](/guide/extending-opencli)
|
||||
- [Plugins — extend with community adapters](/guide/plugins)
|
||||
- [All available adapters](/adapters/)
|
||||
- [For developers / AI agents](/developer/contributing)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Node.js**: >= 21.0.0
|
||||
- **Node.js**: >= 21.0.0, or **Bun** >= 1.0
|
||||
- **Chrome** running and logged into the target site (for browser commands)
|
||||
|
||||
## Install via npm (Recommended)
|
||||
@@ -31,6 +31,16 @@ npm install -g @jackwener/opencli@latest
|
||||
npx skills add jackwener/opencli
|
||||
```
|
||||
|
||||
Or refresh only the skills you actually use:
|
||||
|
||||
```bash
|
||||
npx skills add jackwener/opencli --skill opencli-adapter-author
|
||||
npx skills add jackwener/opencli --skill opencli-autofix
|
||||
npx skills add jackwener/opencli --skill opencli-browser
|
||||
npx skills add jackwener/opencli --skill opencli-usage
|
||||
npx skills add jackwener/opencli --skill smart-search
|
||||
```
|
||||
|
||||
## Verify Installation
|
||||
|
||||
```bash
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ features:
|
||||
details: Reuses Chrome's logged-in state. Your credentials never leave the browser — no tokens, no exposed passwords.
|
||||
- icon: 🤖
|
||||
title: AI Agent Ready
|
||||
details: "explore discovers APIs, synthesize generates adapters, cascade finds auth strategies. Built for AI-first workflows."
|
||||
details: "Browser primitives plus adapter-authoring skills give AI agents a repeatable loop for recon, extraction, verification, and adapter writing."
|
||||
- icon: 💰
|
||||
title: Zero LLM Cost
|
||||
details: No tokens consumed at runtime. Run 10,000 times and pay nothing.
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# 扩展 OpenCLI
|
||||
|
||||
OpenCLI 有五类扩展路径。按源码放在哪里、命令要如何共享来选。
|
||||
|
||||
| 目标 | 使用方式 | 源码位置 | 命令入口 |
|
||||
|------|----------|----------|----------|
|
||||
| 在自己的 Git repo 里写个人网站命令 | 本地 plugin | 你的项目目录,symlink 到 `~/.opencli/plugins/` | `opencli <plugin> <command>` |
|
||||
| 快速写一个只在本机用的 adapter | User adapter | `~/.opencli/clis/<site>/<command>.js` | `opencli <site> <command>` |
|
||||
| 本地修改官方 adapter | Adapter override | `~/.opencli/clis/<site>/` | `opencli <site> <command>` |
|
||||
| 发布或安装第三方命令 | Plugin | Git repo,安装到 `~/.opencli/plugins/` | `opencli <plugin> <command>` |
|
||||
| 包装已有本机 binary | External CLI | `~/.opencli/external-clis.yaml` | `opencli <tool> ...` |
|
||||
|
||||
## 把个人命令放在自己的 Git repo
|
||||
|
||||
如果你希望源码留在普通项目目录里,用 Git 管理,使用本地 plugin。
|
||||
|
||||
```bash
|
||||
opencli plugin create my-cnn
|
||||
cd my-cnn
|
||||
git init
|
||||
opencli plugin install file://$(pwd)
|
||||
opencli my-cnn hello
|
||||
```
|
||||
|
||||
`plugin install file://...` 会在 `~/.opencli/plugins/` 下创建 symlink。源码仍然留在你的项目目录,编辑和提交都在项目目录完成。
|
||||
|
||||
长期维护的自建命令推荐走这条路径。
|
||||
|
||||
## `~/.opencli/clis` 下的私人 adapter
|
||||
|
||||
如果你只想快速生成一个本机 adapter,不需要单独项目目录,可以用 user adapter。
|
||||
|
||||
```bash
|
||||
opencli browser init cnn/top
|
||||
# edit ~/.opencli/clis/cnn/top.js
|
||||
opencli browser verify cnn/top
|
||||
opencli cnn top
|
||||
```
|
||||
|
||||
User adapter 加载路径是:
|
||||
|
||||
```text
|
||||
~/.opencli/clis/<site>/<command>.js
|
||||
```
|
||||
|
||||
这条路径适合快速本地自动化。需要版本管理、review、共享的代码推荐做成 plugin。
|
||||
|
||||
如果命令有 required positional args,而且 fixture 还没创建,第一次 verify 时直接传 seed:
|
||||
|
||||
```bash
|
||||
opencli browser verify instagram/collection-create --write-fixture --seed-args opencli-verify
|
||||
opencli browser verify example/detail --write-fixture --seed-args '["https://example.com/item/1", "--limit", 3]'
|
||||
```
|
||||
|
||||
`--seed-args` 只在 fixture 没有 `args` 时生效。fixture 写出后,`opencli browser verify` 会从 `~/.opencli/sites/<site>/verify/<command>.json` 读取 args。
|
||||
|
||||
## 本地覆盖官方 adapter
|
||||
|
||||
如果你想改一个已有官方 adapter,用 `adapter eject`。
|
||||
|
||||
```bash
|
||||
opencli adapter eject twitter
|
||||
# edit ~/.opencli/clis/twitter/*.js
|
||||
opencli adapter reset twitter
|
||||
```
|
||||
|
||||
`~/.opencli/clis/<site>/<command>.js` 会在本机覆盖同名 package adapter。`opencli browser verify <site>/<command>` 也会跑本地覆盖版本,所以本地 verify 通过不代表 package 里的 adapter 已经改好。
|
||||
|
||||
Package 里的 `cli-manifest.json` 只描述 bundled adapter。User adapter 是运行时发现的,不需要写 manifest。
|
||||
|
||||
把本地修复复制到仓库发 PR 后,merge 后要删除本地副本,或运行 `opencli adapter reset <site>`。否则本地文件会继续 shadow 后续 package 更新。`opencli doctor` 会在发现这种 shadowing 时给出 warning。
|
||||
|
||||
## Plugin:共享命令
|
||||
|
||||
Plugin 是第三方命令包。可以从 GitHub、任意 git URL 或本地目录安装。
|
||||
|
||||
```bash
|
||||
opencli plugin install github:user/opencli-plugin-my-tool
|
||||
opencli plugin install https://github.com/user/opencli-plugin-my-tool
|
||||
opencli plugin install file:///absolute/path/to/plugin
|
||||
opencli plugin list
|
||||
opencli plugin update --all
|
||||
opencli plugin uninstall my-tool
|
||||
```
|
||||
|
||||
每个 plugin 目录会扫描 `.ts` 和 `.js` 命令文件。TypeScript plugin 会在安装时 transpile。
|
||||
|
||||
manifest 字段、TypeScript 示例、更新行为和 monorepo 发布方式见 [插件](./plugins.md)。
|
||||
|
||||
## 一个 repo 管多个自建站点
|
||||
|
||||
Git 托管的 plugin collection 可以在 `opencli-plugin.json` 里声明多个 sub-plugin:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"cnn": { "path": "packages/cnn" },
|
||||
"reuters": { "path": "packages/reuters" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
opencli plugin install github:user/opencli-plugins
|
||||
opencli plugin install github:user/opencli-plugins/cnn
|
||||
```
|
||||
|
||||
本地开发时,直接安装每个 sub-plugin 目录:
|
||||
|
||||
```bash
|
||||
opencli plugin install file:///absolute/path/opencli-plugins/packages/cnn
|
||||
opencli plugin install file:///absolute/path/opencli-plugins/packages/reuters
|
||||
```
|
||||
|
||||
本地 `file://` 安装要求目标目录本身就是一个有效 plugin,并且目录内有命令文件。monorepo root 请推到 GitHub 后走 GitHub monorepo 安装流程。
|
||||
|
||||
## External CLI passthrough
|
||||
|
||||
如果命令已经是本机 binary,只想统一挂到 `opencli` 下,用 external CLI registration。
|
||||
|
||||
```bash
|
||||
opencli external register my-tool \
|
||||
--binary my-tool \
|
||||
--install "npm i -g my-tool" \
|
||||
--desc "My internal CLI"
|
||||
|
||||
opencli my-tool --help
|
||||
```
|
||||
|
||||
External CLI 会把 stdio 和 exit code 透传给底层 binary。
|
||||
@@ -49,7 +49,7 @@ opencli bilibili [Tab] # 补全命令(hot、search、me、download...)
|
||||
|
||||
补全功能包含:
|
||||
- 所有可用的站点和适配器
|
||||
- 内置命令(list、explore、validate...)
|
||||
- 内置命令(list、validate、verify、browser、doctor、plugin、adapter...)
|
||||
- 命令别名
|
||||
- 新增适配器时的实时更新
|
||||
|
||||
@@ -57,6 +57,7 @@ opencli bilibili [Tab] # 补全命令(hot、search、me、download...)
|
||||
|
||||
- [安装详情](/zh/guide/installation)
|
||||
- [Browser Bridge 设置](/zh/guide/browser-bridge)
|
||||
- [扩展 OpenCLI:自定义命令、plugin 和 external CLI](/zh/guide/extending-opencli)
|
||||
- [所有适配器](/zh/adapters/)
|
||||
- [开发者指南](/zh/developer/contributing)
|
||||
- [给新 Electron 应用生成 CLI](/zh/guide/electron-app-cli)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 系统要求
|
||||
|
||||
- **Node.js**: >= 21.0.0
|
||||
- **Node.js**: >= 21.0.0,或 **Bun** >= 1.0
|
||||
- **Chrome** 已运行并登录目标网站(浏览器命令需要)
|
||||
|
||||
## 通过 npm 安装(推荐)
|
||||
@@ -26,6 +26,19 @@ opencli list
|
||||
|
||||
```bash
|
||||
npm install -g @jackwener/opencli@latest
|
||||
|
||||
# 如果你在用打包发布的 OpenCLI skills,也一起刷新
|
||||
npx skills add jackwener/opencli
|
||||
```
|
||||
|
||||
如果你只装了部分 skill,也可以只刷新自己在用的:
|
||||
|
||||
```bash
|
||||
npx skills add jackwener/opencli --skill opencli-adapter-author
|
||||
npx skills add jackwener/opencli --skill opencli-autofix
|
||||
npx skills add jackwener/opencli --skill opencli-browser
|
||||
npx skills add jackwener/opencli --skill opencli-usage
|
||||
npx skills add jackwener/opencli --skill smart-search
|
||||
```
|
||||
|
||||
## 验证安装
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ features:
|
||||
details: 复用 Chrome 登录态,凭证永远不会离开浏览器 — 无 token,无密码泄露。
|
||||
- icon: 🤖
|
||||
title: AI Agent 就绪
|
||||
details: explore 发现 API,synthesize 生成适配器,cascade 查找认证策略。为 AI 优先工作流而生。
|
||||
details: Browser 原语加上适配器编写 skill,让 AI Agent 可以稳定完成侦察、提取、验证和适配器落地。
|
||||
- icon: 💰
|
||||
title: 零 LLM 成本
|
||||
details: 运行时不消耗模型 token。跑 10,000 次也不花一分钱。
|
||||
|
||||
Vendored
+21
-2
@@ -896,16 +896,35 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
||||
if (msg?.type === "getStatus") {
|
||||
void (async () => {
|
||||
const contextId = await getCurrentContextId();
|
||||
const connected = ws?.readyState === WebSocket.OPEN;
|
||||
const extensionVersion = chrome.runtime.getManifest().version;
|
||||
const daemonVersion = connected ? await fetchDaemonVersion() : null;
|
||||
sendResponse({
|
||||
connected: ws?.readyState === WebSocket.OPEN,
|
||||
connected,
|
||||
reconnecting: reconnectTimer !== null,
|
||||
contextId
|
||||
contextId,
|
||||
extensionVersion,
|
||||
daemonVersion
|
||||
});
|
||||
})();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
async function fetchDaemonVersion() {
|
||||
try {
|
||||
const res = await fetch(`http://${DAEMON_HOST}:${DAEMON_PORT}/status`, {
|
||||
method: "GET",
|
||||
headers: { "X-OpenCLI": "1" },
|
||||
signal: AbortSignal.timeout(1500)
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const body = await res.json();
|
||||
return typeof body.daemonVersion === "string" ? body.daemonVersion : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async function handleCommand(cmd) {
|
||||
const workspace = getWorkspaceKey(cmd.workspace);
|
||||
windowFocused = cmd.windowFocused === true;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "OpenCLI",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.4",
|
||||
"description": "Browser automation bridge for the OpenCLI CLI tool. Executes commands in Chrome tab leases via a local daemon.",
|
||||
"permissions": [
|
||||
"debugger",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "opencli-extension",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.4",
|
||||
"private": true,
|
||||
"opencli": {
|
||||
"compatRange": ">=1.7.0"
|
||||
|
||||
+86
-49
@@ -5,28 +5,37 @@
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
width: 280px;
|
||||
width: 300px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
color: #1d1d1f;
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
padding: 14px;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.header img { width: 24px; height: 24px; }
|
||||
.header h1 { font-size: 15px; font-weight: 600; }
|
||||
.status-row {
|
||||
.header img { width: 22px; height: 22px; }
|
||||
.header h1 { font-size: 14px; font-weight: 600; flex: 1; }
|
||||
.header .version-tag {
|
||||
font-size: 11px;
|
||||
color: #86868b;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid #ececec;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card .status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: #f5f5f5;
|
||||
padding: 11px 12px;
|
||||
background: #fafafa;
|
||||
}
|
||||
.dot {
|
||||
width: 8px; height: 8px;
|
||||
@@ -36,50 +45,73 @@
|
||||
.dot.connected { background: #34c759; }
|
||||
.dot.disconnected { background: #ff3b30; }
|
||||
.dot.connecting { background: #ff9500; }
|
||||
.status-text { font-size: 13px; color: #555; }
|
||||
.status-text strong { color: #333; }
|
||||
.status-text {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1d1d1f;
|
||||
flex: 1;
|
||||
}
|
||||
.daemon-version {
|
||||
font-size: 11px;
|
||||
color: #86868b;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.profile-row {
|
||||
margin-top: 10px;
|
||||
padding: 9px 10px;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #ececec;
|
||||
color: #666;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid #ececec;
|
||||
background: #fff;
|
||||
}
|
||||
.profile-row code {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
padding: 3px 5px;
|
||||
border-radius: 4px;
|
||||
background: #f0f0f0;
|
||||
color: #333;
|
||||
.profile-label {
|
||||
font-size: 11px;
|
||||
color: #86868b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.profile-id {
|
||||
flex: 1;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
word-break: break-all;
|
||||
font-size: 12px;
|
||||
color: #1d1d1f;
|
||||
user-select: all;
|
||||
word-break: break-all;
|
||||
}
|
||||
.hint {
|
||||
margin-top: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background: #f0f4ff;
|
||||
.copy-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 4px 9px;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
font-family: inherit;
|
||||
color: #007aff;
|
||||
background: transparent;
|
||||
border: 1px solid #d2d2d7;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.copy-btn:hover { background: #f0f6ff; }
|
||||
.copy-btn:active { background: #e1edff; }
|
||||
.copy-btn.copied { color: #34c759; border-color: #34c759; background: #f0fdf4; }
|
||||
.hint {
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid #ececec;
|
||||
background: #f9fafc;
|
||||
font-size: 11px;
|
||||
color: #6e6e73;
|
||||
line-height: 1.5;
|
||||
display: none;
|
||||
}
|
||||
.hint code {
|
||||
background: #e8ecf1;
|
||||
padding: 1px 4px;
|
||||
background: #ececec;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 14px;
|
||||
margin-top: 12px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
color: #86868b;
|
||||
}
|
||||
.footer a { color: #007aff; text-decoration: none; }
|
||||
.footer a:hover { text-decoration: underline; }
|
||||
@@ -89,17 +121,22 @@
|
||||
<div class="header">
|
||||
<img src="icons/icon-48.png" alt="OpenCLI">
|
||||
<h1>OpenCLI</h1>
|
||||
<span class="version-tag" id="extVersion"></span>
|
||||
</div>
|
||||
<div class="status-row">
|
||||
<span class="dot disconnected" id="dot"></span>
|
||||
<span class="status-text" id="status">Checking...</span>
|
||||
</div>
|
||||
<div class="profile-row" id="profile" style="display: none;">
|
||||
Chrome profile contextId
|
||||
<code id="contextId"></code>
|
||||
</div>
|
||||
<div class="hint" id="hint">
|
||||
This is normal. The extension connects automatically when you run any <code>opencli</code> command.
|
||||
<div class="card disconnected" id="card">
|
||||
<div class="status-row">
|
||||
<span class="dot disconnected" id="dot"></span>
|
||||
<span class="status-text" id="status">Checking...</span>
|
||||
<span class="daemon-version" id="daemonVersion"></span>
|
||||
</div>
|
||||
<div class="profile-row" id="profileRow" style="display: none;">
|
||||
<span class="profile-label">Profile</span>
|
||||
<span class="profile-id" id="contextId"></span>
|
||||
<button type="button" class="copy-btn" id="copyBtn" title="Copy contextId">Copy</button>
|
||||
</div>
|
||||
<div class="hint" id="hint" style="display: none;">
|
||||
The extension connects automatically when you run any <code>opencli</code> command.
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<a href="https://github.com/jackwener/opencli" target="_blank">Documentation</a>
|
||||
|
||||
+56
-13
@@ -1,34 +1,77 @@
|
||||
// Query connection status from background service worker
|
||||
chrome.runtime.sendMessage({ type: 'getStatus' }, (resp) => {
|
||||
const card = document.getElementById('card');
|
||||
const dot = document.getElementById('dot');
|
||||
const status = document.getElementById('status');
|
||||
const hint = document.getElementById('hint');
|
||||
const profile = document.getElementById('profile');
|
||||
const daemonVersion = document.getElementById('daemonVersion');
|
||||
const profileRow = document.getElementById('profileRow');
|
||||
const contextId = document.getElementById('contextId');
|
||||
const copyBtn = document.getElementById('copyBtn');
|
||||
const hint = document.getElementById('hint');
|
||||
const extVersion = document.getElementById('extVersion');
|
||||
|
||||
if (resp && typeof resp.extensionVersion === 'string') {
|
||||
extVersion.textContent = `v${resp.extensionVersion}`;
|
||||
}
|
||||
|
||||
if (chrome.runtime.lastError || !resp) {
|
||||
dot.className = 'dot disconnected';
|
||||
status.innerHTML = '<strong>No daemon connected</strong>';
|
||||
profile.style.display = 'none';
|
||||
setState(card, dot, 'disconnected');
|
||||
status.textContent = 'No daemon connected';
|
||||
daemonVersion.textContent = '';
|
||||
profileRow.style.display = 'none';
|
||||
hint.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof resp.contextId === 'string' && resp.contextId.length > 0) {
|
||||
contextId.textContent = resp.contextId;
|
||||
profile.style.display = 'block';
|
||||
profileRow.style.display = 'flex';
|
||||
copyBtn.addEventListener('click', () => copyToClipboard(resp.contextId, copyBtn));
|
||||
} else {
|
||||
profile.style.display = 'none';
|
||||
profileRow.style.display = 'none';
|
||||
}
|
||||
|
||||
if (resp.connected) {
|
||||
dot.className = 'dot connected';
|
||||
status.innerHTML = '<strong>Connected to daemon</strong>';
|
||||
setState(card, dot, 'connected');
|
||||
status.textContent = 'Connected to daemon';
|
||||
if (typeof resp.daemonVersion === 'string') {
|
||||
daemonVersion.textContent = `daemon v${resp.daemonVersion}`;
|
||||
}
|
||||
hint.style.display = 'none';
|
||||
} else if (resp.reconnecting) {
|
||||
dot.className = 'dot connecting';
|
||||
status.innerHTML = '<strong>Reconnecting...</strong>';
|
||||
setState(card, dot, 'connecting');
|
||||
status.textContent = 'Reconnecting...';
|
||||
daemonVersion.textContent = '';
|
||||
hint.style.display = 'none';
|
||||
} else {
|
||||
dot.className = 'dot disconnected';
|
||||
status.innerHTML = '<strong>No daemon connected</strong>';
|
||||
setState(card, dot, 'disconnected');
|
||||
status.textContent = 'No daemon connected';
|
||||
daemonVersion.textContent = '';
|
||||
hint.style.display = 'block';
|
||||
}
|
||||
});
|
||||
|
||||
function setState(card, dot, state) {
|
||||
card.classList.remove('connected', 'disconnected', 'connecting');
|
||||
card.classList.add(state);
|
||||
dot.classList.remove('connected', 'disconnected', 'connecting');
|
||||
dot.classList.add(state);
|
||||
}
|
||||
|
||||
function copyToClipboard(text, btn) {
|
||||
navigator.clipboard.writeText(text).then(
|
||||
() => {
|
||||
const original = btn.textContent;
|
||||
btn.textContent = 'Copied';
|
||||
btn.classList.add('copied');
|
||||
setTimeout(() => {
|
||||
btn.textContent = original;
|
||||
btn.classList.remove('copied');
|
||||
}, 1200);
|
||||
},
|
||||
() => {
|
||||
btn.textContent = 'Failed';
|
||||
setTimeout(() => { btn.textContent = 'Copy'; }, 1200);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
declare const __OPENCLI_COMPAT_RANGE__: string;
|
||||
|
||||
import type { Command, Result } from './protocol';
|
||||
import { DAEMON_WS_URL, DAEMON_PING_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol';
|
||||
import { DAEMON_HOST, DAEMON_PORT, DAEMON_WS_URL, DAEMON_PING_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol';
|
||||
import * as executor from './cdp';
|
||||
import * as identity from './identity';
|
||||
|
||||
@@ -187,9 +187,8 @@ type TargetLease = {
|
||||
lifecycle: LeaseLifecycle;
|
||||
surface: SurfacePolicy;
|
||||
};
|
||||
type AutomationSession = TargetLease;
|
||||
|
||||
const automationSessions = new Map<string, AutomationSession>();
|
||||
const automationSessions = new Map<string, TargetLease>();
|
||||
let ownedContainerWindowId: number | null = null;
|
||||
const IDLE_TIMEOUT_DEFAULT = 30_000; // 30s — adapter-driven automation
|
||||
const IDLE_TIMEOUT_INTERACTIVE = 600_000; // 10min — human-paced browser:* / operate:*
|
||||
@@ -199,7 +198,7 @@ const LEASE_IDLE_ALARM_PREFIX = 'opencli:lease-idle:';
|
||||
let leaseMutationQueue: Promise<void> = Promise.resolve();
|
||||
let ownedContainerWindowPromise: Promise<{ windowId: number; initialTabId?: number }> | null = null;
|
||||
|
||||
type StoredLease = Omit<AutomationSession, 'idleTimer' | 'idleDeadlineAt'> & {
|
||||
type StoredLease = Omit<TargetLease, 'idleTimer' | 'idleDeadlineAt'> & {
|
||||
idleDeadlineAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
@@ -264,8 +263,8 @@ function withLeaseMutation<T>(fn: () => Promise<T>): Promise<T> {
|
||||
|
||||
function makeSession(
|
||||
workspace: string,
|
||||
session: Omit<AutomationSession, 'idleTimer' | 'idleDeadlineAt' | 'contextId' | 'ownership' | 'lifecycle' | 'surface'>,
|
||||
): Omit<AutomationSession, 'idleTimer' | 'idleDeadlineAt'> {
|
||||
session: Omit<TargetLease, 'idleTimer' | 'idleDeadlineAt' | 'contextId' | 'ownership' | 'lifecycle' | 'surface'>,
|
||||
): Omit<TargetLease, 'idleTimer' | 'idleDeadlineAt'> {
|
||||
const ownership = session.owned ? 'owned' : 'borrowed';
|
||||
return {
|
||||
...session,
|
||||
@@ -622,10 +621,15 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
||||
if (msg?.type === 'getStatus') {
|
||||
void (async () => {
|
||||
const contextId = await getCurrentContextId();
|
||||
const connected = ws?.readyState === WebSocket.OPEN;
|
||||
const extensionVersion = chrome.runtime.getManifest().version;
|
||||
const daemonVersion = connected ? await fetchDaemonVersion() : null;
|
||||
sendResponse({
|
||||
connected: ws?.readyState === WebSocket.OPEN,
|
||||
connected,
|
||||
reconnecting: reconnectTimer !== null,
|
||||
contextId,
|
||||
extensionVersion,
|
||||
daemonVersion,
|
||||
});
|
||||
})();
|
||||
return true;
|
||||
@@ -633,6 +637,26 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
||||
return false;
|
||||
});
|
||||
|
||||
/**
|
||||
* Best-effort fetch of the daemon's reported version for the popup status panel.
|
||||
* Resolves to null on any failure — the popup degrades to showing connection
|
||||
* state without the version label.
|
||||
*/
|
||||
async function fetchDaemonVersion(): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(`http://${DAEMON_HOST}:${DAEMON_PORT}/status`, {
|
||||
method: 'GET',
|
||||
headers: { 'X-OpenCLI': '1' },
|
||||
signal: AbortSignal.timeout(1500),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const body = await res.json() as { daemonVersion?: unknown };
|
||||
return typeof body.daemonVersion === 'string' ? body.daemonVersion : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Command dispatcher ─────────────────────────────────────────────
|
||||
|
||||
async function handleCommand(cmd: Command): Promise<Result> {
|
||||
@@ -790,7 +814,7 @@ function enumerateCrossOriginFrames(tree: any): Array<{ index: number; frameId:
|
||||
|
||||
function setWorkspaceSession(
|
||||
workspace: string,
|
||||
session: Omit<AutomationSession, 'idleTimer' | 'idleDeadlineAt' | 'contextId' | 'ownership' | 'lifecycle' | 'surface'>,
|
||||
session: Omit<TargetLease, 'idleTimer' | 'idleDeadlineAt' | 'contextId' | 'ownership' | 'lifecycle' | 'surface'>,
|
||||
): void {
|
||||
const existing = automationSessions.get(workspace);
|
||||
if (existing?.idleTimer) clearTimeout(existing.idleTimer);
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jackwener/opencli",
|
||||
"version": "1.7.8",
|
||||
"version": "1.7.11",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
@@ -42,7 +42,7 @@
|
||||
"dev": "tsx src/main.ts",
|
||||
"dev:bun": "bun src/main.ts",
|
||||
"build": "npm run clean-dist && tsc && npm run copy-yaml && npm run build-manifest",
|
||||
"build-manifest": "node dist/src/build-manifest.js",
|
||||
"build-manifest": "tsx src/build-manifest.ts",
|
||||
"clean-dist": "node scripts/clean-dist.cjs",
|
||||
"copy-yaml": "node scripts/copy-yaml.cjs",
|
||||
"start": "node dist/src/main.js",
|
||||
|
||||
@@ -10,7 +10,7 @@ allowed-tools: Bash(opencli:*), Read, Edit, Write, Grep
|
||||
|
||||
全程用现有工具:`opencli browser *` / `opencli doctor` / `opencli browser init` / `opencli browser verify`。没有新命令。
|
||||
|
||||
调试浏览器型 adapter 时,优先直接带上 `--live --focus`。这样命令跑完后 automation lease 还在,而且容器在前台,方便你核对最终页面状态,而不是猜是抓数错了还是页面走偏了。
|
||||
调试浏览器型 adapter 时,优先直接带上 `--trace on --live --focus`。`--trace on` 每轮都落 trace artifact,`summary.md` 是失败/成功复盘入口;`--live --focus` 让 automation lease 保留且容器在前台,方便核对最终页面状态。
|
||||
|
||||
---
|
||||
|
||||
@@ -82,7 +82,7 @@ START
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ opencli browser verify │── 失败 ──→ autofix skill,回对应步骤
|
||||
│ opencli browser verify │── 失败 ──→ autofix skill,用 --trace retain-on-failure 回对应步骤
|
||||
└──────────────────────────┘
|
||||
│ 成功
|
||||
▼
|
||||
@@ -163,7 +163,7 @@ DONE
|
||||
| | 200 但 `data: []` 空 | 参数传错 / 接口换版,回 §1 看 network 里真实请求头 |
|
||||
| Step 7 字段解码 | 排序键对比推不出 | field-decode-playbook.md §3 结构差分 |
|
||||
| | 还推不出 | 先输出 raw,adapter 跑起来再迭代 |
|
||||
| Step 10 verify 失败 | `fltt` 漏了 / 字段映射错 | autofix skill |
|
||||
| Step 10 verify 失败 | `fltt` 漏了 / 字段映射错 | autofix skill;复现命令加 `--trace retain-on-failure` |
|
||||
| | 某列永远是 `null` | 字段路径错了,回 Step 7 |
|
||||
| Step 10 verify fixture mismatch | `[pattern]` row[i] 报错 | 先肉眼比对网页值;值对 → 是 fixture pattern 太严,放宽;值不对 → 字段映射错 |
|
||||
| | `[column] missing column "X"` | 实际 response 没这列(站点改版 or args 影响);重新 `--update-fixture` 或修 adapter |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: opencli-autofix
|
||||
description: Automatically fix broken OpenCLI adapters when commands fail. Load this skill when an opencli command fails — it guides you through diagnosing the failure via OPENCLI_DIAGNOSTIC, patching the adapter, retrying, and filing an upstream GitHub issue after a verified fix. Works with any AI agent.
|
||||
description: Automatically fix broken OpenCLI adapters when commands fail. Load this skill when an opencli command fails — it guides you through collecting a trace artifact, patching the adapter, retrying, and filing an upstream GitHub issue after a verified fix. Works with any AI agent.
|
||||
allowed-tools: Bash(opencli:*), Bash(gh:*), Read, Edit, Write
|
||||
---
|
||||
|
||||
@@ -17,7 +17,7 @@ When an `opencli` command fails because a website changed its DOM, API, or respo
|
||||
- **CAPTCHA / rate limiting** — **STOP.** Not an adapter issue.
|
||||
|
||||
**Scope constraint:**
|
||||
- **Only modify the file at `RepairContext.adapter.sourcePath`** — this is the authoritative adapter location (may be `clis/<site>/` in repo or `~/.opencli/clis/<site>/` for npm installs)
|
||||
- **Only modify the file at `adapterSourcePath` in the trace `summary.md` front matter** — this is the authoritative adapter location (may be `clis/<site>/` in repo or `~/.opencli/clis/<site>/` for npm installs)
|
||||
- **Never modify** `src/`, `extension/`, `tests/`, `package.json`, or `tsconfig.json`
|
||||
|
||||
**Retry budget:** Max **3 repair rounds** per failure. If 3 rounds of diagnose → fix → retry don't resolve it, stop and report what was tried.
|
||||
@@ -49,48 +49,65 @@ Use when `opencli <site> <command>` fails with repairable errors:
|
||||
|
||||
Only proceed to Step 1 if the empty/selector-missing result is **reproducible across retries and alternative entry points**. Otherwise you're patching a working adapter to chase noise, and the patched version will break the next working path.
|
||||
|
||||
## Step 1: Collect Diagnostic Context
|
||||
## Step 1: Collect Trace Context
|
||||
|
||||
Run the failing command with diagnostic mode enabled:
|
||||
Run the failing command with failure-retained trace enabled:
|
||||
|
||||
```bash
|
||||
OPENCLI_DIAGNOSTIC=1 opencli <site> <command> [args...] 2>diagnostic.json
|
||||
opencli <site> <command> [args...] --trace retain-on-failure 2>trace-error.yaml
|
||||
```
|
||||
|
||||
This outputs a `RepairContext` JSON between `___OPENCLI_DIAGNOSTIC___` markers in stderr:
|
||||
On failure, stderr contains the normal error envelope plus a small `trace` block:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "SELECTOR",
|
||||
"message": "Could not find element: .old-selector",
|
||||
"hint": "The page UI may have changed."
|
||||
},
|
||||
"adapter": {
|
||||
"site": "example",
|
||||
"command": "example/search",
|
||||
"sourcePath": "/path/to/clis/example/search.js",
|
||||
"source": "// full adapter source code"
|
||||
},
|
||||
"page": {
|
||||
"url": "https://example.com/search",
|
||||
"snapshot": "// DOM snapshot with [N] indices",
|
||||
"networkRequests": [],
|
||||
"consoleErrors": []
|
||||
},
|
||||
"timestamp": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
```yaml
|
||||
ok: false
|
||||
error:
|
||||
code: SELECTOR
|
||||
message: "Could not find element: .old-selector"
|
||||
trace:
|
||||
schemaVersion: 1
|
||||
opencliVersion: "..."
|
||||
traceId: "..."
|
||||
dir: "/path/to/.opencli/profiles/default/traces/..."
|
||||
summaryPath: "/path/to/.opencli/profiles/default/traces/.../summary.md"
|
||||
receiptPath: "/path/to/.opencli/profiles/default/traces/.../receipt.json"
|
||||
```
|
||||
|
||||
**Parse it:**
|
||||
```bash
|
||||
# Extract JSON between markers from stderr output
|
||||
cat diagnostic.json | sed -n '/___OPENCLI_DIAGNOSTIC___/{n;p;}'
|
||||
Read `summaryPath` first. It is the LLM-oriented entry point and includes front matter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
schemaVersion: 1
|
||||
opencliVersion: "..."
|
||||
traceId: "..."
|
||||
status: failure
|
||||
site: "example"
|
||||
command: "example/search"
|
||||
adapterSourcePath: "/path/to/clis/example/search.js"
|
||||
errorCode: "SELECTOR"
|
||||
errorMessage: "Could not find element: .old-selector"
|
||||
---
|
||||
```
|
||||
|
||||
The artifact directory contains:
|
||||
|
||||
```text
|
||||
summary.md # start here
|
||||
receipt.json # machine-readable trace receipt
|
||||
trace.jsonl # full redacted timeline
|
||||
network.jsonl # redacted network events
|
||||
console.jsonl # redacted console events
|
||||
state/ # final snapshots when available
|
||||
screenshots/ # final screenshots when available
|
||||
```
|
||||
|
||||
If you redirected stderr to a file, read that file and copy `trace.summaryPath`.
|
||||
|
||||
Do not ask the user to rerun with legacy diagnostic env vars. Trace is the repair evidence path.
|
||||
|
||||
## Step 2: Analyze the Failure
|
||||
|
||||
Read the diagnostic context and the adapter source. Classify the root cause:
|
||||
Read the trace summary and the adapter source. Classify the root cause:
|
||||
|
||||
| Error Code | Likely Cause | Repair Strategy |
|
||||
|-----------|-------------|-----------------|
|
||||
@@ -102,9 +119,9 @@ Read the diagnostic context and the adapter source. Classify the root cause:
|
||||
| PAGE_CHANGED | Major redesign | May need full adapter rewrite |
|
||||
|
||||
**Key questions to answer:**
|
||||
1. What is the adapter trying to do? (Read the `source` field)
|
||||
2. What did the page look like when it failed? (Read the `snapshot` field)
|
||||
3. What network requests happened? (Read `networkRequests`)
|
||||
1. What is the adapter trying to do? (Read the file at `adapterSourcePath`)
|
||||
2. What did the page look like when it failed? (Read `summary.md`, then `state/` if needed)
|
||||
3. What network requests happened? (Read `Failed Network` in `summary.md`, then `network.jsonl` if needed)
|
||||
4. What's the gap between what the adapter expects and what the page provides?
|
||||
|
||||
## Step 3: Explore the Current Website
|
||||
@@ -139,12 +156,9 @@ opencli browser network --detail <key>
|
||||
|
||||
## Step 4: Patch the Adapter
|
||||
|
||||
Read the adapter source file at the path from `RepairContext.adapter.sourcePath` and make targeted fixes. This path is authoritative — it may be in the repo (`clis/`) or user-local (`~/.opencli/clis/`).
|
||||
Read the adapter source file at `adapterSourcePath` from the trace summary front matter and make targeted fixes. This path is authoritative — it may be in the repo (`clis/`) or user-local (`~/.opencli/clis/`).
|
||||
|
||||
```bash
|
||||
# Read the adapter (use the exact path from diagnostic)
|
||||
cat <RepairContext.adapter.sourcePath>
|
||||
```
|
||||
Use the `Read` tool on the exact path from summary.md front matter.
|
||||
|
||||
### Common Fixes
|
||||
|
||||
@@ -184,11 +198,11 @@ cat <RepairContext.adapter.sourcePath>
|
||||
## Step 5: Verify the Fix
|
||||
|
||||
```bash
|
||||
# Run the command normally (without diagnostic mode)
|
||||
# Run the command normally
|
||||
opencli <site> <command> [args...]
|
||||
```
|
||||
|
||||
If it still fails, go back to Step 1 and collect fresh diagnostics. You have a budget of **3 repair rounds** (diagnose → fix → retry). If the same error persists after a fix, try a different approach. After 3 rounds, stop and report what was tried.
|
||||
If it still fails, go back to Step 1 and collect a fresh trace. You have a budget of **3 repair rounds** (trace → fix → retry). If the same error persists after a fix, try a different approach. After 3 rounds, stop and report what was tried.
|
||||
|
||||
## Step 6: File an Upstream Issue
|
||||
|
||||
@@ -203,7 +217,7 @@ If the retry **passes**, the local adapter has drifted from upstream. File a Git
|
||||
|
||||
**Procedure:**
|
||||
|
||||
1. Prepare the issue content from the RepairContext you already have:
|
||||
1. Prepare the issue content from the trace summary you already have:
|
||||
- **Title:** `[autofix] <site>/<command>: <error_code>` (e.g. `[autofix] zhihu/hot: SELECTOR`)
|
||||
- **Body** (use this template):
|
||||
|
||||
@@ -264,15 +278,15 @@ In all stop cases, clearly communicate the situation to the user rather than mak
|
||||
1. User runs: opencli zhihu hot
|
||||
→ Fails: SELECTOR "Could not find element: .HotList-item"
|
||||
|
||||
2. AI runs: OPENCLI_DIAGNOSTIC=1 opencli zhihu hot 2>diag.json
|
||||
→ Gets RepairContext with DOM snapshot showing page loaded
|
||||
2. AI runs: opencli zhihu hot --trace retain-on-failure 2>trace-error.yaml
|
||||
→ Gets trace summary with final state and failed action evidence
|
||||
|
||||
3. AI reads diagnostic: snapshot shows the page loaded but uses ".HotItem" instead of ".HotList-item"
|
||||
3. AI reads summary/state: page loaded but uses ".HotItem" instead of ".HotList-item"
|
||||
|
||||
4. AI explores: opencli browser open https://www.zhihu.com/hot && opencli browser state
|
||||
→ Confirms new class name ".HotItem" with child ".HotItem-content"
|
||||
|
||||
5. AI patches: Edit adapter at RepairContext.adapter.sourcePath — replace ".HotList-item" with ".HotItem"
|
||||
5. AI patches: Edit adapter at `adapterSourcePath` — replace ".HotList-item" with ".HotItem"
|
||||
|
||||
6. AI verifies: opencli zhihu hot
|
||||
→ Success: returns hot topics
|
||||
|
||||
@@ -380,4 +380,4 @@ opencli browser eval "(() => document.querySelector('input[name=cardnumber]')?.v
|
||||
## See also
|
||||
|
||||
- `opencli-adapter-author` — turning what you just figured out into a reusable `~/.opencli/clis/<site>/<command>.js`.
|
||||
- `opencli-autofix` — when an existing adapter breaks, this skill walks you through `OPENCLI_DIAGNOSTIC` and filing a fix.
|
||||
- `opencli-autofix` — when an existing adapter breaks, this skill walks you through `--trace retain-on-failure` evidence and filing a fix.
|
||||
|
||||
@@ -85,11 +85,10 @@ A few commands override the default via `cmd.defaultFormat` (e.g. chat commands
|
||||
| `OPENCLI_CACHE_DIR` | `~/.opencli/cache` | Network capture + browser-state cache. |
|
||||
| `OPENCLI_WINDOW_FOCUSED` | `false` | `1` → automation window opens in the foreground. |
|
||||
| `OPENCLI_VERBOSE` | `false` | Verbose logging (also triggered by `-v`). |
|
||||
| `OPENCLI_DIAGNOSTIC` | `false` | `1` → emit structured `RepairContext` JSON on adapter failure. Required for `opencli-autofix`. |
|
||||
|
||||
## Self-repair
|
||||
|
||||
When an adapter command fails because the site changed (selectors drifted, API rotated, response schema shifted), the CLI emits a hint: `# AutoFix: re-run with OPENCLI_DIAGNOSTIC=1 ...`. Do that, read the `RepairContext`, patch the adapter at `RepairContext.adapter.sourcePath`, and retry. Max 3 repair rounds. The full flow is in `opencli-autofix`.
|
||||
When an adapter command fails because the site changed (selectors drifted, API rotated, response schema shifted), re-run with `--trace retain-on-failure`. The error envelope includes a `trace` block pointing at `summary.md`; patch only the `adapterSourcePath` from that summary and retry. Max 3 repair rounds. The full flow is in `opencli-autofix`.
|
||||
|
||||
## Writing your own adapter
|
||||
|
||||
@@ -166,4 +165,4 @@ The following were removed in the PR #1094 consolidation — don't try to invoke
|
||||
|
||||
- Don't paste this skill's command list into your plan; it will rot. Call `opencli list -f json` at the start of a task instead.
|
||||
- Don't assume every adapter needs a browser — strategy `PUBLIC` and `LOCAL` don't. Check the `strategy` field.
|
||||
- Don't silently fall back from a failing adapter to a hand-rolled `fetch` — `OPENCLI_DIAGNOSTIC=1` almost always tells you exactly what to change in the adapter. Do that first.
|
||||
- Don't silently fall back from a failing adapter to a hand-rolled `fetch` — `--trace retain-on-failure` gives you the browser evidence and adapter source path. Do that first.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: smart-search
|
||||
description: 基于 opencli 命令的智能搜索路由器。当用户想要搜索、查询、查找或研究信息时,尤其是涉及指定网站、社交媒体、技术资料、新闻、购物、旅游、求职、金融或中文内容时,务必使用此 skill
|
||||
description: 基于 opencli 命令的智能搜索路由器。当用户想要使用 OpenCLI、CLI 或 API 搜索、查询、查找或研究信息时,尤其是涉及指定网站、社交媒体、技术资料、新闻、购物、旅游、求职、金融或中文内容时,务必使用此 skill
|
||||
---
|
||||
|
||||
# 智能搜索路由器
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { findShadowedUserAdapters, formatAdapterShadowIssue } from './adapter-shadow.js';
|
||||
|
||||
describe('adapter shadow detection', () => {
|
||||
it('reports user adapters that shadow packaged manifest commands', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-adapter-shadow-'));
|
||||
try {
|
||||
const userClisDir = path.join(root, 'user-clis');
|
||||
const builtinRoot = path.join(root, 'pkg');
|
||||
const builtinClisDir = path.join(builtinRoot, 'clis');
|
||||
fs.mkdirSync(path.join(userClisDir, 'instagram'), { recursive: true });
|
||||
fs.mkdirSync(path.join(userClisDir, 'twitter'), { recursive: true });
|
||||
fs.mkdirSync(path.join(builtinClisDir, 'instagram'), { recursive: true });
|
||||
fs.mkdirSync(path.join(builtinClisDir, 'twitter'), { recursive: true });
|
||||
|
||||
fs.writeFileSync(path.join(userClisDir, 'instagram', 'saved.js'), '', 'utf-8');
|
||||
fs.writeFileSync(path.join(userClisDir, 'instagram', 'utils.js'), '', 'utf-8');
|
||||
fs.writeFileSync(path.join(userClisDir, 'twitter', 'search.js'), '', 'utf-8');
|
||||
fs.writeFileSync(path.join(builtinClisDir, 'instagram', 'saved.js'), '', 'utf-8');
|
||||
fs.writeFileSync(path.join(builtinClisDir, 'instagram', 'utils.js'), '', 'utf-8');
|
||||
fs.writeFileSync(path.join(builtinClisDir, 'twitter', 'search.js'), '', 'utf-8');
|
||||
fs.writeFileSync(path.join(builtinRoot, 'cli-manifest.json'), `${JSON.stringify([
|
||||
{ site: 'instagram', name: 'saved', sourceFile: 'instagram/saved.js' },
|
||||
])}\n`, 'utf-8');
|
||||
|
||||
expect(findShadowedUserAdapters({ userClisDir, builtinClisDir })).toEqual([
|
||||
{
|
||||
name: 'instagram/saved',
|
||||
userPath: path.join(userClisDir, 'instagram', 'saved.js'),
|
||||
builtinPath: path.join(builtinClisDir, 'instagram', 'saved.js'),
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('formats a concise doctor issue', () => {
|
||||
const issue = formatAdapterShadowIssue([
|
||||
{
|
||||
name: 'instagram/saved',
|
||||
userPath: '/home/me/.opencli/clis/instagram/saved.js',
|
||||
builtinPath: '/pkg/clis/instagram/saved.js',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(issue).toContain('instagram/saved');
|
||||
expect(issue).toContain('opencli adapter reset <site>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { ManifestEntry } from './manifest-types.js';
|
||||
import { findPackageRoot, getCliManifestPath } from './package-paths.js';
|
||||
|
||||
export type AdapterShadow = {
|
||||
name: string;
|
||||
userPath: string;
|
||||
builtinPath: string;
|
||||
};
|
||||
|
||||
export type AdapterShadowOptions = {
|
||||
userClisDir?: string;
|
||||
builtinClisDir?: string;
|
||||
};
|
||||
|
||||
function defaultBuiltinClisDir(): string {
|
||||
return path.join(findPackageRoot(fileURLToPath(import.meta.url)), 'clis');
|
||||
}
|
||||
|
||||
function safeReaddir(dir: string): fs.Dirent[] {
|
||||
try {
|
||||
return fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function loadBuiltinCommandFiles(builtinClisDir: string): Set<string> {
|
||||
try {
|
||||
const raw = fs.readFileSync(getCliManifestPath(builtinClisDir), 'utf-8');
|
||||
const entries = JSON.parse(raw) as ManifestEntry[];
|
||||
const files = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
const rel = entry.sourceFile ?? entry.modulePath;
|
||||
if (rel) files.add(path.resolve(builtinClisDir, rel));
|
||||
}
|
||||
return files;
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
export function findShadowedUserAdapters(opts: AdapterShadowOptions = {}): AdapterShadow[] {
|
||||
const userClisDir = opts.userClisDir ?? path.join(os.homedir(), '.opencli', 'clis');
|
||||
const builtinClisDir = opts.builtinClisDir ?? defaultBuiltinClisDir();
|
||||
const builtinCommandFiles = loadBuiltinCommandFiles(builtinClisDir);
|
||||
const shadows: AdapterShadow[] = [];
|
||||
|
||||
for (const siteEntry of safeReaddir(userClisDir)) {
|
||||
if (!siteEntry.isDirectory()) continue;
|
||||
const site = siteEntry.name;
|
||||
const userSiteDir = path.join(userClisDir, site);
|
||||
const builtinSiteDir = path.join(builtinClisDir, site);
|
||||
|
||||
for (const commandEntry of safeReaddir(userSiteDir)) {
|
||||
if (!commandEntry.isFile() || !commandEntry.name.endsWith('.js')) continue;
|
||||
const userPath = path.join(userSiteDir, commandEntry.name);
|
||||
const builtinPath = path.join(builtinSiteDir, commandEntry.name);
|
||||
const builtinResolved = path.resolve(builtinPath);
|
||||
if (!builtinCommandFiles.has(builtinResolved)) continue;
|
||||
|
||||
shadows.push({
|
||||
name: `${site}/${commandEntry.name.replace(/\.js$/, '')}`,
|
||||
userPath,
|
||||
builtinPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return shadows.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export function formatAdapterShadowIssue(shadows: AdapterShadow[]): string {
|
||||
const visible = shadows.slice(0, 10);
|
||||
const lines = ['Local adapter overrides shadow packaged adapters:'];
|
||||
for (const shadow of visible) {
|
||||
lines.push(` ${shadow.name}: ${shadow.userPath} overrides ${shadow.builtinPath}`);
|
||||
}
|
||||
if (shadows.length > visible.length) {
|
||||
lines.push(` ... and ${shadows.length - visible.length} more`);
|
||||
}
|
||||
lines.push('Remove the local ~/.opencli/clis copy, or run opencli adapter reset <site>, when you want packaged updates.');
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { InternalCliCommand } from './registry.js';
|
||||
import { resolveAdapterSourcePath } from './adapter-source.js';
|
||||
|
||||
function makeCmd(overrides: Partial<InternalCliCommand> = {}): InternalCliCommand {
|
||||
return {
|
||||
site: 'test-site',
|
||||
name: 'test-cmd',
|
||||
description: 'test',
|
||||
args: [],
|
||||
...overrides,
|
||||
} as InternalCliCommand;
|
||||
}
|
||||
|
||||
describe('resolveAdapterSourcePath', () => {
|
||||
it('returns source when it is a real file path (not manifest:)', () => {
|
||||
const cmd = makeCmd({ source: '/home/user/.opencli/clis/arxiv/search.js' });
|
||||
expect(resolveAdapterSourcePath(cmd)).toBe('/home/user/.opencli/clis/arxiv/search.js');
|
||||
});
|
||||
|
||||
it('skips manifest: pseudo-paths and falls back to _modulePath', () => {
|
||||
const cmd = makeCmd({ source: 'manifest:arxiv/search', _modulePath: '/pkg/clis/arxiv/search.js' });
|
||||
expect(resolveAdapterSourcePath(cmd)).toBe('/pkg/clis/arxiv/search.js');
|
||||
});
|
||||
|
||||
it('returns undefined when only manifest: pseudo-path and no _modulePath', () => {
|
||||
const cmd = makeCmd({ source: 'manifest:test/cmd' });
|
||||
expect(resolveAdapterSourcePath(cmd)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns _modulePath when it is the only path available', () => {
|
||||
const cmd = makeCmd({ _modulePath: '/project/clis/site/cmd.js' });
|
||||
expect(resolveAdapterSourcePath(cmd)).toBe('/project/clis/site/cmd.js');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as fs from 'node:fs';
|
||||
import type { InternalCliCommand } from './registry.js';
|
||||
|
||||
/**
|
||||
* Resolve the editable source file path for an adapter.
|
||||
*
|
||||
* Priority:
|
||||
* 1. cmd.source (set for FS-scanned JS and manifest lazy-loaded JS)
|
||||
* 2. cmd._modulePath (set for manifest lazy-loaded JS)
|
||||
*
|
||||
* Skip manifest: prefixed pseudo-paths (YAML commands inlined in manifest).
|
||||
*/
|
||||
export function resolveAdapterSourcePath(cmd: InternalCliCommand): string | undefined {
|
||||
const candidates: string[] = [];
|
||||
|
||||
if (cmd.source && !cmd.source.startsWith('manifest:')) {
|
||||
candidates.push(cmd.source);
|
||||
}
|
||||
if (cmd._modulePath) {
|
||||
candidates.push(cmd._modulePath);
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
|
||||
return candidates[0];
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { CliError } from '../errors.js';
|
||||
import { BasePage } from './base-page.js';
|
||||
|
||||
@@ -18,6 +18,26 @@ class TestPage extends BasePage {
|
||||
async selectTab(): Promise<void> {}
|
||||
}
|
||||
|
||||
class ActionPage extends BasePage {
|
||||
results: unknown[] = [];
|
||||
scripts: string[] = [];
|
||||
nativeType?: (text: string) => Promise<void>;
|
||||
insertText?: (text: string) => Promise<void>;
|
||||
nativeKeyPress?: (key: string, modifiers?: string[]) => Promise<void>;
|
||||
|
||||
async goto(): Promise<void> {}
|
||||
async evaluate(js: string): Promise<unknown> {
|
||||
this.scripts.push(js);
|
||||
return this.results.shift() ?? null;
|
||||
}
|
||||
async getCookies(): Promise<[]> { return []; }
|
||||
async screenshot(): Promise<string> { return ''; }
|
||||
async tabs(): Promise<unknown[]> { return []; }
|
||||
async selectTab(): Promise<void> {}
|
||||
}
|
||||
|
||||
const resolveOk = { ok: true, matches_n: 1, match_level: 'exact' };
|
||||
|
||||
describe('BasePage.fetchJson', () => {
|
||||
it('passes a narrow browser-context JSON request and parses the response in Node', async () => {
|
||||
const page = new TestPage();
|
||||
@@ -81,3 +101,61 @@ describe('BasePage.fetchJson', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('BasePage native input routing', () => {
|
||||
it('types rich-editor text via native Input.insertText when available', async () => {
|
||||
const page = new ActionPage();
|
||||
page.nativeType = vi.fn().mockResolvedValue(undefined);
|
||||
page.results = [resolveOk, { ok: true, mode: 'contenteditable' }];
|
||||
|
||||
await expect(page.typeText('#editor', 'hello')).resolves.toEqual({ matches_n: 1, match_level: 'exact' });
|
||||
|
||||
expect(page.nativeType).toHaveBeenCalledWith('hello');
|
||||
expect(page.scripts).toHaveLength(2);
|
||||
expect(page.scripts[1]).toContain('nearestContentEditableHost');
|
||||
expect(page.scripts.join('\n')).not.toContain("return 'typed'");
|
||||
});
|
||||
|
||||
it('keeps the DOM setter fallback when native text insertion is unavailable', async () => {
|
||||
const page = new ActionPage();
|
||||
page.results = [resolveOk, 'typed'];
|
||||
|
||||
await page.typeText('#q', 'hello');
|
||||
|
||||
expect(page.scripts).toHaveLength(2);
|
||||
expect(page.scripts[1]).toContain('document.execCommand');
|
||||
expect(page.scripts[1]).toContain("return 'typed'");
|
||||
});
|
||||
|
||||
it('falls back to DOM typing if native text insertion fails', async () => {
|
||||
const page = new ActionPage();
|
||||
page.nativeType = vi.fn().mockRejectedValue(new Error('native failed'));
|
||||
page.results = [resolveOk, { ok: true, mode: 'input' }, 'typed'];
|
||||
|
||||
await page.typeText('#q', 'hello');
|
||||
|
||||
expect(page.nativeType).toHaveBeenCalledWith('hello');
|
||||
expect(page.scripts).toHaveLength(3);
|
||||
expect(page.scripts[2]).toContain("return 'typed'");
|
||||
});
|
||||
|
||||
it('presses key chords through native CDP key events when available', async () => {
|
||||
const page = new ActionPage();
|
||||
page.nativeKeyPress = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await page.pressKey('Control+a');
|
||||
|
||||
expect(page.nativeKeyPress).toHaveBeenCalledWith('a', ['Ctrl']);
|
||||
expect(page.scripts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('falls back to synthetic keyboard events with parsed modifiers', async () => {
|
||||
const page = new ActionPage();
|
||||
|
||||
await page.pressKey('Meta+N');
|
||||
|
||||
expect(page.scripts).toHaveLength(1);
|
||||
expect(page.scripts[0]).toContain('key: "N"');
|
||||
expect(page.scripts[0]).toContain('metaKey: true');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
resolveTargetJs,
|
||||
clickResolvedJs,
|
||||
typeResolvedJs,
|
||||
prepareNativeTypeResolvedJs,
|
||||
scrollResolvedJs,
|
||||
type ResolveOptions,
|
||||
type TargetMatchLevel,
|
||||
@@ -73,6 +74,24 @@ function previewText(text: string | undefined): string | undefined {
|
||||
return preview ? `Response preview: ${preview}` : undefined;
|
||||
}
|
||||
|
||||
function parseKeyChord(rawKey: string): { key: string; modifiers: string[] } {
|
||||
const parts = rawKey.split('+').map(part => part.trim()).filter(Boolean);
|
||||
if (parts.length <= 1) return { key: rawKey, modifiers: [] };
|
||||
|
||||
const modifiers: string[] = [];
|
||||
for (const token of parts.slice(0, -1)) {
|
||||
const normalized = token.toLowerCase();
|
||||
if (normalized === 'ctrl' || normalized === 'control') modifiers.push('Ctrl');
|
||||
else if (normalized === 'cmd' || normalized === 'command' || normalized === 'meta') modifiers.push('Meta');
|
||||
else if (normalized === 'option' || normalized === 'alt') modifiers.push('Alt');
|
||||
else if (normalized === 'shift') modifiers.push('Shift');
|
||||
else return { key: rawKey, modifiers: [] };
|
||||
}
|
||||
|
||||
const key = parts.at(-1);
|
||||
return key ? { key, modifiers } : { key: rawKey, modifiers: [] };
|
||||
}
|
||||
|
||||
export abstract class BasePage implements IPage {
|
||||
protected _lastUrl: string | null = null;
|
||||
/** Cached previous snapshot hashes for incremental diff marking */
|
||||
@@ -225,19 +244,79 @@ export abstract class BasePage implements IPage {
|
||||
throw new Error(`Click failed: ${result.error ?? 'JS click and CDP fallback both failed'}`);
|
||||
}
|
||||
|
||||
/** Override in subclasses with CDP native click support */
|
||||
protected async tryNativeClick(_x: number, _y: number): Promise<boolean> {
|
||||
return false;
|
||||
/** Uses native CDP click support when the concrete page exposes it. */
|
||||
protected async tryNativeClick(x: number, y: number): Promise<boolean> {
|
||||
const nativeClick = (this as IPage).nativeClick;
|
||||
if (typeof nativeClick !== 'function') return false;
|
||||
try {
|
||||
await nativeClick.call(this, x, y);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Uses native CDP text insertion when the concrete page exposes it. */
|
||||
protected async tryNativeType(text: string): Promise<boolean> {
|
||||
const nativeType = (this as IPage).nativeType;
|
||||
if (typeof nativeType === 'function') {
|
||||
try {
|
||||
await nativeType.call(this, text);
|
||||
return true;
|
||||
} catch {
|
||||
// Fall through to the older dedicated insertText primitive if present.
|
||||
}
|
||||
}
|
||||
|
||||
const insertText = (this as IPage).insertText;
|
||||
if (typeof insertText !== 'function') return false;
|
||||
try {
|
||||
await insertText.call(this, text);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Uses native CDP key events when the concrete page exposes them. */
|
||||
protected async tryNativeKeyPress(key: string, modifiers: string[]): Promise<boolean> {
|
||||
const nativeKeyPress = (this as IPage).nativeKeyPress;
|
||||
if (typeof nativeKeyPress !== 'function') return false;
|
||||
try {
|
||||
await nativeKeyPress.call(this, key, modifiers);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async typeText(ref: string, text: string, opts: ResolveOptions = {}): Promise<ResolveSuccess> {
|
||||
const resolved = await runResolve(this, ref, opts);
|
||||
await this.evaluate(typeResolvedJs(text));
|
||||
let typed = false;
|
||||
|
||||
if (typeof (this as IPage).nativeType === 'function' || typeof (this as IPage).insertText === 'function') {
|
||||
try {
|
||||
const preparation = await this.evaluate(prepareNativeTypeResolvedJs()) as
|
||||
| { ok?: boolean; mode?: string; reason?: string }
|
||||
| null;
|
||||
typed = preparation?.ok === true && await this.tryNativeType(text);
|
||||
} catch {
|
||||
// Native input is a reliability upgrade, not the only path. Preserve
|
||||
// the existing DOM setter fallback if preparation fails.
|
||||
}
|
||||
}
|
||||
|
||||
if (!typed) {
|
||||
await this.evaluate(typeResolvedJs(text));
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
async pressKey(key: string): Promise<void> {
|
||||
await this.evaluate(pressKeyJs(key));
|
||||
const parsed = parseKeyChord(key);
|
||||
if (!await this.tryNativeKeyPress(parsed.key, parsed.modifiers)) {
|
||||
await this.evaluate(pressKeyJs(parsed.key, parsed.modifiers));
|
||||
}
|
||||
}
|
||||
|
||||
async scrollTo(ref: string, opts: ResolveOptions = {}): Promise<unknown> {
|
||||
|
||||
+5
-34
@@ -2,10 +2,7 @@
|
||||
* Browser session manager — auto-spawns daemon and provides IPage.
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import type { IPage } from '../types.js';
|
||||
import type { IBrowserFactory } from '../runtime.js';
|
||||
import { Page } from './page.js';
|
||||
@@ -14,6 +11,7 @@ import { DEFAULT_DAEMON_PORT } from '../constants.js';
|
||||
import { BrowserConnectError } from '../errors.js';
|
||||
import { PKG_VERSION } from '../version.js';
|
||||
import { resolveProfileContextId } from './profile.js';
|
||||
import { resolveDaemonLaunchSpec, spawnDaemonProcess, waitForDaemonStop } from './daemon-lifecycle.js';
|
||||
|
||||
const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension
|
||||
|
||||
@@ -102,7 +100,7 @@ export class BrowserBridge implements IBrowserFactory {
|
||||
process.stderr.write(`⚠️ Stale daemon detected (${reason}). Restarting...\n`);
|
||||
}
|
||||
const shutdownAccepted = await requestDaemonShutdown();
|
||||
const portReleased = shutdownAccepted && await this._waitForDaemonStop(3000);
|
||||
const portReleased = shutdownAccepted && await waitForDaemonStop(3000);
|
||||
|
||||
if (!portReleased) {
|
||||
// Stale daemon replacement failed — don't blindly spawn on an occupied port
|
||||
@@ -151,27 +149,11 @@ export class BrowserBridge implements IBrowserFactory {
|
||||
}
|
||||
|
||||
// No daemon — spawn one
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const parentDir = path.resolve(__dirname, '..');
|
||||
const daemonTs = path.join(parentDir, 'daemon.ts');
|
||||
const daemonJs = path.join(parentDir, 'daemon.js');
|
||||
const isTs = fs.existsSync(daemonTs);
|
||||
const daemonPath = isTs ? daemonTs : daemonJs;
|
||||
|
||||
if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
|
||||
process.stderr.write('⏳ Starting daemon...\n');
|
||||
}
|
||||
|
||||
const spawnArgs = isTs
|
||||
? [process.execPath, '--import', 'tsx/esm', daemonPath]
|
||||
: [process.execPath, daemonPath];
|
||||
|
||||
this._daemonProc = spawn(spawnArgs[0], spawnArgs.slice(1), {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: { ...process.env },
|
||||
});
|
||||
this._daemonProc.unref();
|
||||
this._daemonProc = spawnDaemonProcess();
|
||||
|
||||
// Wait for daemon + extension
|
||||
if (await this._pollUntilReady(timeoutMs, contextId)) return;
|
||||
@@ -207,22 +189,11 @@ export class BrowserBridge implements IBrowserFactory {
|
||||
|
||||
throw new BrowserConnectError(
|
||||
'Failed to start opencli daemon',
|
||||
`Try running manually:\n node ${daemonPath}\nMake sure port ${DEFAULT_DAEMON_PORT} is available.`,
|
||||
`Try running manually:\n node ${resolveDaemonLaunchSpec().scriptPath}\nMake sure port ${DEFAULT_DAEMON_PORT} is available.`,
|
||||
'daemon-not-running',
|
||||
);
|
||||
}
|
||||
|
||||
/** Poll until daemon is fully stopped (port released). */
|
||||
private async _waitForDaemonStop(timeoutMs: number): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
const h = await getDaemonHealth();
|
||||
if (h.state === 'stopped') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Poll getDaemonHealth() until state is 'ready' or deadline is reached. */
|
||||
private async _pollUntilReady(timeoutMs: number, contextId?: string): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
@@ -63,4 +63,33 @@ describe('CDPBridge cookies', () => {
|
||||
{ name: 'exact', value: '2', domain: 'example.com' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('exposes native input helpers on direct CDP pages', async () => {
|
||||
vi.stubEnv('OPENCLI_CDP_ENDPOINT', 'ws://127.0.0.1:9222/devtools/page/1');
|
||||
|
||||
const bridge = new CDPBridge();
|
||||
const send = vi.spyOn(bridge, 'send').mockResolvedValue({});
|
||||
|
||||
const page = await bridge.connect();
|
||||
send.mockClear();
|
||||
|
||||
expect(page.nativeType).toBeTypeOf('function');
|
||||
expect(page.nativeKeyPress).toBeTypeOf('function');
|
||||
expect(page.nativeClick).toBeTypeOf('function');
|
||||
expect(page.cdp).toBeTypeOf('function');
|
||||
|
||||
await page.nativeType!('hello');
|
||||
await page.nativeKeyPress!('a', ['Ctrl']);
|
||||
await page.nativeClick!(10, 20);
|
||||
await page.cdp!('Page.getLayoutMetrics', {});
|
||||
|
||||
expect(send.mock.calls).toEqual([
|
||||
['Input.insertText', { text: 'hello' }],
|
||||
['Input.dispatchKeyEvent', { type: 'keyDown', key: 'a', modifiers: 2 }],
|
||||
['Input.dispatchKeyEvent', { type: 'keyUp', key: 'a', modifiers: 2 }],
|
||||
['Input.dispatchMouseEvent', { type: 'mousePressed', x: 10, y: 20, button: 'left', clickCount: 1 }],
|
||||
['Input.dispatchMouseEvent', { type: 'mouseReleased', x: 10, y: 20, button: 'left', clickCount: 1 }],
|
||||
['Page.getLayoutMetrics', {}],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+52
-3
@@ -274,7 +274,7 @@ class CDPPage extends BasePage {
|
||||
const idx = this._networkEntries.push({
|
||||
url: p.request.url,
|
||||
method: p.request.method,
|
||||
timestamp: p.timestamp,
|
||||
timestamp: Date.now(),
|
||||
}) - 1;
|
||||
this._pendingRequests.set(p.requestId, idx);
|
||||
}
|
||||
@@ -340,14 +340,14 @@ class CDPPage extends BasePage {
|
||||
this.bridge.on('Runtime.consoleAPICalled', (params: unknown) => {
|
||||
const p = params as { type: string; args: Array<{ value?: unknown; description?: string }>; timestamp: number };
|
||||
const text = (p.args || []).map(a => a.value !== undefined ? String(a.value) : (a.description || '')).join(' ');
|
||||
this._consoleMessages.push({ type: p.type, text, timestamp: p.timestamp });
|
||||
this._consoleMessages.push({ type: p.type, text, timestamp: Date.now() });
|
||||
if (this._consoleMessages.length > 500) this._consoleMessages.shift();
|
||||
});
|
||||
// Capture uncaught exceptions as error-level messages
|
||||
this.bridge.on('Runtime.exceptionThrown', (params: unknown) => {
|
||||
const p = params as { timestamp: number; exceptionDetails?: { exception?: { description?: string }; text?: string } };
|
||||
const desc = p.exceptionDetails?.exception?.description || p.exceptionDetails?.text || 'Unknown exception';
|
||||
this._consoleMessages.push({ type: 'error', text: desc, timestamp: p.timestamp });
|
||||
this._consoleMessages.push({ type: 'error', text: desc, timestamp: Date.now() });
|
||||
if (this._consoleMessages.length > 500) this._consoleMessages.shift();
|
||||
});
|
||||
this._consoleCapturing = true;
|
||||
@@ -365,6 +365,55 @@ class CDPPage extends BasePage {
|
||||
async selectTab(_target: number | string): Promise<void> {
|
||||
// Not supported in direct CDP mode
|
||||
}
|
||||
|
||||
async cdp(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
|
||||
return this.bridge.send(method, params);
|
||||
}
|
||||
|
||||
async nativeClick(x: number, y: number): Promise<void> {
|
||||
await this.cdp('Input.dispatchMouseEvent', {
|
||||
type: 'mousePressed',
|
||||
x,
|
||||
y,
|
||||
button: 'left',
|
||||
clickCount: 1,
|
||||
});
|
||||
await this.cdp('Input.dispatchMouseEvent', {
|
||||
type: 'mouseReleased',
|
||||
x,
|
||||
y,
|
||||
button: 'left',
|
||||
clickCount: 1,
|
||||
});
|
||||
}
|
||||
|
||||
async nativeType(text: string): Promise<void> {
|
||||
await this.cdp('Input.insertText', { text });
|
||||
}
|
||||
|
||||
async insertText(text: string): Promise<void> {
|
||||
await this.nativeType(text);
|
||||
}
|
||||
|
||||
async nativeKeyPress(key: string, modifiers: string[] = []): Promise<void> {
|
||||
let modifierFlags = 0;
|
||||
for (const mod of modifiers) {
|
||||
if (mod === 'Alt') modifierFlags |= 1;
|
||||
if (mod === 'Ctrl' || mod === 'Control') modifierFlags |= 2;
|
||||
if (mod === 'Meta') modifierFlags |= 4;
|
||||
if (mod === 'Shift') modifierFlags |= 8;
|
||||
}
|
||||
await this.cdp('Input.dispatchKeyEvent', {
|
||||
type: 'keyDown',
|
||||
key,
|
||||
modifiers: modifierFlags,
|
||||
});
|
||||
await this.cdp('Input.dispatchKeyEvent', {
|
||||
type: 'keyUp',
|
||||
key,
|
||||
modifiers: modifierFlags,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function isCookie(value: unknown): value is BrowserCookie {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { DEFAULT_DAEMON_PORT } from '../constants.js';
|
||||
import { fetchDaemonStatus, getDaemonHealth, requestDaemonShutdown, type DaemonStatus } from './daemon-client.js';
|
||||
|
||||
export interface DaemonLaunchSpec {
|
||||
binary: string;
|
||||
args: string[];
|
||||
scriptPath: string;
|
||||
}
|
||||
|
||||
export interface DaemonRestartResult {
|
||||
previousStatus: DaemonStatus | null;
|
||||
status: DaemonStatus | null;
|
||||
stopped: boolean;
|
||||
spawned: boolean;
|
||||
}
|
||||
|
||||
export function resolveDaemonLaunchSpec(): DaemonLaunchSpec {
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const parentDir = path.resolve(__dirname, '..');
|
||||
const daemonTs = path.join(parentDir, 'daemon.ts');
|
||||
const daemonJs = path.join(parentDir, 'daemon.js');
|
||||
const isTs = fs.existsSync(daemonTs);
|
||||
const scriptPath = isTs ? daemonTs : daemonJs;
|
||||
return {
|
||||
binary: process.execPath,
|
||||
args: isTs ? ['--import', 'tsx/esm', scriptPath] : [scriptPath],
|
||||
scriptPath,
|
||||
};
|
||||
}
|
||||
|
||||
export function spawnDaemonProcess(): ChildProcess {
|
||||
const launch = resolveDaemonLaunchSpec();
|
||||
const proc = spawn(launch.binary, launch.args, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: { ...process.env },
|
||||
});
|
||||
proc.unref();
|
||||
return proc;
|
||||
}
|
||||
|
||||
export async function waitForDaemonStop(timeoutMs: number): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(200);
|
||||
const h = await getDaemonHealth();
|
||||
if (h.state === 'stopped') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function waitForDaemonStatus(timeoutMs: number): Promise<DaemonStatus | null> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const status = await fetchDaemonStatus({ timeout: Math.min(1000, Math.max(100, deadline - Date.now())) });
|
||||
if (status) return status;
|
||||
await sleep(200);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function restartDaemon(opts: { stopTimeoutMs?: number; startTimeoutMs?: number } = {}): Promise<DaemonRestartResult> {
|
||||
const previousStatus = await fetchDaemonStatus();
|
||||
let stopped = previousStatus === null;
|
||||
if (previousStatus) {
|
||||
const shutdownAccepted = await requestDaemonShutdown();
|
||||
stopped = shutdownAccepted && await waitForDaemonStop(opts.stopTimeoutMs ?? 3000);
|
||||
if (!stopped) {
|
||||
return { previousStatus, status: previousStatus, stopped: false, spawned: false };
|
||||
}
|
||||
}
|
||||
|
||||
spawnDaemonProcess();
|
||||
const status = await waitForDaemonStatus(opts.startTimeoutMs ?? 5000);
|
||||
return { previousStatus, status, stopped, spawned: true };
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export { DEFAULT_DAEMON_PORT };
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { DaemonStatus } from './daemon-client.js';
|
||||
|
||||
export function isDaemonStale(status: Pick<DaemonStatus, 'daemonVersion'> | null | undefined, cliVersion?: string): boolean {
|
||||
if (!status || !cliVersion) return false;
|
||||
return !status.daemonVersion || status.daemonVersion !== cliVersion;
|
||||
}
|
||||
|
||||
export function formatDaemonVersion(status: Pick<DaemonStatus, 'daemonVersion'> | null | undefined): string {
|
||||
return status?.daemonVersion ? `v${status.daemonVersion}` : 'version unknown';
|
||||
}
|
||||
|
||||
export function staleDaemonIssue(status: Pick<DaemonStatus, 'daemonVersion'> | null | undefined, cliVersion: string): string {
|
||||
return `Stale daemon detected: daemon ${formatDaemonVersion(status)} != CLI v${cliVersion}.\n` +
|
||||
' Run: opencli daemon restart';
|
||||
}
|
||||
@@ -84,12 +84,24 @@ export function typeTextJs(ref: string, text: string): string {
|
||||
}
|
||||
|
||||
/** Generate JS to press a keyboard key */
|
||||
export function pressKeyJs(key: string): string {
|
||||
export function pressKeyJs(key: string, modifiers: string[] = []): string {
|
||||
const hasCtrl = modifiers.includes('Ctrl') || modifiers.includes('Control');
|
||||
const hasAlt = modifiers.includes('Alt');
|
||||
const hasMeta = modifiers.includes('Meta');
|
||||
const hasShift = modifiers.includes('Shift');
|
||||
return `
|
||||
(() => {
|
||||
const el = document.activeElement || document.body;
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { key: ${JSON.stringify(key)}, bubbles: true }));
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', { key: ${JSON.stringify(key)}, bubbles: true }));
|
||||
const init = {
|
||||
key: ${JSON.stringify(key)},
|
||||
bubbles: true,
|
||||
ctrlKey: ${hasCtrl},
|
||||
altKey: ${hasAlt},
|
||||
metaKey: ${hasMeta},
|
||||
shiftKey: ${hasShift},
|
||||
};
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', init));
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', init));
|
||||
return 'pressed';
|
||||
})()
|
||||
`;
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface CachedNetworkEntry {
|
||||
*/
|
||||
body_truncated?: boolean;
|
||||
body_full_size?: number;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
export interface NetworkCacheFile {
|
||||
|
||||
+1
-1
@@ -390,7 +390,7 @@ export class Page extends BasePage {
|
||||
let modifierFlags = 0;
|
||||
for (const mod of modifiers) {
|
||||
if (mod === 'Alt') modifierFlags |= 1;
|
||||
if (mod === 'Ctrl') modifierFlags |= 2;
|
||||
if (mod === 'Ctrl' || mod === 'Control') modifierFlags |= 2;
|
||||
if (mod === 'Meta') modifierFlags |= 4;
|
||||
if (mod === 'Shift') modifierFlags |= 8;
|
||||
}
|
||||
|
||||
@@ -354,6 +354,82 @@ export function typeResolvedJs(text: string): string {
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the resolved element for native CDP Input.insertText.
|
||||
*
|
||||
* This preserves `browser type`'s existing "replace current text" semantics:
|
||||
* focus the editable target, select its current contents, then let CDP insert
|
||||
* real browser text input so rich editors can update their internal state.
|
||||
*/
|
||||
export function prepareNativeTypeResolvedJs(): string {
|
||||
return `
|
||||
(() => {
|
||||
const original = window.__resolved;
|
||||
if (!original) throw new Error('No resolved element');
|
||||
|
||||
function nearestContentEditableHost(el) {
|
||||
let current = el;
|
||||
while (current && current.nodeType === 1) {
|
||||
if (current.hasAttribute && current.hasAttribute('contenteditable')) return current;
|
||||
current = current.parentElement;
|
||||
}
|
||||
return el.isContentEditable ? el : null;
|
||||
}
|
||||
|
||||
const editableHost = original.isContentEditable ? nearestContentEditableHost(original) : null;
|
||||
const inputTypes = new Set(['', 'text', 'search', 'url', 'tel', 'email', 'password']);
|
||||
const isInput = original instanceof HTMLInputElement;
|
||||
const isTextarea = original instanceof HTMLTextAreaElement;
|
||||
const isTextControl = isTextarea || (isInput && inputTypes.has((original.getAttribute('type') || original.type || '').toLowerCase()));
|
||||
const el = editableHost || (isTextControl ? original : null);
|
||||
|
||||
if (!el) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'not_editable',
|
||||
tag: original.tagName ? original.tagName.toLowerCase() : '',
|
||||
};
|
||||
}
|
||||
|
||||
window.__resolved = el;
|
||||
el.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'nearest' });
|
||||
try {
|
||||
el.focus({ preventScroll: true });
|
||||
} catch (_) {
|
||||
el.focus();
|
||||
}
|
||||
|
||||
if (editableHost) {
|
||||
const sel = window.getSelection();
|
||||
if (!sel) return { ok: false, reason: 'selection_unavailable', mode: 'contenteditable' };
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
return { ok: true, mode: 'contenteditable' };
|
||||
}
|
||||
|
||||
let selected = false;
|
||||
try {
|
||||
if (typeof el.setSelectionRange === 'function') {
|
||||
el.setSelectionRange(0, String(el.value || '').length);
|
||||
selected = true;
|
||||
}
|
||||
} catch (_) {}
|
||||
try {
|
||||
if (!selected && typeof el.select === 'function') {
|
||||
el.select();
|
||||
selected = true;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return selected
|
||||
? { ok: true, mode: isTextarea ? 'textarea' : 'input' }
|
||||
: { ok: false, reason: 'selection_unavailable', mode: isTextarea ? 'textarea' : 'input' };
|
||||
})()
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate JS for scrollTo that uses the unified resolver.
|
||||
* Assumes resolveTargetJs has been called and __resolved is set.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { deriveFixture, expandFixtureArgs, validateRows, type Fixture } from './verify-fixture.js';
|
||||
import { deriveFixture, expandFixtureArgs, parseSeedArgs, validateRows, type Fixture } from './verify-fixture.js';
|
||||
|
||||
describe('validateRows', () => {
|
||||
it('passes when rows meet all expectations', () => {
|
||||
@@ -221,3 +221,22 @@ describe('expandFixtureArgs', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSeedArgs', () => {
|
||||
it('treats plain text as one positional arg', () => {
|
||||
expect(parseSeedArgs('opencli-verify')).toEqual(['opencli-verify']);
|
||||
});
|
||||
|
||||
it('accepts JSON array seed args', () => {
|
||||
expect(parseSeedArgs('["subject", "--limit", 3]')).toEqual(['subject', '--limit', 3]);
|
||||
});
|
||||
|
||||
it('accepts JSON object seed args', () => {
|
||||
expect(parseSeedArgs('{"limit":3,"sort":"hot"}')).toEqual({ limit: 3, sort: 'hot' });
|
||||
});
|
||||
|
||||
it('ignores empty input', () => {
|
||||
expect(parseSeedArgs(undefined)).toBeUndefined();
|
||||
expect(parseSeedArgs(' ')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -239,6 +239,21 @@ export function expandFixtureArgs(args: FixtureArgs | undefined): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseSeedArgs(raw: string | undefined): FixtureArgs | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return undefined;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
if (parsed !== null && typeof parsed === 'object') return parsed as Record<string, unknown>;
|
||||
return [parsed];
|
||||
} catch {
|
||||
return [raw];
|
||||
}
|
||||
}
|
||||
|
||||
function jsType(v: unknown): string {
|
||||
if (v === null) return 'null';
|
||||
if (Array.isArray(v)) return 'array';
|
||||
|
||||
@@ -3,7 +3,16 @@ import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { cli, getRegistry, Strategy } from './registry.js';
|
||||
import { loadManifestEntries, normalizeManifestPath, serializeManifest } from './build-manifest.js';
|
||||
import {
|
||||
ManifestImportError,
|
||||
diffRemovedEntries,
|
||||
loadManifestEntries,
|
||||
normalizeManifestPath,
|
||||
parseBuildManifestArgs,
|
||||
scanClisDir,
|
||||
serializeManifest,
|
||||
type ManifestEntry,
|
||||
} from './build-manifest.js';
|
||||
|
||||
describe('manifest helper rules', () => {
|
||||
const tempDirs: string[] = [];
|
||||
@@ -176,4 +185,90 @@ describe('manifest helper rules', () => {
|
||||
expect(serialized.endsWith('\n')).toBe(true);
|
||||
expect(serialized).toContain('\n]');
|
||||
});
|
||||
|
||||
it('throws ManifestImportError when an adapter looks like a CLI module but fails to import', async () => {
|
||||
// Reproduces the "stale dist drops adapters silently" incident: the file
|
||||
// matches the cli() pattern (so it's not just a helper), but the importer
|
||||
// throws — we want the failure surfaced, not swallowed.
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-manifest-fail-'));
|
||||
tempDirs.push(dir);
|
||||
const file = path.join(dir, 'broken.ts');
|
||||
fs.writeFileSync(file, `export const command = cli({ site: 'demo', name: 'broken' });`);
|
||||
|
||||
const importer = async () => { throw new Error('boom: stale dist'); };
|
||||
|
||||
await expect(loadManifestEntries(file, 'demo', importer))
|
||||
.rejects.toBeInstanceOf(ManifestImportError);
|
||||
|
||||
try {
|
||||
await loadManifestEntries(file, 'demo', importer);
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ManifestImportError);
|
||||
const e = err as ManifestImportError;
|
||||
expect(e.filePath).toBe(file);
|
||||
expect(e.message).toContain('boom: stale dist');
|
||||
}
|
||||
});
|
||||
|
||||
it('still silently skips files that do not call cli() even if the importer would have thrown', async () => {
|
||||
// The cli() pattern check happens before importing — we don't even ask
|
||||
// the importer about helper modules, so a thrown import does not turn
|
||||
// them into failures.
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-manifest-helper-'));
|
||||
tempDirs.push(dir);
|
||||
const file = path.join(dir, 'helper.ts');
|
||||
fs.writeFileSync(file, `export const helper = () => 42;`);
|
||||
const importer = async () => { throw new Error('should never be called'); };
|
||||
await expect(loadManifestEntries(file, 'demo', importer)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('scanClisDir aggregates per-adapter import failures instead of silently dropping them', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-clis-'));
|
||||
tempDirs.push(root);
|
||||
const siteDir = path.join(root, 'demo');
|
||||
fs.mkdirSync(siteDir);
|
||||
fs.writeFileSync(path.join(siteDir, 'good.js'),
|
||||
`export const cmd = cli({ site: 'demo', name: 'good' });`);
|
||||
fs.writeFileSync(path.join(siteDir, 'broken.js'),
|
||||
`export const cmd = cli({ site: 'demo', name: 'broken' });`);
|
||||
|
||||
const importer = async (href: string): Promise<unknown> => {
|
||||
if (href.endsWith('broken.js')) throw new Error('stale dist drops broken');
|
||||
return { cmd: cli({ site: 'demo', name: 'good', description: 'ok' }) };
|
||||
};
|
||||
|
||||
const result = await scanClisDir(root, importer);
|
||||
|
||||
expect(result.failures).toHaveLength(1);
|
||||
expect(result.failures[0]).toBeInstanceOf(ManifestImportError);
|
||||
expect(result.failures[0].filePath).toMatch(/broken\.js$/);
|
||||
expect(result.failures[0].message).toContain('stale dist drops broken');
|
||||
expect(result.entries.map(e => e.name)).toEqual(['good']);
|
||||
|
||||
getRegistry().delete('demo/good');
|
||||
});
|
||||
|
||||
it('diffRemovedEntries returns site/name keys present only in prev', () => {
|
||||
const prev: ManifestEntry[] = [
|
||||
{ site: 'a', name: '1', description: '', strategy: 'public', browser: false, args: [], type: 'js' },
|
||||
{ site: 'a', name: '2', description: '', strategy: 'public', browser: false, args: [], type: 'js' },
|
||||
{ site: 'b', name: '3', description: '', strategy: 'public', browser: false, args: [], type: 'js' },
|
||||
];
|
||||
const next: ManifestEntry[] = [
|
||||
{ site: 'a', name: '1', description: '', strategy: 'public', browser: false, args: [], type: 'js' },
|
||||
];
|
||||
expect(diffRemovedEntries(prev, next)).toEqual(['a/2', 'b/3']);
|
||||
expect(diffRemovedEntries(prev, prev)).toEqual([]);
|
||||
expect(diffRemovedEntries([], next)).toEqual([]);
|
||||
});
|
||||
|
||||
it('parseBuildManifestArgs reads --allow-removals[=N]', () => {
|
||||
expect(parseBuildManifestArgs([]).allowRemovals).toBe(0);
|
||||
expect(parseBuildManifestArgs(['--allow-removals=5']).allowRemovals).toBe(5);
|
||||
expect(parseBuildManifestArgs(['--allow-removals=0']).allowRemovals).toBe(0);
|
||||
// Bare flag is the explicit "I know what I'm doing" escape hatch.
|
||||
expect(parseBuildManifestArgs(['--allow-removals']).allowRemovals).toBe(Number.POSITIVE_INFINITY);
|
||||
// Unknown flags are ignored.
|
||||
expect(parseBuildManifestArgs(['--something-else']).allowRemovals).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
+207
-59
@@ -5,8 +5,21 @@
|
||||
* Scans all JS CLI definitions in clis/ and pre-compiles them into a single
|
||||
* manifest.json for instant cold-start registration.
|
||||
*
|
||||
* Usage: npx tsx src/build-manifest.ts
|
||||
* Usage: npx tsx src/build-manifest.ts [--allow-removals[=N]]
|
||||
*
|
||||
* Output: cli-manifest.json next to clis/
|
||||
*
|
||||
* Safety invariants:
|
||||
* - Adapters whose source file does not call `cli(...)` are silently
|
||||
* skipped (they are helpers / type modules, not commands).
|
||||
* - Adapters that look like commands but fail to import are reported as
|
||||
* failures, the manifest is NOT written, and the process exits 1. This
|
||||
* prevents a stale dist or a broken adapter from silently dropping
|
||||
* other adapters' entries (root cause of the "manifest lost 478 lines"
|
||||
* incident).
|
||||
* - Net-deletions vs the existing committed manifest abort the build by
|
||||
* default; pass `--allow-removals=N` (or just `--allow-removals` for any
|
||||
* amount) to confirm an intentional removal.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
@@ -15,47 +28,44 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { getErrorMessage } from './errors.js';
|
||||
import { fullName, getRegistry, type CliCommand } from './registry.js';
|
||||
import { findPackageRoot, getCliManifestPath } from './package-paths.js';
|
||||
import type { ManifestEntry } from './manifest-types.js';
|
||||
import { isRecord } from './utils.js';
|
||||
|
||||
export type { ManifestEntry } from './manifest-types.js';
|
||||
|
||||
const PACKAGE_ROOT = findPackageRoot(fileURLToPath(import.meta.url));
|
||||
const CLIS_DIR = path.join(PACKAGE_ROOT, 'clis');
|
||||
// Write manifest next to clis/ so both dev and installed runtime can find it.
|
||||
const OUTPUT = getCliManifestPath(CLIS_DIR);
|
||||
|
||||
export interface ManifestEntry {
|
||||
site: string;
|
||||
name: string;
|
||||
aliases?: string[];
|
||||
description: string;
|
||||
domain?: string;
|
||||
strategy: string;
|
||||
browser: boolean;
|
||||
args: Array<{
|
||||
name: string;
|
||||
type?: string;
|
||||
default?: unknown;
|
||||
required?: boolean;
|
||||
valueRequired?: boolean;
|
||||
positional?: boolean;
|
||||
help?: string;
|
||||
choices?: string[];
|
||||
}>;
|
||||
columns?: string[];
|
||||
pipeline?: Record<string, unknown>[];
|
||||
timeout?: number;
|
||||
deprecated?: boolean | string;
|
||||
replacedBy?: string;
|
||||
type: 'js';
|
||||
/** Relative path from clis/ dir, e.g. 'bilibili/search.js' */
|
||||
modulePath?: string;
|
||||
/** Relative path to the source file from clis/ dir (e.g. 'site/cmd.js') */
|
||||
sourceFile?: string;
|
||||
/** Pre-navigation control — see CliCommand.navigateBefore */
|
||||
navigateBefore?: boolean | string;
|
||||
const CLI_MODULE_PATTERN = /\bcli\s*\(/;
|
||||
|
||||
/**
|
||||
* Thrown by `loadManifestEntries` when an adapter file looks like a CLI
|
||||
* module (matches CLI_MODULE_PATTERN) but cannot be imported. Callers
|
||||
* decide whether to abort or aggregate failures across the whole scan.
|
||||
*/
|
||||
export class ManifestImportError extends Error {
|
||||
constructor(
|
||||
public readonly filePath: string,
|
||||
public readonly cause: unknown,
|
||||
) {
|
||||
super(`failed to scan ${filePath}: ${getErrorMessage(cause)}`);
|
||||
this.name = 'ManifestImportError';
|
||||
}
|
||||
}
|
||||
|
||||
import { isRecord } from './utils.js';
|
||||
export interface BuildManifestResult {
|
||||
entries: ManifestEntry[];
|
||||
/** Adapters that look like CLI modules but failed to import. */
|
||||
failures: ManifestImportError[];
|
||||
}
|
||||
|
||||
const CLI_MODULE_PATTERN = /\bcli\s*\(/;
|
||||
export interface BuildManifestArgs {
|
||||
/** Maximum number of entries that may be removed vs the existing manifest.
|
||||
* `Number.POSITIVE_INFINITY` disables the safety net entirely. */
|
||||
allowRemovals: number;
|
||||
}
|
||||
|
||||
function toManifestArgs(args: CliCommand['args']): ManifestEntry['args'] {
|
||||
return args.map(arg => ({
|
||||
@@ -79,8 +89,8 @@ export function normalizeManifestPath(relativePath: string): string {
|
||||
return relativePath.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
function toManifestRelativePath(filePath: string): string {
|
||||
return normalizeManifestPath(path.relative(CLIS_DIR, filePath));
|
||||
function toManifestRelativePath(filePath: string, clisDir: string): string {
|
||||
return normalizeManifestPath(path.relative(clisDir, filePath));
|
||||
}
|
||||
|
||||
function isCliCommandValue(value: unknown, site: string): value is CliCommand {
|
||||
@@ -112,17 +122,35 @@ function toManifestEntry(cmd: CliCommand, modulePath: string, sourceFile?: strin
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all manifest entries from a single adapter file.
|
||||
*
|
||||
* Returns `[]` for files that do not register a CLI command (helpers, types).
|
||||
* Throws `ManifestImportError` when a file looks like a CLI module but its
|
||||
* import or post-import processing fails — callers must decide whether to
|
||||
* surface or aggregate the failure.
|
||||
*
|
||||
* The third argument `clisDir` is used to compute the POSIX-style
|
||||
* `sourceFile` relative path; it defaults to the package's `clis/` dir so
|
||||
* existing test callers stay backward-compatible.
|
||||
*/
|
||||
export async function loadManifestEntries(
|
||||
filePath: string,
|
||||
site: string,
|
||||
importer: (moduleHref: string) => Promise<unknown> = moduleHref => import(moduleHref),
|
||||
clisDir: string = CLIS_DIR,
|
||||
): Promise<ManifestEntry[]> {
|
||||
let src: string;
|
||||
try {
|
||||
const src = fs.readFileSync(filePath, 'utf-8');
|
||||
src = fs.readFileSync(filePath, 'utf-8');
|
||||
} catch (err) {
|
||||
throw new ManifestImportError(filePath, err);
|
||||
}
|
||||
|
||||
// Helper/test modules should not appear as CLI commands in the manifest.
|
||||
if (!CLI_MODULE_PATTERN.test(src)) return [];
|
||||
// Helper / test modules that do not call cli() are not commands.
|
||||
if (!CLI_MODULE_PATTERN.test(src)) return [];
|
||||
|
||||
try {
|
||||
const modulePath = toModulePath(filePath, site);
|
||||
const registry = getRegistry();
|
||||
const before = new Map(registry.entries());
|
||||
@@ -143,7 +171,7 @@ export async function loadManifestEntries(
|
||||
|
||||
// Manifest paths are cross-platform artifacts; keep them POSIX-style even
|
||||
// when build-manifest runs on Windows.
|
||||
const sourceRelative = toManifestRelativePath(filePath);
|
||||
const sourceRelative = toManifestRelativePath(filePath, clisDir);
|
||||
|
||||
const seen = new Set<string>();
|
||||
return runtimeCommands
|
||||
@@ -156,47 +184,167 @@ export async function loadManifestEntries(
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map(cmd => toManifestEntry(cmd, modulePath, sourceRelative));
|
||||
} catch (err) {
|
||||
// If parsing fails, log a warning (matching scanYaml behaviour) and skip the entry.
|
||||
process.stderr.write(`Warning: failed to scan ${filePath}: ${getErrorMessage(err)}\n`);
|
||||
return [];
|
||||
throw new ManifestImportError(filePath, err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildManifest(): Promise<ManifestEntry[]> {
|
||||
/**
|
||||
* Scan a `clis/` directory and aggregate per-adapter results. Import
|
||||
* failures are collected in `failures` instead of crashing the whole scan,
|
||||
* but the caller (e.g. `main()`) is expected to fail loud if any failure
|
||||
* is present.
|
||||
*/
|
||||
export async function scanClisDir(
|
||||
clisDir: string,
|
||||
importer: (moduleHref: string) => Promise<unknown> = moduleHref => import(moduleHref),
|
||||
): Promise<BuildManifestResult> {
|
||||
const manifest = new Map<string, ManifestEntry>();
|
||||
const failures: ManifestImportError[] = [];
|
||||
|
||||
// Scan JS adapters directly from clis/.
|
||||
// Adapters are now JS-first — no compilation step needed.
|
||||
if (fs.existsSync(CLIS_DIR)) {
|
||||
for (const site of fs.readdirSync(CLIS_DIR)) {
|
||||
const siteDir = path.join(CLIS_DIR, site);
|
||||
if (!fs.statSync(siteDir).isDirectory()) continue;
|
||||
for (const file of fs.readdirSync(siteDir)) {
|
||||
if (file.endsWith('.js') && !file.endsWith('.d.js') && !file.endsWith('.test.js') && file !== 'index.js') {
|
||||
const filePath = path.join(siteDir, file);
|
||||
const entries = await loadManifestEntries(filePath, site);
|
||||
if (!fs.existsSync(clisDir)) {
|
||||
return { entries: [], failures };
|
||||
}
|
||||
|
||||
for (const site of fs.readdirSync(clisDir)) {
|
||||
const siteDir = path.join(clisDir, site);
|
||||
if (!fs.statSync(siteDir).isDirectory()) continue;
|
||||
for (const file of fs.readdirSync(siteDir)) {
|
||||
if (file.endsWith('.js') && !file.endsWith('.d.js') && !file.endsWith('.test.js') && file !== 'index.js') {
|
||||
const filePath = path.join(siteDir, file);
|
||||
try {
|
||||
const entries = await loadManifestEntries(filePath, site, importer, clisDir);
|
||||
for (const entry of entries) {
|
||||
const key = `${entry.site}/${entry.name}`;
|
||||
manifest.set(key, entry);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ManifestImportError) {
|
||||
failures.push(err);
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...manifest.values()].sort((a, b) => a.site.localeCompare(b.site) || a.name.localeCompare(b.name));
|
||||
const entries = [...manifest.values()].sort(
|
||||
(a, b) => a.site.localeCompare(b.site) || a.name.localeCompare(b.name),
|
||||
);
|
||||
return { entries, failures };
|
||||
}
|
||||
|
||||
export async function buildManifest(): Promise<BuildManifestResult> {
|
||||
return scanClisDir(CLIS_DIR);
|
||||
}
|
||||
|
||||
export function serializeManifest(manifest: ManifestEntry[]): string {
|
||||
return `${JSON.stringify(manifest, null, 2)}\n`;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const manifest = await buildManifest();
|
||||
fs.mkdirSync(path.dirname(OUTPUT), { recursive: true });
|
||||
fs.writeFileSync(OUTPUT, serializeManifest(manifest));
|
||||
/**
|
||||
* Diff helper: returns site/name keys that exist in `prev` but not in
|
||||
* `next`. Used as a safety net to detect accidental mass-deletions caused
|
||||
* by silently failing adapter imports.
|
||||
*/
|
||||
export function diffRemovedEntries(
|
||||
prev: readonly ManifestEntry[],
|
||||
next: readonly ManifestEntry[],
|
||||
): string[] {
|
||||
const nextKeys = new Set(next.map(e => `${e.site}/${e.name}`));
|
||||
return prev
|
||||
.map(e => `${e.site}/${e.name}`)
|
||||
.filter(key => !nextKeys.has(key))
|
||||
.sort();
|
||||
}
|
||||
|
||||
console.log(`✅ Manifest compiled: ${manifest.length} entries → ${OUTPUT}`);
|
||||
/**
|
||||
* Parse `--allow-removals` and `--allow-removals=N` from argv.
|
||||
* Bare `--allow-removals` disables the safety net (`Infinity`); the
|
||||
* numeric form sets an explicit upper bound.
|
||||
*/
|
||||
export function parseBuildManifestArgs(argv: readonly string[]): BuildManifestArgs {
|
||||
let allowRemovals = 0;
|
||||
for (const arg of argv) {
|
||||
if (arg === '--allow-removals') {
|
||||
allowRemovals = Number.POSITIVE_INFINITY;
|
||||
continue;
|
||||
}
|
||||
const m = arg.match(/^--allow-removals=(\d+)$/);
|
||||
if (m) {
|
||||
allowRemovals = Number.parseInt(m[1], 10);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return { allowRemovals };
|
||||
}
|
||||
|
||||
function readExistingManifest(filePath: string): ManifestEntry[] | null {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
const raw = fs.readFileSync(filePath, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed as ManifestEntry[] : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// Runtime guard: refuse to run from dist/. tsc transitively emits this
|
||||
// file (the test file imports from it) so dist/src/build-manifest.js
|
||||
// physically exists. If a developer or agent runs that compiled copy,
|
||||
// any stale dist will silently break adapter imports — the exact failure
|
||||
// mode this script is meant to prevent. Direct them at the tsx entry
|
||||
// before they can shoot themselves in the foot.
|
||||
if (fileURLToPath(import.meta.url).includes(`${path.sep}dist${path.sep}`)) {
|
||||
process.stderr.write(
|
||||
`❌ Refusing to run build-manifest from dist/.\n`
|
||||
+ ` Stale compiled output silently drops adapters that import renamed/removed exports.\n`
|
||||
+ ` Run \`npm run build-manifest\` (or \`tsx src/build-manifest.ts\`) from the source tree instead.\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const args = parseBuildManifestArgs(process.argv.slice(2));
|
||||
const { entries, failures } = await buildManifest();
|
||||
|
||||
if (failures.length > 0) {
|
||||
process.stderr.write(`❌ ${failures.length} adapter(s) failed to load:\n`);
|
||||
for (const failure of failures) {
|
||||
const rel = path.relative(PACKAGE_ROOT, failure.filePath) || failure.filePath;
|
||||
process.stderr.write(` - ${rel}: ${getErrorMessage(failure.cause)}\n`);
|
||||
}
|
||||
process.stderr.write(
|
||||
`\nManifest NOT written. Likely cause: stale dist/ or a broken adapter import.\n`
|
||||
+ `Always run via tsx (\`npm run build-manifest\`), not against compiled dist/.\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const existing = readExistingManifest(OUTPUT);
|
||||
if (existing) {
|
||||
const removed = diffRemovedEntries(existing, entries);
|
||||
if (removed.length > args.allowRemovals) {
|
||||
process.stderr.write(
|
||||
`❌ ${removed.length} manifest entries would be removed; refusing to overwrite.\n`,
|
||||
);
|
||||
const preview = removed.slice(0, 20);
|
||||
for (const key of preview) process.stderr.write(` - ${key}\n`);
|
||||
if (removed.length > preview.length) {
|
||||
process.stderr.write(` ... ${removed.length - preview.length} more\n`);
|
||||
}
|
||||
process.stderr.write(
|
||||
`\nIf this removal is intentional, rerun with `
|
||||
+ `\`--allow-removals=${removed.length}\` (or \`--allow-removals\` to disable the check).\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(OUTPUT), { recursive: true });
|
||||
fs.writeFileSync(OUTPUT, serializeManifest(entries));
|
||||
console.log(`✅ Manifest compiled: ${entries.length} entries → ${OUTPUT}`);
|
||||
|
||||
// Restore executable permissions on bin entries.
|
||||
// tsc does not preserve the +x bit, so after a clean rebuild the CLI
|
||||
|
||||
+260
-1
@@ -5,18 +5,21 @@ import * as path from 'node:path';
|
||||
import { BrowserCommandError } from './browser/daemon-client.js';
|
||||
import type { IPage } from './types.js';
|
||||
import { TargetError } from './browser/target-errors.js';
|
||||
import { PKG_VERSION } from './version.js';
|
||||
|
||||
const {
|
||||
mockBrowserConnect,
|
||||
mockBrowserClose,
|
||||
mockBindTab,
|
||||
mockSendCommand,
|
||||
mockExecFileSync,
|
||||
browserState,
|
||||
} = vi.hoisted(() => ({
|
||||
mockBrowserConnect: vi.fn(),
|
||||
mockBrowserClose: vi.fn(),
|
||||
mockBindTab: vi.fn(),
|
||||
mockSendCommand: vi.fn(),
|
||||
mockExecFileSync: vi.fn(),
|
||||
browserState: { page: null as IPage | null },
|
||||
}));
|
||||
|
||||
@@ -39,7 +42,15 @@ vi.mock('./browser/daemon-client.js', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { createProgram, findPackageRoot, normalizeVerifyRows, renderVerifyPreview, resolveBrowserVerifyInvocation } from './cli.js';
|
||||
vi.mock('node:child_process', async () => {
|
||||
const actual = await vi.importActual<typeof import('node:child_process')>('node:child_process');
|
||||
return {
|
||||
...actual,
|
||||
execFileSync: mockExecFileSync,
|
||||
};
|
||||
});
|
||||
|
||||
import { createProgram, findPackageRoot, normalizeVerifyRows, renderVerifyPreview, resolveBrowserVerifyInvocation, selectFreshByTimestamp } from './cli.js';
|
||||
|
||||
describe('resolveBrowserVerifyInvocation', () => {
|
||||
it('prefers the built entry declared in package metadata', () => {
|
||||
@@ -113,6 +124,173 @@ describe('resolveBrowserVerifyInvocation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectFreshByTimestamp', () => {
|
||||
it('uses timestamp watermarks so rolled buffers still emit new messages', () => {
|
||||
const first = selectFreshByTimestamp([
|
||||
{ timestamp: 1, text: 'a' },
|
||||
{ timestamp: 2, text: 'b' },
|
||||
], 0);
|
||||
expect(first.fresh.map((item) => item.text)).toEqual(['a', 'b']);
|
||||
expect(first.lastSeenTs).toBe(2);
|
||||
|
||||
const rolled = selectFreshByTimestamp([
|
||||
{ timestamp: 2, text: 'b' },
|
||||
{ timestamp: 3, text: 'c' },
|
||||
], first.lastSeenTs);
|
||||
expect(rolled.fresh.map((item) => item.text)).toEqual(['c']);
|
||||
expect(rolled.lastSeenTs).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('browser verify', () => {
|
||||
beforeEach(() => {
|
||||
process.exitCode = undefined;
|
||||
mockExecFileSync.mockReset().mockReturnValue('[]');
|
||||
});
|
||||
|
||||
it('passes --trace through to the adapter subprocess', async () => {
|
||||
const originalHome = process.env.HOME;
|
||||
const originalUserProfile = process.env.USERPROFILE;
|
||||
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-browser-verify-trace-'));
|
||||
process.env.HOME = fakeHome;
|
||||
process.env.USERPROFILE = fakeHome;
|
||||
|
||||
try {
|
||||
const adapterDir = path.join(fakeHome, '.opencli', 'clis', 'hn');
|
||||
fs.mkdirSync(adapterDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(adapterDir, 'top.js'), 'export default {};\n', 'utf-8');
|
||||
|
||||
const program = createProgram('', '');
|
||||
await program.parseAsync(['node', 'opencli', 'browser', 'verify', 'hn/top', '--no-fixture', '--trace', 'retain-on-failure']);
|
||||
|
||||
expect(mockExecFileSync).toHaveBeenCalledTimes(1);
|
||||
const [, execArgs] = mockExecFileSync.mock.calls[0] as [string, string[]];
|
||||
expect(execArgs.slice(-6)).toEqual(['hn', 'top', '--trace', 'retain-on-failure', '--format', 'json']);
|
||||
} finally {
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = originalUserProfile;
|
||||
fs.rmSync(fakeHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('uses --seed-args when no fixture args exist', async () => {
|
||||
const originalHome = process.env.HOME;
|
||||
const originalUserProfile = process.env.USERPROFILE;
|
||||
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-browser-verify-seed-'));
|
||||
process.env.HOME = fakeHome;
|
||||
process.env.USERPROFILE = fakeHome;
|
||||
|
||||
try {
|
||||
const adapterDir = path.join(fakeHome, '.opencli', 'clis', 'hn');
|
||||
fs.mkdirSync(adapterDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(adapterDir, 'top.js'), 'export default {};\n', 'utf-8');
|
||||
|
||||
const program = createProgram('', '');
|
||||
await program.parseAsync(['node', 'opencli', 'browser', 'verify', 'hn/top', '--no-fixture', '--seed-args', 'opencli-verify']);
|
||||
|
||||
expect(mockExecFileSync).toHaveBeenCalledTimes(1);
|
||||
const [, execArgs] = mockExecFileSync.mock.calls[0] as [string, string[]];
|
||||
expect(execArgs.slice(-5)).toEqual(['hn', 'top', 'opencli-verify', '--format', 'json']);
|
||||
} finally {
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = originalUserProfile;
|
||||
fs.rmSync(fakeHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('writes --seed-args into a starter fixture', async () => {
|
||||
const originalHome = process.env.HOME;
|
||||
const originalUserProfile = process.env.USERPROFILE;
|
||||
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-browser-verify-write-seed-'));
|
||||
process.env.HOME = fakeHome;
|
||||
process.env.USERPROFILE = fakeHome;
|
||||
mockExecFileSync.mockReturnValue(JSON.stringify([{ title: 'ok' }]));
|
||||
|
||||
try {
|
||||
const adapterDir = path.join(fakeHome, '.opencli', 'clis', 'hn');
|
||||
fs.mkdirSync(adapterDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(adapterDir, 'top.js'), 'export default {};\n', 'utf-8');
|
||||
|
||||
const program = createProgram('', '');
|
||||
await program.parseAsync(['node', 'opencli', 'browser', 'verify', 'hn/top', '--write-fixture', '--seed-args', 'opencli-verify']);
|
||||
|
||||
const fixtureFile = path.join(fakeHome, '.opencli', 'sites', 'hn', 'verify', 'top.json');
|
||||
const fixture = JSON.parse(fs.readFileSync(fixtureFile, 'utf-8'));
|
||||
expect(fixture.args).toEqual(['opencli-verify']);
|
||||
expect(fixture.expect.columns).toEqual(['title']);
|
||||
} finally {
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = originalUserProfile;
|
||||
fs.rmSync(fakeHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('profile list', () => {
|
||||
const stdoutSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
beforeEach(() => {
|
||||
process.exitCode = undefined;
|
||||
stdoutSpy.mockClear();
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
|
||||
it('reports stale daemon instead of no profiles when status lacks profile support', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
pid: 123,
|
||||
uptime: 1,
|
||||
daemonVersion: '1.7.6',
|
||||
extensionConnected: true,
|
||||
extensionVersion: '1.0.3',
|
||||
pending: 0,
|
||||
memoryMB: 20,
|
||||
port: 19825,
|
||||
}),
|
||||
} as Response);
|
||||
const program = createProgram('', '');
|
||||
|
||||
await program.parseAsync(['node', 'opencli', 'profile', 'list']);
|
||||
|
||||
const output = stdoutSpy.mock.calls.flat().join('\n');
|
||||
expect(output).toContain('stale');
|
||||
expect(output).toContain('opencli daemon restart');
|
||||
expect(output).not.toContain('No Browser Bridge profiles connected');
|
||||
});
|
||||
|
||||
it('keeps the empty profile message for current daemon status with no profiles', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
pid: 123,
|
||||
uptime: 1,
|
||||
daemonVersion: PKG_VERSION,
|
||||
extensionConnected: false,
|
||||
profiles: [],
|
||||
pending: 0,
|
||||
memoryMB: 20,
|
||||
port: 19825,
|
||||
}),
|
||||
} as Response);
|
||||
const program = createProgram('', '');
|
||||
|
||||
await program.parseAsync(['node', 'opencli', 'profile', 'list']);
|
||||
|
||||
const output = stdoutSpy.mock.calls.flat().join('\n');
|
||||
expect(output).toContain('No Browser Bridge profiles connected');
|
||||
expect(output).not.toContain('opencli daemon restart');
|
||||
});
|
||||
});
|
||||
|
||||
describe('browser tab targeting commands', () => {
|
||||
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
@@ -653,6 +831,7 @@ describe('browser network command', () => {
|
||||
responseStatus: 200,
|
||||
responseContentType: 'application/json',
|
||||
responsePreview: JSON.stringify({ data: { user: { rest_id: '42' } } }),
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
url: 'https://cdn.example.com/app.js',
|
||||
@@ -707,6 +886,44 @@ describe('browser network command', () => {
|
||||
expect(out.entries.map((e: any) => e.key)).toContain('GET cdn.example.com/app.js');
|
||||
});
|
||||
|
||||
it('--failed and --since filter captured entries by status and time window', async () => {
|
||||
const now = Date.now();
|
||||
browserState.page!.readNetworkCapture = vi.fn().mockResolvedValue([
|
||||
{
|
||||
url: 'https://api.example.com/new-fail',
|
||||
method: 'GET',
|
||||
responseStatus: 500,
|
||||
responseContentType: 'application/json',
|
||||
responsePreview: JSON.stringify({ error: true }),
|
||||
timestamp: now,
|
||||
},
|
||||
{
|
||||
url: 'https://api.example.com/old-fail',
|
||||
method: 'GET',
|
||||
responseStatus: 500,
|
||||
responseContentType: 'application/json',
|
||||
responsePreview: JSON.stringify({ error: true }),
|
||||
timestamp: now - 180_000,
|
||||
},
|
||||
{
|
||||
url: 'https://api.example.com/new-ok',
|
||||
method: 'GET',
|
||||
responseStatus: 200,
|
||||
responseContentType: 'application/json',
|
||||
responsePreview: JSON.stringify({ ok: true }),
|
||||
timestamp: now,
|
||||
},
|
||||
]);
|
||||
const program = createProgram('', '');
|
||||
|
||||
await program.parseAsync(['node', 'opencli', 'browser', 'network', '--since', '120s', '--failed']);
|
||||
|
||||
const out = lastJsonLog();
|
||||
expect(out.count).toBe(1);
|
||||
expect(out.entries[0].url).toBe('https://api.example.com/new-fail');
|
||||
expect(out.entries[0].timestamp).toMatch(/T/);
|
||||
});
|
||||
|
||||
it('default output keeps text/javascript API responses while dropping static JS files', async () => {
|
||||
browserState.page!.readNetworkCapture = vi.fn().mockResolvedValue([
|
||||
{
|
||||
@@ -743,6 +960,7 @@ describe('browser network command', () => {
|
||||
|
||||
const out = lastJsonLog();
|
||||
expect(out.entries[0].body).toEqual({ data: { user: { rest_id: '42' } } });
|
||||
expect(out.entries[0].timestamp).toMatch(/T/);
|
||||
});
|
||||
|
||||
it('--detail <key> returns the full body for the requested entry', async () => {
|
||||
@@ -756,6 +974,7 @@ describe('browser network command', () => {
|
||||
expect(out.key).toBe('UserTweets');
|
||||
expect(out.body).toEqual({ data: { user: { rest_id: '42' } } });
|
||||
expect(out.shape['$.data.user.rest_id']).toBe('string');
|
||||
expect(out.timestamp).toMatch(/T/);
|
||||
});
|
||||
|
||||
it('--detail reports key_not_found with the list of available keys', async () => {
|
||||
@@ -1092,6 +1311,46 @@ describe('browser network command', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('browser console command', () => {
|
||||
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
beforeEach(() => {
|
||||
process.exitCode = undefined;
|
||||
consoleLogSpy.mockClear();
|
||||
mockBrowserConnect.mockClear();
|
||||
mockBrowserClose.mockReset().mockResolvedValue(undefined);
|
||||
const now = Date.now();
|
||||
browserState.page = {
|
||||
setActivePage: vi.fn(),
|
||||
getActivePage: vi.fn().mockReturnValue('tab-1'),
|
||||
tabs: vi.fn().mockResolvedValue([{ page: 'tab-1', active: true }]),
|
||||
consoleMessages: vi.fn().mockResolvedValue([
|
||||
{ type: 'error', text: 'boom', timestamp: now },
|
||||
{ type: 'log', text: 'ok', timestamp: now },
|
||||
{ type: 'warning', text: 'old warning', timestamp: now - 180_000 },
|
||||
]),
|
||||
} as unknown as IPage;
|
||||
});
|
||||
|
||||
function lastJsonLog(): any {
|
||||
const calls = consoleLogSpy.mock.calls;
|
||||
if (calls.length === 0) throw new Error('Expected at least one console.log call');
|
||||
const last = calls[calls.length - 1][0];
|
||||
if (typeof last !== 'string') throw new Error(`Expected string arg to console.log, got ${typeof last}`);
|
||||
return JSON.parse(last);
|
||||
}
|
||||
|
||||
it('filters console messages by level and time window', async () => {
|
||||
const program = createProgram('', '');
|
||||
|
||||
await program.parseAsync(['node', 'opencli', 'browser', 'console', '--level', 'error', '--since', '120s']);
|
||||
|
||||
const out = lastJsonLog();
|
||||
expect(out.count).toBe(1);
|
||||
expect(out.messages[0]).toMatchObject({ type: 'error', text: 'boom' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('browser get html command', () => {
|
||||
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
|
||||
+199
-15
@@ -30,15 +30,17 @@ import { parseFilter, shapeMatchesFilter } from './browser/shape-filter.js';
|
||||
import { buildHtmlTreeJs, type HtmlTreeResult } from './browser/html-tree.js';
|
||||
import { buildExtractHtmlJs, runExtractFromHtml } from './browser/extract.js';
|
||||
import { analyzeSite, type PageSignals } from './browser/analyze.js';
|
||||
import { daemonStatus, daemonStop } from './commands/daemon.js';
|
||||
import { daemonRestart, daemonStatus, daemonStop } from './commands/daemon.js';
|
||||
import { log } from './logger.js';
|
||||
import { bindTab, BrowserCommandError, fetchDaemonStatus, sendCommand } from './browser/daemon-client.js';
|
||||
import { aliasForContextId, loadProfileConfig, renameProfile, resolveProfileContextId, setDefaultProfile } from './browser/profile.js';
|
||||
import { formatDaemonVersion, isDaemonStale } from './browser/daemon-version.js';
|
||||
|
||||
const CLI_FILE = fileURLToPath(import.meta.url);
|
||||
const DEFAULT_BROWSER_WORKSPACE = 'browser:default';
|
||||
const DEFAULT_BOUND_WORKSPACE = 'bound:default';
|
||||
const BROWSER_TAB_OPTION_DESCRIPTION = 'Target tab/page identity returned by "browser open", "browser tab new", or "browser tab list"';
|
||||
const FOLLOW_POLL_MS = 1_000;
|
||||
|
||||
type BrowserNetworkItem = {
|
||||
url: string;
|
||||
@@ -51,8 +53,52 @@ type BrowserNetworkItem = {
|
||||
bodyFullSize?: number;
|
||||
/** True when the capture layer had to cap the stored body to protect memory. */
|
||||
bodyTruncated?: boolean;
|
||||
/** Epoch milliseconds when the request was observed. */
|
||||
timestamp?: number;
|
||||
};
|
||||
|
||||
function parseDurationMs(raw: unknown, flagName: string): number | null | { error: string } {
|
||||
if (raw === undefined || raw === null || raw === '') return null;
|
||||
const str = String(raw).trim();
|
||||
const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(str);
|
||||
if (!match) return { error: `--${flagName} must be a duration like 500ms, 30s, 2m, got "${str}"` };
|
||||
const value = Number.parseFloat(match[1]);
|
||||
const unit = match[2] ?? 'ms';
|
||||
const multiplier = unit === 'h' ? 3_600_000 : unit === 'm' ? 60_000 : unit === 's' ? 1_000 : 1;
|
||||
return Math.round(value * multiplier);
|
||||
}
|
||||
|
||||
function timestampFromRaw(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : Date.now();
|
||||
}
|
||||
|
||||
function toIsoTimestamp(timestamp: unknown): string | undefined {
|
||||
if (typeof timestamp !== 'number' || !Number.isFinite(timestamp) || timestamp <= 0) return undefined;
|
||||
return new Date(timestamp).toISOString();
|
||||
}
|
||||
|
||||
function filterByTimeWindow<T extends { timestamp?: number }>(items: T[], opts: { sinceMs?: number | null; untilMs?: number | null }, now: number = Date.now()): T[] {
|
||||
const sinceTs = opts.sinceMs != null ? now - opts.sinceMs : undefined;
|
||||
const untilTs = opts.untilMs != null ? now - opts.untilMs : undefined;
|
||||
return items.filter((item) => {
|
||||
const ts = item.timestamp ?? now;
|
||||
if (sinceTs !== undefined && ts < sinceTs) return false;
|
||||
if (untilTs !== undefined && ts > untilTs) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function selectFreshByTimestamp<T extends { timestamp?: unknown }>(
|
||||
items: T[],
|
||||
lastSeenTs: number,
|
||||
): { fresh: T[]; lastSeenTs: number } {
|
||||
const fresh = items.filter((item) => Number(item.timestamp ?? 0) > lastSeenTs);
|
||||
const nextSeenTs = fresh.length > 0
|
||||
? Math.max(lastSeenTs, ...fresh.map((item) => Number(item.timestamp ?? 0)).filter(Number.isFinite))
|
||||
: lastSeenTs;
|
||||
return { fresh, lastSeenTs: nextSeenTs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize raw capture entries (from daemon/CDP `readNetworkCapture` or
|
||||
* the JS interceptor's `window.__opencli_net`) into a consistent shape.
|
||||
@@ -83,13 +129,15 @@ async function captureNetworkItems(page: import('./types.js').IPage): Promise<Br
|
||||
body,
|
||||
bodyFullSize: fullSize,
|
||||
bodyTruncated: truncated,
|
||||
timestamp: timestampFromRaw(e.timestamp),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
const raw = await page.evaluate(`(function(){ var out = window.__opencli_net || []; window.__opencli_net = []; return JSON.stringify(out); })()`) as string;
|
||||
try {
|
||||
return JSON.parse(raw) as BrowserNetworkItem[];
|
||||
const parsed = JSON.parse(raw) as BrowserNetworkItem[];
|
||||
return parsed.map((item) => ({ ...item, timestamp: timestampFromRaw(item.timestamp) }));
|
||||
} catch {
|
||||
if (process.env.OPENCLI_VERBOSE) log.warn(`[network] Failed to parse interceptor buffer: ${typeof raw === 'string' ? raw.slice(0, 200) : String(raw)}`);
|
||||
return [];
|
||||
@@ -798,7 +846,7 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command
|
||||
* silently dropping the body. Per-entry cap is 1 MiB and the ring is
|
||||
* capped at 200 entries, bounding worst-case in-page memory.
|
||||
*/
|
||||
const NETWORK_INTERCEPTOR_JS = `(function(){if(window.__opencli_net)return;window.__opencli_net=[];var M=200,B=1048576,F=window.fetch;function capture(url,method,status,text,ct){if(window.__opencli_net.length>=M)return;var full=text?text.length:0,trunc=full>B,stored=trunc?text.slice(0,B):text,body=null;if(stored){if(trunc){body=stored}else{try{body=JSON.parse(stored)}catch(e){body=stored}}}var e={url:url,method:method||'GET',status:status,size:full,ct:ct,body:body};if(trunc){e.bodyTruncated=true;e.bodyFullSize=full}window.__opencli_net.push(e)}window.fetch=async function(){var r=await F.apply(this,arguments);try{var ct=r.headers.get('content-type')||'';if(ct.includes('json')||ct.includes('text')){var c=r.clone(),t=await c.text();capture(r.url||(arguments[0]&&arguments[0].url)||String(arguments[0]),(arguments[1]&&arguments[1].method)||'GET',r.status,t,ct)}}catch(e){}return r};var X=XMLHttpRequest.prototype,O=X.open,S=X.send;X.open=function(m,u){this._om=m;this._ou=u;return O.apply(this,arguments)};X.send=function(){var x=this;x.addEventListener('load',function(){try{var ct=x.getResponseHeader('content-type')||'';if(ct.includes('json')||ct.includes('text')){capture(x._ou,x._om||'GET',x.status,x.responseText||'',ct)}}catch(e){}});return S.apply(this,arguments)}})()`;
|
||||
const NETWORK_INTERCEPTOR_JS = `(function(){if(window.__opencli_net)return;window.__opencli_net=[];var M=200,B=1048576,F=window.fetch;function capture(url,method,status,text,ct){if(window.__opencli_net.length>=M)return;var full=text?text.length:0,trunc=full>B,stored=trunc?text.slice(0,B):text,body=null;if(stored){if(trunc){body=stored}else{try{body=JSON.parse(stored)}catch(e){body=stored}}}var e={url:url,method:method||'GET',status:status,size:full,ct:ct,body:body,timestamp:Date.now()};if(trunc){e.bodyTruncated=true;e.bodyFullSize=full}window.__opencli_net.push(e)}window.fetch=async function(){var r=await F.apply(this,arguments);try{var ct=r.headers.get('content-type')||'';if(ct.includes('json')||ct.includes('text')){var c=r.clone(),t=await c.text();capture(r.url||(arguments[0]&&arguments[0].url)||String(arguments[0]),(arguments[1]&&arguments[1].method)||'GET',r.status,t,ct)}}catch(e){}return r};var X=XMLHttpRequest.prototype,O=X.open,S=X.send;X.open=function(m,u){this._om=m;this._ou=u;return O.apply(this,arguments)};X.send=function(){var x=this;x.addEventListener('load',function(){try{var ct=x.getResponseHeader('content-type')||'';if(ct.includes('json')||ct.includes('text')){capture(x._ou,x._om||'GET',x.status,x.responseText||'',ct)}}catch(e){}});return S.apply(this,arguments)}})()`;
|
||||
|
||||
addBrowserTabOption(browser.command('open').argument('<url>').option('--allow-navigate-bound', 'Allow navigating a bound user tab', false).description('Open URL in automation window'))
|
||||
.action(browserAction(async (page, url, opts) => {
|
||||
@@ -877,6 +925,72 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command
|
||||
}
|
||||
}));
|
||||
|
||||
addBrowserTabOption(browser.command('console'))
|
||||
.option('--level <level>', 'Console level: all, error, warning, log, info, debug', 'all')
|
||||
.option('--since <duration>', 'Only include messages from the last duration (for example: 30s, 2m)')
|
||||
.option('--until <duration>', 'Only include messages older than the duration from now')
|
||||
.option('--follow', 'Continuously print new console messages as JSON lines', false)
|
||||
.description('Read recent browser console messages')
|
||||
.action(browserAction(async (page, opts) => {
|
||||
const sinceMs = parseDurationMs(opts.since, 'since');
|
||||
const untilMs = parseDurationMs(opts.until, 'until');
|
||||
if (sinceMs && typeof sinceMs === 'object') {
|
||||
console.log(JSON.stringify({ error: { code: 'invalid_since', message: sinceMs.error } }, null, 2));
|
||||
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
||||
return;
|
||||
}
|
||||
if (untilMs && typeof untilMs === 'object') {
|
||||
console.log(JSON.stringify({ error: { code: 'invalid_until', message: untilMs.error } }, null, 2));
|
||||
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
||||
return;
|
||||
}
|
||||
const normalize = (messages: unknown[]): Array<Record<string, unknown>> => messages.map((message) => {
|
||||
if (message && typeof message === 'object') {
|
||||
const record = message as Record<string, unknown>;
|
||||
return {
|
||||
...record,
|
||||
timestamp: timestampFromRaw(record.timestamp),
|
||||
};
|
||||
}
|
||||
return { type: 'log', text: String(message), timestamp: Date.now() };
|
||||
});
|
||||
const filter = (messages: Array<Record<string, unknown>>) =>
|
||||
filterByTimeWindow(messages, { sinceMs, untilMs }).filter((message) => {
|
||||
if (opts.level === 'all') return true;
|
||||
const type = String(message.type ?? message.level ?? '').toLowerCase();
|
||||
return opts.level === 'error'
|
||||
? type === 'error' || type === 'warning'
|
||||
: type === String(opts.level).toLowerCase();
|
||||
});
|
||||
|
||||
if (opts.follow) {
|
||||
let lastSeenTs = 0;
|
||||
while (true) {
|
||||
const messages = filter(normalize(await page.consoleMessages('all')));
|
||||
const next = selectFreshByTimestamp(messages, lastSeenTs);
|
||||
for (const message of next.fresh) {
|
||||
console.log(JSON.stringify({
|
||||
...message,
|
||||
timestamp: toIsoTimestamp(message.timestamp),
|
||||
}));
|
||||
}
|
||||
lastSeenTs = next.lastSeenTs;
|
||||
await new Promise((resolve) => setTimeout(resolve, FOLLOW_POLL_MS));
|
||||
}
|
||||
}
|
||||
|
||||
const messages = filter(normalize(await page.consoleMessages(opts.level)));
|
||||
console.log(JSON.stringify({
|
||||
workspace: getPageWorkspace(page),
|
||||
captured_at: new Date().toISOString(),
|
||||
count: messages.length,
|
||||
messages: messages.map((message) => ({
|
||||
...message,
|
||||
timestamp: toIsoTimestamp(message.timestamp),
|
||||
})),
|
||||
}, null, 2));
|
||||
}));
|
||||
|
||||
// ── Analyze (site recon, agent-native) ──
|
||||
//
|
||||
// Mechanizes the `site-recon.md` decision tree into one CLI call. The agent
|
||||
@@ -1487,6 +1601,10 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command
|
||||
.option('--all', 'Include static resources (js/css/images/telemetry)')
|
||||
.option('--raw', 'Emit full bodies for every entry (skip shape preview)')
|
||||
.option('--filter <fields>', 'Comma-separated field names; keep only entries whose body shape has ALL names as path segments')
|
||||
.option('--since <duration>', 'Only include entries from the last duration (for example: 30s, 2m)')
|
||||
.option('--until <duration>', 'Only include entries older than the duration from now')
|
||||
.option('--follow', 'Continuously print new matching entries as JSON lines', false)
|
||||
.option('--failed', 'Only include failed HTTP requests (status 0 or >= 400)', false)
|
||||
.option('--max-body <chars>', 'With --detail: cap the emitted body at N chars (0 = unlimited, default)', '0')
|
||||
.option('--ttl <ms>', 'Cache TTL in ms for --detail lookups', String(DEFAULT_TTL_MS))
|
||||
.description('Capture network requests as shape previews; retrieve full bodies by key')
|
||||
@@ -1495,6 +1613,16 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command
|
||||
const workspace = getPageWorkspace(page);
|
||||
const hasDetail = typeof opts.detail === 'string' && opts.detail.length > 0;
|
||||
const hasFilter = typeof opts.filter === 'string';
|
||||
const sinceMs = parseDurationMs(opts.since, 'since');
|
||||
const untilMs = parseDurationMs(opts.until, 'until');
|
||||
if (sinceMs && typeof sinceMs === 'object') {
|
||||
emitNetworkError('invalid_since', sinceMs.error);
|
||||
return;
|
||||
}
|
||||
if (untilMs && typeof untilMs === 'object') {
|
||||
emitNetworkError('invalid_until', untilMs.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// --detail and --filter do different things (one request by key vs. narrow
|
||||
// the list by shape), don't compose, and combining them has no sensible
|
||||
@@ -1515,6 +1643,11 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command
|
||||
filterFields = parsed.fields;
|
||||
}
|
||||
|
||||
if (hasDetail && opts.follow) {
|
||||
emitNetworkError('invalid_args', '--follow cannot be used with --detail.');
|
||||
return;
|
||||
}
|
||||
|
||||
// --detail short-circuits: read from cache only, no live capture needed.
|
||||
if (hasDetail) {
|
||||
const res = loadNetworkCache(workspace, { ttlMs });
|
||||
@@ -1563,6 +1696,7 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command
|
||||
status: entry.status,
|
||||
ct: entry.ct,
|
||||
size: entry.size,
|
||||
...(typeof entry.timestamp === 'number' ? { timestamp: toIsoTimestamp(entry.timestamp) } : {}),
|
||||
shape: inferShape(entry.body),
|
||||
body: outputBody,
|
||||
};
|
||||
@@ -1577,6 +1711,35 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.follow) {
|
||||
if (!await page.startNetworkCapture?.()) {
|
||||
try { await page.evaluate(NETWORK_INTERCEPTOR_JS); } catch { /* non-fatal */ }
|
||||
}
|
||||
while (true) {
|
||||
const rawItems = await captureNetworkItems(page).catch((err) => {
|
||||
emitNetworkError('capture_failed', `Could not read network capture: ${(err as Error).message}`);
|
||||
return [];
|
||||
});
|
||||
let items = opts.all ? rawItems : filterNetworkItems(rawItems);
|
||||
items = filterByTimeWindow(items, { sinceMs, untilMs });
|
||||
if (opts.failed) items = items.filter((item) => item.status === 0 || item.status >= 400);
|
||||
const keyed = assignKeys(items);
|
||||
for (const item of keyed) {
|
||||
console.log(JSON.stringify({
|
||||
key: item.key,
|
||||
timestamp: toIsoTimestamp(item.timestamp),
|
||||
method: item.method,
|
||||
status: item.status,
|
||||
url: item.url,
|
||||
ct: item.ct,
|
||||
size: item.size,
|
||||
...(item.bodyTruncated ? { body_truncated: true } : {}),
|
||||
}));
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, FOLLOW_POLL_MS));
|
||||
}
|
||||
}
|
||||
|
||||
// Fresh capture path.
|
||||
let rawItems: BrowserNetworkItem[];
|
||||
try {
|
||||
@@ -1586,7 +1749,9 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command
|
||||
return;
|
||||
}
|
||||
|
||||
const items = opts.all ? rawItems : filterNetworkItems(rawItems);
|
||||
let items = opts.all ? rawItems : filterNetworkItems(rawItems);
|
||||
items = filterByTimeWindow(items, { sinceMs, untilMs });
|
||||
if (opts.failed) items = items.filter((item) => item.status === 0 || item.status >= 400);
|
||||
const filteredOut = rawItems.length - items.length;
|
||||
|
||||
const keyed = assignKeys(items);
|
||||
@@ -1598,6 +1763,7 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command
|
||||
size: it.size,
|
||||
ct: it.ct,
|
||||
body: it.body,
|
||||
...(typeof it.timestamp === 'number' ? { timestamp: it.timestamp } : {}),
|
||||
...(it.bodyTruncated ? { body_truncated: true } : {}),
|
||||
...(it.bodyTruncated && typeof it.bodyFullSize === 'number'
|
||||
? { body_full_size: it.bodyFullSize }
|
||||
@@ -1642,11 +1808,15 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command
|
||||
}
|
||||
|
||||
if (opts.raw) {
|
||||
envelope.entries = visible.map((s) => s.entry);
|
||||
envelope.entries = visible.map((s) => ({
|
||||
...s.entry,
|
||||
...(typeof s.entry.timestamp === 'number' ? { timestamp: toIsoTimestamp(s.entry.timestamp) } : {}),
|
||||
}));
|
||||
} else {
|
||||
envelope.entries = visible.map((s) => ({
|
||||
key: s.entry.key,
|
||||
method: s.entry.method,
|
||||
...(typeof s.entry.timestamp === 'number' ? { timestamp: toIsoTimestamp(s.entry.timestamp) } : {}),
|
||||
status: s.entry.status,
|
||||
url: s.entry.url,
|
||||
ct: s.entry.ct,
|
||||
@@ -1722,6 +1892,7 @@ cli({
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(filePath, template, 'utf-8');
|
||||
console.log(`Created: ${filePath}`);
|
||||
console.log('First time on this site? Run: opencli browser analyze <url>');
|
||||
console.log(`Edit the file to implement your adapter, then run: opencli browser verify ${name}`);
|
||||
} catch (err) {
|
||||
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
@@ -1737,8 +1908,10 @@ cli({
|
||||
.option('--update-fixture', 'Overwrite an existing fixture with one derived from current output')
|
||||
.option('--no-fixture', 'Ignore any fixture file for this run (no value-level validation)')
|
||||
.option('--strict-memory', 'Fail (not just warn) when ~/.opencli/sites/<site>/endpoints.json or notes.md is missing')
|
||||
.option('--seed-args <value>', 'Seed args when no fixture exists; use JSON array/object for multiple args or flags')
|
||||
.option('--trace <mode>', 'Trace capture for the adapter subprocess: off, on, retain-on-failure', 'off')
|
||||
.description('Execute an adapter and validate output; uses fixture at ~/.opencli/sites/<site>/verify/<cmd>.json when present')
|
||||
.action(async (name: string, opts: { fixture?: boolean; writeFixture?: boolean; updateFixture?: boolean; strictMemory?: boolean } = {}) => {
|
||||
.action(async (name: string, opts: { fixture?: boolean; writeFixture?: boolean; updateFixture?: boolean; strictMemory?: boolean; seedArgs?: string; trace?: string } = {}) => {
|
||||
try {
|
||||
const parts = name.split('/');
|
||||
if (parts.length !== 2) { console.error('Name must be site/command format'); process.exitCode = EXIT_CODES.USAGE_ERROR; return; }
|
||||
@@ -1750,7 +1923,7 @@ cli({
|
||||
}
|
||||
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
const { loadFixture, writeFixture, deriveFixture, validateRows, fixturePath, expandFixtureArgs } = await import('./browser/verify-fixture.js');
|
||||
const { loadFixture, writeFixture, deriveFixture, validateRows, fixturePath, expandFixtureArgs, parseSeedArgs } = await import('./browser/verify-fixture.js');
|
||||
const filePath = path.join(os.homedir(), '.opencli', 'clis', site, `${command}.js`);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error(`Adapter not found: ${filePath}`);
|
||||
@@ -1770,15 +1943,17 @@ cli({
|
||||
// - array form ["123", "--limit", "3"] → verbatim (for positional subjects)
|
||||
const adapterSrc = fs.readFileSync(filePath, 'utf-8');
|
||||
const hasLimitArg = /['"]limit['"]/.test(adapterSrc);
|
||||
const fixtureArgs = fixture?.args;
|
||||
const cliArgs: string[] = expandFixtureArgs(fixtureArgs);
|
||||
if (cliArgs.length === 0 && hasLimitArg) cliArgs.push('--limit', '3');
|
||||
const seedArgs = parseSeedArgs(opts.seedArgs);
|
||||
const explicitArgs = fixture?.args ?? seedArgs;
|
||||
const cliArgs: string[] = expandFixtureArgs(explicitArgs);
|
||||
if (explicitArgs === undefined && cliArgs.length === 0 && hasLimitArg) cliArgs.push('--limit', '3');
|
||||
|
||||
const argDisplay = cliArgs.join(' ');
|
||||
const traceArgs = opts.trace && opts.trace !== 'off' ? ['--trace', opts.trace] : [];
|
||||
const argDisplay = [...cliArgs, ...traceArgs].join(' ');
|
||||
const invocation = resolveBrowserVerifyInvocation();
|
||||
|
||||
// Always request JSON so we can validate structurally.
|
||||
const execArgs = [...invocation.args, site, command, ...cliArgs, '--format', 'json'];
|
||||
const execArgs = [...invocation.args, site, command, ...cliArgs, ...traceArgs, '--format', 'json'];
|
||||
|
||||
let rawJson: string;
|
||||
try {
|
||||
@@ -1821,10 +1996,10 @@ cli({
|
||||
console.log(`\n Fixture already exists at ${fixturePath(site, command)}.`);
|
||||
console.log(` Use --update-fixture to overwrite.`);
|
||||
} else {
|
||||
const seedArgs = fixtureArgs !== undefined
|
||||
? fixtureArgs
|
||||
const fixtureArgs = explicitArgs !== undefined
|
||||
? explicitArgs
|
||||
: (hasLimitArg ? { limit: 3 } : undefined);
|
||||
const derived = deriveFixture(rows, seedArgs);
|
||||
const derived = deriveFixture(rows, fixtureArgs);
|
||||
const p = writeFixture(site, command, derived);
|
||||
console.log(`\n ${fixture ? '↻ Updated' : '✎ Wrote'} fixture: ${p}`);
|
||||
console.log(` Review and hand-tune the derived expectations (add patterns / notEmpty, tighten rowCount).`);
|
||||
@@ -2223,6 +2398,11 @@ cli({
|
||||
console.log(styleText('yellow', 'Daemon is not running. Run opencli doctor after opening Chrome.'));
|
||||
return;
|
||||
}
|
||||
if (isDaemonStale(status, PKG_VERSION) || !Array.isArray(status.profiles)) {
|
||||
console.log(styleText('yellow', `Daemon ${formatDaemonVersion(status)} is stale for CLI v${PKG_VERSION}.`));
|
||||
console.log(styleText('dim', 'Run: opencli daemon restart'));
|
||||
return;
|
||||
}
|
||||
if (profiles.length === 0) {
|
||||
console.log(styleText('yellow', 'No Browser Bridge profiles connected.'));
|
||||
console.log(styleText('dim', 'Open a Chrome profile with the OpenCLI extension installed, then run opencli profile list again.'));
|
||||
@@ -2295,6 +2475,10 @@ cli({
|
||||
.command('stop')
|
||||
.description('Stop the daemon')
|
||||
.action(async () => { await daemonStop(); });
|
||||
daemonCmd
|
||||
.command('restart')
|
||||
.description('Restart the daemon')
|
||||
.action(async () => { await daemonRestart(); });
|
||||
|
||||
// ── External CLIs ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Command } from 'commander';
|
||||
import type { CliCommand } from './registry.js';
|
||||
import { EmptyResultError, SelectorError } from './errors.js';
|
||||
import { attachTraceReceipt, EmptyResultError, selectorError } from './errors.js';
|
||||
|
||||
const { mockExecuteCommand, mockRenderOutput } = vi.hoisted(() => ({
|
||||
mockExecuteCommand: vi.fn(),
|
||||
@@ -84,6 +84,21 @@ describe('commanderAdapter arg passing', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('passes explicit trace mode to executeCommand', async () => {
|
||||
const program = new Command();
|
||||
const siteCmd = program.command('paperreview');
|
||||
registerCommandToProgram(siteCmd, cmd);
|
||||
|
||||
await program.parseAsync(['node', 'opencli', 'paperreview', 'submit', './paper.pdf', '--trace', 'retain-on-failure']);
|
||||
|
||||
expect(mockExecuteCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ site: 'paperreview', name: 'submit' }),
|
||||
expect.objectContaining({ pdf: './paper.pdf' }),
|
||||
false,
|
||||
{ prepared: true, trace: 'retain-on-failure' },
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid bool values before calling executeCommand', async () => {
|
||||
const program = new Command();
|
||||
const siteCmd = program.command('paperreview');
|
||||
@@ -345,6 +360,9 @@ describe('commanderAdapter error envelope output', () => {
|
||||
expect(output).toContain('ok: false');
|
||||
expect(output).toContain('code: EMPTY_RESULT');
|
||||
expect(output).toContain('xsec_token');
|
||||
expect(output).toContain('--trace=retain-on-failure');
|
||||
expect(output).toContain('opencli xiaohongshu note --trace retain-on-failure');
|
||||
expect(output).not.toContain('OPENCLI_DIAGNOSTIC');
|
||||
|
||||
stderrSpy.mockRestore();
|
||||
});
|
||||
@@ -356,7 +374,7 @@ describe('commanderAdapter error envelope output', () => {
|
||||
|
||||
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
mockExecuteCommand.mockRejectedValueOnce(
|
||||
new SelectorError('.note-title', 'The note title selector no longer matches the current page.'),
|
||||
selectorError('.note-title', 'The note title selector no longer matches the current page.'),
|
||||
);
|
||||
|
||||
await program.parseAsync(['node', 'opencli', 'xiaohongshu', 'note', '69ca3927000000001a020fd5']);
|
||||
@@ -365,6 +383,71 @@ describe('commanderAdapter error envelope output', () => {
|
||||
expect(output).toContain('ok: false');
|
||||
expect(output).toContain('code: SELECTOR');
|
||||
expect(output).toContain('selector no longer matches');
|
||||
expect(output).toContain('--trace=retain-on-failure');
|
||||
|
||||
stderrSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not add an AutoFix rerun hint when trace is already enabled', async () => {
|
||||
const program = new Command();
|
||||
const siteCmd = program.command('xiaohongshu');
|
||||
registerCommandToProgram(siteCmd, cmd);
|
||||
|
||||
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
mockExecuteCommand.mockRejectedValueOnce(selectorError('.note-title'));
|
||||
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'opencli',
|
||||
'xiaohongshu',
|
||||
'note',
|
||||
'69ca3927000000001a020fd5',
|
||||
'--trace',
|
||||
'retain-on-failure',
|
||||
]);
|
||||
|
||||
const output = stderrSpy.mock.calls.map(c => String(c[0])).join('');
|
||||
expect(output).toContain('code: SELECTOR');
|
||||
expect(output).not.toContain('AutoFix: re-run');
|
||||
|
||||
stderrSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('includes trace metadata from the error envelope when execution attached it', async () => {
|
||||
const program = new Command();
|
||||
const siteCmd = program.command('xiaohongshu');
|
||||
registerCommandToProgram(siteCmd, cmd);
|
||||
|
||||
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
const err = selectorError('.note-title');
|
||||
attachTraceReceipt(err, {
|
||||
schemaVersion: 1,
|
||||
opencliVersion: '1.7.8',
|
||||
traceId: 'trace-1',
|
||||
traceDir: '/tmp/opencli/profiles/default/traces/trace-1',
|
||||
summaryPath: '/tmp/opencli/profiles/default/traces/trace-1/summary.md',
|
||||
receiptPath: '/tmp/opencli/profiles/default/traces/trace-1/receipt.json',
|
||||
status: 'failure',
|
||||
createdAt: '2026-05-03T00:00:00.000Z',
|
||||
error: { code: 'SELECTOR', message: 'Could not find element: .note-title' },
|
||||
});
|
||||
mockExecuteCommand.mockRejectedValueOnce(err);
|
||||
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'opencli',
|
||||
'xiaohongshu',
|
||||
'note',
|
||||
'69ca3927000000001a020fd5',
|
||||
'--trace',
|
||||
'retain-on-failure',
|
||||
]);
|
||||
|
||||
const output = stderrSpy.mock.calls.map(c => String(c[0])).join('');
|
||||
expect(output).toContain('trace:');
|
||||
expect(output).toContain('dir: /tmp/opencli/profiles/default/traces/trace-1');
|
||||
expect(output).toContain('summaryPath: /tmp/opencli/profiles/default/traces/trace-1/summary.md');
|
||||
expect(output).toContain('receiptPath: /tmp/opencli/profiles/default/traces/trace-1/receipt.json');
|
||||
|
||||
stderrSpy.mockRestore();
|
||||
});
|
||||
|
||||
+12
-8
@@ -22,7 +22,6 @@ import {
|
||||
EXIT_CODES,
|
||||
toEnvelope,
|
||||
} from './errors.js';
|
||||
import { isDiagnosticEnabled } from './diagnostic.js';
|
||||
|
||||
/**
|
||||
* Register a single CliCommand as a Commander subcommand.
|
||||
@@ -51,6 +50,7 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
|
||||
}
|
||||
subCmd
|
||||
.option('-f, --format <fmt>', 'Output format: table, plain, json, yaml, md, csv', 'table')
|
||||
.option('--trace <mode>', 'Trace capture: off, on, retain-on-failure', 'off')
|
||||
.option('-v, --verbose', 'Debug output', false);
|
||||
|
||||
subCmd.addHelpText('after', formatRegistryHelpText(cmd));
|
||||
@@ -100,6 +100,7 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
|
||||
const result = await executeCommand(cmd, kwargs, verbose, {
|
||||
prepared: true,
|
||||
...(typeof globals.profile === 'string' && globals.profile.trim() ? { profile: globals.profile.trim() } : {}),
|
||||
...(typeof optionsRecord.trace === 'string' && optionsRecord.trace !== 'off' ? { trace: optionsRecord.trace } : {}),
|
||||
});
|
||||
if (result === null || result === undefined) {
|
||||
return;
|
||||
@@ -123,7 +124,7 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
|
||||
footerExtra: resolved.footerExtra?.(kwargs),
|
||||
});
|
||||
} catch (err) {
|
||||
renderError(err, fullName(cmd), optionsRecord.verbose === true);
|
||||
renderError(err, fullName(cmd), optionsRecord.verbose === true, optionsRecord.trace);
|
||||
process.exitCode = resolveExitCode(err);
|
||||
}
|
||||
});
|
||||
@@ -138,13 +139,16 @@ function resolveExitCode(err: unknown): number {
|
||||
|
||||
// ── Error rendering ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Emit AutoFix hint for repairable adapter errors (skipped if already in diagnostic mode). */
|
||||
function emitAutoFixHint(envelope: string, cmdName: string): string {
|
||||
if (isDiagnosticEnabled()) return envelope;
|
||||
return envelope + `# AutoFix: re-run with OPENCLI_DIAGNOSTIC=1 for repair context\n# OPENCLI_DIAGNOSTIC=1 ${cmdName}\n`;
|
||||
/** Emit AutoFix hint for repairable adapter errors (skipped if trace already exported). */
|
||||
function emitAutoFixHint(envelope: string, cmdName: string, traceMode: unknown): string {
|
||||
if (traceMode === 'on' || traceMode === 'retain-on-failure') return envelope;
|
||||
const runnable = cmdName.replace('/', ' ');
|
||||
return envelope
|
||||
+ `# AutoFix: re-run with --trace=retain-on-failure for trace artifact\n`
|
||||
+ `# opencli ${runnable} --trace retain-on-failure\n`;
|
||||
}
|
||||
|
||||
function renderError(err: unknown, cmdName: string, verbose: boolean): void {
|
||||
function renderError(err: unknown, cmdName: string, verbose: boolean, traceMode?: unknown): void {
|
||||
const envelope = toEnvelope(err);
|
||||
|
||||
// In verbose mode, include stack trace for debugging
|
||||
@@ -157,7 +161,7 @@ function renderError(err: unknown, cmdName: string, verbose: boolean): void {
|
||||
// Append AutoFix hint for repairable errors
|
||||
const code = envelope.error.code;
|
||||
if (code === 'SELECTOR' || code === 'EMPTY_RESULT' || code === 'ADAPTER_LOAD' || code === 'UNKNOWN') {
|
||||
output = emitAutoFixHint(output, cmdName);
|
||||
output = emitAutoFixHint(output, cmdName, traceMode);
|
||||
}
|
||||
|
||||
process.stderr.write(output);
|
||||
|
||||
+116
-1
@@ -3,9 +3,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
const {
|
||||
fetchDaemonStatusMock,
|
||||
requestDaemonShutdownMock,
|
||||
restartDaemonMock,
|
||||
} = vi.hoisted(() => ({
|
||||
fetchDaemonStatusMock: vi.fn(),
|
||||
requestDaemonShutdownMock: vi.fn(),
|
||||
restartDaemonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../browser/daemon-client.js', () => ({
|
||||
@@ -13,7 +15,12 @@ vi.mock('../browser/daemon-client.js', () => ({
|
||||
requestDaemonShutdown: requestDaemonShutdownMock,
|
||||
}));
|
||||
|
||||
import { daemonStatus, daemonStop } from './daemon.js';
|
||||
vi.mock('../browser/daemon-lifecycle.js', () => ({
|
||||
restartDaemon: restartDaemonMock,
|
||||
}));
|
||||
|
||||
import { daemonRestart, daemonStatus, daemonStop } from './daemon.js';
|
||||
import { PKG_VERSION } from '../version.js';
|
||||
|
||||
describe('daemonStatus', () => {
|
||||
let stdoutSpy: ReturnType<typeof vi.spyOn>;
|
||||
@@ -41,6 +48,7 @@ describe('daemonStatus', () => {
|
||||
ok: true,
|
||||
pid: 12345,
|
||||
uptime: 3661,
|
||||
daemonVersion: PKG_VERSION,
|
||||
extensionConnected: true,
|
||||
extensionVersion: '1.6.8',
|
||||
pending: 0,
|
||||
@@ -52,6 +60,7 @@ describe('daemonStatus', () => {
|
||||
|
||||
expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('running'));
|
||||
expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('PID 12345'));
|
||||
expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining(`v${PKG_VERSION}`));
|
||||
expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('1h 1m'));
|
||||
expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('connected'));
|
||||
expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('v1.6.8'));
|
||||
@@ -64,6 +73,7 @@ describe('daemonStatus', () => {
|
||||
ok: true,
|
||||
pid: 99,
|
||||
uptime: 120,
|
||||
daemonVersion: PKG_VERSION,
|
||||
extensionConnected: false,
|
||||
pending: 0,
|
||||
memoryMB: 32,
|
||||
@@ -80,6 +90,7 @@ describe('daemonStatus', () => {
|
||||
ok: true,
|
||||
pid: 99,
|
||||
uptime: 120,
|
||||
daemonVersion: PKG_VERSION,
|
||||
extensionConnected: true,
|
||||
extensionVersion: undefined,
|
||||
pending: 0,
|
||||
@@ -119,6 +130,7 @@ describe('daemonStop', () => {
|
||||
ok: true,
|
||||
pid: 12345,
|
||||
uptime: 100,
|
||||
daemonVersion: PKG_VERSION,
|
||||
extensionConnected: true,
|
||||
pending: 0,
|
||||
memoryMB: 50,
|
||||
@@ -137,6 +149,7 @@ describe('daemonStop', () => {
|
||||
ok: true,
|
||||
pid: 12345,
|
||||
uptime: 100,
|
||||
daemonVersion: PKG_VERSION,
|
||||
extensionConnected: true,
|
||||
pending: 0,
|
||||
memoryMB: 50,
|
||||
@@ -149,3 +162,105 @@ describe('daemonStop', () => {
|
||||
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to stop daemon'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('daemonRestart', () => {
|
||||
let stderrSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
fetchDaemonStatusMock.mockReset();
|
||||
requestDaemonShutdownMock.mockReset();
|
||||
restartDaemonMock.mockReset();
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
it('restarts a running daemon and reports the new version', async () => {
|
||||
fetchDaemonStatusMock.mockResolvedValue({
|
||||
ok: true,
|
||||
pid: 12345,
|
||||
uptime: 100,
|
||||
daemonVersion: '1.7.6',
|
||||
extensionConnected: true,
|
||||
profiles: [{ contextId: 'work', extensionConnected: true, pending: 0 }],
|
||||
pending: 0,
|
||||
memoryMB: 50,
|
||||
port: 19825,
|
||||
});
|
||||
restartDaemonMock.mockResolvedValue({
|
||||
previousStatus: { daemonVersion: '1.7.6' },
|
||||
stopped: true,
|
||||
spawned: true,
|
||||
status: {
|
||||
ok: true,
|
||||
pid: 12346,
|
||||
uptime: 1,
|
||||
daemonVersion: PKG_VERSION,
|
||||
extensionConnected: true,
|
||||
profiles: [{ contextId: 'work', extensionConnected: true, pending: 0 }],
|
||||
pending: 0,
|
||||
memoryMB: 51,
|
||||
port: 19825,
|
||||
},
|
||||
});
|
||||
|
||||
await daemonRestart();
|
||||
|
||||
expect(restartDaemonMock).toHaveBeenCalledTimes(1);
|
||||
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('will disconnect 1 browser profile'));
|
||||
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining(`Daemon restarted on port 19825 (v${PKG_VERSION})`));
|
||||
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('Extension connected; profiles connected: 1'));
|
||||
});
|
||||
|
||||
it('starts a new daemon when none was running', async () => {
|
||||
fetchDaemonStatusMock.mockResolvedValue(null);
|
||||
restartDaemonMock.mockResolvedValue({
|
||||
previousStatus: null,
|
||||
stopped: true,
|
||||
spawned: true,
|
||||
status: {
|
||||
ok: true,
|
||||
pid: 12346,
|
||||
uptime: 1,
|
||||
daemonVersion: PKG_VERSION,
|
||||
extensionConnected: false,
|
||||
pending: 0,
|
||||
memoryMB: 51,
|
||||
port: 19825,
|
||||
},
|
||||
});
|
||||
|
||||
await daemonRestart();
|
||||
|
||||
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining(`Daemon started on port 19825 (v${PKG_VERSION})`));
|
||||
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('extension has not connected yet'));
|
||||
});
|
||||
|
||||
it('reports failure when the daemon cannot stop', async () => {
|
||||
fetchDaemonStatusMock.mockResolvedValue({
|
||||
ok: true,
|
||||
pid: 12345,
|
||||
uptime: 100,
|
||||
daemonVersion: '1.7.6',
|
||||
extensionConnected: true,
|
||||
pending: 0,
|
||||
memoryMB: 50,
|
||||
port: 19825,
|
||||
});
|
||||
restartDaemonMock.mockResolvedValue({
|
||||
previousStatus: { daemonVersion: '1.7.6' },
|
||||
status: { daemonVersion: '1.7.6' },
|
||||
stopped: false,
|
||||
spawned: false,
|
||||
});
|
||||
|
||||
await daemonRestart();
|
||||
|
||||
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to stop daemon before restart'));
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
+38
-1
@@ -2,12 +2,16 @@
|
||||
* CLI commands for daemon lifecycle:
|
||||
* opencli daemon status — show daemon state
|
||||
* opencli daemon stop — graceful shutdown
|
||||
* opencli daemon restart — graceful shutdown, then start a fresh daemon
|
||||
*/
|
||||
|
||||
import { styleText } from 'node:util';
|
||||
import { fetchDaemonStatus, requestDaemonShutdown } from '../browser/daemon-client.js';
|
||||
import { restartDaemon } from '../browser/daemon-lifecycle.js';
|
||||
import { formatDuration } from '../download/progress.js';
|
||||
import { log } from '../logger.js';
|
||||
import { PKG_VERSION } from '../version.js';
|
||||
import { formatDaemonVersion, isDaemonStale } from '../browser/daemon-version.js';
|
||||
|
||||
export async function daemonStatus(): Promise<void> {
|
||||
const status = await fetchDaemonStatus();
|
||||
@@ -22,7 +26,10 @@ export async function daemonStatus(): Promise<void> {
|
||||
? `${styleText('green', 'connected')} ${styleText('dim', `(v${status.extensionVersion})`)}`
|
||||
: `${styleText('yellow', 'connected')} ${styleText('dim', '(version unknown)')}`;
|
||||
|
||||
console.log(`Daemon: ${styleText('green', 'running')} (PID ${status.pid})`);
|
||||
const daemonVersion = formatDaemonVersion(status);
|
||||
const stale = isDaemonStale(status, PKG_VERSION);
|
||||
console.log(`Daemon: ${stale ? styleText('yellow', 'stale') : styleText('green', 'running')} (PID ${status.pid})`);
|
||||
console.log(`Version: ${daemonVersion}${stale ? styleText('yellow', ` (CLI v${PKG_VERSION}; run: opencli daemon restart)`) : ''}`);
|
||||
console.log(`Uptime: ${formatDuration(Math.round(status.uptime * 1000))}`);
|
||||
console.log(`Extension: ${extensionLabel}`);
|
||||
if (status.profiles && status.profiles.length > 0) {
|
||||
@@ -50,3 +57,33 @@ export async function daemonStop(): Promise<void> {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonRestart(): Promise<void> {
|
||||
const before = await fetchDaemonStatus();
|
||||
if (before?.profiles && before.profiles.length > 0) {
|
||||
log.warn(`Restarting daemon will disconnect ${before.profiles.length} browser profile(s); the extension should reconnect automatically.`);
|
||||
}
|
||||
|
||||
const result = await restartDaemon();
|
||||
if (!result.stopped) {
|
||||
log.error('Failed to stop daemon before restart.');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (!result.status) {
|
||||
log.error('Daemon restart timed out before the new daemon reported status.');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const action = result.previousStatus ? 'restarted' : 'started';
|
||||
const version = formatDaemonVersion(result.status);
|
||||
log.success(`Daemon ${action} on port ${result.status.port} (${version}).`);
|
||||
if (result.status.extensionConnected) {
|
||||
const profiles = result.status.profiles?.length ?? 0;
|
||||
const profileText = profiles > 0 ? `; profiles connected: ${profiles}` : '';
|
||||
log.status(`Extension connected${profileText}.`);
|
||||
} else {
|
||||
log.warn('Daemon is running, but the Browser Bridge extension has not connected yet.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,357 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import {
|
||||
buildRepairContext, collectDiagnostic, isDiagnosticEnabled, emitDiagnostic,
|
||||
truncate, redactUrl, redactText, resolveAdapterSourcePath, MAX_DIAGNOSTIC_BYTES,
|
||||
type RepairContext,
|
||||
} from './diagnostic.js';
|
||||
import { SelectorError, CommandExecutionError } from './errors.js';
|
||||
import type { InternalCliCommand } from './registry.js';
|
||||
import type { IPage } from './types.js';
|
||||
|
||||
function makeCmd(overrides: Partial<InternalCliCommand> = {}): InternalCliCommand {
|
||||
return {
|
||||
site: 'test-site',
|
||||
name: 'test-cmd',
|
||||
description: 'test',
|
||||
args: [],
|
||||
...overrides,
|
||||
} as InternalCliCommand;
|
||||
}
|
||||
|
||||
describe('isDiagnosticEnabled', () => {
|
||||
const origEnv = process.env.OPENCLI_DIAGNOSTIC;
|
||||
afterEach(() => {
|
||||
if (origEnv === undefined) delete process.env.OPENCLI_DIAGNOSTIC;
|
||||
else process.env.OPENCLI_DIAGNOSTIC = origEnv;
|
||||
});
|
||||
|
||||
it('returns false when env not set', () => {
|
||||
delete process.env.OPENCLI_DIAGNOSTIC;
|
||||
expect(isDiagnosticEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when env is "1"', () => {
|
||||
process.env.OPENCLI_DIAGNOSTIC = '1';
|
||||
expect(isDiagnosticEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for other values', () => {
|
||||
process.env.OPENCLI_DIAGNOSTIC = 'true';
|
||||
expect(isDiagnosticEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncate', () => {
|
||||
it('returns short strings unchanged', () => {
|
||||
expect(truncate('hello', 100)).toBe('hello');
|
||||
});
|
||||
|
||||
it('truncates long strings with marker', () => {
|
||||
const long = 'a'.repeat(200);
|
||||
const result = truncate(long, 50);
|
||||
expect(result.length).toBeLessThan(200);
|
||||
expect(result).toContain('...[truncated,');
|
||||
expect(result).toContain('150 chars omitted]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('redactUrl', () => {
|
||||
it('redacts sensitive query parameters', () => {
|
||||
expect(redactUrl('https://api.com/v1?token=abc123&q=test'))
|
||||
.toBe('https://api.com/v1?token=[REDACTED]&q=test');
|
||||
});
|
||||
|
||||
it('redacts multiple sensitive params', () => {
|
||||
const url = 'https://api.com?api_key=xxx&secret=yyy&page=1';
|
||||
const result = redactUrl(url);
|
||||
expect(result).toContain('api_key=[REDACTED]');
|
||||
expect(result).toContain('secret=[REDACTED]');
|
||||
expect(result).toContain('page=1');
|
||||
});
|
||||
|
||||
it('leaves clean URLs unchanged', () => {
|
||||
expect(redactUrl('https://example.com/page?q=test')).toBe('https://example.com/page?q=test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('redactText', () => {
|
||||
it('redacts Bearer tokens', () => {
|
||||
expect(redactText('Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.test'))
|
||||
.toContain('Bearer [REDACTED]');
|
||||
});
|
||||
|
||||
it('redacts JWT tokens', () => {
|
||||
const jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U';
|
||||
expect(redactText(`token is ${jwt}`)).toContain('[REDACTED_JWT]');
|
||||
expect(redactText(`token is ${jwt}`)).not.toContain('eyJhbGci');
|
||||
});
|
||||
|
||||
it('redacts inline token=value patterns', () => {
|
||||
expect(redactText('failed with token=abc123def456')).toContain('token=[REDACTED]');
|
||||
});
|
||||
|
||||
it('redacts cookie values', () => {
|
||||
const result = redactText('cookie: session=abc123; user=xyz789; path=/');
|
||||
expect(result).toContain('[REDACTED]');
|
||||
expect(result).not.toContain('session=abc123');
|
||||
});
|
||||
|
||||
it('leaves normal text unchanged', () => {
|
||||
expect(redactText('Error: element not found')).toBe('Error: element not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAdapterSourcePath', () => {
|
||||
it('returns source when it is a real file path (not manifest:)', () => {
|
||||
const cmd = makeCmd({ source: '/home/user/.opencli/clis/arxiv/search.js' });
|
||||
expect(resolveAdapterSourcePath(cmd as InternalCliCommand)).toBe('/home/user/.opencli/clis/arxiv/search.js');
|
||||
});
|
||||
|
||||
it('skips manifest: pseudo-paths and falls back to _modulePath', () => {
|
||||
const cmd = makeCmd({ source: 'manifest:arxiv/search', _modulePath: '/pkg/clis/arxiv/search.js' });
|
||||
// Should try to map to source, but since files don't exist on disk, returns _modulePath
|
||||
const result = resolveAdapterSourcePath(cmd as InternalCliCommand);
|
||||
expect(result).toBeDefined();
|
||||
expect(result).not.toContain('manifest:');
|
||||
});
|
||||
|
||||
it('returns undefined when only manifest: pseudo-path and no _modulePath', () => {
|
||||
const cmd = makeCmd({ source: 'manifest:test/cmd' });
|
||||
expect(resolveAdapterSourcePath(cmd as InternalCliCommand)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns _modulePath when it is the only path available', () => {
|
||||
const cmd = makeCmd({ _modulePath: '/project/clis/site/cmd.js' });
|
||||
const result = resolveAdapterSourcePath(cmd as InternalCliCommand);
|
||||
// Since file doesn't exist, returns _modulePath as best guess
|
||||
expect(result).toBe('/project/clis/site/cmd.js');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRepairContext', () => {
|
||||
it('captures CliError fields', () => {
|
||||
const err = new SelectorError('.missing-element', 'Element removed');
|
||||
const ctx = buildRepairContext(err, makeCmd());
|
||||
|
||||
expect(ctx.error.code).toBe('SELECTOR');
|
||||
expect(ctx.error.message).toContain('.missing-element');
|
||||
expect(ctx.error.hint).toBe('Element removed');
|
||||
expect(ctx.error.stack).toBeDefined();
|
||||
expect(ctx.adapter.site).toBe('test-site');
|
||||
expect(ctx.adapter.command).toBe('test-site/test-cmd');
|
||||
expect(ctx.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
|
||||
it('handles non-CliError errors', () => {
|
||||
const err = new TypeError('Cannot read property "x" of undefined');
|
||||
const ctx = buildRepairContext(err, makeCmd());
|
||||
|
||||
expect(ctx.error.code).toBe('UNKNOWN');
|
||||
expect(ctx.error.message).toContain('Cannot read property');
|
||||
expect(ctx.error.hint).toBeUndefined();
|
||||
});
|
||||
|
||||
it('includes page state when provided', () => {
|
||||
const pageState: RepairContext['page'] = {
|
||||
url: 'https://example.com/page',
|
||||
snapshot: '<div>...</div>',
|
||||
networkRequests: [{ url: '/api/data', status: 200 }],
|
||||
consoleErrors: ['Uncaught TypeError'],
|
||||
};
|
||||
const ctx = buildRepairContext(new CommandExecutionError('boom'), makeCmd(), pageState);
|
||||
|
||||
expect(ctx.page).toEqual(pageState);
|
||||
});
|
||||
|
||||
it('omits page when not provided', () => {
|
||||
const ctx = buildRepairContext(new Error('boom'), makeCmd());
|
||||
expect(ctx.page).toBeUndefined();
|
||||
});
|
||||
|
||||
it('truncates long stack traces', () => {
|
||||
const err = new Error('boom');
|
||||
err.stack = 'x'.repeat(10_000);
|
||||
const ctx = buildRepairContext(err, makeCmd());
|
||||
expect(ctx.error.stack!.length).toBeLessThan(10_000);
|
||||
expect(ctx.error.stack).toContain('truncated');
|
||||
});
|
||||
|
||||
it('redacts sensitive data in error message and stack', () => {
|
||||
const err = new Error('Request failed with Bearer eyJhbGciOiJIUzI1NiJ9.test.sig');
|
||||
const ctx = buildRepairContext(err, makeCmd());
|
||||
expect(ctx.error.message).toContain('Bearer [REDACTED]');
|
||||
expect(ctx.error.message).not.toContain('eyJhbGci');
|
||||
// Stack also gets redacted
|
||||
expect(ctx.error.stack).toContain('Bearer [REDACTED]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitDiagnostic', () => {
|
||||
it('writes delimited JSON to stderr', () => {
|
||||
const writeSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
|
||||
|
||||
const ctx = buildRepairContext(new CommandExecutionError('test error'), makeCmd());
|
||||
emitDiagnostic(ctx);
|
||||
|
||||
const output = writeSpy.mock.calls.map(c => c[0]).join('');
|
||||
expect(output).toContain('___OPENCLI_DIAGNOSTIC___');
|
||||
expect(output).toContain('"code":"COMMAND_EXEC"');
|
||||
expect(output).toContain('"message":"test error"');
|
||||
|
||||
// Verify JSON is parseable between markers
|
||||
const match = output.match(/___OPENCLI_DIAGNOSTIC___\n(.*)\n___OPENCLI_DIAGNOSTIC___/);
|
||||
expect(match).toBeTruthy();
|
||||
const parsed = JSON.parse(match![1]);
|
||||
expect(parsed.error.code).toBe('COMMAND_EXEC');
|
||||
|
||||
writeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('drops page snapshot when over size budget', () => {
|
||||
const writeSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
|
||||
|
||||
const ctx: RepairContext = {
|
||||
error: { code: 'COMMAND_EXEC', message: 'boom' },
|
||||
adapter: { site: 'test', command: 'test/cmd' },
|
||||
page: {
|
||||
url: 'https://example.com',
|
||||
snapshot: 'x'.repeat(MAX_DIAGNOSTIC_BYTES + 1000),
|
||||
networkRequests: [],
|
||||
consoleErrors: [],
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
emitDiagnostic(ctx);
|
||||
|
||||
const output = writeSpy.mock.calls.map(c => c[0]).join('');
|
||||
const match = output.match(/___OPENCLI_DIAGNOSTIC___\n(.*)\n___OPENCLI_DIAGNOSTIC___/);
|
||||
expect(match).toBeTruthy();
|
||||
const parsed = JSON.parse(match![1]);
|
||||
// Page snapshot should be replaced or page dropped entirely
|
||||
expect(parsed.page?.snapshot !== ctx.page!.snapshot || parsed.page === undefined).toBe(true);
|
||||
expect(match![1].length).toBeLessThanOrEqual(MAX_DIAGNOSTIC_BYTES);
|
||||
|
||||
writeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('redacts sensitive headers in network requests', () => {
|
||||
const pageState: RepairContext['page'] = {
|
||||
url: 'https://example.com',
|
||||
snapshot: '<div/>',
|
||||
networkRequests: [{
|
||||
url: 'https://api.com/data?token=secret123',
|
||||
headers: { authorization: 'Bearer xyz', 'content-type': 'application/json' },
|
||||
body: '{"data": "ok"}',
|
||||
}],
|
||||
consoleErrors: [],
|
||||
};
|
||||
// Build context manually to test redaction via collectPageState
|
||||
// Since collectPageState is private, test the output of buildRepairContext
|
||||
// with already-collected page state — redaction happens in collectPageState.
|
||||
// For unit test, verify redactUrl directly (tested above) and trust integration.
|
||||
expect(redactUrl('https://api.com/data?token=secret123')).toContain('[REDACTED]');
|
||||
});
|
||||
});
|
||||
|
||||
function makePage(overrides: Partial<IPage> = {}): IPage {
|
||||
return {
|
||||
goto: vi.fn(),
|
||||
evaluate: vi.fn(),
|
||||
getCookies: vi.fn(),
|
||||
snapshot: vi.fn().mockResolvedValue('<div>...</div>'),
|
||||
click: vi.fn(),
|
||||
typeText: vi.fn(),
|
||||
pressKey: vi.fn(),
|
||||
scrollTo: vi.fn(),
|
||||
getFormState: vi.fn(),
|
||||
wait: vi.fn(),
|
||||
tabs: vi.fn(),
|
||||
selectTab: vi.fn(),
|
||||
networkRequests: vi.fn().mockResolvedValue([]),
|
||||
consoleMessages: vi.fn().mockResolvedValue([]),
|
||||
scroll: vi.fn(),
|
||||
autoScroll: vi.fn(),
|
||||
installInterceptor: vi.fn(),
|
||||
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
||||
waitForCapture: vi.fn(),
|
||||
screenshot: vi.fn(),
|
||||
getCurrentUrl: vi.fn().mockResolvedValue('https://example.com/page'),
|
||||
...overrides,
|
||||
} as IPage;
|
||||
}
|
||||
|
||||
describe('collectDiagnostic', () => {
|
||||
it('keeps intercepted payloads in a dedicated capturedPayloads field', async () => {
|
||||
const page = makePage({
|
||||
networkRequests: vi.fn().mockResolvedValue([{ url: '/api/data', status: 200 }]),
|
||||
getInterceptedRequests: vi.fn().mockResolvedValue([{ items: [{ id: 1 }] }]),
|
||||
});
|
||||
|
||||
const ctx = await collectDiagnostic(new Error('boom'), makeCmd(), page);
|
||||
|
||||
expect(ctx.page?.networkRequests).toEqual([
|
||||
{ url: '/api/data', status: 200 },
|
||||
]);
|
||||
expect(ctx.page?.capturedPayloads).toEqual([
|
||||
{ source: 'interceptor', responseBody: { items: [{ id: 1 }] } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves the previous network request output when interception is empty', async () => {
|
||||
const page = makePage({
|
||||
networkRequests: vi.fn().mockResolvedValue([{ url: '/api/data', status: 200 }]),
|
||||
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
||||
});
|
||||
|
||||
const ctx = await collectDiagnostic(new Error('boom'), makeCmd(), page);
|
||||
|
||||
expect(ctx.page?.networkRequests).toEqual([{ url: '/api/data', status: 200 }]);
|
||||
expect(ctx.page?.capturedPayloads).toEqual([]);
|
||||
});
|
||||
|
||||
it('swallows intercepted request failures and still returns page state', async () => {
|
||||
const page = makePage({
|
||||
networkRequests: vi.fn().mockResolvedValue([{ url: '/api/data', status: 200 }]),
|
||||
getInterceptedRequests: vi.fn().mockRejectedValue(new Error('interceptor unavailable')),
|
||||
});
|
||||
|
||||
const ctx = await collectDiagnostic(new Error('boom'), makeCmd(), page);
|
||||
|
||||
expect(ctx.page).toEqual({
|
||||
url: 'https://example.com/page',
|
||||
snapshot: '<div>...</div>',
|
||||
networkRequests: [{ url: '/api/data', status: 200 }],
|
||||
capturedPayloads: [],
|
||||
consoleErrors: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts and truncates intercepted payloads recursively', async () => {
|
||||
const page = makePage({
|
||||
getInterceptedRequests: vi.fn().mockResolvedValue([{
|
||||
token: 'token=abc123def456ghi789',
|
||||
nested: {
|
||||
cookie: 'cookie: session=super-secret-cookie-value',
|
||||
body: 'x'.repeat(5000),
|
||||
},
|
||||
}]),
|
||||
});
|
||||
|
||||
const ctx = await collectDiagnostic(new Error('boom'), makeCmd(), page);
|
||||
const payload = ctx.page?.capturedPayloads?.[0] as Record<string, unknown>;
|
||||
const body = ((payload.responseBody as Record<string, unknown>).nested as Record<string, unknown>).body as string;
|
||||
|
||||
expect(payload).toEqual({
|
||||
source: 'interceptor',
|
||||
responseBody: {
|
||||
token: 'token=[REDACTED]',
|
||||
nested: {
|
||||
cookie: 'cookie: [REDACTED]',
|
||||
body,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(body).toContain('[truncated,');
|
||||
expect(body.length).toBeLessThan(5000);
|
||||
});
|
||||
});
|
||||
@@ -1,360 +0,0 @@
|
||||
/**
|
||||
* Structured diagnostic output for AI-driven adapter repair.
|
||||
*
|
||||
* When OPENCLI_DIAGNOSTIC=1, failed commands emit a JSON RepairContext to stderr
|
||||
* containing the error, adapter source, and browser state (DOM snapshot, network
|
||||
* requests, console errors). AI Agents consume this to diagnose and fix adapters.
|
||||
*
|
||||
* Safety boundaries:
|
||||
* - Sensitive headers/cookies are redacted before emission
|
||||
* - Individual fields are capped to prevent unbounded output
|
||||
* - Network response bodies from authenticated requests are stripped
|
||||
* - Total output is capped to MAX_DIAGNOSTIC_BYTES
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import type { IPage } from './types.js';
|
||||
import { CliError, getErrorMessage } from './errors.js';
|
||||
import type { InternalCliCommand } from './registry.js';
|
||||
import { fullName } from './registry.js';
|
||||
|
||||
// ── Size budgets ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Maximum bytes for the entire diagnostic JSON output. */
|
||||
export const MAX_DIAGNOSTIC_BYTES = 256 * 1024; // 256 KB
|
||||
/** Maximum characters for DOM snapshot. */
|
||||
const MAX_SNAPSHOT_CHARS = 100_000;
|
||||
/** Maximum characters for adapter source. */
|
||||
const MAX_SOURCE_CHARS = 50_000;
|
||||
/** Maximum number of network requests to include. */
|
||||
const MAX_NETWORK_REQUESTS = 50;
|
||||
/** Maximum number of captured interceptor payloads to include. */
|
||||
const MAX_CAPTURED_PAYLOADS = 20;
|
||||
/** Maximum characters for a single network request body. */
|
||||
const MAX_REQUEST_BODY_CHARS = 4_000;
|
||||
/** Maximum characters for error stack trace. */
|
||||
const MAX_STACK_CHARS = 5_000;
|
||||
/** Maximum nesting depth for arbitrary captured payloads. */
|
||||
const MAX_CAPTURED_DEPTH = 4;
|
||||
/** Maximum object keys or array items to keep per nesting level. */
|
||||
const MAX_CAPTURED_CHILDREN = 20;
|
||||
|
||||
// ── Sensitive data patterns ──────────────────────────────────────────────────
|
||||
|
||||
const SENSITIVE_HEADERS = new Set([
|
||||
'authorization',
|
||||
'cookie',
|
||||
'set-cookie',
|
||||
'x-csrf-token',
|
||||
'x-xsrf-token',
|
||||
'proxy-authorization',
|
||||
'x-api-key',
|
||||
'x-auth-token',
|
||||
]);
|
||||
|
||||
const SENSITIVE_URL_PARAMS = /([?&])(token|key|secret|password|auth|access_token|api_key|session_id|csrf)=[^&]*/gi;
|
||||
|
||||
/** Patterns that match inline secrets in free-text strings (error messages, stack traces, console output, DOM). */
|
||||
const SENSITIVE_TEXT_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [
|
||||
// Bearer tokens
|
||||
{ pattern: /Bearer\s+[A-Za-z0-9\-._~+/]+=*/gi, replacement: 'Bearer [REDACTED]' },
|
||||
// Generic "token=...", "key=...", etc. in non-URL text
|
||||
{ pattern: /(token|secret|password|api_key|apikey|access_token|session_id)[=:]\s*['"]?[A-Za-z0-9\-._~+/]{8,}['"]?/gi, replacement: '$1=[REDACTED]' },
|
||||
// Cookie header values (key=value pairs)
|
||||
{ pattern: /(cookie[=:]\s*)[^\n;]{10,}/gi, replacement: '$1[REDACTED]' },
|
||||
// JWT-like tokens (three base64 segments separated by dots)
|
||||
{ pattern: /eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, replacement: '[REDACTED_JWT]' },
|
||||
];
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RepairContext {
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
hint?: string;
|
||||
stack?: string;
|
||||
};
|
||||
adapter: {
|
||||
site: string;
|
||||
command: string;
|
||||
sourcePath?: string;
|
||||
source?: string;
|
||||
};
|
||||
page?: {
|
||||
url: string;
|
||||
snapshot: string;
|
||||
networkRequests: unknown[];
|
||||
capturedPayloads?: unknown[];
|
||||
consoleErrors: unknown[];
|
||||
};
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// ── Redaction helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/** Truncate a string to maxLen, appending a truncation marker. */
|
||||
export function truncate(str: string, maxLen: number): string {
|
||||
if (str.length <= maxLen) return str;
|
||||
return str.slice(0, maxLen) + `\n...[truncated, ${str.length - maxLen} chars omitted]`;
|
||||
}
|
||||
|
||||
/** Redact sensitive query parameters from a URL. */
|
||||
export function redactUrl(url: string): string {
|
||||
return url.replace(SENSITIVE_URL_PARAMS, '$1$2=[REDACTED]');
|
||||
}
|
||||
|
||||
/** Redact inline secrets from free-text strings (error messages, stack traces, console output, DOM). */
|
||||
export function redactText(text: string): string {
|
||||
let result = text;
|
||||
for (const { pattern, replacement } of SENSITIVE_TEXT_PATTERNS) {
|
||||
// Reset lastIndex for global regexps
|
||||
pattern.lastIndex = 0;
|
||||
result = result.replace(pattern, replacement);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Redact sensitive headers from a headers object. */
|
||||
function redactHeaders(headers: Record<string, string> | undefined): Record<string, string> | undefined {
|
||||
if (!headers || typeof headers !== 'object') return headers;
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
result[key] = SENSITIVE_HEADERS.has(key.toLowerCase()) ? '[REDACTED]' : value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Recursively sanitize arbitrary captured response content for diagnostic output. */
|
||||
function sanitizeCapturedValue(value: unknown, depth: number = 0): unknown {
|
||||
if (typeof value === 'string') {
|
||||
return redactText(truncate(value, MAX_REQUEST_BODY_CHARS));
|
||||
}
|
||||
if (value === null || typeof value === 'number' || typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (depth >= MAX_CAPTURED_DEPTH) {
|
||||
return '[truncated: max depth reached]';
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const items = value
|
||||
.slice(0, MAX_CAPTURED_CHILDREN)
|
||||
.map(item => sanitizeCapturedValue(item, depth + 1));
|
||||
if (value.length > MAX_CAPTURED_CHILDREN) {
|
||||
items.push(`[truncated, ${value.length - MAX_CAPTURED_CHILDREN} items omitted]`);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
if (!value || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const entries = Object.entries(value);
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, child] of entries.slice(0, MAX_CAPTURED_CHILDREN)) {
|
||||
result[key] = sanitizeCapturedValue(child, depth + 1);
|
||||
}
|
||||
if (entries.length > MAX_CAPTURED_CHILDREN) {
|
||||
result.__truncated__ = `[${entries.length - MAX_CAPTURED_CHILDREN} fields omitted]`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Redact sensitive data from a single network request entry. */
|
||||
function redactNetworkRequest(req: unknown): unknown {
|
||||
if (!req || typeof req !== 'object') return req;
|
||||
const r = req as Record<string, unknown>;
|
||||
const redacted: Record<string, unknown> = { ...r };
|
||||
|
||||
// Redact URL
|
||||
if (typeof redacted.url === 'string') {
|
||||
redacted.url = redactUrl(redacted.url);
|
||||
}
|
||||
|
||||
// Redact headers
|
||||
if (redacted.headers && typeof redacted.headers === 'object') {
|
||||
redacted.headers = redactHeaders(redacted.headers as Record<string, string>);
|
||||
}
|
||||
if (redacted.requestHeaders && typeof redacted.requestHeaders === 'object') {
|
||||
redacted.requestHeaders = redactHeaders(redacted.requestHeaders as Record<string, string>);
|
||||
}
|
||||
if (redacted.responseHeaders && typeof redacted.responseHeaders === 'object') {
|
||||
redacted.responseHeaders = redactHeaders(redacted.responseHeaders as Record<string, string>);
|
||||
}
|
||||
|
||||
// Redact and truncate response body
|
||||
if (typeof redacted.body === 'string') {
|
||||
redacted.body = redactText(truncate(redacted.body, MAX_REQUEST_BODY_CHARS));
|
||||
}
|
||||
if ('responseBody' in redacted) {
|
||||
redacted.responseBody = sanitizeCapturedValue(redacted.responseBody);
|
||||
}
|
||||
if ('responsePreview' in redacted) {
|
||||
redacted.responsePreview = sanitizeCapturedValue(redacted.responsePreview);
|
||||
}
|
||||
|
||||
return redacted;
|
||||
}
|
||||
|
||||
// ── Timeout helper ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Timeout for page state collection (prevents hang when CDP connection is stuck). */
|
||||
const PAGE_STATE_TIMEOUT_MS = 5_000;
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<T>(resolve => setTimeout(() => resolve(fallback), ms)),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Source path resolution ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the editable source file path for an adapter.
|
||||
*
|
||||
* Priority:
|
||||
* 1. cmd.source (set for FS-scanned JS and manifest lazy-loaded JS)
|
||||
* 2. cmd._modulePath (set for manifest lazy-loaded JS)
|
||||
*
|
||||
* Skip manifest: prefixed pseudo-paths (YAML commands inlined in manifest).
|
||||
*/
|
||||
export function resolveAdapterSourcePath(cmd: InternalCliCommand): string | undefined {
|
||||
const candidates: string[] = [];
|
||||
|
||||
// cmd.source may be a real file path or 'manifest:site/name'
|
||||
if (cmd.source && !cmd.source.startsWith('manifest:')) {
|
||||
candidates.push(cmd.source);
|
||||
}
|
||||
if (cmd._modulePath) {
|
||||
candidates.push(cmd._modulePath);
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
|
||||
return candidates[0]; // Return best guess even if file doesn't exist
|
||||
}
|
||||
|
||||
// ── Diagnostic collection ────────────────────────────────────────────────────
|
||||
|
||||
/** Whether diagnostic mode is enabled. */
|
||||
export function isDiagnosticEnabled(): boolean {
|
||||
return process.env.OPENCLI_DIAGNOSTIC === '1';
|
||||
}
|
||||
|
||||
function normalizeInterceptedRequests(interceptedRequests: unknown[]): unknown[] {
|
||||
return interceptedRequests.slice(0, MAX_CAPTURED_PAYLOADS).map(responseBody => ({
|
||||
source: 'interceptor',
|
||||
responseBody: sanitizeCapturedValue(responseBody),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Safely collect page diagnostic state with redaction, size caps, and timeout. */
|
||||
async function collectPageState(page: IPage): Promise<RepairContext['page'] | undefined> {
|
||||
const collect = async (): Promise<RepairContext['page'] | undefined> => {
|
||||
try {
|
||||
const [url, snapshot, networkRequests, interceptedRequests, consoleErrors] = await Promise.all([
|
||||
page.getCurrentUrl?.().catch(() => null) ?? Promise.resolve(null),
|
||||
page.snapshot().catch(() => '(snapshot unavailable)'),
|
||||
page.networkRequests().catch(() => []),
|
||||
page.getInterceptedRequests().catch(() => []),
|
||||
page.consoleMessages('error').catch(() => []),
|
||||
]);
|
||||
|
||||
const rawUrl = url ?? 'unknown';
|
||||
const capturedResponses = normalizeInterceptedRequests(interceptedRequests as unknown[]);
|
||||
return {
|
||||
url: redactUrl(rawUrl),
|
||||
snapshot: redactText(truncate(snapshot, MAX_SNAPSHOT_CHARS)),
|
||||
networkRequests: (networkRequests as unknown[])
|
||||
.slice(0, MAX_NETWORK_REQUESTS)
|
||||
.map(redactNetworkRequest),
|
||||
capturedPayloads: capturedResponses,
|
||||
consoleErrors: (consoleErrors as unknown[])
|
||||
.slice(0, 50)
|
||||
.map(e => typeof e === 'string' ? redactText(e) : e),
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
return withTimeout(collect(), PAGE_STATE_TIMEOUT_MS, undefined);
|
||||
}
|
||||
|
||||
/** Read adapter source file content with size cap. */
|
||||
function readAdapterSource(sourcePath: string | undefined): string | undefined {
|
||||
if (!sourcePath) return undefined;
|
||||
try {
|
||||
const content = fs.readFileSync(sourcePath, 'utf-8');
|
||||
return truncate(content, MAX_SOURCE_CHARS);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a RepairContext from an error, command metadata, and optional page state. */
|
||||
export function buildRepairContext(
|
||||
err: unknown,
|
||||
cmd: InternalCliCommand,
|
||||
pageState?: RepairContext['page'],
|
||||
): RepairContext {
|
||||
const isCliError = err instanceof CliError;
|
||||
const sourcePath = resolveAdapterSourcePath(cmd);
|
||||
return {
|
||||
error: {
|
||||
code: isCliError ? err.code : 'UNKNOWN',
|
||||
message: redactText(getErrorMessage(err)),
|
||||
hint: isCliError && err.hint ? redactText(err.hint) : undefined,
|
||||
stack: err instanceof Error ? redactText(truncate(err.stack ?? '', MAX_STACK_CHARS)) : undefined,
|
||||
},
|
||||
adapter: {
|
||||
site: cmd.site,
|
||||
command: fullName(cmd),
|
||||
sourcePath,
|
||||
source: readAdapterSource(sourcePath),
|
||||
},
|
||||
page: pageState,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Collect full diagnostic context including page state (with timeout). */
|
||||
export async function collectDiagnostic(
|
||||
err: unknown,
|
||||
cmd: InternalCliCommand,
|
||||
page: IPage | null,
|
||||
): Promise<RepairContext> {
|
||||
const pageState = page ? await collectPageState(page) : undefined;
|
||||
return buildRepairContext(err, cmd, pageState);
|
||||
}
|
||||
|
||||
/** Emit diagnostic JSON to stderr, enforcing total size cap. */
|
||||
export function emitDiagnostic(ctx: RepairContext): void {
|
||||
const marker = '___OPENCLI_DIAGNOSTIC___';
|
||||
let json = JSON.stringify(ctx);
|
||||
|
||||
// Enforce total output budget — drop page state (largest section) first if over budget
|
||||
if (json.length > MAX_DIAGNOSTIC_BYTES && ctx.page) {
|
||||
const trimmed = {
|
||||
...ctx,
|
||||
page: {
|
||||
...ctx.page,
|
||||
snapshot: '[omitted: over size budget]',
|
||||
networkRequests: [],
|
||||
capturedPayloads: [],
|
||||
},
|
||||
};
|
||||
json = JSON.stringify(trimmed);
|
||||
}
|
||||
// If still over budget, drop page entirely
|
||||
if (json.length > MAX_DIAGNOSTIC_BYTES) {
|
||||
const minimal = { ...ctx, page: undefined };
|
||||
json = JSON.stringify(minimal);
|
||||
}
|
||||
|
||||
process.stderr.write(`\n${marker}\n${json}\n${marker}\n`);
|
||||
}
|
||||
+1
-1
@@ -15,7 +15,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { type InternalCliCommand, Strategy, registerCommand } from './registry.js';
|
||||
import { getErrorMessage } from './errors.js';
|
||||
import { log } from './logger.js';
|
||||
import type { ManifestEntry } from './build-manifest.js';
|
||||
import type { ManifestEntry } from './manifest-types.js';
|
||||
import { findPackageRoot, getCliManifestPath } from './package-paths.js';
|
||||
|
||||
/** User runtime directory: ~/.opencli */
|
||||
|
||||
+80
-1
@@ -1,10 +1,11 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { mockGetDaemonHealth, mockListSessions, mockConnect, mockClose } = vi.hoisted(() => ({
|
||||
const { mockGetDaemonHealth, mockListSessions, mockConnect, mockClose, mockFindShadowedUserAdapters } = vi.hoisted(() => ({
|
||||
mockGetDaemonHealth: vi.fn(),
|
||||
mockListSessions: vi.fn(),
|
||||
mockConnect: vi.fn(),
|
||||
mockClose: vi.fn(),
|
||||
mockFindShadowedUserAdapters: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./browser/daemon-client.js', () => ({
|
||||
@@ -19,6 +20,14 @@ vi.mock('./browser/index.js', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./adapter-shadow.js', async () => {
|
||||
const actual = await vi.importActual<typeof import('./adapter-shadow.js')>('./adapter-shadow.js');
|
||||
return {
|
||||
...actual,
|
||||
findShadowedUserAdapters: mockFindShadowedUserAdapters,
|
||||
};
|
||||
});
|
||||
|
||||
import { renderBrowserDoctorReport, runBrowserDoctor } from './doctor.js';
|
||||
|
||||
describe('doctor report rendering', () => {
|
||||
@@ -26,19 +35,40 @@ describe('doctor report rendering', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFindShadowedUserAdapters.mockReturnValue([]);
|
||||
});
|
||||
|
||||
it('renders OK-style report when daemon and extension connected', () => {
|
||||
const text = strip(renderBrowserDoctorReport({
|
||||
cliVersion: '1.7.9',
|
||||
daemonRunning: true,
|
||||
daemonVersion: '1.7.9',
|
||||
extensionConnected: true,
|
||||
extensionVersion: '1.6.8',
|
||||
issues: [],
|
||||
}));
|
||||
|
||||
expect(text).toContain('[OK] Daemon: running on port 19825');
|
||||
expect(text).toContain('(v1.7.9)');
|
||||
expect(text).toContain('[OK] Extension: connected (v1.6.8)');
|
||||
expect(text).toContain('Everything looks good!');
|
||||
expect(text).toContain('opencli browser analyze <url>');
|
||||
});
|
||||
|
||||
it('renders a warning when daemon version is stale', () => {
|
||||
const text = strip(renderBrowserDoctorReport({
|
||||
cliVersion: '1.7.9',
|
||||
daemonRunning: true,
|
||||
daemonVersion: '1.7.6',
|
||||
daemonStale: true,
|
||||
extensionConnected: true,
|
||||
extensionVersion: '1.0.3',
|
||||
issues: ['Stale daemon detected: daemon v1.7.6 != CLI v1.7.9.\n Run: opencli daemon restart'],
|
||||
}));
|
||||
|
||||
expect(text).toContain('[WARN] Daemon: running on port 19825 (v1.7.6, stale; CLI v1.7.9)');
|
||||
expect(text).toContain('Run: opencli daemon restart');
|
||||
expect(text).not.toContain('Everything looks good!');
|
||||
});
|
||||
|
||||
it('renders MISSING when daemon not running', () => {
|
||||
@@ -283,6 +313,55 @@ describe('doctor report rendering', () => {
|
||||
]));
|
||||
});
|
||||
|
||||
it('reports an issue when daemon version differs from CLI version', async () => {
|
||||
const status = {
|
||||
state: 'ready' as const,
|
||||
status: {
|
||||
daemonVersion: '1.7.6',
|
||||
extensionConnected: true,
|
||||
extensionVersion: '1.0.3',
|
||||
},
|
||||
};
|
||||
mockGetDaemonHealth
|
||||
.mockResolvedValueOnce(status)
|
||||
.mockResolvedValueOnce(status);
|
||||
|
||||
const report = await runBrowserDoctor({ live: false, cliVersion: '1.7.9' });
|
||||
|
||||
expect(report.daemonStale).toBe(true);
|
||||
expect(report.issues).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('Stale daemon detected: daemon v1.7.6 != CLI v1.7.9'),
|
||||
]));
|
||||
});
|
||||
|
||||
it('reports local adapter shadows as a warning issue', async () => {
|
||||
const status = {
|
||||
state: 'ready' as const,
|
||||
status: {
|
||||
daemonVersion: '1.7.9',
|
||||
extensionConnected: true,
|
||||
extensionVersion: '1.0.3',
|
||||
},
|
||||
};
|
||||
mockGetDaemonHealth
|
||||
.mockResolvedValueOnce(status)
|
||||
.mockResolvedValueOnce(status);
|
||||
mockFindShadowedUserAdapters.mockReturnValueOnce([
|
||||
{
|
||||
name: 'instagram/saved',
|
||||
userPath: '/home/me/.opencli/clis/instagram/saved.js',
|
||||
builtinPath: '/pkg/clis/instagram/saved.js',
|
||||
},
|
||||
]);
|
||||
|
||||
const report = await runBrowserDoctor({ live: false, cliVersion: '1.7.9' });
|
||||
|
||||
expect(report.adapterShadows).toHaveLength(1);
|
||||
expect(report.issues).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('Local adapter overrides shadow packaged adapters'),
|
||||
]));
|
||||
});
|
||||
|
||||
it('reports profile-required when multiple profiles are connected without a selection', async () => {
|
||||
const status = {
|
||||
state: 'profile-required' as const,
|
||||
|
||||
+24
-16
@@ -14,6 +14,8 @@ import { getCachedLatestExtensionVersion } from './update-check.js';
|
||||
import type { BrowserSessionInfo } from './types.js';
|
||||
import type { BrowserProfileStatus } from './browser/daemon-client.js';
|
||||
import { aliasForContextId, loadProfileConfig } from './browser/profile.js';
|
||||
import { formatDaemonVersion, isDaemonStale, staleDaemonIssue } from './browser/daemon-version.js';
|
||||
import { findShadowedUserAdapters, formatAdapterShadowIssue, type AdapterShadow } from './adapter-shadow.js';
|
||||
|
||||
const DOCTOR_LIVE_TIMEOUT_SECONDS = 8;
|
||||
|
||||
@@ -63,6 +65,7 @@ export type DoctorReport = {
|
||||
cliVersion?: string;
|
||||
daemonRunning: boolean;
|
||||
daemonFlaky?: boolean;
|
||||
daemonStale?: boolean;
|
||||
daemonVersion?: string;
|
||||
extensionConnected: boolean;
|
||||
extensionFlaky?: boolean;
|
||||
@@ -71,6 +74,7 @@ export type DoctorReport = {
|
||||
connectivity?: ConnectivityResult;
|
||||
sessions?: BrowserSessionInfo[];
|
||||
profiles?: BrowserProfileStatus[];
|
||||
adapterShadows?: AdapterShadow[];
|
||||
issues: string[];
|
||||
};
|
||||
|
||||
@@ -121,6 +125,7 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
|
||||
const extensionConnected = health.state === 'ready';
|
||||
const daemonFlaky = !!(connectivity?.ok && !daemonRunning);
|
||||
const extensionFlaky = !!(connectivity?.ok && daemonRunning && !extensionConnected);
|
||||
const daemonStale = isDaemonStale(health.status, opts.cliVersion);
|
||||
const profiles = health.status?.profiles;
|
||||
let sessions: BrowserSessionInfo[] | undefined;
|
||||
if (opts.sessions) {
|
||||
@@ -135,6 +140,7 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
|
||||
}
|
||||
}
|
||||
const extensionVersion = health.status?.extensionVersion;
|
||||
const adapterShadows = findShadowedUserAdapters();
|
||||
|
||||
const issues: string[] = [];
|
||||
if (daemonFlaky) {
|
||||
@@ -145,6 +151,9 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
|
||||
} else if (!daemonRunning) {
|
||||
issues.push('Daemon is not running. It should start automatically when you run an opencli browser command.');
|
||||
}
|
||||
if (daemonStale && opts.cliVersion) {
|
||||
issues.push(staleDaemonIssue(health.status, opts.cliVersion));
|
||||
}
|
||||
if (extensionFlaky) {
|
||||
issues.push(
|
||||
'Extension connection is unstable. The live browser test succeeded, but the daemon reported the extension disconnected immediately afterward.\n' +
|
||||
@@ -161,29 +170,16 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
|
||||
`Selected browser profile is not connected: ${health.status?.contextId ?? 'unknown'}.\n` +
|
||||
' Open that Chrome profile and make sure the OpenCLI extension is enabled.',
|
||||
);
|
||||
} else {
|
||||
const daemonVersion = health.status?.daemonVersion;
|
||||
const isStale = opts.cliVersion && (!daemonVersion || daemonVersion !== opts.cliVersion);
|
||||
if (isStale) {
|
||||
const reason = daemonVersion
|
||||
? `daemon v${daemonVersion} ≠ CLI v${opts.cliVersion}`
|
||||
: `daemon predates version reporting, CLI is v${opts.cliVersion}`;
|
||||
issues.push(
|
||||
`Stale daemon detected: ${reason}.\n` +
|
||||
'The daemon was started by an older CLI version and may have missed the extension registration.\n' +
|
||||
' Quick fix: opencli daemon stop && opencli doctor',
|
||||
);
|
||||
} else {
|
||||
issues.push(
|
||||
'Daemon is running but the Chrome/Chromium extension is not connected.\n' +
|
||||
'If the extension is already installed, try: opencli daemon stop && opencli doctor\n' +
|
||||
'If the extension is already installed, try: opencli daemon restart\n' +
|
||||
'If the extension is not installed:\n' +
|
||||
' 1. Download from https://github.com/jackwener/opencli/releases\n' +
|
||||
' 2. Open chrome://extensions/ → Enable Developer Mode\n' +
|
||||
' 3. Click "Load unpacked" → select the extension folder',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (extensionConnected && !extensionVersion) {
|
||||
issues.push(
|
||||
@@ -224,11 +220,15 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
|
||||
' Download from: https://github.com/jackwener/opencli/releases',
|
||||
);
|
||||
}
|
||||
if (adapterShadows.length > 0) {
|
||||
issues.push(formatAdapterShadowIssue(adapterShadows));
|
||||
}
|
||||
|
||||
return {
|
||||
cliVersion: opts.cliVersion,
|
||||
daemonRunning,
|
||||
daemonFlaky,
|
||||
daemonStale,
|
||||
daemonVersion: health.status?.daemonVersion,
|
||||
extensionConnected,
|
||||
extensionFlaky,
|
||||
@@ -237,6 +237,7 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
|
||||
connectivity,
|
||||
sessions,
|
||||
profiles,
|
||||
adapterShadows,
|
||||
issues,
|
||||
};
|
||||
}
|
||||
@@ -247,10 +248,16 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
|
||||
// Daemon status
|
||||
const daemonIcon = report.daemonFlaky
|
||||
? styleText('yellow', '[WARN]')
|
||||
: report.daemonRunning ? styleText('green', '[OK]') : styleText('red', '[MISSING]');
|
||||
: report.daemonStale
|
||||
? styleText('yellow', '[WARN]')
|
||||
: report.daemonRunning ? styleText('green', '[OK]') : styleText('red', '[MISSING]');
|
||||
const daemonLabel = report.daemonFlaky
|
||||
? 'unstable (running during live check, then stopped)'
|
||||
: report.daemonRunning ? `running on port ${DEFAULT_DAEMON_PORT}` + (report.daemonVersion ? ` (v${report.daemonVersion})` : '') : 'not running';
|
||||
: report.daemonRunning
|
||||
? `running on port ${DEFAULT_DAEMON_PORT} (${report.daemonStale
|
||||
? `${formatDaemonVersion(report)}, stale; CLI v${report.cliVersion ?? 'unknown'}`
|
||||
: formatDaemonVersion(report)})`
|
||||
: 'not running';
|
||||
lines.push(`${daemonIcon} Daemon: ${daemonLabel}`);
|
||||
|
||||
// Extension status
|
||||
@@ -329,6 +336,7 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
|
||||
}
|
||||
} else if (report.daemonRunning && report.extensionConnected) {
|
||||
lines.push('', styleText('green', 'Everything looks good!'));
|
||||
lines.push(styleText('dim', 'Tip: writing a new adapter? Run `opencli browser analyze <url>` for one-shot site recon.'));
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user