Compare commits

...

131 Commits

Author SHA1 Message Date
jackwener 3b6f72ca08 docs: fix extension install instructions — no store yet, no restart needed
- Remove 'Chrome Web Store' references (not published yet)
- Add detailed unpacked extension install steps (chrome://extensions)
- Remove 'restart Chrome' advice (Service Worker activates immediately)
- Direct users to chrome://extensions for troubleshooting
2026-03-19 16:57:40 +08:00
jackwener beda0b714c docs: remove all remaining Playwright references from docs
Updated 6 files:
- CDP.md, CDP.zh-CN.md: Browser Bridge instead of Playwright MCP Bridge
- CLI-ELECTRON.md: Browser Bridge / IPage abstraction wording
- CLI-EXPLORER.md: browser tools instead of Playwright MCP tools
- TESTING.md: Browser Bridge extension mode, removed token references
- src/clis/chatgpt/README{,.zh-CN}.md: CDP instead of Playwright

Zero Playwright references remaining across all .md files.
2026-03-19 16:55:20 +08:00
jackwener 3b33ade214 docs: update README, README.zh-CN, SKILL.md for new Browser Bridge architecture
- Replace all Playwright MCP Bridge references with opencli Browser Bridge
- Remove token setup, MCP config, and manual setup sections
- Simplify prerequisites: just install extension, zero config
- Update troubleshooting: daemon status/logs commands
- Update env vars: add OPENCLI_DAEMON_PORT, OPENCLI_VERBOSE
- Update SKILL.md tags: mcp,playwright → chrome-extension,cdp
2026-03-19 16:49:10 +08:00
jackwener ebe4683a9e fix: add screenshot mock to executor.test.ts for IPage compat
tsc --noEmit failed because createMockPage() was missing the
screenshot() method added to IPage in the round 2 review fix.
2026-03-19 16:43:58 +08:00
jackwener 59c0d639a5 refactor: fix 9 issues from round 2 code review
Bug fixes:
- #1 /logs?level=error returned 404 — use pathname for route matching
- #2 Duplicate initialization — added 'initialized' guard flag

Should fix:
- #4 Added screenshot() to IPage interface
- #5 Graceful shutdown rejects pending requests before exit
- #6 Use process.execPath instead of 'npx tsx' for faster daemon spawn

Cleanup:
- #7 Removed duplicate 'browser' keyword in package.json
- #8 Removed unused normalizeEvaluateSource import from browser.ts
- #9 Changed dynamic import to static import in intercept.ts
- #10 Added explicit throw at end of sendCommand for clarity

61 tests pass (4 test files). Extension: 10.55KB.
2026-03-19 16:36:06 +08:00
jackwener 3d1f9640ea feat: forward extension console logs to daemon
Extension side:
- Hook console.log/warn/error → forward via WS as { type: 'log', level, msg, ts }
- Original console output preserved (for chrome://extensions debug)

Daemon side:
- Ring buffer (200 entries) stores extension logs
- Logs printed to daemon stderr with emoji prefix (📋/⚠️/)
- GET /logs — returns buffered logs (optional ?level= filter)
- DELETE /logs — clears log buffer

Usage:
  curl localhost:19825/logs              # view all logs
  curl localhost:19825/logs?level=error  # errors only
  curl -X DELETE localhost:19825/logs    # clear buffer

Extension build: 10.48KB
2026-03-19 16:21:17 +08:00
jackwener 8e8c4a0229 feat: add exponential backoff reconnect + CDP screenshot support
Exponential backoff:
- Reconnect delay: 2s, 4s, 8s, 16s, ..., capped at 60s
- Resets to base delay on successful connection
- Reduces idle CPU waste vs fixed 3s reconnect

Screenshot via CDP Page.captureScreenshot:
- New 'screenshot' action in protocol (5th action)
- Supports format (png/jpeg), quality, fullPage
- Full-page: uses Emulation.setDeviceMetricsOverride for scroll height
- CLI-side: page.screenshot() with optional file save
- Extension build: 9.81KB (+1.7KB from 8.11KB)

Inspired by bb-browser's architecture patterns.
2026-03-19 16:21:17 +08:00
jackwener 01b8b6b5bf refactor: fix 14 issues from deep code review
P0 Critical:
- #1 Fix double IIFE wrapping: unified wrapForEval() replaces
  normalizeEvaluateSource + ad-hoc wrap in page.evaluate()
- #2 Fix navigate race: check tab.status before addListener,
  reduced timeout 30s→15s

P1 Should Fix:
- #8 Remove unused permissions (scripting, host_permissions, content_scripts)
- #10 Add retry (3x, 500ms) + timeout (30s) to sendCommand()

P2 Cleanup:
- #3 Extract isWebUrl() to safely handle undefined tab.url
- #4 Sanitize maxDepth with Math.max/min bounds
- #6 Delete empty src/daemon/ directory
- #7 Remove dead createJsonRpcRequest + its test
- #9 Remove stale IIFE-mode comment
- #11 Validate body.id in daemon request handler
- #12 Guard ensureAttached: detach+re-attach on 'already attached'
- #14 Extract _tabOpt() helper (removes 13x spread duplication)
- #15 Add verbose warning for unsupported consoleMessages()

All 35 unit tests pass.
2026-03-19 16:21:17 +08:00
jackwener b2fa7daf57 feat: replace @playwright/mcp with lightweight daemon + Chrome Extension
Architecture:
- Micro-daemon (HTTP + WebSocket bridge, ~190 lines, auto-start/idle-exit)
- Chrome MV3 Extension using chrome.debugger CDP (10KB build)
- 5 protocol actions: exec, navigate, tabs, cookies, screenshot
- All DOM ops via JS evaluate — no extension update needed for new features

Key features:
- CDP Runtime.evaluate for JS execution in page context
- Tab management, cookie access via Chrome APIs
- Auto-start daemon on cold boot, idle auto-exit (5min)
- Minimal permissions: debugger, tabs, cookies, activeTab, alarms

Tested: zhihu hot (14.3s), twitter timeline (9.3s)
2026-03-19 16:21:17 +08:00
jackwener 0374b77d16 feat: Introduce Netease Music CLI with CDP enabler 2026-03-19 05:06:48 +08:00
jackwener c3efc5b492 0.9.8
Release / release (push) Has been cancelled
2026-03-19 01:38:55 +08:00
jackwener f85464c1aa 0.9.7 2026-03-19 01:38:49 +08:00
backtime1993 a4f94912cd fix(main): navigate to domain before cookie/header strategy commands in CDP mode (#71)
When using CDP mode (OPENCLI_CDP_ENDPOINT), the browser page context is
the user's active tab which may be on an unrelated domain. Cookie/header
strategy commands that use fetch() with credentials: 'include' then fail
with "Failed to fetch" due to the browser's same-origin policy.

Fix: before executing cookie/header strategy commands, navigate to the
command's declared domain so the fetch runs in same-origin context.
This mirrors the pre-navigation already done in the cascade command.

Affects all cookie-strategy adapters (bilibili, twitter, zhihu, xueqiu,
etc.) when OPENCLI_CDP_ENDPOINT is enabled and the active Chrome tab is
on a different site.

Co-authored-by: kensei <backtime1993@gmail.com>
2026-03-19 01:38:10 +08:00
Shuming Ying deb568dbe5 fix(browser): avoid selecting non-server playwright cli paths (#74)
Co-authored-by: root <root@localhost.localdomain>
2026-03-19 01:31:10 +08:00
Jingyu 32619fa553 fix(xiaohongshu): restore user profile note fetching (#69) 2026-03-19 00:10:33 +08:00
dependabot[bot] 1d871b35f0 chore(deps): bump commander from 13.1.0 to 14.0.3 (#67)
Bumps [commander](https://github.com/tj/commander.js) from 13.1.0 to 14.0.3.
- [Release notes](https://github.com/tj/commander.js/releases)
- [Changelog](https://github.com/tj/commander.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tj/commander.js/compare/v13.1.0...v14.0.3)

---
updated-dependencies:
- dependency-name: commander
  dependency-version: 14.0.3
  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-19 00:03:01 +08:00
dependabot[bot] 75e6ed4593 chore(ci): bump actions/setup-node from 4 to 6 (#65)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '6'
  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-19 00:02:29 +08:00
dependabot[bot] b07434b2a1 chore(ci): bump actions/checkout from 4 to 6 (#66)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  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-18 23:59:47 +08:00
AlexYue 515ce75f3b ci: add Dependabot, security audit, release-please, and CI optimization (#64)
* chore(ci): add Dependabot for npm and GitHub Actions updates

- Weekly npm dependency updates with PR limit of 10
- Weekly GitHub Actions version updates with PR limit of 5
- Conventional commit prefixes (chore(deps), chore(ci))

* ci: add security audit workflow

- Run npm audit on push/PR and weekly schedule
- Fail on high-severity vulnerabilities using audit-ci
- Only audit production dependencies

* ci: add release-please for automated changelog and versioning

- Auto-generate CHANGELOG.md from Conventional Commits
- Create version bump PRs on push to main
- Works alongside existing release.yml for npm publish

* ci: add concurrency controls and Node.js version matrix

- Add concurrency groups to ci, e2e-headed, security workflows
  to cancel duplicate runs on the same branch
- Test unit tests across Node 18/20/22 with fail-fast: false
- Update test step name to show Node version

* chore: bump minimum Node.js version from 18 to 20

- Update engines.node in package.json to >=20.0.0
- Update prerequisites in README.md and README.zh-CN.md
- Remove Node 18 from CI test matrix

* review: fix release token and prod-only audit scope

* docs: align Node 20 troubleshooting guidance

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-18 23:52:49 +08:00
AlexYue f539a44cfd docs: add issue/PR templates and contributing guide (#63)
* docs: add issue/PR templates and contributing guide
- Add GitHub Issue forms: bug report, feature request, new site adapter
- Add PR template with CI-aligned checklist (typecheck, test, validate)
- Add CONTRIBUTING.md with adapter development workflow and testing guide

* docs: simplify adapter request and fix contributor example

* docs: trim contribution and issue templates

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-18 23:49:54 +08:00
jackwener 832370f6e2 feat: add Feishu (飞书/Lark) Desktop adapter via AppleScript (5 commands)
Feishu uses custom 'Lark Framework' (Chromium-based but NOT Electron).
CDP port test failed — --remote-debugging-port has no effect.
Uses AppleScript + clipboard approach (same as WeChat/ChatGPT).

Commands: status, send, read, search (Cmd+K), new (Cmd+N)
Includes adapter READMEs (EN+ZH).
2026-03-18 23:03:23 +08:00
jackwener fc9fc32d14 feat: add WeChat (微信) Desktop adapter via AppleScript (6 commands)
WeChat is a native macOS app (not Electron), so uses AppleScript + clipboard:
- status: check if running + window count
- send: clipboard paste + Enter
- read: Cmd+A → Cmd+C with clipboard backup/restore
- search: Cmd+F + type query
- chats: switch to chats tab (Cmd+1)
- contacts: switch to contacts tab (Cmd+2)

Includes adapter READMEs (EN+ZH).
Total: 30 sites · 157 commands
2026-03-18 22:51:18 +08:00
jackwener 43b753fa02 fix(xiaohongshu): repair command args and request capture 2026-03-18 22:47:39 +08:00
jackwener be194a1849 refactor: rename discord → discord-app to distinguish from web version
Desktop Electron app adapters should use '-app' suffix when a web version also exists.
2026-03-18 22:43:26 +08:00
jackwener 63489fb596 chore: remove untested feishu/wechat adapters, polish CLI-ELECTRON.md
- Remove feishu and wechat adapters (not tested yet, will re-add later)
- Remove their rows from README.md and README.zh-CN.md
- Significantly polish CLI-ELECTRON.md skill guide:
  - Add Electron detection guide (check for Electron Framework)
  - Add Non-Electron AppleScript pattern section
  - Add port assignment table for all CDP adapters
  - Improve code examples with real working TypeScript
2026-03-18 22:29:15 +08:00
stometaverse c370bd0582 feat(xiaohongshu): add 4 creator analytics commands (creator-profile, creator-stats, creator-notes, creator-note-detail) (#49)
* feat(xiaohongshu): add 4 creator analytics commands

Add creator backend support for Xiaohongshu (小红书), enabling
creators to access their analytics data from the command line.

New commands:
- creator-profile: account info (followers, likes, creator level)
- creator-stats: 7-day/30-day overview (views, likes, collects,
  comments, shares, new followers) with daily trend data
- creator-notes: note list with per-note metrics from note manager
- creator-note-detail: single note analytics breakdown
  (organic vs promoted vs video traffic)

API discovery:
- /api/galaxy/creator/home/personal_info (cookie auth, 200 OK)
- /api/galaxy/creator/data/note_detail_new (cookie auth, 200 OK)
- /api/galaxy/creator/data/note_detail?note_id=xxx (cookie auth, 200 OK)
- Note manager DOM extraction for note list (bypasses v2 signature)

All endpoints verified working with real creator account.
Screenshots (redacted) included in docs/screenshots/.

Requires: Chrome logged into creator.xiaohongshu.com

* chore: remove screenshots from repo (will host externally for PR)

* review: fix creator analytics CLI integration

Co-authored-by: stone16 <stone2paul@gmail.com>

* test: add site-scoped test runner

Co-authored-by: stone16 <stone2paul@gmail.com>

* review: ignore publish timestamps in creator note metrics

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-18 22:27:25 +08:00
AlexYue 8a355dfd2d feat: Add download support for xhs, twi, bilibili, zhihu (#22)
* feat: add download support for images, videos, and articles

Add comprehensive download functionality to OpenCLI with support for
multiple platforms and content types.

- Add `src/download/index.ts`: HTTP download with progress, yt-dlp
  wrapper for video platforms, cookie export to Netscape format for
  authenticated downloads
- Add `src/download/progress.ts`: Terminal progress bars, multi-file
  download tracker with status summary
- Add `src/pipeline/steps/download.ts`: New `download` pipeline step
  for declarative YAML pipelines

- Register `download` step in executor.ts
- Add template filters: `slugify`, `sanitize`, `ext`, `basename` for
  filename templating

- `xiaohongshu download`: Download images and videos from notes
- `bilibili download`: Download videos using yt-dlp with cookie auth
- `twitter download`: Download media from user timeline or single tweet
- `zhihu download`: Export articles to Markdown with optional image
  download

```yaml
pipeline:
  - download:
      url: ${{ item.imageUrl }}
      dir: ./downloads
      filename: ${{ item.title | sanitize }}.jpg
      concurrency: 5
      skip_existing: true
      use_ytdlp: false
      type: auto  # auto|image|video|document
```

- Concurrent downloads with configurable parallelism
- Progress bars with file size display
- Skip existing files option
- Cookie forwarding for authenticated downloads
- yt-dlp integration for video platforms (YouTube, Bilibili, Twitter)
- HTML to Markdown conversion for article export

- yt-dlp: Required for video downloads from streaming platforms
- ffmpeg: Optional for video format conversion

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* docs: add download support documentation

- Add Download Support section to both README.md and README.zh-CN.md
- Document supported platforms: Xiaohongshu, Bilibili, Twitter, Zhihu
- Include prerequisites (yt-dlp installation)
- Add usage examples for all download commands
- Document the `download` pipeline step for YAML adapters
- Update built-in commands table with new `download` commands

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* fix: preserve zhihu ordered list content

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-18 22:20:13 +08:00
foreverxdord 700d970f13 feat: add grok.com site support (#60)
Add support for grok.com site with two commands:
- ask: Send a message to Grok and get response
- debug: Debug grok page structure

Implementation uses Playwright CDP protocol with fallback DOM selectors
(div.message-bubble, [data-testid="message-bubble"]) for reliability.

Co-authored-by: xdord <xdord@xdorddeMac-mini.local>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 22:11:36 +08:00
jackwener b1fda7da3b feat: add Feishu (飞书) adapter + Notion favorites command
Feishu/Lark (5 commands via AppleScript):
- status, send, new, search (Cmd+K), read
- Lark Framework wraps Chromium v131 but doesn't expose CDP
- Uses AppleScript + clipboard automation (same as WeChat/ChatGPT)

Notion:
- Added favorites command (list pages from Favorites section)

Total: 32 sites · 162 commands
2026-03-18 21:19:47 +08:00
jackwener 40a6a4cace fix(notion): use precise DOM selectors for favorites extraction 2026-03-18 21:15:24 +08:00
jackwener 920ca3f7e5 feat(notion): add favorites command to list favorited pages 2026-03-18 21:06:18 +08:00
jackwener 799a616359 feat: add WeChat (微信) Desktop adapter via AppleScript (5 commands)
WeChat Mac is native Cocoa (not Electron), so CDP is not available.
Uses AppleScript + clipboard automation instead:
- status: check if WeChat is running
- send: paste + Enter in active conversation
- new: Cmd+N for new chat
- search: Cmd+F and type query
- read: Cmd+A → Cmd+C to copy chat content

Total: 30 sites · 156 commands
2026-03-18 21:03:00 +08:00
jackwener 9c2a983e8b feat: add Notion + Discord Desktop adapters (14 new commands via CDP)
Notion (7 commands):
- status, search (Quick Find), read, new, write, sidebar, export

Discord (7 commands):
- status, send, read, channels, servers, search, members

Both apps are Electron-based, connected via --remote-debugging-port.
Notion port: 9230, Discord port: 9232.
Includes adapter READMEs (EN+ZH) for both.

Total: 29 sites · 151 commands
2026-03-18 20:53:44 +08:00
jackwener 3d1ea9b15c feat: add ChatWise Desktop adapter (9 commands via CDP)
Release / release (push) Has been cancelled
- status, new, send, read, ask, model, screenshot, history, export
- Electron-based multi-LLM client (GPT-4/Claude/Gemini)
- Includes adapter READMEs (EN+ZH)
- Fix truncated README table rows
- Total: 27 sites · 137 commands
2026-03-18 20:48:05 +08:00
AstroHan e1d4a6e5e6 feat(linux-do): add linux.do adapter with 6 commands (#43) (#56)
Add linux.do (Discourse-based forum) support with 6 YAML pipeline commands:
- hot: trending topics with period filter (all/daily/weekly/monthly/yearly)
- latest: newest topics
- categories: list all categories with slug/id for further queries
- category: browse topics within a specific category
- topic: post details with replies (first page)
- search: search topics by keyword

All commands use navigate+evaluate pattern with cookie auth
(linux.do enforces login_required on all endpoints).

Security: user inputs sanitized via | json filter + encodeURIComponent.
HTML content stripped with block-tag spacing and full entity decoding.
2026-03-18 20:25:12 +08:00
stometaverse a06cdbf0ac feat: add jimeng (即梦AI) CLI support (#57)
Add two CLI commands for Jimeng (即梦AI) — ByteDance's AI image generation platform:

- generate: Text-to-image generation with model selection and configurable wait time
- history: View recent generation history with prompt, model, status, and image URLs

Both commands use browser automation with cookie-based authentication on jimeng.jianying.com.
2026-03-18 20:23:45 +08:00
jackwener e76de39f42 feat: desktop adapter improvements — bug fixes + 9 new commands
Release / release (push) Has been cancelled
P0 Bug Fixes:
- codex: add missing args/IPage imports, add wait(0.5) before Enter in send
- cursor: new.ts uses Meta+N shortcut (more robust), composer.ts simplified
- chatgpt: send.ts now backs up and restores clipboard
- antigravity: send.ts/model.ts columns unified to PascalCase
- codex: read.ts column renamed Thread_Content → Content

P1 New Features:
- ask: one-shot send+wait+read for cursor, codex, chatgpt (send → poll DOM → return response)
- screenshot: DOM + accessibility snapshot export for cursor, codex

P2 New Features:
- history: list sidebar chat sessions for cursor, codex
- export: save full conversation as Markdown for cursor, codex

Total: 26 sites · 128 commands
2026-03-18 19:52:39 +08:00
jackwener 813631e468 docs: remove unnecessary --remote-allow-origins, add CDP launch to ChatGPT README
- Remove --remote-allow-origins from antigravity README, README.zh-CN, SKILL.md (not needed for local usage)
- Update ChatGPT README to document both AppleScript and CDP approaches
- Document ChatGPT Electron launch: /Applications/ChatGPT.app/Contents/MacOS/ChatGPT --remote-debugging-port=9224
2026-03-18 19:42:45 +08:00
jackwener 2afbb99660 feat: add ChatGPT Desktop native support + Cursor/Codex advanced commands
Release / release (push) Has been cancelled
- Add ChatGPT macOS Desktop adapter (status, new, send, read) via AppleScript
- Add Cursor composer, model, extract-code commands via CDP
- Add Codex model command via CDP
- Create adapter READMEs for ChatGPT (EN+ZH) and Cursor (EN+ZH)
- Fix README.md duplicate table rows (6 sites were listed twice)
- Update command count: 26 sites · 119 commands
- Bump version to 0.9.5
2026-03-18 19:40:08 +08:00
jackwener aa55c88069 0.9.4
Release / release (push) Has been cancelled
2026-03-18 17:41:41 +08:00
jackwener b32fe1cbc3 feat: add advanced cursor and codex capabilities 2026-03-18 17:41:41 +08:00
jackwener 981cc1bc5e docs: add CLI-ELECTRON.md as an agent skill guide 2026-03-18 17:17:46 +08:00
jackwener cd63231b7e chore(release): 0.9.2
Release / release (push) Has been cancelled
2026-03-18 17:15:01 +08:00
jackwener 685658f7bd build: update cli-manifest 2026-03-18 17:15:01 +08:00
jackwener cd6f7a1f7e fix(codex): use precise selector for read command 2026-03-18 17:14:47 +08:00
jackwener 4ce0345c9a chore(release): 0.9.1
Release / release (push) Has been cancelled
2026-03-18 17:06:47 +08:00
jackwener 3cc2cb5504 feat(codex): implement generic CDP adapters for OpenAI Codex desktop app 2026-03-18 17:06:47 +08:00
jackwener abac070ce4 docs: update root README and SKILL with electron app marketing copy 2026-03-18 16:51:18 +08:00
jackwener 79fbac844e chore(release): 0.9.0
Release / release (push) Has been cancelled
2026-03-18 16:44:36 +08:00
jackwener 7e776e2bd5 feat(antigravity): support cli all electron app via CDP 2026-03-18 16:44:36 +08:00
jackwener bde1c53a3e fix(xiaoyuzhou): validate limits and tighten e2e 2026-03-18 16:38:09 +08:00
AstroHan 5e667b9c2f feat(xiaoyuzhou): add podcast platform adapter (#18) (#53)
Three public commands for Xiaoyuzhou (小宇宙) podcast platform:
- podcast <id>: view podcast profile
- podcast-episodes <id> [--limit]: list recent episodes (up to 15)
- episode <id>: view episode details

Uses __NEXT_DATA__ extraction from SSR pages, no auth required.
Includes unit tests (16), E2E tests (3), and README updates.
2026-03-18 16:34:03 +08:00
jackwener 64e3a2d627 0.8.0
Release / release (push) Has been cancelled
2026-03-18 15:25:51 +08:00
jackwener 849d9faea1 refactor(main): remove duplicate argument coercion in favor of engine validation 2026-03-18 15:24:59 +08:00
jackwener 29ea5ce059 feat(engine): add lightweight runtime validation and coercion for CLI arguments 2026-03-18 15:20:55 +08:00
jackwener 12c4b8853b feat(pipeline): extract STEP_HANDLERS into dynamic PipelineRegistry 2026-03-18 15:19:23 +08:00
jackwener cfad003220 fix(browser): throw explicit BrowserConnectError on Playwright MCP JSON-RPC silent failures 2026-03-18 15:18:01 +08:00
jakevin abfd4b902c feat(browser): add CDP remote connection support for server environments (#52)
* feat(browser): add CDP remote connection support for server environments

This feature enables OpenCLI to connect to a Chrome browser running on a
different machine (e.g., your local computer) from a headless server
environment via Chrome DevTools Protocol (CDP).

Server environments (CI, cloud VMs, headless Linux) cannot run Chrome with
a GUI or install the Playwright MCP Bridge extension. This makes it
impossible to use OpenCLI commands that require browser authentication.

Add support for the `OPENCLI_CDP_ENDPOINT` environment variable, which
tells OpenCLI to connect to a remote Chrome instance via CDP instead of
using the local extension mode.

1. Start Chrome with remote debugging on local machine:
   ```
   chrome --remote-debugging-port=9222 --user-data-dir="$HOME/chrome-debug"
   ```

2. Create SSH tunnel to forward port to server:
   ```
   ssh -R 9222:localhost:9222 your-server
   ```

3. Run OpenCLI on server:
   ```
   export OPENCLI_CDP_ENDPOINT="http://localhost:9222"
   opencli bilibili hot --limit 5
   ```

- src/browser.ts: Add CDP endpoint detection in buildMcpArgs()
- src/doctor.ts: Show CDP mode status in doctor report
- README.md: Add "Remote Chrome (Server/Headless)" section
- README.zh-CN.md: Add corresponding Chinese documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* docs: extract CDP connection guide into separate files

* docs: clarify CDP vs SSH/Proxy distinction in CDP guides

* docs: restructure CDP guides into 3 distinct phases (preparation, tunnel, execution)

---------

Co-authored-by: ByteYue <yj976240184@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-18 15:11:14 +08:00
Alex Yang 2d52abde7c fix(barchart): add CSRF retry and mostActive fallback to flow command (#51)
The flow command returned no data because:
1. The CSRF token may not be in the DOM yet when Angular is still
   initializing — add a polling loop (up to 5s) to wait for it
2. The unusual_activity list is empty outside market hours — fall back
   to the mostActive list which always has data
3. Remove the DOM table fallback that never matched (barchart uses
   Angular components, not standard <tr> elements)
2026-03-18 14:43:51 +08:00
jackwener f102501e4a chore(release): bump version to v0.7.11
Release / release (push) Has been cancelled
2026-03-18 13:35:13 +08:00
jackwener 2a983b6b8d feat(browser): auto-bootstrap playwright mcp via npx 2026-03-18 13:32:41 +08:00
jackwener c114a9d7f1 ci: gate pkg.pr.new publish workflow 2026-03-18 13:26:37 +08:00
jackwener d2e179ced5 fix(barchart): preserve flow semantics and nearest expiry 2026-03-18 13:23:28 +08:00
Alex Yang c806f795cc feat(barchart): add stock quote, options, greeks, and flow commands (#45)
* feat(barchart): add stock quote, options chain, greeks, and flow commands

Add 4 new barchart.com CLI commands:
- `barchart quote`: stock price, volume, market cap, P/E, EPS
- `barchart options`: options chain with strike, bid/ask, greeks, IV, OI
- `barchart greeks`: near-the-money greeks overview (delta, gamma, theta, vega, rho)
- `barchart flow`: unusual options activity sorted by volume/OI ratio

Auth uses CSRF token from <meta name="csrf-token"> + session cookies
via the internal proxy API, with DOM fallback for the quote command.

* feat(barchart): add --expiration date filter to greeks command
2026-03-18 12:13:25 +08:00
Alex Yang de5495bdd7 ci: add pkg.pr.new workflow for continuous package previews (#46)
Publishes preview versions of the package on every push and PR,
allowing reviewers to install and test exact commit builds.
2026-03-18 11:59:25 +08:00
Zhang ShengYan d6222ff932 fix: discover global @playwright/mcp for nvm/npm installs (#42)
* fix: discover global @playwright/mcp in nvm/npm installs

* test: cover global mcp discovery paths

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-17 22:36:06 +08:00
jackwener e0395ce5ed test: make Vitest project order explicit
Add explicit group ordering for Vitest projects so unit tests run before e2e tests, while keeping the e2e ordering fix from PR #38.\n\nCo-authored-by: RbBtSn0w <hamiltonsnow@gmail.com>
2026-03-17 17:46:03 +08:00
jackwener 4c8c6e8be7 fix(twitter): migrate bookmarks to direct GraphQL
Release / release (push) Has been cancelled
chore: bump version to 0.7.10
2026-03-17 17:30:01 +08:00
jackwener 7b5bdfa7d5 fix(twitter): harden remaining twitter commands
Co-authored-by: Sheng-Yan, Zhang <yancode@qq.com>
2026-03-17 17:26:36 +08:00
jackwener 546c0b997a feat: Enhance setup output with token save confirmation and improved browser connectivity guidance. 2026-03-17 17:20:16 +08:00
jackwener 1e34e7e6d3 chore: bump version to 0.7.9 2026-03-17 17:11:13 +08:00
jackwener 9ae9eb3fc6 fix(twitter): rewrite timeline adapter to use direct GraphQL API
The previous implementation injected a fetch interceptor after page
navigation, but by that time the HomeTimeline API call had already
completed, resulting in 'no data captured' every time.

Rewrote to directly call Twitter's HomeTimeline GraphQL endpoint
(same pattern as profile.ts and thread.ts):
- Dynamic queryId resolution with hardcoded fallback
- Pagination support with cursor
- Filters out promoted content
- Returns structured tweet data (id, author, text, likes, retweets,
  replies, views, created_at, url)

Fixes #36
2026-03-17 17:11:02 +08:00
jackwener 612c0ab1af v0.7.8: P0 architecture refactor - split browser.ts, unified errors, strict mode
Release / release (push) Has been cancelled
2026-03-17 17:02:03 +08:00
jackwener 68840fc85c refactor: P0 architecture improvements
- Split browser.ts (700 lines) into src/browser/ module (page, mcp, errors, discover, tabs, index)
- Add unified error handling: CliError base class + logger module
- Enable TypeScript strict mode, fix 12 type errors
- Extract inline build scripts to scripts/clean-yaml.cjs and copy-yaml.cjs
- All 178 unit tests pass, build produces 83 entries across 19 sites
2026-03-17 17:01:51 +08:00
jackwener 8263a06a85 fix(completion): insert fpath before compinit in .zshrc
The postinstall script was appending the fpath line at the end of .zshrc,
but compinit (called earlier by oh-my-zsh or directly) would have already
finished scanning. This caused zsh completion to silently fail for most
users.

Now the script detects the first compinit / oh-my-zsh source line and
inserts the fpath entry before it, ensuring completion works immediately.
2026-03-17 16:53:07 +08:00
jackwener eb2c3fdf89 fix: support Chrome Dev and Chrome Beta browser variants
Add Chrome Dev and Chrome Beta profile paths to discoverExtensionToken()
and checkExtensionInstalled() across macOS, Linux, and Windows.

Closes #30
2026-03-17 16:36:19 +08:00
jackwener 43ed0ace59 0.7.6
Release / release (push) Has been cancelled
2026-03-17 16:35:20 +08:00
jackwener de962eb5fb feat: support commands completion (#32)
Add full shell tab-completion for opencli, supporting Bash, Zsh, and Fish.

Co-authored-by: RinChanNOWWW <rin_chan_now@outlook.com>

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-17 16:33:19 +08:00
jackwener d1da293ef9 0.7.5
Release / release (push) Has been cancelled
2026-03-17 16:14:02 +08:00
jackwener 25bd872a24 fix: doctor/setup edge cases — format detection, dynamic profiles, fish shell
- upsertJsonConfigToken: detect format by file path (opencode → mcp format,
  others → mcpServers). Previously empty files always got OpenCode format.
- Dynamic Chrome profile enumeration: scan for Default/Profile N directories
  instead of hardcoding 4 profiles.
- Fish shell: use 'set -gx' syntax for config.fish, not 'export'.
- Pass filePath through all callers (setup.ts, applyBrowserDoctorFix).
- Reduce setup auto-verify timeout from 8s to 5s.
- Add 7 new tests (19 total): empty file format, opencode path detection,
  claude.json path detection, fish shell set/replace/append, zshrc fallback.
2026-03-17 16:13:05 +08:00
jackwener ff3e5c6887 feat: enhance setup with precise token scan errors and auto-verify
- When token scan fails, diagnose exact cause via checkExtensionInstalled()
  (extension not installed vs token not in LevelDB)
- Show actionable fix instructions instead of generic warning
- Auto-verify browser connectivity after writing configs (Step 7)
- Simplify README setup flow to 2 steps (install + setup)
2026-03-17 16:07:38 +08:00
jackwener 2e66e3183c docs: reorder setup flow — doctor → setup → doctor --live 2026-03-17 16:02:37 +08:00
jackwener a1bcb23239 docs: reorder setup flow — doctor first, then setup
Logical flow: install extension → doctor (verify token discoverable) →
setup (distribute token to tools). --fix moved to a Tip block for
post-setup maintenance.
2026-03-17 16:00:46 +08:00
jackwener 6024af3aa0 docs: split doctor --fix into interactive and non-interactive examples 2026-03-17 15:58:53 +08:00
jackwener 1393ce3327 docs: sync Chinese README with doctor --live, command table polish 2026-03-17 14:57:13 +08:00
jackwener 0fe3b9b921 0.7.4
Release / release (push) Has been cancelled
2026-03-17 14:55:54 +08:00
jackwener 375beaa744 docs: polish README and SKILL
- Sort command table by count (descending), add Count column
- Add xiaohongshu `me`, boss `detail` to command references
- Add Self-healing setup highlight for doctor/setup workflow
- Document `doctor --live` and `doctor --fix` options
- Bump SKILL version to 0.7.3, expand tags
- Fix site count to 19, update descriptions
2026-03-17 14:50:14 +08:00
SonicKang 341c42c62f fix(opencode): use 'environment' instead of 'env' for MCP config (#29)
OpenCode config schema uses 'environment' property for MCP server
environment variables, not 'env'.

Schema reference: https://opencode.ai/config.json
2026-03-17 14:46:53 +08:00
jackwener 50b71c0936 fix: use binary read for LevelDB token discovery on all platforms
The previous strings+grep pipeline failed because LevelDB's internal
encoding fragments ASCII strings like 'auth-token' and the extension ID
across byte boundaries. Replace extractTokenViaStrings with a unified
binary read approach that scans for the extension ID prefix and searches
a 500-byte window for base64url tokens.

Also removes unused execSync import.
2026-03-17 14:44:34 +08:00
jackwener 981c167a0b feat(doctor): add extension install check and token connectivity test
- checkExtensionInstalled(): scans Chrome/Edge/Chromium Extensions dirs
- checkTokenConnectivity(): actual MCP handshake via --live flag
- Updated DoctorReport type and report rendering
- Added unit tests for new rendering (12/12 pass)
2026-03-17 14:38:16 +08:00
jackwener 2463689105 0.7.3
Release / release (push) Has been cancelled
2026-03-17 13:34:34 +08:00
jackwener c714254d8f docs: add YouTube video and transcript commands to README and SKILL 2026-03-17 13:30:42 +08:00
Ji 8e7490407c feat(youtube): add video metadata and transcript commands (#25)
Add two new YouTube adapters:

- **youtube video**: fetch metadata (title, views, description, etc.) from ytInitialPlayerResponse and ytInitialData
- **youtube transcript**: fetch subtitles via Android InnerTube API to bypass PoToken requirement on Web client caption URLs
  - Two output modes: --mode grouped (sentence merging, speaker detection, chapter headings) and --mode raw (precise sub-second timestamps)
  - CJK support with 30s time-window fallback for unpunctuated captions
  - Language selection with --lang and stderr warning on fallback
  - URL normalization for watch, youtu.be, shorts, embed, live formats

Co-authored-by: Ji Zhang <jizhang.work@gmail.com>
2026-03-17 13:26:30 +08:00
Ji 14dcd2bc5f feat(reddit): add threaded comment tree to read command (#26)
Replace flat top-level-only read.yaml with recursive tree walker:
- Configurable depth and breadth (--depth, --replies)
- Replies sorted by score, top-K selected at each level
- Hidden replies surfaced as [+N more replies]
- Multiline bodies preserve indentation at all depths
- Configurable --max_length (was hard-coded 500 chars)
- Input validation: all numeric params clamped to safe minimums
2026-03-17 13:18:11 +08:00
SiweiMa e9b9beedfe feat(linkedin): add job search adapter (#28)
* feat: add linkedin job search adapter

* fix(linkedin): fix parseCsvArg undefined bug, regex escapes in page.evaluate, replace hardcoded wait, add IPage type

* refactor(linkedin): extract evaluate logic, add progress logging, improve code structure

- Extract Voyager query/URL building into typed standalone functions
- Split fetchJobCards into its own function with per-batch evaluate
- Add SearchInput interface for type safety
- Add progress logging to enrichJobDetails (stderr)
- Add section comments for code organization
- Deduplicate normalize helpers in evaluate strings

---------

Co-authored-by: Siwei Ma <siweima@Siweis-MacBook-Pro.local>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-17 13:07:16 +08:00
jackwener 59de5fb3f5 0.7.2
Release / release (push) Has been cancelled
2026-03-17 01:38:29 +08:00
jackwener 7555f14369 refactor: deep code review improvements
- Add *.log to .gitignore, remove debug.log from tracking
- Fix dev-mode FS scan to discover .ts adapter files (not just .js)
- Deduplicate CONNECT_TIMEOUT: browser.ts now uses runtime.ts constant
- Fix CSV output: escape newlines in field values per RFC 4180
- Add proper type interfaces for validate/verify (remove any types)
- Remove unused hadOuterQuotes variable in snapshotFormatter
- Derive CliOptions from CliCommand via Omit+Partial to reduce duplication
- Expand dense one-liner action callbacks in main.ts for readability
2026-03-17 01:38:23 +08:00
jackwener 2652fa40e5 chore: change license from BSD-3-Clause to Apache-2.0 2026-03-17 01:34:51 +08:00
jackwener a7c367a61b docs: update README and SKILL for new Reddit adapters
- Reddit: 4 → 15 commands (popular, read, user, user-posts,
  user-comments, upvote, save, comment, subscribe, saved, upvoted)
- Twitter: add thread command
- Xiaohongshu: remove non-existent me command
- SKILL.md: expand Reddit examples with full 15-command reference
2026-03-17 01:33:43 +08:00
jackwener fbec2f6f5d feat(snapshot): filter contentinfo subtrees, bilibili ad URLs, boilerplate buttons
- Add contentinfo to subtree-level noise filtering (biggest single win)
  - Reuters: 51% → 62%, Google: 57% → 70%, Netflix: 48% → 60%
- Add cm.bilibili.com/cm/api/fees/ ad URL pattern
- Add 广告 keyword to ad detection
- Add back-to-top / 回到顶部 boilerplate button filtering
- Unify ad/boilerplate/contentinfo into single subtree-skip mechanism
- Add vitest config and comprehensive test suite (33 tests)
- Fixture tests skip gracefully when snapshot files are absent

Bump to v0.7.1
2026-03-17 01:26:49 +08:00
jackwener c2a5cbe90e chore(release): 0.7.0
Release / release (push) Has been cancelled
2026-03-16 20:29:27 +08:00
jackwener 34e20d33f2 docs: bump version in SKILL.md to 0.7.0 2026-03-16 20:29:27 +08:00
jackwener 1c496bb85f docs: add new twitter commands (article, follow, unfollow, bookmark, unbookmark)
Also update profile example to use positional argument.
2026-03-16 20:18:50 +08:00
jackwener 0c845d58c8 feat(twitter): implement article, profile, follow, unfollow, bookmark, & unbookmark adapters
This commit introduces the long-form Article adapter, a rewritten Profile adapter, and 4 new UI-based Write commands for managing relationships and bookmarks. Also adds support for positional arguments across the dynamic CLI engine.
2026-03-16 20:17:38 +08:00
jackwener 7f55950fed feat(reddit): add 11 new adapters borrowed from rdt-cli
Phase 1 - YAML adapters (read-only):
- popular: /r/popular feed
- read: read post + comments by ID
- user: view user profile (karma, account age)
- user-posts: user's submitted posts
- user-comments: user's comment history
- search: enhanced with sort/time/subreddit params
- subreddit: enhanced with time filter for top/controversial

Phase 2 - TypeScript adapters (write operations):
- upvote: upvote/downvote posts via /api/vote
- save: save/unsave posts via /api/save
- comment: post comments via /api/comment
- subscribe: subscribe/unsubscribe subreddits
- saved: browse saved posts (auto-resolves username)
- upvoted: browse upvoted posts (auto-resolves username)

Reddit adapters: 4 → 15
2026-03-16 19:56:55 +08:00
jackwener 1576396a21 0.6.3
Release / release (push) Has been cancelled
2026-03-16 19:33:25 +08:00
jackwener 77193a0003 Merge PR #20: feat(boss): add detail adapter + security_id in search
Closes #20

Added boss detail command with fixes:
- district/address field dedup
- template string injection safety
- empty jobInfo guard
- IPage-compatible wait
2026-03-16 19:33:17 +08:00
jackwener 05b7f1bccf fix(boss): improve detail adapter quality
- Fix district/address field duplication (district now uses areaDistrict·businessDistrict)
- Fix template string injection risk in evaluate script (use JSON.stringify)
- Add jobInfo empty guard with user-friendly error message
- Replace raw setTimeout with page.wait for IPage compatibility
- Update README docs to include boss detail command
2026-03-16 19:33:00 +08:00
jackwener 9889a6db11 v0.6.2
Release / release (push) Has been cancelled
2026-03-16 18:14:34 +08:00
jackwener 61ea05bff7 fix: URL injection, strictNullChecks, cross-platform build, +34 tests
Security:
- Fix URL injection in fetch.ts and bilibili.ts (JSON.stringify instead of string interpolation)
- Fix unused scroll() amount parameter

TypeScript:
- Enable strictNullChecks in tsconfig
- Change CliCommand.func signature to IPage (non-null) for browser adapters
- Fix 93 compile errors across all adapters

Build:
- Remove || true from build-manifest (report failures instead of silencing)
- Replace Unix shell commands with Node.js scripts for cross-platform builds

Code quality:
- Remove error-object special detection from pipeline executor
- Unify error handling to throw pattern

Tests:
- New interceptor.test.ts (11 tests)
- New executor.test.ts (13 tests)
- Rewrite output.test.ts with comprehensive coverage (10 tests)
2026-03-16 18:14:27 +08:00
xuelin e781d40408 feat(boss): add security_id to search output
Expose securityId in search results so users can pipe it to
`boss detail` for full job information.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 18:12:46 +08:00
xuelin c230f3e5ad feat(boss): add job detail adapter
Add `boss detail` command to fetch full job posting details using
securityId from search results.

Fields returned: job description, skills, welfare, boss info (name,
title, active time), company info (industry, scale, stage), address.

Tested with real API calls against multiple job postings.

Usage:
  opencli boss detail --security_id <id_from_search>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 18:09:49 +08:00
jackwener 3b2f88b2cf chore: sync package-lock.json version to 0.6.1 2026-03-16 17:38:13 +08:00
jackwener 7eec7ce89f fix: restore tests/ in vitest include for CI compatibility
vitest run tests/e2e/ intersects the CLI path with include patterns,
so tests/ must be in the include glob for CI to find test files.
2026-03-16 17:37:48 +08:00
AlexYue 788b069c02 feat: add E2E testing infrastructure with real Chrome in CI
## Changes

### E2E Test Suite (~52 test cases)
- public-commands.test.ts — Public API commands (hackernews, v2ex)
- browser-public.test.ts — Browser commands for public data across all sites
- browser-auth.test.ts — Graceful failure verification for login-required commands
- management.test.ts — Full coverage of management commands
- output-formats.test.ts — Output format validation (json/yaml/csv/md)
- smoke/api-health.test.ts — Scheduled API health checks

### Auto-detect Browser Mode
- buildMcpArgs uses CI env var to select mode:
  - Local (no CI) → --extension (connect to user's Chrome)
  - CI → standalone (launches its own browser)

### CI Pipeline
- e2e-headed.yml — Real Chrome via setup-chrome + xvfb in headed mode
- ci.yml — build + unit-test (2 shards) + smoke-test (scheduled/manual)
- Composite action for shared Chrome + xvfb setup

### Documentation
- New TESTING.md — Architecture, coverage, local setup, how to add tests

Co-authored-by: AlexYue <yj976240184@qq.com>
2026-03-16 17:35:16 +08:00
jackwener 433ad3a56a 0.6.1
Release / release (push) Has been cancelled
2026-03-16 14:20:22 +08:00
jackwener 50508b954e docs: expand bilibili commands, add setup hint after install, add doctor to troubleshooting 2026-03-16 14:16:54 +08:00
jackwener 35a843b8bd docs: use explicit PLAYWRIGHT_MCP_EXTENSION_TOKEN in auto-discover description 2026-03-16 14:16:12 +08:00
jackwener 486e513d07 fix(setup): only pre-select shell RC, let user choose other configs 2026-03-16 14:15:43 +08:00
jackwener 6c64f617c6 docs: add opencli setup to READMEs, remove hardcoded counts, update SKILL.md to v0.6.0 2026-03-16 14:13:38 +08:00
jackwener cd186bddd3 0.6.0
Release / release (push) Has been cancelled
2026-03-16 14:05:58 +08:00
jackwener 6486a42def fix(setup): clear screen before TUI to prevent page jumping 2026-03-16 14:04:38 +08:00
jackwener b308d5594a refactor(doctor/setup): polish UX and dedup code
- Doctor: chalk-colored output ([OK] green, [MISSING] red, etc.)
- Doctor: paths shortened with ~ and tool labels ([Codex], etc.)
- Doctor --fix: skip already-configured files
- TUI: hide cursor during interaction, proper Ctrl+C exit
- Setup: source hint after shell write, dedup shared helpers
- Tests: strip ANSI for assertions
2026-03-16 14:02:02 +08:00
jackwener 6f629260e7 feat: add interactive 'opencli setup' command with TUI checkbox
New zero-dependency TUI checkbox component (src/tui.ts) with:
  - ↑↓/jk navigation, Space toggle, Tab toggle+move
  - 'a' toggle all, Enter confirm, q/Esc cancel

New 'opencli setup' command (src/setup.ts) that:
  - Auto-discovers token from Chrome LevelDB extension
  - Shows interactive multi-select for config files
  - Displays tool names (Codex, Cursor, Claude Code, etc.)
  - Color-coded status (green=ok, yellow=mismatch, red=missing)
  - Applies changes only to selected files
2026-03-16 13:53:03 +08:00
jackwener 1e0b83bb84 feat(doctor): add Antigravity and Gemini CLI config paths
Add ~/.gemini/settings.json (Gemini CLI) and
~/.gemini/antigravity/mcp_config.json (Antigravity) to the
default MCP config scan list.
2026-03-16 13:45:02 +08:00
jackwener 1ff184e24d feat(doctor): add Claude Code and project .mcp.json config paths
Add ~/.claude.json (Claude Code user-scoped MCP config) and
.mcp.json (Claude Code project-scoped MCP config) to the
default config scan list.
2026-03-16 13:43:03 +08:00
jackwener 8e84145ccc feat(doctor): auto-discover extension token from Chrome LevelDB
Scan Chrome/Edge/Chromium localStorage LevelDB files to extract the
Playwright MCP Bridge auth-token directly from the extension's storage.
Uses a fast 'strings | grep' shell pipeline (~200ms) on macOS/Linux
with a pure-Node fallback for Windows.

The discovered token is now shown in 'opencli doctor' output and takes
priority as the recommended token when using '--fix'.
2026-03-16 13:38:48 +08:00
jackwener c77c8a8e3a feat: add Coupang search and add-to-cart adapters
Add browser-backed Coupang adapters for search and add-to-cart workflows.
- coupang search: multi-strategy data collection (API/JSON-LD/bootstrap/DOM), structured fields (price, rating, rocket, delivery), pagination and rocket filter support
- coupang add-to-cart: logged-in browser session reuse, stops before checkout
- coupang.ts: comprehensive data normalization layer with badge/rocket/delivery mapping
- browser-tab.ts: withTemporaryTab utility for isolated tab operations
- Unit tests for core normalization functions

Co-authored-by: CodeBBakGoSu <127713112+CodeBBakGoSu@users.noreply.github.com>
2026-03-16 13:30:42 +08:00
jackwener 9e024d4e46 0.5.2
Release / release (push) Has been cancelled
2026-03-16 13:22:18 +08:00
jackwener 34a9bff2b3 refactor: eliminate code duplication, improve type safety, add tests
- NEW: src/interceptor.ts — unified XHR/Fetch interceptor (was duplicated 3x)
- NEW: src/version.ts — centralized PKG_VERSION (was duplicated 2x)
- NEW: src/constants.ts — shared VOLATILE_PARAMS, FIELD_ROLES etc.
- NEW: src/engine.test.ts, src/registry.test.ts — 14 new unit tests

- browser.ts: use shared normalizeEval, interceptor, withTimeoutMs, PKG_VERSION
- intercept.ts, tap.ts: use shared interceptor generators
- cascade.ts: extract shared buildFetchProbeJs (90% dedup)
- engine.ts: use InternalCliCommand (no more 'as any' casts)
- executor.ts: remove all 15 'as StepHandler' type casts
- runtime.ts: add withTimeoutMs, IBrowserFactory interface
- registry.ts: add InternalCliCommand type for internal fields
- validate.ts: add pipeline step name validation
- output.ts: remove 'null as any' and '.filter(() => true)' hacks
- explore.ts, synthesize.ts: use shared constants
- docs: fix V2EX commands (3→6), SKILL.md version, verify example

Tests: 88 passed (was 74), tsc --noEmit: 0 errors
2026-03-16 13:22:12 +08:00
jackwener b0c51ddf19 fix: use Playwright MCP --executable-path flag (kebab-case)
Extract buildMcpArgs() helper and fix --executablePath → --executable-path
to match the Playwright MCP CLI's expected flag format.

Closes #16

Co-authored-by: KasumiChen <KasumiChen@users.noreply.github.com>
2026-03-16 12:58:18 +08:00
281 changed files with 19506 additions and 2164 deletions
@@ -0,0 +1,249 @@
---
name: cross-project-adapter-migration
description: "Cross-project CLI command migration workflow for opencli. Use when importing commands from external CLI projects (python/node) like rdt-cli, twitter-cli, etc. Covers: source analysis → gap matrix → batch migration → README/SKILL.md update."
---
# Cross-Project Adapter Migration
> 从外部 CLI 项目(Python/Node/Go 等)批量迁移命令到 opencli 的标准化流程。
## When to Use
- 用户说"把 xxx-cli 的命令迁移过来"
- 用户说"看看 xxx 项目有什么可以借鉴的"
- 用户说"对齐 xxx-cli 的功能"
- 在为新平台扩展 opencli 时,发现已有第三方 CLI 工具
## Prerequisites
- 熟悉 [CLI-EXPLORER.md](file:///Users/jakevin/code/opencli/CLI-EXPLORER.md)adapter 开发决策树)
- 熟悉 [SKILL.md](file:///Users/jakevin/code/opencli/SKILL.md)(命令参考 & 模板)
---
## Phase 1: 源项目分析
### 1.1 克隆 & 理解源项目
```bash
# 克隆源项目到 /tmp 做分析
git clone <source_repo_url> /tmp/<source-cli>
```
分析重点:
- **命令列表**:找到所有可用命令(查看 CLI 入口文件、help 输出或 README
- **认证方式**CookieAPI KeyOAuth?浏览器自动化?
- **数据源**:公开 APIGraphQL?页面抓取?
- **输出字段**:每个命令返回哪些数据字段
### 1.2 生成命令清单
列出源项目所有命令,包括:
| 命令 | 类型 | API/方法 | 输出字段 |
|------|------|---------|---------|
| `xxx feed` | Read | `GET /api/feed` | title, author, time |
| `xxx post` | Write | `POST /api/tweet` | status, id |
---
## Phase 2: 功能对比矩阵
### 2.1 查看 opencli 现有命令
```bash
ls src/clis/<site>/ # 查看已有适配器
opencli list | grep <site> # 确认已注册命令
```
### 2.2 生成对比矩阵
对每个源项目命令,标注三种状态:
| 功能 | 源项目 | opencli 现有 | 行动 |
|------|--------|-------------|------|
| feed | ✅ `xxx feed` | ❌ 无 | ✅ **新增** |
| search | ✅ `xxx search` | ✅ `search.ts` | ❌ 已有,跳过 |
| hot | ✅ `xxx hot` | ⚠️ `hot.yaml`(不完整) | ✅ **增强** |
| like | ✅ `xxx like` | ✅ `like.ts` | ❌ 已有,跳过 |
### 2.3 筛选迁移目标
去掉已有的、低价值的,保留高价值缺失命令,按 Read/Write 分类:
**筛选原则**
- ✅ 高使用频率的命令优先
- ✅ 已有但不完整的命令标记为"增强"
- ❌ 源项目特有但 opencli 架构不支持的功能(如需要持久化存储的)跳过
- ❌ 与现有功能完全重复的跳过
---
## Phase 3: 批量实现
> [!IMPORTANT]
> 实现前必须查阅 [CLI-EXPLORER.md](file:///Users/jakevin/code/opencli/CLI-EXPLORER.md) 确认策略选择。
### 3.1 选择实现方式
基于决策树分类:
| 类别 | 方式 | 适用条件 |
|------|------|---------|
| **Read + 简单 API** | YAML pipeline | 纯 fetch/select/map,无复杂 JS |
| **Read + GraphQL/分页/签名** | TypeScript adapter | 需要 JS 逻辑 |
| **Write 操作** | TypeScript + `Strategy.UI` | 点击/输入等 DOM 操作 |
| **Write + API** | TypeScript + `Strategy.COOKIE/HEADER` | 直接 POST API |
### 3.2 实现顺序
**先 Read 后 Write,先 YAML 后 TS**
1. **Phase A**: YAML Read 适配器(最快,通常每个 10-20 行)
2. **Phase B**: TS Read 适配器(需要 evaluate/intercept 的)
3. **Phase C**: TS Write 适配器(需 UI 自动化或 POST API
### 3.3 实现模板
#### YAML Read 适配器模板(Cookie 策略)
```yaml
site: <site>
name: <command>
description: <描述>
domain: www.<site>.com
strategy: cookie
browser: true
args:
limit:
type: int
default: 20
pipeline:
- navigate: https://www.<site>.com
- evaluate: |
(async () => {
const res = await fetch('<api_endpoint>', { credentials: 'include' });
const d = await res.json();
return (d.data?.items || []).map(item => ({
title: item.title,
// ... map source fields
}));
})()
- map:
rank: ${{ index + 1 }}
title: ${{ item.title }}
- limit: ${{ args.limit }}
columns: [rank, title]
```
#### TS Write 适配器模板(UI 策略)
```typescript
import { cli, Strategy } from '../../registry.js';
cli({
site: '<site>',
name: '<command>',
description: '<描述>',
strategy: Strategy.UI,
args: [{ name: 'target', required: true, help: '<参数说明>' }],
columns: ['status', 'message'],
func: async (page, kwargs) => {
await page.goto(`https://www.<site>.com/${kwargs.target}`);
await page.wait({ text: '<expected_text>', timeout: 10 });
// 获取 snapshot 找到目标按钮
const snapshot = await page.accessibility.snapshot();
// 点击按钮 ...
return [{ status: 'success', message: '<action> completed' }];
},
});
```
### 3.4 公共模式复用
迁移过程中如果发现多个适配器共享逻辑,考虑提取到 `src/<site>.ts` 工具文件:
```typescript
// src/<site>.ts
export async function fetchWithAuth(page, url) { ... }
export function parseItem(raw) { ... }
```
---
## Phase 4: 验证 & 发布
### 4.1 构建验证
```bash
npx tsc --noEmit # TypeScript 编译检查
opencli list | grep <site> # 确认所有命令已注册
```
### 4.2 运行验证(关键!)
每个新命令必须实际运行:
```bash
# Read 命令
opencli <site> <command> --limit 3 -f json
opencli <site> <command> --limit 3 -v # verbose 查看 pipeline
# Write 命令(谨慎!会实际操作)
opencli <site> <command> <test_target>
```
### 4.3 更新文档
迁移完成后必须更新以下文件:
1. **README.md** — 在对应平台区域添加新命令示例
2. **SKILL.md** — 在 Commands Reference 中添加新命令
### 4.4 提交 & 推送
```bash
git add -A
git commit -m "feat(<site>): migrate <N> commands from <source-cli>
- Phase A: <N> YAML adapters (read operations)
- Phase B: <N> TS adapters (write operations)
- Source: <source_repo_url>"
git push
```
---
## Checklist
- [ ] 源项目命令清单已生成
- [ ] 对比矩阵已确认,高价值缺失命令已筛选
- [ ] 用户确认迁移范围
- [ ] Phase A: YAML Read 适配器已完成
- [ ] Phase B: TS Read 适配器已完成
- [ ] Phase C: TS Write 适配器已完成
- [ ] `npx tsc --noEmit` 编译通过
- [ ] 所有新命令已实际运行验证
- [ ] README.md 已更新
- [ ] SKILL.md 已更新
- [ ] 已 commit + push
## 实战案例参考
### rdt-cli → opencli Reddit2026-03-16
- **源项目**: `rdt-cli`25 个 Python 命令)
- **筛选结果**: 13 个高价值命令
- **实现**: 7 个 YAMLread + 6 个 TSwrite
- **产出**: +11 文件,+767 行代码,Reddit 适配器从 4 → 15+275%
### twitter-cli → opencli Twitter2026-03-16
- **源项目**: `twitter-cli`20+ Python 命令)
- **筛选结果**: 11 个待实现
- **策略**: Read 用 `Strategy.COOKIE` + GraphQL fetchWrite 用 `Strategy.UI`
@@ -0,0 +1,54 @@
---
description: Migrate commands from an external CLI project into opencli adapters
---
// turbo-all
## Steps
1. Clone the source CLI project for analysis:
```bash
git clone <source_repo_url> /tmp/<source-cli>
```
2. Analyze source project: list all commands, auth method, API endpoints, and output fields.
3. Check existing opencli adapters for the target site:
```bash
ls src/clis/<site>/
opencli list | grep <site>
```
4. Generate a comparison matrix table (source commands vs opencli existing). Mark each as: ✅ **New** / ✅ **Enhance** / ❌ **Skip**. Ask user to confirm which commands to migrate.
5. Implement YAML Read adapters first (highest ROI, 10-20 lines each). Place files in `src/clis/<site>/<name>.yaml`.
6. Implement TS Read adapters for complex cases (GraphQL, pagination, signing). Place files in `src/clis/<site>/<name>.ts`.
7. Implement TS Write adapters using `Strategy.UI` or `Strategy.COOKIE`. Place files in `src/clis/<site>/<name>.ts`.
8. Verify build:
```bash
npx tsc --noEmit
```
9. Verify all commands are registered:
```bash
opencli list | grep <site>
```
10. Run each new command to verify it works:
```bash
opencli <site> <command> --limit 3 -f json
```
11. Update README.md with new command examples in the appropriate platform section.
12. Update SKILL.md Commands Reference with new commands.
13. Commit and push:
```bash
git add -A
git commit -m "feat(<site>): migrate <N> commands from <source-cli>"
git push
```
+83
View File
@@ -0,0 +1,83 @@
name: "🐛 Bug Report"
description: Report a bug or unexpected behavior in OpenCLI
title: "[Bug]: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug. A short reproduction and any error output are usually enough.
- type: textarea
id: description
attributes:
label: Description
description: A clear and concise description of the bug.
placeholder: What happened?
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to Reproduce
description: How can we reproduce this behavior?
value: |
1. Run `opencli ...`
2. ...
3. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: What did you expect to happen?
validations:
required: true
- type: input
id: version
attributes:
label: OpenCLI Version
description: "Run `opencli --version` to find out."
placeholder: "0.8.0"
validations:
required: true
- type: dropdown
id: node-version
attributes:
label: Node.js Version
options:
- "20.x"
- "22.x"
- Other
validations:
required: true
- type: dropdown
id: os
attributes:
label: Operating System
options:
- macOS
- Linux
- Windows
- Other
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs / Screenshots
description: |
Paste any relevant error output. Run with `-v` for verbose logs:
```
opencli <command> -v
```
render: shell
validations:
required: false
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: true
contact_links:
- name: 📖 Documentation
url: https://github.com/jackwener/opencli#readme
about: Check the README and docs before opening an issue.
- name: 🧪 Testing Guide
url: https://github.com/jackwener/opencli/blob/main/TESTING.md
about: How to run and write tests for OpenCLI.
@@ -0,0 +1,42 @@
name: "✨ Feature Request"
description: Suggest a new feature or improvement
title: "[Feature]: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Have an idea to make OpenCLI better? We'd love to hear it!
- type: textarea
id: description
attributes:
label: Feature Description
description: A clear and concise description of the feature you'd like.
validations:
required: true
- type: textarea
id: use-case
attributes:
label: Use Case
description: What problem does this solve? Who benefits from this feature?
placeholder: "As a user, I want to ... so that ..."
validations:
required: true
- type: textarea
id: proposed-solution
attributes:
label: Proposed Solution
description: If you have a specific implementation in mind, describe it here.
validations:
required: false
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: Any alternative approaches you've thought about?
validations:
required: false
@@ -0,0 +1,57 @@
name: "🌐 New Site Adapter Request"
description: Request support for a new website
title: "[Site]: "
labels: ["new-adapter"]
body:
- type: markdown
attributes:
value: |
Want OpenCLI to support a new site? Tell us about it!
- type: input
id: site-name
attributes:
label: Site Name
description: The name of the website.
placeholder: "e.g. Product Hunt"
validations:
required: true
- type: input
id: site-url
attributes:
label: Site URL
description: The main URL of the website.
placeholder: "https://www.producthunt.com"
validations:
required: true
- type: textarea
id: commands
attributes:
label: Desired Commands
description: What commands would you like? List them with a brief description.
value: |
- `hot` — trending / popular items
- `search` — search the site
validations:
required: true
- type: textarea
id: api-examples
attributes:
label: Example Links or API Endpoints
description: Share any example page URLs or API endpoints if you have them (optional).
placeholder: |
Example page: https://www.producthunt.com/posts/example
GET https://api.producthunt.com/v2/posts?order=votes
Response: { "posts": [{ "name": "...", "tagline": "..." }] }
validations:
required: false
- type: checkboxes
id: contribution
attributes:
label: Willing to Contribute?
options:
- label: I'm willing to submit a PR for this adapter
+26
View File
@@ -0,0 +1,26 @@
name: Setup Chrome + xvfb
description: Install real Chrome and xvfb virtual display for headed browser testing
outputs:
chrome-path:
description: Path to the installed Chrome binary
value: ${{ steps.setup-chrome.outputs.chrome-path }}
runs:
using: composite
steps:
- name: Install real Chrome (stable)
uses: browser-actions/setup-chrome@v1
id: setup-chrome
with:
chrome-version: stable
- name: Verify Chrome installation
shell: bash
run: |
echo "Chrome path: ${{ steps.setup-chrome.outputs.chrome-path }}"
${{ steps.setup-chrome.outputs.chrome-path }} --version
- name: Install xvfb for headed mode
shell: bash
run: sudo apt-get install -y xvfb
+27
View File
@@ -0,0 +1,27 @@
version: 2
updates:
# npm dependencies
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 10
labels:
- "dependencies"
commit-message:
prefix: "chore(deps)"
# GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "ci"
commit-message:
prefix: "chore(ci)"
+24
View File
@@ -0,0 +1,24 @@
## Description
<!-- Briefly describe your changes and link to any related issues. -->
Related issue:
## Type of Change
- [ ] 🐛 Bug fix
- [ ] ✨ New feature
- [ ] 🌐 New site adapter
- [ ] 📝 Documentation
- [ ] ♻️ Refactor
- [ ] 🔧 CI / build / tooling
## Checklist
- [ ] I ran the checks relevant to this PR
- [ ] I updated tests or docs if needed
- [ ] I included output or screenshots when useful
## Screenshots / Output
<!-- If applicable, paste CLI output or screenshots here. -->
+67 -5
View File
@@ -2,19 +2,28 @@ name: CI
on:
push:
branches: [main]
branches: [main, dev]
pull_request:
branches: [main]
branches: [main, dev]
schedule:
- cron: '0 8 * * 1' # Weekly Monday 08:00 UTC — smoke tests
workflow_dispatch:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
# ── Fast gate: typecheck + build ──
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'
- name: Install dependencies
run: npm ci
@@ -24,3 +33,56 @@ jobs:
- name: Build
run: npm run build
# ── Unit tests (vitest shard) ──
unit-test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: ['20', '22']
shard: [1, 2]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
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
# ── Smoke tests (scheduled / manual only) ──
smoke-test:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Setup Chrome + xvfb
uses: ./.github/actions/setup-chrome
id: setup-chrome
- name: Build
run: npm run build
- name: Run smoke tests
run: |
xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" \
npx vitest run tests/smoke/ --reporter=verbose
env:
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
timeout-minutes: 15
+41
View File
@@ -0,0 +1,41 @@
name: E2E Headed Chrome
on:
push:
branches: [main, dev]
pull_request:
branches: [main, dev]
workflow_dispatch:
concurrency:
group: e2e-${{ github.ref }}
cancel-in-progress: true
jobs:
e2e-headed:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Setup Chrome + xvfb
uses: ./.github/actions/setup-chrome
id: setup-chrome
- name: Build
run: npm run build
- name: Run E2E tests (headed Chrome + xvfb)
run: |
xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" \
npx vitest run tests/e2e/ --reporter=verbose
env:
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
+30
View File
@@ -0,0 +1,30 @@
name: Publish Any Commit
on:
push:
branches: [main, dev]
pull_request:
branches: [main, dev]
permissions: {}
jobs:
publish:
if: ${{ vars.PKG_PR_NEW_ENABLED == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Publish to pkg.pr.new
run: npx pkg-pr-new publish
+25
View File
@@ -0,0 +1,25 @@
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 }}
+2 -2
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'
+36
View File
@@ -0,0 +1,36 @@
name: Security Audit
on:
push:
branches: [main, dev]
pull_request:
branches: [main, dev]
schedule:
- cron: '0 9 * * 1' # Weekly Monday 09:00 UTC
permissions:
contents: read
concurrency:
group: security-${{ github.ref }}
cancel-in-progress: true
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: npm audit (production)
run: npm audit --omit=dev --audit-level=high
- name: Check for known vulnerabilities
run: npx --yes audit-ci@^7 --high --skip-dev
+2
View File
@@ -2,3 +2,5 @@ node_modules/
dist/
*.tsbuildinfo
.opencli/
.mcp.json
*.log
+103
View File
@@ -0,0 +1,103 @@
# Connecting OpenCLI via CDP (Remote/Headless Servers)
If you cannot use the opencli Browser Bridge extension (e.g., in a remote headless server environment without a UI), OpenCLI provides an alternative: connecting directly to Chrome via **CDP (Chrome DevTools Protocol)**.
Because CDP binds to `localhost` by default for security reasons, accessing it from a remote server requires an additional networking tunnel.
This guide is broken down into three phases:
1. **Preparation**: Start Chrome with CDP enabled locally.
2. **Network Tunnels**: Expose that CDP port to your remote server using either **SSH Tunnels** or **Reverse Proxies**.
3. **Execution**: Run OpenCLI on your server.
---
## Phase 1: Preparation (Local Machine)
First, you need to start a Chrome browser on your local machine with remote debugging enabled.
**macOS:**
```bash
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
--remote-debugging-port=9222 \
--user-data-dir="$HOME/chrome-debug-profile" \
--remote-allow-origins="*"
```
**Linux:**
```bash
google-chrome \
--remote-debugging-port=9222 \
--user-data-dir="$HOME/chrome-debug-profile" \
--remote-allow-origins="*"
```
**Windows:**
```cmd
"C:\Program Files\Google\Chrome\Application\chrome.exe" ^
--remote-debugging-port=9222 ^
--user-data-dir="%USERPROFILE%\chrome-debug-profile" ^
--remote-allow-origins="*"
```
> **Note**: The `--remote-allow-origins="*"` flag is often required for modern Chrome versions to accept cross-origin CDP WebSocket connections (e.g. from reverse proxies like ngrok).
Once this browser instance opens, **log into the target websites you want to use** (e.g., bilibili.com, zhihu.com) so that the session contains the correct cookies.
---
## Phase 2: Remote Access Methods
Once CDP is running locally on port `9222`, you must securely expose this port to your remote server. Choose one of the two methods below depending on your network conditions.
### Method A: SSH Tunnel (Recommended)
If your local machine has SSH access to the remote server, this is the most secure and straightforward method.
Run this command on your **Local Machine** to forward the remote server's port `9222` back to your local port `9222`:
```bash
ssh -R 9222:localhost:9222 your-server-user@your-server-ip
```
Leave this SSH session running in the background.
### Method B: Reverse Proxy (ngrok / frp / socat)
If you cannot establish a direct SSH connection (e.g., due to NAT or firewalls), you can use an intranet penetration tool like `ngrok`.
Run this command on your **Local Machine** to expose your local port `9222` to the public internet securely via ngrok:
```bash
ngrok http 9222
```
This will print a forwarding URL, such as `https://abcdef.ngrok.app`. **Copy this URL**.
---
## Phase 3: Execution (Remote Server)
Now switch to your **Remote Server** where OpenCLI is installed.
Depending on the network tunnel method you chose in Phase 2, set the `OPENCLI_CDP_ENDPOINT` environment variable and run your commands.
### If you used Method A (SSH Tunnel):
```bash
export OPENCLI_CDP_ENDPOINT="http://localhost:9222"
opencli doctor # Verify connection
opencli bilibili hot --limit 5 # Test a command
```
### If you used Method B (Reverse Proxy like ngrok):
```bash
# Use the URL you copied from ngrok earlier
export OPENCLI_CDP_ENDPOINT="https://abcdef.ngrok.app"
opencli doctor # Verify connection
opencli bilibili hot --limit 5 # Test a command
```
> *Tip: OpenCLI automatically requests the `/json/version` HTTP endpoint to discover the underlying WebSocket URL if you provide a standard HTTP/HTTPS address.*
If you plan to use this setup frequently, you can persist the environment variable by adding the `export` line to your `~/.bashrc` or `~/.zshrc` on the server.
+103
View File
@@ -0,0 +1,103 @@
# 通过 CDP 远程连接 OpenCLI (服务器/无头环境)
如果你无法使用 opencli Browser Bridge 浏览器扩展(例如:在无界面的远程服务器上运行 OpenCLI 时),OpenCLI 提供了备选方案:通过连接 **CDP (Chrome DevTools Protocol,即 Chrome 开发者工具协议)** 来直接控制本地 Chrome。
出于安全考虑,CDP 默认仅绑定在 `localhost` 的本地端口。所以,若是想让**远程服务器**调用本地的 CDP 服务,我们需要依靠一层额外的网络隧道。
本指南将整个过程拆分为三个阶段:
1. **阶段一:准备工作**(在本地启动允许 CDP 调试的 Chrome)。
2. **阶段二:建立网络隧道**(通过 **SSH反向隧道****反向代理工具**,将本地的 CDP 端口暴露给服务器)。
3. **阶段三:执行命令**(在服务器端运行 OpenCLI)。
---
## 阶段一:准备工作 (本地电脑)
首先,你需要在你的本地电脑上,通过命令行参数启动一个开启了远程调试端口的 Chrome 实例。
**macOS:**
```bash
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
--remote-debugging-port=9222 \
--user-data-dir="$HOME/chrome-debug-profile" \
--remote-allow-origins="*"
```
**Linux:**
```bash
google-chrome \
--remote-debugging-port=9222 \
--user-data-dir="$HOME/chrome-debug-profile" \
--remote-allow-origins="*"
```
**Windows:**
```cmd
"C:\Program Files\Google\Chrome\Application\chrome.exe" ^
--remote-debugging-port=9222 ^
--user-data-dir="%USERPROFILE%\chrome-debug-profile" ^
--remote-allow-origins="*"
```
> **注意**:此处增加的 `--remote-allow-origins="*"` 参数对于较新版本的 Chrome 来说通常是[必需的],以允许来自反向代理(如 ngrok)的跨域 WebSocket 连接请求。
待这个新的浏览器实例打开后,**手工登录那些你打算使用的网站**(如 bilibili.com、zhihu.com 等),这可以让该浏览器的运行资料(Profile)保留上这些网站登录用的 Cookie。
---
## 阶段二:建立网络隧道
现在你的本地已经有了一个监听在 `9222` 端口的 CDP 服务,接下来,选择以下任意一种方式将其实际暴露给你的远端服务器。
### 方法 A:SSH 反向端口转发 (推荐)
如果你的本地电脑可以直连远程服务器的 SSH,那么这是最简单且最安全的做法。
在你的 **本地电脑** 终端上直接运行这条 ssh 命令,将远程服务器的 `9222` 端口反向映射回本地的 `9222` 端口:
```bash
ssh -R 9222:localhost:9222 your-server-user@your-server-ip
```
保持此 SSH 会话在后台运行即可。
### 方法 B:反向代理 / 内网穿透 (ngrok / frp / socat)
如果因为 NAT 或防火墙等因素导致无法直连 SSH 服务器,你可以使用 `ngrok` 等内网穿透工具。
**本地电脑** 运行 ngrok 将本地的 `9222` 端口暴露到公网:
```bash
ngrok http 9222
```
此时终端里会打印出一段专属的转发 URL 地址(如:`https://abcdef.ngrok.app`)。**复制这一段 URL 地址备用**。
---
## 阶段三:执行命令 (远程服务器)
现在,所有的准备工作已结束。请切换到你已安装好 OpenCLI 的 **远程服务器** 终端上。
根据你在上方阶段二所选择的隧道方案,在终端中配置对应的 `OPENCLI_CDP_ENDPOINT` 环境变量:
### 若使用 方法 A (SSH 反向隧道):
```bash
export OPENCLI_CDP_ENDPOINT="http://localhost:9222"
opencli doctor # 查看并验证连接是否通畅
opencli bilibili hot --limit 5 # 执行目标命令
```
### 若使用 方法 B (Ngrok 等反向代理):
```bash
# 将刚刚使用 ngrok 得到的地址填入这里
export OPENCLI_CDP_ENDPOINT="https://abcdef.ngrok.app"
opencli doctor # 查看并验证连接是否通畅
opencli bilibili hot --limit 5 # 执行目标命令
```
> *Tip: 如果你填写的是一个普通 HTTP/HTTPS 的 URL 地址,OpenCLI 会自动尝试抓取该地址下的 `/json/version` 节点,来动态解析并连接真正底层依赖的 WebSocket 地址。*
如果你想在此服务器上永久启用该配置,可以将对应的 `export` 语句追加进入你的 `~/.bashrc``~/.zshrc` 配置文件中。
+125
View File
@@ -0,0 +1,125 @@
---
description: How to CLI-ify and automate any Electron Desktop Application via CDP
---
# CLI-ifying Electron Applications (Skill Guide)
Based on the successful automation of **Cursor**, **Codex**, **Antigravity**, **ChatWise**, **Notion**, and **Discord** desktop apps, this guide serves as the standard operating procedure (SOP) for adapting ANY Electron-based application into an OpenCLI adapter.
## Core Concept
Electron apps are essentially local Chromium browser instances. By exposing a debugging port (CDP — Chrome DevTools Protocol) at launch time, we can use the Browser Bridge to pierce through the UI layer, accessing and controlling all underlying state including React/Vue components and Shadow DOM.
> **Note:** Not all desktop apps are Electron. WeChat (native Cocoa) and Feishu/Lark (custom Lark Framework) embed Chromium but do NOT expose CDP. For those apps, use the AppleScript + clipboard approach instead (see [Non-Electron Pattern](#non-electron-pattern-applescript)).
### Launching the Target App
```bash
/Applications/AppName.app/Contents/MacOS/AppName --remote-debugging-port=9222
```
### Verifying Electron
```bash
# Check for Electron Framework in the app bundle
ls /Applications/AppName.app/Contents/Frameworks/Electron\ Framework.framework
# If this directory exists → Electron → CDP works
# If not → check for libEGL.dylib (embedded Chromium/CEF, CDP may not work)
```
## The 5-Command Pattern (CDP / Electron)
Every new Electron adapter should implement these 5 commands in `src/clis/<app_name>/`:
### 1. `status.ts` — Connection Test
```typescript
export const statusCommand = cli({
site: 'myapp',
name: 'status',
domain: 'localhost',
strategy: Strategy.UI,
browser: true, // Requires CDP connection
args: [],
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 }];
},
});
```
### 2. `dump.ts` — Reverse Engineering Core
Modern app DOMs are huge and obfuscated. **Never guess selectors.** Dump first, then extract precise class names with AI or `grep`:
```typescript
const dom = await page.evaluate('document.body.innerHTML');
fs.writeFileSync('/tmp/app-dom.html', dom);
const snap = await page.snapshot({ interactive: false });
fs.writeFileSync('/tmp/app-snapshot.json', JSON.stringify(snap, null, 2));
```
### 3. `send.ts` — Advanced Text Injection
Electron apps often use complex rich-text editors (Monaco, Lexical, ProseMirror). Setting `.value` directly is ignored by React state.
**Best practice:** Use `document.execCommand('insertText')` to perfectly simulate real user input, fully piercing React state:
```javascript
const composer = document.querySelector('[contenteditable="true"]');
composer.focus();
document.execCommand('insertText', false, 'Hello');
```
Then submit with `await page.pressKey('Enter')`.
### 4. `read.ts` — Context Extraction
Don't extract the entire page text. Use `dump.ts` output to find the real "conversation container":
- Look for semantic selectors: `[role="log"]`, `[data-testid="conversation"]`, `[data-content-search-turn-key]`
- Format output as Markdown — readable by both humans and LLMs
### 5. `new.ts` — Keyboard Shortcuts
Many GUI actions respond to native shortcuts rather than button clicks:
```typescript
const isMac = process.platform === 'darwin';
await page.pressKey(isMac ? 'Meta+N' : 'Control+N');
await page.wait(1); // Wait for re-render
```
## Environment Variable
```bash
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9222"
```
## Non-Electron Pattern (AppleScript)
For native macOS apps (WeChat, Feishu) that don't expose CDP:
```typescript
export const statusCommand = cli({
site: 'myapp',
strategy: Strategy.PUBLIC,
browser: false, // No browser needed
func: async (page: IPage | null) => {
const output = execSync("osascript -e 'application \"MyApp\" is running'", { encoding: 'utf-8' }).trim();
return [{ Status: output === 'true' ? 'Running' : 'Stopped' }];
},
});
```
Core techniques:
- **status**: `osascript -e 'application "AppName" is running'`
- **send**: `pbcopy` → activate window → `Cmd+V``Enter`
- **read**: `Cmd+A``Cmd+C``pbpaste`
- **search**: Activate → `Cmd+F`/`Cmd+K``keystroke "query"`
## Pitfalls & Gotchas
1. **Port conflicts (EADDRINUSE)**: Only one app per port. Use unique ports: Codex=9222, ChatGPT=9224, Cursor=9226, ChatWise=9228, Notion=9230, Discord=9232
2. **IPage abstraction**: OpenCLI wraps the browser page as `IPage` (`src/types.ts`). Use `page.pressKey()` and `page.evaluate()`, NOT direct DOM APIs
3. **Timing**: Always add `await page.wait(0.5)` to `1.0` after DOM mutations. Returning too early disconnects prematurely
4. **AppleScript requires Accessibility**: Terminal app must be granted permission in System Settings → Privacy & Security → Accessibility
## Port Assignment Table
| App | Port | Mode |
|-----|------|------|
| Codex | 9222 | CDP |
| ChatGPT | 9224 | CDP / AppleScript |
| Cursor | 9226 | CDP |
| ChatWise | 9228 | CDP |
| Notion | 9230 | CDP |
| Discord App | 9232 | CDP |
+4 -4
View File
@@ -9,12 +9,12 @@
---
## AI Agent 开发者必读:用 Playwright MCP Bridge 探索
## AI Agent 开发者必读:用浏览器探索
> [!CAUTION]
> **你(AI Agent)必须通过 Playwright MCP Bridge 打开浏览器去访问目标网站!**
> **你(AI Agent)必须通过浏览器打开目标网站去探索**
> 不要只靠 `opencli explore` 命令或静态分析来发现 API。
> 你拥有 Playwright MCP 工具,必须主动用它们浏览网页、观察网络请求、模拟用户交互。
> 你拥有浏览器工具,必须主动用它们浏览网页、观察网络请求、模拟用户交互。
### 为什么?
@@ -36,7 +36,7 @@
| ❌ 错误做法 | ✅ 正确做法 |
|------------|------------|
| 只用 `opencli explore` 命令,等结果自动出来 | 用 MCP Bridge 打开浏览器,主动浏览页面 |
| 只用 `opencli explore` 命令,等结果自动出来 | 用浏览器工具打开页面,主动浏览 |
| 直接在代码里 `fetch(url)`,不看浏览器实际请求 | 先在浏览器中确认 API 可用,再写代码 |
| 页面打开后直接抓包,期望所有 API 都出现 | 模拟点击交互(展开评论/切换标签/加载更多) |
| 遇到 HTTP 200 但空数据就放弃 | 检查是否需要 Wbi 签名或 Cookie 鉴权 |
+167
View File
@@ -0,0 +1,167 @@
# Contributing to OpenCLI
Thanks for your interest in contributing to OpenCLI.
## Quick Start
```bash
# 1. Fork & clone
git clone git@github.com:<your-username>/opencli.git
cd opencli
# 2. Install dependencies
npm install
# 3. Build
npm run build
# 4. Run a few checks
npx tsc --noEmit
npx vitest run src/
# 5. Link globally (optional, for testing `opencli` command)
npm link
```
## Adding a New Site Adapter
This is the most common type of contribution. Start with YAML when possible, and use TypeScript only when you need browser-side logic or multi-step flows.
### YAML Adapter (Recommended for data-fetching commands)
Create a file like `src/clis/<site>/<command>.yaml`:
```yaml
site: mysite
name: trending
description: Trending posts on MySite
domain: www.mysite.com
strategy: public # public | cookie | header
browser: false # true if browser session is needed
args:
limit:
type: int
default: 20
description: Number of items
pipeline:
- fetch:
url: https://api.mysite.com/trending
- map:
rank: ${{ index + 1 }}
title: ${{ item.title }}
score: ${{ item.score }}
url: ${{ item.url }}
- limit: ${{ args.limit }}
columns: [rank, title, score, url]
```
See [`hackernews/top.yaml`](src/clis/hackernews/top.yaml) for a real example.
### TypeScript Adapter (For complex browser interactions)
Create a file like `src/clis/<site>/<command>.ts`:
```typescript
import { cli, Strategy } from '../../registry.js';
cli({
site: 'mysite',
name: 'search',
description: 'Search MySite',
domain: 'www.mysite.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'query', required: true, help: 'Search query' },
{ name: 'limit', type: 'int', default: 10, help: 'Max results' },
],
columns: ['title', 'url', 'date'],
func: async (page, kwargs) => {
const { query, limit = 10 } = kwargs;
await page.goto('https://www.mysite.com');
const data = await page.evaluate(`
(async () => {
const res = await fetch('/api/search?q=${encodeURIComponent(query)}', {
credentials: 'include'
});
return (await res.json()).results;
})()
`);
return data.slice(0, Number(limit)).map((item: any) => ({
title: item.title,
url: item.url,
date: item.created_at,
}));
},
});
```
Use `opencli explore <url>` to discover APIs and see [CLI-EXPLORER.md](./CLI-EXPLORER.md) if you need the full adapter workflow.
### Validate Your Adapter
```bash
# Validate YAML syntax and schema
opencli validate
# Test your command
opencli <site> <command> --limit 3 -f json
# Verbose mode for debugging
opencli <site> <command> -v
```
## Testing
See [TESTING.md](./TESTING.md) for the full guide and exact test locations.
```bash
npx vitest run src/ # Unit tests
npx vitest run tests/e2e/ # E2E tests
npx vitest run # All tests
```
## Code Style
- **TypeScript strict mode** — avoid `any` where possible.
- **ES Modules** — use `.js` extensions in imports (TypeScript output).
- **Naming**: `kebab-case` for files, `camelCase` for variables/functions, `PascalCase` for types/classes.
- **No default exports** — use named exports.
## Commit Convention
We use [Conventional Commits](https://www.conventionalcommits.org/):
```
feat(twitter): add thread command
fix(browser): handle CDP timeout gracefully
docs: update CONTRIBUTING.md
test(reddit): add e2e test for save command
chore: bump vitest to v4
```
Common scopes: site name (`twitter`, `reddit`) or module name (`browser`, `pipeline`, `engine`).
## Submitting a Pull Request
1. Create a feature branch: `git checkout -b feat/mysite-trending`
2. Make your changes and add tests when relevant
3. Run the checks that apply:
```bash
npx tsc --noEmit # Type check
npx vitest run src/ # Unit tests
opencli validate # YAML validation (if applicable)
```
4. Commit using conventional commit format
5. Push and open a PR
## License
By contributing, you agree that your contributions will be licensed under the [Apache-2.0 License](./LICENSE).
+184 -22
View File
@@ -1,28 +1,190 @@
BSD 3-Clause License
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Copyright (c) 2025, jackwener
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Definitions.
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2025 jackwener
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+137 -57
View File
@@ -1,7 +1,7 @@
# OpenCLI
> **Make any website your CLI.**
> Zero risk · Reuse Chrome login · AI-powered discovery
> **Make any website or Electron App your CLI.**
> Zero risk · Reuse Chrome login · AI-powered discovery · Browser + Desktop automation
[中文文档](./README.zh-CN.md)
@@ -9,7 +9,10 @@
[![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** into a command-line interface. **57 commands** across **17 sites**bilibili, zhihu, xiaohongshu, twitter, reddit, xueqiu, github, v2ex, hackernews, bbc, weibo, boss, yahoo-finance, reuters, smzdm, ctrip, youtube — powered by browser session reuse and AI-native discovery.
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.
🔥 **CLI All Electron Apps! The Most Powerful Update Has Arrived!** 🔥
Turn ANY Electron application into a CLI tool! Recombine, script, and extend applications like Antigravity Ultra seamlessly. Now AI can control itself natively. Unlimited possibilities await!
---
@@ -19,8 +22,11 @@ A CLI tool that turns **any website** into a command-line interface. **57 comman
- [Prerequisites](#prerequisites)
- [Quick Start](#quick-start)
- [Built-in Commands](#built-in-commands)
- [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)
@@ -29,54 +35,34 @@ A CLI tool that turns **any website** into a command-line interface. **57 comman
## 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.
- **Self-healing setup** — `opencli setup` auto-discovers tokens; `opencli doctor` diagnoses config across 10+ tools; `--fix` repairs them all.
- **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.
- **Dual-Engine Architecture** — Supports both YAML declarative data pipelines and robust browser runtime TypeScript injections.
## Prerequisites
- **Node.js**: >= 18.0.0
- **Node.js**: >= 20.0.0
- **Chrome** running **and logged into the target site** (e.g. bilibili.com, zhihu.com, xiaohongshu.com).
> **⚠️ Important**: Browser commands reuse your Chrome login session. You must be logged into the target website in Chrome before running commands. If you get empty data or errors, check your login status first.
OpenCLI connects to your browser through the Playwright MCP Bridge extension.
OpenCLI connects to your browser through a lightweight **Browser Bridge** Chrome Extension + micro-daemon (zero config, auto-start).
### Playwright MCP Bridge Extension Setup
### Browser Bridge Extension Setup
1. Install **[Playwright MCP Bridge](https://chromewebstore.google.com/detail/playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm)** extension in Chrome.
2. Obtain your token by clicking the extension icon in the browser toolbar or from the extension settings page.
1. Install the **opencli Browser Bridge** extension in Chrome:
- Open `chrome://extensions`, enable **Developer mode** (top-right toggle)
- Click **Load unpacked**, select the `extension/` folder from this repo
2. That's it! The daemon auto-starts when you run any browser command. No tokens, no manual configuration.
**You must configure this token in BOTH your MCP configuration AND system environment variables.**
First, add it to your MCP client config (e.g. Claude/Cursor):
```json
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["-y", "@playwright/mcp@latest", "--extension"],
"env": {
"PLAYWRIGHT_MCP_EXTENSION_TOKEN": "<your-token-here>"
}
}
}
}
```
And, so that `opencli` commands can use it directly in the terminal, export it in your shell environment (e.g. `~/.zshrc`):
```bash
export PLAYWRIGHT_MCP_EXTENSION_TOKEN="<your-token-here>"
```
After configuring, run `opencli doctor` to verify your token is correctly set up across all locations:
```bash
opencli doctor
```
> **Tip**: Use `opencli doctor` for ongoing diagnosis:
> ```bash
> opencli doctor # Check extension + daemon connectivity
> opencli doctor --live # Also test live browser commands
> ```
## Quick Start
@@ -116,25 +102,99 @@ npm install -g @jackwener/opencli@latest
## Built-in Commands
Run `opencli list` for the live registry.
| Site | Commands | Mode |
|------|----------|------|
| **bilibili** | `hot` `search` `me` `favorite` ... (11 commands) | 🔐 Browser |
| **zhihu** | `hot` `search` `question` | 🔐 Browser |
| **xiaohongshu** | `search` `notifications` `feed` `me` `user` | 🔐 Browser |
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` | 🔐 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` `extract-diff` `model` `ask` `screenshot` `history` `export` | 🖥️ Desktop |
| **chatwise** | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` | 🖥️ 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` | 🌐 / 🔐 |
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 🔐 Browser |
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `following` `followers` `notifications` `post` `reply` `delete` `like` | 🔐 Browser |
| **reddit** | `hot` `frontpage` `search` `subreddit` | 🔐 Browser |
| **weibo** | `hot` | 🔐 Browser |
| **boss** | `search` | 🔐 Browser |
| **youtube** | `search` | 🔐 Browser |
| **yahoo-finance** | `quote` | 🔐 Browser |
| **reuters** | `search` | 🔐 Browser |
| **smzdm** | `search` | 🔐 Browser |
| **antigravity** | `status` `send` `read` `new` `evaluate` | 🖥️ Desktop |
| **chatgpt** | `status` `new` `send` `read` `ask` | 🖥️ Desktop |
| **xiaohongshu** | `search` `notifications` `feed` `me` `user` `download` | 🔐 Browser |
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | 🌐 Public |
| **zhihu** | `hot` `search` `question` `download` | 🔐 Browser |
| **youtube** | `search` `video` `transcript` | 🔐 Browser |
| **boss** | `search` `detail` | 🔐 Browser |
| **coupang** | `search` `add-to-cart` | 🔐 Browser |
| **bbc** | `news` | 🌐 Public |
| **ctrip** | `search` | 🔐 Browser |
| **github** | `search` | 🌐 Public |
| **v2ex** | `hot` `latest` `topic` | 🌐 Public |
| **hackernews** | `top` | 🌐 Public |
| **bbc** | `news` | 🌐 Public |
| **linkedin** | `search` | 🔐 Browser |
| **reuters** | `search` | 🔐 Browser |
| **smzdm** | `search` | 🔐 Browser |
| **weibo** | `hot` | 🔐 Browser |
| **yahoo-finance** | `quote` | 🔐 Browser |
## Download Support
OpenCLI supports downloading images, videos, and articles from supported platforms.
### Supported Platforms
| Platform | Content Types | Notes |
|----------|---------------|-------|
| **xiaohongshu** | Images, Videos | Downloads all media from a note |
| **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 |
### Prerequisites
For video downloads from streaming platforms, you need to install `yt-dlp`:
```bash
# Install yt-dlp
pip install yt-dlp
# or
brew install yt-dlp
```
### Usage Examples
```bash
# Download images/videos from Xiaohongshu note
opencli xiaohongshu download --note-id 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
# Download Twitter media from user
opencli twitter download --username elonmusk --limit 20 --output ./twitter
# Download single tweet media
opencli twitter download --tweet-url "https://x.com/user/status/123" --output ./twitter
# Export Zhihu article to Markdown
opencli zhihu download --url "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
# Export with local images
opencli zhihu download --url "https://zhuanlan.zhihu.com/p/xxx" --download-images
```
### 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
@@ -175,15 +235,35 @@ opencli cascade https://api.example.com/data
Explore outputs to `.opencli/explore/<site>/` (manifest.json, endpoints.json, capabilities.json, auth.json).
## 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
```
## Troubleshooting
- **"Failed to connect to Playwright MCP Bridge"**
- Ensure the Playwright MCP extension is installed and **enabled** in your running Chrome.
- Restart the Chrome browser if you just installed the extension.
- **"Extension not connected"**
- Ensure the opencli Browser Bridge extension is installed and **enabled** in `chrome://extensions`.
- **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 to prove you are human.
- 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**
- Make sure you are using Node.js >= 18. Some dependencies require modern Node APIs.
- Make sure you are using Node.js >= 20. Some dependencies require modern Node APIs.
- **Daemon issues**
- Check daemon status: `curl localhost:19825/status`
- View extension logs: `curl localhost:19825/logs`
## Releasing New Versions
@@ -197,4 +277,4 @@ The CI will automatically build, create a GitHub release, and publish to npm.
## License
[BSD-3-Clause](./LICENSE)
[Apache-2.0](./LICENSE)
+120 -57
View File
@@ -1,7 +1,7 @@
# OpenCLI
> **把任何网站变成你的命令行工具。**
> 零风控 · 复用 Chrome 登录 · AI 自动发现接口
> **把任何网站或 Electron 应用变成你的命令行工具。**
> 零风控 · 复用 Chrome 登录 · AI 自动发现接口 · 浏览器与桌面端自动化
[English](./README.md)
@@ -9,7 +9,12 @@
[![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 将任何网站变成命令行工具。**57 个命令**覆盖 **17 个站点** — B站、知乎、小红书、Twitter、Reddit、雪球、GitHub、V2EX、Hacker News、BBC、微博、BOSS直聘、Yahoo Finance、路透社、什么值得买、携程、YouTube — 复用浏览器登录态,AI 驱动探索。
OpenCLI 将任何网站或 Electron 应用(如 Antigravity变成命令行工具 — B站、知乎、小红书、Twitter/X、Reddit、YouTube 等[多种站点与应用](#内置命令) — 复用浏览器登录态,AI 驱动探索。
🔥 **opencli 支持 CLI 化所有 electron 应用!最强大更新来袭!** 🔥
CLI all electron!现在支持把所有 electron 应用 CLI 化,从而组合出各种神奇的能力。
如果你在使用诸如 Antigravity Ultra 等工具时觉得不够灵活或难以扩展,现在通过 OpenCLI 把他 CLI 化,轻松打破界限。
现在,**AI 可以自己控制自己**!结合 cc/openclaw 就可以远程控制任何 electron 应用!无限玩法!!
---
@@ -19,8 +24,10 @@ OpenCLI 将任何网站变成命令行工具。**57 个命令**覆盖 **17 个
- [前置要求](#前置要求)
- [快速开始](#快速开始)
- [内置命令](#内置命令)
- [下载支持](#下载支持)
- [输出格式](#输出格式)
- [致 AI Agent(开发者指南)](#致-ai-agent开发者指南)
- [远程 Chrome(服务器/无头环境)](#远程-chrome服务器无头环境)
- [常见问题排查](#常见问题排查)
- [版本发布](#版本发布)
- [License](#license)
@@ -29,54 +36,34 @@ OpenCLI 将任何网站变成命令行工具。**57 个命令**覆盖 **17 个
## 亮点
- **57 个命令,17 个站点** — B站、知乎、小红书、Twitter、Reddit、雪球(xueqiu)、GitHub、V2EX、Hacker News、BBC、微博、BOSS直聘、Yahoo Finance、路透社、什么值得买、携程、YouTube
- **CLI All Electron** — 支持把所有 electron 应用(如 Antigravity UltraCLI 化,让 AI 控制自己!
- **多站点覆盖** — 覆盖 B站、知乎、小红书、Twitter、Reddit,以及多种桌面应用
- **零风控** — 复用 Chrome 登录态,无需存储任何凭证
- **自修复配置** — `opencli setup` 自动发现 Token`opencli doctor` 诊断 10+ 工具配置;`--fix` 一键修复
- **AI 原生** — `explore` 自动发现 API`synthesize` 生成适配器,`cascade` 探测认证策略
- **动态加载引擎** — 声明式的 `.yaml` 或者底层定制的 `.ts` 适配器,放入 `clis/` 文件夹即可自动注册生效
## 前置要求
- **Node.js**: >= 18.0.0
- **Node.js**: >= 20.0.0
- **Chrome** 浏览器正在运行,且**已登录目标网站**(如 bilibili.com、zhihu.com、xiaohongshu.com
> **⚠️ 重要**:大多数命令复用你的 Chrome 登录状态。运行命令前,你必须已在 Chrome 中打开目标网站并完成登录。如果获取到空数据或报错,请先检查你的浏览器登录状态。
OpenCLI 通过 Playwright MCP Bridge 扩展与你的浏览器通信
OpenCLI 通过轻量化的 **Browser Bridge** Chrome 扩展 + 微型 daemon 与浏览器通信(零配置,自动启动)
### Playwright MCP Bridge 扩展配置
### Browser Bridge 扩展配置
1. 安装 **[Playwright MCP Bridge](https://chromewebstore.google.com/detail/playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm)** 扩展
2. 在浏览器插件栏点击该插件,或者在插件设置页获取你的 Extension Token。
1. 在 Chrome 中安装 **opencli Browser Bridge** 扩展
- 打开 `chrome://extensions`,启用右上角的 **开发者模式**
- 点击 **加载已解压的扩展程序**,选择本仓库的 `extension/` 文件夹
2. 完成!运行任何浏览器命令时 daemon 会自动启动。无需 token,无需手动配置。
**你必须将这个 Token 同时配置到你的 MCP 配置文件 AND 环境变量中。**
首先,配置你的 MCP 客户端(如 Claude/Cursor 等):
```json
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["-y", "@playwright/mcp@latest", "--extension"],
"env": {
"PLAYWRIGHT_MCP_EXTENSION_TOKEN": "<你的-token>"
}
}
}
}
```
并且,为了让 `opencli` 命令行也能直接使用它,你必须在你的终端系统环境变量中导出它(建议写进 `~/.zshrc``~/.bashrc`):
```bash
export PLAYWRIGHT_MCP_EXTENSION_TOKEN="<你的-token>"
```
配置完成后,运行 `opencli doctor` 检测你的 Token 是否在所有位置都正确配置:
```bash
opencli doctor
```
> **Tip**:后续诊断用 `opencli doctor`
> ```bash
> opencli doctor # 检查扩展和 daemon 连通性
> opencli doctor --live # 额外测试浏览器命令
> ```
## 快速开始
@@ -116,25 +103,99 @@ npm install -g @jackwener/opencli@latest
## 内置命令
运行 `opencli list` 查看完整注册表。
| 站点 | 命令 | 模式 |
|------|------|------|
| **bilibili** | `hot` `search` `me` `favorite` ...(共11个) | 🔐 浏览器 |
| **zhihu** | `hot` `search` `question` | 🔐 浏览器 |
| **xiaohongshu** | `search` `notifications` `feed` `me` `user` | 🔐 浏览器 |
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` | 🔐 浏览器 |
| **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` `extract-diff` `model` `ask` `screenshot` `history` `export` | 🖥️ 桌面端 |
| **chatwise** | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` | 🖥️ 桌面端 |
| **notion** | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` | 🖥️ 桌面端 |
| **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` | 🖥️ 桌面端 |
| **v2ex** | `hot` `latest` `topic` `daily` `me` `notifications` | 🌐 / 🔐 |
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 🔐 浏览器 |
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `following` `followers` `notifications` `post` `reply` `delete` `like` | 🔐 浏览器 |
| **reddit** | `hot` `frontpage` `search` `subreddit` | 🔐 浏览器 |
| **weibo** | `hot` | 🔐 浏览器 |
| **boss** | `search` | 🔐 浏览器 |
| **youtube** | `search` | 🔐 浏览器 |
| **yahoo-finance** | `quote` | 🔐 浏览器 |
| **reuters** | `search` | 🔐 浏览器 |
| **smzdm** | `search` | 🔐 浏览器 |
| **antigravity** | `status` `send` `read` `new` `evaluate` | 🖥️ 桌面端 |
| **chatgpt** | `status` `new` `send` `read` `ask` | 🖥️ 桌面端 |
| **xiaohongshu** | `search` `notifications` `feed` `me` `user` `download` | 🔐 浏览器 |
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | 🌐 公开 |
| **zhihu** | `hot` `search` `question` `download` | 🔐 浏览器 |
| **youtube** | `search` `video` `transcript` | 🔐 浏览器 |
| **boss** | `search` `detail` | 🔐 浏览器 |
| **coupang** | `search` `add-to-cart` | 🔐 浏览器 |
| **bbc** | `news` | 🌐 公共 API |
| **ctrip** | `search` | 🔐 浏览器 |
| **github** | `search` | 🌐 公共 API |
| **v2ex** | `hot` `latest` `topic` | 🌐 公共 API |
| **hackernews** | `top` | 🌐 公共 API |
| **bbc** | `news` | 🌐 公共 API |
| **linkedin** | `search` | 🔐 浏览器 |
| **reuters** | `search` | 🔐 浏览器 |
| **smzdm** | `search` | 🔐 浏览器 |
| **weibo** | `hot` | 🔐 浏览器 |
| **yahoo-finance** | `quote` | 🔐 浏览器 |
## 下载支持
OpenCLI 支持从各平台下载图片、视频和文章。
### 支持的平台
| 平台 | 内容类型 | 说明 |
|------|----------|------|
| **小红书** | 图片、视频 | 下载笔记中的所有媒体文件 |
| **B站** | 视频 | 需要安装 `yt-dlp` |
| **Twitter/X** | 图片、视频 | 从用户媒体页或单条推文下载 |
| **知乎** | 文章(Markdown) | 导出文章,可选下载图片到本地 |
### 前置依赖
下载流媒体平台的视频需要安装 `yt-dlp`
```bash
# 安装 yt-dlp
pip install yt-dlp
# 或者
brew install yt-dlp
```
### 使用示例
```bash
# 下载小红书笔记中的图片/视频
opencli xiaohongshu download --note-id abc123 --output ./xhs
# 下载B站视频(需要 yt-dlp
opencli bilibili download --bvid BV1xxx --output ./bilibili
opencli bilibili download --bvid BV1xxx --quality 1080p # 指定画质
# 下载 Twitter 用户的媒体
opencli twitter download --username elonmusk --limit 20 --output ./twitter
# 下载单条推文的媒体
opencli twitter download --tweet-url "https://x.com/user/status/123" --output ./twitter
# 导出知乎文章为 Markdown
opencli zhihu download --url "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
# 导出文章并下载图片到本地
opencli zhihu download --url "https://zhuanlan.zhihu.com/p/xxx" --download-images
```
### 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
```
## 输出格式
@@ -177,13 +238,15 @@ opencli cascade https://api.example.com/data
## 常见问题排查
- **"Failed to connect to Playwright MCP Bridge"** 报错
- 确保你当前的 Chrome 已安装且**开启了** Playwright MCP Bridge 浏览器插件
- 如果是刚装完插件,需要重启 Chrome 浏览器。
- **"Extension not connected" 报错**
- 确保你当前的 Chrome 已安装且**开启了** opencli Browser Bridge 扩展(在 `chrome://extensions` 中检查)
- **返回空数据,或者报错 "Unauthorized"**
- Chrome 里的登录态可能已经过期(甚至被要求过滑动验证码)。请打开当前 Chrome 页面,在新标签页重新手工登录或刷新该页面。
- Chrome 里的登录态可能已经过期。请打开当前 Chrome 页面,在新标签页重新手工登录或刷新该页面。
- **Node API 错误 (如 parseArgs, fs 等)**
- 确保 Node.js 版本 `>= 18`。旧版不支持我们使用的现代核心库 API
- 确保 Node.js 版本 `>= 20`
- **Daemon 问题**
- 检查 daemon 状态:`curl localhost:19825/status`
- 查看扩展日志:`curl localhost:19825/logs`
## 版本发布
@@ -197,4 +260,4 @@ git push --follow-tags
## License
[BSD-3-Clause](./LICENSE)
[Apache-2.0](./LICENSE)
+59 -18
View File
@@ -1,18 +1,18 @@
---
name: opencli
description: "OpenCLI — Make any website your CLI. Zero risk, AI-powered, reuse Chrome login."
version: 0.5.0
description: "OpenCLI — Make any website or Electron App your CLI. Zero risk, AI-powered, reuse Chrome login. 80+ commands across 19 sites."
version: 0.7.3
author: jackwener
tags: [cli, browser, web, mcp, playwright, bilibili, zhihu, twitter, github, v2ex, hackernews, reddit, xiaohongshu, xueqiu, AI, agent]
tags: [cli, browser, web, chrome-extension, cdp, bilibili, zhihu, twitter, github, v2ex, hackernews, reddit, xiaohongshu, xueqiu, youtube, boss, coupang, AI, agent]
---
# OpenCLI
> Make any website your CLI. Reuse Chrome login, zero risk, AI-powered discovery.
> Make any website or Electron App your CLI. Reuse Chrome login, zero risk, AI-powered discovery.
> [!CAUTION]
> **AI Agent 必读:创建或修改任何适配器之前,你必须先阅读 [CLI-EXPLORER.md](./CLI-EXPLORER.md)**
> 该文档包含完整的 API 发现工作流(必须使用 Playwright MCP Bridge 浏览器探索)、5 级认证策略决策树、平台 SDK 速查表、`tap` 步骤调试流程、分页 API 模板、级联请求模式、以及常见陷阱。
> 该文档包含完整的 API 发现工作流(必须使用浏览器探索)、5 级认证策略决策树、平台 SDK 速查表、`tap` 步骤调试流程、分页 API 模板、级联请求模式、以及常见陷阱。
> **本文件(SKILL.md)仅提供命令参考和简化模板,不足以正确开发适配器。**
## Install & Run
@@ -34,7 +34,8 @@ npm update -g @jackwener/opencli
Browser commands require:
1. Chrome browser running **(logged into target sites)**
2. [Playwright MCP Bridge](https://chromewebstore.google.com/detail/playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm) extension installed and configured
2. **opencli Browser Bridge** Chrome extension installed (load `extension/` as unpacked in `chrome://extensions`)
3. No further setup needed — the daemon auto-starts on first browser command
> **Note**: You must be logged into the target website in Chrome before running commands. Tabs opened during command execution are auto-closed afterwards.
@@ -67,7 +68,7 @@ opencli zhihu question --id 34816524 # 问题详情和回答
opencli xiaohongshu search --keyword "美食" # 搜索笔记
opencli xiaohongshu notifications # 通知(mentions/likes/connections
opencli xiaohongshu feed --limit 10 # 推荐 Feed
opencli xiaohongshu me # 我的信息
opencli xiaohongshu me # 我的信息
opencli xiaohongshu user --uid xxx # 用户主页
# 雪球 Xueqiu (browser)
@@ -85,20 +86,40 @@ opencli github search --keyword "cli" # 搜索仓库
opencli twitter trending --limit 10 # 热门话题
opencli twitter bookmarks --limit 20 # 获取收藏的书签推文
opencli twitter search --keyword "AI" # 搜索推文
opencli twitter profile --username elonmusk # 用户资料
opencli twitter profile elonmusk # 用户资料
opencli twitter timeline --limit 20 # 时间线
opencli twitter thread 1234567890 # 推文 thread(原文 + 回复)
opencli twitter article 1891511252174299446 # 推文长文内容
opencli twitter follow elonmusk # 关注用户
opencli twitter unfollow elonmusk # 取消关注
opencli twitter bookmark https://x.com/... # 收藏推文
opencli twitter unbookmark https://x.com/... # 取消收藏
# Reddit (browser)
opencli reddit hot --limit 10 # 热门帖子
opencli reddit hot --subreddit programming # 指定子版块
opencli reddit frontpage --limit 10 # 首页
opencli reddit search --keyword "AI" # 搜索
opencli reddit subreddit --name rust # 子版块浏览
opencli reddit frontpage --limit 10 # 首页 /r/all
opencli reddit popular --limit 10 # /r/popular 热门
opencli reddit search --query "AI" --sort top --time week # 搜索(支持排序+时间过滤)
opencli reddit subreddit --name rust --sort top --time month # 子版块浏览(支持时间过滤)
opencli reddit read --post_id 1abc123 # 阅读帖子 + 评论
opencli reddit user --username spez # 用户资料(karma、注册时间)
opencli reddit user-posts --username spez # 用户发帖历史
opencli reddit user-comments --username spez # 用户评论历史
opencli reddit upvote --post_id xxx --direction up # 投票(up/down/none
opencli reddit save --post_id xxx # 收藏帖子
opencli reddit comment --post_id xxx --text "Great!" # 发表评论
opencli reddit subscribe --subreddit python # 订阅子版块
opencli reddit saved --limit 10 # 我的收藏
opencli reddit upvoted --limit 10 # 我的赞
# V2EX (public)
# V2EX (public + browser)
opencli v2ex hot --limit 10 # 热门话题
opencli v2ex latest --limit 10 # 最新话题
opencli v2ex topic --id 1024 # 主题详情
opencli v2ex daily # 每日签到 (browser)
opencli v2ex me # 我的信息 (browser)
opencli v2ex notifications --limit 10 # 通知 (browser)
# Hacker News (public)
opencli hackernews top --limit 10 # Top stories
@@ -111,9 +132,13 @@ opencli weibo hot --limit 10 # 微博热搜
# BOSS直聘 (browser)
opencli boss search --query "AI agent" # 搜索职位
opencli boss detail --securityId xxx # 职位详情
# YouTube (browser)
opencli youtube search --query "rust" # 搜索视频
opencli youtube video --url "https://www.youtube.com/watch?v=xxx" # 视频元数据(标题、播放量、描述等)
opencli youtube transcript --url "https://www.youtube.com/watch?v=xxx" # 获取视频字幕/转录
opencli youtube transcript --url "xxx" --lang zh-Hans --mode raw # 指定语言 + 原始时间戳模式
# Yahoo Finance (browser)
opencli yahoo-finance quote --symbol AAPL # 股票行情
@@ -126,6 +151,15 @@ opencli smzdm search --keyword "耳机" # 搜索好价
# 携程 (browser)
opencli ctrip search --query "三亚" # 搜索目的地
# Antigravity (Electron/CDP)
opencli antigravity status # 检查 CDP 连接
opencli antigravity send "hello" # 发送文本到当前 agent 聊天框
opencli antigravity read # 读取整个聊天记录面板
opencli antigravity new # 清空聊天、开启新对话
opencli antigravity extract-code # 自动抽取 AI 回复中的代码块
opencli antigravity model claude # 切换底层模型
opencli antigravity watch # 流式监听增量消息
```
### Management Commands
@@ -136,6 +170,11 @@ opencli list --json # JSON output
opencli list -f yaml # YAML output
opencli validate # Validate all CLI definitions
opencli validate bilibili # Validate specific site
opencli setup # Interactive token setup (auto-discover + TUI checkbox)
opencli doctor # Diagnose token & extension config across all tools
opencli doctor --live # Also test live browser connectivity
opencli doctor --fix # Fix mismatched configs (interactive confirmation)
opencli doctor --fix -y # Fix all configs non-interactively
```
### AI Agent Workflow
@@ -156,8 +195,8 @@ opencli cascade <api-url>
# Explore with interactive fuzzing (click buttons to trigger lazy APIs)
opencli explore <url> --auto --click "字幕,CC,评论"
# Verify: smoke-test a generated adapter
opencli verify <site/name> --smoke
# Verify: validate adapter definitions
opencli verify
```
## Output Formats
@@ -188,7 +227,7 @@ opencli bilibili hot -v # Show each pipeline step and data flow
> [!IMPORTANT]
> **完整模式 — 在写任何代码之前,先阅读 [CLI-EXPLORER.md](./CLI-EXPLORER.md)。**
> 它包含:① AI Agent 浏览器探索工作流(必须用 Playwright MCP 抓包验证 API② 认证策略决策树 ③ 平台 SDK(如 Bilibili 的 `apiGet`/`fetchJson`)④ YAML vs TS 选择指南 ⑤ `tap` 步骤调试方法 ⑥ 级联请求模板 ⑦ 常见陷阱表。
> 它包含:① AI Agent 浏览器探索工作流 ② 认证策略决策树 ③ 平台 SDK(如 Bilibili 的 `apiGet`/`fetchJson`)④ YAML vs TS 选择指南 ⑤ `tap` 步骤调试方法 ⑥ 级联请求模板 ⑦ 常见陷阱表。
> **下方仅为简化模板参考,直接使用极易踩坑。**
### YAML Pipeline (declarative, recommended)
@@ -335,16 +374,18 @@ ${{ index + 1 }}
| Variable | Default | Description |
|----------|---------|-------------|
| `OPENCLI_DAEMON_PORT` | 19825 | Daemon listen port |
| `OPENCLI_BROWSER_CONNECT_TIMEOUT` | 30 | Browser connection timeout (sec) |
| `OPENCLI_BROWSER_COMMAND_TIMEOUT` | 45 | Command execution timeout (sec) |
| `OPENCLI_BROWSER_EXPLORE_TIMEOUT` | 120 | Explore timeout (sec) |
| `PLAYWRIGHT_MCP_EXTENSION_TOKEN` | — | Auto-approve extension connection |
| `OPENCLI_VERBOSE` | — | Show daemon/extension logs |
## Troubleshooting
| Issue | Solution |
|-------|----------|
| `npx not found` | Install Node.js: `brew install node` |
| `Timed out connecting to browser` | 1) Chrome must be open 2) Install MCP Bridge extension and configure token |
| `Extension not connected` | 1) Chrome must be open 2) Install opencli Browser Bridge extension |
| `Target page context` error | Add `navigate:` step before `evaluate:` in YAML |
| Empty table data | Check if evaluate returns JSON string (MCP parsing) or data path is wrong |
| Empty table data | Check if evaluate returns correct data path |
| Daemon issues | `curl localhost:19825/status` to check, `curl localhost:19825/logs` for extension logs |
+233
View File
@@ -0,0 +1,233 @@
# Testing Guide
> 面向开发者和 AI Agent 的测试参考手册。
## 目录
- [测试架构](#测试架构)
- [当前覆盖范围](#当前覆盖范围)
- [本地运行测试](#本地运行测试)
- [如何添加新测试](#如何添加新测试)
- [CI/CD 流水线](#cicd-流水线)
- [浏览器模式](#浏览器模式)
- [站点兼容性](#站点兼容性)
---
## 测试架构
测试分为三层,全部使用 **vitest** 运行:
```
tests/
├── e2e/ # E2E 集成测试(子进程运行真实 CLI)
│ ├── helpers.ts # runCli() 共享工具
│ ├── 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 可用性检测
src/
├── *.test.ts # 单元测试(已有 8 个)
```
| 层 | 位置 | 运行方式 | 用途 |
|---|---|---|---|
| 单元测试 | `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 健康 |
---
## 当前覆盖范围
### 单元测试(8 个文件)
| 文件 | 覆盖内容 |
|---|---|
| `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` | 数据变换步骤 |
### 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 |
### 烟雾测试
公开 API 可用性(hackernews, v2ex×2, v2ex/topic+ 全站点注册完整性检查。
---
## 本地运行测试
### 前置条件
```bash
npm ci # 安装依赖
npm run build # 编译(E2E 测试需要 dist/main.js
```
### 运行命令
```bash
# 全部单元测试
npx vitest run src/
# 全部 E2E 测试(会真实调用外部 API)
npx vitest run tests/e2e/
# 单个测试文件
npx vitest run tests/e2e/management.test.ts
# 全部测试(单元 + E2E
npx vitest run
# 烟雾测试
npx vitest run tests/smoke/
# watch 模式(开发时推荐)
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 扩展,手动跑对应测试
---
## 如何添加新测试
### 新增 YAML Adapter(如 `src/clis/producthunt/trending.yaml`
1. **无需额外操作**`validate` 测试会自动覆盖 YAML 结构验证
2. 根据 adapter 类型,在对应文件加一个 `it()` block
```typescript
// 如果 browser: false(公开 API)→ tests/e2e/public-commands.test.ts
it('producthunt trending returns data', async () => {
const { stdout, code } = await runCli(['producthunt', 'trending', '--limit', '3', '-f', 'json']);
expect(code).toBe(0);
const data = parseJsonOutput(stdout);
expect(Array.isArray(data)).toBe(true);
expect(data.length).toBeGreaterThanOrEqual(1);
expect(data[0]).toHaveProperty('title');
}, 30_000);
```
```typescript
// 如果 browser: true 但可公开访问 → tests/e2e/browser-public.test.ts
it('producthunt trending returns data', async () => {
const data = await tryBrowserCommand(['producthunt', 'trending', '--limit', '3', '-f', 'json']);
expectDataOrSkip(data, 'producthunt trending');
}, 60_000);
```
```typescript
// 如果 browser: true 且需登录 → tests/e2e/browser-auth.test.ts
it('producthunt me fails gracefully without login', async () => {
await expectGracefulAuthFailure(['producthunt', 'me', '-f', 'json'], 'producthunt me');
}, 60_000);
```
### 新增管理命令(如 `opencli export`
`tests/e2e/management.test.ts` 添加测试。
### 新增内部模块
`src/` 下对应位置创建 `*.test.ts`
### 决策流程图
```
新增功能 → 是内部模块? → 是 → src/ 下加 *.test.ts
↓ 否
是 CLI 命令? → browser: false? → tests/e2e/public-commands.test.ts
↓ true
公开数据? → tests/e2e/browser-public.test.ts
↓ 需登录
tests/e2e/browser-auth.test.ts
```
---
## CI/CD 流水线
### ci.yml(主流水线)
| Job | 触发条件 | 内容 |
|---|---|---|
| **build** | push/PR to main,dev | typecheck + build |
| **unit-test** | push/PR to main,dev | 单元测试,2 shard 并行 |
| **smoke-test** | 每周一 08:00 UTC / 手动 | xvfb + real Chrome,外部 API 健康检查 |
### e2e-headed.ymlE2E 测试)
| Job | 触发条件 | 内容 |
|---|---|---|
| **e2e-headed** | push/PR to main,dev | xvfb + real Chrome,全部 E2E 测试 |
E2E 使用 `browser-actions/setup-chrome` 安装真实 Chrome,配合 `xvfb-run` 提供虚拟显示器,以 headed 模式运行浏览器。
### Sharding
单元测试使用 vitest 内置 shard
```yaml
strategy:
matrix:
shard: [1, 2]
steps:
- run: npx vitest run src/ --shard=${{ matrix.shard }}/2
```
---
## 浏览器模式
opencli 通过 Browser Bridge 扩展连接浏览器:
| 条件 | 模式 | 使用场景 |
|---|---|---|
| 扩展已安装 | Extension 模式 | 本地用户,连接已登录的 Chrome |
| 扩展未安装 | CLI 报错提示安装 | 需要安装 Browser Bridge 扩展 |
CI 中使用 `OPENCLI_BROWSER_EXECUTABLE_PATH` 指定真实 Chrome 路径:
```yaml
env:
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
```
---
## 站点兼容性
在 GitHub Actions 美国 runner 上,部分站点因地域限制或登录要求返回空数据。E2E 测试对这些站点使用 warn + pass 策略,不影响 CI 绿灯。
| 站点 | CI 状态 | 限制原因 |
|---|---|---|
| hackernews, bbc, v2ex | ✅ 返回数据 | 无限制 |
| yahoo-finance | ✅ 返回数据 | 无限制 |
| bilibili, zhihu, weibo, xiaohongshu | ⚠️ 空数据 | 地域限制(中国站点) |
| reddit, twitter, youtube | ⚠️ 空数据 | 需登录或 cookie |
| smzdm, boss, ctrip, coupang, xueqiu | ⚠️ 空数据 | 地域限制 / 需登录 |
> 使用 self-hosted runner(国内服务器)可解决地域限制问题。
+2
View File
@@ -0,0 +1,2 @@
node_modules/
dist/
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 628 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

+31
View File
@@ -0,0 +1,31 @@
{
"manifest_version": 3,
"name": "opencli Browser Bridge",
"version": "0.1.0",
"description": "Bridge between opencli CLI and your browser — execute commands, read cookies, manage tabs.",
"permissions": [
"debugger",
"tabs",
"cookies",
"activeTab",
"alarms"
],
"background": {
"service_worker": "dist/background.js",
"type": "module"
},
"icons": {
"16": "icons/icon-16.png",
"32": "icons/icon-32.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
},
"action": {
"default_title": "opencli Browser Bridge",
"default_icon": {
"16": "icons/icon-16.png",
"32": "icons/icon-32.png"
}
},
"homepage_url": "https://github.com/jackwener/opencli"
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "opencli-extension",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite build --watch",
"build": "vite build",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/chrome": "^0.0.287",
"typescript": "^5.7.0",
"vite": "^6.0.0"
}
}
+293
View File
@@ -0,0 +1,293 @@
/**
* opencli Browser Bridge — Service Worker (background script).
*
* Connects to the opencli daemon via WebSocket, receives commands,
* dispatches them to Chrome APIs (debugger/tabs/cookies), returns results.
*/
import type { Command, Result } from './protocol';
import { DAEMON_WS_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol';
import * as cdp from './cdp';
let ws: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectAttempts = 0;
// ─── Console log forwarding ──────────────────────────────────────────
// Hook console.log/warn/error to forward logs to daemon via WebSocket.
const _origLog = console.log.bind(console);
const _origWarn = console.warn.bind(console);
const _origError = console.error.bind(console);
function forwardLog(level: 'info' | 'warn' | 'error', args: unknown[]): void {
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 { /* don't recurse */ }
}
console.log = (...args: unknown[]) => { _origLog(...args); forwardLog('info', args); };
console.warn = (...args: unknown[]) => { _origWarn(...args); forwardLog('warn', args); };
console.error = (...args: unknown[]) => { _origError(...args); forwardLog('error', args); };
// ─── WebSocket connection ────────────────────────────────────────────
function connect(): void {
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; // Reset on successful connection
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
};
ws.onmessage = async (event) => {
try {
const command = JSON.parse(event.data as string) as Command;
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();
};
}
function scheduleReconnect(): void {
if (reconnectTimer) return;
reconnectAttempts++;
// Exponential backoff: 2s, 4s, 8s, 16s, ..., capped at 60s
const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY);
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, delay);
}
// ─── Lifecycle events ────────────────────────────────────────────────
let initialized = false;
function initialize(): void {
if (initialized) return;
initialized = true;
chrome.alarms.create('keepalive', { periodInMinutes: 0.4 }); // ~24 seconds
cdp.registerListeners();
connect();
console.log('[opencli] Browser Bridge extension initialized');
}
chrome.runtime.onInstalled.addListener(() => {
initialize();
});
chrome.runtime.onStartup.addListener(() => {
initialize();
});
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'keepalive') connect();
});
// ─── Command dispatcher ─────────────────────────────────────────────
async function handleCommand(cmd: Command): Promise<Result> {
try {
switch (cmd.action) {
case 'exec':
return await handleExec(cmd);
case 'navigate':
return await handleNavigate(cmd);
case 'tabs':
return await handleTabs(cmd);
case 'cookies':
return await handleCookies(cmd);
case 'screenshot':
return await handleScreenshot(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),
};
}
}
// ─── 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;
return !url.startsWith('chrome://') && !url.startsWith('chrome-extension://');
}
/** Resolve target tab: use specified tabId or fall back to active web page tab */
async function resolveTabId(tabId?: number): Promise<number> {
if (tabId !== undefined) return tabId;
// Try the active tab first
const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (activeTab?.id && isWebUrl(activeTab.url)) {
return activeTab.id;
}
// Active tab is not debuggable — try to find any open web page tab
const allTabs = await chrome.tabs.query({ currentWindow: true });
const webTab = allTabs.find(t => t.id && isWebUrl(t.url));
if (webTab?.id) {
await chrome.tabs.update(webTab.id, { active: true });
return webTab.id;
}
// No web tabs at all — create one
const newTab = await chrome.tabs.create({ url: 'about:blank', active: true });
if (!newTab.id) throw new Error('Failed to create new tab');
return newTab.id;
}
async function handleExec(cmd: Command): Promise<Result> {
if (!cmd.code) return { id: cmd.id, ok: false, error: 'Missing code' };
const tabId = await resolveTabId(cmd.tabId);
try {
const data = await cdp.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: Command): Promise<Result> {
if (!cmd.url) return { id: cmd.id, ok: false, error: 'Missing url' };
const tabId = await resolveTabId(cmd.tabId);
await chrome.tabs.update(tabId, { url: cmd.url });
// Wait for page to finish loading, checking current status first to avoid race
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; }
const listener = (id: number, info: chrome.tabs.TabChangeInfo) => {
if (id === tabId && info.status === 'complete') {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}
};
chrome.tabs.onUpdated.addListener(listener);
// Timeout fallback
setTimeout(() => {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}, 15000);
});
});
const tab = await chrome.tabs.get(tabId);
return { id: cmd.id, ok: true, data: { title: tab.title, url: tab.url, tabId } };
}
async function handleTabs(cmd: Command): Promise<Result> {
switch (cmd.op) {
case 'list': {
const tabs = await chrome.tabs.query({});
const data = tabs
.filter((t) => isWebUrl(t.url))
.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 tab = await chrome.tabs.create({ url: cmd.url, active: true });
return { id: cmd.id, ok: true, data: { tabId: tab.id, url: tab.url } };
}
case 'close': {
if (cmd.index !== undefined) {
const tabs = await chrome.tabs.query({});
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);
cdp.detach(target.id);
return { id: cmd.id, ok: true, data: { closed: target.id } };
}
const tabId = await resolveTabId(cmd.tabId);
await chrome.tabs.remove(tabId);
cdp.detach(tabId);
return { id: cmd.id, ok: true, data: { closed: tabId } };
}
case 'select': {
if (cmd.index === undefined && cmd.tabId === undefined)
return { id: cmd.id, ok: false, error: 'Missing index or tabId' };
if (cmd.tabId !== undefined) {
await chrome.tabs.update(cmd.tabId, { active: true });
return { id: cmd.id, ok: true, data: { selected: cmd.tabId } };
}
const tabs = await chrome.tabs.query({});
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}` };
}
}
async function handleCookies(cmd: Command): Promise<Result> {
const details: chrome.cookies.GetAllDetails = {};
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 };
}
async function handleScreenshot(cmd: Command): Promise<Result> {
const tabId = await resolveTabId(cmd.tabId);
try {
const data = await cdp.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) };
}
}
+125
View File
@@ -0,0 +1,125 @@
/**
* 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).
*/
const attached = new Set<number>();
async function ensureAttached(tabId: number): Promise<void> {
if (attached.has(tabId)) return;
try {
await chrome.debugger.attach({ tabId }, '1.3');
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
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}`);
}
} else {
throw new Error(`attach failed: ${msg}`);
}
}
attached.add(tabId);
try {
await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable');
} catch {
// Some pages may not need explicit enable
}
}
export async function evaluate(tabId: number, expression: string): Promise<unknown> {
await ensureAttached(tabId);
const result = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
expression,
returnByValue: true,
awaitPromise: true,
}) as {
result?: { type: string; value?: unknown; description?: string; subtype?: string };
exceptionDetails?: { exception?: { description?: string }; text?: string };
};
if (result.exceptionDetails) {
const errMsg = result.exceptionDetails.exception?.description
|| result.exceptionDetails.text
|| 'Eval error';
throw new Error(errMsg);
}
return result.result?.value;
}
export const evaluateAsync = evaluate;
/**
* Capture a screenshot via CDP Page.captureScreenshot.
* Returns base64-encoded image data.
*/
export async function screenshot(
tabId: number,
options: { format?: 'png' | 'jpeg'; quality?: number; fullPage?: boolean } = {},
): Promise<string> {
await ensureAttached(tabId);
const format = options.format ?? 'png';
// For full-page screenshots, get the full page dimensions first
if (options.fullPage) {
// Get full page metrics
const metrics = await chrome.debugger.sendCommand({ tabId }, 'Page.getLayoutMetrics') as {
contentSize?: { width: number; height: number };
cssContentSize?: { width: number; height: number };
};
const size = metrics.cssContentSize || metrics.contentSize;
if (size) {
// Set device metrics to full page 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: Record<string, unknown> = { format };
if (format === 'jpeg' && options.quality !== undefined) {
params.quality = Math.max(0, Math.min(100, options.quality));
}
const result = await chrome.debugger.sendCommand({ tabId }, 'Page.captureScreenshot', params) as {
data: string; // base64-encoded
};
return result.data;
} finally {
// Reset device metrics if we changed them for full-page
if (options.fullPage) {
await chrome.debugger.sendCommand({ tabId }, 'Emulation.clearDeviceMetricsOverride').catch(() => {});
}
}
}
export function detach(tabId: number): void {
if (!attached.has(tabId)) return;
attached.delete(tabId);
try { chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
}
export function registerListeners(): void {
chrome.tabs.onRemoved.addListener((tabId) => {
attached.delete(tabId);
});
chrome.debugger.onDetach.addListener((source) => {
if (source.tabId) attached.delete(source.tabId);
});
}
+57
View File
@@ -0,0 +1,57 @@
/**
* opencli browser protocol — shared types between daemon, extension, and CLI.
*
* 5 actions: exec, navigate, tabs, cookies, screenshot.
* Everything else is just JS code sent via 'exec'.
*/
export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot';
export interface Command {
/** Unique request ID */
id: string;
/** Action type */
action: Action;
/** Target tab ID (omit for active tab) */
tabId?: number;
/** JS code to evaluate in page context (exec action) */
code?: string;
/** URL to navigate to (navigate action) */
url?: string;
/** Sub-operation for tabs: list, new, close, select */
op?: 'list' | 'new' | 'close' | 'select';
/** Tab index for tabs select/close */
index?: number;
/** Cookie domain filter */
domain?: string;
/** Screenshot format: png (default) or jpeg */
format?: 'png' | 'jpeg';
/** JPEG quality (0-100), only for jpeg format */
quality?: number;
/** Whether to capture full page (not just viewport) */
fullPage?: boolean;
}
export interface Result {
/** Matching request ID */
id: string;
/** Whether the command succeeded */
ok: boolean;
/** Result data on success */
data?: unknown;
/** Error message on failure */
error?: string;
}
/** Default daemon port */
export const DAEMON_PORT = 19825;
export const DAEMON_HOST = 'localhost';
export const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`;
export const DAEMON_HTTP_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
/** Base reconnect delay for extension WebSocket (ms) */
export const WS_RECONNECT_BASE_DELAY = 2000;
/** Max reconnect delay (ms) */
export const WS_RECONNECT_MAX_DELAY = 60000;
/** Idle timeout before daemon auto-exits (ms) */
export const DAEMON_IDLE_TIMEOUT = 5 * 60 * 1000;
Binary file not shown.

After

Width:  |  Height:  |  Size: 565 KiB

+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"declaration": false,
"types": ["chrome"]
},
"include": ["src"]
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
export default defineConfig({
build: {
outDir: 'dist',
emptyOutDir: true,
rollupOptions: {
input: resolve(__dirname, 'src/background.ts'),
output: {
entryFileNames: 'background.js',
format: 'es',
},
},
target: 'esnext',
minify: false,
},
});
+44 -76
View File
@@ -1,32 +1,34 @@
{
"name": "@jackwener/opencli",
"version": "0.5.1",
"version": "0.9.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@jackwener/opencli",
"version": "0.5.1",
"license": "BSD-3-Clause",
"version": "0.9.8",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"chalk": "^5.3.0",
"cli-table3": "^0.6.5",
"commander": "^13.1.0",
"js-yaml": "^4.1.0"
"commander": "^14.0.3",
"js-yaml": "^4.1.0",
"ws": "^8.18.0"
},
"bin": {
"opencli": "dist/main.js"
},
"devDependencies": {
"@playwright/mcp": "^0.0.68",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.13.10",
"@types/ws": "^8.5.13",
"tsx": "^4.19.3",
"typescript": "^5.8.2",
"vitest": "^4.1.0"
},
"engines": {
"node": ">=18.0.0"
"node": ">=20.0.0"
}
},
"node_modules/@colors/colors": {
@@ -559,23 +561,6 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@playwright/mcp": {
"version": "0.0.68",
"resolved": "https://registry.npmjs.org/@playwright/mcp/-/mcp-0.0.68.tgz",
"integrity": "sha512-oP9I9ghXKuQEBo4xaC7HgsS2gRTxyMzlBm3UEhYj4VqqrqbPQUX2shATPaNA/am9joBzq9v0OXISzeIgP+zmHA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.59.0-alpha-1771104257000",
"playwright-core": "1.59.0-alpha-1771104257000"
},
"bin": {
"playwright-mcp": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz",
@@ -894,11 +879,20 @@
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@vitest/expect": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz",
@@ -1075,12 +1069,12 @@
}
},
"node_modules/commander": {
"version": "13.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
"integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
"license": "MIT",
"engines": {
"node": ">=18"
"node": ">=20"
}
},
"node_modules/convert-source-map": {
@@ -1571,53 +1565,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.59.0-alpha-1771104257000",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.0-alpha-1771104257000.tgz",
"integrity": "sha512-6SCMMMJaDRsSqiKVLmb2nhtLES7iTYawTWWrQK6UdIGNzXi8lka4sLKRec3L4DnTWwddAvCuRn8035dhNiHzbg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.0-alpha-1771104257000"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.0-alpha-1771104257000",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.0-alpha-1771104257000.tgz",
"integrity": "sha512-YiXup3pnpQUCBMSIW5zx8CErwRx4K6O5Kojkw2BzJui8MazoMUDU6E3xGsb1kzFviEAE09LFQ+y1a0RhIJQ5SA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
@@ -2020,6 +1967,27 @@
"engines": {
"node": ">=8"
}
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+13 -11
View File
@@ -1,12 +1,12 @@
{
"name": "@jackwener/opencli",
"version": "0.5.1",
"version": "0.9.8",
"publishConfig": {
"access": "public"
},
"description": "Make any website your CLI. AI-powered.",
"engines": {
"node": ">=18.0.0"
"node": ">=20.0.0"
},
"type": "module",
"main": "dist/main.js",
@@ -16,25 +16,26 @@
"scripts": {
"dev": "tsx src/main.ts",
"build": "tsc && npm run clean-yaml && npm run copy-yaml && npm run build-manifest",
"build-manifest": "node dist/build-manifest.js || true",
"clean-yaml": "find dist/clis -name '*.yaml' -o -name '*.yml' 2>/dev/null | xargs rm -f",
"copy-yaml": "find src/clis -name '*.yaml' -o -name '*.yml' | while read f; do d=\"dist/${f#src/}\"; mkdir -p \"$(dirname \"$d\")\"; cp \"$f\" \"$d\"; done",
"build-manifest": "node dist/build-manifest.js",
"clean-yaml": "node scripts/clean-yaml.cjs",
"copy-yaml": "node scripts/copy-yaml.cjs",
"start": "node dist/main.js",
"postinstall": "node scripts/postinstall.js || true",
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit",
"prepublishOnly": "npm run build",
"test": "vitest run",
"test:site": "node scripts/test-site.mjs",
"test:watch": "vitest"
},
"keywords": [
"cli",
"browser",
"web",
"ai",
"playwright"
"ai"
],
"author": "jackwener",
"license": "BSD-3-Clause",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "git+https://github.com/jackwener/opencli.git"
@@ -42,12 +43,13 @@
"dependencies": {
"chalk": "^5.3.0",
"cli-table3": "^0.6.5",
"commander": "^13.1.0",
"js-yaml": "^4.1.0"
"commander": "^14.0.3",
"js-yaml": "^4.1.0",
"ws": "^8.18.0"
},
"devDependencies": {
"@playwright/mcp": "^0.0.68",
"@types/js-yaml": "^4.0.9",
"@types/ws": "^8.5.13",
"@types/node": "^22.13.10",
"tsx": "^4.19.3",
"typescript": "^5.8.2",
+19
View File
@@ -0,0 +1,19 @@
/**
* Clean YAML files from dist/clis/ before copying fresh ones.
*/
const { readdirSync, rmSync, existsSync, statSync } = require('fs');
const path = require('path');
function walk(dir) {
if (!existsSync(dir)) return;
for (const f of readdirSync(dir)) {
const fp = path.join(dir, f);
if (statSync(fp).isDirectory()) {
walk(fp);
} else if (/\.ya?ml$/.test(f)) {
rmSync(fp);
}
}
}
walk('dist/clis');
+21
View File
@@ -0,0 +1,21 @@
/**
* Copy YAML files from src/clis/ to dist/clis/.
*/
const { readdirSync, copyFileSync, mkdirSync, existsSync, statSync } = require('fs');
const path = require('path');
function walk(src, dst) {
if (!existsSync(src)) return;
for (const f of readdirSync(src)) {
const sp = path.join(src, f);
const dp = path.join(dst, f);
if (statSync(sp).isDirectory()) {
walk(sp, dp);
} else if (/\.ya?ml$/.test(f)) {
mkdirSync(path.dirname(dp), { recursive: true });
copyFileSync(sp, dp);
}
}
}
walk('src/clis', 'dist/clis');
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env node
/**
* postinstall script — automatically install shell completion files.
*
* Detects the user's default shell and writes the completion script to the
* standard system completion directory so that tab-completion works immediately
* after `npm install -g`.
*
* Supported shells: bash, zsh, fish.
*
* This script is intentionally plain Node.js (no TypeScript, no imports from
* the main source tree) so that it can run without a build step.
*/
import { mkdirSync, writeFileSync, existsSync, readFileSync, appendFileSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
// ── Completion script content ──────────────────────────────────────────────
const BASH_COMPLETION = `# Bash completion for opencli (auto-installed)
_opencli_completions() {
local cur words cword
_get_comp_words_by_ref -n : cur words cword
local completions
completions=$(opencli --get-completions --cursor "$cword" "\${words[@]:1}" 2>/dev/null)
COMPREPLY=( $(compgen -W "$completions" -- "$cur") )
__ltrim_colon_completions "$cur"
}
complete -F _opencli_completions opencli
`;
const ZSH_COMPLETION = `#compdef opencli
# Zsh completion for opencli (auto-installed)
_opencli() {
local -a completions
local cword=$((CURRENT - 1))
completions=(\${(f)"$(opencli --get-completions --cursor "$cword" "\${words[@]:1}" 2>/dev/null)"})
compadd -a completions
}
_opencli
`;
const FISH_COMPLETION = `# Fish completion for opencli (auto-installed)
complete -c opencli -f -a '(
set -l tokens (commandline -cop)
set -l cursor (count (commandline -cop))
opencli --get-completions --cursor $cursor $tokens[2..] 2>/dev/null
)'
`;
// ── Helpers ────────────────────────────────────────────────────────────────
function detectShell() {
const shell = process.env.SHELL || '';
if (shell.includes('zsh')) return 'zsh';
if (shell.includes('bash')) return 'bash';
if (shell.includes('fish')) return 'fish';
return null;
}
function ensureDir(dir) {
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
}
/**
* Ensure fpath contains the custom completions directory in .zshrc.
*
* Key detail: the fpath line MUST appear BEFORE the first `compinit` call,
* otherwise compinit won't scan our completions directory. This is critical
* for oh-my-zsh users (source $ZSH/oh-my-zsh.sh calls compinit internally).
*/
function ensureZshFpath(completionsDir, zshrcPath) {
const fpathLine = `fpath=(${completionsDir} $fpath)`;
const autoloadLine = `autoload -Uz compinit && compinit`;
const marker = '# opencli completion';
if (!existsSync(zshrcPath)) {
writeFileSync(zshrcPath, `${marker}\n${fpathLine}\n${autoloadLine}\n`, 'utf8');
return;
}
const content = readFileSync(zshrcPath, 'utf8');
// Already configured — nothing to do
if (content.includes(completionsDir)) {
return;
}
// Find the first line that triggers compinit (direct call or oh-my-zsh source)
const lines = content.split('\n');
let insertIdx = -1;
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
// Skip comment-only lines
if (trimmed.startsWith('#')) continue;
if (/compinit/.test(trimmed) || /source\s+.*oh-my-zsh\.sh/.test(trimmed)) {
insertIdx = i;
break;
}
}
if (insertIdx !== -1) {
// Insert fpath BEFORE the compinit / oh-my-zsh source line
lines.splice(insertIdx, 0, marker, fpathLine);
writeFileSync(zshrcPath, lines.join('\n'), 'utf8');
} else {
// No compinit found — append fpath + compinit at the end
let addition = `\n${marker}\n${fpathLine}\n${autoloadLine}\n`;
appendFileSync(zshrcPath, addition, 'utf8');
}
}
// ── Main ───────────────────────────────────────────────────────────────────
function main() {
// Skip in CI environments
if (process.env.CI || process.env.CONTINUOUS_INTEGRATION) {
return;
}
// Only install completion for global installs and npm link
const isGlobal = process.env.npm_config_global === 'true';
if (!isGlobal) {
return;
}
const shell = detectShell();
if (!shell) {
// Cannot determine shell; silently skip
return;
}
const home = homedir();
try {
switch (shell) {
case 'zsh': {
const completionsDir = join(home, '.zsh', 'completions');
const completionFile = join(completionsDir, '_opencli');
ensureDir(completionsDir);
writeFileSync(completionFile, ZSH_COMPLETION, 'utf8');
// Ensure fpath is set up in .zshrc
const zshrcPath = join(home, '.zshrc');
ensureZshFpath(completionsDir, zshrcPath);
console.log(`✓ Zsh completion installed to ${completionFile}`);
console.log(` Restart your shell or run: source ~/.zshrc`);
break;
}
case 'bash': {
// Try system-level first, fall back to user-level
const userCompDir = join(home, '.bash_completion.d');
const completionFile = join(userCompDir, 'opencli');
ensureDir(userCompDir);
writeFileSync(completionFile, BASH_COMPLETION, 'utf8');
// Ensure .bashrc sources the completion directory
const bashrcPath = join(home, '.bashrc');
if (existsSync(bashrcPath)) {
const content = readFileSync(bashrcPath, 'utf8');
if (!content.includes('.bash_completion.d/opencli')) {
appendFileSync(bashrcPath,
`\n# opencli completion\n[ -f "${completionFile}" ] && source "${completionFile}"\n`,
'utf8'
);
}
}
console.log(`✓ Bash completion installed to ${completionFile}`);
console.log(` Restart your shell or run: source ~/.bashrc`);
break;
}
case 'fish': {
const completionsDir = join(home, '.config', 'fish', 'completions');
const completionFile = join(completionsDir, 'opencli.fish');
ensureDir(completionsDir);
writeFileSync(completionFile, FISH_COMPLETION, 'utf8');
console.log(`✓ Fish completion installed to ${completionFile}`);
console.log(` Restart your shell to activate.`);
break;
}
}
} catch (err) {
// Completion install is best-effort; never fail the package install
if (process.env.OPENCLI_VERBOSE) {
console.error(`Warning: Could not install shell completion: ${err.message}`);
}
}
}
main();
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
const site = process.argv[2]?.trim();
if (!site) {
console.error('Usage: npm run test:site -- <site>');
process.exit(1);
}
const repoRoot = path.resolve(new URL('..', import.meta.url).pathname);
const srcDir = path.join(repoRoot, 'src');
function runStep(label, command, args) {
console.log(`\n==> ${label}`);
const result = spawnSync(command, args, {
cwd: repoRoot,
stdio: 'inherit',
env: process.env,
});
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
function walk(dir) {
const files = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...walk(fullPath));
} else {
files.push(fullPath);
}
}
return files;
}
function toPosix(filePath) {
return filePath.split(path.sep).join('/');
}
function findSiteTests() {
return walk(srcDir)
.filter(filePath => filePath.endsWith('.test.ts'))
.filter(filePath => {
const normalized = toPosix(path.relative(repoRoot, filePath));
return normalized.includes(`/clis/${site}/`) || normalized.includes(`/${site}.test.ts`);
})
.sort();
}
runStep('Typecheck', 'npm', ['run', 'typecheck']);
runStep('Targeted verify', 'npx', ['tsx', 'src/main.ts', 'verify', site]);
const testFiles = findSiteTests();
if (testFiles.length === 0) {
console.log(`\nNo site-specific vitest files found for "${site}". Skipping full vitest run.`);
process.exit(0);
}
runStep(
`Site tests (${site})`,
'npx',
['vitest', 'run', ...testFiles.map(filePath => path.relative(repoRoot, filePath))],
);
+3 -3
View File
@@ -56,7 +56,7 @@ export async function wbiSign(
const mixinKey = getMixinKey(imgKey, subKey);
const wts = Math.floor(Date.now() / 1000);
const sorted: Record<string, string> = {};
const allParams = { ...params, wts: String(wts) };
const allParams: Record<string, any> = { ...params, wts: String(wts) };
for (const key of Object.keys(allParams).sort()) {
sorted[key] = String(allParams[key]).replace(/[!'()*]/g, '');
}
@@ -84,10 +84,10 @@ export async function apiGet(
}
export async function fetchJson(page: IPage, url: string): Promise<any> {
const escapedUrl = url.replace(/"/g, '\\"');
const urlJs = JSON.stringify(url);
return page.evaluate(`
async () => {
const res = await fetch("${escapedUrl}", { credentials: "include" });
const res = await fetch(${urlJs}, { credentials: "include" });
return await res.json();
}
`);
+6 -17
View File
@@ -1,16 +1,7 @@
import { describe, it, expect } from 'vitest';
import { PlaywrightMCP, __test__ } from './browser.js';
import { afterEach, describe, it, expect, vi } from 'vitest';
import { PlaywrightMCP, __test__ } from './browser/index.js';
describe('browser helpers', () => {
it('creates JSON-RPC requests with unique ids', () => {
const first = __test__.createJsonRpcRequest('tools/call', { name: 'browser_tabs' });
const second = __test__.createJsonRpcRequest('tools/call', { name: 'browser_snapshot' });
expect(second.id).toBe(first.id + 1);
expect(first.message).toContain(`"id":${first.id}`);
expect(second.message).toContain(`"id":${second.id}`);
});
it('extracts tab entries from string snapshots', () => {
const entries = __test__.extractTabEntries('Tab 0 https://example.com\nTab 1 Chrome Extension');
@@ -50,7 +41,7 @@ describe('browser helpers', () => {
});
it('times out slow promises', async () => {
await expect(__test__.withTimeout(new Promise(() => {}), 10, 'timeout')).rejects.toThrow('timeout');
await expect(__test__.withTimeoutMs(new Promise(() => {}), 10, 'timeout')).rejects.toThrow('timeout');
});
});
@@ -69,22 +60,20 @@ describe('PlaywrightMCP state', () => {
const mcp = new PlaywrightMCP();
await mcp.close();
await expect(mcp.connect()).rejects.toThrow('Playwright MCP session is closed');
await expect(mcp.connect()).rejects.toThrow('Session is closed');
});
it('rejects connect() while already connecting', async () => {
const mcp = new PlaywrightMCP();
(mcp as any)._state = 'connecting';
await expect(mcp.connect()).rejects.toThrow('Playwright MCP is already connecting');
await expect(mcp.connect()).rejects.toThrow('Already connecting');
});
it('rejects connect() while closing', async () => {
const mcp = new PlaywrightMCP();
(mcp as any)._state = 'closing';
await expect(mcp.connect()).rejects.toThrow('Playwright MCP is closing');
await expect(mcp.connect()).rejects.toThrow('Session is closing');
});
});
-754
View File
@@ -1,754 +0,0 @@
/**
* Browser interaction via Playwright MCP Bridge extension.
* Connects to an existing Chrome browser through the extension.
*/
import { spawn, execSync, type ChildProcess } from 'node:child_process';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { formatSnapshot } from './snapshotFormatter.js';
// Read version from package.json (single source of truth)
const __browser_dirname = path.dirname(fileURLToPath(import.meta.url));
const PKG_VERSION = (() => { try { return JSON.parse(fs.readFileSync(path.resolve(__browser_dirname, '..', 'package.json'), 'utf-8')).version; } catch { return '0.0.0'; } })();
const CONNECT_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_CONNECT_TIMEOUT ?? '30', 10);
const STDERR_BUFFER_LIMIT = 16 * 1024;
const INITIAL_TABS_TIMEOUT_MS = 1500;
const TAB_CLEANUP_TIMEOUT_MS = 2000;
let _cachedMcpServerPath: string | null | undefined;
type ConnectFailureKind = 'missing-token' | 'extension-timeout' | 'extension-not-installed' | 'mcp-init' | 'process-exit' | 'unknown';
type PlaywrightMCPState = 'idle' | 'connecting' | 'connected' | 'closing' | 'closed';
type ConnectFailureInput = {
kind: ConnectFailureKind;
timeout: number;
hasExtensionToken: boolean;
tokenFingerprint?: string | null;
stderr?: string;
exitCode?: number | null;
rawMessage?: string;
};
export function getTokenFingerprint(token: string | undefined): string | null {
if (!token) return null;
return createHash('sha256').update(token).digest('hex').slice(0, 8);
}
export function formatBrowserConnectError(input: ConnectFailureInput): Error {
const stderr = input.stderr?.trim();
const suffix = stderr ? `\n\nMCP stderr:\n${stderr}` : '';
const tokenHint = input.tokenFingerprint ? ` Token fingerprint: ${input.tokenFingerprint}.` : '';
if (input.kind === 'missing-token') {
return new Error(
'Failed to connect to Playwright MCP Bridge: PLAYWRIGHT_MCP_EXTENSION_TOKEN is not set.\n\n' +
'Without this token, Chrome will show a manual approval dialog for every new MCP connection. ' +
'Copy the token from the Playwright MCP Bridge extension and set it in BOTH your shell environment and MCP client config.' +
suffix,
);
}
if (input.kind === 'extension-not-installed') {
return new Error(
'Failed to connect to Playwright MCP Bridge: the browser extension did not attach.\n\n' +
'Make sure Chrome is running and the "Playwright MCP Bridge" extension is installed and enabled. ' +
'If Chrome shows an approval dialog, click Allow.' +
suffix,
);
}
if (input.kind === 'extension-timeout') {
const likelyCause = input.hasExtensionToken
? `The most likely cause is that PLAYWRIGHT_MCP_EXTENSION_TOKEN does not match the token currently shown by the browser extension.${tokenHint} Re-copy the token from the extension and update BOTH your shell environment and MCP client config.`
: 'PLAYWRIGHT_MCP_EXTENSION_TOKEN is not configured, so the extension may be waiting for manual approval.';
return new Error(
`Timed out connecting to Playwright MCP Bridge (${input.timeout}s).\n\n` +
`${likelyCause} If a browser prompt is visible, click Allow.` +
suffix,
);
}
if (input.kind === 'mcp-init') {
return new Error(`Failed to initialize Playwright MCP: ${input.rawMessage ?? 'unknown error'}${suffix}`);
}
if (input.kind === 'process-exit') {
return new Error(
`Playwright MCP process exited before the browser connection was established${input.exitCode == null ? '' : ` (code ${input.exitCode})`}.` +
suffix,
);
}
return new Error(input.rawMessage ?? 'Failed to connect to browser');
}
function inferConnectFailureKind(args: {
hasExtensionToken: boolean;
stderr: string;
rawMessage?: string;
exited?: boolean;
}): ConnectFailureKind {
const haystack = `${args.rawMessage ?? ''}\n${args.stderr}`.toLowerCase();
if (!args.hasExtensionToken)
return 'missing-token';
if (haystack.includes('extension connection timeout') || haystack.includes('playwright mcp bridge'))
return 'extension-not-installed';
if (args.rawMessage?.startsWith('MCP init failed:'))
return 'mcp-init';
if (args.exited)
return 'process-exit';
return 'extension-timeout';
}
// JSON-RPC helpers
let _nextId = 1;
function createJsonRpcRequest(method: string, params: Record<string, any> = {}): { id: number; message: string } {
const id = _nextId++;
return {
id,
message: JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n',
};
}
import type { IPage } from './types.js';
/**
* Page abstraction wrapping JSON-RPC calls to Playwright MCP.
*/
export class Page implements IPage {
constructor(private _request: (method: string, params?: Record<string, any>) => Promise<any>) {}
async call(method: string, params: Record<string, any> = {}): Promise<any> {
const resp = await this._request(method, params);
if (resp.error) throw new Error(`page.${method}: ${resp.error.message ?? JSON.stringify(resp.error)}`);
// Extract text content from MCP result
const result = resp.result;
if (result?.content) {
const textParts = result.content.filter((c: any) => c.type === 'text');
if (textParts.length === 1) {
let text = textParts[0].text;
// MCP browser_evaluate returns: "[JSON]\n### Ran Playwright code\n```js\n...\n```"
// Strip the "### Ran Playwright code" suffix to get clean JSON
const codeMarker = text.indexOf('### Ran Playwright code');
if (codeMarker !== -1) {
text = text.slice(0, codeMarker).trim();
}
// Also handle "### Result\n[JSON]" format (some MCP versions)
const resultMarker = text.indexOf('### Result\n');
if (resultMarker !== -1) {
text = text.slice(resultMarker + '### Result\n'.length).trim();
}
try { return JSON.parse(text); } catch { return text; }
}
}
return result;
}
// --- High-level methods ---
async goto(url: string): Promise<void> {
await this.call('tools/call', { name: 'browser_navigate', arguments: { url } });
}
async evaluate(js: string): Promise<any> {
// Normalize IIFE format to function format expected by MCP browser_evaluate
const normalized = this.normalizeEval(js);
return this.call('tools/call', { name: 'browser_evaluate', arguments: { function: normalized } });
}
private normalizeEval(source: string): string {
const s = source.trim();
if (!s) return '() => undefined';
// IIFE: (async () => {...})() → wrap as () => (...)
if (s.startsWith('(') && s.endsWith(')()')) return `() => (${s})`;
// Already a function/arrow
if (/^(async\s+)?\([^)]*\)\s*=>/.test(s)) return s;
if (/^(async\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=>/.test(s)) return s;
if (s.startsWith('function ') || s.startsWith('async function ')) return s;
// Raw expression → wrap
return `() => (${s})`;
}
async snapshot(opts: { interactive?: boolean; compact?: boolean; maxDepth?: number; raw?: boolean } = {}): Promise<any> {
const raw = await this.call('tools/call', { name: 'browser_snapshot', arguments: {} });
if (opts.raw) return raw;
if (typeof raw === 'string') return formatSnapshot(raw, opts);
return raw;
}
async click(ref: string): Promise<void> {
await this.call('tools/call', { name: 'browser_click', arguments: { element: 'click target', ref } });
}
async typeText(ref: string, text: string): Promise<void> {
await this.call('tools/call', { name: 'browser_type', arguments: { element: 'type target', ref, text } });
}
async pressKey(key: string): Promise<void> {
await this.call('tools/call', { name: 'browser_press_key', arguments: { key } });
}
async wait(options: number | { text?: string; time?: number; timeout?: number }): Promise<void> {
if (typeof options === 'number') {
await this.call('tools/call', { name: 'browser_wait_for', arguments: { time: options } });
} else {
// Pass directly to native wait_for, which supports natively awaiting text strings without heavy DOM polling
await this.call('tools/call', { name: 'browser_wait_for', arguments: options });
}
}
async tabs(): Promise<any> {
return this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'list' } });
}
async closeTab(index?: number): Promise<void> {
await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'close', ...(index !== undefined ? { index } : {}) } });
}
async newTab(): Promise<void> {
await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'new' } });
}
async selectTab(index: number): Promise<void> {
await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'select', index } });
}
async networkRequests(includeStatic: boolean = false): Promise<any> {
return this.call('tools/call', { name: 'browser_network_requests', arguments: { includeStatic } });
}
async consoleMessages(level: string = 'info'): Promise<any> {
return this.call('tools/call', { name: 'browser_console_messages', arguments: { level } });
}
async scroll(direction: string = 'down', amount: number = 500): Promise<void> {
await this.call('tools/call', { name: 'browser_press_key', arguments: { key: direction === 'down' ? 'PageDown' : 'PageUp' } });
}
async autoScroll(options: { times?: number; delayMs?: number } = {}): Promise<void> {
const times = options.times ?? 3;
const delayMs = options.delayMs ?? 2000;
const js = `
async () => {
const maxTimes = ${times};
const maxWaitMs = ${delayMs};
for (let i = 0; i < maxTimes; i++) {
const lastHeight = document.body.scrollHeight;
window.scrollTo(0, lastHeight);
await new Promise(resolve => {
let timeoutId;
const observer = new MutationObserver(() => {
if (document.body.scrollHeight > lastHeight) {
clearTimeout(timeoutId);
observer.disconnect();
setTimeout(resolve, 100); // Small debounce for rendering
}
});
observer.observe(document.body, { childList: true, subtree: true });
timeoutId = setTimeout(() => {
observer.disconnect();
resolve(null);
}, maxWaitMs);
});
}
}
`;
await this.evaluate(js);
}
async installInterceptor(pattern: string): Promise<void> {
const js = `
() => {
window.__opencli_xhr = window.__opencli_xhr || [];
window.__opencli_patterns = window.__opencli_patterns || [];
if (!window.__opencli_patterns.includes('${pattern}')) {
window.__opencli_patterns.push('${pattern}');
}
if (!window.__patched_xhr) {
const checkMatch = (url) => window.__opencli_patterns.some(p => url.includes(p));
const XHR = XMLHttpRequest.prototype;
const open = XHR.open;
const send = XHR.send;
XHR.open = function(method, url) {
this._url = url;
return open.call(this, method, url, ...Array.prototype.slice.call(arguments, 2));
};
XHR.send = function() {
this.addEventListener('load', function() {
if (checkMatch(this._url)) {
try { window.__opencli_xhr.push({url: this._url, data: JSON.parse(this.responseText)}); } catch(e){}
}
});
return send.apply(this, arguments);
};
const origFetch = window.fetch;
window.fetch = async function(...args) {
let u = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || '';
const res = await origFetch.apply(this, args);
setTimeout(async () => {
try {
if (checkMatch(u)) {
const clone = res.clone();
const j = await clone.json();
window.__opencli_xhr.push({url: u, data: j});
}
} catch(e) {}
}, 0);
return res;
};
window.__patched_xhr = true;
}
}
`;
await this.evaluate(js);
}
async getInterceptedRequests(): Promise<any[]> {
return (await this.evaluate('() => window.__opencli_xhr')) || [];
}
}
/**
* Playwright MCP process manager.
*/
export class PlaywrightMCP {
private static _activeInsts: Set<PlaywrightMCP> = new Set();
private static _cleanupRegistered = false;
private static _registerGlobalCleanup() {
if (this._cleanupRegistered) return;
this._cleanupRegistered = true;
const cleanup = () => {
for (const inst of this._activeInsts) {
if (inst._proc && !inst._proc.killed) {
try { inst._proc.kill('SIGKILL'); } catch {}
}
}
};
process.on('exit', cleanup);
process.on('SIGINT', () => { cleanup(); process.exit(130); });
process.on('SIGTERM', () => { cleanup(); process.exit(143); });
}
private _proc: ChildProcess | null = null;
private _buffer = '';
private _pending = new Map<number, { resolve: (data: any) => void; reject: (error: Error) => void }>();
private _initialTabIdentities: string[] = [];
private _closingPromise: Promise<void> | null = null;
private _state: PlaywrightMCPState = 'idle';
private _page: Page | null = null;
get state(): PlaywrightMCPState {
return this._state;
}
private _sendRequest(method: string, params: Record<string, any> = {}): Promise<any> {
return new Promise<any>((resolve, reject) => {
if (!this._proc?.stdin?.writable) {
reject(new Error('Playwright MCP process is not writable'));
return;
}
const { id, message } = createJsonRpcRequest(method, params);
this._pending.set(id, { resolve, reject });
this._proc.stdin.write(message, (err) => {
if (!err) return;
this._pending.delete(id);
reject(err);
});
});
}
private _rejectPendingRequests(error: Error): void {
const pending = [...this._pending.values()];
this._pending.clear();
for (const waiter of pending) waiter.reject(error);
}
private _resetAfterFailedConnect(): void {
const proc = this._proc;
this._page = null;
this._proc = null;
this._buffer = '';
this._initialTabIdentities = [];
this._rejectPendingRequests(new Error('Playwright MCP connect failed'));
PlaywrightMCP._activeInsts.delete(this);
if (proc && !proc.killed) {
try { proc.kill('SIGKILL'); } catch {}
}
}
async connect(opts: { timeout?: number } = {}): Promise<Page> {
if (this._state === 'connected' && this._page) return this._page;
if (this._state === 'connecting') throw new Error('Playwright MCP is already connecting');
if (this._state === 'closing') throw new Error('Playwright MCP is closing');
if (this._state === 'closed') throw new Error('Playwright MCP session is closed');
const mcpPath = findMcpServerPath();
if (!mcpPath) throw new Error('Playwright MCP server not found. Install: npm install -D @playwright/mcp');
PlaywrightMCP._registerGlobalCleanup();
PlaywrightMCP._activeInsts.add(this);
this._state = 'connecting';
const timeout = opts.timeout ?? CONNECT_TIMEOUT;
return new Promise<Page>((resolve, reject) => {
const isDebug = process.env.DEBUG?.includes('opencli:mcp');
const debugLog = (msg: string) => isDebug && console.error(`[opencli:mcp] ${msg}`);
const extensionToken = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
const tokenFingerprint = getTokenFingerprint(extensionToken);
let stderrBuffer = '';
let settled = false;
const settleError = (kind: ConnectFailureKind, extra: { rawMessage?: string; exitCode?: number | null } = {}) => {
if (settled) return;
settled = true;
this._state = 'idle';
clearTimeout(timer);
this._resetAfterFailedConnect();
reject(formatBrowserConnectError({
kind,
timeout,
hasExtensionToken: !!extensionToken,
tokenFingerprint,
stderr: stderrBuffer,
exitCode: extra.exitCode,
rawMessage: extra.rawMessage,
}));
};
const settleSuccess = (pageToResolve: Page) => {
if (settled) return;
settled = true;
this._state = 'connected';
clearTimeout(timer);
resolve(pageToResolve);
};
const timer = setTimeout(() => {
debugLog('Connection timed out');
settleError(inferConnectFailureKind({
hasExtensionToken: !!extensionToken,
stderr: stderrBuffer,
}));
}, timeout * 1000);
const mcpArgs: string[] = [mcpPath, '--extension'];
if (process.env.OPENCLI_VERBOSE) {
console.error(`[opencli] Extension token: ${extensionToken ? `configured (fingerprint ${tokenFingerprint})` : 'missing'}`);
}
if (process.env.OPENCLI_BROWSER_EXECUTABLE_PATH) {
mcpArgs.push('--executablePath', process.env.OPENCLI_BROWSER_EXECUTABLE_PATH);
}
debugLog(`Spawning node ${mcpArgs.join(' ')}`);
this._proc = spawn('node', mcpArgs, {
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env },
});
// Increase max listeners to avoid warnings
this._proc.setMaxListeners(20);
if (this._proc.stdout) this._proc.stdout.setMaxListeners(20);
const page = new Page((method, params = {}) => this._sendRequest(method, params));
this._page = page;
this._proc.stdout?.on('data', (chunk: Buffer) => {
this._buffer += chunk.toString();
const lines = this._buffer.split('\n');
this._buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.trim()) continue;
debugLog(`RECV: ${line}`);
try {
const parsed = JSON.parse(line);
if (typeof parsed?.id === 'number') {
const waiter = this._pending.get(parsed.id);
if (waiter) {
this._pending.delete(parsed.id);
waiter.resolve(parsed);
}
}
} catch (e) {
debugLog(`Parse error: ${e}`);
}
}
});
this._proc.stderr?.on('data', (chunk: Buffer) => {
const text = chunk.toString();
stderrBuffer = appendLimited(stderrBuffer, text, STDERR_BUFFER_LIMIT);
debugLog(`STDERR: ${text}`);
});
this._proc.on('error', (err) => {
debugLog(`Subprocess error: ${err.message}`);
this._rejectPendingRequests(new Error(`Playwright MCP process error: ${err.message}`));
settleError('process-exit', { rawMessage: err.message });
});
this._proc.on('close', (code) => {
debugLog(`Subprocess closed with code ${code}`);
this._rejectPendingRequests(new Error(`Playwright MCP process exited before response${code == null ? '' : ` (code ${code})`}`));
if (!settled) {
settleError(inferConnectFailureKind({
hasExtensionToken: !!extensionToken,
stderr: stderrBuffer,
exited: true,
}), { exitCode: code });
}
});
// Initialize: send initialize request
debugLog('Waiting for initialize response...');
this._sendRequest('initialize', {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'opencli', version: PKG_VERSION },
}).then((resp) => {
debugLog('Got initialize response');
if (resp.error) {
settleError(inferConnectFailureKind({
hasExtensionToken: !!extensionToken,
stderr: stderrBuffer,
rawMessage: `MCP init failed: ${resp.error.message}`,
}), { rawMessage: resp.error.message });
return;
}
const initializedMsg = JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n';
debugLog(`SEND: ${initializedMsg.trim()}`);
this._proc?.stdin?.write(initializedMsg);
// Use tabs as a readiness probe and for tab cleanup bookkeeping.
debugLog('Fetching initial tabs count...');
withTimeout(page.tabs(), INITIAL_TABS_TIMEOUT_MS, 'Timed out fetching initial tabs').then((tabs: any) => {
debugLog(`Tabs response: ${typeof tabs === 'string' ? tabs : JSON.stringify(tabs)}`);
this._initialTabIdentities = extractTabIdentities(tabs);
settleSuccess(page);
}).catch((err) => {
debugLog(`Tabs fetch error: ${err.message}`);
settleSuccess(page);
});
}).catch((err) => {
debugLog(`Init promise rejected: ${err.message}`);
settleError('mcp-init', { rawMessage: err.message });
});
});
}
async close(): Promise<void> {
if (this._closingPromise) return this._closingPromise;
if (this._state === 'closed') return;
this._state = 'closing';
this._closingPromise = (async () => {
try {
// Extension mode opens bridge/session tabs that we can clean up best-effort.
if (this._page && this._proc && !this._proc.killed) {
try {
const tabs = await withTimeout(this._page.tabs(), TAB_CLEANUP_TIMEOUT_MS, 'Timed out fetching tabs during cleanup');
const tabEntries = extractTabEntries(tabs);
const tabsToClose = diffTabIndexes(this._initialTabIdentities, tabEntries);
for (const index of tabsToClose) {
try { await this._page.closeTab(index); } catch {}
}
} catch {}
}
if (this._proc && !this._proc.killed) {
this._proc.kill('SIGTERM');
const exited = await new Promise<boolean>((res) => {
let done = false;
const finish = (value: boolean) => {
if (done) return;
done = true;
res(value);
};
this._proc?.once('exit', () => finish(true));
setTimeout(() => finish(false), 3000);
});
if (!exited && this._proc && !this._proc.killed) {
try { this._proc.kill('SIGKILL'); } catch {}
}
}
} finally {
this._rejectPendingRequests(new Error('Playwright MCP session closed'));
this._page = null;
this._proc = null;
this._state = 'closed';
PlaywrightMCP._activeInsts.delete(this);
}
})();
return this._closingPromise;
}
}
function extractTabEntries(raw: any): Array<{ index: number; identity: string }> {
if (Array.isArray(raw)) {
return raw.map((tab: any, index: number) => ({
index,
identity: [
tab?.id ?? '',
tab?.url ?? '',
tab?.title ?? '',
tab?.name ?? '',
].join('|'),
}));
}
if (typeof raw === 'string') {
return raw
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.map(line => {
// Match actual Playwright MCP format: "- 0: (current) [title](url)" or "- 1: [title](url)"
const mcpMatch = line.match(/^-\s+(\d+):\s*(.*)$/);
if (mcpMatch) {
return {
index: parseInt(mcpMatch[1], 10),
identity: mcpMatch[2].trim() || `tab-${mcpMatch[1]}`,
};
}
// Legacy format: "Tab 0 ..."
const legacyMatch = line.match(/Tab\s+(\d+)\s*(.*)$/);
if (legacyMatch) {
return {
index: parseInt(legacyMatch[1], 10),
identity: legacyMatch[2].trim() || `tab-${legacyMatch[1]}`,
};
}
return null;
})
.filter((entry): entry is { index: number; identity: string } => entry !== null);
}
return [];
}
function extractTabIdentities(raw: any): string[] {
return extractTabEntries(raw).map(tab => tab.identity);
}
function diffTabIndexes(initialIdentities: string[], currentTabs: Array<{ index: number; identity: string }>): number[] {
if (initialIdentities.length === 0 || currentTabs.length === 0) return [];
const remaining = new Map<string, number>();
for (const identity of initialIdentities) {
remaining.set(identity, (remaining.get(identity) ?? 0) + 1);
}
const tabsToClose: number[] = [];
for (const tab of currentTabs) {
const count = remaining.get(tab.identity) ?? 0;
if (count > 0) {
remaining.set(tab.identity, count - 1);
continue;
}
tabsToClose.push(tab.index);
}
return tabsToClose.sort((a, b) => b - a);
}
function appendLimited(current: string, chunk: string, limit: number): string {
const next = current + chunk;
if (next.length <= limit) return next;
return next.slice(-limit);
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
promise.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error) => {
clearTimeout(timer);
reject(error);
},
);
});
}
export const __test__ = {
createJsonRpcRequest,
extractTabEntries,
diffTabIndexes,
appendLimited,
withTimeout,
};
function findMcpServerPath(): string | null {
if (_cachedMcpServerPath !== undefined) return _cachedMcpServerPath;
const envMcp = process.env.OPENCLI_MCP_SERVER_PATH;
if (envMcp && fs.existsSync(envMcp)) {
_cachedMcpServerPath = envMcp;
return _cachedMcpServerPath;
}
// Check local node_modules first (@playwright/mcp is the modern package)
const localMcp = path.resolve('node_modules', '@playwright', 'mcp', 'cli.js');
if (fs.existsSync(localMcp)) {
_cachedMcpServerPath = localMcp;
return _cachedMcpServerPath;
}
// Check project-relative path
const __dirname2 = path.dirname(fileURLToPath(import.meta.url));
const projectMcp = path.resolve(__dirname2, '..', 'node_modules', '@playwright', 'mcp', 'cli.js');
if (fs.existsSync(projectMcp)) {
_cachedMcpServerPath = projectMcp;
return _cachedMcpServerPath;
}
// Check common locations
const candidates = [
path.join(os.homedir(), '.npm', '_npx'),
path.join(os.homedir(), 'node_modules', '.bin'),
'/usr/local/lib/node_modules',
];
// Try npx resolution (legacy package name)
try {
const result = execSync('npx -y --package=@playwright/mcp which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 10000 }).trim();
if (result && fs.existsSync(result)) {
_cachedMcpServerPath = result;
return _cachedMcpServerPath;
}
} catch {}
// Try which
try {
const result = execSync('which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 5000 }).trim();
if (result && fs.existsSync(result)) {
_cachedMcpServerPath = result;
return _cachedMcpServerPath;
}
} catch {}
// Search in common npx cache
for (const base of candidates) {
if (!fs.existsSync(base)) continue;
try {
const found = execSync(`find "${base}" -name "cli.js" -path "*playwright*mcp*" 2>/dev/null | head -1`, { encoding: 'utf-8', timeout: 5000 }).trim();
if (found) {
_cachedMcpServerPath = found;
return _cachedMcpServerPath;
}
} catch {}
}
_cachedMcpServerPath = null;
return _cachedMcpServerPath;
}
+113
View File
@@ -0,0 +1,113 @@
/**
* HTTP client for communicating with the opencli daemon.
*
* Provides a typed send() function that posts a Command and returns a Result.
*/
const DAEMON_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? '19825', 10);
const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
let _idCounter = 0;
function generateId(): string {
return `cmd_${Date.now()}_${++_idCounter}`;
}
export interface DaemonCommand {
id: string;
action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot';
tabId?: number;
code?: string;
url?: string;
op?: string;
index?: number;
domain?: string;
format?: 'png' | 'jpeg';
quality?: number;
fullPage?: boolean;
}
export interface DaemonResult {
id: string;
ok: boolean;
data?: unknown;
error?: string;
}
/**
* Check if daemon is running.
*/
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 });
clearTimeout(timer);
return res.ok;
} catch {
return false;
}
}
/**
* Check if daemon is running AND the extension is connected.
*/
export async function isExtensionConnected(): Promise<boolean> {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2000);
const res = await fetch(`${DAEMON_URL}/status`, { signal: controller.signal });
clearTimeout(timer);
if (!res.ok) return false;
const data = await res.json() as { extensionConnected?: boolean };
return !!data.extensionConnected;
} catch {
return false;
}
}
/**
* Send a command to the daemon and wait for a result.
* Retries up to 3 times with 500ms delay for transient failures.
*/
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;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
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' },
body: JSON.stringify(command),
signal: controller.signal,
});
clearTimeout(timer);
const result = (await res.json()) as DaemonResult;
if (!result.ok) {
throw new Error(result.error ?? 'Daemon command failed');
}
return result.data;
} catch (err) {
const isRetryable = err instanceof TypeError // fetch network error
|| (err instanceof Error && err.name === 'AbortError');
if (isRetryable && attempt < maxRetries) {
await new Promise(r => setTimeout(r, 500));
continue;
}
throw err;
}
}
// Unreachable — the loop always returns or throws
throw new Error('sendCommand: max retries exhausted');
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Daemon discovery — simplified from MCP server path discovery.
*
* Only needs to check if the daemon is running. No more file system
* scanning for @playwright/mcp locations.
*/
import { isDaemonRunning } from './daemon-client.js';
export { isDaemonRunning };
/**
* Check daemon status and return connection info.
*/
export async function checkDaemonStatus(): Promise<{
running: boolean;
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 data = await res.json() as { ok: boolean; extensionConnected: boolean };
return { running: true, extensionConnected: data.extensionConnected };
} catch {
return { running: false, extensionConnected: false };
}
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Browser connection error helpers.
*
* Simplified — no more token/extension/CDP classification.
* The daemon architecture has a single failure mode: daemon not reachable or extension not connected.
*/
export type ConnectFailureKind = 'daemon-not-running' | 'extension-not-connected' | 'command-failed' | 'unknown';
export function formatBrowserConnectError(kind: ConnectFailureKind, detail?: string): Error {
switch (kind) {
case 'daemon-not-running':
return new Error(
'Cannot connect to opencli daemon.\n\n' +
'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}` : ''),
);
case 'extension-not-connected':
return new Error(
'opencli Browser Bridge extension is not connected.\n\n' +
'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}` : ''),
);
case 'command-failed':
return new Error(`Browser command failed: ${detail ?? 'unknown error'}`);
default:
return new Error(detail ?? 'Failed to connect to browser');
}
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Browser module — public API re-exports.
*
* This barrel replaces the former monolithic browser.ts.
* External code should import from './browser/index.js' (or './browser.js' via Node resolution).
*/
export { Page } from './page.js';
export { PlaywrightMCP } from './mcp.js';
export { isDaemonRunning } from './daemon-client.js';
// Backward compatibility: getTokenFingerprint is no longer needed but kept as no-op export
export function getTokenFingerprint(_token: string | undefined): string | null {
return null;
}
import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
import { withTimeoutMs } from '../runtime.js';
export const __test__ = {
extractTabEntries,
diffTabIndexes,
appendLimited,
withTimeoutMs,
};
+112
View File
@@ -0,0 +1,112 @@
/**
* Browser session manager — auto-spawns daemon and provides IPage.
*
* Replaces the old PlaywrightMCP class. Still exports as PlaywrightMCP
* for backward compatibility with main.ts and other consumers.
*/
import { spawn, type ChildProcess } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import * as path from 'node:path';
import * as fs from 'node:fs';
import type { IPage } from '../types.js';
import { Page } from './page.js';
import { isDaemonRunning, isExtensionConnected } from './daemon-client.js';
const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension
export type PlaywrightMCPState = 'idle' | 'connecting' | 'connected' | 'closing' | 'closed';
/**
* Browser factory: manages daemon lifecycle and provides IPage instances.
*
* Kept as `PlaywrightMCP` class name for backward compatibility.
*/
export class PlaywrightMCP {
private _state: PlaywrightMCPState = 'idle';
private _page: Page | null = null;
private _daemonProc: ChildProcess | null = null;
get state(): PlaywrightMCPState {
return this._state;
}
async connect(opts: { timeout?: number } = {}): Promise<IPage> {
if (this._state === 'connected' && this._page) return this._page;
if (this._state === 'connecting') throw new Error('Already connecting');
if (this._state === 'closing') throw new Error('Session is closing');
if (this._state === 'closed') throw new Error('Session is closed');
this._state = 'connecting';
try {
await this._ensureDaemon();
this._page = new Page();
this._state = 'connected';
return this._page;
} catch (err) {
this._state = 'idle';
throw err;
}
}
async close(): Promise<void> {
if (this._state === 'closed') return;
this._state = 'closing';
// We don't kill the daemon — it auto-exits on idle.
// Just clean up our reference.
this._page = null;
this._state = 'closed';
}
private async _ensureDaemon(): Promise<void> {
if (await isDaemonRunning()) return;
// Find daemon relative to this file — works for both:
// npx tsx src/main.ts → src/browser/mcp.ts → src/daemon.ts
// node dist/main.js → dist/browser/mcp.js → dist/daemon.js
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const parentDir = path.resolve(__dirname, '..');
const daemonTs = path.join(parentDir, 'daemon.ts');
const daemonJs = path.join(parentDir, 'daemon.js');
const isTs = fs.existsSync(daemonTs);
const daemonPath = isTs ? daemonTs : daemonJs;
if (process.env.OPENCLI_VERBOSE) {
console.error(`[opencli] Starting daemon (${isTs ? 'ts' : 'js'})...`);
}
// Use the current runtime to spawn daemon — avoids slow npx resolution.
// If already running under tsx (dev), process.execPath is tsx's node.
// If running compiled (node dist/), process.execPath is node.
this._daemonProc = spawn(process.execPath, [daemonPath], {
detached: true,
stdio: 'ignore',
env: { ...process.env },
});
this._daemonProc.unref();
// Wait for daemon to be ready AND extension to connect
const deadline = Date.now() + DAEMON_SPAWN_TIMEOUT;
while (Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 300));
if (await isExtensionConnected()) return;
}
// Daemon might be up but extension not connected — give a useful error
if (await isDaemonRunning()) {
throw new Error(
'Daemon is running but the Browser Extension is not connected.\n' +
'Please install and enable the opencli Browser Bridge extension in Chrome.',
);
}
throw new Error(
'Failed to start opencli daemon. Try running manually:\n' +
` node ${daemonPath}\n` +
'Make sure port 19825 is available.',
);
}
}
+305
View File
@@ -0,0 +1,305 @@
/**
* Page abstraction — implements IPage by sending commands to the daemon.
*
* All browser operations are ultimately 'exec' (JS evaluation via CDP)
* plus a few native Chrome Extension APIs (tabs, cookies, navigate).
*
* IMPORTANT: After goto(), we remember the tabId returned by the navigate
* action and pass it to all subsequent commands. This avoids the issue
* where resolveTabId() in the extension picks a chrome:// or
* chrome-extension:// tab that can't be debugged.
*/
import { formatSnapshot } from '../snapshotFormatter.js';
import type { IPage } from '../types.js';
import { sendCommand } from './daemon-client.js';
/**
* Page — implements IPage by talking to the daemon via HTTP.
*/
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 } : {};
}
async goto(url: string): Promise<void> {
const result = await sendCommand('navigate', {
url,
...this._tabOpt(),
}) as { tabId?: number };
// Remember the tabId for subsequent exec calls
if (result?.tabId) {
this._tabId = result.tabId;
}
}
async evaluate(js: string): Promise<any> {
const code = wrapForEval(js);
return sendCommand('exec', { code, ...this._tabOpt() });
}
async snapshot(opts: { interactive?: boolean; compact?: boolean; maxDepth?: number; raw?: boolean } = {}): Promise<any> {
const maxDepth = Math.max(1, Math.min(Number(opts.maxDepth) || 50, 200));
const code = `
(async () => {
function buildTree(node, depth) {
if (depth > ${maxDepth}) return '';
const role = node.getAttribute?.('role') || node.tagName?.toLowerCase() || 'generic';
const name = node.getAttribute?.('aria-label') || node.getAttribute?.('alt') || node.textContent?.trim().slice(0, 80) || '';
const isInteractive = ['a', 'button', 'input', 'select', 'textarea'].includes(node.tagName?.toLowerCase()) || node.getAttribute?.('tabindex') != null;
${opts.interactive ? 'if (!isInteractive && !node.children?.length) return "";' : ''}
let indent = ' '.repeat(depth);
let line = indent + role;
if (name) line += ' "' + name.replace(/"/g, '\\\\"') + '"';
if (node.tagName?.toLowerCase() === 'a' && node.href) line += ' [' + node.href + ']';
if (node.tagName?.toLowerCase() === 'input') line += ' [' + (node.type || 'text') + ']';
let result = line + '\\n';
if (node.children) {
for (const child of node.children) {
result += buildTree(child, depth + 1);
}
}
return result;
}
return buildTree(document.body, 0);
})()
`;
const raw = await sendCommand('exec', { code, ...this._tabOpt() });
if (opts.raw) return raw;
if (typeof raw === 'string') return formatSnapshot(raw, opts);
return raw;
}
async click(ref: string): Promise<void> {
const safeRef = JSON.stringify(ref);
const code = `
(() => {
const ref = ${safeRef};
const el = document.querySelector('[data-ref="' + ref + '"]')
|| document.querySelectorAll('a, button, input, [role="button"], [tabindex]')[parseInt(ref, 10) || 0];
if (!el) throw new Error('Element not found: ' + ref);
el.scrollIntoView({ behavior: 'instant', block: 'center' });
el.click();
return 'clicked';
})()
`;
await sendCommand('exec', { code, ...this._tabOpt() });
}
async typeText(ref: string, text: string): Promise<void> {
const safeRef = JSON.stringify(ref);
const safeText = JSON.stringify(text);
const code = `
(() => {
const ref = ${safeRef};
const el = document.querySelector('[data-ref="' + ref + '"]')
|| document.querySelectorAll('input, textarea, [contenteditable]')[parseInt(ref, 10) || 0];
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 }));
return 'typed';
})()
`;
await sendCommand('exec', { code, ...this._tabOpt() });
}
async pressKey(key: string): Promise<void> {
const code = `
(() => {
const el = document.activeElement || document.body;
el.dispatchEvent(new KeyboardEvent('keydown', { key: ${JSON.stringify(key)}, bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keyup', { key: ${JSON.stringify(key)}, bubbles: true }));
return 'pressed';
})()
`;
await sendCommand('exec', { code, ...this._tabOpt() });
}
async wait(options: number | { text?: string; time?: number; timeout?: number }): 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));
return;
}
if (options.text) {
const timeout = (options.timeout ?? 30) * 1000;
const code = `
new Promise((resolve, reject) => {
const deadline = Date.now() + ${timeout};
const check = () => {
if (document.body.innerText.includes(${JSON.stringify(options.text)})) return resolve('found');
if (Date.now() > deadline) return reject(new Error('Text not found: ' + ${JSON.stringify(options.text)}));
setTimeout(check, 200);
};
check();
})
`;
await sendCommand('exec', { code, ...this._tabOpt() });
}
}
async tabs(): Promise<any> {
return sendCommand('tabs', { op: 'list' });
}
async closeTab(index?: number): Promise<void> {
await sendCommand('tabs', { op: 'close', ...(index !== undefined ? { index } : {}) });
}
async newTab(): Promise<void> {
await sendCommand('tabs', { op: 'new' });
}
async selectTab(index: number): Promise<void> {
await sendCommand('tabs', { op: 'select', index });
}
async networkRequests(includeStatic: boolean = false): Promise<any> {
const code = `
(() => {
const entries = performance.getEntriesByType('resource');
return entries
${includeStatic ? '' : '.filter(e => !["img", "font", "css", "script"].some(t => e.initiatorType === t))'}
.map(e => ({
url: e.name,
type: e.initiatorType,
duration: Math.round(e.duration),
size: e.transferSize || 0,
}));
})()
`;
return sendCommand('exec', { code, ...this._tabOpt() });
}
async consoleMessages(level: string = 'info'): Promise<any> {
// Console messages can't be retrospectively read via CDP Runtime.evaluate.
// Would need Runtime.consoleAPICalled event listener, which is not yet implemented.
if (process.env.OPENCLI_VERBOSE) {
console.error('[page] consoleMessages() not supported in lightweight mode — returning empty');
}
return [];
}
/**
* Capture a screenshot via CDP Page.captureScreenshot.
* @param options.format - 'png' (default) or 'jpeg'
* @param options.quality - JPEG quality 0-100
* @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> {
const base64 = await sendCommand('screenshot', {
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);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(options.path, Buffer.from(base64, 'base64'));
}
return base64;
}
async scroll(direction: string = 'down', amount: number = 500): Promise<void> {
const dx = direction === 'left' ? -amount : direction === 'right' ? amount : 0;
const dy = direction === 'up' ? -amount : direction === 'down' ? amount : 0;
await sendCommand('exec', {
code: `window.scrollBy(${dx}, ${dy})`,
...this._tabOpt(),
});
}
async autoScroll(options: { times?: number; delayMs?: number } = {}): Promise<void> {
const times = options.times ?? 3;
const delayMs = options.delayMs ?? 2000;
const code = `
(async () => {
for (let i = 0; i < ${times}; i++) {
const lastHeight = document.body.scrollHeight;
window.scrollTo(0, lastHeight);
await new Promise(resolve => {
let timeoutId;
const observer = new MutationObserver(() => {
if (document.body.scrollHeight > lastHeight) {
clearTimeout(timeoutId);
observer.disconnect();
setTimeout(resolve, 100);
}
});
observer.observe(document.body, { childList: true, subtree: true });
timeoutId = setTimeout(() => { observer.disconnect(); resolve(null); }, ${delayMs});
});
}
})()
`;
await sendCommand('exec', { code, ...this._tabOpt() });
}
async installInterceptor(pattern: string): Promise<void> {
const { generateInterceptorJs } = await import('../interceptor.js');
await sendCommand('exec', {
code: generateInterceptorJs(JSON.stringify(pattern), {
arrayName: '__opencli_xhr',
patchGuard: '__opencli_interceptor_patched',
}),
...this._tabOpt(),
});
}
async getInterceptedRequests(): Promise<any[]> {
const { generateReadInterceptedJs } = await import('../interceptor.js');
const result = await sendCommand('exec', {
code: generateReadInterceptedJs('__opencli_xhr'),
...this._tabOpt(),
});
return (result as any[]) || [];
}
}
// ─── Helpers ─────────────────────────────────────────────────────────
/**
* Wrap JS code for CDP Runtime.evaluate:
* - Already an IIFE `(...)()` → send as-is
* - Arrow/function literal → wrap as IIFE `(code)()`
* - `new Promise(...)` or raw expression → send as-is (expression)
*/
function wrapForEval(js: string): string {
const code = js.trim();
if (!code) return 'undefined';
// Already an IIFE: `(async () => { ... })()` or `(function() {...})()`
if (/^\([\s\S]*\)\s*\(.*\)\s*$/.test(code)) return code;
// Arrow function: `() => ...` or `async () => ...`
if (/^(async\s+)?(\([^)]*\)|[A-Za-z_]\w*)\s*=>/.test(code)) return `(${code})()`;
// Function declaration: `function ...` or `async function ...`
if (/^(async\s+)?function[\s(]/.test(code)) return `(${code})()`;
// Everything else: bare expression, `new Promise(...)`, etc. → evaluate directly
return code;
}
+76
View File
@@ -0,0 +1,76 @@
/**
* Browser tab management helpers: extract, diff, and cleanup tab state.
*/
export function extractTabEntries(raw: unknown): Array<{ index: number; identity: string }> {
if (Array.isArray(raw)) {
return raw.map((tab: Record<string, unknown>, index: number) => ({
index,
identity: [
tab?.id ?? '',
tab?.url ?? '',
tab?.title ?? '',
tab?.name ?? '',
].join('|'),
}));
}
if (typeof raw === 'string') {
return raw
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.map(line => {
// Match actual Playwright MCP format: "- 0: (current) [title](url)" or "- 1: [title](url)"
const mcpMatch = line.match(/^-\s+(\d+):\s*(.*)$/);
if (mcpMatch) {
return {
index: parseInt(mcpMatch[1], 10),
identity: mcpMatch[2].trim() || `tab-${mcpMatch[1]}`,
};
}
// Legacy format: "Tab 0 ..."
const legacyMatch = line.match(/Tab\s+(\d+)\s*(.*)$/);
if (legacyMatch) {
return {
index: parseInt(legacyMatch[1], 10),
identity: legacyMatch[2].trim() || `tab-${legacyMatch[1]}`,
};
}
return null;
})
.filter((entry): entry is { index: number; identity: string } => entry !== null);
}
return [];
}
export function extractTabIdentities(raw: unknown): string[] {
return extractTabEntries(raw).map(tab => tab.identity);
}
export function diffTabIndexes(initialIdentities: string[], currentTabs: Array<{ index: number; identity: string }>): number[] {
if (initialIdentities.length === 0 || currentTabs.length === 0) return [];
const remaining = new Map<string, number>();
for (const identity of initialIdentities) {
remaining.set(identity, (remaining.get(identity) ?? 0) + 1);
}
const tabsToClose: number[] = [];
for (const tab of currentTabs) {
const count = remaining.get(tab.identity) ?? 0;
if (count > 0) {
remaining.set(tab.identity, count - 1);
continue;
}
tabsToClose.push(tab.index);
}
return tabsToClose.sort((a, b) => b - a);
}
export function appendLimited(current: string, chunk: string, limit: number): string {
const next = current + chunk;
if (next.length <= limit) return next;
return next.slice(-limit);
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { parseTsArgsBlock } from './build-manifest.js';
describe('parseTsArgsBlock', () => {
it('keeps args with nested choices arrays', () => {
const args = parseTsArgsBlock(`
{
name: 'period',
type: 'string',
default: 'seven',
help: 'Stats period: seven or thirty',
choices: ['seven', 'thirty'],
},
`);
expect(args).toEqual([
{
name: 'period',
type: 'string',
default: 'seven',
required: false,
positional: undefined,
help: 'Stats period: seven or thirty',
choices: ['seven', 'thirty'],
},
]);
});
});
+147 -54
View File
@@ -11,7 +11,7 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';
import yaml from 'js-yaml';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -30,6 +30,7 @@ interface ManifestEntry {
type?: string;
default?: any;
required?: boolean;
positional?: boolean;
help?: string;
choices?: string[];
}>;
@@ -42,6 +43,116 @@ interface ManifestEntry {
modulePath?: string;
}
function extractBalancedBlock(
source: string,
startIndex: number,
openChar: string,
closeChar: string,
): string | null {
let depth = 0;
let quote: string | null = null;
let escaped = false;
for (let i = startIndex; i < source.length; i++) {
const ch = source[i];
if (quote) {
if (escaped) {
escaped = false;
continue;
}
if (ch === '\\') {
escaped = true;
continue;
}
if (ch === quote) quote = null;
continue;
}
if (ch === '"' || ch === '\'' || ch === '`') {
quote = ch;
continue;
}
if (ch === openChar) {
depth++;
} else if (ch === closeChar) {
depth--;
if (depth === 0) {
return source.slice(startIndex + 1, i);
}
}
}
return null;
}
function extractTsArgsBlock(source: string): string | null {
const argsMatch = source.match(/args\s*:/);
if (!argsMatch || argsMatch.index === undefined) return null;
const bracketIndex = source.indexOf('[', argsMatch.index);
if (bracketIndex === -1) return null;
return extractBalancedBlock(source, bracketIndex, '[', ']');
}
function parseInlineChoices(body: string): string[] | undefined {
const choicesMatch = body.match(/choices\s*:\s*\[([^\]]*)\]/);
if (!choicesMatch) return undefined;
const values = choicesMatch[1]
.split(',')
.map(s => s.trim().replace(/^['"`]|['"`]$/g, ''))
.filter(Boolean);
return values.length > 0 ? values : undefined;
}
export function parseTsArgsBlock(argsBlock: string): ManifestEntry['args'] {
const args: ManifestEntry['args'] = [];
let cursor = 0;
while (cursor < argsBlock.length) {
const nameMatch = argsBlock.slice(cursor).match(/\{\s*name\s*:\s*['"`](\w+)['"`]/);
if (!nameMatch || nameMatch.index === undefined) break;
const objectStart = cursor + nameMatch.index;
const body = extractBalancedBlock(argsBlock, objectStart, '{', '}');
if (body == null) break;
const typeMatch = body.match(/type\s*:\s*['"`](\w+)['"`]/);
const defaultMatch = body.match(/default\s*:\s*([^,}]+)/);
const requiredMatch = body.match(/required\s*:\s*(true|false)/);
const helpMatch = body.match(/help\s*:\s*['"`]([^'"`]*)['"`]/);
const positionalMatch = body.match(/positional\s*:\s*(true|false)/);
let defaultVal: any = undefined;
if (defaultMatch) {
const raw = defaultMatch[1].trim();
if (raw === 'true') defaultVal = true;
else if (raw === 'false') defaultVal = false;
else if (/^\d+$/.test(raw)) defaultVal = parseInt(raw, 10);
else if (/^\d+\.\d+$/.test(raw)) defaultVal = parseFloat(raw);
else defaultVal = raw.replace(/^['"`]|['"`]$/g, '');
}
args.push({
name: nameMatch[1],
type: typeMatch?.[1] ?? 'str',
default: defaultVal,
required: requiredMatch?.[1] === 'true',
positional: positionalMatch?.[1] === 'true' || undefined,
help: helpMatch?.[1] ?? '',
choices: parseInlineChoices(body),
});
cursor = objectStart + body.length + 2;
}
return args;
}
function scanYaml(filePath: string, site: string): ManifestEntry | null {
try {
const raw = fs.readFileSync(filePath, 'utf-8');
@@ -128,37 +239,9 @@ function scanTs(filePath: string, site: string): ManifestEntry {
}
// Extract args array items: { name: '...', ... }
const argsBlockMatch = src.match(/args\s*:\s*\[([\s\S]*?)\]\s*,/);
if (argsBlockMatch) {
const argsBlock = argsBlockMatch[1];
const argRegex = /\{\s*name\s*:\s*['"`](\w+)['"`]([^}]*)\}/g;
let m;
while ((m = argRegex.exec(argsBlock)) !== null) {
const argName = m[1];
const body = m[2];
const typeMatch = body.match(/type\s*:\s*['"`](\w+)['"`]/);
const defaultMatch = body.match(/default\s*:\s*([^,}]+)/);
const requiredMatch = body.match(/required\s*:\s*(true|false)/);
const helpMatch = body.match(/help\s*:\s*['"`]([^'"`]*)['"`]/);
let defaultVal: any = undefined;
if (defaultMatch) {
const raw = defaultMatch[1].trim();
if (raw === 'true') defaultVal = true;
else if (raw === 'false') defaultVal = false;
else if (/^\d+$/.test(raw)) defaultVal = parseInt(raw, 10);
else if (/^\d+\.\d+$/.test(raw)) defaultVal = parseFloat(raw);
else defaultVal = raw.replace(/^['"`]|['"`]$/g, '');
}
entry.args.push({
name: argName,
type: typeMatch?.[1] ?? 'str',
default: defaultVal,
required: requiredMatch?.[1] === 'true',
help: helpMatch?.[1] ?? '',
});
}
const argsBlock = extractTsArgsBlock(src);
if (argsBlock) {
entry.args = parseTsArgsBlock(argsBlock);
}
} catch {
// If parsing fails, fall back to empty metadata — module will self-register at runtime
@@ -167,32 +250,42 @@ function scanTs(filePath: string, site: string): ManifestEntry {
return entry;
}
// Main
const manifest: ManifestEntry[] = [];
export function buildManifest(): ManifestEntry[] {
const manifest: ManifestEntry[] = [];
if (fs.existsSync(CLIS_DIR)) {
for (const site of fs.readdirSync(CLIS_DIR)) {
const siteDir = path.join(CLIS_DIR, site);
if (!fs.statSync(siteDir).isDirectory()) continue;
for (const file of fs.readdirSync(siteDir)) {
const filePath = path.join(siteDir, file);
if (file.endsWith('.yaml') || file.endsWith('.yml')) {
const entry = scanYaml(filePath, site);
if (entry) manifest.push(entry);
} else if (
(file.endsWith('.ts') && !file.endsWith('.d.ts') && file !== 'index.ts') ||
(file.endsWith('.js') && !file.endsWith('.d.js') && file !== 'index.js')
) {
manifest.push(scanTs(filePath, site));
if (fs.existsSync(CLIS_DIR)) {
for (const site of fs.readdirSync(CLIS_DIR)) {
const siteDir = path.join(CLIS_DIR, site);
if (!fs.statSync(siteDir).isDirectory()) continue;
for (const file of fs.readdirSync(siteDir)) {
const filePath = path.join(siteDir, file);
if (file.endsWith('.yaml') || file.endsWith('.yml')) {
const entry = scanYaml(filePath, site);
if (entry) manifest.push(entry);
} else if (
(file.endsWith('.ts') && !file.endsWith('.d.ts') && file !== 'index.ts') ||
(file.endsWith('.js') && !file.endsWith('.d.js') && file !== 'index.js')
) {
manifest.push(scanTs(filePath, site));
}
}
}
}
return manifest;
}
// Ensure output directory exists
fs.mkdirSync(path.dirname(OUTPUT), { recursive: true });
fs.writeFileSync(OUTPUT, JSON.stringify(manifest, null, 2));
function main(): void {
const manifest = buildManifest();
fs.mkdirSync(path.dirname(OUTPUT), { recursive: true });
fs.writeFileSync(OUTPUT, JSON.stringify(manifest, null, 2));
const yamlCount = manifest.filter(e => e.type === 'yaml').length;
const tsCount = manifest.filter(e => e.type === 'ts').length;
console.log(`✅ Manifest compiled: ${manifest.length} entries (${yamlCount} YAML, ${tsCount} TS) → ${OUTPUT}`);
const yamlCount = manifest.filter(e => e.type === 'yaml').length;
const tsCount = manifest.filter(e => e.type === 'ts').length;
console.log(`✅ Manifest compiled: ${manifest.length} entries (${yamlCount} YAML, ${tsCount} TS) → ${OUTPUT}`);
}
const entrypoint = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null;
if (entrypoint === import.meta.url) {
main();
}
+47 -75
View File
@@ -37,6 +37,49 @@ interface CascadeResult {
confidence: number;
}
/**
* Build the JavaScript source for a fetch probe.
* Shared logic for PUBLIC, COOKIE, and HEADER strategies.
*/
function buildFetchProbeJs(url: string, opts: {
credentials?: boolean;
extractCsrf?: boolean;
}): string {
const credentialsLine = opts.credentials ? `credentials: 'include',` : '';
const headerSetup = opts.extractCsrf
? `
const cookies = document.cookie.split(';').map(c => c.trim());
const csrf = cookies.find(c => c.startsWith('ct0=') || c.startsWith('csrf_token=') || c.startsWith('_csrf='))?.split('=').slice(1).join('=');
const headers = {};
if (csrf) { headers['X-Csrf-Token'] = csrf; headers['X-XSRF-Token'] = csrf; }
`
: 'const headers = {};';
return `
async () => {
try {
${headerSetup}
const resp = await fetch(${JSON.stringify(url)}, {
${credentialsLine}
headers
});
const status = resp.status;
if (!resp.ok) return { status, ok: false };
const text = await resp.text();
let hasData = false;
try {
const json = JSON.parse(text);
hasData = !!json && (Array.isArray(json) ? json.length > 0 :
typeof json === 'object' && Object.keys(json).length > 0);
// Check for API-level error codes (common in Chinese sites)
if (json.code !== undefined && json.code !== 0) hasData = false;
} catch {}
return { status, ok: true, hasData, preview: text.slice(0, 200) };
} catch (e) { return { ok: false, error: e.message }; }
}
`;
}
/**
* Probe an endpoint with a specific strategy.
* Returns whether the probe succeeded and basic response info.
@@ -45,32 +88,14 @@ export async function probeEndpoint(
page: IPage,
url: string,
strategy: Strategy,
opts: { timeout?: number } = {},
_opts: { timeout?: number } = {},
): Promise<ProbeResult> {
const result: ProbeResult = { strategy, success: false };
try {
switch (strategy) {
case Strategy.PUBLIC: {
// Try direct fetch without browser (no credentials)
const js = `
async () => {
try {
const resp = await fetch(${JSON.stringify(url)});
const status = resp.status;
if (!resp.ok) return { status, ok: false };
const text = await resp.text();
let hasData = false;
try {
const json = JSON.parse(text);
hasData = !!json && (Array.isArray(json) ? json.length > 0 :
typeof json === 'object' && Object.keys(json).length > 0);
} catch {}
return { status, ok: true, hasData, preview: text.slice(0, 200) };
} catch (e) { return { ok: false, error: e.message }; }
}
`;
const resp = await page.evaluate(js);
const resp = await page.evaluate(buildFetchProbeJs(url, {}));
result.statusCode = resp?.status;
result.success = resp?.ok && resp?.hasData;
result.hasData = resp?.hasData;
@@ -79,27 +104,7 @@ export async function probeEndpoint(
}
case Strategy.COOKIE: {
// Fetch with credentials: 'include' (uses browser cookies)
const js = `
async () => {
try {
const resp = await fetch(${JSON.stringify(url)}, { credentials: 'include' });
const status = resp.status;
if (!resp.ok) return { status, ok: false };
const text = await resp.text();
let hasData = false;
try {
const json = JSON.parse(text);
hasData = !!json && (Array.isArray(json) ? json.length > 0 :
typeof json === 'object' && Object.keys(json).length > 0);
// Check for API-level error codes (common in Chinese sites)
if (json.code !== undefined && json.code !== 0) hasData = false;
} catch {}
return { status, ok: true, hasData, preview: text.slice(0, 200) };
} catch (e) { return { ok: false, error: e.message }; }
}
`;
const resp = await page.evaluate(js);
const resp = await page.evaluate(buildFetchProbeJs(url, { credentials: true }));
result.statusCode = resp?.status;
result.success = resp?.ok && resp?.hasData;
result.hasData = resp?.hasData;
@@ -108,39 +113,7 @@ export async function probeEndpoint(
}
case Strategy.HEADER: {
// Fetch with credentials + try to extract common auth headers
const js = `
async () => {
try {
// Try to extract CSRF tokens from cookies
const cookies = document.cookie.split(';').map(c => c.trim());
const csrf = cookies.find(c => c.startsWith('ct0=') || c.startsWith('csrf_token=') || c.startsWith('_csrf='))?.split('=').slice(1).join('=');
const headers = {};
if (csrf) {
headers['X-Csrf-Token'] = csrf;
headers['X-XSRF-Token'] = csrf;
}
const resp = await fetch(${JSON.stringify(url)}, {
credentials: 'include',
headers
});
const status = resp.status;
if (!resp.ok) return { status, ok: false };
const text = await resp.text();
let hasData = false;
try {
const json = JSON.parse(text);
hasData = !!json && (Array.isArray(json) ? json.length > 0 :
typeof json === 'object' && Object.keys(json).length > 0);
if (json.code !== undefined && json.code !== 0) hasData = false;
} catch {}
return { status, ok: true, hasData, preview: text.slice(0, 200) };
} catch (e) { return { ok: false, error: e.message }; }
}
`;
const resp = await page.evaluate(js);
const resp = await page.evaluate(buildFetchProbeJs(url, { credentials: true, extractCsrf: true }));
result.statusCode = resp?.status;
result.success = resp?.ok && resp?.hasData;
result.hasData = resp?.hasData;
@@ -151,7 +124,6 @@ export async function probeEndpoint(
case Strategy.INTERCEPT:
case Strategy.UI:
// These require specific implementation per-site
// Mark as needing manual implementation
result.success = false;
result.error = `Strategy ${strategy} requires site-specific implementation`;
break;
+48
View File
@@ -0,0 +1,48 @@
# Antigravity CLI Adapter
🔥 **CLI All Electron Apps! The Most Powerful Update Has Arrived!** 🔥
Turn ANY Electron application into a CLI tool! Recombine, script, and extend applications like Antigravity Ultra seamlessly. Now AI can control itself natively. Unlimited possibilities await!
Turn your local Antigravity desktop application into a programmable AI node via Chrome DevTools Protocol (CDP). This allows you to compose complex LLM workflows entirely through the terminal by manipulating the actual UI natively, bypassing any API restrictions.
## Prerequisites
Start the Antigravity desktop app with the Chrome DevTools `remote-debugging-port` flag:
\`\`\`bash
# Start Antigravity in the background
/Applications/Antigravity.app/Contents/MacOS/Electron \
--remote-debugging-port=9224
\`\`\`
*(Note: Depending on your installation, the executable might be named differently, e.g., \`Antigravity\` instead of \`Electron\`.)*
Next, set the target port in your terminal session to tell OpenCLI where to connect:
\`\`\`bash
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9224"
\`\`\`
## Available Commands
### \`opencli antigravity status\`
Check the Chromium CDP connection. Returns the current window title and active internal URL.
### \`opencli antigravity send <message>\`
Send a text prompt to the AI. Automatically locates the Lexical editor input box, types the prompt securely, and hits Enter.
### \`opencli antigravity read\`
Scrape the entire current conversation history block as pure text. Useful for feeding the context to another script.
### \`opencli antigravity new\`
Click the "New Conversation" button to instantly clear the UI state and start fresh.
### \`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\`).
### \`opencli antigravity model <name>\`
Quickly target and switch the active LLM engine. Example: \`opencli antigravity model claude\` or \`opencli antigravity model gemini\`.
### \`opencli antigravity watch\`
A long-running, streaming process that continuously polls the Antigravity UI for chat updates and outputs them in real-time to standard output.
+51
View File
@@ -0,0 +1,51 @@
# 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 中止。
+42
View File
@@ -0,0 +1,42 @@
---
description: How to automate Antigravity using OpenCLI
---
# Antigravity Automation Skill
This skill allows AI agents to control the [Antigravity](https://github.com/chengazhen/Antigravity) desktop app (and any Electron app with CDP enabled) programmatically via OpenCLI.
## Requirements
The target Electron application MUST be launched with the remote-debugging-port flag:
\`\`\`bash
/Applications/Antigravity.app/Contents/MacOS/Electron --remote-debugging-port=9224
\`\`\`
The agent must configure the endpoint environment variable locally before invoking standard commands:
\`\`\`bash
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9224"
\`\`\`
## High-Level Capabilities
1. **Send Messages (`opencli antigravity send <message>`)**: Type and send a message directly into the chat UI.
2. **Read History (`opencli antigravity read`)**: Scrape the raw chat transcript from the main UI container.
3. **Extract Code (`opencli antigravity extract-code`)**: Automatically isolate and extract source code text blocks from the AI's recent answers.
4. **Switch Models (`opencli antigravity model <name>`)**: Instantly toggle the active LLM (e.g., \`gemini\`, \`claude\`).
5. **Clear Context (`opencli antigravity new`)**: Start a fresh conversation.
## Examples for Automated Workflows
### Generating and Saving Code
\`\`\`bash
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9224"
opencli antigravity send "Write a python script to fetch HN top stories"
# wait ~10-15 seconds for output to render
opencli antigravity extract-code > hn_fetcher.py
\`\`\`
### Reading Real-time Logs
Agents can run long-running streaming watch instances:
\`\`\`bash
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9224"
opencli antigravity watch
\`\`\`
+30
View File
@@ -0,0 +1,30 @@
import { cli, Strategy } from '../../registry.js';
import * as fs from 'node:fs';
export const dumpCommand = cli({
site: 'antigravity',
name: 'dump',
description: 'Dump the DOM to help AI understand the UI',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [],
columns: ['htmlFile', 'snapFile'],
func: async (page) => {
// Extract HTML
const html = await page.evaluate('document.body.innerHTML');
fs.writeFileSync('/tmp/antigravity-dom.html', html);
// Extract Snapshot
let snapFile = '';
try {
const snap = await page.snapshot({ raw: true });
snapFile = '/tmp/antigravity-snapshot.json';
fs.writeFileSync(snapFile, JSON.stringify(snap, null, 2));
} catch (e) {
snapFile = 'Failed';
}
return [{ htmlFile: '/tmp/antigravity-dom.html', snapFile }];
},
});
+34
View File
@@ -0,0 +1,34 @@
import { cli, Strategy } from '../../registry.js';
export const extractCodeCommand = cli({
site: 'antigravity',
name: 'extract-code',
description: 'Extract multi-line code blocks from the current Antigravity conversation',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [],
columns: ['code'],
func: async (page) => {
const blocks = await page.evaluate(`
async () => {
// Find standard pre/code blocks
let elements = Array.from(document.querySelectorAll('pre code'));
// Fallback to Monaco editor content inside the UI
if (elements.length === 0) {
elements = Array.from(document.querySelectorAll('.monaco-editor'));
}
// Generic fallback to any code tag that spans multiple lines
if (elements.length === 0) {
elements = Array.from(document.querySelectorAll('code')).filter(c => c.innerText.includes('\\n'));
}
return elements.map(el => el.innerText).filter(text => text.trim().length > 0);
}
`);
return blocks.map((code: string) => ({ code }));
},
});
+47
View File
@@ -0,0 +1,47 @@
import { cli, Strategy } from '../../registry.js';
export const modelCommand = cli({
site: 'antigravity',
name: 'model',
description: 'Switch the active LLM model in Antigravity',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [
{ name: 'name', help: 'Target model name (e.g. claude, gemini, o1)', required: true, positional: true }
],
columns: ['Status'],
func: async (page, kwargs) => {
const targetName = kwargs.name.toLowerCase();
await page.evaluate(`
async () => {
const targetModelName = ${JSON.stringify(targetName)};
// 1. Locate the model selector dropdown trigger
const trigger = document.querySelector('div[aria-haspopup="dialog"] > div[tabindex="0"]');
if (!trigger) throw new Error('Could not find the model selector trigger in the UI');
trigger.click();
// 2. Wait a brief moment for React to mount the Portal/Dialog
await new Promise(r => setTimeout(r, 200));
// 3. Find the option spanning target text
const spans = Array.from(document.querySelectorAll('[role="dialog"] span'));
const target = spans.find(s => s.innerText.toLowerCase().includes(targetModelName));
if (!target) {
// If not found, click the trigger again to close it safely
trigger.click();
throw new Error('Model matching "' + targetModelName + '" was not found in the dropdown list.');
}
// 4. Click the closest parent that handles the row action
const optionNode = target.closest('.cursor-pointer') || target;
optionNode.click();
}
`);
await page.wait(0.5);
return [{ Status: `Model switched to: ${kwargs.name}` }];
},
});
+28
View File
@@ -0,0 +1,28 @@
import { cli, Strategy } from '../../registry.js';
export const newCommand = cli({
site: 'antigravity',
name: 'new',
description: 'Start a new conversation / clear context in Antigravity',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [],
columns: ['status'],
func: async (page) => {
await page.evaluate(`
async () => {
const btn = document.querySelector('[data-tooltip-id="new-conversation-tooltip"]');
if (!btn) throw new Error('Could not find New Conversation button');
// In case it's disabled, we must check, but we'll try to click it anyway
btn.click();
}
`);
// Give it a moment to reset the UI
await page.wait(0.5);
return [{ status: 'Successfully started a new conversation' }];
},
});
+36
View File
@@ -0,0 +1,36 @@
import { cli, Strategy } from '../../registry.js';
export const readCommand = cli({
site: 'antigravity',
name: 'read',
description: 'Read the latest chat messages from Antigravity AI',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [
{ name: 'last', help: 'Number of recent messages to read (not fully implemented due to generic structure, currently returns full history text or latest chunk)' }
],
columns: ['role', 'content'],
func: async (page, kwargs) => {
// We execute a script inside Antigravity's Chromium environment to extract the text
// of the entire conversation pane.
const rawText = await page.evaluate(`
async () => {
const container = document.getElementById('conversation');
if (!container) throw new Error('Could not find conversation container');
// Extract the full visible text of the conversation
// In Electron/Chromium, innerText preserves basic visual line breaks nicely
return container.innerText;
}
`);
// We can do simple heuristic parsing based on typical visual markers if needed.
// For now, we return the entire text blob, or just the last 2000 characters if it's too long.
const cleanText = String(rawText).trim();
return [{
role: 'history',
content: cleanText
}];
},
});
+40
View File
@@ -0,0 +1,40 @@
import { cli, Strategy } from '../../registry.js';
export const sendCommand = cli({
site: 'antigravity',
name: 'send',
description: 'Send a message to Antigravity AI via the internal Lexical editor',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [
{ name: 'message', help: 'The message text to send', required: true, positional: true }
],
columns: ['Status', 'Message'],
func: async (page, kwargs) => {
const text = kwargs.message;
// We use evaluate to focus and insert text because Lexical editors maintain
// absolute control over their DOM and don't respond to raw node.textContent.
// document.execCommand simulates a native paste/typing action perfectly.
await page.evaluate(`
async () => {
const container = document.getElementById('antigravity.agentSidePanelInputBox');
if (!container) throw new Error('Could not find antigravity.agentSidePanelInputBox');
const editor = container.querySelector('[data-lexical-editor="true"]');
if (!editor) throw new Error('Could not find Antigravity input box');
editor.focus();
document.execCommand('insertText', false, ${JSON.stringify(text)});
}
`);
// Wait for the React/Lexical state to flush the new input
await page.wait(0.5);
// Press Enter to submit the message
await page.pressKey('Enter');
return [{ Status: 'Sent successfully', Message: text }];
},
});
+19
View File
@@ -0,0 +1,19 @@
import { cli, Strategy } from '../../registry.js';
export const statusCommand = cli({
site: 'antigravity',
name: 'status',
description: 'Check Antigravity CDP connection and get current page state',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [],
columns: ['status', 'url', 'title'],
func: async (page) => {
return {
status: 'Connected',
url: await page.evaluate('window.location.href'),
title: await page.evaluate('document.title'),
};
},
});
+45
View File
@@ -0,0 +1,45 @@
import { cli, Strategy } from '../../registry.js';
export const watchCommand = cli({
site: 'antigravity',
name: 'watch',
description: 'Stream new chat messages from Antigravity in real-time',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [],
timeoutSeconds: 86400, // Run for up to 24 hours
columns: [], // We use direct stdout streaming
func: async (page) => {
console.log('Watching Antigravity chat... (Press Ctrl+C to stop)');
let lastLength = 0;
// Loop until process gets killed
while (true) {
const text = await page.evaluate(`
async () => {
const container = document.getElementById('conversation');
return container ? container.innerText : '';
}
`);
const currentLength = text.length;
if (currentLength > lastLength) {
// Delta mode
const newSegment = text.substring(lastLength);
if (newSegment.trim().length > 0) {
process.stdout.write(newSegment);
}
lastLength = currentLength;
} else if (currentLength < lastLength) {
// The conversation was cleared or updated significantly
lastLength = currentLength;
console.log('\\n--- Conversation Cleared/Changed ---\\n');
process.stdout.write(text);
}
await new Promise(resolve => setTimeout(resolve, 500));
}
},
});
+120
View File
@@ -0,0 +1,120 @@
/**
* Barchart unusual options activity (options flow).
* Shows high volume/OI ratio trades that may indicate institutional activity.
* Auth: CSRF token from <meta name="csrf-token"> + session cookies.
*/
import { cli, Strategy } from '../../registry.js';
cli({
site: 'barchart',
name: 'flow',
description: 'Barchart unusual options activity / options flow',
domain: 'www.barchart.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'type', type: 'str', default: 'all', help: 'Filter: all, call, or put', choices: ['all', 'call', 'put'] },
{ name: 'limit', type: 'int', default: 20, help: 'Number of results' },
],
columns: [
'symbol', 'type', 'strike', 'expiration', 'last',
'volume', 'openInterest', 'volOiRatio', 'iv',
],
func: async (page, kwargs) => {
const optionType = kwargs.type || 'all';
const limit = kwargs.limit ?? 20;
await page.goto('https://www.barchart.com/options/unusual-activity/stocks');
await page.wait(5);
const data = await page.evaluate(`
(async () => {
const limit = ${limit};
const typeFilter = '${optionType}'.toLowerCase();
// Wait for CSRF token to appear (Angular may inject it after initial render)
let csrf = '';
for (let i = 0; i < 10; i++) {
csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
if (csrf) break;
await new Promise(r => setTimeout(r, 500));
}
if (!csrf) return { error: 'no-csrf' };
const headers = { 'X-CSRF-TOKEN': csrf };
const fields = [
'baseSymbol','strikePrice','expirationDate','optionType',
'lastPrice','volume','openInterest','volumeOpenInterestRatio','volatility',
].join(',');
// Fetch extra rows when filtering by type since server-side filter doesn't work
const fetchLimit = typeFilter !== 'all' ? limit * 3 : limit;
// Try unusual_activity first, fall back to mostActive (unusual_activity is
// empty outside market hours)
const lists = [
'options.unusual_activity.stocks.us',
'options.mostActive.us',
];
for (const list of lists) {
try {
const url = '/proxies/core-api/v1/options/get?list=' + list
+ '&fields=' + fields
+ '&orderBy=volumeOpenInterestRatio&orderDir=desc'
+ '&raw=1&limit=' + fetchLimit;
const resp = await fetch(url, { credentials: 'include', headers });
if (!resp.ok) continue;
const d = await resp.json();
let items = d?.data || [];
if (items.length === 0) continue;
// Apply client-side type filter
if (typeFilter !== 'all') {
items = items.filter(i => {
const t = ((i.raw || i).optionType || '').toLowerCase();
return t === typeFilter;
});
}
return items.slice(0, limit).map(i => {
const r = i.raw || i;
return {
symbol: r.baseSymbol || r.symbol,
type: r.optionType,
strike: r.strikePrice,
expiration: r.expirationDate,
last: r.lastPrice,
volume: r.volume,
openInterest: r.openInterest,
volOiRatio: r.volumeOpenInterestRatio,
iv: r.volatility,
};
});
} catch(e) {}
}
return [];
})()
`);
if (!data) return [];
if (data.error === 'no-csrf') {
throw new Error('Could not extract CSRF token from barchart.com. Make sure you are logged in.');
}
if (!Array.isArray(data)) return [];
return data.slice(0, limit).map(r => ({
symbol: r.symbol || '',
type: r.type || '',
strike: r.strike,
expiration: r.expiration ?? null,
last: r.last != null ? Number(Number(r.last).toFixed(2)) : null,
volume: r.volume,
openInterest: r.openInterest,
volOiRatio: r.volOiRatio != null ? Number(Number(r.volOiRatio).toFixed(2)) : null,
iv: r.iv != null ? Number(Number(r.iv).toFixed(2)) + '%' : null,
}));
},
});
+123
View File
@@ -0,0 +1,123 @@
/**
* Barchart options greeks overview — IV, delta, gamma, theta, vega, rho
* for near-the-money options on a given symbol.
* Auth: CSRF token from <meta name="csrf-token"> + session cookies.
*/
import { cli, Strategy } from '../../registry.js';
cli({
site: 'barchart',
name: 'greeks',
description: 'Barchart options greeks overview (IV, delta, gamma, theta, vega)',
domain: 'www.barchart.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'symbol', required: 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' },
],
columns: [
'type', 'strike', 'last', 'iv', 'delta', 'gamma', 'theta', 'vega', 'rho',
'volume', 'openInterest', 'expiration',
],
func: async (page, kwargs) => {
const symbol = kwargs.symbol.toUpperCase().trim();
const expiration = kwargs.expiration ?? '';
const limit = kwargs.limit ?? 10;
await page.goto(`https://www.barchart.com/stocks/quotes/${encodeURIComponent(symbol)}/options`);
await page.wait(4);
const data = await page.evaluate(`
(async () => {
const sym = '${symbol}';
const expDate = '${expiration}';
const limit = ${limit};
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
const headers = { 'X-CSRF-TOKEN': csrf };
try {
const fields = [
'strikePrice','lastPrice','volume','openInterest',
'volatility','delta','gamma','theta','vega','rho',
'expirationDate','optionType','percentFromLast',
].join(',');
let url = '/proxies/core-api/v1/options/chain?symbol=' + encodeURIComponent(sym)
+ '&fields=' + fields + '&raw=1';
if (expDate) url += '&expirationDate=' + encodeURIComponent(expDate);
const resp = await fetch(url, { credentials: 'include', headers });
if (resp.ok) {
const d = await resp.json();
let items = d?.data || [];
if (!expDate) {
const expirations = items
.map(i => (i.raw || i).expirationDate || null)
.filter(Boolean)
.sort((a, b) => {
const aTime = Date.parse(a);
const bTime = Date.parse(b);
if (Number.isNaN(aTime) && Number.isNaN(bTime)) return 0;
if (Number.isNaN(aTime)) return 1;
if (Number.isNaN(bTime)) return -1;
return aTime - bTime;
});
const nearestExpiration = expirations[0];
if (nearestExpiration) {
items = items.filter(i => ((i.raw || i).expirationDate || null) === nearestExpiration);
}
}
// Separate calls and puts, sort by distance from current price
const calls = items
.filter(i => ((i.raw || i).optionType || '').toLowerCase() === 'call')
.sort((a, b) => Math.abs((a.raw || a).percentFromLast || 999) - Math.abs((b.raw || b).percentFromLast || 999))
.slice(0, limit);
const puts = items
.filter(i => ((i.raw || i).optionType || '').toLowerCase() === 'put')
.sort((a, b) => Math.abs((a.raw || a).percentFromLast || 999) - Math.abs((b.raw || b).percentFromLast || 999))
.slice(0, limit);
return [...calls, ...puts].map(i => {
const r = i.raw || i;
return {
type: r.optionType,
strike: r.strikePrice,
last: r.lastPrice,
iv: r.volatility,
delta: r.delta,
gamma: r.gamma,
theta: r.theta,
vega: r.vega,
rho: r.rho,
volume: r.volume,
openInterest: r.openInterest,
expiration: r.expirationDate,
};
});
}
} catch(e) {}
return [];
})()
`);
if (!data || !Array.isArray(data)) return [];
return data.map(r => ({
type: r.type || '',
strike: r.strike,
last: r.last != null ? Number(Number(r.last).toFixed(2)) : null,
iv: r.iv != null ? Number(Number(r.iv).toFixed(2)) + '%' : null,
delta: r.delta != null ? Number(Number(r.delta).toFixed(4)) : null,
gamma: r.gamma != null ? Number(Number(r.gamma).toFixed(4)) : null,
theta: r.theta != null ? Number(Number(r.theta).toFixed(4)) : null,
vega: r.vega != null ? Number(Number(r.vega).toFixed(4)) : null,
rho: r.rho != null ? Number(Number(r.rho).toFixed(4)) : null,
volume: r.volume,
openInterest: r.openInterest,
expiration: r.expiration ?? null,
}));
},
});
+110
View File
@@ -0,0 +1,110 @@
/**
* Barchart options chain — strike, bid/ask, volume, OI, greeks, IV.
* Auth: CSRF token from <meta name="csrf-token"> + session cookies.
*/
import { cli, Strategy } from '../../registry.js';
cli({
site: 'barchart',
name: 'options',
description: 'Barchart options chain with greeks, IV, volume, and open interest',
domain: 'www.barchart.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'symbol', required: 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' },
],
columns: [
'strike', 'bid', 'ask', 'last', 'change', 'volume', 'openInterest',
'iv', 'delta', 'gamma', 'theta', 'vega', 'expiration',
],
func: async (page, kwargs) => {
const symbol = kwargs.symbol.toUpperCase().trim();
const optType = kwargs.type || 'Call';
const limit = kwargs.limit ?? 20;
await page.goto(`https://www.barchart.com/stocks/quotes/${encodeURIComponent(symbol)}/options`);
await page.wait(4);
const data = await page.evaluate(`
(async () => {
const sym = '${symbol}';
const type = '${optType}';
const limit = ${limit};
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
const headers = { 'X-CSRF-TOKEN': csrf };
// API: options chain with greeks
try {
const fields = [
'strikePrice','bidPrice','askPrice','lastPrice','priceChange',
'volume','openInterest','volatility',
'delta','gamma','theta','vega',
'expirationDate','optionType','percentFromLast',
].join(',');
const url = '/proxies/core-api/v1/options/chain?symbol=' + encodeURIComponent(sym)
+ '&fields=' + fields + '&raw=1';
const resp = await fetch(url, { credentials: 'include', headers });
if (resp.ok) {
const d = await resp.json();
let items = d?.data || [];
// Filter by type
items = items.filter(i => {
const t = (i.raw || i).optionType || '';
return t.toLowerCase() === type.toLowerCase();
});
// Sort by closeness to current price
items.sort((a, b) => {
const aD = Math.abs((a.raw || a).percentFromLast || 999);
const bD = Math.abs((b.raw || b).percentFromLast || 999);
return aD - bD;
});
return items.slice(0, limit).map(i => {
const r = i.raw || i;
return {
strike: r.strikePrice,
bid: r.bidPrice,
ask: r.askPrice,
last: r.lastPrice,
change: r.priceChange,
volume: r.volume,
openInterest: r.openInterest,
iv: r.volatility,
delta: r.delta,
gamma: r.gamma,
theta: r.theta,
vega: r.vega,
expiration: r.expirationDate,
};
});
}
} catch(e) {}
return [];
})()
`);
if (!data || !Array.isArray(data)) return [];
return data.map(r => ({
strike: r.strike,
bid: r.bid != null ? Number(Number(r.bid).toFixed(2)) : null,
ask: r.ask != null ? Number(Number(r.ask).toFixed(2)) : null,
last: r.last != null ? Number(Number(r.last).toFixed(2)) : null,
change: r.change != null ? Number(Number(r.change).toFixed(2)) : null,
volume: r.volume,
openInterest: r.openInterest,
iv: r.iv != null ? Number(Number(r.iv).toFixed(2)) + '%' : null,
delta: r.delta != null ? Number(Number(r.delta).toFixed(4)) : null,
gamma: r.gamma != null ? Number(Number(r.gamma).toFixed(4)) : null,
theta: r.theta != null ? Number(Number(r.theta).toFixed(4)) : null,
vega: r.vega != null ? Number(Number(r.vega).toFixed(4)) : null,
expiration: r.expiration ?? null,
}));
},
});
+137
View File
@@ -0,0 +1,137 @@
/**
* Barchart stock quote — price, volume, market cap, P/E, EPS, and key metrics.
* Auth: CSRF token from <meta name="csrf-token"> + session cookies.
*/
import { cli, Strategy } from '../../registry.js';
cli({
site: 'barchart',
name: 'quote',
description: 'Barchart stock quote with price, volume, and key metrics',
domain: 'www.barchart.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'symbol', required: true, help: 'Stock ticker (e.g. AAPL, MSFT, TSLA)' },
],
columns: [
'symbol', 'name', 'price', 'change', 'changePct',
'open', 'high', 'low', 'prevClose', 'volume',
'avgVolume', 'marketCap', 'peRatio', 'eps',
],
func: async (page, kwargs) => {
const symbol = kwargs.symbol.toUpperCase().trim();
await page.goto(`https://www.barchart.com/stocks/quotes/${encodeURIComponent(symbol)}/overview`);
await page.wait(4);
const data = await page.evaluate(`
(async () => {
const sym = '${symbol}';
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
// Strategy 1: internal proxy API with CSRF token
try {
const fields = [
'symbol','symbolName','lastPrice','priceChange','percentChange',
'highPrice','lowPrice','openPrice','previousPrice','volume','averageVolume',
'marketCap','peRatio','earningsPerShare','tradeTime',
].join(',');
const url = '/proxies/core-api/v1/quotes/get?symbol=' + encodeURIComponent(sym) + '&fields=' + fields;
const resp = await fetch(url, {
credentials: 'include',
headers: { 'X-CSRF-TOKEN': csrf },
});
if (resp.ok) {
const d = await resp.json();
const row = d?.data?.[0] || null;
if (row) {
return { source: 'api', row };
}
}
} catch(e) {}
// Strategy 2: parse from DOM
try {
const priceEl = document.querySelector('span.last-change');
const price = priceEl ? priceEl.textContent.trim() : null;
// Change values are sibling spans inside .pricechangerow > .last-change
const changeParent = priceEl?.parentElement;
const changeSpans = changeParent ? changeParent.querySelectorAll('span') : [];
let change = null;
let changePct = null;
for (const s of changeSpans) {
const t = s.textContent.trim();
if (s === priceEl) continue;
if (t.includes('%')) changePct = t.replace(/[()]/g, '');
else if (t.match(/^[+-]?[\\d.]+$/)) change = t;
}
// Financial data rows
const rows = document.querySelectorAll('.financial-data-row');
const fdata = {};
for (const row of rows) {
const spans = row.querySelectorAll('span');
if (spans.length >= 2) {
const label = spans[0].textContent.trim();
const valSpan = row.querySelector('span.right span:not(.ng-hide)');
fdata[label] = valSpan ? valSpan.textContent.trim() : '';
}
}
// Day high/low from row chart
const dayLow = document.querySelector('.bc-quote-row-chart .small-6:first-child .inline:not(.ng-hide)');
const dayHigh = document.querySelector('.bc-quote-row-chart .text-right .inline:not(.ng-hide)');
const openEl = document.querySelector('.mark span');
const openText = openEl ? openEl.textContent.trim().replace('Open ', '') : null;
const name = document.querySelector('h1 span.symbol');
return {
source: 'dom',
row: {
symbol: sym,
symbolName: name ? name.textContent.trim() : sym,
lastPrice: price,
priceChange: change,
percentChange: changePct,
open: openText,
highPrice: dayHigh ? dayHigh.textContent.trim() : null,
lowPrice: dayLow ? dayLow.textContent.trim() : null,
previousClose: fdata['Previous Close'] || null,
volume: fdata['Volume'] || null,
averageVolume: fdata['Average Volume'] || null,
marketCap: null,
peRatio: null,
earningsPerShare: null,
}
};
} catch(e) {
return { error: 'Could not fetch quote for ' + sym + ': ' + e.message };
}
})()
`);
if (!data || data.error) return [];
const r = data.row || {};
// API returns formatted strings like "+1.41" and "+0.56%"; use raw if available
const raw = r.raw || {};
return [{
symbol: r.symbol || symbol,
name: r.symbolName || r.name || symbol,
price: r.lastPrice ?? null,
change: r.priceChange ?? null,
changePct: r.percentChange ?? null,
open: r.openPrice ?? r.open ?? null,
high: r.highPrice ?? null,
low: r.lowPrice ?? null,
prevClose: r.previousPrice ?? r.previousClose ?? null,
volume: r.volume ?? null,
avgVolume: r.averageVolume ?? null,
marketCap: r.marketCap ?? null,
peRatio: r.peRatio ?? null,
eps: r.earningsPerShare ?? null,
}];
},
});
+161
View File
@@ -0,0 +1,161 @@
/**
* Bilibili download — download videos using yt-dlp.
*
* Usage:
* opencli bilibili download --bvid BV1xxx --output ./bilibili
*
* Requirements:
* - 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,
} from '../../download/index.js';
import { DownloadProgressTracker, formatBytes } from '../../download/progress.js';
cli({
site: 'bilibili',
name: 'download',
description: '下载B站视频(需要 yt-dlp',
domain: 'www.bilibili.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'bvid', required: 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)' },
],
columns: ['bvid', 'title', 'status', 'size'],
func: async (page, kwargs) => {
const bvid = kwargs.bvid;
const output = kwargs.output;
const quality = kwargs.quality;
// Check yt-dlp availability
if (!checkYtdlp()) {
return [{
bvid,
title: '-',
status: 'failed',
size: 'yt-dlp not installed. Run: pip install yt-dlp',
}];
}
// Navigate to video page to get title and cookies
await page.goto(`https://www.bilibili.com/video/${bvid}`);
await page.wait(3);
// Extract video info
const data = await page.evaluate(`
(() => {
const title = document.querySelector('h1.video-title, .video-title')?.textContent?.trim() || 'video';
const author = document.querySelector('.up-name, .username')?.textContent?.trim() || 'unknown';
return { title, author };
})()
`);
const title = sanitizeFilename(data?.title || 'video');
// Extract cookies for authenticated downloads
const cookieString = await page.evaluate(`(() => document.cookie)()`);
// Create output directory
fs.mkdirSync(output, { recursive: true });
// Export cookies to Netscape format for yt-dlp
let cookiesFile: string | undefined;
if (typeof cookieString === 'string' && cookieString) {
const tempDir = getTempDir();
fs.mkdirSync(tempDir, { recursive: true });
cookiesFile = path.join(tempDir, `bilibili_cookies_${Date.now()}.txt`);
const cookies = cookieString.split(';').map((c) => {
const [name, ...rest] = c.trim().split('=');
return {
name: name || '',
value: rest.join('=') || '',
domain: '.bilibili.com',
path: '/',
secure: true,
httpOnly: false,
};
}).filter((c) => c.name);
exportCookiesToNetscape(cookies, cookiesFile);
}
// Build yt-dlp format string based on quality
let format = 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best';
if (quality === '1080p') {
format = 'bestvideo[height<=1080][ext=mp4]+bestaudio[ext=m4a]/best[height<=1080]';
} else if (quality === '720p') {
format = 'bestvideo[height<=720][ext=mp4]+bestaudio[ext=m4a]/best[height<=720]';
} else if (quality === '480p') {
format = 'bestvideo[height<=480][ext=mp4]+bestaudio[ext=m4a]/best[height<=480]';
}
const destPath = path.join(output, `${bvid}_${title}.mp4`);
const tracker = new DownloadProgressTracker(1, true);
const progressBar = tracker.onFileStart(`${bvid}.mp4`, 0);
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,
}];
}
},
});
+115
View File
@@ -0,0 +1,115 @@
/**
* BOSS直聘 job detail — fetch full job posting details via browser cookie API.
*
* Uses securityId from search results to call the detail API.
* Returns: job description, skills, welfare, boss info, company info, address.
*/
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
cli({
site: 'boss',
name: 'detail',
description: 'BOSS直聘查看职位详情',
domain: 'www.zhipin.com',
strategy: Strategy.COOKIE,
browser: true,
args: [
{ name: 'security_id', required: true, help: 'Security ID from search results (securityId field)' },
],
columns: [
'name', 'salary', 'experience', 'degree', 'city', 'district',
'description', 'skills', 'welfare',
'boss_name', 'boss_title', 'active_time',
'company', 'industry', 'scale', 'stage',
'address', 'url',
],
func: async (page: IPage | null, kwargs) => {
if (!page) throw new Error('Browser page required');
const securityId = kwargs.security_id;
// Navigate to zhipin.com first to establish cookie context (referrer + cookies)
await page.goto('https://www.zhipin.com/web/geek/job');
await page.wait({ time: 1 });
const targetUrl = `https://www.zhipin.com/wapi/zpgeek/job/detail.json?securityId=${encodeURIComponent(securityId)}`;
if (process.env.OPENCLI_VERBOSE || process.env.DEBUG?.includes('opencli')) {
console.error(`[opencli:boss] Fetching job detail...`);
}
const evaluateScript = `
async () => {
return new Promise((resolve, reject) => {
const xhr = new window.XMLHttpRequest();
xhr.open('GET', ${JSON.stringify(targetUrl)}, true);
xhr.withCredentials = true;
xhr.timeout = 15000;
xhr.setRequestHeader('Accept', 'application/json, text/plain, */*');
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve(JSON.parse(xhr.responseText));
} catch (e) {
reject(new Error('Failed to parse JSON. Raw (200 chars): ' + xhr.responseText.substring(0, 200)));
}
} else {
reject(new Error('XHR HTTP Status: ' + xhr.status));
}
};
xhr.onerror = () => reject(new Error('XHR Network Error'));
xhr.ontimeout = () => reject(new Error('XHR Timeout'));
xhr.send();
});
}
`;
let data: any;
try {
data = await page.evaluate(evaluateScript);
} catch (e: any) {
throw new Error('API evaluate failed: ' + e.message);
}
if (data.code !== 0) {
if (data.code === 37) {
throw new Error('Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。');
}
throw new Error(`BOSS API error: ${data.message || 'Unknown'} (code=${data.code})`);
}
const zpData = data.zpData || {};
const jobInfo = zpData.jobInfo || {};
const bossInfo = zpData.bossInfo || {};
const brandComInfo = zpData.brandComInfo || {};
if (!jobInfo.jobName) {
throw new Error('该职位信息不存在或已下架');
}
return [{
name: jobInfo.jobName || '',
salary: jobInfo.salaryDesc || '',
experience: jobInfo.experienceName || '',
degree: jobInfo.degreeName || '',
city: jobInfo.locationName || '',
district: [jobInfo.areaDistrict, jobInfo.businessDistrict].filter(Boolean).join('·'),
description: jobInfo.postDescription || '',
skills: (jobInfo.showSkills || []).join(', '),
welfare: (brandComInfo.labels || []).join(', '),
boss_name: bossInfo.name || '',
boss_title: bossInfo.title || '',
active_time: bossInfo.activeTimeDesc || '',
company: brandComInfo.brandName || bossInfo.brandName || '',
industry: brandComInfo.industryName || '',
scale: brandComInfo.scaleName || '',
stage: brandComInfo.stageName || '',
address: jobInfo.address || '',
url: jobInfo.encryptId
? 'https://www.zhipin.com/job_detail/' + jobInfo.encryptId + '.html'
: '',
}];
},
});
+2 -1
View File
@@ -81,7 +81,7 @@ cli({
{ name: 'page', type: 'int', default: 1, help: 'Page number' },
{ name: 'limit', type: 'int', default: 15, help: 'Number of results' },
],
columns: ['name', 'salary', 'company', 'area', 'experience', 'degree', 'skills', 'boss', 'url'],
columns: ['name', 'salary', 'company', 'area', 'experience', 'degree', 'skills', 'boss', 'security_id', 'url'],
func: async (page: IPage | null, kwargs) => {
if (!page) throw new Error('Browser page required');
@@ -191,6 +191,7 @@ cli({
degree: j.jobDegree,
skills: (j.skills || []).join(','),
boss: j.bossName + ' · ' + j.bossTitle,
security_id: j.securityId || '',
url: 'https://www.zhipin.com/job_detail/' + j.encryptJobId + '.html',
});
addedInBatch++;
+44
View File
@@ -0,0 +1,44 @@
# ChatGPT Desktop Adapter for OpenCLI
Control the **ChatGPT macOS Desktop App** directly from the terminal. OpenCLI supports two automation approaches for ChatGPT.
## Approach 1: AppleScript (Default, No Setup)
The current built-in commands use native AppleScript automation — no extra launch flags needed.
### Prerequisites
1. Install the official [ChatGPT Desktop App](https://openai.com/chatgpt/mac/) from OpenAI.
2. Grant **Accessibility permissions** to your terminal app (Terminal / iTerm / Warp) in **System Settings → Privacy & Security → Accessibility**. This is required for System Events keystroke simulation.
### Commands
- `opencli chatgpt status`: Check if the ChatGPT app is currently running.
- `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`: Copy the last AI response via `Cmd+Shift+C` and return it as text.
## Approach 2: CDP (Advanced, Electron Debug Mode)
ChatGPT Desktop is also an Electron app and can be launched with a remote debugging port for deeper automation via CDP:
```bash
/Applications/ChatGPT.app/Contents/MacOS/ChatGPT \
--remote-debugging-port=9224
```
Then set the endpoint:
```bash
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9224"
```
> **Note**: The CDP approach enables future advanced commands like DOM inspection, model switching, and code extraction — similar to the Cursor and Codex adapters.
## How It Works
- **AppleScript mode**: Uses `osascript` and `pbcopy`/`pbpaste` for clipboard-based text transfer. No remote debugging port needed.
- **CDP mode**: Connects via Chrome DevTools Protocol to the Electron renderer process for direct DOM manipulation.
## Limitations
- macOS only (AppleScript dependency)
- AppleScript mode requires Accessibility permissions
- `read` command copies the last response — earlier messages need manual scroll
+44
View File
@@ -0,0 +1,44 @@
# ChatGPT 桌面端适配器
在终端中直接控制 **ChatGPT macOS 桌面应用**。OpenCLI 支持两种自动化方式。
## 方式一:AppleScript(默认,无需配置)
内置命令使用原生 AppleScript 自动化,无需额外启动参数。
### 前置条件
1. 安装官方 [ChatGPT Desktop App](https://openai.com/chatgpt/mac/)。
2.**系统设置 → 隐私与安全性 → 辅助功能** 中为终端应用授予权限。
### 命令
- `opencli chatgpt status`:检查 ChatGPT 应用是否在运行。
- `opencli chatgpt new`:激活 ChatGPT 并按 `Cmd+N` 开始新对话。
- `opencli chatgpt send "消息"`:将消息复制到剪贴板,激活 ChatGPT,粘贴并提交。
- `opencli chatgpt read`:通过 `Cmd+Shift+C` 复制最后一条 AI 回复并返回文本。
## 方式二:CDP(高级,Electron 调试模式)
ChatGPT Desktop 同样是 Electron 应用,可以通过远程调试端口启动以实现更深度的自动化:
```bash
/Applications/ChatGPT.app/Contents/MacOS/ChatGPT \
--remote-debugging-port=9224
```
然后设置环境变量:
```bash
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9224"
```
> **注意**:CDP 模式支持未来的高级命令(如 DOM 检查、模型切换、代码提取等),与 Cursor 和 Codex 适配器类似。
## 工作原理
- **AppleScript 模式**:使用 `osascript``pbcopy`/`pbpaste` 进行剪贴板文本传输,无需远程调试端口。
- **CDP 模式**:通过 Chrome DevTools Protocol 连接到 Electron 渲染进程,直接操作 DOM。
## 限制
- 仅支持 macOSAppleScript 依赖)
- AppleScript 模式需要辅助功能权限
- `read` 命令复制最后一条回复,更早的消息需手动滚动
+77
View File
@@ -0,0 +1,77 @@
import { execSync, spawnSync } from 'node:child_process';
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
export const askCommand = cli({
site: 'chatgpt',
name: 'ask',
description: 'Send a prompt and wait for the AI response (send + wait + read)',
domain: 'localhost',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'text', required: true, positional: true, help: 'Prompt to send' },
{ name: 'timeout', required: false, help: 'Max seconds to wait for response (default: 30)', default: '30' },
],
columns: ['Role', 'Text'],
func: async (page: IPage | null, kwargs: any) => {
const text = kwargs.text as string;
const timeout = parseInt(kwargs.timeout as string, 10) || 30;
// Backup clipboard
let clipBackup = '';
try { clipBackup = execSync('pbpaste', { encoding: 'utf-8' }); } catch {}
// Send the message
spawnSync('pbcopy', { input: text });
execSync("osascript -e 'tell application \"ChatGPT\" to activate'");
execSync("osascript -e 'delay 0.5'");
const cmd = "osascript " +
"-e 'tell application \"System Events\"' " +
"-e 'keystroke \"v\" using command down' " +
"-e 'delay 0.2' " +
"-e 'keystroke return' " +
"-e 'end tell'";
execSync(cmd);
// Clear clipboard marker
spawnSync('pbcopy', { input: '__OPENCLI_WAITING__' });
// Wait for response, then read it
const pollInterval = 3;
const maxPolls = Math.ceil(timeout / pollInterval);
let response = '';
for (let i = 0; i < maxPolls; i++) {
// Wait
execSync(`sleep ${pollInterval}`);
// Try Cmd+Shift+C to copy the latest response
execSync("osascript -e 'tell application \"ChatGPT\" to activate'");
execSync("osascript -e 'tell application \"System Events\" to keystroke \"c\" using {command down, shift down}'");
execSync("osascript -e 'delay 0.3'");
const copied = execSync('pbpaste', { encoding: 'utf-8' }).trim();
if (copied && copied !== '__OPENCLI_WAITING__' && copied !== text) {
response = copied;
break;
}
}
// Restore clipboard
if (clipBackup) spawnSync('pbcopy', { input: clipBackup });
if (!response) {
return [
{ Role: 'User', Text: text },
{ Role: 'System', Text: `No response within ${timeout}s. ChatGPT may still be generating.` },
];
}
return [
{ Role: 'User', Text: text },
{ Role: 'Assistant', Text: response },
];
},
});
+24
View File
@@ -0,0 +1,24 @@
import { execSync } from 'node:child_process';
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
export const newCommand = cli({
site: 'chatgpt',
name: 'new',
description: 'Open a new chat in ChatGPT Desktop App',
domain: 'localhost',
strategy: Strategy.PUBLIC,
browser: false,
args: [],
columns: ['Status'],
func: async (page: IPage | null) => {
try {
execSync("osascript -e 'tell application \"ChatGPT\" to activate'");
execSync("osascript -e 'delay 0.5'");
execSync("osascript -e 'tell application \"System Events\" to keystroke \"n\" using command down'");
return [{ Status: 'Success' }];
} catch (err: any) {
return [{ Status: "Error: " + err.message }];
}
},
});
+32
View File
@@ -0,0 +1,32 @@
import { execSync } from 'node:child_process';
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
export const readCommand = cli({
site: 'chatgpt',
name: 'read',
description: 'Copy the most recent ChatGPT Desktop App response to clipboard and read it',
domain: 'localhost',
strategy: Strategy.PUBLIC,
browser: false,
args: [],
columns: ['Role', 'Text'],
func: async (page: IPage | null) => {
try {
execSync("osascript -e 'tell application \"ChatGPT\" to activate'");
execSync("osascript -e 'delay 0.5'");
execSync("osascript -e 'tell application \"System Events\" to keystroke \"c\" using {command down, shift down}'");
execSync("osascript -e 'delay 0.3'");
const result = execSync('pbpaste', { encoding: 'utf-8' }).trim();
if (!result) {
return [{ Role: 'System', Text: 'No text was copied. Is there a response in the chat?' }];
}
return [{ Role: 'Assistant', Text: result }];
} catch (err: any) {
throw new Error("Failed to read from ChatGPT: " + err.message);
}
},
});
+48
View File
@@ -0,0 +1,48 @@
import { execSync, spawnSync } from 'node:child_process';
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
export const sendCommand = cli({
site: 'chatgpt',
name: 'send',
description: 'Send a message to the active ChatGPT Desktop App window',
domain: 'localhost',
strategy: Strategy.PUBLIC,
browser: false,
args: [{ name: 'text', required: true, positional: true, help: 'Message to send' }],
columns: ['Status'],
func: async (page: IPage | null, kwargs: any) => {
const text = kwargs.text as string;
try {
// Backup current clipboard content
let clipBackup = '';
try {
clipBackup = execSync('pbpaste', { encoding: 'utf-8' });
} catch { /* clipboard may be empty */ }
// Copy text to clipboard
spawnSync('pbcopy', { input: text });
execSync("osascript -e 'tell application \"ChatGPT\" to activate'");
execSync("osascript -e 'delay 0.5'");
const cmd = "osascript " +
"-e 'tell application \"System Events\"' " +
"-e 'keystroke \"v\" using command down' " +
"-e 'delay 0.2' " +
"-e 'keystroke return' " +
"-e 'end tell'";
execSync(cmd);
// Restore original clipboard content
if (clipBackup) {
spawnSync('pbcopy', { input: clipBackup });
}
return [{ Status: 'Success' }];
} catch (err: any) {
return [{ Status: "Error: " + err.message }];
}
},
});
+22
View File
@@ -0,0 +1,22 @@
import { execSync } from 'node:child_process';
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
export const statusCommand = cli({
site: 'chatgpt',
name: 'status',
description: 'Check if ChatGPT Desktop App is running natively on macOS',
domain: 'localhost',
strategy: Strategy.PUBLIC,
browser: false,
args: [],
columns: ['Status'],
func: async (page: IPage | null) => {
try {
const output = execSync("osascript -e 'application \"ChatGPT\" is running'", { encoding: 'utf-8' }).trim();
return [{ Status: output === 'true' ? 'Running' : 'Stopped' }];
} catch {
return [{ Status: 'Error querying application state' }];
}
},
});
+38
View File
@@ -0,0 +1,38 @@
# ChatWise Adapter for OpenCLI
Control the **ChatWise Desktop App** from the terminal via Chrome DevTools Protocol (CDP). ChatWise is an Electron-based multi-LLM client supporting GPT-4, Claude, Gemini, and more.
## Prerequisites
1. Install [ChatWise](https://chatwise.app/).
2. Launch with remote debugging port:
```bash
/Applications/ChatWise.app/Contents/MacOS/ChatWise \
--remote-debugging-port=9228
```
## Setup
```bash
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9228"
```
## Commands
### Diagnostics
- `opencli chatwise status`: Check CDP connection status.
- `opencli chatwise screenshot`: Export DOM + accessibility snapshot.
### Chat
- `opencli chatwise new`: Start a new conversation (`Cmd+N`).
- `opencli chatwise send "message"`: Send a message to the active chat.
- `opencli chatwise read`: Read the current conversation.
- `opencli chatwise ask "prompt"`: Send + wait for response + return it (one-shot).
### AI Features
- `opencli chatwise model`: Get the current AI model.
- `opencli chatwise model gpt-4`: Switch to a different model.
### Organization
- `opencli chatwise history`: List conversations from the sidebar.
- `opencli chatwise export`: Export conversation as Markdown.
+38
View File
@@ -0,0 +1,38 @@
# ChatWise 适配器
通过 Chrome DevTools Protocol (CDP) 在终端中控制 **ChatWise 桌面应用**。ChatWise 是基于 Electron 的多 LLM 客户端,支持 GPT-4、Claude、Gemini 等。
## 前置条件
1. 安装 [ChatWise](https://chatwise.app/)。
2. 通过远程调试端口启动:
```bash
/Applications/ChatWise.app/Contents/MacOS/ChatWise \
--remote-debugging-port=9228
```
## 配置
```bash
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9228"
```
## 命令
### 诊断
- `opencli chatwise status`:检查 CDP 连接状态。
- `opencli chatwise screenshot`:导出 DOM + accessibility 快照。
### 对话
- `opencli chatwise new`:开始新对话(`Cmd+N`)。
- `opencli chatwise send "消息"`:发送消息到当前对话。
- `opencli chatwise read`:读取当前对话内容。
- `opencli chatwise ask "提示词"`:发送 + 等待回复 + 返回结果(一站式)。
### AI 功能
- `opencli chatwise model`:获取当前 AI 模型。
- `opencli chatwise model gpt-4`:切换模型。
### 组织管理
- `opencli chatwise history`:列出 sidebar 会话列表。
- `opencli chatwise export`:导出对话为 Markdown 文件。
+87
View File
@@ -0,0 +1,87 @@
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
export const askCommand = cli({
site: 'chatwise',
name: 'ask',
description: 'Send a prompt and wait for the AI response (send + wait + read)',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [
{ name: 'text', required: true, positional: true, help: 'Prompt to send' },
{ name: 'timeout', required: false, help: 'Max seconds to wait (default: 30)', default: '30' },
],
columns: ['Role', 'Text'],
func: async (page: IPage, kwargs: any) => {
const text = kwargs.text as string;
const timeout = parseInt(kwargs.timeout as string, 10) || 30;
// Snapshot content length
const beforeLen = await page.evaluate(`
(function() {
const msgs = document.querySelectorAll('[data-message-id], [class*="message"], [class*="bubble"]');
return msgs.length;
})()
`);
// Send message
await page.evaluate(`
(function(text) {
let composer = document.querySelector('textarea');
if (!composer) {
const editables = Array.from(document.querySelectorAll('[contenteditable="true"]'));
composer = editables.length > 0 ? editables[editables.length - 1] : null;
}
if (!composer) throw new Error('Could not find input');
composer.focus();
if (composer.tagName === 'TEXTAREA') {
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
setter.call(composer, text);
composer.dispatchEvent(new Event('input', { bubbles: true }));
} else {
document.execCommand('insertText', false, text);
}
})(${JSON.stringify(text)})
`);
await page.wait(0.5);
await page.pressKey('Enter');
// Poll for response
const pollInterval = 2;
const maxPolls = Math.ceil(timeout / pollInterval);
let response = '';
for (let i = 0; i < maxPolls; i++) {
await page.wait(pollInterval);
const result = await page.evaluate(`
(function(prevLen) {
const msgs = document.querySelectorAll('[data-message-id], [class*="message"], [class*="bubble"]');
if (msgs.length <= prevLen) return null;
const last = msgs[msgs.length - 1];
const text = last.innerText || last.textContent;
return text ? text.trim() : null;
})(${beforeLen})
`);
if (result) {
response = result;
break;
}
}
if (!response) {
return [
{ Role: 'User', Text: text },
{ Role: 'System', Text: `No response within ${timeout}s.` },
];
}
return [
{ Role: 'User', Text: text },
{ Role: 'Assistant', Text: response },
];
},
});
+51
View File
@@ -0,0 +1,51 @@
import * as fs from 'node:fs';
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
export const exportCommand = cli({
site: 'chatwise',
name: 'export',
description: 'Export the current ChatWise conversation to a Markdown file',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [
{ name: 'output', required: false, positional: true, help: 'Output file (default: /tmp/chatwise-export.md)' },
],
columns: ['Status', 'File', 'Messages'],
func: async (page: IPage, kwargs: any) => {
const outputPath = (kwargs.output as string) || '/tmp/chatwise-export.md';
const md = await page.evaluate(`
(function() {
const selectors = [
'[data-message-id]',
'[class*="message"]',
'[class*="chat-item"]',
'[class*="bubble"]',
];
for (const sel of selectors) {
const nodes = document.querySelectorAll(sel);
if (nodes.length > 0) {
return Array.from(nodes).map((n, i) => '## Message ' + (i + 1) + '\\n\\n' + (n.innerText || n.textContent).trim()).join('\\n\\n---\\n\\n');
}
}
const main = document.querySelector('main, [role="main"], [class*="chat-container"]');
if (main) return main.innerText || main.textContent;
return document.body.innerText;
})()
`);
fs.writeFileSync(outputPath, '# ChatWise Conversation Export\\n\\n' + md);
return [
{
Status: 'Success',
File: outputPath,
Messages: md.split('## Message').length - 1,
},
];
},
});
+47
View File
@@ -0,0 +1,47 @@
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
export const historyCommand = cli({
site: 'chatwise',
name: 'history',
description: 'List conversation history in ChatWise sidebar',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [],
columns: ['Index', 'Title'],
func: async (page: IPage) => {
const items = await page.evaluate(`
(function() {
const results = [];
const selectors = [
'[class*="sidebar"] [class*="item"]',
'[class*="conversation-list"] a',
'[class*="chat-list"] > *',
'nav a',
'aside a',
'[role="listbox"] [role="option"]',
];
for (const sel of selectors) {
const nodes = document.querySelectorAll(sel);
if (nodes.length > 0) {
nodes.forEach((n, i) => {
const text = (n.textContent || '').trim().substring(0, 100);
if (text) results.push({ Index: i + 1, Title: text });
});
break;
}
}
return results;
})()
`);
if (items.length === 0) {
return [{ Index: 0, Title: 'No history found. Ensure the sidebar is visible.' }];
}
return items;
},
});
+87
View File
@@ -0,0 +1,87 @@
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
export const modelCommand = cli({
site: 'chatwise',
name: 'model',
description: 'Get or switch the active AI model in ChatWise',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [
{ name: 'model_name', required: false, positional: true, help: 'Model to switch to (e.g. gpt-4, claude-3)' },
],
columns: ['Status', 'Model'],
func: async (page: IPage, kwargs: any) => {
const desiredModel = kwargs.model_name as string | undefined;
if (!desiredModel) {
// Read current model
const currentModel = await page.evaluate(`
(function() {
// ChatWise is a multi-LLM client, it typically shows the model name in a dropdown or header
const selectors = [
'[class*="model"] span',
'[class*="Model"] span',
'[data-testid*="model"]',
'button[class*="model"]',
'[aria-label*="Model"]',
'[aria-label*="model"]',
];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el) {
const text = (el.textContent || el.getAttribute('title') || '').trim();
if (text) return text;
}
}
return 'Unknown or Not Found';
})()
`);
return [{ Status: 'Active', Model: currentModel }];
} else {
// Try to switch model
await page.evaluate(`
(function(target) {
const selectors = [
'[class*="model"]',
'[class*="Model"]',
'button[class*="model"]',
];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el) { el.click(); return; }
}
throw new Error('Could not find model selector');
})(${JSON.stringify(desiredModel)})
`);
await page.wait(0.5);
// Find and click the target model in the dropdown
const found = await page.evaluate(`
(function(target) {
const options = document.querySelectorAll('[role="option"], [role="menuitem"], [class*="dropdown-item"], li');
for (const opt of options) {
if ((opt.textContent || '').toLowerCase().includes(target.toLowerCase())) {
opt.click();
return true;
}
}
return false;
})(${JSON.stringify(desiredModel)})
`);
return [
{
Status: found ? 'Switched' : 'Dropdown opened but model not found',
Model: desiredModel,
},
];
}
},
});
+21
View File
@@ -0,0 +1,21 @@
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
export const newCommand = cli({
site: 'chatwise',
name: 'new',
description: 'Start a new conversation in ChatWise',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [],
columns: ['Status'],
func: async (page: IPage) => {
// ChatWise uses standard Electron shortcuts
const isMac = process.platform === 'darwin';
await page.pressKey(isMac ? 'Meta+N' : 'Control+N');
await page.wait(1);
return [{ Status: 'Success' }];
},
});
+42
View File
@@ -0,0 +1,42 @@
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
export const readCommand = cli({
site: 'chatwise',
name: 'read',
description: 'Read the current ChatWise conversation history',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [],
columns: ['Content'],
func: async (page: IPage) => {
const content = await page.evaluate(`
(function() {
// Try common chat message selectors
const selectors = [
'[data-message-id]',
'[class*="message"]',
'[class*="chat-item"]',
'[class*="bubble"]',
'[role="log"] > *',
];
for (const sel of selectors) {
const nodes = document.querySelectorAll(sel);
if (nodes.length > 0) {
return Array.from(nodes).map(n => (n.innerText || n.textContent).trim()).filter(Boolean).join('\\n\\n---\\n\\n');
}
}
// Fallback: main content area
const main = document.querySelector('main, [role="main"], [class*="chat-container"], [class*="conversation"]');
if (main) return main.innerText || main.textContent;
return document.body.innerText;
})()
`);
return [{ Content: content }];
},
});
+33
View File
@@ -0,0 +1,33 @@
import * as fs from 'node:fs';
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
export const screenshotCommand = cli({
site: 'chatwise',
name: 'screenshot',
description: 'Capture a snapshot of the current ChatWise window (DOM + Accessibility tree)',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [
{ name: 'output', required: false, positional: true, help: 'Output file path (default: /tmp/chatwise-snapshot)' },
],
columns: ['Status', 'File'],
func: async (page: IPage, kwargs: any) => {
const basePath = (kwargs.output as string) || '/tmp/chatwise-snapshot';
const snap = await page.snapshot({ compact: true });
const html = await page.evaluate('document.documentElement.outerHTML');
const htmlPath = basePath + '-dom.html';
const snapPath = basePath + '-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 },
];
},
});
+50
View File
@@ -0,0 +1,50 @@
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
export const sendCommand = cli({
site: 'chatwise',
name: 'send',
description: 'Send a message to the active ChatWise conversation',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [{ name: 'text', required: true, positional: true, help: 'Message to send' }],
columns: ['Status', 'InjectedText'],
func: async (page: IPage, kwargs: any) => {
const text = kwargs.text as string;
await page.evaluate(`
(function(text) {
// ChatWise input can be textarea or contenteditable
let composer = document.querySelector('textarea');
if (!composer) {
const editables = Array.from(document.querySelectorAll('[contenteditable="true"]'));
composer = editables.length > 0 ? editables[editables.length - 1] : null;
}
if (!composer) throw new Error('Could not find ChatWise input element');
composer.focus();
if (composer.tagName === 'TEXTAREA') {
// For textarea, set value and dispatch input event
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
nativeInputValueSetter.call(composer, text);
composer.dispatchEvent(new Event('input', { bubbles: true }));
} else {
document.execCommand('insertText', false, text);
}
})(${JSON.stringify(text)})
`);
await page.wait(0.5);
await page.pressKey('Enter');
return [
{
Status: 'Success',
InjectedText: text,
},
];
},
});
+25
View File
@@ -0,0 +1,25 @@
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
export const statusCommand = cli({
site: 'chatwise',
name: 'status',
description: 'Check active CDP connection to ChatWise Desktop',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [],
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,
},
];
},
});
+34
View File
@@ -0,0 +1,34 @@
# OpenAI Codex Adapter for OpenCLI
Control the **OpenAI Codex Desktop App** headless or headfully via Chrome DevTools Protocol (CDP).
Because Codex is built on Electron, OpenCLI can directly drive its internal UI, automate slash commands, and manipulate its AI agent threads.
## Prerequisites
1. You must have the official OpenAI Codex app installed.
2. Launch it via the terminal and expose the remote debugging port:
```bash
# macOS
/Applications/Codex.app/Contents/MacOS/Codex --remote-debugging-port=9222
```
## Setup
Export the CDP endpoint in your shell:
```bash
export OPENCLI_CODEX_CDP_ENDPOINT="http://127.0.0.1:9222"
```
## Commands
### 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` (ideal for building AI automation tools on top of it).
### 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 by sending them, e.g., `opencli codex send "/review"` or `opencli codex send "$imagegen draw a cat"`.
- `opencli codex read`: Extracts the entire current thread history and AI reasoning logs into readable text.
- `opencli codex extract-diff`: Automatically scrapes any visual Patch chunks and Code Diffs the AI generated inside the review UI.
- `opencli codex model`: Get the currently active AI model.
+33
View File
@@ -0,0 +1,33 @@
# OpenAI Codex 桌面端适配器 (OpenCLI)
利用 CDP 协议,直接从命令行/外部脚本接管和操控 **OpenAI Codex 官方桌面版**
因为官方 Codex 是基于 Electron 构建的“多 Agent 协作中心”,通过本适配器,你可以让 AI 自动控制另一个 AI 完成工作,甚至自动截取代码审查的 Diff!
## 前置环境准备
1. 你必须下载并安装了官方原版的 OpenAI Codex 客户端。
2. 必须通过命令行挂载 CDP 调试端口启动它:
```bash
# macOS 启动示例
/Applications/Codex.app/Contents/MacOS/Codex --remote-debugging-port=9222
```
## 配置指南
在你要运行命令的终端里导出环境变量:
```bash
export OPENCLI_CODEX_CDP_ENDPOINT="http://127.0.0.1:9222"
```
## 核心指令
### 探查与调试
- `opencli codex status`: 检查是否成功连上内部 Chromium,获取上下文 Title。
- `opencli codex dump`: 强制剥离整个 App 的内部 DOM 树和无障碍视图并保存到 `/tmp`,是编写复杂自动化 RPA 脚本的终极利刃。
### 自动化执行
- `opencli codex new`: 模拟按下 `Cmd+N`。建立一个彻底干净、隔离了 Git Worktree 的全线并行 Thread。
- `opencli codex send "要发送的话"`: 强行跨越 Shadow Root 找到对应的富文本编辑器并注入提词。
- *高阶技巧*: 你可以直接发送内置宏!例如 `opencli codex send "/review"` 就能触发本工作流的代码审查,或者 `opencli codex send "$imagegen"` 触发技能。
- `opencli codex read`: 完整抓取并提取整个当前 Thread 里的思考过程和对话日志。
- `opencli codex extract-diff`: 专门用于拦截并提取由 AI 建议的 `+` / `-` 代码 Patch 修改块,直接输出结构化数据!
+77
View File
@@ -0,0 +1,77 @@
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
export const askCommand = cli({
site: 'codex',
name: 'ask',
description: 'Send a prompt and wait for the AI response (send + wait + read)',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [
{ name: 'text', required: true, positional: true, help: 'Prompt to send' },
{ name: 'timeout', required: false, help: 'Max seconds to wait for response (default: 60)', default: '60' },
],
columns: ['Role', 'Text'],
func: async (page: IPage, kwargs: any) => {
const text = kwargs.text as string;
const timeout = parseInt(kwargs.timeout as string, 10) || 60;
// Snapshot the current content length before sending
const beforeLen = await page.evaluate(`
(function() {
const turns = document.querySelectorAll('[data-content-search-turn-key]');
return turns.length;
})()
`);
// Inject and send
await page.evaluate(`
(function(text) {
const editables = Array.from(document.querySelectorAll('[contenteditable="true"]'));
const composer = editables.length > 0 ? editables[editables.length - 1] : document.querySelector('textarea');
if (!composer) throw new Error('Could not find Codex input');
composer.focus();
document.execCommand('insertText', false, text);
})(${JSON.stringify(text)})
`);
await page.wait(0.5);
await page.pressKey('Enter');
// Poll for new content
const pollInterval = 3;
const maxPolls = Math.ceil(timeout / pollInterval);
let response = '';
for (let i = 0; i < maxPolls; i++) {
await page.wait(pollInterval);
const result = await page.evaluate(`
(function(prevLen) {
const turns = document.querySelectorAll('[data-content-search-turn-key]');
if (turns.length <= prevLen) return null;
const lastTurn = turns[turns.length - 1];
const text = lastTurn.innerText || lastTurn.textContent;
return text ? text.trim() : null;
})(${beforeLen})
`);
if (result) {
response = result;
break;
}
}
if (!response) {
return [
{ Role: 'User', Text: text },
{ Role: 'System', Text: `No response within ${timeout}s. The agent may still be working.` },
];
}
return [
{ Role: 'User', Text: text },
{ Role: 'Assistant', Text: response },
];
},
});
+28
View File
@@ -0,0 +1,28 @@
import { cli, Strategy } from '../../registry.js';
import * as fs from 'fs';
export const dumpCommand = cli({
site: 'codex',
name: 'dump',
description: 'Dump the DOM and Accessibility tree of Codex for reverse-engineering',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
columns: ['action', 'files'],
func: async (page) => {
// Extract full HTML
const dom = await page.evaluate('document.body.innerHTML');
fs.writeFileSync('/tmp/codex-dom.html', dom);
// Get accessibility snapshot
const snap = await page.snapshot({ interactive: false });
fs.writeFileSync('/tmp/codex-snapshot.json', JSON.stringify(snap, null, 2));
return [
{
action: 'Dom extraction finished',
files: '/tmp/codex-dom.html, /tmp/codex-snapshot.json',
},
];
},
});
+42
View File
@@ -0,0 +1,42 @@
import * as fs from 'node:fs';
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
export const exportCommand = cli({
site: 'codex',
name: 'export',
description: 'Export the current Codex conversation to a Markdown file',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [
{ name: 'output', required: false, positional: true, help: 'Output file (default: /tmp/codex-export.md)' },
],
columns: ['Status', 'File', 'Messages'],
func: async (page: IPage, kwargs: any) => {
const outputPath = (kwargs.output as string) || '/tmp/codex-export.md';
const md = await page.evaluate(`
(function() {
const turns = document.querySelectorAll('[data-content-search-turn-key]');
if (turns.length > 0) {
return Array.from(turns).map((t, i) => '## Turn ' + (i + 1) + '\\n\\n' + (t.innerText || t.textContent).trim()).join('\\n\\n---\\n\\n');
}
const main = document.querySelector('main, [role="main"], [role="log"]');
if (main) return main.innerText || main.textContent;
return document.body.innerText;
})()
`);
fs.writeFileSync(outputPath, '# Codex Conversation Export\\n\\n' + md);
return [
{
Status: 'Success',
File: outputPath,
Messages: md.split('## Turn').length - 1,
},
];
},
});
+48
View File
@@ -0,0 +1,48 @@
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
export const extractDiffCommand = cli({
site: 'codex',
name: 'extract-diff',
description: 'Extract visual code review diff patches from Codex',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
columns: ['File', 'Diff'],
func: async (page) => {
const diffs = await page.evaluate(`
(function() {
const results = [];
// Assuming diffs are rendered with standard diff classes or monaco difference editors
const diffBlocks = document.querySelectorAll('.diff-editor, .monaco-diff-editor, [data-testid="diff-view"]');
diffBlocks.forEach((block, index) => {
// Very roughly scrape text representing additions/deletions mapped from the inner wrapper
results.push({
File: block.getAttribute('data-filename') || \`DiffBlock_\${index+1}\`,
Diff: block.innerText || block.textContent
});
});
// If no structured diffs found, try to find any code blocks labeled as patches
if (results.length === 0) {
const codeBlocks = document.querySelectorAll('pre code.language-diff, pre code.language-patch');
codeBlocks.forEach((code, index) => {
results.push({
File: \`Patch_\${index+1}\`,
Diff: code.innerText || code.textContent
});
});
}
return results;
})()
`);
if (diffs.length === 0) {
return [{ File: 'No diffs found', Diff: 'Try running opencli codex send "/review" first' }];
}
return diffs;
},
});

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