Compare commits

...

118 Commits

Author SHA1 Message Date
jakevin 812f27a05e chore(release): 1.5.5 (#565)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
2026-03-28 22:19:59 +08:00
jakevin ab0af2de5c feat(exit-codes): Unix-standard process exit codes for all error types (#564)
* feat(exit-codes): add Unix-standard exit codes to all CliError types

Introduce EXIT_CODES constant table (sysexits.h conventions) and wire
exitCode into every CliError subclass so the process exit code reflects
the semantic type of failure:

  0   success (default)
  1   generic / unexpected error
  2   argument / usage error        (ArgumentError)
 66   empty result / not found      (EmptyResultError, SelectorError)
 69   service unavailable           (BrowserConnectError, AdapterLoadError)
 77   permission / auth required    (AuthRequiredError)
 78   configuration error           (ConfigError)
124   timeout                       (TimeoutError)
130   Ctrl-C / SIGINT               (unchanged, tui.ts)

resolveExitCode() in commanderAdapter.ts reads err.exitCode for typed
CliErrors, and falls back to pattern-matching message text for untyped
adapter errors (auth pattern → 77, not-found pattern → 66, else → 1).

Shell scripts can now distinguish error categories:
  opencli spotify status || echo "exit $?"   # 69 if browser not running
  opencli github issues --repo x 2>/dev/null; [ $? -eq 77 ] && opencli github auth

* fix(exit-codes): address review findings

- TIMEOUT: change from 124 → 75 (EX_TEMPFAIL); 124 is bash timeout(1)'s
  own exit code, creating ambiguity when shell runs `timeout 30 opencli`
- SelectorError: change from EMPTY_RESULT(66) → GENERIC_ERROR(1); a
  missing DOM selector is an adapter bug, not a user "no data" condition
- normalizeArgValue: throw ArgumentError instead of bare CliError so
  invalid bool args correctly exit with USAGE_ERROR(2) not GENERIC_ERROR(1)
- resolveExitCode: explicitly map 'http' classification to GENERIC_ERROR
  to keep exit-code path in sync with the render path
- tui.ts: replace hardcoded process.exit(130) with EXIT_CODES.INTERRUPTED

* feat(exit-codes): replace all hardcoded exit numbers with EXIT_CODES constants

Extend the exit code system to cover every process exit point in the codebase.
No magic numbers remain — all exit codes are now referenced by name.

Semantic upgrades beyond pure renaming:
- plugin update missing args  → USAGE_ERROR (2) instead of 1
- plugin update conflicting   → USAGE_ERROR (2) instead of 1
- opencli install <unknown>   → USAGE_ERROR (2) instead of 1
- unknown command fallback    → USAGE_ERROR (2) instead of 1
- record with no candidates   → EMPTY_RESULT (66) instead of 1
- external CLI install fail   → SERVICE_UNAVAIL (69) instead of 1
- daemon EADDRINUSE           → SERVICE_UNAVAIL (69) instead of 1

Files touched: cli.ts, external.ts, daemon.ts, main.ts,
               clis/antigravity/serve.ts
2026-03-28 22:16:42 +08:00
jakevin 5c655ee3c8 feat(sinafinance): rewrite stock as public API, no browser required (#563)
* feat(sinafinance): rewrite stock as public API adapter

Replace browser-based DOM scraping with direct Sina public APIs:
  suggest3.sinajs.cn — symbol search (GBK, no auth)
  hq.sinajs.cn       — real-time quote (GBK, no auth)

Strategy.PUBLIC, browser: false — no Chrome or login required.
Supports A股 (sh/sz), 港股 (hk prefix), 美股 (gb_ prefix).
US MarketCap parsed from hq field [12]; formatted as T/B/M.

* feat(exit-codes): add Unix-standard exit codes to all CliError types

Introduce EXIT_CODES constant table (sysexits.h conventions) and wire
exitCode into every CliError subclass so the process exit code reflects
the semantic type of failure:

  0   success (default)
  1   generic / unexpected error
  2   argument / usage error        (ArgumentError)
 66   empty result / not found      (EmptyResultError, SelectorError)
 69   service unavailable           (BrowserConnectError, AdapterLoadError)
 77   permission / auth required    (AuthRequiredError)
 78   configuration error           (ConfigError)
124   timeout                       (TimeoutError)
130   Ctrl-C / SIGINT               (unchanged, tui.ts)

resolveExitCode() in commanderAdapter.ts reads err.exitCode for typed
CliErrors, and falls back to pattern-matching message text for untyped
adapter errors (auth pattern → 77, not-found pattern → 66, else → 1).

Shell scripts can now distinguish error categories:
  opencli spotify status || echo "exit $?"   # 69 if browser not running
  opencli github issues --repo x 2>/dev/null; [ $? -eq 77 ] && opencli github auth

* review: regex escape sym, fix change precision, optimize suggest type param
2026-03-28 21:59:44 +08:00
yichuanzhao99-ctrl 0b15561025 添加新浪财经行情及滚动新闻抓取 (#546)
* 添加新浪财经行情及滚动新闻抓取

* review: fix injection vuln, dead code, typos, hardcoded waits

rolling-news:
- Remove dead dateToTimestampParams function and unused CliError import
- Fix column field name typo: clomn → column
- Replace page.wait(5) with selector-based wait
- Remove all commented-out code

stock:
- Fix P0 JS injection: use JSON.stringify() to safely embed args.key/market
- Add null guard for inputEl before calling .focus()
- waitForElement returns null instead of throwing on timeout
- Replace page.wait(5) with selector-based wait
- Extract MARKET_CN/HK/US as named constants
- Throw CliError on NOT_FOUND instead of silent empty return

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-28 21:46:33 +08:00
Cjy-CN f9857f8c7b fix: remove invalid state: 'normal' from chrome.windows.create() (#559)
* fix: remove invalid `state: 'normal'` from chrome.windows.create()

Chrome 146+ rejects 'normal' as an invalid value for the `state` parameter
in chrome.windows.create(). This causes the error:

    Error: Invalid value for state

Root cause analysis:
- The Chrome Extensions API documentation states that `state` parameter
  only accepts 'minimized', 'maximized', and 'fullscreen' as input values
- While WindowState enum includes 'normal', it's meant for reading window
  state, not for setting it during creation
- Chrome 146 enforces stricter validation on the `state` parameter
- When `state` is omitted, the window defaults to 'normal' state anyway

Fix: Remove the `state: 'normal'` parameter entirely. The window will
default to normal state without explicitly setting it.

Tested: `opencli doctor` and `opencli bilibili hot` now work correctly
on Chrome 146.0.7680.165.

* build: rebuild dist after removing state: 'normal'

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-28 21:25:31 +08:00
jakevin c9e29d9f22 chore(release): 1.5.4 (#558)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
2026-03-28 20:22:24 +08:00
AstroHan 75ddb6319b fix(extension): probe daemon before WebSocket to eliminate console noise (#534)
* fix(extension): probe daemon via HTTP before WebSocket to eliminate console noise

When the daemon is offline, `new WebSocket()` logs uncatchable
ERR_CONNECTION_REFUSED errors to Chrome's extension error page.
Add `probeAndConnect()` that checks daemon reachability with a
silent `fetch(HEAD)` before attempting WebSocket connection.

All three auto-connect paths (initialize, keepalive alarm, eager
reconnect) now go through the probe, eliminating the error noise
entirely.

Closes #505

* refactor(extension): inline probe into connect(), add /ping to daemon

Instead of a separate probeAndConnect() wrapper that all call sites had
to remember to use, bake the HTTP probe directly into connect() itself.
This makes the guard impossible to accidentally skip when adding new
connection paths in the future.

Also adds a dedicated GET /ping endpoint to the daemon (no X-OpenCLI
header required) so the probe has a clear semantic contract instead of
relying on a 403 side-effect from the root path.

- daemon: GET /ping → 200 {ok:true}, no auth needed, placed before the
  X-OpenCLI header check; only chrome-extension:// and no-origin
  requests reach it (origin check is still enforced above)
- background: connect() is now async; probes /ping with a 1 s timeout
  before new WebSocket(); all call sites (initialize, keepalive alarm,
  scheduleReconnect) remain unchanged
- probeAndConnect() removed — no longer needed

* fix(extension/daemon): address review feedback on probe refactor

- protocol.ts: replace DAEMON_HTTP_URL with DAEMON_PING_URL (clearer
  semantics, single source of truth for the health-check URL)
- background.ts: import DAEMON_PING_URL from protocol instead of
  defining a local constant; check res.ok so an unexpected non-200
  response doesn't fall through to WebSocket; annotate all fire-and-
  forget connect() call sites with `void` to make intent explicit
- daemon.ts: add security comment on /ping documenting the timing
  side-channel tradeoff (loopback-only, accepted risk)

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-28 20:15:51 +08:00
jakevin 5ec34ebc53 feat(hub): add vercel CLI to external CLI hub (#556) 2026-03-28 19:57:49 +08:00
jakevin 959ec5fe1c feat(hub): add lark-cli to external CLI hub (#555) 2026-03-28 19:51:30 +08:00
luo jiyin dbcfbc3bb6 fix(skill): use relative links in migration skill (#551) 2026-03-28 16:45:02 +08:00
pi-dal 5ae9658a21 fix(manifest): preserve dynamic TS arg metadata in help output (#536)
* fix(manifest): preserve runtime arg metadata

* refactor(manifest): build TS metadata from runtime commands

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-28 14:23:16 +08:00
jakevin f415e829a9 docs(readme): restructure quick start, full command list, anti-detection highlight (#544)
* docs(readme): restructure quick start, expand built-in commands, add CLI Hub auto-install note

* docs(readme): highlight anti-CDP fingerprinting and risk-control measures
2026-03-28 12:29:10 +08:00
jakevin 210e6fabb7 docs: CLI Hub intro in header, restore auto-install note
* chore(release): 1.5.2

* test(e2e): stabilize output format checks

* docs: add perf smart-wait design spec (waitForCapture + selector wait + backoff)

* docs: add perf smart-wait implementation plan

* feat(perf): add waitForCaptureJs and waitForSelectorJs to dom-helpers

* feat(perf): extend WaitOptions with selector, add waitForCapture to IPage

* feat(perf): implement waitForCapture() and wait({ selector }) in Page

* feat(perf): implement waitForCapture() and wait({ selector }) in CDPPage

* feat(perf): stepIntercept uses installInterceptor+waitForCapture+getInterceptedRequests

* fix(perf): replace wait(N) with waitForCapture(N) in 7 INTERCEPT adapters

* feat(perf): daemon cold-start uses exponential backoff [50..3000ms]

* fix(perf): replace wait(5) with wait({ selector }) in 15 Twitter UI adapters

* fix(perf): replace wait(N) with wait({ selector }) in medium/substack/bloomberg/sinablog

* fix(types): add waitForCapture to IPage mock helpers in tests

* docs: simplify README to 50-line overview with docs link

Remove redundant command table (already in docs/adapters/index.md).
Keep badges, quick-start, and star history only.

* fix(perf): CDPPage smart wait + MutationObserver selector wait

- CDPPage.wait(N>=1) now uses waitForDomStableJs instead of fixed sleep,
  matching Page.wait() behavior and saving unnecessary idle time
- waitForSelectorJs switches from 100ms polling to MutationObserver,
  resolving instantly when the target element appears in the DOM
- Update dom-helpers tests to stub MutationObserver for Node eval context

* docs: move built-in commands table to docs/adapters/index.md

Replace the 70-row site/command table in README with a one-line link.
All other README sections (Highlights, Why opencli, Quick Start,
External CLI Hub, Desktop App Adapters, Download, Plugins, AI Agents,
Troubleshooting) are preserved unchanged.

* docs: move Quick Start before Prerequisites, tone down Electron promo copy

- Reorder sections: Why opencli → Quick Start → Prerequisites
  so users see "how to use" before "what you need"
- Replace "The Most Powerful Update Has Arrived!" marketing copy
  with a plain one-liner description of the Electron feature
- Remove redundant Bun dev/test commands from Prerequisites
  (developer-only content, not relevant to end users)
- Add "(requires Extension)" hint to browser command in Quick Start

* docs: polish Quick Start — one-line source install, Verify setup section

* docs: show 4 sample adapters in Built-in Commands with link to full list

* docs: polish README — Try it out under Verify setup, trim examples, CLI Hub as top-level section

* docs: add CLI Hub intro line in header, restore auto-install note in CLI Hub section
2026-03-28 12:19:15 +08:00
jakevin 73a8508972 docs: polish README — Try it out, trim examples, CLI Hub section
* chore(release): 1.5.2

* test(e2e): stabilize output format checks

* docs: add perf smart-wait design spec (waitForCapture + selector wait + backoff)

* docs: add perf smart-wait implementation plan

* feat(perf): add waitForCaptureJs and waitForSelectorJs to dom-helpers

* feat(perf): extend WaitOptions with selector, add waitForCapture to IPage

* feat(perf): implement waitForCapture() and wait({ selector }) in Page

* feat(perf): implement waitForCapture() and wait({ selector }) in CDPPage

* feat(perf): stepIntercept uses installInterceptor+waitForCapture+getInterceptedRequests

* fix(perf): replace wait(N) with waitForCapture(N) in 7 INTERCEPT adapters

* feat(perf): daemon cold-start uses exponential backoff [50..3000ms]

* fix(perf): replace wait(5) with wait({ selector }) in 15 Twitter UI adapters

* fix(perf): replace wait(N) with wait({ selector }) in medium/substack/bloomberg/sinablog

* fix(types): add waitForCapture to IPage mock helpers in tests

* docs: simplify README to 50-line overview with docs link

Remove redundant command table (already in docs/adapters/index.md).
Keep badges, quick-start, and star history only.

* fix(perf): CDPPage smart wait + MutationObserver selector wait

- CDPPage.wait(N>=1) now uses waitForDomStableJs instead of fixed sleep,
  matching Page.wait() behavior and saving unnecessary idle time
- waitForSelectorJs switches from 100ms polling to MutationObserver,
  resolving instantly when the target element appears in the DOM
- Update dom-helpers tests to stub MutationObserver for Node eval context

* docs: move built-in commands table to docs/adapters/index.md

Replace the 70-row site/command table in README with a one-line link.
All other README sections (Highlights, Why opencli, Quick Start,
External CLI Hub, Desktop App Adapters, Download, Plugins, AI Agents,
Troubleshooting) are preserved unchanged.

* docs: move Quick Start before Prerequisites, tone down Electron promo copy

- Reorder sections: Why opencli → Quick Start → Prerequisites
  so users see "how to use" before "what you need"
- Replace "The Most Powerful Update Has Arrived!" marketing copy
  with a plain one-liner description of the Electron feature
- Remove redundant Bun dev/test commands from Prerequisites
  (developer-only content, not relevant to end users)
- Add "(requires Extension)" hint to browser command in Quick Start

* docs: polish Quick Start — one-line source install, Verify setup section

* docs: show 4 sample adapters in Built-in Commands with link to full list

* docs: polish README — Try it out under Verify setup, trim examples, CLI Hub as top-level section
2026-03-28 12:13:29 +08:00
jakevin 0fc3bc2d85 docs: show 4 sample adapters in Built-in Commands
* chore(release): 1.5.2

* test(e2e): stabilize output format checks

* docs: add perf smart-wait design spec (waitForCapture + selector wait + backoff)

* docs: add perf smart-wait implementation plan

* feat(perf): add waitForCaptureJs and waitForSelectorJs to dom-helpers

* feat(perf): extend WaitOptions with selector, add waitForCapture to IPage

* feat(perf): implement waitForCapture() and wait({ selector }) in Page

* feat(perf): implement waitForCapture() and wait({ selector }) in CDPPage

* feat(perf): stepIntercept uses installInterceptor+waitForCapture+getInterceptedRequests

* fix(perf): replace wait(N) with waitForCapture(N) in 7 INTERCEPT adapters

* feat(perf): daemon cold-start uses exponential backoff [50..3000ms]

* fix(perf): replace wait(5) with wait({ selector }) in 15 Twitter UI adapters

* fix(perf): replace wait(N) with wait({ selector }) in medium/substack/bloomberg/sinablog

* fix(types): add waitForCapture to IPage mock helpers in tests

* docs: simplify README to 50-line overview with docs link

Remove redundant command table (already in docs/adapters/index.md).
Keep badges, quick-start, and star history only.

* fix(perf): CDPPage smart wait + MutationObserver selector wait

- CDPPage.wait(N>=1) now uses waitForDomStableJs instead of fixed sleep,
  matching Page.wait() behavior and saving unnecessary idle time
- waitForSelectorJs switches from 100ms polling to MutationObserver,
  resolving instantly when the target element appears in the DOM
- Update dom-helpers tests to stub MutationObserver for Node eval context

* docs: move built-in commands table to docs/adapters/index.md

Replace the 70-row site/command table in README with a one-line link.
All other README sections (Highlights, Why opencli, Quick Start,
External CLI Hub, Desktop App Adapters, Download, Plugins, AI Agents,
Troubleshooting) are preserved unchanged.

* docs: move Quick Start before Prerequisites, tone down Electron promo copy

- Reorder sections: Why opencli → Quick Start → Prerequisites
  so users see "how to use" before "what you need"
- Replace "The Most Powerful Update Has Arrived!" marketing copy
  with a plain one-liner description of the Electron feature
- Remove redundant Bun dev/test commands from Prerequisites
  (developer-only content, not relevant to end users)
- Add "(requires Extension)" hint to browser command in Quick Start

* docs: polish Quick Start — one-line source install, Verify setup section

* docs: show 4 sample adapters in Built-in Commands with link to full list
2026-03-28 12:06:56 +08:00
jakevin bc42c8b258 docs: polish Quick Start — one-line source install, Verify setup section
* chore(release): 1.5.2

* test(e2e): stabilize output format checks

* docs: add perf smart-wait design spec (waitForCapture + selector wait + backoff)

* docs: add perf smart-wait implementation plan

* feat(perf): add waitForCaptureJs and waitForSelectorJs to dom-helpers

* feat(perf): extend WaitOptions with selector, add waitForCapture to IPage

* feat(perf): implement waitForCapture() and wait({ selector }) in Page

* feat(perf): implement waitForCapture() and wait({ selector }) in CDPPage

* feat(perf): stepIntercept uses installInterceptor+waitForCapture+getInterceptedRequests

* fix(perf): replace wait(N) with waitForCapture(N) in 7 INTERCEPT adapters

* feat(perf): daemon cold-start uses exponential backoff [50..3000ms]

* fix(perf): replace wait(5) with wait({ selector }) in 15 Twitter UI adapters

* fix(perf): replace wait(N) with wait({ selector }) in medium/substack/bloomberg/sinablog

* fix(types): add waitForCapture to IPage mock helpers in tests

* docs: simplify README to 50-line overview with docs link

Remove redundant command table (already in docs/adapters/index.md).
Keep badges, quick-start, and star history only.

* fix(perf): CDPPage smart wait + MutationObserver selector wait

- CDPPage.wait(N>=1) now uses waitForDomStableJs instead of fixed sleep,
  matching Page.wait() behavior and saving unnecessary idle time
- waitForSelectorJs switches from 100ms polling to MutationObserver,
  resolving instantly when the target element appears in the DOM
- Update dom-helpers tests to stub MutationObserver for Node eval context

* docs: move built-in commands table to docs/adapters/index.md

Replace the 70-row site/command table in README with a one-line link.
All other README sections (Highlights, Why opencli, Quick Start,
External CLI Hub, Desktop App Adapters, Download, Plugins, AI Agents,
Troubleshooting) are preserved unchanged.

* docs: move Quick Start before Prerequisites, tone down Electron promo copy

- Reorder sections: Why opencli → Quick Start → Prerequisites
  so users see "how to use" before "what you need"
- Replace "The Most Powerful Update Has Arrived!" marketing copy
  with a plain one-liner description of the Electron feature
- Remove redundant Bun dev/test commands from Prerequisites
  (developer-only content, not relevant to end users)
- Add "(requires Extension)" hint to browser command in Quick Start

* docs: polish Quick Start — one-line source install, Verify setup section
2026-03-28 12:05:59 +08:00
jakevin c4e7a94bc4 docs: README usability — Quick Start first, tone down promo copy
* chore(release): 1.5.2

* test(e2e): stabilize output format checks

* docs: add perf smart-wait design spec (waitForCapture + selector wait + backoff)

* docs: add perf smart-wait implementation plan

* feat(perf): add waitForCaptureJs and waitForSelectorJs to dom-helpers

* feat(perf): extend WaitOptions with selector, add waitForCapture to IPage

* feat(perf): implement waitForCapture() and wait({ selector }) in Page

* feat(perf): implement waitForCapture() and wait({ selector }) in CDPPage

* feat(perf): stepIntercept uses installInterceptor+waitForCapture+getInterceptedRequests

* fix(perf): replace wait(N) with waitForCapture(N) in 7 INTERCEPT adapters

* feat(perf): daemon cold-start uses exponential backoff [50..3000ms]

* fix(perf): replace wait(5) with wait({ selector }) in 15 Twitter UI adapters

* fix(perf): replace wait(N) with wait({ selector }) in medium/substack/bloomberg/sinablog

* fix(types): add waitForCapture to IPage mock helpers in tests

* docs: simplify README to 50-line overview with docs link

Remove redundant command table (already in docs/adapters/index.md).
Keep badges, quick-start, and star history only.

* fix(perf): CDPPage smart wait + MutationObserver selector wait

- CDPPage.wait(N>=1) now uses waitForDomStableJs instead of fixed sleep,
  matching Page.wait() behavior and saving unnecessary idle time
- waitForSelectorJs switches from 100ms polling to MutationObserver,
  resolving instantly when the target element appears in the DOM
- Update dom-helpers tests to stub MutationObserver for Node eval context

* docs: move built-in commands table to docs/adapters/index.md

Replace the 70-row site/command table in README with a one-line link.
All other README sections (Highlights, Why opencli, Quick Start,
External CLI Hub, Desktop App Adapters, Download, Plugins, AI Agents,
Troubleshooting) are preserved unchanged.

* docs: move Quick Start before Prerequisites, tone down Electron promo copy

- Reorder sections: Why opencli → Quick Start → Prerequisites
  so users see "how to use" before "what you need"
- Replace "The Most Powerful Update Has Arrived!" marketing copy
  with a plain one-liner description of the Electron feature
- Remove redundant Bun dev/test commands from Prerequisites
  (developer-only content, not relevant to end users)
- Add "(requires Extension)" hint to browser command in Quick Start
2026-03-28 11:57:34 +08:00
jakevin 3b0dcfddf0 docs: move built-in commands table to docs/adapters/index.md
* chore(release): 1.5.2

* test(e2e): stabilize output format checks

* docs: add perf smart-wait design spec (waitForCapture + selector wait + backoff)

* docs: add perf smart-wait implementation plan

* feat(perf): add waitForCaptureJs and waitForSelectorJs to dom-helpers

* feat(perf): extend WaitOptions with selector, add waitForCapture to IPage

* feat(perf): implement waitForCapture() and wait({ selector }) in Page

* feat(perf): implement waitForCapture() and wait({ selector }) in CDPPage

* feat(perf): stepIntercept uses installInterceptor+waitForCapture+getInterceptedRequests

* fix(perf): replace wait(N) with waitForCapture(N) in 7 INTERCEPT adapters

* feat(perf): daemon cold-start uses exponential backoff [50..3000ms]

* fix(perf): replace wait(5) with wait({ selector }) in 15 Twitter UI adapters

* fix(perf): replace wait(N) with wait({ selector }) in medium/substack/bloomberg/sinablog

* fix(types): add waitForCapture to IPage mock helpers in tests

* docs: simplify README to 50-line overview with docs link

Remove redundant command table (already in docs/adapters/index.md).
Keep badges, quick-start, and star history only.

* fix(perf): CDPPage smart wait + MutationObserver selector wait

- CDPPage.wait(N>=1) now uses waitForDomStableJs instead of fixed sleep,
  matching Page.wait() behavior and saving unnecessary idle time
- waitForSelectorJs switches from 100ms polling to MutationObserver,
  resolving instantly when the target element appears in the DOM
- Update dom-helpers tests to stub MutationObserver for Node eval context

* docs: move built-in commands table to docs/adapters/index.md

Replace the 70-row site/command table in README with a one-line link.
All other README sections (Highlights, Why opencli, Quick Start,
External CLI Hub, Desktop App Adapters, Download, Plugins, AI Agents,
Troubleshooting) are preserved unchanged.
2026-03-28 11:51:29 +08:00
jakevin 4f13484aa3 chore(release): 1.5.3 (#533)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
2026-03-28 11:02:09 +08:00
AstroHan 5ab3f5d7d5 fix(extension): change automation window state from minimized to normal (#531)
chrome.windows.create rejects state:'minimized' when combined with
width/height (Chrome API constraint). Revert to state:'normal' to fix
the "Invalid value for state" error. The 30s idle timeout from #521
is preserved.

Fixes #526
2026-03-28 10:57:40 +08:00
jakevin 55c3259f28 refactor: slim CI matrix, shared utils, unified logging, remove __test__ leak (#525)
* refactor: slim CI matrix, extract shared utils, unify logging, remove __test__ from public API

- CI: unit-test uses dynamic matrix (PR=ubuntu+22 only, push=full 3OS×2Node);
  adapter-test reduced to ubuntu-latest (OS doesn't affect pure unit tests)
- _shared/common.ts: add sleep() and clampToRange() shared adapter utilities;
  douban/utils.ts and sinablog/utils.ts now use clampToRange instead of duplicate clampLimit
- browser/daemon-client.ts: replace inline setTimeout Promise with local sleep()
- execution.ts: replace conditional console.error with log.debug
- browser/index.ts: remove __test__ from public barrel export;
  browser.test.ts now imports internal helpers directly from source files

* fix: remove unused afterEach import, fix schedule/dispatch CI matrix, clarify clampToRange docs

* refactor: move sleep to src/utils.ts, simplify clamp signature to match lodash convention
2026-03-28 02:19:07 +08:00
jakevin 70bd87b98c perf: smart-wait — waitForCapture, wait({ selector }), daemon backoff
- waitForCapture(): polls window.__opencli_xhr instead of DOM-stable; fixes INTERCEPT adapters returning empty after smart-wait refactor
- wait({ selector }): MutationObserver-based wait; resolves instantly on element insertion
- CDPPage.wait(N): smart DOM-stable wait (matches Page.wait behavior)
- Daemon cold-start: exponential backoff [50..3000ms]
- README: simplified to 50-line overview
2026-03-28 02:16:13 +08:00
wangsl ea0cf4d0b0 fix(network): honor proxy env for node requests (#512)
* fix(network): honor proxy env for node requests

* fix(network): honor default ports in NO_PROXY

* refactor(network): normalize proxy config handling

* refactor(network): delegate proxy env handling to undici

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-28 01:20:39 +08:00
jakevin 9919f03aed chore(release): 1.5.2 (#523)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
* chore(release): 1.5.2

* test(e2e): stabilize output format checks
2026-03-28 00:48:27 +08:00
jakevin 3834292871 perf: smart pre-navigation, DOM-stable waits, in-memory URL tracking (#524)
* perf: smart pre-navigation — skip redundant domain nav + remove hardcoded 2s wait

- Add `getCurrentUrl()` to IPage, Page, and CDPPage to check current browser URL
- Skip pre-navigation entirely if the browser is already on the target domain
- Remove the hardcoded `page.wait(2)` after pre-navigation — `page.goto()` already
  includes smart DOM-settle detection via `waitForDomStable`, making the fixed
  2-second sleep redundant
- Saves ~2s per browser command in the common case (consecutive commands on the
  same site), and ~1-2s even on cold navigation

* perf: smart page.wait() — DOM-stable early return for waits >= 1s

For page.wait(N) where N >= 1 second, use DOM MutationObserver-based
stability detection instead of a fixed sleep. The original wait time
becomes a hard cap, but the call returns as soon as the DOM stops
mutating (500ms quiet period).

This benefits ~200 hardcoded sleep calls across ~40 adapters without
changing any adapter code. A typical page.wait(5) now completes in
<1s when the page is already stable, instead of always waiting 5s.

Short waits (< 1s) are kept as fixed sleeps — these are typically
UI animation delays or anti-bot throttling where DOM-ready is irrelevant.

* refactor: getCurrentUrl() uses in-memory tracking instead of round-trip

Replace the sendCommand('exec', 'window.location.href') call with a
local _lastUrl field set during goto(). This eliminates a daemon HTTP
round-trip for the domain check, making isAlreadyOnDomain() zero-cost.

On fresh tabs (about:blank), _lastUrl is null so we correctly fall
through to navigation — no special-casing needed.
2026-03-28 00:46:30 +08:00
AstroHan 53b06db53d fix(browser): retry settle probe after SPA client-side redirect (#517)
* fix(browser): retry settle probe after SPA client-side redirect

SPA sites like creator.xiaohongshu.com can trigger a client-side
redirect after chrome.tabs reports status 'complete', invalidating
the CDP target. The waitForDomStable probe in page.goto() was
unprotected, causing -32000 "Inspected target navigated or closed".

Wrap the settle probe in try/catch with a single 200ms-delayed retry,
consistent with the existing stealth injection error handling pattern.
The retry gives the SPA redirect time to complete, while the outer
catch ensures settle failure never crashes goto() since navigation
itself already succeeded.

Closes #502

* review: narrow settle retry to target redirects

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-28 00:29:45 +08:00
jakevin f79d5ab838 fix(ci): stabilize public command and plugin e2e checks (#522)
* test(e2e): accept current apple podcasts fetch errors

* fix(ci): stabilize plugin and public command checks

---------

Co-authored-by: pi-dal <hi@pi-dal.com>
2026-03-28 00:19:33 +08:00
jakevin 6e90356649 chore(extension): bump version to 1.5.1 (#519) 2026-03-28 00:09:30 +08:00
AstroHan 0085d63fb8 fix(weread): resolve shelf auth fallback (#518)
* fix(weread): resolve shelf auth fallback

* chore(docs): move local issue notes out of pr

* fix(weread): classify session expiry as auth required

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-28 00:09:28 +08:00
jakevin 0f3021a086 fix: relax extension version check, enable all adapter tests (#520)
* feat: zero onboarding, extension version check, and update notifier

- Fail-fast guard in execution.ts: when daemon is running but extension
  is not connected, immediately surface a setup guide instead of waiting
  for the 30s connect timeout

- Extension version handshake: extension sends `hello` with its version
  on WebSocket connect; daemon stores it and exposes via /status; CLI
  warns on mismatch in both execution path and `opencli doctor`

- `opencli doctor` now shows extension version inline and reports
  version mismatch as an actionable issue

- Non-blocking npm update checker: registers a process exit hook so the
  update notice appears after command output (same pattern as npm/gh/yarn);
  background fetch writes to ~/.opencli/update-check.json for next run

- postinstall: print Browser Bridge setup instructions after shell
  completion install for first-time global install users

Bug fixes caught in review:
- discover.ts: add AbortController timeout to checkDaemonStatus() fetch,
  move clearTimeout after res.json() to cover body streaming
- daemon.ts: clear extensionVersion and reject pending requests in
  ws.on('error') handler, not just ws.on('close')
- update-check.ts: skip update notice when process exits with non-zero
  code; read cache once at module load to avoid double disk I/O;
  guard isNewer() against NaN from pre-release version strings

* fix: relax extension version check to major-only in doctor, remove from hot path

* test: enable all adapter tests via wildcard glob, fix apple-podcasts url field

* fix: clearTimeout in finally block, reset extensionVersion on reconnect, fix e2e regex
2026-03-28 00:08:10 +08:00
jakevin a1561e5361 fix(extension): minimize automation window + reduce idle timeout to 30s (#521)
- Create automation window with `state: 'minimized'` so it never
  appears in the user's taskbar or steals visual attention
- Reduce idle timeout from 120s to 30s — window closes quickly after
  the last command finishes, instead of lingering for 2 minutes
- CDP debugger works fine on minimized windows, no functional impact

Fixes the user-visible issue of a blank data:text/html tab appearing
during command execution.
2026-03-28 00:03:44 +08:00
jakevin f9f11e4b17 chore(release): 1.5.1 (#513)
Release / release (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
2026-03-27 19:25:37 +08:00
jakevin 5bd0497244 refactor(plugin): make plugin installs transactional (#509)
* feat(plugin): stage installs before promote

* feat(plugin): make remote updates transactional

* fix(plugin): rollback relink failures

* refactor(plugin): unify transactional publish flow

* refactor(plugin): extract publish pipeline helpers

* refactor(plugin): add structured source model

* refactor(plugin): promote lockfile to structured sources

* fix(plugin): preserve lock reads when migration rewrite fails

* fix(plugin): write lockfiles atomically

* test(plugin): make source helper assertions cross-platform
2026-03-27 18:25:56 +08:00
Guyue a2d1199b50 fix(v2ex): fetch hot topics through browser context (#493)
* Update hot.yaml

* review: fetch v2ex hot data within browser context

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-27 17:50:44 +08:00
jakevin cf99c61df5 perf: smart pre-navigation — skip redundant nav + remove 2s wait (#507)
* perf: smart pre-navigation — skip redundant domain nav + remove hardcoded 2s wait

- Add `getCurrentUrl()` to IPage, Page, and CDPPage to check current browser URL
- Skip pre-navigation entirely if the browser is already on the target domain
- Remove the hardcoded `page.wait(2)` after pre-navigation — `page.goto()` already
  includes smart DOM-settle detection via `waitForDomStable`, making the fixed
  2-second sleep redundant
- Saves ~2s per browser command in the common case (consecutive commands on the
  same site), and ~1-2s even on cold navigation

* perf: smart page.wait() — DOM-stable early return for waits >= 1s

For page.wait(N) where N >= 1 second, use DOM MutationObserver-based
stability detection instead of a fixed sleep. The original wait time
becomes a hard cap, but the call returns as soon as the DOM stops
mutating (500ms quiet period).

This benefits ~200 hardcoded sleep calls across ~40 adapters without
changing any adapter code. A typical page.wait(5) now completes in
<1s when the page is already stable, instead of always waiting 5s.

Short waits (< 1s) are kept as fixed sleeps — these are typically
UI animation delays or anti-bot throttling where DOM-ready is irrelevant.

* refactor: getCurrentUrl() uses in-memory tracking instead of round-trip

Replace the sendCommand('exec', 'window.location.href') call with a
local _lastUrl field set during goto(). This eliminates a daemon HTTP
round-trip for the domain check, making isAlreadyOnDomain() zero-cost.

On fresh tabs (about:blank), _lastUrl is null so we correctly fall
through to navigation — no special-casing needed.
2026-03-27 17:42:48 +08:00
AlexYue 70ad5700c9 feat(plugin): support multi-source plugin install (ssh, git@, generic https) (#504)
Extends parseSource() to accept any git-cloneable URL, not just GitHub:
- ssh://git@host/path/repo.git
- git@host:user/repo.git (SCP-style)
- https://any-host.com/path/repo.git

GitHub shorthand (github:user/repo) and local paths continue to work.
Updated error messages, CLI description, docs, and added 7 new unit tests.

Closes #492
2026-03-27 17:04:57 +08:00
AlexYue 2ad1215ac2 fix(plugin): prevent raw .ts import crash when esbuild transpilation fails (#500) (#503)
When a TS plugin is installed but esbuild is unavailable or transpilation
fails silently, the plugin discovery would attempt to import() the raw
.ts file, causing 'Unknown file extension .ts' on production Node.js.

Changes:
- discovery.ts: Skip raw .ts import when no compiled .js exists; show
  an actionable warning guiding the user to re-transpile or install esbuild
- plugin.ts: Upgrade esbuild-not-found from debug to warn level; log
  the outer catch error instead of silently swallowing it

Closes #500
2026-03-27 16:57:59 +08:00
AstroHan ee59750ddb fix(execution): apply timeout to non-browser commands (#383)
Non-browser commands (`browser: false`) ran without any timeout
protection, even when `timeoutSeconds` was explicitly set. This wraps
the non-browser execution path with `runWithTimeout()` when the
adapter defines a positive `timeoutSeconds`.

Also adds an optional `hint` parameter to `TimeoutError` so the
non-browser path shows a relevant suggestion instead of the
browser-specific `OPENCLI_BROWSER_COMMAND_TIMEOUT` env var hint.
2026-03-27 14:54:28 +08:00
sline 9a9e078462 feat(bluesky): add Bluesky adapter with 9 commands (#215)
Bluesky (9 commands, public AT Protocol API, no auth needed):
- profile: user profile info (followers, following, posts)
- user: recent posts from a user with engagement stats
- trending: trending topics on Bluesky
- search: search users
- feeds: popular feed generators
- followers: list user's followers
- following: list accounts a user follows
- thread: post thread with replies
- starter-packs: user's starter packs

All commands use the public Bluesky API, no browser or login required.
2026-03-27 14:39:43 +08:00
AlexYue 55d0473bcf fix(plugin): handle EXDEV cross-filesystem rename during install (#488)
* fix(plugin): handle EXDEV cross-filesystem rename during install

fs.renameSync() fails with EXDEV when source and destination are on
different filesystem mount points. This commonly happens because plugin
clones land in os.tmpdir() (often /tmp on a tmpfs) while plugins are
installed to ~/.opencli/plugins/ (on the root filesystem).

Add a moveDir() helper that catches EXDEV and falls back to
fs.cpSync() + fs.rmSync(). Applied to both single-plugin and monorepo
install paths.

* review: clean up failed EXDEV fallback installs

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-27 14:22:03 +08:00
AstroHan 1a44d8ccff feat(xiaohongshu): add published_at to search results (#484) (#485)
Derive approximate publish date from note IDs, which follow MongoDB
ObjectID format (first 8 hex chars = Unix timestamp). Exported as a
pure function with UTC+8 offset for China timezone.

Closes #484
2026-03-27 14:20:22 +08:00
jakevin 31cb2291c5 perf: parallel file discovery, plugin scanning, and external CLI caching (#501)
- Parallelize file scanning in discoverClisFromFs and discoverPluginDir
  using Promise.all(files.map(async ...)) instead of serial for-of with
  await, so isCliModule checks run concurrently
- Parallelize plugin directory scanning in discoverPlugins
- Cache loadExternalClis() result to avoid re-parsing YAML on every call
- Invalidate cache in registerExternalCli after writing to disk
- Cache strategyLabel() call in list command to avoid redundant computation
- Add comment explaining why discovery must remain sequential (plugin override semantics)
2026-03-27 14:19:55 +08:00
AstroHan fb5b608607 fix(twitter): use DOM-only scraping for trending to match page results (#486)
Remove guide.json API path that returned data inconsistent with what
users see on the page (#463). Use semantic caret button detection
via data-testid instead of position-based heuristics, and validate
post count text contains digits before displaying.
2026-03-27 14:15:36 +08:00
AlexYue 5e2e1dfe60 fix(plugin): detect symlinked monorepo sub-plugins in discoverPlugins (#487)
* fix(plugin): detect symlinked monorepo sub-plugins in discoverPlugins

discoverPlugins() used entry.isDirectory() to filter plugin directories,
but monorepo sub-plugins are installed as symlinks pointing into
~/.opencli/monorepos/. On most Node.js versions, isDirectory() returns
false for symlinks, causing monorepo plugin commands to be silently
skipped during discovery.

Add entry.isSymbolicLink() check so symlinked plugin directories are
properly discovered and their commands registered.

* fix(plugin): skip broken symlink discovery

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-27 14:12:54 +08:00
AstroHan 39eec0da82 fix(xiaohongshu): adapt publish to new two-step creator center UI (#490)
* fix(xiaohongshu): adapt publish to new two-step creator center UI (#460)

The creator center now requires image upload before showing the
title/content editor form. This caused the publish command to fail
with "Could not find title input".

- Add waitForEditForm() to poll for editor after image upload
- Extract TITLE_SELECTORS constant shared by waitForEditForm and fillField
- Add contenteditable title selectors for new UI
- Make images required (new UI mandates images before editor)
- Update draft button to match both '暂存离开' and '存草稿'
- Exclude title placeholder from content fallback selector
- Update tests to match new flow

* refactor(xiaohongshu): clarify publish surface states

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-27 14:03:10 +08:00
AlexYue 384419f4f5 feat(plugin): support local path install via file:// and absolute path (#491)
Add support for installing plugins from local directories:
  opencli plugin install file:///path/to/my-plugin
  opencli plugin install /path/to/my-plugin

Local plugins are symlinked (not copied) into ~/.opencli/plugins/
so code changes are reflected immediately without reinstall — ideal
for plugin development workflows.

Changes:
- parseSource() now handles file:// URLs and bare absolute paths
- New installLocalPlugin() creates symlink + installs deps + transpiles
- Lock file records 'local:<path>' as source for local plugins
- 6 new test cases for local path parsing and install behavior
2026-03-27 13:45:42 +08:00
AlexYue fa4c44a0c4 feat(plugin): add 'plugin create <name>' scaffold command (#494)
* feat(plugin): add 'plugin create <name>' scaffold command

Generate a ready-to-develop plugin directory with all required files:
- opencli-plugin.json (manifest with name, version, compatibility)
- package.json (ESM, peer dependency on @jackwener/opencli)
- hello.yaml (sample YAML command using httpbin)
- greet.ts (sample TS command using cli() API)
- README.md (install, usage, and development instructions)

Usage:
  opencli plugin create my-plugin
  opencli plugin create my-plugin --dir /path/to/dir
  opencli plugin create my-plugin --description 'My awesome plugin'

Includes 5 test cases for scaffold generation and error handling.

* fix(plugin): align scaffold with local install flow

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-27 13:34:36 +08:00
AlexYue d74263523f fix(plugin): remove legacy LOCK_FILE/MONOREPOS_DIR constants (#495)
Remove the module-level LOCK_FILE and MONOREPOS_DIR constants that were
computed at load time using os.homedir(). These ignored the HOME
environment variable, causing path mismatches when tests use HOME for
isolation.

All usages now go through getLockFilePath() and getMonoreposDir() which
respect process.env.HOME. Updated plugin.test.ts accordingly.
2026-03-27 13:27:47 +08:00
jakevin f9f018d7f4 fix(doctor): remove unused fix option and add release URL to extension install hint (#498)
* feat: zero onboarding, extension version check, and update notifier

- Fail-fast guard in execution.ts: when daemon is running but extension
  is not connected, immediately surface a setup guide instead of waiting
  for the 30s connect timeout

- Extension version handshake: extension sends `hello` with its version
  on WebSocket connect; daemon stores it and exposes via /status; CLI
  warns on mismatch in both execution path and `opencli doctor`

- `opencli doctor` now shows extension version inline and reports
  version mismatch as an actionable issue

- Non-blocking npm update checker: registers a process exit hook so the
  update notice appears after command output (same pattern as npm/gh/yarn);
  background fetch writes to ~/.opencli/update-check.json for next run

- postinstall: print Browser Bridge setup instructions after shell
  completion install for first-time global install users

Bug fixes caught in review:
- discover.ts: add AbortController timeout to checkDaemonStatus() fetch,
  move clearTimeout after res.json() to cover body streaming
- daemon.ts: clear extensionVersion and reject pending requests in
  ws.on('error') handler, not just ws.on('close')
- update-check.ts: skip update notice when process exits with non-zero
  code; read cache once at module load to avoid double disk I/O;
  guard isNewer() against NaN from pre-release version strings

* fix: reduce fail-fast timeout to 300ms and guard stderr.write in exit hook

* fix(doctor): remove unused fix option and add release URL to extension install hint

* fix(e2e): update BrowserBridge unavailable detection regex to match current error format
2026-03-27 13:26:27 +08:00
jakevin 218ba918d9 chore: bump version to 1.5.0 (#482)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
2026-03-27 03:57:05 +08:00
jakevin 40b923778f feat: smart error dispatch with inline Browser Bridge diagnosis (#481)
* feat: smart error dispatch with inline Browser Bridge diagnosis

- BrowserConnectError: runs checkDaemonStatus() on failure, shows real-time
  daemon/extension status and specific fix steps instead of a static hint
- AuthRequiredError: domain-specific login guidance
- TimeoutError: shows exact env var override command
- SelectorError/EmptyResultError: flags adapter as potentially outdated,
  links to debug command and issue tracker
- Generic untyped errors (164 in adapters): pattern-classified into
  auth/http/not-found/other with tailored guidance per category
- BrowserConnectError gains a `kind` field for future dispatch
- Added 6 new error icons (COMMAND_EXEC, ADAPTER_LOAD, NETWORK, etc.)
- Updated test: invalid bool now rejected eagerly in commanderAdapter

* fix: review fixes for smart error dispatch

- checkDaemonStatus: add { timeout: 300 } to match execution.ts behavior,
  avoids 2s wait on an already-failed path
- catch block: use named _statusErr variable; fall back to kind-derived
  state (running/extensionConnected inferred from BrowserConnectError.kind)
  instead of re-accessing outer err.hint ambiguously
- Extract renderBridgeStatus() helper to share logic between real-time
  and kind-derived fallback paths
- AuthRequiredError: use err.hint when set, respecting adapter-supplied
  hints; fall back to generic domain-based guidance
- HTTP regex: broaden from 'http [45]xx' to also match 'status: 404',
  bare '404', 'status 500', etc. — avoids false negatives
2026-03-27 03:04:09 +08:00
jakevin 15c6d0d508 refactor: deduplicate code, improve type safety, simplify error classes (#480)
- Extract shared parseYamlArgs() to yaml-schema.ts, eliminating duplicate
  YAML args parsing in discovery.ts and build-manifest.ts
- Unify BROWSER_ONLY_STEPS: export from capabilityRouting.ts, reuse in
  pipeline executor (fixes missing intercept/tap in retry set)
- Remove dead normalizeArgValue from commanderAdapter; bool coercion now
  handled solely by coerceAndValidateArgs in execution.ts
- Add closeWindow?() to IPage interface, replacing unsafe casts in executor
- BrowserBridge/CDPBridge implement IBrowserFactory, removing double cast
  in getBrowserFactory()
- Simplify CliError subclasses with new.target.name (9 redundant this.name
  assignments removed)
- Add hook dedup in addHook() to prevent duplicate registrations
- Fix normalizeRows to safely handle primitive values
- Unify CommandArgs type: execution.ts now imports from registry.ts
- Cache strategyLabel() call in cli.ts list command
2026-03-27 02:45:42 +08:00
jakevin 7617dff262 feat: zero onboarding, extension version check, and update notifier (#479)
* feat: zero onboarding, extension version check, and update notifier

- Fail-fast guard in execution.ts: when daemon is running but extension
  is not connected, immediately surface a setup guide instead of waiting
  for the 30s connect timeout

- Extension version handshake: extension sends `hello` with its version
  on WebSocket connect; daemon stores it and exposes via /status; CLI
  warns on mismatch in both execution path and `opencli doctor`

- `opencli doctor` now shows extension version inline and reports
  version mismatch as an actionable issue

- Non-blocking npm update checker: registers a process exit hook so the
  update notice appears after command output (same pattern as npm/gh/yarn);
  background fetch writes to ~/.opencli/update-check.json for next run

- postinstall: print Browser Bridge setup instructions after shell
  completion install for first-time global install users

Bug fixes caught in review:
- discover.ts: add AbortController timeout to checkDaemonStatus() fetch,
  move clearTimeout after res.json() to cover body streaming
- daemon.ts: clear extensionVersion and reject pending requests in
  ws.on('error') handler, not just ws.on('close')
- update-check.ts: skip update notice when process exits with non-zero
  code; read cache once at module load to avoid double disk I/O;
  guard isNewer() against NaN from pre-release version strings

* fix: reduce fail-fast timeout to 300ms and guard stderr.write in exit hook
2026-03-27 02:14:37 +08:00
AlexYue 31d3988398 feat(plugin): add opencli-plugin.json manifest and monorepo plugin support (#475)
* feat(plugin): add opencli-plugin.json manifest and monorepo plugin support

- New : types, read/validate, semver compatibility
- Monorepo install: clone → symlink sub-plugins → postInstall per sub-plugin
- Monorepo uninstall: symlink cleanup with ref counting
- Monorepo update: git pull on repo root, refresh all sub-plugins
-  supports  syntax
-  reads manifest metadata, groups monorepo plugins
-  install/list handlers updated for monorepo output
- 60 unit tests (25 manifest + 35 plugin including 11 new monorepo tests)
- Docs updated (EN + ZH) with monorepo section

* fix(plugin): install monorepo dependencies at repo root

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-27 01:31:38 +08:00
Lr_2002 310e136a6c feat(paperreview): add paperreview.ai adapter (#464)
* feat(paperreview): add paperreview.ai adapter

* fix(cli): normalize boolean command options

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-26 22:25:30 +08:00
d 🔹 776674c8dc feat(twitter): add time column to search output (#473)
* feat(twitter): add time column to search output

Extract created_at from tweet data and format as ISO datetime.
This helps users filter tweets by recency during monitoring.

Closes #465

* refactor(twitter): align search timestamp field with created_at

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-26 22:25:22 +08:00
AstroHan 0773616c1e feat(imdb): add IMDb adapter with 6 commands (#472)
* feat(imdb): add IMDb adapter with 6 commands

Add a public IMDb adapter using browser-based JSON-LD and __NEXT_DATA__
extraction. All commands use Strategy.PUBLIC with browser: true.

Commands:
- imdb search <query> — search movies, TV shows, and people
- imdb title <id> — get movie/show details (Movie, TVSeries, TVEpisode, TVMiniseries, TVMovie, etc.)
- imdb top — IMDb Top 250 chart
- imdb trending — Most Popular Movies
- imdb person <id> — actor/director info with filmography
- imdb reviews <id> — user reviews (first page, max 25)

Shared utils: ID normalization, ISO 8601 duration formatting, locale
forcing, JSON-LD extraction (supports type array filtering), and
anti-bot challenge detection.

* review: harden imdb adapter loading and tests

* test: unblock PR CI on merge head

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-26 22:21:52 +08:00
Mikey Zhou 5731881d59 feat: add bilibili/comments, xiaohongshu/comments commands + rate-limiter plugin docs (#457)
* feat: add bilibili/comments, xiaohongshu/comments, and rate-limiter plugin docs

- bilibili/comments: fetch top-level replies via /x/v2/reply/main with WBI signing
  (bvid → aid resolution + signed params, no DOM dependency)
- xiaohongshu/comments: DOM extraction from note detail page with login-wall detection
  and correct handling of 0-like counts (XHS shows "赞" text instead of "0")
- docs/advanced/rate-limiter-plugin.md: documents the onAfterExecute hook pattern
  and shows a plug-and-play rate limiter that adds random sleep between platform
  commands to reduce bot-detection risk

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

* fix(xiaohongshu): allow empty comments results

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-26 22:10:12 +08:00
槑囿脑袋 c75fea90ad feat(douban): add photo listing and download commands (#474)
* feat(douban): add photo listing and download commands

* refactor(douban): remove unreachable empty download branch

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-26 21:59:20 +08:00
AlexYue 6d1fb6d219 feat(runtime): add Bun runtime compatibility (#459)
* feat(runtime): add runtime detection utility for Bun/Node.js

Add runtime-detect.ts module that detects whether opencli is running
under Bun or Node.js via globalThis.Bun check. Includes helper
functions for version string and label formatting.

Add corresponding unit tests that work correctly under both runtimes.

* feat(runtime): integrate Bun runtime support into CLI tooling

- doctor: show runtime label (e.g. 'node v22.13.0') in diagnostic output
- package.json: add dev:bun, start:bun, test:bun convenience scripts
- E2E helpers: support OPENCLI_TEST_RUNTIME env var for runtime selection

* ci: add Bun compatibility test job and document runtime support

- ci.yml: add bun-test job using oven-sh/setup-bun@v2
- README.md: update Prerequisites to mention Bun, add Runtime Support
  section with usage examples for dev:bun, start:bun, test:bun

* ci: pin Bun version in compatibility job

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-26 19:10:31 +08:00
zzf fe87fc7b87 fix(extension): fail release packaging when manifest entry files are missing (#470) 2026-03-26 19:08:47 +08:00
jakevin b4cdc922c9 fix(36kr): avoid slow Intl timezone formatting in tests (#466) 2026-03-26 16:25:13 +08:00
Conn Ho 15b9bc8e0c feat(producthunt): add Product Hunt CLI adapter (#462)
* feat(producthunt): add Product Hunt CLI adapter

Add three commands:
- posts: RSS feed with optional category filter
- today: latest day's posts from feed
- hot: today's top posts with vote counts (browser INTERCEPT strategy)

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

* feat(producthunt): add browse command for category best products

Browse top-rated products in any Product Hunt category (e.g. vibe-coding,
ai-agents, developer-tools) with name, tagline, and review count.

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

* docs(producthunt): add adapter documentation

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

* fix(producthunt): rebase on main and stabilize selectors

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-26 16:02:33 +08:00
Conn Ho 22399cee1a feat(36kr): add 36氪 CLI adapter (#461)
* feat(36kr): add 36氪 CLI adapter with 4 commands

- news: latest articles via public RSS feed (no browser needed), includes title/summary/date/url
- hot: trending articles via INTERCEPT strategy, supports --type renqi/zonghe/shoucang/catalog
- search: keyword search via INTERCEPT + DOM scraping
- article: fetch article detail (title/author/date/body) by ID or URL

Also adds vitest adapter project entry for 36kr tests.

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

* docs(36kr): add adapter documentation

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

* fix(36kr): use Shanghai hot-list dates and complete docs

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-26 15:34:46 +08:00
Xeron ed89157804 fix(jd): filter avif images only from pcpubliccms CDN (#453)
* feat(jd): add item adapter for JD.com product details

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

Usage: opencli jd item <sku>

* fix(jd): update test to expect avifImages column

* review: tighten jd item image contract

* fix: stabilize extension packaging and Chinese-site e2e

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-26 15:12:41 +08:00
jakevin 53122cd028 docs: align language badges with status badges (#455) 2026-03-26 12:51:47 +08:00
jakevin 6c6b3c0a39 docs: turn language links into badges (#454) 2026-03-26 12:50:07 +08:00
Xiao Han 7348231b08 feat(twitter): add likes command (#448)
* feat(twitter): add likes command

* review: harden twitter likes query resolution

* refactor(twitter): share query id resolution

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-26 12:38:35 +08:00
HzTTT 0705d38b40 fix(ci): include popup assets in extension release zip (#444)
* fix(ci): include popup assets in extension release

Copy popup assets into the packaged Chrome extension zip and validate that manifest-referenced files exist before publishing the artifact.

Co-authored-by: Codex <noreply@openai.com>

* fix: restore executable permission on bin entries after tsc build (#446) (#452)

tsc does not preserve the +x bit when compiling, so after clean-dist
removes dist/ and tsc regenerates it, dist/main.js loses its executable
permission. This causes 'Permission denied' when users run 'npm run build'
in the installed directory.

Fix: read bin entries from package.json at the end of build-manifest and
chmod 0o755 them (skipped on Windows). Wrapped in try/catch so it never
breaks the build.

Closes #446

* fix: correct positional arg usage in tests (#449)

* fix yahoo-finance quote e2e invocation

* fix positional args in v2ex topic tests

* fix(ci): script extension release packaging

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: jakevin <jakevingoo@gmail.com>
Co-authored-by: pi-dal <hi@pi-dal.com>
2026-03-26 12:27:57 +08:00
glwlg 784bbc45f4 fix(xiaohongshu): improve image-text publish flow (#447)
* fix(xiaohongshu): improve image-text publish flow

Match visible 图文 tab labels instead of relying on narrow class selectors, fail early when the page is still on the video publish surface, and avoid injecting images into a generic file input. Add regression coverage for the image-text tab flow and the video-page failure case.

* test(xiaohongshu): include publish tests in adapter project

* fix(xiaohongshu): wait for image-text surface before upload

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-26 12:25:57 +08:00
pi-dal 232ad55d0f fix: correct positional arg usage in tests (#449)
* fix yahoo-finance quote e2e invocation

* fix positional args in v2ex topic tests
2026-03-26 11:59:42 +08:00
jakevin 4e5b00beeb fix: restore executable permission on bin entries after tsc build (#446) (#452)
tsc does not preserve the +x bit when compiling, so after clean-dist
removes dist/ and tsc regenerates it, dist/main.js loses its executable
permission. This causes 'Permission denied' when users run 'npm run build'
in the installed directory.

Fix: read bin entries from package.json at the end of build-manifest and
chmod 0o755 them (skipped on Windows). Wrapped in try/catch so it never
breaks the build.

Closes #446
2026-03-26 11:59:14 +08:00
tiaot33 e64046219d feat(linux-do): refactor adapters with unified feed, tags, user commands (#434)
* feat(linux-do): refactor adapters with unified feed, tags, user commands

- Replace hot/latest/category with unified `feed` command (tag/category/view routing)
- Add `tags`, `user-topics`, `user-posts` commands
- Add static data files for categories and tags lookup
- Fix error handling: use CliError subclasses instead of raw Error
- Fix Discourse API field mapping in search (tags, created)
- Add strategy: cookie to all YAML adapters
- Update docs and README command listings
- Update E2E tests for new command signatures

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

* review: resolve linux-do feed from live metadata

* fix: restore linux-do CI

* fix: harden linux-do compatibility

* refactor: stabilize linux-do command migration

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-26 00:45:34 +08:00
jakevin ed706f606b fix: stabilize http download temp file handling (#443) 2026-03-26 00:01:06 +08:00
Conn Ho 824dc38aab fix(weread): restore positional book-id coverage (#433)
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-25 23:58:40 +08:00
jakevin 0872bbec83 fix(weixin): rewrite publish time extraction (#440) 2026-03-25 23:53:14 +08:00
MatrixA a5f90884d8 feat(chatgpt): add model/mode selection and fix response polling (#438)
* feat(chatgpt): add model/mode selection and fix response polling

Add --model option to ask and send commands, and a new standalone
model command for switching ChatGPT Desktop models via Accessibility API.

Supported models: auto, instant, thinking, 5.2-instant, 5.2-thinking.

Changes:
- ax.ts: add AX_MODEL_SCRIPT (opens Options popover, searches within
  AXPopover to avoid matching sidebar items, supports legacy models
  submenu) and AX_GENERATING_SCRIPT (detects "Stop generating" button)
- ask.ts: add --model flag; fix polling to wait for generation to
  complete instead of returning partial/thinking intermediate text
- send.ts: add --model flag
- model.ts: new standalone command to switch model/mode

* review: activate chatgpt before model selection

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-25 23:45:09 +08:00
AstroHan 79dbd80b88 fix: repair weread private api requests (#436) 2026-03-25 23:43:50 +08:00
jakevin 53b4f2fc8e docs: add Chinese Electron adapter entry guide (#432) 2026-03-25 18:13:57 +08:00
jakevin 16589a9c7d docs: add entry guide for Electron app adapters (#430) 2026-03-25 18:07:16 +08:00
jakevin 8469c894c5 fix(test): harden download tests for Windows EPERM flakiness (#426)
- Clean up temp directories in afterEach to avoid stale file locks
- Add retry(2) on Windows to handle Defender file scanning EPERM
2026-03-25 16:26:33 +08:00
jakevin 2bfd3eeeb3 chore: release v1.4.1 (#425)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
2026-03-25 16:21:14 +08:00
jakevin bf5f327775 fix(extension): improve UX when daemon is not running (#424)
- Show helpful hint in popup when disconnected: "This is normal. The
  extension connects automatically when you run any opencli command."
- Stop eager reconnect after 6 attempts (reaching 60s backoff) to
  reduce ERR_CONNECTION_REFUSED noise in console; keepalive alarm
  still retries every ~24s at low frequency.
2026-03-25 16:19:02 +08:00
jakevin dba93c2739 fix(test): limit default e2e to bilibili/zhihu/v2ex, gate extended browser tests (#423)
Split browser-public.test.ts: core sites (bilibili, zhihu, v2ex) run
by default; all other 20+ site tests moved to browser-public-extended
and gated behind OPENCLI_E2E=1 to prevent AI agents from launching
dozens of browser instances.
2026-03-25 16:13:45 +08:00
jakevin 03d94ba2e1 chore: trim adapter test suite to bilibili, zhihu, v2ex only (#421)
Remove other adapter sites from vitest config to keep test runs
focused and avoid flaky failures from live site changes.
2026-03-25 16:01:15 +08:00
jakevin 46177e8d1e fix: remove nonexistent readwise external CLI entry (#420)
The npm package @readwiseio/readwise-cli returns 404 and the
GitHub repo readwiseio/readwise-cli doesn't exist.
2026-03-25 15:47:40 +08:00
jakevin 41a630d4f0 fix: remove incorrect gws external CLI entry (#419)
brew install gws installs a git workspace manager, not Google
Workspace CLI. The npm package @nicholasgasior/gws doesn't exist
either. Remove the misleading entry entirely.
2026-03-25 15:42:07 +08:00
jakevin 3e0c18fc7b feat(weibo,youtube): add Weibo commands and YouTube channel/comments (#418)
Weibo: add feed, me, user, post, comments commands with cookie-based
auth and proper AuthRequiredError handling.

YouTube: add channel info and video comments via InnerTube API.

Also remove internal source references from file headers.
2026-03-25 15:37:51 +08:00
nianyi(likai) 39ca8330c5 feat(douyin): add Douyin creator center adapter (14 commands, 8-phase publish pipeline) (#416)
* feat(douyin): add Douyin creator center adapter (14 commands, 8-phase publish pipeline)

- publish: 8-phase pipeline (STS2 → TOS multipart upload w/ resume → ImageX cover → transcode poll → safety check → create_v2)
- draft: save as draft (phases 1-6 + is_draft:1, no timing)
- videos/drafts/delete/profile/update: content management
- hashtag (search/suggest/hot) / location / activities / collections / stats: discovery & analytics
- _shared: tos-upload (AWS Sig V4, multipart, resume), imagex-upload, transcode poller (encode=2), browser-fetch, sts2, creation-id, timing, text-extra
- 124 tests, tsc clean

* fix(douyin): accept unix timestamp strings

* docs(douyin): add browser adapter guide

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-25 15:36:07 +08:00
AllenS0104 9245bf4529 feat: add url field to 9 search adapters (67% -> 97% coverage) (#414)
* feat(tiktok): add video URL to search results

Add a 'url' field to the TikTok search adapter output, constructed from
the author's uniqueId and the video id returned by the API. This allows
downstream consumers (AI agents, pipelines, scripts) to link directly to
each video instead of only having the author handle.

The URL format is: https://www.tiktok.com/@{author}/video/{videoId}

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat: add url field to 9 search adapters missing it

Add url output to search commands that were missing direct links:

YAML adapters:
- hackernews: surface existing url from map step into columns
- zhihu: pass computed url through map step into columns
- linux-do: construct url from topic id
- instagram: construct profile url from username
- xueqiu: pass computed url through map step into columns

TS adapters:
- arxiv: surface existing url from parseEntries into return + columns
- apple-podcasts: add collectionViewUrl from iTunes API
- medium: add url to columns (already computed in utils)
- weread: construct book url from bookId

This brings search adapter url coverage from 67% to 97% (32/33).
The only adapter without url is dictionary (word lookup, no URL concept).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(weread): use query arg in search

---------

Co-authored-by: Allen Song (Beyondsoft) <v-songjun@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-25 15:24:15 +08:00
pshu 554329fceb feat: add filter option for twitter search (#410)
* feat: add filter option for twitter search

* test: add tests

* docs: 📝 update

* fix(twitter): default search filter safely

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-25 15:09:38 +08:00
jakevin 4812486482 feat(extension): add popup UI, privacy policy, and CSP for Chrome Web Store (#415)
- Add popup.html/popup.js showing daemon connection status
  (Connected / Reconnecting / No daemon connected)
- Add message listener in background.ts to expose WebSocket state
- Add PRIVACY.md with full privacy policy covering all permissions
- Add content_security_policy to manifest.json
- Update description to be clearer for CWS reviewers
2026-03-25 15:07:40 +08:00
jakevin 15369fa23c chore: release v1.4.0 (#413)
* chore: release v1.4.0

* docs: sync command references across SKILL.md, README, and docs

SKILL.md:
- Add 12 missing sites: apple-podcasts, arxiv, bloomberg, coupang,
  dictionary, doubao, jd, linkedin, pixiv, web, weixin, xiaoyuzhou
- Add 36 missing commands across 6 existing sites (twitter, hackernews,
  yollomi, xueqiu, linux-do, v2ex)

README (EN + zh-CN):
- Add linkedin timeline command

docs/:
- Add 13 missing adapters to vitepress sidebar navigation
- Add 6 missing adapters to docs/adapters/index.md overview table
- Update xueqiu commands with fund-holdings, fund-snapshot
2026-03-25 14:48:46 +08:00
AlexYue a9571b196f ci: add cross-platform E2E and smoke test support (Linux/macOS/Windows) (#411)
* ci: add cross-platform support for E2E and smoke tests

Make headed browser tests (E2E and smoke) runnable on Linux, macOS,
and Windows:

- setup-chrome action: only install xvfb on Linux (macOS/Windows
  have native GUI sessions and don't need a virtual display)
- e2e-headed.yml: add OS matrix, use xvfb-run wrapper only on Linux
- ci.yml smoke-test: add OS matrix, use xvfb-run wrapper only on Linux

The browser-actions/setup-chrome action already supports all three
platforms natively.

* ci: exclude Windows from E2E/smoke matrix (Chrome install hangs)

browser-actions/setup-chrome hangs indefinitely during Chrome MSI
installation on Windows runners (observed 10+ min with no progress).
This is a known limitation of Windows CI runners.

Keep Linux + macOS for headed browser tests. Windows is still covered
by build, unit-test, and adapter-test jobs.
2026-03-25 14:35:24 +08:00
jakevin 594ad50949 fix: pre-release cleanup — bugs, version sync, and error handling (#412)
Build Chrome Extension / build (push) Has been cancelled
Release / release (push) Has been cancelled
* fix: pre-release cleanup — bugs, version sync, and error handling

Bug fixes:
- Fix hasLimit using wrong Set (SEARCH_PARAMS → LIMIT_PARAMS) in
  analysis.ts classifyQueryParams
- Remove phantom scroll step from BROWSER_STEPS and KNOWN_STEP_NAMES
  (declared but never registered, causes runtime crash if used in YAML)
- Add missing download step to KNOWN_STEP_NAMES (was producing
  false-positive validation warnings)

Docs:
- Sync version numbers: SKILL.md, extension/package.json,
  extension/manifest.json → 1.3.3
- Add jd, web to README command tables (both EN and zh-CN)
- Update xueqiu commands with fund-holdings, fund-snapshot

Code quality:
- Replace all 22 catch (err: any) with typed error handling using
  existing getErrorMessage() utility across 13 files

* fix: remove (err as any) casts in error handling

- antigravity/serve.ts: use typed Error.cause instead of (err as any).cause
- external.ts: move instanceof guard into shouldRetryWithCmdShim,
  accept unknown instead of forcing NodeJS.ErrnoException cast at call site
2026-03-25 14:32:29 +08:00
Saeed Al Mansouri 0ff28aa0d8 fix(extension): security hardening — tab isolation, URL validation, cookie scope (#409)
* fix(extension): security hardening — tab isolation, URL validation, cookie scope

Addresses issues raised in #399 (Astro-Han's community triage):

1. Tab isolation bypass: resolveTabId now verifies that an explicit tabId
   belongs to the automation window (tab.windowId === session.windowId)
   before accepting it. Tabs from the user's browsing session are rejected.

2. URL scheme allowlist: isDebuggableUrl switched from a blocklist
   (chrome://, chrome-extension://) to an allowlist (http://, https:// only).
   handleNavigate and tabs.new also reject non-http(s) URLs early, blocking
   file://, javascript:, and data: scheme abuse.

3. Cookie scope restriction: handleCookies now requires domain or url.
   Requests with neither are rejected instead of dumping all browser cookies.

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

* fix(extension): resolve data: URI vs allowlist conflict, plug tabs.select bypass

- Add BLANK_PAGE constant and whitelist it in isDebuggableUrl so
  internal blank tabs are not treated as non-debuggable after the
  blocklist-to-allowlist change.
- Add isSafeNavigationUrl for user-facing URL validation (http/https
  only), keeping it separate from internal isDebuggableUrl.
- Fix tabs.select to verify tab belongs to automation window before
  activating, closing a tab isolation bypass.
- Normalize error message style (-- instead of em dash).

* fix(extension): add try-catch for tabs.select with explicit tabId

Gracefully handle the case where cmd.tabId points to a closed tab
instead of letting the unhandled exception bubble up.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-25 14:03:18 +08:00
AstroHan e573f3fc32 fix(sort): use localeCompare with natural numeric sort by default (#306)
Replace manual < > comparison with localeCompare({ numeric: true })
so string-encoded numbers (e.g. "99" vs "1000") sort correctly
without requiring an explicit flag. This is a one-line fix that
makes sort just work for all YAML authors.

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-25 13:45:47 +08:00
AstroHan 78813fcb38 fix(pipeline): evaluate chained || in template engine (#305)
* chore: fix pre-existing biome lint in template.ts

- isNaN → Number.isNaN (2 occurrences)
- string concatenation → template literal
- biome-ignore for intentional control chars in sanitize regex

* fix(pipeline): evaluate chained || in template engine (#303)

The || handler in evalExpr returned the right side as a literal string
instead of recursively evaluating it. This broke chained fallbacks like
`item.a || item.b || 'default'` — when item.a was falsy, the entire
`item.b || 'default'` was returned as text.

Fix: call evalExpr on the right side so chained || works at any depth.

* perf(pipeline): fast-path string literals in evalExpr to skip VM

When the right side of || is a quoted string like 'N/A', detect it
with a simple regex and return directly instead of falling through
to evalJsExpr which spins up a node:vm sandbox.

* refactor(pipeline): simplify evalExpr by removing hand-rolled operator parsing

Replace the manual regex-based || and arithmetic handlers with a
streamlined flow: pipe filters → fast-path literals → resolvePath →
evalJsExpr (VM). The VM already handles ||, ??, arithmetic, ternary,
etc. natively, so reimplementing them with regex was redundant and
bug-prone (see issue #303).

Key improvements:
- Fix pipe | vs || disambiguation with lookbehind/lookahead regex
  (?<!|)|(?!|) so "item.a || item.b | upper" works correctly
- Remove ~20 lines of manual operator handling
- Add numeric literal fast path
- Pipe filter handler now uses evalExpr recursively (not just
  resolvePath), enabling filters on complex expressions

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-25 13:45:13 +08:00
iridite 9c99cbf8ab feat(xueqiu): add Danjuan fund account commands (#391)
* feat(xueqiu): add danjuan fund account commands

* refactor(xueqiu): convert danjuan fund YAML adapters to TS

- Replace 3 YAML files with 4 TS files (shared utils + 3 commands)
- Extract shared helpers: fetchDanjuanApi, fetchAssetGain, collectHoldings
- Fix double-navigation by using navigateBefore instead of pipeline navigate
- Unify error messages to English with Hint pattern
- Mask real account ID in docs example
- Add explicit default for --account arg

* refactor(xueqiu): optimize danjuan fund adapters

- Single page.evaluate with Promise.all for parallel account fetching
  (1 browser round-trip instead of N+1)
- Merge fund-accounts into fund-holdings (account info visible per row)
- 3 files: danjuan-utils.ts (shared), fund-holdings.ts, fund-snapshot.ts
- Strong TypeScript interfaces for all data shapes
- Update docs to reflect 2-command design

* fix(xueqiu): preserve danjuan pre-navigation metadata

* fix(xueqiu): fail on incomplete danjuan snapshots

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-25 13:32:41 +08:00
AllenS0104 297fd15f02 feat(tiktok): add video URL to search results (#404)
* feat(tiktok): add video URL to search results

Add a 'url' field to the TikTok search adapter output, constructed from
the author's uniqueId and the video id returned by the API. This allows
downstream consumers (AI agents, pipelines, scripts) to link directly to
each video instead of only having the author handle.

The URL format is: https://www.tiktok.com/@{author}/video/{videoId}

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: guard against empty uniqueId/id producing invalid URL

When uniqueId or id is missing, return empty string instead of
a malformed URL like "https://www.tiktok.com/@/video/".

---------

Co-authored-by: Allen Song (Beyondsoft) <v-songjun@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-25 13:21:43 +08:00
aresbit 806b358c0e fix windows chatwise connect (#405)
* Add

* test(chatwise): cover missing cdp endpoint guard

* refactor(chatwise): replace site special-case with command metadata

---------

Co-authored-by: ericyangbit <yangyang581@huawei.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-25 13:13:57 +08:00
AstroHan 541fd2129c fix(pipeline): check HTTP status in fetch step (#384)
* chore: ignore worktree directory

* fix(pipeline): check HTTP status in fetch step

* fix(pipeline): align fetch error semantics

* fix(pipeline): use CliError and add warn logging in fetch step

- Replace bare Error with CliError('FETCH_ERROR') for consistent CLI output
- Return error status from browser evaluate instead of throwing inside it
- Add log.warn() for batch item failures in both browser and non-browser paths

* chore: remove unrelated .worktrees/ from .gitignore

* refactor(fetch): use getErrorMessage(), unify sentinel naming to __httpError

- Use project's existing getErrorMessage() utility instead of manual instanceof checks
- Rename sentinel from __fetchError to __httpError for consistency with other adapters
- Simplify sentinel structure (url already available in outer scope, no need to pass through evaluate)
- Add comment explaining why getErrorMessage() can't be used inside evaluate()
- Add comment explaining CDP error message rewriting behavior

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-25 13:11:22 +08:00
Ryan Tan d36a43e805 feat(pixiv): add Pixiv adapter (#403)
* feat(pixiv): add Pixiv adapter with 6 commands

Add support for Pixiv (pixiv.net) with the following commands:
- ranking: daily/weekly/monthly illustration rankings
- search: search illustrations by keyword/tag
- user: view artist profile info
- illusts: list illustrations by artist
- detail: view illustration details (tags, stats)
- download: download original-quality images

All commands use COOKIE strategy to reuse Chrome's logged-in session.
YAML adapters for simple API fetches (ranking, detail, user), TypeScript
for complex logic (search, illusts, download with Referer header).

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

* test(pixiv): add unit tests and E2E auth failure tests

- search.test.ts: auth error, result parsing, limit, empty results (4 tests)
- illusts.test.ts: auth error, empty user, two-step fetch, limit (4 tests)
- download.test.ts: auth error, no images, Referer header, partial failure (4 tests)
- Add pixiv to vitest adapter project include list
- Add 5 pixiv commands to E2E browser-auth graceful failure tests

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

* fix(pixiv): correct ranking API path and YAML arg naming

- ranking: use /ranking.php?format=json (not /ajax/ranking which 404s)
- ranking: fix JSON path from data.body.contents to data.contents
- user/detail: rename hyphenated args (user-id → uid, illust-id → id)
  to fix YAML template evaluation (dot access doesn't support hyphens)

All 6 commands verified working against live Pixiv API.

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

* fix(pixiv): use JSON.stringify to prevent code injection in page.evaluate

Address CodeRabbit review: all user inputs (query, userId, illustId,
idsParam) passed to page.evaluate are now serialized via JSON.stringify
instead of direct string interpolation, preventing code injection in
browser context.

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

* refactor(pixiv): address code review feedback

- ranking.yaml: add | json filter to page/limit args for defense-in-depth
- user.yaml: guard illusts/manga/novels with typeof check for robustness
- Extract shared createPageMock to test-utils.ts, deduplicate across 3 test files

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

* refactor(pixiv): use minimal page mock and add download E2E test

- test-utils.ts: slim down to minimal mock (goto, evaluate, getCookies)
  with overrides support, matching upstream's pragmatic mock style
- Add missing download command to E2E browser-auth graceful failure tests

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

* fix(pixiv): address all remaining CodeRabbit review comments

- detail.yaml: add url to columns to match description mentioning "URLs"
- All adapters: differentiate HTTP errors — 401/403 → AuthRequiredError,
  404 → "not found", others → generic "request failed (HTTP N)"
- Tests: use beforeAll to cache registry lookup, avoiding repeated reads
  from global singleton
- Tests: assert error type (AuthRequiredError) not just message content
- Tests: add dedicated test cases for non-auth errors (500) and 404

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

* docs(pixiv): add adapter docs and indexes

- Add pixiv.md documentation page under docs/adapters/browser/
- Update docs/adapters/index.md with pixiv entry
- Add Pixiv to sidebar in docs/.vitepress/config.mts
- Update README.md and README.zh-CN.md adapter tables
- Add pixiv to download support tables in both READMEs

Completes the documentation checklist for the pixiv adapter PR.

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

* fix(pixiv): address code review findings

- Use CommandExecutionError instead of raw Error for HTTP failures
- Add page.goto() before page.evaluate() to establish browser context
- Fix search keyword double-encoding in URL construction
- Fix ranking.yaml using rating_count instead of illust_bookmark_count
- Throw on batch detail fetch failure instead of silent empty return
- Add beforeEach mock reset in download tests
- Add novels column to user.yaml output

Ensures pixiv adapter follows upstream CliError conventions and handles
edge cases correctly before submitting to upstream.

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

* docs(pixiv): improve download description in READMEs

- Replace technical Referer header detail with user-facing description
- Describe what users care about: original quality and multi-page support

Technical details belong in code comments, not user-facing docs.

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

* docs(pixiv): expand usage examples with all options

- Add ranking mode examples including R18 variants
- Add search filter examples (mode, order, pagination)
- Organize examples by command category for readability

Users need to know available options without reading source code.

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

* fix(pixiv): address second round of CodeRabbit review comments

- Validate illust-id is numeric to prevent path traversal
- Move URL parsing inside per-item try block for graceful error handling
- Add auth error handling for batch detail request (consistent with step 1)

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

* refactor(pixiv): extract shared pixivFetch helper, add input validation & batch support

- Create utils.ts with pixivFetch() for unified navigate + fetch + error handling
- Refactor search.ts, illusts.ts, download.ts to use pixivFetch (DRY)
- Add user-id/illust-id numeric validation in TS adapters
- Add batch pagination in illusts.ts for limit > 48 (Pixiv server limit)
- Add comment explaining Pixiv search API dual keyword requirement
- Update tests: new invalid-ID test cases, aligned mock format with pixivFetch

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-25 12:52:21 +08:00
AlexYue 4eeed2d4d7 ci: add cross-platform CI matrix (Linux/macOS/Windows) (#402)
* ci: add cross-platform matrix (Linux/macOS/Windows) to build, unit-test, adapter-test

Add OS matrix with ubuntu-latest, macos-latest, and windows-latest to
the build, unit-test, and adapter-test CI jobs. This ensures cross-
platform compatibility is verified on every push and PR.

Smoke tests remain Linux-only due to xvfb dependency.

Relates to #392 (Windows plugin path issues).

* test: replace hardcoded /tmp with os.tmpdir() for Windows compatibility

Fix Windows CI failures caused by hardcoded '/tmp' paths that don't
exist on Windows. Use os.tmpdir() which returns the correct platform-
specific temp directory on all operating systems.

Files fixed:
- src/engine.test.ts: 3 occurrences (mkdtemp, discoverClis path)
- src/plugin.test.ts: 2 occurrences (getCommitHash test, mock condition)

* test: fix remaining Windows path issues in test files

- engine.test.ts: use pathToFileURL().href for dynamic import paths
  (path.join produces backslashes on Windows, breaking ES module imports)
- download.test.ts: replace hardcoded '/tmp' with os.tmpdir() + path.join
2026-03-25 10:44:33 +08:00
Saeed Al Mansouri d51f361bd3 fix(plugin): resolve Windows path and symlink issues (#400)
* fix(plugin): resolve Windows path and symlink issues

- Replace `new URL(import.meta.url).pathname` with `fileURLToPath()` from
  node:url — the former returns `/C:/Users/...` on Windows (leading slash
  before drive letter), breaking path resolution for host linking and
  esbuild binary lookup.

- Use junction (`'junction'`) instead of directory symlink (`'dir'`) on
  Windows in linkHostOpencli — junctions don't require admin privileges,
  while `fs.symlinkSync(..., 'dir')` does on Windows.

- Use `where` instead of `which` on Windows for global esbuild lookup.

All changes are platform-conditional and preserve existing Unix behavior.

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

* fix(plugin): additional Windows fixes found during UAT

- npm execFileSync needs shell:true on Windows (.cmd wrapper)
- esbuild binary is a shebang script, needs shell:true on Windows
- resolveEsbuildBin: prefer .cmd in node_modules/.bin/ on Windows
  over import.meta.resolve (which returns a shebang script)
- Updated test to accept .cmd extension on Windows

Found during UAT testing on Windows 11.

* fix: handle multi-line output from 'where' on Windows

'where esbuild' on Windows can return multiple matching paths, one per
line. Take only the first match to get a valid single path for
resolveEsbuildBin().

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: ByteYue <yj976240184@gmail.com>
2026-03-25 10:25:06 +08:00
AlexYue 78d52d984b test(plugin): add E2E integration tests for plugin lifecycle (#389)
* test(plugin): add E2E integration tests for plugin lifecycle

Add plugin-management.test.ts covering the full plugin lifecycle
using real GitHub clone of opencli-plugin-hot-digest:
- plugin install from github:ByteYue/opencli-plugin-hot-digest
- plugin list (table and JSON formats)
- plugin update on installed plugin
- plugin uninstall with cleanup verification
- error paths: invalid source, non-existent plugin, missing args

Tests safely backup/restore existing plugin state to avoid
interfering with user's real installed plugins.

Update TESTING.md to document the new test file.

* test(plugin): isolate lifecycle e2e from user home

* fix(plugin): respect HOME env var for test isolation

The E2E tests for plugin management were failing because os.homedir()
doesn't respect the HOME environment variable. This made test isolation
impossible since all tests would use the real ~/.opencli directory.

Added getHomeDir() helper that checks process.env.HOME first before
falling back to os.homedir(). Updated readLockFile() and writeLockFile()
to use this new function.

Fixes test failures in plugin-management.test.ts where:
- plugin install would write to real home instead of temp dir
- lock file assertions would fail with ENOENT

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-25 00:23:51 +08:00
AlexYue 1512016967 feat(plugin): add lifecycle hooks API (onStartup, onBeforeExecute, onAfterExecute) (#376)
* feat(plugin): add lifecycle hooks API (onStartup, onBeforeExecute, onAfterExecute)

Introduce a hooks system that allows plugins to tap into opencli's
execution lifecycle without modifying core code.

New files:
- src/hooks.ts: hook registration, emission, and globalThis singleton
- src/hooks.test.ts: 10 unit tests covering registration, ordering,
  error isolation, async support, and globalThis sharing

Modified files:
- src/execution.ts: emit onBeforeExecute/onAfterExecute around command execution
- src/main.ts: emit onStartup after discoverPlugins()
- src/registry-api.ts: export hooks API for plugin consumption

Example plugin: https://github.com/ByteYue/opencli-plugin-audit-log

* fix(discovery): load plugin files that register lifecycle hooks

The isCliModule() check only matched files containing 'cli(' calls,
silently skipping hook-only files like audit-hooks.ts that register
onBeforeExecute/onAfterExecute without any cli() command registration.

Renamed CLI_MODULE_PATTERN → PLUGIN_MODULE_PATTERN and extended the
regex to also match onStartup(, onBeforeExecute(, onAfterExecute(.

* fix(plugin): tighten lifecycle hook semantics

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-25 00:09:43 +08:00
jakevin 8d2ee03d2d DOM 元素检测增强 (browser-use 研究)
Add search element heuristics and label/span wrapper detection

- Add SEARCH_INDICATORS set to detect search-related elements
- Add isSearchElement function for heuristic detection
- Add hasFormControlDescendant to detect wrapped form controls
- Enhance isInteractive for label/span wrapper patterns

Ref: browser-use ClickableElementDetector research
Review: @codex
2026-03-24 23:59:27 +08:00
AstroHan 106ab3a424 fix(download): scope cookies to target domain (#385)
* chore: ignore worktree directory

* fix(security): scope download cookies to target domain

* fix(download): scope yt-dlp cookies per target domain

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-24 23:28:04 +08:00
AlexYue 316b495c72 review: rebase plugin esbuild resolution on current main (#366)
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-24 22:43:12 +08:00
jakevin cffc043fa7 ci: trim low-value workflows and duplicate checks (#381) 2026-03-24 22:35:37 +08:00
jakevin 3b2a51fcd3 fix(extension): revert #377 and cleanly fix same-url navigation timeout (#380)
* Revert "fix(extension): avoid same-url navigation timeout (#377)"

This reverts commit b7ada0e38c.

* fix(extension): avoid same-url navigation timeout

- Add normalizeUrlForComparison for minimal URL canonicalization
  (root slash + default port only; preserves hash and non-root paths)
- Fast-path: skip navigation when tab is already at the target URL
- Rewrite wait logic with finish() pattern to prevent double-resolve
- Handle both same-URL and redirect scenarios in navigation listener
- Add regression tests for same-URL and hash-route distinction
2026-03-24 22:25:29 +08:00
AlexYue e7e4367827 review: scope plugin lock tracking cleanly (#362)
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-24 22:21:15 +08:00
ayotme b7ada0e38c fix(extension): avoid same-url navigation timeout (#377)
* fix(extension): avoid same-url navigation timeout

* review: preserve hash-aware extension navigation

---------

Co-authored-by: huruichen <huruichen@kanzhun.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-24 22:05:22 +08:00
AlexYue 93b9db5337 review: scope plugin update-all and sync docs (#368)
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-03-24 21:54:40 +08:00
jakevin ba5f133cd6 docs: tighten adapter authoring guidelines (#371) 2026-03-24 21:40:29 +08:00
AlexYue 3cf323f68b feat(plugin): validate plugin structure on install and update (#364) 2026-03-24 21:38:56 +08:00
jakevin 180e7eae13 refactor: adopt CliError in social adapters (#375) 2026-03-24 21:26:55 +08:00
357 changed files with 23340 additions and 2446 deletions
@@ -16,8 +16,8 @@ description: "Cross-project CLI command migration workflow for opencli. Use when
## Prerequisites
- 熟悉 [CLI-EXPLORER.md](file:///Users/jakevin/code/opencli/CLI-EXPLORER.md)adapter 开发决策树)
- 熟悉 [SKILL.md](file:///Users/jakevin/code/opencli/SKILL.md)(命令参考 & 模板)
- 熟悉 [CLI-EXPLORER.md](../../../CLI-EXPLORER.md)adapter 开发决策树)
- 熟悉 [SKILL.md](../../../SKILL.md)(命令参考 & 模板)
---
@@ -82,7 +82,7 @@ opencli list | grep <site> # 确认已注册命令
## Phase 3: 批量实现
> [!IMPORTANT]
> 实现前必须查阅 [CLI-EXPLORER.md](file:///Users/jakevin/code/opencli/CLI-EXPLORER.md) 确认策略选择。
> 实现前必须查阅 [CLI-EXPLORER.md](../../../CLI-EXPLORER.md) 确认策略选择。
### 3.1 选择实现方式
+5 -4
View File
@@ -1,5 +1,5 @@
name: Setup Chrome + xvfb
description: Install real Chrome and xvfb virtual display for headed browser testing
name: Setup Chrome
description: Install real Chrome for browser testing (with xvfb on Linux)
outputs:
chrome-path:
@@ -19,8 +19,9 @@ runs:
shell: bash
run: |
echo "Chrome path: ${{ steps.setup-chrome.outputs.chrome-path }}"
${{ steps.setup-chrome.outputs.chrome-path }} --version
"${{ steps.setup-chrome.outputs.chrome-path }}" --version
- name: Install xvfb for headed mode
- name: Install xvfb (Linux only)
if: runner.os == 'Linux'
shell: bash
run: sudo apt-get install -y xvfb
+3 -1
View File
@@ -24,8 +24,10 @@ Related issue:
- [ ] Added doc page under `docs/adapters/` (if new adapter)
- [ ] Updated `docs/adapters/index.md` table (if new adapter)
- [ ] Updated sidebar in `docs/.vitepress/config.mts` (if new adapter)
- [ ] Updated `README.md` / `README.zh-CN.md` when command discoverability changed
- [ ] Used positional args for the command's primary subject unless a named flag is clearly better
- [ ] Normalized expected adapter failures to `CliError` subclasses instead of raw `Error`
## Screenshots / Output
<!-- If applicable, paste CLI output or screenshots here. -->
+8 -6
View File
@@ -4,8 +4,14 @@ on:
push:
branches: [ "main" ]
tags: [ "v*.*.*" ]
paths:
- 'extension/**'
- '.github/workflows/build-extension.yml'
pull_request:
branches: [ "main" ]
paths:
- 'extension/**'
- '.github/workflows/build-extension.yml'
permissions:
contents: write
@@ -33,12 +39,8 @@ jobs:
working-directory: extension
- name: Prepare extension package
run: |
rm -rf extension-package
mkdir -p extension-package
cp extension/manifest.json extension-package/
cp -R extension/dist extension-package/
cp -R extension/icons extension-package/
run: npm run package:release -- --out ../extension-package
working-directory: extension
- name: Create Extension ZIP
run: |
+48 -6
View File
@@ -16,7 +16,11 @@ concurrency:
jobs:
# ── Fast gate: typecheck + build ──
build:
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v6
@@ -35,12 +39,15 @@ jobs:
run: npm run build
# ── Unit tests (vitest shard) ──
# PR: ubuntu + Node 22 only (fast feedback, 2 jobs).
# Push to main/dev: full matrix for cross-platform/cross-version coverage (12 jobs).
unit-test:
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
node-version: ['20', '22']
os: ${{ (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && fromJSON('["ubuntu-latest","macos-latest","windows-latest"]') || fromJSON('["ubuntu-latest"]') }}
node-version: ${{ (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && fromJSON('["20","22"]') || fromJSON('["22"]') }}
shard: [1, 2]
steps:
- uses: actions/checkout@v6
@@ -56,6 +63,28 @@ jobs:
- name: Run unit tests (Node ${{ matrix.node-version }}, shard ${{ matrix.shard }}/2)
run: npm test -- --reporter=verbose --shard=${{ matrix.shard }}/2
# ── Bun compatibility check ──
bun-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.5
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests under Bun
run: bun vitest run --project unit --reporter=verbose
# Adapter tests are pure unit tests — OS doesn't affect results.
adapter-test:
runs-on: ubuntu-latest
needs: build
@@ -77,7 +106,13 @@ jobs:
smoke-test:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
needs: build
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
# NOTE: Windows excluded — browser-actions/setup-chrome hangs during
# Chrome MSI installation on Windows runners (known issue).
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v6
@@ -89,17 +124,24 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Setup Chrome + xvfb
- name: Setup Chrome
uses: ./.github/actions/setup-chrome
id: setup-chrome
- name: Build
run: npm run build
- name: Run smoke tests
- name: Run smoke tests (Linux, via xvfb)
if: runner.os == 'Linux'
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 }}
- name: Run smoke tests (macOS / Windows)
if: runner.os != 'Linux'
run: npx vitest run tests/smoke/ --reporter=verbose
env:
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
timeout-minutes: 15
+36 -3
View File
@@ -3,8 +3,28 @@ name: E2E Headed Chrome
on:
push:
branches: [main, dev]
paths:
- 'extension/**'
- 'src/browser/**'
- 'src/daemon.ts'
- 'src/execution.ts'
- 'src/interceptor.ts'
- 'tests/e2e/**'
- 'tests/smoke/**'
- '.github/actions/setup-chrome/**'
- '.github/workflows/e2e-headed.yml'
pull_request:
branches: [main, dev]
paths:
- 'extension/**'
- 'src/browser/**'
- 'src/daemon.ts'
- 'src/execution.ts'
- 'src/interceptor.ts'
- 'tests/e2e/**'
- 'tests/smoke/**'
- '.github/actions/setup-chrome/**'
- '.github/workflows/e2e-headed.yml'
workflow_dispatch:
concurrency:
@@ -13,7 +33,13 @@ concurrency:
jobs:
e2e-headed:
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
# NOTE: Windows excluded — browser-actions/setup-chrome hangs during
# Chrome MSI installation on Windows runners (known issue).
os: [ubuntu-latest, macos-latest]
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
@@ -26,16 +52,23 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Setup Chrome + xvfb
- name: Setup Chrome
uses: ./.github/actions/setup-chrome
id: setup-chrome
- name: Build
run: npm run build
- name: Run E2E tests (headed Chrome + xvfb)
- name: Run E2E tests (Linux, via xvfb)
if: runner.os == 'Linux'
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 }}
- name: Run E2E tests (macOS / Windows)
if: runner.os != 'Linux'
run: npx vitest run tests/e2e/ --reporter=verbose
env:
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
-30
View File
@@ -1,30 +0,0 @@
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
-3
View File
@@ -31,6 +31,3 @@ jobs:
- 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
+78
View File
@@ -1,5 +1,83 @@
# Changelog
## [1.4.1](https://github.com/jackwener/opencli/compare/v1.4.0...v1.4.1) (2026-03-25)
### Features
* **douyin:** add Douyin creator center adapter — 14 commands, 8-phase publish pipeline ([#416](https://github.com/jackwener/opencli/issues/416))
* **weibo,youtube:** add Weibo commands and YouTube channel/comments ([#418](https://github.com/jackwener/opencli/issues/418))
* **twitter:** add filter option for search ([#410](https://github.com/jackwener/opencli/issues/410))
* **extension:** add popup UI, privacy policy, and CSP for Chrome Web Store ([#415](https://github.com/jackwener/opencli/issues/415))
* add url field to 9 search adapters (67% -> 97% coverage) ([#414](https://github.com/jackwener/opencli/issues/414))
### Bug Fixes
* **extension:** improve UX when daemon is not running — show hint in popup, reduce reconnect noise ([#424](https://github.com/jackwener/opencli/issues/424))
* remove incorrect gws and readwise external CLI entries ([#419](https://github.com/jackwener/opencli/issues/419), [#420](https://github.com/jackwener/opencli/issues/420))
### CI
* limit default e2e to bilibili/zhihu/v2ex, gate extended browser tests ([#421](https://github.com/jackwener/opencli/issues/421), [#423](https://github.com/jackwener/opencli/issues/423))
## [1.4.0](https://github.com/jackwener/opencli/compare/v1.3.3...v1.4.0) (2026-03-25)
### Features
* **pixiv:** add Pixiv adapter — ranking, search, user illusts, detail, download ([#403](https://github.com/jackwener/opencli/issues/403))
* **plugin:** add lifecycle hooks API — onStartup, onBeforeExecute, onAfterExecute ([#376](https://github.com/jackwener/opencli/issues/376))
* **plugin:** validate plugin structure on install and update ([#364](https://github.com/jackwener/opencli/issues/364))
* **xueqiu:** add Danjuan fund account commands — fund-holdings, fund-snapshot ([#391](https://github.com/jackwener/opencli/issues/391))
* **tiktok:** add video URL to search results ([#404](https://github.com/jackwener/opencli/issues/404))
* **linkedin:** add timeline feed command ([#342](https://github.com/jackwener/opencli/issues/342))
* **jd:** add JD.com product details adapter ([#344](https://github.com/jackwener/opencli/issues/344))
* **web:** add generic `web read` command for any URL → Markdown ([#343](https://github.com/jackwener/opencli/issues/343))
* **dictionary:** add dictionary search, synonyms, and examples adapters ([#241](https://github.com/jackwener/opencli/issues/241))
### Bug Fixes
* **analysis:** fix hasLimit using wrong Set (SEARCH_PARAMS → LIMIT_PARAMS) ([#412](https://github.com/jackwener/opencli/issues/412))
* **pipeline:** remove phantom scroll step — declared but never registered ([#412](https://github.com/jackwener/opencli/issues/412))
* **validate:** add missing download step to KNOWN_STEP_NAMES ([#412](https://github.com/jackwener/opencli/issues/412))
* **extension:** security hardening — tab isolation, URL validation, cookie scope ([#409](https://github.com/jackwener/opencli/issues/409))
* **sort:** use localeCompare with natural numeric sort by default ([#306](https://github.com/jackwener/opencli/issues/306))
* **pipeline:** evaluate chained || in template engine ([#305](https://github.com/jackwener/opencli/issues/305))
* **pipeline:** check HTTP status in fetch step ([#384](https://github.com/jackwener/opencli/issues/384))
* **plugin:** resolve Windows path and symlink issues ([#400](https://github.com/jackwener/opencli/issues/400))
* **download:** scope cookies to target domain ([#385](https://github.com/jackwener/opencli/issues/385))
* **extension:** fix same-url navigation timeout ([#380](https://github.com/jackwener/opencli/issues/380))
* fix ChatWise Windows connect ([#405](https://github.com/jackwener/opencli/issues/405))
* resolve 6 critical + 11 important bugs from deep code review ([#337](https://github.com/jackwener/opencli/issues/337), [#340](https://github.com/jackwener/opencli/issues/340))
* harden security-sensitive execution paths ([#335](https://github.com/jackwener/opencli/issues/335))
* **stealth:** harden anti-detection against advanced fingerprinting ([#357](https://github.com/jackwener/opencli/issues/357))
### Code Quality
* replace all `catch (err: any)` with typed `getErrorMessage()` across 13 files ([#412](https://github.com/jackwener/opencli/issues/412))
* adopt CliError subclasses in social and desktop adapters ([#367](https://github.com/jackwener/opencli/issues/367), [#372](https://github.com/jackwener/opencli/issues/372), [#375](https://github.com/jackwener/opencli/issues/375))
* simplify codebase with type dedup, shared analysis module, and consistent naming ([#373](https://github.com/jackwener/opencli/issues/373))
* **ci:** add cross-platform CI matrix (Linux/macOS/Windows) ([#402](https://github.com/jackwener/opencli/issues/402))
## [1.3.3](https://github.com/jackwener/opencli/compare/v1.3.2...v1.3.3) (2026-03-25)
### Features
* **browser:** add stealth anti-detection for CDP and daemon modes ([#319](https://github.com/jackwener/opencli/issues/319))
### Bug Fixes
* **stealth:** review fixes — guard plugins, rewrite stack trace cleanup ([#320](https://github.com/jackwener/opencli/issues/320))
## [1.3.2](https://github.com/jackwener/opencli/compare/v1.3.1...v1.3.2) (2026-03-24)
+57
View File
@@ -0,0 +1,57 @@
# Privacy Policy — OpenCLI Browser Extension
**Last updated**: 2026-03-25
## What the extension does
The OpenCLI Browser Extension is a bridge between the [OpenCLI](https://github.com/jackwener/opencli) command-line tool and your Chrome browser. It receives commands from a **locally running daemon** process via WebSocket (`localhost` only) and executes them in **isolated Chrome windows** that are separate from your normal browsing session.
## Data collection
The extension does **NOT** collect, store, transmit, or sell any personal data. Specifically:
- **No analytics or telemetry** — no data is sent to any remote server.
- **No user tracking** — no cookies, identifiers, or fingerprints are created.
- **No external network requests** — all communication is strictly `localhost` (WebSocket to `ws://localhost:19825`).
## Permissions explained
| Permission | Why it's needed |
|------------|----------------|
| `debugger` | Required to use Chrome DevTools Protocol (CDP) for browser automation — executing JavaScript, capturing page content, and taking screenshots in isolated windows. |
| `tabs` | Required to create and manage isolated automation windows and tabs, separate from the user's browsing session. |
| `cookies` | Required to read site-specific cookies (scoped by domain) so CLI commands can authenticate with websites the user is already logged into. Cookies are **never written, modified, or transmitted externally**. |
| `activeTab` | Required to identify the currently active tab for context-aware commands. |
| `alarms` | Required to maintain the WebSocket connection to the local daemon via periodic keepalive checks. |
## Data flow
```
User's terminal (opencli CLI)
↓ (spawns)
Local daemon process (localhost:19825)
↓ (WebSocket, localhost only)
Chrome Extension (this extension)
↓ (Chrome APIs)
Isolated Chrome automation window
```
All data stays on the user's machine. No data leaves `localhost`.
## Cookie access
The extension reads cookies **only** when explicitly requested by a CLI command, and **only** for the specific domain the command targets. It cannot and does not dump all cookies. Cookie data is returned to the local daemon process and is never sent to any external server.
## Third-party services
This extension does not integrate with, send data to, or receive data from any third-party service.
## Open source
This extension is fully open source. You can audit the complete source code at:
https://github.com/jackwener/opencli/tree/main/extension
## Contact
For privacy questions or concerns, please open an issue at:
https://github.com/jackwener/opencli/issues
+86 -196
View File
@@ -1,27 +1,28 @@
# OpenCLI
> **Make any website, Electron App, or Local Tool your CLI.**
> **Make any website, Electron App, or Local Tool your CLI.**
> Zero risk · Reuse Chrome login · AI-powered discovery · Universal CLI Hub
[中文文档](./README.zh-CN.md)
[![中文文档](https://img.shields.io/badge/docs-%E4%B8%AD%E6%96%87-0F766E?style=flat-square)](./README.zh-CN.md)
[![npm](https://img.shields.io/npm/v/@jackwener/opencli?style=flat-square)](https://www.npmjs.com/package/@jackwener/opencli)
[![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**, **Electron app**, or **local CLI tool** into a command-line interface — Bilibili, Zhihu, 小红书, Twitter/X, Reddit, YouTube, Antigravity, `gh`, `docker`, and [many more](#built-in-commands) — powered by browser session reuse and AI-native discovery.
**Built for AI Agents**: Simply configure an instruction in your global `AGENT.md` or `.cursorrules` guiding the AI to execute `opencli list` via Bash to discover available tools. Register your favorite local CLIs (`opencli register mycli`), and the AI will automatically learn how to invoke all your tools perfectly!
**Built for AI Agents** — Configure an instruction in your `AGENT.md` or `.cursorrules` to run `opencli list` via Bash. The AI will automatically discover and invoke all available tools.
**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!
**CLI Hub** — Register any local CLI (`opencli register mycli`) so AI agents can discover and call it alongside built-in commands. Auto-installs missing tools via your package manager (e.g. if `gh` isn't installed, `opencli gh ...` runs `brew install gh` first then re-executes seamlessly).
**CLI for Electron Apps** — Turn any Electron application into a CLI tool. Recombine, script, and extend apps like Antigravity Ultra from the terminal. AI agents can now control other AI apps natively.
---
## Highlights
- **CLI All Electron** — CLI-ify apps like Antigravity Ultra! Now AI can control itself natively using cc/openclaw!
- **CLI All Electron** — CLI-ify apps like Antigravity Ultra! Now AI can control itself natively.
- **Account-safe** — Reuses Chrome's logged-in state; your credentials never leave the browser.
- **Anti-detection built-in** — Patches `navigator.webdriver`, stubs `window.chrome`, fakes plugin lists, cleans ChromeDriver/Playwright globals, and strips CDP frames from Error stack traces. Extensive anti-fingerprinting and risk-control evasion measures baked in at every layer.
- **AI Agent ready** — `explore` discovers APIs, `synthesize` generates adapters, `cascade` finds auth strategies.
- **External CLI Hub** — Discover, auto-install, and passthrough commands to any external CLI (gh, obsidian, docker, etc). Zero setup.
- **Self-healing setup** — `opencli doctor` diagnoses and auto-starts the daemon, extension, and live browser connectivity.
@@ -48,63 +49,38 @@ There are many great browser automation tools. Here's when opencli is the right
> For a detailed comparison with Browser-Use, Crawl4AI, Firecrawl, and others, see the [Comparison Guide](./docs/comparison.md).
## Prerequisites
---
- **Node.js**: >= 20.0.0
- **Chrome** running **and logged into the target site** (e.g. bilibili.com, zhihu.com, xiaohongshu.com).
## Quick Start
> **⚠️ 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.
### 1. Install Browser Bridge Extension
OpenCLI connects to your browser through a lightweight **Browser Bridge** Chrome Extension + micro-daemon (zero config, auto-start).
> OpenCLI connects to your browser through a lightweight **Browser Bridge** Chrome Extension + micro-daemon (zero config, auto-start).
### Browser Bridge Extension Setup
You can install the extension via either method:
**Method 1: Download Pre-built Release (Recommended)**
1. Go to the GitHub [Releases page](https://github.com/jackwener/opencli/releases) and download the latest `opencli-extension.zip`.
2. Unzip the file and open `chrome://extensions`, enable **Developer mode** (top-right toggle).
3. Click **Load unpacked** and select the unzipped folder.
**Method 2: Load Source (For Developers)**
1. Open `chrome://extensions` and enable **Developer mode**.
2. Click **Load unpacked** and select the `extension/` directory from this repository.
### 2. Install OpenCLI
That's it! The daemon auto-starts when you run any browser command. No tokens, no manual configuration.
> **Tip**: Use `opencli doctor` for ongoing diagnosis:
> ```bash
> opencli doctor # Check extension + daemon connectivity
> ```
## Quick Start
### Install via npm (recommended)
**Install via npm (recommended)**
```bash
npm install -g @jackwener/opencli
```
Then use directly:
### 3. Verify & Try
```bash
opencli list # See all commands
opencli list -f yaml # List commands as YAML
opencli hackernews top --limit 5 # Public API, no browser
opencli bilibili hot --limit 5 # Browser command
opencli zhihu hot -f json # JSON output
opencli zhihu hot -f yaml # YAML output
opencli doctor # Check extension + daemon connectivity
```
### Install from source (for developers)
**Try it out:**
```bash
git clone git@github.com:jackwener/opencli.git
cd opencli
npm install
npm run build
npm link # Link binary globally
opencli list # Now you can use it anywhere!
opencli list # See all commands
opencli hackernews top --limit 5 # Public API, no browser needed
opencli bilibili hot --limit 5 # Browser command (requires Extension)
```
### Update
@@ -113,95 +89,63 @@ opencli list # Now you can use it anywhere!
npm install -g @jackwener/opencli@latest
```
---
### For Developers
**Install from source**
```bash
git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && npm run build && npm link
```
**Load Source Browser Bridge Extension**
1. Open `chrome://extensions` and enable **Developer mode** (top-right toggle).
2. Click **Load unpacked** and select the `extension/` directory from this repository.
---
## Prerequisites
- **Node.js**: >= 20.0.0 (or **Bun** >= 1.0)
- **Chrome** running **and logged into the target site** (e.g. bilibili.com, zhihu.com, xiaohongshu.com).
> **⚠️ 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.
## Built-in Commands
Run `opencli list` for the live registry.
| Site | Commands |
|------|----------|
| **xiaohongshu** | `search` `feed` `user` `download` `publish` `comments` `notifications` `creator-notes` `creator-notes-summary` `creator-note-detail` `creator-profile` `creator-stats` |
| **bilibili** | `hot` `search` `history` `feed` `ranking` `download` `comments` `dynamic` `favorite` `following` `me` `subtitle` `user-videos` |
| **twitter** | `trending` `search` `timeline` `bookmarks` `post` `download` `profile` `article` `like` `likes` `notifications` `reply` `reply-dm` `thread` `follow` `unfollow` `followers` `following` `block` `unblock` `bookmark` `unbookmark` `delete` `hide-reply` `accept` |
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `user` `user-posts` `user-comments` `read` `save` `saved` `subscribe` `upvote` `upvoted` `comment` |
| Site | Commands | Mode |
|------|----------|------|
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | Browser |
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | Browser |
| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | Desktop |
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | Browser |
| **codex** | `status` `send` `read` `new` `dump` `extract-diff` `model` `ask` `screenshot` `history` `export` | Desktop |
| **chatwise** | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` | Desktop |
| **doubao** | `status` `new` `send` `read` `ask` | Browser |
| **doubao-app** | `status` `new` `send` `read` `ask` `screenshot` `dump` | Desktop |
| **notion** | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` | Desktop |
| **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` | Desktop |
| **v2ex** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | Public / Browser |
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` `earnings-date` | Browser |
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | Desktop |
| **chatgpt** | `status` `new` `send` `read` `ask` | Desktop |
| **xiaohongshu** | `search` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | Browser |
| **apple-podcasts** | `search` `episodes` `top` | Public |
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | Public |
| **zhihu** | `hot` `search` `question` `download` | Browser |
| **weixin** | `download` | Browser |
| **youtube** | `search` `video` `transcript` | Browser |
| **boss** | `search` `detail` `recommend` `joblist` `greet` `batchgreet` `send` `chatlist` `chatmsg` `invite` `mark` `exchange` `resume` `stats` | Browser |
| **coupang** | `search` `add-to-cart` | Browser |
| **bbc** | `news` | Public |
| **bloomberg** | `main` `markets` `economics` `industries` `tech` `politics` `businessweek` `opinions` `feeds` `news` | Public / Browser |
| **ctrip** | `search` | Browser |
| **devto** | `top` `tag` `user` | Public |
| **dictionary** | `search` `synonyms` `examples` | Public |
| **arxiv** | `search` `paper` | Public |
| **wikipedia** | `search` `summary` `random` `trending` | Public |
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | Public |
| **linkedin** | `search` | Browser |
| **reuters** | `search` | Browser |
| **smzdm** | `search` | Browser |
| **weibo** | `hot` `search` | Browser |
| **yahoo-finance** | `quote` | Browser |
| **sinafinance** | `news` | 🌐 Public |
| **barchart** | `quote` `options` `greeks` `flow` | Browser |
| **chaoxing** | `assignments` `exams` | Browser |
| **grok** | `ask` | Browser |
| **hf** | `top` | Public |
| **jike** | `feed` `search` `create` `like` `comment` `repost` `notifications` `post` `topic` `user` | Browser |
| **jimeng** | `generate` `history` | Browser |
| **yollomi** | `generate` `video` `edit` `upload` `models` `remove-bg` `upscale` `face-swap` `restore` `try-on` `background` `object-remover` | Browser |
| **linux-do** | `hot` `latest` `search` `categories` `category` `topic` | Public |
| **stackoverflow** | `hot` `search` `bounties` `unanswered` | Public |
| **steam** | `top-sellers` | Public |
| **weread** | `shelf` `search` `book` `highlights` `notes` `notebooks` `ranking` | Browser |
| **douban** | `search` `top250` `subject` `marks` `reviews` `movie-hot` `book-hot` | Browser |
| **facebook** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | Browser |
| **google** | `news` `search` `suggest` `trends` | Public |
| **instagram** | `explore` `profile` `search` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `saved` | Browser |
| **lobsters** | `hot` `newest` `active` `tag` | Public |
| **medium** | `feed` `search` `user` | Browser |
| **sinablog** | `hot` `search` `article` `user` | Browser |
| **substack** | `feed` `search` `publication` | Browser |
| **tiktok** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | Browser |
65+ adapters in total — **[→ see all supported sites & commands](./docs/adapters/index.md)**
## CLI Hub
### External CLI Hub
OpenCLI acts as a universal hub for your existing command-line tools — unified discovery, pure passthrough execution, and auto-install (if a tool isn't installed, OpenCLI runs `brew install <tool>` automatically before re-running the command).
OpenCLI acts as a universal hub for your existing command-line tools. It provides unified discovery, automatic installation, and pure passthrough execution.
| External CLI | Description | Commands Example |
|--------------|-------------|------------------|
| External CLI | Description | Example |
|--------------|-------------|---------|
| **gh** | GitHub CLI | `opencli gh pr list --limit 5` |
| **obsidian** | Obsidian vault management | `opencli obsidian search query="AI"` |
| **docker** | Docker command-line interface | `opencli docker ps` |
| **readwise** | Readwise & Reader CLI | `opencli readwise login` |
| **gws** | Google Workspace CLI — Docs, Sheets, Drive, Gmail, Calendar | `opencli gws docs list` |
| **docker** | Docker | `opencli docker ps` |
| **gws** | Google Workspace CLI | `opencli gws docs list` |
| **lark-cli** | Lark/Feishu — messages, docs, calendar, tasks, 200+ commands | `opencli lark-cli calendar +agenda` |
| **vercel** | Vercel — deploy projects, manage domains, env vars, logs | `opencli vercel deploy --prod` |
**Zero Configuration**: OpenCLI purely passes your inputs to the underlying binary via standard I/O streams. The external CLI works exactly as it naturally would, maintaining its standard output formats.
**Register your own** — add any local CLI so AI agents can discover it via `opencli list`:
**Auto-Installation**: If you run `opencli gh ...` and `gh` is not installed on your system, OpenCLI will automatically try to install it using your system's package manager (e.g., `brew install gh`) before seamlessly re-running the command.
**Register Your Own**:
Add any local CLI to your OpenCLI registry so AI agents can automatically discover it via the `opencli list` command.
```bash
opencli register mycli
```
### Desktop App Adapters
Each desktop adapter has its own detailed documentation with commands reference, setup guide, and examples:
Control Electron desktop apps directly from the terminal. Each adapter has its own detailed documentation:
| App | Description | Doc |
|-----|-------------|-----|
@@ -214,83 +158,49 @@ Each desktop adapter has its own detailed documentation with commands reference,
| **Discord** | Discord Desktop — messages, channels, servers | [Doc](./docs/adapters/desktop/discord.md) |
| **Doubao** | Control Doubao AI desktop app via CDP | [Doc](./docs/adapters/desktop/doubao-app.md) |
To add a new Electron app, start with [docs/guide/electron-app-cli.md](./docs/guide/electron-app-cli.md).
## 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 |
| **weixin** | Articles (Markdown) | Exports WeChat Official Account articles |
| **twitter** | Images, Videos | From user media tab or single tweet |
| **douban** | Images | Poster / still image lists |
| **pixiv** | Images | Original-quality illustrations, multi-page |
| **zhihu** | Articles (Markdown) | Exports with optional image download |
| **weixin** | Articles (Markdown) | WeChat Official Account articles |
### Prerequisites
For video downloads from streaming platforms, you need to install `yt-dlp`:
For video downloads, install `yt-dlp` first: `brew 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 abc123 --output ./xhs
# Download Bilibili video (requires yt-dlp)
opencli bilibili download BV1xxx --output ./bilibili
opencli bilibili download BV1xxx --quality 1080p # Specify quality
# Download Twitter media from user
opencli twitter download elonmusk --limit 20 --output ./twitter
# 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 "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
# Export with local images
opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --download-images
# Export WeChat article to Markdown
opencli weixin download --url "https://mp.weixin.qq.com/s/xxx" --output ./weixin
```
## Output Formats
All built-in commands support `--format` / `-f` with `table`, `json`, `yaml`, `md`, and `csv`.
The `list` command supports the same format options, and keeps `--json` for backward compatibility.
All built-in commands support `--format` / `-f` with `table` (default), `json`, `yaml`, `md`, and `csv`.
```bash
opencli list -f yaml # Command registry as YAML
opencli bilibili hot -f table # Default: rich terminal table
opencli bilibili hot -f json # JSON (pipe to jq or LLMs)
opencli bilibili hot -f yaml # YAML (human-readable structured output)
opencli bilibili hot -f md # Markdown
opencli bilibili hot -f csv # CSV
opencli bilibili hot -f json # Pipe to jq or LLMs
opencli bilibili hot -f csv # Spreadsheet-friendly
opencli bilibili hot -v # Verbose: show pipeline debug steps
```
## Plugins
Extend OpenCLI with community-contributed adapters. Plugins use the same YAML/TS format as built-in commands and are automatically discovered at startup.
Extend OpenCLI with community-contributed adapters:
```bash
opencli plugin install github:user/opencli-plugin-my-tool # Install
opencli plugin list # List installed
opencli plugin update my-tool # Update to latest
opencli plugin uninstall my-tool # Remove
opencli plugin install github:user/opencli-plugin-my-tool
opencli plugin list
opencli plugin update --all
opencli plugin uninstall my-tool
```
| Plugin | Type | Description |
@@ -303,53 +213,33 @@ See [Plugins Guide](./docs/guide/plugins.md) for creating your own plugin.
## For AI Agents (Developer Guide)
If you are an AI assistant tasked with creating a new command adapter for `opencli`, please follow the AI Agent workflow below:
> **Quick mode**: To generate a single command for a specific page URL, see [CLI-ONESHOT.md](./CLI-ONESHOT.md) — just a URL + one-line goal, 4 steps done.
> **Full mode**: Before writing any adapter code, read [CLI-EXPLORER.md](./CLI-EXPLORER.md). It contains the complete browser exploration workflow, the 5-tier authentication strategy decision tree, and debugging guide.
```bash
# 1. Deep Explore — discover APIs, infer capabilities, detect framework
opencli explore https://example.com --site mysite
# 2. Synthesize — generate YAML adapters from explore artifacts
opencli synthesize mysite
# 3. Generate — one-shot: explore → synthesize → register
opencli generate https://example.com --goal "hot"
# 4. Strategy Cascade — auto-probe: PUBLIC → COOKIE → HEADER
opencli cascade https://api.example.com/data
opencli explore https://example.com --site mysite # Discover APIs + capabilities
opencli synthesize mysite # Generate YAML adapters
opencli generate https://example.com --goal "hot" # One-shot: explore → synthesize → register
opencli cascade https://api.example.com/data # Auto-probe: PUBLIC → COOKIE → HEADER
```
Explore outputs to `.opencli/explore/<site>/` (manifest.json, endpoints.json, capabilities.json, auth.json).
## Testing
See **[TESTING.md](./TESTING.md)** for how to run and write tests.
## Troubleshooting
- **"Extension not connected"**
- Ensure the opencli Browser Bridge extension is installed and **enabled** in `chrome://extensions`.
- **"attach failed: Cannot access a chrome-extension:// URL"**
- Another Chrome extension (e.g. youmind, New Tab Override, or AI assistant extensions) may be interfering. Try **disabling other extensions** temporarily, then retry.
- **Empty data returns or 'Unauthorized' error**
- Your login session in Chrome might have expired. Open a normal Chrome tab, navigate to the target site, and log in or refresh the page.
- **Node API errors**
- 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`
- **"Extension not connected"** — Ensure the Browser Bridge extension is installed and **enabled** in `chrome://extensions`.
- **"attach failed: Cannot access a chrome-extension:// URL"** — Another extension may be interfering. Try disabling other extensions temporarily.
- **Empty data or 'Unauthorized' error** — Your Chrome login session may have expired. Navigate to the target site and log in again.
- **Node API errors** — Ensure Node.js >= 20. Some dependencies require modern Node APIs.
- **Daemon issues** — Check status: `curl localhost:19825/status` · View logs: `curl localhost:19825/logs`
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=jackwener/opencli&type=Date)](https://star-history.com/#jackwener/opencli&Date)
## License
[Apache-2.0](./LICENSE)
+21 -7
View File
@@ -3,8 +3,7 @@
> **把任何网站、本地工具、Electron 应用变成能够让 AI 调用的命令行!**
> 零风控 · 复用 Chrome 登录 · AI 自动发现接口 · 全能 CLI 枢纽
[English](./README.md)
[![English](https://img.shields.io/badge/docs-English-1D4ED8?style=flat-square)](./README.md)
[![npm](https://img.shields.io/npm/v/@jackwener/opencli?style=flat-square)](https://www.npmjs.com/package/@jackwener/opencli)
[![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)
@@ -132,9 +131,9 @@ npm install -g @jackwener/opencli@latest
| **notion** | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` | 桌面端 |
| **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` | 桌面端 |
| **v2ex** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | 公开 / 浏览器 |
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` `earnings-date` | 浏览器 |
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` `earnings-date` `fund-holdings` `fund-snapshot` | 浏览器 |
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | 桌面端 |
| **chatgpt** | `status` `new` `send` `read` `ask` | 桌面端 |
| **chatgpt** | `status` `new` `send` `read` `ask` `model` | 桌面端 |
| **xiaohongshu** | `search` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 浏览器 |
| **apple-podcasts** | `search` `episodes` `top` | 公开 |
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | 公开 |
@@ -149,11 +148,14 @@ npm install -g @jackwener/opencli@latest
| **devto** | `top` `tag` `user` | 公开 |
| **dictionary** | `search` `synonyms` `examples` | 公开 |
| **arxiv** | `search` `paper` | 公开 |
| **paperreview** | `submit` `review` `feedback` | 公开 |
| **wikipedia** | `search` `summary` `random` `trending` | 公开 |
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | 公共 API |
| **linkedin** | `search` | 浏览器 |
| **jd** | `item` | 浏览器 |
| **linkedin** | `search` `timeline` | 浏览器 |
| **reuters** | `search` | 浏览器 |
| **smzdm** | `search` | 浏览器 |
| **web** | `read` | 浏览器 |
| **weibo** | `hot` `search` | 浏览器 |
| **yahoo-finance** | `quote` | 浏览器 |
| **sinafinance** | `news` | 🌐 公开 |
@@ -164,18 +166,22 @@ npm install -g @jackwener/opencli@latest
| **jike** | `feed` `search` `create` `like` `comment` `repost` `notifications` `post` `topic` `user` | 浏览器 |
| **jimeng** | `generate` `history` | 浏览器 |
| **yollomi** | `generate` `video` `edit` `upload` `models` `remove-bg` `upscale` `face-swap` `restore` `try-on` `background` `object-remover` | 浏览器 |
| **linux-do** | `hot` `latest` `search` `categories` `category` `topic` | 公开 |
| **linux-do** | `feed` `categories` `tags` `search` `topic` `user-topics` `user-posts` | 浏览器 |
| **stackoverflow** | `hot` `search` `bounties` `unanswered` | 公开 |
| **steam** | `top-sellers` | 公开 |
| **weread** | `shelf` `search` `book` `highlights` `notes` `notebooks` `ranking` | 浏览器 |
| **douban** | `search` `top250` `subject` `marks` `reviews` `movie-hot` `book-hot` | 浏览器 |
| **douban** | `search` `top250` `subject` `photos` `download` `marks` `reviews` `movie-hot` `book-hot` | 浏览器 |
| **facebook** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | 浏览器 |
| **google** | `news` `search` `suggest` `trends` | 公开 |
| **36kr** | `news` `hot` `search` `article` | 公开 / 浏览器 |
| **imdb** | `search` `title` `top` `trending` `person` `reviews` | 公开 |
| **producthunt** | `posts` `today` `hot` `browse` | 公开 / 浏览器 |
| **instagram** | `explore` `profile` `search` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `saved` | 浏览器 |
| **lobsters** | `hot` `newest` `active` `tag` | 公开 |
| **medium** | `feed` `search` `user` | 浏览器 |
| **sinablog** | `hot` `search` `article` `user` | 浏览器 |
| **substack** | `feed` `search` `publication` | 浏览器 |
| **pixiv** | `ranking` `search` `user` `illusts` `detail` `download` | 浏览器 |
| **tiktok** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | 浏览器 |
@@ -227,8 +233,10 @@ OpenCLI 支持从各平台下载图片、视频和文章。
| **小红书** | 图片、视频 | 下载笔记中的所有媒体文件 |
| **B站** | 视频 | 需要安装 `yt-dlp` |
| **Twitter/X** | 图片、视频 | 从用户媒体页或单条推文下载 |
| **Pixiv** | 图片 | 下载原始画质插画,支持多页作品 |
| **知乎** | 文章(Markdown) | 导出文章,可选下载图片到本地 |
| **微信公众号** | 文章(Markdown | 导出微信公众号文章为 Markdown |
| **豆瓣** | 图片 | 下载电影条目的海报 / 剧照图片 |
### 前置依赖
@@ -257,6 +265,9 @@ opencli twitter download elonmusk --limit 20 --output ./twitter
# 下载单条推文的媒体
opencli twitter download --tweet-url "https://x.com/user/status/123" --output ./twitter
# 下载豆瓣电影海报 / 剧照
opencli douban download 30382501 --output ./douban
# 导出知乎文章为 Markdown
opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
@@ -292,9 +303,12 @@ opencli bilibili hot -v # 详细模式:展示管线执行步骤调试
opencli plugin install github:user/opencli-plugin-my-tool # 安装
opencli plugin list # 查看已安装
opencli plugin update my-tool # 更新到最新
opencli plugin update --all # 更新全部已安装插件
opencli plugin uninstall my-tool # 卸载
```
当 plugin 的版本被记录到 `~/.opencli/plugins.lock.json` 后,`opencli plugin list` 也会显示对应的短 commit hash。
| 插件 | 类型 | 描述 |
|------|------|------|
| [opencli-plugin-github-trending](https://github.com/ByteYue/opencli-plugin-github-trending) | YAML | GitHub Trending 仓库 |
+109 -2
View File
@@ -1,7 +1,7 @@
---
name: opencli
description: "OpenCLI — Make any website or Electron App your CLI. Zero risk, AI-powered, reuse Chrome login."
version: 1.3.1
version: 1.4.1
author: jackwener
tags: [cli, browser, web, chrome-extension, cdp, bilibili, zhihu, twitter, github, v2ex, hackernews, reddit, xiaohongshu, xueqiu, youtube, boss, coupang, yollomi, AI, agent]
---
@@ -15,6 +15,12 @@ tags: [cli, browser, web, chrome-extension, cdp, bilibili, zhihu, twitter, githu
> 该文档包含完整的 API 发现工作流(必须使用浏览器探索)、5 级认证策略决策树、平台 SDK 速查表、`tap` 步骤调试流程、分页 API 模板、级联请求模式、以及常见陷阱。
> **本文件(SKILL.md)仅提供命令参考和简化模板,不足以正确开发适配器。**
> [!IMPORTANT]
> 创建或修改 adapter 时,再额外遵守 3 条收口规则:
> 1. 主参数优先用 positional arg,不要把 `query` / `id` / `url` 默认做成 `--query` / `--id` / `--url`
> 2. 预期中的 adapter 失败优先抛 `CliError` 子类,不要直接 throw 原始 `Error`
> 3. 新增 adapter 或新增用户可发现命令时,同步更新 adapter docs、`docs/adapters/index.md`、sidebar,以及 README/README.zh-CN 中受影响的入口
## Install & Run
```bash
@@ -82,6 +88,9 @@ opencli xueqiu watchlist # 获取自选股/持仓列表
opencli xueqiu feed # 我的关注 timeline
opencli xueqiu hot --limit 10 # 雪球热榜
opencli xueqiu search "特斯拉" # 搜索 (query positional)
opencli xueqiu earnings-date SH600519 # 股票财报发布日期 (symbol positional)
opencli xueqiu fund-holdings # 蛋卷基金持仓明细 (支持 --account 过滤)
opencli xueqiu fund-snapshot # 蛋卷基金快照(总资产、子账户、持仓)
# GitHub (via gh External CLI)
opencli gh repo list # 列出仓库 (passthrough to gh)
@@ -100,6 +109,19 @@ opencli twitter follow elonmusk # 关注用户
opencli twitter unfollow elonmusk # 取消关注
opencli twitter bookmark https://x.com/... # 收藏推文
opencli twitter unbookmark https://x.com/... # 取消收藏
opencli twitter post "Hello world" # 发布推文 (text positional)
opencli twitter like https://x.com/... # 点赞推文 (url positional)
opencli twitter reply https://x.com/... "Nice!" # 回复推文 (url + text positional)
opencli twitter delete https://x.com/... # 删除推文 (url positional)
opencli twitter block elonmusk # 屏蔽用户 (username positional)
opencli twitter unblock elonmusk # 取消屏蔽 (username positional)
opencli twitter followers elonmusk # 用户的粉丝列表 (user positional)
opencli twitter following elonmusk # 用户的关注列表 (user positional)
opencli twitter notifications --limit 20 # 通知列表
opencli twitter hide-reply https://x.com/... # 隐藏回复 (url positional)
opencli twitter download elonmusk # 下载用户媒体 (username positional, 支持 --tweet-url)
opencli twitter accept "群,微信" # 自动接受含关键词的 DM 请求 (query positional)
opencli twitter reply-dm "消息内容" # 批量回复 DM (text positional)
# Reddit (browser)
opencli reddit hot --limit 10 # 热门帖子
@@ -126,9 +148,21 @@ opencli v2ex topic 1024 # 主题详情 (id positional)
opencli v2ex daily # 每日签到 (browser)
opencli v2ex me # 我的信息 (browser)
opencli v2ex notifications --limit 10 # 通知 (browser)
opencli v2ex node python # 节点话题列表 (name positional)
opencli v2ex nodes --limit 30 # 所有节点列表
opencli v2ex member username # 用户资料 (username positional)
opencli v2ex user username # 用户发帖列表 (username positional)
opencli v2ex replies 1024 # 主题回复列表 (id positional)
# Hacker News (public)
opencli hackernews top --limit 10 # Top stories
opencli hackernews new --limit 10 # Newest stories
opencli hackernews best --limit 10 # Best stories
opencli hackernews ask --limit 10 # Ask HN posts
opencli hackernews show --limit 10 # Show HN posts
opencli hackernews jobs --limit 10 # Job postings
opencli hackernews search "rust" # 搜索 (query positional)
opencli hackernews user dang # 用户资料 (username positional)
# BBC (public)
opencli bbc news --limit 10 # BBC News RSS headlines
@@ -198,11 +232,13 @@ opencli jike comment xxx "评论" # 评论 (id + text positional)
opencli jike repost xxx # 转发 (id positional)
opencli jike notifications # 通知
# Linux.do (public)
# Linux.do (public + browser)
opencli linux-do hot --limit 10 # 热门话题
opencli linux-do latest --limit 10 # 最新话题
opencli linux-do search "rust" # 搜索 (query positional)
opencli linux-do topic 1024 # 主题详情 (id positional)
opencli linux-do categories --limit 20 # 分类列表 (browser)
opencli linux-do category dev 7 # 分类内话题 (slug + id positional, browser)
# StackOverflow (public)
opencli stackoverflow hot --limit 10 # 热门问题
@@ -228,6 +264,12 @@ opencli yollomi video "提示词" --model kling-2-1 # 视频
opencli yollomi upload ./photo.jpg # 上传得 URL,供 img2img / 工具链使用
opencli yollomi remove-bg <image-url> # 去背景(免费)
opencli yollomi edit <image-url> "改成油画风格" # Qwen 图像编辑
opencli yollomi background <image-url> # AI 背景生成 (5 credits)
opencli yollomi face-swap --source <url> --target <url> # 换脸 (3 credits)
opencli yollomi object-remover <image-url> <mask-url> # AI 去除物体 (3 credits)
opencli yollomi restore <image-url> # AI 修复老照片 (4 credits)
opencli yollomi try-on --person <url> --cloth <url> # 虚拟试衣 (3 credits)
opencli yollomi upscale <image-url> # AI 超分辨率 (1 credit, 支持 --scale 2/4)
# Grok (default + explicit web)
opencli grok ask --prompt "问题" # 提问 Grok(兼容默认路径)
@@ -244,6 +286,8 @@ opencli chaoxing exams # 考试列表
opencli douban search "三体" # 搜索 (query positional)
opencli douban top250 # 豆瓣 Top 250
opencli douban subject 1234567 # 条目详情 (id positional)
opencli douban photos 30382501 # 图片列表 / 直链(默认海报)
opencli douban download 30382501 # 下载海报 / 剧照
opencli douban marks --limit 10 # 我的标记
opencli douban reviews --limit 10 # 短评
@@ -328,6 +372,69 @@ opencli devto user username # 用户文章 (username positional)
# Steam (public)
opencli steam top-sellers --limit 10 # 热销游戏
# Apple Podcasts (public)
opencli apple-podcasts top --limit 10 # 热门播客排行榜 (支持 --country us/cn/gb/jp)
opencli apple-podcasts search "科技" # 搜索播客 (query positional)
opencli apple-podcasts episodes 12345 # 播客剧集列表 (id positional, 用 search 获取 ID)
# arXiv (public)
opencli arxiv search "attention" # 搜索论文 (query positional)
opencli arxiv paper 1706.03762 # 论文详情 (id positional)
# Bloomberg (public RSS + browser)
opencli bloomberg main --limit 10 # Bloomberg 首页头条 (RSS)
opencli bloomberg markets --limit 10 # 市场新闻 (RSS)
opencli bloomberg tech --limit 10 # 科技新闻 (RSS)
opencli bloomberg politics --limit 10 # 政治新闻 (RSS)
opencli bloomberg economics --limit 10 # 经济新闻 (RSS)
opencli bloomberg opinions --limit 10 # 观点 (RSS)
opencli bloomberg industries --limit 10 # 行业新闻 (RSS)
opencli bloomberg businessweek --limit 10 # Businessweek (RSS)
opencli bloomberg feeds # 列出所有 RSS feed 别名
opencli bloomberg news "https://..." # 阅读 Bloomberg 文章全文 (link positional, browser)
# Coupang 쿠팡 (browser)
opencli coupang search "耳机" # 搜索商品 (query positional, 支持 --filter rocket)
opencli coupang add-to-cart 12345 # 加入购物车 (product-id positional, 或 --url)
# Dictionary (public)
opencli dictionary search "serendipity" # 单词释义 (word positional)
opencli dictionary synonyms "happy" # 近义词 (word positional)
opencli dictionary examples "ubiquitous" # 例句 (word positional)
# 豆包 Doubao Web (browser)
opencli doubao status # 检查豆包页面状态
opencli doubao new # 新建对话
opencli doubao send "你好" # 发送消息 (text positional)
opencli doubao read # 读取对话记录
opencli doubao ask "问题" # 一键提问并等回复 (text positional)
# 京东 JD (browser)
opencli jd item 100291143898 # 商品详情 (sku positional, 含价格/主图/规格)
# LinkedIn (browser)
opencli linkedin search "AI engineer" # 搜索职位 (query positional, 支持 --location/--company/--remote)
opencli linkedin timeline --limit 20 # 首页动态流
# Pixiv (browser)
opencli pixiv ranking --limit 20 # 插画排行榜 (支持 --mode daily/weekly/monthly)
opencli pixiv search "風景" # 搜索插画 (query positional)
opencli pixiv user 12345 # 画师资料 (uid positional)
opencli pixiv illusts 12345 # 画师作品列表 (user-id positional)
opencli pixiv detail 12345 # 插画详情 (id positional)
opencli pixiv download 12345 # 下载插画 (illust-id positional)
# Web (browser)
opencli web read --url "https://..." # 抓取任意网页并导出为 Markdown
# 微信公众号 Weixin (browser)
opencli weixin download --url "https://mp.weixin.qq.com/s/xxx" # 下载公众号文章为 Markdown
# 小宇宙 Xiaoyuzhou (public)
opencli xiaoyuzhou podcast 12345 # 播客资料 (id positional)
opencli xiaoyuzhou podcast-episodes 12345 # 播客剧集列表 (id positional)
opencli xiaoyuzhou episode 12345 # 单集详情 (id positional)
# Wikipedia (public)
opencli wikipedia search "AI" # 搜索 (query positional)
opencli wikipedia summary "Python" # 摘要 (title positional)
+1
View File
@@ -68,6 +68,7 @@ src/
| `tests/e2e/browser-auth.test.ts` | `bilibili``twitter``v2ex``xueqiu``linux-do``xiaohongshu` 的需登录命令 graceful failure |
| `tests/e2e/management.test.ts` | `list``validate``verify``--version``--help`、unknown command |
| `tests/e2e/output-formats.test.ts` | `json` / `yaml` / `csv` / `md` 输出格式校验 |
| `tests/e2e/plugin-management.test.ts` | `plugin install` / `list` / `update` / `uninstall` 全生命周期 |
### 烟雾测试(1 个文件)
+82
View File
@@ -0,0 +1,82 @@
[CmdletBinding()]
param(
[Parameter(ValueFromRemainingArguments = $true)]
[string[]]$OpenCliArgs
)
$ErrorActionPreference = 'Stop'
$chatwiseExe = 'C:\Program Files\ChatWise\ChatWise.exe'
if (-not (Test-Path $chatwiseExe)) {
throw "ChatWise executable not found at $chatwiseExe"
}
$opencli = Get-Command opencli -ErrorAction SilentlyContinue
if (-not $opencli) {
throw 'opencli was not found in PATH'
}
function Clear-LocalProxyEnv {
$vars = 'http_proxy','https_proxy','HTTP_PROXY','HTTPS_PROXY'
foreach ($name in $vars) {
Set-Item -Path "Env:$name" -Value ''
}
$noProxy = '127.0.0.1,localhost'
Set-Item -Path 'Env:NO_PROXY' -Value $noProxy
Set-Item -Path 'Env:no_proxy' -Value $noProxy
}
function Stop-ChatWiseTree {
$candidates = Get-CimInstance Win32_Process |
Where-Object { $_.Name -match '^ChatWise\.exe$|^chatwise\.exe$' }
foreach ($proc in $candidates) {
try {
Stop-Process -Id $proc.ProcessId -Force -ErrorAction Stop
} catch {}
}
Start-Sleep -Seconds 2
}
function Wait-ChatWiseDebugPort {
param(
[int]$Port = 9228,
[int]$TimeoutSeconds = 20
)
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) {
try {
$resp = Invoke-WebRequest -UseBasicParsing -TimeoutSec 2 -Uri "http://127.0.0.1:$Port/json/version"
if ($resp.StatusCode -ge 200 -and $resp.StatusCode -lt 300) {
return
}
} catch {}
Start-Sleep -Milliseconds 500
}
throw "ChatWise debugging endpoint did not come up on 127.0.0.1:$Port"
}
Clear-LocalProxyEnv
Stop-ChatWiseTree
$proc = Start-Process -FilePath $chatwiseExe -ArgumentList '--remote-debugging-port=9228' -PassThru
Start-Sleep -Seconds 4
if ($proc.HasExited) {
throw "ChatWise exited early with code $($proc.ExitCode)"
}
Wait-ChatWiseDebugPort
$env:OPENCLI_CDP_ENDPOINT = 'http://127.0.0.1:9228'
if (-not $OpenCliArgs -or $OpenCliArgs.Count -eq 0) {
& $opencli.Source 'chatwise' 'status'
exit $LASTEXITCODE
}
& $opencli.Source @OpenCliArgs
exit $LASTEXITCODE
+18
View File
@@ -32,6 +32,7 @@ export default defineConfig({
{ text: 'Comparison', link: '/comparison' },
{ text: 'Browser Bridge', link: '/guide/browser-bridge' },
{ text: 'Troubleshooting', link: '/guide/troubleshooting' },
{ text: 'Add an Electron App CLI', link: '/guide/electron-app-cli' },
{ text: 'Plugins', link: '/guide/plugins' },
],
},
@@ -73,6 +74,18 @@ export default defineConfig({
{ text: 'Douban', link: '/adapters/browser/douban' },
{ text: 'Sina Blog', link: '/adapters/browser/sinablog' },
{ text: 'Substack', link: '/adapters/browser/substack' },
{ text: 'Pixiv', link: '/adapters/browser/pixiv' },
{ text: 'Douban', link: '/adapters/browser/douban' },
{ text: 'Doubao', link: '/adapters/browser/doubao' },
{ text: 'Facebook', link: '/adapters/browser/facebook' },
{ text: 'Google', link: '/adapters/browser/google' },
{ text: 'IMDb', link: '/adapters/browser/imdb' },
{ text: 'Instagram', link: '/adapters/browser/instagram' },
{ text: 'JD.com', link: '/adapters/browser/jd' },
{ text: 'Medium', link: '/adapters/browser/medium' },
{ text: 'TikTok', link: '/adapters/browser/tiktok' },
{ text: 'Web (Generic)', link: '/adapters/browser/web' },
{ text: 'Weixin', link: '/adapters/browser/weixin' },
],
},
{
@@ -87,11 +100,14 @@ export default defineConfig({
{ text: 'Xiaoyuzhou', link: '/adapters/browser/xiaoyuzhou' },
{ text: 'Yahoo Finance', link: '/adapters/browser/yahoo-finance' },
{ text: 'arXiv', link: '/adapters/browser/arxiv' },
{ text: 'paperreview.ai', link: '/adapters/browser/paperreview' },
{ text: 'Barchart', link: '/adapters/browser/barchart' },
{ text: 'Hugging Face', link: '/adapters/browser/hf' },
{ text: 'Sina Finance', link: '/adapters/browser/sinafinance' },
{ text: 'Stack Overflow', link: '/adapters/browser/stackoverflow' },
{ text: 'Wikipedia', link: '/adapters/browser/wikipedia' },
{ text: 'Lobsters', link: '/adapters/browser/lobsters' },
{ text: 'Steam', link: '/adapters/browser/steam' },
],
},
{
@@ -105,6 +121,7 @@ export default defineConfig({
{ text: 'ChatWise', link: '/adapters/desktop/chatwise' },
{ text: 'Notion', link: '/adapters/desktop/notion' },
{ text: 'Discord', link: '/adapters/desktop/discord' },
{ text: 'Doubao App', link: '/adapters/desktop/doubao-app' },
],
},
],
@@ -154,6 +171,7 @@ export default defineConfig({
{ text: '快速开始', link: '/zh/guide/getting-started' },
{ text: '安装', link: '/zh/guide/installation' },
{ text: 'Browser Bridge', link: '/zh/guide/browser-bridge' },
{ text: '给新 Electron 应用生成 CLI', link: '/zh/guide/electron-app-cli' },
{ text: '插件', link: '/zh/guide/plugins' },
],
},
+47
View File
@@ -0,0 +1,47 @@
# 36kr (36氪)
**Mode**: 🌐 Public / 🔐 Browser · **Domain**: `36kr.com`
## Commands
| Command | Description |
|---------|-------------|
| `opencli 36kr hot` | 36氪热榜 — trending articles |
| `opencli 36kr news` | Latest tech/startup news from 36kr |
| `opencli 36kr search <query>` | Search 36kr articles |
| `opencli 36kr article <id-or-url>` | Read full article content |
## Usage Examples
```bash
# Trending articles
opencli 36kr hot --limit 10
# Hot by type
opencli 36kr hot --type renqi --limit 10
opencli 36kr hot --type zonghe --limit 10
# Latest news
opencli 36kr news --limit 20
# Search articles
opencli 36kr search "AI" --limit 10
opencli 36kr search "OpenAI" --limit 5
# Read full article (by ID or URL)
opencli 36kr article 3000000123456
opencli 36kr article https://36kr.com/p/3000000123456
# JSON output
opencli 36kr hot -f json
```
## Notes
- `news` uses the public RSS feed and works without Browser Bridge.
- `hot`, `search`, and `article` use Browser Bridge and are best run with Chrome open.
- `hot --type` accepts `catalog`, `renqi`, `zonghe`, and `shoucang`.
## Prerequisites
- No browser required — uses public API
+53
View File
@@ -0,0 +1,53 @@
# Bluesky
**Mode**: 🌐 Public · **Domain**: `bsky.app`
## Commands
| Command | Description |
|---------|-------------|
| `opencli bluesky profile` | User profile info |
| `opencli bluesky user` | Recent posts from a user |
| `opencli bluesky trending` | Trending topics |
| `opencli bluesky search` | Search users |
| `opencli bluesky feeds` | Popular feed generators |
| `opencli bluesky followers` | User's followers |
| `opencli bluesky following` | Accounts a user follows |
| `opencli bluesky thread` | Post thread with replies |
| `opencli bluesky starter-packs` | User's starter packs |
## Usage Examples
```bash
# User profile
opencli bluesky profile --handle bsky.app
# Recent posts
opencli bluesky user --handle bsky.app --limit 10
# Trending topics
opencli bluesky trending --limit 10
# Search users
opencli bluesky search --query "AI" --limit 10
# Popular feeds
opencli bluesky feeds --limit 10
# Followers / following
opencli bluesky followers --handle bsky.app --limit 10
opencli bluesky following --handle bsky.app
# Post thread with replies
opencli bluesky thread --uri "at://did:.../app.bsky.feed.post/..."
# Starter packs
opencli bluesky starter-packs --handle bsky.app
# JSON output
opencli bluesky profile --handle bsky.app -f json
```
## Prerequisites
None — all commands use the public Bluesky AT Protocol API, no browser or login required.
+14
View File
@@ -9,6 +9,8 @@
| `opencli douban search` | 搜索豆瓣电影、图书或音乐 |
| `opencli douban top250` | 豆瓣电影 Top 250 |
| `opencli douban subject` | 条目详情 |
| `opencli douban photos` | 获取电影海报/剧照图片列表 |
| `opencli douban download` | 下载电影海报/剧照图片 |
| `opencli douban marks` | 我的标记 |
| `opencli douban reviews` | 我的短评 |
| `opencli douban movie-hot` | 豆瓣电影热门榜单 |
@@ -32,6 +34,18 @@ opencli douban top250 --limit 10
# 条目详情
opencli douban subject 1292052
# 获取海报直链(默认 type=Rb)
opencli douban photos 30382501 --limit 20
# 下载海报到本地目录
opencli douban download 30382501 --output ./douban
# 只下载指定 photo_id 的一张图
opencli douban download 30382501 --photo-id 2913621075 --output ./douban
# 返回 JSON,便于上层界面直接渲染图片并右键取图
opencli douban photos 30382501 -f json
# 电影热门
opencli douban movie-hot --limit 10
+75
View File
@@ -0,0 +1,75 @@
# Douyin (抖音创作者中心)
**Mode**: 🔐 Browser · **Domain**: `creator.douyin.com`
## Commands
| Command | Description |
|---------|-------------|
| `opencli douyin profile` | 获取账号信息 |
| `opencli douyin videos` | 获取作品列表 |
| `opencli douyin drafts` | 获取草稿列表 |
| `opencli douyin draft` | 上传视频并保存为草稿 |
| `opencli douyin publish` | 定时发布视频到抖音 |
| `opencli douyin update` | 更新视频信息 |
| `opencli douyin delete` | 删除作品 |
| `opencli douyin stats` | 查询作品数据分析 |
| `opencli douyin collections` | 获取合集列表 |
| `opencli douyin activities` | 获取官方活动列表 |
| `opencli douyin location` | 搜索发布可用的地理位置 |
| `opencli douyin hashtag search` | 按关键词搜索话题 |
| `opencli douyin hashtag suggest` | 基于封面 URI 推荐话题 |
| `opencli douyin hashtag hot` | 获取热点词 |
## Usage Examples
```bash
# 账号与作品
opencli douyin profile
opencli douyin videos --limit 10
opencli douyin videos --status scheduled
opencli douyin drafts
# 发布前辅助信息
opencli douyin collections
opencli douyin activities
opencli douyin location "东京塔"
opencli douyin hashtag search "春游"
opencli douyin hashtag hot --limit 10
# 保存草稿
opencli douyin draft ./video.mp4 \
--title "春游 vlog" \
--caption "#春游 先存草稿"
# 定时发布
opencli douyin publish ./video.mp4 \
--title "春游 vlog" \
--caption "#春游 今天去看樱花" \
--schedule "2026-04-08T12:00:00+09:00"
# 也支持 Unix 秒字符串
opencli douyin publish ./video.mp4 \
--title "春游 vlog" \
--schedule 1775617200
# 更新与删除
opencli douyin update 1234567890 --caption "更新后的文案"
opencli douyin update 1234567890 --reschedule "2026-04-09T20:00:00+09:00"
opencli douyin delete 1234567890
# JSON 输出
opencli douyin profile -f json
```
## Prerequisites
- Chrome running and **logged into** `creator.douyin.com`
- The logged-in account must have access to Douyin Creator Center publishing features
- [Browser Bridge extension](/guide/browser-bridge) installed
## Notes
- `publish` requires `--schedule` to be at least 2 hours later and no more than 14 days later
- `draft` and `publish` upload the video through Douyin/ByteDance browser-authenticated APIs, so cookies in the active browser session must be valid
- `hashtag suggest` expects a valid `cover`/`cover_uri` value produced during the publish pipeline; for normal manual use, `hashtag search` and `hashtag hot` are usually more convenient
+47
View File
@@ -0,0 +1,47 @@
# IMDb
**Mode**: 🌐 Public (Browser) · **Domain**: `www.imdb.com`
## Commands
| Command | Description |
|---------|-------------|
| `opencli imdb search` | Search movies, TV shows, and people |
| `opencli imdb title` | Get movie or TV show details |
| `opencli imdb top` | IMDb Top 250 Movies |
| `opencli imdb trending` | IMDb Most Popular Movies |
| `opencli imdb person` | Get actor or director info |
| `opencli imdb reviews` | Get user reviews for a title |
## Usage Examples
```bash
# Search for a movie
opencli imdb search "inception" --limit 10
# Get movie details
opencli imdb title tt1375666
# Get TV series details (also accepts full URL)
opencli imdb title "https://www.imdb.com/title/tt0903747/"
# Top 250 movies
opencli imdb top --limit 20
# Currently trending movies
opencli imdb trending --limit 10
# Actor/director info with filmography
opencli imdb person nm0634240 --limit 5
# User reviews
opencli imdb reviews tt1375666 --limit 5
# JSON output
opencli imdb top --limit 5 -f json
```
## Prerequisites
- Chrome with Browser Bridge extension installed
- No login required (all data is public)
+2 -2
View File
@@ -6,7 +6,7 @@
| Command | Description |
|---------|-------------|
| `opencli jd item <sku>` | Fetch product details (price, images, specs) |
| `opencli jd item <sku>` | Fetch product details (price, shop, specs, AVIF images) |
## Usage Examples
@@ -14,7 +14,7 @@
# Get product details by SKU
opencli jd item 100291143898
# Limit detail images
# Limit returned AVIF images
opencli jd item 100291143898 --images 5
# JSON output
+182 -21
View File
@@ -6,37 +6,198 @@
| Command | Description |
|---------|-------------|
| `opencli linux-do hot` | 热门话题 |
| `opencli linux-do latest` | 最新话题 |
| `opencli linux-do categories` | 板块列表 |
| `opencli linux-do category` | 板块话题 |
| `opencli linux-do search` | 搜索话题 |
| `opencli linux-do topic` | 话题详情 |
| `opencli linux-do feed` | Browse topics (site-wide, by tag, or by category) |
| `opencli linux-do categories` | List all categories |
| `opencli linux-do tags` | List popular tags |
| `opencli linux-do search <query>` | Search topics |
| `opencli linux-do topic <id>` | View topic posts |
| `opencli linux-do user-topics <username>` | Topics created by a user |
| `opencli linux-do user-posts <username>` | Replies posted by a user |
## Usage Examples
## feed
Browse topic listings. Defaults to latest topics when called with no arguments.
- Supports filtering by `--tag`, `--category`, or both
- `--tag` accepts tag name, slug, or ID
- `--category` accepts category name, slug, ID, or `Parent / Child` path for sub-categories
- Use `--view` to switch between latest / hot / top
### Basic
```bash
# Hot topics this week
opencli linux-do hot --limit 20
# Latest topics (default)
opencli linux-do feed
# Hot topics by period
opencli linux-do hot --period daily
opencli linux-do hot --period monthly
# Hot topics
opencli linux-do feed --view hot
# Latest topics
opencli linux-do latest --limit 10
# Top topics — default period is weekly
opencli linux-do feed --view top
opencli linux-do feed --view top --period daily
opencli linux-do feed --view top --period monthly
# List all categories
opencli linux-do categories
# Sort by views descending
opencli linux-do feed --order views
# Search topics
opencli linux-do search "NixOS"
# Sort by created time ascending
opencli linux-do feed --order created --ascending
# View topic details
opencli linux-do topic 12345
# Limit results
opencli linux-do feed --limit 10
# JSON output
opencli linux-do hot -f json
opencli linux-do feed -f json
```
### Filter by tag
```bash
# By tag name, slug, or ID — all equivalent
opencli linux-do feed --tag "ChatGPT"
opencli linux-do feed --tag chatgpt
opencli linux-do feed --tag 3
# Tag + hot view
opencli linux-do feed --tag "ChatGPT" --view hot
# Tag + top view with period
opencli linux-do feed --tag "OpenAI" --view top --period monthly
```
### Filter by category
Supports both top-level and sub-categories. Sub-categories auto-resolve their parent path.
```bash
# Top-level category — name, slug, or ID
opencli linux-do feed --category "开发调优"
opencli linux-do feed --category develop
opencli linux-do feed --category 4
# Sub-category
opencli linux-do feed --category "开发调优 / Lv1"
opencli linux-do feed --category "网盘资源"
# Category + hot / top view
opencli linux-do feed --category "开发调优" --view hot
opencli linux-do feed --category "开发调优" --view top --period weekly
```
### Category + tag
Combine `--category` and `--tag` to narrow results within a category.
```bash
opencli linux-do feed --category "开发调优" --tag "ChatGPT"
opencli linux-do feed --category "网盘资源" --tag "OpenAI"
opencli linux-do feed --category 94 --tag 4 --view top --period monthly
```
### Parameters
| Parameter | Description | Default |
|-----------|-------------|---------|
| `--view V` | `latest`, `hot`, `top` | `latest` |
| `--tag VALUE` | Tag name, slug, or ID | — |
| `--category VALUE` | Category name, slug, or ID | — |
| `--limit N` | Number of results | `20` |
| `--order O` | `default`, `created`, `activity`, `views`, `posts`, `category`, `likes`, `op_likes`, `posters` | `default` |
| `--ascending` | Sort ascending instead of descending | off |
| `--period P` | `all`, `daily`, `weekly`, `monthly`, `quarterly`, `yearly` (only with `--view top`) | `weekly` |
Output columns: `title`, `replies`, `created`, `likes`, `views`, `url`
## categories
List forum categories with optional sub-category expansion.
```bash
opencli linux-do categories
opencli linux-do categories --subcategories
opencli linux-do categories --limit 50
```
When `--subcategories` is enabled, sub-categories are rendered as `Parent / Child` so the `name` value can be copied directly into `opencli linux-do feed --category ...`.
Output columns: `name`, `slug`, `id`, `topics`, `description`
## tags
List tags sorted by usage count.
```bash
opencli linux-do tags
opencli linux-do tags --limit 50
```
Output columns: `rank`, `name`, `count`, `url`
## search
Search topics by keyword.
```bash
opencli linux-do search "NixOS"
opencli linux-do search "Docker" --limit 10
opencli linux-do search "Claude" -f json
```
Output columns: `rank`, `title`, `views`, `likes`, `replies`, `url`
## topic
View posts within a topic (first page).
```bash
opencli linux-do topic 1234
opencli linux-do topic 1234 --limit 50
opencli linux-do topic 1234 --main_only -f json | jq -r '.[0].content'
```
Notes:
- `--main_only` returns only the main post row and keeps the body untruncated
Output columns: `author`, `content`, `likes`, `created_at`
## user-topics
List topics created by a user.
```bash
opencli linux-do user-topics neo
opencli linux-do user-topics neo --limit 10
```
Output columns: `rank`, `title`, `replies`, `created_at`, `likes`, `views`, `url`
## user-posts
List replies posted by a user.
```bash
opencli linux-do user-posts neo
opencli linux-do user-posts neo --limit 10
```
Output columns: `index`, `topic_user`, `topic`, `reply`, `time`, `url`
## Compatibility
The legacy commands below are still available as compatibility wrappers while `feed` becomes the canonical entrypoint:
```bash
opencli linux-do latest
opencli linux-do hot --period weekly
opencli linux-do category develop 4
```
Preferred modern forms:
```bash
opencli linux-do feed --view latest
opencli linux-do feed --view top --period weekly
opencli linux-do feed --category 4
```
## Prerequisites
+43
View File
@@ -0,0 +1,43 @@
# paperreview.ai
**Mode**: 🌐 Public · **Domain**: `paperreview.ai`
## Commands
| Command | Description |
|---------|-------------|
| `opencli paperreview submit` | Submit a PDF to paperreview.ai for review |
| `opencli paperreview review` | Fetch a review by token |
| `opencli paperreview feedback` | Send feedback on a completed review |
## Usage Examples
```bash
# Validate a local PDF without uploading it
opencli paperreview submit ./paper.pdf --email you@example.com --venue RAL --dry-run true
# Request an upload slot but stop before the actual upload
opencli paperreview submit ./paper.pdf --email you@example.com --venue RAL --prepare-only true
# Submit a paper for review
opencli paperreview submit ./paper.pdf --email you@example.com --venue RAL -f json
# Check the review status or fetch the final review
opencli paperreview review tok_123 -f json
# Submit feedback on the review quality
opencli paperreview feedback tok_123 --helpfulness 4 --critical-error no --actionable-suggestions yes
```
## Prerequisites
- No browser required — uses public paperreview.ai endpoints
- The input file must be a local `.pdf`
- paperreview.ai currently rejects files larger than `10MB`
- `submit` requires `--email`; `--venue` is optional
## Notes
- `submit` returns both the review token and the review URL when submission succeeds
- `review` returns `processing` until the paperreview.ai result is ready
- `feedback` expects `yes` / `no` values for `--critical-error` and `--actionable-suggestions`
+92
View File
@@ -0,0 +1,92 @@
# Pixiv
**Mode**: 🔐 Browser · **Domain**: `www.pixiv.net`
## Commands
| Command | Description |
|---------|-------------|
| `opencli pixiv ranking` | Daily/weekly/monthly illustration rankings |
| `opencli pixiv search <query>` | Search illustrations by keyword or tag |
| `opencli pixiv user <uid>` | View artist profile info |
| `opencli pixiv illusts <user-id>` | List illustrations by artist |
| `opencli pixiv detail <id>` | View illustration details |
| `opencli pixiv download <illust-id>` | Download original-quality images |
## Usage Examples
### Ranking
```bash
# Daily rankings (default)
opencli pixiv ranking --limit 10
# Weekly / monthly rankings
opencli pixiv ranking --mode weekly
opencli pixiv ranking --mode monthly
# R18 rankings
opencli pixiv ranking --mode daily_r18
opencli pixiv ranking --mode weekly_r18
# Other modes: rookie, original, male, female
opencli pixiv ranking --mode rookie
```
### Search
```bash
# Search by keyword or tag
opencli pixiv search "初音ミク" --limit 20
# Filter by content rating
opencli pixiv search "風景" --mode safe # Safe-for-work only
opencli pixiv search "風景" --mode r18 # R18 only
opencli pixiv search "風景" --mode all # All (default)
# Sort by popularity
opencli pixiv search "VOCALOID" --order popular_d
# All sort options: date_d (newest), date (oldest), popular_d, popular_male_d, popular_female_d
# Pagination
opencli pixiv search "オリジナル" --page 2 --limit 30
```
### User & Illustrations
```bash
# View artist profile
opencli pixiv user 11
# List artist's illustrations (newest first)
opencli pixiv illusts 11 --limit 10
# View illustration details (tags, stats, type)
opencli pixiv detail 12345678
```
### Download
```bash
# Download all images from an illustration
opencli pixiv download 12345678
# Download to a custom directory
opencli pixiv download 12345678 --output ./my-images
```
### Output Formats
```bash
# JSON output
opencli pixiv ranking -f json
# Verbose mode
opencli pixiv search "test" -v
```
## Prerequisites
- Chrome running and **logged into** pixiv.net
- [Browser Bridge extension](/guide/browser-bridge) installed
+49
View File
@@ -0,0 +1,49 @@
# Product Hunt
**Mode**: 🌐 Public / 🔐 Browser · **Domain**: `www.producthunt.com`
## Commands
| Command | Description |
|---------|-------------|
| `opencli producthunt posts` | Latest Product Hunt launches (optional category filter) |
| `opencli producthunt today` | Today's Product Hunt launches (most recent day in feed) |
| `opencli producthunt hot` | Today's top Product Hunt launches with vote counts |
| `opencli producthunt browse <category>` | Best products in a Product Hunt category |
## Usage Examples
```bash
# Today's top launches with vote counts
opencli producthunt hot --limit 10
# Latest posts (RSS feed)
opencli producthunt posts --limit 20
# Filter by category
opencli producthunt posts --category developer-tools --limit 10
# Today's launches only
opencli producthunt today --limit 10
# Browse best products in a category
opencli producthunt browse vibe-coding --limit 10
opencli producthunt browse ai-agents --limit 10
opencli producthunt browse developer-tools --limit 10
# JSON output
opencli producthunt hot -f json
```
## Category Slugs
Common categories for `browse` and `posts --category`:
`ai-agents`, `ai-coding-agents`, `ai-code-editors`, `ai-chatbots`, `ai-workflow-automation`,
`vibe-coding`, `developer-tools`, `productivity`, `design-creative`, `marketing-sales`,
`no-code-platforms`, `llms`, `finance`, `social-community`, `engineering-development`
## Prerequisites
- `posts` and `today` — no browser required (public RSS feed)
- `hot` and `browse` — Chrome running with [Browser Bridge extension](/guide/browser-bridge) installed
+56 -6
View File
@@ -1,15 +1,19 @@
# 新浪财经 (Sina Finance)
**Mode**: 🌐 Public · **Domain**: `finance.sina.com.cn`
**Mode**: 🌐 Public / 🔐 Browser · **Domain**: `finance.sina.com.cn`
## Commands
| Command | Description |
|---------|-------------|
| `opencli sinafinance news` | 新浪财经 7×24 小时实时快讯 |
| Command | Description | Mode |
|---------|-------------|------|
| `opencli sinafinance news` | 新浪财经 7×24 小时实时快讯 | 🌐 Public |
| `opencli sinafinance rolling-news` | 新浪财经滚动新闻 | 🔐 Browser |
| `opencli sinafinance stock` | 新浪财经行情(A股/港股/美股) | 🌐 Public |
## Usage Examples
### news - 7×24 实时快讯
```bash
# Latest financial news
opencli sinafinance news --limit 20
@@ -23,13 +27,59 @@ opencli sinafinance news --type 6 # 国际
opencli sinafinance news -f json
```
### Options
### rolling-news - 滚动新闻
```bash
# Rolling news feed
opencli sinafinance rolling-news
# JSON output
opencli sinafinance rolling-news -f json
```
### stock - 股票行情
```bash
# Search and view A-share stock
opencli sinafinance stock 贵州茅台 --market cn
# Search and view HK stock
opencli sinafinance stock 腾讯控股 --market hk
# Search and view US stock
opencli sinafinance stock aapl --market us
# Auto-detect market (searches cn, hk, us in order)
opencli sinafinance stock 招商证券
# JSON output
opencli sinafinance stock 贵州茅台 -f json
```
## Options
### news
| Option | Description |
|--------|-------------|
| `--limit` | Max results, up to 50 (default: 20) |
| `--type` | News type: `0`=全部, `1`=A股, `2`=宏观, `3`=公司, `4`=数据, `5`=市场, `6`=国际, `7`=观点, `8`=央行, `9`=其它 |
### stock
| Option | Description |
|--------|-------------|
| `--market` | Market: `cn`, `hk`, `us`, `auto` (default: auto). When `auto`, searches in cn, hk, us order |
## Prerequisites
- No browser required — uses public API
- `news` & `stock`: No browser required — uses public API
- `rolling-news`: Chrome running and **logged into** `finance.sina.com.cn`
- For `rolling-news`: [Browser Bridge extension](/guide/browser-bridge) installed
## Notes
- `news` and `stock` use public APIs — no browser or login needed
- `stock` supports Chinese names, Chinese codes, and ticker symbols; auto-detects market
- Market priority for auto-detection: cn (A股) → hk (港股) → us (美股)
- US stock `High`/`Low` columns show 52-week range; A股/港股 show today's range
+6
View File
@@ -37,6 +37,12 @@
# Quick start
opencli twitter trending --limit 5
# Search top tweets (default)
opencli twitter search "react 19"
# Search latest/live tweets
opencli twitter search "react 19" --filter live
# JSON output
opencli twitter trending -f json
+27 -9
View File
@@ -1,18 +1,20 @@
# Xueqiu (雪球)
**Mode**: 🔐 Browser · **Domain**: `xueqiu.com`
**Mode**: 🔐 Browser · **Domain**: `xueqiu.com` / `danjuanfunds.com`
## Commands
| Command | Description |
|---------|-------------|
| `opencli xueqiu feed` | |
| `opencli xueqiu earnings-date` | |
| `opencli xueqiu hot-stock` | |
| `opencli xueqiu hot` | |
| `opencli xueqiu search` | |
| `opencli xueqiu stock` | |
| `opencli xueqiu watchlist` | |
| `opencli xueqiu feed` | 获取雪球首页时间线 |
| `opencli xueqiu earnings-date` | 获取股票预计财报发布日期 |
| `opencli xueqiu hot-stock` | 获取雪球热门股票榜 |
| `opencli xueqiu hot` | 获取雪球热门动态 |
| `opencli xueqiu search` | 搜索雪球股票(代码或名称) |
| `opencli xueqiu stock` | 获取雪球股票实时行情 |
| `opencli xueqiu watchlist` | 获取雪球自选股列表 |
| `opencli xueqiu fund-holdings` | 获取蛋卷基金持仓明细(可用 `--account` 按子账户过滤) |
| `opencli xueqiu fund-snapshot` | 获取蛋卷基金快照(总资产、子账户、持仓,推荐 `-f json` |
## Usage Examples
@@ -29,6 +31,15 @@ opencli xueqiu stock SH600519
# Upcoming earnings dates
opencli xueqiu earnings-date SH600519 --next
# Danjuan all holdings
opencli xueqiu fund-holdings
# Filter one Danjuan sub-account
opencli xueqiu fund-holdings --account 默认账户
# Full Danjuan snapshot as JSON
opencli xueqiu fund-snapshot -f json
# JSON output
opencli xueqiu feed -f json
@@ -38,5 +49,12 @@ opencli xueqiu feed -v
## Prerequisites
- Chrome running and **logged into** xueqiu.com
- Chrome running and **logged into** `xueqiu.com`
- For fund commands, Chrome must also be logged into `danjuanfunds.com` and able to open `https://danjuanfunds.com/my-money`
- [Browser Bridge extension](/guide/browser-bridge) installed
## Notes
- `fund-holdings` exposes both market value and share fields (`volume`, `usableRemainShare`)
- `fund-snapshot -f json` is the easiest way to persist a full account snapshot for later analysis or diffing
- If the commands return empty data, first confirm the logged-in browser can directly see the Danjuan asset page
+5
View File
@@ -14,8 +14,13 @@ The current built-in commands use native AppleScript automation — no extra lau
- `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 send "message" --model thinking`: Switch model/mode first, then send the message.
- `opencli chatgpt read`: Read the last visible message from the focused ChatGPT window via the Accessibility tree.
- `opencli chatgpt ask "message"`: Send a prompt and wait for the visible reply in one shot.
- `opencli chatgpt ask "message" --model instant`: Run a one-shot prompt using a specific model/mode.
- `opencli chatgpt model thinking`: Switch the active ChatGPT model/mode without sending a message.
Supported model choices: `auto`, `instant`, `thinking`, `5.2-instant`, `5.2-thinking`.
## Approach 2: CDP (Advanced, Electron Debug Mode)
+13 -3
View File
@@ -11,7 +11,7 @@ Run `opencli list` for the live registry.
| **[bilibili](/adapters/browser/bilibili)** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | 🔐 Browser |
| **[zhihu](/adapters/browser/zhihu)** | `hot` `search` `question` `download` | 🔐 Browser |
| **[xiaohongshu](/adapters/browser/xiaohongshu)** | `search` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 🔐 Browser |
| **[xueqiu](/adapters/browser/xueqiu)** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` `earnings-date` | 🔐 Browser |
| **[xueqiu](/adapters/browser/xueqiu)** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` `earnings-date` `fund-holdings` `fund-snapshot` | 🔐 Browser |
| **[youtube](/adapters/browser/youtube)** | `search` `video` `transcript` | 🔐 Browser |
| **[v2ex](/adapters/browser/v2ex)** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | 🌐 / 🔐 |
| **[bloomberg](/adapters/browser/bloomberg)** | `main` `markets` `economics` `industries` `tech` `politics` `businessweek` `opinions` `feeds` `news` | 🌐 / 🔐 |
@@ -25,18 +25,26 @@ Run `opencli list` for the live registry.
| **[jike](/adapters/browser/jike)** | `feed` `search` `post` `topic` `user` `create` `comment` `like` `repost` `notifications` | 🔐 Browser |
| **[jimeng](/adapters/browser/jimeng)** | `generate` `history` | 🔐 Browser |
| **[yollomi](/adapters/browser/yollomi)** | `generate` `video` `edit` `upload` `models` `remove-bg` `upscale` `face-swap` `restore` `try-on` `background` `object-remover` | 🔐 Browser |
| **[linux-do](/adapters/browser/linux-do)** | `hot` `latest` `categories` `category` `search` `topic` | 🔐 Browser |
| **[linux-do](/adapters/browser/linux-do)** | `feed` `categories` `tags` `search` `topic` `user-topics` `user-posts` | 🔐 Browser |
| **[chaoxing](/adapters/browser/chaoxing)** | `assignments` `exams` | 🔐 Browser |
| **[grok](/adapters/browser/grok)** | `ask` | 🔐 Browser |
| **[doubao](/adapters/browser/doubao)** | `status` `new` `send` `read` `ask` | 🔐 Browser |
| **[weread](/adapters/browser/weread)** | `shelf` `search` `book` `ranking` `notebooks` `highlights` `notes` | 🔐 Browser |
| **[douban](/adapters/browser/douban)** | `search` `top250` `subject` `marks` `reviews` `movie-hot` `book-hot` | 🔐 Browser |
| **[douban](/adapters/browser/douban)** | `search` `top250` `subject` `photos` `download` `marks` `reviews` `movie-hot` `book-hot` | 🔐 Browser |
| **[facebook](/adapters/browser/facebook)** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | 🔐 Browser |
| **[imdb](/adapters/browser/imdb)** | `search` `title` `top` `trending` `person` `reviews` | 🌐 / 🔐 |
| **[instagram](/adapters/browser/instagram)** | `explore` `profile` `search` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `saved` | 🔐 Browser |
| **[medium](/adapters/browser/medium)** | `feed` `search` `user` | 🔐 Browser |
| **[sinablog](/adapters/browser/sinablog)** | `hot` `search` `article` `user` | 🔐 Browser |
| **[substack](/adapters/browser/substack)** | `feed` `search` `publication` | 🔐 Browser |
| **[pixiv](/adapters/browser/pixiv)** | `ranking` `search` `user` `illusts` `detail` `download` | 🔐 Browser |
| **[tiktok](/adapters/browser/tiktok)** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | 🔐 Browser |
| **[google](/adapters/browser/google)** | `news` `search` `suggest` `trends` | 🌐 / 🔐 |
| **[jd](/adapters/browser/jd)** | `item` | 🔐 Browser |
| **[web](/adapters/browser/web)** | `read` | 🔐 Browser |
| **[weixin](/adapters/browser/weixin)** | `download` | 🔐 Browser |
| **[36kr](/adapters/browser/36kr)** | `news` `hot` `search` `article` | 🌐 / 🔐 |
| **[producthunt](/adapters/browser/producthunt)** | `posts` `today` `hot` `browse` | 🌐 / 🔐 |
## Public API Adapters
@@ -50,12 +58,14 @@ Run `opencli list` for the live registry.
| **[xiaoyuzhou](/adapters/browser/xiaoyuzhou)** | `podcast` `podcast-episodes` `episode` | 🌐 Public |
| **[yahoo-finance](/adapters/browser/yahoo-finance)** | `quote` | 🌐 Public |
| **[arxiv](/adapters/browser/arxiv)** | `search` `paper` | 🌐 Public |
| **[paperreview](/adapters/browser/paperreview)** | `submit` `review` `feedback` | 🌐 Public |
| **[barchart](/adapters/browser/barchart)** | `quote` `options` `greeks` `flow` | 🌐 Public |
| **[hf](/adapters/browser/hf)** | `top` | 🌐 Public |
| **[sinafinance](/adapters/browser/sinafinance)** | `news` | 🌐 Public |
| **[stackoverflow](/adapters/browser/stackoverflow)** | `hot` `search` `bounties` `unanswered` | 🌐 Public |
| **[wikipedia](/adapters/browser/wikipedia)** | `search` `summary` `random` `trending` | 🌐 Public |
| **[lobsters](/adapters/browser/lobsters)** | `hot` `newest` `active` `tag` | 🌐 Public |
| **[steam](/adapters/browser/steam)** | `top-sellers` | 🌐 Public |
## Desktop Adapters
+4
View File
@@ -9,6 +9,7 @@ OpenCLI supports downloading images, videos, and articles from supported platfor
| **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 |
| **douban** | Images | Downloads poster / still image lists from movie subjects |
| **zhihu** | Articles (Markdown) | Exports articles with optional image download |
| **weixin** | Articles (Markdown) | Exports WeChat Official Account articles |
@@ -39,6 +40,9 @@ opencli twitter download elonmusk --limit 20 --output ./twitter
# Download single tweet media
opencli twitter download --tweet-url "https://x.com/user/status/123" --output ./twitter
# Download Douban posters / stills
opencli douban download 30382501 --output ./douban
# Export Zhihu article to Markdown
opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
+99
View File
@@ -0,0 +1,99 @@
# Rate Limiter Plugin
An optional plugin that adds a random sleep between browser-based commands to reduce the risk of platform rate-limiting or bot detection.
## Install
```bash
opencli plugin install github:jackwener/opencli-plugin-rate-limiter
```
Or copy the example below into `~/.opencli/plugins/rate-limiter/` to use it locally without installing from GitHub.
## What it does
After every command targeting a browser platform (xiaohongshu, weibo, bilibili, douyin, tiktok, …), the plugin sleeps for a random duration — 530 seconds by default — before returning control to the caller.
## Configuration
| Variable | Default | Description |
|---|---|---|
| `OPENCLI_RATE_MIN` | `5` | Minimum sleep in seconds |
| `OPENCLI_RATE_MAX` | `30` | Maximum sleep in seconds |
| `OPENCLI_NO_RATE` | — | Set to `1` to disable entirely (local dev) |
```bash
# Shorter delays for light scraping
OPENCLI_RATE_MIN=3 OPENCLI_RATE_MAX=10 opencli xiaohongshu search "AI眼镜"
# Skip delays when iterating locally
OPENCLI_NO_RATE=1 opencli bilibili comments BV1WtAGzYEBm
```
## Local installation (without GitHub)
1. Create the plugin directory:
```bash
mkdir -p ~/.opencli/plugins/rate-limiter
```
2. Create `~/.opencli/plugins/rate-limiter/package.json`:
```json
{ "type": "module" }
```
3. Create `~/.opencli/plugins/rate-limiter/index.js`:
```js
import { onAfterExecute } from '@jackwener/opencli/hooks'
const BROWSER_DOMAINS = [
'xiaohongshu', 'weibo', 'bilibili', 'douyin', 'tiktok',
'instagram', 'twitter', 'youtube', 'zhihu', 'douban',
'jike', 'weixin', 'xiaoyuzhou',
]
onAfterExecute(async (ctx) => {
if (process.env.OPENCLI_NO_RATE === '1') return
const site = ctx.command?.split('/')?.[0] ?? ''
if (!BROWSER_DOMAINS.includes(site)) return
const min = Number(process.env.OPENCLI_RATE_MIN ?? 5)
const max = Number(process.env.OPENCLI_RATE_MAX ?? 30)
const ms = Math.floor(Math.random() * (max - min + 1) + min) * 1000
process.stderr.write(`[rate-limiter] ${site}: sleeping ${(ms / 1000).toFixed(0)}s\n`)
await new Promise(r => setTimeout(r, ms))
})
```
4. Verify it loaded:
```bash
OPENCLI_NO_RATE=1 opencli xiaohongshu search "test" 2>&1 | grep rate-limiter
# → (no output — plugin loaded but rate limit skipped)
opencli xiaohongshu search "test" 2>&1 | grep rate-limiter
# → [rate-limiter] xiaohongshu: sleeping 12s
```
## Writing your own plugin
Plugins are plain JS/TS files in `~/.opencli/plugins/<name>/`. A plugin file must export a hook registration call that matches the pattern `onStartup(`, `onBeforeExecute(`, or `onAfterExecute(` — opencli's discovery engine uses this pattern to identify hook files vs. command files.
```js
// ~/.opencli/plugins/my-plugin/index.js
import { onAfterExecute } from '@jackwener/opencli/hooks'
onAfterExecute(async (ctx) => {
// ctx.command — e.g. "bilibili/comments"
// ctx.args — coerced command arguments
// ctx.error — set if the command threw
console.error(`[my-plugin] finished: ${ctx.command}`)
})
```
See [hooks.ts](../../src/hooks.ts) for the full `HookContext` type.
+17
View File
@@ -28,6 +28,12 @@ npm link
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.
Before you start:
- Prefer positional args for the command's primary subject (`search <query>`, `topic <id>`, `download <url>`). Reserve named flags for optional modifiers such as `--limit`, `--sort`, `--lang`, and `--output`.
- Normalize expected adapter failures to `CliError` subclasses instead of raw `Error` whenever possible. Prefer `AuthRequiredError`, `EmptyResultError`, `CommandExecutionError`, `TimeoutError`, and `ArgumentError` so the top-level CLI can render better messages and hints.
- If you add a new adapter or make a command newly discoverable, update the matching doc page and the user-facing indexes that expose it.
### YAML Adapter (Recommended for data-fetching commands)
Create a file like `src/clis/<site>/<command>.yaml`:
@@ -71,6 +77,7 @@ Create a file like `src/clis/<site>/<command>.ts`:
```typescript
import { cli, Strategy } from '../../registry.js';
import { CommandExecutionError, EmptyResultError } from '../../errors.js';
cli({
site: 'mysite',
@@ -87,6 +94,8 @@ cli({
func: async (page, kwargs) => {
const { query, limit = 10 } = kwargs;
// ... browser automation logic
if (!Array.isArray(data)) throw new CommandExecutionError('MySite returned an unexpected response');
if (!data.length) throw new EmptyResultError('mysite search', 'Try a different keyword');
return data.slice(0, Number(limit)).map((item: any) => ({
title: item.title,
url: item.url,
@@ -110,6 +119,7 @@ opencli <site> <command> -v # Verbose mode for debugging
- **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.
- **Errors** — throw `CliError` subclasses for expected adapter failures; avoid raw `Error` for normal adapter control flow.
## Commit Convention
@@ -136,3 +146,10 @@ chore: bump vitest to v4
```
4. Commit using conventional commit format
5. Push and open a PR
If your PR adds a new adapter or changes user-facing commands, also verify:
- Adapter docs exist under `docs/adapters/`
- `docs/adapters/index.md` is updated for new adapters
- VitePress sidebar includes the new doc page
- `README.md` / `README.zh-CN.md` stay aligned when command discoverability changes
+18
View File
@@ -6,6 +6,7 @@ Use TypeScript adapters when you need browser-side logic, multi-step flows, DOM
```typescript
import { cli, Strategy } from '../../registry.js';
import { CommandExecutionError, EmptyResultError } from '../../errors.js';
cli({
site: 'mysite',
@@ -34,6 +35,9 @@ cli({
})()
`);
if (!Array.isArray(data)) throw new CommandExecutionError('MySite returned an unexpected response');
if (!data.length) throw new EmptyResultError('mysite search', 'Try a different keyword');
return data.slice(0, Number(limit)).map((item: any) => ({
title: item.title,
url: item.url,
@@ -69,6 +73,20 @@ Contains parsed CLI arguments as key-value pairs. Always destructure with defaul
const { query, limit = 10, format = 'json' } = kwargs;
```
For most search/read/detail commands, the main subject should be positional (`opencli mysite search "rust"`, `opencli mysite article 123`) instead of a named flag such as `--query` or `--id`. Keep named flags for optional modifiers.
## Error Handling
Prefer throwing `CliError` subclasses from `src/errors.ts` for expected adapter failures:
- `AuthRequiredError` for missing login / cookies
- `EmptyResultError` for empty but valid responses
- `CommandExecutionError` for unexpected API or browser failures
- `TimeoutError` for site timeouts
- `ArgumentError` for invalid user input
Avoid raw `Error` for normal adapter control flow. This keeps top-level CLI output consistent and preserves hints for users.
## AI-Assisted Development
Use the AI workflow tools to accelerate adapter creation:
+16
View File
@@ -2,6 +2,8 @@
YAML adapters are the recommended way to add new commands when the site offers a straightforward API. They use a declarative pipeline approach — no TypeScript required.
Use YAML only when the command stays mostly declarative. If you find yourself embedding long JavaScript expressions, many fallbacks, or multi-step browser logic, move the command to a TypeScript adapter instead of growing an opaque template blob.
## Basic Structure
::: v-pre
@@ -33,6 +35,14 @@ columns: [rank, title, score, url]
```
:::
For most commands, keep the primary subject positional. Good examples:
- `opencli mysite search "rust"`
- `opencli mysite topic 123`
- `opencli mysite download "https://example.com/post/1"`
Prefer named flags only for optional modifiers such as `--limit`, `--sort`, `--lang`, or `--output`.
## Pipeline Steps
### `fetch`
@@ -106,3 +116,9 @@ Use `${{ ... }}` for dynamic values:
## Real Example
See [`src/clis/hackernews/top.yaml`](https://github.com/jackwener/opencli/blob/main/src/clis/hackernews/top.yaml).
## Guardrails
- Add fallbacks for optional fields in `map` expressions when upstream payloads may be sparse.
- Keep template expressions short and readable. If the expression starts looking like a mini program, switch to TypeScript.
- If you add a new adapter, also add the matching doc page plus index/sidebar entries so `doc-coverage` stays green.
+200
View File
@@ -0,0 +1,200 @@
---
description: How to turn a new Electron desktop app into an OpenCLI adapter
---
# Add a New Electron App CLI
This guide is the **fast entry point** for turning a new Electron desktop application into an OpenCLI adapter.
If you want the full background and deeper SOP, read:
- [CLI-ifying Electron Applications](/advanced/electron)
- [Chrome DevTools Protocol](/advanced/cdp)
- [TypeScript Adapter Guide](/developer/ts-adapter)
## When to use this guide
Use this workflow when the target app:
- is built with **Electron**, or at least exposes a working **Chrome DevTools Protocol (CDP)** endpoint
- can be launched with `--remote-debugging-port=<port>`
- should be automated through its real UI instead of a public HTTP API
If the app is **not** Electron and does **not** expose CDP, use the native desktop automation pattern instead. See [CLI-ifying Electron Applications](/advanced/electron#non-electron-pattern-applescript).
## The shortest path
### 1. Confirm the app is Electron
Typical macOS check:
```bash
ls /Applications/AppName.app/Contents/Frameworks/Electron\ Framework.framework
```
If Electron is present, the next step is usually to launch the app with a debugging port.
### 2. Launch it with CDP enabled
```bash
/Applications/AppName.app/Contents/MacOS/AppName --remote-debugging-port=9222
```
Then point OpenCLI at that CDP endpoint:
```bash
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9222"
```
### 3. Start with the 5-command pattern
For a new Electron adapter, implement these commands first in `src/clis/<app>/`:
- `status.ts` — verify the app is reachable through CDP
- `dump.ts` — inspect DOM and snapshot structure before guessing selectors
- `read.ts` — extract the visible context you actually need
- `send.ts` — inject text and submit through the real editor
- `new.ts` — create a new session, tab, thread, or document
This is the standard baseline because it gives you:
- a connection check
- a reverse-engineering tool
- one read path
- one write path
- one session reset path
The full rationale and examples are in [CLI-ifying Electron Applications](/advanced/electron).
## Recommended implementation workflow
### Step 1: Build `status`
Goal: prove CDP connectivity before touching app-specific logic.
Typical checks:
- current URL
- document title
- app shell presence
If `status` is unstable, stop there and fix connectivity first.
### Step 2: Build `dump`
Do **not** guess selectors from the rendered UI.
Dump:
- `document.body.innerHTML`
- accessibility snapshot
- any stable attributes such as `data-testid`, `role`, `aria-*`, framework-specific markers
Use the dump to identify real containers, buttons, composers, and conversation regions.
### Step 3: Build `read`
Target only the app region that matters.
Good targets:
- message list
- editor history
- visible thread content
- selected document panel
Avoid dumping the entire page text into the final command output.
### Step 4: Build `send`
Most Electron apps use React-style controlled editors, so direct `.value = ...` assignments are often ignored.
Prefer editor-aware input patterns such as:
- focus the editable region
- use `document.execCommand('insertText', false, text)` when applicable
- use real key presses like `Enter`, `Meta+Enter`, or app-specific shortcuts
### Step 5: Build `new`
Many desktop apps rely on keyboard shortcuts for “new chat”, “new tab”, or “new note”.
Typical pattern:
```ts
const isMac = process.platform === 'darwin';
await page.pressKey(isMac ? 'Meta+N' : 'Control+N');
await page.wait(1);
```
## Where to put files
For a TypeScript desktop adapter, the usual layout is:
```text
src/clis/<app>/status.ts
src/clis/<app>/dump.ts
src/clis/<app>/read.ts
src/clis/<app>/send.ts
src/clis/<app>/new.ts
src/clis/<app>/utils.ts
```
If the app grows beyond the baseline, add higher-level commands such as:
- `ask`
- `history`
- `model`
- `screenshot`
- `export`
## What to document when you add a new app
When the adapter is ready, also add:
- an adapter doc under `docs/adapters/desktop/`
- command list and examples
- launch instructions with `--remote-debugging-port`
- any required environment variables
- platform-specific caveats
Examples to study:
- `docs/adapters/desktop/codex.md`
- `docs/adapters/desktop/chatwise.md`
- `docs/adapters/desktop/notion.md`
- `docs/adapters/desktop/discord.md`
## Common failure modes
### CDP endpoint exists, but commands are flaky
Usually one of these:
- the wrong window/tab is selected
- the app has not finished rendering
- selectors were guessed instead of discovered from `dump`
- the editor is controlled and ignores direct value assignment
### The app is Chromium-based but not truly controllable
Some desktop apps embed Chromium but do not expose a usable CDP surface.
In that case, switch to the non-Electron desktop automation approach instead of forcing the Electron pattern.
### You already have a browser workflow and wonder whether to reuse it
If the app exposes a normal web URL and the browser flow is enough, a browser adapter is usually simpler.
Use an Electron adapter only when the desktop app is the real integration surface.
## Recommended reading order
If you are starting from zero:
1. This page
2. [CLI-ifying Electron Applications](/advanced/electron)
3. [Chrome DevTools Protocol](/advanced/cdp)
4. [TypeScript Adapter Guide](/developer/ts-adapter)
5. One concrete desktop adapter doc under `docs/adapters/desktop/`
## Practical rule
Do not start with a large feature surface.
Start with:
- `status`
- `dump`
- `read`
- `send`
- `new`
Once those are stable, extend outward.
+1
View File
@@ -55,3 +55,4 @@ opencli bilibili hot -v # Verbose: show pipeline debug
- [Plugins — extend with community adapters](/guide/plugins)
- [All available adapters](/adapters/)
- [For developers / AI agents](/developer/contributing)
- [Add a new Electron app CLI](/guide/electron-app-cli)
+107
View File
@@ -11,6 +11,12 @@ opencli plugin install github:ByteYue/opencli-plugin-github-trending
# List installed plugins
opencli plugin list
# Update one plugin
opencli plugin update github-trending
# Update all installed plugins
opencli plugin update --all
# Use the plugin (it's just a regular command)
opencli github-trending repos --limit 10
@@ -25,12 +31,113 @@ Plugins live in `~/.opencli/plugins/<name>/`. Each subdirectory is scanned at st
### Supported Source Formats
```bash
# GitHub shorthand
opencli plugin install github:user/repo
opencli plugin install github:user/repo/subplugin # install specific sub-plugin from monorepo
opencli plugin install https://github.com/user/repo
# Any git-cloneable URL
opencli plugin install https://gitlab.example.com/team/repo.git
opencli plugin install ssh://git@gitlab.example.com/team/repo.git
opencli plugin install git@gitlab.example.com:team/repo.git
# Local plugin (for development)
opencli plugin install file:///path/to/plugin
opencli plugin install /path/to/plugin
```
The repo name prefix `opencli-plugin-` is automatically stripped for the local directory name. For example, `opencli-plugin-hot-digest` becomes `hot-digest`.
## Plugin Manifest (`opencli-plugin.json`)
Plugins can include an `opencli-plugin.json` manifest file at the repo root to declare metadata:
```json
{
"name": "my-plugin",
"version": "1.0.0",
"opencli": ">=1.0.0",
"description": "My awesome plugin"
}
```
| Field | Description |
|-------|-------------|
| `name` | Plugin name (overrides repo-derived name) |
| `version` | Semantic version |
| `opencli` | Required opencli version range (e.g. `>=1.0.0`, `^1.2.0`) |
| `description` | Human-readable description |
| `plugins` | Monorepo sub-plugin declarations (see below) |
The manifest is optional — plugins without one continue to work exactly as before.
## Monorepo Plugins
A single repository can contain multiple plugins by declaring a `plugins` field in `opencli-plugin.json`:
```json
{
"version": "1.0.0",
"opencli": ">=1.0.0",
"description": "My plugin collection",
"plugins": {
"polymarket": {
"path": "packages/polymarket",
"description": "Prediction market analysis",
"version": "1.2.0"
},
"defi": {
"path": "packages/defi",
"description": "DeFi protocol data",
"version": "0.8.0",
"opencli": ">=1.2.0"
},
"experimental": {
"path": "packages/experimental",
"disabled": true
}
}
}
```
### Installing
```bash
# Install ALL enabled sub-plugins from a monorepo
opencli plugin install github:user/opencli-plugins
# Install a SPECIFIC sub-plugin
opencli plugin install github:user/opencli-plugins/polymarket
```
### How It Works
- The monorepo is cloned once to `~/.opencli/monorepos/<repo>/`
- Each sub-plugin gets a symlink in `~/.opencli/plugins/<name>/` pointing to its subdirectory
- Command discovery works transparently — symlinks are scanned just like regular directories
- Disabled sub-plugins (with `"disabled": true`) are skipped during install
- Sub-plugins can specify their own `opencli` compatibility range
### Updating
Updating any sub-plugin from a monorepo pulls the entire repo and refreshes all sub-plugins:
```bash
opencli plugin update polymarket # updates the monorepo, refreshes all
```
### Uninstalling
```bash
opencli plugin uninstall polymarket # removes just this sub-plugin's symlink
```
When the last sub-plugin from a monorepo is uninstalled, the monorepo clone is automatically cleaned up.
## Version Tracking
OpenCLI records installed plugin versions in `~/.opencli/plugins.lock.json`. Each entry stores the plugin source, current git commit hash, install time, and last update time. `opencli plugin list` shows the short commit hash when version metadata is available.
## Creating a Plugin
### Option 1: YAML Plugin (Simplest)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,170 @@
# Performance: Smart Wait & INTERCEPT Fix
**Date**: 2026-03-28
**Status**: Approved
## Problem
Three distinct performance/correctness issues:
1. **INTERCEPT strategy semantic bug**: After `installInterceptor()` + `goto()`, adapters call `wait(N)` — which now uses `waitForDomStableJs` and returns early when the DOM settles. But DOM-settle != network capture. The API response may arrive *after* DOM is stable, causing `getInterceptedRequests()` to return an empty array.
2. **Blind `wait(N)` in adapters**: ~30 high-traffic adapters (Twitter family, Medium, Substack, etc.) call `wait(5)` waiting for React/Vue to hydrate. These should wait for a specific DOM element to appear, not a fixed cap.
3. **Daemon cold-start polling**: Fixed 300ms poll loop means ~600ms before first successful `isExtensionConnected()` check, even though the daemon is typically ready in 500800ms.
## Design
### Layer 1 — `waitForCapture()` (correctness fix + perf)
Add `waitForCapture(timeout?: number): Promise<void>` to `IPage`.
Polls `window.__opencli_xhr.length > 0` every 100ms inside the browser tab. Resolves as soon as ≥1 capture arrives; rejects after `timeout` seconds.
```typescript
// dom-helpers.ts
export function waitForCaptureJs(maxMs: number): string {
return `
new Promise((resolve, reject) => {
const deadline = Date.now() + ${maxMs};
const check = () => {
if ((window.__opencli_xhr || []).length > 0) return resolve('captured');
if (Date.now() > deadline) return reject(new Error('No capture within ${maxMs / 1000}s'));
setTimeout(check, 100);
};
check();
})
`;
}
```
`page.ts` and `cdp.ts` implement `waitForCapture()` by calling `waitForCaptureJs`.
**All INTERCEPT adapters** replace `wait(N)``waitForCapture(N+2)` (slightly longer timeout as safety margin).
`stepIntercept` in `pipeline/steps/intercept.ts` replaces its internal `wait(timeout)` with `waitForCapture(timeout)`.
**Expected gain**: 36kr hot/search: 6s → ~12s. Twitter search/followers: 58s → ~13s.
### Layer 2 — `wait({ selector })` (semantic precision)
Extend `WaitOptions` with `selector?: string`.
Add `waitForSelectorJs(selector, timeoutMs)` to `dom-helpers.ts` — polls `document.querySelector(selector)` every 100ms, resolves on first match, rejects on timeout.
```typescript
// types.ts
export interface WaitOptions {
text?: string;
selector?: string; // NEW
time?: number;
timeout?: number;
}
```
```typescript
// dom-helpers.ts
export function waitForSelectorJs(selector: string, timeoutMs: number): string {
return `
new Promise((resolve, reject) => {
const deadline = Date.now() + ${timeoutMs};
const check = () => {
if (document.querySelector(${JSON.stringify(selector)})) return resolve('found');
if (Date.now() > deadline) return reject(new Error('Selector not found: ' + ${JSON.stringify(selector)}));
setTimeout(check, 100);
};
check();
})
`;
}
```
`page.ts` and `cdp.ts` handle `selector` branch in `wait()`.
**High-impact adapter changes**:
| Adapter | Old | New |
|---------|-----|-----|
| `twitter/*` (15 adapters) | `wait(5)` | `wait({ selector: '[data-testid="primaryColumn"]', timeout: 6 })` |
| `twitter/reply.ts` | `wait(5)` | `wait({ selector: '[data-testid="tweetTextarea_0"]', timeout: 8 })` |
| `medium/utils.ts` | `wait(5)` + inline 3s setTimeout | `wait({ selector: 'article', timeout: 8 })` + remove inline sleep |
| `substack/utils.ts` | `wait(5)` × 2 | `wait({ selector: 'article', timeout: 8 })` |
| `bloomberg/news.ts` | `wait(5)` | `wait({ selector: 'article', timeout: 6 })` |
| `sinablog/utils.ts` | `wait(5)` | `wait({ selector: 'article, .article', timeout: 6 })` |
| `producthunt` (already covered by layer 1) | — | — |
**Expected gain**: Twitter commands: 5s → ~0.52s. Medium: 8s → ~13s.
### Layer 3 — Daemon exponential backoff (cold-start)
Replace fixed 300ms poll in `_ensureDaemon()` (`browser/mcp.ts`) with exponential backoff:
```typescript
// before
while (Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 300));
if (await isExtensionConnected()) return;
}
// after
const backoffs = [50, 100, 200, 400, 800, 1500, 3000];
let i = 0;
while (Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, backoffs[Math.min(i++, backoffs.length - 1)]));
if (await isExtensionConnected()) return;
}
```
**Expected gain**: First cold-start check succeeds at ~150ms instead of ~600ms.
## Files Changed
### New / Modified (framework)
- `src/types.ts``WaitOptions.selector`, `IPage.waitForCapture()`
- `src/browser/dom-helpers.ts``waitForCaptureJs()`, `waitForSelectorJs()`
- `src/browser/page.ts``waitForCapture()`, `wait()` selector branch
- `src/browser/cdp.ts``waitForCapture()`, `wait()` selector branch
- `src/browser/mcp.ts` — exponential backoff in `_ensureDaemon()`
- `src/pipeline/steps/intercept.ts` — use `waitForCapture()`
### Modified (adapters — Layer 1, INTERCEPT)
- `src/clis/36kr/hot.ts`
- `src/clis/36kr/search.ts`
- `src/clis/twitter/search.ts`
- `src/clis/twitter/followers.ts`
- `src/clis/twitter/following.ts`
- `src/clis/producthunt/hot.ts`
- `src/clis/producthunt/browse.ts`
### Modified (adapters — Layer 2, selector)
- `src/clis/twitter/reply.ts`
- `src/clis/twitter/follow.ts`
- `src/clis/twitter/unfollow.ts`
- `src/clis/twitter/like.ts`
- `src/clis/twitter/bookmark.ts`
- `src/clis/twitter/unbookmark.ts`
- `src/clis/twitter/block.ts`
- `src/clis/twitter/unblock.ts`
- `src/clis/twitter/hide-reply.ts`
- `src/clis/twitter/notifications.ts`
- `src/clis/twitter/profile.ts`
- `src/clis/twitter/thread.ts`
- `src/clis/twitter/timeline.ts`
- `src/clis/twitter/delete.ts`
- `src/clis/twitter/reply-dm.ts`
- `src/clis/medium/utils.ts`
- `src/clis/substack/utils.ts`
- `src/clis/bloomberg/news.ts`
- `src/clis/sinablog/utils.ts`
## Delivery Order
1. Layer 1 (`waitForCapture`) — correctness fix, highest ROI
2. Layer 3 (backoff) — 3-line change, zero risk
3. Layer 2 (`wait({ selector })`) — largest adapter surface, can be done per-site
## Testing
- Unit tests: `waitForCaptureJs`, `waitForSelectorJs` exported and tested in `dom-helpers.test.ts` (if exists) or new test file
- Adapter tests: existing tests must continue to pass (mock `page.wait` / `page.waitForCapture`)
- Run: `npx vitest run --project unit --project adapter`
+188
View File
@@ -0,0 +1,188 @@
# 给新 Electron 应用生成 CLI
这篇文档是把一个新的 Electron 桌面应用接入 OpenCLI 的**中文入口指南**。
如果你需要更完整的背景和标准流程,继续看:
- [Chrome DevTools Protocol(中文)](/zh/advanced/cdp)
- [CLI-ifying Electron Applications(英文深度版)](/advanced/electron)
- [TypeScript 适配器开发指南(英文)](/developer/ts-adapter)
## 这篇文档适合什么场景
当目标应用满足下面条件时,用这套流程:
- 应用是 **Electron**,或者至少能暴露可用的 **CDPChrome DevTools Protocol** 端口
- 可以通过 `--remote-debugging-port=<port>` 启动
- 你希望控制的是桌面应用本身,而不是它背后的公开 HTTP API
如果应用**不是** Electron,或者不暴露 CDP,就不要硬套这套方案。那种情况应改用原生桌面自动化方案。可参考 [英文版说明](/advanced/electron#non-electron-pattern-applescript)。
## 最短落地路径
### 1. 先确认它是不是 Electron
macOS 下常见检查方式:
```bash
ls /Applications/AppName.app/Contents/Frameworks/Electron\ Framework.framework
```
如果存在,通常就可以继续尝试 CDP。
### 2. 带 CDP 端口启动应用
```bash
/Applications/AppName.app/Contents/MacOS/AppName --remote-debugging-port=9222
```
然后把 OpenCLI 指到这个端口:
```bash
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9222"
```
### 3. 先做 5 个基础命令
建议一个新 Electron 适配器先实现这 5 个命令:
- `status.ts` —— 确认 CDP 连通
- `dump.ts` —— 导出 DOM / snapshot,先做逆向再写逻辑
- `read.ts` —— 读取当前上下文
- `send.ts` —— 往真实编辑器里输入并发送
- `new.ts` —— 新建会话 / 标签页 / 文档
这是最稳妥的基线,因为它先把“能连上、能看见、能读、能写、能重置状态”这 5 件核心事情打通了。
## 推荐开发顺序
### 第一步:先做 `status`
目标不是功能,而是先证明:
- CDP 真的连上了
- 你连到的是对的窗口/标签页
- 应用当前页面确实可读
如果 `status` 都不稳定,先不要继续往下做。
### 第二步:做 `dump`
**不要猜 selector。**
先把这些导出来:
- `document.body.innerHTML`
- accessibility snapshot
- 稳定属性:`data-testid``role``aria-*`
然后再决定:
- 消息列表在哪
- 输入框在哪
- 按钮在哪
- 当前会话容器在哪
### 第三步:做 `read`
只读真正需要的区域,不要把整个页面文本都塞出来。
常见目标:
- 对话消息区
- 当前线程内容
- 当前编辑器历史
- 当前文档主区域
### 第四步:做 `send`
很多 Electron 应用的输入框是 React 控制组件,直接改 `.value` 往往没用。
更稳妥的方式通常是:
- 先 focus 到可编辑区域
- 能用时优先 `document.execCommand('insertText', false, text)`
- 最后用真实按键提交,比如 `Enter``Meta+Enter`
### 第五步:做 `new`
很多桌面应用的新建动作其实更适合走快捷键,而不是点按钮。
典型模式:
```ts
const isMac = process.platform === 'darwin';
await page.pressKey(isMac ? 'Meta+N' : 'Control+N');
await page.wait(1);
```
## 文件一般怎么放
一个 TypeScript 桌面适配器,通常结构是:
```text
src/clis/<app>/status.ts
src/clis/<app>/dump.ts
src/clis/<app>/read.ts
src/clis/<app>/send.ts
src/clis/<app>/new.ts
src/clis/<app>/utils.ts
```
当基础能力稳定后,再继续加:
- `ask`
- `history`
- `model`
- `screenshot`
- `export`
## 加完适配器后,还应该补什么文档
至少补这几项:
- `docs/adapters/desktop/` 下的适配器说明页
- 命令列表和示例
- 如何带 `--remote-debugging-port` 启动
- 需要哪些环境变量
- 平台限制和注意事项
可以参考这些现成文档:
- `docs/adapters/desktop/codex.md`
- `docs/adapters/desktop/chatwise.md`
- `docs/adapters/desktop/notion.md`
- `docs/adapters/desktop/discord.md`
## 常见问题
### CDP 能连,但命令不稳定
常见原因:
- 连错窗口或标签页
- 页面还没渲染完
- selector 是猜的,不是从 `dump` 里找出来的
- 输入框是受控组件,直接赋值不生效
### 应用看起来像 Chromium,但就是不好控
有些桌面应用虽然嵌了 Chromium,但并不真正暴露可用的 CDP 接口。
这种情况不要强行走 Electron 方案,应该换到非 Electron 的桌面自动化方案。
### 这个应用其实也有网页版本,还要不要做 Electron 适配器
如果网页版本已经足够稳定,浏览器适配器通常更简单。
只有当**桌面应用才是真正的集成面**时,再优先做 Electron 适配器。
## 推荐阅读顺序
如果你从零开始:
1. 先看这篇
2. 再看 [CLI-ifying Electron Applications(英文深度版)](/advanced/electron)
3. 再看 [Chrome DevTools Protocol(中文)](/zh/advanced/cdp)
4. 再看 [TypeScript Adapter Guide(英文)](/developer/ts-adapter)
5. 最后找一个现成桌面适配器文档照着做
## 最后一个实践建议
不要一上来就做很大的命令面。
先把下面 5 个做稳:
- `status`
- `dump`
- `read`
- `send`
- `new`
这 5 个稳定了,再往外扩,成本最低,返工也最少。
+1
View File
@@ -38,3 +38,4 @@ opencli bilibili hot -f csv # CSV
- [Browser Bridge 设置](/zh/guide/browser-bridge)
- [所有适配器](/zh/adapters/)
- [开发者指南](/zh/developer/contributing)
- [给新 Electron 应用生成 CLI](/zh/guide/electron-app-cli)
+75
View File
@@ -11,6 +11,12 @@ opencli plugin install github:ByteYue/opencli-plugin-github-trending
# 列出已安装插件
opencli plugin list
# 更新单个插件
opencli plugin update github-trending
# 更新全部已安装插件
opencli plugin update --all
# 使用插件(本质上就是普通 command)
opencli github-trending today
@@ -26,11 +32,80 @@ Plugins 存放在 `~/.opencli/plugins/<name>/`。每个子目录都会在启动
```bash
opencli plugin install github:user/repo
opencli plugin install github:user/repo/subplugin # 安装 monorepo 中的指定子插件
opencli plugin install https://github.com/user/repo
```
如果仓库名带 `opencli-plugin-` 前缀,本地目录会自动去掉这个前缀。例如 `opencli-plugin-hot-digest` 会变成 `hot-digest`
## 插件清单 (`opencli-plugin.json`)
插件可以在仓库根目录放置 `opencli-plugin.json` 来声明元数据:
```json
{
"name": "my-plugin",
"version": "1.0.0",
"opencli": ">=1.0.0",
"description": "我的插件"
}
```
| 字段 | 说明 |
|------|------|
| `name` | 插件名称(覆盖从仓库名推导的名称) |
| `version` | 语义化版本 |
| `opencli` | 所需的 opencli 版本范围(如 `>=1.0.0``^1.2.0` |
| `description` | 描述 |
| `plugins` | Monorepo 子插件声明(见下文) |
清单文件是可选的——没有它的插件依然可以正常工作。
## Monorepo 插件
一个仓库可以通过在 `opencli-plugin.json` 中声明 `plugins` 字段来包含多个插件:
```json
{
"version": "1.0.0",
"opencli": ">=1.0.0",
"description": "我的插件合集",
"plugins": {
"polymarket": {
"path": "packages/polymarket",
"description": "预测市场分析",
"version": "1.2.0"
},
"defi": {
"path": "packages/defi",
"description": "DeFi 协议数据",
"version": "0.8.0"
},
"experimental": {
"path": "packages/experimental",
"disabled": true
}
}
}
```
```bash
# 安装 monorepo 中的全部子插件
opencli plugin install github:user/opencli-plugins
# 安装指定子插件
opencli plugin install github:user/opencli-plugins/polymarket
```
- Monorepo 只 clone 一次到 `~/.opencli/monorepos/<repo>/`
- 每个子插件通过 symlink 出现在 `~/.opencli/plugins/<name>/`
- 更新任何子插件会拉取整个 monorepo 并刷新所有子插件
- 卸载最后一个子插件时,monorepo 目录会被自动清理
## 版本追踪
OpenCLI 会把已安装 plugin 的版本记录到 `~/.opencli/plugins.lock.json`。每条记录会保存 plugin source、当前 git commit hash、安装时间,以及最近一次更新时间。只要有这份元数据,`opencli plugin list` 就会显示对应的短 commit hash。
## YAML plugin 示例
```text
+517 -519
View File
File diff suppressed because it is too large Load Diff
+10 -3
View File
@@ -1,8 +1,8 @@
{
"manifest_version": 3,
"name": "OpenCLI",
"version": "1.2.6",
"description": "Bridge between opencli CLI and your browser — execute commands, read cookies, manage tabs.",
"version": "1.5.5",
"description": "Browser automation bridge for the OpenCLI CLI tool. Executes commands in isolated Chrome windows via a local daemon.",
"permissions": [
"debugger",
"tabs",
@@ -10,6 +10,9 @@
"activeTab",
"alarms"
],
"host_permissions": [
"<all_urls>"
],
"background": {
"service_worker": "dist/background.js",
"type": "module"
@@ -22,10 +25,14 @@
},
"action": {
"default_title": "OpenCLI",
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon-16.png",
"32": "icons/icon-32.png"
}
},
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'"
},
"homepage_url": "https://github.com/jackwener/opencli"
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "opencli-extension",
"version": "0.2.0",
"version": "1.5.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "opencli-extension",
"version": "0.2.0",
"version": "1.5.4",
"devDependencies": {
"@types/chrome": "^0.0.287",
"typescript": "^5.7.0",
+2 -1
View File
@@ -1,11 +1,12 @@
{
"name": "opencli-extension",
"version": "1.2.6",
"version": "1.5.5",
"private": true,
"type": "module",
"scripts": {
"dev": "vite build --watch",
"build": "vite build",
"package:release": "node scripts/package-release.mjs",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
+84
View File
@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 280px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 13px;
color: #333;
background: #fff;
padding: 16px;
}
.header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
}
.header img { width: 24px; height: 24px; }
.header h1 { font-size: 15px; font-weight: 600; }
.status-row {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 8px;
background: #f5f5f5;
}
.dot {
width: 8px; height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.dot.connected { background: #34c759; }
.dot.disconnected { background: #ff3b30; }
.dot.connecting { background: #ff9500; }
.status-text { font-size: 13px; color: #555; }
.status-text strong { color: #333; }
.hint {
margin-top: 10px;
padding: 8px 10px;
border-radius: 6px;
background: #f0f4ff;
font-size: 11px;
color: #666;
line-height: 1.5;
display: none;
}
.hint code {
background: #e8ecf1;
padding: 1px 4px;
border-radius: 3px;
font-size: 11px;
}
.footer {
margin-top: 14px;
text-align: center;
font-size: 11px;
color: #999;
}
.footer a { color: #007aff; text-decoration: none; }
.footer a:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="header">
<img src="icons/icon-48.png" alt="OpenCLI">
<h1>OpenCLI</h1>
</div>
<div class="status-row">
<span class="dot disconnected" id="dot"></span>
<span class="status-text" id="status">Checking...</span>
</div>
<div class="hint" id="hint">
This is normal. The extension connects automatically when you run any <code>opencli</code> command.
</div>
<div class="footer">
<a href="https://github.com/jackwener/opencli" target="_blank">Documentation</a>
</div>
<script src="popup.js"></script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
// Query connection status from background service worker
chrome.runtime.sendMessage({ type: 'getStatus' }, (resp) => {
const dot = document.getElementById('dot');
const status = document.getElementById('status');
const hint = document.getElementById('hint');
if (chrome.runtime.lastError || !resp) {
dot.className = 'dot disconnected';
status.innerHTML = '<strong>No daemon connected</strong>';
hint.style.display = 'block';
return;
}
if (resp.connected) {
dot.className = 'dot connected';
status.innerHTML = '<strong>Connected to daemon</strong>';
hint.style.display = 'none';
} else if (resp.reconnecting) {
dot.className = 'dot connecting';
status.innerHTML = '<strong>Reconnecting...</strong>';
hint.style.display = 'none';
} else {
dot.className = 'dot disconnected';
status.innerHTML = '<strong>No daemon connected</strong>';
hint.style.display = 'block';
}
});
+179
View File
@@ -0,0 +1,179 @@
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const extensionDir = path.resolve(__dirname, '..');
const repoRoot = path.resolve(extensionDir, '..');
function parseArgs(argv) {
const args = { outDir: path.join(repoRoot, 'extension-package') };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--out' && argv[i + 1]) {
const outDir = argv[++i];
args.outDir = path.isAbsolute(outDir)
? outDir
: path.resolve(process.cwd(), outDir);
}
}
return args;
}
async function exists(targetPath) {
try {
await fs.access(targetPath);
return true;
} catch {
return false;
}
}
function isLocalAsset(ref) {
return typeof ref === 'string'
&& ref.length > 0
&& !ref.startsWith('http://')
&& !ref.startsWith('https://')
&& !ref.startsWith('//')
&& !ref.startsWith('chrome://')
&& !ref.startsWith('chrome-extension://')
&& !ref.startsWith('data:')
&& !ref.startsWith('#');
}
function addLocalAsset(files, ref) {
if (isLocalAsset(ref)) files.add(ref);
}
function collectManifestEntrypoints(manifest) {
const files = new Set(['manifest.json']);
addLocalAsset(files, manifest.background?.service_worker);
addLocalAsset(files, manifest.action?.default_popup);
addLocalAsset(files, manifest.options_page);
addLocalAsset(files, manifest.devtools_page);
addLocalAsset(files, manifest.side_panel?.default_path);
for (const ref of Object.values(manifest.icons ?? {})) addLocalAsset(files, ref);
for (const ref of Object.values(manifest.action?.default_icon ?? {})) addLocalAsset(files, ref);
for (const contentScript of manifest.content_scripts ?? []) {
for (const jsFile of contentScript.js ?? []) addLocalAsset(files, jsFile);
for (const cssFile of contentScript.css ?? []) addLocalAsset(files, cssFile);
}
for (const page of manifest.sandbox?.pages ?? []) addLocalAsset(files, page);
for (const overridePage of Object.values(manifest.chrome_url_overrides ?? {})) addLocalAsset(files, overridePage);
for (const entry of manifest.web_accessible_resources ?? []) {
for (const resource of entry.resources ?? []) addLocalAsset(files, resource);
}
if (manifest.default_locale) files.add('_locales');
return [...files];
}
async function collectHtmlDependencies(relativeHtmlPath, files, visited) {
if (visited.has(relativeHtmlPath)) return;
visited.add(relativeHtmlPath);
const htmlPath = path.join(extensionDir, relativeHtmlPath);
const html = await fs.readFile(htmlPath, 'utf8');
const attrRe = /\b(?:src|href)=["']([^"'#?]+(?:\?[^"']*)?)["']/gi;
for (const match of html.matchAll(attrRe)) {
const rawRef = match[1];
const cleanRef = rawRef.split('?')[0];
if (!isLocalAsset(cleanRef)) continue;
const resolvedRelativePath = cleanRef.startsWith('/')
? cleanRef.slice(1)
: path.posix.normalize(path.posix.join(path.posix.dirname(relativeHtmlPath), cleanRef));
addLocalAsset(files, resolvedRelativePath);
if (resolvedRelativePath.endsWith('.html')) {
await collectHtmlDependencies(resolvedRelativePath, files, visited);
}
}
}
async function collectManifestAssets(manifest) {
const files = new Set(collectManifestEntrypoints(manifest));
const htmlPages = [];
if (manifest.action?.default_popup) {
htmlPages.push(manifest.action.default_popup);
}
if (manifest.options_page) htmlPages.push(manifest.options_page);
if (manifest.devtools_page) htmlPages.push(manifest.devtools_page);
if (manifest.side_panel?.default_path) htmlPages.push(manifest.side_panel.default_path);
for (const page of manifest.sandbox?.pages ?? []) htmlPages.push(page);
for (const overridePage of Object.values(manifest.chrome_url_overrides ?? {})) htmlPages.push(overridePage);
const visited = new Set();
for (const htmlPage of htmlPages) {
if (isLocalAsset(htmlPage)) {
await collectHtmlDependencies(htmlPage, files, visited);
}
}
return [...files];
}
async function copyEntry(relativePath, outDir) {
const fromPath = path.join(extensionDir, relativePath);
const toPath = path.join(outDir, relativePath);
const stats = await fs.stat(fromPath);
if (stats.isDirectory()) {
await fs.cp(fromPath, toPath, { recursive: true });
return;
}
await fs.mkdir(path.dirname(toPath), { recursive: true });
await fs.copyFile(fromPath, toPath);
}
async function findMissingEntries(baseDir, entries) {
const missingEntries = [];
for (const relativePath of entries) {
const absolutePath = path.join(baseDir, relativePath);
if (!(await exists(absolutePath))) {
missingEntries.push(relativePath);
}
}
return missingEntries;
}
async function main() {
const { outDir } = parseArgs(process.argv.slice(2));
const manifestPath = path.join(extensionDir, 'manifest.json');
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
const requiredEntries = await collectManifestAssets(manifest);
const missingEntries = await findMissingEntries(extensionDir, requiredEntries);
if (missingEntries.length > 0) {
console.error('Missing files referenced by the extension package:');
for (const missingEntry of missingEntries) console.error(`- ${missingEntry}`);
process.exit(1);
}
await fs.rm(outDir, { recursive: true, force: true });
await fs.mkdir(outDir, { recursive: true });
for (const relativePath of requiredEntries) {
await copyEntry(relativePath, outDir);
}
// Guard against regressions where manifest entry files (e.g. action.default_popup)
// are accidentally omitted from the packaged directory.
const packagedEntrypoints = collectManifestEntrypoints(manifest);
const missingPackagedEntrypoints = await findMissingEntries(outDir, packagedEntrypoints);
if (missingPackagedEntrypoints.length > 0) {
console.error('Packaged extension is missing files referenced by manifest.json:');
for (const missingEntry of missingPackagedEntrypoints) console.error(`- ${missingEntry}`);
process.exit(1);
}
console.log(`Extension package prepared at ${path.relative(repoRoot, outDir) || outDir}`);
}
await main();
+46 -1
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
type Listener<T extends (...args: any[]) => void> = { addListener: (fn: T) => void };
@@ -96,9 +96,15 @@ function createChromeMock() {
describe('background tab isolation', () => {
beforeEach(() => {
vi.resetModules();
vi.useRealTimers();
vi.stubGlobal('WebSocket', MockWebSocket);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('lists only automation-window web tabs', async () => {
const { chrome } = createChromeMock();
vi.stubGlobal('chrome', chrome);
@@ -133,6 +139,45 @@ describe('background tab isolation', () => {
expect(create).toHaveBeenCalledWith({ windowId: 1, url: 'https://new.example', active: true });
});
it('treats normalized same-url navigate as already complete', async () => {
const { chrome, tabs, update } = createChromeMock();
tabs[0].url = 'https://www.bilibili.com/';
tabs[0].title = 'bilibili';
tabs[0].status = 'complete';
vi.stubGlobal('chrome', chrome);
const mod = await import('./background');
mod.__test__.setAutomationWindowId('site:bilibili', 1);
const result = await mod.__test__.handleNavigate(
{ id: 'same-url', action: 'navigate', url: 'https://www.bilibili.com', workspace: 'site:bilibili' },
'site:bilibili',
);
expect(result).toEqual({
id: 'same-url',
ok: true,
data: {
title: 'bilibili',
url: 'https://www.bilibili.com/',
tabId: 1,
timedOut: false,
},
});
expect(update).not.toHaveBeenCalled();
});
it('keeps hash routes distinct when comparing target URLs', async () => {
const { chrome } = createChromeMock();
vi.stubGlobal('chrome', chrome);
const mod = await import('./background');
expect(mod.__test__.isTargetUrl('https://example.com/', 'https://example.com')).toBe(true);
expect(mod.__test__.isTargetUrl('https://example.com/#feed', 'https://example.com/#settings')).toBe(false);
expect(mod.__test__.isTargetUrl('https://example.com/app/', 'https://example.com/app')).toBe(false);
});
it('reports sessions per workspace', async () => {
const { chrome } = createChromeMock();
vi.stubGlobal('chrome', chrome);
+142 -38
View File
@@ -6,7 +6,7 @@
*/
import type { Command, Result } from './protocol';
import { DAEMON_WS_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol';
import { DAEMON_WS_URL, DAEMON_PING_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol';
import * as executor from './cdp';
let ws: WebSocket | null = null;
@@ -34,9 +34,23 @@ console.error = (...args: unknown[]) => { _origError(...args); forwardLog('error
// ─── WebSocket connection ────────────────────────────────────────────
function connect(): void {
/**
* Probe the daemon via its /ping HTTP endpoint before attempting a WebSocket
* connection. fetch() failures are silently catchable; new WebSocket() is not
* — Chrome logs ERR_CONNECTION_REFUSED to the extension error page before any
* JS handler can intercept it. By keeping the probe inside connect() every
* call site remains unchanged and the guard can never be accidentally skipped.
*/
async function connect(): Promise<void> {
if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return;
try {
const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1000) });
if (!res.ok) return; // unexpected response — not our daemon
} catch {
return; // daemon not running — skip WebSocket to avoid console noise
}
try {
ws = new WebSocket(DAEMON_WS_URL);
} catch {
@@ -51,6 +65,8 @@ function connect(): void {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
// Send version so the daemon can report mismatches to the CLI
ws?.send(JSON.stringify({ type: 'hello', version: chrome.runtime.getManifest().version }));
};
ws.onmessage = async (event) => {
@@ -74,14 +90,21 @@ function connect(): void {
};
}
/**
* After MAX_EAGER_ATTEMPTS (reaching 60s backoff), stop scheduling reconnects.
* The keepalive alarm (~24s) will still call connect() periodically, but at a
* much lower frequency — reducing console noise when the daemon is not running.
*/
const MAX_EAGER_ATTEMPTS = 6; // 2s, 4s, 8s, 16s, 32s, 60s — then stop
function scheduleReconnect(): void {
if (reconnectTimer) return;
reconnectAttempts++;
// Exponential backoff: 2s, 4s, 8s, 16s, ..., capped at 60s
if (reconnectAttempts > MAX_EAGER_ATTEMPTS) return; // let keepalive alarm handle it
const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY);
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
void connect();
}, delay);
}
@@ -97,7 +120,7 @@ type AutomationSession = {
};
const automationSessions = new Map<string, AutomationSession>();
const WINDOW_IDLE_TIMEOUT = 120000; // 120s — longer to survive slow pipelines
const WINDOW_IDLE_TIMEOUT = 30000; // 30s — quick cleanup after command finishes
function getWorkspaceKey(workspace?: string): string {
return workspace?.trim() || 'default';
@@ -137,8 +160,10 @@ async function getAutomationWindow(workspace: string): Promise<number> {
// Create a new window with a data: URI that New Tab Override extensions cannot intercept.
// Using about:blank would be hijacked by extensions like "New Tab Override".
// Note: Do NOT set `state` parameter here. Chrome 146+ rejects 'normal' as an invalid
// state value for windows.create(). The window defaults to 'normal' state anyway.
const win = await chrome.windows.create({
url: 'data:text/html,<html></html>',
url: BLANK_PAGE,
focused: false,
width: 1280,
height: 900,
@@ -177,7 +202,7 @@ function initialize(): void {
initialized = true;
chrome.alarms.create('keepalive', { periodInMinutes: 0.4 }); // ~24 seconds
executor.registerListeners();
connect();
void connect();
console.log('[opencli] OpenCLI extension initialized');
}
@@ -190,7 +215,19 @@ chrome.runtime.onStartup.addListener(() => {
});
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'keepalive') connect();
if (alarm.name === 'keepalive') void connect();
});
// ─── Popup status API ───────────────────────────────────────────────
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg?.type === 'getStatus') {
sendResponse({
connected: ws?.readyState === WebSocket.OPEN,
reconnecting: reconnectTimer !== null,
});
}
return false;
});
// ─── Command dispatcher ─────────────────────────────────────────────
@@ -229,10 +266,37 @@ async function handleCommand(cmd: Command): Promise<Result> {
// ─── Action handlers ─────────────────────────────────────────────────
/** Check if a URL can be attached via CDP (not chrome:// or chrome-extension://) */
/** Internal blank page used when no user URL is provided. */
const BLANK_PAGE = 'data:text/html,<html></html>';
/** Check if a URL can be attached via CDP — only allow http(s) and our internal blank page. */
function isDebuggableUrl(url?: string): boolean {
if (!url) return true; // empty/undefined = tab still loading, allow it
return !url.startsWith('chrome://') && !url.startsWith('chrome-extension://');
return url.startsWith('http://') || url.startsWith('https://') || url === BLANK_PAGE;
}
/** Check if a URL is safe for user-facing navigation (http/https only). */
function isSafeNavigationUrl(url: string): boolean {
return url.startsWith('http://') || url.startsWith('https://');
}
/** Minimal URL normalization for same-page comparison: root slash + default port only. */
function normalizeUrlForComparison(url?: string): string {
if (!url) return '';
try {
const parsed = new URL(url);
if ((parsed.protocol === 'https:' && parsed.port === '443') || (parsed.protocol === 'http:' && parsed.port === '80')) {
parsed.port = '';
}
const pathname = parsed.pathname === '/' ? '' : parsed.pathname;
return `${parsed.protocol}//${parsed.host}${pathname}${parsed.search}${parsed.hash}`;
} catch {
return url;
}
}
function isTargetUrl(currentUrl: string | undefined, targetUrl: string): boolean {
return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl);
}
/**
@@ -247,9 +311,14 @@ async function resolveTabId(tabId: number | undefined, workspace: string): Promi
if (tabId !== undefined) {
try {
const tab = await chrome.tabs.get(tabId);
if (isDebuggableUrl(tab.url)) return tabId;
// Tab exists but URL is not debuggable — fall through to auto-resolve
console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`);
const session = automationSessions.get(workspace);
if (isDebuggableUrl(tab.url) && session && tab.windowId === session.windowId) return tabId;
if (session && tab.windowId !== session.windowId) {
console.warn(`[opencli] Tab ${tabId} belongs to window ${tab.windowId}, not automation window ${session.windowId}, re-resolving`);
} else if (!isDebuggableUrl(tab.url)) {
// Tab exists but URL is not debuggable — fall through to auto-resolve
console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`);
}
} catch {
// Tab was closed — fall through to auto-resolve
console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`);
@@ -268,7 +337,7 @@ async function resolveTabId(tabId: number | undefined, workspace: string): Promi
// Try to reuse by navigating to a data: URI (not interceptable by New Tab Override).
const reuseTab = tabs.find(t => t.id);
if (reuseTab?.id) {
await chrome.tabs.update(reuseTab.id, { url: 'data:text/html,<html></html>' });
await chrome.tabs.update(reuseTab.id, { url: BLANK_PAGE });
await new Promise(resolve => setTimeout(resolve, 300));
try {
const updated = await chrome.tabs.get(reuseTab.id);
@@ -280,7 +349,7 @@ async function resolveTabId(tabId: number | undefined, workspace: string): Promi
}
// Fallback: create a new tab
const newTab = await chrome.tabs.create({ windowId, url: 'data:text/html,<html></html>', active: true });
const newTab = await chrome.tabs.create({ windowId, url: BLANK_PAGE, active: true });
if (!newTab.id) throw new Error('Failed to create tab in automation window');
return newTab.id;
}
@@ -314,13 +383,24 @@ async function handleExec(cmd: Command, workspace: string): Promise<Result> {
async function handleNavigate(cmd: Command, workspace: string): Promise<Result> {
if (!cmd.url) return { id: cmd.id, ok: false, error: 'Missing url' };
if (!isSafeNavigationUrl(cmd.url)) {
return { id: cmd.id, ok: false, error: 'Blocked URL scheme -- only http:// and https:// are allowed' };
}
const tabId = await resolveTabId(cmd.tabId, workspace);
// Capture the current URL before navigation to detect actual URL change
const beforeTab = await chrome.tabs.get(tabId);
const beforeUrl = beforeTab.url ?? '';
const beforeNormalized = normalizeUrlForComparison(beforeTab.url);
const targetUrl = cmd.url;
// Fast-path: tab is already at the target URL and fully loaded.
if (beforeTab.status === 'complete' && isTargetUrl(beforeTab.url, targetUrl)) {
return {
id: cmd.id,
ok: true,
data: { title: beforeTab.title, url: beforeTab.url, tabId, timedOut: false },
};
}
// Detach any existing debugger before top-level navigation.
// Some sites (observed on creator.xiaohongshu.com flows) can invalidate the
// current inspected target during navigation, which leaves a stale CDP attach
@@ -331,45 +411,51 @@ async function handleNavigate(cmd: Command, workspace: string): Promise<Result>
await chrome.tabs.update(tabId, { url: targetUrl });
// Wait for: 1) URL to change from the old URL, 2) tab.status === 'complete'
// This avoids the race where 'complete' fires for the OLD URL (e.g. about:blank)
// Wait until navigation completes. Resolve when status is 'complete' AND either:
// - the URL matches the target (handles same-URL / canonicalized navigations), OR
// - the URL differs from the pre-navigation URL (handles redirects).
let timedOut = false;
await new Promise<void>((resolve) => {
let urlChanged = false;
let settled = false;
let checkTimer: ReturnType<typeof setTimeout> | null = null;
let timeoutTimer: ReturnType<typeof setTimeout> | null = null;
const finish = () => {
if (settled) return;
settled = true;
chrome.tabs.onUpdated.removeListener(listener);
if (checkTimer) clearTimeout(checkTimer);
if (timeoutTimer) clearTimeout(timeoutTimer);
resolve();
};
const isNavigationDone = (url: string | undefined): boolean => {
return isTargetUrl(url, targetUrl) || normalizeUrlForComparison(url) !== beforeNormalized;
};
const listener = (id: number, info: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) => {
if (id !== tabId) return;
// Track URL change (new URL differs from the one before navigation)
if (info.url && info.url !== beforeUrl) {
urlChanged = true;
}
// Only resolve when both URL has changed AND status is complete
if (urlChanged && info.status === 'complete') {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
if (info.status === 'complete' && isNavigationDone(tab.url ?? info.url)) {
finish();
}
};
chrome.tabs.onUpdated.addListener(listener);
// Also check if the tab already navigated (e.g. instant cache hit)
setTimeout(async () => {
checkTimer = setTimeout(async () => {
try {
const currentTab = await chrome.tabs.get(tabId);
if (currentTab.url !== beforeUrl && currentTab.status === 'complete') {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
if (currentTab.status === 'complete' && isNavigationDone(currentTab.url)) {
finish();
}
} catch { /* tab gone */ }
}, 100);
// Timeout fallback with warning
setTimeout(() => {
chrome.tabs.onUpdated.removeListener(listener);
timeoutTimer = setTimeout(() => {
timedOut = true;
console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`);
resolve();
finish();
}, 15000);
});
@@ -396,8 +482,11 @@ async function handleTabs(cmd: Command, workspace: string): Promise<Result> {
return { id: cmd.id, ok: true, data };
}
case 'new': {
if (cmd.url && !isSafeNavigationUrl(cmd.url)) {
return { id: cmd.id, ok: false, error: 'Blocked URL scheme -- only http:// and https:// are allowed' };
}
const windowId = await getAutomationWindow(workspace);
const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? 'data:text/html,<html></html>', active: true });
const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? BLANK_PAGE, active: true });
return { id: cmd.id, ok: true, data: { tabId: tab.id, url: tab.url } };
}
case 'close': {
@@ -418,6 +507,16 @@ async function handleTabs(cmd: Command, workspace: string): Promise<Result> {
if (cmd.index === undefined && cmd.tabId === undefined)
return { id: cmd.id, ok: false, error: 'Missing index or tabId' };
if (cmd.tabId !== undefined) {
const session = automationSessions.get(workspace);
let tab: chrome.tabs.Tab;
try {
tab = await chrome.tabs.get(cmd.tabId);
} catch {
return { id: cmd.id, ok: false, error: `Tab ${cmd.tabId} no longer exists` };
}
if (!session || tab.windowId !== session.windowId) {
return { id: cmd.id, ok: false, error: `Tab ${cmd.tabId} is not in the automation window` };
}
await chrome.tabs.update(cmd.tabId, { active: true });
return { id: cmd.id, ok: true, data: { selected: cmd.tabId } };
}
@@ -433,6 +532,9 @@ async function handleTabs(cmd: Command, workspace: string): Promise<Result> {
}
async function handleCookies(cmd: Command): Promise<Result> {
if (!cmd.domain && !cmd.url) {
return { id: cmd.id, ok: false, error: 'Cookie scope required: provide domain or url to avoid dumping all cookies' };
}
const details: chrome.cookies.GetAllDetails = {};
if (cmd.domain) details.domain = cmd.domain;
if (cmd.url) details.url = cmd.url;
@@ -489,6 +591,8 @@ async function handleSessions(cmd: Command): Promise<Result> {
}
export const __test__ = {
handleNavigate,
isTargetUrl,
handleTabs,
handleSessions,
getAutomationWindowId: (workspace: string = 'default') => automationSessions.get(workspace)?.windowId ?? null,
+5 -2
View File
@@ -8,10 +8,13 @@
const attached = new Set<number>();
/** Check if a URL can be attached via CDP */
/** Internal blank page used when no user URL is provided. */
const BLANK_PAGE = 'data:text/html,<html></html>';
/** Check if a URL can be attached via CDP — only allow http(s) and our internal blank page. */
function isDebuggableUrl(url?: string): boolean {
if (!url) return true; // empty/undefined = tab still loading, allow it
return !url.startsWith('chrome://') && !url.startsWith('chrome-extension://');
return url.startsWith('http://') || url.startsWith('https://') || url === BLANK_PAGE;
}
async function ensureAttached(tabId: number): Promise<void> {
+2 -1
View File
@@ -49,7 +49,8 @@ export interface Result {
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}`;
/** Lightweight health-check endpoint — probed before each WebSocket attempt. */
export const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`;
/** Base reconnect delay for extension WebSocket (ms) */
export const WS_RECONNECT_BASE_DELAY = 2000;
+12 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@jackwener/opencli",
"version": "1.3.3",
"version": "1.5.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@jackwener/opencli",
"version": "1.3.3",
"version": "1.5.4",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -15,6 +15,7 @@
"commander": "^14.0.3",
"js-yaml": "^4.1.0",
"turndown": "^7.2.2",
"undici": "^7.24.6",
"ws": "^8.18.0"
},
"bin": {
@@ -3514,6 +3515,15 @@
"node": ">=14.17"
}
},
"node_modules/undici": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz",
"integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@jackwener/opencli",
"version": "1.3.3",
"version": "1.5.5",
"publishConfig": {
"access": "public"
},
@@ -19,17 +19,20 @@
},
"scripts": {
"dev": "tsx src/main.ts",
"dev:bun": "bun src/main.ts",
"build": "npm run clean-dist && tsc && npm run clean-yaml && npm run copy-yaml && npm run build-manifest",
"build-manifest": "node dist/build-manifest.js",
"clean-dist": "node scripts/clean-dist.cjs",
"clean-yaml": "node scripts/clean-yaml.cjs",
"copy-yaml": "node scripts/copy-yaml.cjs",
"start": "node dist/main.js",
"start:bun": "bun dist/main.js",
"postinstall": "node scripts/postinstall.js || true",
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit",
"prepublishOnly": "npm run build",
"test": "vitest run --project unit",
"test:bun": "bun vitest run --project unit",
"test:adapter": "vitest run --project adapter",
"test:all": "vitest run",
"test:e2e": "vitest run --project e2e",
@@ -55,6 +58,7 @@
"commander": "^14.0.3",
"js-yaml": "^4.1.0",
"turndown": "^7.2.2",
"undici": "^7.24.6",
"ws": "^8.18.0"
},
"devDependencies": {
+10
View File
@@ -195,6 +195,16 @@ function main() {
console.error(`Warning: Could not install shell completion: ${err.message}`);
}
}
// ── Browser Bridge setup hint ───────────────────────────────────────
console.log('');
console.log(' \x1b[1mNext step — Browser Bridge setup\x1b[0m');
console.log(' Browser commands (bilibili, zhihu, twitter...) require the extension:');
console.log(' 1. Download: https://github.com/jackwener/opencli/releases');
console.log(' 2. Open chrome://extensions → enable Developer Mode → Load unpacked');
console.log('');
console.log(' Then run \x1b[36mopencli doctor\x1b[0m to verify.');
console.log('');
}
main();
+2 -1
View File
@@ -14,6 +14,7 @@ import {
VOLATILE_PARAMS,
SEARCH_PARAMS,
PAGINATION_PARAMS,
LIMIT_PARAMS,
FIELD_ROLES,
} from './constants.js';
@@ -164,6 +165,6 @@ export function classifyQueryParams(url: string): {
params,
hasSearch: params.some(p => SEARCH_PARAMS.has(p)),
hasPagination: params.some(p => PAGINATION_PARAMS.has(p)),
hasLimit: params.some(p => SEARCH_PARAMS.has(p)),
hasLimit: params.some(p => LIMIT_PARAMS.has(p)),
};
}
+19 -9
View File
@@ -1,10 +1,14 @@
import { afterEach, describe, it, expect, vi } from 'vitest';
import { BrowserBridge, __test__, generateStealthJs } from './browser/index.js';
import { describe, it, expect, vi } from 'vitest';
import { BrowserBridge, generateStealthJs } from './browser/index.js';
import { extractTabEntries, diffTabIndexes, appendLimited } from './browser/tabs.js';
import { withTimeoutMs } from './runtime.js';
import { __test__ as cdpTest } from './browser/cdp.js';
import { isRetryableSettleError } from './browser/page.js';
import * as daemonClient from './browser/daemon-client.js';
describe('browser helpers', () => {
it('extracts tab entries from string snapshots', () => {
const entries = __test__.extractTabEntries('Tab 0 https://example.com\nTab 1 Chrome Extension');
const entries = extractTabEntries('Tab 0 https://example.com\nTab 1 Chrome Extension');
expect(entries).toEqual([
{ index: 0, identity: 'https://example.com' },
@@ -13,7 +17,7 @@ describe('browser helpers', () => {
});
it('extracts tab entries from MCP markdown format', () => {
const entries = __test__.extractTabEntries(
const entries = extractTabEntries(
'- 0: (current) [Playwright MCP extension](chrome-extension://abc/connect.html)\n- 1: [知乎 - 首页](https://www.zhihu.com/)'
);
@@ -24,7 +28,7 @@ describe('browser helpers', () => {
});
it('closes only tabs that were opened during the session', () => {
const tabsToClose = __test__.diffTabIndexes(
const tabsToClose = diffTabIndexes(
['https://example.com', 'Chrome Extension'],
[
{ index: 0, identity: 'https://example.com' },
@@ -38,15 +42,21 @@ describe('browser helpers', () => {
});
it('keeps only the tail of stderr buffers', () => {
expect(__test__.appendLimited('12345', '67890', 8)).toBe('34567890');
expect(appendLimited('12345', '67890', 8)).toBe('34567890');
});
it('times out slow promises', async () => {
await expect(__test__.withTimeoutMs(new Promise(() => {}), 10, 'timeout')).rejects.toThrow('timeout');
await expect(withTimeoutMs(new Promise(() => {}), 10, 'timeout')).rejects.toThrow('timeout');
});
it('retries settle only for target-invalidated errors', () => {
expect(isRetryableSettleError(new Error('{"code":-32000,"message":"Inspected target navigated or closed"}'))).toBe(true);
expect(isRetryableSettleError(new Error('attach failed: target no longer exists'))).toBe(false);
expect(isRetryableSettleError(new Error('malformed exec payload'))).toBe(false);
});
it('prefers the real Electron app target over DevTools and blank pages', () => {
const target = __test__.selectCDPTarget([
const target = cdpTest.selectCDPTarget([
{
type: 'page',
title: 'DevTools - localhost:9224',
@@ -73,7 +83,7 @@ describe('browser helpers', () => {
it('honors OPENCLI_CDP_TARGET when multiple inspectable targets exist', () => {
vi.stubEnv('OPENCLI_CDP_TARGET', 'codex');
const target = __test__.selectCDPTarget([
const target = cdpTest.selectCDPTarget([
{
type: 'app',
title: 'Cursor',
+66
View File
@@ -0,0 +1,66 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { MockWebSocket } = vi.hoisted(() => {
class MockWebSocket {
static OPEN = 1;
readyState = 1;
private handlers = new Map<string, Array<(...args: any[]) => void>>();
constructor(_url: string) {
queueMicrotask(() => this.emit('open'));
}
on(event: string, handler: (...args: any[]) => void): void {
const handlers = this.handlers.get(event) ?? [];
handlers.push(handler);
this.handlers.set(event, handlers);
}
send(_message: string): void {}
close(): void {
this.readyState = 3;
}
private emit(event: string, ...args: any[]): void {
for (const handler of this.handlers.get(event) ?? []) {
handler(...args);
}
}
}
return { MockWebSocket };
});
vi.mock('ws', () => ({
WebSocket: MockWebSocket,
}));
import { CDPBridge } from './cdp.js';
describe('CDPBridge cookies', () => {
beforeEach(() => {
vi.unstubAllEnvs();
});
it('filters cookies by actual domain match instead of substring match', async () => {
vi.stubEnv('OPENCLI_CDP_ENDPOINT', 'ws://127.0.0.1:9222/devtools/page/1');
const bridge = new CDPBridge();
vi.spyOn(bridge, 'send').mockResolvedValue({
cookies: [
{ name: 'good', value: '1', domain: '.example.com' },
{ name: 'exact', value: '2', domain: 'example.com' },
{ name: 'bad', value: '3', domain: 'notexample.com' },
],
});
const page = await bridge.connect();
const cookies = await page.getCookies({ domain: 'example.com' });
expect(cookies).toEqual([
{ name: 'good', value: '1', domain: '.example.com' },
{ name: 'exact', value: '2', domain: 'example.com' },
]);
});
});
+80 -37
View File
@@ -9,7 +9,10 @@
*/
import { WebSocket, type RawData } from 'ws';
import { request as httpRequest } from 'node:http';
import { request as httpsRequest } from 'node:https';
import type { BrowserCookie, IPage, ScreenshotOptions, SnapshotOptions, WaitOptions } from '../types.js';
import type { IBrowserFactory } from '../runtime.js';
import { wrapForEval } from './utils.js';
import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
import { generateStealthJs } from './stealth.js';
@@ -22,7 +25,10 @@ import {
autoScrollJs,
networkRequestsJs,
waitForDomStableJs,
waitForCaptureJs,
waitForSelectorJs,
} from './dom-helpers.js';
import { isRecord, saveBase64ToFile } from '../utils.js';
export interface CDPTarget {
type?: string;
@@ -42,9 +48,9 @@ interface RuntimeEvaluateResult {
};
}
const CDP_SEND_TIMEOUT = 30_000; // 30s per command
const CDP_SEND_TIMEOUT = 30_000;
export class CDPBridge {
export class CDPBridge implements IBrowserFactory {
private _ws: WebSocket | null = null;
private _idCounter = 0;
private _pending = new Map<number, { resolve: (val: unknown) => void; reject: (err: Error) => void; timer: ReturnType<typeof setTimeout> }>();
@@ -56,12 +62,9 @@ export class CDPBridge {
const endpoint = process.env.OPENCLI_CDP_ENDPOINT;
if (!endpoint) throw new Error('OPENCLI_CDP_ENDPOINT is not set');
// If it's a direct ws:// URL, use it. Otherwise, fetch the /json endpoint to find a page.
let wsUrl = endpoint;
if (endpoint.startsWith('http')) {
const res = await fetch(`${endpoint.replace(/\/$/, '')}/json`);
if (!res.ok) throw new Error(`Failed to fetch CDP targets: ${res.statusText}`);
const targets = await res.json() as CDPTarget[];
const targets = await fetchJsonDirect(`${endpoint.replace(/\/$/, '')}/json`) as CDPTarget[];
const target = selectCDPTarget(targets);
if (!target || !target.webSocketDebuggerUrl) {
throw new Error('No inspectable targets found at CDP endpoint');
@@ -71,19 +74,16 @@ export class CDPBridge {
return new Promise((resolve, reject) => {
const ws = new WebSocket(wsUrl);
const timeoutMs = (opts?.timeout ?? 10) * 1000; // opts.timeout is in seconds
const timeoutMs = (opts?.timeout ?? 10) * 1000;
const timeout = setTimeout(() => reject(new Error('CDP connect timeout')), timeoutMs);
ws.on('open', async () => {
clearTimeout(timeout);
this._ws = ws;
// Register stealth script to run before any page JS on every navigation.
try {
await this.send('Page.enable');
await this.send('Page.addScriptToEvaluateOnNewDocument', { source: generateStealthJs() });
} catch {
// Non-fatal: stealth is best-effort
}
} catch {}
resolve(new CDPPage(this));
});
@@ -95,7 +95,6 @@ export class CDPBridge {
ws.on('message', (data: RawData) => {
try {
const msg = JSON.parse(data.toString());
// Handle command responses
if (msg.id && this._pending.has(msg.id)) {
const entry = this._pending.get(msg.id)!;
clearTimeout(entry.timer);
@@ -106,16 +105,13 @@ export class CDPBridge {
entry.resolve(msg.result);
}
}
// Handle CDP events
if (msg.method) {
const listeners = this._eventListeners.get(msg.method);
if (listeners) {
for (const fn of listeners) fn(msg.params);
}
}
} catch {
// ignore parsing errors
}
} catch {}
});
});
}
@@ -133,7 +129,6 @@ export class CDPBridge {
this._eventListeners.clear();
}
/** Send a CDP command with timeout guard (P0 fix #4) */
async send(method: string, params: Record<string, unknown> = {}, timeoutMs: number = CDP_SEND_TIMEOUT): Promise<unknown> {
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) {
throw new Error('CDP connection is not open');
@@ -149,19 +144,19 @@ export class CDPBridge {
});
}
/** Listen for a CDP event */
on(event: string, handler: (params: unknown) => void): void {
let set = this._eventListeners.get(event);
if (!set) { set = new Set(); this._eventListeners.set(event, set); }
if (!set) {
set = new Set();
this._eventListeners.set(event, set);
}
set.add(handler);
}
/** Remove a CDP event listener */
off(event: string, handler: (params: unknown) => void): void {
this._eventListeners.get(event)?.delete(handler);
}
/** Wait for a CDP event to fire (one-shot) */
waitForEvent(event: string, timeoutMs: number = 15_000): Promise<unknown> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
@@ -180,20 +175,18 @@ export class CDPBridge {
class CDPPage implements IPage {
private _pageEnabled = false;
private _lastUrl: string | null = null;
constructor(private bridge: CDPBridge) {}
/** Navigate with proper load event waiting (P1 fix #3) */
async goto(url: string, options?: { waitUntil?: 'load' | 'none'; settleMs?: number }): Promise<void> {
if (!this._pageEnabled) {
await this.bridge.send('Page.enable');
this._pageEnabled = true;
}
const loadPromise = this.bridge.waitForEvent('Page.loadEventFired', 30_000)
.catch(() => {}); // Don't fail if load event times out — page may be an SPA
const loadPromise = this.bridge.waitForEvent('Page.loadEventFired', 30_000).catch(() => {});
await this.bridge.send('Page.navigate', { url });
await loadPromise;
// Smart settle: use DOM stability detection instead of fixed sleep.
// settleMs is now a timeout cap (default 1000ms), not a fixed wait.
this._lastUrl = url;
if (options?.waitUntil !== 'none') {
const maxMs = options?.settleMs ?? 1000;
await this.evaluate(waitForDomStableJs(maxMs, Math.min(500, maxMs)));
@@ -205,7 +198,7 @@ class CDPPage implements IPage {
const result = await this.bridge.send('Runtime.evaluate', {
expression,
returnByValue: true,
awaitPromise: true
awaitPromise: true,
}) as RuntimeEvaluateResult;
if (result.exceptionDetails) {
throw new Error('Evaluate error: ' + (result.exceptionDetails.exception?.description || 'Unknown exception'));
@@ -218,7 +211,7 @@ class CDPPage implements IPage {
const cookies = isRecord(result) && Array.isArray(result.cookies) ? result.cookies : [];
const domain = opts.domain;
return domain
? cookies.filter((cookie): cookie is BrowserCookie => isCookie(cookie) && cookie.domain.includes(domain))
? cookies.filter((cookie): cookie is BrowserCookie => isCookie(cookie) && matchesCookieDomain(cookie.domain, domain))
: cookies;
}
@@ -234,8 +227,6 @@ class CDPPage implements IPage {
return this.evaluate(snapshotJs);
}
// ── Shared DOM operations (P1 fix #5 — using dom-helpers.ts) ──
async click(ref: string): Promise<void> {
await this.evaluate(clickJs(ref));
}
@@ -258,12 +249,26 @@ class CDPPage implements IPage {
async wait(options: number | WaitOptions): Promise<void> {
if (typeof options === 'number') {
await new Promise(resolve => setTimeout(resolve, options * 1000));
if (options >= 1) {
try {
const maxMs = options * 1000;
await this.evaluate(waitForDomStableJs(maxMs, Math.min(500, maxMs)));
return;
} catch {
// Fallback: fixed sleep
}
}
await new Promise((resolve) => setTimeout(resolve, options * 1000));
return;
}
if (typeof options.time === 'number') {
const waitTime = options.time;
await new Promise(resolve => setTimeout(resolve, waitTime * 1000));
await new Promise((resolve) => setTimeout(resolve, waitTime * 1000));
return;
}
if (options.selector) {
const timeout = (options.timeout ?? 10) * 1000;
await this.evaluate(waitForSelectorJs(options.selector, timeout));
return;
}
if (options.text) {
@@ -272,8 +277,6 @@ class CDPPage implements IPage {
}
}
// ── Implemented methods (P1 fix #2) ──
async scroll(direction: string = 'down', amount: number = 500): Promise<void> {
await this.evaluate(scrollJs(direction, amount));
}
@@ -322,6 +325,10 @@ class CDPPage implements IPage {
return [];
}
async getCurrentUrl(): Promise<string | null> {
return this._lastUrl;
}
async installInterceptor(pattern: string): Promise<void> {
const { generateInterceptorJs } = await import('../interceptor.js');
await this.evaluate(generateInterceptorJs(JSON.stringify(pattern), {
@@ -335,9 +342,12 @@ class CDPPage implements IPage {
const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
return Array.isArray(result) ? result : [];
}
}
import { isRecord, saveBase64ToFile } from '../utils.js';
async waitForCapture(timeout: number = 10): Promise<void> {
const maxMs = timeout * 1000;
await this.evaluate(waitForCaptureJs(maxMs));
}
}
function isCookie(value: unknown): value is BrowserCookie {
return isRecord(value)
@@ -346,7 +356,12 @@ function isCookie(value: unknown): value is BrowserCookie {
&& typeof value.domain === 'string';
}
// ── CDP target selection (unchanged) ──
function matchesCookieDomain(cookieDomain: string, targetDomain: string): boolean {
const normalizedCookieDomain = cookieDomain.replace(/^\./, '').toLowerCase();
const normalizedTargetDomain = targetDomain.replace(/^\./, '').toLowerCase();
return normalizedTargetDomain === normalizedCookieDomain
|| normalizedTargetDomain.endsWith(`.${normalizedCookieDomain}`);
}
function selectCDPTarget(targets: CDPTarget[]): CDPTarget | undefined {
const preferredPattern = compilePreferredPattern(process.env.OPENCLI_CDP_TARGET);
@@ -420,3 +435,31 @@ export const __test__ = {
selectCDPTarget,
scoreCDPTarget,
};
function fetchJsonDirect(url: string): Promise<unknown> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const request = (parsed.protocol === 'https:' ? httpsRequest : httpRequest)(parsed, (res) => {
const statusCode = res.statusCode ?? 0;
if (statusCode < 200 || statusCode >= 300) {
res.resume();
reject(new Error(`Failed to fetch CDP targets: HTTP ${statusCode}`));
return;
}
const chunks: Buffer[] = [];
res.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
res.on('end', () => {
try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
} catch (error) {
reject(error instanceof Error ? error : new Error(String(error)));
}
});
});
request.on('error', reject);
request.setTimeout(10_000, () => request.destroy(new Error('Timed out fetching CDP targets')));
request.end();
});
}
+3 -2
View File
@@ -6,6 +6,7 @@
import { DEFAULT_DAEMON_PORT } from '../constants.js';
import type { BrowserSessionInfo } from '../types.js';
import { sleep } from '../utils.js';
const DAEMON_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
@@ -114,7 +115,7 @@ export async function sendCommand(
|| errMsg.includes('no longer exists');
if (isTransient && attempt < maxRetries) {
// Longer delay for extension recovery (service worker restart)
await new Promise(r => setTimeout(r, 1500));
await sleep(1500);
continue;
}
throw new Error(result.error ?? 'Daemon command failed');
@@ -125,7 +126,7 @@ export async function sendCommand(
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));
await sleep(500);
continue;
}
throw err;
+14 -6
View File
@@ -13,17 +13,25 @@ export { isDaemonRunning };
/**
* Check daemon status and return connection info.
*/
export async function checkDaemonStatus(): Promise<{
export async function checkDaemonStatus(opts?: { timeout?: number }): Promise<{
running: boolean;
extensionConnected: boolean;
extensionVersion?: string;
}> {
try {
const port = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
const res = await fetch(`http://127.0.0.1:${port}/status`, {
headers: { 'X-OpenCLI': '1' },
});
const data = await res.json() as { ok: boolean; extensionConnected: boolean };
return { running: true, extensionConnected: data.extensionConnected };
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), opts?.timeout ?? 2000);
try {
const res = await fetch(`http://127.0.0.1:${port}/status`, {
headers: { 'X-OpenCLI': '1' },
signal: controller.signal,
});
const data = await res.json() as { ok: boolean; extensionConnected: boolean; extensionVersion?: string };
return { running: true, extensionConnected: data.extensionConnected, extensionVersion: data.extensionVersion };
} finally {
clearTimeout(timer);
}
} catch {
return { running: false, extensionConnected: false };
}
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest';
import { waitForCaptureJs, waitForSelectorJs } from './dom-helpers.js';
describe('waitForCaptureJs', () => {
it('returns a non-empty string', () => {
const code = waitForCaptureJs(1000);
expect(typeof code).toBe('string');
expect(code.length).toBeGreaterThan(0);
expect(code).toContain('__opencli_xhr');
expect(code).toContain('resolve');
expect(code).toContain('reject');
});
it('resolves "captured" when __opencli_xhr is populated before deadline', async () => {
const g = globalThis as any;
g.__opencli_xhr = [];
g.window = g; // stub window for Node eval
const code = waitForCaptureJs(1000);
const promise = eval(code) as Promise<string>;
g.__opencli_xhr.push({ data: 'test' });
await expect(promise).resolves.toBe('captured');
delete g.__opencli_xhr;
delete g.window;
});
it('rejects when __opencli_xhr stays empty past deadline', async () => {
const g = globalThis as any;
g.__opencli_xhr = [];
g.window = g;
const code = waitForCaptureJs(50); // 50ms timeout
const promise = eval(code) as Promise<string>;
await expect(promise).rejects.toThrow('No network capture within 0.05s');
delete g.__opencli_xhr;
delete g.window;
});
it('resolves immediately when __opencli_xhr already has data', async () => {
const g = globalThis as any;
g.__opencli_xhr = [{ data: 'already here' }];
g.window = g;
const code = waitForCaptureJs(1000);
await expect(eval(code) as Promise<string>).resolves.toBe('captured');
delete g.__opencli_xhr;
delete g.window;
});
});
describe('waitForSelectorJs', () => {
it('returns a non-empty string', () => {
const code = waitForSelectorJs('#app', 1000);
expect(typeof code).toBe('string');
expect(code).toContain('#app');
expect(code).toContain('querySelector');
expect(code).toContain('MutationObserver');
});
it('resolves "found" immediately when selector already present', async () => {
const g = globalThis as any;
const fakeEl = { tagName: 'DIV' };
g.document = { querySelector: (_: string) => fakeEl };
const code = waitForSelectorJs('[data-testid="primaryColumn"]', 1000);
await expect(eval(code) as Promise<string>).resolves.toBe('found');
delete g.document;
});
it('resolves "found" when selector appears after DOM mutation', async () => {
const g = globalThis as any;
let mutationCallback!: () => void;
g.MutationObserver = class {
constructor(cb: () => void) { mutationCallback = cb; }
observe() {}
disconnect() {}
};
let calls = 0;
g.document = {
querySelector: (_: string) => (calls++ > 0 ? { tagName: 'DIV' } : null),
body: {},
};
const code = waitForSelectorJs('#app', 1000);
const promise = eval(code) as Promise<string>;
mutationCallback(); // simulate DOM mutation
await expect(promise).resolves.toBe('found');
delete g.document;
delete g.MutationObserver;
});
it('rejects when selector never appears within timeout', async () => {
const g = globalThis as any;
g.MutationObserver = class {
constructor(_cb: () => void) {}
observe() {}
disconnect() {}
};
g.document = { querySelector: (_: string) => null, body: {} };
const code = waitForSelectorJs('#missing', 50);
await expect(eval(code) as Promise<string>).rejects.toThrow('Selector not found: #missing');
delete g.document;
delete g.MutationObserver;
});
});
+44
View File
@@ -179,3 +179,47 @@ export function waitForDomStableJs(maxMs: number, quietMs: number): string {
})
`;
}
/**
* Generate JS to wait until window.__opencli_xhr has ≥1 captured response.
* Polls every 100ms. Resolves 'captured' on success; rejects after maxMs.
* Used after installInterceptor() + goto() instead of a fixed sleep.
*/
export function waitForCaptureJs(maxMs: number): string {
return `
new Promise((resolve, reject) => {
const deadline = Date.now() + ${maxMs};
const check = () => {
if ((window.__opencli_xhr || []).length > 0) return resolve('captured');
if (Date.now() > deadline) return reject(new Error('No network capture within ${maxMs / 1000}s'));
setTimeout(check, 100);
};
check();
})
`;
}
/**
* Generate JS to wait until document.querySelector(selector) returns a match.
* Uses MutationObserver for near-instant resolution; falls back to reject after timeoutMs.
*/
export function waitForSelectorJs(selector: string, timeoutMs: number): string {
return `
new Promise((resolve, reject) => {
const sel = ${JSON.stringify(selector)};
if (document.querySelector(sel)) return resolve('found');
const cap = setTimeout(() => {
obs.disconnect();
reject(new Error('Selector not found: ' + sel));
}, ${timeoutMs});
const obs = new MutationObserver(() => {
if (document.querySelector(sel)) {
clearTimeout(cap);
obs.disconnect();
resolve('found');
}
});
obs.observe(document.body || document.documentElement, { childList: true, subtree: true });
})
`;
}
+42
View File
@@ -247,3 +247,45 @@ describe('getFormStateJs', () => {
expect(js).toContain('data-opencli-ref');
});
});
describe('Search Element Detection', () => {
it('includes SEARCH_INDICATORS set', () => {
const js = generateSnapshotJs();
expect(js).toContain('SEARCH_INDICATORS');
expect(js).toContain('search');
expect(js).toContain('magnify');
expect(js).toContain('glass');
});
it('includes hasFormControlDescendant function', () => {
const js = generateSnapshotJs();
expect(js).toContain('hasFormControlDescendant');
expect(js).toContain('input');
expect(js).toContain('select');
expect(js).toContain('textarea');
});
it('includes isSearchElement function', () => {
const js = generateSnapshotJs();
expect(js).toContain('isSearchElement');
expect(js).toContain('className');
expect(js).toContain('data-');
});
it('checks label wrapper detection in isInteractive', () => {
const js = generateSnapshotJs();
// Label elements without "for" attribute should check for form control descendants
expect(js).toContain('hasFormControlDescendant(el, 2)');
});
it('checks span wrapper detection in isInteractive', () => {
const js = generateSnapshotJs();
// Span elements should check for form control descendants
expect(js).toContain("tag === 'span'");
});
it('integrates search element detection into isInteractive', () => {
const js = generateSnapshotJs();
expect(js).toContain('isSearchElement(el)');
});
});
+54 -1
View File
@@ -271,6 +271,13 @@ export function generateSnapshotJs(opts: DomSnapshotOptions = {}): string {
const AD_SELECTOR_RE = /\\b(ad[_-]?(?:banner|container|wrapper|slot|unit|block|frame|leaderboard|sidebar)|google[_-]?ad|sponsored|adsbygoogle|banner[_-]?ad)\\b/i;
// Search element indicators for heuristic detection
const SEARCH_INDICATORS = new Set([
'search', 'magnify', 'glass', 'lookup', 'find', 'query',
'search-icon', 'search-btn', 'search-button', 'searchbox',
'fa-search', 'icon-search', 'btn-search',
]);
// ── Viewport & Layout Helpers ──────────────────────────────────────
const vw = window.innerWidth;
@@ -339,19 +346,65 @@ export function generateSnapshotJs(opts: DomSnapshotOptions = {}): string {
// ── Interactivity Detection ────────────────────────────────────────
// Check if element contains a form control within limited depth (handles label/span wrappers)
function hasFormControlDescendant(el, maxDepth = 2) {
if (maxDepth <= 0) return false;
for (const child of el.children || []) {
const tag = child.tagName?.toLowerCase();
if (tag === 'input' || tag === 'select' || tag === 'textarea') return true;
if (hasFormControlDescendant(child, maxDepth - 1)) return true;
}
return false;
}
function isInteractive(el) {
const tag = el.tagName.toLowerCase();
if (INTERACTIVE_TAGS.has(tag)) {
if (tag === 'label' && el.hasAttribute('for')) return false;
// Skip labels that proxy via "for" to avoid double-activating external inputs
if (tag === 'label') {
if (el.hasAttribute('for')) return false;
// Detect labels that wrap form controls up to two levels deep (label > span > input)
if (hasFormControlDescendant(el, 2)) return true;
}
if (el.disabled && (tag === 'button' || tag === 'input')) return false;
return true;
}
// Span wrappers for UI components - check if they contain form controls
if (tag === 'span') {
if (hasFormControlDescendant(el, 2)) return true;
}
const role = el.getAttribute('role');
if (role && INTERACTIVE_ROLES.has(role)) return true;
if (el.hasAttribute('onclick') || el.hasAttribute('onmousedown') || el.hasAttribute('ontouchstart')) return true;
if (el.hasAttribute('tabindex') && el.getAttribute('tabindex') !== '-1') return true;
try { if (window.getComputedStyle(el).cursor === 'pointer') return true; } catch {}
if (el.isContentEditable && el.getAttribute('contenteditable') !== 'false') return true;
// Search element heuristic detection
if (isSearchElement(el)) return true;
return false;
}
function isSearchElement(el) {
// Check class names for search indicators
const className = el.className?.toLowerCase() || '';
const classes = className.split(/\\s+/).filter(Boolean);
for (const cls of classes) {
const cleaned = cls.replace(/[^a-z0-9-]/g, '');
if (SEARCH_INDICATORS.has(cleaned)) return true;
}
// Check id for search indicators
const id = el.id?.toLowerCase() || '';
const cleanedId = id.replace(/[^a-z0-9-]/g, '');
if (SEARCH_INDICATORS.has(cleanedId)) return true;
// Check data-* attributes for search functionality
for (const attr of el.attributes || []) {
if (attr.name.startsWith('data-')) {
const value = attr.value.toLowerCase();
for (const kw of SEARCH_INDICATORS) {
if (value.includes(kw)) return true;
}
}
}
return false;
}
+13 -14
View File
@@ -5,38 +5,37 @@
* The daemon architecture has a single failure mode: daemon not reachable or extension not connected.
*/
import { BrowserConnectError } from '../errors.js';
import { BrowserConnectError, type BrowserConnectKind } from '../errors.js';
import { DEFAULT_DAEMON_PORT } from '../constants.js';
export type ConnectFailureKind = 'daemon-not-running' | 'extension-not-connected' | 'command-failed' | 'unknown';
// Re-export so callers don't need to import from two places
export type ConnectFailureKind = BrowserConnectKind;
export function formatBrowserConnectError(kind: ConnectFailureKind, detail?: string): BrowserConnectError {
switch (kind) {
case 'daemon-not-running':
return new BrowserConnectError(
'Cannot connect to opencli daemon.' +
(detail ? `\n\n${detail}` : ''),
'The daemon should start automatically. If it doesn\'t, try:\n' +
' node dist/daemon.js\n' +
`Make sure port ${DEFAULT_DAEMON_PORT} is available.`,
'Cannot connect to opencli daemon.' + (detail ? `\n\n${detail}` : ''),
`The daemon should auto-start. If it keeps failing, make sure port ${DEFAULT_DAEMON_PORT} is available.`,
kind,
);
case 'extension-not-connected':
return new BrowserConnectError(
'opencli Browser Bridge extension is not connected.' +
(detail ? `\n\n${detail}` : ''),
'Please install the extension:\n' +
' 1. Download from GitHub Releases\n' +
' 2. Open chrome://extensions/ → Enable Developer Mode\n' +
' 3. Click "Load unpacked" → select the extension folder\n' +
' 4. Make sure Chrome is running',
'Browser Bridge extension is not connected.' + (detail ? `\n\n${detail}` : ''),
'Install the extension from GitHub Releases, then reload.',
kind,
);
case 'command-failed':
return new BrowserConnectError(
`Browser command failed: ${detail ?? 'unknown error'}`,
undefined,
kind,
);
default:
return new BrowserConnectError(
detail ?? 'Failed to connect to browser',
undefined,
kind,
);
}
}
-13
View File
@@ -12,16 +12,3 @@ export { isDaemonRunning } from './daemon-client.js';
export { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
export { generateStealthJs } from './stealth.js';
export type { DomSnapshotOptions } from './dom-snapshot.js';
import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
import { __test__ as cdpTest } from './cdp.js';
import { withTimeoutMs } from '../runtime.js';
export const __test__ = {
extractTabEntries,
diffTabIndexes,
appendLimited,
withTimeoutMs,
selectCDPTarget: cdpTest.selectCDPTarget,
scoreCDPTarget: cdpTest.scoreCDPTarget,
};
+6 -4
View File
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url';
import * as path from 'node:path';
import * as fs from 'node:fs';
import type { IPage } from '../types.js';
import type { IBrowserFactory } from '../runtime.js';
import { Page } from './page.js';
import { isDaemonRunning, isExtensionConnected } from './daemon-client.js';
import { DEFAULT_DAEMON_PORT } from '../constants.js';
@@ -18,7 +19,7 @@ export type BrowserBridgeState = 'idle' | 'connecting' | 'connected' | 'closing'
/**
* Browser factory: manages daemon lifecycle and provides IPage instances.
*/
export class BrowserBridge {
export class BrowserBridge implements IBrowserFactory {
private _state: BrowserBridgeState = 'idle';
private _page: Page | null = null;
private _daemonProc: ChildProcess | null = null;
@@ -95,10 +96,11 @@ export class BrowserBridge {
});
this._daemonProc.unref();
// Wait for daemon to be ready AND extension to connect
// Wait for daemon to be ready AND extension to connect (exponential backoff)
const backoffs = [50, 100, 200, 400, 800, 1500, 3000];
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 300));
for (let i = 0; Date.now() < deadline; i++) {
await new Promise(resolve => setTimeout(resolve, backoffs[Math.min(i, backoffs.length - 1)]));
if (await isExtensionConnected()) return;
}
+65 -3
View File
@@ -22,12 +22,20 @@ import {
typeTextJs,
pressKeyJs,
waitForTextJs,
waitForCaptureJs,
waitForSelectorJs,
scrollJs,
autoScrollJs,
networkRequestsJs,
waitForDomStableJs,
} from './dom-helpers.js';
export function isRetryableSettleError(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err);
return message.includes('Inspected target navigated or closed')
|| (message.includes('-32000') && message.toLowerCase().includes('target'));
}
/**
* Page — implements IPage by talking to the daemon via HTTP.
*/
@@ -36,6 +44,8 @@ export class Page implements IPage {
/** Active tab ID, set after navigate and used in all subsequent commands */
private _tabId: number | undefined;
/** Last navigated URL, tracked in-memory to avoid extra round-trips */
private _lastUrl: string | null = null;
/** Helper: spread workspace into command params */
private _wsOpt(): { workspace: string } {
@@ -55,10 +65,11 @@ export class Page implements IPage {
url,
...this._cmdOpts(),
}) as { tabId?: number };
// Remember the tabId for subsequent exec calls
// Remember the tabId and URL for subsequent calls
if (result?.tabId) {
this._tabId = result.tabId;
}
this._lastUrl = url;
// Inject stealth anti-detection patches (guard flag prevents double-injection).
try {
await sendCommand('exec', {
@@ -72,13 +83,34 @@ export class Page implements IPage {
// settleMs is now a timeout cap (default 1000ms), not a fixed wait.
if (options?.waitUntil !== 'none') {
const maxMs = options?.settleMs ?? 1000;
await sendCommand('exec', {
const settleOpts = {
code: waitForDomStableJs(maxMs, Math.min(500, maxMs)),
...this._cmdOpts(),
});
};
try {
await sendCommand('exec', settleOpts);
} catch (err) {
if (!isRetryableSettleError(err)) throw err;
// SPA client-side redirects can invalidate the CDP target after
// chrome.tabs reports 'complete'. Wait briefly for the new document
// to load, then retry the settle probe once.
try {
await new Promise((r) => setTimeout(r, 200));
await sendCommand('exec', settleOpts);
} catch (retryErr) {
if (!isRetryableSettleError(retryErr)) throw retryErr;
// Retry also failed — give up silently. Settle is best-effort
// after successful navigation; the next real command will surface
// any persistent target error immediately.
}
}
}
}
async getCurrentUrl(): Promise<string | null> {
return this._lastUrl;
}
/** Close the automation window in the extension */
async closeWindow(): Promise<void> {
try {
@@ -183,6 +215,22 @@ export class Page implements IPage {
async wait(options: number | WaitOptions): Promise<void> {
if (typeof options === 'number') {
if (options >= 1) {
// For waits >= 1s, use DOM-stable check: return early when the page
// stops mutating, with the original wait time as the hard cap.
// This turns e.g. `page.wait(5)` from a fixed 5s sleep into
// "wait until DOM is stable, max 5s" — often completing in <1s.
try {
const maxMs = options * 1000;
await sendCommand('exec', {
code: waitForDomStableJs(maxMs, Math.min(500, maxMs)),
...this._cmdOpts(),
});
return;
} catch {
// Fallback: fixed sleep (e.g. if page has no DOM yet)
}
}
await new Promise(resolve => setTimeout(resolve, options * 1000));
return;
}
@@ -190,6 +238,12 @@ export class Page implements IPage {
await new Promise(resolve => setTimeout(resolve, options.time! * 1000));
return;
}
if (options.selector) {
const timeout = (options.timeout ?? 10) * 1000;
const code = waitForSelectorJs(options.selector, timeout);
await sendCommand('exec', { code, ...this._cmdOpts() });
return;
}
if (options.text) {
const timeout = (options.timeout ?? 30) * 1000;
const code = waitForTextJs(options.text, timeout);
@@ -284,6 +338,14 @@ export class Page implements IPage {
const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
return Array.isArray(result) ? result : [];
}
async waitForCapture(timeout: number = 10): Promise<void> {
const maxMs = timeout * 1000;
await sendCommand('exec', {
code: waitForCaptureJs(maxMs),
...this._cmdOpts(),
});
}
}
// (End of file)
+130 -64
View File
@@ -2,69 +2,8 @@ import { afterEach, describe, expect, it } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { parseTsArgsBlock, scanTs, shouldReplaceManifestEntry } from './build-manifest.js';
describe('parseTsArgsBlock', () => {
it('keeps args with nested choices arrays', () => {
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'],
},
]);
});
it('keeps hyphenated arg names from TS adapters', () => {
const args = parseTsArgsBlock(`
{
name: 'tweet-url',
help: 'Single tweet URL to download',
},
{
name: 'download-images',
type: 'boolean',
default: false,
help: 'Download images locally',
},
`);
expect(args).toEqual([
{
name: 'tweet-url',
type: 'str',
default: undefined,
required: false,
positional: undefined,
help: 'Single tweet URL to download',
choices: undefined,
},
{
name: 'download-images',
type: 'boolean',
default: false,
required: false,
positional: undefined,
help: 'Download images locally',
choices: undefined,
},
]);
});
});
import { cli, getRegistry, Strategy } from './registry.js';
import { loadTsManifestEntries, shouldReplaceManifestEntry } from './build-manifest.js';
describe('manifest helper rules', () => {
const tempDirs: string[] = [];
@@ -127,6 +66,133 @@ describe('manifest helper rules', () => {
const file = path.join(dir, 'utils.ts');
fs.writeFileSync(file, `export function helper() { return 'noop'; }`);
expect(scanTs(file, 'demo')).toBeNull();
return expect(loadTsManifestEntries(file, 'demo', async () => ({}))).resolves.toEqual([]);
});
it('builds TS manifest entries from exported runtime commands', async () => {
const site = `manifest-hydrate-${Date.now()}`;
const key = `${site}/dynamic`;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-manifest-'));
tempDirs.push(dir);
const file = path.join(dir, `${site}.ts`);
fs.writeFileSync(file, `export const command = cli({ site: '${site}', name: 'dynamic' });`);
const entries = await loadTsManifestEntries(file, site, async () => ({
command: cli({
site,
name: 'dynamic',
description: 'dynamic command',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{
name: 'model',
required: true,
positional: true,
help: 'Choose a model',
choices: ['auto', 'thinking'],
default: '30',
},
],
domain: 'localhost',
navigateBefore: 'https://example.com/session',
deprecated: 'legacy command',
replacedBy: 'opencli demo new',
}),
}));
expect(entries).toEqual([
{
site,
name: 'dynamic',
description: 'dynamic command',
domain: 'localhost',
strategy: 'public',
browser: false,
args: [
{
name: 'model',
type: 'str',
required: true,
positional: true,
help: 'Choose a model',
choices: ['auto', 'thinking'],
default: '30',
},
],
type: 'ts',
modulePath: `${site}/${site}.js`,
navigateBefore: 'https://example.com/session',
deprecated: 'legacy command',
replacedBy: 'opencli demo new',
},
]);
getRegistry().delete(key);
});
it('falls back to registry delta for side-effect-only cli modules', async () => {
const site = `manifest-side-effect-${Date.now()}`;
const key = `${site}/legacy`;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-manifest-'));
tempDirs.push(dir);
const file = path.join(dir, `${site}.ts`);
fs.writeFileSync(file, `cli({ site: '${site}', name: 'legacy' });`);
const entries = await loadTsManifestEntries(file, site, async () => {
cli({
site,
name: 'legacy',
description: 'legacy command',
deprecated: 'legacy is deprecated',
replacedBy: 'opencli demo new',
});
return {};
});
expect(entries).toEqual([
{
site,
name: 'legacy',
description: 'legacy command',
strategy: 'cookie',
browser: true,
args: [],
type: 'ts',
modulePath: `${site}/${site}.js`,
deprecated: 'legacy is deprecated',
replacedBy: 'opencli demo new',
},
]);
getRegistry().delete(key);
});
it('keeps every command a module exports instead of guessing by site', async () => {
const site = `manifest-multi-${Date.now()}`;
const screenKey = `${site}/screen`;
const statusKey = `${site}/status`;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-manifest-'));
tempDirs.push(dir);
const file = path.join(dir, `${site}.ts`);
fs.writeFileSync(file, `export const screen = cli({ site: '${site}', name: 'screen' });`);
const entries = await loadTsManifestEntries(file, site, async () => ({
screen: cli({
site,
name: 'screen',
description: 'capture screen',
}),
status: cli({
site,
name: 'status',
description: 'show status',
}),
}));
expect(entries.map(entry => entry.name)).toEqual(['screen', 'status']);
getRegistry().delete(screenKey);
getRegistry().delete(statusKey);
});
});
+105 -174
View File
@@ -14,6 +14,7 @@ import * as path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import yaml from 'js-yaml';
import { getErrorMessage } from './errors.js';
import { fullName, getRegistry, type CliCommand } from './registry.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CLIS_DIR = path.resolve(__dirname, 'clis');
@@ -38,6 +39,8 @@ export interface ManifestEntry {
columns?: string[];
pipeline?: Record<string, unknown>[];
timeout?: number;
deprecated?: boolean | string;
replacedBy?: string;
/** 'yaml' or 'ts' — determines how executeCommand loads the handler */
type: 'yaml' | 'ts';
/** Relative path from clis/ dir, e.g. 'bilibili/hot.yaml' or 'bilibili/search.js' */
@@ -46,120 +49,54 @@ export interface ManifestEntry {
navigateBefore?: boolean | string;
}
import type { YamlCliDefinition } from './yaml-schema.js';
import { type YamlCliDefinition, parseYamlArgs } from './yaml-schema.js';
import { isRecord } from './utils.js';
const CLI_MODULE_PATTERN = /\bcli\s*\(/;
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 toManifestArgs(args: CliCommand['args']): ManifestEntry['args'] {
return args.map(arg => ({
name: arg.name,
type: arg.type ?? 'str',
default: arg.default,
required: !!arg.required,
positional: arg.positional || undefined,
help: arg.help ?? '',
choices: arg.choices,
}));
}
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 toTsModulePath(filePath: string, site: string): string {
const baseName = path.basename(filePath, path.extname(filePath));
return `${site}/${baseName}.js`;
}
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;
function isCliCommandValue(value: unknown, site: string): value is CliCommand {
return isRecord(value)
&& typeof value.site === 'string'
&& value.site === site
&& typeof value.name === 'string'
&& Array.isArray(value.args);
}
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*['"`]([^'"`]+)['"`]/);
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: unknown = 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;
if (cursor <= objectStart) break; // safety: prevent infinite loop
}
return args;
function toManifestEntry(cmd: CliCommand, modulePath: string): ManifestEntry {
return {
site: cmd.site,
name: cmd.name,
description: cmd.description ?? '',
domain: cmd.domain,
strategy: (cmd.strategy ?? 'public').toString().toLowerCase(),
browser: cmd.browser ?? true,
args: toManifestArgs(cmd.args),
columns: cmd.columns,
timeout: cmd.timeoutSeconds,
deprecated: cmd.deprecated,
replacedBy: cmd.replacedBy,
type: 'ts',
modulePath,
navigateBefore: cmd.navigateBefore,
};
}
function scanYaml(filePath: string, site: string): ManifestEntry | null {
@@ -173,20 +110,7 @@ function scanYaml(filePath: string, site: string): ManifestEntry | null {
const strategy = strategyStr.toUpperCase();
const browser = cliDef.browser ?? (strategy !== 'PUBLIC');
const args: ManifestEntry['args'] = [];
if (cliDef.args && typeof cliDef.args === 'object') {
for (const [argName, argDef] of Object.entries(cliDef.args)) {
args.push({
name: argName,
type: argDef?.type ?? 'str',
default: argDef?.default,
required: argDef?.required ?? false,
positional: argDef?.positional === true || undefined,
help: argDef?.description ?? argDef?.help ?? '',
choices: argDef?.choices,
});
}
}
const args = parseYamlArgs(cliDef.args);
return {
site: cliDef.site ?? site,
@@ -199,6 +123,8 @@ function scanYaml(filePath: string, site: string): ManifestEntry | null {
columns: cliDef.columns,
pipeline: cliDef.pipeline,
timeout: cliDef.timeout,
deprecated: (cliDef as Record<string, unknown>).deprecated as boolean | string | undefined,
replacedBy: (cliDef as Record<string, unknown>).replacedBy as string | undefined,
type: 'yaml',
navigateBefore: cliDef.navigateBefore,
};
@@ -208,67 +134,49 @@ function scanYaml(filePath: string, site: string): ManifestEntry | null {
}
}
export function scanTs(filePath: string, site: string): ManifestEntry | null {
// TS adapters self-register via cli() at import time.
// We statically parse the source to extract metadata for the manifest stub.
const baseName = path.basename(filePath, path.extname(filePath));
const relativePath = `${site}/${baseName}.js`;
export async function loadTsManifestEntries(
filePath: string,
site: string,
importer: (moduleHref: string) => Promise<unknown> = moduleHref => import(moduleHref),
): Promise<ManifestEntry[]> {
try {
const src = fs.readFileSync(filePath, 'utf-8');
// Helper/test modules should not appear as CLI commands in the manifest.
if (!/\bcli\s*\(/.test(src)) return null;
if (!CLI_MODULE_PATTERN.test(src)) return [];
const entry: ManifestEntry = {
site,
name: baseName,
description: '',
strategy: 'cookie',
browser: true,
args: [],
type: 'ts',
modulePath: relativePath,
};
const modulePath = toTsModulePath(filePath, site);
const registry = getRegistry();
const before = new Map(registry.entries());
const mod = await importer(pathToFileURL(filePath).href);
// Extract description
const descMatch = src.match(/description\s*:\s*['"`]([^'"`]*)['"`]/);
if (descMatch) entry.description = descMatch[1];
const exportedCommands = Object.values(isRecord(mod) ? mod : {})
.filter(value => isCliCommandValue(value, site));
// Extract domain
const domainMatch = src.match(/domain\s*:\s*['"`]([^'"`]*)['"`]/);
if (domainMatch) entry.domain = domainMatch[1];
const runtimeCommands = exportedCommands.length > 0
? exportedCommands
: [...registry.entries()]
.filter(([key, cmd]) => {
if (cmd.site !== site) return false;
const previous = before.get(key);
return !previous || previous !== cmd;
})
.map(([, cmd]) => cmd);
// Extract strategy
const stratMatch = src.match(/strategy\s*:\s*Strategy\.(\w+)/);
if (stratMatch) entry.strategy = stratMatch[1].toLowerCase();
// Extract browser: false (some adapters bypass browser entirely)
const browserMatch = src.match(/browser\s*:\s*(true|false)/);
if (browserMatch) entry.browser = browserMatch[1] === 'true';
else entry.browser = entry.strategy !== 'public';
// Extract columns
const colMatch = src.match(/columns\s*:\s*\[([^\]]*)\]/);
if (colMatch) {
entry.columns = colMatch[1].split(',').map(s => s.trim().replace(/^['"`]|['"`]$/g, '')).filter(Boolean);
}
// Extract args array items: { name: '...', ... }
const argsBlock = extractTsArgsBlock(src);
if (argsBlock) {
entry.args = parseTsArgsBlock(argsBlock);
}
// Extract navigateBefore: false
const navMatch = src.match(/navigateBefore\s*:\s*(true|false)/);
if (navMatch) entry.navigateBefore = navMatch[1] === 'true' ? true : false;
return entry;
const seen = new Set<string>();
return runtimeCommands
.filter((cmd) => {
const key = fullName(cmd);
if (seen.has(key)) return false;
seen.add(key);
return true;
})
.sort((a, b) => a.name.localeCompare(b.name))
.map(cmd => toManifestEntry(cmd, modulePath));
} catch (err) {
// If parsing fails, log a warning (matching scanYaml behaviour) and skip the entry.
process.stderr.write(`Warning: failed to scan ${filePath}: ${getErrorMessage(err)}\n`);
return null;
return [];
}
}
@@ -281,7 +189,7 @@ export function shouldReplaceManifestEntry(current: ManifestEntry, next: Manifes
return current.type === 'yaml' && next.type === 'ts';
}
export function buildManifest(): ManifestEntry[] {
export async function buildManifest(): Promise<ManifestEntry[]> {
const manifest = new Map<string, ManifestEntry>();
if (fs.existsSync(CLIS_DIR)) {
@@ -306,8 +214,8 @@ export function buildManifest(): ManifestEntry[] {
(file.endsWith('.ts') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts') && file !== 'index.ts') ||
(file.endsWith('.js') && !file.endsWith('.d.js') && !file.endsWith('.test.js') && file !== 'index.js')
) {
const entry = scanTs(filePath, site);
if (entry) {
const entries = await loadTsManifestEntries(filePath, site);
for (const entry of entries) {
const key = `${entry.site}/${entry.name}`;
const existing = manifest.get(key);
if (!existing || shouldReplaceManifestEntry(existing, entry)) {
@@ -325,17 +233,40 @@ export function buildManifest(): ManifestEntry[] {
return [...manifest.values()];
}
function main(): void {
const manifest = buildManifest();
async function main(): Promise<void> {
const manifest = await 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}`);
// Restore executable permissions on bin entries.
// tsc does not preserve the +x bit, so after a clean rebuild the CLI
// entry-point loses its executable permission, causing "Permission denied".
// See: https://github.com/jackwener/opencli/issues/446
if (process.platform !== 'win32') {
const pkgPath = path.resolve(__dirname, '..', 'package.json');
try {
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
const bins: Record<string, string> = typeof pkg.bin === 'string'
? { [pkg.name ?? 'cli']: pkg.bin }
: pkg.bin ?? {};
for (const binPath of Object.values(bins)) {
const abs = path.resolve(__dirname, '..', binPath);
if (fs.existsSync(abs)) {
fs.chmodSync(abs, 0o755);
console.log(`✅ Restored executable permission: ${binPath}`);
}
}
} catch {
// Best-effort; never break the build for a permission fix.
}
}
}
const entrypoint = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null;
if (entrypoint === import.meta.url) {
main();
void main();
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { Strategy, type CliCommand } from './registry.js';
const BROWSER_ONLY_STEPS = new Set([
/** Pipeline steps that require a live browser session. */
export const BROWSER_ONLY_STEPS = new Set([
'navigate',
'click',
'type',
+3 -2
View File
@@ -12,6 +12,7 @@
import { Strategy } from './registry.js';
import type { IPage } from './types.js';
import { getErrorMessage } from './errors.js';
/** Strategy cascade order (simplest → most complex) */
const CASCADE_ORDER: Strategy[] = [
@@ -128,9 +129,9 @@ export async function probeEndpoint(
result.error = `Strategy ${strategy} requires site-specific implementation`;
break;
}
} catch (err: any) {
} catch (err) {
result.success = false;
result.error = err.message ?? String(err);
result.error = getErrorMessage(err);
}
return result;
+137 -28
View File
@@ -15,6 +15,7 @@ import { PKG_VERSION } from './version.js';
import { printCompletionScript } from './completion.js';
import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled } from './external.js';
import { registerAllCommands } from './commanderAdapter.js';
import { EXIT_CODES, getErrorMessage } from './errors.js';
export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
const program = new Command();
@@ -75,9 +76,10 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
for (const [site, cmds] of sites) {
console.log(chalk.bold.cyan(` ${site}`));
for (const cmd of cmds) {
const tag = strategyLabel(cmd) === 'public'
const label = strategyLabel(cmd);
const tag = label === 'public'
? chalk.green('[public]')
: chalk.yellow(`[${strategyLabel(cmd)}]`);
: chalk.yellow(`[${label}]`);
console.log(` ${cmd.name} ${tag}${cmd.description ? chalk.dim(`${cmd.description}`) : ''}`);
}
console.log();
@@ -118,7 +120,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
const { verifyClis, renderVerifyReport } = await import('./verify.js');
const r = await verifyClis({ builtinClis: BUILTIN_CLIS, userClis: USER_CLIS, target, smoke: opts.smoke });
console.log(renderVerifyReport(r));
process.exitCode = r.ok ? 0 : 1;
process.exitCode = r.ok ? EXIT_CODES.SUCCESS : EXIT_CODES.GENERIC_ERROR;
});
// ── Built-in: explore / synthesize / generate / cascade ───────────────────
@@ -178,7 +180,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
workspace,
});
console.log(renderGenerateSummary(r));
process.exitCode = r.ok ? 0 : 1;
process.exitCode = r.ok ? EXIT_CODES.SUCCESS : EXIT_CODES.GENERIC_ERROR;
});
// ── Built-in: record ─────────────────────────────────────────────────────
@@ -202,7 +204,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
timeoutMs: parseInt(opts.timeout, 10),
});
console.log(renderRecordSummary(result));
process.exitCode = result.candidateCount > 0 ? 0 : 1;
process.exitCode = result.candidateCount > 0 ? EXIT_CODES.SUCCESS : EXIT_CODES.EMPTY_RESULT;
});
program
@@ -251,18 +253,26 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
pluginCmd
.command('install')
.description('Install a plugin from GitHub')
.description('Install a plugin from a git repository')
.argument('<source>', 'Plugin source (e.g. github:user/repo)')
.action(async (source: string) => {
const { installPlugin } = await import('./plugin.js');
const { discoverPlugins } = await import('./discovery.js');
try {
const name = installPlugin(source);
const result = installPlugin(source);
await discoverPlugins();
console.log(chalk.green(`✅ Plugin "${name}" installed successfully. Commands are ready to use.`));
} catch (err: any) {
console.error(chalk.red(`Error: ${err.message}`));
process.exitCode = 1;
if (Array.isArray(result)) {
if (result.length === 0) {
console.log(chalk.yellow('No plugins were installed (all skipped or incompatible).'));
} else {
console.log(chalk.green(`\u2705 Installed ${result.length} plugin(s) from monorepo: ${result.join(', ')}`));
}
} else {
console.log(chalk.green(`\u2705 Plugin "${result}" installed successfully. Commands are ready to use.`));
}
} catch (err) {
console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
process.exitCode = EXIT_CODES.GENERIC_ERROR;
}
});
@@ -275,26 +285,70 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
try {
uninstallPlugin(name);
console.log(chalk.green(`✅ Plugin "${name}" uninstalled.`));
} catch (err: any) {
console.error(chalk.red(`Error: ${err.message}`));
process.exitCode = 1;
} catch (err) {
console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
process.exitCode = EXIT_CODES.GENERIC_ERROR;
}
});
pluginCmd
.command('update')
.description('Update a plugin to the latest version')
.argument('<name>', 'Plugin name')
.action(async (name: string) => {
const { updatePlugin } = await import('./plugin.js');
.description('Update a plugin (or all plugins) to the latest version')
.argument('[name]', 'Plugin name (required unless --all is passed)')
.option('--all', 'Update all installed plugins')
.action(async (name: string | undefined, opts: { all?: boolean }) => {
if (!name && !opts.all) {
console.error(chalk.red('Error: Please specify a plugin name or use the --all flag.'));
process.exitCode = EXIT_CODES.USAGE_ERROR;
return;
}
if (name && opts.all) {
console.error(chalk.red('Error: Cannot specify both a plugin name and --all.'));
process.exitCode = EXIT_CODES.USAGE_ERROR;
return;
}
const { updatePlugin, updateAllPlugins } = await import('./plugin.js');
const { discoverPlugins } = await import('./discovery.js');
if (opts.all) {
const results = updateAllPlugins();
if (results.length > 0) {
await discoverPlugins();
}
let hasErrors = false;
console.log(chalk.bold(' Update Results:'));
for (const result of results) {
if (result.success) {
console.log(` ${chalk.green('✓')} ${result.name}`);
continue;
}
hasErrors = true;
console.log(` ${chalk.red('✗')} ${result.name}${chalk.dim(result.error)}`);
}
if (results.length === 0) {
console.log(chalk.dim(' No plugins installed.'));
return;
}
console.log();
if (hasErrors) {
console.error(chalk.red('Completed with some errors.'));
process.exitCode = EXIT_CODES.GENERIC_ERROR;
} else {
console.log(chalk.green('✅ All plugins updated successfully.'));
}
return;
}
try {
updatePlugin(name);
updatePlugin(name!);
await discoverPlugins();
console.log(chalk.green(`✅ Plugin "${name}" updated successfully.`));
} catch (err: any) {
console.error(chalk.red(`Error: ${err.message}`));
process.exitCode = 1;
} catch (err) {
console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
process.exitCode = EXIT_CODES.GENERIC_ERROR;
}
});
@@ -323,16 +377,71 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
console.log();
console.log(chalk.bold(' Installed plugins'));
console.log();
// Group by monorepo
const standalone = plugins.filter((p) => !p.monorepoName);
const monoGroups = new Map<string, typeof plugins>();
for (const p of plugins) {
if (!p.monorepoName) continue;
const g = monoGroups.get(p.monorepoName) ?? [];
g.push(p);
monoGroups.set(p.monorepoName, g);
}
for (const p of standalone) {
const version = p.version ? chalk.green(` @${p.version}`) : '';
const desc = p.description ? chalk.dim(`${p.description}`) : '';
const cmds = p.commands.length > 0 ? chalk.dim(` (${p.commands.join(', ')})`) : '';
const src = p.source ? chalk.dim(`${p.source}`) : '';
console.log(` ${chalk.cyan(p.name)}${cmds}${src}`);
console.log(` ${chalk.cyan(p.name)}${version}${desc}${cmds}${src}`);
}
for (const [mono, group] of monoGroups) {
console.log();
console.log(chalk.bold.magenta(` 📦 ${mono}`) + chalk.dim(' (monorepo)'));
for (const p of group) {
const version = p.version ? chalk.green(` @${p.version}`) : '';
const desc = p.description ? chalk.dim(`${p.description}`) : '';
const cmds = p.commands.length > 0 ? chalk.dim(` (${p.commands.join(', ')})`) : '';
console.log(` ${chalk.cyan(p.name)}${version}${desc}${cmds}`);
}
}
console.log();
console.log(chalk.dim(` ${plugins.length} plugin(s) installed`));
console.log();
});
pluginCmd
.command('create')
.description('Create a new plugin scaffold')
.argument('<name>', 'Plugin name (lowercase, hyphens allowed)')
.option('-d, --dir <path>', 'Output directory (default: ./<name>)')
.option('--description <text>', 'Plugin description')
.action(async (name: string, opts: { dir?: string; description?: string }) => {
const { createPluginScaffold } = await import('./plugin-scaffold.js');
try {
const result = createPluginScaffold(name, {
dir: opts.dir,
description: opts.description,
});
console.log(chalk.green(`✅ Plugin scaffold created at ${result.dir}`));
console.log();
console.log(chalk.bold(' Files created:'));
for (const f of result.files) {
console.log(` ${chalk.cyan(f)}`);
}
console.log();
console.log(chalk.dim(' Next steps:'));
console.log(chalk.dim(` cd ${result.dir}`));
console.log(chalk.dim(` opencli plugin install file://${result.dir}`));
console.log(chalk.dim(` opencli ${name} hello`));
} catch (err) {
console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
process.exitCode = EXIT_CODES.GENERIC_ERROR;
}
});
// ── External CLIs ─────────────────────────────────────────────────────────
const externalClis = loadExternalClis();
@@ -345,7 +454,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
const ext = externalClis.find(e => e.name === name);
if (!ext) {
console.error(chalk.red(`External CLI '${name}' not found in registry.`));
process.exitCode = 1;
process.exitCode = EXIT_CODES.USAGE_ERROR;
return;
}
installExternalCli(ext);
@@ -369,9 +478,9 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
})();
try {
executeExternalCli(name, args, externalClis);
} catch (err: any) {
console.error(chalk.red(`Error: ${err.message}`));
process.exitCode = 1;
} catch (err) {
console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
process.exitCode = EXIT_CODES.GENERIC_ERROR;
}
}
@@ -416,7 +525,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
console.error(chalk.dim(` Tip: '${binary}' exists on your PATH. Use 'opencli register ${binary}' to add it as an external CLI.`));
}
program.outputHelp();
process.exitCode = 1;
process.exitCode = EXIT_CODES.USAGE_ERROR;
});
program.parse();
+69
View File
@@ -0,0 +1,69 @@
/**
* 36kr article detail — INTERCEPT strategy.
*
* Fetches the full content of a 36kr article given its ID or URL.
*/
import { cli, Strategy } from '../../registry.js';
import { CliError } from '../../errors.js';
import type { IPage } from '../../types.js';
/** Extract article ID from a full URL or a bare numeric ID string */
function parseArticleId(input: string): string {
const m = input.match(/\/p\/(\d+)/);
return m ? m[1] : input.replace(/\D/g, '');
}
cli({
site: '36kr',
name: 'article',
description: '获取36氪文章正文内容',
domain: 'www.36kr.com',
strategy: Strategy.INTERCEPT,
args: [
{ name: 'id', positional: true, required: true, help: 'Article ID or full 36kr article URL' },
],
columns: ['field', 'value'],
func: async (page: IPage, args) => {
const articleId = parseArticleId(String(args.id ?? ''));
if (!articleId) {
throw new CliError('INVALID_ARGUMENT', 'Invalid article ID or URL');
}
await page.installInterceptor('36kr.com/api');
await page.goto(`https://www.36kr.com/p/${articleId}`);
await page.wait(5);
const data: any = await page.evaluate(`
(() => {
// Title: 36kr uses class "article-title" on h1
const title = document.querySelector('.article-title, h1')?.textContent?.trim() || '';
// Author: second .author-name (first is empty nav link, second has real name)
const authorEls = document.querySelectorAll('.author-name');
const author = Array.from(authorEls).map(el => el.textContent?.trim()).filter(Boolean)[0] || '';
// Date: 36kr uses class "title-icon-item item-time" for the publish date
const dateRaw = document.querySelector('.item-time')?.textContent?.trim() || '';
const date = dateRaw.replace(/^[·\s]+/, '').trim();
// Article body paragraphs
const bodyEls = document.querySelectorAll('[class*="article-content"] p, [class*="rich-text"] p, .article p');
const body = Array.from(bodyEls)
.map(el => el.textContent?.trim())
.filter(t => t && t.length > 10)
.join(' ')
.slice(0, 800);
return { title, author, date, body };
})()
`);
if (!data?.title) {
throw new CliError('NOT_FOUND', 'Article not found or failed to load', 'Check the article ID');
}
return [
{ field: 'title', value: data.title },
{ field: 'author', value: data.author || '-' },
{ field: 'date', value: data.date || '-' },
{ field: 'url', value: `https://36kr.com/p/${articleId}` },
{ field: 'body', value: data.body || '-' },
];
},
});
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { buildHotListUrl, getShanghaiDate } from './hot.js';
describe('36kr/hot date routing', () => {
it('formats dates in Asia/Shanghai instead of UTC', () => {
const date = new Date('2026-03-25T18:30:00.000Z');
expect(getShanghaiDate(date)).toBe('2026-03-26');
});
it('builds dated hot-list routes with Shanghai-local date', () => {
const date = new Date('2026-03-25T18:30:00.000Z');
expect(buildHotListUrl('renqi', date)).toBe('https://www.36kr.com/hot-list/renqi/2026-03-26/1');
});
it('keeps catalog on the static route', () => {
expect(buildHotListUrl('catalog')).toBe('https://www.36kr.com/hot-list/catalog');
});
});
+100
View File
@@ -0,0 +1,100 @@
/**
* 36kr hot-list — INTERCEPT strategy.
*
* Navigates to the 36kr hot-list page and scrapes rendered article links.
* Supports category types: renqi (人气), zonghe (综合), shoucang (收藏), catalog (综合热门).
*/
import { cli, Strategy } from '../../registry.js';
import { CliError } from '../../errors.js';
import type { IPage } from '../../types.js';
const TYPE_MAP: Record<string, string> = {
renqi: '人气榜',
zonghe: '综合榜',
shoucang: '收藏榜',
catalog: '热门资讯',
};
function getShanghaiDate(date = new Date()): string {
// Shanghai stays on UTC+8 year-round, so a fixed offset is sufficient here
// and avoids the slow Intl timezone path that timed out on Windows CI.
return new Date(date.getTime() + 8 * 60 * 60 * 1000).toISOString().slice(0, 10);
}
function buildHotListUrl(listType: string, date = new Date()): string {
if (listType === 'catalog') {
return 'https://www.36kr.com/hot-list/catalog';
}
return `https://www.36kr.com/hot-list/${listType}/${getShanghaiDate(date)}/1`;
}
cli({
site: '36kr',
name: 'hot',
description: '36氪热榜 — trending articles (renqi/zonghe/shoucang/catalog)',
domain: 'www.36kr.com',
strategy: Strategy.INTERCEPT,
args: [
{ name: 'limit', type: 'int', default: 20, help: 'Number of items (max 50)' },
{
name: 'type',
type: 'string',
default: 'catalog',
help: 'List type: renqi (人气), zonghe (综合), shoucang (收藏), catalog (热门资讯)',
},
],
columns: ['rank', 'title', 'url'],
func: async (page: IPage, args) => {
const count = Math.min(Number(args.limit) || 20, 50);
const listType = String(args.type ?? 'catalog');
if (!TYPE_MAP[listType]) {
throw new CliError(
'INVALID_ARGUMENT',
`Unknown type "${listType}". Valid types: ${Object.keys(TYPE_MAP).join(', ')}`,
);
}
const url = buildHotListUrl(listType);
await page.installInterceptor('36kr.com/api');
await page.goto(url);
await page.waitForCapture(6);
// Scrape rendered article links from DOM (deduplicated)
const domItems: any = await page.evaluate(`
(() => {
const seen = new Set();
const results = [];
const links = document.querySelectorAll('a[href*="/p/"]');
for (const el of links) {
const href = el.getAttribute('href') || '';
const title = el.textContent?.trim() || '';
if (!title || title.length < 5 || seen.has(href) || seen.has(title)) continue;
seen.add(href);
seen.add(title);
results.push({ title, url: href.startsWith('http') ? href : 'https://36kr.com' + href });
}
return results;
})()
`);
const items = Array.isArray(domItems) ? (domItems as any[]) : [];
if (items.length === 0) {
throw new CliError(
'NO_DATA',
'Could not retrieve 36kr hot list',
'36kr may have changed its DOM structure',
);
}
return items.slice(0, count).map((item: any, i: number) => ({
rank: i + 1,
title: item.title,
url: item.url,
}));
},
});
export { buildHotListUrl, getShanghaiDate };
+90
View File
@@ -0,0 +1,90 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
const SAMPLE_RSS = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel><title>36氪</title>
<item>
<title>红杉中国领投AI公司「示例」,金额近2亿元</title>
<link><![CDATA[https://36kr.com/p/1111111111111111?f=rss]]></link>
<pubDate>2026-03-26 10:00:00 +0800</pubDate>
</item>
<item>
<title>马斯克旗下xAI估值突破1000亿美元</title>
<link><![CDATA[https://36kr.com/p/2222222222222222?f=rss]]></link>
<pubDate>2026-03-26 09:00:00 +0800</pubDate>
</item>
<item>
<title>OpenAI发布GPT-5,多模态能力大幅提升</title>
<link><![CDATA[https://36kr.com/p/3333333333333333?f=rss]]></link>
<pubDate>2026-03-25 20:00:00 +0800</pubDate>
</item>
</channel></rss>`;
afterEach(() => {
vi.restoreAllMocks();
});
describe('36kr/news RSS parsing', () => {
it('parses RSS feed into ranked news items', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
text: async () => SAMPLE_RSS,
} as Response);
// Direct RSS parse test using the same regex logic as news.ts
const xml = SAMPLE_RSS;
const items: { rank: number; title: string; date: string; url: string }[] = [];
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
let match;
while ((match = itemRegex.exec(xml)) && items.length < 10) {
const block = match[1];
const title = block.match(/<title>([\s\S]*?)<\/title>/)?.[1]?.trim() ?? '';
const url =
block.match(/<link><!\[CDATA\[(.*?)\]\]>/)?.[1] ??
block.match(/<link>(.*?)<\/link>/)?.[1] ??
'';
const pubDate = block.match(/<pubDate>(.*?)<\/pubDate>/)?.[1]?.trim() ?? '';
const date = pubDate.slice(0, 10);
if (title) items.push({ rank: items.length + 1, title, date, url: url.trim() });
}
expect(items).toHaveLength(3);
expect(items[0].rank).toBe(1);
expect(items[0].title).toBe('红杉中国领投AI公司「示例」,金额近2亿元');
expect(items[0].date).toBe('2026-03-26');
expect(items[0].url).toBe('https://36kr.com/p/1111111111111111?f=rss');
});
it('respects limit — returns at most N items', async () => {
const xml = SAMPLE_RSS;
const limit = 2;
const items: { rank: number; title: string; date: string; url: string }[] = [];
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
let match;
while ((match = itemRegex.exec(xml)) && items.length < limit) {
const block = match[1];
const title = block.match(/<title>([\s\S]*?)<\/title>/)?.[1]?.trim() ?? '';
const url = block.match(/<link><!\[CDATA\[(.*?)\]\]>/)?.[1] ?? '';
const pubDate = block.match(/<pubDate>(.*?)<\/pubDate>/)?.[1]?.trim() ?? '';
const date = pubDate.slice(0, 10);
if (title) items.push({ rank: items.length + 1, title, date, url: url.trim() });
}
expect(items).toHaveLength(2);
});
it('skips items with empty title', async () => {
const xml = `<rss><channel>
<item><title></title><link>https://36kr.com/p/0</link><pubDate>2026-01-01</pubDate></item>
<item><title>有标题的文章</title><link>https://36kr.com/p/1</link><pubDate>2026-01-01</pubDate></item>
</channel></rss>`;
const items: any[] = [];
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
let match;
while ((match = itemRegex.exec(xml))) {
const block = match[1];
const title = block.match(/<title>([\s\S]*?)<\/title>/)?.[1]?.trim() ?? '';
if (title) items.push({ title });
}
expect(items).toHaveLength(1);
expect(items[0].title).toBe('有标题的文章');
});
});
+54
View File
@@ -0,0 +1,54 @@
/**
* 36kr latest news — public RSS feed, no browser needed.
*/
import { cli, Strategy } from '../../registry.js';
cli({
site: '36kr',
name: 'news',
description: 'Latest tech/startup news from 36kr (36氪)',
domain: 'www.36kr.com',
strategy: Strategy.PUBLIC,
args: [
{ name: 'limit', type: 'int', default: 20, help: 'Number of articles (max 50)' },
],
columns: ['rank', 'title', 'summary', 'date', 'url'],
func: async (_page, kwargs) => {
const count = Math.min(kwargs.limit || 20, 50);
const resp = await fetch('https://www.36kr.com/feed', {
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; opencli/1.0)' },
});
if (!resp.ok) return [];
const xml = await resp.text();
const items: { rank: number; title: string; summary: string; date: string; url: string }[] = [];
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
let match;
while ((match = itemRegex.exec(xml)) && items.length < count) {
const block = match[1];
const title = block.match(/<title>([\s\S]*?)<\/title>/)?.[1]?.trim() ?? '';
const url =
block.match(/<link><!\[CDATA\[(.*?)\]\]>/)?.[1] ??
block.match(/<link>(.*?)<\/link>/)?.[1] ??
'';
const pubDate = block.match(/<pubDate>(.*?)<\/pubDate>/)?.[1]?.trim() ?? '';
const date = pubDate.slice(0, 10);
// Extract plain-text summary from HTML description (first ~120 chars)
const rawDesc = block.match(/<description><!\[CDATA\[([\s\S]*?)\]\]>/)?.[1] ?? '';
const summary = rawDesc
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 120);
if (title) {
items.push({ rank: items.length + 1, title, summary, date, url: url.trim() });
}
}
return items;
},
});
+78
View File
@@ -0,0 +1,78 @@
/**
* 36kr article search — INTERCEPT strategy.
*
* Navigates to the 36kr search results page and scrapes rendered articles.
*/
import { cli, Strategy } from '../../registry.js';
import { CliError } from '../../errors.js';
import type { IPage } from '../../types.js';
cli({
site: '36kr',
name: 'search',
description: '搜索36氪文章',
domain: 'www.36kr.com',
strategy: Strategy.INTERCEPT,
args: [
{ name: 'query', positional: true, required: true, help: 'Search keyword (e.g. "AI", "OpenAI")' },
{ name: 'limit', type: 'int', default: 20, help: 'Number of results (max 50)' },
],
columns: ['rank', 'title', 'date', 'url'],
func: async (page: IPage, args) => {
const count = Math.min(Number(args.limit) || 20, 50);
const query = encodeURIComponent(String(args.query ?? ''));
await page.installInterceptor('36kr.com/api');
await page.goto(`https://www.36kr.com/search/articles/${query}`);
await page.waitForCapture(6);
const domItems: any = await page.evaluate(`
(() => {
const seen = new Set();
const results = [];
// article-item-title contains the clickable title link
const titleEls = document.querySelectorAll('.article-item-title a[href*="/p/"], .article-item-title[href*="/p/"]');
for (const el of titleEls) {
const href = el.getAttribute('href') || '';
const title = el.textContent?.trim() || '';
if (!title || seen.has(href)) continue;
seen.add(href);
// Look for date near the article item
const item = el.closest('[class*="article-item"]') || el.parentElement;
const dateEl = item?.querySelector('[class*="time"], [class*="date"], time');
const date = dateEl?.textContent?.trim() || '';
results.push({
title,
url: href.startsWith('http') ? href : 'https://36kr.com' + href,
date,
});
}
// Fallback: generic /p/ links with meaningful text
if (results.length === 0) {
const links = document.querySelectorAll('a[href*="/p/"]');
for (const el of links) {
const href = el.getAttribute('href') || '';
const title = el.textContent?.trim() || '';
if (!title || title.length < 8 || seen.has(href) || seen.has(title)) continue;
seen.add(href);
seen.add(title);
results.push({ title, url: href.startsWith('http') ? href : 'https://36kr.com' + href, date: '' });
}
}
return results;
})()
`);
const items = Array.isArray(domItems) ? (domItems as any[]) : [];
if (items.length === 0) {
throw new CliError('NO_DATA', 'No results found', `Try a different query or check your keyword`);
}
return items.slice(0, count).map((item: any, i: number) => ({
rank: i + 1,
title: item.title,
date: item.date,
url: item.url,
}));
},
});
+11
View File
@@ -0,0 +1,11 @@
/**
* Shared utilities for CLI adapters.
*/
/**
* Clamp a numeric value to [min, max].
* Matches the signature of lodash.clamp and Rust's clamp.
*/
export function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(value, max));
}
+7 -3
View File
@@ -7,13 +7,15 @@
import * as fs from 'node:fs';
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
import type { CliOptions } from '../../registry.js';
/**
* Factory: capture DOM HTML + accessibility snapshot.
*/
export function makeScreenshotCommand(site: string, displayName?: string) {
export function makeScreenshotCommand(site: string, displayName?: string, extra: Partial<CliOptions> = {}) {
const label = displayName ?? site;
return cli({
...extra,
site,
name: 'screenshot',
description: `Capture a snapshot of the current ${label} window (DOM + Accessibility tree)`,
@@ -47,9 +49,10 @@ export function makeScreenshotCommand(site: string, displayName?: string) {
/**
* Factory: check CDP connection status.
*/
export function makeStatusCommand(site: string, displayName?: string) {
export function makeStatusCommand(site: string, displayName?: string, extra: Partial<CliOptions> = {}) {
const label = displayName ?? site;
return cli({
...extra,
site,
name: 'status',
description: `Check active CDP connection to ${label}`,
@@ -68,9 +71,10 @@ export function makeStatusCommand(site: string, displayName?: string) {
/**
* Factory: start a new session via Cmd/Ctrl+N.
*/
export function makeNewCommand(site: string, displayName?: string) {
export function makeNewCommand(site: string, displayName?: string, extra: Partial<CliOptions> = {}) {
const label = displayName ?? site;
return cli({
...extra,
site,
name: 'new',
description: `Start a new ${label} session`,
+7 -4
View File
@@ -13,6 +13,7 @@
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { CDPBridge } from '../../browser/cdp.js';
import type { IPage } from '../../types.js';
import { EXIT_CODES, getErrorMessage } from '../../errors.js';
// ─── Types ───────────────────────────────────────────────────────────
@@ -461,15 +462,17 @@ export async function startServe(opts: { port?: number } = {}): Promise<void> {
cdp = new CDPBridge();
try {
page = await cdp.connect({ timeout: 15_000 });
} catch (err: any) {
} catch (err: unknown) {
cdp = null;
const isRefused = err?.cause?.code === 'ECONNREFUSED' || err?.message?.includes('ECONNREFUSED');
const errMsg = getErrorMessage(err);
const cause = err instanceof Error ? (err.cause as Record<string, unknown> | undefined) : undefined;
const isRefused = cause?.code === 'ECONNREFUSED' || errMsg.includes('ECONNREFUSED');
throw new Error(
isRefused
? `Cannot connect to Antigravity at ${endpoint}.\n` +
' 1. Make sure Antigravity is running\n' +
' 2. Launch with: --remote-debugging-port=9224'
: `CDP connection failed: ${err.message}`
: `CDP connection failed: ${errMsg}`
);
}
@@ -591,7 +594,7 @@ export async function startServe(opts: { port?: number } = {}): Promise<void> {
console.error('\n[serve] Shutting down...');
cdp?.close().catch(() => {});
server.close();
process.exit(0);
process.exit(EXIT_CODES.SUCCESS);
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
+30 -2
View File
@@ -38,13 +38,14 @@ describe('apple-podcasts search command', () => {
'https://itunes.apple.com/search?term=machine%20learning&media=podcast&limit=5',
);
expect(result).toEqual([
{
expect.objectContaining({
id: 42,
title: 'Machine Learning Guide',
author: 'OpenCLI',
episodes: 12,
genre: 'Technology',
},
url: '',
}),
]);
});
});
@@ -54,6 +55,30 @@ describe('apple-podcasts top command', () => {
vi.restoreAllMocks();
});
it('adds a timeout signal to chart fetches', async () => {
const cmd = getRegistry().get('apple-podcasts/top');
expect(cmd?.func).toBeTypeOf('function');
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({
feed: {
results: [
{ id: '100', name: 'Top Show', artistName: 'Host A' },
],
},
}),
});
vi.stubGlobal('fetch', fetchMock);
await cmd!.func!(null as any, { country: 'US', limit: 1 });
const [, options] = fetchMock.mock.calls[0] ?? [];
expect(options).toBeDefined();
expect(options.signal).toBeDefined();
expect(options.signal).toHaveProperty('aborted', false);
});
it('uses the canonical Apple charts host and maps ranked results', async () => {
const cmd = getRegistry().get('apple-podcasts/top');
expect(cmd?.func).toBeTypeOf('function');
@@ -75,6 +100,9 @@ describe('apple-podcasts top command', () => {
expect(fetchMock).toHaveBeenCalledWith(
'https://rss.marketingtools.apple.com/api/v2/us/podcasts/top/2/podcasts.json',
expect.objectContaining({
signal: expect.any(Object),
}),
);
expect(result).toEqual([
{ rank: 1, title: 'Top Show', author: 'Host A', id: '100' },
+2 -1
View File
@@ -12,7 +12,7 @@ cli({
{ name: 'query', positional: true, required: true, help: 'Search keyword' },
{ name: 'limit', type: 'int', default: 10, help: 'Max results' },
],
columns: ['id', 'title', 'author', 'episodes', 'genre'],
columns: ['id', 'title', 'author', 'episodes', 'genre', 'url'],
func: async (_page, args) => {
const term = encodeURIComponent(args.query);
const limit = Math.max(1, Math.min(Number(args.limit), 25));
@@ -24,6 +24,7 @@ cli({
author: p.artistName,
episodes: p.trackCount ?? '-',
genre: p.primaryGenreName ?? '-',
url: p.collectionViewUrl || '',
}));
},
});
+4 -1
View File
@@ -3,6 +3,7 @@ import { CliError } from '../../errors.js';
// Apple Marketing Tools RSS API — public, no key required
const CHARTS_URL = 'https://rss.marketingtools.apple.com/api/v2';
const CHARTS_TIMEOUT_MS = 15_000;
cli({
site: 'apple-podcasts',
@@ -21,7 +22,9 @@ cli({
const url = `${CHARTS_URL}/${country}/podcasts/top/${limit}/podcasts.json`;
let resp: Response;
try {
resp = await fetch(url);
resp = await fetch(url, {
signal: AbortSignal.timeout(CHARTS_TIMEOUT_MS),
});
} catch (error: any) {
const reason = error?.cause?.code ?? error?.message ?? 'unknown network error';
throw new CliError(
+2 -2
View File
@@ -12,13 +12,13 @@ cli({
{ name: 'query', positional: true, required: true, help: 'Search keyword (e.g. "attention is all you need")' },
{ name: 'limit', type: 'int', default: 10, help: 'Max results (max 25)' },
],
columns: ['id', 'title', 'authors', 'published'],
columns: ['id', 'title', 'authors', 'published', 'url'],
func: async (_page, args) => {
const limit = Math.max(1, Math.min(Number(args.limit), 25));
const query = encodeURIComponent(`all:${args.query}`);
const xml = await arxivFetch(`search_query=${query}&max_results=${limit}&sortBy=relevance`);
const entries = parseEntries(xml);
if (!entries.length) throw new CliError('NOT_FOUND', 'No papers found', 'Try a different keyword');
return entries.map(e => ({ id: e.id, title: e.title, authors: e.authors, published: e.published }));
return entries.map(e => ({ id: e.id, title: e.title, authors: e.authors, published: e.published, url: e.url }));
},
});
-1
View File
@@ -1,6 +1,5 @@
/**
* BBC News headlines — public RSS feed, no browser needed.
* Source: bb-sites/bbc/news.js
*/
import { cli, Strategy } from '../../registry.js';
+102
View File
@@ -0,0 +1,102 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { mockApiGet } = vi.hoisted(() => ({
mockApiGet: vi.fn(),
}));
vi.mock('./utils.js', () => ({
apiGet: mockApiGet,
}));
import { getRegistry } from '../../registry.js';
import './comments.js';
describe('bilibili comments', () => {
const command = getRegistry().get('bilibili/comments');
beforeEach(() => {
mockApiGet.mockReset();
});
it('resolves bvid to aid and fetches replies', async () => {
mockApiGet
.mockResolvedValueOnce({ data: { aid: 12345 } }) // view endpoint
.mockResolvedValueOnce({
data: {
replies: [
{
member: { uname: 'Alice' },
content: { message: 'Great video!' },
like: 42,
rcount: 3,
ctime: 1700000000,
},
],
},
});
const result = await command!.func!({} as any, { bvid: 'BV1WtAGzYEBm', limit: 5 });
expect(mockApiGet).toHaveBeenNthCalledWith(1, {}, '/x/web-interface/view', { params: { bvid: 'BV1WtAGzYEBm' } });
expect(mockApiGet).toHaveBeenNthCalledWith(2, {}, '/x/v2/reply/main', {
params: { oid: 12345, type: 1, mode: 3, ps: 5 },
signed: true,
});
expect(result).toEqual([
{
rank: 1,
author: 'Alice',
text: 'Great video!',
likes: 42,
replies: 3,
time: new Date(1700000000 * 1000).toISOString().slice(0, 16).replace('T', ' '),
},
]);
});
it('throws when aid cannot be resolved', async () => {
mockApiGet.mockResolvedValueOnce({ data: {} }); // no aid
await expect(command!.func!({} as any, { bvid: 'BV_invalid', limit: 5 })).rejects.toThrow(
'Cannot resolve aid for bvid: BV_invalid',
);
});
it('returns empty array when replies is missing', async () => {
mockApiGet
.mockResolvedValueOnce({ data: { aid: 99 } })
.mockResolvedValueOnce({ data: {} }); // no replies key
const result = await command!.func!({} as any, { bvid: 'BV1xxx', limit: 5 });
expect(result).toEqual([]);
});
it('caps limit at 50', async () => {
mockApiGet
.mockResolvedValueOnce({ data: { aid: 1 } })
.mockResolvedValueOnce({ data: { replies: [] } });
await command!.func!({} as any, { bvid: 'BV1xxx', limit: 999 });
expect(mockApiGet).toHaveBeenNthCalledWith(2, {}, '/x/v2/reply/main', {
params: { oid: 1, type: 1, mode: 3, ps: 50 },
signed: true,
});
});
it('collapses newlines in comment text', async () => {
mockApiGet
.mockResolvedValueOnce({ data: { aid: 1 } })
.mockResolvedValueOnce({
data: {
replies: [
{ member: { uname: 'Bob' }, content: { message: 'line1\nline2\nline3' }, like: 0, rcount: 0, ctime: 0 },
],
},
});
const result = (await command!.func!({} as any, { bvid: 'BV1xxx', limit: 5 })) as any[];
expect(result[0].text).toBe('line1 line2 line3');
});
});
+44
View File
@@ -0,0 +1,44 @@
/**
* Bilibili comments — fetches top-level replies via the official API with WBI signing.
* Uses the /x/v2/reply/main endpoint which is stable and doesn't depend on DOM structure.
*/
import { cli, Strategy } from '../../registry.js';
import { apiGet } from './utils.js';
cli({
site: 'bilibili',
name: 'comments',
description: '获取 B站视频评论(使用官方 API + WBI 签名)',
domain: 'www.bilibili.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'bvid', required: true, positional: true, help: 'Video BV ID (e.g. BV1WtAGzYEBm)' },
{ name: 'limit', type: 'int', default: 20, help: 'Number of comments (max 50)' },
],
columns: ['rank', 'author', 'text', 'likes', 'replies', 'time'],
func: async (page, kwargs) => {
const bvid = String(kwargs.bvid).trim();
const limit = Math.min(Number(kwargs.limit) || 20, 50);
// Resolve bvid → aid (required by reply API)
const view = await apiGet(page, '/x/web-interface/view', { params: { bvid } });
const aid = view?.data?.aid;
if (!aid) throw new Error(`Cannot resolve aid for bvid: ${bvid}`);
const payload = await apiGet(page, '/x/v2/reply/main', {
params: { oid: aid, type: 1, mode: 3, ps: limit },
signed: true,
});
const replies: any[] = payload?.data?.replies ?? [];
return replies.slice(0, limit).map((r: any, i: number) => ({
rank: i + 1,
author: r.member?.uname ?? '',
text: (r.content?.message ?? '').replace(/\n/g, ' ').trim(),
likes: r.like ?? 0,
replies: r.rcount ?? 0,
time: new Date(r.ctime * 1000).toISOString().slice(0, 16).replace('T', ' '),
}));
},
});
+1 -1
View File
@@ -23,7 +23,7 @@ cli({
// Navigate and wait for the page to hydrate before extracting story data.
await page.goto(url);
await page.wait(5);
await page.wait({ selector: 'article', timeout: 5 });
const loadStory = async () => page.evaluate(`(() => {
const isRobot = /Are you a robot/i.test(document.title)
+29
View File
@@ -0,0 +1,29 @@
site: bluesky
name: feeds
description: Popular Bluesky feed generators
domain: public.api.bsky.app
strategy: public
browser: false
args:
limit:
type: int
default: 20
description: Number of feeds
pipeline:
- fetch:
url: https://public.api.bsky.app/xrpc/app.bsky.unspecced.getPopularFeedGenerators?limit=${{ args.limit }}
- select: feeds
- map:
rank: ${{ index + 1 }}
name: ${{ item.displayName }}
likes: ${{ item.likeCount }}
creator: ${{ item.creator.handle }}
description: ${{ item.description }}
- limit: ${{ args.limit }}
columns: [rank, name, likes, creator, description]
+33
View File
@@ -0,0 +1,33 @@
site: bluesky
name: followers
description: List followers of a Bluesky user
domain: public.api.bsky.app
strategy: public
browser: false
args:
handle:
type: str
required: true
positional: true
description: "Bluesky handle"
limit:
type: int
default: 20
description: Number of followers
pipeline:
- fetch:
url: https://public.api.bsky.app/xrpc/app.bsky.graph.getFollowers?actor=${{ args.handle }}&limit=${{ args.limit }}
- select: followers
- map:
rank: ${{ index + 1 }}
handle: ${{ item.handle }}
name: ${{ item.displayName }}
description: ${{ item.description }}
- limit: ${{ args.limit }}
columns: [rank, handle, name, description]
+33
View File
@@ -0,0 +1,33 @@
site: bluesky
name: following
description: List accounts a Bluesky user is following
domain: public.api.bsky.app
strategy: public
browser: false
args:
handle:
type: str
required: true
positional: true
description: "Bluesky handle"
limit:
type: int
default: 20
description: Number of accounts
pipeline:
- fetch:
url: https://public.api.bsky.app/xrpc/app.bsky.graph.getFollows?actor=${{ args.handle }}&limit=${{ args.limit }}
- select: follows
- map:
rank: ${{ index + 1 }}
handle: ${{ item.handle }}
name: ${{ item.displayName }}
description: ${{ item.description }}
- limit: ${{ args.limit }}
columns: [rank, handle, name, description]
+27
View File
@@ -0,0 +1,27 @@
site: bluesky
name: profile
description: Get Bluesky user profile info
domain: public.api.bsky.app
strategy: public
browser: false
args:
handle:
type: str
required: true
positional: true
description: "Bluesky handle (e.g. bsky.app, jay.bsky.team)"
pipeline:
- fetch:
url: https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${{ args.handle }}
- map:
handle: ${{ item.handle }}
name: ${{ item.displayName }}
followers: ${{ item.followersCount }}
following: ${{ item.followsCount }}
posts: ${{ item.postsCount }}
description: ${{ item.description }}
columns: [handle, name, followers, following, posts, description]
+34
View File
@@ -0,0 +1,34 @@
site: bluesky
name: search
description: Search Bluesky users
domain: public.api.bsky.app
strategy: public
browser: false
args:
query:
type: str
required: true
positional: true
description: Search query
limit:
type: int
default: 10
description: Number of results
pipeline:
- fetch:
url: https://public.api.bsky.app/xrpc/app.bsky.actor.searchActors?q=${{ args.query }}&limit=${{ args.limit }}
- select: actors
- map:
rank: ${{ index + 1 }}
handle: ${{ item.handle }}
name: ${{ item.displayName }}
followers: ${{ item.followersCount }}
description: ${{ item.description }}
- limit: ${{ args.limit }}
columns: [rank, handle, name, followers, description]
+34
View File
@@ -0,0 +1,34 @@
site: bluesky
name: starter-packs
description: Get starter packs created by a Bluesky user
domain: public.api.bsky.app
strategy: public
browser: false
args:
handle:
type: str
required: true
positional: true
description: "Bluesky handle"
limit:
type: int
default: 10
description: Number of starter packs
pipeline:
- fetch:
url: https://public.api.bsky.app/xrpc/app.bsky.graph.getActorStarterPacks?actor=${{ args.handle }}&limit=${{ args.limit }}
- select: starterPacks
- map:
rank: ${{ index + 1 }}
name: ${{ item.record.name }}
description: ${{ item.record.description }}
members: ${{ item.listItemCount }}
joins: ${{ item.joinedAllTimeCount }}
- limit: ${{ args.limit }}
columns: [rank, name, description, members, joins]

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