Compare commits
125 Commits
self-extension
...
v1.0.5
| Author | SHA1 | Date | |
|---|---|---|---|
| fafa990acd | |||
| 3bde01aa1c | |||
| 152cc48091 | |||
| dff8f1e9c4 | |||
| 9d8b6441be | |||
| 1e0e4cd660 | |||
| 024d9908b3 | |||
| 943e286815 | |||
| 31f58ae699 | |||
| 812db29ed8 | |||
| 47a898125f | |||
| b60c69950d | |||
| ce38a1604e | |||
| d6e0aa120b | |||
| 44f0bbe94d | |||
| 0ea6e4a15c | |||
| ee35ee723f | |||
| 92fc13d60e | |||
| f5f7a9500e | |||
| 3229294f08 | |||
| f945b51f43 | |||
| e9a3ef7538 | |||
| 39b6413e47 | |||
| 33957ea0ba | |||
| 691f835bdf | |||
| 5b447e7a11 | |||
| d7bf5d6e04 | |||
| 390dbe7199 | |||
| 40846291e6 | |||
| f8dea7d8fc | |||
| 67474bb6db | |||
| d1986f0144 | |||
| ebc5c09ad9 | |||
| 055403abc1 | |||
| 10af754c89 | |||
| a7e5307226 | |||
| c21250bd88 | |||
| 0fa9573790 | |||
| 65e30b9ac4 | |||
| 540f3c677a | |||
| b5c1b242e2 | |||
| 2f6d28a3e9 | |||
| fde618063f | |||
| 89947fee50 | |||
| 2e962b2e7c | |||
| 13e2345089 | |||
| 4bf946edaf | |||
| 4398202b05 | |||
| b01bf6769b | |||
| 6d3e595d36 | |||
| edb21ca67b | |||
| 1f270397f6 | |||
| aa2f37be32 | |||
| aeb1cb6a3a | |||
| 4e260ecdeb | |||
| f7c7230854 | |||
| 48e277bd0b | |||
| 8bb03ecc9b | |||
| 3b6f72ca08 | |||
| beda0b714c | |||
| 3b33ade214 | |||
| ebe4683a9e | |||
| 59c0d639a5 | |||
| 3d1f9640ea | |||
| 8e8c4a0229 | |||
| 01b8b6b5bf | |||
| b2fa7daf57 | |||
| 0374b77d16 | |||
| c3efc5b492 | |||
| f85464c1aa | |||
| a4f94912cd | |||
| deb568dbe5 | |||
| 32619fa553 | |||
| 1d871b35f0 | |||
| 75e6ed4593 | |||
| b07434b2a1 | |||
| 515ce75f3b | |||
| f539a44cfd | |||
| 832370f6e2 | |||
| fc9fc32d14 | |||
| 43b753fa02 | |||
| be194a1849 | |||
| 63489fb596 | |||
| c370bd0582 | |||
| 8a355dfd2d | |||
| 700d970f13 | |||
| b1fda7da3b | |||
| 40a6a4cace | |||
| 920ca3f7e5 | |||
| 799a616359 | |||
| 9c2a983e8b | |||
| 3d1ea9b15c | |||
| e1d4a6e5e6 | |||
| a06cdbf0ac | |||
| e76de39f42 | |||
| 813631e468 | |||
| 2afbb99660 | |||
| aa55c88069 | |||
| b32fe1cbc3 | |||
| 981cc1bc5e | |||
| cd63231b7e | |||
| 685658f7bd | |||
| cd6f7a1f7e | |||
| 4ce0345c9a | |||
| 3cc2cb5504 | |||
| abac070ce4 | |||
| 79fbac844e | |||
| 7e776e2bd5 | |||
| bde1c53a3e | |||
| 5e667b9c2f | |||
| 64e3a2d627 | |||
| 849d9faea1 | |||
| 29ea5ce059 | |||
| 12c4b8853b | |||
| cfad003220 | |||
| abfd4b902c | |||
| 2d52abde7c | |||
| f102501e4a | |||
| 2a983b6b8d | |||
| c114a9d7f1 | |||
| d2e179ced5 | |||
| c806f795cc | |||
| de5495bdd7 | |||
| d6222ff932 | |||
| e0395ce5ed |
@@ -0,0 +1,83 @@
|
||||
name: "🐛 Bug Report"
|
||||
description: Report a bug or unexpected behavior in OpenCLI
|
||||
title: "[Bug]: "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report a bug. A short reproduction and any error output are usually enough.
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: A clear and concise description of the bug.
|
||||
placeholder: What happened?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: How can we reproduce this behavior?
|
||||
value: |
|
||||
1. Run `opencli ...`
|
||||
2. ...
|
||||
3. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: What did you expect to happen?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: OpenCLI Version
|
||||
description: "Run `opencli --version` to find out."
|
||||
placeholder: "0.8.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: node-version
|
||||
attributes:
|
||||
label: Node.js Version
|
||||
options:
|
||||
- "20.x"
|
||||
- "22.x"
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating System
|
||||
options:
|
||||
- macOS
|
||||
- Linux
|
||||
- Windows
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs / Screenshots
|
||||
description: |
|
||||
Paste any relevant error output. Run with `-v` for verbose logs:
|
||||
```
|
||||
opencli <command> -v
|
||||
```
|
||||
render: shell
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: 📖 Documentation
|
||||
url: https://github.com/jackwener/opencli#readme
|
||||
about: Check the README and docs before opening an issue.
|
||||
- name: 🧪 Testing Guide
|
||||
url: https://github.com/jackwener/opencli/blob/main/TESTING.md
|
||||
about: How to run and write tests for OpenCLI.
|
||||
@@ -0,0 +1,42 @@
|
||||
name: "✨ Feature Request"
|
||||
description: Suggest a new feature or improvement
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Have an idea to make OpenCLI better? We'd love to hear it!
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Feature Description
|
||||
description: A clear and concise description of the feature you'd like.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: use-case
|
||||
attributes:
|
||||
label: Use Case
|
||||
description: What problem does this solve? Who benefits from this feature?
|
||||
placeholder: "As a user, I want to ... so that ..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposed-solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: If you have a specific implementation in mind, describe it here.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Any alternative approaches you've thought about?
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,57 @@
|
||||
name: "🌐 New Site Adapter Request"
|
||||
description: Request support for a new website
|
||||
title: "[Site]: "
|
||||
labels: ["new-adapter"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Want OpenCLI to support a new site? Tell us about it!
|
||||
|
||||
- type: input
|
||||
id: site-name
|
||||
attributes:
|
||||
label: Site Name
|
||||
description: The name of the website.
|
||||
placeholder: "e.g. Product Hunt"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: site-url
|
||||
attributes:
|
||||
label: Site URL
|
||||
description: The main URL of the website.
|
||||
placeholder: "https://www.producthunt.com"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: commands
|
||||
attributes:
|
||||
label: Desired Commands
|
||||
description: What commands would you like? List them with a brief description.
|
||||
value: |
|
||||
- `hot` — trending / popular items
|
||||
- `search` — search the site
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: api-examples
|
||||
attributes:
|
||||
label: Example Links or API Endpoints
|
||||
description: Share any example page URLs or API endpoints if you have them (optional).
|
||||
placeholder: |
|
||||
Example page: https://www.producthunt.com/posts/example
|
||||
GET https://api.producthunt.com/v2/posts?order=votes
|
||||
Response: { "posts": [{ "name": "...", "tagline": "..." }] }
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: contribution
|
||||
attributes:
|
||||
label: Willing to Contribute?
|
||||
options:
|
||||
- label: I'm willing to submit a PR for this adapter
|
||||
@@ -0,0 +1,27 @@
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
# npm dependencies
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- "dependencies"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "ci"
|
||||
commit-message:
|
||||
prefix: "chore(ci)"
|
||||
@@ -0,0 +1,24 @@
|
||||
## Description
|
||||
|
||||
<!-- Briefly describe your changes and link to any related issues. -->
|
||||
|
||||
Related issue:
|
||||
|
||||
## Type of Change
|
||||
|
||||
- [ ] 🐛 Bug fix
|
||||
- [ ] ✨ New feature
|
||||
- [ ] 🌐 New site adapter
|
||||
- [ ] 📝 Documentation
|
||||
- [ ] ♻️ Refactor
|
||||
- [ ] 🔧 CI / build / tooling
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I ran the checks relevant to this PR
|
||||
- [ ] I updated tests or docs if needed
|
||||
- [ ] I included output or screenshots when useful
|
||||
|
||||
## Screenshots / Output
|
||||
|
||||
<!-- If applicable, paste CLI output or screenshots here. -->
|
||||
@@ -0,0 +1,80 @@
|
||||
name: Build Chrome Extension
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
tags: [ "v*.*.*" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
cache-dependency-path: extension/package-lock.json
|
||||
|
||||
- name: Install extension dependencies
|
||||
run: npm ci
|
||||
working-directory: extension
|
||||
|
||||
- name: Build extension
|
||||
run: npm run build
|
||||
working-directory: extension
|
||||
|
||||
- name: Prepare extension package
|
||||
run: |
|
||||
rm -rf extension-package
|
||||
mkdir -p extension-package
|
||||
cp extension/manifest.json extension-package/
|
||||
cp -R extension/dist extension-package/
|
||||
cp -R extension/icons extension-package/
|
||||
|
||||
- name: Create Extension ZIP
|
||||
run: |
|
||||
cd extension-package
|
||||
zip -r ../opencli-extension.zip .
|
||||
|
||||
- name: Create Extension CRX
|
||||
run: |
|
||||
npm install -g crx3
|
||||
if [ -n "${{ secrets.CRX_PRIVATE_KEY }}" ]; then
|
||||
echo "Found CRX_PRIVATE_KEY, signing extension..."
|
||||
echo "${{ secrets.CRX_PRIVATE_KEY }}" > crx-key.pem
|
||||
crx3 pack extension-package -o opencli-extension.crx -p crx-key.pem
|
||||
rm crx-key.pem
|
||||
else
|
||||
echo "No CRX_PRIVATE_KEY configured. Generating CRX with a temporary random key..."
|
||||
crx3 pack extension-package -o opencli-extension.crx
|
||||
fi
|
||||
|
||||
- name: Upload Artifacts (Action Run)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: opencli-extension-build
|
||||
path: |
|
||||
opencli-extension.zip
|
||||
opencli-extension.crx
|
||||
retention-days: 7
|
||||
|
||||
- name: Attach to GitHub Release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
opencli-extension.zip
|
||||
opencli-extension.crx
|
||||
draft: false
|
||||
prerelease: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -9,6 +9,10 @@ on:
|
||||
- cron: '0 8 * * 1' # Weekly Monday 08:00 UTC — smoke tests
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ── Fast gate: typecheck + build ──
|
||||
build:
|
||||
@@ -34,20 +38,22 @@ jobs:
|
||||
unit-test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node-version: ['20', '22']
|
||||
shard: [1, 2]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run unit tests (shard ${{ matrix.shard }}/2)
|
||||
- name: Run unit tests (Node ${{ matrix.node-version }}, shard ${{ matrix.shard }}/2)
|
||||
run: npx vitest run src/ --reporter=verbose --shard=${{ matrix.shard }}/2
|
||||
|
||||
# ── Smoke tests (scheduled / manual only) ──
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
name: Deploy Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['docs/**']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build docs
|
||||
run: npm run docs:build
|
||||
|
||||
- uses: actions/configure-pages@v4
|
||||
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: docs/.vitepress/dist
|
||||
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -7,6 +7,10 @@ on:
|
||||
branches: [main, dev]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: e2e-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
e2e-headed:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Publish Any Commit
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: ${{ vars.PKG_PR_NEW_ENABLED == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Publish to pkg.pr.new
|
||||
run: npx pkg-pr-new publish
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Release Please
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Ensure release-please token is configured
|
||||
run: |
|
||||
if [ -z "${{ secrets.RELEASE_PLEASE_TOKEN }}" ]; then
|
||||
echo "RELEASE_PLEASE_TOKEN secret is required so release PRs can trigger downstream CI workflows." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: googleapis/release-please-action@v4
|
||||
with:
|
||||
release-type: node
|
||||
token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
|
||||
@@ -26,9 +26,6 @@ jobs:
|
||||
- name: Type check
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
name: Security Audit
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
schedule:
|
||||
- cron: '0 9 * * 1' # Weekly Monday 09:00 UTC
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: security-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: npm audit (production)
|
||||
run: npm audit --omit=dev --audit-level=high
|
||||
|
||||
- name: Check for known vulnerabilities
|
||||
run: npx --yes audit-ci@^7 --high --skip-dev
|
||||
+10
@@ -1,6 +1,16 @@
|
||||
node_modules/
|
||||
dist/
|
||||
!extension/dist/
|
||||
*.tsbuildinfo
|
||||
.opencli/
|
||||
.mcp.json
|
||||
*.log
|
||||
|
||||
# VitePress
|
||||
docs/.vitepress/dist
|
||||
docs/.vitepress/cache
|
||||
|
||||
# Extensions & Secrets
|
||||
*.pem
|
||||
*.crx
|
||||
*.zip
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# Connecting OpenCLI via CDP (Remote/Headless Servers)
|
||||
|
||||
If you cannot use the opencli Browser Bridge extension (e.g., in a remote headless server environment without a UI), OpenCLI provides an alternative: connecting directly to Chrome via **CDP (Chrome DevTools Protocol)**.
|
||||
|
||||
Because CDP binds to `localhost` by default for security reasons, accessing it from a remote server requires an additional networking tunnel.
|
||||
|
||||
This guide is broken down into three phases:
|
||||
1. **Preparation**: Start Chrome with CDP enabled locally.
|
||||
2. **Network Tunnels**: Expose that CDP port to your remote server using either **SSH Tunnels** or **Reverse Proxies**.
|
||||
3. **Execution**: Run OpenCLI on your server.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Preparation (Local Machine)
|
||||
|
||||
First, you need to start a Chrome browser on your local machine with remote debugging enabled.
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
|
||||
--remote-debugging-port=9222 \
|
||||
--user-data-dir="$HOME/chrome-debug-profile" \
|
||||
--remote-allow-origins="*"
|
||||
```
|
||||
|
||||
**Linux:**
|
||||
```bash
|
||||
google-chrome \
|
||||
--remote-debugging-port=9222 \
|
||||
--user-data-dir="$HOME/chrome-debug-profile" \
|
||||
--remote-allow-origins="*"
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
```cmd
|
||||
"C:\Program Files\Google\Chrome\Application\chrome.exe" ^
|
||||
--remote-debugging-port=9222 ^
|
||||
--user-data-dir="%USERPROFILE%\chrome-debug-profile" ^
|
||||
--remote-allow-origins="*"
|
||||
```
|
||||
|
||||
> **Note**: The `--remote-allow-origins="*"` flag is often required for modern Chrome versions to accept cross-origin CDP WebSocket connections (e.g. from reverse proxies like ngrok).
|
||||
|
||||
Once this browser instance opens, **log into the target websites you want to use** (e.g., bilibili.com, zhihu.com) so that the session contains the correct cookies.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Remote Access Methods
|
||||
|
||||
Once CDP is running locally on port `9222`, you must securely expose this port to your remote server. Choose one of the two methods below depending on your network conditions.
|
||||
|
||||
### Method A: SSH Tunnel (Recommended)
|
||||
|
||||
If your local machine has SSH access to the remote server, this is the most secure and straightforward method.
|
||||
|
||||
Run this command on your **Local Machine** to forward the remote server's port `9222` back to your local port `9222`:
|
||||
|
||||
```bash
|
||||
ssh -R 9222:localhost:9222 your-server-user@your-server-ip
|
||||
```
|
||||
|
||||
Leave this SSH session running in the background.
|
||||
|
||||
### Method B: Reverse Proxy (ngrok / frp / socat)
|
||||
|
||||
If you cannot establish a direct SSH connection (e.g., due to NAT or firewalls), you can use an intranet penetration tool like `ngrok`.
|
||||
|
||||
Run this command on your **Local Machine** to expose your local port `9222` to the public internet securely via ngrok:
|
||||
|
||||
```bash
|
||||
ngrok http 9222
|
||||
```
|
||||
|
||||
This will print a forwarding URL, such as `https://abcdef.ngrok.app`. **Copy this URL**.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Execution (Remote Server)
|
||||
|
||||
Now switch to your **Remote Server** where OpenCLI is installed.
|
||||
|
||||
Depending on the network tunnel method you chose in Phase 2, set the `OPENCLI_CDP_ENDPOINT` environment variable and run your commands.
|
||||
|
||||
### If you used Method A (SSH Tunnel):
|
||||
|
||||
```bash
|
||||
export OPENCLI_CDP_ENDPOINT="http://localhost:9222"
|
||||
opencli doctor # Verify connection
|
||||
opencli bilibili hot --limit 5 # Test a command
|
||||
```
|
||||
|
||||
### If you used Method B (Reverse Proxy like ngrok):
|
||||
|
||||
```bash
|
||||
# Use the URL you copied from ngrok earlier
|
||||
export OPENCLI_CDP_ENDPOINT="https://abcdef.ngrok.app"
|
||||
opencli doctor # Verify connection
|
||||
opencli bilibili hot --limit 5 # Test a command
|
||||
```
|
||||
|
||||
> *Tip: If you provide a standard HTTP/HTTPS CDP endpoint, OpenCLI requests the `/json` target list and picks the most likely inspectable app/page target automatically. If multiple app targets exist, you can further narrow selection with `OPENCLI_CDP_TARGET` (for example `antigravity` or `codex`).*
|
||||
|
||||
If you plan to use this setup frequently, you can persist the environment variable by adding the `export` line to your `~/.bashrc` or `~/.zshrc` on the server.
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
# 通过 CDP 远程连接 OpenCLI (服务器/无头环境)
|
||||
|
||||
如果你无法使用 opencli Browser Bridge 浏览器扩展(例如:在无界面的远程服务器上运行 OpenCLI 时),OpenCLI 提供了备选方案:通过连接 **CDP (Chrome DevTools Protocol,即 Chrome 开发者工具协议)** 来直接控制本地 Chrome。
|
||||
|
||||
出于安全考虑,CDP 默认仅绑定在 `localhost` 的本地端口。所以,若是想让**远程服务器**调用本地的 CDP 服务,我们需要依靠一层额外的网络隧道。
|
||||
|
||||
本指南将整个过程拆分为三个阶段:
|
||||
1. **阶段一:准备工作**(在本地启动允许 CDP 调试的 Chrome)。
|
||||
2. **阶段二:建立网络隧道**(通过 **SSH反向隧道** 或 **反向代理工具**,将本地的 CDP 端口暴露给服务器)。
|
||||
3. **阶段三:执行命令**(在服务器端运行 OpenCLI)。
|
||||
|
||||
---
|
||||
|
||||
## 阶段一:准备工作 (本地电脑)
|
||||
|
||||
首先,你需要在你的本地电脑上,通过命令行参数启动一个开启了远程调试端口的 Chrome 实例。
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
|
||||
--remote-debugging-port=9222 \
|
||||
--user-data-dir="$HOME/chrome-debug-profile" \
|
||||
--remote-allow-origins="*"
|
||||
```
|
||||
|
||||
**Linux:**
|
||||
```bash
|
||||
google-chrome \
|
||||
--remote-debugging-port=9222 \
|
||||
--user-data-dir="$HOME/chrome-debug-profile" \
|
||||
--remote-allow-origins="*"
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
```cmd
|
||||
"C:\Program Files\Google\Chrome\Application\chrome.exe" ^
|
||||
--remote-debugging-port=9222 ^
|
||||
--user-data-dir="%USERPROFILE%\chrome-debug-profile" ^
|
||||
--remote-allow-origins="*"
|
||||
```
|
||||
|
||||
> **注意**:此处增加的 `--remote-allow-origins="*"` 参数对于较新版本的 Chrome 来说通常是[必需的],以允许来自反向代理(如 ngrok)的跨域 WebSocket 连接请求。
|
||||
|
||||
待这个新的浏览器实例打开后,**手工登录那些你打算使用的网站**(如 bilibili.com、zhihu.com 等),这可以让该浏览器的运行资料(Profile)保留上这些网站登录用的 Cookie。
|
||||
|
||||
---
|
||||
|
||||
## 阶段二:建立网络隧道
|
||||
|
||||
现在你的本地已经有了一个监听在 `9222` 端口的 CDP 服务,接下来,选择以下任意一种方式将其实际暴露给你的远端服务器。
|
||||
|
||||
### 方法 A:SSH 反向端口转发 (推荐)
|
||||
|
||||
如果你的本地电脑可以直连远程服务器的 SSH,那么这是最简单且最安全的做法。
|
||||
|
||||
在你的 **本地电脑** 终端上直接运行这条 ssh 命令,将远程服务器的 `9222` 端口反向映射回本地的 `9222` 端口:
|
||||
|
||||
```bash
|
||||
ssh -R 9222:localhost:9222 your-server-user@your-server-ip
|
||||
```
|
||||
|
||||
保持此 SSH 会话在后台运行即可。
|
||||
|
||||
### 方法 B:反向代理 / 内网穿透 (ngrok / frp / socat)
|
||||
|
||||
如果因为 NAT 或防火墙等因素导致无法直连 SSH 服务器,你可以使用 `ngrok` 等内网穿透工具。
|
||||
|
||||
在 **本地电脑** 运行 ngrok 将本地的 `9222` 端口暴露到公网:
|
||||
|
||||
```bash
|
||||
ngrok http 9222
|
||||
```
|
||||
|
||||
此时终端里会打印出一段专属的转发 URL 地址(如:`https://abcdef.ngrok.app`)。**复制这一段 URL 地址备用**。
|
||||
|
||||
---
|
||||
|
||||
## 阶段三:执行命令 (远程服务器)
|
||||
|
||||
现在,所有的准备工作已结束。请切换到你已安装好 OpenCLI 的 **远程服务器** 终端上。
|
||||
|
||||
根据你在上方阶段二所选择的隧道方案,在终端中配置对应的 `OPENCLI_CDP_ENDPOINT` 环境变量:
|
||||
|
||||
### 若使用 方法 A (SSH 反向隧道):
|
||||
|
||||
```bash
|
||||
export OPENCLI_CDP_ENDPOINT="http://localhost:9222"
|
||||
opencli doctor # 查看并验证连接是否通畅
|
||||
opencli bilibili hot --limit 5 # 执行目标命令
|
||||
```
|
||||
|
||||
### 若使用 方法 B (Ngrok 等反向代理):
|
||||
|
||||
```bash
|
||||
# 将刚刚使用 ngrok 得到的地址填入这里
|
||||
export OPENCLI_CDP_ENDPOINT="https://abcdef.ngrok.app"
|
||||
opencli doctor # 查看并验证连接是否通畅
|
||||
opencli bilibili hot --limit 5 # 执行目标命令
|
||||
```
|
||||
|
||||
> *Tip: 如果你填写的是一个普通 HTTP/HTTPS 的 CDP 地址,OpenCLI 会自动请求 `/json` target 列表,并挑选最可能的 app/page target;如果同一个端口下暴露了多个应用 target,还可以通过 `OPENCLI_CDP_TARGET`(例如 `antigravity`、`codex`)进一步缩小匹配范围。*
|
||||
|
||||
如果你想在此服务器上永久启用该配置,可以将对应的 `export` 语句追加进入你的 `~/.bashrc` 或 `~/.zshrc` 配置文件中。
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
---
|
||||
description: How to CLI-ify and automate any Electron Desktop Application via CDP
|
||||
---
|
||||
|
||||
# CLI-ifying Electron Applications (Skill Guide)
|
||||
|
||||
Based on the successful automation of **Cursor**, **Codex**, **Antigravity**, **ChatWise**, **Notion**, and **Discord** desktop apps, this guide serves as the standard operating procedure (SOP) for adapting ANY Electron-based application into an OpenCLI adapter.
|
||||
|
||||
## Core Concept
|
||||
|
||||
Electron apps are essentially local Chromium browser instances. By exposing a debugging port (CDP — Chrome DevTools Protocol) at launch time, we can use the Browser Bridge to pierce through the UI layer, accessing and controlling all underlying state including React/Vue components and Shadow DOM.
|
||||
|
||||
> **Note:** Not all desktop apps are Electron. WeChat (native Cocoa) and Feishu/Lark (custom Lark Framework) embed Chromium but do NOT expose CDP. For those apps, use the AppleScript + clipboard approach instead (see [Non-Electron Pattern](#non-electron-pattern-applescript)).
|
||||
|
||||
### Launching the Target App
|
||||
```bash
|
||||
/Applications/AppName.app/Contents/MacOS/AppName --remote-debugging-port=9222
|
||||
```
|
||||
|
||||
### Verifying Electron
|
||||
```bash
|
||||
# Check for Electron Framework in the app bundle
|
||||
ls /Applications/AppName.app/Contents/Frameworks/Electron\ Framework.framework
|
||||
# If this directory exists → Electron → CDP works
|
||||
# If not → check for libEGL.dylib (embedded Chromium/CEF, CDP may not work)
|
||||
```
|
||||
|
||||
## The 5-Command Pattern (CDP / Electron)
|
||||
|
||||
Every new Electron adapter should implement these 5 commands in `src/clis/<app_name>/`:
|
||||
|
||||
### 1. `status.ts` — Connection Test
|
||||
```typescript
|
||||
export const statusCommand = cli({
|
||||
site: 'myapp',
|
||||
name: 'status',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true, // Requires CDP connection
|
||||
args: [],
|
||||
columns: ['Status', 'Url', 'Title'],
|
||||
func: async (page: IPage) => {
|
||||
const url = await page.evaluate('window.location.href');
|
||||
const title = await page.evaluate('document.title');
|
||||
return [{ Status: 'Connected', Url: url, Title: title }];
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 2. `dump.ts` — Reverse Engineering Core
|
||||
Modern app DOMs are huge and obfuscated. **Never guess selectors.** Dump first, then extract precise class names with AI or `grep`:
|
||||
```typescript
|
||||
const dom = await page.evaluate('document.body.innerHTML');
|
||||
fs.writeFileSync('/tmp/app-dom.html', dom);
|
||||
const snap = await page.snapshot({ interactive: false });
|
||||
fs.writeFileSync('/tmp/app-snapshot.json', JSON.stringify(snap, null, 2));
|
||||
```
|
||||
|
||||
### 3. `send.ts` — Advanced Text Injection
|
||||
Electron apps often use complex rich-text editors (Monaco, Lexical, ProseMirror). Setting `.value` directly is ignored by React state.
|
||||
|
||||
**Best practice:** Use `document.execCommand('insertText')` to perfectly simulate real user input, fully piercing React state:
|
||||
```javascript
|
||||
const composer = document.querySelector('[contenteditable="true"]');
|
||||
composer.focus();
|
||||
document.execCommand('insertText', false, 'Hello');
|
||||
```
|
||||
Then submit with `await page.pressKey('Enter')`.
|
||||
|
||||
### 4. `read.ts` — Context Extraction
|
||||
Don't extract the entire page text. Use `dump.ts` output to find the real "conversation container":
|
||||
- Look for semantic selectors: `[role="log"]`, `[data-testid="conversation"]`, `[data-content-search-turn-key]`
|
||||
- Format output as Markdown — readable by both humans and LLMs
|
||||
|
||||
### 5. `new.ts` — Keyboard Shortcuts
|
||||
Many GUI actions respond to native shortcuts rather than button clicks:
|
||||
```typescript
|
||||
const isMac = process.platform === 'darwin';
|
||||
await page.pressKey(isMac ? 'Meta+N' : 'Control+N');
|
||||
await page.wait(1); // Wait for re-render
|
||||
```
|
||||
|
||||
## Environment Variable
|
||||
```bash
|
||||
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9222"
|
||||
```
|
||||
|
||||
## Non-Electron Pattern (AppleScript)
|
||||
|
||||
For native macOS apps (WeChat, Feishu) that don't expose CDP:
|
||||
```typescript
|
||||
export const statusCommand = cli({
|
||||
site: 'myapp',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false, // No browser needed
|
||||
func: async (page: IPage | null) => {
|
||||
const output = execSync("osascript -e 'application \"MyApp\" is running'", { encoding: 'utf-8' }).trim();
|
||||
return [{ Status: output === 'true' ? 'Running' : 'Stopped' }];
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Core techniques:
|
||||
- **status**: `osascript -e 'application "AppName" is running'`
|
||||
- **send**: `pbcopy` → activate window → `Cmd+V` → `Enter`
|
||||
- **read**: `Cmd+A` → `Cmd+C` → `pbpaste`
|
||||
- **search**: Activate → `Cmd+F`/`Cmd+K` → `keystroke "query"`
|
||||
|
||||
## Pitfalls & Gotchas
|
||||
|
||||
1. **Port conflicts (EADDRINUSE)**: Only one app per port. Use unique ports: Codex=9222, ChatGPT=9224, Cursor=9226, ChatWise=9228, Notion=9230, Discord=9232
|
||||
2. **IPage abstraction**: OpenCLI wraps the browser page as `IPage` (`src/types.ts`). Use `page.pressKey()` and `page.evaluate()`, NOT direct DOM APIs
|
||||
3. **Timing**: Always add `await page.wait(0.5)` to `1.0` after DOM mutations. Returning too early disconnects prematurely
|
||||
4. **AppleScript requires Accessibility**: Terminal app must be granted permission in System Settings → Privacy & Security → Accessibility
|
||||
|
||||
## Port Assignment Table
|
||||
|
||||
| App | Port | Mode |
|
||||
|-----|------|------|
|
||||
| Codex | 9222 | CDP |
|
||||
| ChatGPT | 9224 | CDP / AppleScript |
|
||||
| Cursor | 9226 | CDP |
|
||||
| ChatWise | 9228 | CDP |
|
||||
| Notion | 9230 | CDP |
|
||||
| Discord App | 9232 | CDP |
|
||||
+4
-4
@@ -9,12 +9,12 @@
|
||||
|
||||
---
|
||||
|
||||
## AI Agent 开发者必读:用 Playwright MCP Bridge 探索
|
||||
## AI Agent 开发者必读:用浏览器探索
|
||||
|
||||
> [!CAUTION]
|
||||
> **你(AI Agent)必须通过 Playwright MCP Bridge 打开浏览器去访问目标网站!**
|
||||
> **你(AI Agent)必须通过浏览器打开目标网站去探索!**
|
||||
> 不要只靠 `opencli explore` 命令或静态分析来发现 API。
|
||||
> 你拥有 Playwright MCP 工具,必须主动用它们浏览网页、观察网络请求、模拟用户交互。
|
||||
> 你拥有浏览器工具,必须主动用它们浏览网页、观察网络请求、模拟用户交互。
|
||||
|
||||
### 为什么?
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
| ❌ 错误做法 | ✅ 正确做法 |
|
||||
|------------|------------|
|
||||
| 只用 `opencli explore` 命令,等结果自动出来 | 用 MCP Bridge 打开浏览器,主动浏览页面 |
|
||||
| 只用 `opencli explore` 命令,等结果自动出来 | 用浏览器工具打开页面,主动浏览 |
|
||||
| 直接在代码里 `fetch(url)`,不看浏览器实际请求 | 先在浏览器中确认 API 可用,再写代码 |
|
||||
| 页面打开后直接抓包,期望所有 API 都出现 | 模拟点击交互(展开评论/切换标签/加载更多) |
|
||||
| 遇到 HTTP 200 但空数据就放弃 | 检查是否需要 Wbi 签名或 Cookie 鉴权 |
|
||||
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
# Contributing to OpenCLI
|
||||
|
||||
Thanks for your interest in contributing to OpenCLI.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Fork & clone
|
||||
git clone git@github.com:<your-username>/opencli.git
|
||||
cd opencli
|
||||
|
||||
# 2. Install dependencies
|
||||
npm install
|
||||
|
||||
# 3. Build
|
||||
npm run build
|
||||
|
||||
# 4. Run a few checks
|
||||
npx tsc --noEmit
|
||||
npx vitest run src/
|
||||
|
||||
# 5. Link globally (optional, for testing `opencli` command)
|
||||
npm link
|
||||
```
|
||||
|
||||
## Adding a New Site Adapter
|
||||
|
||||
This is the most common type of contribution. Start with YAML when possible, and use TypeScript only when you need browser-side logic or multi-step flows.
|
||||
|
||||
### YAML Adapter (Recommended for data-fetching commands)
|
||||
|
||||
Create a file like `src/clis/<site>/<command>.yaml`:
|
||||
|
||||
```yaml
|
||||
site: mysite
|
||||
name: trending
|
||||
description: Trending posts on MySite
|
||||
domain: www.mysite.com
|
||||
strategy: public # public | cookie | header
|
||||
browser: false # true if browser session is needed
|
||||
|
||||
args:
|
||||
limit:
|
||||
type: int
|
||||
default: 20
|
||||
description: Number of items
|
||||
|
||||
pipeline:
|
||||
- fetch:
|
||||
url: https://api.mysite.com/trending
|
||||
|
||||
- map:
|
||||
rank: ${{ index + 1 }}
|
||||
title: ${{ item.title }}
|
||||
score: ${{ item.score }}
|
||||
url: ${{ item.url }}
|
||||
|
||||
- limit: ${{ args.limit }}
|
||||
|
||||
columns: [rank, title, score, url]
|
||||
```
|
||||
|
||||
See [`hackernews/top.yaml`](src/clis/hackernews/top.yaml) for a real example.
|
||||
|
||||
### TypeScript Adapter (For complex browser interactions)
|
||||
|
||||
Create a file like `src/clis/<site>/<command>.ts`:
|
||||
|
||||
```typescript
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
cli({
|
||||
site: 'mysite',
|
||||
name: 'search',
|
||||
description: 'Search MySite',
|
||||
domain: 'www.mysite.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
args: [
|
||||
{ name: 'query', required: true, help: 'Search query' },
|
||||
{ name: 'limit', type: 'int', default: 10, help: 'Max results' },
|
||||
],
|
||||
columns: ['title', 'url', 'date'],
|
||||
|
||||
func: async (page, kwargs) => {
|
||||
const { query, limit = 10 } = kwargs;
|
||||
await page.goto('https://www.mysite.com');
|
||||
|
||||
const data = await page.evaluate(`
|
||||
(async () => {
|
||||
const res = await fetch('/api/search?q=${encodeURIComponent(query)}', {
|
||||
credentials: 'include'
|
||||
});
|
||||
return (await res.json()).results;
|
||||
})()
|
||||
`);
|
||||
|
||||
return data.slice(0, Number(limit)).map((item: any) => ({
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
date: item.created_at,
|
||||
}));
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Use `opencli explore <url>` to discover APIs and see [CLI-EXPLORER.md](./CLI-EXPLORER.md) if you need the full adapter workflow.
|
||||
|
||||
### Validate Your Adapter
|
||||
|
||||
```bash
|
||||
# Validate YAML syntax and schema
|
||||
opencli validate
|
||||
|
||||
# Test your command
|
||||
opencli <site> <command> --limit 3 -f json
|
||||
|
||||
# Verbose mode for debugging
|
||||
opencli <site> <command> -v
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
See [TESTING.md](./TESTING.md) for the full guide and exact test locations.
|
||||
|
||||
```bash
|
||||
npx vitest run src/ # Unit tests
|
||||
npx vitest run tests/e2e/ # E2E tests
|
||||
npx vitest run # All tests
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
- **TypeScript strict mode** — avoid `any` where possible.
|
||||
- **ES Modules** — use `.js` extensions in imports (TypeScript output).
|
||||
- **Naming**: `kebab-case` for files, `camelCase` for variables/functions, `PascalCase` for types/classes.
|
||||
- **No default exports** — use named exports.
|
||||
|
||||
## Commit Convention
|
||||
|
||||
We use [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
feat(twitter): add thread command
|
||||
fix(browser): handle CDP timeout gracefully
|
||||
docs: update CONTRIBUTING.md
|
||||
test(reddit): add e2e test for save command
|
||||
chore: bump vitest to v4
|
||||
```
|
||||
|
||||
Common scopes: site name (`twitter`, `reddit`) or module name (`browser`, `pipeline`, `engine`).
|
||||
|
||||
## Submitting a Pull Request
|
||||
|
||||
1. Create a feature branch: `git checkout -b feat/mysite-trending`
|
||||
2. Make your changes and add tests when relevant
|
||||
3. Run the checks that apply:
|
||||
```bash
|
||||
npx tsc --noEmit # Type check
|
||||
npx vitest run src/ # Unit tests
|
||||
opencli validate # YAML validation (if applicable)
|
||||
```
|
||||
4. Commit using conventional commit format
|
||||
5. Push and open a PR
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the [Apache-2.0 License](./LICENSE).
|
||||
@@ -1,7 +1,7 @@
|
||||
# OpenCLI
|
||||
|
||||
> **Make any website your CLI.**
|
||||
> Zero risk · Reuse Chrome login · AI-powered discovery · 80+ commands · 19 sites
|
||||
> **Make any website or Electron App your CLI.**
|
||||
> Zero risk · Reuse Chrome login · AI-powered discovery · Browser + Desktop automation
|
||||
|
||||
[中文文档](./README.zh-CN.md)
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
[](https://nodejs.org)
|
||||
[](./LICENSE)
|
||||
|
||||
A CLI tool that turns **any website** into a command-line interface — Bilibili, Zhihu, 小红书, Twitter/X, Reddit, YouTube, and [many more](#built-in-commands) — powered by browser session reuse and AI-native discovery.
|
||||
A CLI tool that turns **any website** or **Electron app** into a command-line interface — Bilibili, Zhihu, 小红书, Twitter/X, Reddit, YouTube, Antigravity, and [many more](#built-in-commands) — powered by browser session reuse and AI-native discovery.
|
||||
|
||||
🔥 **CLI All Electron Apps! The Most Powerful Update Has Arrived!** 🔥
|
||||
Turn ANY Electron application into a CLI tool! Recombine, script, and extend applications like Antigravity Ultra seamlessly. Now AI can control itself natively. Unlimited possibilities await!
|
||||
|
||||
---
|
||||
|
||||
@@ -19,8 +22,11 @@ A CLI tool that turns **any website** into a command-line interface — Bilibili
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Built-in Commands](#built-in-commands)
|
||||
- [Desktop App Adapters](#desktop-app-adapters)
|
||||
- [Download Support](#download-support)
|
||||
- [Output Formats](#output-formats)
|
||||
- [For AI Agents (Developer Guide)](#for-ai-agents-developer-guide)
|
||||
- [Remote Chrome (Server/Headless)](#remote-chrome-serverheadless)
|
||||
- [Testing](#testing)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Releasing New Versions](#releasing-new-versions)
|
||||
@@ -30,78 +36,49 @@ A CLI tool that turns **any website** into a command-line interface — Bilibili
|
||||
|
||||
## Highlights
|
||||
|
||||
- **CLI All Electron** — CLI-ify apps like Antigravity Ultra! Now AI can control itself natively using cc/openclaw!
|
||||
- **Account-safe** — Reuses Chrome's logged-in state; your credentials never leave the browser.
|
||||
- **AI Agent ready** — `explore` discovers APIs, `synthesize` generates adapters, `cascade` finds auth strategies.
|
||||
- **Self-healing setup** — `opencli setup` auto-discovers tokens; `opencli doctor` diagnoses config across 10+ tools; `--fix` repairs them all.
|
||||
- **Self-healing setup** — `opencli setup` verifies Browser Bridge connectivity; `opencli doctor` diagnoses daemon, extension, and live browser connectivity.
|
||||
- **Dynamic Loader** — Simply drop `.ts` or `.yaml` adapters into the `clis/` folder for auto-registration.
|
||||
- **Dual-Engine Architecture** — Supports both YAML declarative data pipelines and robust browser runtime TypeScript injections.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js**: >= 18.0.0
|
||||
- **Node.js**: >= 20.0.0
|
||||
- **Chrome** running **and logged into the target site** (e.g. bilibili.com, zhihu.com, xiaohongshu.com).
|
||||
|
||||
> **⚠️ Important**: Browser commands reuse your Chrome login session. You must be logged into the target website in Chrome before running commands. If you get empty data or errors, check your login status first.
|
||||
|
||||
OpenCLI connects to your browser through the Playwright MCP Bridge extension.
|
||||
OpenCLI connects to your browser through a lightweight **Browser Bridge** Chrome Extension + micro-daemon (zero config, auto-start).
|
||||
|
||||
### Playwright MCP Bridge Extension Setup
|
||||
### Browser Bridge Extension Setup
|
||||
|
||||
1. Install **[Playwright MCP Bridge](https://chromewebstore.google.com/detail/playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm)** extension in Chrome.
|
||||
2. Run `opencli setup` — discovers the token, distributes it to your tools, and verifies connectivity:
|
||||
You can install the extension via either method:
|
||||
|
||||
```bash
|
||||
opencli setup
|
||||
```
|
||||
**Method 1: Download Pre-built Release (Recommended)**
|
||||
1. Go to the GitHub [Releases page](https://github.com/jackwener/opencli/releases) and download the latest `opencli-extension.zip` or `opencli-extension.crx`.
|
||||
2. Open `chrome://extensions` and enable **Developer mode** (top-right toggle).
|
||||
3. Drag and drop the `.crx` file or the unzipped folder into the extensions page.
|
||||
|
||||
The interactive TUI will:
|
||||
- 🔍 Auto-discover `PLAYWRIGHT_MCP_EXTENSION_TOKEN` from Chrome (no manual copy needed)
|
||||
- ☑️ Show all detected tools (Codex, Cursor, Claude Code, Gemini CLI, etc.)
|
||||
- ✏️ Update only the files you select (Space to toggle, Enter to confirm)
|
||||
- 🔌 Auto-verify browser connectivity after writing configs
|
||||
**Method 2: Load Unpacked Source (For Developers)**
|
||||
1. Open `chrome://extensions` and enable **Developer mode**.
|
||||
2. Click **Load unpacked** and select the `extension/` directory from this repository.
|
||||
|
||||
> **Tip**: Use `opencli doctor` for ongoing diagnosis and maintenance:
|
||||
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 # Read-only token & config diagnosis
|
||||
> opencli doctor --live # Also test live browser connectivity
|
||||
> opencli doctor --fix # Fix mismatched configs (interactive)
|
||||
> opencli doctor --fix -y # Fix all configs non-interactively
|
||||
> opencli doctor # Check extension + daemon connectivity
|
||||
> opencli doctor --live # Also test live browser commands
|
||||
> ```
|
||||
|
||||
<details>
|
||||
<summary>Manual setup (alternative)</summary>
|
||||
|
||||
Add token to your MCP client config (e.g. Claude/Cursor):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@playwright/mcp@latest", "--extension"],
|
||||
"env": {
|
||||
"PLAYWRIGHT_MCP_EXTENSION_TOKEN": "<your-token-here>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Export in shell (e.g. `~/.zshrc`):
|
||||
|
||||
```bash
|
||||
export PLAYWRIGHT_MCP_EXTENSION_TOKEN="<your-token-here>"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Install via npm (recommended)
|
||||
|
||||
```bash
|
||||
npm install -g @jackwener/opencli
|
||||
opencli setup # One-time: configure Playwright MCP token
|
||||
```
|
||||
|
||||
Then use directly:
|
||||
@@ -134,29 +111,117 @@ npm install -g @jackwener/opencli@latest
|
||||
|
||||
## Built-in Commands
|
||||
|
||||
**19 sites · 80+ commands** — run `opencli list` for the live registry.
|
||||
Run `opencli list` for the live registry.
|
||||
|
||||
| Site | Commands | Count | Mode |
|
||||
|------|----------|:-----:|------|
|
||||
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` | 18 | 🔐 Browser |
|
||||
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 15 | 🔐 Browser |
|
||||
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` | 11 | 🔐 Browser |
|
||||
| **v2ex** | `hot` `latest` `topic` `daily` `me` `notifications` | 6 | 🌐 / 🔐 |
|
||||
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 6 | 🔐 Browser |
|
||||
| **xiaohongshu** | `search` `notifications` `feed` `me` `user` | 5 | 🔐 Browser |
|
||||
| **youtube** | `search` `video` `transcript` | 3 | 🔐 Browser |
|
||||
| **zhihu** | `hot` `search` `question` | 3 | 🔐 Browser |
|
||||
| **boss** | `search` `detail` | 2 | 🔐 Browser |
|
||||
| **coupang** | `search` `add-to-cart` | 2 | 🔐 Browser |
|
||||
| **bbc** | `news` | 1 | 🌐 Public |
|
||||
| **ctrip** | `search` | 1 | 🔐 Browser |
|
||||
| **github** | `search` | 1 | 🌐 Public |
|
||||
| **hackernews** | `top` | 1 | 🌐 Public |
|
||||
| **linkedin** | `search` | 1 | 🔐 Browser |
|
||||
| **reuters** | `search` | 1 | 🔐 Browser |
|
||||
| **smzdm** | `search` | 1 | 🔐 Browser |
|
||||
| **weibo** | `hot` | 1 | 🔐 Browser |
|
||||
| **yahoo-finance** | `quote` | 1 | 🔐 Browser |
|
||||
| Site | Commands | Mode |
|
||||
|------|----------|------|
|
||||
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` | Browser |
|
||||
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | Browser |
|
||||
| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | Desktop |
|
||||
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | Browser |
|
||||
| **codex** | `status` `send` `read` `new` `extract-diff` `model` `ask` `screenshot` `history` `export` | Desktop |
|
||||
| **chatwise** | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` | Desktop |
|
||||
| **notion** | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` | Desktop |
|
||||
| **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` | Desktop |
|
||||
| **v2ex** | `hot` `latest` `topic` `daily` `me` `notifications` | Public / Browser |
|
||||
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | Browser |
|
||||
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | Desktop |
|
||||
| **chatgpt** | `status` `new` `send` `read` `ask` | Desktop |
|
||||
| **xiaohongshu** | `search` `notifications` `feed` `me` `user` `download` | Browser |
|
||||
| **apple-podcasts** | `search` `episodes` `top` | Public |
|
||||
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | Public |
|
||||
| **zhihu** | `hot` `search` `question` `download` | Browser |
|
||||
| **youtube** | `search` `video` `transcript` | Browser |
|
||||
| **boss** | `search` `detail` | Browser |
|
||||
| **coupang** | `search` `add-to-cart` | Browser |
|
||||
| **bbc** | `news` | Public |
|
||||
| **ctrip** | `search` | Browser |
|
||||
| **github** | `search` | Public |
|
||||
| **hackernews** | `top` | Public |
|
||||
| **linkedin** | `search` | Browser |
|
||||
| **reuters** | `search` | Browser |
|
||||
| **smzdm** | `search` | Browser |
|
||||
| **weibo** | `hot` | Browser |
|
||||
| **yahoo-finance** | `quote` | Browser |
|
||||
|
||||
### Desktop App Adapters
|
||||
|
||||
Each desktop adapter has its own detailed documentation with commands reference, setup guide, and examples:
|
||||
|
||||
| App | Description | Doc |
|
||||
|-----|-------------|-----|
|
||||
| **Cursor** | Control Cursor IDE — Composer, chat, code extraction | [README](./src/clis/cursor/README.md) |
|
||||
| **Codex** | Drive OpenAI Codex CLI agent headlessly | [README](./src/clis/codex/README.md) |
|
||||
| **Antigravity** | Control Antigravity Ultra from terminal | [README](./src/clis/antigravity/README.md) |
|
||||
| **ChatGPT** | Automate ChatGPT macOS desktop app | [README](./src/clis/chatgpt/README.md) |
|
||||
| **ChatWise** | Multi-LLM client (GPT-4, Claude, Gemini) | [README](./src/clis/chatwise/README.md) |
|
||||
| **Notion** | Search, read, write Notion pages | [README](./src/clis/notion/README.md) |
|
||||
| **Discord** | Discord Desktop — messages, channels, servers | [README](./src/clis/discord-app/README.md) |
|
||||
| **Feishu** | 飞书/Lark Desktop via AppleScript | [README](./src/clis/feishu/README.md) |
|
||||
| **WeChat** | 微信 Desktop via AppleScript + Accessibility | [README](./src/clis/wechat/README.md) |
|
||||
| **NeteaseMusic** | 网易云音乐 Desktop via CEF/CDP | [README](./src/clis/neteasemusic/README.md) |
|
||||
|
||||
## Download Support
|
||||
|
||||
OpenCLI supports downloading images, videos, and articles from supported platforms.
|
||||
|
||||
### Supported Platforms
|
||||
|
||||
| Platform | Content Types | Notes |
|
||||
|----------|---------------|-------|
|
||||
| **xiaohongshu** | Images, Videos | Downloads all media from a note |
|
||||
| **bilibili** | Videos | Requires `yt-dlp` installed |
|
||||
| **twitter** | Images, Videos | Downloads from user media tab or single tweet |
|
||||
| **zhihu** | Articles (Markdown) | Exports articles with optional image download |
|
||||
|
||||
### Prerequisites
|
||||
|
||||
For video downloads from streaming platforms, you need to install `yt-dlp`:
|
||||
|
||||
```bash
|
||||
# Install yt-dlp
|
||||
pip install yt-dlp
|
||||
# or
|
||||
brew install yt-dlp
|
||||
```
|
||||
|
||||
### Usage Examples
|
||||
|
||||
```bash
|
||||
# Download images/videos from Xiaohongshu note
|
||||
opencli xiaohongshu download --note_id abc123 --output ./xhs
|
||||
|
||||
# Download Bilibili video (requires yt-dlp)
|
||||
opencli bilibili download --bvid BV1xxx --output ./bilibili
|
||||
opencli bilibili download --bvid BV1xxx --quality 1080p # Specify quality
|
||||
|
||||
# Download Twitter media from user
|
||||
opencli twitter download --username elonmusk --limit 20 --output ./twitter
|
||||
|
||||
# Download single tweet media
|
||||
opencli twitter download --tweet-url "https://x.com/user/status/123" --output ./twitter
|
||||
|
||||
# Export Zhihu article to Markdown
|
||||
opencli zhihu download --url "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
|
||||
|
||||
# Export with local images
|
||||
opencli zhihu download --url "https://zhuanlan.zhihu.com/p/xxx" --download-images
|
||||
```
|
||||
|
||||
### Pipeline Step (for YAML adapters)
|
||||
|
||||
The `download` step can be used in YAML pipelines:
|
||||
|
||||
```yaml
|
||||
pipeline:
|
||||
- fetch: https://api.example.com/media
|
||||
- download:
|
||||
url: ${{ item.imageUrl }}
|
||||
dir: ./downloads
|
||||
filename: ${{ item.title | sanitize }}.jpg
|
||||
concurrency: 5
|
||||
skip_existing: true
|
||||
```
|
||||
|
||||
## Output Formats
|
||||
|
||||
@@ -201,7 +266,7 @@ Explore outputs to `.opencli/explore/<site>/` (manifest.json, endpoints.json, ca
|
||||
|
||||
See **[TESTING.md](./TESTING.md)** for the full testing guide, including:
|
||||
|
||||
- Current test coverage (unit + E2E tests across 19 sites)
|
||||
- Current test coverage (unit + E2E tests across browser and desktop adapters)
|
||||
- How to run tests locally
|
||||
- How to add tests when creating new adapters
|
||||
- CI/CD pipeline with sharding
|
||||
@@ -217,15 +282,15 @@ npx vitest run tests/e2e/ # E2E tests
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"Failed to connect to Playwright MCP Bridge"**
|
||||
- Ensure the Playwright MCP extension is installed and **enabled** in your running Chrome.
|
||||
- Restart the Chrome browser if you just installed the extension.
|
||||
- **"Extension not connected"**
|
||||
- Ensure the opencli Browser Bridge extension is installed and **enabled** in `chrome://extensions`.
|
||||
- **Empty data returns or 'Unauthorized' error**
|
||||
- Your login session in Chrome might have expired. Open a normal Chrome tab, navigate to the target site, and log in or refresh the page to prove you are human.
|
||||
- Your login session in Chrome might have expired. Open a normal Chrome tab, navigate to the target site, and log in or refresh the page.
|
||||
- **Node API errors**
|
||||
- Make sure you are using Node.js >= 18. Some dependencies require modern Node APIs.
|
||||
- **Token issues**
|
||||
- Run `opencli doctor` to diagnose token configuration across all tools.
|
||||
- Make sure you are using Node.js >= 20. Some dependencies require modern Node APIs.
|
||||
- **Daemon issues**
|
||||
- Check daemon status: `curl localhost:19825/status`
|
||||
- View extension logs: `curl localhost:19825/logs`
|
||||
|
||||
## Releasing New Versions
|
||||
|
||||
|
||||
+147
-81
@@ -1,7 +1,7 @@
|
||||
# OpenCLI
|
||||
|
||||
> **把任何网站变成你的命令行工具。**
|
||||
> 零风控 · 复用 Chrome 登录 · AI 自动发现接口 · 80+ 命令 · 19 站点
|
||||
> **把任何网站或 Electron 应用变成你的命令行工具。**
|
||||
> 零风控 · 复用 Chrome 登录 · AI 自动发现接口 · 浏览器与桌面端自动化
|
||||
|
||||
[English](./README.md)
|
||||
|
||||
@@ -9,7 +9,12 @@
|
||||
[](https://nodejs.org)
|
||||
[](./LICENSE)
|
||||
|
||||
OpenCLI 将任何网站变成命令行工具 — B站、知乎、小红书、Twitter/X、Reddit、YouTube 等 [19 个站点](#内置命令) — 复用浏览器登录态,AI 驱动探索。
|
||||
OpenCLI 将任何网站或 Electron 应用(如 Antigravity)变成命令行工具 — B站、知乎、小红书、Twitter/X、Reddit、YouTube 等[多种站点与应用](#内置命令) — 复用浏览器登录态,AI 驱动探索。
|
||||
|
||||
🔥 **opencli 支持 CLI 化所有 electron 应用!最强大更新来袭!** 🔥
|
||||
CLI all electron!现在支持把所有 electron 应用 CLI 化,从而组合出各种神奇的能力。
|
||||
如果你在使用诸如 Antigravity Ultra 等工具时觉得不够灵活或难以扩展,现在通过 OpenCLI 把他 CLI 化,轻松打破界限。
|
||||
现在,**AI 可以自己控制自己**!结合 cc/openclaw 就可以远程控制任何 electron 应用!无限玩法!!
|
||||
|
||||
---
|
||||
|
||||
@@ -19,8 +24,11 @@ OpenCLI 将任何网站变成命令行工具 — B站、知乎、小红书、Twi
|
||||
- [前置要求](#前置要求)
|
||||
- [快速开始](#快速开始)
|
||||
- [内置命令](#内置命令)
|
||||
- [桌面应用适配器](#桌面应用适配器)
|
||||
- [下载支持](#下载支持)
|
||||
- [输出格式](#输出格式)
|
||||
- [致 AI Agent(开发者指南)](#致-ai-agent开发者指南)
|
||||
- [远程 Chrome(服务器/无头环境)](#远程-chrome服务器无头环境)
|
||||
- [常见问题排查](#常见问题排查)
|
||||
- [版本发布](#版本发布)
|
||||
- [License](#license)
|
||||
@@ -29,78 +37,49 @@ OpenCLI 将任何网站变成命令行工具 — B站、知乎、小红书、Twi
|
||||
|
||||
## 亮点
|
||||
|
||||
- **多站点覆盖** — B站、知乎、小红书、Twitter、Reddit 等 19 个站点,80+ 命令
|
||||
- **CLI All Electron** — 支持把所有 electron 应用(如 Antigravity Ultra)CLI 化,让 AI 控制自己!
|
||||
- **多站点覆盖** — 覆盖 B站、知乎、小红书、Twitter、Reddit,以及多种桌面应用
|
||||
- **零风控** — 复用 Chrome 登录态,无需存储任何凭证
|
||||
- **自修复配置** — `opencli setup` 自动发现 Token;`opencli doctor` 诊断 10+ 工具配置;`--fix` 一键修复
|
||||
- **自修复配置** — `opencli setup` 检查 Browser Bridge 连通性;`opencli doctor` 诊断 daemon、扩展和浏览器连接状态
|
||||
- **AI 原生** — `explore` 自动发现 API,`synthesize` 生成适配器,`cascade` 探测认证策略
|
||||
- **动态加载引擎** — 声明式的 `.yaml` 或者底层定制的 `.ts` 适配器,放入 `clis/` 文件夹即可自动注册生效
|
||||
|
||||
## 前置要求
|
||||
|
||||
- **Node.js**: >= 18.0.0
|
||||
- **Node.js**: >= 20.0.0
|
||||
- **Chrome** 浏览器正在运行,且**已登录目标网站**(如 bilibili.com、zhihu.com、xiaohongshu.com)
|
||||
|
||||
> **⚠️ 重要**:大多数命令复用你的 Chrome 登录状态。运行命令前,你必须已在 Chrome 中打开目标网站并完成登录。如果获取到空数据或报错,请先检查你的浏览器登录状态。
|
||||
|
||||
OpenCLI 通过 Playwright MCP Bridge 扩展与你的浏览器通信。
|
||||
OpenCLI 通过轻量化的 **Browser Bridge** Chrome 扩展 + 微型 daemon 与浏览器通信(零配置,自动启动)。
|
||||
|
||||
### Playwright MCP Bridge 扩展配置
|
||||
### Browser Bridge 扩展配置
|
||||
|
||||
1. 安装 **[Playwright MCP Bridge](https://chromewebstore.google.com/detail/playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm)** 扩展
|
||||
2. 运行 `opencli setup` — 自动发现 Token、分发到各工具、验证连通性:
|
||||
你可以选择以下任一方式安装扩展:
|
||||
|
||||
```bash
|
||||
opencli setup
|
||||
```
|
||||
**方式一:下载构建好的安装包(推荐)**
|
||||
1. 到 GitHub [Releases 页面](https://github.com/jackwener/opencli/releases) 下载最新的 `opencli-extension.zip` 或 `opencli-extension.crx`。
|
||||
2. 打开 Chrome 的 `chrome://extensions`,启用右上角的 **开发者模式**。
|
||||
3. 将 `.crx` 拖入浏览器窗口,或将解压后的文件夹拖入即可完成安装。
|
||||
|
||||
交互式 TUI 会:
|
||||
- 🔍 从 Chrome 自动发现 `PLAYWRIGHT_MCP_EXTENSION_TOKEN`(无需手动复制)
|
||||
- ☑️ 显示所有支持的工具(Codex、Cursor、Claude Code、Gemini CLI 等)
|
||||
- ✏️ 只更新你选中的文件(空格切换,回车确认)
|
||||
- 🔌 完成后自动验证浏览器连通性
|
||||
**方式二:加载源码(针对开发者)**
|
||||
1. 同样在 `chrome://extensions` 开启 **开发者模式**。
|
||||
2. 点击 **加载已解压的扩展程序**,选择本仓库代码树中的 `extension/` 文件夹。
|
||||
|
||||
> **Tip**:后续诊断和维护用 `opencli doctor`:
|
||||
完成!运行任何 opencli 浏览器命令时,后台微型 daemon 会自动启动与浏览器通信。无需配 API Token,零代码配置。
|
||||
|
||||
> **Tip**:后续诊断用 `opencli doctor`:
|
||||
> ```bash
|
||||
> opencli doctor # 只读 Token 与配置诊断
|
||||
> opencli doctor --live # 额外测试浏览器连通性
|
||||
> opencli doctor --fix # 修复不一致的配置(交互确认)
|
||||
> opencli doctor --fix -y # 无交互直接修复所有配置
|
||||
> opencli doctor # 检查扩展和 daemon 连通性
|
||||
> opencli doctor --live # 额外测试浏览器命令
|
||||
> ```
|
||||
|
||||
<details>
|
||||
<summary>手动配置(备选方案)</summary>
|
||||
|
||||
配置你的 MCP 客户端(如 Claude/Cursor 等):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@playwright/mcp@latest", "--extension"],
|
||||
"env": {
|
||||
"PLAYWRIGHT_MCP_EXTENSION_TOKEN": "<你的-token>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
在终端环境变量中导出(建议写进 `~/.zshrc`):
|
||||
|
||||
```bash
|
||||
export PLAYWRIGHT_MCP_EXTENSION_TOKEN="<你的-token>"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## 快速开始
|
||||
|
||||
### npm 全局安装(推荐)
|
||||
|
||||
```bash
|
||||
npm install -g @jackwener/opencli
|
||||
opencli setup # 首次使用:配置 Playwright MCP token
|
||||
```
|
||||
|
||||
直接使用:
|
||||
@@ -133,29 +112,117 @@ npm install -g @jackwener/opencli@latest
|
||||
|
||||
## 内置命令
|
||||
|
||||
**19 个站点 · 80+ 命令** — 运行 `opencli list` 查看完整注册表。
|
||||
运行 `opencli list` 查看完整注册表。
|
||||
|
||||
| 站点 | 命令 | 数量 | 模式 |
|
||||
|------|------|:----:|------|
|
||||
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` | 18 | 🔐 浏览器 |
|
||||
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 15 | 🔐 浏览器 |
|
||||
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` | 11 | 🔐 浏览器 |
|
||||
| **v2ex** | `hot` `latest` `topic` `daily` `me` `notifications` | 6 | 🌐 / 🔐 |
|
||||
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 6 | 🔐 浏览器 |
|
||||
| **xiaohongshu** | `search` `notifications` `feed` `me` `user` | 5 | 🔐 浏览器 |
|
||||
| **youtube** | `search` `video` `transcript` | 3 | 🔐 浏览器 |
|
||||
| **zhihu** | `hot` `search` `question` | 3 | 🔐 浏览器 |
|
||||
| **boss** | `search` `detail` | 2 | 🔐 浏览器 |
|
||||
| **coupang** | `search` `add-to-cart` | 2 | 🔐 浏览器 |
|
||||
| **bbc** | `news` | 1 | 🌐 公共 API |
|
||||
| **ctrip** | `search` | 1 | 🔐 浏览器 |
|
||||
| **github** | `search` | 1 | 🌐 公共 API |
|
||||
| **hackernews** | `top` | 1 | 🌐 公共 API |
|
||||
| **linkedin** | `search` | 1 | 🔐 浏览器 |
|
||||
| **reuters** | `search` | 1 | 🔐 浏览器 |
|
||||
| **smzdm** | `search` | 1 | 🔐 浏览器 |
|
||||
| **weibo** | `hot` | 1 | 🔐 浏览器 |
|
||||
| **yahoo-finance** | `quote` | 1 | 🔐 浏览器 |
|
||||
| 站点 | 命令 | 模式 |
|
||||
|------|------|------|
|
||||
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` | 浏览器 |
|
||||
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 浏览器 |
|
||||
| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | 桌面端 |
|
||||
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | 浏览器 |
|
||||
| **codex** | `status` `send` `read` `new` `extract-diff` `model` `ask` `screenshot` `history` `export` | 桌面端 |
|
||||
| **chatwise** | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` | 桌面端 |
|
||||
| **notion** | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` | 桌面端 |
|
||||
| **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` | 桌面端 |
|
||||
| **v2ex** | `hot` `latest` `topic` `daily` `me` `notifications` | 公开 / 浏览器 |
|
||||
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 浏览器 |
|
||||
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | 桌面端 |
|
||||
| **chatgpt** | `status` `new` `send` `read` `ask` | 桌面端 |
|
||||
| **xiaohongshu** | `search` `notifications` `feed` `me` `user` `download` | 浏览器 |
|
||||
| **apple-podcasts** | `search` `episodes` `top` | 公开 |
|
||||
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | 公开 |
|
||||
| **zhihu** | `hot` `search` `question` `download` | 浏览器 |
|
||||
| **youtube** | `search` `video` `transcript` | 浏览器 |
|
||||
| **boss** | `search` `detail` | 浏览器 |
|
||||
| **coupang** | `search` `add-to-cart` | 浏览器 |
|
||||
| **bbc** | `news` | 公共 API |
|
||||
| **ctrip** | `search` | 浏览器 |
|
||||
| **github** | `search` | 公共 API |
|
||||
| **hackernews** | `top` | 公共 API |
|
||||
| **linkedin** | `search` | 浏览器 |
|
||||
| **reuters** | `search` | 浏览器 |
|
||||
| **smzdm** | `search` | 浏览器 |
|
||||
| **weibo** | `hot` | 浏览器 |
|
||||
| **yahoo-finance** | `quote` | 浏览器 |
|
||||
|
||||
### 桌面应用适配器
|
||||
|
||||
每个桌面适配器都有自己详细的文档说明,包括命令参考、启动配置与使用示例:
|
||||
|
||||
| 应用 | 描述 | 文档 |
|
||||
|-----|-------------|-----|
|
||||
| **Cursor** | 控制 Cursor IDE — Composer、对话、代码提取等 | [README](./src/clis/cursor/README.md) |
|
||||
| **Codex** | 在后台(无头)驱动 OpenAI Codex CLI Agent | [README](./src/clis/codex/README.md) |
|
||||
| **Antigravity** | 在终端直接控制 Antigravity Ultra | [README](./src/clis/antigravity/README.md) |
|
||||
| **ChatGPT** | 自动化操作 ChatGPT macOS 桌面客户端 | [README](./src/clis/chatgpt/README.md) |
|
||||
| **ChatWise** | 多 LLM 客户端(GPT-4、Claude、Gemini) | [README](./src/clis/chatwise/README.md) |
|
||||
| **Notion** | 搜索、读取、写入 Notion 页面 | [README](./src/clis/notion/README.md) |
|
||||
| **Discord** | Discord 桌面版 — 消息、频道、服务器 | [README](./src/clis/discord-app/README.md) |
|
||||
| **Feishu** | 飞书/Lark 桌面版 (AppleScript 驱动) | [README](./src/clis/feishu/README.md) |
|
||||
| **WeChat** | 微信 Mac 桌面端 (AppleScript + 无障碍接口) | [README](./src/clis/wechat/README.md) |
|
||||
| **NeteaseMusic** | 网易云音乐 (CEF/CDP 驱动) | [README](./src/clis/neteasemusic/README.md) |
|
||||
|
||||
## 下载支持
|
||||
|
||||
OpenCLI 支持从各平台下载图片、视频和文章。
|
||||
|
||||
### 支持的平台
|
||||
|
||||
| 平台 | 内容类型 | 说明 |
|
||||
|------|----------|------|
|
||||
| **小红书** | 图片、视频 | 下载笔记中的所有媒体文件 |
|
||||
| **B站** | 视频 | 需要安装 `yt-dlp` |
|
||||
| **Twitter/X** | 图片、视频 | 从用户媒体页或单条推文下载 |
|
||||
| **知乎** | 文章(Markdown) | 导出文章,可选下载图片到本地 |
|
||||
|
||||
### 前置依赖
|
||||
|
||||
下载流媒体平台的视频需要安装 `yt-dlp`:
|
||||
|
||||
```bash
|
||||
# 安装 yt-dlp
|
||||
pip install yt-dlp
|
||||
# 或者
|
||||
brew install yt-dlp
|
||||
```
|
||||
|
||||
### 使用示例
|
||||
|
||||
```bash
|
||||
# 下载小红书笔记中的图片/视频
|
||||
opencli xiaohongshu download --note_id abc123 --output ./xhs
|
||||
|
||||
# 下载B站视频(需要 yt-dlp)
|
||||
opencli bilibili download --bvid BV1xxx --output ./bilibili
|
||||
opencli bilibili download --bvid BV1xxx --quality 1080p # 指定画质
|
||||
|
||||
# 下载 Twitter 用户的媒体
|
||||
opencli twitter download --username elonmusk --limit 20 --output ./twitter
|
||||
|
||||
# 下载单条推文的媒体
|
||||
opencli twitter download --tweet-url "https://x.com/user/status/123" --output ./twitter
|
||||
|
||||
# 导出知乎文章为 Markdown
|
||||
opencli zhihu download --url "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
|
||||
|
||||
# 导出文章并下载图片到本地
|
||||
opencli zhihu download --url "https://zhuanlan.zhihu.com/p/xxx" --download-images
|
||||
```
|
||||
|
||||
### Pipeline Step(用于 YAML 适配器)
|
||||
|
||||
`download` step 可以在 YAML 管线中使用:
|
||||
|
||||
```yaml
|
||||
pipeline:
|
||||
- fetch: https://api.example.com/media
|
||||
- download:
|
||||
url: ${{ item.imageUrl }}
|
||||
dir: ./downloads
|
||||
filename: ${{ item.title | sanitize }}.jpg
|
||||
concurrency: 5
|
||||
skip_existing: true
|
||||
```
|
||||
|
||||
## 输出格式
|
||||
|
||||
@@ -198,16 +265,15 @@ opencli cascade https://api.example.com/data
|
||||
|
||||
## 常见问题排查
|
||||
|
||||
- **"Failed to connect to Playwright MCP Bridge"** 报错
|
||||
- 确保你当前的 Chrome 已安装且**开启了** Playwright MCP Bridge 浏览器插件。
|
||||
- 如果是刚装完插件,需要重启 Chrome 浏览器。
|
||||
- **"Extension not connected" 报错**
|
||||
- 确保你当前的 Chrome 已安装且**开启了** opencli Browser Bridge 扩展(在 `chrome://extensions` 中检查)。
|
||||
- **返回空数据,或者报错 "Unauthorized"**
|
||||
- Chrome 里的登录态可能已经过期(甚至被要求过滑动验证码)。请打开当前 Chrome 页面,在新标签页重新手工登录或刷新该页面。
|
||||
- Chrome 里的登录态可能已经过期。请打开当前 Chrome 页面,在新标签页重新手工登录或刷新该页面。
|
||||
- **Node API 错误 (如 parseArgs, fs 等)**
|
||||
- 确保 Node.js 版本 `>= 18`。旧版不支持我们使用的现代核心库 API。
|
||||
- **Token 问题**
|
||||
- 运行 `opencli doctor` 诊断所有工具的 Token 配置状态。
|
||||
- 使用 `opencli doctor --live` 测试浏览器连通性。
|
||||
- 确保 Node.js 版本 `>= 20`。
|
||||
- **Daemon 问题**
|
||||
- 检查 daemon 状态:`curl localhost:19825/status`
|
||||
- 查看扩展日志:`curl localhost:19825/logs`
|
||||
|
||||
## 版本发布
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
---
|
||||
name: opencli
|
||||
description: "OpenCLI — Make any website your CLI. Zero risk, AI-powered, reuse Chrome login. 80+ commands across 19 sites."
|
||||
version: 0.7.3
|
||||
description: "OpenCLI — Make any website or Electron App your CLI. Zero risk, AI-powered, reuse Chrome login. 80+ commands across 19 sites."
|
||||
version: 0.7.4
|
||||
author: jackwener
|
||||
tags: [cli, browser, web, mcp, playwright, bilibili, zhihu, twitter, github, v2ex, hackernews, reddit, xiaohongshu, xueqiu, youtube, boss, coupang, AI, agent]
|
||||
tags: [cli, browser, web, chrome-extension, cdp, bilibili, zhihu, twitter, github, v2ex, hackernews, reddit, xiaohongshu, xueqiu, youtube, boss, coupang, AI, agent]
|
||||
---
|
||||
|
||||
# OpenCLI
|
||||
|
||||
> Make any website your CLI. Reuse Chrome login, zero risk, AI-powered discovery.
|
||||
> Make any website or Electron App your CLI. Reuse Chrome login, zero risk, AI-powered discovery.
|
||||
|
||||
> [!CAUTION]
|
||||
> **AI Agent 必读:创建或修改任何适配器之前,你必须先阅读 [CLI-EXPLORER.md](./CLI-EXPLORER.md)!**
|
||||
> 该文档包含完整的 API 发现工作流(必须使用 Playwright MCP Bridge 浏览器探索)、5 级认证策略决策树、平台 SDK 速查表、`tap` 步骤调试流程、分页 API 模板、级联请求模式、以及常见陷阱。
|
||||
> 该文档包含完整的 API 发现工作流(必须使用浏览器探索)、5 级认证策略决策树、平台 SDK 速查表、`tap` 步骤调试流程、分页 API 模板、级联请求模式、以及常见陷阱。
|
||||
> **本文件(SKILL.md)仅提供命令参考和简化模板,不足以正确开发适配器。**
|
||||
|
||||
## Install & Run
|
||||
@@ -34,8 +34,8 @@ npm update -g @jackwener/opencli
|
||||
|
||||
Browser commands require:
|
||||
1. Chrome browser running **(logged into target sites)**
|
||||
2. [Playwright MCP Bridge](https://chromewebstore.google.com/detail/playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm) extension installed
|
||||
3. Run `opencli setup` to auto-discover token and configure all tools
|
||||
2. **opencli Browser Bridge** Chrome extension installed (load `extension/` as unpacked in `chrome://extensions`)
|
||||
3. No further setup needed — the daemon auto-starts on first browser command
|
||||
|
||||
> **Note**: You must be logged into the target website in Chrome before running commands. Tabs opened during command execution are auto-closed afterwards.
|
||||
|
||||
@@ -151,6 +151,15 @@ opencli smzdm search --keyword "耳机" # 搜索好价
|
||||
|
||||
# 携程 (browser)
|
||||
opencli ctrip search --query "三亚" # 搜索目的地
|
||||
|
||||
# Antigravity (Electron/CDP)
|
||||
opencli antigravity status # 检查 CDP 连接
|
||||
opencli antigravity send "hello" # 发送文本到当前 agent 聊天框
|
||||
opencli antigravity read # 读取整个聊天记录面板
|
||||
opencli antigravity new # 清空聊天、开启新对话
|
||||
opencli antigravity extract-code # 自动抽取 AI 回复中的代码块
|
||||
opencli antigravity model claude # 切换底层模型
|
||||
opencli antigravity watch # 流式监听增量消息
|
||||
```
|
||||
|
||||
### Management Commands
|
||||
@@ -161,11 +170,9 @@ opencli list --json # JSON output
|
||||
opencli list -f yaml # YAML output
|
||||
opencli validate # Validate all CLI definitions
|
||||
opencli validate bilibili # Validate specific site
|
||||
opencli setup # Interactive token setup (auto-discover + TUI checkbox)
|
||||
opencli doctor # Diagnose token & extension config across all tools
|
||||
opencli setup # Interactive Browser Bridge setup and connectivity check
|
||||
opencli doctor # Diagnose daemon, extension, and browser connectivity
|
||||
opencli doctor --live # Also test live browser connectivity
|
||||
opencli doctor --fix # Fix mismatched configs (interactive confirmation)
|
||||
opencli doctor --fix -y # Fix all configs non-interactively
|
||||
```
|
||||
|
||||
### AI Agent Workflow
|
||||
@@ -218,7 +225,7 @@ opencli bilibili hot -v # Show each pipeline step and data flow
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **完整模式 — 在写任何代码之前,先阅读 [CLI-EXPLORER.md](./CLI-EXPLORER.md)。**
|
||||
> 它包含:① AI Agent 浏览器探索工作流(必须用 Playwright MCP 抓包验证 API)② 认证策略决策树 ③ 平台 SDK(如 Bilibili 的 `apiGet`/`fetchJson`)④ YAML vs TS 选择指南 ⑤ `tap` 步骤调试方法 ⑥ 级联请求模板 ⑦ 常见陷阱表。
|
||||
> 它包含:① AI Agent 浏览器探索工作流 ② 认证策略决策树 ③ 平台 SDK(如 Bilibili 的 `apiGet`/`fetchJson`)④ YAML vs TS 选择指南 ⑤ `tap` 步骤调试方法 ⑥ 级联请求模板 ⑦ 常见陷阱表。
|
||||
> **下方仅为简化模板参考,直接使用极易踩坑。**
|
||||
|
||||
### YAML Pipeline (declarative, recommended)
|
||||
@@ -365,16 +372,18 @@ ${{ index + 1 }}
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `OPENCLI_DAEMON_PORT` | 19825 | Daemon listen port |
|
||||
| `OPENCLI_BROWSER_CONNECT_TIMEOUT` | 30 | Browser connection timeout (sec) |
|
||||
| `OPENCLI_BROWSER_COMMAND_TIMEOUT` | 45 | Command execution timeout (sec) |
|
||||
| `OPENCLI_BROWSER_EXPLORE_TIMEOUT` | 120 | Explore timeout (sec) |
|
||||
| `PLAYWRIGHT_MCP_EXTENSION_TOKEN` | — | Auto-approve extension connection |
|
||||
| `OPENCLI_VERBOSE` | — | Show daemon/extension logs |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| `npx not found` | Install Node.js: `brew install node` |
|
||||
| `Timed out connecting to browser` | 1) Chrome must be open 2) Install MCP Bridge extension and configure token |
|
||||
| `Extension not connected` | 1) Chrome must be open 2) Install opencli Browser Bridge extension |
|
||||
| `Target page context` error | Add `navigate:` step before `evaluate:` in YAML |
|
||||
| Empty table data | Check if evaluate returns JSON string (MCP parsing) or data path is wrong |
|
||||
| Empty table data | Check if evaluate returns correct data path |
|
||||
| Daemon issues | `curl localhost:19825/status` to check, `curl localhost:19825/logs` for extension logs |
|
||||
|
||||
+7
-7
@@ -105,10 +105,10 @@ npx vitest src/
|
||||
|
||||
### 浏览器命令本地测试须知
|
||||
|
||||
- 无 `PLAYWRIGHT_MCP_EXTENSION_TOKEN` 时,opencli 自动启动一个独立浏览器实例
|
||||
- opencli 通过 Browser Bridge 扩展连接已运行的 Chrome 浏览器
|
||||
- `browser-public.test.ts` 使用 `tryBrowserCommand()`,站点反爬导致空数据时 warn + pass
|
||||
- `browser-auth.test.ts` 验证 **graceful failure**(不 crash 不 hang 即通过)
|
||||
- 如需测试完整登录态,保持 Chrome 登录态 + 设置 `PLAYWRIGHT_MCP_EXTENSION_TOKEN`,手动跑对应测试
|
||||
- 如需测试完整登录态,保持 Chrome 登录态并安装 Browser Bridge 扩展,手动跑对应测试
|
||||
|
||||
---
|
||||
|
||||
@@ -202,12 +202,12 @@ steps:
|
||||
|
||||
## 浏览器模式
|
||||
|
||||
opencli 根据 `PLAYWRIGHT_MCP_EXTENSION_TOKEN` 环境变量自动选择模式:
|
||||
opencli 通过 Browser Bridge 扩展连接浏览器:
|
||||
|
||||
| 条件 | 模式 | MCP 参数 | 使用场景 |
|
||||
|---|---|---|---|
|
||||
| Token 已设置 | Extension 模式 | `--extension` | 本地用户,连接已登录的 Chrome |
|
||||
| Token 未设置 | Standalone 模式 | (无特殊 flag) | CI 或无扩展环境,自启浏览器 |
|
||||
| 条件 | 模式 | 使用场景 |
|
||||
|---|---|---|
|
||||
| 扩展已安装 | Extension 模式 | 本地用户,连接已登录的 Chrome |
|
||||
| 扩展未安装 | CLI 报错提示安装 | 需要安装 Browser Bridge 扩展 |
|
||||
|
||||
CI 中使用 `OPENCLI_BROWSER_EXECUTABLE_PATH` 指定真实 Chrome 路径:
|
||||
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { defineConfig } from 'vitepress'
|
||||
|
||||
export default defineConfig({
|
||||
title: 'OpenCLI',
|
||||
description: 'Make any website or Electron App your CLI — AI-powered, account-safe, self-healing.',
|
||||
|
||||
head: [
|
||||
['meta', { property: 'og:title', content: 'OpenCLI Documentation' }],
|
||||
['meta', { property: 'og:description', content: 'Make any website or Electron App your CLI.' }],
|
||||
['meta', { name: 'twitter:card', content: 'summary_large_image' }],
|
||||
],
|
||||
|
||||
locales: {
|
||||
root: {
|
||||
label: 'English',
|
||||
lang: 'en',
|
||||
themeConfig: {
|
||||
nav: [
|
||||
{ text: 'Guide', link: '/guide/getting-started' },
|
||||
{ text: 'Adapters', link: '/adapters/' },
|
||||
{ text: 'Developer', link: '/developer/contributing' },
|
||||
{ text: 'Advanced', link: '/advanced/cdp' },
|
||||
],
|
||||
sidebar: {
|
||||
'/guide/': [
|
||||
{
|
||||
text: 'Guide',
|
||||
items: [
|
||||
{ text: 'Getting Started', link: '/guide/getting-started' },
|
||||
{ text: 'Installation', link: '/guide/installation' },
|
||||
{ text: 'Browser Bridge', link: '/guide/browser-bridge' },
|
||||
{ text: 'Troubleshooting', link: '/guide/troubleshooting' },
|
||||
],
|
||||
},
|
||||
],
|
||||
'/adapters/': [
|
||||
{
|
||||
text: 'Adapters Overview',
|
||||
items: [
|
||||
{ text: 'All Adapters', link: '/adapters/' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Browser Adapters',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: 'Twitter / X', link: '/adapters/browser/twitter' },
|
||||
{ text: 'Reddit', link: '/adapters/browser/reddit' },
|
||||
{ text: 'Bilibili', link: '/adapters/browser/bilibili' },
|
||||
{ text: 'Zhihu', link: '/adapters/browser/zhihu' },
|
||||
{ text: 'Xiaohongshu', link: '/adapters/browser/xiaohongshu' },
|
||||
{ text: 'Weibo', link: '/adapters/browser/weibo' },
|
||||
{ text: 'YouTube', link: '/adapters/browser/youtube' },
|
||||
{ text: 'Xueqiu', link: '/adapters/browser/xueqiu' },
|
||||
{ text: 'V2EX', link: '/adapters/browser/v2ex' },
|
||||
{ text: 'LinkedIn', link: '/adapters/browser/linkedin' },
|
||||
{ text: 'Coupang', link: '/adapters/browser/coupang' },
|
||||
{ text: 'BOSS Zhipin', link: '/adapters/browser/boss' },
|
||||
{ text: 'Ctrip', link: '/adapters/browser/ctrip' },
|
||||
{ text: 'Reuters', link: '/adapters/browser/reuters' },
|
||||
{ text: 'SMZDM', link: '/adapters/browser/smzdm' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Public API Adapters',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: 'HackerNews', link: '/adapters/browser/hackernews' },
|
||||
{ text: 'GitHub', link: '/adapters/browser/github' },
|
||||
{ text: 'BBC', link: '/adapters/browser/bbc' },
|
||||
{ text: 'Apple Podcasts', link: '/adapters/browser/apple-podcasts' },
|
||||
{ text: 'Xiaoyuzhou', link: '/adapters/browser/xiaoyuzhou' },
|
||||
{ text: 'Yahoo Finance', link: '/adapters/browser/yahoo-finance' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Desktop Adapters',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: 'Cursor', link: '/adapters/desktop/cursor' },
|
||||
{ text: 'Codex', link: '/adapters/desktop/codex' },
|
||||
{ text: 'Antigravity', link: '/adapters/desktop/antigravity' },
|
||||
{ text: 'ChatGPT', link: '/adapters/desktop/chatgpt' },
|
||||
{ text: 'ChatWise', link: '/adapters/desktop/chatwise' },
|
||||
{ text: 'Notion', link: '/adapters/desktop/notion' },
|
||||
{ text: 'Discord', link: '/adapters/desktop/discord' },
|
||||
{ text: 'Feishu', link: '/adapters/desktop/feishu' },
|
||||
{ text: 'WeChat', link: '/adapters/desktop/wechat' },
|
||||
{ text: 'NeteaseMusic', link: '/adapters/desktop/neteasemusic' },
|
||||
],
|
||||
},
|
||||
],
|
||||
'/developer/': [
|
||||
{
|
||||
text: 'Developer Guide',
|
||||
items: [
|
||||
{ text: 'Contributing', link: '/developer/contributing' },
|
||||
{ text: 'Testing', link: '/developer/testing' },
|
||||
{ text: 'Architecture', link: '/developer/architecture' },
|
||||
{ text: 'YAML Adapter Guide', link: '/developer/yaml-adapter' },
|
||||
{ text: 'TypeScript Adapter Guide', link: '/developer/ts-adapter' },
|
||||
{ text: 'AI Workflow', link: '/developer/ai-workflow' },
|
||||
],
|
||||
},
|
||||
],
|
||||
'/advanced/': [
|
||||
{
|
||||
text: 'Advanced',
|
||||
items: [
|
||||
{ text: 'Chrome DevTools Protocol', link: '/advanced/cdp' },
|
||||
{ text: 'Electron Apps', link: '/advanced/electron' },
|
||||
{ text: 'Remote Chrome', link: '/advanced/remote-chrome' },
|
||||
{ text: 'Download Support', link: '/advanced/download' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
zh: {
|
||||
label: '中文',
|
||||
lang: 'zh-CN',
|
||||
link: '/zh/',
|
||||
themeConfig: {
|
||||
nav: [
|
||||
{ text: '指南', link: '/zh/guide/getting-started' },
|
||||
{ text: '适配器', link: '/zh/adapters/' },
|
||||
{ text: '开发者', link: '/zh/developer/contributing' },
|
||||
{ text: '进阶', link: '/zh/advanced/cdp' },
|
||||
],
|
||||
sidebar: {
|
||||
'/zh/guide/': [
|
||||
{
|
||||
text: '指南',
|
||||
items: [
|
||||
{ text: '快速开始', link: '/zh/guide/getting-started' },
|
||||
{ text: '安装', link: '/zh/guide/installation' },
|
||||
{ text: 'Browser Bridge', link: '/zh/guide/browser-bridge' },
|
||||
{ text: '问题排查', link: '/zh/guide/troubleshooting' },
|
||||
],
|
||||
},
|
||||
],
|
||||
'/zh/adapters/': [
|
||||
{
|
||||
text: '适配器概览',
|
||||
items: [
|
||||
{ text: '所有适配器', link: '/zh/adapters/' },
|
||||
],
|
||||
},
|
||||
],
|
||||
'/zh/developer/': [
|
||||
{
|
||||
text: '开发者指南',
|
||||
items: [
|
||||
{ text: '贡献指南', link: '/zh/developer/contributing' },
|
||||
{ text: '测试', link: '/zh/developer/testing' },
|
||||
],
|
||||
},
|
||||
],
|
||||
'/zh/advanced/': [
|
||||
{
|
||||
text: '进阶',
|
||||
items: [
|
||||
{ text: 'Chrome DevTools Protocol', link: '/zh/advanced/cdp' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
themeConfig: {
|
||||
search: {
|
||||
provider: 'local',
|
||||
},
|
||||
|
||||
socialLinks: [
|
||||
{ icon: 'github', link: 'https://github.com/jackwener/opencli' },
|
||||
{ icon: 'npm', link: 'https://www.npmjs.com/package/@jackwener/opencli' },
|
||||
],
|
||||
|
||||
editLink: {
|
||||
pattern: 'https://github.com/jackwener/opencli/edit/main/docs/:path',
|
||||
text: 'Edit this page on GitHub',
|
||||
},
|
||||
|
||||
footer: {
|
||||
message: 'Released under the Apache-2.0 License.',
|
||||
copyright: 'Copyright © 2024-present jackwener',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
# Apple Podcasts
|
||||
|
||||
**Mode**: 🌐 Public · **Domain**: `podcasts.apple.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli apple-podcasts search` | |
|
||||
| `opencli apple-podcasts episodes` | |
|
||||
| `opencli apple-podcasts top` | |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Quick start
|
||||
opencli apple-podcasts search --limit 5
|
||||
|
||||
# JSON output
|
||||
opencli apple-podcasts search -f json
|
||||
|
||||
# Verbose mode
|
||||
opencli apple-podcasts search -v
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- No browser required — uses public API
|
||||
@@ -0,0 +1,26 @@
|
||||
# BBC News
|
||||
|
||||
**Mode**: 🌐 Public · **Domain**: `bbc.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli bbc news` | |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Quick start
|
||||
opencli bbc news --limit 5
|
||||
|
||||
# JSON output
|
||||
opencli bbc news -f json
|
||||
|
||||
# Verbose mode
|
||||
opencli bbc news -v
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- No browser required — uses public API
|
||||
@@ -0,0 +1,38 @@
|
||||
# Bilibili
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: `bilibili.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli bilibili hot` | |
|
||||
| `opencli bilibili search` | |
|
||||
| `opencli bilibili me` | |
|
||||
| `opencli bilibili favorite` | |
|
||||
| `opencli bilibili history` | |
|
||||
| `opencli bilibili feed` | |
|
||||
| `opencli bilibili subtitle` | |
|
||||
| `opencli bilibili dynamic` | |
|
||||
| `opencli bilibili ranking` | |
|
||||
| `opencli bilibili following` | |
|
||||
| `opencli bilibili user-videos` | |
|
||||
| `opencli bilibili download` | |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Quick start
|
||||
opencli bilibili hot --limit 5
|
||||
|
||||
# JSON output
|
||||
opencli bilibili hot -f json
|
||||
|
||||
# Verbose mode
|
||||
opencli bilibili hot -v
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running and **logged into** bilibili.com
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
@@ -0,0 +1,28 @@
|
||||
# BOSS Zhipin
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: `zhipin.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli boss search` | |
|
||||
| `opencli boss detail` | |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Quick start
|
||||
opencli boss search --limit 5
|
||||
|
||||
# JSON output
|
||||
opencli boss search -f json
|
||||
|
||||
# Verbose mode
|
||||
opencli boss search -v
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running and **logged into** zhipin.com
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
@@ -0,0 +1,28 @@
|
||||
# Coupang
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: `coupang.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli coupang search` | |
|
||||
| `opencli coupang add-to-cart` | |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Quick start
|
||||
opencli coupang search --limit 5
|
||||
|
||||
# JSON output
|
||||
opencli coupang search -f json
|
||||
|
||||
# Verbose mode
|
||||
opencli coupang search -v
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running and **logged into** coupang.com
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
@@ -0,0 +1,27 @@
|
||||
# Ctrip (携程)
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: `ctrip.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli ctrip search` | |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Quick start
|
||||
opencli ctrip search --limit 5
|
||||
|
||||
# JSON output
|
||||
opencli ctrip search -f json
|
||||
|
||||
# Verbose mode
|
||||
opencli ctrip search -v
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running and **logged into** ctrip.com
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
@@ -0,0 +1,26 @@
|
||||
# GitHub
|
||||
|
||||
**Mode**: 🌐 Public · **Domain**: `github.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli github search` | |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Quick start
|
||||
opencli github search --limit 5
|
||||
|
||||
# JSON output
|
||||
opencli github search -f json
|
||||
|
||||
# Verbose mode
|
||||
opencli github search -v
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- No browser required — uses public API
|
||||
@@ -0,0 +1,26 @@
|
||||
# HackerNews
|
||||
|
||||
**Mode**: 🌐 Public · **Domain**: `news.ycombinator.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli hackernews top` | |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Quick start
|
||||
opencli hackernews top --limit 5
|
||||
|
||||
# JSON output
|
||||
opencli hackernews top -f json
|
||||
|
||||
# Verbose mode
|
||||
opencli hackernews top -v
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- No browser required — uses public API
|
||||
@@ -0,0 +1,27 @@
|
||||
# LinkedIn
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: `linkedin.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli linkedin search` | |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Quick start
|
||||
opencli linkedin search --limit 5
|
||||
|
||||
# JSON output
|
||||
opencli linkedin search -f json
|
||||
|
||||
# Verbose mode
|
||||
opencli linkedin search -v
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running and **logged into** linkedin.com
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
@@ -0,0 +1,41 @@
|
||||
# Reddit
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: `reddit.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli reddit hot` | |
|
||||
| `opencli reddit frontpage` | |
|
||||
| `opencli reddit popular` | |
|
||||
| `opencli reddit search` | |
|
||||
| `opencli reddit subreddit` | |
|
||||
| `opencli reddit read` | |
|
||||
| `opencli reddit user` | |
|
||||
| `opencli reddit user-posts` | |
|
||||
| `opencli reddit user-comments` | |
|
||||
| `opencli reddit upvote` | |
|
||||
| `opencli reddit save` | |
|
||||
| `opencli reddit comment` | |
|
||||
| `opencli reddit subscribe` | |
|
||||
| `opencli reddit saved` | |
|
||||
| `opencli reddit upvoted` | |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Quick start
|
||||
opencli reddit hot --limit 5
|
||||
|
||||
# JSON output
|
||||
opencli reddit hot -f json
|
||||
|
||||
# Verbose mode
|
||||
opencli reddit hot -v
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running and **logged into** reddit.com
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
@@ -0,0 +1,27 @@
|
||||
# Reuters
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: `reuters.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli reuters search` | |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Quick start
|
||||
opencli reuters search --limit 5
|
||||
|
||||
# JSON output
|
||||
opencli reuters search -f json
|
||||
|
||||
# Verbose mode
|
||||
opencli reuters search -v
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running and **logged into** reuters.com
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
@@ -0,0 +1,27 @@
|
||||
# SMZDM (什么值得买)
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: `smzdm.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli smzdm search` | |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Quick start
|
||||
opencli smzdm search --limit 5
|
||||
|
||||
# JSON output
|
||||
opencli smzdm search -f json
|
||||
|
||||
# Verbose mode
|
||||
opencli smzdm search -v
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running and **logged into** smzdm.com
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
@@ -0,0 +1,47 @@
|
||||
# Twitter / X
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: `twitter.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli twitter trending` | |
|
||||
| `opencli twitter bookmarks` | |
|
||||
| `opencli twitter profile` | |
|
||||
| `opencli twitter search` | |
|
||||
| `opencli twitter timeline` | |
|
||||
| `opencli twitter thread` | |
|
||||
| `opencli twitter following` | |
|
||||
| `opencli twitter followers` | |
|
||||
| `opencli twitter notifications` | |
|
||||
| `opencli twitter post` | |
|
||||
| `opencli twitter reply` | |
|
||||
| `opencli twitter delete` | |
|
||||
| `opencli twitter like` | |
|
||||
| `opencli twitter article` | |
|
||||
| `opencli twitter follow` | |
|
||||
| `opencli twitter unfollow` | |
|
||||
| `opencli twitter bookmark` | |
|
||||
| `opencli twitter unbookmark` | |
|
||||
| `opencli twitter download` | |
|
||||
| `opencli twitter accept` | |
|
||||
| `opencli twitter reply-dm` | |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Quick start
|
||||
opencli twitter trending --limit 5
|
||||
|
||||
# JSON output
|
||||
opencli twitter trending -f json
|
||||
|
||||
# Verbose mode
|
||||
opencli twitter trending -v
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running and **logged into** twitter.com
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
@@ -0,0 +1,32 @@
|
||||
# V2EX
|
||||
|
||||
**Mode**: 🌐 / 🔐 · **Domain**: `v2ex.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli v2ex hot` | |
|
||||
| `opencli v2ex latest` | |
|
||||
| `opencli v2ex topic` | |
|
||||
| `opencli v2ex daily` | |
|
||||
| `opencli v2ex me` | |
|
||||
| `opencli v2ex notifications` | |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Quick start
|
||||
opencli v2ex hot --limit 5
|
||||
|
||||
# JSON output
|
||||
opencli v2ex hot -f json
|
||||
|
||||
# Verbose mode
|
||||
opencli v2ex hot -v
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running and **logged into** v2ex.com
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
@@ -0,0 +1,27 @@
|
||||
# Weibo (微博)
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: `weibo.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli weibo hot` | |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Quick start
|
||||
opencli weibo hot --limit 5
|
||||
|
||||
# JSON output
|
||||
opencli weibo hot -f json
|
||||
|
||||
# Verbose mode
|
||||
opencli weibo hot -v
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running and **logged into** weibo.com
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
@@ -0,0 +1,32 @@
|
||||
# Xiaohongshu (小红书)
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: `xiaohongshu.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli xiaohongshu search` | |
|
||||
| `opencli xiaohongshu notifications` | |
|
||||
| `opencli xiaohongshu feed` | |
|
||||
| `opencli xiaohongshu me` | |
|
||||
| `opencli xiaohongshu user` | |
|
||||
| `opencli xiaohongshu download` | |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Quick start
|
||||
opencli xiaohongshu search --limit 5
|
||||
|
||||
# JSON output
|
||||
opencli xiaohongshu search -f json
|
||||
|
||||
# Verbose mode
|
||||
opencli xiaohongshu search -v
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running and **logged into** xiaohongshu.com
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
@@ -0,0 +1,28 @@
|
||||
# Xiaoyuzhou (小宇宙)
|
||||
|
||||
**Mode**: 🌐 Public · **Domain**: `xiaoyuzhou.fm`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli xiaoyuzhou podcast` | |
|
||||
| `opencli xiaoyuzhou podcast-episodes` | |
|
||||
| `opencli xiaoyuzhou episode` | |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Quick start
|
||||
opencli xiaoyuzhou podcast --limit 5
|
||||
|
||||
# JSON output
|
||||
opencli xiaoyuzhou podcast -f json
|
||||
|
||||
# Verbose mode
|
||||
opencli xiaoyuzhou podcast -v
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- No browser required — uses public API
|
||||
@@ -0,0 +1,32 @@
|
||||
# Xueqiu (雪球)
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: `xueqiu.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli xueqiu feed` | |
|
||||
| `opencli xueqiu hot-stock` | |
|
||||
| `opencli xueqiu hot` | |
|
||||
| `opencli xueqiu search` | |
|
||||
| `opencli xueqiu stock` | |
|
||||
| `opencli xueqiu watchlist` | |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Quick start
|
||||
opencli xueqiu feed --limit 5
|
||||
|
||||
# JSON output
|
||||
opencli xueqiu feed -f json
|
||||
|
||||
# Verbose mode
|
||||
opencli xueqiu feed -v
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running and **logged into** xueqiu.com
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
@@ -0,0 +1,26 @@
|
||||
# Yahoo Finance
|
||||
|
||||
**Mode**: 🌐 Public · **Domain**: `finance.yahoo.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli yahoo-finance quote` | |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Quick start
|
||||
opencli yahoo-finance quote --limit 5
|
||||
|
||||
# JSON output
|
||||
opencli yahoo-finance quote -f json
|
||||
|
||||
# Verbose mode
|
||||
opencli yahoo-finance quote -v
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- No browser required — uses public API
|
||||
@@ -0,0 +1,29 @@
|
||||
# YouTube
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: `youtube.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli youtube search` | |
|
||||
| `opencli youtube video` | |
|
||||
| `opencli youtube transcript` | |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Quick start
|
||||
opencli youtube search --limit 5
|
||||
|
||||
# JSON output
|
||||
opencli youtube search -f json
|
||||
|
||||
# Verbose mode
|
||||
opencli youtube search -v
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running and **logged into** youtube.com
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
@@ -0,0 +1,30 @@
|
||||
# Zhihu
|
||||
|
||||
**Mode**: 🔐 Browser · **Domain**: `zhihu.com`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli zhihu hot` | |
|
||||
| `opencli zhihu search` | |
|
||||
| `opencli zhihu question` | |
|
||||
| `opencli zhihu download` | |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Quick start
|
||||
opencli zhihu hot --limit 5
|
||||
|
||||
# JSON output
|
||||
opencli zhihu hot -f json
|
||||
|
||||
# Verbose mode
|
||||
opencli zhihu hot -v
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome running and **logged into** zhihu.com
|
||||
- [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
@@ -0,0 +1,46 @@
|
||||
# Antigravity
|
||||
|
||||
🔥 **CLI All Electron Apps! The Most Powerful Update Has Arrived!** 🔥
|
||||
|
||||
Turn your local Antigravity desktop application into a programmable AI node via Chrome DevTools Protocol (CDP). This allows you to compose complex LLM workflows entirely through the terminal by manipulating the actual UI natively, bypassing any API restrictions.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Start the Antigravity desktop app with the Chrome DevTools `remote-debugging-port` flag:
|
||||
|
||||
```bash
|
||||
# Start Antigravity in the background
|
||||
/Applications/Antigravity.app/Contents/MacOS/Electron \
|
||||
--remote-debugging-port=9224
|
||||
```
|
||||
|
||||
> Depending on your installation, the executable might be named differently, e.g., `Antigravity` instead of `Electron`.
|
||||
|
||||
Then set the target port:
|
||||
|
||||
```bash
|
||||
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9224"
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### `opencli antigravity status`
|
||||
Check the Chromium CDP connection. Returns the current window title and active internal URL.
|
||||
|
||||
### `opencli antigravity send <message>`
|
||||
Send a text prompt to the AI. Automatically locates the Lexical editor input box, types the prompt securely, and hits Enter.
|
||||
|
||||
### `opencli antigravity read`
|
||||
Scrape the entire current conversation history block as pure text.
|
||||
|
||||
### `opencli antigravity new`
|
||||
Click the "New Conversation" button to instantly clear the UI state and start fresh.
|
||||
|
||||
### `opencli antigravity extract-code`
|
||||
Extract any multi-line code blocks from the current conversation view. Ideal for automated script extraction (e.g. `opencli antigravity extract-code > script.sh`).
|
||||
|
||||
### `opencli antigravity model <name>`
|
||||
Quickly target and switch the active LLM engine. Example: `opencli antigravity model claude` or `opencli antigravity model gemini`.
|
||||
|
||||
### `opencli antigravity watch`
|
||||
A long-running, streaming process that continuously polls the Antigravity UI for chat updates and outputs them in real-time to standard output.
|
||||
@@ -0,0 +1,43 @@
|
||||
# ChatGPT
|
||||
|
||||
Control the **ChatGPT macOS Desktop App** directly from the terminal. OpenCLI supports two automation approaches for ChatGPT.
|
||||
|
||||
## Approach 1: AppleScript (Default, No Setup)
|
||||
|
||||
The current built-in commands use native AppleScript automation — no extra launch flags needed.
|
||||
|
||||
### Prerequisites
|
||||
1. Install the official [ChatGPT Desktop App](https://openai.com/chatgpt/mac/) from OpenAI.
|
||||
2. Grant **Accessibility permissions** to your terminal app in **System Settings → Privacy & Security → Accessibility**.
|
||||
|
||||
### Commands
|
||||
- `opencli chatgpt status`: Check if the ChatGPT app is currently running.
|
||||
- `opencli chatgpt new`: Activate ChatGPT and press `Cmd+N` to start a new conversation.
|
||||
- `opencli chatgpt send "message"`: Copy your message to clipboard, activate ChatGPT, paste, and submit.
|
||||
- `opencli chatgpt read`: Copy the last AI response via `Cmd+Shift+C` and return it as text.
|
||||
|
||||
## Approach 2: CDP (Advanced, Electron Debug Mode)
|
||||
|
||||
ChatGPT Desktop is also an Electron app and can be launched with a remote debugging port:
|
||||
|
||||
```bash
|
||||
/Applications/ChatGPT.app/Contents/MacOS/ChatGPT \
|
||||
--remote-debugging-port=9224
|
||||
```
|
||||
|
||||
```bash
|
||||
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9224"
|
||||
```
|
||||
|
||||
> The CDP approach enables future advanced commands like DOM inspection, model switching, and code extraction.
|
||||
|
||||
## How It Works
|
||||
|
||||
- **AppleScript mode**: Uses `osascript` and `pbcopy`/`pbpaste` for clipboard-based text transfer.
|
||||
- **CDP mode**: Connects via Chrome DevTools Protocol to the Electron renderer process.
|
||||
|
||||
## Limitations
|
||||
|
||||
- macOS only (AppleScript dependency)
|
||||
- AppleScript mode requires Accessibility permissions
|
||||
- `read` command copies the last response — earlier messages need manual scroll
|
||||
@@ -0,0 +1,38 @@
|
||||
# ChatWise
|
||||
|
||||
Control the **ChatWise Desktop App** from the terminal via Chrome DevTools Protocol (CDP). ChatWise is an Electron-based multi-LLM client supporting GPT-4, Claude, Gemini, and more.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Install [ChatWise](https://chatwise.app/).
|
||||
2. Launch with remote debugging port:
|
||||
```bash
|
||||
/Applications/ChatWise.app/Contents/MacOS/ChatWise \
|
||||
--remote-debugging-port=9228
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9228"
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Diagnostics
|
||||
- `opencli chatwise status`: Check CDP connection status.
|
||||
- `opencli chatwise screenshot`: Export DOM + accessibility snapshot.
|
||||
|
||||
### Chat
|
||||
- `opencli chatwise new`: Start a new conversation (`Cmd+N`).
|
||||
- `opencli chatwise send "message"`: Send a message to the active chat.
|
||||
- `opencli chatwise read`: Read the current conversation.
|
||||
- `opencli chatwise ask "prompt"`: Send + wait for response + return it (one-shot).
|
||||
|
||||
### AI Features
|
||||
- `opencli chatwise model`: Get the current AI model.
|
||||
- `opencli chatwise model gpt-4`: Switch to a different model.
|
||||
|
||||
### Organization
|
||||
- `opencli chatwise history`: List conversations from the sidebar.
|
||||
- `opencli chatwise export`: Export conversation as Markdown.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Codex
|
||||
|
||||
Control the **OpenAI Codex Desktop App** headless or headfully via Chrome DevTools Protocol (CDP). Because Codex is built on Electron, OpenCLI can directly drive its internal UI, automate slash commands, and manipulate its AI agent threads.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. You must have the official OpenAI Codex app installed.
|
||||
2. Launch it via the terminal and expose the remote debugging port:
|
||||
```bash
|
||||
# macOS
|
||||
/Applications/Codex.app/Contents/MacOS/Codex --remote-debugging-port=9222
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
export OPENCLI_CODEX_CDP_ENDPOINT="http://127.0.0.1:9222"
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Diagnostics
|
||||
- `opencli codex status`: Checks connection and reads the current active window URL/title.
|
||||
- `opencli codex dump`: Dumps the full UI DOM and Accessibility tree into `/tmp`.
|
||||
|
||||
### Agent Manipulation
|
||||
- `opencli codex new`: Simulates `Cmd+N` to start a completely fresh and isolated Git Worktree thread context.
|
||||
- `opencli codex send "message"`: Robustly finds the active Thread Composer and injects your text.
|
||||
- *Pro-tip*: You can trigger internal shortcuts, e.g., `opencli codex send "/review"`.
|
||||
- `opencli codex read`: Extracts the entire current thread history and AI reasoning logs.
|
||||
- `opencli codex extract-diff`: Automatically scrapes any visual Patch chunks and Code Diffs.
|
||||
- `opencli codex model`: Get the currently active AI model.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Cursor
|
||||
|
||||
Control the **Cursor IDE** from the terminal via Chrome DevTools Protocol (CDP). Since Cursor is built on Electron (VS Code fork), OpenCLI can drive its internal UI, automate Composer interactions, and manipulate chat sessions.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Install [Cursor](https://cursor.sh/).
|
||||
2. Launch it with the remote debugging port:
|
||||
```bash
|
||||
/Applications/Cursor.app/Contents/MacOS/Cursor --remote-debugging-port=9226
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9226"
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Diagnostics
|
||||
- `opencli cursor status`: Check CDP connection status.
|
||||
- `opencli cursor dump`: Dump the full DOM and Accessibility snapshot to `/tmp/cursor-dom.html` and `/tmp/cursor-snapshot.json`.
|
||||
|
||||
### Chat Manipulation
|
||||
- `opencli cursor new`: Press `Cmd+N` to start a new file/tab.
|
||||
- `opencli cursor send "message"`: Inject text into the active Composer/Chat input and submit.
|
||||
- `opencli cursor read`: Extract the full conversation history from the active chat panel.
|
||||
|
||||
### AI Features
|
||||
- `opencli cursor composer "prompt"`: Open the Composer panel (`Cmd+I`) and send a prompt for inline AI editing.
|
||||
- `opencli cursor model`: Get the currently active AI model (e.g., `claude-4.5-sonnet`).
|
||||
- `opencli cursor extract-code`: Extract all code blocks from the current conversation.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Discord
|
||||
|
||||
Control the **Discord Desktop App** from the terminal via Chrome DevTools Protocol (CDP).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Launch with remote debugging port:
|
||||
```bash
|
||||
/Applications/Discord.app/Contents/MacOS/Discord --remote-debugging-port=9232
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9232"
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli discord status` | Check CDP connection |
|
||||
| `opencli discord send "message"` | Send a message in the active channel |
|
||||
| `opencli discord read` | Read recent messages |
|
||||
| `opencli discord channels` | List channels in the current server |
|
||||
| `opencli discord servers` | List all joined servers |
|
||||
| `opencli discord search "query"` | Search messages (Cmd+F) |
|
||||
| `opencli discord members` | List online members |
|
||||
@@ -0,0 +1,20 @@
|
||||
# Feishu (飞书/Lark)
|
||||
|
||||
Control **Feishu/Lark Desktop** from the terminal via AppleScript.
|
||||
|
||||
> **Note:** Feishu uses a custom `Lark Framework` (Chromium-based but NOT Electron). CDP is not available, so this adapter uses AppleScript + clipboard.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Feishu/Lark must be running and logged in
|
||||
2. Terminal must have **Accessibility permission**
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli feishu status` | Check if Feishu/Lark is running |
|
||||
| `opencli feishu send "msg"` | Send message in active chat (paste + Enter) |
|
||||
| `opencli feishu read` | Read current chat (Cmd+A → Cmd+C) |
|
||||
| `opencli feishu search "query"` | Global search (Cmd+K) |
|
||||
| `opencli feishu new` | New message/document (Cmd+N) |
|
||||
@@ -0,0 +1,31 @@
|
||||
# NeteaseMusic (网易云音乐)
|
||||
|
||||
Control **NeteaseMusic** (网易云音乐) from the terminal via Chrome DevTools Protocol (CDP). The app uses Chromium Embedded Framework (CEF).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Launch with remote debugging port:
|
||||
```bash
|
||||
/Applications/NeteaseMusic.app/Contents/MacOS/NeteaseMusic --remote-debugging-port=9234
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9234"
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli neteasemusic status` | Check CDP connection |
|
||||
| `opencli neteasemusic playing` | Current song info (title, artist, album) |
|
||||
| `opencli neteasemusic play` | Play / Pause toggle |
|
||||
| `opencli neteasemusic next` | Skip to next song |
|
||||
| `opencli neteasemusic prev` | Go to previous song |
|
||||
| `opencli neteasemusic search "query"` | Search songs, artists |
|
||||
| `opencli neteasemusic playlist` | Show current playback queue |
|
||||
| `opencli neteasemusic like` | Like / unlike current song |
|
||||
| `opencli neteasemusic lyrics` | Get lyrics of current song |
|
||||
| `opencli neteasemusic volume [0-100]` | Get or set volume |
|
||||
@@ -0,0 +1,29 @@
|
||||
# Notion
|
||||
|
||||
Control the **Notion Desktop App** from the terminal via Chrome DevTools Protocol (CDP).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Launch with remote debugging port:
|
||||
```bash
|
||||
/Applications/Notion.app/Contents/MacOS/Notion --remote-debugging-port=9230
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9230"
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli notion status` | Check CDP connection |
|
||||
| `opencli notion search "query"` | Quick Find search (Cmd+P) |
|
||||
| `opencli notion read` | Read the current page content |
|
||||
| `opencli notion new "title"` | Create a new page (Cmd+N) |
|
||||
| `opencli notion write "text"` | Append text to the current page |
|
||||
| `opencli notion sidebar` | List pages from the sidebar |
|
||||
| `opencli notion favorites` | List pages from the Favorites section |
|
||||
| `opencli notion export` | Export page as Markdown |
|
||||
@@ -0,0 +1,28 @@
|
||||
# WeChat (微信)
|
||||
|
||||
Control **WeChat Mac Desktop** from the terminal via AppleScript + Accessibility API.
|
||||
|
||||
> **Note:** WeChat is a native macOS app (not Electron), so CDP is not available. This adapter uses AppleScript keyboard simulation and clipboard operations.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. WeChat must be running and logged in
|
||||
2. Terminal must have **Accessibility permission** (System Settings → Privacy & Security → Accessibility)
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli wechat status` | Check if WeChat is running |
|
||||
| `opencli wechat send "msg"` | Send message in the active chat (clipboard paste + Enter) |
|
||||
| `opencli wechat read` | Read current chat content (Cmd+A → Cmd+C) |
|
||||
| `opencli wechat search "keyword"` | Open search and type a query (Cmd+F) |
|
||||
| `opencli wechat chats` | Switch to Chats tab (Cmd+1) |
|
||||
| `opencli wechat contacts` | Switch to Contacts tab (Cmd+2) |
|
||||
|
||||
## Limitations
|
||||
|
||||
- **No CDP support** — WeChat is native Cocoa, not Electron
|
||||
- `send` requires the correct conversation to be already open
|
||||
- `read` captures whatever is visible via select-all + copy
|
||||
- `search` types the query but cannot programmatically click results
|
||||
@@ -0,0 +1,49 @@
|
||||
# All Adapters
|
||||
|
||||
Run `opencli list` for the live registry.
|
||||
|
||||
## Browser Adapters
|
||||
|
||||
| Site | Commands | Mode |
|
||||
|------|----------|------|
|
||||
| **[twitter](/adapters/browser/twitter)** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` | 🔐 Browser |
|
||||
| **[reddit](/adapters/browser/reddit)** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 🔐 Browser |
|
||||
| **[bilibili](/adapters/browser/bilibili)** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | 🔐 Browser |
|
||||
| **[zhihu](/adapters/browser/zhihu)** | `hot` `search` `question` `download` | 🔐 Browser |
|
||||
| **[xiaohongshu](/adapters/browser/xiaohongshu)** | `search` `notifications` `feed` `me` `user` `download` | 🔐 Browser |
|
||||
| **[xueqiu](/adapters/browser/xueqiu)** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 🔐 Browser |
|
||||
| **[youtube](/adapters/browser/youtube)** | `search` `video` `transcript` | 🔐 Browser |
|
||||
| **[v2ex](/adapters/browser/v2ex)** | `hot` `latest` `topic` `daily` `me` `notifications` | 🌐 / 🔐 |
|
||||
| **[weibo](/adapters/browser/weibo)** | `hot` | 🔐 Browser |
|
||||
| **[linkedin](/adapters/browser/linkedin)** | `search` | 🔐 Browser |
|
||||
| **[coupang](/adapters/browser/coupang)** | `search` `add-to-cart` | 🔐 Browser |
|
||||
| **[boss](/adapters/browser/boss)** | `search` `detail` | 🔐 Browser |
|
||||
| **[ctrip](/adapters/browser/ctrip)** | `search` | 🔐 Browser |
|
||||
| **[reuters](/adapters/browser/reuters)** | `search` | 🔐 Browser |
|
||||
| **[smzdm](/adapters/browser/smzdm)** | `search` | 🔐 Browser |
|
||||
|
||||
## Public API Adapters
|
||||
|
||||
| Site | Commands | Mode |
|
||||
|------|----------|------|
|
||||
| **[hackernews](/adapters/browser/hackernews)** | `top` | 🌐 Public |
|
||||
| **[github](/adapters/browser/github)** | `search` | 🌐 Public |
|
||||
| **[bbc](/adapters/browser/bbc)** | `news` | 🌐 Public |
|
||||
| **[apple-podcasts](/adapters/browser/apple-podcasts)** | `search` `episodes` `top` | 🌐 Public |
|
||||
| **[xiaoyuzhou](/adapters/browser/xiaoyuzhou)** | `podcast` `podcast-episodes` `episode` | 🌐 Public |
|
||||
| **[yahoo-finance](/adapters/browser/yahoo-finance)** | `quote` | 🌐 Public |
|
||||
|
||||
## Desktop Adapters
|
||||
|
||||
| App | Description | Commands |
|
||||
|-----|-------------|----------|
|
||||
| **[Cursor](/adapters/desktop/cursor)** | Control Cursor IDE | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` |
|
||||
| **[Codex](/adapters/desktop/codex)** | Drive OpenAI Codex CLI agent | `status` `send` `read` `new` `extract-diff` `model` `ask` `screenshot` `history` `export` |
|
||||
| **[Antigravity](/adapters/desktop/antigravity)** | Control Antigravity Ultra | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` |
|
||||
| **[ChatGPT](/adapters/desktop/chatgpt)** | Automate ChatGPT macOS app | `status` `new` `send` `read` `ask` |
|
||||
| **[ChatWise](/adapters/desktop/chatwise)** | Multi-LLM client | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` |
|
||||
| **[Notion](/adapters/desktop/notion)** | Search, read, write pages | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` |
|
||||
| **[Discord](/adapters/desktop/discord)** | Desktop messages & channels | `status` `send` `read` `channels` `servers` `search` `members` |
|
||||
| **[Feishu](/adapters/desktop/feishu)** | 飞书/Lark via AppleScript | `status` `send` `read` `search` `new` |
|
||||
| **[WeChat](/adapters/desktop/wechat)** | 微信 via AppleScript | `status` `send` `read` `search` `chats` `contacts` |
|
||||
| **[NeteaseMusic](/adapters/desktop/neteasemusic)** | 网易云音乐 via CDP | `status` `playing` `play` `next` `prev` `search` `playlist` `like` `lyrics` `volume` |
|
||||
@@ -0,0 +1,103 @@
|
||||
# Connecting OpenCLI via CDP (Remote/Headless Servers)
|
||||
|
||||
If you cannot use the opencli Browser Bridge extension (e.g., in a remote headless server environment without a UI), OpenCLI provides an alternative: connecting directly to Chrome via **CDP (Chrome DevTools Protocol)**.
|
||||
|
||||
Because CDP binds to `localhost` by default for security reasons, accessing it from a remote server requires an additional networking tunnel.
|
||||
|
||||
This guide is broken down into three phases:
|
||||
1. **Preparation**: Start Chrome with CDP enabled locally.
|
||||
2. **Network Tunnels**: Expose that CDP port to your remote server using either **SSH Tunnels** or **Reverse Proxies**.
|
||||
3. **Execution**: Run OpenCLI on your server.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Preparation (Local Machine)
|
||||
|
||||
First, you need to start a Chrome browser on your local machine with remote debugging enabled.
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
|
||||
--remote-debugging-port=9222 \
|
||||
--user-data-dir="$HOME/chrome-debug-profile" \
|
||||
--remote-allow-origins="*"
|
||||
```
|
||||
|
||||
**Linux:**
|
||||
```bash
|
||||
google-chrome \
|
||||
--remote-debugging-port=9222 \
|
||||
--user-data-dir="$HOME/chrome-debug-profile" \
|
||||
--remote-allow-origins="*"
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
```cmd
|
||||
"C:\Program Files\Google\Chrome\Application\chrome.exe" ^
|
||||
--remote-debugging-port=9222 ^
|
||||
--user-data-dir="%USERPROFILE%\chrome-debug-profile" ^
|
||||
--remote-allow-origins="*"
|
||||
```
|
||||
|
||||
> **Note**: The `--remote-allow-origins="*"` flag is often required for modern Chrome versions to accept cross-origin CDP WebSocket connections (e.g. from reverse proxies like ngrok).
|
||||
|
||||
Once this browser instance opens, **log into the target websites you want to use** (e.g., bilibili.com, zhihu.com) so that the session contains the correct cookies.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Remote Access Methods
|
||||
|
||||
Once CDP is running locally on port `9222`, you must securely expose this port to your remote server. Choose one of the two methods below depending on your network conditions.
|
||||
|
||||
### Method A: SSH Tunnel (Recommended)
|
||||
|
||||
If your local machine has SSH access to the remote server, this is the most secure and straightforward method.
|
||||
|
||||
Run this command on your **Local Machine** to forward the remote server's port `9222` back to your local port `9222`:
|
||||
|
||||
```bash
|
||||
ssh -R 9222:localhost:9222 your-server-user@your-server-ip
|
||||
```
|
||||
|
||||
Leave this SSH session running in the background.
|
||||
|
||||
### Method B: Reverse Proxy (ngrok / frp / socat)
|
||||
|
||||
If you cannot establish a direct SSH connection (e.g., due to NAT or firewalls), you can use an intranet penetration tool like `ngrok`.
|
||||
|
||||
Run this command on your **Local Machine** to expose your local port `9222` to the public internet securely via ngrok:
|
||||
|
||||
```bash
|
||||
ngrok http 9222
|
||||
```
|
||||
|
||||
This will print a forwarding URL, such as `https://abcdef.ngrok.app`. **Copy this URL**.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Execution (Remote Server)
|
||||
|
||||
Now switch to your **Remote Server** where OpenCLI is installed.
|
||||
|
||||
Depending on the network tunnel method you chose in Phase 2, set the `OPENCLI_CDP_ENDPOINT` environment variable and run your commands.
|
||||
|
||||
### If you used Method A (SSH Tunnel):
|
||||
|
||||
```bash
|
||||
export OPENCLI_CDP_ENDPOINT="http://localhost:9222"
|
||||
opencli doctor # Verify connection
|
||||
opencli bilibili hot --limit 5 # Test a command
|
||||
```
|
||||
|
||||
### If you used Method B (Reverse Proxy like ngrok):
|
||||
|
||||
```bash
|
||||
# Use the URL you copied from ngrok earlier
|
||||
export OPENCLI_CDP_ENDPOINT="https://abcdef.ngrok.app"
|
||||
opencli doctor # Verify connection
|
||||
opencli bilibili hot --limit 5 # Test a command
|
||||
```
|
||||
|
||||
> *Tip: If you provide a standard HTTP/HTTPS CDP endpoint, OpenCLI requests the `/json` target list and picks the most likely inspectable app/page target automatically. If multiple app targets exist, you can further narrow selection with `OPENCLI_CDP_TARGET` (for example `antigravity` or `codex`).*
|
||||
|
||||
If you plan to use this setup frequently, you can persist the environment variable by adding the `export` line to your `~/.bashrc` or `~/.zshrc` on the server.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Download Support
|
||||
|
||||
OpenCLI supports downloading images, videos, and articles from supported platforms.
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
| Platform | Content Types | Notes |
|
||||
|----------|---------------|-------|
|
||||
| **xiaohongshu** | Images, Videos | Downloads all media from a note |
|
||||
| **bilibili** | Videos | Requires `yt-dlp` installed |
|
||||
| **twitter** | Images, Videos | Downloads from user media tab or single tweet |
|
||||
| **zhihu** | Articles (Markdown) | Exports articles with optional image download |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
For video downloads from streaming platforms, install `yt-dlp`:
|
||||
|
||||
```bash
|
||||
# Install yt-dlp
|
||||
pip install yt-dlp
|
||||
# or
|
||||
brew install yt-dlp
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Download images/videos from Xiaohongshu note
|
||||
opencli xiaohongshu download --note_id abc123 --output ./xhs
|
||||
|
||||
# Download Bilibili video (requires yt-dlp)
|
||||
opencli bilibili download --bvid BV1xxx --output ./bilibili
|
||||
opencli bilibili download --bvid BV1xxx --quality 1080p
|
||||
|
||||
# Download Twitter media from user
|
||||
opencli twitter download --username elonmusk --limit 20 --output ./twitter
|
||||
|
||||
# Download single tweet media
|
||||
opencli twitter download --tweet-url "https://x.com/user/status/123" --output ./twitter
|
||||
|
||||
# Export Zhihu article to Markdown
|
||||
opencli zhihu download --url "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
|
||||
|
||||
# Export with local images
|
||||
opencli zhihu download --url "https://zhuanlan.zhihu.com/p/xxx" --download-images
|
||||
```
|
||||
|
||||
## Pipeline Step (YAML Adapters)
|
||||
|
||||
The `download` step can be used in YAML pipelines:
|
||||
|
||||
::: v-pre
|
||||
```yaml
|
||||
pipeline:
|
||||
- fetch: https://api.example.com/media
|
||||
- download:
|
||||
url: ${{ item.imageUrl }}
|
||||
dir: ./downloads
|
||||
filename: ${{ item.title | sanitize }}.jpg
|
||||
concurrency: 5
|
||||
skip_existing: true
|
||||
```
|
||||
:::
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
description: How to CLI-ify and automate any Electron Desktop Application via CDP
|
||||
---
|
||||
|
||||
# CLI-ifying Electron Applications (Skill Guide)
|
||||
|
||||
Based on the successful automation of **Cursor**, **Codex**, **Antigravity**, **ChatWise**, **Notion**, and **Discord** desktop apps, this guide serves as the standard operating procedure (SOP) for adapting ANY Electron-based application into an OpenCLI adapter.
|
||||
|
||||
## Core Concept
|
||||
|
||||
Electron apps are essentially local Chromium browser instances. By exposing a debugging port (CDP — Chrome DevTools Protocol) at launch time, we can use the Browser Bridge to pierce through the UI layer, accessing and controlling all underlying state including React/Vue components and Shadow DOM.
|
||||
|
||||
> **Note:** Not all desktop apps are Electron. WeChat (native Cocoa) and Feishu/Lark (custom Lark Framework) embed Chromium but do NOT expose CDP. For those apps, use the AppleScript + clipboard approach instead (see [Non-Electron Pattern](#non-electron-pattern-applescript)).
|
||||
|
||||
### Launching the Target App
|
||||
```bash
|
||||
/Applications/AppName.app/Contents/MacOS/AppName --remote-debugging-port=9222
|
||||
```
|
||||
|
||||
### Verifying Electron
|
||||
```bash
|
||||
# Check for Electron Framework in the app bundle
|
||||
ls /Applications/AppName.app/Contents/Frameworks/Electron\ Framework.framework
|
||||
# If this directory exists → Electron → CDP works
|
||||
# If not → check for libEGL.dylib (embedded Chromium/CEF, CDP may not work)
|
||||
```
|
||||
|
||||
## The 5-Command Pattern (CDP / Electron)
|
||||
|
||||
Every new Electron adapter should implement these 5 commands in `src/clis/<app_name>/`:
|
||||
|
||||
### 1. `status.ts` — Connection Test
|
||||
```typescript
|
||||
export const statusCommand = cli({
|
||||
site: 'myapp',
|
||||
name: 'status',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true, // Requires CDP connection
|
||||
args: [],
|
||||
columns: ['Status', 'Url', 'Title'],
|
||||
func: async (page: IPage) => {
|
||||
const url = await page.evaluate('window.location.href');
|
||||
const title = await page.evaluate('document.title');
|
||||
return [{ Status: 'Connected', Url: url, Title: title }];
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 2. `dump.ts` — Reverse Engineering Core
|
||||
Modern app DOMs are huge and obfuscated. **Never guess selectors.** Dump first, then extract precise class names with AI or `grep`:
|
||||
```typescript
|
||||
const dom = await page.evaluate('document.body.innerHTML');
|
||||
fs.writeFileSync('/tmp/app-dom.html', dom);
|
||||
const snap = await page.snapshot({ interactive: false });
|
||||
fs.writeFileSync('/tmp/app-snapshot.json', JSON.stringify(snap, null, 2));
|
||||
```
|
||||
|
||||
### 3. `send.ts` — Advanced Text Injection
|
||||
Electron apps often use complex rich-text editors (Monaco, Lexical, ProseMirror). Setting `.value` directly is ignored by React state.
|
||||
|
||||
**Best practice:** Use `document.execCommand('insertText')` to perfectly simulate real user input, fully piercing React state:
|
||||
```javascript
|
||||
const composer = document.querySelector('[contenteditable="true"]');
|
||||
composer.focus();
|
||||
document.execCommand('insertText', false, 'Hello');
|
||||
```
|
||||
Then submit with `await page.pressKey('Enter')`.
|
||||
|
||||
### 4. `read.ts` — Context Extraction
|
||||
Don't extract the entire page text. Use `dump.ts` output to find the real "conversation container":
|
||||
- Look for semantic selectors: `[role="log"]`, `[data-testid="conversation"]`, `[data-content-search-turn-key]`
|
||||
- Format output as Markdown — readable by both humans and LLMs
|
||||
|
||||
### 5. `new.ts` — Keyboard Shortcuts
|
||||
Many GUI actions respond to native shortcuts rather than button clicks:
|
||||
```typescript
|
||||
const isMac = process.platform === 'darwin';
|
||||
await page.pressKey(isMac ? 'Meta+N' : 'Control+N');
|
||||
await page.wait(1); // Wait for re-render
|
||||
```
|
||||
|
||||
## Environment Variable
|
||||
```bash
|
||||
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9222"
|
||||
```
|
||||
|
||||
## Non-Electron Pattern (AppleScript)
|
||||
|
||||
For native macOS apps (WeChat, Feishu) that don't expose CDP:
|
||||
```typescript
|
||||
export const statusCommand = cli({
|
||||
site: 'myapp',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false, // No browser needed
|
||||
func: async (page: IPage | null) => {
|
||||
const output = execSync("osascript -e 'application \"MyApp\" is running'", { encoding: 'utf-8' }).trim();
|
||||
return [{ Status: output === 'true' ? 'Running' : 'Stopped' }];
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Core techniques:
|
||||
- **status**: `osascript -e 'application "AppName" is running'`
|
||||
- **send**: `pbcopy` → activate window → `Cmd+V` → `Enter`
|
||||
- **read**: `Cmd+A` → `Cmd+C` → `pbpaste`
|
||||
- **search**: Activate → `Cmd+F`/`Cmd+K` → `keystroke "query"`
|
||||
|
||||
## Pitfalls & Gotchas
|
||||
|
||||
1. **Port conflicts (EADDRINUSE)**: Only one app per port. Use unique ports: Codex=9222, ChatGPT=9224, Cursor=9226, ChatWise=9228, Notion=9230, Discord=9232
|
||||
2. **IPage abstraction**: OpenCLI wraps the browser page as `IPage` (`src/types.ts`). Use `page.pressKey()` and `page.evaluate()`, NOT direct DOM APIs
|
||||
3. **Timing**: Always add `await page.wait(0.5)` to `1.0` after DOM mutations. Returning too early disconnects prematurely
|
||||
4. **AppleScript requires Accessibility**: Terminal app must be granted permission in System Settings → Privacy & Security → Accessibility
|
||||
|
||||
## Port Assignment Table
|
||||
|
||||
| App | Port | Mode |
|
||||
|-----|------|------|
|
||||
| Codex | 9222 | CDP |
|
||||
| ChatGPT | 9224 | CDP / AppleScript |
|
||||
| Cursor | 9226 | CDP |
|
||||
| ChatWise | 9228 | CDP |
|
||||
| Notion | 9230 | CDP |
|
||||
| Discord App | 9232 | CDP |
|
||||
@@ -0,0 +1,72 @@
|
||||
# Remote Chrome
|
||||
|
||||
Run OpenCLI on a server or headless environment by connecting to a remote Chrome instance.
|
||||
|
||||
## Use Cases
|
||||
|
||||
- Running CLI commands on a remote server
|
||||
- CI/CD automation with headed browser
|
||||
- Shared team browser sessions
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Start Chrome on the Remote Machine
|
||||
|
||||
```bash
|
||||
# On the remote machine (or your Mac)
|
||||
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
|
||||
--remote-debugging-port=9222
|
||||
```
|
||||
|
||||
### 2. SSH Tunnel (If Needed)
|
||||
|
||||
If the remote Chrome is on a different machine, create an SSH tunnel:
|
||||
|
||||
```bash
|
||||
# On your local machine or server
|
||||
ssh -L 9222:127.0.0.1:9222 user@remote-host
|
||||
```
|
||||
|
||||
::: warning
|
||||
Use `127.0.0.1` instead of `localhost` in the SSH command to avoid IPv6 resolution issues that can cause timeouts.
|
||||
:::
|
||||
|
||||
### 3. Configure OpenCLI
|
||||
|
||||
```bash
|
||||
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9222"
|
||||
```
|
||||
|
||||
### 4. Verify
|
||||
|
||||
```bash
|
||||
# Test the connection
|
||||
curl http://127.0.0.1:9222/json/version
|
||||
|
||||
# Run a diagnostic
|
||||
opencli doctor
|
||||
```
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
For CI/CD environments, use a real Chrome instance with `xvfb`:
|
||||
|
||||
::: v-pre
|
||||
```yaml
|
||||
steps:
|
||||
- uses: browser-actions/setup-chrome@latest
|
||||
id: setup-chrome
|
||||
- run: |
|
||||
xvfb-run --auto-servernum \
|
||||
${{ steps.setup-chrome.outputs.chrome-path }} \
|
||||
--remote-debugging-port=9222 &
|
||||
```
|
||||
:::
|
||||
|
||||
Set the browser executable path:
|
||||
::: v-pre
|
||||
```yaml
|
||||
env:
|
||||
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
|
||||
```
|
||||
:::
|
||||
@@ -0,0 +1,66 @@
|
||||
# AI Workflow
|
||||
|
||||
OpenCLI is designed with AI agents in mind. This guide covers the AI-native discovery and code generation tools.
|
||||
|
||||
## Quick Mode (One-Shot)
|
||||
|
||||
Generate a single command for a specific page URL — just a URL + one-line goal, 4 steps done:
|
||||
|
||||
```bash
|
||||
opencli generate https://example.com --goal "trending"
|
||||
```
|
||||
|
||||
This runs: explore → synthesize → register in one shot.
|
||||
|
||||
For the complete one-shot workflow details, see [CLI-ONESHOT.md](https://github.com/jackwener/opencli/blob/main/CLI-ONESHOT.md).
|
||||
|
||||
## Full Mode (Explorer Workflow)
|
||||
|
||||
### Step 1: Deep Explore
|
||||
|
||||
Discover APIs, infer capabilities, and detect framework:
|
||||
|
||||
```bash
|
||||
opencli explore https://example.com --site mysite
|
||||
```
|
||||
|
||||
Outputs to `.opencli/explore/<site>/`:
|
||||
- `manifest.json` — Site metadata
|
||||
- `endpoints.json` — Discovered API endpoints
|
||||
- `capabilities.json` — Inferred capabilities
|
||||
- `auth.json` — Authentication strategy details
|
||||
|
||||
### Step 2: Synthesize
|
||||
|
||||
Generate YAML adapters from explore artifacts:
|
||||
|
||||
```bash
|
||||
opencli synthesize mysite
|
||||
```
|
||||
|
||||
### Step 3: Strategy Cascade
|
||||
|
||||
Auto-probe authentication strategies: `PUBLIC → COOKIE → HEADER`:
|
||||
|
||||
```bash
|
||||
opencli cascade https://api.example.com/data
|
||||
```
|
||||
|
||||
### Step 4: Validate & Test
|
||||
|
||||
```bash
|
||||
opencli validate # Validate generated YAML
|
||||
opencli <site> <command> --limit 3 -f json # Test the command
|
||||
```
|
||||
|
||||
## 5-Tier Authentication Strategy
|
||||
|
||||
The explorer uses a decision tree to determine the best authentication approach:
|
||||
|
||||
1. **PUBLIC** — No auth, direct API call
|
||||
2. **COOKIE** — Reuse Chrome session cookies
|
||||
3. **HEADER** — Custom auth headers
|
||||
4. **BROWSER** — Full browser automation
|
||||
5. **CDP** — Chrome DevTools Protocol for Electron apps
|
||||
|
||||
For the complete browser exploration workflow and debugging guide, see [CLI-EXPLORER.md](https://github.com/jackwener/opencli/blob/main/CLI-EXPLORER.md).
|
||||
@@ -0,0 +1,90 @@
|
||||
# Architecture
|
||||
|
||||
OpenCLI is built on a **Dual-Engine Architecture** that supports both declarative YAML pipelines and programmatic TypeScript adapters.
|
||||
|
||||
## High-Level Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ opencli CLI │
|
||||
│ (Commander.js entry point) │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ Engine Layer │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌────────────┐ │
|
||||
│ │ Registry │ │ Dynamic │ │ Output │ │
|
||||
│ │ (commands) │ │ Loader │ │ Formatter │ │
|
||||
│ └──────────────┘ └──────────────┘ └────────────┘ │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ Adapter Layer │
|
||||
│ ┌─────────────────┐ ┌──────────────────────────┐ │
|
||||
│ │ YAML Pipeline │ │ TypeScript Adapters │ │
|
||||
│ │ (declarative) │ │ (browser/desktop/AI) │ │
|
||||
│ └─────────────────┘ └──────────────────────────┘ │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ Connection Layer │
|
||||
│ ┌─────────────────┐ ┌──────────────────────────┐ │
|
||||
│ │ Browser Bridge │ │ CDP (Chrome DevTools) │ │
|
||||
│ │ (Extension+WS) │ │ (Electron apps) │ │
|
||||
│ └─────────────────┘ └──────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Core Modules
|
||||
|
||||
### Registry (`src/registry.ts`)
|
||||
Central command registry. All adapters register their commands via the `cli()` function with metadata: site, name, description, domain, strategy, args, columns.
|
||||
|
||||
### Engine (`src/engine.ts`)
|
||||
Command discovery and execution engine. Discovers commands from the registry, parses arguments, executes the appropriate adapter, and routes output through the formatter.
|
||||
|
||||
### Browser (`src/browser.ts`)
|
||||
Manages connections to Chrome via the Browser Bridge WebSocket daemon. Handles JSON-RPC messaging, tab management, and extension/standalone mode switching.
|
||||
|
||||
### Pipeline (`src/pipeline/`)
|
||||
The YAML pipeline engine. Processes declarative steps:
|
||||
- **fetch** — HTTP requests with cookie/header strategies
|
||||
- **map** — Data transformation with template expressions
|
||||
- **limit** — Result truncation
|
||||
- **filter** — Conditional filtering
|
||||
- **download** — Media download support
|
||||
|
||||
### Output (`src/output.ts`)
|
||||
Unified output formatting: `table`, `json`, `yaml`, `md`, `csv`.
|
||||
|
||||
## Authentication Strategies
|
||||
|
||||
OpenCLI uses a 3-tier authentication strategy:
|
||||
|
||||
| Strategy | How It Works | When to Use |
|
||||
|----------|-------------|-------------|
|
||||
| `public` | Direct HTTP fetch, no auth | Public APIs (HackerNews, BBC) |
|
||||
| `cookie` | Reuse Chrome cookies via Browser Bridge | Logged-in sites (Bilibili, Zhihu) |
|
||||
| `header` | Custom auth headers | API-key based services |
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.ts # Entry point
|
||||
├── engine.ts # Command execution engine
|
||||
├── registry.ts # Command registry
|
||||
├── browser.ts # Browser Bridge connection
|
||||
├── output.ts # Output formatting
|
||||
├── doctor.ts # Diagnostic tool
|
||||
├── pipeline/ # YAML pipeline engine
|
||||
│ ├── runner.ts
|
||||
│ ├── template.ts
|
||||
│ ├── transform.ts
|
||||
│ └── steps/
|
||||
│ ├── fetch.ts
|
||||
│ ├── map.ts
|
||||
│ ├── limit.ts
|
||||
│ ├── filter.ts
|
||||
│ └── download.ts
|
||||
└── clis/ # Site adapters
|
||||
├── twitter/
|
||||
├── reddit/
|
||||
├── bilibili/
|
||||
├── cursor/
|
||||
└── ...
|
||||
```
|
||||
@@ -0,0 +1,136 @@
|
||||
# Contributing
|
||||
|
||||
Thanks for your interest in contributing to OpenCLI.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Fork & clone
|
||||
git clone git@github.com:<your-username>/opencli.git
|
||||
cd opencli
|
||||
|
||||
# 2. Install dependencies
|
||||
npm install
|
||||
|
||||
# 3. Build
|
||||
npm run build
|
||||
|
||||
# 4. Run a few checks
|
||||
npx tsc --noEmit
|
||||
npx vitest run src/
|
||||
|
||||
# 5. Link globally (optional, for testing `opencli` command)
|
||||
npm link
|
||||
```
|
||||
|
||||
## Adding a New Site Adapter
|
||||
|
||||
This is the most common type of contribution. Start with YAML when possible, and use TypeScript only when you need browser-side logic or multi-step flows.
|
||||
|
||||
### YAML Adapter (Recommended for data-fetching commands)
|
||||
|
||||
Create a file like `src/clis/<site>/<command>.yaml`:
|
||||
|
||||
::: v-pre
|
||||
```yaml
|
||||
site: mysite
|
||||
name: trending
|
||||
description: Trending posts on MySite
|
||||
domain: www.mysite.com
|
||||
strategy: public # public | cookie | header
|
||||
browser: false # true if browser session is needed
|
||||
|
||||
args:
|
||||
limit:
|
||||
type: int
|
||||
default: 20
|
||||
description: Number of items
|
||||
|
||||
pipeline:
|
||||
- fetch:
|
||||
url: https://api.mysite.com/trending
|
||||
|
||||
- map:
|
||||
rank: ${{ index + 1 }}
|
||||
title: ${{ item.title }}
|
||||
score: ${{ item.score }}
|
||||
url: ${{ item.url }}
|
||||
|
||||
- limit: ${{ args.limit }}
|
||||
|
||||
columns: [rank, title, score, url]
|
||||
```
|
||||
:::
|
||||
|
||||
See [`hackernews/top.yaml`](https://github.com/jackwener/opencli/blob/main/src/clis/hackernews/top.yaml) for a real example.
|
||||
|
||||
### TypeScript Adapter (For complex browser interactions)
|
||||
|
||||
Create a file like `src/clis/<site>/<command>.ts`:
|
||||
|
||||
```typescript
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
cli({
|
||||
site: 'mysite',
|
||||
name: 'search',
|
||||
description: 'Search MySite',
|
||||
domain: 'www.mysite.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
args: [
|
||||
{ name: 'query', required: true, help: 'Search query' },
|
||||
{ name: 'limit', type: 'int', default: 10, help: 'Max results' },
|
||||
],
|
||||
columns: ['title', 'url', 'date'],
|
||||
|
||||
func: async (page, kwargs) => {
|
||||
const { query, limit = 10 } = kwargs;
|
||||
// ... browser automation logic
|
||||
return data.slice(0, Number(limit)).map((item: any) => ({
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
date: item.created_at,
|
||||
}));
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Validate Your Adapter
|
||||
|
||||
```bash
|
||||
opencli validate # Validate YAML syntax and schema
|
||||
opencli <site> <command> --limit 3 -f json # Test your command
|
||||
opencli <site> <command> -v # Verbose mode for debugging
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
- **TypeScript strict mode** — avoid `any` where possible.
|
||||
- **ES Modules** — use `.js` extensions in imports (TypeScript output).
|
||||
- **Naming**: `kebab-case` for files, `camelCase` for variables/functions, `PascalCase` for types/classes.
|
||||
- **No default exports** — use named exports.
|
||||
|
||||
## Commit Convention
|
||||
|
||||
We use [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
feat(twitter): add thread command
|
||||
fix(browser): handle CDP timeout gracefully
|
||||
docs: update CONTRIBUTING.md
|
||||
test(reddit): add e2e test for save command
|
||||
chore: bump vitest to v4
|
||||
```
|
||||
|
||||
## Submitting a Pull Request
|
||||
|
||||
1. Create a feature branch: `git checkout -b feat/mysite-trending`
|
||||
2. Make your changes and add tests when relevant
|
||||
3. Run the checks:
|
||||
```bash
|
||||
npx tsc --noEmit # Type check
|
||||
npx vitest run src/ # Unit tests
|
||||
opencli validate # YAML validation (if applicable)
|
||||
```
|
||||
4. Commit using conventional commit format
|
||||
5. Push and open a PR
|
||||
@@ -0,0 +1,237 @@
|
||||
# Testing Guide
|
||||
|
||||
> 面向开发者和 AI Agent 的测试参考手册。
|
||||
|
||||
## 目录
|
||||
|
||||
- [测试架构](#测试架构)
|
||||
- [当前覆盖范围](#当前覆盖范围)
|
||||
- [本地运行测试](#本地运行测试)
|
||||
- [如何添加新测试](#如何添加新测试)
|
||||
- [CI/CD 流水线](#cicd-流水线)
|
||||
- [浏览器模式](#浏览器模式)
|
||||
- [站点兼容性](#站点兼容性)
|
||||
|
||||
---
|
||||
|
||||
## 测试架构
|
||||
|
||||
测试分为三层,全部使用 **vitest** 运行:
|
||||
|
||||
```
|
||||
tests/
|
||||
├── e2e/ # E2E 集成测试(子进程运行真实 CLI)
|
||||
│ ├── helpers.ts # runCli() 共享工具
|
||||
│ ├── public-commands.test.ts # 公开 API 命令(无需浏览器)
|
||||
│ ├── browser-public.test.ts # 浏览器命令(公开数据)
|
||||
│ ├── browser-auth.test.ts # 需登录命令(graceful failure 测试)
|
||||
│ ├── management.test.ts # 管理命令(list, validate, verify, help)
|
||||
│ └── output-formats.test.ts # 输出格式(json/yaml/csv/md)
|
||||
├── smoke/ # 烟雾测试(仅定时 / 手动触发)
|
||||
│ └── api-health.test.ts # 外部 API 可用性检测
|
||||
src/
|
||||
├── *.test.ts # 单元测试(已有 8 个)
|
||||
```
|
||||
|
||||
| 层 | 位置 | 运行方式 | 用途 |
|
||||
|---|---|---|---|
|
||||
| 单元测试 | `src/**/*.test.ts` | `npx vitest run src/` | 内部模块逻辑 |
|
||||
| E2E 测试 | `tests/e2e/*.test.ts` | `npx vitest run tests/e2e/` | 真实 CLI 命令执行 |
|
||||
| 烟雾测试 | `tests/smoke/*.test.ts` | `npx vitest run tests/smoke/` | 外部 API 健康 |
|
||||
|
||||
---
|
||||
|
||||
## 当前覆盖范围
|
||||
|
||||
### 单元测试(8 个文件)
|
||||
|
||||
| 文件 | 覆盖内容 |
|
||||
|---|---|
|
||||
| `browser.test.ts` | JSON-RPC、tab 管理、extension/standalone 模式切换 |
|
||||
| `engine.test.ts` | 命令发现与执行 |
|
||||
| `registry.test.ts` | 命令注册与策略分配 |
|
||||
| `output.test.ts` | 输出格式渲染 |
|
||||
| `doctor.test.ts` | Token 诊断 |
|
||||
| `coupang.test.ts` | 数据归一化 |
|
||||
| `pipeline/template.test.ts` | 模板表达式求值 |
|
||||
| `pipeline/transform.test.ts` | 数据变换步骤 |
|
||||
|
||||
### E2E 测试(~52 个用例)
|
||||
|
||||
| 文件 | 覆盖站点/功能 | 测试数 |
|
||||
|---|---|---|
|
||||
| `public-commands.test.ts` | hackernews/top, v2ex/hot, v2ex/latest, v2ex/topic | 5 |
|
||||
| `browser-public.test.ts` | bbc, bilibili×3, weibo, zhihu×2, reddit×2, twitter, xueqiu×2, reuters, youtube, smzdm, boss, ctrip, coupang, xiaohongshu, yahoo-finance, v2ex/daily | 21 |
|
||||
| `browser-auth.test.ts` | bilibili/me,dynamic,favorite,history,following + twitter/bookmarks,timeline,notifications + v2ex/me,notifications + xueqiu/feed,watchlist + xiaohongshu/feed,notifications | 14 |
|
||||
| `management.test.ts` | list×5 格式, validate×3 级别, verify, --version, --help, unknown cmd | 12 |
|
||||
| `output-formats.test.ts` | json, yaml, csv, md 格式验证 | 5 |
|
||||
|
||||
### 烟雾测试
|
||||
|
||||
公开 API 可用性(hackernews, v2ex×2, v2ex/topic)+ 全站点注册完整性检查。
|
||||
|
||||
---
|
||||
|
||||
## 本地运行测试
|
||||
|
||||
### 前置条件
|
||||
|
||||
```bash
|
||||
npm ci # 安装依赖
|
||||
npm run build # 编译(E2E 测试需要 dist/main.js)
|
||||
```
|
||||
|
||||
### 运行命令
|
||||
|
||||
```bash
|
||||
# 全部单元测试
|
||||
npx vitest run src/
|
||||
|
||||
# 全部 E2E 测试(会真实调用外部 API)
|
||||
npx vitest run tests/e2e/
|
||||
|
||||
# 单个测试文件
|
||||
npx vitest run tests/e2e/management.test.ts
|
||||
|
||||
# 全部测试(单元 + E2E)
|
||||
npx vitest run
|
||||
|
||||
# 烟雾测试
|
||||
npx vitest run tests/smoke/
|
||||
|
||||
# watch 模式(开发时推荐)
|
||||
npx vitest src/
|
||||
```
|
||||
|
||||
### 浏览器命令本地测试须知
|
||||
|
||||
- opencli 通过 Browser Bridge 扩展连接已运行的 Chrome 浏览器
|
||||
- `browser-public.test.ts` 使用 `tryBrowserCommand()`,站点反爬导致空数据时 warn + pass
|
||||
- `browser-auth.test.ts` 验证 **graceful failure**(不 crash 不 hang 即通过)
|
||||
- 如需测试完整登录态,保持 Chrome 登录态并安装 Browser Bridge 扩展,手动跑对应测试
|
||||
|
||||
---
|
||||
|
||||
## 如何添加新测试
|
||||
|
||||
### 新增 YAML Adapter(如 `src/clis/producthunt/trending.yaml`)
|
||||
|
||||
1. **无需额外操作**:`validate` 测试会自动覆盖 YAML 结构验证
|
||||
2. 根据 adapter 类型,在对应文件加一个 `it()` block:
|
||||
|
||||
```typescript
|
||||
// 如果 browser: false(公开 API)→ tests/e2e/public-commands.test.ts
|
||||
it('producthunt trending returns data', async () => {
|
||||
const { stdout, code } = await runCli(['producthunt', 'trending', '--limit', '3', '-f', 'json']);
|
||||
expect(code).toBe(0);
|
||||
const data = parseJsonOutput(stdout);
|
||||
expect(Array.isArray(data)).toBe(true);
|
||||
expect(data.length).toBeGreaterThanOrEqual(1);
|
||||
expect(data[0]).toHaveProperty('title');
|
||||
}, 30_000);
|
||||
```
|
||||
|
||||
```typescript
|
||||
// 如果 browser: true 但可公开访问 → tests/e2e/browser-public.test.ts
|
||||
it('producthunt trending returns data', async () => {
|
||||
const data = await tryBrowserCommand(['producthunt', 'trending', '--limit', '3', '-f', 'json']);
|
||||
expectDataOrSkip(data, 'producthunt trending');
|
||||
}, 60_000);
|
||||
```
|
||||
|
||||
```typescript
|
||||
// 如果 browser: true 且需登录 → tests/e2e/browser-auth.test.ts
|
||||
it('producthunt me fails gracefully without login', async () => {
|
||||
await expectGracefulAuthFailure(['producthunt', 'me', '-f', 'json'], 'producthunt me');
|
||||
}, 60_000);
|
||||
```
|
||||
|
||||
### 新增管理命令(如 `opencli export`)
|
||||
|
||||
在 `tests/e2e/management.test.ts` 添加测试。
|
||||
|
||||
### 新增内部模块
|
||||
|
||||
在 `src/` 下对应位置创建 `*.test.ts`。
|
||||
|
||||
### 决策流程图
|
||||
|
||||
```
|
||||
新增功能 → 是内部模块? → 是 → src/ 下加 *.test.ts
|
||||
↓ 否
|
||||
是 CLI 命令? → browser: false? → tests/e2e/public-commands.test.ts
|
||||
↓ true
|
||||
公开数据? → tests/e2e/browser-public.test.ts
|
||||
↓ 需登录
|
||||
tests/e2e/browser-auth.test.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CI/CD 流水线
|
||||
|
||||
### ci.yml(主流水线)
|
||||
|
||||
| Job | 触发条件 | 内容 |
|
||||
|---|---|---|
|
||||
| **build** | push/PR to main,dev | typecheck + build |
|
||||
| **unit-test** | push/PR to main,dev | 单元测试,2 shard 并行 |
|
||||
| **smoke-test** | 每周一 08:00 UTC / 手动 | xvfb + real Chrome,外部 API 健康检查 |
|
||||
|
||||
### e2e-headed.yml(E2E 测试)
|
||||
|
||||
| Job | 触发条件 | 内容 |
|
||||
|---|---|---|
|
||||
| **e2e-headed** | push/PR to main,dev | xvfb + real Chrome,全部 E2E 测试 |
|
||||
|
||||
E2E 使用 `browser-actions/setup-chrome` 安装真实 Chrome,配合 `xvfb-run` 提供虚拟显示器,以 headed 模式运行浏览器。
|
||||
|
||||
### Sharding
|
||||
|
||||
单元测试使用 vitest 内置 shard:
|
||||
|
||||
::: v-pre
|
||||
```yaml
|
||||
strategy:
|
||||
matrix:
|
||||
shard: [1, 2]
|
||||
steps:
|
||||
- run: npx vitest run src/ --shard=${{ matrix.shard }}/2
|
||||
```
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## 浏览器模式
|
||||
|
||||
opencli 通过 Browser Bridge 扩展连接浏览器:
|
||||
|
||||
| 条件 | 模式 | 使用场景 |
|
||||
|---|---|---|
|
||||
| 扩展已安装 | Extension 模式 | 本地用户,连接已登录的 Chrome |
|
||||
| 扩展未安装 | CLI 报错提示安装 | 需要安装 Browser Bridge 扩展 |
|
||||
|
||||
CI 中使用 `OPENCLI_BROWSER_EXECUTABLE_PATH` 指定真实 Chrome 路径:
|
||||
|
||||
::: v-pre
|
||||
```yaml
|
||||
env:
|
||||
OPENCLI_BROWSER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
|
||||
```
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## 站点兼容性
|
||||
|
||||
在 GitHub Actions 美国 runner 上,部分站点因地域限制或登录要求返回空数据。E2E 测试对这些站点使用 warn + pass 策略,不影响 CI 绿灯。
|
||||
|
||||
| 站点 | CI 状态 | 限制原因 |
|
||||
|---|---|---|
|
||||
| hackernews, bbc, v2ex | ✅ 返回数据 | 无限制 |
|
||||
| yahoo-finance | ✅ 返回数据 | 无限制 |
|
||||
| bilibili, zhihu, weibo, xiaohongshu | ⚠️ 空数据 | 地域限制(中国站点) |
|
||||
| reddit, twitter, youtube | ⚠️ 空数据 | 需登录或 cookie |
|
||||
| smzdm, boss, ctrip, coupang, xueqiu | ⚠️ 空数据 | 地域限制 / 需登录 |
|
||||
|
||||
> 使用 self-hosted runner(国内服务器)可解决地域限制问题。
|
||||
@@ -0,0 +1,87 @@
|
||||
# TypeScript Adapter Guide
|
||||
|
||||
Use TypeScript adapters when you need browser-side logic, multi-step flows, DOM manipulation, or complex data extraction that goes beyond simple API fetching.
|
||||
|
||||
## Basic Structure
|
||||
|
||||
```typescript
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
cli({
|
||||
site: 'mysite',
|
||||
name: 'search',
|
||||
description: 'Search MySite',
|
||||
domain: 'www.mysite.com',
|
||||
strategy: Strategy.COOKIE, // PUBLIC | COOKIE | HEADER
|
||||
args: [
|
||||
{ name: 'query', required: true, help: 'Search query' },
|
||||
{ name: 'limit', type: 'int', default: 10, help: 'Max results' },
|
||||
],
|
||||
columns: ['title', 'url', 'date'],
|
||||
|
||||
func: async (page, kwargs) => {
|
||||
const { query, limit = 10 } = kwargs;
|
||||
|
||||
// Navigate and extract data
|
||||
await page.goto('https://www.mysite.com');
|
||||
|
||||
const data = await page.evaluate(`
|
||||
(async () => {
|
||||
const res = await fetch('/api/search?q=${encodeURIComponent(String(query))}', {
|
||||
credentials: 'include'
|
||||
});
|
||||
return (await res.json()).results;
|
||||
})()
|
||||
`);
|
||||
|
||||
return data.slice(0, Number(limit)).map((item: any) => ({
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
date: item.created_at,
|
||||
}));
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Strategy Types
|
||||
|
||||
| Strategy | Constant | Use Case |
|
||||
|----------|----------|----------|
|
||||
| Public | `Strategy.PUBLIC` | No auth needed |
|
||||
| Cookie | `Strategy.COOKIE` | Browser session cookies |
|
||||
| Header | `Strategy.HEADER` | Custom headers/tokens |
|
||||
|
||||
## The `page` Object
|
||||
|
||||
The `page` parameter provides browser interaction methods:
|
||||
|
||||
- `page.goto(url)` — Navigate to a URL
|
||||
- `page.evaluate(script)` — Execute JavaScript in the page context
|
||||
- `page.waitForSelector(selector)` — Wait for an element
|
||||
- `page.click(selector)` — Click an element
|
||||
- `page.type(selector, text)` — Type text into an input
|
||||
|
||||
## The `kwargs` Object
|
||||
|
||||
Contains parsed CLI arguments as key-value pairs. Always destructure with defaults:
|
||||
|
||||
```typescript
|
||||
const { query, limit = 10, format = 'json' } = kwargs;
|
||||
```
|
||||
|
||||
## AI-Assisted Development
|
||||
|
||||
Use the AI workflow tools to accelerate adapter creation:
|
||||
|
||||
```bash
|
||||
# Discover APIs and page structure
|
||||
opencli explore https://example.com --site mysite
|
||||
|
||||
# Auto-generate adapter from explore artifacts
|
||||
opencli synthesize mysite
|
||||
|
||||
# One-shot: explore → synthesize → register
|
||||
opencli generate https://example.com --goal "trending"
|
||||
```
|
||||
|
||||
See [AI Workflow](/developer/ai-workflow) for the complete guide.
|
||||
@@ -0,0 +1,108 @@
|
||||
# YAML Adapter Guide
|
||||
|
||||
YAML adapters are the recommended way to add new commands when the site offers a straightforward API. They use a declarative pipeline approach — no TypeScript required.
|
||||
|
||||
## Basic Structure
|
||||
|
||||
::: v-pre
|
||||
```yaml
|
||||
site: mysite # Site identifier
|
||||
name: trending # Command name (opencli mysite trending)
|
||||
description: ... # Help text
|
||||
domain: www.mysite.com
|
||||
strategy: public # public | cookie | header
|
||||
browser: false # true if browser session is needed
|
||||
|
||||
args: # CLI arguments
|
||||
limit:
|
||||
type: int
|
||||
default: 20
|
||||
description: Number of items
|
||||
|
||||
pipeline: # Data processing steps
|
||||
- fetch:
|
||||
url: https://api.mysite.com/trending
|
||||
|
||||
- map:
|
||||
rank: ${{ index + 1 }}
|
||||
title: ${{ item.title }}
|
||||
|
||||
- limit: ${{ args.limit }}
|
||||
|
||||
columns: [rank, title, score, url]
|
||||
```
|
||||
:::
|
||||
|
||||
## Pipeline Steps
|
||||
|
||||
### `fetch`
|
||||
Fetch data from a URL. Supports template expressions for dynamic URLs.
|
||||
|
||||
::: v-pre
|
||||
```yaml
|
||||
- fetch:
|
||||
url: https://api.example.com/search?q=${{ args.query }}
|
||||
headers:
|
||||
Accept: application/json
|
||||
```
|
||||
:::
|
||||
|
||||
### `map`
|
||||
|
||||
::: v-pre
|
||||
Transform each item in the result array. Use `${{ item.xxx }}` for field access and `${{ index }}` for position.
|
||||
|
||||
```yaml
|
||||
- map:
|
||||
rank: ${{ index + 1 }}
|
||||
title: ${{ item.title }}
|
||||
url: https://example.com${{ item.path }}
|
||||
```
|
||||
:::
|
||||
|
||||
### `limit`
|
||||
Truncate results to N items.
|
||||
|
||||
::: v-pre
|
||||
```yaml
|
||||
- limit: ${{ args.limit }}
|
||||
```
|
||||
:::
|
||||
|
||||
### `filter`
|
||||
Filter items by condition.
|
||||
|
||||
::: v-pre
|
||||
```yaml
|
||||
- filter: ${{ item.score > 100 }}
|
||||
```
|
||||
:::
|
||||
|
||||
### `download`
|
||||
Download media files.
|
||||
|
||||
::: v-pre
|
||||
```yaml
|
||||
- download:
|
||||
url: ${{ item.imageUrl }}
|
||||
dir: ./downloads
|
||||
filename: ${{ item.title | sanitize }}.jpg
|
||||
```
|
||||
:::
|
||||
|
||||
## Template Expressions
|
||||
|
||||
::: v-pre
|
||||
Use `${{ ... }}` for dynamic values:
|
||||
|
||||
| Expression | Description |
|
||||
|-----------|-------------|
|
||||
| `${{ args.limit }}` | CLI argument |
|
||||
| `${{ item.title }}` | Current item field |
|
||||
| `${{ index }}` | Current index (0-based) |
|
||||
| `${{ item.x \| sanitize }}` | Pipe filters |
|
||||
:::
|
||||
|
||||
## Real Example
|
||||
|
||||
See [`src/clis/hackernews/top.yaml`](https://github.com/jackwener/opencli/blob/main/src/clis/hackernews/top.yaml).
|
||||
@@ -0,0 +1,38 @@
|
||||
# Browser Bridge Setup
|
||||
|
||||
> **⚠️ Important**: Browser commands reuse your Chrome login session. You must be logged into the target website in Chrome before running commands.
|
||||
|
||||
OpenCLI connects to your browser through a lightweight **Browser Bridge** Chrome Extension + micro-daemon (zero config, auto-start).
|
||||
|
||||
## Extension Installation
|
||||
|
||||
### Method 1: Download Pre-built Release (Recommended)
|
||||
|
||||
1. Go to the GitHub [Releases page](https://github.com/jackwener/opencli/releases) and download the latest `opencli-extension.zip` or `opencli-extension.crx`.
|
||||
2. Open `chrome://extensions` and enable **Developer mode** (top-right toggle).
|
||||
3. Drag and drop the `.crx` file or the unzipped folder into the extensions page.
|
||||
|
||||
### Method 2: Load Unpacked Source (For Developers)
|
||||
|
||||
1. Open `chrome://extensions` and enable **Developer mode**.
|
||||
2. Click **Load unpacked** and select the `extension/` directory from the repository.
|
||||
|
||||
## Verification
|
||||
|
||||
That's it! The daemon auto-starts when you run any browser command. No tokens, no manual configuration.
|
||||
|
||||
```bash
|
||||
opencli doctor # Check extension + daemon connectivity
|
||||
opencli doctor --live # Also test live browser commands
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
┌─────────────┐ WebSocket ┌──────────────┐ Chrome API ┌─────────┐
|
||||
│ opencli │ ◄──────────────► │ micro-daemon │ ◄──────────────► │ Chrome │
|
||||
│ (Node.js) │ localhost:19825 │ (auto-start) │ Extension │ Browser │
|
||||
└─────────────┘ └──────────────┘ └─────────┘
|
||||
```
|
||||
|
||||
The daemon manages the WebSocket connection between your CLI commands and the Chrome extension. The extension executes JavaScript in the context of web pages, with access to the logged-in session.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Getting Started
|
||||
|
||||
> **Make any website or Electron App your CLI.**
|
||||
> Zero risk · Reuse Chrome login · AI-powered discovery · Browser + Desktop automation
|
||||
|
||||
[](https://www.npmjs.com/package/@jackwener/opencli)
|
||||
[](https://nodejs.org)
|
||||
[](https://github.com/jackwener/opencli/blob/main/LICENSE)
|
||||
|
||||
OpenCLI turns **any website** or **Electron app** into a command-line interface — Bilibili, Zhihu, 小红书, Twitter/X, Reddit, YouTube, Antigravity, and [many more](/adapters/) — powered by browser session reuse and AI-native discovery.
|
||||
|
||||
## Highlights
|
||||
|
||||
- **CLI All Electron** — CLI-ify apps like Antigravity Ultra! Now AI can control itself natively.
|
||||
- **Account-safe** — Reuses Chrome's logged-in state; your credentials never leave the browser.
|
||||
- **AI Agent ready** — `explore` discovers APIs, `synthesize` generates adapters, `cascade` finds auth strategies.
|
||||
- **Self-healing setup** — `opencli setup` verifies Browser Bridge connectivity; `opencli doctor` diagnoses daemon, extension, and live browser connectivity.
|
||||
- **Dynamic Loader** — Simply drop `.ts` or `.yaml` adapters into the `clis/` folder for auto-registration.
|
||||
- **Dual-Engine Architecture** — Supports both YAML declarative data pipelines and robust browser runtime TypeScript injections.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Install via npm
|
||||
|
||||
```bash
|
||||
npm install -g @jackwener/opencli
|
||||
```
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```bash
|
||||
opencli list # See all commands
|
||||
opencli hackernews top --limit 5 # Public API, no browser
|
||||
opencli bilibili hot --limit 5 # Browser command
|
||||
opencli zhihu hot -f json # JSON output
|
||||
```
|
||||
|
||||
### Output Formats
|
||||
|
||||
All built-in commands support `--format` / `-f`:
|
||||
|
||||
```bash
|
||||
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)
|
||||
opencli bilibili hot -f md # Markdown
|
||||
opencli bilibili hot -f csv # CSV
|
||||
opencli bilibili hot -v # Verbose: show pipeline debug
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Installation details](/guide/installation)
|
||||
- [Browser Bridge setup](/guide/browser-bridge)
|
||||
- [All available adapters](/adapters/)
|
||||
- [For developers / AI agents](/developer/contributing)
|
||||
@@ -0,0 +1,37 @@
|
||||
# Installation
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Node.js**: >= 20.0.0
|
||||
- **Chrome** running and logged into the target site (for browser commands)
|
||||
|
||||
## Install via npm (Recommended)
|
||||
|
||||
```bash
|
||||
npm install -g @jackwener/opencli
|
||||
```
|
||||
|
||||
## Install from Source
|
||||
|
||||
```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!
|
||||
```
|
||||
|
||||
## Update
|
||||
|
||||
```bash
|
||||
npm install -g @jackwener/opencli@latest
|
||||
```
|
||||
|
||||
## Verify Installation
|
||||
|
||||
```bash
|
||||
opencli --version # Check version
|
||||
opencli list # List all commands
|
||||
opencli doctor # Diagnose connectivity
|
||||
```
|
||||
@@ -0,0 +1,56 @@
|
||||
# Troubleshooting
|
||||
|
||||
## Common Issues
|
||||
|
||||
### "Extension not connected"
|
||||
|
||||
- Ensure the opencli Browser Bridge extension is installed and **enabled** in `chrome://extensions`.
|
||||
- Run `opencli doctor` to diagnose connectivity.
|
||||
|
||||
### Empty data 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.
|
||||
- Some sites have geographic restrictions (e.g., Bilibili, Zhihu from outside China).
|
||||
|
||||
### Node API errors
|
||||
|
||||
- Make sure you are using **Node.js >= 20**. Some dependencies require modern Node APIs.
|
||||
- Run `node --version` to verify.
|
||||
|
||||
### Daemon issues
|
||||
|
||||
```bash
|
||||
# Check daemon status
|
||||
curl localhost:19825/status
|
||||
|
||||
# View extension logs
|
||||
curl localhost:19825/logs
|
||||
|
||||
# Kill and restart daemon
|
||||
pkill -f opencli-daemon
|
||||
opencli doctor
|
||||
```
|
||||
|
||||
### Desktop adapter connection issues
|
||||
|
||||
For Electron/CDP-based adapters (Cursor, Codex, etc.):
|
||||
|
||||
1. Make sure the app is launched with `--remote-debugging-port=XXXX`
|
||||
2. Verify the endpoint is set: `echo $OPENCLI_CDP_ENDPOINT`
|
||||
3. Test the endpoint: `curl http://127.0.0.1:XXXX/json/version`
|
||||
|
||||
### Build errors
|
||||
|
||||
```bash
|
||||
# Clean rebuild
|
||||
rm -rf dist/
|
||||
npm run build
|
||||
|
||||
# Type check
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
## Getting Help
|
||||
|
||||
- [GitHub Issues](https://github.com/jackwener/opencli/issues) — Bug reports and feature requests
|
||||
- Run `opencli doctor --live` for comprehensive diagnostics
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
layout: home
|
||||
|
||||
hero:
|
||||
name: OpenCLI
|
||||
text: Make any website or Electron App your CLI
|
||||
tagline: Zero risk · Reuse Chrome login · AI-powered discovery · Browser + Desktop automation
|
||||
actions:
|
||||
- theme: brand
|
||||
text: Get Started
|
||||
link: /guide/getting-started
|
||||
- theme: alt
|
||||
text: View on GitHub
|
||||
link: https://github.com/jackwener/opencli
|
||||
|
||||
features:
|
||||
- icon: 🖥️
|
||||
title: CLI All Electron
|
||||
details: Turn ANY Electron application into a CLI tool — Cursor, Codex, Antigravity, ChatGPT, Notion, and more. AI can control itself natively.
|
||||
- icon: 🔐
|
||||
title: Account Safe
|
||||
details: Reuses Chrome's logged-in state. Your credentials never leave the browser — no tokens, no exposed passwords.
|
||||
- icon: 🤖
|
||||
title: AI Agent Ready
|
||||
details: "explore discovers APIs, synthesize generates adapters, cascade finds auth strategies. Built for AI-first workflows."
|
||||
- icon: ⚡
|
||||
title: Dual-Engine Architecture
|
||||
details: Supports both YAML declarative data pipelines and robust browser runtime TypeScript injections for maximum flexibility.
|
||||
- icon: 🔧
|
||||
title: Self-Healing Setup
|
||||
details: "opencli setup verifies Browser Bridge connectivity. opencli doctor diagnoses daemon, extension, and live browser."
|
||||
- icon: 📦
|
||||
title: Dynamic Loader
|
||||
details: Simply drop .ts or .yaml adapters into the clis/ folder for auto-registration. Zero boilerplate.
|
||||
---
|
||||
@@ -0,0 +1,5 @@
|
||||
# 所有适配器
|
||||
|
||||
运行 `opencli list` 查看完整命令列表。
|
||||
|
||||
详细文档请参考 [英文版本](/adapters/)。
|
||||
@@ -0,0 +1,3 @@
|
||||
# Chrome DevTools Protocol
|
||||
|
||||
详细文档请参考 [英文版本](/advanced/cdp)。
|
||||
@@ -0,0 +1,24 @@
|
||||
# 贡献指南
|
||||
|
||||
详细贡献指南请参考 [英文版本](/developer/contributing)。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
git clone git@github.com:<your-username>/opencli.git
|
||||
cd opencli
|
||||
npm install
|
||||
npm run build
|
||||
npx tsc --noEmit
|
||||
npx vitest run src/
|
||||
```
|
||||
|
||||
## 提交规范
|
||||
|
||||
使用 [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
feat(twitter): add thread command
|
||||
fix(browser): handle CDP timeout gracefully
|
||||
docs: update CONTRIBUTING.md
|
||||
```
|
||||
@@ -0,0 +1,25 @@
|
||||
# Browser Bridge 设置
|
||||
|
||||
> **⚠️ 重要**: 浏览器命令复用你的 Chrome 登录会话。运行命令前必须在 Chrome 中登录目标网站。
|
||||
|
||||
OpenCLI 通过轻量级 **Browser Bridge** Chrome 扩展 + 微守护进程连接浏览器(零配置,自动启动)。
|
||||
|
||||
## 扩展安装
|
||||
|
||||
### 方法 1:下载预构建版本(推荐)
|
||||
|
||||
1. 前往 GitHub [Releases 页面](https://github.com/jackwener/opencli/releases) 下载最新的 `opencli-extension.zip` 或 `opencli-extension.crx`。
|
||||
2. 打开 `chrome://extensions`,启用**开发者模式**。
|
||||
3. 拖放 `.crx` 文件或解压后的文件夹到扩展页面。
|
||||
|
||||
### 方法 2:加载源码(开发者)
|
||||
|
||||
1. 打开 `chrome://extensions`,启用**开发者模式**。
|
||||
2. 点击**加载已解压的扩展程序**,选择仓库中的 `extension/` 目录。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
opencli doctor # 检查扩展 + 守护进程连接
|
||||
opencli doctor --live # 同时测试实时浏览器命令
|
||||
```
|
||||
@@ -0,0 +1,40 @@
|
||||
# 快速开始
|
||||
|
||||
> **让任何网站或 Electron 应用成为你的 CLI。**
|
||||
> 零风险 · 复用 Chrome 登录态 · AI 驱动发现 · 浏览器 + 桌面自动化
|
||||
|
||||
OpenCLI 将**任何网站**或 **Electron 应用**变成命令行界面 — Bilibili、知乎、小红书、Twitter/X、Reddit、YouTube、Antigravity 等 — 基于浏览器会话复用和 AI 原生发现。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
npm install -g @jackwener/opencli
|
||||
```
|
||||
|
||||
## 基本使用
|
||||
|
||||
```bash
|
||||
opencli list # 查看所有命令
|
||||
opencli hackernews top --limit 5 # 公开 API,无需浏览器
|
||||
opencli bilibili hot --limit 5 # 浏览器命令
|
||||
opencli zhihu hot -f json # JSON 输出
|
||||
```
|
||||
|
||||
## 输出格式
|
||||
|
||||
所有命令支持 `--format` / `-f`:
|
||||
|
||||
```bash
|
||||
opencli bilibili hot -f table # 默认:终端表格
|
||||
opencli bilibili hot -f json # JSON
|
||||
opencli bilibili hot -f yaml # YAML
|
||||
opencli bilibili hot -f md # Markdown
|
||||
opencli bilibili hot -f csv # CSV
|
||||
```
|
||||
|
||||
## 下一步
|
||||
|
||||
- [安装详情](/zh/guide/installation)
|
||||
- [Browser Bridge 设置](/zh/guide/browser-bridge)
|
||||
- [所有适配器](/zh/adapters/)
|
||||
- [开发者指南](/zh/developer/contributing)
|
||||
@@ -0,0 +1,37 @@
|
||||
# 安装
|
||||
|
||||
## 系统要求
|
||||
|
||||
- **Node.js**: >= 20.0.0
|
||||
- **Chrome** 已运行并登录目标网站(浏览器命令需要)
|
||||
|
||||
## 通过 npm 安装(推荐)
|
||||
|
||||
```bash
|
||||
npm install -g @jackwener/opencli
|
||||
```
|
||||
|
||||
## 从源码安装
|
||||
|
||||
```bash
|
||||
git clone git@github.com:jackwener/opencli.git
|
||||
cd opencli
|
||||
npm install
|
||||
npm run build
|
||||
npm link
|
||||
opencli list
|
||||
```
|
||||
|
||||
## 更新
|
||||
|
||||
```bash
|
||||
npm install -g @jackwener/opencli@latest
|
||||
```
|
||||
|
||||
## 验证安装
|
||||
|
||||
```bash
|
||||
opencli --version
|
||||
opencli list
|
||||
opencli doctor
|
||||
```
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
layout: home
|
||||
|
||||
hero:
|
||||
name: OpenCLI
|
||||
text: 让任何网站或 Electron 应用成为你的 CLI
|
||||
tagline: 零风险 · 复用 Chrome 登录态 · AI 驱动发现 · 浏览器 + 桌面自动化
|
||||
actions:
|
||||
- theme: brand
|
||||
text: 快速开始
|
||||
link: /zh/guide/getting-started
|
||||
- theme: alt
|
||||
text: 在 GitHub 查看
|
||||
link: https://github.com/jackwener/opencli
|
||||
|
||||
features:
|
||||
- icon: 🖥️
|
||||
title: CLI 所有 Electron 应用
|
||||
details: 将任何 Electron 应用变成 CLI 工具 — Cursor、Codex、Antigravity、ChatGPT、Notion 等。AI 可以原生控制自身。
|
||||
- icon: 🔐
|
||||
title: 账号安全
|
||||
details: 复用 Chrome 登录态,凭证永远不会离开浏览器 — 无 token,无密码泄露。
|
||||
- icon: 🤖
|
||||
title: AI Agent 就绪
|
||||
details: explore 发现 API,synthesize 生成适配器,cascade 查找认证策略。为 AI 优先工作流而生。
|
||||
- icon: ⚡
|
||||
title: 双引擎架构
|
||||
details: 同时支持 YAML 声明式数据管道和强大的浏览器运行时 TypeScript 注入。
|
||||
---
|
||||
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
Vendored
+432
@@ -0,0 +1,432 @@
|
||||
const DAEMON_PORT = 19825;
|
||||
const DAEMON_HOST = "localhost";
|
||||
const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`;
|
||||
const WS_RECONNECT_BASE_DELAY = 2e3;
|
||||
const WS_RECONNECT_MAX_DELAY = 6e4;
|
||||
|
||||
const attached = /* @__PURE__ */ new Set();
|
||||
async function ensureAttached(tabId) {
|
||||
if (attached.has(tabId)) return;
|
||||
try {
|
||||
await chrome.debugger.attach({ tabId }, "1.3");
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg.includes("Another debugger is already attached")) {
|
||||
try {
|
||||
await chrome.debugger.detach({ tabId });
|
||||
} catch {
|
||||
}
|
||||
try {
|
||||
await chrome.debugger.attach({ tabId }, "1.3");
|
||||
} catch {
|
||||
throw new Error(`attach failed: ${msg}`);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`attach failed: ${msg}`);
|
||||
}
|
||||
}
|
||||
attached.add(tabId);
|
||||
try {
|
||||
await chrome.debugger.sendCommand({ tabId }, "Runtime.enable");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
async function evaluate(tabId, expression) {
|
||||
await ensureAttached(tabId);
|
||||
const result = await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", {
|
||||
expression,
|
||||
returnByValue: true,
|
||||
awaitPromise: true
|
||||
});
|
||||
if (result.exceptionDetails) {
|
||||
const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error";
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
return result.result?.value;
|
||||
}
|
||||
const evaluateAsync = evaluate;
|
||||
async function screenshot(tabId, options = {}) {
|
||||
await ensureAttached(tabId);
|
||||
const format = options.format ?? "png";
|
||||
if (options.fullPage) {
|
||||
const metrics = await chrome.debugger.sendCommand({ tabId }, "Page.getLayoutMetrics");
|
||||
const size = metrics.cssContentSize || metrics.contentSize;
|
||||
if (size) {
|
||||
await chrome.debugger.sendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", {
|
||||
mobile: false,
|
||||
width: Math.ceil(size.width),
|
||||
height: Math.ceil(size.height),
|
||||
deviceScaleFactor: 1
|
||||
});
|
||||
}
|
||||
}
|
||||
try {
|
||||
const params = { format };
|
||||
if (format === "jpeg" && options.quality !== void 0) {
|
||||
params.quality = Math.max(0, Math.min(100, options.quality));
|
||||
}
|
||||
const result = await chrome.debugger.sendCommand({ tabId }, "Page.captureScreenshot", params);
|
||||
return result.data;
|
||||
} finally {
|
||||
if (options.fullPage) {
|
||||
await chrome.debugger.sendCommand({ tabId }, "Emulation.clearDeviceMetricsOverride").catch(() => {
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
function detach(tabId) {
|
||||
if (!attached.has(tabId)) return;
|
||||
attached.delete(tabId);
|
||||
try {
|
||||
chrome.debugger.detach({ tabId });
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
function registerListeners() {
|
||||
chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
attached.delete(tabId);
|
||||
});
|
||||
chrome.debugger.onDetach.addListener((source) => {
|
||||
if (source.tabId) attached.delete(source.tabId);
|
||||
});
|
||||
}
|
||||
|
||||
let ws = null;
|
||||
let reconnectTimer = null;
|
||||
let reconnectAttempts = 0;
|
||||
const _origLog = console.log.bind(console);
|
||||
const _origWarn = console.warn.bind(console);
|
||||
const _origError = console.error.bind(console);
|
||||
function forwardLog(level, args) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
try {
|
||||
const msg = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ");
|
||||
ws.send(JSON.stringify({ type: "log", level, msg, ts: Date.now() }));
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
console.log = (...args) => {
|
||||
_origLog(...args);
|
||||
forwardLog("info", args);
|
||||
};
|
||||
console.warn = (...args) => {
|
||||
_origWarn(...args);
|
||||
forwardLog("warn", args);
|
||||
};
|
||||
console.error = (...args) => {
|
||||
_origError(...args);
|
||||
forwardLog("error", args);
|
||||
};
|
||||
function connect() {
|
||||
if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return;
|
||||
try {
|
||||
ws = new WebSocket(DAEMON_WS_URL);
|
||||
} catch {
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
ws.onopen = () => {
|
||||
console.log("[opencli] Connected to daemon");
|
||||
reconnectAttempts = 0;
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
};
|
||||
ws.onmessage = async (event) => {
|
||||
try {
|
||||
const command = JSON.parse(event.data);
|
||||
const result = await handleCommand(command);
|
||||
ws?.send(JSON.stringify(result));
|
||||
} catch (err) {
|
||||
console.error("[opencli] Message handling error:", err);
|
||||
}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
console.log("[opencli] Disconnected from daemon");
|
||||
ws = null;
|
||||
scheduleReconnect();
|
||||
};
|
||||
ws.onerror = () => {
|
||||
ws?.close();
|
||||
};
|
||||
}
|
||||
function scheduleReconnect() {
|
||||
if (reconnectTimer) return;
|
||||
reconnectAttempts++;
|
||||
const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY);
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connect();
|
||||
}, delay);
|
||||
}
|
||||
const automationSessions = /* @__PURE__ */ new Map();
|
||||
const WINDOW_IDLE_TIMEOUT = 3e4;
|
||||
function getWorkspaceKey(workspace) {
|
||||
return workspace?.trim() || "default";
|
||||
}
|
||||
function resetWindowIdleTimer(workspace) {
|
||||
const session = automationSessions.get(workspace);
|
||||
if (!session) return;
|
||||
if (session.idleTimer) clearTimeout(session.idleTimer);
|
||||
session.idleDeadlineAt = Date.now() + WINDOW_IDLE_TIMEOUT;
|
||||
session.idleTimer = setTimeout(async () => {
|
||||
const current = automationSessions.get(workspace);
|
||||
if (!current) return;
|
||||
try {
|
||||
await chrome.windows.remove(current.windowId);
|
||||
console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout)`);
|
||||
} catch {
|
||||
}
|
||||
automationSessions.delete(workspace);
|
||||
}, WINDOW_IDLE_TIMEOUT);
|
||||
}
|
||||
async function getAutomationWindow(workspace) {
|
||||
const existing = automationSessions.get(workspace);
|
||||
if (existing) {
|
||||
try {
|
||||
await chrome.windows.get(existing.windowId);
|
||||
return existing.windowId;
|
||||
} catch {
|
||||
automationSessions.delete(workspace);
|
||||
}
|
||||
}
|
||||
const win = await chrome.windows.create({
|
||||
url: "about:blank",
|
||||
focused: false,
|
||||
width: 1280,
|
||||
height: 900,
|
||||
type: "normal"
|
||||
});
|
||||
const session = {
|
||||
windowId: win.id,
|
||||
idleTimer: null,
|
||||
idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT
|
||||
};
|
||||
automationSessions.set(workspace, session);
|
||||
console.log(`[opencli] Created automation window ${session.windowId} (${workspace})`);
|
||||
resetWindowIdleTimer(workspace);
|
||||
return session.windowId;
|
||||
}
|
||||
chrome.windows.onRemoved.addListener((windowId) => {
|
||||
for (const [workspace, session] of automationSessions.entries()) {
|
||||
if (session.windowId === windowId) {
|
||||
console.log(`[opencli] Automation window closed (${workspace})`);
|
||||
if (session.idleTimer) clearTimeout(session.idleTimer);
|
||||
automationSessions.delete(workspace);
|
||||
}
|
||||
}
|
||||
});
|
||||
let initialized = false;
|
||||
function initialize() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
chrome.alarms.create("keepalive", { periodInMinutes: 0.4 });
|
||||
registerListeners();
|
||||
connect();
|
||||
console.log("[opencli] OpenCLI extension initialized");
|
||||
}
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
initialize();
|
||||
});
|
||||
chrome.runtime.onStartup.addListener(() => {
|
||||
initialize();
|
||||
});
|
||||
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||
if (alarm.name === "keepalive") connect();
|
||||
});
|
||||
async function handleCommand(cmd) {
|
||||
const workspace = getWorkspaceKey(cmd.workspace);
|
||||
resetWindowIdleTimer(workspace);
|
||||
try {
|
||||
switch (cmd.action) {
|
||||
case "exec":
|
||||
return await handleExec(cmd, workspace);
|
||||
case "navigate":
|
||||
return await handleNavigate(cmd, workspace);
|
||||
case "tabs":
|
||||
return await handleTabs(cmd, workspace);
|
||||
case "cookies":
|
||||
return await handleCookies(cmd);
|
||||
case "screenshot":
|
||||
return await handleScreenshot(cmd, workspace);
|
||||
case "close-window":
|
||||
return await handleCloseWindow(cmd, workspace);
|
||||
case "sessions":
|
||||
return await handleSessions(cmd);
|
||||
default:
|
||||
return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` };
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
id: cmd.id,
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err)
|
||||
};
|
||||
}
|
||||
}
|
||||
function isWebUrl(url) {
|
||||
if (!url) return false;
|
||||
return !url.startsWith("chrome://") && !url.startsWith("chrome-extension://");
|
||||
}
|
||||
async function resolveTabId(tabId, workspace) {
|
||||
if (tabId !== void 0) return tabId;
|
||||
const windowId = await getAutomationWindow(workspace);
|
||||
const tabs = await chrome.tabs.query({ windowId });
|
||||
const webTab = tabs.find((t) => t.id && isWebUrl(t.url));
|
||||
if (webTab?.id) return webTab.id;
|
||||
if (tabs.length > 0 && tabs[0]?.id) return tabs[0].id;
|
||||
const newTab = await chrome.tabs.create({ windowId, url: "about:blank", active: true });
|
||||
if (!newTab.id) throw new Error("Failed to create tab in automation window");
|
||||
return newTab.id;
|
||||
}
|
||||
async function listAutomationTabs(workspace) {
|
||||
const session = automationSessions.get(workspace);
|
||||
if (!session) return [];
|
||||
try {
|
||||
return await chrome.tabs.query({ windowId: session.windowId });
|
||||
} catch {
|
||||
automationSessions.delete(workspace);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
async function listAutomationWebTabs(workspace) {
|
||||
const tabs = await listAutomationTabs(workspace);
|
||||
return tabs.filter((tab) => isWebUrl(tab.url));
|
||||
}
|
||||
async function handleExec(cmd, workspace) {
|
||||
if (!cmd.code) return { id: cmd.id, ok: false, error: "Missing code" };
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace);
|
||||
try {
|
||||
const data = await evaluateAsync(tabId, cmd.code);
|
||||
return { id: cmd.id, ok: true, data };
|
||||
} catch (err) {
|
||||
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
async function handleNavigate(cmd, workspace) {
|
||||
if (!cmd.url) return { id: cmd.id, ok: false, error: "Missing url" };
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace);
|
||||
await chrome.tabs.update(tabId, { url: cmd.url });
|
||||
await new Promise((resolve) => {
|
||||
chrome.tabs.get(tabId).then((tab2) => {
|
||||
if (tab2.status === "complete") {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const listener = (id, info) => {
|
||||
if (id === tabId && info.status === "complete") {
|
||||
chrome.tabs.onUpdated.removeListener(listener);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
chrome.tabs.onUpdated.addListener(listener);
|
||||
setTimeout(() => {
|
||||
chrome.tabs.onUpdated.removeListener(listener);
|
||||
resolve();
|
||||
}, 15e3);
|
||||
});
|
||||
});
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
return { id: cmd.id, ok: true, data: { title: tab.title, url: tab.url, tabId } };
|
||||
}
|
||||
async function handleTabs(cmd, workspace) {
|
||||
switch (cmd.op) {
|
||||
case "list": {
|
||||
const tabs = await listAutomationWebTabs(workspace);
|
||||
const data = tabs.map((t, i) => ({
|
||||
index: i,
|
||||
tabId: t.id,
|
||||
url: t.url,
|
||||
title: t.title,
|
||||
active: t.active
|
||||
}));
|
||||
return { id: cmd.id, ok: true, data };
|
||||
}
|
||||
case "new": {
|
||||
const windowId = await getAutomationWindow(workspace);
|
||||
const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? "about:blank", active: true });
|
||||
return { id: cmd.id, ok: true, data: { tabId: tab.id, url: tab.url } };
|
||||
}
|
||||
case "close": {
|
||||
if (cmd.index !== void 0) {
|
||||
const tabs = await listAutomationWebTabs(workspace);
|
||||
const target = tabs[cmd.index];
|
||||
if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
|
||||
await chrome.tabs.remove(target.id);
|
||||
detach(target.id);
|
||||
return { id: cmd.id, ok: true, data: { closed: target.id } };
|
||||
}
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace);
|
||||
await chrome.tabs.remove(tabId);
|
||||
detach(tabId);
|
||||
return { id: cmd.id, ok: true, data: { closed: tabId } };
|
||||
}
|
||||
case "select": {
|
||||
if (cmd.index === void 0 && cmd.tabId === void 0)
|
||||
return { id: cmd.id, ok: false, error: "Missing index or tabId" };
|
||||
if (cmd.tabId !== void 0) {
|
||||
await chrome.tabs.update(cmd.tabId, { active: true });
|
||||
return { id: cmd.id, ok: true, data: { selected: cmd.tabId } };
|
||||
}
|
||||
const tabs = await listAutomationWebTabs(workspace);
|
||||
const target = tabs[cmd.index];
|
||||
if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
|
||||
await chrome.tabs.update(target.id, { active: true });
|
||||
return { id: cmd.id, ok: true, data: { selected: target.id } };
|
||||
}
|
||||
default:
|
||||
return { id: cmd.id, ok: false, error: `Unknown tabs op: ${cmd.op}` };
|
||||
}
|
||||
}
|
||||
async function handleCookies(cmd) {
|
||||
const details = {};
|
||||
if (cmd.domain) details.domain = cmd.domain;
|
||||
if (cmd.url) details.url = cmd.url;
|
||||
const cookies = await chrome.cookies.getAll(details);
|
||||
const data = cookies.map((c) => ({
|
||||
name: c.name,
|
||||
value: c.value,
|
||||
domain: c.domain,
|
||||
path: c.path,
|
||||
secure: c.secure,
|
||||
httpOnly: c.httpOnly,
|
||||
expirationDate: c.expirationDate
|
||||
}));
|
||||
return { id: cmd.id, ok: true, data };
|
||||
}
|
||||
async function handleScreenshot(cmd, workspace) {
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace);
|
||||
try {
|
||||
const data = await screenshot(tabId, {
|
||||
format: cmd.format,
|
||||
quality: cmd.quality,
|
||||
fullPage: cmd.fullPage
|
||||
});
|
||||
return { id: cmd.id, ok: true, data };
|
||||
} catch (err) {
|
||||
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
async function handleCloseWindow(cmd, workspace) {
|
||||
const session = automationSessions.get(workspace);
|
||||
if (session) {
|
||||
try {
|
||||
await chrome.windows.remove(session.windowId);
|
||||
} catch {
|
||||
}
|
||||
if (session.idleTimer) clearTimeout(session.idleTimer);
|
||||
automationSessions.delete(workspace);
|
||||
}
|
||||
return { id: cmd.id, ok: true, data: { closed: true } };
|
||||
}
|
||||
async function handleSessions(cmd) {
|
||||
const now = Date.now();
|
||||
const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({
|
||||
workspace,
|
||||
windowId: session.windowId,
|
||||
tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isWebUrl(tab.url)).length,
|
||||
idleMsRemaining: Math.max(0, session.idleDeadlineAt - now)
|
||||
})));
|
||||
return { id: cmd.id, ok: true, data };
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 628 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "OpenCLI",
|
||||
"version": "0.2.0",
|
||||
"description": "Bridge between opencli CLI and your browser — execute commands, read cookies, manage tabs.",
|
||||
"permissions": [
|
||||
"debugger",
|
||||
"tabs",
|
||||
"cookies",
|
||||
"activeTab",
|
||||
"alarms"
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "dist/background.js",
|
||||
"type": "module"
|
||||
},
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
},
|
||||
"action": {
|
||||
"default_title": "OpenCLI",
|
||||
"default_icon": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png"
|
||||
}
|
||||
},
|
||||
"homepage_url": "https://github.com/jackwener/opencli"
|
||||
}
|
||||
Generated
+1156
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "opencli-extension",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite build --watch",
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.0.287",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
type Listener<T extends (...args: any[]) => void> = { addListener: (fn: T) => void };
|
||||
|
||||
type MockTab = {
|
||||
id: number;
|
||||
windowId: number;
|
||||
url?: string;
|
||||
title?: string;
|
||||
active?: boolean;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
class MockWebSocket {
|
||||
static OPEN = 1;
|
||||
static CONNECTING = 0;
|
||||
readyState = MockWebSocket.CONNECTING;
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((event: { data: string }) => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
|
||||
constructor(_url: string) {}
|
||||
send(_data: string): void {}
|
||||
close(): void {
|
||||
this.onclose?.();
|
||||
}
|
||||
}
|
||||
|
||||
function createChromeMock() {
|
||||
let nextTabId = 10;
|
||||
const tabs: MockTab[] = [
|
||||
{ id: 1, windowId: 1, url: 'https://automation.example', title: 'automation', active: true, status: 'complete' },
|
||||
{ id: 2, windowId: 2, url: 'https://user.example', title: 'user', active: true, status: 'complete' },
|
||||
{ id: 3, windowId: 1, url: 'chrome://extensions', title: 'chrome', active: false, status: 'complete' },
|
||||
];
|
||||
|
||||
const query = vi.fn(async (queryInfo: { windowId?: number } = {}) => {
|
||||
return tabs.filter((tab) => queryInfo.windowId === undefined || tab.windowId === queryInfo.windowId);
|
||||
});
|
||||
const create = vi.fn(async ({ windowId, url, active }: { windowId?: number; url?: string; active?: boolean }) => {
|
||||
const tab: MockTab = {
|
||||
id: nextTabId++,
|
||||
windowId: windowId ?? 999,
|
||||
url,
|
||||
title: url ?? 'blank',
|
||||
active: !!active,
|
||||
status: 'complete',
|
||||
};
|
||||
tabs.push(tab);
|
||||
return tab;
|
||||
});
|
||||
const update = vi.fn(async (tabId: number, updates: { active?: boolean; url?: string }) => {
|
||||
const tab = tabs.find((entry) => entry.id === tabId);
|
||||
if (!tab) throw new Error(`Unknown tab ${tabId}`);
|
||||
if (updates.active !== undefined) tab.active = updates.active;
|
||||
if (updates.url !== undefined) tab.url = updates.url;
|
||||
return tab;
|
||||
});
|
||||
|
||||
const chrome = {
|
||||
tabs: {
|
||||
query,
|
||||
create,
|
||||
update,
|
||||
remove: vi.fn(async (_tabId: number) => {}),
|
||||
get: vi.fn(async (tabId: number) => {
|
||||
const tab = tabs.find((entry) => entry.id === tabId);
|
||||
if (!tab) throw new Error(`Unknown tab ${tabId}`);
|
||||
return tab;
|
||||
}),
|
||||
onUpdated: { addListener: vi.fn(), removeListener: vi.fn() } as Listener<(id: number, info: chrome.tabs.TabChangeInfo) => void>,
|
||||
},
|
||||
windows: {
|
||||
get: vi.fn(async (windowId: number) => ({ id: windowId })),
|
||||
create: vi.fn(async ({ url, focused, width, height, type }: any) => ({ id: 1, url, focused, width, height, type })),
|
||||
remove: vi.fn(async (_windowId: number) => {}),
|
||||
onRemoved: { addListener: vi.fn() } as Listener<(windowId: number) => void>,
|
||||
},
|
||||
alarms: {
|
||||
create: vi.fn(),
|
||||
onAlarm: { addListener: vi.fn() } as Listener<(alarm: { name: string }) => void>,
|
||||
},
|
||||
runtime: {
|
||||
onInstalled: { addListener: vi.fn() } as Listener<() => void>,
|
||||
onStartup: { addListener: vi.fn() } as Listener<() => void>,
|
||||
},
|
||||
cookies: {
|
||||
getAll: vi.fn(async () => []),
|
||||
},
|
||||
};
|
||||
|
||||
return { chrome, tabs, query, create, update };
|
||||
}
|
||||
|
||||
describe('background tab isolation', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.stubGlobal('WebSocket', MockWebSocket);
|
||||
});
|
||||
|
||||
it('lists only automation-window web tabs', async () => {
|
||||
const { chrome } = createChromeMock();
|
||||
vi.stubGlobal('chrome', chrome);
|
||||
|
||||
const mod = await import('./background');
|
||||
mod.__test__.setAutomationWindowId('site:twitter', 1);
|
||||
|
||||
const result = await mod.__test__.handleTabs({ id: '1', action: 'tabs', op: 'list', workspace: 'site:twitter' }, 'site:twitter');
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.data).toEqual([
|
||||
{
|
||||
index: 0,
|
||||
tabId: 1,
|
||||
url: 'https://automation.example',
|
||||
title: 'automation',
|
||||
active: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates new tabs inside the automation window', async () => {
|
||||
const { chrome, create } = createChromeMock();
|
||||
vi.stubGlobal('chrome', chrome);
|
||||
|
||||
const mod = await import('./background');
|
||||
mod.__test__.setAutomationWindowId('site:twitter', 1);
|
||||
|
||||
const result = await mod.__test__.handleTabs({ id: '2', action: 'tabs', op: 'new', url: 'https://new.example', workspace: 'site:twitter' }, 'site:twitter');
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(create).toHaveBeenCalledWith({ windowId: 1, url: 'https://new.example', active: true });
|
||||
});
|
||||
|
||||
it('reports sessions per workspace', async () => {
|
||||
const { chrome } = createChromeMock();
|
||||
vi.stubGlobal('chrome', chrome);
|
||||
|
||||
const mod = await import('./background');
|
||||
mod.__test__.setAutomationWindowId('site:twitter', 1);
|
||||
mod.__test__.setAutomationWindowId('site:zhihu', 2);
|
||||
|
||||
const result = await mod.__test__.handleSessions({ id: '3', action: 'sessions' });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.data).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ workspace: 'site:twitter', windowId: 1 }),
|
||||
expect.objectContaining({ workspace: 'site:zhihu', windowId: 2 }),
|
||||
]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* OpenCLI — Service Worker (background script).
|
||||
*
|
||||
* Connects to the opencli daemon via WebSocket, receives commands,
|
||||
* dispatches them to Chrome APIs (debugger/tabs/cookies), returns results.
|
||||
*/
|
||||
|
||||
import type { Command, Result } from './protocol';
|
||||
import { DAEMON_WS_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol';
|
||||
import * as executor from './cdp';
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let reconnectAttempts = 0;
|
||||
|
||||
// ─── Console log forwarding ──────────────────────────────────────────
|
||||
// Hook console.log/warn/error to forward logs to daemon via WebSocket.
|
||||
|
||||
const _origLog = console.log.bind(console);
|
||||
const _origWarn = console.warn.bind(console);
|
||||
const _origError = console.error.bind(console);
|
||||
|
||||
function forwardLog(level: 'info' | 'warn' | 'error', args: unknown[]): void {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
try {
|
||||
const msg = args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ');
|
||||
ws.send(JSON.stringify({ type: 'log', level, msg, ts: Date.now() }));
|
||||
} catch { /* don't recurse */ }
|
||||
}
|
||||
|
||||
console.log = (...args: unknown[]) => { _origLog(...args); forwardLog('info', args); };
|
||||
console.warn = (...args: unknown[]) => { _origWarn(...args); forwardLog('warn', args); };
|
||||
console.error = (...args: unknown[]) => { _origError(...args); forwardLog('error', args); };
|
||||
|
||||
// ─── WebSocket connection ────────────────────────────────────────────
|
||||
|
||||
function connect(): void {
|
||||
if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return;
|
||||
|
||||
try {
|
||||
ws = new WebSocket(DAEMON_WS_URL);
|
||||
} catch {
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[opencli] Connected to daemon');
|
||||
reconnectAttempts = 0; // Reset on successful connection
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = async (event) => {
|
||||
try {
|
||||
const command = JSON.parse(event.data as string) as Command;
|
||||
const result = await handleCommand(command);
|
||||
ws?.send(JSON.stringify(result));
|
||||
} catch (err) {
|
||||
console.error('[opencli] Message handling error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('[opencli] Disconnected from daemon');
|
||||
ws = null;
|
||||
scheduleReconnect();
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
ws?.close();
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleReconnect(): void {
|
||||
if (reconnectTimer) return;
|
||||
reconnectAttempts++;
|
||||
// Exponential backoff: 2s, 4s, 8s, 16s, ..., capped at 60s
|
||||
const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY);
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
// ─── Automation window isolation ─────────────────────────────────────
|
||||
// All opencli operations happen in a dedicated Chrome window so the
|
||||
// user's active browsing session is never touched.
|
||||
// The window auto-closes after 30s of idle (no commands).
|
||||
|
||||
type AutomationSession = {
|
||||
windowId: number;
|
||||
idleTimer: ReturnType<typeof setTimeout> | null;
|
||||
idleDeadlineAt: number;
|
||||
};
|
||||
|
||||
const automationSessions = new Map<string, AutomationSession>();
|
||||
const WINDOW_IDLE_TIMEOUT = 30000; // 30s
|
||||
|
||||
function getWorkspaceKey(workspace?: string): string {
|
||||
return workspace?.trim() || 'default';
|
||||
}
|
||||
|
||||
function resetWindowIdleTimer(workspace: string): void {
|
||||
const session = automationSessions.get(workspace);
|
||||
if (!session) return;
|
||||
if (session.idleTimer) clearTimeout(session.idleTimer);
|
||||
session.idleDeadlineAt = Date.now() + WINDOW_IDLE_TIMEOUT;
|
||||
session.idleTimer = setTimeout(async () => {
|
||||
const current = automationSessions.get(workspace);
|
||||
if (!current) return;
|
||||
try {
|
||||
await chrome.windows.remove(current.windowId);
|
||||
console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout)`);
|
||||
} catch {
|
||||
// Already gone
|
||||
}
|
||||
automationSessions.delete(workspace);
|
||||
}, WINDOW_IDLE_TIMEOUT);
|
||||
}
|
||||
|
||||
/** Get or create the dedicated automation window. */
|
||||
async function getAutomationWindow(workspace: string): Promise<number> {
|
||||
// Check if our window is still alive
|
||||
const existing = automationSessions.get(workspace);
|
||||
if (existing) {
|
||||
try {
|
||||
await chrome.windows.get(existing.windowId);
|
||||
return existing.windowId;
|
||||
} catch {
|
||||
// Window was closed by user
|
||||
automationSessions.delete(workspace);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new window with about:blank (not chrome://newtab which blocks scripting)
|
||||
const win = await chrome.windows.create({
|
||||
url: 'about:blank',
|
||||
focused: false,
|
||||
width: 1280,
|
||||
height: 900,
|
||||
type: 'normal',
|
||||
});
|
||||
const session: AutomationSession = {
|
||||
windowId: win.id!,
|
||||
idleTimer: null,
|
||||
idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT,
|
||||
};
|
||||
automationSessions.set(workspace, session);
|
||||
console.log(`[opencli] Created automation window ${session.windowId} (${workspace})`);
|
||||
resetWindowIdleTimer(workspace);
|
||||
return session.windowId;
|
||||
}
|
||||
|
||||
// Clean up when the automation window is closed
|
||||
chrome.windows.onRemoved.addListener((windowId) => {
|
||||
for (const [workspace, session] of automationSessions.entries()) {
|
||||
if (session.windowId === windowId) {
|
||||
console.log(`[opencli] Automation window closed (${workspace})`);
|
||||
if (session.idleTimer) clearTimeout(session.idleTimer);
|
||||
automationSessions.delete(workspace);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Lifecycle events ────────────────────────────────────────────────
|
||||
|
||||
let initialized = false;
|
||||
|
||||
function initialize(): void {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
chrome.alarms.create('keepalive', { periodInMinutes: 0.4 }); // ~24 seconds
|
||||
executor.registerListeners();
|
||||
connect();
|
||||
console.log('[opencli] OpenCLI extension initialized');
|
||||
}
|
||||
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
initialize();
|
||||
});
|
||||
|
||||
chrome.runtime.onStartup.addListener(() => {
|
||||
initialize();
|
||||
});
|
||||
|
||||
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||
if (alarm.name === 'keepalive') connect();
|
||||
});
|
||||
|
||||
// ─── Command dispatcher ─────────────────────────────────────────────
|
||||
|
||||
async function handleCommand(cmd: Command): Promise<Result> {
|
||||
const workspace = getWorkspaceKey(cmd.workspace);
|
||||
// Reset idle timer on every command (window stays alive while active)
|
||||
resetWindowIdleTimer(workspace);
|
||||
try {
|
||||
switch (cmd.action) {
|
||||
case 'exec':
|
||||
return await handleExec(cmd, workspace);
|
||||
case 'navigate':
|
||||
return await handleNavigate(cmd, workspace);
|
||||
case 'tabs':
|
||||
return await handleTabs(cmd, workspace);
|
||||
case 'cookies':
|
||||
return await handleCookies(cmd);
|
||||
case 'screenshot':
|
||||
return await handleScreenshot(cmd, workspace);
|
||||
case 'close-window':
|
||||
return await handleCloseWindow(cmd, workspace);
|
||||
case 'sessions':
|
||||
return await handleSessions(cmd);
|
||||
default:
|
||||
return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` };
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
id: cmd.id,
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Action handlers ─────────────────────────────────────────────────
|
||||
|
||||
/** Check if a URL is a debuggable web page (not chrome:// or extension page) */
|
||||
function isWebUrl(url?: string): boolean {
|
||||
if (!url) return false;
|
||||
return !url.startsWith('chrome://') && !url.startsWith('chrome-extension://');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve target tab in the automation window.
|
||||
* If explicit tabId is given, use that directly.
|
||||
* Otherwise, find or create a tab in the dedicated automation window.
|
||||
*/
|
||||
async function resolveTabId(tabId: number | undefined, workspace: string): Promise<number> {
|
||||
if (tabId !== undefined) return tabId;
|
||||
|
||||
// Get (or create) the automation window
|
||||
const windowId = await getAutomationWindow(workspace);
|
||||
|
||||
// Find the active tab in our automation window
|
||||
const tabs = await chrome.tabs.query({ windowId });
|
||||
const webTab = tabs.find(t => t.id && isWebUrl(t.url));
|
||||
if (webTab?.id) return webTab.id;
|
||||
|
||||
// Use the first tab if it's a blank/new tab page
|
||||
if (tabs.length > 0 && tabs[0]?.id) return tabs[0].id;
|
||||
|
||||
// No suitable tab — create one
|
||||
const newTab = await chrome.tabs.create({ windowId, url: 'about:blank', active: true });
|
||||
if (!newTab.id) throw new Error('Failed to create tab in automation window');
|
||||
return newTab.id;
|
||||
}
|
||||
|
||||
async function listAutomationTabs(workspace: string): Promise<chrome.tabs.Tab[]> {
|
||||
const session = automationSessions.get(workspace);
|
||||
if (!session) return [];
|
||||
try {
|
||||
return await chrome.tabs.query({ windowId: session.windowId });
|
||||
} catch {
|
||||
automationSessions.delete(workspace);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function listAutomationWebTabs(workspace: string): Promise<chrome.tabs.Tab[]> {
|
||||
const tabs = await listAutomationTabs(workspace);
|
||||
return tabs.filter((tab) => isWebUrl(tab.url));
|
||||
}
|
||||
|
||||
async function handleExec(cmd: Command, workspace: string): Promise<Result> {
|
||||
if (!cmd.code) return { id: cmd.id, ok: false, error: 'Missing code' };
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace);
|
||||
try {
|
||||
const data = await executor.evaluateAsync(tabId, cmd.code);
|
||||
return { id: cmd.id, ok: true, data };
|
||||
} catch (err) {
|
||||
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNavigate(cmd: Command, workspace: string): Promise<Result> {
|
||||
if (!cmd.url) return { id: cmd.id, ok: false, error: 'Missing url' };
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace);
|
||||
await chrome.tabs.update(tabId, { url: cmd.url });
|
||||
|
||||
// Wait for page to finish loading, checking current status first to avoid race
|
||||
await new Promise<void>((resolve) => {
|
||||
// Check if already complete (e.g. cached pages)
|
||||
chrome.tabs.get(tabId).then(tab => {
|
||||
if (tab.status === 'complete') { resolve(); return; }
|
||||
|
||||
const listener = (id: number, info: chrome.tabs.TabChangeInfo) => {
|
||||
if (id === tabId && info.status === 'complete') {
|
||||
chrome.tabs.onUpdated.removeListener(listener);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
chrome.tabs.onUpdated.addListener(listener);
|
||||
// Timeout fallback
|
||||
setTimeout(() => {
|
||||
chrome.tabs.onUpdated.removeListener(listener);
|
||||
resolve();
|
||||
}, 15000);
|
||||
});
|
||||
});
|
||||
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
return { id: cmd.id, ok: true, data: { title: tab.title, url: tab.url, tabId } };
|
||||
}
|
||||
|
||||
async function handleTabs(cmd: Command, workspace: string): Promise<Result> {
|
||||
switch (cmd.op) {
|
||||
case 'list': {
|
||||
const tabs = await listAutomationWebTabs(workspace);
|
||||
const data = tabs
|
||||
.map((t, i) => ({
|
||||
index: i,
|
||||
tabId: t.id,
|
||||
url: t.url,
|
||||
title: t.title,
|
||||
active: t.active,
|
||||
}));
|
||||
return { id: cmd.id, ok: true, data };
|
||||
}
|
||||
case 'new': {
|
||||
const windowId = await getAutomationWindow(workspace);
|
||||
const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? 'about:blank', active: true });
|
||||
return { id: cmd.id, ok: true, data: { tabId: tab.id, url: tab.url } };
|
||||
}
|
||||
case 'close': {
|
||||
if (cmd.index !== undefined) {
|
||||
const tabs = await listAutomationWebTabs(workspace);
|
||||
const target = tabs[cmd.index];
|
||||
if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
|
||||
await chrome.tabs.remove(target.id);
|
||||
executor.detach(target.id);
|
||||
return { id: cmd.id, ok: true, data: { closed: target.id } };
|
||||
}
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace);
|
||||
await chrome.tabs.remove(tabId);
|
||||
executor.detach(tabId);
|
||||
return { id: cmd.id, ok: true, data: { closed: tabId } };
|
||||
}
|
||||
case 'select': {
|
||||
if (cmd.index === undefined && cmd.tabId === undefined)
|
||||
return { id: cmd.id, ok: false, error: 'Missing index or tabId' };
|
||||
if (cmd.tabId !== undefined) {
|
||||
await chrome.tabs.update(cmd.tabId, { active: true });
|
||||
return { id: cmd.id, ok: true, data: { selected: cmd.tabId } };
|
||||
}
|
||||
const tabs = await listAutomationWebTabs(workspace);
|
||||
const target = tabs[cmd.index!];
|
||||
if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
|
||||
await chrome.tabs.update(target.id, { active: true });
|
||||
return { id: cmd.id, ok: true, data: { selected: target.id } };
|
||||
}
|
||||
default:
|
||||
return { id: cmd.id, ok: false, error: `Unknown tabs op: ${cmd.op}` };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCookies(cmd: Command): Promise<Result> {
|
||||
const details: chrome.cookies.GetAllDetails = {};
|
||||
if (cmd.domain) details.domain = cmd.domain;
|
||||
if (cmd.url) details.url = cmd.url;
|
||||
const cookies = await chrome.cookies.getAll(details);
|
||||
const data = cookies.map((c) => ({
|
||||
name: c.name,
|
||||
value: c.value,
|
||||
domain: c.domain,
|
||||
path: c.path,
|
||||
secure: c.secure,
|
||||
httpOnly: c.httpOnly,
|
||||
expirationDate: c.expirationDate,
|
||||
}));
|
||||
return { id: cmd.id, ok: true, data };
|
||||
}
|
||||
|
||||
async function handleScreenshot(cmd: Command, workspace: string): Promise<Result> {
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace);
|
||||
try {
|
||||
const data = await executor.screenshot(tabId, {
|
||||
format: cmd.format,
|
||||
quality: cmd.quality,
|
||||
fullPage: cmd.fullPage,
|
||||
});
|
||||
return { id: cmd.id, ok: true, data };
|
||||
} catch (err) {
|
||||
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCloseWindow(cmd: Command, workspace: string): Promise<Result> {
|
||||
const session = automationSessions.get(workspace);
|
||||
if (session) {
|
||||
try {
|
||||
await chrome.windows.remove(session.windowId);
|
||||
} catch {
|
||||
// Window may already be closed
|
||||
}
|
||||
if (session.idleTimer) clearTimeout(session.idleTimer);
|
||||
automationSessions.delete(workspace);
|
||||
}
|
||||
return { id: cmd.id, ok: true, data: { closed: true } };
|
||||
}
|
||||
|
||||
async function handleSessions(cmd: Command): Promise<Result> {
|
||||
const now = Date.now();
|
||||
const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({
|
||||
workspace,
|
||||
windowId: session.windowId,
|
||||
tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isWebUrl(tab.url)).length,
|
||||
idleMsRemaining: Math.max(0, session.idleDeadlineAt - now),
|
||||
})));
|
||||
return { id: cmd.id, ok: true, data };
|
||||
}
|
||||
|
||||
export const __test__ = {
|
||||
handleTabs,
|
||||
handleSessions,
|
||||
getAutomationWindowId: (workspace: string = 'default') => automationSessions.get(workspace)?.windowId ?? null,
|
||||
setAutomationWindowId: (workspace: string, windowId: number | null) => {
|
||||
if (windowId === null) {
|
||||
const session = automationSessions.get(workspace);
|
||||
if (session?.idleTimer) clearTimeout(session.idleTimer);
|
||||
automationSessions.delete(workspace);
|
||||
return;
|
||||
}
|
||||
automationSessions.set(workspace, {
|
||||
windowId,
|
||||
idleTimer: null,
|
||||
idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* CDP execution via chrome.debugger API.
|
||||
*
|
||||
* chrome.debugger only needs the "debugger" permission — no host_permissions.
|
||||
* It can attach to any http/https tab. Avoid chrome:// and chrome-extension://
|
||||
* tabs (resolveTabId in background.ts filters them).
|
||||
*/
|
||||
|
||||
const attached = new Set<number>();
|
||||
|
||||
async function ensureAttached(tabId: number): Promise<void> {
|
||||
if (attached.has(tabId)) return;
|
||||
|
||||
try {
|
||||
await chrome.debugger.attach({ tabId }, '1.3');
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg.includes('Another debugger is already attached')) {
|
||||
try { await chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
|
||||
try {
|
||||
await chrome.debugger.attach({ tabId }, '1.3');
|
||||
} catch {
|
||||
throw new Error(`attach failed: ${msg}`);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`attach failed: ${msg}`);
|
||||
}
|
||||
}
|
||||
attached.add(tabId);
|
||||
|
||||
try {
|
||||
await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable');
|
||||
} catch {
|
||||
// Some pages may not need explicit enable
|
||||
}
|
||||
}
|
||||
|
||||
export async function evaluate(tabId: number, expression: string): Promise<unknown> {
|
||||
await ensureAttached(tabId);
|
||||
|
||||
const result = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
|
||||
expression,
|
||||
returnByValue: true,
|
||||
awaitPromise: true,
|
||||
}) as {
|
||||
result?: { type: string; value?: unknown; description?: string; subtype?: string };
|
||||
exceptionDetails?: { exception?: { description?: string }; text?: string };
|
||||
};
|
||||
|
||||
if (result.exceptionDetails) {
|
||||
const errMsg = result.exceptionDetails.exception?.description
|
||||
|| result.exceptionDetails.text
|
||||
|| 'Eval error';
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
|
||||
return result.result?.value;
|
||||
}
|
||||
|
||||
export const evaluateAsync = evaluate;
|
||||
|
||||
/**
|
||||
* Capture a screenshot via CDP Page.captureScreenshot.
|
||||
* Returns base64-encoded image data.
|
||||
*/
|
||||
export async function screenshot(
|
||||
tabId: number,
|
||||
options: { format?: 'png' | 'jpeg'; quality?: number; fullPage?: boolean } = {},
|
||||
): Promise<string> {
|
||||
await ensureAttached(tabId);
|
||||
|
||||
const format = options.format ?? 'png';
|
||||
|
||||
// For full-page screenshots, get the full page dimensions first
|
||||
if (options.fullPage) {
|
||||
// Get full page metrics
|
||||
const metrics = await chrome.debugger.sendCommand({ tabId }, 'Page.getLayoutMetrics') as {
|
||||
contentSize?: { width: number; height: number };
|
||||
cssContentSize?: { width: number; height: number };
|
||||
};
|
||||
const size = metrics.cssContentSize || metrics.contentSize;
|
||||
if (size) {
|
||||
// Set device metrics to full page size
|
||||
await chrome.debugger.sendCommand({ tabId }, 'Emulation.setDeviceMetricsOverride', {
|
||||
mobile: false,
|
||||
width: Math.ceil(size.width),
|
||||
height: Math.ceil(size.height),
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const params: Record<string, unknown> = { format };
|
||||
if (format === 'jpeg' && options.quality !== undefined) {
|
||||
params.quality = Math.max(0, Math.min(100, options.quality));
|
||||
}
|
||||
|
||||
const result = await chrome.debugger.sendCommand({ tabId }, 'Page.captureScreenshot', params) as {
|
||||
data: string; // base64-encoded
|
||||
};
|
||||
|
||||
return result.data;
|
||||
} finally {
|
||||
// Reset device metrics if we changed them for full-page
|
||||
if (options.fullPage) {
|
||||
await chrome.debugger.sendCommand({ tabId }, 'Emulation.clearDeviceMetricsOverride').catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function detach(tabId: number): void {
|
||||
if (!attached.has(tabId)) return;
|
||||
attached.delete(tabId);
|
||||
try { chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function registerListeners(): void {
|
||||
chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
attached.delete(tabId);
|
||||
});
|
||||
chrome.debugger.onDetach.addListener((source) => {
|
||||
if (source.tabId) attached.delete(source.tabId);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* opencli browser protocol — shared types between daemon, extension, and CLI.
|
||||
*
|
||||
* 5 actions: exec, navigate, tabs, cookies, screenshot.
|
||||
* Everything else is just JS code sent via 'exec'.
|
||||
*/
|
||||
|
||||
export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions';
|
||||
|
||||
export interface Command {
|
||||
/** Unique request ID */
|
||||
id: string;
|
||||
/** Action type */
|
||||
action: Action;
|
||||
/** Target tab ID (omit for active tab) */
|
||||
tabId?: number;
|
||||
/** JS code to evaluate in page context (exec action) */
|
||||
code?: string;
|
||||
/** Logical workspace for automation session reuse */
|
||||
workspace?: string;
|
||||
/** URL to navigate to (navigate action) */
|
||||
url?: string;
|
||||
/** Sub-operation for tabs: list, new, close, select */
|
||||
op?: 'list' | 'new' | 'close' | 'select';
|
||||
/** Tab index for tabs select/close */
|
||||
index?: number;
|
||||
/** Cookie domain filter */
|
||||
domain?: string;
|
||||
/** Screenshot format: png (default) or jpeg */
|
||||
format?: 'png' | 'jpeg';
|
||||
/** JPEG quality (0-100), only for jpeg format */
|
||||
quality?: number;
|
||||
/** Whether to capture full page (not just viewport) */
|
||||
fullPage?: boolean;
|
||||
}
|
||||
|
||||
export interface Result {
|
||||
/** Matching request ID */
|
||||
id: string;
|
||||
/** Whether the command succeeded */
|
||||
ok: boolean;
|
||||
/** Result data on success */
|
||||
data?: unknown;
|
||||
/** Error message on failure */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Default daemon port */
|
||||
export const DAEMON_PORT = 19825;
|
||||
export const DAEMON_HOST = 'localhost';
|
||||
export const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`;
|
||||
export const DAEMON_HTTP_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
|
||||
|
||||
/** Base reconnect delay for extension WebSocket (ms) */
|
||||
export const WS_RECONNECT_BASE_DELAY = 2000;
|
||||
/** Max reconnect delay (ms) */
|
||||
export const WS_RECONNECT_MAX_DELAY = 60000;
|
||||
/** Idle timeout before daemon auto-exits (ms) */
|
||||
export const DAEMON_IDLE_TIMEOUT = 5 * 60 * 1000;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 565 KiB |
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": false,
|
||||
"types": ["chrome"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
input: resolve(__dirname, 'src/background.ts'),
|
||||
output: {
|
||||
entryFileNames: 'background.js',
|
||||
format: 'es',
|
||||
},
|
||||
},
|
||||
target: 'esnext',
|
||||
minify: false,
|
||||
},
|
||||
});
|
||||
Generated
+2444
-74
File diff suppressed because it is too large
Load Diff
+13
-8
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@jackwener/opencli",
|
||||
"version": "0.7.10",
|
||||
"version": "1.0.5",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"description": "Make any website your CLI. AI-powered.",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "dist/main.js",
|
||||
@@ -25,14 +25,17 @@
|
||||
"lint": "tsc --noEmit",
|
||||
"prepublishOnly": "npm run build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:site": "node scripts/test-site.mjs",
|
||||
"test:watch": "vitest",
|
||||
"docs:dev": "vitepress dev docs",
|
||||
"docs:build": "vitepress build docs",
|
||||
"docs:preview": "vitepress preview docs"
|
||||
},
|
||||
"keywords": [
|
||||
"cli",
|
||||
"browser",
|
||||
"web",
|
||||
"ai",
|
||||
"playwright"
|
||||
"ai"
|
||||
],
|
||||
"author": "jackwener",
|
||||
"license": "Apache-2.0",
|
||||
@@ -43,15 +46,17 @@
|
||||
"dependencies": {
|
||||
"chalk": "^5.3.0",
|
||||
"cli-table3": "^0.6.5",
|
||||
"commander": "^13.1.0",
|
||||
"js-yaml": "^4.1.0"
|
||||
"commander": "^14.0.3",
|
||||
"js-yaml": "^4.1.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/mcp": "^0.0.68",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/ws": "^8.5.13",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.2",
|
||||
"vitepress": "^1.6.4",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
const site = process.argv[2]?.trim();
|
||||
|
||||
if (!site) {
|
||||
console.error('Usage: npm run test:site -- <site>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const repoRoot = path.resolve(new URL('..', import.meta.url).pathname);
|
||||
const srcDir = path.join(repoRoot, 'src');
|
||||
|
||||
function runStep(label, command, args) {
|
||||
console.log(`\n==> ${label}`);
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: repoRoot,
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
function walk(dir) {
|
||||
const files = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...walk(fullPath));
|
||||
} else {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function toPosix(filePath) {
|
||||
return filePath.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function findSiteTests() {
|
||||
return walk(srcDir)
|
||||
.filter(filePath => filePath.endsWith('.test.ts'))
|
||||
.filter(filePath => {
|
||||
const normalized = toPosix(path.relative(repoRoot, filePath));
|
||||
return normalized.includes(`/clis/${site}/`) || normalized.includes(`/${site}.test.ts`);
|
||||
})
|
||||
.sort();
|
||||
}
|
||||
|
||||
runStep('Typecheck', 'npm', ['run', 'typecheck']);
|
||||
runStep('Targeted verify', 'npx', ['tsx', 'src/main.ts', 'verify', site]);
|
||||
|
||||
const testFiles = findSiteTests();
|
||||
if (testFiles.length === 0) {
|
||||
console.log(`\nNo site-specific vitest files found for "${site}". Skipping full vitest run.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
runStep(
|
||||
`Site tests (${site})`,
|
||||
'npx',
|
||||
['vitest', 'run', ...testFiles.map(filePath => path.relative(repoRoot, filePath))],
|
||||
);
|
||||
+64
-76
@@ -1,16 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { PlaywrightMCP, __test__ } from './browser/index.js';
|
||||
import { afterEach, describe, it, expect, vi } from 'vitest';
|
||||
import { BrowserBridge, __test__ } from './browser/index.js';
|
||||
import * as daemonClient from './browser/daemon-client.js';
|
||||
|
||||
describe('browser helpers', () => {
|
||||
it('creates JSON-RPC requests with unique ids', () => {
|
||||
const first = __test__.createJsonRpcRequest('tools/call', { name: 'browser_tabs' });
|
||||
const second = __test__.createJsonRpcRequest('tools/call', { name: 'browser_snapshot' });
|
||||
|
||||
expect(second.id).toBe(first.id + 1);
|
||||
expect(first.message).toContain(`"id":${first.id}`);
|
||||
expect(second.message).toContain(`"id":${second.id}`);
|
||||
});
|
||||
|
||||
it('extracts tab entries from string snapshots', () => {
|
||||
const entries = __test__.extractTabEntries('Tab 0 https://example.com\nTab 1 Chrome Extension');
|
||||
|
||||
@@ -49,71 +41,60 @@ describe('browser helpers', () => {
|
||||
expect(__test__.appendLimited('12345', '67890', 8)).toBe('34567890');
|
||||
});
|
||||
|
||||
it('builds extension MCP args in local mode (no CI)', () => {
|
||||
const savedCI = process.env.CI;
|
||||
delete process.env.CI;
|
||||
try {
|
||||
expect(__test__.buildMcpArgs({
|
||||
mcpPath: '/tmp/cli.js',
|
||||
executablePath: '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
})).toEqual([
|
||||
'/tmp/cli.js',
|
||||
'--extension',
|
||||
'--executable-path',
|
||||
'/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
]);
|
||||
|
||||
expect(__test__.buildMcpArgs({
|
||||
mcpPath: '/tmp/cli.js',
|
||||
})).toEqual([
|
||||
'/tmp/cli.js',
|
||||
'--extension',
|
||||
]);
|
||||
} finally {
|
||||
if (savedCI !== undefined) {
|
||||
process.env.CI = savedCI;
|
||||
} else {
|
||||
delete process.env.CI;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('builds standalone MCP args in CI mode', () => {
|
||||
const savedCI = process.env.CI;
|
||||
process.env.CI = 'true';
|
||||
try {
|
||||
// CI mode: no --extension — browser launches in standalone headed mode
|
||||
expect(__test__.buildMcpArgs({
|
||||
mcpPath: '/tmp/cli.js',
|
||||
})).toEqual([
|
||||
'/tmp/cli.js',
|
||||
]);
|
||||
|
||||
expect(__test__.buildMcpArgs({
|
||||
mcpPath: '/tmp/cli.js',
|
||||
executablePath: '/usr/bin/chromium',
|
||||
})).toEqual([
|
||||
'/tmp/cli.js',
|
||||
'--executable-path',
|
||||
'/usr/bin/chromium',
|
||||
]);
|
||||
} finally {
|
||||
if (savedCI !== undefined) {
|
||||
process.env.CI = savedCI;
|
||||
} else {
|
||||
delete process.env.CI;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('times out slow promises', async () => {
|
||||
await expect(__test__.withTimeoutMs(new Promise(() => {}), 10, 'timeout')).rejects.toThrow('timeout');
|
||||
});
|
||||
|
||||
it('prefers the real Electron app target over DevTools and blank pages', () => {
|
||||
const target = __test__.selectCDPTarget([
|
||||
{
|
||||
type: 'page',
|
||||
title: 'DevTools - localhost:9224',
|
||||
url: 'devtools://devtools/bundled/inspector.html',
|
||||
webSocketDebuggerUrl: 'ws://127.0.0.1:9224/devtools',
|
||||
},
|
||||
{
|
||||
type: 'page',
|
||||
title: '',
|
||||
url: 'about:blank',
|
||||
webSocketDebuggerUrl: 'ws://127.0.0.1:9224/blank',
|
||||
},
|
||||
{
|
||||
type: 'app',
|
||||
title: 'Antigravity',
|
||||
url: 'http://localhost:3000/',
|
||||
webSocketDebuggerUrl: 'ws://127.0.0.1:9224/app',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(target?.webSocketDebuggerUrl).toBe('ws://127.0.0.1:9224/app');
|
||||
});
|
||||
|
||||
it('honors OPENCLI_CDP_TARGET when multiple inspectable targets exist', () => {
|
||||
vi.stubEnv('OPENCLI_CDP_TARGET', 'codex');
|
||||
|
||||
const target = __test__.selectCDPTarget([
|
||||
{
|
||||
type: 'app',
|
||||
title: 'Cursor',
|
||||
url: 'http://localhost:3000/cursor',
|
||||
webSocketDebuggerUrl: 'ws://127.0.0.1:9226/cursor',
|
||||
},
|
||||
{
|
||||
type: 'app',
|
||||
title: 'OpenAI Codex',
|
||||
url: 'http://localhost:3000/codex',
|
||||
webSocketDebuggerUrl: 'ws://127.0.0.1:9226/codex',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(target?.webSocketDebuggerUrl).toBe('ws://127.0.0.1:9226/codex');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlaywrightMCP state', () => {
|
||||
describe('BrowserBridge state', () => {
|
||||
it('transitions to closed after close()', async () => {
|
||||
const mcp = new PlaywrightMCP();
|
||||
const mcp = new BrowserBridge();
|
||||
|
||||
expect(mcp.state).toBe('idle');
|
||||
|
||||
@@ -123,25 +104,32 @@ describe('PlaywrightMCP state', () => {
|
||||
});
|
||||
|
||||
it('rejects connect() after the session has been closed', async () => {
|
||||
const mcp = new PlaywrightMCP();
|
||||
const mcp = new BrowserBridge();
|
||||
await mcp.close();
|
||||
|
||||
await expect(mcp.connect()).rejects.toThrow('Playwright MCP session is closed');
|
||||
await expect(mcp.connect()).rejects.toThrow('Session is closed');
|
||||
});
|
||||
|
||||
it('rejects connect() while already connecting', async () => {
|
||||
const mcp = new PlaywrightMCP();
|
||||
const mcp = new BrowserBridge();
|
||||
(mcp as any)._state = 'connecting';
|
||||
|
||||
await expect(mcp.connect()).rejects.toThrow('Playwright MCP is already connecting');
|
||||
await expect(mcp.connect()).rejects.toThrow('Already connecting');
|
||||
});
|
||||
|
||||
it('rejects connect() while closing', async () => {
|
||||
const mcp = new PlaywrightMCP();
|
||||
const mcp = new BrowserBridge();
|
||||
(mcp as any)._state = 'closing';
|
||||
|
||||
await expect(mcp.connect()).rejects.toThrow('Playwright MCP is closing');
|
||||
await expect(mcp.connect()).rejects.toThrow('Session is closing');
|
||||
});
|
||||
|
||||
it('fails fast when daemon is running but extension is disconnected', async () => {
|
||||
vi.spyOn(daemonClient, 'isExtensionConnected').mockResolvedValue(false);
|
||||
vi.spyOn(daemonClient, 'isDaemonRunning').mockResolvedValue(true);
|
||||
|
||||
const mcp = new BrowserBridge();
|
||||
|
||||
await expect(mcp.connect()).rejects.toThrow('Browser Extension is not connected');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* CDP client — implements IPage by connecting directly to a Chrome/Electron CDP WebSocket.
|
||||
*
|
||||
* Fixes applied:
|
||||
* - send() now has a 30s timeout guard (P0 #4)
|
||||
* - goto() waits for Page.loadEventFired instead of hardcoded 1s sleep (P1 #3)
|
||||
* - Implemented scroll, autoScroll, screenshot, networkRequests (P1 #2)
|
||||
* - Shared DOM helper methods extracted to reduce duplication with Page (P1 #5)
|
||||
*/
|
||||
|
||||
import { WebSocket } from 'ws';
|
||||
import type { IPage } from '../types.js';
|
||||
import { wrapForEval } from './utils.js';
|
||||
import {
|
||||
clickJs,
|
||||
typeTextJs,
|
||||
pressKeyJs,
|
||||
waitForTextJs,
|
||||
scrollJs,
|
||||
autoScrollJs,
|
||||
networkRequestsJs,
|
||||
} from './dom-helpers.js';
|
||||
|
||||
export interface CDPTarget {
|
||||
type?: string;
|
||||
url?: string;
|
||||
title?: string;
|
||||
webSocketDebuggerUrl?: string;
|
||||
}
|
||||
|
||||
const CDP_SEND_TIMEOUT = 30_000; // 30s per command
|
||||
|
||||
export class CDPBridge {
|
||||
private _ws: WebSocket | null = null;
|
||||
private _idCounter = 0;
|
||||
private _pending = new Map<number, { resolve: (val: any) => void; reject: (err: Error) => void; timer: ReturnType<typeof setTimeout> }>();
|
||||
private _eventListeners = new Map<string, Set<(params: any) => void>>();
|
||||
|
||||
async connect(opts?: { timeout?: number; workspace?: string }): Promise<IPage> {
|
||||
const endpoint = process.env.OPENCLI_CDP_ENDPOINT;
|
||||
if (!endpoint) throw new Error('OPENCLI_CDP_ENDPOINT is not set');
|
||||
|
||||
// If it's a direct ws:// URL, use it. Otherwise, fetch the /json endpoint to find a page.
|
||||
let wsUrl = endpoint;
|
||||
if (endpoint.startsWith('http')) {
|
||||
const res = await fetch(`${endpoint.replace(/\/$/, '')}/json`);
|
||||
if (!res.ok) throw new Error(`Failed to fetch CDP targets: ${res.statusText}`);
|
||||
const targets = await res.json() as CDPTarget[];
|
||||
const target = selectCDPTarget(targets);
|
||||
if (!target || !target.webSocketDebuggerUrl) {
|
||||
throw new Error('No inspectable targets found at CDP endpoint');
|
||||
}
|
||||
wsUrl = target.webSocketDebuggerUrl;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
const timeout = setTimeout(() => reject(new Error('CDP connect timeout')), opts?.timeout ?? 10000);
|
||||
|
||||
ws.on('open', () => {
|
||||
clearTimeout(timeout);
|
||||
this._ws = ws;
|
||||
resolve(new CDPPage(this));
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const msg = JSON.parse(data.toString());
|
||||
// Handle command responses
|
||||
if (msg.id && this._pending.has(msg.id)) {
|
||||
const entry = this._pending.get(msg.id)!;
|
||||
clearTimeout(entry.timer);
|
||||
this._pending.delete(msg.id);
|
||||
if (msg.error) {
|
||||
entry.reject(new Error(msg.error.message));
|
||||
} else {
|
||||
entry.resolve(msg.result);
|
||||
}
|
||||
}
|
||||
// Handle CDP events
|
||||
if (msg.method) {
|
||||
const listeners = this._eventListeners.get(msg.method);
|
||||
if (listeners) {
|
||||
for (const fn of listeners) fn(msg.params);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore parsing errors
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this._ws) {
|
||||
this._ws.close();
|
||||
this._ws = null;
|
||||
}
|
||||
for (const p of this._pending.values()) {
|
||||
clearTimeout(p.timer);
|
||||
p.reject(new Error('CDP connection closed'));
|
||||
}
|
||||
this._pending.clear();
|
||||
this._eventListeners.clear();
|
||||
}
|
||||
|
||||
/** Send a CDP command with timeout guard (P0 fix #4) */
|
||||
async send(method: string, params: any = {}, timeoutMs: number = CDP_SEND_TIMEOUT): Promise<any> {
|
||||
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) {
|
||||
throw new Error('CDP connection is not open');
|
||||
}
|
||||
const id = ++this._idCounter;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this._pending.delete(id);
|
||||
reject(new Error(`CDP command '${method}' timed out after ${timeoutMs / 1000}s`));
|
||||
}, timeoutMs);
|
||||
this._pending.set(id, { resolve, reject, timer });
|
||||
this._ws!.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
}
|
||||
|
||||
/** Listen for a CDP event */
|
||||
on(event: string, handler: (params: any) => void): void {
|
||||
let set = this._eventListeners.get(event);
|
||||
if (!set) { set = new Set(); this._eventListeners.set(event, set); }
|
||||
set.add(handler);
|
||||
}
|
||||
|
||||
/** Remove a CDP event listener */
|
||||
off(event: string, handler: (params: any) => void): void {
|
||||
this._eventListeners.get(event)?.delete(handler);
|
||||
}
|
||||
|
||||
/** Wait for a CDP event to fire (one-shot) */
|
||||
waitForEvent(event: string, timeoutMs: number = 15_000): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.off(event, handler);
|
||||
reject(new Error(`Timed out waiting for CDP event '${event}'`));
|
||||
}, timeoutMs);
|
||||
const handler = (params: any) => {
|
||||
clearTimeout(timer);
|
||||
this.off(event, handler);
|
||||
resolve(params);
|
||||
};
|
||||
this.on(event, handler);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class CDPPage implements IPage {
|
||||
constructor(private bridge: CDPBridge) {}
|
||||
|
||||
/** Navigate with proper load event waiting (P1 fix #3) */
|
||||
async goto(url: string): Promise<void> {
|
||||
await this.bridge.send('Page.enable');
|
||||
const loadPromise = this.bridge.waitForEvent('Page.loadEventFired', 30_000)
|
||||
.catch(() => {}); // Don't fail if event times out
|
||||
await this.bridge.send('Page.navigate', { url });
|
||||
await loadPromise;
|
||||
}
|
||||
|
||||
async evaluate(js: string): Promise<any> {
|
||||
const expression = wrapForEval(js);
|
||||
const result = await this.bridge.send('Runtime.evaluate', {
|
||||
expression,
|
||||
returnByValue: true,
|
||||
awaitPromise: true
|
||||
});
|
||||
if (result.exceptionDetails) {
|
||||
throw new Error('Evaluate error: ' + (result.exceptionDetails.exception?.description || 'Unknown exception'));
|
||||
}
|
||||
return result.result?.value;
|
||||
}
|
||||
|
||||
async getCookies(opts: { domain?: string; url?: string } = {}): Promise<any[]> {
|
||||
const result = await this.bridge.send('Network.getCookies', opts.url ? { urls: [opts.url] } : {});
|
||||
const cookies = Array.isArray(result?.cookies) ? result.cookies : [];
|
||||
return opts.domain
|
||||
? cookies.filter((cookie: any) => typeof cookie.domain === 'string' && cookie.domain.includes(opts.domain!))
|
||||
: cookies;
|
||||
}
|
||||
|
||||
async snapshot(_opts?: any): Promise<any> {
|
||||
// CDP doesn't have a built-in accessibility tree equivalent without additional setup
|
||||
return '(snapshot not available in CDP mode)';
|
||||
}
|
||||
|
||||
// ── Shared DOM operations (P1 fix #5 — using dom-helpers.ts) ──
|
||||
|
||||
async click(ref: string): Promise<void> {
|
||||
await this.evaluate(clickJs(ref));
|
||||
}
|
||||
|
||||
async typeText(ref: string, text: string): Promise<void> {
|
||||
await this.evaluate(typeTextJs(ref, text));
|
||||
}
|
||||
|
||||
async pressKey(key: string): Promise<void> {
|
||||
await this.evaluate(pressKeyJs(key));
|
||||
}
|
||||
|
||||
async wait(options: any): Promise<void> {
|
||||
if (typeof options === 'number') {
|
||||
await new Promise(resolve => setTimeout(resolve, options * 1000));
|
||||
return;
|
||||
}
|
||||
if (options.time) {
|
||||
await new Promise(resolve => setTimeout(resolve, options.time * 1000));
|
||||
return;
|
||||
}
|
||||
if (options.text) {
|
||||
const timeout = (options.timeout ?? 30) * 1000;
|
||||
await this.evaluate(waitForTextJs(options.text, timeout));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Implemented methods (P1 fix #2) ──
|
||||
|
||||
async scroll(direction: string = 'down', amount: number = 500): Promise<void> {
|
||||
await this.evaluate(scrollJs(direction, amount));
|
||||
}
|
||||
|
||||
async autoScroll(options?: { times?: number; delayMs?: number }): Promise<void> {
|
||||
const times = options?.times ?? 3;
|
||||
const delayMs = options?.delayMs ?? 2000;
|
||||
await this.evaluate(autoScrollJs(times, delayMs));
|
||||
}
|
||||
|
||||
async screenshot(options: any = {}): Promise<string> {
|
||||
const result = await this.bridge.send('Page.captureScreenshot', {
|
||||
format: options.format ?? 'png',
|
||||
quality: options.format === 'jpeg' ? (options.quality ?? 80) : undefined,
|
||||
captureBeyondViewport: options.fullPage ?? false,
|
||||
});
|
||||
const base64 = result.data;
|
||||
if (options.path) {
|
||||
const fs = await import('node:fs');
|
||||
const path = await import('node:path');
|
||||
const dir = path.dirname(options.path);
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
await fs.promises.writeFile(options.path, Buffer.from(base64, 'base64'));
|
||||
}
|
||||
return base64;
|
||||
}
|
||||
|
||||
async networkRequests(includeStatic: boolean = false): Promise<any> {
|
||||
return this.evaluate(networkRequestsJs(includeStatic));
|
||||
}
|
||||
|
||||
async tabs(): Promise<any> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async closeTab(_index?: number): Promise<void> {
|
||||
// Not supported in direct CDP mode
|
||||
}
|
||||
|
||||
async newTab(): Promise<void> {
|
||||
await this.bridge.send('Target.createTarget', { url: 'about:blank' });
|
||||
}
|
||||
|
||||
async selectTab(_index: number): Promise<void> {
|
||||
// Not supported in direct CDP mode
|
||||
}
|
||||
|
||||
async consoleMessages(_level?: string): Promise<any> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async installInterceptor(pattern: string): Promise<void> {
|
||||
const { generateInterceptorJs } = await import('../interceptor.js');
|
||||
await this.evaluate(generateInterceptorJs(JSON.stringify(pattern), {
|
||||
arrayName: '__opencli_xhr',
|
||||
patchGuard: '__opencli_interceptor_patched',
|
||||
}));
|
||||
}
|
||||
|
||||
async getInterceptedRequests(): Promise<any[]> {
|
||||
const { generateReadInterceptedJs } = await import('../interceptor.js');
|
||||
const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
|
||||
return (result as any[]) || [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── CDP target selection (unchanged) ──
|
||||
|
||||
function selectCDPTarget(targets: CDPTarget[]): CDPTarget | undefined {
|
||||
const preferredPattern = compilePreferredPattern(process.env.OPENCLI_CDP_TARGET);
|
||||
|
||||
const ranked = targets
|
||||
.map((target, index) => ({ target, index, score: scoreCDPTarget(target, preferredPattern) }))
|
||||
.filter(({ score }) => Number.isFinite(score))
|
||||
.sort((a, b) => {
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
return a.index - b.index;
|
||||
});
|
||||
|
||||
return ranked[0]?.target;
|
||||
}
|
||||
|
||||
function scoreCDPTarget(target: CDPTarget, preferredPattern?: RegExp): number {
|
||||
if (!target.webSocketDebuggerUrl) return Number.NEGATIVE_INFINITY;
|
||||
|
||||
const type = (target.type ?? '').toLowerCase();
|
||||
const url = (target.url ?? '').toLowerCase();
|
||||
const title = (target.title ?? '').toLowerCase();
|
||||
const haystack = `${title} ${url}`;
|
||||
|
||||
if (!haystack.trim() && !type) return Number.NEGATIVE_INFINITY;
|
||||
if (haystack.includes('devtools')) return Number.NEGATIVE_INFINITY;
|
||||
|
||||
let score = 0;
|
||||
|
||||
if (preferredPattern && preferredPattern.test(haystack)) score += 1000;
|
||||
|
||||
if (type === 'app') score += 120;
|
||||
else if (type === 'webview') score += 100;
|
||||
else if (type === 'page') score += 80;
|
||||
else if (type === 'iframe') score += 20;
|
||||
|
||||
if (url.startsWith('http://localhost') || url.startsWith('https://localhost')) score += 90;
|
||||
if (url.startsWith('file://')) score += 60;
|
||||
if (url.startsWith('http://127.0.0.1') || url.startsWith('https://127.0.0.1')) score += 50;
|
||||
if (url.startsWith('about:blank')) score -= 120;
|
||||
if (url === '' || url === 'about:blank') score -= 40;
|
||||
|
||||
if (title && title !== 'devtools') score += 25;
|
||||
if (title.includes('antigravity')) score += 120;
|
||||
if (title.includes('codex')) score += 120;
|
||||
if (title.includes('cursor')) score += 120;
|
||||
if (title.includes('chatwise')) score += 120;
|
||||
if (title.includes('notion')) score += 120;
|
||||
if (title.includes('discord')) score += 120;
|
||||
if (title.includes('netease')) score += 120;
|
||||
|
||||
if (url.includes('antigravity')) score += 100;
|
||||
if (url.includes('codex')) score += 100;
|
||||
if (url.includes('cursor')) score += 100;
|
||||
if (url.includes('chatwise')) score += 100;
|
||||
if (url.includes('notion')) score += 100;
|
||||
if (url.includes('discord')) score += 100;
|
||||
if (url.includes('netease')) score += 100;
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
function compilePreferredPattern(raw: string | undefined): RegExp | undefined {
|
||||
const value = raw?.trim();
|
||||
if (!value) return undefined;
|
||||
return new RegExp(escapeRegExp(value.toLowerCase()));
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export const __test__ = {
|
||||
selectCDPTarget,
|
||||
scoreCDPTarget,
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user