Compare commits

...

26 Commits

Author SHA1 Message Date
jackwener 6e2d6f5d7e docs: add dingtalk and wecom CLI to external CLI hub
Add dingtalk-workspace-cli and wecom-cli as external CLI integrations
alongside lark-cli, gh, docker, etc.
2026-03-30 12:29:12 +08:00
jackwener f6f50cf1f9 docs: sync docs with codebase (v1.5.5, exit codes, hub table, new adapters)
- SKILL.md: version 1.4.1 → 1.5.5
- README.md: remove non-existent gws from CLI Hub table; bump adapter
  count to 66+; add Exit Codes section (sysexits.h table + usage example)
- README.zh-CN.md: replace readwise/gws (not in external-clis.yaml) with
  lark-cli/vercel; add bluesky and douyin to built-in commands table;
  add 退出码 section matching English README; add 66+ adapter count line
2026-03-29 15:48:47 +08:00
jakevin bcaf6121b8 fix(tests): update E2E exit code assertions for usage errors (#567)
Argument/usage errors now correctly exit with code 2 (EX_USAGE) since
the exit-codes feature landed. Update the two affected E2E assertions:
- unknown command → 2 (usage error, not generic failure)
- plugin update without args → 2 (ArgumentError)
2026-03-28 23:37:40 +08:00
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
82 changed files with 2776 additions and 701 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 选择实现方式
+6 -7
View File
@@ -39,13 +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: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
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
@@ -82,12 +84,9 @@ jobs:
- 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: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v6
+108 -231
View File
@@ -1,6 +1,6 @@
# 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
[![中文文档](https://img.shields.io/badge/docs-%E4%B8%AD%E6%96%87-0F766E?style=flat-square)](./README.zh-CN.md)
@@ -10,17 +10,19 @@
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.
@@ -47,83 +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 (or **Bun** >= 1.0 — see [Runtime Support](#runtime-support) below)
- **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
### Runtime Support
> OpenCLI connects to your browser through a lightweight **Browser Bridge** Chrome Extension + micro-daemon (zero config, auto-start).
OpenCLI works with both **Node.js** (≥ 20) and **Bun** (≥ 1.0). All commands and adapters are runtime-agnostic.
```bash
# Development with Bun (faster startup)
npm run dev:bun
# Run the built CLI with Bun
npm run start:bun
# Run unit tests under Bun
npm run test:bun
# Run E2E tests with Bun as the runtime
OPENCLI_TEST_RUNTIME=bun npm run test:e2e
```
Use `opencli doctor` to check your current runtime — it displays the active engine (e.g. `node v22.13.0` or `bun 1.1.42`).
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
@@ -132,104 +89,64 @@ 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` `fund-holdings` `fund-snapshot` | Browser |
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | Desktop |
| **chatgpt** | `status` `new` `send` `read` `ask` `model` | 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 |
| **paperreview** | `submit` `review` `feedback` | Public |
| **wikipedia** | `search` `summary` `random` `trending` | Public |
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | Public |
| **jd** | `item` | Browser |
| **linkedin** | `search` `timeline` | Browser |
| **reuters** | `search` | Browser |
| **smzdm** | `search` | Browser |
| **web** | `read` | 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** | `feed` `categories` `tags` `search` `topic` `user-topics` `user-posts` | Browser |
| **stackoverflow** | `hot` `search` `bounties` `unanswered` | Public |
| **steam** | `top-sellers` | Public |
| **weread** | `shelf` `search` `book` `highlights` `notes` `notebooks` `ranking` | Browser |
| **douban** | `search` `top250` `subject` `photos` `download` `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 |
| **36kr** | `news` `hot` `search` `article` | Public / Browser |
| **imdb** | `search` `title` `top` `trending` `person` `reviews` | Public |
| **producthunt** | `posts` `today` `hot` `browse` | Public / Browser |
| **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 |
| **pixiv** | `ranking` `search` `user` `illusts` `detail` `download` | Browser |
| **tiktok** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | Browser |
66+ 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` |
| **lark-cli** | Lark/Feishu — messages, docs, calendar, tasks, 200+ commands | `opencli lark-cli calendar +agenda` |
| **dingtalk** | DingTalk — cross-platform CLI for DingTalk's full suite, designed for humans and AI agents | `opencli dingtalk msg send --to user "hello"` |
| **wecom** | WeCom/企业微信 — CLI for WeCom open platform, for humans and AI agents | `opencli wecom msg send --to user "hello"` |
| **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:
If you want to add support for a new Electron desktop app, start with [docs/guide/electron-app-cli.md](./docs/guide/electron-app-cli.md) and the deeper [Electron guide](./docs/advanced/electron.md).
Control Electron desktop apps directly from the terminal. Each adapter has its own detailed documentation:
| App | Description | Doc |
|-----|-------------|-----|
@@ -242,93 +159,73 @@ If you want to add support for a new Electron desktop app, start with [docs/guid
| **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 |
| **douban** | Images | Downloads poster / still image lists from movie subjects |
| **pixiv** | Images | Downloads original-quality illustrations, supports multi-page works |
| **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
# 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
# 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
```
## Exit Codes
opencli follows Unix `sysexits.h` conventions so it integrates naturally with shell pipelines and CI scripts:
| Code | Meaning | When |
|------|---------|------|
| `0` | Success | Command completed normally |
| `1` | Generic error | Unexpected / unclassified failure |
| `2` | Usage error | Bad arguments or unknown command |
| `66` | Empty result | No data returned (`EX_NOINPUT`) |
| `69` | Service unavailable | Browser Bridge not connected (`EX_UNAVAILABLE`) |
| `75` | Temporary failure | Command timed out — retry (`EX_TEMPFAIL`) |
| `77` | Auth required | Not logged in to target site (`EX_NOPERM`) |
| `78` | Config error | Missing credentials or bad config (`EX_CONFIG`) |
| `130` | Interrupted | Ctrl-C / SIGINT |
```bash
opencli spotify status || echo "exit $?" # 69 if browser not running
opencli github issues 2>/dev/null
[ $? -eq 77 ] && opencli github auth # auto-auth if not logged in
```
## 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 update --all # Update all installed plugins
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
```
`opencli plugin list` also shows the tracked short commit hash when a plugin version is recorded in `~/.opencli/plugins.lock.json`.
| Plugin | Type | Description |
|--------|------|-------------|
| [opencli-plugin-github-trending](https://github.com/ByteYue/opencli-plugin-github-trending) | YAML | GitHub Trending repositories |
@@ -339,53 +236,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)
+32 -2
View File
@@ -183,7 +183,10 @@ npm install -g @jackwener/opencli@latest
| **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` | 浏览器 |
| **bluesky** | `search` `trending` `user` `profile` `thread` `feeds` `followers` `following` `starter-packs` | 公开 |
| **douyin** | `videos` `publish` `drafts` `draft` `delete` `stats` `profile` `update` `hashtag` `location` `activities` `collections` | 浏览器 |
66+ 适配器 — **[→ 查看完整命令列表](./docs/adapters/index.md)**
### 外部 CLI 枢纽
@@ -194,8 +197,10 @@ OpenCLI 也可以作为你现有命令行工具的统一入口,负责发现、
| **gh** | GitHub CLI | `opencli gh pr list --limit 5` |
| **obsidian** | Obsidian 仓库管理 | `opencli obsidian search query="AI"` |
| **docker** | Docker 命令行工具 | `opencli docker ps` |
| **readwise** | Readwise / Reader CLI | `opencli readwise login` |
| **gws** | Google Workspace CLI — Docs, Sheets, Drive, Gmail, Calendar | `opencli gws docs list` |
| **lark-cli** | 飞书 CLI — 消息、文档、日历、任务,200+ 命令 | `opencli lark-cli calendar +agenda` |
| **dingtalk** | 钉钉 CLI — 钉钉全套产品能力的跨平台命令行工具,支持人类和 AI Agent 使用 | `opencli dingtalk msg send --to user "hello"` |
| **wecom** | 企业微信 CLI — 企业微信开放平台命令行工具,支持人类和 AI Agent 使用 | `opencli wecom msg send --to user "hello"` |
| **vercel** | Vercel — 部署项目、管理域名、环境变量、日志 | `opencli vercel deploy --prod` |
**零配置透传**:OpenCLI 会把你的输入原样转发给底层二进制,保留原生 stdout / stderr 行为。
@@ -295,6 +300,31 @@ opencli bilibili hot -f csv # CSV
opencli bilibili hot -v # 详细模式:展示管线执行步骤调试信息
```
## 退出码
opencli 遵循 Unix `sysexits.h` 惯例,可无缝接入 shell 管道和 CI 脚本:
| 退出码 | 含义 | 触发场景 |
|--------|------|----------|
| `0` | 成功 | 命令正常完成 |
| `1` | 通用错误 | 未分类的意外错误 |
| `2` | 用法错误 | 参数错误或未知命令 |
| `66` | 无数据 | 命令返回空结果(`EX_NOINPUT` |
| `69` | 服务不可用 | Browser Bridge 未连接(`EX_UNAVAILABLE` |
| `75` | 临时失败 | 命令超时,可重试(`EX_TEMPFAIL` |
| `77` | 需要认证 | 未登录目标网站(`EX_NOPERM` |
| `78` | 配置错误 | 凭证缺失或配置有误(`EX_CONFIG` |
| `130` | 中断 | Ctrl-C / SIGINT |
```bash
opencli bilibili hot 2>/dev/null
case $? in
0) echo "ok" ;;
69) echo "请先启动 Browser Bridge" ;;
77) echo "请先登录 bilibili.com" ;;
esac
```
## 插件
通过社区贡献的插件扩展 OpenCLI。插件使用与内置命令相同的 YAML/TS 格式,启动时自动发现。
+1 -1
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.4.1
version: 1.5.5
author: jackwener
tags: [cli, browser, web, chrome-extension, cdp, bilibili, zhihu, twitter, github, v2ex, hackernews, reddit, xiaohongshu, xueqiu, youtube, boss, coupang, yollomi, AI, agent]
---
+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
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`
+12 -6
View File
@@ -1,6 +1,7 @@
const DAEMON_PORT = 19825;
const DAEMON_HOST = "localhost";
const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`;
const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`;
const WS_RECONNECT_BASE_DELAY = 2e3;
const WS_RECONNECT_MAX_DELAY = 6e4;
@@ -149,8 +150,14 @@ console.error = (...args) => {
_origError(...args);
forwardLog("error", args);
};
function connect() {
async function connect() {
if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return;
try {
const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) });
if (!res.ok) return;
} catch {
return;
}
try {
ws = new WebSocket(DAEMON_WS_URL);
} catch {
@@ -192,7 +199,7 @@ function scheduleReconnect() {
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);
}
const automationSessions = /* @__PURE__ */ new Map();
@@ -231,8 +238,7 @@ async function getAutomationWindow(workspace) {
focused: false,
width: 1280,
height: 900,
type: "normal",
state: "minimized"
type: "normal"
});
const session = {
windowId: win.id,
@@ -260,7 +266,7 @@ function initialize() {
initialized = true;
chrome.alarms.create("keepalive", { periodInMinutes: 0.4 });
registerListeners();
connect();
void connect();
console.log("[opencli] OpenCLI extension initialized");
}
chrome.runtime.onInstalled.addListener(() => {
@@ -270,7 +276,7 @@ chrome.runtime.onStartup.addListener(() => {
initialize();
});
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === "keepalive") connect();
if (alarm.name === "keepalive") void connect();
});
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg?.type === "getStatus") {
+2 -2
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "OpenCLI",
"version": "1.5.2",
"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",
@@ -35,4 +35,4 @@
"extension_pages": "script-src 'self'; object-src 'self'"
},
"homepage_url": "https://github.com/jackwener/opencli"
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "opencli-extension",
"version": "1.5.2",
"version": "1.5.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "opencli-extension",
"version": "1.5.2",
"version": "1.5.4",
"devDependencies": {
"@types/chrome": "^0.0.287",
"typescript": "^5.7.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "opencli-extension",
"version": "1.5.2",
"version": "1.5.5",
"private": true,
"type": "module",
"scripts": {
+21 -6
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 {
@@ -90,7 +104,7 @@ function scheduleReconnect(): void {
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);
}
@@ -146,13 +160,14 @@ 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: BLANK_PAGE,
focused: false,
width: 1280,
height: 900,
type: 'normal',
state: 'minimized',
});
const session: AutomationSession = {
windowId: win.id!,
@@ -187,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');
}
@@ -200,7 +215,7 @@ chrome.runtime.onStartup.addListener(() => {
});
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'keepalive') connect();
if (alarm.name === 'keepalive') void connect();
});
// ─── Popup status API ───────────────────────────────────────────────
+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.5.2",
"version": "1.5.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@jackwener/opencli",
"version": "1.5.2",
"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",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@jackwener/opencli",
"version": "1.5.2",
"version": "1.5.5",
"publishConfig": {
"access": "public"
},
@@ -58,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": {
+16 -12
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,21 +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(__test__.isRetryableSettleError(new Error('{"code":-32000,"message":"Inspected target navigated or closed"}'))).toBe(true);
expect(__test__.isRetryableSettleError(new Error('attach failed: target no longer exists'))).toBe(false);
expect(__test__.isRetryableSettleError(new Error('malformed exec payload'))).toBe(false);
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',
@@ -79,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',
+21
View File
@@ -25,6 +25,8 @@ import {
autoScrollJs,
networkRequestsJs,
waitForDomStableJs,
waitForCaptureJs,
waitForSelectorJs,
} from './dom-helpers.js';
import { isRecord, saveBase64ToFile } from '../utils.js';
@@ -247,6 +249,15 @@ class CDPPage implements IPage {
async wait(options: number | WaitOptions): Promise<void> {
if (typeof options === 'number') {
if (options >= 1) {
try {
const maxMs = options * 1000;
await this.evaluate(waitForDomStableJs(maxMs, Math.min(500, maxMs)));
return;
} catch {
// Fallback: fixed sleep
}
}
await new Promise((resolve) => setTimeout(resolve, options * 1000));
return;
}
@@ -255,6 +266,11 @@ class CDPPage implements IPage {
await new Promise((resolve) => setTimeout(resolve, waitTime * 1000));
return;
}
if (options.selector) {
const timeout = (options.timeout ?? 10) * 1000;
await this.evaluate(waitForSelectorJs(options.selector, timeout));
return;
}
if (options.text) {
const timeout = (options.timeout ?? 30) * 1000;
await this.evaluate(waitForTextJs(options.text, timeout));
@@ -326,6 +342,11 @@ class CDPPage 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 this.evaluate(waitForCaptureJs(maxMs));
}
}
function isCookie(value: unknown): value is BrowserCookie {
+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;
+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 });
})
`;
}
-15
View File
@@ -12,18 +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 { isRetryableSettleError } from './page.js';
import { withTimeoutMs } from '../runtime.js';
export const __test__ = {
extractTabEntries,
diffTabIndexes,
appendLimited,
withTimeoutMs,
selectCDPTarget: cdpTest.selectCDPTarget,
scoreCDPTarget: cdpTest.scoreCDPTarget,
isRetryableSettleError,
};
+4 -3
View File
@@ -96,10 +96,11 @@ export class BrowserBridge implements IBrowserFactory {
});
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;
}
+16
View File
@@ -22,6 +22,8 @@ import {
typeTextJs,
pressKeyJs,
waitForTextJs,
waitForCaptureJs,
waitForSelectorJs,
scrollJs,
autoScrollJs,
networkRequestsJs,
@@ -236,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);
@@ -330,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)
+119 -90
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,43 +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('keeps literal domain and navigateBefore for TS adapters', () => {
const file = path.join(process.cwd(), 'src', 'clis', 'xueqiu', 'fund-holdings.ts');
const entry = scanTs(file, 'xueqiu');
expect(entry).toMatchObject({
site: 'xueqiu',
name: 'fund-holdings',
domain: 'danjuanfunds.com',
navigateBefore: 'https://danjuanfunds.com/my-money',
type: 'ts',
modulePath: 'xueqiu/fund-holdings.js',
});
});
it('captures deprecated metadata for TS adapters', () => {
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, 'legacy.ts');
fs.writeFileSync(file, `
import { cli } from '../../registry.js';
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: 'demo',
site,
name: 'legacy',
description: 'legacy command',
deprecated: 'legacy is deprecated',
replacedBy: 'opencli demo new',
});
`);
expect(scanTs(file, 'demo')).toMatchObject({
site: 'demo',
name: 'legacy',
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);
});
});
+76 -175
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');
@@ -52,116 +53,50 @@ 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 {
@@ -199,83 +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 / true / 'https://...'
const navBoolMatch = src.match(/navigateBefore\s*:\s*(true|false)/);
if (navBoolMatch) {
entry.navigateBefore = navBoolMatch[1] === 'true';
} else {
const navStringMatch = src.match(/navigateBefore\s*:\s*['"`]([^'"`]+)['"`]/);
if (navStringMatch) entry.navigateBefore = navStringMatch[1];
}
const deprecatedBoolMatch = src.match(/deprecated\s*:\s*(true|false)/);
if (deprecatedBoolMatch) {
entry.deprecated = deprecatedBoolMatch[1] === 'true';
} else {
const deprecatedStringMatch = src.match(/deprecated\s*:\s*['"`]([^'"`]+)['"`]/);
if (deprecatedStringMatch) entry.deprecated = deprecatedStringMatch[1];
}
const replacedByMatch = src.match(/replacedBy\s*:\s*['"`]([^'"`]+)['"`]/);
if (replacedByMatch) entry.replacedBy = replacedByMatch[1];
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 [];
}
}
@@ -288,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)) {
@@ -313,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)) {
@@ -332,8 +233,8 @@ 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));
@@ -367,5 +268,5 @@ function main(): void {
const entrypoint = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null;
if (entrypoint === import.meta.url) {
main();
void main();
}
+14 -14
View File
@@ -15,7 +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 { getErrorMessage } from './errors.js';
import { EXIT_CODES, getErrorMessage } from './errors.js';
export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
const program = new Command();
@@ -120,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 ───────────────────
@@ -180,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 ─────────────────────────────────────────────────────
@@ -204,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
@@ -272,7 +272,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
}
} catch (err) {
console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
process.exitCode = 1;
process.exitCode = EXIT_CODES.GENERIC_ERROR;
}
});
@@ -287,7 +287,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
console.log(chalk.green(`✅ Plugin "${name}" uninstalled.`));
} catch (err) {
console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
process.exitCode = 1;
process.exitCode = EXIT_CODES.GENERIC_ERROR;
}
});
@@ -299,12 +299,12 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
.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 = 1;
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 = 1;
process.exitCode = EXIT_CODES.USAGE_ERROR;
return;
}
@@ -335,7 +335,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
console.log();
if (hasErrors) {
console.error(chalk.red('Completed with some errors.'));
process.exitCode = 1;
process.exitCode = EXIT_CODES.GENERIC_ERROR;
} else {
console.log(chalk.green('✅ All plugins updated successfully.'));
}
@@ -348,7 +348,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
console.log(chalk.green(`✅ Plugin "${name}" updated successfully.`));
} catch (err) {
console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
process.exitCode = 1;
process.exitCode = EXIT_CODES.GENERIC_ERROR;
}
});
@@ -438,7 +438,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
console.log(chalk.dim(` opencli ${name} hello`));
} catch (err) {
console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
process.exitCode = 1;
process.exitCode = EXIT_CODES.GENERIC_ERROR;
}
});
@@ -454,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);
@@ -480,7 +480,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
executeExternalCli(name, args, externalClis);
} catch (err) {
console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
process.exitCode = 1;
process.exitCode = EXIT_CODES.GENERIC_ERROR;
}
}
@@ -525,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();
+1 -1
View File
@@ -60,7 +60,7 @@ cli({
await page.installInterceptor('36kr.com/api');
await page.goto(url);
await page.wait(6);
await page.waitForCapture(6);
// Scrape rendered article links from DOM (deduplicated)
const domItems: any = await page.evaluate(`
+1 -1
View File
@@ -24,7 +24,7 @@ cli({
await page.installInterceptor('36kr.com/api');
await page.goto(`https://www.36kr.com/search/articles/${query}`);
await page.wait(6);
await page.waitForCapture(6);
const domItems: any = await page.evaluate(`
(() => {
+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));
}
+2 -2
View File
@@ -13,7 +13,7 @@
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { CDPBridge } from '../../browser/cdp.js';
import type { IPage } from '../../types.js';
import { getErrorMessage } from '../../errors.js';
import { EXIT_CODES, getErrorMessage } from '../../errors.js';
// ─── Types ───────────────────────────────────────────────────────────
@@ -594,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);
+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)
+3 -7
View File
@@ -4,17 +4,13 @@
import { ArgumentError, CliError, EmptyResultError } from '../../errors.js';
import type { IPage } from '../../types.js';
import { clamp } from '../_shared/common.js';
const DOUBAN_PHOTO_PAGE_SIZE = 30;
const MAX_DOUBAN_PHOTOS = 500;
function clampLimit(limit: number): number {
return Math.max(1, Math.min(limit || 20, 50));
}
function clampPhotoLimit(limit: number): number {
return Math.max(1, Math.min(limit || 120, MAX_DOUBAN_PHOTOS));
}
const clampLimit = (limit: number) => clamp(limit || 20, 1, 50);
const clampPhotoLimit = (limit: number) => clamp(limit || 120, 1, MAX_DOUBAN_PHOTOS);
async function ensureDoubanReady(page: IPage): Promise<void> {
const state = await page.evaluate(`
+1 -1
View File
@@ -16,7 +16,7 @@ export function buildMediumUserUrl(username: string): string {
export async function loadMediumPosts(page: IPage, url: string, limit: number): Promise<any[]> {
if (!page) throw new CommandExecutionError('Browser session required for medium posts');
await page.goto(url);
await page.wait(5);
await page.wait({ selector: 'article', timeout: 5 });
const data = await page.evaluate(`
(async () => {
await new Promise((resolve) => setTimeout(resolve, 3000));
+1 -1
View File
@@ -32,7 +32,7 @@ cli({
await page.installInterceptor('producthunt.com');
await page.goto(`https://www.producthunt.com/categories/${slug}`);
await page.wait(5);
await page.waitForCapture(5);
const domItems: any = await page.evaluate(`
(() => {
+1 -1
View File
@@ -23,7 +23,7 @@ cli({
await page.installInterceptor('producthunt.com');
await page.goto('https://www.producthunt.com');
await page.wait(5);
await page.waitForCapture(5);
const domItems: any = await page.evaluate(`
(() => {
+6 -7
View File
@@ -1,8 +1,7 @@
import type { IPage } from '../../types.js';
import { clamp } from '../_shared/common.js';
function clampLimit(limit: number): number {
return Math.max(1, Math.min(limit || 20, 50));
}
const clampLimit = (limit: number) => clamp(limit || 20, 1, 50);
export function buildSinaBlogSearchUrl(keyword: string): string {
return `https://search.sina.com.cn/search?q=${encodeURIComponent(keyword)}&tp=mix`;
@@ -14,7 +13,7 @@ export function buildSinaBlogUserUrl(uid: string): string {
export async function loadSinaBlogArticle(page: IPage, url: string): Promise<any> {
await page.goto(url);
await page.wait(3);
await page.wait({ selector: 'h1', timeout: 3 });
return page.evaluate(`
(async () => {
await new Promise((resolve) => setTimeout(resolve, 1500));
@@ -52,7 +51,7 @@ export async function loadSinaBlogArticle(page: IPage, url: string): Promise<any
export async function loadSinaBlogHot(page: IPage, limit: number): Promise<any[]> {
const safeLimit = clampLimit(limit);
await page.goto('https://blog.sina.com.cn/');
await page.wait(3);
await page.wait({ selector: 'h1', timeout: 3 });
const data = await page.evaluate(`
(async () => {
await new Promise((resolve) => setTimeout(resolve, 1500));
@@ -122,7 +121,7 @@ export async function loadSinaBlogHot(page: IPage, limit: number): Promise<any[]
export async function loadSinaBlogSearch(page: IPage, keyword: string, limit: number): Promise<any[]> {
const safeLimit = clampLimit(limit);
await page.goto(buildSinaBlogSearchUrl(keyword));
await page.wait(5);
await page.wait({ selector: '.result-item', timeout: 5 });
const data = await page.evaluate(`
(async () => {
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -159,7 +158,7 @@ export async function loadSinaBlogSearch(page: IPage, keyword: string, limit: nu
export async function loadSinaBlogUser(page: IPage, uid: string, limit: number): Promise<any[]> {
const safeLimit = clampLimit(limit);
await page.goto(buildSinaBlogUserUrl(uid));
await page.wait(3);
await page.wait({ selector: 'h1', timeout: 3 });
const data = await page.evaluate(`
(async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
+42
View File
@@ -0,0 +1,42 @@
/**
* Sinafinance rolling news feed
*/
import { cli, Strategy } from '../../registry.js';
cli({
site: 'sinafinance',
name: 'rolling-news',
description: '新浪财经滚动新闻',
domain: 'finance.sina.com.cn/roll',
strategy: Strategy.COOKIE,
args: [],
columns: ['column', 'title', 'date', 'url'],
func: async (page, _args) => {
await page.goto(`https://finance.sina.com.cn/roll/#pageid=384&lid=2519`);
await page.wait({ selector: '.d_list_txt li', timeout: 10000 });
const payload = await page.evaluate(`
(() => {
const cleanText = (value) => (value || '').replace(/\\s+/g, ' ').trim();
const results = [];
document.querySelectorAll('.d_list_txt li').forEach(el => {
const titleEl = el.querySelector('.c_tit a');
const columnEl = el.querySelector('.c_chl');
const dateEl = el.querySelector('.c_time');
const url = titleEl?.getAttribute('href') || '';
if (!url) return;
results.push({
title: cleanText(titleEl?.textContent || ''),
column: cleanText(columnEl?.textContent || ''),
date: cleanText(dateEl?.textContent || ''),
url: url,
});
});
return results;
})()
`);
if (!Array.isArray(payload)) return [];
return payload;
},
});
+127
View File
@@ -0,0 +1,127 @@
/**
* Sinafinance stock quote — A股 / 港股 / 美股
*
* Uses two public Sina APIs (no browser required):
* suggest3.sinajs.cn — symbol search
* hq.sinajs.cn — real-time quote
*/
import { cli, Strategy } from '../../registry.js';
import { CliError } from '../../errors.js';
const MARKET_CN = '11';
const MARKET_HK = '31';
const MARKET_US = '41';
async function fetchGBK(url: string): Promise<string> {
const res = await fetch(url, { headers: { Referer: 'https://finance.sina.com.cn' } });
if (!res.ok) throw new CliError('FETCH_ERROR', `Sina API HTTP ${res.status}`, 'Check your network');
const buf = await res.arrayBuffer();
return new TextDecoder('gbk').decode(buf);
}
interface SuggestEntry { name: string; market: string; symbol: string; }
function parseSuggest(raw: string, markets: string[]): SuggestEntry[] {
const m = raw.match(/suggestvalue="(.*)"/s);
if (!m) return [];
return m[1].split(';').filter(Boolean).map(s => {
const p = s.split(',');
return { name: p[4] || p[0] || '', market: p[1] || '', symbol: p[3] || '' };
}).filter(e => markets.includes(e.market));
}
function hqSymbol(e: SuggestEntry): string {
if (e.market === MARKET_HK) return `hk${e.symbol}`;
if (e.market === MARKET_US) return `gb_${e.symbol}`;
return e.symbol; // A股: already "sh600519" / "sz300XXX"
}
function parseHq(raw: string, sym: string): string[] {
const escaped = sym.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const m = raw.match(new RegExp(`hq_str_${escaped}="([^"]*)"`));
return m ? m[1].split(',') : [];
}
function fmtMktCap(val: string): string {
const n = parseFloat(val);
if (!n) return '';
if (n >= 1e12) return (n / 1e12).toFixed(2) + 'T';
if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
return String(n);
}
cli({
site: 'sinafinance',
name: 'stock',
description: '新浪财经行情(A股/港股/美股)',
domain: 'suggest3.sinajs.cn,hq.sinajs.cn',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'key', type: 'string', required: true, positional: true, help: 'Stock name or code (e.g. 贵州茅台, 腾讯控股, AAPL)' },
{ name: 'market', type: 'string', default: 'auto', help: 'Market: cn, hk, us, auto (default: auto searches cn → hk → us)' },
],
columns: ['Symbol', 'Name', 'Price', 'Change', 'ChangePercent', 'Open', 'High', 'Low', 'Volume', 'MarketCap'],
func: async (_page, args) => {
const key = String(args.key);
const market = String(args.market);
const marketMap: Record<string, string[]> = {
cn: [MARKET_CN], hk: [MARKET_HK], us: [MARKET_US],
auto: [MARKET_CN, MARKET_HK, MARKET_US],
};
const targetMarkets = marketMap[market];
if (!targetMarkets) {
throw new CliError('INPUT_ERROR', `Invalid market: "${market}"`, 'Expected cn, hk, us, or auto');
}
// 1. Search symbol — only request the markets we care about
const suggestRaw = await fetchGBK(
`https://suggest3.sinajs.cn/suggest/type=${targetMarkets.join(',')}&key=${encodeURIComponent(key)}`
);
const entries = parseSuggest(suggestRaw, targetMarkets);
if (!entries.length) {
throw new CliError('NOT_FOUND', `No stock found for "${key}"`, 'Try a different name, code, or --market');
}
// Pick best match: score by name similarity, tiebreak by market priority
const needle = key.toLowerCase();
const score = (e: SuggestEntry): number => {
const n = e.name.toLowerCase();
if (n === needle) return 1;
if (n.includes(needle)) return needle.length / n.length;
return 0;
};
const best = entries.sort((a, b) => {
const d = score(b) - score(a);
return d !== 0 ? d : targetMarkets.indexOf(a.market) - targetMarkets.indexOf(b.market);
})[0];
// 2. Fetch quote
const sym = hqSymbol(best);
const hqRaw = await fetchGBK(`https://hq.sinajs.cn/list=${sym}`);
const f = parseHq(hqRaw, sym);
if (f.length < 2 || !f[0]) {
throw new CliError('NOT_FOUND', `No quote data for "${key}"`, 'Market may be closed or data unavailable');
}
if (best.market === MARKET_CN) {
const price = parseFloat(f[3]);
const prev = parseFloat(f[2]);
const chg = (price - prev).toFixed(2);
const chgPct = ((price - prev) / prev * 100).toFixed(2) + '%';
return [{ Symbol: sym.toUpperCase(), Name: f[0], Price: f[3], Change: chg, ChangePercent: chgPct, Open: f[1], High: f[4], Low: f[5], Volume: f[8], MarketCap: '' }];
}
if (best.market === MARKET_HK) {
// [2]=price [4]=high [5]=low [6]=open [7]=change [8]=change% [11]=volume
return [{ Symbol: best.symbol, Name: f[1], Price: f[2], Change: f[7], ChangePercent: f[8] + '%', Open: f[6], High: f[4], Low: f[5], Volume: f[11], MarketCap: '' }];
}
// MARKET_US: [1]=price [2]=change% [4]=change [6]=open [7]=today_low [8]=52wH [9]=52wL [10]=volume [12]=mktcap
return [{ Symbol: best.symbol.toUpperCase(), Name: f[0], Price: f[1], Change: f[4], ChangePercent: f[2] + '%', Open: f[6], High: f[8], Low: f[9], Volume: f[10], MarketCap: fmtMktCap(f[12]) }];
},
});
+2 -2
View File
@@ -10,7 +10,7 @@ export function buildSubstackBrowseUrl(category?: string): string {
export async function loadSubstackFeed(page: IPage, url: string, limit: number): Promise<any[]> {
if (!page) throw new CommandExecutionError('Browser session required for substack feed');
await page.goto(url);
await page.wait(5);
await page.wait({ selector: 'article', timeout: 5 });
const data = await page.evaluate(`
(async () => {
await new Promise((resolve) => setTimeout(resolve, 3000));
@@ -79,7 +79,7 @@ export async function loadSubstackFeed(page: IPage, url: string, limit: number):
export async function loadSubstackArchive(page: IPage, baseUrl: string, limit: number): Promise<any[]> {
if (!page) throw new CommandExecutionError('Browser session required for substack archive');
await page.goto(`${baseUrl}/archive`);
await page.wait(5);
await page.wait({ selector: 'article', timeout: 5 });
const data = await page.evaluate(`
(async () => {
await new Promise((resolve) => setTimeout(resolve, 3000));
+1 -1
View File
@@ -18,7 +18,7 @@ cli({
const username = kwargs.username.replace(/^@/, '');
await page.goto(`https://x.com/${username}`);
await page.wait(5);
await page.wait({ selector: '[data-testid="primaryColumn"]' });
const result = await page.evaluate(`(async () => {
try {
+1 -1
View File
@@ -17,7 +17,7 @@ cli({
if (!page) throw new CommandExecutionError('Browser session required for twitter bookmark');
await page.goto(kwargs.url);
await page.wait(5);
await page.wait({ selector: '[data-testid="primaryColumn"]' });
const result = await page.evaluate(`(async () => {
try {
+1 -1
View File
@@ -17,7 +17,7 @@ cli({
if (!page) throw new CommandExecutionError('Browser session required for twitter delete');
await page.goto(kwargs.url);
await page.wait(5); // Wait for tweet to load completely
await page.wait({ selector: '[data-testid="primaryColumn"]' }); // Wait for tweet to load completely
const result = await page.evaluate(`(async () => {
try {
+1 -1
View File
@@ -18,7 +18,7 @@ cli({
const username = kwargs.username.replace(/^@/, '');
await page.goto(`https://x.com/${username}`);
await page.wait(5);
await page.wait({ selector: '[data-testid="primaryColumn"]' });
const result = await page.evaluate(`(async () => {
try {
+2 -2
View File
@@ -19,7 +19,7 @@ cli({
// If no user is specified, figure out the logged-in user's handle
if (!targetUser) {
await page.goto('https://x.com/home');
await page.wait(5);
await page.wait({ selector: '[data-testid="primaryColumn"]' });
const href = await page.evaluate(`() => {
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
@@ -58,7 +58,7 @@ cli({
if (!clicked) {
throw new SelectorError('Twitter followers link', 'Twitter may have changed the layout.');
}
await page.wait(5);
await page.waitForCapture(5);
// 4. Scroll to trigger pagination API calls
await page.autoScroll({ times: Math.ceil(kwargs.limit / 20), delayMs: 2000 });
+2 -2
View File
@@ -19,7 +19,7 @@ cli({
// If no user is specified, figure out the logged-in user's handle
if (!targetUser) {
await page.goto('https://x.com/home');
await page.wait(5);
await page.wait({ selector: '[data-testid="primaryColumn"]' });
const href = await page.evaluate(`() => {
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
@@ -51,7 +51,7 @@ cli({
if (!clicked) {
throw new SelectorError('Twitter following link', 'Twitter may have changed the layout.');
}
await page.wait(5);
await page.waitForCapture(5);
// 4. Scroll to trigger pagination API calls
await page.autoScroll({ times: Math.ceil(kwargs.limit / 20), delayMs: 2000 });
+1 -1
View File
@@ -17,7 +17,7 @@ cli({
if (!page) throw new CommandExecutionError('Browser session required for twitter hide-reply');
await page.goto(kwargs.url);
await page.wait(5);
await page.wait({ selector: '[data-testid="primaryColumn"]' });
const result = await page.evaluate(`(async () => {
try {
+1 -1
View File
@@ -17,7 +17,7 @@ cli({
if (!page) throw new CommandExecutionError('Browser session required for twitter like');
await page.goto(kwargs.url);
await page.wait(5); // Wait for tweet to load completely
await page.wait({ selector: '[data-testid="primaryColumn"]' }); // Wait for tweet to load completely
const result = await page.evaluate(`(async () => {
try {
+1 -1
View File
@@ -25,7 +25,7 @@ cli({
window.history.pushState({}, '', '/notifications');
window.dispatchEvent(new PopStateEvent('popstate', { state: {} }));
}`);
await page.wait(5);
await page.waitForCapture(5);
// Verify SPA navigation succeeded
const currentUrl = await page.evaluate('() => window.location.pathname');
+1 -1
View File
@@ -21,7 +21,7 @@ cli({
// If no username, detect the logged-in user
if (!username) {
await page.goto('https://x.com/home');
await page.wait(5);
await page.wait({ selector: '[data-testid="primaryColumn"]' });
const href = await page.evaluate(`() => {
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
return link ? link.getAttribute('href') : null;
+1 -1
View File
@@ -27,7 +27,7 @@ cli({
// Step 1: Navigate to messages to get conversation list
await page.goto('https://x.com/messages');
await page.wait(5);
await page.wait({ selector: '[data-testid="primaryColumn"]' });
// Step 2: Collect conversations with scroll-to-load
const needed = maxSend + 10; // extra buffer for skips
+1 -1
View File
@@ -19,7 +19,7 @@ cli({
// 1. Navigate to the tweet page
await page.goto(kwargs.url);
await page.wait(5); // Wait for the react application to hydrate
await page.wait({ selector: '[data-testid="primaryColumn"]' });
// 2. Automate typing the reply and clicking reply
const result = await page.evaluate(`(async () => {
+1 -1
View File
@@ -20,7 +20,7 @@ async function navigateToSearch(page: Pick<IPage, 'evaluate' | 'wait'>, query: s
window.dispatchEvent(new PopStateEvent('popstate', { state: {} }));
})()
`);
await page.wait(5);
await page.wait({ selector: '[data-testid="primaryColumn"]' });
lastPath = String(await page.evaluate('() => window.location.pathname') || '');
if (lastPath.startsWith('/search')) {
+1 -1
View File
@@ -18,7 +18,7 @@ cli({
const username = kwargs.username.replace(/^@/, '');
await page.goto(`https://x.com/${username}`);
await page.wait(5);
await page.wait({ selector: '[data-testid="primaryColumn"]' });
const result = await page.evaluate(`(async () => {
try {
+1 -1
View File
@@ -17,7 +17,7 @@ cli({
if (!page) throw new CommandExecutionError('Browser session required for twitter unbookmark');
await page.goto(kwargs.url);
await page.wait(5);
await page.wait({ selector: '[data-testid="primaryColumn"]' });
const result = await page.evaluate(`(async () => {
try {
+1 -1
View File
@@ -18,7 +18,7 @@ cli({
const username = kwargs.username.replace(/^@/, '');
await page.goto(`https://x.com/${username}`);
await page.wait(5);
await page.wait({ selector: '[data-testid="primaryColumn"]' });
const result = await page.evaluate(`(async () => {
try {
+1
View File
@@ -26,6 +26,7 @@ function createPageMock(evaluateResult: any): IPage {
getInterceptedRequests: vi.fn().mockResolvedValue([]),
getCookies: vi.fn().mockResolvedValue([]),
screenshot: vi.fn().mockResolvedValue(''),
waitForCapture: vi.fn().mockResolvedValue(undefined),
};
}
@@ -33,6 +33,7 @@ function createPageMock(evaluateResult: any): IPage {
getInterceptedRequests: vi.fn().mockResolvedValue([]),
getCookies: vi.fn().mockResolvedValue([]),
screenshot: vi.fn().mockResolvedValue(''),
waitForCapture: vi.fn().mockResolvedValue(undefined),
};
}
@@ -37,6 +37,7 @@ function createPageMock(evaluateResult: any, interceptedRequests: any[] = []): I
getInterceptedRequests,
getCookies: vi.fn().mockResolvedValue([]),
screenshot: vi.fn().mockResolvedValue(''),
waitForCapture: vi.fn().mockResolvedValue(undefined),
};
}
+1
View File
@@ -36,6 +36,7 @@ function createPageMock(evaluateResults: any[]): IPage {
getInterceptedRequests: vi.fn().mockResolvedValue([]),
getCookies: vi.fn().mockResolvedValue([]),
screenshot: vi.fn().mockResolvedValue(''),
waitForCapture: vi.fn().mockResolvedValue(undefined),
};
}
+1
View File
@@ -31,6 +31,7 @@ function createPageMock(evaluateResults: any[]): IPage {
getInterceptedRequests: vi.fn().mockResolvedValue([]),
getCookies: vi.fn().mockResolvedValue([]),
screenshot: vi.fn().mockResolvedValue(''),
waitForCapture: vi.fn().mockResolvedValue(undefined),
};
}
+25 -2
View File
@@ -18,6 +18,7 @@ import { render as renderOutput } from './output.js';
import { executeCommand } from './execution.js';
import {
CliError,
EXIT_CODES,
ERROR_ICONS,
getErrorMessage,
BrowserConnectError,
@@ -40,7 +41,7 @@ export function normalizeArgValue(argType: string | undefined, value: unknown, n
if (normalized === 'true') return true;
if (normalized === 'false') return false;
throw new CliError('ARGUMENT', `"${name}" must be either "true" or "false".`);
throw new ArgumentError(`"${name}" must be either "true" or "false".`);
}
/**
@@ -117,11 +118,33 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
});
} catch (err) {
await renderError(err, fullName(cmd), optionsRecord.verbose === true);
process.exitCode = 1;
process.exitCode = resolveExitCode(err);
}
});
}
// ── Exit code resolution ─────────────────────────────────────────────────────
/**
* Map any thrown value to a Unix process exit code.
*
* - CliError subclasses carry their own exitCode (set in errors.ts).
* - Generic Error objects are classified by message pattern so that
* un-typed auth / not-found errors from adapters still produce
* meaningful exit codes for shell scripts.
*/
function resolveExitCode(err: unknown): number {
if (err instanceof CliError) return err.exitCode;
// Pattern-based fallback for untyped errors thrown by third-party adapters.
const msg = getErrorMessage(err);
const kind = classifyGenericError(msg);
if (kind === 'auth') return EXIT_CODES.NOPERM;
if (kind === 'not-found') return EXIT_CODES.EMPTY_RESULT;
if (kind === 'http') return EXIT_CODES.GENERIC_ERROR; // HTTP 4xx/5xx → generic; renderer shows details
return EXIT_CODES.GENERIC_ERROR;
}
// ── Error rendering ──────────────────────────────────────────────────────────
const ISSUES_URL = 'https://github.com/jackwener/opencli/issues';
+21 -8
View File
@@ -22,6 +22,7 @@
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { WebSocketServer, WebSocket, type RawData } from 'ws';
import { DEFAULT_DAEMON_PORT } from './constants.js';
import { EXIT_CODES } from './errors.js';
const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
const IDLE_TIMEOUT = 5 * 60 * 1000; // 5 minutes
@@ -53,7 +54,7 @@ function resetIdleTimer(): void {
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
console.error('[daemon] Idle timeout, shutting down');
process.exit(0);
process.exit(EXIT_CODES.SUCCESS);
}, IDLE_TIMEOUT);
}
@@ -102,7 +103,22 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
return;
}
// Require custom header on all HTTP requests. Browsers cannot attach
const url = req.url ?? '/';
const pathname = url.split('?')[0];
// Health-check endpoint — no X-OpenCLI header required.
// Used by the extension to silently probe daemon reachability before
// attempting a WebSocket connection (avoids uncatchable ERR_CONNECTION_REFUSED).
// Security note: this endpoint is reachable by any client that passes the
// origin check above (chrome-extension:// or no Origin header, e.g. curl).
// Timing side-channels can reveal daemon presence to local processes, which
// is an accepted risk given the daemon is loopback-only and short-lived.
if (req.method === 'GET' && pathname === '/ping') {
jsonResponse(res, 200, { ok: true });
return;
}
// Require custom header on all other HTTP requests. Browsers cannot attach
// custom headers in "simple" requests, and our preflight returns no
// Access-Control-Allow-Headers, so scripted fetch() from web pages is
// blocked even if Origin check is somehow bypassed.
@@ -111,9 +127,6 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
return;
}
const url = req.url ?? '/';
const pathname = url.split('?')[0];
if (req.method === 'GET' && pathname === '/status') {
jsonResponse(res, 200, {
ok: true,
@@ -291,10 +304,10 @@ httpServer.listen(PORT, '127.0.0.1', () => {
httpServer.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
console.error(`[daemon] Port ${PORT} already in use — another daemon is likely running. Exiting.`);
process.exit(1);
process.exit(EXIT_CODES.SERVICE_UNAVAIL);
}
console.error('[daemon] Server error:', err.message);
process.exit(1);
process.exit(EXIT_CODES.GENERIC_ERROR);
});
// Graceful shutdown
@@ -307,7 +320,7 @@ function shutdown(): void {
pending.clear();
if (extensionWs) extensionWs.close();
httpServer.close();
process.exit(0);
process.exit(EXIT_CODES.SUCCESS);
}
process.on('SIGTERM', shutdown);
+19 -1
View File
@@ -2,13 +2,14 @@ import * as fs from 'node:fs';
import * as http from 'node:http';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { formatCookieHeader, httpDownload, resolveRedirectUrl } from './index.js';
const servers: http.Server[] = [];
const tempDirs: string[] = [];
afterEach(async () => {
vi.unstubAllEnvs();
await Promise.all(servers.map((server) => new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
})));
@@ -114,4 +115,21 @@ describe('download helpers', { retry: process.platform === 'win32' ? 2 : 0 }, ()
expect(forwardedCookie).toBeUndefined();
expect(fs.readFileSync(destPath, 'utf8')).toBe('ok');
});
it('bypasses proxy settings for loopback downloads', async () => {
vi.stubEnv('HTTP_PROXY', 'http://127.0.0.1:9');
const baseUrl = await startServer((_req, res) => {
res.statusCode = 200;
res.end('ok');
});
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-dl-'));
tempDirs.push(tempDir);
const destPath = path.join(tempDir, 'loopback.txt');
const result = await httpDownload(`${baseUrl}/ok`, destPath);
expect(result).toEqual({ success: true, size: 2 });
expect(fs.readFileSync(destPath, 'utf8')).toBe('ok');
});
});
+50 -41
View File
@@ -5,16 +5,16 @@
import { spawn } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as https from 'node:https';
import * as http from 'node:http';
import * as os from 'node:os';
import { Transform } from 'node:stream';
import { Readable, Transform } from 'node:stream';
import type { ReadableStream as WebReadableStream } from 'node:stream/web';
import { pipeline } from 'node:stream/promises';
import { URL } from 'node:url';
import type { ProgressBar } from './progress.js';
import { isBinaryInstalled } from '../external.js';
import type { BrowserCookie } from '../types.js';
import { getErrorMessage } from '../errors.js';
import { fetchWithNodeNetwork } from '../node-network.js';
export type { BrowserCookie } from '../types.js';
@@ -89,9 +89,6 @@ export async function httpDownload(
const { cookies, headers = {}, timeout = 30000, onProgress, maxRedirects = 10 } = options;
return new Promise((resolve) => {
const parsedUrl = new URL(url);
const protocol = parsedUrl.protocol === 'https:' ? https : http;
const requestHeaders: Record<string, string> = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
...headers,
@@ -118,37 +115,52 @@ export async function httpDownload(
}
};
const request = protocol.get(url, { headers: requestHeaders, timeout }, (response) => {
void (async () => {
void (async () => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetchWithNodeNetwork(url, {
headers: requestHeaders,
signal: controller.signal,
redirect: 'manual',
});
clearTimeout(timer);
// Handle redirects before creating any file handles.
if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
response.resume();
if (redirectCount >= maxRedirects) {
finish({ success: false, size: 0, error: `Too many redirects (> ${maxRedirects})` });
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location');
if (location) {
if (redirectCount >= maxRedirects) {
finish({ success: false, size: 0, error: `Too many redirects (> ${maxRedirects})` });
return;
}
const redirectUrl = resolveRedirectUrl(url, location);
const originalHost = new URL(url).hostname;
const redirectHost = new URL(redirectUrl).hostname;
const redirectOptions = originalHost === redirectHost
? options
: { ...options, cookies: undefined, headers: stripCookieHeaders(options.headers) };
finish(await httpDownload(
redirectUrl,
destPath,
redirectOptions,
redirectCount + 1,
));
return;
}
const redirectUrl = resolveRedirectUrl(url, response.headers.location);
const originalHost = new URL(url).hostname;
const redirectHost = new URL(redirectUrl).hostname;
const redirectOptions = originalHost === redirectHost
? options
: { ...options, cookies: undefined, headers: stripCookieHeaders(options.headers) };
finish(await httpDownload(
redirectUrl,
destPath,
redirectOptions,
redirectCount + 1,
));
}
if (response.status !== 200) {
finish({ success: false, size: 0, error: `HTTP ${response.status}` });
return;
}
if (response.statusCode !== 200) {
response.resume();
finish({ success: false, size: 0, error: `HTTP ${response.statusCode}` });
if (!response.body) {
finish({ success: false, size: 0, error: 'Empty response body' });
return;
}
const totalSize = parseInt(response.headers['content-length'] || '0', 10);
const totalSize = parseInt(response.headers.get('content-length') || '0', 10);
let received = 0;
const progressStream = new Transform({
transform(chunk, _encoding, callback) {
@@ -160,26 +172,23 @@ export async function httpDownload(
try {
await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
await pipeline(response, progressStream, fs.createWriteStream(tempPath));
await pipeline(
Readable.fromWeb(response.body as unknown as WebReadableStream),
progressStream,
fs.createWriteStream(tempPath),
);
await fs.promises.rename(tempPath, destPath);
finish({ success: true, size: received });
} catch (err) {
await cleanupTempFile();
finish({ success: false, size: 0, error: getErrorMessage(err) });
}
})();
});
request.on('error', (err) => {
void (async () => {
} catch (err) {
clearTimeout(timer);
await cleanupTempFile();
finish({ success: false, size: 0, error: err.message });
})();
});
request.on('timeout', () => {
request.destroy(new Error('Timeout'));
});
finish({ success: false, size: 0, error: err instanceof Error ? err.message : String(err) });
}
})();
});
}
+71 -10
View File
@@ -4,48 +4,96 @@
* All errors thrown by the framework should extend CliError so that
* the top-level handler in commanderAdapter.ts can render consistent,
* helpful output with emoji-coded severity and actionable hints.
*
* ## Exit codes
*
* opencli follows Unix conventions (sysexits.h) for process exit codes:
*
* 0 Success
* 1 Generic / unexpected error
* 2 Argument / usage error (ArgumentError)
* 66 No input / empty result (EmptyResultError)
* 69 Service unavailable (BrowserConnectError, AdapterLoadError)
* 75 Temporary failure, retry later (TimeoutError) EX_TEMPFAIL
* 77 Permission denied / auth needed (AuthRequiredError)
* 78 Configuration error (ConfigError)
* 130 Interrupted by Ctrl-C (set by tui.ts SIGINT handler)
*/
// ── Exit code table ──────────────────────────────────────────────────────────
export const EXIT_CODES = {
SUCCESS: 0,
GENERIC_ERROR: 1,
USAGE_ERROR: 2, // Bad arguments / command misuse
EMPTY_RESULT: 66, // No data / not found (EX_NOINPUT)
SERVICE_UNAVAIL:69, // Daemon / browser unavailable (EX_UNAVAILABLE)
TEMPFAIL: 75, // Timeout — try again later (EX_TEMPFAIL)
NOPERM: 77, // Auth required / permission (EX_NOPERM)
CONFIG_ERROR: 78, // Missing / invalid config (EX_CONFIG)
INTERRUPTED: 130, // Ctrl-C / SIGINT
} as const;
export type ExitCode = typeof EXIT_CODES[keyof typeof EXIT_CODES];
// ── Base class ───────────────────────────────────────────────────────────────
export class CliError extends Error {
/** Machine-readable error code (e.g. 'BROWSER_CONNECT', 'AUTH_REQUIRED') */
readonly code: string;
/** Human-readable hint on how to fix the problem */
readonly hint?: string;
/** Unix process exit code — defaults to 1 (generic error) */
readonly exitCode: ExitCode;
constructor(code: string, message: string, hint?: string) {
constructor(code: string, message: string, hint?: string, exitCode: ExitCode = EXIT_CODES.GENERIC_ERROR) {
super(message);
this.name = new.target.name;
this.code = code;
this.hint = hint;
this.exitCode = exitCode;
}
}
// ── Typed subclasses ─────────────────────────────────────────────────────────
export type BrowserConnectKind = 'daemon-not-running' | 'extension-not-connected' | 'command-failed' | 'unknown';
export class BrowserConnectError extends CliError {
readonly kind: BrowserConnectKind;
constructor(message: string, hint?: string, kind: BrowserConnectKind = 'unknown') {
super('BROWSER_CONNECT', message, hint);
super('BROWSER_CONNECT', message, hint, EXIT_CODES.SERVICE_UNAVAIL);
this.kind = kind;
}
}
export class AdapterLoadError extends CliError {
constructor(message: string, hint?: string) { super('ADAPTER_LOAD', message, hint); }
constructor(message: string, hint?: string) {
super('ADAPTER_LOAD', message, hint, EXIT_CODES.SERVICE_UNAVAIL);
}
}
export class CommandExecutionError extends CliError {
constructor(message: string, hint?: string) { super('COMMAND_EXEC', message, hint); }
constructor(message: string, hint?: string) {
super('COMMAND_EXEC', message, hint, EXIT_CODES.GENERIC_ERROR);
}
}
export class ConfigError extends CliError {
constructor(message: string, hint?: string) { super('CONFIG', message, hint); }
constructor(message: string, hint?: string) {
super('CONFIG', message, hint, EXIT_CODES.CONFIG_ERROR);
}
}
export class AuthRequiredError extends CliError {
readonly domain: string;
constructor(domain: string, message?: string) {
super('AUTH_REQUIRED', message ?? `Not logged in to ${domain}`, `Please open Chrome and log in to https://${domain}`);
super(
'AUTH_REQUIRED',
message ?? `Not logged in to ${domain}`,
`Please open Chrome and log in to https://${domain}`,
EXIT_CODES.NOPERM,
);
this.domain = domain;
}
}
@@ -56,27 +104,40 @@ export class TimeoutError extends CliError {
'TIMEOUT',
`${label} timed out after ${seconds}s`,
hint ?? 'Try again, or increase timeout with OPENCLI_BROWSER_COMMAND_TIMEOUT env var',
EXIT_CODES.TEMPFAIL,
);
}
}
export class ArgumentError extends CliError {
constructor(message: string, hint?: string) { super('ARGUMENT', message, hint); }
constructor(message: string, hint?: string) {
super('ARGUMENT', message, hint, EXIT_CODES.USAGE_ERROR);
}
}
export class EmptyResultError extends CliError {
constructor(command: string, hint?: string) {
super('EMPTY_RESULT', `${command} returned no data`, hint ?? 'The page structure may have changed, or you may need to log in');
super(
'EMPTY_RESULT',
`${command} returned no data`,
hint ?? 'The page structure may have changed, or you may need to log in',
EXIT_CODES.EMPTY_RESULT,
);
}
}
export class SelectorError extends CliError {
constructor(selector: string, hint?: string) {
super('SELECTOR', `Could not find element: ${selector}`, hint ?? 'The page UI may have changed. Please report this issue.');
super(
'SELECTOR',
`Could not find element: ${selector}`,
hint ?? 'The page UI may have changed. Please report this issue.',
EXIT_CODES.GENERIC_ERROR,
);
}
}
// ── Utilities ───────────────────────────────────────────────────────────
// ── Utilities ───────────────────────────────────────────────────────────────
/** Extract a human-readable message from an unknown caught value. */
export function getErrorMessage(error: unknown): string {
+3 -2
View File
@@ -19,6 +19,7 @@ import { shouldUseBrowserSession } from './capabilityRouting.js';
import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT } from './runtime.js';
import { emitHook, type HookContext } from './hooks.js';
import { checkDaemonStatus } from './browser/discover.js';
import { log } from './logger.js';
const _loadedModules = new Set<string>();
@@ -191,14 +192,14 @@ export async function executeCommand(
if (preNavUrl) {
const skip = await isAlreadyOnDomain(page, preNavUrl);
if (skip) {
if (debug) console.error(`[pre-nav] Already on target domain, skipping navigation`);
if (debug) log.debug('[pre-nav] Already on target domain, skipping navigation');
} else {
try {
// goto() already includes smart DOM-settle detection (waitForDomStable).
// No additional fixed sleep needed.
await page.goto(preNavUrl);
} catch (err) {
if (debug) console.error(`[pre-nav] Failed to navigate to ${preNavUrl}: ${err instanceof Error ? err.message : err}`);
if (debug) log.debug(`[pre-nav] Failed to navigate to ${preNavUrl}: ${err instanceof Error ? err.message : err}`);
}
}
}
+16
View File
@@ -21,3 +21,19 @@
tags: [docker, containers, devops]
install:
mac: "brew install --cask docker"
- name: lark-cli
binary: lark-cli
description: "Lark/Feishu CLI — messages, documents, spreadsheets, calendar, tasks and 200+ commands for AI agents"
homepage: "https://github.com/larksuite/cli"
tags: [lark, feishu, collaboration, productivity, ai-agent]
install:
default: "npm install -g @larksuite/cli"
- name: vercel
binary: vercel
description: "Vercel CLI — deploy projects, manage domains, env vars, logs and serverless functions"
homepage: "https://vercel.com/docs/cli"
tags: [vercel, deployment, serverless, frontend, devops]
install:
default: "npm install -g vercel"
+3 -3
View File
@@ -6,7 +6,7 @@ import { spawnSync, execFileSync } from 'node:child_process';
import yaml from 'js-yaml';
import chalk from 'chalk';
import { log } from './logger.js';
import { getErrorMessage } from './errors.js';
import { EXIT_CODES, getErrorMessage } from './errors.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -180,7 +180,7 @@ export function executeExternalCli(name: string, args: string[], preloaded?: Ext
// 2. Try to auto install
const success = installExternalCli(cli);
if (!success) {
process.exitCode = 1;
process.exitCode = EXIT_CODES.SERVICE_UNAVAIL;
return;
}
}
@@ -189,7 +189,7 @@ export function executeExternalCli(name: string, args: string[], preloaded?: Ext
const result = spawnSync(cli.binary, args, { stdio: 'inherit' });
if (result.error) {
console.error(chalk.red(`Failed to execute '${cli.binary}': ${result.error.message}`));
process.exitCode = 1;
process.exitCode = EXIT_CODES.GENERIC_ERROR;
return;
}
+5 -1
View File
@@ -20,7 +20,11 @@ import { discoverClis, discoverPlugins } from './discovery.js';
import { getCompletions } from './completion.js';
import { runCli } from './cli.js';
import { emitHook } from './hooks.js';
import { installNodeNetwork } from './node-network.js';
import { registerUpdateNoticeOnExit, checkForUpdateBackground } from './update-check.js';
import { EXIT_CODES } from './errors.js';
installNodeNetwork();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -54,7 +58,7 @@ if (getCompIdx !== -1) {
if (cursor === undefined) cursor = words.length;
const candidates = getCompletions(words, cursor);
process.stdout.write(candidates.join('\n') + '\n');
process.exit(0);
process.exit(EXIT_CODES.SUCCESS);
}
await emitHook('onStartup', { command: '__startup__', args: {} });
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest';
import { decideProxy, hasProxyEnv } from './node-network.js';
describe('node network proxy decisions', () => {
it('detects common proxy env variables', () => {
expect(hasProxyEnv({ https_proxy: 'http://127.0.0.1:7897' })).toBe(true);
expect(hasProxyEnv({ HTTP_PROXY: 'http://proxy.example:8080' })).toBe(true);
expect(hasProxyEnv({})).toBe(false);
});
it('routes external https traffic through https_proxy', () => {
const decision = decideProxy(
new URL('https://www.v2ex.com/api/topics/latest.json'),
{ https_proxy: 'http://127.0.0.1:7897' },
);
expect(decision).toEqual({
mode: 'proxy',
proxyUrl: 'http://127.0.0.1:7897',
});
});
it('falls back to HTTP_PROXY for https traffic when HTTPS_PROXY is absent', () => {
const decision = decideProxy(
new URL('https://www.v2ex.com/api/topics/latest.json'),
{ HTTP_PROXY: 'http://127.0.0.1:7897' },
);
expect(decision).toEqual({
mode: 'proxy',
proxyUrl: 'http://127.0.0.1:7897',
});
});
it('bypasses proxies for loopback addresses', () => {
const env = { https_proxy: 'http://127.0.0.1:7897', http_proxy: 'http://127.0.0.1:7897' };
expect(decideProxy(new URL('http://127.0.0.1:19825/status'), env)).toEqual({ mode: 'direct' });
expect(decideProxy(new URL('http://localhost:19825/status'), env)).toEqual({ mode: 'direct' });
expect(decideProxy(new URL('http://[::1]:19825/status'), env)).toEqual({ mode: 'direct' });
});
it('honors NO_PROXY domain matches', () => {
const decision = decideProxy(
new URL('https://api.example.com/v1/items'),
{
https_proxy: 'http://127.0.0.1:7897',
no_proxy: '.example.com',
},
);
expect(decision).toEqual({ mode: 'direct' });
});
it('supports wildcard-style NO_PROXY subdomain entries', () => {
const decision = decideProxy(
new URL('https://api.example.com/v1/items'),
{
https_proxy: 'http://127.0.0.1:7897',
no_proxy: '*.example.com',
},
);
expect(decision).toEqual({ mode: 'direct' });
});
it('matches NO_PROXY entries that rely on the default URL port', () => {
const env = { https_proxy: 'http://127.0.0.1:7897', http_proxy: 'http://127.0.0.1:7897' };
expect(decideProxy(
new URL('https://example.com/'),
{ ...env, NO_PROXY: 'example.com:443' },
)).toEqual({ mode: 'direct' });
expect(decideProxy(
new URL('http://example.com/health'),
{ ...env, NO_PROXY: 'example.com:80' },
)).toEqual({ mode: 'direct' });
});
it('falls back to ALL_PROXY when protocol-specific settings are absent', () => {
const decision = decideProxy(
new URL('http://example.net/data'),
{ ALL_PROXY: 'socks5://127.0.0.1:1080' },
);
expect(decision).toEqual({
mode: 'proxy',
proxyUrl: 'socks5://127.0.0.1:1080',
});
});
});
+213
View File
@@ -0,0 +1,213 @@
import { Agent, EnvHttpProxyAgent, fetch as undiciFetch, type Dispatcher } from 'undici';
const LOOPBACK_NO_PROXY_ENTRIES = ['127.0.0.1', 'localhost', '::1'];
type ProxyEnvKey =
| 'http_proxy'
| 'https_proxy'
| 'all_proxy'
| 'HTTP_PROXY'
| 'HTTPS_PROXY'
| 'ALL_PROXY';
const PROXY_ENV_BY_PROTOCOL: Record<'http:' | 'https:', ProxyEnvKey[]> = {
'http:': ['http_proxy', 'HTTP_PROXY', 'all_proxy', 'ALL_PROXY'],
'https:': ['https_proxy', 'HTTPS_PROXY', 'all_proxy', 'ALL_PROXY'],
};
const DEFAULT_PORT_BY_PROTOCOL: Record<'http:' | 'https:', string> = {
'http:': '80',
'https:': '443',
};
export interface ProxyDecision {
mode: 'direct' | 'proxy';
proxyUrl?: string;
}
interface NoProxyEntry {
host: string;
port?: string;
}
interface ProxyConfig {
httpProxy?: string;
httpsProxy?: string;
noProxy?: string;
noProxyEntries: NoProxyEntry[];
}
let installed = false;
const directDispatcher = new Agent();
const proxyDispatcherCache = new Map<string, Dispatcher>();
const nativeFetch = globalThis.fetch.bind(globalThis);
function readEnv(env: NodeJS.ProcessEnv, lower: string, upper: string): string | undefined {
const lowerValue = env[lower];
if (typeof lowerValue === 'string' && lowerValue.trim() !== '') return lowerValue;
const upperValue = env[upper];
if (typeof upperValue === 'string' && upperValue.trim() !== '') return upperValue;
return undefined;
}
function readProxyEnv(env: NodeJS.ProcessEnv, keys: ProxyEnvKey[]): string | undefined {
for (const key of keys) {
const value = env[key];
if (typeof value === 'string' && value.trim() !== '') return value;
}
return undefined;
}
function normalizeHostname(hostname: string): string {
return hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase();
}
function splitNoProxy(raw: string | undefined): string[] {
return (raw ?? '')
.split(/[,\s]+/)
.map((token) => token.trim())
.filter(Boolean);
}
function parseNoProxyEntry(entry: string): NoProxyEntry {
if (entry === '*') return { host: '*' };
const trimmed = entry.trim().replace(/^\*?\./, '');
if (trimmed.startsWith('[')) {
const end = trimmed.indexOf(']');
if (end !== -1) {
const host = trimmed.slice(1, end);
const rest = trimmed.slice(end + 1);
if (rest.startsWith(':')) return { host: normalizeHostname(host), port: rest.slice(1) };
return { host: normalizeHostname(host) };
}
}
const colonCount = (trimmed.match(/:/g) ?? []).length;
if (colonCount === 1) {
const [host, port] = trimmed.split(':');
return { host: normalizeHostname(host), port };
}
return { host: normalizeHostname(trimmed) };
}
function effectiveNoProxyEntries(env: NodeJS.ProcessEnv): NoProxyEntry[] {
const raw = readEnv(env, 'no_proxy', 'NO_PROXY');
const entries = splitNoProxy(raw).map(parseNoProxyEntry);
const seen = new Set(entries.map((entry) => `${entry.host}:${entry.port ?? ''}`));
for (const rawEntry of LOOPBACK_NO_PROXY_ENTRIES) {
const entry = parseNoProxyEntry(rawEntry);
const key = `${entry.host}:${entry.port ?? ''}`;
if (seen.has(key)) continue;
entries.push(entry);
seen.add(key);
}
return entries;
}
function serializeNoProxyEntry(entry: NoProxyEntry): string {
if (entry.host === '*') return '*';
const host = entry.host.includes(':') ? `[${entry.host}]` : entry.host;
return entry.port ? `${host}:${entry.port}` : host;
}
function effectiveNoProxyValue(entries: NoProxyEntry[]): string | undefined {
if (entries.length === 0) return undefined;
return entries.map(serializeNoProxyEntry).join(',');
}
function matchesNoProxyEntry(url: URL, entry: NoProxyEntry): boolean {
const { host, port } = entry;
if (host === '*') return true;
const hostname = normalizeHostname(url.hostname);
const urlPort = url.port || DEFAULT_PORT_BY_PROTOCOL[url.protocol as 'http:' | 'https:'] || undefined;
if (port && port !== urlPort) return false;
return hostname === host || hostname.endsWith(`.${host}`);
}
function resolveProxyConfig(env: NodeJS.ProcessEnv = process.env): ProxyConfig {
const noProxyEntries = effectiveNoProxyEntries(env);
return {
httpProxy: readProxyEnv(env, PROXY_ENV_BY_PROTOCOL['http:']),
httpsProxy: readProxyEnv(env, [
'https_proxy',
'HTTPS_PROXY',
'http_proxy',
'HTTP_PROXY',
'all_proxy',
'ALL_PROXY',
]),
noProxy: effectiveNoProxyValue(noProxyEntries),
noProxyEntries,
};
}
function createProxyDispatcher(config: ProxyConfig): Dispatcher {
const cacheKey = JSON.stringify([
config.httpProxy ?? '',
config.httpsProxy ?? '',
config.noProxy ?? '',
]);
const cached = proxyDispatcherCache.get(cacheKey);
if (cached) return cached;
const dispatcher = new EnvHttpProxyAgent({
httpProxy: config.httpProxy,
httpsProxy: config.httpsProxy,
noProxy: config.noProxy,
});
proxyDispatcherCache.set(cacheKey, dispatcher);
return dispatcher;
}
function resolveUrl(input: RequestInfo | URL): URL | null {
if (typeof input === 'string') return new URL(input);
if (input instanceof URL) return input;
if (typeof Request !== 'undefined' && input instanceof Request) return new URL(input.url);
return null;
}
export function hasProxyEnv(env: NodeJS.ProcessEnv = process.env): boolean {
const config = resolveProxyConfig(env);
return Boolean(config.httpProxy || config.httpsProxy);
}
export function decideProxy(url: URL, env: NodeJS.ProcessEnv = process.env): ProxyDecision {
const config = resolveProxyConfig(env);
if (config.noProxyEntries.some((entry) => matchesNoProxyEntry(url, entry))) {
return { mode: 'direct' };
}
const proxyUrl = url.protocol === 'https:' ? config.httpsProxy : config.httpProxy;
if (!proxyUrl) return { mode: 'direct' };
return { mode: 'proxy', proxyUrl };
}
export function getDispatcherForUrl(url: URL, env: NodeJS.ProcessEnv = process.env): Dispatcher {
const config = resolveProxyConfig(env);
if (!config.httpProxy && !config.httpsProxy) return directDispatcher;
return createProxyDispatcher(config);
}
export async function fetchWithNodeNetwork(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
const url = resolveUrl(input);
if (!url || !hasProxyEnv()) {
return nativeFetch(input, init);
}
return (await undiciFetch(input as Parameters<typeof undiciFetch>[0], {
...init,
dispatcher: getDispatcherForUrl(url),
} as Parameters<typeof undiciFetch>[1])) as unknown as Response;
}
export function installNodeNetwork(): void {
if (installed) return;
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => (
fetchWithNodeNetwork(input, init)
)) as typeof globalThis.fetch;
installed = true;
}
+1
View File
@@ -31,6 +31,7 @@ function createMockPage(overrides: Partial<IPage> = {}): IPage {
installInterceptor: vi.fn(),
getInterceptedRequests: vi.fn().mockResolvedValue([]),
screenshot: vi.fn().mockResolvedValue(''),
waitForCapture: vi.fn().mockResolvedValue(undefined),
...overrides,
};
}
+1
View File
@@ -44,6 +44,7 @@ function createMockPage(getCookies: IPage['getCookies']): IPage {
installInterceptor: vi.fn(),
getInterceptedRequests: vi.fn().mockResolvedValue([]),
screenshot: vi.fn().mockResolvedValue(''),
waitForCapture: vi.fn().mockResolvedValue(undefined),
};
}
+4 -5
View File
@@ -4,7 +4,6 @@
import type { IPage } from '../../types.js';
import { render, normalizeEvaluateSource } from '../template.js';
import { generateInterceptorJs, generateReadInterceptedJs } from '../../interceptor.js';
export async function stepIntercept(page: IPage | null, params: any, data: any, args: Record<string, any>): Promise<any> {
const cfg = typeof params === 'object' ? params : {};
@@ -16,7 +15,7 @@ export async function stepIntercept(page: IPage | null, params: any, data: any,
if (!capturePattern) return data;
// Step 1: Inject fetch/XHR interceptor BEFORE trigger
await page!.evaluate(generateInterceptorJs(JSON.stringify(capturePattern)));
await page!.installInterceptor(capturePattern);
// Step 2: Execute the trigger action
if (trigger.startsWith('navigate:')) {
@@ -32,11 +31,11 @@ export async function stepIntercept(page: IPage | null, params: any, data: any,
await page!.scroll('down');
}
// Step 3: Wait a bit for network requests to fire
await page!.wait(Math.min(timeout, 3));
// Step 3: Wait for network capture (event-driven, not fixed sleep)
await page!.waitForCapture(timeout);
// Step 4: Retrieve captured data
const matchingResponses = await page!.evaluate(generateReadInterceptedJs());
const matchingResponses = await page!.getInterceptedRequests();
// Step 5: Select from response if specified
let result = matchingResponses.length === 1 ? matchingResponses[0] :
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import type { CliCommand } from './registry.js';
import { Strategy } from './registry.js';
import { formatRegistryHelpText } from './serialization.js';
describe('formatRegistryHelpText', () => {
it('summarizes long choices lists so help text stays readable', () => {
const cmd: CliCommand = {
site: 'demo',
name: 'dynamic',
description: 'Demo command',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{
name: 'field',
help: 'Field to use',
choices: ['all-fields', 'topic', 'title', 'author', 'publication-titles', 'year-published', 'doi'],
},
],
columns: ['field'],
};
expect(formatRegistryHelpText(cmd)).toContain('--field: all-fields, topic, title, author, ... (+3 more)');
});
});
+6 -1
View File
@@ -62,6 +62,11 @@ export function formatArgSummary(args: Arg[]): string {
.join(' ');
}
function summarizeChoices(choices: string[]): string {
if (choices.length <= 4) return choices.join(', ');
return `${choices.slice(0, 4).join(', ')}, ... (+${choices.length - 4} more)`;
}
/** Generate the --help appendix showing registry metadata not exposed by Commander. */
export function formatRegistryHelpText(cmd: CliCommand): string {
const lines: string[] = [];
@@ -69,7 +74,7 @@ export function formatRegistryHelpText(cmd: CliCommand): string {
for (const a of choicesArgs) {
const prefix = a.positional ? `<${a.name}>` : `--${a.name}`;
const def = a.default != null ? ` (default: ${a.default})` : '';
lines.push(` ${prefix}: ${a.choices!.join(', ')}${def}`);
lines.push(` ${prefix}: ${summarizeChoices(a.choices!)}${def}`);
}
const meta: string[] = [];
meta.push(`Strategy: ${strategyLabel(cmd)}`);
+2 -1
View File
@@ -4,6 +4,7 @@
* Uses raw stdin mode + ANSI escape codes for interactive prompts.
*/
import chalk from 'chalk';
import { EXIT_CODES } from './errors.js';
export interface CheckboxItem {
label: string;
@@ -161,7 +162,7 @@ export async function checkboxPrompt(
// Ctrl+C — exit process
if (key === '\x03') {
cleanup();
process.exit(130);
process.exit(EXIT_CODES.INTERRUPTED);
}
}
+2
View File
@@ -26,6 +26,7 @@ export interface SnapshotOptions {
export interface WaitOptions {
text?: string;
selector?: string; // wait until document.querySelector(selector) matches
time?: number;
timeout?: number;
}
@@ -64,6 +65,7 @@ export interface IPage {
autoScroll(options?: { times?: number; delayMs?: number }): Promise<void>;
installInterceptor(pattern: string): Promise<void>;
getInterceptedRequests(): Promise<any[]>;
waitForCapture(timeout?: number): Promise<void>;
screenshot(options?: ScreenshotOptions): Promise<string>;
closeWindow?(): Promise<void>;
/** Returns the current page URL, or null if unavailable. */
+5
View File
@@ -31,6 +31,11 @@ export async function mapConcurrent<T, R>(
return results;
}
/** Pause for the given number of milliseconds. */
export function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/** Save a base64-encoded string to a file, creating parent directories as needed. */
export async function saveBase64ToFile(base64: string, filePath: string): Promise<void> {
const dir = path.dirname(filePath);
+1 -1
View File
@@ -101,6 +101,6 @@ describe('management commands E2E', () => {
// ── unknown command ──
it('unknown command shows error', async () => {
const { stderr, code } = await runCli(['nonexistent-command-xyz']);
expect(code).toBe(1);
expect(code).toBe(2);
});
});
+1 -1
View File
@@ -134,7 +134,7 @@ describe('plugin management E2E', () => {
it('plugin update without name or --all shows error', async () => {
const { stderr, code } = await runPluginCli(['plugin', 'update']);
expect(code).toBe(1);
expect(code).toBe(2);
expect(stderr).toContain('specify a plugin name');
});
});