Compare commits

..

23 Commits

Author SHA1 Message Date
jakevin 1b19b3ebe9 chore: bump version to 1.7.11 (#1275)
Release / release (push) Has been cancelled
2026-05-03 19:35:22 +08:00
jakevin d60e1cf43d fix(browser): route type and keys through native input (#1274)
Fixes #1265 by routing browser type/keys through existing native CDP input primitives, with DOM fallbacks and direct CDPPage parity.
2026-05-03 19:32:31 +08:00
jakevin 1cd1253d46 feat(instagram): add collection-delete adapter
Pairs with the new collection-create adapter so users (and future
fixture-teardown logic) can clean up saved-post collections from CLI.

- POST /api/v1/collections/{id}/delete/ with multipart module_name=collection_settings
- Accepts collection name (case-insensitive) or numeric collection_id; resolves
  via /collections/list/ first so unknown / duplicate names error explicitly
  instead of bubbling up a 404 or silently deleting the wrong one.
2026-05-03 19:05:55 +08:00
jakevin 7869bdb2ca feat(browser): polish adapter author verify workflow 2026-05-03 19:03:38 +08:00
jakevin 2e93ac6e63 fix(release): build before manifest drift check (#1269) 2026-05-03 18:40:39 +08:00
jakevin de0d74bf62 fix(build-manifest): fail loud on import errors and refuse stale dist (#1268)
The previous implementation silently skipped any adapter whose import
failed (catch + warn-to-stderr + return []), then printed a successful
" Manifest compiled: N entries". When dist/ was stale (e.g. after
renaming an export the JS adapters re-import) every adapter using that
export would fail to load, get skipped, and the script still exited 0.
An agent reading exit codes to gate work would commit the resulting
manifest and silently delete dozens of unrelated adapter entries.

Three layers of defense:

1. Distinguish skip kinds. Files that don't call `cli(...)` are still
   silently dropped (helpers / type modules). Files that look like CLI
   modules but fail to import now throw `ManifestImportError`. The
   batch scanner aggregates failures and `main()` exits 1 with an
   explicit list, leaving the existing manifest on disk untouched.

2. Net-deletion safety net. `main()` diffs the new entries against the
   committed manifest and refuses to overwrite when entries would be
   removed. `--allow-removals=N` (or bare `--allow-removals` for any)
   is the explicit opt-in; the error message tells the caller exactly
   what value to pass.

3. Runtime dist guard. `node dist/src/build-manifest.js` now refuses
   to run with a clear pointer at `npm run build-manifest` (which uses
   tsx). The npm script itself is migrated to `tsx src/build-manifest.ts`
   so no project-level command points at the compiled copy anymore.

Release CI gains a manifest-drift gate (build-manifest + git diff
--exit-code) so a tag push can never publish stale or silently-shrunk
manifests. The existing CI check on PRs is preserved.

`ManifestEntry` is split into `src/manifest-types.ts` so runtime code
(discovery.ts) imports the type without pulling the build-time
compiler module.

Tests:
- `loadManifestEntries` throws ManifestImportError on import failure
- helper modules without cli() are still silently skipped
- `scanClisDir` aggregates per-adapter failures
- `diffRemovedEntries` returns expected site/name diff
- `parseBuildManifestArgs` reads --allow-removals[=N]
2026-05-03 18:27:23 +08:00
jakevin a9e0ca648f fix(extension): remove status-row left border accent (#1267)
WAWQAQ feedback: the green left border on the status row looked
disconnected — only on the top half of the card, creating an awkward
stub. Connection state is already conveyed clearly by the colored dot
and the "Connected to daemon" / "Disconnected" text, so the border was
redundant decoration.

Drop the .card.connected/.disconnected/.connecting border-left rules.
No JS or layout changes; cleaner surface, fewer visual variants.
2026-05-03 18:18:30 +08:00
jakevin bebc7aa35e chore: bump version to 1.7.10 (extension 1.0.4) (#1266)
Release / release (push) Has been cancelled
2026-05-03 18:00:35 +08:00
jakevin 061fba100d feat(extension): polish popup UI with merged card and copy contextId (#1262)
- Merge status row and profile row into a single rounded card with a
  brand-colored left border accent indicating connection state
- Render contextId inline next to a "Profile" label with a Copy button,
  letting users paste it into `opencli profile rename` without manual
  selection (replaces the old full-width code block treatment)
- Show daemon version inline in the status row when connected, and
  render the extension version as a tag in the popup header — both
  surface version information that helps diagnose stale-daemon issues
- Forward both versions through the existing `getStatus` background
  message: extension reads its own version from the manifest, daemon
  version is fetched best-effort from `/status` with a 1.5s timeout so
  popup never hangs when the daemon is unreachable
2026-05-03 17:37:51 +08:00
jakevin e364ec6b9c feat(browser): pass trace through verify (#1263) 2026-05-03 17:32:02 +08:00
jakevin 765eb56c99 feat(daemon): surface stale versions and restart (#1261) 2026-05-03 17:20:54 +08:00
jakevin 5f72770eff feat(instagram): add collection-create + collection filter for saved (#1192) (#1260)
Closes #1192. Two changes:

1. New `instagram collection-create <name>` adapter wraps
   `POST /api/v1/collections/create/` (multipart `name` +
   `module_name=collection_create`, X-IG-App-ID + X-CSRFToken).
2. `instagram saved` gains an optional `--collection <name>` flag.
   When set, the adapter resolves the name to a collection id via
   `/api/v1/collections/list/` (case-insensitive trim match) and then
   fetches `/api/v1/feed/collection/{id}/posts/`. Unknown names throw
   with the available list so callers can self-correct.

Both verified end-to-end against a live IG account. Verify fixtures
under ~/.opencli/sites/instagram/verify/ ship the
patterns/notEmpty/mustBeTruthy guards from the latest adapter-author
skill (success-rate-pitfalls §1, §4, §8).
2026-05-03 16:33:15 +08:00
jakevin 3017ca78aa chore: bump version to 1.7.9 (extension 1.0.3) (#1259)
Release / release (push) Has been cancelled
2026-05-03 15:46:48 +08:00
jakevin 7e68e19f0d feat(trace): prune retained artifacts (#1258) 2026-05-03 15:34:30 +08:00
jakevin 4ceb3314fe refactor(trace): retire diagnostic repair path (#1257)
* refactor(trace): retire diagnostic repair path

* chore(trace): clarify artifact summary guidance

* chore(trace): version trace receipt schema
2026-05-03 15:19:44 +08:00
Jack He 5f0cce7b22 feat(weibo): add favorites + publish CLI commands (#1253)
* feat(weibo): add favorites + publish CLI commands

Consolidates #1253 (favorites) and #1254 (publish) into a single PR per maintainer request.

- clis/weibo/favorites.ts: cookie-mode fetch of authenticated user's favorites via weibo.com/u/page/fav/{uid}
- clis/weibo/publish.js: UI-automation post (text up to 2000 chars, up to 9 images jpg/png/gif/webp)
- cli-manifest.json regenerated to include the new commands

Note: favorites.ts uses TypeScript syntax but build-manifest.js scans only *.js — favorites is currently NOT registered in the manifest. Reviewers please check whether to rename to .js or whether the manifest scanner should learn .ts.

Authored-by: hszhsz <heshaoz1990@gmail.com>

* fix(weibo): harden favorites and publish commands

* fix(weibo): publish without execute gate

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-03 15:00:38 +08:00
Benjamin Liu 284c96133b feat(claude): add Claude adapter (#1252)
* feat(claude): add Claude adapter

Adds a Claude (claude.ai) browser adapter family with seven commands
modeled on the existing clis/deepseek/ pattern: ask, send, new, status,
read, history, detail.

Closes #1251

* feat(claude): align send command columns with doubao

Match the established Status / SubmittedBy / InjectedText shape used by
doubao send so agent loops can rely on a consistent fire-and-forget
output across AI chat adapters.

* fix(claude): preserve DOM order in getVisibleMessages

The previous implementation queried user-message and assistant-message
nodes in two passes, which serialized as [u1, u2, u3, a1, a2, a3] for
multi-turn chats instead of the correct conversation order. Single
combined query preserves DOM order so claude read / detail return
turns in the order the user reads them on the page.

* docs(claude): note --live requirement for read across invocations

* fix(claude): fail fast on auth and empty states

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-03 14:55:41 +08:00
jakevin eac17b361e feat(observation): add runtime trace capture (#1255) 2026-05-03 14:38:59 +08:00
jakevin aa33262ef7 docs: narrow smart-search trigger description (#1248) 2026-05-02 16:51:17 +08:00
jakevin a0b2df1448 docs: refresh stale entry and developer docs (#1244) 2026-05-02 12:31:48 +08:00
jakevin fc7245f9f6 chore: enforce node 21 baseline (#1242) 2026-05-02 09:30:28 +08:00
jakevin 88bcd814ee refactor: simplify diagnostics and low-use errors (#1241) 2026-05-02 09:28:26 +08:00
jakevin 2fd7272559 docs: clarify opencli extension paths (#1240) 2026-05-02 09:27:17 +08:00
118 changed files with 7498 additions and 1489 deletions
+12 -1
View File
@@ -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 .
+2
View File
@@ -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.
+18 -6
View File
@@ -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
View File
@@ -10,7 +10,7 @@
OpenCLI 可以用同一套 CLI 做三类事情:
- **直接使用现成适配器**:B站、知乎、小红书、Twitter/X、Reddit、HackerNews 等 [90+ 站点](#内置命令) 开箱即用。
- **直接使用现成适配器**:B站、知乎、小红书、Twitter/X、Reddit、HackerNews 等 [100+ 站点](#内置命令) 开箱即用。
- **让 AI Agent 操作任意网站**:在你的 AI AgentClaude 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 AgentClaude 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 # 卸载
- 侦察站点,选定 patternSPA / 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
View File
@@ -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",
+3 -3
View File
@@ -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.
+2 -2
View File
@@ -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
View File
@@ -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}` }];
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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(`
+2 -2
View File
@@ -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 [
+128
View File
@@ -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 }];
},
});
+338
View File
@@ -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' }]);
});
});
+118
View File
@@ -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);
});
});
+29
View File
@@ -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}.`);
},
});
+31
View File
@@ -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);
},
});
+21
View File
@@ -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' }];
},
});
+24
View File
@@ -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.');
},
});
+41
View File
@@ -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,
}];
},
});
+24
View File
@@ -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,
}];
},
});
+440
View File
@@ -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';
}
+148
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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);
+57
View File
@@ -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,
}];
})()
` },
],
});
+91
View File
@@ -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
View File
@@ -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) => {
+2 -2
View File
@@ -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
+169
View File
@@ -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,
};
+114
View File
@@ -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');
});
});
+282
View File
@@ -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,
};
+183
View File
@@ -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
View File
@@ -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
View File
@@ -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', '闲鱼商品详情接口未返回有效数据');
+3 -3
View File
@@ -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' });
});
});
+71 -73
View File
@@ -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
```
+3
View File
@@ -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' },
],
},
+69
View File
@@ -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`
+21 -2
View File
@@ -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
+11
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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.
+6 -4
View File
@@ -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
View File
@@ -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.
+5 -4
View File
@@ -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
View File
@@ -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` 测试脚本
- 共享运行时的高风险模块
+130
View File
@@ -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.
+2 -1
View File
@@ -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)
+11 -1
View File
@@ -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
View File
@@ -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.
+130
View File
@@ -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。
+2 -1
View File
@@ -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)
+14 -1
View File
@@ -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
View File
@@ -25,7 +25,7 @@ features:
details: 复用 Chrome 登录态,凭证永远不会离开浏览器 — 无 token,无密码泄露。
- icon: 🤖
title: AI Agent 就绪
details: explore 发现 APIsynthesize 生成适配器,cascade 查找认证策略。为 AI 优先工作流而生
details: Browser 原语加上适配器编写 skill,让 AI Agent 可以稳定完成侦察、提取、验证和适配器落地
- icon: 💰
title: 零 LLM 成本
details: 运行时不消耗模型 token。跑 10,000 次也不花一分钱。
+21 -2
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "opencli-extension",
"version": "1.0.2",
"version": "1.0.4",
"private": true,
"opencli": {
"compatRange": ">=1.7.0"
+86 -49
View File
@@ -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
View File
@@ -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);
},
);
}
+32 -8
View File
@@ -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
View File
@@ -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",
+3 -3
View File
@@ -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 结构差分 |
| | 还推不出 | 先输出 rawadapter 跑起来再迭代 |
| 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 |
+61 -47
View File
@@ -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
+1 -1
View File
@@ -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.
+2 -3
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
---
name: smart-search
description: 基于 opencli 命令的智能搜索路由器。当用户想要搜索、查询、查找或研究信息时,尤其是涉及指定网站、社交媒体、技术资料、新闻、购物、旅游、求职、金融或中文内容时,务必使用此 skill
description: 基于 opencli 命令的智能搜索路由器。当用户想要使用 OpenCLI、CLI 或 API 搜索、查询、查找或研究信息时,尤其是涉及指定网站、社交媒体、技术资料、新闻、购物、旅游、求职、金融或中文内容时,务必使用此 skill
---
# 智能搜索路由器
+53
View File
@@ -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>');
});
});
+87
View File
@@ -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');
}
+35
View File
@@ -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');
});
});
+28
View File
@@ -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];
}
+79 -1
View File
@@ -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');
});
});
+84 -5
View File
@@ -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
View File
@@ -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;
+29
View File
@@ -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
View File
@@ -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 {
+86
View File
@@ -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 };
+15
View File
@@ -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';
}
+15 -3
View File
@@ -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';
})()
`;
+1
View File
@@ -31,6 +31,7 @@ export interface CachedNetworkEntry {
*/
body_truncated?: boolean;
body_full_size?: number;
timestamp?: number;
}
export interface NetworkCacheFile {
+1 -1
View File
@@ -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;
}
+76
View File
@@ -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.
+20 -1
View File
@@ -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();
});
});
+15
View File
@@ -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';
+96 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 ─────────────────────────────────────────────────────────
+85 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.');
}
}
-357
View File
@@ -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);
});
});
-360
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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