Compare commits

..

102 Commits

Author SHA1 Message Date
jackwener 34624adb18 refactor(instagram): remove UI fallback from post, simplify reel
- Remove ~1200 lines of UI automation from post.ts (executeUiInstagramPost
  and all its dependencies: composer, upload, caption, retry logic)
- post.ts now uses Private API exclusively (like story.ts/note.ts)
- Remove process.env.VITEST coupling in reel.ts; always use passed-in page
- Replace hardcoded /tmp debug paths with os.tmpdir() in reel.ts and
  protocol-capture.ts
- Update post.test.ts: remove UI automation tests, keep Private API and
  JS builder function tests (used by reel.ts)
2026-04-04 16:14:32 +08:00
jackwener 12fe89d12c fix(instagram): use JSON.stringify for constants in note evaluate string
Replace template literal interpolation of Node-side constants with
JSON.stringify() for consistency with codebase evaluate patterns.
Use bracket notation for dynamic property access instead of template
interpolation into a property chain.
2026-04-04 15:36:13 +08:00
Ray 4546a3d3e3 Merge upstream main into instagram-post 2026-04-03 17:45:06 +08:00
Ray c8c773197e Add Instagram note publishing command 2026-04-03 17:23:08 +08:00
jakevin f594e500a8 feat: AutoResearch framework + V2EX/Zhihu test suites (194/194) (#731)
* feat: AutoResearch framework + V2EX test suite (40 tasks)

AutoResearch framework (Karpathy-style autonomous iteration):
- engine.ts: 8-phase loop (review → modify → commit → verify → guard → decide → log)
- config.ts: typed config + CLI parser + metric extraction
- logger.ts: TSV append-only results log
- commands/run.ts: main loop spawning Claude Code per iteration
- commands/plan.ts: interactive config wizard
- commands/fix.ts: auto-detect broken state, iteratively fix
- commands/debug.ts: hypothesis-driven debugging for failing tasks

V2EX test suite (5 layers, 40 tasks):
- L1 Atomic (10): open, state, click, scroll, eval, back, wait
- L2 Single Page (10): hot topics, node list, topic meta, pagination
- L3 Multi-Step (10): click-read, navigate-node, tab-then-topic, pagination
- L4 Write Ops (5): reply typing, favorite detection, form detection
- L5 Complex Chain (5): cross-page collect, multi-node compare, full workflow

Presets: operate-reliability, skill-quality, v2ex-reliability

* test: V2EX test suite 60/60 — fix selectors, add harder tasks

- Fix v2ex-collect-hot-authors selector (pathname-based member link detection)
- Fix v2ex-wait-text judge (accept "appeared")
- Fix trailing commas in eval step strings
- Add 20 harder tasks: state+click interaction + long chain workflows
- Baseline: 60/60 across all layers

* feat: Zhihu test suite — 60 tasks across 8 layers, 60/60 passing

Knowledge-intensive Chinese Q&A site (React SPA, lazy loading, complex DOM):

- L1 Atomic (10): open, state, title, url, scroll, tab, back, wait, keys, screenshot
- L2 Feed (8): feed titles, hot list, metrics, tabs, authors, content types, avatar, search
- L3 Question (8): title, meta, answer, votes, buttons, descriptions, answer count
- L4 Navigation (8): hot→question, feed→question, author profile, search, topic, user, back
- L5 Write (6): upvote/follow/comment/bookmark/write-answer/share button detection
- L6 Chain (8): read-answer-author, author-profile, multi-hot, search-then-read, scroll-answers
- L7 Search (6): basic, people, topic, click-result, filter, back
- L8 Complex (6): full workflow, deep author chain, cross-question, search-read, 3-page, scroll-deep

Key fixes during development:
- Zhihu search page needs 5s+ wait (SPA lazy loading)
- Back navigation goes to about:blank (daemon init page), fixed with direct navigate
- User profile answers page needs 4s wait for content
- Broader selectors needed (h2 a instead of specific class names)

* feat: combined eval-all runner + combined-reliability preset

* experiment(operate): fix extract-npm-description + nav-click-link-example

Round 1: Fix 2 remaining browse-tasks failures:
- extract-npm-description: use generic <p> selector instead of class-based
- nav-click-link-example: include URL in output (title is 'Example Domains', not 'IANA')

* experiment(operate): fix bench-imdb-matrix — use broader selectors for year/rating

Round 2: IMDB page selectors were too specific (data-testid changed).
Use generic h1 for title, link text match for year, broader class match for rating.

* experiment(operate): add edge cases + fix SPA navigation timing

Round 3: Add 10 edge case tasks (5 V2EX + 5 Zhihu):
- rapid-navigate: 3 consecutive opens
- eval-after-click: verify URL changes after SPA click
- scroll-and-extract: extract after deep scroll
- structured extraction: multi-field JSON from dynamic content
- lazy-load answers: scroll triggers more content

Key finding: Zhihu SPA click() doesn't update location.pathname
immediately. Use window.location.href = a.href for reliable navigation.

V2EX: 65/65, Zhihu: 65/65, Browse: 59/59 = 189/189

* experiment(operate): add agent-style tasks using state+click+type (no eval for interaction)

Round 4-5: Add 5 tasks that test the actual agent workflow:
- agent-click-first-topic: find topic index via data-opencli-ref
- agent-type-search: type into search using state index
- agent-click-navigate-back: click by ref, verify navigation
- agent-state-has-interactive: verify state output format
- agent-state-after-scroll: verify scroll position in state

V2EX: 70/70 tasks

* fix: review fixes — extractVerdict, stderr, dead code

- eval-skill.ts: remove dead TASKS_FILE variable (skill-tasks.yaml never existed)
- eval-skill.ts: rewrite extractVerdict to use brace-counting JSON.parse
  instead of regex (handles escaped quotes in explanation)
- eval-browse.ts: include stderr in runCommand error output for debuggability
2026-04-03 17:14:38 +08:00
Ted Li f2a3ee6ee4 fix(doubao): preserve image URLs in read output (#708)
* fix doubao image urls in read output

* fix(doubao): derive image selector from messageTextSelectors

Hardcoded image selector only covered the first two text selectors,
so images inside class-based message containers would be missed.
Generate from the shared selector list for consistency.

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-03 17:08:33 +08:00
tiaot33 f377ec000c feat(元宝): add browser adapter and docs (#693)
* feat(yuanbao): add browser adapter and docs

* refactor(yuanbao): normalize adapter failures to CliError

* refactor: extract shared yuanbao helpers to reduce duplication

Move isOnYuanbao, ensureYuanbaoPage, hasLoginGate, authRequired,
and IS_VISIBLE_JS to shared.ts. This eliminates identical copies
across ask.ts and new.ts, reducing correctness risk when modifying
shared logic.

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-03 17:03:48 +08:00
jakevin 988908f348 refactor(xiaohongshu): replace blind retry with MutationObserver wait (#730)
* refactor(xiaohongshu): replace blind retry with MutationObserver wait

Instead of retrying the entire navigation when search results are empty,
use a MutationObserver to wait for `section.note-item` elements (or login
wall text) to appear in the DOM, with a 5s timeout. This is faster (resolves
as soon as content renders) and more correct (addresses the root cause of
delayed hydration rather than working around it with a full re-navigation).

* simplify: merge login-wall detection into MutationObserver wait

WAIT_FOR_CONTENT_JS now returns 'content', 'login_wall', or 'timeout'
instead of just true/false. This eliminates the separate login-wall
evaluate call and the redundant loginWall field in the extraction payload.
Two evaluate calls total (wait + extract) instead of three.
2026-04-03 16:40:33 +08:00
GanFanNewOrder 2b623b35b6 fix(xiaohongshu): retry once on intermittent empty first paint (#681)
Co-authored-by: 泽加武 <zejiawu@zejiawudeMac-mini.local>
2026-04-03 16:26:26 +08:00
jakevin 6cdcb9dd51 fix: add prepare script so source installs trigger build (#729)
* fix: add prepare script so source installs trigger build

npm install from git (e.g. npm install github:jackwener/opencli) skips
prepublishOnly, so dist/ is never generated. The prepare hook runs on
git-based installs; the [ -d src ] guard skips it for registry installs.

* fix: include extension/dist in git so clone works out of the box

.gitignore had conflicting rules: line 3 tried to un-ignore extension/dist/
but line 26 re-ignored it. Remove the later rule so the built extension JS
is tracked in git — users can load the extension directly after clone.
2026-04-03 16:23:05 +08:00
Ray 6e950a4201 Add Instagram story posting command 2026-04-03 14:59:26 +08:00
BruceLoveDecimal 835c146fb7 fix(doubao-app): connect to correct CDP target instead of background … (#674)
* fix(doubao-app): connect to correct CDP target instead of background page

Doubao desktop app exposes multiple CDP targets. The scoring logic picked
the background page (doubao-background) over the actual chat page because
its URL-as-title contained "doubao", boosting its score above the real
chat page (title "豆包"). This caused all commands (send, ask, read) to
fail with "No textarea found".

- Add `targetFilter` field to ElectronAppEntry for per-app preferred target
- Set doubao-app targetFilter to 'doubao-chat/chat'
- Penalize background/new-tab-page URLs and URL-like titles in scoring
- Thread cdpTargetFilter through execution → runtime → CDPBridge

Closes #634, closes #506

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(cdp): exclude background targets instead of targetFilter

Replace the targetFilter plumbing (4 files, new interface field) with
a single-line fix: exclude `background_page` and `service_worker`
type targets from CDP selection entirely.

Background pages should never be connection targets — they have no
visible DOM and all selectors will fail. This is the root cause of
#506/#634 (doubao-app connecting to empty background page).

Simpler fix: 1 line added vs 4 files modified. No new interface
fields, no per-app configuration needed.

---------

Co-authored-by: 刘启灏 <liuqihao@liuqihaodeMacBook-Pro.local>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-03 12:52:31 +08:00
jakevin fc818b3c2c fix: classify xianyu item auth and blocked states (#726)
* fix: classify xianyu item auth and blocked states

* fix: classify xianyu item auth and blocked states
2026-04-03 12:50:48 +08:00
BruceLoveDecimal 0ce46b15bb feat:add xianyu (#696)
* feat:add xianyu

feat:add xianyu

feat:add xianyu

* chore:add xianyu docs

* fix:update xianyu after review

---------

Co-authored-by: 刘启灏 <liuqihao@liuqihaodeMacBook-Pro.local>
2026-04-03 12:30:28 +08:00
jakevin 37f1b46a77 feat: AutoResearch framework + V2EX test suite (60 tasks, SKILL.md optimization) (#717)
* feat: AutoResearch framework + V2EX test suite (40 tasks)

AutoResearch framework (Karpathy-style autonomous iteration):
- engine.ts: 8-phase loop (review → modify → commit → verify → guard → decide → log)
- config.ts: typed config + CLI parser + metric extraction
- logger.ts: TSV append-only results log
- commands/run.ts: main loop spawning Claude Code per iteration
- commands/plan.ts: interactive config wizard
- commands/fix.ts: auto-detect broken state, iteratively fix
- commands/debug.ts: hypothesis-driven debugging for failing tasks

V2EX test suite (5 layers, 40 tasks):
- L1 Atomic (10): open, state, click, scroll, eval, back, wait
- L2 Single Page (10): hot topics, node list, topic meta, pagination
- L3 Multi-Step (10): click-read, navigate-node, tab-then-topic, pagination
- L4 Write Ops (5): reply typing, favorite detection, form detection
- L5 Complex Chain (5): cross-page collect, multi-node compare, full workflow

Presets: operate-reliability, skill-quality, v2ex-reliability

* test: V2EX test suite 60/60 — fix selectors, add harder tasks

- Fix v2ex-collect-hot-authors selector (pathname-based member link detection)
- Fix v2ex-wait-text judge (accept "appeared")
- Fix trailing commas in eval step strings
- Add 20 harder tasks: state+click interaction + long chain workflows
- Baseline: 60/60 across all layers

* docs: optimize SKILL.md for efficiency — aggressive chaining, minimize turns

- Add Rule #7: minimize total tool calls (3-5 per task, not 15-20)
- Strengthen Rule #5: chain aggressively with &&
- Add explicit good/bad chaining examples
- Add click+wait+state chaining pattern
- Add type+verify chaining pattern

Before: 21 turns for complex V2EX reply task
After: 12 turns for same task (-43% turns, -28% cost)
2026-04-03 11:31:22 +08:00
Ray f54401488a Unify Instagram post media input 2026-04-03 11:02:42 +08:00
Ray e22a312f50 Add Instagram mixed-media carousel posting 2026-04-03 09:54:46 +08:00
jakevin 2d005d14a8 fix: recover drifted tabs instead of abandoning them (#652) (#715)
When other Chrome extensions (tab managers, new-tab overrides) move
automation tabs to a different window, the Browser Bridge now attempts
to move the tab back to the automation window rather than creating a
new one. This preserves the existing page state and avoids redundant
navigation.

Changes:
- resolveTab(): when a provided tabId has drifted to another window but
  content is still debuggable, use chrome.tabs.move() to bring it back
- handleNavigate(): after navigation completes, detect if the tab drifted
  during navigation and move it back to the session window
- cdp.ts ensureAttached(): log final tab URL and windowId on attach
  failure for better diagnosis of extension conflicts

Closes #652 (partially — addresses tab drift recovery and diagnostics)
2026-04-03 03:48:13 +08:00
jakevin 1708626731 fix: update BrowserBridge test to mock fetchDaemonStatus instead of isDaemonRunning (#714)
PR #712 refactored _ensureDaemon to use a single fetchDaemonStatus() call
instead of separate isDaemonRunning(). The test was still mocking the old
function, causing it to fall through to the spawn-daemon path and throw
the wrong error message.
2026-04-03 03:44:56 +08:00
jakevin 5fe081b28c perf: optimize browser pipeline — tab query dedup, parallel stealth, incremental snapshots (#713)
* perf: optimize browser pipeline — tab query dedup, parallel stealth, incremental snapshots

- resolveTab() now returns { tabId, tab } so handleNavigate skips redundant chrome.tabs.get()
- goto() fires stealth injection in parallel with navigation instead of sequentially
- snapshot() passes previousHashes to enable incremental diff marking on consecutive calls

* revert: remove stealth parallelization — simplicity over performance
2026-04-03 03:32:24 +08:00
jakevin 0c75ab3f7e perf: reduce round-trips in browser command hot path (#712)
1. eval retry delay: 1000ms → 200ms for SPA navigation errors, 500ms
   for debugger detach. SPA navigations recover within ~100ms, the old
   1000ms delay was unnecessarily long.

2. Window creation: replace fixed 200ms sleep with tab-load poll.
   Listens for chrome.tabs.onUpdated status=complete with 500ms
   fallback cap. about:blank loads in ~20ms, saving ~180ms.

3. bridge.ts _ensureDaemon: single fetchDaemonStatus() call instead of
   two sequential calls (isExtensionConnected + isDaemonRunning both
   called fetchDaemonStatus independently). Saves one HTTP round-trip.

4. goto() post-navigation: coalesce stealth injection + DOM settle into
   a single exec call. Previously two sequential round-trips
   (Node→daemon→WS→extension→CDP each). Saves ~60-160ms per goto().
2026-04-03 03:24:54 +08:00
NullCode 017cbc5692 docs: add rubysec plugin example (#699)
Co-authored-by: NullCode <20016311+nullptrKey@users.noreply.github.com>
2026-04-03 02:59:50 +08:00
jakevin cd5da59187 perf: skip blank page on first browser command (#710)
Two changes that eliminate the about:blank → target-domain navigation
on first command execution:

1. Extension: getAutomationWindow() accepts an optional initialUrl.
   When creating a new window, uses the target URL directly instead
   of about:blank. handleNavigate() passes cmd.url through so the
   window starts on the correct domain.

2. CLI: Remove isAlreadyOnDomain() check before pre-nav. Instead,
   always call page.goto(preNavUrl) — the extension's handleNavigate
   already has a fast-path that skips navigation when the tab is
   already at the target URL. This avoids an extra exec round-trip
   (getCurrentUrl eval) on first command.

Net effect: first command saves ~1-3s (one fewer page load),
subsequent commands behave the same (navigate fast-path handles
domain matching efficiently via chrome.tabs.get).
2026-04-03 02:59:15 +08:00
jakevin d7d5211fde refactor: remove unused newTab() and closeTab() from IPage interface (#709)
Both methods had zero production callers — only test mocks referenced them.
newTab() created about:blank pages via CDP Target.createTarget, but no
adapter or pipeline step ever invoked it. closeTab() was similarly unused.

selectTab() and tabs() are kept as they have active production usage
(e.g. doubao adapter). The scoreTarget about:blank penalty is retained
as a defensive measure against user-opened blank tabs.
2026-04-03 02:42:03 +08:00
jakevin de817730ca feat: Browser Use best practices — click/type/state improvements (#707)
* docs: improve operate skill with Browser Use best practices

- Add Critical Rules section (state over screenshot, verify with get value)
- Add Command Cost Guide (free/instant vs expensive vision tokens)
- Add Action Chaining Rules (safe to chain vs page-changing)
- Add Tips section
- Fix Core Workflow to use state/get value for verification, not screenshot
- Mark screenshot as "ONLY for user deliverables"

Inspired by Browser Use's design: DOM-first state representation,
action cost awareness, and multi-action chaining patterns.

* docs: fix operate skill — eval read-only, IIFE, interaction rules

- Add rule: NEVER use eval to click/type — use click/type/select commands
  (eval bypasses scrollIntoView + CDP pipeline, fails on off-screen elements)
- Add rule: eval is read-only, always wrap in IIFE to avoid variable conflicts
- Reorder Critical Rules for priority
- Add IIFE example in Extract section

Root cause: Claude Code was using eval("el.click()") instead of
click <index>, and hitting "already declared" errors from repeated
eval calls in the same page context.

* feat: Browser Use best practices — click/type/state improvements

Inspired by deep analysis of Browser Use's design patterns:

1. Framework listener detection (React/Vue/Angular)
   - Detect __reactProps$ onClick, Vue _vei, Angular ng-reflect-click
   - Catches <div onClick> elements that pure ARIA/tag heuristics miss

2. Click CDP fallback
   - clickJs() now returns coordinates on failure
   - BasePage.click() falls back to CDP Input.dispatchMouseEvent
   - Page.clickWithQuads() uses DOM.getContentQuads for inline elements

3. Type improvements
   - React-compatible: use native HTMLInputElement.prototype.value setter
   - Contenteditable: selectAll + execCommand('insertText') for rich editors
   - Autocomplete: detect role=combobox, wait 400ms for dropdown suggestions

4. getContentQuads precise click
   - Page.clickWithQuads() for multi-line inline elements (e.g. wrapped <a>)
   - Falls back through getContentQuads → getBoxModel → JS click

* fix: address code review — injection, silent failure, setter prototype

1. clickWithQuads: escape ref with JSON.stringify before inserting into
   JS strings and CSS selectors (injection risk)
2. base-page click: throw error when both JS click and CDP fallback fail
   instead of silently succeeding
3. typeTextJs: use matching prototype for native setter
   (HTMLTextAreaElement for textarea, HTMLInputElement for input)
2026-04-03 02:38:49 +08:00
Flo 9cdbcd3066 docs: add opencli-plugin-vk to plugins list (#350) 2026-04-03 01:49:33 +08:00
jakevin b113885dde docs: remove Why opencli, merge advantages into Highlights, add operate quickstart (CN) (#706)
* docs: remove Why opencli section, merge advantages into Highlights, add operate quickstart to CN README

- Remove "Why opencli?" / "为什么选 opencli?" sections from both READMEs
- Incorporate Zero LLM cost, Deterministic, Broad coverage bullets into Highlights
- Add operate command mention to AI Agent ready highlight
- Add browser automation / operate quickstart section to README.zh-CN.md (mirrors English README)

* docs: update Built for AI Agents paragraph, add browser automation and website→CLI to Highlights, remove Dual-Engine

- Rewrite "Built for AI Agents" to emphasize operate skill + browser control + crystallizing into CLIs
- Add "Browser Automation" and "Website → CLI" bullets to Highlights (both EN and CN)
- Remove "Dual-Engine Architecture" bullet from EN Highlights
- Remove "动态加载引擎" from CN Highlights (already covered by other bullets)

* docs: remove human quickstart from operate section, AI-only
2026-04-03 01:46:48 +08:00
tiaot33 ef449058aa docs(skills): add smart-search skill (#689)
* docs(skills): add smart-search skill

* docs(skill): tighten smart-search routing rules
2026-04-03 01:46:05 +08:00
jakevin 706e01dbca docs: fix outdated adapter counts, missing commands and adapters (#704)
* docs: fix outdated adapter counts, missing commands, and absent adapters

- Update version 1.6.0 → 1.6.1 in skills/opencli-usage/SKILL.md
- Update site count 70+ → 73+ across README.md, README.zh-CN.md,
  docs/comparison.md
- Remove non-existent adapters (kimi, deepseek, qwen) from SKILL.md
- Add missing commands for xiaohongshu (+note, comments, download,
  publish), weibo (+search, feed, user, me, post, comments),
  jike (+post, topic, user), linux-do (+hot, latest, category),
  doubao (+detail, history, meeting-summary, meeting-transcript),
  weread (+notebooks), chatgpt (+model), wikipedia (+random, trending),
  stackoverflow (+unanswered), producthunt (fix command list)
- Add entirely missing adapters: band, zsxq, bluesky, douyin, 36kr,
  ones, tieba, gemini, notebooklm, imdb, spotify, paperreview
- Update docs/adapters/index.md with same fixes
- Add opencli-operate to Related Skills section

* docs: second-pass audit fixes — deeper inconsistencies

Skills sub-files (browser.md, public-api.md):
- Remove phantom kimi/deepseek/qwen adapters (no src/clis/ dirs)
- Replace with real gemini and notebooklm sections
- Add missing weibo commands (search, feed, user, me, post, comments)
- Add missing xiaohongshu commands (note, comments, download, publish)
- Add missing doubao commands (detail, history, meeting-summary, meeting-transcript)
- Add 7 entirely missing adapter sections: bluesky, douyin, band, zsxq,
  tieba, 36kr, ones
- Fix producthunt: remove non-existent week/month/search, add hot/browse/posts
- Add wikipedia random and trending

SKILL.md command table:
- Add twitter `likes`, xueqiu `comments`, douban `movie-hot`/`book-hot`
- Add entirely missing `amazon` adapter
- Add linux-do `latest`
- Remove producthunt non-existent `search`

Individual adapter docs:
- docs/adapters/browser/weibo.md: add 5 missing commands
- docs/adapters/browser/doubao.md: add 4 missing commands
- docs/adapters/browser/wikipedia.md: add random and trending
- docs/adapters/browser/36kr.md: fix contradictory prerequisites
- docs/adapters/index.md: add twitter `likes`
- docs/developer/contributing.md: add missing `positional: true`
- package.json: fix description to include "Electron App"
2026-04-03 01:05:47 +08:00
Ray d50cfe345f Add Instagram reel posting command 2026-04-03 00:55:26 +08:00
jakevin ba67a3e086 docs: add individual skill install examples to README (#702)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
Root SKILL.md was already removed in #703. Add per-skill install
commands to both EN and CN READMEs (without --full-depth since
root SKILL.md no longer blocks sub-skill discovery).
2026-04-03 00:23:56 +08:00
jakevin ed1a61a445 chore: remove root SKILL.md, simplify README skill install (#703)
Root SKILL.md is redundant — skills/ directory (opencli-operate,
opencli-explorer, opencli-oneshot, opencli-usage) handles discovery.
Simplified README to single install command.
2026-04-03 00:12:31 +08:00
jakevin fe82b3882f docs: update outdated adapter counts, operate commands, and skill references (#701)
- Update adapter count from 50+/60+/66+ to 70+ across all docs (actual: 74 sites)
- Add missing operate commands (eval, network, init, verify) to README
- Add opencli-operate skill to Install AI Skills section in both READMEs
- Replace outdated "Playwright MCP Bridge" with "Browser Bridge" in doubao docs
2026-04-02 23:32:36 +08:00
Ray c7d8678c11 Retry transient Instagram private setup failures 2026-04-02 23:11:49 +08:00
Ray 6aad5522f1 Add dynamic Instagram posting routes 2026-04-02 22:59:20 +08:00
jakevin 4d036a5364 chore: release v1.6.1 (#700) 2026-04-02 22:45:41 +08:00
jakevin a23de8fe7d fix: sync package-lock.json version to 1.6.0 (#698)
The v1.6.0 release commit bumped package.json but not package-lock.json,
causing bun/npm install failures due to version mismatch.
2026-04-02 22:39:09 +08:00
sline b8f1abc3a1 fix(twitter): use search input for SPA navigation instead of pushState (#695)
* fix(twitter): add search input fallback for intermittent SPA navigation failures

The pushState + popstate approach works in most environments but fails
intermittently for some users (see #690), likely due to Twitter A/B
tests or timing race conditions where the pathname hasn't updated when
checked.

This commit adds a fallback strategy: when pushState fails after 2
retries, we type the query into the search input on /explore and press
Enter. This triggers Twitter's own form handler, performing SPA
navigation without a full page reload (keeping the fetch interceptor
alive).

Both strategies use selector-based waiting ([data-testid="primaryColumn"])
rather than fixed delays, with graceful fallthrough on timeout.

Fixes #690

* test(twitter): update search test for fallback evaluate call

The search input fallback adds one extra evaluate() call when pushState
fails. Update the mock chain and assertion count accordingly.

* fix(twitter): guard nativeSetter and add fallback success test

- Add optional chaining on getOwnPropertyDescriptor().set to handle
  edge cases where Twitter's sandbox overrides the HTMLInputElement
  prototype.
- Add test case covering the full fallback path: pushState fails twice,
  search input fallback succeeds, results are returned correctly.

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-02 22:27:48 +08:00
jakevin aa3edfefc0 chore: release v1.6.0 (#697)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
2026-04-02 22:14:23 +08:00
jakevin b657c946a2 fix(skills): add YAML frontmatter for discovery and improve descriptions (#694)
* fix(skills): add YAML frontmatter for discovery and improve descriptions

- opencli-explorer: add missing frontmatter with name, description, tags
- opencli-oneshot: add missing frontmatter with name, description, tags
- opencli-usage: rewrite description to start with "Use when..." and
  include specific platform names for better keyword matching

Root cause of low trigger rate: explorer and oneshot had no frontmatter
at all, making them invisible to AI agent skill discovery. Usage had a
generic description without triggering conditions.

* fix(skills): add capability index, cross-skill links, and plugins entry

- Add "Quick Lookup by Capability" table so agents can find platforms
  by what they need (search, trending, feed, AI chat, finance, etc.)
- Add plugins.md entry to main index (was completely hidden)
- Add "Related Skills" section linking to opencli-explorer and
  opencli-oneshot for adapter development
- Compress platform listings for scannability

* fix(skills): inline compact command quick-reference table in SKILL.md

Add a self-contained command reference table directly in SKILL.md so
agents that can only read the main skill file still have full command
visibility. Each platform gets one row with all available commands.
Organized into Browser/Desktop/Public API/Management sections.
2026-04-02 22:03:39 +08:00
jakevin bb137ce901 feat: add opencli operate — browser control commands for Claude Code skill (#614)
Add `opencli operate` subcommand group with 15+ commands for
step-by-step browser control, designed as a Claude Code skill.
No LLM API key needed — Claude Code IS the LLM.

Commands:
  Navigation: open, back, scroll
  Inspect: state, screenshot, get (title/url/text/value/html/attributes)
  Interact: click, type, select, keys
  Wait: wait selector/text/time
  Extract: eval (execute JS in page context)
  API Discovery: network (auto-captured since last open, --detail N)
  Sedimentation: init (generate adapter scaffold), verify (test adapter)
  Session: close

Infrastructure:
  - CDP passthrough with 22-method allowlist
  - Two-layer retry for extension interference (aggressive for operate:*)
  - Network interceptor auto-injected on operate open
  - node_modules symlink for user TS adapter imports

Skill: skills/opencli-operate/SKILL.md
  - Complete command reference
  - Sedimentation workflow guide (explore → network → init → verify)
  - Adapter strategy guide (PUBLIC/COOKIE/UI)
  - Dual quickstart (AI Agent 1 step / Human 3 steps)
2026-04-02 19:30:35 +08:00
gucasbrg 7d7203891f fix(twitter): resolve article ID to tweet ID before GraphQL query (#688)
* fix(twitter): resolve article ID to tweet ID before GraphQL query

Article URLs (x.com/i/article/{articleId}) use a different ID than
tweet status URLs. The GraphQL TweetResultByRestId endpoint requires
the parent tweet ID, not the article ID.

Fix: navigate to the article page first, extract the associated tweet
ID from DOM links, then use that for the GraphQL query.

Fixes article fetching returning "Article not found" for all article URLs.

* fix: distinguish article URLs from status URLs, add explicit error handling

The previous commit routed all inputs through the article page, breaking
status URL and bare ID flows. Now only article URLs trigger the
article→tweet ID resolution. Status URLs and bare IDs keep the original
behavior. Also throws an explicit error if resolution fails instead of
silently falling back to the article ID.

---------

Co-authored-by: buruguo <buruguo@lambdafintech.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-02 18:33:51 +08:00
AstroHan 081efe37f7 fix(xiaohongshu): clarify empty note shell hint (#686)
* fix(xiaohongshu): clarify empty note shell hint

* fix(xiaohongshu): simplify empty shell detection to title+author check

The 7-field conjunction was overly strict — a note that rendered only
placeholder metrics but no title/author was still a valid empty shell.
Since title and author are always present on real notes, checking just
those two fields is a more reliable and simpler signal.

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-02 18:33:36 +08:00
jakevin 777b882040 refactor: centralize daemon transport client (#692) 2026-04-02 18:28:50 +08:00
luo jiyin eead9e0aa5 docs: add tab completion to getting started guides (#658)
* docs: add tab completion to getting started guide

* docs: add tab completion to zh getting started guide
2026-04-02 16:11:54 +08:00
Ray e8c27d8da5 Refine Instagram post flow 2026-04-02 15:35:45 +08:00
jakevin a21cc5e9f0 chore: release v1.5.9 (#678)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
2026-04-02 13:49:10 +08:00
fii6 abd46bccba feat(gemini): add Gemini web adapter with minimal output (#619)
* feat(gemini): add web adapter with minimal output

* fix(gemini): use defaultFormat for minimal output

* fix(gemini): preserve full transcript responses

* docs(gemini): add browser adapter guide

* review: wire gemini into adapter indexes

---------

Co-authored-by: fii6 <246637913+fii6@users.noreply.github.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-02 13:39:35 +08:00
jakevin 098c7f4f92 feat: create skills/ directory structure (#670)
* feat: create skills/ directory structure per issue #605

- Create skills/opencli-usage/ with index and categorized command references
  - SKILL.md: main index with installation and prerequisites
  - browser.md: all browser-based commands (Bilibili, Twitter, Reddit, etc.)
  - desktop.md: desktop adapter commands (Cursor, Codex, Notion, etc.)
  - public-api.md: public API commands (HackerNews, V2EX, arXiv, etc.)
  - plugins.md: management commands, AI workflow, output formats
- Create skills/opencli-explorer/ from CLI-EXPLORER.md
- Create skills/opencli-oneshot/ from CLI-ONESHOT.md

Addresses #605 - enables skill-based discovery and selective installation

* fix: complete browser.md with all missing adapters and fix incorrect entries

- Added 15 missing browser adapters: Reuters, SMZDM, Ctrip, Barchart,
  Jike, Linux.do, WeRead, Jimeng, Pixiv, Web, Weixin, JD, LinkedIn,
  Sina Finance, Bloomberg (browser)
- Fixed incomplete entries: Facebook (added 5 missing commands),
  Coupang (corrected to match actual CLI), Yollomi (restored all 12
  commands), Doubao Web (restored send/read commands), Grok (fixed format)
- Added missing public APIs: StackOverflow, Xiaoyuzhou, Wikipedia
- Updated SKILL.md index to list all supported platforms across all
  categories including desktop adapters

* refactor: remove root SKILL.md, migrate Record Workflow to opencli-explorer

- Moved Record Workflow documentation (工作原理, 使用步骤, 页面类型表,
  候选 YAML→TS 转换, 故障排查) into skills/opencli-explorer/SKILL.md
- Deleted root SKILL.md — all content now lives under skills/

* docs: add AI skills installation guide to README

Add npx skills add instructions for all 3 skills (opencli-usage,
opencli-explorer, opencli-oneshot) to both README.md and README.zh-CN.md.
2026-04-02 13:38:27 +08:00
GanFanNewOrder d721eb6c6c feat(amazon): add browser adapter and docs (#659)
* feat(amazon): add browser adapter and docs

* review: wire amazon into discovery docs

---------

Co-authored-by: 泽加武 <zejiawu@zejiawudeMac-mini.local>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-02 13:32:23 +08:00
Ray a8fbb637cb Add draft Instagram posting flow 2026-04-02 01:44:09 +08:00
ajia1206 341bb87e09 test(xiaohongshu): redact creator fixture data (#647) 2026-04-02 01:21:53 +08:00
AstroHan 127dc3edea feat: add minimal record write candidates (#665) 2026-04-02 01:21:11 +08:00
jakevin f88e7569e9 refactor: src cleanup — deduplicate errors, cache VM, extract BasePage, remove Playwright MCP legacy (#667)
* refactor: deduplicate transient error checks, cache VM contexts, expose tab ID

- Extract shared isTransientBrowserError() into browser/errors.ts, replacing
  duplicated string-matching lists in daemon-client.ts and pipeline/executor.ts
- Cache compiled vm.Script objects in template.ts with LRU eviction (max 256),
  avoiding per-invocation VM context creation in pipeline loops
- Add getActiveTabId() to IPage interface and Page class for tab state inspection

* refactor: extract BasePage to deduplicate DOM helpers across Page and CDPPage

Both Page (daemon-backed) and CDPPage (direct CDP) had ~200 lines of
identical DOM helper implementations (click, type, scroll, wait, snapshot,
interceptor, etc). Extract shared logic into abstract BasePage class.
Subclasses now only implement transport-specific methods.

* refactor: rename mcp.ts to bridge.ts and clean up Playwright MCP references

The file browser/mcp.ts contained BrowserBridge (daemon session manager),
not MCP functionality. Renamed to bridge.ts for clarity. Also removed all
stale "Playwright MCP" references from comments and variable names across
the codebase — Playwright was removed long ago.
2026-04-02 00:51:15 +08:00
jakevin 9c2a777d11 chore: remove .agents directory (#668)
- Remove redundant .agents/skills and .agents/workflows
- Content already covered in CLI-EXPLORER.md and SKILL.md
2026-04-02 00:27:59 +08:00
jakevin 773178345d refactor: remove bind-current, restore owned-only browser automation model (#664)
* fix(notebooklm): remove bind-current workflow

* fix: relax notebook ID check in open.ts and clean up idle timeout test

- open.ts: only throw when page kind is not 'notebook'; log a warning
  instead of throwing when the notebook ID doesn't match exactly
- background.test.ts: remove unused tabs[1] setup in idle timeout test
  that was leftover from borrowed-session era

* build: rebuild extension dist after bind-current removal
2026-04-01 23:23:48 +08:00
jakevin 1ddca55b4a chore: release v1.5.8 (#663)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
2026-04-01 22:12:39 +08:00
小左同学 4818871309 Handle foreign extension embeds before debugger attach (#662)
* Handle foreign extension embeds before debugger attach

* fix(extension): recover owned tabs without mutating borrowed tabs

* fix(extension): avoid adopting unrelated tabs

* Revert "fix(extension): avoid adopting unrelated tabs"

This reverts commit 2cba0c19daa032a60a8b878ffd9875f64551a2fc.

* Revert "fix(extension): recover owned tabs without mutating borrowed tabs"

This reverts commit 69dfadedae78c6c878984f1ff43f144d79e81187.

* fix(extension): avoid mutating tabs before attach

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-01 22:10:30 +08:00
jakevin c908d9cd47 chore: release v1.5.7 (#654)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
2026-04-01 13:09:11 +08:00
jakevin 9b425c7550 feat: Electron auto-launcher — zero-config CDP connection (#653)
* docs: add dingtalk and wecom CLI to external CLI hub

Add dingtalk-workspace-cli and wecom-cli as external CLI integrations
alongside lark-cli, gh, docker, etc.

* feat: add confirmPrompt() to TUI module

* feat: add Electron app registry with builtin + user-defined apps

* feat: add Electron app launcher with auto-detect and restart

* fix: launcher uses processName for path discovery, platform-guard tests

* feat: integrate Electron auto-launcher into execution pipeline

- CDPBridge.connect() accepts cdpEndpoint parameter instead of requiring env var
- getBrowserFactory() selects CDPBridge for registered Electron apps by site name
- executeCommand() calls resolveElectronEndpoint() for Electron apps, skips daemon check
- Remove requiredEnv/OPENCLI_CDP_ENDPOINT from all chatwise commands
- Remove chatwise-opencli.ps1 wrapper script and chatwise/shared.ts
- Update antigravity/serve.ts to use launcher instead of manual env var
- Replace hardcoded app names in scoreCDPTarget with registry lookup
- Fix Discord bundleId typo (com.iscord.app → com.discord.app)

* fix: resolve review issues — port collision and registry completeness

- Change ChatGPT CDP port from 9224 to 9236 (was colliding with Antigravity)
- scoreCDPTarget now uses full registry (builtin + user-defined) via getAllElectronApps()
- Use displayName (falling back to processName) for target score boosting

* fix: assign unique CDP ports — antigravity 9234, chatgpt 9236

Both were sharing port 9224, which could cause silent mis-connection.
2026-04-01 12:49:52 +08:00
reabiter 12443f049e enhance(v2ex): add content, member, created, node fields to topic output (#648)
- Add content field to display topic body text
- Add member field to show topic author
- Add created field to show topic creation timestamp
- Add node field to show topic category
- Add id field for consistency with hot/latest commands

This makes v2ex topic command return meaningful details that are
not available in hot/latest listings.
2026-04-01 02:10:28 +08:00
Jack Lee ee0c2b65ea feat(youtube): add search filters — --type shorts/video/channel, --upload, --sort (#616)
* feat(youtube): add --type shorts/video/channel, --upload, --sort filters

Uses YouTube's native sp= filter params. Shorts = type 9 (sp=EgIQCQ).
Also parses reelItemRenderer for Shorts results.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(youtube): add published time to search results

Shows when video was uploaded (e.g. "8h ago", "4d ago", "3mo ago").

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(youtube): prevent duplicate sp= params and remove redundant Shorts URL rewrite

- YouTube only supports one sp= parameter; using multiple causes
  unpredictable behavior. Pick the most specific filter with priority:
  type > upload > sort.
- Remove the post-processing Shorts URL rewrite — the reelItemRenderer
  branch already generates /shorts/ URLs directly.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-01 01:32:08 +08:00
warkcod 01057527f5 fix(bilibili): distinguish login-gated subtitles from empty results (#645)
* fix(bilibili): distinguish login-gated subtitles from empty results

* fix(test): use single toSatisfy assertion instead of double rejects.toThrow

Awaiting the same rejected promise twice is unreliable. Combine the
AuthRequiredError type check and message regex into one toSatisfy call.

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-01 00:59:19 +08:00
reabiter 67fb022f16 fix(v2ex): add id field to hot and latest API responses (#646)
* fix(v2ex): add id field to hot and latest API responses

- Add id field to hot.yaml and latest.yaml pipeline output
- Enables downstream commands like 'v2ex topic <id>' to work seamlessly
- Fixes issue where v2ex hot/latest JSON output lacked topic IDs

* enhance(v2ex): add node and url fields to hot/latest output

In addition to the id field, include node name (板块) and topic URL
for richer output. All fields come from the existing API response.

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-04-01 00:25:57 +08:00
jakevin 7ea3f6d3fb feat(stealth): harden CDP debugger detection countermeasures (#644)
Add 6 new anti-detection patches to stealth.ts and CDP-level debugger
statement neutralization to reduce risk of bot detection on platforms
like Xiaohongshu.

New patches:
- Shared toString disguise via WeakMap (undetectable by anti-bot scripts)
- Anti-debugger statement trap (Function/eval patching + CDP Debugger.setBreakpointsActive)
- Console method fingerprinting defense (re-wrap CDP-bound console methods)
- Window dimension detection defense (outerWidth/outerHeight normalization)
- Performance API entry filtering (remove debugger/devtools entries)
- document.$cdc_ property cleanup (backup for window-level cleanup)
- Iframe contentWindow.chrome consistency
2026-03-31 23:34:37 +08:00
ajia1206 818ae61889 fix(douyin): support current creator api response shapes (#618) 2026-03-31 23:01:13 +08:00
jakevin 811e4f5c3e fix(douyin): narrow getDraftCommand return type to fix TS2722 (#643) 2026-03-31 22:59:23 +08:00
AstroHan fea47abcec fix(douyin): repair creator draft flow (#640)
* fix(douyin): handle creator payload shapes

* refactor(douyin): drive draft via creator page

* fix(douyin): save resumable draft session

* fix(douyin): harden draft cover flow

* fix(douyin): wait for stable cover state

* fix(douyin): wait for cover detection result

* fix(douyin): require cover state transition

* fix(douyin): scope cover checks to quick panel

* fix(douyin): narrow quick-check state match

* fix(douyin): drop ambiguous quick-check match

* fix(douyin): test quick-check panel extraction

* fix(douyin): cover busy state extraction

* refactor(douyin): clean up draft tests — temp file cleanup, reduce boilerplate

- Add afterAll cleanup for temp dirs (fixes temp file leak)
- Extract createTempVideo/createTempCover/getDraftCommand helpers
- Remove repeated registry lookup + mkdtempSync boilerplate from each test

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-31 22:37:35 +08:00
AstroHan 519b3cfe85 fix: avoid in-page redirect in facebook search (#642)
* fix(facebook): split search navigation from extraction

* refactor: use settleMs instead of waitUntil:none + wait:4

Replace `waitUntil: none` + separate `wait: 4` step with `settleMs: 4000`
on the navigate step. This is consistent with other Facebook adapters
(feed.yaml, memories.yaml, profile.yaml) and lets the navigate step
handle the timing in one place.

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-31 22:26:07 +08:00
jakevin 57534cf8b3 feat(daemon): replace 5min idle timeout with long-lived daemon model (#641)
* docs: add daemon lifecycle redesign spec

Replace the aggressive 5-minute idle timeout with a long-lived daemon
model that stays running for hours, reducing restart overhead during
development cycles.

* docs: add daemon lifecycle redesign implementation plan

8-task TDD plan for replacing aggressive 5-minute idle timeout with
long-lived daemon model (4h default, dual-condition exit).

* feat(daemon): add DEFAULT_DAEMON_IDLE_TIMEOUT constant (4 hours)

* feat(daemon): replace fixed 5min timeout with dual-condition idle manager (4h default)

* feat(extension): reduce WS reconnect backoff cap from 60s to 5s

* feat(daemon): improve CLI connection-waiting UX with progress messages and 200ms polling

* feat(daemon): add opencli daemon status/stop/restart commands

* test(daemon): add tests for daemon status/stop commands

* fix(daemon): address code review issues — stale constant, restart robustness, timer cleanup, test coverage

* docs: update daemon documentation for new lifecycle and CLI commands

- troubleshooting.md: replace manual curl/pkill with `opencli daemon status/stop/restart`
- browser-bridge.md (en/zh): add Daemon Lifecycle section
- README.md: add `opencli daemon status` to Quick Start
- README.zh-CN.md: add daemon management commands to tips
2026-03-31 22:17:54 +08:00
jakevin 62fde40aab fix(docs): use relative links in adapter index (#629)
VitePress base is /docs/, so absolute links like /adapters/browser/twitter
resolve incorrectly. Changed all links to relative paths (./browser/...,
./desktop/...) so they work correctly on the docs site.
2026-03-31 14:39:15 +08:00
muqiao215 0204dbb018 feat(notebooklm): add read commands and compatibility layer (#622)
* feat(notebooklm): add read commands and compatibility layer

* review: trim notebooklm artifacts and sync docs

---------

Co-authored-by: qiaoqiao147 <camtup044@gmail.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-31 13:19:47 +08:00
fii6 1858eace6e feat(instagram): add media download command (#623)
* feat(instagram): add media download command

* feat(instagram): add media download command

* fix(instagram): align download command with platform conventions

---------

Co-authored-by: fii6 <246637913+fii6@users.noreply.github.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-31 13:04:31 +08:00
Kagura 0fbeb2d77f fix(substack): update selectors for Substack DOM redesign (fixes #621) (#624)
* fix(substack): update selectors for Substack DOM redesign (fixes #621)

Substack replaced <article> elements with role="article" divs and a
new SPA-based feed. The wait() selector 'article' no longer matches,
causing 'Selector not found: article' on feed and publication commands.

- loadSubstackFeed: use 'a[href*="/p/"]' (matches actual post links)
- loadSubstackArchive: use '[role="article"]' (Substack's new ARIA roles)

The evaluate() scraping logic inside both functions is unchanged since
it already uses 'a' href pattern matching, not article tags.

* review: align substack wait selectors with scraper

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-31 12:58:22 +08:00
AstroHan 33ca81b785 fix(weread): recover book details from cached shelf fallback (#628) 2026-03-31 12:47:52 +08:00
GanFanNewOrder 4c3cd3878e fix(ctrip): update search adapter to live endpoint (#627)
* fix(ctrip): update search adapter to live endpoint

* review: make ctrip search a public fetch adapter

---------

Co-authored-by: 泽加武 <zejiawu@zejiawudeMac-mini.local>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-31 12:47:48 +08:00
dependabot[bot] 5f541ea42b chore(deps): bump vitest from 4.1.1 to 4.1.2 (#620)
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.1.1 to 4.1.2.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.2/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 4.1.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-31 12:43:16 +08:00
geegewu ca2165cbc7 fix(xiaohongshu): support full URL/short link and fix video extraction (#615)
* fix(xiaohongshu): support full URL/short link and fix video extraction

Two issues fixed:

1. URL handling: The download command only accepted bare note IDs and
   constructed `explore/{noteId}` URLs, which lack the `xsec_token`
   parameter now required by Xiaohongshu. This made all video/image
   downloads fail with "No media found". Now accepts full URLs
   (with xsec_token) and short links (xhslink.com) in addition to
   bare note IDs.

2. Video extraction: XHS video player uses blob: URLs in DOM, which
   cannot be downloaded via HTTP. Now extracts real video URLs from
   `window.__INITIAL_STATE__` (SSR data) and inline script JSON
   before falling back to DOM selectors, skipping blob: URLs.

Tested with a video note via short link — successfully downloaded
21.5 MB MP4.

* review: resolve xiaohongshu note id after redirects

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-31 00:32:08 +08:00
ahahaha d0803857f1 feat(xiaohongshu): add note command and nested reply support for comments (#599)
* feat(xiaohongshu): add note command and nested reply support for comments

Add `xiaohongshu note` command to read full note content (title, author,
description, engagement metrics, tags) from public note pages.

Enhance `xiaohongshu comments` with `--with-replies` flag to extract
nested replies (楼中楼), including reply_to attribution and per-reply
like counts. Limit logic counts only top-level comments so replies
are included for free.

Extract shared `parseNoteId` into side-effect-free `note-helpers.ts`
to avoid cross-module command registration leakage.

Normalize non-numeric engagement placeholders ("赞"/"收藏"/"评论")
to "0" for zero-count notes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs(xiaohongshu): add note and comments --with-replies to adapter docs

Update xiaohongshu adapter documentation and README command table
to reflect the new note command and enhanced comments with nested
reply support.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs(xiaohongshu): fix download example to show both note-id and url

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(xiaohongshu): expand nested reply threads before scraping

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-30 23:29:30 +08:00
Kagura 701d9e859f fix(xiaohongshu): check login wall before autoScroll in search (fixes #597) (#608)
- Add early login-wall detection before autoScroll() in search.ts
  to prevent crash when XHS shows a login gate instead of results
- Add document.body null guard in autoScrollJs (dom-helpers.ts)
- Update search.test.ts: verify autoScroll is not called on login wall
- Add autoScrollJs null-body defense test in dom-helpers.test.ts
2026-03-30 23:12:02 +08:00
AstroHan 59d41f28e1 fix(zhihu): stop question command failing on unused detail fetch (#606)
* fix(zhihu): stop question command failing on unused detail fetch

* review: harden zhihu question fetch path

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-30 23:10:53 +08:00
jakevin fcdc15d385 fix: improve weixin article download extraction (#612) 2026-03-30 23:01:44 +08:00
jakevin 030adc9341 fix: restore root SKILL.md (#609) 2026-03-30 21:30:37 +08:00
jakevin 62e3c55993 chore(release): 1.5.6 (#596)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
2026-03-30 13:27:19 +08:00
bhutano 8e37f66e53 fix(spotify): follow-up fixes for token refresh, null guards and credentials guidance (#591)
* fix(spotify): fix token refresh, null guards, env parse, missing credentials guidance, postinstall template

* fix(spotify): restore credential guardrails

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-30 13:03:25 +08:00
jakevin 770c28301a docs: add dingtalk and wecom CLI to external CLI hub (#594)
* docs: add dingtalk and wecom CLI to external CLI hub

Add dingtalk-workspace-cli and wecom-cli as external CLI integrations
alongside lark-cli, gh, docker, etc.

* feat: register dingtalk and wecom as external CLIs

Add dws (DingTalk Workspace CLI) and wecom-cli to
external-clis.yaml so they are discoverable via opencli list
and auto-installable.
2026-03-30 12:49:36 +08:00
AstroHan cf79ec5c23 feat(xueqiu): add comments command (#587)
* feat: add xueqiu comments command

* docs(xueqiu): add comments command docs

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-30 01:22:44 +08:00
Zhangchen 8c00ad9f02 feat(browser): add ONES adapter support for tasks and worklog commands (#386)
* feat(browser): add ONES adapter support for tasks and worklog commands
Add ONES auth/session commands, task listing/details utilities, and worklog operations, with related docs and helper utilities.

* fix(ones): harden worklog and task-list adapter behavior

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-30 01:06:38 +08:00
Inori333 3eb2e88c85 fix: normalize boolean arg aliases (#585) 2026-03-30 00:44:45 +08:00
Haoyue Bai b280f19321 feat(youtube): mute and pause watch pages for read commands (#578)
* Mute and pause YouTube watch pages for read commands

* fix(youtube): quiet watch pages earlier

* refactor(youtube): avoid watch ui for read commands

* test(youtube): cover html bootstrap parser

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-29 23:22:53 +08:00
AstroHan a32b65be4a feat: add Tieba browser adapters in TypeScript (#581)
* feat(tieba): add browser adapters for hot posts search and read

* fix(tieba): stabilize search and e2e coverage

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-29 22:39:26 +08:00
Ron ab7eca35e4 feat(doubao): add history, detail, meeting-summary (#566)
* feat(doubao): add history, detail, meeting-summary and meeting-transcript commands

- history: list conversation history from sidebar
- detail: read a specific conversation by ID, with meeting card detection
- meeting-summary: extract summary and AI chapters from meeting minutes
- meeting-transcript: read or download meeting transcript via browser

Made-with: Cursor

* docs: update doubao command list in adapter index and README.zh-CN

Made-with: Cursor

* fix(doubao): handle meeting-only detail and merge transcript snapshots

* refactor(doubao): model conversation ids as first-class output

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-29 22:36:24 +08:00
jakevin 107ed28449 refactor(douyin): share user video public api (#580) 2026-03-29 17:50:46 +08:00
Howard 79b4e069f0 feat(douyin): add user-videos command with top comments (#554)
* feat(douyin): add user-videos command with top-10 comments

Adds a new adapter for fetching a public user's video list by sec_uid,
alongside the top-10 hottest comments for each video.

- Navigates to the user's profile page to establish a cookie session
- Fetches video list via /aweme/v1/web/aweme/post/
- Concurrently fetches top-10 comments per video via
  /aweme/v1/web/comment/list/ (sorted by hotness, API default)

Output columns: index, aweme_id, title, duration, digg_count,
                play_url, top_comments

* refactor(douyin): replace Object.assign with spread in user-videos

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(douyin): validate user-videos inputs

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-29 17:45:11 +08:00
PXLZJ d2b563e55b feat(xiaohongshu): add cover image URL to user notes output (#572)
* feat(xiaohongshu): add cover image URL to user notes output

Extract cover image URL from noteCard.cover.urlDefault in
__INITIAL_STATE__ and include it in the user command output columns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(xiaohongshu): cover user note rows

* refactor(xiaohongshu): keep cover out of default columns

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-29 17:40:33 +08:00
jakevin f8e9b08223 fix(zsxq): require active group context (#579)
* fix(zsxq): require active group context

* docs(zsxq): add adapter guide
2026-03-29 17:29:36 +08:00
bhutano 1ae1c82c4a feat(spotify): add Spotify playback adapter (#560)
* feat(spotify): add Spotify playback adapter

Adds a new adapter for controlling Spotify via the official Web API.
Uses Strategy.PUBLIC with OAuth2 — no browser session required.

Commands: auth, status, play, pause, next, prev, volume, search, queue, shuffle, repeat.
Credentials are loaded from ~/.opencli/spotify.env or environment variables.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(spotify): rename index.ts → spotify.ts and fix CliError calls

- Renamed src/clis/spotify/index.ts to spotify.ts so the build-manifest
  picks it up (index.js is intentionally excluded from manifest scanning)
- Fixed 4 CliError calls: constructor now requires (code, message, hint?)
  so each throw now passes an appropriate error code as first argument

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(spotify): fix token refresh corruption, env parse, null guards, validation

- refreshAccessToken: check res.ok before parsing; construct Tokens object
  directly instead of mutating loadTokens() result to avoid writing
  undefined/NaN on Spotify error responses; preserve existing refresh_token
  when Spotify omits it from the response
- loadEnv: split on first '=' only so values containing '=' are preserved
- SCOPES: remove write/library/top scopes not used by any command
- status: guard against data.item being null (active device but no track)
- volume: validate 0-100 range before API call
- auth: check tokenRes.ok on initial token exchange; add server.on('error')
  handler for EADDRINUSE; add 5-minute timeout with clearTimeout on close

* feat(postinstall): auto-create ~/.opencli/spotify.env template on install

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(spotify): guard null progress, podcast items, missing tracks data, corrupted tokens, invalid search limit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(spotify): improve missing credentials error with step-by-step guidance

* fix(spotify): harden setup and add docs coverage

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-29 17:28:37 +08:00
康厚超 440c001a20 feat(band): add Band.us adapter — bands, posts, mentions, post commands (#532)
* feat(band): add bands, posts, and mentions commands for band.us

- bands: lists all Bands via get_band_list_with_filter intercept
- posts: lists posts from a Band via get_posts_and_announcements intercept
- mentions: shows @mention notifications via get_news intercept

All use Strategy.INTERCEPT since band.us API requires an HMAC md header
generated by its own JS. SPA navigation to /band/{no}/post triggers the
band list and posts APIs; bell + @メンション tab click triggers mentions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(band): clean up all three band adapters

- Fix doc comments: Band uses XHR not fetch; clarify INTERCEPT rationale
- bands: replace for-loop with flatMap; explain why band page nav is needed
- posts: remove item.post ?? item fallback (API always wraps in post); rename
  finalRequests → requests for consistency; extract stripBandTags helper
- mentions: remove redundant ?? defaults (args have defaults defined); fix
  unreadOnly bug (was not applied to post/comment modes); consolidate Band tag
  stripping to single regex; cast kwargs types directly instead of converting;
  add comments explaining last-response strategy and 'referred' filter flag

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(band/posts): handle mixed post/announcement items from API

get_posts_and_announcements returns both regular posts and announcements
that have different shapes — some lack post_no and wrap differently.
Restore item.post ?? item fallback and filter out items with no resolvable
identifier to prevent undefined in URLs and empty rows in output.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(band): add post command — full post export with comments and photo download

Exports the complete content of a single Band post:
- Post body (with Band markup tags stripped)
- All comments in chronological order
- Photo URLs shown inline, or downloaded with --output <dir>

Uses Strategy.INTERCEPT with a broad 'band.us' pattern to capture both the
batch request (embedding get_post) and get_comments in one SPA navigation.
Responses are identified client-side by shape: batch_result array vs items
array with comment_id fields.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(band): replace XHR interception with direct DOM extraction

- bands, posts, post: navigate directly to target URL instead of home→SPA detour
- All three switch from Strategy.INTERCEPT to Strategy.COOKIE with navigateBefore: false
  (bands uses framework pre-nav to home; posts/post disable it and goto target directly)
- DOM extraction polls for specific content elements rather than fixed waits
- post: confirm selectors via browser inspection (a.text, time.time, .sCommentList,
  .sReplyList for nested replies); add --comments flag to skip comment fetch
- posts: extract from rendered post list DOM; correct comment item selector (div.cComment)
- Fix: post empty-result guard changed from && to handle null data safely
- Fix: photo download now checks HTTP status code before piping to avoid writing
  redirect HTML into image files
- Fix: mentions unread client-side filter skipped for 'mentioned' mode since
  server already filtered via 未確認のみ button click

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(band): address code review feedback

- post: replace manual http/https download with shared downloadMedia utility
  (handles redirects, timeouts, stream errors correctly)
- post: fix photo URL resolution to use location.href as base, handling
  protocol-relative and relative URLs without throwing
- post: switch to node:-prefixed imports per repo convention
- post/posts: remove redundant ArgumentError guards — framework already
  validates required args before func() is called
- mentions: INTERCEPT strategy is intentional (Band HMAC prevents DOM-only
  approach for notifications; update PR description to clarify)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(band): address second round of code review feedback

- bands: tighten href selector to /band/{id}(?:/post)?$ so feed/post-detail
  links are excluded; only sidebar navigation links match
- mentions: replace fixed page.wait(2) sleeps with polling on
  getInterceptedRequests() — waits up to 8 s per action, exits as soon
  as the expected number of captures arrives (avoids flakiness on slow XHR)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(band): fix selector bugs found during testing

- bands: use a.bandCover._link + p.uriText + span.member em selectors
  (previous a[href*="/band/"] + .bandName combo leaked "メンバー" text)
- posts: use article.cContentsCard._postMainWrap + span.count selectors
  (previous li._postListItem selector matched nothing; DOM changed)
- mentions: fix page.wait(500) → page.wait(0.5) (was waiting 500s not ms);
  use timestamp-suffixed URL to force fresh page load each run so the
  notification panel is closed; fix get_news vs get_news_count capture
  ambiguity with result_data.news check; replace cumulative waitForCaptures
  with waitForOneCapture (getInterceptedRequests clears array on each call)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(band/mentions): use CSS class selector for bell button instead of locale-dependent text match

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(band): address third round of code review feedback

- post: pass browser cookies to downloadMedia so Band's login-protected
  photo URLs don't fail with 401/403
- post: include photos.length in empty-result guard so photo-only posts
  are not falsely reported as not found
- mentions: accumulate captures across poll iterations so get_news_count
  responses don't cause early exit before the real get_news arrives
- mentions: update docstring to match actual implementation (client-side
  filtering, no tab-click)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(band): address fourth round of code review feedback

- mentions: fail fast with a clear error when bell button is not found,
  instead of silently no-op and waiting 8s before EmptyResultError
- post: use shared formatCookieHeader() instead of manual cookie string
  construction

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(band): address fifth round of code review feedback

- mentions: replace fixed page.wait(2) with polling for bell button
  readiness (up to 10s), eliminating the fixed sleep and fail-fast
  when the selector is missing
- mentions: add explicit !newsReq guard with a clear error message when
  get_news capture times out, instead of falling through to a misleading
  "No notifications found"
- posts: skip posts with no permalink href instead of emitting a bogus
  'https://www.band.us' URL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(band): address sixth round of code review feedback

- post: only send Band cookies to *.band.us photo URLs; third-party CDN
  URLs are downloaded without cookies to avoid cross-domain cookie leakage
- bands: strip non-digit chars before parseInt so member counts like
  "1,234" parse correctly
- posts: same fix for comment counts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(band): address seventh round of code review feedback

- posts: check limit before push so --limit 0 returns empty result
- post: indent replies proportionally by depth ('  '.repeat(depth))
  so multi-level threads remain readable in table output

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(band/bands): anchor href regex to prevent matching post-detail URLs

Pattern now requires /band/{id} or /band/{id}/post (with optional trailing
slash) so deeper paths like /band/{id}/post/{postNo} are excluded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(band): address ninth round of code review feedback

- mentions: guard bell click with a boolean return so a disappearing
  element throws a clear EmptyResultError instead of a raw TypeError
- post: wait for comment list container instead of first .cComment so
  posts with zero comments don't incur a fixed 6s delay

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(band): use page.getCookies() for login detection across all commands

Replaces document.cookie.includes('band_session') with
page.getCookies({ domain: 'band.us' }) so login detection works even
if Band.us marks the session cookie as HttpOnly in the future.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(band): address eleventh round of code review feedback

- mentions: replace EmptyResultError with SelectorError for missing/
  disappeared bell button — produces a clearer SELECTOR error code
- post: assign per-photo filenames using a global index across both
  download batches so band-hosted and CDN photos don't overwrite each other

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(band): address twelfth round of code review feedback

- post: derive file extension from URL path and include in filename
  (e.g. photo_1.jpg) so downloaded photos have correct extensions
- posts: remove dead code guard (!url && !content) — url is always
  non-empty here since href-empty posts are already skipped above

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(band/post): use url-scoped getCookies for photo download auth

Domain-scoped getCookies may omit host-only cookies scoped to www.band.us;
using url: 'https://www.band.us' ensures all relevant cookies are included
in the auth header for Band-hosted photo downloads.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(band): add adapter documentation and sidebar entry

Required by CI doc-check --strict: every adapter in src/clis/ must have
a corresponding docs/adapters/browser/*.md file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(e2e): wire band auth coverage into default matrix

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-29 17:19:26 +08:00
James 5925849414 feat(xiaohongshu): use CDP DOM.setFileInputFiles for image upload (#574)
* feat(xiaohongshu): use CDP DOM.setFileInputFiles for image upload

Replace base64 DataTransfer injection with CDP DOM.setFileInputFiles,
which lets Chrome read image files directly from the local filesystem.
This eliminates payload size limits that caused "fetch failed" errors
when uploading large images (>500KB) through the browser bridge.

Changes:
- Add 'set-file-input' action to protocol, extension handler, and CDP executor
- Add Page.setFileInput() method for CLI-side usage
- Rewrite publish image upload to use CDP path, with base64 fallback
  for older extension versions that don't support the new action
- Add clear warning when falling back to base64 with large payloads

Closes #542 (partially — image upload reliability)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: cover cdp file input upload path

* fix: keep image upload on image-only inputs

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-29 17:16:55 +08:00
xtftbwvfp d8d9643e89 feat: add 知识星球(zsxq) site adapter (#571)
* feat: add 知识星球(zsxq) site adapter

Add cookie-based adapter for 知识星球 (zsxq.com) with 5 commands:
- groups: list joined groups
- topics: list topics in current group
- topic: get single topic detail with comments
- search: search topics within a group
- dynamics: latest cross-group activity feed

Uses XHR over Chrome extension (Strategy.COOKIE) to call
https://api.zsxq.com/v2/ APIs with credential forwarding.

* fix(zsxq): map missing topics to not found

* refactor(zsxq): preserve detail response semantics

---------

Co-authored-by: xiaojian <xiaojian@xiaojiandeMac-mini.local>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-29 17:15:27 +08:00
AstroHan bb5c2b1fc6 fix(weread): harden reader fallback and search mapping (#562)
* fix(weread): harden reader fallback and search mapping

* fix(ci): remove stale weread regression test duplicates

* refactor(weread): simplify search fetch and eliminate redundant getCookies

- Parallelize search API + HTML fetch with Promise.all
- Add generic numeric entity decoding (decimal + hex) in decodeHtmlText
- Extract loadWebShelfSnapshotWithVid to pass currentVid downstream,
  avoiding a redundant getCookies call in waitForTrustedWebShelfSnapshot
- Split mixed early-return conditions with individual comments
- Add mirror comments between browser/Node trusted-index logic
2026-03-29 17:02:02 +08:00
jakevin f44fcd512b docs: sync docs with codebase (v1.5.5, exit codes, hub table, new adapters) (#575)
- SKILL.md: version 1.4.1 → 1.5.5
- README.md: remove non-existent gws from CLI Hub table; bump adapter
  count to 66+; add Exit Codes section (sysexits.h table + usage example)
- README.zh-CN.md: replace readwise/gws (not in external-clis.yaml) with
  lark-cli/vercel; add bluesky and douyin to built-in commands table;
  add 退出码 section matching English README; add 66+ adapter count line
2026-03-29 15:49:22 +08:00
357 changed files with 40809 additions and 3603 deletions
@@ -1,249 +0,0 @@
---
name: cross-project-adapter-migration
description: "Cross-project CLI command migration workflow for opencli. Use when importing commands from external CLI projects (python/node) like rdt-cli, twitter-cli, etc. Covers: source analysis → gap matrix → batch migration → README/SKILL.md update."
---
# Cross-Project Adapter Migration
> 从外部 CLI 项目(Python/Node/Go 等)批量迁移命令到 opencli 的标准化流程。
## When to Use
- 用户说"把 xxx-cli 的命令迁移过来"
- 用户说"看看 xxx 项目有什么可以借鉴的"
- 用户说"对齐 xxx-cli 的功能"
- 在为新平台扩展 opencli 时,发现已有第三方 CLI 工具
## Prerequisites
- 熟悉 [CLI-EXPLORER.md](../../../CLI-EXPLORER.md)adapter 开发决策树)
- 熟悉 [SKILL.md](../../../SKILL.md)(命令参考 & 模板)
---
## Phase 1: 源项目分析
### 1.1 克隆 & 理解源项目
```bash
# 克隆源项目到 /tmp 做分析
git clone <source_repo_url> /tmp/<source-cli>
```
分析重点:
- **命令列表**:找到所有可用命令(查看 CLI 入口文件、help 输出或 README
- **认证方式**CookieAPI KeyOAuth?浏览器自动化?
- **数据源**:公开 APIGraphQL?页面抓取?
- **输出字段**:每个命令返回哪些数据字段
### 1.2 生成命令清单
列出源项目所有命令,包括:
| 命令 | 类型 | API/方法 | 输出字段 |
|------|------|---------|---------|
| `xxx feed` | Read | `GET /api/feed` | title, author, time |
| `xxx post` | Write | `POST /api/tweet` | status, id |
---
## Phase 2: 功能对比矩阵
### 2.1 查看 opencli 现有命令
```bash
ls src/clis/<site>/ # 查看已有适配器
opencli list | grep <site> # 确认已注册命令
```
### 2.2 生成对比矩阵
对每个源项目命令,标注三种状态:
| 功能 | 源项目 | opencli 现有 | 行动 |
|------|--------|-------------|------|
| feed | ✅ `xxx feed` | ❌ 无 | ✅ **新增** |
| search | ✅ `xxx search` | ✅ `search.ts` | ❌ 已有,跳过 |
| hot | ✅ `xxx hot` | ⚠️ `hot.yaml`(不完整) | ✅ **增强** |
| like | ✅ `xxx like` | ✅ `like.ts` | ❌ 已有,跳过 |
### 2.3 筛选迁移目标
去掉已有的、低价值的,保留高价值缺失命令,按 Read/Write 分类:
**筛选原则**
- ✅ 高使用频率的命令优先
- ✅ 已有但不完整的命令标记为"增强"
- ❌ 源项目特有但 opencli 架构不支持的功能(如需要持久化存储的)跳过
- ❌ 与现有功能完全重复的跳过
---
## Phase 3: 批量实现
> [!IMPORTANT]
> 实现前必须查阅 [CLI-EXPLORER.md](../../../CLI-EXPLORER.md) 确认策略选择。
### 3.1 选择实现方式
基于决策树分类:
| 类别 | 方式 | 适用条件 |
|------|------|---------|
| **Read + 简单 API** | YAML pipeline | 纯 fetch/select/map,无复杂 JS |
| **Read + GraphQL/分页/签名** | TypeScript adapter | 需要 JS 逻辑 |
| **Write 操作** | TypeScript + `Strategy.UI` | 点击/输入等 DOM 操作 |
| **Write + API** | TypeScript + `Strategy.COOKIE/HEADER` | 直接 POST API |
### 3.2 实现顺序
**先 Read 后 Write,先 YAML 后 TS**
1. **Phase A**: YAML Read 适配器(最快,通常每个 10-20 行)
2. **Phase B**: TS Read 适配器(需要 evaluate/intercept 的)
3. **Phase C**: TS Write 适配器(需 UI 自动化或 POST API
### 3.3 实现模板
#### YAML Read 适配器模板(Cookie 策略)
```yaml
site: <site>
name: <command>
description: <描述>
domain: www.<site>.com
strategy: cookie
browser: true
args:
limit:
type: int
default: 20
pipeline:
- navigate: https://www.<site>.com
- evaluate: |
(async () => {
const res = await fetch('<api_endpoint>', { credentials: 'include' });
const d = await res.json();
return (d.data?.items || []).map(item => ({
title: item.title,
// ... map source fields
}));
})()
- map:
rank: ${{ index + 1 }}
title: ${{ item.title }}
- limit: ${{ args.limit }}
columns: [rank, title]
```
#### TS Write 适配器模板(UI 策略)
```typescript
import { cli, Strategy } from '../../registry.js';
cli({
site: '<site>',
name: '<command>',
description: '<描述>',
strategy: Strategy.UI,
args: [{ name: 'target', required: true, help: '<参数说明>' }],
columns: ['status', 'message'],
func: async (page, kwargs) => {
await page.goto(`https://www.<site>.com/${kwargs.target}`);
await page.wait({ text: '<expected_text>', timeout: 10 });
// 获取 snapshot 找到目标按钮
const snapshot = await page.accessibility.snapshot();
// 点击按钮 ...
return [{ status: 'success', message: '<action> completed' }];
},
});
```
### 3.4 公共模式复用
迁移过程中如果发现多个适配器共享逻辑,考虑提取到 `src/clis/<site>/utils.ts` 工具文件:
```typescript
// src/clis/<site>/utils.ts
export async function fetchWithAuth(page, url) { ... }
export function parseItem(raw) { ... }
```
---
## Phase 4: 验证 & 发布
### 4.1 构建验证
```bash
npx tsc --noEmit # TypeScript 编译检查
opencli list | grep <site> # 确认所有命令已注册
```
### 4.2 运行验证(关键!)
每个新命令必须实际运行:
```bash
# Read 命令
opencli <site> <command> --limit 3 -f json
opencli <site> <command> --limit 3 -v # verbose 查看 pipeline
# Write 命令(谨慎!会实际操作)
opencli <site> <command> <test_target>
```
### 4.3 更新文档
迁移完成后必须更新以下文件:
1. **README.md** — 在对应平台区域添加新命令示例
2. **SKILL.md** — 在 Commands Reference 中添加新命令
### 4.4 提交 & 推送
```bash
git add -A
git commit -m "feat(<site>): migrate <N> commands from <source-cli>
- Phase A: <N> YAML adapters (read operations)
- Phase B: <N> TS adapters (write operations)
- Source: <source_repo_url>"
git push
```
---
## Checklist
- [ ] 源项目命令清单已生成
- [ ] 对比矩阵已确认,高价值缺失命令已筛选
- [ ] 用户确认迁移范围
- [ ] Phase A: YAML Read 适配器已完成
- [ ] Phase B: TS Read 适配器已完成
- [ ] Phase C: TS Write 适配器已完成
- [ ] `npx tsc --noEmit` 编译通过
- [ ] 所有新命令已实际运行验证
- [ ] README.md 已更新
- [ ] SKILL.md 已更新
- [ ] 已 commit + push
## 实战案例参考
### rdt-cli → opencli Reddit2026-03-16
- **源项目**: `rdt-cli`25 个 Python 命令)
- **筛选结果**: 13 个高价值命令
- **实现**: 7 个 YAMLread + 6 个 TSwrite
- **产出**: +11 文件,+767 行代码,Reddit 适配器从 4 → 15+275%
### twitter-cli → opencli Twitter2026-03-16
- **源项目**: `twitter-cli`20+ Python 命令)
- **筛选结果**: 11 个待实现
- **策略**: Read 用 `Strategy.COOKIE` + GraphQL fetchWrite 用 `Strategy.UI`
@@ -1,54 +0,0 @@
---
description: Migrate commands from an external CLI project into opencli adapters
---
// turbo-all
## Steps
1. Clone the source CLI project for analysis:
```bash
git clone <source_repo_url> /tmp/<source-cli>
```
2. Analyze source project: list all commands, auth method, API endpoints, and output fields.
3. Check existing opencli adapters for the target site:
```bash
ls src/clis/<site>/
opencli list | grep <site>
```
4. Generate a comparison matrix table (source commands vs opencli existing). Mark each as: ✅ **New** / ✅ **Enhance** / ❌ **Skip**. Ask user to confirm which commands to migrate.
5. Implement YAML Read adapters first (highest ROI, 10-20 lines each). Place files in `src/clis/<site>/<name>.yaml`.
6. Implement TS Read adapters for complex cases (GraphQL, pagination, signing). Place files in `src/clis/<site>/<name>.ts`.
7. Implement TS Write adapters using `Strategy.UI` or `Strategy.COOKIE`. Place files in `src/clis/<site>/<name>.ts`.
8. Verify build:
```bash
npx tsc --noEmit
```
9. Verify all commands are registered:
```bash
opencli list | grep <site>
```
10. Run each new command to verify it works:
```bash
opencli <site> <command> --limit 3 -f json
```
11. Update README.md with new command examples in the appropriate platform section.
12. Update SKILL.md Commands Reference with new commands.
13. Commit and push:
```bash
git add -A
git commit -m "feat(<site>): migrate <N> commands from <source-cli>"
git push
```
+1
View File
@@ -22,3 +22,4 @@ docs/.vitepress/cache
# Database files
*.db
autoresearch/results/
+84
View File
@@ -1,5 +1,89 @@
# Changelog
## [1.6.1](https://github.com/jackwener/opencli/compare/v1.6.0...v1.6.1) (2026-04-02)
### Bug Fixes
* sync package-lock.json version with package.json ([#698](https://github.com/jackwener/opencli/issues/698))
## [1.6.0](https://github.com/jackwener/opencli/compare/v1.5.9...v1.6.0) (2026-04-02)
### Features
* **opencli-operate:** add browser control commands for Claude Code skill ([#614](https://github.com/jackwener/opencli/issues/614))
* **docs:** add tab completion to getting started guides ([#658](https://github.com/jackwener/opencli/issues/658))
### Bug Fixes
* **twitter:** resolve article ID to tweet ID before GraphQL query ([#688](https://github.com/jackwener/opencli/issues/688))
* **xiaohongshu:** clarify empty note shell hint ([#686](https://github.com/jackwener/opencli/issues/686))
* **skills:** add YAML frontmatter for discovery and improve descriptions ([#694](https://github.com/jackwener/opencli/issues/694))
### Refactoring
* centralize daemon transport client ([#692](https://github.com/jackwener/opencli/issues/692))
## [1.5.9](https://github.com/jackwener/opencli/compare/v1.5.8...v1.5.9) (2026-04-02)
### Features
* **amazon:** add browser adapter — bestsellers, search, product, offer, discussion ([#659](https://github.com/jackwener/opencli/issues/659))
* **skills:** create skills/ directory structure with opencli-usage, opencli-explorer, opencli-oneshot ([#670](https://github.com/jackwener/opencli/issues/670))
* **record:** add minimal record write candidates ([#665](https://github.com/jackwener/opencli/issues/665))
### Refactoring
* src cleanup — deduplicate errors, cache VM, extract BasePage, remove Playwright MCP legacy ([#667](https://github.com/jackwener/opencli/issues/667))
* remove bind-current, restore owned-only browser automation model ([#664](https://github.com/jackwener/opencli/issues/664))
### Chores
* remove .agents directory ([#668](https://github.com/jackwener/opencli/issues/668))
## [1.5.8](https://github.com/jackwener/opencli/compare/v1.5.7...v1.5.8) (2026-04-01)
### Bug Fixes
* **extension:** avoid mutating healthy tabs before debugger attach and add regression coverage ([#662](https://github.com/jackwener/opencli/issues/662))
## [1.5.7](https://github.com/jackwener/opencli/compare/v1.5.6...v1.5.7) (2026-04-01)
### Features
* **daemon:** replace 5min idle timeout with long-lived daemon model (4h default, dual-condition exit) ([#641](https://github.com/jackwener/opencli/issues/641))
* **daemon:** add `opencli daemon status/stop/restart` CLI commands ([#641](https://github.com/jackwener/opencli/issues/641))
* **youtube:** add search filters — `--type` shorts/video/channel, `--upload`, `--sort` ([#616](https://github.com/jackwener/opencli/issues/616))
* **notebooklm:** add read commands and compatibility layer ([#622](https://github.com/jackwener/opencli/issues/622))
* **instagram:** add media download command ([#623](https://github.com/jackwener/opencli/issues/623))
* **stealth:** harden CDP debugger detection countermeasures ([#644](https://github.com/jackwener/opencli/issues/644))
* **v2ex:** add id, node, url, content, member fields to topic output ([#646](https://github.com/jackwener/opencli/issues/646), [#648](https://github.com/jackwener/opencli/issues/648))
* **electron:** auto-launcher — zero-config CDP connection ([#653](https://github.com/jackwener/opencli/issues/653))
### Bug Fixes
* **douyin:** repair creator draft flow — switch from broken API pipeline to UI-driven approach ([#640](https://github.com/jackwener/opencli/issues/640))
* **douyin:** support current creator API response shapes for activities, profile, collections, hashtag, videos ([#618](https://github.com/jackwener/opencli/issues/618))
* **bilibili:** distinguish login-gated subtitles from empty results ([#645](https://github.com/jackwener/opencli/issues/645))
* **facebook:** avoid in-page redirect in search — use navigate step instead of window.location.href ([#642](https://github.com/jackwener/opencli/issues/642))
* **substack:** update selectors for DOM redesign ([#624](https://github.com/jackwener/opencli/issues/624))
* **weread:** recover book details from cached shelf fallback ([#628](https://github.com/jackwener/opencli/issues/628))
* **docs:** use relative links in adapter index ([#629](https://github.com/jackwener/opencli/issues/629))
## [1.4.1](https://github.com/jackwener/opencli/compare/v1.4.0...v1.4.1) (2026-03-25)
+39 -25
View File
@@ -10,7 +10,7 @@
A CLI tool that turns **any website**, **Electron app**, or **local CLI tool** into a command-line interface — Bilibili, Zhihu, 小红书, Twitter/X, Reddit, YouTube, Antigravity, `gh`, `docker`, and [many more](#built-in-commands) — powered by browser session reuse and AI-native discovery.
**Built for AI Agents**Configure an instruction in your `AGENT.md` or `.cursorrules` to run `opencli list` via Bash. The AI will automatically discover and invoke all available tools.
**Built for AI Agents**Load the [`opencli-operate` skill](./skills/opencli-operate/SKILL.md) to give any AI agent (Claude Code, Cursor) direct browser control. Operate any website, then crystallize those interactions into reusable CLI commands. Configure `opencli list` in your `AGENT.md` or `.cursorrules` so the AI auto-discovers all available tools.
**CLI Hub** — Register any local CLI (`opencli register mycli`) so AI agents can discover and call it alongside built-in commands. Auto-installs missing tools via your package manager (e.g. if `gh` isn't installed, `opencli gh ...` runs `brew install gh` first then re-executes seamlessly).
@@ -21,33 +21,17 @@ A CLI tool that turns **any website**, **Electron app**, or **local CLI tool** i
## Highlights
- **CLI All Electron** — CLI-ify apps like Antigravity Ultra! Now AI can control itself natively.
- **Browser Automation** — `operate` gives AI agents direct browser control: click, type, extract, screenshot — any interaction, fully scriptable.
- **Website → CLI** — Turn any website into a deterministic CLI: 70+ pre-built adapters, or crystallize your own with `opencli record`.
- **Account-safe** — Reuses Chrome's logged-in state; your credentials never leave the browser.
- **Anti-detection built-in** — Patches `navigator.webdriver`, stubs `window.chrome`, fakes plugin lists, cleans ChromeDriver/Playwright globals, and strips CDP frames from Error stack traces. Extensive anti-fingerprinting and risk-control evasion measures baked in at every layer.
- **AI Agent ready** — `explore` discovers APIs, `synthesize` generates adapters, `cascade` finds auth strategies.
- **AI Agent ready** — `explore` discovers APIs, `synthesize` generates adapters, `cascade` finds auth strategies, `operate` controls the browser directly.
- **External CLI Hub** — Discover, auto-install, and passthrough commands to any external CLI (gh, obsidian, docker, etc). Zero setup.
- **Self-healing setup** — `opencli doctor` diagnoses and auto-starts the daemon, extension, and live browser connectivity.
- **Dynamic Loader** — Simply drop `.ts` or `.yaml` adapters into the `clis/` folder for auto-registration.
- **Dual-Engine Architecture** — Supports both YAML declarative data pipelines and robust browser runtime TypeScript injections.
## Why opencli?
There are many great browser automation tools. Here's when opencli is the right choice:
| Your need | Best tool | Why |
|-----------|-----------|-----|
| Scheduled data extraction from specific sites | **opencli** | Pre-built adapters, deterministic JSON, zero LLM cost |
| AI agent needs reliable site operations | **opencli** | Hundreds of commands, structured output, fast deterministic response |
| Explore an unknown website ad-hoc | Browser-Use, Stagehand | LLM-driven general browsing for one-off tasks |
| Large-scale web crawling | Crawl4AI, Scrapy | Purpose-built for throughput and scale |
| Control desktop Electron apps from terminal | **opencli** | CDP + AppleScript — the only CLI tool that does this |
**What makes opencli different:**
- **Zero LLM cost** — No tokens consumed at runtime. Run 10,000 times and pay nothing.
- **Deterministic** — Same command, same output schema, every time. Pipeable, scriptable, CI-friendly.
- **Broad coverage** — 50+ sites across global and Chinese platforms (Bilibili, Zhihu, Xiaohongshu, Reddit, HackerNews, and more), plus desktop Electron apps via CDP.
> For a detailed comparison with Browser-Use, Crawl4AI, Firecrawl, and others, see the [Comparison Guide](./docs/comparison.md).
- **Broad coverage** — 73+ sites across global and Chinese platforms (Bilibili, Zhihu, Xiaohongshu, Reddit, HackerNews, and more), plus desktop Electron apps via CDP.
---
@@ -72,7 +56,8 @@ npm install -g @jackwener/opencli
### 3. Verify & Try
```bash
opencli doctor # Check extension + daemon connectivity
opencli doctor # Check extension + daemon connectivity
opencli daemon status # Check daemon state (PID, uptime, memory)
```
**Try it out:**
@@ -83,12 +68,33 @@ opencli hackernews top --limit 5 # Public API, no browser needed
opencli bilibili hot --limit 5 # Browser command (requires Extension)
```
### 4. Browser Automation — Make Websites Accessible for AI Agents
Point your AI agent (Claude Code, Cursor) to [`skills/opencli-operate/SKILL.md`](./skills/opencli-operate/SKILL.md). It has everything needed — full command reference, examples, and workflow.
Available commands: `open`, `state`, `click`, `type`, `select`, `keys`, `wait`, `get`, `screenshot`, `scroll`, `back`, `eval`, `network`, `init`, `verify`, `close`.
### Update
```bash
npm install -g @jackwener/opencli@latest
```
### Install AI Skills
OpenCLI provides [skills](./skills/) for AI agents (Claude Code, etc.):
```bash
# Install all OpenCLI skills
npx skills add jackwener/opencli
# Or install specific skills
npx skills add jackwener/opencli --skill opencli-usage # Command reference
npx skills add jackwener/opencli --skill opencli-operate # Browser automation for AI agents
npx skills add jackwener/opencli --skill opencli-explorer # Adapter development guide
npx skills add jackwener/opencli --skill opencli-oneshot # Quick command reference
```
---
### For Developers
@@ -109,7 +115,7 @@ git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && n
## Prerequisites
- **Node.js**: >= 20.0.0 (or **Bun** >= 1.0)
- **Chrome** running **and logged into the target site** (e.g. bilibili.com, zhihu.com, xiaohongshu.com).
- **Chrome** running **and logged into the target site** (e.g. bilibili.com, zhihu.com, xiaohongshu.com, goofish.com).
> **⚠️ Important**: Browser commands reuse your Chrome login session. You must be logged into the target website in Chrome before running commands. If you get empty data or errors, check your login status first.
@@ -117,12 +123,19 @@ git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && n
| Site | Commands |
|------|----------|
| **xiaohongshu** | `search` `feed` `user` `download` `publish` `comments` `notifications` `creator-notes` `creator-notes-summary` `creator-note-detail` `creator-profile` `creator-stats` |
| **xiaohongshu** | `search` `note` `comments` `feed` `user` `download` `publish` `notifications` `creator-notes` `creator-notes-summary` `creator-note-detail` `creator-profile` `creator-stats` |
| **bilibili** | `hot` `search` `history` `feed` `ranking` `download` `comments` `dynamic` `favorite` `following` `me` `subtitle` `user-videos` |
| **tieba** | `hot` `posts` `search` `read` |
| **twitter** | `trending` `search` `timeline` `bookmarks` `post` `download` `profile` `article` `like` `likes` `notifications` `reply` `reply-dm` `thread` `follow` `unfollow` `followers` `following` `block` `unblock` `bookmark` `unbookmark` `delete` `hide-reply` `accept` |
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `user` `user-posts` `user-comments` `read` `save` `saved` `subscribe` `upvote` `upvoted` `comment` |
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` |
| **gemini** | `new` `ask` `image` |
| **yuanbao** | `new` `ask` |
| **notebooklm** | `status` `list` `open` `select` `current` `get` `metadata` `source-list` `source-get` `source-fulltext` `source-guide` `history` `note-list` `notes-list` `notes-get` `summary` |
| **spotify** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` |
| **xianyu** | `search` `item` `chat` |
66+ adapters in total — **[→ see all supported sites & commands](./docs/adapters/index.md)**
73+ adapters in total — **[→ see all supported sites & commands](./docs/adapters/index.md)**
## CLI Hub
@@ -231,6 +244,7 @@ opencli plugin uninstall my-tool
| [opencli-plugin-github-trending](https://github.com/ByteYue/opencli-plugin-github-trending) | YAML | GitHub Trending repositories |
| [opencli-plugin-hot-digest](https://github.com/ByteYue/opencli-plugin-hot-digest) | TS | Multi-platform trending aggregator |
| [opencli-plugin-juejin](https://github.com/Astro-Han/opencli-plugin-juejin) | YAML | 稀土掘金 (Juejin) hot articles |
| [opencli-plugin-vk](https://github.com/flobo3/opencli-plugin-vk) | TS | VK (VKontakte) wall, feed, and search |
See [Plugins Guide](./docs/guide/plugins.md) for creating your own plugin.
+40 -27
View File
@@ -10,7 +10,7 @@
OpenCLI 将任何网站、本地 CLI 或 Electron 应用(如 Antigravity)变成命令行工具 — B站、知乎、小红书、Twitter/X、Reddit、YouTube,以及 `gh``docker` 等[多种站点与工具](#内置命令) — 复用浏览器登录态,AI 驱动探索。
**专为 AI Agent 打造**只需在全局 `.cursorrules``AGENT.md` 中配置简单指令,引导 AI 通过 Bash 执行 `opencli list` 来检索可用的 CLI 工具及其用法。随后,将你常用的 CLI 列表整合注册进去(`opencli register mycli`AI 便能瞬间学会自动调用相应的本地工具
**专为 AI Agent 打造**加载 [`opencli-operate` skill](./skills/opencli-operate/SKILL.md),赋予 AI AgentClaude Code、Cursor 等)直接操控浏览器的能力——操作任意网站,并将这些交互沉淀为可复用的 CLI 命令。在 `AGENT.md``.cursorrules` 中配置 `opencli list`AI 即可自动发现并调用所有可用工具
**opencli 支持 CLI 化所有 electron 应用!最强大更新来袭!**
CLI all electron!现在支持把所有 electron 应用 CLI 化,从而组合出各种神奇的能力。
@@ -22,37 +22,20 @@ CLI all electron!现在支持把所有 electron 应用 CLI 化,从而组合
## 亮点
- **CLI All Electron** — 支持把所有 electron 应用(如 Antigravity UltraCLI 化,让 AI 控制自己!
- **多站点覆盖** — 覆盖 B站、知乎、小红书、Twitter、Reddit,以及多种桌面应用
- **浏览器自动化** — `operate` 赋予 AI Agent 直接操控浏览器的能力:点击、输入、提取、截图,任意交互皆可脚本化
- **网页转 CLI** — 将任意网站变成确定性命令行工具:73+ 预置适配器,或用 `opencli record` 沉淀自己的操作
- **多站点覆盖** — 73+ 站点,横跨全球与中国平台(B站、知乎、小红书、Reddit、HackerNews 等),并支持通过 CDP 控制桌面 Electron 应用
- **零风控** — 复用 Chrome 登录态,无需存储任何凭证
- **外部 CLI 枢纽** — 统一发现、自动安装、透传执行 `gh``docker` 等本地 CLI
- **自修复配置** — `opencli doctor` 自动启动 daemon,诊断扩展和浏览器连接状态
- **AI 原生** — `explore` 自动发现 API`synthesize` 生成适配器,`cascade` 探测认证策略
- **动态加载引擎** — 声明式的 `.yaml` 或者底层定制的 `.ts` 适配器,放入 `clis/` 文件夹即可自动注册生效
## 为什么选 opencli
浏览器自动化工具很多,opencli 适合什么场景?
| 你的需求 | 最佳工具 | 原因 |
|----------|----------|------|
| 定时从特定站点提取结构化数据 | **opencli** | 预定义适配器,确定性 JSON 输出,零 LLM 成本 |
| AI Agent 需要可靠的站点操作 | **opencli** | 数百条命令,结构化输出,快速确定性响应 |
| 临时探索未知网站 | Browser-Use、Stagehand | LLM 驱动的通用浏览,适合一次性任务 |
| 大规模网页爬取 | Crawl4AI、Scrapy | 专为吞吐量和规模设计 |
| 从终端控制桌面 Electron 应用 | **opencli** | CDP + AppleScript,目前唯一能做到这一点的 CLI 工具 |
**opencli 的核心差异:**
- **AI 原生** — `explore` 自动发现 API`synthesize` 生成适配器,`cascade` 探测认证策略`operate` 直接控制浏览器
- **零 LLM 成本** — 运行时不消耗任何 token,跑一万次不花一分钱
- **确定性** — 同一命令永远返回同一结构,可管道化、可脚本化、CI 友好
- **覆盖广泛** — 50+ 站点,横跨全球与中国平台(B站、知乎、小红书、Reddit、HackerNews 等),并支持通过 CDP 控制桌面 Electron 应用
> 与 Browser-Use、Crawl4AI、Firecrawl 等工具的详细对比,请查看 [Comparison Guide](./docs/comparison.md)。
## 前置要求
- **Node.js**: >= 20.0.0
- **Chrome** 浏览器正在运行,且**已登录目标网站**(如 bilibili.com、zhihu.com、xiaohongshu.com
- **Chrome** 浏览器正在运行,且**已登录目标网站**(如 bilibili.com、zhihu.com、xiaohongshu.com、goofish.com
> **⚠️ 重要**:大多数命令复用你的 Chrome 登录状态。运行命令前,你必须已在 Chrome 中打开目标网站并完成登录。如果获取到空数据或报错,请先检查你的浏览器登录状态。
@@ -73,9 +56,11 @@ OpenCLI 通过轻量化的 **Browser Bridge** Chrome 扩展 + 微型 daemon 与
完成!运行任何 opencli 浏览器命令时,后台微型 daemon 会自动启动与浏览器通信。无需配 API Token,零代码配置。
> **Tip**:后续诊断用 `opencli doctor`
> **Tip**:后续诊断和 daemon 管理
> ```bash
> opencli doctor # 检查扩展和 daemon 连通性
> opencli daemon status # 查看 daemon 状态
> opencli daemon stop # 停止 daemon
> ```
## 快速开始
@@ -114,6 +99,27 @@ opencli list # 可以在任何地方使用了!
npm install -g @jackwener/opencli@latest
```
### 浏览器自动化 — 让 AI Agent 直接控制浏览器
将 [`skills/opencli-operate/SKILL.md`](./skills/opencli-operate/SKILL.md) 指向你的 AI AgentClaude Code、Cursor),即可开箱即用,内含完整命令参考与使用示例。
可用命令:`open``state``click``type``select``keys``wait``get``screenshot``scroll``back``eval``network``init``verify``close`
### 安装 AI Skills
OpenCLI 提供 [skills](./skills/) 供 AI AgentClaude Code 等)使用:
```bash
# 安装所有 OpenCLI skills
npx skills add jackwener/opencli
# 或安装特定 skill
npx skills add jackwener/opencli --skill opencli-usage # 命令参考
npx skills add jackwener/opencli --skill opencli-operate # 浏览器自动化(AI Agent 专用)
npx skills add jackwener/opencli --skill opencli-explorer # 适配器开发指南
npx skills add jackwener/opencli --skill opencli-oneshot # 快速命令参考
```
## 内置命令
运行 `opencli list` 查看完整注册表。
@@ -122,16 +128,17 @@ npm install -g @jackwener/opencli@latest
|------|------|------|
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | 浏览器 |
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 浏览器 |
| **tieba** | `hot` `posts` `search` `read` | 浏览器 |
| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | 桌面端 |
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | 浏览器 |
| **codex** | `status` `send` `read` `new` `dump` `extract-diff` `model` `ask` `screenshot` `history` `export` | 桌面端 |
| **chatwise** | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` | 桌面端 |
| **doubao** | `status` `new` `send` `read` `ask` | 浏览器 |
| **doubao** | `status` `new` `send` `read` `ask` `history` `detail` `meeting-summary` `meeting-transcript` | 浏览器 |
| **doubao-app** | `status` `new` `send` `read` `ask` `screenshot` `dump` | 桌面端 |
| **notion** | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` | 桌面端 |
| **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` | 桌面端 |
| **v2ex** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | 公开 / 浏览器 |
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` `earnings-date` `fund-holdings` `fund-snapshot` | 浏览器 |
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `comments` `watchlist` `earnings-date` `fund-holdings` `fund-snapshot` | 浏览器 |
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | 桌面端 |
| **chatgpt** | `status` `new` `send` `read` `ask` `model` | 桌面端 |
| **xiaohongshu** | `search` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 浏览器 |
@@ -173,6 +180,10 @@ npm install -g @jackwener/opencli@latest
| **douban** | `search` `top250` `subject` `photos` `download` `marks` `reviews` `movie-hot` `book-hot` | 浏览器 |
| **facebook** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | 浏览器 |
| **google** | `news` `search` `suggest` `trends` | 公开 |
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` | 浏览器 |
| **gemini** | `new` `ask` `image` | 浏览器 |
| **spotify** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` | OAuth API |
| **notebooklm** | `status` `list` `open` `select` `current` `get` `metadata` `source-list` `source-get` `source-fulltext` `source-guide` `history` `note-list` `notes-list` `notes-get` `summary` | 浏览器 |
| **36kr** | `news` `hot` `search` `article` | 公开 / 浏览器 |
| **imdb** | `search` `title` `top` `trending` `person` `reviews` | 公开 |
| **producthunt** | `posts` `today` `hot` `browse` | 公开 / 浏览器 |
@@ -184,9 +195,11 @@ npm install -g @jackwener/opencli@latest
| **pixiv** | `ranking` `search` `user` `illusts` `detail` `download` | 浏览器 |
| **tiktok** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | 浏览器 |
| **bluesky** | `search` `trending` `user` `profile` `thread` `feeds` `followers` `following` `starter-packs` | 公开 |
| **xianyu** | `search` `item` `chat` | 浏览器 |
| **douyin** | `videos` `publish` `drafts` `draft` `delete` `stats` `profile` `update` `hashtag` `location` `activities` `collections` | 浏览器 |
| **yuanbao** | `new` `ask` | 浏览器 |
66+ 适配器 — **[→ 查看完整命令列表](./docs/adapters/index.md)**
73+ 适配器 — **[→ 查看完整命令列表](./docs/adapters/index.md)**
### 外部 CLI 枢纽
-879
View File
@@ -1,879 +0,0 @@
---
name: opencli
description: "OpenCLI — Make any website or Electron App your CLI. Zero risk, AI-powered, reuse Chrome login."
version: 1.5.5
author: jackwener
tags: [cli, browser, web, chrome-extension, cdp, bilibili, zhihu, twitter, github, v2ex, hackernews, reddit, xiaohongshu, xueqiu, youtube, boss, coupang, yollomi, AI, agent]
---
# OpenCLI
> Make any website or Electron App your CLI. Reuse Chrome login, zero risk, AI-powered discovery.
> [!CAUTION]
> **AI Agent 必读:创建或修改任何适配器之前,你必须先阅读 [CLI-EXPLORER.md](./CLI-EXPLORER.md)**
> 该文档包含完整的 API 发现工作流(必须使用浏览器探索)、5 级认证策略决策树、平台 SDK 速查表、`tap` 步骤调试流程、分页 API 模板、级联请求模式、以及常见陷阱。
> **本文件(SKILL.md)仅提供命令参考和简化模板,不足以正确开发适配器。**
> [!IMPORTANT]
> 创建或修改 adapter 时,再额外遵守 3 条收口规则:
> 1. 主参数优先用 positional arg,不要把 `query` / `id` / `url` 默认做成 `--query` / `--id` / `--url`
> 2. 预期中的 adapter 失败优先抛 `CliError` 子类,不要直接 throw 原始 `Error`
> 3. 新增 adapter 或新增用户可发现命令时,同步更新 adapter docs、`docs/adapters/index.md`、sidebar,以及 README/README.zh-CN 中受影响的入口
## Install & Run
```bash
# npm global install (recommended)
npm install -g @jackwener/opencli
opencli <command>
# Or from source
cd ~/code/opencli && npm install
npx tsx src/main.ts <command>
# Update to latest
npm update -g @jackwener/opencli
```
## Prerequisites
Browser commands require:
1. Chrome browser running **(logged into target sites)**
2. **opencli Browser Bridge** Chrome extension installed (load `extension/` as unpacked in `chrome://extensions`)
3. No further setup needed — the daemon auto-starts on first browser command
> **Note**: You must be logged into the target website in Chrome before running commands. Tabs opened during command execution are auto-closed afterwards.
Public API commands (`hackernews`, `v2ex`) need no browser.
## Commands Reference
### Data Commands
```bash
# Bilibili (browser)
opencli bilibili hot --limit 10 # B站热门视频
opencli bilibili search "rust" # 搜索视频 (query positional)
opencli bilibili me # 我的信息
opencli bilibili favorite # 我的收藏
opencli bilibili history --limit 20 # 观看历史
opencli bilibili feed --limit 10 # 动态时间线
opencli bilibili user-videos --uid 12345 # 用户投稿
opencli bilibili subtitle --bvid BV1xxx # 获取视频字幕 (支持 --lang zh-CN)
opencli bilibili dynamic --limit 10 # 动态
opencli bilibili ranking --limit 10 # 排行榜
opencli bilibili following --limit 20 # 我的关注列表 (支持 --uid 查看他人)
# 知乎 (browser)
opencli zhihu hot --limit 10 # 知乎热榜
opencli zhihu search "AI" # 搜索 (query positional)
opencli zhihu question 34816524 # 问题详情和回答 (id positional)
# 小红书 (browser)
opencli xiaohongshu search "美食" # 搜索笔记 (query positional)
opencli xiaohongshu notifications # 通知(mentions/likes/connections
opencli xiaohongshu feed --limit 10 # 推荐 Feed
opencli xiaohongshu user xxx # 用户主页 (id positional)
opencli xiaohongshu creator-notes --limit 10 # 创作者笔记列表
opencli xiaohongshu creator-note-detail --note-id xxx # 笔记详情
opencli xiaohongshu creator-notes-summary # 笔记数据概览
opencli xiaohongshu creator-profile # 创作者资料
opencli xiaohongshu creator-stats # 创作者数据统计
# 雪球 Xueqiu (browser)
opencli xueqiu hot-stock --limit 10 # 雪球热门股票榜
opencli xueqiu stock --symbol SH600519 # 查看股票实时行情
opencli xueqiu watchlist # 获取自选股/持仓列表
opencli xueqiu feed # 我的关注 timeline
opencli xueqiu hot --limit 10 # 雪球热榜
opencli xueqiu search "特斯拉" # 搜索 (query positional)
opencli xueqiu earnings-date SH600519 # 股票财报发布日期 (symbol positional)
opencli xueqiu fund-holdings # 蛋卷基金持仓明细 (支持 --account 过滤)
opencli xueqiu fund-snapshot # 蛋卷基金快照(总资产、子账户、持仓)
# GitHub (via gh External CLI)
opencli gh repo list # 列出仓库 (passthrough to gh)
opencli gh pr list --limit 5 # PR 列表
opencli gh issue list # Issue 列表
# Twitter/X (browser)
opencli twitter trending --limit 10 # 热门话题
opencli twitter bookmarks --limit 20 # 获取收藏的书签推文
opencli twitter search "AI" # 搜索推文 (query positional)
opencli twitter profile elonmusk # 用户资料
opencli twitter timeline --limit 20 # 时间线
opencli twitter thread 1234567890 # 推文 thread(原文 + 回复)
opencli twitter article 1891511252174299446 # 推文长文内容
opencli twitter follow elonmusk # 关注用户
opencli twitter unfollow elonmusk # 取消关注
opencli twitter bookmark https://x.com/... # 收藏推文
opencli twitter unbookmark https://x.com/... # 取消收藏
opencli twitter post "Hello world" # 发布推文 (text positional)
opencli twitter like https://x.com/... # 点赞推文 (url positional)
opencli twitter reply https://x.com/... "Nice!" # 回复推文 (url + text positional)
opencli twitter delete https://x.com/... # 删除推文 (url positional)
opencli twitter block elonmusk # 屏蔽用户 (username positional)
opencli twitter unblock elonmusk # 取消屏蔽 (username positional)
opencli twitter followers elonmusk # 用户的粉丝列表 (user positional)
opencli twitter following elonmusk # 用户的关注列表 (user positional)
opencli twitter notifications --limit 20 # 通知列表
opencli twitter hide-reply https://x.com/... # 隐藏回复 (url positional)
opencli twitter download elonmusk # 下载用户媒体 (username positional, 支持 --tweet-url)
opencli twitter accept "群,微信" # 自动接受含关键词的 DM 请求 (query positional)
opencli twitter reply-dm "消息内容" # 批量回复 DM (text positional)
# Reddit (browser)
opencli reddit hot --limit 10 # 热门帖子
opencli reddit hot --subreddit programming # 指定子版块
opencli reddit frontpage --limit 10 # 首页 /r/all
opencli reddit popular --limit 10 # /r/popular 热门
opencli reddit search "AI" --sort top --time week # 搜索(支持排序+时间过滤)
opencli reddit subreddit rust --sort top --time month # 子版块浏览(支持时间过滤)
opencli reddit read --post-id 1abc123 # 阅读帖子 + 评论
opencli reddit user spez # 用户资料(karma、注册时间)
opencli reddit user-posts spez # 用户发帖历史
opencli reddit user-comments spez # 用户评论历史
opencli reddit upvote --post-id xxx --direction up # 投票(up/down/none
opencli reddit save --post-id xxx # 收藏帖子
opencli reddit comment --post-id xxx "Great!" # 发表评论 (text positional)
opencli reddit subscribe --subreddit python # 订阅子版块
opencli reddit saved --limit 10 # 我的收藏
opencli reddit upvoted --limit 10 # 我的赞
# V2EX (public + browser)
opencli v2ex hot --limit 10 # 热门话题
opencli v2ex latest --limit 10 # 最新话题
opencli v2ex topic 1024 # 主题详情 (id positional)
opencli v2ex daily # 每日签到 (browser)
opencli v2ex me # 我的信息 (browser)
opencli v2ex notifications --limit 10 # 通知 (browser)
opencli v2ex node python # 节点话题列表 (name positional)
opencli v2ex nodes --limit 30 # 所有节点列表
opencli v2ex member username # 用户资料 (username positional)
opencli v2ex user username # 用户发帖列表 (username positional)
opencli v2ex replies 1024 # 主题回复列表 (id positional)
# Hacker News (public)
opencli hackernews top --limit 10 # Top stories
opencli hackernews new --limit 10 # Newest stories
opencli hackernews best --limit 10 # Best stories
opencli hackernews ask --limit 10 # Ask HN posts
opencli hackernews show --limit 10 # Show HN posts
opencli hackernews jobs --limit 10 # Job postings
opencli hackernews search "rust" # 搜索 (query positional)
opencli hackernews user dang # 用户资料 (username positional)
# BBC (public)
opencli bbc news --limit 10 # BBC News RSS headlines
# 微博 (browser)
opencli weibo hot --limit 10 # 微博热搜
# BOSS直聘 (browser)
opencli boss search "AI agent" # 搜索职位 (query positional)
opencli boss detail --security-id xxx # 职位详情
opencli boss recommend --limit 10 # 推荐职位
opencli boss joblist --limit 10 # 职位列表
opencli boss greet --security-id xxx # 打招呼
opencli boss batchgreet --job-id xxx # 批量打招呼
opencli boss send --uid xxx "消息内容" # 发消息 (text positional)
opencli boss chatlist --limit 10 # 聊天列表
opencli boss chatmsg --security-id xxx # 聊天记录
opencli boss invite --security-id xxx # 邀请沟通
opencli boss mark --security-id xxx # 标记管理
opencli boss exchange --security-id xxx # 交换联系方式
opencli boss resume # 简历管理
opencli boss stats # 数据统计
# YouTube (browser)
opencli youtube search "rust" # 搜索视频 (query positional)
opencli youtube video "https://www.youtube.com/watch?v=xxx" # 视频元数据
opencli youtube transcript "https://www.youtube.com/watch?v=xxx" # 获取视频字幕/转录
opencli youtube transcript "xxx" --lang zh-Hans --mode raw # 指定语言 + 原始时间戳模式
# Yahoo Finance (browser)
opencli yahoo-finance quote --symbol AAPL # 股票行情
# Sina Finance
opencli sinafinance news --limit 10 --type 1 # 7x24实时快讯 (0=全部 1=A股 2=宏观 3=公司 4=数据 5=市场 6=国际 7=观点 8=央行 9=其它)
# Reuters (browser)
opencli reuters search "AI" # 路透社搜索 (query positional)
# 什么值得买 (browser)
opencli smzdm search "耳机" # 搜索好价 (query positional)
# 携程 (browser)
opencli ctrip search "三亚" # 搜索目的地 (query positional)
# Antigravity (Electron/CDP)
opencli antigravity status # 检查 CDP 连接
opencli antigravity send "hello" # 发送文本到当前 agent 聊天框
opencli antigravity read # 读取整个聊天记录面板
opencli antigravity new # 清空聊天、开启新对话
opencli antigravity dump # 导出 DOM 和快照调试信息
opencli antigravity extract-code # 自动抽取 AI 回复中的代码块
opencli antigravity model claude # 切换底层模型
opencli antigravity watch # 流式监听增量消息
# Barchart (browser)
opencli barchart quote --symbol AAPL # 股票行情
opencli barchart options --symbol AAPL # 期权链
opencli barchart greeks --symbol AAPL # 期权 Greeks
opencli barchart flow --limit 20 # 异常期权活动
# Jike 即刻 (browser)
opencli jike feed --limit 10 # 动态流
opencli jike search "AI" # 搜索 (query positional)
opencli jike create "内容" # 发布动态 (text positional)
opencli jike like xxx # 点赞 (id positional)
opencli jike comment xxx "评论" # 评论 (id + text positional)
opencli jike repost xxx # 转发 (id positional)
opencli jike notifications # 通知
# Linux.do (public + browser)
opencli linux-do hot --limit 10 # 热门话题
opencli linux-do latest --limit 10 # 最新话题
opencli linux-do search "rust" # 搜索 (query positional)
opencli linux-do topic 1024 # 主题详情 (id positional)
opencli linux-do categories --limit 20 # 分类列表 (browser)
opencli linux-do category dev 7 # 分类内话题 (slug + id positional, browser)
# StackOverflow (public)
opencli stackoverflow hot --limit 10 # 热门问题
opencli stackoverflow search "typescript" # 搜索 (query positional)
opencli stackoverflow bounties --limit 10 # 悬赏问题
# WeRead 微信读书 (browser)
opencli weread shelf --limit 10 # 书架
opencli weread search "AI" # 搜索图书 (query positional)
opencli weread book xxx # 图书详情 (book-id positional)
opencli weread highlights xxx # 划线笔记 (book-id positional)
opencli weread notes xxx # 想法笔记 (book-id positional)
opencli weread ranking --limit 10 # 排行榜
# Jimeng 即梦 AI (browser)
opencli jimeng generate --prompt "描述" # AI 生图
opencli jimeng history --limit 10 # 生成历史
# Yollomi yollomi.com (browser — 需在 Chrome 登录 yollomi.com,复用站点 session)
opencli yollomi models --type image # 列出图像模型与积分
opencli yollomi generate "提示词" --model z-image-turbo # 文生图
opencli yollomi video "提示词" --model kling-2-1 # 视频
opencli yollomi upload ./photo.jpg # 上传得 URL,供 img2img / 工具链使用
opencli yollomi remove-bg <image-url> # 去背景(免费)
opencli yollomi edit <image-url> "改成油画风格" # Qwen 图像编辑
opencli yollomi background <image-url> # AI 背景生成 (5 credits)
opencli yollomi face-swap --source <url> --target <url> # 换脸 (3 credits)
opencli yollomi object-remover <image-url> <mask-url> # AI 去除物体 (3 credits)
opencli yollomi restore <image-url> # AI 修复老照片 (4 credits)
opencli yollomi try-on --person <url> --cloth <url> # 虚拟试衣 (3 credits)
opencli yollomi upscale <image-url> # AI 超分辨率 (1 credit, 支持 --scale 2/4)
# Grok (default + explicit web)
opencli grok ask --prompt "问题" # 提问 Grok(兼容默认路径)
opencli grok ask --prompt "问题" --web # 显式 grok.com consumer web UI 路径
# HuggingFace (public)
opencli hf top --limit 10 # 热门模型
# 超星学习通 (browser)
opencli chaoxing assignments # 作业列表
opencli chaoxing exams # 考试列表
# Douban 豆瓣 (browser)
opencli douban search "三体" # 搜索 (query positional)
opencli douban top250 # 豆瓣 Top 250
opencli douban subject 1234567 # 条目详情 (id positional)
opencli douban photos 30382501 # 图片列表 / 直链(默认海报)
opencli douban download 30382501 # 下载海报 / 剧照
opencli douban marks --limit 10 # 我的标记
opencli douban reviews --limit 10 # 短评
# Facebook (browser)
opencli facebook feed --limit 10 # 动态流
opencli facebook profile username # 用户资料 (id positional)
opencli facebook search "AI" # 搜索 (query positional)
opencli facebook friends # 好友列表
opencli facebook groups # 群组
opencli facebook events # 活动
opencli facebook notifications # 通知
opencli facebook memories # 回忆
opencli facebook add-friend username # 添加好友 (id positional)
opencli facebook join-group groupid # 加入群组 (id positional)
# Instagram (browser)
opencli instagram explore # 探索
opencli instagram profile username # 用户资料 (id positional)
opencli instagram search "AI" # 搜索 (query positional)
opencli instagram user username # 用户详情 (id positional)
opencli instagram followers username # 粉丝 (id positional)
opencli instagram following username # 关注 (id positional)
opencli instagram follow username # 关注用户 (id positional)
opencli instagram unfollow username # 取消关注 (id positional)
opencli instagram like postid # 点赞 (id positional)
opencli instagram unlike postid # 取消点赞 (id positional)
opencli instagram comment postid "评论" # 评论 (id + text positional)
opencli instagram save postid # 收藏 (id positional)
opencli instagram unsave postid # 取消收藏 (id positional)
opencli instagram saved # 已收藏列表
# TikTok (browser)
opencli tiktok explore # 探索
opencli tiktok search "AI" # 搜索 (query positional)
opencli tiktok profile username # 用户资料 (id positional)
opencli tiktok user username # 用户详情 (id positional)
opencli tiktok following username # 关注列表 (id positional)
opencli tiktok follow username # 关注 (id positional)
opencli tiktok unfollow username # 取消关注 (id positional)
opencli tiktok like videoid # 点赞 (id positional)
opencli tiktok unlike videoid # 取消点赞 (id positional)
opencli tiktok comment videoid "评论" # 评论 (id + text positional)
opencli tiktok save videoid # 收藏 (id positional)
opencli tiktok unsave videoid # 取消收藏 (id positional)
opencli tiktok live # 直播
opencli tiktok notifications # 通知
opencli tiktok friends # 朋友
# Medium (browser)
opencli medium feed --limit 10 # 动态流
opencli medium search "AI" # 搜索 (query positional)
opencli medium user username # 用户主页 (id positional)
# Substack (browser)
opencli substack feed --limit 10 # 订阅动态
opencli substack search "AI" # 搜索 (query positional)
opencli substack publication name # 出版物详情 (id positional)
# Sinablog 新浪博客 (browser)
opencli sinablog hot --limit 10 # 热门
opencli sinablog search "AI" # 搜索 (query positional)
opencli sinablog article url # 文章详情
opencli sinablog user username # 用户主页 (id positional)
# Lobsters (public)
opencli lobsters hot --limit 10 # 热门
opencli lobsters newest --limit 10 # 最新
opencli lobsters active --limit 10 # 活跃
opencli lobsters tag rust # 按标签筛选 (tag positional)
# Google (public)
opencli google news --limit 10 # 新闻
opencli google search "AI" # 搜索 (query positional)
opencli google suggest "AI" # 搜索建议 (query positional)
opencli google trends # 趋势
# DEV.to (public)
opencli devto top --limit 10 # 热门文章
opencli devto tag javascript --limit 10 # 按标签 (tag positional)
opencli devto user username # 用户文章 (username positional)
# Steam (public)
opencli steam top-sellers --limit 10 # 热销游戏
# Apple Podcasts (public)
opencli apple-podcasts top --limit 10 # 热门播客排行榜 (支持 --country us/cn/gb/jp)
opencli apple-podcasts search "科技" # 搜索播客 (query positional)
opencli apple-podcasts episodes 12345 # 播客剧集列表 (id positional, 用 search 获取 ID)
# arXiv (public)
opencli arxiv search "attention" # 搜索论文 (query positional)
opencli arxiv paper 1706.03762 # 论文详情 (id positional)
# Bloomberg (public RSS + browser)
opencli bloomberg main --limit 10 # Bloomberg 首页头条 (RSS)
opencli bloomberg markets --limit 10 # 市场新闻 (RSS)
opencli bloomberg tech --limit 10 # 科技新闻 (RSS)
opencli bloomberg politics --limit 10 # 政治新闻 (RSS)
opencli bloomberg economics --limit 10 # 经济新闻 (RSS)
opencli bloomberg opinions --limit 10 # 观点 (RSS)
opencli bloomberg industries --limit 10 # 行业新闻 (RSS)
opencli bloomberg businessweek --limit 10 # Businessweek (RSS)
opencli bloomberg feeds # 列出所有 RSS feed 别名
opencli bloomberg news "https://..." # 阅读 Bloomberg 文章全文 (link positional, browser)
# Coupang 쿠팡 (browser)
opencli coupang search "耳机" # 搜索商品 (query positional, 支持 --filter rocket)
opencli coupang add-to-cart 12345 # 加入购物车 (product-id positional, 或 --url)
# Dictionary (public)
opencli dictionary search "serendipity" # 单词释义 (word positional)
opencli dictionary synonyms "happy" # 近义词 (word positional)
opencli dictionary examples "ubiquitous" # 例句 (word positional)
# 豆包 Doubao Web (browser)
opencli doubao status # 检查豆包页面状态
opencli doubao new # 新建对话
opencli doubao send "你好" # 发送消息 (text positional)
opencli doubao read # 读取对话记录
opencli doubao ask "问题" # 一键提问并等回复 (text positional)
# 京东 JD (browser)
opencli jd item 100291143898 # 商品详情 (sku positional, 含价格/主图/规格)
# LinkedIn (browser)
opencli linkedin search "AI engineer" # 搜索职位 (query positional, 支持 --location/--company/--remote)
opencli linkedin timeline --limit 20 # 首页动态流
# Pixiv (browser)
opencli pixiv ranking --limit 20 # 插画排行榜 (支持 --mode daily/weekly/monthly)
opencli pixiv search "風景" # 搜索插画 (query positional)
opencli pixiv user 12345 # 画师资料 (uid positional)
opencli pixiv illusts 12345 # 画师作品列表 (user-id positional)
opencli pixiv detail 12345 # 插画详情 (id positional)
opencli pixiv download 12345 # 下载插画 (illust-id positional)
# Web (browser)
opencli web read --url "https://..." # 抓取任意网页并导出为 Markdown
# 微信公众号 Weixin (browser)
opencli weixin download --url "https://mp.weixin.qq.com/s/xxx" # 下载公众号文章为 Markdown
# 小宇宙 Xiaoyuzhou (public)
opencli xiaoyuzhou podcast 12345 # 播客资料 (id positional)
opencli xiaoyuzhou podcast-episodes 12345 # 播客剧集列表 (id positional)
opencli xiaoyuzhou episode 12345 # 单集详情 (id positional)
# Wikipedia (public)
opencli wikipedia search "AI" # 搜索 (query positional)
opencli wikipedia summary "Python" # 摘要 (title positional)
```
### Desktop Adapter Commands
```bash
# Cursor (desktop — CDP via Electron)
opencli cursor status # 检查连接
opencli cursor send "message" # 发送消息
opencli cursor read # 读取回复
opencli cursor new # 新建对话
opencli cursor dump # 导出 DOM 调试信息
opencli cursor composer # Composer 模式
opencli cursor model claude # 切换模型
opencli cursor extract-code # 提取代码块
opencli cursor ask "question" # 一键提问并等回复
opencli cursor screenshot # 截图
opencli cursor history # 对话历史
opencli cursor export # 导出对话
# Codex (desktop — headless CLI agent)
opencli codex status # 检查连接
opencli codex send "message" # 发送消息
opencli codex read # 读取回复
opencli codex new # 新建对话
opencli codex dump # 导出调试信息
opencli codex extract-diff # 提取 diff
opencli codex model gpt-4 # 切换模型
opencli codex ask "question" # 一键提问并等回复
opencli codex screenshot # 截图
opencli codex history # 对话历史
opencli codex export # 导出对话
# ChatGPT (desktop — macOS AppleScript/CDP)
opencli chatgpt status # 检查应用状态
opencli chatgpt new # 新建对话
opencli chatgpt send "message" # 发送消息
opencli chatgpt read # 读取回复
opencli chatgpt ask "question" # 一键提问并等回复
# ChatWise (desktop — multi-LLM client)
opencli chatwise status # 检查连接
opencli chatwise new # 新建对话
opencli chatwise send "message" # 发送消息
opencli chatwise read # 读取回复
opencli chatwise ask "question" # 一键提问并等回复
opencli chatwise model claude # 切换模型
opencli chatwise history # 对话历史
opencli chatwise export # 导出对话
opencli chatwise screenshot # 截图
# Notion (desktop — CDP via Electron)
opencli notion status # 检查连接
opencli notion search "keyword" # 搜索页面
opencli notion read # 读取当前页面
opencli notion new # 新建页面
opencli notion write "content" # 写入内容
opencli notion sidebar # 侧边栏导航
opencli notion favorites # 收藏列表
opencli notion export # 导出
# Discord App (desktop — CDP via Electron)
opencli discord-app status # 检查连接
opencli discord-app send "message" # 发送消息
opencli discord-app read # 读取消息
opencli discord-app channels # 频道列表
opencli discord-app servers # 服务器列表
opencli discord-app search "keyword" # 搜索
opencli discord-app members # 成员列表
# Doubao App 豆包桌面版 (desktop — CDP via Electron)
opencli doubao-app status # 检查连接
opencli doubao-app new # 新建对话
opencli doubao-app send "message" # 发送消息
opencli doubao-app read # 读取回复
opencli doubao-app ask "question" # 一键提问并等回复
opencli doubao-app screenshot # 截图
opencli doubao-app dump # 导出 DOM 调试信息
```
### Management Commands
```bash
opencli list # List all commands (including External CLIs)
opencli list --json # JSON output
opencli list -f yaml # YAML output
opencli install <name> # Auto-install an external CLI (e.g., gh, obsidian)
opencli register <name> # Register a local custom CLI for unified discovery
opencli validate # Validate all CLI definitions
opencli validate bilibili # Validate specific site
opencli doctor # Diagnose browser bridge (auto-starts daemon, includes live test)
```
### AI Agent Workflow
```bash
# Deep Explore: network intercept → response analysis → capability inference
opencli explore <url> --site <name>
# Synthesize: generate evaluate-based YAML pipelines from explore artifacts
opencli synthesize <site>
# Generate: one-shot explore → synthesize → register
opencli generate <url> --goal "hot"
# Record: YOU operate the page, opencli captures every API call → YAML candidates
# Opens the URL in automation window, injects fetch/XHR interceptor into ALL tabs,
# polls every 2s, auto-stops after 60s (or press Enter to stop early).
opencli record <url> # 录制,site name 从域名推断
opencli record <url> --site mysite # 指定 site name
opencli record <url> --timeout 120000 # 自定义超时(毫秒,默认 60000)
opencli record <url> --poll 1000 # 缩短轮询间隔(毫秒,默认 2000)
opencli record <url> --out .opencli/record/x # 自定义输出目录
# Output:
# .opencli/record/<site>/captured.json ← 原始捕获数据(带 url/method/body
# .opencli/record/<site>/candidates/*.yaml ← 高置信度候选适配器(score ≥ 8,有 array 结果)
# Strategy Cascade: auto-probe PUBLIC → COOKIE → HEADER
opencli cascade <api-url>
# Explore with interactive fuzzing (click buttons to trigger lazy APIs)
opencli explore <url> --auto --click "字幕,CC,评论"
# Validate: validate adapter definitions
opencli validate
```
## Output Formats
All built-in commands support `--format` / `-f` with `table`, `json`, `yaml`, `md`, and `csv`.
The `list` command supports the same formats and also keeps `--json` as a compatibility alias.
```bash
opencli list -f yaml # YAML command registry
opencli bilibili hot -f table # Default: rich table
opencli bilibili hot -f json # JSON (pipe to jq, feed to AI agent)
opencli bilibili hot -f yaml # YAML (readable structured output)
opencli bilibili hot -f md # Markdown
opencli bilibili hot -f csv # CSV
```
## Verbose Mode
```bash
opencli bilibili hot -v # Show each pipeline step and data flow
```
## Record Workflow
`record` 是为「无法用 `explore` 自动发现」的页面(需要登录操作、复杂交互、SPA 内路由)准备的手动录制方案。
### 工作原理
```
opencli record <url>
→ 打开 automation window 并导航到目标 URL
→ 向所有 tab 注入 fetch/XHR 拦截器(幂等,可重复注入)
→ 每 2s 轮询一次:发现新 tab 自动注入,drain 所有 tab 的捕获缓冲区
→ 超时(默认 60s)或按 Enter 停止
→ 分析捕获到的 JSON 请求:去重 → 评分 → 生成候选 YAML
```
**拦截器特性**
- 同时 patch `window.fetch``XMLHttpRequest`
- 只捕获 `Content-Type: application/json` 的响应
- 过滤纯对象少于 2 个 key 的响应(避免 tracking/ping
- 跨 tab 隔离:每个 tab 独立缓冲区,轮询时分别 drain
- 幂等注入:同一 tab 二次注入时先 restore 原始函数再重新 patch,不丢失已捕获数据
### 使用步骤
```bash
# 1. 启动录制(建议 --timeout 给足操作时间)
opencli record "https://example.com/page" --timeout 120000
# 2. 在弹出的 automation window 里正常操作页面:
# - 打开列表、搜索、点击条目、切换 Tab
# - 凡是触发网络请求的操作都会被捕获
# 3. 完成操作后按 Enter 停止(或等超时自动停止)
# 4. 查看结果
cat .opencli/record/<site>/captured.json # 原始捕获
ls .opencli/record/<site>/candidates/ # 候选 YAML
```
### 页面类型与捕获预期
| 页面类型 | 预期捕获量 | 说明 |
|---------|-----------|------|
| 列表/搜索页 | 多(5~20+) | 每次搜索/翻页都会触发新请求 |
| 详情页(只读) | 少(1~5) | 首屏数据一次性返回,后续操作走 form/redirect |
| SPA 内路由跳转 | 中等 | 路由切换会触发新接口,但首屏请求在注入前已发出 |
| 需要登录的页面 | 视操作而定 | 确保 Chrome 已登录目标网站 |
> **注意**:如果页面在导航完成前就发出了大部分请求(服务端渲染 / SSR 注水),拦截器会错过这些请求。
> 解决方案:在页面加载完成后,手动触发能产生新请求的操作(搜索、翻页、切 Tab、展开折叠项等)。
### 候选 YAML → TS CLI 转换
生成的候选 YAML 是起点,通常需要转换为 TypeScript(尤其是 tae 等内部系统):
**候选 YAML 结构**(自动生成):
```yaml
site: tae
name: getList # 从 URL path 推断的名称
strategy: cookie
browser: true
pipeline:
- navigate: https://...
- evaluate: |
(async () => {
const res = await fetch('/approval/getList.json?procInsId=...', { credentials: 'include' });
const data = await res.json();
return (data?.content?.operatorRecords || []).map(item => ({ ... }));
})()
```
**转换为 TS CLI**(参考 `src/clis/tae/add-expense.ts` 风格):
```typescript
import { cli, Strategy } from '../../registry.js';
cli({
site: 'tae',
name: 'get-approval',
description: '查看报销单审批流程和操作记录',
domain: 'tae.alibaba-inc.com',
strategy: Strategy.COOKIE,
browser: true,
args: [
{ name: 'proc_ins_id', type: 'string', required: true, positional: true, help: '流程实例 IDprocInsId' },
],
columns: ['step', 'operator', 'action', 'time'],
func: async (page, kwargs) => {
await page.goto('https://tae.alibaba-inc.com/expense/pc.html?_authType=SAML');
await page.wait(2);
const result = await page.evaluate(`(async () => {
const res = await fetch('/approval/getList.json?taskId=&procInsId=${kwargs.proc_ins_id}', {
credentials: 'include'
});
const data = await res.json();
return data?.content?.operatorRecords || [];
})()`);
return (result as any[]).map((r, i) => ({
step: i + 1,
operator: r.operatorName || r.userId,
action: r.operationType,
time: r.operateTime,
}));
},
});
```
**转换要点**
1. URL 中的动态 ID`procInsId``taskId` 等)提取为 `args`
2. `captured.json` 里的真实 body 结构用于确定正确的数据路径(如 `content.operatorRecords`
3. tae 系统统一用 `{ success, content, errorCode, errorMsg }` 外层包裹,取数据要走 `content.*`
4. 认证方式:cookie`credentials: 'include'`),不需要额外 header
5. 文件放入 `src/clis/<site>/`,无需手动注册,`npm run build` 后自动发现
### 故障排查
| 现象 | 原因 | 解法 |
|------|------|------|
| 捕获 0 条请求 | 拦截器注入失败,或页面无 JSON API | 检查 daemon 是否运行:`curl localhost:19825/status` |
| 捕获量少(1~3 条) | 页面是只读详情页,首屏数据已在注入前发出 | 手动操作触发更多请求(搜索/翻页),或换用列表页 |
| 候选 YAML 为 0 | 捕获到的 JSON 都没有 array 结构 | 直接看 `captured.json` 手写 TS CLI |
| 新开的 tab 没有被拦截 | 轮询间隔内 tab 已关闭 | 缩短 `--poll 500` |
| 二次运行 record 时数据不连续 | 正常,每次 `record` 启动都是新的 automation window | 无需处理 |
## Creating Adapters
> [!TIP]
> **快速模式**:如果你只想为一个具体页面生成一个命令,直接看 [CLI-ONESHOT.md](./CLI-ONESHOT.md)。
> 只需要一个 URL + 一句话描述,4 步搞定。
> [!IMPORTANT]
> **完整模式 — 在写任何代码之前,先阅读 [CLI-EXPLORER.md](./CLI-EXPLORER.md)。**
> 它包含:① AI Agent 浏览器探索工作流 ② 认证策略决策树 ③ 平台 SDK(如 Bilibili 的 `apiGet`/`fetchJson`)④ YAML vs TS 选择指南 ⑤ `tap` 步骤调试方法 ⑥ 级联请求模板 ⑦ 常见陷阱表。
> **下方仅为简化模板参考,直接使用极易踩坑。**
### YAML Pipeline (declarative, recommended)
Create `src/clis/<site>/<name>.yaml`:
```yaml
site: mysite
name: hot
description: Hot topics
domain: www.mysite.com
strategy: cookie # public | cookie | header | intercept | ui
browser: true
args:
limit:
type: int
default: 20
description: Number of items
pipeline:
- navigate: https://www.mysite.com
- evaluate: |
(async () => {
const res = await fetch('/api/hot', { credentials: 'include' });
const d = await res.json();
return d.data.items.map(item => ({
title: item.title,
score: item.score,
}));
})()
- map:
rank: ${{ index + 1 }}
title: ${{ item.title }}
score: ${{ item.score }}
- limit: ${{ args.limit }}
columns: [rank, title, score]
```
For public APIs (no browser):
```yaml
strategy: public
browser: false
pipeline:
- fetch:
url: https://api.example.com/hot.json
- select: data.items
- map:
title: ${{ item.title }}
- limit: ${{ args.limit }}
```
### TypeScript Adapter (programmatic)
Create `src/clis/<site>/<name>.ts`. It will be automatically dynamically loaded (DO NOT manually import it in `index.ts`):
```typescript
import { cli, Strategy } from '../../registry.js';
cli({
site: 'mysite',
name: 'search',
strategy: Strategy.INTERCEPT, // Or COOKIE
args: [{ name: 'query', required: true, positional: true }],
columns: ['rank', 'title', 'url'],
func: async (page, kwargs) => {
await page.goto('https://www.mysite.com/search');
// Inject native XHR/Fetch interceptor hook
await page.installInterceptor('/api/search');
// Auto scroll down to trigger lazy loading
await page.autoScroll({ times: 3, delayMs: 2000 });
// Retrieve intercepted JSON payloads
const requests = await page.getInterceptedRequests();
let results = [];
for (const req of requests) {
results.push(...req.data.items);
}
return results.map((item, i) => ({
rank: i + 1, title: item.title, url: item.url,
}));
},
});
```
**When to use TS**: XHR interception (`page.installInterceptor`), infinite scrolling (`page.autoScroll`), cookie extraction, complex data transforms (like GraphQL unwrapping).
## Pipeline Steps
| Step | Description | Example |
|------|-------------|---------|
| `navigate` | Go to URL | `navigate: https://example.com` |
| `fetch` | HTTP request (browser cookies) | `fetch: { url: "...", params: { q: "..." } }` |
| `evaluate` | Run JavaScript in page | `evaluate: \| (async () => { ... })()` |
| `select` | Extract JSON path | `select: data.items` |
| `map` | Map fields | `map: { title: "${{ item.title }}" }` |
| `filter` | Filter items | `filter: item.score > 100` |
| `sort` | Sort items | `sort: { by: score, order: desc }` |
| `limit` | Cap result count | `limit: ${{ args.limit }}` |
| `intercept` | Declarative XHR capture | `intercept: { trigger: "navigate:...", capture: "api/hot" }` |
| `tap` | Store action + XHR capture | `tap: { store: "feed", action: "fetchFeeds", capture: "homefeed" }` |
| `snapshot` | Page accessibility tree | `snapshot: { interactive: true }` |
| `click` | Click element | `click: ${{ ref }}` |
| `type` | Type text | `type: { ref: "@1", text: "hello" }` |
| `wait` | Wait for time/text | `wait: 2` or `wait: { text: "loaded" }` |
| `press` | Press key | `press: Enter` |
## Template Syntax
```yaml
# Arguments with defaults
${{ args.query }}
${{ args.limit | default(20) }}
# Current item (in map/filter)
${{ item.title }}
${{ item.data.nested.field }}
# Index (0-based)
${{ index }}
${{ index + 1 }}
```
## 5-Tier Authentication Strategy
| Tier | Name | Method | Example |
|------|------|--------|---------|
| 1 | `public` | No auth, Node.js fetch | Hacker News, V2EX |
| 2 | `cookie` | Browser fetch with `credentials: include` | Bilibili, Zhihu |
| 3 | `header` | Custom headers (ct0, Bearer) | Twitter GraphQL |
| 4 | `intercept` | XHR interception + store mutation | 小红书 Pinia |
| 5 | `ui` | Full UI automation (click/type/scroll) | Last resort |
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `OPENCLI_DAEMON_PORT` | 19825 | Daemon listen port |
| `OPENCLI_BROWSER_CONNECT_TIMEOUT` | 30 | Browser connection timeout (sec) |
| `OPENCLI_BROWSER_COMMAND_TIMEOUT` | 45 | Command execution timeout (sec) |
| `OPENCLI_BROWSER_EXPLORE_TIMEOUT` | 120 | Explore timeout (sec) |
| `OPENCLI_VERBOSE` | — | Show daemon/extension logs |
## Troubleshooting
| Issue | Solution |
|-------|----------|
| `npx not found` | Install Node.js: `brew install node` |
| `Extension not connected` | 1) Chrome must be open 2) Install opencli Browser Bridge extension |
| `Target page context` error | Add `navigate:` step before `evaluate:` in YAML |
| Empty table data | Check if evaluate returns correct data path |
| Daemon issues | `curl localhost:19825/status` to check, `curl localhost:19825/logs` for extension logs |
+1
View File
@@ -0,0 +1 @@
56/59
+1
View File
@@ -0,0 +1 @@
31/31
+690
View File
@@ -0,0 +1,690 @@
[
{
"name": "extract-title-example",
"steps": [
"opencli operate open https://example.com",
"opencli operate eval \"document.title\""
],
"judge": {
"type": "contains",
"value": "Example Domain"
}
},
{
"name": "extract-title-iana",
"steps": [
"opencli operate open https://www.iana.org",
"opencli operate eval \"document.querySelector('h1')?.textContent\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "extract-paragraph-wiki-js",
"steps": [
"opencli operate open https://en.wikipedia.org/wiki/JavaScript",
"opencli operate eval \"(() => { const ps = document.querySelectorAll('#mw-content-text .mw-parser-output > p'); for (const p of ps) { const t = p.textContent?.trim(); if (t && t.length > 50 && !t.startsWith('{')) return t.slice(0,300); } return ''; })()\""
],
"judge": {
"type": "contains",
"value": "programming language"
}
},
{
"name": "extract-paragraph-wiki-python",
"steps": [
"opencli operate open \"https://en.wikipedia.org/wiki/Python_(programming_language)\"",
"opencli operate eval \"(() => { const ps = document.querySelectorAll('#mw-content-text .mw-parser-output > p'); for (const p of ps) { const t = p.textContent?.trim(); if (t && t.length > 50 && !t.startsWith('{')) return t.slice(0,300); } return ''; })()\""
],
"judge": {
"type": "contains",
"value": "programming language"
}
},
{
"name": "extract-github-stars",
"steps": [
"opencli operate open https://github.com/browser-use/browser-use",
"opencli operate eval \"document.querySelector('#repo-stars-counter-star')?.textContent?.trim()\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\\d"
}
},
{
"name": "extract-github-description",
"steps": [
"opencli operate open https://github.com/anthropics/claude-code",
"opencli operate eval \"document.querySelector('p.f4, [data-testid=about-description], .f4.my-3, .BorderGrid-cell p')?.textContent?.trim()\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "extract-github-readme-heading",
"steps": [
"opencli operate open https://github.com/vercel/next.js",
"opencli operate eval \"document.querySelector('article h1, article h2')?.textContent?.trim()\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "extract-npm-downloads",
"steps": [
"opencli operate open https://www.npmjs.com/package/zod",
"opencli operate eval \"document.querySelector('[data-nosnippet]')?.textContent?.trim() || document.querySelector('p.f2874b88')?.textContent?.trim()\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\\d"
}
},
{
"name": "extract-npm-description",
"steps": [
"opencli operate open https://www.npmjs.com/package/express",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var ps=document.querySelectorAll('p');for(var i=0;i<ps.length;i++){var t=ps[i].textContent.trim();if(t.length>10&&t.length<200)return t;}return '';})()\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "list-hn-top5",
"steps": [
"opencli operate open https://news.ycombinator.com",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('.titleline > a')].slice(0,5).map(a=>({title:a.textContent,url:a.href})))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 5
}
},
{
"name": "list-hn-top10",
"steps": [
"opencli operate open https://news.ycombinator.com",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('.athing')].slice(0,10).map(tr=>{const a=tr.querySelector('.titleline>a');const s=tr.nextElementSibling?.querySelector('.score');return{title:a?.textContent,score:parseInt(s?.textContent)||0}}))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 10
}
},
{
"name": "list-books-5",
"steps": [
"opencli operate open https://books.toscrape.com",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('article.product_pod')].slice(0,5).map(el=>({title:el.querySelector('h3 a')?.getAttribute('title'),price:el.querySelector('.price_color')?.textContent})))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 5
}
},
{
"name": "list-books-10",
"steps": [
"opencli operate open https://books.toscrape.com",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('article.product_pod')].slice(0,10).map(el=>({title:el.querySelector('h3 a')?.getAttribute('title'),price:el.querySelector('.price_color')?.textContent})))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 10
}
},
{
"name": "list-quotes-3",
"steps": [
"opencli operate open https://quotes.toscrape.com",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('.quote')].slice(0,3).map(el=>({text:el.querySelector('.text')?.textContent,author:el.querySelector('.author')?.textContent})))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 3
}
},
{
"name": "list-quotes-tags",
"steps": [
"opencli operate open https://quotes.toscrape.com",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('.quote')].slice(0,5).map(el=>({text:el.querySelector('.text')?.textContent,author:el.querySelector('.author')?.textContent,tags:[...el.querySelectorAll('.tag')].map(t=>t.textContent)})))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 5
}
},
{
"name": "list-github-trending",
"steps": [
"opencli operate open https://github.com/trending",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('article.Box-row')].slice(0,3).map(el=>({name:el.querySelector('h2 a')?.textContent?.trim().replace(/\\s+/g,' '),desc:el.querySelector('p')?.textContent?.trim()})))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 3
}
},
{
"name": "list-github-trending-lang",
"steps": [
"opencli operate open https://github.com/trending/python",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('article.Box-row')].slice(0,5).map(el=>({name:el.querySelector('h2 a')?.textContent?.trim().replace(/\\s+/g,' ')})))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 5
}
},
{
"name": "list-jsonplaceholder-posts",
"steps": [
"opencli operate open https://jsonplaceholder.typicode.com/posts",
"opencli operate eval \"JSON.stringify(JSON.parse(document.body.innerText).slice(0,5).map(p=>({id:p.id,title:p.title})))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 5
}
},
{
"name": "list-jsonplaceholder-users",
"steps": [
"opencli operate open https://jsonplaceholder.typicode.com/users",
"opencli operate eval \"JSON.stringify(JSON.parse(document.body.innerText).map(u=>({name:u.name,email:u.email})))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 5
}
},
{
"name": "search-google",
"steps": [
"opencli operate open https://www.google.com",
"opencli operate eval \"document.querySelector('textarea[name=q], input[name=q]').value='opencli github';document.querySelector('form').submit();'submitted'\"",
"opencli operate wait time 3",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('h3')].slice(0,3).map(h=>h.textContent))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 3
},
"note": "index 5 may vary"
},
{
"name": "search-ddg",
"steps": [
"opencli operate open https://duckduckgo.com",
"opencli operate state",
"opencli operate type 1 \"weather beijing\"",
"opencli operate keys Enter",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('[data-testid=result-title-a]')].slice(0,3).map(a=>a.textContent))\""
],
"judge": {
"type": "nonEmpty"
},
"note": "index may vary"
},
{
"name": "search-ddg-tech",
"steps": [
"opencli operate open https://duckduckgo.com",
"opencli operate eval \"document.querySelector('input[name=q]').value='TypeScript tutorial';document.querySelector('form').submit();'submitted'\"",
"opencli operate wait time 3",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('[data-testid=result-title-a], .result__a')].slice(0,3).map(a=>({title:a.textContent,url:a.href})))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 3
},
"note": "index may vary"
},
{
"name": "search-wiki",
"steps": [
"opencli operate open https://en.wikipedia.org",
"opencli operate eval \"document.querySelector('input[name=search]').value='Rust programming language';document.querySelector('form#searchform, form[role=search]').submit();'submitted'\"",
"opencli operate wait time 3",
"opencli operate eval \"(() => { const ps = document.querySelectorAll('#mw-content-text .mw-parser-output > p'); for (const p of ps) { const t = p.textContent?.trim(); if (t && t.length > 50) return t.slice(0,300); } return ''; })()\""
],
"judge": {
"type": "contains",
"value": "programming language"
},
"note": "index may vary"
},
{
"name": "search-npm",
"steps": [
"opencli operate open https://www.npmjs.com",
"opencli operate state",
"opencli operate type 1 \"react\"",
"opencli operate keys Enter",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('[data-testid=pkg-list-item] h3, section h3')].slice(0,3).map(h=>h.textContent?.trim()))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 3
},
"note": "index may vary"
},
{
"name": "search-github",
"steps": [
"opencli operate open https://github.com/search?q=browser+automation&type=repositories",
"opencli operate wait time 3",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('.search-title a, [data-testid=results-list] a.Link--primary')].slice(0,3).map(a=>a.textContent?.trim().replace(/\\\\s+/g,' ')))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 3
},
"note": "index may vary"
},
{
"name": "nav-click-link-example",
"steps": [
"opencli operate open https://example.com",
"opencli operate eval \"document.querySelector('a')?.click();'clicked'\"",
"opencli operate wait time 2",
"opencli operate eval \"document.title + ' ' + location.href\""
],
"judge": {
"type": "contains",
"value": "IANA"
}
},
{
"name": "nav-click-hn-first",
"steps": [
"opencli operate open https://news.ycombinator.com",
"opencli operate eval \"document.querySelector('.titleline a')?.click();'clicked'\"",
"opencli operate wait time 2",
"opencli operate eval \"document.title\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "nav-click-hn-comments",
"steps": [
"opencli operate open https://news.ycombinator.com",
"opencli operate eval \"document.querySelector('.subtext a:last-child')?.click(); 'clicked'\"",
"opencli operate eval \"document.title\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "nav-click-wiki-link",
"steps": [
"opencli operate open https://en.wikipedia.org/wiki/JavaScript",
"opencli operate eval \"document.querySelector('#toc a, .toc a, [href=\\\"#History\\\"]')?.click(); 'clicked'\"",
"opencli operate eval \"document.querySelector('#History, #History ~ p')?.textContent?.slice(0,100)\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "nav-click-github-tab",
"steps": [
"opencli operate open https://github.com/vercel/next.js",
"opencli operate eval \"document.querySelector('[data-tab-item=i1issues-tab] a, #issues-tab')?.click(); 'clicked'\"",
"opencli operate eval \"document.title\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "nav-go-back",
"steps": [
"opencli operate open https://example.com",
"opencli operate eval \"document.querySelector('a')?.click();'clicked'\"",
"opencli operate wait time 2",
"opencli operate back",
"opencli operate wait time 2",
"opencli operate eval \"document.title\""
],
"judge": {
"type": "contains",
"value": "Example Domain"
}
},
{
"name": "nav-multi-step",
"steps": [
"opencli operate open https://quotes.toscrape.com",
"opencli operate eval \"document.querySelector('.next a')?.click(); 'clicked'\"",
"opencli operate eval \"document.querySelector('.quote .text')?.textContent\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "scroll-footer-quotes",
"steps": [
"opencli operate open https://quotes.toscrape.com",
"opencli operate scroll down",
"opencli operate scroll down",
"opencli operate eval \"document.querySelector('footer, .footer, .tags-box')?.textContent?.trim().slice(0,100)\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "scroll-footer-books",
"steps": [
"opencli operate open https://books.toscrape.com",
"opencli operate scroll down",
"opencli operate scroll down",
"opencli operate eval \"document.querySelector('.pager .current')?.textContent?.trim()\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\\d"
}
},
{
"name": "scroll-long-page",
"steps": [
"opencli operate open https://jsonplaceholder.typicode.com/posts",
"opencli operate eval \"JSON.parse(document.body.innerText).length\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\\d"
}
},
{
"name": "scroll-find-element",
"steps": [
"opencli operate open https://quotes.toscrape.com",
"opencli operate eval \"document.querySelector('.next a')?.href\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "scroll-lazy-load",
"steps": [
"opencli operate open https://books.toscrape.com",
"opencli operate eval \"document.querySelectorAll('article.product_pod').length\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\\d"
}
},
{
"name": "form-simple-name",
"steps": [
"opencli operate open https://httpbin.org/forms/post",
"opencli operate eval \"var el=document.querySelector('[name=custname]');el.value='OpenCLI Test';el.dispatchEvent(new Event('input',{bubbles:true}));el.value\""
],
"judge": {
"type": "contains",
"value": "OpenCLI"
},
"note": "index may vary"
},
{
"name": "form-text-inputs",
"steps": [
"opencli operate open https://httpbin.org/forms/post",
"opencli operate eval \"var n=document.querySelector('[name=custname]');n.value='Alice';n.dispatchEvent(new Event('input',{bubbles:true}));var t=document.querySelector('[name=custtel]');t.value='555-1234';t.dispatchEvent(new Event('input',{bubbles:true}));n.value+'|'+t.value\""
],
"judge": {
"type": "contains",
"value": "Alice"
},
"note": "index may vary"
},
{
"name": "form-radio-select",
"steps": [
"opencli operate open https://httpbin.org/forms/post",
"opencli operate eval \"document.querySelector('[value=medium]').checked=true;document.querySelector('[value=medium]').dispatchEvent(new Event('change',{bubbles:true}));document.querySelector('[value=medium]').checked\""
],
"judge": {
"type": "contains",
"value": "true"
}
},
{
"name": "form-checkbox",
"steps": [
"opencli operate open https://httpbin.org/forms/post",
"opencli operate eval \"var cb=document.querySelector('[value=cheese]');cb.checked=true;cb.dispatchEvent(new Event('change',{bubbles:true}));cb.checked\""
],
"judge": {
"type": "contains",
"value": "true"
}
},
{
"name": "form-textarea",
"steps": [
"opencli operate open https://httpbin.org/forms/post",
"opencli operate eval \"var ta=document.querySelector('textarea[name=comments]');ta.value='AutoResearch test';ta.dispatchEvent(new Event('input',{bubbles:true}));ta.value\""
],
"judge": {
"type": "contains",
"value": "AutoResearch"
}
},
{
"name": "form-login-fake",
"steps": [
"opencli operate open https://the-internet.herokuapp.com/login",
"opencli operate eval \"var u=document.querySelector('#username');u.value='testuser';u.dispatchEvent(new Event('input',{bubbles:true}));var p=document.querySelector('#password');p.value='testpass';p.dispatchEvent(new Event('input',{bubbles:true}));u.value+'|'+p.value\""
],
"judge": {
"type": "contains",
"value": "testuser"
},
"note": "index may vary"
},
{
"name": "complex-wiki-toc",
"steps": [
"opencli operate open https://en.wikipedia.org/wiki/JavaScript",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('.toc li a, #toc li a, .vector-toc-contents a')].slice(0,8).map(a=>a.textContent?.trim()))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 5
}
},
{
"name": "complex-books-detail",
"steps": [
"opencli operate open https://books.toscrape.com",
"opencli operate eval \"document.querySelector('article.product_pod h3 a')?.click();'clicked'\"",
"opencli operate eval \"JSON.stringify({title:document.querySelector('h1')?.textContent,price:document.querySelector('.price_color')?.textContent})\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "complex-quotes-page2",
"steps": [
"opencli operate open https://quotes.toscrape.com",
"opencli operate eval \"document.querySelector('.next a')?.click();'clicked'\"",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('.quote')].slice(0,3).map(el=>({text:el.querySelector('.text')?.textContent,author:el.querySelector('.author')?.textContent})))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 3
}
},
{
"name": "complex-github-repo-info",
"steps": [
"opencli operate open https://github.com/expressjs/express",
"opencli operate eval \"JSON.stringify({lang:document.querySelector('[itemprop=programmingLanguage]')?.textContent?.trim(),license:document.querySelector('[data-analytics-event*=license] span, .Layout-sidebar [href*=LICENSE]')?.textContent?.trim()})\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "complex-hn-story-comments",
"steps": [
"opencli operate open https://news.ycombinator.com",
"opencli operate eval \"document.querySelector('.subtext a:last-child')?.click();'clicked'\"",
"opencli operate eval \"document.querySelector('.fatitem .titleline a')?.textContent\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "complex-multi-extract",
"steps": [
"opencli operate open https://en.wikipedia.org/wiki/TypeScript",
"opencli operate eval \"JSON.stringify({title:document.title,firstParagraph:document.querySelector('#mw-content-text p')?.textContent?.slice(0,150)})\""
],
"judge": {
"type": "contains",
"value": "TypeScript"
}
},
{
"name": "bench-reddit-top5",
"steps": [
"opencli operate open https://old.reddit.com",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('#siteTable .thing .title a.title')].slice(0,5).map(a=>a.textContent))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 5
},
"set": "test"
},
{
"name": "bench-imdb-matrix",
"steps": [
"opencli operate open https://www.imdb.com/title/tt0133093/",
"opencli operate wait time 3",
"opencli operate eval \"(function(){var title=document.querySelector('h1')?.textContent?.trim()||'';var year='';var links=document.querySelectorAll('a');for(var i=0;i<links.length;i++){if(links[i].textContent.trim()==='1999'){year='1999';break;}}var rating=document.querySelector('[data-testid=hero-rating-bar__aggregate-rating__score] span, .sc-bde20123-1')?.textContent?.trim()||'';return JSON.stringify({title:title,year:year,rating:rating});})()\""
],
"judge": {
"type": "contains",
"value": "1999"
},
"set": "test"
},
{
"name": "bench-npm-zod",
"steps": [
"opencli operate open https://www.npmjs.com/package/zod",
"opencli operate eval \"JSON.stringify({name:document.querySelector('h1 span, #top h2')?.textContent?.trim(),description:document.querySelector('[data-testid=package-description], p.package-description-redundant')?.textContent?.trim()})\""
],
"judge": {
"type": "nonEmpty"
},
"set": "test"
},
{
"name": "bench-wiki-search",
"steps": [
"opencli operate open https://en.wikipedia.org/wiki/Machine_learning",
"opencli operate eval \"(() => { const ps = document.querySelectorAll('#mw-content-text .mw-parser-output > p'); for (const p of ps) { const t = p.textContent?.trim(); if (t && t.length > 50) return t.slice(0,300); } return ''; })()\""
],
"judge": {
"type": "contains",
"value": "learning"
},
"set": "test"
},
{
"name": "bench-github-profile",
"steps": [
"opencli operate open https://github.com/torvalds",
"opencli operate eval \"JSON.stringify({name:document.querySelector('[itemprop=name]')?.textContent?.trim(),bio:document.querySelector('[data-bio-text]')?.textContent?.trim()||document.querySelector('.p-note')?.textContent?.trim()})\""
],
"judge": {
"type": "nonEmpty"
},
"set": "test"
},
{
"name": "bench-books-category",
"steps": [
"opencli operate open https://books.toscrape.com",
"opencli operate eval \"document.querySelector('a[href*=science]')?.click();'clicked'\"",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('article.product_pod h3 a')].slice(0,3).map(a=>a.getAttribute('title')))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 3
},
"set": "test"
},
{
"name": "bench-quotes-author",
"steps": [
"opencli operate open https://quotes.toscrape.com",
"opencli operate eval \"document.querySelector('.author + a, a[href*=author]')?.click();'clicked'\"",
"opencli operate eval \"document.querySelector('.author-description, .author-details p')?.textContent?.slice(0,100)\""
],
"judge": {
"type": "nonEmpty"
},
"set": "test"
},
{
"name": "bench-ddg-images",
"steps": [
"opencli operate open https://duckduckgo.com",
"opencli operate eval \"document.querySelector('input[name=q]').value='sunset';document.querySelector('form').submit();'submitted'\"",
"opencli operate wait time 3",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('[data-testid=result-title-a], .result__a')].slice(0,3).map(a=>a.textContent))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 3
},
"set": "test",
"note": "index may vary"
},
{
"name": "bench-httpbin-headers",
"steps": [
"opencli operate open https://httpbin.org/headers",
"opencli operate eval \"JSON.parse(document.body.innerText).headers['User-Agent']\""
],
"judge": {
"type": "nonEmpty"
},
"set": "test"
},
{
"name": "bench-jsonapi-todo",
"steps": [
"opencli operate open https://jsonplaceholder.typicode.com/todos",
"opencli operate eval \"JSON.stringify(JSON.parse(document.body.innerText).slice(0,5).map(t=>({id:t.id,title:t.title,completed:t.completed})))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 5
},
"set": "test"
}
]
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env npx tsx
/**
* /autoresearch:debug — Hypothesis-driven debugging for specific failing tasks.
*
* Scientific method: Gather → Hypothesize → Test → Classify → Log → Repeat
*
* Usage:
* npx tsx autoresearch/commands/debug.ts --task extract-npm-description
* npx tsx autoresearch/commands/debug.ts --task bench-imdb-matrix --iterations 5
*/
import { execSync } from 'node:child_process';
import { readFileSync, appendFileSync, writeFileSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseArgs } from '../config.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..', '..');
const TASKS_FILE = join(__dirname, '..', 'browse-tasks.json');
const DEBUG_LOG = join(ROOT, 'debug-results.tsv');
interface BrowseTask {
name: string;
steps: string[];
judge: { type: string; value?: string; minLength?: number; pattern?: string };
}
function exec(cmd: string): string {
try {
return execSync(cmd, {
cwd: ROOT, timeout: 30_000, encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
} catch (err: any) {
return err.stdout?.trim() ?? err.message ?? '';
}
}
function initLog(): void {
if (!existsSync(DEBUG_LOG)) {
writeFileSync(DEBUG_LOG, '# AutoResearch Debug Log\niteration\ttask\thypothesis\tresult\tverdict\tdescription\n', 'utf-8');
}
}
function appendLog(iteration: number, task: string, hypothesis: string, result: string, verdict: string, description: string): void {
appendFileSync(DEBUG_LOG, `${iteration}\t${task}\t${hypothesis}\t${result}\t${verdict}\t${description}\n`, 'utf-8');
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const taskName = args.task;
const maxIterations = args.iterations ?? 10;
if (!taskName) {
console.error('Usage: npx tsx autoresearch/commands/debug.ts --task <task-name> [--iterations N]');
console.error('\nAvailable tasks:');
const tasks: BrowseTask[] = JSON.parse(readFileSync(TASKS_FILE, 'utf-8'));
// Show only failing tasks
for (const task of tasks) {
try { exec('opencli operate close'); } catch {}
let lastOutput = '';
for (const step of task.steps) lastOutput = exec(step);
const passed = lastOutput.trim().length > 0; // simplified check
if (!passed) console.error(`${task.name}`);
}
process.exit(1);
}
const tasks: BrowseTask[] = JSON.parse(readFileSync(TASKS_FILE, 'utf-8'));
const task = tasks.find(t => t.name === taskName);
if (!task) {
console.error(`Task not found: ${taskName}`);
process.exit(1);
}
console.log(`\n🔍 AutoResearch Debug: ${taskName}`);
console.log(` Steps: ${task.steps.length}`);
console.log(` Judge: ${task.judge.type}${task.judge.value ? ` "${task.judge.value}"` : ''}`);
console.log(` Max iterations: ${maxIterations}\n`);
initLog();
// Phase 1: Gather — run the task and capture output
console.log('Phase 1: Gathering symptoms...');
try { exec('opencli operate close'); } catch {}
let lastOutput = '';
for (let i = 0; i < task.steps.length; i++) {
const step = task.steps[i];
console.log(` Step ${i + 1}: ${step.slice(0, 80)}`);
lastOutput = exec(step);
if (i < task.steps.length - 1) {
console.log(`${lastOutput.slice(0, 100)}`);
}
}
console.log(`\n Final output: ${lastOutput.slice(0, 200)}`);
console.log(` Judge expects: ${JSON.stringify(task.judge)}`);
// Phase 2: Hypothesize + investigate via Claude Code
for (let iter = 1; iter <= maxIterations; iter++) {
console.log(`\n━━━ Debug Iteration ${iter}/${maxIterations} ━━━`);
const prompt = `You are debugging a failing browser automation task.
## Task: ${taskName}
Steps:
${task.steps.map((s, i) => ` ${i + 1}. ${s}`).join('\n')}
## Judge criteria
${JSON.stringify(task.judge)}
## Last output
${lastOutput.slice(0, 500)}
## Instructions
1. Form a SPECIFIC, FALSIFIABLE hypothesis about why this task fails
2. Run the MINIMUM experiment to test your hypothesis (e.g. run one step, check output)
3. Classify: CONFIRMED (bug found), DISPROVEN (try different hypothesis), INCONCLUSIVE
4. If CONFIRMED: describe the root cause and suggest a fix
5. Output format: one line "HYPOTHESIS: ...", one line "RESULT: CONFIRMED|DISPROVEN|INCONCLUSIVE — ..."
Do NOT fix the code — just diagnose. Use opencli operate commands to investigate.`;
try {
const result = execSync(
`claude -p --dangerously-skip-permissions --allowedTools "Bash(opencli:*),Bash(npm:*),Read,Grep,Glob" --output-format text --no-session-persistence "${prompt.replace(/"/g, '\\"')}"`,
{ cwd: ROOT, timeout: 120_000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
).trim();
// Extract hypothesis and result
const hypMatch = result.match(/HYPOTHESIS:\s*(.+)/i);
const resMatch = result.match(/RESULT:\s*(CONFIRMED|DISPROVEN|INCONCLUSIVE)\s*[-—]\s*(.+)/i);
const hypothesis = hypMatch?.[1]?.trim() ?? 'unknown';
const verdict = resMatch?.[1]?.trim() ?? 'INCONCLUSIVE';
const description = resMatch?.[2]?.trim() ?? result.split('\n').pop()?.trim() ?? '';
console.log(` Hypothesis: ${hypothesis.slice(0, 100)}`);
console.log(` Verdict: ${verdict}${description.slice(0, 100)}`);
appendLog(iter, taskName, hypothesis, lastOutput.slice(0, 50), verdict, description);
if (verdict === 'CONFIRMED') {
console.log(`\n✅ Root cause found at iteration ${iter}!`);
console.log(` ${description}`);
break;
}
} catch (err: any) {
console.error(` Error: ${err.message?.slice(0, 100)}`);
appendLog(iter, taskName, 'error', '', 'CRASH', err.message?.slice(0, 80) ?? '');
}
// Re-run task for fresh output
try { exec('opencli operate close'); } catch {}
for (const step of task.steps) lastOutput = exec(step);
}
try { exec('opencli operate close'); } catch {}
console.log(`\nDebug log saved to: ${DEBUG_LOG}\n`);
}
main();
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env npx tsx
/**
* /autoresearch:fix — Iterative error elimination.
*
* Auto-detects broken state (build → test → browse tests) and iteratively
* fixes errors one at a time. Stops when error count reaches 0.
*
* Priority: build errors → test failures → browse task failures
*
* Usage:
* npx tsx autoresearch/commands/fix.ts
* npx tsx autoresearch/commands/fix.ts --iterations 10
*/
import { execSync } from 'node:child_process';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseArgs } from '../config.js';
import { Engine, type ModifyContext } from '../engine.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..', '..');
function exec(cmd: string): { ok: boolean; output: string } {
try {
const output = execSync(cmd, {
cwd: ROOT, timeout: 120_000, encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
return { ok: true, output };
} catch (err: any) {
return { ok: false, output: (err.stdout ?? '') + '\n' + (err.stderr ?? '') };
}
}
/** Detect current broken state and return verify command + error count */
function detectBrokenState(): { verify: string; errors: number; description: string } | null {
// 1. Build
const build = exec('npm run build 2>&1');
if (!build.ok) {
const errorCount = (build.output.match(/error TS/g) || []).length || 1;
return {
verify: 'npm run build 2>&1 | grep -c "error TS" || echo 0',
errors: errorCount,
description: `${errorCount} TypeScript build error(s)`,
};
}
// 2. Tests
const test = exec('npm test 2>&1');
if (!test.ok) {
const failMatch = test.output.match(/(\d+)\s+fail/i);
const errorCount = failMatch ? parseInt(failMatch[1], 10) : 1;
return {
verify: 'npm test 2>&1 | grep -oP "\\d+(?= fail)" || echo 0',
errors: errorCount,
description: `${errorCount} test failure(s)`,
};
}
// 3. Browse tests
const browse = exec('npx tsx autoresearch/eval-browse.ts 2>&1');
const scoreMatch = browse.output.match(/SCORE=(\d+)\/(\d+)/);
if (scoreMatch) {
const passed = parseInt(scoreMatch[1], 10);
const total = parseInt(scoreMatch[2], 10);
const failures = total - passed;
if (failures > 0) {
return {
verify: 'npx tsx autoresearch/eval-browse.ts 2>&1 | tail -1',
errors: failures,
description: `${failures} browse task failure(s) (${passed}/${total})`,
};
}
}
return null; // all clean
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const maxIterations = args.iterations ?? 20;
console.log('\n🔧 AutoResearch Fix — Detecting broken state...\n');
const broken = detectBrokenState();
if (!broken) {
console.log(' ✓ All clean — nothing to fix!\n');
return;
}
console.log(` Found: ${broken.description}`);
console.log(` Verify: ${broken.verify}\n`);
const config = {
goal: `Fix all errors: ${broken.description}`,
scope: ['src/**/*.ts', 'extension/src/**/*.ts'],
metric: 'error_count',
direction: 'lower' as const,
verify: broken.verify,
guard: 'npm run build',
iterations: maxIterations,
minDelta: 1,
};
const logPath = join(ROOT, 'autoresearch-results.tsv');
const engine = new Engine(config, logPath, {
modify: async (ctx: ModifyContext) => {
const prompt = `Fix ONE error. Current error count: ${ctx.currentMetric}. Goal: 0 errors.
Read the error output, understand the root cause, and make ONE focused fix.
Do NOT fix multiple unrelated errors at once.
Do NOT modify test files.
${ctx.stuckHint ? `STUCK HINT: ${ctx.stuckHint}` : ''}`;
try {
const result = execSync(
`claude -p --dangerously-skip-permissions --allowedTools "Bash(npm:*),Bash(npx:*),Read,Edit,Write,Glob,Grep" --output-format text --no-session-persistence "${prompt.replace(/"/g, '\\"')}"`,
{ cwd: ROOT, timeout: 180_000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
).trim();
const lines = result.split('\n').filter(l => l.trim());
return lines[lines.length - 1]?.trim()?.slice(0, 120) || 'fix attempt';
} catch {
return null;
}
},
onStatus: (msg) => console.log(msg),
});
try {
const results = await engine.run();
const finalMetric = results[results.length - 1]?.metric ?? broken.errors;
if (finalMetric === 0) {
console.log('\n✅ All errors fixed!\n');
} else {
console.log(`\n⚠ ${finalMetric} error(s) remaining after ${maxIterations} iterations.\n`);
}
} catch (err: any) {
console.error(`\n❌ ${err.message}`);
process.exit(1);
}
}
main();
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env npx tsx
/**
* /autoresearch:plan — Interactive configuration wizard.
*
* Walks through goal, scope, metric, verify, guard settings
* and outputs a ready-to-paste run command.
*
* Usage:
* npx tsx autoresearch/commands/plan.ts
*/
import { execSync } from 'node:child_process';
import { createInterface } from 'node:readline';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { PRESETS } from '../presets/index.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..', '..');
const rl = createInterface({ input: process.stdin, output: process.stdout });
const ask = (q: string): Promise<string> => new Promise(r => rl.question(q, r));
async function main() {
console.log('\n🔬 AutoResearch — Configuration Wizard\n');
// Offer presets first
const presetNames = Object.keys(PRESETS);
console.log('Available presets:');
presetNames.forEach((name, i) => {
console.log(` [${i + 1}] ${name}${PRESETS[name].goal}`);
});
console.log(` [0] Custom config\n`);
const choice = await ask('Choose preset or 0 for custom: ');
const idx = parseInt(choice, 10);
if (idx > 0 && idx <= presetNames.length) {
const name = presetNames[idx - 1];
const iterations = await ask('Iterations (empty = unbounded): ');
const iterFlag = iterations ? ` --iterations ${iterations}` : '';
console.log(`\n✅ Ready to run:\n`);
console.log(` npx tsx autoresearch/commands/run.ts --preset ${name}${iterFlag}\n`);
rl.close();
return;
}
// Custom config
const goal = await ask('Goal (what to improve): ');
const scope = await ask('Scope (file globs, comma-separated): ');
const metric = await ask('Metric name (e.g. pass_count, coverage): ');
const direction = await ask('Direction (higher/lower): ') as 'higher' | 'lower';
const verify = await ask('Verify command (must output a number): ');
// Dry-run verify
console.log('\n Dry-running verify command...');
try {
const output = execSync(verify, { cwd: ROOT, timeout: 120_000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
const { extractMetric } = await import('../config.js');
const value = extractMetric(output);
if (value != null) {
console.log(` ✓ Verify works — current ${metric}: ${value}`);
} else {
console.log(` ⚠ Verify ran but no number extracted from output:\n ${output.slice(0, 200)}`);
}
} catch (err: any) {
console.log(` ✗ Verify failed: ${err.message?.slice(0, 100)}`);
}
const guard = await ask('Guard command (optional, press Enter to skip): ');
const iterations = await ask('Iterations (empty = unbounded): ');
const parts = ['npx tsx autoresearch/commands/run.ts'];
parts.push(`--goal "${goal}"`);
parts.push(`--scope "${scope}"`);
parts.push(`--metric "${metric}"`);
parts.push(`--direction ${direction}`);
parts.push(`--verify "${verify}"`);
if (guard) parts.push(`--guard "${guard}"`);
if (iterations) parts.push(`--iterations ${iterations}`);
console.log(`\n✅ Ready to run:\n`);
console.log(` ${parts.join(' \\\n ')}\n`);
rl.close();
}
main();
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env npx tsx
/**
* /autoresearch — Main autonomous iteration loop.
*
* Usage:
* npx tsx autoresearch/commands/run.ts --preset operate-reliability
* npx tsx autoresearch/commands/run.ts --preset operate-reliability --iterations 5
* npx tsx autoresearch/commands/run.ts --goal "..." --scope "src/*.ts" --verify "..." --iterations 10
*
* The modify callback spawns Claude Code to make ONE atomic change per iteration.
* Engine handles commit, verify, guard, keep/discard, and logging.
*/
import { execSync } from 'node:child_process';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseArgs, type AutoResearchConfig } from '../config.js';
import { Engine, type ModifyContext } from '../engine.js';
import { PRESETS } from '../presets/index.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..', '..');
function buildModifyPrompt(ctx: ModifyContext, config: AutoResearchConfig): string {
const recent = ctx.recentLog.slice(-10).map(r =>
` ${r.status.padEnd(12)} ${r.description}`
).join('\n');
return `You are an autonomous improvement agent. Make ONE atomic change to improve this metric.
## Goal
${config.goal}
## Current State
- Metric (${config.metric}): ${ctx.currentMetric} (best: ${ctx.bestMetric})
- Iteration: ${ctx.iteration}
- Consecutive discards: ${ctx.consecutiveDiscards}
${ctx.stuckHint ? `\n## STUCK — Try a Different Approach\n${ctx.stuckHint}` : ''}
## Recent History
${recent || ' (no history yet)'}
## Git Log (recent experiments)
${ctx.gitLog.split('\n').slice(0, 10).join('\n')}
## Scope (files you can modify)
${ctx.scopeFiles.join('\n')}
## Rules
1. Make ONE atomic change (one logical intent, even if multiple files)
2. Read the failing test output or code BEFORE modifying
3. DO NOT modify test files or the verify command
4. Describe what you changed in one sentence (no "and" linking unrelated actions)
5. If previous approach was discarded, try something DIFFERENT
6. Focus on the specific failures — read error messages carefully`;
}
async function modify(ctx: ModifyContext, config: AutoResearchConfig): Promise<string | null> {
const prompt = buildModifyPrompt(ctx, config);
console.log(' Claude Code making a change...');
try {
const result = execSync(
`claude -p --dangerously-skip-permissions --allowedTools "Bash(npm:*),Bash(npx:*),Bash(git:*),Read,Edit,Write,Glob,Grep" --output-format text --no-session-persistence "${prompt.replace(/"/g, '\\"')}"`,
{
cwd: ROOT,
timeout: 180_000,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env: process.env,
}
).trim();
// Extract description from Claude's response (last non-empty line or summary)
const lines = result.split('\n').filter(l => l.trim());
const desc = lines[lines.length - 1]?.trim() || 'change made by Claude Code';
return desc.slice(0, 120);
} catch (err: any) {
console.error(' Claude Code failed:', err.message?.slice(0, 100));
return null;
}
}
async function main() {
const args = parseArgs(process.argv.slice(2));
// Resolve config from preset or CLI args
let config: AutoResearchConfig;
if (args.preset) {
config = PRESETS[args.preset];
if (!config) {
console.error(`Unknown preset: ${args.preset}`);
console.error(`Available: ${Object.keys(PRESETS).join(', ')}`);
process.exit(1);
}
// Allow CLI overrides
if (args.iterations != null) config = { ...config, iterations: args.iterations };
if (args.guard != null) config = { ...config, guard: args.guard };
} else if (args.goal && args.verify) {
config = {
goal: args.goal,
scope: args.scope ?? ['src/**/*.ts'],
metric: args.metric ?? 'score',
direction: args.direction ?? 'higher',
verify: args.verify,
guard: args.guard,
iterations: args.iterations,
minDelta: args.minDelta,
};
} else {
console.error('Usage: npx tsx autoresearch/commands/run.ts --preset <name> [--iterations N]');
console.error(' or: npx tsx autoresearch/commands/run.ts --goal "..." --verify "..." --scope "..."');
console.error(`\nAvailable presets: ${Object.keys(PRESETS).join(', ')}`);
process.exit(1);
}
console.log(`\n🔬 AutoResearch: ${config.goal}`);
console.log(` Metric: ${config.metric} (${config.direction})`);
console.log(` Verify: ${config.verify}`);
console.log(` Guard: ${config.guard ?? '(none)'}`);
console.log(` Iterations: ${config.iterations ?? '∞'}`);
console.log('');
const logPath = join(ROOT, 'autoresearch-results.tsv');
const engine = new Engine(config, logPath, {
modify: (ctx) => modify(ctx, config),
onStatus: (msg) => console.log(msg),
});
try {
await engine.run();
} catch (err: any) {
console.error(`\n❌ ${err.message}`);
process.exit(1);
}
}
main();
+82
View File
@@ -0,0 +1,82 @@
/**
* AutoResearch Configuration — type definitions and CLI parsing.
*
* Based on Karpathy's autoresearch: constraint + mechanical metric + unbounded loop.
*/
export interface AutoResearchConfig {
/** Plain-language goal, e.g. "Increase operate pass rate to 59/59" */
goal: string;
/** Glob patterns for files the agent can modify */
scope: string[];
/** What the metric measures, e.g. "pass_count" */
metric: string;
/** Whether improvement means the number goes up or down */
direction: 'higher' | 'lower';
/** Shell command that outputs a number (the metric value) */
verify: string;
/** Optional guard command — must pass for a keep decision */
guard?: string;
/** Max iterations (undefined = unbounded) */
iterations?: number;
/** Minimum delta to count as real improvement (noise filter) */
minDelta?: number;
}
export type IterationStatus =
| 'baseline'
| 'keep'
| 'keep (reworked)'
| 'discard'
| 'crash'
| 'no-op'
| 'hook-blocked';
export interface IterationResult {
iteration: number;
commit: string;
metric: number;
delta: number;
guard: 'pass' | 'fail' | '-';
status: IterationStatus;
description: string;
}
/** Parse CLI args into a partial config (missing fields filled by preset or prompts) */
export function parseArgs(argv: string[]): Partial<AutoResearchConfig> & { preset?: string; task?: string } {
const config: Partial<AutoResearchConfig> & { preset?: string; task?: string } = {};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
const next = argv[i + 1];
switch (arg) {
case '--preset': config.preset = next; i++; break;
case '--goal': config.goal = next; i++; break;
case '--scope': config.scope = next?.split(','); i++; break;
case '--metric': config.metric = next; i++; break;
case '--direction': config.direction = next as 'higher' | 'lower'; i++; break;
case '--verify': config.verify = next; i++; break;
case '--guard': config.guard = next; i++; break;
case '--iterations': config.iterations = parseInt(next, 10); i++; break;
case '--min-delta': config.minDelta = parseFloat(next); i++; break;
case '--task': config.task = next; i++; break;
}
}
return config;
}
/** Extract a number from command output using common patterns */
export function extractMetric(output: string): number | null {
// Try: last line that looks like a number
const lines = output.trim().split('\n');
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i].trim();
// Match standalone numbers: "56", "95.2", "SCORE=56/59" → 56
const scoreMatch = line.match(/SCORE[=:]\s*(\d+)/i);
if (scoreMatch) return parseFloat(scoreMatch[1]);
const numMatch = line.match(/^[\d.]+$/);
if (numMatch) return parseFloat(numMatch[0]);
}
// Fallback: first number in output
const fallback = output.match(/(\d+(?:\.\d+)?)/);
return fallback ? parseFloat(fallback[1]) : null;
}
+359
View File
@@ -0,0 +1,359 @@
/**
* AutoResearch Engine — Karpathy's 8-phase autonomous iteration loop.
*
* Phase 0: Precondition checks (git clean, no locks)
* Phase 1: Review (read scope files + log + git history)
* Phase 2: Ideate (select next change based on history)
* Phase 3: Modify (one atomic change — delegated to caller)
* Phase 4: Commit (git add + commit with experiment prefix)
* Phase 5: Verify (run verify command, extract metric)
* Phase 5.5: Guard (optional regression check)
* Phase 6: Decide (keep/discard/crash + rollback)
* Phase 7: Log (append TSV)
* Phase 8: Repeat
*/
import { execSync } from 'node:child_process';
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { type AutoResearchConfig, type IterationResult, type IterationStatus, extractMetric } from './config.js';
import { Logger } from './logger.js';
export interface EngineCallbacks {
/** Called at Phase 2-3: review context, ideate, and make ONE change.
* Return a one-sentence description of what was changed, or null to skip. */
modify(context: ModifyContext): Promise<string | null>;
/** Called when engine needs to report status */
onStatus?(msg: string): void;
}
export interface ModifyContext {
iteration: number;
bestMetric: number;
currentMetric: number;
recentLog: IterationResult[];
gitLog: string;
scopeFiles: string[];
consecutiveDiscards: number;
stuckHint: string | null;
}
const ROOT = join(import.meta.dirname ?? process.cwd(), '..');
function exec(cmd: string, opts?: { timeout?: number; cwd?: string }): string {
try {
return execSync(cmd, {
cwd: opts?.cwd ?? ROOT,
timeout: opts?.timeout ?? 120_000,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env: process.env,
}).trim();
} catch (err: any) {
return err.stdout?.trim() ?? err.message ?? '';
}
}
function execStrict(cmd: string, opts?: { timeout?: number }): string {
return execSync(cmd, {
cwd: ROOT,
timeout: opts?.timeout ?? 120_000,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env: process.env,
}).trim();
}
export class Engine {
private config: AutoResearchConfig;
private logger: Logger;
private callbacks: EngineCallbacks;
private bestMetric: number = 0;
private currentMetric: number = 0;
private iteration: number = 0;
constructor(config: AutoResearchConfig, logPath: string, callbacks: EngineCallbacks) {
this.config = config;
this.logger = new Logger(logPath);
this.callbacks = callbacks;
}
private log(msg: string): void {
this.callbacks.onStatus?.(msg);
}
/** Phase 0: Precondition checks */
private checkPreconditions(): void {
// Git repo exists
try { execStrict('git rev-parse --git-dir'); }
catch { throw new Error('Not a git repository'); }
// Clean working tree
const status = exec('git status --porcelain');
if (status) throw new Error(`Working tree not clean:\n${status}`);
// No stale locks
if (existsSync(join(ROOT, '.git', 'index.lock'))) {
throw new Error('Stale .git/index.lock found — remove it first');
}
// Not detached HEAD
try { execStrict('git symbolic-ref HEAD'); }
catch { throw new Error('Detached HEAD — checkout a branch first'); }
}
/** Phase 5: Run verify command and extract metric */
private runVerify(): number | null {
this.log(' verify...');
const output = exec(this.config.verify, { timeout: 300_000 });
return extractMetric(output);
}
/** Phase 5.5: Run guard command */
private runGuard(): boolean {
if (!this.config.guard) return true;
this.log(' guard...');
try {
execStrict(this.config.guard, { timeout: 300_000 });
return true;
} catch {
return false;
}
}
/** Phase 4: Commit changes */
private commit(description: string): string | null {
// Stage all changes in scope (but not untracked outside scope)
exec('git add -A');
const diff = exec('git diff --cached --quiet; echo $?');
if (diff === '0') return null; // no changes
try {
execStrict(`git commit -m "experiment(operate): ${description.replace(/"/g, '\\"')}"`);
return exec('git rev-parse --short HEAD');
} catch {
// Hook failure
exec('git reset HEAD');
return 'hook-blocked';
}
}
/** Phase 6: Rollback */
private safeRevert(): void {
try {
execStrict('git revert HEAD --no-edit');
} catch {
exec('git revert --abort');
exec('git reset --hard HEAD~1');
}
}
/** Get stuck hint when >5 consecutive discards */
private getStuckHint(discards: number): string | null {
if (discards < 5) return null;
const hints = [
'Re-read ALL scope files from scratch. Try a completely different approach.',
'Review entire results log — what worked before? Try combining successful changes.',
'Try the OPPOSITE of what has been failing.',
'Try a radical architectural change instead of incremental tweaks.',
'Simplify — remove complexity rather than adding it.',
];
return hints[Math.min(discards - 5, hints.length - 1)];
}
/** Run the main loop */
async run(): Promise<IterationResult[]> {
const results: IterationResult[] = [];
// Phase 0: Preconditions
this.log('Phase 0: Precondition checks...');
this.checkPreconditions();
// Initialize logger
this.logger.init(this.config);
// Baseline measurement
this.log('Measuring baseline...');
const baseline = this.runVerify();
if (baseline == null) throw new Error('Verify command returned no metric for baseline');
this.bestMetric = baseline;
this.currentMetric = baseline;
const baselineCommit = exec('git rev-parse --short HEAD');
const baselineResult: IterationResult = {
iteration: 0,
commit: baselineCommit,
metric: baseline,
delta: 0,
guard: this.config.guard ? (this.runGuard() ? 'pass' : 'fail') : '-',
status: 'baseline',
description: `initial state — ${this.config.metric} ${baseline}`,
};
this.logger.append(baselineResult);
results.push(baselineResult);
this.log(`Baseline: ${this.config.metric} = ${baseline}`);
// Main loop
const maxIter = this.config.iterations ?? Infinity;
for (this.iteration = 1; this.iteration <= maxIter; this.iteration++) {
this.log(`\n━━━ Iteration ${this.iteration}${maxIter < Infinity ? `/${maxIter}` : ''} ━━━`);
// Phase 1: Review
const gitLog = exec('git log --oneline -20');
const recentLog = this.logger.readLast(20);
const scopeFiles = this.config.scope;
const consecutiveDiscards = this.logger.consecutiveDiscards();
// Phase 2-3: Ideate + Modify (delegated to callback)
const context: ModifyContext = {
iteration: this.iteration,
bestMetric: this.bestMetric,
currentMetric: this.currentMetric,
recentLog,
gitLog,
scopeFiles,
consecutiveDiscards,
stuckHint: this.getStuckHint(consecutiveDiscards),
};
let description: string | null;
try {
description = await this.callbacks.modify(context);
} catch (err: any) {
this.log(` modify error: ${err.message}`);
const result: IterationResult = {
iteration: this.iteration,
commit: '-',
metric: this.currentMetric,
delta: 0,
guard: '-',
status: 'crash',
description: `modify crashed: ${err.message?.slice(0, 80)}`,
};
this.logger.append(result);
results.push(result);
continue;
}
if (!description) {
const result: IterationResult = {
iteration: this.iteration,
commit: '-',
metric: this.currentMetric,
delta: 0,
guard: '-',
status: 'no-op',
description: 'no changes made',
};
this.logger.append(result);
results.push(result);
continue;
}
// Phase 4: Commit
this.log(` commit: ${description}`);
const commitHash = this.commit(description);
if (!commitHash) {
const result: IterationResult = {
iteration: this.iteration,
commit: '-',
metric: this.currentMetric,
delta: 0,
guard: '-',
status: 'no-op',
description: `no diff after: ${description}`,
};
this.logger.append(result);
results.push(result);
continue;
}
if (commitHash === 'hook-blocked') {
const result: IterationResult = {
iteration: this.iteration,
commit: '-',
metric: this.currentMetric,
delta: 0,
guard: '-',
status: 'hook-blocked',
description: `hook rejected: ${description}`,
};
this.logger.append(result);
results.push(result);
continue;
}
// Phase 5: Verify
const metric = this.runVerify();
if (metric == null) {
this.log(' verify crashed — reverting');
this.safeRevert();
const result: IterationResult = {
iteration: this.iteration,
commit: '-',
metric: this.currentMetric,
delta: 0,
guard: '-',
status: 'crash',
description: `verify crashed: ${description}`,
};
this.logger.append(result);
results.push(result);
continue;
}
const improved = this.config.direction === 'higher'
? metric > this.bestMetric
: metric < this.bestMetric;
const delta = +(metric - this.bestMetric).toFixed(4);
const absDelta = Math.abs(delta);
const minDelta = this.config.minDelta ?? 0;
// Phase 5.5: Guard
let guardResult: 'pass' | 'fail' | '-' = '-';
if (this.config.guard && improved && absDelta >= minDelta) {
guardResult = this.runGuard() ? 'pass' : 'fail';
}
// Phase 6: Decide
let status: IterationStatus;
if (improved && absDelta >= minDelta && (guardResult !== 'fail')) {
status = 'keep';
this.bestMetric = metric;
this.currentMetric = metric;
this.log(` ✓ KEEP — ${this.config.metric}: ${metric} (${delta >= 0 ? '+' : ''}${delta})`);
} else if (improved && guardResult === 'fail') {
this.log(' guard failed — reverting');
this.safeRevert();
status = 'discard';
this.log(` ✗ DISCARD (guard) — ${description}`);
} else {
this.safeRevert();
status = 'discard';
const reason = absDelta < minDelta ? 'below min delta' : 'no improvement';
this.log(` ✗ DISCARD (${reason}) — ${this.config.metric}: ${metric} (${delta >= 0 ? '+' : ''}${delta})`);
}
const result: IterationResult = {
iteration: this.iteration,
commit: status === 'keep' ? commitHash : '-',
metric,
delta,
guard: guardResult,
status,
description,
};
this.logger.append(result);
results.push(result);
}
// Summary
const keeps = results.filter(r => r.status === 'keep' || r.status === 'keep (reworked)');
const discards = results.filter(r => r.status === 'discard');
this.log(`\n${'━'.repeat(50)}`);
this.log(`Done: ${this.iteration - 1} iterations, ${keeps.length} kept, ${discards.length} discarded`);
this.log(`Final ${this.config.metric}: ${this.bestMetric} (started at ${results[0]?.metric})`);
return results;
}
}
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env npx tsx
/**
* Combined Test Suite Runner — runs browse + V2EX + Zhihu tasks.
* Reports combined score for AutoResearch iteration.
*
* Usage:
* npx tsx autoresearch/eval-all.ts # Run all
* npx tsx autoresearch/eval-all.ts --suite v2ex # Run one suite
*/
import { execSync } from 'node:child_process';
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
const RESULTS_DIR = join(__dirname, 'results');
interface SuiteResult {
name: string;
passed: number;
total: number;
failures: string[];
duration: number;
}
function runSuite(name: string, script: string): SuiteResult {
const start = Date.now();
try {
const output = execSync(`npx tsx ${script}`, {
cwd: ROOT,
timeout: 600_000,
encoding: 'utf-8',
env: process.env,
stdio: ['pipe', 'pipe', 'pipe'],
});
// Parse SCORE=X/Y from output
const scoreMatch = output.match(/SCORE=(\d+)\/(\d+)/);
const passed = scoreMatch ? parseInt(scoreMatch[1], 10) : 0;
const total = scoreMatch ? parseInt(scoreMatch[2], 10) : 0;
// Parse failures
const failures: string[] = [];
const failLines = output.match(/✗.*$/gm) || [];
for (const line of failLines) {
const m = line.match(/✗\s+(?:\[.*?\]\s+)?(\S+)/);
if (m) failures.push(m[1].replace(/:$/, ''));
}
return { name, passed, total, failures, duration: Date.now() - start };
} catch (err: any) {
const output = err.stdout ?? '';
const scoreMatch = output.match(/SCORE=(\d+)\/(\d+)/);
const passed = scoreMatch ? parseInt(scoreMatch[1], 10) : 0;
const total = scoreMatch ? parseInt(scoreMatch[2], 10) : 0;
const failures: string[] = [];
const failLines = output.match(/✗.*$/gm) || [];
for (const line of failLines) {
const m = line.match(/✗\s+(?:\[.*?\]\s+)?(\S+)/);
if (m) failures.push(m[1].replace(/:$/, ''));
}
return { name, passed, total, failures, duration: Date.now() - start };
}
}
function main() {
const args = process.argv.slice(2);
const singleSuite = args.includes('--suite') ? args[args.indexOf('--suite') + 1] : null;
const suites = [
{ name: 'browse', script: 'autoresearch/eval-browse.ts' },
{ name: 'v2ex', script: 'autoresearch/eval-v2ex.ts' },
{ name: 'zhihu', script: 'autoresearch/eval-zhihu.ts' },
].filter(s => !singleSuite || s.name === singleSuite);
console.log(`\n🔬 Combined AutoResearch — ${suites.length} suites\n`);
const results: SuiteResult[] = [];
for (const suite of suites) {
console.log(` Running ${suite.name}...`);
const result = runSuite(suite.name, suite.script);
results.push(result);
const icon = result.passed === result.total ? '✓' : '✗';
console.log(` ${icon} ${result.name}: ${result.passed}/${result.total} (${Math.round(result.duration / 1000)}s)`);
if (result.failures.length > 0) {
for (const f of result.failures.slice(0, 5)) {
console.log(`${f}`);
}
}
}
// Summary
const totalPassed = results.reduce((s, r) => s + r.passed, 0);
const totalTasks = results.reduce((s, r) => s + r.total, 0);
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
const allFailures = results.flatMap(r => r.failures.map(f => `${r.name}:${f}`));
console.log(`\n${'━'.repeat(50)}`);
console.log(` Combined: ${totalPassed}/${totalTasks}`);
for (const r of results) {
console.log(` ${r.name}: ${r.passed}/${r.total}`);
}
console.log(` Time: ${Math.round(totalDuration / 60000)}min`);
if (allFailures.length > 0) {
console.log(`\n All failures:`);
for (const f of allFailures) console.log(`${f}`);
}
// Save result
mkdirSync(RESULTS_DIR, { recursive: true });
const existing = readdirSync(RESULTS_DIR).filter(f => f.startsWith('all-')).length;
const roundNum = String(existing + 1).padStart(3, '0');
const resultPath = join(RESULTS_DIR, `all-${roundNum}.json`);
writeFileSync(resultPath, JSON.stringify({
timestamp: new Date().toISOString(),
score: `${totalPassed}/${totalTasks}`,
suites: Object.fromEntries(results.map(r => [r.name, `${r.passed}/${r.total}`])),
failures: allFailures,
duration: `${Math.round(totalDuration / 60000)}min`,
}, null, 2), 'utf-8');
console.log(`\n Results saved to: ${resultPath}`);
console.log(`\nSCORE=${totalPassed}/${totalTasks}`);
}
main();
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env npx tsx
/**
* Layer 1: Deterministic Browse Command Testing
*
* Runs predefined opencli operate command sequences against real websites.
* No LLM involved — tests command reliability only.
*
* Usage:
* npx tsx autoresearch/eval-browse.ts # Run all tasks
* npx tsx autoresearch/eval-browse.ts --task hn-top5 # Run single task
*/
import { execSync } from 'node:child_process';
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const TASKS_FILE = join(__dirname, 'browse-tasks.json');
const RESULTS_DIR = join(__dirname, 'results');
const BASELINE_FILE = join(__dirname, 'baseline-browse.txt');
interface BrowseTask {
name: string;
steps: string[];
judge: JudgeCriteria;
set?: 'test';
note?: string;
}
type JudgeCriteria =
| { type: 'contains'; value: string }
| { type: 'arrayMinLength'; minLength: number }
| { type: 'nonEmpty' }
| { type: 'matchesPattern'; pattern: string };
interface TaskResult {
name: string;
passed: boolean;
duration: number;
error?: string;
set: 'train' | 'test';
}
function judge(criteria: JudgeCriteria, output: string): boolean {
try {
switch (criteria.type) {
case 'contains':
return output.toLowerCase().includes(criteria.value.toLowerCase());
case 'arrayMinLength': {
try {
const arr = JSON.parse(output);
if (Array.isArray(arr)) return arr.length >= criteria.minLength;
} catch { /* not JSON array */ }
return false;
}
case 'nonEmpty':
return output.trim().length > 0 && output.trim() !== 'null' && output.trim() !== 'undefined';
case 'matchesPattern':
return new RegExp(criteria.pattern).test(output);
default:
return false;
}
} catch {
return false;
}
}
function runCommand(cmd: string): string {
try {
return execSync(cmd, {
cwd: join(__dirname, '..'),
timeout: 30000,
encoding: 'utf-8',
env: process.env,
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
} catch (err: any) {
return err.stdout?.trim() || err.stderr?.trim() || '';
}
}
function runTask(task: BrowseTask): TaskResult {
const start = Date.now();
let lastOutput = '';
try {
for (const step of task.steps) {
lastOutput = runCommand(step);
}
const passed = judge(task.judge, lastOutput);
return {
name: task.name,
passed,
duration: Date.now() - start,
error: passed ? undefined : `Output: ${lastOutput.slice(0, 100)}`,
set: task.set === 'test' ? 'test' : 'train',
};
} catch (err: any) {
return {
name: task.name,
passed: false,
duration: Date.now() - start,
error: err.message?.slice(0, 100),
set: task.set === 'test' ? 'test' : 'train',
};
}
}
function main() {
const args = process.argv.slice(2);
const singleTask = args.includes('--task') ? args[args.indexOf('--task') + 1] : null;
const allTasks: BrowseTask[] = JSON.parse(readFileSync(TASKS_FILE, 'utf-8'));
const tasks = singleTask ? allTasks.filter(t => t.name === singleTask) : allTasks;
if (tasks.length === 0) {
console.error(`Task "${singleTask}" not found.`);
process.exit(1);
}
console.log(`\n🔬 Layer 1: Browse Commands — ${tasks.length} tasks\n`);
const results: TaskResult[] = [];
for (let i = 0; i < tasks.length; i++) {
const task = tasks[i];
process.stdout.write(` [${i + 1}/${tasks.length}] ${task.name}...`);
const result = runTask(task);
results.push(result);
const icon = result.passed ? '✓' : '✗';
console.log(` ${icon} (${(result.duration / 1000).toFixed(1)}s)`);
// Close browser between tasks for clean state
if (i < tasks.length - 1) {
try { runCommand('opencli operate close'); } catch { /* ignore */ }
}
}
// Final close
try { runCommand('opencli operate close'); } catch { /* ignore */ }
// Summary
const trainResults = results.filter(r => r.set === 'train');
const testResults = results.filter(r => r.set === 'test');
const totalPassed = results.filter(r => r.passed).length;
const trainPassed = trainResults.filter(r => r.passed).length;
const testPassed = testResults.filter(r => r.passed).length;
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
console.log(`\n${'─'.repeat(50)}`);
console.log(` Score: ${totalPassed}/${results.length} (train: ${trainPassed}/${trainResults.length}, test: ${testPassed}/${testResults.length})`);
console.log(` Time: ${Math.round(totalDuration / 60000)}min`);
const failures = results.filter(r => !r.passed);
if (failures.length > 0) {
console.log(`\n Failures:`);
for (const f of failures) {
console.log(`${f.name}: ${f.error ?? 'unknown'}`);
}
}
console.log('');
// Save result
mkdirSync(RESULTS_DIR, { recursive: true });
const existing = readdirSync(RESULTS_DIR).filter(f => f.startsWith('browse-')).length;
const roundNum = String(existing + 1).padStart(3, '0');
const resultPath = join(RESULTS_DIR, `browse-${roundNum}.json`);
writeFileSync(resultPath, JSON.stringify({
timestamp: new Date().toISOString(),
score: `${totalPassed}/${results.length}`,
trainScore: `${trainPassed}/${trainResults.length}`,
testScore: `${testPassed}/${testResults.length}`,
duration: `${Math.round(totalDuration / 60000)}min`,
tasks: results,
}, null, 2), 'utf-8');
console.log(` Results saved to: ${resultPath}`);
console.log(`\nSCORE=${totalPassed}/${results.length}`);
}
main();
+254
View File
@@ -0,0 +1,254 @@
#!/usr/bin/env npx tsx
/**
* Layer 2: Claude Code Skill E2E Testing (LLM Judge)
*
* Spawns Claude Code with the opencli-operate skill. Claude Code
* completes the task using browse commands AND judges its own result.
*
* Task format: YAML with judge_context (multi-criteria, like Browser Use)
*
* Usage:
* npx tsx autoresearch/eval-skill.ts # Run all
* npx tsx autoresearch/eval-skill.ts --task hn-top5 # Run single
*/
import { execSync } from 'node:child_process';
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const RESULTS_DIR = join(__dirname, 'results');
const SKILL_PATH = join(__dirname, '..', 'skills', 'opencli-operate', 'SKILL.md');
// ── Types ──────────────────────────────────────────────────────────
interface SkillTask {
name: string;
task: string;
url?: string;
judge_context: string[];
max_steps?: number;
}
interface TaskResult {
name: string;
passed: boolean;
duration: number;
cost: number;
explanation: string;
}
// ── Task Definitions (inline, to avoid YAML dependency) ────────────
const TASKS: SkillTask[] = [
// Extract
{ name: "extract-title-example", task: "Extract the main heading text from this page", url: "https://example.com", judge_context: ["Output must contain 'Example Domain'"] },
{ name: "extract-paragraph-wiki", task: "Extract the first paragraph of the JavaScript article", url: "https://en.wikipedia.org/wiki/JavaScript", judge_context: ["Output must mention 'programming language'", "Output must contain actual paragraph text, not just the title"] },
{ name: "extract-github-stars", task: "Find the number of stars on this repository", url: "https://github.com/browser-use/browser-use", judge_context: ["Output must contain a number (the star count)"] },
{ name: "extract-npm-downloads", task: "Find the weekly download count for this package", url: "https://www.npmjs.com/package/zod", judge_context: ["Output must contain a number (weekly downloads)"] },
// List extraction
{ name: "list-hn-top5", task: "Extract the top 5 stories with their titles", url: "https://news.ycombinator.com", judge_context: ["Output must contain 5 story titles", "Each title must be an actual HN story, not made up"] },
{ name: "list-books-5", task: "Extract the first 5 books with their title and price", url: "https://books.toscrape.com", judge_context: ["Output must contain 5 books", "Each book must have a title and a price"] },
{ name: "list-quotes-3", task: "Extract the first 3 quotes with their text and author", url: "https://quotes.toscrape.com", judge_context: ["Output must contain 3 quotes", "Each quote must have text and an author name"] },
{ name: "list-github-trending", task: "Extract the top 3 trending repositories with name and description", url: "https://github.com/trending", judge_context: ["Output must contain 3 repositories", "Each must have a repo name"] },
{ name: "list-jsonplaceholder", task: "Extract the first 5 posts with their title", url: "https://jsonplaceholder.typicode.com/posts", judge_context: ["Output must contain 5 posts", "Each post must have a title"] },
// Search
{ name: "search-ddg", task: "Search for 'TypeScript tutorial' and extract the first 3 result titles", url: "https://duckduckgo.com", judge_context: ["The agent must type a search query", "Output must contain at least 3 search result titles"] },
{ name: "search-npm", task: "Search for 'react' and extract the top 3 package names", url: "https://www.npmjs.com", judge_context: ["The agent must search for 'react'", "Output must contain at least 3 package names"] },
{ name: "search-wiki", task: "Search for 'Rust programming language' and extract the first sentence of the article", url: "https://en.wikipedia.org", judge_context: ["The agent must search and navigate to the article", "Output must mention 'programming language'"] },
// Navigation
{ name: "nav-click-link", task: "Click the 'More information...' link and extract the heading of the new page", url: "https://example.com", judge_context: ["The agent must click a link", "Output must contain 'IANA' or reference the new page"] },
{ name: "nav-click-hn", task: "Click on the first story link and tell me the title of the page you land on", url: "https://news.ycombinator.com", judge_context: ["The agent must click a story link", "Output must contain the title of the destination page"] },
{ name: "nav-go-back", task: "Click the 'More information...' link, then go back, and tell me the heading of the original page", url: "https://example.com", judge_context: ["The agent must click a link then go back", "Output must contain 'Example Domain'"] },
{ name: "nav-multi-step", task: "Click the Next page link at the bottom, then extract the first quote from page 2", url: "https://quotes.toscrape.com", judge_context: ["The agent must navigate to page 2", "Output must contain a quote from page 2"] },
// Scroll
{ name: "scroll-footer", task: "Scroll to the bottom and extract the footer text", url: "https://quotes.toscrape.com", judge_context: ["The agent must scroll down", "Output must contain footer or bottom-of-page content"] },
{ name: "scroll-pagination", task: "Find the pagination info at the bottom of the page", url: "https://books.toscrape.com", judge_context: ["Output must contain page number or pagination info"] },
// Form
{ name: "form-fill-basic", task: "Fill the Customer Name with 'OpenCLI' and Telephone with '555-0100'. Do not submit.", url: "https://httpbin.org/forms/post", judge_context: ["The agent must type 'OpenCLI' into a name field", "The agent must type '555-0100' into a phone field", "The form must NOT be submitted"] },
{ name: "form-radio", task: "Select the 'Medium' pizza size option. Do not submit.", url: "https://httpbin.org/forms/post", judge_context: ["The agent must select a radio button for Medium size"] },
{ name: "form-login", task: "Fill the username with 'testuser' and password with 'testpass'. Do not submit.", url: "https://the-internet.herokuapp.com/login", judge_context: ["The agent must fill the username field", "The agent must fill the password field", "The form must NOT be submitted"] },
// Complex
{ name: "complex-wiki-toc", task: "Extract the table of contents headings", url: "https://en.wikipedia.org/wiki/JavaScript", judge_context: ["Output must contain at least 5 section headings from the table of contents"] },
{ name: "complex-books-detail", task: "Click on the first book and extract its title and price from the detail page", url: "https://books.toscrape.com", judge_context: ["The agent must click on a book", "Output must contain the book title", "Output must contain a price"] },
{ name: "complex-quotes-page2", task: "Navigate to page 2 and extract the first 3 quotes with their authors", url: "https://quotes.toscrape.com", judge_context: ["The agent must navigate to page 2", "Output must contain 3 quotes with authors"] },
{ name: "complex-multi-extract", task: "Extract both the page title and the first paragraph text", url: "https://en.wikipedia.org/wiki/TypeScript", judge_context: ["Output must contain 'TypeScript'", "Output must contain actual paragraph text"] },
// Bench (harder, real-world)
{ name: "bench-reddit", task: "Extract the titles of the top 5 posts", url: "https://old.reddit.com", judge_context: ["Output must contain 5 post titles", "Titles must be actual Reddit posts"] },
{ name: "bench-imdb", task: "Find the year and rating of The Matrix", url: "https://www.imdb.com/title/tt0133093/", judge_context: ["Output must contain '1999'", "Output must contain a rating number"] },
{ name: "bench-github-profile", task: "Extract the bio and number of public repositories", url: "https://github.com/torvalds", judge_context: ["Output must contain bio text or 'Linux'", "Output must contain a number for repos"] },
{ name: "bench-httpbin", task: "Extract the User-Agent header shown on this page", url: "https://httpbin.org/headers", judge_context: ["Output must contain a User-Agent string"] },
{ name: "bench-jsonapi-todo", task: "Extract the first 5 todo items with their title and completion status", url: "https://jsonplaceholder.typicode.com/todos", judge_context: ["Output must contain 5 todo items", "Each must have a title and completed status"] },
// Codex form (the real test)
{ name: "codex-form-fill", task: "Fill the basic information using 'opencli' as the identity (first name=open, last name=cli, email=opencli@example.com, GitHub username=opencli). Do NOT submit the form.", url: "https://openai.com/form/codex-for-oss/", judge_context: ["The agent must fill the first name field", "The agent must fill the last name field", "The agent must fill the email field", "The form must NOT be submitted"], max_steps: 15 },
];
// ── Run Task ───────────────────────────────────────────────────────
function runSkillTask(task: SkillTask): TaskResult {
const start = Date.now();
const skillContent = readFileSync(SKILL_PATH, 'utf-8');
const urlPart = task.url ? ` Start URL: ${task.url}` : '';
const criteria = task.judge_context.map((c, i) => `${i + 1}. ${c}`).join('\n');
const prompt = `Complete this browser task using opencli operate commands:
TASK: ${task.task}${urlPart}
After completing the task, evaluate your own result against these criteria:
${criteria}
At the very end of your response, output a JSON verdict on its own line:
{"success": true/false, "explanation": "brief explanation"}
Always close the browser with 'opencli operate close' when done.`;
try {
const output = execSync(
`claude -p --dangerously-skip-permissions --allowedTools "Bash(opencli:*)" --system-prompt ${JSON.stringify(skillContent)} --output-format json --no-session-persistence ${JSON.stringify(prompt)}`,
{
cwd: join(__dirname, '..'),
timeout: (task.max_steps ?? 10) * 15_000,
encoding: 'utf-8',
env: process.env,
stdio: ['pipe', 'pipe', 'pipe'],
}
);
const duration = Date.now() - start;
// Parse Claude Code output
let resultText = '';
let cost = 0;
try {
const parsed = JSON.parse(output);
resultText = parsed.result ?? output;
cost = parsed.total_cost_usd ?? 0;
} catch {
resultText = output;
}
// Extract verdict JSON from the result
const verdict = extractVerdict(resultText);
return {
name: task.name,
passed: verdict.success,
duration,
cost,
explanation: verdict.explanation,
};
} catch (err: any) {
return {
name: task.name,
passed: false,
duration: Date.now() - start,
cost: 0,
explanation: (err.stdout ?? err.message ?? 'timeout or crash').slice(0, 200),
};
}
}
function extractVerdict(text: string): { success: boolean; explanation: string } {
// Try to find and parse {"success": ...} JSON from the last occurrence
const idx = text.lastIndexOf('{"success"');
if (idx !== -1) {
// Find the matching closing brace (handle escaped quotes in explanation)
const sub = text.slice(idx);
let braceCount = 0;
let end = -1;
for (let i = 0; i < sub.length; i++) {
if (sub[i] === '{') braceCount++;
else if (sub[i] === '}') { braceCount--; if (braceCount === 0) { end = i + 1; break; } }
}
if (end > 0) {
try { return JSON.parse(sub.slice(0, end)); } catch { /* fall through */ }
}
}
// Fallback: check for success indicators in text
const lower = text.toLowerCase();
if (lower.includes('"success": true') || lower.includes('"success":true')) {
return { success: true, explanation: 'Parsed success from output' };
}
if (lower.includes('"success": false') || lower.includes('"success":false')) {
return { success: false, explanation: 'Parsed failure from output' };
}
// Final fallback: assume failure if we can't parse
return { success: false, explanation: 'Could not parse verdict from output' };
}
// ── Main ───────────────────────────────────────────────────────────
function main() {
const args = process.argv.slice(2);
const singleTask = args.includes('--task') ? args[args.indexOf('--task') + 1] : null;
const tasks = singleTask ? TASKS.filter(t => t.name === singleTask) : TASKS;
if (tasks.length === 0) {
console.error(`Task "${singleTask}" not found. Available: ${TASKS.map(t => t.name).join(', ')}`);
process.exit(1);
}
console.log(`\n🔬 Layer 2: Skill E2E (LLM Judge) — ${tasks.length} tasks\n`);
const results: TaskResult[] = [];
for (let i = 0; i < tasks.length; i++) {
const task = tasks[i];
process.stdout.write(` [${i + 1}/${tasks.length}] ${task.name}...`);
const result = runSkillTask(task);
results.push(result);
const icon = result.passed ? '✓' : '✗';
const costStr = result.cost > 0 ? `, $${result.cost.toFixed(2)}` : '';
console.log(` ${icon} (${Math.round(result.duration / 1000)}s${costStr})`);
}
// Summary
const totalPassed = results.filter(r => r.passed).length;
const totalCost = results.reduce((s, r) => s + r.cost, 0);
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
console.log(`\n${'─'.repeat(50)}`);
console.log(` Score: ${totalPassed}/${results.length} (${Math.round(totalPassed / results.length * 100)}%)`);
console.log(` Cost: $${totalCost.toFixed(2)}`);
console.log(` Time: ${Math.round(totalDuration / 60000)}min`);
const failures = results.filter(r => !r.passed);
if (failures.length > 0) {
console.log(`\n Failures:`);
for (const f of failures) {
console.log(`${f.name}: ${f.explanation}`);
}
}
console.log('');
// Save
mkdirSync(RESULTS_DIR, { recursive: true });
const existing = readdirSync(RESULTS_DIR).filter(f => f.startsWith('skill-')).length;
const roundNum = String(existing + 1).padStart(3, '0');
const resultPath = join(RESULTS_DIR, `skill-${roundNum}.json`);
writeFileSync(resultPath, JSON.stringify({
timestamp: new Date().toISOString(),
score: `${totalPassed}/${results.length}`,
totalCost,
duration: `${Math.round(totalDuration / 60000)}min`,
tasks: results,
}, null, 2), 'utf-8');
console.log(` Results saved to: ${resultPath}`);
console.log(`\nSCORE=${totalPassed}/${results.length}`);
}
main();
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env npx tsx
/**
* V2EX Test Suite: Deterministic command testing against v2ex.com.
*
* 40 tasks across 5 difficulty layers:
* L1 Atomic (10) → L2 Single Page (10) → L3 Multi-Step (10)
* → L4 Write Ops (5) → L5 Complex Chain (5)
*
* Usage:
* npx tsx autoresearch/eval-v2ex.ts # Run all tasks
* npx tsx autoresearch/eval-v2ex.ts --task v2ex-hot-topics # Run single task
* npx tsx autoresearch/eval-v2ex.ts --layer 1 # Run only Layer 1 (atomic)
*/
import { execSync } from 'node:child_process';
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const TASKS_FILE = join(__dirname, 'v2ex-tasks.json');
const RESULTS_DIR = join(__dirname, 'results');
interface BrowseTask {
name: string;
steps: string[];
judge: JudgeCriteria;
set?: 'test';
note?: string;
_comment?: string;
}
type JudgeCriteria =
| { type: 'contains'; value: string }
| { type: 'arrayMinLength'; minLength: number }
| { type: 'nonEmpty' }
| { type: 'matchesPattern'; pattern: string };
interface TaskResult {
name: string;
passed: boolean;
duration: number;
error?: string;
layer: string;
}
// Layer classification by task name prefix pattern
function getLayer(name: string): string {
if (['v2ex-open-', 'v2ex-state-', 'v2ex-get-title', 'v2ex-click-tab', 'v2ex-scroll-down',
'v2ex-get-first-', 'v2ex-eval-extract', 'v2ex-get-url', 'v2ex-back-nav', 'v2ex-wait-'].some(p => name.startsWith(p)))
return 'L1-atomic';
if (['v2ex-hot-topics', 'v2ex-node-list', 'v2ex-topic-meta', 'v2ex-node-topics',
'v2ex-node-pagination', 'v2ex-tab-content', 'v2ex-topic-replies-extract',
'v2ex-topic-reply-count', 'v2ex-member-info', 'v2ex-search-results'].includes(name))
return 'L2-single-page';
if (['v2ex-click-topic-read', 'v2ex-click-author', 'v2ex-navigate-node', 'v2ex-pagination-page2',
'v2ex-topic-and-back', 'v2ex-tab-then-topic', 'v2ex-scroll-find-more',
'v2ex-node-to-topic', 'v2ex-multi-tab-compare', 'v2ex-topic-reply-to-author'].some(p => name.startsWith(p)))
return 'L3-multi-step';
if (['v2ex-reply-', 'v2ex-favorite-', 'v2ex-thank-', 'v2ex-create-'].some(p => name.startsWith(p)))
return 'L4-write';
if (['v2ex-collect-', 'v2ex-multi-node-', 'v2ex-topic-deep-', 'v2ex-cross-page-', 'v2ex-full-'].some(p => name.startsWith(p)))
return 'L5-complex';
return 'unknown';
}
function judge(criteria: JudgeCriteria, output: string): boolean {
try {
switch (criteria.type) {
case 'contains':
return output.toLowerCase().includes(criteria.value.toLowerCase());
case 'arrayMinLength': {
try {
const arr = JSON.parse(output);
if (Array.isArray(arr)) return arr.length >= criteria.minLength;
} catch { /* not JSON array */ }
return false;
}
case 'nonEmpty':
return output.trim().length > 0 && output.trim() !== 'null' && output.trim() !== 'undefined';
case 'matchesPattern':
return new RegExp(criteria.pattern).test(output);
default:
return false;
}
} catch {
return false;
}
}
function runCommand(cmd: string, timeout = 30000): string {
try {
return execSync(cmd, {
cwd: join(__dirname, '..'),
timeout,
encoding: 'utf-8',
env: process.env,
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
} catch (err: any) {
return err.stdout?.trim() ?? '';
}
}
function runTask(task: BrowseTask): TaskResult {
const start = Date.now();
let lastOutput = '';
try {
for (const step of task.steps) {
lastOutput = runCommand(step);
}
const passed = judge(task.judge, lastOutput);
return {
name: task.name,
passed,
duration: Date.now() - start,
error: passed ? undefined : `Output: ${lastOutput.slice(0, 150)}`,
layer: getLayer(task.name),
};
} catch (err: any) {
return {
name: task.name,
passed: false,
duration: Date.now() - start,
error: err.message?.slice(0, 100),
layer: getLayer(task.name),
};
}
}
function main() {
const args = process.argv.slice(2);
const singleTask = args.includes('--task') ? args[args.indexOf('--task') + 1] : null;
const layerFilter = args.includes('--layer') ? args[args.indexOf('--layer') + 1] : null;
const raw = JSON.parse(readFileSync(TASKS_FILE, 'utf-8')) as (BrowseTask | { _comment: string })[];
const allTasks = raw.filter((t): t is BrowseTask => 'name' in t && 'steps' in t);
let tasks = allTasks;
if (singleTask) {
tasks = allTasks.filter(t => t.name === singleTask);
} else if (layerFilter) {
const prefix = `L${layerFilter}`;
tasks = allTasks.filter(t => getLayer(t.name).startsWith(prefix));
}
if (tasks.length === 0) {
console.error(singleTask ? `Task "${singleTask}" not found.` : `No tasks for layer ${layerFilter}.`);
process.exit(1);
}
console.log(`\n🔬 V2EX Test Suite — ${tasks.length} tasks\n`);
const results: TaskResult[] = [];
for (let i = 0; i < tasks.length; i++) {
const task = tasks[i];
process.stdout.write(` [${i + 1}/${tasks.length}] ${task.name}...`);
const result = runTask(task);
results.push(result);
const icon = result.passed ? '✓' : '✗';
console.log(` ${icon} (${(result.duration / 1000).toFixed(1)}s)`);
// Close browser between tasks for clean state
if (i < tasks.length - 1) {
try { runCommand('opencli operate close'); } catch { /* ignore */ }
}
}
// Final close
try { runCommand('opencli operate close'); } catch { /* ignore */ }
// Summary by layer
const layers = [...new Set(results.map(r => r.layer))].sort();
const totalPassed = results.filter(r => r.passed).length;
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
console.log(`\n${'─'.repeat(50)}`);
console.log(` Total: ${totalPassed}/${results.length}`);
for (const layer of layers) {
const layerResults = results.filter(r => r.layer === layer);
const layerPassed = layerResults.filter(r => r.passed).length;
console.log(` ${layer}: ${layerPassed}/${layerResults.length}`);
}
console.log(` Time: ${Math.round(totalDuration / 60000)}min`);
const failures = results.filter(r => !r.passed);
if (failures.length > 0) {
console.log(`\n Failures:`);
for (const f of failures) {
console.log(` ✗ [${f.layer}] ${f.name}: ${f.error ?? 'unknown'}`);
}
}
console.log('');
// Save result
mkdirSync(RESULTS_DIR, { recursive: true });
const existing = readdirSync(RESULTS_DIR).filter(f => f.startsWith('v2ex-')).length;
const roundNum = String(existing + 1).padStart(3, '0');
const resultPath = join(RESULTS_DIR, `v2ex-${roundNum}.json`);
writeFileSync(resultPath, JSON.stringify({
timestamp: new Date().toISOString(),
score: `${totalPassed}/${results.length}`,
layers: Object.fromEntries(layers.map(l => {
const lr = results.filter(r => r.layer === l);
return [l, `${lr.filter(r => r.passed).length}/${lr.length}`];
})),
duration: `${Math.round(totalDuration / 60000)}min`,
tasks: results,
}, null, 2), 'utf-8');
console.log(` Results saved to: ${resultPath}`);
console.log(`\nSCORE=${totalPassed}/${results.length}`);
}
main();
+230
View File
@@ -0,0 +1,230 @@
#!/usr/bin/env npx tsx
/**
* Zhihu Test Suite: Deterministic command testing against v2ex.com.
*
* 40 tasks across 5 difficulty layers:
* L1 Atomic (10) → L2 Single Page (10) → L3 Multi-Step (10)
* → L4 Write Ops (5) → L5 Complex Chain (5)
*
* Usage:
* npx tsx autoresearch/eval-v2ex.ts # Run all tasks
* npx tsx autoresearch/eval-v2ex.ts --task zhihu-hot-topics # Run single task
* npx tsx autoresearch/eval-v2ex.ts --layer 1 # Run only Layer 1 (atomic)
*/
import { execSync } from 'node:child_process';
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const TASKS_FILE = join(__dirname, 'zhihu-tasks.json');
const RESULTS_DIR = join(__dirname, 'results');
interface BrowseTask {
name: string;
steps: string[];
judge: JudgeCriteria;
set?: 'test';
note?: string;
_comment?: string;
}
type JudgeCriteria =
| { type: 'contains'; value: string }
| { type: 'arrayMinLength'; minLength: number }
| { type: 'nonEmpty' }
| { type: 'matchesPattern'; pattern: string };
interface TaskResult {
name: string;
passed: boolean;
duration: number;
error?: string;
layer: string;
}
// Layer classification by task name
function getLayer(name: string): string {
const l1 = ['zhihu-open-home', 'zhihu-get-title', 'zhihu-state', 'zhihu-get-url', 'zhihu-scroll-down',
'zhihu-click-tab-hot', 'zhihu-back-navigation', 'zhihu-wait-page-load', 'zhihu-keys-escape', 'zhihu-screenshot'];
const l2 = ['zhihu-feed-titles', 'zhihu-hot-list', 'zhihu-hot-metrics', 'zhihu-nav-tabs',
'zhihu-feed-with-authors', 'zhihu-feed-types', 'zhihu-user-avatar', 'zhihu-search-input-exists'];
const l3 = ['zhihu-question-title', 'zhihu-question-meta', 'zhihu-first-answer', 'zhihu-answer-votes',
'zhihu-question-buttons', 'zhihu-multiple-answers', 'zhihu-question-description', 'zhihu-answer-count-number'];
const l4 = ['zhihu-hot-to-question', 'zhihu-feed-to-question', 'zhihu-question-to-author',
'zhihu-search-navigate', 'zhihu-topic-page', 'zhihu-user-profile', 'zhihu-question-and-back', 'zhihu-scroll-load-more'];
const l5 = ['zhihu-upvote-button-find', 'zhihu-follow-question-find', 'zhihu-comment-button-find',
'zhihu-bookmark-find', 'zhihu-write-answer-btn', 'zhihu-share-find'];
const l6 = ['zhihu-hot-read-answer-author', 'zhihu-hot-to-author-profile', 'zhihu-multi-hot-topics',
'zhihu-search-then-read', 'zhihu-question-scroll-answers', 'zhihu-compare-tabs', 'zhihu-user-answers', 'zhihu-topic-questions'];
const l7 = ['zhihu-search-basic', 'zhihu-search-people', 'zhihu-search-topic',
'zhihu-search-click-result', 'zhihu-search-filter-answers', 'zhihu-search-and-back'];
const l8 = ['zhihu-full-browse-workflow', 'zhihu-deep-author-chain', 'zhihu-cross-question-compare',
'zhihu-search-read-chain', 'zhihu-3-page-chain', 'zhihu-hot-scroll-deep-read'];
if (l1.includes(name)) return 'L1-atomic';
if (l2.includes(name)) return 'L2-feed';
if (l3.includes(name)) return 'L3-question';
if (l4.includes(name)) return 'L4-navigation';
if (l5.includes(name)) return 'L5-write';
if (l6.includes(name)) return 'L6-chain';
if (l7.includes(name)) return 'L7-search';
if (l8.includes(name)) return 'L8-complex';
return 'unknown';
}
function judge(criteria: JudgeCriteria, output: string): boolean {
try {
switch (criteria.type) {
case 'contains':
return output.toLowerCase().includes(criteria.value.toLowerCase());
case 'arrayMinLength': {
try {
const arr = JSON.parse(output);
if (Array.isArray(arr)) return arr.length >= criteria.minLength;
} catch { /* not JSON array */ }
return false;
}
case 'nonEmpty':
return output.trim().length > 0 && output.trim() !== 'null' && output.trim() !== 'undefined';
case 'matchesPattern':
return new RegExp(criteria.pattern).test(output);
default:
return false;
}
} catch {
return false;
}
}
function runCommand(cmd: string, timeout = 30000): string {
try {
return execSync(cmd, {
cwd: join(__dirname, '..'),
timeout,
encoding: 'utf-8',
env: process.env,
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
} catch (err: any) {
return err.stdout?.trim() ?? '';
}
}
function runTask(task: BrowseTask): TaskResult {
const start = Date.now();
let lastOutput = '';
try {
for (const step of task.steps) {
lastOutput = runCommand(step);
}
const passed = judge(task.judge, lastOutput);
return {
name: task.name,
passed,
duration: Date.now() - start,
error: passed ? undefined : `Output: ${lastOutput.slice(0, 150)}`,
layer: getLayer(task.name),
};
} catch (err: any) {
return {
name: task.name,
passed: false,
duration: Date.now() - start,
error: err.message?.slice(0, 100),
layer: getLayer(task.name),
};
}
}
function main() {
const args = process.argv.slice(2);
const singleTask = args.includes('--task') ? args[args.indexOf('--task') + 1] : null;
const layerFilter = args.includes('--layer') ? args[args.indexOf('--layer') + 1] : null;
const raw = JSON.parse(readFileSync(TASKS_FILE, 'utf-8')) as (BrowseTask | { _comment: string })[];
const allTasks = raw.filter((t): t is BrowseTask => 'name' in t && 'steps' in t);
let tasks = allTasks;
if (singleTask) {
tasks = allTasks.filter(t => t.name === singleTask);
} else if (layerFilter) {
const prefix = `L${layerFilter}`;
tasks = allTasks.filter(t => getLayer(t.name).startsWith(prefix));
}
if (tasks.length === 0) {
console.error(singleTask ? `Task "${singleTask}" not found.` : `No tasks for layer ${layerFilter}.`);
process.exit(1);
}
console.log(`\n🔬 Zhihu Test Suite — ${tasks.length} tasks\n`);
const results: TaskResult[] = [];
for (let i = 0; i < tasks.length; i++) {
const task = tasks[i];
process.stdout.write(` [${i + 1}/${tasks.length}] ${task.name}...`);
const result = runTask(task);
results.push(result);
const icon = result.passed ? '✓' : '✗';
console.log(` ${icon} (${(result.duration / 1000).toFixed(1)}s)`);
// Close browser between tasks for clean state
if (i < tasks.length - 1) {
try { runCommand('opencli operate close'); } catch { /* ignore */ }
}
}
// Final close
try { runCommand('opencli operate close'); } catch { /* ignore */ }
// Summary by layer
const layers = [...new Set(results.map(r => r.layer))].sort();
const totalPassed = results.filter(r => r.passed).length;
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
console.log(`\n${'─'.repeat(50)}`);
console.log(` Total: ${totalPassed}/${results.length}`);
for (const layer of layers) {
const layerResults = results.filter(r => r.layer === layer);
const layerPassed = layerResults.filter(r => r.passed).length;
console.log(` ${layer}: ${layerPassed}/${layerResults.length}`);
}
console.log(` Time: ${Math.round(totalDuration / 60000)}min`);
const failures = results.filter(r => !r.passed);
if (failures.length > 0) {
console.log(`\n Failures:`);
for (const f of failures) {
console.log(` ✗ [${f.layer}] ${f.name}: ${f.error ?? 'unknown'}`);
}
}
console.log('');
// Save result
mkdirSync(RESULTS_DIR, { recursive: true });
const existing = readdirSync(RESULTS_DIR).filter(f => f.startsWith('zhihu-')).length;
const roundNum = String(existing + 1).padStart(3, '0');
const resultPath = join(RESULTS_DIR, `zhihu-${roundNum}.json`);
writeFileSync(resultPath, JSON.stringify({
timestamp: new Date().toISOString(),
score: `${totalPassed}/${results.length}`,
layers: Object.fromEntries(layers.map(l => {
const lr = results.filter(r => r.layer === l);
return [l, `${lr.filter(r => r.passed).length}/${lr.length}`];
})),
duration: `${Math.round(totalDuration / 60000)}min`,
tasks: results,
}, null, 2), 'utf-8');
console.log(` Results saved to: ${resultPath}`);
console.log(`\nSCORE=${totalPassed}/${results.length}`);
}
main();
+69
View File
@@ -0,0 +1,69 @@
/**
* AutoResearch TSV Logger — append-only results log with metadata header.
*/
import { writeFileSync, readFileSync, existsSync, appendFileSync } from 'node:fs';
import type { AutoResearchConfig, IterationResult } from './config.js';
const COLUMNS = ['iteration', 'commit', 'metric', 'delta', 'guard', 'status', 'description'];
export class Logger {
constructor(private path: string) {}
/** Create the TSV file with metadata header */
init(config: AutoResearchConfig): void {
const header = [
`# metric_direction: ${config.direction === 'higher' ? 'higher_is_better' : 'lower_is_better'}`,
`# goal: ${config.goal}`,
`# scope: ${config.scope.join(', ')}`,
`# verify: ${config.verify}`,
config.guard ? `# guard: ${config.guard}` : null,
COLUMNS.join('\t'),
].filter(Boolean).join('\n');
writeFileSync(this.path, header + '\n', 'utf-8');
}
/** Append one iteration result */
append(result: IterationResult): void {
const row = [
result.iteration,
result.commit,
result.metric,
result.delta >= 0 ? `+${result.delta}` : result.delta,
result.guard,
result.status,
result.description,
].join('\t');
appendFileSync(this.path, row + '\n', 'utf-8');
}
/** Read last N entries for pattern recognition */
readLast(n: number): IterationResult[] {
if (!existsSync(this.path)) return [];
const lines = readFileSync(this.path, 'utf-8').split('\n')
.filter(l => l && !l.startsWith('#') && !l.startsWith('iteration'));
return lines.slice(-n).map(line => {
const [iteration, commit, metric, delta, guard, status, ...desc] = line.split('\t');
return {
iteration: parseInt(iteration, 10),
commit,
metric: parseFloat(metric),
delta: parseFloat(delta),
guard: guard as 'pass' | 'fail' | '-',
status: status as IterationResult['status'],
description: desc.join('\t'),
};
});
}
/** Count consecutive discards from the end */
consecutiveDiscards(): number {
const entries = this.readLast(20);
let count = 0;
for (let i = entries.length - 1; i >= 0; i--) {
if (entries[i].status === 'discard') count++;
else break;
}
return count;
}
}
@@ -0,0 +1,27 @@
/**
* Preset: Combined Reliability (browse + V2EX + Zhihu)
*
* Optimizes across ALL test suites simultaneously.
* Current baseline: 57/59 + 60/60 + 60/60 = 177/179
* Target: 179/179 (100%)
*/
import type { AutoResearchConfig } from '../config.js';
export const combinedReliability: AutoResearchConfig = {
goal: 'Fix all remaining test failures across browse + V2EX + Zhihu (177/179 → 179/179)',
scope: [
'src/browser/dom-snapshot.ts',
'src/browser/dom-helpers.ts',
'src/browser/base-page.ts',
'src/browser/page.ts',
'src/cli.ts',
'autoresearch/browse-tasks.json',
],
metric: 'pass_count',
direction: 'higher',
verify: 'npx tsx autoresearch/eval-all.ts 2>&1 | tail -1',
guard: 'npm run build',
iterations: 10,
minDelta: 1,
};
+20
View File
@@ -0,0 +1,20 @@
export { operateReliability } from './operate-reliability.js';
export { skillQuality } from './skill-quality.js';
export { v2exReliability } from './v2ex-reliability.js';
export { zhihuReliability } from './zhihu-reliability.js';
export { combinedReliability } from './combined-reliability.js';
import type { AutoResearchConfig } from '../config.js';
import { operateReliability } from './operate-reliability.js';
import { skillQuality } from './skill-quality.js';
import { v2exReliability } from './v2ex-reliability.js';
import { zhihuReliability } from './zhihu-reliability.js';
import { combinedReliability } from './combined-reliability.js';
export const PRESETS: Record<string, AutoResearchConfig> = {
'operate-reliability': operateReliability,
'skill-quality': skillQuality,
'v2ex-reliability': v2exReliability,
'zhihu-reliability': zhihuReliability,
'combined': combinedReliability,
};
@@ -0,0 +1,24 @@
/**
* Preset: Operate Command Reliability
*
* Optimizes opencli operate commands against the Layer 1 deterministic test suite.
* Metric: number of passing browse-tasks (out of 59).
*/
import type { AutoResearchConfig } from '../config.js';
export const operateReliability: AutoResearchConfig = {
goal: 'Increase operate command pass rate to 59/59 (100%)',
scope: [
'src/browser/dom-snapshot.ts',
'src/browser/dom-helpers.ts',
'src/browser/base-page.ts',
'src/browser/page.ts',
'src/cli.ts',
],
metric: 'pass_count',
direction: 'higher',
verify: 'npx tsx autoresearch/eval-browse.ts 2>&1 | tail -1',
guard: 'npm run build',
minDelta: 1,
};
+20
View File
@@ -0,0 +1,20 @@
/**
* Preset: Skill E2E Quality
*
* Optimizes the opencli-operate SKILL.md against the Layer 2 LLM E2E test suite.
* Metric: number of passing skill-tasks (out of 35).
*/
import type { AutoResearchConfig } from '../config.js';
export const skillQuality: AutoResearchConfig = {
goal: 'Increase skill E2E pass rate to 35/35 (100%)',
scope: [
'skills/opencli-operate/SKILL.md',
],
metric: 'pass_count',
direction: 'higher',
verify: 'npx tsx autoresearch/eval-skill.ts 2>&1 | tail -1',
guard: 'npm run build',
iterations: 20,
};
+24
View File
@@ -0,0 +1,24 @@
/**
* Preset: V2EX Command Reliability
*
* Optimizes opencli operate commands against the V2EX-specific test suite.
* 40 tasks across 5 difficulty layers (atomic → complex chain).
*/
import type { AutoResearchConfig } from '../config.js';
export const v2exReliability: AutoResearchConfig = {
goal: 'Increase V2EX operate command pass rate to 40/40 (100%)',
scope: [
'src/browser/dom-snapshot.ts',
'src/browser/dom-helpers.ts',
'src/browser/base-page.ts',
'src/browser/page.ts',
'src/cli.ts',
],
metric: 'pass_count',
direction: 'higher',
verify: 'npx tsx autoresearch/eval-v2ex.ts 2>&1 | tail -1',
guard: 'npm run build',
minDelta: 1,
};
+25
View File
@@ -0,0 +1,25 @@
/**
* Preset: Zhihu Command Reliability
*
* Optimizes opencli operate commands against the Zhihu test suite.
* 60 tasks across 8 difficulty layers (atomic → complex long chain).
* Zhihu is a React SPA with lazy loading, making it harder than V2EX.
*/
import type { AutoResearchConfig } from '../config.js';
export const zhihuReliability: AutoResearchConfig = {
goal: 'Increase Zhihu operate command pass rate to 60/60 (100%)',
scope: [
'src/browser/dom-snapshot.ts',
'src/browser/dom-helpers.ts',
'src/browser/base-page.ts',
'src/browser/page.ts',
'src/cli.ts',
],
metric: 'pass_count',
direction: 'higher',
verify: 'npx tsx autoresearch/eval-zhihu.ts 2>&1 | tail -1',
guard: 'npm run build',
minDelta: 1,
};
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
# Layer 1: Deterministic browse command testing
set -e
cd "$(dirname "$0")/.."
echo "Building OpenCLI..."
npm run build > /dev/null 2>&1
echo "Build OK"
echo ""
npx tsx autoresearch/eval-browse.ts "$@"
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
# Layer 2: Claude Code skill E2E testing
set -e
cd "$(dirname "$0")/.."
echo "Building OpenCLI..."
npm run build > /dev/null 2>&1
echo "Build OK"
echo ""
npx tsx autoresearch/eval-skill.ts "$@"
+899
View File
@@ -0,0 +1,899 @@
[
{
"_comment": "=== Layer 1: Atomic Operations (10 tasks) ==="
},
{
"name": "v2ex-open-home",
"steps": [
"opencli operate open https://v2ex.com/"
],
"judge": {
"type": "contains",
"value": "Navigated to"
}
},
{
"name": "v2ex-state-home",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate state"
],
"judge": {
"type": "matchesPattern",
"pattern": "\\[\\d+\\]"
}
},
{
"name": "v2ex-get-title",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate eval \"document.title\""
],
"judge": {
"type": "contains",
"value": "V2EX"
}
},
{
"name": "v2ex-click-tab",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate eval \"(() => { const a = document.querySelector('a[href=\\\"/?tab=tech\\\"]'); if(a){a.click(); return 'clicked';} return 'not found'; })()\""
],
"judge": {
"type": "contains",
"value": "clicked"
}
},
{
"name": "v2ex-scroll-down",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate scroll down --amount 500",
"opencli operate eval \"window.scrollY > 100 ? 'scrolled' : 'not scrolled'\""
],
"judge": {
"type": "contains",
"value": "scrolled"
}
},
{
"name": "v2ex-get-first-topic-text",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate eval \"document.querySelector('a[href^=\\\"/t/\\\"]')?.textContent?.trim()\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "v2ex-eval-extract-titles",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 3
}
},
{
"name": "v2ex-get-url",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate get url"
],
"judge": {
"type": "contains",
"value": "v2ex.com"
}
},
{
"name": "v2ex-back-navigation",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate open https://v2ex.com/go/programmer",
"opencli operate back",
"opencli operate get url"
],
"judge": {
"type": "matchesPattern",
"pattern": "v2ex\\.com/?$"
}
},
{
"name": "v2ex-wait-page-load",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate wait selector \"a[href^='/t/']\"",
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length > 0 ? 'loaded' : 'empty'\""
],
"judge": {
"type": "contains",
"value": "loaded"
}
},
{
"_comment": "=== Layer 2: Single Page Tasks (10 tasks) ==="
},
{
"name": "v2ex-hot-topics",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,10).map(a=>({title:a.textContent.trim(),url:a.href})).filter(t=>t.title.length>2))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 5
}
},
{
"name": "v2ex-node-list",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/go/\\\"]')].map(a=>a.textContent.trim()).filter(t=>t.length>0))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 10
}
},
{
"name": "v2ex-topic-meta",
"steps": [
"opencli operate open https://v2ex.com/go/programmer",
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');const href=a?.href;return href||'';})()\"",
"opencli operate eval \"(()=>{const links=[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')];const first=links[0];if(!first)return JSON.stringify({error:'no topic'});const title=first.textContent.trim();const row=first.closest('tr')||first.parentElement;const author=row?.querySelector('a[href^=\\\"/member/\\\"]')?.textContent?.trim()||'';return JSON.stringify({title,author});})()\" "
],
"judge": {
"type": "matchesPattern",
"pattern": "\"title\":"
}
},
{
"name": "v2ex-node-topics",
"steps": [
"opencli operate open https://v2ex.com/go/python",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 3
}
},
{
"name": "v2ex-node-pagination-info",
"steps": [
"opencli operate open https://v2ex.com/go/programmer",
"opencli operate eval \"(()=>{const pages=[...document.querySelectorAll('a[href*=\\\"?p=\\\"]')];if(pages.length===0)return'no pagination';const nums=pages.map(a=>{const m=a.href.match(/p=(\\d+)/);return m?parseInt(m[1]):0}).filter(n=>n>0);return JSON.stringify({pages:nums.length,max:Math.max(...nums)});})()\" "
],
"judge": {
"type": "matchesPattern",
"pattern": "\"max\":\\d+"
}
},
{
"name": "v2ex-tab-content",
"steps": [
"opencli operate open https://v2ex.com/?tab=jobs",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 3
}
},
{
"name": "v2ex-topic-replies-extract",
"steps": [
"opencli operate open https://v2ex.com/go/programmer",
"opencli operate eval \"(()=>{const link=document.querySelector('a[href^=\\\"/t/\\\"]');if(!link)return'';return link.href;})()\"",
"opencli operate eval \"(()=>{const link=document.querySelector('a[href^=\\\"/t/\\\"]');if(link)window.location.href=link.href;return'navigating';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('.reply_content')].slice(0,5).map(el=>el.textContent.trim().slice(0,100)))\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "v2ex-topic-reply-count",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate eval \"(()=>{const counts=[...document.querySelectorAll('a[class*=\\\"count\\\"]')].map(a=>parseInt(a.textContent)).filter(n=>!isNaN(n));return JSON.stringify(counts.slice(0,10));})()\" "
],
"judge": {
"type": "arrayMinLength",
"minLength": 3
}
},
{
"name": "v2ex-member-info",
"steps": [
"opencli operate open https://v2ex.com/member/Livid",
"opencli operate eval \"(()=>{const name=document.querySelector('h1')?.textContent?.trim();const bio=document.querySelector('.bigger')?.textContent?.trim()||'';return JSON.stringify({name,bio});})()\" "
],
"judge": {
"type": "contains",
"value": "Livid"
}
},
{
"name": "v2ex-search-results",
"steps": [
"opencli operate open https://www.google.com/search?q=site:v2ex.com+TypeScript",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('h3')].slice(0,5).map(h=>h.textContent.trim()))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 2
}
},
{
"_comment": "=== Layer 3: Multi-Step (10 tasks) ==="
},
{
"name": "v2ex-click-topic-read",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate state",
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked '+a.textContent.trim().slice(0,30);}return 'not found';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"document.querySelector('.topic_content,.markdown_body')?.textContent?.trim()?.slice(0,200)||document.title\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "v2ex-click-author-profile",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/member/\\\"]');if(a){const name=a.textContent.trim();a.click();return name;}return'not found';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(()=>{const h1=document.querySelector('h1');const joined=document.querySelector('.gray')?.textContent||'';return JSON.stringify({name:h1?.textContent?.trim(),info:joined.slice(0,100)});})()\" "
],
"judge": {
"type": "matchesPattern",
"pattern": "\"name\":"
}
},
{
"name": "v2ex-navigate-node-from-home",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate eval \"(()=>{const a=document.querySelector('a[href=\\\"/go/programmer\\\"]')||document.querySelector('a[href^=\\\"/go/\\\"]');if(a){a.click();return 'clicked '+a.textContent.trim();}return'not found';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 3
}
},
{
"name": "v2ex-pagination-page2",
"steps": [
"opencli operate open https://v2ex.com/go/programmer",
"opencli operate eval \"(()=>{const a=document.querySelector('a[href*=\\\"?p=2\\\"]');if(a){a.click();return'navigating to page 2';}return'no page 2 link';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"JSON.stringify({url:location.href,topics:[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,5).map(a=>a.textContent.trim()).filter(t=>t.length>2)})\""
],
"judge": {
"type": "matchesPattern",
"pattern": "p=2"
}
},
{
"name": "v2ex-topic-and-back",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
"opencli operate wait time 2",
"opencli operate back",
"opencli operate wait time 1",
"opencli operate get url"
],
"judge": {
"type": "matchesPattern",
"pattern": "v2ex\\.com/?$"
}
},
{
"name": "v2ex-tab-then-topic",
"steps": [
"opencli operate open https://v2ex.com/?tab=creative",
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){const t=a.textContent.trim();a.click();return t;}return'not found';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"document.title\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "v2ex-scroll-find-more",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\"",
"opencli operate scroll down --amount 1000",
"opencli operate scroll down --amount 1000",
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\\d+"
}
},
{
"name": "v2ex-node-to-topic-content",
"steps": [
"opencli operate open https://v2ex.com/go/python",
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(()=>{const title=document.querySelector('h1')?.textContent?.trim()||document.title;const content=document.querySelector('.topic_content,.markdown_body')?.textContent?.trim()?.slice(0,200)||'';return JSON.stringify({title,content});})()\" "
],
"judge": {
"type": "matchesPattern",
"pattern": "\"title\":"
}
},
{
"name": "v2ex-multi-tab-compare",
"steps": [
"opencli operate open https://v2ex.com/?tab=tech",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()))\"",
"opencli operate open https://v2ex.com/?tab=creative",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 2
}
},
{
"name": "v2ex-topic-reply-to-author",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(()=>{const replies=document.querySelectorAll('.reply_content');const authors=[...document.querySelectorAll('a[href^=\\\"/member/\\\"]')];if(replies.length>0){const authorLink=document.querySelector('.cell a[href^=\\\"/member/\\\"]');if(authorLink){authorLink.click();return'clicked author';}};return'no replies found';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"document.querySelector('h1')?.textContent?.trim()||document.title\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"_comment": "=== Layer 4: Write Operations (5 tasks, requires login) ==="
},
{
"name": "v2ex-reply-type-text",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate eval \"(()=>{const links=[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')];const link=links.find(a=>a.closest('tr')?.querySelector('a[class*=\\\"count\\\"]'));if(link){link.click();return'clicked';}if(links[0]){links[0].click();return'clicked first';}return'no topic';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(()=>{const ta=document.querySelector('textarea#reply_content,textarea[name=\\\"content\\\"]');if(ta){ta.focus();ta.value='AutoResearch test reply - please ignore';ta.dispatchEvent(new Event('input',{bubbles:true}));return ta.value;}return'no textarea';})()\" "
],
"judge": {
"type": "contains",
"value": "AutoResearch test reply"
},
"note": "Types into reply box but does NOT submit"
},
{
"name": "v2ex-favorite-topic",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(()=>{const favLink=[...document.querySelectorAll('a')].find(a=>a.textContent.includes('加入收藏')||a.textContent.includes('Favorite'));return favLink?favLink.href:'no fav link';})()\" "
],
"judge": {
"type": "matchesPattern",
"pattern": "favorite|收藏"
},
"note": "Finds favorite link but does NOT click it"
},
{
"name": "v2ex-thank-reply-find",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(()=>{const thankBtns=document.querySelectorAll('.thank_area,a[onclick*=\\\"thank\\\"],.thank');return JSON.stringify({found:thankBtns.length});})()\" "
],
"judge": {
"type": "matchesPattern",
"pattern": "\"found\":\\d+"
},
"note": "Finds thank buttons but does NOT click"
},
{
"name": "v2ex-reply-form-detect",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(()=>{const ta=document.querySelector('textarea#reply_content,textarea[name=\\\"content\\\"]');const btn=document.querySelector('input[type=\\\"submit\\\"],button[type=\\\"submit\\\"]');const once=document.querySelector('input[name=\\\"once\\\"]');return JSON.stringify({textarea:!!ta,submitBtn:!!btn,csrfToken:!!once});})()\" "
],
"judge": {
"type": "contains",
"value": "\"textarea\":"
}
},
{
"name": "v2ex-create-topic-form-detect",
"steps": [
"opencli operate open https://v2ex.com/new",
"opencli operate wait time 2",
"opencli operate eval \"(()=>{const title=document.querySelector('input[name=\\\"title\\\"],#topic_title');const content=document.querySelector('textarea[name=\\\"content\\\"],#topic_content,textarea#editor');const nodeSelect=document.querySelector('select[name=\\\"node_name\\\"],#node-select');return JSON.stringify({titleInput:!!title,contentArea:!!content,nodeSelect:!!nodeSelect,url:location.href});})()\" "
],
"judge": {
"type": "nonEmpty"
},
"note": "Detects create topic form elements, does NOT submit"
},
{
"_comment": "=== Layer 5: Complex Chain (5 tasks) ==="
},
{
"name": "v2ex-collect-hot-authors",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate eval \"JSON.stringify([...new Set([...document.querySelectorAll('a')].filter(a=>a.pathname&&a.pathname.startsWith('/member/')).map(a=>a.textContent.trim()).filter(n=>n.length>1))].slice(0,5))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 2
}
},
{
"name": "v2ex-multi-node-compare",
"steps": [
"opencli operate open https://v2ex.com/go/python",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()).filter(t=>t.length>2))\"",
"opencli operate open https://v2ex.com/go/go",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 2
}
},
{
"name": "v2ex-topic-deep-read",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return'clicked';}return'not found';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(()=>{const title=document.querySelector('h1')?.textContent?.trim()||document.title;const author=document.querySelector('.header a[href^=\\\"/member/\\\"]')?.textContent?.trim()||'';const content=document.querySelector('.topic_content,.markdown_body')?.textContent?.trim()?.slice(0,300)||'';const replyCount=document.querySelectorAll('.reply_content').length;return JSON.stringify({title,author,content,replyCount});})()\" "
],
"judge": {
"type": "matchesPattern",
"pattern": "\"title\":.+\"author\":"
}
},
{
"name": "v2ex-cross-page-data-collect",
"steps": [
"opencli operate open https://v2ex.com/go/programmer",
"opencli operate eval \"(()=>{const titles=[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim());window.__collected=titles;return JSON.stringify(titles);})()\"",
"opencli operate open https://v2ex.com/go/programmer?p=2",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()).filter(t=>t.length>2))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 2
}
},
{
"name": "v2ex-full-workflow",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate eval \"(()=>{const topics=[...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>({title:a.textContent.trim(),href:a.href}));return JSON.stringify(topics);})()\"",
"opencli operate eval \"(()=>{const a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked: '+a.textContent.trim().slice(0,30);}return'not found';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(()=>{const title=document.querySelector('h1')?.textContent?.trim()||document.title;const replies=[...document.querySelectorAll('.reply_content')].slice(0,3).map(el=>el.textContent.trim().slice(0,80));const author=document.querySelector('.header a[href^=\\\"/member/\\\"]')?.textContent?.trim()||'';return JSON.stringify({title,author,replies,replyCount:replies.length});})()\" "
],
"judge": {
"type": "matchesPattern",
"pattern": "\"title\":.+\"author\":"
}
},
{
"_comment": "=== Layer 6: State + Click Interaction (10 tasks) ==="
},
{
"name": "v2ex-state-click-topic",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate state",
"opencli operate click 1"
],
"judge": {
"type": "contains",
"value": "Clicked"
}
},
{
"name": "v2ex-state-click-tab-tech",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate state",
"opencli operate eval \"(function(){var links=[...document.querySelectorAll('a')];var tab=links.find(a=>a.href&&a.href.includes('tab=tech'));if(tab){var ref=tab.getAttribute('data-opencli-ref');return ref||'no-ref';}return 'not-found';})()\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\\d+|no-ref"
}
},
{
"name": "v2ex-state-count-interactive",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate state"
],
"judge": {
"type": "matchesPattern",
"pattern": "interactive: \\d+"
}
},
{
"name": "v2ex-state-scroll-state",
"steps": [
"opencli operate open https://v2ex.com/go/programmer",
"opencli operate scroll down --amount 500",
"opencli operate state"
],
"judge": {
"type": "matchesPattern",
"pattern": "\\[\\d+\\]"
}
},
{
"name": "v2ex-type-search-box",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate state",
"opencli operate eval \"(function(){var input=document.querySelector('input[type=\\\"text\\\"]');if(input){input.focus();input.value='TypeScript';input.dispatchEvent(new Event('input',{bubbles:true}));return input.value;}return 'no-input';})()\""
],
"judge": {
"type": "contains",
"value": "TypeScript"
}
},
{
"name": "v2ex-get-value-after-type",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a)a.click();return 'clicked';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var ta=document.querySelector('textarea#reply_content,textarea[name=\\\"content\\\"]');if(ta){ta.focus();ta.value='test message 12345';ta.dispatchEvent(new Event('input',{bubbles:true}));return ta.value;}return 'no-textarea';})()\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "v2ex-screenshot-exists",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate screenshot /tmp/v2ex-test-screenshot.png"
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "v2ex-get-html-selector",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate get html --selector h1"
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "v2ex-keys-escape",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate keys Escape"
],
"judge": {
"type": "contains",
"value": "pressed"
}
},
{
"name": "v2ex-wait-text",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate wait text V2EX"
],
"judge": {
"type": "matchesPattern",
"pattern": "found|appeared"
}
},
{
"_comment": "=== Layer 7: Long Chain Workflows (10 tasks) ==="
},
{
"name": "v2ex-chain-3-pages",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate eval \"document.title\"",
"opencli operate open https://v2ex.com/go/programmer",
"opencli operate eval \"document.title\"",
"opencli operate open https://v2ex.com/go/python",
"opencli operate eval \"document.title\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "v2ex-chain-navigate-extract-back",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){var t=a.textContent.trim();a.click();return t;}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"document.querySelector('h1')?.textContent?.trim()||document.title\"",
"opencli operate back",
"opencli operate wait time 1",
"opencli operate eval \"document.title\""
],
"judge": {
"type": "contains",
"value": "V2EX"
}
},
{
"name": "v2ex-chain-multi-node-scroll",
"steps": [
"opencli operate open https://v2ex.com/go/python",
"opencli operate scroll down --amount 500",
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\"",
"opencli operate open https://v2ex.com/go/go",
"opencli operate scroll down --amount 500",
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\\d+"
}
},
{
"name": "v2ex-chain-topic-replies-pagination",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate eval \"(function(){var links=document.querySelectorAll('a[href^=\\\"/t/\\\"]');for(var i=0;i<links.length;i++){var row=links[i].closest('tr')||links[i].parentElement;var count=row?.querySelector('a[class*=\\\"count\\\"]');if(count&&parseInt(count.textContent)>5){links[i].click();return 'clicked topic with '+count.textContent+' replies';}}return 'no high-reply topic';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"document.querySelectorAll('.reply_content').length\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\\d+"
}
},
{
"name": "v2ex-chain-member-topics",
"steps": [
"opencli operate open https://v2ex.com/member/Livid",
"opencli operate eval \"document.querySelector('h1')?.textContent?.trim()\"",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,3).map(a=>a.textContent.trim()))\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "v2ex-chain-search-navigate-extract",
"steps": [
"opencli operate open https://www.google.com/search?q=site:v2ex.com+Python",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var links=[...document.querySelectorAll('a')];var v2exLink=links.find(a=>a.href&&a.href.includes('v2ex.com/t/'));if(v2exLink){v2exLink.click();return 'clicked';}return 'no v2ex link found';})()\"",
"opencli operate wait time 3",
"opencli operate eval \"document.title\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "v2ex-chain-tab-topic-author",
"steps": [
"opencli operate open https://v2ex.com/?tab=tech",
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var author=document.querySelector('.header a[href^=\\\"/member/\\\"]');if(author){var name=author.textContent.trim();author.click();return name;}return 'no author';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"document.querySelector('h1')?.textContent?.trim()||'no h1'\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "v2ex-chain-node-page2-extract",
"steps": [
"opencli operate open https://v2ex.com/go/programmer",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,2).map(a=>a.textContent.trim()))\"",
"opencli operate open https://v2ex.com/go/programmer?p=2",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,2).map(a=>a.textContent.trim()))\"",
"opencli operate open https://v2ex.com/go/programmer?p=3",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(0,2).map(a=>a.textContent.trim()))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 1
}
},
{
"name": "v2ex-chain-full-interaction",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate state",
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate state",
"opencli operate eval \"(function(){var ta=document.querySelector('textarea#reply_content');if(ta)return 'reply form found';return 'no reply form';})()\"",
"opencli operate eval \"JSON.stringify({title:document.querySelector('h1')?.textContent?.trim()||document.title,replies:document.querySelectorAll('.reply_content').length})\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\"title\":"
}
},
{
"name": "v2ex-chain-deep-5-step",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/go/\\\"]').length\"",
"opencli operate open https://v2ex.com/go/programmer",
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\"",
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"document.querySelectorAll('.reply_content').length\"",
"opencli operate back",
"opencli operate eval \"document.querySelectorAll('a[href^=\\\"/t/\\\"]').length\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\\d+"
}
},
{
"_comment": "=== Edge Cases: SPA navigation, timing, dynamic content ==="
},
{
"name": "v2ex-rapid-navigate",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate open https://v2ex.com/go/programmer",
"opencli operate open https://v2ex.com/go/python",
"opencli operate eval \"location.pathname\""
],
"judge": {
"type": "contains",
"value": "/go/python"
}
},
{
"name": "v2ex-eval-after-click",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli operate wait time 1",
"opencli operate eval \"location.pathname.startsWith('/t/') ? 'on topic page' : 'wrong page: '+location.pathname\""
],
"judge": {
"type": "contains",
"value": "on topic page"
}
},
{
"name": "v2ex-scroll-and-extract",
"steps": [
"opencli operate open https://v2ex.com/go/programmer",
"opencli operate scroll down --amount 2000",
"opencli operate wait time 1",
"opencli operate eval \"JSON.stringify([...document.querySelectorAll('a[href^=\\\"/t/\\\"]')].slice(-3).map(a=>a.textContent.trim().slice(0,30)))\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 2
}
},
{
"name": "v2ex-concurrent-eval",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate eval \"JSON.stringify({title:document.title,url:location.href,links:document.querySelectorAll('a').length})\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\"title\":.*\"url\":.*\"links\":"
}
},
{
"name": "v2ex-unicode-content",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate eval \"(function(){var a=document.querySelector('a[href^=\\\"/t/\\\"]');return a?a.textContent.trim():'none';})()\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"_comment": "=== Agent-Style: state + click + type (no eval for interaction) ==="
},
{
"name": "v2ex-agent-click-first-topic",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate state",
"opencli operate eval \"(function(){var links=document.querySelectorAll('[data-opencli-ref]');for(var i=0;i<links.length;i++){if(links[i].pathname&&links[i].pathname.startsWith('/t/'))return links[i].getAttribute('data-opencli-ref');}return 'none';})()\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\\d+"
},
"note": "Finds the index of first topic link via data-opencli-ref"
},
{
"name": "v2ex-agent-type-search",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate state",
"opencli operate type 3 TypeScript",
"opencli operate get value 3"
],
"judge": {
"type": "contains",
"value": "TypeScript"
},
"note": "Types into search box using state index"
},
{
"name": "v2ex-agent-click-navigate-back",
"steps": [
"opencli operate open https://v2ex.com/?tab=hot",
"opencli operate state",
"opencli operate eval \"(function(){var links=document.querySelectorAll('[data-opencli-ref]');for(var i=0;i<links.length;i++){if(links[i].pathname&&links[i].pathname.startsWith('/t/')){var ref=links[i].getAttribute('data-opencli-ref');document.querySelector('[data-opencli-ref=\\\"'+ref+'\\\"]').click();return 'clicked '+ref;}}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"document.title\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "v2ex-agent-state-has-interactive",
"steps": [
"opencli operate open https://v2ex.com/",
"opencli operate state"
],
"judge": {
"type": "matchesPattern",
"pattern": "interactive: \\d+"
}
},
{
"name": "v2ex-agent-state-after-scroll",
"steps": [
"opencli operate open https://v2ex.com/go/programmer",
"opencli operate scroll down --amount 800",
"opencli operate state"
],
"judge": {
"type": "matchesPattern",
"pattern": "page_scroll: [\\d.]+↑"
}
}
]
+848
View File
@@ -0,0 +1,848 @@
[
{
"_comment": "=== L1: Atomic Operations (10 tasks) ==="
},
{
"name": "zhihu-open-home",
"steps": [
"opencli operate open https://www.zhihu.com/"
],
"judge": {
"type": "contains",
"value": "Navigated to"
}
},
{
"name": "zhihu-get-title",
"steps": [
"opencli operate open https://www.zhihu.com/",
"opencli operate eval \"document.title\""
],
"judge": {
"type": "contains",
"value": "知乎"
}
},
{
"name": "zhihu-state",
"steps": [
"opencli operate open https://www.zhihu.com/",
"opencli operate state"
],
"judge": {
"type": "matchesPattern",
"pattern": "\\[@?\\d+\\]"
}
},
{
"name": "zhihu-get-url",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate get url"
],
"judge": {
"type": "contains",
"value": "zhihu.com/hot"
}
},
{
"name": "zhihu-scroll-down",
"steps": [
"opencli operate open https://www.zhihu.com/",
"opencli operate scroll down --amount 500",
"opencli operate eval \"window.scrollY > 100 ? 'scrolled' : 'not scrolled'\""
],
"judge": {
"type": "contains",
"value": "scrolled"
}
},
{
"name": "zhihu-click-tab-hot",
"steps": [
"opencli operate open https://www.zhihu.com/",
"opencli operate eval \"(function(){var a=document.querySelector('nav a[href*=hot]');if(a){a.click();return 'clicked';}return 'not found';})()\""
],
"judge": {
"type": "contains",
"value": "clicked"
}
},
{
"name": "zhihu-back-navigation",
"steps": [
"opencli operate open https://www.zhihu.com/",
"opencli operate open https://www.zhihu.com/hot",
"opencli operate back",
"opencli operate get url"
],
"judge": {
"type": "matchesPattern",
"pattern": "zhihu\\.com/?$"
}
},
{
"name": "zhihu-wait-page-load",
"steps": [
"opencli operate open https://www.zhihu.com/",
"opencli operate wait text 推荐",
"opencli operate eval \"document.querySelector('nav')?.textContent?.includes('推荐') ? 'loaded' : 'empty'\""
],
"judge": {
"type": "contains",
"value": "loaded"
}
},
{
"name": "zhihu-keys-escape",
"steps": [
"opencli operate open https://www.zhihu.com/",
"opencli operate keys Escape"
],
"judge": {
"type": "matchesPattern",
"pattern": "pressed|Pressed"
}
},
{
"name": "zhihu-screenshot",
"steps": [
"opencli operate open https://www.zhihu.com/",
"opencli operate screenshot /tmp/zhihu-test.png"
],
"judge": {
"type": "nonEmpty"
}
},
{
"_comment": "=== L2: Homepage & Feed Extraction (8 tasks) ==="
},
{
"name": "zhihu-feed-titles",
"steps": [
"opencli operate open https://www.zhihu.com/",
"opencli operate eval \"(function(){var items=document.querySelectorAll('h2.ContentItem-title a, .ContentItem-title a');var r=[];for(var i=0;i<Math.min(items.length,5);i++){r.push(items[i].textContent.trim().slice(0,60));}return JSON.stringify(r);})()\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 3
}
},
{
"name": "zhihu-hot-list",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');var r=[];for(var i=0;i<Math.min(items.length,5);i++){r.push({title:items[i].textContent.trim().slice(0,50),href:items[i].pathname});}return JSON.stringify(r);})()\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 5
}
},
{
"name": "zhihu-hot-metrics",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var metrics=document.querySelectorAll('.HotItem-metrics');var r=[];for(var i=0;i<Math.min(metrics.length,5);i++){r.push(metrics[i].textContent.trim());}return JSON.stringify(r);})()\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 3
}
},
{
"name": "zhihu-nav-tabs",
"steps": [
"opencli operate open https://www.zhihu.com/",
"opencli operate eval \"(function(){var tabs=document.querySelectorAll('nav a');var r=[];for(var i=0;i<tabs.length;i++){r.push(tabs[i].textContent.trim());}return JSON.stringify(r);})()\""
],
"judge": {
"type": "contains",
"value": "推荐"
}
},
{
"name": "zhihu-feed-with-authors",
"steps": [
"opencli operate open https://www.zhihu.com/",
"opencli operate eval \"(function(){var items=document.querySelectorAll('.ContentItem');var r=[];for(var i=0;i<Math.min(items.length,3);i++){var title=items[i].querySelector('h2 a')?.textContent?.trim()||'';var author=items[i].querySelector('.AuthorInfo-name')?.textContent?.trim()||'';if(title)r.push({title:title.slice(0,40),author:author});}return JSON.stringify(r);})()\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 2
}
},
{
"name": "zhihu-feed-types",
"steps": [
"opencli operate open https://www.zhihu.com/",
"opencli operate eval \"(function(){var links=document.querySelectorAll('h2.ContentItem-title a, .ContentItem-title a');var types={question:0,article:0,other:0};for(var i=0;i<links.length;i++){var h=links[i].pathname||'';if(h.includes('/question/'))types.question++;else if(h.includes('/p/'))types.article++;else types.other++;}return JSON.stringify(types);})()\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\"question\":\\d+"
}
},
{
"name": "zhihu-user-avatar",
"steps": [
"opencli operate open https://www.zhihu.com/",
"opencli operate eval \"(function(){var img=document.querySelector('img[alt*=\\\"头像\\\"],img[alt*=\\\"主页\\\"],img[class*=\\\"Avatar\\\"]');return img?img.src:'no avatar';})()\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "zhihu-search-input-exists",
"steps": [
"opencli operate open https://www.zhihu.com/",
"opencli operate eval \"(function(){var input=document.querySelector('input[role=combobox],input[type=search]');return input?'search found':'no search';})()\""
],
"judge": {
"type": "contains",
"value": "search found"
}
},
{
"_comment": "=== L3: Question Page Operations (8 tasks) ==="
},
{
"name": "zhihu-question-title",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');return a?a.href:'none';})()\"",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,80)||document.title\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "zhihu-question-meta",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var title=document.querySelector('.QuestionHeader-title')?.textContent?.trim()||'';var answerCount=document.querySelector('.List-headerText')?.textContent?.trim()||'';var followers=document.querySelector('[class*=FollowButton]')?.textContent?.trim()||'';return JSON.stringify({title:title.slice(0,60),answerCount:answerCount,followers:followers});})()\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\"title\":"
}
},
{
"name": "zhihu-first-answer",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var content=document.querySelector('.RichContent-inner')?.textContent?.trim()?.slice(0,200)||'';var author=document.querySelector('.AuthorInfo-name')?.textContent?.trim()||'';return JSON.stringify({author:author,content:content});})()\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\"content\":"
}
},
{
"name": "zhihu-answer-votes",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button[class*=VoteButton]');var r=[];for(var i=0;i<Math.min(btns.length,6);i++){var label=btns[i].getAttribute('aria-label')||btns[i].textContent.trim();if(label)r.push(label.slice(0,30));}return JSON.stringify(r);})()\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 2
}
},
{
"name": "zhihu-question-buttons",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');var r=[];for(var i=0;i<btns.length;i++){var t=btns[i].textContent.trim();if(t.length>0&&t.length<25)r.push(t);}return JSON.stringify(r.slice(0,15));})()\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 3
}
},
{
"name": "zhihu-multiple-answers",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var answers=document.querySelectorAll('.List-item .RichContent-inner');var r=[];for(var i=0;i<Math.min(answers.length,3);i++){r.push(answers[i].textContent.trim().slice(0,80));}return JSON.stringify(r);})()\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 1
}
},
{
"name": "zhihu-question-description",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var desc=document.querySelector('.QuestionRichText')?.textContent?.trim()?.slice(0,200)||'no description';return desc;})()\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "zhihu-answer-count-number",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var header=document.querySelector('.List-headerText');if(!header)return '0';var m=header.textContent.match(/\\\\d+/);return m?m[0]:'0';})()\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\\d+"
}
},
{
"_comment": "=== L4: Multi-Step Navigation (8 tasks) ==="
},
{
"name": "zhihu-hot-to-question",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){a.click();return 'clicked: '+a.textContent.trim().slice(0,30);}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,80)||document.title\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "zhihu-feed-to-question",
"steps": [
"opencli operate open https://www.zhihu.com/",
"opencli operate eval \"(function(){var a=document.querySelector('h2.ContentItem-title a');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli operate wait time 3",
"opencli operate eval \"document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,80)||document.title\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "zhihu-question-to-author",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var author=document.querySelector('.AuthorInfo-name a, .UserLink-link');if(author){var name=author.textContent.trim();window.location.href=author.href;return name;}return 'no author';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"document.title\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "zhihu-search-navigate",
"steps": [
"opencli operate open https://www.zhihu.com/search?type=content&q=TypeScript",
"opencli operate wait time 5",
"opencli operate scroll down --amount 300",
"opencli operate wait time 1",
"opencli operate eval \"(function(){var items=document.querySelectorAll('h2 a');var r=[];var seen={};for(var i=0;i<items.length;i++){var t=items[i].textContent.trim();if(t.length>5&&t.length<100&&!seen[t]){seen[t]=1;r.push(t.slice(0,50));}}return JSON.stringify(r.slice(0,5));})()\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "zhihu-topic-page",
"steps": [
"opencli operate open https://www.zhihu.com/topic/19552832/hot",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var title=document.querySelector('.TopicName, .ContentItem-title, h1')?.textContent?.trim()||document.title;return title;})()\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "zhihu-user-profile",
"steps": [
"opencli operate open https://www.zhihu.com/people/excited-vczh",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var name=document.querySelector('.ProfileHeader-title .ProfileHeader-name')?.textContent?.trim()||document.querySelector('h1')?.textContent?.trim()||'';var bio=document.querySelector('.ProfileHeader-headline')?.textContent?.trim()||'';return JSON.stringify({name:name,bio:bio.slice(0,100)});})()\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\"name\":"
}
},
{
"name": "zhihu-question-and-back",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli operate wait time 3",
"opencli operate open https://www.zhihu.com/hot",
"opencli operate get url"
],
"judge": {
"type": "contains",
"value": "zhihu.com/hot"
}
},
{
"name": "zhihu-scroll-load-more",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"document.querySelectorAll('.HotItem-content').length\"",
"opencli operate scroll down --amount 2000",
"opencli operate wait time 1",
"opencli operate eval \"document.querySelectorAll('.HotItem-content').length\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\\d+"
}
},
{
"_comment": "=== L5: Write Operations (6 tasks, requires login) ==="
},
{
"name": "zhihu-upvote-button-find",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var btn=document.querySelector('button[aria-label*=赞同]');return btn?JSON.stringify({text:btn.textContent.trim(),ariaLabel:btn.getAttribute('aria-label')}):'no upvote button';})()\""
],
"judge": {
"type": "contains",
"value": "赞同"
},
"note": "Finds upvote button but does NOT click"
},
{
"name": "zhihu-follow-question-find",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){if(btns[i].textContent.includes('关注问题'))return 'found: '+btns[i].textContent.trim();}return 'not found';})()\""
],
"judge": {
"type": "contains",
"value": "关注问题"
},
"note": "Finds follow button but does NOT click"
},
{
"name": "zhihu-comment-button-find",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){if(btns[i].textContent.includes('评论'))return 'found: '+btns[i].textContent.trim();}return 'not found';})()\""
],
"judge": {
"type": "matchesPattern",
"pattern": "评论"
},
"note": "Finds comment button but does NOT click"
},
{
"name": "zhihu-bookmark-find",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){var t=btns[i].textContent.trim();if(t.includes('收藏')||t.includes('Bookmark'))return 'found: '+t;}return 'not found';})()\""
],
"judge": {
"type": "matchesPattern",
"pattern": "收藏|found"
},
"note": "Finds bookmark button but does NOT click"
},
{
"name": "zhihu-write-answer-btn",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){if(btns[i].textContent.includes('写回答'))return 'found: '+btns[i].textContent.trim();}return 'not found';})()\""
],
"judge": {
"type": "contains",
"value": "写回答"
},
"note": "Finds write answer button but does NOT click"
},
{
"name": "zhihu-share-find",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var btns=document.querySelectorAll('button');for(var i=0;i<btns.length;i++){if(btns[i].textContent.includes('分享'))return 'found: '+btns[i].textContent.trim();}return 'not found';})()\""
],
"judge": {
"type": "contains",
"value": "分享"
},
"note": "Finds share button but does NOT click"
},
{
"_comment": "=== L6: Long Chain Workflows (8 tasks) ==="
},
{
"name": "zhihu-hot-read-answer-author",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var author=document.querySelector('.AuthorInfo-name')?.textContent?.trim()||'';var content=document.querySelector('.RichContent-inner')?.textContent?.trim()?.slice(0,100)||'';return JSON.stringify({author:author,content:content});})()\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\"author\":"
}
},
{
"name": "zhihu-hot-to-author-profile",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var link=document.querySelector('.AuthorInfo-name a, .UserLink-link');if(link){window.location.href=link.href;return 'going to author';}return 'no author link';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var name=document.querySelector('h1, .ProfileHeader-name')?.textContent?.trim()||document.title;return name;})()\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "zhihu-multi-hot-topics",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');var r=[];for(var i=0;i<Math.min(items.length,3);i++){r.push(items[i].textContent.trim().slice(0,40));}return JSON.stringify(r);})()\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 3
}
},
{
"name": "zhihu-search-then-read",
"steps": [
"opencli operate open https://www.zhihu.com/search?type=content&q=Python",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var a=document.querySelector('.ContentItem-title a, .SearchResult-Card h2 a');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli operate wait time 3",
"opencli operate eval \"document.querySelector('.QuestionHeader-title, h1')?.textContent?.trim()?.slice(0,80)||document.title\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "zhihu-question-scroll-answers",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate scroll down --amount 1000",
"opencli operate wait time 1",
"opencli operate eval \"document.querySelectorAll('.RichContent-inner').length\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\\d+"
}
},
{
"name": "zhihu-compare-tabs",
"steps": [
"opencli operate open https://www.zhihu.com/",
"opencli operate eval \"(function(){var a=document.querySelector('h2.ContentItem-title a');return a?a.textContent.trim().slice(0,40):'none';})()\"",
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');return a?a.textContent.trim().slice(0,40):'none';})()\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "zhihu-user-answers",
"steps": [
"opencli operate open https://www.zhihu.com/people/excited-vczh/answers",
"opencli operate wait time 4",
"opencli operate eval \"(function(){var items=document.querySelectorAll('h2 a, [class*=title] a, [class*=Title] a');var r=[];for(var i=0;i<items.length;i++){var t=items[i].textContent.trim();if(t.length>10&&t.length<100)r.push(t.slice(0,50));}return JSON.stringify(r.slice(0,3));})()\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "zhihu-topic-questions",
"steps": [
"opencli operate open https://www.zhihu.com/topic/19552832/hot",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var items=document.querySelectorAll('.ContentItem-title a, h2 a');var r=[];for(var i=0;i<Math.min(items.length,3);i++){var t=items[i].textContent.trim();if(t.length>5)r.push(t.slice(0,50));}return JSON.stringify(r);})()\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 1
}
},
{
"_comment": "=== L7: Search Workflows (6 tasks) ==="
},
{
"name": "zhihu-search-basic",
"steps": [
"opencli operate open https://www.zhihu.com/search?type=content&q=AI",
"opencli operate wait time 5",
"opencli operate scroll down --amount 300",
"opencli operate wait time 1",
"opencli operate eval \"(function(){var items=document.querySelectorAll('h2 a');var r=[];var seen={};for(var i=0;i<items.length;i++){var t=items[i].textContent.trim();if(t.length>5&&t.length<100&&!seen[t]){seen[t]=1;r.push(t.slice(0,50));}}return JSON.stringify(r.slice(0,5));})()\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "zhihu-search-people",
"steps": [
"opencli operate open https://www.zhihu.com/search?type=people&q=Python",
"opencli operate wait time 3",
"opencli operate eval \"(function(){var items=document.querySelectorAll('[class*=UserItem] a, [class*=user] a, .List-item a');var r=[];for(var i=0;i<Math.min(items.length,10);i++){var t=items[i].textContent.trim();if(t.length>1&&t.length<30)r.push(t);}return JSON.stringify(r.slice(0,3));})()\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "zhihu-search-topic",
"steps": [
"opencli operate open https://www.zhihu.com/search?type=topic&q=编程",
"opencli operate wait time 3",
"opencli operate eval \"(function(){var items=document.querySelectorAll('a');var r=[];for(var i=0;i<items.length;i++){var t=items[i].textContent.trim();if(t.length>2&&t.length<30&&(t.includes('编程')||items[i].pathname?.includes('/topic/')))r.push(t);}return JSON.stringify(r.slice(0,3));})()\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "zhihu-search-click-result",
"steps": [
"opencli operate open https://www.zhihu.com/search?type=content&q=Rust编程",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var a=document.querySelector('.ContentItem-title a, h2 a');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli operate wait time 3",
"opencli operate eval \"document.title\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "zhihu-search-filter-answers",
"steps": [
"opencli operate open https://www.zhihu.com/search?type=content&q=Docker",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var items=document.querySelectorAll('.ContentItem');return JSON.stringify({total:items.length,hasAnswers:items.length>0});})()\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\"total\":\\d+"
}
},
{
"name": "zhihu-search-and-back",
"steps": [
"opencli operate open https://www.zhihu.com/search?type=content&q=前端开发",
"opencli operate wait time 5",
"opencli operate eval \"(function(){var a=document.querySelector('h2 a, [class*=title] a');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate open https://www.zhihu.com/search?type=content&q=前端开发",
"opencli operate wait time 3",
"opencli operate get url"
],
"judge": {
"type": "contains",
"value": "search"
}
},
{
"_comment": "=== L8: Complex Long Chain (6 tasks) ==="
},
{
"name": "zhihu-full-browse-workflow",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');var r=[];for(var i=0;i<Math.min(items.length,3);i++){r.push(items[i].textContent.trim().slice(0,30));}return JSON.stringify(r);})()\"",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var title=document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,60)||'';var answers=document.querySelectorAll('.RichContent-inner').length;return JSON.stringify({title:title,answers:answers});})()\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\"title\":"
}
},
{
"name": "zhihu-deep-author-chain",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'step1';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var link=document.querySelector('.AuthorInfo-name a');if(link){var name=link.textContent.trim();window.location.href=link.href;return 'step2: '+name;}return 'no author';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var answers=document.querySelectorAll('.ContentItem-title a');var r=[];for(var i=0;i<Math.min(answers.length,2);i++){r.push(answers[i].textContent.trim().slice(0,40));}return JSON.stringify({profile:document.title,recentAnswers:r});})()\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\"profile\":"
}
},
{
"name": "zhihu-cross-question-compare",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');return items.length>=2?JSON.stringify([items[0].textContent.trim().slice(0,30),items[1].textContent.trim().slice(0,30)]):'not enough';})()\"",
"opencli operate eval \"(function(){var a=document.querySelectorAll('.HotItem-content a')[0];if(a){window.location.href=a.href;return 'q1';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){return JSON.stringify({q1_title:document.querySelector('.QuestionHeader-title')?.textContent?.trim()?.slice(0,40)||'',q1_answers:document.querySelectorAll('.RichContent-inner').length});})()\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\"q1_title\":"
}
},
{
"name": "zhihu-search-read-chain",
"steps": [
"opencli operate open https://www.zhihu.com/search?type=content&q=Claude",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var items=document.querySelectorAll('.ContentItem-title a, h2 a');var r=[];for(var i=0;i<Math.min(items.length,3);i++){r.push(items[i].textContent.trim().slice(0,40));}return JSON.stringify(r);})()\"",
"opencli operate eval \"(function(){var a=document.querySelector('.ContentItem-title a, h2 a');if(a){a.click();return 'clicked';}return 'none';})()\"",
"opencli operate wait time 3",
"opencli operate eval \"document.querySelector('.QuestionHeader-title, h1')?.textContent?.trim()?.slice(0,60)||document.title\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "zhihu-3-page-chain",
"steps": [
"opencli operate open https://www.zhihu.com/",
"opencli operate eval \"document.title\"",
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"document.querySelectorAll('.HotItem-content').length\"",
"opencli operate open https://www.zhihu.com/people/excited-vczh",
"opencli operate wait time 2",
"opencli operate eval \"document.title\""
],
"judge": {
"type": "nonEmpty"
}
},
{
"name": "zhihu-hot-scroll-deep-read",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate scroll down --amount 1000",
"opencli operate eval \"document.querySelectorAll('.HotItem-content a').length\"",
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content a');var last=items[items.length-1];if(last){last.click();return 'clicked last';}return 'none';})()\"",
"opencli operate wait time 2",
"opencli operate eval \"(function(){var title=document.querySelector('.QuestionHeader-title')?.textContent?.trim()||document.title;var firstAnswer=document.querySelector('.RichContent-inner')?.textContent?.trim()?.slice(0,100)||'';return JSON.stringify({title:title.slice(0,60),firstAnswer:firstAnswer});})()\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\"title\":"
}
},
{
"_comment": "=== Edge Cases: SPA lazy load, dynamic content ==="
},
{
"name": "zhihu-rapid-navigate",
"steps": [
"opencli operate open https://www.zhihu.com/",
"opencli operate open https://www.zhihu.com/hot",
"opencli operate open https://www.zhihu.com/people/excited-vczh",
"opencli operate wait time 2",
"opencli operate eval \"location.pathname\""
],
"judge": {
"type": "contains",
"value": "/people/"
}
},
{
"name": "zhihu-hot-click-verify-url",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 3",
"opencli operate eval \"location.pathname.startsWith('/question/') ? 'on question page' : 'wrong: '+location.pathname\""
],
"judge": {
"type": "contains",
"value": "on question page"
}
},
{
"name": "zhihu-scroll-lazy-answers",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 3",
"opencli operate eval \"document.querySelectorAll('.RichContent-inner').length\"",
"opencli operate scroll down --amount 2000",
"opencli operate wait time 2",
"opencli operate eval \"document.querySelectorAll('.RichContent-inner').length\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\\d+"
}
},
{
"name": "zhihu-extract-structured",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var items=document.querySelectorAll('.HotItem-content');var r=[];for(var i=0;i<Math.min(items.length,3);i++){var a=items[i].querySelector('a');var m=items[i].closest('[class*=HotItem]')?.querySelector('[class*=metrics]');r.push({title:(a?.textContent||'').trim().slice(0,40),heat:(m?.textContent||'').trim()});}return JSON.stringify(r);})()\""
],
"judge": {
"type": "arrayMinLength",
"minLength": 3
}
},
{
"name": "zhihu-question-answer-chain",
"steps": [
"opencli operate open https://www.zhihu.com/hot",
"opencli operate eval \"(function(){var a=document.querySelector('.HotItem-content a');if(a){window.location.href=a.href;return 'navigating';}return 'none';})()\"",
"opencli operate wait time 3",
"opencli operate eval \"(function(){var title=document.querySelector('.QuestionHeader-title')?.textContent?.trim()||'';var answers=document.querySelectorAll('.RichContent-inner');var first=answers[0]?.textContent?.trim()?.slice(0,100)||'';var count=answers.length;return JSON.stringify({title:title.slice(0,50),firstAnswer:first,answerCount:count});})()\""
],
"judge": {
"type": "matchesPattern",
"pattern": "\"answerCount\":\\d+"
}
}
]
+615
View File
@@ -0,0 +1,615 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "@jackwener/opencli",
"dependencies": {
"chalk": "^5.3.0",
"cli-table3": "^0.6.5",
"commander": "^14.0.3",
"js-yaml": "^4.1.0",
"turndown": "^7.2.2",
"undici": "^7.24.6",
"ws": "^8.18.0",
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.13.10",
"@types/turndown": "^5.0.6",
"@types/ws": "^8.5.13",
"tsx": "^4.19.3",
"typescript": "^6.0.2",
"vitepress": "^1.6.4",
"vitest": "^4.1.0",
},
},
},
"packages": {
"@algolia/abtesting": ["@algolia/abtesting@1.15.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-rF7vRVE61E0QORw8e2NNdnttcl3jmFMWS9B4hhdga12COe+lMa26bQLfcBn/Nbp9/AF/8gXdaRCPsVns3CnjsA=="],
"@algolia/autocomplete-core": ["@algolia/autocomplete-core@1.17.7", "", { "dependencies": { "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", "@algolia/autocomplete-shared": "1.17.7" } }, "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q=="],
"@algolia/autocomplete-plugin-algolia-insights": ["@algolia/autocomplete-plugin-algolia-insights@1.17.7", "", { "dependencies": { "@algolia/autocomplete-shared": "1.17.7" }, "peerDependencies": { "search-insights": ">= 1 < 3" } }, "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A=="],
"@algolia/autocomplete-preset-algolia": ["@algolia/autocomplete-preset-algolia@1.17.7", "", { "dependencies": { "@algolia/autocomplete-shared": "1.17.7" }, "peerDependencies": { "@algolia/client-search": ">= 4.9.1 < 6", "algoliasearch": ">= 4.9.1 < 6" } }, "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA=="],
"@algolia/autocomplete-shared": ["@algolia/autocomplete-shared@1.17.7", "", { "peerDependencies": { "@algolia/client-search": ">= 4.9.1 < 6", "algoliasearch": ">= 4.9.1 < 6" } }, "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg=="],
"@algolia/client-abtesting": ["@algolia/client-abtesting@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-XyvKCm0RRmovMI/ChaAVjTwpZhXdbgt3iZofK914HeEHLqD1MUFFVLz7M0+Ou7F56UkHXwRbpHwb9xBDNopprQ=="],
"@algolia/client-analytics": ["@algolia/client-analytics@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-jq/3qvtmj3NijZlhq7A1B0Cl41GfaBpjJxcwukGsYds6aMSCWrEAJ9pUqw/C9B3hAmILYKl7Ljz3N9SFvekD3Q=="],
"@algolia/client-common": ["@algolia/client-common@5.49.2", "", {}, "sha512-bn0biLequn3epobCfjUqCxlIlurLr4RHu7RaE4trgN+RDcUq6HCVC3/yqq1hwbNYpVtulnTOJzcaxYlSr1fnuw=="],
"@algolia/client-insights": ["@algolia/client-insights@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-z14wfFs1T3eeYbCArC8pvntAWsPo9f6hnUGoj8IoRUJTwgJiiySECkm8bmmV47/x0oGHfsVn3kBdjMX0yq0sNA=="],
"@algolia/client-personalization": ["@algolia/client-personalization@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-GpRf7yuuAX93+Qt0JGEJZwgtL0MFdjFO9n7dn8s2pA9mTjzl0Sc5+uTk1VPbIAuf7xhCP9Mve+URGb6J+EYxgA=="],
"@algolia/client-query-suggestions": ["@algolia/client-query-suggestions@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-HZwApmNkp0DiAjZcLYdQLddcG4Agb88OkojiAHGgcm5DVXobT5uSZ9lmyrbw/tmQBJwgu2CNw4zTyXoIB7YbPA=="],
"@algolia/client-search": ["@algolia/client-search@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-y1IOpG6OSmTpGg/CT0YBb/EAhR2nsC18QWp9Jy8HO9iGySpcwaTvs5kHa17daP3BMTwWyaX9/1tDTDQshZzXdg=="],
"@algolia/ingestion": ["@algolia/ingestion@1.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-YYJRjaZ2bqk923HxE4um7j/Cm3/xoSkF2HC2ZweOF8cXL3sqnlndSUYmCaxHFjNPWLaSHk2IfssX6J/tdKTULw=="],
"@algolia/monitoring": ["@algolia/monitoring@1.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-9WgH+Dha39EQQyGKCHlGYnxW/7W19DIrEbCEbnzwAMpGAv1yTWCHMPXHxYa+LcL3eCp2V/5idD1zHNlIKmHRHg=="],
"@algolia/recommend": ["@algolia/recommend@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-K7Gp5u+JtVYgaVpBxF5rGiM+Ia8SsMdcAJMTDV93rwh00DKNllC19o1g+PwrDjDvyXNrnTEbofzbTs2GLfFyKA=="],
"@algolia/requester-browser-xhr": ["@algolia/requester-browser-xhr@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2" } }, "sha512-3UhYCcWX6fbtN8ABcxZlhaQEwXFh3CsFtARyyadQShHMPe3mJV9Wel4FpJTa+seugRkbezFz0tt6aPTZSYTBuA=="],
"@algolia/requester-fetch": ["@algolia/requester-fetch@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2" } }, "sha512-G94VKSGbsr+WjsDDOBe5QDQ82QYgxvpxRGJfCHZBnYKYsy/jv9qGIDb93biza+LJWizQBUtDj7bZzp3QZyzhPQ=="],
"@algolia/requester-node-http": ["@algolia/requester-node-http@5.49.2", "", { "dependencies": { "@algolia/client-common": "5.49.2" } }, "sha512-UuihBGHafG/ENsrcTGAn5rsOffrCIRuHMOsD85fZGLEY92ate+BMTUqxz60dv5zerh8ZumN4bRm8eW2z9L11jA=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="],
"@docsearch/css": ["@docsearch/css@3.8.2", "", {}, "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ=="],
"@docsearch/js": ["@docsearch/js@3.8.2", "", { "dependencies": { "@docsearch/react": "3.8.2", "preact": "^10.0.0" } }, "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ=="],
"@docsearch/react": ["@docsearch/react@3.8.2", "", { "dependencies": { "@algolia/autocomplete-core": "1.17.7", "@algolia/autocomplete-preset-algolia": "1.17.7", "@docsearch/css": "3.8.2", "algoliasearch": "^5.14.2" }, "peerDependencies": { "@types/react": ">= 16.8.0 < 19.0.0", "react": ">= 16.8.0 < 19.0.0", "react-dom": ">= 16.8.0 < 19.0.0", "search-insights": ">= 1 < 3" }, "optionalPeers": ["@types/react", "react", "react-dom"] }, "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg=="],
"@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
"@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.4", "", { "os": "android", "cpu": "arm" }, "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.4", "", { "os": "android", "cpu": "arm64" }, "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.4", "", { "os": "android", "cpu": "x64" }, "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.4", "", { "os": "linux", "cpu": "arm" }, "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.4", "", { "os": "linux", "cpu": "x64" }, "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.4", "", { "os": "none", "cpu": "x64" }, "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="],
"@iconify-json/simple-icons": ["@iconify-json/simple-icons@1.2.74", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-yqaohfY6jnYjTVpuTkaBQHrWbdUrQyWXhau0r/0EZiNWYXPX/P8WWwl1DoLH5CbvDjjcWQw5J0zADhgCUklOqA=="],
"@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.11", "", { "os": "android", "cpu": "arm64" }, "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.11", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.11", "", { "os": "linux", "cpu": "arm" }, "sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.11", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.11", "", { "os": "linux", "cpu": "s390x" }, "sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.11", "", { "os": "linux", "cpu": "x64" }, "sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.11", "", { "os": "linux", "cpu": "x64" }, "sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.11", "", { "os": "none", "cpu": "arm64" }, "sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.11", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.11", "", { "os": "win32", "cpu": "x64" }, "sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.11", "", {}, "sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="],
"@shikijs/core": ["@shikijs/core@2.5.0", "", { "dependencies": { "@shikijs/engine-javascript": "2.5.0", "@shikijs/engine-oniguruma": "2.5.0", "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.4" } }, "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^3.1.0" } }, "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w=="],
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw=="],
"@shikijs/langs": ["@shikijs/langs@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0" } }, "sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w=="],
"@shikijs/themes": ["@shikijs/themes@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0" } }, "sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw=="],
"@shikijs/transformers": ["@shikijs/transformers@2.5.0", "", { "dependencies": { "@shikijs/core": "2.5.0", "@shikijs/types": "2.5.0" } }, "sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg=="],
"@shikijs/types": ["@shikijs/types@2.5.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw=="],
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
"@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="],
"@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="],
"@types/markdown-it": ["@types/markdown-it@14.1.2", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="],
"@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
"@types/turndown": ["@types/turndown@5.0.6", "", {}, "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.4", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA=="],
"@vitest/expect": ["@vitest/expect@4.1.1", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.1", "@vitest/utils": "4.1.1", "chai": "^6.2.2", "tinyrainbow": "^3.0.3" } }, "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A=="],
"@vitest/mocker": ["@vitest/mocker@4.1.1", "", { "dependencies": { "@vitest/spy": "4.1.1", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw"] }, "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A=="],
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.1", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ=="],
"@vitest/runner": ["@vitest/runner@4.1.1", "", { "dependencies": { "@vitest/utils": "4.1.1", "pathe": "^2.0.3" } }, "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A=="],
"@vitest/snapshot": ["@vitest/snapshot@4.1.1", "", { "dependencies": { "@vitest/pretty-format": "4.1.1", "@vitest/utils": "4.1.1", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg=="],
"@vitest/spy": ["@vitest/spy@4.1.1", "", {}, "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA=="],
"@vitest/utils": ["@vitest/utils@4.1.1", "", { "dependencies": { "@vitest/pretty-format": "4.1.1", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" } }, "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ=="],
"@vue/compiler-core": ["@vue/compiler-core@3.5.30", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/shared": "3.5.30", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw=="],
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.30", "", { "dependencies": { "@vue/compiler-core": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g=="],
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.30", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/compiler-core": "3.5.30", "@vue/compiler-dom": "3.5.30", "@vue/compiler-ssr": "3.5.30", "@vue/shared": "3.5.30", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.8", "source-map-js": "^1.2.1" } }, "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A=="],
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.30", "", { "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA=="],
"@vue/devtools-api": ["@vue/devtools-api@7.7.9", "", { "dependencies": { "@vue/devtools-kit": "^7.7.9" } }, "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g=="],
"@vue/devtools-kit": ["@vue/devtools-kit@7.7.9", "", { "dependencies": { "@vue/devtools-shared": "^7.7.9", "birpc": "^2.3.0", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^1.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA=="],
"@vue/devtools-shared": ["@vue/devtools-shared@7.7.9", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA=="],
"@vue/reactivity": ["@vue/reactivity@3.5.30", "", { "dependencies": { "@vue/shared": "3.5.30" } }, "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q=="],
"@vue/runtime-core": ["@vue/runtime-core@3.5.30", "", { "dependencies": { "@vue/reactivity": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg=="],
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.30", "", { "dependencies": { "@vue/reactivity": "3.5.30", "@vue/runtime-core": "3.5.30", "@vue/shared": "3.5.30", "csstype": "^3.2.3" } }, "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw=="],
"@vue/server-renderer": ["@vue/server-renderer@3.5.30", "", { "dependencies": { "@vue/compiler-ssr": "3.5.30", "@vue/shared": "3.5.30" }, "peerDependencies": { "vue": "3.5.30" } }, "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ=="],
"@vue/shared": ["@vue/shared@3.5.30", "", {}, "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ=="],
"@vueuse/core": ["@vueuse/core@12.8.2", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "12.8.2", "@vueuse/shared": "12.8.2", "vue": "^3.5.13" } }, "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ=="],
"@vueuse/integrations": ["@vueuse/integrations@12.8.2", "", { "dependencies": { "@vueuse/core": "12.8.2", "@vueuse/shared": "12.8.2", "vue": "^3.5.13" }, "peerDependencies": { "async-validator": "^4", "axios": "^1", "change-case": "^5", "drauu": "^0.4", "focus-trap": "^7", "fuse.js": "^7", "idb-keyval": "^6", "jwt-decode": "^4", "nprogress": "^0.2", "qrcode": "^1.5", "sortablejs": "^1", "universal-cookie": "^7" }, "optionalPeers": ["async-validator", "axios", "change-case", "drauu", "fuse.js", "idb-keyval", "jwt-decode", "nprogress", "qrcode", "sortablejs", "universal-cookie"] }, "sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g=="],
"@vueuse/metadata": ["@vueuse/metadata@12.8.2", "", {}, "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A=="],
"@vueuse/shared": ["@vueuse/shared@12.8.2", "", { "dependencies": { "vue": "^3.5.13" } }, "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w=="],
"algoliasearch": ["algoliasearch@5.49.2", "", { "dependencies": { "@algolia/abtesting": "1.15.2", "@algolia/client-abtesting": "5.49.2", "@algolia/client-analytics": "5.49.2", "@algolia/client-common": "5.49.2", "@algolia/client-insights": "5.49.2", "@algolia/client-personalization": "5.49.2", "@algolia/client-query-suggestions": "5.49.2", "@algolia/client-search": "5.49.2", "@algolia/ingestion": "1.49.2", "@algolia/monitoring": "1.49.2", "@algolia/recommend": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", "@algolia/requester-fetch": "5.49.2", "@algolia/requester-node-http": "5.49.2" } }, "sha512-1K0wtDaRONwfhL4h8bbJ9qTjmY6rhGgRvvagXkMBsAOMNr+3Q2SffHECh9DIuNVrMA1JwA0zCwhyepgBZVakng=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
"cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="],
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"emoji-regex-xs": ["emoji-regex-xs@1.0.0", "", {}, "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="],
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="],
"esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": "bin/esbuild" }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="],
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"focus-trap": ["focus-trap@7.8.0", "", { "dependencies": { "tabbable": "^6.4.0" } }, "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"mark.js": ["mark.js@8.11.1", "", {}, "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ=="],
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
"minisearch": ["minisearch@7.2.0", "", {}, "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg=="],
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": "bin/nanoid.cjs" }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
"oniguruma-to-es": ["oniguruma-to-es@3.1.1", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
"preact": ["preact@10.29.0", "", {}, "sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg=="],
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
"regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
"regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"rolldown": ["rolldown@1.0.0-rc.11", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@rolldown/pluginutils": "1.0.0-rc.11" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.11", "@rolldown/binding-darwin-arm64": "1.0.0-rc.11", "@rolldown/binding-darwin-x64": "1.0.0-rc.11", "@rolldown/binding-freebsd-x64": "1.0.0-rc.11", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.11", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.11", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.11", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.11", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.11", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.11", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.11" }, "bin": "bin/cli.mjs" }, "sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw=="],
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
"search-insights": ["search-insights@2.17.3", "", {}, "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ=="],
"shiki": ["shiki@2.5.0", "", { "dependencies": { "@shikijs/core": "2.5.0", "@shikijs/engine-javascript": "2.5.0", "@shikijs/engine-oniguruma": "2.5.0", "@shikijs/langs": "2.5.0", "@shikijs/themes": "2.5.0", "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ=="],
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"speakingurl": ["speakingurl@14.0.1", "", {}, "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"superjson": ["superjson@2.2.6", "", { "dependencies": { "copy-anything": "^4" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="],
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": "dist/cli.mjs" }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
"turndown": ["turndown@7.2.2", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ=="],
"typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="],
"undici": ["undici@7.24.6", "", {}, "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["less", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": "bin/vite.js" }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
"vitepress": ["vitepress@1.6.4", "", { "dependencies": { "@docsearch/css": "3.8.2", "@docsearch/js": "3.8.2", "@iconify-json/simple-icons": "^1.2.21", "@shikijs/core": "^2.1.0", "@shikijs/transformers": "^2.1.0", "@shikijs/types": "^2.1.0", "@types/markdown-it": "^14.1.2", "@vitejs/plugin-vue": "^5.2.1", "@vue/devtools-api": "^7.7.0", "@vue/shared": "^3.5.13", "@vueuse/core": "^12.4.0", "@vueuse/integrations": "^12.4.0", "focus-trap": "^7.6.4", "mark.js": "8.11.1", "minisearch": "^7.1.1", "shiki": "^2.1.0", "vite": "^5.4.14", "vue": "^3.5.13" }, "peerDependencies": { "markdown-it-mathjax3": "^4", "postcss": "^8" }, "optionalPeers": ["markdown-it-mathjax3"], "bin": "bin/vitepress.js" }, "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg=="],
"vitest": ["vitest@4.1.1", "", { "dependencies": { "@vitest/expect": "4.1.1", "@vitest/mocker": "4.1.1", "@vitest/pretty-format": "4.1.1", "@vitest/runner": "4.1.1", "@vitest/snapshot": "4.1.1", "@vitest/spy": "4.1.1", "@vitest/utils": "4.1.1", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.1", "@vitest/browser-preview": "4.1.1", "@vitest/browser-webdriverio": "4.1.1", "@vitest/ui": "4.1.1", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": "vitest.mjs" }, "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA=="],
"vue": ["vue@3.5.30", "", { "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/compiler-sfc": "3.5.30", "@vue/runtime-dom": "3.5.30", "@vue/server-renderer": "3.5.30", "@vue/shared": "3.5.30" }, "peerDependencies": { "typescript": "*" } }, "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg=="],
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": "cli.js" }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@vitest/mocker/vite": ["vite@8.0.2", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.11", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@vitejs/devtools", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "yaml"], "bin": "bin/vite.js" }, "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA=="],
"@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": "bin/esbuild" }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
"vitest/vite": ["vite@8.0.2", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.11", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@vitejs/devtools", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "yaml"], "bin": "bin/vite.js" }, "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA=="],
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
"vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
"vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
"vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
"vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
"vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
"vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
"vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
"vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
"vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
"vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
"vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
"vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
"vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
"vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
"vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
"vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
"vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
"vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
"vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
"vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
"vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
}
}
-82
View File
@@ -1,82 +0,0 @@
[CmdletBinding()]
param(
[Parameter(ValueFromRemainingArguments = $true)]
[string[]]$OpenCliArgs
)
$ErrorActionPreference = 'Stop'
$chatwiseExe = 'C:\Program Files\ChatWise\ChatWise.exe'
if (-not (Test-Path $chatwiseExe)) {
throw "ChatWise executable not found at $chatwiseExe"
}
$opencli = Get-Command opencli -ErrorAction SilentlyContinue
if (-not $opencli) {
throw 'opencli was not found in PATH'
}
function Clear-LocalProxyEnv {
$vars = 'http_proxy','https_proxy','HTTP_PROXY','HTTPS_PROXY'
foreach ($name in $vars) {
Set-Item -Path "Env:$name" -Value ''
}
$noProxy = '127.0.0.1,localhost'
Set-Item -Path 'Env:NO_PROXY' -Value $noProxy
Set-Item -Path 'Env:no_proxy' -Value $noProxy
}
function Stop-ChatWiseTree {
$candidates = Get-CimInstance Win32_Process |
Where-Object { $_.Name -match '^ChatWise\.exe$|^chatwise\.exe$' }
foreach ($proc in $candidates) {
try {
Stop-Process -Id $proc.ProcessId -Force -ErrorAction Stop
} catch {}
}
Start-Sleep -Seconds 2
}
function Wait-ChatWiseDebugPort {
param(
[int]$Port = 9228,
[int]$TimeoutSeconds = 20
)
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) {
try {
$resp = Invoke-WebRequest -UseBasicParsing -TimeoutSec 2 -Uri "http://127.0.0.1:$Port/json/version"
if ($resp.StatusCode -ge 200 -and $resp.StatusCode -lt 300) {
return
}
} catch {}
Start-Sleep -Milliseconds 500
}
throw "ChatWise debugging endpoint did not come up on 127.0.0.1:$Port"
}
Clear-LocalProxyEnv
Stop-ChatWiseTree
$proc = Start-Process -FilePath $chatwiseExe -ArgumentList '--remote-debugging-port=9228' -PassThru
Start-Sleep -Seconds 4
if ($proc.HasExited) {
throw "ChatWise exited early with code $($proc.ExitCode)"
}
Wait-ChatWiseDebugPort
$env:OPENCLI_CDP_ENDPOINT = 'http://127.0.0.1:9228'
if (-not $OpenCliArgs -or $OpenCliArgs.Count -eq 0) {
& $opencli.Source 'chatwise' 'status'
exit $LASTEXITCODE
}
& $opencli.Source @OpenCliArgs
exit $LASTEXITCODE
+8
View File
@@ -50,6 +50,7 @@ export default defineConfig({
items: [
{ text: 'Twitter / X', link: '/adapters/browser/twitter' },
{ text: 'Reddit', link: '/adapters/browser/reddit' },
{ text: 'Tieba', link: '/adapters/browser/tieba' },
{ text: 'Bilibili', link: '/adapters/browser/bilibili' },
{ text: 'Zhihu', link: '/adapters/browser/zhihu' },
{ text: 'Xiaohongshu', link: '/adapters/browser/xiaohongshu' },
@@ -68,8 +69,13 @@ export default defineConfig({
{ text: 'Jimeng', link: '/adapters/browser/jimeng' },
{ text: 'Yollomi', link: '/adapters/browser/yollomi' },
{ text: 'LINUX DO', link: '/adapters/browser/linux-do' },
{ text: 'Band', link: '/adapters/browser/band' },
{ text: 'Chaoxing', link: '/adapters/browser/chaoxing' },
{ text: 'Grok', link: '/adapters/browser/grok' },
{ text: 'Amazon', link: '/adapters/browser/amazon' },
{ text: 'Gemini', link: '/adapters/browser/gemini' },
{ text: 'Yuanbao', link: '/adapters/browser/yuanbao' },
{ text: 'NotebookLM', link: '/adapters/browser/notebooklm' },
{ text: 'WeRead', link: '/adapters/browser/weread' },
{ text: 'Douban', link: '/adapters/browser/douban' },
{ text: 'Sina Blog', link: '/adapters/browser/sinablog' },
@@ -86,6 +92,7 @@ export default defineConfig({
{ text: 'TikTok', link: '/adapters/browser/tiktok' },
{ text: 'Web (Generic)', link: '/adapters/browser/web' },
{ text: 'Weixin', link: '/adapters/browser/weixin' },
{ text: 'Xianyu', link: '/adapters/browser/xianyu' },
],
},
{
@@ -104,6 +111,7 @@ export default defineConfig({
{ text: 'Barchart', link: '/adapters/browser/barchart' },
{ text: 'Hugging Face', link: '/adapters/browser/hf' },
{ text: 'Sina Finance', link: '/adapters/browser/sinafinance' },
{ text: 'Spotify', link: '/adapters/browser/spotify' },
{ text: 'Stack Overflow', link: '/adapters/browser/stackoverflow' },
{ text: 'Wikipedia', link: '/adapters/browser/wikipedia' },
{ text: 'Lobsters', link: '/adapters/browser/lobsters' },
+32
View File
@@ -0,0 +1,32 @@
# ONES 项目管理平台(OpenCLI
基于官方 [ONES Project API](https://developer.ones.cn/zh-CN/docs/api/readme/),经 **Chrome + Browser Bridge** 在页面里 `fetch``credentials: 'include'`)。
## 环境变量
| 变量 | 必填 | 说明 |
|------|------|------|
| `ONES_BASE_URL` | 是 | 与 Chrome 中访问的 ONES 根 URL 一致 |
| `ONES_USER_ID` / `ONES_AUTH_TOKEN` | 视部署 | 若接口强制要文档中的 Header,再设置(可先只依赖浏览器登录) |
| `ONES_EMAIL` / `ONES_PHONE` / `ONES_PASSWORD` | 否 | 供 `ones login` 脚本化 |
## 命令
```bash
export ONES_BASE_URL=https://your-host
# 安装扩展,Chrome 已登录 ONES
opencli ones me
opencli ones token-info # teams column includes name(uuid), useful for tasks
opencli ones tasks <teamUUID> --limit 20 --project <optional>
opencli ones my-tasks <teamUUID> --limit 100 # default assignee=self
opencli ones my-tasks <teamUUID> --mode field004 # deployments using field004 as assignee
opencli ones my-tasks <teamUUID> --mode both # assignee OR creator
opencli ones task <taskUUID> --team <teamUUID> # single task (URL .../task/<uuid>)
opencli ones worklog <taskUUID> 2 --team <teamUUID> # log hours for today
opencli ones worklog <taskUUID> 1 --team <teamUUID> --date 2026-03-01 # backfill
opencli ones login --email you@corp.com --password '***' # optional; stderr prints header export hints
opencli ones logout
```
更完整的说明见 [docs/adapters/browser/ones.md](../adapters/browser/ones.md)。
+2 -1
View File
@@ -44,4 +44,5 @@ opencli 36kr hot -f json
## Prerequisites
- No browser required — uses public API
- `news`: No browser required — uses public RSS feed
- `hot`, `search`, `article`: Chrome running with [Browser Bridge extension](/guide/browser-bridge) installed
+53
View File
@@ -0,0 +1,53 @@
# Amazon
**Mode**: 🔐 Browser · **Domain**: `amazon.com`
## Commands
| Command | Description |
|---------|-------------|
| `opencli amazon bestsellers [<best-sellers-url>]` | Read Amazon Best Sellers pages for ranked candidate discovery |
| `opencli amazon search "<query>"` | Read Amazon search results for coarse filtering |
| `opencli amazon product <asin-or-url>` | Read a product page with title, price, rating, breadcrumbs, and bullets |
| `opencli amazon offer <asin-or-url>` | Read seller / fulfillment / buy-box facts from the product page |
| `opencli amazon discussion <asin-or-url>` | Read review summary and sample customer reviews |
## Usage Examples
```bash
# Root Best Sellers page
opencli amazon bestsellers https://www.amazon.com/Best-Sellers/zgbs --limit 10 -f json
# Category-specific Best Sellers page
opencli amazon bestsellers "<category-best-sellers-url>" --limit 50 -f json
# Search products
opencli amazon search "desk shelf organizer" --limit 20 -f json
# Validate one product
opencli amazon product B0FJS72893 -f json
# Validate seller / offer facts
opencli amazon offer B0FJS72893 -f json
# Read review summary + samples
opencli amazon discussion B0FJS72893 --limit 5 -f json
```
## Prerequisites
- Chrome running with an active `amazon.com` session in the shared profile
- [Browser Bridge extension](/guide/browser-bridge) installed
## Notes
- This adapter only returns fields visible on public Amazon pages.
- `bestsellers` and `search` are for candidate discovery; `product`, `offer`, and `discussion` are the validation surfaces.
- `offer` is the right surface for `sold_by`, `ships_from`, and Amazon-retail exclusion.
- `discussion` may return review data even when Q&A is absent. Missing Q&A is a normal outcome, not an error.
## Troubleshooting
- If Amazon shows a robot-check page, clear it in Chrome and retry.
- If CDP is attached to the wrong tab, retry with `OPENCLI_CDP_TARGET=amazon.com`.
- Avoid running multiple Amazon browser commands in parallel against the same shared Chrome target.
+63
View File
@@ -0,0 +1,63 @@
# Band
**Mode**: 🔐 Browser · **Domain**: `www.band.us`
Read posts, comments, and notifications from [Band](https://www.band.us), a private community platform. Authentication uses your logged-in Chrome session (cookie-based).
## Commands
| Command | Description |
|---------|-------------|
| `opencli band bands` | List all Bands you belong to |
| `opencli band posts <band_no>` | List posts from a Band |
| `opencli band post <band_no> <post_no>` | Export full post content including nested comments |
| `opencli band mentions` | Show notifications where you were @mentioned |
## Usage Examples
```bash
# List all your bands (get band_no from here)
opencli band bands
# List recent posts in a band
opencli band posts 12345678 --limit 10
# Export a post with comments
opencli band post 12345678 987654321
# Export post body only (skip comments)
opencli band post 12345678 987654321 --comments false
# Export post and download attached photos
opencli band post 12345678 987654321 --output ./band-photos
# Show recent @mention notifications
opencli band mentions --limit 20
# Show only unread mentions
opencli band mentions --unread true
# Show all notification types
opencli band mentions --filter all
```
### `band mentions` filter options
| Filter | Description |
|--------|-------------|
| `mentioned` | Only notifications where you were @mentioned (default) |
| `all` | All notifications |
| `post` | Post-related notifications |
| `comment` | Comment-related notifications |
## Prerequisites
- Chrome running and **logged into** [band.us](https://www.band.us)
- [Browser Bridge extension](/guide/browser-bridge) installed
## Notes
- `band_no` is the numeric ID in the Band URL: `band.us/band/{band_no}/post`
- `band bands` lists all your bands with their `band_no` values
- `band post` output rows: `type=post` (the post itself), `type=comment` (top-level comment), `type=reply` (nested reply)
- Photo downloads use the full-resolution URL (thumbnail query params are stripped automatically)
+5 -1
View File
@@ -11,12 +11,16 @@ Browser adapter for [Doubao Chat](https://www.doubao.com/chat).
| `opencli doubao send "..."` | Send a message to the current Doubao chat |
| `opencli doubao read` | Read the visible Doubao conversation |
| `opencli doubao ask "..."` | Send a prompt and wait for a reply |
| `opencli doubao detail <id>` | 对话详情 |
| `opencli doubao history` | 历史对话列表 |
| `opencli doubao meeting-summary <id>` | 会议总结 |
| `opencli doubao meeting-transcript <id>` | 会议记录 |
## Prerequisites
- Chrome is running
- You are already logged into [doubao.com](https://www.doubao.com/)
- Playwright MCP Bridge / browser bridge is configured for OpenCLI
- Browser Bridge extension is installed and enabled for OpenCLI
## Examples
+72
View File
@@ -0,0 +1,72 @@
# Gemini
**Mode**: 🔐 Browser · **Domain**: `gemini.google.com`
## Commands
| Command | Description |
|---------|-------------|
| `opencli gemini new` | Start a new Gemini web chat |
| `opencli gemini ask <prompt>` | Send a prompt and return only the assistant reply |
| `opencli gemini image <prompt>` | Generate images in Gemini and optionally save them locally |
## Usage Examples
```bash
# Start a fresh chat
opencli gemini new
# Ask Gemini and return minimal plain-text output
opencli gemini ask "Reply with exactly: HELLO"
# Ask in a new chat and wait longer
opencli gemini ask "Summarize this design in 3 bullets" --new true --timeout 90
# Generate an icon image with short flags
opencli gemini image "Generate a tiny cyan moon icon" --rt 1:1 --st icon
# Only generate in Gemini and print the page link without downloading files
opencli gemini image "A watercolor sunset over a lake" --sd true
# Save generated images to a custom directory
opencli gemini image "A flat illustration of a robot" --op ~/tmp/gemini-images
```
## Options
### `ask`
| Option | Description |
|--------|-------------|
| `prompt` | Prompt to send (required positional argument) |
| `--timeout` | Max seconds to wait for a reply (default: `60`) |
| `--new` | Start a new chat before sending (default: `false`) |
### `image`
| Option | Description |
|--------|-------------|
| `prompt` | Image prompt to send (required positional argument) |
| `--rt` | Aspect ratio shorthand: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `3:2`, `2:3` |
| `--st` | Optional style shorthand, e.g. `icon`, `anime`, `watercolor` |
| `--op` | Output directory for downloaded images (default: `~/tmp/gemini-images`) |
| `--sd` | Skip download and only print the Gemini page link |
## Behavior
- `ask` uses plain minimal output and returns only the assistant response text prefixed with `💬`.
- `image` also uses plain output and prints `status / file / link` instead of a table.
- `image` always starts from a fresh Gemini chat before sending the prompt.
- When `--sd` is enabled, `image` keeps the generation in Gemini and only prints the conversation link.
## Prerequisites
- Chrome is running
- You are already logged into `gemini.google.com`
- [Browser Bridge extension](/guide/browser-bridge) is installed
## Caveats
- This adapter drives the Gemini consumer web UI, not a public API.
- It depends on the current browser session and may fail if Gemini shows login, consent, challenge, quota, or other gating UI.
- DOM or product changes on Gemini can break composer detection, new-chat handling, or image export behavior.
+69
View File
@@ -0,0 +1,69 @@
# NotebookLM
**Mode**: 🔐 Browser Bridge · **Domain**: `notebooklm.google.com`
## Commands
| Command | Description |
|---------|-------------|
| `opencli notebooklm status` | Check whether NotebookLM is reachable in the current Chrome session |
| `opencli notebooklm list` | List notebooks visible from the NotebookLM home page |
| `opencli notebooklm open <notebook>` | Open one notebook in the NotebookLM automation workspace by id or URL |
| `opencli notebooklm current` | Show metadata for the currently opened notebook in the automation workspace |
| `opencli notebooklm get` | Get richer metadata for the current notebook |
| `opencli notebooklm source-list` | List sources in the current notebook |
| `opencli notebooklm source-get <source>` | Resolve one source in the current notebook by id or title |
| `opencli notebooklm source-fulltext <source>` | Fetch extracted source fulltext through NotebookLM RPC |
| `opencli notebooklm source-guide <source>` | Fetch guide summary and keywords for one source |
| `opencli notebooklm history` | List conversation history threads for the current notebook |
| `opencli notebooklm note-list` | List Studio notes visible in the current notebook |
| `opencli notebooklm notes-get <note>` | Read the currently visible Studio note by title |
| `opencli notebooklm summary` | Read the current notebook summary |
## Compatibility Aliases
| Alias | Canonical command |
|-------|-------------------|
| `opencli notebooklm select <notebook>` | `opencli notebooklm open <notebook>` |
| `opencli notebooklm metadata` | `opencli notebooklm get` |
| `opencli notebooklm notes-list` | `opencli notebooklm note-list` |
## Positioning
This adapter reuses the existing OpenCLI Browser Bridge runtime:
- no custom NotebookLM extension
- no exported cookie replay
- requests and page state stay in the real Chrome session
The current milestone focuses on a stable NotebookLM read surface in desktop Chrome with an already logged-in Google account.
## Usage Examples
```bash
opencli notebooklm status
opencli notebooklm list -f json
opencli notebooklm open nb-demo -f json
opencli notebooklm current -f json
opencli notebooklm metadata -f json
opencli notebooklm source-list -f json
opencli notebooklm source-get "Quarterly report" -f json
opencli notebooklm source-guide "Quarterly report" -f json
opencli notebooklm source-fulltext "Quarterly report" -f json
opencli notebooklm history -f json
opencli notebooklm notes-list -f json
opencli notebooklm notes-get "Draft note" -f json
opencli notebooklm summary -f json
```
## Prerequisites
- Chrome running and logged into Google / NotebookLM
- [Browser Bridge extension](/guide/browser-bridge) installed
- NotebookLM accessible in the current browser session
## Notes
- Notebook-oriented commands run in OpenCLI's owned NotebookLM automation workspace/window. Use `opencli notebooklm open <notebook>` first to choose the current notebook for follow-up commands.
- `list`, `get`, `source-list`, `history`, `source-fulltext`, and `source-guide` prefer NotebookLM RPC paths and fall back only when the richer path is unavailable.
- `notes-get` currently reads note content only from the visible Studio note editor; if the note is listed but not open, open it in NotebookLM first and then retry.
+59
View File
@@ -0,0 +1,59 @@
# ONES
**Mode**: 🔐 Browser Bridge · **Domain**: `ones.cn` (self-hosted via `ONES_BASE_URL`)
## Commands
| Command | Description |
|---------|-------------|
| `opencli ones login` | Login via Project API (`auth/login`) |
| `opencli ones me` | Current user profile (`users/me`) |
| `opencli ones token-info` | Token/user/team summary (`auth/token_info`) |
| `opencli ones tasks` | Team task list with status/project labels and hours |
| `opencli ones my-tasks` | My tasks (`assign`/`field004`/`owner`/`both`) |
| `opencli ones task` | Task detail by UUID (`team/:team/task/:id/info`) |
| `opencli ones worklog` | Log/backfill hours (GraphQL `addManhour` first, then REST fallbacks) |
| `opencli ones logout` | Logout (`auth/logout`) |
## Usage Examples
```bash
# Required: your ONES base URL
export ONES_BASE_URL=https://your-instance.example.com
# Optional if your deployment requires auth headers
# export ONES_USER_ID=...
# export ONES_AUTH_TOKEN=...
# Login/profile
opencli ones login --email you@company.com --password 'your-password'
opencli ones me
opencli ones token-info
# Task lists
opencli ones tasks <teamUUID> --limit 20
opencli ones tasks <teamUUID> --project <projectUUID> --assign <userUUID>
opencli ones my-tasks <teamUUID> --limit 100
opencli ones my-tasks <teamUUID> --mode both
# Task detail
opencli ones task <taskUUID> --team <teamUUID>
# Worklog: today / backfill
opencli ones worklog <taskUUID> 2 --team <teamUUID>
opencli ones worklog <taskUUID> 1.5 --team <teamUUID> --date 2026-03-23 --note "integration"
opencli ones logout
```
## Prerequisites
- Chrome running and logged into your ONES instance
- [Browser Bridge extension](/guide/browser-bridge) installed
- `ONES_BASE_URL` set to the same origin opened in Chrome
## Notes
- This adapter targets legacy ONES Project API deployments.
- `ONES_TEAM_UUID` can be set to omit `--team` in `tasks` / `my-tasks` / `task`.
- Hours display and input use `ONES_MANHOUR_SCALE` (default `100000`).
+62
View File
@@ -0,0 +1,62 @@
# Spotify
**Mode**: 🔑 OAuth API · **Domains**: `accounts.spotify.com`, `api.spotify.com`
## Commands
| Command | Description |
|---------|-------------|
| `opencli spotify auth` | Authenticate with Spotify and store tokens locally |
| `opencli spotify status` | Show current playback status |
| `opencli spotify play [query]` | Resume playback or search-and-play a track |
| `opencli spotify pause` | Pause playback |
| `opencli spotify next` | Skip to the next track |
| `opencli spotify prev` | Skip to the previous track |
| `opencli spotify volume <0-100>` | Set playback volume |
| `opencli spotify search <query>` | Search Spotify tracks |
| `opencli spotify queue <query>` | Add a track to the playback queue |
| `opencli spotify shuffle <on|off>` | Toggle shuffle |
| `opencli spotify repeat <off|track|context>` | Set repeat mode |
## Usage Examples
```bash
# First-time setup
opencli spotify auth
# What is playing right now?
opencli spotify status
# Resume playback
opencli spotify play
# Search and immediately play a track
opencli spotify play "Numb Linkin Park"
# Search without playing
opencli spotify search "Daft Punk" --limit 5 -f json
# Queue a track
opencli spotify queue "Get Lucky"
# Playback controls
opencli spotify pause
opencli spotify next
opencli spotify prev
opencli spotify volume 35
opencli spotify shuffle on
opencli spotify repeat track
```
## Setup
1. Create a Spotify app at <https://developer.spotify.com/dashboard>
2. Add `http://127.0.0.1:8888/callback` to the app's Redirect URIs
3. Fill in `SPOTIFY_CLIENT_ID` and `SPOTIFY_CLIENT_SECRET` in `~/.opencli/spotify.env`
4. Run `opencli spotify auth`
## Notes
- Browser Bridge is not required.
- Tokens are stored locally at `~/.opencli/spotify-tokens.json`.
- Playback commands work best when you already have an active Spotify device/session.
+45
View File
@@ -0,0 +1,45 @@
# Tieba
**Mode**: 🔐 Browser · **Domain**: `tieba.baidu.com`
## Commands
| Command | Description |
|---------|-------------|
| `opencli tieba hot` | Read Tieba trending topics |
| `opencli tieba posts <forum>` | List threads in one forum |
| `opencli tieba search <keyword>` | Search threads across Tieba |
| `opencli tieba read <thread-id>` | Read one thread page |
## Usage Examples
```bash
# Trending topics
opencli tieba hot --limit 5
# List forum threads
opencli tieba posts 李毅 --limit 10
# Search Tieba
opencli tieba search 编程 --limit 10
# Read one thread
opencli tieba read 10163164720 --limit 10
# Read page 2 of a thread
opencli tieba read 10163164720 --page 2 --limit 10
# JSON output
opencli tieba hot -f json
```
## Notes
- `tieba search` currently supports only `--page 1`
- `tieba read --limit` counts reply rows; page 1 may also include the main post
## Prerequisites
- Chrome running and able to open `tieba.baidu.com`
- [Browser Bridge extension](/guide/browser-bridge) installed
- For `posts`, `search`, and `read`, a valid Tieba login session in Chrome is recommended
+6 -1
View File
@@ -6,8 +6,13 @@
| Command | Description |
|---------|-------------|
| `opencli weibo hot` | |
| `opencli weibo hot` | 微博热搜 |
| `opencli weibo search` | Search Weibo posts by keyword |
| `opencli weibo feed` | 首页时间线 |
| `opencli weibo user` | 用户信息 |
| `opencli weibo me` | 我的信息 |
| `opencli weibo post` | 发微博 |
| `opencli weibo comments` | 微博评论 |
## Usage Examples
+2
View File
@@ -8,6 +8,8 @@
|---------|-------------|
| `opencli wikipedia search` | Search Wikipedia articles |
| `opencli wikipedia summary` | Get Wikipedia article summary |
| `opencli wikipedia random` | Random Wikipedia article |
| `opencli wikipedia trending` | Trending Wikipedia articles |
## Usage Examples
+42
View File
@@ -0,0 +1,42 @@
# Xianyu (闲鱼)
**Mode**: 🔐 Browser · **Domain**: `goofish.com`
## Commands
| Command | Description |
|---------|-------------|
| `opencli xianyu search <query>` | Search Xianyu items by keyword and return item cards with `item_id` |
| `opencli xianyu item <item_id>` | Fetch item details including title, price, condition, brand, seller, and image URLs |
| `opencli xianyu chat <item_id> <user_id>` | Open a Xianyu chat session for the item/user pair and optionally send a message with `--text` |
## Usage Examples
```bash
# Search items
opencli xianyu search "macbook" --limit 5
# Read a single item's details
opencli xianyu item 1040754408976
# Open a chat session
opencli xianyu chat 1038951278192 3650092411
# Send a message in chat
opencli xianyu chat 1038951278192 3650092411 --text "你好,这个还在吗?"
# JSON output
opencli xianyu search "笔记本电脑" -f json
opencli xianyu item 1040754408976 -f json
```
## Prerequisites
- Chrome running and **logged into** `goofish.com`
- [Browser Bridge extension](/guide/browser-bridge) installed
## Notes
- `search` returns `item_id`, which can be passed directly into `opencli xianyu item`
- `chat` requires both the item ID and the target user's `user_id` / `peerUserId`
- Browser-authenticated commands depend on the active Chrome login session remaining valid
+19 -10
View File
@@ -7,15 +7,18 @@
| Command | Description |
|---------|-------------|
| `opencli xiaohongshu search` | Search notes by keyword (returns title, author, likes, URL) |
| `opencli xiaohongshu notifications` | |
| `opencli xiaohongshu feed` | |
| `opencli xiaohongshu user` | |
| `opencli xiaohongshu download` | |
| `opencli xiaohongshu creator-notes` | |
| `opencli xiaohongshu creator-note-detail` | |
| `opencli xiaohongshu creator-notes-summary` | |
| `opencli xiaohongshu creator-profile` | |
| `opencli xiaohongshu creator-stats` | |
| `opencli xiaohongshu note` | Read full note content (title, author, description, likes, collects, comments, tags) |
| `opencli xiaohongshu comments` | Read comments from a note (`--with-replies` for nested 楼中楼 replies) |
| `opencli xiaohongshu feed` | Home feed recommendations (via Pinia store interception) |
| `opencli xiaohongshu notifications` | User notifications (mentions, likes, connections) |
| `opencli xiaohongshu user` | Get public notes from a user profile |
| `opencli xiaohongshu download` | Download images and videos from a note |
| `opencli xiaohongshu publish` | Publish image-text notes (creator center UI automation) |
| `opencli xiaohongshu creator-notes` | Creator's note list with per-note metrics |
| `opencli xiaohongshu creator-note-detail` | Detailed analytics for a single creator note |
| `opencli xiaohongshu creator-notes-summary` | Combined note list + detail analytics summary |
| `opencli xiaohongshu creator-profile` | Creator account info (followers, growth level) |
| `opencli xiaohongshu creator-stats` | Creator data overview (views, likes, collects, trends) |
## Usage Examples
@@ -23,13 +26,19 @@
# Search for notes
opencli xiaohongshu search 美食 --limit 10
# Read a note's full content (pass URL from search results to preserve xsec_token)
opencli xiaohongshu note "https://www.xiaohongshu.com/search_result/<id>?xsec_token=..."
# Read comments with nested replies (楼中楼)
opencli xiaohongshu comments "https://www.xiaohongshu.com/search_result/<id>?xsec_token=..." --with-replies --limit 20
# JSON output
opencli xiaohongshu search 旅行 -f json
# Other commands
opencli xiaohongshu feed
opencli xiaohongshu notifications
opencli xiaohongshu download <url>
opencli xiaohongshu download <note-id or url>
```
## Prerequisites
+5
View File
@@ -12,6 +12,7 @@
| `opencli xueqiu hot` | 获取雪球热门动态 |
| `opencli xueqiu search` | 搜索雪球股票(代码或名称) |
| `opencli xueqiu stock` | 获取雪球股票实时行情 |
| `opencli xueqiu comments` | 获取单只股票的讨论动态(按时间排序) |
| `opencli xueqiu watchlist` | 获取雪球自选股列表 |
| `opencli xueqiu fund-holdings` | 获取蛋卷基金持仓明细(可用 `--account` 按子账户过滤) |
| `opencli xueqiu fund-snapshot` | 获取蛋卷基金快照(总资产、子账户、持仓,推荐 `-f json` |
@@ -28,6 +29,9 @@ opencli xueqiu search 茅台
# View one stock
opencli xueqiu stock SH600519
# View recent discussions for one stock
opencli xueqiu comments SH600519 --limit 5
# Upcoming earnings dates
opencli xueqiu earnings-date SH600519 --next
@@ -57,4 +61,5 @@ opencli xueqiu feed -v
- `fund-holdings` exposes both market value and share fields (`volume`, `usableRemainShare`)
- `fund-snapshot -f json` is the easiest way to persist a full account snapshot for later analysis or diffing
- `comments` returns stock-scoped discussion posts from the symbol page, not reply threads under one parent post
- If the commands return empty data, first confirm the logged-in browser can directly see the Danjuan asset page
+64
View File
@@ -0,0 +1,64 @@
# Yuanbao
**Mode**: 🔐 Browser · **Domain**: `yuanbao.tencent.com`
## Commands
| Command | Description |
|---------|-------------|
| `opencli yuanbao new` | Start a new Yuanbao conversation |
| `opencli yuanbao ask <prompt>` | Send a prompt to Yuanbao web chat and wait for the reply |
## Usage Examples
```bash
# Start a fresh chat
opencli yuanbao new
# Basic ask (internet search on by default, deep thinking off by default)
opencli yuanbao ask "你好"
# Wait longer for a longer answer
opencli yuanbao ask "帮我总结这篇文章" --timeout 90
# Disable internet search explicitly
opencli yuanbao ask "你好" --search false
# Enable deep thinking explicitly
opencli yuanbao ask "你好" --think true
```
## Options
### `new`
- No options
### `ask`
| Option | Description |
|--------|-------------|
| `prompt` | Prompt to send (required positional argument) |
| `--timeout` | Max seconds to wait for a reply (default: `60`) |
| `--search` | Enable internet search before sending (default: `true`) |
| `--think` | Enable deep thinking before sending (default: `false`) |
## Behavior
- The adapter targets the Yuanbao consumer web UI and sends the prompt through the visible Quill composer.
- `new` clicks the left-side Yuanbao new-chat trigger and falls back to reloading the Yuanbao homepage if needed.
- Before sending, it aligns the `联网搜索` and `深度思考` buttons to the requested `--search` / `--think` state.
- It waits for transcript changes to stabilize before returning the assistant reply.
- If Yuanbao opens a login gate instead of answering, the command returns a `[BLOCKED]` system message with a session hint.
## Prerequisites
- Chrome is running
- You are already logged into `yuanbao.tencent.com`
- [Browser Bridge extension](/guide/browser-bridge) is installed
## Caveats
- This adapter drives the Yuanbao web UI, not a public API.
- It depends on the current browser session and may fail if Yuanbao shows login, consent, challenge, or other gating UI.
- DOM or product changes on Yuanbao can break composer detection, submit behavior, or transcript extraction.
+49
View File
@@ -0,0 +1,49 @@
# 知识星球 (ZSXQ)
**Mode**: 🔐 Browser · **Domain**: `wx.zsxq.com`
Read groups, topics, search results, dynamics, and single-topic details from [知识星球](https://wx.zsxq.com) using your logged-in Chrome session.
## Commands
| Command | Description |
|---------|-------------|
| `opencli zsxq groups` | List the groups your account has joined |
| `opencli zsxq topics` | List topics in the active group |
| `opencli zsxq topic <id>` | Fetch a single topic with comments |
| `opencli zsxq search <keyword>` | Search topics inside a group |
| `opencli zsxq dynamics` | List recent dynamics across groups |
## Usage Examples
```bash
# List your groups
opencli zsxq groups
# List topics from the active group in Chrome
opencli zsxq topics --limit 20
# Search inside the active group
opencli zsxq search "opencli"
# Search inside a specific group explicitly
opencli zsxq search "opencli" --group_id 123456789
# Export a single topic with comments
opencli zsxq topic 987654321 --comment_limit 20
# Read recent dynamics across all joined groups
opencli zsxq dynamics --limit 20
```
## Prerequisites
- Chrome running and **logged into** [wx.zsxq.com](https://wx.zsxq.com)
- [Browser Bridge extension](/guide/browser-bridge) installed
## Notes
- `zsxq topics` and `zsxq search` use the current active group context from Chrome by default
- If there is no active group context, pass `--group_id <id>` or open the target group in Chrome first
- `zsxq groups` returns `group_id`, which you can reuse with `--group_id`
- `zsxq topic` surfaces a missing topic as `NOT_FOUND` instead of a generic fetch error
+75 -63
View File
@@ -6,76 +6,88 @@ Run `opencli list` for the live registry.
| Site | Commands | Mode |
|------|----------|------|
| **[twitter](/adapters/browser/twitter)** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | 🔐 Browser |
| **[reddit](/adapters/browser/reddit)** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 🔐 Browser |
| **[bilibili](/adapters/browser/bilibili)** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | 🔐 Browser |
| **[zhihu](/adapters/browser/zhihu)** | `hot` `search` `question` `download` | 🔐 Browser |
| **[xiaohongshu](/adapters/browser/xiaohongshu)** | `search` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 🔐 Browser |
| **[xueqiu](/adapters/browser/xueqiu)** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` `earnings-date` `fund-holdings` `fund-snapshot` | 🔐 Browser |
| **[youtube](/adapters/browser/youtube)** | `search` `video` `transcript` | 🔐 Browser |
| **[v2ex](/adapters/browser/v2ex)** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | 🌐 / 🔐 |
| **[bloomberg](/adapters/browser/bloomberg)** | `main` `markets` `economics` `industries` `tech` `politics` `businessweek` `opinions` `feeds` `news` | 🌐 / 🔐 |
| **[weibo](/adapters/browser/weibo)** | `hot` `search` | 🔐 Browser |
| **[linkedin](/adapters/browser/linkedin)** | `search` `timeline` | 🔐 Browser |
| **[coupang](/adapters/browser/coupang)** | `search` `add-to-cart` | 🔐 Browser |
| **[boss](/adapters/browser/boss)** | `search` `detail` `recommend` `joblist` `greet` `batchgreet` `send` `chatlist` `chatmsg` `invite` `mark` `exchange` `resume` `stats` | 🔐 Browser |
| **[ctrip](/adapters/browser/ctrip)** | `search` | 🔐 Browser |
| **[reuters](/adapters/browser/reuters)** | `search` | 🔐 Browser |
| **[smzdm](/adapters/browser/smzdm)** | `search` | 🔐 Browser |
| **[jike](/adapters/browser/jike)** | `feed` `search` `post` `topic` `user` `create` `comment` `like` `repost` `notifications` | 🔐 Browser |
| **[jimeng](/adapters/browser/jimeng)** | `generate` `history` | 🔐 Browser |
| **[yollomi](/adapters/browser/yollomi)** | `generate` `video` `edit` `upload` `models` `remove-bg` `upscale` `face-swap` `restore` `try-on` `background` `object-remover` | 🔐 Browser |
| **[linux-do](/adapters/browser/linux-do)** | `feed` `categories` `tags` `search` `topic` `user-topics` `user-posts` | 🔐 Browser |
| **[chaoxing](/adapters/browser/chaoxing)** | `assignments` `exams` | 🔐 Browser |
| **[grok](/adapters/browser/grok)** | `ask` | 🔐 Browser |
| **[doubao](/adapters/browser/doubao)** | `status` `new` `send` `read` `ask` | 🔐 Browser |
| **[weread](/adapters/browser/weread)** | `shelf` `search` `book` `ranking` `notebooks` `highlights` `notes` | 🔐 Browser |
| **[douban](/adapters/browser/douban)** | `search` `top250` `subject` `photos` `download` `marks` `reviews` `movie-hot` `book-hot` | 🔐 Browser |
| **[facebook](/adapters/browser/facebook)** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | 🔐 Browser |
| **[imdb](/adapters/browser/imdb)** | `search` `title` `top` `trending` `person` `reviews` | 🌐 / 🔐 |
| **[instagram](/adapters/browser/instagram)** | `explore` `profile` `search` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `saved` | 🔐 Browser |
| **[medium](/adapters/browser/medium)** | `feed` `search` `user` | 🔐 Browser |
| **[sinablog](/adapters/browser/sinablog)** | `hot` `search` `article` `user` | 🔐 Browser |
| **[substack](/adapters/browser/substack)** | `feed` `search` `publication` | 🔐 Browser |
| **[pixiv](/adapters/browser/pixiv)** | `ranking` `search` `user` `illusts` `detail` `download` | 🔐 Browser |
| **[tiktok](/adapters/browser/tiktok)** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | 🔐 Browser |
| **[google](/adapters/browser/google)** | `news` `search` `suggest` `trends` | 🌐 / 🔐 |
| **[jd](/adapters/browser/jd)** | `item` | 🔐 Browser |
| **[web](/adapters/browser/web)** | `read` | 🔐 Browser |
| **[weixin](/adapters/browser/weixin)** | `download` | 🔐 Browser |
| **[36kr](/adapters/browser/36kr)** | `news` `hot` `search` `article` | 🌐 / 🔐 |
| **[producthunt](/adapters/browser/producthunt)** | `posts` `today` `hot` `browse` | 🌐 / 🔐 |
| **[twitter](./browser/twitter)** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `likes` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | 🔐 Browser |
| **[reddit](./browser/reddit)** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 🔐 Browser |
| **[tieba](./browser/tieba)** | `hot` `posts` `search` `read` | 🔐 Browser |
| **[bilibili](./browser/bilibili)** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | 🔐 Browser |
| **[zhihu](./browser/zhihu)** | `hot` `search` `question` `download` | 🔐 Browser |
| **[xiaohongshu](./browser/xiaohongshu)** | `search` `notifications` `feed` `user` `note` `comments` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 🔐 Browser |
| **[xueqiu](./browser/xueqiu)** | `feed` `hot-stock` `hot` `search` `stock` `comments` `watchlist` `earnings-date` `fund-holdings` `fund-snapshot` | 🔐 Browser |
| **[youtube](./browser/youtube)** | `search` `video` `transcript` | 🔐 Browser |
| **[v2ex](./browser/v2ex)** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | 🌐 / 🔐 |
| **[bloomberg](./browser/bloomberg)** | `main` `markets` `economics` `industries` `tech` `politics` `businessweek` `opinions` `feeds` `news` | 🌐 / 🔐 |
| **[weibo](./browser/weibo)** | `hot` `search` `feed` `user` `me` `post` `comments` | 🔐 Browser |
| **[linkedin](./browser/linkedin)** | `search` `timeline` | 🔐 Browser |
| **[coupang](./browser/coupang)** | `search` `add-to-cart` | 🔐 Browser |
| **[boss](./browser/boss)** | `search` `detail` `recommend` `joblist` `greet` `batchgreet` `send` `chatlist` `chatmsg` `invite` `mark` `exchange` `resume` `stats` | 🔐 Browser |
| **[ctrip](./browser/ctrip)** | `search` | 🔐 Browser |
| **[reuters](./browser/reuters)** | `search` | 🔐 Browser |
| **[smzdm](./browser/smzdm)** | `search` | 🔐 Browser |
| **[jike](./browser/jike)** | `feed` `search` `post` `topic` `user` `create` `comment` `like` `repost` `notifications` | 🔐 Browser |
| **[jimeng](./browser/jimeng)** | `generate` `history` | 🔐 Browser |
| **[yollomi](./browser/yollomi)** | `generate` `video` `edit` `upload` `models` `remove-bg` `upscale` `face-swap` `restore` `try-on` `background` `object-remover` | 🔐 Browser |
| **[linux-do](./browser/linux-do)** | `hot` `latest` `feed` `search` `categories` `category` `tags` `topic` `user-posts` `user-topics` | 🔐 Browser |
| **[chaoxing](./browser/chaoxing)** | `assignments` `exams` | 🔐 Browser |
| **[grok](./browser/grok)** | `ask` | 🔐 Browser |
| **[gemini](./browser/gemini)** | `new` `ask` `image` | 🔐 Browser |
| **[yuanbao](./browser/yuanbao)** | `new` `ask` | 🔐 Browser |
| **[notebooklm](./browser/notebooklm)** | `status` `list` `open` `select` `current` `get` `metadata` `source-list` `source-get` `source-fulltext` `source-guide` `history` `note-list` `notes-list` `notes-get` `summary` | 🔐 Browser |
| **[doubao](./browser/doubao)** | `status` `new` `send` `read` `ask` `history` `detail` `meeting-summary` `meeting-transcript` | 🔐 Browser |
| **[weread](./browser/weread)** | `shelf` `search` `book` `ranking` `notebooks` `highlights` `notes` | 🔐 Browser |
| **[douban](./browser/douban)** | `search` `top250` `subject` `photos` `download` `marks` `reviews` `movie-hot` `book-hot` | 🔐 Browser |
| **[facebook](./browser/facebook)** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | 🔐 Browser |
| **[imdb](./browser/imdb)** | `search` `title` `top` `trending` `person` `reviews` | 🌐 / 🔐 |
| **[instagram](./browser/instagram)** | `explore` `profile` `search` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `saved` | 🔐 Browser |
| **[medium](./browser/medium)** | `feed` `search` `user` | 🔐 Browser |
| **[sinablog](./browser/sinablog)** | `hot` `search` `article` `user` | 🔐 Browser |
| **[substack](./browser/substack)** | `feed` `search` `publication` | 🔐 Browser |
| **[pixiv](./browser/pixiv)** | `ranking` `search` `user` `illusts` `detail` `download` | 🔐 Browser |
| **[tiktok](./browser/tiktok)** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | 🔐 Browser |
| **[google](./browser/google)** | `news` `search` `suggest` `trends` | 🌐 / 🔐 |
| **[jd](./browser/jd)** | `item` | 🔐 Browser |
| **[amazon](./browser/amazon)** | `bestsellers` `search` `product` `offer` `discussion` | 🔐 Browser |
| **[web](./browser/web)** | `read` | 🔐 Browser |
| **[weixin](./browser/weixin)** | `download` | 🔐 Browser |
| **[36kr](./browser/36kr)** | `news` `hot` `search` `article` | 🌐 / 🔐 |
| **[producthunt](./browser/producthunt)** | `posts` `today` `hot` `browse` | 🌐 / 🔐 |
| **[ones](./browser/ones)** | `login` `me` `token-info` `tasks` `my-tasks` `task` `worklog` `logout` | 🔐 Browser Bridge + `ONES_BASE_URL` |
| **[band](./browser/band)** | `bands` `posts` `post` `mentions` | 🔐 Browser |
| **[zsxq](./browser/zsxq)** | `groups` `dynamics` `topics` `topic` `search` | 🔐 Browser |
| **[bluesky](./browser/bluesky)** | `search` `profile` `user` `feeds` `followers` `following` `thread` `trending` `starter-packs` | 🌐 Public |
| **[douyin](./browser/douyin)** | `profile` `videos` `user-videos` `activities` `collections` `hashtag` `location` `stats` `publish` `draft` `drafts` `delete` `update` | 🔐 Browser |
| **[xianyu](./browser/xianyu)** | `search` `item` `chat` | 🔐 Browser |
## Public API Adapters
| Site | Commands | Mode |
|------|----------|------|
| **[hackernews](/adapters/browser/hackernews)** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | 🌐 Public |
| **[bbc](/adapters/browser/bbc)** | `news` | 🌐 Public |
| **[devto](/adapters/browser/devto)** | `top` `tag` `user` | 🌐 Public |
| **[dictionary](/adapters/browser/dictionary)** | `search` `synonyms` `examples` | 🌐 Public |
| **[apple-podcasts](/adapters/browser/apple-podcasts)** | `search` `episodes` `top` | 🌐 Public |
| **[xiaoyuzhou](/adapters/browser/xiaoyuzhou)** | `podcast` `podcast-episodes` `episode` | 🌐 Public |
| **[yahoo-finance](/adapters/browser/yahoo-finance)** | `quote` | 🌐 Public |
| **[arxiv](/adapters/browser/arxiv)** | `search` `paper` | 🌐 Public |
| **[paperreview](/adapters/browser/paperreview)** | `submit` `review` `feedback` | 🌐 Public |
| **[barchart](/adapters/browser/barchart)** | `quote` `options` `greeks` `flow` | 🌐 Public |
| **[hf](/adapters/browser/hf)** | `top` | 🌐 Public |
| **[sinafinance](/adapters/browser/sinafinance)** | `news` | 🌐 Public |
| **[stackoverflow](/adapters/browser/stackoverflow)** | `hot` `search` `bounties` `unanswered` | 🌐 Public |
| **[wikipedia](/adapters/browser/wikipedia)** | `search` `summary` `random` `trending` | 🌐 Public |
| **[lobsters](/adapters/browser/lobsters)** | `hot` `newest` `active` `tag` | 🌐 Public |
| **[steam](/adapters/browser/steam)** | `top-sellers` | 🌐 Public |
| **[hackernews](./browser/hackernews)** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | 🌐 Public |
| **[bbc](./browser/bbc)** | `news` | 🌐 Public |
| **[devto](./browser/devto)** | `top` `tag` `user` | 🌐 Public |
| **[dictionary](./browser/dictionary)** | `search` `synonyms` `examples` | 🌐 Public |
| **[apple-podcasts](./browser/apple-podcasts)** | `search` `episodes` `top` | 🌐 Public |
| **[xiaoyuzhou](./browser/xiaoyuzhou)** | `podcast` `podcast-episodes` `episode` | 🌐 Public |
| **[yahoo-finance](./browser/yahoo-finance)** | `quote` | 🌐 Public |
| **[arxiv](./browser/arxiv)** | `search` `paper` | 🌐 Public |
| **[paperreview](./browser/paperreview)** | `submit` `review` `feedback` | 🌐 Public |
| **[barchart](./browser/barchart)** | `quote` `options` `greeks` `flow` | 🌐 Public |
| **[hf](./browser/hf)** | `top` | 🌐 Public |
| **[sinafinance](./browser/sinafinance)** | `news` | 🌐 Public |
| **[spotify](./browser/spotify)** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` | 🔑 OAuth API |
| **[stackoverflow](./browser/stackoverflow)** | `hot` `search` `bounties` `unanswered` | 🌐 Public |
| **[wikipedia](./browser/wikipedia)** | `search` `summary` `random` `trending` | 🌐 Public |
| **[lobsters](./browser/lobsters)** | `hot` `newest` `active` `tag` | 🌐 Public |
| **[steam](./browser/steam)** | `top-sellers` | 🌐 Public |
## Desktop Adapters
| App | Description | Commands |
|-----|-------------|----------|
| **[Cursor](/adapters/desktop/cursor)** | Control Cursor IDE | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` |
| **[Codex](/adapters/desktop/codex)** | Drive OpenAI Codex CLI agent | `status` `send` `read` `new` `extract-diff` `model` `ask` `screenshot` `history` `export` |
| **[Antigravity](/adapters/desktop/antigravity)** | Control Antigravity Ultra | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` |
| **[ChatGPT](/adapters/desktop/chatgpt)** | Automate ChatGPT macOS app | `status` `new` `send` `read` `ask` |
| **[ChatWise](/adapters/desktop/chatwise)** | Multi-LLM client | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` |
| **[Notion](/adapters/desktop/notion)** | Search, read, write pages | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` |
| **[Discord](/adapters/desktop/discord)** | Desktop messages & channels | `status` `send` `read` `channels` `servers` `search` `members` |
| **[Doubao App](/adapters/desktop/doubao-app)** | Doubao AI desktop app via CDP | `status` `new` `send` `read` `ask` `screenshot` `dump` |
| **[Cursor](./desktop/cursor)** | Control Cursor IDE | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` |
| **[Codex](./desktop/codex)** | Drive OpenAI Codex CLI agent | `status` `send` `read` `new` `extract-diff` `model` `ask` `screenshot` `history` `export` |
| **[Antigravity](./desktop/antigravity)** | Control Antigravity Ultra | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` |
| **[ChatGPT](./desktop/chatgpt)** | Automate ChatGPT macOS app | `status` `new` `send` `read` `ask` `model` |
| **[ChatWise](./desktop/chatwise)** | Multi-LLM client | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` |
| **[Notion](./desktop/notion)** | Search, read, write pages | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` |
| **[Discord](./desktop/discord)** | Desktop messages & channels | `status` `send` `read` `channels` `servers` `search` `members` |
| **[Doubao App](./desktop/doubao-app)** | Doubao AI desktop app via CDP | `status` `new` `send` `read` `ask` `screenshot` `dump` |
+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** — 50+ 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** — 73+ sites 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 `.yaml` or `.ts` adapter into the `clis/` folder for auto-registration. Contributing a new site adapter is straightforward.
+1 -1
View File
@@ -86,7 +86,7 @@ cli({
domain: 'www.mysite.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'query', required: true, help: 'Search query' },
{ name: 'query', positional: true, required: true, help: 'Search query' },
{ name: 'limit', type: 'int', default: 10, help: 'Max results' },
],
columns: ['title', 'url', 'date'],
+12
View File
@@ -35,3 +35,15 @@ opencli doctor # Check extension + daemon connectivity
```
The daemon manages the WebSocket connection between your CLI commands and the Chrome extension. The extension executes JavaScript in the context of web pages, with access to the logged-in session.
## Daemon Lifecycle
The daemon auto-starts on first browser command and stays alive for **4 hours** by default. It exits only when both conditions are met: no CLI requests for the timeout period AND no Chrome extension connected.
```bash
opencli daemon status # Check daemon state (PID, uptime, extension, memory)
opencli daemon stop # Graceful shutdown
opencli daemon restart # Stop + restart
```
Override the timeout via the `OPENCLI_DAEMON_TIMEOUT` environment variable (milliseconds). Set to `0` to keep the daemon alive indefinitely.
+21
View File
@@ -48,6 +48,27 @@ opencli bilibili hot -f csv # CSV
opencli bilibili hot -v # Verbose: show pipeline debug
```
### Tab Completion
OpenCLI supports intelligent tab completion to speed up command input:
```bash
# Add shell completion to your startup config
echo 'eval "$(opencli completion zsh)"' >> ~/.zshrc # Zsh
echo 'eval "$(opencli completion bash)"' >> ~/.bashrc # Bash
echo 'opencli completion fish | source' >> ~/.config/fish/config.fish # Fish
# Restart your shell, then press Tab to complete:
opencli [Tab] # Complete site names (bilibili, zhihu, twitter...)
opencli bilibili [Tab] # Complete commands (hot, search, me, download...)
```
The completion includes:
- All available sites and adapters
- Built-in commands (list, explore, validate...)
- Command aliases
- Real-time updates as you add new adapters
## Next Steps
- [Installation details](/guide/installation)
+1
View File
@@ -243,6 +243,7 @@ On startup, if both `my-command.ts` and `my-command.js` exist, the `.js` version
| [opencli-plugin-github-trending](https://github.com/ByteYue/opencli-plugin-github-trending) | YAML | GitHub Trending repositories |
| [opencli-plugin-hot-digest](https://github.com/ByteYue/opencli-plugin-hot-digest) | TS | Multi-platform trending aggregator (zhihu, weibo, bilibili, v2ex, stackoverflow, reddit, linux-do) |
| [opencli-plugin-juejin](https://github.com/Astro-Han/opencli-plugin-juejin) | YAML | 稀土掘金 (Juejin) hot articles, categories, and article feed |
| [opencli-plugin-rubysec](https://github.com/nullptrKey/opencli-plugin-rubysec) | TS | RubySec advisory archive and advisory article reader |
## Troubleshooting
+9 -4
View File
@@ -20,17 +20,22 @@
### Daemon issues
```bash
# Check daemon status
curl localhost:19825/status
# Check daemon status (PID, uptime, extension connection, memory)
opencli daemon status
# View extension logs
curl localhost:19825/logs
# Kill and restart daemon
pkill -f opencli-daemon
# Stop or restart the daemon
opencli daemon stop
opencli daemon restart
# Full diagnostics
opencli doctor
```
> The daemon auto-exits after 4 hours of inactivity (no CLI requests and no extension connection). Override with `OPENCLI_DAEMON_TIMEOUT` (milliseconds, `0` = never timeout).
### Desktop adapter connection issues
For Electron/CDP-based adapters (Cursor, Codex, etc.):
@@ -0,0 +1,857 @@
# Daemon Lifecycle Redesign Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the daemon's aggressive 5-minute idle timeout with a long-lived model (4h default) that requires both CLI inactivity AND Extension disconnection before exiting, plus add `daemon status/stop/restart` CLI commands.
**Architecture:** The daemon keeps its existing HTTP + WebSocket bridge architecture. We change the idle timeout logic to track two independent activity signals (CLI requests and Extension connection), add `/status` and `/shutdown` HTTP endpoints, reduce the Extension reconnect backoff cap, and register new CLI commands via Commander.js.
**Tech Stack:** Node.js, TypeScript, Commander.js, ws, Vitest
---
## File Structure
| File | Action | Responsibility |
|------|--------|----------------|
| `src/constants.ts` | Modify | Add `DEFAULT_DAEMON_IDLE_TIMEOUT` constant |
| `src/daemon.ts` | Modify | Dual-condition idle timer, `/status` endpoint, `/shutdown` endpoint |
| `src/daemon.test.ts` | Create | Unit tests for idle timer logic, `/status`, `/shutdown` |
| `extension/src/protocol.ts` | Modify | Change `WS_RECONNECT_MAX_DELAY` from 60000 to 5000 |
| `src/cli.ts` | Modify | Register `daemon` subcommand group |
| `src/commands/daemon.ts` | Create | `status`, `stop`, `restart` subcommand implementations |
| `src/commands/daemon.test.ts` | Create | Unit tests for daemon commands |
| `src/browser/mcp.ts` | Modify | Better connection-waiting UX messages, 200ms poll interval |
---
### Task 1: Add `DEFAULT_DAEMON_IDLE_TIMEOUT` constant
**Files:**
- Modify: `src/constants.ts`
- [ ] **Step 1: Add the constant**
In `src/constants.ts`, add after the `DEFAULT_DAEMON_PORT` line:
```typescript
/** Default idle timeout before daemon auto-exits (ms). Override via OPENCLI_DAEMON_TIMEOUT env var. */
export const DEFAULT_DAEMON_IDLE_TIMEOUT = 4 * 60 * 60 * 1000; // 4 hours
```
- [ ] **Step 2: Commit**
```bash
git add src/constants.ts
git commit -m "feat(daemon): add DEFAULT_DAEMON_IDLE_TIMEOUT constant (4 hours)"
```
---
### Task 2: Implement dual-condition idle timer in daemon
**Files:**
- Modify: `src/daemon.ts:27,29-57,116-123,196-198,245-262,265-269`
- Test: `src/daemon.test.ts` (create)
- [ ] **Step 1: Write failing tests for the new idle timer logic**
Create `src/daemon.test.ts`:
```typescript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// We test the idle timer logic by extracting it into testable functions.
// The daemon module has side effects (starts server), so we test the logic unit directly.
describe('IdleManager', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('does not start timer when extension is connected', async () => {
const { IdleManager } = await import('./daemon.js');
const exit = vi.fn();
const mgr = new IdleManager(300_000, exit); // 5 min for fast test
mgr.setExtensionConnected(true);
mgr.onCliRequest();
vi.advanceTimersByTime(300_000 + 1000);
expect(exit).not.toHaveBeenCalled();
});
it('starts timer when extension disconnects and CLI is idle', async () => {
const { IdleManager } = await import('./daemon.js');
const exit = vi.fn();
const mgr = new IdleManager(300_000, exit);
mgr.onCliRequest(); // CLI was active
mgr.setExtensionConnected(true);
mgr.setExtensionConnected(false); // Extension disconnects
// Should not exit immediately — CLI was just active
expect(exit).not.toHaveBeenCalled();
// Advance past timeout
vi.advanceTimersByTime(300_000 + 1000);
expect(exit).toHaveBeenCalledTimes(1);
});
it('exits immediately on extension disconnect if CLI has been idle past timeout', async () => {
const { IdleManager } = await import('./daemon.js');
const exit = vi.fn();
const mgr = new IdleManager(300_000, exit);
mgr.onCliRequest(); // Last CLI activity
vi.advanceTimersByTime(400_000); // 400s elapsed — past 300s timeout
mgr.setExtensionConnected(true);
mgr.setExtensionConnected(false);
expect(exit).toHaveBeenCalledTimes(1);
});
it('resets timer on new CLI request', async () => {
const { IdleManager } = await import('./daemon.js');
const exit = vi.fn();
const mgr = new IdleManager(300_000, exit);
mgr.onCliRequest();
vi.advanceTimersByTime(200_000); // 200s elapsed
mgr.onCliRequest(); // Reset
vi.advanceTimersByTime(200_000); // 200s more — only 200s since last request
expect(exit).not.toHaveBeenCalled();
vi.advanceTimersByTime(100_001); // Now 300s+ since last request
expect(exit).toHaveBeenCalledTimes(1);
});
it('does not exit when timeout is 0 (disabled)', async () => {
const { IdleManager } = await import('./daemon.js');
const exit = vi.fn();
const mgr = new IdleManager(0, exit);
mgr.onCliRequest();
vi.advanceTimersByTime(24 * 60 * 60 * 1000); // 24 hours
expect(exit).not.toHaveBeenCalled();
});
it('clears timer when extension connects', async () => {
const { IdleManager } = await import('./daemon.js');
const exit = vi.fn();
const mgr = new IdleManager(300_000, exit);
mgr.onCliRequest();
vi.advanceTimersByTime(200_000); // Timer running
mgr.setExtensionConnected(true); // Should clear timer
vi.advanceTimersByTime(200_000); // Would have fired
expect(exit).not.toHaveBeenCalled();
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
npx vitest run src/daemon.test.ts
```
Expected: FAIL — `IdleManager` is not exported from `./daemon.js`
- [ ] **Step 3: Extract IdleManager class and refactor daemon.ts**
In `src/daemon.ts`, replace the idle timeout section (lines 27, 29-57) with:
Replace the `IDLE_TIMEOUT` constant (line 27):
```typescript
import { DEFAULT_DAEMON_PORT, DEFAULT_DAEMON_IDLE_TIMEOUT } from './constants.js';
const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
const IDLE_TIMEOUT = Number(process.env.OPENCLI_DAEMON_TIMEOUT ?? DEFAULT_DAEMON_IDLE_TIMEOUT);
```
Replace the idle timer state and `resetIdleTimer` function (lines 37, 49-57) with the `IdleManager` class:
```typescript
/**
* Manages daemon idle timeout with dual-condition logic:
* exits only when BOTH CLI is idle AND Extension is disconnected.
*/
export class IdleManager {
private _timer: ReturnType<typeof setTimeout> | null = null;
private _lastCliRequestTime = Date.now();
private _extensionConnected = false;
private _timeoutMs: number;
private _onExit: () => void;
constructor(timeoutMs: number, onExit: () => void) {
this._timeoutMs = timeoutMs;
this._onExit = onExit;
}
/** Call when an HTTP request arrives from CLI */
onCliRequest(): void {
this._lastCliRequestTime = Date.now();
this._resetTimer();
}
/** Call when Extension WebSocket connects or disconnects */
setExtensionConnected(connected: boolean): void {
this._extensionConnected = connected;
if (connected) {
// Extension is alive — clear any pending exit timer
this._clearTimer();
} else {
// Extension gone — check if CLI has also been idle long enough
this._resetTimer();
}
}
private _clearTimer(): void {
if (this._timer) {
clearTimeout(this._timer);
this._timer = null;
}
}
private _resetTimer(): void {
this._clearTimer();
// Timeout disabled
if (this._timeoutMs <= 0) return;
// Extension connected — don't start timer
if (this._extensionConnected) return;
const elapsed = Date.now() - this._lastCliRequestTime;
if (elapsed >= this._timeoutMs) {
// CLI has been idle past the timeout already
this._onExit();
return;
}
// Start timer for remaining duration
this._timer = setTimeout(() => {
this._onExit();
}, this._timeoutMs - elapsed);
}
}
```
Then create the global `idleManager` instance after the class definition:
```typescript
const idleManager = new IdleManager(IDLE_TIMEOUT, () => {
console.error('[daemon] Idle timeout (no CLI requests + no Extension), shutting down');
process.exit(0);
});
```
- [ ] **Step 4: Wire IdleManager into existing daemon code**
In the `handleRequest` function, replace `resetIdleTimer()` (line 142) with:
```typescript
idleManager.onCliRequest();
```
In the `wss.on('connection')` handler (around line 196-198), add after `extensionWs = ws;`:
```typescript
idleManager.setExtensionConnected(true);
```
In the `ws.on('close')` handler (around line 245-249), add after `extensionWs = null;`:
```typescript
idleManager.setExtensionConnected(false);
```
In the `ws.on('error')` handler (around line 259-261), add after `extensionWs = null;`:
```typescript
idleManager.setExtensionConnected(false);
```
In the `httpServer.listen` callback (line 268-269), replace `resetIdleTimer()` with:
```typescript
idleManager.onCliRequest(); // Start initial idle countdown
```
Remove the old `resetIdleTimer` function and `idleTimer` variable entirely.
- [ ] **Step 5: Run tests to verify they pass**
```bash
npx vitest run src/daemon.test.ts
```
Expected: All 6 tests PASS
- [ ] **Step 6: Commit**
```bash
git add src/daemon.ts src/daemon.test.ts
git commit -m "feat(daemon): replace fixed 5min timeout with dual-condition idle manager (4h default)"
```
---
### Task 3: Add `/status` and `/shutdown` endpoints to daemon
**Files:**
- Modify: `src/daemon.ts:116-123`
- [ ] **Step 1: Add tests for /status and /shutdown endpoints**
Append to `src/daemon.test.ts`:
```typescript
describe('/status endpoint', () => {
it('returns daemon status with correct fields', async () => {
// This is an integration test — tested via the daemon command tests.
// Here we just verify the shape of the status response type.
expect(true).toBe(true); // Placeholder — real coverage in Task 6
});
});
```
Note: The `/status` and `/shutdown` endpoints run inside the daemon process, which makes them hard to unit test in isolation. They are integration-tested via the `opencli daemon status/stop` commands in Task 6.
- [ ] **Step 2: Enhance the existing `/status` endpoint**
In `src/daemon.ts`, replace the existing `/status` handler (lines 116-123) with:
```typescript
if (req.method === 'GET' && pathname === '/status') {
const uptime = process.uptime();
const mem = process.memoryUsage();
jsonResponse(res, 200, {
ok: true,
pid: process.pid,
uptime,
extensionConnected: extensionWs?.readyState === WebSocket.OPEN,
pending: pending.size,
lastCliRequestTime: idleManager.lastCliRequestTime,
memoryMB: Math.round(mem.rss / 1024 / 1024 * 10) / 10,
port: PORT,
});
return;
}
```
Also add a public getter to `IdleManager`:
```typescript
get lastCliRequestTime(): number {
return this._lastCliRequestTime;
}
```
- [ ] **Step 3: Add the `/shutdown` endpoint**
In `src/daemon.ts`, add before the `POST /command` handler:
```typescript
if (req.method === 'POST' && pathname === '/shutdown') {
jsonResponse(res, 200, { ok: true, message: 'Shutting down' });
// Graceful shutdown after response is sent
setTimeout(() => shutdown(), 100);
return;
}
```
- [ ] **Step 4: Run all tests**
```bash
npx vitest run src/daemon.test.ts
```
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/daemon.ts src/daemon.test.ts
git commit -m "feat(daemon): enhance /status endpoint, add /shutdown endpoint"
```
---
### Task 4: Reduce Extension WebSocket reconnect backoff cap
**Files:**
- Modify: `extension/src/protocol.ts:57`
- [ ] **Step 1: Change the constant**
In `extension/src/protocol.ts`, change line 57:
```typescript
/** Max reconnect delay (ms) — kept short since daemon is long-lived */
export const WS_RECONNECT_MAX_DELAY = 5000;
```
- [ ] **Step 2: Commit**
```bash
git add extension/src/protocol.ts
git commit -m "feat(extension): reduce WS reconnect backoff cap from 60s to 5s"
```
---
### Task 5: Implement `daemon status/stop/restart` CLI commands
**Files:**
- Create: `src/commands/daemon.ts`
- Modify: `src/cli.ts`
- [ ] **Step 1: Create daemon command module**
Create `src/commands/daemon.ts`:
```typescript
/**
* CLI commands for daemon lifecycle management:
* opencli daemon status — show daemon state
* opencli daemon stop — graceful shutdown
* opencli daemon restart — stop + respawn
*/
import chalk from 'chalk';
import { DEFAULT_DAEMON_PORT } from '../constants.js';
const DAEMON_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
interface DaemonStatus {
ok: boolean;
pid: number;
uptime: number;
extensionConnected: boolean;
pending: number;
lastCliRequestTime: number;
memoryMB: number;
port: number;
}
async function fetchStatus(): Promise<DaemonStatus | null> {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2000);
const res = await fetch(`${DAEMON_URL}/status`, {
headers: { 'X-OpenCLI': '1' },
signal: controller.signal,
});
clearTimeout(timer);
if (!res.ok) return null;
return await res.json() as DaemonStatus;
} catch {
return null;
}
}
async function requestShutdown(): Promise<boolean> {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
const res = await fetch(`${DAEMON_URL}/shutdown`, {
method: 'POST',
headers: { 'X-OpenCLI': '1' },
signal: controller.signal,
});
clearTimeout(timer);
return res.ok;
} catch {
return false;
}
}
function formatUptime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m`;
return `${Math.floor(seconds)}s`;
}
function formatTimeSince(timestampMs: number): string {
const seconds = (Date.now() - timestampMs) / 1000;
if (seconds < 60) return `${Math.floor(seconds)}s ago`;
const m = Math.floor(seconds / 60);
if (m < 60) return `${m} min ago`;
const h = Math.floor(m / 60);
return `${h}h ${m % 60}m ago`;
}
export async function daemonStatus(): Promise<void> {
const status = await fetchStatus();
if (!status) {
console.log(`Daemon: ${chalk.dim('not running')}`);
return;
}
console.log(`Daemon: ${chalk.green('running')} (PID ${status.pid})`);
console.log(`Uptime: ${formatUptime(status.uptime)}`);
console.log(`Extension: ${status.extensionConnected ? chalk.green('connected') : chalk.yellow('disconnected')}`);
console.log(`Last CLI request: ${formatTimeSince(status.lastCliRequestTime)}`);
console.log(`Memory: ${status.memoryMB} MB`);
console.log(`Port: ${status.port}`);
}
export async function daemonStop(): Promise<void> {
const status = await fetchStatus();
if (!status) {
console.log(chalk.dim('Daemon is not running.'));
return;
}
const ok = await requestShutdown();
if (ok) {
console.log(chalk.green('Daemon stopped.'));
} else {
console.error(chalk.red('Failed to stop daemon.'));
process.exitCode = 1;
}
}
export async function daemonRestart(): Promise<void> {
const status = await fetchStatus();
if (status) {
const ok = await requestShutdown();
if (!ok) {
console.error(chalk.red('Failed to stop daemon.'));
process.exitCode = 1;
return;
}
// Wait for daemon to exit
await new Promise(r => setTimeout(r, 500));
}
// Import BrowserBridge to spawn a new daemon
const { BrowserBridge } = await import('../browser/mcp.js');
const bridge = new BrowserBridge();
try {
console.log('Starting daemon...');
await bridge.connect({ timeout: 10 });
console.log(chalk.green('Daemon restarted.'));
} catch (err) {
console.error(chalk.red(`Failed to restart daemon: ${err instanceof Error ? err.message : err}`));
process.exitCode = 1;
}
}
```
- [ ] **Step 2: Register daemon commands in cli.ts**
In `src/cli.ts`, add the import at the top:
```typescript
import { daemonStatus, daemonStop, daemonRestart } from './commands/daemon.js';
```
Add the daemon subcommand group before the `// ── External CLIs` section (around line 380):
```typescript
// ── Built-in: daemon ──────────────────────────────────────────────────────
const daemonCmd = program.command('daemon').description('Manage the opencli daemon');
daemonCmd
.command('status')
.description('Show daemon status')
.action(async () => { await daemonStatus(); });
daemonCmd
.command('stop')
.description('Stop the daemon')
.action(async () => { await daemonStop(); });
daemonCmd
.command('restart')
.description('Restart the daemon')
.action(async () => { await daemonRestart(); });
```
- [ ] **Step 3: Run linter/type check**
```bash
npx tsc --noEmit
```
Expected: No errors
- [ ] **Step 4: Commit**
```bash
git add src/commands/daemon.ts src/cli.ts
git commit -m "feat(daemon): add opencli daemon status/stop/restart commands"
```
---
### Task 6: Write tests for daemon commands
**Files:**
- Create: `src/commands/daemon.test.ts`
- [ ] **Step 1: Write tests**
Create `src/commands/daemon.test.ts`:
```typescript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock fetch globally for all tests
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
// Mock chalk to avoid ANSI in assertions
vi.mock('chalk', () => ({
default: {
green: (s: string) => s,
yellow: (s: string) => s,
red: (s: string) => s,
dim: (s: string) => s,
},
}));
describe('daemonStatus', () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
consoleSpy.mockRestore();
mockFetch.mockReset();
});
it('shows "not running" when daemon is unreachable', async () => {
mockFetch.mockRejectedValue(new TypeError('fetch failed'));
const { daemonStatus } = await import('./daemon.js');
await daemonStatus();
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('not running'));
});
it('shows daemon info when running', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({
ok: true,
pid: 12345,
uptime: 7200,
extensionConnected: true,
pending: 0,
lastCliRequestTime: Date.now() - 60_000,
memoryMB: 12.3,
port: 19825,
}),
});
const { daemonStatus } = await import('./daemon.js');
await daemonStatus();
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('running'));
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('12345'));
});
});
describe('daemonStop', () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
let consoleErrSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
consoleErrSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
consoleSpy.mockRestore();
consoleErrSpy.mockRestore();
mockFetch.mockReset();
});
it('reports when daemon is not running', async () => {
mockFetch.mockRejectedValue(new TypeError('fetch failed'));
const { daemonStop } = await import('./daemon.js');
await daemonStop();
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('not running'));
});
it('sends shutdown and reports success', async () => {
// First call: fetchStatus
// Second call: requestShutdown
mockFetch
.mockResolvedValueOnce({
ok: true,
json: async () => ({ ok: true, pid: 123, uptime: 100, extensionConnected: false, pending: 0, lastCliRequestTime: Date.now(), memoryMB: 10, port: 19825 }),
})
.mockResolvedValueOnce({ ok: true });
const { daemonStop } = await import('./daemon.js');
await daemonStop();
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('stopped'));
});
});
```
- [ ] **Step 2: Run tests**
```bash
npx vitest run src/commands/daemon.test.ts
```
Expected: PASS
- [ ] **Step 3: Commit**
```bash
git add src/commands/daemon.test.ts
git commit -m "test(daemon): add tests for daemon status/stop commands"
```
---
### Task 7: Improve CLI connection-waiting UX
**Files:**
- Modify: `src/browser/mcp.ts:58-118`
- [ ] **Step 1: Improve error messages and poll interval**
In `src/browser/mcp.ts`, replace the `_ensureDaemon` method (lines 58-118) with:
```typescript
private async _ensureDaemon(timeoutSeconds?: number): Promise<void> {
const effectiveSeconds = (timeoutSeconds && timeoutSeconds > 0) ? timeoutSeconds : Math.ceil(DAEMON_SPAWN_TIMEOUT / 1000);
const timeoutMs = effectiveSeconds * 1000;
// Fast path: extension already connected
if (await isExtensionConnected()) return;
// Daemon running but no extension — wait for extension with progress
if (await isDaemonRunning()) {
if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
process.stderr.write('⏳ Waiting for Chrome extension to connect...\n');
process.stderr.write(' Make sure Chrome is open and the OpenCLI extension is enabled.\n');
}
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 200));
if (await isExtensionConnected()) return;
}
throw new Error(
'Daemon is running but the Browser Extension is not connected.\n' +
'Please install and enable the opencli Browser Bridge extension in Chrome.',
);
}
// 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();
// Wait for daemon + extension with faster polling
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 200));
if (await isExtensionConnected()) return;
}
if (await isDaemonRunning()) {
throw new Error(
'Daemon is running but the Browser Extension is not connected.\n' +
'Please install and enable the opencli Browser Bridge extension in Chrome.',
);
}
throw new Error(
'Failed to start opencli daemon. Try running manually:\n' +
` node ${daemonPath}\n` +
`Make sure port ${DEFAULT_DAEMON_PORT} is available.`,
);
}
```
- [ ] **Step 2: Run existing browser tests to check for regressions**
```bash
npx vitest run src/browser.test.ts
```
Expected: PASS
- [ ] **Step 3: Commit**
```bash
git add src/browser/mcp.ts
git commit -m "feat(daemon): improve CLI connection-waiting UX with progress messages and 200ms polling"
```
---
### Task 8: Run full test suite and verify
- [ ] **Step 1: Run type check**
```bash
npx tsc --noEmit
```
Expected: No errors
- [ ] **Step 2: Run all tests**
```bash
npx vitest run
```
Expected: All tests pass, no regressions
- [ ] **Step 3: Manual smoke test**
```bash
# Check daemon status (should be "not running" if daemon isn't started)
npx tsx src/main.ts daemon status
# Start daemon by running any browser command, then check status
npx tsx src/main.ts daemon status
# Stop daemon
npx tsx src/main.ts daemon stop
# Verify stopped
npx tsx src/main.ts daemon status
```
- [ ] **Step 4: Final commit if any fixes needed**
```bash
git add -A
git commit -m "fix: address issues found during smoke testing"
```
@@ -0,0 +1,208 @@
# Daemon Lifecycle Redesign
## Problem
OpenCLI's daemon auto-exits after 5 minutes of idle time. During typical development
cycles (write code → test → modify → test again), coding intervals frequently exceed
5 minutes. Each restart incurs 2-4 seconds of overhead (process spawn + Extension
WebSocket reconnection), creating a noticeable and frustrating delay.
The current design treats the daemon as a disposable process, but the actual cost
profile doesn't justify this:
| Cost of staying alive | Cost of restarting |
|-----------------------|--------------------|
| ~12 MB memory, 0% CPU | 2-4 seconds delay per restart |
The restart cost far outweighs the idle cost.
## Solution
Replace the aggressive 5-minute fixed timeout with a long-lived daemon model. The
daemon stays running for hours, exits only when truly abandoned, and reconnects to
the Chrome Extension faster when needed.
Four changes:
1. Extend idle timeout from 5 minutes to 4 hours (configurable)
2. Require dual idle condition: both no CLI requests AND no Extension connection
3. Reduce Extension WebSocket reconnect backoff cap from 60s to 5s
4. Add `opencli daemon status/stop/restart` commands
## Design
### Timeout Strategy
**Current behavior:** A single idle timer resets on each HTTP request. After 5
minutes without a request, the daemon calls `process.exit(0)`.
**New behavior:** The daemon tracks two activity signals independently:
- **CLI activity:** timestamp of the last HTTP request from any CLI invocation
- **Extension activity:** whether a WebSocket connection from the Chrome Extension
is currently open
The exit countdown starts only when BOTH conditions are met simultaneously:
- No CLI request for `IDLE_TIMEOUT` duration
- No Extension WebSocket connection
If either signal is active, the daemon stays alive. This means:
- A connected Extension keeps the daemon alive indefinitely (user has Chrome open,
likely still working)
- Recent CLI activity keeps the daemon alive even if Extension temporarily
disconnects (Chrome restarting, Extension updating)
**Timeout value:** 4 hours by default, configurable via `OPENCLI_DAEMON_TIMEOUT`
environment variable. Value in milliseconds. Set to `0` to disable timeout entirely.
```typescript
const DEFAULT_IDLE_TIMEOUT = 4 * 60 * 60 * 1000; // 4 hours
const IDLE_TIMEOUT = Number(process.env.OPENCLI_DAEMON_TIMEOUT ?? DEFAULT_IDLE_TIMEOUT);
```
**Timer implementation:**
```
resetIdleTimer():
clear existing timer
if Extension is connected:
do not start timer (Extension connection keeps daemon alive)
return
start timer with IDLE_TIMEOUT duration
on timeout: process.exit(0)
On CLI HTTP request:
update lastRequestTime
resetIdleTimer()
On Extension WebSocket connect:
clear timer (Extension keeps daemon alive)
On Extension WebSocket disconnect:
elapsed = now - lastRequestTime
if elapsed >= IDLE_TIMEOUT:
process.exit(0) // CLI has been idle long enough already
else:
start timer with (IDLE_TIMEOUT - elapsed) // count remaining time
```
### Extension Fast Reconnect
**Current behavior:** When the Extension loses its WebSocket connection to the
daemon, it reconnects with exponential backoff: 2s → 4s → 8s → 16s → 32s → 60s
(capped). In the worst case, the Extension waits up to 60 seconds before attempting
reconnection.
**New behavior:** Cap the backoff at 5 seconds instead of 60 seconds.
```typescript
// extension/src/background.ts
const WS_RECONNECT_MAX_DELAY = 5000; // was 60000
```
Rationale: with a 4-hour daemon timeout, the daemon is almost always running. Long
backoff intervals are unnecessary and only increase reconnection latency. A 5-second
cap means the Extension reconnects within 5 seconds of the daemon becoming available.
### Daemon Management Commands
Add three new CLI commands for daemon lifecycle management:
**`opencli daemon status`**
Queries the daemon's `/status` endpoint (new) and displays:
```
Daemon: running (PID 12345)
Uptime: 2h 15m
Extension: connected
Last CLI request: 8 min ago
Memory: 12.3 MB
Port: 19825
```
If daemon is not running:
```
Daemon: not running
```
**`opencli daemon stop`**
Sends a `POST /shutdown` request to the daemon, which triggers a graceful shutdown:
reject pending requests with a shutdown message, close WebSocket connections, close
HTTP server, then exit.
**`opencli daemon restart`**
Equivalent to `stop` followed by spawning a new daemon. Useful when the daemon gets
into a bad state.
**Daemon-side endpoints:**
- `GET /status` — returns JSON with PID, uptime, extension connection state, last
request time, memory usage
- `POST /shutdown` — initiates graceful shutdown
Both endpoints require the same `X-OpenCLI` header as existing endpoints for CSRF
protection.
### CLI Connection Experience
**Current behavior:** When daemon is running but Extension is not connected, the CLI
silently polls every 300ms and eventually times out with a generic error.
**New behavior:** Show a progress indicator and actionable message:
```
⏳ Waiting for Chrome extension to connect...
Make sure Chrome is open and the OpenCLI extension is enabled.
```
Poll interval reduced from 300ms to 200ms for slightly faster detection.
If the daemon is not running at all (connection refused), the CLI spawns it as before
and shows:
```
⏳ Starting daemon...
```
## Files Changed
| File | Change | Estimated LOC |
|------|--------|---------------|
| `src/daemon.ts` | Dual-condition idle timeout, `/status` endpoint, `/shutdown` endpoint | ~40 |
| `extension/src/background.ts` | `WS_RECONNECT_MAX_DELAY` 60000 → 5000 | 1 |
| `src/browser/daemon-client.ts` | Better connection-waiting UX, 200ms poll interval | ~20 |
| `src/commands/daemon.ts` (new) | `status`, `stop`, `restart` subcommands | ~80 |
| `src/constants.ts` | `DEFAULT_IDLE_TIMEOUT` constant | 2 |
**Total: ~143 lines of new/changed code.**
## Backward Compatibility
- No breaking changes to CLI commands or Extension protocol
- Existing `OPENCLI_DAEMON_PORT` environment variable continues to work
- The only observable behavior change: daemon stays alive longer
- New `daemon` subcommands are additive
## Testing
- Unit test: idle timer starts only when both CLI and Extension are idle
- Unit test: idle timer is cleared when Extension connects
- Unit test: `/status` returns correct state
- Unit test: `/shutdown` triggers graceful exit
- Integration test: daemon survives 10+ minutes without CLI requests while Extension
is connected
- Integration test: daemon exits after configured timeout when fully idle
- Integration test: `opencli daemon status/stop/restart` work correctly
## Out of Scope
- OS-level daemon management (launchd/systemd) — can be added later if needed
- Daemon auto-update mechanism
- Multi-daemon coordination
- Persistent daemon state across restarts
@@ -0,0 +1,144 @@
# Browse Skill Testing Design
Two-layer testing framework for `opencli browse` commands and the
Claude Code skill integration.
## Goal
Verify that `opencli browse` works reliably on real websites and that
Claude Code can use the skill to complete browser tasks end-to-end.
## Architecture
```
autoresearch/
├── browse-tasks.json ← 59 task definitions with browse command sequences
├── eval-browse.ts ← Layer 1: deterministic browse command testing
├── eval-skill.ts ← Layer 2: Claude Code skill E2E testing
├── run-browse.sh ← Launch Layer 1
├── run-skill.sh ← Launch Layer 2
├── baseline-browse.txt ← Layer 1 best score
├── baseline-skill.txt ← Layer 2 best score
└── results/ ← Per-run results (gitignored)
```
## Layer 1: Deterministic Browse Command Testing
Tests `opencli browse` commands directly on real websites. No LLM
involved — pure command reliability testing.
### How It Works
Each task defines a sequence of browse commands and a judge for the
last command's output:
```json
{
"name": "hn-top-stories",
"steps": [
"opencli browse open https://news.ycombinator.com",
"opencli browse eval \"JSON.stringify([...document.querySelectorAll('.titleline a')].slice(0,5).map(a=>({title:a.textContent,url:a.href})))\""
],
"judge": { "type": "arrayMinLength", "minLength": 5 }
}
```
### Execution
```bash
./autoresearch/run-browse.sh
```
- Runs all 59 tasks serially
- Each task: execute steps → judge last step output → pass/fail
- `opencli browse close` between tasks for clean state
- Expected: ~2 minutes, $0 cost
### Task Categories
| Category | Count | Example |
|----------|-------|---------|
| extract | 9 | Open page, eval JS to extract data |
| list | 10 | Open page, eval JS to extract array |
| search | 6 | Open, type query, keys Enter, eval results |
| nav | 7 | Open, click link, eval new page title |
| scroll | 5 | Open, scroll, eval footer/hidden content |
| form | 6 | Open, type into fields, eval field values |
| complex | 6 | Multi-step: open → click → navigate → extract |
| bench | 10 | Test set (various) |
## Layer 2: Claude Code Skill E2E Testing
Spawns Claude Code with the opencli-operate skill to complete tasks
autonomously using browse commands.
### How It Works
```bash
claude -p \
--system-prompt "$(cat skills/opencli-operate/SKILL.md)" \
--dangerously-skip-permissions \
--allowedTools "Bash(opencli:*)" \
--output-format json \
"用 opencli browse 完成任务:Extract the top 5 stories from Hacker News with title and score. Start URL: https://news.ycombinator.com"
```
### Execution
```bash
./autoresearch/run-skill.sh
```
- Runs all 59 tasks serially
- Each task: spawn Claude Code → it uses browse commands autonomously → judge output
- Expected: ~20 minutes, ~$5-10
### Judge
Both layers use the same judge types:
| Type | Description |
|------|-------------|
| `contains` | Output contains a substring |
| `arrayMinLength` | Output is an array with ≥ N items |
| `arrayFieldsPresent` | Array items have required fields |
| `nonEmpty` | Output is non-empty |
| `matchesPattern` | Output matches a regex |
## Output Format
```
🔬 Layer 1: Browse Commands — 59 tasks
[1/59] extract-title-example... ✓ (0.5s)
[2/59] hn-top-stories... ✓ (1.2s)
...
Score: 55/59 (93%)
Time: 2min
Cost: $0
🔬 Layer 2: Skill E2E — 59 tasks
[1/59] extract-title-example... ✓ (8s, $0.01)
[2/59] hn-top-stories... ✓ (15s, $0.08)
...
Score: 52/59 (88%)
Time: 20min
Cost: $6.50
```
## Constraints
- All 59 tasks run on real websites (no mocks)
- Layer 1: zero LLM cost, ~2 min
- Layer 2: ~$5-10 LLM cost, ~20 min
- Results saved to `autoresearch/results/` (gitignored)
- Baselines tracked in `baseline-browse.txt` and `baseline-skill.txt`
## Success Criteria
- Layer 1 ≥ 90% (browse commands work on real sites)
- Layer 2 ≥ 85% (Claude Code can use skill effectively)
- Both layers cover all 8 task categories
@@ -0,0 +1,41 @@
# V2EX AutoResearch Test Suite Design
## Goal
Build a comprehensive test suite using V2EX (https://v2ex.com/) as the single target website to iteratively improve OpenCLI Operate's reliability and Claude Code skill effectiveness. Run 10 rounds of AutoResearch iteration (5 code-level + 5 SKILL.md-level).
## Test Suite Structure — 5 Layers, 40 Tasks
### Layer 1: Atomic (10 tasks)
Single operate commands testing command-level reliability.
### Layer 2: Single Page (10 tasks)
Meaningful extraction/interaction within one page.
### Layer 3: Multi-Step (10 tasks)
Cross-page navigation + extraction combos.
### Layer 4: Write Operations (5 tasks)
Login-required write operations (reply, favorite, thank).
### Layer 5: Complex Chain (5 tasks)
Long chains: cross-post reference, multi-node comparison, full workflows.
## Test Infrastructure
- **v2ex-tasks.json** — Layer 1 deterministic tasks (browse commands + judge criteria)
- **eval-v2ex.ts** — Runner for V2EX tasks (reuses eval-browse.ts pattern)
- **v2ex-skill-tasks** — Layer 2 LLM E2E tasks embedded in eval runner
- **presets/v2ex-reliability.ts** — AutoResearch preset for code optimization
- **presets/v2ex-skill.ts** — AutoResearch preset for SKILL.md optimization
## AutoResearch Iteration Plan
- Rounds 1-5: Layer 1 preset → optimize src/browser/*.ts code
- Rounds 6-10: Layer 2 preset → optimize skills/opencli-operate/SKILL.md
- Alternating: fix code issues first, then improve LLM guidance
## Success Criteria
- Layer 1 baseline → target 100% pass rate after 5 code iterations
- Layer 2 baseline → target 100% pass rate after 5 SKILL.md iterations
+12
View File
@@ -22,3 +22,15 @@ OpenCLI 通过轻量级 **Browser Bridge** Chrome 扩展 + 微守护进程连接
```bash
opencli doctor # 检查扩展 + 守护进程连接
```
## Daemon 生命周期
Daemon 在首次运行浏览器命令时自动启动,默认保持 **4 小时**。仅当 CLI 空闲超时**且** Chrome 扩展未连接时才会退出。
```bash
opencli daemon status # 查看 daemon 状态(PID、运行时长、扩展连接、内存)
opencli daemon stop # 优雅关停
opencli daemon restart # 重启
```
通过 `OPENCLI_DAEMON_TIMEOUT` 环境变量覆盖超时时间(毫秒)。设为 `0` 则永不超时。
+21
View File
@@ -32,6 +32,27 @@ opencli bilibili hot -f md # Markdown
opencli bilibili hot -f csv # CSV
```
## 终端自动补全
OpenCLI 支持智能的 Tab 自动补全,加快命令输入:
```bash
# 把自动补全加入 shell 启动配置
echo 'eval "$(opencli completion zsh)"' >> ~/.zshrc # Zsh
echo 'eval "$(opencli completion bash)"' >> ~/.bashrc # Bash
echo 'opencli completion fish | source' >> ~/.config/fish/config.fish # Fish
# 重启 shell 后,按 Tab 键补全:
opencli [Tab] # 补全站点名称(bilibili、zhihu、twitter...
opencli bilibili [Tab] # 补全命令(hot、search、me、download...
```
补全功能包含:
- 所有可用的站点和适配器
- 内置命令(list、explore、validate...
- 命令别名
- 新增适配器时的实时更新
## 下一步
- [安装详情](/zh/guide/installation)
+1
View File
@@ -167,6 +167,7 @@ cli({
- `opencli-plugin-github-trending`GitHub Trending 仓库
- `opencli-plugin-hot-digest`:多平台热点聚合(zhihu、weibo、bilibili、v2ex、stackoverflow、reddit、linux-do
- `opencli-plugin-juejin`:稀土掘金热榜、分类和文章流
- `opencli-plugin-rubysec`:RubySec 漏洞归档与单篇漏洞文章读取
## 排查问题
+1059 -512
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -5,6 +5,7 @@
"description": "Browser automation bridge for the OpenCLI CLI tool. Executes commands in isolated Chrome windows via a local daemon.",
"permissions": [
"debugger",
"scripting",
"tabs",
"cookies",
"activeTab",
@@ -35,4 +36,4 @@
"extension_pages": "script-src 'self'; object-src 'self'"
},
"homepage_url": "https://github.com/jackwener/opencli"
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "opencli-extension",
"version": "1.5.4",
"version": "1.5.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "opencli-extension",
"version": "1.5.4",
"version": "1.5.5",
"devDependencies": {
"@types/chrome": "^0.0.287",
"typescript": "^5.7.0",
+85 -2
View File
@@ -35,8 +35,12 @@ function createChromeMock() {
{ id: 3, windowId: 1, url: 'chrome://extensions', title: 'chrome', active: false, status: 'complete' },
];
const query = vi.fn(async (queryInfo: { windowId?: number } = {}) => {
return tabs.filter((tab) => queryInfo.windowId === undefined || tab.windowId === queryInfo.windowId);
const query = vi.fn(async (queryInfo: { windowId?: number; active?: boolean } = {}) => {
return tabs.filter((tab) => {
if (queryInfo.windowId !== undefined && tab.windowId !== queryInfo.windowId) return false;
if (queryInfo.active !== undefined && !!tab.active !== queryInfo.active) return false;
return true;
});
});
const create = vi.fn(async ({ windowId, url, active }: { windowId?: number; url?: string; active?: boolean }) => {
const tab: MockTab = {
@@ -69,6 +73,12 @@ function createChromeMock() {
if (!tab) throw new Error(`Unknown tab ${tabId}`);
return tab;
}),
move: vi.fn(async (tabId: number, moveProps: { windowId: number; index: number }) => {
const tab = tabs.find((entry) => entry.id === tabId);
if (!tab) throw new Error(`Unknown tab ${tabId}`);
tab.windowId = moveProps.windowId;
return tab;
}),
onUpdated: { addListener: vi.fn(), removeListener: vi.fn() } as Listener<(id: number, info: chrome.tabs.TabChangeInfo) => void>,
},
windows: {
@@ -84,6 +94,8 @@ function createChromeMock() {
runtime: {
onInstalled: { addListener: vi.fn() } as Listener<() => void>,
onStartup: { addListener: vi.fn() } as Listener<() => void>,
onMessage: { addListener: vi.fn() } as Listener<(msg: unknown, sender: unknown, sendResponse: (value: unknown) => void) => void>,
getManifest: vi.fn(() => ({ version: 'test-version' })),
},
cookies: {
getAll: vi.fn(async () => []),
@@ -193,4 +205,75 @@ describe('background tab isolation', () => {
expect.objectContaining({ workspace: 'site:zhihu', windowId: 2 }),
]));
});
it('keeps site:notebooklm inside its owned automation window instead of rebinding to a user tab', async () => {
const { chrome, tabs } = createChromeMock();
tabs[0].url = 'https://notebooklm.google.com/';
tabs[0].title = 'NotebookLM Home';
tabs[1].url = 'https://notebooklm.google.com/notebook/nb-live';
tabs[1].title = 'Live Notebook';
vi.stubGlobal('chrome', chrome);
const mod = await import('./background');
mod.__test__.setAutomationWindowId('site:notebooklm', 1);
const tabId = await mod.__test__.resolveTabId(undefined, 'site:notebooklm');
expect(tabId).toBe(1);
expect(mod.__test__.getSession('site:notebooklm')).toEqual(expect.objectContaining({
windowId: 1,
}));
});
it('moves drifted tab back to automation window instead of creating a new one', async () => {
const { chrome, tabs } = createChromeMock();
// Tab 1 belongs to automation window 1 but drifted to window 2
tabs[0].windowId = 2;
tabs[0].url = 'https://twitter.com/home';
vi.stubGlobal('chrome', chrome);
const mod = await import('./background');
mod.__test__.setAutomationWindowId('site:twitter', 1);
const tabId = await mod.__test__.resolveTabId(1, 'site:twitter');
// Should have moved tab 1 back to window 1 and reused it
expect(chrome.tabs.move).toHaveBeenCalledWith(1, { windowId: 1, index: -1 });
expect(tabId).toBe(1);
});
it('falls through to re-resolve when drifted tab move fails', async () => {
const { chrome, tabs } = createChromeMock();
tabs[0].windowId = 2;
tabs[0].url = 'https://twitter.com/home';
// Make move fail
chrome.tabs.move = vi.fn(async () => { throw new Error('Cannot move tab'); });
vi.stubGlobal('chrome', chrome);
const mod = await import('./background');
mod.__test__.setAutomationWindowId('site:twitter', 1);
// Should still resolve (by finding/creating a tab in the correct window)
const tabId = await mod.__test__.resolveTabId(1, 'site:twitter');
expect(typeof tabId).toBe('number');
});
it('idle timeout closes the automation window for site:notebooklm', async () => {
const { chrome, tabs } = createChromeMock();
tabs[0].url = 'https://notebooklm.google.com/';
tabs[0].title = 'NotebookLM Home';
tabs[0].active = true;
vi.useFakeTimers();
vi.stubGlobal('chrome', chrome);
const mod = await import('./background');
mod.__test__.setAutomationWindowId('site:notebooklm', 1);
mod.__test__.resetWindowIdleTimer('site:notebooklm');
await vi.advanceTimersByTimeAsync(30001);
expect(chrome.windows.remove).toHaveBeenCalledWith(1);
expect(mod.__test__.getSession('site:notebooklm')).toBeNull();
});
});
+304 -38
View File
@@ -117,6 +117,8 @@ type AutomationSession = {
windowId: number;
idleTimer: ReturnType<typeof setTimeout> | null;
idleDeadlineAt: number;
owned: boolean;
preferredTabId: number | null;
};
const automationSessions = new Map<string, AutomationSession>();
@@ -134,6 +136,11 @@ function resetWindowIdleTimer(workspace: string): void {
session.idleTimer = setTimeout(async () => {
const current = automationSessions.get(workspace);
if (!current) return;
if (!current.owned) {
console.log(`[opencli] Borrowed workspace ${workspace} detached from window ${current.windowId} (idle timeout)`);
automationSessions.delete(workspace);
return;
}
try {
await chrome.windows.remove(current.windowId);
console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout)`);
@@ -144,8 +151,11 @@ function resetWindowIdleTimer(workspace: string): void {
}, WINDOW_IDLE_TIMEOUT);
}
/** Get or create the dedicated automation window. */
async function getAutomationWindow(workspace: string): Promise<number> {
/** Get or create the dedicated automation window.
* @param initialUrl — if provided (http/https), used as the initial page instead of about:blank.
* This avoids an extra blank-page→target-domain navigation on first command.
*/
async function getAutomationWindow(workspace: string, initialUrl?: string): Promise<number> {
// Check if our window is still alive
const existing = automationSessions.get(workspace);
if (existing) {
@@ -158,12 +168,13 @@ async function getAutomationWindow(workspace: string): Promise<number> {
}
}
// Create a new window with a data: URI that New Tab Override extensions cannot intercept.
// Using about:blank would be hijacked by extensions like "New Tab Override".
// Use the target URL directly if it's a safe navigation URL, otherwise fall back to about:blank.
const startUrl = (initialUrl && isSafeNavigationUrl(initialUrl)) ? initialUrl : BLANK_PAGE;
// Note: Do NOT set `state` parameter here. Chrome 146+ rejects 'normal' as an invalid
// state value for windows.create(). The window defaults to 'normal' state anyway.
const win = await chrome.windows.create({
url: BLANK_PAGE,
url: startUrl,
focused: false,
width: 1280,
height: 900,
@@ -173,12 +184,33 @@ async function getAutomationWindow(workspace: string): Promise<number> {
windowId: win.id!,
idleTimer: null,
idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT,
owned: true,
preferredTabId: null,
};
automationSessions.set(workspace, session);
console.log(`[opencli] Created automation window ${session.windowId} (${workspace})`);
console.log(`[opencli] Created automation window ${session.windowId} (${workspace}, start=${startUrl})`);
resetWindowIdleTimer(workspace);
// Brief delay to let Chrome load the initial data: URI tab
await new Promise(resolve => setTimeout(resolve, 200));
// Wait for the initial tab to finish loading instead of a fixed 200ms sleep.
const tabs = await chrome.tabs.query({ windowId: win.id! });
if (tabs[0]?.id) {
await new Promise<void>((resolve) => {
const timeout = setTimeout(resolve, 500); // fallback cap
const listener = (tabId: number, info: chrome.tabs.TabChangeInfo) => {
if (tabId === tabs[0].id && info.status === 'complete') {
chrome.tabs.onUpdated.removeListener(listener);
clearTimeout(timeout);
resolve();
}
};
// Check if already complete before listening
if (tabs[0].status === 'complete') {
clearTimeout(timeout);
resolve();
} else {
chrome.tabs.onUpdated.addListener(listener);
}
});
}
return session.windowId;
}
@@ -250,8 +282,20 @@ async function handleCommand(cmd: Command): Promise<Result> {
return await handleScreenshot(cmd, workspace);
case 'close-window':
return await handleCloseWindow(cmd, workspace);
case 'cdp':
return await handleCdp(cmd, workspace);
case 'sessions':
return await handleSessions(cmd);
case 'set-file-input':
return await handleSetFileInput(cmd, workspace);
case 'insert-text':
return await handleInsertText(cmd, workspace);
case 'bind-current':
return await handleBindCurrent(cmd, workspace);
case 'network-capture-start':
return await handleNetworkCaptureStart(cmd, workspace);
case 'network-capture-read':
return await handleNetworkCaptureRead(cmd, workspace);
default:
return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` };
}
@@ -267,12 +311,12 @@ async function handleCommand(cmd: Command): Promise<Result> {
// ─── Action handlers ─────────────────────────────────────────────────
/** Internal blank page used when no user URL is provided. */
const BLANK_PAGE = 'data:text/html,<html></html>';
const BLANK_PAGE = 'about:blank';
/** Check if a URL can be attached via CDP — only allow http(s) and our internal blank page. */
/** Check if a URL can be attached via CDP — only allow http(s) and blank pages. */
function isDebuggableUrl(url?: string): boolean {
if (!url) return true; // empty/undefined = tab still loading, allow it
return url.startsWith('http://') || url.startsWith('https://') || url === BLANK_PAGE;
return url.startsWith('http://') || url.startsWith('https://') || url === 'about:blank' || url.startsWith('data:');
}
/** Check if a URL is safe for user-facing navigation (http/https only). */
@@ -299,49 +343,103 @@ function isTargetUrl(currentUrl: string | undefined, targetUrl: string): boolean
return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl);
}
function matchesDomain(url: string | undefined, domain: string): boolean {
if (!url) return false;
try {
const parsed = new URL(url);
return parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`);
} catch {
return false;
}
}
function matchesBindCriteria(tab: chrome.tabs.Tab, cmd: Command): boolean {
if (!tab.id || !isDebuggableUrl(tab.url)) return false;
if (cmd.matchDomain && !matchesDomain(tab.url, cmd.matchDomain)) return false;
if (cmd.matchPathPrefix) {
try {
const parsed = new URL(tab.url!);
if (!parsed.pathname.startsWith(cmd.matchPathPrefix)) return false;
} catch {
return false;
}
}
return true;
}
function setWorkspaceSession(workspace: string, session: Omit<AutomationSession, 'idleTimer' | 'idleDeadlineAt'>): void {
const existing = automationSessions.get(workspace);
if (existing?.idleTimer) clearTimeout(existing.idleTimer);
automationSessions.set(workspace, {
...session,
idleTimer: null,
idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT,
});
}
type ResolvedTab = { tabId: number; tab: chrome.tabs.Tab | null };
/**
* Resolve target tab in the automation window.
* If explicit tabId is given, use that directly.
* Otherwise, find or create a tab in the dedicated automation window.
* Resolve target tab in the automation window, returning both the tabId and
* the Tab object (when available) so callers can skip a redundant chrome.tabs.get().
*/
async function resolveTabId(tabId: number | undefined, workspace: string): Promise<number> {
async function resolveTab(tabId: number | undefined, workspace: string, initialUrl?: string): Promise<ResolvedTab> {
// Even when an explicit tabId is provided, validate it is still debuggable.
// This prevents issues when extensions hijack the tab URL to chrome-extension://
// or when the tab has been closed by the user.
if (tabId !== undefined) {
try {
const tab = await chrome.tabs.get(tabId);
const session = automationSessions.get(workspace);
if (isDebuggableUrl(tab.url) && session && tab.windowId === session.windowId) return tabId;
if (session && tab.windowId !== session.windowId) {
console.warn(`[opencli] Tab ${tabId} belongs to window ${tab.windowId}, not automation window ${session.windowId}, re-resolving`);
const matchesSession = session
? (session.preferredTabId !== null ? session.preferredTabId === tabId : tab.windowId === session.windowId)
: false;
if (isDebuggableUrl(tab.url) && matchesSession) return { tabId, tab };
if (session && !matchesSession && session.preferredTabId === null && isDebuggableUrl(tab.url)) {
// Tab drifted to another window but content is still valid.
// Try to move it back instead of abandoning it.
console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId}, moving back to ${session.windowId}`);
try {
await chrome.tabs.move(tabId, { windowId: session.windowId, index: -1 });
const moved = await chrome.tabs.get(tabId);
if (moved.windowId === session.windowId && isDebuggableUrl(moved.url)) {
return { tabId, tab: moved };
}
} catch (moveErr) {
console.warn(`[opencli] Failed to move tab back: ${moveErr}`);
}
} else if (!isDebuggableUrl(tab.url)) {
// Tab exists but URL is not debuggable — fall through to auto-resolve
console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`);
}
} catch {
// Tab was closed — fall through to auto-resolve
console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`);
}
}
const existingSession = automationSessions.get(workspace);
if (existingSession?.preferredTabId !== null) {
try {
const preferredTab = await chrome.tabs.get(existingSession.preferredTabId);
if (isDebuggableUrl(preferredTab.url)) return { tabId: preferredTab.id!, tab: preferredTab };
} catch {
automationSessions.delete(workspace);
}
}
// Get (or create) the automation window
const windowId = await getAutomationWindow(workspace);
const windowId = await getAutomationWindow(workspace, initialUrl);
// Prefer an existing debuggable tab
const tabs = await chrome.tabs.query({ windowId });
const debuggableTab = tabs.find(t => t.id && isDebuggableUrl(t.url));
if (debuggableTab?.id) return debuggableTab.id;
if (debuggableTab?.id) return { tabId: debuggableTab.id, tab: debuggableTab };
// No debuggable tab — another extension may have hijacked the tab URL.
// Try to reuse by navigating to a data: URI (not interceptable by New Tab Override).
const reuseTab = tabs.find(t => t.id);
if (reuseTab?.id) {
await chrome.tabs.update(reuseTab.id, { url: BLANK_PAGE });
await new Promise(resolve => setTimeout(resolve, 300));
try {
const updated = await chrome.tabs.get(reuseTab.id);
if (isDebuggableUrl(updated.url)) return reuseTab.id;
if (isDebuggableUrl(updated.url)) return { tabId: reuseTab.id, tab: updated };
console.warn(`[opencli] data: URI was intercepted (${updated.url}), creating fresh tab`);
} catch {
// Tab was closed during navigation
@@ -351,12 +449,26 @@ async function resolveTabId(tabId: number | undefined, workspace: string): Promi
// Fallback: create a new tab
const newTab = await chrome.tabs.create({ windowId, url: BLANK_PAGE, active: true });
if (!newTab.id) throw new Error('Failed to create tab in automation window');
return newTab.id;
return { tabId: newTab.id, tab: newTab };
}
/** Convenience wrapper returning just the tabId (used by most handlers) */
async function resolveTabId(tabId: number | undefined, workspace: string, initialUrl?: string): Promise<number> {
const resolved = await resolveTab(tabId, workspace, initialUrl);
return resolved.tabId;
}
async function listAutomationTabs(workspace: string): Promise<chrome.tabs.Tab[]> {
const session = automationSessions.get(workspace);
if (!session) return [];
if (session.preferredTabId !== null) {
try {
return [await chrome.tabs.get(session.preferredTabId)];
} catch {
automationSessions.delete(workspace);
return [];
}
}
try {
return await chrome.tabs.query({ windowId: session.windowId });
} catch {
@@ -374,7 +486,8 @@ async function handleExec(cmd: Command, workspace: string): Promise<Result> {
if (!cmd.code) return { id: cmd.id, ok: false, error: 'Missing code' };
const tabId = await resolveTabId(cmd.tabId, workspace);
try {
const data = await executor.evaluateAsync(tabId, cmd.code);
const aggressive = workspace.startsWith('operate:');
const data = await executor.evaluateAsync(tabId, cmd.code, aggressive);
return { id: cmd.id, ok: true, data };
} catch (err) {
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
@@ -386,9 +499,11 @@ async function handleNavigate(cmd: Command, workspace: string): Promise<Result>
if (!isSafeNavigationUrl(cmd.url)) {
return { id: cmd.id, ok: false, error: 'Blocked URL scheme -- only http:// and https:// are allowed' };
}
const tabId = await resolveTabId(cmd.tabId, workspace);
// Pass target URL so that first-time window creation can start on the right domain
const resolved = await resolveTab(cmd.tabId, workspace, cmd.url);
const tabId = resolved.tabId;
const beforeTab = await chrome.tabs.get(tabId);
const beforeTab = resolved.tab ?? await chrome.tabs.get(tabId);
const beforeNormalized = normalizeUrlForComparison(beforeTab.url);
const targetUrl = cmd.url;
@@ -459,7 +574,22 @@ async function handleNavigate(cmd: Command, workspace: string): Promise<Result>
}, 15000);
});
const tab = await chrome.tabs.get(tabId);
let tab = await chrome.tabs.get(tabId);
// Post-navigation drift detection: if the tab moved to another window
// during navigation (e.g. a tab-management extension regrouped it),
// try to move it back to maintain session isolation.
const session = automationSessions.get(workspace);
if (session && tab.windowId !== session.windowId) {
console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId} during navigation, moving back to ${session.windowId}`);
try {
await chrome.tabs.move(tabId, { windowId: session.windowId, index: -1 });
tab = await chrome.tabs.get(tabId);
} catch (moveErr) {
console.warn(`[opencli] Failed to recover drifted tab: ${moveErr}`);
}
}
return {
id: cmd.id,
ok: true,
@@ -565,13 +695,59 @@ async function handleScreenshot(cmd: Command, workspace: string): Promise<Result
}
}
/** CDP methods permitted via the 'cdp' passthrough action. */
const CDP_ALLOWLIST = new Set([
// Agent DOM context
'Accessibility.getFullAXTree',
'DOM.getDocument',
'DOM.getBoxModel',
'DOM.getContentQuads',
'DOM.querySelectorAll',
'DOM.scrollIntoViewIfNeeded',
'DOMSnapshot.captureSnapshot',
// Native input events
'Input.dispatchMouseEvent',
'Input.dispatchKeyEvent',
'Input.insertText',
// Page metrics & screenshots
'Page.getLayoutMetrics',
'Page.captureScreenshot',
// Runtime.enable needed for CDP attach setup (Runtime.evaluate goes through 'exec' action)
'Runtime.enable',
// Emulation (used by screenshot full-page)
'Emulation.setDeviceMetricsOverride',
'Emulation.clearDeviceMetricsOverride',
]);
async function handleCdp(cmd: Command, workspace: string): Promise<Result> {
if (!cmd.cdpMethod) return { id: cmd.id, ok: false, error: 'Missing cdpMethod' };
if (!CDP_ALLOWLIST.has(cmd.cdpMethod)) {
return { id: cmd.id, ok: false, error: `CDP method not permitted: ${cmd.cdpMethod}` };
}
const tabId = await resolveTabId(cmd.tabId, workspace);
try {
const aggressive = workspace.startsWith('operate:');
await executor.ensureAttached(tabId, aggressive);
const data = await chrome.debugger.sendCommand(
{ tabId },
cmd.cdpMethod,
cmd.cdpParams ?? {},
);
return { id: cmd.id, ok: true, data };
} catch (err) {
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
async function handleCloseWindow(cmd: Command, workspace: string): Promise<Result> {
const session = automationSessions.get(workspace);
if (session) {
try {
await chrome.windows.remove(session.windowId);
} catch {
// Window may already be closed
if (session.owned) {
try {
await chrome.windows.remove(session.windowId);
} catch {
// Window may already be closed
}
}
if (session.idleTimer) clearTimeout(session.idleTimer);
automationSessions.delete(workspace);
@@ -579,6 +755,52 @@ async function handleCloseWindow(cmd: Command, workspace: string): Promise<Resul
return { id: cmd.id, ok: true, data: { closed: true } };
}
async function handleSetFileInput(cmd: Command, workspace: string): Promise<Result> {
if (!cmd.files || !Array.isArray(cmd.files) || cmd.files.length === 0) {
return { id: cmd.id, ok: false, error: 'Missing or empty files array' };
}
const tabId = await resolveTabId(cmd.tabId, workspace);
try {
await executor.setFileInputFiles(tabId, cmd.files, cmd.selector);
return { id: cmd.id, ok: true, data: { count: cmd.files.length } };
} catch (err) {
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
async function handleInsertText(cmd: Command, workspace: string): Promise<Result> {
if (typeof cmd.text !== 'string') {
return { id: cmd.id, ok: false, error: 'Missing text payload' };
}
const tabId = await resolveTabId(cmd.tabId, workspace);
try {
await executor.insertText(tabId, cmd.text);
return { id: cmd.id, ok: true, data: { inserted: true } };
} catch (err) {
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
async function handleNetworkCaptureStart(cmd: Command, workspace: string): Promise<Result> {
const tabId = await resolveTabId(cmd.tabId, workspace);
try {
await executor.startNetworkCapture(tabId, cmd.pattern);
return { id: cmd.id, ok: true, data: { started: true } };
} catch (err) {
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
async function handleNetworkCaptureRead(cmd: Command, workspace: string): Promise<Result> {
const tabId = await resolveTabId(cmd.tabId, workspace);
try {
const data = await executor.readNetworkCapture(tabId);
return { id: cmd.id, ok: true, data };
} catch (err) {
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
async function handleSessions(cmd: Command): Promise<Result> {
const now = Date.now();
const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({
@@ -590,11 +812,52 @@ async function handleSessions(cmd: Command): Promise<Result> {
return { id: cmd.id, ok: true, data };
}
async function handleBindCurrent(cmd: Command, workspace: string): Promise<Result> {
const activeTabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
const fallbackTabs = await chrome.tabs.query({ lastFocusedWindow: true });
const allTabs = await chrome.tabs.query({});
const boundTab = activeTabs.find((tab) => matchesBindCriteria(tab, cmd))
?? fallbackTabs.find((tab) => matchesBindCriteria(tab, cmd))
?? allTabs.find((tab) => matchesBindCriteria(tab, cmd));
if (!boundTab?.id) {
return {
id: cmd.id,
ok: false,
error: cmd.matchDomain || cmd.matchPathPrefix
? `No visible tab matching ${cmd.matchDomain ?? 'domain'}${cmd.matchPathPrefix ? ` ${cmd.matchPathPrefix}` : ''}`
: 'No active debuggable tab found',
};
}
setWorkspaceSession(workspace, {
windowId: boundTab.windowId,
owned: false,
preferredTabId: boundTab.id,
});
resetWindowIdleTimer(workspace);
console.log(`[opencli] Workspace ${workspace} explicitly bound to tab ${boundTab.id} (${boundTab.url})`);
return {
id: cmd.id,
ok: true,
data: {
tabId: boundTab.id,
windowId: boundTab.windowId,
url: boundTab.url,
title: boundTab.title,
workspace,
},
};
}
export const __test__ = {
handleNavigate,
isTargetUrl,
handleTabs,
handleSessions,
handleBindCurrent,
resolveTabId,
resetWindowIdleTimer,
getSession: (workspace: string = 'default') => automationSessions.get(workspace) ?? null,
getAutomationWindowId: (workspace: string = 'default') => automationSessions.get(workspace)?.windowId ?? null,
setAutomationWindowId: (workspace: string, windowId: number | null) => {
if (windowId === null) {
@@ -603,10 +866,13 @@ export const __test__ = {
automationSessions.delete(workspace);
return;
}
automationSessions.set(workspace, {
setWorkspaceSession(workspace, {
windowId,
idleTimer: null,
idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT,
owned: true,
preferredTabId: null,
});
},
setSession: (workspace: string, session: { windowId: number; owned: boolean; preferredTabId: number | null }) => {
setWorkspaceSession(workspace, session);
},
};
+75
View File
@@ -0,0 +1,75 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
function createChromeMock() {
const tabs = {
get: vi.fn(async (_tabId: number) => ({
id: 1,
windowId: 1,
url: 'https://x.com/home',
})),
onRemoved: { addListener: vi.fn() },
onUpdated: { addListener: vi.fn() },
};
const debuggerApi = {
attach: vi.fn(async () => {}),
detach: vi.fn(async () => {}),
sendCommand: vi.fn(async (_target: unknown, method: string) => {
if (method === 'Runtime.evaluate') return { result: { value: 'ok' } };
return {};
}),
onDetach: { addListener: vi.fn() },
};
const scripting = {
executeScript: vi.fn(async () => [{ result: { removed: 1 } }]),
};
return {
chrome: {
tabs,
debugger: debuggerApi,
scripting,
runtime: { id: 'opencli-test' },
},
debuggerApi,
scripting,
};
}
describe('cdp attach recovery', () => {
beforeEach(() => {
vi.resetModules();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('does not mutate the DOM before a successful attach', async () => {
const { chrome, debuggerApi, scripting } = createChromeMock();
vi.stubGlobal('chrome', chrome);
const mod = await import('./cdp');
const result = await mod.evaluate(1, '1');
expect(result).toBe('ok');
expect(debuggerApi.attach).toHaveBeenCalledTimes(1);
expect(scripting.executeScript).not.toHaveBeenCalled();
});
it('retries after cleanup when attach fails with a foreign extension error', async () => {
const { chrome, debuggerApi, scripting } = createChromeMock();
debuggerApi.attach
.mockRejectedValueOnce(new Error('Cannot access a chrome-extension:// URL of different extension'))
.mockResolvedValueOnce(undefined);
vi.stubGlobal('chrome', chrome);
const mod = await import('./cdp');
const result = await mod.evaluate(1, '1');
expect(result).toBe('ok');
expect(scripting.executeScript).toHaveBeenCalledTimes(1);
expect(debuggerApi.attach).toHaveBeenCalledTimes(2);
});
});
+309 -38
View File
@@ -8,16 +8,34 @@
const attached = new Set<number>();
/** Internal blank page used when no user URL is provided. */
const BLANK_PAGE = 'data:text/html,<html></html>';
type NetworkCaptureEntry = {
kind: 'cdp';
url: string;
method: string;
requestHeaders?: Record<string, string>;
requestBodyKind?: string;
requestBodyPreview?: string;
responseStatus?: number;
responseContentType?: string;
responseHeaders?: Record<string, string>;
responsePreview?: string;
timestamp: number;
};
/** Check if a URL can be attached via CDP — only allow http(s) and our internal blank page. */
type NetworkCaptureState = {
patterns: string[];
entries: NetworkCaptureEntry[];
requestToIndex: Map<string, number>;
};
const networkCaptures = new Map<number, NetworkCaptureState>();
/** Check if a URL can be attached via CDP — only allow http(s) and blank pages. */
function isDebuggableUrl(url?: string): boolean {
if (!url) return true; // empty/undefined = tab still loading, allow it
return url.startsWith('http://') || url.startsWith('https://') || url === BLANK_PAGE;
return url.startsWith('http://') || url.startsWith('https://') || url === 'about:blank' || url.startsWith('data:');
}
async function ensureAttached(tabId: number): Promise<void> {
export async function ensureAttached(tabId: number, aggressiveRetry: boolean = false): Promise<void> {
// Verify the tab URL is debuggable before attempting attach
try {
const tab = await chrome.tabs.get(tabId);
@@ -46,23 +64,56 @@ async function ensureAttached(tabId: number): Promise<void> {
}
}
try {
await chrome.debugger.attach({ tabId }, '1.3');
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
const hint = msg.includes('chrome-extension://')
// Retry attach up to 3 times — other extensions (1Password, Playwright MCP Bridge)
// can temporarily interfere with chrome.debugger. A short delay usually resolves it.
// Normal commands: 2 retries, 500ms delay (fast fail for non-operate use)
// Operate commands: 5 retries, 1500ms delay (aggressive, tolerates extension interference)
const MAX_ATTACH_RETRIES = aggressiveRetry ? 5 : 2;
const RETRY_DELAY_MS = aggressiveRetry ? 1500 : 500;
let lastError = '';
for (let attempt = 1; attempt <= MAX_ATTACH_RETRIES; attempt++) {
try {
// Force detach first to clear any stale state from other extensions
try { await chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
await chrome.debugger.attach({ tabId }, '1.3');
lastError = '';
break; // Success
} catch (e: unknown) {
lastError = e instanceof Error ? e.message : String(e);
if (attempt < MAX_ATTACH_RETRIES) {
console.warn(`[opencli] attach attempt ${attempt}/${MAX_ATTACH_RETRIES} failed: ${lastError}, retrying in ${RETRY_DELAY_MS}ms...`);
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS));
// Re-verify tab URL before retrying (it may have changed)
try {
const tab = await chrome.tabs.get(tabId);
if (!isDebuggableUrl(tab.url)) {
lastError = `Tab URL changed to ${tab.url} during retry`;
break; // Don't retry if URL became un-debuggable
}
} catch {
lastError = `Tab ${tabId} no longer exists`;
break;
}
}
}
}
if (lastError) {
// Log detailed diagnostics for debugging extension conflicts
let finalUrl = 'unknown';
let finalWindowId = 'unknown';
try {
const tab = await chrome.tabs.get(tabId);
finalUrl = tab.url ?? 'undefined';
finalWindowId = String(tab.windowId);
} catch { /* tab gone */ }
console.warn(`[opencli] attach failed for tab ${tabId}: url=${finalUrl}, windowId=${finalWindowId}, error=${lastError}`);
const hint = lastError.includes('chrome-extension://')
? '. Tip: another Chrome extension may be interfering — try disabling other extensions'
: '';
if (msg.includes('Another debugger is already attached')) {
try { await chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
try {
await chrome.debugger.attach({ tabId }, '1.3');
} catch {
throw new Error(`attach failed: ${msg}${hint}`);
}
} else {
throw new Error(`attach failed: ${msg}${hint}`);
}
throw new Error(`attach failed: ${lastError}${hint}`);
}
attached.add(tabId);
@@ -73,26 +124,48 @@ async function ensureAttached(tabId: number): Promise<void> {
}
}
export async function evaluate(tabId: number, expression: string): Promise<unknown> {
await ensureAttached(tabId);
export async function evaluate(tabId: number, expression: string, aggressiveRetry: boolean = false): Promise<unknown> {
// Retry the entire evaluate (attach + command).
// Normal: 2 retries. Operate: 3 retries (tolerates extension interference).
const MAX_EVAL_RETRIES = aggressiveRetry ? 3 : 2;
for (let attempt = 1; attempt <= MAX_EVAL_RETRIES; attempt++) {
try {
await ensureAttached(tabId, aggressiveRetry);
const result = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
expression,
returnByValue: true,
awaitPromise: true,
}) as {
result?: { type: string; value?: unknown; description?: string; subtype?: string };
exceptionDetails?: { exception?: { description?: string }; text?: string };
};
const result = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
expression,
returnByValue: true,
awaitPromise: true,
}) as {
result?: { type: string; value?: unknown; description?: string; subtype?: string };
exceptionDetails?: { exception?: { description?: string }; text?: string };
};
if (result.exceptionDetails) {
const errMsg = result.exceptionDetails.exception?.description
|| result.exceptionDetails.text
|| 'Eval error';
throw new Error(errMsg);
if (result.exceptionDetails) {
const errMsg = result.exceptionDetails.exception?.description
|| result.exceptionDetails.text
|| 'Eval error';
throw new Error(errMsg);
}
return result.result?.value;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
// Only retry on attach/debugger errors, not on JS eval errors
const isNavigateError = msg.includes('Inspected target navigated') || msg.includes('Target closed');
const isAttachError = isNavigateError || msg.includes('attach failed') || msg.includes('Debugger is not attached')
|| msg.includes('chrome-extension://');
if (isAttachError && attempt < MAX_EVAL_RETRIES) {
attached.delete(tabId); // Force re-attach on next attempt
// SPA navigations recover quickly; debugger detach needs longer
const retryMs = isNavigateError ? 200 : 500;
await new Promise(resolve => setTimeout(resolve, retryMs));
continue;
}
throw e;
}
}
return result.result?.value;
throw new Error('evaluate: max retries exhausted');
}
export const evaluateAsync = evaluate;
@@ -147,18 +220,142 @@ export async function screenshot(
}
}
/**
* Set local file paths on a file input element via CDP DOM.setFileInputFiles.
* This bypasses the need to send large base64 payloads through the message channel —
* Chrome reads the files directly from the local filesystem.
*
* @param tabId - Target tab ID
* @param files - Array of absolute local file paths
* @param selector - CSS selector to find the file input (optional, defaults to first file input)
*/
export async function setFileInputFiles(
tabId: number,
files: string[],
selector?: string,
): Promise<void> {
await ensureAttached(tabId);
// Enable DOM domain (required for DOM.querySelector and DOM.setFileInputFiles)
await chrome.debugger.sendCommand({ tabId }, 'DOM.enable');
// Get the document root
const doc = await chrome.debugger.sendCommand({ tabId }, 'DOM.getDocument') as {
root: { nodeId: number };
};
// Find the file input element
const query = selector || 'input[type="file"]';
const result = await chrome.debugger.sendCommand({ tabId }, 'DOM.querySelector', {
nodeId: doc.root.nodeId,
selector: query,
}) as { nodeId: number };
if (!result.nodeId) {
throw new Error(`No element found matching selector: ${query}`);
}
// Set files directly via CDP — Chrome reads from local filesystem
await chrome.debugger.sendCommand({ tabId }, 'DOM.setFileInputFiles', {
files,
nodeId: result.nodeId,
});
}
export async function insertText(
tabId: number,
text: string,
): Promise<void> {
await ensureAttached(tabId);
await chrome.debugger.sendCommand({ tabId }, 'Input.insertText', { text });
}
function normalizeCapturePatterns(pattern?: string): string[] {
return String(pattern || '')
.split('|')
.map((part) => part.trim())
.filter(Boolean);
}
function shouldCaptureUrl(url: string | undefined, patterns: string[]): boolean {
if (!url) return false;
if (!patterns.length) return true;
return patterns.some((pattern) => url.includes(pattern));
}
function normalizeHeaders(headers: unknown): Record<string, string> {
if (!headers || typeof headers !== 'object') return {};
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
out[String(key)] = String(value);
}
return out;
}
function getOrCreateNetworkCaptureEntry(tabId: number, requestId: string, fallback?: {
url?: string;
method?: string;
requestHeaders?: Record<string, string>;
}): NetworkCaptureEntry | null {
const state = networkCaptures.get(tabId);
if (!state) return null;
const existingIndex = state.requestToIndex.get(requestId);
if (existingIndex !== undefined) {
return state.entries[existingIndex] || null;
}
const url = fallback?.url || '';
if (!shouldCaptureUrl(url, state.patterns)) return null;
const entry: NetworkCaptureEntry = {
kind: 'cdp',
url,
method: fallback?.method || 'GET',
requestHeaders: fallback?.requestHeaders || {},
timestamp: Date.now(),
};
state.entries.push(entry);
state.requestToIndex.set(requestId, state.entries.length - 1);
return entry;
}
export async function startNetworkCapture(
tabId: number,
pattern?: string,
): Promise<void> {
await ensureAttached(tabId);
await chrome.debugger.sendCommand({ tabId }, 'Network.enable');
networkCaptures.set(tabId, {
patterns: normalizeCapturePatterns(pattern),
entries: [],
requestToIndex: new Map(),
});
}
export async function readNetworkCapture(tabId: number): Promise<NetworkCaptureEntry[]> {
const state = networkCaptures.get(tabId);
if (!state) return [];
const entries = state.entries.slice();
state.entries = [];
state.requestToIndex.clear();
return entries;
}
export async function detach(tabId: number): Promise<void> {
if (!attached.has(tabId)) return;
attached.delete(tabId);
networkCaptures.delete(tabId);
try { await chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
}
export function registerListeners(): void {
chrome.tabs.onRemoved.addListener((tabId) => {
attached.delete(tabId);
networkCaptures.delete(tabId);
});
chrome.debugger.onDetach.addListener((source) => {
if (source.tabId) attached.delete(source.tabId);
if (source.tabId) {
attached.delete(source.tabId);
networkCaptures.delete(source.tabId);
}
});
// Invalidate attached cache when tab URL changes to non-debuggable
chrome.tabs.onUpdated.addListener(async (tabId, info) => {
@@ -166,4 +363,78 @@ export function registerListeners(): void {
await detach(tabId);
}
});
chrome.debugger.onEvent.addListener(async (source, method, params) => {
const tabId = source.tabId;
if (!tabId) return;
const state = networkCaptures.get(tabId);
if (!state) return;
if (method === 'Network.requestWillBeSent') {
const requestId = String(params?.requestId || '');
const request = params?.request as {
url?: string;
method?: string;
headers?: Record<string, unknown>;
postData?: string;
hasPostData?: boolean;
} | undefined;
const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, {
url: request?.url,
method: request?.method,
requestHeaders: normalizeHeaders(request?.headers),
});
if (!entry) return;
entry.requestBodyKind = request?.hasPostData ? 'string' : 'empty';
entry.requestBodyPreview = String(request?.postData || '').slice(0, 4000);
try {
const postData = await chrome.debugger.sendCommand({ tabId }, 'Network.getRequestPostData', { requestId }) as { postData?: string };
if (postData?.postData) {
entry.requestBodyKind = 'string';
entry.requestBodyPreview = postData.postData.slice(0, 4000);
}
} catch {
// Optional; some requests do not expose postData.
}
return;
}
if (method === 'Network.responseReceived') {
const requestId = String(params?.requestId || '');
const response = params?.response as {
url?: string;
mimeType?: string;
status?: number;
headers?: Record<string, unknown>;
} | undefined;
const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, {
url: response?.url,
});
if (!entry) return;
entry.responseStatus = response?.status;
entry.responseContentType = response?.mimeType || '';
entry.responseHeaders = normalizeHeaders(response?.headers);
return;
}
if (method === 'Network.loadingFinished') {
const requestId = String(params?.requestId || '');
const stateEntryIndex = state.requestToIndex.get(requestId);
if (stateEntryIndex === undefined) return;
const entry = state.entries[stateEntryIndex];
if (!entry) return;
try {
const body = await chrome.debugger.sendCommand({ tabId }, 'Network.getResponseBody', { requestId }) as {
body?: string;
base64Encoded?: boolean;
};
if (typeof body?.body === 'string') {
entry.responsePreview = body.base64Encoded
? `base64:${body.body.slice(0, 4000)}`
: body.body.slice(0, 4000);
}
} catch {
// Optional; bodies are unavailable for some requests (e.g. uploads).
}
}
});
}
+32 -5
View File
@@ -5,7 +5,20 @@
* Everything else is just JS code sent via 'exec'.
*/
export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions';
export type Action =
| 'exec'
| 'navigate'
| 'tabs'
| 'cookies'
| 'screenshot'
| 'close-window'
| 'sessions'
| 'set-file-input'
| 'insert-text'
| 'bind-current'
| 'network-capture-start'
| 'network-capture-read'
| 'cdp';
export interface Command {
/** Unique request ID */
@@ -26,12 +39,28 @@ export interface Command {
index?: number;
/** Cookie domain filter */
domain?: string;
/** Optional hostname/domain to require for current-tab binding */
matchDomain?: string;
/** Optional pathname prefix to require for current-tab binding */
matchPathPrefix?: string;
/** Screenshot format: png (default) or jpeg */
format?: 'png' | 'jpeg';
/** JPEG quality (0-100), only for jpeg format */
quality?: number;
/** Whether to capture full page (not just viewport) */
fullPage?: boolean;
/** Local file paths for set-file-input action */
files?: string[];
/** CSS selector for file input element (set-file-input action) */
selector?: string;
/** Raw text payload for insert-text action */
text?: string;
/** URL substring filter pattern for network capture actions */
pattern?: string;
/** CDP method name for 'cdp' action (e.g. 'Accessibility.getFullAXTree') */
cdpMethod?: string;
/** CDP method params for 'cdp' action */
cdpParams?: Record<string, unknown>;
}
export interface Result {
@@ -54,7 +83,5 @@ export const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`;
/** Base reconnect delay for extension WebSocket (ms) */
export const WS_RECONNECT_BASE_DELAY = 2000;
/** Max reconnect delay (ms) */
export const WS_RECONNECT_MAX_DELAY = 60000;
/** Idle timeout before daemon auto-exits (ms) */
export const DAEMON_IDLE_TIMEOUT = 5 * 60 * 1000;
/** Max reconnect delay (ms) — kept short since daemon is long-lived */
export const WS_RECONNECT_MAX_DELAY = 5000;
+12 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@jackwener/opencli",
"version": "1.5.4",
"version": "1.6.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@jackwener/opencli",
"version": "1.5.4",
"version": "1.6.1",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -196,6 +196,7 @@
"integrity": "sha512-y1IOpG6OSmTpGg/CT0YBb/EAhR2nsC18QWp9Jy8HO9iGySpcwaTvs5kHa17daP3BMTwWyaX9/1tDTDQshZzXdg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/client-common": "5.49.2",
"@algolia/requester-browser-xhr": "5.49.2",
@@ -2163,6 +2164,7 @@
"integrity": "sha512-1K0wtDaRONwfhL4h8bbJ9qTjmY6rhGgRvvagXkMBsAOMNr+3Q2SffHECh9DIuNVrMA1JwA0zCwhyepgBZVakng==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/abtesting": "1.15.2",
"@algolia/client-abtesting": "5.49.2",
@@ -2491,6 +2493,7 @@
"integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"tabbable": "^6.4.0"
}
@@ -2619,6 +2622,7 @@
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
"license": "MPL-2.0",
"peer": true,
"dependencies": {
"detect-libc": "^2.0.3"
},
@@ -3090,6 +3094,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -3478,6 +3483,7 @@
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
@@ -3507,6 +3513,7 @@
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -3640,6 +3647,7 @@
"integrity": "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.3",
@@ -4204,6 +4212,7 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -4346,6 +4355,7 @@
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.30",
"@vue/compiler-sfc": "3.5.30",
+3 -2
View File
@@ -1,10 +1,10 @@
{
"name": "@jackwener/opencli",
"version": "1.5.5",
"version": "1.6.1",
"publishConfig": {
"access": "public"
},
"description": "Make any website your CLI. AI-powered.",
"description": "Make any website or Electron App your CLI. AI-powered.",
"engines": {
"node": ">=20.0.0"
},
@@ -30,6 +30,7 @@
"postinstall": "node scripts/postinstall.js || true",
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit",
"prepare": "[ -d src ] && npm run build || true",
"prepublishOnly": "npm run build",
"test": "vitest run --project unit",
"test:bun": "bun vitest run --project unit",
+16
View File
@@ -196,6 +196,22 @@ function main() {
}
}
// ── Spotify credentials template ────────────────────────────────────
const opencliDir = join(home, '.opencli');
const spotifyEnvFile = join(opencliDir, 'spotify.env');
ensureDir(opencliDir);
if (!existsSync(spotifyEnvFile)) {
writeFileSync(spotifyEnvFile,
`# Spotify credentials — get them at https://developer.spotify.com/dashboard\n` +
`# Add http://127.0.0.1:8888/callback as a Redirect URI in your Spotify app\n` +
`SPOTIFY_CLIENT_ID=your_spotify_client_id_here\n` +
`SPOTIFY_CLIENT_SECRET=your_spotify_client_secret_here\n`,
'utf8'
);
console.log(`✓ Spotify credentials template created at ${spotifyEnvFile}`);
console.log(` Edit the file and add your Client ID and Secret, then run: opencli spotify auth`);
}
// ── Browser Bridge setup hint ───────────────────────────────────────
console.log('');
console.log(' \x1b[1mNext step — Browser Bridge setup\x1b[0m');
+853
View File
@@ -0,0 +1,853 @@
---
name: opencli-explorer
description: Use when creating a new OpenCLI adapter from scratch, adding support for a new website or platform, or exploring a site's API endpoints via browser DevTools. Covers API discovery workflow, authentication strategy selection, YAML/TS adapter writing, and testing.
tags: [opencli, adapter, browser, api-discovery, cli, web-scraping, automation]
---
# CLI-EXPLORER — 适配器探索式开发完全指南
> 本文档教你(或 AI Agent)如何为 OpenCLI 添加一个新网站的命令。
> 从零到发布,覆盖 API 发现、方案选择、适配器编写、测试验证全流程。
> [!TIP]
> **只想为一个具体页面快速生成一个命令?** 看 [CLI-ONESHOT.md](./CLI-ONESHOT.md)~150 行,4 步搞定)。
> 本文档适合从零探索一个新站点的完整流程。
---
## AI Agent 开发者必读:用浏览器探索
> [!CAUTION]
> **你(AI Agent)必须通过浏览器打开目标网站去探索!**
> 不要只靠 `opencli explore` 命令或静态分析来发现 API。
> 你拥有浏览器工具,必须主动用它们浏览网页、观察网络请求、模拟用户交互。
### 为什么?
很多 API 是**懒加载**的(用户必须点击某个按钮/标签才会触发网络请求)。字幕、评论、关注列表等深层数据不会在页面首次加载时出现在 Network 面板中。**如果你不主动去浏览和交互页面,你永远发现不了这些 API。**
### AI Agent 探索工作流(必须遵循)
| 步骤 | 工具 | 做什么 |
|------|------|--------|
| 0. 打开浏览器 | `browser_navigate` | 导航到目标页面 |
| 1. 观察页面 | `browser_snapshot` | 观察可交互元素(按钮/标签/链接) |
| 2. 首次抓包 | `browser_network_requests` | 筛选 JSON API 端点,记录 URL pattern |
| 3. 模拟交互 | `browser_click` + `browser_wait_for` | 点击"字幕""评论""关注"等按钮 |
| 4. 二次抓包 | `browser_network_requests` | 对比步骤 2,找出新触发的 API |
| 5. 验证 API | `browser_evaluate` | `fetch(url, {credentials:'include'})` 测试返回结构 |
| 6. 写代码 | — | 基于确认的 API 写适配器 |
### 常犯错误
| ❌ 错误做法 | ✅ 正确做法 |
|------------|------------|
| 只用 `opencli explore` 命令,等结果自动出来 | 用浏览器工具打开页面,主动浏览 |
| 直接在代码里 `fetch(url)`,不看浏览器实际请求 | 先在浏览器中确认 API 可用,再写代码 |
| 页面打开后直接抓包,期望所有 API 都出现 | 模拟点击交互(展开评论/切换标签/加载更多) |
| 遇到 HTTP 200 但空数据就放弃 | 检查是否需要 Wbi 签名或 Cookie 鉴权 |
| 完全依赖 `__INITIAL_STATE__` 拿所有数据 | `__INITIAL_STATE__` 只有首屏数据,深层数据要调 API |
### 实战成功案例:5 分钟实现「关注列表」适配器
以下是用上述工作流实际发现 Bilibili 关注列表 API 的完整过程:
```
1. browser_navigate → https://space.bilibili.com/{uid}/fans/follow
2. browser_network_requests → 发现:
GET /x/relation/followings?vmid={uid}&pn=1&ps=24 → [200]
GET /x/relation/stat?vmid={uid} → [200]
3. browser_evaluate → 验证 API:
fetch('/x/relation/followings?vmid=137702077&pn=1&ps=5', {credentials:'include'})
→ { code: 0, data: { total: 1342, list: [{mid, uname, sign, ...}] } }
4. 结论:标准 Cookie API,无需 Wbi 签名
5. 写 following.ts → 一次构建通过
```
**关键决策点**
- 直接访问 `fans/follow` 页面(不是首页),页面加载就会触发 following API
- 看到 URL 里没有 `/wbi/` → 不需要签名 → 直接用 `fetchJson` 而非 `apiGet`
- API 返回 `code: 0` + 非空 `list` → Tier 2 Cookie 策略确认
---
## 核心流程
```
┌─────────────┐ ┌─────────────┐ ┌──────────────┐ ┌────────┐
│ 1. 发现 API │ ──▶ │ 2. 选择策略 │ ──▶ │ 3. 写适配器 │ ──▶ │ 4. 测试 │
└─────────────┘ └─────────────┘ └──────────────┘ └────────┘
explore cascade YAML / TS run + verify
```
---
## Step 1: 发现 API
### 1a. 自动化发现(推荐)
OpenCLI 内置 Deep Explore,自动分析网站网络请求:
```bash
opencli explore https://www.example.com --site mysite
```
输出到 `.opencli/explore/mysite/`
| 文件 | 内容 |
|------|------|
| `manifest.json` | 站点元数据、框架检测(Vue2/3、React、Next.js、Pinia、Vuex |
| `endpoints.json` | 已发现的 API 端点,按评分排序,含 URL pattern、方法、响应类型 |
| `capabilities.json` | 推理出的功能(`hot``search``feed`…),含置信度和推荐参数 |
| `auth.json` | 认证方式检测(Cookie/Header/无认证),策略候选列表 |
### 1b. 手动抓包验证
Explore 的自动分析可能不完美,用 verbose 模式手动确认:
```bash
# 在浏览器中打开目标页面,观察网络请求
opencli explore https://www.example.com --site mysite -v
# 或直接用 evaluate 测试 API
opencli bilibili hot -v # 查看已有命令的 pipeline 每步数据流
```
关注抓包结果中的关键信息:
- **URL pattern**: `/api/v2/hot?limit=20` → 这就是你要调用的端点
- **Method**: `GET` / `POST`
- **Request Headers**: Cookie? Bearer? 自定义签名头(X-s、X-t?
- **Response Body**: JSON 结构,特别是数据在哪个路径(`data.items``data.list`
### 1c. 高阶 API 发现捷径法则 (Heuristics)
在开始死磕复杂的抓包拦截之前,按照以下优先级进行尝试:
1. **后缀爆破法 (`.json`)**: 像 Reddit 这样复杂的网站,只要在其 URL 后加上 `.json`(例如 `/r/all.json`),就能在带 Cookie 的情况下直接利用 `fetch` 拿到极其干净的 REST 数据(Tier 2 Cookie 策略极速秒杀)。另外如功能完备的**雪球 (xueqiu)** 也可以走这种纯 API 的方式极简获取,成为你构建简单 YAML 的黄金标杆。
2. **全局状态查找法 (`__INITIAL_STATE__`)**: 许多服务端渲染 (SSR) 的网站(如小红书、Bilibili)会将首页或详情页的完整数据挂载到全局 window 对象上。与其去拦截网络请求,不如直接 `page.evaluate('() => window.__INITIAL_STATE__')` 获取整个数据树。
3. **主动交互触发法 (Active Interaction)**: 很多深层 API(如视频字幕、评论下的回复)是懒加载的。在静态抓包找不到数据时,尝试在 `evaluate` 步骤或手动打断点时,主动去**点击(Click)页面上的对应按钮**(如"CC"、"展开全部"),从而诱发隐藏的 Network Fetch。
4. **框架探测与 Store Action 截断**: 如果站点使用 Vue + Pinia,可以使用 `tap` 步骤调用 action,让前端框架代替你完成复杂的鉴权签名封装。
5. **底层 XHR/Fetch 拦截**: 最后手段,当上述都不行时,使用 TypeScript 适配器进行无侵入式的请求抓取。
### 1d. 框架检测
Explore 自动检测前端框架。如果需要手动确认:
```bash
# 在已打开目标网站的情况下
opencli evaluate "(()=>{
const vue3 = !!document.querySelector('#app')?.__vue_app__;
const vue2 = !!document.querySelector('#app')?.__vue__;
const react = !!window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
const pinia = vue3 && !!document.querySelector('#app').__vue_app__.config.globalProperties.\$pinia;
return JSON.stringify({vue3, vue2, react, pinia});
})()"
```
Vue + Pinia 的站点(如小红书)可以直接通过 Store Action 绕过签名。
---
## Step 2: 选择认证策略
OpenCLI 提供 5 级认证策略。使用 `cascade` 命令自动探测:
```bash
opencli cascade https://api.example.com/hot
```
### 策略决策树
```
直接 fetch(url) 能拿到数据?
→ ✅ Tier 1: public(公开 API,不需要浏览器)
→ ❌ fetch(url, {credentials:'include'}) 带 Cookie 能拿到?
→ ✅ Tier 2: cookie(最常见,evaluate 步骤内 fetch
→ ❌ → 加上 Bearer / CSRF header 后能拿到?
→ ✅ Tier 3: header(如 Twitter ct0 + Bearer
→ ❌ → 网站有 Pinia/Vuex Store
→ ✅ Tier 4: interceptStore Action + XHR 拦截)
→ ❌ Tier 5: ui(UI 自动化,最后手段)
```
### 各策略对比
| Tier | 策略 | 速度 | 复杂度 | 适用场景 | 实例 |
|------|------|------|--------|---------|------|
| 1 | `public` | ⚡ ~1s | 最简 | 公开 API,无需登录 | Hacker News, V2EX |
| 2 | `cookie` | 🔄 ~7s | 简单 | Cookie 认证即可 | Bilibili, Zhihu, Reddit |
| 3 | `header` | 🔄 ~7s | 中等 | 需要 CSRF token 或 Bearer | Twitter GraphQL |
| 4 | `intercept` | 🔄 ~10s | 较高 | 请求有复杂签名 | 小红书 (Pinia + XHR) |
| 5 | `ui` | 🐌 ~15s+ | 最高 | 无 API,纯 DOM 解析 | 遗留网站 |
---
## Step 2.5: 准备工作(写代码之前)
### 先找模板:从最相似的现有适配器开始
**不要从零开始写**。先看看同站点已有哪些适配器:
```bash
ls src/clis/<site>/ # 看看已有什么
cat src/clis/<site>/feed.ts # 读最相似的那个
```
最高效的方式是 **复制最相似的适配器,然后改 3 个地方**
1. `name` → 新命令名
2. API URL → 你在 Step 1 发现的端点
3. 字段映射 → 对应新 API 的字段
### 平台 SDK 速查表
写 TS 适配器之前,先看看你的目标站点有没有**现成的 helper 函数**可以复用:
#### Bilibili (`src/clis/bilibili/utils.ts`)
| 函数 | 用途 | 何时使用 |
|------|------|----------|
| `fetchJson(page, url)` | 带 Cookie 的 fetch + JSON 解析 | 普通 Cookie-tier API |
| `apiGet(page, path, {signed, params})` | 带 Wbi 签名的 API 调用 | URL 含 `/wbi/` 的接口 |
| `getSelfUid(page)` | 获取当前登录用户的 UID | "我的xxx" 类命令 |
| `resolveUid(page, input)` | 解析用户输入的 UID(支持数字/URL) | `--uid` 参数处理 |
| `wbiSign(page, params)` | 底层 Wbi 签名生成 | 通常不直接用,`apiGet` 已封装 |
| `stripHtml(s)` | 去除 HTML 标签 | 清理富文本字段 |
**如何判断需不需要 `apiGet`**?看 Network 请求 URL
-`/wbi/``w_rid=` → 必须用 `apiGet(..., { signed: true })`
- 不含 → 直接用 `fetchJson`
> 其他站点(Twitter、小红书等)暂无专用 SDK,直接用 `page.evaluate` + `fetch` 即可。
---
## Step 3: 编写适配器
### YAML vs TS?先看决策树
```
你的 pipeline 里有 evaluate 步骤(内嵌 JS 代码)?
→ ✅ 用 TypeScript (src/clis/<site>/<name>.ts),保存即自动动态注册
→ ❌ 纯声明式(navigate + tap + map + limit)?
→ ✅ 用 YAML (src/clis/<site>/<name>.yaml),保存即自动注册
```
| 场景 | 选择 | 示例 |
|------|------|------|
| 纯 fetch/select/map/limit | YAML | `v2ex/hot.yaml`, `hackernews/top.yaml` |
| navigate + evaluate(fetch) + map | YAML(评估复杂度) | `zhihu/hot.yaml` |
| navigate + tap + map | YAML ✅ | `xiaohongshu/feed.yaml`, `xiaohongshu/notifications.yaml` |
| 有复杂 JS 逻辑(Pinia state 读取、条件分支) | TS | `xiaohongshu/me.ts`, `bilibili/me.ts` |
| XHR 拦截 + 签名 | TS | `xiaohongshu/search.ts` |
| GraphQL / 分页 / Wbi 签名 | TS | `bilibili/search.ts`, `twitter/search.ts` |
> **经验法则**:如果你发现 YAML 里嵌了超过 10 行 JS,改用 TS 更可维护。
### 通用模式:分页 API
很多 API 使用 `pn`(页码)+ `ps`(每页数量)分页。标准处理模式:
```typescript
args: [
{ name: 'page', type: 'int', required: false, default: 1, help: '页码' },
{ name: 'limit', type: 'int', required: false, default: 50, help: '每页数量 (最大 50)' },
],
func: async (page, kwargs) => {
const pn = kwargs.page ?? 1;
const ps = Math.min(kwargs.limit ?? 50, 50); // 尊重 API 的 ps 上限
const payload = await fetchJson(page,
`https://api.example.com/list?pn=${pn}&ps=${ps}`
);
return payload.data?.list || [];
},
```
> 大多数站点的 `ps` 上限是 20~50。超过会被静默截断或返回错误。
### 方式 A: YAML Pipeline(声明式,推荐)
文件路径: `src/clis/<site>/<name>.yaml`,放入即自动注册。
#### Tier 1 — 公开 API 模板
```yaml
# src/clis/v2ex/hot.yaml
site: v2ex
name: hot
description: V2EX 热门话题
domain: www.v2ex.com
strategy: public
browser: false
args:
limit:
type: int
default: 20
pipeline:
- fetch:
url: https://www.v2ex.com/api/topics/hot.json
- map:
rank: ${{ index + 1 }}
title: ${{ item.title }}
replies: ${{ item.replies }}
- limit: ${{ args.limit }}
columns: [rank, title, replies]
```
#### Tier 2 — Cookie 认证模板(最常用)
```yaml
# src/clis/zhihu/hot.yaml
site: zhihu
name: hot
description: 知乎热榜
domain: www.zhihu.com
pipeline:
- navigate: https://www.zhihu.com # 先加载页面建立 session
- evaluate: | # 在浏览器内发请求,自动带 Cookie
(async () => {
const res = await fetch('/api/v3/feed/topstory/hot-lists/total?limit=50', {
credentials: 'include'
});
const d = await res.json();
return (d?.data || []).map(item => {
const t = item.target || {};
return {
title: t.title,
heat: item.detail_text || '',
answers: t.answer_count,
};
});
})()
- map:
rank: ${{ index + 1 }}
title: ${{ item.title }}
heat: ${{ item.heat }}
answers: ${{ item.answers }}
- limit: ${{ args.limit }}
columns: [rank, title, heat, answers]
```
> **关键**: `evaluate` 步骤内的 `fetch` 运行在浏览器页面内,自动携带 `credentials: 'include'`,无需手动处理 Cookie。
#### 进阶 — 带搜索参数
```yaml
# src/clis/zhihu/search.yaml
site: zhihu
name: search
description: 知乎搜索
args:
query:
type: str
required: true
positional: true
description: Search query
limit:
type: int
default: 10
pipeline:
- navigate: https://www.zhihu.com
- evaluate: |
(async () => {
const q = encodeURIComponent('${{ args.query }}');
const res = await fetch('/api/v4/search_v3?q=' + q + '&t=general&limit=${{ args.limit }}', {
credentials: 'include'
});
const d = await res.json();
return (d?.data || [])
.filter(item => item.type === 'search_result')
.map(item => ({
title: (item.object?.title || '').replace(/<[^>]+>/g, ''),
type: item.object?.type || '',
author: item.object?.author?.name || '',
votes: item.object?.voteup_count || 0,
}));
})()
- map:
rank: ${{ index + 1 }}
title: ${{ item.title }}
type: ${{ item.type }}
author: ${{ item.author }}
votes: ${{ item.votes }}
- limit: ${{ args.limit }}
columns: [rank, title, type, author, votes]
```
#### Tier 4 — Store Action Bridge`tap` 步骤,intercept 策略推荐)
适用于 Vue + Pinia/Vuex 的网站(如小红书),无须手动写 XHR 拦截代码:
```yaml
# src/clis/xiaohongshu/notifications.yaml
site: xiaohongshu
name: notifications
description: "小红书通知"
domain: www.xiaohongshu.com
strategy: intercept
browser: true
args:
type:
type: str
default: mentions
description: "Notification type: mentions, likes, or connections"
limit:
type: int
default: 20
columns: [rank, user, action, content, note, time]
pipeline:
- navigate: https://www.xiaohongshu.com/notification
- wait: 3
- tap:
store: notification # Pinia store name
action: getNotification # Store action to call
args: # Action arguments
- ${{ args.type | default('mentions') }}
capture: /you/ # URL pattern to capture response
select: data.message_list # Extract sub-path from response
timeout: 8
- map:
rank: ${{ index + 1 }}
user: ${{ item.user_info.nickname }}
action: ${{ item.title }}
content: ${{ item.comment_info.content }}
- limit: ${{ args.limit | default(20) }}
```
> **`tap` 步骤自动完成**:注入 fetch+XHR 双拦截 → 查找 Pinia/Vuex store → 调用 action → 捕获匹配 URL 的响应 → 清理拦截。
> 如果 store 或 action 找不到,会返回 `hint` 列出所有可用的 store actions,方便调试。
| tap 参数 | 必填 | 说明 |
|---------|------|------|
| `store` | ✅ | Pinia store 名称(如 `feed`, `search`, `notification` |
| `action` | ✅ | Store action 方法名 |
| `capture` | ✅ | URL 子串匹配(匹配网络请求 URL) |
| `args` | ❌ | 传给 action 的参数数组 |
| `select` | ❌ | 从 captured JSON 中提取的路径(如 `data.items` |
| `timeout` | ❌ | 等待网络响应的超时秒数(默认 5s) |
| `framework` | ❌ | `pinia``vuex`(默认自动检测) |
### 方式 B: TypeScript 适配器(编程式)
适用于需要嵌入 JS 代码读取 Pinia state、XHR 拦截、GraphQL、分页、复杂数据转换等场景。
文件路径: `src/clis/<site>/<name>.ts`。文件将会在运行时被动态扫描并注册(切勿在 `index.ts` 中手动 `import`)。
#### Tier 3 — Header 认证(Twitter
```typescript
// src/clis/twitter/search.ts
import { cli, Strategy } from '../../registry.js';
cli({
site: 'twitter',
name: 'search',
description: 'Search tweets',
strategy: Strategy.HEADER,
args: [{ name: 'query', required: true, positional: true }],
columns: ['rank', 'author', 'text', 'likes'],
func: async (page, kwargs) => {
await page.goto('https://x.com');
const data = await page.evaluate(`
(async () => {
// 从 Cookie 提取 CSRF token
const ct0 = document.cookie.split(';')
.map(c => c.trim())
.find(c => c.startsWith('ct0='))?.split('=')[1];
if (!ct0) return { error: 'Not logged in' };
const bearer = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D...';
const headers = {
'Authorization': 'Bearer ' + decodeURIComponent(bearer),
'X-Csrf-Token': ct0,
'X-Twitter-Auth-Type': 'OAuth2Session',
};
const variables = JSON.stringify({ rawQuery: '${kwargs.query}', count: 20 });
const url = '/i/api/graphql/xxx/SearchTimeline?variables=' + encodeURIComponent(variables);
const res = await fetch(url, { headers, credentials: 'include' });
return await res.json();
})()
`);
// ... 解析 data
},
});
```
#### Tier 4 — XHR/Fetch 双重拦截 (Twitter/小红书 通用模式)
```typescript
// src/clis/xiaohongshu/user.ts
import { cli, Strategy } from '../../registry.js';
cli({
site: 'xiaohongshu',
name: 'user',
description: '获取用户笔记',
strategy: Strategy.INTERCEPT,
args: [{ name: 'id', required: true }],
columns: ['rank', 'title', 'likes', 'url'],
func: async (page, kwargs) => {
await page.goto(`https://www.xiaohongshu.com/user/profile/${kwargs.id}`);
await page.wait(5);
// XHR/Fetch 底层拦截:捕获所有包含 'v1/user/posted' 的请求
await page.installInterceptor('v1/user/posted');
// 触发后端 API:模拟人类用户向底部滚动2次
await page.autoScroll({ times: 2, delayMs: 2000 });
// 提取所有被拦截捕获的 JSON 响应体
const requests = await page.getInterceptedRequests();
if (!requests || requests.length === 0) return [];
let results = [];
for (const req of requests) {
if (req.data?.data?.notes) {
for (const note of req.data.data.notes) {
results.push({
title: note.display_title || '',
likes: note.interact_info?.liked_count || '0',
url: `https://explore/${note.note_id || note.id}`
});
}
}
}
return results.slice(0, 20).map((item, i) => ({
rank: i + 1, ...item,
}));
},
});
```
> **拦截核心思路**:不自己构造签名,而是利用 `installInterceptor` 劫持网站自己的 `XMLHttpRequest` 和 `fetch`,让网站发请求,我们直接在底层取出解析好的 `response.json()`。
> **级联请求**(如 BVID→CID→字幕)的完整模板和要点见下方[进阶模式: 级联请求](#进阶模式-级联请求-cascading-requests)章节。
---
## Step 4: 测试
> **构建通过 ≠ 功能正常**。`npm run build` 只验证 TypeScript / YAML 语法,不验证运行时行为。
> 每个新命令 **必须实际运行** 并确认输出正确后才算完成。
### 必做清单
```bash
# 1. 构建(确认语法无误)
npm run build
# 2. 确认命令已注册
opencli list | grep mysite
# 3. 实际运行命令(最关键!)
opencli mysite hot --limit 3 -v # verbose 查看每步数据流
opencli mysite hot --limit 3 -f json # JSON 输出确认字段完整
```
### tap 步骤调试(intercept 策略专用)
> **不要猜 store name / action name**。先用 evaluate 探索,再写 YAML。
#### Step 1: 列出所有 Pinia store
在浏览器中打开目标网站后:
```bash
opencli evaluate "(() => {
const app = document.querySelector('#app')?.__vue_app__;
const pinia = app?.config?.globalProperties?.\$pinia;
return [...pinia._s.keys()];
})()"
# 输出: ["user", "feed", "search", "notification", ...]
```
#### Step 2: 查看 store 的 action 名称
故意写一个错误 action 名,tap 会返回所有可用 actions
```
⚠ tap: Action not found: wrongName on store notification
💡 Available: getNotification, replyComment, getNotificationCount, reset
```
#### Step 3: 用 network requests 确认 capture 模式
```bash
# 在浏览器打开目标页面,查看网络请求
# 找到目标 API 的 URL 特征(如 "/you/mentions"、"homefeed"
```
#### 完整流程
```
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌────────┐
│ 1. navigate │ ──▶ │ 2. 探索 store │ ──▶ │ 3. 写 YAML │ ──▶ │ 4. 测试 │
│ 到目标页面 │ │ name/action │ │ tap 步骤 │ │ 运行验证 │
└──────────────┘ └──────────────┘ └──────────────┘ └────────┘
```
### Verbose 模式 & 输出验证
```bash
opencli bilibili hot --limit 1 -v # 查看 pipeline 每步数据流
opencli mysite hot -f json | jq '.[0]' # 确认 JSON 可被解析
opencli mysite hot -f csv > data.csv # 确认 CSV 可导入
```
---
## Step 5: 提交发布
文件放入 `src/clis/<site>/` 即自动注册(YAML 或 TS 无需手动 import),然后:
```bash
opencli list | grep mysite # 确认注册
git add src/clis/mysite/ && git commit -m "feat(mysite): add hot" && git push
```
> **架构理念**OpenCLI 内建 **Zero-Dependency jq** 数据流 — 所有解析在 `evaluate` 的原生 JS 内完成,外层 YAML 用 `select`/`map` 提取,无需依赖系统 `jq` 二进制。
---
## 进阶模式: 级联请求 (Cascading Requests)
当目标数据需要多步 API 链式获取时(如 `BVID → CID → 字幕列表 → 字幕内容`),必须使用 **TS 适配器**。YAML 无法处理这种多步逻辑。
### 模板代码
```typescript
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
import { apiGet } from './utils.js'; // 复用平台 SDK
cli({
site: 'bilibili',
name: 'subtitle',
strategy: Strategy.COOKIE,
args: [{ name: 'bvid', required: true }],
columns: ['index', 'from', 'to', 'content'],
func: async (page: IPage | null, kwargs: any) => {
if (!page) throw new Error('Requires browser');
// Step 1: 建立 Session
await page.goto(`https://www.bilibili.com/video/${kwargs.bvid}/`);
// Step 2: 从页面提取中间 ID (__INITIAL_STATE__)
const cid = await page.evaluate(`(async () => {
return window.__INITIAL_STATE__?.videoData?.cid;
})()`);
if (!cid) throw new Error('无法提取 CID');
// Step 3: 用中间 ID 调用下一级 API (自动 Wbi 签名)
const payload = await apiGet(page, '/x/player/wbi/v2', {
params: { bvid: kwargs.bvid, cid },
signed: true, // ← 自动生成 w_rid
});
// Step 4: 检测风控降级 (空值断言)
const subtitles = payload.data?.subtitle?.subtitles || [];
const url = subtitles[0]?.subtitle_url;
if (!url) throw new Error('subtitle_url 为空,疑似风控降级');
// Step 5: 拉取最终数据 (CDN JSON)
const items = await page.evaluate(`(async () => {
const res = await fetch(${JSON.stringify('https:' + url)});
const json = await res.json();
return { data: json.body || json };
})()`);
return items.data.map((item, idx) => ({ ... }));
},
});
```
### 关键要点
| 步骤 | 注意事项 |
|------|----------|
| 提取中间 ID | 优先从 `__INITIAL_STATE__` 拿,避免额外 API 调用 |
| Wbi 签名 | B 站 `/wbi/` 接口**强制校验** `w_rid`,纯 `fetch` 会被 403 |
| 空值断言 | 即使 HTTP 200,核心字段可能为空串(风控降级) |
| CDN URL | 常以 `//` 开头,记得补 `https:` |
| `JSON.stringify` | 拼接 URL 到 evaluate 时必须用它转义,避免注入 |
---
## 常见陷阱
| 陷阱 | 表现 | 解决方案 |
|------|------|---------|
| 缺少 `navigate` | evaluate 报 `Target page context` 错误 | 在 evaluate 前加 `navigate:` 步骤 |
| 嵌套字段访问 | `${{ item.node?.title }}` 不工作 | 在 evaluate 中 flatten 数据,不在模板中用 optional chaining |
| 缺少 `strategy: public` | 公开 API 也启动浏览器,7s → 1s | 公开 API 加上 `strategy: public` + `browser: false` |
| evaluate 返回字符串 | map 步骤收到 `""` 而非数组 | pipeline 有 auto-parse,但建议在 evaluate 内 `.map()` 整形 |
| 搜索参数被 URL 编码 | `${{ args.query }}` 被浏览器二次编码 | 在 evaluate 内用 `encodeURIComponent()` 手动编码 |
| Cookie 过期 | 返回 401 / 空数据 | 在浏览器里重新登录目标站点 |
| Extension tab 残留 | Chrome 多出 `chrome-extension://` tab | 已自动清理;若残留,手动关闭即可 |
| TS evaluate 格式 | `() => {}``result is not a function` | TS 中 `page.evaluate()` 必须用 IIFE`(async () => { ... })()` |
| 页面异步加载 | evaluate 拿到空数据(store state 还没更新) | 在 evaluate 内用 polling 等待数据出现,或增加 `wait` 时间 |
| YAML 内嵌大段 JS | 调试困难,字符串转义问题 | 超过 10 行 JS 的命令改用 TS adapter |
| **风控被拦截(伪200)** | 获取到的 JSON 里核心数据是 `""` (空串) | 极易被误判。必须添加断言!无核心数据立刻要求升级鉴权 Tier 并重新配置 Cookie |
| **API 没找见** | `explore` 工具打分出来的都拿不到深层数据 | 点击页面按钮诱发懒加载数据,再结合 `getInterceptedRequests` 获取 |
---
## 用 AI Agent 自动生成适配器
最快的方式是让 AI Agent 完成全流程:
```bash
# 一键:探索 → 分析 → 合成 → 注册
opencli generate https://www.example.com --goal "hot"
# 或分步执行:
opencli explore https://www.example.com --site mysite # 发现 API
opencli explore https://www.example.com --auto --click "字幕,CC" # 模拟点击触发懒加载 API
opencli synthesize mysite # 生成候选 YAML
opencli verify mysite/hot --smoke # 冒烟测试
```
生成的候选 YAML 保存在 `.opencli/explore/mysite/candidates/`,可直接复制到 `src/clis/mysite/` 并微调。
## Record Workflow
`record` 是为「无法用 `explore` 自动发现」的页面(需要登录操作、复杂交互、SPA 内路由)准备的手动录制方案。
### 工作原理
```
opencli record <url>
→ 打开 automation window 并导航到目标 URL
→ 向所有 tab 注入 fetch/XHR 拦截器(幂等,可重复注入)
→ 每 2s 轮询一次:发现新 tab 自动注入,drain 所有 tab 的捕获缓冲区
→ 超时(默认 60s)或按 Enter 停止
→ 分析捕获到的 JSON 请求:去重 → 评分 → 生成候选 YAML
```
**拦截器特性**
- 同时 patch `window.fetch``XMLHttpRequest`
- 只捕获 `Content-Type: application/json` 的响应
- 过滤纯对象少于 2 个 key 的响应(避免 tracking/ping
- 跨 tab 隔离:每个 tab 独立缓冲区,轮询时分别 drain
- 幂等注入:同一 tab 二次注入时先 restore 原始函数再重新 patch,不丢失已捕获数据
### 使用步骤
```bash
# 1. 启动录制(建议 --timeout 给足操作时间)
opencli record "https://example.com/page" --timeout 120000
# 2. 在弹出的 automation window 里正常操作页面:
# - 打开列表、搜索、点击条目、切换 Tab
# - 凡是触发网络请求的操作都会被捕获
# 3. 完成操作后按 Enter 停止(或等超时自动停止)
# 4. 查看结果
cat .opencli/record/<site>/captured.json # 原始捕获
ls .opencli/record/<site>/candidates/ # 候选 YAML
```
### 页面类型与捕获预期
| 页面类型 | 预期捕获量 | 说明 |
|---------|-----------|------|
| 列表/搜索页 | 多(5~20+) | 每次搜索/翻页都会触发新请求 |
| 详情页(只读) | 少(1~5) | 首屏数据一次性返回,后续操作走 form/redirect |
| SPA 内路由跳转 | 中等 | 路由切换会触发新接口,但首屏请求在注入前已发出 |
| 需要登录的页面 | 视操作而定 | 确保 Chrome 已登录目标网站 |
> **注意**:如果页面在导航完成前就发出了大部分请求(服务端渲染 / SSR 注水),拦截器会错过这些请求。
> 解决方案:在页面加载完成后,手动触发能产生新请求的操作(搜索、翻页、切 Tab、展开折叠项等)。
### 候选 YAML → TS CLI 转换
生成的候选 YAML 是起点,通常需要转换为 TypeScript(尤其是 tae 等内部系统):
**候选 YAML 结构**(自动生成):
```yaml
site: tae
name: getList # 从 URL path 推断的名称
strategy: cookie
browser: true
pipeline:
- navigate: https://...
- evaluate: |
(async () => {
const res = await fetch('/approval/getList.json?procInsId=...', { credentials: 'include' });
const data = await res.json();
return (data?.content?.operatorRecords || []).map(item => ({ ... }));
})()
```
**转换为 TS CLI**(参考 `src/clis/tae/add-expense.ts` 风格):
```typescript
import { cli, Strategy } from '../../registry.js';
cli({
site: 'tae',
name: 'get-approval',
description: '查看报销单审批流程和操作记录',
domain: 'tae.alibaba-inc.com',
strategy: Strategy.COOKIE,
browser: true,
args: [
{ name: 'proc_ins_id', type: 'string', required: true, positional: true, help: '流程实例 IDprocInsId' },
],
columns: ['step', 'operator', 'action', 'time'],
func: async (page, kwargs) => {
await page.goto('https://tae.alibaba-inc.com/expense/pc.html?_authType=SAML');
await page.wait(2);
const result = await page.evaluate(`(async () => {
const res = await fetch('/approval/getList.json?taskId=&procInsId=${kwargs.proc_ins_id}', {
credentials: 'include'
});
const data = await res.json();
return data?.content?.operatorRecords || [];
})()`);
return (result as any[]).map((r, i) => ({
step: i + 1,
operator: r.operatorName || r.userId,
action: r.operationType,
time: r.operateTime,
}));
},
});
```
**转换要点**
1. URL 中的动态 ID`procInsId``taskId` 等)提取为 `args`
2. `captured.json` 里的真实 body 结构用于确定正确的数据路径(如 `content.operatorRecords`
3. tae 系统统一用 `{ success, content, errorCode, errorMsg }` 外层包裹,取数据要走 `content.*`
4. 认证方式:cookie`credentials: 'include'`),不需要额外 header
5. 文件放入 `src/clis/<site>/`,无需手动注册,`npm run build` 后自动发现
### 故障排查
| 现象 | 原因 | 解法 |
|------|------|------|
| 捕获 0 条请求 | 拦截器注入失败,或页面无 JSON API | 检查 daemon 是否运行:`curl localhost:19825/status` |
| 捕获量少(1~3 条) | 页面是只读详情页,首屏数据已在注入前发出 | 手动操作触发更多请求(搜索/翻页),或换用列表页 |
| 候选 YAML 为 0 | 捕获到的 JSON 都没有 array 结构 | 直接看 `captured.json` 手写 TS CLI |
| 新开的 tab 没有被拦截 | 轮询间隔内 tab 已关闭 | 缩短 `--poll 500` |
| 二次运行 record 时数据不连续 | 正常,每次 `record` 启动都是新的 automation window | 无需处理 |
+222
View File
@@ -0,0 +1,222 @@
---
name: opencli-oneshot
description: Use when quickly generating a single OpenCLI command from a specific URL and goal description. 4-step process — open page, capture API, write YAML adapter, test. For full site exploration, use opencli-explorer instead.
tags: [opencli, adapter, quick-start, yaml, cli, one-shot, automation]
---
# CLI-ONESHOT — 单点快速 CLI 生成
> 给一个 URL + 一句话描述,4 步生成一个 CLI 命令。
> 完整探索式开发请看 [CLI-EXPLORER.md](./CLI-EXPLORER.md)。
---
## 输入
| 项目 | 示例 |
|------|------|
| **URL** | `https://x.com/jakevin7/lists` |
| **Goal** | 获取我的 Twitter Lists |
---
## 流程
### Step 1: 打开页面 + 抓包
```
1. browser_navigate → 打开目标 URL
2. 等待 3-5 秒(让页面加载完、API 请求触发)
3. browser_network_requests → 筛选 JSON API
```
**关键**:只关注返回 `application/json` 的请求,忽略静态资源。
如果没有自动触发 API,手动点击目标按钮/标签再抓一次。
### Step 2: 锁定一个接口
从抓包结果中找到**那个**目标 API。看这几个字段:
| 字段 | 关注什么 |
|------|----------|
| URL | API 路径 pattern(如 `/i/api/graphql/xxx/ListsManagePinTimeline` |
| Method | GET / POST |
| Headers | 有 Cookie? Bearer? CSRF? 自定义签名? |
| Response | 数据在哪个路径(如 `data.list.lists` |
### Step 3: 验证接口能复现
`browser_evaluate` 中用 `fetch` 复现请求:
```javascript
// Tier 2 (Cookie): 大多数情况
fetch('/api/endpoint', { credentials: 'include' }).then(r => r.json())
// Tier 3 (Header): 如 Twitter 需要额外 header
const ct0 = document.cookie.match(/ct0=([^;]+)/)?.[1];
fetch('/api/endpoint', {
headers: { 'Authorization': 'Bearer ...', 'X-Csrf-Token': ct0 },
credentials: 'include'
}).then(r => r.json())
```
如果 fetch 能拿到数据 → 用 YAML 或简单 TS adapter。
如果 fetch 拿不到(签名/风控)→ 用 intercept 策略。
### Step 4: 套模板,生成 adapter
根据 Step 3 判定的策略,选一个模板生成文件。
---
## 认证速查
```
fetch(url) 直接能拿到? → Tier 1: public (YAML, browser: false)
fetch(url, {credentials:'include'}) → Tier 2: cookie (YAML)
加 Bearer/CSRF header 后拿到? → Tier 3: header (TS)
都不行,但页面自己能请求成功? → Tier 4: intercept (TS, installInterceptor)
```
---
## 模板
### YAML — Cookie/Public(最简)
```yaml
# src/clis/<site>/<name>.yaml
site: mysite
name: mycommand
description: "一句话描述"
domain: www.example.com
strategy: cookie # 或 public (加 browser: false)
args:
limit:
type: int
default: 20
pipeline:
- navigate: https://www.example.com/target-page
- evaluate: |
(async () => {
const res = await fetch('/api/target', { credentials: 'include' });
const d = await res.json();
return (d.data?.items || []).map(item => ({
title: item.title,
value: item.value,
}));
})()
- map:
rank: ${{ index + 1 }}
title: ${{ item.title }}
value: ${{ item.value }}
- limit: ${{ args.limit }}
columns: [rank, title, value]
```
### TS — Intercept(抓包模式)
```typescript
// src/clis/<site>/<name>.ts
import { cli, Strategy } from '../../registry.js';
cli({
site: 'mysite',
name: 'mycommand',
description: '一句话描述',
domain: 'www.example.com',
strategy: Strategy.INTERCEPT,
browser: true,
args: [
{ name: 'limit', type: 'int', default: 20 },
],
columns: ['rank', 'title', 'value'],
func: async (page, kwargs) => {
// 1. 导航
await page.goto('https://www.example.com/target-page');
await page.wait(3);
// 2. 注入拦截器(URL 子串匹配)
await page.installInterceptor('target-api-keyword');
// 3. 触发 API(滚动/点击)
await page.autoScroll({ times: 2, delayMs: 2000 });
// 4. 读取拦截的响应
const requests = await page.getInterceptedRequests();
if (!requests?.length) return [];
let results: any[] = [];
for (const req of requests) {
const items = req.data?.data?.items || [];
results.push(...items);
}
return results.slice(0, kwargs.limit).map((item, i) => ({
rank: i + 1,
title: item.title || '',
value: item.value || '',
}));
},
});
```
### TS — Header(如 Twitter GraphQL
```typescript
import { cli, Strategy } from '../../registry.js';
cli({
site: 'twitter',
name: 'mycommand',
description: '一句话描述',
domain: 'x.com',
strategy: Strategy.HEADER,
browser: true,
args: [
{ name: 'limit', type: 'int', default: 20 },
],
columns: ['rank', 'name', 'value'],
func: async (page, kwargs) => {
await page.goto('https://x.com');
const data = await page.evaluate(`(async () => {
const ct0 = document.cookie.match(/ct0=([^;]+)/)?.[1];
if (!ct0) return { error: 'Not logged in' };
const bearer = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D...';
const res = await fetch('/i/api/graphql/QUERY_ID/Endpoint', {
headers: {
'Authorization': 'Bearer ' + decodeURIComponent(bearer),
'X-Csrf-Token': ct0,
'X-Twitter-Auth-Type': 'OAuth2Session',
},
credentials: 'include',
});
return res.json();
})()`);
// 解析 data...
return [];
},
});
```
---
## 测试(必做)
```bash
npm run build # 语法检查
opencli list | grep mysite # 确认注册
opencli mysite mycommand --limit 3 -v # 实际运行
```
---
## 就这样,没了
写完文件 → build → run → 提交。有问题再看 [CLI-EXPLORER.md](./CLI-EXPLORER.md)。
+262
View File
@@ -0,0 +1,262 @@
---
name: opencli-operate
description: Make websites accessible for AI agents. Navigate, click, type, extract, wait — using Chrome with existing login sessions. No LLM API key needed.
allowed-tools: Bash(opencli:*), Read, Edit, Write
---
# OpenCLI Operate — Browser Automation for AI Agents
Control Chrome step-by-step via CLI. Reuses existing login sessions — no passwords needed.
## Prerequisites
```bash
opencli doctor # Verify extension + daemon connectivity
```
Requires: Chrome running + OpenCLI Browser Bridge extension installed.
## Critical Rules
1. **ALWAYS use `state` to inspect the page, NEVER use `screenshot`**`state` returns structured DOM with `[N]` element indices, is instant and costs zero tokens. `screenshot` requires vision processing and is slow. Only use `screenshot` when the user explicitly asks to save a visual.
2. **ALWAYS use `click`/`type`/`select` for interaction, NEVER use `eval` to click or type**`eval "el.click()"` bypasses scrollIntoView and CDP click pipeline, causing failures on off-screen elements. Use `state` to find the `[N]` index, then `click <N>`.
3. **Verify inputs with `get value`, not screenshots** — after `type`, run `get value <index>` to confirm.
4. **Run `state` after every page change** — after `open`, `click` (on links), `scroll`, always run `state` to see the new elements and their indices. Never guess indices.
5. **Chain commands aggressively with `&&`** — combine `open + state`, multiple `type` calls, and `type + get value` into single `&&` chains. Each tool call has overhead; chaining cuts it.
6. **`eval` is read-only** — use `eval` ONLY for data extraction (`JSON.stringify(...)`), never for clicking, typing, or navigating. Always wrap in IIFE to avoid variable conflicts: `eval "(function(){ const x = ...; return JSON.stringify(x); })()"`.
7. **Minimize total tool calls** — plan your sequence before acting. A good task completion uses 3-5 tool calls, not 15-20. Combine `open + state` as one call. Combine `type + type + click` as one call. Only run `state` separately when you need to discover new indices.
8. **Prefer `network` to discover APIs** — most sites have JSON APIs. API-based adapters are more reliable than DOM scraping.
## Command Cost Guide
| Cost | Commands | When to use |
|------|----------|-------------|
| **Free & instant** | `state`, `get *`, `eval`, `network`, `scroll`, `keys` | Default — use these |
| **Free but changes page** | `open`, `click`, `type`, `select`, `back` | Interaction — run `state` after |
| **Expensive (vision tokens)** | `screenshot` | ONLY when user needs a saved image |
## Action Chaining Rules
Commands can be chained with `&&`. The browser persists via daemon, so chaining is safe.
**Always chain when possible** — fewer tool calls = faster completion:
```bash
# GOOD: open + inspect in one call (saves 1 round trip)
opencli operate open https://example.com && opencli operate state
# GOOD: fill form in one call (saves 2 round trips)
opencli operate type 3 "hello" && opencli operate type 4 "world" && opencli operate click 7
# GOOD: type + verify in one call
opencli operate type 5 "test@example.com" && opencli operate get value 5
# GOOD: click + wait + state in one call (for page-changing clicks)
opencli operate click 12 && opencli operate wait time 1 && opencli operate state
# BAD: separate calls for each action (wasteful)
opencli operate type 3 "hello" # Don't do this
opencli operate type 4 "world" # when you can chain
opencli operate click 7 # all three together
```
**Page-changing — always put last** in a chain (subsequent commands see stale indices):
- `open <url>`, `back`, `click <link/button that navigates>`
**Rule**: Chain when you already know the indices. Run `state` separately when you need to discover indices first.
## Core Workflow
1. **Navigate**: `opencli operate open <url>`
2. **Inspect**: `opencli operate state` → elements with `[N]` indices
3. **Interact**: use indices — `click`, `type`, `select`, `keys`
4. **Wait** (if needed): `opencli operate wait selector ".loaded"` or `wait text "Success"`
5. **Verify**: `opencli operate state` or `opencli operate get value <N>`
6. **Repeat**: browser stays open between commands
7. **Save**: write a TS adapter to `~/.opencli/clis/<site>/<command>.ts`
## Commands
### Navigation
```bash
opencli operate open <url> # Open URL (page-changing)
opencli operate back # Go back (page-changing)
opencli operate scroll down # Scroll (up/down, --amount N)
opencli operate scroll up --amount 1000
```
### Inspect (free & instant)
```bash
opencli operate state # Structured DOM with [N] indices — PRIMARY tool
opencli operate screenshot [path.png] # Save visual to file — ONLY for user deliverables
```
### Get (free & instant)
```bash
opencli operate get title # Page title
opencli operate get url # Current URL
opencli operate get text <index> # Element text content
opencli operate get value <index> # Input/textarea value (use to verify after type)
opencli operate get html # Full page HTML
opencli operate get html --selector "h1" # Scoped HTML
opencli operate get attributes <index> # Element attributes
```
### Interact
```bash
opencli operate click <index> # Click element [N]
opencli operate type <index> "text" # Type into element [N]
opencli operate select <index> "option" # Select dropdown
opencli operate keys "Enter" # Press key (Enter, Escape, Tab, Control+a)
```
### Wait
```bash
opencli operate wait selector ".loaded" # Wait for element
opencli operate wait selector ".spinner" --timeout 5000 # With timeout
opencli operate wait text "Success" # Wait for text
opencli operate wait time 3 # Wait N seconds
```
### Extract (free & instant, read-only)
Use `eval` ONLY for reading data. Never use it to click, type, or navigate.
```bash
opencli operate eval "document.title"
opencli operate eval "JSON.stringify([...document.querySelectorAll('h2')].map(e => e.textContent))"
# IMPORTANT: wrap complex logic in IIFE to avoid "already declared" errors
opencli operate eval "(function(){ const items = [...document.querySelectorAll('.item')]; return JSON.stringify(items.map(e => e.textContent)); })()"
```
### Network (API Discovery)
```bash
opencli operate network # Show captured API requests (auto-captured since open)
opencli operate network --detail 3 # Show full response body of request #3
opencli operate network --all # Include static resources
```
### Sedimentation (Save as CLI)
```bash
opencli operate init hn/top # Generate adapter scaffold
opencli operate verify hn/top # Test the adapter
```
### Session
```bash
opencli operate close # Close automation window
```
## Example: Extract HN Stories
```bash
opencli operate open https://news.ycombinator.com
opencli operate state # See [1] a "Story 1", [2] a "Story 2"...
opencli operate eval "JSON.stringify([...document.querySelectorAll('.titleline a')].slice(0,5).map(a => ({title: a.textContent, url: a.href})))"
opencli operate close
```
## Example: Fill a Form
```bash
opencli operate open https://httpbin.org/forms/post
opencli operate state # See [3] input "Customer Name", [4] input "Telephone"
opencli operate type 3 "OpenCLI" && opencli operate type 4 "555-0100"
opencli operate get value 3 # Verify: "OpenCLI"
opencli operate close
```
## Saving as Reusable CLI — Complete Workflow
### Step-by-step sedimentation flow:
```bash
# 1. Explore the website
opencli operate open https://news.ycombinator.com
opencli operate state # Understand DOM structure
# 2. Discover APIs (crucial for high-quality adapters)
opencli operate eval "fetch('/api/...').then(r=>r.json())" # Trigger API calls
opencli operate network # See captured API requests
opencli operate network --detail 0 # Inspect response body
# 3. Generate scaffold
opencli operate init hn/top # Creates ~/.opencli/clis/hn/top.ts
# 4. Edit the adapter (fill in func logic)
# - If API found: use fetch() directly (Strategy.PUBLIC or COOKIE)
# - If no API: use page.evaluate() for DOM extraction (Strategy.UI)
# 5. Verify
opencli operate verify hn/top # Runs the adapter and shows output
# 6. If verify fails, edit and retry
# 7. Close when done
opencli operate close
```
### Example adapter:
```typescript
// ~/.opencli/clis/hn/top.ts
import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'hn',
name: 'top',
description: 'Top Hacker News stories',
domain: 'news.ycombinator.com',
strategy: Strategy.PUBLIC,
browser: false,
args: [{ name: 'limit', type: 'int', default: 5 }],
columns: ['rank', 'title', 'score', 'url'],
func: async (_page, kwargs) => {
const limit = Math.min(Math.max(1, kwargs.limit ?? 5), 50);
const resp = await fetch('https://hacker-news.firebaseio.com/v0/topstories.json');
const ids = await resp.json();
return Promise.all(
ids.slice(0, limit).map(async (id: number, i: number) => {
const item = await (await fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`)).json();
return { rank: i + 1, title: item.title, score: item.score, url: item.url ?? '' };
})
);
},
});
```
Save to `~/.opencli/clis/<site>/<command>.ts` → immediately available as `opencli <site> <command>`.
### Strategy Guide
| Strategy | When | browser: |
|----------|------|----------|
| `Strategy.PUBLIC` | Public API, no auth | `false` |
| `Strategy.COOKIE` | Needs login cookies | `true` |
| `Strategy.UI` | Direct DOM interaction | `true` |
**Always prefer API over UI** — if you discovered an API during browsing, use `fetch()` directly.
## Tips
1. **Always `state` first** — never guess element indices, always inspect first
2. **Sessions persist** — browser stays open between commands, no need to re-open
3. **Use `eval` for data extraction**`eval "JSON.stringify(...)"` is faster than multiple `get` calls
4. **Use `network` to find APIs** — JSON APIs are more reliable than DOM scraping
5. **Alias**: `opencli op` is shorthand for `opencli operate`
## Troubleshooting
| Error | Fix |
|-------|-----|
| "Browser not connected" | Run `opencli doctor` |
| "attach failed: chrome-extension://" | Disable 1Password temporarily |
| Element not found | `opencli operate scroll down && opencli operate state` |
| Stale indices after page change | Run `opencli operate state` again to get fresh indices |
+163
View File
@@ -0,0 +1,163 @@
---
name: opencli-usage
description: "Use when running OpenCLI commands to interact with websites (Bilibili, Twitter, Reddit, Xiaohongshu, etc.), desktop apps (Cursor, Notion), or public APIs (HackerNews, arXiv). Covers installation, command reference, and output formats for 73+ adapters."
version: 1.6.1
author: jackwener
tags: [opencli, cli, browser, web, chrome-extension, cdp, bilibili, twitter, reddit, xiaohongshu, github, youtube, AI, agent, automation]
---
# OpenCLI Usage Guide
> Make any website or Electron App your CLI. Reuse Chrome login, zero risk, AI-powered discovery.
## Install & Run
```bash
# npm global install (recommended)
npm install -g @jackwener/opencli
opencli <command>
# Or from source
cd ~/code/opencli && npm install
npx tsx src/main.ts <command>
# Update to latest
npm update -g @jackwener/opencli
```
## Prerequisites
Browser commands require:
1. Chrome browser running **(logged into target sites)**
2. **opencli Browser Bridge** Chrome extension installed (load `extension/` as unpacked in `chrome://extensions`)
3. No further setup needed — the daemon auto-starts on first browser command
> **Note**: You must be logged into the target website in Chrome before running commands. Tabs opened during command execution are auto-closed afterwards.
Public API commands (`hackernews`, `v2ex`) need no browser.
## Quick Lookup by Capability
| Capability | Platforms (partial list) | File |
|-----------|--------------------------|------|
| **search** | Bilibili, Twitter, Reddit, Xiaohongshu, Zhihu, YouTube, Google, arXiv, LinkedIn, Pixiv, etc. | browser.md / public-api.md |
| **hot/trending** | Bilibili, Twitter, Weibo, HackerNews, Reddit, V2EX, Xueqiu, Lobsters, Douban | browser.md / public-api.md |
| **feed/timeline** | Twitter, Reddit, Xiaohongshu, Xueqiu, Jike, Facebook, Instagram, Medium | browser.md |
| **user/profile** | Twitter, Reddit, Instagram, TikTok, Facebook, Bilibili, Pixiv | browser.md |
| **post/create** | Twitter, Jike, Douyin, Weibo | browser.md |
| **AI chat** | Grok, Doubao, ChatGPT, Gemini, Cursor, Codex, NotebookLM | browser.md / desktop.md |
| **finance/stock** | Xueqiu, Yahoo Finance, Barchart, Sina Finance, Bloomberg | browser.md / public-api.md |
| **web scraping** | `opencli web read --url <url>` — any URL to Markdown | browser.md |
## Command Quick Reference
Usage: `opencli <site> <command> [args] [--limit N] [-f json|yaml|md|csv|table]`
### Browser-based (login required)
| Site | Commands |
|------|----------|
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `user-videos` `subtitle` `dynamic` `ranking` `following` |
| **zhihu** | `hot` `search` `question` |
| **xiaohongshu** | `search` `notifications` `feed` `user` `note` `comments` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` |
| **xueqiu** | `hot-stock` `stock` `watchlist` `feed` `hot` `search` `comments` `earnings-date` `fund-holdings` `fund-snapshot` |
| **twitter** | `trending` `bookmarks` `search` `profile` `timeline` `thread` `article` `follow` `unfollow` `bookmark` `unbookmark` `post` `like` `likes` `reply` `delete` `block` `unblock` `followers` `following` `notifications` `hide-reply` `download` `accept` `reply-dm` |
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` |
| **youtube** | `search` `video` `transcript` |
| **facebook** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` |
| **instagram** | `explore` `profile` `search` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `saved` |
| **tiktok** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` |
| **linkedin** | `search` `timeline` |
| **medium** | `feed` `search` `user` |
| **substack** | `feed` `search` `publication` |
| **sinablog** | `hot` `search` `article` `user` |
| **weibo** | `hot` `search` `feed` `user` `me` `post` `comments` |
| **douyin** | `profile` `videos` `user-videos` `activities` `collections` `hashtag` `location` `stats` `publish` `draft` `drafts` `delete` `update` |
| **bluesky** | `search` `profile` `user` `feeds` `followers` `following` `thread` `trending` `starter-packs` |
| **boss** | `search` `detail` `recommend` `joblist` `greet` `batchgreet` `send` `chatlist` `chatmsg` `invite` `mark` `exchange` `resume` `stats` |
| **douban** | `search` `top250` `subject` `photos` `download` `marks` `reviews` `movie-hot` `book-hot` |
| **pixiv** | `ranking` `search` `user` `illusts` `detail` `download` |
| **jike** | `feed` `search` `create` `like` `comment` `repost` `notifications` `post` `topic` `user` |
| **band** | `bands` `posts` `post` `mentions` |
| **zsxq** | `groups` `dynamics` `topics` `topic` `search` |
| **tieba** | `hot` `search` `posts` `read` |
| **yahoo-finance** | `quote` |
| **barchart** | `quote` `options` `greeks` `flow` |
| **sinafinance** | `news` |
| **reuters** | `search` |
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` |
| **coupang** | `search` `add-to-cart` |
| **jd** | `item` |
| **smzdm** | `search` |
| **ctrip** | `search` |
| **36kr** | `hot` `news` `search` `article` |
| **weread** | `shelf` `search` `book` `highlights` `notes` `notebooks` `ranking` |
| **chaoxing** | `assignments` `exams` |
| **jimeng** | `generate` `history` |
| **yollomi** | `models` `generate` `video` `upload` `remove-bg` `edit` `background` `face-swap` `object-remover` `restore` `try-on` `upscale` |
| **ones** | `login` `logout` `me` `tasks` `task` `my-tasks` `worklog` `token-info` |
| **web** | `read` — any URL to Markdown |
| **weixin** | `download` — 公众号 article to Markdown |
| **v2ex** (browser) | `daily` `me` `notifications` |
| **linux-do** (browser) | `hot` `latest` `feed` `search` `categories` `category` `tags` `topic` `user-posts` `user-topics` |
| **bloomberg** (browser) | `news` — full article reader |
| **grok** | `ask` |
| **doubao** | `status` `new` `send` `read` `ask` `detail` `history` `meeting-summary` `meeting-transcript` |
| **gemini** | `ask` `new` `image` |
| **notebooklm** | `status` `list` `open` `get` `current` `history` `summary` `note-list` `notes-get` `source-list` `source-get` `source-fulltext` `source-guide` |
### Desktop (CDP/Electron)
| Site | Commands |
|------|----------|
| **gh** | `repo` `pr` `issue` — passthrough to gh CLI |
| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` |
| **codex** | `status` `send` `read` `new` `dump` `extract-diff` `model` `ask` `screenshot` `history` `export` |
| **chatgpt** | `status` `new` `send` `read` `ask` `model` |
| **chatwise** | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` |
| **notion** | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` |
| **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` |
| **doubao-app** | `status` `new` `send` `read` `ask` `screenshot` `dump` |
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` |
### Public API (no browser)
| Site | Commands |
|------|----------|
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` |
| **v2ex** (public) | `hot` `latest` `topic` `node` `nodes` `member` `user` `replies` |
| **bbc** | `news` |
| **lobsters** | `hot` `newest` `active` `tag` |
| **google** | `news` `search` `suggest` `trends` |
| **devto** | `top` `tag` `user` |
| **steam** | `top-sellers` |
| **apple-podcasts** | `top` `search` `episodes` |
| **arxiv** | `search` `paper` |
| **bloomberg** (RSS) | `main` `markets` `tech` `politics` `economics` `opinions` `industries` `businessweek` `feeds` |
| **dictionary** | `search` `synonyms` `examples` |
| **hf** | `top` |
| **stackoverflow** | `hot` `search` `bounties` `unanswered` |
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` |
| **wikipedia** | `search` `summary` `random` `trending` |
| **producthunt** | `today` `hot` `browse` `posts` |
| **imdb** | `top` `trending` `search` `title` `person` `reviews` |
| **spotify** | `auth` `status` `play` |
| **paperreview** | `submit` `review` `feedback` |
### Management
```bash
opencli list [-f json|yaml] # List all commands
opencli validate [site] # Validate adapter definitions
opencli doctor # Diagnose browser bridge
opencli explore <url> # AI-powered API discovery
opencli record <url> # Record API calls manually
```
All commands support: `--format` / `-f` with `table` `json` `yaml` `md` `csv`
## Related Skills
- **opencli-operate** — Browser automation for AI agents (navigate, click, type, extract via Chrome)
- **opencli-explorer** — Full guide for creating new adapters (API discovery, auth strategy, YAML/TS writing)
- **opencli-oneshot** — Quick 4-step template for adding a single command from a URL
+527
View File
@@ -0,0 +1,527 @@
# Browser-based Commands
Commands that require Chrome browser with login state.
## Bilibili (哔哩哔哩)
```bash
opencli bilibili hot --limit 10 # B站热门视频
opencli bilibili search "rust" # 搜索视频 (query positional)
opencli bilibili me # 我的信息
opencli bilibili favorite # 我的收藏
opencli bilibili history --limit 20 # 观看历史
opencli bilibili feed --limit 10 # 动态时间线
opencli bilibili user-videos --uid 12345 # 用户投稿
opencli bilibili subtitle --bvid BV1xxx # 获取视频字幕 (支持 --lang zh-CN)
opencli bilibili dynamic --limit 10 # 动态
opencli bilibili ranking --limit 10 # 排行榜
opencli bilibili following --limit 20 # 我的关注列表 (支持 --uid 查看他人)
```
## Zhihu (知乎)
```bash
opencli zhihu hot --limit 10 # 知乎热榜
opencli zhihu search "AI" # 搜索 (query positional)
opencli zhihu question 34816524 # 问题详情和回答 (id positional)
```
## Xiaohongshu (小红书)
```bash
opencli xiaohongshu search "美食" # 搜索笔记 (query positional)
opencli xiaohongshu note <note-id-or-url> # 读取笔记正文和互动数据
opencli xiaohongshu comments <note-id> # 笔记评论
opencli xiaohongshu notifications # 通知(mentions/likes/connections
opencli xiaohongshu feed --limit 10 # 推荐 Feed
opencli xiaohongshu user xxx # 用户主页 (id positional)
opencli xiaohongshu download <note-id> # 下载笔记图片/视频
opencli xiaohongshu publish # 发布笔记
opencli xiaohongshu creator-notes --limit 10 # 创作者笔记列表
opencli xiaohongshu creator-note-detail --note-id xxx # 笔记详情
opencli xiaohongshu creator-notes-summary # 笔记数据概览
opencli xiaohongshu creator-profile # 创作者资料
opencli xiaohongshu creator-stats # 创作者数据统计
```
## Xueqiu (雪球)
```bash
opencli xueqiu hot-stock --limit 10 # 雪球热门股票榜
opencli xueqiu stock --symbol SH600519 # 查看股票实时行情
opencli xueqiu watchlist # 获取自选股/持仓列表
opencli xueqiu feed # 我的关注 timeline
opencli xueqiu hot --limit 10 # 雪球热榜
opencli xueqiu search "特斯拉" # 搜索 (query positional)
opencli xueqiu earnings-date SH600519 # 股票财报发布日期 (symbol positional)
opencli xueqiu fund-holdings # 蛋卷基金持仓明细 (支持 --account 过滤)
opencli xueqiu fund-snapshot # 蛋卷基金快照(总资产、子账户、持仓)
```
## Twitter/X
```bash
opencli twitter trending --limit 10 # 热门话题
opencli twitter bookmarks --limit 20 # 获取收藏的书签推文
opencli twitter search "AI" # 搜索推文 (query positional)
opencli twitter profile elonmusk # 用户资料
opencli twitter timeline --limit 20 # 时间线
opencli twitter thread 1234567890 # 推文 thread(原文 + 回复)
opencli twitter article 1891511252174299446 # 推文长文内容
opencli twitter follow elonmusk # 关注用户
opencli twitter unfollow elonmusk # 取消关注
opencli twitter bookmark https://x.com/... # 收藏推文
opencli twitter unbookmark https://x.com/... # 取消收藏
opencli twitter post "Hello world" # 发布推文 (text positional)
opencli twitter like https://x.com/... # 点赞推文 (url positional)
opencli twitter reply https://x.com/... "Nice!" # 回复推文 (url + text positional)
opencli twitter delete https://x.com/... # 删除推文 (url positional)
opencli twitter block elonmusk # 屏蔽用户 (username positional)
opencli twitter unblock elonmusk # 取消屏蔽 (username positional)
opencli twitter followers elonmusk # 用户的粉丝列表 (user positional)
opencli twitter following elonmusk # 用户的关注列表 (user positional)
opencli twitter notifications --limit 20 # 通知列表
opencli twitter hide-reply https://x.com/... # 隐藏回复 (url positional)
opencli twitter download elonmusk # 下载用户媒体 (username positional, 支持 --tweet-url)
opencli twitter accept "群,微信" # 自动接受含关键词的 DM 请求 (query positional)
opencli twitter reply-dm "消息内容" # 批量回复 DM (text positional)
```
## Reddit
```bash
opencli reddit hot --limit 10 # 热门帖子
opencli reddit hot --subreddit programming # 指定子版块
opencli reddit frontpage --limit 10 # 首页 /r/all
opencli reddit popular --limit 10 # /r/popular 热门
opencli reddit search "AI" --sort top --time week # 搜索(支持排序+时间过滤)
opencli reddit subreddit rust --sort top --time month # 子版块浏览(支持时间过滤)
opencli reddit read --post-id 1abc123 # 阅读帖子 + 评论
opencli reddit user spez # 用户资料(karma、注册时间)
opencli reddit user-posts spez # 用户发帖历史
opencli reddit user-comments spez # 用户评论历史
opencli reddit upvote --post-id xxx --direction up # 投票(up/down/none
opencli reddit save --post-id xxx # 收藏帖子
opencli reddit comment --post-id xxx "Great!" # 发表评论 (text positional)
opencli reddit subscribe --subreddit python # 订阅子版块
opencli reddit saved --limit 10 # 我的收藏
opencli reddit upvoted --limit 10 # 我的赞
```
## V2EX (Browser Features)
```bash
opencli v2ex daily # 每日签到
opencli v2ex me # 我的信息
opencli v2ex notifications --limit 10 # 通知
```
## Weibo (微博)
```bash
opencli weibo hot --limit 10 # 微博热搜
opencli weibo search "关键词" # 搜索微博
opencli weibo feed --limit 20 # 首页时间线
opencli weibo user <uid> # 用户信息
opencli weibo me # 我的信息
opencli weibo post "内容" # 发微博
opencli weibo comments <mid> # 微博评论
```
## BOSS直聘
```bash
opencli boss search "AI agent" # 搜索职位 (query positional)
opencli boss detail --security-id xxx # 职位详情
opencli boss recommend --limit 10 # 推荐职位
opencli boss joblist --limit 10 # 职位列表
opencli boss greet --security-id xxx # 打招呼
opencli boss batchgreet --job-id xxx # 批量打招呼
opencli boss send --uid xxx "消息内容" # 发消息 (text positional)
opencli boss chatlist --limit 10 # 聊天列表
opencli boss chatmsg --security-id xxx # 聊天记录
opencli boss invite --security-id xxx # 邀请沟通
opencli boss mark --security-id xxx # 标记管理
opencli boss exchange --security-id xxx # 交换联系方式
opencli boss resume # 简历管理
opencli boss stats # 数据统计
```
## YouTube
```bash
opencli youtube search "rust" # 搜索视频 (query positional)
opencli youtube video "https://www.youtube.com/watch?v=xxx" # 视频元数据
opencli youtube transcript "https://www.youtube.com/watch?v=xxx" # 获取视频字幕/转录
opencli youtube transcript "xxx" --lang zh-Hans --mode raw # 指定语言 + 原始时间戳模式
```
## Yahoo Finance
```bash
opencli yahoo-finance quote --symbol AAPL # 股票行情
```
## Sina Finance
```bash
opencli sinafinance news --limit 10 --type 1 # 7x24实时快讯
# Types: 0=全部 1=A股 2=宏观 3=公司 4=数据 5=市场 6=国际 7=观点 8=央行 9=其它
```
## Reuters (路透社)
```bash
opencli reuters search "AI" # 路透社搜索 (query positional)
```
## SMZDM (什么值得买)
```bash
opencli smzdm search "耳机" # 搜索好价 (query positional)
```
## Ctrip (携程)
```bash
opencli ctrip search "三亚" # 搜索目的地 (query positional)
```
## Barchart
```bash
opencli barchart quote --symbol AAPL # 股票行情
opencli barchart options --symbol AAPL # 期权链
opencli barchart greeks --symbol AAPL # 期权 Greeks
opencli barchart flow --limit 20 # 异常期权活动
```
## Jike (即刻)
```bash
opencli jike feed --limit 10 # 动态流
opencli jike search "AI" # 搜索 (query positional)
opencli jike create "内容" # 发布动态 (text positional)
opencli jike like xxx # 点赞 (id positional)
opencli jike comment xxx "评论" # 评论 (id + text positional)
opencli jike repost xxx # 转发 (id positional)
opencli jike notifications # 通知
```
## Linux.do (Browser Features)
```bash
opencli linux-do categories --limit 20 # 分类列表
opencli linux-do category dev 7 # 分类内话题 (slug + id positional)
```
## WeRead (微信读书)
```bash
opencli weread shelf --limit 10 # 书架
opencli weread search "AI" # 搜索图书 (query positional)
opencli weread book xxx # 图书详情 (book-id positional)
opencli weread highlights xxx # 划线笔记 (book-id positional)
opencli weread notes xxx # 想法笔记 (book-id positional)
opencli weread ranking --limit 10 # 排行榜
```
## Jimeng (即梦 AI)
```bash
opencli jimeng generate --prompt "描述" # AI 生图
opencli jimeng history --limit 10 # 生成历史
```
## Chaoxing (超星学习通)
```bash
opencli chaoxing assignments # 作业列表
opencli chaoxing exams # 考试列表
```
## Douban (豆瓣)
```bash
opencli douban search "三体" # 搜索 (query positional)
opencli douban top250 # 豆瓣 Top 250
opencli douban subject 1234567 # 条目详情 (id positional)
opencli douban photos 30382501 # 图片列表 / 直链(默认海报)
opencli douban download 30382501 # 下载海报 / 剧照
opencli douban marks --limit 10 # 我的标记
opencli douban reviews --limit 10 # 短评
```
## Facebook
```bash
opencli facebook feed --limit 10 # 动态流
opencli facebook profile username # 用户资料 (id positional)
opencli facebook search "AI" # 搜索 (query positional)
opencli facebook friends # 好友列表
opencli facebook groups # 群组
opencli facebook events # 活动
opencli facebook notifications # 通知
opencli facebook memories # 回忆
opencli facebook add-friend username # 添加好友 (id positional)
opencli facebook join-group groupid # 加入群组 (id positional)
```
## Instagram
```bash
opencli instagram explore # 探索
opencli instagram profile username # 用户资料 (id positional)
opencli instagram search "AI" # 搜索 (query positional)
opencli instagram user username # 用户详情 (id positional)
opencli instagram followers username # 粉丝 (id positional)
opencli instagram following username # 关注 (id positional)
opencli instagram follow username # 关注用户 (id positional)
opencli instagram unfollow username # 取消关注 (id positional)
opencli instagram like postid # 点赞 (id positional)
opencli instagram unlike postid # 取消点赞 (id positional)
opencli instagram comment postid "评论" # 评论 (id + text positional)
opencli instagram save postid # 收藏 (id positional)
opencli instagram unsave postid # 取消收藏 (id positional)
opencli instagram saved # 已收藏列表
```
## TikTok
```bash
opencli tiktok explore # 探索
opencli tiktok search "AI" # 搜索 (query positional)
opencli tiktok profile username # 用户资料 (id positional)
opencli tiktok user username # 用户详情 (id positional)
opencli tiktok following username # 关注列表 (id positional)
opencli tiktok follow username # 关注 (id positional)
opencli tiktok unfollow username # 取消关注 (id positional)
opencli tiktok like videoid # 点赞 (id positional)
opencli tiktok unlike videoid # 取消点赞 (id positional)
opencli tiktok comment videoid "评论" # 评论 (id + text positional)
opencli tiktok save videoid # 收藏 (id positional)
opencli tiktok unsave videoid # 取消收藏 (id positional)
opencli tiktok live # 直播
opencli tiktok notifications # 通知
opencli tiktok friends # 朋友
```
## Medium
```bash
opencli medium feed --limit 10 # 动态流
opencli medium search "AI" # 搜索 (query positional)
opencli medium user username # 用户主页 (id positional)
```
## Substack
```bash
opencli substack feed --limit 10 # 订阅动态
opencli substack search "AI" # 搜索 (query positional)
opencli substack publication name # 出版物详情 (id positional)
```
## Sinablog (新浪博客)
```bash
opencli sinablog hot --limit 10 # 热门
opencli sinablog search "AI" # 搜索 (query positional)
opencli sinablog article url # 文章详情
opencli sinablog user username # 用户主页 (id positional)
```
## Coupang (쿠팡)
```bash
opencli coupang search "耳机" # 搜索商品 (query positional, 支持 --filter rocket)
opencli coupang add-to-cart 12345 # 加入购物车 (product-id positional, 或 --url)
```
## Yollomi (browser — 需在 Chrome 登录 yollomi.com)
```bash
opencli yollomi models --type image # 列出图像模型与积分
opencli yollomi generate "提示词" --model z-image-turbo # 文生图
opencli yollomi video "提示词" --model kling-2-1 # 视频
opencli yollomi upload ./photo.jpg # 上传得 URL,供 img2img / 工具链使用
opencli yollomi remove-bg <image-url> # 去背景(免费)
opencli yollomi edit <image-url> "改成油画风格" # Qwen 图像编辑
opencli yollomi background <image-url> # AI 背景生成 (5 credits)
opencli yollomi face-swap --source <url> --target <url> # 换脸 (3 credits)
opencli yollomi object-remover <image-url> <mask-url> # AI 去除物体 (3 credits)
opencli yollomi restore <image-url> # AI 修复老照片 (4 credits)
opencli yollomi try-on --person <url> --cloth <url> # 虚拟试衣 (3 credits)
opencli yollomi upscale <image-url> # AI 超分辨率 (1 credit, 支持 --scale 2/4)
```
## Doubao Web (豆包)
```bash
opencli doubao status # 检查豆包页面状态
opencli doubao new # 新建对话
opencli doubao send "你好" # 发送消息 (text positional)
opencli doubao read # 读取对话记录
opencli doubao ask "问题" # 一键提问并等回复 (text positional)
opencli doubao detail <id> # 对话详情
opencli doubao history # 历史对话列表
opencli doubao meeting-summary <id> # 会议总结
opencli doubao meeting-transcript <id> # 会议记录
```
## Grok
```bash
opencli grok ask --prompt "问题" # 提问 Grok(兼容默认路径)
opencli grok ask --prompt "问题" --web # 显式 grok.com consumer web UI 路径
```
## Pixiv
```bash
opencli pixiv ranking --limit 20 # 插画排行榜 (支持 --mode daily/weekly/monthly)
opencli pixiv search "風景" # 搜索插画 (query positional)
opencli pixiv user 12345 # 画师资料 (uid positional)
opencli pixiv illusts 12345 # 画师作品列表 (user-id positional)
opencli pixiv detail 12345 # 插画详情 (id positional)
opencli pixiv download 12345 # 下载插画 (illust-id positional)
```
## Web
```bash
opencli web read --url "https://..." # 抓取任意网页并导出为 Markdown
```
## Weixin (微信公众号)
```bash
opencli weixin download --url "https://mp.weixin.qq.com/s/xxx" # 下载公众号文章为 Markdown
```
## JD (京东)
```bash
opencli jd item 100291143898 # 商品详情 (sku positional, 含价格/主图/规格)
```
## LinkedIn
```bash
opencli linkedin search "AI engineer" # 搜索职位 (query positional, 支持 --location/--company/--remote)
opencli linkedin timeline --limit 20 # 首页动态流
```
## Bloomberg (Browser - Full Article)
```bash
opencli bloomberg news "https://..." # 阅读 Bloomberg 文章全文 (link positional, browser)
```
## Gemini
```bash
opencli gemini ask "问题" # 提问 (prompt positional)
opencli gemini new # 新建对话
opencli gemini image "描述" # 生成图片
```
## NotebookLM
```bash
opencli notebooklm status # 检查页面状态
opencli notebooklm list # 列出所有笔记本
opencli notebooklm open <notebook> # 打开笔记本
opencli notebooklm current # 当前笔记本信息
opencli notebooklm get # 获取笔记本详情
opencli notebooklm history # 对话历史
opencli notebooklm summary # 笔记本摘要
opencli notebooklm source-list # 列出来源
opencli notebooklm source-get <source> # 获取来源详情
opencli notebooklm source-fulltext <src> # 来源全文
opencli notebooklm source-guide <src> # 来源指南
opencli notebooklm note-list # 笔记列表
opencli notebooklm notes-get <note> # 获取笔记内容
```
## Bluesky
```bash
opencli bluesky search "关键词" # 搜索帖子 (query positional)
opencli bluesky profile <handle> # 用户资料
opencli bluesky user <handle> # 用户详情
opencli bluesky feeds <handle> # 用户 feeds
opencli bluesky followers <handle> # 粉丝列表
opencli bluesky following <handle> # 关注列表
opencli bluesky thread <uri> # 帖子线程
opencli bluesky trending # 热门话题
opencli bluesky starter-packs # Starter packs
```
## Douyin (抖音)
```bash
opencli douyin profile # 创作者资料
opencli douyin videos --limit 10 # 浏览视频
opencli douyin user-videos # 我的作品列表
opencli douyin activities # 动态
opencli douyin collections # 收藏夹
opencli douyin hashtag <tag> # 话题页
opencli douyin location <poi> # 地点页
opencli douyin stats # 数据统计
opencli douyin publish # 发布视频
opencli douyin draft # 编辑草稿
opencli douyin drafts # 草稿列表
opencli douyin delete <id> # 删除作品
opencli douyin update <id> # 更新作品信息
```
## Band
```bash
opencli band bands # 列出已加入的 bands
opencli band posts <band-id> # Band 帖子列表
opencli band post <post-key> # 帖子详情
opencli band mentions # 提到我的消息
```
## ZSXQ (知识星球)
```bash
opencli zsxq groups # 我加入的星球
opencli zsxq dynamics <group-id> # 星球动态
opencli zsxq topics <group-id> # 主题列表
opencli zsxq topic <topic-id> # 主题详情
opencli zsxq search "关键词" # 搜索
```
## Tieba (百度贴吧)
```bash
opencli tieba hot # 热门贴吧
opencli tieba search "关键词" # 搜索
opencli tieba posts <forum> # 帖子列表
opencli tieba read <thread-id> # 阅读帖子
```
## 36kr
```bash
opencli 36kr hot # 热门文章
opencli 36kr news # 最新资讯
opencli 36kr search "关键词" # 搜索文章
opencli 36kr article <id> # 文章全文
```
## ONES
```bash
opencli ones login # 登录
opencli ones me # 我的信息
opencli ones tasks --team <id> # 项目任务列表
opencli ones my-tasks # 我的任务
opencli ones task <id> # 任务详情
opencli ones worklog --task <id> # 工时日志
opencli ones token-info # Token 信息
opencli ones logout # 登出
```
+118
View File
@@ -0,0 +1,118 @@
# Desktop Adapter Commands
Commands that interact with desktop applications via CDP (Chrome DevTools Protocol) on Electron apps, or external CLI tools.
## GitHub (via gh CLI)
```bash
opencli gh repo list # 列出仓库 (passthrough to gh)
opencli gh pr list --limit 5 # PR 列表
opencli gh issue list # Issue 列表
```
## Cursor (desktop — CDP via Electron)
```bash
opencli cursor status # 检查连接
opencli cursor send "message" # 发送消息
opencli cursor read # 读取回复
opencli cursor new # 新建对话
opencli cursor dump # 导出 DOM 调试信息
opencli cursor composer # Composer 模式
opencli cursor model claude # 切换模型
opencli cursor extract-code # 提取代码块
opencli cursor ask "question" # 一键提问并等回复
opencli cursor screenshot # 截图
opencli cursor history # 对话历史
opencli cursor export # 导出对话
```
## Codex (desktop — headless CLI agent)
```bash
opencli codex status # 检查连接
opencli codex send "message" # 发送消息
opencli codex read # 读取回复
opencli codex new # 新建对话
opencli codex dump # 导出调试信息
opencli codex extract-diff # 提取 diff
opencli codex model gpt-4 # 切换模型
opencli codex ask "question" # 一键提问并等回复
opencli codex screenshot # 截图
opencli codex history # 对话历史
opencli codex export # 导出对话
```
## ChatGPT (desktop — macOS AppleScript/CDP)
```bash
opencli chatgpt status # 检查应用状态
opencli chatgpt new # 新建对话
opencli chatgpt send "message" # 发送消息
opencli chatgpt read # 读取回复
opencli chatgpt ask "question" # 一键提问并等回复
```
## ChatWise (desktop — multi-LLM client)
```bash
opencli chatwise status # 检查连接
opencli chatwise new # 新建对话
opencli chatwise send "message" # 发送消息
opencli chatwise read # 读取回复
opencli chatwise ask "question" # 一键提问并等回复
opencli chatwise model claude # 切换模型
opencli chatwise history # 对话历史
opencli chatwise export # 导出对话
opencli chatwise screenshot # 截图
```
## Notion (desktop — CDP via Electron)
```bash
opencli notion status # 检查连接
opencli notion search "keyword" # 搜索页面
opencli notion read # 读取当前页面
opencli notion new # 新建页面
opencli notion write "content" # 写入内容
opencli notion sidebar # 侧边栏导航
opencli notion favorites # 收藏列表
opencli notion export # 导出
```
## Discord App (desktop — CDP via Electron)
```bash
opencli discord-app status # 检查连接
opencli discord-app send "message" # 发送消息
opencli discord-app read # 读取消息
opencli discord-app channels # 频道列表
opencli discord-app servers # 服务器列表
opencli discord-app search "keyword" # 搜索
opencli discord-app members # 成员列表
```
## Doubao App 豆包桌面版 (desktop — CDP via Electron)
```bash
opencli doubao-app status # 检查连接
opencli doubao-app new # 新建对话
opencli doubao-app send "message" # 发送消息
opencli doubao-app read # 读取回复
opencli doubao-app ask "question" # 一键提问并等回复
opencli doubao-app screenshot # 截图
opencli doubao-app dump # 导出 DOM 调试信息
```
## Antigravity (Electron/CDP)
```bash
opencli antigravity status # 检查 CDP 连接
opencli antigravity send "hello" # 发送文本到当前 agent 聊天框
opencli antigravity read # 读取整个聊天记录面板
opencli antigravity new # 清空聊天、开启新对话
opencli antigravity dump # 导出 DOM 和快照调试信息
opencli antigravity extract-code # 自动抽取 AI 回复中的代码块
opencli antigravity model claude # 切换底层模型
opencli antigravity watch # 流式监听增量消息
```
+82
View File
@@ -0,0 +1,82 @@
# Plugin System & Advanced Features
## Management Commands
```bash
opencli list # List all commands (including External CLIs)
opencli list --json # JSON output
opencli list -f yaml # YAML output
opencli install <name> # Auto-install an external CLI (e.g., gh, obsidian)
opencli register <name> # Register a local custom CLI for unified discovery
opencli validate # Validate all CLI definitions
opencli validate bilibili # Validate specific site
opencli doctor # Diagnose browser bridge (auto-starts daemon, includes live test)
```
## AI Agent Workflow
```bash
# Deep Explore: network intercept → response analysis → capability inference
opencli explore <url> --site <name>
# Synthesize: generate evaluate-based YAML pipelines from explore artifacts
opencli synthesize <site>
# Generate: one-shot explore → synthesize → register
opencli generate <url> --goal "hot"
# Record: YOU operate the page, opencli captures every API call → YAML candidates
opencli record <url> # 录制,site name 从域名推断
opencli record <url> --site mysite # 指定 site name
opencli record <url> --timeout 120000 # 自定义超时(毫秒,默认 60000)
opencli record <url> --poll 1000 # 缩短轮询间隔(毫秒,默认 2000)
opencli record <url> --out .opencli/record/x # 自定义输出目录
# Strategy Cascade: auto-probe PUBLIC → COOKIE → HEADER
opencli cascade <api-url>
# Explore with interactive fuzzing (click buttons to trigger lazy APIs)
opencli explore <url> --auto --click "字幕,CC,评论"
# Validate: validate adapter definitions
opencli validate
```
## Output Formats
All built-in commands support `--format` / `-f` with `table`, `json`, `yaml`, `md`, and `csv`.
```bash
opencli list -f yaml # YAML command registry
opencli bilibili hot -f table # Default: rich table
opencli bilibili hot -f json # JSON (pipe to jq, feed to AI agent)
opencli bilibili hot -f yaml # YAML (readable structured output)
opencli bilibili hot -f md # Markdown
opencli bilibili hot -f csv # CSV
```
## Verbose Mode
```bash
opencli bilibili hot -v # Show each pipeline step and data flow
```
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `OPENCLI_DAEMON_PORT` | 19825 | Daemon listen port |
| `OPENCLI_BROWSER_CONNECT_TIMEOUT` | 30 | Browser connection timeout (sec) |
| `OPENCLI_BROWSER_COMMAND_TIMEOUT` | 45 | Command execution timeout (sec) |
| `OPENCLI_BROWSER_EXPLORE_TIMEOUT` | 120 | Explore timeout (sec) |
| `OPENCLI_VERBOSE` | — | Show daemon/extension logs |
## Troubleshooting
| Issue | Solution |
|-------|----------|
| `npx not found` | Install Node.js: `brew install node` |
| `Extension not connected` | 1) Chrome must be open 2) Install opencli Browser Bridge extension |
| `Target page context` error | Add `navigate:` step before `evaluate:` in YAML |
| Empty table data | Check if evaluate returns correct data path |
| Daemon issues | `curl localhost:19825/status` to check, `curl localhost:19825/logs` for extension logs |
+178
View File
@@ -0,0 +1,178 @@
# Public API Commands
Commands that work without browser or authentication.
## Hacker News
```bash
opencli hackernews top --limit 10 # Top stories
opencli hackernews new --limit 10 # Newest stories
opencli hackernews best --limit 10 # Best stories
opencli hackernews ask --limit 10 # Ask HN posts
opencli hackernews show --limit 10 # Show HN posts
opencli hackernews jobs --limit 10 # Job postings
opencli hackernews search "rust" # 搜索 (query positional)
opencli hackernews user dang # 用户资料 (username positional)
```
## V2EX (Public Features)
```bash
opencli v2ex hot --limit 10 # 热门话题
opencli v2ex latest --limit 10 # 最新话题
opencli v2ex topic 1024 # 主题详情 (id positional)
opencli v2ex node python # 节点话题列表 (name positional)
opencli v2ex nodes --limit 30 # 所有节点列表
opencli v2ex member username # 用户资料 (username positional)
opencli v2ex user username # 用户发帖列表 (username positional)
opencli v2ex replies 1024 # 主题回复列表 (id positional)
```
## BBC News
```bash
opencli bbc news --limit 10 # BBC News RSS headlines
```
## Sina Finance
```bash
opencli sinafinance news --limit 10 --type 1 # 7x24实时快讯
# Types: 0=全部 1=A股 2=宏观 3=公司 4=数据 5=市场 6=国际 7=观点 8=央行 9=其它
```
## Lobsters
```bash
opencli lobsters hot --limit 10 # 热门
opencli lobsters newest --limit 10 # 最新
opencli lobsters active --limit 10 # 活跃
opencli lobsters tag rust # 按标签筛选 (tag positional)
```
## Google
```bash
opencli google news --limit 10 # 新闻
opencli google search "AI" # 搜索 (query positional)
opencli google suggest "AI" # 搜索建议 (query positional)
opencli google trends # 趋势
```
## DEV.to
```bash
opencli devto top --limit 10 # 热门文章
opencli devto tag javascript --limit 10 # 按标签 (tag positional)
opencli devto user username # 用户文章 (username positional)
```
## Steam
```bash
opencli steam top-sellers --limit 10 # 热销游戏
```
## Apple Podcasts
```bash
opencli apple-podcasts top --limit 10 # 热门播客排行榜 (支持 --country us/cn/gb/jp)
opencli apple-podcasts search "科技" # 搜索播客 (query positional)
opencli apple-podcasts episodes 12345 # 播客剧集列表 (id positional)
```
## arXiv
```bash
opencli arxiv search "attention" # 搜索论文 (query positional)
opencli arxiv paper 1706.03762 # 论文详情 (id positional)
```
## StackOverflow
```bash
opencli stackoverflow hot --limit 10 # 热门问题
opencli stackoverflow search "typescript" # 搜索 (query positional)
opencli stackoverflow bounties --limit 10 # 悬赏问题
```
## Xiaoyuzhou (小宇宙)
```bash
opencli xiaoyuzhou podcast 12345 # 播客资料 (id positional)
opencli xiaoyuzhou podcast-episodes 12345 # 播客剧集列表 (id positional)
opencli xiaoyuzhou episode 12345 # 单集详情 (id positional)
```
## Wikipedia
```bash
opencli wikipedia search "AI" # 搜索 (query positional)
opencli wikipedia summary "Python" # 摘要 (title positional)
opencli wikipedia random # 随机条目
opencli wikipedia trending # 热门条目
```
## Bloomberg (RSS)
```bash
opencli bloomberg main --limit 10 # Bloomberg 首页头条
opencli bloomberg markets --limit 10 # 市场新闻
opencli bloomberg tech --limit 10 # 科技新闻
opencli bloomberg politics --limit 10 # 政治新闻
opencli bloomberg economics --limit 10 # 经济新闻
opencli bloomberg opinions --limit 10 # 观点
opencli bloomberg industries --limit 10 # 行业新闻
opencli bloomberg businessweek --limit 10 # Businessweek
opencli bloomberg feeds # 列出所有 RSS feed 别名
```
## Dictionary
```bash
opencli dictionary search "serendipity" # 单词释义 (word positional)
opencli dictionary synonyms "happy" # 近义词 (word positional)
opencli dictionary examples "ubiquitous" # 例句 (word positional)
```
## HuggingFace
```bash
opencli hf top --limit 10 # 热门模型
```
## Product Hunt
```bash
opencli producthunt today --limit 10 # 今日产品
opencli producthunt hot --limit 10 # 热门产品
opencli producthunt browse --limit 10 # 浏览产品
opencli producthunt posts --limit 10 # 最新产品
```
## IMDB
```bash
opencli imdb top --limit 10 # Top 250
opencli imdb trending --limit 10 # 热门影视
opencli imdb search "关键词" # 搜索 (query positional)
opencli imdb title <id> # 影视详情
opencli imdb person <id> # 演员详情
opencli imdb reviews <id> # 评论
```
## Spotify
```bash
opencli spotify auth # OAuth 授权
opencli spotify status # 播放状态
opencli spotify play "歌曲" # 播放 (query positional)
```
## Paper Review
```bash
opencli paperreview submit # 提交论文
opencli paperreview review # 审阅
opencli paperreview feedback # 反馈
```
+156
View File
@@ -0,0 +1,156 @@
---
name: smart-search
description: 基于 opencli 命令的智能搜索路由器。当用户想要搜索、查询、查找或研究信息时,尤其是涉及指定网站、社交媒体、技术资料、新闻、购物、旅游、求职、金融或中文内容时,务必使用此 skill
---
# 智能搜索路由器
根据话题和场景,将查询路由到最佳的 opencli 搜索源。此 skill 的核心目标不是记忆命令,而是先定位数据源,再让 Agent 通过 `opencli` 自己读取实时帮助,避免文档漂移。
## 强制预检
每次使用前,必须先做下面两步:
- 运行 `opencli list -f yaml`
- 用 live registry 确认候选站点是否存在,并检查 `strategy``browser``domain`
选定站点后,必须再做下面两步:
- 运行 `opencli <site> -h` 查看该站点有哪些子命令
- 若已锁定某个子命令,再运行 `opencli <site> <command> -h` 查看参数、输出列、策略
不要在 skill 文档里硬编码参数或假设命令签名;以 `opencli ... -h` 的实时输出为准。
## 主路由规则
只使用这一条规则,不再维护多套优先级:
1. 当用户明确指定网站、平台或数据源时,直接使用对应网站。
2. 当用户没有指定网站时,优先只选择一个 AI 源:`grok``doubao``gemini` 三选一。
3. 当 AI 返回内容不足、缺少原始数据、需要权威佐证或需要垂直结果时,再补充 1-2 个专用源。
## 单题预算与频率限制
把“单个用户问题”理解为同一意图链路下的一次问题求解;同一轮追问、澄清、补充条件,若核心问题未变,仍算同一题。
先建立一份站点调用台账。每次真正执行搜索命令后,立刻更新:
- `site`
- `query`
- `count`
- `status`
计数规则:
- `opencli list -f yaml``opencli <site> -h``opencli <site> <command> -h` 属于预检与帮助,不计入搜索次数
- 一次真正的 `opencli <site> ...` 搜索/查询执行,计为该站点 1 次调用
- 同站点因为报错、超时、验证码、反爬、登录态异常而失败,也算 1 次调用,不要无限重试
频率上限:
- AI 站点硬限制:同一题内,每个 AI 站点最多调用 1 次
- 默认策略仍然是只选 1 个 AI 站点,不要把多个 AI 站点串成常规流程
- 只有当用户明确要求比较多个 AI 站点时,才可以额外调用其他 AI 站点;但每个被点名的 AI 站点仍然最多 1 次
- 非 AI 站点默认最多调用 2 次
- 非 AI 站点第 2 次调用必须有明确理由,例如第一次结果过宽,需要加时间、地区、类别、排序或关键词限定
- 非 AI 站点不要进行第 3 次调用;若信息仍不足,停止扩搜并明确说明缺口
触发限频后的处理:
- 记录:「已跳过:<site> 达到频率上限」
- 优先改用其他同类站点
- 若没有合适替代源,则直接基于已收集信息回答,并说明覆盖范围与缺口
## 查询结束汇报
每次查询结束后,回答末尾必须追加一段简短的“搜索摘要”,至少包含下面三项:
- 使用了什么网站搜索
- 每个网站搜了什么词
- 每个网站搜了几次
如果有被限频跳过的站点,也要明确写出。
建议使用下面的固定格式:
```md
搜索摘要
- 网站:<site1> | 查询词:<term1> | 次数:<n>
- 网站:<site2> | 查询词:<term2><term3> | 次数:<n>
- 已跳过:<site3>,原因:达到频率上限
```
## AI 源选择
- `grok`
适合实时讨论、英文互联网舆论、Twitter/X 语境、热点追踪。
- `doubao`
适合中文语境、字节抖音生态、生活方式内容、中文热点与泛中文问答。
- `gemini`
适合全球网页、英文资料、通用信息检索、背景综述。
如果用户没有指定网站,默认先判断语言和语境,再从这三个里只选一个。
一旦某个 AI 站点已经执行过一次真实查询,就不要在同一题里改写关键词后再次调用该 AI 站点。若答案不足,优先补专用源,不要反复追打同一个 AI 站点。
## AI 查询词建议
当使用 AI 源时,不要只丢一个过短关键词。优先构造成“主题 + 目标 + 限定条件”的查询。
- 主题
用户真正要查的对象、事件、产品、人物、公司、技术名词。
- 目标
想要什么结果,例如总结、对比、原因、趋势、推荐、原始线索。
- 限定条件
语言、地区、时间范围、平台范围、受众、价格带、岗位地点、是否要引用原始来源。
优先使用下面这种表达方式:
- `<主题> + <你要回答的问题>`
- `<主题> + <时间范围/地区/语言>`
- `<主题> + <平台或来源范围>`
- `<主题> + <输出要求>`
避免只输入:
- 单个名词
- 没有时间范围的热点问题
- 没有地区限制的购物、求职、旅游问题
- 没有平台限制的社交媒体问题
## 专用源补充时机
当出现以下任一情况时,再补充专用源:
- AI 给出的是摘要,但你需要原始帖子、原始视频、原始商品或原始职位结果
- AI 覆盖面不足,漏掉垂直站点信息
- 需要更高权威性或更强领域相关性
- 用户明确要求“从某个平台找”
单次查询通常控制在 1 个 AI 源 + 1 到 2 个专用源,避免结果过载。
## 处理不可用的源
当站点不可用时:
- 不要因为单个源失败而中止整个搜索
- 记录:「已跳过:<site> 不可用」
- 回退到同类其他站点,或回退到一个 AI 源
- 始终以 `opencli list -f yaml``opencli <site> -h` 的实际结果为准
不要假设任何站点“绝对可用”。即使是公开站点,也以当前环境中的 live help 和执行结果为准。
## 参考文件
根据需要读取对应文件:
- **`references/sources-ai.md`** — AI 默认源
- **`references/sources-tech.md`** — 技术 / 学术
- **`references/sources-social.md`** — 社交媒体
- **`references/sources-media.md`** — 媒体 / 娱乐
- **`references/sources-info.md`** — 资讯 / 知识
- **`references/sources-shopping.md`** — 购物
- **`references/sources-travel.md`** — 旅游
- **`references/sources-other.md`** — 其他垂直源
只读与当前查询相关的文件,无需全部加载。
@@ -0,0 +1,74 @@
# AI 默认源
当用户没有明确指定网站时,先在 `grok``doubao``gemini` 中选择一个,不要一开始并行跑多个 AI 源。
## 使用规则
1. 先运行 `opencli list -f yaml`
2. 确认 `grok``doubao``gemini` 哪些在当前 registry 中可用
3. 运行 `opencli <site> -h`
4. 锁定具体子命令后,再运行 `opencli <site> <command> -h`
## 路由建议
### grok
- 适用:实时热点、Twitter/X 语境、英文互联网讨论、舆论与趋势
- 补充源常见去向:`twitter``reddit``reuters``google`
- 搜索词建议:
- 加上时间范围,例如“today / this week”
- 加上平台范围,例如“on X”, “from social posts”
- 加上目标,例如“latest reactions”, “main viewpoints”, “key claims”
- 示例:
- `OpenAI latest reactions on X this week`
- `TSLA earnings main viewpoints on social media April 2026`
- `Nintendo Switch 2 rumors latest discussion on X`
### doubao
- 适用:中文语境、国内热点、生活方式、字节生态、中文泛问答
- 补充源常见去向:`xiaohongshu``weibo``zhihu``bilibili``36kr`
- 搜索词建议:
- 加上中文场景限定,例如“中文讨论”“国内用户”“小红书/微博语境”
- 加上需求目标,例如“帮我总结”“给我对比”“提取推荐理由”
- 加上人群或用途,例如“适合新手”“预算 500 元内”“上海求职”
- 示例:
- `2026年MacBook Air值得买吗 中文讨论里主要观点是什么`
- `上海 AI 产品经理招聘趋势 近一个月中文信息总结`
- `敏感肌防晒推荐 中文用户常提到的优缺点`
### gemini
- 适用:全球网页、英文资料、背景综述、通用检索
- 补充源常见去向:`google``wikipedia``arxiv``stackoverflow`
- 搜索词建议:
- 加上主题类型,例如“overview”, “comparison”, “background”, “best sources”
- 加上范围限定,例如地区、时间、语言、行业
- 加上结果形式,例如“with sources”, “compare pros and cons”, “official guidance”
- 示例:
- `MCP overview and official guidance with sources`
- `best budget travel destinations in Japan April 2026 compare pros and cons`
- `TypeScript decorators current status official sources`
## 补充原则
- 先用一个 AI 源拿到初步答案
- 若答案缺少原始数据、垂直结果或权威来源,再补 1-2 个专用源
- 不要把 AI 默认源当成命令签名的事实来源;命令细节始终以 `opencli ... -h` 为准
## 通用写法模板
可直接按下面模板构造 AI 查询:
- 热点/新闻:
`<事件> + 最新进展 + <时间范围> + <地区/平台>`
- 对比/推荐:
`<对象A> vs <对象B> + <关注维度> + <人群/预算/用途>`
- 中文社区:
`<主题> + 中文讨论里主要观点 + <时间范围>`
- 全球资料:
`<主题> + overview/background + with sources`
- 求职:
`<岗位> + <城市/国家> + market trends/hiring + <时间范围>`
- 购物:
`<商品> + reviews/price/value + <地区/预算>`
@@ -0,0 +1,43 @@
# 资讯 / 知识
用于新闻、百科、通用知识、中文科技资讯。
## 站点
### google
- 适用:通用网页搜索、跨站点兜底
- 使用前先运行:`opencli google -h`
### wikipedia
- 适用:名词解释、背景知识、历史事实
- 使用前先运行:`opencli wikipedia -h`
### reuters
- 适用:国际新闻、事实性报道
- 使用前先运行:`opencli reuters -h`
### weibo
- 适用:微博热点、话题、中文舆论
- 使用前先运行:`opencli weibo -h`
### 36kr
- 适用:中文科技、创业、融资资讯
- 使用前先运行:`opencli 36kr -h`
### substack
- 适用:newsletter、作者订阅内容、长文
- 使用前先运行:`opencli substack -h`
## 路由提示
- 背景知识与定义,优先 `wikipedia`
- 通用网页与跨站信息,优先 `google`
- 国际新闻优先 `reuters`
- 中文科技资讯优先 `36kr`
- 当 AI 回答过于泛化时,用这些源补充事实和链接
@@ -0,0 +1,50 @@
# 媒体 / 娱乐
用于视频、电影、剧集、音乐、播客、内容平台搜索。
## 站点
### bilibili
- 适用:中文视频、教程、评测、UP 主内容
- 使用前先运行:`opencli bilibili -h`
### youtube
- 适用:英文视频、教程、评测、访谈
- 使用前先运行:`opencli youtube -h`
### tiktok
- 适用:短视频、热点、娱乐内容
- 使用前先运行:`opencli tiktok -h`
### imdb
- 适用:电影、剧集、演员、评分
- 使用前先运行:`opencli imdb -h`
### douban
- 适用:中文电影、书籍、音乐口碑与评分
- 使用前先运行:`opencli douban -h`
### spotify
- 适用:歌曲、歌手、专辑
- 使用前先运行:`opencli spotify -h`
### apple-podcasts
- 适用:播客节目、播客搜索
- 使用前先运行:`opencli apple-podcasts -h`
### medium
- 适用:长文、技术博客、设计与产品文章
- 使用前先运行:`opencli medium -h`
## 路由提示
- 用户只说“找视频/影评/播客/文章”,先按内容形态选站点
- 用户没有指定站点时,可先用 `doubao``grok``gemini` 做一轮粗检索,再补媒体站点拿原始结果
@@ -0,0 +1,42 @@
# 其他垂直源
用于求职、金融、书籍、词典等不适合归入前述分类的场景。
## 站点
### linkedin
- 适用:全球职位、英文岗位、跨国公司招聘
- 使用前先运行:`opencli linkedin -h`
### boss
- 适用:国内职位搜索、招聘与岗位信息
- 使用前先运行:`opencli boss -h`
### xueqiu
- 适用:股票、金融讨论、行情相关线索
- 使用前先运行:`opencli xueqiu -h`
### weread
- 适用:中文书籍搜索与书单线索
- 使用前先运行:`opencli weread -h`
### dictionary
- 适用:英文词义、基础词典查询
- 使用前先运行:`opencli dictionary -h`
### sinablog
- 适用:较旧的中文博客内容
- 使用前先运行:`opencli sinablog -h`
## 路由提示
- 求职:全球优先 `linkedin`,国内优先 `boss`
- 金融:优先 `xueqiu`
- 书籍:中文优先 `weread`
- 英文单词定义:优先 `dictionary`
@@ -0,0 +1,31 @@
# 购物
用于商品搜索、价格、好价、口碑与区域电商结果。
## 站点
### amazon
- 适用:全球商品搜索、价格参考、英文电商
- 使用前先运行:`opencli amazon -h`
### smzdm
- 适用:国内好价、优惠、导购、商品讨论
- 使用前先运行:`opencli smzdm -h`
### coupang
- 适用:韩国电商商品搜索
- 使用前先运行:`opencli coupang -h`
### douban
- 适用:图书、影视、音乐类口碑补充,不是标准电商,但可用于消费决策补充
- 使用前先运行:`opencli douban -h`
## 路由提示
- 用户指定平台时直接使用该平台
- 未指定平台时,全球商品优先 `amazon`,国内好价优先 `smzdm`
- 若先用 AI 做产品调研,后续可补电商站点拿实际商品结果
@@ -0,0 +1,51 @@
# 社交媒体
用于需要原始帖子、原始用户结果、中文或英文社区讨论时。
## 站点
### twitter
- 适用:Twitter/X 原始帖子、作者、实时讨论
- 使用前先运行:`opencli twitter -h`
### weibo
- 适用:微博热点、话题、中文舆论
- 使用前先运行:`opencli weibo -h`
### xiaohongshu
- 适用:生活方式、穿搭、美妆、旅行、真实体验
- 使用前先运行:`opencli xiaohongshu -h`
### zhihu
- 适用:中文深度问答、专业解释、行业经验
- 使用前先运行:`opencli zhihu -h`
### tieba
- 适用:兴趣圈子、历史帖子、粉丝社区
- 使用前先运行:`opencli tieba -h`
### instagram
- 适用:账号搜索、图片社交线索
- 使用前先运行:`opencli instagram -h`
### facebook
- 适用:主页、人物、帖子线索
- 使用前先运行:`opencli facebook -h`
### bluesky
- 适用:Bluesky 用户与账号搜索
- 使用前先运行:`opencli bluesky -h`
## 路由提示
- 用户明确指定某个平台时,直接用该平台
- 用户只说“社交媒体上怎么看”时,可先选 `grok``doubao`
- AI 只给摘要而没有原始帖子时,再切到对应社交站点
@@ -0,0 +1,42 @@
# 技术 / 学术
用于技术问题、研究论文、开发者讨论、开源社区信息。
## 站点
### arxiv
- 适用:论文、研究、模型、算法、学术背景
- 使用前先运行:`opencli arxiv -h`
### stackoverflow
- 适用:具体报错、API 用法、代码模式、实现细节
- 使用前先运行:`opencli stackoverflow -h`
### hackernews
- 适用:技术社区讨论、开发者观点、创业和产品话题
- 使用前先运行:`opencli hackernews -h`
### reddit
- 适用:英文社区问答、经验贴、推荐、对比讨论
- 使用前先运行:`opencli reddit -h`
### linux-do
- 适用:人工智能、开源工具、中文技术社区
- 使用前先运行:`opencli linux-do -h`
### v2ex
- 适用:中文技术社区
- 使用前先运行:`opencli linux-do
## 路由提示
- 用户明确说“论文”“研究”时,优先 `arxiv`
- 用户明确说“报错”“API 怎么用”时,优先 `stackoverflow`
- 用户要“社区讨论”“开发者观点”时,优先 `hackernews``reddit`
- 若用户没有明确指定站点,可先选 `gemini``grok`,内容不足时再补这些专用源
@@ -0,0 +1,20 @@
# 旅游
用于目的地、景区、酒店联想和旅行线索。
## 站点
### ctrip
- 适用:目的地、景区、酒店、旅行联想搜索
- 使用前先运行:`opencli ctrip -h`
### xiaohongshu
- 适用:生活方式、穿搭、美妆、旅行、真实体验
- 使用前先运行:`opencli xiaohongshu -h`
## 路由提示
- 用户明确说“携程”时直接使用 `ctrip`
- 用户只说“旅行/目的地/景区”时,可先选 `doubao` 做中文语境粗检索,再补 `ctrip`
+16 -16
View File
@@ -104,43 +104,43 @@ describe('browser helpers', () => {
describe('BrowserBridge state', () => {
it('transitions to closed after close()', async () => {
const mcp = new BrowserBridge();
const bridge = new BrowserBridge();
expect(mcp.state).toBe('idle');
expect(bridge.state).toBe('idle');
await mcp.close();
await bridge.close();
expect(mcp.state).toBe('closed');
expect(bridge.state).toBe('closed');
});
it('rejects connect() after the session has been closed', async () => {
const mcp = new BrowserBridge();
await mcp.close();
const bridge = new BrowserBridge();
await bridge.close();
await expect(mcp.connect()).rejects.toThrow('Session is closed');
await expect(bridge.connect()).rejects.toThrow('Session is closed');
});
it('rejects connect() while already connecting', async () => {
const mcp = new BrowserBridge();
(mcp as any)._state = 'connecting';
const bridge = new BrowserBridge();
(bridge as any)._state = 'connecting';
await expect(mcp.connect()).rejects.toThrow('Already connecting');
await expect(bridge.connect()).rejects.toThrow('Already connecting');
});
it('rejects connect() while closing', async () => {
const mcp = new BrowserBridge();
(mcp as any)._state = 'closing';
const bridge = new BrowserBridge();
(bridge as any)._state = 'closing';
await expect(mcp.connect()).rejects.toThrow('Session is closing');
await expect(bridge.connect()).rejects.toThrow('Session is closing');
});
it('fails fast when daemon is running but extension is disconnected', async () => {
vi.spyOn(daemonClient, 'isExtensionConnected').mockResolvedValue(false);
vi.spyOn(daemonClient, 'isDaemonRunning').mockResolvedValue(true);
vi.spyOn(daemonClient, 'fetchDaemonStatus').mockResolvedValue({ extensionConnected: false } as any);
const mcp = new BrowserBridge();
const bridge = new BrowserBridge();
await expect(mcp.connect()).rejects.toThrow('Browser Extension is not connected');
await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Browser Extension is not connected');
});
});
+228
View File
@@ -0,0 +1,228 @@
/**
* BasePage — shared IPage method implementations for DOM helpers.
*
* Both Page (daemon-backed) and CDPPage (direct CDP) execute JS the same way
* for DOM operations. This base class deduplicates ~200 lines of identical
* click/type/scroll/wait/snapshot/interceptor methods.
*
* Subclasses implement the transport-specific methods: goto, evaluate,
* getCookies, screenshot, tabs, etc.
*/
import type { BrowserCookie, IPage, ScreenshotOptions, SnapshotOptions, WaitOptions } from '../types.js';
import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
import {
clickJs,
typeTextJs,
pressKeyJs,
waitForTextJs,
waitForCaptureJs,
waitForSelectorJs,
scrollJs,
autoScrollJs,
networkRequestsJs,
waitForDomStableJs,
} from './dom-helpers.js';
import { formatSnapshot } from '../snapshotFormatter.js';
export abstract class BasePage implements IPage {
protected _lastUrl: string | null = null;
/** Cached previous snapshot hashes for incremental diff marking */
private _prevSnapshotHashes: string | null = null;
// ── Transport-specific methods (must be implemented by subclasses) ──
abstract goto(url: string, options?: { waitUntil?: 'load' | 'none'; settleMs?: number }): Promise<void>;
abstract evaluate(js: string): Promise<unknown>;
abstract getCookies(opts?: { domain?: string; url?: string }): Promise<BrowserCookie[]>;
abstract screenshot(options?: ScreenshotOptions): Promise<string>;
abstract tabs(): Promise<unknown[]>;
abstract selectTab(index: number): Promise<void>;
// ── Shared DOM helper implementations ──
async click(ref: string): Promise<void> {
const result = await this.evaluate(clickJs(ref)) as
| string
| { status: string; x?: number; y?: number; w?: number; h?: number; error?: string }
| null;
// Backwards compat: old format returned 'clicked' string
if (typeof result === 'string' || result == null) return;
// JS click succeeded
if (result.status === 'clicked') return;
// JS click failed — try CDP native click if coordinates available
if (result.x != null && result.y != null) {
const success = await this.tryNativeClick(result.x, result.y);
if (success) return;
}
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;
}
async typeText(ref: string, text: string): Promise<void> {
await this.evaluate(typeTextJs(ref, text));
}
async pressKey(key: string): Promise<void> {
await this.evaluate(pressKeyJs(key));
}
async scrollTo(ref: string): Promise<unknown> {
return this.evaluate(scrollToRefJs(ref));
}
async getFormState(): Promise<Record<string, unknown>> {
return (await this.evaluate(getFormStateJs())) as Record<string, unknown>;
}
async scroll(direction: string = 'down', amount: number = 500): Promise<void> {
await this.evaluate(scrollJs(direction, amount));
}
async autoScroll(options?: { times?: number; delayMs?: number }): Promise<void> {
const times = options?.times ?? 3;
const delayMs = options?.delayMs ?? 2000;
await this.evaluate(autoScrollJs(times, delayMs));
}
async networkRequests(includeStatic: boolean = false): Promise<unknown[]> {
const result = await this.evaluate(networkRequestsJs(includeStatic));
return Array.isArray(result) ? result : [];
}
async consoleMessages(_level: string = 'info'): Promise<unknown[]> {
return [];
}
async wait(options: number | WaitOptions): Promise<void> {
if (typeof options === 'number') {
if (options >= 1) {
try {
const maxMs = options * 1000;
await this.evaluate(waitForDomStableJs(maxMs, Math.min(500, maxMs)));
return;
} catch {
// Fallback: fixed sleep
}
}
await new Promise(resolve => setTimeout(resolve, options * 1000));
return;
}
if (typeof options.time === 'number') {
await new Promise(resolve => setTimeout(resolve, options.time! * 1000));
return;
}
if (options.selector) {
const timeout = (options.timeout ?? 10) * 1000;
await this.evaluate(waitForSelectorJs(options.selector, timeout));
return;
}
if (options.text) {
const timeout = (options.timeout ?? 30) * 1000;
await this.evaluate(waitForTextJs(options.text, timeout));
}
}
async snapshot(opts: SnapshotOptions = {}): Promise<unknown> {
const snapshotJs = generateSnapshotJs({
viewportExpand: opts.viewportExpand ?? 800,
maxDepth: Math.max(1, Math.min(Number(opts.maxDepth) || 50, 200)),
interactiveOnly: opts.interactive ?? false,
maxTextLength: opts.maxTextLength ?? 120,
includeScrollInfo: true,
bboxDedup: true,
previousHashes: this._prevSnapshotHashes,
});
try {
const result = await this.evaluate(snapshotJs);
// Read back the hashes stored by the snapshot for next diff
try {
const hashes = await this.evaluate('window.__opencli_prev_hashes') as string | null;
this._prevSnapshotHashes = typeof hashes === 'string' ? hashes : null;
} catch {
// Non-fatal: diff is best-effort
}
return result;
} catch {
return this._basicSnapshot(opts);
}
}
async getCurrentUrl(): Promise<string | null> {
if (this._lastUrl) return this._lastUrl;
try {
const current = await this.evaluate('window.location.href');
if (typeof current === 'string' && current) {
this._lastUrl = current;
return current;
}
} catch {
// Best-effort
}
return null;
}
async installInterceptor(pattern: string): Promise<void> {
const { generateInterceptorJs } = await import('../interceptor.js');
await this.evaluate(generateInterceptorJs(JSON.stringify(pattern), {
arrayName: '__opencli_xhr',
patchGuard: '__opencli_interceptor_patched',
}));
}
async getInterceptedRequests(): Promise<unknown[]> {
const { generateReadInterceptedJs } = await import('../interceptor.js');
const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
return Array.isArray(result) ? result : [];
}
async waitForCapture(timeout: number = 10): Promise<void> {
const maxMs = timeout * 1000;
await this.evaluate(waitForCaptureJs(maxMs));
}
/** Fallback basic snapshot */
protected async _basicSnapshot(opts: Pick<SnapshotOptions, 'interactive' | 'compact' | 'maxDepth' | 'raw'> = {}): Promise<unknown> {
const maxDepth = Math.max(1, Math.min(Number(opts.maxDepth) || 50, 200));
const code = `
(async () => {
function buildTree(node, depth) {
if (depth > ${maxDepth}) return '';
const role = node.getAttribute?.('role') || node.tagName?.toLowerCase() || 'generic';
const name = node.getAttribute?.('aria-label') || node.getAttribute?.('alt') || node.textContent?.trim().slice(0, 80) || '';
const isInteractive = ['a', 'button', 'input', 'select', 'textarea'].includes(node.tagName?.toLowerCase()) || node.getAttribute?.('tabindex') != null;
${opts.interactive ? 'if (!isInteractive && !node.children?.length) return "";' : ''}
let indent = ' '.repeat(depth);
let line = indent + role;
if (name) line += ' "' + name.replace(/"/g, '\\\\\\"') + '"';
if (node.tagName?.toLowerCase() === 'a' && node.href) line += ' [' + node.href + ']';
if (node.tagName?.toLowerCase() === 'input') line += ' [' + (node.type || 'text') + ']';
let result = line + '\\n';
if (node.children) {
for (const child of node.children) {
result += buildTree(child, depth + 1);
}
}
return result;
}
return buildTree(document.body, 0);
})()
`;
const raw = await this.evaluate(code);
if (opts.raw) return raw;
if (typeof raw === 'string') return formatSnapshot(raw, opts);
return raw;
}
}
+25 -17
View File
@@ -9,7 +9,7 @@ import * as fs from 'node:fs';
import type { IPage } from '../types.js';
import type { IBrowserFactory } from '../runtime.js';
import { Page } from './page.js';
import { isDaemonRunning, isExtensionConnected } from './daemon-client.js';
import { fetchDaemonStatus, isExtensionConnected } from './daemon-client.js';
import { DEFAULT_DAEMON_PORT } from '../constants.js';
const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension
@@ -57,21 +57,33 @@ export class BrowserBridge implements IBrowserFactory {
}
private async _ensureDaemon(timeoutSeconds?: number): Promise<void> {
// Use default if not provided, zero, or negative
const effectiveSeconds = (timeoutSeconds && timeoutSeconds > 0) ? timeoutSeconds : Math.ceil(DAEMON_SPAWN_TIMEOUT / 1000);
const timeoutMs = effectiveSeconds * 1000;
if (await isExtensionConnected()) return;
if (await isDaemonRunning()) {
// Single status check instead of two separate fetchDaemonStatus() calls
const status = await fetchDaemonStatus();
// Fast path: extension already connected
if (status?.extensionConnected) return;
// Daemon running but no extension — wait for extension with progress
if (status !== null) {
if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
process.stderr.write('⏳ Waiting for Chrome extension to connect...\n');
process.stderr.write(' Make sure Chrome is open and the OpenCLI extension is enabled.\n');
}
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 200));
if (await isExtensionConnected()) return;
}
throw new Error(
'Daemon is running but the Browser Extension is not connected.\n' +
'Please install and enable the opencli Browser Bridge extension in Chrome.',
);
}
// Find daemon relative to this file — works for both:
// npx tsx src/main.ts → src/browser/mcp.ts → src/daemon.ts
// node dist/main.js → dist/browser/mcp.js → dist/daemon.js
// No daemon — spawn one
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const parentDir = path.resolve(__dirname, '..');
const daemonTs = path.join(parentDir, 'daemon.ts');
@@ -79,12 +91,10 @@ export class BrowserBridge implements IBrowserFactory {
const isTs = fs.existsSync(daemonTs);
const daemonPath = isTs ? daemonTs : daemonJs;
if (process.env.OPENCLI_VERBOSE) {
console.error(`[opencli] Starting daemon (${isTs ? 'ts' : 'js'})...`);
if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
process.stderr.write('⏳ Starting daemon...\n');
}
// For compiled .js, use the current node binary directly (fast).
// For .ts dev mode, node can't run .ts files — use tsx via --import.
const spawnArgs = isTs
? [process.execPath, '--import', 'tsx/esm', daemonPath]
: [process.execPath, daemonPath];
@@ -96,16 +106,14 @@ export class BrowserBridge implements IBrowserFactory {
});
this._daemonProc.unref();
// Wait for daemon to be ready AND extension to connect (exponential backoff)
const backoffs = [50, 100, 200, 400, 800, 1500, 3000];
// Wait for daemon + extension with faster polling
const deadline = Date.now() + timeoutMs;
for (let i = 0; Date.now() < deadline; i++) {
await new Promise(resolve => setTimeout(resolve, backoffs[Math.min(i, backoffs.length - 1)]));
while (Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 200));
if (await isExtensionConnected()) return;
}
// Daemon might be up but extension not connected — give a useful error
if (await isDaemonRunning()) {
if ((await fetchDaemonStatus()) !== null) {
throw new Error(
'Daemon is running but the Browser Extension is not connected.\n' +
'Please install and enable the opencli Browser Bridge extension in Chrome.',
+20 -144
View File
@@ -11,24 +11,14 @@
import { WebSocket, type RawData } from 'ws';
import { request as httpRequest } from 'node:http';
import { request as httpsRequest } from 'node:https';
import type { BrowserCookie, IPage, ScreenshotOptions, SnapshotOptions, WaitOptions } from '../types.js';
import type { BrowserCookie, IPage, ScreenshotOptions } from '../types.js';
import type { IBrowserFactory } from '../runtime.js';
import { wrapForEval } from './utils.js';
import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
import { generateStealthJs } from './stealth.js';
import {
clickJs,
typeTextJs,
pressKeyJs,
waitForTextJs,
scrollJs,
autoScrollJs,
networkRequestsJs,
waitForDomStableJs,
waitForCaptureJs,
waitForSelectorJs,
} from './dom-helpers.js';
import { waitForDomStableJs } from './dom-helpers.js';
import { isRecord, saveBase64ToFile } from '../utils.js';
import { getAllElectronApps } from '../electron-apps.js';
import { BasePage } from './base-page.js';
export interface CDPTarget {
type?: string;
@@ -56,11 +46,11 @@ export class CDPBridge implements IBrowserFactory {
private _pending = new Map<number, { resolve: (val: unknown) => void; reject: (err: Error) => void; timer: ReturnType<typeof setTimeout> }>();
private _eventListeners = new Map<string, Set<(params: unknown) => void>>();
async connect(opts?: { timeout?: number; workspace?: string }): Promise<IPage> {
async connect(opts?: { timeout?: number; workspace?: string; cdpEndpoint?: string }): Promise<IPage> {
if (this._ws) throw new Error('CDPBridge is already connected. Call close() before reconnecting.');
const endpoint = process.env.OPENCLI_CDP_ENDPOINT;
if (!endpoint) throw new Error('OPENCLI_CDP_ENDPOINT is not set');
const endpoint = opts?.cdpEndpoint ?? process.env.OPENCLI_CDP_ENDPOINT;
if (!endpoint) throw new Error('CDP endpoint not provided (pass cdpEndpoint or set OPENCLI_CDP_ENDPOINT)');
let wsUrl = endpoint;
if (endpoint.startsWith('http')) {
@@ -173,10 +163,11 @@ export class CDPBridge implements IBrowserFactory {
}
}
class CDPPage implements IPage {
class CDPPage extends BasePage {
private _pageEnabled = false;
private _lastUrl: string | null = null;
constructor(private bridge: CDPBridge) {}
constructor(private bridge: CDPBridge) {
super();
}
async goto(url: string, options?: { waitUntil?: 'load' | 'none'; settleMs?: number }): Promise<void> {
if (!this._pageEnabled) {
@@ -215,78 +206,6 @@ class CDPPage implements IPage {
: cookies;
}
async snapshot(opts: SnapshotOptions = {}): Promise<unknown> {
const snapshotJs = generateSnapshotJs({
viewportExpand: opts.viewportExpand ?? 800,
maxDepth: Math.max(1, Math.min(Number(opts.maxDepth) || 50, 200)),
interactiveOnly: opts.interactive ?? false,
maxTextLength: opts.maxTextLength ?? 120,
includeScrollInfo: true,
bboxDedup: true,
});
return this.evaluate(snapshotJs);
}
async click(ref: string): Promise<void> {
await this.evaluate(clickJs(ref));
}
async typeText(ref: string, text: string): Promise<void> {
await this.evaluate(typeTextJs(ref, text));
}
async pressKey(key: string): Promise<void> {
await this.evaluate(pressKeyJs(key));
}
async scrollTo(ref: string): Promise<unknown> {
return this.evaluate(scrollToRefJs(ref));
}
async getFormState(): Promise<Record<string, unknown>> {
return (await this.evaluate(getFormStateJs())) as Record<string, unknown>;
}
async wait(options: number | WaitOptions): Promise<void> {
if (typeof options === 'number') {
if (options >= 1) {
try {
const maxMs = options * 1000;
await this.evaluate(waitForDomStableJs(maxMs, Math.min(500, maxMs)));
return;
} catch {
// Fallback: fixed sleep
}
}
await new Promise((resolve) => setTimeout(resolve, options * 1000));
return;
}
if (typeof options.time === 'number') {
const waitTime = options.time;
await new Promise((resolve) => setTimeout(resolve, waitTime * 1000));
return;
}
if (options.selector) {
const timeout = (options.timeout ?? 10) * 1000;
await this.evaluate(waitForSelectorJs(options.selector, timeout));
return;
}
if (options.text) {
const timeout = (options.timeout ?? 30) * 1000;
await this.evaluate(waitForTextJs(options.text, timeout));
}
}
async scroll(direction: string = 'down', amount: number = 500): Promise<void> {
await this.evaluate(scrollJs(direction, amount));
}
async autoScroll(options?: { times?: number; delayMs?: number }): Promise<void> {
const times = options?.times ?? 3;
const delayMs = options?.delayMs ?? 2000;
await this.evaluate(autoScrollJs(times, delayMs));
}
async screenshot(options: ScreenshotOptions = {}): Promise<string> {
const result = await this.bridge.send('Page.captureScreenshot', {
format: options.format ?? 'png',
@@ -300,53 +219,13 @@ class CDPPage implements IPage {
return base64;
}
async networkRequests(includeStatic: boolean = false): Promise<unknown[]> {
const result = await this.evaluate(networkRequestsJs(includeStatic));
return Array.isArray(result) ? result : [];
}
async tabs(): Promise<unknown[]> {
return [];
}
async closeTab(_index?: number): Promise<void> {
// Not supported in direct CDP mode
}
async newTab(): Promise<void> {
await this.bridge.send('Target.createTarget', { url: 'about:blank' });
}
async selectTab(_index: number): Promise<void> {
// Not supported in direct CDP mode
}
async consoleMessages(_level?: string): Promise<unknown[]> {
return [];
}
async getCurrentUrl(): Promise<string | null> {
return this._lastUrl;
}
async installInterceptor(pattern: string): Promise<void> {
const { generateInterceptorJs } = await import('../interceptor.js');
await this.evaluate(generateInterceptorJs(JSON.stringify(pattern), {
arrayName: '__opencli_xhr',
patchGuard: '__opencli_interceptor_patched',
}));
}
async getInterceptedRequests(): Promise<unknown[]> {
const { generateReadInterceptedJs } = await import('../interceptor.js');
const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
return Array.isArray(result) ? result : [];
}
async waitForCapture(timeout: number = 10): Promise<void> {
const maxMs = timeout * 1000;
await this.evaluate(waitForCaptureJs(maxMs));
}
}
function isCookie(value: unknown): value is BrowserCookie {
@@ -387,6 +266,7 @@ function scoreCDPTarget(target: CDPTarget, preferredPattern?: RegExp): number {
if (!haystack.trim() && !type) return Number.NEGATIVE_INFINITY;
if (haystack.includes('devtools')) return Number.NEGATIVE_INFINITY;
if (type === 'background_page' || type === 'service_worker') return Number.NEGATIVE_INFINITY;
let score = 0;
@@ -404,19 +284,15 @@ function scoreCDPTarget(target: CDPTarget, preferredPattern?: RegExp): number {
if (url === '' || url === 'about:blank') score -= 40;
if (title && title !== 'devtools') score += 25;
if (title.includes('antigravity')) score += 120;
if (title.includes('codex')) score += 120;
if (title.includes('cursor')) score += 120;
if (title.includes('chatwise')) score += 120;
if (title.includes('notion')) score += 120;
if (title.includes('discord')) score += 120;
if (url.includes('antigravity')) score += 100;
if (url.includes('codex')) score += 100;
if (url.includes('cursor')) score += 100;
if (url.includes('chatwise')) score += 100;
if (url.includes('notion')) score += 100;
if (url.includes('discord')) score += 100;
// Boost score for known Electron app names from the registry (builtin + user-defined)
const appNames = Object.values(getAllElectronApps()).map(a => (a.displayName ?? a.processName).toLowerCase());
for (const name of appNames) {
if (title.includes(name)) { score += 120; break; }
}
for (const name of appNames) {
if (url.includes(name)) { score += 100; break; }
}
return score;
}
+103
View File
@@ -0,0 +1,103 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
fetchDaemonStatus,
isDaemonRunning,
isExtensionConnected,
requestDaemonShutdown,
} from './daemon-client.js';
describe('daemon-client', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn());
});
afterEach(() => {
vi.restoreAllMocks();
});
it('fetchDaemonStatus sends the shared status request and returns parsed data', async () => {
const status = {
ok: true,
pid: 123,
uptime: 10,
extensionConnected: true,
extensionVersion: '1.2.3',
pending: 0,
lastCliRequestTime: Date.now(),
memoryMB: 32,
port: 19825,
};
const fetchMock = vi.mocked(fetch);
fetchMock.mockResolvedValue({
ok: true,
json: () => Promise.resolve(status),
} as Response);
await expect(fetchDaemonStatus()).resolves.toEqual(status);
expect(fetchMock).toHaveBeenCalledWith(
expect.stringMatching(/\/status$/),
expect.objectContaining({
headers: expect.objectContaining({ 'X-OpenCLI': '1' }),
}),
);
});
it('fetchDaemonStatus returns null on network failure', async () => {
vi.mocked(fetch).mockRejectedValue(new Error('ECONNREFUSED'));
await expect(fetchDaemonStatus()).resolves.toBeNull();
});
it('requestDaemonShutdown POSTs to the shared shutdown endpoint', async () => {
const fetchMock = vi.mocked(fetch);
fetchMock.mockResolvedValue({ ok: true } as Response);
await expect(requestDaemonShutdown()).resolves.toBe(true);
expect(fetchMock).toHaveBeenCalledWith(
expect.stringMatching(/\/shutdown$/),
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ 'X-OpenCLI': '1' }),
}),
);
});
it('isDaemonRunning reflects shared status availability', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
ok: true,
pid: 123,
uptime: 10,
extensionConnected: false,
pending: 0,
lastCliRequestTime: Date.now(),
memoryMB: 16,
port: 19825,
}),
} as Response);
await expect(isDaemonRunning()).resolves.toBe(true);
});
it('isExtensionConnected reflects shared status payload', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
ok: true,
pid: 123,
uptime: 10,
extensionConnected: false,
pending: 0,
lastCliRequestTime: Date.now(),
memoryMB: 16,
port: 19825,
}),
} as Response);
await expect(isExtensionConnected()).resolves.toBe(false);
});
});
+68 -36
View File
@@ -7,9 +7,11 @@
import { DEFAULT_DAEMON_PORT } from '../constants.js';
import type { BrowserSessionInfo } from '../types.js';
import { sleep } from '../utils.js';
import { isTransientBrowserError } from './errors.js';
const DAEMON_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
const OPENCLI_HEADERS = { 'X-OpenCLI': '1' };
let _idCounter = 0;
@@ -19,7 +21,7 @@ function generateId(): string {
export interface DaemonCommand {
id: string;
action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions';
action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'insert-text' | 'bind-current' | 'network-capture-start' | 'network-capture-read' | 'cdp';
tabId?: number;
code?: string;
workspace?: string;
@@ -27,9 +29,22 @@ export interface DaemonCommand {
op?: string;
index?: number;
domain?: string;
matchDomain?: string;
matchPathPrefix?: string;
format?: 'png' | 'jpeg';
quality?: number;
fullPage?: boolean;
/** Local file paths for set-file-input action */
files?: string[];
/** CSS selector for file input element (set-file-input action) */
selector?: string;
/** Raw text payload for insert-text action */
text?: string;
/** URL substring filter pattern for network capture */
pattern?: string;
cdpMethod?: string;
cdpParams?: Record<string, unknown>;
}
export interface DaemonResult {
@@ -39,42 +54,65 @@ export interface DaemonResult {
error?: string;
}
/**
* Check if daemon is running.
*/
export async function isDaemonRunning(): Promise<boolean> {
export interface DaemonStatus {
ok: boolean;
pid: number;
uptime: number;
extensionConnected: boolean;
extensionVersion?: string;
pending: number;
lastCliRequestTime: number;
memoryMB: number;
port: number;
}
async function requestDaemon(pathname: string, init?: RequestInit & { timeout?: number }): Promise<Response> {
const { timeout = 2000, headers, ...rest } = init ?? {};
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2000);
const res = await fetch(`${DAEMON_URL}/status`, {
headers: { 'X-OpenCLI': '1' },
return await fetch(`${DAEMON_URL}${pathname}`, {
...rest,
headers: { ...OPENCLI_HEADERS, ...headers },
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
}
export async function fetchDaemonStatus(opts?: { timeout?: number }): Promise<DaemonStatus | null> {
try {
const res = await requestDaemon('/status', { timeout: opts?.timeout ?? 2000 });
if (!res.ok) return null;
return await res.json() as DaemonStatus;
} catch {
return null;
}
}
export async function requestDaemonShutdown(opts?: { timeout?: number }): Promise<boolean> {
try {
const res = await requestDaemon('/shutdown', { method: 'POST', timeout: opts?.timeout ?? 5000 });
return res.ok;
} catch {
return false;
}
}
/**
* Check if daemon is running.
*/
export async function isDaemonRunning(): Promise<boolean> {
return (await fetchDaemonStatus()) !== null;
}
/**
* Check if daemon is running AND the extension is connected.
*/
export async function isExtensionConnected(): Promise<boolean> {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2000);
const res = await fetch(`${DAEMON_URL}/status`, {
headers: { 'X-OpenCLI': '1' },
signal: controller.signal,
});
clearTimeout(timer);
if (!res.ok) return false;
const data = await res.json() as { extensionConnected?: boolean };
return !!data.extensionConnected;
} catch {
return false;
}
const status = await fetchDaemonStatus();
return !!status?.extensionConnected;
}
/**
@@ -93,27 +131,18 @@ export async function sendCommand(
const id = generateId();
const command: DaemonCommand = { id, action, ...params };
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30000);
const res = await fetch(`${DAEMON_URL}/command`, {
const res = await requestDaemon('/command', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-OpenCLI': '1' },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(command),
signal: controller.signal,
timeout: 30000,
});
clearTimeout(timer);
const result = (await res.json()) as DaemonResult;
if (!result.ok) {
// Check if error is a transient extension issue worth retrying
const errMsg = result.error ?? '';
const isTransient = errMsg.includes('Extension disconnected')
|| errMsg.includes('Extension not connected')
|| errMsg.includes('attach failed')
|| errMsg.includes('no longer exists');
if (isTransient && attempt < maxRetries) {
if (isTransientBrowserError(new Error(result.error ?? '')) && attempt < maxRetries) {
// Longer delay for extension recovery (service worker restart)
await sleep(1500);
continue;
@@ -141,3 +170,6 @@ export async function listSessions(): Promise<BrowserSessionInfo[]> {
return Array.isArray(result) ? result : [];
}
export async function bindCurrentTab(workspace: string, opts: { matchDomain?: string; matchPathPrefix?: string } = {}): Promise<unknown> {
return sendCommand('bind-current', { workspace, ...opts });
}

Some files were not shown because too many files have changed in this diff Show More