Compare commits

...

128 Commits

Author SHA1 Message Date
jackwener f8d6319f87 refactor: use CliError subclasses in youtube, bilibili, and boss adapters
Replace raw Error throws with appropriate CliError subclasses:
- youtube/transcript.ts: CommandExecutionError, EmptyResultError
- youtube/video.ts: CommandExecutionError
- bilibili/utils.ts: EmptyResultError
- boss/send.ts: EmptyResultError, SelectorError
- boss/mark.ts: ArgumentError, EmptyResultError

This enables better error handling and user-facing error messages.
2026-03-24 20:59:09 +08:00
jackwener bf6207a4e2 refactor: use CliError subclasses in adapters for better error handling
- linkedin/timeline: AuthRequiredError, EmptyResultError
- linkedin/search: ArgumentError, CommandExecutionError
- bilibili/subtitle: AuthRequiredError, CommandExecutionError, EmptyResultError, SelectorError
- bilibili/following: CommandExecutionError
- medium/shared: CommandExecutionError
- twitter/delete, twitter/unfollow: CommandExecutionError

This allows the top-level error handler to render consistent,
helpful output with emoji-coded severity and actionable hints.
2026-03-24 20:44:31 +08:00
jackwener 4e669629fd refactor: use CliError subclasses in reddit adapters
Replace raw Error throws with appropriate CliError subclasses:
- reddit/read.ts: CommandExecutionError for API-related errors

This enables better error handling and user-facing error messages.
2026-03-24 20:44:10 +08:00
jackwener 6cf14e0800 refactor: use CliError subclasses in twitter adapters
Replace raw Error throws with appropriate CliError subclasses:
- twitter/trending.ts: AuthRequiredError, EmptyResultError
- twitter/bookmarks.ts: AuthRequiredError, CommandExecutionError

This enables better error handling and user-facing error messages.
2026-03-24 20:43:44 +08:00
jackwener 45370b50e1 refactor: use CliError subclasses in linkedin adapters
Replace raw Error throws with appropriate CliError subclasses:
- linkedin/timeline.ts: AuthRequiredError, EmptyResultError
- linkedin/search.ts: ArgumentError, CommandExecutionError

This enables better error handling and user-facing error messages.
2026-03-24 20:43:20 +08:00
jakevin 1af0f48023 docs: remove kubectl references from documentation and external CLI list (#363)
Remove kubectl from:
- README.md highlights and external CLI examples table
- README.zh-CN.md highlights and external CLI examples table
- src/external-clis.yaml external CLI registry

kubectl is not relevant to the opencli project scope and should not be showcased as a primary example.
2026-03-24 20:25:15 +08:00
jakevin 2fb7ed131b fix(e2e): remove duplicate closing bracket causing vite:oxc parse error (#361)
The dictionary adapters commit (3d39574) introduced a duplicate
`}, 30_000);` at line 524 of public-commands.test.ts, causing
the vite:oxc transformer to fail with [PARSE_ERROR] Unexpected token
in the E2E Headed Chrome CI workflow.
2026-03-24 20:21:11 +08:00
jakevin 14672ddf9b refactor: simplify codebase by removing dead code, deduplicating types, and extracting shared desktop adapter commands (#360)
- Remove dead code: unused `promises` array in discovery.ts, unused `DEFAULT_BROWSER_SMOKE_TIMEOUT`, unused `checkFfmpeg()`, deprecated `PlaywrightMCP` alias
- Extract shared `YamlArgDefinition`/`YamlCliDefinition` into `yaml-schema.ts` (was duplicated in discovery.ts and build-manifest.ts)
- Unify `BrowserCookie` type: remove duplicate from download/index.ts, re-export from types.ts
- Create `_shared/desktop-commands.ts` with factory functions (makeScreenshotCommand, makeStatusCommand, makeNewCommand, makeDumpCommand), simplifying 11 adapter files from ~20-30 lines each to 3 lines
- Fix unnecessary dynamic imports in utils.ts

Net: +30 / -361 lines
2026-03-24 20:20:32 +08:00
jakevin 77814553cf Revert "feat(browser): human-like delay system for anti-detection (#297)" (#359)
This reverts commit 376c63c7db.
2026-03-24 19:49:53 +08:00
jakevin 9018713749 fix: add fallback guards to dictionary search for better error handling (#358) 2026-03-24 19:43:26 +08:00
VK 3d39574501 feat(dictionary): add dictionary search, synonyms, and examples adapters (#241)
* feat(dictionary): add dictionary search, synonyms, and examples adapters

* feat: Improve dictionary commands with positional word arguments, URL encoding, enhanced phonetic parsing, and new E2E tests.
2026-03-24 19:26:00 +08:00
dependabot[bot] b1067b64ee chore(ci): bump peter-evans/repository-dispatch from 3 to 4 (#323)
Bumps [peter-evans/repository-dispatch](https://github.com/peter-evans/repository-dispatch) from 3 to 4.
- [Release notes](https://github.com/peter-evans/repository-dispatch/releases)
- [Commits](https://github.com/peter-evans/repository-dispatch/compare/v3...v4)

---
updated-dependencies:
- dependency-name: peter-evans/repository-dispatch
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-24 19:23:59 +08:00
工具人研究所 376c63c7db feat(browser): human-like delay system for anti-detection (#297)
* feat(browser): human-like delay system for anti-detection

Adds a framework-level delay/jitter system using log-normal distribution
to simulate natural browsing patterns, addressing issue #59 (P0).

- New `HumanDelay` class with configurable profiles (none/fast/moderate/cautious/stealth)
- Log-normal distribution for realistic delay variance (not uniform)
- Periodic "breaks" that simulate reading/thinking pauses
- Auto-injected between page.goto() navigations
- Configurable via OPENCLI_DELAY_PROFILE env var
- Boss search adapter migrated from hardcoded jitter to framework delay
- 10 unit tests covering all profiles and edge cases

Real-world validation against a major job board (cookie-authenticated,
aggressive bot detection):

| Scenario              | Without jitter     | With jitter        |
|-----------------------|--------------------|--------------------|
| 50 detail pages       |  OK              |  OK              |
| 200 detail pages      |  Banned (code 32) |  OK              |
| 850 requests over 5h  | N/A (banned early) |  Zero detection   |
| 4-day sustained crawl | N/A                |  1800+ records    |

Closes #59

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

* fix: disable human delay in CI environment to prevent E2E timeouts

In CI environments (CI=true), resolveProfile() now defaults to the
'none' profile instead of 'moderate'. This prevents the 1-8s per-
navigation delay from causing E2E test timeouts (30s limit).

Users can override this by setting OPENCLI_DELAY_PROFILE explicitly.

---------

Co-authored-by: toolmanlab <toolmanlab@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-24 19:22:13 +08:00
sline ccfc0ed0de fix: remove stale SQLite file, add chatgpt platform guard, fix daemon exit code (#308)
- Add *.db to .gitignore to prevent accidental database commits
- Add macOS platform check before osascript calls in chatgpt commands
- Change daemon EADDRINUSE exit code from 0 to 1
2026-03-24 19:20:42 +08:00
AstroHan 3a7a5e135b fix(grok): preserve conversation across repeated ask calls (#332)
* fix(grok): preserve conversation across repeated ask calls (#330)

The adapter unconditionally navigated to grok.com/ on every invocation,
destroying the existing conversation URL even when --new was not passed.
Since the browser daemon already reuses the same Chrome tab, skipping
navigation lets the tab stay on the current chat thread.

- Only navigate to grok.com/ when --new is true or tab is not on grok.com
- Add tryStartFreshChat to the default path's --new branch (was dead code)
- Add isOnGrok helper with hostname-based domain matching
- Add unit tests for isOnGrok

* test(grok): add adapter to vitest project config

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-24 19:19:40 +08:00
jakevin 618dae9148 fix(stealth): harden anti-detection against advanced fingerprinting (#357)
- navigator.webdriver returns false instead of undefined (matches real Chrome)
- Stealth guard uses non-enumerable prototype property instead of discoverable window prop
- Interceptor globals are non-enumerable via Object.defineProperty
- Monkey-patched fetch/XHR disguised with native toString() signatures
- XHR instance properties use non-enumerable descriptors
- Remove overly broad chrome-extension:// stack filter
- Remove __opencli from stack patterns to avoid self-exposure
- Update download User-Agent from Chrome/120 to Chrome/134
- Clean up dead STEALTH_GUARD export

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:19:27 +08:00
AstroHan 11df7181b3 fix(twitter): retry transient search spa navigation (#355)
* fix(twitter): retry transient search spa navigation

* test(twitter): cover search navigation failure path
2026-03-24 19:18:23 +08:00
MatrixA 2b24f517fe fix(arxiv): use correct query arg instead of keyword in search (#356)
The search command defined its argument as `query` but referenced
`args.keyword`, causing the search term to be undefined.

Closes #334

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:16:16 +08:00
AstroHan d44a0ab256 docs: add "Why opencli?" section and comparison guide (#331)
* docs: add "Why opencli?" section and comparison guide (#238)

- Add "Why opencli?" section to README.md and README.zh-CN.md
  (between Highlights and Prerequisites)
- Add docs/comparison.md with 5-scenario honest evaluation
- Add Comparison entry to VitePress sidebar

* docs: refine positioning — use approximate numbers, emphasize broad coverage

- Replace specific counts (300+, 55, 20+) with approximate descriptions
- Emphasize broad global + Chinese platform coverage instead of singling out Chinese sites
- Fix Firecrawl description to mention self-hosted option
- Replace "sub-second" / "milliseconds" with accurate "seconds" / "fast deterministic"
- Add testing and AI workflow to Further Reading links
- Add "easy to extend" point to strengths

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-24 19:13:49 +08:00
zhanghui67 9f2aa3b711 fix(bilibili): use actual user UID instead of 0 for favorite command (#333)
The favorite command was using up_mid: 0 which returns empty results. Now it correctly fetches the current user UID using getSelfUid().

Co-authored-by: 章晖 <zhanghui@MacBook-Pro.local>
2026-03-24 16:23:41 +08:00
jakevin 505c86bae9 docs: add missing adapter docs for jd and web (#349)
Add documentation for jd (item) and web (read) adapters to fix
doc-coverage CI check (55/57 → 57/57).
2026-03-24 16:03:18 +08:00
dependabot[bot] b8b4fa011b chore(deps): bump ws from 8.19.0 to 8.20.0 (#326)
Bumps [ws](https://github.com/websockets/ws) from 8.19.0 to 8.20.0.
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.19.0...8.20.0)

---
updated-dependencies:
- dependency-name: ws
  dependency-version: 8.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-24 15:57:50 +08:00
Piotr Yordanov 8869d3b457 feat(linkedin): add timeline feed command (#342)
* feat(linkedin): add timeline feed command

* test(linkedin): add timeline adapter unit tests

Add shape tests and utility function tests for the new timeline command.
Include linkedin in the vitest adapter project config.

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-24 15:54:51 +08:00
jakevin 7c9caa81f8 refactor: extract shared utilities and simplify codebase (#348)
- R1: Extract isRecord() type guard to shared src/utils.ts (8 files)
- R2: Merge duplicate mapConcurrent() to shared utils (fetch.ts + download.ts)
- R3: Extract saveBase64ToFile() helper (cdp.ts + page.ts)
- R4: Merge _tabOpt() + _workspaceOpt() into _cmdOpts()/_wsOpt() (page.ts)
- R5: Extract normalizeRows() + resolveColumns() in output.ts
- R6: Remove dead register stub from generate.ts
2026-03-24 15:47:21 +08:00
Xeron 7c808fd339 feat(jd): add JD.com product details adapter (#344)
* feat(jd): add item adapter for JD.com product details

Support fetching:
- Product title, price, shop
- Product specifications (品牌, 型号, 规格参数 etc.)
- Main product images
- Detail images from product page

Usage: opencli jd item <sku>

* fix: use images arg instead of hardcoded limit, add command shape test

- Wire the `images` arg to control mainImages/detailImages slice count
  (was hardcoded to 10, ignoring the arg entirely)
- Add item.test.ts verifying command registration shape

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-24 15:37:22 +08:00
jakevin fb562fa1e9 fix: allow browser:false commands to run without page after lazy-load (#347)
The C2 fix in PR #337 added a null-page guard after lazy-loading TS
modules, but it threw unconditionally — breaking all browser:false
commands (bloomberg, apple-podcasts, google, yollomi, etc.) that
use func() with a null page. Guard now checks updated.browser !== false.

Also fixes apple-podcasts top E2E flake: when the command times out on
CI, stderr is empty and the guard didn't catch it.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 15:08:08 +08:00
haijun huang f6466db39a feat: add generic web read command for any URL → Markdown (#343)
* feat: add generic `web read` command for any URL → Markdown

Adds a new `opencli web read --url <any-url>` command that fetches any
web page and exports it as clean Markdown with optional image download.

Uses browser-side DOM heuristics for content extraction:
  1. <article> element
  2. [role="main"] element
  3. <main> element
  4. Largest text-dense block fallback

Pipes through the existing article-download pipeline (Turndown + image
localization), so it inherits code block handling, frontmatter generation,
and concurrent image downloading for free.

Tested on: Anthropic blog, OpenAI blog, general news sites.

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

* fix: improve web read dedup for sites with duplicated DOM paragraphs

Anthropic's blog renders each paragraph twice (a normal version + a
line-broken animation version). The previous substring-based dedup
missed these because whitespace differences changed string lengths.

Fix: compare texts after stripping ALL whitespace, and keep the
version with more proper spacing (more spaces = better formatted).

Result on Anthropic blog: 98.4KB → 53.7KB (45% reduction).

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

---------

Co-authored-by: Harrison <harrison@HarrisondeMacBook-Pro.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 14:57:34 +08:00
dependabot[bot] 37c7ea41b8 chore(deps): bump typescript from 5.9.3 to 6.0.2 (#327)
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 6.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-24 14:51:15 +08:00
jakevin d58e1a74cd fix: resolve 11 important bugs from deep code review (#340)
- I1: Log pre-navigation failures in debug mode instead of silently swallowing
- I2: Validate env var timeout values, fallback on NaN/negative
- I4: Guard against indexOf returning -1 for unknown strategies in cascade
- I5: Fix shouldReplaceManifestEntry returning true for same-type entries
- I6: Prevent infinite loop in parseTsArgsBlock cursor advancement
- I7: Skip redundant Page.enable calls in CDP goto
- I8: Fix wait({time:0}) being treated as falsy
- I10: Warn when cookiesFile path doesn't exist before fallback
- I11: Sanitize tab/newline chars in cookie name/value for Netscape format
- I12: Use DEFAULT_DAEMON_PORT constant instead of hardcoded port in error
- I15: Log npm install failures in plugin lifecycle instead of swallowing

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 12:10:13 +08:00
jakevin 1b3f74cd6a test: focus adapter coverage on four priority sites (#339) 2026-03-24 12:03:26 +08:00
jakevin bdcffd147f fix: resolve 6 critical bugs from deep code review (#337)
1. execution.ts: Guard lazy-loaded func commands against null page — if a
   lazy module incorrectly requires browser context, throw a clear error
   instead of a cryptic TypeError on page.goto().

2. daemon.ts: Fix readBody race condition — add aborted flag to prevent
   req.destroy() from triggering both reject (via error) and resolve
   (via end event) on the same Promise, which could process truncated data.

3. browser/cdp.ts: Prevent CDPBridge.connect() reentry — throw if already
   connected instead of silently leaking the previous WebSocket and its
   message handlers.

4. interceptor.ts: Store intercept pattern in a separate global variable
   so subsequent installInterceptor calls with different patterns update
   the match condition without being blocked by the patchGuard.

5. record.ts: Always call cleanupEnter() after Promise.race — previously
   only called in the timeout path, leaving readline open when user pressed
   Enter, potentially blocking process exit. Also removed unused enterRace.

6. generate.ts: Fix undefined entering String.includes() — when c.name is
   undefined, toLowerCase() returns undefined which gets coerced to the
   string "undefined" by includes(), causing false positive matches.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 11:37:03 +08:00
jakevin 53699eb807 fix: harden security-sensitive execution paths (#335)
* fix(security): harden against command injection and sandbox escape

1. cli.ts: Remove auto-discover of arbitrary system binaries via denylist.
   Unknown commands now require explicit registration via `opencli register`.
   The previous denylist approach was trivially bypassable (bash, curl, etc.).

2. template.ts: Protect evalJsExpr against prototype chain escape.
   Block expressions containing constructor/prototype/__proto__/process/etc.
   Deep-copy context objects to sever prototype chains before passing to
   new Function().

3. external.ts: Expand shell operator detection in parseCommand to cover
   $(), $, #, \n, \r — preventing command substitution and comment injection.

4. fetch.ts: Use JSON.stringify for HTTP method in browser evaluate() instead
   of raw string interpolation, preventing JS injection via crafted method values.

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

* fix: harden security-sensitive execution paths

* chore: tighten template sandbox guard

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 11:28:22 +08:00
jakevin 56d4326646 refactor(extension): reuse async detach() in registerListeners (#328)
Replace inline sync chrome.debugger.detach() in onUpdated listener
with the shared async detach() function for consistent cleanup behavior
across all detach paths.
2026-03-24 02:18:34 +08:00
QSam2023 4c9a2b1fde fix: detach debugger before navigation in browser bridge (#322)
* fix: detach debugger before navigation in browser bridge

* refactor: make detach() async, await all detach calls

- cdp.ts: detach() now async, awaits chrome.debugger.detach()
- background.ts: await detach() in handleNavigate and handleTabs close
- Eliminates theoretical race between detach and subsequent tab operations

Co-authored-by: jackwener <jackwener@gmail.com>

---------

Co-authored-by: bluey_heeler <fragwang231@gmail.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
Co-authored-by: jackwener <jackwener@gmail.com>
2026-03-24 02:14:31 +08:00
dependabot[bot] 57fcc50c1b chore(deps): bump vitest from 4.1.0 to 4.1.1 (#325)
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.1.0 to 4.1.1.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.1/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 4.1.1
  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-24 02:13:04 +08:00
dependabot[bot] 689cd1ebb3 chore(ci): bump actions/upload-artifact from 4 to 7 (#324)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-24 02:12:48 +08:00
jakevin 167c7c784a chore: release v1.3.3 (#321)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
2026-03-24 01:45:21 +08:00
jakevin f852da3af4 fix(stealth): review fixes — guard plugins, rewrite stack trace cleanup (#320)
- Only override navigator.plugins when empty (don't replace real user
  browser plugins with fakes)
- Replace Error.prepareStackTrace (V8/Node-only) with
  Error.prototype.stack getter override that works in browser context
- Fix \\n escaping in template literal for stack trace split/join
- Dynamic cdc_ variable scan via getOwnPropertyNames instead of
  hardcoded names
- Update tests to cover 7 patches
2026-03-24 01:41:22 +08:00
jakevin 15d9ef814f feat(browser): add stealth anti-detection for CDP and daemon modes (#319)
Add stealth.ts module that patches browser globals to hide automation
fingerprints when opencli controls a browser via CDP or daemon extension.

Patches applied:
- navigator.webdriver → undefined (CDP sets it to true)
- window.chrome stub (only if missing)
- navigator.plugins fake list (only if empty)
- navigator.languages guarantee (only if empty)
- Permissions.query normalization for notifications
- Cleanup __playwright/__puppeteer/cdc_* artifacts

CDP mode: stealth registered via Page.addScriptToEvaluateOnNewDocument
(runs before any page JS on every navigation).

Daemon mode: stealth injected via exec after navigation, with guard
flag to prevent double-injection.
2026-03-24 01:32:06 +08:00
jakevin 89a20e11bc docs: sync command references with current registry (#318) 2026-03-24 01:13:55 +08:00
jakevin 760b91e7b3 chore: release v1.3.2 (#317) 2026-03-24 01:05:47 +08:00
calm b6a02f82ed refactor: extract getErrorMessage and DEFAULT_DAEMON_PORT to shared modules (#313)
- Add getErrorMessage() to errors.ts (used in 5 files)
- Add DEFAULT_DAEMON_PORT to constants.ts (used in 5 files)
- Reduces code duplication and improves maintainability
2026-03-24 00:56:15 +08:00
jakevin a170873ad6 fix(e2e): broaden xiaoyuzhou skip logic for overseas CI runners (#316)
* fix: remove duplicate getErrorMessage import in discovery.ts

Squash merge left a duplicate import line causing TS2300 and oxc parse
errors in CI. Also clean up stale blank lines in discovery.ts and
execution.ts.

* fix(e2e): broaden xiaoyuzhou skip logic for overseas CI runners

The isExpectedChineseSiteRestriction function only matched FETCH_ERROR
with specific HTTP status codes. On overseas CI runners, xiaoyuzhou may
also return PARSE_ERROR (mangled HTML) or NOT_FOUND (geo-redirected
pages), causing false test failures. Now matches all CliError codes
from the adapter.
2026-03-24 00:47:07 +08:00
jakevin 75f42371ca fix: remove duplicate getErrorMessage import in discovery.ts (#315)
Squash merge left a duplicate import line causing TS2300 and oxc parse
errors in CI. Also clean up stale blank lines in discovery.ts and
execution.ts.
2026-03-24 00:42:29 +08:00
sline 41aedf68cd fix(external): replace execSync with execFileSync to prevent command injection (#309)
* fix(external): replace execSync with execFileSync to prevent command injection

* fix(review): preserve Windows external installs and restore docs build

* fix(review): preserve Windows external installs after rebase

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-24 00:40:17 +08:00
jakevin 8bf750c4ea docs(SKILL.md): sync command reference — add missing sites and desktop adapters (#314)
- Remove hardcoded '150+ commands across 30+ sites' from description
- Fix verify → validate command name
- Add 15+ missing site command references: douban, facebook, instagram,
  tiktok, medium, substack, sinablog, lobsters, google, devto, steam, wikipedia
- Add Desktop Adapter Commands section with 7 adapters: cursor, codex,
  chatgpt, chatwise, notion, discord-app, doubao-app
2026-03-24 00:37:42 +08:00
jakevin c9b3568594 chore: pre-release cleanup — fix dependencies, sync docs, reduce code duplication (#311)
* chore: pre-release cleanup — fix dependencies, sync docs, reduce code duplication

- fix: move @types/turndown from dependencies to devDependencies
- docs: backfill CHANGELOG for v1.2.0 through v1.3.1
- docs: remove internal release reminder from READMEs
- docs: update SKILL.md version to 1.3.1
- refactor: extract getErrorMessage() to errors.ts (was duplicated 5x)
- refactor: introduce CommandArgs type alias in registry.ts
- refactor: eliminate as-any in runtime.ts and cli.ts
- refactor: parallelize site directory scanning in discovery.ts
- refactor: add declare global for registry globalThis access
- fix: strengthen isBooleanRecord type guard in explore.ts
- fix: replace empty catch with log.debug in explore.ts
- fix: daemon now accepts timeout from request body
- chore: remove unused REGISTRY_KEY constant
- chore: update generate.ts TODO to stub annotation

* docs: add doubao-app desktop adapter doc (fixes docs-build dead link)
2026-03-24 00:33:40 +08:00
jakevin b4d64cad6e feat: refine error handling with semantic error types (#312)
- Add 5 new CliError subclasses: AuthRequiredError, TimeoutError,
  ArgumentError, EmptyResultError, SelectorError
- Centralize getErrorMessage() and ERROR_ICONS in errors.ts
- Data-driven error rendering in commanderAdapter.ts (replaces 5 if/else)
- withTimeoutMs accepts factory function for backward compatibility
- browser/errors.ts returns BrowserConnectError instead of bare Error
- Migrate 5 benchmark adapters to AuthRequiredError
- 318 unit tests passing, 0 regressions
2026-03-24 00:20:38 +08:00
jakevin 966f6e5019 feat(plugin): add update command, hot reload after install, README section (#307)
- Add `opencli plugin update <name>` command (git pull + post-install lifecycle)
- Extract shared postInstallLifecycle() helper to deduplicate install/update code
- Hot reload: call discoverPlugins() after install/update (no restart needed)
- Add Plugins section to README.md and README.zh-CN.md
- Add updatePlugin test coverage
2026-03-23 23:21:02 +08:00
AniChikage ea8324257d feat(yollomi): add new commands and update documentation in README files (#235)
* feat(yollomi): add new commands and update documentation in README files

- Added yollomi commands for generating images, videos, and editing capabilities.
- Updated README.md and README.zh-CN.md to include yollomi in the command list.
- Enhanced SKILL.md with yollomi-related tags and usage examples.

* feat(yollomi): add yollomi adapter to documentation

- Included yollomi in the VitePress configuration for browser adapters.
- Updated adapters index documentation to reflect yollomi's capabilities and commands.

* fix(yollomi): bug fixes, tests & improvements

- models.ts: add browser: false (no browser connection needed for hardcoded data)
- edit.ts: remove unused resolveImageInput import
- upload.ts: lower video upload limit from 100MB to 20MB (base64 OOM risk)
- generate.ts: improve file extension detection using URL.pathname
- upscale.ts: use choices for scale arg, improve extension detection
- object-remover.ts: make image/mask args positional
- Add yollomi models tests to public-commands.test.ts
- Add yollomi generate/video graceful-failure tests to browser-auth.test.ts

---------

Co-authored-by: anichikage <hanzhishuai@bytedance.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-23 23:04:01 +08:00
Yee dff0fe510c feat(record): add live recording command for API capture (#300)
* feat(record): add live recording command for API capture

- Add `opencli record <url>` command that injects fetch/XHR interceptors
  into all tabs in the automation window, polls captured requests, and
  auto-generates YAML candidate adapters
- Support multi-tab recording: new tabs discovered during polling are
  automatically injected
- Add --timeout (default 60s) for agent-friendly non-blocking operation;
  stops on Enter, timeout, or SIGINT — whichever comes first
- Fix idempotent re-injection: restores original fetch/XHR before
  re-patching so guard flag no longer blocks subsequent record runs
- Add --poll interval option (default 2000ms)
- Expand SKILL.md with full Record Workflow section: interceptor
  internals, page-type capture expectations, YAML→TS conversion guide,
  and troubleshooting table

* fix(record): fix XHR listener leak, pathChain syntax error, readline hang & args interpolation

- XHR send(): add __rec_listener_added guard to prevent duplicate event
  listeners when XHR is reused (abort → open → send)
- pathChain: when findArrayPath returns '' (root-level array), data access
  is just 'data' not 'data?.' which was invalid JS syntax
- waitForEnter(): return cleanup fn so timeout path can close readline.Interface
  preventing the process from hanging on stdin after auto-timeout
- buildRecordedYaml: replace search/page query param values with template
  vars ({{args.keyword}}, {{args.page}}) so generated YAML actually uses
  the declared args instead of hardcoding the recorded URL

---------

Co-authored-by: yee.wang <yee.wang@lazada.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-23 19:19:54 +08:00
jakevin 4343ec07e0 fix(tests): use positional arg syntax in browser search tests (#302)
Replace redundant --keyword/--query named flags with positional
arg syntax for all search commands that declare positional: true.
Also fix --query usage in tiktok.md docs example.

Affected:
- bilibili, weibo, zhihu, reuters, youtube, smzdm, boss, coupang, xiaohongshu search (browser-public.test.ts)
- linux-do search (browser-auth.test.ts)
- tiktok search (docs/adapters/browser/tiktok.md)
2026-03-23 19:00:12 +08:00
jakevin f00a5d1929 docs: update xiaohongshu search description, fix test count 31→32 (#301)
- docs/adapters/browser/xiaohongshu.md: fill in search command description
  (was empty), update usage examples with keyword positional arg
- TESTING.md: update unit test count 31→32 (search.test.ts added in #298),
  add xiaohongshu/search.test.ts to the adapter test file list
2026-03-23 17:59:35 +08:00
caokaizz c7895eaf8e Add weibo search command (#299)
* Add weibo search command

* fix(weibo/search): correct domain to weibo.com, add browser: true, fill doc description

- Change domain from s.weibo.com to weibo.com so browser cookies are picked
  up correctly (matches hot.ts which also uses weibo.com)
- Add browser: true for consistency with other browser-based adapters
- Add description for weibo search in adapter docs table

---------

Co-authored-by: 小小机器人 <14351708+little-little-robot@user.noreply.gitee.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-23 17:48:16 +08:00
AstroHan a83027d19c feat(v2ex): add node, user, member, replies, nodes commands (#282)
* feat(v2ex): add node, user, member, replies, nodes commands

Add 5 new public API commands to the v2ex adapter:
- node: browse topics by node name
- user: list topics by username
- member: show user profile
- replies: list topic replies
- nodes: list all nodes sorted by topic count

All commands use strategy: public, browser: false.

* test(v2ex): add E2E tests for node, user, member, replies, nodes commands

* docs(v2ex): update adapter docs with new commands

* fix(v2ex): address review findings - rate-limit guards, sort verification, docs

* docs(v2ex): update README command tables and add user example

* test(v2ex): improve test quality - soft guards, value assertions, smoke tests

- Replace isExpectedChineseSiteRestriction with if(code===0) soft guard
  (V2EX is globally accessible; YAML fetch doesn't throw FETCH_ERROR)
- Add value assertions: member username===Livid, limit effectiveness
- Add smoke tests for node, member, replies, nodes commands

* fix(v2ex): add url field to node/user commands, add missing user smoke test

- Add url to node.yaml and user.yaml pipeline map steps and columns
  (V2EX API provides item.url; improves usability for follow-up lookups)
- Add v2ex user smoke test (other 4 new commands all had smoke tests; user was missing)
- Update E2E assertions to verify url field in node/user results

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-23 17:45:02 +08:00
yin1991 f8bf66390d fix(xiaohongshu): improve search login-wall handling and detail output (#298)
* fix(xiaohongshu): improve search login-wall handling and detail output

* fix(xiaohongshu/search): keep login-wall detection & URL improvements, remove serial per-note enrichment

- Detect login wall and throw a clear error message (from original PR)
- Preserve search_result/ URL with xsec_token instead of degrading to /explore/<id>
- Add author_url to results
- Remove readNoteDetail() + sequential page.goto() per note (caused 60s+ delays
  for default limit=20 with 3s wait each)
- Simplify and unify DOM extraction logic (remove unused fallback anchor scan)
- Update tests: cover login-wall, URL preservation (assert single goto), and limit/filter

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-23 17:34:23 +08:00
jicaiji1-max 22f5c7ade0 fix: ensure standard PATH is available for external CLIs (#285)
Some environments (GUI apps, cron, IDE terminals) launch with a minimal
PATH that excludes standard directories like /usr/local/bin and /usr/sbin.
This causes external CLIs to fail when they try to run system commands
(e.g. sysctl).

Fix by ensuring standard system paths exist in process.env.PATH at
startup. This is a one-time fix that benefits ALL child processes —
isBinaryInstalled(), installExternalCli(), daemon spawn, etc. — without
needing per-call env patching.

Fixes #284

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-23 15:59:57 +08:00
jakevin 8ab0cd2a50 Revert "feat: add xianyu-cli as external CLI (#292)" (#295)
This reverts commit d4b06be049.
2026-03-23 15:45:09 +08:00
donquijote2557-web d4b06be049 feat: add xianyu-cli as external CLI (#292)
Add xianyu (闲鱼/Goofish) CLI tool as an external CLI integration.

Features: search, messaging, agent-flow auto-pricing pipeline,
QR code login, WebSocket real-time messaging with auto-reconnect.

Repo: https://github.com/Donquijote-coder/xianyu-cli

Co-authored-by: donquijote2557-web <donquijote2557-web@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:40:39 +08:00
AstroHan 7073645e30 docs: add gws to External CLI Hub table in README (#286)
* docs: add gws to External CLI Hub table in README

The Google Workspace CLI (gws) was registered in external-clis.yaml
but missing from the README table. Closes #120.

* docs: add gws to Chinese README External CLI Hub table

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-23 15:36:46 +08:00
jakevin 3a21be624e fix(xiaohongshu): scope image selector to avoid downloading avatars (#293)
Narrow '#noteContainer img[src*="xhscdn"]' to
'#noteContainer .media-container img[src*="xhscdn"]'
to exclude user avatars and sidebar icons from downloads.

Closes #281
2026-03-23 15:29:43 +08:00
AstroHan 127a974ecd feat(hackernews): add new, best, ask, show, jobs, search, user commands (#290)
Expand HackerNews from 1 command to 8, covering all major HN use cases.
All YAML adapters, strategy: public, browser: false.

- new/best/ask/show/jobs: Firebase API list endpoints with deleted/dead filtering
- search: Algolia API with query + sort (relevance/date)
- user: Firebase user profile with date formatting
- top.yaml: add filter for deleted/dead items + dynamic pre-fetch limit
- E2E tests for all 7 new commands
- Update README, README.zh-CN, adapter docs

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-23 15:13:47 +08:00
jakevin fcb5a9d409 docs: sync adapter lists with codebase (#291)
- Add 9 missing adapters: facebook, google, instagram, tiktok, lobsters,
  medium, sinablog, substack, doubao-app
- Add missing xiaohongshu publish command
- Fix grok mode: Desktop → Browser
- Fix boss commands in docs/adapters/index.md (was incomplete)
- Add doubao-app to Desktop Adapters table
2026-03-23 15:03:23 +08:00
jakevin 66c4b841f2 feat(doubao-app): add Doubao AI desktop app CLI adapter (#289)
- Commands: status, send, read, new, ask, screenshot, dump
- Uses Strategy.UI for desktop CDP connection
- Shared common.ts with selectors and evaluate script builders
- Requires Doubao launched with --remote-debugging-port=9226
2026-03-23 14:52:58 +08:00
jakevin 2a52906ed2 fix: add turndown dependency to package.json (#288)
turndown and @types/turndown were used in article-download.ts and
zhihu/download.test.ts but never declared in package.json, causing
CI failures on fresh npm ci installs.
2026-03-23 14:46:08 +08:00
helloimcx 9cdc1274b2 feat: add doubao browser adapter (#277) 2026-03-23 12:34:27 +08:00
stometaverse a6d993f37f feat(xiaohongshu): add publish command for 图文 note automation (#276)
Adds `opencli xiaohongshu publish` which automates posting a 图文 (image+text)
note via the creator center UI (creator.xiaohongshu.com/publish/publish).

Features:
- --title (required, max 20 chars)
- positional content argument
- --images comma-separated local file paths (jpg/png/gif/webp, max 9)
- --topics comma-separated hashtag names (without #)
- --draft flag to save as draft instead of publishing

Image upload uses DataTransfer injection into the file input element, converting
local files to base64 in Node.js and creating File blobs in the browser context.
Text fields use document.execCommand('insertText') for contenteditable editors.
Graceful debug screenshots on failure (/tmp/xhs_publish_*_debug.png).

Requires: opencli browser session logged into creator.xiaohongshu.com.
2026-03-23 12:26:02 +08:00
jakevin b7c6c02370 feat: add weixin article download adapter & abstract download helpers (#280)
- New: src/clis/weixin/download.ts — WeChat article to Markdown adapter
- New: src/download/article-download.ts — shared article download helper
  (TurndownService, image localization, frontmatter, customizable labels)
- New: src/download/media-download.ts — shared media download helper
  (batch download, ProgressTracker, yt-dlp routing, auto cookie export)
- Refactor: migrate zhihu/download to use downloadArticle()
- Refactor: migrate xiaohongshu/download to use downloadMedia()
- Refactor: migrate twitter/download to use downloadMedia()
- Refactor: migrate bilibili/download to use downloadMedia()
- Docs: add weixin to README, README.zh-CN, download docs, adapter docs
2026-03-23 12:11:43 +08:00
jakevin 722c180a0a docs: clarify release follow-up steps (#279) 2026-03-23 11:47:26 +08:00
jakevin 788126198b chore: release v1.3.1 (#273)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
2026-03-23 00:33:04 +08:00
jakevin 98ecfab8ad chore: bump version to 1.3.0 (#272)
* chore: bump version to 1.3.0

* chore: bump version to 1.3.0
2026-03-23 00:27:55 +08:00
jakevin 4b976da04f perf: smart page settle via DOM stability detection (#271)
Replace fixed settleMs sleep in goto() with MutationObserver-based DOM
stability detection. The page is considered settled when no DOM mutations
occur for quietMs (default 500ms), with settleMs as a hard timeout cap.

Changes:
- Add waitForDomStableJs() shared helper to dom-helpers.ts
- Update Page.goto() and CDPPage.goto() to use smart settle
- No IPage interface changes (implementation detail only)

Key improvements over naive approach:
- Timer starts AFTER MutationObserver.observe() to avoid race condition
- Falls back to sleep(maxMs) if document.body is not available
- Monitors attributes in addition to childList/subtree
- quietMs defaults to 500ms (conservative) for async request buffering
2026-03-23 00:24:09 +08:00
Zhang ShengYan 3bedaccc25 docs: refresh testing guide (#223)
* docs: refresh testing guide

* docs: include missing twitter/timeline.test.ts in test inventory

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-23 00:11:44 +08:00
jakevin 40bd11dfbe fix(daemon): harden security against browser CSRF attacks (#268) (#270)
- Add Origin header check: reject HTTP/WS from non chrome-extension:// origins
- Require X-OpenCLI custom header on all HTTP requests
- Remove Access-Control-Allow-Origin: * from all responses
- Add WebSocket verifyClient to reject malicious connections at upgrade
- Add 1MB body size limit to prevent OOM
- Update file header with security model documentation

Closes #268
2026-03-22 23:56:39 +08:00
AlexYue 9a77dba139 ci: trigger website rebuild on release (#269) 2026-03-22 23:46:48 +08:00
jakevin 49c2dc7426 docs: remove all --live references (now default behavior) (#267) 2026-03-22 23:03:53 +08:00
jakevin e4a13cb6f0 fix: remove duplicate horizontal rules and extra blank lines in READMEs (#266) 2026-03-22 22:58:54 +08:00
jakevin 637161f0ab fix: update doctor tests for auto-start daemon and --no-live default (#265)
- Fix skip message assertion: 'skipped (--no-live)' instead of old text
- Fix auto-start test: mock checkDaemonStatus for both initial and final calls
2026-03-22 22:57:33 +08:00
jakevin a778f617ca chore: update package-lock.json (#264) 2026-03-22 22:55:36 +08:00
jakevin b4a8089224 refactor: doctor defaults to live mode, remove setup command entirely (#263)
- Remove setup command completely (no backward compat needed)
- Doctor now runs live connectivity test by default
- Add --no-live flag to skip if needed
- Update SKILL.md docs
2026-03-22 22:51:28 +08:00
jakevin 428b831f85 refactor: deprecate opencli setup, enhance doctor with daemon auto-start (#262)
- Delete setup.ts (fully redundant with doctor)
- opencli setup now prints deprecation warning and delegates to doctor
- doctor auto-starts daemon if not running (no more false 'not connected')
- Update all doc references (README, SKILL.md, docs/)
2026-03-22 22:49:14 +08:00
jakevin 7ebe8134cc docs: clean up READMEs - remove redundant sections (#261)
Removed from both EN/CN READMEs:
- Table of Contents (GitHub auto-generates TOC)
- Method 2: Load from npm Package (keep recommended + dev only)
- Bloomberg detailed note (too specific for README)
- Pipeline Step YAML example (developer-internal)
- Releasing New Versions (belongs in CONTRIBUTING.md)

EN README only:
- Simplified Testing section to one-liner + link to TESTING.md
2026-03-22 22:26:49 +08:00
jakevin c44bc62b60 chore: code cleanup + extension conflict troubleshooting (#260)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
Cleanup:
- Remove redundant double-retry in resolveTabId (was retrying data: URI
  with the same data: URI)
- Fix stale comment (30s → 120s idle timeout)
- Remove verbose debug logging in resolveTabId
- Built extension is now smaller (16.66kB vs 17.18kB)

Extension conflict:
- Add hint to attach-failed error when chrome-extension:// URL is detected
- Add troubleshooting entry for extension conflicts (e.g. youmind, New Tab
  Override) to both README.md and README.zh-CN.md

Ref: #249
2026-03-22 22:18:22 +08:00
jakevin 7f57e76485 fix: treat empty tab URL as debuggable (fixes first-run doctor --live failure) (#259)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
When a new automation window is created, the initial tab URL may be
empty briefly while Chrome loads the data: URI. isDebuggableUrl('') was
returning false, causing ensureAttached to reject the tab.

Fix: only reject known non-debuggable URLs (chrome://, chrome-extension://).
Empty/undefined URLs are now treated as debuggable since they represent
tabs still loading.

Also adds 200ms delay after window creation to let Chrome populate the
tab URL.
2026-03-22 22:10:30 +08:00
jakevin e9818c1b41 chore: remove CRX from release pipeline and docs (#258)
CRX files cannot be installed in modern Chrome without Chrome Web Store
publishing. Updated all docs to recommend 'Load unpacked' installation
method only. Added npm package loading method as alternative.

- Removed CRX build step from build-extension.yml workflow
- Removed CRX from artifact upload and release attachment
- Updated README.md, README.zh-CN.md, browser-bridge docs (en/zh)
- Added 'Load from npm package' as installation method
2026-03-22 22:07:33 +08:00
jakevin 3e91876d13 fix: replace all about:blank with data: URI to prevent New Tab Override interception (#257)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
Root cause: getAutomationWindow and resolveTabId used about:blank which
New Tab Override extensions intercept immediately, replacing it with
chrome-extension:// URLs that cannot be debugged.

Changes:
- Window creation: about:blank → data:text/html
- reuseTab fallback: about:blank → data:text/html
- newTab handler: about:blank → data:text/html
- Added diagnostic logging to resolveTabId for debugging
- Synced extension version to 1.2.4

Ref: #249
2026-03-22 22:04:25 +08:00
jakevin 7c02588105 chore: bump version to 1.2.3 (#256)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
2026-03-22 21:48:17 +08:00
jakevin 112fdefa8d fix: harden resolveTabId against New Tab Override extension interception (#255)
resolveTabId's reuseTab path now verifies the URL is actually debuggable
after navigating to about:blank. If a New Tab Override extension intercepts
it (setting it back to chrome-extension://), falls back to a data: URI,
then creates a fresh tab as last resort.

This fixes the persistent 'attach failed: Cannot access chrome-extension://'
error for users with New Tab Override extensions installed.

Ref: #249
2026-03-22 21:47:44 +08:00
jakevin e077ad2336 chore: bump version to 1.2.2 (#254)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
2026-03-22 21:27:03 +08:00
jakevin 81384ede00 chore: bump version to 1.2.1 (#252)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
2026-03-22 21:24:42 +08:00
jakevin 71b2c3961b fix: harden browser automation pipeline (resolves #249) (#251)
- resolveTabId: validate URL even for explicit tabId, fall through to
  auto-resolve when tab is not debuggable or has been closed
- handleNavigate: wait for URL change before checking 'complete' status
  to avoid race condition with stale about:blank
- ensureAttached: pre-check tab URL, verify cached attach with probe,
  invalidate cache on URL change via onUpdated listener
- daemon-client: recognize transient extension errors (disconnected,
  attach failed) as retryable with 1500ms delay; fresh command ID per attempt
- pipeline executor: add per-step retry for browser steps (up to 2 retries
  on transient errors); cleanup automation window on pipeline failure
- page.ts: selectTab/newTab/closeTab properly update/invalidate _tabId
- daemon.ts: add WebSocket ping/pong heartbeat (15s interval, 2-miss disconnect)
- Increase automation window idle timeout from 30s to 120s
- Fix timeout param edge cases in BrowserBridge._ensureDaemon
- Remove unused chalk import; fix trailing import placement

Closes #249
2026-03-22 21:23:21 +08:00
jakevin b3b9892836 docs: add star history chart (#246) 2026-03-22 19:22:41 +08:00
jakevin 520622ac75 ci: update GitHub Actions runtime versions (#245) 2026-03-22 19:02:21 +08:00
jakevin 2d1b8c1e76 chore: prepare v1.2 release (#244)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
2026-03-22 18:40:32 +08:00
ykfnxx 70651d3ba8 feat(douban): add movie adapter with search, top250, subject, marks, reviews commands (#239)
* feat(douban): add movie adapter with search, top250, subject, marks, reviews commands

- search: search movies by keyword
- top250: get top 250 movies
- subject: get movie details by id
- marks: export personal viewing marks
- reviews: export personal movie reviews

* review: resolve douban adapter blockers

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-22 17:57:34 +08:00
plat1ko 9696db9ed4 feat: make primary args positional across all CLIs (#242)
* feat: make primary args positional across all CLIs

Convert primary arguments from named options (--arg) to positional
arguments for a more natural CLI experience.

Affected sites: antigravity, bilibili, boss, chaoxing, coupang, grok,
hf, instagram, jike, jimeng, linkedin, linux-do, tiktok, twitter,
xiaohongshu, youtube

Also adds Arg Design Convention to CONTRIBUTING.md and earnings-date
to xueqiu command list in READMEs.

Usage examples:
  opencli xueqiu search '茅台'       (was: --query '茅台')
  opencli twitter followers elonmusk  (was: --user elonmusk)
  opencli bilibili download BV1xxx    (was: --bvid BV1xxx)

* review: keep config args named in positional cleanup

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-22 17:04:56 +08:00
jakevin c76f86c9cb refactor: fail fast on invalid pipeline steps (#237) 2026-03-22 14:53:57 +08:00
jakevin 4cd0409ded fix: harden twitter timeline review findings (#236) 2026-03-22 14:30:03 +08:00
VK ea113a6471 feat(devto): add devto adapter (#234)
* feat(devto): add devto adapter

* refactor(devto): improve adapters to match project conventions

- Make tag/username args positional for natural CLI usage:
  opencli devto tag javascript (instead of --tag javascript)
  opencli devto user ben (instead of --username ben)
- Add rank field (index + 1) matching hackernews/lobsters pattern
- Add tags field from tag_list for richer output
- Remove redundant author column from user command (already filtering by user)
- Use type: str (project convention) instead of type: string
- Increase default limit from 10 to 20 (matching other adapters)
- Update docs with positional arg examples

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-22 14:18:47 +08:00
AstroHan a439286398 docs(plugin): add juejin plugin to example plugins (#207) 2026-03-22 14:17:12 +08:00
AstroHan 1d56dd77a8 fix(wikipedia): fix search arg name + add random and trending commands (#231)
* fix(wikipedia): fix search arg name + add random and trending commands

- fix: search.ts referenced `args.keyword` but the argument is defined
  as `query`, causing the search term to always be undefined
- feat: add `random` command (random article summary via REST API)
- feat: add `trending` command (most-read articles, yesterday's data)

All commands are PUBLIC strategy, no browser required, reuse wikiFetch.

* refactor(wikipedia): extract shared types + add docs for random/trending

- Extract WikiSummary, WikiMostReadArticle types to utils.ts
- Extract EXTRACT_MAX_LEN/DESC_MAX_LEN constants
- Add formatSummaryRow() helper to eliminate duplicate mapping in
  summary.ts and random.ts
- Update docs with random and trending command examples

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-22 14:14:21 +08:00
AstroHan e98cf756e9 feat(twitter): add --type flag to timeline command (#83) (#232)
Support switching between For You (algorithmic) and Following
(chronological) timelines via `--type for-you|following`.

Both endpoints share the same response structure; only the GraphQL
endpoint name and queryId differ. QueryId is resolved dynamically
from fa0311/twitter-openapi with a hardcoded fallback, and validated
against /^[A-Za-z0-9_-]+$/ to prevent injection from upstream.
2026-03-22 12:11:21 +08:00
Zhang ShengYan 387aa0d6e5 fix: resolve inconsistent doctor --live report (fix #121) (#224)
* fix(doctor): refresh status after live check to resolve #121

* refactor: reorder live check before status read for natural consistency

Instead of calling checkDaemonStatus() twice (before and after the
connectivity check), reorder so that the live connectivity check runs
first, then read daemon status only once. This:
- Eliminates redundant checkDaemonStatus() call
- Naturally avoids the timing inconsistency (fixes #121)
- Also fixes the sessions query using stale status
- Simplifies test assertions to avoid over-coupling to exact wording

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-22 10:59:59 +08:00
jakevin 1ecac25df5 fix: correct SKILL.md github reference and add missing adapter docs (#230)
- SKILL.md: replace non-existent 'opencli github search' with correct
  'opencli gh' external CLI passthrough examples
- SKILL.md: remove 'github search' from public API commands list
- Add missing docs for douban, sinablog, substack adapters (fixes
  doc-check CI failure: 47/50 → 50/50)
- Add new adapter pages to VitePress sidebar config
2026-03-22 10:54:03 +08:00
jakevin 9921e5d696 remove broken desktop adapters (#221) 2026-03-22 04:14:07 +08:00
jakevin 4c8a447ead fix: align positional primary args and docs (#220) 2026-03-22 04:02:59 +08:00
plat1ko fb2a145e36 feat(xueqiu): make primary args positional (#213)
- search: query → positional
- stock: symbol → positional
- earnings-date: symbol → positional
- Fix build-manifest scanYaml to preserve positional field

Usage:
  opencli xueqiu search '茅台'
  opencli xueqiu stock SH600519
  opencli xueqiu earnings-date SH600519
2026-03-22 03:57:14 +08:00
jakevin bd274ce2d7 refactor: type discovery core (#219) 2026-03-22 03:46:24 +08:00
jakevin 28c393ec86 refactor: type browser core (#218) 2026-03-22 03:41:36 +08:00
jakevin 8a4ea411e1 refactor: type pipeline core (#217) 2026-03-22 03:33:59 +08:00
jakevin 45cee57ca0 refactor: reduce core any usage (#216) 2026-03-22 03:23:16 +08:00
AstroHan 4e3259976b feat(google): add search, suggest, news, and trends adapters (#184)
* feat(google): add search, suggest, news, and trends adapters

Four new commands under `google`:
- search: browser-based DOM extraction from google.com/search
- suggest: public JSON API (suggestqueries.google.com)
- news: public RSS feed (top stories + keyword search)
- trends: public RSS feed (daily trending searches by region)

Shared RSS parser in utils.ts with attribute/CDATA support.
Unit tests for parseRssItems, E2E tests with network skip guards.

* refactor(google): downgrade search strategy from COOKIE to PUBLIC

Google search results are public data, no login needed. Browser is
required for DOM rendering, not authentication. Standalone mode
confirmed working in testing.

* fix: update test comment to reflect PUBLIC strategy
2026-03-22 01:02:32 +08:00
Leo Yuan Tsao bdf5967abd feat: add douban, sinablog, substack adapters; upgrade medium to TS (#185)
New adapters:
- douban: book-hot, movie-hot, search (browser/cookie)
- sinablog: hot, search, article, user (search uses public API)
- substack: feed, publication, search (search uses public API)

Medium upgrade (YAML → TS):
- Replace tag.yaml/user.yaml/publication.yaml with TS adapters
- feed.ts (tag feed by topic), search.ts, user.ts with browser scraping
- Richer data: readTime, claps, description

Core pipeline improvements:
- template.ts: trim template before matching (supports multiline expressions)
- template.ts: evalJsExpr fallback for JS expressions in YAML templates
- template.ts: add urlencode/urldecode filters
- transform.ts: inline select inside map params
- build-manifest.ts: TS-over-YAML dedup with warning log
- build-manifest.ts: export scanTs/shouldReplaceManifestEntry for testing

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-22 01:01:21 +08:00
jakevin 7776db83d7 docs: consolidate adapter docs and discovery loading (#212) 2026-03-22 00:24:45 +08:00
plat1ko fae1dce027 feat(xueqiu): add earnings-date command (#211)
Add new YAML adapter to fetch upcoming earnings dates from xueqiu's
company events API (公司大事). Supports A-share and H-share stocks.

Features:
- Filter by subtype=2 (预计财报发布) from event timeline
- Show date, report name, and release status (/)
- --next flag to return only the closest upcoming earnings date
- --limit to control result count

Co-authored-by: nekomoto911 <nekomoto911@gmail.com>
2026-03-22 00:22:30 +08:00
jakevin d831b04d48 feat(browser): advanced DOM snapshot engine with 13-layer pruning pipeline (#210)
Core Changes:
- New dom-snapshot.ts: 13-layer LLM-optimized DOM pruning engine
  - Tag filtering, SVG collapse, ad/noise detection
  - CSS visibility, viewport threshold, paint-order occlusion
  - Shadow DOM traversal, same-origin iframe extraction
  - BBox parent-child dedup, attribute whitelist + synthetic attrs
  - Table → markdown serialization
  - Incremental diff (mark new elements with *)
  - data-opencli-ref annotation for precise click/type targeting
  - Hidden interactive element hints (scroll-to-reveal)

New APIs:
- IPage.scrollTo(ref) — scroll to snapshot-identified elements
- IPage.getFormState() — extract all form fields as structured JSON
- scrollToRefJs(), getFormStateJs() — standalone JS generators

Integration:
- Page (daemon) + CDPPage (direct CDP): use new engine as primary
- dom-helpers click/type: 4-layer fallback (data-opencli-ref → data-ref → CSS → index)
- Exports from browser/index.ts barrel

Testing:
- 21 new tests for dom-snapshot engine
- All 283 tests pass (29 files, 1.07s)
- Split test scripts: npm test (unit only), npm run test:all (full)
2026-03-22 00:11:51 +08:00
jakevin a22875814b refactor: replace hardcoded skipPreNav with declarative navigateBefore field (#208)
Browser adapters using COOKIE/HEADER strategy need the page on the target
domain so credentialed fetch() carries cookies. Previously, execution.ts
hardcoded `cmd.site === 'boss'` to skip this pre-navigation for adapters
that handle their own goto().

Now each adapter self-declares via `navigateBefore: false` on CliCommand.
This is more extensible — new sites that manage their own navigation just
add the field instead of editing execution.ts.

Changes:
- Add `navigateBefore?: boolean | string` to CliCommand interface
- Add `resolvePreNav()` helper in execution.ts (replaces hardcoded check)
- All 14 boss adapters declare `navigateBefore: false`
- Wire through discovery.ts (YAML + manifest) and build-manifest.ts
2026-03-21 23:52:29 +08:00
云比云 ae30763e9b refactor(boss): extract common.ts utilities, fix missing login detection (#200)
* refactor(boss): extract common utilities, fix missing login detection

- Add src/clis/boss/common.ts with shared helpers:
  - bossFetch(): unified XHR template with auto cookie-expiry detection (code 7/37)
  - navigateToChat()/navigateTo(): page navigation helpers
  - checkAuth()/assertOk(): centralized login state validation
  - fetchFriendList()/fetchRecommendList()/findFriendByUid(): data queries
  - clickCandidateInList()/typeAndSendMessage(): UI automation helpers
  - verbose(): conditional debug logging

- Refactor all 14 boss adapters to use common.ts:
  - chatlist.ts: was missing cookie-expiry check (fixes #login-detect)
  - chatmsg.ts: was missing cookie-expiry check (fixes #login-detect)
  - Remaining 12 adapters: deduplicated XHR boilerplate and error handling

- Fix execution.ts: skip redundant pre-navigation for TS adapters
  - TS adapters handle their own goto(), pre-navigating caused double
    page loads and could trigger duplicate login prompts
  - Pre-navigation preserved for YAML pipeline commands that need it

Net reduction: ~730 lines of duplicated code across boss adapters.
All 244 unit tests pass.

* fix(review): fix execution.ts pre-nav regression, sanitize UID input, restore docs

- execution.ts: use site-specific skip (boss only) instead of isYamlPipeline.
  The original check skipped pre-navigation for ALL TS adapters, but weread,
  chaoxing, and others don't do their own goto() and depend on it.
- common.ts: sanitize numericUid to digits-only and use JSON.stringify for
  safe interpolation in page.evaluate() (prevents template literal injection).
- resume.ts: restore HTML structure doc comments (scraping selector guide).
- send.ts: restore MQTT architecture note (explains why UI automation is needed).

* fix: restore DEBUG env support in verbose(), improve skipPreNav comment

- verbose() now checks both OPENCLI_VERBOSE and DEBUG=opencli,
  matching the original behavior from search.ts and detail.ts
- Clarify skipPreNav comment with TODO for future adapter-level flag

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-21 23:37:13 +08:00
jakevin 3669a89323 fix: fix social adapter bugs, sync docs, refactor boss common utils (#204)
* refactor(boss): extract common utilities, fix missing login detection

- Add src/clis/boss/common.ts with shared helpers:
  - bossFetch(): unified XHR template with auto cookie-expiry detection (code 7/37)
  - navigateToChat()/navigateTo(): page navigation helpers
  - checkAuth()/assertOk(): centralized login state validation
  - fetchFriendList()/fetchRecommendList()/findFriendByUid(): data queries
  - clickCandidateInList()/typeAndSendMessage(): UI automation helpers
  - verbose(): conditional debug logging

- Refactor all 14 boss adapters to use common.ts:
  - chatlist.ts: was missing cookie-expiry check (fixes #login-detect)
  - chatmsg.ts: was missing cookie-expiry check (fixes #login-detect)
  - Remaining 12 adapters: deduplicated XHR boilerplate and error handling

- Fix execution.ts: skip redundant pre-navigation for TS adapters
  - TS adapters handle their own goto(), pre-navigating caused double
    page loads and could trigger duplicate login prompts
  - Pre-navigation preserved for YAML pipeline commands that need it

Net reduction: ~730 lines of duplicated code across boss adapters.
All 244 unit tests pass.

* fix: fix social adapter bugs and sync docs with implementation

Instagram:
- Remove 6 non-existent commands from docs (like/unlike/comment/save/unsave/follow/unfollow)
- Fix usage examples to use positional args

Facebook:
- Remove 6 non-existent commands from docs (friends/groups/memories/events/add-friend/join-group)
- Fix search.yaml: URL encode query param, add missing url column
- Fix feed.yaml: add English locale support for engagement regex patterns

TikTok:
- Fix save/unsave: replace broken data-e2e="undefined-icon" with bookmark-icon/collect-icon
- Fix like/unlike: add state detection to prevent toggling (checks aria-label + computed color)
- Fix notifications: rewrite nested setTimeout to async/await
- Fix comment: add post-comment verification, throw on missing post button

---------

Co-authored-by: Wing Huang <huangsen365@gmail.com>
2026-03-21 23:11:24 +08:00
sline eb0ccaf549 feat(instagram,facebook): add write actions and extended commands (#201)
* feat(instagram,facebook): add write actions and extended commands

Instagram write actions (7 commands, internal REST API + CSRF token):
- like/unlike: like or unlike a user's post by username + index
- comment: comment on a user's post
- save/unsave: bookmark or remove bookmark on a post
- follow/unfollow: follow or unfollow a user

Facebook extended commands (6 commands, DOM scraping):
- friends: friend suggestions list
- groups: list your joined groups with last post time
- memories: On This Day memories
- events: browse event categories
- add-friend: send friend request by username
- join-group: join a group by ID

All commands tested with live data. 258 existing tests pass.

* docs: add adapter documentation for instagram, facebook, lobsters

* docs: add missing medium adapter documentation
2026-03-21 23:09:39 +08:00
AstroHan fbf051d539 fix(extension): skip chrome-extension:// tabs in resolveTabId fallback (#198)
* fix(extension): skip chrome-extension:// tabs in resolveTabId fallback

Remove the unsafe fallback that returned `tabs[0]` regardless of URL
type. When no web-accessible tab exists in the automation window (e.g.
a New Tab Override extension replaced about:blank with its own
chrome-extension:// page), we now always create a fresh about:blank
tab instead. This prevents chrome.debugger.attach from failing with
"Cannot access a chrome-extension:// URL of different extension".

Fixes #195, fixes #197

* refactor(extension): rename isWebUrl → isDebuggableUrl & reuse tabs in resolveTabId

Improvements over the original fix:

1. Rename isWebUrl() → isDebuggableUrl(): better reflects the intent —
   the function determines whether a URL can be attached via CDP, not
   just whether it's a "web" URL (about:blank is debuggable but not
   really a web URL).

2. Reuse existing non-debuggable tabs: when a New Tab Override extension
   replaces about:blank with chrome-extension://, use chrome.tabs.update()
   to navigate the existing tab to about:blank instead of creating a new
   one. This prevents orphan tab accumulation since chrome.tabs.create()
   may also get intercepted by the same extension.

3. Only fall back to chrome.tabs.create() when the window has zero tabs,
   which is the truly empty-window edge case.

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-21 23:05:06 +08:00
Kasumi fcff2e40be feat(grok): add opt-in --web flow for grok ask (#193) 2026-03-21 23:00:10 +08:00
sline 4391ccfcb8 feat(tiktok): add TikTok adapter with 15 commands (#202)
* feat(tiktok): add TikTok adapter with 15 commands

TikTok (15 commands, browser mode):

Read commands:
- profile: user profile info via rehydration script parsing
- search: search videos via internal search API
- explore: trending videos from explore page (DOM scraping)
- user: recent videos from a user page (DOM scraping)
- following: list accounts you follow
- friends: friend suggestions
- live: browse live streams with viewer counts
- notifications: activity notifications

Write commands (verified with real interactions):
- like/unlike: like or unlike a video by URL
- save/unsave: add or remove video from Favorites
- follow/unfollow: follow or unfollow a user
- comment: comment on a video

All write operations verified with live TikTok interactions.

* docs: add missing adapter documentation for doc-coverage CI
2026-03-21 22:57:56 +08:00
sline ce484c2a63 feat: add Lobste.rs, Instagram, and Facebook adapters (#199)
* feat(lobsters): add Lobste.rs adapter with hot, newest, active, tag commands

Add public API adapter for Lobste.rs (lobste.rs), a developer-focused
link aggregation community. All commands use the public JSON API and
require no authentication or browser.

Commands:
- hot: hottest stories
- newest: latest stories
- active: most active discussions
- tag: filter stories by tag (e.g. rust, security, programming)

* feat(instagram,facebook): add Instagram and Facebook adapters

Instagram (7 commands, browser mode - internal REST API):
- profile: user profile info (followers, following, posts, bio)
- search: search users
- user: recent posts from a user
- followers: list user's followers
- following: list user's following
- saved: saved posts
- explore: discover trending posts

Facebook (4 commands, browser mode - DOM scraping):
- profile: user/page profile info
- notifications: recent notifications
- feed: news feed posts
- search: search people, pages, posts

All commands require Chrome to be logged in to the respective site.
Instagram uses stable internal API endpoints with cookie auth.
Facebook uses DOM scraping via role attributes and semantic selectors.
2026-03-21 20:43:32 +08:00
VK 06c902aeed feat(medium): add medium adapter (#190) 2026-03-21 20:42:45 +08:00
AlexYue 1d39295f4b feat: plugin system (Stage 0-2)
* feat: plugin system (Stage 0-2)

- Stage 0: discoverPlugins() scans ~/.opencli/plugins/ at startup
- Stage 1: demo plugin repos (github-trending, hot-digest)
- Stage 2: opencli plugin install/uninstall/list commands
- package.json exports ./registry for TS plugin peerDep support
- 17 new/updated tests, tsc --noEmit clean

* fix: CDPBridge connect timeout unit mismatch (seconds vs ms)

opts.timeout is passed in seconds from runtime.ts but CDPBridge
was using it as milliseconds, causing instant timeout (30ms).

* feat: add registry-api public entry point for TS plugin peerDep support

- Add src/registry-api.ts: re-exports core registration API (cli, Strategy,
  getRegistry) without transitive side-effects, safe for plugin imports
- Update package.json exports: './registry' -> './dist/registry-api.js'
- Update src/registry.ts: use globalThis shared registry to ensure single
  instance across npm-linked plugin modules
- Update .gitignore for plugin-related artifacts

* fix: symlink host opencli into plugin node_modules on install

After npm install, replace the npm-installed @jackwener/opencli
with a symlink to the running host's package root. This ensures
TS plugins always resolve '@jackwener/opencli/registry' against
the host installation, avoiding version mismatches when the
published npm package lags behind.

* fix: transpile TS plugins to JS on install, deduplicate .ts/.js discovery

- installPlugin: after symlinking host opencli, transpile any .ts files
  to .js using esbuild from the host's node_modules/.bin/
- discoverPluginDir: skip .ts files when a .js sibling exists (production
  node cannot load .ts directly)
- scanPluginCommands: deduplicate basenames via Set to avoid showing
  'aggregate, aggregate' when both .ts and .js exist

* docs: add plugin system user guide

- New docs/guide/plugins.md covering:
  - Installation/uninstallation commands
  - Creating YAML plugins (zero-dep)
  - Creating TS plugins (with peerDep)
  - TS plugin install lifecycle (clone → deps → symlink → transpile)
  - Example plugins and troubleshooting
- Add Plugins to VitePress sidebar (EN + ZH)
- Link from getting-started.md Next Steps

* fix: address review issues in plugin system

- Security: replace execSync with execFileSync to prevent shell injection
- Replace deprecated npm --production with --omit=dev
- Tighten parseSource regex to [\w.-]+ to reject special chars
- Fix ZH sidebar plugin link (/guide/plugins → /zh/guide/plugins)
- Return plugin name from installPlugin() to avoid duplicated logic
- Use execFileSync for esbuild transpilation
- Fix misleading comment in linkHostOpencli

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-21 19:41:15 +08:00
jakevin 50ec7c6868 fix(docs): remove dead link to deleted github adapter (#194) 2026-03-21 13:48:49 +08:00
jackwener 644b8bcbd4 chore: remove github adapter (covered by gh CLI hub) 2026-03-21 10:55:03 +08:00
398 changed files with 18263 additions and 5229 deletions
+4 -19
View File
@@ -15,10 +15,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 20
cache: 'npm'
@@ -45,35 +45,20 @@ jobs:
cd extension-package
zip -r ../opencli-extension.zip .
- name: Create Extension CRX
run: |
npm install -g crx3
if [ -n "${{ secrets.CRX_PRIVATE_KEY }}" ]; then
echo "Found CRX_PRIVATE_KEY, signing extension..."
echo "${{ secrets.CRX_PRIVATE_KEY }}" > crx-key.pem
crx3 pack extension-package -o opencli-extension.crx -p crx-key.pem
rm crx-key.pem
else
echo "No CRX_PRIVATE_KEY configured. Generating CRX with a temporary random key..."
crx3 pack extension-package -o opencli-extension.crx
fi
- name: Upload Artifacts (Action Run)
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: opencli-extension-build
path: |
opencli-extension.zip
opencli-extension.crx
retention-days: 7
- name: Attach to GitHub Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v2.6.1
with:
files: |
opencli-extension.zip
opencli-extension.crx
draft: false
prerelease: false
env:
+24 -7
View File
@@ -18,9 +18,9 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
@@ -43,9 +43,9 @@ jobs:
node-version: ['20', '22']
shard: [1, 2]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
@@ -54,7 +54,24 @@ jobs:
run: npm ci
- name: Run unit tests (Node ${{ matrix.node-version }}, shard ${{ matrix.shard }}/2)
run: npx vitest run src/ --reporter=verbose --shard=${{ matrix.shard }}/2
run: npm test -- --reporter=verbose --shard=${{ matrix.shard }}/2
adapter-test:
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run focused adapter tests
run: npm run test:adapter -- --reporter=verbose
# ── Smoke tests (scheduled / manual only) ──
smoke-test:
@@ -62,9 +79,9 @@ jobs:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
+3 -3
View File
@@ -13,7 +13,7 @@ jobs:
doc-coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Check adapter doc coverage
run: bash scripts/check-doc-coverage.sh --strict
@@ -22,9 +22,9 @@ jobs:
docs-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Trigger opencli-website rebuild
uses: peter-evans/repository-dispatch@v3
uses: peter-evans/repository-dispatch@v4
with:
token: ${{ secrets.WEBSITE_DEPLOY_TOKEN }}
repository: jackwener/opencli-website
+2 -2
View File
@@ -16,9 +16,9 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
+2 -2
View File
@@ -13,9 +13,9 @@ jobs:
if: ${{ vars.PKG_PR_NEW_ENABLED == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
-25
View File
@@ -1,25 +0,0 @@
name: Release Please
on:
push:
branches: [main]
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
steps:
- name: Ensure release-please token is configured
run: |
if [ -z "${{ secrets.RELEASE_PLEASE_TOKEN }}" ]; then
echo "RELEASE_PLEASE_TOKEN secret is required so release PRs can trigger downstream CI workflows." >&2
exit 1
fi
- uses: googleapis/release-please-action@v4
with:
release-type: node
token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
+10 -3
View File
@@ -13,9 +13,9 @@ jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
@@ -27,7 +27,7 @@ jobs:
run: npx tsc --noEmit
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v2.6.1
with:
generate_release_notes: true
@@ -35,3 +35,10 @@ jobs:
run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Trigger website rebuild
uses: peter-evans/repository-dispatch@v4
with:
token: ${{ secrets.WEBSITE_DEPLOY_TOKEN }}
repository: jackwener/opencli-website
event-type: version-released
+2 -2
View File
@@ -19,9 +19,9 @@ jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
+7
View File
@@ -15,3 +15,10 @@ docs/.vitepress/cache
*.pem
*.crx
*.zip
.envrc
.windsurf
.claude
.cortex
# Database files
*.db
+128
View File
@@ -1,5 +1,133 @@
# Changelog
## [1.3.2](https://github.com/jackwener/opencli/compare/v1.3.1...v1.3.2) (2026-03-24)
### Features
* **error-handling:** refine error handling with semantic error types and emoji-coded output ([#312](https://github.com/jackwener/opencli/issues/312)) ([b4d64ca](https://github.com/jackwener/opencli/commit/b4d64ca))
### Bug Fixes
* **security:** replace execSync with execFileSync to prevent command injection ([#309](https://github.com/jackwener/opencli/issues/309)) ([41aedf6](https://github.com/jackwener/opencli/commit/41aedf6))
* remove duplicate getErrorMessage import in discovery.ts ([#315](https://github.com/jackwener/opencli/issues/315)) ([75f4237](https://github.com/jackwener/opencli/commit/75f4237))
* **e2e:** broaden xiaoyuzhou skip logic for overseas CI runners ([#316](https://github.com/jackwener/opencli/issues/316)) ([a170873](https://github.com/jackwener/opencli/commit/a170873))
### Documentation
* **SKILL.md:** sync command reference — add missing sites and desktop adapters ([#314](https://github.com/jackwener/opencli/issues/314)) ([8bf750c](https://github.com/jackwener/opencli/commit/8bf750c))
### Chores
* pre-release cleanup — fix dependencies, sync docs, reduce code duplication ([#311](https://github.com/jackwener/opencli/issues/311)) ([c9b3568](https://github.com/jackwener/opencli/commit/c9b3568))
## [1.3.1](https://github.com/jackwener/opencli/compare/v1.3.0...v1.3.1) (2026-03-22)
### Features
* **plugin:** add update command, hot reload after install, README section ([#307](https://github.com/jackwener/opencli/issues/307)) ([966f6e5](https://github.com/jackwener/opencli/commit/966f6e5))
* **yollomi:** add new commands and update documentation ([#235](https://github.com/jackwener/opencli/issues/235)) ([ea83242](https://github.com/jackwener/opencli/commit/ea83242))
* **record:** add live recording command for API capture ([#300](https://github.com/jackwener/opencli/issues/300)) ([dff0fe5](https://github.com/jackwener/opencli/commit/dff0fe5))
* **weibo:** add weibo search command ([#299](https://github.com/jackwener/opencli/issues/299)) ([c7895ea](https://github.com/jackwener/opencli/commit/c7895ea))
* **v2ex:** add node, user, member, replies, nodes commands ([#282](https://github.com/jackwener/opencli/issues/282)) ([a83027d](https://github.com/jackwener/opencli/commit/a83027d))
* **hackernews:** add new, best, ask, show, jobs, search, user commands ([#290](https://github.com/jackwener/opencli/issues/290)) ([127a974](https://github.com/jackwener/opencli/commit/127a974))
* **doubao-app:** add Doubao AI desktop app CLI adapter ([#289](https://github.com/jackwener/opencli/issues/289)) ([66c4b84](https://github.com/jackwener/opencli/commit/66c4b84))
* **doubao:** add doubao browser adapter ([#277](https://github.com/jackwener/opencli/issues/277)) ([9cdc127](https://github.com/jackwener/opencli/commit/9cdc127))
* **xiaohongshu:** add publish command for 图文 note automation ([#276](https://github.com/jackwener/opencli/issues/276)) ([a6d993f](https://github.com/jackwener/opencli/commit/a6d993f))
* **weixin:** add weixin article download adapter & abstract download helpers ([#280](https://github.com/jackwener/opencli/issues/280)) ([b7c6c02](https://github.com/jackwener/opencli/commit/b7c6c02))
### Bug Fixes
* **tests:** use positional arg syntax in browser search tests ([#302](https://github.com/jackwener/opencli/issues/302)) ([4343ec0](https://github.com/jackwener/opencli/commit/4343ec0))
* **xiaohongshu:** improve search login-wall handling and detail output ([#298](https://github.com/jackwener/opencli/issues/298)) ([f8bf663](https://github.com/jackwener/opencli/commit/f8bf663))
* ensure standard PATH is available for external CLIs ([#285](https://github.com/jackwener/opencli/issues/285)) ([22f5c7a](https://github.com/jackwener/opencli/commit/22f5c7a))
* **xiaohongshu:** scope image selector to avoid downloading avatars ([#293](https://github.com/jackwener/opencli/issues/293)) ([3a21be6](https://github.com/jackwener/opencli/commit/3a21be6))
* add turndown dependency to package.json ([#288](https://github.com/jackwener/opencli/issues/288)) ([2a52906](https://github.com/jackwener/opencli/commit/2a52906))
## [1.3.0](https://github.com/jackwener/opencli/compare/v1.2.3...v1.3.0) (2026-03-21)
### Features
* **daemon:** harden security against browser CSRF attacks ([#268](https://github.com/jackwener/opencli/issues/268)) ([40bd11d](https://github.com/jackwener/opencli/commit/40bd11d))
### Performance
* smart page settle via DOM stability detection ([#271](https://github.com/jackwener/opencli/issues/271)) ([4b976da](https://github.com/jackwener/opencli/commit/4b976da))
### Refactoring
* doctor defaults to live mode, remove setup command entirely ([#263](https://github.com/jackwener/opencli/issues/263)) ([b4a8089](https://github.com/jackwener/opencli/commit/b4a8089))
## [1.2.3](https://github.com/jackwener/opencli/compare/v1.2.2...v1.2.3) (2026-03-21)
### Bug Fixes
* replace all about:blank with data: URI to prevent New Tab Override interception ([#257](https://github.com/jackwener/opencli/issues/257)) ([3e91876](https://github.com/jackwener/opencli/commit/3e91876))
* harden resolveTabId against New Tab Override extension interception ([#255](https://github.com/jackwener/opencli/issues/255)) ([112fdef](https://github.com/jackwener/opencli/commit/112fdef))
## [1.2.2](https://github.com/jackwener/opencli/compare/v1.2.1...v1.2.2) (2026-03-21)
### Bug Fixes
* harden browser automation pipeline (resolves [#249](https://github.com/jackwener/opencli/issues/249)) ([#251](https://github.com/jackwener/opencli/issues/251)) ([71b2c39](https://github.com/jackwener/opencli/commit/71b2c39))
## [1.2.1](https://github.com/jackwener/opencli/compare/v1.2.0...v1.2.1) (2026-03-21)
### Bug Fixes
* **twitter:** harden timeline review findings ([#236](https://github.com/jackwener/opencli/issues/236)) ([4cd0409](https://github.com/jackwener/opencli/commit/4cd0409))
* **wikipedia:** fix search arg name + add random and trending commands ([#231](https://github.com/jackwener/opencli/issues/231)) ([1d56dd7](https://github.com/jackwener/opencli/commit/1d56dd7))
* resolve inconsistent doctor --live report (fix [#121](https://github.com/jackwener/opencli/issues/121)) ([#224](https://github.com/jackwener/opencli/issues/224)) ([387aa0d](https://github.com/jackwener/opencli/commit/387aa0d))
## [1.2.0](https://github.com/jackwener/opencli/compare/v1.1.0...v1.2.0) (2026-03-21)
### Features
* **douban:** add movie adapter with search, top250, subject, marks, reviews commands ([#239](https://github.com/jackwener/opencli/issues/239)) ([70651d3](https://github.com/jackwener/opencli/commit/70651d3))
* **devto:** add devto adapter ([#234](https://github.com/jackwener/opencli/issues/234)) ([ea113a6](https://github.com/jackwener/opencli/commit/ea113a6))
* **twitter:** add --type flag to timeline command ([#83](https://github.com/jackwener/opencli/issues/83)) ([e98cf75](https://github.com/jackwener/opencli/commit/e98cf75))
* **google:** add search, suggest, news, and trends adapters ([#184](https://github.com/jackwener/opencli/issues/184)) ([4e32599](https://github.com/jackwener/opencli/commit/4e32599))
* add douban, sinablog, substack adapters; upgrade medium to TS ([#185](https://github.com/jackwener/opencli/issues/185)) ([bdf5967](https://github.com/jackwener/opencli/commit/bdf5967))
* **xueqiu:** add earnings-date command ([#211](https://github.com/jackwener/opencli/issues/211)) ([fae1dce](https://github.com/jackwener/opencli/commit/fae1dce))
* **browser:** advanced DOM snapshot engine with 13-layer pruning pipeline ([#210](https://github.com/jackwener/opencli/issues/210)) ([d831b04](https://github.com/jackwener/opencli/commit/d831b04))
* **instagram,facebook:** add write actions and extended commands ([#201](https://github.com/jackwener/opencli/issues/201)) ([eb0ccaf](https://github.com/jackwener/opencli/commit/eb0ccaf))
* **grok:** add opt-in --web flow for grok ask ([#193](https://github.com/jackwener/opencli/issues/193)) ([fcff2e4](https://github.com/jackwener/opencli/commit/fcff2e4))
* **tiktok:** add TikTok adapter with 15 commands ([#202](https://github.com/jackwener/opencli/issues/202)) ([4391ccf](https://github.com/jackwener/opencli/commit/4391ccf))
* add Lobste.rs, Instagram, and Facebook adapters ([#199](https://github.com/jackwener/opencli/issues/199)) ([ce484c2](https://github.com/jackwener/opencli/commit/ce484c2))
* **medium:** add medium adapter ([#190](https://github.com/jackwener/opencli/issues/190)) ([06c902a](https://github.com/jackwener/opencli/commit/06c902a))
* plugin system (Stage 0-2) ([1d39295](https://github.com/jackwener/opencli/commit/1d39295))
* make primary args positional across all CLIs ([#242](https://github.com/jackwener/opencli/issues/242)) ([9696db9](https://github.com/jackwener/opencli/commit/9696db9))
* **xueqiu:** make primary args positional ([#213](https://github.com/jackwener/opencli/issues/213)) ([fb2a145](https://github.com/jackwener/opencli/commit/fb2a145))
### Refactoring
* replace hardcoded skipPreNav with declarative navigateBefore field ([#208](https://github.com/jackwener/opencli/issues/208)) ([a228758](https://github.com/jackwener/opencli/commit/a228758))
* **boss:** extract common.ts utilities, fix missing login detection ([#200](https://github.com/jackwener/opencli/issues/200)) ([ae30763](https://github.com/jackwener/opencli/commit/ae30763))
* type discovery core ([#219](https://github.com/jackwener/opencli/issues/219)) ([bd274ce](https://github.com/jackwener/opencli/commit/bd274ce))
* type browser core ([#218](https://github.com/jackwener/opencli/issues/218)) ([28c393e](https://github.com/jackwener/opencli/commit/28c393e))
* type pipeline core ([#217](https://github.com/jackwener/opencli/issues/217)) ([8a4ea41](https://github.com/jackwener/opencli/commit/8a4ea41))
* reduce core any usage ([#216](https://github.com/jackwener/opencli/issues/216)) ([45cee57](https://github.com/jackwener/opencli/commit/45cee57))
* fail fast on invalid pipeline steps ([#237](https://github.com/jackwener/opencli/issues/237)) ([c76f86c](https://github.com/jackwener/opencli/commit/c76f86c))
## [1.1.0](https://github.com/jackwener/opencli/compare/v1.0.6...v1.1.0) (2026-03-20)
+45 -4
View File
@@ -17,7 +17,8 @@ npm run build
# 4. Run a few checks
npx tsc --noEmit
npx vitest run src/
npm test
npm run test:adapter
# 5. Link globally (optional, for testing `opencli` command)
npm link
@@ -40,6 +41,11 @@ strategy: public # public | cookie | header
browser: false # true if browser session is needed
args:
query:
positional: true
type: str
required: true
description: Search keyword
limit:
type: int
default: 20
@@ -76,7 +82,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'],
@@ -118,12 +124,46 @@ opencli <site> <command> --limit 3 -f json
opencli <site> <command> -v
```
## Arg Design Convention
Use **positional** for the primary, required argument of a command (the "what" — query, symbol, id, url, username). Use **named options** (`--flag`) for secondary/optional configuration (limit, format, sort, page, filters, language, date).
**Rule of thumb**: Think about how the user will type the command. `opencli xueqiu stock SH600519` is more natural than `opencli xueqiu stock --symbol SH600519`.
| Arg type | Positional? | Examples |
|----------|-------------|----------|
| Main target (query, symbol, id, url, username) | ✅ `positional: true` | `search '茅台'`, `stock SH600519`, `download BV1xxx` |
| Configuration (limit, format, sort, page, type, filters) | ❌ Named `--flag` | `--limit 10`, `--format json`, `--sort hot`, `--location seattle` |
Do **not** convert an argument to positional just because it appears first in the file. If the argument is optional, acts like a filter, or selects a mode/configuration, it should usually stay a named option.
YAML example:
```yaml
args:
query:
positional: true # ← primary arg, user types it directly
type: str
required: true
limit:
type: int # ← config arg, user types --limit 10
default: 20
```
TS example:
```typescript
args: [
{ name: 'query', positional: true, required: true, help: 'Search query' },
{ name: 'limit', type: 'int', default: 10, help: 'Max results' },
]
```
## Testing
See [TESTING.md](./TESTING.md) for the full guide and exact test locations.
```bash
npx vitest run src/ # Unit tests
npm test # Core unit tests (non-adapter)
npm run test:adapter # Focused adapter tests: zhihu/twitter/reddit/bilibili
npx vitest run tests/e2e/ # E2E tests
npx vitest run # All tests
```
@@ -156,7 +196,8 @@ Common scopes: site name (`twitter`, `reddit`) or module name (`browser`, `pipel
3. Run the checks that apply:
```bash
npx tsc --noEmit # Type check
npx vitest run src/ # Unit tests
npm test # Core unit tests
npm run test:adapter # Focused adapter tests (if you touched adapter logic)
opencli validate # YAML validation (if applicable)
```
4. Commit using conventional commit format
+85 -78
View File
@@ -9,7 +9,7 @@
[![Node.js Version](https://img.shields.io/node/v/@jackwener/opencli?style=flat-square)](https://nodejs.org)
[![License](https://img.shields.io/npm/l/@jackwener/opencli?style=flat-square)](./LICENSE)
A CLI tool that turns **any website** or **Electron app** into a command-line interface — Bilibili, Zhihu, 小红书, Twitter/X, Reddit, YouTube, Antigravity, and [many more](#built-in-commands) — powered by browser session reuse and AI-native discovery.
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**: Simply configure an instruction in your global `AGENT.md` or `.cursorrules` guiding the AI to execute `opencli list` via Bash to discover available tools. Register your favorite local CLIs (`opencli register mycli`), and the AI will automatically learn how to invoke all your tools perfectly!
@@ -18,34 +18,36 @@ Turn ANY Electron application into a CLI tool! Recombine, script, and extend app
---
## Table of Contents
- [Highlights](#highlights)
- [Prerequisites](#prerequisites)
- [Quick Start](#quick-start)
- [Built-in Commands](#built-in-commands)
- [Desktop App Adapters](#desktop-app-adapters)
- [Download Support](#download-support)
- [Output Formats](#output-formats)
- [For AI Agents (Developer Guide)](#for-ai-agents-developer-guide)
- [Remote Chrome (Server/Headless)](#remote-chrome-serverheadless)
- [Testing](#testing)
- [Troubleshooting](#troubleshooting)
- [Releasing New Versions](#releasing-new-versions)
- [License](#license)
---
## Highlights
- **CLI All Electron** — CLI-ify apps like Antigravity Ultra! Now AI can control itself natively using cc/openclaw!
- **Account-safe** — Reuses Chrome's logged-in state; your credentials never leave the browser.
- **AI Agent ready** — `explore` discovers APIs, `synthesize` generates adapters, `cascade` finds auth strategies.
- **External CLI Hub** — Discover, auto-install, and passthrough commands to any external CLI (gh, obsidian, docker, kubectl, etc). Zero setup.
- **Self-healing setup** — `opencli setup` verifies Browser Bridge connectivity; `opencli doctor` diagnoses daemon, extension, and live browser connectivity.
- **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).
## Prerequisites
- **Node.js**: >= 20.0.0
@@ -60,11 +62,11 @@ OpenCLI connects to your browser through a lightweight **Browser Bridge** Chrome
You can install the extension via either method:
**Method 1: Download Pre-built Release (Recommended)**
1. Go to the GitHub [Releases page](https://github.com/jackwener/opencli/releases) and download the latest `opencli-extension.zip` or `opencli-extension.crx`.
2. Open `chrome://extensions` and enable **Developer mode** (top-right toggle).
3. Drag and drop the `.crx` file or the unzipped folder into the extensions page.
1. Go to the GitHub [Releases page](https://github.com/jackwener/opencli/releases) and download the latest `opencli-extension.zip`.
2. Unzip the file and open `chrome://extensions`, enable **Developer mode** (top-right toggle).
3. Click **Load unpacked** and select the unzipped folder.
**Method 2: Load Unpacked Source (For Developers)**
**Method 2: Load Source (For Developers)**
1. Open `chrome://extensions` and enable **Developer mode**.
2. Click **Load unpacked** and select the `extension/` directory from this repository.
@@ -73,7 +75,6 @@ That's it! The daemon auto-starts when you run any browser command. No tokens, n
> **Tip**: Use `opencli doctor` for ongoing diagnosis:
> ```bash
> opencli doctor # Check extension + daemon connectivity
> opencli doctor --live # Also test live browser commands
> ```
## Quick Start
@@ -118,50 +119,63 @@ Run `opencli list` for the live registry.
| Site | Commands | Mode |
|------|----------|------|
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` | 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** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | Browser |
| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | Desktop |
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | Browser |
| **codex** | `status` `send` `read` `new` `dump` `extract-diff` `model` `ask` `screenshot` `history` `export` | Desktop |
| **chatwise** | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` | Desktop |
| **doubao** | `status` `new` `send` `read` `ask` | Browser |
| **doubao-app** | `status` `new` `send` `read` `ask` `screenshot` `dump` | Desktop |
| **notion** | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` | Desktop |
| **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` | Desktop |
| **v2ex** | `hot` `latest` `topic` `daily` `me` `notifications` | Public / Browser |
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | Browser |
| **v2ex** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | Public / Browser |
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` `earnings-date` | Browser |
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | Desktop |
| **chatgpt** | `status` `new` `send` `read` `ask` | Desktop |
| **xiaohongshu** | `search` `notifications` `feed` `user` `download` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | Browser |
| **xiaohongshu** | `search` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | Browser |
| **apple-podcasts** | `search` `episodes` `top` | Public |
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | Public |
| **zhihu** | `hot` `search` `question` `download` | Browser |
| **weixin** | `download` | Browser |
| **youtube** | `search` `video` `transcript` | Browser |
| **boss** | `search` `detail` `recommend` `joblist` `greet` `batchgreet` `send` `chatlist` `chatmsg` `invite` `mark` `exchange` `resume` `stats` | Browser |
| **coupang** | `search` `add-to-cart` | Browser |
| **bbc** | `news` | Public |
| **bloomberg** | `main` `markets` `economics` `industries` `tech` `politics` `businessweek` `opinions` `feeds` `news` | Public / Browser |
| **ctrip** | `search` | Browser |
| **github** | `search` | Public |
| **devto** | `top` `tag` `user` | Public |
| **dictionary** | `search` `synonyms` `examples` | Public |
| **arxiv** | `search` `paper` | Public |
| **wikipedia** | `search` `summary` | Public |
| **hackernews** | `top` | Public |
| **wikipedia** | `search` `summary` `random` `trending` | Public |
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | Public |
| **linkedin** | `search` | Browser |
| **reuters** | `search` | Browser |
| **smzdm** | `search` | Browser |
| **weibo** | `hot` | Browser |
| **weibo** | `hot` `search` | Browser |
| **yahoo-finance** | `quote` | Browser |
| **sinafinance** | `news` | 🌐 Public |
| **barchart** | `quote` `options` `greeks` `flow` | Browser |
| **chaoxing** | `assignments` `exams` | Browser |
| **grok** | `ask` | Desktop |
| **grok** | `ask` | Browser |
| **hf** | `top` | Public |
| **jike** | `feed` `search` `create` `like` `comment` `repost` `notifications` `post` `topic` `user` | Browser |
| **jimeng** | `generate` `history` | Browser |
| **yollomi** | `generate` `video` `edit` `upload` `models` `remove-bg` `upscale` `face-swap` `restore` `try-on` `background` `object-remover` | Browser |
| **linux-do** | `hot` `latest` `search` `categories` `category` `topic` | Public |
| **stackoverflow** | `hot` `search` `bounties` `unanswered` | Public |
| **steam** | `top-sellers` | Public |
| **weread** | `shelf` `search` `book` `highlights` `notes` `notebooks` `ranking` | Browser |
| **douban** | `search` `top250` `subject` `marks` `reviews` `movie-hot` `book-hot` | Browser |
| **facebook** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | Browser |
| **google** | `news` `search` `suggest` `trends` | Public |
| **instagram** | `explore` `profile` `search` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `saved` | Browser |
| **lobsters** | `hot` `newest` `active` `tag` | Public |
| **medium** | `feed` `search` `user` | Browser |
| **sinablog** | `hot` `search` `article` `user` | Browser |
| **substack** | `feed` `search` `publication` | Browser |
| **tiktok** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | Browser |
> **Bloomberg note**: The RSS-backed Bloomberg listing commands (`main`, section feeds, `feeds`) work without a browser. `bloomberg news` is for standard Bloomberg story/article pages that your current Chrome session can already access. Audio and some other non-standard pages may fail, and OpenCLI does not bypass Bloomberg paywall or entitlement checks.
### External CLI Hub
@@ -172,8 +186,8 @@ OpenCLI acts as a universal hub for your existing command-line tools. It provide
| **gh** | GitHub CLI | `opencli gh pr list --limit 5` |
| **obsidian** | Obsidian vault management | `opencli obsidian search query="AI"` |
| **docker** | Docker command-line interface | `opencli docker ps` |
| **kubectl** | Kubernetes command-line tool | `opencli kubectl get pods` |
| **readwise** | Readwise & Reader CLI | `opencli readwise login` |
| **gws** | Google Workspace CLI — Docs, Sheets, Drive, Gmail, Calendar | `opencli gws docs list` |
**Zero Configuration**: OpenCLI purely passes your inputs to the underlying binary via standard I/O streams. The external CLI works exactly as it naturally would, maintaining its standard output formats.
@@ -198,9 +212,7 @@ Each desktop adapter has its own detailed documentation with commands reference,
| **ChatWise** | Multi-LLM client (GPT-4, Claude, Gemini) | [Doc](./docs/adapters/desktop/chatwise.md) |
| **Notion** | Search, read, write Notion pages | [Doc](./docs/adapters/desktop/notion.md) |
| **Discord** | Discord Desktop — messages, channels, servers | [Doc](./docs/adapters/desktop/discord.md) |
| **Feishu** | 飞书/Lark Desktop via AppleScript | [Doc](./docs/adapters/desktop/feishu.md) |
| **WeChat** | 微信 Desktop via AppleScript + Accessibility | [Doc](./docs/adapters/desktop/wechat.md) |
| **NeteaseMusic** | 网易云音乐 Desktop via CEF/CDP | [Doc](./docs/adapters/desktop/neteasemusic.md) |
| **Doubao** | Control Doubao AI desktop app via CDP | [Doc](./docs/adapters/desktop/doubao-app.md) |
## Download Support
@@ -214,6 +226,7 @@ OpenCLI supports downloading images, videos, and articles from supported platfor
| **bilibili** | Videos | Requires `yt-dlp` installed |
| **twitter** | Images, Videos | Downloads from user media tab or single tweet |
| **zhihu** | Articles (Markdown) | Exports articles with optional image download |
| **weixin** | Articles (Markdown) | Exports WeChat Official Account articles |
### Prerequisites
@@ -230,11 +243,11 @@ brew install yt-dlp
```bash
# Download images/videos from Xiaohongshu note
opencli xiaohongshu download --note-id abc123 --output ./xhs
opencli xiaohongshu download abc123 --output ./xhs
# Download Bilibili video (requires yt-dlp)
opencli bilibili download --bvid BV1xxx --output ./bilibili
opencli bilibili download --bvid BV1xxx --quality 1080p # Specify quality
opencli bilibili download BV1xxx --output ./bilibili
opencli bilibili download BV1xxx --quality 1080p # Specify quality
# Download Twitter media from user
opencli twitter download elonmusk --limit 20 --output ./twitter
@@ -247,22 +260,12 @@ opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
# Export with local images
opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --download-images
# Export WeChat article to Markdown
opencli weixin download --url "https://mp.weixin.qq.com/s/xxx" --output ./weixin
```
### Pipeline Step (for YAML adapters)
The `download` step can be used in YAML pipelines:
```yaml
pipeline:
- fetch: https://api.example.com/media
- download:
url: ${{ item.imageUrl }}
dir: ./downloads
filename: ${{ item.title | sanitize }}.jpg
concurrency: 5
skip_existing: true
```
## Output Formats
@@ -279,6 +282,25 @@ opencli bilibili hot -f csv # CSV
opencli bilibili hot -v # Verbose: show pipeline debug steps
```
## Plugins
Extend OpenCLI with community-contributed adapters. Plugins use the same YAML/TS format as built-in commands and are automatically discovered at startup.
```bash
opencli plugin install github:user/opencli-plugin-my-tool # Install
opencli plugin list # List installed
opencli plugin update my-tool # Update to latest
opencli plugin uninstall my-tool # Remove
```
| Plugin | Type | Description |
|--------|------|-------------|
| [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 |
See [Plugins Guide](./docs/guide/plugins.md) for creating your own plugin.
## For AI Agents (Developer Guide)
If you are an AI assistant tasked with creating a new command adapter for `opencli`, please follow the AI Agent workflow below:
@@ -305,26 +327,14 @@ Explore outputs to `.opencli/explore/<site>/` (manifest.json, endpoints.json, ca
## Testing
See **[TESTING.md](./TESTING.md)** for the full testing guide, including:
- Current test coverage (unit + E2E tests across browser and desktop adapters)
- How to run tests locally
- How to add tests when creating new adapters
- CI/CD pipeline with sharding
- Headless browser mode (`OPENCLI_HEADLESS=1`)
```bash
# Quick start
npm run build
npx vitest run # All tests
npx vitest run src/ # Unit tests only
npx vitest run tests/e2e/ # E2E tests
```
See **[TESTING.md](./TESTING.md)** for how to run and write tests.
## Troubleshooting
- **"Extension not connected"**
- Ensure the opencli Browser Bridge extension is installed and **enabled** in `chrome://extensions`.
- **"attach failed: Cannot access a chrome-extension:// URL"**
- Another Chrome extension (e.g. youmind, New Tab Override, or AI assistant extensions) may be interfering. Try **disabling other extensions** temporarily, then retry.
- **Empty data returns or 'Unauthorized' error**
- Your login session in Chrome might have expired. Open a normal Chrome tab, navigate to the target site, and log in or refresh the page.
- **Node API errors**
@@ -333,15 +343,12 @@ npx vitest run tests/e2e/ # E2E tests
- Check daemon status: `curl localhost:19825/status`
- View extension logs: `curl localhost:19825/logs`
## Releasing New Versions
```bash
npm version patch # 0.1.0 → 0.1.1
npm version minor # 0.1.0 → 0.2.0
git push --follow-tags
```
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=jackwener/opencli&type=Date)](https://star-history.com/#jackwener/opencli&Date)
The CI will automatically build, create a GitHub release, and publish to npm.
## License
+111 -66
View File
@@ -9,7 +9,7 @@
[![Node.js Version](https://img.shields.io/node/v/@jackwener/opencli?style=flat-square)](https://nodejs.org)
[![License](https://img.shields.io/npm/l/@jackwener/opencli?style=flat-square)](./LICENSE)
OpenCLI 将任何网站或 Electron 应用(如 Antigravity)变成命令行工具 — B站、知乎、小红书、Twitter/X、Reddit、YouTube 等[多种站点与应用](#内置命令) — 复用浏览器登录态,AI 驱动探索。
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 便能瞬间学会自动调用相应的本地工具!
@@ -20,32 +20,36 @@ CLI all electron!现在支持把所有 electron 应用 CLI 化,从而组合
---
## 目录
- [亮点](#亮点)
- [前置要求](#前置要求)
- [快速开始](#快速开始)
- [内置命令](#内置命令)
- [桌面应用适配器](#桌面应用适配器)
- [下载支持](#下载支持)
- [输出格式](#输出格式)
- [致 AI Agent(开发者指南)](#致-ai-agent开发者指南)
- [远程 Chrome(服务器/无头环境)](#远程-chrome服务器无头环境)
- [常见问题排查](#常见问题排查)
- [版本发布](#版本发布)
- [License](#license)
---
## 亮点
- **CLI All Electron** — 支持把所有 electron 应用(如 Antigravity UltraCLI 化,让 AI 控制自己!
- **多站点覆盖** — 覆盖 B站、知乎、小红书、Twitter、Reddit,以及多种桌面应用
- **零风控** — 复用 Chrome 登录态,无需存储任何凭证
- **自修复配置** — `opencli setup` 检查 Browser Bridge 连通性;`opencli doctor` 诊断 daemon、扩展和浏览器连接状态
- **外部 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 的核心差异:**
- **零 LLM 成本** — 运行时不消耗任何 token,跑一万次不花一分钱
- **确定性** — 同一命令永远返回同一结构,可管道化、可脚本化、CI 友好
- **覆盖广泛** — 50+ 站点,横跨全球与中国平台(B站、知乎、小红书、Reddit、HackerNews 等),并支持通过 CDP 控制桌面 Electron 应用
> 与 Browser-Use、Crawl4AI、Firecrawl 等工具的详细对比,请查看 [Comparison Guide](./docs/comparison.md)。
## 前置要求
- **Node.js**: >= 20.0.0
@@ -60,9 +64,9 @@ OpenCLI 通过轻量化的 **Browser Bridge** Chrome 扩展 + 微型 daemon 与
你可以选择以下任一方式安装扩展:
**方式一:下载构建好的安装包(推荐)**
1. 到 GitHub [Releases 页面](https://github.com/jackwener/opencli/releases) 下载最新的 `opencli-extension.zip``opencli-extension.crx`
2. 打开 Chrome 的 `chrome://extensions`,启用右上角的 **开发者模式**
3. `.crx` 拖入浏览器窗口,或将解压后的文件夹拖入即可完成安装
1. 到 GitHub [Releases 页面](https://github.com/jackwener/opencli/releases) 下载最新的 `opencli-extension.zip`
2. 解压后打开 Chrome 的 `chrome://extensions`,启用右上角的 **开发者模式**
3. 点击 **加载已解压的扩展程序**,选择解压后的文件夹
**方式二:加载源码(针对开发者)**
1. 同样在 `chrome://extensions` 开启 **开发者模式**
@@ -73,7 +77,6 @@ OpenCLI 通过轻量化的 **Browser Bridge** Chrome 扩展 + 微型 daemon 与
> **Tip**:后续诊断用 `opencli doctor`
> ```bash
> opencli doctor # 检查扩展和 daemon 连通性
> opencli doctor --live # 额外测试浏览器命令
> ```
## 快速开始
@@ -118,50 +121,85 @@ 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` | 浏览器 |
| **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` | 浏览器 |
| **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-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` `daily` `me` `notifications` | 公开 / 浏览器 |
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 浏览器 |
| **v2ex** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | 公开 / 浏览器 |
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` `earnings-date` | 浏览器 |
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | 桌面端 |
| **chatgpt** | `status` `new` `send` `read` `ask` | 桌面端 |
| **xiaohongshu** | `search` `notifications` `feed` `user` `download` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 浏览器 |
| **xiaohongshu** | `search` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 浏览器 |
| **apple-podcasts** | `search` `episodes` `top` | 公开 |
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | 公开 |
| **zhihu** | `hot` `search` `question` `download` | 浏览器 |
| **weixin** | `download` | 浏览器 |
| **youtube** | `search` `video` `transcript` | 浏览器 |
| **boss** | `search` `detail` `recommend` `joblist` `greet` `batchgreet` `send` `chatlist` `chatmsg` `invite` `mark` `exchange` `resume` `stats` | 浏览器 |
| **coupang** | `search` `add-to-cart` | 浏览器 |
| **bbc** | `news` | 公共 API |
| **bloomberg** | `main` `markets` `economics` `industries` `tech` `politics` `businessweek` `opinions` `feeds` `news` | 公共 API / 浏览器 |
| **ctrip** | `search` | 浏览器 |
| **github** | `search` | 公共 API |
| **devto** | `top` `tag` `user` | 公 |
| **dictionary** | `search` `synonyms` `examples` | 公开 |
| **arxiv** | `search` `paper` | 公开 |
| **wikipedia** | `search` `summary` | 公开 |
| **hackernews** | `top` | 公共 API |
| **wikipedia** | `search` `summary` `random` `trending` | 公开 |
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | 公共 API |
| **linkedin** | `search` | 浏览器 |
| **reuters** | `search` | 浏览器 |
| **smzdm** | `search` | 浏览器 |
| **weibo** | `hot` | 浏览器 |
| **weibo** | `hot` `search` | 浏览器 |
| **yahoo-finance** | `quote` | 浏览器 |
| **sinafinance** | `news` | 🌐 公开 |
| **barchart** | `quote` `options` `greeks` `flow` | 浏览器 |
| **chaoxing** | `assignments` `exams` | 浏览器 |
| **grok** | `ask` | 桌面端 |
| **grok** | `ask` | 浏览器 |
| **hf** | `top` | 公开 |
| **jike** | `feed` `search` `create` `like` `comment` `repost` `notifications` `post` `topic` `user` | 浏览器 |
| **jimeng** | `generate` `history` | 浏览器 |
| **yollomi** | `generate` `video` `edit` `upload` `models` `remove-bg` `upscale` `face-swap` `restore` `try-on` `background` `object-remover` | 浏览器 |
| **linux-do** | `hot` `latest` `search` `categories` `category` `topic` | 公开 |
| **stackoverflow** | `hot` `search` `bounties` `unanswered` | 公开 |
| **steam** | `top-sellers` | 公开 |
| **weread** | `shelf` `search` `book` `highlights` `notes` `notebooks` `ranking` | 浏览器 |
| **douban** | `search` `top250` `subject` `marks` `reviews` `movie-hot` `book-hot` | 浏览器 |
| **facebook** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | 浏览器 |
| **google** | `news` `search` `suggest` `trends` | 公开 |
| **instagram** | `explore` `profile` `search` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `saved` | 浏览器 |
| **lobsters** | `hot` `newest` `active` `tag` | 公开 |
| **medium** | `feed` `search` `user` | 浏览器 |
| **sinablog** | `hot` `search` `article` `user` | 浏览器 |
| **substack** | `feed` `search` `publication` | 浏览器 |
| **tiktok** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | 浏览器 |
> **Bloomberg 说明**Bloomberg 的 RSS 列表命令(`main`、各栏目 feed、`feeds`)无需浏览器即可使用。`bloomberg news` 适用于当前 Chrome 会话本身就能访问的标准 Bloomberg 文章页。音频页和部分非标准页面可能失败,OpenCLI 也不会绕过 Bloomberg 的付费墙、登录或权限校验。
### 外部 CLI 枢纽
OpenCLI 也可以作为你现有命令行工具的统一入口,负责发现、自动安装和纯透传执行。
| 外部 CLI | 描述 | 示例 |
|----------|------|------|
| **gh** | GitHub CLI | `opencli gh pr list --limit 5` |
| **obsidian** | Obsidian 仓库管理 | `opencli obsidian search query="AI"` |
| **docker** | Docker 命令行工具 | `opencli docker ps` |
| **readwise** | Readwise / Reader CLI | `opencli readwise login` |
| **gws** | Google Workspace CLI — Docs, Sheets, Drive, Gmail, Calendar | `opencli gws docs list` |
**零配置透传**:OpenCLI 会把你的输入原样转发给底层二进制,保留原生 stdout / stderr 行为。
**自动安装**:如果你运行 `opencli gh ...` 时系统中还没有 `gh`,OpenCLI 会优先尝试通过系统包管理器安装,然后自动重试命令。
**注册自定义本地 CLI**
```bash
opencli register mycli
```
### 桌面应用适配器
@@ -169,16 +207,14 @@ npm install -g @jackwener/opencli@latest
| 应用 | 描述 | 文档 |
|-----|-------------|-----|
| **Cursor** | 控制 Cursor IDE — Composer、对话、代码提取等 | [README](./src/clis/cursor/README.md) |
| **Codex** | 在后台(无头)驱动 OpenAI Codex CLI Agent | [README](./src/clis/codex/README.md) |
| **Antigravity** | 在终端直接控制 Antigravity Ultra | [README](./src/clis/antigravity/README.md) |
| **ChatGPT** | 自动化操作 ChatGPT macOS 桌面客户端 | [README](./src/clis/chatgpt/README.md) |
| **ChatWise** | 多 LLM 客户端(GPT-4、Claude、Gemini | [README](./src/clis/chatwise/README.md) |
| **Notion** | 搜索、读取、写入 Notion 页面 | [README](./src/clis/notion/README.md) |
| **Discord** | Discord 桌面版 — 消息、频道、服务器 | [README](./src/clis/discord-app/README.md) |
| **Feishu** | 飞书/Lark 桌面版 (AppleScript 驱动) | [README](./src/clis/feishu/README.md) |
| **WeChat** | 微信 Mac 桌面端 (AppleScript + 无障碍接口) | [README](./src/clis/wechat/README.md) |
| **NeteaseMusic** | 网易云音乐 (CEF/CDP 驱动) | [README](./src/clis/neteasemusic/README.md) |
| **Cursor** | 控制 Cursor IDE — Composer、对话、代码提取等 | [Doc](./docs/adapters/desktop/cursor.md) |
| **Codex** | 在后台(无头)驱动 OpenAI Codex CLI Agent | [Doc](./docs/adapters/desktop/codex.md) |
| **Antigravity** | 在终端直接控制 Antigravity Ultra | [Doc](./docs/adapters/desktop/antigravity.md) |
| **ChatGPT** | 自动化操作 ChatGPT macOS 桌面客户端 | [Doc](./docs/adapters/desktop/chatgpt.md) |
| **ChatWise** | 多 LLM 客户端(GPT-4、Claude、Gemini | [Doc](./docs/adapters/desktop/chatwise.md) |
| **Notion** | 搜索、读取、写入 Notion 页面 | [Doc](./docs/adapters/desktop/notion.md) |
| **Discord** | Discord 桌面版 — 消息、频道、服务器 | [Doc](./docs/adapters/desktop/discord.md) |
| **Doubao** | 通过 CDP 控制豆包桌面应用 | [Doc](./docs/adapters/desktop/doubao-app.md) |
## 下载支持
@@ -192,6 +228,7 @@ OpenCLI 支持从各平台下载图片、视频和文章。
| **B站** | 视频 | 需要安装 `yt-dlp` |
| **Twitter/X** | 图片、视频 | 从用户媒体页或单条推文下载 |
| **知乎** | 文章(Markdown) | 导出文章,可选下载图片到本地 |
| **微信公众号** | 文章(Markdown | 导出微信公众号文章为 Markdown |
### 前置依赖
@@ -208,11 +245,11 @@ brew install yt-dlp
```bash
# 下载小红书笔记中的图片/视频
opencli xiaohongshu download --note-id abc123 --output ./xhs
opencli xiaohongshu download abc123 --output ./xhs
# 下载B站视频(需要 yt-dlp
opencli bilibili download --bvid BV1xxx --output ./bilibili
opencli bilibili download --bvid BV1xxx --quality 1080p # 指定画质
opencli bilibili download BV1xxx --output ./bilibili
opencli bilibili download BV1xxx --quality 1080p # 指定画质
# 下载 Twitter 用户的媒体
opencli twitter download elonmusk --limit 20 --output ./twitter
@@ -225,22 +262,12 @@ opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
# 导出并下载图片
opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --download-images
# 导出微信公众号文章为 Markdown
opencli weixin download --url "https://mp.weixin.qq.com/s/xxx" --output ./weixin
```
### Pipeline Step(用于 YAML 适配器)
`download` step 可以在 YAML 管线中使用:
```yaml
pipeline:
- fetch: https://api.example.com/media
- download:
url: ${{ item.imageUrl }}
dir: ./downloads
filename: ${{ item.title | sanitize }}.jpg
concurrency: 5
skip_existing: true
```
## 输出格式
@@ -257,6 +284,25 @@ opencli bilibili hot -f csv # CSV
opencli bilibili hot -v # 详细模式:展示管线执行步骤调试信息
```
## 插件
通过社区贡献的插件扩展 OpenCLI。插件使用与内置命令相同的 YAML/TS 格式,启动时自动发现。
```bash
opencli plugin install github:user/opencli-plugin-my-tool # 安装
opencli plugin list # 查看已安装
opencli plugin update my-tool # 更新到最新
opencli plugin uninstall my-tool # 卸载
```
| 插件 | 类型 | 描述 |
|------|------|------|
| [opencli-plugin-github-trending](https://github.com/ByteYue/opencli-plugin-github-trending) | YAML | GitHub Trending 仓库 |
| [opencli-plugin-hot-digest](https://github.com/ByteYue/opencli-plugin-hot-digest) | TS | 多平台热榜聚合 |
| [opencli-plugin-juejin](https://github.com/Astro-Han/opencli-plugin-juejin) | YAML | 稀土掘金热门文章 |
详见 [插件指南](./docs/zh/guide/plugins.md) 了解如何创建自己的插件。
## 致 AI Agent(开发者指南)
如果你是一个被要求查阅代码并编写新 `opencli` 适配器的 AI,请遵守以下工作流。
@@ -285,6 +331,8 @@ opencli cascade https://api.example.com/data
- **"Extension not connected" 报错**
- 确保你当前的 Chrome 已安装且**开启了** opencli Browser Bridge 扩展(在 `chrome://extensions` 中检查)。
- **"attach failed: Cannot access a chrome-extension:// URL" 报错**
- 其他 Chrome 扩展(如 youmind、New Tab Override 或 AI 助手类扩展)可能产生冲突。请尝试**暂时禁用其他扩展**后重试。
- **返回空数据,或者报错 "Unauthorized"**
- Chrome 里的登录态可能已经过期。请打开当前 Chrome 页面,在新标签页重新手工登录或刷新该页面。
- **Node API 错误 (如 parseArgs, fs 等)**
@@ -293,15 +341,12 @@ opencli cascade https://api.example.com/data
- 检查 daemon 状态:`curl localhost:19825/status`
- 查看扩展日志:`curl localhost:19825/logs`
## 版本发布
```bash
npm version patch # 0.1.0 → 0.1.1
npm version minor # 0.1.0 → 0.2.0
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=jackwener/opencli&type=Date)](https://star-history.com/#jackwener/opencli&Date)
# 推送 tagGitHub Actions 将自动执行发版和 npm 发布
git push --follow-tags
```
## License
+327 -13
View File
@@ -1,9 +1,9 @@
---
name: opencli
description: "OpenCLI — Make any website or Electron App your CLI. Zero risk, AI-powered, reuse Chrome login. 150+ commands across 30+ sites."
version: 1.1.0
description: "OpenCLI — Make any website or Electron App your CLI. Zero risk, AI-powered, reuse Chrome login."
version: 1.3.1
author: jackwener
tags: [cli, browser, web, chrome-extension, cdp, bilibili, zhihu, twitter, github, v2ex, hackernews, reddit, xiaohongshu, xueqiu, youtube, boss, coupang, AI, agent]
tags: [cli, browser, web, chrome-extension, cdp, bilibili, zhihu, twitter, github, v2ex, hackernews, reddit, xiaohongshu, xueqiu, youtube, boss, coupang, yollomi, AI, agent]
---
# OpenCLI
@@ -39,7 +39,7 @@ Browser commands require:
> **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`, `github search`, `v2ex`) need no browser.
Public API commands (`hackernews`, `v2ex`) need no browser.
## Commands Reference
@@ -83,8 +83,10 @@ opencli xueqiu feed # 我的关注 timeline
opencli xueqiu hot --limit 10 # 雪球热榜
opencli xueqiu search "特斯拉" # 搜索 (query positional)
# GitHub (public)
opencli github search "cli" # 搜索仓库 (query positional)
# 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 # 热门话题
@@ -176,6 +178,7 @@ 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 # 流式监听增量消息
@@ -218,8 +221,17 @@ opencli weread ranking --limit 10 # 排行榜
opencli jimeng generate --prompt "描述" # AI 生图
opencli jimeng history --limit 10 # 生成历史
# Grok (Desktop)
opencli grok ask "问题" # 提问 Grok (text positional)
# 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 图像编辑
# 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 # 热门模型
@@ -227,6 +239,175 @@ 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 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 # 热销游戏
# 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
@@ -239,9 +420,7 @@ 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 setup # Interactive Browser Bridge setup and connectivity check
opencli doctor # Diagnose daemon, extension, and browser connectivity
opencli doctor --live # Also test live browser connectivity
opencli doctor # Diagnose browser bridge (auto-starts daemon, includes live test)
```
### AI Agent Workflow
@@ -256,14 +435,26 @@ 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,评论"
# Verify: validate adapter definitions
opencli verify
# Validate: validate adapter definitions
opencli validate
```
## Output Formats
@@ -286,6 +477,129 @@ opencli bilibili hot -f csv # CSV
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]
+85 -67
View File
@@ -18,57 +18,72 @@
测试分为三层,全部使用 **vitest** 运行:
```
```text
tests/
├── e2e/ # E2E 集成测试(子进程运行真实 CLI)
│ ├── helpers.ts # runCli() 共享工具
│ ├── public-commands.test.ts # 公开 API 命令(无需浏览器)
│ ├── helpers.ts # runCli() / parseJsonOutput() 共享工具
│ ├── public-commands.test.ts # 公开 API 命令
│ ├── browser-public.test.ts # 浏览器命令(公开数据)
│ ├── browser-auth.test.ts # 需登录命令(graceful failure 测试
│ ├── management.test.ts # 管理命令(list, validate, verify, help
│ └── output-formats.test.ts # 输出格式json/yaml/csv/md
├── smoke/ # 烟雾测试(仅定时 / 手动触发)
│ └── api-health.test.ts # 外部 API 可用性检测
│ ├── browser-auth.test.ts # 需登录命令(graceful failure
│ ├── management.test.ts # 管理命令(list / validate / verify / help
│ └── output-formats.test.ts # 输出格式校验
├── smoke/
│ └── api-health.test.ts # 外部 API、adapter 定义、命令注册健康检查
src/
── *.test.ts # 单元测试(已有 8 个
── **/*.test.ts # 单元测试(当前 32 个文件
```
| 层 | 位置 | 运行方式 | 用途 |
|---|---|---|---|
| 单元测试 | `src/**/*.test.ts` | `npx vitest run src/` | 内部模块逻辑 |
| E2E 测试 | `tests/e2e/*.test.ts` | `npx vitest run tests/e2e/` | 真实 CLI 命令执行 |
| 烟雾测试 | `tests/smoke/*.test.ts` | `npx vitest run tests/smoke/` | 外部 API 健康 |
| 层 | 位置 | 当前文件数 | 运行方式 | 用途 |
|---|---|---:|---|---|
| 单元测试 | `src/**/*.test.ts` | 32 | `npx vitest run src/` | 内部模块、pipeline、adapter 工具函数 |
| E2E 测试 | `tests/e2e/*.test.ts` | 5 | `npx vitest run tests/e2e/` | 真实 CLI 命令执行 |
| 烟雾测试 | `tests/smoke/*.test.ts` | 1 | `npx vitest run tests/smoke/` | 外部 API 与注册完整性 |
---
## 当前覆盖范围
### 单元测试(8 个文件)
### 单元测试(32 个文件)
| 文件 | 覆盖内容 |
| 领域 | 文件 |
|---|---|
| `browser.test.ts` | JSON-RPC、tab 管理、extension/standalone 模式切换 |
| `engine.test.ts` | 命令发现与执行 |
| `registry.test.ts` | 命令注册与策略分配 |
| `output.test.ts` | 输出格式渲染 |
| `doctor.test.ts` | Token 诊断 |
| `coupang.test.ts` | 数据归一化 |
| `pipeline/template.test.ts` | 模板表达式求值 |
| `pipeline/transform.test.ts` | 数据变换步骤 |
| 核心运行时与输出 | `src/browser.test.ts`, `src/browser/dom-snapshot.test.ts`, `src/build-manifest.test.ts`, `src/capabilityRouting.test.ts`, `src/doctor.test.ts`, `src/engine.test.ts`, `src/interceptor.test.ts`, `src/output.test.ts`, `src/plugin.test.ts`, `src/registry.test.ts`, `src/snapshotFormatter.test.ts` |
| pipeline 与下载 | `src/download/index.test.ts`, `src/pipeline/executor.test.ts`, `src/pipeline/template.test.ts`, `src/pipeline/transform.test.ts` |
| 站点 / adapter 逻辑 | `src/clis/apple-podcasts/commands.test.ts`, `src/clis/apple-podcasts/utils.test.ts`, `src/clis/bloomberg/utils.test.ts`, `src/clis/chaoxing/utils.test.ts`, `src/clis/coupang/utils.test.ts`, `src/clis/google/utils.test.ts`, `src/clis/grok/ask.test.ts`, `src/clis/twitter/timeline.test.ts`, `src/clis/weread/utils.test.ts`, `src/clis/xiaohongshu/creator-note-detail.test.ts`, `src/clis/xiaohongshu/creator-notes-summary.test.ts`, `src/clis/xiaohongshu/creator-notes.test.ts`, `src/clis/xiaohongshu/search.test.ts`, `src/clis/xiaohongshu/user-helpers.test.ts`, `src/clis/xiaoyuzhou/utils.test.ts`, `src/clis/youtube/transcript-group.test.ts`, `src/clis/zhihu/download.test.ts` |
### E2E 测试(~52 个用例)
这些测试覆盖的重点包括:
| 文件 | 覆盖站点/功能 | 测试数 |
|---|---|---|
| `public-commands.test.ts` | hackernews/top, v2ex/hot, v2ex/latest, v2ex/topic | 5 |
| `browser-public.test.ts` | bbc, bilibili×3, weibo, zhihu×2, reddit×2, twitter, xueqiu×2, reuters, youtube, smzdm, boss, ctrip, coupang, xiaohongshu, yahoo-finance, v2ex/daily | 21 |
| `browser-auth.test.ts` | bilibili/me,dynamic,favorite,history,following + twitter/bookmarks,timeline,notifications + v2ex/me,notifications + xueqiu/feed,watchlist + xiaohongshu/feed,notifications | 14 |
| `management.test.ts` | list×5 格式, validate×3 级别, verify, --version, --help, unknown cmd | 12 |
| `output-formats.test.ts` | json, yaml, csv, md 格式验证 | 5 |
- Browser Bridge、DOM snapshot、interceptor、capability routing
- manifest 生成、命令发现、插件安装与注册表
- 输出格式渲染与 snapshot formatting
- pipeline 模板求值、执行器与变换步骤
- 各站点 adapter 的数据归一化、参数处理与容错逻辑
### 烟雾测试
### E2E 测试(5 个文件)
公开 API 可用性(hackernews, v2ex×2, v2ex/topic+ 全站点注册完整性检查。
| 文件 | 当前覆盖范围 |
|---|---|
| `tests/e2e/public-commands.test.ts` | `bloomberg``apple-podcasts``hackernews``v2ex``xiaoyuzhou``google suggest` 等公开命令 |
| `tests/e2e/browser-public.test.ts` | `bbc``bloomberg``bilibili``weibo``zhihu``reddit``twitter``xueqiu``reuters``youtube``smzdm``boss``ctrip``coupang``xiaohongshu``google``yahoo-finance``v2ex daily` |
| `tests/e2e/browser-auth.test.ts` | `bilibili``twitter``v2ex``xueqiu``linux-do``xiaohongshu` 的需登录命令 graceful failure |
| `tests/e2e/management.test.ts` | `list``validate``verify``--version``--help`、unknown command |
| `tests/e2e/output-formats.test.ts` | `json` / `yaml` / `csv` / `md` 输出格式校验 |
### 烟雾测试(1 个文件)
| 文件 | 当前覆盖范围 |
|---|---|
| `tests/smoke/api-health.test.ts` | `hackernews``v2ex` 公开 API 可用性,`validate` 全量 adapter 校验,以及命令注册表基础完整性 |
### 快速核对命令
需要刷新测试清单时,直接以仓库文件为准:
```bash
find src -name '*.test.ts' | sort
find tests/e2e -name '*.test.ts' | sort
find tests/smoke -name '*.test.ts' | sort
```
---
@@ -78,7 +93,7 @@ src/
```bash
npm ci # 安装依赖
npm run build # 编译(E2E 测试需要 dist/main.js
npm run build # 编译(E2E / smoke 测试需要 dist/main.js
```
### 运行命令
@@ -87,18 +102,19 @@ npm run build # 编译(E2E 测试需要 dist/main.js
# 全部单元测试
npx vitest run src/
# 全部 E2E 测试(会真实调用外部 API)
# 全部 E2E 测试(会真实调用外部 API / 浏览器
npx vitest run tests/e2e/
# 全部 smoke 测试
npx vitest run tests/smoke/
# 单个测试文件
npx vitest run src/clis/apple-podcasts/commands.test.ts
npx vitest run tests/e2e/management.test.ts
# 全部测试(单元 + E2E
# 全部测试
npx vitest run
# 烟雾测试
npx vitest run tests/smoke/
# watch 模式(开发时推荐)
npx vitest src/
```
@@ -106,9 +122,10 @@ npx vitest src/
### 浏览器命令本地测试须知
- opencli 通过 Browser Bridge 扩展连接已运行的 Chrome 浏览器
- `browser-public.test.ts` 使用 `tryBrowserCommand()`,站点反爬导致空数据时 warn + pass
- `browser-auth.test.ts` 验证 **graceful failure**(不 crash 不 hang 即通过)
- 如需测试完整登录态,保持 Chrome 登录态并安装 Browser Bridge 扩展,手动跑对应测试
- E2E 测试通过 `tests/e2e/helpers.ts` 里的 `runCli()` 调用已构建的 `dist/main.js`
- `browser-public.test.ts` 使用 `tryBrowserCommand()`,站点反爬或地域限制导致空数据时会 warn + pass
- `browser-auth.test.ts` 验证 **graceful failure**,重点是不 crash、不 hang、错误信息可控
- 如需测试完整登录态,保持 Chrome 登录态并安装 Browser Bridge 扩展,再手动运行对应测试
---
@@ -116,8 +133,8 @@ npx vitest src/
### 新增 YAML Adapter(如 `src/clis/producthunt/trending.yaml`
1. **无需额外操作**`validate` 测试会自动覆盖 YAML 结构验
2. 根据 adapter 类型,在对应文件一个 `it()` block
1. `opencli validate` 的 E2E / smoke 测试会覆盖 adapter 结构
2. 根据 adapter 类型,在对应测试文件一个 `it()` block
```typescript
// 如果 browser: false(公开 API)→ tests/e2e/public-commands.test.ts
@@ -148,15 +165,15 @@ it('producthunt me fails gracefully without login', async () => {
### 新增管理命令(如 `opencli export`
`tests/e2e/management.test.ts` 添加测试。
`tests/e2e/management.test.ts` 添加测试;如果新命令会影响输出格式,也同步补 `tests/e2e/output-formats.test.ts`
### 新增内部模块
`src/` 下对应位置创建 `*.test.ts`
对应源码旁创建 `*.test.ts`,优先和被测模块放在同一目录下,便于发现与维护
### 决策流程图
```
```text
新增功能 → 是内部模块? → 是 → src/ 下加 *.test.ts
↓ 否
是 CLI 命令? → browser: false? → tests/e2e/public-commands.test.ts
@@ -170,32 +187,33 @@ it('producthunt me fails gracefully without login', async () => {
## CI/CD 流水线
### ci.yml(主流水线)
### `ci.yml`
| Job | 触发条件 | 内容 |
|---|---|---|
| **build** | push/PR to main,dev | typecheck + build |
| **unit-test** | push/PR to main,dev | 单元测试,2 shard 并行 |
| **smoke-test** | 每周一 08:00 UTC / 手动 | xvfb + real Chrome,外部 API 健康检查 |
| `build` | push/PR `main`,`dev` | `tsc --noEmit` + `npm run build` |
| `unit-test` | push/PR `main`,`dev` | Node `20``22` 双版本运行 `src/` 单元测试,按 `2` shard 并行 |
| `smoke-test` | `schedule``workflow_dispatch` | 安装真实 Chrome`xvfb-run` 执行 `tests/smoke/` |
### e2e-headed.ymlE2E 测试)
### `e2e-headed.yml`
| Job | 触发条件 | 内容 |
|---|---|---|
| **e2e-headed** | push/PR to main,dev | xvfb + real Chrome,全部 E2E 测试 |
| `e2e-headed` | push/PR `main`,`dev`,或手动触发 | 安装真实 Chrome,`xvfb-run` 执行 `tests/e2e/` |
E2E 使用 `browser-actions/setup-chrome` 安装真实 Chrome配合 `xvfb-run` 提供虚拟显示器,以 headed 模式运行浏览器。
E2E 与 smoke 都使用 `./.github/actions/setup-chrome` 准备真实 Chrome并通过 `OPENCLI_BROWSER_EXECUTABLE_PATH` 注入浏览器路径
### Sharding
单元测试使用 vitest 内置 shard
单元测试使用 vitest 内置 shard,并在 Node `20` / `22` 两个版本上运行
```yaml
strategy:
matrix:
node-version: ['20', '22']
shard: [1, 2]
steps:
- run: npx vitest run src/ --shard=${{ matrix.shard }}/2
- run: npx vitest run src/ --reporter=verbose --shard=${{ matrix.shard }}/2
```
---
@@ -206,8 +224,8 @@ opencli 通过 Browser Bridge 扩展连接浏览器:
| 条件 | 模式 | 使用场景 |
|---|---|---|
| 扩展已安装 | Extension 模式 | 本地用户,连接已登录的 Chrome |
| 扩展未安装 | CLI 报错提示安装 | 需要安装 Browser Bridge 扩展 |
| 扩展已安装 / 已连接 | Extension 模式 | 本地用户,连接已登录的 Chrome |
| 扩展 token | CLI 自行拉起浏览器 | CI、无登录态或纯自动化场景 |
CI 中使用 `OPENCLI_BROWSER_EXECUTABLE_PATH` 指定真实 Chrome 路径:
@@ -220,14 +238,14 @@ env:
## 站点兼容性
GitHub Actions 美国 runner 上,部分站点地域限制登录要求返回空数据。E2E 测试对这些站点使用 warn + pass 策略,不影响 CI 绿灯
GitHub Actions 美国 runner 上,部分站点会因为地域限制登录要求或反爬而返回空数据。当前 E2E 对这些场景采用 warn + pass 策略,避免偶发站点限制把整条 CI 打红
| 站点 | CI 状态 | 限制原因 |
| 站点 | CI 表现 | 常见原因 |
|---|---|---|
| hackernews, bbc, v2ex | 返回数据 | 无限制 |
| yahoo-finance | 返回数据 | 无限制 |
| bilibili, zhihu, weibo, xiaohongshu | ⚠️ 空数据 | 地域限制(中国站点) |
| reddit, twitter, youtube | ⚠️ 空数据 | 登录cookie |
| smzdm, boss, ctrip, coupang, xueqiu | ⚠️ 空数据 | 地域限制 / 需登录 |
| `hackernews``bbc``v2ex``bloomberg` | 通常返回数据 | 公开接口或公开页面 |
| `yahoo-finance``google` | 通常返回数据 | 页面公开,但仍可能受限流影响 |
| `bilibili``zhihu``weibo``xiaohongshu``xueqiu` | 容易空数据 | 地域限制、反爬、登录要求 |
| `reddit``twitter``youtube` | 容易空数据 | 登录态、cookie、机器人检测 |
| `smzdm``boss``ctrip``coupang``linux-do` | 结果波动较大 | 地域限制、风控或页面结构变动 |
> 使用 self-hosted runner(国内服务器)可解决地域限制问题
> 如果需要更稳定的浏览器 E2E 结果,优先使用具备目标站点网络可达性的 self-hosted runner。
+9 -4
View File
@@ -29,8 +29,10 @@ export default defineConfig({
items: [
{ text: 'Getting Started', link: '/guide/getting-started' },
{ text: 'Installation', link: '/guide/installation' },
{ text: 'Comparison', link: '/comparison' },
{ text: 'Browser Bridge', link: '/guide/browser-bridge' },
{ text: 'Troubleshooting', link: '/guide/troubleshooting' },
{ text: 'Plugins', link: '/guide/plugins' },
],
},
],
@@ -63,10 +65,14 @@ export default defineConfig({
{ text: 'SMZDM', link: '/adapters/browser/smzdm' },
{ text: 'Jike', link: '/adapters/browser/jike' },
{ text: 'Jimeng', link: '/adapters/browser/jimeng' },
{ text: 'Yollomi', link: '/adapters/browser/yollomi' },
{ text: 'LINUX DO', link: '/adapters/browser/linux-do' },
{ text: 'Chaoxing', link: '/adapters/browser/chaoxing' },
{ text: 'Grok', link: '/adapters/browser/grok' },
{ text: 'WeRead', link: '/adapters/browser/weread' },
{ text: 'Douban', link: '/adapters/browser/douban' },
{ text: 'Sina Blog', link: '/adapters/browser/sinablog' },
{ text: 'Substack', link: '/adapters/browser/substack' },
],
},
{
@@ -74,7 +80,8 @@ export default defineConfig({
collapsed: false,
items: [
{ text: 'HackerNews', link: '/adapters/browser/hackernews' },
{ text: 'GitHub', link: '/adapters/browser/github' },
{ text: 'Dev.to', link: '/adapters/browser/devto' },
{ text: 'Dictionary', link: '/adapters/browser/dictionary' },
{ text: 'BBC', link: '/adapters/browser/bbc' },
{ text: 'Apple Podcasts', link: '/adapters/browser/apple-podcasts' },
{ text: 'Xiaoyuzhou', link: '/adapters/browser/xiaoyuzhou' },
@@ -98,9 +105,6 @@ export default defineConfig({
{ text: 'ChatWise', link: '/adapters/desktop/chatwise' },
{ text: 'Notion', link: '/adapters/desktop/notion' },
{ text: 'Discord', link: '/adapters/desktop/discord' },
{ text: 'Feishu', link: '/adapters/desktop/feishu' },
{ text: 'WeChat', link: '/adapters/desktop/wechat' },
{ text: 'NeteaseMusic', link: '/adapters/desktop/neteasemusic' },
],
},
],
@@ -150,6 +154,7 @@ export default defineConfig({
{ text: '快速开始', link: '/zh/guide/getting-started' },
{ text: '安装', link: '/zh/guide/installation' },
{ text: 'Browser Bridge', link: '/zh/guide/browser-bridge' },
{ text: '插件', link: '/zh/guide/plugins' },
],
},
],
+6 -5
View File
@@ -1,6 +1,6 @@
# Barchart
**Mode**: 🌐 Public · **Domain**: `barchart.com`
**Mode**: 🔐 Browser · **Domain**: `barchart.com`
## Commands
@@ -15,13 +15,13 @@
```bash
# Get stock quote
opencli barchart quote --symbol AAPL
opencli barchart quote AAPL
# View options chain
opencli barchart options --symbol TSLA
opencli barchart options TSLA
# Options greeks overview
opencli barchart greeks --symbol NVDA
opencli barchart greeks NVDA
# Unusual options flow
opencli barchart flow --limit 20 -f json
@@ -29,4 +29,5 @@ opencli barchart flow --limit 20 -f json
## Prerequisites
- No browser required — uses public API
- Chrome running and able to open `barchart.com`
- [Browser Bridge extension](/guide/browser-bridge) installed
+9
View File
@@ -25,6 +25,15 @@
# Quick start
opencli bilibili hot --limit 5
# Search videos
opencli bilibili search 黑神话 --limit 10
# Read one creator's videos
opencli bilibili user-videos 2 --limit 10
# Fetch subtitles
opencli bilibili subtitle BV1xx411c7mD --lang zh-CN
# JSON output
opencli bilibili hot -f json
+35
View File
@@ -0,0 +1,35 @@
# Dev.to
**Mode**: 🌐 Public · **Domain**: `dev.to`
Fetch the latest and greatest developer articles from the DEV community without needing an API key.
## Commands
| Command | Description |
|---------|-------------|
| `opencli devto top` | Top DEV.to articles of the day |
| `opencli devto tag` | Latest articles for a specific tag |
| `opencli devto user` | Recent articles from a specific user |
## Usage Examples
```bash
# Top articles today
opencli devto top --limit 5
# Articles by tag (positional argument)
opencli devto tag javascript
opencli devto tag python --limit 20
# Articles by a specific author
opencli devto user ben
opencli devto user thepracticaldev --limit 5
# JSON output
opencli devto top -f json
```
## Prerequisites
- No browser required — uses the public DEV.to API
+27
View File
@@ -0,0 +1,27 @@
# Dictionary
**Mode**: 🌐 Public · **Domain**: `api.dictionaryapi.dev`
Search the open dictionary to quickly fetch native definitions, part of speech contexts, and phonetic pronunciations directly in your IDE terminal.
## Commands
| Command | Description |
|---------|-------------|
| `opencli dictionary search` | Fetch the exact definition of a word |
| `opencli dictionary synonyms` | Find related synonyms for a word |
| `opencli dictionary examples` | Read real-world sentence usage examples |
## Usage Examples
```bash
# Look up a complex term
opencli dictionary search serendipity
# Discover phonetics
opencli dictionary search ephemeral
```
## Prerequisites
- No browser required — utilizes the fast, open JSON definitions API.
+48
View File
@@ -0,0 +1,48 @@
# 豆瓣 (Douban)
**Mode**: 🔐 Browser (Cookie) · **Domain**: `douban.com`
## Commands
| Command | Description |
|---------|-------------|
| `opencli douban search` | 搜索豆瓣电影、图书或音乐 |
| `opencli douban top250` | 豆瓣电影 Top 250 |
| `opencli douban subject` | 条目详情 |
| `opencli douban marks` | 我的标记 |
| `opencli douban reviews` | 我的短评 |
| `opencli douban movie-hot` | 豆瓣电影热门榜单 |
| `opencli douban book-hot` | 豆瓣图书热门榜单 |
## Usage Examples
```bash
# 搜索电影
opencli douban search "流浪地球"
# 搜索图书
opencli douban search --type book "三体"
# 搜索音乐
opencli douban search --type music "周杰伦"
# 电影 Top 250
opencli douban top250 --limit 10
# 条目详情
opencli douban subject 1292052
# 电影热门
opencli douban movie-hot --limit 10
# 图书热门
opencli douban book-hot --limit 10
# JSON output
opencli douban top250 -f json
```
## Prerequisites
- Chrome logged into `douban.com`
- Browser Bridge extension installed
+35
View File
@@ -0,0 +1,35 @@
# doubao
Browser adapter for [Doubao Chat](https://www.doubao.com/chat).
## Commands
| Command | Description |
|---------|-------------|
| `opencli doubao status` | Check whether the page is reachable and whether Doubao appears logged in |
| `opencli doubao new` | Start a new Doubao conversation |
| `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 |
## Prerequisites
- Chrome is running
- You are already logged into [doubao.com](https://www.doubao.com/)
- Playwright MCP Bridge / browser bridge is configured for OpenCLI
## Examples
```bash
opencli doubao status
opencli doubao new
opencli doubao send "帮我总结这段文档"
opencli doubao read
opencli doubao ask "请写一个 Python 快速排序示例" --timeout 90
```
## Notes
- The adapter targets the web chat page at `https://www.doubao.com/chat`
- `new` first tries the visible "New Chat / 新对话" button, then falls back to the new-thread route
- `ask` uses DOM polling, so very long generations may need a larger `--timeout`
+36
View File
@@ -0,0 +1,36 @@
# Facebook
**Mode**: 🔐 Browser · **Domain**: `facebook.com`
## Commands
| Command | Description |
|---------|-------------|
| `opencli facebook profile` | Get user/page profile info |
| `opencli facebook notifications` | Get recent notifications |
| `opencli facebook feed` | Get news feed posts |
| `opencli facebook search` | Search people, pages, posts |
## Usage Examples
```bash
# View a profile
opencli facebook profile zuck
# Get notifications
opencli facebook notifications --limit 10
# News feed
opencli facebook feed --limit 5
# Search
opencli facebook search "OpenAI" --limit 5
# JSON output
opencli facebook profile zuck -f json
```
## Prerequisites
- Chrome running and **logged into** facebook.com
- [Browser Bridge extension](/guide/browser-bridge) installed
-26
View File
@@ -1,26 +0,0 @@
# GitHub
**Mode**: 🌐 Public · **Domain**: `github.com`
## Commands
| Command | Description |
|---------|-------------|
| `opencli github search` | |
## Usage Examples
```bash
# Quick start
opencli github search --limit 5
# JSON output
opencli github search -f json
# Verbose mode
opencli github search -v
```
## Prerequisites
- No browser required — uses public API
+62
View File
@@ -0,0 +1,62 @@
# Google
**Mode**: 🌐 / 🔐 Mixed · **Domains**: `google.com`, `suggestqueries.google.com`, `news.google.com`, `trends.google.com`
## Commands
| Command | Description |
|---------|-------------|
| `opencli google search <keyword>` | Search Google and extract results from the page |
| `opencli google suggest <keyword>` | Get Google search suggestions |
| `opencli google news [keyword]` | Get Google News headlines (top stories or search) |
| `opencli google trends` | Get Google Trends daily trending searches |
## What works today
- Public API commands work without a browser:
- `suggest` — JSON API, no auth needed
- `news` — RSS feed, supports top stories and keyword search
- `trends` — RSS feed, supports different regions
- `google search` uses browser mode to extract results from google.com.
## Current limitations
- `google search` may trigger CAPTCHA in Standalone browser mode. Extension mode (with an established Chrome session) is more reliable.
- Google frequently changes its DOM structure. If `search` stops returning results, selectors may need updating.
- Snippet extraction may return empty for some results depending on Google's layout.
## Usage Examples
```bash
# Search Google
opencli google search "typescript tutorial" --limit 10
# Get search suggestions
opencli google suggest python
# Get top news headlines
opencli google news --limit 5
# Search news for a topic
opencli google news "artificial intelligence" --limit 10 --lang en --region US
# Get trending searches in Japan
opencli google trends --region JP --limit 10
# Output as JSON
opencli google search "machine learning" -f json
```
## Prerequisites
- `suggest`, `news`, `trends` do not require Chrome.
- `search` requires:
- Chrome running (or Standalone mode will auto-launch)
- For best results, use the [Browser Bridge extension](/guide/browser-bridge) with an established Google session
## Notes
- `suggest` defaults to `--lang zh-CN`; other commands default to `--lang en`.
- `news` supports `--lang` and `--region` parameters for localized results.
- `trends` traffic values are raw strings (e.g. "500K+", "1,000,000+"), not numeric.
- `search` output includes three result types: `result` (standard), `snippet` (featured answer box), and `paa` (People Also Ask).
+26 -8
View File
@@ -1,24 +1,28 @@
# Grok
**Mode**: 🔐 Browser · **Domain**: `grok.com`
**Mode**: Default Grok adapter + optional explicit consumer web path · **Domain**: `grok.com`
## Commands
| Command | Description |
|---------|-------------|
| `opencli grok ask` | Send a message to Grok and get response |
| `opencli grok ask` | Keep the default Grok ask behavior |
| `opencli grok ask --web` | Use the explicit grok.com consumer web UI flow |
## Usage Examples
```bash
# Ask Grok a question
# Default / compatibility path
opencli grok ask --prompt "Explain quantum computing in simple terms"
# Start a new chat session
opencli grok ask --prompt "Hello" --new
# Explicit consumer web path
opencli grok ask --prompt "Explain quantum computing in simple terms" --web
# Best-effort fresh chat on the consumer web path
opencli grok ask --prompt "Hello" --web --new
# Set custom timeout (default: 120s)
opencli grok ask --prompt "Write a long essay" --timeout 180
opencli grok ask --prompt "Write a long essay" --web --timeout 180
```
### Options
@@ -27,9 +31,23 @@ opencli grok ask --prompt "Write a long essay" --timeout 180
|--------|-------------|
| `--prompt` | The message to send (required) |
| `--timeout` | Wait timeout in seconds (default: 120) |
| `--new` | Start a new chat session (default: false) |
| `--new` | Start a new chat before sending (default: false) |
| `--web` | Opt into the explicit grok.com consumer web flow (default: false) |
## Behavior
- `opencli grok ask` keeps the upstream/default behavior intact.
- `opencli grok ask --web` switches to the newer hardened consumer-web implementation.
- The `--web` path adds stricter composer detection, clearer blocked/session-gated hints, and waits for a stabilized assistant bubble before returning.
## Prerequisites
- Chrome running and **logged into** grok.com
- The Grok adapter still depends on browser-backed access to `grok.com`
- For `--web`, Chrome should already be running with an authenticated Grok consumer session
- [Browser Bridge extension](/guide/browser-bridge) installed
## Caveats
- `--web` drives the Grok consumer web UI in the browser, not an API.
- It depends on an already-authenticated session and can fail if Grok shows login, challenge, rate-limit, or other session-gating UI.
- It may break when the Grok composer DOM, submit button behavior, or message bubble structure changes.
+20 -4
View File
@@ -6,19 +6,35 @@
| Command | Description |
|---------|-------------|
| `opencli hackernews top` | |
| `opencli hackernews top` | Hacker News top stories |
| `opencli hackernews new` | Hacker News newest stories |
| `opencli hackernews best` | Hacker News best stories |
| `opencli hackernews ask` | Hacker News Ask HN posts |
| `opencli hackernews show` | Hacker News Show HN posts |
| `opencli hackernews jobs` | Hacker News job postings |
| `opencli hackernews search <query>` | Search Hacker News stories |
| `opencli hackernews user <username>` | Hacker News user profile |
## Usage Examples
```bash
# Quick start
# Top stories
opencli hackernews top --limit 5
# Newest stories
opencli hackernews new --limit 10
# Search stories
opencli hackernews search "machine learning" --limit 5
# User profile
opencli hackernews user pg
# JSON output
opencli hackernews top -f json
# Verbose mode
opencli hackernews top -v
# Sort search by date
opencli hackernews search "rust" --sort date
```
## Prerequisites
+46
View File
@@ -0,0 +1,46 @@
# Instagram
**Mode**: 🔐 Browser · **Domain**: `instagram.com`
## Commands
| Command | Description |
|---------|-------------|
| `opencli instagram profile` | Get user profile info |
| `opencli instagram search` | Search users |
| `opencli instagram user` | Get recent posts from a user |
| `opencli instagram explore` | Discover trending posts |
| `opencli instagram followers` | List user's followers |
| `opencli instagram following` | List user's following |
| `opencli instagram saved` | Get your saved posts |
## Usage Examples
```bash
# View a user's profile
opencli instagram profile nasa
# Search users
opencli instagram search nasa --limit 5
# View a user's recent posts
opencli instagram user nasa --limit 10
# Discover trending posts
opencli instagram explore --limit 20
# List followers/following
opencli instagram followers nasa --limit 20
opencli instagram following nasa --limit 20
# Get your saved posts
opencli instagram saved --limit 10
# JSON output
opencli instagram profile nasa -f json
```
## Prerequisites
- Chrome running and **logged into** instagram.com
- [Browser Bridge extension](/guide/browser-bridge) installed
+27
View File
@@ -0,0 +1,27 @@
# JD.com
**Mode**: 🔐 Browser · **Domain**: `item.jd.com`
## Commands
| Command | Description |
|---------|-------------|
| `opencli jd item <sku>` | Fetch product details (price, images, specs) |
## Usage Examples
```bash
# Get product details by SKU
opencli jd item 100291143898
# Limit detail images
opencli jd item 100291143898 --images 5
# JSON output
opencli jd item 100291143898 -f json
```
## Prerequisites
- Chrome running and **logged into** jd.com
- [Browser Bridge extension](/guide/browser-bridge) installed
+6
View File
@@ -7,6 +7,7 @@
| Command | Description |
|---------|-------------|
| `opencli linkedin search` | |
| `opencli linkedin timeline` | Read posts from your LinkedIn home feed |
## Usage Examples
@@ -14,9 +15,14 @@
# Quick start
opencli linkedin search --limit 5
# Read your home timeline
opencli linkedin timeline --limit 5
# JSON output
opencli linkedin search -f json
opencli linkedin timeline -f json
# Verbose mode
opencli linkedin search -v
```
+32
View File
@@ -0,0 +1,32 @@
# Lobsters
**Mode**: 🌐 Public · **Domain**: `lobste.rs`
## Commands
| Command | Description |
|---------|-------------|
| `opencli lobsters hot` | Hottest stories |
| `opencli lobsters newest` | Latest stories |
| `opencli lobsters active` | Most active discussions |
| `opencli lobsters tag` | Stories by tag |
## Usage Examples
```bash
# Quick start
opencli lobsters hot --limit 10
# Filter by tag
opencli lobsters tag --tag rust --limit 5
# JSON output
opencli lobsters hot -f json
# Verbose mode
opencli lobsters hot -v
```
## Prerequisites
None — all commands use the public JSON API, no browser or login required.
+32
View File
@@ -0,0 +1,32 @@
# Medium
**Mode**: 🌗 Mixed · **Domain**: `medium.com`
## Commands
| Command | Description |
|---------|-------------|
| `opencli medium feed` | Get hot Medium posts, optionally scoped to a topic |
| `opencli medium search` | Search Medium posts by keyword |
| `opencli medium user` | Get recent articles by a user |
## Usage Examples
```bash
# Get the general Medium feed
opencli medium feed --limit 10
# Search posts by keyword
opencli medium search ai
# Get articles by a user
opencli medium user @username
# Topic feed as JSON
opencli medium feed --topic programming -f json
```
## Prerequisites
- `opencli medium search` can run without a browser
- `opencli medium feed` and `opencli medium user` require Browser Bridge access to `medium.com`
+9
View File
@@ -28,6 +28,15 @@
# Quick start
opencli reddit hot --limit 5
# Read one subreddit
opencli reddit subreddit python --limit 10
# Read a post thread
opencli reddit read 1abc123 --depth 2
# Comment on a post
opencli reddit comment 1abc123 "Great post"
# JSON output
opencli reddit hot -f json
+36
View File
@@ -0,0 +1,36 @@
# 新浪博客 (Sina Blog)
**Mode**: 🌐 Public (search) / 🔐 Browser (hot, article, user) · **Domain**: `blog.sina.com.cn`
## Commands
| Command | Description |
|---------|-------------|
| `opencli sinablog hot` | 获取新浪博客热门文章/推荐 |
| `opencli sinablog search` | 搜索新浪博客文章(通过新浪搜索,无需浏览器) |
| `opencli sinablog article` | 获取新浪博客单篇文章详情 |
| `opencli sinablog user` | 获取新浪博客用户的文章列表 |
## Usage Examples
```bash
# 热门文章
opencli sinablog hot --limit 10
# 搜索文章(公开 API,无需浏览器)
opencli sinablog search "人工智能"
# 文章详情
opencli sinablog article "https://blog.sina.com.cn/s/blog_xxx.html"
# 用户文章列表
opencli sinablog user 1234567890 --limit 10
# JSON output
opencli sinablog hot -f json
```
## Prerequisites
- `search` command: No login required (public API)
- `hot`, `article`, `user` commands: Chrome with `blog.sina.com.cn` accessible, Browser Bridge extension installed
+38
View File
@@ -0,0 +1,38 @@
# Substack
**Mode**: 🌐 Public (search) / 🔐 Browser (feed, publication) · **Domain**: `substack.com`
## Commands
| Command | Description |
|---------|-------------|
| `opencli substack feed` | Substack 热门文章 Feed |
| `opencli substack search` | 搜索 Substack 文章和 Newsletter(无需浏览器) |
| `opencli substack publication` | 获取特定 Substack Newsletter 的最新文章 |
## Usage Examples
```bash
# 热门 Feed
opencli substack feed --limit 10
# 按分类浏览
opencli substack feed --category tech --limit 10
# 搜索文章(公开 API,无需浏览器)
opencli substack search "AI"
# 搜索 Newsletter
opencli substack search "technology" --type publications
# 查看特定 Newsletter 的最新文章
opencli substack publication "https://example.substack.com" --limit 10
# JSON output
opencli substack search "AI" -f json
```
## Prerequisites
- `search` command: No login required (public API)
- `feed`, `publication` commands: Chrome with `substack.com` accessible, Browser Bridge extension installed
+68
View File
@@ -0,0 +1,68 @@
# TikTok
**Mode**: 🔐 Browser · **Domain**: `tiktok.com`
## Commands
| Command | Description |
|---------|-------------|
| `opencli tiktok profile` | Get user profile info |
| `opencli tiktok search` | Search videos |
| `opencli tiktok explore` | Trending videos from explore page |
| `opencli tiktok user` | Get recent videos from a user |
| `opencli tiktok following` | List accounts you follow |
| `opencli tiktok friends` | Friend suggestions |
| `opencli tiktok live` | Browse live streams |
| `opencli tiktok notifications` | Get notifications |
| `opencli tiktok like` | Like a video |
| `opencli tiktok unlike` | Unlike a video |
| `opencli tiktok save` | Add to Favorites |
| `opencli tiktok unsave` | Remove from Favorites |
| `opencli tiktok follow` | Follow a user |
| `opencli tiktok unfollow` | Unfollow a user |
| `opencli tiktok comment` | Comment on a video |
## Usage Examples
```bash
# View a user's profile
opencli tiktok profile --username tiktok
# Search videos
opencli tiktok search "cooking" --limit 10
# Trending explore videos
opencli tiktok explore --limit 20
# Browse live streams
opencli tiktok live --limit 10
# List who you follow
opencli tiktok following
# Friend suggestions
opencli tiktok friends --limit 10
# Like/unlike a video
opencli tiktok like --url "https://www.tiktok.com/@user/video/123"
opencli tiktok unlike --url "https://www.tiktok.com/@user/video/123"
# Save/unsave (Favorites)
opencli tiktok save --url "https://www.tiktok.com/@user/video/123"
opencli tiktok unsave --url "https://www.tiktok.com/@user/video/123"
# Follow/unfollow
opencli tiktok follow --username nasa
opencli tiktok unfollow --username nasa
# Comment on a video
opencli tiktok comment --url "https://www.tiktok.com/@user/video/123" --text "Great!"
# JSON output
opencli tiktok profile --username tiktok -f json
```
## Prerequisites
- Chrome running and **logged into** tiktok.com
- [Browser Bridge extension](/guide/browser-bridge) installed
+31 -10
View File
@@ -6,27 +6,48 @@
| Command | Description |
|---------|-------------|
| `opencli v2ex hot` | |
| `opencli v2ex latest` | |
| `opencli v2ex topic` | |
| `opencli v2ex daily` | |
| `opencli v2ex me` | |
| `opencli v2ex notifications` | |
| `opencli v2ex hot` | Hot topics |
| `opencli v2ex latest` | Latest topics |
| `opencli v2ex topic <id>` | Topic detail |
| `opencli v2ex node <name>` | Topics by node |
| `opencli v2ex user <username>` | Topics by user |
| `opencli v2ex member <username>` | User profile |
| `opencli v2ex replies <id>` | Topic replies |
| `opencli v2ex nodes` | All nodes (sorted by topic count) |
| `opencli v2ex daily` | Daily hot |
| `opencli v2ex me` | My profile (auth required) |
| `opencli v2ex notifications` | My notifications (auth required) |
## Usage Examples
```bash
# Quick start
# Hot topics
opencli v2ex hot --limit 5
# Browse topics in a node
opencli v2ex node python
# View topic replies
opencli v2ex replies 1000
# User's topics
opencli v2ex user Livid
# User profile
opencli v2ex member Livid
# List all nodes
opencli v2ex nodes --limit 10
# JSON output
opencli v2ex hot -f json
# Verbose mode
opencli v2ex hot -v
```
## Prerequisites
Most commands (`hot`, `latest`, `topic`, `node`, `user`, `member`, `replies`, `nodes`) use the public V2EX API and **require no browser or login**.
For `daily`, `me`, and `notifications`:
- Chrome running and **logged into** v2ex.com
- [Browser Bridge extension](/guide/browser-bridge) installed
+30
View File
@@ -0,0 +1,30 @@
# Web
**Mode**: 🔐 Browser · **Domain**: any URL
## Commands
| Command | Description |
|---------|-------------|
| `opencli web read <url>` | Fetch any web page and export as Markdown |
## Usage Examples
```bash
# Read a web page and save as Markdown
opencli web read https://example.com/article
# Custom output directory
opencli web read https://example.com/article --output ./my-articles
# Skip image download
opencli web read https://example.com/article --download-images false
# JSON output
opencli web read https://example.com/article -f json
```
## Prerequisites
- Chrome running
- [Browser Bridge extension](/guide/browser-bridge) installed
+4
View File
@@ -7,6 +7,7 @@
| Command | Description |
|---------|-------------|
| `opencli weibo hot` | |
| `opencli weibo search` | Search Weibo posts by keyword |
## Usage Examples
@@ -17,6 +18,9 @@ opencli weibo hot --limit 5
# JSON output
opencli weibo hot -f json
# Search
opencli weibo search "OpenAI" --limit 5
# Verbose mode
opencli weibo hot -v
```
+33
View File
@@ -0,0 +1,33 @@
# WeChat (微信公众号)
**Mode**: 🔐 Browser · **Domain**: `mp.weixin.qq.com`
## Commands
| Command | Description |
|---------|-------------|
| `opencli weixin download` | 下载微信公众号文章为 Markdown 格式 |
## Usage Examples
```bash
# Export article to Markdown
opencli weixin download --url "https://mp.weixin.qq.com/s/xxx" --output ./weixin
# Export with locally downloaded images
opencli weixin download --url "https://mp.weixin.qq.com/s/xxx" --download-images
# Export without images
opencli weixin download --url "https://mp.weixin.qq.com/s/xxx" --no-download-images
```
## Output
Downloads to `<output>/<article-title>/`:
- `<article-title>.md` — Markdown with frontmatter (title, author, publish time, source URL)
- `images/` — Downloaded images (if `--download-images` is enabled, default: true)
## Prerequisites
- Chrome running and **logged into** mp.weixin.qq.com (for articles behind login wall)
- [Browser Bridge extension](/guide/browser-bridge) installed
+2 -2
View File
@@ -16,9 +16,9 @@
opencli wikipedia search "quantum computing" --limit 10
# Get article summary
opencli wikipedia summary --title "Artificial intelligence"
opencli wikipedia summary "Artificial intelligence"
# Search in other languages
# Use with other languages
opencli wikipedia search "人工智能" --lang zh
# JSON output
+8 -6
View File
@@ -6,7 +6,7 @@
| Command | Description |
|---------|-------------|
| `opencli xiaohongshu search` | |
| `opencli xiaohongshu search` | Search notes by keyword (returns title, author, likes, URL) |
| `opencli xiaohongshu notifications` | |
| `opencli xiaohongshu feed` | |
| `opencli xiaohongshu user` | |
@@ -20,14 +20,16 @@
## Usage Examples
```bash
# Quick start
opencli xiaohongshu search --limit 5
# Search for notes
opencli xiaohongshu search 美食 --limit 10
# JSON output
opencli xiaohongshu search -f json
opencli xiaohongshu search 旅行 -f json
# Verbose mode
opencli xiaohongshu search -v
# Other commands
opencli xiaohongshu feed
opencli xiaohongshu notifications
opencli xiaohongshu download <url>
```
## Prerequisites
+10
View File
@@ -7,6 +7,7 @@
| Command | Description |
|---------|-------------|
| `opencli xueqiu feed` | |
| `opencli xueqiu earnings-date` | |
| `opencli xueqiu hot-stock` | |
| `opencli xueqiu hot` | |
| `opencli xueqiu search` | |
@@ -19,6 +20,15 @@
# Quick start
opencli xueqiu feed --limit 5
# Search stocks
opencli xueqiu search 茅台
# View one stock
opencli xueqiu stock SH600519
# Upcoming earnings dates
opencli xueqiu earnings-date SH600519 --next
# JSON output
opencli xueqiu feed -f json
+6 -5
View File
@@ -1,6 +1,6 @@
# Yahoo Finance
**Mode**: 🌐 Public · **Domain**: `finance.yahoo.com`
**Mode**: 🔐 Browser · **Domain**: `finance.yahoo.com`
## Commands
@@ -12,15 +12,16 @@
```bash
# Quick start
opencli yahoo-finance quote --limit 5
opencli yahoo-finance quote AAPL
# JSON output
opencli yahoo-finance quote -f json
opencli yahoo-finance quote TSLA -f json
# Verbose mode
opencli yahoo-finance quote -v
opencli yahoo-finance quote NVDA -v
```
## Prerequisites
- No browser required — uses public API
- Chrome running and able to open `finance.yahoo.com`
- [Browser Bridge extension](/guide/browser-bridge) installed
+69
View File
@@ -0,0 +1,69 @@
# Yollomi
**Mode**: 🔐 Browser · **Domain**: `yollomi.com`
AI image/video generation and editing on [yollomi.com](https://yollomi.com). Uses the same `/api/ai/*` routes as the web app; authentication is your **logged-in Chrome session** (NextAuth cookies).
## Commands
| Command | Description |
|---------|-------------|
| `opencli yollomi generate` | Text-to-image / image-to-image |
| `opencli yollomi video` | Text-to-video / image-to-video |
| `opencli yollomi edit` | Qwen image edit (prompt + image) |
| `opencli yollomi upload` | Upload a local file → public URL for other commands |
| `opencli yollomi models` | List image / video / tool models and credit costs |
| `opencli yollomi remove-bg` | Remove background (free) |
| `opencli yollomi upscale` | Image upscaling |
| `opencli yollomi face-swap` | Face swap between two images |
| `opencli yollomi restore` | Photo restoration |
| `opencli yollomi try-on` | Virtual try-on |
| `opencli yollomi background` | AI background for product/object images |
| `opencli yollomi object-remover` | Remove objects (image + mask URLs) |
## Usage Examples
```bash
# List models
opencli yollomi models --type image
# Text-to-image (default model: z-image-turbo)
opencli yollomi generate "a red apple on a wooden table"
# Choose model and aspect ratio
opencli yollomi generate "sunset" --model flux-schnell --ratio 16:9
# Image-to-image: upload first, then pass URL
opencli yollomi upload ./photo.png
opencli yollomi generate "oil painting style" --model flux-2-pro --image "https://..."
# Video
opencli yollomi video "waves on a beach" --model kling-2-1
# Tools
opencli yollomi remove-bg https://example.com/image.png
opencli yollomi upscale https://example.com/image.png --scale 4
opencli yollomi edit https://example.com/in.png "make it vintage"
```
### Common options
| Option | Applies to | Description |
|--------|------------|-------------|
| `--model` | `generate`, `video` | Model id (see `yollomi models`) |
| `--ratio` | `generate`, `video` | Aspect ratio, e.g. `1:1`, `16:9` |
| `--image` | `generate`, `video` | Image URL for img2img / i2v |
| `--output` | Most | Output directory (default `./yollomi-output`) |
| `--no-download` | Several | Print URLs only, skip saving files |
## Prerequisites
- Chrome running and **logged into** [yollomi.com](https://yollomi.com) (Google OAuth)
- [Browser Bridge extension](/guide/browser-bridge) installed; daemon connects on first command
The CLI ensures the automation tab is on `yollomi.com` before calling APIs (same-origin `fetch` with session cookies).
## Notes
- **Credits**: Each model consumes account credits; insufficient credits returns HTTP 402.
- **Upload**: Local paths for tools are not accepted directly — use `yollomi upload` to get a URL, or pass an existing HTTPS image URL.
+3
View File
@@ -36,6 +36,9 @@ Scrape the entire current conversation history block as pure text.
### `opencli antigravity new`
Click the "New Conversation" button to instantly clear the UI state and start fresh.
### `opencli antigravity dump`
Dump the current DOM and snapshot artifacts to `/tmp` for reverse-engineering and selector debugging.
### `opencli antigravity extract-code`
Extract any multi-line code blocks from the current conversation view. Ideal for automated script extraction (e.g. `opencli antigravity extract-code > script.sh`).
+2 -1
View File
@@ -15,6 +15,7 @@ The current built-in commands use native AppleScript automation — no extra lau
- `opencli chatgpt new`: Activate ChatGPT and press `Cmd+N` to start a new conversation.
- `opencli chatgpt send "message"`: Copy your message to clipboard, activate ChatGPT, paste, and submit.
- `opencli chatgpt read`: Read the last visible message from the focused ChatGPT window via the Accessibility tree.
- `opencli chatgpt ask "message"`: Send a prompt and wait for the visible reply in one shot.
## Approach 2: CDP (Advanced, Electron Debug Mode)
@@ -29,7 +30,7 @@ ChatGPT Desktop is also an Electron app and can be launched with a remote debugg
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9224"
```
> The CDP approach enables future advanced commands like DOM inspection, model switching, and code extraction.
> The CDP approach is primarily for advanced automation and future desktop-only commands. The built-in command set above still works in the default AppleScript path unless you explicitly route through `OPENCLI_CDP_ENDPOINT`.
## How It Works
+5 -1
View File
@@ -14,7 +14,7 @@ Control the **OpenAI Codex Desktop App** headless or headfully via Chrome DevToo
## Setup
```bash
export OPENCLI_CODEX_CDP_ENDPOINT="http://127.0.0.1:9222"
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9222"
```
## Commands
@@ -22,11 +22,15 @@ export OPENCLI_CODEX_CDP_ENDPOINT="http://127.0.0.1:9222"
### Diagnostics
- `opencli codex status`: Checks connection and reads the current active window URL/title.
- `opencli codex dump`: Dumps the full UI DOM and Accessibility tree into `/tmp`.
- `opencli codex screenshot`: Captures DOM + snapshot artifacts of the current window.
### Agent Manipulation
- `opencli codex new`: Simulates `Cmd+N` to start a completely fresh and isolated Git Worktree thread context.
- `opencli codex send "message"`: Robustly finds the active Thread Composer and injects your text.
- *Pro-tip*: You can trigger internal shortcuts, e.g., `opencli codex send "/review"`.
- `opencli codex ask "message"`: Send + wait + read in one shot.
- `opencli codex read`: Extracts the entire current thread history and AI reasoning logs.
- `opencli codex extract-diff`: Automatically scrapes any visual Patch chunks and Code Diffs.
- `opencli codex model`: Get the currently active AI model.
- `opencli codex history`: List recent conversation threads from the sidebar.
- `opencli codex export`: Export the current conversation as Markdown.
+4
View File
@@ -21,13 +21,17 @@ export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9226"
### Diagnostics
- `opencli cursor status`: Check CDP connection status.
- `opencli cursor dump`: Dump the full DOM and Accessibility snapshot to `/tmp/cursor-dom.html` and `/tmp/cursor-snapshot.json`.
- `opencli cursor screenshot`: Capture DOM + snapshot artifacts of the current window.
### Chat Manipulation
- `opencli cursor new`: Press `Cmd+N` to start a new file/tab.
- `opencli cursor send "message"`: Inject text into the active Composer/Chat input and submit.
- `opencli cursor ask "message"`: Send + wait + read in one shot.
- `opencli cursor read`: Extract the full conversation history from the active chat panel.
### AI Features
- `opencli cursor composer "prompt"`: Open the Composer panel (`Cmd+I`) and send a prompt for inline AI editing.
- `opencli cursor model`: Get the currently active AI model (e.g., `claude-4.5-sonnet`).
- `opencli cursor extract-code`: Extract all code blocks from the current conversation.
- `opencli cursor history`: List recent chat/composer sessions from the sidebar.
- `opencli cursor export`: Export the current conversation as Markdown.
+7 -7
View File
@@ -19,10 +19,10 @@ export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9232"
| Command | Description |
|---------|-------------|
| `opencli discord status` | Check CDP connection |
| `opencli discord send "message"` | Send a message in the active channel |
| `opencli discord read` | Read recent messages |
| `opencli discord channels` | List channels in the current server |
| `opencli discord servers` | List all joined servers |
| `opencli discord search "query"` | Search messages (Cmd+F) |
| `opencli discord members` | List online members |
| `opencli discord-app status` | Check CDP connection |
| `opencli discord-app send "message"` | Send a message in the active channel |
| `opencli discord-app read` | Read recent messages |
| `opencli discord-app channels` | List channels in the current server |
| `opencli discord-app servers` | List all joined servers |
| `opencli discord-app search "query"` | Search messages (Cmd+F) |
| `opencli discord-app members` | List online members |
+35
View File
@@ -0,0 +1,35 @@
# Doubao App (豆包桌面版)
Control the **Doubao AI Desktop App** via Chrome DevTools Protocol (CDP).
## Prerequisites
1. Launch Doubao Desktop with remote debugging enabled:
```bash
/Applications/Doubao.app/Contents/MacOS/Doubao --remote-debugging-port=9225
```
2. Set the CDP endpoint:
```bash
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9225"
```
## Commands
| Command | Description |
|---------|-------------|
| `opencli doubao-app status` | Check CDP connection status |
| `opencli doubao-app new` | Start a new conversation |
| `opencli doubao-app send "message"` | Send a message to the current chat |
| `opencli doubao-app read` | Read the latest assistant reply |
| `opencli doubao-app ask "message"` | Send a prompt and wait for the reply |
| `opencli doubao-app screenshot` | Capture a screenshot of the app window |
| `opencli doubao-app dump` | Export DOM and snapshot debug info |
## How It Works
Connects to the Doubao Electron app via CDP, injecting JavaScript into the renderer process to control the chat UI — sending messages, reading replies, and capturing screenshots.
## Limitations
- Requires Doubao Desktop to be launched with `--remote-debugging-port`
- macOS / Linux / Windows (Electron-based, platform independent)
-20
View File
@@ -1,20 +0,0 @@
# Feishu (飞书/Lark)
Control **Feishu/Lark Desktop** from the terminal via AppleScript.
> **Note:** Feishu uses a custom `Lark Framework` (Chromium-based but NOT Electron). CDP is not available, so this adapter uses AppleScript + clipboard.
## Prerequisites
1. Feishu/Lark must be running and logged in
2. Terminal must have **Accessibility permission**
## Commands
| Command | Description |
|---------|-------------|
| `opencli feishu status` | Check if Feishu/Lark is running |
| `opencli feishu send "msg"` | Send message in active chat (paste + Enter) |
| `opencli feishu read` | Read current chat (Cmd+A → Cmd+C) |
| `opencli feishu search "query"` | Global search (Cmd+K) |
| `opencli feishu new` | New message/document (Cmd+N) |
-31
View File
@@ -1,31 +0,0 @@
# NeteaseMusic (网易云音乐)
Control **NeteaseMusic** (网易云音乐) from the terminal via Chrome DevTools Protocol (CDP). The app uses Chromium Embedded Framework (CEF).
## Prerequisites
Launch with remote debugging port:
```bash
/Applications/NeteaseMusic.app/Contents/MacOS/NeteaseMusic --remote-debugging-port=9234
```
## Setup
```bash
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9234"
```
## Commands
| Command | Description |
|---------|-------------|
| `opencli neteasemusic status` | Check CDP connection |
| `opencli neteasemusic playing` | Current song info (title, artist, album) |
| `opencli neteasemusic play` | Play / Pause toggle |
| `opencli neteasemusic next` | Skip to next song |
| `opencli neteasemusic prev` | Go to previous song |
| `opencli neteasemusic search "query"` | Search songs, artists |
| `opencli neteasemusic playlist` | Show current playback queue |
| `opencli neteasemusic like` | Like / unlike current song |
| `opencli neteasemusic lyrics` | Get lyrics of current song |
| `opencli neteasemusic volume [0-100]` | Get or set volume |
-28
View File
@@ -1,28 +0,0 @@
# WeChat (微信)
Control **WeChat Mac Desktop** from the terminal via AppleScript + Accessibility API.
> **Note:** WeChat is a native macOS app (not Electron), so CDP is not available. This adapter uses AppleScript keyboard simulation and clipboard operations.
## Prerequisites
1. WeChat must be running and logged in
2. Terminal must have **Accessibility permission** (System Settings → Privacy & Security → Accessibility)
## Commands
| Command | Description |
|---------|-------------|
| `opencli wechat status` | Check if WeChat is running |
| `opencli wechat send "msg"` | Send message in the active chat (clipboard paste + Enter) |
| `opencli wechat read` | Read current chat content (Cmd+A → Cmd+C) |
| `opencli wechat search "keyword"` | Open search and type a query (Cmd+F) |
| `opencli wechat chats` | Switch to Chats tab (Cmd+1) |
| `opencli wechat contacts` | Switch to Contacts tab (Cmd+2) |
## Limitations
- **No CDP support** — WeChat is native Cocoa, not Electron
- `send` requires the correct conversation to be already open
- `read` captures whatever is visible via select-all + copy
- `search` types the query but cannot programmatically click results
+22 -13
View File
@@ -6,36 +6,46 @@ 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` | 🔐 Browser |
| **[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` `me` `user` `download` | 🔐 Browser |
| **[xueqiu](/adapters/browser/xueqiu)** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 🔐 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` | 🔐 Browser |
| **[youtube](/adapters/browser/youtube)** | `search` `video` `transcript` | 🔐 Browser |
| **[v2ex](/adapters/browser/v2ex)** | `hot` `latest` `topic` `daily` `me` `notifications` | 🌐 / 🔐 |
| **[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` | 🔐 Browser |
| **[linkedin](/adapters/browser/linkedin)** | `search` | 🔐 Browser |
| **[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` | 🔐 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)** | `hot` `latest` `categories` `category` `search` `topic` | 🔐 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` `marks` `reviews` `movie-hot` `book-hot` | 🔐 Browser |
| **[facebook](/adapters/browser/facebook)** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | 🔐 Browser |
| **[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 |
| **[tiktok](/adapters/browser/tiktok)** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | 🔐 Browser |
## Public API Adapters
| Site | Commands | Mode |
|------|----------|------|
| **[hackernews](/adapters/browser/hackernews)** | `top` | 🌐 Public |
| **[github](/adapters/browser/github)** | `search` | 🌐 Public |
| **[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 |
@@ -44,7 +54,8 @@ Run `opencli list` for the live registry.
| **[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` | 🌐 Public |
| **[wikipedia](/adapters/browser/wikipedia)** | `search` `summary` `random` `trending` | 🌐 Public |
| **[lobsters](/adapters/browser/lobsters)** | `hot` `newest` `active` `tag` | 🌐 Public |
## Desktop Adapters
@@ -57,6 +68,4 @@ Run `opencli list` for the live registry.
| **[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` |
| **[Feishu](/adapters/desktop/feishu)** | 飞书/Lark via AppleScript | `status` `send` `read` `search` `new` |
| **[WeChat](/adapters/desktop/wechat)** | 微信 via AppleScript | `status` `send` `read` `search` `chats` `contacts` |
| **[NeteaseMusic](/adapters/desktop/neteasemusic)** | 网易云音乐 via CDP | `status` `playing` `play` `next` `prev` `search` `playlist` `like` `lyrics` `volume` |
| **[Doubao App](/adapters/desktop/doubao-app)** | Doubao AI desktop app via CDP | `status` `new` `send` `read` `ask` `screenshot` `dump` |
+4
View File
@@ -10,6 +10,7 @@ OpenCLI supports downloading images, videos, and articles from supported platfor
| **bilibili** | Videos | Requires `yt-dlp` installed |
| **twitter** | Images, Videos | Downloads from user media tab or single tweet |
| **zhihu** | Articles (Markdown) | Exports articles with optional image download |
| **weixin** | Articles (Markdown) | Exports WeChat Official Account articles |
## Prerequisites
@@ -43,6 +44,9 @@ opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
# Export with local images
opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --download-images
# Export WeChat article to Markdown
opencli weixin download --url "https://mp.weixin.qq.com/s/xxx" --output ./weixin
```
## Pipeline Step (YAML Adapters)
+125
View File
@@ -0,0 +1,125 @@
# Comparison Guide
OpenCLI occupies a specific niche in the browser automation ecosystem. This guide honestly evaluates where opencli excels, where it's a viable option, and where other tools are a better fit.
## At a Glance
| Tool | Approach | Best for |
|------|----------|----------|
| **opencli** | Pre-built adapters (YAML/TS) | Deterministic site commands, broad platform coverage, desktop apps |
| **Browser-Use** | LLM-driven browser control | General-purpose AI browser automation |
| **Crawl4AI** | Async web crawler | Large-scale data crawling |
| **Firecrawl** | Scraping API / self-hosted | Clean markdown extraction, managed or self-hosted infrastructure |
| **agent-browser** | Browser primitive CLI | Token-efficient AI agent browsing |
| **Stagehand** | AI browser framework | Developer-friendly browser automation |
| **Skyvern** | Visual AI automation | Cross-site generalized workflows |
## Scenario Comparison
### 1. Scheduled Batch Data Extraction
> "I want to pull trending posts from Bilibili/Reddit/HackerNews every hour into my pipeline."
| Tool | Fit | Notes |
|------|-----|-------|
| **opencli** | Best | One command, structured JSON output, zero runtime cost. Runs in cron/CI without tokens or API keys. |
| Crawl4AI | Good | Strong for large-scale crawling, but requires writing extraction logic per site. |
| Firecrawl | Viable | Managed service with clean output, but costs scale with volume. |
| Browser-Use / Stagehand | Poor | LLM inference on every run is slow, expensive, and non-deterministic for repeated tasks. |
**Why opencli wins here:** A command like `opencli bilibili hot -f json` returns the same structured schema every time, costs nothing to run, and finishes in seconds. For recurring data extraction from known sites, pre-built adapters beat LLM-driven approaches on cost, speed, and reliability.
### 2. AI Agent Site Operations
> "My AI agent needs to search Twitter, read Reddit threads, or post to Xiaohongshu."
| Tool | Fit | Notes |
|------|-----|-------|
| **opencli** | Best | Structured JSON output, fast deterministic execution, hundreds of commands ready to use. |
| agent-browser | Good | Token-efficient browser primitives, but requires LLM reasoning for every step. |
| Browser-Use | Viable | General-purpose, but each operation costs tokens and takes 10-60s. |
| Stagehand | Viable | Good DX, but same LLM-per-action cost model. |
**Why opencli wins here:** When your agent needs `twitter search "AI news" -f json`, a deterministic command that returns in seconds is strictly better than an LLM clicking through a webpage. The agent saves tokens for reasoning, not navigation.
### 3. Authenticated Operations (Login-Required Sites)
> "I need to access my bookmarks, post content, or interact with sites that require login."
| Tool | Fit | Notes |
|------|-----|-------|
| **opencli** | Best | Reuses your Chrome login session via Browser Bridge. No credentials stored or transmitted. |
| Browser-Use | Viable | Can use browser profiles, but credential management is manual. |
| Firecrawl | Poor | Cloud service cannot access your authenticated sessions. |
| Crawl4AI | Poor | Requires manual cookie/session injection. |
**Why opencli wins here:** The Browser Bridge extension reuses your existing Chrome login state in real-time. You log in once in Chrome, and opencli commands work immediately. No OAuth setup, no API keys, no credential files.
### 4. General Web Browsing & Exploration
> "I need to explore an unknown website, fill forms, or navigate complex multi-step flows."
| Tool | Fit | Notes |
|------|-----|-------|
| Browser-Use | Best | LLM-driven, handles arbitrary websites and flows. |
| Stagehand | Best | Clean API for `act()`, `extract()`, `observe()` on any page. |
| agent-browser | Good | Token-efficient primitives for AI agents. |
| Skyvern | Good | Visual AI that generalizes across sites. |
| **opencli** | Poor | Only works with sites that have pre-built adapters. Cannot handle arbitrary websites. |
**opencli is not the right tool here.** If you need to explore unknown websites or handle one-off tasks on sites without adapters, use an LLM-driven browser tool. opencli trades generality for determinism and cost.
### 5. Desktop App Control
> "I want to script Cursor, ChatGPT, Notion, or other Electron apps from the terminal."
| Tool | Fit | Notes |
|------|-----|-------|
| **opencli** | Best | 8 desktop adapters via CDP + AppleScript. The only CLI tool with this capability. |
| All others | N/A | Browser automation tools cannot control desktop applications. |
**This is unique to opencli.** No other tool in this comparison can send a prompt to ChatGPT desktop, extract code from Cursor, or write to Notion pages via CLI.
## Key Trade-offs
### opencli's Strengths
- **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.
- **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.
### opencli's Limitations
- **Coverage requires adapters** — opencli only works with sites that have pre-built adapters. Adding a new site means writing a YAML or TypeScript adapter.
- **Adapter maintenance** — When a website updates its DOM or API, the corresponding adapter may need updating. The community maintains these, but breakage is possible.
- **Not general-purpose** — Cannot handle arbitrary websites. For unknown sites, pair opencli with a general browser tool as a fallback.
## Complementary Usage
opencli works best alongside general-purpose browser tools, not as a replacement:
```
Has adapter? ──yes──▶ opencli (fast, free, deterministic)
no
One-off task? ──yes──▶ Browser-Use / Stagehand (LLM-driven)
no
Recurring? ──yes──▶ Write an opencli adapter, then use opencli
```
## Further Reading
- [Architecture Overview](./developer/architecture.md)
- [Writing a YAML Adapter](./developer/yaml-adapter.md)
- [Writing a TypeScript Adapter](./developer/ts-adapter.md)
- [Testing Guide](./developer/testing.md)
- [AI Workflow](./developer/ai-workflow.md)
- [Contributing Guide](./developer/contributing.md)
+4 -2
View File
@@ -17,7 +17,8 @@ npm run build
# 4. Run a few checks
npx tsc --noEmit
npx vitest run src/
npm test
npm run test:adapter
# 5. Link globally (optional, for testing `opencli` command)
npm link
@@ -129,7 +130,8 @@ chore: bump vitest to v4
3. Run the checks:
```bash
npx tsc --noEmit # Type check
npx vitest run src/ # Unit tests
npm test # Core unit tests
npm run test:adapter # Focused adapter tests (if adapter logic changed)
opencli validate # YAML validation (if applicable)
```
4. Commit using conventional commit format
+93 -69
View File
@@ -18,57 +18,74 @@
测试分为三层,全部使用 **vitest** 运行:
```
```text
tests/
├── e2e/ # E2E 集成测试(子进程运行真实 CLI)
│ ├── helpers.ts # runCli() 共享工具
│ ├── public-commands.test.ts # 公开 API 命令(无需浏览器)
│ ├── helpers.ts # runCli() / parseJsonOutput() 共享工具
│ ├── public-commands.test.ts # 公开 API 命令
│ ├── browser-public.test.ts # 浏览器命令(公开数据)
│ ├── browser-auth.test.ts # 需登录命令(graceful failure 测试
│ ├── management.test.ts # 管理命令(list, validate, verify, help
│ └── output-formats.test.ts # 输出格式json/yaml/csv/md
├── smoke/ # 烟雾测试(仅定时 / 手动触发)
│ └── api-health.test.ts # 外部 API 可用性检测
│ ├── browser-auth.test.ts # 需登录命令(graceful failure
│ ├── management.test.ts # 管理命令(list / validate / verify / help
│ └── output-formats.test.ts # 输出格式校验
├── smoke/
│ └── api-health.test.ts # 外部 API、adapter 定义、命令注册健康检查
src/
├── *.test.ts # 单元测试(已有 8 个
├── **/*.test.ts # 核心单元测试(默认 `unit` project
└── clis/{zhihu,twitter,reddit,bilibili}/**/*.test.ts # 聚焦 adapter tests
```
| 层 | 位置 | 运行方式 | 用途 |
|---|---|---|---|
| 单元测试 | `src/**/*.test.ts` | `npx vitest run src/` | 内部模块逻辑 |
| E2E 测试 | `tests/e2e/*.test.ts` | `npx vitest run tests/e2e/` | 真实 CLI 命令执行 |
| 烟雾测试 | `tests/smoke/*.test.ts` | `npx vitest run tests/smoke/` | 外部 API 健康 |
| 层 | 位置 | 当前文件数 | 运行方式 | 用途 |
|---|---|---:|---|---|
| 单元测试 | `src/**/*.test.ts`(排除 `src/clis/**` | - | `npm test` | 内部模块、pipeline、runtime |
| Adapter 测试 | `src/clis/{zhihu,twitter,reddit,bilibili}/**/*.test.ts` | - | `npm run test:adapter` | 保留 4 个重点站点的 adapter 覆盖 |
| E2E 测试 | `tests/e2e/*.test.ts` | 5 | `npx vitest run tests/e2e/` | 真实 CLI 命令执行 |
| 烟雾测试 | `tests/smoke/*.test.ts` | 1 | `npx vitest run tests/smoke/` | 外部 API 与注册完整性 |
---
## 当前覆盖范围
### 单元测试8 个文件)
### 单元测试与 Adapter 测试
| 文件 | 覆盖内容 |
| 领域 | 文件 |
|---|---|
| `browser.test.ts` | JSON-RPC、tab 管理、extension/standalone 模式切换 |
| `engine.test.ts` | 命令发现与执行 |
| `registry.test.ts` | 命令注册与策略分配 |
| `output.test.ts` | 输出格式渲染 |
| `doctor.test.ts` | Token 诊断 |
| `coupang.test.ts` | 数据归一化 |
| `pipeline/template.test.ts` | 模板表达式求值 |
| `pipeline/transform.test.ts` | 数据变换步骤 |
| 核心运行时与输出 | `src/browser.test.ts`, `src/browser/dom-snapshot.test.ts`, `src/build-manifest.test.ts`, `src/capabilityRouting.test.ts`, `src/doctor.test.ts`, `src/engine.test.ts`, `src/interceptor.test.ts`, `src/output.test.ts`, `src/plugin.test.ts`, `src/registry.test.ts`, `src/snapshotFormatter.test.ts` |
| pipeline 与下载 | `src/download/index.test.ts`, `src/pipeline/executor.test.ts`, `src/pipeline/template.test.ts`, `src/pipeline/transform.test.ts` |
| 聚焦 adapter 逻辑 | `src/clis/zhihu/download.test.ts`, `src/clis/twitter/timeline.test.ts`, `src/clis/reddit/read.test.ts`, `src/clis/bilibili/dynamic.test.ts` |
### E2E 测试(~52 个用例)
这些测试覆盖的重点包括:
| 文件 | 覆盖站点/功能 | 测试数 |
|---|---|---|
| `public-commands.test.ts` | hackernews/top, v2ex/hot, v2ex/latest, v2ex/topic | 5 |
| `browser-public.test.ts` | bbc, bilibili×3, weibo, zhihu×2, reddit×2, twitter, xueqiu×2, reuters, youtube, smzdm, boss, ctrip, coupang, xiaohongshu, yahoo-finance, v2ex/daily | 21 |
| `browser-auth.test.ts` | bilibili/me,dynamic,favorite,history,following + twitter/bookmarks,timeline,notifications + v2ex/me,notifications + xueqiu/feed,watchlist + xiaohongshu/feed,notifications | 14 |
| `management.test.ts` | list×5 格式, validate×3 级别, verify, --version, --help, unknown cmd | 12 |
| `output-formats.test.ts` | json, yaml, csv, md 格式验证 | 5 |
- Browser Bridge、DOM snapshot、interceptor、capability routing
- manifest 生成、命令发现、插件安装与注册表
- 输出格式渲染与 snapshot formatting
- pipeline 模板求值、执行器与变换步骤
- 各站点 adapter 的数据归一化、参数处理与容错逻辑
### 烟雾测试
### E2E 测试(5 个文件)
公开 API 可用性(hackernews, v2ex×2, v2ex/topic+ 全站点注册完整性检查。
| 文件 | 当前覆盖范围 |
|---|---|
| `tests/e2e/public-commands.test.ts` | `bloomberg``apple-podcasts``hackernews``v2ex``xiaoyuzhou``google suggest` 等公开命令 |
| `tests/e2e/browser-public.test.ts` | `bbc``bloomberg``bilibili``weibo``zhihu``reddit``twitter``xueqiu``reuters``youtube``smzdm``boss``ctrip``coupang``xiaohongshu``google``yahoo-finance``v2ex daily` |
| `tests/e2e/browser-auth.test.ts` | `bilibili``twitter``v2ex``xueqiu``linux-do``xiaohongshu` 的需登录命令 graceful failure |
| `tests/e2e/management.test.ts` | `list``validate``verify``--version``--help`、unknown command |
| `tests/e2e/output-formats.test.ts` | `json` / `yaml` / `csv` / `md` 输出格式校验 |
### 烟雾测试(1 个文件)
| 文件 | 当前覆盖范围 |
|---|---|
| `tests/smoke/api-health.test.ts` | `hackernews``v2ex` 公开 API 可用性,`validate` 全量 adapter 校验,以及命令注册表基础完整性 |
### 快速核对命令
需要刷新测试清单时,直接以仓库文件为准:
```bash
find src -name '*.test.ts' | sort
find tests/e2e -name '*.test.ts' | sort
find tests/smoke -name '*.test.ts' | sort
```
---
@@ -78,27 +95,31 @@ src/
```bash
npm ci # 安装依赖
npm run build # 编译(E2E 测试需要 dist/main.js
npm run build # 编译(E2E / smoke 测试需要 dist/main.js
```
### 运行命令
```bash
# 全部单元测试
npx vitest run src/
# 默认核心单元测试(不含大多数 adapter tests
npm test
# 全部 E2E 测试(会真实调用外部 API
# 聚焦 adapter tests(只保留 4 个重点站点
npm run test:adapter
# 全部 E2E 测试(会真实调用外部 API / 浏览器)
npx vitest run tests/e2e/
# 全部 smoke 测试
npx vitest run tests/smoke/
# 单个测试文件
npx vitest run src/clis/apple-podcasts/commands.test.ts
npx vitest run tests/e2e/management.test.ts
# 全部测试(单元 + E2E
# 全部测试
npx vitest run
# 烟雾测试
npx vitest run tests/smoke/
# watch 模式(开发时推荐)
npx vitest src/
```
@@ -106,9 +127,10 @@ npx vitest src/
### 浏览器命令本地测试须知
- opencli 通过 Browser Bridge 扩展连接已运行的 Chrome 浏览器
- `browser-public.test.ts` 使用 `tryBrowserCommand()`,站点反爬导致空数据时 warn + pass
- `browser-auth.test.ts` 验证 **graceful failure**(不 crash 不 hang 即通过)
- 如需测试完整登录态,保持 Chrome 登录态并安装 Browser Bridge 扩展,手动跑对应测试
- E2E 测试通过 `tests/e2e/helpers.ts` 里的 `runCli()` 调用已构建的 `dist/main.js`
- `browser-public.test.ts` 使用 `tryBrowserCommand()`,站点反爬或地域限制导致空数据时会 warn + pass
- `browser-auth.test.ts` 验证 **graceful failure**,重点是不 crash、不 hang、错误信息可控
- 如需测试完整登录态,保持 Chrome 登录态并安装 Browser Bridge 扩展,再手动运行对应测试
---
@@ -116,8 +138,8 @@ npx vitest src/
### 新增 YAML Adapter(如 `src/clis/producthunt/trending.yaml`
1. **无需额外操作**`validate` 测试会自动覆盖 YAML 结构验
2. 根据 adapter 类型,在对应文件一个 `it()` block
1. `opencli validate` 的 E2E / smoke 测试会覆盖 adapter 结构
2. 根据 adapter 类型,在对应测试文件一个 `it()` block
```typescript
// 如果 browser: false(公开 API)→ tests/e2e/public-commands.test.ts
@@ -148,15 +170,15 @@ it('producthunt me fails gracefully without login', async () => {
### 新增管理命令(如 `opencli export`
`tests/e2e/management.test.ts` 添加测试。
`tests/e2e/management.test.ts` 添加测试;如果新命令会影响输出格式,也同步补 `tests/e2e/output-formats.test.ts`
### 新增内部模块
`src/` 下对应位置创建 `*.test.ts`
对应源码旁创建 `*.test.ts`,优先和被测模块放在同一目录下,便于发现与维护
### 决策流程图
```
```text
新增功能 → 是内部模块? → 是 → src/ 下加 *.test.ts
↓ 否
是 CLI 命令? → browser: false? → tests/e2e/public-commands.test.ts
@@ -170,33 +192,35 @@ it('producthunt me fails gracefully without login', async () => {
## CI/CD 流水线
### ci.yml(主流水线)
### `ci.yml`
| Job | 触发条件 | 内容 |
|---|---|---|
| **build** | push/PR to main,dev | typecheck + build |
| **unit-test** | push/PR to main,dev | 单元测试,2 shard 并行 |
| **smoke-test** | 每周一 08:00 UTC / 手动 | xvfb + real Chrome,外部 API 健康检查 |
| `build` | push/PR `main`,`dev` | `tsc --noEmit` + `npm run build` |
| `unit-test` | push/PR `main`,`dev` | Node `20``22` 双版本运行核心 `unit` tests,按 `2` shard 并行 |
| `adapter-test` | push/PR 到 `main`,`dev` | Node `22` 运行聚焦的 `zhihu/twitter/reddit/bilibili` adapter tests |
| `smoke-test` | `schedule``workflow_dispatch` | 安装真实 Chrome`xvfb-run` 执行 `tests/smoke/` |
### e2e-headed.ymlE2E 测试)
### `e2e-headed.yml`
| Job | 触发条件 | 内容 |
|---|---|---|
| **e2e-headed** | push/PR to main,dev | xvfb + real Chrome,全部 E2E 测试 |
| `e2e-headed` | push/PR `main`,`dev`,或手动触发 | 安装真实 Chrome,`xvfb-run` 执行 `tests/e2e/` |
E2E 使用 `browser-actions/setup-chrome` 安装真实 Chrome配合 `xvfb-run` 提供虚拟显示器,以 headed 模式运行浏览器。
E2E 与 smoke 都使用 `./.github/actions/setup-chrome` 准备真实 Chrome并通过 `OPENCLI_BROWSER_EXECUTABLE_PATH` 注入浏览器路径
### Sharding
单元测试使用 vitest 内置 shard
单元测试使用 vitest 内置 shard,并在 Node `20` / `22` 两个版本上运行
::: v-pre
```yaml
strategy:
matrix:
node-version: ['20', '22']
shard: [1, 2]
steps:
- run: npx vitest run src/ --shard=${{ matrix.shard }}/2
- run: npm test -- --reporter=verbose --shard=${{ matrix.shard }}/2
```
:::
@@ -208,8 +232,8 @@ opencli 通过 Browser Bridge 扩展连接浏览器:
| 条件 | 模式 | 使用场景 |
|---|---|---|
| 扩展已安装 | Extension 模式 | 本地用户,连接已登录的 Chrome |
| 扩展未安装 | CLI 报错提示安装 | 需要安装 Browser Bridge 扩展 |
| 扩展已安装 / 已连接 | Extension 模式 | 本地用户,连接已登录的 Chrome |
| 扩展 token | CLI 自行拉起浏览器 | CI、无登录态或纯自动化场景 |
CI 中使用 `OPENCLI_BROWSER_EXECUTABLE_PATH` 指定真实 Chrome 路径:
@@ -224,14 +248,14 @@ env:
## 站点兼容性
GitHub Actions 美国 runner 上,部分站点地域限制登录要求返回空数据。E2E 测试对这些站点使用 warn + pass 策略,不影响 CI 绿灯
GitHub Actions 美国 runner 上,部分站点会因为地域限制登录要求或反爬而返回空数据。当前 E2E 对这些场景采用 warn + pass 策略,避免偶发站点限制把整条 CI 打红
| 站点 | CI 状态 | 限制原因 |
| 站点 | CI 表现 | 常见原因 |
|---|---|---|
| hackernews, bbc, v2ex | 返回数据 | 无限制 |
| yahoo-finance | 返回数据 | 无限制 |
| bilibili, zhihu, weibo, xiaohongshu | ⚠️ 空数据 | 地域限制(中国站点) |
| reddit, twitter, youtube | ⚠️ 空数据 | 登录cookie |
| smzdm, boss, ctrip, coupang, xueqiu | ⚠️ 空数据 | 地域限制 / 需登录 |
| `hackernews``bbc``v2ex``bloomberg` | 通常返回数据 | 公开接口或公开页面 |
| `yahoo-finance``google` | 通常返回数据 | 页面公开,但仍可能受限流影响 |
| `bilibili``zhihu``weibo``xiaohongshu``xueqiu` | 容易空数据 | 地域限制、反爬、登录要求 |
| `reddit``twitter``youtube` | 容易空数据 | 登录态、cookie、机器人检测 |
| `smzdm``boss``ctrip``coupang``linux-do` | 结果波动较大 | 地域限制、风控或页面结构变动 |
> 使用 self-hosted runner(国内服务器)可解决地域限制问题
> 如果需要更稳定的浏览器 E2E 结果,优先使用具备目标站点网络可达性的 self-hosted runner。
+3 -4
View File
@@ -8,9 +8,9 @@ OpenCLI connects to your browser through a lightweight **Browser Bridge** Chrome
### Method 1: Download Pre-built Release (Recommended)
1. Go to the GitHub [Releases page](https://github.com/jackwener/opencli/releases) and download the latest `opencli-extension.zip` or `opencli-extension.crx`.
2. Open `chrome://extensions` and enable **Developer mode** (top-right toggle).
3. Drag and drop the `.crx` file or the unzipped folder into the extensions page.
1. Go to the GitHub [Releases page](https://github.com/jackwener/opencli/releases) and download the latest `opencli-extension.zip`.
2. Unzip the file and open `chrome://extensions`, enable **Developer mode** (top-right toggle).
3. Click **Load unpacked** and select the unzipped folder.
### Method 2: Load Unpacked Source (For Developers)
@@ -23,7 +23,6 @@ That's it! The daemon auto-starts when you run any browser command. No tokens, n
```bash
opencli doctor # Check extension + daemon connectivity
opencli doctor --live # Also test live browser commands
```
## How It Works
+2 -1
View File
@@ -14,7 +14,7 @@ OpenCLI turns **any website** or **Electron app** into a command-line interface
- **CLI All Electron** — CLI-ify apps like Antigravity Ultra! Now AI can control itself natively.
- **Account-safe** — Reuses Chrome's logged-in state; your credentials never leave the browser.
- **AI Agent ready** — `explore` discovers APIs, `synthesize` generates adapters, `cascade` finds auth strategies.
- **Self-healing setup** — `opencli setup` verifies Browser Bridge connectivity; `opencli doctor` diagnoses daemon, extension, and live browser connectivity.
- **Self-healing setup** — `opencli doctor` auto-starts the daemon and diagnoses extension + 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.
@@ -52,5 +52,6 @@ opencli bilibili hot -v # Verbose: show pipeline debug
- [Installation details](/guide/installation)
- [Browser Bridge setup](/guide/browser-bridge)
- [Plugins — extend with community adapters](/guide/plugins)
- [All available adapters](/adapters/)
- [For developers / AI agents](/developer/contributing)
+153
View File
@@ -0,0 +1,153 @@
# Plugins
OpenCLI supports community-contributed plugins. Install third-party adapters from GitHub, and they're automatically discovered alongside built-in commands.
## Quick Start
```bash
# Install a plugin
opencli plugin install github:ByteYue/opencli-plugin-github-trending
# List installed plugins
opencli plugin list
# Use the plugin (it's just a regular command)
opencli github-trending repos --limit 10
# Remove a plugin
opencli plugin uninstall github-trending
```
## How Plugins Work
Plugins live in `~/.opencli/plugins/<name>/`. Each subdirectory is scanned at startup for `.yaml`, `.ts`, or `.js` command files — the same formats used by built-in adapters.
### Supported Source Formats
```bash
opencli plugin install github:user/repo
opencli plugin install https://github.com/user/repo
```
The repo name prefix `opencli-plugin-` is automatically stripped for the local directory name. For example, `opencli-plugin-hot-digest` becomes `hot-digest`.
## Creating a Plugin
### Option 1: YAML Plugin (Simplest)
Zero dependencies, no build step. Just create a `.yaml` file:
```
my-plugin/
├── my-command.yaml
└── README.md
```
Example `my-command.yaml`:
```yaml
site: my-plugin
name: my-command
description: My custom command
strategy: public
browser: false
args:
limit:
type: int
default: 10
pipeline:
- fetch:
url: https://api.example.com/data
- map:
title: ${{ item.title }}
score: ${{ item.score }}
- limit: ${{ args.limit }}
columns: [title, score]
```
### Option 2: TypeScript Plugin
For richer logic (multi-source aggregation, custom transformations, etc.):
```
my-plugin/
├── package.json
├── my-command.ts
└── README.md
```
`package.json`:
```json
{
"name": "opencli-plugin-my-plugin",
"version": "0.1.0",
"type": "module",
"peerDependencies": {
"@jackwener/opencli": ">=1.0.0"
}
}
```
`my-command.ts`:
```typescript
import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'my-plugin',
name: 'my-command',
description: 'My custom command',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'limit', type: 'int', default: 10, help: 'Number of items' },
],
columns: ['title', 'score'],
func: async (_page, kwargs) => {
const res = await fetch('https://api.example.com/data');
const data = await res.json();
return data.items.slice(0, kwargs.limit).map((item: any, i: number) => ({
title: item.title,
score: item.score,
}));
},
});
```
### TS Plugin Install Lifecycle
When you run `opencli plugin install`, TS plugins are automatically set up:
1. **Clone**`git clone --depth 1` from GitHub
2. **npm install** — Resolves regular dependencies
3. **Host symlink** — Links the running `@jackwener/opencli` into the plugin's `node_modules/` so `import from '@jackwener/opencli/registry'` always resolves against the host
4. **Transpile** — Compiles `.ts``.js` via `esbuild` (production `node` cannot load `.ts` directly)
On startup, if both `my-command.ts` and `my-command.js` exist, the `.js` version is loaded to avoid duplicate registration.
## Example Plugins
| Repo | Type | Description |
|------|------|-------------|
| [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 |
## Troubleshooting
### Command not found after install
Restart opencli (or open a new terminal) — plugins are discovered at startup.
### TS plugin import errors
If you see `Cannot find module '@jackwener/opencli/registry'`, the host symlink may be broken. Reinstall the plugin:
```bash
opencli plugin uninstall my-plugin
opencli plugin install github:user/opencli-plugin-my-plugin
```
+1 -1
View File
@@ -53,4 +53,4 @@ npx tsc --noEmit
## Getting Help
- [GitHub Issues](https://github.com/jackwener/opencli/issues) — Bug reports and feature requests
- Run `opencli doctor --live` for comprehensive diagnostics
- Run `opencli doctor` for comprehensive diagnostics
+1 -1
View File
@@ -28,7 +28,7 @@ features:
details: Supports both YAML declarative data pipelines and robust browser runtime TypeScript injections for maximum flexibility.
- icon: 🔧
title: Self-Healing Setup
details: "opencli setup verifies Browser Bridge connectivity. opencli doctor diagnoses daemon, extension, and live browser."
details: "opencli doctor auto-starts the daemon and diagnoses extension + live browser connectivity."
- icon: 📦
title: Dynamic Loader
details: Simply drop .ts or .yaml adapters into the clis/ folder for auto-registration. Zero boilerplate.
+3 -4
View File
@@ -8,9 +8,9 @@ OpenCLI 通过轻量级 **Browser Bridge** Chrome 扩展 + 微守护进程连接
### 方法 1:下载预构建版本(推荐)
1. 前往 GitHub [Releases 页面](https://github.com/jackwener/opencli/releases) 下载最新的 `opencli-extension.zip``opencli-extension.crx`
2. 打开 `chrome://extensions`,启用**开发者模式**。
3. 拖放 `.crx` 文件或解压后的文件夹到扩展页面
1. 前往 GitHub [Releases 页面](https://github.com/jackwener/opencli/releases) 下载最新的 `opencli-extension.zip`
2. 解压后打开 `chrome://extensions`,启用**开发者模式**。
3. 点击**加载已解压的扩展程序**,选择解压后的文件夹。
### 方法 2:加载源码(开发者)
@@ -21,5 +21,4 @@ OpenCLI 通过轻量级 **Browser Bridge** Chrome 扩展 + 微守护进程连接
```bash
opencli doctor # 检查扩展 + 守护进程连接
opencli doctor --live # 同时测试实时浏览器命令
```
+107
View File
@@ -0,0 +1,107 @@
# 插件
OpenCLI 支持社区贡献的 plugins。你可以从 GitHub 安装第三方 adapters,它们会和内置 commands 一起在启动时自动发现。
## 安装插件
```bash
# 安装插件
opencli plugin install github:ByteYue/opencli-plugin-github-trending
# 列出已安装插件
opencli plugin list
# 使用插件(本质上就是普通 command)
opencli github-trending today
# 卸载插件
opencli plugin uninstall github-trending
```
## 插件目录结构
Plugins 存放在 `~/.opencli/plugins/<name>/`。每个子目录都会在启动时扫描 `.yaml``.ts``.js` 命令文件,格式与内置 adapters 相同。
## 安装来源
```bash
opencli plugin install github:user/repo
opencli plugin install https://github.com/user/repo
```
如果仓库名带 `opencli-plugin-` 前缀,本地目录会自动去掉这个前缀。例如 `opencli-plugin-hot-digest` 会变成 `hot-digest`
## YAML plugin 示例
```text
my-plugin/
hot.yaml
```
```yaml
site: my-plugin
name: hot
description: Example plugin command
strategy: public
browser: false
pipeline:
- evaluate: |
() => [{ title: 'hello', url: 'https://example.com' }]
columns: [title, url]
```
## TypeScript plugin 示例
```text
my-plugin/
index.ts
package.json
```
```json
{
"name": "opencli-plugin-my-plugin",
"type": "module"
}
```
```ts
import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'my-plugin',
name: 'hot',
description: 'Example TS plugin command',
strategy: Strategy.PUBLIC,
browser: false,
columns: ['title', 'url'],
func: async () => [{ title: 'hello', url: 'https://example.com' }],
});
```
运行 `opencli plugin install` 时,TS plugins 会自动完成基础设置:
1. 安装 plugin 自身依赖
2. 补齐 TypeScript 运行环境
3. 将宿主 `@jackwener/opencli` 链接到 plugin 的 `node_modules/`,保证 `@jackwener/opencli/registry` 指向当前宿主版本
## 示例 plugins
- `opencli-plugin-github-trending`GitHub Trending 仓库
- `opencli-plugin-hot-digest`:多平台热点聚合(zhihu、weibo、bilibili、v2ex、stackoverflow、reddit、linux-do
- `opencli-plugin-juejin`:稀土掘金热榜、分类和文章流
## 排查问题
### TS plugin import 报错
如果看到 `Cannot find module '@jackwener/opencli/registry'`,通常是宿主 symlink 失效。重新安装 plugin 即可:
```bash
opencli plugin uninstall my-plugin
opencli plugin install github:user/opencli-plugin-my-plugin
```
安装或卸载 plugin 后,建议重新打开一个终端,确保启动时重新发现命令。
+522 -372
View File
@@ -1,432 +1,582 @@
const DAEMON_PORT = 19825;
const DAEMON_HOST = "localhost";
const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`;
const WS_RECONNECT_BASE_DELAY = 2e3;
const WS_RECONNECT_MAX_DELAY = 6e4;
const attached = /* @__PURE__ */ new Set();
//#region src/protocol.ts
/** Default daemon port */
var DAEMON_PORT = 19825;
var DAEMON_HOST = "localhost";
var DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`;
`${DAEMON_HOST}${DAEMON_PORT}`;
/** Base reconnect delay for extension WebSocket (ms) */
var WS_RECONNECT_BASE_DELAY = 2e3;
/** Max reconnect delay (ms) */
var WS_RECONNECT_MAX_DELAY = 6e4;
//#endregion
//#region src/cdp.ts
/**
* CDP execution via chrome.debugger API.
*
* chrome.debugger only needs the "debugger" permission — no host_permissions.
* It can attach to any http/https tab. Avoid chrome:// and chrome-extension://
* tabs (resolveTabId in background.ts filters them).
*/
var attached = /* @__PURE__ */ new Set();
/** Check if a URL can be attached via CDP */
function isDebuggableUrl$1(url) {
if (!url) return true;
return !url.startsWith("chrome://") && !url.startsWith("chrome-extension://");
}
async function ensureAttached(tabId) {
if (attached.has(tabId)) return;
try {
await chrome.debugger.attach({ tabId }, "1.3");
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("Another debugger is already attached")) {
try {
await chrome.debugger.detach({ tabId });
} catch {
}
try {
await chrome.debugger.attach({ tabId }, "1.3");
} catch {
throw new Error(`attach failed: ${msg}`);
}
} else {
throw new Error(`attach failed: ${msg}`);
}
}
attached.add(tabId);
try {
await chrome.debugger.sendCommand({ tabId }, "Runtime.enable");
} catch {
}
try {
const tab = await chrome.tabs.get(tabId);
if (!isDebuggableUrl$1(tab.url)) {
attached.delete(tabId);
throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? "unknown"}`);
}
} catch (e) {
if (e instanceof Error && e.message.startsWith("Cannot debug tab")) throw e;
attached.delete(tabId);
throw new Error(`Tab ${tabId} no longer exists`);
}
if (attached.has(tabId)) try {
await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", {
expression: "1",
returnByValue: true
});
return;
} catch {
attached.delete(tabId);
}
try {
await chrome.debugger.attach({ tabId }, "1.3");
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
const hint = msg.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 {}
try {
await chrome.debugger.attach({ tabId }, "1.3");
} catch {
throw new Error(`attach failed: ${msg}${hint}`);
}
} else throw new Error(`attach failed: ${msg}${hint}`);
}
attached.add(tabId);
try {
await chrome.debugger.sendCommand({ tabId }, "Runtime.enable");
} catch {}
}
async function evaluate(tabId, expression) {
await ensureAttached(tabId);
const result = await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", {
expression,
returnByValue: true,
awaitPromise: true
});
if (result.exceptionDetails) {
const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error";
throw new Error(errMsg);
}
return result.result?.value;
await ensureAttached(tabId);
const result = await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", {
expression,
returnByValue: true,
awaitPromise: true
});
if (result.exceptionDetails) {
const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error";
throw new Error(errMsg);
}
return result.result?.value;
}
const evaluateAsync = evaluate;
var evaluateAsync = evaluate;
/**
* Capture a screenshot via CDP Page.captureScreenshot.
* Returns base64-encoded image data.
*/
async function screenshot(tabId, options = {}) {
await ensureAttached(tabId);
const format = options.format ?? "png";
if (options.fullPage) {
const metrics = await chrome.debugger.sendCommand({ tabId }, "Page.getLayoutMetrics");
const size = metrics.cssContentSize || metrics.contentSize;
if (size) {
await chrome.debugger.sendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", {
mobile: false,
width: Math.ceil(size.width),
height: Math.ceil(size.height),
deviceScaleFactor: 1
});
}
}
try {
const params = { format };
if (format === "jpeg" && options.quality !== void 0) {
params.quality = Math.max(0, Math.min(100, options.quality));
}
const result = await chrome.debugger.sendCommand({ tabId }, "Page.captureScreenshot", params);
return result.data;
} finally {
if (options.fullPage) {
await chrome.debugger.sendCommand({ tabId }, "Emulation.clearDeviceMetricsOverride").catch(() => {
});
}
}
await ensureAttached(tabId);
const format = options.format ?? "png";
if (options.fullPage) {
const metrics = await chrome.debugger.sendCommand({ tabId }, "Page.getLayoutMetrics");
const size = metrics.cssContentSize || metrics.contentSize;
if (size) await chrome.debugger.sendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", {
mobile: false,
width: Math.ceil(size.width),
height: Math.ceil(size.height),
deviceScaleFactor: 1
});
}
try {
const params = { format };
if (format === "jpeg" && options.quality !== void 0) params.quality = Math.max(0, Math.min(100, options.quality));
return (await chrome.debugger.sendCommand({ tabId }, "Page.captureScreenshot", params)).data;
} finally {
if (options.fullPage) await chrome.debugger.sendCommand({ tabId }, "Emulation.clearDeviceMetricsOverride").catch(() => {});
}
}
function detach(tabId) {
if (!attached.has(tabId)) return;
attached.delete(tabId);
try {
chrome.debugger.detach({ tabId });
} catch {
}
async function detach(tabId) {
if (!attached.has(tabId)) return;
attached.delete(tabId);
try {
await chrome.debugger.detach({ tabId });
} catch {}
}
function registerListeners() {
chrome.tabs.onRemoved.addListener((tabId) => {
attached.delete(tabId);
});
chrome.debugger.onDetach.addListener((source) => {
if (source.tabId) attached.delete(source.tabId);
});
chrome.tabs.onRemoved.addListener((tabId) => {
attached.delete(tabId);
});
chrome.debugger.onDetach.addListener((source) => {
if (source.tabId) attached.delete(source.tabId);
});
chrome.tabs.onUpdated.addListener(async (tabId, info) => {
if (info.url && !isDebuggableUrl$1(info.url)) await detach(tabId);
});
}
let ws = null;
let reconnectTimer = null;
let reconnectAttempts = 0;
const _origLog = console.log.bind(console);
const _origWarn = console.warn.bind(console);
const _origError = console.error.bind(console);
//#endregion
//#region src/background.ts
var ws = null;
var reconnectTimer = null;
var reconnectAttempts = 0;
var _origLog = console.log.bind(console);
var _origWarn = console.warn.bind(console);
var _origError = console.error.bind(console);
function forwardLog(level, args) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
try {
const msg = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ");
ws.send(JSON.stringify({ type: "log", level, msg, ts: Date.now() }));
} catch {
}
if (!ws || ws.readyState !== WebSocket.OPEN) return;
try {
const msg = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ");
ws.send(JSON.stringify({
type: "log",
level,
msg,
ts: Date.now()
}));
} catch {}
}
console.log = (...args) => {
_origLog(...args);
forwardLog("info", args);
_origLog(...args);
forwardLog("info", args);
};
console.warn = (...args) => {
_origWarn(...args);
forwardLog("warn", args);
_origWarn(...args);
forwardLog("warn", args);
};
console.error = (...args) => {
_origError(...args);
forwardLog("error", args);
_origError(...args);
forwardLog("error", args);
};
function connect() {
if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return;
try {
ws = new WebSocket(DAEMON_WS_URL);
} catch {
scheduleReconnect();
return;
}
ws.onopen = () => {
console.log("[opencli] Connected to daemon");
reconnectAttempts = 0;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
};
ws.onmessage = async (event) => {
try {
const command = JSON.parse(event.data);
const result = await handleCommand(command);
ws?.send(JSON.stringify(result));
} catch (err) {
console.error("[opencli] Message handling error:", err);
}
};
ws.onclose = () => {
console.log("[opencli] Disconnected from daemon");
ws = null;
scheduleReconnect();
};
ws.onerror = () => {
ws?.close();
};
if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return;
try {
ws = new WebSocket(DAEMON_WS_URL);
} catch {
scheduleReconnect();
return;
}
ws.onopen = () => {
console.log("[opencli] Connected to daemon");
reconnectAttempts = 0;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
};
ws.onmessage = async (event) => {
try {
const result = await handleCommand(JSON.parse(event.data));
ws?.send(JSON.stringify(result));
} catch (err) {
console.error("[opencli] Message handling error:", err);
}
};
ws.onclose = () => {
console.log("[opencli] Disconnected from daemon");
ws = null;
scheduleReconnect();
};
ws.onerror = () => {
ws?.close();
};
}
function scheduleReconnect() {
if (reconnectTimer) return;
reconnectAttempts++;
const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY);
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, delay);
if (reconnectTimer) return;
reconnectAttempts++;
const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY);
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, delay);
}
const automationSessions = /* @__PURE__ */ new Map();
const WINDOW_IDLE_TIMEOUT = 3e4;
var automationSessions = /* @__PURE__ */ new Map();
var WINDOW_IDLE_TIMEOUT = 12e4;
function getWorkspaceKey(workspace) {
return workspace?.trim() || "default";
return workspace?.trim() || "default";
}
function resetWindowIdleTimer(workspace) {
const session = automationSessions.get(workspace);
if (!session) return;
if (session.idleTimer) clearTimeout(session.idleTimer);
session.idleDeadlineAt = Date.now() + WINDOW_IDLE_TIMEOUT;
session.idleTimer = setTimeout(async () => {
const current = automationSessions.get(workspace);
if (!current) return;
try {
await chrome.windows.remove(current.windowId);
console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout)`);
} catch {
}
automationSessions.delete(workspace);
}, WINDOW_IDLE_TIMEOUT);
const session = automationSessions.get(workspace);
if (!session) return;
if (session.idleTimer) clearTimeout(session.idleTimer);
session.idleDeadlineAt = Date.now() + WINDOW_IDLE_TIMEOUT;
session.idleTimer = setTimeout(async () => {
const current = automationSessions.get(workspace);
if (!current) return;
try {
await chrome.windows.remove(current.windowId);
console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout)`);
} catch {}
automationSessions.delete(workspace);
}, WINDOW_IDLE_TIMEOUT);
}
/** Get or create the dedicated automation window. */
async function getAutomationWindow(workspace) {
const existing = automationSessions.get(workspace);
if (existing) {
try {
await chrome.windows.get(existing.windowId);
return existing.windowId;
} catch {
automationSessions.delete(workspace);
}
}
const win = await chrome.windows.create({
url: "about:blank",
focused: false,
width: 1280,
height: 900,
type: "normal"
});
const session = {
windowId: win.id,
idleTimer: null,
idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT
};
automationSessions.set(workspace, session);
console.log(`[opencli] Created automation window ${session.windowId} (${workspace})`);
resetWindowIdleTimer(workspace);
return session.windowId;
const existing = automationSessions.get(workspace);
if (existing) try {
await chrome.windows.get(existing.windowId);
return existing.windowId;
} catch {
automationSessions.delete(workspace);
}
const session = {
windowId: (await chrome.windows.create({
url: "data:text/html,<html></html>",
focused: false,
width: 1280,
height: 900,
type: "normal"
})).id,
idleTimer: null,
idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT
};
automationSessions.set(workspace, session);
console.log(`[opencli] Created automation window ${session.windowId} (${workspace})`);
resetWindowIdleTimer(workspace);
await new Promise((resolve) => setTimeout(resolve, 200));
return session.windowId;
}
chrome.windows.onRemoved.addListener((windowId) => {
for (const [workspace, session] of automationSessions.entries()) {
if (session.windowId === windowId) {
console.log(`[opencli] Automation window closed (${workspace})`);
if (session.idleTimer) clearTimeout(session.idleTimer);
automationSessions.delete(workspace);
}
}
for (const [workspace, session] of automationSessions.entries()) if (session.windowId === windowId) {
console.log(`[opencli] Automation window closed (${workspace})`);
if (session.idleTimer) clearTimeout(session.idleTimer);
automationSessions.delete(workspace);
}
});
let initialized = false;
var initialized = false;
function initialize() {
if (initialized) return;
initialized = true;
chrome.alarms.create("keepalive", { periodInMinutes: 0.4 });
registerListeners();
connect();
console.log("[opencli] OpenCLI extension initialized");
if (initialized) return;
initialized = true;
chrome.alarms.create("keepalive", { periodInMinutes: .4 });
registerListeners();
connect();
console.log("[opencli] OpenCLI extension initialized");
}
chrome.runtime.onInstalled.addListener(() => {
initialize();
initialize();
});
chrome.runtime.onStartup.addListener(() => {
initialize();
initialize();
});
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === "keepalive") connect();
if (alarm.name === "keepalive") connect();
});
async function handleCommand(cmd) {
const workspace = getWorkspaceKey(cmd.workspace);
resetWindowIdleTimer(workspace);
try {
switch (cmd.action) {
case "exec":
return await handleExec(cmd, workspace);
case "navigate":
return await handleNavigate(cmd, workspace);
case "tabs":
return await handleTabs(cmd, workspace);
case "cookies":
return await handleCookies(cmd);
case "screenshot":
return await handleScreenshot(cmd, workspace);
case "close-window":
return await handleCloseWindow(cmd, workspace);
case "sessions":
return await handleSessions(cmd);
default:
return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` };
}
} catch (err) {
return {
id: cmd.id,
ok: false,
error: err instanceof Error ? err.message : String(err)
};
}
const workspace = getWorkspaceKey(cmd.workspace);
resetWindowIdleTimer(workspace);
try {
switch (cmd.action) {
case "exec": return await handleExec(cmd, workspace);
case "navigate": return await handleNavigate(cmd, workspace);
case "tabs": return await handleTabs(cmd, workspace);
case "cookies": return await handleCookies(cmd);
case "screenshot": return await handleScreenshot(cmd, workspace);
case "close-window": return await handleCloseWindow(cmd, workspace);
case "sessions": return await handleSessions(cmd);
default: return {
id: cmd.id,
ok: false,
error: `Unknown action: ${cmd.action}`
};
}
} catch (err) {
return {
id: cmd.id,
ok: false,
error: err instanceof Error ? err.message : String(err)
};
}
}
function isWebUrl(url) {
if (!url) return false;
return !url.startsWith("chrome://") && !url.startsWith("chrome-extension://");
/** Check if a URL can be attached via CDP (not chrome:// or chrome-extension://) */
function isDebuggableUrl(url) {
if (!url) return true;
return !url.startsWith("chrome://") && !url.startsWith("chrome-extension://");
}
/**
* 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.
*/
async function resolveTabId(tabId, workspace) {
if (tabId !== void 0) return tabId;
const windowId = await getAutomationWindow(workspace);
const tabs = await chrome.tabs.query({ windowId });
const webTab = tabs.find((t) => t.id && isWebUrl(t.url));
if (webTab?.id) return webTab.id;
if (tabs.length > 0 && tabs[0]?.id) return tabs[0].id;
const newTab = await chrome.tabs.create({ windowId, url: "about:blank", active: true });
if (!newTab.id) throw new Error("Failed to create tab in automation window");
return newTab.id;
if (tabId !== void 0) try {
const tab = await chrome.tabs.get(tabId);
if (isDebuggableUrl(tab.url)) return tabId;
console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`);
} catch {
console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`);
}
const windowId = await getAutomationWindow(workspace);
const tabs = await chrome.tabs.query({ windowId });
const debuggableTab = tabs.find((t) => t.id && isDebuggableUrl(t.url));
if (debuggableTab?.id) return debuggableTab.id;
const reuseTab = tabs.find((t) => t.id);
if (reuseTab?.id) {
await chrome.tabs.update(reuseTab.id, { url: "data:text/html,<html></html>" });
await new Promise((resolve) => setTimeout(resolve, 300));
try {
const updated = await chrome.tabs.get(reuseTab.id);
if (isDebuggableUrl(updated.url)) return reuseTab.id;
console.warn(`[opencli] data: URI was intercepted (${updated.url}), creating fresh tab`);
} catch {}
}
const newTab = await chrome.tabs.create({
windowId,
url: "data:text/html,<html></html>",
active: true
});
if (!newTab.id) throw new Error("Failed to create tab in automation window");
return newTab.id;
}
async function listAutomationTabs(workspace) {
const session = automationSessions.get(workspace);
if (!session) return [];
try {
return await chrome.tabs.query({ windowId: session.windowId });
} catch {
automationSessions.delete(workspace);
return [];
}
const session = automationSessions.get(workspace);
if (!session) return [];
try {
return await chrome.tabs.query({ windowId: session.windowId });
} catch {
automationSessions.delete(workspace);
return [];
}
}
async function listAutomationWebTabs(workspace) {
const tabs = await listAutomationTabs(workspace);
return tabs.filter((tab) => isWebUrl(tab.url));
return (await listAutomationTabs(workspace)).filter((tab) => isDebuggableUrl(tab.url));
}
async function handleExec(cmd, workspace) {
if (!cmd.code) return { id: cmd.id, ok: false, error: "Missing code" };
const tabId = await resolveTabId(cmd.tabId, workspace);
try {
const data = await evaluateAsync(tabId, cmd.code);
return { id: cmd.id, ok: true, data };
} catch (err) {
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
}
if (!cmd.code) return {
id: cmd.id,
ok: false,
error: "Missing code"
};
const tabId = await resolveTabId(cmd.tabId, workspace);
try {
const data = await evaluateAsync(tabId, cmd.code);
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 handleNavigate(cmd, workspace) {
if (!cmd.url) return { id: cmd.id, ok: false, error: "Missing url" };
const tabId = await resolveTabId(cmd.tabId, workspace);
await chrome.tabs.update(tabId, { url: cmd.url });
await new Promise((resolve) => {
chrome.tabs.get(tabId).then((tab2) => {
if (tab2.status === "complete") {
resolve();
return;
}
const listener = (id, info) => {
if (id === tabId && info.status === "complete") {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}
};
chrome.tabs.onUpdated.addListener(listener);
setTimeout(() => {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}, 15e3);
});
});
const tab = await chrome.tabs.get(tabId);
return { id: cmd.id, ok: true, data: { title: tab.title, url: tab.url, tabId } };
if (!cmd.url) return {
id: cmd.id,
ok: false,
error: "Missing url"
};
const tabId = await resolveTabId(cmd.tabId, workspace);
const beforeUrl = (await chrome.tabs.get(tabId)).url ?? "";
const targetUrl = cmd.url;
await detach(tabId);
await chrome.tabs.update(tabId, { url: targetUrl });
let timedOut = false;
await new Promise((resolve) => {
let urlChanged = false;
const listener = (id, info, tab) => {
if (id !== tabId) return;
if (info.url && info.url !== beforeUrl) urlChanged = true;
if (urlChanged && info.status === "complete") {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}
};
chrome.tabs.onUpdated.addListener(listener);
setTimeout(async () => {
try {
const currentTab = await chrome.tabs.get(tabId);
if (currentTab.url !== beforeUrl && currentTab.status === "complete") {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}
} catch {}
}, 100);
setTimeout(() => {
chrome.tabs.onUpdated.removeListener(listener);
timedOut = true;
console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`);
resolve();
}, 15e3);
});
const tab = await chrome.tabs.get(tabId);
return {
id: cmd.id,
ok: true,
data: {
title: tab.title,
url: tab.url,
tabId,
timedOut
}
};
}
async function handleTabs(cmd, workspace) {
switch (cmd.op) {
case "list": {
const tabs = await listAutomationWebTabs(workspace);
const data = tabs.map((t, i) => ({
index: i,
tabId: t.id,
url: t.url,
title: t.title,
active: t.active
}));
return { id: cmd.id, ok: true, data };
}
case "new": {
const windowId = await getAutomationWindow(workspace);
const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? "about:blank", active: true });
return { id: cmd.id, ok: true, data: { tabId: tab.id, url: tab.url } };
}
case "close": {
if (cmd.index !== void 0) {
const tabs = await listAutomationWebTabs(workspace);
const target = tabs[cmd.index];
if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
await chrome.tabs.remove(target.id);
detach(target.id);
return { id: cmd.id, ok: true, data: { closed: target.id } };
}
const tabId = await resolveTabId(cmd.tabId, workspace);
await chrome.tabs.remove(tabId);
detach(tabId);
return { id: cmd.id, ok: true, data: { closed: tabId } };
}
case "select": {
if (cmd.index === void 0 && cmd.tabId === void 0)
return { id: cmd.id, ok: false, error: "Missing index or tabId" };
if (cmd.tabId !== void 0) {
await chrome.tabs.update(cmd.tabId, { active: true });
return { id: cmd.id, ok: true, data: { selected: cmd.tabId } };
}
const tabs = await listAutomationWebTabs(workspace);
const target = tabs[cmd.index];
if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
await chrome.tabs.update(target.id, { active: true });
return { id: cmd.id, ok: true, data: { selected: target.id } };
}
default:
return { id: cmd.id, ok: false, error: `Unknown tabs op: ${cmd.op}` };
}
switch (cmd.op) {
case "list": {
const data = (await listAutomationWebTabs(workspace)).map((t, i) => ({
index: i,
tabId: t.id,
url: t.url,
title: t.title,
active: t.active
}));
return {
id: cmd.id,
ok: true,
data
};
}
case "new": {
const windowId = await getAutomationWindow(workspace);
const tab = await chrome.tabs.create({
windowId,
url: cmd.url ?? "data:text/html,<html></html>",
active: true
});
return {
id: cmd.id,
ok: true,
data: {
tabId: tab.id,
url: tab.url
}
};
}
case "close": {
if (cmd.index !== void 0) {
const target = (await listAutomationWebTabs(workspace))[cmd.index];
if (!target?.id) return {
id: cmd.id,
ok: false,
error: `Tab index ${cmd.index} not found`
};
await chrome.tabs.remove(target.id);
await detach(target.id);
return {
id: cmd.id,
ok: true,
data: { closed: target.id }
};
}
const tabId = await resolveTabId(cmd.tabId, workspace);
await chrome.tabs.remove(tabId);
await detach(tabId);
return {
id: cmd.id,
ok: true,
data: { closed: tabId }
};
}
case "select": {
if (cmd.index === void 0 && cmd.tabId === void 0) return {
id: cmd.id,
ok: false,
error: "Missing index or tabId"
};
if (cmd.tabId !== void 0) {
await chrome.tabs.update(cmd.tabId, { active: true });
return {
id: cmd.id,
ok: true,
data: { selected: cmd.tabId }
};
}
const target = (await listAutomationWebTabs(workspace))[cmd.index];
if (!target?.id) return {
id: cmd.id,
ok: false,
error: `Tab index ${cmd.index} not found`
};
await chrome.tabs.update(target.id, { active: true });
return {
id: cmd.id,
ok: true,
data: { selected: target.id }
};
}
default: return {
id: cmd.id,
ok: false,
error: `Unknown tabs op: ${cmd.op}`
};
}
}
async function handleCookies(cmd) {
const details = {};
if (cmd.domain) details.domain = cmd.domain;
if (cmd.url) details.url = cmd.url;
const cookies = await chrome.cookies.getAll(details);
const data = cookies.map((c) => ({
name: c.name,
value: c.value,
domain: c.domain,
path: c.path,
secure: c.secure,
httpOnly: c.httpOnly,
expirationDate: c.expirationDate
}));
return { id: cmd.id, ok: true, data };
const details = {};
if (cmd.domain) details.domain = cmd.domain;
if (cmd.url) details.url = cmd.url;
const data = (await chrome.cookies.getAll(details)).map((c) => ({
name: c.name,
value: c.value,
domain: c.domain,
path: c.path,
secure: c.secure,
httpOnly: c.httpOnly,
expirationDate: c.expirationDate
}));
return {
id: cmd.id,
ok: true,
data
};
}
async function handleScreenshot(cmd, workspace) {
const tabId = await resolveTabId(cmd.tabId, workspace);
try {
const data = await screenshot(tabId, {
format: cmd.format,
quality: cmd.quality,
fullPage: cmd.fullPage
});
return { id: cmd.id, ok: true, data };
} catch (err) {
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
}
const tabId = await resolveTabId(cmd.tabId, workspace);
try {
const data = await screenshot(tabId, {
format: cmd.format,
quality: cmd.quality,
fullPage: cmd.fullPage
});
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, workspace) {
const session = automationSessions.get(workspace);
if (session) {
try {
await chrome.windows.remove(session.windowId);
} catch {
}
if (session.idleTimer) clearTimeout(session.idleTimer);
automationSessions.delete(workspace);
}
return { id: cmd.id, ok: true, data: { closed: true } };
const session = automationSessions.get(workspace);
if (session) {
try {
await chrome.windows.remove(session.windowId);
} catch {}
if (session.idleTimer) clearTimeout(session.idleTimer);
automationSessions.delete(workspace);
}
return {
id: cmd.id,
ok: true,
data: { closed: true }
};
}
async function handleSessions(cmd) {
const now = Date.now();
const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({
workspace,
windowId: session.windowId,
tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isWebUrl(tab.url)).length,
idleMsRemaining: Math.max(0, session.idleDeadlineAt - now)
})));
return { id: cmd.id, ok: true, data };
const now = Date.now();
const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({
workspace,
windowId: session.windowId,
tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isDebuggableUrl(tab.url)).length,
idleMsRemaining: Math.max(0, session.idleDeadlineAt - now)
})));
return {
id: cmd.id,
ok: true,
data
};
}
//#endregion
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "OpenCLI",
"version": "0.2.0",
"version": "1.2.6",
"description": "Bridge between opencli CLI and your browser — execute commands, read cookies, manage tabs.",
"permissions": [
"debugger",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "opencli-extension",
"version": "0.2.0",
"version": "1.2.6",
"private": true,
"type": "module",
"scripts": {
+103 -36
View File
@@ -88,7 +88,7 @@ function scheduleReconnect(): void {
// ─── Automation window isolation ─────────────────────────────────────
// All opencli operations happen in a dedicated Chrome window so the
// user's active browsing session is never touched.
// The window auto-closes after 30s of idle (no commands).
// The window auto-closes after 120s of idle (no commands).
type AutomationSession = {
windowId: number;
@@ -97,7 +97,7 @@ type AutomationSession = {
};
const automationSessions = new Map<string, AutomationSession>();
const WINDOW_IDLE_TIMEOUT = 30000; // 30s
const WINDOW_IDLE_TIMEOUT = 120000; // 120s — longer to survive slow pipelines
function getWorkspaceKey(workspace?: string): string {
return workspace?.trim() || 'default';
@@ -135,9 +135,10 @@ async function getAutomationWindow(workspace: string): Promise<number> {
}
}
// Create a new window with about:blank (not chrome://newtab which blocks scripting)
// 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".
const win = await chrome.windows.create({
url: 'about:blank',
url: 'data:text/html,<html></html>',
focused: false,
width: 1280,
height: 900,
@@ -151,6 +152,8 @@ async function getAutomationWindow(workspace: string): Promise<number> {
automationSessions.set(workspace, session);
console.log(`[opencli] Created automation window ${session.windowId} (${workspace})`);
resetWindowIdleTimer(workspace);
// Brief delay to let Chrome load the initial data: URI tab
await new Promise(resolve => setTimeout(resolve, 200));
return session.windowId;
}
@@ -226,9 +229,9 @@ async function handleCommand(cmd: Command): Promise<Result> {
// ─── Action handlers ─────────────────────────────────────────────────
/** Check if a URL is a debuggable web page (not chrome:// or extension page) */
function isWebUrl(url?: string): boolean {
if (!url) return false;
/** Check if a URL can be attached via CDP (not chrome:// or chrome-extension://) */
function isDebuggableUrl(url?: string): boolean {
if (!url) return true; // empty/undefined = tab still loading, allow it
return !url.startsWith('chrome://') && !url.startsWith('chrome-extension://');
}
@@ -238,21 +241,46 @@ function isWebUrl(url?: string): boolean {
* Otherwise, find or create a tab in the dedicated automation window.
*/
async function resolveTabId(tabId: number | undefined, workspace: string): Promise<number> {
if (tabId !== undefined) return tabId;
// 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);
if (isDebuggableUrl(tab.url)) return tabId;
// 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`);
}
}
// Get (or create) the automation window
const windowId = await getAutomationWindow(workspace);
// Find the active tab in our automation window
// Prefer an existing debuggable tab
const tabs = await chrome.tabs.query({ windowId });
const webTab = tabs.find(t => t.id && isWebUrl(t.url));
if (webTab?.id) return webTab.id;
const debuggableTab = tabs.find(t => t.id && isDebuggableUrl(t.url));
if (debuggableTab?.id) return debuggableTab.id;
// Use the first tab if it's a blank/new tab page
if (tabs.length > 0 && tabs[0]?.id) return tabs[0].id;
// 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: 'data:text/html,<html></html>' });
await new Promise(resolve => setTimeout(resolve, 300));
try {
const updated = await chrome.tabs.get(reuseTab.id);
if (isDebuggableUrl(updated.url)) return reuseTab.id;
console.warn(`[opencli] data: URI was intercepted (${updated.url}), creating fresh tab`);
} catch {
// Tab was closed during navigation
}
}
// No suitable tab — create one
const newTab = await chrome.tabs.create({ windowId, url: 'about:blank', active: true });
// Fallback: create a new tab
const newTab = await chrome.tabs.create({ windowId, url: 'data:text/html,<html></html>', active: true });
if (!newTab.id) throw new Error('Failed to create tab in automation window');
return newTab.id;
}
@@ -270,7 +298,7 @@ async function listAutomationTabs(workspace: string): Promise<chrome.tabs.Tab[]>
async function listAutomationWebTabs(workspace: string): Promise<chrome.tabs.Tab[]> {
const tabs = await listAutomationTabs(workspace);
return tabs.filter((tab) => isWebUrl(tab.url));
return tabs.filter((tab) => isDebuggableUrl(tab.url));
}
async function handleExec(cmd: Command, workspace: string): Promise<Result> {
@@ -287,31 +315,70 @@ async function handleExec(cmd: Command, workspace: string): Promise<Result> {
async function handleNavigate(cmd: Command, workspace: string): Promise<Result> {
if (!cmd.url) return { id: cmd.id, ok: false, error: 'Missing url' };
const tabId = await resolveTabId(cmd.tabId, workspace);
await chrome.tabs.update(tabId, { url: cmd.url });
// Wait for page to finish loading, checking current status first to avoid race
// Capture the current URL before navigation to detect actual URL change
const beforeTab = await chrome.tabs.get(tabId);
const beforeUrl = beforeTab.url ?? '';
const targetUrl = cmd.url;
// Detach any existing debugger before top-level navigation.
// Some sites (observed on creator.xiaohongshu.com flows) can invalidate the
// current inspected target during navigation, which leaves a stale CDP attach
// state and causes the next Runtime.evaluate to fail with
// "Inspected target navigated or closed". Resetting here forces a clean
// re-attach after navigation.
await executor.detach(tabId);
await chrome.tabs.update(tabId, { url: targetUrl });
// Wait for: 1) URL to change from the old URL, 2) tab.status === 'complete'
// This avoids the race where 'complete' fires for the OLD URL (e.g. about:blank)
let timedOut = false;
await new Promise<void>((resolve) => {
// Check if already complete (e.g. cached pages)
chrome.tabs.get(tabId).then(tab => {
if (tab.status === 'complete') { resolve(); return; }
let urlChanged = false;
const listener = (id: number, info: chrome.tabs.TabChangeInfo) => {
if (id === tabId && info.status === 'complete') {
const listener = (id: number, info: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) => {
if (id !== tabId) return;
// Track URL change (new URL differs from the one before navigation)
if (info.url && info.url !== beforeUrl) {
urlChanged = true;
}
// Only resolve when both URL has changed AND status is complete
if (urlChanged && info.status === 'complete') {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}
};
chrome.tabs.onUpdated.addListener(listener);
// Also check if the tab already navigated (e.g. instant cache hit)
setTimeout(async () => {
try {
const currentTab = await chrome.tabs.get(tabId);
if (currentTab.url !== beforeUrl && currentTab.status === 'complete') {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}
};
chrome.tabs.onUpdated.addListener(listener);
// Timeout fallback
setTimeout(() => {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}, 15000);
});
} catch { /* tab gone */ }
}, 100);
// Timeout fallback with warning
setTimeout(() => {
chrome.tabs.onUpdated.removeListener(listener);
timedOut = true;
console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`);
resolve();
}, 15000);
});
const tab = await chrome.tabs.get(tabId);
return { id: cmd.id, ok: true, data: { title: tab.title, url: tab.url, tabId } };
return {
id: cmd.id,
ok: true,
data: { title: tab.title, url: tab.url, tabId, timedOut },
};
}
async function handleTabs(cmd: Command, workspace: string): Promise<Result> {
@@ -330,7 +397,7 @@ async function handleTabs(cmd: Command, workspace: string): Promise<Result> {
}
case 'new': {
const windowId = await getAutomationWindow(workspace);
const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? 'about:blank', active: true });
const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? 'data:text/html,<html></html>', active: true });
return { id: cmd.id, ok: true, data: { tabId: tab.id, url: tab.url } };
}
case 'close': {
@@ -339,12 +406,12 @@ async function handleTabs(cmd: Command, workspace: string): Promise<Result> {
const target = tabs[cmd.index];
if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
await chrome.tabs.remove(target.id);
executor.detach(target.id);
await executor.detach(target.id);
return { id: cmd.id, ok: true, data: { closed: target.id } };
}
const tabId = await resolveTabId(cmd.tabId, workspace);
await chrome.tabs.remove(tabId);
executor.detach(tabId);
await executor.detach(tabId);
return { id: cmd.id, ok: true, data: { closed: tabId } };
}
case 'select': {
@@ -415,7 +482,7 @@ async function handleSessions(cmd: Command): Promise<Result> {
const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({
workspace,
windowId: session.windowId,
tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isWebUrl(tab.url)).length,
tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isDebuggableUrl(tab.url)).length,
idleMsRemaining: Math.max(0, session.idleDeadlineAt - now),
})));
return { id: cmd.id, ok: true, data };
+46 -5
View File
@@ -8,22 +8,57 @@
const attached = new Set<number>();
/** Check if a URL can be attached via CDP */
function isDebuggableUrl(url?: string): boolean {
if (!url) return true; // empty/undefined = tab still loading, allow it
return !url.startsWith('chrome://') && !url.startsWith('chrome-extension://');
}
async function ensureAttached(tabId: number): Promise<void> {
if (attached.has(tabId)) return;
// Verify the tab URL is debuggable before attempting attach
try {
const tab = await chrome.tabs.get(tabId);
if (!isDebuggableUrl(tab.url)) {
// Invalidate cache if previously attached
attached.delete(tabId);
throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? 'unknown'}`);
}
} catch (e) {
// Re-throw our own error, catch only chrome.tabs.get failures
if (e instanceof Error && e.message.startsWith('Cannot debug tab')) throw e;
attached.delete(tabId);
throw new Error(`Tab ${tabId} no longer exists`);
}
if (attached.has(tabId)) {
// Verify the debugger is still actually attached by sending a harmless command
try {
await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
expression: '1', returnByValue: true,
});
return; // Still attached and working
} catch {
// Stale cache entry — need to re-attach
attached.delete(tabId);
}
}
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://')
? '. 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}`);
throw new Error(`attach failed: ${msg}${hint}`);
}
} else {
throw new Error(`attach failed: ${msg}`);
throw new Error(`attach failed: ${msg}${hint}`);
}
}
attached.add(tabId);
@@ -109,10 +144,10 @@ export async function screenshot(
}
}
export function detach(tabId: number): void {
export async function detach(tabId: number): Promise<void> {
if (!attached.has(tabId)) return;
attached.delete(tabId);
try { chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
try { await chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
}
export function registerListeners(): void {
@@ -122,4 +157,10 @@ export function registerListeners(): void {
chrome.debugger.onDetach.addListener((source) => {
if (source.tabId) attached.delete(source.tabId);
});
// Invalidate attached cache when tab URL changes to non-debuggable
chrome.tabs.onUpdated.addListener(async (tabId, info) => {
if (info.url && !isDebuggableUrl(info.url)) {
await detach(tabId);
}
});
}
+160 -147
View File
@@ -1,12 +1,12 @@
{
"name": "@jackwener/opencli",
"version": "1.1.1",
"version": "1.3.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@jackwener/opencli",
"version": "1.1.1",
"version": "1.3.3",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -14,6 +14,7 @@
"cli-table3": "^0.6.5",
"commander": "^14.0.3",
"js-yaml": "^4.1.0",
"turndown": "^7.2.2",
"ws": "^8.18.0"
},
"bin": {
@@ -22,9 +23,10 @@
"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": "^5.8.2",
"typescript": "^6.0.2",
"vitepress": "^1.6.4",
"vitest": "^4.1.0"
},
@@ -402,9 +404,9 @@
}
},
"node_modules/@emnapi/core": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz",
"integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
"integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -414,9 +416,9 @@
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz",
"integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
"integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -901,6 +903,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@mixmark-io/domino": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
"license": "BSD-2-Clause"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
@@ -918,20 +926,10 @@
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@oxc-project/runtime": {
"version": "0.115.0",
"resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz",
"integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-project/types": {
"version": "0.115.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz",
"integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==",
"version": "0.122.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz",
"integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -939,9 +937,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz",
"integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==",
"version": "1.0.0-rc.11",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.11.tgz",
"integrity": "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A==",
"cpu": [
"arm64"
],
@@ -956,9 +954,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz",
"integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==",
"version": "1.0.0-rc.11",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.11.tgz",
"integrity": "sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg==",
"cpu": [
"arm64"
],
@@ -973,9 +971,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz",
"integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==",
"version": "1.0.0-rc.11",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.11.tgz",
"integrity": "sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ==",
"cpu": [
"x64"
],
@@ -990,9 +988,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz",
"integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==",
"version": "1.0.0-rc.11",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.11.tgz",
"integrity": "sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag==",
"cpu": [
"x64"
],
@@ -1007,9 +1005,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz",
"integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==",
"version": "1.0.0-rc.11",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.11.tgz",
"integrity": "sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ==",
"cpu": [
"arm"
],
@@ -1024,9 +1022,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz",
"integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==",
"version": "1.0.0-rc.11",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.11.tgz",
"integrity": "sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q==",
"cpu": [
"arm64"
],
@@ -1041,9 +1039,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz",
"integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==",
"version": "1.0.0-rc.11",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.11.tgz",
"integrity": "sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg==",
"cpu": [
"arm64"
],
@@ -1058,9 +1056,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz",
"integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==",
"version": "1.0.0-rc.11",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.11.tgz",
"integrity": "sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw==",
"cpu": [
"ppc64"
],
@@ -1075,9 +1073,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz",
"integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==",
"version": "1.0.0-rc.11",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.11.tgz",
"integrity": "sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw==",
"cpu": [
"s390x"
],
@@ -1092,9 +1090,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz",
"integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==",
"version": "1.0.0-rc.11",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.11.tgz",
"integrity": "sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA==",
"cpu": [
"x64"
],
@@ -1109,9 +1107,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz",
"integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==",
"version": "1.0.0-rc.11",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.11.tgz",
"integrity": "sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg==",
"cpu": [
"x64"
],
@@ -1126,9 +1124,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz",
"integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==",
"version": "1.0.0-rc.11",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.11.tgz",
"integrity": "sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g==",
"cpu": [
"arm64"
],
@@ -1143,9 +1141,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz",
"integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==",
"version": "1.0.0-rc.11",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.11.tgz",
"integrity": "sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw==",
"cpu": [
"wasm32"
],
@@ -1160,9 +1158,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz",
"integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==",
"version": "1.0.0-rc.11",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.11.tgz",
"integrity": "sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q==",
"cpu": [
"arm64"
],
@@ -1177,9 +1175,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz",
"integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==",
"version": "1.0.0-rc.11",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.11.tgz",
"integrity": "sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA==",
"cpu": [
"x64"
],
@@ -1194,9 +1192,9 @@
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz",
"integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==",
"version": "1.0.0-rc.11",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.11.tgz",
"integrity": "sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ==",
"dev": true,
"license": "MIT"
},
@@ -1742,6 +1740,13 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/turndown": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz",
"integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -1774,16 +1779,16 @@
"license": "ISC"
},
"node_modules/@vitest/expect": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz",
"integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.1.tgz",
"integrity": "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.0",
"@vitest/utils": "4.1.0",
"@vitest/spy": "4.1.1",
"@vitest/utils": "4.1.1",
"chai": "^6.2.2",
"tinyrainbow": "^3.0.3"
},
@@ -1792,13 +1797,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz",
"integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.1.tgz",
"integrity": "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.0",
"@vitest/spy": "4.1.1",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -1807,7 +1812,7 @@
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0"
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
@@ -1819,9 +1824,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz",
"integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.1.tgz",
"integrity": "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1832,13 +1837,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz",
"integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.1.tgz",
"integrity": "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.0",
"@vitest/utils": "4.1.1",
"pathe": "^2.0.3"
},
"funding": {
@@ -1846,14 +1851,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz",
"integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.1.tgz",
"integrity": "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.0",
"@vitest/utils": "4.1.0",
"@vitest/pretty-format": "4.1.1",
"@vitest/utils": "4.1.1",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -1862,9 +1867,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz",
"integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.1.tgz",
"integrity": "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -1872,13 +1877,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz",
"integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.1.tgz",
"integrity": "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.0",
"@vitest/pretty-format": "4.1.1",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.0.3"
},
@@ -3187,14 +3192,14 @@
"license": "MIT"
},
"node_modules/rolldown": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz",
"integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==",
"version": "1.0.0-rc.11",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.11.tgz",
"integrity": "sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.115.0",
"@rolldown/pluginutils": "1.0.0-rc.9"
"@oxc-project/types": "=0.122.0",
"@rolldown/pluginutils": "1.0.0-rc.11"
},
"bin": {
"rolldown": "bin/cli.mjs"
@@ -3203,21 +3208,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.9",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.9",
"@rolldown/binding-darwin-x64": "1.0.0-rc.9",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.9",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.9",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.9",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.9",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9"
"@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"
}
},
"node_modules/rollup": {
@@ -3486,10 +3491,19 @@
"fsevents": "~2.3.3"
}
},
"node_modules/turndown": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.2.tgz",
"integrity": "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==",
"license": "MIT",
"dependencies": {
"@mixmark-io/domino": "^2.2.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -3611,17 +3625,16 @@
}
},
"node_modules/vite": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz",
"integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==",
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.2.tgz",
"integrity": "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/runtime": "0.115.0",
"lightningcss": "^1.32.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.8",
"rolldown": "1.0.0-rc.9",
"rolldown": "1.0.0-rc.11",
"tinyglobby": "^0.2.15"
},
"bin": {
@@ -3638,7 +3651,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.0.0-alpha.31",
"@vitejs/devtools": "^0.1.0",
"esbuild": "^0.27.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
@@ -4236,19 +4249,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz",
"integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.1.tgz",
"integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.0",
"@vitest/mocker": "4.1.0",
"@vitest/pretty-format": "4.1.0",
"@vitest/runner": "4.1.0",
"@vitest/snapshot": "4.1.0",
"@vitest/spy": "4.1.0",
"@vitest/utils": "4.1.0",
"@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",
@@ -4260,7 +4273,7 @@
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.0.3",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
@@ -4276,13 +4289,13 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.0",
"@vitest/browser-preview": "4.1.0",
"@vitest/browser-webdriverio": "4.1.0",
"@vitest/ui": "4.1.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-0"
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
@@ -4357,9 +4370,9 @@
}
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
+14 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@jackwener/opencli",
"version": "1.1.1",
"version": "1.3.3",
"publishConfig": {
"access": "public"
},
@@ -13,10 +13,15 @@
"bin": {
"opencli": "dist/main.js"
},
"exports": {
".": "./dist/main.js",
"./registry": "./dist/registry-api.js"
},
"scripts": {
"dev": "tsx src/main.ts",
"build": "tsc && npm run clean-yaml && npm run copy-yaml && npm run build-manifest",
"build": "npm run clean-dist && tsc && npm run clean-yaml && npm run copy-yaml && npm run build-manifest",
"build-manifest": "node dist/build-manifest.js",
"clean-dist": "node scripts/clean-dist.cjs",
"clean-yaml": "node scripts/clean-yaml.cjs",
"copy-yaml": "node scripts/copy-yaml.cjs",
"start": "node dist/main.js",
@@ -24,9 +29,10 @@
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit",
"prepublishOnly": "npm run build",
"test": "vitest run",
"test:site": "node scripts/test-site.mjs",
"test:watch": "vitest",
"test": "vitest run --project unit",
"test:adapter": "vitest run --project adapter",
"test:all": "vitest run",
"test:e2e": "vitest run --project e2e",
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs"
@@ -48,14 +54,16 @@
"cli-table3": "^0.6.5",
"commander": "^14.0.3",
"js-yaml": "^4.1.0",
"turndown": "^7.2.2",
"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": "^5.8.2",
"typescript": "^6.0.2",
"vitepress": "^1.6.4",
"vitest": "^4.1.0"
}
+2
View File
@@ -28,6 +28,8 @@ total=0
for adapter_dir in "$SRC_DIR"/*/; do
adapter_name="$(basename "$adapter_dir")"
# Skip internal directories (e.g., _shared)
[[ "$adapter_name" == _* ]] && continue
total=$((total + 1))
# Check if doc exists in browser/ or desktop/ subdirectories
+13
View File
@@ -0,0 +1,13 @@
/**
* Remove dist/ before a fresh build so deleted adapters do not leave stale
* compiled files behind in dist/clis/.
*/
const { existsSync, rmSync } = require('fs');
if (existsSync('dist')) {
rmSync('dist', { recursive: true, force: true });
}
if (existsSync('tsconfig.tsbuildinfo')) {
rmSync('tsconfig.tsbuildinfo', { force: true });
}
+51 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, it, expect, vi } from 'vitest';
import { BrowserBridge, __test__ } from './browser/index.js';
import { BrowserBridge, __test__, generateStealthJs } from './browser/index.js';
import * as daemonClient from './browser/daemon-client.js';
describe('browser helpers', () => {
@@ -133,3 +133,53 @@ describe('BrowserBridge state', () => {
await expect(mcp.connect()).rejects.toThrow('Browser Extension is not connected');
});
});
describe('stealth anti-detection', () => {
it('generates non-empty JS string', () => {
const js = generateStealthJs();
expect(typeof js).toBe('string');
expect(js.length).toBeGreaterThan(100);
});
it('contains all 7 anti-detection patches', () => {
const js = generateStealthJs();
// 1. webdriver
expect(js).toContain('navigator');
expect(js).toContain('webdriver');
// 2. chrome stub
expect(js).toContain('window.chrome');
// 3. plugins
expect(js).toContain('plugins');
expect(js).toContain('PDF Viewer');
// 4. languages
expect(js).toContain('languages');
// 5. permissions
expect(js).toContain('Permissions');
expect(js).toContain('notifications');
// 6. automation artifacts (dynamic cdc_ scan)
expect(js).toContain('__playwright');
expect(js).toContain('__puppeteer');
expect(js).toContain('getOwnPropertyNames');
expect(js).toContain('cdc_');
// 7. CDP stack trace cleanup
expect(js).toContain('Error.prototype');
expect(js).toContain('puppeteer_evaluation_script');
expect(js).toContain('getOwnPropertyDescriptor');
});
it('includes guard flag to prevent double-injection', () => {
const js = generateStealthJs();
// Guard uses a non-enumerable property on a built-in prototype
expect(js).toContain("EventTarget.prototype");
// Guard should check early and return 'skipped'
expect(js).toContain("return 'skipped'");
// Normal path returns 'applied'
expect(js).toContain("return 'applied'");
});
it('generates syntactically valid JS', () => {
const js = generateStealthJs();
// Should not throw when parsed
expect(() => new Function(js)).not.toThrow();
});
});
+92 -42
View File
@@ -9,8 +9,10 @@
*/
import { WebSocket, type RawData } from 'ws';
import type { IPage } from '../types.js';
import type { BrowserCookie, IPage, ScreenshotOptions, SnapshotOptions, WaitOptions } from '../types.js';
import { wrapForEval } from './utils.js';
import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
import { generateStealthJs } from './stealth.js';
import {
clickJs,
typeTextJs,
@@ -19,6 +21,7 @@ import {
scrollJs,
autoScrollJs,
networkRequestsJs,
waitForDomStableJs,
} from './dom-helpers.js';
export interface CDPTarget {
@@ -28,15 +31,28 @@ export interface CDPTarget {
webSocketDebuggerUrl?: string;
}
interface RuntimeEvaluateResult {
result?: {
value?: unknown;
};
exceptionDetails?: {
exception?: {
description?: string;
};
};
}
const CDP_SEND_TIMEOUT = 30_000; // 30s per command
export class CDPBridge {
private _ws: WebSocket | null = null;
private _idCounter = 0;
private _pending = new Map<number, { resolve: (val: any) => void; reject: (err: Error) => void; timer: ReturnType<typeof setTimeout> }>();
private _eventListeners = new Map<string, Set<(params: any) => void>>();
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> {
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');
@@ -55,11 +71,19 @@ export class CDPBridge {
return new Promise((resolve, reject) => {
const ws = new WebSocket(wsUrl);
const timeout = setTimeout(() => reject(new Error('CDP connect timeout')), opts?.timeout ?? 10000);
const timeoutMs = (opts?.timeout ?? 10) * 1000; // opts.timeout is in seconds
const timeout = setTimeout(() => reject(new Error('CDP connect timeout')), timeoutMs);
ws.on('open', () => {
ws.on('open', async () => {
clearTimeout(timeout);
this._ws = ws;
// Register stealth script to run before any page JS on every navigation.
try {
await this.send('Page.enable');
await this.send('Page.addScriptToEvaluateOnNewDocument', { source: generateStealthJs() });
} catch {
// Non-fatal: stealth is best-effort
}
resolve(new CDPPage(this));
});
@@ -110,7 +134,7 @@ export class CDPBridge {
}
/** Send a CDP command with timeout guard (P0 fix #4) */
async send(method: string, params: any = {}, timeoutMs: number = CDP_SEND_TIMEOUT): Promise<any> {
async send(method: string, params: Record<string, unknown> = {}, timeoutMs: number = CDP_SEND_TIMEOUT): Promise<unknown> {
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) {
throw new Error('CDP connection is not open');
}
@@ -126,25 +150,25 @@ export class CDPBridge {
}
/** Listen for a CDP event */
on(event: string, handler: (params: any) => void): void {
on(event: string, handler: (params: unknown) => void): void {
let set = this._eventListeners.get(event);
if (!set) { set = new Set(); this._eventListeners.set(event, set); }
set.add(handler);
}
/** Remove a CDP event listener */
off(event: string, handler: (params: any) => void): void {
off(event: string, handler: (params: unknown) => void): void {
this._eventListeners.get(event)?.delete(handler);
}
/** Wait for a CDP event to fire (one-shot) */
waitForEvent(event: string, timeoutMs: number = 15_000): Promise<any> {
waitForEvent(event: string, timeoutMs: number = 15_000): Promise<unknown> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.off(event, handler);
reject(new Error(`Timed out waiting for CDP event '${event}'`));
}, timeoutMs);
const handler = (params: any) => {
const handler = (params: unknown) => {
clearTimeout(timer);
this.off(event, handler);
resolve(params);
@@ -155,46 +179,59 @@ export class CDPBridge {
}
class CDPPage implements IPage {
private _pageEnabled = false;
constructor(private bridge: CDPBridge) {}
/** Navigate with proper load event waiting (P1 fix #3) */
async goto(url: string, options?: { waitUntil?: 'load' | 'none'; settleMs?: number }): Promise<void> {
await this.bridge.send('Page.enable');
if (!this._pageEnabled) {
await this.bridge.send('Page.enable');
this._pageEnabled = true;
}
const loadPromise = this.bridge.waitForEvent('Page.loadEventFired', 30_000)
.catch(() => {}); // Don't fail if event times out
.catch(() => {}); // Don't fail if load event times out — page may be an SPA
await this.bridge.send('Page.navigate', { url });
await loadPromise;
// Post-load settle: SPA frameworks need extra time to render after load event
// Smart settle: use DOM stability detection instead of fixed sleep.
// settleMs is now a timeout cap (default 1000ms), not a fixed wait.
if (options?.waitUntil !== 'none') {
const settleMs = options?.settleMs ?? 1000;
await new Promise(resolve => setTimeout(resolve, settleMs));
const maxMs = options?.settleMs ?? 1000;
await this.evaluate(waitForDomStableJs(maxMs, Math.min(500, maxMs)));
}
}
async evaluate(js: string): Promise<any> {
async evaluate(js: string): Promise<unknown> {
const expression = wrapForEval(js);
const result = await this.bridge.send('Runtime.evaluate', {
expression,
returnByValue: true,
awaitPromise: true
});
}) as RuntimeEvaluateResult;
if (result.exceptionDetails) {
throw new Error('Evaluate error: ' + (result.exceptionDetails.exception?.description || 'Unknown exception'));
}
return result.result?.value;
}
async getCookies(opts: { domain?: string; url?: string } = {}): Promise<any[]> {
async getCookies(opts: { domain?: string; url?: string } = {}): Promise<BrowserCookie[]> {
const result = await this.bridge.send('Network.getCookies', opts.url ? { urls: [opts.url] } : {});
const cookies = Array.isArray(result?.cookies) ? result.cookies : [];
return opts.domain
? cookies.filter((cookie: any) => typeof cookie.domain === 'string' && cookie.domain.includes(opts.domain!))
const cookies = isRecord(result) && Array.isArray(result.cookies) ? result.cookies : [];
const domain = opts.domain;
return domain
? cookies.filter((cookie): cookie is BrowserCookie => isCookie(cookie) && cookie.domain.includes(domain))
: cookies;
}
async snapshot(_opts?: any): Promise<any> {
// CDP doesn't have a built-in accessibility tree equivalent without additional setup
return '(snapshot not available in CDP mode)';
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);
}
// ── Shared DOM operations (P1 fix #5 — using dom-helpers.ts) ──
@@ -211,13 +248,22 @@ class CDPPage implements IPage {
await this.evaluate(pressKeyJs(key));
}
async wait(options: any): Promise<void> {
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') {
await new Promise(resolve => setTimeout(resolve, options * 1000));
return;
}
if (options.time) {
await new Promise(resolve => setTimeout(resolve, options.time * 1000));
if (typeof options.time === 'number') {
const waitTime = options.time;
await new Promise(resolve => setTimeout(resolve, waitTime * 1000));
return;
}
if (options.text) {
@@ -238,28 +284,25 @@ class CDPPage implements IPage {
await this.evaluate(autoScrollJs(times, delayMs));
}
async screenshot(options: any = {}): Promise<string> {
async screenshot(options: ScreenshotOptions = {}): Promise<string> {
const result = await this.bridge.send('Page.captureScreenshot', {
format: options.format ?? 'png',
quality: options.format === 'jpeg' ? (options.quality ?? 80) : undefined,
captureBeyondViewport: options.fullPage ?? false,
});
const base64 = result.data;
const base64 = isRecord(result) && typeof result.data === 'string' ? result.data : '';
if (options.path) {
const fs = await import('node:fs');
const path = await import('node:path');
const dir = path.dirname(options.path);
await fs.promises.mkdir(dir, { recursive: true });
await fs.promises.writeFile(options.path, Buffer.from(base64, 'base64'));
await saveBase64ToFile(base64, options.path);
}
return base64;
}
async networkRequests(includeStatic: boolean = false): Promise<any> {
return this.evaluate(networkRequestsJs(includeStatic));
async networkRequests(includeStatic: boolean = false): Promise<unknown[]> {
const result = await this.evaluate(networkRequestsJs(includeStatic));
return Array.isArray(result) ? result : [];
}
async tabs(): Promise<any> {
async tabs(): Promise<unknown[]> {
return [];
}
@@ -275,7 +318,7 @@ class CDPPage implements IPage {
// Not supported in direct CDP mode
}
async consoleMessages(_level?: string): Promise<any> {
async consoleMessages(_level?: string): Promise<unknown[]> {
return [];
}
@@ -287,13 +330,22 @@ class CDPPage implements IPage {
}));
}
async getInterceptedRequests(): Promise<any[]> {
async getInterceptedRequests(): Promise<unknown[]> {
const { generateReadInterceptedJs } = await import('../interceptor.js');
const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
return (result as any[]) || [];
return Array.isArray(result) ? result : [];
}
}
import { isRecord, saveBase64ToFile } from '../utils.js';
function isCookie(value: unknown): value is BrowserCookie {
return isRecord(value)
&& typeof value.name === 'string'
&& typeof value.value === 'string'
&& typeof value.domain === 'string';
}
// ── CDP target selection (unchanged) ──
function selectCDPTarget(targets: CDPTarget[]): CDPTarget | undefined {
@@ -343,7 +395,6 @@ function scoreCDPTarget(target: CDPTarget, preferredPattern?: RegExp): number {
if (title.includes('chatwise')) score += 120;
if (title.includes('notion')) score += 120;
if (title.includes('discord')) score += 120;
if (title.includes('netease')) score += 120;
if (url.includes('antigravity')) score += 100;
if (url.includes('codex')) score += 100;
@@ -351,7 +402,6 @@ function scoreCDPTarget(target: CDPTarget, preferredPattern?: RegExp): number {
if (url.includes('chatwise')) score += 100;
if (url.includes('notion')) score += 100;
if (url.includes('discord')) score += 100;
if (url.includes('netease')) score += 100;
return score;
}
+32 -9
View File
@@ -4,7 +4,10 @@
* Provides a typed send() function that posts a Command and returns a Result.
*/
const DAEMON_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? '19825', 10);
import { DEFAULT_DAEMON_PORT } from '../constants.js';
import type { BrowserSessionInfo } from '../types.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}`;
let _idCounter = 0;
@@ -42,7 +45,10 @@ export async function isDaemonRunning(): Promise<boolean> {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2000);
const res = await fetch(`${DAEMON_URL}/status`, { signal: controller.signal });
const res = await fetch(`${DAEMON_URL}/status`, {
headers: { 'X-OpenCLI': '1' },
signal: controller.signal,
});
clearTimeout(timer);
return res.ok;
} catch {
@@ -57,7 +63,10 @@ export async function isExtensionConnected(): Promise<boolean> {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2000);
const res = await fetch(`${DAEMON_URL}/status`, { signal: controller.signal });
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 };
@@ -69,24 +78,26 @@ export async function isExtensionConnected(): Promise<boolean> {
/**
* Send a command to the daemon and wait for a result.
* Retries up to 3 times with 500ms delay for transient failures.
* Retries up to 4 times: network errors retry at 500ms,
* transient extension errors retry at 1500ms.
*/
export async function sendCommand(
action: DaemonCommand['action'],
params: Omit<DaemonCommand, 'id' | 'action'> = {},
): Promise<unknown> {
const id = generateId();
const command: DaemonCommand = { id, action, ...params };
const maxRetries = 3;
const maxRetries = 4;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
// Generate a fresh ID per attempt to avoid daemon-side duplicate detection
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`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', 'X-OpenCLI': '1' },
body: JSON.stringify(command),
signal: controller.signal,
});
@@ -95,6 +106,17 @@ export async function sendCommand(
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) {
// Longer delay for extension recovery (service worker restart)
await new Promise(r => setTimeout(r, 1500));
continue;
}
throw new Error(result.error ?? 'Daemon command failed');
}
@@ -113,7 +135,8 @@ export async function sendCommand(
throw new Error('sendCommand: max retries exhausted');
}
export async function listSessions(): Promise<any[]> {
export async function listSessions(): Promise<BrowserSessionInfo[]> {
const result = await sendCommand('sessions');
return Array.isArray(result) ? result : [];
}
+5 -2
View File
@@ -5,6 +5,7 @@
* scanning for @playwright/mcp locations.
*/
import { DEFAULT_DAEMON_PORT } from '../constants.js';
import { isDaemonRunning } from './daemon-client.js';
export { isDaemonRunning };
@@ -17,8 +18,10 @@ export async function checkDaemonStatus(): Promise<{
extensionConnected: boolean;
}> {
try {
const port = parseInt(process.env.OPENCLI_DAEMON_PORT ?? '19825', 10);
const res = await fetch(`http://127.0.0.1:${port}/status`);
const port = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
const res = await fetch(`http://127.0.0.1:${port}/status`, {
headers: { 'X-OpenCLI': '1' },
});
const data = await res.json() as { ok: boolean; extensionConnected: boolean };
return { running: true, extensionConnected: data.extensionConnected };
} catch {
+72 -7
View File
@@ -11,8 +11,21 @@ export function clickJs(ref: string): string {
return `
(() => {
const ref = ${safeRef};
const el = document.querySelector('[data-ref="' + ref + '"]')
|| document.querySelectorAll('a, button, input, [role="button"], [tabindex]')[parseInt(ref, 10) || 0];
// 1. data-opencli-ref (set by snapshot engine)
let el = document.querySelector('[data-opencli-ref="' + ref + '"]');
// 2. data-ref (legacy)
if (!el) el = document.querySelector('[data-ref="' + ref + '"]');
// 3. CSS selector
if (!el && ref.match(/^[a-zA-Z#.\\[]/)) {
try { el = document.querySelector(ref); } catch {}
}
// 4. Numeric index into interactive elements
if (!el) {
const idx = parseInt(ref, 10);
if (!isNaN(idx)) {
el = document.querySelectorAll('a, button, input, select, textarea, [role="button"], [tabindex]:not([tabindex="-1"])')[idx];
}
}
if (!el) throw new Error('Element not found: ' + ref);
el.scrollIntoView({ behavior: 'instant', block: 'center' });
el.click();
@@ -28,13 +41,31 @@ export function typeTextJs(ref: string, text: string): string {
return `
(() => {
const ref = ${safeRef};
const el = document.querySelector('[data-ref="' + ref + '"]')
|| document.querySelectorAll('input, textarea, [contenteditable]')[parseInt(ref, 10) || 0];
// 1. data-opencli-ref (set by snapshot engine)
let el = document.querySelector('[data-opencli-ref="' + ref + '"]');
// 2. data-ref (legacy)
if (!el) el = document.querySelector('[data-ref="' + ref + '"]');
// 3. CSS selector
if (!el && ref.match(/^[a-zA-Z#.\\[]/)) {
try { el = document.querySelector(ref); } catch {}
}
// 4. Numeric index into typeable elements
if (!el) {
const idx = parseInt(ref, 10);
if (!isNaN(idx)) {
el = document.querySelectorAll('input, textarea, [contenteditable="true"]')[idx];
}
}
if (!el) throw new Error('Element not found: ' + ref);
el.focus();
el.value = ${safeText};
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
if (el.isContentEditable) {
el.textContent = ${safeText};
el.dispatchEvent(new Event('input', { bubbles: true }));
} else {
el.value = ${safeText};
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}
return 'typed';
})()
`;
@@ -114,3 +145,37 @@ export function networkRequestsJs(includeStatic: boolean): string {
})()
`;
}
/**
* Generate JS to wait until the DOM stabilizes (no mutations for `quietMs`),
* with a hard cap at `maxMs`. Uses MutationObserver in the browser.
*
* Returns as soon as the page stops changing, avoiding unnecessary fixed waits.
* If document.body is not available, falls back to a fixed sleep of maxMs.
*/
export function waitForDomStableJs(maxMs: number, quietMs: number): string {
return `
new Promise(resolve => {
if (!document.body) {
setTimeout(() => resolve('nobody'), ${maxMs});
return;
}
let timer = null;
let cap = null;
const done = (reason) => {
clearTimeout(timer);
clearTimeout(cap);
obs.disconnect();
resolve(reason);
};
const resetQuiet = () => {
clearTimeout(timer);
timer = setTimeout(() => done('quiet'), ${quietMs});
};
const obs = new MutationObserver(resetQuiet);
obs.observe(document.body, { childList: true, subtree: true, attributes: true });
resetQuiet();
cap = setTimeout(() => done('capped'), ${maxMs});
})
`;
}
+249
View File
@@ -0,0 +1,249 @@
/**
* Tests for dom-snapshot.ts: DOM snapshot engine.
*
* Since the engine generates JavaScript strings for in-page evaluation,
* these tests validate:
* 1. The generated code is syntactically valid JS
* 2. Options are correctly embedded
* 3. The output structure matches expected format
* 4. All features are present (Shadow DOM, iframe, table, diff, etc.)
*/
import { describe, it, expect } from 'vitest';
import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
describe('generateSnapshotJs', () => {
it('returns a non-empty string', () => {
const js = generateSnapshotJs();
expect(typeof js).toBe('string');
expect(js.length).toBeGreaterThan(100);
});
it('generates syntactically valid JS (can be parsed)', () => {
const js = generateSnapshotJs();
expect(() => new Function(js)).not.toThrow();
});
it('embeds default options correctly', () => {
const js = generateSnapshotJs();
expect(js).toContain('VIEWPORT_EXPAND = 800');
expect(js).toContain('MAX_DEPTH = 50');
expect(js).toContain('INTERACTIVE_ONLY = false');
expect(js).toContain('MAX_TEXT_LEN = 120');
expect(js).toContain('INCLUDE_SCROLL_INFO = true');
expect(js).toContain('BBOX_DEDUP = true');
expect(js).toContain('INCLUDE_SHADOW_DOM = true');
expect(js).toContain('INCLUDE_IFRAMES = true');
expect(js).toContain('PAINT_ORDER_CHECK = true');
expect(js).toContain('ANNOTATE_REFS = true');
expect(js).toContain('REPORT_HIDDEN = true');
expect(js).toContain('FILTER_ADS = true');
expect(js).toContain('MARKDOWN_TABLES = true');
expect(js).toContain('PREV_HASHES = null');
});
it('embeds custom options correctly', () => {
const js = generateSnapshotJs({
viewportExpand: 2000,
maxDepth: 30,
interactiveOnly: true,
maxTextLength: 200,
includeScrollInfo: false,
bboxDedup: false,
includeShadowDom: false,
includeIframes: false,
maxIframes: 3,
paintOrderCheck: false,
annotateRefs: false,
reportHidden: false,
filterAds: false,
markdownTables: false,
});
expect(js).toContain('VIEWPORT_EXPAND = 2000');
expect(js).toContain('MAX_DEPTH = 30');
expect(js).toContain('INTERACTIVE_ONLY = true');
expect(js).toContain('MAX_TEXT_LEN = 200');
expect(js).toContain('INCLUDE_SCROLL_INFO = false');
expect(js).toContain('BBOX_DEDUP = false');
expect(js).toContain('INCLUDE_SHADOW_DOM = false');
expect(js).toContain('INCLUDE_IFRAMES = false');
expect(js).toContain('MAX_IFRAMES = 3');
expect(js).toContain('PAINT_ORDER_CHECK = false');
expect(js).toContain('ANNOTATE_REFS = false');
expect(js).toContain('REPORT_HIDDEN = false');
expect(js).toContain('FILTER_ADS = false');
expect(js).toContain('MARKDOWN_TABLES = false');
});
it('clamps maxDepth between 1 and 200', () => {
expect(generateSnapshotJs({ maxDepth: -5 })).toContain('MAX_DEPTH = 1');
expect(generateSnapshotJs({ maxDepth: 999 })).toContain('MAX_DEPTH = 200');
expect(generateSnapshotJs({ maxDepth: 75 })).toContain('MAX_DEPTH = 75');
});
it('wraps output as an IIFE', () => {
const js = generateSnapshotJs();
expect(js.startsWith('(() =>')).toBe(true);
expect(js.trimEnd().endsWith(')()')).toBe(true);
});
it('embeds previousHashes for incremental diff', () => {
const hashes = JSON.stringify(['12345', '67890']);
const js = generateSnapshotJs({ previousHashes: hashes });
expect(js).toContain('new Set(["12345","67890"])');
});
it('includes all core features in generated code', () => {
const js = generateSnapshotJs();
// Tag filtering
expect(js).toContain('SKIP_TAGS');
expect(js).toContain("'script'");
expect(js).toContain("'style'");
// SVG collapsing
expect(js).toContain('SVG_CHILDREN');
// Interactive detection
expect(js).toContain('INTERACTIVE_TAGS');
expect(js).toContain('INTERACTIVE_ROLES');
expect(js).toContain('isInteractive');
// Visibility
expect(js).toContain('isVisibleByCSS');
expect(js).toContain('isInExpandedViewport');
// BBox dedup
expect(js).toContain('isContainedBy');
expect(js).toContain('PROPAGATING_TAGS');
// Shadow DOM
expect(js).toContain('shadowRoot');
expect(js).toContain('|shadow|');
// iframe
expect(js).toContain('walkIframe');
expect(js).toContain('|iframe|');
// Paint order
expect(js).toContain('isOccludedByOverlay');
expect(js).toContain('elementFromPoint');
// Ad filtering
expect(js).toContain('isAdElement');
expect(js).toContain('AD_PATTERNS');
// data-ref annotation
expect(js).toContain('data-opencli-ref');
// Hidden elements report
expect(js).toContain('hiddenInteractives');
expect(js).toContain('hidden_interactive');
// Incremental diff
expect(js).toContain('hashElement');
expect(js).toContain('currentHashes');
expect(js).toContain('__opencli_prev_hashes');
// Table serialization
expect(js).toContain('serializeTable');
expect(js).toContain('|table|');
// Synthetic attributes
expect(js).toContain("'YYYY-MM-DD'");
expect(js).toContain('value=••••');
// Page metadata
expect(js).toContain('location.href');
expect(js).toContain('document.title');
});
it('contains proper attribute whitelist', () => {
const js = generateSnapshotJs();
const expectedAttrs = [
'aria-label', 'aria-expanded', 'aria-checked', 'aria-selected',
'placeholder', 'href', 'role', 'data-testid', 'autocomplete',
];
for (const attr of expectedAttrs) {
expect(js).toContain(`'${attr}'`);
}
});
it('includes scroll info formatting', () => {
const js = generateSnapshotJs();
expect(js).toContain('scrollHeight');
expect(js).toContain('scrollTop');
expect(js).toContain('|scroll|');
expect(js).toContain('page_scroll');
});
});
describe('scrollToRefJs', () => {
it('generates valid JS', () => {
const js = scrollToRefJs('42');
expect(() => new Function(js)).not.toThrow();
});
it('targets data-opencli-ref', () => {
const js = scrollToRefJs('7');
expect(js).toContain('data-opencli-ref');
expect(js).toContain('scrollIntoView');
expect(js).toContain('"7"');
});
it('falls back to data-ref', () => {
const js = scrollToRefJs('3');
expect(js).toContain('data-ref');
});
it('returns scrolled info', () => {
const js = scrollToRefJs('1');
expect(js).toContain('scrolled: true');
expect(js).toContain('tag:');
});
});
describe('getFormStateJs', () => {
it('generates valid JS', () => {
const js = getFormStateJs();
expect(() => new Function(js)).not.toThrow();
});
it('collects form elements', () => {
const js = getFormStateJs();
expect(js).toContain('document.forms');
expect(js).toContain('form.elements');
});
it('collects orphan fields', () => {
const js = getFormStateJs();
expect(js).toContain('orphanFields');
expect(js).toContain('el.form');
});
it('handles different input types', () => {
const js = getFormStateJs();
expect(js).toContain('checkbox');
expect(js).toContain('radio');
expect(js).toContain('password');
expect(js).toContain('contenteditable');
});
it('extracts labels', () => {
const js = getFormStateJs();
expect(js).toContain('aria-label');
expect(js).toContain('label[for=');
expect(js).toContain('closest');
expect(js).toContain('placeholder');
});
it('masks passwords', () => {
const js = getFormStateJs();
expect(js).toContain('••••');
});
it('includes data-opencli-ref in output', () => {
const js = getFormStateJs();
expect(js).toContain('data-opencli-ref');
});
});
+770
View File
@@ -0,0 +1,770 @@
/**
* DOM Snapshot Engine — Advanced DOM pruning for LLM consumption.
*
* Inspired by browser-use's multi-layer pruning pipeline, adapted for opencli's
* Chrome Extension + CDP architecture. Runs entirely in-page via Runtime.evaluate.
*
* Pipeline:
* 1. Walk DOM tree, collect visibility + layout + interactivity signals
* 2. Prune invisible, zero-area, non-content elements
* 3. SVG & decoration collapse
* 4. Shadow DOM traversal
* 5. Same-origin iframe content extraction
* 6. Bounding-box parent-child dedup (link/button wrapping children)
* 7. Paint-order occlusion detection (overlay/modal coverage)
* 8. Attribute whitelist filtering
* 9. Table-aware serialization (markdown tables)
* 10. Token-efficient serialization with interactive indices
* 11. data-ref annotation for click/type targeting
* 12. Hidden interactive element hints (scroll-to-reveal)
* 13. Incremental diff (mark new elements with *)
*
* Additional tools:
* - scrollToRefJs(ref) — scroll to a data-opencli-ref element
* - getFormStateJs() — extract all form fields as structured JSON
*/
// ─── Types ───────────────────────────────────────────────────────────
export interface SnapshotOptions {
/** Extra pixels beyond viewport to include (default 800) */
viewportExpand?: number;
/** Maximum DOM depth to traverse (default 50) */
maxDepth?: number;
/** Only emit interactive elements and their landmark ancestors */
interactiveOnly?: boolean;
/** Maximum text content length per node (default 120) */
maxTextLength?: number;
/** Include scroll position info on scrollable containers (default true) */
includeScrollInfo?: boolean;
/** Enable bounding-box parent-child dedup (default true) */
bboxDedup?: boolean;
/** Traverse Shadow DOM roots (default true) */
includeShadowDom?: boolean;
/** Extract same-origin iframe content (default true) */
includeIframes?: boolean;
/** Maximum number of iframes to process (default 5) */
maxIframes?: number;
/** Enable paint-order occlusion detection (default true) */
paintOrderCheck?: boolean;
/** Annotate interactive elements with data-opencli-ref (default true) */
annotateRefs?: boolean;
/** Report hidden interactive elements outside viewport (default true) */
reportHidden?: boolean;
/** Filter ad/noise elements (default true) */
filterAds?: boolean;
/** Serialize tables as markdown (default true) */
markdownTables?: boolean;
/** Previous snapshot hash set (JSON array of hashes) for diff marking (default null) */
previousHashes?: string | null;
}
// ─── Utility JS Generators ───────────────────────────────────────────
/**
* Generate JS to scroll to an element identified by data-opencli-ref.
* Completes the snapshot→action loop: snapshot identifies `[3]<button>`,
* caller can then `scrollToRef('3')` to bring it into view.
*/
export function scrollToRefJs(ref: string): string {
const safeRef = JSON.stringify(ref);
return `
(() => {
const ref = ${safeRef};
const el = document.querySelector('[data-opencli-ref="' + ref + '"]')
|| document.querySelector('[data-ref="' + ref + '"]');
if (!el) throw new Error('Element not found: ref=' + ref);
el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
return { scrolled: true, tag: el.tagName.toLowerCase(), text: (el.textContent || '').trim().slice(0, 80) };
})()
`.trim();
}
/**
* Generate JS to extract all form field values from the page.
* Returns structured JSON: { forms: [{ id, action, fields: [{ tag, type, name, value, ... }] }] }
*/
export function getFormStateJs(): string {
return `
(() => {
const result = { forms: [], orphanFields: [] };
// Collect all forms
for (const form of document.forms) {
const formData = {
id: form.id || null,
name: form.name || null,
action: form.action || null,
method: (form.method || 'get').toUpperCase(),
fields: [],
};
for (const el of form.elements) {
const field = extractField(el);
if (field) formData.fields.push(field);
}
if (formData.fields.length > 0) result.forms.push(formData);
}
// Collect orphan fields (not inside a form)
const allInputs = document.querySelectorAll('input, textarea, select, [contenteditable="true"]');
for (const el of allInputs) {
if (el.form) continue; // already in a form
const field = extractField(el);
if (field) result.orphanFields.push(field);
}
function extractField(el) {
const tag = el.tagName.toLowerCase();
const type = (el.getAttribute('type') || (tag === 'textarea' ? 'textarea' : tag === 'select' ? 'select' : 'text')).toLowerCase();
if (type === 'hidden' || type === 'submit' || type === 'button' || type === 'reset') return null;
const name = el.name || el.id || null;
const ref = el.getAttribute('data-opencli-ref') || null;
const label = findLabel(el);
let value;
if (tag === 'select') {
const opt = el.options?.[el.selectedIndex];
value = opt ? opt.textContent.trim() : '';
} else if (type === 'checkbox' || type === 'radio') {
value = el.checked;
} else if (type === 'password') {
value = el.value ? '••••' : '';
} else if (el.isContentEditable) {
value = (el.textContent || '').trim().slice(0, 200);
} else {
value = (el.value || '').slice(0, 200);
}
return { tag, type, name, ref, label, value, required: el.required || false, disabled: el.disabled || false };
}
function findLabel(el) {
// 1. aria-label
if (el.getAttribute('aria-label')) return el.getAttribute('aria-label');
// 2. associated <label>
if (el.id) {
const label = document.querySelector('label[for="' + el.id + '"]');
if (label) return label.textContent.trim().slice(0, 80);
}
// 3. parent label
const parentLabel = el.closest('label');
if (parentLabel) return parentLabel.textContent.trim().slice(0, 80);
// 4. placeholder
return el.placeholder || null;
}
return result;
})()
`.trim();
}
// ─── Main Snapshot JS Generator ──────────────────────────────────────
/**
* Generate JavaScript code that, when evaluated in a page context via CDP
* Runtime.evaluate, returns a pruned DOM snapshot string optimised for LLMs.
*
* The snapshot output format:
* [42]<button type=submit>Search</button>
* |scroll|<div> (0.5↑ 3.2↓)
* *[58]<a href=/r/1>Result 1</a>
* [59]<a href=/r/2>Result 2</a>
*
* - `[id]` — interactive element with backend index for targeting
* - `*` prefix — newly appeared element (incremental diff)
* - `|scroll|` — scrollable container with page counts
* - `|shadow|` — Shadow DOM boundary
* - `|iframe|` — iframe content
* - `|table|` — markdown table rendering
*/
export function generateSnapshotJs(opts: SnapshotOptions = {}): string {
const viewportExpand = opts.viewportExpand ?? 800;
const maxDepth = Math.max(1, Math.min(opts.maxDepth ?? 50, 200));
const interactiveOnly = opts.interactiveOnly ?? false;
const maxTextLength = opts.maxTextLength ?? 120;
const includeScrollInfo = opts.includeScrollInfo ?? true;
const bboxDedup = opts.bboxDedup ?? true;
const includeShadowDom = opts.includeShadowDom ?? true;
const includeIframes = opts.includeIframes ?? true;
const maxIframes = opts.maxIframes ?? 5;
const paintOrderCheck = opts.paintOrderCheck ?? true;
const annotateRefs = opts.annotateRefs ?? true;
const reportHidden = opts.reportHidden ?? true;
const filterAds = opts.filterAds ?? true;
const markdownTables = opts.markdownTables ?? true;
const previousHashes = opts.previousHashes ?? null;
return `
(() => {
'use strict';
// ── Config ─────────────────────────────────────────────────────────
const VIEWPORT_EXPAND = ${viewportExpand};
const MAX_DEPTH = ${maxDepth};
const INTERACTIVE_ONLY = ${interactiveOnly};
const MAX_TEXT_LEN = ${maxTextLength};
const INCLUDE_SCROLL_INFO = ${includeScrollInfo};
const BBOX_DEDUP = ${bboxDedup};
const INCLUDE_SHADOW_DOM = ${includeShadowDom};
const INCLUDE_IFRAMES = ${includeIframes};
const MAX_IFRAMES = ${maxIframes};
const PAINT_ORDER_CHECK = ${paintOrderCheck};
const ANNOTATE_REFS = ${annotateRefs};
const REPORT_HIDDEN = ${reportHidden};
const FILTER_ADS = ${filterAds};
const MARKDOWN_TABLES = ${markdownTables};
const PREV_HASHES = ${previousHashes ? `new Set(${previousHashes})` : 'null'};
// ── Constants ──────────────────────────────────────────────────────
const SKIP_TAGS = new Set([
'script', 'style', 'noscript', 'link', 'meta', 'head',
'template', 'br', 'wbr', 'col', 'colgroup',
]);
const SVG_CHILDREN = new Set([
'path', 'rect', 'g', 'circle', 'ellipse', 'line', 'polyline',
'polygon', 'use', 'defs', 'clippath', 'mask', 'pattern',
'text', 'tspan', 'lineargradient', 'radialgradient', 'stop',
'filter', 'fegaussianblur', 'fecolormatrix', 'feblend',
'symbol', 'marker', 'foreignobject', 'desc', 'title',
]);
const INTERACTIVE_TAGS = new Set([
'a', 'button', 'input', 'select', 'textarea', 'details',
'summary', 'option', 'optgroup',
]);
const INTERACTIVE_ROLES = new Set([
'button', 'link', 'menuitem', 'option', 'radio', 'checkbox',
'tab', 'textbox', 'combobox', 'slider', 'spinbutton',
'searchbox', 'switch', 'menuitemcheckbox', 'menuitemradio',
'treeitem', 'gridcell', 'row',
]);
const LANDMARK_ROLES = new Set([
'main', 'navigation', 'banner', 'search', 'region',
'complementary', 'contentinfo', 'form', 'dialog',
]);
const LANDMARK_TAGS = new Set([
'nav', 'main', 'header', 'footer', 'aside', 'form',
'search', 'dialog', 'section', 'article',
]);
const ATTR_WHITELIST = new Set([
'id', 'name', 'type', 'value', 'placeholder', 'title', 'alt',
'role', 'aria-label', 'aria-expanded', 'aria-checked', 'aria-selected',
'aria-disabled', 'aria-valuemin', 'aria-valuemax', 'aria-valuenow',
'aria-haspopup', 'aria-live', 'aria-required',
'href', 'src', 'action', 'method', 'for', 'checked', 'selected',
'disabled', 'required', 'multiple', 'accept', 'min', 'max',
'pattern', 'maxlength', 'minlength', 'data-testid', 'data-test',
'contenteditable', 'tabindex', 'autocomplete',
]);
const PROPAGATING_TAGS = new Set(['a', 'button']);
const AD_PATTERNS = [
'googleadservices.com', 'doubleclick.net', 'googlesyndication.com',
'facebook.com/tr', 'analytics.google.com', 'connect.facebook.net',
'ad.doubleclick', 'pagead', 'adsense',
];
const AD_SELECTOR_RE = /\\b(ad[_-]?(?:banner|container|wrapper|slot|unit|block|frame|leaderboard|sidebar)|google[_-]?ad|sponsored|adsbygoogle|banner[_-]?ad)\\b/i;
// ── Viewport & Layout Helpers ──────────────────────────────────────
const vw = window.innerWidth;
const vh = window.innerHeight;
function isInExpandedViewport(rect) {
if (!rect || (rect.width === 0 && rect.height === 0)) return false;
return rect.bottom > -VIEWPORT_EXPAND && rect.top < vh + VIEWPORT_EXPAND &&
rect.right > -VIEWPORT_EXPAND && rect.left < vw + VIEWPORT_EXPAND;
}
function isVisibleByCSS(el) {
const style = el.style;
if (style.display === 'none') return false;
if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
if (style.opacity === '0') return false;
try {
const cs = window.getComputedStyle(el);
if (cs.display === 'none') return false;
if (cs.visibility === 'hidden') return false;
if (parseFloat(cs.opacity) <= 0) return false;
if (cs.clip === 'rect(0px, 0px, 0px, 0px)' && cs.position === 'absolute') return false;
if (cs.overflow === 'hidden' && el.offsetWidth === 0 && el.offsetHeight === 0) return false;
} catch {}
return true;
}
// ── Paint Order Occlusion ──────────────────────────────────────────
function isOccludedByOverlay(el) {
if (!PAINT_ORDER_CHECK) return false;
try {
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return false;
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
if (cx < 0 || cy < 0 || cx > vw || cy > vh) return false;
const topEl = document.elementFromPoint(cx, cy);
if (!topEl || topEl === el || el.contains(topEl) || topEl.contains(el)) return false;
const cs = window.getComputedStyle(topEl);
if (parseFloat(cs.opacity) < 0.5) return false;
const bg = cs.backgroundColor;
if (bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent') return false;
return true;
} catch { return false; }
}
// ── Ad/Noise Detection ─────────────────────────────────────────────
function isAdElement(el) {
if (!FILTER_ADS) return false;
try {
const id = el.id || '';
const cls = el.className || '';
const testStr = id + ' ' + (typeof cls === 'string' ? cls : '');
if (AD_SELECTOR_RE.test(testStr)) return true;
if (el.tagName === 'IFRAME') {
const src = el.src || '';
for (const p of AD_PATTERNS) { if (src.includes(p)) return true; }
}
if (el.hasAttribute('data-ad') || el.hasAttribute('data-ad-slot') ||
el.hasAttribute('data-adunit') || el.hasAttribute('data-google-query-id')) return true;
} catch {}
return false;
}
// ── Interactivity Detection ────────────────────────────────────────
function isInteractive(el) {
const tag = el.tagName.toLowerCase();
if (INTERACTIVE_TAGS.has(tag)) {
if (tag === 'label' && el.hasAttribute('for')) return false;
if (el.disabled && (tag === 'button' || tag === 'input')) return false;
return true;
}
const role = el.getAttribute('role');
if (role && INTERACTIVE_ROLES.has(role)) return true;
if (el.hasAttribute('onclick') || el.hasAttribute('onmousedown') || el.hasAttribute('ontouchstart')) return true;
if (el.hasAttribute('tabindex') && el.getAttribute('tabindex') !== '-1') return true;
try { if (window.getComputedStyle(el).cursor === 'pointer') return true; } catch {}
if (el.isContentEditable && el.getAttribute('contenteditable') !== 'false') return true;
return false;
}
function isLandmark(el) {
const role = el.getAttribute('role');
if (role && LANDMARK_ROLES.has(role)) return true;
return LANDMARK_TAGS.has(el.tagName.toLowerCase());
}
// ── Scrollability Detection ────────────────────────────────────────
function getScrollInfo(el) {
if (!INCLUDE_SCROLL_INFO) return null;
const sh = el.scrollHeight, ch = el.clientHeight;
const sw = el.scrollWidth, cw = el.clientWidth;
const isV = sh > ch + 5, isH = sw > cw + 5;
if (!isV && !isH) return null;
try {
const cs = window.getComputedStyle(el);
const scrollable = ['auto', 'scroll', 'overlay'];
const tag = el.tagName.toLowerCase();
const isBody = tag === 'body' || tag === 'html';
if (isV && !isBody && !scrollable.includes(cs.overflowY)) return null;
const info = {};
if (isV) {
const above = ch > 0 ? +(el.scrollTop / ch).toFixed(1) : 0;
const below = ch > 0 ? +((sh - ch - el.scrollTop) / ch).toFixed(1) : 0;
if (above > 0 || below > 0) info.v = { above, below };
}
if (isH && scrollable.includes(cs.overflowX)) {
info.h = { pct: cw > 0 ? Math.round(el.scrollLeft / (sw - cw) * 100) : 0 };
}
return Object.keys(info).length > 0 ? info : null;
} catch { return null; }
}
// ── BBox Containment Check ─────────────────────────────────────────
function isContainedBy(childRect, parentRect, threshold) {
if (!childRect || !parentRect) return false;
const cArea = childRect.width * childRect.height;
if (cArea === 0) return false;
const xO = Math.max(0, Math.min(childRect.right, parentRect.right) - Math.max(childRect.left, parentRect.left));
const yO = Math.max(0, Math.min(childRect.bottom, parentRect.bottom) - Math.max(childRect.top, parentRect.top));
return (xO * yO) / cArea >= threshold;
}
// ── Text Helpers ───────────────────────────────────────────────────
function getDirectText(el) {
let text = '';
for (const child of el.childNodes) {
if (child.nodeType === 3) {
const t = child.textContent.trim();
if (t) text += (text ? ' ' : '') + t;
}
}
return text;
}
function capText(s) {
if (!s) return '';
const t = s.replace(/\\s+/g, ' ').trim();
return t.length > MAX_TEXT_LEN ? t.slice(0, MAX_TEXT_LEN) + '…' : t;
}
// ── Element Hashing (for incremental diff) ─────────────────────────
function hashElement(el) {
// Simple hash: tag + id + className + textContent prefix
const tag = el.tagName || '';
const id = el.id || '';
const cls = (typeof el.className === 'string' ? el.className : '').slice(0, 50);
const text = (el.textContent || '').trim().slice(0, 40);
const s = tag + '|' + id + '|' + cls + '|' + text;
let h = 0;
for (let i = 0; i < s.length; i++) {
h = ((h << 5) - h + s.charCodeAt(i)) | 0;
}
return '' + (h >>> 0); // unsigned
}
// ── Attribute Serialization ────────────────────────────────────────
function serializeAttrs(el) {
const parts = [];
for (const attr of el.attributes) {
if (!ATTR_WHITELIST.has(attr.name)) continue;
let val = attr.value.trim();
if (!val) continue;
if (val.length > 120) val = val.slice(0, 100) + '…';
if (attr.name === 'type' && val.toLowerCase() === el.tagName.toLowerCase()) continue;
if (attr.name === 'value' && el.getAttribute('type') === 'password') { parts.push('value=••••'); continue; }
if (attr.name === 'href') {
if (val.startsWith('javascript:')) continue;
try {
const u = new URL(val, location.origin);
if (u.origin === location.origin) val = u.pathname + u.search + u.hash;
} catch {}
}
parts.push(attr.name + '=' + val);
}
// Synthetic attributes
const tag = el.tagName;
if (tag === 'INPUT') {
const type = (el.getAttribute('type') || 'text').toLowerCase();
const fmts = { 'date':'YYYY-MM-DD', 'time':'HH:MM', 'datetime-local':'YYYY-MM-DDTHH:MM', 'month':'YYYY-MM', 'week':'YYYY-W##' };
if (fmts[type]) parts.push('format=' + fmts[type]);
if (['text','email','tel','url','search','number','date','time','datetime-local','month','week'].includes(type)) {
if (el.value && !parts.some(p => p.startsWith('value='))) parts.push('value=' + capText(el.value));
}
if (type === 'password' && el.value && !parts.some(p => p.startsWith('value='))) parts.push('value=••••');
if ((type === 'checkbox' || type === 'radio') && el.checked && !parts.some(p => p.startsWith('checked'))) parts.push('checked');
if (type === 'file' && el.files && el.files.length > 0) parts.push('files=' + Array.from(el.files).map(f => f.name).join(','));
}
if (tag === 'TEXTAREA' && el.value && !parts.some(p => p.startsWith('value='))) parts.push('value=' + capText(el.value));
if (tag === 'SELECT') {
const sel = el.options?.[el.selectedIndex];
if (sel && !parts.some(p => p.startsWith('value='))) parts.push('value=' + capText(sel.textContent));
const optEls = Array.from(el.options || []).slice(0, 6);
if (optEls.length > 0) {
const ot = optEls.map(o => capText(o.textContent).slice(0, 30));
if (el.options.length > 6) ot.push('…' + (el.options.length - 6) + ' more');
parts.push('options=[' + ot.join('|') + ']');
}
}
return parts.join(' ');
}
// ── Table → Markdown Serialization ─────────────────────────────────
function serializeTable(table, depth) {
if (!MARKDOWN_TABLES) return false;
try {
const rows = table.querySelectorAll('tr');
if (rows.length === 0 || rows.length > 50) return false; // skip huge tables
const grid = [];
let maxCols = 0;
for (const row of rows) {
const cells = [];
for (const cell of row.querySelectorAll('th, td')) {
let text = capText(cell.textContent || '');
// Include interactive elements in cells
const links = cell.querySelectorAll('a[href]');
if (links.length === 1 && text) {
const href = links[0].getAttribute('href');
if (href && !href.startsWith('javascript:')) {
try {
const u = new URL(href, location.origin);
text = '[' + text + '](' + (u.origin === location.origin ? u.pathname + u.search : href) + ')';
} catch { text = '[' + text + '](' + href + ')'; }
}
}
cells.push(text || '');
}
if (cells.length > 0) {
grid.push(cells);
if (cells.length > maxCols) maxCols = cells.length;
}
}
if (grid.length < 2 || maxCols === 0) return false; // need at least header + 1 row
// Pad rows to maxCols
for (const row of grid) { while (row.length < maxCols) row.push(''); }
// Compute column widths
const widths = [];
for (let c = 0; c < maxCols; c++) {
let w = 3;
for (const row of grid) { if (row[c].length > w) w = Math.min(row[c].length, 40); }
widths.push(w);
}
const indent = ' '.repeat(depth);
const tableLines = [];
// Header
tableLines.push(indent + '| ' + grid[0].map((c, i) => c.padEnd(widths[i])).join(' | ') + ' |');
tableLines.push(indent + '| ' + widths.map(w => '-'.repeat(w)).join(' | ') + ' |');
// Body
for (let r = 1; r < grid.length; r++) {
tableLines.push(indent + '| ' + grid[r].map((c, i) => c.padEnd(widths[i])).join(' | ') + ' |');
}
return tableLines;
} catch { return false; }
}
// ── Main Tree Walk ─────────────────────────────────────────────────
let interactiveIndex = 0;
const lines = [];
const hiddenInteractives = [];
const currentHashes = [];
let iframeCount = 0;
function walk(el, depth, parentPropagatingRect) {
if (depth > MAX_DEPTH) return false;
if (el.nodeType !== 1) return false;
const tag = el.tagName.toLowerCase();
if (SKIP_TAGS.has(tag)) return false;
if (isAdElement(el)) return false;
// SVG: emit tag, collapse children
if (tag === 'svg') {
const attrs = serializeAttrs(el);
const interactive = isInteractive(el);
let prefix = '';
if (interactive) {
interactiveIndex++;
if (ANNOTATE_REFS) el.setAttribute('data-opencli-ref', '' + interactiveIndex);
prefix = '[' + interactiveIndex + ']';
}
lines.push(' '.repeat(depth) + prefix + '<svg' + (attrs ? ' ' + attrs : '') + ' />');
return interactive;
}
if (SVG_CHILDREN.has(tag)) return false;
// Table: try markdown serialization before generic walk
if (tag === 'table' && MARKDOWN_TABLES) {
const tableLines = serializeTable(el, depth);
if (tableLines) {
const indent = ' '.repeat(depth);
lines.push(indent + '|table|');
for (const tl of tableLines) lines.push(tl);
return false; // tables usually non-interactive
}
// Fall through to generic walk if markdown failed
}
// iframe handling
if (tag === 'iframe' && INCLUDE_IFRAMES && iframeCount < MAX_IFRAMES) {
return walkIframe(el, depth);
}
// Visibility check
let rect;
try { rect = el.getBoundingClientRect(); } catch { return false; }
const hasArea = rect.width > 0 && rect.height > 0;
if (hasArea && !isVisibleByCSS(el)) {
if (!(tag === 'input' && el.type === 'file')) return false;
}
const interactive = isInteractive(el);
// Viewport threshold pruning
if (hasArea && !isInExpandedViewport(rect)) {
if (interactive && REPORT_HIDDEN) {
const scrollDist = rect.top > vh ? rect.top - vh : -rect.bottom;
const pagesAway = Math.abs(scrollDist / vh).toFixed(1);
const direction = rect.top > vh ? 'below' : 'above';
const text = capText(getDirectText(el) || el.getAttribute('aria-label') || el.getAttribute('title') || '');
hiddenInteractives.push({ tag, text, direction, pagesAway });
}
return false;
}
// Paint order occlusion
if (interactive && hasArea && isOccludedByOverlay(el)) return false;
const landmark = isLandmark(el);
const scrollInfo = getScrollInfo(el);
const isScrollable = scrollInfo !== null;
// BBox dedup
let excludedByParent = false;
if (BBOX_DEDUP && parentPropagatingRect && !interactive) {
if (hasArea && isContainedBy(rect, parentPropagatingRect, 0.95)) {
const hasSemantic = el.hasAttribute('aria-label') ||
(el.getAttribute('role') && INTERACTIVE_ROLES.has(el.getAttribute('role')));
if (!hasSemantic && !['input','select','textarea','label'].includes(tag)) {
excludedByParent = true;
}
}
}
let propagateRect = parentPropagatingRect;
if (BBOX_DEDUP && PROPAGATING_TAGS.has(tag) && hasArea) propagateRect = rect;
// Process children
const origLen = lines.length;
let hasInteractiveDescendant = false;
for (const child of el.children) {
const r = walk(child, depth + 1, propagateRect);
if (r) hasInteractiveDescendant = true;
}
// Shadow DOM
if (INCLUDE_SHADOW_DOM && el.shadowRoot) {
const shadowOrigLen = lines.length;
for (const child of el.shadowRoot.children) {
const r = walk(child, depth + 1, propagateRect);
if (r) hasInteractiveDescendant = true;
}
if (lines.length > shadowOrigLen) {
lines.splice(shadowOrigLen, 0, ' '.repeat(depth + 1) + '|shadow|');
}
}
const childLinesCount = lines.length - origLen;
const text = capText(getDirectText(el));
// Decide whether to emit
if (INTERACTIVE_ONLY && !interactive && !landmark && !hasInteractiveDescendant && !text) {
lines.length = origLen;
return false;
}
if (excludedByParent && !interactive && !isScrollable) return hasInteractiveDescendant;
if (!interactive && !isScrollable && !text && childLinesCount === 0 && !landmark) return false;
// ── Emit node ────────────────────────────────────────────────────
const indent = ' '.repeat(depth);
let line = indent;
// Incremental diff: mark new elements with *
if (PREV_HASHES) {
const h = hashElement(el);
currentHashes.push(h);
if (!PREV_HASHES.has(h)) line += '*';
} else {
currentHashes.push(hashElement(el));
}
// Scroll marker
if (isScrollable && !interactive) line += '|scroll|';
// Interactive index + data-ref
if (interactive) {
interactiveIndex++;
if (ANNOTATE_REFS) el.setAttribute('data-opencli-ref', '' + interactiveIndex);
line += isScrollable ? '|scroll[' + interactiveIndex + ']|' : '[' + interactiveIndex + ']';
}
// Tag + attributes
const attrs = serializeAttrs(el);
line += '<' + tag;
if (attrs) line += ' ' + attrs;
// Scroll info suffix, inline text, or self-close
if (isScrollable && scrollInfo) {
const parts = [];
if (scrollInfo.v) parts.push(scrollInfo.v.above + '↑ ' + scrollInfo.v.below + '↓');
if (scrollInfo.h) parts.push('h:' + scrollInfo.h.pct + '%');
line += ' /> (' + parts.join(', ') + ')';
} else if (text && childLinesCount === 0) {
line += '>' + text + '</' + tag + '>';
} else {
line += ' />';
}
lines.splice(origLen, 0, line);
if (text && childLinesCount > 0) lines.splice(origLen + 1, 0, indent + ' ' + text);
return interactive || hasInteractiveDescendant;
}
// ── iframe Processing ──────────────────────────────────────────────
function walkIframe(el, depth) {
const indent = ' '.repeat(depth);
try {
const doc = el.contentDocument;
if (!doc || !doc.body) {
const attrs = serializeAttrs(el);
lines.push(indent + '|iframe|<iframe' + (attrs ? ' ' + attrs : '') + ' /> (cross-origin)');
return false;
}
iframeCount++;
const attrs = serializeAttrs(el);
lines.push(indent + '|iframe|<iframe' + (attrs ? ' ' + attrs : '') + ' />');
let has = false;
for (const child of doc.body.children) {
if (walk(child, depth + 1, null)) has = true;
}
return has;
} catch {
const attrs = serializeAttrs(el);
lines.push(indent + '|iframe|<iframe' + (attrs ? ' ' + attrs : '') + ' /> (blocked)');
return false;
}
}
// ── Entry Point ────────────────────────────────────────────────────
lines.push('url: ' + location.href);
lines.push('title: ' + document.title);
lines.push('viewport: ' + vw + 'x' + vh);
const pageScrollInfo = getScrollInfo(document.documentElement) || getScrollInfo(document.body);
if (pageScrollInfo && pageScrollInfo.v) {
lines.push('page_scroll: ' + pageScrollInfo.v.above + '↑ ' + pageScrollInfo.v.below + '↓');
}
lines.push('---');
const root = document.body || document.documentElement;
if (root) walk(root, 0, null);
// Hidden interactive elements hint
if (REPORT_HIDDEN && hiddenInteractives.length > 0) {
lines.push('---');
lines.push('hidden_interactive (' + hiddenInteractives.length + '):');
const shown = hiddenInteractives.slice(0, 10);
for (const h of shown) {
const label = h.text ? ' "' + h.text + '"' : '';
lines.push(' <' + h.tag + '>' + label + ' ~' + h.pagesAway + ' pages ' + h.direction);
}
if (hiddenInteractives.length > 10) lines.push(' …' + (hiddenInteractives.length - 10) + ' more');
}
// Footer
lines.push('---');
lines.push('interactive: ' + interactiveIndex + ' | iframes: ' + iframeCount);
// Store hashes on window for next diff snapshot
try { window.__opencli_prev_hashes = JSON.stringify(currentHashes); } catch {}
return lines.join('\\n');
})()
`.trim();
}
+18 -11
View File
@@ -5,31 +5,38 @@
* The daemon architecture has a single failure mode: daemon not reachable or extension not connected.
*/
import { BrowserConnectError } from '../errors.js';
import { DEFAULT_DAEMON_PORT } from '../constants.js';
export type ConnectFailureKind = 'daemon-not-running' | 'extension-not-connected' | 'command-failed' | 'unknown';
export function formatBrowserConnectError(kind: ConnectFailureKind, detail?: string): Error {
export function formatBrowserConnectError(kind: ConnectFailureKind, detail?: string): BrowserConnectError {
switch (kind) {
case 'daemon-not-running':
return new Error(
'Cannot connect to opencli daemon.\n\n' +
return new BrowserConnectError(
'Cannot connect to opencli daemon.' +
(detail ? `\n\n${detail}` : ''),
'The daemon should start automatically. If it doesn\'t, try:\n' +
' node dist/daemon.js\n' +
'Make sure port 19825 is available.' +
(detail ? `\n\n${detail}` : ''),
`Make sure port ${DEFAULT_DAEMON_PORT} is available.`,
);
case 'extension-not-connected':
return new Error(
'opencli Browser Bridge extension is not connected.\n\n' +
return new BrowserConnectError(
'opencli Browser Bridge extension is not connected.' +
(detail ? `\n\n${detail}` : ''),
'Please install the extension:\n' +
' 1. Download from GitHub Releases\n' +
' 2. Open chrome://extensions/ → Enable Developer Mode\n' +
' 3. Click "Load unpacked" → select the extension folder\n' +
' 4. Make sure Chrome is running' +
(detail ? `\n\n${detail}` : ''),
' 4. Make sure Chrome is running',
);
case 'command-failed':
return new Error(`Browser command failed: ${detail ?? 'unknown error'}`);
return new BrowserConnectError(
`Browser command failed: ${detail ?? 'unknown error'}`,
);
default:
return new Error(detail ?? 'Failed to connect to browser');
return new BrowserConnectError(
detail ?? 'Failed to connect to browser',
);
}
}
+4 -1
View File
@@ -6,9 +6,12 @@
*/
export { Page } from './page.js';
export { BrowserBridge, BrowserBridge as PlaywrightMCP } from './mcp.js';
export { BrowserBridge } from './mcp.js';
export { CDPBridge } from './cdp.js';
export { isDaemonRunning } from './daemon-client.js';
export { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
export { generateStealthJs } from './stealth.js';
export type { SnapshotOptions } from './dom-snapshot.js';
import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
import { __test__ as cdpTest } from './cdp.js';
+5 -5
View File
@@ -9,6 +9,7 @@ import * as fs from 'node:fs';
import type { IPage } from '../types.js';
import { Page } from './page.js';
import { isDaemonRunning, isExtensionConnected } from './daemon-client.js';
import { DEFAULT_DAEMON_PORT } from '../constants.js';
const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension
@@ -55,7 +56,9 @@ export class BrowserBridge {
}
private async _ensureDaemon(timeoutSeconds?: number): Promise<void> {
const timeoutMs = Math.max(1, timeoutSeconds ?? Math.ceil(DAEMON_SPAWN_TIMEOUT / 1000)) * 1000;
// 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()) {
@@ -110,10 +113,7 @@ export class BrowserBridge {
throw new Error(
'Failed to start opencli daemon. Try running manually:\n' +
` node ${daemonPath}\n` +
'Make sure port 19825 is available.',
`Make sure port ${DEFAULT_DAEMON_PORT} is available.`,
);
}
}
/** @deprecated Use BrowserBridge instead */
export const PlaywrightMCP = BrowserBridge;
+100 -51
View File
@@ -11,9 +11,12 @@
*/
import { formatSnapshot } from '../snapshotFormatter.js';
import type { IPage } from '../types.js';
import type { BrowserCookie, IPage, ScreenshotOptions, SnapshotOptions, WaitOptions } from '../types.js';
import { sendCommand } from './daemon-client.js';
import { wrapForEval } from './utils.js';
import { saveBase64ToFile } from '../utils.js';
import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
import { generateStealthJs } from './stealth.js';
import {
clickJs,
typeTextJs,
@@ -22,6 +25,7 @@ import {
scrollJs,
autoScrollJs,
networkRequestsJs,
waitForDomStableJs,
} from './dom-helpers.js';
/**
@@ -33,53 +37,91 @@ export class Page implements IPage {
/** Active tab ID, set after navigate and used in all subsequent commands */
private _tabId: number | undefined;
/** Helper: spread tabId into command params if we have one */
private _tabOpt(): { tabId: number } | Record<string, never> {
return this._tabId !== undefined ? { tabId: this._tabId } : {};
/** Helper: spread workspace into command params */
private _wsOpt(): { workspace: string } {
return { workspace: this.workspace };
}
private _workspaceOpt(): { workspace: string } {
return { workspace: this.workspace };
/** Helper: spread workspace + tabId into command params */
private _cmdOpts(): Record<string, unknown> {
return {
workspace: this.workspace,
...(this._tabId !== undefined && { tabId: this._tabId }),
};
}
async goto(url: string, options?: { waitUntil?: 'load' | 'none'; settleMs?: number }): Promise<void> {
const result = await sendCommand('navigate', {
url,
...this._workspaceOpt(),
...this._tabOpt(),
...this._cmdOpts(),
}) as { tabId?: number };
// Remember the tabId for subsequent exec calls
if (result?.tabId) {
this._tabId = result.tabId;
}
// Post-load settle: the extension already waits for tab.status === 'complete',
// but SPA frameworks (React/Vue) need extra time to render after DOM load.
// Inject stealth anti-detection patches (guard flag prevents double-injection).
try {
await sendCommand('exec', {
code: generateStealthJs(),
...this._cmdOpts(),
});
} catch {
// Non-fatal: stealth is best-effort
}
// Smart settle: use DOM stability detection instead of fixed sleep.
// settleMs is now a timeout cap (default 1000ms), not a fixed wait.
if (options?.waitUntil !== 'none') {
const settleMs = options?.settleMs ?? 1000;
await new Promise(resolve => setTimeout(resolve, settleMs));
const maxMs = options?.settleMs ?? 1000;
await sendCommand('exec', {
code: waitForDomStableJs(maxMs, Math.min(500, maxMs)),
...this._cmdOpts(),
});
}
}
/** Close the automation window in the extension */
async closeWindow(): Promise<void> {
try {
await sendCommand('close-window', { ...this._workspaceOpt() });
await sendCommand('close-window', { ...this._wsOpt() });
} catch {
// Window may already be closed or daemon may be down
}
}
async evaluate(js: string): Promise<any> {
async evaluate(js: string): Promise<unknown> {
const code = wrapForEval(js);
return sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
return sendCommand('exec', { code, ...this._cmdOpts() });
}
async getCookies(opts: { domain?: string; url?: string } = {}): Promise<any[]> {
const result = await sendCommand('cookies', { ...this._workspaceOpt(), ...opts });
async getCookies(opts: { domain?: string; url?: string } = {}): Promise<BrowserCookie[]> {
const result = await sendCommand('cookies', { ...this._wsOpt(), ...opts });
return Array.isArray(result) ? result : [];
}
async snapshot(opts: { interactive?: boolean; compact?: boolean; maxDepth?: number; raw?: boolean } = {}): Promise<any> {
async snapshot(opts: SnapshotOptions = {}): Promise<unknown> {
// Primary: use the advanced DOM snapshot engine with multi-layer pruning
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,
});
try {
const result = await sendCommand('exec', { code: snapshotJs, ...this._cmdOpts() });
// The advanced engine already produces a clean, pruned, LLM-friendly output.
// Do NOT pass through formatSnapshot — its format is incompatible.
return result;
} catch {
// Fallback: basic DOM snapshot (original implementation)
return this._basicSnapshot(opts);
}
}
/** Fallback basic snapshot — original buildTree approach */
private 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 () => {
@@ -93,7 +135,7 @@ export class Page implements IPage {
let indent = ' '.repeat(depth);
let line = indent + role;
if (name) line += ' "' + name.replace(/"/g, '\\\\"') + '"';
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') + ']';
@@ -108,7 +150,7 @@ export class Page implements IPage {
return buildTree(document.body, 0);
})()
`;
const raw = await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
const raw = await sendCommand('exec', { code, ...this._cmdOpts() });
if (opts.raw) return raw;
if (typeof raw === 'string') return formatSnapshot(raw, opts);
return raw;
@@ -116,54 +158,71 @@ export class Page implements IPage {
async click(ref: string): Promise<void> {
const code = clickJs(ref);
await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
await sendCommand('exec', { code, ...this._cmdOpts() });
}
async typeText(ref: string, text: string): Promise<void> {
const code = typeTextJs(ref, text);
await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
await sendCommand('exec', { code, ...this._cmdOpts() });
}
async pressKey(key: string): Promise<void> {
const code = pressKeyJs(key);
await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
await sendCommand('exec', { code, ...this._cmdOpts() });
}
async wait(options: number | { text?: string; time?: number; timeout?: number }): Promise<void> {
async scrollTo(ref: string): Promise<unknown> {
const code = scrollToRefJs(ref);
return sendCommand('exec', { code, ...this._cmdOpts() });
}
async getFormState(): Promise<Record<string, unknown>> {
const code = getFormStateJs();
return (await sendCommand('exec', { code, ...this._cmdOpts() })) as Record<string, unknown>;
}
async wait(options: number | WaitOptions): Promise<void> {
if (typeof options === 'number') {
await new Promise(resolve => setTimeout(resolve, options * 1000));
return;
}
if (options.time) {
if (typeof options.time === 'number') {
await new Promise(resolve => setTimeout(resolve, options.time! * 1000));
return;
}
if (options.text) {
const timeout = (options.timeout ?? 30) * 1000;
const code = waitForTextJs(options.text, timeout);
await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
await sendCommand('exec', { code, ...this._cmdOpts() });
}
}
async tabs(): Promise<any> {
return sendCommand('tabs', { op: 'list', ...this._workspaceOpt() });
async tabs(): Promise<unknown[]> {
const result = await sendCommand('tabs', { op: 'list', ...this._wsOpt() });
return Array.isArray(result) ? result : [];
}
async closeTab(index?: number): Promise<void> {
await sendCommand('tabs', { op: 'close', ...this._workspaceOpt(), ...(index !== undefined ? { index } : {}) });
await sendCommand('tabs', { op: 'close', ...this._wsOpt(), ...(index !== undefined ? { index } : {}) });
// Invalidate cached tabId — the closed tab might have been our active one.
// We can't know for sure (close-by-index doesn't return tabId), so reset.
this._tabId = undefined;
}
async newTab(): Promise<void> {
await sendCommand('tabs', { op: 'new', ...this._workspaceOpt() });
const result = await sendCommand('tabs', { op: 'new', ...this._wsOpt() }) as { tabId?: number };
if (result?.tabId) this._tabId = result.tabId;
}
async selectTab(index: number): Promise<void> {
await sendCommand('tabs', { op: 'select', index, ...this._workspaceOpt() });
const result = await sendCommand('tabs', { op: 'select', index, ...this._wsOpt() }) as { selected?: number };
if (result?.selected) this._tabId = result.selected;
}
async networkRequests(includeStatic: boolean = false): Promise<any> {
async networkRequests(includeStatic: boolean = false): Promise<unknown[]> {
const code = networkRequestsJs(includeStatic);
return sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
const result = await sendCommand('exec', { code, ...this._cmdOpts() });
return Array.isArray(result) ? result : [];
}
/**
@@ -171,7 +230,7 @@ export class Page implements IPage {
* Would require CDP Runtime.consoleAPICalled event listener.
* @returns Always returns empty array.
*/
async consoleMessages(_level: string = 'info'): Promise<any> {
async consoleMessages(_level: string = 'info'): Promise<unknown[]> {
return [];
}
@@ -182,26 +241,16 @@ export class Page implements IPage {
* @param options.fullPage - capture full scrollable page
* @param options.path - save to file path (returns base64 if omitted)
*/
async screenshot(options: {
format?: 'png' | 'jpeg';
quality?: number;
fullPage?: boolean;
path?: string;
} = {}): Promise<string> {
async screenshot(options: ScreenshotOptions = {}): Promise<string> {
const base64 = await sendCommand('screenshot', {
...this._workspaceOpt(),
...this._cmdOpts(),
format: options.format,
quality: options.quality,
fullPage: options.fullPage,
...this._tabOpt(),
}) as string;
if (options.path) {
const fs = await import('node:fs');
const path = await import('node:path');
const dir = path.dirname(options.path);
await fs.promises.mkdir(dir, { recursive: true });
await fs.promises.writeFile(options.path, Buffer.from(base64, 'base64'));
await saveBase64ToFile(base64, options.path);
}
return base64;
@@ -209,14 +258,14 @@ export class Page implements IPage {
async scroll(direction: string = 'down', amount: number = 500): Promise<void> {
const code = scrollJs(direction, amount);
await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
await sendCommand('exec', { code, ...this._cmdOpts() });
}
async autoScroll(options: { times?: number; delayMs?: number } = {}): Promise<void> {
const times = options.times ?? 3;
const delayMs = options.delayMs ?? 2000;
const code = autoScrollJs(times, delayMs);
await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
await sendCommand('exec', { code, ...this._cmdOpts() });
}
async installInterceptor(pattern: string): Promise<void> {
@@ -229,11 +278,11 @@ export class Page implements IPage {
}));
}
async getInterceptedRequests(): Promise<any[]> {
async getInterceptedRequests(): Promise<unknown[]> {
const { generateReadInterceptedJs } = await import('../interceptor.js');
// Same as installInterceptor: must go through evaluate() for IIFE wrapping
const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
return (result as any[]) || [];
return Array.isArray(result) ? result : [];
}
}
+156
View File
@@ -0,0 +1,156 @@
/**
* Stealth anti-detection module.
*
* Generates JS code that patches browser globals to hide automation
* fingerprints (e.g. navigator.webdriver, missing chrome object, empty
* plugin list). Injected before page scripts run so that websites cannot
* detect CDP / extension-based control.
*
* Inspired by puppeteer-extra-plugin-stealth.
*/
/**
* Return a self-contained JS string that, when evaluated in a page context,
* applies all stealth patches. Safe to call multiple times — the guard flag
* ensures patches are applied only once.
*/
export function generateStealthJs(): string {
return `
(() => {
// Guard: prevent double-injection across separate CDP evaluations.
// We cannot use a closure variable (each eval is a fresh scope), and
// window properties / Symbols are discoverable by anti-bot scripts.
// Instead, stash the flag in a non-enumerable getter on a built-in
// prototype that fingerprinters are unlikely to scan.
const _gProto = EventTarget.prototype;
const _gKey = '__lsn'; // looks like an internal listener cache
if (_gProto[_gKey]) return 'skipped';
try {
Object.defineProperty(_gProto, _gKey, { value: true, enumerable: false, configurable: true });
} catch {}
// 1. navigator.webdriver → false
// Most common check; Playwright/Puppeteer/CDP set this to true.
// Real Chrome returns false (not undefined) — returning undefined is
// itself a detection signal for advanced fingerprinters.
try {
Object.defineProperty(navigator, 'webdriver', {
get: () => false,
configurable: true,
});
} catch {}
// 2. window.chrome stub
// Real Chrome exposes window.chrome with runtime, loadTimes, csi.
// Headless/automated Chrome may not have it.
try {
if (!window.chrome) {
window.chrome = {
runtime: {
onConnect: { addListener: () => {}, removeListener: () => {} },
onMessage: { addListener: () => {}, removeListener: () => {} },
},
loadTimes: () => ({}),
csi: () => ({}),
};
}
} catch {}
// 3. navigator.plugins — fake population only if empty
// Real user browser already has plugins; only patch in automated/headless
// contexts where the list is empty (overwriting real plugins with fakes
// would be counterproductive and detectable).
try {
if (!navigator.plugins || navigator.plugins.length === 0) {
const fakePlugins = [
{ name: 'PDF Viewer', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
{ name: 'Chrome PDF Viewer', filename: 'internal-pdf-viewer', description: '' },
{ name: 'Chromium PDF Viewer', filename: 'internal-pdf-viewer', description: '' },
{ name: 'Microsoft Edge PDF Viewer', filename: 'internal-pdf-viewer', description: '' },
{ name: 'WebKit built-in PDF', filename: 'internal-pdf-viewer', description: '' },
];
fakePlugins.item = (i) => fakePlugins[i] || null;
fakePlugins.namedItem = (n) => fakePlugins.find(p => p.name === n) || null;
fakePlugins.refresh = () => {};
Object.defineProperty(navigator, 'plugins', {
get: () => fakePlugins,
configurable: true,
});
}
} catch {}
// 4. navigator.languages — guarantee non-empty
// Some automated contexts return undefined or empty array.
try {
if (!navigator.languages || navigator.languages.length === 0) {
Object.defineProperty(navigator, 'languages', {
get: () => ['en-US', 'en'],
configurable: true,
});
}
} catch {}
// 5. Permissions.query — normalize notification permission
// Headless Chrome throws on Permissions.query({ name: 'notifications' }).
try {
const origQuery = window.Permissions?.prototype?.query;
if (origQuery) {
window.Permissions.prototype.query = function (parameters) {
if (parameters?.name === 'notifications') {
return Promise.resolve({ state: Notification.permission, onchange: null });
}
return origQuery.call(this, parameters);
};
}
} catch {}
// 6. Clean automation artifacts
// Remove properties left by Playwright, Puppeteer, or CDP injection.
try {
delete window.__playwright;
delete window.__puppeteer;
// ChromeDriver injects cdc_ prefixed globals; the suffix varies by version,
// so scan window for any matching property rather than hardcoding names.
for (const prop of Object.getOwnPropertyNames(window)) {
if (prop.startsWith('cdc_') || prop.startsWith('__cdc_')) {
try { delete window[prop]; } catch {}
}
}
} catch {}
// 7. CDP stack trace cleanup
// Runtime.evaluate injects scripts whose source URLs appear in Error
// stack traces (e.g. __puppeteer_evaluation_script__, pptr:, debugger://).
// Websites detect automation by doing: new Error().stack and inspecting it.
// We override the stack property getter on Error.prototype to filter them.
// Note: Error.prepareStackTrace is V8/Node-only and not available in
// browser page context, so we use a property descriptor approach instead.
// We use generic protocol patterns instead of product-specific names to
// also catch our own injected code frames without leaking identifiers.
try {
const _origDescriptor = Object.getOwnPropertyDescriptor(Error.prototype, 'stack');
const _cdpPatterns = [
'puppeteer_evaluation_script',
'pptr:',
'debugger://',
'__playwright',
'__puppeteer',
];
if (_origDescriptor && _origDescriptor.get) {
Object.defineProperty(Error.prototype, 'stack', {
get: function () {
const raw = _origDescriptor.get.call(this);
if (typeof raw !== 'string') return raw;
return raw.split('\\n').filter(line =>
!_cdpPatterns.some(p => line.includes(p))
).join('\\n');
},
configurable: true,
});
}
} catch {}
return 'applied';
})()
`;
}
+70 -2
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest';
import { parseTsArgsBlock } from './build-manifest.js';
import { afterEach, describe, expect, it } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { parseTsArgsBlock, scanTs, shouldReplaceManifestEntry } from './build-manifest.js';
describe('parseTsArgsBlock', () => {
it('keeps args with nested choices arrays', () => {
@@ -62,3 +65,68 @@ describe('parseTsArgsBlock', () => {
]);
});
});
describe('manifest helper rules', () => {
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it('prefers TS adapters over duplicate YAML adapters', () => {
expect(shouldReplaceManifestEntry(
{
site: 'demo',
name: 'search',
description: 'yaml',
strategy: 'public',
browser: false,
args: [],
type: 'yaml',
},
{
site: 'demo',
name: 'search',
description: 'ts',
strategy: 'public',
browser: false,
args: [],
type: 'ts',
modulePath: 'demo/search.js',
},
)).toBe(true);
expect(shouldReplaceManifestEntry(
{
site: 'demo',
name: 'search',
description: 'ts',
strategy: 'public',
browser: false,
args: [],
type: 'ts',
modulePath: 'demo/search.js',
},
{
site: 'demo',
name: 'search',
description: 'yaml',
strategy: 'public',
browser: false,
args: [],
type: 'yaml',
},
)).toBe(false);
});
it('skips TS files that do not register a cli', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-manifest-'));
tempDirs.push(dir);
const file = path.join(dir, 'utils.ts');
fs.writeFileSync(file, `export function helper() { return 'noop'; }`);
expect(scanTs(file, 'demo')).toBeNull();
});
});
+70 -27
View File
@@ -13,12 +13,13 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import yaml from 'js-yaml';
import { getErrorMessage } from './errors.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CLIS_DIR = path.resolve(__dirname, 'clis');
const OUTPUT = path.resolve(__dirname, '..', 'dist', 'cli-manifest.json');
interface ManifestEntry {
export interface ManifestEntry {
site: string;
name: string;
description: string;
@@ -28,21 +29,28 @@ interface ManifestEntry {
args: Array<{
name: string;
type?: string;
default?: any;
default?: unknown;
required?: boolean;
positional?: boolean;
help?: string;
choices?: string[];
}>;
columns?: string[];
pipeline?: any[];
pipeline?: Record<string, unknown>[];
timeout?: number;
/** 'yaml' or 'ts' — determines how executeCommand loads the handler */
type: 'yaml' | 'ts';
/** Relative path from clis/ dir, e.g. 'bilibili/hot.yaml' or 'bilibili/search.js' */
modulePath?: string;
/** Pre-navigation control — see CliCommand.navigateBefore */
navigateBefore?: boolean | string;
}
import type { YamlCliDefinition } from './yaml-schema.js';
import { isRecord } from './utils.js';
function extractBalancedBlock(
source: string,
startIndex: number,
@@ -127,7 +135,7 @@ export function parseTsArgsBlock(argsBlock: string): ManifestEntry['args'] {
const helpMatch = body.match(/help\s*:\s*['"`]([^'"`]*)['"`]/);
const positionalMatch = body.match(/positional\s*:\s*(true|false)/);
let defaultVal: any = undefined;
let defaultVal: unknown = undefined;
if (defaultMatch) {
const raw = defaultMatch[1].trim();
if (raw === 'true') defaultVal = true;
@@ -147,7 +155,8 @@ export function parseTsArgsBlock(argsBlock: string): ManifestEntry['args'] {
choices: parseInlineChoices(body),
});
cursor = objectStart + body.length + 2;
cursor = objectStart + body.length;
if (cursor <= objectStart) break; // safety: prevent infinite loop
}
return args;
@@ -156,21 +165,23 @@ export function parseTsArgsBlock(argsBlock: string): ManifestEntry['args'] {
function scanYaml(filePath: string, site: string): ManifestEntry | null {
try {
const raw = fs.readFileSync(filePath, 'utf-8');
const def = yaml.load(raw) as any;
if (!def || typeof def !== 'object') return null;
const def = yaml.load(raw) as YamlCliDefinition | null;
if (!isRecord(def)) return null;
const cliDef = def as YamlCliDefinition;
const strategyStr = def.strategy ?? (def.browser === false ? 'public' : 'cookie');
const strategyStr = cliDef.strategy ?? (cliDef.browser === false ? 'public' : 'cookie');
const strategy = strategyStr.toUpperCase();
const browser = def.browser ?? (strategy !== 'PUBLIC');
const browser = cliDef.browser ?? (strategy !== 'PUBLIC');
const args: ManifestEntry['args'] = [];
if (def.args && typeof def.args === 'object') {
for (const [argName, argDef] of Object.entries(def.args as Record<string, any>)) {
if (cliDef.args && typeof cliDef.args === 'object') {
for (const [argName, argDef] of Object.entries(cliDef.args)) {
args.push({
name: argName,
type: argDef?.type ?? 'str',
default: argDef?.default,
required: argDef?.required ?? false,
positional: argDef?.positional === true || undefined,
help: argDef?.description ?? argDef?.help ?? '',
choices: argDef?.choices,
});
@@ -178,25 +189,26 @@ function scanYaml(filePath: string, site: string): ManifestEntry | null {
}
return {
site: def.site ?? site,
name: def.name ?? path.basename(filePath, path.extname(filePath)),
description: def.description ?? '',
domain: def.domain,
site: cliDef.site ?? site,
name: cliDef.name ?? path.basename(filePath, path.extname(filePath)),
description: cliDef.description ?? '',
domain: cliDef.domain,
strategy: strategy.toLowerCase(),
browser,
args,
columns: def.columns,
pipeline: def.pipeline,
timeout: def.timeout,
columns: cliDef.columns,
pipeline: cliDef.pipeline,
timeout: cliDef.timeout,
type: 'yaml',
navigateBefore: cliDef.navigateBefore,
};
} catch (err: any) {
process.stderr.write(`Warning: failed to parse ${filePath}: ${err.message}\n`);
} catch (err) {
process.stderr.write(`Warning: failed to parse ${filePath}: ${getErrorMessage(err)}\n`);
return null;
}
}
function scanTs(filePath: string, site: string): ManifestEntry | null {
export function scanTs(filePath: string, site: string): ManifestEntry | null {
// TS adapters self-register via cli() at import time.
// We statically parse the source to extract metadata for the manifest stub.
const baseName = path.basename(filePath, path.extname(filePath));
@@ -248,16 +260,29 @@ function scanTs(filePath: string, site: string): ManifestEntry | null {
entry.args = parseTsArgsBlock(argsBlock);
}
// Extract navigateBefore: false
const navMatch = src.match(/navigateBefore\s*:\s*(true|false)/);
if (navMatch) entry.navigateBefore = navMatch[1] === 'true' ? true : false;
return entry;
} catch (err: any) {
} catch (err) {
// If parsing fails, log a warning (matching scanYaml behaviour) and skip the entry.
process.stderr.write(`Warning: failed to scan ${filePath}: ${err.message}\n`);
process.stderr.write(`Warning: failed to scan ${filePath}: ${getErrorMessage(err)}\n`);
return null;
}
}
/**
* When both YAML and TS adapters exist for the same site/name,
* prefer the TS version (it self-registers and typically has richer logic).
*/
export function shouldReplaceManifestEntry(current: ManifestEntry, next: ManifestEntry): boolean {
if (current.type === next.type) return false;
return current.type === 'yaml' && next.type === 'ts';
}
export function buildManifest(): ManifestEntry[] {
const manifest: ManifestEntry[] = [];
const manifest = new Map<string, ManifestEntry>();
if (fs.existsSync(CLIS_DIR)) {
for (const site of fs.readdirSync(CLIS_DIR)) {
@@ -267,19 +292,37 @@ export function buildManifest(): ManifestEntry[] {
const filePath = path.join(siteDir, file);
if (file.endsWith('.yaml') || file.endsWith('.yml')) {
const entry = scanYaml(filePath, site);
if (entry) manifest.push(entry);
if (entry) {
const key = `${entry.site}/${entry.name}`;
const existing = manifest.get(key);
if (!existing || shouldReplaceManifestEntry(existing, entry)) {
if (existing && existing.type !== entry.type) {
process.stderr.write(`⚠️ Duplicate adapter ${key}: ${existing.type} superseded by ${entry.type}\n`);
}
manifest.set(key, entry);
}
}
} else if (
(file.endsWith('.ts') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts') && file !== 'index.ts') ||
(file.endsWith('.js') && !file.endsWith('.d.js') && !file.endsWith('.test.js') && file !== 'index.js')
) {
const entry = scanTs(filePath, site);
if (entry) manifest.push(entry);
if (entry) {
const key = `${entry.site}/${entry.name}`;
const existing = manifest.get(key);
if (!existing || shouldReplaceManifestEntry(existing, entry)) {
if (existing && existing.type !== entry.type) {
process.stderr.write(`⚠️ Duplicate adapter ${key}: ${existing.type} superseded by ${entry.type}\n`);
}
manifest.set(key, entry);
}
}
}
}
}
}
return manifest;
return [...manifest.values()];
}
function main(): void {
+2 -1
View File
@@ -145,9 +145,10 @@ export async function cascadeProbe(
url: string,
opts: { maxStrategy?: Strategy; timeout?: number } = {},
): Promise<CascadeResult> {
const maxIdx = opts.maxStrategy
const rawIdx = opts.maxStrategy
? CASCADE_ORDER.indexOf(opts.maxStrategy)
: CASCADE_ORDER.indexOf(Strategy.HEADER); // Don't auto-try INTERCEPT/UI
const maxIdx = rawIdx === -1 ? CASCADE_ORDER.indexOf(Strategy.HEADER) : rawIdx;
const probes: ProbeResult[] = [];
+122 -32
View File
@@ -140,7 +140,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
: undefined;
const workspace = `explore:${inferHost(url, opts.site)}`;
const result = await exploreUrl(url, {
BrowserFactory: getBrowserFactory() as any,
BrowserFactory: getBrowserFactory(),
site: opts.site,
goal: opts.goal,
waitSeconds: parseFloat(opts.wait),
@@ -172,9 +172,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
const workspace = `generate:${inferHost(url, opts.site)}`;
const r = await generateCliFromUrl({
url,
BrowserFactory: getBrowserFactory() as any,
builtinClis: BUILTIN_CLIS,
userClis: USER_CLIS,
BrowserFactory: getBrowserFactory(),
goal: opts.goal,
site: opts.site,
workspace,
@@ -183,6 +181,30 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
process.exitCode = r.ok ? 0 : 1;
});
// ── Built-in: record ─────────────────────────────────────────────────────
program
.command('record')
.description('Record API calls from a live browser session → generate YAML candidates')
.argument('<url>', 'URL to open and record')
.option('--site <name>', 'Site name (inferred from URL if omitted)')
.option('--out <dir>', 'Output directory for candidates')
.option('--poll <ms>', 'Poll interval in milliseconds', '2000')
.option('--timeout <ms>', 'Auto-stop after N milliseconds (default: 60000)', '60000')
.action(async (url, opts) => {
const { recordSession, renderRecordSummary } = await import('./record.js');
const result = await recordSession({
BrowserFactory: getBrowserFactory(),
url,
site: opts.site,
outDir: opts.out,
pollMs: parseInt(opts.poll, 10),
timeoutMs: parseInt(opts.timeout, 10),
});
console.log(renderRecordSummary(result));
process.exitCode = result.candidateCount > 0 ? 0 : 1;
});
program
.command('cascade')
.description('Strategy cascade: find simplest working strategy')
@@ -202,12 +224,12 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
console.log(renderCascadeResult(result));
});
// ── Built-in: doctor / setup / completion ─────────────────────────────────
// ── Built-in: doctor / completion ──────────────────────────────────────────
program
.command('doctor')
.description('Diagnose opencli browser bridge connectivity')
.option('--live', 'Test browser connectivity (requires Chrome running)', false)
.option('--no-live', 'Skip live browser connectivity test')
.option('--sessions', 'Show active automation sessions', false)
.action(async (opts) => {
const { runBrowserDoctor, renderBrowserDoctorReport } = await import('./doctor.js');
@@ -215,14 +237,6 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
console.log(renderBrowserDoctorReport(report));
});
program
.command('setup')
.description('Interactive setup: verify browser bridge connectivity')
.action(async () => {
const { runSetup } = await import('./setup.js');
await runSetup({ cliVersion: PKG_VERSION });
});
program
.command('completion')
.description('Output shell completion script')
@@ -231,6 +245,94 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
printCompletionScript(shell);
});
// ── Plugin management ──────────────────────────────────────────────────────
const pluginCmd = program.command('plugin').description('Manage opencli plugins');
pluginCmd
.command('install')
.description('Install a plugin from GitHub')
.argument('<source>', 'Plugin source (e.g. github:user/repo)')
.action(async (source: string) => {
const { installPlugin } = await import('./plugin.js');
const { discoverPlugins } = await import('./discovery.js');
try {
const name = installPlugin(source);
await discoverPlugins();
console.log(chalk.green(`✅ Plugin "${name}" installed successfully. Commands are ready to use.`));
} catch (err: any) {
console.error(chalk.red(`Error: ${err.message}`));
process.exitCode = 1;
}
});
pluginCmd
.command('uninstall')
.description('Uninstall a plugin')
.argument('<name>', 'Plugin name')
.action(async (name: string) => {
const { uninstallPlugin } = await import('./plugin.js');
try {
uninstallPlugin(name);
console.log(chalk.green(`✅ Plugin "${name}" uninstalled.`));
} catch (err: any) {
console.error(chalk.red(`Error: ${err.message}`));
process.exitCode = 1;
}
});
pluginCmd
.command('update')
.description('Update a plugin to the latest version')
.argument('<name>', 'Plugin name')
.action(async (name: string) => {
const { updatePlugin } = await import('./plugin.js');
const { discoverPlugins } = await import('./discovery.js');
try {
updatePlugin(name);
await discoverPlugins();
console.log(chalk.green(`✅ Plugin "${name}" updated successfully.`));
} catch (err: any) {
console.error(chalk.red(`Error: ${err.message}`));
process.exitCode = 1;
}
});
pluginCmd
.command('list')
.description('List installed plugins')
.option('-f, --format <fmt>', 'Output format: table, json', 'table')
.action(async (opts) => {
const { listPlugins } = await import('./plugin.js');
const plugins = listPlugins();
if (plugins.length === 0) {
console.log(chalk.dim(' No plugins installed.'));
console.log(chalk.dim(` Install one with: opencli plugin install github:user/repo`));
return;
}
if (opts.format === 'json') {
renderOutput(plugins, {
fmt: 'json',
columns: ['name', 'commands', 'source'],
title: 'opencli/plugins',
source: 'opencli plugin list',
});
return;
}
console.log();
console.log(chalk.bold(' Installed plugins'));
console.log();
for (const p of plugins) {
const cmds = p.commands.length > 0 ? chalk.dim(` (${p.commands.join(', ')})`) : '';
const src = p.source ? chalk.dim(`${p.source}`) : '';
console.log(` ${chalk.cyan(p.name)}${cmds}${src}`);
}
console.log();
console.log(chalk.dim(` ${plugins.length} plugin(s) installed`));
console.log();
});
// ── External CLIs ─────────────────────────────────────────────────────────
const externalClis = loadExternalClis();
@@ -304,29 +406,17 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
registerAllCommands(program, siteGroups);
// ── Unknown command fallback ──────────────────────────────────────────────
const DENY_LIST = new Set([
'rm', 'sudo', 'dd', 'mkfs', 'fdisk', 'shutdown', 'reboot',
'kill', 'killall', 'chmod', 'chown', 'passwd', 'su', 'mount',
'umount', 'format', 'diskutil',
]);
// Security: do NOT auto-discover and register arbitrary system binaries.
// Only explicitly registered external CLIs (via `opencli register`) are allowed.
program.on('command:*', (operands: string[]) => {
const binary = operands[0];
if (DENY_LIST.has(binary)) {
console.error(chalk.red(`Refusing to register system command '${binary}'.`));
process.exitCode = 1;
return;
}
console.error(chalk.red(`error: unknown command '${binary}'`));
if (isBinaryInstalled(binary)) {
console.log(chalk.cyan(`🔹 Auto-discovered local CLI '${binary}'. Registering...`));
registerExternalCli(binary);
passthroughExternal(binary);
} else {
console.error(chalk.red(`error: unknown command '${binary}'`));
program.outputHelp();
process.exitCode = 1;
console.error(chalk.dim(` Tip: '${binary}' exists on your PATH. Use 'opencli register ${binary}' to add it as an external CLI.`));
}
program.outputHelp();
process.exitCode = 1;
});
program.parse();
+117
View File
@@ -0,0 +1,117 @@
/**
* Shared command factories for Electron/desktop app adapters.
* Eliminates duplicate screenshot/status/new/dump implementations
* across cursor, codex, chatwise, etc.
*/
import * as fs from 'node:fs';
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
/**
* Factory: capture DOM HTML + accessibility snapshot.
*/
export function makeScreenshotCommand(site: string, displayName?: string) {
const label = displayName ?? site;
return cli({
site,
name: 'screenshot',
description: `Capture a snapshot of the current ${label} window (DOM + Accessibility tree)`,
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [
{ name: 'output', required: false, help: `Output file path (default: /tmp/${site}-snapshot.txt)` },
],
columns: ['Status', 'File'],
func: async (page: IPage, kwargs: any) => {
const outputPath = (kwargs.output as string) || `/tmp/${site}-snapshot.txt`;
const snap = await page.snapshot({ compact: true });
const html = await page.evaluate('document.documentElement.outerHTML');
const htmlPath = outputPath.replace(/\.\w+$/, '') + '-dom.html';
const snapPath = outputPath.replace(/\.\w+$/, '') + '-a11y.txt';
fs.writeFileSync(htmlPath, html);
fs.writeFileSync(snapPath, typeof snap === 'string' ? snap : JSON.stringify(snap, null, 2));
return [
{ Status: 'Success', File: htmlPath },
{ Status: 'Success', File: snapPath },
];
},
});
}
/**
* Factory: check CDP connection status.
*/
export function makeStatusCommand(site: string, displayName?: string) {
const label = displayName ?? site;
return cli({
site,
name: 'status',
description: `Check active CDP connection to ${label}`,
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
columns: ['Status', 'Url', 'Title'],
func: async (page: IPage) => {
const url = await page.evaluate('window.location.href');
const title = await page.evaluate('document.title');
return [{ Status: 'Connected', Url: url, Title: title }];
},
});
}
/**
* Factory: start a new session via Cmd/Ctrl+N.
*/
export function makeNewCommand(site: string, displayName?: string) {
const label = displayName ?? site;
return cli({
site,
name: 'new',
description: `Start a new ${label} session`,
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
columns: ['Status'],
func: async (page: IPage) => {
const isMac = process.platform === 'darwin';
await page.pressKey(isMac ? 'Meta+N' : 'Control+N');
await page.wait(1);
return [{ Status: 'Success' }];
},
});
}
/**
* Factory: dump DOM + snapshot for reverse-engineering.
*/
export function makeDumpCommand(site: string) {
return cli({
site,
name: 'dump',
description: `Dump the DOM and Accessibility tree of ${site} for reverse-engineering`,
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
columns: ['action', 'files'],
func: async (page: IPage) => {
const dom = await page.evaluate('document.body.innerHTML');
fs.writeFileSync(`/tmp/${site}-dom.html`, dom);
const snap = await page.snapshot({ interactive: false });
fs.writeFileSync(`/tmp/${site}-snapshot.json`, JSON.stringify(snap, null, 2));
return [
{
action: 'Dom extraction finished',
files: `/tmp/${site}-dom.html, /tmp/${site}-snapshot.json`,
},
];
},
});
}
-5
View File
@@ -1,5 +0,0 @@
# Antigravity Adapter
Control **Antigravity Ultra** from the terminal via Chrome DevTools Protocol (CDP).
📖 **Full documentation**: [docs/adapters/desktop/antigravity](../../../docs/adapters/desktop/antigravity.md)
-51
View File
@@ -1,51 +0,0 @@
# Antigravity CLI Adapter (探针插件)
🔥 **opencli 支持 CLI 化所有 electron 应用!最强大更新来袭!** 🔥
CLI all electron!现在支持把所有 electron 应用 CLI 化,从而组合出各种神奇的能力。
如果你在使用诸如 Antigravity Ultra 等工具时觉得不够灵活或难以扩展,现在通过 OpenCLI 把他 CLI 化,轻松打破界限。
现在,**AI 可以自己控制自己**!结合 cc/openclaw 就可以远程控制任何 electron 应用!无限玩法!!
通过 Chrome DevTools Protocol (CDP),将你本地运行的 Antigravity 桌面客户端转变为一个完全可编程的 AI 节点。这让你可以在命令行终端中直接操控它的 UI 界面,实现真正的“零 API 限制”本地自动化大模型工作流调度。
## 开发准备
首先,**请在终端启动 Antigravity 桌面版**,并附加上允许远程调试(CDP)的内核启动参数:
\`\`\`bash
# 在后台启动并驻留
/Applications/Antigravity.app/Contents/MacOS/Electron \
--remote-debugging-port=9224
\`\`\`
*(注意:如果你打包的应用重命名过主构建,可能需要把 `Electron` 换成实际的可执行文件名,如 `Antigravity`)*
接下来,在你想执行 CLI 命令的另一个新终端板块里,声明要连入的本地调试端口环境变量:
\`\`\`bash
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9224"
\`\`\`
## 全部指令一览
### \`opencli antigravity status\`
快速检查当前探针与内核 CDP 的连接状态。会返回底层的当前 URL 和网页 Title。
### \`opencli antigravity send <message>\`
给 Agent 发送消息。它会自动定位到底部的 Lexical 输入框,安全地注入你的指定文本然后模拟回车发送。
### \`opencli antigravity read\`
全量抓取当前的对话面板,将所有历史聊天记录作为一整块纯文本取回。
### \`opencli antigravity new\`
模拟点击侧边栏顶部的“开启新对话”按钮,瞬间清空并重置 Agent 的上下文状态。
### \`opencli antigravity extract-code\`
从当前的 Agent 聊天记录中单独提取所有的多行代码块。非常适合自动化脚手架开发(例如直接重定向输出写入本地文件:\`opencli antigravity extract-code > script.sh\`)。
### \`opencli antigravity model <name>\`
切换大模型引擎。只需传入关键词(比如:\`opencli antigravity model claude\` 或 \`model gemini\`),它会自动帮你点开模型选择菜单并模拟点击。
### \`opencli antigravity watch\`
开启一个长连接流式监听。通过持续轮询 DOM 的变化量,它能像流式 API 一样,在终端实时向你推送 Agent 刚刚打出的那一行最新回复,直到你按 Ctrl+C 中止。
+1 -1
View File
@@ -15,7 +15,7 @@ cli({
columns: ['id', 'title', 'authors', 'published'],
func: async (_page, args) => {
const limit = Math.max(1, Math.min(Number(args.limit), 25));
const query = encodeURIComponent(`all:${args.keyword}`);
const query = encodeURIComponent(`all:${args.query}`);
const xml = await arxivFetch(`search_query=${query}&max_results=${limit}&sortBy=relevance`);
const entries = parseEntries(xml);
if (!entries.length) throw new CliError('NOT_FOUND', 'No papers found', 'Try a different keyword');
+1 -1
View File
@@ -12,7 +12,7 @@ cli({
domain: 'www.barchart.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'symbol', required: true, help: 'Stock ticker (e.g. AAPL)' },
{ name: 'symbol', required: true, positional: true, help: 'Stock ticker (e.g. AAPL)' },
{ name: 'expiration', type: 'str', help: 'Expiration date (YYYY-MM-DD). Defaults to the nearest available expiration.' },
{ name: 'limit', type: 'int', default: 10, help: 'Number of near-the-money strikes per type' },
],
+1 -1
View File
@@ -11,7 +11,7 @@ cli({
domain: 'www.barchart.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'symbol', required: true, help: 'Stock ticker (e.g. AAPL)' },
{ name: 'symbol', required: true, positional: true, help: 'Stock ticker (e.g. AAPL)' },
{ name: 'type', type: 'str', default: 'Call', help: 'Option type: Call or Put', choices: ['Call', 'Put'] },
{ name: 'limit', type: 'int', default: 20, help: 'Max number of strikes to return' },
],
+1 -1
View File
@@ -11,7 +11,7 @@ cli({
domain: 'www.barchart.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'symbol', required: true, help: 'Stock ticker (e.g. AAPL, MSFT, TSLA)' },
{ name: 'symbol', required: true, positional: true, help: 'Stock ticker (e.g. AAPL, MSFT, TSLA)' },
],
columns: [
'symbol', 'name', 'price', 'change', 'changePct',
+24 -82
View File
@@ -8,18 +8,9 @@
* - yt-dlp must be installed: pip install yt-dlp
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { cli, Strategy } from '../../registry.js';
import {
ytdlpDownload,
checkYtdlp,
sanitizeFilename,
getTempDir,
exportCookiesToNetscape,
formatCookieHeader,
} from '../../download/index.js';
import { DownloadProgressTracker, formatBytes } from '../../download/progress.js';
import { checkYtdlp, sanitizeFilename } from '../../download/index.js';
import { downloadMedia } from '../../download/media-download.js';
cli({
site: 'bilibili',
@@ -28,7 +19,7 @@ cli({
domain: 'www.bilibili.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'bvid', required: true, help: 'Video BV ID (e.g., BV1xxx)' },
{ name: 'bvid', required: true, positional: true, help: 'Video BV ID (e.g., BV1xxx)' },
{ name: 'output', default: './bilibili-downloads', help: 'Output directory' },
{ name: 'quality', default: 'best', help: 'Video quality (best, 1080p, 720p, 480p)' },
],
@@ -63,21 +54,8 @@ cli({
const title = sanitizeFilename(data?.title || 'video');
// Extract cookies for authenticated downloads
const cookies = await page.getCookies({ domain: 'bilibili.com' });
const cookieString = formatCookieHeader(cookies);
// Create output directory
fs.mkdirSync(output, { recursive: true });
// Export cookies to Netscape format for yt-dlp
let cookiesFile: string | undefined;
if (cookies.length > 0) {
const tempDir = getTempDir();
fs.mkdirSync(tempDir, { recursive: true });
cookiesFile = path.join(tempDir, `bilibili_cookies_${Date.now()}.txt`);
exportCookiesToNetscape(cookies, cookiesFile);
}
// Extract cookies for yt-dlp
const browserCookies = await page.getCookies({ domain: 'bilibili.com' });
// Build yt-dlp format string based on quality
let format = 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best';
@@ -89,62 +67,26 @@ cli({
format = 'bestvideo[height<=480][ext=mp4]+bestaudio[ext=m4a]/best[height<=480]';
}
const destPath = path.join(output, `${bvid}_${title}.mp4`);
const videoUrl = `https://www.bilibili.com/video/${bvid}`;
const filename = `${bvid}_${title}.mp4`;
const tracker = new DownloadProgressTracker(1, true);
const progressBar = tracker.onFileStart(`${bvid}.mp4`, 0);
const results = await downloadMedia(
[{ type: 'video-ytdlp', url: videoUrl, filename }],
{
output,
browserCookies,
filenamePrefix: bvid,
ytdlpExtraArgs: ['-f', format, '--merge-output-format', 'mp4', '--embed-thumbnail'],
},
);
try {
const result = await ytdlpDownload(
`https://www.bilibili.com/video/${bvid}`,
destPath,
{
cookiesFile,
format,
extraArgs: [
'--merge-output-format', 'mp4',
'--embed-thumbnail',
],
onProgress: (percent) => {
if (progressBar) progressBar.update(percent, 100);
},
},
);
if (progressBar) {
progressBar.complete(result.success, result.success ? formatBytes(result.size) : undefined);
}
tracker.onFileComplete(result.success);
tracker.finish();
// Cleanup cookies file
if (cookiesFile && fs.existsSync(cookiesFile)) {
fs.unlinkSync(cookiesFile);
}
return [{
bvid,
title: data?.title || 'video',
status: result.success ? 'success' : 'failed',
size: result.success ? formatBytes(result.size) : (result.error || 'unknown error'),
}];
} catch (err: any) {
if (progressBar) progressBar.fail(err.message);
tracker.onFileComplete(false);
tracker.finish();
// Cleanup cookies file
if (cookiesFile && fs.existsSync(cookiesFile)) {
fs.unlinkSync(cookiesFile);
}
return [{
bvid,
title: data?.title || 'video',
status: 'failed',
size: err.message,
}];
}
// Map results to bilibili-specific columns
const r = results[0] || { status: 'failed', size: '-' };
return [{
bvid,
title: data?.title || 'video',
status: r.status,
size: r.size,
}];
},
});

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