diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b09b226..a17438f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,23 +16,6 @@ concurrency: cancel-in-progress: true jobs: - plugin-pr-scope: - name: Plugin PR scope - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - persist-credentials: false - - - uses: actions/setup-node@v6 - with: - node-version: '22' - - - name: Check plugin PR scope - run: npm run check:plugin-pr-scope -- "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}" - skills: name: Skill sources runs-on: ubuntu-latest @@ -73,10 +56,6 @@ jobs: - name: Type check run: npm run typecheck - - name: Check community plugin metadata - if: runner.os == 'Linux' - run: npm run check-community-plugins - - name: Check Codex plugin metadata if: runner.os == 'Linux' run: npm run check:codex-plugin @@ -84,33 +63,17 @@ jobs: - name: Build run: npm run build - - name: Build plugin command manifest - if: runner.os == 'Linux' - run: npm run build-plugin-manifest - - - name: Check plugin command parity - if: runner.os == 'Linux' - run: npm run check:plugin-parity - - name: Check generated contract artifacts if: runner.os == 'Linux' run: npm run check:hosted-contract - name: Check all generated artifacts are committed if: runner.os == 'Linux' - run: git diff --exit-code -- cli-manifest.json hosted-contract.json webcmd-plugin.json README.md + run: git diff --exit-code -- cli-manifest.json hosted-contract.json README.md - name: Verify packed CLI executables run: npm run check:package-bin - - name: Check silent column drops - if: runner.os == 'Linux' - run: npm run check:silent-column-drop - - - name: Check typed error lint baseline - if: runner.os == 'Linux' - run: npm run check:typed-error-lint - unit-test: name: Unit tests (${{ matrix.os }}, shard ${{ matrix.shard }}/2) needs: build @@ -134,31 +97,6 @@ jobs: - name: Run unit tests run: npx vitest run --project unit --reporter=verbose --shard=${{ matrix.shard }}/2 - plugin-test: - name: Plugin tests (${{ matrix.os }}) - needs: build - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - steps: - - uses: actions/checkout@v6 - - - uses: actions/setup-node@v6 - with: - node-version: '22' - cache: npm - - - name: Install dependencies - run: npm ci - - - name: Build - run: npm run build - - - name: Run plugin tests - run: npm run test:plugin -- --reporter=verbose - bun-test: name: Bun compatibility needs: build diff --git a/.github/workflows/reconcile-hosted-plugins.yml b/.github/workflows/reconcile-hosted-plugins.yml deleted file mode 100644 index dbbc79b4..00000000 --- a/.github/workflows/reconcile-hosted-plugins.yml +++ /dev/null @@ -1,51 +0,0 @@ -# Required repository secrets: -# GCP_WORKLOAD_IDENTITY_PROVIDER — workload identity provider resource name -# GCP_RECONCILE_SERVICE_ACCOUNT — service account with run.jobs.run on the job -# GCP_REGION — region hosting webcmd-reconcile-marketplace -# -# The Cloud Run Job webcmd-reconcile-marketplace must exist and run -# `npm run job:reconcile-marketplace` with DATABASE_URL, WEBCMD_ARTIFACT_ROOT, -# and GITHUB_TOKEN configured. - -name: Reconcile hosted plugin catalog - -on: - push: - branches: [main] - paths: - - 'plugins/**' - - 'webcmd-plugin.json' - workflow_dispatch: - # Note: manual runs should be triggered against main to ensure github.sha - # points to the current tip. Reconciling an older ref would delist plugins - # added since that commit. - -concurrency: - group: reconcile-hosted-plugins - cancel-in-progress: false - -jobs: - reconcile: - runs-on: ubuntu-latest - permissions: - contents: read - id-token: write - steps: - - name: Authenticate to Google Cloud - uses: google-github-actions/auth@v2 - with: - workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }} - service_account: ${{ secrets.GCP_RECONCILE_SERVICE_ACCOUNT }} - - - name: Set up gcloud CLI - uses: google-github-actions/setup-gcloud@v2 - - - name: Execute Cloud Run Job - run: | - # Note: --update-env-vars on 'execute' (not 'update') is a per-execution - # override that does not modify the job's stored configuration. See: - # "environment variables overrides for an execution of a job" - gcloud run jobs execute webcmd-reconcile-marketplace \ - --region "${{ secrets.GCP_REGION }}" \ - --update-env-vars "WEBCMD_PLUGINS_COMMIT=${{ github.sha }}" \ - --wait diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6aa5978d..7f61398c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -77,25 +77,13 @@ jobs: if: ${{ steps.release.outputs.release_created || inputs.publish_tag != '' }} run: npm run build - - name: Build plugin command manifest - if: ${{ steps.release.outputs.release_created || inputs.publish_tag != '' }} - run: npm run build-plugin-manifest - - - name: Check plugin command parity - if: ${{ steps.release.outputs.release_created || inputs.publish_tag != '' }} - run: npm run check:plugin-parity - - - name: Check community plugin metadata - if: ${{ steps.release.outputs.release_created || inputs.publish_tag != '' }} - run: npm run check-community-plugins - - name: Check generated contract artifacts if: ${{ steps.release.outputs.release_created || inputs.publish_tag != '' }} run: npm run check:hosted-contract - name: Check all generated artifacts are committed if: ${{ steps.release.outputs.release_created || inputs.publish_tag != '' }} - run: git diff --exit-code -- cli-manifest.json hosted-contract.json webcmd-plugin.json README.md + run: git diff --exit-code -- cli-manifest.json hosted-contract.json README.md - name: Check Codex plugin metadata if: ${{ steps.release.outputs.release_created || inputs.publish_tag != '' }} diff --git a/.github/workflows/sync-community-plugins.yml b/.github/workflows/sync-community-plugins.yml deleted file mode 100644 index 2358d7de..00000000 --- a/.github/workflows/sync-community-plugins.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Sync community plugins - -on: - push: - branches: [main] - paths: - - 'plugins/*/webcmd-plugin.json' - - 'plugin-catalog.json' - - 'src/community-plugin-sync.ts' - - 'scripts/sync-community-plugins.ts' - - 'package.json' - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: sync-community-plugins - cancel-in-progress: false - -jobs: - generate: - runs-on: ubuntu-latest - steps: - - name: Checkout main - uses: actions/checkout@v6 - with: - token: ${{ secrets.RELEASE_PLEASE_TOKEN || github.token }} - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: '22' - cache: npm - - - name: Install dependencies - run: npm ci - - - name: Sync community plugin files - run: npm run sync-community-plugins - - - name: Commit generated files - shell: bash - run: | - if git diff --quiet -- README.md webcmd-plugin.json; then - echo "Community plugin files are already up to date." - else - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add README.md webcmd-plugin.json - git commit -m "docs: sync community plugins [skip ci]" - git push origin "HEAD:${{ github.ref_name }}" - fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6a0c951e..b1dd8223 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,6 +9,10 @@ npm run build npm test ``` +## Community adapters + +Public site adapters live in [`agentrhq/webcmd-plugins`](https://github.com/agentrhq/webcmd-plugins), not this repository. + ## Adapter Imports Adapters must import public APIs from `@agentrhq/webcmd`: diff --git a/README.md b/README.md index 47c0c205..92b8bf1e 100644 --- a/README.md +++ b/README.md @@ -158,15 +158,12 @@ Webcmd Cloud can run supported commands and browser sessions on hosted infrastru ## Community - -### Community plugins +Site adapters live in [`agentrhq/webcmd-plugins`](https://github.com/agentrhq/webcmd-plugins). Search and install them with: -| Plugin | Description | Author | -| --- | --- | --- | -| [`omnisearch`](./plugins/omnisearch/) | No-login research across Hacker News, Stack Overflow, GitHub, arXiv, Dev.to, Lobsters, and Bluesky | [Rishet Mehra](https://github.com/Rishet11) | -| [`pypi`](./plugins/pypi/) | Inspect public Python package metadata, downloads, and releases from PyPI | [Kemal Kaya](https://github.com/yoldaolmak) | -| [`skyscanner`](./plugins/skyscanner/) | Skyscanner flight search commands for Webcmd | [Rishabh](https://github.com/rishabhraj36) | - +```bash +webcmd plugin search -f json +webcmd plugin install github:agentrhq/webcmd-plugins/ +``` ## Contributing diff --git a/TESTING.md b/TESTING.md index 4c286374..fb53b2f2 100644 --- a/TESTING.md +++ b/TESTING.md @@ -5,13 +5,11 @@ ```bash npm run typecheck npm run build -npm run build-plugin-manifest npm test ``` -`npm run build` must run before plugin tests because repository plugins import -the compiled public package exports. The core package contains no site -adapters; `npm test` runs the unit and generic plugin projects. +The core package contains no site adapters; `npm test` runs the unit project. +Public adapter tests live in `agentrhq/webcmd-plugins`. ## Skill Sources @@ -29,7 +27,6 @@ make verify npx vitest run --project unit src/skills.test.ts npx vitest run --project unit src/package-exports.test.ts npx vitest run --project unit src/convention-audit.test.ts src/runtime-copy.test.ts -npm run test:plugin -- --reporter=verbose ``` ## Cloak Runtime Smoke diff --git a/docs/agent-prompts.mdx b/docs/agent-prompts.mdx index b8d4b93b..a66916c6 100644 --- a/docs/agent-prompts.mdx +++ b/docs/agent-prompts.mdx @@ -52,7 +52,7 @@ Create a private `webcmd-acme` plugin for our internal tools: Jira release notes ## Publish a Community Plugin ```text -Prepare my `webcmd-acme` plugin for contribution to the AgentR Webcmd repository. Place it under `plugins/acme/`, make the manifest name match the directory, include version, description, Webcmd compatibility, and my author name and GitHub handle, improve the README and examples, run the repository plugin checks, and open a draft pull request returning its URL, workflow summary, and verification evidence. Use my authenticated GitHub account only for that draft PR; do not merge, publish a package, or modify unrelated repository files. +Prepare my `webcmd-acme` plugin for contribution to agentrhq/webcmd-plugins. Place it under `plugins/acme/`, make the manifest name match the directory, include version, description, Webcmd compatibility, and my author name and GitHub handle, improve the README and examples, run the repository plugin checks, and open a draft pull request returning its URL, workflow summary, and verification evidence. Use my authenticated GitHub account only for that draft PR; do not merge, publish a package, or modify unrelated repository files. ``` ## Ask for a Plan First diff --git a/docs/publish-community-plugin.mdx b/docs/publish-community-plugin.mdx index d31bc7a8..7824c056 100644 --- a/docs/publish-community-plugin.mdx +++ b/docs/publish-community-plugin.mdx @@ -12,12 +12,12 @@ Publish a plugin only when its workflows, examples, and verification are useful ## Ask an Agent to Prepare the Contribution ```text -Prepare my `webcmd-acme` plugin for contribution to the AgentR Webcmd repository. Place it under `plugins/acme/`, make the manifest name match the directory, include version, description, Webcmd compatibility, and my author name and GitHub handle, improve the README and examples, run the repository plugin checks, and open a pull request summarizing the workflows and verification evidence. +Prepare my `webcmd-acme` plugin for contribution to agentrhq/webcmd-plugins. Place it under `plugins/acme/`, make the manifest name match the directory, include version, description, Webcmd compatibility, and my author name and GitHub handle, improve the README and examples, run the repository plugin checks, and open a pull request summarizing the workflows and verification evidence. ``` ## Repository Requirements -Community plugins live under `plugins//`. Their `webcmd-plugin.json` must use the directory name, and include non-empty `version`, `description`, and `webcmd` compatibility fields. Author metadata needs both a display name and valid GitHub handle. +Community plugins live under `plugins//` in [`agentrhq/webcmd-plugins`](https://github.com/agentrhq/webcmd-plugins). Their `webcmd-plugin.json` must use the directory name, and include non-empty `version`, `description`, and `webcmd` compatibility fields. Author metadata needs both a display name and valid GitHub handle. Repository plugins are catalog source, not npm package contents. Users install an approved plugin explicitly; Webcmd core does not bundle it. @@ -30,7 +30,7 @@ To fork an installed command into a private copy you can edit, run `webcmd adapt ## Validation and Pull Request -The agent runs `npm run check-community-plugins`, improves the plugin README and examples, and opens a pull request with the workflows and verification evidence. The pull request is the review/publishing boundary. +The agent runs `npm run sync-community-plugins` and `npm run check-community-plugins` in `agentrhq/webcmd-plugins`, improves the plugin README and examples, and opens a pull request with the workflows and verification evidence. The pull request is the review/publishing boundary. ## What Happens After Merge diff --git a/package.json b/package.json index 9fcebf95..fa24eddf 100644 --- a/package.json +++ b/package.json @@ -54,16 +54,13 @@ "benchmark": "uv run python benchmarks/scripts/run_eval.py", "dev": "tsx src/main.ts", "dev:bun": "bun src/main.ts", - "build": "npm run clean-dist && npm run copy-yaml && npm run compile && npm run build-manifest && npm run build-plugin-manifest", + "build": "npm run clean-dist && npm run copy-yaml && npm run compile && npm run build-manifest", "compile": "tsc --build && node -e \"require('fs').chmodSync('dist/src/main.js', 0o755)\"", "build-manifest": "tsx src/build-manifest.ts", - "build-plugin-manifest": "tsx src/build-plugin-command-manifest.ts", "check:codex-plugin": "node scripts/check-codex-plugin.mjs", "check:hosted-contract": "node scripts/check-hosted-contract.mjs", - "check:plugin-pr-scope": "node scripts/check-plugin-pr-scope.mjs", "clean-dist": "node scripts/clean-dist.cjs", "copy-yaml": "node scripts/copy-yaml.cjs", - "sync-community-plugins": "tsx scripts/sync-community-plugins.ts", "generate-release-notes": "tsx scripts/generate-release-notes.ts", "docs-sync-review": "tsx scripts/docs-sync-review.ts", "benchmark:snapshot": "tsx scripts/benchmark-snapshot-render.ts", @@ -74,18 +71,13 @@ "typecheck": "tsc --noEmit", "prepare": "[ -d src ] && npm run build || true", "prepublishOnly": "npm run build", - "test": "vitest run --project unit --project plugin", - "test:bun": "bun vitest run --project unit --project plugin", - "test:plugin": "vitest run --project plugin", + "test": "vitest run --project unit", + "test:bun": "bun vitest run --project unit", "test:all": "vitest run", "test:e2e": "vitest run --project e2e-fixed-port --project e2e", "gate:cloak-sessions": "WEBCMD_LIVE_CLOAK=1 vitest run --project e2e tests/e2e/cloak-session-concurrency.test.ts", - "check-community-plugins": "tsx scripts/sync-community-plugins.ts --check", "advise:listing-id-pairing": "node scripts/check-listing-id-pairing.mjs", - "check:package-bin": "node scripts/check-package-bin.mjs", - "check:silent-column-drop": "node scripts/check-silent-column-drop.mjs", - "check:typed-error-lint": "node scripts/check-typed-error-lint.mjs", - "check:plugin-parity": "node scripts/check-plugin-command-parity.mjs" + "check:package-bin": "node scripts/check-package-bin.mjs" }, "keywords": [ "cli", diff --git a/plugin-catalog.json b/plugin-catalog.json index 1e0060d5..d8445d11 100644 --- a/plugin-catalog.json +++ b/plugin-catalog.json @@ -2,9 +2,9 @@ "version": 1, "sources": [ { - "id": "agentrhq/webcmd", - "source": "github:agentrhq/webcmd", - "manifestUrl": "https://raw.githubusercontent.com/agentrhq/webcmd/main/webcmd-plugin.json" + "id": "agentrhq/webcmd-plugins", + "source": "github:agentrhq/webcmd-plugins", + "manifestUrl": "https://raw.githubusercontent.com/agentrhq/webcmd-plugins/main/webcmd-plugin.json" } ] } diff --git a/plugin-command-manifest.json b/plugin-command-manifest.json deleted file mode 100644 index c812e0a1..00000000 --- a/plugin-command-manifest.json +++ /dev/null @@ -1,30954 +0,0 @@ -[ - { - "site": "amazon", - "name": "bestsellers", - "description": "Amazon Best Sellers pages for category candidate discovery", - "access": "read", - "domain": "amazon.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "input", - "type": "str", - "required": false, - "positional": true, - "help": "Ranking URL or supported Amazon path. Omit to use the list root." - }, - { - "name": "limit", - "type": "int", - "default": 100, - "required": false, - "help": "Maximum number of ranked items to return (default 100)" - } - ], - "columns": [ - "list_type", - "rank", - "asin", - "title", - "price_text", - "rating_value", - "review_count" - ], - "type": "js", - "modulePath": "plugins/amazon/bestsellers.js", - "sourceFile": "plugins/amazon/bestsellers.js", - "navigateBefore": false - }, - { - "site": "amazon", - "name": "discussion", - "description": "Amazon review summary and sample customer discussion from product review pages", - "access": "read", - "domain": "amazon.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "input", - "type": "str", - "required": true, - "positional": true, - "help": "ASIN or product URL, for example B0FJS72893" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Maximum number of review samples to return (default 10)" - } - ], - "columns": [ - "asin", - "average_rating_value", - "total_review_count" - ], - "type": "js", - "modulePath": "plugins/amazon/discussion.js", - "sourceFile": "plugins/amazon/discussion.js", - "navigateBefore": false - }, - { - "site": "amazon", - "name": "login", - "description": "Open amazon login", - "access": "write", - "domain": "amazon.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "status", - "logged_in", - "site", - "user_name", - "action", - "verify_command" - ], - "type": "js", - "modulePath": "plugins/amazon/auth.js", - "sourceFile": "plugins/amazon/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "amazon", - "name": "movers-shakers", - "description": "Amazon Movers & Shakers pages for short-term growth signals", - "access": "read", - "domain": "amazon.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "input", - "type": "str", - "required": false, - "positional": true, - "help": "Ranking URL or supported Amazon path. Omit to use the list root." - }, - { - "name": "limit", - "type": "int", - "default": 100, - "required": false, - "help": "Maximum number of ranked items to return (default 100)" - } - ], - "columns": [ - "list_type", - "rank", - "asin", - "title", - "price_text", - "rating_value", - "review_count" - ], - "type": "js", - "modulePath": "plugins/amazon/movers-shakers.js", - "sourceFile": "plugins/amazon/movers-shakers.js", - "navigateBefore": false - }, - { - "site": "amazon", - "name": "new-releases", - "description": "Amazon New Releases pages for early momentum discovery", - "access": "read", - "domain": "amazon.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "input", - "type": "str", - "required": false, - "positional": true, - "help": "Ranking URL or supported Amazon path. Omit to use the list root." - }, - { - "name": "limit", - "type": "int", - "default": 100, - "required": false, - "help": "Maximum number of ranked items to return (default 100)" - } - ], - "columns": [ - "list_type", - "rank", - "asin", - "title", - "price_text", - "rating_value", - "review_count" - ], - "type": "js", - "modulePath": "plugins/amazon/new-releases.js", - "sourceFile": "plugins/amazon/new-releases.js", - "navigateBefore": false - }, - { - "site": "amazon", - "name": "offer", - "description": "Amazon seller, buy box, and fulfillment facts from the product page", - "access": "read", - "domain": "amazon.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "input", - "type": "str", - "required": true, - "positional": true, - "help": "ASIN or product URL, for example B0FJS72893" - } - ], - "columns": [ - "asin", - "price_text", - "sold_by", - "ships_from", - "is_amazon_sold", - "is_amazon_fulfilled" - ], - "type": "js", - "modulePath": "plugins/amazon/offer.js", - "sourceFile": "plugins/amazon/offer.js", - "navigateBefore": false - }, - { - "site": "amazon", - "name": "product", - "description": "Amazon product page facts for candidate validation", - "access": "read", - "domain": "amazon.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "input", - "type": "str", - "required": true, - "positional": true, - "help": "ASIN or product URL, for example B0FJS72893" - } - ], - "columns": [ - "asin", - "title", - "price_text", - "rating_value", - "review_count" - ], - "type": "js", - "modulePath": "plugins/amazon/product.js", - "sourceFile": "plugins/amazon/product.js", - "navigateBefore": false - }, - { - "site": "amazon", - "name": "search", - "description": "Amazon search results for product discovery and coarse filtering", - "access": "read", - "domain": "amazon.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search query, for example \"desk shelf organizer\"" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Maximum number of results to return (default 20)" - } - ], - "columns": [ - "rank", - "asin", - "title", - "price_text", - "rating_value", - "review_count" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/amazon/search.js", - "sourceFile": "plugins/amazon/search.js", - "navigateBefore": false - }, - { - "site": "amazon", - "name": "whoami", - "description": "Show the current logged-in amazon account", - "access": "read", - "domain": "amazon.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "logged_in", - "site", - "user_name" - ], - "type": "js", - "modulePath": "plugins/amazon/auth.js", - "sourceFile": "plugins/amazon/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "amazon-in", - "name": "cart", - "description": "Read the authenticated Amazon.in cart", - "access": "read", - "domain": "amazon.in", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "asin", - "title", - "price", - "quantity", - "product_url" - ], - "type": "js", - "modulePath": "plugins/amazon-in/cart.js", - "sourceFile": "plugins/amazon-in/cart.js", - "navigateBefore": false, - "siteSession": "persistent", - "freshPage": true - }, - { - "site": "amazon-in", - "name": "cart-add", - "description": "Add one confirmed Amazon.in product variant to the cart", - "access": "write", - "domain": "amazon.in", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "input", - "type": "str", - "required": true, - "positional": true, - "help": "Amazon.in product URL or ASIN" - }, - { - "name": "size", - "type": "str", - "required": false, - "help": "Exact visible size label" - }, - { - "name": "colour", - "type": "str", - "required": false, - "help": "Exact visible colour label" - } - ], - "columns": [ - "status", - "asin", - "title", - "size", - "colour", - "action" - ], - "type": "js", - "modulePath": "plugins/amazon-in/cart-add.js", - "sourceFile": "plugins/amazon-in/cart-add.js", - "navigateBefore": false, - "siteSession": "persistent", - "freshPage": true - }, - { - "site": "amazon-in", - "name": "checkout", - "description": "Prepare a guarded Amazon.in checkout with browser-only payment handoff", - "access": "write", - "domain": "amazon.in", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "input", - "type": "str", - "required": true, - "positional": true, - "help": "Amazon.in product URL or ASIN" - }, - { - "name": "quantity", - "type": "int", - "default": 1, - "required": false, - "help": "Quantity (1-10)" - }, - { - "name": "size", - "type": "str", - "required": false, - "help": "Exact visible size label" - }, - { - "name": "colour", - "type": "str", - "required": false, - "help": "Exact visible colour label" - }, - { - "name": "payment", - "type": "str", - "required": true, - "help": "Payment method; secrets remain browser-only", - "choices": [ - "upi", - "saved-card", - "new-card", - "cod" - ] - }, - { - "name": "card-last4", - "type": "str", - "required": false, - "help": "Saved-card selector: exactly four digits" - }, - { - "name": "place-order", - "type": "boolean", - "default": false, - "required": false, - "help": "Submit the final Amazon action once" - } - ], - "columns": [ - "status", - "asin", - "title", - "size", - "colour", - "quantity", - "item_price", - "total", - "payment_method", - "delivery_date", - "action", - "verify_command" - ], - "type": "js", - "modulePath": "plugins/amazon-in/checkout.js", - "sourceFile": "plugins/amazon-in/checkout.js", - "navigateBefore": false, - "siteSession": "persistent", - "freshPage": true - }, - { - "site": "amazon-in", - "name": "checkout-status", - "description": "Read the current Amazon.in checkout or payment state without clicking", - "access": "read", - "domain": "amazon.in", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "status", - "order_id", - "total", - "payment_method", - "action" - ], - "type": "js", - "modulePath": "plugins/amazon-in/checkout-status.js", - "sourceFile": "plugins/amazon-in/checkout-status.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "amazon-in", - "name": "login", - "description": "Open amazon-in login", - "access": "write", - "domain": "amazon.in", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "status", - "logged_in", - "site", - "user_name", - "action", - "verify_command" - ], - "type": "js", - "modulePath": "plugins/amazon-in/auth.js", - "sourceFile": "plugins/amazon-in/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "amazon-in", - "name": "product", - "description": "Fetch the current Amazon.in price and selected product variant", - "access": "read", - "domain": "amazon.in", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "input", - "type": "str", - "required": true, - "positional": true, - "help": "Amazon.in product URL or ASIN" - } - ], - "columns": [ - "asin", - "title", - "price", - "mrp", - "discount", - "availability", - "size", - "colour", - "image_url", - "product_url" - ], - "type": "js", - "modulePath": "plugins/amazon-in/product.js", - "sourceFile": "plugins/amazon-in/product.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "amazon-in", - "name": "search", - "description": "Search Amazon.in products with inclusive INR price bounds and images", - "access": "read", - "domain": "amazon.in", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Product search query" - }, - { - "name": "min-price", - "type": "number", - "required": false, - "help": "Inclusive minimum price in rupees" - }, - { - "name": "max-price", - "type": "number", - "required": false, - "help": "Inclusive maximum price in rupees" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Maximum results (1-50)" - } - ], - "columns": [ - "rank", - "asin", - "title", - "price", - "mrp", - "rating", - "review_count", - "image_url", - "product_url", - "is_sponsored" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/amazon-in/search.js", - "sourceFile": "plugins/amazon-in/search.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "amazon-in", - "name": "whoami", - "description": "Show the current logged-in amazon-in account", - "access": "read", - "domain": "amazon.in", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "logged_in", - "site", - "user_name" - ], - "type": "js", - "modulePath": "plugins/amazon-in/auth.js", - "sourceFile": "plugins/amazon-in/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "amazon-in", - "name": "wishlist", - "description": "Fetch current prices for products in the default Amazon.in wishlist", - "access": "read", - "domain": "amazon.in", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "filter", - "type": "str", - "default": "unpurchased", - "required": false, - "help": "Wishlist items to include", - "choices": [ - "unpurchased", - "all" - ] - } - ], - "columns": [ - "list_name", - "item_id", - "asin", - "title", - "price", - "mrp", - "availability", - "size", - "colour", - "image_url", - "product_url" - ], - "type": "js", - "modulePath": "plugins/amazon-in/wishlist.js", - "sourceFile": "plugins/amazon-in/wishlist.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "antigravity", - "name": "add-context", - "description": "Click the Add context button in the composer (opens file/URL picker for context attachment).", - "access": "write", - "domain": "127.0.0.1", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Status" - ], - "type": "js", - "modulePath": "plugins/antigravity/audit-extras.js", - "sourceFile": "plugins/antigravity/audit-extras.js", - "navigateBefore": true - }, - { - "site": "antigravity", - "name": "cookies", - "description": "List cookies on the Antigravity renderer (JS-visible via document.cookie).", - "access": "read", - "domain": "127.0.0.1", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Index", - "Key", - "Bytes", - "Name", - "Preview", - "Database", - "Version", - "Kind", - "Path", - "Workspace Id", - "Folder", - "Modified", - "Field", - "Value" - ], - "type": "js", - "modulePath": "plugins/antigravity/storage.js", - "sourceFile": "plugins/antigravity/storage.js", - "navigateBefore": true - }, - { - "site": "antigravity", - "name": "copy-code", - "description": "Return the text of a code block in the current conversation. Default: last code block; pass --index N (1-based from top) to pick a specific one.", - "access": "read", - "domain": "127.0.0.1", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "index", - "type": "int", - "required": false, - "help": "1-based index of code block (default: last)" - } - ], - "columns": [ - "Field", - "Value" - ], - "type": "js", - "modulePath": "plugins/antigravity/audit-extras.js", - "sourceFile": "plugins/antigravity/audit-extras.js", - "navigateBefore": true - }, - { - "site": "antigravity", - "name": "copy-message", - "description": "Return the text of the last assistant message (best-effort: walks up from the last visible Copy button).", - "access": "write", - "domain": "127.0.0.1", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "click-button", - "type": "boolean", - "default": false, - "required": false, - "help": "Also click the in-UI Copy button" - } - ], - "columns": [ - "Field", - "Value" - ], - "type": "js", - "modulePath": "plugins/antigravity/audit-extras.js", - "sourceFile": "plugins/antigravity/audit-extras.js", - "navigateBefore": true - }, - { - "site": "antigravity", - "name": "delete", - "description": "Delete an Antigravity conversation by ID. Antigravity asks for confirmation; we click through it. Require --yes to actually delete.", - "access": "write", - "domain": "127.0.0.1", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "id", - "type": "string", - "required": true, - "positional": true, - "help": "Conversation UUID (the part after \"convo-pill-\" in the sidebar testid)" - }, - { - "name": "yes", - "type": "boolean", - "default": false, - "required": false, - "help": "Actually delete (default: dry-run preview)" - } - ], - "columns": [ - "status", - "id" - ], - "type": "js", - "modulePath": "plugins/antigravity/delete.js", - "sourceFile": "plugins/antigravity/delete.js", - "navigateBefore": true - }, - { - "site": "antigravity", - "name": "display-options", - "description": "Open the Display Options menu and list its items.", - "access": "read", - "domain": "127.0.0.1", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Index", - "Item" - ], - "type": "js", - "modulePath": "plugins/antigravity/audit-extras.js", - "sourceFile": "plugins/antigravity/audit-extras.js", - "navigateBefore": true - }, - { - "site": "antigravity", - "name": "dump", - "description": "Dump the DOM to help AI understand the UI", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "htmlFile", - "snapFile" - ], - "type": "js", - "modulePath": "plugins/antigravity/dump.js", - "sourceFile": "plugins/antigravity/dump.js", - "navigateBefore": true - }, - { - "site": "antigravity", - "name": "extract-code", - "description": "Extract multi-line code blocks from the current Antigravity conversation", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "code" - ], - "type": "js", - "modulePath": "plugins/antigravity/extract-code.js", - "sourceFile": "plugins/antigravity/extract-code.js", - "navigateBefore": true - }, - { - "site": "antigravity", - "name": "history", - "description": "List visible Antigravity conversations from the sidebar", - "access": "read", - "domain": "127.0.0.1", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 50, - "required": false, - "help": "Max conversations to return" - } - ], - "columns": [ - "Index", - "Id", - "Title" - ], - "type": "js", - "modulePath": "plugins/antigravity/history.js", - "sourceFile": "plugins/antigravity/history.js", - "navigateBefore": true - }, - { - "site": "antigravity", - "name": "idb-list", - "description": "List IndexedDB databases on the Antigravity renderer.", - "access": "read", - "domain": "127.0.0.1", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Index", - "Key", - "Bytes", - "Name", - "Preview", - "Database", - "Version", - "Kind", - "Path", - "Workspace Id", - "Folder", - "Modified", - "Field", - "Value" - ], - "type": "js", - "modulePath": "plugins/antigravity/storage.js", - "sourceFile": "plugins/antigravity/storage.js", - "navigateBefore": true - }, - { - "site": "antigravity", - "name": "mark-read", - "description": "Mark an unread Antigravity conversation as read. Fails if the row is already read or the postcondition cannot be verified.", - "access": "write", - "domain": "127.0.0.1", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "id", - "type": "string", - "required": true, - "positional": true, - "help": "Conversation UUID (the part after \"convo-pill-\" in the sidebar testid)" - } - ], - "columns": [ - "status", - "id", - "clicked" - ], - "type": "js", - "modulePath": "plugins/antigravity/mark-read.js", - "sourceFile": "plugins/antigravity/mark-read.js", - "navigateBefore": true - }, - { - "site": "antigravity", - "name": "model", - "description": "Read or switch the active model in Antigravity. Without arguments, reports the current model. With (substring, case-insensitive), switches.", - "access": "write", - "domain": "127.0.0.1", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "name", - "type": "str", - "required": false, - "positional": true, - "help": "Substring (case-insensitive) of target model name. Omit to read current." - }, - { - "name": "list", - "type": "boolean", - "default": false, - "required": false, - "help": "List models in the picker (does not switch)" - } - ], - "columns": [ - "Status", - "Model" - ], - "type": "js", - "modulePath": "plugins/antigravity/model.js", - "sourceFile": "plugins/antigravity/model.js", - "navigateBefore": true - }, - { - "site": "antigravity", - "name": "nav", - "description": "Click Go Back or Go Forward (Antigravity in-app history).", - "access": "write", - "domain": "127.0.0.1", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "direction", - "type": "str", - "required": true, - "positional": true, - "help": "back or forward" - } - ], - "columns": [ - "Status" - ], - "type": "js", - "modulePath": "plugins/antigravity/audit-extras.js", - "sourceFile": "plugins/antigravity/audit-extras.js", - "navigateBefore": true - }, - { - "site": "antigravity", - "name": "new", - "description": "Start a new conversation / clear context in Antigravity", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "status" - ], - "type": "js", - "modulePath": "plugins/antigravity/new.js", - "sourceFile": "plugins/antigravity/new.js", - "navigateBefore": true - }, - { - "site": "antigravity", - "name": "react", - "description": "Click \"Good response\" or \"Bad response\" on the LAST assistant message.", - "access": "write", - "domain": "127.0.0.1", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "kind", - "type": "str", - "required": true, - "positional": true, - "help": "good or bad" - } - ], - "columns": [ - "Status", - "Reaction" - ], - "type": "js", - "modulePath": "plugins/antigravity/audit-extras.js", - "sourceFile": "plugins/antigravity/audit-extras.js", - "navigateBefore": true - }, - { - "site": "antigravity", - "name": "read", - "description": "Read the latest chat messages from Antigravity AI", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "last", - "type": "str", - "required": false, - "help": "Number of recent messages to read (not fully implemented due to generic structure, currently returns full history text or latest chunk)" - } - ], - "columns": [ - "role", - "content" - ], - "type": "js", - "modulePath": "plugins/antigravity/read.js", - "sourceFile": "plugins/antigravity/read.js", - "navigateBefore": true - }, - { - "site": "antigravity", - "name": "recent-paths", - "description": "Show Antigravity's recently-opened folders/files (history.recentlyOpenedPathsList).", - "access": "read", - "domain": "localhost", - "strategy": "local", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max rows to return" - } - ], - "columns": [ - "Index", - "Key", - "Bytes", - "Name", - "Preview", - "Database", - "Version", - "Kind", - "Path", - "Workspace Id", - "Folder", - "Modified", - "Field", - "Value" - ], - "type": "js", - "modulePath": "plugins/antigravity/storage.js", - "sourceFile": "plugins/antigravity/storage.js" - }, - { - "site": "antigravity", - "name": "rename", - "description": "Rename an Antigravity conversation by ID (NOT YET IMPLEMENTED — see source comment).", - "access": "write", - "domain": "127.0.0.1", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "id", - "type": "string", - "required": true, - "positional": true, - "help": "Conversation UUID (the part after \"convo-pill-\" in the sidebar testid)" - }, - { - "name": "title", - "type": "string", - "required": true, - "positional": true, - "help": "New title" - } - ], - "columns": [ - "status" - ], - "type": "js", - "modulePath": "plugins/antigravity/rename.js", - "sourceFile": "plugins/antigravity/rename.js", - "navigateBefore": true - }, - { - "site": "antigravity", - "name": "revert", - "description": "Click the revert button (per-message revert for agent changes). Requires --yes (this modifies your workspace).", - "access": "write", - "domain": "127.0.0.1", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "yes", - "type": "boolean", - "default": false, - "required": false, - "help": "Actually revert (default: dry-run)" - } - ], - "columns": [ - "Status" - ], - "type": "js", - "modulePath": "plugins/antigravity/audit-extras.js", - "sourceFile": "plugins/antigravity/audit-extras.js", - "navigateBefore": true - }, - { - "site": "antigravity", - "name": "send", - "description": "Send a message to Antigravity AI via the internal Lexical editor", - "access": "write", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "message", - "type": "str", - "required": true, - "positional": true, - "help": "The message text to send" - } - ], - "columns": [ - "Status", - "Message" - ], - "type": "js", - "modulePath": "plugins/antigravity/send.js", - "sourceFile": "plugins/antigravity/send.js", - "navigateBefore": true - }, - { - "site": "antigravity", - "name": "settings", - "description": "Click the Antigravity settings button (matched by data-testid=\"settings-button\").", - "access": "write", - "domain": "127.0.0.1", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Status" - ], - "type": "js", - "modulePath": "plugins/antigravity/audit-extras.js", - "sourceFile": "plugins/antigravity/audit-extras.js", - "navigateBefore": true - }, - { - "site": "antigravity", - "name": "settings-read", - "description": "Read Antigravity's user settings.json (theme, proxy, agCockpit, tfa.system.autoAccept, etc.).", - "access": "read", - "domain": "localhost", - "strategy": "local", - "browser": false, - "args": [], - "columns": [ - "Index", - "Key", - "Bytes", - "Name", - "Preview", - "Database", - "Version", - "Kind", - "Path", - "Workspace Id", - "Folder", - "Modified", - "Field", - "Value" - ], - "type": "js", - "modulePath": "plugins/antigravity/storage.js", - "sourceFile": "plugins/antigravity/storage.js" - }, - { - "site": "antigravity", - "name": "sidebar-toggle", - "description": "Click Toggle Sidebar (collapses/expands the Antigravity sidebar).", - "access": "write", - "domain": "127.0.0.1", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Status" - ], - "type": "js", - "modulePath": "plugins/antigravity/audit-extras.js", - "sourceFile": "plugins/antigravity/audit-extras.js", - "navigateBefore": true - }, - { - "site": "antigravity", - "name": "state-get", - "description": "Read one value from Antigravity's state.vscdb. Pass --workspace for per-workspace.", - "access": "read", - "domain": "localhost", - "strategy": "local", - "browser": false, - "args": [ - { - "name": "key", - "type": "str", - "required": true, - "positional": true, - "help": "Storage key name" - }, - { - "name": "workspace", - "type": "str", - "required": false, - "help": "Workspace id (from workspaces-list) to query per-workspace DB" - }, - { - "name": "max-bytes", - "type": "int", - "default": 8000, - "required": false, - "help": "Truncate value to this many chars" - } - ], - "columns": [ - "Index", - "Key", - "Bytes", - "Name", - "Preview", - "Database", - "Version", - "Kind", - "Path", - "Workspace Id", - "Folder", - "Modified", - "Field", - "Value" - ], - "type": "js", - "modulePath": "plugins/antigravity/storage.js", - "sourceFile": "plugins/antigravity/storage.js" - }, - { - "site": "antigravity", - "name": "state-keys", - "description": "List keys in Antigravity's globalStorage state.vscdb (VSCode-style). Pass --workspace to query a per-workspace DB. Works while Antigravity is closed.", - "access": "read", - "domain": "localhost", - "strategy": "local", - "browser": false, - "args": [ - { - "name": "filter", - "type": "str", - "required": false, - "help": "Case-insensitive substring filter over keys" - }, - { - "name": "workspace", - "type": "str", - "required": false, - "help": "Workspace id (from workspaces-list) to query per-workspace DB" - }, - { - "name": "limit", - "type": "int", - "default": 200, - "required": false, - "help": "Max rows to return" - } - ], - "columns": [ - "Index", - "Key", - "Bytes", - "Name", - "Preview", - "Database", - "Version", - "Kind", - "Path", - "Workspace Id", - "Folder", - "Modified", - "Field", - "Value" - ], - "type": "js", - "modulePath": "plugins/antigravity/storage.js", - "sourceFile": "plugins/antigravity/storage.js" - }, - { - "site": "antigravity", - "name": "status", - "description": "Check Antigravity CDP connection and get current page state", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "status", - "url", - "title" - ], - "type": "js", - "modulePath": "plugins/antigravity/status.js", - "sourceFile": "plugins/antigravity/status.js", - "navigateBefore": true - }, - { - "site": "antigravity", - "name": "storage-get", - "description": "Read a single localStorage / sessionStorage value on the Antigravity renderer.", - "access": "read", - "domain": "127.0.0.1", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "key", - "type": "str", - "required": true, - "positional": true, - "help": "Storage key name" - }, - { - "name": "storage", - "type": "str", - "default": "local", - "required": false, - "help": "\"local\" or \"session\"" - }, - { - "name": "max-bytes", - "type": "int", - "default": 4000, - "required": false, - "help": "Truncate value to this many chars" - } - ], - "columns": [ - "Index", - "Key", - "Bytes", - "Name", - "Preview", - "Database", - "Version", - "Kind", - "Path", - "Workspace Id", - "Folder", - "Modified", - "Field", - "Value" - ], - "type": "js", - "modulePath": "plugins/antigravity/storage.js", - "sourceFile": "plugins/antigravity/storage.js", - "navigateBefore": true - }, - { - "site": "antigravity", - "name": "storage-keys", - "description": "List localStorage / sessionStorage keys on the Antigravity renderer (CDP).", - "access": "read", - "domain": "127.0.0.1", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "storage", - "type": "str", - "default": "local", - "required": false, - "help": "\"local\" or \"session\"" - }, - { - "name": "filter", - "type": "str", - "required": false, - "help": "Case-insensitive substring filter" - }, - { - "name": "limit", - "type": "int", - "default": 100, - "required": false, - "help": "Max rows to return" - } - ], - "columns": [ - "Index", - "Key", - "Bytes", - "Name", - "Preview", - "Database", - "Version", - "Kind", - "Path", - "Workspace Id", - "Folder", - "Modified", - "Field", - "Value" - ], - "type": "js", - "modulePath": "plugins/antigravity/storage.js", - "sourceFile": "plugins/antigravity/storage.js", - "navigateBefore": true - }, - { - "site": "antigravity", - "name": "toggle-aux", - "description": "Toggle the Auxiliary Pane (Antigravity's secondary panel for code/preview).", - "access": "write", - "domain": "127.0.0.1", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Status" - ], - "type": "js", - "modulePath": "plugins/antigravity/audit-extras.js", - "sourceFile": "plugins/antigravity/audit-extras.js", - "navigateBefore": true - }, - { - "site": "antigravity", - "name": "watch", - "description": "Stream new chat messages from Antigravity in real-time", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "timeout", - "type": "int", - "default": 86400, - "required": false, - "help": "Max seconds to keep watching (default: 86400 — 24h)" - } - ], - "columns": [], - "type": "js", - "modulePath": "plugins/antigravity/watch.js", - "sourceFile": "plugins/antigravity/watch.js", - "navigateBefore": true - }, - { - "site": "antigravity", - "name": "workspaces-list", - "description": "List Antigravity workspaceStorage entries (each represents a previously-opened folder).", - "access": "read", - "domain": "localhost", - "strategy": "local", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 50, - "required": false, - "help": "Max rows to return" - } - ], - "columns": [ - "Index", - "Key", - "Bytes", - "Name", - "Preview", - "Database", - "Version", - "Kind", - "Path", - "Workspace Id", - "Folder", - "Modified", - "Field", - "Value" - ], - "type": "js", - "modulePath": "plugins/antigravity/storage.js", - "sourceFile": "plugins/antigravity/storage.js" - }, - { - "site": "apple-podcasts", - "name": "episodes", - "description": "List recent episodes of an Apple Podcast (use ID from search)", - "access": "read", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "id", - "type": "str", - "required": true, - "positional": true, - "help": "Podcast ID (collectionId from search output)" - }, - { - "name": "limit", - "type": "int", - "default": 15, - "required": false, - "help": "Max episodes to show" - } - ], - "columns": [ - "title", - "duration", - "date" - ], - "type": "js", - "modulePath": "plugins/apple-podcasts/episodes.js", - "sourceFile": "plugins/apple-podcasts/episodes.js" - }, - { - "site": "apple-podcasts", - "name": "search", - "description": "Search Apple Podcasts", - "access": "read", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search keyword" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Max results" - } - ], - "columns": [ - "id", - "title", - "author", - "episodes", - "genre", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/apple-podcasts/search.js", - "sourceFile": "plugins/apple-podcasts/search.js" - }, - { - "site": "apple-podcasts", - "name": "top", - "description": "Top podcasts chart on Apple Podcasts", - "access": "read", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of podcasts (max 100)" - }, - { - "name": "country", - "type": "str", - "default": "us", - "required": false, - "help": "Country code (e.g. us, cn, gb, jp)" - } - ], - "columns": [ - "rank", - "title", - "author", - "id" - ], - "type": "js", - "modulePath": "plugins/apple-podcasts/top.js", - "sourceFile": "plugins/apple-podcasts/top.js" - }, - { - "site": "archive", - "name": "item", - "description": "Fetch metadata for a single Internet Archive item by identifier.", - "access": "read", - "domain": "archive.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "identifier", - "type": "str", - "required": true, - "positional": true, - "help": "Archive item identifier (e.g. \"open-syllabus\", \"FinalFantasy2_356\")." - } - ], - "columns": [ - "identifier", - "title", - "creator", - "date", - "mediatype", - "collection", - "description", - "file_count", - "url" - ], - "type": "js", - "modulePath": "plugins/archive/item.js", - "sourceFile": "plugins/archive/item.js" - }, - { - "site": "archive", - "name": "search", - "description": "Search Internet Archive items across books, movies, audio, software, and web.", - "access": "read", - "domain": "archive.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Full-text query (matches title, description, creator, subject)." - }, - { - "name": "mediatype", - "type": "string", - "required": false, - "help": "Restrict to mediatype: texts, movies, audio, software, image, web, data, collection" - }, - { - "name": "sort", - "type": "string", - "default": "downloads", - "required": false, - "help": "Sort key: downloads, date, addeddate, week, title" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max items (max 100; one API page)." - } - ], - "columns": [ - "rank", - "identifier", - "title", - "creator", - "date", - "mediatype", - "downloads", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/archive/search.js", - "sourceFile": "plugins/archive/search.js" - }, - { - "site": "archive", - "name": "snapshots", - "description": "List Wayback Machine snapshots over time for a URL via the CDX API.", - "access": "read", - "domain": "archive.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "url", - "type": "str", - "required": true, - "positional": true, - "help": "URL to look up (with or without scheme)." - }, - { - "name": "from", - "type": "string", - "required": false, - "help": "Earliest year/timestamp (YYYY[MM[DD[hh[mm[ss]]]]])" - }, - { - "name": "to", - "type": "string", - "required": false, - "help": "Latest year/timestamp (YYYY[MM[DD[hh[mm[ss]]]]])" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max snapshots to return (max 1000)." - } - ], - "columns": [ - "timestamp", - "snapshot_url", - "status", - "mimetype", - "original_url" - ], - "type": "js", - "modulePath": "plugins/archive/snapshots.js", - "sourceFile": "plugins/archive/snapshots.js" - }, - { - "site": "archive", - "name": "wayback", - "description": "Look up the closest Wayback Machine snapshot for a URL.", - "access": "read", - "domain": "archive.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "url", - "type": "str", - "required": true, - "positional": true, - "help": "URL to look up (with or without scheme)." - }, - { - "name": "timestamp", - "type": "string", - "required": false, - "help": "Target timestamp (YYYY[MM[DD[hh[mm[ss]]]]] or ISO date). Defaults to most recent snapshot." - } - ], - "columns": [ - "original_url", - "requested_timestamp", - "snapshot_timestamp", - "snapshot_url", - "status" - ], - "type": "js", - "modulePath": "plugins/archive/wayback.js", - "sourceFile": "plugins/archive/wayback.js" - }, - { - "site": "arxiv", - "name": "author", - "description": "List arXiv papers by a given author (newest first)", - "access": "read", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "author", - "type": "str", - "required": true, - "positional": true, - "help": "Author name (e.g. \"Yoshua Bengio\" or \"Y Bengio\")" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max papers to return (max 50)" - } - ], - "columns": [ - "id", - "title", - "authors", - "published", - "primary_category", - "url" - ], - "type": "js", - "modulePath": "plugins/arxiv/author.js", - "sourceFile": "plugins/arxiv/author.js" - }, - { - "site": "arxiv", - "name": "paper", - "description": "Get arXiv paper details by ID", - "access": "read", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "id", - "type": "str", - "required": true, - "positional": true, - "help": "arXiv paper ID (e.g. 1706.03762)" - } - ], - "columns": [ - "id", - "title", - "authors", - "published", - "updated", - "primary_category", - "categories", - "abstract", - "comment", - "pdf", - "url" - ], - "type": "js", - "modulePath": "plugins/arxiv/paper.js", - "sourceFile": "plugins/arxiv/paper.js" - }, - { - "site": "arxiv", - "name": "recent", - "description": "List recent arXiv submissions in a category", - "access": "read", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "category", - "type": "str", - "required": true, - "positional": true, - "help": "arXiv category (e.g. cs.CL, cs.LG, math.PR, q-bio.NC)" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Max results (max 50)" - } - ], - "columns": [ - "id", - "title", - "authors", - "published", - "primary_category", - "url" - ], - "type": "js", - "modulePath": "plugins/arxiv/recent.js", - "sourceFile": "plugins/arxiv/recent.js" - }, - { - "site": "arxiv", - "name": "search", - "description": "Search arXiv papers", - "access": "read", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search keyword (e.g. \"attention is all you need\")" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Max results (max 25)" - } - ], - "columns": [ - "id", - "title", - "authors", - "published", - "primary_category", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/arxiv/search.js", - "sourceFile": "plugins/arxiv/search.js" - }, - { - "site": "band", - "name": "bands", - "description": "List all Bands you belong to", - "access": "read", - "domain": "www.band.us", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "band_no", - "name", - "members" - ], - "type": "js", - "modulePath": "plugins/band/bands.js", - "sourceFile": "plugins/band/bands.js", - "navigateBefore": "https://www.band.us" - }, - { - "site": "band", - "name": "login", - "description": "Open band login", - "access": "write", - "domain": "band.us", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "status", - "logged_in", - "site", - "user_id", - "action", - "verify_command" - ], - "type": "js", - "modulePath": "plugins/band/auth.js", - "sourceFile": "plugins/band/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "band", - "name": "mentions", - "description": "Show Band notifications where you are @mentioned", - "access": "read", - "domain": "www.band.us", - "strategy": "intercept", - "browser": true, - "args": [ - { - "name": "filter", - "type": "str", - "default": "mentioned", - "required": false, - "help": "Filter: mentioned (default) | all | post | comment", - "choices": [ - "mentioned", - "all", - "post", - "comment" - ] - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max results" - }, - { - "name": "unread", - "type": "bool", - "default": false, - "required": false, - "help": "Show only unread notifications" - } - ], - "columns": [ - "time", - "band", - "type", - "from", - "text", - "url" - ], - "type": "js", - "modulePath": "plugins/band/mentions.js", - "sourceFile": "plugins/band/mentions.js", - "navigateBefore": true - }, - { - "site": "band", - "name": "post", - "description": "Export full content of a post including comments", - "access": "read", - "domain": "www.band.us", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "band_no", - "type": "int", - "required": true, - "positional": true, - "help": "Band number" - }, - { - "name": "post_no", - "type": "int", - "required": true, - "positional": true, - "help": "Post number" - }, - { - "name": "output", - "type": "str", - "default": "", - "required": false, - "help": "Directory to save attached photos" - }, - { - "name": "comments", - "type": "bool", - "default": true, - "required": false, - "help": "Include comments (default: true)" - } - ], - "columns": [ - "type", - "author", - "date", - "text" - ], - "type": "js", - "modulePath": "plugins/band/post.js", - "sourceFile": "plugins/band/post.js", - "navigateBefore": false - }, - { - "site": "band", - "name": "posts", - "description": "List posts from a Band", - "access": "read", - "domain": "www.band.us", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "band_no", - "type": "int", - "required": true, - "positional": true, - "help": "Band number (get it from: band bands)" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max results" - } - ], - "columns": [ - "date", - "author", - "content", - "comments", - "url" - ], - "type": "js", - "modulePath": "plugins/band/posts.js", - "sourceFile": "plugins/band/posts.js", - "navigateBefore": false - }, - { - "site": "band", - "name": "whoami", - "description": "Show the current logged-in band account", - "access": "read", - "domain": "band.us", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "logged_in", - "site", - "user_id" - ], - "type": "js", - "modulePath": "plugins/band/auth.js", - "sourceFile": "plugins/band/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "barchart", - "name": "flow", - "description": "Barchart unusual options activity / options flow", - "access": "read", - "domain": "www.barchart.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "type", - "type": "str", - "default": "all", - "required": false, - "help": "Filter: all, call, or put", - "choices": [ - "all", - "call", - "put" - ] - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of results" - } - ], - "columns": [ - "symbol", - "type", - "strike", - "expiration", - "last", - "volume", - "openInterest", - "volOiRatio", - "iv" - ], - "type": "js", - "modulePath": "plugins/barchart/flow.js", - "sourceFile": "plugins/barchart/flow.js", - "navigateBefore": "https://www.barchart.com" - }, - { - "site": "barchart", - "name": "greeks", - "description": "Barchart options greeks overview (IV, delta, gamma, theta, vega)", - "access": "read", - "domain": "www.barchart.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "symbol", - "type": "str", - "required": true, - "positional": true, - "help": "Stock ticker (e.g. AAPL)" - }, - { - "name": "expiration", - "type": "str", - "required": false, - "help": "Expiration date (YYYY-MM-DD). Defaults to the nearest available expiration." - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of near-the-money strikes per type (1-100)" - } - ], - "columns": [ - "type", - "strike", - "last", - "iv", - "delta", - "gamma", - "theta", - "vega", - "rho", - "volume", - "openInterest", - "expiration" - ], - "type": "js", - "modulePath": "plugins/barchart/greeks.js", - "sourceFile": "plugins/barchart/greeks.js", - "navigateBefore": "https://www.barchart.com" - }, - { - "site": "barchart", - "name": "options", - "description": "Barchart options chain with greeks, IV, volume, and open interest", - "access": "read", - "domain": "www.barchart.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "symbol", - "type": "str", - "required": true, - "positional": true, - "help": "Stock ticker (e.g. AAPL)" - }, - { - "name": "type", - "type": "str", - "default": "Call", - "required": false, - "help": "Option type: Call or Put", - "choices": [ - "Call", - "Put" - ] - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max number of strikes to return" - } - ], - "columns": [ - "strike", - "bid", - "ask", - "last", - "change", - "volume", - "openInterest", - "iv", - "delta", - "gamma", - "theta", - "vega", - "expiration" - ], - "type": "js", - "modulePath": "plugins/barchart/options.js", - "sourceFile": "plugins/barchart/options.js", - "navigateBefore": "https://www.barchart.com" - }, - { - "site": "barchart", - "name": "quote", - "description": "Barchart stock quote with price, volume, and key metrics", - "access": "read", - "domain": "www.barchart.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "symbol", - "type": "str", - "required": true, - "positional": true, - "help": "Stock ticker (e.g. AAPL, MSFT, TSLA)" - } - ], - "columns": [ - "symbol", - "name", - "price", - "change", - "changePct", - "open", - "high", - "low", - "prevClose", - "volume", - "avgVolume", - "marketCap", - "peRatio", - "eps" - ], - "type": "js", - "modulePath": "plugins/barchart/quote.js", - "sourceFile": "plugins/barchart/quote.js", - "navigateBefore": "https://www.barchart.com" - }, - { - "site": "bbc", - "name": "news", - "description": "BBC News headlines (RSS)", - "access": "read", - "domain": "www.bbc.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of headlines (max 50)" - } - ], - "columns": [ - "rank", - "title", - "description", - "url" - ], - "type": "js", - "modulePath": "plugins/bbc/news.js", - "sourceFile": "plugins/bbc/news.js" - }, - { - "site": "bbc", - "name": "topic", - "description": "BBC News headlines for a specific section (RSS feed)", - "access": "read", - "domain": "www.bbc.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "topic", - "type": "str", - "required": true, - "positional": true, - "help": "Section name (world / business / politics / health / education / science_and_environment / technology / entertainment_and_arts)" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max headlines (1-50)" - } - ], - "columns": [ - "rank", - "title", - "description", - "pubDate", - "url" - ], - "type": "js", - "modulePath": "plugins/bbc/topic.js", - "sourceFile": "plugins/bbc/topic.js" - }, - { - "site": "bigbasket", - "name": "add-to-cart", - "description": "Add a BigBasket product to cart", - "access": "write", - "domain": "www.bigbasket.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "product", - "type": "str", - "required": true, - "positional": true, - "help": "Product ID or URL" - }, - { - "name": "quantity", - "type": "int", - "default": 1, - "required": false, - "help": "Quantity to add (max 20)" - } - ], - "columns": [ - "ok", - "product_id", - "quantity", - "url", - "message" - ], - "type": "js", - "modulePath": "plugins/bigbasket/add-to-cart.js", - "sourceFile": "plugins/bigbasket/add-to-cart.js", - "navigateBefore": "https://www.bigbasket.com" - }, - { - "site": "bigbasket", - "name": "cart", - "description": "Read BigBasket cart line items", - "access": "read", - "domain": "www.bigbasket.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "product_id", - "title", - "quantity", - "price", - "line_total", - "availability", - "url" - ], - "type": "js", - "modulePath": "plugins/bigbasket/cart.js", - "sourceFile": "plugins/bigbasket/cart.js", - "navigateBefore": "https://www.bigbasket.com" - }, - { - "site": "bigbasket", - "name": "category", - "description": "Read BigBasket category product cards", - "access": "read", - "domain": "www.bigbasket.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "category", - "type": "str", - "required": true, - "positional": true, - "help": "Category URL or slug" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Maximum products to return (max 50)" - } - ], - "columns": [ - "rank", - "product_id", - "title", - "brand", - "pack_size", - "price", - "mrp", - "discount", - "availability", - "url" - ], - "type": "js", - "modulePath": "plugins/bigbasket/category.js", - "sourceFile": "plugins/bigbasket/category.js", - "navigateBefore": "https://www.bigbasket.com" - }, - { - "site": "bigbasket", - "name": "checkout", - "description": "Open BigBasket checkout review without placing an order", - "access": "write", - "domain": "www.bigbasket.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "ok", - "stage", - "cart_total", - "address_ready", - "delivery_ready", - "payment_ready", - "next_action", - "url" - ], - "type": "js", - "modulePath": "plugins/bigbasket/checkout.js", - "sourceFile": "plugins/bigbasket/checkout.js", - "navigateBefore": "https://www.bigbasket.com" - }, - { - "site": "bigbasket", - "name": "location", - "description": "Show the selected BigBasket delivery location", - "access": "read", - "domain": "www.bigbasket.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "selected", - "label", - "area", - "city", - "pincode", - "source" - ], - "type": "js", - "modulePath": "plugins/bigbasket/location.js", - "sourceFile": "plugins/bigbasket/location.js", - "navigateBefore": "https://www.bigbasket.com" - }, - { - "site": "bigbasket", - "name": "product", - "description": "Read BigBasket product details", - "access": "read", - "domain": "www.bigbasket.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "product", - "type": "str", - "required": true, - "positional": true, - "help": "Product ID or URL" - } - ], - "columns": [ - "product_id", - "title", - "brand", - "pack_size", - "price", - "mrp", - "discount", - "availability", - "delivery", - "image_url", - "url" - ], - "type": "js", - "modulePath": "plugins/bigbasket/product.js", - "sourceFile": "plugins/bigbasket/product.js", - "navigateBefore": "https://www.bigbasket.com" - }, - { - "site": "bigbasket", - "name": "search", - "description": "Search BigBasket products", - "access": "read", - "domain": "www.bigbasket.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search query" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Maximum products to return (max 50)" - } - ], - "columns": [ - "rank", - "product_id", - "title", - "brand", - "pack_size", - "price", - "mrp", - "discount", - "availability", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/bigbasket/search.js", - "sourceFile": "plugins/bigbasket/search.js", - "navigateBefore": "https://www.bigbasket.com" - }, - { - "site": "binance", - "name": "asks", - "description": "Order book ask prices for a trading pair", - "access": "read", - "domain": "data-api.binance.vision", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "symbol", - "type": "str", - "required": true, - "positional": true, - "help": "Trading pair symbol (e.g. BTCUSDT, ETHUSDT)" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of price levels (5, 10, 20, 50, 100)" - } - ], - "columns": [ - "rank", - "ask_price", - "ask_qty" - ], - "type": "js", - "modulePath": "plugins/binance/asks.js", - "sourceFile": "plugins/binance/asks.js" - }, - { - "site": "binance", - "name": "depth", - "description": "Order book bid and ask prices for a trading pair", - "access": "read", - "domain": "data-api.binance.vision", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "symbol", - "type": "str", - "required": true, - "positional": true, - "help": "Trading pair symbol (e.g. BTCUSDT, ETHUSDT)" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of price levels (5, 10, 20, 50, 100)" - } - ], - "columns": [ - "rank", - "bid_price", - "bid_qty", - "ask_price", - "ask_qty" - ], - "type": "js", - "modulePath": "plugins/binance/depth.js", - "sourceFile": "plugins/binance/depth.js" - }, - { - "site": "binance", - "name": "gainers", - "description": "Top gaining trading pairs by 24h price change", - "access": "read", - "domain": "data-api.binance.vision", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of trading pairs" - } - ], - "columns": [ - "rank", - "symbol", - "price", - "change_24h", - "volume" - ], - "type": "js", - "modulePath": "plugins/binance/gainers.js", - "sourceFile": "plugins/binance/gainers.js" - }, - { - "site": "binance", - "name": "klines", - "description": "Candlestick/kline data for a trading pair", - "access": "read", - "domain": "data-api.binance.vision", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "symbol", - "type": "str", - "required": true, - "positional": true, - "help": "Trading pair symbol (e.g. BTCUSDT, ETHUSDT)" - }, - { - "name": "interval", - "type": "str", - "default": "1d", - "required": false, - "help": "Kline interval (1m, 5m, 15m, 1h, 4h, 1d, 1w, 1M)" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of klines (max 1000)" - } - ], - "columns": [ - "open", - "high", - "low", - "close", - "volume" - ], - "type": "js", - "modulePath": "plugins/binance/klines.js", - "sourceFile": "plugins/binance/klines.js" - }, - { - "site": "binance", - "name": "losers", - "description": "Top losing trading pairs by 24h price change", - "access": "read", - "domain": "data-api.binance.vision", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of trading pairs" - } - ], - "columns": [ - "rank", - "symbol", - "price", - "change_24h", - "volume" - ], - "type": "js", - "modulePath": "plugins/binance/losers.js", - "sourceFile": "plugins/binance/losers.js" - }, - { - "site": "binance", - "name": "pairs", - "description": "List active trading pairs on Binance", - "access": "read", - "domain": "data-api.binance.vision", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of trading pairs" - } - ], - "columns": [ - "symbol", - "base", - "quote", - "status" - ], - "type": "js", - "modulePath": "plugins/binance/pairs.js", - "sourceFile": "plugins/binance/pairs.js" - }, - { - "site": "binance", - "name": "price", - "description": "Quick price check for a trading pair", - "access": "read", - "domain": "data-api.binance.vision", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "symbol", - "type": "str", - "required": true, - "positional": true, - "help": "Trading pair symbol (e.g. BTCUSDT, ETHUSDT)" - } - ], - "columns": [ - "symbol", - "price", - "change", - "change_pct", - "high", - "low", - "volume", - "quote_volume", - "trades" - ], - "type": "js", - "modulePath": "plugins/binance/price.js", - "sourceFile": "plugins/binance/price.js" - }, - { - "site": "binance", - "name": "prices", - "description": "Latest prices for all trading pairs", - "access": "read", - "domain": "data-api.binance.vision", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of prices" - } - ], - "columns": [ - "rank", - "symbol", - "price" - ], - "type": "js", - "modulePath": "plugins/binance/prices.js", - "sourceFile": "plugins/binance/prices.js" - }, - { - "site": "binance", - "name": "ticker", - "description": "24h ticker statistics for top trading pairs by volume", - "access": "read", - "domain": "data-api.binance.vision", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of tickers" - } - ], - "columns": [ - "symbol", - "price", - "change_pct", - "high", - "low", - "volume", - "quote_vol", - "trades" - ], - "type": "js", - "modulePath": "plugins/binance/ticker.js", - "sourceFile": "plugins/binance/ticker.js" - }, - { - "site": "binance", - "name": "top", - "description": "Top trading pairs by 24h volume on Binance", - "access": "read", - "domain": "data-api.binance.vision", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of trading pairs" - } - ], - "columns": [ - "rank", - "symbol", - "price", - "change_24h", - "high", - "low", - "volume" - ], - "type": "js", - "modulePath": "plugins/binance/top.js", - "sourceFile": "plugins/binance/top.js" - }, - { - "site": "binance", - "name": "trades", - "description": "Recent trades for a trading pair", - "access": "read", - "domain": "data-api.binance.vision", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "symbol", - "type": "str", - "required": true, - "positional": true, - "help": "Trading pair symbol (e.g. BTCUSDT, ETHUSDT)" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of trades (max 1000)" - } - ], - "columns": [ - "id", - "price", - "qty", - "quote_qty", - "buyer_maker" - ], - "type": "js", - "modulePath": "plugins/binance/trades.js", - "sourceFile": "plugins/binance/trades.js" - }, - { - "site": "blinkit", - "name": "add-to-cart", - "description": "Add a Blinkit product to cart", - "access": "write", - "domain": "blinkit.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "productId", - "type": "str", - "required": true, - "positional": true, - "help": "Blinkit product id" - }, - { - "name": "quantity", - "type": "int", - "default": 1, - "required": false, - "help": "Quantity to add (default 1, max 12)" - }, - { - "name": "lat", - "type": "str", - "required": false, - "help": "Delivery latitude (defaults to current Blinkit browser location)" - }, - { - "name": "lon", - "type": "str", - "required": false, - "help": "Delivery longitude (defaults to current Blinkit browser location)" - } - ], - "columns": [ - "status", - "productId", - "quantity", - "itemCount", - "itemsTotal", - "payable", - "message" - ], - "type": "js", - "modulePath": "plugins/blinkit/add-to-cart.js", - "sourceFile": "plugins/blinkit/add-to-cart.js", - "navigateBefore": false - }, - { - "site": "blinkit", - "name": "cart", - "description": "Show the current Blinkit cart", - "access": "read", - "domain": "blinkit.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "status", - "productId", - "name", - "variant", - "price", - "quantity", - "total", - "itemCount", - "payable", - "cartState" - ], - "type": "js", - "modulePath": "plugins/blinkit/cart.js", - "sourceFile": "plugins/blinkit/cart.js", - "navigateBefore": false - }, - { - "site": "blinkit", - "name": "checkout", - "description": "Review Blinkit checkout totals and blockers without placing an order", - "access": "read", - "domain": "blinkit.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "status", - "itemCount", - "itemsTotal", - "deliveryCharge", - "handlingCharge", - "payable", - "cartState", - "checkoutBlocked", - "validations" - ], - "type": "js", - "modulePath": "plugins/blinkit/checkout.js", - "sourceFile": "plugins/blinkit/checkout.js", - "navigateBefore": false - }, - { - "site": "blinkit", - "name": "location", - "description": "Show the selected Blinkit delivery location", - "access": "read", - "domain": "blinkit.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "selected", - "label", - "area", - "city", - "pincode", - "hasCoordinates", - "source" - ], - "type": "js", - "modulePath": "plugins/blinkit/location.js", - "sourceFile": "plugins/blinkit/location.js", - "navigateBefore": "https://blinkit.com" - }, - { - "site": "blinkit", - "name": "login", - "description": "Open blinkit login", - "access": "write", - "domain": "blinkit.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "status", - "logged_in", - "site", - "phone", - "user_id", - "action", - "verify_command" - ], - "type": "js", - "modulePath": "plugins/blinkit/auth.js", - "sourceFile": "plugins/blinkit/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "blinkit", - "name": "place-order", - "description": "Submit the visible Blinkit final order/payment action. Requires --confirm.", - "access": "write", - "domain": "blinkit.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "confirm", - "type": "bool", - "default": false, - "required": false, - "help": "Required acknowledgement that this may place/pay for a real order" - } - ], - "columns": [ - "status", - "confirmed", - "itemCount", - "payable", - "orderId", - "url", - "message" - ], - "type": "js", - "modulePath": "plugins/blinkit/place-order.js", - "sourceFile": "plugins/blinkit/place-order.js", - "navigateBefore": false - }, - { - "site": "blinkit", - "name": "product", - "description": "Read Blinkit product details for a delivery location", - "access": "read", - "domain": "blinkit.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "productId", - "type": "str", - "required": true, - "positional": true, - "help": "Blinkit product id" - }, - { - "name": "lat", - "type": "str", - "required": false, - "help": "Delivery latitude (defaults to current Blinkit browser location)" - }, - { - "name": "lon", - "type": "str", - "required": false, - "help": "Delivery longitude (defaults to current Blinkit browser location)" - } - ], - "columns": [ - "productId", - "name", - "brand", - "variant", - "price", - "mrp", - "currency", - "inventory", - "available", - "imageUrl", - "url" - ], - "type": "js", - "modulePath": "plugins/blinkit/product.js", - "sourceFile": "plugins/blinkit/product.js", - "navigateBefore": false - }, - { - "site": "blinkit", - "name": "search", - "description": "Search Blinkit products for a delivery location", - "access": "read", - "domain": "blinkit.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search keyword" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max results (max 48)" - }, - { - "name": "lat", - "type": "str", - "required": false, - "help": "Delivery latitude (defaults to current Blinkit browser location)" - }, - { - "name": "lon", - "type": "str", - "required": false, - "help": "Delivery longitude (defaults to current Blinkit browser location)" - } - ], - "columns": [ - "rank", - "productId", - "name", - "brand", - "variant", - "price", - "mrp", - "currency", - "inventory", - "available", - "imageUrl", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/blinkit/search.js", - "sourceFile": "plugins/blinkit/search.js", - "navigateBefore": false - }, - { - "site": "blinkit", - "name": "whoami", - "description": "Show the current logged-in blinkit account", - "access": "read", - "domain": "blinkit.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "logged_in", - "site", - "phone", - "user_id" - ], - "type": "js", - "modulePath": "plugins/blinkit/auth.js", - "sourceFile": "plugins/blinkit/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "bloomberg", - "name": "businessweek", - "description": "Bloomberg Businessweek top stories", - "access": "read", - "domain": "www.bloomberg.com", - "strategy": "public", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 1, - "required": false, - "help": "Number of stories to return (max 20)" - } - ], - "columns": [ - "title", - "summary", - "link", - "mediaLinks" - ], - "type": "js", - "modulePath": "plugins/bloomberg/businessweek.js", - "sourceFile": "plugins/bloomberg/businessweek.js" - }, - { - "site": "bloomberg", - "name": "crypto", - "description": "Bloomberg Crypto top stories (RSS)", - "access": "read", - "domain": "feeds.bloomberg.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 1, - "required": false, - "help": "Number of feed items to return (max 20)" - } - ], - "columns": [ - "title", - "summary", - "link", - "mediaLinks" - ], - "type": "js", - "modulePath": "plugins/bloomberg/crypto.js", - "sourceFile": "plugins/bloomberg/crypto.js" - }, - { - "site": "bloomberg", - "name": "economics", - "description": "Bloomberg Economics top stories (RSS)", - "access": "read", - "domain": "feeds.bloomberg.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 1, - "required": false, - "help": "Number of feed items to return (max 20)" - } - ], - "columns": [ - "title", - "summary", - "link", - "mediaLinks" - ], - "type": "js", - "modulePath": "plugins/bloomberg/economics.js", - "sourceFile": "plugins/bloomberg/economics.js" - }, - { - "site": "bloomberg", - "name": "feeds", - "description": "List the Bloomberg RSS feed aliases used by the adapter", - "access": "read", - "domain": "feeds.bloomberg.com", - "strategy": "public", - "browser": false, - "args": [], - "columns": [ - "name", - "url" - ], - "type": "js", - "modulePath": "plugins/bloomberg/feeds.js", - "sourceFile": "plugins/bloomberg/feeds.js" - }, - { - "site": "bloomberg", - "name": "green", - "description": "Bloomberg Green (climate & energy) top stories (RSS)", - "access": "read", - "domain": "feeds.bloomberg.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 1, - "required": false, - "help": "Number of feed items to return (max 20)" - } - ], - "columns": [ - "title", - "summary", - "link", - "mediaLinks" - ], - "type": "js", - "modulePath": "plugins/bloomberg/green.js", - "sourceFile": "plugins/bloomberg/green.js" - }, - { - "site": "bloomberg", - "name": "industries", - "description": "Bloomberg Industries top stories (RSS)", - "access": "read", - "domain": "feeds.bloomberg.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 1, - "required": false, - "help": "Number of feed items to return (max 20)" - } - ], - "columns": [ - "title", - "summary", - "link", - "mediaLinks" - ], - "type": "js", - "modulePath": "plugins/bloomberg/industries.js", - "sourceFile": "plugins/bloomberg/industries.js" - }, - { - "site": "bloomberg", - "name": "main", - "description": "Bloomberg homepage top stories (RSS)", - "access": "read", - "domain": "feeds.bloomberg.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 1, - "required": false, - "help": "Number of feed items to return (max 20)" - } - ], - "columns": [ - "title", - "summary", - "link", - "mediaLinks" - ], - "type": "js", - "modulePath": "plugins/bloomberg/main.js", - "sourceFile": "plugins/bloomberg/main.js" - }, - { - "site": "bloomberg", - "name": "markets", - "description": "Bloomberg Markets top stories (RSS)", - "access": "read", - "domain": "feeds.bloomberg.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 1, - "required": false, - "help": "Number of feed items to return (max 20)" - } - ], - "columns": [ - "title", - "summary", - "link", - "mediaLinks" - ], - "type": "js", - "modulePath": "plugins/bloomberg/markets.js", - "sourceFile": "plugins/bloomberg/markets.js" - }, - { - "site": "bloomberg", - "name": "news", - "description": "Read a Bloomberg story/article page and return title, full content, and media links", - "access": "read", - "domain": "www.bloomberg.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "link", - "type": "str", - "required": true, - "positional": true, - "help": "Bloomberg story/article URL or relative Bloomberg path" - } - ], - "columns": [ - "title", - "summary", - "link", - "mediaLinks", - "content" - ], - "type": "js", - "modulePath": "plugins/bloomberg/news.js", - "sourceFile": "plugins/bloomberg/news.js", - "navigateBefore": "https://www.bloomberg.com" - }, - { - "site": "bloomberg", - "name": "opinions", - "description": "Bloomberg Opinion top stories (RSS)", - "access": "read", - "domain": "feeds.bloomberg.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 1, - "required": false, - "help": "Number of feed items to return (max 20)" - } - ], - "columns": [ - "title", - "summary", - "link", - "mediaLinks" - ], - "type": "js", - "modulePath": "plugins/bloomberg/opinions.js", - "sourceFile": "plugins/bloomberg/opinions.js" - }, - { - "site": "bloomberg", - "name": "politics", - "description": "Bloomberg Politics top stories (RSS)", - "access": "read", - "domain": "feeds.bloomberg.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 1, - "required": false, - "help": "Number of feed items to return (max 20)" - } - ], - "columns": [ - "title", - "summary", - "link", - "mediaLinks" - ], - "type": "js", - "modulePath": "plugins/bloomberg/politics.js", - "sourceFile": "plugins/bloomberg/politics.js" - }, - { - "site": "bloomberg", - "name": "pursuits", - "description": "Bloomberg Pursuits (lifestyle) top stories (RSS)", - "access": "read", - "domain": "feeds.bloomberg.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 1, - "required": false, - "help": "Number of feed items to return (max 20)" - } - ], - "columns": [ - "title", - "summary", - "link", - "mediaLinks" - ], - "type": "js", - "modulePath": "plugins/bloomberg/pursuits.js", - "sourceFile": "plugins/bloomberg/pursuits.js" - }, - { - "site": "bloomberg", - "name": "tech", - "description": "Bloomberg Tech top stories (RSS)", - "access": "read", - "domain": "feeds.bloomberg.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 1, - "required": false, - "help": "Number of feed items to return (max 20)" - } - ], - "columns": [ - "title", - "summary", - "link", - "mediaLinks" - ], - "type": "js", - "modulePath": "plugins/bloomberg/tech.js", - "sourceFile": "plugins/bloomberg/tech.js" - }, - { - "site": "bluesky", - "name": "feeds", - "description": "Popular Bluesky feed generators", - "access": "read", - "domain": "public.api.bsky.app", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of feeds" - } - ], - "columns": [ - "rank", - "name", - "likes", - "creator", - "description" - ], - "type": "js", - "modulePath": "plugins/bluesky/feeds.js", - "sourceFile": "plugins/bluesky/feeds.js" - }, - { - "site": "bluesky", - "name": "followers", - "description": "List followers of a Bluesky user", - "access": "read", - "domain": "public.api.bsky.app", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "handle", - "type": "str", - "required": true, - "positional": true, - "help": "Bluesky handle" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of followers" - } - ], - "columns": [ - "rank", - "handle", - "name", - "description" - ], - "type": "js", - "modulePath": "plugins/bluesky/followers.js", - "sourceFile": "plugins/bluesky/followers.js" - }, - { - "site": "bluesky", - "name": "following", - "description": "List accounts a Bluesky user is following", - "access": "read", - "domain": "public.api.bsky.app", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "handle", - "type": "str", - "required": true, - "positional": true, - "help": "Bluesky handle" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of accounts" - } - ], - "columns": [ - "rank", - "handle", - "name", - "description" - ], - "type": "js", - "modulePath": "plugins/bluesky/following.js", - "sourceFile": "plugins/bluesky/following.js" - }, - { - "site": "bluesky", - "name": "profile", - "description": "Get Bluesky user profile info", - "access": "read", - "domain": "public.api.bsky.app", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "handle", - "type": "str", - "required": true, - "positional": true, - "help": "Bluesky handle (e.g. bsky.app, jay.bsky.team)" - } - ], - "columns": [ - "handle", - "name", - "followers", - "following", - "posts", - "description" - ], - "type": "js", - "modulePath": "plugins/bluesky/profile.js", - "sourceFile": "plugins/bluesky/profile.js" - }, - { - "site": "bluesky", - "name": "search", - "description": "Search Bluesky users", - "access": "read", - "domain": "public.api.bsky.app", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search query" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of results" - } - ], - "columns": [ - "rank", - "handle", - "name", - "followers", - "description" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/bluesky/search.js", - "sourceFile": "plugins/bluesky/search.js" - }, - { - "site": "bluesky", - "name": "starter-packs", - "description": "Get starter packs created by a Bluesky user", - "access": "read", - "domain": "public.api.bsky.app", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "handle", - "type": "str", - "required": true, - "positional": true, - "help": "Bluesky handle" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of starter packs" - } - ], - "columns": [ - "rank", - "name", - "description", - "members", - "joins" - ], - "type": "js", - "modulePath": "plugins/bluesky/starter-packs.js", - "sourceFile": "plugins/bluesky/starter-packs.js" - }, - { - "site": "bluesky", - "name": "thread", - "description": "Get a Bluesky post thread with replies", - "access": "read", - "domain": "public.api.bsky.app", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "uri", - "type": "str", - "required": true, - "positional": true, - "help": "Post AT URI (at://did:.../app.bsky.feed.post/...) or bsky.app URL" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of replies" - } - ], - "columns": [ - "author", - "text", - "likes", - "reposts", - "replies_count" - ], - "type": "js", - "modulePath": "plugins/bluesky/thread.js", - "sourceFile": "plugins/bluesky/thread.js" - }, - { - "site": "bluesky", - "name": "trending", - "description": "Trending topics on Bluesky", - "access": "read", - "domain": "public.api.bsky.app", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of topics" - } - ], - "columns": [ - "rank", - "topic", - "link" - ], - "type": "js", - "modulePath": "plugins/bluesky/trending.js", - "sourceFile": "plugins/bluesky/trending.js" - }, - { - "site": "bluesky", - "name": "user", - "description": "Get recent posts from a Bluesky user", - "access": "read", - "domain": "public.api.bsky.app", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "handle", - "type": "str", - "required": true, - "positional": true, - "help": "Bluesky handle (e.g. bsky.app)" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of posts" - } - ], - "columns": [ - "rank", - "uri", - "text", - "likes", - "reposts", - "replies" - ], - "type": "js", - "modulePath": "plugins/bluesky/user.js", - "sourceFile": "plugins/bluesky/user.js" - }, - { - "site": "bmwblog", - "name": "article", - "description": "Read a BMWBLOG article by URL or slug", - "access": "read", - "domain": "www.bmwblog.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "url-or-slug", - "type": "str", - "required": true, - "positional": true, - "help": "BMWBLOG article URL or slug" - } - ], - "columns": [ - "title", - "date", - "author", - "sections", - "excerpt", - "url", - "content" - ], - "type": "js", - "modulePath": "plugins/bmwblog/article.js", - "sourceFile": "plugins/bmwblog/article.js" - }, - { - "site": "bmwblog", - "name": "latest", - "description": "List the latest BMWBLOG articles", - "access": "read", - "domain": "www.bmwblog.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of articles (1-50)" - } - ], - "columns": [ - "rank", - "title", - "date", - "author", - "section", - "excerpt", - "url" - ], - "type": "js", - "modulePath": "plugins/bmwblog/latest.js", - "sourceFile": "plugins/bmwblog/latest.js" - }, - { - "site": "bmwblog", - "name": "search", - "description": "Search BMWBLOG articles", - "access": "read", - "domain": "www.bmwblog.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search query" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of results (1-50)" - } - ], - "columns": [ - "rank", - "title", - "date", - "author", - "section", - "excerpt", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/bmwblog/search.js", - "sourceFile": "plugins/bmwblog/search.js" - }, - { - "site": "booking", - "name": "search", - "description": "Search Booking.com hotels by destination and dates (server-rendered card scrape).", - "access": "read", - "example": "webcmd booking search Tokyo --checkin 2026-06-15 --checkout 2026-06-17 -f yaml", - "domain": "www.booking.com", - "strategy": "public", - "browser": true, - "args": [ - { - "name": "destination", - "type": "str", - "required": true, - "positional": true, - "help": "Destination keyword (city, district, or hotel name)" - }, - { - "name": "checkin", - "type": "str", - "required": true, - "help": "Check-in date YYYY-MM-DD" - }, - { - "name": "checkout", - "type": "str", - "required": true, - "help": "Check-out date YYYY-MM-DD" - }, - { - "name": "adults", - "type": "int", - "default": 2, - "required": false, - "help": "Number of adults (1-30)" - }, - { - "name": "rooms", - "type": "int", - "default": 1, - "required": false, - "help": "Number of rooms (1-30)" - }, - { - "name": "children", - "type": "int", - "default": 0, - "required": false, - "help": "Number of children (0-10)" - }, - { - "name": "currency", - "type": "str", - "required": false, - "help": "Force result currency (e.g. USD, JPY, CNY)" - }, - { - "name": "lang", - "type": "str", - "required": false, - "help": "Force result language (e.g. en-us, zh-cn, ja)" - }, - { - "name": "limit", - "type": "int", - "default": 25, - "required": false, - "help": "Max rows to return (1-100; Booking pages 25 per request)" - }, - { - "name": "offset", - "type": "int", - "default": 0, - "required": false, - "help": "Result offset for pagination (multiple of 25)" - } - ], - "columns": [ - "rank", - "name", - "country", - "slug", - "star_rating", - "review_score", - "review_count", - "price_amount", - "price_currency", - "distance", - "recommended_room", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/booking/search.js", - "sourceFile": "plugins/booking/search.js" - }, - { - "site": "brave", - "name": "search", - "description": "Search Brave Search", - "access": "read", - "domain": "search.brave.com", - "strategy": "public", - "browser": true, - "args": [ - { - "name": "keyword", - "type": "str", - "required": true, - "positional": true, - "help": "Search query" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of results per page (max 18)" - }, - { - "name": "offset", - "type": "int", - "default": 0, - "required": false, - "help": "Page offset (0, 1, 2...). Brave returns ~18 results per page" - } - ], - "columns": [ - "rank", - "title", - "url", - "snippet" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/brave/search.js", - "sourceFile": "plugins/brave/search.js" - }, - { - "site": "chatgpt", - "name": "ask", - "description": "Send a prompt to ChatGPT web and wait for the response", - "access": "write", - "domain": "chatgpt.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "prompt", - "type": "str", - "required": true, - "positional": true, - "help": "Prompt to send" - }, - { - "name": "timeout", - "type": "int", - "default": 120, - "required": false, - "help": "Max seconds to wait for response" - }, - { - "name": "new", - "type": "boolean", - "default": false, - "required": false, - "help": "Start a new chat before sending" - }, - { - "name": "conversation", - "type": "str", - "required": false, - "valueRequired": true, - "help": "Continue an existing ChatGPT conversation ID or /c/ URL" - }, - { - "name": "project", - "type": "str", - "required": false, - "valueRequired": true, - "help": "Start a new chat inside a ChatGPT project ID or /g/g-p- URL" - }, - { - "name": "wait", - "type": "boolean", - "default": true, - "required": false, - "help": "Wait for the assistant response after sending" - }, - { - "name": "deep-research", - "type": "boolean", - "default": false, - "required": false, - "help": "Enable ChatGPT Deep Research (Deep Research)" - }, - { - "name": "web-search", - "type": "boolean", - "default": false, - "required": false, - "help": "Enable ChatGPT Web Search (Web Search)" - } - ], - "columns": [ - "conversationId", - "conversationUrl", - "tool", - "response" - ], - "type": "js", - "modulePath": "plugins/chatgpt/ask.js", - "sourceFile": "plugins/chatgpt/ask.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "chatgpt", - "name": "deep-research-result", - "description": "Read a ChatGPT Deep Research report or progress from the conversation payload", - "access": "read", - "domain": "chatgpt.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "id", - "type": "str", - "required": true, - "positional": true, - "help": "Conversation ID or full /c/ URL" - }, - { - "name": "wait", - "type": "boolean", - "default": false, - "required": false, - "help": "Wait until Deep Research completes or becomes extractable" - }, - { - "name": "timeout", - "type": "int", - "default": 120, - "required": false, - "help": "Max seconds to wait when --wait is true" - }, - { - "name": "stable", - "type": "int", - "default": 6, - "required": false, - "help": "Seconds the report text must remain unchanged when --wait is true" - } - ], - "columns": [ - "conversationId", - "status", - "report", - "sources", - "progress", - "asyncTaskConversationId", - "widgetSessionId", - "asyncStatus", - "venusMessageType", - "venusStatus", - "waitingForUserUntil", - "planTitle", - "planId", - "url", - "method", - "diagnostics" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/chatgpt/deep-research-result.js", - "sourceFile": "plugins/chatgpt/deep-research-result.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "chatgpt", - "name": "detail", - "description": "Open a ChatGPT web conversation by ID and read its messages", - "access": "read", - "domain": "chatgpt.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "id", - "type": "str", - "required": true, - "positional": true, - "help": "Conversation ID or full /c/ URL" - }, - { - "name": "markdown", - "type": "boolean", - "default": false, - "required": false, - "help": "Emit assistant replies as markdown" - }, - { - "name": "wait", - "type": "boolean", - "default": false, - "required": false, - "help": "Wait until the conversation stops generating and stabilizes" - }, - { - "name": "timeout", - "type": "int", - "default": 120, - "required": false, - "help": "Max seconds to wait when --wait is true" - }, - { - "name": "stable", - "type": "int", - "default": 6, - "required": false, - "help": "Seconds the final messages must remain unchanged when --wait is true" - } - ], - "columns": [ - "Index", - "Role", - "Text", - "Generating", - "StableSeconds" - ], - "type": "js", - "modulePath": "plugins/chatgpt/detail.js", - "sourceFile": "plugins/chatgpt/detail.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "chatgpt", - "name": "history", - "description": "List visible ChatGPT web conversation history from the sidebar", - "access": "read", - "domain": "chatgpt.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max conversations to show" - } - ], - "columns": [ - "Index", - "Id", - "Title", - "Url" - ], - "type": "js", - "modulePath": "plugins/chatgpt/history.js", - "sourceFile": "plugins/chatgpt/history.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "chatgpt", - "name": "image", - "description": "Generate images with ChatGPT web and save them locally", - "access": "write", - "domain": "chatgpt.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "prompt", - "type": "str", - "required": true, - "positional": true, - "help": "Image prompt to send to ChatGPT" - }, - { - "name": "image", - "type": "str", - "required": false, - "help": "Local image path to attach before prompting; comma-separated paths are supported", - "file": { - "direction": "input", - "pathKind": "file", - "multiple": true, - "separator": ",", - "contentTypes": [ - "image/jpeg", - "image/png", - "image/gif", - "image/webp" - ], - "maxBytes": 26214400 - } - }, - { - "name": "project", - "type": "str", - "required": false, - "valueRequired": true, - "help": "Start image generation inside a ChatGPT project ID or /g/g-p- URL" - }, - { - "name": "op", - "type": "str", - "required": false, - "help": "Output directory (default: ~/Pictures/chatgpt)", - "file": { - "direction": "output", - "pathKind": "directory", - "multiple": false, - "defaultPath": "~/Pictures/chatgpt" - } - }, - { - "name": "sd", - "type": "boolean", - "default": false, - "required": false, - "help": "Skip download shorthand; only show ChatGPT link" - }, - { - "name": "timeout", - "type": "int", - "default": 240, - "required": false, - "help": "Max seconds for the overall command (default: 240)" - } - ], - "columns": [ - "status", - "file", - "link" - ], - "defaultFormat": "plain", - "type": "js", - "modulePath": "plugins/chatgpt/image.js", - "sourceFile": "plugins/chatgpt/image.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "chatgpt", - "name": "login", - "description": "Open chatgpt login", - "access": "write", - "domain": "chatgpt.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "status", - "logged_in", - "site", - "user_id", - "name", - "action", - "verify_command" - ], - "type": "js", - "modulePath": "plugins/chatgpt/auth.js", - "sourceFile": "plugins/chatgpt/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "chatgpt", - "name": "model", - "description": "Switch ChatGPT web model or intelligence level (GPT-5.6 Pro, fast, balanced, advanced, very-high, pro)", - "access": "write", - "domain": "chatgpt.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "model", - "type": "str", - "required": true, - "positional": true, - "help": "ChatGPT model or intelligence level to switch to", - "choices": [ - "fast", - "speed", - "instant", - "balanced", - "balance", - "medium", - "advanced", - "high", - "thinking", - "very-high", - "ultra", - "xhigh", - "x-high", - "extra-high", - "very high", - "gpt-5.6-pro", - "gpt-5-6-pro", - "gpt-5.6-sol-pro", - "gpt-5-6-sol-pro", - "gpt-5.6", - "gpt-5-6", - "5.6-pro", - "5.6", - "pro", - "professional" - ] - }, - { - "name": "project", - "type": "str", - "required": false, - "valueRequired": true, - "help": "Open a ChatGPT project ID or /g/g-p- URL before switching intelligence level" - } - ], - "columns": [ - "Status", - "Model" - ], - "type": "js", - "modulePath": "plugins/chatgpt/model.js", - "sourceFile": "plugins/chatgpt/model.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "chatgpt", - "name": "new", - "description": "Start a new ChatGPT web conversation", - "access": "read", - "domain": "chatgpt.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "project", - "type": "str", - "required": false, - "valueRequired": true, - "help": "Start a new chat inside a ChatGPT project ID or /g/g-p- URL" - } - ], - "columns": [ - "Status" - ], - "type": "js", - "modulePath": "plugins/chatgpt/new.js", - "sourceFile": "plugins/chatgpt/new.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "chatgpt", - "name": "project-file-add", - "description": "Upload files to a ChatGPT project as project knowledge (not just conversation attachments)", - "access": "write", - "domain": "chatgpt.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "file", - "type": "str", - "required": true, - "positional": true, - "help": "Local file path(s) to upload; comma-separated paths are supported", - "file": { - "direction": "input", - "pathKind": "file", - "multiple": true, - "separator": ",", - "contentTypes": [ - "application/pdf", - "text/plain", - "text/markdown", - "text/csv", - "application/json", - "image/jpeg", - "image/png", - "image/gif", - "image/webp" - ], - "maxBytes": 26214400 - } - }, - { - "name": "id", - "type": "str", - "required": true, - "help": "Project ID or /g/g-p- URL" - } - ], - "columns": [ - "Status", - "File" - ], - "type": "js", - "modulePath": "plugins/chatgpt/project-file-add.js", - "sourceFile": "plugins/chatgpt/project-file-add.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "chatgpt", - "name": "project-list", - "description": "List visible ChatGPT projects from the sidebar", - "access": "read", - "domain": "chatgpt.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max projects to show" - } - ], - "columns": [ - "Index", - "Id", - "Title", - "Url" - ], - "type": "js", - "modulePath": "plugins/chatgpt/project-list.js", - "sourceFile": "plugins/chatgpt/project-list.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "chatgpt", - "name": "read", - "description": "Read messages in the current ChatGPT web conversation", - "access": "read", - "domain": "chatgpt.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "markdown", - "type": "boolean", - "default": false, - "required": false, - "help": "Emit assistant replies as markdown" - } - ], - "columns": [ - "Index", - "Role", - "Text" - ], - "type": "js", - "modulePath": "plugins/chatgpt/read.js", - "sourceFile": "plugins/chatgpt/read.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "chatgpt", - "name": "send", - "description": "Send a prompt to ChatGPT web without waiting for the response", - "access": "write", - "domain": "chatgpt.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "prompt", - "type": "str", - "required": true, - "positional": true, - "help": "Prompt to send" - }, - { - "name": "new", - "type": "boolean", - "default": false, - "required": false, - "help": "Start a new chat before sending" - }, - { - "name": "conversation", - "type": "str", - "required": false, - "valueRequired": true, - "help": "Continue an existing ChatGPT conversation ID or /c/ URL" - }, - { - "name": "project", - "type": "str", - "required": false, - "valueRequired": true, - "help": "Start a new chat inside a ChatGPT project ID or /g/g-p- URL" - } - ], - "columns": [ - "Status", - "InjectedText" - ], - "type": "js", - "modulePath": "plugins/chatgpt/send.js", - "sourceFile": "plugins/chatgpt/send.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "chatgpt", - "name": "status", - "description": "Check ChatGPT web page availability and login state", - "access": "read", - "domain": "chatgpt.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "Status", - "Login", - "Url" - ], - "type": "js", - "modulePath": "plugins/chatgpt/status.js", - "sourceFile": "plugins/chatgpt/status.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "chatgpt", - "name": "whoami", - "description": "Show the current logged-in chatgpt account", - "access": "read", - "domain": "chatgpt.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "logged_in", - "site", - "user_id", - "name" - ], - "type": "js", - "modulePath": "plugins/chatgpt/auth.js", - "sourceFile": "plugins/chatgpt/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "chatgpt-app", - "name": "ask", - "description": "Send a prompt and wait for the AI response (send + wait + read)", - "access": "write", - "domain": "localhost", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "text", - "type": "str", - "required": true, - "positional": true, - "help": "Prompt to send" - }, - { - "name": "model", - "type": "str", - "required": false, - "help": "Model/mode to use: auto, instant, thinking, 5.2-instant, 5.2-thinking", - "choices": [ - "auto", - "instant", - "thinking", - "5.2-instant", - "5.2-thinking" - ] - }, - { - "name": "timeout", - "type": "int", - "default": 30, - "required": false, - "help": "Max seconds to wait for response (default: 30)" - }, - { - "name": "image", - "type": "str", - "required": false, - "help": "Path to local image to attach (optional)" - } - ], - "columns": [ - "Role", - "Text" - ], - "type": "js", - "modulePath": "plugins/chatgpt-app/ask.js", - "sourceFile": "plugins/chatgpt-app/ask.js" - }, - { - "site": "chatgpt-app", - "name": "model", - "description": "Switch ChatGPT Desktop model/mode (auto, instant, thinking, 5.2-instant, 5.2-thinking)", - "access": "read", - "domain": "localhost", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "model", - "type": "str", - "required": true, - "positional": true, - "help": "Model to switch to", - "choices": [ - "auto", - "instant", - "thinking", - "5.2-instant", - "5.2-thinking" - ] - } - ], - "columns": [ - "Status", - "Model" - ], - "type": "js", - "modulePath": "plugins/chatgpt-app/model.js", - "sourceFile": "plugins/chatgpt-app/model.js" - }, - { - "site": "chatgpt-app", - "name": "new", - "description": "Open a new chat in ChatGPT Desktop App", - "access": "write", - "domain": "localhost", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "temp", - "type": "boolean", - "default": false, - "required": false, - "help": "Open a temporary chat with privacy protection" - } - ], - "columns": [ - "Status" - ], - "type": "js", - "modulePath": "plugins/chatgpt-app/new.js", - "sourceFile": "plugins/chatgpt-app/new.js" - }, - { - "site": "chatgpt-app", - "name": "read", - "description": "Read the last visible message from the focused ChatGPT Desktop window", - "access": "read", - "domain": "localhost", - "strategy": "public", - "browser": false, - "args": [], - "columns": [ - "Role", - "Text" - ], - "type": "js", - "modulePath": "plugins/chatgpt-app/read.js", - "sourceFile": "plugins/chatgpt-app/read.js" - }, - { - "site": "chatgpt-app", - "name": "send", - "description": "Send a message to the active ChatGPT Desktop App window", - "access": "write", - "domain": "localhost", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "text", - "type": "str", - "required": true, - "positional": true, - "help": "Message to send" - }, - { - "name": "model", - "type": "str", - "required": false, - "help": "Model/mode to use: auto, instant, thinking, 5.2-instant, 5.2-thinking", - "choices": [ - "auto", - "instant", - "thinking", - "5.2-instant", - "5.2-thinking" - ] - } - ], - "columns": [ - "Status" - ], - "type": "js", - "modulePath": "plugins/chatgpt-app/send.js", - "sourceFile": "plugins/chatgpt-app/send.js" - }, - { - "site": "chatgpt-app", - "name": "status", - "description": "Check if ChatGPT Desktop App is running natively on macOS", - "access": "read", - "domain": "localhost", - "strategy": "public", - "browser": false, - "args": [], - "columns": [ - "Status" - ], - "type": "js", - "modulePath": "plugins/chatgpt-app/status.js", - "sourceFile": "plugins/chatgpt-app/status.js" - }, - { - "site": "chatwise", - "name": "ask", - "description": "Send a prompt and wait for the AI response (send + wait + read)", - "access": "write", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "text", - "type": "str", - "required": true, - "positional": true, - "help": "Prompt to send" - }, - { - "name": "timeout", - "type": "int", - "default": 30, - "required": false, - "help": "Max seconds to wait (default: 30)" - } - ], - "columns": [ - "Role", - "Text" - ], - "type": "js", - "modulePath": "plugins/chatwise/ask.js", - "sourceFile": "plugins/chatwise/ask.js", - "navigateBefore": true - }, - { - "site": "chatwise", - "name": "export", - "description": "Export the current ChatWise conversation to a Markdown file", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "output", - "type": "str", - "required": false, - "help": "Output file (default: /tmp/chatwise-export.md)" - } - ], - "columns": [ - "Status", - "File", - "Messages" - ], - "type": "js", - "modulePath": "plugins/chatwise/export.js", - "sourceFile": "plugins/chatwise/export.js", - "navigateBefore": true - }, - { - "site": "chatwise", - "name": "history", - "description": "List conversation history in ChatWise sidebar", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Index", - "Title" - ], - "type": "js", - "modulePath": "plugins/chatwise/history.js", - "sourceFile": "plugins/chatwise/history.js", - "navigateBefore": true - }, - { - "site": "chatwise", - "name": "model", - "description": "Get or switch the active AI model in ChatWise", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "model-name", - "type": "str", - "required": false, - "positional": true, - "help": "Model to switch to (e.g. gpt-4, claude-3)" - } - ], - "columns": [ - "Status", - "Model" - ], - "type": "js", - "modulePath": "plugins/chatwise/model.js", - "sourceFile": "plugins/chatwise/model.js", - "navigateBefore": true - }, - { - "site": "chatwise", - "name": "new", - "description": "Start a new ChatWise conversation session", - "access": "write", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Status" - ], - "type": "js", - "modulePath": "plugins/chatwise/new.js", - "sourceFile": "plugins/chatwise/new.js", - "navigateBefore": true - }, - { - "site": "chatwise", - "name": "read", - "description": "Read the current ChatWise conversation history", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Content" - ], - "type": "js", - "modulePath": "plugins/chatwise/read.js", - "sourceFile": "plugins/chatwise/read.js", - "navigateBefore": true - }, - { - "site": "chatwise", - "name": "screenshot", - "description": "Capture a snapshot of the current ChatWise window (DOM + Accessibility tree)", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "output", - "type": "str", - "required": false, - "help": "Output file path (default: /tmp/chatwise-snapshot.txt)" - } - ], - "columns": [ - "Status", - "File" - ], - "type": "js", - "modulePath": "plugins/chatwise/screenshot.js", - "sourceFile": "plugins/chatwise/screenshot.js", - "navigateBefore": true - }, - { - "site": "chatwise", - "name": "send", - "description": "Send a message to the active ChatWise conversation", - "access": "write", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "text", - "type": "str", - "required": true, - "positional": true, - "help": "Message to send" - } - ], - "columns": [ - "Status", - "InjectedText" - ], - "type": "js", - "modulePath": "plugins/chatwise/send.js", - "sourceFile": "plugins/chatwise/send.js", - "navigateBefore": true - }, - { - "site": "chatwise", - "name": "status", - "description": "Check active CDP connection to ChatWise Desktop", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Status", - "Url", - "Title" - ], - "type": "js", - "modulePath": "plugins/chatwise/status.js", - "sourceFile": "plugins/chatwise/status.js", - "navigateBefore": true - }, - { - "site": "chess", - "name": "analyze", - "description": "Open a Chess.com game in the browser analysis board", - "access": "read", - "domain": "www.chess.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "game-url", - "type": "string", - "required": true, - "positional": true, - "help": "Full game URL, e.g. https://www.chess.com/game/live/168842570216" - } - ], - "columns": [ - "kind", - "game_id", - "analysis_url" - ], - "type": "js", - "modulePath": "plugins/chess/analyze.js", - "sourceFile": "plugins/chess/analyze.js", - "navigateBefore": false - }, - { - "site": "chess", - "name": "game", - "description": "Chess.com single-game detail (white, black, result, ECO, time control) by full game URL", - "access": "read", - "domain": "www.chess.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "game-url", - "type": "string", - "required": true, - "positional": true, - "help": "Full game URL, e.g. https://www.chess.com/game/live/168842570216" - } - ], - "columns": [ - "kind", - "game_id", - "date", - "white", - "white_rating", - "black", - "black_rating", - "result", - "winner_color", - "termination", - "eco", - "time_control", - "rated", - "ply_count", - "url" - ], - "type": "js", - "modulePath": "plugins/chess/game.js", - "sourceFile": "plugins/chess/game.js" - }, - { - "site": "chess", - "name": "games", - "description": "Chess.com recent games for a player, newest first", - "access": "read", - "domain": "api.chess.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "username", - "type": "string", - "required": true, - "positional": true, - "help": "Chess.com username" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of recent games (1-100)" - } - ], - "columns": [ - "date", - "time_class", - "rated", - "my_color", - "my_rating", - "my_result", - "opponent", - "opponent_rating", - "accuracy_white", - "accuracy_black", - "eco", - "opening_name", - "url" - ], - "type": "js", - "modulePath": "plugins/chess/games.js", - "sourceFile": "plugins/chess/games.js" - }, - { - "site": "chess", - "name": "stats", - "description": "Chess.com player ratings + win/loss record across game kinds", - "access": "read", - "domain": "api.chess.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "username", - "type": "string", - "required": true, - "positional": true, - "help": "Chess.com username (case-insensitive)" - } - ], - "columns": [ - "kind", - "rating_current", - "rating_best", - "wins", - "losses", - "draws" - ], - "type": "js", - "modulePath": "plugins/chess/stats.js", - "sourceFile": "plugins/chess/stats.js" - }, - { - "site": "cincinnati", - "name": "export-postgraduate-courses", - "description": "Export University of Cincinnati graduate and professional programs from official public sources.", - "access": "read", - "example": "webcmd cincinnati export-postgraduate-courses --degree-level masters --count 10 -f csv", - "domain": "www.grad.uc.edu", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "degree-level", - "type": "string", - "default": "all", - "required": false, - "help": "all, masters, certificate, diploma, professional, or doctorate" - }, - { - "name": "count", - "type": "int", - "required": false, - "help": "Positive maximum number of programs after filtering and deduplication" - } - ], - "columns": [ - "Course Name", - "Course URL", - "University \nname", - "Intake Month", - "Substream/\nSpecialisation", - "App fees", - "Degree Level", - "Study Level", - "Duration\n(in months)", - "Study option", - "Program Type", - "Partner", - "Tution fees \n(per year)", - "Total Tution \nFees", - "IELTS \n(Overall & Subscores)", - "ielts_reading_score", - "ielts_writing_score", - "ielts_listening_score", - "ielts_speaking_score", - "TOEFL\n(Overall & Subscores)", - "toefl_reading_score", - "toefl_writing_score", - "toefl_listening_score", - "toefl_speaking_score", - "PTE\n(Overall & Subscores)", - "pte_reading_score", - "pte_writing_score", - "pte_listening_score", - "pte_speaking_score", - "Duolingo\n(Overall & Subscores)", - "duolingo_comprehension_score", - "duolingo_literacy_score", - "duolingo_conversation_score", - "duolingo_production_score", - "Is Waiver \nProvided?", - "Waiver Info", - "Is MOI \naccepted?", - "Share list, if any", - "GRE Required", - "GMAT Required", - "GRE/GMAT Scores", - "12th scores", - "Min UG score", - "15 years of\nEducation Allowed?", - "Gap Years", - "Backlogs", - "Work \nExperience \nRequired?", - "Main Entry \nRequirements", - "Status", - "Intake status(open/close)\n(eg: Fall (september)-Open\nSpring(January)- Closed)", - "Remarks (if any)", - "Reference Links (if any)" - ], - "type": "js", - "modulePath": "plugins/cincinnati/export-postgraduate-courses.js", - "sourceFile": "plugins/cincinnati/export-postgraduate-courses.js" - }, - { - "site": "claude", - "name": "ask", - "description": "Send a prompt to Claude and get the response", - "access": "write", - "domain": "claude.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "prompt", - "type": "str", - "required": true, - "positional": true, - "help": "Prompt to send" - }, - { - "name": "timeout", - "type": "int", - "default": 120, - "required": false, - "help": "Max seconds to wait for response" - }, - { - "name": "new", - "type": "boolean", - "default": false, - "required": false, - "help": "Start a new chat before sending" - }, - { - "name": "model", - "type": "str", - "default": "sonnet", - "required": false, - "help": "Model to use: sonnet, opus, or haiku", - "choices": [ - "sonnet", - "opus", - "haiku" - ] - }, - { - "name": "think", - "type": "boolean", - "default": false, - "required": false, - "help": "Enable Adaptive thinking" - }, - { - "name": "file", - "type": "str", - "required": false, - "help": "Attach a file (image, PDF, text) with the prompt", - "file": { - "direction": "input", - "pathKind": "file", - "multiple": false, - "contentTypes": [ - "application/pdf", - "text/plain", - "text/markdown", - "text/csv", - "application/json", - "image/jpeg", - "image/png", - "image/gif", - "image/webp" - ], - "maxBytes": 26214400 - } - } - ], - "columns": [ - "response" - ], - "type": "js", - "modulePath": "plugins/claude/ask.js", - "sourceFile": "plugins/claude/ask.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "claude", - "name": "detail", - "description": "Open a Claude conversation by ID and read its messages", - "access": "read", - "domain": "claude.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "id", - "type": "str", - "required": true, - "positional": true, - "help": "Conversation ID (UUID from /chat/)" - } - ], - "columns": [ - "Index", - "Role", - "Text" - ], - "type": "js", - "modulePath": "plugins/claude/detail.js", - "sourceFile": "plugins/claude/detail.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "claude", - "name": "history", - "description": "List conversation history from Claude /recents", - "access": "read", - "domain": "claude.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max conversations to show" - } - ], - "columns": [ - "Index", - "Id", - "Title", - "Url" - ], - "type": "js", - "modulePath": "plugins/claude/history.js", - "sourceFile": "plugins/claude/history.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "claude", - "name": "login", - "description": "Open claude login", - "access": "write", - "domain": "claude.ai", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "status", - "logged_in", - "site", - "user_id", - "org_name", - "org_uuid", - "action", - "verify_command" - ], - "type": "js", - "modulePath": "plugins/claude/auth.js", - "sourceFile": "plugins/claude/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "claude", - "name": "new", - "description": "Start a new conversation in Claude", - "access": "read", - "domain": "claude.ai", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "Status" - ], - "type": "js", - "modulePath": "plugins/claude/new.js", - "sourceFile": "plugins/claude/new.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "claude", - "name": "read", - "description": "Read the current Claude conversation", - "access": "read", - "domain": "claude.ai", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "Index", - "Role", - "Text" - ], - "type": "js", - "modulePath": "plugins/claude/read.js", - "sourceFile": "plugins/claude/read.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "claude", - "name": "send", - "description": "Send a prompt to Claude without waiting for the response", - "access": "write", - "domain": "claude.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "prompt", - "type": "str", - "required": true, - "positional": true, - "help": "Prompt to send" - }, - { - "name": "new", - "type": "boolean", - "default": false, - "required": false, - "help": "Start a new chat before sending" - } - ], - "columns": [ - "Status", - "SubmittedBy", - "InjectedText" - ], - "type": "js", - "modulePath": "plugins/claude/send.js", - "sourceFile": "plugins/claude/send.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "claude", - "name": "status", - "description": "Check Claude page availability and login state", - "access": "read", - "domain": "claude.ai", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "Status", - "Login", - "Url" - ], - "type": "js", - "modulePath": "plugins/claude/status.js", - "sourceFile": "plugins/claude/status.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "claude", - "name": "whoami", - "description": "Show the current logged-in claude account", - "access": "read", - "domain": "claude.ai", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "logged_in", - "site", - "user_id", - "org_name", - "org_uuid" - ], - "type": "js", - "modulePath": "plugins/claude/auth.js", - "sourceFile": "plugins/claude/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "codex", - "name": "archive", - "description": "Archive (Codex's term for delete) the selected conversation via the Chat actions header menu. No confirmation in UI — pass --yes to actually archive.", - "access": "write", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "yes", - "type": "boolean", - "default": false, - "required": false, - "help": "Actually archive (default: dry-run preview)" - }, - { - "name": "project", - "type": "str", - "required": false, - "help": "Project label or path to select before running the command" - }, - { - "name": "conversation", - "type": "str", - "required": false, - "help": "Conversation title to select within --project" - }, - { - "name": "index", - "type": "str", - "required": false, - "help": "1-based conversation index within --project" - }, - { - "name": "thread-id", - "type": "str", - "required": false, - "help": "Exact Codex thread id to select" - } - ], - "columns": [ - "status", - "thread_id", - "project", - "conversation" - ], - "type": "js", - "modulePath": "plugins/codex/archive.js", - "sourceFile": "plugins/codex/archive.js", - "navigateBefore": true - }, - { - "site": "codex", - "name": "ask", - "description": "Send a prompt to the current or selected Codex conversation and wait for the AI response", - "access": "write", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "text", - "type": "str", - "required": true, - "positional": true, - "help": "Prompt to send" - }, - { - "name": "timeout", - "type": "int", - "default": 60, - "required": false, - "help": "Max seconds to wait for response (default: 60)" - }, - { - "name": "project", - "type": "str", - "required": false, - "help": "Project label or path to select before running the command" - }, - { - "name": "conversation", - "type": "str", - "required": false, - "help": "Conversation title to select within --project" - }, - { - "name": "index", - "type": "str", - "required": false, - "help": "1-based conversation index within --project" - }, - { - "name": "thread-id", - "type": "str", - "required": false, - "help": "Exact Codex thread id to select" - } - ], - "columns": [ - "Role", - "Project", - "Conversation", - "Text" - ], - "type": "js", - "modulePath": "plugins/codex/ask.js", - "sourceFile": "plugins/codex/ask.js", - "navigateBefore": true - }, - { - "site": "codex", - "name": "dump", - "description": "Dump the DOM and Accessibility tree of codex for reverse-engineering", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "action", - "files" - ], - "type": "js", - "modulePath": "plugins/codex/dump.js", - "sourceFile": "plugins/codex/dump.js", - "navigateBefore": true - }, - { - "site": "codex", - "name": "export", - "description": "Export the current Codex conversation to a Markdown file", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "output", - "type": "str", - "required": false, - "help": "Output file (default: /tmp/codex-export.md)" - } - ], - "columns": [ - "Status", - "File", - "Messages" - ], - "type": "js", - "modulePath": "plugins/codex/export.js", - "sourceFile": "plugins/codex/export.js", - "navigateBefore": true - }, - { - "site": "codex", - "name": "extract-diff", - "description": "Extract visual code review diff patches from Codex", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "File", - "Diff" - ], - "type": "js", - "modulePath": "plugins/codex/extract-diff.js", - "sourceFile": "plugins/codex/extract-diff.js", - "navigateBefore": true - }, - { - "site": "codex", - "name": "history", - "description": "List visible Codex conversation threads grouped by project", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "project", - "type": "str", - "required": false, - "help": "Filter by project label or path" - }, - { - "name": "limit", - "type": "str", - "required": false, - "help": "Max conversations per project" - } - ], - "columns": [ - "Project", - "Index", - "Title", - "Updated", - "Active" - ], - "type": "js", - "modulePath": "plugins/codex/history.js", - "sourceFile": "plugins/codex/history.js", - "navigateBefore": true - }, - { - "site": "codex", - "name": "model", - "description": "Read, list, or switch the active model / reasoning level in Codex Desktop. The composer toolbar button toggles a menu that mixes model variants (GPT-5.5, Speed) with reasoning levels (Low/Medium/High/Extra High).", - "access": "write", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "name", - "type": "str", - "required": false, - "positional": true, - "help": "Substring (case-insensitive) of a model / reasoning level to switch to. Omit to read current." - }, - { - "name": "list", - "type": "boolean", - "default": false, - "required": false, - "help": "List all menu options (does not switch)" - } - ], - "columns": [ - "Status", - "Model" - ], - "type": "js", - "modulePath": "plugins/codex/model.js", - "sourceFile": "plugins/codex/model.js", - "navigateBefore": true - }, - { - "site": "codex", - "name": "new", - "description": "Start a new Codex conversation session", - "access": "write", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Status" - ], - "type": "js", - "modulePath": "plugins/codex/new.js", - "sourceFile": "plugins/codex/new.js", - "navigateBefore": true - }, - { - "site": "codex", - "name": "pin", - "description": "Pin the selected Codex conversation via the Chat actions header menu.", - "access": "write", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "project", - "type": "str", - "required": false, - "help": "Project label or path to select before running the command" - }, - { - "name": "conversation", - "type": "str", - "required": false, - "help": "Conversation title to select within --project" - }, - { - "name": "index", - "type": "str", - "required": false, - "help": "1-based conversation index within --project" - }, - { - "name": "thread-id", - "type": "str", - "required": false, - "help": "Exact Codex thread id to select" - } - ], - "columns": [ - "status", - "thread_id", - "project", - "conversation" - ], - "type": "js", - "modulePath": "plugins/codex/pin.js", - "sourceFile": "plugins/codex/pin.js", - "navigateBefore": true - }, - { - "site": "codex", - "name": "projects", - "description": "List Codex projects and visible conversations from the sidebar", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "project", - "type": "str", - "required": false, - "help": "Filter by project label or path" - }, - { - "name": "limit", - "type": "str", - "required": false, - "help": "Max conversations per project" - } - ], - "columns": [ - "Project", - "Index", - "Title", - "Updated", - "Active" - ], - "type": "js", - "modulePath": "plugins/codex/projects.js", - "sourceFile": "plugins/codex/projects.js", - "navigateBefore": true - }, - { - "site": "codex", - "name": "read", - "description": "Read the contents of the current or selected Codex conversation thread", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "project", - "type": "str", - "required": false, - "help": "Project label or path to select before running the command" - }, - { - "name": "conversation", - "type": "str", - "required": false, - "help": "Conversation title to select within --project" - }, - { - "name": "index", - "type": "str", - "required": false, - "help": "1-based conversation index within --project" - }, - { - "name": "thread-id", - "type": "str", - "required": false, - "help": "Exact Codex thread id to select" - } - ], - "columns": [ - "Project", - "Conversation", - "Content" - ], - "type": "js", - "modulePath": "plugins/codex/read.js", - "sourceFile": "plugins/codex/read.js", - "navigateBefore": true - }, - { - "site": "codex", - "name": "rename", - "description": "Rename the selected Codex conversation. Opens the Chat actions menu → \"Rename chat\", then types the new title.", - "access": "write", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "title", - "type": "str", - "required": true, - "positional": true, - "help": "New title (single line, no newlines)" - }, - { - "name": "project", - "type": "str", - "required": false, - "help": "Project label or path to select before running the command" - }, - { - "name": "conversation", - "type": "str", - "required": false, - "help": "Conversation title to select within --project" - }, - { - "name": "index", - "type": "str", - "required": false, - "help": "1-based conversation index within --project" - }, - { - "name": "thread-id", - "type": "str", - "required": false, - "help": "Exact Codex thread id to select" - } - ], - "columns": [ - "status", - "title", - "thread_id", - "project" - ], - "type": "js", - "modulePath": "plugins/codex/rename.js", - "sourceFile": "plugins/codex/rename.js", - "navigateBefore": true - }, - { - "site": "codex", - "name": "screenshot", - "description": "Capture a snapshot of the current Codex window (DOM + Accessibility tree)", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "output", - "type": "str", - "required": false, - "help": "Output file path (default: /tmp/codex-snapshot.txt)" - } - ], - "columns": [ - "Status", - "File" - ], - "type": "js", - "modulePath": "plugins/codex/screenshot.js", - "sourceFile": "plugins/codex/screenshot.js", - "navigateBefore": true - }, - { - "site": "codex", - "name": "send", - "description": "Send text/commands to the current or selected Codex AI composer", - "access": "write", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "text", - "type": "str", - "required": true, - "positional": true, - "help": "Text, command (e.g. /review), or skill (e.g. $imagegen)" - }, - { - "name": "project", - "type": "str", - "required": false, - "help": "Project label or path to select before running the command" - }, - { - "name": "conversation", - "type": "str", - "required": false, - "help": "Conversation title to select within --project" - }, - { - "name": "index", - "type": "str", - "required": false, - "help": "1-based conversation index within --project" - }, - { - "name": "thread-id", - "type": "str", - "required": false, - "help": "Exact Codex thread id to select" - } - ], - "columns": [ - "Status", - "Project", - "Conversation", - "InjectedText" - ], - "type": "js", - "modulePath": "plugins/codex/send.js", - "sourceFile": "plugins/codex/send.js", - "navigateBefore": true - }, - { - "site": "codex", - "name": "status", - "description": "Check active CDP connection to OpenAI Codex App", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Status", - "Url", - "Title" - ], - "type": "js", - "modulePath": "plugins/codex/status.js", - "sourceFile": "plugins/codex/status.js", - "navigateBefore": true - }, - { - "site": "codex", - "name": "unpin", - "description": "Unpin the selected Codex conversation via the Chat actions header menu.", - "access": "write", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "project", - "type": "str", - "required": false, - "help": "Project label or path to select before running the command" - }, - { - "name": "conversation", - "type": "str", - "required": false, - "help": "Conversation title to select within --project" - }, - { - "name": "index", - "type": "str", - "required": false, - "help": "1-based conversation index within --project" - }, - { - "name": "thread-id", - "type": "str", - "required": false, - "help": "Exact Codex thread id to select" - } - ], - "columns": [ - "status", - "thread_id", - "project", - "conversation" - ], - "type": "js", - "modulePath": "plugins/codex/pin.js", - "sourceFile": "plugins/codex/pin.js", - "navigateBefore": true - }, - { - "site": "coingecko", - "name": "categories", - "description": "Crypto categories ranked by aggregated market cap", - "access": "read", - "domain": "api.coingecko.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "sort", - "type": "str", - "default": "market_cap_desc", - "required": false, - "help": "Sort order (market_cap_desc / market_cap_asc / name_desc / name_asc / market_cap_change_24h_desc / market_cap_change_24h_asc)" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of categories (1-100; CoinGecko returns ~120 max)" - } - ], - "columns": [ - "rank", - "id", - "name", - "marketCap", - "volume24h", - "marketCapChange24hPct", - "top3Coins" - ], - "type": "js", - "modulePath": "plugins/coingecko/categories.js", - "sourceFile": "plugins/coingecko/categories.js" - }, - { - "site": "coingecko", - "name": "coin", - "description": "Fetch a single cryptocurrency's market data by CoinGecko id (e.g. bitcoin, ethereum).", - "access": "read", - "domain": "api.coingecko.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "id", - "type": "string", - "required": true, - "positional": true, - "help": "CoinGecko coin id (lowercase, e.g. bitcoin / ethereum / solana)." - }, - { - "name": "currency", - "type": "string", - "default": "usd", - "required": false, - "help": "Quote currency (usd, cny, eur, jpy, ...)." - } - ], - "columns": [ - "id", - "symbol", - "name", - "rank", - "price", - "marketCap", - "volume24h", - "change24hPct", - "change7dPct", - "change30dPct", - "ath", - "athDate", - "atl", - "atlDate", - "circulatingSupply", - "totalSupply", - "maxSupply", - "genesisDate", - "homepage" - ], - "type": "js", - "modulePath": "plugins/coingecko/coin.js", - "sourceFile": "plugins/coingecko/coin.js" - }, - { - "site": "coingecko", - "name": "derivatives", - "description": "Top crypto derivative (perpetual / futures) markets by 24h volume", - "access": "read", - "domain": "api.coingecko.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max rows to return (1-500; CoinGecko returns one large page)." - }, - { - "name": "symbol", - "type": "string", - "required": false, - "help": "Optional symbol substring filter (e.g. \"BTC\", \"ETHUSDT\")." - } - ], - "columns": [ - "rank", - "market", - "symbol", - "indexId", - "contractType", - "price", - "change24hPct", - "fundingRate", - "openInterestUsd", - "volume24hUsd", - "expired" - ], - "type": "js", - "modulePath": "plugins/coingecko/derivatives.js", - "sourceFile": "plugins/coingecko/derivatives.js" - }, - { - "site": "coingecko", - "name": "exchanges", - "description": "Top crypto exchanges by 24h BTC trading volume", - "access": "read", - "domain": "api.coingecko.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of exchanges (1-250, CoinGecko per_page upper bound)" - }, - { - "name": "page", - "type": "int", - "default": 1, - "required": false, - "help": "Page number (1-based)" - } - ], - "columns": [ - "rank", - "id", - "name", - "trustScore", - "volume24hBtc", - "country", - "yearEstablished", - "url" - ], - "type": "js", - "modulePath": "plugins/coingecko/exchanges.js", - "sourceFile": "plugins/coingecko/exchanges.js" - }, - { - "site": "coingecko", - "name": "global", - "description": "Aggregate crypto market stats: total market cap, volume, dominance", - "access": "read", - "domain": "api.coingecko.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "currency", - "type": "string", - "default": "usd", - "required": false, - "help": "Quote currency for total market cap / volume (usd, cny, eur, jpy, ...)" - } - ], - "columns": [ - "currency", - "totalMarketCap", - "totalVolume24h", - "marketCapChange24hPct", - "btcDominancePct", - "ethDominancePct", - "activeCryptocurrencies", - "markets", - "ongoingIcos", - "updatedAt" - ], - "type": "js", - "modulePath": "plugins/coingecko/global.js", - "sourceFile": "plugins/coingecko/global.js" - }, - { - "site": "coingecko", - "name": "top", - "description": "Cryptocurrency quotes by market cap (default USD)", - "access": "read", - "domain": "api.coingecko.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "currency", - "type": "string", - "default": "usd", - "required": false, - "help": "quote currency (usd / cny / eur / jpy ...)" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number to return (default 10, maximum 250)" - } - ], - "columns": [ - "rank", - "symbol", - "name", - "price", - "change24hPct", - "marketCap", - "volume24h", - "high24h", - "low24h" - ], - "type": "js", - "modulePath": "plugins/coingecko/top.js", - "sourceFile": "plugins/coingecko/top.js" - }, - { - "site": "coingecko", - "name": "trending", - "description": "Top trending cryptocurrencies on CoinGecko in the last 24h (search-volume based).", - "access": "read", - "domain": "api.coingecko.com", - "strategy": "public", - "browser": false, - "args": [], - "columns": [ - "rank", - "id", - "symbol", - "name", - "marketCapRank", - "priceBtc", - "thumb" - ], - "type": "js", - "modulePath": "plugins/coingecko/trending.js", - "sourceFile": "plugins/coingecko/trending.js" - }, - { - "site": "concordia", - "name": "export-postgraduate-courses", - "description": "Export Concordia University Montreal postgraduate programs using official public sources.", - "access": "read", - "example": "webcmd concordia export-postgraduate-courses --degree-level masters --count 10 -f csv", - "domain": "www.concordia.ca", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "degree-level", - "type": "string", - "default": "all", - "required": false, - "help": "all, masters, certificate, diploma, professional, or doctorate" - }, - { - "name": "count", - "type": "int", - "required": false, - "help": "Positive maximum number of programs after filtering and deduplication" - } - ], - "columns": [ - "Course Name", - "Course URL", - "University \nname", - "Intake Month", - "Substream/\nSpecialisation", - "App fees", - "Degree Level", - "Study Level", - "Duration\n(in months)", - "Study option", - "Program Type", - "Partner", - "Tution fees \n(per year)", - "Total Tution \nFees", - "IELTS \n(Overall & Subscores)", - "ielts_reading_score", - "ielts_writing_score", - "ielts_listening_score", - "ielts_speaking_score", - "TOEFL\n(Overall & Subscores)", - "toefl_reading_score", - "toefl_writing_score", - "toefl_listening_score", - "toefl_speaking_score", - "PTE\n(Overall & Subscores)", - "pte_reading_score", - "pte_writing_score", - "pte_listening_score", - "pte_speaking_score", - "Duolingo\n(Overall & Subscores)", - "duolingo_comprehension_score", - "duolingo_literacy_score", - "duolingo_conversation_score", - "duolingo_production_score", - "Is Waiver \nProvided?", - "Waiver Info", - "Is MOI \naccepted?", - "Share list, if any", - "GRE Required", - "GMAT Required", - "GRE/GMAT Scores", - "12th scores", - "Min UG score", - "15 years of\nEducation Allowed?", - "Gap Years", - "Backlogs", - "Work \nExperience \nRequired?", - "Main Entry \nRequirements", - "Status", - "Intake status(open/close)\n(eg: Fall (september)-Open\nSpring(January)- Closed)", - "Remarks (if any)", - "Reference Links (if any)" - ], - "type": "js", - "modulePath": "plugins/concordia/export-postgraduate-courses.js", - "sourceFile": "plugins/concordia/export-postgraduate-courses.js" - }, - { - "site": "confluence", - "name": "create", - "description": "Create a Confluence page from Markdown or storage XHTML", - "access": "write", - "domain": "atlassian.net", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "space", - "type": "string", - "required": true, - "help": "Cloud space id, or Data Center space key" - }, - { - "name": "title", - "type": "string", - "required": true, - "help": "Page title" - }, - { - "name": "file", - "type": "string", - "required": true, - "help": "Markdown file path", - "file": { - "direction": "input", - "pathKind": "file", - "multiple": false - } - }, - { - "name": "parent", - "type": "string", - "required": false, - "help": "Optional parent page id" - }, - { - "name": "representation", - "type": "string", - "default": "markdown", - "required": false, - "help": "Input file format", - "choices": [ - "markdown", - "storage" - ] - }, - { - "name": "execute", - "type": "boolean", - "required": false, - "help": "Actually create the remote page" - } - ], - "columns": [ - "status", - "id", - "title", - "spaceId", - "spaceKey", - "version", - "url" - ], - "type": "js", - "modulePath": "plugins/confluence/create.js", - "sourceFile": "plugins/confluence/create.js" - }, - { - "site": "confluence", - "name": "page", - "description": "Confluence page by id with storage and Markdown body", - "access": "read", - "domain": "atlassian.net", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "id", - "type": "str", - "required": true, - "positional": true, - "help": "Confluence page id" - } - ], - "columns": [ - "id", - "title", - "status", - "spaceId", - "spaceKey", - "version", - "url" - ], - "type": "js", - "modulePath": "plugins/confluence/page.js", - "sourceFile": "plugins/confluence/page.js" - }, - { - "site": "confluence", - "name": "search", - "description": "Search Confluence content with CQL", - "access": "read", - "domain": "atlassian.net", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "cql", - "type": "str", - "required": true, - "positional": true, - "help": "CQL query, e.g. \"type = page and title ~ \\\"RCA\\\"\"" - }, - { - "name": "space", - "type": "string", - "required": false, - "help": "Limit search to a Confluence space key" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max results to return (1-100)" - } - ], - "columns": [ - "id", - "title", - "type", - "spaceKey", - "status", - "lastModified", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/confluence/search.js", - "sourceFile": "plugins/confluence/search.js" - }, - { - "site": "confluence", - "name": "update", - "description": "Update a Confluence page body from Markdown or storage XHTML", - "access": "write", - "domain": "atlassian.net", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "id", - "type": "str", - "required": true, - "positional": true, - "help": "Confluence page id" - }, - { - "name": "file", - "type": "string", - "required": true, - "help": "Markdown file path", - "file": { - "direction": "input", - "pathKind": "file", - "multiple": false - } - }, - { - "name": "title", - "type": "string", - "required": false, - "help": "Optional replacement title; defaults to current title" - }, - { - "name": "version-message", - "type": "string", - "required": false, - "help": "Confluence version message" - }, - { - "name": "representation", - "type": "string", - "default": "markdown", - "required": false, - "help": "Input file format", - "choices": [ - "markdown", - "storage" - ] - }, - { - "name": "execute", - "type": "boolean", - "required": false, - "help": "Actually update the remote page" - } - ], - "columns": [ - "status", - "id", - "title", - "spaceId", - "spaceKey", - "version", - "url" - ], - "type": "js", - "modulePath": "plugins/confluence/update.js", - "sourceFile": "plugins/confluence/update.js" - }, - { - "site": "coupang", - "name": "add-to-cart", - "description": "Add a Coupang product to cart using logged-in browser session", - "access": "write", - "domain": "www.coupang.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "product-id", - "type": "str", - "required": false, - "positional": true, - "help": "Coupang product ID" - }, - { - "name": "url", - "type": "str", - "required": false, - "help": "Canonical product URL" - } - ], - "columns": [ - "ok", - "product_id", - "url", - "message" - ], - "type": "js", - "modulePath": "plugins/coupang/add-to-cart.js", - "sourceFile": "plugins/coupang/add-to-cart.js", - "navigateBefore": "https://www.coupang.com" - }, - { - "site": "coupang", - "name": "login", - "description": "Open coupang login", - "access": "write", - "domain": "coupang.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "status", - "logged_in", - "site", - "name", - "action", - "verify_command" - ], - "type": "js", - "modulePath": "plugins/coupang/auth.js", - "sourceFile": "plugins/coupang/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "coupang", - "name": "product", - "description": "Read full product detail (price, rating, seller, delivery) for a Coupang product", - "access": "read", - "domain": "www.coupang.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "product-id", - "type": "str", - "required": false, - "positional": true, - "help": "Coupang product ID (digits only)" - }, - { - "name": "url", - "type": "str", - "required": false, - "help": "Canonical Coupang product URL (alternative to --product-id)" - } - ], - "columns": [ - "product_id", - "title", - "price", - "original_price", - "discount_rate", - "rating", - "review_count", - "seller", - "brand", - "rocket", - "delivery_promise", - "image_url", - "url" - ], - "type": "js", - "modulePath": "plugins/coupang/product.js", - "sourceFile": "plugins/coupang/product.js", - "navigateBefore": "https://www.coupang.com" - }, - { - "site": "coupang", - "name": "search", - "description": "Search Coupang products with logged-in browser session", - "access": "read", - "domain": "www.coupang.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search keyword" - }, - { - "name": "page", - "type": "int", - "default": 1, - "required": false, - "help": "Search result page number" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max results (max 50)" - }, - { - "name": "filter", - "type": "str", - "required": false, - "help": "Optional search filter (currently supports: rocket)" - } - ], - "columns": [ - "rank", - "product_id", - "title", - "price", - "unit_price", - "rating", - "review_count", - "rocket", - "delivery_type", - "delivery_promise", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/coupang/search.js", - "sourceFile": "plugins/coupang/search.js", - "navigateBefore": "https://www.coupang.com" - }, - { - "site": "coupang", - "name": "whoami", - "description": "Show the current logged-in coupang account", - "access": "read", - "domain": "coupang.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "logged_in", - "site", - "name" - ], - "type": "js", - "modulePath": "plugins/coupang/auth.js", - "sourceFile": "plugins/coupang/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "crates", - "name": "crate", - "description": "Single crates.io crate metadata (latest version, downloads, license, repo)", - "access": "read", - "domain": "crates.io", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "name", - "type": "str", - "required": true, - "positional": true, - "help": "crates.io crate name (e.g. \"serde\", \"tokio\")" - } - ], - "columns": [ - "name", - "latestVersion", - "description", - "downloads", - "recentDownloads", - "versions", - "license", - "homepage", - "documentation", - "repository", - "keywords", - "categories", - "created", - "updated", - "url" - ], - "type": "js", - "modulePath": "plugins/crates/crate.js", - "sourceFile": "plugins/crates/crate.js" - }, - { - "site": "crates", - "name": "search", - "description": "Search the public crates.io registry by keyword", - "access": "read", - "domain": "crates.io", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search keyword (e.g. \"serde\", \"async runtime\")" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max results (1-100)" - } - ], - "columns": [ - "rank", - "name", - "latestVersion", - "description", - "downloads", - "recentDownloads", - "repository", - "updated", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/crates/search.js", - "sourceFile": "plugins/crates/search.js" - }, - { - "site": "cursor", - "name": "ask", - "description": "Send a prompt and wait for the AI response (send + wait + read)", - "access": "write", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "text", - "type": "str", - "required": true, - "positional": true, - "help": "Prompt to send" - }, - { - "name": "timeout", - "type": "int", - "default": 30, - "required": false, - "help": "Max seconds to wait for response (default: 30)" - } - ], - "columns": [ - "Role", - "Text" - ], - "type": "js", - "modulePath": "plugins/cursor/ask.js", - "sourceFile": "plugins/cursor/ask.js", - "navigateBefore": true - }, - { - "site": "cursor", - "name": "composer", - "description": "Send a prompt directly into Cursor Composer (Cmd+I shortcut)", - "access": "write", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "text", - "type": "str", - "required": true, - "positional": true, - "help": "Text to send into Composer" - } - ], - "columns": [ - "Status", - "InjectedText" - ], - "type": "js", - "modulePath": "plugins/cursor/composer.js", - "sourceFile": "plugins/cursor/composer.js", - "navigateBefore": true - }, - { - "site": "cursor", - "name": "dump", - "description": "Dump the DOM and Accessibility tree of cursor for reverse-engineering", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "action", - "files" - ], - "type": "js", - "modulePath": "plugins/cursor/dump.js", - "sourceFile": "plugins/cursor/dump.js", - "navigateBefore": true - }, - { - "site": "cursor", - "name": "export", - "description": "Export the current cursor conversation to a Markdown file", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "output", - "type": "str", - "required": false, - "help": "Output file (default: /tmp/cursor-export.md)" - } - ], - "columns": [ - "Status", - "File", - "Messages" - ], - "type": "js", - "modulePath": "plugins/cursor/export.js", - "sourceFile": "plugins/cursor/export.js", - "navigateBefore": true - }, - { - "site": "cursor", - "name": "extract-code", - "description": "Extract multi-line code blocks from the current Cursor conversation", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Code" - ], - "type": "js", - "modulePath": "plugins/cursor/extract-code.js", - "sourceFile": "plugins/cursor/extract-code.js", - "navigateBefore": true - }, - { - "site": "cursor", - "name": "history", - "description": "List recent chat sessions from the Cursor sidebar", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Index", - "Title" - ], - "type": "js", - "modulePath": "plugins/cursor/history.js", - "sourceFile": "plugins/cursor/history.js", - "navigateBefore": true - }, - { - "site": "cursor", - "name": "model", - "description": "Get or switch the currently active AI model in Cursor", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "model-name", - "type": "str", - "required": false, - "positional": true, - "help": "The ID of the model to switch to (e.g. claude-3.5-sonnet)" - } - ], - "columns": [ - "Status", - "Model" - ], - "type": "js", - "modulePath": "plugins/cursor/model.js", - "sourceFile": "plugins/cursor/model.js", - "navigateBefore": true - }, - { - "site": "cursor", - "name": "new", - "description": "Start a new Cursor chat or Composer session", - "access": "write", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Status" - ], - "type": "js", - "modulePath": "plugins/cursor/new.js", - "sourceFile": "plugins/cursor/new.js", - "navigateBefore": true - }, - { - "site": "cursor", - "name": "read", - "description": "Read the current Cursor chat/composer conversation history", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Role", - "Text" - ], - "type": "js", - "modulePath": "plugins/cursor/read.js", - "sourceFile": "plugins/cursor/read.js", - "navigateBefore": true - }, - { - "site": "cursor", - "name": "screenshot", - "description": "Capture a snapshot of the current cursor window (DOM + Accessibility tree)", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "output", - "type": "str", - "required": false, - "help": "Output file path (default: /tmp/cursor-snapshot.txt)" - } - ], - "columns": [ - "Status", - "File" - ], - "type": "js", - "modulePath": "plugins/cursor/screenshot.js", - "sourceFile": "plugins/cursor/screenshot.js", - "navigateBefore": true - }, - { - "site": "cursor", - "name": "send", - "description": "Send a prompt directly into Cursor Composer/Chat", - "access": "write", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "text", - "type": "str", - "required": true, - "positional": true, - "help": "Text to send into Cursor" - } - ], - "columns": [ - "Status", - "InjectedText" - ], - "type": "js", - "modulePath": "plugins/cursor/send.js", - "sourceFile": "plugins/cursor/send.js", - "navigateBefore": true - }, - { - "site": "cursor", - "name": "status", - "description": "Check active CDP connection to Cursor AI Editor", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Status", - "Url", - "Title" - ], - "type": "js", - "modulePath": "plugins/cursor/status.js", - "sourceFile": "plugins/cursor/status.js", - "navigateBefore": true - }, - { - "site": "dblp", - "name": "author", - "description": "List dblp publications by a given author (newest first; resolves to top PID match)", - "access": "read", - "domain": "dblp.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "author", - "type": "str", - "required": false, - "positional": true, - "help": "Author name (e.g. \"Yoshua Bengio\"). Optional when --pid is given." - }, - { - "name": "pid", - "type": "str", - "required": false, - "help": "Canonical dblp PID (e.g. \"56/953\"). Bypasses author search." - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max publications (1-200)" - } - ], - "columns": [ - "rank", - "key", - "title", - "authors", - "venue", - "year", - "type", - "doi", - "pid", - "url" - ], - "type": "js", - "modulePath": "plugins/dblp/author.js", - "sourceFile": "plugins/dblp/author.js" - }, - { - "site": "dblp", - "name": "paper", - "aliases": [ - "detail", - "view" - ], - "description": "Fetch a dblp record by canonical key (e.g. conf/nips/VaswaniSPUJGKP17)", - "access": "read", - "domain": "dblp.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "key", - "type": "str", - "required": true, - "positional": true, - "help": "dblp record key (round-tripped from the `key` column of `dblp search`)" - } - ], - "columns": [ - "key", - "type", - "title", - "authors", - "venue", - "year", - "pages", - "doi", - "open_access_url", - "dblp_url" - ], - "type": "js", - "modulePath": "plugins/dblp/paper.js", - "sourceFile": "plugins/dblp/paper.js" - }, - { - "site": "dblp", - "name": "search", - "description": "Search dblp computer-science bibliography by free-text query", - "access": "read", - "domain": "dblp.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search keyword (title / author / venue, e.g. \"attention is all you need\")" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max results (1-100, single dblp page)" - } - ], - "columns": [ - "rank", - "key", - "title", - "authors", - "venue", - "year", - "type", - "doi", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/dblp/search.js", - "sourceFile": "plugins/dblp/search.js" - }, - { - "site": "dblp", - "name": "venue", - "description": "Search dblp venue registry (conferences / journals) by name or acronym", - "access": "read", - "domain": "dblp.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Venue name or acronym (e.g. \"ICLR\", \"neural networks\")" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max venues (1-100, single dblp page)" - } - ], - "columns": [ - "rank", - "acronym", - "venue", - "type", - "url" - ], - "type": "js", - "modulePath": "plugins/dblp/venue.js", - "sourceFile": "plugins/dblp/venue.js" - }, - { - "site": "defillama", - "name": "protocol", - "description": "Single DefiLlama protocol details (current TVL, mcap, chains, twitter, github, description)", - "access": "read", - "domain": "defillama.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "slug", - "type": "string", - "required": true, - "positional": true, - "help": "DefiLlama protocol slug (e.g. \"aave\", \"lido\")" - } - ], - "columns": [ - "slug", - "name", - "category", - "isParent", - "tvl", - "tvlAt", - "mcap", - "chains", - "twitter", - "github", - "audits", - "listedAt", - "description", - "website", - "url" - ], - "type": "js", - "modulePath": "plugins/defillama/protocol.js", - "sourceFile": "plugins/defillama/protocol.js" - }, - { - "site": "defillama", - "name": "protocols", - "description": "Top DeFi protocols on DefiLlama by current TVL (slug, name, category, TVL, mcap, change_1d/7d, chains)", - "access": "read", - "domain": "defillama.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 30, - "required": false, - "help": "Number of rows to return (1-500)" - } - ], - "columns": [ - "rank", - "slug", - "name", - "category", - "tvl", - "mcap", - "change_1d", - "change_7d", - "chains", - "listedAt", - "url" - ], - "type": "js", - "modulePath": "plugins/defillama/protocols.js", - "sourceFile": "plugins/defillama/protocols.js" - }, - { - "site": "devto", - "name": "latest", - "description": "Newest dev.to articles (firehose, all tags)", - "access": "read", - "domain": "dev.to", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Articles per page (1-100)" - }, - { - "name": "page", - "type": "int", - "default": 1, - "required": false, - "help": "Page number (1-based)" - } - ], - "columns": [ - "rank", - "id", - "title", - "author", - "tags", - "reactions", - "comments", - "published", - "url" - ], - "type": "js", - "modulePath": "plugins/devto/latest.js", - "sourceFile": "plugins/devto/latest.js" - }, - { - "site": "devto", - "name": "read", - "description": "Read a DEV.to article body by id", - "access": "read", - "domain": "dev.to", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "id", - "type": "str", - "required": true, - "positional": true, - "help": "DEV.to article id (numeric, e.g. 3605688)" - }, - { - "name": "max-length", - "type": "int", - "default": 20000, - "required": false, - "help": "Max characters of body to return (min 100)" - } - ], - "columns": [ - "id", - "title", - "author", - "reactions", - "reading_time", - "tags", - "published_at", - "body", - "url" - ], - "type": "js", - "modulePath": "plugins/devto/read.js", - "sourceFile": "plugins/devto/read.js" - }, - { - "site": "devto", - "name": "tag", - "description": "Latest DEV.to articles for a specific tag", - "access": "read", - "domain": "dev.to", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "tag", - "type": "str", - "required": true, - "positional": true, - "help": "Tag name (e.g. javascript, python, webdev)" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of articles" - } - ], - "columns": [ - "rank", - "id", - "title", - "author", - "reactions", - "comments", - "reading_time", - "published_at", - "tags", - "url" - ], - "type": "js", - "modulePath": "plugins/devto/tag.js", - "sourceFile": "plugins/devto/tag.js" - }, - { - "site": "devto", - "name": "top", - "description": "Top DEV.to articles of the day", - "access": "read", - "domain": "dev.to", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of articles" - } - ], - "columns": [ - "rank", - "id", - "title", - "author", - "reactions", - "comments", - "reading_time", - "published_at", - "tags", - "url" - ], - "type": "js", - "modulePath": "plugins/devto/top.js", - "sourceFile": "plugins/devto/top.js" - }, - { - "site": "devto", - "name": "user", - "description": "Recent DEV.to articles from a specific user", - "access": "read", - "domain": "dev.to", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "username", - "type": "str", - "required": true, - "positional": true, - "help": "DEV.to username (e.g. ben, thepracticaldev)" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of articles" - } - ], - "columns": [ - "rank", - "id", - "title", - "reactions", - "comments", - "reading_time", - "published_at", - "tags", - "url" - ], - "type": "js", - "modulePath": "plugins/devto/user.js", - "sourceFile": "plugins/devto/user.js" - }, - { - "site": "dictionary", - "name": "examples", - "description": "Read real-world example sentences utilizing the word", - "access": "read", - "domain": "api.dictionaryapi.dev", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "word", - "type": "string", - "required": true, - "positional": true, - "help": "Word to get example sentences for" - } - ], - "columns": [ - "word", - "example" - ], - "type": "js", - "modulePath": "plugins/dictionary/examples.js", - "sourceFile": "plugins/dictionary/examples.js" - }, - { - "site": "dictionary", - "name": "search", - "description": "Search the Free Dictionary API for definitions, parts of speech, and pronunciations.", - "access": "read", - "domain": "api.dictionaryapi.dev", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "word", - "type": "string", - "required": true, - "positional": true, - "help": "Word to define (e.g., serendipity)" - } - ], - "columns": [ - "word", - "phonetic", - "type", - "definition" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/dictionary/search.js", - "sourceFile": "plugins/dictionary/search.js" - }, - { - "site": "dictionary", - "name": "synonyms", - "description": "Find synonyms for a specific word", - "access": "read", - "domain": "api.dictionaryapi.dev", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "word", - "type": "string", - "required": true, - "positional": true, - "help": "Word to find synonyms for (e.g., serendipity)" - } - ], - "columns": [ - "word", - "synonyms" - ], - "type": "js", - "modulePath": "plugins/dictionary/synonyms.js", - "sourceFile": "plugins/dictionary/synonyms.js" - }, - { - "site": "discord-app", - "name": "channels", - "description": "List channels in the current Discord server", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Index", - "Channel", - "Type", - "guild_id", - "channel_id", - "url" - ], - "type": "js", - "modulePath": "plugins/discord-app/channels.js", - "sourceFile": "plugins/discord-app/channels.js", - "navigateBefore": true - }, - { - "site": "discord-app", - "name": "delete", - "description": "Delete a message by its ID in the active Discord channel", - "access": "write", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "message_id", - "type": "string", - "required": true, - "positional": true, - "help": "The ID of the message to delete (visible via Developer Mode or the read command)" - } - ], - "columns": [ - "status", - "message" - ], - "type": "js", - "modulePath": "plugins/discord-app/delete.js", - "sourceFile": "plugins/discord-app/delete.js", - "navigateBefore": true - }, - { - "site": "discord-app", - "name": "goto", - "description": "Open a Discord channel by id/name/url without sending messages", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "guild", - "type": "str", - "required": false, - "help": "Guild/server id or visible name" - }, - { - "name": "channel", - "type": "str", - "required": false, - "help": "Channel id or visible name" - }, - { - "name": "url", - "type": "str", - "required": false, - "help": "Discord channel URL" - }, - { - "name": "timeout", - "type": "str", - "default": "8", - "required": false, - "help": "Seconds to wait for Discord to show the route (default: 8)" - } - ], - "columns": [ - "Status", - "guild_id", - "channel_id", - "url" - ], - "type": "js", - "modulePath": "plugins/discord-app/goto.js", - "sourceFile": "plugins/discord-app/goto.js", - "navigateBefore": true - }, - { - "site": "discord-app", - "name": "members", - "description": "List online members in the current Discord channel", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Index", - "Name", - "Status" - ], - "type": "js", - "modulePath": "plugins/discord-app/members.js", - "sourceFile": "plugins/discord-app/members.js", - "navigateBefore": true - }, - { - "site": "discord-app", - "name": "read", - "description": "Read recent messages from the active or targeted Discord channel", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "count", - "type": "str", - "default": "20", - "required": false, - "help": "Number of messages to read (default: 20)" - }, - { - "name": "guild", - "type": "str", - "required": false, - "help": "Guild/server id or visible name for targeted reads" - }, - { - "name": "channel", - "type": "str", - "required": false, - "help": "Channel id or visible name for targeted reads" - }, - { - "name": "url", - "type": "str", - "required": false, - "help": "Discord channel URL to open before reading" - } - ], - "columns": [ - "Author", - "Time", - "Message", - "channel_id", - "message_id" - ], - "type": "js", - "modulePath": "plugins/discord-app/read.js", - "sourceFile": "plugins/discord-app/read.js", - "navigateBefore": true - }, - { - "site": "discord-app", - "name": "search", - "description": "Search messages in the current Discord server/channel (Cmd+F)", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search query" - } - ], - "columns": [ - "Index", - "Author", - "Message" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/discord-app/search.js", - "sourceFile": "plugins/discord-app/search.js", - "navigateBefore": true - }, - { - "site": "discord-app", - "name": "send", - "description": "Send a message in the active Discord channel", - "access": "write", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "text", - "type": "str", - "required": true, - "positional": true, - "help": "Message to send" - } - ], - "columns": [ - "Status" - ], - "type": "js", - "modulePath": "plugins/discord-app/send.js", - "sourceFile": "plugins/discord-app/send.js", - "navigateBefore": true - }, - { - "site": "discord-app", - "name": "servers", - "description": "List all Discord servers (guilds) in the sidebar", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Index", - "Server", - "guild_id", - "url" - ], - "type": "js", - "modulePath": "plugins/discord-app/servers.js", - "sourceFile": "plugins/discord-app/servers.js", - "navigateBefore": true - }, - { - "site": "discord-app", - "name": "status", - "description": "Check active CDP connection to Discord Desktop", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Status", - "Url", - "Title" - ], - "type": "js", - "modulePath": "plugins/discord-app/status.js", - "sourceFile": "plugins/discord-app/status.js", - "navigateBefore": true - }, - { - "site": "discord-app", - "name": "thread-read", - "description": "Read recent messages from a Discord thread/post by id or URL", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "thread", - "type": "str", - "required": false, - "help": "Thread/post id, or a full Discord thread/post URL" - }, - { - "name": "count", - "type": "str", - "default": "20", - "required": false, - "help": "Number of messages to read (default: 20)" - }, - { - "name": "guild", - "type": "str", - "required": false, - "help": "Parent guild/server id or visible name" - }, - { - "name": "channel", - "type": "str", - "required": false, - "help": "Parent forum/channel id or visible name" - }, - { - "name": "url", - "type": "str", - "required": false, - "help": "Discord thread/post URL" - } - ], - "columns": [ - "Author", - "Time", - "Message", - "channel_id", - "message_id" - ], - "type": "js", - "modulePath": "plugins/discord-app/thread-read.js", - "sourceFile": "plugins/discord-app/thread-read.js", - "navigateBefore": true - }, - { - "site": "discord-app", - "name": "threads", - "description": "List visible Discord forum/thread posts in the active or targeted channel", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "limit", - "type": "str", - "default": "30", - "required": false, - "help": "Maximum thread/post cards to return (default: 30)" - }, - { - "name": "guild", - "type": "str", - "required": false, - "help": "Guild/server id or visible name for targeted thread listing" - }, - { - "name": "channel", - "type": "str", - "required": false, - "help": "Forum/channel id or visible name for targeted thread listing" - }, - { - "name": "url", - "type": "str", - "required": false, - "help": "Discord forum/channel URL to open before listing threads" - } - ], - "columns": [ - "Index", - "Thread", - "Author", - "Updated", - "Preview", - "guild_id", - "channel_id", - "thread_id", - "url" - ], - "type": "js", - "modulePath": "plugins/discord-app/threads.js", - "sourceFile": "plugins/discord-app/threads.js", - "navigateBefore": true - }, - { - "site": "district", - "name": "checkout", - "description": "Select District movie seats and open the UPI QR payment scanner", - "access": "write", - "domain": "www.district.in", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "show", - "type": "str", - "required": true, - "positional": true, - "help": "District seat-layout URL or showId from district showtimes" - }, - { - "name": "seats", - "type": "str", - "required": true, - "help": "Comma-separated seat labels to select, e.g. I22,I21" - }, - { - "name": "format-id", - "type": "str", - "required": false, - "help": "District formatId from showtimes; required when show is a showId" - }, - { - "name": "content-id", - "type": "str", - "required": false, - "help": "District content id; required when show is a showId" - }, - { - "name": "timeout", - "type": "int", - "default": 45, - "required": false, - "help": "Maximum seconds to wait for selection, review page, and payment handoff" - }, - { - "name": "payment", - "type": "str", - "default": "upi-qr", - "required": false, - "help": "Payment handoff target: upi-qr opens the scanner; review stops on order review" - } - ], - "columns": [ - "status", - "movie", - "cinema", - "date", - "time", - "seats", - "ticketCount", - "orderAmount", - "bookingCharge", - "total", - "paymentMethod", - "paymentState", - "upiQrVisible", - "paymentAmount", - "paymentUrl", - "showId" - ], - "type": "js", - "modulePath": "plugins/district/checkout.js", - "sourceFile": "plugins/district/checkout.js", - "navigateBefore": false, - "siteSession": "persistent", - "freshPage": true - }, - { - "site": "district", - "name": "listings", - "aliases": [ - "ls" - ], - "description": "List public District by Zomato movies, events, and nearby going-out cards", - "access": "read", - "domain": "www.district.in", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "input", - "type": "str", - "default": "home", - "required": false, - "positional": true, - "help": "home, movies, events, a district.in URL, or a District path" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Maximum rows to return (1-100)" - } - ], - "columns": [ - "rank", - "title", - "category", - "date", - "venue", - "price", - "url" - ], - "type": "js", - "modulePath": "plugins/district/listings.js", - "sourceFile": "plugins/district/listings.js" - }, - { - "site": "district", - "name": "locations", - "aliases": [ - "location-search" - ], - "description": "Search District-supported cities, areas, malls, and places for booking filters", - "access": "read", - "domain": "www.district.in", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "City, area, mall, or locality, for example \"bangalore\" or \"indiranagar\"" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Maximum location rows to return (1-50)" - } - ], - "columns": [ - "rank", - "name", - "kind", - "city", - "state", - "cityKey", - "cityId", - "placeId", - "lat", - "lng", - "distanceKm", - "source" - ], - "type": "js", - "modulePath": "plugins/district/locations.js", - "sourceFile": "plugins/district/locations.js" - }, - { - "site": "district", - "name": "login", - "description": "Open district login", - "access": "write", - "domain": "www.district.in", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "status", - "logged_in", - "site", - "user_id", - "name", - "phone_number", - "email", - "action", - "verify_command" - ], - "type": "js", - "modulePath": "plugins/district/auth.js", - "sourceFile": "plugins/district/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "district", - "name": "search", - "aliases": [ - "s" - ], - "description": "Search District by Zomato across movies, events, dining, stores, activities, and play", - "access": "read", - "domain": "www.district.in", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search query, for example \"hamlet\" or \"arijit\"" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Maximum rows to return (1-100)" - }, - { - "name": "tab", - "type": "str", - "default": "all", - "required": false, - "help": "Search tab: all, dining, events, movies, stores, activities, or play" - } - ], - "columns": [ - "rank", - "title", - "category", - "date", - "venue", - "price", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/district/search.js", - "sourceFile": "plugins/district/search.js" - }, - { - "site": "district", - "name": "seats", - "description": "List available seats for a District movie showtime", - "access": "read", - "domain": "www.district.in", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "show", - "type": "str", - "required": true, - "positional": true, - "help": "District seat-layout URL or showId from district showtimes" - }, - { - "name": "format-id", - "type": "str", - "required": false, - "help": "District formatId from showtimes; required when show is a showId" - }, - { - "name": "content-id", - "type": "str", - "required": false, - "help": "District content id; required when show is a showId" - }, - { - "name": "class", - "type": "str", - "required": false, - "help": "Optional seat class filter, e.g. premium, premium xl, or recliner" - }, - { - "name": "count", - "type": "int", - "required": false, - "help": "Number of seats to choose (1-10); without count, seats are listed normally" - }, - { - "name": "together", - "type": "str", - "required": false, - "help": "Require selected seats to be adjacent when count is provided" - }, - { - "name": "max-price", - "type": "float", - "required": false, - "help": "Maximum price per seat" - }, - { - "name": "limit", - "type": "int", - "default": 100, - "required": false, - "help": "Maximum seats to return (1-300)" - }, - { - "name": "timeout", - "type": "int", - "default": 30, - "required": false, - "help": "Maximum seconds to wait for the seat map to render" - } - ], - "columns": [ - "rank", - "seat", - "row", - "number", - "column", - "seatClass", - "price", - "status", - "flags", - "showId", - "formatId", - "url" - ], - "type": "js", - "modulePath": "plugins/district/seats.js", - "sourceFile": "plugins/district/seats.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "district", - "name": "set-location", - "aliases": [ - "setlocation" - ], - "description": "Set the District browser session location for movie booking filters", - "access": "write", - "domain": "www.district.in", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "location", - "type": "str", - "required": true, - "positional": true, - "help": "City, area, mall, or locality, for example \"Bangalore\" or \"Indiranagar\"" - }, - { - "name": "rank", - "type": "int", - "default": 1, - "required": false, - "help": "Pick the Nth District location result (1-20), default: 1" - }, - { - "name": "timeout", - "type": "int", - "default": 45, - "required": false, - "help": "Maximum seconds to wait for the picker and location change" - } - ], - "columns": [ - "status", - "name", - "city", - "state", - "cityKey", - "cityId", - "placeId", - "subzoneId", - "lat", - "lng", - "availableTabs", - "source" - ], - "type": "js", - "modulePath": "plugins/district/set-location.js", - "sourceFile": "plugins/district/set-location.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "district", - "name": "showtimes", - "aliases": [ - "shows" - ], - "description": "List District movie showtimes with location, time, cinema, language, price, and format filters", - "access": "read", - "domain": "www.district.in", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "movie", - "type": "str", - "required": true, - "positional": true, - "help": "Movie name or District movie URL" - }, - { - "name": "date", - "type": "str", - "required": false, - "help": "Show date in YYYY-MM-DD format; defaults to District selected date" - }, - { - "name": "city", - "type": "str", - "required": false, - "help": "District city name/key, for example Bangalore or Bengaluru" - }, - { - "name": "near", - "type": "str", - "required": false, - "help": "Area, mall, or locality to search near, for example Indiranagar" - }, - { - "name": "city-key", - "type": "str", - "required": false, - "help": "Legacy District city key override, for example bengaluru" - }, - { - "name": "after", - "type": "str", - "required": false, - "help": "Only shows at or after HH:MM, 24-hour time" - }, - { - "name": "before", - "type": "str", - "required": false, - "help": "Only shows at or before HH:MM, 24-hour time" - }, - { - "name": "cinema", - "type": "str", - "required": false, - "help": "Filter cinema/theatre name, for example PVR, INOX, Orion" - }, - { - "name": "language", - "type": "str", - "required": false, - "help": "Filter movie language, for example English, Hindi, Kannada" - }, - { - "name": "max-price", - "type": "float", - "required": false, - "help": "Only shows with at least one ticket class at or below this price" - }, - { - "name": "quality", - "type": "str", - "required": false, - "help": "Generic format/quality filter, for example 2D, 3D, IMAX, IMAX 3D, 4DX" - }, - { - "name": "limit", - "type": "int", - "default": 50, - "required": false, - "help": "Maximum showtime rows to return (1-200)" - } - ], - "columns": [ - "rank", - "movie", - "language", - "date", - "time", - "cinema", - "format", - "priceRange", - "available", - "showId", - "formatId", - "url" - ], - "type": "js", - "modulePath": "plugins/district/showtimes.js", - "sourceFile": "plugins/district/showtimes.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "district", - "name": "whoami", - "description": "Show the current logged-in district account", - "access": "read", - "domain": "www.district.in", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "logged_in", - "site", - "user_id", - "name", - "phone_number", - "email" - ], - "type": "js", - "modulePath": "plugins/district/auth.js", - "sourceFile": "plugins/district/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "dockerhub", - "name": "image", - "description": "Fetch a Docker Hub repository's public metadata (stars, pulls, last updated, status)", - "access": "read", - "domain": "hub.docker.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "image", - "type": "str", - "required": true, - "positional": true, - "help": "Image name (e.g. \"nginx\", \"library/nginx\", \"bitnami/redis\")" - } - ], - "columns": [ - "image", - "official", - "stars", - "pulls", - "description", - "lastUpdated", - "lastModified", - "registered", - "status", - "url" - ], - "type": "js", - "modulePath": "plugins/dockerhub/image.js", - "sourceFile": "plugins/dockerhub/image.js" - }, - { - "site": "dockerhub", - "name": "search", - "description": "Search Docker Hub repositories by keyword", - "access": "read", - "domain": "hub.docker.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search keyword (e.g. \"nginx\", \"bitnami redis\")" - }, - { - "name": "limit", - "type": "int", - "default": 25, - "required": false, - "help": "Max repositories (1-100, single Docker Hub page)" - } - ], - "columns": [ - "rank", - "image", - "official", - "stars", - "pulls", - "description", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/dockerhub/search.js", - "sourceFile": "plugins/dockerhub/search.js" - }, - { - "site": "duckduckgo", - "name": "search", - "description": "Search DuckDuckGo", - "access": "read", - "domain": "html.duckduckgo.com", - "strategy": "public", - "browser": true, - "args": [ - { - "name": "keyword", - "type": "str", - "required": true, - "positional": true, - "help": "Search query" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of results per page (1-10). For multi-page, use --offset" - }, - { - "name": "offset", - "type": "int", - "default": 0, - "required": false, - "help": "Result offset for pagination (0, 10, 20...). Uses XHR POST internally" - }, - { - "name": "region", - "type": "str", - "required": false, - "help": "Region code (e.g. jp-jp, us-en, cn-zh). Default: all regions" - }, - { - "name": "time", - "type": "str", - "required": false, - "help": "Time range: d (day), w (week), m (month), y (year)" - } - ], - "columns": [ - "rank", - "title", - "url", - "snippet", - "displayUrl", - "icon", - "resultType" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/duckduckgo/search.js", - "sourceFile": "plugins/duckduckgo/search.js" - }, - { - "site": "duckduckgo", - "name": "suggest", - "description": "DuckDuckGo search suggestions", - "access": "read", - "domain": "duckduckgo.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "keyword", - "type": "str", - "required": true, - "positional": true, - "help": "Search query prefix" - }, - { - "name": "limit", - "type": "int", - "default": 8, - "required": false, - "help": "Max number of suggestions" - } - ], - "columns": [ - "phrase" - ], - "type": "js", - "modulePath": "plugins/duckduckgo/suggest.js", - "sourceFile": "plugins/duckduckgo/suggest.js" - }, - { - "site": "endoflife", - "name": "product", - "description": "Release cycles + EOL / LTS / support dates for one product on endoflife.date", - "access": "read", - "domain": "endoflife.date", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "product", - "type": "string", - "required": true, - "positional": true, - "help": "endoflife.date product slug (e.g. \"nodejs\", \"python\", \"ubuntu\")" - } - ], - "columns": [ - "product", - "cycle", - "releaseDate", - "latest", - "latestReleaseDate", - "lts", - "support", - "eol", - "extendedSupport", - "eolStatus", - "url" - ], - "type": "js", - "modulePath": "plugins/endoflife/product.js", - "sourceFile": "plugins/endoflife/product.js" - }, - { - "site": "facebook", - "name": "add-friend", - "description": "Send a friend request on Facebook", - "access": "write", - "domain": "www.facebook.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "username", - "type": "str", - "required": true, - "positional": true, - "help": "Facebook username or profile URL" - } - ], - "columns": [ - "status", - "username" - ], - "type": "js", - "modulePath": "plugins/facebook/add-friend.js", - "sourceFile": "plugins/facebook/add-friend.js", - "navigateBefore": "https://www.facebook.com" - }, - { - "site": "facebook", - "name": "events", - "description": "Browse Facebook event categories", - "access": "read", - "domain": "www.facebook.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 15, - "required": false, - "help": "Number of categories" - } - ], - "columns": [ - "index", - "name" - ], - "type": "js", - "modulePath": "plugins/facebook/events.js", - "sourceFile": "plugins/facebook/events.js", - "navigateBefore": "https://www.facebook.com" - }, - { - "site": "facebook", - "name": "feed", - "description": "Get your Facebook news feed", - "access": "read", - "domain": "www.facebook.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of posts" - } - ], - "columns": [ - "index", - "author", - "content", - "likes", - "comments", - "shares" - ], - "type": "js", - "modulePath": "plugins/facebook/feed.js", - "sourceFile": "plugins/facebook/feed.js", - "navigateBefore": false - }, - { - "site": "facebook", - "name": "friends", - "description": "Get Facebook friend suggestions", - "access": "read", - "domain": "www.facebook.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of friend suggestions" - } - ], - "columns": [ - "index", - "name", - "mutual" - ], - "type": "js", - "modulePath": "plugins/facebook/friends.js", - "sourceFile": "plugins/facebook/friends.js", - "navigateBefore": "https://www.facebook.com" - }, - { - "site": "facebook", - "name": "groups", - "description": "List your Facebook groups", - "access": "read", - "domain": "www.facebook.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of groups" - } - ], - "columns": [ - "index", - "name", - "last_post", - "url" - ], - "type": "js", - "modulePath": "plugins/facebook/groups.js", - "sourceFile": "plugins/facebook/groups.js", - "navigateBefore": "https://www.facebook.com" - }, - { - "site": "facebook", - "name": "join-group", - "description": "Join a Facebook group", - "access": "write", - "domain": "www.facebook.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "group", - "type": "str", - "required": true, - "positional": true, - "help": "Group ID or URL path (e.g. '1876150192925481' or group name)" - } - ], - "columns": [ - "status", - "group" - ], - "type": "js", - "modulePath": "plugins/facebook/join-group.js", - "sourceFile": "plugins/facebook/join-group.js", - "navigateBefore": "https://www.facebook.com" - }, - { - "site": "facebook", - "name": "login", - "description": "Open facebook login", - "access": "write", - "domain": "facebook.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "status", - "logged_in", - "site", - "user_id", - "vanity", - "profile_url", - "action", - "verify_command" - ], - "type": "js", - "modulePath": "plugins/facebook/auth.js", - "sourceFile": "plugins/facebook/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "facebook", - "name": "marketplace-inbox", - "description": "List recent Facebook Marketplace buyer/seller conversations", - "access": "read", - "domain": "www.facebook.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of conversations to return" - } - ], - "columns": [ - "index", - "buyer", - "listing", - "snippet", - "time", - "unread" - ], - "type": "js", - "modulePath": "plugins/facebook/marketplace-inbox.js", - "sourceFile": "plugins/facebook/marketplace-inbox.js", - "navigateBefore": "https://www.facebook.com" - }, - { - "site": "facebook", - "name": "marketplace-listings", - "description": "List your Facebook Marketplace seller listings", - "access": "read", - "domain": "www.facebook.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of listings to return" - } - ], - "columns": [ - "index", - "title", - "price", - "status", - "listed", - "clicks", - "actions" - ], - "type": "js", - "modulePath": "plugins/facebook/marketplace-listings.js", - "sourceFile": "plugins/facebook/marketplace-listings.js", - "navigateBefore": "https://www.facebook.com" - }, - { - "site": "facebook", - "name": "memories", - "description": "Get your Facebook memories (On This Day)", - "access": "read", - "domain": "www.facebook.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of memories" - } - ], - "columns": [ - "index", - "source", - "content", - "time" - ], - "type": "js", - "modulePath": "plugins/facebook/memories.js", - "sourceFile": "plugins/facebook/memories.js", - "navigateBefore": "https://www.facebook.com" - }, - { - "site": "facebook", - "name": "notifications", - "description": "Get recent Facebook notifications (includes unread / time / url / notif_id / notif_type columns)", - "access": "read", - "domain": "www.facebook.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 15, - "required": false, - "help": "Number of notifications (1-100)" - } - ], - "columns": [ - "index", - "unread", - "text", - "time", - "url", - "notif_id", - "notif_type" - ], - "type": "js", - "modulePath": "plugins/facebook/notifications.js", - "sourceFile": "plugins/facebook/notifications.js", - "navigateBefore": false - }, - { - "site": "facebook", - "name": "profile", - "description": "Get Facebook user/page profile info", - "access": "read", - "domain": "www.facebook.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "username", - "type": "str", - "required": true, - "positional": true, - "help": "Facebook username or page name" - } - ], - "columns": [ - "name", - "username", - "friends", - "followers", - "url" - ], - "type": "js", - "modulePath": "plugins/facebook/profile.js", - "sourceFile": "plugins/facebook/profile.js", - "navigateBefore": "https://www.facebook.com" - }, - { - "site": "facebook", - "name": "search", - "description": "Search Facebook for people, pages, or posts", - "access": "read", - "domain": "www.facebook.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search query" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of results" - } - ], - "columns": [ - "index", - "title", - "text", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/facebook/search.js", - "sourceFile": "plugins/facebook/search.js", - "navigateBefore": false - }, - { - "site": "facebook", - "name": "whoami", - "description": "Show the current logged-in facebook account", - "access": "read", - "domain": "facebook.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "logged_in", - "site", - "user_id", - "vanity", - "profile_url" - ], - "type": "js", - "modulePath": "plugins/facebook/auth.js", - "sourceFile": "plugins/facebook/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "flathub", - "name": "app", - "description": "Full Flathub appstream metadata for an app id (license, categories, latest release)", - "access": "read", - "domain": "flathub.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "appId", - "type": "str", - "required": true, - "positional": true, - "help": "AppStream id (e.g. \"org.mozilla.firefox\", \"org.gnome.Calculator\")" - } - ], - "columns": [ - "appId", - "name", - "summary", - "developer", - "license", - "isFreeLicense", - "isEol", - "categories", - "keywords", - "latestVersion", - "latestReleaseDate", - "homepage", - "bugtracker", - "donation", - "url" - ], - "type": "js", - "modulePath": "plugins/flathub/app.js", - "sourceFile": "plugins/flathub/app.js" - }, - { - "site": "flathub", - "name": "search", - "description": "Search Flathub apps by keyword", - "access": "read", - "domain": "flathub.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search keyword" - }, - { - "name": "limit", - "type": "int", - "default": 25, - "required": false, - "help": "Max apps (1-100)" - } - ], - "columns": [ - "rank", - "appId", - "name", - "summary", - "developer", - "license", - "isFreeLicense", - "mainCategories", - "installsLastMonth", - "updatedAt", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/flathub/search.js", - "sourceFile": "plugins/flathub/search.js" - }, - { - "site": "gemini", - "name": "ask", - "description": "Send a prompt to Gemini and return only the assistant response", - "access": "write", - "domain": "gemini.google.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "prompt", - "type": "str", - "required": true, - "positional": true, - "help": "Prompt to send" - }, - { - "name": "model", - "type": "string", - "required": false, - "help": "Gemini model to use (e.g. \"2.5-flash\"). Use \"webcmd gemini models\" to list available values." - }, - { - "name": "timeout", - "type": "int", - "default": 60, - "required": false, - "help": "Max seconds to wait (default: 60)" - }, - { - "name": "new", - "type": "str", - "default": "false", - "required": false, - "help": "Start a new chat first (true/false, default: false)" - }, - { - "name": "thinking", - "type": "str", - "default": null, - "required": false, - "help": "Thinking level: standard or extended (omitted = leave unchanged)" - } - ], - "columns": [ - "response" - ], - "defaultFormat": "plain", - "type": "js", - "modulePath": "plugins/gemini/ask.js", - "sourceFile": "plugins/gemini/ask.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "gemini", - "name": "deep-research", - "description": "Start a Gemini Deep Research run and confirm it", - "access": "write", - "domain": "gemini.google.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "prompt", - "type": "str", - "required": true, - "positional": true, - "help": "Prompt to send" - }, - { - "name": "timeout", - "type": "int", - "default": 180, - "required": false, - "help": "Max seconds for the overall command (default: 180; confirm-wait clamps internally to 6-20s)" - }, - { - "name": "tool", - "type": "str", - "required": false, - "help": "Override tool label (default: Deep Research)" - }, - { - "name": "confirm", - "type": "str", - "required": false, - "help": "Override confirm button label (default: Start research)" - } - ], - "columns": [ - "status", - "url" - ], - "tags": [ - "search" - ], - "defaultFormat": "plain", - "type": "js", - "modulePath": "plugins/gemini/deep-research.js", - "sourceFile": "plugins/gemini/deep-research.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "gemini", - "name": "deep-research-result", - "description": "Export Deep Research report URL from a Gemini conversation", - "access": "read", - "domain": "gemini.google.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "query", - "type": "str", - "required": false, - "positional": true, - "help": "Conversation title or URL (optional; defaults to latest conversation)" - }, - { - "name": "match", - "type": "str", - "default": "contains", - "required": false, - "help": "Match mode", - "choices": [ - "contains", - "exact" - ] - }, - { - "name": "timeout", - "type": "int", - "default": 120, - "required": false, - "help": "Max seconds to wait for Docs export (default: 120)" - } - ], - "columns": [ - "response" - ], - "tags": [ - "search" - ], - "defaultFormat": "plain", - "type": "js", - "modulePath": "plugins/gemini/deep-research-result.js", - "sourceFile": "plugins/gemini/deep-research-result.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "gemini", - "name": "detail", - "description": "Open a Gemini web conversation by id, URL, or sidebar title and read its turns", - "access": "read", - "domain": "gemini.google.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "id", - "type": "str", - "required": true, - "positional": true, - "help": "Conversation id, /app/ URL, or sidebar title" - } - ], - "columns": [ - "Index", - "Role", - "Text" - ], - "type": "js", - "modulePath": "plugins/gemini/detail.js", - "sourceFile": "plugins/gemini/detail.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "gemini", - "name": "history", - "description": "List visible Gemini web conversation history from the sidebar", - "access": "read", - "domain": "gemini.google.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max conversations to show" - } - ], - "columns": [ - "Index", - "Id", - "Title", - "Url" - ], - "type": "js", - "modulePath": "plugins/gemini/history.js", - "sourceFile": "plugins/gemini/history.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "gemini", - "name": "image", - "description": "Generate images with Gemini web and save them locally", - "access": "write", - "domain": "gemini.google.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "prompt", - "type": "str", - "required": true, - "positional": true, - "help": "Image prompt to send to Gemini" - }, - { - "name": "rt", - "type": "str", - "default": "1:1", - "required": false, - "help": "Ratio shorthand for aspect ratio (1:1, 16:9, 9:16, 4:3, 3:4, 3:2, 2:3)" - }, - { - "name": "st", - "type": "str", - "default": "", - "required": false, - "help": "Style shorthand, e.g. anime, icon, watercolor" - }, - { - "name": "op", - "type": "str", - "default": "~/tmp/gemini-images", - "required": false, - "help": "Output directory shorthand", - "file": { - "direction": "output", - "pathKind": "directory", - "multiple": false - } - }, - { - "name": "sd", - "type": "boolean", - "default": false, - "required": false, - "help": "Skip download shorthand; only show Gemini page link" - }, - { - "name": "timeout", - "type": "int", - "default": 240, - "required": false, - "help": "Max seconds for the overall command (default: 240)" - } - ], - "columns": [ - "status", - "file", - "link" - ], - "defaultFormat": "plain", - "type": "js", - "modulePath": "plugins/gemini/image.js", - "sourceFile": "plugins/gemini/image.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "gemini", - "name": "login", - "description": "Open gemini login", - "access": "write", - "domain": "gemini.google.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "status", - "logged_in", - "site", - "name", - "action", - "verify_command" - ], - "type": "js", - "modulePath": "plugins/gemini/auth.js", - "sourceFile": "plugins/gemini/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "gemini", - "name": "models", - "description": "List available Gemini models from the web UI", - "access": "read", - "domain": "gemini.google.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "model", - "thinkingValues" - ], - "type": "js", - "modulePath": "plugins/gemini/models.js", - "sourceFile": "plugins/gemini/models.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "gemini", - "name": "new", - "description": "Start a new conversation in Gemini web chat", - "access": "read", - "domain": "gemini.google.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "Status", - "Action" - ], - "type": "js", - "modulePath": "plugins/gemini/new.js", - "sourceFile": "plugins/gemini/new.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "gemini", - "name": "read", - "description": "Read the turns visible in the current Gemini web conversation", - "access": "read", - "domain": "gemini.google.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "Index", - "Role", - "Text" - ], - "type": "js", - "modulePath": "plugins/gemini/read.js", - "sourceFile": "plugins/gemini/read.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "gemini", - "name": "status", - "description": "Check Gemini web page availability and login state", - "access": "read", - "domain": "gemini.google.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "Status", - "Login", - "Url" - ], - "type": "js", - "modulePath": "plugins/gemini/status.js", - "sourceFile": "plugins/gemini/status.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "gemini", - "name": "whoami", - "description": "Show the current logged-in gemini account", - "access": "read", - "domain": "gemini.google.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "logged_in", - "site", - "name" - ], - "type": "js", - "modulePath": "plugins/gemini/auth.js", - "sourceFile": "plugins/gemini/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "geogebra", - "name": "add-circle", - "description": "Create a circle by center+radius or center+point", - "access": "write", - "example": "webcmd geogebra add-circle --center A --radius 3", - "domain": "www.geogebra.org", - "strategy": "public", - "browser": true, - "args": [ - { - "name": "center", - "type": "str", - "required": true, - "help": "Center point label (e.g. A)" - }, - { - "name": "radius", - "type": "str", - "required": false, - "help": "Radius value (number) or a point label on the circle" - }, - { - "name": "point", - "type": "str", - "required": false, - "help": "Alternative: a point label on the circle (use instead of --radius for Circle(center,point))" - } - ], - "columns": [ - "label", - "center", - "radius" - ], - "type": "js", - "modulePath": "plugins/geogebra/add-circle.js", - "sourceFile": "plugins/geogebra/add-circle.js", - "navigateBefore": false - }, - { - "site": "geogebra", - "name": "add-line", - "description": "Create a line through two points or a segment between two points", - "access": "write", - "example": "webcmd geogebra add-line --points A,B --type segment", - "domain": "www.geogebra.org", - "strategy": "public", - "browser": true, - "args": [ - { - "name": "points", - "type": "str", - "required": true, - "help": "Two point labels separated by comma (e.g. \"A,B\")" - }, - { - "name": "type", - "type": "str", - "default": "line", - "required": false, - "help": "Type: line, segment, or ray (default: line)", - "choices": [ - "line", - "segment", - "ray" - ] - } - ], - "columns": [ - "label", - "type", - "points" - ], - "type": "js", - "modulePath": "plugins/geogebra/add-line.js", - "sourceFile": "plugins/geogebra/add-line.js", - "navigateBefore": false - }, - { - "site": "geogebra", - "name": "add-point", - "description": "Create a point with given label and coordinates", - "access": "write", - "example": "webcmd geogebra add-point --name A --coords 1,2", - "domain": "www.geogebra.org", - "strategy": "public", - "browser": true, - "args": [ - { - "name": "name", - "type": "str", - "required": true, - "help": "Point label (e.g. A, B, P1)" - }, - { - "name": "coords", - "type": "str", - "required": true, - "help": "Coordinates as x,y (e.g. \"1,2\")" - } - ], - "columns": [ - "name", - "x", - "y" - ], - "type": "js", - "modulePath": "plugins/geogebra/add-point.js", - "sourceFile": "plugins/geogebra/add-point.js", - "navigateBefore": false - }, - { - "site": "geogebra", - "name": "add-polygon", - "description": "Create a polygon from a list of point labels", - "access": "write", - "example": "webcmd geogebra add-polygon --points A,B,C", - "domain": "www.geogebra.org", - "strategy": "public", - "browser": true, - "args": [ - { - "name": "points", - "type": "str", - "required": true, - "help": "Comma-separated point labels (e.g. \"A,B,C\" or \"A,B,C,D\")" - } - ], - "columns": [ - "label", - "vertices" - ], - "type": "js", - "modulePath": "plugins/geogebra/add-polygon.js", - "sourceFile": "plugins/geogebra/add-polygon.js", - "navigateBefore": false - }, - { - "site": "geogebra", - "name": "eval", - "description": "Execute one or more GeoGebra command strings (semicolon-separated)", - "access": "write", - "example": "webcmd geogebra eval \"A=(0,0);B=(4,0);c=Circle(A,B);d=Circle(B,A);C=Intersect(c,d,1);Polygon(A,B,C)\"", - "domain": "www.geogebra.org", - "strategy": "public", - "browser": true, - "args": [ - { - "name": "command", - "type": "str", - "required": true, - "positional": true, - "help": "GeoGebra command string (use ; to chain multiple commands)" - } - ], - "columns": [ - "command", - "result" - ], - "type": "js", - "modulePath": "plugins/geogebra/eval.js", - "sourceFile": "plugins/geogebra/eval.js", - "navigateBefore": false - }, - { - "site": "geogebra", - "name": "hexagon", - "description": "Draw a regular hexagon centered at the origin", - "access": "write", - "example": "webcmd geogebra hexagon --size 3", - "domain": "www.geogebra.org", - "strategy": "public", - "browser": true, - "args": [ - { - "name": "size", - "type": "str", - "default": "2", - "required": false, - "help": "Radius of the hexagon (default: 2)" - }, - { - "name": "output", - "type": "string", - "required": false, - "help": "", - "file": { - "direction": "output", - "pathKind": "file", - "multiple": false, - "defaultPath": "./geogebra-hexagon.png" - } - } - ], - "columns": [ - "step", - "result" - ], - "type": "js", - "modulePath": "plugins/geogebra/hexagon.js", - "sourceFile": "plugins/geogebra/hexagon.js", - "navigateBefore": false - }, - { - "site": "geogebra", - "name": "info", - "description": "Get detailed properties of a GeoGebra object", - "access": "read", - "example": "webcmd geogebra info --name A", - "domain": "www.geogebra.org", - "strategy": "public", - "browser": true, - "args": [ - { - "name": "name", - "type": "str", - "required": true, - "help": "Object label (e.g. A, c1, poly1)" - } - ], - "columns": [ - "property", - "value" - ], - "type": "js", - "modulePath": "plugins/geogebra/info.js", - "sourceFile": "plugins/geogebra/info.js", - "navigateBefore": false - }, - { - "site": "geogebra", - "name": "list", - "description": "List all geometric objects on the GeoGebra canvas", - "access": "read", - "domain": "www.geogebra.org", - "strategy": "public", - "browser": true, - "args": [ - { - "name": "type", - "type": "str", - "required": false, - "help": "Filter by object type (e.g. \"point\", \"line\", \"circle\")" - } - ], - "columns": [ - "name", - "type", - "value", - "visible" - ], - "type": "js", - "modulePath": "plugins/geogebra/list.js", - "sourceFile": "plugins/geogebra/list.js", - "navigateBefore": false - }, - { - "site": "geogebra", - "name": "triangle", - "description": "Draw an equilateral triangle from a horizontal base segment", - "access": "write", - "example": "webcmd geogebra triangle --size 4", - "domain": "www.geogebra.org", - "strategy": "public", - "browser": true, - "args": [ - { - "name": "size", - "type": "str", - "default": "2", - "required": false, - "help": "Side length of the triangle (default: 2)" - }, - { - "name": "output", - "type": "string", - "required": false, - "help": "", - "file": { - "direction": "output", - "pathKind": "file", - "multiple": false, - "defaultPath": "./geogebra-triangle.png" - } - } - ], - "columns": [ - "step", - "result" - ], - "type": "js", - "modulePath": "plugins/geogebra/triangle.js", - "sourceFile": "plugins/geogebra/triangle.js", - "navigateBefore": false - }, - { - "site": "github", - "name": "login", - "description": "Open github login", - "access": "write", - "domain": "github.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "status", - "logged_in", - "site", - "id", - "username", - "name", - "url", - "action", - "verify_command" - ], - "type": "js", - "modulePath": "plugins/github/auth.js", - "sourceFile": "plugins/github/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "github", - "name": "whoami", - "description": "Show the current logged-in github account", - "access": "read", - "domain": "github.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "logged_in", - "site", - "id", - "username", - "name", - "url" - ], - "type": "js", - "modulePath": "plugins/github/auth.js", - "sourceFile": "plugins/github/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "github-trending", - "name": "repos", - "description": "GitHub Trending repositories (public, no login). Filter by --language and --since.", - "access": "read", - "domain": "github.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "since", - "type": "string", - "default": "daily", - "required": false, - "help": "Time range: daily / weekly / monthly" - }, - { - "name": "language", - "type": "string", - "default": "", - "required": false, - "help": "Filter by programming language slug, e.g. python, rust, \"c++\"" - }, - { - "name": "limit", - "type": "int", - "default": 25, - "required": false, - "help": "Number of repositories to return (max 25)" - } - ], - "columns": [ - "rank", - "repo", - "description", - "language", - "stars", - "forks", - "starsSince", - "url" - ], - "type": "js", - "modulePath": "plugins/github-trending/repos.js", - "sourceFile": "plugins/github-trending/repos.js" - }, - { - "site": "goettingen", - "name": "export-postgraduate-courses", - "description": "Export University of Göttingen postgraduate programmes from the official A-Z API.", - "access": "read", - "example": "webcmd goettingen export-postgraduate-courses --degree-level masters --count 10 -f csv", - "domain": "www.uni-goettingen.de", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "degree-level", - "type": "string", - "default": "all", - "required": false, - "help": "all, masters, certificate, diploma, professional, or doctorate" - }, - { - "name": "count", - "type": "int", - "required": false, - "help": "Positive maximum number of programmes after filtering and deduplication" - } - ], - "columns": [ - "Course Name", - "Course URL", - "University \nname", - "Intake Month", - "Substream/\nSpecialisation", - "App fees", - "Degree Level", - "Study Level", - "Duration\n(in months)", - "Study option", - "Program Type", - "Partner", - "Tution fees \n(per year)", - "Total Tution \nFees", - "IELTS \n(Overall & Subscores)", - "ielts_reading_score", - "ielts_writing_score", - "ielts_listening_score", - "ielts_speaking_score", - "TOEFL\n(Overall & Subscores)", - "toefl_reading_score", - "toefl_writing_score", - "toefl_listening_score", - "toefl_speaking_score", - "PTE\n(Overall & Subscores)", - "pte_reading_score", - "pte_writing_score", - "pte_listening_score", - "pte_speaking_score", - "Duolingo\n(Overall & Subscores)", - "duolingo_comprehension_score", - "duolingo_literacy_score", - "duolingo_conversation_score", - "duolingo_production_score", - "Is Waiver \nProvided?", - "Waiver Info", - "Is MOI \naccepted?", - "Share list, if any", - "GRE Required", - "GMAT Required", - "GRE/GMAT Scores", - "12th scores", - "Min UG score", - "15 years of\nEducation Allowed?", - "Gap Years", - "Backlogs", - "Work \nExperience \nRequired?", - "Main Entry \nRequirements", - "Status", - "Intake status(open/close)\n(eg: Fall (september)-Open\nSpring(January)- Closed)", - "Remarks (if any)", - "Reference Links (if any)" - ], - "type": "js", - "modulePath": "plugins/goettingen/export-postgraduate-courses.js", - "sourceFile": "plugins/goettingen/export-postgraduate-courses.js" - }, - { - "site": "google", - "name": "images", - "description": "Search Google Images for photos and image results", - "access": "read", - "domain": "google.com", - "strategy": "public", - "browser": true, - "args": [ - { - "name": "keyword", - "type": "str", - "required": true, - "positional": true, - "help": "Image search query" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of image results (1-100)" - }, - { - "name": "lang", - "type": "str", - "default": "en", - "required": false, - "help": "Language short code (e.g. en, zh)" - }, - { - "name": "resolve", - "type": "bool", - "default": true, - "required": false, - "help": "Click image previews to resolve original imgurl values" - } - ], - "columns": [ - "rank", - "title", - "imageUrl", - "thumbnailUrl", - "sourceUrl", - "source", - "width", - "height" - ], - "type": "js", - "modulePath": "plugins/google/images.js", - "sourceFile": "plugins/google/images.js", - "navigateBefore": false - }, - { - "site": "google", - "name": "news", - "description": "Get Google News headlines", - "access": "read", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "keyword", - "type": "str", - "required": false, - "positional": true, - "help": "Search query (omit for top stories)" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of results" - }, - { - "name": "lang", - "type": "str", - "default": "en", - "required": false, - "help": "Language short code (e.g. en, zh)" - }, - { - "name": "region", - "type": "str", - "default": "US", - "required": false, - "help": "Region code (e.g. US, CN)" - } - ], - "columns": [ - "title", - "source", - "date", - "url" - ], - "type": "js", - "modulePath": "plugins/google/news.js", - "sourceFile": "plugins/google/news.js" - }, - { - "site": "google", - "name": "search", - "description": "Search Google", - "access": "read", - "domain": "google.com", - "strategy": "public", - "browser": true, - "args": [ - { - "name": "keyword", - "type": "str", - "required": true, - "positional": true, - "help": "Search query" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of results (1-100)" - }, - { - "name": "lang", - "type": "str", - "default": "en", - "required": false, - "help": "Language short code (e.g. en, zh)" - } - ], - "columns": [ - "type", - "title", - "url", - "snippet" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/google/search.js", - "sourceFile": "plugins/google/search.js" - }, - { - "site": "google", - "name": "suggest", - "description": "Get Google search suggestions", - "access": "read", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "keyword", - "type": "str", - "required": true, - "positional": true, - "help": "Search query" - }, - { - "name": "lang", - "type": "str", - "default": "zh-CN", - "required": false, - "help": "Language code" - } - ], - "columns": [ - "suggestion" - ], - "type": "js", - "modulePath": "plugins/google/suggest.js", - "sourceFile": "plugins/google/suggest.js" - }, - { - "site": "google", - "name": "trends", - "description": "Get Google Trends daily trending searches", - "access": "read", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "region", - "type": "str", - "default": "US", - "required": false, - "help": "Region code (e.g. US, CN, JP)" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of results" - } - ], - "columns": [ - "title", - "traffic", - "date" - ], - "type": "js", - "modulePath": "plugins/google/trends.js", - "sourceFile": "plugins/google/trends.js" - }, - { - "site": "google-scholar", - "name": "cite", - "description": "Get citation for a Google Scholar paper", - "access": "read", - "domain": "scholar.google.com", - "strategy": "public", - "browser": true, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Paper title to search for" - }, - { - "name": "style", - "type": "str", - "default": "bibtex", - "required": false, - "help": "Citation format", - "choices": [ - "bibtex", - "endnote", - "refman", - "refworks" - ] - }, - { - "name": "index", - "type": "int", - "default": 1, - "required": false, - "help": "Which search result to cite (1-based)" - } - ], - "columns": [ - "title", - "format", - "citation" - ], - "type": "js", - "modulePath": "plugins/google-scholar/cite.js", - "sourceFile": "plugins/google-scholar/cite.js" - }, - { - "site": "google-scholar", - "name": "profile", - "description": "View a Google Scholar author profile", - "access": "read", - "domain": "scholar.google.com", - "strategy": "public", - "browser": true, - "args": [ - { - "name": "author", - "type": "str", - "required": true, - "positional": true, - "help": "Author name or Scholar user ID (e.g. JicYPdAAAAAJ)" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Max papers to show (max 20)" - } - ], - "columns": [ - "rank", - "title", - "cited", - "year" - ], - "type": "js", - "modulePath": "plugins/google-scholar/profile.js", - "sourceFile": "plugins/google-scholar/profile.js" - }, - { - "site": "google-scholar", - "name": "search", - "description": "Google Scholar scholar search", - "access": "read", - "domain": "scholar.google.com", - "strategy": "public", - "browser": true, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search keyword" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of results to return (max 20)" - } - ], - "columns": [ - "rank", - "title", - "authors", - "source", - "year", - "cited", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/google-scholar/search.js", - "sourceFile": "plugins/google-scholar/search.js" - }, - { - "site": "goproxy", - "name": "module", - "description": "Latest version + VCS origin metadata for a Go module on proxy.golang.org", - "access": "read", - "domain": "proxy.golang.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "module", - "type": "string", - "required": true, - "positional": true, - "help": "Go module path (e.g. \"github.com/gin-gonic/gin\", \"golang.org/x/net\")" - } - ], - "columns": [ - "module", - "version", - "publishedAt", - "vcs", - "repository", - "commit", - "ref", - "pkgGoDevUrl", - "url" - ], - "type": "js", - "modulePath": "plugins/goproxy/module.js", - "sourceFile": "plugins/goproxy/module.js" - }, - { - "site": "goproxy", - "name": "versions", - "description": "Published version tags for a Go module (newest first), optionally with publish times", - "access": "read", - "domain": "proxy.golang.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "module", - "type": "string", - "required": true, - "positional": true, - "help": "Go module path (e.g. \"github.com/gin-gonic/gin\")" - }, - { - "name": "limit", - "type": "int", - "default": 30, - "required": false, - "help": "Max rows to return (1-200)" - }, - { - "name": "with-time", - "type": "boolean", - "default": false, - "required": false, - "help": "Fetch each version's publish time (one extra request per row)" - } - ], - "columns": [ - "rank", - "module", - "version", - "publishedAt", - "url" - ], - "type": "js", - "modulePath": "plugins/goproxy/versions.js", - "sourceFile": "plugins/goproxy/versions.js" - }, - { - "site": "grok", - "name": "ask", - "description": "Send a message to Grok and get response", - "access": "write", - "domain": "grok.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "prompt", - "type": "string", - "required": true, - "positional": true, - "help": "Prompt to send to Grok" - }, - { - "name": "timeout", - "type": "int", - "default": 120, - "required": false, - "help": "Max seconds to wait for response (default: 120)" - }, - { - "name": "new", - "type": "boolean", - "default": false, - "required": false, - "help": "Start a new chat before sending (default: false)" - } - ], - "columns": [ - "response" - ], - "type": "js", - "modulePath": "plugins/grok/ask.js", - "sourceFile": "plugins/grok/ask.js", - "navigateBefore": "https://grok.com", - "siteSession": "persistent" - }, - { - "site": "grok", - "name": "delete", - "description": "Delete a Grok conversation by ID. Grok takes effect immediately with no confirmation dialog — require --yes to actually delete.", - "access": "write", - "domain": "grok.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "id", - "type": "string", - "required": true, - "positional": true, - "help": "Conversation UUID or grok.com/c/ URL" - }, - { - "name": "yes", - "type": "boolean", - "default": false, - "required": false, - "help": "Actually delete (default is a dry-run preview)" - } - ], - "columns": [ - "status", - "id" - ], - "type": "js", - "modulePath": "plugins/grok/delete.js", - "sourceFile": "plugins/grok/delete.js", - "navigateBefore": "https://grok.com", - "siteSession": "persistent" - }, - { - "site": "grok", - "name": "detail", - "description": "Open a Grok conversation by ID and read its messages", - "access": "read", - "domain": "grok.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "id", - "type": "str", - "required": true, - "positional": true, - "help": "Session ID (UUID) or full https://grok.com/c/ URL" - }, - { - "name": "markdown", - "type": "boolean", - "default": false, - "required": false, - "help": "Emit assistant replies as markdown" - } - ], - "columns": [ - "Role", - "Text" - ], - "type": "js", - "modulePath": "plugins/grok/detail.js", - "sourceFile": "plugins/grok/detail.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "grok", - "name": "export", - "description": "Export all visible Grok conversation history metadata", - "access": "read", - "example": "webcmd grok export -f yaml", - "domain": "grok.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 0, - "required": false, - "help": "Max conversations to export; 0 means all loaded history" - }, - { - "name": "maxScrolls", - "type": "int", - "default": 80, - "required": false, - "help": "Max history-list scroll rounds when limit is 0 (max 500)" - } - ], - "columns": [ - "index", - "id", - "title", - "date", - "url" - ], - "type": "js", - "modulePath": "plugins/grok/export.js", - "sourceFile": "plugins/grok/export.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "grok", - "name": "export-all", - "description": "Export Grok conversation history and each conversation transcript", - "access": "read", - "example": "webcmd grok export-all --limit 5 -f json", - "domain": "grok.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 0, - "required": false, - "help": "Max conversations to export; 0 means all loaded history" - }, - { - "name": "offset", - "type": "int", - "default": 0, - "required": false, - "help": "Skip this many conversations before exporting" - }, - { - "name": "manifestPath", - "type": "string", - "default": "", - "required": false, - "help": "Optional grok/export JSON manifest path; skips history dialog and visits listed /c pages directly", - "file": { - "direction": "input", - "pathKind": "file", - "multiple": false, - "contentTypes": [ - "application/json" - ] - } - }, - { - "name": "maxScrolls", - "type": "int", - "default": 80, - "required": false, - "help": "Max history-list scroll rounds when limit is 0 (max 500)" - }, - { - "name": "pageScrolls", - "type": "int", - "default": 30, - "required": false, - "help": "Max per-conversation scroll-to-bottom rounds (max 200)" - }, - { - "name": "pageTimeoutMs", - "type": "int", - "default": 30000, - "required": false, - "help": "Max wait for each conversation page to show messages" - }, - { - "name": "delayMinMs", - "type": "int", - "default": 0, - "required": false, - "help": "Minimum polite delay after a conversation page loads" - }, - { - "name": "delayMaxMs", - "type": "int", - "default": 5000, - "required": false, - "help": "Maximum polite delay after a conversation page loads" - } - ], - "columns": [ - "index", - "id", - "title", - "date", - "url", - "status", - "messageCount", - "error", - "messagesJson" - ], - "type": "js", - "modulePath": "plugins/grok/export-all.js", - "sourceFile": "plugins/grok/export-all.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "grok", - "name": "history", - "description": "List recent Grok conversations from the sidebar (requires login)", - "access": "read", - "domain": "grok.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max conversations to show (default 20, max 100)" - } - ], - "columns": [ - "Index", - "Title", - "Url" - ], - "type": "js", - "modulePath": "plugins/grok/history.js", - "sourceFile": "plugins/grok/history.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "grok", - "name": "image", - "description": "Generate images on grok.com and return image URLs", - "access": "write", - "domain": "grok.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "prompt", - "type": "string", - "required": true, - "positional": true, - "help": "Image generation prompt" - }, - { - "name": "timeout", - "type": "int", - "default": 240, - "required": false, - "help": "Max seconds to wait for the image (default: 240)" - }, - { - "name": "new", - "type": "boolean", - "default": false, - "required": false, - "help": "Start a new chat before sending (default: false)" - }, - { - "name": "count", - "type": "int", - "default": 1, - "required": false, - "help": "Minimum images to wait for before returning (default: 1)" - }, - { - "name": "out", - "type": "string", - "default": "", - "required": false, - "help": "Directory to save downloaded images (uses browser session to bypass auth)", - "file": { - "direction": "output", - "pathKind": "directory", - "multiple": false, - "defaultPath": "./grok-images" - } - } - ], - "columns": [ - "url", - "width", - "height", - "path" - ], - "type": "js", - "modulePath": "plugins/grok/image.js", - "sourceFile": "plugins/grok/image.js", - "navigateBefore": "https://grok.com", - "siteSession": "persistent" - }, - { - "site": "grok", - "name": "login", - "description": "Open grok login", - "access": "write", - "domain": "grok.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "status", - "logged_in", - "site", - "user_id", - "name", - "action", - "verify_command" - ], - "type": "js", - "modulePath": "plugins/grok/auth.js", - "sourceFile": "plugins/grok/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "grok", - "name": "new", - "description": "Start a new conversation in Grok", - "access": "write", - "domain": "grok.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "Status" - ], - "type": "js", - "modulePath": "plugins/grok/new.js", - "sourceFile": "plugins/grok/new.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "grok", - "name": "pin", - "description": "Pin a Grok conversation by ID", - "access": "write", - "domain": "grok.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "id", - "type": "string", - "required": true, - "positional": true, - "help": "Conversation UUID or grok.com/c/ URL" - } - ], - "columns": [ - "status", - "id" - ], - "type": "js", - "modulePath": "plugins/grok/pin.js", - "sourceFile": "plugins/grok/pin.js", - "navigateBefore": "https://grok.com", - "siteSession": "persistent" - }, - { - "site": "grok", - "name": "read", - "description": "Read messages in the current Grok conversation", - "access": "read", - "domain": "grok.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "markdown", - "type": "boolean", - "default": false, - "required": false, - "help": "Emit assistant replies as markdown" - } - ], - "columns": [ - "Role", - "Text" - ], - "type": "js", - "modulePath": "plugins/grok/read.js", - "sourceFile": "plugins/grok/read.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "grok", - "name": "send", - "description": "Fire-and-forget: send a prompt to Grok without waiting for the reply", - "access": "write", - "domain": "grok.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "prompt", - "type": "str", - "required": true, - "positional": true, - "help": "Prompt to send to Grok" - }, - { - "name": "new", - "type": "boolean", - "default": false, - "required": false, - "help": "Start a new chat before sending" - } - ], - "columns": [ - "Status", - "Prompt" - ], - "type": "js", - "modulePath": "plugins/grok/send.js", - "sourceFile": "plugins/grok/send.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "grok", - "name": "status", - "description": "Check Grok page availability, login state, current session and model", - "access": "read", - "domain": "grok.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "Status", - "Login", - "Model", - "SessionId", - "Url" - ], - "type": "js", - "modulePath": "plugins/grok/status.js", - "sourceFile": "plugins/grok/status.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "grok", - "name": "unpin", - "description": "Unpin a Grok conversation by ID", - "access": "write", - "domain": "grok.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "id", - "type": "string", - "required": true, - "positional": true, - "help": "Conversation UUID or grok.com/c/ URL" - } - ], - "columns": [ - "status", - "id" - ], - "type": "js", - "modulePath": "plugins/grok/pin.js", - "sourceFile": "plugins/grok/pin.js", - "navigateBefore": "https://grok.com", - "siteSession": "persistent" - }, - { - "site": "grok", - "name": "whoami", - "description": "Show the current logged-in grok account", - "access": "read", - "domain": "grok.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "logged_in", - "site", - "user_id", - "name" - ], - "type": "js", - "modulePath": "plugins/grok/auth.js", - "sourceFile": "plugins/grok/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "hackernews", - "name": "ask", - "description": "Hacker News Ask HN posts", - "access": "read", - "domain": "news.ycombinator.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of stories" - } - ], - "columns": [ - "rank", - "id", - "title", - "score", - "author", - "comments", - "url" - ], - "type": "js", - "modulePath": "plugins/hackernews/ask.js", - "sourceFile": "plugins/hackernews/ask.js" - }, - { - "site": "hackernews", - "name": "best", - "description": "Hacker News best stories", - "access": "read", - "domain": "news.ycombinator.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of stories" - } - ], - "columns": [ - "rank", - "id", - "title", - "score", - "author", - "comments", - "url" - ], - "type": "js", - "modulePath": "plugins/hackernews/best.js", - "sourceFile": "plugins/hackernews/best.js" - }, - { - "site": "hackernews", - "name": "jobs", - "description": "Hacker News job postings", - "access": "read", - "domain": "news.ycombinator.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of job postings" - } - ], - "columns": [ - "rank", - "id", - "title", - "author", - "url" - ], - "type": "js", - "modulePath": "plugins/hackernews/jobs.js", - "sourceFile": "plugins/hackernews/jobs.js" - }, - { - "site": "hackernews", - "name": "new", - "description": "Hacker News newest stories", - "access": "read", - "domain": "news.ycombinator.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of stories" - } - ], - "columns": [ - "rank", - "id", - "title", - "score", - "author", - "comments", - "url" - ], - "type": "js", - "modulePath": "plugins/hackernews/new.js", - "sourceFile": "plugins/hackernews/new.js" - }, - { - "site": "hackernews", - "name": "read", - "description": "Read a Hacker News story and its comment tree", - "access": "read", - "domain": "news.ycombinator.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "id", - "type": "str", - "required": true, - "positional": true, - "help": "HN item ID (e.g. 39847301)" - }, - { - "name": "limit", - "type": "int", - "default": 25, - "required": false, - "help": "Max top-level comments" - }, - { - "name": "depth", - "type": "int", - "default": 2, - "required": false, - "help": "Max reply depth (1=no replies, 2=one level of replies, etc.)" - }, - { - "name": "replies", - "type": "int", - "default": 5, - "required": false, - "help": "Max replies shown per comment at each level" - }, - { - "name": "max-length", - "type": "int", - "default": 2000, - "required": false, - "help": "Max characters per comment body (min 100)" - } - ], - "columns": [ - "type", - "author", - "score", - "text" - ], - "type": "js", - "modulePath": "plugins/hackernews/read.js", - "sourceFile": "plugins/hackernews/read.js" - }, - { - "site": "hackernews", - "name": "search", - "description": "Search Hacker News stories", - "access": "read", - "domain": "news.ycombinator.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search query" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of results" - }, - { - "name": "sort", - "type": "str", - "default": "relevance", - "required": false, - "help": "Sort by relevance or date", - "choices": [ - "relevance", - "date" - ] - } - ], - "columns": [ - "rank", - "id", - "title", - "score", - "author", - "comments", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/hackernews/search.js", - "sourceFile": "plugins/hackernews/search.js" - }, - { - "site": "hackernews", - "name": "show", - "description": "Hacker News Show HN posts", - "access": "read", - "domain": "news.ycombinator.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of stories" - } - ], - "columns": [ - "rank", - "id", - "title", - "score", - "author", - "comments", - "url" - ], - "type": "js", - "modulePath": "plugins/hackernews/show.js", - "sourceFile": "plugins/hackernews/show.js" - }, - { - "site": "hackernews", - "name": "top", - "description": "Hacker News top stories", - "access": "read", - "domain": "news.ycombinator.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of stories" - } - ], - "columns": [ - "rank", - "id", - "title", - "score", - "author", - "comments", - "url" - ], - "type": "js", - "modulePath": "plugins/hackernews/top.js", - "sourceFile": "plugins/hackernews/top.js" - }, - { - "site": "hackernews", - "name": "user", - "description": "Hacker News user profile", - "access": "read", - "domain": "news.ycombinator.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "username", - "type": "str", - "required": true, - "positional": true, - "help": "HN username" - } - ], - "columns": [ - "username", - "karma", - "created", - "about" - ], - "type": "js", - "modulePath": "plugins/hackernews/user.js", - "sourceFile": "plugins/hackernews/user.js" - }, - { - "site": "heidelberg", - "name": "export-postgraduate-courses", - "description": "Export Heidelberg University non-bachelor/postgraduate programs using the official study finder.", - "access": "read", - "example": "webcmd heidelberg export-postgraduate-courses --degree-level masters --count 10 -f csv", - "domain": "www.uni-heidelberg.de", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "degree-level", - "type": "string", - "default": "all", - "required": false, - "help": "all, masters, certificate, diploma, professional, or doctorate" - }, - { - "name": "count", - "type": "int", - "required": false, - "help": "Positive maximum number of programs after filtering and deduplication" - } - ], - "columns": [ - "Course Name", - "Course URL", - "University \nname", - "Intake Month", - "Substream/\nSpecialisation", - "App fees", - "Degree Level", - "Study Level", - "Duration\n(in months)", - "Study option", - "Program Type", - "Partner", - "Tution fees \n(per year)", - "Total Tution \nFees", - "IELTS \n(Overall & Subscores)", - "ielts_reading_score", - "ielts_writing_score", - "ielts_listening_score", - "ielts_speaking_score", - "TOEFL\n(Overall & Subscores)", - "toefl_reading_score", - "toefl_writing_score", - "toefl_listening_score", - "toefl_speaking_score", - "PTE\n(Overall & Subscores)", - "pte_reading_score", - "pte_writing_score", - "pte_listening_score", - "pte_speaking_score", - "Duolingo\n(Overall & Subscores)", - "duolingo_comprehension_score", - "duolingo_literacy_score", - "duolingo_conversation_score", - "duolingo_production_score", - "Is Waiver \nProvided?", - "Waiver Info", - "Is MOI \naccepted?", - "Share list, if any", - "GRE Required", - "GMAT Required", - "GRE/GMAT Scores", - "12th scores", - "Min UG score", - "15 years of\nEducation Allowed?", - "Gap Years", - "Backlogs", - "Work \nExperience \nRequired?", - "Main Entry \nRequirements", - "Status", - "Intake status(open/close)\n(eg: Fall (september)-Open\nSpring(January)- Closed)", - "Remarks (if any)", - "Reference Links (if any)" - ], - "type": "js", - "modulePath": "plugins/heidelberg/export-postgraduate-courses.js", - "sourceFile": "plugins/heidelberg/export-postgraduate-courses.js" - }, - { - "site": "hf", - "name": "datasets", - "description": "Top Hugging Face datasets (downloads / likes / trending / freshness).", - "access": "read", - "domain": "huggingface.co", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "sort", - "type": "string", - "default": "downloads", - "required": false, - "help": "Sort key: downloads, likes, trending, created_at, last_modified" - }, - { - "name": "search", - "type": "string", - "required": false, - "help": "Optional name/owner substring filter." - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max datasets (max 100; one API page)." - } - ], - "columns": [ - "rank", - "id", - "author", - "downloads", - "likes", - "tags", - "lastModified", - "url" - ], - "type": "js", - "modulePath": "plugins/hf/datasets.js", - "sourceFile": "plugins/hf/datasets.js" - }, - { - "site": "hf", - "name": "login", - "description": "Open hf login", - "access": "write", - "domain": "huggingface.co", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "status", - "logged_in", - "site", - "username", - "fullname", - "type", - "action", - "verify_command" - ], - "type": "js", - "modulePath": "plugins/hf/auth.js", - "sourceFile": "plugins/hf/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "hf", - "name": "models", - "description": "Top Hugging Face models (downloads / likes / trending / freshness).", - "access": "read", - "domain": "huggingface.co", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "sort", - "type": "string", - "default": "downloads", - "required": false, - "help": "Sort key: downloads, likes, trending, created_at, last_modified" - }, - { - "name": "search", - "type": "string", - "required": false, - "help": "Optional name/owner substring filter (e.g. \"llama\", \"mistralai/\")" - }, - { - "name": "pipeline", - "type": "string", - "required": false, - "help": "Filter by pipeline tag (e.g. text-generation, image-classification)" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max models (max 100; one API page)." - } - ], - "columns": [ - "rank", - "id", - "author", - "pipelineTag", - "downloads", - "likes", - "tags", - "lastModified", - "url" - ], - "type": "js", - "modulePath": "plugins/hf/models.js", - "sourceFile": "plugins/hf/models.js" - }, - { - "site": "hf", - "name": "paper", - "description": "Hugging Face paper detail by arXiv id (full title / summary / authors / AI keywords)", - "access": "read", - "domain": "huggingface.co", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "id", - "type": "str", - "required": true, - "positional": true, - "help": "arXiv id (e.g. \"1706.03762\") — same value HF uses to mirror the paper" - } - ], - "columns": [ - "id", - "title", - "authors", - "publishedAt", - "upvotes", - "aiKeywords", - "summary", - "aiSummary", - "url" - ], - "type": "js", - "modulePath": "plugins/hf/paper.js", - "sourceFile": "plugins/hf/paper.js" - }, - { - "site": "hf", - "name": "spaces", - "description": "Top Hugging Face Spaces (likes / created_at / last_modified).", - "access": "read", - "domain": "huggingface.co", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "sort", - "type": "string", - "default": "likes", - "required": false, - "help": "Sort key: likes, created_at, last_modified" - }, - { - "name": "search", - "type": "string", - "required": false, - "help": "Optional name/owner substring filter (e.g. \"stability\", \"openai/\")" - }, - { - "name": "sdk", - "type": "string", - "required": false, - "help": "Filter by Space SDK: gradio / streamlit / docker / static" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max spaces (max 100; one API page)." - } - ], - "columns": [ - "rank", - "id", - "author", - "sdk", - "likes", - "tags", - "lastModified", - "url" - ], - "type": "js", - "modulePath": "plugins/hf/spaces.js", - "sourceFile": "plugins/hf/spaces.js" - }, - { - "site": "hf", - "name": "top", - "description": "Top upvoted Hugging Face papers", - "access": "read", - "domain": "huggingface.co", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of papers" - }, - { - "name": "all", - "type": "bool", - "default": false, - "required": false, - "help": "Return all papers (ignore limit)" - }, - { - "name": "date", - "type": "str", - "required": false, - "help": "Date (YYYY-MM-DD), defaults to most recent" - }, - { - "name": "period", - "type": "str", - "default": "daily", - "required": false, - "help": "Time period: daily, weekly, or monthly", - "choices": [ - "daily", - "weekly", - "monthly" - ] - } - ], - "columns": [ - "rank", - "id", - "title", - "upvotes", - "authors" - ], - "type": "js", - "modulePath": "plugins/hf/top.js", - "sourceFile": "plugins/hf/top.js" - }, - { - "site": "hf", - "name": "whoami", - "description": "Show the current logged-in hf account", - "access": "read", - "domain": "huggingface.co", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "logged_in", - "site", - "username", - "fullname", - "type" - ], - "type": "js", - "modulePath": "plugins/hf/auth.js", - "sourceFile": "plugins/hf/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "hft", - "name": "export-postgraduate-courses", - "description": "Export HFT Stuttgart postgraduate programs using the official public programme pages.", - "access": "read", - "example": "webcmd hft export-postgraduate-courses --degree-level masters --count 10 -f csv", - "domain": "www.hft-stuttgart.de", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "degree-level", - "type": "string", - "default": "all", - "required": false, - "help": "all, masters, certificate, diploma, professional, or doctorate" - }, - { - "name": "count", - "type": "int", - "required": false, - "help": "Positive maximum number of programs after filtering and deduplication" - } - ], - "columns": [ - "Course Name", - "Course URL", - "University \nname", - "Intake Month", - "Substream/\nSpecialisation", - "App fees", - "Degree Level", - "Study Level", - "Duration\n(in months)", - "Study option", - "Program Type", - "Partner", - "Tution fees \n(per year)", - "Total Tution \nFees", - "IELTS \n(Overall & Subscores)", - "ielts_reading_score", - "ielts_writing_score", - "ielts_listening_score", - "ielts_speaking_score", - "TOEFL\n(Overall & Subscores)", - "toefl_reading_score", - "toefl_writing_score", - "toefl_listening_score", - "toefl_speaking_score", - "PTE\n(Overall & Subscores)", - "pte_reading_score", - "pte_writing_score", - "pte_listening_score", - "pte_speaking_score", - "Duolingo\n(Overall & Subscores)", - "duolingo_comprehension_score", - "duolingo_literacy_score", - "duolingo_conversation_score", - "duolingo_production_score", - "Is Waiver \nProvided?", - "Waiver Info", - "Is MOI \naccepted?", - "Share list, if any", - "GRE Required", - "GMAT Required", - "GRE/GMAT Scores", - "12th scores", - "Min UG score", - "15 years of\nEducation Allowed?", - "Gap Years", - "Backlogs", - "Work \nExperience \nRequired?", - "Main Entry \nRequirements", - "Status", - "Intake status(open/close)\n(eg: Fall (september)-Open\nSpring(January)- Closed)", - "Remarks (if any)", - "Reference Links (if any)" - ], - "type": "js", - "modulePath": "plugins/hft/export-postgraduate-courses.js", - "sourceFile": "plugins/hft/export-postgraduate-courses.js" - }, - { - "site": "homebrew", - "name": "cask", - "description": "Fetch a Homebrew cask's metadata (version, homepage, deprecation, download URL)", - "access": "read", - "domain": "formulae.brew.sh", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "token", - "type": "str", - "required": true, - "positional": true, - "help": "Cask token (e.g. \"firefox\", \"visual-studio-code\", \"google-chrome\")" - } - ], - "columns": [ - "cask", - "tap", - "name", - "version", - "description", - "homepage", - "deprecated", - "disabled", - "download", - "url" - ], - "type": "js", - "modulePath": "plugins/homebrew/cask.js", - "sourceFile": "plugins/homebrew/cask.js" - }, - { - "site": "homebrew", - "name": "formula", - "description": "Fetch a Homebrew formula's metadata (version, license, deps, deprecation, source)", - "access": "read", - "domain": "formulae.brew.sh", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "name", - "type": "str", - "required": true, - "positional": true, - "help": "Formula name (e.g. \"wget\", \"gcc@13\", \"imagemagick\")" - } - ], - "columns": [ - "formula", - "tap", - "version", - "license", - "description", - "homepage", - "dependencies", - "deprecated", - "disabled", - "source", - "url" - ], - "type": "js", - "modulePath": "plugins/homebrew/formula.js", - "sourceFile": "plugins/homebrew/formula.js" - }, - { - "site": "homebrew", - "name": "popular", - "description": "List most-installed Homebrew formulae or casks (Homebrew's analytics ranking)", - "access": "read", - "domain": "formulae.brew.sh", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "type", - "type": "str", - "default": "formula", - "required": false, - "help": "Package type (formula / cask)" - }, - { - "name": "window", - "type": "str", - "default": "30d", - "required": false, - "help": "Time window (30d / 90d / 365d)" - }, - { - "name": "limit", - "type": "int", - "default": 30, - "required": false, - "help": "Max rows (1-500)" - } - ], - "columns": [ - "rank", - "token", - "type", - "installs", - "percent", - "window", - "url" - ], - "type": "js", - "modulePath": "plugins/homebrew/popular.js", - "sourceFile": "plugins/homebrew/popular.js" - }, - { - "site": "iit", - "name": "export-postgraduate-courses", - "description": "Export Illinois Tech postgraduate programs using official public sources.", - "access": "read", - "example": "webcmd iit export-postgraduate-courses --degree-level masters --count 10 -f csv", - "domain": "www.iit.edu", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "degree-level", - "type": "string", - "default": "all", - "required": false, - "help": "all, masters, certificate, diploma, professional, or doctorate" - }, - { - "name": "count", - "type": "int", - "required": false, - "help": "Positive maximum number of programs after filtering and deduplication" - } - ], - "columns": [ - "Course Name", - "Course URL", - "University \nname", - "Intake Month", - "Substream/\nSpecialisation", - "App fees", - "Degree Level", - "Study Level", - "Duration\n(in months)", - "Study option", - "Program Type", - "Partner", - "Tution fees \n(per year)", - "Total Tution \nFees", - "IELTS \n(Overall & Subscores)", - "ielts_reading_score", - "ielts_writing_score", - "ielts_listening_score", - "ielts_speaking_score", - "TOEFL\n(Overall & Subscores)", - "toefl_reading_score", - "toefl_writing_score", - "toefl_listening_score", - "toefl_speaking_score", - "PTE\n(Overall & Subscores)", - "pte_reading_score", - "pte_writing_score", - "pte_listening_score", - "pte_speaking_score", - "Duolingo\n(Overall & Subscores)", - "duolingo_comprehension_score", - "duolingo_literacy_score", - "duolingo_conversation_score", - "duolingo_production_score", - "Is Waiver \nProvided?", - "Waiver Info", - "Is MOI \naccepted?", - "Share list, if any", - "GRE Required", - "GMAT Required", - "GRE/GMAT Scores", - "12th scores", - "Min UG score", - "15 years of\nEducation Allowed?", - "Gap Years", - "Backlogs", - "Work \nExperience \nRequired?", - "Main Entry \nRequirements", - "Status", - "Intake status(open/close)\n(eg: Fall (september)-Open\nSpring(January)- Closed)", - "Remarks (if any)", - "Reference Links (if any)" - ], - "type": "js", - "modulePath": "plugins/iit/export-postgraduate-courses.js", - "sourceFile": "plugins/iit/export-postgraduate-courses.js" - }, - { - "site": "imdb", - "name": "person", - "description": "Get actor or director info", - "access": "read", - "domain": "www.imdb.com", - "strategy": "public", - "browser": true, - "args": [ - { - "name": "id", - "type": "str", - "required": true, - "positional": true, - "help": "IMDb person ID (nm0634240) or URL" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Max filmography entries" - } - ], - "columns": [ - "field", - "value" - ], - "type": "js", - "modulePath": "plugins/imdb/person.js", - "sourceFile": "plugins/imdb/person.js" - }, - { - "site": "imdb", - "name": "reviews", - "description": "Get user reviews for a movie or TV show", - "access": "read", - "domain": "www.imdb.com", - "strategy": "public", - "browser": true, - "args": [ - { - "name": "id", - "type": "str", - "required": true, - "positional": true, - "help": "IMDb title ID (tt1375666) or URL" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of reviews" - } - ], - "columns": [ - "rank", - "title", - "rating", - "author", - "date", - "text" - ], - "type": "js", - "modulePath": "plugins/imdb/reviews.js", - "sourceFile": "plugins/imdb/reviews.js" - }, - { - "site": "imdb", - "name": "search", - "description": "Search IMDb for movies, TV shows, and people", - "access": "read", - "domain": "www.imdb.com", - "strategy": "public", - "browser": true, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search query" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of results" - } - ], - "columns": [ - "rank", - "id", - "title", - "year", - "type", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/imdb/search.js", - "sourceFile": "plugins/imdb/search.js" - }, - { - "site": "imdb", - "name": "title", - "description": "Get movie or TV show details", - "access": "read", - "domain": "www.imdb.com", - "strategy": "public", - "browser": true, - "args": [ - { - "name": "id", - "type": "str", - "required": true, - "positional": true, - "help": "IMDb title ID (tt1375666) or URL" - } - ], - "columns": [ - "field", - "value" - ], - "type": "js", - "modulePath": "plugins/imdb/title.js", - "sourceFile": "plugins/imdb/title.js" - }, - { - "site": "imdb", - "name": "top", - "description": "IMDb Top 250 Movies", - "access": "read", - "domain": "www.imdb.com", - "strategy": "public", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of results" - } - ], - "columns": [ - "rank", - "title", - "rating", - "votes", - "genre", - "url" - ], - "type": "js", - "modulePath": "plugins/imdb/top.js", - "sourceFile": "plugins/imdb/top.js" - }, - { - "site": "imdb", - "name": "trending", - "description": "IMDb Most Popular Movies", - "access": "read", - "domain": "www.imdb.com", - "strategy": "public", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of results" - } - ], - "columns": [ - "rank", - "title", - "rating", - "genre", - "url" - ], - "type": "js", - "modulePath": "plugins/imdb/trending.js", - "sourceFile": "plugins/imdb/trending.js" - }, - { - "site": "indeed", - "name": "job", - "aliases": [ - "detail", - "view" - ], - "description": "Read the full Indeed job posting by jk (job key)", - "access": "read", - "domain": "www.indeed.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "id", - "type": "str", - "required": true, - "positional": true, - "help": "Job key (16-char hex from `indeed search`, e.g. \"dccc07ac5a6a3683\")" - } - ], - "columns": [ - "id", - "title", - "company", - "location", - "salary", - "job_type", - "description", - "url" - ], - "type": "js", - "modulePath": "plugins/indeed/job.js", - "sourceFile": "plugins/indeed/job.js", - "navigateBefore": false - }, - { - "site": "indeed", - "name": "search", - "description": "Indeed keyword job search (rendered DOM via browser session, US site)", - "access": "read", - "domain": "www.indeed.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Job keyword (title / skill / company)" - }, - { - "name": "location", - "type": "string", - "default": "", - "required": false, - "help": "Location filter (e.g. \"remote\", \"New York, NY\", \"San Francisco\")" - }, - { - "name": "fromage", - "type": "string", - "default": "", - "required": false, - "help": "Recency filter, days back: 1 / 3 / 7 / 14" - }, - { - "name": "sort", - "type": "string", - "default": "relevance", - "required": false, - "help": "Sort order: relevance | date" - }, - { - "name": "start", - "type": "int", - "default": 0, - "required": false, - "help": "Pagination offset (multiple of 10, 0-based)" - }, - { - "name": "limit", - "type": "int", - "default": 15, - "required": false, - "help": "Max rows to return (1-25, capped at one page)" - } - ], - "columns": [ - "rank", - "id", - "title", - "company", - "location", - "salary", - "tags", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/indeed/search.js", - "sourceFile": "plugins/indeed/search.js", - "navigateBefore": false - }, - { - "site": "instagram", - "name": "collection-create", - "description": "Create a new Instagram saved-posts collection (folder)", - "access": "write", - "domain": "www.instagram.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "name", - "type": "str", - "required": true, - "positional": true, - "help": "Name of the collection to create" - } - ], - "columns": [ - "status", - "collectionId", - "collectionName", - "mediaCount" - ], - "type": "js", - "modulePath": "plugins/instagram/collection-create.js", - "sourceFile": "plugins/instagram/collection-create.js", - "navigateBefore": "https://www.instagram.com" - }, - { - "site": "instagram", - "name": "collection-delete", - "description": "Delete an Instagram saved-posts collection (folder) by name or id", - "access": "write", - "domain": "www.instagram.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "target", - "type": "str", - "required": true, - "positional": true, - "help": "Collection name (case-insensitive) or numeric collection_id" - } - ], - "columns": [ - "status", - "collectionId", - "collectionName" - ], - "type": "js", - "modulePath": "plugins/instagram/collection-delete.js", - "sourceFile": "plugins/instagram/collection-delete.js", - "navigateBefore": "https://www.instagram.com" - }, - { - "site": "instagram", - "name": "comment", - "description": "Comment on an Instagram post", - "access": "write", - "domain": "www.instagram.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "username", - "type": "str", - "required": true, - "positional": true, - "help": "Username of the post author" - }, - { - "name": "text", - "type": "str", - "required": true, - "positional": true, - "help": "Comment text" - }, - { - "name": "index", - "type": "int", - "default": 1, - "required": false, - "help": "Post index (1 = most recent)" - } - ], - "columns": [ - "status", - "user", - "text" - ], - "type": "js", - "modulePath": "plugins/instagram/comment.js", - "sourceFile": "plugins/instagram/comment.js", - "navigateBefore": "https://www.instagram.com" - }, - { - "site": "instagram", - "name": "download", - "description": "Download images and videos from Instagram posts and reels", - "access": "read", - "domain": "www.instagram.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "url", - "type": "str", - "required": true, - "positional": true, - "help": "Instagram post / reel / tv URL" - }, - { - "name": "path", - "type": "str", - "default": "~/Downloads/Instagram", - "required": false, - "help": "Download directory", - "file": { - "direction": "output", - "pathKind": "directory", - "multiple": false - } - } - ], - "type": "js", - "modulePath": "plugins/instagram/download.js", - "sourceFile": "plugins/instagram/download.js", - "navigateBefore": false - }, - { - "site": "instagram", - "name": "explore", - "description": "Instagram explore/discover trending posts", - "access": "read", - "domain": "www.instagram.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of posts" - } - ], - "columns": [ - "rank", - "user", - "caption", - "likes", - "comments", - "type" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/instagram/explore.js", - "sourceFile": "plugins/instagram/explore.js", - "navigateBefore": "https://www.instagram.com" - }, - { - "site": "instagram", - "name": "follow", - "description": "Follow an Instagram user", - "access": "write", - "domain": "www.instagram.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "username", - "type": "str", - "required": true, - "positional": true, - "help": "Instagram username to follow" - } - ], - "columns": [ - "status", - "username" - ], - "type": "js", - "modulePath": "plugins/instagram/follow.js", - "sourceFile": "plugins/instagram/follow.js", - "navigateBefore": "https://www.instagram.com" - }, - { - "site": "instagram", - "name": "followers", - "description": "List followers of an Instagram user", - "access": "read", - "domain": "www.instagram.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "username", - "type": "str", - "required": true, - "positional": true, - "help": "Instagram username" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of followers" - } - ], - "columns": [ - "rank", - "username", - "name", - "verified", - "private" - ], - "type": "js", - "modulePath": "plugins/instagram/followers.js", - "sourceFile": "plugins/instagram/followers.js", - "navigateBefore": "https://www.instagram.com" - }, - { - "site": "instagram", - "name": "following", - "description": "List accounts an Instagram user is following", - "access": "read", - "domain": "www.instagram.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "username", - "type": "str", - "required": true, - "positional": true, - "help": "Instagram username" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of accounts" - } - ], - "columns": [ - "rank", - "username", - "name", - "verified", - "private" - ], - "type": "js", - "modulePath": "plugins/instagram/following.js", - "sourceFile": "plugins/instagram/following.js", - "navigateBefore": "https://www.instagram.com" - }, - { - "site": "instagram", - "name": "like", - "description": "Like an Instagram post", - "access": "write", - "domain": "www.instagram.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "username", - "type": "str", - "required": true, - "positional": true, - "help": "Username of the post author" - }, - { - "name": "index", - "type": "int", - "default": 1, - "required": false, - "help": "Post index (1 = most recent)" - } - ], - "columns": [ - "status", - "user", - "post" - ], - "type": "js", - "modulePath": "plugins/instagram/like.js", - "sourceFile": "plugins/instagram/like.js", - "navigateBefore": "https://www.instagram.com" - }, - { - "site": "instagram", - "name": "login", - "description": "Open instagram login", - "access": "write", - "domain": "instagram.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "status", - "logged_in", - "site", - "user_id", - "username", - "full_name", - "action", - "verify_command" - ], - "type": "js", - "modulePath": "plugins/instagram/auth.js", - "sourceFile": "plugins/instagram/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "instagram", - "name": "note", - "description": "Publish a text Instagram note", - "access": "write", - "domain": "www.instagram.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "content", - "type": "str", - "required": true, - "positional": true, - "help": "Note text (max 60 characters)" - }, - { - "name": "timeout", - "type": "int", - "default": 120, - "required": false, - "help": "Max seconds for the overall command (default: 120)" - } - ], - "columns": [ - "status", - "detail", - "noteId" - ], - "type": "js", - "modulePath": "plugins/instagram/note.js", - "sourceFile": "plugins/instagram/note.js", - "navigateBefore": true - }, - { - "site": "instagram", - "name": "post", - "description": "Post an Instagram feed image or mixed-media carousel", - "access": "write", - "domain": "www.instagram.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "media", - "type": "str", - "required": false, - "valueRequired": true, - "help": "Comma-separated media paths (images/videos, up to 10)", - "file": { - "direction": "input", - "pathKind": "file", - "multiple": true, - "separator": ",", - "contentTypes": [ - "image/jpeg", - "image/png", - "image/webp", - "video/mp4" - ], - "maxBytes": 262144000 - } - }, - { - "name": "content", - "type": "str", - "required": false, - "positional": true, - "help": "Caption text" - }, - { - "name": "timeout", - "type": "int", - "default": 300, - "required": false, - "help": "Max seconds for the overall command (default: 300)" - } - ], - "columns": [ - "status", - "detail", - "url" - ], - "type": "js", - "modulePath": "plugins/instagram/post.js", - "sourceFile": "plugins/instagram/post.js", - "navigateBefore": true - }, - { - "site": "instagram", - "name": "profile", - "description": "Get Instagram user profile info", - "access": "read", - "domain": "www.instagram.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "username", - "type": "str", - "required": true, - "positional": true, - "help": "Instagram username" - } - ], - "columns": [ - "username", - "name", - "followers", - "following", - "posts", - "verified", - "bio" - ], - "type": "js", - "modulePath": "plugins/instagram/profile.js", - "sourceFile": "plugins/instagram/profile.js", - "navigateBefore": "https://www.instagram.com" - }, - { - "site": "instagram", - "name": "reel", - "description": "Post an Instagram reel video", - "access": "write", - "domain": "www.instagram.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "video", - "type": "str", - "required": false, - "valueRequired": true, - "help": "Path to a single .mp4 video file", - "file": { - "direction": "input", - "pathKind": "file", - "multiple": false, - "contentTypes": [ - "video/mp4" - ], - "maxBytes": 262144000 - } - }, - { - "name": "content", - "type": "str", - "required": false, - "positional": true, - "help": "Caption text" - }, - { - "name": "timeout", - "type": "int", - "default": 600, - "required": false, - "help": "Max seconds for the overall command (default: 600)" - } - ], - "columns": [ - "status", - "detail", - "url" - ], - "type": "js", - "modulePath": "plugins/instagram/reel.js", - "sourceFile": "plugins/instagram/reel.js", - "navigateBefore": true - }, - { - "site": "instagram", - "name": "save", - "description": "Save (bookmark) an Instagram post", - "access": "write", - "domain": "www.instagram.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "username", - "type": "str", - "required": true, - "positional": true, - "help": "Username of the post author" - }, - { - "name": "index", - "type": "int", - "default": 1, - "required": false, - "help": "Post index (1 = most recent)" - } - ], - "columns": [ - "status", - "user", - "post" - ], - "type": "js", - "modulePath": "plugins/instagram/save.js", - "sourceFile": "plugins/instagram/save.js", - "navigateBefore": "https://www.instagram.com" - }, - { - "site": "instagram", - "name": "saved", - "description": "Get your saved Instagram posts (optionally from a specific collection)", - "access": "read", - "domain": "www.instagram.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of saved posts" - }, - { - "name": "collection", - "type": "str", - "required": false, - "help": "Collection name (case-insensitive). Omit for the default \"All posts\" feed." - } - ], - "columns": [ - "index", - "user", - "caption", - "likes", - "comments", - "type" - ], - "type": "js", - "modulePath": "plugins/instagram/saved.js", - "sourceFile": "plugins/instagram/saved.js", - "navigateBefore": "https://www.instagram.com" - }, - { - "site": "instagram", - "name": "search", - "description": "Search Instagram users", - "access": "read", - "domain": "www.instagram.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search query" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of results" - } - ], - "columns": [ - "rank", - "username", - "name", - "verified", - "private", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/instagram/search.js", - "sourceFile": "plugins/instagram/search.js", - "navigateBefore": "https://www.instagram.com" - }, - { - "site": "instagram", - "name": "story", - "description": "Post a single Instagram story image or video", - "access": "write", - "domain": "www.instagram.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "media", - "type": "str", - "required": false, - "valueRequired": true, - "help": "Path to a single story image or video file", - "file": { - "direction": "input", - "pathKind": "file", - "multiple": false, - "contentTypes": [ - "image/jpeg", - "image/png", - "video/mp4" - ], - "maxBytes": 262144000 - } - }, - { - "name": "timeout", - "type": "int", - "default": 300, - "required": false, - "help": "Max seconds for the overall command (default: 300)" - } - ], - "columns": [ - "status", - "detail", - "url" - ], - "type": "js", - "modulePath": "plugins/instagram/story.js", - "sourceFile": "plugins/instagram/story.js", - "navigateBefore": true - }, - { - "site": "instagram", - "name": "unfollow", - "description": "Unfollow an Instagram user", - "access": "write", - "domain": "www.instagram.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "username", - "type": "str", - "required": true, - "positional": true, - "help": "Instagram username to unfollow" - } - ], - "columns": [ - "status", - "username" - ], - "type": "js", - "modulePath": "plugins/instagram/unfollow.js", - "sourceFile": "plugins/instagram/unfollow.js", - "navigateBefore": "https://www.instagram.com" - }, - { - "site": "instagram", - "name": "unlike", - "description": "Unlike an Instagram post", - "access": "write", - "domain": "www.instagram.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "username", - "type": "str", - "required": true, - "positional": true, - "help": "Username of the post author" - }, - { - "name": "index", - "type": "int", - "default": 1, - "required": false, - "help": "Post index (1 = most recent)" - } - ], - "columns": [ - "status", - "user", - "post" - ], - "type": "js", - "modulePath": "plugins/instagram/unlike.js", - "sourceFile": "plugins/instagram/unlike.js", - "navigateBefore": "https://www.instagram.com" - }, - { - "site": "instagram", - "name": "unsave", - "description": "Unsave (remove bookmark) an Instagram post", - "access": "write", - "domain": "www.instagram.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "username", - "type": "str", - "required": true, - "positional": true, - "help": "Username of the post author" - }, - { - "name": "index", - "type": "int", - "default": 1, - "required": false, - "help": "Post index (1 = most recent)" - } - ], - "columns": [ - "status", - "user", - "post" - ], - "type": "js", - "modulePath": "plugins/instagram/unsave.js", - "sourceFile": "plugins/instagram/unsave.js", - "navigateBefore": "https://www.instagram.com" - }, - { - "site": "instagram", - "name": "user", - "description": "Get recent posts from an Instagram user", - "access": "read", - "domain": "www.instagram.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "username", - "type": "str", - "required": true, - "positional": true, - "help": "Instagram username" - }, - { - "name": "limit", - "type": "int", - "default": 12, - "required": false, - "help": "Number of posts" - } - ], - "columns": [ - "index", - "caption", - "likes", - "comments", - "type", - "date" - ], - "type": "js", - "modulePath": "plugins/instagram/user.js", - "sourceFile": "plugins/instagram/user.js", - "navigateBefore": "https://www.instagram.com" - }, - { - "site": "instagram", - "name": "whoami", - "description": "Show the current logged-in instagram account", - "access": "read", - "domain": "instagram.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "logged_in", - "site", - "user_id", - "username", - "full_name" - ], - "type": "js", - "modulePath": "plugins/instagram/auth.js", - "sourceFile": "plugins/instagram/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "jhu", - "name": "export-postgraduate-courses", - "description": "Export Johns Hopkins University postgraduate programs using the official Academic Catalogue.", - "access": "read", - "example": "webcmd jhu export-postgraduate-courses --degree-level masters --count 10 -f csv", - "domain": "e-catalogue.jhu.edu", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "degree-level", - "type": "string", - "default": "all", - "required": false, - "help": "all, masters, certificate, diploma, professional, or doctorate" - }, - { - "name": "count", - "type": "int", - "required": false, - "help": "Positive maximum number of programs after filtering and deduplication" - } - ], - "columns": [ - "Course Name", - "Course URL", - "University \nname", - "Intake Month", - "Substream/\nSpecialisation", - "App fees", - "Degree Level", - "Study Level", - "Duration\n(in months)", - "Study option", - "Program Type", - "Partner", - "Tution fees \n(per year)", - "Total Tution \nFees", - "IELTS \n(Overall & Subscores)", - "ielts_reading_score", - "ielts_writing_score", - "ielts_listening_score", - "ielts_speaking_score", - "TOEFL\n(Overall & Subscores)", - "toefl_reading_score", - "toefl_writing_score", - "toefl_listening_score", - "toefl_speaking_score", - "PTE\n(Overall & Subscores)", - "pte_reading_score", - "pte_writing_score", - "pte_listening_score", - "pte_speaking_score", - "Duolingo\n(Overall & Subscores)", - "duolingo_comprehension_score", - "duolingo_literacy_score", - "duolingo_conversation_score", - "duolingo_production_score", - "Is Waiver \nProvided?", - "Waiver Info", - "Is MOI \naccepted?", - "Share list, if any", - "GRE Required", - "GMAT Required", - "GRE/GMAT Scores", - "12th scores", - "Min UG score", - "15 years of\nEducation Allowed?", - "Gap Years", - "Backlogs", - "Work \nExperience \nRequired?", - "Main Entry \nRequirements", - "Status", - "Intake status(open/close)\n(eg: Fall (september)-Open\nSpring(January)- Closed)", - "Remarks (if any)", - "Reference Links (if any)" - ], - "type": "js", - "modulePath": "plugins/jhu/export-postgraduate-courses.js", - "sourceFile": "plugins/jhu/export-postgraduate-courses.js" - }, - { - "site": "jira", - "name": "attachments", - "description": "Jira issue attachment metadata", - "access": "read", - "domain": "atlassian.net", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "key", - "type": "str", - "required": true, - "positional": true, - "help": "Jira issue key, e.g. PROJ-123" - } - ], - "columns": [ - "id", - "filename", - "mimeType", - "size", - "url" - ], - "type": "js", - "modulePath": "plugins/jira/attachments.js", - "sourceFile": "plugins/jira/attachments.js" - }, - { - "site": "jira", - "name": "comments", - "description": "Jira issue comments as Markdown", - "access": "read", - "domain": "atlassian.net", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "key", - "type": "str", - "required": true, - "positional": true, - "help": "Jira issue key, e.g. PROJ-123" - }, - { - "name": "limit", - "type": "int", - "default": 50, - "required": false, - "help": "Max comments to return (1-100)" - } - ], - "columns": [ - "id", - "author", - "created", - "updated", - "markdown" - ], - "type": "js", - "modulePath": "plugins/jira/comments.js", - "sourceFile": "plugins/jira/comments.js" - }, - { - "site": "jira", - "name": "issue", - "description": "Jira issue detail normalized for agents (description, comments, attachments, links)", - "access": "read", - "domain": "atlassian.net", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "key", - "type": "str", - "required": true, - "positional": true, - "help": "Jira issue key, e.g. PROJ-123" - }, - { - "name": "comments-limit", - "type": "int", - "default": 100, - "required": false, - "help": "Max comments to include (1-100)" - } - ], - "columns": [ - "key", - "summary", - "issueType", - "status", - "priority", - "assignee", - "updated", - "url" - ], - "type": "js", - "modulePath": "plugins/jira/issue.js", - "sourceFile": "plugins/jira/issue.js" - }, - { - "site": "jira", - "name": "links", - "description": "Jira issue links", - "access": "read", - "domain": "atlassian.net", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "key", - "type": "str", - "required": true, - "positional": true, - "help": "Jira issue key, e.g. PROJ-123" - } - ], - "columns": [ - "key", - "type", - "direction" - ], - "type": "js", - "modulePath": "plugins/jira/links.js", - "sourceFile": "plugins/jira/links.js" - }, - { - "site": "jira", - "name": "search", - "description": "Search Jira issues with JQL", - "access": "read", - "domain": "atlassian.net", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "jql", - "type": "str", - "required": true, - "positional": true, - "help": "JQL query, e.g. \"project = PROJ order by updated desc\"" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max issues to return (1-100)" - } - ], - "columns": [ - "key", - "summary", - "issueType", - "status", - "priority", - "assignee", - "updated", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/jira/search.js", - "sourceFile": "plugins/jira/search.js" - }, - { - "site": "lesswrong", - "name": "comments", - "description": "Top comments on a post", - "access": "read", - "domain": "www.lesswrong.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "url-or-id", - "type": "string", - "required": true, - "positional": true, - "help": "Post URL or LessWrong post ID" - }, - { - "name": "limit", - "type": "int", - "default": 5, - "required": false, - "help": "Number of comments" - } - ], - "columns": [ - "rank", - "score", - "author", - "text" - ], - "type": "js", - "modulePath": "plugins/lesswrong/comments.js", - "sourceFile": "plugins/lesswrong/comments.js" - }, - { - "site": "lesswrong", - "name": "curated", - "description": "Curated editor's picks", - "access": "read", - "domain": "www.lesswrong.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of results" - } - ], - "columns": [ - "rank", - "title", - "author", - "karma", - "comments", - "url" - ], - "type": "js", - "modulePath": "plugins/lesswrong/curated.js", - "sourceFile": "plugins/lesswrong/curated.js" - }, - { - "site": "lesswrong", - "name": "frontpage", - "description": "Algorithmic frontpage", - "access": "read", - "domain": "www.lesswrong.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of results" - } - ], - "columns": [ - "rank", - "title", - "author", - "karma", - "comments", - "url" - ], - "type": "js", - "modulePath": "plugins/lesswrong/frontpage.js", - "sourceFile": "plugins/lesswrong/frontpage.js" - }, - { - "site": "lesswrong", - "name": "new", - "description": "Latest posts", - "access": "read", - "domain": "www.lesswrong.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of results" - } - ], - "columns": [ - "rank", - "title", - "author", - "karma", - "comments", - "url" - ], - "type": "js", - "modulePath": "plugins/lesswrong/new.js", - "sourceFile": "plugins/lesswrong/new.js" - }, - { - "site": "lesswrong", - "name": "read", - "description": "Read full post by URL or ID", - "access": "read", - "domain": "www.lesswrong.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "url-or-id", - "type": "string", - "required": true, - "positional": true, - "help": "Post URL or LessWrong post ID" - } - ], - "columns": [ - "title", - "author", - "karma", - "comments", - "tags", - "content", - "url" - ], - "type": "js", - "modulePath": "plugins/lesswrong/read.js", - "sourceFile": "plugins/lesswrong/read.js" - }, - { - "site": "lesswrong", - "name": "sequences", - "description": "List post collections", - "access": "read", - "domain": "www.lesswrong.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of results" - } - ], - "columns": [ - "rank", - "title", - "author" - ], - "type": "js", - "modulePath": "plugins/lesswrong/sequences.js", - "sourceFile": "plugins/lesswrong/sequences.js" - }, - { - "site": "lesswrong", - "name": "shortform", - "description": "Quick takes / shortform posts", - "access": "read", - "domain": "www.lesswrong.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of results" - } - ], - "columns": [ - "rank", - "title", - "author", - "karma", - "comments", - "url" - ], - "type": "js", - "modulePath": "plugins/lesswrong/shortform.js", - "sourceFile": "plugins/lesswrong/shortform.js" - }, - { - "site": "lesswrong", - "name": "tag", - "description": "Posts by tag", - "access": "read", - "domain": "www.lesswrong.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "tag", - "type": "string", - "required": true, - "positional": true, - "help": "Tag slug or name" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of results" - } - ], - "columns": [ - "rank", - "title", - "author", - "karma", - "comments", - "url" - ], - "type": "js", - "modulePath": "plugins/lesswrong/tag.js", - "sourceFile": "plugins/lesswrong/tag.js" - }, - { - "site": "lesswrong", - "name": "tags", - "description": "List popular tags", - "access": "read", - "domain": "www.lesswrong.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of results" - } - ], - "columns": [ - "rank", - "name", - "posts" - ], - "type": "js", - "modulePath": "plugins/lesswrong/tags.js", - "sourceFile": "plugins/lesswrong/tags.js" - }, - { - "site": "lesswrong", - "name": "top", - "description": "Top all-time", - "access": "read", - "domain": "www.lesswrong.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of results" - } - ], - "columns": [ - "rank", - "title", - "author", - "karma", - "comments", - "url" - ], - "type": "js", - "modulePath": "plugins/lesswrong/top.js", - "sourceFile": "plugins/lesswrong/top.js" - }, - { - "site": "lesswrong", - "name": "top-month", - "description": "Top this month", - "access": "read", - "domain": "www.lesswrong.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of results" - } - ], - "columns": [ - "rank", - "title", - "author", - "karma", - "comments", - "url" - ], - "type": "js", - "modulePath": "plugins/lesswrong/top-month.js", - "sourceFile": "plugins/lesswrong/top-month.js" - }, - { - "site": "lesswrong", - "name": "top-week", - "description": "Top this week", - "access": "read", - "domain": "www.lesswrong.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of results" - } - ], - "columns": [ - "rank", - "title", - "author", - "karma", - "comments", - "url" - ], - "type": "js", - "modulePath": "plugins/lesswrong/top-week.js", - "sourceFile": "plugins/lesswrong/top-week.js" - }, - { - "site": "lesswrong", - "name": "top-year", - "description": "Top this year", - "access": "read", - "domain": "www.lesswrong.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of results" - } - ], - "columns": [ - "rank", - "title", - "author", - "karma", - "comments", - "url" - ], - "type": "js", - "modulePath": "plugins/lesswrong/top-year.js", - "sourceFile": "plugins/lesswrong/top-year.js" - }, - { - "site": "lesswrong", - "name": "user", - "description": "User profile", - "access": "read", - "domain": "www.lesswrong.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "username", - "type": "string", - "required": true, - "positional": true, - "help": "LessWrong username or slug" - } - ], - "columns": [ - "field", - "value" - ], - "type": "js", - "modulePath": "plugins/lesswrong/user.js", - "sourceFile": "plugins/lesswrong/user.js" - }, - { - "site": "lesswrong", - "name": "user-posts", - "description": "List a user's posts", - "access": "read", - "domain": "www.lesswrong.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "username", - "type": "string", - "required": true, - "positional": true, - "help": "LessWrong username or slug" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of results" - } - ], - "columns": [ - "rank", - "title", - "karma", - "comments", - "date", - "url" - ], - "type": "js", - "modulePath": "plugins/lesswrong/user-posts.js", - "sourceFile": "plugins/lesswrong/user-posts.js" - }, - { - "site": "lichess", - "name": "top", - "description": "Top-N Lichess leaderboard for a perf type (bullet/blitz/rapid/classical/...)", - "access": "read", - "domain": "lichess.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "perf", - "type": "str", - "required": true, - "positional": true, - "help": "Perf type (bullet, blitz, rapid, classical, ultraBullet, chess960, ...)" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Top-N rows (1-200)" - } - ], - "columns": [ - "rank", - "username", - "id", - "title", - "rating", - "progress", - "patron", - "url" - ], - "type": "js", - "modulePath": "plugins/lichess/top.js", - "sourceFile": "plugins/lichess/top.js" - }, - { - "site": "lichess", - "name": "user", - "description": "Fetch a Lichess player profile by username (rating, perfs, counts)", - "access": "read", - "domain": "lichess.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "username", - "type": "str", - "required": true, - "positional": true, - "help": "Lichess username (case-insensitive)" - } - ], - "columns": [ - "username", - "id", - "title", - "patron", - "online", - "tosViolation", - "createdAt", - "seenAt", - "gamesAll", - "gamesWin", - "gamesLoss", - "gamesDraw", - "topPerfName", - "topPerfRating", - "topPerfGames", - "fideRating", - "country", - "bio", - "url" - ], - "type": "js", - "modulePath": "plugins/lichess/user.js", - "sourceFile": "plugins/lichess/user.js" - }, - { - "site": "linkedin", - "name": "company", - "description": "Read a LinkedIn company page: industry, size, HQ, founded, website, followers, and about text", - "access": "read", - "domain": "www.linkedin.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "company", - "type": "string", - "required": true, - "positional": true, - "help": "Company universal name, /company/ path, or full URL" - } - ], - "columns": [ - "name", - "industry", - "size", - "headquarters", - "founded", - "website", - "specialties", - "followers", - "about", - "url" - ], - "type": "js", - "modulePath": "plugins/linkedin/company.js", - "sourceFile": "plugins/linkedin/company.js", - "navigateBefore": "https://www.linkedin.com" - }, - { - "site": "linkedin", - "name": "connect", - "description": "Fail-closed LinkedIn connection request sender that verifies the exact profile before optionally sending a note", - "access": "write", - "domain": "www.linkedin.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "profile-url", - "type": "string", - "required": true, - "positional": true, - "help": "Exact LinkedIn profile URL to open and verify" - }, - { - "name": "expected-name", - "type": "string", - "required": true, - "help": "Expected visible profile name" - }, - { - "name": "note", - "type": "string", - "default": "", - "required": false, - "help": "Optional connection note, max 300 chars" - }, - { - "name": "send", - "type": "bool", - "default": false, - "required": false, - "help": "Actually click Send. Default is dry-run verification only." - } - ], - "columns": [ - "status", - "recipient", - "reason", - "profile_url", - "note_chars", - "connectable", - "delivery_verified", - "matched_invitation_name", - "matched_invitation_url", - "actualValue", - "blockReason", - "expectedValue", - "observedUrl", - "safety" - ], - "type": "js", - "modulePath": "plugins/linkedin/connect.js", - "sourceFile": "plugins/linkedin/connect.js", - "navigateBefore": true - }, - { - "site": "linkedin", - "name": "connections", - "description": "List your LinkedIn first-degree connections with names, headlines, and profile URLs", - "access": "read", - "domain": "www.linkedin.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of connections to return (max 500)" - } - ], - "columns": [ - "rank", - "name", - "occupation", - "public_id", - "connected_at", - "url" - ], - "type": "js", - "modulePath": "plugins/linkedin/connections.js", - "sourceFile": "plugins/linkedin/connections.js", - "navigateBefore": "https://www.linkedin.com" - }, - { - "site": "linkedin", - "name": "inbox", - "description": "List LinkedIn messaging inbox conversations and unread messages", - "access": "read", - "domain": "www.linkedin.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 40, - "required": false, - "help": "Maximum conversations to return (1-100)" - }, - { - "name": "unread-only", - "type": "bool", - "default": false, - "required": false, - "help": "Return only conversations with unread messages" - } - ], - "columns": [ - "rank", - "thread_url", - "thread_id", - "person_name", - "last_message_preview", - "unread", - "counterparty_type", - "category", - "timestamp" - ], - "type": "js", - "modulePath": "plugins/linkedin/inbox.js", - "sourceFile": "plugins/linkedin/inbox.js", - "navigateBefore": "https://www.linkedin.com" - }, - { - "site": "linkedin", - "name": "job-detail", - "description": "Read one LinkedIn job page with description, apply URL, workplace type, applicants, and company metadata", - "access": "read", - "domain": "www.linkedin.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "job-url", - "type": "string", - "required": true, - "positional": true, - "help": "Exact LinkedIn job URL, e.g. https://www.linkedin.com/jobs/view/123/" - } - ], - "columns": [ - "title", - "company", - "location", - "workplace_type", - "job_type", - "applicants", - "listed", - "apply_url", - "company_url", - "url", - "description" - ], - "type": "js", - "modulePath": "plugins/linkedin/job-detail.js", - "sourceFile": "plugins/linkedin/job-detail.js", - "navigateBefore": "https://www.linkedin.com" - }, - { - "site": "linkedin", - "name": "jobs-preferences", - "description": "Read visible LinkedIn Jobs preferences and alert settings without changing them", - "access": "read", - "domain": "www.linkedin.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "open_to_work", - "job_titles", - "locations", - "job_alerts", - "preferences_url", - "alerts_url", - "raw_preferences" - ], - "type": "js", - "modulePath": "plugins/linkedin/jobs-preferences.js", - "sourceFile": "plugins/linkedin/jobs-preferences.js", - "navigateBefore": "https://www.linkedin.com" - }, - { - "site": "linkedin", - "name": "login", - "description": "Open linkedin login", - "access": "write", - "domain": "www.linkedin.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "status", - "logged_in", - "site", - "public_id", - "plain_id", - "name", - "action", - "verify_command" - ], - "type": "js", - "modulePath": "plugins/linkedin/auth.js", - "sourceFile": "plugins/linkedin/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "linkedin", - "name": "people-search", - "description": "Search standard LinkedIn (not Sales Navigator) for people by keyword. Each invocation consumes against LinkedIn's monthly Commercial Use Limit on people search; throttle accordingly.", - "access": "read", - "domain": "www.linkedin.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "keywords", - "type": "string", - "required": true, - "positional": true, - "help": "People search keywords, e.g. \"site reliability engineer berlin\"" - }, - { - "name": "limit", - "type": "int", - "default": 5, - "required": false, - "help": "Maximum people to return (1-10); each query counts toward LinkedIn's monthly CUL" - } - ], - "columns": [ - "rank", - "name", - "headline", - "location", - "profile_url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/linkedin/people-search.js", - "sourceFile": "plugins/linkedin/people-search.js", - "navigateBefore": "https://www.linkedin.com" - }, - { - "site": "linkedin", - "name": "post-analytics", - "description": "Summarize raw visible LinkedIn post counters without custom scoring or classification", - "access": "read", - "domain": "www.linkedin.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "profile-url", - "type": "string", - "required": false, - "help": "LinkedIn /in// profile URL. Defaults to /in/me/." - }, - { - "name": "limit", - "type": "int", - "default": 30, - "required": false, - "help": "Maximum posts to summarize (1-100)" - } - ], - "columns": [ - "posts_analyzed", - "total_reactions", - "total_comments", - "total_reposts", - "total_impressions", - "posts_with_media", - "posts_with_urls", - "latest_posted_at", - "latest_reactions", - "latest_comments", - "latest_reposts", - "latest_impressions", - "latest_url" - ], - "type": "js", - "modulePath": "plugins/linkedin/post-analytics.js", - "sourceFile": "plugins/linkedin/post-analytics.js", - "navigateBefore": "https://www.linkedin.com" - }, - { - "site": "linkedin", - "name": "post-comments", - "description": "List unique commenters and reply authors from one exact LinkedIn post URL", - "access": "read", - "domain": "www.linkedin.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "post-url", - "type": "string", - "required": true, - "positional": true, - "help": "Exact LinkedIn post URL" - }, - { - "name": "limit", - "type": "int", - "required": false, - "help": "Maximum unique commenters to return; omit to fetch all" - } - ], - "columns": [ - "rank", - "name", - "headline", - "profile_url", - "comment_count", - "sample_comment", - "commented_at", - "source_post" - ], - "type": "js", - "modulePath": "plugins/linkedin/post-comments.js", - "sourceFile": "plugins/linkedin/post-comments.js", - "navigateBefore": "https://www.linkedin.com" - }, - { - "site": "linkedin", - "name": "posts", - "description": "Export visible posts from a LinkedIn profile activity page with engagement metrics", - "access": "read", - "domain": "www.linkedin.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "profile-url", - "type": "string", - "required": false, - "help": "LinkedIn /in// profile URL. Defaults to /in/me/." - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Maximum posts to return (1-100)" - } - ], - "columns": [ - "rank", - "author", - "posted_at", - "body", - "reactions", - "comments", - "reposts", - "impressions", - "media", - "media_urls", - "url", - "raw_text" - ], - "type": "js", - "modulePath": "plugins/linkedin/posts.js", - "sourceFile": "plugins/linkedin/posts.js", - "navigateBefore": "https://www.linkedin.com" - }, - { - "site": "linkedin", - "name": "profile-analytics", - "description": "Read visible LinkedIn profile dashboard metrics such as profile views, post impressions, and search appearances", - "access": "read", - "domain": "www.linkedin.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "profile-url", - "type": "string", - "required": false, - "help": "LinkedIn /in// profile URL. Defaults to /in/me/." - } - ], - "columns": [ - "profile_url", - "profile_views", - "post_impressions", - "search_appearances", - "followers", - "connections", - "raw_analytics" - ], - "type": "js", - "modulePath": "plugins/linkedin/profile-analytics.js", - "sourceFile": "plugins/linkedin/profile-analytics.js", - "navigateBefore": "https://www.linkedin.com" - }, - { - "site": "linkedin", - "name": "profile-experience", - "description": "Read visible LinkedIn profile experience entries with titles, dates, locations, skills, media, and URLs", - "access": "read", - "domain": "www.linkedin.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "profile-url", - "type": "string", - "required": false, - "help": "LinkedIn /in// profile URL. Defaults to /in/me/." - } - ], - "columns": [ - "rank", - "total_count", - "title", - "employment_type", - "company", - "date_range", - "start_date", - "end_date", - "location", - "location_type", - "description", - "skills", - "media", - "urls", - "skill_url", - "media_url", - "profile_url", - "raw_text" - ], - "type": "js", - "modulePath": "plugins/linkedin/profile-experience.js", - "sourceFile": "plugins/linkedin/profile-experience.js", - "navigateBefore": "https://www.linkedin.com" - }, - { - "site": "linkedin", - "name": "profile-projects", - "description": "Read visible LinkedIn profile projects with descriptions, dates, skills, media, and URLs", - "access": "read", - "domain": "www.linkedin.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "profile-url", - "type": "string", - "required": false, - "help": "LinkedIn /in// profile URL. Defaults to /in/me/." - } - ], - "columns": [ - "rank", - "title", - "date_range", - "associated_with", - "description", - "skills", - "media", - "urls", - "profile_url", - "raw_text" - ], - "type": "js", - "modulePath": "plugins/linkedin/profile-projects.js", - "sourceFile": "plugins/linkedin/profile-projects.js", - "navigateBefore": "https://www.linkedin.com" - }, - { - "site": "linkedin", - "name": "profile-read", - "description": "Read visible LinkedIn profile sections: headline, About, experience, education, services, and featured sections", - "access": "read", - "domain": "www.linkedin.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "profile-url", - "type": "string", - "required": false, - "help": "LinkedIn /in// profile URL. Defaults to /in/me/." - } - ], - "columns": [ - "profile_url", - "name", - "headline", - "location", - "about", - "about_character_count", - "about_skills", - "experience", - "education", - "services", - "featured" - ], - "type": "js", - "modulePath": "plugins/linkedin/profile-read.js", - "sourceFile": "plugins/linkedin/profile-read.js", - "navigateBefore": "https://www.linkedin.com" - }, - { - "site": "linkedin", - "name": "safe-send", - "description": "Fail-closed LinkedIn message sender that verifies exact thread, recipient, and latest message before filling/sending", - "access": "write", - "domain": "www.linkedin.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "thread-url", - "type": "str", - "required": true, - "help": "Exact LinkedIn messaging thread URL to open and verify" - }, - { - "name": "expected-name", - "type": "str", - "required": true, - "help": "Expected visible recipient name in the active thread header" - }, - { - "name": "message", - "type": "str", - "required": true, - "help": "Message body to send or dry-run" - }, - { - "name": "expected-last-text", - "type": "str", - "required": false, - "help": "Substring expected in the currently visible latest conversation context" - }, - { - "name": "expected-last-hash", - "type": "str", - "required": false, - "help": "SHA-256 hash of expected latest visible message text" - }, - { - "name": "send", - "type": "bool", - "default": false, - "required": false, - "help": "Actually click Send. Default is dry-run verification only." - }, - { - "name": "screenshot", - "type": "bool", - "default": false, - "required": false, - "help": "Capture a screenshot during verification" - } - ], - "columns": [ - "status", - "recipient", - "reason", - "thread_url", - "message_chars", - "screenshot" - ], - "type": "js", - "modulePath": "plugins/linkedin/safe-send.js", - "sourceFile": "plugins/linkedin/safe-send.js", - "navigateBefore": true - }, - { - "site": "linkedin", - "name": "salesnav-inbox", - "description": "List LinkedIn Sales Navigator message conversations with API pagination", - "access": "read", - "domain": "www.linkedin.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "limit", - "type": "number", - "default": 40, - "required": false, - "help": "Maximum conversations to return (1-500)" - }, - { - "name": "max-pages", - "type": "number", - "default": 30, - "required": false, - "help": "Maximum Sales Navigator API pages to fetch" - }, - { - "name": "unread-only", - "type": "bool", - "default": false, - "required": false, - "help": "Return only unread conversations" - } - ], - "columns": [ - "rank", - "thread_id", - "thread_url", - "person_name", - "last_message_snippet", - "last_activity_time", - "unread", - "unread_count", - "total_message_count", - "archived", - "participants", - "next_page_starts_at" - ], - "type": "js", - "modulePath": "plugins/linkedin/salesnav-inbox.js", - "sourceFile": "plugins/linkedin/salesnav-inbox.js", - "navigateBefore": true - }, - { - "site": "linkedin", - "name": "salesnav-message", - "description": "Send or dry-run a LinkedIn Sales Navigator InMail to a lead using the Sales Navigator messaging API", - "access": "write", - "domain": "www.linkedin.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "recipient", - "type": "string", - "required": true, - "positional": true, - "help": "Sales Navigator lead URL, LinkedIn /in/ URL from salesnav-search, or urn:li:fs_salesProfile:(...)" - }, - { - "name": "subject", - "type": "string", - "required": true, - "help": "InMail subject" - }, - { - "name": "body", - "type": "string", - "required": true, - "help": "InMail body" - }, - { - "name": "send", - "type": "bool", - "default": false, - "required": false, - "help": "Actually send the InMail. Default is dry-run validation only." - }, - { - "name": "copy-to-crm", - "type": "bool", - "default": false, - "required": false, - "help": "Set Sales Navigator copyToCrm on the message request" - } - ], - "columns": [ - "status", - "recipient", - "title", - "company", - "credits_remaining", - "credits_before", - "credits_after", - "sent_in_salesnav", - "message_chars", - "subject_chars", - "recipient_urn", - "degree", - "inmail_restriction", - "open_link" - ], - "type": "js", - "modulePath": "plugins/linkedin/salesnav-message.js", - "sourceFile": "plugins/linkedin/salesnav-message.js", - "navigateBefore": true - }, - { - "site": "linkedin", - "name": "salesnav-search", - "description": "Search LinkedIn Sales Navigator for people leads by keyword", - "access": "read", - "domain": "www.linkedin.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "keywords", - "type": "string", - "required": true, - "positional": true, - "help": "People search keywords, e.g. \"quality manager food manufacturing\"" - }, - { - "name": "limit", - "type": "number", - "default": 25, - "required": false, - "help": "Maximum leads to return (1-500, fetched 25 per request)" - } - ], - "columns": [ - "rank", - "name", - "title", - "company", - "location", - "degree", - "profile_url", - "lead_url", - "recipient_urn" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/linkedin/salesnav-search.js", - "sourceFile": "plugins/linkedin/salesnav-search.js", - "navigateBefore": true - }, - { - "site": "linkedin", - "name": "salesnav-thread", - "description": "Return full Sales Navigator message history for a thread id, Sales Navigator inbox URL, lead URL, recipient urn, or exact recipient name", - "access": "read", - "domain": "www.linkedin.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "thread-or-recipient", - "type": "string", - "required": true, - "positional": true, - "help": "Sales Navigator inbox URL/thread id, Sales Navigator lead URL, recipient urn, or exact participant name" - }, - { - "name": "limit", - "type": "number", - "default": 200, - "required": false, - "help": "Maximum messages to return (1-500)" - }, - { - "name": "max-pages", - "type": "number", - "default": 30, - "required": false, - "help": "Maximum inbox pages to scan when resolving a recipient" - } - ], - "columns": [ - "index", - "thread_id", - "thread_url", - "sender", - "text", - "timestamp", - "subject", - "message_id", - "sender_urn", - "delivered_at", - "type", - "total_message_count" - ], - "type": "js", - "modulePath": "plugins/linkedin/salesnav-thread.js", - "sourceFile": "plugins/linkedin/salesnav-thread.js", - "navigateBefore": true - }, - { - "site": "linkedin", - "name": "search", - "description": "Search LinkedIn jobs", - "access": "read", - "domain": "www.linkedin.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "query", - "type": "string", - "required": true, - "positional": true, - "help": "Job search keywords" - }, - { - "name": "location", - "type": "string", - "required": false, - "help": "Location text such as San Francisco Bay Area" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of jobs to return (max 100)" - }, - { - "name": "start", - "type": "int", - "default": 0, - "required": false, - "help": "Result offset for pagination" - }, - { - "name": "details", - "type": "bool", - "default": false, - "required": false, - "help": "Include full job description and apply URL (slower)" - }, - { - "name": "company", - "type": "string", - "required": false, - "help": "Comma-separated company names or LinkedIn company IDs" - }, - { - "name": "experience-level", - "type": "string", - "required": false, - "help": "Comma-separated: internship, entry, associate, mid-senior, director, executive" - }, - { - "name": "job-type", - "type": "string", - "required": false, - "help": "Comma-separated: full-time, part-time, contract, temporary, volunteer, internship, other" - }, - { - "name": "date-posted", - "type": "string", - "required": false, - "help": "One of: any, month, week, 24h" - }, - { - "name": "remote", - "type": "string", - "required": false, - "help": "Comma-separated: on-site, hybrid, remote" - } - ], - "columns": [ - "rank", - "title", - "company", - "location", - "listed", - "salary", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/linkedin/search.js", - "sourceFile": "plugins/linkedin/search.js", - "navigateBefore": "https://www.linkedin.com" - }, - { - "site": "linkedin", - "name": "sent-invitations", - "description": "List pending LinkedIn sent invitations for CRM reconciliation", - "access": "read", - "domain": "www.linkedin.com", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "rank", - "name", - "profile_url", - "invited_date_text" - ], - "type": "js", - "modulePath": "plugins/linkedin/sent-invitations.js", - "sourceFile": "plugins/linkedin/sent-invitations.js", - "navigateBefore": true - }, - { - "site": "linkedin", - "name": "services-read", - "description": "Read LinkedIn Services page details including services, overview, availability, pricing, and media titles/descriptions", - "access": "read", - "domain": "www.linkedin.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "profile-url", - "type": "string", - "required": false, - "help": "LinkedIn /in// profile URL. Defaults to /in/me/." - }, - { - "name": "services-url", - "type": "string", - "required": false, - "help": "LinkedIn /services/page// URL. If omitted, it is discovered from the profile." - } - ], - "columns": [ - "service_url", - "page_title", - "overview", - "availability", - "work_locations", - "pricing", - "services_provided", - "services_count", - "media", - "media_count", - "messages", - "reviews_visibility" - ], - "type": "js", - "modulePath": "plugins/linkedin/services-read.js", - "sourceFile": "plugins/linkedin/services-read.js", - "navigateBefore": "https://www.linkedin.com" - }, - { - "site": "linkedin", - "name": "thread-snapshot", - "description": "Load a LinkedIn messaging thread, scroll for available history, and return a full context snapshot", - "access": "read", - "domain": "www.linkedin.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "thread-url", - "type": "str", - "required": true, - "help": "Exact LinkedIn messaging thread URL to open and snapshot" - }, - { - "name": "max-scrolls", - "type": "number", - "default": 30, - "required": false, - "help": "Maximum upward scroll attempts to load older messages" - }, - { - "name": "json", - "type": "bool", - "default": false, - "required": false, - "help": "Return only JSON snapshot string in the snapshot_json field" - } - ], - "columns": [ - "thread_url", - "recipient", - "message_count", - "latest_text", - "snapshot_json" - ], - "type": "js", - "modulePath": "plugins/linkedin/thread-snapshot.js", - "sourceFile": "plugins/linkedin/thread-snapshot.js", - "navigateBefore": true - }, - { - "site": "linkedin", - "name": "timeline", - "description": "Read LinkedIn home timeline posts", - "access": "read", - "domain": "www.linkedin.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of posts to return (max 100)" - } - ], - "columns": [ - "rank", - "author", - "author_url", - "headline", - "text", - "posted_at", - "reactions", - "comments", - "url" - ], - "type": "js", - "modulePath": "plugins/linkedin/timeline.js", - "sourceFile": "plugins/linkedin/timeline.js", - "navigateBefore": "https://www.linkedin.com" - }, - { - "site": "linkedin", - "name": "whoami", - "description": "Show the current logged-in linkedin account", - "access": "read", - "domain": "www.linkedin.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "logged_in", - "site", - "public_id", - "plain_id", - "name" - ], - "type": "js", - "modulePath": "plugins/linkedin/auth.js", - "sourceFile": "plugins/linkedin/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "linkedin-learning", - "name": "course", - "description": "Get LinkedIn Learning course detail by slug or course URL", - "access": "read", - "domain": "www.linkedin.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "slug", - "type": "string", - "required": true, - "positional": true, - "help": "Course slug (e.g. agentic-ai-build-your-first-agentic-ai-system) or full /learning/ URL" - } - ], - "columns": [ - "title", - "slug", - "description", - "difficulty", - "duration_sec", - "videos_count", - "rating", - "rating_count", - "released", - "url" - ], - "type": "js", - "modulePath": "plugins/linkedin-learning/course.js", - "sourceFile": "plugins/linkedin-learning/course.js", - "navigateBefore": "https://www.linkedin.com" - }, - { - "site": "linkedin-learning", - "name": "login", - "description": "Open linkedin-learning login", - "access": "write", - "domain": "linkedin.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "status", - "logged_in", - "site", - "public_id", - "plain_id", - "name", - "action", - "verify_command" - ], - "type": "js", - "modulePath": "plugins/linkedin-learning/auth.js", - "sourceFile": "plugins/linkedin-learning/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "linkedin-learning", - "name": "search", - "description": "Search LinkedIn Learning courses, videos, and learning paths by keyword", - "access": "read", - "domain": "www.linkedin.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "keywords", - "type": "string", - "required": true, - "positional": true, - "help": "Search keywords, e.g. \"AI agent\"" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Maximum results to return (1-50)" - } - ], - "columns": [ - "rank", - "type", - "title", - "instructor", - "difficulty", - "duration_sec", - "rating", - "rating_count", - "viewers", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/linkedin-learning/search.js", - "sourceFile": "plugins/linkedin-learning/search.js", - "navigateBefore": "https://www.linkedin.com" - }, - { - "site": "linkedin-learning", - "name": "trending", - "description": "Browse LinkedIn Learning recommended courses across personalized carousels", - "access": "read", - "domain": "www.linkedin.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Maximum results to return (1-50)" - } - ], - "columns": [ - "rank", - "group", - "type", - "title", - "difficulty", - "viewers", - "url" - ], - "type": "js", - "modulePath": "plugins/linkedin-learning/trending.js", - "sourceFile": "plugins/linkedin-learning/trending.js", - "navigateBefore": "https://www.linkedin.com" - }, - { - "site": "linkedin-learning", - "name": "whoami", - "description": "Show the current logged-in linkedin-learning account", - "access": "read", - "domain": "linkedin.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "logged_in", - "site", - "public_id", - "plain_id", - "name" - ], - "type": "js", - "modulePath": "plugins/linkedin-learning/auth.js", - "sourceFile": "plugins/linkedin-learning/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "lobsters", - "name": "active", - "description": "Lobste.rs most active discussions", - "access": "read", - "domain": "lobste.rs", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of stories" - } - ], - "columns": [ - "rank", - "id", - "title", - "score", - "author", - "comments", - "created_at", - "tags", - "url" - ], - "type": "js", - "modulePath": "plugins/lobsters/active.js", - "sourceFile": "plugins/lobsters/active.js" - }, - { - "site": "lobsters", - "name": "domain", - "description": "Lobste.rs stories submitted from a specific domain", - "access": "read", - "domain": "lobste.rs", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "domain", - "type": "str", - "required": true, - "positional": true, - "help": "Source domain (e.g. github.com, arxiv.org, blog.cloudflare.com)" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of stories (1-25 — single page)" - } - ], - "columns": [ - "rank", - "id", - "title", - "score", - "author", - "comments", - "created_at", - "tags", - "submission_url", - "comments_url" - ], - "type": "js", - "modulePath": "plugins/lobsters/domain.js", - "sourceFile": "plugins/lobsters/domain.js" - }, - { - "site": "lobsters", - "name": "hot", - "description": "Lobste.rs hottest stories", - "access": "read", - "domain": "lobste.rs", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of stories" - } - ], - "columns": [ - "rank", - "id", - "title", - "score", - "author", - "comments", - "created_at", - "tags", - "url" - ], - "type": "js", - "modulePath": "plugins/lobsters/hot.js", - "sourceFile": "plugins/lobsters/hot.js" - }, - { - "site": "lobsters", - "name": "newest", - "description": "Lobste.rs newest stories", - "access": "read", - "domain": "lobste.rs", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of stories" - } - ], - "columns": [ - "rank", - "id", - "title", - "score", - "author", - "comments", - "created_at", - "tags", - "url" - ], - "type": "js", - "modulePath": "plugins/lobsters/newest.js", - "sourceFile": "plugins/lobsters/newest.js" - }, - { - "site": "lobsters", - "name": "read", - "description": "Read a Lobste.rs story and its comment tree", - "access": "read", - "domain": "lobste.rs", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "id", - "type": "str", - "required": true, - "positional": true, - "help": "Lobste.rs short_id (e.g. 6cmh6h)" - }, - { - "name": "limit", - "type": "int", - "default": 25, - "required": false, - "help": "Max top-level comments" - }, - { - "name": "depth", - "type": "int", - "default": 2, - "required": false, - "help": "Max reply depth (1=no replies, 2=one level of replies, etc.)" - }, - { - "name": "replies", - "type": "int", - "default": 5, - "required": false, - "help": "Max replies shown per comment at each level" - }, - { - "name": "max-length", - "type": "int", - "default": 2000, - "required": false, - "help": "Max characters per comment body (min 100)" - } - ], - "columns": [ - "type", - "author", - "score", - "text" - ], - "type": "js", - "modulePath": "plugins/lobsters/read.js", - "sourceFile": "plugins/lobsters/read.js" - }, - { - "site": "lobsters", - "name": "tag", - "description": "Lobste.rs stories by tag", - "access": "read", - "domain": "lobste.rs", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "tag", - "type": "str", - "required": true, - "positional": true, - "help": "Tag name (e.g. programming, rust, security, ai)" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of stories" - } - ], - "columns": [ - "rank", - "id", - "title", - "score", - "author", - "comments", - "created_at", - "tags", - "url" - ], - "type": "js", - "modulePath": "plugins/lobsters/tag.js", - "sourceFile": "plugins/lobsters/tag.js" - }, - { - "site": "luma", - "name": "create-event", - "description": "Create a free single-session Luma event", - "access": "write", - "domain": "luma.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "name", - "type": "str", - "required": true, - "help": "" - }, - { - "name": "start", - "type": "str", - "required": true, - "help": "" - }, - { - "name": "end", - "type": "str", - "required": true, - "help": "" - }, - { - "name": "timezone", - "type": "str", - "required": true, - "help": "" - }, - { - "name": "calendar", - "type": "str", - "required": false, - "help": "" - }, - { - "name": "description", - "type": "str", - "required": false, - "help": "" - }, - { - "name": "location", - "type": "str", - "required": false, - "help": "" - }, - { - "name": "virtual-url", - "type": "str", - "required": false, - "help": "" - }, - { - "name": "visibility", - "type": "str", - "default": "public", - "required": false, - "help": "", - "choices": [ - "public", - "private", - "members-only" - ] - }, - { - "name": "capacity", - "type": "int", - "required": false, - "help": "" - }, - { - "name": "require-approval", - "type": "boolean", - "default": false, - "required": false, - "help": "" - }, - { - "name": "confirm", - "type": "boolean", - "default": false, - "required": false, - "help": "" - } - ], - "columns": [ - "eventId", - "name", - "startsAt", - "endsAt", - "timezone", - "visibility", - "requireApproval", - "capacity", - "eventUrl", - "manageUrl" - ], - "type": "js", - "modulePath": "plugins/luma/create-event.js", - "sourceFile": "plugins/luma/create-event.js", - "navigateBefore": false, - "siteSession": "persistent", - "freshPage": true - }, - { - "site": "luma", - "name": "events", - "description": "List upcoming or past Luma events managed by the logged-in account", - "access": "read", - "example": "webcmd luma events --period future --limit 25 -f json", - "domain": "luma.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "period", - "type": "str", - "default": "future", - "required": false, - "help": "List future or past events", - "choices": [ - "future", - "past" - ] - }, - { - "name": "limit", - "type": "int", - "default": 25, - "required": false, - "help": "Maximum number of events to request" - } - ], - "columns": [ - "eventId", - "name", - "startsAt", - "endsAt", - "timezone", - "guestCount", - "requireApproval", - "managerLevel", - "location", - "manageUrl", - "eventUrl" - ], - "type": "js", - "modulePath": "plugins/luma/events.js", - "sourceFile": "plugins/luma/events.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "luma", - "name": "guests", - "description": "List guests and all custom registration answers for a managed Luma event", - "access": "read", - "example": "webcmd luma guests evt-abc --status pending_approval --limit 100 -f json", - "domain": "luma.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "eventId", - "type": "str", - "required": true, - "positional": true, - "help": "Luma event ID returned by webcmd luma events" - }, - { - "name": "status", - "type": "str", - "default": "all", - "required": false, - "help": "Filter by guest approval status", - "choices": [ - "all", - "approved", - "pending_approval", - "declined", - "waitlist", - "invited" - ] - }, - { - "name": "limit", - "type": "int", - "default": 100, - "required": false, - "help": "Maximum matching guests to return" - }, - { - "name": "query", - "type": "str", - "default": "", - "required": false, - "help": "Search text passed to Luma guest search" - } - ], - "columns": [ - "eventId", - "guestId", - "userId", - "name", - "email", - "phone", - "status", - "registeredAt", - "profiles", - "answers" - ], - "type": "js", - "modulePath": "plugins/luma/guests.js", - "sourceFile": "plugins/luma/guests.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "luma", - "name": "login", - "description": "Open Luma sign in", - "access": "write", - "domain": "luma.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "status", - "logged_in", - "site", - "name", - "email", - "url", - "action", - "verify_command" - ], - "type": "js", - "modulePath": "plugins/luma/auth.js", - "sourceFile": "plugins/luma/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "luma", - "name": "set-registration-questions", - "description": "Append or replace custom registration questions on a managed Luma event", - "access": "write", - "domain": "luma.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "eventId", - "type": "str", - "required": true, - "positional": true, - "help": "" - }, - { - "name": "questions-file", - "type": "str", - "required": true, - "help": "", - "file": { - "direction": "input", - "pathKind": "file", - "multiple": false, - "contentTypes": [ - "application/json" - ] - } - }, - { - "name": "mode", - "type": "str", - "required": true, - "help": "", - "choices": [ - "append", - "replace" - ] - }, - { - "name": "confirm", - "type": "boolean", - "default": false, - "required": false, - "help": "" - } - ], - "columns": [ - "eventId", - "mode", - "previousCount", - "questionCount", - "questions", - "registrationUrl" - ], - "type": "js", - "modulePath": "plugins/luma/set-registration-questions.js", - "sourceFile": "plugins/luma/set-registration-questions.js", - "navigateBefore": false, - "siteSession": "persistent", - "freshPage": true - }, - { - "site": "luma", - "name": "update-guest-status", - "description": "Approve or decline a pending Luma guest after explicit confirmation", - "access": "write", - "example": "webcmd luma update-guest-status evt-abc gst-abc --status approved --confirm true -f json", - "domain": "luma.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "eventId", - "type": "str", - "required": true, - "positional": true, - "help": "Luma event ID returned by webcmd luma events" - }, - { - "name": "guestId", - "type": "str", - "required": true, - "positional": true, - "help": "Luma guest ID returned by webcmd luma guests" - }, - { - "name": "status", - "type": "str", - "required": true, - "help": "New guest status", - "choices": [ - "approved", - "declined" - ] - }, - { - "name": "suppress-email", - "type": "boolean", - "default": false, - "required": false, - "help": "Set true to prevent Luma from emailing the guest" - }, - { - "name": "confirm", - "type": "boolean", - "default": false, - "required": false, - "help": "Required. Set --confirm true to change the real guest status" - } - ], - "columns": [ - "eventId", - "guestId", - "name", - "email", - "previousStatus", - "status", - "emailSuppressed" - ], - "type": "js", - "modulePath": "plugins/luma/update-guest-status.js", - "sourceFile": "plugins/luma/update-guest-status.js", - "navigateBefore": false, - "siteSession": "persistent", - "freshPage": true - }, - { - "site": "luma", - "name": "whoami", - "description": "Show the current logged-in Luma account", - "access": "read", - "domain": "luma.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "logged_in", - "site", - "name", - "email", - "url" - ], - "type": "js", - "modulePath": "plugins/luma/auth.js", - "sourceFile": "plugins/luma/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "manus", - "name": "connectors", - "description": "List available Manus connectors (integrations).", - "access": "read", - "domain": "manus.im", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 50, - "required": false, - "help": "Max connectors to return" - } - ], - "columns": [ - "UID", - "Name", - "Brief" - ], - "type": "js", - "modulePath": "plugins/manus/connectors.js", - "sourceFile": "plugins/manus/connectors.js", - "navigateBefore": true, - "siteSession": "persistent" - }, - { - "site": "manus", - "name": "credits", - "description": "Show Manus credit balance and refresh details.", - "access": "read", - "domain": "manus.im", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "Field", - "Value" - ], - "type": "js", - "modulePath": "plugins/manus/credits.js", - "sourceFile": "plugins/manus/credits.js", - "navigateBefore": true, - "siteSession": "persistent" - }, - { - "site": "manus", - "name": "list", - "description": "List Manus sessions (tasks).", - "access": "read", - "domain": "manus.im", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max sessions to return" - }, - { - "name": "archived", - "type": "bool", - "default": false, - "required": false, - "help": "Include archived sessions" - } - ], - "columns": [ - "id", - "Title", - "Status", - "Last Message", - "Last Updated", - "Credits" - ], - "type": "js", - "modulePath": "plugins/manus/list.js", - "sourceFile": "plugins/manus/list.js", - "navigateBefore": true, - "siteSession": "persistent" - }, - { - "site": "manus", - "name": "login", - "description": "Open manus login", - "access": "write", - "domain": "manus.im", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "status", - "logged_in", - "site", - "user_id", - "name", - "action", - "verify_command" - ], - "type": "js", - "modulePath": "plugins/manus/auth.js", - "sourceFile": "plugins/manus/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "manus", - "name": "read", - "description": "Show details for a specific Manus session.", - "access": "read", - "domain": "manus.im", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "uid", - "type": "str", - "required": true, - "positional": true, - "help": "Session UID" - } - ], - "columns": [ - "Field", - "Value" - ], - "type": "js", - "modulePath": "plugins/manus/read.js", - "sourceFile": "plugins/manus/read.js", - "navigateBefore": true, - "siteSession": "persistent" - }, - { - "site": "manus", - "name": "skills", - "description": "List Manus skills (user-added and system).", - "access": "read", - "domain": "manus.im", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "ID", - "Name", - "Description", - "Source" - ], - "type": "js", - "modulePath": "plugins/manus/skills.js", - "sourceFile": "plugins/manus/skills.js", - "navigateBefore": true, - "siteSession": "persistent" - }, - { - "site": "manus", - "name": "status", - "description": "Show current Manus user profile and credit summary.", - "access": "read", - "domain": "manus.im", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "Field", - "Value" - ], - "type": "js", - "modulePath": "plugins/manus/status.js", - "sourceFile": "plugins/manus/status.js", - "navigateBefore": true, - "siteSession": "persistent" - }, - { - "site": "manus", - "name": "whoami", - "description": "Show the current logged-in manus account", - "access": "read", - "domain": "manus.im", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "logged_in", - "site", - "user_id", - "name" - ], - "type": "js", - "modulePath": "plugins/manus/auth.js", - "sourceFile": "plugins/manus/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "maven", - "name": "artifact", - "description": "Fetch a Maven Central artifact's version history (groupId:artifactId[:version])", - "access": "read", - "domain": "search.maven.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "coordinate", - "type": "str", - "required": true, - "positional": true, - "help": "Maven coord \"groupId:artifactId\" or \"groupId:artifactId:version\"" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max versions (1-200, ignored when version is pinned)" - } - ], - "columns": [ - "groupId", - "artifactId", - "version", - "packaging", - "publishedAt", - "tags", - "url" - ], - "type": "js", - "modulePath": "plugins/maven/artifact.js", - "sourceFile": "plugins/maven/artifact.js" - }, - { - "site": "maven", - "name": "search", - "description": "Search Maven Central by keyword (artifact name, groupId, tag)", - "access": "read", - "domain": "search.maven.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search keyword (e.g. \"jackson\", \"guava\", \"ai.koog\")" - }, - { - "name": "limit", - "type": "int", - "default": 30, - "required": false, - "help": "Max artifacts (1-200)" - } - ], - "columns": [ - "rank", - "coordinate", - "groupId", - "artifactId", - "latestVersion", - "packaging", - "versions", - "lastPublished", - "repository", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/maven/search.js", - "sourceFile": "plugins/maven/search.js" - }, - { - "site": "mdn", - "name": "search", - "description": "Search MDN Web Docs by keyword", - "access": "read", - "domain": "developer.mozilla.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search keyword (e.g. \"fetch\", \"flexbox\", \"Array.prototype.map\")" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Max results (1-50)" - }, - { - "name": "locale", - "type": "str", - "default": "en-US", - "required": false, - "help": "Doc locale (en-US default; de / es / fr / ja / ko / pt-BR / ru / zh-CN / zh-TW)" - } - ], - "columns": [ - "rank", - "title", - "slug", - "locale", - "summary", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/mdn/search.js", - "sourceFile": "plugins/mdn/search.js" - }, - { - "site": "medium", - "name": "feed", - "description": "Medium popular posts Feed", - "access": "read", - "domain": "medium.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "topic", - "type": "str", - "default": "", - "required": false, - "help": "Topic (for example technology, programming, ai)" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of posts to return" - } - ], - "columns": [ - "rank", - "title", - "author", - "date", - "readTime", - "claps" - ], - "type": "js", - "modulePath": "plugins/medium/feed.js", - "sourceFile": "plugins/medium/feed.js", - "navigateBefore": "https://medium.com" - }, - { - "site": "medium", - "name": "search", - "description": "Search Medium posts", - "access": "read", - "domain": "medium.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "keyword", - "type": "str", - "required": true, - "positional": true, - "help": "Search keyword" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of posts to return" - } - ], - "columns": [ - "rank", - "title", - "author", - "date", - "readTime", - "claps", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/medium/search.js", - "sourceFile": "plugins/medium/search.js", - "navigateBefore": "https://medium.com" - }, - { - "site": "medium", - "name": "tag", - "description": "Latest Medium articles tagged with a given keyword (RSS feed)", - "access": "read", - "domain": "medium.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "tag", - "type": "str", - "required": true, - "positional": true, - "help": "Lowercase tag slug (e.g. \"programming\", \"machine-learning\")" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max articles (1-25 — single RSS page)" - } - ], - "columns": [ - "rank", - "title", - "author", - "description", - "categories", - "published", - "url" - ], - "type": "js", - "modulePath": "plugins/medium/tag.js", - "sourceFile": "plugins/medium/tag.js" - }, - { - "site": "medium", - "name": "user", - "description": "Get Medium user posts", - "access": "read", - "domain": "medium.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "username", - "type": "str", - "required": true, - "positional": true, - "help": "Medium username(for example @username or username)" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of posts to return" - } - ], - "columns": [ - "rank", - "title", - "date", - "readTime", - "claps", - "url" - ], - "type": "js", - "modulePath": "plugins/medium/user.js", - "sourceFile": "plugins/medium/user.js", - "navigateBefore": "https://medium.com" - }, - { - "site": "mercury", - "name": "check-login", - "description": "Open Mercury reimbursements and report whether the active browser profile is logged in", - "access": "read", - "example": "webcmd --profile mercury check-login -f json", - "domain": "app.mercury.com", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "status", - "loggedIn", - "url", - "hasSubmitExpense", - "hasReimbursements", - "title" - ], - "type": "js", - "modulePath": "plugins/mercury/check-login.js", - "sourceFile": "plugins/mercury/check-login.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "mercury", - "name": "reimbursement-draft", - "description": "Create a Mercury reimbursement draft from a local receipt, correct OCR fields, and stop at Review", - "access": "write", - "example": "webcmd --profile mercury reimbursement-draft --receipt /tmp/receipt.png --amount 140.00 --currency CNY --date 2026-06-26 --merchant \"Example Merchant\" --category \"Marketing & Advertising\" --notes \"Example business purpose.\" -f json", - "domain": "app.mercury.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "receipt", - "type": "str", - "required": true, - "help": "Local receipt/proof file path", - "file": { - "direction": "input", - "pathKind": "file", - "multiple": false, - "contentTypes": [ - "image/jpeg", - "image/png", - "image/gif", - "image/webp", - "application/pdf" - ], - "maxBytes": 26214400 - } - }, - { - "name": "amount", - "type": "str", - "required": true, - "help": "Original-currency amount, e.g. 140.00" - }, - { - "name": "currency", - "type": "str", - "default": "CNY", - "required": false, - "help": "Original currency code" - }, - { - "name": "date", - "type": "str", - "required": true, - "help": "Expense date as YYYY-MM-DD" - }, - { - "name": "merchant", - "type": "str", - "required": true, - "help": "Merchant shown on the reimbursement" - }, - { - "name": "category", - "type": "str", - "default": "Marketing & Advertising", - "required": false, - "help": "Mercury expense category" - }, - { - "name": "notes", - "type": "str", - "required": true, - "help": "Business purpose / reimbursement notes" - }, - { - "name": "ocr-wait-seconds", - "type": "str", - "default": "8", - "required": false, - "help": "Seconds to wait after receipt upload before correcting OCR-overwritten fields" - }, - { - "name": "close-after-review", - "type": "boolean", - "default": false, - "required": false, - "help": "Close the Review dialog after verification; final Submit is still never clicked" - } - ], - "columns": [ - "status", - "url", - "receipt", - "uploaded", - "fieldsTouched", - "reviewReady", - "submitBlocked", - "warnings" - ], - "type": "js", - "modulePath": "plugins/mercury/reimbursement-draft.js", - "sourceFile": "plugins/mercury/reimbursement-draft.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "mercury", - "name": "reimbursement-plan", - "description": "Validate Mercury reimbursement inputs and print the draft plan without opening a browser", - "access": "read", - "example": "webcmd mercury reimbursement-plan --receipt /tmp/receipt.png --amount 140.00 --currency CNY --date 2026-06-26 --merchant \"Example Merchant\" --category \"Marketing & Advertising\" --notes \"Example business purpose.\" -f json", - "strategy": "local", - "browser": false, - "args": [ - { - "name": "receipt", - "type": "str", - "required": true, - "help": "Local receipt/proof file path" - }, - { - "name": "amount", - "type": "str", - "required": true, - "help": "Original-currency amount, e.g. 140.00" - }, - { - "name": "currency", - "type": "str", - "default": "CNY", - "required": false, - "help": "Original currency code" - }, - { - "name": "date", - "type": "str", - "required": true, - "help": "Expense date as YYYY-MM-DD" - }, - { - "name": "merchant", - "type": "str", - "required": true, - "help": "Merchant shown on the reimbursement" - }, - { - "name": "category", - "type": "str", - "default": "Marketing & Advertising", - "required": false, - "help": "Mercury expense category" - }, - { - "name": "notes", - "type": "str", - "required": true, - "help": "Business purpose / reimbursement notes" - }, - { - "name": "ocr-wait-seconds", - "type": "str", - "default": "8", - "required": false, - "help": "Seconds the draft command waits after receipt upload before correcting OCR fields" - }, - { - "name": "close-after-review", - "type": "boolean", - "default": false, - "required": false, - "help": "For draft command: close the Review dialog after verification" - } - ], - "columns": [ - "status", - "receipt", - "amount", - "currency", - "date", - "merchant", - "category", - "notes", - "safety" - ], - "type": "js", - "modulePath": "plugins/mercury/reimbursement-plan.js", - "sourceFile": "plugins/mercury/reimbursement-plan.js" - }, - { - "site": "notebooklm", - "name": "add-source", - "description": "Add a URL, text, or local file source to an existing NotebookLM notebook", - "access": "write", - "domain": "notebooklm.google.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "notebook", - "type": "str", - "required": true, - "positional": true, - "help": "Notebook id from `notebooklm list` or full notebook URL" - }, - { - "name": "url", - "type": "str", - "required": false, - "help": "Source URL to add (http/https). Pass exactly one of --url, --content, --file." - }, - { - "name": "content", - "type": "str", - "required": false, - "help": "Raw text content to add as a Text source (max 10 MB)." - }, - { - "name": "file", - "type": "str", - "required": false, - "help": "Local file path to upload as a source (max 52428800 bytes; pdf / txt / md / html / docx / etc.). Uses Google Drive's 3-step resumable upload protocol.", - "file": { - "direction": "input", - "pathKind": "file", - "multiple": false - } - }, - { - "name": "title", - "type": "str", - "required": false, - "help": "Title for the text source (default \"Text Source\"). Ignored for --url and --file." - }, - { - "name": "mime-type", - "type": "str", - "required": false, - "help": "Override the auto-detected MIME type when --file is given." - }, - { - "name": "execute", - "type": "boolean", - "required": false, - "help": "Actually add the remote source to the NotebookLM notebook" - } - ], - "columns": [ - "notebook_id", - "source_id", - "kind", - "identifier", - "notebook_url" - ], - "type": "js", - "modulePath": "plugins/notebooklm/add-source.js", - "sourceFile": "plugins/notebooklm/add-source.js", - "navigateBefore": false - }, - { - "site": "notebooklm", - "name": "create", - "description": "Create a new NotebookLM notebook with the given title", - "access": "write", - "domain": "notebooklm.google.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "title", - "type": "str", - "required": true, - "positional": true, - "help": "Notebook title (1-200 chars)" - }, - { - "name": "emoji", - "type": "str", - "required": false, - "help": "Notebook emoji icon (default 📒)" - }, - { - "name": "execute", - "type": "boolean", - "required": false, - "help": "Actually create the remote NotebookLM notebook" - } - ], - "columns": [ - "id", - "title", - "emoji", - "url" - ], - "type": "js", - "modulePath": "plugins/notebooklm/create.js", - "sourceFile": "plugins/notebooklm/create.js", - "navigateBefore": false - }, - { - "site": "notebooklm", - "name": "current", - "description": "Show metadata for the currently opened NotebookLM notebook tab", - "access": "read", - "domain": "notebooklm.google.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "id", - "title", - "url", - "source" - ], - "type": "js", - "modulePath": "plugins/notebooklm/current.js", - "sourceFile": "plugins/notebooklm/current.js", - "navigateBefore": false - }, - { - "site": "notebooklm", - "name": "generate-audio", - "description": "Trigger an Audio Overview (Deep Dive podcast) generation for a NotebookLM notebook, using all of its sources", - "access": "write", - "domain": "notebooklm.google.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "notebook", - "type": "str", - "required": true, - "positional": true, - "help": "Notebook id from `notebooklm list` or full notebook URL" - }, - { - "name": "execute", - "type": "boolean", - "required": false, - "help": "Actually trigger remote NotebookLM audio generation" - } - ], - "columns": [ - "notebook_id", - "audio_id", - "source_count", - "status", - "notebook_url" - ], - "type": "js", - "modulePath": "plugins/notebooklm/generate-audio.js", - "sourceFile": "plugins/notebooklm/generate-audio.js", - "navigateBefore": false - }, - { - "site": "notebooklm", - "name": "generate-slides", - "description": "Trigger a Slide Deck (AI presentation) generation for a NotebookLM notebook, using all of its sources", - "access": "write", - "domain": "notebooklm.google.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "notebook", - "type": "str", - "required": true, - "positional": true, - "help": "Notebook id from `notebooklm list` or full notebook URL" - }, - { - "name": "length", - "type": "str", - "required": false, - "help": "Slide deck length: 1=Short, 3=Default (default 3)" - }, - { - "name": "language", - "type": "str", - "required": false, - "help": "Language code (default en)" - }, - { - "name": "execute", - "type": "boolean", - "required": false, - "help": "Actually trigger remote NotebookLM slide deck generation" - } - ], - "columns": [ - "notebook_id", - "slides_id", - "source_count", - "status", - "notebook_url" - ], - "type": "js", - "modulePath": "plugins/notebooklm/generate-slides.js", - "sourceFile": "plugins/notebooklm/generate-slides.js", - "navigateBefore": false - }, - { - "site": "notebooklm", - "name": "get", - "aliases": [ - "metadata" - ], - "description": "Get rich metadata for the currently opened NotebookLM notebook", - "access": "read", - "domain": "notebooklm.google.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "id", - "title", - "emoji", - "source_count", - "created_at", - "updated_at", - "url", - "source" - ], - "type": "js", - "modulePath": "plugins/notebooklm/get.js", - "sourceFile": "plugins/notebooklm/get.js", - "navigateBefore": false - }, - { - "site": "notebooklm", - "name": "history", - "description": "List NotebookLM conversation history threads in the current notebook", - "access": "read", - "domain": "notebooklm.google.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "thread_id", - "item_count", - "preview", - "source", - "notebook_id", - "url" - ], - "type": "js", - "modulePath": "plugins/notebooklm/history.js", - "sourceFile": "plugins/notebooklm/history.js", - "navigateBefore": false - }, - { - "site": "notebooklm", - "name": "list", - "description": "List NotebookLM notebooks via in-page batchexecute RPC in the current logged-in session", - "access": "read", - "domain": "notebooklm.google.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "title", - "id", - "is_owner", - "created_at", - "source", - "url" - ], - "type": "js", - "modulePath": "plugins/notebooklm/list.js", - "sourceFile": "plugins/notebooklm/list.js", - "navigateBefore": false - }, - { - "site": "notebooklm", - "name": "login", - "description": "Open notebooklm login", - "access": "write", - "domain": "google.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "status", - "logged_in", - "site", - "name", - "authuser", - "action", - "verify_command" - ], - "type": "js", - "modulePath": "plugins/notebooklm/auth.js", - "sourceFile": "plugins/notebooklm/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "notebooklm", - "name": "note-list", - "aliases": [ - "notes-list" - ], - "description": "List saved notes from the Studio panel of the current NotebookLM notebook", - "access": "read", - "domain": "notebooklm.google.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "title", - "created_at", - "source", - "url" - ], - "type": "js", - "modulePath": "plugins/notebooklm/note-list.js", - "sourceFile": "plugins/notebooklm/note-list.js", - "navigateBefore": false - }, - { - "site": "notebooklm", - "name": "notes-get", - "description": "Get one note from the current NotebookLM notebook by title from the visible note editor", - "access": "read", - "domain": "notebooklm.google.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "note", - "type": "str", - "required": true, - "positional": true, - "help": "Note title or id from the current notebook" - } - ], - "columns": [ - "title", - "content", - "source", - "url" - ], - "type": "js", - "modulePath": "plugins/notebooklm/notes-get.js", - "sourceFile": "plugins/notebooklm/notes-get.js", - "navigateBefore": false - }, - { - "site": "notebooklm", - "name": "open", - "aliases": [ - "select" - ], - "description": "Open one NotebookLM notebook in the adapter session by id or URL", - "access": "read", - "domain": "notebooklm.google.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "notebook", - "type": "str", - "required": true, - "positional": true, - "help": "Notebook id from list output, or a full NotebookLM notebook URL" - } - ], - "columns": [ - "id", - "title", - "url", - "source" - ], - "type": "js", - "modulePath": "plugins/notebooklm/open.js", - "sourceFile": "plugins/notebooklm/open.js", - "navigateBefore": false - }, - { - "site": "notebooklm", - "name": "source-fulltext", - "description": "Get the extracted fulltext for one source in the currently opened NotebookLM notebook", - "access": "read", - "domain": "notebooklm.google.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "source", - "type": "str", - "required": true, - "positional": true, - "help": "Source id or title from the current notebook" - } - ], - "columns": [ - "title", - "kind", - "char_count", - "url", - "source" - ], - "type": "js", - "modulePath": "plugins/notebooklm/source-fulltext.js", - "sourceFile": "plugins/notebooklm/source-fulltext.js", - "navigateBefore": false - }, - { - "site": "notebooklm", - "name": "source-get", - "description": "Get one source from the currently opened NotebookLM notebook by id or title", - "access": "read", - "domain": "notebooklm.google.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "source", - "type": "str", - "required": true, - "positional": true, - "help": "Source id or title from the current notebook" - } - ], - "columns": [ - "title", - "id", - "type", - "size", - "created_at", - "updated_at", - "url", - "source" - ], - "type": "js", - "modulePath": "plugins/notebooklm/source-get.js", - "sourceFile": "plugins/notebooklm/source-get.js", - "navigateBefore": false - }, - { - "site": "notebooklm", - "name": "source-guide", - "description": "Get the guide summary and keywords for one source in the currently opened NotebookLM notebook", - "access": "read", - "domain": "notebooklm.google.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "source", - "type": "str", - "required": true, - "positional": true, - "help": "Source id or title from the current notebook" - } - ], - "columns": [ - "source_id", - "notebook_id", - "title", - "type", - "summary", - "keywords", - "source" - ], - "type": "js", - "modulePath": "plugins/notebooklm/source-guide.js", - "sourceFile": "plugins/notebooklm/source-guide.js", - "navigateBefore": false - }, - { - "site": "notebooklm", - "name": "source-list", - "description": "List sources for the currently opened NotebookLM notebook", - "access": "read", - "domain": "notebooklm.google.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "title", - "id", - "type", - "size", - "created_at", - "updated_at", - "url", - "source" - ], - "type": "js", - "modulePath": "plugins/notebooklm/source-list.js", - "sourceFile": "plugins/notebooklm/source-list.js", - "navigateBefore": false - }, - { - "site": "notebooklm", - "name": "status", - "description": "Check NotebookLM page availability and login state in the current Chrome session", - "access": "read", - "domain": "notebooklm.google.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "status", - "login", - "page", - "url", - "title", - "notebooks" - ], - "type": "js", - "modulePath": "plugins/notebooklm/status.js", - "sourceFile": "plugins/notebooklm/status.js", - "navigateBefore": false - }, - { - "site": "notebooklm", - "name": "summary", - "description": "Get the summary block from the currently opened NotebookLM notebook", - "access": "read", - "domain": "notebooklm.google.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "title", - "summary", - "source", - "url" - ], - "type": "js", - "modulePath": "plugins/notebooklm/summary.js", - "sourceFile": "plugins/notebooklm/summary.js", - "navigateBefore": false - }, - { - "site": "notebooklm", - "name": "whoami", - "description": "Show the current logged-in notebooklm account", - "access": "read", - "domain": "google.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "logged_in", - "site", - "name", - "authuser" - ], - "type": "js", - "modulePath": "plugins/notebooklm/auth.js", - "sourceFile": "plugins/notebooklm/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "notebooklm", - "name": "write-note", - "description": "Create a Studio note in an existing NotebookLM notebook with the given title and Markdown content", - "access": "write", - "domain": "notebooklm.google.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "notebook", - "type": "str", - "required": true, - "positional": true, - "help": "Notebook id from `notebooklm list` or full notebook URL" - }, - { - "name": "title", - "type": "str", - "required": true, - "help": "Note title (1-200 chars)" - }, - { - "name": "content", - "type": "str", - "required": true, - "help": "Note body as Markdown" - }, - { - "name": "execute", - "type": "boolean", - "required": false, - "help": "Actually create the remote NotebookLM note" - } - ], - "columns": [ - "notebook_id", - "note_id", - "title", - "notebook_url" - ], - "type": "js", - "modulePath": "plugins/notebooklm/write-note.js", - "sourceFile": "plugins/notebooklm/write-note.js", - "navigateBefore": false - }, - { - "site": "npm", - "name": "downloads", - "description": "Daily download counts for an npm package over a window", - "access": "read", - "domain": "api.npmjs.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "name", - "type": "str", - "required": true, - "positional": true, - "help": "npm package name (e.g. \"react\", \"@vercel/og\")" - }, - { - "name": "period", - "type": "str", - "default": "last-week", - "required": false, - "help": "last-day / last-week / last-month / last-year, or YYYY-MM-DD:YYYY-MM-DD" - } - ], - "columns": [ - "rank", - "package", - "day", - "downloads" - ], - "type": "js", - "modulePath": "plugins/npm/downloads.js", - "sourceFile": "plugins/npm/downloads.js" - }, - { - "site": "npm", - "name": "package", - "description": "Single npm package metadata (latest version, license, homepage, repository). Use `npm downloads` for stats.", - "access": "read", - "domain": "registry.npmjs.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "name", - "type": "str", - "required": true, - "positional": true, - "help": "npm package name (e.g. \"react\", \"@vercel/og\")" - } - ], - "columns": [ - "name", - "latestVersion", - "description", - "license", - "homepage", - "repository", - "bugs", - "maintainers", - "keywords", - "created", - "modified", - "url" - ], - "type": "js", - "modulePath": "plugins/npm/package.js", - "sourceFile": "plugins/npm/package.js" - }, - { - "site": "npm", - "name": "search", - "description": "Search the public npm registry by keyword", - "access": "read", - "domain": "registry.npmjs.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search keyword (e.g. \"react\", \"graphql client\")" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max results (1-250)" - } - ], - "columns": [ - "rank", - "name", - "version", - "description", - "weeklyDownloads", - "dependents", - "license", - "publisher", - "updated", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/npm/search.js", - "sourceFile": "plugins/npm/search.js" - }, - { - "site": "npm", - "name": "versions", - "description": "List published versions of an npm package, newest first", - "access": "read", - "domain": "registry.npmjs.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "name", - "type": "str", - "required": true, - "positional": true, - "help": "npm package name (e.g. \"react\", \"@vercel/og\")" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Maximum versions to return (1-50)" - } - ], - "columns": [ - "version", - "publishedAt", - "isLatest", - "url" - ], - "type": "js", - "modulePath": "plugins/npm/versions.js", - "sourceFile": "plugins/npm/versions.js" - }, - { - "site": "nuget", - "name": "package", - "description": "Full NuGet package version history (catalogEntry per release)", - "access": "read", - "domain": "api.nuget.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "id", - "type": "str", - "required": true, - "positional": true, - "help": "NuGet package id (e.g. \"Newtonsoft.Json\", case-insensitive)" - } - ], - "columns": [ - "rank", - "id", - "version", - "title", - "authors", - "tags", - "language", - "licenseExpression", - "projectUrl", - "published", - "listed", - "url" - ], - "type": "js", - "modulePath": "plugins/nuget/package.js", - "sourceFile": "plugins/nuget/package.js" - }, - { - "site": "nuget", - "name": "search", - "description": "Search NuGet packages by keyword", - "access": "read", - "domain": "api.nuget.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search keyword" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max packages (1-1000)" - }, - { - "name": "prerelease", - "type": "boolean", - "default": false, - "required": false, - "help": "Include prerelease versions" - } - ], - "columns": [ - "rank", - "id", - "version", - "title", - "description", - "authors", - "tags", - "totalDownloads", - "verified", - "projectUrl", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/nuget/search.js", - "sourceFile": "plugins/nuget/search.js" - }, - { - "site": "nvd", - "name": "cve", - "description": "NIST NVD CVE detail (description, CVSS, CWE, KEV flag)", - "access": "read", - "domain": "services.nvd.nist.gov", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "id", - "type": "str", - "required": true, - "positional": true, - "help": "CVE identifier (e.g. \"CVE-2021-44228\")" - } - ], - "columns": [ - "id", - "published", - "lastModified", - "vulnStatus", - "baseScore", - "severity", - "attackVector", - "cwe", - "kevAdded", - "description", - "url" - ], - "type": "js", - "modulePath": "plugins/nvd/cve.js", - "sourceFile": "plugins/nvd/cve.js" - }, - { - "site": "oeis", - "name": "search", - "description": "Search OEIS sequences by keyword or numeric pattern", - "access": "read", - "domain": "oeis.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search keyword or comma-separated terms (e.g. \"fibonacci\", \"1,1,2,3,5,8\")" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Max sequences (1-100)" - } - ], - "columns": [ - "rank", - "id", - "name", - "keywords", - "preview", - "author", - "created", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/oeis/search.js", - "sourceFile": "plugins/oeis/search.js" - }, - { - "site": "oeis", - "name": "sequence", - "description": "Full OEIS sequence detail by A-number (terms, name, keywords, formula counts)", - "access": "read", - "domain": "oeis.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "id", - "type": "str", - "required": true, - "positional": true, - "help": "OEIS sequence id (e.g. \"A000045\" for Fibonacci)" - } - ], - "columns": [ - "id", - "name", - "keywords", - "preview", - "termCount", - "offset", - "author", - "created", - "revision", - "commentCount", - "formulaCount", - "referenceCount", - "xrefCount", - "linkCount", - "url" - ], - "type": "js", - "modulePath": "plugins/oeis/sequence.js", - "sourceFile": "plugins/oeis/sequence.js" - }, - { - "site": "omnisearch", - "name": "arxiv", - "description": "Search arXiv research papers (no login)", - "access": "read", - "domain": "export.arxiv.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Research topic" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of results" - } - ], - "columns": [ - "platform", - "title", - "author", - "score", - "commentCount", - "createdAt", - "url", - "text" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/omnisearch/arxiv.js", - "sourceFile": "plugins/omnisearch/arxiv.js" - }, - { - "site": "omnisearch", - "name": "bluesky-posts", - "description": "Recent posts from a public Bluesky account (no login)", - "access": "read", - "domain": "public.api.bsky.app", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "handle", - "type": "str", - "required": true, - "positional": true, - "help": "Bluesky handle (e.g. bsky.app)" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of posts" - } - ], - "columns": [ - "rank", - "uri", - "createdAt", - "text", - "likeCount", - "replyCount", - "repostCount", - "url" - ], - "type": "js", - "modulePath": "plugins/omnisearch/bluesky-posts.js", - "sourceFile": "plugins/omnisearch/bluesky-posts.js" - }, - { - "site": "omnisearch", - "name": "github", - "description": "Search GitHub issues & PRs for real problems (no login)", - "access": "read", - "domain": "api.github.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Problem or feature to research" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of results" - } - ], - "columns": [ - "platform", - "title", - "author", - "score", - "commentCount", - "createdAt", - "url", - "text" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/omnisearch/github.js", - "sourceFile": "plugins/omnisearch/github.js" - }, - { - "site": "omnisearch", - "name": "hackermind", - "description": "Search Hacker News stories & comments (no login)", - "access": "read", - "domain": "hn.algolia.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Topic, product, or problem to research" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of results" - }, - { - "name": "scope", - "type": "str", - "default": "story", - "required": false, - "help": "What to search: story (headlines) or comment (reply text)", - "choices": [ - "story", - "comment" - ] - } - ], - "columns": [ - "rank", - "id", - "objectType", - "title", - "author", - "score", - "commentCount", - "createdAt", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/omnisearch/hackermind.js", - "sourceFile": "plugins/omnisearch/hackermind.js" - }, - { - "site": "omnisearch", - "name": "lobsters", - "description": "Lobste.rs newest / active / hot discussions (no login)", - "access": "read", - "domain": "lobste.rs", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of stories" - }, - { - "name": "sort", - "type": "str", - "default": "newest", - "required": false, - "help": "Sort order: newest, active, hot", - "choices": [ - "newest", - "active", - "hot" - ] - } - ], - "columns": [ - "rank", - "id", - "title", - "author", - "score", - "commentCount", - "createdAt", - "tags", - "url" - ], - "type": "js", - "modulePath": "plugins/omnisearch/lobsters.js", - "sourceFile": "plugins/omnisearch/lobsters.js" - }, - { - "site": "omnisearch", - "name": "research", - "description": "Aggregate results about a topic across all public platforms (Hacker News, Lobsters, Stack Overflow, Dev.to, GitHub, arXiv)", - "access": "read", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Topic, product, or problem to research" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Maximum total results" - }, - { - "name": "sources", - "type": "str", - "default": "hn,lobsters,stackoverflow,devto,github,arxiv", - "required": false, - "help": "Comma-separated sources to query (default: all)" - } - ], - "columns": [ - "platform", - "title", - "author", - "score", - "commentCount", - "createdAt", - "url", - "text" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/omnisearch/research.js", - "sourceFile": "plugins/omnisearch/research.js" - }, - { - "site": "omnisearch", - "name": "stackoverflow", - "description": "Search Stack Overflow questions & problems (no login)", - "access": "read", - "domain": "api.stackexchange.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Problem or question to research" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of results" - } - ], - "columns": [ - "platform", - "title", - "author", - "score", - "commentCount", - "createdAt", - "url", - "text" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/omnisearch/stackoverflow.js", - "sourceFile": "plugins/omnisearch/stackoverflow.js" - }, - { - "site": "omnisearch", - "name": "verdict", - "description": "Synthesize the community's verdict on a topic across all public platforms (signal, not search)", - "access": "read", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "topic", - "type": "str", - "required": true, - "positional": true, - "help": "Topic to synthesize" - }, - { - "name": "perSource", - "type": "int", - "default": 3, - "required": false, - "help": "Top results per source to consider" - } - ], - "columns": [ - "verdict", - "topResult", - "topSource", - "topScore", - "platforms", - "totalResults" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/omnisearch/verdict.js", - "sourceFile": "plugins/omnisearch/verdict.js" - }, - { - "site": "openalex", - "name": "search", - "description": "Search OpenAlex Works (papers, books, preprints) by keyword", - "access": "read", - "domain": "api.openalex.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search text (e.g. \"transformers\", \"open access scholarly\")" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max works (1-200, single OpenAlex page)" - } - ], - "columns": [ - "rank", - "id", - "title", - "year", - "citations", - "firstAuthor", - "venue", - "openAccess", - "type", - "doi", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/openalex/search.js", - "sourceFile": "plugins/openalex/search.js" - }, - { - "site": "openalex", - "name": "work", - "description": "Fetch a single OpenAlex Work (paper / preprint / book) — metadata + abstract", - "access": "read", - "domain": "api.openalex.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "id", - "type": "str", - "required": true, - "positional": true, - "help": "OpenAlex Work id (\"W2741809807\"), DOI (\"10.7717/peerj.4375\"), or full URL" - } - ], - "columns": [ - "id", - "title", - "type", - "year", - "date", - "language", - "authors", - "venue", - "citations", - "openAccess", - "openAccessUrl", - "referencedCount", - "doi", - "abstract", - "url" - ], - "type": "js", - "modulePath": "plugins/openalex/work.js", - "sourceFile": "plugins/openalex/work.js" - }, - { - "site": "openfda", - "name": "drug-label", - "description": "Search FDA-approved drug labels (brand or generic name)", - "access": "read", - "domain": "fda.gov", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Brand or generic drug name (e.g. \"aspirin\", \"lisinopril\")" - }, - { - "name": "limit", - "type": "int", - "default": 5, - "required": false, - "help": "Max rows (1-25, default 5; openFDA caps anonymous tier at 25/page)" - } - ], - "columns": [ - "rank", - "id", - "brandName", - "genericName", - "manufacturer", - "productType", - "route", - "productNdc", - "pharmClass", - "purpose", - "indications", - "warnings", - "dosage", - "effectiveTime" - ], - "type": "js", - "modulePath": "plugins/openfda/drug-label.js", - "sourceFile": "plugins/openfda/drug-label.js" - }, - { - "site": "openfda", - "name": "food-recall", - "description": "FDA food recall and enforcement actions (most recent first)", - "access": "read", - "domain": "fda.gov", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": false, - "help": "Free-text Lucene query (e.g. \"salmonella\", \"listeria\"); default: all recent recalls" - }, - { - "name": "status", - "type": "str", - "required": false, - "help": "Filter by status: \"Ongoing\", \"Completed\", \"Terminated\"" - }, - { - "name": "classification", - "type": "str", - "required": false, - "help": "Filter by class: \"Class I\" (most serious), \"Class II\", \"Class III\"" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Max rows (1-100, default 10; openFDA caps anonymous tier at 100/page)" - } - ], - "columns": [ - "rank", - "recallNumber", - "status", - "classification", - "voluntary", - "recallingFirm", - "city", - "state", - "country", - "productDescription", - "reasonForRecall", - "productQuantity", - "distributionPattern", - "reportDate", - "recallInitiationDate", - "terminationDate" - ], - "type": "js", - "modulePath": "plugins/openfda/food-recall.js", - "sourceFile": "plugins/openfda/food-recall.js" - }, - { - "site": "openreview", - "name": "author", - "description": "List OpenReview submissions by an author profile id (newest first)", - "access": "read", - "domain": "openreview.net", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "profile", - "type": "str", - "required": true, - "positional": true, - "help": "OpenReview profile id (e.g. \"~Yoshua_Bengio1\"). Find it on the author profile URL on openreview.net." - }, - { - "name": "limit", - "type": "int", - "default": 50, - "required": false, - "help": "Max submissions (1-1000)" - } - ], - "columns": [ - "rank", - "id", - "title", - "authors", - "venue", - "pdate", - "url" - ], - "type": "js", - "modulePath": "plugins/openreview/author.js", - "sourceFile": "plugins/openreview/author.js" - }, - { - "site": "openreview", - "name": "paper", - "description": "Show full metadata for a single OpenReview paper", - "access": "read", - "domain": "openreview.net", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "id", - "type": "str", - "required": true, - "positional": true, - "help": "OpenReview note id (e.g. \"5sRnsubyAK\")" - } - ], - "columns": [ - "id", - "title", - "authors", - "keywords", - "venue", - "venueid", - "primary_area", - "abstract", - "pdate", - "pdf", - "url" - ], - "type": "js", - "modulePath": "plugins/openreview/paper.js", - "sourceFile": "plugins/openreview/paper.js" - }, - { - "site": "openreview", - "name": "reviews", - "description": "Show full review thread (paper + reviews + decisions) for an OpenReview forum", - "access": "read", - "domain": "openreview.net", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "forum", - "type": "str", - "required": true, - "positional": true, - "help": "OpenReview forum id (same as paper id)" - }, - { - "name": "max-length", - "type": "int", - "default": 4000, - "required": false, - "help": "Per-row text truncation (min 200)" - } - ], - "columns": [ - "type", - "author", - "rating", - "confidence", - "text" - ], - "type": "js", - "modulePath": "plugins/openreview/reviews.js", - "sourceFile": "plugins/openreview/reviews.js" - }, - { - "site": "openreview", - "name": "search", - "description": "Search OpenReview papers by free-text query", - "access": "read", - "domain": "openreview.net", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search keyword (e.g. \"diffusion model\")" - }, - { - "name": "limit", - "type": "int", - "default": 25, - "required": false, - "help": "Max results (max 50)" - } - ], - "columns": [ - "rank", - "id", - "title", - "authors", - "venue", - "pdate", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/openreview/search.js", - "sourceFile": "plugins/openreview/search.js" - }, - { - "site": "openreview", - "name": "venue", - "description": "List papers at an OpenReview venue (e.g. \"ICLR 2024 oral\" or full invitation id)", - "access": "read", - "domain": "openreview.net", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "venue", - "type": "str", - "required": true, - "positional": true, - "help": "Venue name (\"ICLR 2024 oral\") or invitation (\"ICLR.cc/2025/Conference/-/Submission\")" - }, - { - "name": "limit", - "type": "int", - "default": 25, - "required": false, - "help": "Max results (max 200)" - }, - { - "name": "offset", - "type": "int", - "default": 0, - "required": false, - "help": "Pagination offset" - } - ], - "columns": [ - "rank", - "id", - "title", - "authors", - "keywords", - "primary_area", - "pdate", - "pdf", - "url" - ], - "type": "js", - "modulePath": "plugins/openreview/venue.js", - "sourceFile": "plugins/openreview/venue.js" - }, - { - "site": "osv", - "name": "query", - "description": "OSV.dev vulnerabilities affecting a package (optionally pinned to a version)", - "access": "read", - "domain": "osv.dev", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "package", - "type": "string", - "required": true, - "positional": true, - "help": "Package name (e.g. \"lodash\", \"django\")" - }, - { - "name": "ecosystem", - "type": "string", - "required": true, - "help": "OSV ecosystem (npm / PyPI / Go / Maven / NuGet / RubyGems / crates.io / Packagist / ...)" - }, - { - "name": "version", - "type": "string", - "required": false, - "help": "Pin to a specific version (e.g. \"4.17.20\"); omit for all known vulns" - }, - { - "name": "limit", - "type": "int", - "default": 30, - "required": false, - "help": "Max rows to return (1-200)" - } - ], - "columns": [ - "rank", - "id", - "summary", - "severity", - "aliases", - "published", - "modified", - "affectedPackages", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/osv/query.js", - "sourceFile": "plugins/osv/query.js" - }, - { - "site": "osv", - "name": "vulnerability", - "description": "Single OSV.dev vulnerability detail (severity, affected packages, CVE/GHSA aliases)", - "access": "read", - "domain": "osv.dev", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "id", - "type": "string", - "required": true, - "positional": true, - "help": "OSV vulnerability id (e.g. \"GHSA-29mw-wpgm-hmr9\", \"CVE-2020-28500\")" - } - ], - "columns": [ - "id", - "summary", - "severity", - "aliases", - "published", - "modified", - "affectedPackages", - "cwes", - "referenceCount", - "url" - ], - "type": "js", - "modulePath": "plugins/osv/vulnerability.js", - "sourceFile": "plugins/osv/vulnerability.js" - }, - { - "site": "packagist", - "name": "package", - "description": "Fetch a Packagist package's metadata (version, downloads, license, repo, GitHub stars)", - "access": "read", - "domain": "packagist.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "name", - "type": "str", - "required": true, - "positional": true, - "help": "Composer package \"/\" (e.g. \"symfony/console\", \"monolog/monolog\")" - } - ], - "columns": [ - "package", - "version", - "releasedAt", - "license", - "description", - "repository", - "githubStars", - "favers", - "downloads", - "monthlyDownloads", - "dailyDownloads", - "url" - ], - "type": "js", - "modulePath": "plugins/packagist/package.js", - "sourceFile": "plugins/packagist/package.js" - }, - { - "site": "packagist", - "name": "search", - "description": "Search Packagist (PHP / Composer) packages by keyword", - "access": "read", - "domain": "packagist.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search keyword (e.g. \"symfony\", \"laravel http\")" - }, - { - "name": "limit", - "type": "int", - "default": 30, - "required": false, - "help": "Max packages (1-100, single Packagist page)" - } - ], - "columns": [ - "rank", - "package", - "description", - "downloads", - "favers", - "repository", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/packagist/search.js", - "sourceFile": "plugins/packagist/search.js" - }, - { - "site": "paperreview", - "name": "feedback", - "description": "Submit feedback for a paperreview.ai review token", - "access": "write", - "domain": "paperreview.ai", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "token", - "type": "str", - "required": true, - "positional": true, - "help": "Review token returned by paperreview.ai" - }, - { - "name": "helpfulness", - "type": "int", - "required": true, - "help": "Helpfulness score from 1 to 5" - }, - { - "name": "critical-error", - "type": "str", - "required": true, - "help": "Whether the review contains a critical error", - "choices": [ - "yes", - "no" - ] - }, - { - "name": "actionable-suggestions", - "type": "str", - "required": true, - "help": "Whether the review contains actionable suggestions", - "choices": [ - "yes", - "no" - ] - }, - { - "name": "additional-comments", - "type": "str", - "required": false, - "help": "Optional free-text feedback" - }, - { - "name": "timeout", - "type": "int", - "default": 30, - "required": false, - "help": "Max seconds for the overall command (default: 30)" - } - ], - "columns": [ - "status", - "token", - "helpfulness", - "critical_error", - "actionable_suggestions", - "message" - ], - "type": "js", - "modulePath": "plugins/paperreview/feedback.js", - "sourceFile": "plugins/paperreview/feedback.js" - }, - { - "site": "paperreview", - "name": "review", - "description": "Fetch a paperreview.ai review by token", - "access": "read", - "domain": "paperreview.ai", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "token", - "type": "str", - "required": true, - "positional": true, - "help": "Review token returned by paperreview.ai" - }, - { - "name": "timeout", - "type": "int", - "default": 30, - "required": false, - "help": "Max seconds for the overall command (default: 30)" - } - ], - "columns": [ - "status", - "title", - "venue", - "numerical_score", - "has_feedback", - "review_url" - ], - "type": "js", - "modulePath": "plugins/paperreview/review.js", - "sourceFile": "plugins/paperreview/review.js" - }, - { - "site": "paperreview", - "name": "submit", - "description": "Submit a PDF to paperreview.ai for review", - "access": "write", - "domain": "paperreview.ai", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "pdf", - "type": "str", - "required": true, - "positional": true, - "help": "Path to the paper PDF", - "file": { - "direction": "input", - "pathKind": "file", - "multiple": false, - "contentTypes": [ - "application/pdf" - ] - } - }, - { - "name": "email", - "type": "str", - "required": true, - "help": "Email address for the submission" - }, - { - "name": "venue", - "type": "str", - "required": false, - "help": "Optional target venue such as ICLR or NeurIPS" - }, - { - "name": "dry-run", - "type": "bool", - "default": false, - "required": false, - "help": "Validate the input and stop before remote submission" - }, - { - "name": "prepare-only", - "type": "bool", - "default": false, - "required": false, - "help": "Request an upload slot but stop before uploading the PDF" - }, - { - "name": "timeout", - "type": "int", - "default": 120, - "required": false, - "help": "Max seconds for the overall command (default: 120)" - } - ], - "columns": [ - "status", - "file", - "email", - "venue", - "token", - "review_url", - "message" - ], - "type": "js", - "modulePath": "plugins/paperreview/submit.js", - "sourceFile": "plugins/paperreview/submit.js" - }, - { - "site": "pixiv", - "name": "detail", - "description": "View illustration details (tags, stats, URLs)", - "access": "read", - "domain": "www.pixiv.net", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "id", - "type": "str", - "required": true, - "positional": true, - "help": "Illustration ID" - } - ], - "columns": [ - "illust_id", - "title", - "author", - "type", - "pages", - "bookmarks", - "likes", - "views", - "tags", - "created", - "url" - ], - "type": "js", - "modulePath": "plugins/pixiv/detail.js", - "sourceFile": "plugins/pixiv/detail.js", - "navigateBefore": "https://www.pixiv.net" - }, - { - "site": "pixiv", - "name": "download", - "description": "Download illustration images from Pixiv", - "access": "read", - "domain": "www.pixiv.net", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "illust-id", - "type": "str", - "required": true, - "positional": true, - "help": "Illustration ID" - }, - { - "name": "output", - "type": "str", - "default": "./pixiv-downloads", - "required": false, - "help": "Output directory", - "file": { - "direction": "output", - "pathKind": "directory", - "multiple": false - } - } - ], - "columns": [ - "index", - "type", - "status", - "size" - ], - "type": "js", - "modulePath": "plugins/pixiv/download.js", - "sourceFile": "plugins/pixiv/download.js", - "navigateBefore": "https://www.pixiv.net" - }, - { - "site": "pixiv", - "name": "illusts", - "description": "List a Pixiv artist's illustrations", - "access": "read", - "domain": "www.pixiv.net", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "user-id", - "type": "str", - "required": true, - "positional": true, - "help": "Pixiv user ID" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of results" - } - ], - "columns": [ - "rank", - "title", - "illust_id", - "pages", - "bookmarks", - "tags", - "created", - "url" - ], - "type": "js", - "modulePath": "plugins/pixiv/illusts.js", - "sourceFile": "plugins/pixiv/illusts.js", - "navigateBefore": "https://www.pixiv.net" - }, - { - "site": "pixiv", - "name": "login", - "description": "Open pixiv login", - "access": "write", - "domain": "pixiv.net", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "status", - "logged_in", - "site", - "user_id", - "name", - "action", - "verify_command" - ], - "type": "js", - "modulePath": "plugins/pixiv/auth.js", - "sourceFile": "plugins/pixiv/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "pixiv", - "name": "ranking", - "description": "Pixiv illustration rankings (daily/weekly/monthly)", - "access": "read", - "domain": "www.pixiv.net", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "mode", - "type": "str", - "default": "daily", - "required": false, - "help": "Ranking mode", - "choices": [ - "daily", - "weekly", - "monthly", - "rookie", - "original", - "male", - "female", - "daily_r18", - "weekly_r18" - ] - }, - { - "name": "page", - "type": "int", - "default": 1, - "required": false, - "help": "Page number" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of results" - } - ], - "columns": [ - "rank", - "title", - "author", - "user_id", - "illust_id", - "pages", - "bookmarks", - "url" - ], - "type": "js", - "modulePath": "plugins/pixiv/ranking.js", - "sourceFile": "plugins/pixiv/ranking.js", - "navigateBefore": "https://www.pixiv.net" - }, - { - "site": "pixiv", - "name": "search", - "description": "Search Pixiv illustrations by keyword", - "access": "read", - "domain": "www.pixiv.net", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search keyword or tag" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of results" - }, - { - "name": "order", - "type": "str", - "default": "date_d", - "required": false, - "help": "Sort order", - "choices": [ - "date_d", - "date", - "popular_d", - "popular_male_d", - "popular_female_d" - ] - }, - { - "name": "mode", - "type": "str", - "default": "all", - "required": false, - "help": "Search mode", - "choices": [ - "all", - "safe", - "r18" - ] - }, - { - "name": "page", - "type": "int", - "default": 1, - "required": false, - "help": "Page number" - } - ], - "columns": [ - "rank", - "title", - "author", - "user_id", - "illust_id", - "pages", - "bookmarks", - "tags", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/pixiv/search.js", - "sourceFile": "plugins/pixiv/search.js", - "navigateBefore": "https://www.pixiv.net" - }, - { - "site": "pixiv", - "name": "user", - "description": "View Pixiv artist profile", - "access": "read", - "domain": "www.pixiv.net", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "uid", - "type": "str", - "required": true, - "positional": true, - "help": "Pixiv user ID" - } - ], - "columns": [ - "user_id", - "name", - "premium", - "following", - "illusts", - "manga", - "novels", - "comment", - "url" - ], - "type": "js", - "modulePath": "plugins/pixiv/user.js", - "sourceFile": "plugins/pixiv/user.js", - "navigateBefore": "https://www.pixiv.net" - }, - { - "site": "pixiv", - "name": "whoami", - "description": "Show the current logged-in pixiv account", - "access": "read", - "domain": "pixiv.net", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "logged_in", - "site", - "user_id", - "name" - ], - "type": "js", - "modulePath": "plugins/pixiv/auth.js", - "sourceFile": "plugins/pixiv/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "practo", - "name": "appointment", - "description": "Show logged-in Practo Drive appointment details", - "access": "read", - "domain": "drive.practo.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "appointment_id", - "type": "str", - "required": true, - "positional": true, - "help": "Appointment id from `practo appointments`" - } - ], - "columns": [ - "appointment_id", - "status", - "summary" - ], - "type": "js", - "modulePath": "plugins/practo/appointment.js", - "sourceFile": "plugins/practo/appointment.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "practo", - "name": "appointments", - "description": "List logged-in Practo Drive appointments", - "access": "read", - "domain": "drive.practo.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "appointment_id", - "doctor", - "practice", - "time", - "status" - ], - "type": "js", - "modulePath": "plugins/practo/appointments.js", - "sourceFile": "plugins/practo/appointments.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "practo", - "name": "book-confirm", - "description": "Confirm a Practo clinic visit booking after explicit confirmation", - "access": "write", - "domain": "www.practo.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "practice_doctor_id", - "type": "str", - "required": true, - "positional": true, - "help": "Practo practice_doctor_id" - }, - { - "name": "time", - "type": "str", - "required": true, - "help": "Slot time YYYY-MM-DD HH:mm:ss" - }, - { - "name": "profile-url", - "type": "str", - "required": false, - "help": "Doctor profile_url from `practo search`, used to build a canonical booking URL" - }, - { - "name": "confirm", - "type": "boolean", - "default": false, - "required": false, - "help": "Required. Set --confirm true to create the appointment." - } - ], - "columns": [ - "status", - "practice_doctor_id", - "time", - "url" - ], - "type": "js", - "modulePath": "plugins/practo/book-confirm.js", - "sourceFile": "plugins/practo/book-confirm.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "practo", - "name": "book-preview", - "description": "Preview Practo booking details for a selected slot without confirming", - "access": "read", - "domain": "www.practo.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "practice_doctor_id", - "type": "str", - "required": true, - "positional": true, - "help": "Practo practice_doctor_id" - }, - { - "name": "time", - "type": "str", - "required": true, - "help": "Slot time YYYY-MM-DD HH:mm:ss" - }, - { - "name": "profile-url", - "type": "str", - "required": false, - "help": "Doctor profile_url from `practo search`, used to build a canonical booking URL" - } - ], - "columns": [ - "practice_doctor_id", - "time", - "amount", - "prepaid", - "payment_mode", - "requires_payment", - "confirm_button", - "booking_url" - ], - "type": "js", - "modulePath": "plugins/practo/book-preview.js", - "sourceFile": "plugins/practo/book-preview.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "practo", - "name": "booking-link", - "description": "Build a Practo booking URL for a selected slot without confirming it", - "access": "read", - "domain": "www.practo.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "practice_doctor_id", - "type": "str", - "required": true, - "positional": true, - "help": "Practo practice_doctor_id" - }, - { - "name": "time", - "type": "str", - "required": true, - "help": "Slot time YYYY-MM-DD HH:mm:ss" - }, - { - "name": "profile-url", - "type": "str", - "required": false, - "help": "Doctor profile_url from `practo search`, used to build a canonical booking URL" - } - ], - "columns": [ - "practice_doctor_id", - "time", - "booking_url" - ], - "type": "js", - "modulePath": "plugins/practo/booking-link.js", - "sourceFile": "plugins/practo/booking-link.js", - "navigateBefore": false - }, - { - "site": "practo", - "name": "cancel", - "description": "Cancel a logged-in Practo Drive appointment after explicit confirmation", - "access": "write", - "domain": "drive.practo.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "appointment_id", - "type": "str", - "required": true, - "positional": true, - "help": "Appointment id from `practo appointments`" - }, - { - "name": "confirm", - "type": "boolean", - "default": false, - "required": false, - "help": "Required. Set --confirm true to cancel the appointment." - } - ], - "columns": [ - "status", - "appointment_id" - ], - "type": "js", - "modulePath": "plugins/practo/cancel.js", - "sourceFile": "plugins/practo/cancel.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "practo", - "name": "contact", - "description": "Get Practo virtual contact number for a practice_doctor_id", - "access": "read", - "domain": "www.practo.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "practice_doctor_id", - "type": "str", - "required": true, - "positional": true, - "help": "Practo practice_doctor_id from search results" - } - ], - "columns": [ - "practice_doctor_id", - "phone", - "raw" - ], - "type": "js", - "modulePath": "plugins/practo/contact.js", - "sourceFile": "plugins/practo/contact.js", - "navigateBefore": false - }, - { - "site": "practo", - "name": "login", - "description": "Open practo login", - "access": "write", - "domain": "www.practo.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "status", - "logged_in", - "site", - "name", - "action", - "verify_command" - ], - "type": "js", - "modulePath": "plugins/practo/login.js", - "sourceFile": "plugins/practo/login.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "practo", - "name": "profile", - "description": "Read public details from a Practo doctor profile URL", - "access": "read", - "domain": "www.practo.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "url", - "type": "str", - "required": true, - "positional": true, - "help": "Practo doctor profile URL" - } - ], - "columns": [ - "name", - "specialty", - "experience", - "fee", - "profile_url" - ], - "type": "js", - "modulePath": "plugins/practo/profile.js", - "sourceFile": "plugins/practo/profile.js", - "navigateBefore": false - }, - { - "site": "practo", - "name": "search", - "description": "Search Practo doctors by specialty, city, and optional locality", - "access": "read", - "domain": "www.practo.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "specialty", - "type": "str", - "required": true, - "positional": true, - "help": "Doctor specialty, e.g. orthopedist or dermatologist" - }, - { - "name": "city", - "type": "str", - "default": "bangalore", - "required": false, - "help": "City, e.g. bangalore" - }, - { - "name": "locality", - "type": "str", - "required": false, - "help": "Optional locality, e.g. indiranagar" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Max doctors to return (1-25)" - } - ], - "columns": [ - "rank", - "practice_doctor_id", - "doctor_id", - "practice_id", - "name", - "specialty", - "experience_years", - "locality", - "clinic", - "fee", - "next_available", - "profile_url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/practo/search.js", - "sourceFile": "plugins/practo/search.js", - "navigateBefore": false - }, - { - "site": "practo", - "name": "slots", - "description": "List available Practo appointment slots for a practice_doctor_id", - "access": "read", - "domain": "www.practo.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "practice_doctor_id", - "type": "str", - "required": true, - "positional": true, - "help": "Practo practice_doctor_id from search results" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max slots to return (1-25)" - } - ], - "columns": [ - "practice_doctor_id", - "time", - "available", - "amount", - "prepaid", - "appointment_token" - ], - "type": "js", - "modulePath": "plugins/practo/slots.js", - "sourceFile": "plugins/practo/slots.js", - "navigateBefore": false - }, - { - "site": "practo", - "name": "whoami", - "aliases": [ - "auth-status" - ], - "description": "Show the current logged-in practo account", - "access": "read", - "domain": "www.practo.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "logged_in", - "site", - "name" - ], - "type": "js", - "modulePath": "plugins/practo/login.js", - "sourceFile": "plugins/practo/login.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "producthunt", - "name": "browse", - "description": "Best products in a Product Hunt category", - "access": "read", - "domain": "www.producthunt.com", - "strategy": "intercept", - "browser": true, - "args": [ - { - "name": "category", - "type": "string", - "required": true, - "positional": true, - "help": "Category slug, e.g. vibe-coding, ai-agents, developer-tools" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of results (max 50)" - } - ], - "columns": [ - "rank", - "name", - "tagline", - "reviews", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/producthunt/browse.js", - "sourceFile": "plugins/producthunt/browse.js", - "navigateBefore": true - }, - { - "site": "producthunt", - "name": "hot", - "description": "Today's top Product Hunt launches with vote counts", - "access": "read", - "domain": "www.producthunt.com", - "strategy": "intercept", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of results (max 50)" - } - ], - "columns": [ - "rank", - "name", - "votes", - "url" - ], - "type": "js", - "modulePath": "plugins/producthunt/hot.js", - "sourceFile": "plugins/producthunt/hot.js", - "navigateBefore": true - }, - { - "site": "producthunt", - "name": "posts", - "description": "Latest Product Hunt launches (optional category filter)", - "access": "read", - "domain": "www.producthunt.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of results (max 50)" - }, - { - "name": "category", - "type": "string", - "default": "", - "required": false, - "help": "Category filter: ai-agents, ai-coding-agents, ai-code-editors, ai-chatbots, ai-workflow-automation, vibe-coding, developer-tools, productivity, design-creative, marketing-sales, no-code-platforms, llms, finance, social-community, engineering-development" - } - ], - "columns": [ - "rank", - "name", - "tagline", - "author", - "date", - "url" - ], - "type": "js", - "modulePath": "plugins/producthunt/posts.js", - "sourceFile": "plugins/producthunt/posts.js" - }, - { - "site": "producthunt", - "name": "today", - "description": "Today's Product Hunt launches (most recent day in feed)", - "access": "read", - "domain": "www.producthunt.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max results" - } - ], - "columns": [ - "rank", - "name", - "tagline", - "author", - "url" - ], - "type": "js", - "modulePath": "plugins/producthunt/today.js", - "sourceFile": "plugins/producthunt/today.js" - }, - { - "site": "pubmed", - "name": "article", - "aliases": [ - "paper", - "read" - ], - "description": "Get detailed information for a PubMed article by PMID", - "access": "read", - "domain": "pubmed.ncbi.nlm.nih.gov", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "pmid", - "type": "str", - "required": true, - "positional": true, - "help": "PubMed ID, e.g. 37780221" - }, - { - "name": "full-abstract", - "type": "boolean", - "default": false, - "required": false, - "help": "Do not truncate the abstract in table output" - } - ], - "columns": [ - "pmid", - "title", - "authors", - "journal", - "year", - "date", - "article_type", - "language", - "doi", - "pmc", - "affiliations", - "grants", - "mesh_terms", - "keywords", - "abstract", - "url" - ], - "defaultFormat": "plain", - "type": "js", - "modulePath": "plugins/pubmed/article.js", - "sourceFile": "plugins/pubmed/article.js" - }, - { - "site": "pubmed", - "name": "author", - "description": "Search PubMed articles by author name and optional affiliation", - "access": "read", - "domain": "pubmed.ncbi.nlm.nih.gov", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "name", - "type": "str", - "required": true, - "positional": true, - "help": "Author name, e.g. \"Smith J\"" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max results (1-100)" - }, - { - "name": "affiliation", - "type": "str", - "required": false, - "help": "Filter by author affiliation" - }, - { - "name": "position", - "type": "str", - "default": "any", - "required": false, - "help": "Author position: any, first, or last", - "choices": [ - "any", - "first", - "last" - ] - }, - { - "name": "year-from", - "type": "int", - "required": false, - "help": "Filter publication year from" - }, - { - "name": "year-to", - "type": "int", - "required": false, - "help": "Filter publication year to" - }, - { - "name": "sort", - "type": "str", - "default": "date", - "required": false, - "help": "Sort by date or relevance", - "choices": [ - "date", - "relevance" - ] - } - ], - "columns": [ - "rank", - "pmid", - "title", - "authors", - "journal", - "year", - "article_type", - "doi", - "url" - ], - "type": "js", - "modulePath": "plugins/pubmed/author.js", - "sourceFile": "plugins/pubmed/author.js" - }, - { - "site": "pubmed", - "name": "citations", - "description": "Get PubMed citation relationships for an article", - "access": "read", - "domain": "pubmed.ncbi.nlm.nih.gov", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "pmid", - "type": "str", - "required": true, - "positional": true, - "help": "PubMed ID, e.g. 37780221" - }, - { - "name": "direction", - "type": "str", - "default": "citedby", - "required": false, - "help": "citedby or references", - "choices": [ - "citedby", - "references" - ] - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max results (1-100)" - } - ], - "columns": [ - "rank", - "pmid", - "title", - "authors", - "journal", - "year", - "article_type", - "doi", - "url" - ], - "type": "js", - "modulePath": "plugins/pubmed/citations.js", - "sourceFile": "plugins/pubmed/citations.js" - }, - { - "site": "pubmed", - "name": "clinical-trial", - "description": "Search PubMed clinical trials with a trial-study preset", - "access": "read", - "domain": "pubmed.ncbi.nlm.nih.gov", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Clinical topic query, e.g. \"breast cancer\"" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max results (1-100)" - }, - { - "name": "year-from", - "type": "int", - "required": false, - "help": "Filter publication year from" - }, - { - "name": "year-to", - "type": "int", - "required": false, - "help": "Filter publication year to" - }, - { - "name": "free-full-text", - "type": "boolean", - "default": false, - "required": false, - "help": "Only include free full text articles" - }, - { - "name": "sort", - "type": "str", - "default": "date", - "required": false, - "help": "Sort by date or relevance", - "choices": [ - "date", - "relevance" - ] - } - ], - "columns": [ - "rank", - "pmid", - "title", - "authors", - "journal", - "year", - "article_type", - "doi", - "url" - ], - "type": "js", - "modulePath": "plugins/pubmed/clinical-trial.js", - "sourceFile": "plugins/pubmed/clinical-trial.js" - }, - { - "site": "pubmed", - "name": "journal", - "description": "Search PubMed articles by journal name", - "access": "read", - "domain": "pubmed.ncbi.nlm.nih.gov", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "journal", - "type": "str", - "required": true, - "positional": true, - "help": "Journal name, e.g. \"Nature\" or \"The Lancet\"" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max results (1-100)" - }, - { - "name": "year-from", - "type": "int", - "required": false, - "help": "Filter publication year from" - }, - { - "name": "year-to", - "type": "int", - "required": false, - "help": "Filter publication year to" - }, - { - "name": "sort", - "type": "str", - "default": "relevance", - "required": false, - "help": "Sort by relevance or date", - "choices": [ - "relevance", - "date" - ] - } - ], - "columns": [ - "rank", - "pmid", - "title", - "authors", - "journal", - "year", - "article_type", - "doi", - "url" - ], - "type": "js", - "modulePath": "plugins/pubmed/journal.js", - "sourceFile": "plugins/pubmed/journal.js" - }, - { - "site": "pubmed", - "name": "mesh", - "description": "Search PubMed articles by MeSH term", - "access": "read", - "domain": "pubmed.ncbi.nlm.nih.gov", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "term", - "type": "str", - "required": true, - "positional": true, - "help": "MeSH term, e.g. \"Neoplasms\" or \"Machine Learning\"" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max results (1-100)" - }, - { - "name": "major", - "type": "boolean", - "default": false, - "required": false, - "help": "Only include articles where this is a major MeSH topic" - }, - { - "name": "sort", - "type": "str", - "default": "relevance", - "required": false, - "help": "Sort by relevance or date", - "choices": [ - "relevance", - "date" - ] - } - ], - "columns": [ - "rank", - "pmid", - "title", - "authors", - "journal", - "year", - "article_type", - "doi", - "url" - ], - "type": "js", - "modulePath": "plugins/pubmed/mesh.js", - "sourceFile": "plugins/pubmed/mesh.js" - }, - { - "site": "pubmed", - "name": "related", - "description": "Find articles related to a PubMed article", - "access": "read", - "domain": "pubmed.ncbi.nlm.nih.gov", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "pmid", - "type": "str", - "required": true, - "positional": true, - "help": "PubMed ID, e.g. 37780221" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max results (1-100)" - }, - { - "name": "score", - "type": "boolean", - "default": false, - "required": false, - "help": "Show similarity scores when available" - } - ], - "columns": [ - "rank", - "pmid", - "title", - "authors", - "journal", - "year", - "article_type", - "score", - "doi", - "url" - ], - "type": "js", - "modulePath": "plugins/pubmed/related.js", - "sourceFile": "plugins/pubmed/related.js" - }, - { - "site": "pubmed", - "name": "review", - "description": "Search PubMed review articles with a review preset", - "access": "read", - "domain": "pubmed.ncbi.nlm.nih.gov", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Review topic query, e.g. \"immunotherapy\"" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max results (1-100)" - }, - { - "name": "year-from", - "type": "int", - "required": false, - "help": "Filter publication year from" - }, - { - "name": "year-to", - "type": "int", - "required": false, - "help": "Filter publication year to" - }, - { - "name": "has-abstract", - "type": "boolean", - "default": false, - "required": false, - "help": "Only include articles with abstracts" - }, - { - "name": "sort", - "type": "str", - "default": "date", - "required": false, - "help": "Sort by date or relevance", - "choices": [ - "date", - "relevance" - ] - } - ], - "columns": [ - "rank", - "pmid", - "title", - "authors", - "journal", - "year", - "article_type", - "doi", - "url" - ], - "type": "js", - "modulePath": "plugins/pubmed/review.js", - "sourceFile": "plugins/pubmed/review.js" - }, - { - "site": "pubmed", - "name": "search", - "description": "Search PubMed articles with advanced filters", - "access": "read", - "domain": "pubmed.ncbi.nlm.nih.gov", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search query, e.g. \"machine learning cancer\"" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max results (1-100)" - }, - { - "name": "author", - "type": "str", - "required": false, - "help": "Filter by author name" - }, - { - "name": "journal", - "type": "str", - "required": false, - "help": "Filter by journal name" - }, - { - "name": "year-from", - "type": "int", - "required": false, - "help": "Filter publication year from" - }, - { - "name": "year-to", - "type": "int", - "required": false, - "help": "Filter publication year to" - }, - { - "name": "article-type", - "type": "str", - "required": false, - "help": "Filter by publication type, e.g. Review or Clinical Trial" - }, - { - "name": "has-abstract", - "type": "boolean", - "default": false, - "required": false, - "help": "Only include articles with abstracts" - }, - { - "name": "free-full-text", - "type": "boolean", - "default": false, - "required": false, - "help": "Only include free full text articles" - }, - { - "name": "humans-only", - "type": "boolean", - "default": false, - "required": false, - "help": "Only include human studies" - }, - { - "name": "english-only", - "type": "boolean", - "default": false, - "required": false, - "help": "Only include English articles" - }, - { - "name": "sort", - "type": "str", - "default": "relevance", - "required": false, - "help": "Sort by relevance, date, author, or journal", - "choices": [ - "relevance", - "date", - "author", - "journal" - ] - } - ], - "columns": [ - "rank", - "pmid", - "title", - "authors", - "journal", - "year", - "article_type", - "doi", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/pubmed/search.js", - "sourceFile": "plugins/pubmed/search.js" - }, - { - "site": "pypi", - "name": "downloads", - "description": "PyPI download stats for a package (recent totals or full daily history)", - "access": "read", - "domain": "pypistats.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "name", - "type": "str", - "required": true, - "positional": true, - "help": "PyPI package name (e.g. \"requests\", \"pandas\")" - }, - { - "name": "period", - "type": "str", - "default": "recent", - "required": false, - "help": "recent (default — 1 row, last day/week/month) or overall (1 row per day)" - } - ], - "columns": [ - "rank", - "package", - "period", - "date", - "downloads" - ], - "type": "js", - "modulePath": "plugins/pypi/downloads.js", - "sourceFile": "plugins/pypi/downloads.js" - }, - { - "site": "pypi", - "name": "package", - "description": "Single PyPI package metadata (latest version, license, homepage, classifiers)", - "access": "read", - "domain": "pypi.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "name", - "type": "str", - "required": true, - "positional": true, - "help": "PyPI package name (e.g. \"requests\", \"pandas\")" - } - ], - "columns": [ - "name", - "latestVersion", - "summary", - "author", - "license", - "homepage", - "repository", - "requiresPython", - "keywords", - "releases", - "firstReleased", - "lastReleased", - "url" - ], - "type": "js", - "modulePath": "plugins/pypi/package.js", - "sourceFile": "plugins/pypi/package.js" - }, - { - "site": "pypi", - "name": "releases", - "description": "List recent public PyPI package releases", - "access": "read", - "domain": "pypi.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "name", - "type": "string", - "required": true, - "positional": true, - "help": "Python package name, for example django" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Maximum releases to return (1-50)" - } - ], - "columns": [ - "version", - "uploadedAt", - "fileCount", - "pythonVersions", - "yanked", - "url" - ], - "type": "js", - "modulePath": "plugins/pypi/releases.js", - "sourceFile": "plugins/pypi/releases.js" - }, - { - "site": "qoder", - "name": "account", - "description": "Click the account button (username) in the Qoder sidebar and return the visible account dropdown items.", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "username", - "type": "str", - "required": false, - "help": "Username text shown in the sidebar (default: tries common short labels)" - } - ], - "columns": [ - "Field", - "Value" - ], - "type": "js", - "modulePath": "plugins/qoder/ui.js", - "sourceFile": "plugins/qoder/ui.js", - "navigateBefore": true - }, - { - "site": "qoder", - "name": "add-workspace", - "description": "Click \"Add Workspace\" — opens the folder picker. Note: this opens a system file-picker dialog that Qoder controls; the actual folder selection must be done in the UI by the user.", - "access": "write", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Status" - ], - "type": "js", - "modulePath": "plugins/qoder/ui.js", - "sourceFile": "plugins/qoder/ui.js", - "navigateBefore": true - }, - { - "site": "qoder", - "name": "ask", - "description": "Send a prompt to Qoder and wait up to --timeout seconds for the reply (best-effort: polls for the chat turn count to grow + stabilize).", - "access": "write", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "text", - "type": "str", - "required": true, - "positional": true, - "help": "Prompt text" - }, - { - "name": "timeout", - "type": "int", - "default": 120, - "required": false, - "help": "Max seconds to wait" - } - ], - "columns": [ - "Role", - "Text", - "WaitedSeconds" - ], - "type": "js", - "modulePath": "plugins/qoder/quest.js", - "sourceFile": "plugins/qoder/quest.js", - "navigateBefore": true - }, - { - "site": "qoder", - "name": "credits", - "description": "Click \"Credits Usage\" and return the credits-usage display text.", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Field", - "Value" - ], - "type": "js", - "modulePath": "plugins/qoder/ui.js", - "sourceFile": "plugins/qoder/ui.js", - "navigateBefore": true - }, - { - "site": "qoder", - "name": "history", - "description": "List Quests visible in the Qoder sidebar. Returns title + visible metadata.", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 50, - "required": false, - "help": "" - } - ], - "columns": [ - "Index", - "Title" - ], - "type": "js", - "modulePath": "plugins/qoder/history.js", - "sourceFile": "plugins/qoder/history.js", - "navigateBefore": true - }, - { - "site": "qoder", - "name": "knowledge", - "description": "Open the Knowledge view (Qoder's personal/team knowledge base).", - "access": "write", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Status" - ], - "type": "js", - "modulePath": "plugins/qoder/ui.js", - "sourceFile": "plugins/qoder/ui.js", - "navigateBefore": true - }, - { - "site": "qoder", - "name": "marketplace", - "description": "Open the Qoder Marketplace.", - "access": "write", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Status" - ], - "type": "js", - "modulePath": "plugins/qoder/ui.js", - "sourceFile": "plugins/qoder/ui.js", - "navigateBefore": true - }, - { - "site": "qoder", - "name": "more-actions", - "description": "Click the \"More Actions\" button and list its menu items.", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Index", - "Item" - ], - "type": "js", - "modulePath": "plugins/qoder/ui.js", - "sourceFile": "plugins/qoder/ui.js", - "navigateBefore": true - }, - { - "site": "qoder", - "name": "new", - "description": "Start a new Qoder Quest (conversation). Clicks the \"New Quest\" button in the sidebar (or its ⌘N variant).", - "access": "write", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Status" - ], - "type": "js", - "modulePath": "plugins/qoder/quest.js", - "sourceFile": "plugins/qoder/quest.js", - "navigateBefore": true - }, - { - "site": "qoder", - "name": "open-editor", - "description": "Click \"Open Editor\" — opens the current draft in a full editor pane.", - "access": "write", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Status" - ], - "type": "js", - "modulePath": "plugins/qoder/composer.js", - "sourceFile": "plugins/qoder/composer.js", - "navigateBefore": true - }, - { - "site": "qoder", - "name": "open-panel", - "description": "Open / close the Qoder bottom panel (Output / Terminal / Debug Console). ⌥⌘B equivalent.", - "access": "write", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Status" - ], - "type": "js", - "modulePath": "plugins/qoder/ui.js", - "sourceFile": "plugins/qoder/ui.js", - "navigateBefore": true - }, - { - "site": "qoder", - "name": "prompt-enhance", - "description": "Click \"Prompt Enhance\" — Qoder rewrites the current composer draft for better LLM consumption.", - "access": "write", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Status" - ], - "type": "js", - "modulePath": "plugins/qoder/composer.js", - "sourceFile": "plugins/qoder/composer.js", - "navigateBefore": true - }, - { - "site": "qoder", - "name": "read", - "description": "Read messages in the current Qoder Quest. Returns role + text for each visible turn.", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 30, - "required": false, - "help": "" - } - ], - "columns": [ - "Index", - "Role", - "Text" - ], - "type": "js", - "modulePath": "plugins/qoder/read.js", - "sourceFile": "plugins/qoder/read.js", - "navigateBefore": true - }, - { - "site": "qoder", - "name": "search", - "description": "Open Qoder Search palette (⌘P), type a query, return matched options.", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search text" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "" - } - ], - "columns": [ - "Index", - "Item" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/qoder/ui.js", - "sourceFile": "plugins/qoder/ui.js", - "navigateBefore": true - }, - { - "site": "qoder", - "name": "send", - "description": "Type text into the Qoder composer and click \"Send message\" (fire-and-forget).", - "access": "write", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "text", - "type": "str", - "required": true, - "positional": true, - "help": "Text to send" - } - ], - "columns": [ - "Status", - "Length" - ], - "type": "js", - "modulePath": "plugins/qoder/quest.js", - "sourceFile": "plugins/qoder/quest.js", - "navigateBefore": true - }, - { - "site": "qoder", - "name": "settings", - "description": "Click the Settings button in the Qoder sidebar.", - "access": "write", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Status" - ], - "type": "js", - "modulePath": "plugins/qoder/ui.js", - "sourceFile": "plugins/qoder/ui.js", - "navigateBefore": true - }, - { - "site": "qoder", - "name": "sidebar-toggle", - "description": "Collapse / Expand the Qoder Quest List sidebar (⌘B).", - "access": "write", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Status" - ], - "type": "js", - "modulePath": "plugins/qoder/ui.js", - "sourceFile": "plugins/qoder/ui.js", - "navigateBefore": true - }, - { - "site": "qoder", - "name": "status", - "description": "Check Qoder CDP connection and report the current renderer URL + title.", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Status", - "Url", - "Title" - ], - "type": "js", - "modulePath": "plugins/qoder/status.js", - "sourceFile": "plugins/qoder/status.js", - "navigateBefore": true - }, - { - "site": "qoder", - "name": "view-all", - "description": "Click \"View all\" to show all Quests.", - "access": "write", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Status" - ], - "type": "js", - "modulePath": "plugins/qoder/ui.js", - "sourceFile": "plugins/qoder/ui.js", - "navigateBefore": true - }, - { - "site": "reddit", - "name": "comment", - "description": "Post a comment on a Reddit post", - "access": "write", - "domain": "reddit.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "post-id", - "type": "string", - "required": true, - "positional": true, - "help": "Post ID (e.g. 1abc123) or fullname (t3_xxx)" - }, - { - "name": "text", - "type": "string", - "required": true, - "positional": true, - "help": "Comment text" - } - ], - "columns": [ - "status", - "message" - ], - "type": "js", - "modulePath": "plugins/reddit/comment.js", - "sourceFile": "plugins/reddit/comment.js", - "navigateBefore": "https://reddit.com" - }, - { - "site": "reddit", - "name": "frontpage", - "description": "Reddit Frontpage / r/all", - "access": "read", - "domain": "reddit.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 15, - "required": false, - "help": "" - } - ], - "columns": [ - "title", - "subreddit", - "author", - "upvotes", - "comments", - "url", - "post_hint", - "url_overridden_by_dest", - "preview_image_url", - "gallery_urls" - ], - "type": "js", - "modulePath": "plugins/reddit/frontpage.js", - "sourceFile": "plugins/reddit/frontpage.js", - "navigateBefore": "https://reddit.com" - }, - { - "site": "reddit", - "name": "home", - "description": "Reddit personalized home feed (Best, requires login)", - "access": "read", - "domain": "reddit.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 25, - "required": false, - "help": "Number of posts (1–100)" - } - ], - "columns": [ - "rank", - "title", - "subreddit", - "score", - "comments", - "postId", - "author", - "url", - "post_hint", - "url_overridden_by_dest", - "preview_image_url", - "gallery_urls" - ], - "type": "js", - "modulePath": "plugins/reddit/home.js", - "sourceFile": "plugins/reddit/home.js", - "navigateBefore": "https://reddit.com" - }, - { - "site": "reddit", - "name": "hot", - "description": "Reddit hot posts", - "access": "read", - "domain": "www.reddit.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "subreddit", - "type": "str", - "default": "", - "required": false, - "help": "Subreddit name (e.g. programming). Empty for frontpage" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of posts" - } - ], - "columns": [ - "rank", - "title", - "subreddit", - "score", - "comments", - "postId", - "author", - "url", - "post_hint", - "url_overridden_by_dest", - "preview_image_url", - "gallery_urls" - ], - "type": "js", - "modulePath": "plugins/reddit/hot.js", - "sourceFile": "plugins/reddit/hot.js", - "navigateBefore": "https://www.reddit.com" - }, - { - "site": "reddit", - "name": "login", - "description": "Open reddit login", - "access": "write", - "domain": "reddit.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "status", - "logged_in", - "site", - "username", - "id", - "action", - "verify_command" - ], - "type": "js", - "modulePath": "plugins/reddit/auth.js", - "sourceFile": "plugins/reddit/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "reddit", - "name": "popular", - "description": "Reddit Popular posts (/r/popular)", - "access": "read", - "domain": "reddit.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "" - } - ], - "columns": [ - "rank", - "id", - "title", - "subreddit", - "score", - "comments", - "author", - "url", - "created_utc", - "selftext", - "post_hint", - "url_overridden_by_dest", - "preview_image_url", - "gallery_urls" - ], - "type": "js", - "modulePath": "plugins/reddit/popular.js", - "sourceFile": "plugins/reddit/popular.js", - "navigateBefore": "https://reddit.com" - }, - { - "site": "reddit", - "name": "read", - "description": "Read a Reddit post and its comments", - "access": "read", - "domain": "reddit.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "post-id", - "type": "str", - "required": true, - "positional": true, - "help": "Post ID (e.g. 1abc123) or full URL" - }, - { - "name": "sort", - "type": "str", - "default": "best", - "required": false, - "help": "Comment sort: best, top, new, controversial, old, qa" - }, - { - "name": "limit", - "type": "int", - "default": 25, - "required": false, - "help": "Number of top-level comments" - }, - { - "name": "depth", - "type": "int", - "default": 2, - "required": false, - "help": "Max reply depth (1=no replies, 2=one level of replies, etc.)" - }, - { - "name": "replies", - "type": "int", - "default": 5, - "required": false, - "help": "Max replies shown per comment at each level (sorted by score)" - }, - { - "name": "max-length", - "type": "int", - "default": 2000, - "required": false, - "help": "Max characters per comment body (min 100)" - }, - { - "name": "expand-more", - "type": "bool", - "default": false, - "required": false, - "help": "Follow Reddit \"more comments\" stubs by calling /api/morechildren.json" - }, - { - "name": "expand-rounds", - "type": "int", - "default": 2, - "required": false, - "help": "Max expansion passes when --expand-more is on (1–5; each round can fan out new \"more\" stubs)" - } - ], - "columns": [ - "type", - "author", - "score", - "text", - "post_hint", - "url_overridden_by_dest", - "preview_image_url", - "gallery_urls" - ], - "type": "js", - "modulePath": "plugins/reddit/read.js", - "sourceFile": "plugins/reddit/read.js", - "navigateBefore": "https://reddit.com" - }, - { - "site": "reddit", - "name": "reply", - "description": "Reply to a Reddit comment", - "access": "write", - "domain": "reddit.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "comment-id", - "type": "string", - "required": true, - "positional": true, - "help": "Comment ID (e.g. okf3s7u) or fullname (t1_xxx)" - }, - { - "name": "text", - "type": "string", - "required": true, - "positional": true, - "help": "Reply text" - } - ], - "columns": [ - "status", - "message" - ], - "type": "js", - "modulePath": "plugins/reddit/reply.js", - "sourceFile": "plugins/reddit/reply.js", - "navigateBefore": "https://reddit.com" - }, - { - "site": "reddit", - "name": "save", - "description": "Save or unsave a Reddit post", - "access": "write", - "domain": "reddit.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "post-id", - "type": "string", - "required": true, - "positional": true, - "help": "Post ID (e.g. 1abc123) or fullname (t3_xxx)" - }, - { - "name": "undo", - "type": "boolean", - "default": false, - "required": false, - "help": "Unsave instead of save" - } - ], - "columns": [ - "status", - "message" - ], - "type": "js", - "modulePath": "plugins/reddit/save.js", - "sourceFile": "plugins/reddit/save.js", - "navigateBefore": "https://reddit.com" - }, - { - "site": "reddit", - "name": "saved", - "description": "Browse your saved Reddit posts", - "access": "read", - "domain": "reddit.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 15, - "required": false, - "help": "" - } - ], - "columns": [ - "title", - "subreddit", - "score", - "comments", - "url" - ], - "type": "js", - "modulePath": "plugins/reddit/saved.js", - "sourceFile": "plugins/reddit/saved.js", - "navigateBefore": "https://reddit.com" - }, - { - "site": "reddit", - "name": "search", - "description": "Search Reddit Posts", - "access": "read", - "domain": "reddit.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "query", - "type": "string", - "required": true, - "positional": true, - "help": "Reddit search query" - }, - { - "name": "subreddit", - "type": "string", - "default": "", - "required": false, - "help": "Search within a specific subreddit" - }, - { - "name": "sort", - "type": "string", - "default": "relevance", - "required": false, - "help": "Sort order: relevance, hot, top, new, comments" - }, - { - "name": "time", - "type": "string", - "default": "all", - "required": false, - "help": "Time filter: hour, day, week, month, year, all" - }, - { - "name": "limit", - "type": "int", - "default": 15, - "required": false, - "help": "" - } - ], - "columns": [ - "id", - "title", - "subreddit", - "author", - "score", - "comments", - "url", - "created_utc", - "selftext", - "post_hint", - "url_overridden_by_dest", - "preview_image_url", - "gallery_urls" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/reddit/search.js", - "sourceFile": "plugins/reddit/search.js", - "navigateBefore": "https://reddit.com" - }, - { - "site": "reddit", - "name": "subreddit", - "description": "Get posts from a specific Subreddit", - "access": "read", - "domain": "reddit.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "name", - "type": "string", - "required": true, - "positional": true, - "help": "Subreddit name (no `r/` prefix; e.g. `python`)" - }, - { - "name": "sort", - "type": "string", - "default": "hot", - "required": false, - "help": "Sorting method: hot, new, top, rising, controversial" - }, - { - "name": "time", - "type": "string", - "default": "all", - "required": false, - "help": "Time filter for top/controversial: hour, day, week, month, year, all" - }, - { - "name": "limit", - "type": "int", - "default": 15, - "required": false, - "help": "" - } - ], - "columns": [ - "id", - "title", - "subreddit", - "author", - "upvotes", - "comments", - "url", - "created_utc", - "selftext", - "post_hint", - "url_overridden_by_dest", - "preview_image_url", - "gallery_urls" - ], - "type": "js", - "modulePath": "plugins/reddit/subreddit.js", - "sourceFile": "plugins/reddit/subreddit.js", - "navigateBefore": "https://reddit.com" - }, - { - "site": "reddit", - "name": "subreddit-info", - "description": "Show metadata for a Reddit subreddit (subscribers, description, created date, NSFW)", - "access": "read", - "domain": "reddit.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "name", - "type": "string", - "required": true, - "positional": true, - "help": "Subreddit name (no `r/` prefix needed)" - } - ], - "columns": [ - "field", - "value" - ], - "type": "js", - "modulePath": "plugins/reddit/subreddit-info.js", - "sourceFile": "plugins/reddit/subreddit-info.js", - "navigateBefore": "https://reddit.com" - }, - { - "site": "reddit", - "name": "subscribe", - "description": "Subscribe or unsubscribe to a subreddit", - "access": "write", - "domain": "reddit.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "subreddit", - "type": "string", - "required": true, - "positional": true, - "help": "Subreddit name (e.g. python)" - }, - { - "name": "undo", - "type": "boolean", - "default": false, - "required": false, - "help": "Unsubscribe instead of subscribe" - } - ], - "columns": [ - "status", - "message" - ], - "type": "js", - "modulePath": "plugins/reddit/subscribe.js", - "sourceFile": "plugins/reddit/subscribe.js", - "navigateBefore": "https://reddit.com" - }, - { - "site": "reddit", - "name": "subscribed", - "description": "List subreddits you are subscribed to", - "access": "read", - "domain": "reddit.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 100, - "required": false, - "help": "Max subreddits to return (1-1000, auto-paginates)" - } - ], - "columns": [ - "id", - "subreddit", - "title", - "subscribers", - "description", - "url" - ], - "type": "js", - "modulePath": "plugins/reddit/subscribed.js", - "sourceFile": "plugins/reddit/subscribed.js", - "navigateBefore": "https://reddit.com" - }, - { - "site": "reddit", - "name": "upvote", - "description": "Upvote or downvote a Reddit post", - "access": "write", - "domain": "reddit.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "post-id", - "type": "string", - "required": true, - "positional": true, - "help": "Post ID (e.g. 1abc123) or fullname (t3_xxx)" - }, - { - "name": "direction", - "type": "string", - "default": "up", - "required": false, - "help": "Vote direction: up, down, none" - } - ], - "columns": [ - "status", - "message" - ], - "type": "js", - "modulePath": "plugins/reddit/upvote.js", - "sourceFile": "plugins/reddit/upvote.js", - "navigateBefore": "https://reddit.com" - }, - { - "site": "reddit", - "name": "upvoted", - "description": "Browse your upvoted Reddit posts", - "access": "read", - "domain": "reddit.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 15, - "required": false, - "help": "" - } - ], - "columns": [ - "title", - "subreddit", - "score", - "comments", - "url" - ], - "type": "js", - "modulePath": "plugins/reddit/upvoted.js", - "sourceFile": "plugins/reddit/upvoted.js", - "navigateBefore": "https://reddit.com" - }, - { - "site": "reddit", - "name": "user", - "description": "View a Reddit user profile", - "access": "read", - "domain": "reddit.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "username", - "type": "string", - "required": true, - "positional": true, - "help": "Reddit username (no `u/` prefix needed)" - } - ], - "columns": [ - "field", - "value" - ], - "type": "js", - "modulePath": "plugins/reddit/user.js", - "sourceFile": "plugins/reddit/user.js", - "navigateBefore": "https://reddit.com" - }, - { - "site": "reddit", - "name": "user-comments", - "description": "View a Reddit user's comment history", - "access": "read", - "domain": "reddit.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "username", - "type": "string", - "required": true, - "positional": true, - "help": "Reddit username (no `u/` prefix needed)" - }, - { - "name": "limit", - "type": "int", - "default": 15, - "required": false, - "help": "" - } - ], - "columns": [ - "subreddit", - "score", - "body", - "url" - ], - "type": "js", - "modulePath": "plugins/reddit/user-comments.js", - "sourceFile": "plugins/reddit/user-comments.js", - "navigateBefore": "https://reddit.com" - }, - { - "site": "reddit", - "name": "user-posts", - "description": "View a Reddit user's submitted posts", - "access": "read", - "domain": "reddit.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "username", - "type": "string", - "required": true, - "positional": true, - "help": "Reddit username (no `u/` prefix needed)" - }, - { - "name": "limit", - "type": "int", - "default": 15, - "required": false, - "help": "" - } - ], - "columns": [ - "title", - "subreddit", - "score", - "comments", - "url" - ], - "type": "js", - "modulePath": "plugins/reddit/user-posts.js", - "sourceFile": "plugins/reddit/user-posts.js", - "navigateBefore": "https://reddit.com" - }, - { - "site": "reddit", - "name": "whoami", - "description": "Show the currently logged-in Reddit user", - "access": "read", - "domain": "reddit.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "field", - "value" - ], - "type": "js", - "modulePath": "plugins/reddit/whoami.js", - "sourceFile": "plugins/reddit/whoami.js", - "navigateBefore": "https://reddit.com" - }, - { - "site": "rest-countries", - "name": "country", - "description": "Look up countries by name (common / official, substring match)", - "access": "read", - "domain": "restcountries.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "name", - "type": "str", - "required": true, - "positional": true, - "help": "Country name (e.g. \"japan\", \"united kingdom\")" - }, - { - "name": "limit", - "type": "int", - "default": 25, - "required": false, - "help": "Max rows (1-250)" - } - ], - "columns": [ - "rank", - "commonName", - "officialName", - "cca2", - "cca3", - "ccn3", - "capital", - "region", - "subregion", - "population", - "area", - "languages", - "currencies", - "latitude", - "longitude", - "timezones", - "independent", - "unMember", - "landlocked", - "flag", - "url" - ], - "type": "js", - "modulePath": "plugins/rest-countries/country.js", - "sourceFile": "plugins/rest-countries/country.js" - }, - { - "site": "rest-countries", - "name": "region", - "description": "List countries in a region (africa / americas / asia / europe / oceania / antarctic)", - "access": "read", - "domain": "restcountries.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "region", - "type": "str", - "required": true, - "positional": true, - "help": "Region name (case-insensitive)" - }, - { - "name": "limit", - "type": "int", - "default": 250, - "required": false, - "help": "Max rows (1-250)" - } - ], - "columns": [ - "rank", - "commonName", - "officialName", - "cca2", - "cca3", - "ccn3", - "capital", - "region", - "subregion", - "population", - "area", - "languages", - "currencies", - "latitude", - "longitude", - "timezones", - "independent", - "unMember", - "landlocked", - "flag", - "url" - ], - "type": "js", - "modulePath": "plugins/rest-countries/region.js", - "sourceFile": "plugins/rest-countries/region.js" - }, - { - "site": "reuters", - "name": "article-detail", - "description": "Reuters Reuters article detail:title/author/body text", - "access": "read", - "domain": "www.reuters.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "url", - "type": "str", - "required": true, - "positional": true, - "help": "Reuters article URL (must be on reuters.com)" - } - ], - "columns": [ - "title", - "date", - "section", - "section_path", - "authors", - "description", - "word_count", - "url", - "body" - ], - "type": "js", - "modulePath": "plugins/reuters/article-detail.js", - "sourceFile": "plugins/reuters/article-detail.js", - "navigateBefore": "https://www.reuters.com" - }, - { - "site": "reuters", - "name": "login", - "description": "Open reuters login", - "access": "write", - "domain": "reuters.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "status", - "logged_in", - "site", - "user_id", - "subscribed", - "action", - "verify_command" - ], - "type": "js", - "modulePath": "plugins/reuters/auth.js", - "sourceFile": "plugins/reuters/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "reuters", - "name": "search", - "description": "Reuters Reuters news search", - "access": "read", - "domain": "www.reuters.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search query" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of results (1-40)" - } - ], - "columns": [ - "rank", - "title", - "date", - "section", - "section_path", - "authors", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/reuters/search.js", - "sourceFile": "plugins/reuters/search.js", - "navigateBefore": "https://www.reuters.com" - }, - { - "site": "reuters", - "name": "whoami", - "description": "Show the current logged-in reuters account", - "access": "read", - "domain": "reuters.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "logged_in", - "site", - "user_id", - "subscribed" - ], - "type": "js", - "modulePath": "plugins/reuters/auth.js", - "sourceFile": "plugins/reuters/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "rfc", - "name": "rfc", - "description": "Single IETF RFC metadata (title, abstract, working group, authors, std level)", - "access": "read", - "domain": "datatracker.ietf.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "number", - "type": "int", - "required": true, - "positional": true, - "help": "RFC number (e.g. 9000, 791, 2616)" - } - ], - "columns": [ - "rfc", - "title", - "state", - "stdLevel", - "group", - "groupType", - "pages", - "published", - "authors", - "abstract", - "rfcEditorUrl", - "url" - ], - "type": "js", - "modulePath": "plugins/rfc/rfc.js", - "sourceFile": "plugins/rfc/rfc.js" - }, - { - "site": "rubygems", - "name": "gem", - "description": "Fetch a RubyGems.org gem's metadata (version, downloads, license, links)", - "access": "read", - "domain": "rubygems.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "name", - "type": "str", - "required": true, - "positional": true, - "help": "Gem name (e.g. \"rails\", \"sidekiq\")" - } - ], - "columns": [ - "gem", - "version", - "releasedAt", - "downloads", - "versionDownloads", - "license", - "authors", - "homepage", - "source", - "bugs", - "info", - "url" - ], - "type": "js", - "modulePath": "plugins/rubygems/gem.js", - "sourceFile": "plugins/rubygems/gem.js" - }, - { - "site": "rubygems", - "name": "search", - "description": "Search RubyGems.org gems by keyword", - "access": "read", - "domain": "rubygems.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search keyword (e.g. \"rails\", \"redis\")" - }, - { - "name": "limit", - "type": "int", - "default": 30, - "required": false, - "help": "Max gems (1-100, single RubyGems page)" - } - ], - "columns": [ - "rank", - "gem", - "version", - "downloads", - "license", - "authors", - "info", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/rubygems/search.js", - "sourceFile": "plugins/rubygems/search.js" - }, - { - "site": "semanticscholar", - "name": "citations", - "description": "List papers that cite a Semantic Scholar paper (paginated)", - "access": "read", - "domain": "api.semanticscholar.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "id", - "type": "str", - "required": true, - "positional": true, - "help": "paperId (40-char hex), DOI, arXiv id, or prefixed id" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max citing papers (1-1000, single Semantic Scholar page)" - }, - { - "name": "offset", - "type": "int", - "default": 0, - "required": false, - "help": "Page offset (0-based)" - } - ], - "columns": [ - "rank", - "paperId", - "doi", - "title", - "year", - "firstAuthor", - "citationCount", - "url" - ], - "type": "js", - "modulePath": "plugins/semanticscholar/citations.js", - "sourceFile": "plugins/semanticscholar/citations.js" - }, - { - "site": "semanticscholar", - "name": "paper", - "description": "Semantic Scholar paper detail (citation graph + AI tldr) by paperId, DOI, or arXiv id", - "access": "read", - "domain": "api.semanticscholar.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "id", - "type": "str", - "required": true, - "positional": true, - "help": "paperId (40-char hex), DOI, arXiv id, or prefixed id (e.g. \"ARXIV:1706.03762\", \"PMID:12345\")" - } - ], - "columns": [ - "paperId", - "doi", - "title", - "year", - "firstAuthor", - "citationCount", - "influentialCitationCount", - "referenceCount", - "tldr", - "url" - ], - "type": "js", - "modulePath": "plugins/semanticscholar/paper.js", - "sourceFile": "plugins/semanticscholar/paper.js" - }, - { - "site": "semanticscholar", - "name": "recommendations", - "description": "Semantic Scholar AI-curated related papers for a paperId, DOI, or arXiv id", - "access": "read", - "domain": "api.semanticscholar.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "id", - "type": "str", - "required": true, - "positional": true, - "help": "paperId (40-char hex), DOI, arXiv id, or prefixed id" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Max recommendations (1-500)" - } - ], - "columns": [ - "rank", - "paperId", - "doi", - "title", - "year", - "firstAuthor", - "citationCount", - "url" - ], - "type": "js", - "modulePath": "plugins/semanticscholar/recommendations.js", - "sourceFile": "plugins/semanticscholar/recommendations.js" - }, - { - "site": "semanticscholar", - "name": "search", - "description": "Search Semantic Scholar papers by free text", - "access": "read", - "domain": "api.semanticscholar.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search text (e.g. \"attention is all you need\", \"diffusion model\")" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max papers (1-100, single Semantic Scholar page)" - } - ], - "columns": [ - "rank", - "paperId", - "doi", - "title", - "year", - "firstAuthor", - "citationCount", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/semanticscholar/search.js", - "sourceFile": "plugins/semanticscholar/search.js" - }, - { - "site": "skyscanner", - "name": "flights", - "description": "Skyscanner visible round-trip flight results from a warmed browser session", - "access": "read", - "domain": "www.skyscanner.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "origin", - "type": "str", - "required": true, - "positional": true, - "help": "Skyscanner origin route code, for example nyca" - }, - { - "name": "destination", - "type": "str", - "required": true, - "positional": true, - "help": "Skyscanner destination route code, for example lond" - }, - { - "name": "depart-date", - "type": "str", - "required": true, - "help": "Outbound date as YYYY-MM-DD" - }, - { - "name": "return-date", - "type": "str", - "required": true, - "help": "Return date as YYYY-MM-DD" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Maximum flight rows to return (1-30)" - } - ], - "columns": [ - "rank", - "priceText", - "airlines", - "outboundTime", - "outboundRoute", - "outboundDuration", - "outboundStops", - "returnTime", - "returnRoute", - "returnDuration", - "returnStops", - "url" - ], - "type": "js", - "modulePath": "plugins/skyscanner/flights.js", - "sourceFile": "plugins/skyscanner/flights.js", - "navigateBefore": false - }, - { - "site": "slock", - "name": "attachment-download", - "description": "Download an attachment to a local file. Resolves a signed CDN URL in the page, then fetches bytes node-side (no CORS).", - "access": "read", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "attachmentId", - "type": "str", - "required": true, - "positional": true, - "help": "Attachment UUID" - }, - { - "name": "out", - "type": "str", - "required": false, - "help": "Local path to write to. Defaults to ./.bin", - "file": { - "direction": "output", - "pathKind": "file", - "multiple": false, - "defaultPath": "./attachment.bin" - } - }, - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server slug" - } - ], - "columns": [ - "attachmentId", - "out", - "sizeBytes" - ], - "type": "js", - "modulePath": "plugins/slock/attachment-download.js", - "sourceFile": "plugins/slock/attachment-download.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "attachment-upload", - "description": "Upload a local file to Slock attachments. Prints the attachmentId for use with `message-send --attach`.", - "access": "write", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "file", - "type": "str", - "required": true, - "positional": true, - "help": "Local file path to upload (single file; max 50 MB)", - "file": { - "direction": "input", - "pathKind": "file", - "multiple": false - } - }, - { - "name": "channel", - "type": "str", - "required": true, - "positional": true, - "help": "channelId UUID or #name — server requires the attachment be scoped to a channel" - }, - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server slug" - } - ], - "columns": [ - "attachmentId", - "filename", - "mimeType", - "sizeBytes" - ], - "type": "js", - "modulePath": "plugins/slock/attachment-upload.js", - "sourceFile": "plugins/slock/attachment-upload.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "attachment-url", - "description": "Get a short-lived signed CDN URL for an attachment (does not download bytes).", - "access": "read", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "attachmentId", - "type": "str", - "required": true, - "positional": true, - "help": "Attachment UUID" - }, - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server slug" - } - ], - "columns": [ - "attachmentId", - "url", - "expiresAt" - ], - "type": "js", - "modulePath": "plugins/slock/attachment-url.js", - "sourceFile": "plugins/slock/attachment-url.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "bookmark-add", - "description": "Bookmark a message (POST /channels/saved). Requires full messageId UUID.", - "access": "write", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "messageId", - "type": "str", - "required": true, - "positional": true, - "help": "Full messageId UUID (short ids rejected)" - }, - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server" - } - ], - "columns": [ - "messageId", - "saved" - ], - "type": "js", - "modulePath": "plugins/slock/bookmark-add.js", - "sourceFile": "plugins/slock/bookmark-add.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "bookmark-list", - "description": "List bookmarks (saved messages) in the active server", - "access": "read", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 50, - "required": false, - "help": "Max results" - }, - { - "name": "offset", - "type": "int", - "default": 0, - "required": false, - "help": "Offset" - }, - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server" - } - ], - "columns": [ - "id", - "messageId", - "content", - "savedAt" - ], - "type": "js", - "modulePath": "plugins/slock/bookmark-list.js", - "sourceFile": "plugins/slock/bookmark-list.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "bookmark-remove", - "description": "Remove a bookmark (DELETE /channels/saved/:messageId). 404 is treated as already-removed.", - "access": "write", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "messageId", - "type": "str", - "required": true, - "positional": true, - "help": "Full messageId UUID" - }, - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server" - } - ], - "columns": [ - "messageId", - "removed", - "note" - ], - "type": "js", - "modulePath": "plugins/slock/bookmark-remove.js", - "sourceFile": "plugins/slock/bookmark-remove.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "channel-archive", - "description": "Archive a channel — admin only (POST /channels/:id/archive)", - "access": "write", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "channel", - "type": "str", - "required": true, - "positional": true, - "help": "channelId UUID or #name" - }, - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server" - } - ], - "columns": [ - "channel", - "id", - "archivedAt", - "result" - ], - "type": "js", - "modulePath": "plugins/slock/channel-archive.js", - "sourceFile": "plugins/slock/channel-archive.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "channel-create", - "description": "Create a channel — admin only (POST /channels/). Public unless --private.", - "access": "write", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "name", - "type": "str", - "required": true, - "positional": true, - "help": "Channel name" - }, - { - "name": "description", - "type": "str", - "required": false, - "help": "Channel description / topic (≤500 chars)" - }, - { - "name": "private", - "type": "bool", - "default": false, - "required": false, - "help": "Create a private channel instead of public" - }, - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server" - } - ], - "columns": [ - "id", - "name", - "type", - "result" - ], - "type": "js", - "modulePath": "plugins/slock/channel-create.js", - "sourceFile": "plugins/slock/channel-create.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "channel-files", - "description": "List files shared in a channel (GET /channels/:id/files)", - "access": "read", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "channel", - "type": "str", - "required": true, - "positional": true, - "help": "channelId UUID or #name" - }, - { - "name": "limit", - "type": "int", - "default": 50, - "required": false, - "help": "Max files" - }, - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server" - } - ], - "columns": [ - "id", - "filename", - "mimeType", - "sizeBytes", - "messageId", - "createdAt" - ], - "type": "js", - "modulePath": "plugins/slock/channel-files.js", - "sourceFile": "plugins/slock/channel-files.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "channel-info", - "description": "Show one channel's details (GET /channels/:id)", - "access": "read", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "channel", - "type": "str", - "required": true, - "positional": true, - "help": "channelId UUID or #name" - }, - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server" - } - ], - "columns": [ - "id", - "name", - "type", - "topic", - "joined", - "archivedAt" - ], - "type": "js", - "modulePath": "plugins/slock/channel-info.js", - "sourceFile": "plugins/slock/channel-info.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "channel-join", - "description": "Join a public channel (POST /channels/:id/join)", - "access": "write", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "channel", - "type": "str", - "required": true, - "positional": true, - "help": "channelId UUID or #name" - }, - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server" - } - ], - "columns": [ - "channel", - "id", - "archivedAt", - "result" - ], - "type": "js", - "modulePath": "plugins/slock/channel-join.js", - "sourceFile": "plugins/slock/channel-join.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "channel-leave", - "description": "Leave a channel (POST /channels/:id/leave)", - "access": "write", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "channel", - "type": "str", - "required": true, - "positional": true, - "help": "channelId UUID or #name" - }, - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server" - } - ], - "columns": [ - "channel", - "id", - "archivedAt", - "result" - ], - "type": "js", - "modulePath": "plugins/slock/channel-leave.js", - "sourceFile": "plugins/slock/channel-leave.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "channel-list", - "description": "List channels in the active slock server", - "access": "read", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server (slug or id) for this call" - } - ], - "columns": [ - "id", - "name", - "topic" - ], - "type": "js", - "modulePath": "plugins/slock/channel-list.js", - "sourceFile": "plugins/slock/channel-list.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "channel-mark", - "description": "Mark a channel read (default), read up to --seq, or --unread.", - "access": "write", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "channel", - "type": "str", - "required": true, - "positional": true, - "help": "channelId UUID or #name" - }, - { - "name": "seq", - "type": "int", - "required": false, - "help": "Mark read up to this seq (omit for read-all)" - }, - { - "name": "unread", - "type": "bool", - "default": false, - "required": false, - "help": "Mark the channel unread instead of read" - }, - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server" - } - ], - "columns": [ - "channel", - "action", - "result" - ], - "type": "js", - "modulePath": "plugins/slock/channel-mark.js", - "sourceFile": "plugins/slock/channel-mark.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "channel-members", - "description": "List members of a channel", - "access": "read", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "channel", - "type": "str", - "required": true, - "positional": true, - "help": "channelId UUID or #name" - }, - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server (slug or id)" - } - ], - "columns": [ - "userId", - "name", - "kind", - "role" - ], - "type": "js", - "modulePath": "plugins/slock/channel-members.js", - "sourceFile": "plugins/slock/channel-members.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "channel-unarchive", - "description": "Unarchive a channel — admin only (POST /channels/:id/unarchive). #name lookups exclude archived channels; pass the channelId UUID for archived ones.", - "access": "write", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "channel", - "type": "str", - "required": true, - "positional": true, - "help": "channelId UUID or #name" - }, - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server" - } - ], - "columns": [ - "channel", - "id", - "archivedAt", - "result" - ], - "type": "js", - "modulePath": "plugins/slock/channel-unarchive.js", - "sourceFile": "plugins/slock/channel-unarchive.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "dm-list", - "description": "List DM channels in the active server (GET /channels/dm)", - "access": "read", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server (slug or id)" - } - ], - "columns": [ - "channelId", - "peerName", - "peerId", - "createdAt" - ], - "type": "js", - "modulePath": "plugins/slock/dm-list.js", - "sourceFile": "plugins/slock/dm-list.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "inbox", - "description": "List unified inbox items (channels, DMs, followed threads) that need attention.", - "access": "read", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "filter", - "type": "str", - "default": "all", - "required": false, - "help": "all | unread | mentions" - }, - { - "name": "limit", - "type": "int", - "default": 30, - "required": false, - "help": "Max items (server caps at 100)" - }, - { - "name": "offset", - "type": "int", - "default": 0, - "required": false, - "help": "Pagination offset" - }, - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server" - } - ], - "columns": [ - "kind", - "id", - "name", - "unreadCount", - "hasMention", - "lastActivityAt", - "preview" - ], - "type": "js", - "modulePath": "plugins/slock/inbox.js", - "sourceFile": "plugins/slock/inbox.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "inbox-done", - "description": "Mark one chat as done / clear it from the inbox (POST /channels/inbox/done)", - "access": "write", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "channel", - "type": "str", - "required": true, - "positional": true, - "help": "channelId UUID or #name" - }, - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server" - } - ], - "columns": [ - "channel", - "result" - ], - "type": "js", - "modulePath": "plugins/slock/inbox-done.js", - "sourceFile": "plugins/slock/inbox-done.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "inbox-read-all", - "description": "Mark the entire inbox as read (POST /channels/inbox/read-all)", - "access": "write", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server" - } - ], - "columns": [ - "result", - "markedCount" - ], - "type": "js", - "modulePath": "plugins/slock/inbox-read-all.js", - "sourceFile": "plugins/slock/inbox-read-all.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "login", - "description": "Open slock login", - "access": "write", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "status", - "logged_in", - "site", - "id", - "name", - "email", - "action", - "verify_command" - ], - "type": "js", - "modulePath": "plugins/slock/whoami.js", - "sourceFile": "plugins/slock/whoami.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "message-read", - "description": "Read messages in a channel or thread. Thread form: \"#channel:msgIdOrShort\". Use --after seq|UUID for cursor.", - "access": "read", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "channel", - "type": "str", - "required": true, - "positional": true, - "help": "channelId UUID, \"#name\", or \"#channel:msgIdOrShort\"" - }, - { - "name": "after", - "type": "str", - "required": false, - "help": "Cursor: seq number or messageId UUID (exclusive)" - }, - { - "name": "before", - "type": "str", - "required": false, - "help": "seq to page before" - }, - { - "name": "limit", - "type": "int", - "default": 50, - "required": false, - "help": "Max messages" - }, - { - "name": "no-threads", - "type": "bool", - "default": false, - "required": false, - "help": "Skip /threads enrichment" - }, - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server" - } - ], - "columns": [ - "id", - "seq", - "createdAt", - "senderName", - "content", - "threadChannelId", - "replyCount", - "unreadCount", - "lastReplyAt" - ], - "type": "js", - "modulePath": "plugins/slock/message-read.js", - "sourceFile": "plugins/slock/message-read.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "message-search", - "description": "Search messages", - "access": "read", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search query" - }, - { - "name": "channel", - "type": "str", - "required": false, - "help": "Restrict to a channel (UUID or #name)" - }, - { - "name": "limit", - "type": "int", - "default": 50, - "required": false, - "help": "Max results" - }, - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server" - } - ], - "columns": [ - "id", - "channelId", - "createdAt", - "senderName", - "content" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/slock/message-search.js", - "sourceFile": "plugins/slock/message-search.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "message-send", - "description": "Send a message to a channel, DM, or thread (content sent verbatim)", - "access": "write", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "target", - "type": "str", - "required": true, - "positional": true, - "help": "\"#channel\", \"#channel:msgIdOrShort\", \"dm:@name\", \"dm:\", or channel UUID" - }, - { - "name": "content", - "type": "str", - "required": true, - "positional": true, - "help": "Message body (sent verbatim, no marker)" - }, - { - "name": "dry-run", - "type": "bool", - "default": false, - "required": false, - "help": "Print the planned payload without sending" - }, - { - "name": "as-task", - "type": "bool", - "default": false, - "required": false, - "help": "Create the message as a task (asTask)" - }, - { - "name": "attach", - "type": "str", - "required": false, - "help": "Comma-separated attachmentId UUIDs (upload separately first)" - }, - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server (slug or id)" - } - ], - "columns": [ - "target", - "channelId", - "content", - "result", - "messageId" - ], - "type": "js", - "modulePath": "plugins/slock/message-send.js", - "sourceFile": "plugins/slock/message-send.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "reaction-add", - "description": "Add an emoji reaction to a message (POST /messages/:id/reactions). Idempotent server-side.", - "access": "write", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "messageId", - "type": "str", - "required": true, - "positional": true, - "help": "Full messageId UUID (short ids rejected)" - }, - { - "name": "emoji", - "type": "str", - "required": true, - "positional": true, - "help": "A single unicode emoji, e.g. 👍" - }, - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server" - } - ], - "columns": [ - "messageId", - "emoji", - "result" - ], - "type": "js", - "modulePath": "plugins/slock/reaction-add.js", - "sourceFile": "plugins/slock/reaction-add.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "reaction-remove", - "description": "Remove your emoji reaction from a message (DELETE /messages/:id/reactions).", - "access": "write", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "messageId", - "type": "str", - "required": true, - "positional": true, - "help": "Full messageId UUID (short ids rejected)" - }, - { - "name": "emoji", - "type": "str", - "required": true, - "positional": true, - "help": "The unicode emoji to remove, e.g. 👍" - }, - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server" - } - ], - "columns": [ - "messageId", - "emoji", - "result" - ], - "type": "js", - "modulePath": "plugins/slock/reaction-remove.js", - "sourceFile": "plugins/slock/reaction-remove.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "server-list", - "description": "List slock servers you belong to; marks active per localStorage slug", - "access": "read", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "id", - "slug", - "name", - "active" - ], - "type": "js", - "modulePath": "plugins/slock/server-list.js", - "sourceFile": "plugins/slock/server-list.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "server-use", - "description": "Set the active slock server (writes localStorage.slock_last_server_slug)", - "access": "write", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "input", - "type": "str", - "required": true, - "positional": true, - "help": "server slug, \"#slug\", or UUID id" - } - ], - "columns": [ - "id", - "slug", - "name", - "written" - ], - "type": "js", - "modulePath": "plugins/slock/server-use.js", - "sourceFile": "plugins/slock/server-use.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "task-claim", - "description": "Claim a chat task (PATCH /tasks/:id/claim). Requires full task UUID (= message id).", - "access": "write", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "taskId", - "type": "str", - "required": true, - "positional": true, - "help": "Full task UUID (= message id; short ids rejected)" - }, - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server" - } - ], - "columns": [ - "taskId", - "taskStatus", - "assigneeId", - "taskNumber" - ], - "type": "js", - "modulePath": "plugins/slock/task-claim.js", - "sourceFile": "plugins/slock/task-claim.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "task-convert", - "description": "Convert a message into a chat task (POST /tasks/convert-message). Accepts a message UUID or \"#channel:shortId\".", - "access": "write", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "messageId", - "type": "str", - "required": true, - "positional": true, - "help": "Full message UUID, or \"#channel:shortId\" (short id expanded via /messages/context)" - }, - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server" - } - ], - "columns": [ - "id", - "taskNumber", - "title", - "taskStatus", - "channelId" - ], - "type": "js", - "modulePath": "plugins/slock/task-convert.js", - "sourceFile": "plugins/slock/task-convert.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "task-create", - "description": "Create a task in a channel (single title; batch 1-50 is server-supported but client surface is single — see backlog R4).", - "access": "write", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "channel", - "type": "str", - "required": true, - "positional": true, - "help": "channelId UUID or #name" - }, - { - "name": "title", - "type": "str", - "required": true, - "positional": true, - "help": "Task title (single; batch TODO via R4)" - }, - { - "name": "desc", - "type": "str", - "required": false, - "help": "Optional description body for the task" - }, - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server" - } - ], - "columns": [ - "id", - "taskNumber", - "title", - "taskStatus", - "channelId" - ], - "type": "js", - "modulePath": "plugins/slock/task-create.js", - "sourceFile": "plugins/slock/task-create.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "task-delete", - "description": "Delete a chat task (DELETE /tasks/:taskId). Requires --confirm — destructive, irreversible.", - "access": "write", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "taskId", - "type": "str", - "required": true, - "positional": true, - "help": "Full task UUID (= message id; short ids rejected)" - }, - { - "name": "confirm", - "type": "bool", - "default": false, - "required": false, - "help": "Required acknowledgement: deletion is irreversible" - }, - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server" - } - ], - "columns": [ - "taskId", - "deleted" - ], - "type": "js", - "modulePath": "plugins/slock/task-delete.js", - "sourceFile": "plugins/slock/task-delete.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "task-get", - "description": "Fetch a task by channel + taskNumber (GET /tasks/channel/:channelId/number/:taskNumber).", - "access": "read", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "channel", - "type": "str", - "required": true, - "positional": true, - "help": "channelId UUID or #name" - }, - { - "name": "number", - "type": "str", - "required": true, - "positional": true, - "help": "taskNumber (per-channel integer, as shown in \"task #N\")" - }, - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server" - } - ], - "columns": [ - "id", - "taskNumber", - "title", - "taskStatus", - "assigneeId" - ], - "type": "js", - "modulePath": "plugins/slock/task-get.js", - "sourceFile": "plugins/slock/task-get.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "task-list", - "description": "List tasks (chat tasks = messages with task fields) attached to a channel. Optional --status filter.", - "access": "read", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "channel", - "type": "str", - "required": true, - "positional": true, - "help": "channelId UUID or #name" - }, - { - "name": "status", - "type": "str", - "required": false, - "help": "Filter by status: todo|in_progress|in_review|done|closed" - }, - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server" - } - ], - "columns": [ - "id", - "taskNumber", - "title", - "taskStatus", - "assigneeId" - ], - "type": "js", - "modulePath": "plugins/slock/task-list.js", - "sourceFile": "plugins/slock/task-list.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "task-list-server", - "description": "List tasks across all channels in the active server (GET /tasks/server). Optional --status filter.", - "access": "read", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "status", - "type": "str", - "required": false, - "help": "Filter by status: todo|in_progress|in_review|done|closed" - }, - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server" - } - ], - "columns": [ - "id", - "taskNumber", - "title", - "taskStatus", - "channelId", - "assigneeId" - ], - "type": "js", - "modulePath": "plugins/slock/task-list-server.js", - "sourceFile": "plugins/slock/task-list-server.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "task-status", - "description": "Set a task's status (PATCH /tasks/:taskId/status, body {status}). One of todo|in_progress|in_review|done|closed.", - "access": "write", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "taskId", - "type": "str", - "required": true, - "positional": true, - "help": "Full task UUID (= message id; short ids rejected)" - }, - { - "name": "status", - "type": "str", - "required": true, - "positional": true, - "help": "One of: todo|in_progress|in_review|done|closed" - }, - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server" - } - ], - "columns": [ - "taskId", - "taskStatus", - "assigneeId", - "taskNumber" - ], - "type": "js", - "modulePath": "plugins/slock/task-status.js", - "sourceFile": "plugins/slock/task-status.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "task-unclaim", - "description": "Release ownership of a chat task (PATCH /tasks/:id/unclaim).", - "access": "write", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "taskId", - "type": "str", - "required": true, - "positional": true, - "help": "Full task UUID (= message id; short ids rejected)" - }, - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server" - } - ], - "columns": [ - "taskId", - "taskStatus", - "assigneeId", - "taskNumber" - ], - "type": "js", - "modulePath": "plugins/slock/task-unclaim.js", - "sourceFile": "plugins/slock/task-unclaim.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "thread-done", - "description": "Mark a thread as done / hide it from the active list (POST /channels/threads/done)", - "access": "write", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "threadChannelId", - "type": "str", - "required": true, - "positional": true, - "help": "Thread channel UUID (from thread-list / message-read)" - }, - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server" - } - ], - "columns": [ - "threadChannelId", - "result" - ], - "type": "js", - "modulePath": "plugins/slock/thread-done.js", - "sourceFile": "plugins/slock/thread-done.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "thread-follow", - "description": "Follow the thread on a parent message (POST /channels/threads/follow)", - "access": "write", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "parentMessageId", - "type": "str", - "required": true, - "positional": true, - "help": "Full parent messageId UUID (short ids rejected)" - }, - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server" - } - ], - "columns": [ - "parentMessageId", - "threadChannelId", - "result" - ], - "type": "js", - "modulePath": "plugins/slock/thread-follow.js", - "sourceFile": "plugins/slock/thread-follow.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "thread-list", - "description": "List followed threads in the active server (GET /channels/threads/followed)", - "access": "read", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server" - } - ], - "columns": [ - "threadChannelId", - "parentMessageId", - "parentChannelName", - "unreadCount", - "replyCount", - "lastReplyAt" - ], - "type": "js", - "modulePath": "plugins/slock/thread-list.js", - "sourceFile": "plugins/slock/thread-list.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "thread-undone", - "description": "Restore a done thread to the active list (POST /channels/threads/undone)", - "access": "write", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "threadChannelId", - "type": "str", - "required": true, - "positional": true, - "help": "Thread channel UUID (from thread-list / message-read)" - }, - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server" - } - ], - "columns": [ - "threadChannelId", - "result" - ], - "type": "js", - "modulePath": "plugins/slock/thread-undone.js", - "sourceFile": "plugins/slock/thread-undone.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "thread-unfollow", - "description": "Stop following a thread (POST /channels/threads/unfollow)", - "access": "write", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "threadChannelId", - "type": "str", - "required": true, - "positional": true, - "help": "Thread channel UUID (from thread-list / message-read)" - }, - { - "name": "server", - "type": "str", - "required": false, - "help": "Override active server" - } - ], - "columns": [ - "threadChannelId", - "result" - ], - "type": "js", - "modulePath": "plugins/slock/thread-unfollow.js", - "sourceFile": "plugins/slock/thread-unfollow.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "unread-summary", - "description": "Global unread counts across every server you belong to.", - "access": "read", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "serverId", - "slug", - "name", - "unreadCount" - ], - "type": "js", - "modulePath": "plugins/slock/unread-summary.js", - "sourceFile": "plugins/slock/unread-summary.js", - "navigateBefore": "https://app.slock.ai", - "siteSession": "persistent" - }, - { - "site": "slock", - "name": "whoami", - "description": "Show the current logged-in slock account", - "access": "read", - "domain": "app.slock.ai", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "logged_in", - "site", - "id", - "name", - "email" - ], - "type": "js", - "modulePath": "plugins/slock/whoami.js", - "sourceFile": "plugins/slock/whoami.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "spotify", - "name": "auth", - "description": "Authenticate with Spotify (OAuth — run once)", - "access": "write", - "strategy": "local", - "browser": false, - "args": [], - "columns": [ - "status" - ], - "type": "js", - "modulePath": "plugins/spotify/spotify.js", - "sourceFile": "plugins/spotify/spotify.js" - }, - { - "site": "spotify", - "name": "next", - "description": "Skip to next track", - "access": "write", - "strategy": "local", - "browser": false, - "args": [], - "columns": [ - "status" - ], - "type": "js", - "modulePath": "plugins/spotify/spotify.js", - "sourceFile": "plugins/spotify/spotify.js" - }, - { - "site": "spotify", - "name": "pause", - "description": "Pause playback", - "access": "write", - "strategy": "local", - "browser": false, - "args": [], - "columns": [ - "status" - ], - "type": "js", - "modulePath": "plugins/spotify/spotify.js", - "sourceFile": "plugins/spotify/spotify.js" - }, - { - "site": "spotify", - "name": "play", - "description": "Resume playback or search and play a track/artist", - "access": "write", - "strategy": "local", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "default": "", - "required": false, - "positional": true, - "help": "Track or artist to play (optional)" - } - ], - "columns": [ - "track", - "artist", - "status" - ], - "type": "js", - "modulePath": "plugins/spotify/spotify.js", - "sourceFile": "plugins/spotify/spotify.js" - }, - { - "site": "spotify", - "name": "prev", - "description": "Skip to previous track", - "access": "write", - "strategy": "local", - "browser": false, - "args": [], - "columns": [ - "status" - ], - "type": "js", - "modulePath": "plugins/spotify/spotify.js", - "sourceFile": "plugins/spotify/spotify.js" - }, - { - "site": "spotify", - "name": "queue", - "description": "Add a track to the playback queue", - "access": "write", - "strategy": "local", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Track to add to queue" - } - ], - "columns": [ - "track", - "artist", - "status" - ], - "type": "js", - "modulePath": "plugins/spotify/spotify.js", - "sourceFile": "plugins/spotify/spotify.js" - }, - { - "site": "spotify", - "name": "repeat", - "description": "Set repeat mode (off / track / context)", - "access": "write", - "strategy": "local", - "browser": false, - "args": [ - { - "name": "mode", - "type": "str", - "default": "context", - "required": false, - "positional": true, - "help": "off / track / context", - "choices": [ - "off", - "track", - "context" - ] - } - ], - "columns": [ - "repeat" - ], - "type": "js", - "modulePath": "plugins/spotify/spotify.js", - "sourceFile": "plugins/spotify/spotify.js" - }, - { - "site": "spotify", - "name": "search", - "description": "Search for tracks", - "access": "read", - "strategy": "local", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search query" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of results (default: 10)" - } - ], - "columns": [ - "track", - "artist", - "album", - "uri" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/spotify/spotify.js", - "sourceFile": "plugins/spotify/spotify.js" - }, - { - "site": "spotify", - "name": "shuffle", - "description": "Toggle shuffle on/off", - "access": "write", - "strategy": "local", - "browser": false, - "args": [ - { - "name": "state", - "type": "str", - "default": "on", - "required": false, - "positional": true, - "help": "on or off", - "choices": [ - "on", - "off" - ] - } - ], - "columns": [ - "shuffle" - ], - "type": "js", - "modulePath": "plugins/spotify/spotify.js", - "sourceFile": "plugins/spotify/spotify.js" - }, - { - "site": "spotify", - "name": "status", - "description": "Show current playback status", - "access": "read", - "strategy": "local", - "browser": false, - "args": [], - "columns": [ - "track", - "artist", - "album", - "status", - "progress" - ], - "type": "js", - "modulePath": "plugins/spotify/spotify.js", - "sourceFile": "plugins/spotify/spotify.js" - }, - { - "site": "spotify", - "name": "volume", - "description": "Set playback volume (0-100)", - "access": "write", - "strategy": "local", - "browser": false, - "args": [ - { - "name": "level", - "type": "int", - "default": 50, - "required": true, - "positional": true, - "help": "Volume 0–100" - } - ], - "columns": [ - "volume" - ], - "type": "js", - "modulePath": "plugins/spotify/spotify.js", - "sourceFile": "plugins/spotify/spotify.js" - }, - { - "site": "stackoverflow", - "name": "bounties", - "description": "Active bounties on Stack Overflow", - "access": "read", - "domain": "stackoverflow.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Max number of results" - } - ], - "columns": [ - "rank", - "id", - "bounty", - "title", - "score", - "answers", - "views", - "is_answered", - "tags", - "author", - "creation_date", - "url" - ], - "type": "js", - "modulePath": "plugins/stackoverflow/bounties.js", - "sourceFile": "plugins/stackoverflow/bounties.js" - }, - { - "site": "stackoverflow", - "name": "hot", - "description": "Hot Stack Overflow questions", - "access": "read", - "domain": "stackoverflow.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Max number of results" - } - ], - "columns": [ - "rank", - "id", - "title", - "score", - "answers", - "views", - "is_answered", - "tags", - "author", - "creation_date", - "url" - ], - "type": "js", - "modulePath": "plugins/stackoverflow/hot.js", - "sourceFile": "plugins/stackoverflow/hot.js" - }, - { - "site": "stackoverflow", - "name": "read", - "description": "Read a Stack Overflow question with answers and comments", - "access": "read", - "domain": "stackoverflow.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "id", - "type": "str", - "required": true, - "positional": true, - "help": "Stack Overflow question id (numeric, e.g. 79935770)" - }, - { - "name": "answers-limit", - "type": "int", - "default": 10, - "required": false, - "help": "Max answers to include (1-100; accepted answer always included first)" - }, - { - "name": "comments-limit", - "type": "int", - "default": 5, - "required": false, - "help": "Max comments per question/answer (1-100)" - }, - { - "name": "max-length", - "type": "int", - "default": 4000, - "required": false, - "help": "Max characters per body / answer / comment (min 100)" - } - ], - "columns": [ - "type", - "author", - "score", - "accepted", - "text" - ], - "type": "js", - "modulePath": "plugins/stackoverflow/read.js", - "sourceFile": "plugins/stackoverflow/read.js" - }, - { - "site": "stackoverflow", - "name": "related", - "description": "List Stack Overflow questions related to a given question id.", - "access": "read", - "domain": "stackoverflow.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "id", - "type": "string", - "required": true, - "positional": true, - "help": "Stack Overflow question id (numeric, e.g. 79935770)." - }, - { - "name": "sort", - "type": "string", - "default": "rank", - "required": false, - "help": "Sort key: rank, activity, votes, creation (rank = SO relevance default)." - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max related questions (1-100)." - } - ], - "columns": [ - "rank", - "id", - "title", - "score", - "answers", - "views", - "isAnswered", - "tags", - "author", - "createdAt", - "lastActivityAt", - "url" - ], - "type": "js", - "modulePath": "plugins/stackoverflow/related.js", - "sourceFile": "plugins/stackoverflow/related.js" - }, - { - "site": "stackoverflow", - "name": "search", - "description": "Search Stack Overflow questions", - "access": "read", - "domain": "stackoverflow.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "string", - "required": true, - "positional": true, - "help": "Search query" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Max number of results" - } - ], - "columns": [ - "rank", - "id", - "title", - "score", - "answers", - "views", - "is_answered", - "tags", - "author", - "creation_date", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/stackoverflow/search.js", - "sourceFile": "plugins/stackoverflow/search.js" - }, - { - "site": "stackoverflow", - "name": "tag", - "description": "List Stack Overflow questions tagged with a given tag (most active first).", - "access": "read", - "domain": "stackoverflow.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "tag", - "type": "string", - "required": true, - "positional": true, - "help": "Tag slug (e.g. python, rust, typescript)." - }, - { - "name": "sort", - "type": "string", - "default": "activity", - "required": false, - "help": "Sort key: activity, votes, creation, hot, week, month" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max questions to return (max 100)." - } - ], - "columns": [ - "rank", - "id", - "title", - "score", - "answers", - "views", - "isAnswered", - "tags", - "author", - "createdAt", - "lastActivityAt", - "url" - ], - "type": "js", - "modulePath": "plugins/stackoverflow/tag.js", - "sourceFile": "plugins/stackoverflow/tag.js" - }, - { - "site": "stackoverflow", - "name": "unanswered", - "description": "Top voted unanswered questions on Stack Overflow", - "access": "read", - "domain": "stackoverflow.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Max number of results" - } - ], - "columns": [ - "rank", - "id", - "title", - "score", - "answers", - "views", - "tags", - "author", - "creation_date", - "url" - ], - "type": "js", - "modulePath": "plugins/stackoverflow/unanswered.js", - "sourceFile": "plugins/stackoverflow/unanswered.js" - }, - { - "site": "stackoverflow", - "name": "user", - "description": "Find Stack Overflow users by display name (highest reputation first).", - "access": "read", - "domain": "stackoverflow.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "name", - "type": "string", - "required": true, - "positional": true, - "help": "Display name (or substring) to search." - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Max users to return (max 100)." - } - ], - "columns": [ - "userId", - "displayName", - "reputation", - "goldBadges", - "silverBadges", - "bronzeBadges", - "location", - "createdAt", - "lastAccessAt", - "url" - ], - "type": "js", - "modulePath": "plugins/stackoverflow/user.js", - "sourceFile": "plugins/stackoverflow/user.js" - }, - { - "site": "steam", - "name": "app", - "description": "Steam storefront detail for a single app id", - "access": "read", - "domain": "store.steampowered.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "id", - "type": "str", - "required": true, - "positional": true, - "help": "Numeric Steam app id (e.g. \"620\" for Portal 2)" - }, - { - "name": "currency", - "type": "str", - "default": "us", - "required": false, - "help": "Storefront country code (e.g. us / cn / jp / de)" - } - ], - "columns": [ - "id", - "name", - "type", - "isFree", - "releaseDate", - "developers", - "publishers", - "price", - "currency", - "metacritic", - "recommendations", - "genres", - "categories", - "shortDescription", - "website", - "url" - ], - "type": "js", - "modulePath": "plugins/steam/app.js", - "sourceFile": "plugins/steam/app.js" - }, - { - "site": "steam", - "name": "search", - "description": "Search the Steam storefront by name keyword", - "access": "read", - "domain": "store.steampowered.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search keyword (e.g. \"portal\", \"stardew\")" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max results (1-50)" - }, - { - "name": "currency", - "type": "str", - "default": "us", - "required": false, - "help": "Storefront country code (e.g. us / cn / jp / de)" - } - ], - "columns": [ - "rank", - "id", - "name", - "price", - "currency", - "metascore", - "platforms", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/steam/search.js", - "sourceFile": "plugins/steam/search.js" - }, - { - "site": "steam", - "name": "top-sellers", - "description": "Steam top selling games", - "access": "read", - "domain": "store.steampowered.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of games" - } - ], - "columns": [ - "rank", - "name", - "price", - "discount", - "url" - ], - "type": "js", - "modulePath": "plugins/steam/top-sellers.js", - "sourceFile": "plugins/steam/top-sellers.js" - }, - { - "site": "substack", - "name": "feed", - "description": "Substack popular posts Feed", - "access": "read", - "domain": "substack.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "category", - "type": "str", - "default": "all", - "required": false, - "help": "Post category: all, tech, business, culture, politics, science, health" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of posts to return" - } - ], - "columns": [ - "rank", - "title", - "author", - "date", - "readTime", - "url" - ], - "type": "js", - "modulePath": "plugins/substack/feed.js", - "sourceFile": "plugins/substack/feed.js", - "navigateBefore": "https://substack.com" - }, - { - "site": "substack", - "name": "publication", - "description": "Get a specific Substack Newsletter latest posts", - "access": "read", - "domain": "substack.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "url", - "type": "str", - "required": true, - "positional": true, - "help": "Newsletter URL(for example https://example.substack.com)" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of posts to return" - } - ], - "columns": [ - "rank", - "title", - "date", - "description", - "url" - ], - "type": "js", - "modulePath": "plugins/substack/publication.js", - "sourceFile": "plugins/substack/publication.js", - "navigateBefore": "https://substack.com" - }, - { - "site": "substack", - "name": "search", - "description": "Search Substack posts and newsletters", - "access": "read", - "domain": "substack.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "keyword", - "type": "str", - "required": true, - "positional": true, - "help": "Search keyword" - }, - { - "name": "type", - "type": "str", - "default": "posts", - "required": false, - "help": "Search type(posts=posts, publications=Newsletter)", - "choices": [ - "posts", - "publications" - ] - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of results to return" - } - ], - "columns": [ - "rank", - "title", - "author", - "date", - "description", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/substack/search.js", - "sourceFile": "plugins/substack/search.js" - }, - { - "site": "suno", - "name": "download", - "description": "Download an existing Suno clip (MP3 + optional WAV/M4A/video) by id", - "access": "write", - "domain": "suno.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "clip", - "type": "str", - "required": true, - "positional": true, - "help": "Clip UUID or https://suno.com/song/ URL" - }, - { - "name": "formats", - "type": "str", - "required": false, - "help": "Comma-separated formats: mp3, m4a, wav, video, cover, metadata. Default: mp3,metadata" - }, - { - "name": "op", - "type": "str", - "required": false, - "help": "Output directory (default: ~/Music/suno)", - "file": { - "direction": "output", - "pathKind": "directory", - "multiple": false, - "defaultPath": "./suno" - } - }, - { - "name": "confirm-paid", - "type": "boolean", - "default": false, - "required": false, - "help": "Required to allow paid downloads (wav). Without it, paid formats are skipped with a warning." - } - ], - "columns": [ - "status", - "clip", - "title", - "files", - "link" - ], - "defaultFormat": "plain", - "type": "js", - "modulePath": "plugins/suno/download.js", - "sourceFile": "plugins/suno/download.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "suno", - "name": "generate", - "description": "Generate music with Suno (V5.5 chirp-fenix by default) and download clips locally", - "access": "write", - "domain": "suno.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "prompt", - "type": "str", - "required": false, - "positional": true, - "help": "Simple-mode description (ignored when --lyrics is provided)" - }, - { - "name": "lyrics", - "type": "str", - "required": false, - "help": "Custom-mode lyrics (with [Verse]/[Chorus] metatags). Triggers Custom mode." - }, - { - "name": "tags", - "type": "str", - "required": false, - "help": "Custom-mode style tags (genre, BPM, instruments...). Used with --lyrics." - }, - { - "name": "negative-tags", - "type": "str", - "required": false, - "help": "Custom-mode style exclusions (e.g. \"no vocals, no autotune\"). Used with --lyrics." - }, - { - "name": "title", - "type": "str", - "required": false, - "help": "Song title (default: auto-derived from prompt)" - }, - { - "name": "instrumental", - "type": "boolean", - "default": false, - "required": false, - "help": "No vocals" - }, - { - "name": "model", - "type": "str", - "required": false, - "help": "Model id: chirp-fenix, chirp-bluejay, chirp-v4, chirp-v3-5. Default: chirp-fenix" - }, - { - "name": "weirdness", - "type": "str", - "required": false, - "help": "Creative weirdness slider (0..1). Default: 0.5" - }, - { - "name": "style-weight", - "type": "str", - "required": false, - "help": "Style adherence slider (0..1). Default: 0.5" - }, - { - "name": "formats", - "type": "str", - "required": false, - "help": "Comma-separated download formats: mp3, m4a, wav, video, cover, metadata. Default: mp3,metadata" - }, - { - "name": "op", - "type": "str", - "required": false, - "help": "Output directory (default: ~/Music/suno)", - "file": { - "direction": "output", - "pathKind": "directory", - "multiple": false, - "defaultPath": "./suno" - } - }, - { - "name": "timeout", - "type": "int", - "default": 300, - "required": false, - "help": "Max seconds to wait for clips to finish (default: 300)" - }, - { - "name": "sd", - "type": "boolean", - "default": false, - "required": false, - "help": "Skip download; only print clip ids and Suno URLs" - }, - { - "name": "confirm-paid", - "type": "boolean", - "default": false, - "required": false, - "help": "Required to allow paid downloads (wav). Without it, paid formats are skipped with a warning." - } - ], - "columns": [ - "status", - "clip", - "title", - "files", - "link" - ], - "defaultFormat": "plain", - "type": "js", - "modulePath": "plugins/suno/generate.js", - "sourceFile": "plugins/suno/generate.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "suno", - "name": "list", - "description": "List recent Suno clips in your library (id, title, status, created_at, link)", - "access": "read", - "domain": "suno.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max clips to list (default: 20)" - }, - { - "name": "page", - "type": "int", - "default": 0, - "required": false, - "help": "Pagination offset, 0-based (default: 0)" - } - ], - "columns": [ - "rank", - "clip", - "title", - "status", - "created", - "link" - ], - "type": "js", - "modulePath": "plugins/suno/list.js", - "sourceFile": "plugins/suno/list.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "suno", - "name": "login", - "description": "Open suno login", - "access": "write", - "domain": "suno.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "status", - "logged_in", - "site", - "user_id", - "name", - "action", - "verify_command" - ], - "type": "js", - "modulePath": "plugins/suno/auth.js", - "sourceFile": "plugins/suno/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "suno", - "name": "status", - "description": "Check Suno login, plan, credit balance, and captcha readiness", - "access": "read", - "domain": "suno.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "Status", - "Plan", - "Credits", - "Monthly", - "Captcha" - ], - "type": "js", - "modulePath": "plugins/suno/status.js", - "sourceFile": "plugins/suno/status.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "suno", - "name": "whoami", - "description": "Show the current logged-in suno account", - "access": "read", - "domain": "suno.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "logged_in", - "site", - "user_id", - "name" - ], - "type": "js", - "modulePath": "plugins/suno/auth.js", - "sourceFile": "plugins/suno/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "techcrunch", - "name": "article", - "description": "Read a TechCrunch article from its URL", - "access": "read", - "domain": "techcrunch.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "url", - "type": "string", - "required": true, - "positional": true, - "help": "TechCrunch article URL" - } - ], - "columns": [ - "title", - "author", - "publishedAt", - "categories", - "description", - "content", - "url" - ], - "type": "js", - "modulePath": "plugins/techcrunch/article.js", - "sourceFile": "plugins/techcrunch/article.js" - }, - { - "site": "techcrunch", - "name": "search", - "description": "Search TechCrunch stories or list the latest stories", - "access": "read", - "domain": "techcrunch.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "string", - "required": false, - "positional": true, - "help": "Words to search for" - }, - { - "name": "latest", - "type": "boolean", - "default": false, - "required": false, - "help": "List the latest stories instead of searching" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Maximum stories to return (1-50)" - } - ], - "columns": [ - "rank", - "title", - "author", - "publishedAt", - "description", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/techcrunch/search.js", - "sourceFile": "plugins/techcrunch/search.js" - }, - { - "site": "tiktok", - "name": "comment", - "description": "Post a comment on a TikTok video", - "access": "write", - "domain": "www.tiktok.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "url", - "type": "str", - "required": true, - "positional": true, - "help": "TikTok video URL (https://www.tiktok.com/@user/video/)" - }, - { - "name": "text", - "type": "str", - "required": true, - "positional": true, - "help": "Comment text (≤150 chars)" - } - ], - "columns": [ - "url", - "text", - "result" - ], - "type": "js", - "modulePath": "plugins/tiktok/comment.js", - "sourceFile": "plugins/tiktok/comment.js", - "navigateBefore": "https://www.tiktok.com" - }, - { - "site": "tiktok", - "name": "creator-videos", - "description": "TikTok Studio creator content list (views/likes/comments/saves/shares)", - "access": "read", - "domain": "www.tiktok.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of creator videos to return (max 250)" - }, - { - "name": "cursor", - "type": "string", - "default": "0", - "required": false, - "help": "Non-negative TikTok Studio pagination cursor" - } - ], - "columns": [ - "video_id", - "title", - "date", - "views", - "likes", - "comments", - "saves", - "shares", - "url" - ], - "type": "js", - "modulePath": "plugins/tiktok/creator-videos.js", - "sourceFile": "plugins/tiktok/creator-videos.js", - "navigateBefore": "https://www.tiktok.com/tiktokstudio/content" - }, - { - "site": "tiktok", - "name": "explore", - "description": "Get trending TikTok videos from the recommend feed via page-context APIs", - "access": "read", - "domain": "www.tiktok.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of videos to return (max 120)" - } - ], - "columns": [ - "index", - "id", - "author", - "url", - "cover", - "title", - "desc", - "plays", - "likes", - "comments", - "shares", - "createTime" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/tiktok/explore.js", - "sourceFile": "plugins/tiktok/explore.js", - "navigateBefore": "https://www.tiktok.com" - }, - { - "site": "tiktok", - "name": "follow", - "description": "Follow a TikTok user by username", - "access": "write", - "domain": "www.tiktok.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "username", - "type": "str", - "required": true, - "positional": true, - "help": "TikTok username (without @)" - } - ], - "columns": [ - "username", - "url", - "result" - ], - "type": "js", - "modulePath": "plugins/tiktok/follow.js", - "sourceFile": "plugins/tiktok/follow.js", - "navigateBefore": "https://www.tiktok.com" - }, - { - "site": "tiktok", - "name": "following", - "description": "List accounts the logged-in user follows on TikTok via page-context APIs", - "access": "read", - "domain": "www.tiktok.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of accounts (max 200)" - } - ], - "columns": [ - "index", - "username", - "name", - "secUid", - "verified", - "followers", - "following", - "url" - ], - "type": "js", - "modulePath": "plugins/tiktok/following.js", - "sourceFile": "plugins/tiktok/following.js", - "navigateBefore": "https://www.tiktok.com" - }, - { - "site": "tiktok", - "name": "friends", - "description": "Get TikTok friend / who-to-follow suggestions via page-context APIs", - "access": "read", - "domain": "www.tiktok.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of suggestions (max 100)" - } - ], - "columns": [ - "index", - "username", - "name", - "secUid", - "verified", - "followers", - "following", - "url" - ], - "type": "js", - "modulePath": "plugins/tiktok/friends.js", - "sourceFile": "plugins/tiktok/friends.js", - "navigateBefore": "https://www.tiktok.com" - }, - { - "site": "tiktok", - "name": "like", - "description": "Like a TikTok video", - "access": "write", - "domain": "www.tiktok.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "url", - "type": "str", - "required": true, - "positional": true, - "help": "TikTok video URL" - } - ], - "columns": [ - "status", - "likes", - "url" - ], - "type": "js", - "modulePath": "plugins/tiktok/like.js", - "sourceFile": "plugins/tiktok/like.js", - "navigateBefore": "https://www.tiktok.com" - }, - { - "site": "tiktok", - "name": "live", - "description": "Browse TikTok live streams via page-context APIs", - "access": "read", - "domain": "www.tiktok.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of streams (max 60)" - } - ], - "columns": [ - "index", - "streamer", - "name", - "title", - "viewers", - "likes", - "secUid", - "url" - ], - "type": "js", - "modulePath": "plugins/tiktok/live.js", - "sourceFile": "plugins/tiktok/live.js", - "navigateBefore": "https://www.tiktok.com" - }, - { - "site": "tiktok", - "name": "login", - "description": "Open tiktok login", - "access": "write", - "domain": "tiktok.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "status", - "logged_in", - "site", - "sec_uid", - "username", - "nickname", - "action", - "verify_command" - ], - "type": "js", - "modulePath": "plugins/tiktok/auth.js", - "sourceFile": "plugins/tiktok/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "tiktok", - "name": "notifications", - "description": "Read TikTok inbox notifications (likes, comments, mentions, followers) via page-context APIs", - "access": "read", - "domain": "www.tiktok.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 15, - "required": false, - "help": "Number of notifications (max 100)" - }, - { - "name": "type", - "type": "str", - "default": "all", - "required": false, - "help": "Notification type", - "choices": [ - "all", - "likes", - "comments", - "mentions", - "followers" - ] - } - ], - "columns": [ - "index", - "id", - "from", - "text", - "createTime" - ], - "type": "js", - "modulePath": "plugins/tiktok/notifications.js", - "sourceFile": "plugins/tiktok/notifications.js", - "navigateBefore": "https://www.tiktok.com" - }, - { - "site": "tiktok", - "name": "profile", - "description": "Get TikTok user profile info", - "access": "read", - "domain": "www.tiktok.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "username", - "type": "str", - "required": true, - "positional": true, - "help": "TikTok username (without @)" - } - ], - "columns": [ - "username", - "name", - "followers", - "following", - "likes", - "videos", - "verified", - "bio" - ], - "type": "js", - "modulePath": "plugins/tiktok/profile.js", - "sourceFile": "plugins/tiktok/profile.js", - "navigateBefore": "https://www.tiktok.com" - }, - { - "site": "tiktok", - "name": "save", - "description": "Add a TikTok video to Favorites", - "access": "write", - "domain": "www.tiktok.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "url", - "type": "str", - "required": true, - "positional": true, - "help": "TikTok video URL" - } - ], - "columns": [ - "status", - "url" - ], - "type": "js", - "modulePath": "plugins/tiktok/save.js", - "sourceFile": "plugins/tiktok/save.js", - "navigateBefore": "https://www.tiktok.com" - }, - { - "site": "tiktok", - "name": "search", - "description": "Search TikTok videos", - "access": "read", - "domain": "www.tiktok.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search query" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Number of results" - } - ], - "columns": [ - "rank", - "desc", - "author", - "url", - "plays", - "likes", - "comments", - "shares" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/tiktok/search.js", - "sourceFile": "plugins/tiktok/search.js", - "navigateBefore": "https://www.tiktok.com" - }, - { - "site": "tiktok", - "name": "unfollow", - "description": "Unfollow a TikTok user by username", - "access": "write", - "domain": "www.tiktok.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "username", - "type": "str", - "required": true, - "positional": true, - "help": "TikTok username (without @)" - } - ], - "columns": [ - "username", - "url", - "result" - ], - "type": "js", - "modulePath": "plugins/tiktok/unfollow.js", - "sourceFile": "plugins/tiktok/unfollow.js", - "navigateBefore": "https://www.tiktok.com" - }, - { - "site": "tiktok", - "name": "unlike", - "description": "Unlike a TikTok video", - "access": "write", - "domain": "www.tiktok.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "url", - "type": "str", - "required": true, - "positional": true, - "help": "TikTok video URL" - } - ], - "columns": [ - "status", - "likes", - "url" - ], - "type": "js", - "modulePath": "plugins/tiktok/unlike.js", - "sourceFile": "plugins/tiktok/unlike.js", - "navigateBefore": "https://www.tiktok.com" - }, - { - "site": "tiktok", - "name": "unsave", - "description": "Remove a TikTok video from Favorites", - "access": "write", - "domain": "www.tiktok.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "url", - "type": "str", - "required": true, - "positional": true, - "help": "TikTok video URL" - } - ], - "columns": [ - "status", - "url" - ], - "type": "js", - "modulePath": "plugins/tiktok/unsave.js", - "sourceFile": "plugins/tiktok/unsave.js", - "navigateBefore": "https://www.tiktok.com" - }, - { - "site": "tiktok", - "name": "user", - "description": "Get recent videos from a TikTok user via page-context APIs", - "access": "read", - "domain": "www.tiktok.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "username", - "type": "str", - "required": true, - "positional": true, - "help": "TikTok username (without @)" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of videos to return (max 120)" - } - ], - "columns": [ - "index", - "id", - "source", - "author", - "url", - "cover", - "title", - "desc", - "plays", - "likes", - "comments", - "shares", - "createTime" - ], - "type": "js", - "modulePath": "plugins/tiktok/user.js", - "sourceFile": "plugins/tiktok/user.js", - "navigateBefore": "https://www.tiktok.com" - }, - { - "site": "tiktok", - "name": "whoami", - "description": "Show the current logged-in tiktok account", - "access": "read", - "domain": "tiktok.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "logged_in", - "site", - "sec_uid", - "username", - "nickname" - ], - "type": "js", - "modulePath": "plugins/tiktok/auth.js", - "sourceFile": "plugins/tiktok/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "trae-solo", - "name": "automation-list", - "description": "List Trae SOLO Automation tab content. Default tab is \"Configured\"; pass --tab to switch.", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "tab", - "type": "str", - "default": "configured", - "required": false, - "help": "Tab to view: configured / run-history / task-template" - }, - { - "name": "limit", - "type": "int", - "default": 50, - "required": false, - "help": "" - } - ], - "columns": [ - "Index", - "Title", - "Summary" - ], - "type": "js", - "modulePath": "plugins/trae-solo/automation.js", - "sourceFile": "plugins/trae-solo/automation.js", - "navigateBefore": true - }, - { - "site": "trae-solo", - "name": "cookies", - "description": "List cookies on the Trae SOLO renderer (JS-visible via document.cookie; httpOnly cookies not shown).", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Index", - "Key", - "Bytes", - "Name", - "Preview", - "Database", - "Version" - ], - "type": "js", - "modulePath": "plugins/trae-solo/renderer-storage.js", - "sourceFile": "plugins/trae-solo/renderer-storage.js", - "navigateBefore": true - }, - { - "site": "trae-solo", - "name": "extensions-list", - "description": "List VSCode extensions installed in Trae SOLO (~/.trae/extensions/extensions.json). Works while Trae is closed.", - "access": "read", - "domain": "localhost", - "strategy": "local", - "browser": false, - "args": [], - "columns": [ - "Index", - "Workspace Id", - "Kind", - "Target", - "Modified", - "Id", - "Version", - "Source", - "Installed" - ], - "type": "js", - "modulePath": "plugins/trae-solo/workspaces-fs.js", - "sourceFile": "plugins/trae-solo/workspaces-fs.js" - }, - { - "site": "trae-solo", - "name": "history", - "description": "List Trae SOLO projects and the tasks within each (from the project-list view sidebar).", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "project", - "type": "str", - "required": false, - "help": "Filter by project name (substring, case-insensitive)" - }, - { - "name": "limit", - "type": "int", - "default": 100, - "required": false, - "help": "Max tasks per project" - } - ], - "columns": [ - "Project", - "Task Index", - "Task" - ], - "type": "js", - "modulePath": "plugins/trae-solo/history.js", - "sourceFile": "plugins/trae-solo/history.js", - "navigateBefore": true - }, - { - "site": "trae-solo", - "name": "idb-list", - "description": "List IndexedDB databases on the Trae SOLO renderer. Trae ships an @byted/ve-rtc DB used by the Volcengine RTC voice/video infrastructure.", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Index", - "Key", - "Bytes", - "Name", - "Preview", - "Database", - "Version" - ], - "type": "js", - "modulePath": "plugins/trae-solo/renderer-storage.js", - "sourceFile": "plugins/trae-solo/renderer-storage.js", - "navigateBefore": true - }, - { - "site": "trae-solo", - "name": "mode", - "description": "Read or switch TRAE SOLO between Code mode and Work mode.", - "access": "write", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "target", - "type": "str", - "required": false, - "positional": true, - "help": "Target mode: code or work. Omit to read current." - } - ], - "columns": [ - "Status", - "Mode" - ], - "type": "js", - "modulePath": "plugins/trae-solo/mode.js", - "sourceFile": "plugins/trae-solo/mode.js", - "navigateBefore": true - }, - { - "site": "trae-solo", - "name": "model", - "description": "Read or switch the current AI model in TRAE SOLO. Without arguments, reports the current model. With argument (substring, case-insensitive), switches to a matching model. Pass --list to enumerate available models.", - "access": "write", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "name", - "type": "str", - "required": false, - "positional": true, - "help": "Target model name (substring match, case-insensitive). Omit to read current." - }, - { - "name": "list", - "type": "boolean", - "default": false, - "required": false, - "help": "List all available models (does not switch)" - } - ], - "columns": [ - "Status", - "Model" - ], - "type": "js", - "modulePath": "plugins/trae-solo/model.js", - "sourceFile": "plugins/trae-solo/model.js", - "navigateBefore": true - }, - { - "site": "trae-solo", - "name": "recent-workspaces", - "description": "Show Trae SOLO's recently-opened workspaces (the File → Open Recent menu, stored under key \"history.recentlyOpenedPathsList\" in state.vscdb).", - "access": "read", - "domain": "localhost", - "strategy": "local", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "" - } - ], - "columns": [ - "Index", - "Key", - "Kind", - "Path" - ], - "type": "js", - "modulePath": "plugins/trae-solo/state-fs.js", - "sourceFile": "plugins/trae-solo/state-fs.js" - }, - { - "site": "trae-solo", - "name": "settings-read", - "description": "Parse and pretty-print Trae SOLO user settings.json (~/Library/Application Support/TRAE SOLO/User/settings.json). Handles VSCode JSONC syntax (line comments + trailing commas).", - "access": "read", - "domain": "localhost", - "strategy": "local", - "browser": false, - "args": [], - "columns": [ - "Field", - "Value" - ], - "type": "js", - "modulePath": "plugins/trae-solo/settings.js", - "sourceFile": "plugins/trae-solo/settings.js" - }, - { - "site": "trae-solo", - "name": "skill-category", - "description": "Filter Skills Marketplace by category. Pass --list to see categories.", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "name", - "type": "str", - "required": false, - "positional": true, - "help": "Category name (substring; case-insensitive). Common: All / Developer Tools / Data Analysis / UI Design / Content Creation / Productivity" - }, - { - "name": "list", - "type": "boolean", - "default": false, - "required": false, - "help": "List available categories" - }, - { - "name": "limit", - "type": "int", - "default": 100, - "required": false, - "help": "" - } - ], - "columns": [ - "Index", - "Name", - "Description" - ], - "type": "js", - "modulePath": "plugins/trae-solo/skill.js", - "sourceFile": "plugins/trae-solo/skill.js", - "navigateBefore": true - }, - { - "site": "trae-solo", - "name": "skill-fs-installed", - "description": "List INSTALLED Trae SOLO skills (managedSkills entry in ~/.trae/skill-config.json).", - "access": "read", - "domain": "localhost", - "strategy": "local", - "browser": false, - "args": [], - "columns": [ - "Index", - "Name", - "Description", - "Source" - ], - "type": "js", - "modulePath": "plugins/trae-solo/skill-fs.js", - "sourceFile": "plugins/trae-solo/skill-fs.js" - }, - { - "site": "trae-solo", - "name": "skill-fs-list", - "description": "List all Trae SOLO skills present on disk under ~/.trae/skills/. Reads SKILL.md front-matter for descriptions. Works while Trae is closed.", - "access": "read", - "domain": "localhost", - "strategy": "local", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 200, - "required": false, - "help": "Max rows" - } - ], - "columns": [ - "Index", - "Name", - "Description", - "Source" - ], - "type": "js", - "modulePath": "plugins/trae-solo/skill-fs.js", - "sourceFile": "plugins/trae-solo/skill-fs.js" - }, - { - "site": "trae-solo", - "name": "skill-fs-show", - "description": "Print a skill's SKILL.md content + on-disk path.", - "access": "read", - "domain": "localhost", - "strategy": "local", - "browser": false, - "args": [ - { - "name": "name", - "type": "str", - "required": true, - "positional": true, - "help": "Skill name (folder under ~/.trae/skills/)" - } - ], - "columns": [ - "Field", - "Value" - ], - "type": "js", - "modulePath": "plugins/trae-solo/skill-fs.js", - "sourceFile": "plugins/trae-solo/skill-fs.js" - }, - { - "site": "trae-solo", - "name": "skill-list", - "description": "List Trae SOLO Skills — by default the Marketplace; pass --installed to list installed ones.", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "installed", - "type": "boolean", - "default": false, - "required": false, - "help": "List installed skills instead of the marketplace" - }, - { - "name": "limit", - "type": "int", - "default": 100, - "required": false, - "help": "Max rows to return" - } - ], - "columns": [ - "Index", - "Name", - "Description" - ], - "type": "js", - "modulePath": "plugins/trae-solo/skill.js", - "sourceFile": "plugins/trae-solo/skill.js", - "navigateBefore": true - }, - { - "site": "trae-solo", - "name": "skill-search", - "description": "Filter Skills Marketplace by keyword.", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "keyword", - "type": "str", - "required": true, - "positional": true, - "help": "Search keyword (substring)" - }, - { - "name": "limit", - "type": "int", - "default": 50, - "required": false, - "help": "Max rows" - } - ], - "columns": [ - "Index", - "Name", - "Description" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/trae-solo/skill.js", - "sourceFile": "plugins/trae-solo/skill.js", - "navigateBefore": true - }, - { - "site": "trae-solo", - "name": "state-get", - "description": "Read a single key from Trae SOLO's globalStorage state.vscdb. Pass --workspace to query a per-workspace DB instead. Returns parsed JSON if the value is JSON.", - "access": "read", - "domain": "localhost", - "strategy": "local", - "browser": false, - "args": [ - { - "name": "key", - "type": "str", - "required": true, - "positional": true, - "help": "State key (use state-keys to discover)" - }, - { - "name": "workspace", - "type": "str", - "required": false, - "help": "Workspace id (from workspaces-list) to query a per-workspace DB" - }, - { - "name": "max-bytes", - "type": "int", - "default": 8000, - "required": false, - "help": "Truncate value to this many bytes" - } - ], - "columns": [ - "Field", - "Value" - ], - "type": "js", - "modulePath": "plugins/trae-solo/state-fs.js", - "sourceFile": "plugins/trae-solo/state-fs.js" - }, - { - "site": "trae-solo", - "name": "state-keys", - "description": "List all keys present in Trae SOLO's globalStorage state.vscdb (VSCode-style UI/agent state). Pass --workspace to query a per-workspace DB instead. Use state-get to read a specific value. (See renderer storage-keys for browser-side LS/SS.)", - "access": "read", - "domain": "localhost", - "strategy": "local", - "browser": false, - "args": [ - { - "name": "filter", - "type": "str", - "required": false, - "help": "Case-insensitive substring filter over keys" - }, - { - "name": "workspace", - "type": "str", - "required": false, - "help": "Workspace id (from workspaces-list) to query a per-workspace DB" - }, - { - "name": "limit", - "type": "int", - "default": 200, - "required": false, - "help": "" - } - ], - "columns": [ - "Index", - "Key", - "Kind", - "Path" - ], - "type": "js", - "modulePath": "plugins/trae-solo/state-fs.js", - "sourceFile": "plugins/trae-solo/state-fs.js" - }, - { - "site": "trae-solo", - "name": "status", - "description": "Check active CDP connection to Trae SOLO Desktop", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [], - "columns": [ - "Status", - "Url", - "Title" - ], - "type": "js", - "modulePath": "plugins/trae-solo/status.js", - "sourceFile": "plugins/trae-solo/status.js", - "navigateBefore": true - }, - { - "site": "trae-solo", - "name": "storage-get", - "description": "Read a single localStorage / sessionStorage value on the Trae SOLO renderer.", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "key", - "type": "str", - "required": true, - "positional": true, - "help": "Storage key (use storage-keys to discover)" - }, - { - "name": "storage", - "type": "str", - "default": "local", - "required": false, - "help": "\"local\" or \"session\"" - }, - { - "name": "max-bytes", - "type": "int", - "default": 4000, - "required": false, - "help": "Truncate value to this many chars" - } - ], - "columns": [ - "Field", - "Value" - ], - "type": "js", - "modulePath": "plugins/trae-solo/renderer-storage.js", - "sourceFile": "plugins/trae-solo/renderer-storage.js", - "navigateBefore": true - }, - { - "site": "trae-solo", - "name": "storage-keys", - "description": "List localStorage / sessionStorage keys on the Trae SOLO renderer (CDP). For the on-disk VSCode state.vscdb, see state-keys.", - "access": "read", - "domain": "localhost", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "storage", - "type": "str", - "default": "local", - "required": false, - "help": "\"local\" or \"session\"" - }, - { - "name": "filter", - "type": "str", - "required": false, - "help": "Case-insensitive substring filter" - }, - { - "name": "limit", - "type": "int", - "default": 100, - "required": false, - "help": "Max rows to return" - } - ], - "columns": [ - "Index", - "Key", - "Bytes", - "Name", - "Preview", - "Database", - "Version" - ], - "type": "js", - "modulePath": "plugins/trae-solo/renderer-storage.js", - "sourceFile": "plugins/trae-solo/renderer-storage.js", - "navigateBefore": true - }, - { - "site": "trae-solo", - "name": "task-fs-list", - "description": "List Trae SOLO task ids from disk (snapshot/ + agentconfig/.json). Works while Trae is closed.", - "access": "read", - "domain": "localhost", - "strategy": "local", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 100, - "required": false, - "help": "" - } - ], - "columns": [ - "Index", - "Task Id", - "Has Snapshot", - "Has Config", - "Modified", - "Phase", - "Turn Id", - "Commit" - ], - "type": "js", - "modulePath": "plugins/trae-solo/task-fs.js", - "sourceFile": "plugins/trae-solo/task-fs.js" - }, - { - "site": "trae-solo", - "name": "task-fs-show", - "description": "Show the workspace tree at a given chat-turn ref (via git ls-tree). Pass --turn to pick a turn; otherwise the latest after-chat-turn ref.", - "access": "read", - "domain": "localhost", - "strategy": "local", - "browser": false, - "args": [ - { - "name": "task-id", - "type": "str", - "required": true, - "positional": true, - "help": "Task UUID" - }, - { - "name": "turn", - "type": "str", - "required": false, - "help": "Specific turn id (omit for latest after-chat-turn)" - }, - { - "name": "limit", - "type": "int", - "default": 50, - "required": false, - "help": "" - } - ], - "columns": [ - "Mode", - "Path", - "Size" - ], - "type": "js", - "modulePath": "plugins/trae-solo/task-fs.js", - "sourceFile": "plugins/trae-solo/task-fs.js" - }, - { - "site": "trae-solo", - "name": "task-fs-turns", - "description": "Show the chat-turn timeline for a Trae SOLO task as git tags (before-chat-turn-* / after-chat-turn-*).", - "access": "read", - "domain": "localhost", - "strategy": "local", - "browser": false, - "args": [ - { - "name": "task-id", - "type": "str", - "required": true, - "positional": true, - "help": "Task UUID (folder name under snapshot/)" - }, - { - "name": "limit", - "type": "int", - "default": 50, - "required": false, - "help": "" - } - ], - "columns": [ - "Index", - "Task Id", - "Has Snapshot", - "Has Config", - "Modified", - "Phase", - "Turn Id", - "Commit" - ], - "type": "js", - "modulePath": "plugins/trae-solo/task-fs.js", - "sourceFile": "plugins/trae-solo/task-fs.js" - }, - { - "site": "trae-solo", - "name": "user-rules", - "description": "Print Trae SOLO user rules (~/.trae/user_rules.md).", - "access": "read", - "domain": "localhost", - "strategy": "local", - "browser": false, - "args": [], - "columns": [ - "Field", - "Value" - ], - "type": "js", - "modulePath": "plugins/trae-solo/user-rules.js", - "sourceFile": "plugins/trae-solo/user-rules.js" - }, - { - "site": "trae-solo", - "name": "workspaces-list", - "description": "List Trae SOLO workspaceStorage entries (~/Library/.../TRAE SOLO/User/workspaceStorage//), resolving each workspace.json to its single-folder path or multi-folder workspace target. Works while Trae is closed.", - "access": "read", - "domain": "localhost", - "strategy": "local", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 100, - "required": false, - "help": "" - } - ], - "columns": [ - "Index", - "Workspace Id", - "Kind", - "Target", - "Modified", - "Id", - "Version", - "Source", - "Installed" - ], - "type": "js", - "modulePath": "plugins/trae-solo/workspaces-fs.js", - "sourceFile": "plugins/trae-solo/workspaces-fs.js" - }, - { - "site": "trip", - "name": "attraction", - "description": "Search Trip.com attractions and experiences by destination keyword", - "access": "read", - "domain": "trip.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Destination or attraction keyword (e.g. Tokyo / Paris / Louvre)" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of results (1-50)" - } - ], - "columns": [ - "rank", - "name", - "rating", - "reviews", - "booked", - "price", - "currency", - "url" - ], - "type": "js", - "modulePath": "plugins/trip/attraction.js", - "sourceFile": "plugins/trip/attraction.js", - "navigateBefore": false - }, - { - "site": "trip", - "name": "car", - "description": "List Trip.com car-rental vehicles for a city (category, model, seats, daily price)", - "access": "read", - "domain": "trip.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "city", - "type": "str", - "required": true, - "positional": true, - "help": "Numeric Trip.com carhire city id (discover via the carhire search box)" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of vehicles (1-50)" - } - ], - "columns": [ - "rank", - "category", - "vehicle", - "seats", - "price", - "currency", - "url" - ], - "type": "js", - "modulePath": "plugins/trip/car.js", - "sourceFile": "plugins/trip/car.js", - "navigateBefore": false - }, - { - "site": "trip", - "name": "deals", - "description": "List Trip.com live promotions from the Top Deals hub: campaign title, offer, discount, and link", - "access": "read", - "domain": "trip.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of deals (1-50)" - } - ], - "columns": [ - "rank", - "title", - "offer", - "discount", - "url" - ], - "type": "js", - "modulePath": "plugins/trip/deals.js", - "sourceFile": "plugins/trip/deals.js", - "navigateBefore": false - }, - { - "site": "trip", - "name": "flight", - "description": "Search Trip.com one-way flights by IATA route + departure date", - "access": "read", - "domain": "trip.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "from", - "type": "str", - "required": true, - "positional": true, - "help": "Departure IATA code (e.g. LON / LHR)" - }, - { - "name": "to", - "type": "str", - "required": true, - "positional": true, - "help": "Arrival IATA code (e.g. NYC / JFK)" - }, - { - "name": "date", - "type": "str", - "required": true, - "help": "Departure date (YYYY-MM-DD)" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of flights (1-50)" - } - ], - "columns": [ - "rank", - "airline", - "departureTime", - "departureAirport", - "arrivalTime", - "arrivalAirport", - "duration", - "stops", - "price", - "currency", - "url" - ], - "type": "js", - "modulePath": "plugins/trip/flight.js", - "sourceFile": "plugins/trip/flight.js", - "navigateBefore": false - }, - { - "site": "trip", - "name": "flight-round", - "description": "Search Trip.com round-trip flights by IATA route + depart/return dates", - "access": "read", - "domain": "trip.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "from", - "type": "str", - "required": true, - "positional": true, - "help": "Departure IATA code (e.g. LON / LHR)" - }, - { - "name": "to", - "type": "str", - "required": true, - "positional": true, - "help": "Arrival IATA code (e.g. NYC / JFK)" - }, - { - "name": "depart", - "type": "str", - "required": true, - "help": "Outbound date (YYYY-MM-DD)" - }, - { - "name": "return", - "type": "str", - "required": true, - "help": "Return date (YYYY-MM-DD)" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of flights (1-50)" - } - ], - "columns": [ - "rank", - "airline", - "departureTime", - "departureAirport", - "arrivalTime", - "arrivalAirport", - "duration", - "stops", - "price", - "currency", - "url" - ], - "type": "js", - "modulePath": "plugins/trip/flight-round.js", - "sourceFile": "plugins/trip/flight-round.js", - "navigateBefore": false - }, - { - "site": "trip", - "name": "hotel", - "description": "Show a Trip.com hotel detail by id (rating breakdown, amenities, check-in/out policy)", - "access": "read", - "domain": "trip.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "id", - "type": "str", - "required": true, - "positional": true, - "help": "Numeric Trip.com hotel id (discover via the hotels list; e.g. 715233)" - } - ], - "columns": [ - "hotelId", - "name", - "enName", - "star", - "score", - "scoreLabel", - "reviewCount", - "ratingBreakdown", - "facilities", - "checkInOut", - "cityName", - "address", - "lat", - "lon", - "url" - ], - "type": "js", - "modulePath": "plugins/trip/hotel.js", - "sourceFile": "plugins/trip/hotel.js", - "navigateBefore": false - }, - { - "site": "trip", - "name": "hotel-search", - "description": "List Trip.com hotels for a city id + check-in/out date range", - "access": "read", - "domain": "trip.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "city", - "type": "str", - "required": true, - "positional": true, - "help": "Numeric Trip.com city id (discover via the hotels search box; e.g. 338 for London)" - }, - { - "name": "checkin", - "type": "str", - "required": true, - "help": "Check-in date (YYYY-MM-DD)" - }, - { - "name": "checkout", - "type": "str", - "required": true, - "help": "Check-out date (YYYY-MM-DD)" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of hotels (1-50)" - } - ], - "columns": [ - "rank", - "name", - "score", - "reviewLabel", - "reviews", - "location", - "room", - "price", - "currency", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/trip/hotel-search.js", - "sourceFile": "plugins/trip/hotel-search.js", - "navigateBefore": false - }, - { - "site": "trip", - "name": "package", - "description": "Search Trip.com flight+hotel packages by route + dates; lists the package flight options priced at the bundle rate", - "access": "read", - "domain": "trip.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "from", - "type": "str", - "required": true, - "positional": true, - "help": "Origin city keyword (e.g. Seoul / London / Bangkok)" - }, - { - "name": "to", - "type": "str", - "required": true, - "positional": true, - "help": "Destination city keyword (e.g. Tokyo / Paris / Singapore)" - }, - { - "name": "depart", - "type": "str", - "required": true, - "help": "Outbound date (YYYY-MM-DD)" - }, - { - "name": "return", - "type": "str", - "required": true, - "help": "Return date (YYYY-MM-DD)" - }, - { - "name": "adults", - "type": "int", - "default": 2, - "required": false, - "help": "Number of adults (1-9, default 2)" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of packages (1-50)" - } - ], - "columns": [ - "rank", - "airline", - "flightNo", - "from", - "to", - "departure", - "arrival", - "stops", - "price", - "currency" - ], - "type": "js", - "modulePath": "plugins/trip/package.js", - "sourceFile": "plugins/trip/package.js" - }, - { - "site": "trip", - "name": "search", - "description": "Suggest Trip.com destinations (cities, airports) for a keyword; resolves the ids the other commands take", - "access": "read", - "domain": "trip.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Destination keyword (e.g. Tokyo / Bali / London)" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of suggestions (1-50)" - } - ], - "columns": [ - "rank", - "name", - "type", - "cityId", - "airportCode", - "province", - "country" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/trip/search.js", - "sourceFile": "plugins/trip/search.js" - }, - { - "site": "trip", - "name": "tour", - "description": "Search Trip.com tour packages by destination keyword (private or group tours)", - "access": "read", - "domain": "trip.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Destination or tour keyword (e.g. Tokyo / Kyoto / Bali)" - }, - { - "name": "type", - "type": "str", - "default": "private", - "required": false, - "help": "Tour line: private or group (default private)" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of tours (1-50)" - } - ], - "columns": [ - "rank", - "name", - "type", - "rating", - "reviews", - "price", - "currency", - "url" - ], - "type": "js", - "modulePath": "plugins/trip/tour.js", - "sourceFile": "plugins/trip/tour.js", - "navigateBefore": false - }, - { - "site": "trip", - "name": "train", - "description": "Show a Trip.com train route timetable (departure/arrival times, duration, changes)", - "access": "read", - "domain": "trip.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "from", - "type": "str", - "required": true, - "positional": true, - "help": "Departure city (e.g. London / Paris / Shanghai)" - }, - { - "name": "to", - "type": "str", - "required": true, - "positional": true, - "help": "Arrival city (e.g. Manchester / Lyon / Beijing)" - }, - { - "name": "country", - "type": "str", - "required": true, - "help": "Route country slug (e.g. uk / france / italy / spain / germany / china)" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of journeys (1-50)" - } - ], - "columns": [ - "rank", - "departureTime", - "fromStation", - "arrivalTime", - "toStation", - "duration", - "changes", - "url" - ], - "type": "js", - "modulePath": "plugins/trip/train.js", - "sourceFile": "plugins/trip/train.js", - "navigateBefore": false - }, - { - "site": "trip", - "name": "transfer", - "description": "List Trip.com airport-transfer vehicles for a city + airport (type, seats, from-price)", - "access": "read", - "domain": "trip.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "city", - "type": "str", - "required": true, - "positional": true, - "help": "Airport city (e.g. Bangkok / Beijing / Da Nang)" - }, - { - "name": "airport", - "type": "str", - "required": true, - "positional": true, - "help": "3-letter airport IATA code (e.g. DMK / PKX / DAD)" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of vehicles (1-50)" - } - ], - "columns": [ - "rank", - "type", - "passengers", - "luggage", - "price", - "currency", - "url" - ], - "type": "js", - "modulePath": "plugins/trip/transfer.js", - "sourceFile": "plugins/trip/transfer.js", - "navigateBefore": false - }, - { - "site": "tvmaze", - "name": "search", - "description": "TVmaze TV show search by title (returns id, name, network, premiered/ended, rating)", - "access": "read", - "domain": "tvmaze.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "string", - "required": true, - "positional": true, - "help": "TV show title or fragment to search for" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max rows to return (1-50)" - } - ], - "columns": [ - "rank", - "id", - "name", - "type", - "language", - "genres", - "status", - "premiered", - "ended", - "network", - "rating", - "matchScore", - "summary", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/tvmaze/search.js", - "sourceFile": "plugins/tvmaze/search.js" - }, - { - "site": "tvmaze", - "name": "show", - "description": "Single TVmaze TV show detail by id (network, schedule, rating, IMDB/TheTVDB cross-refs)", - "access": "read", - "domain": "tvmaze.com", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "id", - "type": "int", - "required": true, - "positional": true, - "help": "TVmaze show id (positive integer)" - } - ], - "columns": [ - "id", - "name", - "type", - "language", - "genres", - "status", - "premiered", - "ended", - "runtime", - "averageRuntime", - "network", - "country", - "schedule", - "rating", - "imdb", - "thetvdb", - "officialSite", - "summary", - "url" - ], - "type": "js", - "modulePath": "plugins/tvmaze/show.js", - "sourceFile": "plugins/tvmaze/show.js" - }, - { - "site": "twitter", - "name": "accept", - "description": "Auto-accept DM requests containing specific keywords", - "access": "write", - "domain": "x.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "query", - "type": "string", - "required": true, - "positional": true, - "help": "Keywords to match (comma-separated for OR, e.g. \"invoice,urgent\")" - }, - { - "name": "max", - "type": "int", - "default": 20, - "required": false, - "help": "Maximum number of requests to accept (default: 20)" - }, - { - "name": "timeout", - "type": "int", - "default": 600, - "required": false, - "help": "Max seconds for the overall command (default: 600 — batch op)" - } - ], - "columns": [ - "index", - "status", - "user", - "message" - ], - "type": "js", - "modulePath": "plugins/twitter/accept.js", - "sourceFile": "plugins/twitter/accept.js", - "navigateBefore": true - }, - { - "site": "twitter", - "name": "article", - "description": "Fetch a Twitter Article (long-form content) and export as Markdown", - "access": "read", - "domain": "x.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "tweet-id", - "type": "string", - "required": true, - "positional": true, - "help": "Tweet ID or URL containing the article" - } - ], - "columns": [ - "title", - "author", - "content", - "url" - ], - "type": "js", - "modulePath": "plugins/twitter/article.js", - "sourceFile": "plugins/twitter/article.js", - "navigateBefore": "https://x.com" - }, - { - "site": "twitter", - "name": "block", - "description": "Block a Twitter user", - "access": "write", - "domain": "x.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "username", - "type": "string", - "required": true, - "positional": true, - "help": "Twitter screen name (without @)" - } - ], - "columns": [ - "status", - "message" - ], - "type": "js", - "modulePath": "plugins/twitter/block.js", - "sourceFile": "plugins/twitter/block.js", - "navigateBefore": true - }, - { - "site": "twitter", - "name": "bookmark", - "description": "Bookmark a tweet", - "access": "write", - "domain": "x.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "url", - "type": "string", - "required": true, - "positional": true, - "help": "Tweet URL to bookmark" - } - ], - "columns": [ - "status", - "message" - ], - "type": "js", - "modulePath": "plugins/twitter/bookmark.js", - "sourceFile": "plugins/twitter/bookmark.js", - "navigateBefore": true - }, - { - "site": "twitter", - "name": "bookmark-folder", - "description": "Read the tweets inside a single Twitter/X bookmark folder. Get the folder id from `webcmd twitter bookmark-folders`.", - "access": "read", - "domain": "x.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "folder-id", - "type": "string", - "required": true, - "positional": true, - "help": "Folder id from `webcmd twitter bookmark-folders`." - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Maximum number of bookmarks to return (default 20)." - }, - { - "name": "top-by-engagement", - "type": "int", - "default": 0, - "required": false, - "help": "When set to N>0, re-rank the folder by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps the API's native (saved-time) ordering." - } - ], - "columns": [ - "id", - "author", - "text", - "likes", - "retweets", - "bookmarks", - "created_at", - "url", - "has_media", - "media_urls", - "media_posters" - ], - "type": "js", - "modulePath": "plugins/twitter/bookmark-folder.js", - "sourceFile": "plugins/twitter/bookmark-folder.js", - "navigateBefore": "https://x.com" - }, - { - "site": "twitter", - "name": "bookmark-folders", - "description": "List your Twitter/X bookmark folders (the user-created collections under Bookmarks). Returns folder id, name, item count, and created_at.", - "access": "read", - "domain": "x.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "id", - "name", - "items", - "created_at" - ], - "type": "js", - "modulePath": "plugins/twitter/bookmark-folders.js", - "sourceFile": "plugins/twitter/bookmark-folders.js", - "navigateBefore": "https://x.com" - }, - { - "site": "twitter", - "name": "bookmarks", - "description": "Fetch your Twitter/X bookmarks (the logged-in user's saved tweets, newest first)", - "access": "read", - "domain": "x.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Maximum number of bookmarks to return (default 20). Ignored when --all is set." - }, - { - "name": "all", - "type": "bool", - "default": false, - "required": false, - "help": "Fetch all bookmark pages until exhausted. Prefer --output-file for large archives." - }, - { - "name": "resume-file", - "type": "string", - "required": false, - "help": "Resume file for long-running all-pages bookmark syncs.", - "file": { - "direction": "input-output", - "pathKind": "file", - "multiple": false, - "contentTypes": [ - "application/json" - ] - } - }, - { - "name": "output-file", - "type": "string", - "required": false, - "help": "Write all-page results to JSONL. Requires --all and --resume-file.", - "file": { - "direction": "output", - "pathKind": "file", - "multiple": false - } - }, - { - "name": "max-pages", - "type": "int", - "required": false, - "help": "Optional pagination safety cap (default 100; raised automatically with --all)." - }, - { - "name": "top-by-engagement", - "type": "int", - "default": 0, - "required": false, - "help": "When set to N>0, re-rank the bookmarks by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps the API's native (saved-time) ordering. Incompatible with --output-file." - } - ], - "columns": [ - "id", - "author", - "text", - "likes", - "retweets", - "bookmarks", - "created_at", - "url", - "has_media", - "media_urls", - "media_posters" - ], - "type": "js", - "modulePath": "plugins/twitter/bookmarks.js", - "sourceFile": "plugins/twitter/bookmarks.js", - "navigateBefore": "https://x.com" - }, - { - "site": "twitter", - "name": "collection", - "description": "Fetch a user timeline with relationship facts and a bounded completion receipt.", - "access": "read", - "domain": "x.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "username", - "type": "string", - "required": true, - "positional": true, - "help": "Twitter screen name (with or without @)." - }, - { - "name": "until", - "type": "string", - "required": true, - "help": "RFC3339 lower time boundary that must be reached or exhausted." - }, - { - "name": "limit", - "type": "int", - "default": 10000, - "required": false, - "help": "Safety ceiling; reaching it is a typed failure." - }, - { - "name": "page-delay", - "type": "int", - "default": 2, - "required": false, - "help": "Seconds to wait between cursor pages." - } - ], - "columns": [ - "posts", - "receipt" - ], - "type": "js", - "modulePath": "plugins/twitter/collection.js", - "sourceFile": "plugins/twitter/collection.js", - "navigateBefore": "https://x.com" - }, - { - "site": "twitter", - "name": "delete", - "description": "Delete a specific tweet by URL", - "access": "write", - "domain": "x.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "url", - "type": "string", - "required": true, - "positional": true, - "help": "The URL of the tweet to delete" - } - ], - "columns": [ - "status", - "message" - ], - "type": "js", - "modulePath": "plugins/twitter/delete.js", - "sourceFile": "plugins/twitter/delete.js", - "navigateBefore": true - }, - { - "site": "twitter", - "name": "device-follow", - "description": "Read the /i/timeline device-follow notification stream (tweets aggregated under a bell-icon \"new posts from @userA and N others\" notification)", - "access": "read", - "domain": "x.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Maximum number of tweets to return (1-200, default 20)" - }, - { - "name": "top-by-engagement", - "type": "int", - "default": 0, - "required": false, - "help": "When set to N>0, re-rank by weighted engagement and return the top N. Default 0 keeps upstream ordering." - } - ], - "columns": [ - "id", - "author", - "text", - "likes", - "retweets", - "replies", - "views", - "created_at", - "url" - ], - "type": "js", - "modulePath": "plugins/twitter/device-follow.js", - "sourceFile": "plugins/twitter/device-follow.js", - "navigateBefore": "https://x.com" - }, - { - "site": "twitter", - "name": "download", - "description": "Download Twitter/X media (images and videos). Provide either to fetch every media item from their profile via the GraphQL UserMedia endpoint with cursor pagination, or --tweet-url to download a single tweet.", - "access": "read", - "domain": "x.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "username", - "type": "str", - "required": false, - "positional": true, - "help": "Twitter username (with or without @) to scan their profile media. Either or --tweet-url is required." - }, - { - "name": "tweet-url", - "type": "str", - "required": false, - "help": "Single tweet URL to download. Use this OR , not both required at once." - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Maximum number of media items to download when scanning a profile (default 10). Ignored when --tweet-url is used." - }, - { - "name": "output", - "type": "str", - "default": "./twitter-downloads", - "required": false, - "help": "Output directory (default ./twitter-downloads). A per-source subdir is created inside.", - "file": { - "direction": "output", - "pathKind": "directory", - "multiple": false - } - } - ], - "columns": [ - "index", - "tweet_id", - "url", - "type", - "status", - "size" - ], - "type": "js", - "modulePath": "plugins/twitter/download.js", - "sourceFile": "plugins/twitter/download.js", - "navigateBefore": "https://x.com" - }, - { - "site": "twitter", - "name": "follow", - "description": "Follow a Twitter user", - "access": "write", - "domain": "x.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "username", - "type": "string", - "required": true, - "positional": true, - "help": "Twitter screen name (without @)" - } - ], - "columns": [ - "status", - "message" - ], - "type": "js", - "modulePath": "plugins/twitter/follow.js", - "sourceFile": "plugins/twitter/follow.js", - "navigateBefore": true - }, - { - "site": "twitter", - "name": "follow-batch", - "description": "Follow multiple Twitter/X users from a comma-separated username list", - "access": "write", - "domain": "x.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "usernames", - "type": "string", - "required": true, - "positional": true, - "help": "Comma-separated Twitter/X screen names, with or without @" - }, - { - "name": "delay-ms", - "type": "int", - "default": 3000, - "required": false, - "help": "Delay between follow attempts in milliseconds" - } - ], - "columns": [ - "username", - "status", - "message" - ], - "type": "js", - "modulePath": "plugins/twitter/follow-batch.js", - "sourceFile": "plugins/twitter/follow-batch.js", - "navigateBefore": true - }, - { - "site": "twitter", - "name": "followers", - "description": "Get accounts following a Twitter/X user (defaults to the logged-in user when no user is given)", - "access": "read", - "domain": "x.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "user", - "type": "string", - "required": false, - "positional": true, - "help": "Twitter/X handle (with or without @). Omit to fetch followers of the currently logged-in account." - }, - { - "name": "limit", - "type": "int", - "default": 50, - "required": false, - "help": "Maximum number of follower rows to return (default 50). Must be a positive integer." - } - ], - "columns": [ - "screen_name", - "name", - "bio" - ], - "type": "js", - "modulePath": "plugins/twitter/followers.js", - "sourceFile": "plugins/twitter/followers.js", - "navigateBefore": true - }, - { - "site": "twitter", - "name": "following", - "description": "Get accounts a Twitter/X user is following (defaults to the logged-in user when no user is given)", - "access": "read", - "domain": "x.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "user", - "type": "string", - "required": false, - "positional": true, - "help": "Twitter/X handle (with or without @). Omit to fetch the accounts the currently logged-in user follows." - }, - { - "name": "limit", - "type": "int", - "default": 50, - "required": false, - "help": "Maximum number of following rows to return (default 50). Must be a positive integer." - } - ], - "columns": [ - "screen_name", - "name", - "bio", - "followers" - ], - "type": "js", - "modulePath": "plugins/twitter/following.js", - "sourceFile": "plugins/twitter/following.js", - "navigateBefore": "https://x.com" - }, - { - "site": "twitter", - "name": "hide-reply", - "description": "Hide a reply on your tweet (useful for hiding bot/spam replies)", - "access": "write", - "domain": "x.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "url", - "type": "string", - "required": true, - "positional": true, - "help": "The URL of the reply tweet to hide" - } - ], - "columns": [ - "status", - "message" - ], - "type": "js", - "modulePath": "plugins/twitter/hide-reply.js", - "sourceFile": "plugins/twitter/hide-reply.js", - "navigateBefore": true - }, - { - "site": "twitter", - "name": "like", - "description": "Like a specific tweet", - "access": "write", - "domain": "x.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "url", - "type": "string", - "required": true, - "positional": true, - "help": "The URL of the tweet to like" - } - ], - "columns": [ - "status", - "message" - ], - "type": "js", - "modulePath": "plugins/twitter/like.js", - "sourceFile": "plugins/twitter/like.js", - "navigateBefore": true - }, - { - "site": "twitter", - "name": "likes", - "description": "Fetch liked tweets of a Twitter user (defaults to the logged-in user when no username is given)", - "access": "read", - "domain": "x.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "username", - "type": "string", - "required": false, - "positional": true, - "help": "Twitter screen name (with or without @). Defaults to the logged-in user when omitted." - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Maximum number of liked tweets to return (default 20). Ignored when --all is set." - }, - { - "name": "all", - "type": "bool", - "default": false, - "required": false, - "help": "Fetch all liked-tweet pages until exhausted. Prefer --output-file for large archives." - }, - { - "name": "resume-file", - "type": "string", - "required": false, - "help": "Resume file for long-running all-pages likes syncs.", - "file": { - "direction": "input-output", - "pathKind": "file", - "multiple": false, - "contentTypes": [ - "application/json" - ] - } - }, - { - "name": "output-file", - "type": "string", - "required": false, - "help": "Write all-page results to JSONL. Requires --all and --resume-file.", - "file": { - "direction": "output", - "pathKind": "file", - "multiple": false - } - }, - { - "name": "max-pages", - "type": "int", - "required": false, - "help": "Optional pagination safety cap (default 100; raised automatically with --all)." - }, - { - "name": "top-by-engagement", - "type": "int", - "default": 0, - "required": false, - "help": "When set to N>0, re-rank the liked tweets by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps the API's native (recency) ordering. Incompatible with --output-file." - } - ], - "columns": [ - "id", - "author", - "name", - "text", - "likes", - "retweets", - "created_at", - "url", - "has_media", - "media_urls", - "media_posters" - ], - "type": "js", - "modulePath": "plugins/twitter/likes.js", - "sourceFile": "plugins/twitter/likes.js", - "navigateBefore": "https://x.com" - }, - { - "site": "twitter", - "name": "list-add", - "description": "Add a user to a Twitter/X list you own (no-op if already a member)", - "access": "write", - "domain": "x.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "listId", - "type": "string", - "required": true, - "positional": true, - "help": "Numeric ID of the list you own (e.g. from `webcmd twitter lists`)" - }, - { - "name": "username", - "type": "string", - "required": true, - "positional": true, - "help": "Twitter/X handle to add (with or without @)" - } - ], - "columns": [ - "listId", - "username", - "userId", - "status", - "message" - ], - "type": "js", - "modulePath": "plugins/twitter/list-add.js", - "sourceFile": "plugins/twitter/list-add.js", - "navigateBefore": true - }, - { - "site": "twitter", - "name": "list-add-batch", - "description": "Add multiple users to a Twitter/X list you own from a comma-separated username list", - "access": "write", - "domain": "x.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "listId", - "type": "string", - "required": true, - "positional": true, - "help": "Numeric ID of the list you own (e.g. from `webcmd twitter lists`)" - }, - { - "name": "usernames", - "type": "string", - "required": true, - "positional": true, - "help": "Comma-separated Twitter/X handles to add (with or without @)" - }, - { - "name": "interval", - "type": "int", - "default": 5, - "required": false, - "help": "Seconds to wait between account additions (default: 5)" - }, - { - "name": "timeout", - "type": "int", - "default": 600, - "required": false, - "help": "Max seconds for the overall batch command (default: 600)" - } - ], - "columns": [ - "listId", - "username", - "userId", - "status", - "message" - ], - "type": "js", - "modulePath": "plugins/twitter/list-add-batch.js", - "sourceFile": "plugins/twitter/list-add-batch.js", - "navigateBefore": true - }, - { - "site": "twitter", - "name": "list-create", - "description": "Create a new Twitter/X list (returns the new list id)", - "access": "write", - "domain": "x.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "name", - "type": "string", - "required": true, - "positional": true, - "help": "List name (max 25 chars)" - }, - { - "name": "description", - "type": "string", - "default": "", - "required": false, - "help": "Optional list description (max 100 chars)" - }, - { - "name": "mode", - "type": "string", - "default": "public", - "required": false, - "help": "public | private" - } - ], - "columns": [ - "id", - "name", - "description", - "mode", - "status" - ], - "type": "js", - "modulePath": "plugins/twitter/list-create.js", - "sourceFile": "plugins/twitter/list-create.js", - "navigateBefore": "https://x.com" - }, - { - "site": "twitter", - "name": "list-delete", - "description": "Delete a Twitter/X list you own after explicit confirmation", - "access": "write", - "domain": "x.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "listId", - "type": "string", - "required": true, - "positional": true, - "help": "Numeric ID of the list you own (e.g. from `webcmd twitter lists`)" - }, - { - "name": "confirm", - "type": "boolean", - "default": false, - "required": false, - "help": "Required. Set --confirm true to delete the list." - }, - { - "name": "timeout", - "type": "int", - "default": 300, - "required": false, - "help": "Max seconds for the overall delete command (default: 300)" - } - ], - "columns": [ - "listId", - "name", - "members", - "status", - "message" - ], - "type": "js", - "modulePath": "plugins/twitter/list-delete.js", - "sourceFile": "plugins/twitter/list-delete.js", - "navigateBefore": true - }, - { - "site": "twitter", - "name": "list-remove", - "description": "Remove a user from a Twitter/X list you own (toggles via UI; no-op if not currently a member)", - "access": "write", - "domain": "x.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "listId", - "type": "string", - "required": true, - "positional": true, - "help": "Numeric ID of the list you own (e.g. from `webcmd twitter lists`)" - }, - { - "name": "username", - "type": "string", - "required": true, - "positional": true, - "help": "Twitter/X handle to remove (with or without @)" - } - ], - "columns": [ - "listId", - "username", - "userId", - "status", - "message" - ], - "type": "js", - "modulePath": "plugins/twitter/list-remove.js", - "sourceFile": "plugins/twitter/list-remove.js", - "navigateBefore": true - }, - { - "site": "twitter", - "name": "list-remove-batch", - "description": "Remove multiple users from a Twitter/X list you own from a comma-separated username list", - "access": "write", - "domain": "x.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "listId", - "type": "string", - "required": true, - "positional": true, - "help": "Numeric ID of the list you own (e.g. from `webcmd twitter lists`)" - }, - { - "name": "usernames", - "type": "string", - "required": true, - "positional": true, - "help": "Comma-separated Twitter/X handles to remove (with or without @)" - }, - { - "name": "interval", - "type": "int", - "default": 5, - "required": false, - "help": "Seconds to wait between account removals (default: 5)" - }, - { - "name": "timeout", - "type": "int", - "default": 600, - "required": false, - "help": "Max seconds for the overall batch command (default: 600)" - } - ], - "columns": [ - "listId", - "username", - "userId", - "status", - "message" - ], - "type": "js", - "modulePath": "plugins/twitter/list-remove-batch.js", - "sourceFile": "plugins/twitter/list-remove-batch.js", - "navigateBefore": true - }, - { - "site": "twitter", - "name": "list-tweets", - "description": "Fetch tweets from a Twitter/X list timeline", - "access": "read", - "domain": "x.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "listId", - "type": "string", - "required": true, - "positional": true, - "help": "Numeric ID of a Twitter/X list (e.g. from `webcmd twitter lists`)" - }, - { - "name": "limit", - "type": "int", - "default": 50, - "required": false, - "help": "" - }, - { - "name": "top-by-engagement", - "type": "int", - "default": 0, - "required": false, - "help": "When set to N>0, re-rank the list timeline by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps the list's native (recency) ordering." - } - ], - "columns": [ - "id", - "author", - "bio", - "text", - "likes", - "retweets", - "replies", - "created_at", - "url", - "has_media", - "media_urls", - "media_posters", - "card", - "quoted_tweet" - ], - "type": "js", - "modulePath": "plugins/twitter/list-tweets.js", - "sourceFile": "plugins/twitter/list-tweets.js", - "navigateBefore": "https://x.com" - }, - { - "site": "twitter", - "name": "lists", - "description": "Get Twitter/X lists for the logged-in user (owned + subscribed)", - "access": "read", - "domain": "x.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 50, - "required": false, - "help": "Maximum number of lists to return (default 50)." - } - ], - "columns": [ - "id", - "name", - "members", - "followers", - "mode" - ], - "type": "js", - "modulePath": "plugins/twitter/lists.js", - "sourceFile": "plugins/twitter/lists.js", - "navigateBefore": "https://x.com" - }, - { - "site": "twitter", - "name": "login", - "description": "Open twitter login", - "access": "write", - "domain": "x.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "status", - "logged_in", - "site", - "username", - "url", - "action", - "verify_command" - ], - "type": "js", - "modulePath": "plugins/twitter/auth.js", - "sourceFile": "plugins/twitter/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "twitter", - "name": "notifications", - "description": "Get your Twitter/X notifications (the logged-in user's likes/replies/follows feed, newest first)", - "access": "read", - "domain": "x.com", - "strategy": "intercept", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Maximum number of notifications to return (default 20)." - } - ], - "columns": [ - "id", - "action", - "author", - "text", - "url" - ], - "type": "js", - "modulePath": "plugins/twitter/notifications.js", - "sourceFile": "plugins/twitter/notifications.js", - "navigateBefore": true - }, - { - "site": "twitter", - "name": "post", - "description": "Post a new tweet/thread", - "access": "write", - "domain": "x.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "text", - "type": "string", - "required": true, - "positional": true, - "help": "The text content of the tweet" - }, - { - "name": "images", - "type": "string", - "required": false, - "help": "Image paths, comma-separated, max 4 (jpg/png/gif/webp)", - "file": { - "direction": "input", - "pathKind": "file", - "multiple": true, - "separator": ",", - "contentTypes": [ - "image/jpeg", - "image/png", - "image/gif", - "image/webp" - ], - "maxBytes": 26214400 - } - } - ], - "columns": [ - "status", - "message", - "text", - "id", - "url" - ], - "type": "js", - "modulePath": "plugins/twitter/post.js", - "sourceFile": "plugins/twitter/post.js", - "navigateBefore": true - }, - { - "site": "twitter", - "name": "profile", - "description": "Fetch a Twitter user profile — bio, stats, etc. (defaults to the logged-in user when no username is given)", - "access": "read", - "domain": "x.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "username", - "type": "string", - "required": false, - "positional": true, - "help": "Twitter screen name (with or without @). Defaults to the logged-in user when omitted." - } - ], - "columns": [ - "screen_name", - "name", - "bio", - "location", - "url", - "followers", - "following", - "tweets", - "likes", - "verified", - "created_at" - ], - "type": "js", - "modulePath": "plugins/twitter/profile.js", - "sourceFile": "plugins/twitter/profile.js", - "navigateBefore": "https://x.com" - }, - { - "site": "twitter", - "name": "quote", - "description": "Quote-tweet a specific tweet with your own text, optionally with a local or remote image", - "access": "write", - "domain": "x.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "url", - "type": "string", - "required": true, - "positional": true, - "help": "The URL of the tweet to quote" - }, - { - "name": "text", - "type": "string", - "required": true, - "positional": true, - "help": "The text content of your quote" - }, - { - "name": "image", - "type": "str", - "required": false, - "help": "Optional local image path to attach to the quote tweet", - "file": { - "direction": "input", - "pathKind": "file", - "multiple": false, - "contentTypes": [ - "image/jpeg", - "image/png", - "image/gif", - "image/webp" - ], - "maxBytes": 26214400 - } - }, - { - "name": "image-url", - "type": "str", - "required": false, - "help": "Optional remote image URL to download and attach to the quote tweet" - } - ], - "columns": [ - "status", - "message", - "text" - ], - "type": "js", - "modulePath": "plugins/twitter/quote.js", - "sourceFile": "plugins/twitter/quote.js", - "navigateBefore": true - }, - { - "site": "twitter", - "name": "reply", - "description": "Reply to a specific tweet, optionally with a local or remote image", - "access": "write", - "domain": "x.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "url", - "type": "string", - "required": true, - "positional": true, - "help": "The URL of the tweet to reply to" - }, - { - "name": "text", - "type": "string", - "required": true, - "positional": true, - "help": "The text content of your reply" - }, - { - "name": "image", - "type": "str", - "required": false, - "help": "Optional local image path to attach to the reply", - "file": { - "direction": "input", - "pathKind": "file", - "multiple": false, - "contentTypes": [ - "image/jpeg", - "image/png", - "image/gif", - "image/webp" - ], - "maxBytes": 26214400 - } - }, - { - "name": "image-url", - "type": "str", - "required": false, - "help": "Optional remote image URL to download and attach to the reply" - } - ], - "columns": [ - "status", - "message", - "text", - "url" - ], - "type": "js", - "modulePath": "plugins/twitter/reply.js", - "sourceFile": "plugins/twitter/reply.js", - "navigateBefore": true - }, - { - "site": "twitter", - "name": "reply-dm", - "description": "Send a message to recent DM conversations", - "access": "write", - "domain": "x.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "text", - "type": "string", - "required": true, - "positional": true, - "help": "Message text to send (e.g. \"my messaging handle wxkabi\")" - }, - { - "name": "max", - "type": "int", - "default": 20, - "required": false, - "help": "Maximum number of conversations to reply to (default: 20)" - }, - { - "name": "skip-replied", - "type": "boolean", - "default": true, - "required": false, - "help": "Skip conversations where you already sent the same text (default: true)" - }, - { - "name": "timeout", - "type": "int", - "default": 600, - "required": false, - "help": "Max seconds for the overall command (default: 600 — batch op)" - } - ], - "columns": [ - "index", - "status", - "user", - "message" - ], - "type": "js", - "modulePath": "plugins/twitter/reply-dm.js", - "sourceFile": "plugins/twitter/reply-dm.js", - "navigateBefore": true - }, - { - "site": "twitter", - "name": "retweet", - "description": "Retweet a specific tweet", - "access": "write", - "domain": "x.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "url", - "type": "string", - "required": true, - "positional": true, - "help": "The URL of the tweet to retweet" - } - ], - "columns": [ - "status", - "message" - ], - "type": "js", - "modulePath": "plugins/twitter/retweet.js", - "sourceFile": "plugins/twitter/retweet.js", - "navigateBefore": true - }, - { - "site": "twitter", - "name": "search", - "description": "Search Twitter/X for tweets, with optional --from / --has / --exclude / --product filters mapped to X's search operators", - "access": "read", - "domain": "x.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "query", - "type": "string", - "required": true, - "positional": true, - "help": "Search query. Raw X operators (e.g. \"exact phrase\", #tag, OR, lang:en, since:YYYY-MM-DD, from:, since:) are passed through unchanged." - }, - { - "name": "filter", - "type": "string", - "default": "top", - "required": false, - "help": "Legacy alias for --product. Kept for backwards compatibility; if --product is set it wins.", - "choices": [ - "top", - "live" - ] - }, - { - "name": "product", - "type": "string", - "required": false, - "help": "Which X search tab to read: top (default), live (Latest), photos, videos. Maps to the f= URL param.", - "choices": [ - "top", - "live", - "photos", - "videos" - ] - }, - { - "name": "from", - "type": "string", - "required": false, - "help": "Restrict to tweets authored by . Leading @ is stripped. Equivalent to appending `from:` to the query." - }, - { - "name": "has", - "type": "string", - "required": false, - "help": "Restrict to tweets that have media|images|videos|links|replies. Maps to X's `filter:` operator.", - "choices": [ - "media", - "images", - "videos", - "links", - "replies" - ] - }, - { - "name": "exclude", - "type": "string", - "required": false, - "help": "Exclude tweets matching : replies|retweets|media|links. Maps to X's `-filter:` operator (retweets → -filter:nativeretweets).", - "choices": [ - "replies", - "retweets", - "media", - "links" - ] - }, - { - "name": "limit", - "type": "int", - "default": 15, - "required": false, - "help": "Maximum number of tweets to return (default 15). Result count after server-side filtering." - }, - { - "name": "top-by-engagement", - "type": "int", - "default": 0, - "required": false, - "help": "When set to N>0, re-rank the results by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps X's native ordering." - } - ], - "columns": [ - "id", - "author", - "bio", - "text", - "created_at", - "likes", - "views", - "url", - "has_media", - "media_urls", - "media_posters", - "card", - "quoted_tweet" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/twitter/search.js", - "sourceFile": "plugins/twitter/search.js", - "navigateBefore": "https://x.com" - }, - { - "site": "twitter", - "name": "thread", - "description": "Get a tweet thread (original + all replies)", - "access": "read", - "domain": "x.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "tweet-id", - "type": "string", - "required": true, - "positional": true, - "help": "Tweet numeric ID (e.g. 1234567890) or full status URL" - }, - { - "name": "limit", - "type": "int", - "default": 50, - "required": false, - "help": "" - }, - { - "name": "top-by-engagement", - "type": "int", - "default": 0, - "required": false, - "help": "When set to N>0, re-rank the thread by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps the conversation's structural ordering." - } - ], - "columns": [ - "id", - "author", - "bio", - "text", - "likes", - "retweets", - "url", - "has_media", - "media_urls", - "media_posters", - "card", - "quoted_tweet" - ], - "type": "js", - "modulePath": "plugins/twitter/thread.js", - "sourceFile": "plugins/twitter/thread.js", - "navigateBefore": "https://x.com" - }, - { - "site": "twitter", - "name": "timeline", - "description": "Fetch the logged-in user's home timeline (for-you algorithmic feed by default; pass --type following for the chronological feed of accounts you follow)", - "access": "read", - "domain": "x.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "type", - "type": "str", - "default": "for-you", - "required": false, - "help": "Which home-timeline feed to read. Default for-you (algorithmic). Use following for the chronological feed of accounts you follow.", - "choices": [ - "for-you", - "following" - ] - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Maximum number of tweets to return (default 20)." - }, - { - "name": "top-by-engagement", - "type": "int", - "default": 0, - "required": false, - "help": "When set to N>0, re-rank the timeline by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps X's native ordering." - } - ], - "columns": [ - "id", - "author", - "bio", - "text", - "likes", - "retweets", - "replies", - "quotes", - "bookmarks", - "views", - "created_at", - "url", - "has_media", - "media_urls", - "media_posters", - "card", - "quoted_tweet" - ], - "type": "js", - "modulePath": "plugins/twitter/timeline.js", - "sourceFile": "plugins/twitter/timeline.js", - "navigateBefore": "https://x.com" - }, - { - "site": "twitter", - "name": "trending", - "description": "Twitter/X trending topics", - "access": "read", - "domain": "x.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Number of trends to show" - } - ], - "columns": [ - "rank", - "topic", - "category" - ], - "type": "js", - "modulePath": "plugins/twitter/trending.js", - "sourceFile": "plugins/twitter/trending.js", - "navigateBefore": "https://x.com" - }, - { - "site": "twitter", - "name": "tweets", - "description": "Fetch a Twitter user's most recent tweets (chronological, excludes pinned; defaults to the logged-in user when no username is given)", - "access": "read", - "domain": "x.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "username", - "type": "string", - "required": false, - "positional": true, - "help": "Twitter screen name (with or without @). Defaults to the logged-in user when omitted." - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max tweets to return (1-10000; fetched across cursor pages)" - }, - { - "name": "page-delay", - "type": "int", - "default": 2, - "required": false, - "help": "Seconds to wait between paginated timeline requests to reduce rate-limit risk. Use 0 to disable." - }, - { - "name": "top-by-engagement", - "type": "int", - "default": 0, - "required": false, - "help": "When set to N>0, re-rank the tweets by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps the chronological ordering." - } - ], - "columns": [ - "id", - "author", - "created_at", - "is_retweet", - "text", - "likes", - "retweets", - "replies", - "views", - "url", - "has_media", - "media_urls", - "media_posters", - "quoted_tweet" - ], - "type": "js", - "modulePath": "plugins/twitter/tweets.js", - "sourceFile": "plugins/twitter/tweets.js", - "navigateBefore": "https://x.com" - }, - { - "site": "twitter", - "name": "unblock", - "description": "Unblock a Twitter user", - "access": "write", - "domain": "x.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "username", - "type": "string", - "required": true, - "positional": true, - "help": "Twitter screen name (without @)" - } - ], - "columns": [ - "status", - "message" - ], - "type": "js", - "modulePath": "plugins/twitter/unblock.js", - "sourceFile": "plugins/twitter/unblock.js", - "navigateBefore": true - }, - { - "site": "twitter", - "name": "unbookmark", - "description": "Remove a tweet from bookmarks", - "access": "write", - "domain": "x.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "url", - "type": "string", - "required": true, - "positional": true, - "help": "Tweet URL to unbookmark" - } - ], - "columns": [ - "status", - "message" - ], - "type": "js", - "modulePath": "plugins/twitter/unbookmark.js", - "sourceFile": "plugins/twitter/unbookmark.js", - "navigateBefore": true - }, - { - "site": "twitter", - "name": "unfollow", - "description": "Unfollow a Twitter user", - "access": "write", - "domain": "x.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "username", - "type": "string", - "required": true, - "positional": true, - "help": "Twitter screen name (without @)" - } - ], - "columns": [ - "status", - "message" - ], - "type": "js", - "modulePath": "plugins/twitter/unfollow.js", - "sourceFile": "plugins/twitter/unfollow.js", - "navigateBefore": true - }, - { - "site": "twitter", - "name": "unlike", - "description": "Remove a like from a specific tweet", - "access": "write", - "domain": "x.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "url", - "type": "string", - "required": true, - "positional": true, - "help": "The URL of the tweet to unlike" - } - ], - "columns": [ - "status", - "message" - ], - "type": "js", - "modulePath": "plugins/twitter/unlike.js", - "sourceFile": "plugins/twitter/unlike.js", - "navigateBefore": true - }, - { - "site": "twitter", - "name": "unretweet", - "description": "Undo a retweet on a specific tweet", - "access": "write", - "domain": "x.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "url", - "type": "string", - "required": true, - "positional": true, - "help": "The URL of the tweet to unretweet" - } - ], - "columns": [ - "status", - "message" - ], - "type": "js", - "modulePath": "plugins/twitter/unretweet.js", - "sourceFile": "plugins/twitter/unretweet.js", - "navigateBefore": true - }, - { - "site": "twitter", - "name": "whoami", - "description": "Show the current logged-in twitter account", - "access": "read", - "domain": "x.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "logged_in", - "site", - "username", - "url" - ], - "type": "js", - "modulePath": "plugins/twitter/auth.js", - "sourceFile": "plugins/twitter/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "ualberta", - "name": "export-postgraduate-courses", - "description": "Export University of Alberta postgraduate programs from the official graduate-program catalogue.", - "access": "read", - "example": "webcmd ualberta export-postgraduate-courses --degree-level masters --count 10 -f csv", - "domain": "www.ualberta.ca", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "degree-level", - "type": "string", - "default": "all", - "required": false, - "help": "all, masters, certificate, diploma, professional, or doctorate" - }, - { - "name": "count", - "type": "int", - "required": false, - "help": "Positive maximum number of programs after filtering and deduplication" - } - ], - "columns": [ - "Course Name", - "Course URL", - "University \nname", - "Intake Month", - "Substream/\nSpecialisation", - "App fees", - "Degree Level", - "Study Level", - "Duration\n(in months)", - "Study option", - "Program Type", - "Partner", - "Tution fees \n(per year)", - "Total Tution \nFees", - "IELTS \n(Overall & Subscores)", - "ielts_reading_score", - "ielts_writing_score", - "ielts_listening_score", - "ielts_speaking_score", - "TOEFL\n(Overall & Subscores)", - "toefl_reading_score", - "toefl_writing_score", - "toefl_listening_score", - "toefl_speaking_score", - "PTE\n(Overall & Subscores)", - "pte_reading_score", - "pte_writing_score", - "pte_listening_score", - "pte_speaking_score", - "Duolingo\n(Overall & Subscores)", - "duolingo_comprehension_score", - "duolingo_literacy_score", - "duolingo_conversation_score", - "duolingo_production_score", - "Is Waiver \nProvided?", - "Waiver Info", - "Is MOI \naccepted?", - "Share list, if any", - "GRE Required", - "GMAT Required", - "GRE/GMAT Scores", - "12th scores", - "Min UG score", - "15 years of\nEducation Allowed?", - "Gap Years", - "Backlogs", - "Work \nExperience \nRequired?", - "Main Entry \nRequirements", - "Status", - "Intake status(open/close)\n(eg: Fall (september)-Open\nSpring(January)- Closed)", - "Remarks (if any)", - "Reference Links (if any)" - ], - "type": "js", - "modulePath": "plugins/ualberta/export-postgraduate-courses.js", - "sourceFile": "plugins/ualberta/export-postgraduate-courses.js", - "navigateBefore": false - }, - { - "site": "uiverse", - "name": "code", - "description": "Export Uiverse component code (HTML, CSS, React, or Vue)", - "access": "read", - "domain": "uiverse.io", - "strategy": "public", - "browser": true, - "args": [ - { - "name": "input", - "type": "str", - "required": true, - "positional": true, - "help": "Uiverse URL or author/slug identifier" - }, - { - "name": "target", - "type": "str", - "required": true, - "help": "Code target to export", - "choices": [ - "html", - "css", - "react", - "vue" - ] - } - ], - "columns": [ - "target", - "username", - "slug", - "language", - "length" - ], - "type": "js", - "modulePath": "plugins/uiverse/code.js", - "sourceFile": "plugins/uiverse/code.js", - "navigateBefore": "https://uiverse.io" - }, - { - "site": "uiverse", - "name": "preview", - "description": "Capture a screenshot of the Uiverse preview element", - "access": "read", - "domain": "uiverse.io", - "strategy": "public", - "browser": true, - "args": [ - { - "name": "input", - "type": "str", - "required": true, - "positional": true, - "help": "Uiverse URL or author/slug identifier" - }, - { - "name": "output", - "type": "str", - "required": false, - "help": "Output image path (defaults to a temp file)", - "file": { - "direction": "output", - "pathKind": "file", - "multiple": false, - "defaultPath": "./uiverse-preview.png" - } - }, - { - "name": "padding", - "type": "int", - "default": 8, - "required": false, - "help": "Extra padding around the captured preview in pixels" - } - ], - "columns": [ - "username", - "slug", - "width", - "height", - "output" - ], - "type": "js", - "modulePath": "plugins/uiverse/preview.js", - "sourceFile": "plugins/uiverse/preview.js", - "navigateBefore": "https://uiverse.io" - }, - { - "site": "upwork", - "name": "detail", - "aliases": [ - "job", - "view" - ], - "description": "Read the full Upwork job posting by ciphertext id (e.g. ~022054964136512093518)", - "access": "read", - "domain": "www.upwork.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "id", - "type": "str", - "required": true, - "positional": true, - "help": "Job ciphertext id (~01… / ~02…) or full /jobs/~02… URL" - } - ], - "columns": [ - "id", - "title", - "type", - "budget", - "experienceLevel", - "workload", - "category", - "skills", - "description", - "clientCountry", - "clientSpent", - "clientHires", - "clientRating", - "proposalsCount", - "publishedOn", - "url" - ], - "type": "js", - "modulePath": "plugins/upwork/detail.js", - "sourceFile": "plugins/upwork/detail.js", - "navigateBefore": false - }, - { - "site": "upwork", - "name": "feed", - "aliases": [ - "best-matches" - ], - "description": "Upwork personalized jobs feed (best-matches | most-recent) — requires login", - "access": "read", - "domain": "www.upwork.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "tab", - "type": "str", - "default": "best-matches", - "required": false, - "positional": true, - "help": "Feed tab: best-matches | most-recent" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max rows to return (1-50, capped at one page)" - } - ], - "columns": [ - "rank", - "id", - "title", - "type", - "budget", - "experienceLevel", - "proposalsTier", - "skills", - "clientCountry", - "clientRating", - "publishedOn", - "url" - ], - "type": "js", - "modulePath": "plugins/upwork/feed.js", - "sourceFile": "plugins/upwork/feed.js", - "navigateBefore": false - }, - { - "site": "upwork", - "name": "login", - "description": "Open upwork login", - "access": "write", - "domain": "upwork.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "status", - "logged_in", - "site", - "user_id", - "ciphertext", - "action", - "verify_command" - ], - "type": "js", - "modulePath": "plugins/upwork/auth.js", - "sourceFile": "plugins/upwork/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "upwork", - "name": "search", - "description": "Upwork keyword job search (logged-in browser session, US site)", - "access": "read", - "domain": "www.upwork.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Job keyword (skill / title / company)" - }, - { - "name": "location", - "type": "string", - "default": "", - "required": false, - "help": "Country/city filter (e.g. \"United States\", \"Remote\")" - }, - { - "name": "category", - "type": "string", - "default": "", - "required": false, - "help": "Category uid filter (advanced; from job detail `category` slug)" - }, - { - "name": "sort", - "type": "string", - "default": "recency", - "required": false, - "help": "Sort: recency | relevance | client_total_charge | client_total_reviews" - }, - { - "name": "page", - "type": "int", - "default": 1, - "required": false, - "help": "Page number (1-based)" - }, - { - "name": "per_page", - "type": "int", - "default": 10, - "required": false, - "help": "Rows per page (10-50, capped at one page)" - } - ], - "columns": [ - "rank", - "id", - "title", - "type", - "budget", - "experienceLevel", - "proposalsTier", - "skills", - "clientCountry", - "clientRating", - "publishedOn", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/upwork/search.js", - "sourceFile": "plugins/upwork/search.js", - "navigateBefore": false - }, - { - "site": "upwork", - "name": "whoami", - "description": "Show the current logged-in upwork account", - "access": "read", - "domain": "upwork.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "logged_in", - "site", - "user_id", - "ciphertext" - ], - "type": "js", - "modulePath": "plugins/upwork/auth.js", - "sourceFile": "plugins/upwork/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "wikidata", - "name": "entity", - "description": "Fetch a Wikidata entity by Q/P/L id (label, description, aliases, claim summary)", - "access": "read", - "domain": "www.wikidata.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "id", - "type": "str", - "required": true, - "positional": true, - "help": "Entity id (e.g. Q937 = Albert Einstein, P31 = instance of)" - }, - { - "name": "language", - "type": "str", - "default": "en", - "required": false, - "help": "Display language (ISO 639, falls back to English when missing)" - } - ], - "columns": [ - "qid", - "type", - "label", - "description", - "aliases", - "claimPropertyCount", - "sitelinkCount", - "enwikiTitle", - "modified", - "url" - ], - "type": "js", - "modulePath": "plugins/wikidata/entity.js", - "sourceFile": "plugins/wikidata/entity.js" - }, - { - "site": "wikidata", - "name": "search", - "description": "Search Wikidata items by keyword (returns Q-IDs)", - "access": "read", - "domain": "www.wikidata.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search keyword (label / alias)" - }, - { - "name": "language", - "type": "str", - "default": "en", - "required": false, - "help": "Search & display language (ISO 639, e.g. en, fr, zh)" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max items (1-50)" - } - ], - "columns": [ - "rank", - "qid", - "label", - "description", - "matchType", - "matchText", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/wikidata/search.js", - "sourceFile": "plugins/wikidata/search.js" - }, - { - "site": "wikipedia", - "name": "page", - "description": "Full plain-text extract of a Wikipedia article (optional paragraph cap).", - "access": "read", - "domain": "wikipedia.org", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "title", - "type": "string", - "required": true, - "positional": true, - "help": "Article title (e.g. \"Transformer (machine learning model)\")" - }, - { - "name": "lang", - "type": "string", - "default": "en", - "required": false, - "help": "Language code (en, zh, ja, de, ...)." - }, - { - "name": "paragraphs", - "type": "int", - "default": 0, - "required": false, - "help": "Cap to first N paragraphs (0 = full article)." - } - ], - "columns": [ - "title", - "description", - "pageId", - "paragraphs", - "extract", - "url" - ], - "type": "js", - "modulePath": "plugins/wikipedia/page.js", - "sourceFile": "plugins/wikipedia/page.js" - }, - { - "site": "wikipedia", - "name": "random", - "description": "Get a random Wikipedia article", - "access": "read", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "lang", - "type": "str", - "default": "en", - "required": false, - "help": "Language code (e.g. en, zh, ja)" - } - ], - "columns": [ - "title", - "description", - "extract", - "url" - ], - "type": "js", - "modulePath": "plugins/wikipedia/random.js", - "sourceFile": "plugins/wikipedia/random.js" - }, - { - "site": "wikipedia", - "name": "search", - "description": "Search Wikipedia articles", - "access": "read", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search keyword" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Max results" - }, - { - "name": "lang", - "type": "str", - "default": "en", - "required": false, - "help": "Language code (e.g. en, zh, ja)" - } - ], - "columns": [ - "title", - "snippet", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/wikipedia/search.js", - "sourceFile": "plugins/wikipedia/search.js" - }, - { - "site": "wikipedia", - "name": "summary", - "description": "Get Wikipedia article summary", - "access": "read", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "title", - "type": "str", - "required": true, - "positional": true, - "help": "Article title (e.g. \"Transformer (machine learning model)\")" - }, - { - "name": "lang", - "type": "str", - "default": "en", - "required": false, - "help": "Language code (e.g. en, zh, ja)" - } - ], - "columns": [ - "title", - "description", - "extract", - "url" - ], - "type": "js", - "modulePath": "plugins/wikipedia/summary.js", - "sourceFile": "plugins/wikipedia/summary.js" - }, - { - "site": "wikipedia", - "name": "trending", - "description": "Most-read Wikipedia articles (yesterday)", - "access": "read", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Max results" - }, - { - "name": "lang", - "type": "str", - "default": "en", - "required": false, - "help": "Language code (e.g. en, zh, ja)" - } - ], - "columns": [ - "rank", - "title", - "description", - "views" - ], - "type": "js", - "modulePath": "plugins/wikipedia/trending.js", - "sourceFile": "plugins/wikipedia/trending.js" - }, - { - "site": "wttr", - "name": "current", - "description": "Current weather conditions for a location (city, lat,lon, or airport code)", - "access": "read", - "domain": "wttr.in", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "location", - "type": "str", - "required": true, - "positional": true, - "help": "City name, \"lat,lon\", airport ICAO code, or \"@domain\"" - } - ], - "columns": [ - "location", - "region", - "country", - "latitude", - "longitude", - "observedAt", - "tempC", - "tempF", - "feelsLikeC", - "feelsLikeF", - "description", - "humidity", - "cloudCover", - "pressure", - "precipMm", - "visibilityKm", - "uvIndex", - "windKmph", - "windDirection", - "windDirectionDegree" - ], - "type": "js", - "modulePath": "plugins/wttr/current.js", - "sourceFile": "plugins/wttr/current.js" - }, - { - "site": "wttr", - "name": "forecast", - "description": "Multi-day weather forecast (up to 3 days, wttr.in free tier max)", - "access": "read", - "domain": "wttr.in", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "location", - "type": "str", - "required": true, - "positional": true, - "help": "City name, \"lat,lon\", airport ICAO code, or \"@domain\"" - }, - { - "name": "days", - "type": "int", - "default": 3, - "required": false, - "help": "Max forecast days (1-3, wttr.in caps the response at 3 days)" - } - ], - "columns": [ - "rank", - "date", - "minTempC", - "maxTempC", - "avgTempC", - "minTempF", - "maxTempF", - "avgTempF", - "sunHour", - "totalSnowCm", - "uvIndex", - "description", - "sunrise", - "sunset" - ], - "type": "js", - "modulePath": "plugins/wttr/forecast.js", - "sourceFile": "plugins/wttr/forecast.js" - }, - { - "site": "yahoo", - "name": "search", - "description": "Search Yahoo (powered by Bing)", - "access": "read", - "domain": "search.yahoo.com", - "strategy": "public", - "browser": true, - "args": [ - { - "name": "keyword", - "type": "str", - "required": true, - "positional": true, - "help": "Search query" - }, - { - "name": "limit", - "type": "int", - "default": 7, - "required": false, - "help": "Number of results per page (max 7)" - }, - { - "name": "page", - "type": "int", - "default": 1, - "required": false, - "help": "Page number (1, 2, 3...). Yahoo returns ~7 results per page" - } - ], - "columns": [ - "rank", - "title", - "url", - "snippet" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/yahoo/search.js", - "sourceFile": "plugins/yahoo/search.js" - }, - { - "site": "yahoo-finance", - "name": "quote", - "description": "Yahoo Finance stock quote", - "access": "read", - "domain": "finance.yahoo.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "symbol", - "type": "str", - "required": true, - "positional": true, - "help": "Stock ticker (e.g. AAPL, MSFT, TSLA)" - } - ], - "columns": [ - "symbol", - "name", - "price", - "change", - "changePercent", - "open", - "high", - "low", - "volume", - "marketCap" - ], - "type": "js", - "modulePath": "plugins/yahoo-finance/quote.js", - "sourceFile": "plugins/yahoo-finance/quote.js", - "navigateBefore": "https://finance.yahoo.com" - }, - { - "site": "yale", - "name": "export-postgraduate-courses", - "description": "Export Yale University postgraduate and professional programs from official Yale sources.", - "access": "read", - "example": "webcmd yale export-postgraduate-courses --degree-level masters --count 10 -f csv", - "domain": "yale.edu", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "degree-level", - "type": "string", - "default": "all", - "required": false, - "help": "all, masters, certificate, diploma, professional, or doctorate" - }, - { - "name": "count", - "type": "int", - "required": false, - "help": "Positive maximum number of programs after filtering and deduplication" - } - ], - "columns": [ - "Course Name", - "Course URL", - "University \nname", - "Intake Month", - "Substream/\nSpecialisation", - "App fees", - "Degree Level", - "Study Level", - "Duration\n(in months)", - "Study option", - "Program Type", - "Partner", - "Tution fees \n(per year)", - "Total Tution \nFees", - "IELTS \n(Overall & Subscores)", - "ielts_reading_score", - "ielts_writing_score", - "ielts_listening_score", - "ielts_speaking_score", - "TOEFL\n(Overall & Subscores)", - "toefl_reading_score", - "toefl_writing_score", - "toefl_listening_score", - "toefl_speaking_score", - "PTE\n(Overall & Subscores)", - "pte_reading_score", - "pte_writing_score", - "pte_listening_score", - "pte_speaking_score", - "Duolingo\n(Overall & Subscores)", - "duolingo_comprehension_score", - "duolingo_literacy_score", - "duolingo_conversation_score", - "duolingo_production_score", - "Is Waiver \nProvided?", - "Waiver Info", - "Is MOI \naccepted?", - "Share list, if any", - "GRE Required", - "GMAT Required", - "GRE/GMAT Scores", - "12th scores", - "Min UG score", - "15 years of\nEducation Allowed?", - "Gap Years", - "Backlogs", - "Work \nExperience \nRequired?", - "Main Entry \nRequirements", - "Status", - "Intake status(open/close)\n(eg: Fall (september)-Open\nSpring(January)- Closed)", - "Remarks (if any)", - "Reference Links (if any)" - ], - "type": "js", - "modulePath": "plugins/yale/export-postgraduate-courses.js", - "sourceFile": "plugins/yale/export-postgraduate-courses.js" - }, - { - "site": "ycombinator", - "name": "companies", - "description": "Search the public Y Combinator startup directory", - "access": "read", - "domain": "www.ycombinator.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "query", - "type": "str", - "required": false, - "positional": true, - "help": "Company name, product, or keyword such as AI" - }, - { - "name": "batch", - "type": "str", - "required": false, - "help": "Exact YC batch, for example Spring 2026" - }, - { - "name": "industry", - "type": "str", - "required": false, - "help": "Exact YC industry, for example B2B" - }, - { - "name": "recent", - "type": "boolean", - "default": false, - "required": false, - "help": "Sort matches by launch date, newest first" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Maximum companies to return (1-40)" - } - ], - "columns": [ - "rank", - "name", - "batch", - "location", - "description", - "industries", - "url" - ], - "type": "js", - "modulePath": "plugins/ycombinator/companies.js", - "sourceFile": "plugins/ycombinator/companies.js", - "navigateBefore": false - }, - { - "site": "ycombinator", - "name": "company", - "description": "Read a public Y Combinator company profile", - "access": "read", - "domain": "www.ycombinator.com", - "strategy": "ui", - "browser": true, - "args": [ - { - "name": "company", - "type": "str", - "required": true, - "positional": true, - "help": "YC company slug or full company URL" - } - ], - "columns": [ - "name", - "description", - "batch", - "status", - "location", - "founded", - "teamSize", - "website", - "founders", - "jobCount", - "url" - ], - "type": "js", - "modulePath": "plugins/ycombinator/company.js", - "sourceFile": "plugins/ycombinator/company.js", - "navigateBefore": false - }, - { - "site": "yollomi", - "name": "background", - "description": "Generate AI background for a product/object image (5 credits)", - "access": "write", - "domain": "yollomi.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "image", - "type": "str", - "required": true, - "positional": true, - "help": "Image URL (upload via \"webcmd yollomi upload\" first)" - }, - { - "name": "prompt", - "type": "str", - "default": "", - "required": false, - "help": "Background description (optional)" - }, - { - "name": "output", - "type": "str", - "default": "./yollomi-output", - "required": false, - "help": "Output directory", - "file": { - "direction": "output", - "pathKind": "directory", - "multiple": false - } - }, - { - "name": "no-download", - "type": "boolean", - "default": false, - "required": false, - "help": "Only show URL" - } - ], - "columns": [ - "status", - "file", - "size", - "url" - ], - "type": "js", - "modulePath": "plugins/yollomi/background.js", - "sourceFile": "plugins/yollomi/background.js", - "navigateBefore": "https://yollomi.com" - }, - { - "site": "yollomi", - "name": "edit", - "description": "Edit images with AI text prompts (Qwen image edit)", - "access": "write", - "domain": "yollomi.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "image", - "type": "str", - "required": true, - "positional": true, - "help": "Input image URL (upload via \"webcmd yollomi upload\" first)" - }, - { - "name": "prompt", - "type": "str", - "required": true, - "positional": true, - "help": "Editing instruction (e.g. \"Make it look vintage\")" - }, - { - "name": "model", - "type": "str", - "default": "qwen-image-edit", - "required": false, - "help": "Edit model", - "choices": [ - "qwen-image-edit", - "qwen-image-edit-plus" - ] - }, - { - "name": "output", - "type": "str", - "default": "./yollomi-output", - "required": false, - "help": "Output directory", - "file": { - "direction": "output", - "pathKind": "directory", - "multiple": false - } - }, - { - "name": "no-download", - "type": "boolean", - "default": false, - "required": false, - "help": "Only show URL" - } - ], - "columns": [ - "status", - "file", - "size", - "credits", - "url" - ], - "type": "js", - "modulePath": "plugins/yollomi/edit.js", - "sourceFile": "plugins/yollomi/edit.js", - "navigateBefore": "https://yollomi.com" - }, - { - "site": "yollomi", - "name": "face-swap", - "description": "Swap faces between two photos (3 credits)", - "access": "write", - "domain": "yollomi.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "source", - "type": "str", - "required": true, - "help": "Source face image URL" - }, - { - "name": "target", - "type": "str", - "required": true, - "help": "Target photo URL" - }, - { - "name": "output", - "type": "str", - "default": "./yollomi-output", - "required": false, - "help": "Output directory", - "file": { - "direction": "output", - "pathKind": "directory", - "multiple": false - } - }, - { - "name": "no-download", - "type": "boolean", - "default": false, - "required": false, - "help": "Only show URL" - } - ], - "columns": [ - "status", - "file", - "size", - "url" - ], - "type": "js", - "modulePath": "plugins/yollomi/face-swap.js", - "sourceFile": "plugins/yollomi/face-swap.js", - "navigateBefore": "https://yollomi.com" - }, - { - "site": "yollomi", - "name": "generate", - "description": "Generate images with AI (text-to-image or image-to-image)", - "access": "write", - "domain": "yollomi.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "prompt", - "type": "str", - "required": true, - "positional": true, - "help": "Text prompt describing the image" - }, - { - "name": "model", - "type": "str", - "default": "z-image-turbo", - "required": false, - "help": "Model ID (z-image-turbo, flux-schnell, nano-banana, flux-2-pro, ...)" - }, - { - "name": "ratio", - "type": "str", - "default": "1:1", - "required": false, - "help": "Aspect ratio", - "choices": [ - "1:1", - "16:9", - "9:16", - "4:3", - "3:4" - ] - }, - { - "name": "image", - "type": "str", - "required": false, - "help": "Input image URL for image-to-image (upload via \"webcmd yollomi upload\" first)" - }, - { - "name": "output", - "type": "str", - "default": "./yollomi-output", - "required": false, - "help": "Output directory", - "file": { - "direction": "output", - "pathKind": "directory", - "multiple": false - } - }, - { - "name": "no-download", - "type": "boolean", - "default": false, - "required": false, - "help": "Only show URLs, skip download" - } - ], - "columns": [ - "index", - "status", - "file", - "size", - "url" - ], - "type": "js", - "modulePath": "plugins/yollomi/generate.js", - "sourceFile": "plugins/yollomi/generate.js", - "navigateBefore": "https://yollomi.com" - }, - { - "site": "yollomi", - "name": "models", - "description": "List available Yollomi AI models (image, video, tools)", - "access": "read", - "strategy": "public", - "browser": false, - "args": [ - { - "name": "type", - "type": "str", - "default": "all", - "required": false, - "help": "Filter by model type", - "choices": [ - "all", - "image", - "video", - "tool" - ] - } - ], - "columns": [ - "type", - "model", - "credits", - "description" - ], - "type": "js", - "modulePath": "plugins/yollomi/models.js", - "sourceFile": "plugins/yollomi/models.js" - }, - { - "site": "yollomi", - "name": "object-remover", - "description": "Remove unwanted objects from images (3 credits)", - "access": "write", - "domain": "yollomi.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "image", - "type": "str", - "required": true, - "positional": true, - "help": "Image URL" - }, - { - "name": "mask", - "type": "str", - "required": true, - "positional": true, - "help": "Mask image URL (white = area to remove)" - }, - { - "name": "output", - "type": "str", - "default": "./yollomi-output", - "required": false, - "help": "Output directory", - "file": { - "direction": "output", - "pathKind": "directory", - "multiple": false - } - }, - { - "name": "no-download", - "type": "boolean", - "default": false, - "required": false, - "help": "Only show URL" - } - ], - "columns": [ - "status", - "file", - "size", - "url" - ], - "type": "js", - "modulePath": "plugins/yollomi/object-remover.js", - "sourceFile": "plugins/yollomi/object-remover.js", - "navigateBefore": "https://yollomi.com" - }, - { - "site": "yollomi", - "name": "remove-bg", - "description": "Remove image background with AI (free)", - "access": "write", - "domain": "yollomi.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "image", - "type": "str", - "required": true, - "positional": true, - "help": "Image URL to remove background from" - }, - { - "name": "output", - "type": "str", - "default": "./yollomi-output", - "required": false, - "help": "Output directory", - "file": { - "direction": "output", - "pathKind": "directory", - "multiple": false - } - }, - { - "name": "no-download", - "type": "boolean", - "default": false, - "required": false, - "help": "Only show URL" - } - ], - "columns": [ - "status", - "file", - "size", - "url" - ], - "type": "js", - "modulePath": "plugins/yollomi/remove-bg.js", - "sourceFile": "plugins/yollomi/remove-bg.js", - "navigateBefore": "https://yollomi.com" - }, - { - "site": "yollomi", - "name": "restore", - "description": "Restore old or damaged photos with AI (4 credits)", - "access": "write", - "domain": "yollomi.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "image", - "type": "str", - "required": true, - "positional": true, - "help": "Image URL to restore" - }, - { - "name": "output", - "type": "str", - "default": "./yollomi-output", - "required": false, - "help": "Output directory", - "file": { - "direction": "output", - "pathKind": "directory", - "multiple": false - } - }, - { - "name": "no-download", - "type": "boolean", - "default": false, - "required": false, - "help": "Only show URL" - } - ], - "columns": [ - "status", - "file", - "size", - "url" - ], - "type": "js", - "modulePath": "plugins/yollomi/restore.js", - "sourceFile": "plugins/yollomi/restore.js", - "navigateBefore": "https://yollomi.com" - }, - { - "site": "yollomi", - "name": "try-on", - "description": "Virtual try-on — see how clothes look on a person (3 credits)", - "access": "write", - "domain": "yollomi.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "person", - "type": "str", - "required": true, - "help": "Person photo URL (upload via \"webcmd yollomi upload\" first)" - }, - { - "name": "cloth", - "type": "str", - "required": true, - "help": "Clothing image URL" - }, - { - "name": "cloth-type", - "type": "str", - "default": "upper", - "required": false, - "help": "Clothing type", - "choices": [ - "upper", - "lower", - "overall" - ] - }, - { - "name": "output", - "type": "str", - "default": "./yollomi-output", - "required": false, - "help": "Output directory", - "file": { - "direction": "output", - "pathKind": "directory", - "multiple": false - } - }, - { - "name": "no-download", - "type": "boolean", - "default": false, - "required": false, - "help": "Only show URL" - } - ], - "columns": [ - "status", - "file", - "size", - "url" - ], - "type": "js", - "modulePath": "plugins/yollomi/try-on.js", - "sourceFile": "plugins/yollomi/try-on.js", - "navigateBefore": "https://yollomi.com" - }, - { - "site": "yollomi", - "name": "upload", - "description": "Upload an image or video to Yollomi (returns URL for other commands)", - "access": "write", - "domain": "yollomi.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "file", - "type": "str", - "required": true, - "positional": true, - "help": "Local file path to upload", - "file": { - "direction": "input", - "pathKind": "file", - "multiple": false - } - } - ], - "columns": [ - "status", - "file", - "size", - "url" - ], - "type": "js", - "modulePath": "plugins/yollomi/upload.js", - "sourceFile": "plugins/yollomi/upload.js", - "navigateBefore": "https://yollomi.com" - }, - { - "site": "yollomi", - "name": "upscale", - "description": "Upscale image resolution with AI (1 credit)", - "access": "write", - "domain": "yollomi.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "image", - "type": "str", - "required": true, - "positional": true, - "help": "Image URL to upscale" - }, - { - "name": "scale", - "type": "str", - "default": "2", - "required": false, - "help": "Upscale factor (2 or 4)", - "choices": [ - "2", - "4" - ] - }, - { - "name": "output", - "type": "str", - "default": "./yollomi-output", - "required": false, - "help": "Output directory", - "file": { - "direction": "output", - "pathKind": "directory", - "multiple": false - } - }, - { - "name": "no-download", - "type": "boolean", - "default": false, - "required": false, - "help": "Only show URL" - } - ], - "columns": [ - "status", - "file", - "size", - "scale", - "url" - ], - "type": "js", - "modulePath": "plugins/yollomi/upscale.js", - "sourceFile": "plugins/yollomi/upscale.js", - "navigateBefore": "https://yollomi.com" - }, - { - "site": "yollomi", - "name": "video", - "description": "Generate videos with AI (text-to-video or image-to-video)", - "access": "write", - "domain": "yollomi.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "prompt", - "type": "str", - "required": true, - "positional": true, - "help": "Text prompt describing the video" - }, - { - "name": "model", - "type": "str", - "default": "kling-2-1", - "required": false, - "help": "Model (kling-2-1, openai-sora-2, google-veo-3-1, wan-2-5-t2v, ...)" - }, - { - "name": "image", - "type": "str", - "required": false, - "help": "Input image URL for image-to-video" - }, - { - "name": "ratio", - "type": "str", - "default": "16:9", - "required": false, - "help": "Aspect ratio", - "choices": [ - "1:1", - "16:9", - "9:16", - "4:3", - "3:4" - ] - }, - { - "name": "output", - "type": "str", - "default": "./yollomi-output", - "required": false, - "help": "Output directory", - "file": { - "direction": "output", - "pathKind": "directory", - "multiple": false - } - }, - { - "name": "no-download", - "type": "boolean", - "default": false, - "required": false, - "help": "Only show URL, skip download" - } - ], - "columns": [ - "status", - "file", - "size", - "credits", - "url" - ], - "type": "js", - "modulePath": "plugins/yollomi/video.js", - "sourceFile": "plugins/yollomi/video.js", - "navigateBefore": "https://yollomi.com" - }, - { - "site": "youtube", - "name": "channel", - "description": "Get YouTube channel info and recent videos", - "access": "read", - "domain": "www.youtube.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "id", - "type": "str", - "required": true, - "positional": true, - "help": "Channel ID (UCxxxx) or handle (@name)" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Max recent videos (max 30)" - } - ], - "columns": [ - "field", - "value" - ], - "type": "js", - "modulePath": "plugins/youtube/channel.js", - "sourceFile": "plugins/youtube/channel.js", - "navigateBefore": "https://www.youtube.com" - }, - { - "site": "youtube", - "name": "comments", - "description": "Get YouTube video comments", - "access": "read", - "domain": "www.youtube.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "url", - "type": "str", - "required": true, - "positional": true, - "help": "YouTube video URL or video ID" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max comments (max 100)" - } - ], - "columns": [ - "rank", - "author", - "text", - "likes", - "replies", - "time" - ], - "type": "js", - "modulePath": "plugins/youtube/comments.js", - "sourceFile": "plugins/youtube/comments.js", - "navigateBefore": "https://www.youtube.com" - }, - { - "site": "youtube", - "name": "feed", - "description": "Get YouTube homepage recommended videos", - "access": "read", - "domain": "www.youtube.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max videos to return (default 20, max 100)" - } - ], - "columns": [ - "rank", - "title", - "channel", - "video_id", - "views", - "duration", - "published", - "url" - ], - "type": "js", - "modulePath": "plugins/youtube/feed.js", - "sourceFile": "plugins/youtube/feed.js", - "navigateBefore": "https://www.youtube.com" - }, - { - "site": "youtube", - "name": "frames", - "description": "Capture timestamped PNG frames from a YouTube video", - "access": "read", - "domain": "www.youtube.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "url", - "type": "str", - "required": true, - "positional": true, - "help": "YouTube watch URL, Shorts URL, or video ID" - }, - { - "name": "timestamps", - "type": "str", - "required": false, - "help": "Comma-separated exact timestamps in seconds" - }, - { - "name": "count", - "type": "int", - "required": false, - "help": "Automatically distribute 1-20 frames; default 5" - }, - { - "name": "output", - "type": "str", - "required": false, - "help": "Output directory", - "file": { - "direction": "output", - "pathKind": "directory", - "multiple": false, - "defaultPath": "./youtube-frames" - } - } - ], - "columns": [ - "video_id", - "duration_seconds", - "timestamp_seconds", - "actual_timestamp_seconds", - "path", - "status", - "error", - "requested_count", - "selected_count", - "captured_count", - "failed_count" - ], - "type": "js", - "modulePath": "plugins/youtube/frames.js", - "sourceFile": "plugins/youtube/frames.js", - "navigateBefore": false, - "siteSession": "ephemeral" - }, - { - "site": "youtube", - "name": "history", - "description": "Get YouTube watch history", - "access": "read", - "domain": "www.youtube.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 30, - "required": false, - "help": "Max videos to return (default 30, max 200)" - } - ], - "columns": [ - "rank", - "title", - "channel", - "views", - "duration", - "url" - ], - "type": "js", - "modulePath": "plugins/youtube/history.js", - "sourceFile": "plugins/youtube/history.js", - "navigateBefore": "https://www.youtube.com" - }, - { - "site": "youtube", - "name": "like", - "description": "Like a YouTube video", - "access": "write", - "domain": "www.youtube.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "url", - "type": "str", - "required": true, - "positional": true, - "help": "YouTube video URL or video ID" - } - ], - "columns": [ - "status", - "message" - ], - "type": "js", - "modulePath": "plugins/youtube/like.js", - "sourceFile": "plugins/youtube/like.js", - "navigateBefore": "https://www.youtube.com" - }, - { - "site": "youtube", - "name": "login", - "description": "Open youtube login", - "access": "write", - "domain": "www.youtube.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "status", - "logged_in", - "site", - "name", - "action", - "verify_command" - ], - "type": "js", - "modulePath": "plugins/youtube/auth.js", - "sourceFile": "plugins/youtube/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "youtube", - "name": "playlist", - "description": "Get YouTube playlist info and video list", - "access": "read", - "domain": "www.youtube.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "id", - "type": "str", - "required": true, - "positional": true, - "help": "Playlist URL or playlist ID (PLxxxxxx)" - }, - { - "name": "limit", - "type": "int", - "default": 50, - "required": false, - "help": "Max videos to return (default 50, max 200)" - } - ], - "columns": [ - "rank", - "title", - "channel", - "duration", - "views", - "published", - "url" - ], - "type": "js", - "modulePath": "plugins/youtube/playlist.js", - "sourceFile": "plugins/youtube/playlist.js", - "navigateBefore": "https://www.youtube.com" - }, - { - "site": "youtube", - "name": "search", - "description": "Search YouTube videos", - "access": "read", - "domain": "www.youtube.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search query" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Max results (max 50)" - }, - { - "name": "type", - "type": "str", - "default": "", - "required": false, - "help": "Filter type: shorts, video, channel, playlist" - }, - { - "name": "upload", - "type": "str", - "default": "", - "required": false, - "help": "Upload date: hour, today, week, month, year" - }, - { - "name": "sort", - "type": "str", - "default": "", - "required": false, - "help": "Sort by: relevance, date, views, rating" - } - ], - "columns": [ - "rank", - "title", - "channel", - "views", - "duration", - "published", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/youtube/search.js", - "sourceFile": "plugins/youtube/search.js", - "navigateBefore": "https://www.youtube.com" - }, - { - "site": "youtube", - "name": "subscribe", - "description": "Subscribe to a YouTube channel", - "access": "write", - "domain": "www.youtube.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "channel", - "type": "str", - "required": true, - "positional": true, - "help": "Channel ID (UCxxxx) or handle (@name)" - } - ], - "columns": [ - "status", - "message" - ], - "type": "js", - "modulePath": "plugins/youtube/subscribe.js", - "sourceFile": "plugins/youtube/subscribe.js", - "navigateBefore": "https://www.youtube.com" - }, - { - "site": "youtube", - "name": "subscriptions", - "description": "List subscribed YouTube channels", - "access": "read", - "domain": "www.youtube.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 50, - "required": false, - "help": "Max channels to return (default 50)" - } - ], - "columns": [ - "rank", - "name", - "handle", - "subscribers", - "url" - ], - "type": "js", - "modulePath": "plugins/youtube/subscriptions.js", - "sourceFile": "plugins/youtube/subscriptions.js", - "navigateBefore": "https://www.youtube.com" - }, - { - "site": "youtube", - "name": "transcript", - "description": "Get YouTube video transcript/subtitles", - "access": "read", - "domain": "www.youtube.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "url", - "type": "str", - "required": true, - "positional": true, - "help": "YouTube video URL or video ID" - }, - { - "name": "lang", - "type": "str", - "required": false, - "help": "Language code (e.g. en, zh-Hans). Omit to auto-select" - }, - { - "name": "mode", - "type": "str", - "default": "grouped", - "required": false, - "help": "Output mode: grouped (readable paragraphs) or raw (every segment)" - } - ], - "type": "js", - "modulePath": "plugins/youtube/transcript.js", - "sourceFile": "plugins/youtube/transcript.js", - "navigateBefore": "https://www.youtube.com" - }, - { - "site": "youtube", - "name": "unlike", - "description": "Remove like from a YouTube video", - "access": "write", - "domain": "www.youtube.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "url", - "type": "str", - "required": true, - "positional": true, - "help": "YouTube video URL or video ID" - } - ], - "columns": [ - "status", - "message" - ], - "type": "js", - "modulePath": "plugins/youtube/unlike.js", - "sourceFile": "plugins/youtube/unlike.js", - "navigateBefore": "https://www.youtube.com" - }, - { - "site": "youtube", - "name": "unsubscribe", - "description": "Unsubscribe from a YouTube channel", - "access": "write", - "domain": "www.youtube.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "channel", - "type": "str", - "required": true, - "positional": true, - "help": "Channel ID (UCxxxx) or handle (@name)" - } - ], - "columns": [ - "status", - "message" - ], - "type": "js", - "modulePath": "plugins/youtube/unsubscribe.js", - "sourceFile": "plugins/youtube/unsubscribe.js", - "navigateBefore": "https://www.youtube.com" - }, - { - "site": "youtube", - "name": "video", - "description": "Get YouTube video metadata (title, views, description, etc.)", - "access": "read", - "domain": "www.youtube.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "url", - "type": "str", - "required": true, - "positional": true, - "help": "YouTube video URL or video ID" - } - ], - "columns": [ - "field", - "value" - ], - "type": "js", - "modulePath": "plugins/youtube/video.js", - "sourceFile": "plugins/youtube/video.js", - "navigateBefore": "https://www.youtube.com" - }, - { - "site": "youtube", - "name": "watch-later", - "description": "Get your YouTube Watch Later queue", - "access": "read", - "domain": "www.youtube.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "limit", - "type": "int", - "default": 50, - "required": false, - "help": "Max videos to return (default 50, max 200)" - } - ], - "columns": [ - "rank", - "title", - "channel", - "duration", - "views", - "published", - "url" - ], - "type": "js", - "modulePath": "plugins/youtube/watch-later.js", - "sourceFile": "plugins/youtube/watch-later.js", - "navigateBefore": "https://www.youtube.com" - }, - { - "site": "youtube", - "name": "whoami", - "description": "Show the current logged-in youtube account", - "access": "read", - "domain": "www.youtube.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "logged_in", - "site", - "name" - ], - "type": "js", - "modulePath": "plugins/youtube/auth.js", - "sourceFile": "plugins/youtube/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "zepto", - "name": "add-to-cart", - "description": "Add a Zepto product to cart", - "access": "write", - "domain": "www.zepto.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "product", - "type": "str", - "required": true, - "positional": true, - "help": "Product URL from Zepto search results" - }, - { - "name": "quantity", - "type": "int", - "default": 1, - "required": false, - "help": "Quantity to add (max 12)" - } - ], - "columns": [ - "ok", - "product_id", - "quantity", - "item_count", - "message" - ], - "type": "js", - "modulePath": "plugins/zepto/add-to-cart.js", - "sourceFile": "plugins/zepto/add-to-cart.js", - "navigateBefore": false - }, - { - "site": "zepto", - "name": "cart", - "description": "Read Zepto cart line items", - "access": "read", - "domain": "www.zepto.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "rank", - "product_id", - "title", - "pack_size", - "quantity", - "price", - "mrp", - "availability" - ], - "type": "js", - "modulePath": "plugins/zepto/cart.js", - "sourceFile": "plugins/zepto/cart.js", - "navigateBefore": false - }, - { - "site": "zepto", - "name": "checkout", - "description": "Open Zepto checkout review without placing an order", - "access": "write", - "domain": "www.zepto.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "ok", - "stage", - "item_count", - "next_action", - "url" - ], - "type": "js", - "modulePath": "plugins/zepto/checkout.js", - "sourceFile": "plugins/zepto/checkout.js", - "navigateBefore": false - }, - { - "site": "zepto", - "name": "location", - "description": "Show the selected Zepto delivery location", - "access": "read", - "domain": "www.zepto.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "selected", - "label", - "area", - "city", - "pincode", - "hasCoordinates", - "source" - ], - "type": "js", - "modulePath": "plugins/zepto/location.js", - "sourceFile": "plugins/zepto/location.js", - "navigateBefore": false - }, - { - "site": "zepto", - "name": "login", - "description": "Open zepto login", - "access": "write", - "domain": "www.zepto.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "status", - "logged_in", - "site", - "action", - "verify_command" - ], - "type": "js", - "modulePath": "plugins/zepto/auth.js", - "sourceFile": "plugins/zepto/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "zepto", - "name": "place-order", - "description": "Submit a real Zepto order only when --confirm true is passed", - "access": "write", - "domain": "www.zepto.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "confirm", - "type": "boolean", - "default": false, - "required": false, - "help": "Required. Set true to submit a real Zepto order/payment action." - } - ], - "columns": [ - "status", - "confirmed", - "message" - ], - "type": "js", - "modulePath": "plugins/zepto/place-order.js", - "sourceFile": "plugins/zepto/place-order.js", - "navigateBefore": false - }, - { - "site": "zepto", - "name": "product", - "description": "Read Zepto product details", - "access": "read", - "domain": "www.zepto.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "product", - "type": "str", - "required": true, - "positional": true, - "help": "Product URL from Zepto search results" - } - ], - "columns": [ - "product_id", - "title", - "brand", - "pack_size", - "price", - "mrp", - "availability", - "url" - ], - "type": "js", - "modulePath": "plugins/zepto/product.js", - "sourceFile": "plugins/zepto/product.js", - "navigateBefore": false - }, - { - "site": "zepto", - "name": "search", - "description": "Search Zepto products", - "access": "read", - "domain": "www.zepto.com", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search query" - }, - { - "name": "limit", - "type": "int", - "default": 20, - "required": false, - "help": "Maximum products to return (max 50)" - } - ], - "columns": [ - "rank", - "product_id", - "title", - "brand", - "pack_size", - "price", - "mrp", - "availability", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/zepto/search.js", - "sourceFile": "plugins/zepto/search.js", - "navigateBefore": false - }, - { - "site": "zepto", - "name": "whoami", - "description": "Show the current logged-in zepto account", - "access": "read", - "domain": "www.zepto.com", - "strategy": "cookie", - "browser": true, - "args": [], - "columns": [ - "logged_in", - "site" - ], - "type": "js", - "modulePath": "plugins/zepto/auth.js", - "sourceFile": "plugins/zepto/auth.js", - "navigateBefore": false, - "siteSession": "persistent" - }, - { - "site": "zlibrary", - "name": "info", - "description": "Get book details and available download formats from a Z-Library book page", - "access": "read", - "domain": "z-library.im", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "url", - "type": "str", - "required": true, - "positional": true, - "help": "Z-Library book page URL (e.g. https://z-library.im/book/...)" - } - ], - "columns": [ - "title", - "pdf", - "epub", - "url" - ], - "type": "js", - "modulePath": "plugins/zlibrary/info.js", - "sourceFile": "plugins/zlibrary/info.js", - "navigateBefore": false - }, - { - "site": "zlibrary", - "name": "search", - "description": "Search Z-Library for books by title, author, ISBN, or keyword", - "access": "read", - "domain": "z-library.im", - "strategy": "cookie", - "browser": true, - "args": [ - { - "name": "query", - "type": "str", - "required": true, - "positional": true, - "help": "Search keyword (title, author, ISBN, etc.)" - }, - { - "name": "limit", - "type": "int", - "default": 10, - "required": false, - "help": "Max results (1–25)" - } - ], - "columns": [ - "rank", - "title", - "author", - "url" - ], - "tags": [ - "search" - ], - "type": "js", - "modulePath": "plugins/zlibrary/search.js", - "sourceFile": "plugins/zlibrary/search.js", - "navigateBefore": false - } -] diff --git a/plugins/amazon-in/README.md b/plugins/amazon-in/README.md deleted file mode 100644 index 3cf3f1de..00000000 --- a/plugins/amazon-in/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# webcmd-plugin-amazon-in - -Webcmd commands for amazon-in. - -## Install - -```bash -webcmd plugin install github:agentrhq/webcmd/amazon-in -``` - -## Commands - -| Command | Description | -| --- | --- | -| `webcmd amazon-in checkout` | Prepare a guarded Amazon.in checkout with browser-only payment handoff | -| `webcmd amazon-in checkout-status` | Read the current Amazon.in checkout or payment state without clicking | -| `webcmd amazon-in cart-add` | Add one confirmed product variant to the authenticated cart | -| `webcmd amazon-in cart` | Read the authenticated cart | -| `webcmd amazon-in login` | Open amazon-in login | -| `webcmd amazon-in product` | Fetch the current Amazon.in price and selected product variant | -| `webcmd amazon-in search` | Search Amazon.in products with inclusive INR price bounds and images | -| `webcmd amazon-in whoami` | Show the current logged-in amazon-in account | -| `webcmd amazon-in wishlist` | Fetch current prices for products in the default Amazon.in wishlist | diff --git a/plugins/amazon-in/auth.js b/plugins/amazon-in/auth.js deleted file mode 100644 index 7640fc87..00000000 --- a/plugins/amazon-in/auth.js +++ /dev/null @@ -1,53 +0,0 @@ -import { AuthRequiredError, CommandExecutionError } from '@agentrhq/webcmd/errors'; -import { registerSiteAuthCommands } from '@agentrhq/webcmd/plugin-runtime'; -import { hasAmazonInAuthCookie } from './parsers.js'; -import { DOMAIN, HOME_URL, SITE } from './shared.js'; - -async function hasAmazonInSessionCookies(page) { - const cookies = await page.getCookies({ url: HOME_URL }); - return hasAmazonInAuthCookie(cookies.map((cookie) => cookie.name)); -} - -async function verifyAmazonInIdentity(page) { - if (!await hasAmazonInSessionCookies(page)) { - throw new AuthRequiredError(DOMAIN, 'Amazon.in authentication cookies are missing'); - } - await page.goto(HOME_URL, { waitUntil: 'load' }); - await page.wait(2); - const identity = await page.evaluate(` - (() => { - const greeting = ( - document.querySelector('#nav-link-accountList-nav-line-1')?.textContent || '' - ).trim(); - if (/sign\\s*in/i.test(greeting)) return { kind: 'auth' }; - const match = greeting.match(/^Hello,?\\s+(.+)$/i); - return match ? { user_name: match[1].trim() } : null; - })() - `); - if (identity?.kind === 'auth') throw new AuthRequiredError(DOMAIN); - if (!identity?.user_name) { - throw new CommandExecutionError( - 'Amazon.in account greeting could not be read', - 'Open Amazon.in in the Webcmd browser and check for a robot challenge or layout change.', - ); - } - return identity; -} - -registerSiteAuthCommands({ - site: SITE, - domain: DOMAIN, - loginUrl: HOME_URL, - columns: ['user_name'], - quickCheck: hasAmazonInSessionCookies, - verify: verifyAmazonInIdentity, - openLogin: async (page) => { - await page.goto(HOME_URL, { waitUntil: 'load' }); - await page.wait(1); - const loginUrl = await page.evaluate(` - (() => document.querySelector('a[href*="/ap/signin"]')?.href || '')() - `); - if (!loginUrl) throw new CommandExecutionError('Amazon.in login link could not be found'); - await page.goto(loginUrl, { waitUntil: 'load' }); - }, -}); diff --git a/plugins/amazon-in/cart-add.js b/plugins/amazon-in/cart-add.js deleted file mode 100644 index aa3f6e7f..00000000 --- a/plugins/amazon-in/cart-add.js +++ /dev/null @@ -1,96 +0,0 @@ -import { AuthRequiredError, ArgumentError, CommandExecutionError } from '@agentrhq/webcmd/errors'; -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { hasAmazonInAuthCookie, buildProductUrl } from './parsers.js'; -import { gotoAmazon, SITE, DOMAIN } from './shared.js'; - -async function assertAuthenticated(page) { - const cookies = await page.getCookies({ url: 'https://www.amazon.in/' }); - if (!hasAmazonInAuthCookie(cookies.map((cookie) => cookie.name))) { - throw new AuthRequiredError(DOMAIN, 'Amazon.in login is required before changing the cart'); - } -} - -async function selectVariant(page, dimension, requested) { - if (!requested) return ''; - const result = await page.evaluateWithArgs(` - (() => { - const current = (document.querySelector( - '#inline-twister-expanded-dimension-text-' + dimension + '_name, #variation_' + dimension + '_name .selection' - )?.textContent || '').trim(); - if (current.toLowerCase() === requested.toLowerCase()) return { current, changed: false }; - const options = [...document.querySelectorAll( - '#inline-twister-expander-content-' + dimension + '_name span[id^="' + dimension + '_name_"]:not([id$="-announce"])' - )].filter((node) => !node.classList.contains('aok-hidden')); - const matches = options.filter((node) => { - const label = dimension === 'color' ? (node.querySelector('img')?.alt || '') : (node.textContent || '').trim(); - return label.toLowerCase() === requested.toLowerCase(); - }); - if (matches.length !== 1) return { current, changed: false, matches: matches.length }; - (matches[0].querySelector('input') || matches[0]).click(); - return { current, changed: true, matches: 1 }; - })() - `, { dimension, requested }); - if (!result?.changed && result?.current?.toLowerCase() !== requested.toLowerCase()) { - throw new ArgumentError(`${dimension === 'color' ? 'colour' : dimension} "${requested}" is not uniquely available`); - } - if (result.changed) await page.sleep(2); - const selected = await page.evaluateWithArgs(` - (() => (document.querySelector( - '#inline-twister-expanded-dimension-text-' + dimension + '_name, ' + - '#variation_' + dimension + '_name .selection' - )?.textContent || '').trim())() - `, { dimension }); - if (selected.toLowerCase() !== requested.toLowerCase()) { - throw new CommandExecutionError(`Amazon did not select ${dimension} "${requested}"`); - } - return selected; -} - -cli({ - site: SITE, - name: 'cart-add', - access: 'write', - description: 'Add one confirmed Amazon.in product variant to the cart', - domain: DOMAIN, - strategy: Strategy.UI, - browser: true, - navigateBefore: false, - siteSession: 'persistent', - freshPage: true, - args: [ - { name: 'input', required: true, positional: true, help: 'Amazon.in product URL or ASIN' }, - { name: 'size', help: 'Exact visible size label' }, - { name: 'colour', help: 'Exact visible colour label' }, - ], - columns: ['status', 'asin', 'title', 'size', 'colour', 'action'], - func: async (page, args) => { - let url; - try { url = buildProductUrl(args.input); } catch (error) { throw new ArgumentError(error.message); } - await gotoAmazon(page, url, 'cart product'); - await assertAuthenticated(page); - await selectVariant(page, 'color', args.colour); - await selectVariant(page, 'size', args.size); - const selected = await page.evaluate(` - (() => ({ - asin: (location.pathname.match(/\\/dp\\/([A-Z0-9]{10})/i)?.[1] || document.querySelector('#ASIN')?.value || '').toUpperCase(), - title: (document.querySelector('#productTitle')?.textContent || '').replace(/\\s+/g, ' ').trim(), - }))() - `); - if (!selected.asin || !selected.title) throw new CommandExecutionError('Amazon product selection could not be verified'); - await page.evaluate(` - (() => document.querySelector('#add-to-cart-button')?.click())() - `); - await page.sleep(2.5); - const confirmed = await page.evaluate(` - (() => ({ - url: location.href, - text: document.body?.innerText || '', - confirmation: Boolean(document.querySelector('#huc-v2-order-row, #attachDisplayAddBaseAlert')), - }))() - `); - if (!confirmed.confirmation && !/added to cart|added to your cart/i.test(confirmed.text)) { - throw new CommandExecutionError('Amazon.in did not confirm the item was added to cart'); - } - return [{ status: 'added', asin: selected.asin, title: selected.title, size: args.size || '', colour: args.colour || '', action: 'Item added to the authenticated Amazon.in cart.' }]; - }, -}); diff --git a/plugins/amazon-in/cart.js b/plugins/amazon-in/cart.js deleted file mode 100644 index e9799eff..00000000 --- a/plugins/amazon-in/cart.js +++ /dev/null @@ -1,58 +0,0 @@ -import { AuthRequiredError, CommandExecutionError } from '@agentrhq/webcmd/errors'; -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { hasAmazonInAuthCookie } from './parsers.js'; -import { gotoAmazon, SITE, DOMAIN } from './shared.js'; - -const CART_URL = 'https://www.amazon.in/gp/cart/view.html'; - -cli({ - site: SITE, - name: 'cart', - access: 'read', - description: 'Read the authenticated Amazon.in cart', - domain: DOMAIN, - strategy: Strategy.UI, - browser: true, - navigateBefore: false, - siteSession: 'persistent', - freshPage: true, - args: [], - columns: ['asin', 'title', 'price', 'quantity', 'product_url'], - func: async (page) => { - await gotoAmazon(page, CART_URL, 'cart'); - try { - await page.wait({ selector: '#sc-active-cart .sc-list-item', timeout: 20 }); - } catch { - await page.sleep(1); - } - const cookies = await page.getCookies({ url: 'https://www.amazon.in/' }); - if (!hasAmazonInAuthCookie(cookies.map((cookie) => cookie.name))) { - throw new AuthRequiredError(DOMAIN, 'Amazon.in login is required before reading the cart'); - } - const payload = await page.evaluate(` - (() => { - const text = (node) => (node?.textContent || '').replace(/\\s+/g, ' ').trim(); - const rows = [...document.querySelectorAll('#sc-active-cart .sc-list-item')].map((item) => { - const asin = item.getAttribute('data-asin') || item.querySelector('[data-asin]')?.getAttribute('data-asin') || ''; - const title = text(item.querySelector('.sc-product-title') || item.querySelector('.a-truncate-cut, [data-name]')) - .replace(/Opens in a new tab/gi, '').trim(); - const price = text(item.querySelector('.sc-price, .a-price .a-offscreen')); - const quantity = Number(item.querySelector('select[name^="quantity"], input[name^="quantity"]')?.value || 1); - return { asin, title, price, quantity }; - }).filter((row) => row.asin && row.title); - return { rows, empty: /your amazon cart is empty|no items in your cart/i.test(document.body?.innerText || '') }; - })() - `); - if (payload.rows.length > 0) { - return payload.rows.map((row) => ({ - asin: row.asin, - title: row.title, - price: Number(row.price.replaceAll('₹', '').replaceAll(',', '')) || null, - quantity: Number.isInteger(row.quantity) && row.quantity > 0 ? row.quantity : 1, - product_url: `https://www.amazon.in/dp/${row.asin}`, - })); - } - if (payload.empty) return []; - throw new CommandExecutionError('Amazon.in cart exposed no recognizable items', 'The cart page may have changed or a login challenge may be visible.'); - }, -}); diff --git a/plugins/amazon-in/checkout-status.js b/plugins/amazon-in/checkout-status.js deleted file mode 100644 index 0849a962..00000000 --- a/plugins/amazon-in/checkout-status.js +++ /dev/null @@ -1,54 +0,0 @@ -import { AuthRequiredError, CommandExecutionError } from '@agentrhq/webcmd/errors'; -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { classifyCheckoutSnapshot } from './parsers.js'; -import { DOMAIN, SITE } from './shared.js'; - -cli({ - site: SITE, - name: 'checkout-status', - access: 'read', - description: 'Read the current Amazon.in checkout or payment state without clicking', - domain: DOMAIN, - strategy: Strategy.UI, - browser: true, - navigateBefore: false, - siteSession: 'persistent', - args: [], - columns: ['status', 'order_id', 'total', 'payment_method', 'action'], - func: async (page) => { - const snapshot = await page.evaluate(` - (() => { - const body = document.body?.innerText || ''; - const rows = [...document.querySelectorAll('#subtotals-marketplace-table li')]; - const total = rows.find((node) => /^Order Total:/i.test((node.textContent || '').trim())); - const selectedPayment = document.querySelector('#selected-payment-methods-list-container'); - const checkedPayment = document.querySelector( - '#checkout-paymentOptionPanel input[type="radio"]:checked, input[name*="payment"]:checked' - ); - const payment = selectedPayment || - checkedPayment?.closest('label, [data-testid*="payment"], .pmts-instrument-box'); - const order = document.querySelector('[data-order-id], #orderDetails, .order-number'); - return { - url: location.href, - paymentText: payment?.textContent || '', - text: [ - total?.textContent || '', - order?.textContent || '', - body, - ].join('\\n'), - }; - })() - `); - try { - const row = classifyCheckoutSnapshot(snapshot); - if (row.status === 'login_required') throw new AuthRequiredError(DOMAIN); - return [row]; - } catch (error) { - if (error instanceof AuthRequiredError) throw error; - throw new CommandExecutionError( - `Amazon checkout status could not be classified: ${error.message}`, - 'Keep the checkout or payment page open in the persistent Webcmd browser and retry.', - ); - } - }, -}); diff --git a/plugins/amazon-in/checkout.js b/plugins/amazon-in/checkout.js deleted file mode 100644 index 5a12cfbc..00000000 --- a/plugins/amazon-in/checkout.js +++ /dev/null @@ -1,478 +0,0 @@ -import { - ArgumentError, - AuthRequiredError, - CommandExecutionError, -} from '@agentrhq/webcmd/errors'; -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { - buildProductUrl, - normalizeCheckoutReview, - totalsAreConsistent, - validateCheckoutArgs, -} from './parsers.js'; -import { assertUsablePage, gotoAmazon, SITE } from './shared.js'; - -const VERIFY_COMMAND = 'webcmd amazon-in checkout-status'; - -function handoffRow({ status = 'action_required', asin, title, size, colour, quantity, payment, action }) { - return { - status, - asin, - title, - size, - colour, - quantity, - item_price: null, - total: null, - payment_method: payment, - delivery_date: '', - action, - verify_command: VERIFY_COMMAND, - }; -} - -async function selectVariant(page, dimension, requested) { - if (!requested) return ''; - const result = await page.evaluateWithArgs(` - (() => { - const current = (document.querySelector( - '#inline-twister-expanded-dimension-text-' + dimension + '_name, ' + - '#variation_' + dimension + '_name .selection' - )?.textContent || '').trim(); - if (current.toLowerCase() === requested.toLowerCase()) return { current, changed: false }; - const options = [...document.querySelectorAll( - '#inline-twister-expander-content-' + dimension + '_name span[id^="' + dimension + '_name_"]:not([id$="-announce"])' - )].filter((node) => !node.classList.contains('aok-hidden')); - const matches = options.filter((node) => { - const label = dimension === 'color' - ? (node.querySelector('img')?.alt || '') - : (node.textContent || '').trim(); - return label.toLowerCase() === requested.toLowerCase(); - }); - if (matches.length !== 1) return { current, changed: false, matches: matches.length }; - (matches[0].querySelector('input') || matches[0]).click(); - return { current, changed: true, matches: 1 }; - })() - `, { dimension, requested }); - if (!result?.changed && result?.current?.toLowerCase() !== requested.toLowerCase()) { - throw new ArgumentError( - `${dimension === 'color' ? 'colour' : dimension} "${requested}" is not uniquely available`, - ); - } - if (result.changed) await page.sleep(2); - const selected = await page.evaluateWithArgs(` - (() => (document.querySelector( - '#inline-twister-expanded-dimension-text-' + dimension + '_name, ' + - '#variation_' + dimension + '_name .selection' - )?.textContent || '').trim())() - `, { dimension }); - if (selected.toLowerCase() !== requested.toLowerCase()) { - throw new CommandExecutionError(`Amazon did not select ${dimension} "${requested}"`); - } - return selected; -} - -async function readProductSelection(page) { - return page.evaluate(` - (() => ({ - asin: (location.pathname.match(/\\/dp\\/([A-Z0-9]{10})/i)?.[1] || document.querySelector('#ASIN')?.value || '').toUpperCase(), - title: (document.querySelector('#productTitle')?.textContent || '').replace(/\\s+/g, ' ').trim(), - size: (document.querySelector('#inline-twister-expanded-dimension-text-size_name, #variation_size_name .selection')?.textContent || '').trim(), - colour: (document.querySelector('#inline-twister-expanded-dimension-text-color_name, #variation_color_name .selection')?.textContent || '').trim(), - }))() - `); -} - -async function selectPayment(page, options) { - return page.evaluateWithArgs(` - (() => { - const radios = [...document.querySelectorAll('input[type="radio"]')]; - radios.forEach((radio) => radio.removeAttribute('data-webcmd-payment-target')); - let matches = []; - if (payment === 'upi') { - matches = radios.filter((radio) => /paymentMethod=UnifiedPaymentsInterface/i.test(radio.value)); - } else if (payment === 'cod') { - matches = radios.filter((radio) => /paymentMethod=COD/i.test(radio.value)); - } else if (payment === 'new-card') { - matches = radios.filter((radio) => radio.value === 'SelectableAddCreditCard'); - } else { - matches = radios.filter((radio) => { - const label = radio.closest('label')?.innerText || radio.parentElement?.innerText || ''; - return new RegExp('ending in\\\\s+' + cardLast4 + '\\\\b', 'i').test(label); - }); - } - if (matches.length !== 1) return { matches: matches.length }; - matches[0].setAttribute('data-webcmd-payment-target', 'true'); - return { matches: 1 }; - })() - `, options); -} - -async function waitForPaymentUi(page) { - for (let attempt = 0; attempt < 40; attempt += 1) { - const ready = await page.evaluate(` - (() => { - const visible = (node) => { - const rect = node.getBoundingClientRect(); - const style = getComputedStyle(node); - return rect.width > 0 && rect.height > 0 && - style.display !== 'none' && style.visibility !== 'hidden'; - }; - const loaders = [...document.querySelectorAll('[aria-label^="acp-loading"]')]; - return document.querySelectorAll('#checkout-paymentOptionPanel input[type="radio"]').length > 0 && - Boolean(document.querySelector('[data-testid="bottom-continue-button"]')) && - !loaders.some(visible); - })() - `); - if (ready) return; - await page.sleep(0.5); - } - throw new CommandExecutionError('Amazon payment interface did not finish initializing'); -} - -async function continueAfterPaymentSelection(page, options, selected, quantity) { - if (options.payment === 'new-card') { - return handoffRow({ - ...selected, - quantity, - payment: options.payment, - action: 'Enter the new card details in the opened browser, then run checkout-status.', - }); - } - if (options.payment === 'saved-card') { - for (let attempt = 0; attempt < 20; attempt += 1) { - const readiness = await page.evaluate(` - (() => { - const button = document.querySelector('[data-testid="bottom-continue-button"]'); - return { - needsSecret: Boolean(document.querySelector( - 'input[name*="verification"], input[autocomplete="cc-csc"], input[placeholder*="CVV" i]' - )), - continueEnabled: Boolean(button && !button.disabled), - }; - })() - `); - if (readiness?.needsSecret) { - return handoffRow({ - ...selected, - quantity, - payment: options.payment, - action: 'Enter the saved card CVV in the opened browser, then run checkout-status.', - }); - } - if (readiness?.continueEnabled) { - await page.click('[data-testid="bottom-continue-button"]'); - return null; - } - await page.sleep(0.25); - } - throw new CommandExecutionError('Amazon did not expose the saved-card CVV or enable Continue'); - } - try { - await page.wait({ - selector: '[data-testid="bottom-continue-button"]:not([disabled])', - timeout: 10, - }); - } catch { - throw new CommandExecutionError('Amazon did not enable the selected payment method'); - } - await page.click('[data-testid="bottom-continue-button"]'); - return null; -} - -async function waitForUrlChange(page, previousUrl) { - for (let attempt = 0; attempt < 40; attempt += 1) { - try { - if (await page.evaluate('location.href') !== previousUrl) return; - } catch { - return; - } - await page.sleep(0.25); - } -} - -async function advanceToReview(page) { - for (let step = 0; step < 4; step += 1) { - try { - await page.wait({ - selector: '#placeOrder, #noThanksCtaButtonId a, a[href*="/spc"]', - timeout: 20, - }); - } catch { - throw new CommandExecutionError('Amazon checkout did not expose a review transition'); - } - const state = await page.evaluate(` - (() => { - if (document.querySelector('#placeOrder')) return { kind: 'review' }; - const reviewLinks = [...document.querySelectorAll('a[href*="/spc"]')] - .filter((link) => (link.textContent || '').trim() === 'Review Order'); - if (reviewLinks.length === 1) return { kind: 'review-link', href: reviewLinks[0].href }; - const decline = document.querySelector('#noThanksCtaButtonId a'); - if (decline && (decline.textContent || '').trim() === 'No Thanks') { - return { kind: 'decline', url: location.href }; - } - return { kind: 'unknown' }; - })() - `); - if (state.kind === 'review') return; - if (state.kind === 'review-link') { - await page.goto(state.href, { waitUntil: 'load' }); - continue; - } - if (state.kind === 'decline') { - await page.click('#noThanksCtaButtonId a'); - await waitForUrlChange(page, state.url); - continue; - } - throw new CommandExecutionError('Amazon checkout exposed an unsupported Prime offer state'); - } - throw new CommandExecutionError('Amazon checkout did not reach final review'); -} - -async function waitForReviewUi(page) { - for (let attempt = 0; attempt < 40; attempt += 1) { - const ready = await page.evaluate(` - (() => { - const total = [...document.querySelectorAll('#subtotals-marketplace-table li')] - .find((node) => /^Order Total:/i.test((node.textContent || '').trim())); - return Boolean( - document.querySelector('[data-csa-c-item-type="asin"][data-csa-c-item-id*="amzn1.asin."]') && - document.querySelector('[data-a-component="stepper"]') && - /₹|Rs\\.?/i.test(total?.textContent || '') - ); - })() - `); - if (ready) return; - await page.sleep(0.5); - } - throw new CommandExecutionError('Amazon checkout review details did not finish loading'); -} - -async function readReview(page, selected, options) { - const snapshot = await page.evaluate(` - (() => { - const text = (node) => (node?.textContent || '').replace(/\\s+/g, ' ').trim(); - const rows = [...document.querySelectorAll('#subtotals-marketplace-table li')]; - const row = (pattern) => text(rows.find((node) => pattern.test(text(node)))); - const amount = (type) => text( - [...document.querySelectorAll('input[name="subtotalLineType"]')] - .find((input) => input.value === type)?.parentElement - ); - const asinMarkers = [...document.querySelectorAll( - '[data-csa-c-item-type="asin"][data-csa-c-item-id*="amzn1.asin."]' - )]; - const asinMarker = asinMarkers[0]; - const asin = asinMarker?.getAttribute('data-csa-c-item-id')?.match(/amzn1\\.asin\\.([A-Z0-9]{10})/)?.[1] || ''; - const item = asinMarker?.closest('.product-description-column') || document.querySelector('.product-description-column'); - const snapshot = { - itemCount: asinMarkers.length, - asin, - title: text(item?.querySelector('.lineitem-title-text')), - itemPriceText: text(item?.querySelector('.a-price .a-offscreen')), - deliveryFeeText: amount('SHIPPING_TAX_INCLUSIVE'), - deliveryDiscountText: row(/^Free Delivery/i), - marketplaceFeeText: amount('MARKETPLACE_FEE_TAX_INCLUSIVE'), - totalText: row(/^Order Total:/i), - quantity: Number(document.querySelector('[data-a-component="stepper"]')?.getAttribute('data-steppervalue') || 0), - deliveryDate: text(document.querySelector('.address-promise-text')), - bodyText: document.body?.innerText || '', - }; - return snapshot; - })() - `); - assertSingleLineItem(snapshot, selected, options); - const totals = readCheckoutTotals(snapshot); - return { - status: 'review_ready', - asin: selected.asin, - title: snapshot.title || selected.title, - size: selected.size, - colour: selected.colour, - quantity: options.quantity, - item_price: totals.itemPrice, - total: totals.total, - payment_method: options.payment, - delivery_date: snapshot.deliveryDate, - action: '', - verify_command: VERIFY_COMMAND, - }; -} - -function assertSingleLineItem(snapshot, selected, options) { - if (snapshot.itemCount !== 1) { - throw new CommandExecutionError( - `Expected exactly one checkout line item; found ${snapshot.itemCount}`, - ); - } - if (snapshot.asin !== selected.asin || snapshot.quantity !== options.quantity) { - throw new CommandExecutionError( - `Amazon checkout item or quantity mismatch: expected ${selected.asin} × ${options.quantity}, got ${snapshot.asin || '(missing ASIN)'} × ${snapshot.quantity}`, - ); - } - if (options.size && !snapshot.title.toLowerCase().includes(options.size.toLowerCase())) { - throw new CommandExecutionError(`Amazon checkout review does not show size "${options.size}"`); - } - if (options.colour && !snapshot.title.toLowerCase().includes(options.colour.toLowerCase())) { - throw new CommandExecutionError(`Amazon checkout review does not show colour "${options.colour}"`); - } - const paymentPatterns = { - upi: /Pay by scanning the QR code|Pay with UPI/i, - cod: /Cash on Delivery|Pay on Delivery/i, - 'saved-card': new RegExp(`ending in\\s+${options.cardLast4}\\b`, 'i'), - 'new-card': /credit or debit card/i, - }; - if (!paymentPatterns[options.payment].test(snapshot.bodyText)) { - throw new CommandExecutionError('Amazon checkout payment method does not match the requested method'); - } -} - -function readCheckoutTotals(snapshot) { - let totals; - try { - totals = normalizeCheckoutReview(snapshot); - } catch (error) { - throw new CommandExecutionError(`Amazon checkout totals could not be read: ${error.message}`); - } - if (!totalsAreConsistent(totals)) { - throw new CommandExecutionError('Amazon checkout total is inconsistent with item price and fees'); - } - return totals; -} - -async function submitOrder(page, enabled) { - if (!enabled) return false; - const placement = await page.evaluate(` - (() => { - window.scrollTo(0, 0); - const candidates = [...document.querySelectorAll('#placeOrder')] - .filter((button) => { - const rect = button.getBoundingClientRect(); - const style = getComputedStyle(button); - return !button.disabled && rect.width > 0 && rect.height > 0 && - rect.top >= 0 && rect.bottom <= innerHeight && - style.display !== 'none' && style.visibility !== 'hidden'; - }); - if (candidates.length !== 1) return { clicked: false, matches: candidates.length }; - candidates[0].click(); - return { clicked: true, matches: 1 }; - })() - `); - if (!placement?.clicked) { - throw new CommandExecutionError( - `Expected one visible final order control; found ${placement?.matches ?? 0}`, - ); - } - return true; -} - -cli({ - site: SITE, - name: 'checkout', - access: 'write', - description: 'Prepare a guarded Amazon.in checkout with browser-only payment handoff', - domain: 'amazon.in', - strategy: Strategy.UI, - browser: true, - navigateBefore: false, - siteSession: 'persistent', - freshPage: true, - args: [ - { name: 'input', required: true, positional: true, help: 'Amazon.in product URL or ASIN' }, - { name: 'quantity', type: 'int', default: 1, help: 'Quantity (1-10)' }, - { name: 'size', help: 'Exact visible size label' }, - { name: 'colour', help: 'Exact visible colour label' }, - { - name: 'payment', - required: true, - choices: ['upi', 'saved-card', 'new-card', 'cod'], - help: 'Payment method; secrets remain browser-only', - }, - { name: 'card-last4', help: 'Saved-card selector: exactly four digits' }, - { name: 'place-order', type: 'boolean', default: false, help: 'Submit the final Amazon action once' }, - ], - columns: [ - 'status', 'asin', 'title', 'size', 'colour', 'quantity', 'item_price', - 'total', 'payment_method', 'delivery_date', 'action', 'verify_command', - ], - func: async (page, args) => { - let url; - let options; - try { - url = buildProductUrl(args.input); - options = validateCheckoutArgs({ - quantity: args.quantity, - size: args.size, - colour: args.colour, - payment: args.payment, - cardLast4: args['card-last4'], - placeOrder: args['place-order'], - }); - } catch (error) { - throw new ArgumentError(error.message); - } - - await gotoAmazon(page, url, 'checkout product'); - await page.wait({ selector: '#productTitle', timeout: 15 }); - await selectVariant(page, 'color', options.colour); - await selectVariant(page, 'size', options.size); - const selected = await readProductSelection(page); - if (!selected.asin || !selected.title) { - throw new CommandExecutionError('Amazon product selection could not be verified'); - } - - const quantitySet = await page.evaluateWithArgs(` - (() => { - const select = document.querySelector('#quantity'); - if (!select) return quantity === 1; - if (![...select.options].some((option) => Number(option.value) === quantity)) return false; - select.value = String(quantity); - select.dispatchEvent(new Event('change', { bubbles: true })); - return Number(select.value) === quantity; - })() - `, { quantity: options.quantity }); - if (!quantitySet) throw new ArgumentError(`quantity ${options.quantity} is not available`); - - await page.click('#buy-now-button'); - await page.sleep(3); - await assertUsablePage(page, 'checkout payment page'); - try { - await page.wait({ selector: '#checkout-paymentOptionPanel input[type="radio"]', timeout: 20 }); - } catch { - throw new CommandExecutionError('Amazon payment methods did not appear'); - } - await waitForPaymentUi(page); - - const paymentResult = await selectPayment(page, options); - if (paymentResult?.matches !== 1) { - throw new ArgumentError( - options.payment === 'saved-card' - ? `card-last4 ${options.cardLast4} did not match exactly one saved card` - : `${options.payment} is not uniquely available for this checkout`, - ); - } - await page.click('input[data-webcmd-payment-target="true"]'); - const handoff = await continueAfterPaymentSelection(page, options, selected, options.quantity); - if (handoff) return [handoff]; - await advanceToReview(page); - await waitForReviewUi(page); - await assertUsablePage(page, 'checkout review'); - const review = await readReview(page, selected, options); - if (!await submitOrder(page, options.placeOrder)) return [review]; - await page.sleep(3); - return [{ - ...review, - status: options.payment === 'cod' ? 'submitted' : 'action_required', - action: options.payment === 'upi' - ? 'Scan Amazon’s QR code in the opened browser and approve the UPI payment, then run checkout-status.' - : options.payment === 'cod' - ? '' - : 'Complete the bank verification in the opened browser, then run checkout-status.', - }]; - }, -}); - -export const __test__ = { - assertSingleLineItem, - continueAfterPaymentSelection, - submitOrder, -}; diff --git a/plugins/amazon-in/package.json b/plugins/amazon-in/package.json deleted file mode 100644 index 760f0d37..00000000 --- a/plugins/amazon-in/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "webcmd-plugin-amazon-in", - "version": "0.1.0", - "type": "module", - "description": "Webcmd commands for amazon-in", - "peerDependencies": { - "@agentrhq/webcmd": ">=0.5.3" - } -} diff --git a/plugins/amazon-in/parsers.js b/plugins/amazon-in/parsers.js deleted file mode 100644 index fa9354f5..00000000 --- a/plugins/amazon-in/parsers.js +++ /dev/null @@ -1,252 +0,0 @@ -export const cleanText = (value) => - typeof value === 'string' - ? value.replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim() - : ''; - -export function classifyPageState(url, text) { - if (/\/(?:ap\/signin|signin)(?:[/?]|$)/i.test(url)) return 'login'; - if (/enter the characters you see below|sorry, we just need to make sure you're not a robot/i.test(cleanText(text))) { - return 'robot'; - } - return 'usable'; -} - -export function hasAmazonInAuthCookie(names) { - const cookies = new Set(names); - return cookies.has('at-acbin') || cookies.has('x-acbin'); -} - -export function extractAsin(input) { - const value = cleanText(input); - if (/^[A-Z0-9]{10}$/i.test(value)) return value.toUpperCase(); - try { - const url = new URL(value); - if (!/(^|\.)amazon\.in$/i.test(url.hostname)) return null; - return url.pathname.match(/\/(?:dp|gp\/product)\/([A-Z0-9]{10})/i)?.[1]?.toUpperCase() ?? null; - } catch { - return null; - } -} - -export function buildProductUrl(input) { - const asin = extractAsin(input); - if (!asin) throw new RangeError('Expected an Amazon.in product URL or 10-character ASIN'); - return `https://www.amazon.in/dp/${asin}`; -} - -export function parseMoney(text) { - const match = cleanText(text).match(/(?:₹|Rs\.?)\s*([\d,]+(?:\.\d{1,2})?)/i); - return match ? Number(match[1].replaceAll(',', '')) : null; -} - -export function parseCompactCount(text) { - const match = cleanText(text).match(/([\d,.]+)\s*([KM])?/i); - if (!match) return null; - const scale = match[2]?.toUpperCase() === 'M' ? 1_000_000 : match[2] ? 1_000 : 1; - return Math.round(Number(match[1].replaceAll(',', '')) * scale); -} - -export function validatePositiveInteger(value, name, maximum) { - const number = Number(value); - if (!Number.isInteger(number) || number < 1 || number > maximum) { - throw new RangeError(`${name} must be an integer from 1 to ${maximum}`); - } - return number; -} - -export function parseBoolean(value) { - if (typeof value === 'boolean') return value; - if (value === undefined || value === null || value === '') return false; - if (/^(true|1)$/i.test(String(value))) return true; - if (/^(false|0)$/i.test(String(value))) return false; - throw new RangeError('place-order must be true or false'); -} - -export function validatePriceBounds(minimum, maximum) { - const minPrice = minimum === undefined ? null : Number(minimum); - const maxPrice = maximum === undefined ? null : Number(maximum); - if (minPrice !== null && (!Number.isFinite(minPrice) || minPrice < 0)) { - throw new RangeError('minimum price must be zero or greater'); - } - if (maxPrice !== null && (!Number.isFinite(maxPrice) || maxPrice < 0)) { - throw new RangeError('maximum price must be zero or greater'); - } - if (minPrice !== null && maxPrice !== null && minPrice > maxPrice) { - throw new RangeError('minimum price cannot exceed maximum price'); - } - return { minPrice, maxPrice }; -} - -export function buildSearchUrl(query, { minPrice, maxPrice }) { - const url = new URL('/s', 'https://www.amazon.in'); - url.searchParams.set('k', cleanText(query)); - if (minPrice !== null || maxPrice !== null) { - const low = Math.round((minPrice ?? 0) * 100); - const high = Math.round((maxPrice ?? 10_000_000) * 100); - url.searchParams.set('rh', `p_36:${low}-${high}`); - } - return url.href; -} - -export function normalizeSearchCards(cards, { minPrice, maxPrice, limit }) { - return cards - .map((card) => { - const price = parseMoney(card.cardPriceText); - if (!card.cardAsin || !cleanText(card.cardTitle) || price === null) return null; - if (minPrice !== null && price < minPrice) return null; - if (maxPrice !== null && price > maxPrice) return null; - return { - rank: 0, - asin: card.cardAsin, - title: cleanText(card.cardTitle), - price, - mrp: parseMoney(card.cardMrpText), - rating: Number(cleanText(card.cardRatingText).match(/[\d.]+/)?.[0]) || null, - review_count: parseCompactCount(card.cardReviewText), - image_url: cleanText(card.cardImageUrl), - product_url: `https://www.amazon.in/dp/${card.cardAsin}`, - is_sponsored: card.cardSponsored === true, - }; - }) - .filter(Boolean) - .slice(0, limit) - .map((row, index) => ({ ...row, rank: index + 1 })); -} - -export function normalizeProductSnapshot(snapshot) { - const asin = extractAsin(snapshot.href); - const title = cleanText(snapshot.title); - const availability = cleanText(snapshot.availabilityText); - if (!asin || !title || !availability) throw new Error('Amazon product details are incomplete'); - const price = parseMoney(snapshot.priceText); - if (price === null && !/unavailable|out of stock|currently unavailable/i.test(availability)) { - throw new Error('Amazon product price is missing'); - } - const discountMatch = cleanText(snapshot.discountText).match(/-?\s*(\d+(?:\.\d+)?)\s*%/); - return { - asin, - title, - price, - mrp: parseMoney(snapshot.mrpText), - discount: discountMatch ? Number(discountMatch[1]) : null, - availability, - size: cleanText(snapshot.sizeText), - colour: cleanText(snapshot.colourText), - image_url: cleanText(snapshot.imageUrl), - product_url: `https://www.amazon.in/dp/${asin}`, - }; -} - -export function normalizeWishlistRows(listName, cards) { - return cards.map((card) => { - const asin = extractAsin(card.cardHref); - const title = cleanText(card.cardTitle); - const itemId = cleanText(card.cardItemId); - const availability = cleanText(card.cardAvailabilityText); - const price = parseMoney(card.cardPriceText); - if (!asin || !title || !itemId) throw new Error('Amazon wishlist item details are incomplete'); - if (price === null && !/unavailable|out of stock|currently unavailable/i.test(availability)) { - throw new Error(`Amazon wishlist price is missing for ${asin}`); - } - return { - list_name: cleanText(listName), - item_id: itemId, - asin, - title, - price, - mrp: parseMoney(card.cardMrpText), - availability, - size: cleanText(card.cardSizeText), - colour: cleanText(card.cardColourText), - image_url: cleanText(card.cardImageUrl), - product_url: `https://www.amazon.in/dp/${asin}`, - }; - }); -} - -export function validateCheckoutArgs(args) { - const quantity = validatePositiveInteger(args.quantity ?? 1, 'quantity', 10); - const payment = cleanText(args.payment); - if (!['upi', 'saved-card', 'new-card', 'cod'].includes(payment)) { - throw new RangeError('payment must be upi, saved-card, new-card, or cod'); - } - const cardLast4 = cleanText(args.cardLast4); - if (payment === 'saved-card' && !/^\d{4}$/.test(cardLast4)) { - throw new RangeError('card-last4 must contain exactly four digits for saved-card'); - } - return { - quantity, - payment, - cardLast4, - size: cleanText(args.size), - colour: cleanText(args.colour), - placeOrder: parseBoolean(args.placeOrder), - }; -} - -export function normalizeCheckoutReview(snapshot) { - const itemPrice = parseMoney(snapshot.itemPriceText); - const total = parseMoney(snapshot.totalText); - if (itemPrice === null || total === null) throw new Error('Checkout item price or total is missing'); - const deliveryFee = (parseMoney(snapshot.deliveryFeeText) ?? 0) - - (parseMoney(snapshot.deliveryDiscountText) ?? 0); - return { - itemPrice, - deliveryFee, - marketplaceFee: parseMoney(snapshot.marketplaceFeeText) ?? 0, - total, - quantity: Number(snapshot.quantity), - }; -} - -export function totalsAreConsistent(review) { - const expected = review.itemPrice * review.quantity + review.deliveryFee + review.marketplaceFee; - return Math.abs(expected - review.total) <= 0.011; -} - -export function classifyCheckoutSnapshot(snapshot) { - const url = cleanText(snapshot.url); - const text = cleanText(snapshot.text); - const reviewReady = /\/checkout\/p\/.+\/spc/i.test(url) && /Order Total:/i.test(text); - const awaitingPayment = !reviewReady && ( - /aips\/process-payment/i.test(url) - || /QR code|enter (?:your )?CVV|one.time password|3-D Secure/i.test(text) - ); - const hasPaymentText = Object.hasOwn(snapshot, 'paymentText'); - const explicitPaymentText = hasPaymentText ? cleanText(snapshot.paymentText) : ''; - const paymentText = hasPaymentText - ? explicitPaymentText || (awaitingPayment ? text : '') - : text; - const paymentMethod = /UPI|QR code/i.test(paymentText) - ? 'upi' - : /Cash on Delivery|Pay on Delivery/i.test(paymentText) - ? 'cod' - : /ending in|credit card|debit card|CVV|3-D Secure/i.test(paymentText) - ? 'card' - : ''; - const base = { - order_id: text.match(/\b\d{3}-\d{7}-\d{7}\b/)?.[0] ?? '', - total: parseMoney(text), - payment_method: paymentMethod, - action: '', - }; - if (/\/(?:thankyou|buy\/thankyou)\//i.test(url) && /order (?:placed|confirmed)|thank you/i.test(text)) { - return { status: 'ordered', ...base }; - } - if (/payment (?:failed|declined|unsuccessful)|transaction failed/i.test(text)) { - return { status: 'failed', ...base, action: 'Choose a payment method in the browser; do not retry a charge automatically.' }; - } - if (/(?:QR|session|payment link).{0,30}expired|expired.{0,30}(?:QR|session|payment)/i.test(text)) { - return { status: 'expired', ...base, action: 'Return to checkout and create a new payment attempt.' }; - } - if (reviewReady) { - return { status: 'review_ready', ...base }; - } - if (awaitingPayment) { - return { status: 'awaiting_payment', ...base, action: 'Complete payment in the opened browser, then run checkout-status again.' }; - } - if (/\/ap\/signin/i.test(url) || /enter your (?:email|mobile number)|sign in/i.test(text)) { - return { status: 'login_required', ...base, action: 'Sign in in the opened browser, then run checkout-status again.' }; - } - throw new Error('Amazon checkout state is not recognized'); -} diff --git a/plugins/amazon-in/product.js b/plugins/amazon-in/product.js deleted file mode 100644 index 99dce3bb..00000000 --- a/plugins/amazon-in/product.js +++ /dev/null @@ -1,72 +0,0 @@ -import { ArgumentError, CommandExecutionError } from '@agentrhq/webcmd/errors'; -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { buildProductUrl, normalizeProductSnapshot } from './parsers.js'; -import { gotoAmazon, SITE } from './shared.js'; - -cli({ - site: SITE, - name: 'product', - access: 'read', - description: 'Fetch the current Amazon.in price and selected product variant', - domain: 'amazon.in', - strategy: Strategy.UI, - browser: true, - navigateBefore: false, - siteSession: 'persistent', - args: [ - { - name: 'input', - required: true, - positional: true, - help: 'Amazon.in product URL or ASIN', - }, - ], - columns: [ - 'asin', 'title', 'price', 'mrp', 'discount', 'availability', - 'size', 'colour', 'image_url', 'product_url', - ], - func: async (page, args) => { - let url; - try { - url = buildProductUrl(args.input); - } catch (error) { - throw new ArgumentError(error.message); - } - await gotoAmazon(page, url, 'product page'); - try { - await page.wait({ selector: '#productTitle', timeout: 15 }); - } catch { - throw new CommandExecutionError( - 'Amazon.in product title did not appear', - 'The product may be unavailable or the page layout may have changed.', - ); - } - const snapshot = await page.evaluate(` - (() => { - const text = (selector) => (document.querySelector(selector)?.textContent || '') - .replace(/\\s+/g, ' ').trim(); - const image = document.querySelector('#landingImage'); - const snapshot = { - href: location.href, - title: text('#productTitle'), - priceText: text('#corePrice_feature_div .a-price:not(.a-text-price) .a-offscreen, #priceblock_ourprice, #priceblock_dealprice'), - mrpText: text('#corePrice_feature_div .a-price.a-text-price .a-offscreen, .basisPrice .a-offscreen'), - discountText: text('#corePrice_feature_div .savingsPercentage, .savingsPercentage'), - availabilityText: text('#availability'), - sizeText: text('#inline-twister-expanded-dimension-text-size_name, #variation_size_name .selection, #variation_size_name li.swatchSelect .a-button-text'), - colourText: text('#inline-twister-expanded-dimension-text-color_name, #variation_color_name .selection, #variation_color_name li.swatchSelect .a-button-text'), - imageUrl: image?.getAttribute('data-old-hires') || image?.currentSrc || image?.src || '', - }; - return snapshot; - })() - `); - try { - return [normalizeProductSnapshot(snapshot)]; - } catch (error) { - throw new CommandExecutionError( - `Amazon.in product details could not be normalized: ${error.message}`, - 'Check the visible product page for an unavailable item or changed layout.', - ); - } - }, -}); diff --git a/plugins/amazon-in/search.js b/plugins/amazon-in/search.js deleted file mode 100644 index c72a8c90..00000000 --- a/plugins/amazon-in/search.js +++ /dev/null @@ -1,77 +0,0 @@ -import { - ArgumentError, - CommandExecutionError, - EmptyResultError, -} from '@agentrhq/webcmd/errors'; -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { - buildSearchUrl, - cleanText, - normalizeSearchCards, - validatePositiveInteger, - validatePriceBounds, -} from './parsers.js'; -import { argumentValue, gotoAmazon, SITE } from './shared.js'; - -cli({ - site: SITE, - name: 'search', - tags: ['search'], - access: 'read', - description: 'Search Amazon.in products with inclusive INR price bounds and images', - domain: 'amazon.in', - strategy: Strategy.UI, - browser: true, - navigateBefore: false, - siteSession: 'persistent', - args: [ - { name: 'query', required: true, positional: true, help: 'Product search query' }, - { name: 'min-price', type: 'number', help: 'Inclusive minimum price in rupees' }, - { name: 'max-price', type: 'number', help: 'Inclusive maximum price in rupees' }, - { name: 'limit', type: 'int', default: 20, help: 'Maximum results (1-50)' }, - ], - columns: [ - 'rank', 'asin', 'title', 'price', 'mrp', 'rating', - 'review_count', 'image_url', 'product_url', 'is_sponsored', - ], - func: async (page, args) => { - const query = cleanText(args.query); - if (!query) throw new ArgumentError('query must not be empty'); - const bounds = argumentValue(() => validatePriceBounds(args['min-price'], args['max-price'])); - const limit = argumentValue(() => validatePositiveInteger(args.limit ?? 20, 'limit', 50)); - await gotoAmazon(page, buildSearchUrl(query, bounds), 'product search'); - - const payload = await page.evaluate(` - (() => { - const text = (node) => (node?.textContent || '').replace(/\\s+/g, ' ').trim(); - const cards = [...document.querySelectorAll('[data-component-type="s-search-result"]')] - .map((card) => ({ - cardAsin: (card.getAttribute('data-asin') || '').trim().toUpperCase(), - cardTitle: text(card.querySelector('a.a-text-normal.s-line-clamp-2')), - cardPriceText: text(card.querySelector('.a-price:not(.a-text-price) .a-offscreen, .a-price .a-offscreen')), - cardMrpText: text(card.querySelector('.a-price.a-text-price .a-offscreen')), - cardRatingText: text(card.querySelector('[aria-label*="out of 5 stars"], .a-icon-alt')), - cardReviewText: text(card.querySelector('a[href*="#customerReviews"], [aria-label*="ratings"]')), - cardImageUrl: card.querySelector('img.s-image')?.currentSrc || card.querySelector('img.s-image')?.src || '', - cardSponsored: /sponsored/i.test(text(card.querySelector('.puis-sponsored-label-text, [data-component-type="sp-sponsored-result"]'))), - })); - return { - cards, - noResults: /no results for|did not match any products/i.test(document.body?.innerText || ''), - }; - })() - `); - if (!payload || !Array.isArray(payload.cards)) { - throw new CommandExecutionError('Amazon.in search returned an unsupported page shape'); - } - const rows = normalizeSearchCards(payload.cards, { ...bounds, limit }); - if (rows.length === 0) { - if (payload.noResults || payload.cards.length > 0) throw new EmptyResultError('amazon-in search'); - throw new CommandExecutionError( - 'Amazon.in search exposed no result cards', - 'The page layout may have changed or a robot challenge may be visible.', - ); - } - return rows; - }, -}); diff --git a/plugins/amazon-in/shared.js b/plugins/amazon-in/shared.js deleted file mode 100644 index 20582c58..00000000 --- a/plugins/amazon-in/shared.js +++ /dev/null @@ -1,40 +0,0 @@ -import { - ArgumentError, - AuthRequiredError, - CommandExecutionError, -} from '@agentrhq/webcmd/errors'; -import { classifyPageState } from './parsers.js'; - -export const SITE = 'amazon-in'; -export const DOMAIN = 'amazon.in'; -export const HOME_URL = 'https://www.amazon.in/'; -export const WISHLIST_URL = 'https://www.amazon.in/hz/wishlist/ls'; - -export function argumentValue(fn) { - try { - return fn(); - } catch (error) { - if (error instanceof RangeError) throw new ArgumentError(error.message); - throw error; - } -} - -export async function assertUsablePage(page, context) { - const snapshot = await page.evaluate(` - (() => ({ url: location.href, text: document.body?.innerText || '' }))() - `); - const state = classifyPageState(snapshot.url, snapshot.text); - if (state === 'login') throw new AuthRequiredError(DOMAIN); - if (state === 'robot') { - throw new CommandExecutionError( - `Amazon robot check blocked ${context}`, - 'Complete the visible challenge in the Webcmd browser, then retry.', - ); - } -} - -export async function gotoAmazon(page, url, context) { - await page.goto(url, { waitUntil: 'load' }); - await page.wait(2); - await assertUsablePage(page, context); -} diff --git a/plugins/amazon-in/test/parsers.test.js b/plugins/amazon-in/test/parsers.test.js deleted file mode 100644 index 8c5778b6..00000000 --- a/plugins/amazon-in/test/parsers.test.js +++ /dev/null @@ -1,264 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { getRegistry } from '@agentrhq/webcmd/registry'; -import { __test__ as checkoutTest } from '../checkout.js'; -import '../cart-add.js'; -import { - buildProductUrl, - classifyCheckoutSnapshot, - classifyPageState, - extractAsin, - hasAmazonInAuthCookie, - normalizeCheckoutReview, - normalizeProductSnapshot, - normalizeSearchCards, - normalizeWishlistRows, - parseCompactCount, - parseMoney, - totalsAreConsistent, - validateCheckoutArgs, - validatePositiveInteger, - validatePriceBounds, -} from '../parsers.js'; - -describe('amazon-in parsers', () => { - it('normalizes ASINs and parses Indian prices and counts', () => { - expect(extractAsin('https://www.amazon.in/dp/B0D2QL339Z?psc=1')).toBe('B0D2QL339Z'); - expect(buildProductUrl('B0D2QL339Z')).toBe('https://www.amazon.in/dp/B0D2QL339Z'); - expect(extractAsin('https://amazon.com/dp/B0D2QL339Z')).toBeNull(); - expect(parseMoney('₹1,499.00')).toBe(1499); - expect(parseMoney('Unavailable')).toBeNull(); - expect(parseCompactCount('39.7K')).toBe(39700); - expect(parseCompactCount('3,564 ratings')).toBe(3564); - }); - - it('validates bounds without silently clamping', () => { - expect(validatePriceBounds('300', '500')).toEqual({ minPrice: 300, maxPrice: 500 }); - expect(() => validatePriceBounds('500', '300')).toThrow(/minimum price/i); - expect(validatePositiveInteger('20', 'limit', 50)).toBe(20); - expect(() => validatePositiveInteger('51', 'limit', 50)).toThrow(/1 to 50/i); - }); - - it('classifies login and robot pages before DOM parsing', () => { - expect(classifyPageState('https://www.amazon.in/ap/signin', '')).toBe('login'); - expect(classifyPageState('https://www.amazon.in/', 'Enter the characters you see below')).toBe('robot'); - expect(classifyPageState('https://www.amazon.in/s?k=shirt', 'Results')).toBe('usable'); - expect(hasAmazonInAuthCookie(['session-id', 'x-acbin'])).toBe(true); - expect(hasAmazonInAuthCookie(['session-id', 'i18n-prefs'])).toBe(false); - }); - - it('filters search cards inclusively and preserves images', () => { - const rows = normalizeSearchCards([ - { - cardAsin: 'B000000001', - cardTitle: 'Low', - cardPriceText: '₹299', - cardImageUrl: 'https://m.media-amazon.com/low.jpg', - }, - { - cardAsin: 'B000000002', - cardTitle: 'Match', - cardPriceText: '₹500', - cardImageUrl: 'https://m.media-amazon.com/match.jpg', - }, - { - cardAsin: 'B000000003', - cardTitle: 'No price', - cardPriceText: '', - cardImageUrl: '', - }, - ], { minPrice: 300, maxPrice: 500, limit: 10 }); - expect(rows.map((row) => row.asin)).toEqual(['B000000002']); - expect(rows[0].image_url).toBe('https://m.media-amazon.com/match.jpg'); - expect(rows[0].rank).toBe(1); - }); - - it('normalizes selected product and wishlist variants', () => { - expect(normalizeProductSnapshot({ - href: 'https://www.amazon.in/dp/B0D2QL339Z?th=1&psc=1', - title: 'Besix shirt', - priceText: '₹395.00', - mrpText: '₹1,499.00', - discountText: '-74%', - availabilityText: 'In stock', - sizeText: 'L', - colourText: 'Red', - imageUrl: 'https://m.media-amazon.com/image.jpg', - })).toEqual({ - asin: 'B0D2QL339Z', - title: 'Besix shirt', - price: 395, - mrp: 1499, - discount: 74, - availability: 'In stock', - size: 'L', - colour: 'Red', - image_url: 'https://m.media-amazon.com/image.jpg', - product_url: 'https://www.amazon.in/dp/B0D2QL339Z', - }); - - const [row] = normalizeWishlistRows('Shopping List', [{ - cardItemId: 'item-1', - cardHref: 'https://www.amazon.in/dp/B0D2QL339Z', - cardTitle: 'Besix shirt', - cardPriceText: '₹395.00', - cardMrpText: '₹1,499.00', - cardAvailabilityText: 'In stock', - cardSizeText: 'L', - cardColourText: 'Red', - cardImageUrl: 'https://m.media-amazon.com/image.jpg', - }]); - expect(row).toMatchObject({ - asin: 'B0D2QL339Z', - price: 395, - list_name: 'Shopping List', - }); - }); - - it('validates checkout payment selectors and totals', () => { - expect(validateCheckoutArgs({ - quantity: 1, - payment: 'saved-card', - cardLast4: '6764', - placeOrder: false, - }).payment).toBe('saved-card'); - expect(validateCheckoutArgs({ quantity: 1, payment: 'upi' }).placeOrder).toBe(false); - expect(() => validateCheckoutArgs({ - quantity: 1, - payment: 'saved-card', - cardLast4: '', - })).toThrow(/card-last4/i); - expect(() => validateCheckoutArgs({ - quantity: 1, - payment: 'card-number', - })).toThrow(/upi, saved-card, new-card, or cod/i); - - const review = normalizeCheckoutReview({ - itemPriceText: '₹395', - deliveryFeeText: '₹40', - deliveryDiscountText: '-₹40', - marketplaceFeeText: '₹5', - totalText: '₹400', - quantity: 1, - }); - expect(review.deliveryFee).toBe(0); - expect(totalsAreConsistent(review)).toBe(true); - expect(totalsAreConsistent({ ...review, total: 500 })).toBe(false); - }); - - it('classifies checkout state without submitting', () => { - expect(classifyCheckoutSnapshot({ - url: 'https://www.amazon.in/aips/process-payment', - text: 'Complete your payment Payment of ₹ 400.00 QR code is valid', - })).toMatchObject({ status: 'awaiting_payment', payment_method: 'upi', total: 400 }); - expect(classifyCheckoutSnapshot({ - url: 'https://www.amazon.in/gp/buy/thankyou/handlers/display.html', - text: 'Order placed, thank you', - }).status).toBe('ordered'); - expect(classifyCheckoutSnapshot({ - url: 'https://www.amazon.in/checkout/p/example/spc', - text: 'Order Total: ₹400 Pay with UPI', - }).status).toBe('review_ready'); - expect(classifyCheckoutSnapshot({ - url: 'https://www.amazon.in/checkout/p/example/spc', - paymentText: 'Pay by scanning the QR code', - text: 'Order Total: ₹400 A UPI QR code will appear on the next page', - }).status).toBe('review_ready'); - expect(classifyCheckoutSnapshot({ - url: 'https://www.amazon.in/checkout/p/example/spc', - paymentText: 'Visa ending in 6764', - text: 'Order Total: ₹400 Other methods: Pay with UPI', - }).payment_method).toBe('card'); - expect(classifyCheckoutSnapshot({ - url: 'https://www.amazon.in/aips/process-payment', - paymentText: '', - text: 'Complete your payment Payment of ₹400 Scan the QR code', - }).payment_method).toBe('upi'); - }); - - it('hands secret entry to the browser before trying Continue', async () => { - const page = { - wait: () => { throw new Error('must not wait'); }, - click: () => { throw new Error('must not click Continue'); }, - evaluate: () => { throw new Error('must not inspect new-card secrets'); }, - }; - const row = await checkoutTest.continueAfterPaymentSelection( - page, - { payment: 'new-card' }, - { asin: 'B0D2QL339Z', title: 'Shirt', size: 'L', colour: 'Red' }, - 1, - ); - expect(row).toMatchObject({ status: 'action_required', payment_method: 'new-card' }); - - let clicks = 0; - const savedCardRow = await checkoutTest.continueAfterPaymentSelection({ - evaluate: async () => ({ needsSecret: true, continueEnabled: false }), - click: async () => { clicks += 1; }, - sleep: () => { throw new Error('must not sleep after finding CVV'); }, - }, { - payment: 'saved-card', - }, { - asin: 'B0D2QL339Z', - title: 'Shirt', - size: 'L', - colour: 'Red', - }, 1); - expect(savedCardRow).toMatchObject({ status: 'action_required', payment_method: 'saved-card' }); - expect(clicks).toBe(0); - }); - - it('requires one matching line item and never clicks Place Order by default', async () => { - expect(() => checkoutTest.assertSingleLineItem({ - itemCount: 2, - asin: 'B0D2QL339Z', - quantity: 1, - }, { - asin: 'B0D2QL339Z', - }, { - quantity: 1, - })).toThrow(/exactly one checkout line item/i); - - let placements = 0; - const page = { - evaluate: async () => { - placements += 1; - return { clicked: true, matches: 1 }; - }, - }; - expect(await checkoutTest.submitOrder(page, false)).toBe(false); - expect(placements).toBe(0); - expect(await checkoutTest.submitOrder(page, true)).toBe(true); - expect(placements).toBe(1); - }); - - it('does not add an item when Amazon fails to select the requested variant', async () => { - let variantRead = 0; - const page = { - goto: async () => {}, - wait: async () => {}, - sleep: async () => {}, - getCookies: async () => [{ name: 'x-acbin' }], - evaluateWithArgs: async () => { - variantRead += 1; - return variantRead === 1 ? { changed: true, matches: 1 } : 'Red'; - }, - evaluate: async (script) => { - if (script.includes('confirmation:')) { - return { confirmation: true, text: 'Added to cart' }; - } - if (script.includes('document.body?.innerText')) { - return { url: 'https://www.amazon.in/dp/B0D2QL339Z', text: 'Product page' }; - } - if (script.includes('location.pathname.match')) { - return { asin: 'B0D2QL339Z', title: 'Shirt' }; - } - return undefined; - }, - }; - const command = getRegistry().get('amazon-in/cart-add'); - - await expect(command.func(page, { - input: 'B0D2QL339Z', - colour: 'Blue', - })).rejects.toThrow(/did not select color "Blue"/i); - }); -}); diff --git a/plugins/amazon-in/webcmd-plugin.json b/plugins/amazon-in/webcmd-plugin.json deleted file mode 100644 index 200f5055..00000000 --- a/plugins/amazon-in/webcmd-plugin.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "amazon-in", - "version": "0.1.0", - "description": "Webcmd commands for amazon-in", - "webcmd": ">=0.5.3", - "author": { - "name": "WebCMD Agent", - "handle": "agentrhq" - } -} diff --git a/plugins/amazon-in/wishlist.js b/plugins/amazon-in/wishlist.js deleted file mode 100644 index be085b9f..00000000 --- a/plugins/amazon-in/wishlist.js +++ /dev/null @@ -1,98 +0,0 @@ -import { - CommandExecutionError, - EmptyResultError, - TimeoutError, -} from '@agentrhq/webcmd/errors'; -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { normalizeWishlistRows } from './parsers.js'; -import { gotoAmazon, SITE, WISHLIST_URL } from './shared.js'; - -cli({ - site: SITE, - name: 'wishlist', - access: 'read', - description: 'Fetch current prices for products in the default Amazon.in wishlist', - domain: 'amazon.in', - strategy: Strategy.UI, - browser: true, - navigateBefore: false, - siteSession: 'persistent', - args: [ - { - name: 'filter', - default: 'unpurchased', - choices: ['unpurchased', 'all'], - help: 'Wishlist items to include', - }, - ], - columns: [ - 'list_name', 'item_id', 'asin', 'title', 'price', 'mrp', - 'availability', 'size', 'colour', 'image_url', 'product_url', - ], - func: async (page, args) => { - const url = new URL(WISHLIST_URL); - url.searchParams.set('type', 'wishlist'); - url.searchParams.set('filter', args.filter ?? 'unpurchased'); - url.searchParams.set('sort', 'date-added'); - url.searchParams.set('viewType', 'list'); - await gotoAmazon(page, url.href, 'wishlist'); - - let reachedEnd = false; - for (let step = 0; step < 100; step += 1) { - reachedEnd = await page.evaluate(` - (() => Boolean(document.querySelector('#endOfListMarker')))() - `); - if (reachedEnd) break; - await page.scroll('down', 700); - await page.sleep(0.25); - } - if (!reachedEnd) { - throw new TimeoutError( - 'Amazon.in wishlist loading', - 25, - 'Open the wishlist in the Webcmd browser and check whether Amazon is still loading items.', - ); - } - - const payload = await page.evaluate(` - (() => { - const text = (node) => (node?.textContent || '').replace(/\\s+/g, ' ').trim(); - const cards = [...document.querySelectorAll('#g-items li.g-item-sortable')].map((card) => { - const productLinks = [...card.querySelectorAll('a[href*="/dp/"]')]; - const productLink = productLinks.find((link) => link.title) || productLinks[0]; - const variants = [...card.querySelectorAll('#twisterText')].map((node) => text(node)); - return { - cardItemId: card.getAttribute('data-itemid') || '', - cardHref: productLink?.href || '', - cardTitle: productLink?.title || text(productLink), - cardPriceText: text(card.querySelector('.price-section .a-price .a-offscreen, .a-price .a-offscreen')), - cardMrpText: text(card.querySelector('.wl-deal-price.a-text-strike, .a-price.a-text-price .a-offscreen')), - cardAvailabilityText: text(card.querySelector('[id^="availability-"], .itemAvailability, .a-color-price')), - cardSizeText: variants.find((value) => /^size\\s*:/i.test(value))?.replace(/^size\\s*:\\s*/i, '') || '', - cardColourText: variants.find((value) => /^colou?r\\s*:/i.test(value))?.replace(/^colou?r\\s*:\\s*/i, '') || '', - cardImageUrl: card.querySelector('img[alt]')?.currentSrc || card.querySelector('img[alt]')?.src || '', - }; - }); - return { - listName: text(document.querySelector('#profile-list-name')), - cards, - empty: /no items|this list is empty/i.test(document.body?.innerText || ''), - }; - })() - `); - if (!payload?.listName) { - throw new CommandExecutionError('Amazon.in wishlist name could not be read'); - } - if (!payload.cards?.length) { - if (payload.empty) throw new EmptyResultError('amazon-in wishlist'); - throw new CommandExecutionError('Amazon.in wishlist exposed no item cards'); - } - try { - return normalizeWishlistRows(payload.listName, payload.cards); - } catch (error) { - throw new CommandExecutionError( - `Amazon.in wishlist details could not be normalized: ${error.message}`, - ); - } - }, -}); diff --git a/plugins/amazon/README.md b/plugins/amazon/README.md deleted file mode 100644 index eddebe68..00000000 --- a/plugins/amazon/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# webcmd-plugin-amazon - -Webcmd commands for amazon. - -## Install - -```bash -webcmd plugin install github:agentrhq/webcmd/amazon -``` - -## Commands - -| Command | Description | -| --- | --- | -| `webcmd amazon bestsellers` | Amazon Best Sellers pages for category candidate discovery | -| `webcmd amazon discussion` | Amazon review summary and sample customer discussion from product review pages | -| `webcmd amazon login` | Open amazon login | -| `webcmd amazon movers-shakers` | Amazon Movers & Shakers pages for short-term growth signals | -| `webcmd amazon new-releases` | Amazon New Releases pages for early momentum discovery | -| `webcmd amazon offer` | Amazon seller, buy box, and fulfillment facts from the product page | -| `webcmd amazon product` | Amazon product page facts for candidate validation | -| `webcmd amazon search` | Amazon search results for product discovery and coarse filtering | -| `webcmd amazon whoami` | Show the current logged-in amazon account | - -## Notes - -- A product or review URL from a sibling marketplace (`amazon.co.uk`, `amazon.de`, `amazon.com.au`, …) is read on that marketplace, and the emitted URLs stay on it. A bare ASIN still defaults to `amazon.com`. diff --git a/plugins/amazon/auth.js b/plugins/amazon/auth.js deleted file mode 100644 index 88650505..00000000 --- a/plugins/amazon/auth.js +++ /dev/null @@ -1,47 +0,0 @@ -import { AuthRequiredError, CommandExecutionError } from '@agentrhq/webcmd/errors'; -import { registerSiteAuthCommands } from '@agentrhq/webcmd/plugin-runtime'; - -async function hasAmazonSessionCookies(page) { - const cookies = await page.getCookies({ url: 'https://www.amazon.com' }); - const names = new Set(cookies.map(c => c.name)); - return names.has('at-main') || names.has('x-main'); -} - -async function verifyAmazonIdentity(page) { - if (!await hasAmazonSessionCookies(page)) { - throw new AuthRequiredError('amazon.com', 'Amazon auth cookies (at-main / x-main) are missing'); - } - await page.goto('https://www.amazon.com/', { waitUntil: 'load' }); - await page.wait(3); - const probe = await page.evaluate(` - (() => { - const navLink = document.querySelector('#nav-link-accountList'); - if (!navLink) { - return { kind: 'auth', detail: 'Amazon header missing nav-link-accountList — layout changed or robot challenge' }; - } - const greeting = (navLink.querySelector('.nav-line-1, #nav-link-accountList-nav-line-1') || {}).textContent || ''; - const trimmed = greeting.trim(); - if (/sign\\s*in/i.test(trimmed)) { - return { kind: 'auth', detail: 'Amazon header shows "Hello, sign in" — anonymous' }; - } - const m = trimmed.match(/^Hello,?\\s+(.+)$/i); - const name = m ? m[1].trim() : ''; - if (!name) { - return { kind: 'auth', detail: 'Amazon greeting unparseable: ' + trimmed }; - } - return { ok: true, user_name: name }; - })() - `); - if (probe?.kind === 'auth') throw new AuthRequiredError('amazon.com', probe.detail); - if (!probe?.ok) throw new CommandExecutionError(`Unexpected Amazon probe: ${JSON.stringify(probe)}`); - return { user_name: probe.user_name }; -} - -registerSiteAuthCommands({ - site: 'amazon', - domain: 'amazon.com', - loginUrl: 'https://www.amazon.com/ap/signin?openid.return_to=https%3A%2F%2Fwww.amazon.com%2F&openid.identity=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0%2Fidentifier_select&openid.assoc_handle=usflex&openid.mode=checkid_setup&openid.claimed_id=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0%2Fidentifier_select&openid.ns=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0', - columns: ['user_name'], - quickCheck: hasAmazonSessionCookies, - verify: verifyAmazonIdentity, -}); diff --git a/plugins/amazon/bestsellers.js b/plugins/amazon/bestsellers.js deleted file mode 100644 index 06f77a7a..00000000 --- a/plugins/amazon/bestsellers.js +++ /dev/null @@ -1,8 +0,0 @@ -import { cli } from '@agentrhq/webcmd/registry'; -import { createRankingCliOptions } from './rankings.js'; -cli(createRankingCliOptions({ - commandName: 'bestsellers', - access: 'read', - listType: 'bestsellers', - description: 'Amazon Best Sellers pages for category candidate discovery', -})); diff --git a/plugins/amazon/discussion.js b/plugins/amazon/discussion.js deleted file mode 100644 index de2b00f9..00000000 --- a/plugins/amazon/discussion.js +++ /dev/null @@ -1,124 +0,0 @@ -import { AuthRequiredError, CommandExecutionError } from '@agentrhq/webcmd/errors'; -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { DOMAIN, amazonHostFromInput, buildProductUrl, buildDiscussionUrl, buildProvenance, cleanText, extractAsin, normalizeProductUrl, parseRatingValue, parseReviewCount, trimRatingPrefix, uniqueNonEmpty, assertUsableState, gotoAndReadState, } from './shared.js'; -function normalizeDiscussionPayload(payload) { - const sourceUrl = cleanText(payload.href) || buildDiscussionUrl(payload.href ?? ''); - const asin = extractAsin(payload.href ?? '') ?? null; - const averageRatingText = cleanText(payload.average_rating_text) || null; - const totalReviewCountText = cleanText(payload.total_review_count_text) || null; - const provenance = buildProvenance(sourceUrl); - return { - asin, - product_url: asin ? normalizeProductUrl(sourceUrl) : null, - discussion_url: sourceUrl, - ...provenance, - average_rating_text: averageRatingText, - average_rating_value: parseRatingValue(averageRatingText), - total_review_count_text: totalReviewCountText, - total_review_count: parseReviewCount(totalReviewCountText), - qa_urls: uniqueNonEmpty(payload.qa_links ?? []), - review_samples: (payload.review_samples ?? []).map((sample) => ({ - title: trimRatingPrefix(sample.title) || null, - rating_text: cleanText(sample.rating_text) || null, - rating_value: parseRatingValue(sample.rating_text), - author: cleanText(sample.author) || null, - date_text: cleanText(sample.date_text) || null, - body: cleanText(sample.body) || null, - verified_purchase: sample.verified === true, - })), - }; -} -function hasDiscussionSummary(payload) { - return Boolean(cleanText(payload.average_rating_text) || cleanText(payload.total_review_count_text)); -} -function isSignInState(state) { - const href = cleanText(state.href).toLowerCase(); - const title = cleanText(state.title).toLowerCase(); - return href.includes('/ap/signin') - || title.includes('amazon sign-in'); -} -async function readCurrentDiscussionPayload(page, limit) { - return await page.evaluate(` - (() => ({ - href: window.location.href, - title: document.title || '', - average_rating_text: document.querySelector('[data-hook="rating-out-of-text"]')?.textContent || '', - total_review_count_text: document.querySelector('[data-hook="total-review-count"]')?.textContent || '', - qa_links: Array.from(document.querySelectorAll('a[href*="ask/questions"]')).map((anchor) => anchor.href || ''), - review_samples: Array.from(document.querySelectorAll('[data-hook="review"]')).slice(0, ${limit}).map((card) => ({ - title: card.querySelector('[data-hook="review-title"]')?.textContent || '', - rating_text: - card.querySelector('[data-hook="review-star-rating"]')?.textContent - || card.querySelector('[data-hook="cmps-review-star-rating"]')?.textContent - || '', - author: card.querySelector('.a-profile-name')?.textContent || '', - date_text: card.querySelector('[data-hook="review-date"]')?.textContent || '', - body: card.querySelector('[data-hook="review-body"]')?.textContent || '', - verified: !!card.querySelector('[data-hook="avp-badge"]'), - })), - }))() - `); -} -async function readDiscussionPayload(page, input, limit) { - const reviewUrl = buildDiscussionUrl(input); - const reviewState = await gotoAndReadState(page, reviewUrl, 2500, 'discussion'); - assertUsableState(reviewState, 'discussion'); - const reviewPayload = await readCurrentDiscussionPayload(page, limit); - if (hasDiscussionSummary(reviewPayload)) { - return reviewPayload; - } - const productUrl = buildProductUrl(input); - const productState = await gotoAndReadState(page, productUrl, 2500, 'discussion'); - assertUsableState(productState, 'discussion'); - if (isSignInState(reviewState) && isSignInState(productState)) { - throw new AuthRequiredError(amazonHostFromInput(input) ?? DOMAIN, 'Amazon review discussion requires an active signed-in Amazon session in the shared Chrome profile.'); - } - const productPayload = await readCurrentDiscussionPayload(page, limit); - if (hasDiscussionSummary(productPayload)) { - return productPayload; - } - if (isSignInState(reviewState)) { - throw new CommandExecutionError('amazon review page redirected to sign-in and product page fallback did not expose review summary', 'Open the product page in Chrome, verify reviews are visible, and retry.'); - } - return reviewPayload; -} -cli({ - site: 'amazon', - name: 'discussion', - access: 'read', - description: 'Amazon review summary and sample customer discussion from product review pages', - domain: 'amazon.com', - strategy: Strategy.COOKIE, - navigateBefore: false, - args: [ - { - name: 'input', - required: true, - positional: true, - help: 'ASIN or product URL, for example B0FJS72893', - }, - { - name: 'limit', - type: 'int', - default: 10, - help: 'Maximum number of review samples to return (default 10)', - }, - ], - columns: ['asin', 'average_rating_value', 'total_review_count'], - func: async (page, kwargs) => { - const input = String(kwargs.input ?? ''); - const limit = Math.max(1, Number(kwargs.limit) || 10); - const payload = await readDiscussionPayload(page, input, limit); - const normalized = normalizeDiscussionPayload(payload); - if (!normalized.average_rating_text && !normalized.total_review_count_text) { - const landedUrl = cleanText(payload.href) || buildDiscussionUrl(input); - throw new CommandExecutionError(`amazon discussion page did not expose review summary (landed on ${landedUrl})`, 'The review page may have changed or hit a robot check. Open the review page in Chrome and retry.'); - } - return [normalized]; - }, -}); -export const __test__ = { - normalizeDiscussionPayload, - hasDiscussionSummary, - isSignInState, -}; diff --git a/plugins/amazon/movers-shakers.js b/plugins/amazon/movers-shakers.js deleted file mode 100644 index 004d8f7b..00000000 --- a/plugins/amazon/movers-shakers.js +++ /dev/null @@ -1,8 +0,0 @@ -import { cli } from '@agentrhq/webcmd/registry'; -import { createRankingCliOptions } from './rankings.js'; -cli(createRankingCliOptions({ - commandName: 'movers-shakers', - access: 'read', - listType: 'movers_shakers', - description: 'Amazon Movers & Shakers pages for short-term growth signals', -})); diff --git a/plugins/amazon/new-releases.js b/plugins/amazon/new-releases.js deleted file mode 100644 index b6f6d7a5..00000000 --- a/plugins/amazon/new-releases.js +++ /dev/null @@ -1,8 +0,0 @@ -import { cli } from '@agentrhq/webcmd/registry'; -import { createRankingCliOptions } from './rankings.js'; -cli(createRankingCliOptions({ - commandName: 'new-releases', - access: 'read', - listType: 'new_releases', - description: 'Amazon New Releases pages for early momentum discovery', -})); diff --git a/plugins/amazon/offer.js b/plugins/amazon/offer.js deleted file mode 100644 index 1c49fa4f..00000000 --- a/plugins/amazon/offer.js +++ /dev/null @@ -1,141 +0,0 @@ -import { CommandExecutionError } from '@agentrhq/webcmd/errors'; -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { buildProductUrl, buildProvenance, cleanText, extractAsin, isAmazonEntity, normalizeProductUrl, PRIMARY_PRICE_SELECTORS, parsePriceText, assertUsableState, gotoAndReadState, } from './shared.js'; -const OFFER_FACT_SELECTOR = [ - '#sellerProfileTriggerId', - '#shipsFromSoldByInsideBuyBox_feature_div', - '#fulfillerInfoFeature_feature_div', - '#merchantInfoFeature_feature_div', - '#tabular-buybox-container', - '#merchant-info', -].join(', '); -function collapseAdjacentWords(text) { - const parts = cleanText(text).split(' ').filter(Boolean); - const deduped = []; - for (const part of parts) { - if (deduped[deduped.length - 1] === part) - continue; - deduped.push(part); - } - return deduped.join(' '); -} -function extractShipsFrom(text) { - const normalized = cleanText(text); - const match = normalized.match(/Ships from\s+(.+?)(?=Sold by|and Fulfilled by|$)/i); - return match ? collapseAdjacentWords(match[1].replace(/Ships from/ig, '')) : null; -} -function extractSoldBy(text) { - const normalized = cleanText(text); - const match = normalized.match(/Sold by\s+(.+?)(?=and Fulfilled by|Ships from|$)/i); - return match ? collapseAdjacentWords(match[1]) : null; -} -function isDeliveryLocationBlocked(text) { - const normalized = cleanText(text).toLowerCase(); - return normalized.includes('cannot be shipped to your selected delivery location') - || normalized.includes('similar items shipping to') - || normalized.includes('deliver to hong kong'); -} -function normalizeOfferPayload(payload) { - const asin = extractAsin(payload.href ?? '') ?? null; - const sourceUrl = cleanText(payload.href) || buildProductUrl(payload.href ?? ''); - const price = parsePriceText(payload.price_text); - const merchantInfo = cleanText(payload.merchant_info) || null; - const soldBy = cleanText(payload.sold_by) - || extractSoldBy(payload.ships_from_text ?? '') - || extractSoldBy(merchantInfo ?? '') - || null; - const shipsFrom = extractShipsFrom(payload.ships_from_text ?? '') - || extractShipsFrom(merchantInfo ?? '') - || cleanText(payload.ships_from_text) - || null; - const provenance = buildProvenance(sourceUrl); - return { - asin, - product_url: normalizeProductUrl(payload.href), - ...provenance, - price_text: price.price_text, - price_value: price.price_value, - currency: price.currency, - merchant_info_text: merchantInfo, - sold_by: soldBy, - ships_from: shipsFrom, - offer_listing_url: cleanText(payload.offer_link) || null, - review_url: cleanText(payload.review_url) || null, - qa_url: cleanText(payload.qa_url) || null, - is_amazon_sold: isAmazonEntity(soldBy), - is_amazon_fulfilled: isAmazonEntity(shipsFrom) || /fulfilled by amazon/i.test(merchantInfo ?? ''), - }; -} -async function readOfferPayload(page, input) { - const url = buildProductUrl(input); - const state = await gotoAndReadState(page, url, 2500, 'offer'); - assertUsableState(state, 'offer'); - // Reconnecting to an existing Amazon target can surface the product page - // before the buy-box / merchant blocks are reattached to the DOM. - await page.wait({ selector: OFFER_FACT_SELECTOR, timeout: 6 }).catch(() => { }); - return await page.evaluate(` - (() => ({ - href: window.location.href, - title: document.title || '', - price_text: (() => { - const selectors = ${JSON.stringify(PRIMARY_PRICE_SELECTORS)}; - for (const selector of selectors) { - const text = document.querySelector(selector)?.textContent || ''; - if (text.trim()) return text; - } - return ''; - })(), - merchant_info: document.querySelector('#merchant-info')?.textContent || '', - sold_by: document.querySelector('#sellerProfileTriggerId')?.textContent || '', - ships_from_text: - document.querySelector('#shipsFromSoldByInsideBuyBox_feature_div')?.textContent - || document.querySelector('#fulfillerInfoFeature_feature_div')?.textContent - || document.querySelector('#merchantInfoFeature_feature_div')?.textContent - || document.querySelector('#tabular-buybox-container')?.textContent - || '', - offer_link: document.querySelector('a[href*="/gp/offer-listing/"]')?.href || '', - review_url: document.querySelector('a[href*="#customerReviews"]')?.href || '', - qa_url: document.querySelector('a[href*="ask/questions"]')?.href || '', - buybox_text: - document.querySelector('#desktop_qualifiedBuyBox')?.textContent - || document.querySelector('#buybox')?.textContent - || '', - }))() - `); -} -cli({ - site: 'amazon', - name: 'offer', - access: 'read', - description: 'Amazon seller, buy box, and fulfillment facts from the product page', - domain: 'amazon.com', - strategy: Strategy.COOKIE, - navigateBefore: false, - args: [ - { - name: 'input', - required: true, - positional: true, - help: 'ASIN or product URL, for example B0FJS72893', - }, - ], - columns: ['asin', 'price_text', 'sold_by', 'ships_from', 'is_amazon_sold', 'is_amazon_fulfilled'], - func: async (page, kwargs) => { - const input = String(kwargs.input ?? ''); - const payload = await readOfferPayload(page, input); - const normalized = normalizeOfferPayload(payload); - if (!normalized.sold_by && !normalized.ships_from && !normalized.merchant_info_text) { - if (isDeliveryLocationBlocked(payload.buybox_text)) { - throw new CommandExecutionError('amazon offer buy box is blocked by the current delivery location', 'The shared Chrome profile is not set to the target US delivery address. Switch Amazon delivery location to the requested US destination, reopen the product page, and retry.'); - } - throw new CommandExecutionError('amazon offer surface did not expose seller or fulfillment facts', 'The product page may have changed. Open the product page in Chrome, make sure the buy box is visible, and retry.'); - } - return [normalized]; - }, -}); -export const __test__ = { - extractShipsFrom, - extractSoldBy, - isDeliveryLocationBlocked, - normalizeOfferPayload, -}; diff --git a/plugins/amazon/package.json b/plugins/amazon/package.json deleted file mode 100644 index 4ac6204e..00000000 --- a/plugins/amazon/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "webcmd-plugin-amazon", - "version": "0.1.0", - "type": "module", - "description": "Webcmd commands for amazon", - "peerDependencies": { - "@agentrhq/webcmd": ">=0.5.3" - } -} diff --git a/plugins/amazon/product.js b/plugins/amazon/product.js deleted file mode 100644 index 58313452..00000000 --- a/plugins/amazon/product.js +++ /dev/null @@ -1,94 +0,0 @@ -import { CommandExecutionError } from '@agentrhq/webcmd/errors'; -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { buildProductUrl, buildProvenance, cleanText, extractAsin, PRIMARY_PRICE_SELECTORS, parsePriceText, parseRatingValue, parseReviewCount, normalizeProductUrl, uniqueNonEmpty, assertUsableState, gotoAndReadState, } from './shared.js'; -const PRODUCT_TITLE_SELECTOR = '#productTitle, #title span, [data-feature-name="title"] h1 span'; -const BYLINE_SELECTOR = '#bylineInfo, [data-feature-name="bylineInfo"] #bylineInfo'; -function normalizeProductPayload(payload) { - const sourceUrl = cleanText(payload.href) || buildProductUrl(cleanText(payload.product_title) || cleanText(payload.href)); - const asin = extractAsin(payload.href ?? '') ?? null; - const price = parsePriceText(payload.price_text); - const ratingText = cleanText(payload.rating_text) || null; - const reviewCountText = cleanText(payload.review_count_text) || null; - const provenance = buildProvenance(sourceUrl); - return { - asin, - title: cleanText(payload.product_title) || cleanText(payload.title) || null, - product_url: normalizeProductUrl(payload.href), - ...provenance, - brand_text: cleanText(payload.byline) || null, - price_text: price.price_text, - price_value: price.price_value, - currency: price.currency, - rating_text: ratingText, - rating_value: parseRatingValue(ratingText), - review_count_text: reviewCountText, - review_count: parseReviewCount(reviewCountText), - review_url: cleanText(payload.review_url) || null, - qa_url: cleanText(payload.qa_url) || null, - breadcrumbs: uniqueNonEmpty(payload.breadcrumbs ?? []), - bullet_points: uniqueNonEmpty(payload.bullets ?? []), - }; -} -async function readProductPayload(page, input) { - const url = buildProductUrl(input); - const state = await gotoAndReadState(page, url, 2500, 'product'); - assertUsableState(state, 'product'); - // Amazon can report a "stable" DOM before the product title block hydrates, - // especially when reconnecting to an existing shared CDP target. - await page.wait({ selector: PRODUCT_TITLE_SELECTOR, timeout: 6 }).catch(() => { }); - return await page.evaluate(` - (() => ({ - href: window.location.href, - title: document.title || '', - product_title: document.querySelector(${JSON.stringify(PRODUCT_TITLE_SELECTOR)})?.textContent || '', - byline: document.querySelector(${JSON.stringify(BYLINE_SELECTOR)})?.textContent || '', - price_text: (() => { - const selectors = ${JSON.stringify(PRIMARY_PRICE_SELECTORS)}; - for (const selector of selectors) { - const text = document.querySelector(selector)?.textContent || ''; - if (text.trim()) return text; - } - return ''; - })(), - rating_text: - document.querySelector('#acrPopover')?.getAttribute('title') - || document.querySelector('#acrPopover')?.textContent - || '', - review_count_text: document.querySelector('#acrCustomerReviewText')?.textContent || '', - review_url: document.querySelector('a[href*="#customerReviews"]')?.href || '', - qa_url: document.querySelector('a[href*="ask/questions"]')?.href || '', - bullets: Array.from(document.querySelectorAll('#feature-bullets li .a-list-item')).map((node) => node.textContent || ''), - breadcrumbs: Array.from(document.querySelectorAll('#wayfinding-breadcrumbs_feature_div a')).map((node) => node.textContent || ''), - }))() - `); -} -cli({ - site: 'amazon', - name: 'product', - access: 'read', - description: 'Amazon product page facts for candidate validation', - domain: 'amazon.com', - strategy: Strategy.COOKIE, - navigateBefore: false, - args: [ - { - name: 'input', - required: true, - positional: true, - help: 'ASIN or product URL, for example B0FJS72893', - }, - ], - columns: ['asin', 'title', 'price_text', 'rating_value', 'review_count'], - func: async (page, kwargs) => { - const input = String(kwargs.input ?? ''); - const payload = await readProductPayload(page, input); - if (!cleanText(payload.product_title)) { - const landedUrl = cleanText(payload.href) || buildProductUrl(input); - throw new CommandExecutionError(`amazon product page did not expose product content (landed on ${landedUrl})`, 'The product page may have changed or hit a robot check. Open the product page in Chrome and retry.'); - } - return [normalizeProductPayload(payload)]; - }, -}); -export const __test__ = { - normalizeProductPayload, -}; diff --git a/plugins/amazon/rankings.js b/plugins/amazon/rankings.js deleted file mode 100644 index 8387d962..00000000 --- a/plugins/amazon/rankings.js +++ /dev/null @@ -1,227 +0,0 @@ -import { CommandExecutionError } from '@agentrhq/webcmd/errors'; -import { Strategy } from '@agentrhq/webcmd/registry'; -import { assertUsableState, buildProvenance, cleanText, extractAsin, extractCategoryNodeId, extractReviewCountFromCardText, firstMeaningfulLine, gotoAndReadState, isRankingPaginationUrl, normalizeProductUrl, parsePriceText, parseRatingValue, parseReviewCount, resolveRankingUrl, toAbsoluteAmazonUrl, uniqueNonEmpty, } from './shared.js'; -function parseRank(rawRank, fallback) { - const normalized = cleanText(rawRank); - const match = normalized.match(/(\d{1,4})/); - if (!match) - return fallback; - const parsed = Number.parseInt(match[1], 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; -} -function normalizeVisibleCategoryLinks(links) { - const normalized = (links ?? []) - .map((entry) => ({ - title: cleanText(entry?.title), - url: toAbsoluteAmazonUrl(entry?.url) ?? '', - node_id: cleanText(entry?.node_id) || extractCategoryNodeId(entry?.url) || null, - })) - .filter((entry) => Boolean(entry.title) && Boolean(entry.url)); - const seen = new Set(); - const deduped = []; - for (const entry of normalized) { - if (seen.has(entry.url)) - continue; - seen.add(entry.url); - deduped.push(entry); - } - return deduped; -} -export function normalizeRankingCandidate(candidate, context) { - const productUrl = normalizeProductUrl(candidate.href); - const asin = extractAsin(candidate.asin ?? '') ?? extractAsin(productUrl ?? '') ?? null; - const title = cleanText(candidate.title) || firstMeaningfulLine(candidate.card_text); - const price = parsePriceText(cleanText(candidate.price_text) || candidate.card_text); - const ratingText = cleanText(candidate.rating_text) || null; - const reviewCountText = cleanText(candidate.review_count_text) - || extractReviewCountFromCardText(candidate.card_text) - || null; - const provenance = buildProvenance(context.sourceUrl); - const categoryUrl = context.categoryUrl || context.sourceUrl; - return { - list_type: context.listType, - rank: parseRank(candidate.rank_text, context.rankFallback), - asin, - title: title || null, - product_url: productUrl, - price_text: price.price_text, - price_value: price.price_value, - currency: price.currency, - rating_text: ratingText, - rating_value: parseRatingValue(ratingText), - review_count_text: reviewCountText, - review_count: parseReviewCount(reviewCountText), - list_title: context.listTitle, - category_title: context.categoryTitle, - category_url: categoryUrl, - category_node_id: extractCategoryNodeId(categoryUrl), - category_path: context.categoryPath, - visible_category_links: context.visibleCategoryLinks, - ...provenance, - }; -} -async function readRankingPage(page, listType, url) { - const state = await gotoAndReadState(page, url, 2500, listType); - assertUsableState(state, listType); - return await page.evaluate(` - (() => ({ - href: window.location.href, - title: document.title || '', - list_title: - document.querySelector('#zg_banner_text')?.textContent - || document.querySelector('h1')?.textContent - || '', - category_title: - document.querySelector('#zg_browseRoot .zg_selected')?.textContent - || document.querySelector('#wayfinding-breadcrumbs_feature_div ul li:last-child')?.textContent - || document.querySelector('#wayfinding-breadcrumbs_container ul li:last-child')?.textContent - || '', - category_path: Array.from(document.querySelectorAll( - '#zg_browseRoot ul li a, #zg_browseRoot ul li span, ' + - '#wayfinding-breadcrumbs_feature_div ul li a, #wayfinding-breadcrumbs_feature_div ul li span.a-list-item, ' + - '#wayfinding-breadcrumbs_container ul li a, #wayfinding-breadcrumbs_container ul li span.a-list-item' - )) - .map((entry) => (entry.textContent || '').trim()) - .filter(Boolean), - cards: Array.from(document.querySelectorAll( - '.p13n-sc-uncoverable-faceout, .zg-grid-general-faceout, [data-asin][class*="p13n"]' - )).map((card) => ({ - rank_text: - card.querySelector('.zg-bdg-text')?.textContent - || card.querySelector('[class*="rank"]')?.textContent - || '', - asin: - card.getAttribute('data-asin') - || card.getAttribute('id') - || '', - title: - card.querySelector('[class*="line-clamp"]')?.textContent - || card.querySelector('img')?.getAttribute('alt') - || card.querySelector('a[href*="/dp/"]')?.textContent - || '', - href: - card.querySelector('a[href*="/dp/"], a[href*="/gp/product/"]')?.href - || '', - price_text: - card.querySelector('.a-price .a-offscreen')?.textContent - || card.querySelector('.a-color-price')?.textContent - || '', - rating_text: - card.querySelector('[aria-label*="out of 5 stars"]')?.getAttribute('aria-label') - || '', - review_count_text: - card.querySelector('a[href*="#customerReviews"]')?.textContent - || card.querySelector('.a-size-small')?.textContent - || '', - card_text: card.innerText || '', - })), - page_links: Array.from(document.querySelectorAll('.a-pagination a[href], li.a-normal a[href], li.a-selected a[href]')) - .map((anchor) => anchor.href || '') - .filter(Boolean), - visible_category_links: Array.from(document.querySelectorAll( - '#zg_browseRoot a[href], #zg-left-col a[href], [class*="zg-browse"] a[href]' - )).map((anchor) => ({ - title: (anchor.textContent || '').trim(), - url: anchor.href || '', - node_id: - anchor.getAttribute('data-node-id') - || anchor.dataset?.nodeid - || '', - })) - .filter((entry) => entry.title && entry.url), - }))() - `); -} -function createEmptyResultHint(commandName) { - return [ - `Open the same Amazon ${commandName} page in shared Chrome and verify ranked items are visible.`, - 'If the page shows a robot check, clear it manually and retry.', - ].join(' '); -} -export function createRankingCliOptions(definition) { - return { - site: 'amazon', - name: definition.commandName, - access: definition.access ?? 'read', - description: definition.description, - domain: 'amazon.com', - strategy: Strategy.COOKIE, - navigateBefore: false, - args: [ - { - name: 'input', - positional: true, - help: 'Ranking URL or supported Amazon path. Omit to use the list root.', - }, - { - name: 'limit', - type: 'int', - default: 100, - help: 'Maximum number of ranked items to return (default 100)', - }, - ], - columns: ['list_type', 'rank', 'asin', 'title', 'price_text', 'rating_value', 'review_count'], - func: async (page, kwargs) => { - const limit = Math.max(1, Number(kwargs.limit) || 100); - const initialUrl = resolveRankingUrl(definition.listType, typeof kwargs.input === 'string' ? kwargs.input : undefined); - const queue = [initialUrl]; - const visited = new Set(); - const seenEntityKeys = new Set(); - const results = []; - let listTitle = null; - while (queue.length > 0 && results.length < limit) { - const nextUrl = queue.shift(); - if (visited.has(nextUrl)) - continue; - visited.add(nextUrl); - const payload = await readRankingPage(page, definition.listType, nextUrl); - const sourceUrl = cleanText(payload.href) || nextUrl; - listTitle = cleanText(payload.list_title) || cleanText(payload.title) || listTitle; - const categoryPath = uniqueNonEmpty(payload.category_path ?? []); - const categoryTitle = cleanText(payload.category_title) - || (categoryPath.length > 0 ? categoryPath[categoryPath.length - 1] : ''); - const visibleCategoryLinks = normalizeVisibleCategoryLinks(payload.visible_category_links); - const cards = payload.cards ?? []; - for (const card of cards) { - const normalized = normalizeRankingCandidate(card, { - listType: definition.listType, - rankFallback: results.length + 1, - listTitle, - sourceUrl, - categoryTitle: categoryTitle || null, - categoryUrl: sourceUrl, - categoryPath, - visibleCategoryLinks, - }); - const dedupeKey = cleanText(String(normalized.asin ?? '')) - || cleanText(String(normalized.product_url ?? '')); - if (dedupeKey && seenEntityKeys.has(dedupeKey)) - continue; - if (dedupeKey) - seenEntityKeys.add(dedupeKey); - results.push(normalized); - if (results.length >= limit) - break; - } - const pageLinks = uniqueNonEmpty(payload.page_links ?? []); - for (const href of pageLinks) { - const absolute = toAbsoluteAmazonUrl(href); - if (!absolute || !isRankingPaginationUrl(definition.listType, absolute)) - continue; - if (!visited.has(absolute) && !queue.includes(absolute)) { - queue.push(absolute); - } - } - } - if (results.length === 0) { - throw new CommandExecutionError(`amazon ${definition.commandName} did not expose any ranked items`, createEmptyResultHint(definition.commandName)); - } - return results.slice(0, limit); - }, - }; -} -export const __test__ = { - parseRank, - normalizeVisibleCategoryLinks, - normalizeRankingCandidate, -}; diff --git a/plugins/amazon/search.js b/plugins/amazon/search.js deleted file mode 100644 index 324f25fd..00000000 --- a/plugins/amazon/search.js +++ /dev/null @@ -1,89 +0,0 @@ -import { CommandExecutionError } from '@agentrhq/webcmd/errors'; -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { buildProvenance, buildSearchUrl, cleanText, extractAsin, normalizeProductUrl, parsePriceText, parseRatingValue, parseReviewCount, assertUsableState, gotoAndReadState, } from './shared.js'; -function normalizeSearchCandidate(candidate, rank, sourceUrl) { - const productUrl = normalizeProductUrl(candidate.href); - const asin = extractAsin(candidate.asin ?? '') ?? extractAsin(productUrl ?? '') ?? null; - const price = parsePriceText(candidate.price_text); - const ratingText = cleanText(candidate.rating_text) || null; - const reviewCountText = cleanText(candidate.review_count_text) || null; - const provenance = buildProvenance(sourceUrl); - return { - rank, - asin, - title: cleanText(candidate.title) || null, - product_url: productUrl, - ...provenance, - price_text: price.price_text, - price_value: price.price_value, - currency: price.currency, - rating_text: ratingText, - rating_value: parseRatingValue(ratingText), - review_count_text: reviewCountText, - review_count: parseReviewCount(reviewCountText), - is_sponsored: candidate.sponsored === true, - badges: (candidate.badge_texts ?? []).map((value) => cleanText(value)).filter(Boolean), - }; -} -async function readSearchPayload(page, query) { - const url = buildSearchUrl(query); - const state = await gotoAndReadState(page, url, 2500, 'search'); - assertUsableState(state, 'search'); - return await page.evaluate(` - (() => ({ - href: window.location.href, - cards: Array.from(document.querySelectorAll('[data-component-type="s-search-result"]')) - .map((card) => ({ - asin: card.getAttribute('data-asin') || '', - title: card.querySelector('h2')?.textContent || '', - href: card.querySelector('a.a-link-normal[href*="/dp/"]')?.href || '', - price_text: card.querySelector('.a-price .a-offscreen')?.textContent || '', - rating_text: card.querySelector('[aria-label*="out of 5 stars"]')?.getAttribute('aria-label') || '', - review_count_text: card.querySelector('a[href*="#customerReviews"]')?.textContent || '', - sponsored: /sponsored/i.test(card.innerText || ''), - badge_texts: Array.from(card.querySelectorAll('.a-badge-text')).map((node) => node.textContent || ''), - })), - }))() - `); -} -cli({ - site: 'amazon', - name: 'search', - tags: ['search'], - access: 'read', - description: 'Amazon search results for product discovery and coarse filtering', - domain: 'amazon.com', - strategy: Strategy.COOKIE, - navigateBefore: false, - args: [ - { - name: 'query', - required: true, - positional: true, - help: 'Search query, for example "desk shelf organizer"', - }, - { - name: 'limit', - type: 'int', - default: 20, - help: 'Maximum number of results to return (default 20)', - }, - ], - columns: ['rank', 'asin', 'title', 'price_text', 'rating_value', 'review_count'], - func: async (page, kwargs) => { - const query = String(kwargs.query ?? ''); - const limit = Math.max(1, Number(kwargs.limit) || 20); - const payload = await readSearchPayload(page, query); - const sourceUrl = cleanText(payload.href) || buildSearchUrl(query); - const cards = (payload.cards ?? []) - .filter((card) => cleanText(card.asin) && cleanText(card.title)) - .slice(0, limit); - if (cards.length === 0) { - throw new CommandExecutionError('amazon search did not expose any product cards', 'The search page may have changed or hit a robot check. Open the same query in Chrome, verify the page is visible, and retry.'); - } - return cards.map((card, index) => normalizeSearchCandidate(card, index + 1, sourceUrl)); - }, -}); -export const __test__ = { - normalizeSearchCandidate, -}; diff --git a/plugins/amazon/shared.js b/plugins/amazon/shared.js deleted file mode 100644 index d5b81880..00000000 --- a/plugins/amazon/shared.js +++ /dev/null @@ -1,418 +0,0 @@ -import { ArgumentError, CommandExecutionError } from '@agentrhq/webcmd/errors'; -export const SITE = 'amazon'; -export const DOMAIN = 'amazon.com'; -export const HOME_URL = 'https://www.amazon.com/'; -export const BESTSELLERS_URL = 'https://www.amazon.com/Best-Sellers/zgbs'; -export const NEW_RELEASES_URL = 'https://www.amazon.com/gp/new-releases'; -export const MOVERS_SHAKERS_URL = 'https://www.amazon.com/gp/movers-and-shakers'; -export const SEARCH_URL_PREFIX = 'https://www.amazon.com/s?k='; -export const PRODUCT_URL_PREFIX = 'https://www.amazon.com/dp/'; -export const DISCUSSION_URL_PREFIX = 'https://www.amazon.com/product-reviews/'; -export const STRATEGY = 'cookie'; -export const PRIMARY_PRICE_SELECTORS = [ - '#corePrice_feature_div .a-offscreen', - '#corePriceDisplay_desktop_feature_div .a-offscreen', - '#corePrice_desktop .a-offscreen', - '#apex_desktop .a-offscreen', - '#newAccordionRow_0 .a-offscreen', - '#price_inside_buybox', - '#priceblock_ourprice', - '#priceblock_dealprice', - '#tp_price_block_total_price_ww', -]; -// Keep this explicit because these hosts are navigation targets in the user's -// signed-in browser. A shape-only `amazon.` pattern also accepts unrelated -// registrable domains such as amazon.shop or amazon.zip. -const MARKETPLACE_DOMAINS = new Set([ - 'amazon.com', - 'amazon.ca', - 'amazon.com.mx', - 'amazon.com.br', - 'amazon.co.uk', - 'amazon.de', - 'amazon.fr', - 'amazon.it', - 'amazon.es', - 'amazon.nl', - 'amazon.pl', - 'amazon.se', - 'amazon.com.be', - 'amazon.ie', - 'amazon.com.tr', - 'amazon.ae', - 'amazon.sa', - 'amazon.eg', - 'amazon.co.za', - 'amazon.in', - 'amazon.co.jp', - 'amazon.com.au', - 'amazon.sg', -]); -function isAmazonMarketplaceHost(hostname) { - const normalized = cleanText(hostname).toLowerCase().replace(/\.$/, ''); - for (const domain of MARKETPLACE_DOMAINS) { - if (normalized === domain || normalized.endsWith(`.${domain}`)) - return true; - } - return false; -} -const ROBOT_TEXT_PATTERNS = [ - 'Sorry, we just need to make sure you\'re not a robot', - 'Enter the characters you see below', - 'Type the characters you see in this image', - 'To discuss automated access to Amazon data please contact', -]; -const AMAZON_RANKING_SPECS = { - bestsellers: { - commandName: 'bestsellers', - rootUrl: BESTSELLERS_URL, - pathPattern: /(?:^|\/)zgbs(?:\/|$)/i, - invalidInputMessage: 'amazon bestsellers expects a best sellers URL or /zgbs path', - invalidInputHint: 'Example: webcmd amazon bestsellers https://www.amazon.com/Best-Sellers/zgbs', - }, - new_releases: { - commandName: 'new-releases', - rootUrl: NEW_RELEASES_URL, - pathPattern: /\/gp\/new-releases(?:\/|$)/i, - invalidInputMessage: 'amazon new-releases expects a new releases URL or /gp/new-releases path', - invalidInputHint: 'Example: webcmd amazon new-releases https://www.amazon.com/gp/new-releases', - }, - movers_shakers: { - commandName: 'movers-shakers', - rootUrl: MOVERS_SHAKERS_URL, - pathPattern: /\/gp\/movers-and-shakers(?:\/|$)/i, - invalidInputMessage: 'amazon movers-shakers expects a movers-and-shakers URL or /gp/movers-and-shakers path', - invalidInputHint: 'Example: webcmd amazon movers-shakers https://www.amazon.com/gp/movers-and-shakers', - }, -}; -export function cleanText(value) { - return typeof value === 'string' - ? value.replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim() - : ''; -} -export function cleanMultilineText(value) { - return typeof value === 'string' - ? value - .replace(/\u00a0/g, ' ') - .split('\n') - .map((line) => line.replace(/\s+/g, ' ').trim()) - .filter(Boolean) - .join('\n') - : ''; -} -export function uniqueNonEmpty(values) { - return [...new Set(values.map((value) => cleanText(value)).filter(Boolean))]; -} -export function buildProvenance(sourceUrl) { - return { - source_url: sourceUrl, - fetched_at: new Date().toISOString(), - strategy: STRATEGY, - }; -} -export function buildSearchUrl(query) { - const normalized = cleanText(query); - if (!normalized) { - throw new ArgumentError('amazon search query cannot be empty'); - } - return `${SEARCH_URL_PREFIX}${encodeURIComponent(normalized)}`; -} -export function extractAsin(input) { - const normalized = cleanText(input); - if (!normalized) - return null; - if (/^[A-Z0-9]{10}$/i.test(normalized)) { - return normalized.toUpperCase(); - } - const match = normalized.match(/\/(?:dp|gp\/product|product-reviews)\/([A-Z0-9]{10})/i); - return match ? match[1].toUpperCase() : null; -} -export function amazonHostFromInput(input) { - const normalized = cleanText(input); - if (!normalized) - return null; - try { - const url = new URL(normalized); - return isAmazonMarketplaceHost(url.hostname) ? url.hostname : null; - } - catch { - return null; - } -} -export function buildProductUrl(input) { - const asin = extractAsin(input); - if (!asin) { - throw new ArgumentError('amazon product expects an ASIN or product URL', 'Example: webcmd amazon product B0FJS72893'); - } - const host = amazonHostFromInput(input); - return host ? `https://${host}/dp/${asin}` : `${PRODUCT_URL_PREFIX}${asin}`; -} -export function buildDiscussionUrl(input) { - const asin = extractAsin(input); - if (!asin) { - throw new ArgumentError('amazon discussion expects an ASIN or product URL', 'Example: webcmd amazon discussion B0FJS72893'); - } - const host = amazonHostFromInput(input); - return host ? `https://${host}/product-reviews/${asin}` : `${DISCUSSION_URL_PREFIX}${asin}`; -} -function getRankingSpec(listType) { - return AMAZON_RANKING_SPECS[listType]; -} -export function isSupportedRankingPath(listType, inputUrl) { - try { - const url = new URL(inputUrl); - return getRankingSpec(listType).pathPattern.test(url.pathname); - } - catch { - return false; - } -} -export function resolveRankingUrl(listType, input) { - const spec = getRankingSpec(listType); - const normalized = cleanText(input); - if (!normalized || normalized === 'root') - return spec.rootUrl; - let candidateUrl; - if (normalized.startsWith('/')) { - candidateUrl = new URL(normalized, HOME_URL).toString(); - } - else if (/^https?:\/\//i.test(normalized)) { - candidateUrl = canonicalizeAmazonUrl(normalized); - } - else if (normalized.includes('amazon.') && normalized.includes('/')) { - candidateUrl = canonicalizeAmazonUrl(`https://${normalized.replace(/^\/+/, '')}`); - } - else { - throw new ArgumentError(spec.invalidInputMessage, spec.invalidInputHint); - } - if (!isSupportedRankingPath(listType, candidateUrl)) { - throw new ArgumentError(spec.invalidInputMessage, spec.invalidInputHint); - } - return normalizeRankingInputUrl(candidateUrl); -} -function normalizeRankingInputUrl(inputUrl) { - try { - const url = new URL(inputUrl); - const normalizedPathSegments = url.pathname - .split('/') - .filter(Boolean) - .filter((segment) => !/^ref=/i.test(segment)); - url.pathname = `/${normalizedPathSegments.join('/')}`; - url.hash = ''; - // Ranking pages are frequently shared with tracking refs that can land on unstable variants. - // Dropping ref keeps the canonical ranking path while preserving useful params (for example pg=2). - url.searchParams.delete('ref'); - return url.toString(); - } - catch { - return inputUrl; - } -} -export function isRankingPaginationUrl(listType, inputUrl) { - const absolute = toAbsoluteAmazonUrl(inputUrl); - if (!absolute || !isSupportedRankingPath(listType, absolute)) - return false; - try { - const url = new URL(absolute); - const ref = cleanText(url.searchParams.get('ref')).toLowerCase(); - // pg= query param is the most reliable pagination indicator across all ranking lists - return url.searchParams.has('pg') - || /(?:^|_)pg(?:_|$)/.test(ref) - // Amazon ranking pagination refs: zg_bs_pg_ (bestsellers), zg_bsnr_pg_ (new releases), zg_bsms_pg_ (movers & shakers) - || /zg_bs(?:nr|ms)?_pg_/.test(ref); - } - catch { - return false; - } -} -export function extractCategoryNodeId(inputUrl) { - const absolute = toAbsoluteAmazonUrl(inputUrl); - if (!absolute) - return null; - try { - const url = new URL(absolute); - for (const key of ['node', 'nodeid', 'nodeId', 'browseNode']) { - const value = cleanText(url.searchParams.get(key)); - if (/^\d{4,}$/.test(value)) - return value; - } - const rhValue = cleanText(url.searchParams.get('rh')); - const rhMatch = decodeURIComponent(rhValue).match(/(?:^|,)\s*n:(\d{4,})(?:,|$)/i); - if (rhMatch) - return rhMatch[1]; - const pathMatches = [...url.pathname.matchAll(/\/(\d{4,})(?=\/|$)/g)]; - if (pathMatches.length > 0) { - return pathMatches[pathMatches.length - 1][1]; - } - } - catch { - return null; - } - return null; -} -export function resolveBestsellersUrl(input) { - return resolveRankingUrl('bestsellers', input); -} -export function canonicalizeAmazonUrl(input) { - try { - const url = new URL(input); - if (!isAmazonMarketplaceHost(url.hostname)) { - throw new Error('not-amazon'); - } - return url.toString(); - } - catch { - throw new ArgumentError('Invalid Amazon URL'); - } -} -export function toAbsoluteAmazonUrl(value) { - const normalized = cleanText(value); - if (!normalized) - return null; - try { - return new URL(normalized, HOME_URL).toString(); - } - catch { - return null; - } -} -export function normalizeProductUrl(value) { - const normalized = cleanText(value); - const asin = extractAsin(normalized); - if (asin) - return buildProductUrl(normalized); - return toAbsoluteAmazonUrl(normalized); -} -export function parsePriceText(text) { - const normalized = cleanText(text); - const match = normalized.match(/([$€£])\s*(\d+(?:,\d{3})*(?:\.\d+)?)/); - if (!match) { - return { - price_text: normalized || null, - price_value: null, - currency: null, - }; - } - const currencyMap = { - '$': 'USD', - '€': 'EUR', - '£': 'GBP', - }; - return { - price_text: `${match[1]}${match[2]}`, - price_value: Number.parseFloat(match[2].replace(/,/g, '')), - currency: currencyMap[match[1]] ?? null, - }; -} -export function parseRatingValue(text) { - const normalized = cleanText(text); - const match = normalized.match(/(\d+(?:\.\d+)?)\s*out of 5/i); - return match ? Number.parseFloat(match[1]) : null; -} -export function parseReviewCount(text) { - const normalized = cleanText(text); - const compactMatch = normalized.match(/(\d+(?:\.\d+)?)\s*([kKmM])/); - if (compactMatch) { - const value = Number.parseFloat(compactMatch[1]); - const multiplier = /m/i.test(compactMatch[2]) ? 1_000_000 : 1_000; - return Number.isFinite(value) ? Math.round(value * multiplier) : null; - } - const match = normalized.match(/([\d,]+)/); - return match ? Number.parseInt(match[1].replace(/,/g, ''), 10) : null; -} -export function extractReviewCountFromCardText(text) { - const normalized = cleanMultilineText(text); - const match = normalized.match(/out of 5 stars(?:, rating details)?\s*([\d,]+)/i); - if (match) - return match[1]; - const numericLine = normalized - .split('\n') - .map((line) => cleanText(line)) - .find((line) => /^[\d,]+$/.test(line)); - return numericLine ?? null; -} -export function isAmazonEntity(text) { - const normalized = cleanText(text).toLowerCase(); - return normalized.includes('amazon'); -} -export function firstMeaningfulLine(text) { - return cleanMultilineText(text) - .split('\n') - .map((line) => cleanText(line)) - .find(Boolean) - ?? ''; -} -export function trimRatingPrefix(text) { - const normalized = cleanText(text); - if (!normalized) - return null; - return normalized.replace(/^\d+(?:\.\d+)?\s*out of 5 stars\s*/i, '').trim() || normalized; -} -export function isRobotState(state) { - const title = cleanText(state.title); - const bodyText = cleanMultilineText(state.body_text); - return ROBOT_TEXT_PATTERNS.some((pattern) => title.includes(pattern) || bodyText.includes(pattern)); -} -export function buildChallengeHint(action) { - return [ - `Open a clean Amazon ${action} page in the shared Chrome profile and clear any robot check first.`, - 'If you are using CDP, set WEBCMD_CDP_TARGET=amazon.com and avoid parallel Amazon commands against the same browser target.', - ].join(' '); -} -export async function readPageState(page) { - const result = await page.evaluate(` - (() => ({ - href: window.location.href, - title: document.title || '', - body_text: document.body ? document.body.innerText || '' : '', - }))() - `); - return { - href: cleanText(result.href), - title: cleanText(result.title), - body_text: cleanMultilineText(result.body_text), - }; -} -export async function gotoAndReadState(page, url, settleMs = 2500, action = 'page') { - try { - await page.goto(url, { settleMs }); - await page.wait(1.5); - return await readPageState(page); - } - catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message.includes('Inspected target navigated or closed') - || message.includes('Cannot find context with specified id') - || message.includes('Target closed')) { - throw new CommandExecutionError(`amazon ${action} navigation lost the current browser target`, `${buildChallengeHint(action)} If CDP is attached to a stale tab, open a fresh Amazon tab and retry.`); - } - throw error; - } -} -export function assertUsableState(state, action) { - if (!isRobotState(state)) - return; - throw new CommandExecutionError(`amazon ${action} hit a robot check`, buildChallengeHint(action)); -} -export const __test__ = { - buildSearchUrl, - extractAsin, - amazonHostFromInput, - buildProductUrl, - buildDiscussionUrl, - normalizeProductUrl, - canonicalizeAmazonUrl, - resolveBestsellersUrl, - resolveRankingUrl, - isSupportedRankingPath, - isRankingPaginationUrl, - extractCategoryNodeId, - parsePriceText, - parseRatingValue, - parseReviewCount, - extractReviewCountFromCardText, - isAmazonEntity, - trimRatingPrefix, - isRobotState, - PRIMARY_PRICE_SELECTORS, -}; diff --git a/plugins/amazon/test/bestsellers.test.js b/plugins/amazon/test/bestsellers.test.js deleted file mode 100644 index 3723b217..00000000 --- a/plugins/amazon/test/bestsellers.test.js +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { __test__ } from '../rankings.js'; -describe('amazon bestsellers normalization', () => { - it('normalizes bestseller cards and infers review counts from card text', () => { - const result = __test__.normalizeRankingCandidate({ - asin: 'B0DR31GC3D', - title: '', - href: 'https://www.amazon.com/NUTIKAS-Shelves-Desktop-Orgnizer-Shlef/dp/B0DR31GC3D/ref=zg_bs', - price_text: '$25.92', - rating_text: '4.3 out of 5 stars', - review_count_text: '', - card_text: 'Desk Shelves Desktop Organizer Shlef\n4.3 out of 5 stars\n435\n$25.92', - }, { - listType: 'bestsellers', - rankFallback: 2, - listTitle: 'Amazon Best Sellers: Best Desktop & Off-Surface Shelves', - sourceUrl: 'https://www.amazon.com/example', - categoryTitle: null, - categoryUrl: 'https://www.amazon.com/example', - categoryPath: [], - visibleCategoryLinks: [], - }); - expect(result.rank).toBe(2); - expect(result.asin).toBe('B0DR31GC3D'); - expect(result.title).toBe('Desk Shelves Desktop Organizer Shlef'); - expect(result.review_count).toBe(435); - expect(result.list_title).toBe('Amazon Best Sellers: Best Desktop & Off-Surface Shelves'); - }); -}); diff --git a/plugins/amazon/test/discussion.test.js b/plugins/amazon/test/discussion.test.js deleted file mode 100644 index c4522dc0..00000000 --- a/plugins/amazon/test/discussion.test.js +++ /dev/null @@ -1,186 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { AuthRequiredError } from '@agentrhq/webcmd/errors'; -import { getRegistry } from '@agentrhq/webcmd/registry'; -import { __test__ } from '../discussion.js'; -import '../discussion.js'; -import { createPageMock } from './page-mock.js'; - - -describe('amazon discussion normalization', () => { - it('normalizes review summary and sample reviews', () => { - const result = __test__.normalizeDiscussionPayload({ - href: 'https://www.amazon.com/product-reviews/B0FJS72893', - average_rating_text: '3.9 out of 5', - total_review_count_text: '27 global ratings', - qa_links: [], - review_samples: [ - { - title: '5.0 out of 5 stars Great value and quality', - rating_text: '5.0 out of 5 stars', - author: 'GTreader2', - date_text: 'Reviewed in the United States on February 21, 2026', - body: 'Small but mighty.', - verified: true, - }, - ], - }); - - expect(result.asin).toBe('B0FJS72893'); - expect(result.average_rating_value).toBe(3.9); - expect(result.total_review_count).toBe(27); - expect(result.review_samples).toEqual([ - { - title: 'Great value and quality', - rating_text: '5.0 out of 5 stars', - rating_value: 5, - author: 'GTreader2', - date_text: 'Reviewed in the United States on February 21, 2026', - body: 'Small but mighty.', - verified_purchase: true, - }, - ]); - }); - - it('keeps the review marketplace in every emitted url', () => { - const result = __test__.normalizeDiscussionPayload({ - href: 'https://www.amazon.co.uk/product-reviews/B0FGCPFY9L', - average_rating_text: '4.4 out of 5', - total_review_count_text: '40 global ratings', - qa_links: [], - review_samples: [], - }); - - expect(result.asin).toBe('B0FGCPFY9L'); - expect(result.discussion_url).toBe('https://www.amazon.co.uk/product-reviews/B0FGCPFY9L'); - expect(result.product_url).toBe('https://www.amazon.co.uk/dp/B0FGCPFY9L'); - }); - - it('requests the review page on the marketplace the input names', async () => { - const command = getRegistry().get('amazon/discussion'); - const page = createPageMock([ - { - href: 'https://www.amazon.co.uk/product-reviews/B0FGCPFY9L', - title: 'Amazon.co.uk: Example product', - body_text: 'Customer reviews', - }, - { - href: 'https://www.amazon.co.uk/product-reviews/B0FGCPFY9L', - average_rating_text: '4.4 out of 5', - total_review_count_text: '40 global ratings', - review_samples: [], - }, - ]); - - await command.func(page, { input: 'https://www.amazon.co.uk/product-reviews/B0FGCPFY9L', limit: 1 }); - - expect(page.goto.mock.calls[0][0]).toBe('https://www.amazon.co.uk/product-reviews/B0FGCPFY9L'); - }); - - it('names the loaded url when neither page exposes a review summary', async () => { - const command = getRegistry().get('amazon/discussion'); - const emptyPayload = { href: 'https://www.amazon.co.uk/dp/B0FGCPFY9L', average_rating_text: '', total_review_count_text: '', review_samples: [] }; - const page = createPageMock([ - { href: 'https://www.amazon.co.uk/product-reviews/B0FGCPFY9L', title: 'Amazon.co.uk', body_text: 'Customer reviews' }, - emptyPayload, - { href: 'https://www.amazon.co.uk/dp/B0FGCPFY9L', title: 'Amazon.co.uk', body_text: 'Product' }, - emptyPayload, - ]); - - await expect(command.func(page, { input: 'https://www.amazon.co.uk/dp/B0FGCPFY9L', limit: 1 })) - .rejects.toThrow('landed on https://www.amazon.co.uk/dp/B0FGCPFY9L'); - }); - - it('points a gated non-US review page at that marketplace, not the US store', async () => { - const command = getRegistry().get('amazon/discussion'); - const signIn = { href: 'https://www.amazon.co.uk/ap/signin', title: 'Amazon Sign-In', body_text: 'Sign in Create account' }; - const page = createPageMock([ - signIn, - { href: signIn.href, average_rating_text: '', total_review_count_text: '', review_samples: [] }, - signIn, - ]); - - await expect(command.func(page, { input: 'https://www.amazon.co.uk/dp/B0FGCPFY9L', limit: 1 })) - .rejects.toMatchObject({ domain: 'www.amazon.co.uk' }); - }); - - it('falls back to the product page when the review page redirects to sign-in', async () => { - const command = getRegistry().get('amazon/discussion'); - const page = createPageMock([ - { - href: 'https://www.amazon.com/ap/signin?openid.return_to=https%3A%2F%2Fwww.amazon.com%2Fproduct-reviews%2FB09HKN2ZRT', - title: 'Amazon Sign-In', - body_text: 'Sign in Create account', - }, - { - href: 'https://www.amazon.com/ap/signin?openid.return_to=https%3A%2F%2Fwww.amazon.com%2Fproduct-reviews%2FB09HKN2ZRT', - average_rating_text: '', - total_review_count_text: '', - review_samples: [], - }, - { - href: 'https://www.amazon.com/dp/B09HKN2ZRT', - title: 'Amazon.com: Example product', - body_text: 'Hello, zejia-wu Reviews', - }, - { - href: 'https://www.amazon.com/dp/B09HKN2ZRT', - average_rating_text: '4.4 out of 5', - total_review_count_text: '349 global ratings', - review_samples: [ - { - title: '5.0 out of 5 stars Perfect for the office', - rating_text: '5.0 out of 5 stars', - author: 'Ken', - date_text: 'Reviewed in the United States on March 19, 2026', - body: 'Good for the office, no complaints.', - verified: true, - }, - ], - }, - ]); - - const result = await command.func(page, { input: 'B09HKN2ZRT', limit: 1 }); - - expect(page.goto.mock.calls.map((call) => call[0])).toEqual([ - 'https://www.amazon.com/product-reviews/B09HKN2ZRT', - 'https://www.amazon.com/dp/B09HKN2ZRT', - ]); - expect(result).toEqual([ - expect.objectContaining({ - asin: 'B09HKN2ZRT', - discussion_url: 'https://www.amazon.com/dp/B09HKN2ZRT', - average_rating_value: 4.4, - total_review_count: 349, - }), - ]); - }); - - it('throws AuthRequiredError when both review and product pages are gated', async () => { - const command = getRegistry().get('amazon/discussion'); - const authState = { - href: 'https://www.amazon.com/ap/signin?openid.return_to=https%3A%2F%2Fwww.amazon.com%2Fproduct-reviews%2FB09HKN2ZRT', - title: 'Amazon Sign-In', - body_text: 'Sign in Create account', - }; - const page = createPageMock([ - authState, - { - href: authState.href, - average_rating_text: '', - total_review_count_text: '', - review_samples: [], - }, - authState, - ]); - - await expect(command.func(page, { input: 'B09HKN2ZRT', limit: 1 })).rejects.toBeInstanceOf(AuthRequiredError); - }); - - it('does not treat a public product page with sign-in copy as a gated page', () => { - expect(__test__.isSignInState({ - href: 'https://www.amazon.com/dp/B09HKN2ZRT', - title: 'Amazon.com: Example product', - body_text: 'Hello, sign in Account & Lists Create account', - })).toBe(false); - }); -}); diff --git a/plugins/amazon/test/offer.test.js b/plugins/amazon/test/offer.test.js deleted file mode 100644 index a27061be..00000000 --- a/plugins/amazon/test/offer.test.js +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { __test__ } from '../offer.js'; -describe('amazon offer normalization', () => { - it('extracts sold-by and fulfillment facts from product offer text', () => { - const result = __test__.normalizeOfferPayload({ - href: 'https://www.amazon.com/dp/B0FJS72893', - price_text: '$15.99', - merchant_info: '', - sold_by: 'KUATUDIRECT', - ships_from_text: 'Ships from Amazon', - offer_link: null, - review_url: 'https://www.amazon.com/dp/B0FJS72893#customerReviews', - qa_url: null, - }); - expect(result.asin).toBe('B0FJS72893'); - expect(result.sold_by).toBe('KUATUDIRECT'); - expect(result.ships_from).toBe('Amazon'); - expect(result.is_amazon_sold).toBe(false); - expect(result.is_amazon_fulfilled).toBe(true); - }); - it('parses merchant info fallback text', () => { - expect(__test__.extractSoldBy('Sold by Example Seller and Fulfilled by Amazon.')).toBe('Example Seller'); - expect(__test__.extractShipsFrom('Ships from Amazon')).toBe('Amazon'); - }); - it('detects delivery-location blocking in the buy box text', () => { - expect(__test__.isDeliveryLocationBlocked('This item cannot be shipped to your selected delivery location. Similar items shipping to Hong Kong')).toBe(true); - expect(__test__.isDeliveryLocationBlocked('Ships from Amazon')).toBe(false); - }); -}); diff --git a/plugins/amazon/test/page-mock.js b/plugins/amazon/test/page-mock.js deleted file mode 100644 index 473a2eb0..00000000 --- a/plugins/amazon/test/page-mock.js +++ /dev/null @@ -1,11 +0,0 @@ -import { vi } from 'vitest'; - -export function createPageMock(evaluateResults = []) { - const evaluate = vi.fn(); - for (const result of evaluateResults) evaluate.mockResolvedValueOnce(result); - return { - evaluate, - goto: vi.fn().mockResolvedValue(undefined), - wait: vi.fn().mockResolvedValue(undefined), - }; -} diff --git a/plugins/amazon/test/product.test.js b/plugins/amazon/test/product.test.js deleted file mode 100644 index 9cb7eb01..00000000 --- a/plugins/amazon/test/product.test.js +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { __test__ } from '../product.js'; -describe('amazon product normalization', () => { - it('normalizes product facts from the product page', () => { - const result = __test__.normalizeProductPayload({ - href: 'https://www.amazon.com/dp/B0FJS72893', - title: 'Amazon.com: KVTUKIAIT Desktop Shelf Organizer', - product_title: 'White Desktop Shelf Organizer for Top of Desk', - byline: 'Visit the KVTUKIAIT Store', - price_text: '$15.99', - rating_text: '3.9 out of 5 stars', - review_count_text: '27 ratings', - review_url: 'https://www.amazon.com/dp/B0FJS72893#customerReviews', - qa_url: null, - bullets: ['SPACE-SAVING DESK SHELF ORGANIZER', 'SMALL AND STYLISH AESTHETIC DECOR'], - breadcrumbs: ['Office Products', 'Desktop & Off-Surface Shelves'], - }); - expect(result.asin).toBe('B0FJS72893'); - expect(result.price_value).toBe(15.99); - expect(result.rating_value).toBe(3.9); - expect(result.review_count).toBe(27); - expect(result.breadcrumbs).toEqual(['Office Products', 'Desktop & Off-Surface Shelves']); - }); -}); diff --git a/plugins/amazon/test/rankings.test.js b/plugins/amazon/test/rankings.test.js deleted file mode 100644 index fdf6b4f5..00000000 --- a/plugins/amazon/test/rankings.test.js +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { __test__ } from '../rankings.js'; -describe('amazon rankings helpers', () => { - it('normalizes ranking candidates with unified schema', () => { - const result = __test__.normalizeRankingCandidate({ - rank_text: '#3', - asin: 'B0DR31GC3D', - title: 'Desk Shelves Desktop Organizer', - href: 'https://www.amazon.com/dp/B0DR31GC3D/ref=zg_bs', - price_text: '$25.92', - rating_text: '4.3 out of 5 stars', - review_count_text: '435', - }, { - listType: 'new_releases', - rankFallback: 3, - listTitle: 'Amazon New Releases', - sourceUrl: 'https://www.amazon.com/gp/new-releases', - categoryTitle: 'Home & Kitchen', - categoryUrl: 'https://www.amazon.com/gp/new-releases/home-garden', - categoryPath: ['Home & Kitchen'], - visibleCategoryLinks: [{ title: 'Storage', url: 'https://www.amazon.com/gp/new-releases/storage', node_id: null }], - }); - expect(result.list_type).toBe('new_releases'); - expect(result.rank).toBe(3); - expect(result.asin).toBe('B0DR31GC3D'); - expect(result.product_url).toBe('https://www.amazon.com/dp/B0DR31GC3D'); - expect(result.category_title).toBe('Home & Kitchen'); - expect(result.visible_category_links).toEqual([ - { title: 'Storage', url: 'https://www.amazon.com/gp/new-releases/storage', node_id: null }, - ]); - }); - it('deduplicates category links and parses rank fallback', () => { - const links = __test__.normalizeVisibleCategoryLinks([ - { title: 'Kitchen', url: '/gp/new-releases/home-garden' }, - { title: 'Kitchen', url: 'https://www.amazon.com/gp/new-releases/home-garden' }, - { title: 'Storage', url: '/gp/new-releases/storage', node_id: '1064954' }, - ]); - expect(links.length).toBe(2); - expect(__test__.parseRank('N/A', 8)).toBe(8); - }); -}); diff --git a/plugins/amazon/test/search.test.js b/plugins/amazon/test/search.test.js deleted file mode 100644 index e8814831..00000000 --- a/plugins/amazon/test/search.test.js +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { __test__ } from '../search.js'; -describe('amazon search normalization', () => { - it('normalizes search cards into research-friendly fields', () => { - const result = __test__.normalizeSearchCandidate({ - asin: 'B0FJS72893', - title: 'White Desktop Shelf Organizer for Top of Desk', - href: 'https://www.amazon.com/KVTUKIAIT-White-Desktop-Shelf-Organizer/dp/B0FJS72893/ref=sr_1_1', - price_text: '$15.99', - rating_text: '3.9 out of 5 stars, rating details', - review_count_text: '(27)', - sponsored: false, - badge_texts: ['Limited time deal'], - }, 1, 'https://www.amazon.com/s?k=desk+shelf+organizer'); - expect(result.asin).toBe('B0FJS72893'); - expect(result.product_url).toBe('https://www.amazon.com/dp/B0FJS72893'); - expect(result.price_value).toBe(15.99); - expect(result.rating_value).toBe(3.9); - expect(result.review_count).toBe(27); - expect(result.badges).toEqual(['Limited time deal']); - }); -}); diff --git a/plugins/amazon/test/shared.test.js b/plugins/amazon/test/shared.test.js deleted file mode 100644 index 628da2f8..00000000 --- a/plugins/amazon/test/shared.test.js +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { __test__ } from '../shared.js'; -describe('amazon shared helpers', () => { - it('builds canonical product and discussion URLs from ASINs and product URLs', () => { - expect(__test__.buildProductUrl('B0FJS72893')).toBe('https://www.amazon.com/dp/B0FJS72893'); - expect(__test__.buildProductUrl('https://www.amazon.com/dp/B0FJS72893/ref=something')).toBe('https://www.amazon.com/dp/B0FJS72893'); - expect(__test__.buildDiscussionUrl('https://www.amazon.com/dp/B0FJS72893')).toBe('https://www.amazon.com/product-reviews/B0FJS72893'); - }); - it('keeps the input marketplace instead of rewriting it to the US store', () => { - expect(__test__.buildProductUrl('https://www.amazon.co.uk/dp/B0FGCPFY9L')).toBe('https://www.amazon.co.uk/dp/B0FGCPFY9L'); - expect(__test__.buildProductUrl('https://www.amazon.de/dp/B0FJS72893/ref=something')).toBe('https://www.amazon.de/dp/B0FJS72893'); - expect(__test__.buildProductUrl('https://www.amazon.com.au/dp/B0FJS72893')).toBe('https://www.amazon.com.au/dp/B0FJS72893'); - expect(__test__.buildDiscussionUrl('https://www.amazon.co.uk/product-reviews/B0FGCPFY9L?pageNumber=1')).toBe('https://www.amazon.co.uk/product-reviews/B0FGCPFY9L'); - expect(__test__.normalizeProductUrl('https://www.amazon.co.uk/dp/B0FGCPFY9L')).toBe('https://www.amazon.co.uk/dp/B0FGCPFY9L'); - }); - it('defaults to the US store for bare ASINs and non-marketplace hosts', () => { - expect(__test__.amazonHostFromInput('B0FJS72893')).toBeNull(); - expect(__test__.buildProductUrl('B0FJS72893')).toBe('https://www.amazon.com/dp/B0FJS72893'); - expect(__test__.buildDiscussionUrl('B0FJS72893')).toBe('https://www.amazon.com/product-reviews/B0FJS72893'); - expect(__test__.normalizeProductUrl('B0FJS72893')).toBe('https://www.amazon.com/dp/B0FJS72893'); - }); - it('accepts sibling marketplaces but rejects look-alike hosts', () => { - expect(__test__.amazonHostFromInput('https://www.amazon.co.uk/dp/B0FJS72893')).toBe('www.amazon.co.uk'); - expect(__test__.amazonHostFromInput('https://amazon.de/dp/B0FJS72893')).toBe('amazon.de'); - expect(__test__.amazonHostFromInput('https://amazon.com.au/dp/B0FJS72893')).toBe('amazon.com.au'); - expect(__test__.amazonHostFromInput('https://smile.amazon.com.be/dp/B0FJS72893')).toBe('smile.amazon.com.be'); - expect(__test__.amazonHostFromInput('https://evilamazon.com/dp/B0FJS72893')).toBeNull(); - expect(__test__.amazonHostFromInput('https://amazon.com.evil.com/dp/B0FJS72893')).toBeNull(); - expect(__test__.amazonHostFromInput('https://amazon.evil.com/dp/B0FJS72893')).toBeNull(); - expect(__test__.amazonHostFromInput('https://x.amazon.evil.com/dp/B0FJS72893')).toBeNull(); - expect(__test__.amazonHostFromInput('https://amazon.attacker.io/dp/B0FJS72893')).toBeNull(); - expect(__test__.amazonHostFromInput('https://amazon.shop/dp/B0FJS72893')).toBeNull(); - expect(__test__.amazonHostFromInput('https://amazon.zip/dp/B0FJS72893')).toBeNull(); - expect(() => __test__.canonicalizeAmazonUrl('https://amazon.evil.com/gp/bestsellers')).toThrow('Invalid Amazon URL'); - expect(__test__.canonicalizeAmazonUrl('https://www.amazon.co.uk/gp/bestsellers/books')).toBe('https://www.amazon.co.uk/gp/bestsellers/books'); - expect(() => __test__.canonicalizeAmazonUrl('https://evilamazon.com/gp/bestsellers')).toThrow('Invalid Amazon URL'); - }); - it('parses price, rating, and review-count text', () => { - expect(__test__.parsePriceText('1 offer from $34.11')).toEqual({ - price_text: '$34.11', - price_value: 34.11, - currency: 'USD', - }); - expect(__test__.parseRatingValue('3.9 out of 5 stars, rating details')).toBe(3.9); - expect(__test__.parseReviewCount('27 global ratings')).toBe(27); - expect(__test__.parseReviewCount('(2.9K)')).toBe(2900); - expect(__test__.parseReviewCount('1.2M global ratings')).toBe(1200000); - expect(__test__.extractReviewCountFromCardText('Desk Shelf\n4.3 out of 5 stars\n435\n$25.92')).toBe('435'); - }); - it('recognizes robot checks and Amazon-owned merchants', () => { - expect(__test__.isAmazonEntity('Ships from Amazon')).toBe(true); - expect(__test__.trimRatingPrefix('5.0 out of 5 stars Great value and quality')).toBe('Great value and quality'); - expect(__test__.isRobotState({ - title: 'Robot Check', - body_text: 'Sorry, we just need to make sure you\'re not a robot', - })).toBe(true); - }); - it('requires a real best-sellers URL or path', () => { - expect(__test__.resolveBestsellersUrl('/Best-Sellers/zgbs')).toBe('https://www.amazon.com/Best-Sellers/zgbs'); - expect(() => __test__.resolveBestsellersUrl('desk shelf organizer')).toThrow('amazon bestsellers expects a best sellers URL or /zgbs path'); - }); - it('resolves and validates all ranking list URLs', () => { - expect(__test__.resolveRankingUrl('new_releases')).toBe('https://www.amazon.com/gp/new-releases'); - expect(__test__.resolveRankingUrl('movers_shakers')).toBe('https://www.amazon.com/gp/movers-and-shakers'); - expect(__test__.resolveRankingUrl('new_releases', '/gp/new-releases/kitchen')).toBe('https://www.amazon.com/gp/new-releases/kitchen'); - expect(__test__.resolveRankingUrl('bestsellers', 'https://www.amazon.com/Best-Sellers/zgbs/ref=zg_bsnr_tab_bs')).toBe('https://www.amazon.com/Best-Sellers/zgbs'); - expect(() => __test__.resolveRankingUrl('movers_shakers', 'https://example.com/gp/movers-and-shakers')).toThrow('Invalid Amazon URL'); - }); - it('extracts category node id from URL best effort', () => { - expect(__test__.extractCategoryNodeId('https://www.amazon.com/Best-Sellers-Home-Kitchen/zgbs/home-garden/3744371')).toBe('3744371'); - expect(__test__.extractCategoryNodeId('https://www.amazon.com/s?k=desk+organizer&rh=n%3A1064954')).toBe('1064954'); - }); -}); diff --git a/plugins/amazon/webcmd-plugin.json b/plugins/amazon/webcmd-plugin.json deleted file mode 100644 index 2aae0553..00000000 --- a/plugins/amazon/webcmd-plugin.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "amazon", - "version": "0.1.0", - "description": "Webcmd commands for amazon", - "webcmd": ">=0.5.3", - "author": { - "name": "WebCMD Agent", - "handle": "agentrhq" - } -} diff --git a/plugins/antigravity/README.md b/plugins/antigravity/README.md deleted file mode 100644 index 8197db59..00000000 --- a/plugins/antigravity/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# webcmd-plugin-antigravity - -Webcmd commands for antigravity. - -## Install - -```bash -webcmd plugin install github:agentrhq/webcmd/antigravity -``` - -## Commands - -| Command | Description | -| --- | --- | -| `webcmd antigravity add-context` | Click the Add context button in the composer (opens file/URL picker for context attachment). | -| `webcmd antigravity cookies` | List cookies on the Antigravity renderer (JS-visible via document.cookie). | -| `webcmd antigravity copy-code` | Return the text of a code block in the current conversation. Default: last code block; pass --index N (1-based from top) to pick a specific one. | -| `webcmd antigravity copy-message` | Return the text of the last assistant message (best-effort: walks up from the last visible Copy button). | -| `webcmd antigravity delete` | Delete an Antigravity conversation by ID. Antigravity asks for confirmation; we click through it. Require --yes to actually delete. | -| `webcmd antigravity display-options` | Open the Display Options menu and list its items. | -| `webcmd antigravity dump` | Dump the DOM to help AI understand the UI | -| `webcmd antigravity extract-code` | Extract multi-line code blocks from the current Antigravity conversation | -| `webcmd antigravity history` | List visible Antigravity conversations from the sidebar | -| `webcmd antigravity idb-list` | List IndexedDB databases on the Antigravity renderer. | -| `webcmd antigravity mark-read` | Mark an unread Antigravity conversation as read. Fails if the row is already read or the postcondition cannot be verified. | -| `webcmd antigravity model` | Read or switch the active model in Antigravity. Without arguments, reports the current model. With (substring, case-insensitive), switches. | -| `webcmd antigravity nav` | Click Go Back or Go Forward (Antigravity in-app history). | -| `webcmd antigravity new` | Start a new conversation / clear context in Antigravity | -| `webcmd antigravity react` | Click "Good response" or "Bad response" on the LAST assistant message. | -| `webcmd antigravity read` | Read the latest chat messages from Antigravity AI | -| `webcmd antigravity recent-paths` | Show Antigravity's recently-opened folders/files (history.recentlyOpenedPathsList). | -| `webcmd antigravity rename` | Rename an Antigravity conversation by ID (NOT YET IMPLEMENTED — see source comment). | -| `webcmd antigravity revert` | Click the revert button (per-message revert for agent changes). Requires --yes (this modifies your workspace). | -| `webcmd antigravity send` | Send a message to Antigravity AI via the internal Lexical editor | -| `webcmd antigravity settings` | Click the Antigravity settings button (matched by data-testid="settings-button"). | -| `webcmd antigravity settings-read` | Read Antigravity's user settings.json (theme, proxy, agCockpit, tfa.system.autoAccept, etc.). | -| `webcmd antigravity sidebar-toggle` | Click Toggle Sidebar (collapses/expands the Antigravity sidebar). | -| `webcmd antigravity state-get` | Read one value from Antigravity's state.vscdb. Pass --workspace for per-workspace. | -| `webcmd antigravity state-keys` | List keys in Antigravity's globalStorage state.vscdb (VSCode-style). Pass --workspace to query a per-workspace DB. Works while Antigravity is closed. | -| `webcmd antigravity status` | Check Antigravity CDP connection and get current page state | -| `webcmd antigravity storage-get` | Read a single localStorage / sessionStorage value on the Antigravity renderer. | -| `webcmd antigravity storage-keys` | List localStorage / sessionStorage keys on the Antigravity renderer (CDP). | -| `webcmd antigravity toggle-aux` | Toggle the Auxiliary Pane (Antigravity's secondary panel for code/preview). | -| `webcmd antigravity watch` | Stream new chat messages from Antigravity in real-time | -| `webcmd antigravity workspaces-list` | List Antigravity workspaceStorage entries (each represents a previously-opened folder). | diff --git a/plugins/antigravity/_actions.js b/plugins/antigravity/_actions.js deleted file mode 100644 index 2e0507e2..00000000 --- a/plugins/antigravity/_actions.js +++ /dev/null @@ -1,318 +0,0 @@ -// Shared helpers for Antigravity sidebar conversation management. -// -// Each conversation in the sidebar is rendered as a row whose visible -// title element has stable testid `convo-pill-`. The row container -// is the 3rd ancestor — it carries `role="button"` and acts as the -// clickable row. -// -// On hover the row shows 3 icon-only buttons. The FIRST (button[0]) is a -// "more options" 3-dot trigger that opens a 3-item dropdown: -// -// Mark as Read -// Rename -// Delete Conversation -// -// We use that dropdown for all management operations. Antigravity does -// not currently expose Pin/Unpin as menu items (different model than -// Codex / Grok). -// -// All clicks go through the full pointer-event chain because the menu is -// likely radix-based and ignores bare .click(). - -import { CommandExecutionError, selectorError } from '@agentrhq/webcmd/errors'; - -const PILL_SELECTOR_PREFIX = 'convo-pill-'; - -export function unwrapEvaluateResult(payload) { - if ( - payload - && typeof payload === 'object' - && Object.prototype.hasOwnProperty.call(payload, 'data') - && Object.prototype.hasOwnProperty.call(payload, 'session') - ) { - return payload.data; - } - return payload; -} - -export function buildPillTestId(conversationId) { - return `${PILL_SELECTOR_PREFIX}${String(conversationId).toLowerCase()}`; -} - -/** - * Return all visible conversation pills with their {id, title} for - * history-style listings or for fuzzy match. - */ -export async function listConversations(page) { - const result = unwrapEvaluateResult(await page.evaluate(`(function() { - return Array.from(document.querySelectorAll('[data-testid^="${PILL_SELECTOR_PREFIX}"]')) - .filter((el) => el.offsetParent) - .map((el, idx) => ({ - index: idx + 1, - id: el.getAttribute('data-testid').slice(${PILL_SELECTOR_PREFIX.length}), - title: (el.textContent || '').trim().slice(0, 200), - })); - })()`)); - return Array.isArray(result) ? result : []; -} - -export async function conversationVisible(page, conversationId) { - const testId = buildPillTestId(conversationId); - return !!unwrapEvaluateResult(await page.evaluate(`(() => { - const el = document.querySelector(${JSON.stringify(`[data-testid="${testId}"]`)}); - return !!(el && el.offsetParent); - })()`)); -} - -export async function getConversationMenuLabels(page, conversationId) { - const testId = buildPillTestId(conversationId); - const result = unwrapEvaluateResult(await page.evaluate(`(async () => { - const wait = (ms) => new Promise((r) => setTimeout(r, ms)); - const pill = document.querySelector(${JSON.stringify(`[data-testid="${testId}"]`)}); - if (!pill) return { ok: false, reason: 'Conversation pill not found.', detail: 'testid=${testId}' }; - let row = pill; - for (let i = 0; i < 3; i++) row = row.parentElement || row; - row.scrollIntoView({ block: 'center' }); - row.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); - row.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); - let dotBtn = null; - for (let attempt = 0; attempt < 12; attempt += 1) { - await wait(80); - const btns = Array.from(row.querySelectorAll('button')).filter((b) => b.offsetParent); - if (btns.length >= 1) { dotBtn = btns[0]; break; } - } - if (!dotBtn) return { ok: false, reason: 'Per-row 3-dot trigger never mounted after hover.' }; - const r = dotBtn.getBoundingClientRect(); - const init = { - bubbles: true, cancelable: true, button: 0, buttons: 1, - clientX: Math.round(r.left + r.width / 2), - clientY: Math.round(r.top + r.height / 2), - }; - dotBtn.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerType: 'mouse' })); - dotBtn.dispatchEvent(new MouseEvent('mousedown', init)); - dotBtn.dispatchEvent(new PointerEvent('pointerup', { ...init, pointerType: 'mouse' })); - dotBtn.dispatchEvent(new MouseEvent('mouseup', init)); - dotBtn.dispatchEvent(new MouseEvent('click', init)); - let menuItems = []; - for (let attempt = 0; attempt < 20; attempt += 1) { - await wait(80); - menuItems = Array.from(document.querySelectorAll('[role="menuitem"], [role="option"]')) - .filter((it) => it instanceof HTMLElement && it.offsetParent); - if (menuItems.length) break; - } - const labels = menuItems.map((it) => { - const clone = it.cloneNode(true); - clone.querySelectorAll('kbd').forEach((k) => k.remove()); - return (clone.textContent || '').trim(); - }).filter(Boolean); - document.body.click(); - return { ok: true, labels }; - })()`)); - return result || { ok: false, reason: 'Empty result from page.evaluate.' }; -} - -/** - * Open the per-row 3-dot menu for the given conversation, click the - * menu item whose visible text matches `labelOptions`, return status. - * Single page.evaluate so the menu stays mounted while we click. - * - * Returns { ok, clicked? , reason?, detail? }. - */ -export async function clickConversationMenuItem(page, conversationId, labelOptions) { - const testId = buildPillTestId(conversationId); - const testIdJson = JSON.stringify(testId); - const labelsJson = JSON.stringify(labelOptions); - - // Wrap in try/catch — Antigravity menu clicks often trigger a - // sidebar re-render that destroys the eval reply mid-stream, surfacing - // as "Promise was collected" or 30s Runtime.evaluate timeout. The - // click DID happen (we verified live by toggling Mark as Read / - // Unread). Treat these specific failures as success-with-no-confirmation - // and let the caller re-query history to verify. - let result; - try { - result = unwrapEvaluateResult(await page.evaluate(`(async () => { - const wait = (ms) => new Promise((r) => setTimeout(r, ms)); - const testId = ${testIdJson}; - const labels = ${labelsJson}; - - const pill = document.querySelector(\`[data-testid="\${testId}"]\`); - if (!pill) { - return { ok: false, reason: 'Conversation pill not found.', detail: 'testid=' + testId }; - } - - // Walk up to the row container — depth 3 holds the role="button" row - // with the per-row action buttons. - let row = pill; - for (let i = 0; i < 3; i++) row = row.parentElement || row; - if (!row) { - return { ok: false, reason: 'Could not locate the row container above the pill.' }; - } - - row.scrollIntoView({ block: 'center' }); - - // React synthetic hover mounts the per-row buttons. Visibility-state - // doesn't appear to gate Antigravity's overlay (unlike Codex), but - // we still dispatch the full set for safety. - row.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); - row.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); - - // Wait for the row's 3-dot trigger to mount. - let dotBtn = null; - for (let attempt = 0; attempt < 12; attempt += 1) { - await wait(80); - const btns = Array.from(row.querySelectorAll('button')).filter((b) => b.offsetParent); - if (btns.length >= 1) { dotBtn = btns[0]; break; } // First button == more-options - } - if (!dotBtn) { - return { ok: false, reason: 'Per-row 3-dot trigger never mounted after hover.' }; - } - - // Open the menu via full pointer chain. - const r = dotBtn.getBoundingClientRect(); - const init = { - bubbles: true, cancelable: true, button: 0, buttons: 1, - clientX: Math.round(r.left + r.width / 2), - clientY: Math.round(r.top + r.height / 2), - }; - dotBtn.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerType: 'mouse' })); - dotBtn.dispatchEvent(new MouseEvent('mousedown', init)); - dotBtn.dispatchEvent(new PointerEvent('pointerup', { ...init, pointerType: 'mouse' })); - dotBtn.dispatchEvent(new MouseEvent('mouseup', init)); - dotBtn.dispatchEvent(new MouseEvent('click', init)); - - // Wait for menu items to mount. - let menuItems = []; - for (let attempt = 0; attempt < 20; attempt += 1) { - await wait(80); - menuItems = Array.from(document.querySelectorAll('[role="menuitem"], [role="option"]')) - .filter((it) => it instanceof HTMLElement && it.offsetParent); - if (menuItems.length) break; - } - if (!menuItems.length) { - return { ok: false, reason: 'Conversation 3-dot menu did not open after click.' }; - } - - function leadingText(el) { - const clone = el.cloneNode(true); - clone.querySelectorAll('kbd').forEach((k) => k.remove()); - return (clone.textContent || '').trim(); - } - - let target = null; - for (const item of menuItems) { - const text = leadingText(item); - for (const label of labels) { - if (text === label || text.startsWith(label)) { - target = item; - break; - } - } - if (target) break; - } - if (!target) { - const visible = menuItems.map(leadingText); - document.body.click(); // close menu - return { - ok: false, - reason: 'No menu item matched the requested label.', - detail: 'wanted=' + JSON.stringify(labels) + ' visible=' + JSON.stringify(visible), - }; - } - - // Click via pointer chain too — radix is picky. - const tr = target.getBoundingClientRect(); - const tinit = { - bubbles: true, cancelable: true, button: 0, buttons: 1, - clientX: Math.round(tr.left + tr.width / 2), - clientY: Math.round(tr.top + tr.height / 2), - }; - const matchedLabel = leadingText(target); - // Defer to next microtask so the eval reply returns before any re-render. - Promise.resolve().then(() => { - try { - target.dispatchEvent(new PointerEvent('pointerdown', { ...tinit, pointerType: 'mouse' })); - target.dispatchEvent(new MouseEvent('mousedown', tinit)); - target.dispatchEvent(new PointerEvent('pointerup', { ...tinit, pointerType: 'mouse' })); - target.dispatchEvent(new MouseEvent('mouseup', tinit)); - target.dispatchEvent(new MouseEvent('click', tinit)); - } catch {} - }); - return { ok: true, clicked: matchedLabel }; - })()`)); - } catch (err) { - const msg = String(err?.message || err); - if (/Promise was collected|timed out after \d+s|Runtime\.evaluate/i.test(msg)) { - // Click was scheduled inside a microtask before destruction, so - // the action almost certainly fired. Report ambiguous-but-likely-ok. - return { - ok: true, - clicked: labelOptions[0], - note: 'eval reply destroyed by post-click re-render; click likely fired', - }; - } - throw err; - } - - return result || { ok: false, reason: 'Empty result from page.evaluate.' }; -} - -/** - * After Delete Conversation menu item is clicked, Antigravity shows a - * confirm dialog. Locate it and click the confirm button. - */ -export async function confirmDeleteDialog(page, confirmLabels) { - const labelsJson = JSON.stringify(confirmLabels); - const result = unwrapEvaluateResult(await page.evaluate(`(async () => { - const wait = (ms) => new Promise((r) => setTimeout(r, ms)); - let dialog = null; - for (let attempt = 0; attempt < 15; attempt += 1) { - await wait(120); - dialog = document.querySelector('[role="alertdialog"], [role="dialog"]'); - if (dialog && dialog.offsetParent) break; - } - if (!dialog) { - return { ok: false, reason: 'Delete confirm dialog did not appear.' }; - } - const buttons = Array.from(dialog.querySelectorAll('button')); - const labels = ${labelsJson}; - const confirmBtn = buttons.find((b) => { - const t = (b.textContent || '').trim(); - return labels.some((l) => t === l || t.toLowerCase() === l.toLowerCase()); - }); - if (!confirmBtn) { - return { - ok: false, - reason: 'Confirm button not found in dialog.', - detail: 'present=' + JSON.stringify(buttons.map((b) => (b.textContent || '').trim())), - }; - } - const r = confirmBtn.getBoundingClientRect(); - const init = { - bubbles: true, button: 0, buttons: 1, cancelable: true, - clientX: Math.round(r.left + r.width / 2), - clientY: Math.round(r.top + r.height / 2), - }; - Promise.resolve().then(() => { - try { - confirmBtn.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerType: 'mouse' })); - confirmBtn.dispatchEvent(new MouseEvent('mousedown', init)); - confirmBtn.dispatchEvent(new PointerEvent('pointerup', { ...init, pointerType: 'mouse' })); - confirmBtn.dispatchEvent(new MouseEvent('mouseup', init)); - confirmBtn.dispatchEvent(new MouseEvent('click', init)); - } catch {} - }); - return { ok: true, confirmed: (confirmBtn.textContent || '').trim() }; - })()`)); - return result || { ok: false, reason: 'Empty result.' }; -} - -export const conversationTargetArgs = [ - { - name: 'id', - positional: true, - type: 'string', - required: true, - help: 'Conversation UUID (the part after "convo-pill-" in the sidebar testid)', - }, -]; diff --git a/plugins/antigravity/audit-extras.js b/plugins/antigravity/audit-extras.js deleted file mode 100644 index 2796044c..00000000 --- a/plugins/antigravity/audit-extras.js +++ /dev/null @@ -1,341 +0,0 @@ -// Deep-audit gap closers for Antigravity (port 9234). -// -// Live snapshot of CodexBar agent project (chat view) showed 49 visible -// interactive elements / 28 unique labels. Beyond the 12 existing -// commands, these 10 wrap the rest: -// -// react — Good response / Bad response -// copy-message — text of last assistant turn (clicks last visible Copy) -// copy-code [--index N] — copy a specific code block (uses Copy code button) -// settings — click the settings-button data-testid -// sidebar-toggle — click Toggle Sidebar -// nav — Go Back / Go Forward -// toggle-aux — Toggle Auxiliary Pane -// display-options — open Display Options menu + list items -// add-context — click Add context (opens file/url picker) -// revert — click revert-button (per-message revert) - -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { - ArgumentError, - CommandExecutionError, - EmptyResultError, -} from '@agentrhq/webcmd/errors'; -import { unwrapEvaluateResult } from './_actions.js'; - -function clickFirstScript(sels) { - return `(() => { - const isVis = (el) => { const r = el.getBoundingClientRect(); return r.width > 1 && r.height > 1; }; - for (const sel of ${JSON.stringify(sels)}) { - const t = Array.from(document.querySelectorAll(sel)).filter(isVis)[0]; - if (t) { - const r = t.getBoundingClientRect(); - const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 }; - t.dispatchEvent(new PointerEvent('pointerdown', opts)); - t.dispatchEvent(new MouseEvent('mousedown', opts)); - t.dispatchEvent(new PointerEvent('pointerup', opts)); - t.dispatchEvent(new MouseEvent('mouseup', opts)); - t.click(); - return { ok: true, sel }; - } - } - return { ok: false, reason: 'No matching visible element.' }; - })()`; -} - -function clickLastScript(sels) { - return `(() => { - const isVis = (el) => { const r = el.getBoundingClientRect(); return r.width > 1 && r.height > 1; }; - for (const sel of ${JSON.stringify(sels)}) { - const found = Array.from(document.querySelectorAll(sel)).filter(isVis); - if (found.length) { - const t = found[found.length - 1]; - const r = t.getBoundingClientRect(); - const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 }; - t.dispatchEvent(new PointerEvent('pointerdown', opts)); - t.dispatchEvent(new MouseEvent('mousedown', opts)); - t.dispatchEvent(new PointerEvent('pointerup', opts)); - t.dispatchEvent(new MouseEvent('mouseup', opts)); - t.click(); - return { ok: true, sel }; - } - } - return { ok: false, reason: 'No matching visible element.' }; - })()`; -} - -// -------- react -------- -cli({ - site: 'antigravity', - name: 'react', - access: 'write', - description: 'Click "Good response" or "Bad response" on the LAST assistant message.', - domain: '127.0.0.1', - strategy: Strategy.UI, - browser: true, - args: [ - { name: 'kind', positional: true, required: true, help: 'good or bad' }, - ], - columns: ['Status', 'Reaction'], - func: async (page, kwargs) => { - const kind = String(kwargs?.kind || '').trim().toLowerCase(); - if (kind !== 'good' && kind !== 'bad') throw new ArgumentError('kind', 'must be "good" or "bad"'); - const label = kind === 'good' ? 'Good response' : 'Bad response'; - const res = unwrapEvaluateResult(await page.evaluate(clickLastScript([`button[aria-label="${label}"]`]))); - if (!res?.ok) throw new CommandExecutionError(res?.reason || `${label} click failed`, ''); - return [{ Status: 'clicked', Reaction: kind }]; - }, -}); - -// -------- copy-message -------- -cli({ - site: 'antigravity', - name: 'copy-message', - access: 'write', - description: 'Return the text of the last assistant message (best-effort: walks up from the last visible Copy button).', - domain: '127.0.0.1', - strategy: Strategy.UI, - browser: true, - args: [ - { name: 'click-button', type: 'boolean', default: false, help: 'Also click the in-UI Copy button' }, - ], - columns: ['Field', 'Value'], - func: async (page, kwargs) => { - const data = unwrapEvaluateResult(await page.evaluate(`(() => { - const isVis = (el) => { const r = el.getBoundingClientRect(); return r.width > 1 && r.height > 1; }; - // Antigravity has both "Copy" (message) and "Copy code" (code block) buttons. - // We want the bottom-of-message Copy, not the code-block Copy. - const copies = Array.from(document.querySelectorAll('button[aria-label="Copy"]')).filter(isVis); - if (!copies.length) return null; - const lastCopy = copies[copies.length - 1]; - let container = lastCopy; - let best = ''; - for (let i = 0; i < 8 && container.parentElement; i++) { - container = container.parentElement; - const txt = (container.innerText || '').trim(); - if (txt.length > best.length) best = txt; - if (best.length > 200) break; - } - return { text: best }; - })()`)); - if (!data) throw new EmptyResultError('antigravity copy-message', 'No Copy buttons visible — make sure an assistant reply is on screen.'); - if (kwargs?.['click-button'] === true || kwargs?.['click-button'] === 'true') { - const clickResult = unwrapEvaluateResult(await page.evaluate(clickLastScript(['button[aria-label="Copy"]']))); - if (!clickResult?.ok) { - throw new CommandExecutionError(clickResult?.reason || 'Copy button click failed', ''); - } - } - return [ - { Field: 'Length', Value: String((data.text || '').length) + ' chars' }, - { Field: 'ClipboardClicked', Value: (kwargs?.['click-button'] === true || kwargs?.['click-button'] === 'true') ? 'yes' : 'no' }, - { Field: 'Text', Value: data.text || '' }, - ]; - }, -}); - -// -------- copy-code -------- -cli({ - site: 'antigravity', - name: 'copy-code', - access: 'read', - description: 'Return the text of a code block in the current conversation. Default: last code block; pass --index N (1-based from top) to pick a specific one.', - domain: '127.0.0.1', - strategy: Strategy.UI, - browser: true, - args: [ - { name: 'index', type: 'int', required: false, help: '1-based index of code block (default: last)' }, - ], - columns: ['Field', 'Value'], - func: async (page, kwargs) => { - const idx = Number.isInteger(kwargs?.index) ? kwargs.index : null; - const data = unwrapEvaluateResult(await page.evaluate(`(() => { - const isVis = (el) => { const r = el.getBoundingClientRect(); return r.width > 1 && r.height > 1; }; - const btns = Array.from(document.querySelectorAll('button[aria-label="Copy code"]')).filter(isVis); - if (!btns.length) return null; - const idx = ${idx === null ? 'btns.length - 1' : (idx - 1)}; - const btn = btns[idx]; - if (!btn) return { err: 'index ' + (${idx} ?? 'last') + ' out of range. Have ' + btns.length + ' code blocks.' }; - // Find the or
 element inside the parent block.
-      let container = btn;
-      for (let i = 0; i < 6 && container.parentElement; i++) container = container.parentElement;
-      const code = container.querySelector('pre, code');
-      return { text: code ? (code.innerText || '').trim() : (container.innerText || '').trim(), total: btns.length };
-    })()`));
-        if (!data) throw new EmptyResultError('antigravity copy-code', 'No code blocks visible.');
-        if (data.err) throw new CommandExecutionError(data.err, '');
-        return [
-            { Field: 'TotalCodeBlocks', Value: String(data.total) },
-            { Field: 'PickedIndex', Value: String(idx === null ? data.total : idx) },
-            { Field: 'Length', Value: String((data.text || '').length) + ' chars' },
-            { Field: 'Code', Value: data.text || '' },
-        ];
-    },
-});
-
-// -------- settings --------
-cli({
-    site: 'antigravity',
-    name: 'settings',
-    access: 'write',
-    description: 'Click the Antigravity settings button (matched by data-testid="settings-button").',
-    domain: '127.0.0.1',
-    strategy: Strategy.UI,
-    browser: true,
-    args: [],
-    columns: ['Status'],
-    func: async (page) => {
-        const res = unwrapEvaluateResult(await page.evaluate(clickFirstScript([
-            '[data-testid="settings-button"]',
-            'button[aria-label="Settings"]',
-        ])));
-        if (!res?.ok) throw new CommandExecutionError(res?.reason || 'settings click failed', '');
-        await page.wait(0.6);
-        return [{ Status: `clicked via ${res.sel}` }];
-    },
-});
-
-// -------- sidebar-toggle --------
-cli({
-    site: 'antigravity',
-    name: 'sidebar-toggle',
-    access: 'write',
-    description: 'Click Toggle Sidebar (collapses/expands the Antigravity sidebar).',
-    domain: '127.0.0.1',
-    strategy: Strategy.UI,
-    browser: true,
-    args: [],
-    columns: ['Status'],
-    func: async (page) => {
-        const res = unwrapEvaluateResult(await page.evaluate(clickFirstScript(['button[aria-label="Toggle Sidebar"]'])));
-        if (!res?.ok) throw new CommandExecutionError(res?.reason || 'sidebar-toggle failed', '');
-        return [{ Status: 'toggled' }];
-    },
-});
-
-// -------- nav --------
-cli({
-    site: 'antigravity',
-    name: 'nav',
-    access: 'write',
-    description: 'Click Go Back or Go Forward (Antigravity in-app history).',
-    domain: '127.0.0.1',
-    strategy: Strategy.UI,
-    browser: true,
-    args: [
-        { name: 'direction', positional: true, required: true, help: 'back or forward' },
-    ],
-    columns: ['Status'],
-    func: async (page, kwargs) => {
-        const dir = String(kwargs?.direction || '').trim().toLowerCase();
-        if (dir !== 'back' && dir !== 'forward') throw new ArgumentError('direction', 'must be "back" or "forward"');
-        const label = dir === 'back' ? 'Go Back' : 'Go Forward';
-        const res = unwrapEvaluateResult(await page.evaluate(clickFirstScript([`button[aria-label="${label}"]`])));
-        if (!res?.ok) throw new CommandExecutionError(res?.reason || `${label} click failed`, '');
-        return [{ Status: `${dir} clicked` }];
-    },
-});
-
-// -------- toggle-aux --------
-cli({
-    site: 'antigravity',
-    name: 'toggle-aux',
-    access: 'write',
-    description: 'Toggle the Auxiliary Pane (Antigravity\'s secondary panel for code/preview).',
-    domain: '127.0.0.1',
-    strategy: Strategy.UI,
-    browser: true,
-    args: [],
-    columns: ['Status'],
-    func: async (page) => {
-        const res = unwrapEvaluateResult(await page.evaluate(clickFirstScript(['button[aria-label="Toggle Auxiliary Pane"]'])));
-        if (!res?.ok) throw new CommandExecutionError(res?.reason || 'toggle-aux failed', '');
-        return [{ Status: 'toggled' }];
-    },
-});
-
-// -------- display-options --------
-cli({
-    site: 'antigravity',
-    name: 'display-options',
-    access: 'read',
-    description: 'Open the Display Options menu and list its items.',
-    domain: '127.0.0.1',
-    strategy: Strategy.UI,
-    browser: true,
-    args: [],
-    columns: ['Index', 'Item'],
-    func: async (page) => {
-        const res = unwrapEvaluateResult(await page.evaluate(clickFirstScript(['button[aria-label="Display Options"]'])));
-        if (!res?.ok) throw new CommandExecutionError(res?.reason || 'display-options click failed', '');
-        await page.wait(0.4);
-        // Antigravity renders Display Options as a [role="dialog"] popover,
-        // NOT a [role="menu"]. Search both. Among visible candidates, prefer
-        // the most-recently-mounted small popover (not a full-page dialog).
-        const items = unwrapEvaluateResult(await page.evaluate(`(() => {
-      const isVis = (el) => { const r = el.getBoundingClientRect(); return r.width > 1 && r.height > 1; };
-      const candidates = Array.from(document.querySelectorAll('[role="menu"], [role="dialog"], [class*="popover"i]'))
-        .filter(isVis)
-        // Filter out app-shell dialogs (huge ones); prefer small popovers (<600px wide).
-        .filter((el) => {
-          const r = el.getBoundingClientRect();
-          return r.width < 600 && r.height < 600;
-        });
-      if (!candidates.length) return [];
-      // The popover is usually the LAST one mounted (highest in DOM order).
-      const menu = candidates[candidates.length - 1];
-      return Array.from(menu.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], button'))
-        .filter(isVis)
-        .map((it) => (it.innerText || '').trim().replace(/\\s+/g, ' '))
-        .filter(Boolean);
-    })()`));
-        try { await page.evaluate(`document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));`); } catch {}
-        if (!items.length) {
-            throw new EmptyResultError('antigravity display-options', 'Menu opened but no items detected.');
-        }
-        return items.map((it, i) => ({ Index: i + 1, Item: it }));
-    },
-});
-
-// -------- add-context --------
-cli({
-    site: 'antigravity',
-    name: 'add-context',
-    access: 'write',
-    description: 'Click the Add context button in the composer (opens file/URL picker for context attachment).',
-    domain: '127.0.0.1',
-    strategy: Strategy.UI,
-    browser: true,
-    args: [],
-    columns: ['Status'],
-    func: async (page) => {
-        const res = unwrapEvaluateResult(await page.evaluate(clickFirstScript(['button[aria-label="Add context"]'])));
-        if (!res?.ok) throw new CommandExecutionError(res?.reason || 'add-context click failed', '');
-        await page.wait(0.4);
-        return [{ Status: 'clicked — picker should be open' }];
-    },
-});
-
-// -------- revert --------
-cli({
-    site: 'antigravity',
-    name: 'revert',
-    access: 'write',
-    description: 'Click the revert button (per-message revert for agent changes). Requires --yes (this modifies your workspace).',
-    domain: '127.0.0.1',
-    strategy: Strategy.UI,
-    browser: true,
-    args: [
-        { name: 'yes', type: 'boolean', default: false, help: 'Actually revert (default: dry-run)' },
-    ],
-    columns: ['Status'],
-    func: async (page, kwargs) => {
-        const yes = kwargs?.yes === true || kwargs?.yes === 'true' || kwargs?.yes === '1';
-        if (!yes) {
-            return [{ Status: 'dry-run — pass --yes to revert (modifies workspace)' }];
-        }
-        const res = unwrapEvaluateResult(await page.evaluate(clickFirstScript(['[data-testid="revert-button"]', 'button[aria-label="Revert"]'])));
-        if (!res?.ok) throw new CommandExecutionError(res?.reason || 'revert click failed', '');
-        await page.wait(1);
-        return [{ Status: 'reverted' }];
-    },
-});
diff --git a/plugins/antigravity/delete.js b/plugins/antigravity/delete.js
deleted file mode 100644
index 24c45020..00000000
--- a/plugins/antigravity/delete.js
+++ /dev/null
@@ -1,60 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { CommandExecutionError } from '@agentrhq/webcmd/errors';
-import {
-    clickConversationMenuItem,
-    confirmDeleteDialog,
-    conversationVisible,
-    conversationTargetArgs,
-} from './_actions.js';
-
-cli({
-    site: 'antigravity',
-    name: 'delete',
-    access: 'write',
-    description: 'Delete an Antigravity conversation by ID. Antigravity asks for confirmation; we click through it. Require --yes to actually delete.',
-    domain: '127.0.0.1',
-    strategy: Strategy.UI,
-    browser: true,
-    args: [
-        ...conversationTargetArgs,
-        { name: 'yes', type: 'boolean', default: false, help: 'Actually delete (default: dry-run preview)' },
-    ],
-    columns: ['status', 'id'],
-    func: async (page, kwargs) => {
-        const id = String(kwargs.id);
-        const yes = kwargs.yes === true || kwargs.yes === 'true' || kwargs.yes === '1';
-        if (!yes) {
-            return [{ status: 'dry-run (pass --yes to actually delete)', id }];
-        }
-
-        // 1. Open the per-row 3-dot menu and click "Delete Conversation".
-        const menuRes = await clickConversationMenuItem(page, id, ['Delete Conversation', 'Delete']);
-        if (!menuRes.ok) {
-            throw new CommandExecutionError(
-                `${menuRes.reason}${menuRes.detail ? ' ' + menuRes.detail : ''}`,
-                'Make sure Antigravity is in the foreground and the sidebar is open.',
-            );
-        }
-
-        // 2. Click the Delete button in the confirm dialog.
-        const confirmRes = await confirmDeleteDialog(page, ['Delete', 'Delete Conversation', 'Confirm', 'OK']);
-        if (!confirmRes.ok) {
-            throw new CommandExecutionError(
-                `${confirmRes.reason}${confirmRes.detail ? ' ' + confirmRes.detail : ''}`,
-                'Delete menu fired but the confirm dialog did not show / its button was not found.',
-            );
-        }
-
-        await page.wait(1);
-        for (let attempt = 0; attempt < 10; attempt += 1) {
-            if (!(await conversationVisible(page, id))) {
-                return [{ status: 'deleted', id }];
-            }
-            await page.wait(0.5);
-        }
-        throw new CommandExecutionError(
-            `Delete did not remove conversation ${id} from the visible sidebar.`,
-            'The delete click/confirmation may have failed or the selector contract drifted.',
-        );
-    },
-});
diff --git a/plugins/antigravity/dump.js b/plugins/antigravity/dump.js
deleted file mode 100644
index b4189ded..00000000
--- a/plugins/antigravity/dump.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import * as fs from 'node:fs';
-export const dumpCommand = cli({
-    site: 'antigravity',
-    name: 'dump',
-    access: 'read',
-    description: 'Dump the DOM to help AI understand the UI',
-    domain: 'localhost',
-    strategy: Strategy.UI,
-    browser: true,
-    args: [],
-    columns: ['htmlFile', 'snapFile'],
-    func: async (page) => {
-        // Extract HTML
-        const html = await page.evaluate('document.body.innerHTML');
-        fs.writeFileSync('/tmp/antigravity-dom.html', html);
-        // Extract Snapshot
-        let snapFile = '';
-        try {
-            const snap = await page.snapshot({ raw: true });
-            snapFile = '/tmp/antigravity-snapshot.json';
-            fs.writeFileSync(snapFile, JSON.stringify(snap, null, 2));
-        }
-        catch (e) {
-            snapFile = 'Failed';
-        }
-        return [{ htmlFile: '/tmp/antigravity-dom.html', snapFile }];
-    },
-});
diff --git a/plugins/antigravity/extract-code.js b/plugins/antigravity/extract-code.js
deleted file mode 100644
index ef285432..00000000
--- a/plugins/antigravity/extract-code.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-export const extractCodeCommand = cli({
-    site: 'antigravity',
-    name: 'extract-code',
-    access: 'read',
-    description: 'Extract multi-line code blocks from the current Antigravity conversation',
-    domain: 'localhost',
-    strategy: Strategy.UI,
-    browser: true,
-    args: [],
-    columns: ['code'],
-    func: async (page) => {
-        const blocks = await page.evaluate(`
-      async () => {
-        // Find standard pre/code blocks
-        let elements = Array.from(document.querySelectorAll('pre code'));
-        
-        // Fallback to Monaco editor content inside the UI
-        if (elements.length === 0) {
-          elements = Array.from(document.querySelectorAll('.monaco-editor'));
-        }
-        
-        // Generic fallback to any code tag that spans multiple lines
-        if (elements.length === 0) {
-          elements = Array.from(document.querySelectorAll('code')).filter(c => c.innerText.includes('\\n'));
-        }
-        
-        return elements.map(el => el.innerText).filter(text => text.trim().length > 0);
-      }
-    `);
-        return blocks.map((code) => ({ code }));
-    },
-});
diff --git a/plugins/antigravity/history.js b/plugins/antigravity/history.js
deleted file mode 100644
index 1f65ffc1..00000000
--- a/plugins/antigravity/history.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { EmptyResultError } from '@agentrhq/webcmd/errors';
-import { listConversations } from './_actions.js';
-
-cli({
-    site: 'antigravity',
-    name: 'history',
-    access: 'read',
-    description: 'List visible Antigravity conversations from the sidebar',
-    domain: '127.0.0.1',
-    strategy: Strategy.UI,
-    browser: true,
-    args: [
-        { name: 'limit', type: 'int', required: false, default: 50, help: 'Max conversations to return' },
-    ],
-    columns: ['Index', 'Id', 'Title'],
-    func: async (page, kwargs) => {
-        const all = await listConversations(page);
-        const limit = Number.isInteger(kwargs.limit) && kwargs.limit > 0 ? kwargs.limit : 50;
-        const sliced = all.slice(0, limit);
-        if (!sliced.length) {
-            throw new EmptyResultError('antigravity history', 'No conversations are visible in the sidebar. Open the sidebar and retry.');
-        }
-        return sliced.map((c) => ({ Index: c.index, Id: c.id, Title: c.title }));
-    },
-});
diff --git a/plugins/antigravity/mark-read.js b/plugins/antigravity/mark-read.js
deleted file mode 100644
index 5a868139..00000000
--- a/plugins/antigravity/mark-read.js
+++ /dev/null
@@ -1,52 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { CommandExecutionError } from '@agentrhq/webcmd/errors';
-import { clickConversationMenuItem, conversationTargetArgs, getConversationMenuLabels } from './_actions.js';
-
-cli({
-    site: 'antigravity',
-    name: 'mark-read',
-    access: 'write',
-    description: 'Mark an unread Antigravity conversation as read. Fails if the row is already read or the postcondition cannot be verified.',
-    domain: '127.0.0.1',
-    strategy: Strategy.UI,
-    browser: true,
-    args: [...conversationTargetArgs],
-    columns: ['status', 'id', 'clicked'],
-    func: async (page, kwargs) => {
-        const id = String(kwargs.id);
-        const before = await getConversationMenuLabels(page, id);
-        if (!before.ok) {
-            throw new CommandExecutionError(
-                `${before.reason}${before.detail ? ' ' + before.detail : ''}`,
-                'Make sure Antigravity is in the foreground and the sidebar is open.',
-            );
-        }
-        if (!before.labels?.includes('Mark as Read')) {
-            throw new CommandExecutionError(
-                `Conversation ${id} is not currently markable as read.`,
-                `Visible menu labels: ${JSON.stringify(before.labels || [])}`,
-            );
-        }
-
-        const res = await clickConversationMenuItem(page, id, ['Mark as Read']);
-        if (!res.ok) {
-            throw new CommandExecutionError(
-                `${res.reason}${res.detail ? ' ' + res.detail : ''}`,
-                'Make sure Antigravity is in the foreground and the sidebar is open.',
-            );
-        }
-        await page.wait(0.6);
-        const after = await getConversationMenuLabels(page, id);
-        if (!after.ok || !after.labels?.includes('Mark as Unread')) {
-            throw new CommandExecutionError(
-                `Could not verify conversation ${id} was marked read.`,
-                `Visible menu labels after click: ${JSON.stringify(after.labels || [])}`,
-            );
-        }
-        return [{
-            status: 'marked-read',
-            id,
-            clicked: res.clicked,
-        }];
-    },
-});
diff --git a/plugins/antigravity/model.js b/plugins/antigravity/model.js
deleted file mode 100644
index 7b8114ea..00000000
--- a/plugins/antigravity/model.js
+++ /dev/null
@@ -1,161 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { ArgumentError, CommandExecutionError, selectorError } from '@agentrhq/webcmd/errors';
-import { unwrapEvaluateResult } from './_actions.js';
-
-// Antigravity exposes the active model via the composer button whose
-// aria-label looks like:
-//   "Select model, current: Gemini 3.5 Flash (Medium)"
-// We parse the current model from that aria-label, and switch by clicking
-// the button to open the model picker dialog, then matching by visible
-// text inside the dialog.
-
-cli({
-    site: 'antigravity',
-    name: 'model',
-    access: 'write',
-    description: 'Read or switch the active model in Antigravity. Without arguments, reports the current model. With  (substring, case-insensitive), switches.',
-    domain: '127.0.0.1',
-    strategy: Strategy.UI,
-    browser: true,
-    args: [
-        { name: 'name', required: false, positional: true, help: 'Substring (case-insensitive) of target model name. Omit to read current.' },
-        { name: 'list', type: 'boolean', default: false, help: 'List models in the picker (does not switch)' },
-    ],
-    columns: ['Status', 'Model'],
-    func: async (page, kwargs) => {
-        const name = String(kwargs.name || '').trim().toLowerCase();
-        const listOnly = kwargs.list === true || kwargs.list === 'true';
-        const normalize = (value) => String(value || '').trim().replace(/\s+/g, ' ').toLowerCase();
-
-        // Read current model from button's aria-label.
-        const current = unwrapEvaluateResult(await page.evaluate(`(function() {
-      const btn = document.querySelector('button[aria-label^="Select model, current:"]');
-      if (!btn) return '';
-      const aria = btn.getAttribute('aria-label') || '';
-      const m = aria.match(/current:\\s*(.*)$/i);
-      return m ? m[1].trim() : (btn.textContent || '').trim();
-    })()`));
-        if (!current) {
-            throw selectorError('Antigravity model button (button[aria-label^="Select model, current:"]). Make sure a chat is open in the foreground.');
-        }
-
-        if (!name && !listOnly) {
-            return [{ Status: 'Active', Model: current }];
-        }
-
-        const namejson = JSON.stringify(name);
-        const result = unwrapEvaluateResult(await page.evaluate(`(async () => {
-      const wait = (ms) => new Promise((r) => setTimeout(r, ms));
-      const trigger = document.querySelector('button[aria-label^="Select model, current:"]');
-      if (!trigger) return { ok: false, reason: 'trigger missing' };
-
-      // Open the picker dialog (full pointer chain — radix uses pointer events).
-      const r = trigger.getBoundingClientRect();
-      const init = {
-        bubbles: true, cancelable: true, button: 0, buttons: 1,
-        clientX: Math.round(r.left + r.width / 2),
-        clientY: Math.round(r.top + r.height / 2),
-      };
-      trigger.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerType: 'mouse' }));
-      trigger.dispatchEvent(new MouseEvent('mousedown', init));
-      trigger.dispatchEvent(new PointerEvent('pointerup', { ...init, pointerType: 'mouse' }));
-      trigger.dispatchEvent(new MouseEvent('mouseup', init));
-      trigger.dispatchEvent(new MouseEvent('click', init));
-
-      // Wait for the picker dialog to open. Antigravity renders it as a
-      // [role="dialog"] or a div with selectable rows (cursor-pointer).
-      let rows = [];
-      for (let attempt = 0; attempt < 18; attempt += 1) {
-        await wait(80);
-        rows = Array.from(document.querySelectorAll('[role="dialog"] .cursor-pointer, [role="dialog"] [role="option"], [role="dialog"] li, .cursor-pointer'))
-          .filter((el) => el instanceof HTMLElement && el.offsetParent);
-        // Filter out rows clearly outside the dialog (e.g. global cursor-pointer in sidebar)
-        const dialog = document.querySelector('[role="dialog"]');
-        if (dialog) {
-          rows = rows.filter((r) => dialog.contains(r));
-        }
-        if (rows.length) break;
-      }
-      if (!rows.length) {
-        return { ok: false, reason: 'Model picker dialog did not surface any rows.' };
-      }
-
-      const labels = rows.map((r) => (r.innerText || r.textContent || '').trim().slice(0, 80));
-      const target = ${namejson};
-      const listOnly = ${listOnly ? 'true' : 'false'};
-      if (!target || listOnly) {
-        // Close picker (Esc) and return list.
-        document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }));
-        return { ok: true, labels };
-      }
-      const exactMatches = labels
-        .map((label, index) => ({ label, index }))
-        .filter((entry) => entry.label.toLowerCase() === target);
-      const matches = exactMatches.length ? exactMatches : labels
-        .map((label, index) => ({ label, index }))
-        .filter((entry) => entry.label.toLowerCase().includes(target));
-      if (!matches.length) {
-        document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }));
-        return { ok: false, reason: 'No model matched.', detail: 'wanted=' + target + ' visible=' + JSON.stringify(labels) };
-      }
-      if (matches.length > 1) {
-        document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }));
-        return { ok: false, reason: 'Ambiguous model match.', detail: 'wanted=' + target + ' matches=' + JSON.stringify(matches.map((m) => m.label)) };
-      }
-      const chosen = rows[matches[0].index];
-      const chosenLabel = matches[0].label;
-
-      const cr = chosen.getBoundingClientRect();
-      const cinit = {
-        bubbles: true, cancelable: true, button: 0, buttons: 1,
-        clientX: Math.round(cr.left + cr.width / 2),
-        clientY: Math.round(cr.top + cr.height / 2),
-      };
-      Promise.resolve().then(() => {
-        try {
-          chosen.dispatchEvent(new PointerEvent('pointerdown', { ...cinit, pointerType: 'mouse' }));
-          chosen.dispatchEvent(new MouseEvent('mousedown', cinit));
-          chosen.dispatchEvent(new PointerEvent('pointerup', { ...cinit, pointerType: 'mouse' }));
-          chosen.dispatchEvent(new MouseEvent('mouseup', cinit));
-          chosen.dispatchEvent(new MouseEvent('click', cinit));
-        } catch {}
-      });
-      return { ok: true, switched: true, chosen: chosenLabel, labels };
-    })()`));
-
-        if (!result.ok) {
-            if (result.reason === 'Ambiguous model match.') {
-                throw new ArgumentError(result.detail || 'Ambiguous model match.');
-            }
-            throw new CommandExecutionError(result.reason, result.detail || '');
-        }
-        if (listOnly) {
-            return result.labels.map((m) => ({ Status: m.startsWith(current.slice(0, 20)) ? 'Active' : 'Available', Model: m }));
-        }
-        await page.wait(0.8);
-        let verified = '';
-        for (let attempt = 0; attempt < 8; attempt += 1) {
-            verified = unwrapEvaluateResult(await page.evaluate(`(function() {
-        const btn = document.querySelector('button[aria-label^="Select model, current:"]');
-        if (!btn) return '';
-        const aria = btn.getAttribute('aria-label') || '';
-        const m = aria.match(/current:\\s*(.*)$/i);
-        return m ? m[1].trim() : (btn.textContent || '').trim();
-      })()`));
-            if (
-                normalize(verified)
-                && (normalize(result.chosen).includes(normalize(verified)) || normalize(verified).includes(normalize(result.chosen)))
-            ) {
-                return [{ Status: 'switched', Model: verified }];
-            }
-            if (normalize(verified) === normalize(result.chosen)) {
-                return [{ Status: 'switched', Model: verified }];
-            }
-            await page.wait(0.4);
-        }
-        throw new CommandExecutionError(
-            `Could not verify Antigravity model switched to ${result.chosen}.`,
-            `Read back current model: ${verified || '(empty)'}`,
-        );
-    },
-});
diff --git a/plugins/antigravity/new.js b/plugins/antigravity/new.js
deleted file mode 100644
index 7513917a..00000000
--- a/plugins/antigravity/new.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-export const newCommand = cli({
-    site: 'antigravity',
-    name: 'new',
-    access: 'read',
-    description: 'Start a new conversation / clear context in Antigravity',
-    domain: 'localhost',
-    strategy: Strategy.UI,
-    browser: true,
-    args: [],
-    columns: ['status'],
-    func: async (page) => {
-        await page.evaluate(`
-      async () => {
-        const btn = document.querySelector('[data-tooltip-id="new-conversation-tooltip"]');
-        if (!btn) throw new Error('Could not find New Conversation button');
-        
-        // In case it's disabled, we must check, but we'll try to click it anyway
-        btn.click();
-      }
-    `);
-        // Give it a moment to reset the UI
-        await page.wait(0.5);
-        return [{ status: 'Successfully started a new conversation' }];
-    },
-});
diff --git a/plugins/antigravity/package.json b/plugins/antigravity/package.json
deleted file mode 100644
index ba5976f1..00000000
--- a/plugins/antigravity/package.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
-  "name": "webcmd-plugin-antigravity",
-  "version": "0.1.0",
-  "type": "module",
-  "description": "Webcmd commands for antigravity",
-  "peerDependencies": {
-    "@agentrhq/webcmd": ">=0.5.3"
-  }
-}
diff --git a/plugins/antigravity/read.js b/plugins/antigravity/read.js
deleted file mode 100644
index 746565e3..00000000
--- a/plugins/antigravity/read.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-export const readCommand = cli({
-    site: 'antigravity',
-    name: 'read',
-    access: 'read',
-    description: 'Read the latest chat messages from Antigravity AI',
-    domain: 'localhost',
-    strategy: Strategy.UI,
-    browser: true,
-    args: [
-        { name: 'last', help: 'Number of recent messages to read (not fully implemented due to generic structure, currently returns full history text or latest chunk)' }
-    ],
-    columns: ['role', 'content'],
-    func: async (page, kwargs) => {
-        // We execute a script inside Antigravity's Chromium environment to extract the text 
-        // of the entire conversation pane.
-        const rawText = await page.evaluate(`
-      async () => {
-        const container = document.getElementById('conversation');
-        if (!container) throw new Error('Could not find conversation container');
-        
-        // Extract the full visible text of the conversation
-        // In Electron/Chromium, innerText preserves basic visual line breaks nicely
-        return container.innerText;
-      }
-    `);
-        // We can do simple heuristic parsing based on typical visual markers if needed.
-        // For now, we return the entire text blob, or just the last 2000 characters if it's too long.
-        const cleanText = String(rawText).trim();
-        return [{
-                role: 'history',
-                content: cleanText
-            }];
-    },
-});
diff --git a/plugins/antigravity/rename.js b/plugins/antigravity/rename.js
deleted file mode 100644
index 068b4a25..00000000
--- a/plugins/antigravity/rename.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { CommandExecutionError } from '@agentrhq/webcmd/errors';
-import { conversationTargetArgs } from './_actions.js';
-
-// Known followup: a first attempt at rename triggered a destructive side
-// effect that removed the conversation from the sidebar (the convo titled
-// "1" disappeared after attempting `rename b79d8b28-... "..."` with the
-// Promise eval being collected mid-way). The 3-dot menu's Rename option
-// may interact with Antigravity's React state in a way that an
-// incomplete eval treats as "discard" — needs more investigation before
-// it's safe to ship.
-//
-// For now this command refuses to run; pin/delete/mark-read are wired up.
-cli({
-    site: 'antigravity',
-    name: 'rename',
-    access: 'write',
-    description: 'Rename an Antigravity conversation by ID (NOT YET IMPLEMENTED — see source comment).',
-    domain: '127.0.0.1',
-    strategy: Strategy.UI,
-    browser: true,
-    args: [
-        ...conversationTargetArgs,
-        { name: 'title', positional: true, type: 'string', required: true, help: 'New title' },
-    ],
-    columns: ['status'],
-    func: async () => {
-        throw new CommandExecutionError(
-            'antigravity rename is not yet implemented — first attempt caused the conversation to be removed from the sidebar instead of renamed. Use the Antigravity UI to rename until this is fixed.',
-            '',
-        );
-    },
-});
diff --git a/plugins/antigravity/send.js b/plugins/antigravity/send.js
deleted file mode 100644
index 6289e5b0..00000000
--- a/plugins/antigravity/send.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-export const sendCommand = cli({
-    site: 'antigravity',
-    name: 'send',
-    access: 'write',
-    description: 'Send a message to Antigravity AI via the internal Lexical editor',
-    domain: 'localhost',
-    strategy: Strategy.UI,
-    browser: true,
-    args: [
-        { name: 'message', help: 'The message text to send', required: true, positional: true }
-    ],
-    columns: ['Status', 'Message'],
-    func: async (page, kwargs) => {
-        const text = kwargs.message;
-        // We use evaluate to focus and insert text because Lexical editors maintain
-        // absolute control over their DOM and don't respond to raw node.textContent.
-        // document.execCommand simulates a native paste/typing action perfectly.
-        await page.evaluate(`
-      async () => {
-        const container = document.getElementById('antigravity.agentSidePanelInputBox');
-        if (!container) throw new Error('Could not find antigravity.agentSidePanelInputBox');
-        const editor = container.querySelector('[data-lexical-editor="true"]');
-        if (!editor) throw new Error('Could not find Antigravity input box');
-        
-        editor.focus();
-        document.execCommand('insertText', false, ${JSON.stringify(text)});
-      }
-    `);
-        // Wait for the React/Lexical state to flush the new input
-        await page.wait(0.5);
-        // Press Enter to submit the message
-        await page.pressKey('Enter');
-        return [{ Status: 'Sent successfully', Message: text }];
-    },
-});
diff --git a/plugins/antigravity/serve.js b/plugins/antigravity/serve.js
deleted file mode 100644
index 1707342e..00000000
--- a/plugins/antigravity/serve.js
+++ /dev/null
@@ -1,558 +0,0 @@
-/**
- * antigravity serve — Anthropic-compatible `/v1/messages` proxy server.
- *
- * Starts an HTTP server that accepts Anthropic Messages API requests,
- * forwards them to a running Antigravity app via CDP, polls for the response,
- * and returns it in Anthropic format.
- *
- * Usage:
- *   webcmd antigravity serve --port 8082
- *   ANTHROPIC_BASE_URL=http://localhost:8082 claude
- */
-import { createServer } from 'node:http';
-import { CDPBridge } from '@agentrhq/webcmd/browser/cdp';
-import { resolveElectronEndpoint } from '@agentrhq/webcmd/launcher';
-import { EXIT_CODES, getErrorMessage } from '@agentrhq/webcmd/errors';
-// ─── Helpers ─────────────────────────────────────────────────────────
-function generateMsgId() {
-    const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
-    let id = 'msg_';
-    for (let i = 0; i < 24; i++)
-        id += chars[Math.floor(Math.random() * chars.length)];
-    return id;
-}
-function estimateTokens(text) {
-    // Rough approximation: ~4 chars per token for English, ~2 for CJK
-    return Math.max(1, Math.ceil(text.length / 3));
-}
-function extractTextContent(content) {
-    if (typeof content === 'string')
-        return content;
-    return content
-        .filter(b => b.type === 'text' && b.text)
-        .map(b => b.text)
-        .join('\n');
-}
-function readBody(req) {
-    return new Promise((resolve, reject) => {
-        const chunks = [];
-        req.on('data', (c) => chunks.push(c));
-        req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
-        req.on('error', reject);
-    });
-}
-function jsonResponse(res, status, data) {
-    const body = JSON.stringify(data);
-    res.writeHead(status, {
-        'Content-Type': 'application/json',
-        'Access-Control-Allow-Origin': '*',
-        'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
-        'Access-Control-Allow-Headers': 'Content-Type, x-api-key, anthropic-version, Authorization',
-    });
-    res.end(body);
-}
-function sleep(ms) {
-    return new Promise(resolve => setTimeout(resolve, ms));
-}
-function parseTimeoutValue(val, label, fallback) {
-    if (val === undefined) {
-        return fallback;
-    }
-    const parsed = typeof val === 'number' ? val : parseInt(String(val), 10);
-    if (Number.isNaN(parsed) || parsed <= 0) {
-        console.error(`[serve] Invalid ${label}="${val}", using default ${fallback}s`);
-        return fallback;
-    }
-    return parsed;
-}
-function parseEnvTimeout(envVar, fallback) {
-    return parseTimeoutValue(process.env[envVar], envVar, fallback);
-}
-// ─── DOM helpers ─────────────────────────────────────────────────────
-/**
- * Click the 'New Conversation' button to reset context.
- */
-async function startNewConversation(page) {
-    await page.evaluate(`
-    (() => {
-      const btn = document.querySelector('[data-tooltip-id="new-conversation-tooltip"]');
-      if (btn) btn.click();
-    })()
-  `);
-    await sleep(1000); // Give UI time to clear
-}
-/**
- * Switch the active model in Antigravity UI.
- */
-async function switchModel(page, anthropicModelId) {
-    // Map standard model IDs to Antigravity UI names based on actual UI
-    let targetName = 'claude sonnet 4.6'; // Default fallback
-    const id = anthropicModelId.toLowerCase();
-    if (id.includes('sonnet')) {
-        targetName = 'claude sonnet 4.6';
-    }
-    else if (id.includes('opus')) {
-        targetName = 'claude opus 4.6';
-    }
-    else if (id.includes('gemini') && id.includes('pro')) {
-        targetName = 'gemini 3.1 pro (high)';
-    }
-    else if (id.includes('gemini') && id.includes('flash')) {
-        targetName = 'gemini 3 flash';
-    }
-    else if (id.includes('gpt')) {
-        targetName = 'gpt-oss 120b';
-    }
-    try {
-        await page.evaluate(`
-      async () => {
-        const targetModelName = ${JSON.stringify(targetName)};
-        const trigger = document.querySelector('div[aria-haspopup="dialog"] > div[tabindex="0"]');
-        if (!trigger) return; // Silent fail if UI changed
-        
-        // Open dropdown only if not already selected
-        if (trigger.innerText.toLowerCase().includes(targetModelName)) return;
-        
-        trigger.click();
-        await new Promise(r => setTimeout(r, 200));
-        
-        const spans = Array.from(document.querySelectorAll('[role="dialog"] span'));
-        const target = spans.find(s => s.innerText.toLowerCase().includes(targetModelName));
-        if (target) {
-          const optionNode = target.closest('.cursor-pointer') || target;
-          optionNode.click();
-        } else {
-          // Close if not found
-          trigger.click(); 
-        }
-      }
-    `);
-        await sleep(500); // Wait for switch
-    }
-    catch (err) {
-        console.error(`[serve] Warning: Could not switch to model ${targetName}:`, err);
-    }
-}
-/**
- * Check if the Antigravity UI is currently generating a response
- * by looking for Stop/Cancel buttons or loading indicators.
- */
-async function isGenerating(page) {
-    const result = await page.evaluate(`
-    (() => {
-      // Look for a cancel/stop button in the UI
-      const cancelBtn = document.querySelector('button[aria-label*="cancel" i], button[aria-label*="stop" i], button[title*="cancel" i], button[title*="stop" i]');
-      return !!cancelBtn;
-    })()
-  `);
-    return Boolean(result);
-}
-/**
- * Walk from the scroll container and find the deepest element that
- * has multiple non-empty children (our message container).
- */
-function findMessageContainer(root, depth = 0) {
-    if (!root || depth > 12)
-        return null;
-    const nonEmpty = Array.from(root.children).filter(c => c.innerText?.trim().length > 5);
-    if (nonEmpty.length >= 2)
-        return root;
-    if (nonEmpty.length === 1)
-        return findMessageContainer(nonEmpty[0], depth + 1);
-    return root;
-}
-// ─── Antigravity CDP Operations ──────────────────────────────────────
-/**
- * Get the full chat text for change-detection polling.
- */
-async function getConversationText(page) {
-    const text = await page.evaluate(`
-    (() => {
-      const container = document.getElementById('conversation');
-      if (!container) return '';
-      // Read only the first child div (actual chat content),
-      // skipping UI chrome like file change panels, model selectors, etc.
-      const chatContent = container.children[0];
-      return chatContent ? chatContent.innerText : container.innerText;
-    })()
-  `);
-    return String(text ?? '');
-}
-/**
- * Get the text of the last assistant reply by navigating to the message container
- * and extracting the last non-empty message block.
- */
-async function getLastAssistantReply(page, userText) {
-    const text = await page.evaluate(`
-    (() => {
-      const conv = document.getElementById('conversation')?.children[0];
-      const scroll = conv?.querySelector('.overflow-y-auto');
-      
-      // Walk down until we find a container with multiple message siblings
-      function findMsgContainer(el, depth) {
-        if (!el || depth > 12) return null;
-        const nonEmpty = Array.from(el.children).filter(c => c.innerText && c.innerText.trim().length > 5);
-        if (nonEmpty.length >= 2) return el;
-        if (nonEmpty.length === 1) return findMsgContainer(nonEmpty[0], depth + 1);
-        return null;
-      }
-      
-      const container = findMsgContainer(scroll || conv, 0);
-      if (!container) return '';
-      
-      // Get all non-empty children (skip trailing empty UI divs)
-      const msgs = Array.from(container.children).filter(
-        c => c.innerText && c.innerText.trim().length > 5
-      );
-      
-      if (msgs.length === 0) return '';
-      
-      // The last element is the last assistant reply
-      const last = msgs[msgs.length - 1];
-      return last.innerText || '';
-    })()
-  `);
-    let reply = String(text ?? '').trim();
-    // Strip echoed user message from the top (Antigravity sometimes includes it)
-    if (userText && reply.startsWith(userText)) {
-        reply = reply.slice(userText.length).trim();
-    }
-    // Strip thinking block: "Thought for Xs\n..." at the start
-    reply = reply.replace(/^Thought for[^\n]*\n+/i, '').trim();
-    // Strip "Copy" button text at the end
-    reply = reply.replace(/\s*\bCopy\b\s*$/m, '').trim();
-    // De-duplicate trailing repeated content (e.g., "OK\n\nOK" → "OK")
-    const half = Math.floor(reply.length / 2);
-    const firstHalf = reply.slice(0, half).trim();
-    const secondHalf = reply.slice(half).trim();
-    if (firstHalf && firstHalf === secondHalf) {
-        reply = firstHalf;
-    }
-    return reply;
-}
-async function sendMessage(page, message, bridge) {
-    if (!bridge) {
-        // Fallback: use JS-based approach
-        await page.evaluate(`
-      (() => {
-        const container = document.getElementById('antigravity.agentSidePanelInputBox');
-        const editor = container?.querySelector('[data-lexical-editor="true"]');
-        if (!editor) throw new Error('Could not find input box');
-        editor.focus();
-        document.execCommand('insertText', false, ${JSON.stringify(message)});
-      })()
-    `);
-        await sleep(500);
-        await page.pressKey('Enter');
-        return;
-    }
-    // Get the bounding box of the Lexical editor for a physical mouse click
-    const rect = await page.evaluate(`
-    (() => {
-      const container = document.getElementById('antigravity.agentSidePanelInputBox');
-      if (!container) throw new Error('Could not find antigravity.agentSidePanelInputBox');
-      const editor = container.querySelector('[data-lexical-editor="true"]');
-      if (!editor) throw new Error('Could not find Antigravity input box');
-      const r = editor.getBoundingClientRect();
-      return JSON.stringify({ x: r.left + r.width / 2, y: r.top + r.height / 2 });
-    })()
-  `);
-    const { x, y } = JSON.parse(String(rect));
-    // Physical mouse click to give the element real browser focus
-    await bridge.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 });
-    await sleep(50);
-    await bridge.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });
-    await sleep(200);
-    // Inject text at the CDP level (no deprecated execCommand)
-    await bridge.send('Input.insertText', { text: message });
-    await sleep(300);
-    // Send Enter via native CDP key event
-    await bridge.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 });
-    await sleep(50);
-    await bridge.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 });
-}
-async function waitForReply(page, beforeText, opts = {}) {
-    const timeout = opts.timeout ?? 120_000; // 2 minutes max
-    const pollInterval = opts.pollInterval ?? 500; // 500ms polling
-    const deadline = Date.now() + timeout;
-    // Wait a bit to ensure the UI transitions to "generating" state after we hit Enter
-    await sleep(1000);
-    let hasStartedGenerating = false;
-    let lastText = beforeText;
-    let stableCount = 0;
-    const stableThreshold = 4; // 4 * 500ms = 2s of stability fallback
-    let reconnectCount = 0;
-    while (Date.now() < deadline) {
-        try {
-            const generating = await isGenerating(page);
-            const currentText = await getConversationText(page);
-            const textChanged = currentText !== beforeText && currentText.length > 0;
-            if (generating) {
-                hasStartedGenerating = true;
-                stableCount = 0; // Reset stability while generating
-            }
-            else {
-                if (hasStartedGenerating) {
-                    // It actively generated and now it stopped -> DONE
-                    // Provide a small buffer to let React render the final message fully
-                    await sleep(500);
-                    return page;
-                }
-                // Fallback: If it never showed "Generating/Cancel", but text changed and is stable
-                if (textChanged) {
-                    if (currentText === lastText) {
-                        stableCount++;
-                        if (stableCount >= stableThreshold) {
-                            return page; // Text has been stable for 2 seconds -> DONE
-                        }
-                    }
-                    else {
-                        stableCount = 0;
-                        lastText = currentText;
-                    }
-                }
-            }
-        }
-        catch (err) {
-            const msg = err.message || String(err);
-            const isSessionLoss = /closed|lost|not open|websocket/i.test(msg);
-            if (opts.reconnect && isSessionLoss && reconnectCount < 2) {
-                reconnectCount++;
-                console.error(`[serve] CDP session loss detected (${msg}), attempting to reconnect (${reconnectCount}/2)...`);
-                try {
-                    page = await opts.reconnect();
-                    // Reset stability tracking after reconnect
-                    stableCount = 0;
-                    lastText = beforeText;
-                    continue;
-                }
-                catch (reconnectErr) {
-                    console.error(`[serve] Reconnection failed: ${reconnectErr.message}`);
-                    throw err; // Throw original error if reconnection itself fails
-                }
-            }
-            throw err;
-        }
-        await sleep(pollInterval);
-    }
-    throw new Error(`Timeout waiting for Antigravity reply after ${timeout / 1000}s`);
-}
-// ─── Request Handlers ────────────────────────────────────────────────
-async function handleMessages(body, page, opts = {}) {
-    const { bridge, timeout, reconnect } = opts;
-    // Extract the last user message
-    const userMessages = body.messages.filter(m => m.role === 'user');
-    if (userMessages.length === 0) {
-        throw new Error('No user message found in request');
-    }
-    const lastUserMsg = userMessages[userMessages.length - 1];
-    const userText = extractTextContent(lastUserMsg.content);
-    if (!userText.trim()) {
-        throw new Error('Empty user message');
-    }
-    // Optimization 1: New conversation if this is the first message in the session
-    if (body.messages.length === 1) {
-        console.error(`[serve] New session detected (1 message). Starting new conversation in UI.`);
-        await startNewConversation(page);
-    }
-    // Optimization 3: Switch model if requested
-    if (body.model) {
-        await switchModel(page, body.model);
-    }
-    // Get conversation state before sending
-    const beforeText = await getConversationText(page);
-    // Send the message
-    console.error(`[serve] Sending: "${userText.slice(0, 80)}${userText.length > 80 ? '...' : ''}"`);
-    await sendMessage(page, userText, bridge);
-    // Poll for reply (change detection)
-    console.error('[serve] Waiting for reply...');
-    page = await waitForReply(page, beforeText, { timeout, reconnect });
-    // Extract the actual reply text precisely from the DOM
-    const replyText = await getLastAssistantReply(page, userText);
-    console.error(`[serve] Got reply: "${replyText.slice(0, 80)}${replyText.length > 80 ? '...' : ''}"`);
-    return {
-        id: generateMsgId(),
-        type: 'message',
-        role: 'assistant',
-        content: [{ type: 'text', text: replyText }],
-        model: body.model ?? 'antigravity',
-        stop_reason: 'end_turn',
-        stop_sequence: null,
-        usage: {
-            input_tokens: estimateTokens(userText),
-            output_tokens: estimateTokens(replyText),
-        },
-    };
-}
-// ─── Server ──────────────────────────────────────────────────────────
-export async function startServe(opts = {}) {
-    const port = opts.port ?? 8082;
-    const envTimeoutSeconds = parseEnvTimeout('WEBCMD_ANTIGRAVITY_TIMEOUT', 120);
-    const effectiveTimeoutSeconds = parseTimeoutValue(opts.timeout, '--timeout', envTimeoutSeconds);
-    const effectiveTimeout = effectiveTimeoutSeconds * 1000;
-    console.error(`[serve] Starting Antigravity API proxy on port ${port} (timeout: ${effectiveTimeout / 1000}s)`);
-    // Lazy CDP connection — connect when first request comes in
-    let cdp = null;
-    let page = null;
-    let requestInFlight = false;
-    async function ensureConnected() {
-        if (page) {
-            try {
-                await page.evaluate('1+1');
-                return page;
-            }
-            catch {
-                console.error('[serve] CDP connection lost, reconnecting...');
-                cdp?.close().catch(() => { });
-                cdp = null;
-                page = null;
-            }
-        }
-        const endpoint = await resolveElectronEndpoint('antigravity');
-        // Note: Antigravity chat panel lives inside editor windows, not in Launchpad.
-        // If multiple editor windows are open, set WEBCMD_CDP_TARGET to the window title.
-        if (process.env.WEBCMD_CDP_TARGET) {
-            console.error(`[serve] Using WEBCMD_CDP_TARGET=${process.env.WEBCMD_CDP_TARGET}`);
-        }
-        // List available targets for debugging
-        try {
-            const res = await fetch(`${endpoint.replace(/\/$/, '')}/json`);
-            const targets = await res.json();
-            const pages = targets.filter(t => t.type === 'page');
-            console.error(`[serve] Available targets: ${pages.map(t => `"${t.title}"`).join(', ')}`);
-        }
-        catch { /* ignore */ }
-        console.error(`[serve] Connecting via CDP (target pattern: "${process.env.WEBCMD_CDP_TARGET}")...`);
-        cdp = new CDPBridge();
-        try {
-            page = await cdp.connect({ timeout: 15_000, cdpEndpoint: endpoint });
-        }
-        catch (err) {
-            cdp = null;
-            const errMsg = getErrorMessage(err);
-            const cause = err instanceof Error ? err.cause : undefined;
-            const isRefused = cause?.code === 'ECONNREFUSED' || errMsg.includes('ECONNREFUSED');
-            throw new Error(isRefused
-                ? `Cannot connect to Antigravity at ${endpoint}.\n` +
-                    '  1. Make sure Antigravity is running\n' +
-                    '  2. Launch with: --remote-debugging-port=9234'
-                : `CDP connection failed: ${errMsg}`);
-        }
-        console.error('[serve] ✅ CDP connected.');
-        // Quick verification
-        const hasUI = await page.evaluate(`
-      (() => !!document.getElementById('conversation') || !!document.getElementById('antigravity.agentSidePanelInputBox'))()
-    `);
-        if (!hasUI) {
-            console.error('[serve] ⚠️  Warning: chat UI elements not found in this target. Try setting WEBCMD_CDP_TARGET to the correct window title.');
-        }
-        return page;
-    }
-    const server = createServer(async (req, res) => {
-        // CORS preflight
-        if (req.method === 'OPTIONS') {
-            res.writeHead(204, {
-                'Access-Control-Allow-Origin': '*',
-                'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
-                'Access-Control-Allow-Headers': 'Content-Type, x-api-key, anthropic-version, Authorization',
-            });
-            res.end();
-            return;
-        }
-        const url = req.url ?? '/';
-        const pathname = url.split('?')[0];
-        try {
-            // GET /v1/models — return available models
-            if (req.method === 'GET' && pathname === '/v1/models') {
-                jsonResponse(res, 200, {
-                    data: [
-                        {
-                            id: 'antigravity',
-                            object: 'model',
-                            created: Math.floor(Date.now() / 1000),
-                            owned_by: 'antigravity',
-                        },
-                    ],
-                });
-                return;
-            }
-            // POST /v1/messages — main endpoint
-            if (req.method === 'POST' && pathname === '/v1/messages') {
-                if (requestInFlight) {
-                    jsonResponse(res, 429, {
-                        type: 'error',
-                        error: {
-                            type: 'rate_limit_error',
-                            message: 'Another request is currently being processed. Antigravity can only handle one request at a time.',
-                        },
-                    });
-                    return;
-                }
-                requestInFlight = true;
-                try {
-                    const rawBody = await readBody(req);
-                    const body = JSON.parse(rawBody);
-                    if (body.stream) {
-                        jsonResponse(res, 400, {
-                            type: 'error',
-                            error: {
-                                type: 'invalid_request_error',
-                                message: 'Streaming is not supported. Set "stream": false.',
-                            },
-                        });
-                        return;
-                    }
-                    // Lazy connect on first request
-                    const activePage = await ensureConnected();
-                    const response = await handleMessages(body, activePage, {
-                        bridge: cdp,
-                        timeout: effectiveTimeout,
-                        reconnect: ensureConnected,
-                    });
-                    jsonResponse(res, 200, response);
-                }
-                finally {
-                    requestInFlight = false;
-                }
-                return;
-            }
-            // Health check
-            if (req.method === 'GET' && (pathname === '/' || pathname === '/health')) {
-                jsonResponse(res, 200, { ok: true, cdpConnected: page !== null });
-                return;
-            }
-            jsonResponse(res, 404, {
-                type: 'error',
-                error: { type: 'not_found_error', message: `Not found: ${pathname}` },
-            });
-        }
-        catch (err) {
-            console.error('[serve] Error:', err instanceof Error ? err.message : err);
-            jsonResponse(res, 500, {
-                type: 'error',
-                error: {
-                    type: 'api_error',
-                    message: err instanceof Error ? err.message : 'Internal server error',
-                },
-            });
-        }
-    });
-    server.listen(port, '127.0.0.1', () => {
-        console.error(`\n[serve] ✅ Antigravity API proxy running at http://127.0.0.1:${port}`);
-        console.error(`[serve] Compatible with Anthropic /v1/messages API`);
-        console.error(`[serve] CDP connection will be established on first request.`);
-        console.error(`\n[serve] Usage with Claude Code:`);
-        console.error(`  ANTHROPIC_BASE_URL=http://localhost:${port} claude\n`);
-    });
-    // Graceful shutdown
-    const shutdown = () => {
-        console.error('\n[serve] Shutting down...');
-        cdp?.close().catch(() => { });
-        server.close();
-        process.exit(EXIT_CODES.SUCCESS);
-    };
-    process.on('SIGTERM', shutdown);
-    process.on('SIGINT', shutdown);
-    // Keep alive
-    await new Promise(() => { });
-}
diff --git a/plugins/antigravity/status.js b/plugins/antigravity/status.js
deleted file mode 100644
index 632496c8..00000000
--- a/plugins/antigravity/status.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-export const statusCommand = cli({
-    site: 'antigravity',
-    name: 'status',
-    access: 'read',
-    description: 'Check Antigravity CDP connection and get current page state',
-    domain: 'localhost',
-    strategy: Strategy.UI,
-    browser: true,
-    args: [],
-    columns: ['status', 'url', 'title'],
-    func: async (page) => {
-        return {
-            status: 'Connected',
-            url: await page.evaluate('window.location.href'),
-            title: await page.evaluate('document.title'),
-        };
-    },
-});
diff --git a/plugins/antigravity/storage.js b/plugins/antigravity/storage.js
deleted file mode 100644
index 66284468..00000000
--- a/plugins/antigravity/storage.js
+++ /dev/null
@@ -1,366 +0,0 @@
-// Storage commands for Antigravity:
-//   Renderer-side (4): storage-keys / storage-get / cookies / idb-list
-//   VSCode FS-side (4): state-keys / state-get / recent-paths / workspaces-list
-//   Settings (1):       settings-read
-
-import * as fs from 'node:fs';
-import * as path from 'node:path';
-import * as os from 'node:os';
-import { execFileSync } from 'node:child_process';
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import {
-    ArgumentError,
-    CommandExecutionError,
-    EmptyResultError,
-} from '@agentrhq/webcmd/errors';
-import { unwrapEvaluateResult } from './_actions.js';
-
-const STORAGE_COLUMNS = [
-    'Index',
-    'Key',
-    'Bytes',
-    'Name',
-    'Preview',
-    'Database',
-    'Version',
-    'Kind',
-    'Path',
-    'Workspace Id',
-    'Folder',
-    'Modified',
-    'Field',
-    'Value',
-];
-
-// ====== Path helpers ======
-const AG_APP_SUPPORT = path.join(os.homedir(), 'Library/Application Support/Antigravity');
-const AG_USER_DIR = path.join(AG_APP_SUPPORT, 'User');
-const AG_GLOBAL_STATE_DB = path.join(AG_USER_DIR, 'globalStorage/state.vscdb');
-const AG_WORKSPACE_STORAGE = path.join(AG_USER_DIR, 'workspaceStorage');
-const AG_SETTINGS_JSON = path.join(AG_USER_DIR, 'settings.json');
-
-function sqliteQuery(db, sql) {
-    if (!fs.existsSync(db)) {
-        throw new CommandExecutionError(`state.vscdb not found: ${db}`, 'Has Antigravity been run at least once?');
-    }
-    try {
-        return execFileSync('/usr/bin/sqlite3', [db, sql], { encoding: 'utf-8', maxBuffer: 64 * 1024 * 1024 });
-    } catch (e) {
-        throw new CommandExecutionError(
-            `sqlite3 failed on ${path.basename(db)}: ${e.message}`,
-            'The DB may be locked by a running Antigravity instance. Try closing it or wait a few seconds.',
-        );
-    }
-}
-function listKeys(db) {
-    const out = sqliteQuery(db, 'SELECT key FROM ItemTable ORDER BY key;');
-    return out.split('\n').map((s) => s.trim()).filter(Boolean);
-}
-function getValue(db, key) {
-    const esc = key.replace(/'/g, "''");
-    const raw = sqliteQuery(db, `SELECT value FROM ItemTable WHERE key = '${esc}';`).trim();
-    if (!raw) return null;
-    try { return JSON.parse(raw); } catch { return raw; }
-}
-function resolveStateDb(args) {
-    const ws = args?.workspace ? String(args.workspace).trim() : '';
-    if (!ws) return AG_GLOBAL_STATE_DB;
-    const db = path.join(AG_WORKSPACE_STORAGE, ws, 'state.vscdb');
-    if (!fs.existsSync(db)) {
-        throw new CommandExecutionError(`Workspace state.vscdb not found: ${db}`, 'List workspace ids with `webcmd antigravity workspaces-list`.');
-    }
-    return db;
-}
-
-// ====== Renderer-side: storage-keys ======
-cli({
-    site: 'antigravity',
-    name: 'storage-keys',
-    access: 'read',
-    description: 'List localStorage / sessionStorage keys on the Antigravity renderer (CDP).',
-    domain: '127.0.0.1',
-    strategy: Strategy.UI,
-    browser: true,
-    args: [
-        { name: 'storage', required: false, default: 'local', help: '"local" or "session"' },
-        { name: 'filter', required: false, help: 'Case-insensitive substring filter' },
-        { name: 'limit', type: 'int', required: false, default: 100, help: 'Max rows to return' },
-    ],
-    columns: STORAGE_COLUMNS,
-    func: async (page, kwargs) => {
-        const s = String(kwargs?.storage || 'local').trim().toLowerCase();
-        if (s !== 'local' && s !== 'session') throw new ArgumentError('storage', 'must be "local" or "session"');
-        const store = s === 'session' ? 'sessionStorage' : 'localStorage';
-        const raw = unwrapEvaluateResult(await page.evaluate(`(() => {
-      const s = ${store};
-      const out = [];
-      for (let i = 0; i < s.length; i++) {
-        const k = s.key(i); const v = s.getItem(k) || '';
-        out.push({ k, bytes: v.length });
-      }
-      return out;
-    })()`));
-        const flt = kwargs?.filter ? String(kwargs.filter).toLowerCase() : null;
-        const filtered = flt ? raw.filter((r) => r.k.toLowerCase().includes(flt)) : raw;
-        if (!filtered.length) throw new EmptyResultError('antigravity storage-keys', flt ? `No keys match "${flt}".` : `${store} is empty.`);
-        filtered.sort((a, b) => a.k.localeCompare(b.k));
-        const limit = Number.isInteger(kwargs?.limit) && kwargs.limit > 0 ? kwargs.limit : 100;
-        return filtered.slice(0, limit).map((r, i) => ({ Index: i + 1, Key: r.k, Bytes: r.bytes }));
-    },
-});
-
-// ====== Renderer-side: storage-get ======
-cli({
-    site: 'antigravity',
-    name: 'storage-get',
-    access: 'read',
-    description: 'Read a single localStorage / sessionStorage value on the Antigravity renderer.',
-    domain: '127.0.0.1',
-    strategy: Strategy.UI,
-    browser: true,
-    args: [
-        { name: 'key', positional: true, required: true, help: 'Storage key name' },
-        { name: 'storage', required: false, default: 'local', help: '"local" or "session"' },
-        { name: 'max-bytes', type: 'int', required: false, default: 4000, help: 'Truncate value to this many chars' },
-    ],
-    columns: STORAGE_COLUMNS,
-    func: async (page, kwargs) => {
-        const key = String(kwargs?.key || '').trim();
-        if (!key) throw new ArgumentError('key', 'is required');
-        const s = String(kwargs?.storage || 'local').trim().toLowerCase();
-        const store = s === 'session' ? 'sessionStorage' : 'localStorage';
-        const raw = unwrapEvaluateResult(await page.evaluate(`${store}.getItem(${JSON.stringify(key)})`));
-        if (raw === null) throw new CommandExecutionError(`Key not found in ${store}: ${key}`, '');
-        const max = Number.isInteger(kwargs['max-bytes']) && kwargs['max-bytes'] > 0 ? kwargs['max-bytes'] : 4000;
-        let parsed = raw, kind = 'string';
-        try { parsed = JSON.parse(raw); kind = Array.isArray(parsed) ? 'array' : typeof parsed; } catch {}
-        const text = kind === 'string' ? parsed : JSON.stringify(parsed, null, 2);
-        const truncated = text.length > max;
-        return [
-            { Field: 'Key', Value: key },
-            { Field: 'Store', Value: store },
-            { Field: 'Type', Value: kind },
-            { Field: 'Size', Value: `${text.length} chars${truncated ? ' (truncated)' : ''}` },
-            { Field: 'Value', Value: truncated ? text.slice(0, max) + '\n...(truncated)' : text },
-        ];
-    },
-});
-
-// ====== Renderer-side: cookies ======
-cli({
-    site: 'antigravity',
-    name: 'cookies',
-    access: 'read',
-    description: 'List cookies on the Antigravity renderer (JS-visible via document.cookie).',
-    domain: '127.0.0.1',
-    strategy: Strategy.UI,
-    browser: true,
-    args: [],
-    columns: STORAGE_COLUMNS,
-    func: async (page) => {
-        const raw = unwrapEvaluateResult(await page.evaluate('document.cookie'));
-        if (!raw) throw new EmptyResultError('antigravity cookies', 'document.cookie is empty.');
-        const cookies = raw.split('; ').map((pair) => {
-            const idx = pair.indexOf('=');
-            if (idx < 0) return { name: pair, value: '' };
-            return { name: pair.slice(0, idx), value: pair.slice(idx + 1) };
-        });
-        return cookies.map((c, i) => ({
-            Index: i + 1, Name: c.name, Bytes: c.value.length,
-            Preview: c.value.slice(0, 40) + (c.value.length > 40 ? '…' : ''),
-        }));
-    },
-});
-
-// ====== Renderer-side: idb-list ======
-cli({
-    site: 'antigravity',
-    name: 'idb-list',
-    access: 'read',
-    description: 'List IndexedDB databases on the Antigravity renderer.',
-    domain: '127.0.0.1',
-    strategy: Strategy.UI,
-    browser: true,
-    args: [],
-    columns: STORAGE_COLUMNS,
-    func: async (page) => {
-        const dbs = unwrapEvaluateResult(await page.evaluate(`(async () => indexedDB.databases ? await indexedDB.databases() : [])()`));
-        if (!Array.isArray(dbs) || !dbs.length) throw new EmptyResultError('antigravity idb-list', 'No IndexedDB databases.');
-        return dbs.map((d, i) => ({ Index: i + 1, Database: d.name || '(unnamed)', Version: String(d.version || '') }));
-    },
-});
-
-// ====== FS-side: state-keys ======
-cli({
-    site: 'antigravity',
-    name: 'state-keys',
-    access: 'read',
-    description: 'List keys in Antigravity\'s globalStorage state.vscdb (VSCode-style). Pass --workspace  to query a per-workspace DB. Works while Antigravity is closed.',
-    domain: 'localhost',
-    strategy: Strategy.LOCAL,
-    browser: false,
-    args: [
-        { name: 'filter', required: false, help: 'Case-insensitive substring filter over keys' },
-        { name: 'workspace', required: false, help: 'Workspace id (from workspaces-list) to query per-workspace DB' },
-        { name: 'limit', type: 'int', required: false, default: 200, help: 'Max rows to return' },
-    ],
-    columns: STORAGE_COLUMNS,
-    func: async (args) => {
-        const db = resolveStateDb(args);
-        const keys = listKeys(db);
-        const flt = args?.filter ? String(args.filter).toLowerCase() : null;
-        const filtered = flt ? keys.filter((k) => k.toLowerCase().includes(flt)) : keys;
-        if (!filtered.length) throw new EmptyResultError('antigravity state-keys', flt ? `No keys match "${flt}".` : 'No keys.');
-        const limit = Number.isInteger(args?.limit) && args.limit > 0 ? args.limit : 200;
-        return filtered.slice(0, limit).map((k, i) => ({ Index: i + 1, Key: k }));
-    },
-});
-
-// ====== FS-side: state-get ======
-cli({
-    site: 'antigravity',
-    name: 'state-get',
-    access: 'read',
-    description: 'Read one value from Antigravity\'s state.vscdb. Pass --workspace  for per-workspace.',
-    domain: 'localhost',
-    strategy: Strategy.LOCAL,
-    browser: false,
-    args: [
-        { name: 'key', positional: true, required: true, help: 'Storage key name' },
-        { name: 'workspace', required: false, help: 'Workspace id (from workspaces-list) to query per-workspace DB' },
-        { name: 'max-bytes', type: 'int', required: false, default: 8000, help: 'Truncate value to this many chars' },
-    ],
-    columns: STORAGE_COLUMNS,
-    func: async (args) => {
-        const key = String(args?.key || '').trim();
-        if (!key) throw new ArgumentError('key', 'is required');
-        const db = resolveStateDb(args);
-        const val = getValue(db, key);
-        if (val === null) throw new CommandExecutionError(`Key not found: ${key}`, '');
-        const max = Number.isInteger(args['max-bytes']) && args['max-bytes'] > 0 ? args['max-bytes'] : 8000;
-        const valStr = typeof val === 'string' ? val : JSON.stringify(val, null, 2);
-        const truncated = valStr.length > max;
-        return [
-            { Field: 'Key', Value: key },
-            { Field: 'Type', Value: typeof val === 'string' ? 'string' : (Array.isArray(val) ? 'array' : typeof val) },
-            { Field: 'Size', Value: `${valStr.length} chars${truncated ? ' (truncated)' : ''}` },
-            { Field: 'Value', Value: truncated ? valStr.slice(0, max) + '\n...(truncated)' : valStr },
-        ];
-    },
-});
-
-// ====== FS-side: recent-paths ======
-cli({
-    site: 'antigravity',
-    name: 'recent-paths',
-    access: 'read',
-    description: 'Show Antigravity\'s recently-opened folders/files (history.recentlyOpenedPathsList).',
-    domain: 'localhost',
-    strategy: Strategy.LOCAL,
-    browser: false,
-    args: [
-        { name: 'limit', type: 'int', required: false, default: 20, help: 'Max rows to return' },
-    ],
-    columns: STORAGE_COLUMNS,
-    func: async (args) => {
-        const val = getValue(AG_GLOBAL_STATE_DB, 'history.recentlyOpenedPathsList');
-        if (!val) throw new EmptyResultError('antigravity recent-paths', 'No recent paths recorded.');
-        const entries = val.entries || [];
-        if (!entries.length) throw new EmptyResultError('antigravity recent-paths', 'Recent paths list is empty.');
-        const limit = Number.isInteger(args?.limit) && args.limit > 0 ? args.limit : 20;
-        return entries.slice(0, limit).map((e, i) => {
-            let kind = 'other', target = JSON.stringify(e).slice(0, 200);
-            if (e.folderUri) {
-                kind = 'folder';
-                target = decodeURI(String(e.folderUri).replace(/^file:\/\//, ''));
-            } else if (e.fileUri) {
-                kind = 'file';
-                target = decodeURI(String(e.fileUri).replace(/^file:\/\//, ''));
-            } else if (e.workspace?.configPath) {
-                kind = 'workspace';
-                target = decodeURI(String(e.workspace.configPath).replace(/^file:\/\//, ''));
-            }
-            return { Index: i + 1, Kind: kind, Path: target };
-        });
-    },
-});
-
-// ====== FS-side: workspaces-list ======
-cli({
-    site: 'antigravity',
-    name: 'workspaces-list',
-    access: 'read',
-    description: 'List Antigravity workspaceStorage entries (each represents a previously-opened folder).',
-    domain: 'localhost',
-    strategy: Strategy.LOCAL,
-    browser: false,
-    args: [
-        { name: 'limit', type: 'int', required: false, default: 50, help: 'Max rows to return' },
-    ],
-    columns: STORAGE_COLUMNS,
-    func: async (args) => {
-        if (!fs.existsSync(AG_WORKSPACE_STORAGE)) {
-            throw new CommandExecutionError(`workspaceStorage not found: ${AG_WORKSPACE_STORAGE}`, '');
-        }
-        const dirs = fs.readdirSync(AG_WORKSPACE_STORAGE).filter((n) => {
-            const full = path.join(AG_WORKSPACE_STORAGE, n);
-            return fs.statSync(full).isDirectory();
-        });
-        if (!dirs.length) throw new EmptyResultError('antigravity workspaces-list', 'No workspace storage.');
-        const rows = dirs.map((id) => {
-            const dir = path.join(AG_WORKSPACE_STORAGE, id);
-            const wj = path.join(dir, 'workspace.json');
-            let folder = '(no workspace.json)';
-            if (fs.existsSync(wj)) {
-                try {
-                    const outer = JSON.parse(fs.readFileSync(wj, 'utf-8'));
-                    if (outer.folder) folder = decodeURI(outer.folder.replace(/^file:\/\//, ''));
-                    else if (outer.workspace) folder = '(multi-folder) ' + decodeURI(outer.workspace.replace(/^file:\/\//, ''));
-                } catch { folder = '(invalid workspace.json)'; }
-            }
-            return { id, folder, mtime: fs.statSync(dir).mtimeMs };
-        }).sort((a, b) => b.mtime - a.mtime);
-        const limit = Number.isInteger(args?.limit) && args.limit > 0 ? args.limit : 50;
-        return rows.slice(0, limit).map((r, i) => ({
-            Index: i + 1,
-            'Workspace Id': r.id,
-            Folder: r.folder.slice(0, 120),
-            Modified: new Date(r.mtime).toISOString().replace('T', ' ').slice(0, 19),
-        }));
-    },
-});
-
-// ====== Settings ======
-cli({
-    site: 'antigravity',
-    name: 'settings-read',
-    access: 'read',
-    description: 'Read Antigravity\'s user settings.json (theme, proxy, agCockpit, tfa.system.autoAccept, etc.).',
-    domain: 'localhost',
-    strategy: Strategy.LOCAL,
-    browser: false,
-    args: [],
-    columns: STORAGE_COLUMNS,
-    func: async () => {
-        if (!fs.existsSync(AG_SETTINGS_JSON)) {
-            throw new CommandExecutionError(`settings.json not found: ${AG_SETTINGS_JSON}`, '');
-        }
-        const raw = fs.readFileSync(AG_SETTINGS_JSON, 'utf-8');
-        // VSCode allows JSONC (line + block comments + trailing commas).
-        // Strip comments and trailing commas before parsing.
-        const stripped = raw
-            .replace(/\/\*[\s\S]*?\*\//g, '')              // block comments
-            .replace(/^\s*\/\/.*$/gm, '')                  // line comments (full line)
-            .replace(/([^:"])\/\/.*$/gm, '$1')             // line comments (after code)
-            .replace(/,(\s*[}\]])/g, '$1');                // trailing commas
-        let obj;
-        try { obj = JSON.parse(stripped); } catch (e) {
-            throw new CommandExecutionError(`Failed to parse settings.json: ${e.message}`, '');
-        }
-        const rows = [];
-        for (const [k, v] of Object.entries(obj)) {
-            rows.push({ Field: k, Value: typeof v === 'object' ? JSON.stringify(v) : String(v) });
-        }
-        return rows;
-    },
-});
diff --git a/plugins/antigravity/test/antigravity.test.js b/plugins/antigravity/test/antigravity.test.js
deleted file mode 100644
index bc0178c5..00000000
--- a/plugins/antigravity/test/antigravity.test.js
+++ /dev/null
@@ -1,172 +0,0 @@
-import { beforeAll, describe, expect, it, vi } from 'vitest';
-import { getRegistry } from '@agentrhq/webcmd/registry';
-import { ArgumentError, CommandExecutionError } from '@agentrhq/webcmd/errors';
-import { listConversations } from '../_actions.js';
-import '../audit-extras.js';
-import '../delete.js';
-import '../history.js';
-import '../mark-read.js';
-import '../model.js';
-import '../rename.js';
-import '../storage.js';
-
-function makePage(evaluateResults = []) {
-    const queue = [...evaluateResults];
-    return {
-        evaluate: vi.fn(async () => (queue.length ? queue.shift() : null)),
-        wait: vi.fn(async () => {}),
-    };
-}
-
-describe('antigravity command registration', () => {
-    it('classifies commands by maximum side effect', () => {
-        const expected = {
-            history: 'read',
-            delete: 'write',
-            'mark-read': 'write',
-            model: 'write',
-            rename: 'write',
-            'copy-message': 'write',
-            'copy-code': 'read',
-            'state-keys': 'read',
-            'state-get': 'read',
-            'recent-paths': 'read',
-            'workspaces-list': 'read',
-            'settings-read': 'read',
-        };
-        for (const [name, access] of Object.entries(expected)) {
-            const command = getRegistry().get(`antigravity/${name}`);
-            expect(command, `antigravity/${name}`).toBeDefined();
-            expect(command.access).toBe(access);
-        }
-    });
-});
-
-describe('antigravity Browser Bridge envelopes', () => {
-    it('unwraps conversation listings returned as { session, data }', async () => {
-        const page = makePage([
-            { session: { id: 's1' }, data: [{ index: 1, id: 'abc', title: 'Demo' }] },
-        ]);
-
-        await expect(listConversations(page)).resolves.toEqual([
-            { index: 1, id: 'abc', title: 'Demo' },
-        ]);
-    });
-});
-
-describe('antigravity write postconditions', () => {
-    let deleteCommand;
-    let markReadCommand;
-    let modelCommand;
-    let storageKeysCommand;
-
-    beforeAll(() => {
-        deleteCommand = getRegistry().get('antigravity/delete');
-        markReadCommand = getRegistry().get('antigravity/mark-read');
-        modelCommand = getRegistry().get('antigravity/model');
-        storageKeysCommand = getRegistry().get('antigravity/storage-keys');
-    });
-
-    it('delete fails closed when the conversation remains visible after confirmation', async () => {
-        const page = makePage([
-            { ok: true, clicked: 'Delete Conversation' },
-            { ok: true, confirmed: 'Delete' },
-            true,
-            true,
-            true,
-            true,
-            true,
-            true,
-            true,
-            true,
-            true,
-            true,
-        ]);
-
-        await expect(deleteCommand.func(page, { id: 'abc', yes: true }))
-            .rejects.toBeInstanceOf(CommandExecutionError);
-    });
-
-    it('mark-read refuses to toggle already-read rows back to unread', async () => {
-        const page = makePage([
-            { ok: true, labels: ['Mark as Unread', 'Rename', 'Delete Conversation'] },
-        ]);
-
-        await expect(markReadCommand.func(page, { id: 'abc' }))
-            .rejects.toBeInstanceOf(CommandExecutionError);
-    });
-
-    it('model rejects ambiguous partial matches before clicking', async () => {
-        const page = makePage([
-            'Gemini 3.5 Flash',
-            { ok: false, reason: 'Ambiguous model match.', detail: 'wanted=gemini matches=["Gemini Pro","Gemini Flash"]' },
-        ]);
-
-        await expect(modelCommand.func(page, { name: 'gemini' }))
-            .rejects.toBeInstanceOf(ArgumentError);
-    });
-
-    it('model list mode never switches even when a name filter is supplied', async () => {
-        const page = makePage([
-            'Gemini 3.5 Flash',
-            { ok: true, labels: ['Gemini 3.5 Flash', 'Claude Sonnet'] },
-        ]);
-
-        await expect(modelCommand.func(page, { list: true, name: 'claude' })).resolves.toEqual([
-            { Status: 'Active', Model: 'Gemini 3.5 Flash' },
-            { Status: 'Available', Model: 'Claude Sonnet' },
-        ]);
-        expect(page.evaluate).toHaveBeenCalledTimes(2);
-    });
-
-    it('model accepts an exact match before falling back to ambiguous partial matching', async () => {
-        const page = makePage([
-            'Gemini 3.5 Flash',
-            { ok: true, switched: true, chosen: 'Gemini Pro', labels: ['Gemini Pro', 'Gemini Pro Extended'] },
-            'Gemini Pro',
-        ]);
-
-        await expect(modelCommand.func(page, { name: 'gemini pro' })).resolves.toEqual([
-            { Status: 'switched', Model: 'Gemini Pro' },
-        ]);
-    });
-
-    it('model fails closed when read-back does not prove the target is active', async () => {
-        const page = makePage([
-            'Gemini 3.5 Flash',
-            { ok: true, switched: true, chosen: 'Claude Sonnet', labels: ['Claude Sonnet'] },
-            'Gemini 3.5 Flash',
-            'Gemini 3.5 Flash',
-            'Gemini 3.5 Flash',
-            'Gemini 3.5 Flash',
-            'Gemini 3.5 Flash',
-            'Gemini 3.5 Flash',
-            'Gemini 3.5 Flash',
-            'Gemini 3.5 Flash',
-        ]);
-
-        await expect(modelCommand.func(page, { name: 'claude' }))
-            .rejects.toBeInstanceOf(CommandExecutionError);
-    });
-
-    it('storage-keys unwraps Browser Bridge envelopes before shaping rows', async () => {
-        const page = makePage([
-            { session: { id: 's1' }, data: [{ k: 'alpha', bytes: 12 }] },
-        ]);
-
-        await expect(storageKeysCommand.func(page, { storage: 'local' })).resolves.toEqual([
-            { Index: 1, Key: 'alpha', Bytes: 12 },
-        ]);
-    });
-
-    it('copy-message click-button fails closed when the in-UI copy click fails', async () => {
-        const copyMessageCommand = getRegistry().get('antigravity/copy-message');
-        const page = makePage([
-            { text: 'assistant response' },
-            { ok: false, reason: 'No matching visible element.' },
-        ]);
-
-        await expect(copyMessageCommand.func(page, { 'click-button': true }))
-            .rejects.toBeInstanceOf(CommandExecutionError);
-    });
-});
diff --git a/plugins/antigravity/watch.js b/plugins/antigravity/watch.js
deleted file mode 100644
index 42853893..00000000
--- a/plugins/antigravity/watch.js
+++ /dev/null
@@ -1,43 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-export const watchCommand = cli({
-    site: 'antigravity',
-    name: 'watch',
-    access: 'read',
-    description: 'Stream new chat messages from Antigravity in real-time',
-    domain: 'localhost',
-    strategy: Strategy.UI,
-    browser: true,
-    args: [
-        { name: 'timeout', type: 'int', required: false, default: 86400, help: 'Max seconds to keep watching (default: 86400 — 24h)' },
-    ],
-    columns: [], // We use direct stdout streaming
-    func: async (page) => {
-        console.log('Watching Antigravity chat... (Press Ctrl+C to stop)');
-        let lastLength = 0;
-        // Loop until process gets killed
-        while (true) {
-            const text = await page.evaluate(`
-        async () => {
-          const container = document.getElementById('conversation');
-          return container ? container.innerText : '';
-        }
-      `);
-            const currentLength = text.length;
-            if (currentLength > lastLength) {
-                // Delta mode
-                const newSegment = text.substring(lastLength);
-                if (newSegment.trim().length > 0) {
-                    process.stdout.write(newSegment);
-                }
-                lastLength = currentLength;
-            }
-            else if (currentLength < lastLength) {
-                // The conversation was cleared or updated significantly
-                lastLength = currentLength;
-                console.log('\\n--- Conversation Cleared/Changed ---\\n');
-                process.stdout.write(text);
-            }
-            await new Promise(resolve => setTimeout(resolve, 500));
-        }
-    },
-});
diff --git a/plugins/antigravity/webcmd-plugin.json b/plugins/antigravity/webcmd-plugin.json
deleted file mode 100644
index cfd295d3..00000000
--- a/plugins/antigravity/webcmd-plugin.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
-  "name": "antigravity",
-  "version": "0.1.0",
-  "description": "Webcmd commands for antigravity",
-  "webcmd": ">=0.5.3",
-  "author": {
-    "name": "WebCMD Agent",
-    "handle": "agentrhq"
-  }
-}
diff --git a/plugins/apple-podcasts/README.md b/plugins/apple-podcasts/README.md
deleted file mode 100644
index 5df1433d..00000000
--- a/plugins/apple-podcasts/README.md
+++ /dev/null
@@ -1,17 +0,0 @@
-# webcmd-plugin-apple-podcasts
-
-Webcmd commands for apple-podcasts.
-
-## Install
-
-```bash
-webcmd plugin install github:agentrhq/webcmd/apple-podcasts
-```
-
-## Commands
-
-| Command | Description |
-| --- | --- |
-| `webcmd apple-podcasts episodes` | List recent episodes of an Apple Podcast (use ID from search) |
-| `webcmd apple-podcasts search` | Search Apple Podcasts |
-| `webcmd apple-podcasts top` | Top podcasts chart on Apple Podcasts |
diff --git a/plugins/apple-podcasts/episodes.js b/plugins/apple-podcasts/episodes.js
deleted file mode 100644
index e65c9833..00000000
--- a/plugins/apple-podcasts/episodes.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { CliError } from '@agentrhq/webcmd/errors';
-import { itunesFetch, formatDuration, formatDate } from './utils.js';
-cli({
-    site: 'apple-podcasts',
-    name: 'episodes',
-    access: 'read',
-    description: 'List recent episodes of an Apple Podcast (use ID from search)',
-    strategy: Strategy.PUBLIC,
-    browser: false,
-    args: [
-        { name: 'id', positional: true, required: true, help: 'Podcast ID (collectionId from search output)' },
-        { name: 'limit', type: 'int', default: 15, help: 'Max episodes to show' },
-    ],
-    columns: ['title', 'duration', 'date'],
-    func: async (args) => {
-        const limit = Math.max(1, Math.min(Number(args.limit), 200));
-        // results[0] is the podcast itself; the rest are episodes
-        const data = await itunesFetch(`/lookup?id=${args.id}&entity=podcastEpisode&limit=${limit + 1}`);
-        const episodes = (data.results ?? []).filter((r) => r.kind === 'podcast-episode');
-        if (!episodes.length)
-            throw new CliError('NOT_FOUND', 'No episodes found', 'Check the podcast ID from: webcmd apple-podcasts search ');
-        return episodes.slice(0, limit).map((ep) => ({
-            title: ep.trackName,
-            duration: formatDuration(ep.trackTimeMillis),
-            date: formatDate(ep.releaseDate),
-        }));
-    },
-});
diff --git a/plugins/apple-podcasts/package.json b/plugins/apple-podcasts/package.json
deleted file mode 100644
index ca74a8c4..00000000
--- a/plugins/apple-podcasts/package.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
-  "name": "webcmd-plugin-apple-podcasts",
-  "version": "0.1.0",
-  "type": "module",
-  "description": "Webcmd commands for apple-podcasts",
-  "peerDependencies": {
-    "@agentrhq/webcmd": ">=0.5.3"
-  }
-}
diff --git a/plugins/apple-podcasts/search.js b/plugins/apple-podcasts/search.js
deleted file mode 100644
index f3a9f6e0..00000000
--- a/plugins/apple-podcasts/search.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { CliError } from '@agentrhq/webcmd/errors';
-import { itunesFetch } from './utils.js';
-cli({
-    site: 'apple-podcasts',
-    name: 'search',
-    tags: ['search'],
-    access: 'read',
-    description: 'Search Apple Podcasts',
-    strategy: Strategy.PUBLIC,
-    browser: false,
-    args: [
-        { name: 'query', positional: true, required: true, help: 'Search keyword' },
-        { name: 'limit', type: 'int', default: 10, help: 'Max results' },
-    ],
-    columns: ['id', 'title', 'author', 'episodes', 'genre', 'url'],
-    func: async (args) => {
-        const term = encodeURIComponent(args.query);
-        const limit = Math.max(1, Math.min(Number(args.limit), 25));
-        const data = await itunesFetch(`/search?term=${term}&media=podcast&limit=${limit}`);
-        if (!data.results?.length)
-            throw new CliError('NOT_FOUND', 'No podcasts found', `Try a different keyword`);
-        return data.results.map((p) => ({
-            id: p.collectionId,
-            title: p.collectionName,
-            author: p.artistName,
-            episodes: p.trackCount ?? '',
-            genre: p.primaryGenreName ?? '',
-            url: p.collectionViewUrl || '',
-        }));
-    },
-});
diff --git a/plugins/apple-podcasts/test/commands.test.js b/plugins/apple-podcasts/test/commands.test.js
deleted file mode 100644
index 34f5b408..00000000
--- a/plugins/apple-podcasts/test/commands.test.js
+++ /dev/null
@@ -1,119 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { getRegistry } from '@agentrhq/webcmd/registry';
-import '../search.js';
-import '../top.js';
-describe('apple-podcasts search command', () => {
-    beforeEach(() => {
-        vi.restoreAllMocks();
-    });
-    it('uses the positional query argument for the iTunes search request', async () => {
-        const cmd = getRegistry().get('apple-podcasts/search');
-        expect(cmd?.func).toBeTypeOf('function');
-        const fetchMock = vi.fn().mockResolvedValue({
-            ok: true,
-            json: () => Promise.resolve({
-                results: [
-                    {
-                        collectionId: 42,
-                        collectionName: 'Machine Learning Guide',
-                        artistName: 'Webcmd',
-                        trackCount: 12,
-                        primaryGenreName: 'Technology',
-                    },
-                ],
-            }),
-        });
-        vi.stubGlobal('fetch', fetchMock);
-        const result = await cmd.func({
-            query: 'machine learning',
-            keyword: 'sports',
-            limit: 5,
-        });
-        expect(fetchMock).toHaveBeenCalledWith('https://itunes.apple.com/search?term=machine%20learning&media=podcast&limit=5');
-        expect(result).toEqual([
-            expect.objectContaining({
-                id: 42,
-                title: 'Machine Learning Guide',
-                author: 'Webcmd',
-                episodes: 12,
-                genre: 'Technology',
-                url: '',
-            }),
-        ]);
-    });
-    it('emits empty-string for missing trackCount and primaryGenreName instead of a sentinel', async () => {
-        const cmd = getRegistry().get('apple-podcasts/search');
-        const fetchMock = vi.fn().mockResolvedValue({
-            ok: true,
-            json: () => Promise.resolve({
-                results: [
-                    {
-                        collectionId: 99,
-                        collectionName: 'No-Meta Show',
-                        artistName: 'Anon Host',
-                        collectionViewUrl: 'https://example.com/p/99',
-                    },
-                ],
-            }),
-        });
-        vi.stubGlobal('fetch', fetchMock);
-        const result = await cmd.func({ query: 'no-meta', limit: 1 });
-        expect(result[0].episodes).toBe('');
-        expect(result[0].genre).toBe('');
-    });
-});
-describe('apple-podcasts top command', () => {
-    beforeEach(() => {
-        vi.restoreAllMocks();
-    });
-    it('adds a timeout signal to chart fetches', async () => {
-        const cmd = getRegistry().get('apple-podcasts/top');
-        expect(cmd?.func).toBeTypeOf('function');
-        const fetchMock = vi.fn().mockResolvedValue({
-            ok: true,
-            json: () => Promise.resolve({
-                feed: {
-                    results: [
-                        { id: '100', name: 'Top Show', artistName: 'Host A' },
-                    ],
-                },
-            }),
-        });
-        vi.stubGlobal('fetch', fetchMock);
-        await cmd.func({ country: 'US', limit: 1 });
-        const [, options] = fetchMock.mock.calls[0] ?? [];
-        expect(options).toBeDefined();
-        expect(options.signal).toBeDefined();
-        expect(options.signal).toHaveProperty('aborted', false);
-    });
-    it('uses the canonical Apple charts host and maps ranked results', async () => {
-        const cmd = getRegistry().get('apple-podcasts/top');
-        expect(cmd?.func).toBeTypeOf('function');
-        const fetchMock = vi.fn().mockResolvedValue({
-            ok: true,
-            json: () => Promise.resolve({
-                feed: {
-                    results: [
-                        { id: '100', name: 'Top Show', artistName: 'Host A' },
-                        { id: '101', name: 'Second Show', artistName: 'Host B' },
-                    ],
-                },
-            }),
-        });
-        vi.stubGlobal('fetch', fetchMock);
-        const result = await cmd.func({ country: 'US', limit: 2 });
-        expect(fetchMock).toHaveBeenCalledWith('https://rss.marketingtools.apple.com/api/v2/us/podcasts/top/2/podcasts.json', expect.objectContaining({
-            signal: expect.any(Object),
-        }));
-        expect(result).toEqual([
-            { rank: 1, title: 'Top Show', author: 'Host A', id: '100' },
-            { rank: 2, title: 'Second Show', author: 'Host B', id: '101' },
-        ]);
-    });
-    it('normalizes network failures into CliError output', async () => {
-        const cmd = getRegistry().get('apple-podcasts/top');
-        expect(cmd?.func).toBeTypeOf('function');
-        vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('socket hang up')));
-        await expect(cmd.func({ country: 'us', limit: 3 })).rejects.toThrow('Unable to reach Apple Podcasts charts for US');
-    });
-});
diff --git a/plugins/apple-podcasts/test/utils.test.js b/plugins/apple-podcasts/test/utils.test.js
deleted file mode 100644
index 8e7a10f7..00000000
--- a/plugins/apple-podcasts/test/utils.test.js
+++ /dev/null
@@ -1,57 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { formatDuration, formatDate, itunesFetch } from '../utils.js';
-describe('formatDuration', () => {
-    it('formats typical duration in ms', () => {
-        expect(formatDuration(3661000)).toBe('61:01');
-    });
-    it('pads single-digit seconds', () => {
-        expect(formatDuration(65000)).toBe('1:05');
-    });
-    it('formats exact minutes', () => {
-        expect(formatDuration(3600000)).toBe('60:00');
-    });
-    it('rounds fractional milliseconds', () => {
-        expect(formatDuration(3600500)).toBe('60:01');
-    });
-    it('returns dash for zero', () => {
-        expect(formatDuration(0)).toBe('-');
-    });
-    it('returns dash for NaN', () => {
-        expect(formatDuration(NaN)).toBe('-');
-    });
-});
-describe('formatDate', () => {
-    it('extracts YYYY-MM-DD from ISO string', () => {
-        expect(formatDate('2026-03-19T12:00:00.000Z')).toBe('2026-03-19');
-    });
-    it('handles date-only string', () => {
-        expect(formatDate('2025-01-01')).toBe('2025-01-01');
-    });
-    it('returns dash for empty string', () => {
-        expect(formatDate('')).toBe('-');
-    });
-    it('returns dash for undefined', () => {
-        expect(formatDate(undefined)).toBe('-');
-    });
-});
-describe('itunesFetch', () => {
-    beforeEach(() => {
-        vi.restoreAllMocks();
-    });
-    it('returns parsed JSON on success', async () => {
-        const mockData = { resultCount: 1, results: [{ collectionId: 123 }] };
-        vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
-            ok: true,
-            json: () => Promise.resolve(mockData),
-        }));
-        const result = await itunesFetch('/search?term=test&media=podcast&limit=1');
-        expect(result).toEqual(mockData);
-    });
-    it('throws CliError on HTTP error', async () => {
-        vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
-            ok: false,
-            status: 403,
-        }));
-        await expect(itunesFetch('/search?term=test')).rejects.toThrow('iTunes API HTTP 403');
-    });
-});
diff --git a/plugins/apple-podcasts/top.js b/plugins/apple-podcasts/top.js
deleted file mode 100644
index 0bd7875e..00000000
--- a/plugins/apple-podcasts/top.js
+++ /dev/null
@@ -1,45 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { CliError } from '@agentrhq/webcmd/errors';
-// Apple Marketing Tools RSS API — public, no key required
-const CHARTS_URL = 'https://rss.marketingtools.apple.com/api/v2';
-const CHARTS_TIMEOUT_MS = 15_000;
-cli({
-    site: 'apple-podcasts',
-    name: 'top',
-    access: 'read',
-    description: 'Top podcasts chart on Apple Podcasts',
-    strategy: Strategy.PUBLIC,
-    browser: false,
-    args: [
-        { name: 'limit', type: 'int', default: 20, help: 'Number of podcasts (max 100)' },
-        { name: 'country', default: 'us', help: 'Country code (e.g. us, cn, gb, jp)' },
-    ],
-    columns: ['rank', 'title', 'author', 'id'],
-    func: async (args) => {
-        const limit = Math.max(1, Math.min(Number(args.limit), 100));
-        const country = String(args.country || 'us').trim().toLowerCase();
-        const url = `${CHARTS_URL}/${country}/podcasts/top/${limit}/podcasts.json`;
-        let resp;
-        try {
-            resp = await fetch(url, {
-                signal: AbortSignal.timeout(CHARTS_TIMEOUT_MS),
-            });
-        }
-        catch (error) {
-            const reason = error?.cause?.code ?? error?.message ?? 'unknown network error';
-            throw new CliError('FETCH_ERROR', `Unable to reach Apple Podcasts charts for ${country.toUpperCase()}`, `Apple charts may be temporarily unavailable (${reason}). Try again later.`);
-        }
-        if (!resp.ok)
-            throw new CliError('FETCH_ERROR', `Charts API HTTP ${resp.status}`, `Check country code: ${country}`);
-        const data = await resp.json();
-        const results = data?.feed?.results;
-        if (!results?.length)
-            throw new CliError('NOT_FOUND', 'No chart data found', `Try a different country code`);
-        return results.map((p, i) => ({
-            rank: i + 1,
-            title: p.name,
-            author: p.artistName,
-            id: p.id,
-        }));
-    },
-});
diff --git a/plugins/apple-podcasts/utils.js b/plugins/apple-podcasts/utils.js
deleted file mode 100644
index 5641e996..00000000
--- a/plugins/apple-podcasts/utils.js
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * Shared Apple Podcasts utilities.
- *
- * Uses the public iTunes Search API — no API key required.
- * https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/
- */
-import { CliError } from '@agentrhq/webcmd/errors';
-const BASE = 'https://itunes.apple.com';
-export async function itunesFetch(path) {
-    const resp = await fetch(`${BASE}${path}`);
-    if (!resp.ok) {
-        throw new CliError('FETCH_ERROR', `iTunes API HTTP ${resp.status}`, 'Check your search term or podcast ID');
-    }
-    return resp.json();
-}
-/** Format milliseconds to mm:ss. Returns '-' for missing input. */
-export function formatDuration(ms) {
-    if (!ms || !Number.isFinite(ms))
-        return '-';
-    const totalSec = Math.round(ms / 1000);
-    const m = Math.floor(totalSec / 60);
-    const s = totalSec % 60;
-    return `${m}:${String(s).padStart(2, '0')}`;
-}
-/** Format ISO date string to YYYY-MM-DD. Returns '-' for missing input. */
-export function formatDate(iso) {
-    if (!iso)
-        return '-';
-    return iso.slice(0, 10);
-}
diff --git a/plugins/apple-podcasts/webcmd-plugin.json b/plugins/apple-podcasts/webcmd-plugin.json
deleted file mode 100644
index f2537c70..00000000
--- a/plugins/apple-podcasts/webcmd-plugin.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
-  "name": "apple-podcasts",
-  "version": "0.1.0",
-  "description": "Webcmd commands for apple-podcasts",
-  "webcmd": ">=0.5.3",
-  "author": {
-    "name": "WebCMD Agent",
-    "handle": "agentrhq"
-  }
-}
diff --git a/plugins/archive/README.md b/plugins/archive/README.md
deleted file mode 100644
index 5c8d1440..00000000
--- a/plugins/archive/README.md
+++ /dev/null
@@ -1,18 +0,0 @@
-# webcmd-plugin-archive
-
-Webcmd commands for archive.
-
-## Install
-
-```bash
-webcmd plugin install github:agentrhq/webcmd/archive
-```
-
-## Commands
-
-| Command | Description |
-| --- | --- |
-| `webcmd archive item` | Fetch metadata for a single Internet Archive item by identifier. |
-| `webcmd archive search` | Search Internet Archive items across books, movies, audio, software, and web. |
-| `webcmd archive snapshots` | List Wayback Machine snapshots over time for a URL via the CDX API. |
-| `webcmd archive wayback` | Look up the closest Wayback Machine snapshot for a URL. |
diff --git a/plugins/archive/item.js b/plugins/archive/item.js
deleted file mode 100644
index 3e3b2330..00000000
--- a/plugins/archive/item.js
+++ /dev/null
@@ -1,92 +0,0 @@
-// archive item: Internet Archive item metadata (one row per identifier).
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import {
-    ArgumentError,
-    CommandExecutionError,
-    EmptyResultError,
-} from '@agentrhq/webcmd/errors';
-
-const IDENTIFIER_RE = /^[A-Za-z0-9._-]+$/;
-
-cli({
-    site: 'archive',
-    name: 'item',
-    access: 'read',
-    description: 'Fetch metadata for a single Internet Archive item by identifier.',
-    domain: 'archive.org',
-    strategy: Strategy.PUBLIC,
-    browser: false,
-    args: [
-        { name: 'identifier', positional: true, required: true, help: 'Archive item identifier (e.g. "open-syllabus", "FinalFantasy2_356").' },
-    ],
-    columns: ['identifier', 'title', 'creator', 'date', 'mediatype', 'collection', 'description', 'file_count', 'url'],
-    func: async (args) => {
-        const identifier = String(args.identifier ?? '').trim();
-        if (!identifier) {
-            throw new ArgumentError(
-                'archive item identifier cannot be empty',
-                'Example: webcmd archive item open-syllabus',
-            );
-        }
-        if (!IDENTIFIER_RE.test(identifier)) {
-            throw new ArgumentError(
-                `archive item identifier "${args.identifier}" is not valid`,
-                'Archive item identifiers may only contain letters, digits, ".", "_", "-".',
-            );
-        }
-
-        const url = `https://archive.org/metadata/${encodeURIComponent(identifier)}`;
-        let resp;
-        try {
-            resp = await fetch(url, {
-                headers: {
-                    'Accept': 'application/json',
-                    'User-Agent': 'webcmd/1.0 (+https://github.com/agentrhq/webcmd)',
-                },
-            });
-        } catch (error) {
-            throw new CommandExecutionError(`archive item request failed: ${error?.message || error}`);
-        }
-        if (!resp.ok) {
-            throw new CommandExecutionError(`archive item failed: HTTP ${resp.status}`);
-        }
-        let data;
-        try {
-            data = await resp.json();
-        } catch (error) {
-            throw new CommandExecutionError(`archive item returned malformed JSON: ${error?.message || error}`);
-        }
-
-        const meta = data?.metadata;
-        // The metadata endpoint returns {} for missing or dark items.
-        if (!meta || typeof meta !== 'object' || !meta.identifier) {
-            throw new EmptyResultError('archive item', `No public metadata for "${identifier}" on archive.org.`);
-        }
-        const responseIdentifier = String(meta.identifier);
-        if (!IDENTIFIER_RE.test(responseIdentifier)) {
-            throw new CommandExecutionError('archive item returned malformed payload: metadata.identifier is not stable');
-        }
-        if (responseIdentifier !== identifier) {
-            throw new CommandExecutionError(`archive item returned metadata for "${responseIdentifier}" instead of "${identifier}"`);
-        }
-
-        const creator = Array.isArray(meta.creator) ? meta.creator.join(', ') : String(meta.creator ?? '');
-        const collection = Array.isArray(meta.collection) ? meta.collection.join(', ') : String(meta.collection ?? '');
-        const description = Array.isArray(meta.description) ? meta.description.join(' ') : String(meta.description ?? '');
-        if (!Array.isArray(data.files)) {
-            throw new CommandExecutionError('archive item returned malformed payload: files must be an array');
-        }
-
-        return [{
-            identifier: responseIdentifier,
-            title: String(meta.title ?? ''),
-            creator,
-            date: meta.date ? String(meta.date).slice(0, 10) : '',
-            mediatype: String(meta.mediatype ?? ''),
-            collection,
-            description,
-            file_count: data.files.length,
-            url: `https://archive.org/details/${responseIdentifier}`,
-        }];
-    },
-});
diff --git a/plugins/archive/package.json b/plugins/archive/package.json
deleted file mode 100644
index 66f9a9d4..00000000
--- a/plugins/archive/package.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
-  "name": "webcmd-plugin-archive",
-  "version": "0.1.0",
-  "type": "module",
-  "description": "Webcmd commands for archive",
-  "peerDependencies": {
-    "@agentrhq/webcmd": ">=0.5.3"
-  }
-}
diff --git a/plugins/archive/search.js b/plugins/archive/search.js
deleted file mode 100644
index 28339cd2..00000000
--- a/plugins/archive/search.js
+++ /dev/null
@@ -1,116 +0,0 @@
-// archive search: Internet Archive Advanced Search across all mediatypes.
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import {
-    ArgumentError,
-    CommandExecutionError,
-    EmptyResultError,
-} from '@agentrhq/webcmd/errors';
-
-const SORT_OPTIONS = ['downloads', 'date', 'addeddate', 'week', 'title'];
-const SORT_ALIAS = { added: 'addeddate', published: 'date' };
-const MEDIATYPES = ['texts', 'movies', 'audio', 'software', 'image', 'web', 'data', 'collection'];
-const IDENTIFIER_RE = /^[A-Za-z0-9._-]+$/;
-
-cli({
-    site: 'archive',
-    name: 'search',
-    tags: ['search'],
-    access: 'read',
-    description: 'Search Internet Archive items across books, movies, audio, software, and web.',
-    domain: 'archive.org',
-    strategy: Strategy.PUBLIC,
-    browser: false,
-    args: [
-        { name: 'query', positional: true, required: true, help: 'Full-text query (matches title, description, creator, subject).' },
-        { name: 'mediatype', type: 'string', required: false, help: `Restrict to mediatype: ${MEDIATYPES.join(', ')}` },
-        { name: 'sort', type: 'string', default: 'downloads', help: `Sort key: ${SORT_OPTIONS.join(', ')}` },
-        { name: 'limit', type: 'int', default: 20, help: 'Max items (max 100; one API page).' },
-    ],
-    columns: ['rank', 'identifier', 'title', 'creator', 'date', 'mediatype', 'downloads', 'url'],
-    func: async (args) => {
-        const sortRaw = String(args.sort ?? 'downloads').toLowerCase();
-        const sort = SORT_ALIAS[sortRaw] ?? sortRaw;
-        if (!SORT_OPTIONS.includes(sort)) {
-            throw new ArgumentError(`archive search sort must be one of ${SORT_OPTIONS.join(', ')}`);
-        }
-        if (args.mediatype && !MEDIATYPES.includes(String(args.mediatype))) {
-            throw new ArgumentError(`archive search mediatype must be one of ${MEDIATYPES.join(', ')}`);
-        }
-        const limit = Number(args.limit ?? 20);
-        if (!Number.isInteger(limit) || limit <= 0) {
-            throw new ArgumentError('archive search limit must be a positive integer');
-        }
-        if (limit > 100) {
-            throw new ArgumentError('archive search limit must be <= 100');
-        }
-
-        const query = String(args.query ?? '').trim();
-        if (!query) {
-            throw new ArgumentError('archive search query must not be empty');
-        }
-
-        const fullQuery = args.mediatype
-            ? `(${query}) AND mediatype:${args.mediatype}`
-            : query;
-
-        const url = new URL('https://archive.org/advancedsearch.php');
-        url.searchParams.set('q', fullQuery);
-        url.searchParams.set('output', 'json');
-        url.searchParams.set('rows', String(limit));
-        url.searchParams.set('sort[]', `${sort} desc`);
-        for (const fl of ['identifier', 'title', 'creator', 'date', 'mediatype', 'downloads']) {
-            url.searchParams.append('fl[]', fl);
-        }
-
-        let resp;
-        try {
-            resp = await fetch(url, {
-                headers: {
-                    'Accept': 'application/json',
-                    'User-Agent': 'webcmd/1.0 (+https://github.com/agentrhq/webcmd)',
-                },
-            });
-        } catch (error) {
-            throw new CommandExecutionError(`archive search request failed: ${error?.message || error}`);
-        }
-        if (!resp.ok) {
-            throw new CommandExecutionError(`archive search failed: HTTP ${resp.status}`);
-        }
-        let data;
-        try {
-            data = await resp.json();
-        } catch (error) {
-            throw new CommandExecutionError(`archive search returned malformed JSON: ${error?.message || error}`);
-        }
-
-        const docs = data?.response?.docs;
-        if (!Array.isArray(docs)) {
-            throw new CommandExecutionError('archive search returned malformed payload: response.docs must be an array');
-        }
-        if (docs.length === 0) {
-            throw new EmptyResultError('archive search', `No items match "${query}" on archive.org.`);
-        }
-
-        return docs.slice(0, limit).map((d, i) => {
-            const id = String(d.identifier ?? '');
-            if (!IDENTIFIER_RE.test(id)) {
-                throw new CommandExecutionError('archive search returned malformed payload: result row is missing a stable identifier');
-            }
-            const downloads = Number(d.downloads ?? 0);
-            if (!Number.isFinite(downloads)) {
-                throw new CommandExecutionError(`archive search returned malformed payload for "${id}": downloads must be numeric`);
-            }
-            const creator = Array.isArray(d.creator) ? d.creator.join(', ') : String(d.creator ?? '');
-            return {
-                rank: i + 1,
-                identifier: id,
-                title: String(d.title ?? ''),
-                creator,
-                date: d.date ? String(d.date).slice(0, 10) : '',
-                mediatype: String(d.mediatype ?? ''),
-                downloads,
-                url: id ? `https://archive.org/details/${id}` : '',
-            };
-        });
-    },
-});
diff --git a/plugins/archive/snapshots.js b/plugins/archive/snapshots.js
deleted file mode 100644
index ae27684a..00000000
--- a/plugins/archive/snapshots.js
+++ /dev/null
@@ -1,129 +0,0 @@
-// archive snapshots: Wayback Machine CDX history for a URL.
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import {
-    ArgumentError,
-    CommandExecutionError,
-    EmptyResultError,
-} from '@agentrhq/webcmd/errors';
-
-function buildWaybackUrl(timestamp, original) {
-    if (!timestamp || !original) return '';
-    return `https://web.archive.org/web/${timestamp}/${original}`;
-}
-
-function requireCdxColumn(cols, name) {
-    const index = cols[name];
-    if (!Number.isInteger(index)) {
-        throw new CommandExecutionError(`archive snapshots returned malformed CDX payload: missing "${name}" column`);
-    }
-    return index;
-}
-
-cli({
-    site: 'archive',
-    name: 'snapshots',
-    access: 'read',
-    description: 'List Wayback Machine snapshots over time for a URL via the CDX API.',
-    domain: 'archive.org',
-    strategy: Strategy.PUBLIC,
-    browser: false,
-    args: [
-        { name: 'url', positional: true, required: true, help: 'URL to look up (with or without scheme).' },
-        { name: 'from', type: 'string', required: false, help: 'Earliest year/timestamp (YYYY[MM[DD[hh[mm[ss]]]]])' },
-        { name: 'to', type: 'string', required: false, help: 'Latest year/timestamp (YYYY[MM[DD[hh[mm[ss]]]]])' },
-        { name: 'limit', type: 'int', default: 20, help: 'Max snapshots to return (max 1000).' },
-    ],
-    columns: ['timestamp', 'snapshot_url', 'status', 'mimetype', 'original_url'],
-    func: async (args) => {
-        const target = String(args.url ?? '').trim();
-        if (!target) {
-            throw new ArgumentError(
-                'archive snapshots url cannot be empty',
-                'Example: webcmd archive snapshots wikipedia.org',
-            );
-        }
-        const limit = Number(args.limit ?? 20);
-        if (!Number.isInteger(limit) || limit <= 0) {
-            throw new ArgumentError('archive snapshots limit must be a positive integer');
-        }
-        if (limit > 1000) {
-            throw new ArgumentError('archive snapshots limit must be <= 1000');
-        }
-        for (const key of ['from', 'to']) {
-            const v = args[key];
-            if (v != null && !/^\d{4,14}$/.test(String(v))) {
-                throw new ArgumentError(`archive snapshots ${key} must be a digit-only timestamp (YYYY[MM[DD[hh[mm[ss]]]]])`);
-            }
-        }
-
-        // Wayback CDX is served on HTTP only; the HTTPS endpoint returns 503.
-        const apiUrl = new URL('http://web.archive.org/cdx/search/cdx');
-        apiUrl.searchParams.set('url', target);
-        apiUrl.searchParams.set('output', 'json');
-        apiUrl.searchParams.set('limit', String(limit));
-        if (args.from) apiUrl.searchParams.set('from', String(args.from));
-        if (args.to) apiUrl.searchParams.set('to', String(args.to));
-
-        let resp;
-        try {
-            resp = await fetch(apiUrl, {
-                headers: {
-                    'Accept': 'application/json',
-                    'User-Agent': 'webcmd/1.0 (+https://github.com/agentrhq/webcmd)',
-                },
-            });
-        } catch (error) {
-            throw new CommandExecutionError(`archive snapshots request failed: ${error?.message || error}`);
-        }
-        if (!resp.ok) {
-            throw new CommandExecutionError(`archive snapshots failed: HTTP ${resp.status}`);
-        }
-        let data;
-        try {
-            data = await resp.json();
-        } catch (error) {
-            throw new CommandExecutionError(`archive snapshots returned malformed JSON: ${error?.message || error}`);
-        }
-
-        // CDX returns an array of arrays; the first row is the header.
-        if (!Array.isArray(data)) {
-            throw new CommandExecutionError('archive snapshots returned malformed CDX payload: top-level payload must be an array');
-        }
-        if (data.length < 2) {
-            throw new EmptyResultError('archive snapshots', `No Wayback snapshots for "${target}".`);
-        }
-        const [header, ...rows] = data;
-        if (!Array.isArray(header)) {
-            throw new CommandExecutionError('archive snapshots returned malformed CDX payload: header row must be an array');
-        }
-        const cols = {};
-        header.forEach((name, i) => { cols[name] = i; });
-        const timestampCol = requireCdxColumn(cols, 'timestamp');
-        const originalCol = requireCdxColumn(cols, 'original');
-        const statusCol = requireCdxColumn(cols, 'statuscode');
-        const mimetypeCol = requireCdxColumn(cols, 'mimetype');
-
-        return rows.slice(0, limit).map(row => {
-            if (!Array.isArray(row)) {
-                throw new CommandExecutionError('archive snapshots returned malformed CDX payload: snapshot row must be an array');
-            }
-            const timestamp = String(row[timestampCol] ?? '');
-            const original = String(row[originalCol] ?? '');
-            const status = row[statusCol];
-            const mimetype = row[mimetypeCol];
-            if (!/^\d{14}$/.test(timestamp) || !original) {
-                throw new CommandExecutionError('archive snapshots returned malformed CDX payload: snapshot row is missing timestamp/original URL');
-            }
-            if (status == null || mimetype == null || String(status) === '' || String(mimetype) === '') {
-                throw new CommandExecutionError('archive snapshots returned malformed CDX payload: snapshot row is missing statuscode/mimetype');
-            }
-            return {
-                timestamp,
-                snapshot_url: buildWaybackUrl(timestamp, original),
-                status: String(status),
-                mimetype: String(mimetype),
-                original_url: original,
-            };
-        });
-    },
-});
diff --git a/plugins/archive/test/archive.test.js b/plugins/archive/test/archive.test.js
deleted file mode 100644
index c0d975e7..00000000
--- a/plugins/archive/test/archive.test.js
+++ /dev/null
@@ -1,262 +0,0 @@
-import { afterEach, describe, expect, it, vi } from 'vitest';
-import { getRegistry } from '@agentrhq/webcmd/registry';
-import { ArgumentError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors';
-import '../search.js';
-import '../item.js';
-import '../wayback.js';
-import '../snapshots.js';
-
-function jsonResponse(body, status = 200) {
-    return new Response(JSON.stringify(body), {
-        status,
-        headers: { 'content-type': 'application/json' },
-    });
-}
-
-afterEach(() => {
-    vi.unstubAllGlobals();
-});
-
-describe('archive adapter registry contracts', () => {
-    it('declares archive search columns so identifier round-trips into archive item', () => {
-        const search = getRegistry().get('archive/search');
-        const item = getRegistry().get('archive/item');
-
-        expect(search).toBeDefined();
-        expect(item).toBeDefined();
-        expect(search.columns).toEqual(['rank', 'identifier', 'title', 'creator', 'date', 'mediatype', 'downloads', 'url']);
-        expect(item.columns).toContain('identifier');
-    });
-
-    it('declares wayback and snapshots columns so URL round-trips between them', () => {
-        const wayback = getRegistry().get('archive/wayback');
-        const snapshots = getRegistry().get('archive/snapshots');
-
-        expect(wayback).toBeDefined();
-        expect(snapshots).toBeDefined();
-        expect(wayback.columns).toContain('snapshot_url');
-        expect(snapshots.columns).toContain('snapshot_url');
-        expect(wayback.columns).toContain('original_url');
-        expect(snapshots.columns).toContain('original_url');
-    });
-
-    it('marks every archive command as read access on the archive.org domain', () => {
-        for (const name of ['search', 'item', 'wayback', 'snapshots']) {
-            const cmd = getRegistry().get(`archive/${name}`);
-            expect(cmd, name).toBeDefined();
-            expect(cmd.access, name).toBe('read');
-            expect(cmd.domain, name).toBe('archive.org');
-            expect(cmd.browser, name).toBe(false);
-        }
-    });
-});
-
-describe('archive search command', () => {
-    const command = getRegistry().get('archive/search');
-
-    it('returns stable identifier rows that round-trip to archive item', async () => {
-        const fetchMock = vi.fn().mockResolvedValue(jsonResponse({
-            response: {
-                docs: [{
-                    identifier: 'sample_item-1',
-                    title: 'Sample Item',
-                    creator: ['Alice', 'Bob'],
-                    date: '2020-01-02T00:00:00Z',
-                    mediatype: 'texts',
-                    downloads: '42',
-                }],
-            },
-        }));
-        vi.stubGlobal('fetch', fetchMock);
-
-        await expect(command.func({ query: 'sample', limit: 1 })).resolves.toEqual([{
-            rank: 1,
-            identifier: 'sample_item-1',
-            title: 'Sample Item',
-            creator: 'Alice, Bob',
-            date: '2020-01-02',
-            mediatype: 'texts',
-            downloads: 42,
-            url: 'https://archive.org/details/sample_item-1',
-        }]);
-        const url = new URL(fetchMock.mock.calls[0][0]);
-        expect(url.searchParams.get('q')).toBe('sample');
-        expect(url.searchParams.getAll('fl[]')).toContain('identifier');
-    });
-
-    it('rejects invalid arguments before fetching', async () => {
-        const fetchMock = vi.fn();
-        vi.stubGlobal('fetch', fetchMock);
-
-        await expect(command.func({ query: ' ', limit: 1 })).rejects.toBeInstanceOf(ArgumentError);
-        await expect(command.func({ query: 'x', mediatype: 'bad' })).rejects.toBeInstanceOf(ArgumentError);
-        await expect(command.func({ query: 'x', sort: 'bad' })).rejects.toBeInstanceOf(ArgumentError);
-        await expect(command.func({ query: 'x', limit: 101 })).rejects.toBeInstanceOf(ArgumentError);
-        expect(fetchMock).not.toHaveBeenCalled();
-    });
-
-    it('maps true empty search results to EmptyResultError', async () => {
-        vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ response: { docs: [] } })));
-
-        await expect(command.func({ query: 'zz-no-hit', limit: 5 })).rejects.toBeInstanceOf(EmptyResultError);
-    });
-
-    it('typed-fails malformed search payloads instead of emitting empty identifiers', async () => {
-        vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ response: { docs: [{ title: 'No id' }] } })));
-
-        await expect(command.func({ query: 'bad', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
-    });
-});
-
-describe('archive item command', () => {
-    const command = getRegistry().get('archive/item');
-
-    it('returns metadata for the requested stable identifier', async () => {
-        vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({
-            metadata: {
-                identifier: 'sample_item-1',
-                title: 'Sample Item',
-                creator: 'Alice',
-                date: '2020',
-                mediatype: 'texts',
-                collection: ['opensource'],
-                description: ['Line one.', 'Line two.'],
-            },
-            files: [{ name: 'a.txt' }, { name: 'b.txt' }],
-        })));
-
-        await expect(command.func({ identifier: 'sample_item-1' })).resolves.toEqual([{
-            identifier: 'sample_item-1',
-            title: 'Sample Item',
-            creator: 'Alice',
-            date: '2020',
-            mediatype: 'texts',
-            collection: 'opensource',
-            description: 'Line one. Line two.',
-            file_count: 2,
-            url: 'https://archive.org/details/sample_item-1',
-        }]);
-    });
-
-    it('rejects invalid identifiers before fetching', async () => {
-        const fetchMock = vi.fn();
-        vi.stubGlobal('fetch', fetchMock);
-
-        await expect(command.func({ identifier: '' })).rejects.toBeInstanceOf(ArgumentError);
-        await expect(command.func({ identifier: '../secret' })).rejects.toBeInstanceOf(ArgumentError);
-        expect(fetchMock).not.toHaveBeenCalled();
-    });
-
-    it('maps missing public metadata to EmptyResultError', async () => {
-        vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({})));
-
-        await expect(command.func({ identifier: 'missing_item' })).rejects.toBeInstanceOf(EmptyResultError);
-    });
-
-    it('typed-fails mismatched identity and malformed files payload', async () => {
-        vi.stubGlobal('fetch', vi.fn()
-            .mockResolvedValueOnce(jsonResponse({ metadata: { identifier: 'other_item' }, files: [] }))
-            .mockResolvedValueOnce(jsonResponse({ metadata: { identifier: 'sample_item' }, files: {} })));
-
-        await expect(command.func({ identifier: 'sample_item' })).rejects.toBeInstanceOf(CommandExecutionError);
-        await expect(command.func({ identifier: 'sample_item' })).rejects.toBeInstanceOf(CommandExecutionError);
-    });
-});
-
-describe('archive wayback command', () => {
-    const command = getRegistry().get('archive/wayback');
-
-    it('returns the closest snapshot with normalized timestamp input', async () => {
-        const fetchMock = vi.fn().mockResolvedValue(jsonResponse({
-            url: 'example.com',
-            archived_snapshots: {
-                closest: {
-                    available: true,
-                    timestamp: '20200102030405',
-                    url: 'https://web.archive.org/web/20200102030405/https://example.com/',
-                    status: '200',
-                },
-            },
-        }));
-        vi.stubGlobal('fetch', fetchMock);
-
-        await expect(command.func({ url: 'example.com', timestamp: '2020-01-02T03:04:05' })).resolves.toEqual([{
-            original_url: 'example.com',
-            requested_timestamp: '20200102030405',
-            snapshot_timestamp: '20200102030405',
-            snapshot_url: 'https://web.archive.org/web/20200102030405/https://example.com/',
-            status: '200',
-        }]);
-        expect(new URL(fetchMock.mock.calls[0][0]).searchParams.get('timestamp')).toBe('20200102030405');
-    });
-
-    it('rejects invalid URL/timestamp arguments before fetching', async () => {
-        const fetchMock = vi.fn();
-        vi.stubGlobal('fetch', fetchMock);
-
-        await expect(command.func({ url: '' })).rejects.toBeInstanceOf(ArgumentError);
-        await expect(command.func({ url: 'example.com', timestamp: '202' })).rejects.toBeInstanceOf(ArgumentError);
-        expect(fetchMock).not.toHaveBeenCalled();
-    });
-
-    it('distinguishes no snapshot from malformed closest snapshot', async () => {
-        vi.stubGlobal('fetch', vi.fn()
-            .mockResolvedValueOnce(jsonResponse({ archived_snapshots: {} }))
-            .mockResolvedValueOnce(jsonResponse({ archived_snapshots: { closest: { available: true, url: 'x' } } })));
-
-        await expect(command.func({ url: 'example.com' })).rejects.toBeInstanceOf(EmptyResultError);
-        await expect(command.func({ url: 'example.com' })).rejects.toBeInstanceOf(CommandExecutionError);
-    });
-});
-
-describe('archive snapshots command', () => {
-    const command = getRegistry().get('archive/snapshots');
-
-    it('returns CDX snapshots with stable Wayback permalinks', async () => {
-        const fetchMock = vi.fn().mockResolvedValue(jsonResponse([
-            ['urlkey', 'timestamp', 'original', 'mimetype', 'statuscode'],
-            ['com,example)/', '20200102030405', 'https://example.com/', 'text/html', '200'],
-        ]));
-        vi.stubGlobal('fetch', fetchMock);
-
-        await expect(command.func({ url: 'example.com', from: '2020', limit: 1 })).resolves.toEqual([{
-            timestamp: '20200102030405',
-            snapshot_url: 'https://web.archive.org/web/20200102030405/https://example.com/',
-            status: '200',
-            mimetype: 'text/html',
-            original_url: 'https://example.com/',
-        }]);
-        const url = new URL(fetchMock.mock.calls[0][0]);
-        expect(url.protocol).toBe('http:');
-        expect(url.searchParams.get('from')).toBe('2020');
-    });
-
-    it('rejects invalid arguments before fetching', async () => {
-        const fetchMock = vi.fn();
-        vi.stubGlobal('fetch', fetchMock);
-
-        await expect(command.func({ url: '', limit: 1 })).rejects.toBeInstanceOf(ArgumentError);
-        await expect(command.func({ url: 'example.com', limit: 1001 })).rejects.toBeInstanceOf(ArgumentError);
-        await expect(command.func({ url: 'example.com', from: '2020-01' })).rejects.toBeInstanceOf(ArgumentError);
-        expect(fetchMock).not.toHaveBeenCalled();
-    });
-
-    it('maps no CDX rows to EmptyResultError', async () => {
-        vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse([['timestamp', 'original', 'statuscode', 'mimetype']])));
-
-        await expect(command.func({ url: 'missing.example', limit: 5 })).rejects.toBeInstanceOf(EmptyResultError);
-    });
-
-    it('typed-fails malformed CDX headers and rows', async () => {
-        vi.stubGlobal('fetch', vi.fn()
-            .mockResolvedValueOnce(jsonResponse([['timestamp', 'original'], ['20200102030405', 'https://example.com/']]))
-            .mockResolvedValueOnce(jsonResponse([['timestamp', 'original', 'statuscode', 'mimetype'], ['', 'https://example.com/', '200', 'text/html']]))
-            .mockResolvedValueOnce(jsonResponse({ timestamp: '20200102030405' }))
-            .mockResolvedValueOnce(jsonResponse([['timestamp', 'original', 'statuscode', 'mimetype'], ['20200102030405', 'https://example.com/']])));
-
-        await expect(command.func({ url: 'example.com', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
-        await expect(command.func({ url: 'example.com', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
-        await expect(command.func({ url: 'example.com', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
-        await expect(command.func({ url: 'example.com', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
-    });
-});
diff --git a/plugins/archive/wayback.js b/plugins/archive/wayback.js
deleted file mode 100644
index 1f7b2079..00000000
--- a/plugins/archive/wayback.js
+++ /dev/null
@@ -1,83 +0,0 @@
-// archive wayback: Wayback Machine closest-snapshot lookup for a URL.
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import {
-    ArgumentError,
-    CommandExecutionError,
-    EmptyResultError,
-} from '@agentrhq/webcmd/errors';
-
-function normalizeTimestamp(raw) {
-    // Accept YYYY, YYYYMM, YYYYMMDD, YYYYMMDDhh, YYYYMMDDhhmm, YYYYMMDDhhmmss,
-    // YYYY-MM-DD, or YYYY-MM-DDThh:mm:ss. Strip non-digits and validate length.
-    const digits = String(raw).replace(/[^0-9]/g, '');
-    if (!/^\d{4,14}$/.test(digits) || digits.length % 2 !== 0 && digits.length !== 4) {
-        throw new ArgumentError('archive wayback timestamp must be YYYY[MM[DD[hh[mm[ss]]]]] or an ISO date');
-    }
-    return digits;
-}
-
-cli({
-    site: 'archive',
-    name: 'wayback',
-    access: 'read',
-    description: 'Look up the closest Wayback Machine snapshot for a URL.',
-    domain: 'archive.org',
-    strategy: Strategy.PUBLIC,
-    browser: false,
-    args: [
-        { name: 'url', positional: true, required: true, help: 'URL to look up (with or without scheme).' },
-        { name: 'timestamp', type: 'string', required: false, help: 'Target timestamp (YYYY[MM[DD[hh[mm[ss]]]]] or ISO date). Defaults to most recent snapshot.' },
-    ],
-    columns: ['original_url', 'requested_timestamp', 'snapshot_timestamp', 'snapshot_url', 'status'],
-    func: async (args) => {
-        const target = String(args.url ?? '').trim();
-        if (!target) {
-            throw new ArgumentError(
-                'archive wayback url cannot be empty',
-                'Example: webcmd archive wayback wikipedia.org',
-            );
-        }
-        const timestamp = args.timestamp ? normalizeTimestamp(args.timestamp) : '';
-
-        const apiUrl = new URL('https://archive.org/wayback/available');
-        apiUrl.searchParams.set('url', target);
-        if (timestamp) apiUrl.searchParams.set('timestamp', timestamp);
-
-        let resp;
-        try {
-            resp = await fetch(apiUrl, {
-                headers: {
-                    'Accept': 'application/json',
-                    'User-Agent': 'webcmd/1.0 (+https://github.com/agentrhq/webcmd)',
-                },
-            });
-        } catch (error) {
-            throw new CommandExecutionError(`archive wayback request failed: ${error?.message || error}`);
-        }
-        if (!resp.ok) {
-            throw new CommandExecutionError(`archive wayback failed: HTTP ${resp.status}`);
-        }
-        let data;
-        try {
-            data = await resp.json();
-        } catch (error) {
-            throw new CommandExecutionError(`archive wayback returned malformed JSON: ${error?.message || error}`);
-        }
-
-        const snap = data?.archived_snapshots?.closest;
-        if (!snap || !snap.available) {
-            throw new EmptyResultError('archive wayback', `No Wayback snapshot for "${target}".`);
-        }
-        if (typeof snap.url !== 'string' || !snap.url || !/^\d{14}$/.test(String(snap.timestamp ?? ''))) {
-            throw new CommandExecutionError('archive wayback returned malformed payload: closest snapshot is missing url/timestamp');
-        }
-
-        return [{
-            original_url: String(data.url ?? target),
-            requested_timestamp: timestamp,
-            snapshot_timestamp: String(snap.timestamp ?? ''),
-            snapshot_url: String(snap.url),
-            status: String(snap.status ?? ''),
-        }];
-    },
-});
diff --git a/plugins/archive/webcmd-plugin.json b/plugins/archive/webcmd-plugin.json
deleted file mode 100644
index 5e182a1f..00000000
--- a/plugins/archive/webcmd-plugin.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
-  "name": "archive",
-  "version": "0.1.0",
-  "description": "Webcmd commands for archive",
-  "webcmd": ">=0.5.3",
-  "author": {
-    "name": "WebCMD Agent",
-    "handle": "agentrhq"
-  }
-}
diff --git a/plugins/arxiv/README.md b/plugins/arxiv/README.md
deleted file mode 100644
index d3137852..00000000
--- a/plugins/arxiv/README.md
+++ /dev/null
@@ -1,18 +0,0 @@
-# webcmd-plugin-arxiv
-
-Webcmd commands for arxiv.
-
-## Install
-
-```bash
-webcmd plugin install github:agentrhq/webcmd/arxiv
-```
-
-## Commands
-
-| Command | Description |
-| --- | --- |
-| `webcmd arxiv author` | List arXiv papers by a given author (newest first) |
-| `webcmd arxiv paper` | Get arXiv paper details by ID |
-| `webcmd arxiv recent` | List recent arXiv submissions in a category |
-| `webcmd arxiv search` | Search arXiv papers |
diff --git a/plugins/arxiv/author.js b/plugins/arxiv/author.js
deleted file mode 100644
index bc2cedcf..00000000
--- a/plugins/arxiv/author.js
+++ /dev/null
@@ -1,44 +0,0 @@
-// arxiv author — list papers authored by a person, newest first.
-//
-// arXiv's public API supports `au:` prefix queries. Author names on arXiv are
-// not stable IDs, so this is a best-effort fuzzy match — the same person can
-// appear under multiple spellings ("Y. Bengio" vs "Yoshua Bengio").
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { ArgumentError, EmptyResultError } from '@agentrhq/webcmd/errors';
-import { arxivFetch, normalizeArxivLimit, parseEntries } from './utils.js';
-
-cli({
-    site: 'arxiv',
-    name: 'author',
-    access: 'read',
-    description: 'List arXiv papers by a given author (newest first)',
-    strategy: Strategy.PUBLIC,
-    browser: false,
-    args: [
-        { name: 'author', positional: true, required: true, help: 'Author name (e.g. "Yoshua Bengio" or "Y Bengio")' },
-        { name: 'limit', type: 'int', default: 20, help: 'Max papers to return (max 50)' },
-    ],
-    columns: ['id', 'title', 'authors', 'published', 'primary_category', 'url'],
-    func: async (args) => {
-        const authorText = String(args.author || '').trim();
-        if (!authorText) {
-            throw new ArgumentError('arxiv author cannot be empty', 'Example: webcmd arxiv author "Yoshua Bengio"');
-        }
-        const limit = normalizeArxivLimit(args.limit, 20, 50);
-        // Quote the value so multi-word author names match as a phrase.
-        const query = encodeURIComponent(`au:"${authorText}"`);
-        const xml = await arxivFetch(`search_query=${query}&max_results=${limit}&sortBy=submittedDate&sortOrder=descending`);
-        const entries = parseEntries(xml);
-        if (!entries.length) {
-            throw new EmptyResultError('arxiv author', `No papers found for author "${authorText}". Try alternate spellings (e.g. initials).`);
-        }
-        return entries.map(e => ({
-            id: e.id,
-            title: e.title,
-            authors: e.authors,
-            published: e.published,
-            primary_category: e.primary_category,
-            url: e.url,
-        }));
-    },
-});
diff --git a/plugins/arxiv/package.json b/plugins/arxiv/package.json
deleted file mode 100644
index 7d179665..00000000
--- a/plugins/arxiv/package.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
-  "name": "webcmd-plugin-arxiv",
-  "version": "0.1.0",
-  "type": "module",
-  "description": "Webcmd commands for arxiv",
-  "peerDependencies": {
-    "@agentrhq/webcmd": ">=0.5.3"
-  }
-}
diff --git a/plugins/arxiv/paper.js b/plugins/arxiv/paper.js
deleted file mode 100644
index 339b95cd..00000000
--- a/plugins/arxiv/paper.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { EmptyResultError } from '@agentrhq/webcmd/errors';
-import { arxivFetch, parseEntries } from './utils.js';
-cli({
-    site: 'arxiv',
-    name: 'paper',
-    access: 'read',
-    description: 'Get arXiv paper details by ID',
-    strategy: Strategy.PUBLIC,
-    browser: false,
-    args: [
-        { name: 'id', positional: true, required: true, help: 'arXiv paper ID (e.g. 1706.03762)' },
-    ],
-    columns: ['id', 'title', 'authors', 'published', 'updated', 'primary_category', 'categories', 'abstract', 'comment', 'pdf', 'url'],
-    func: async (args) => {
-        const xml = await arxivFetch(`id_list=${encodeURIComponent(args.id)}`);
-        const entries = parseEntries(xml);
-        if (!entries.length)
-            throw new EmptyResultError('arxiv paper', `Paper ${args.id} was not found. Check the arXiv ID format, e.g. 1706.03762`);
-        return entries;
-    },
-});
diff --git a/plugins/arxiv/recent.js b/plugins/arxiv/recent.js
deleted file mode 100644
index 07ffd037..00000000
--- a/plugins/arxiv/recent.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { EmptyResultError } from '@agentrhq/webcmd/errors';
-import { arxivFetch, normalizeArxivCategory, normalizeArxivLimit, parseEntries } from './utils.js';
-cli({
-    site: 'arxiv',
-    name: 'recent',
-    access: 'read',
-    description: 'List recent arXiv submissions in a category',
-    strategy: Strategy.PUBLIC,
-    browser: false,
-    args: [
-        { name: 'category', positional: true, required: true, help: 'arXiv category (e.g. cs.CL, cs.LG, math.PR, q-bio.NC)' },
-        { name: 'limit', type: 'int', default: 10, help: 'Max results (max 50)' },
-    ],
-    columns: ['id', 'title', 'authors', 'published', 'primary_category', 'url'],
-    func: async (args) => {
-        const category = normalizeArxivCategory(args.category);
-        const limit = normalizeArxivLimit(args.limit, 10, 50);
-        const query = encodeURIComponent(`cat:${category}`);
-        const xml = await arxivFetch(`search_query=${query}&max_results=${limit}&sortBy=submittedDate&sortOrder=descending`);
-        const entries = parseEntries(xml);
-        if (!entries.length)
-            throw new EmptyResultError('arxiv', `No recent papers in ${category}. Check the category name.`);
-        return entries.map(e => ({
-            id: e.id,
-            title: e.title,
-            authors: e.authors,
-            published: e.published,
-            primary_category: e.primary_category,
-            url: e.url,
-        }));
-    },
-});
diff --git a/plugins/arxiv/search.js b/plugins/arxiv/search.js
deleted file mode 100644
index 3381076b..00000000
--- a/plugins/arxiv/search.js
+++ /dev/null
@@ -1,37 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { ArgumentError, EmptyResultError } from '@agentrhq/webcmd/errors';
-import { arxivFetch, normalizeArxivLimit, parseEntries } from './utils.js';
-cli({
-    site: 'arxiv',
-    name: 'search',
-    tags: ['search'],
-    access: 'read',
-    description: 'Search arXiv papers',
-    strategy: Strategy.PUBLIC,
-    browser: false,
-    args: [
-        { name: 'query', positional: true, required: true, help: 'Search keyword (e.g. "attention is all you need")' },
-        { name: 'limit', type: 'int', default: 10, help: 'Max results (max 25)' },
-    ],
-    columns: ['id', 'title', 'authors', 'published', 'primary_category', 'url'],
-    func: async (args) => {
-        const queryText = String(args.query || '').trim();
-        if (!queryText) {
-            throw new ArgumentError('arxiv search query cannot be empty');
-        }
-        const limit = normalizeArxivLimit(args.limit, 10, 25);
-        const query = encodeURIComponent(`all:${queryText}`);
-        const xml = await arxivFetch(`search_query=${query}&max_results=${limit}&sortBy=relevance`);
-        const entries = parseEntries(xml);
-        if (!entries.length)
-            throw new EmptyResultError('arxiv', 'No papers found. Try a different keyword.');
-        return entries.map(e => ({
-            id: e.id,
-            title: e.title,
-            authors: e.authors,
-            published: e.published,
-            primary_category: e.primary_category,
-            url: e.url,
-        }));
-    },
-});
diff --git a/plugins/arxiv/test/arxiv.test.js b/plugins/arxiv/test/arxiv.test.js
deleted file mode 100644
index ff4f0c66..00000000
--- a/plugins/arxiv/test/arxiv.test.js
+++ /dev/null
@@ -1,112 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { getRegistry } from '@agentrhq/webcmd/registry';
-import { normalizeArxivCategory, normalizeArxivLimit, parseEntries } from '../utils.js';
-import '../paper.js';
-import '../search.js';
-import '../recent.js';
-
-const SAMPLE_ENTRY_XML = `
-
-  
-    http://arxiv.org/abs/1706.03762v7
-    Attention Is All You Need & Friends
-    2023-08-02T00:41:18Z
-    
-    
-    The dominant sequence transduction models are based on complex recurrent or convolutional neural networks. We propose a new simple network architecture, the Transformer, based solely on attention.
-    
-    
-    2017-06-12T17:57:34Z
-    15 pages, 5 figures
-    
-    Ashish Vaswani
-    Noam Shazeer
-    Niki Parmar
-    Jakob Uszkoreit
-    Llion Jones
-    Aidan N. Gomez
-    Lukasz Kaiser
-    Illia Polosukhin
-  
-`;
-
-describe('arxiv adapter', () => {
-  it('registers paper, search and recent commands with the expected columns', () => {
-    const paper = getRegistry().get('arxiv/paper');
-    const search = getRegistry().get('arxiv/search');
-    const recent = getRegistry().get('arxiv/recent');
-
-    expect(paper).toBeDefined();
-    expect(search).toBeDefined();
-    expect(recent).toBeDefined();
-
-    expect(paper.columns).toEqual([
-      'id', 'title', 'authors', 'published', 'updated',
-      'primary_category', 'categories', 'abstract', 'comment', 'pdf', 'url',
-    ]);
-    expect(search.columns).toEqual([
-      'id', 'title', 'authors', 'published', 'primary_category', 'url',
-    ]);
-    expect(recent.columns).toEqual([
-      'id', 'title', 'authors', 'published', 'primary_category', 'url',
-    ]);
-  });
-
-  it('parseEntries returns full abstract, all authors, pdf, primary category and comment', () => {
-    const [entry] = parseEntries(SAMPLE_ENTRY_XML);
-
-    expect(entry.id).toBe('1706.03762');
-    expect(entry.title).toBe('Attention Is All You Need & Friends');
-    // All 8 authors must be present — earlier impl truncated to 3.
-    expect(entry.authors.split(', ')).toHaveLength(8);
-    expect(entry.authors).toContain('Ashish Vaswani');
-    expect(entry.authors).toContain('Illia Polosukhin');
-    // Full abstract — earlier impl truncated at 200 chars.
-    expect(entry.abstract.length).toBeGreaterThan(140);
-    expect(entry.abstract.endsWith('...')).toBe(false);
-    expect(entry.abstract).toContain('attention');
-    expect(entry.published).toBe('2017-06-12');
-    expect(entry.updated).toBe('2023-08-02');
-    expect(entry.primary_category).toBe('cs.CL');
-    expect(entry.categories).toBe('cs.CL, cs.LG');
-    expect(entry.comment).toBe('15 pages, 5 figures');
-    expect(entry.pdf).toBe('https://arxiv.org/pdf/1706.03762v7');
-    expect(entry.url).toBe('https://arxiv.org/abs/1706.03762');
-  });
-
-  it('parseEntries returns an empty list for feeds with no entries', () => {
-    expect(parseEntries('')).toEqual([]);
-  });
-
-  it('recent rejects malformed category strings', async () => {
-    const recent = getRegistry().get('arxiv/recent');
-    await expect(recent.func({ category: 'not a category', limit: 5 })).rejects.toMatchObject({
-      code: 'ARGUMENT',
-    });
-    await expect(recent.func({ category: '', limit: 5 })).rejects.toMatchObject({
-      code: 'ARGUMENT',
-    });
-  });
-
-  it('category validation accepts real arXiv archive and subcategory forms', () => {
-    expect(normalizeArxivCategory('cs.CL')).toBe('cs.CL');
-    expect(normalizeArxivCategory('math')).toBe('math');
-    expect(normalizeArxivCategory('physics.comp-ph')).toBe('physics.comp-ph');
-    expect(normalizeArxivCategory('physics.data-an')).toBe('physics.data-an');
-    expect(normalizeArxivCategory('cond-mat.soft')).toBe('cond-mat.soft');
-    expect(normalizeArxivCategory('q-bio.NC')).toBe('q-bio.NC');
-    expect(() => normalizeArxivCategory('not a category')).toThrow('Invalid arXiv category');
-    expect(() => normalizeArxivCategory('cs/CL')).toThrow('Invalid arXiv category');
-    expect(() => normalizeArxivCategory('')).toThrow('Invalid arXiv category');
-  });
-
-  it('limit validation rejects non-positive, non-integer and over-cap values', () => {
-    expect(normalizeArxivLimit(10, 5, 25)).toBe(10);
-    expect(normalizeArxivLimit(undefined, 5, 25)).toBe(5);
-    expect(() => normalizeArxivLimit(0, 5, 25)).toThrow('positive integer');
-    expect(() => normalizeArxivLimit(1.5, 5, 25)).toThrow('positive integer');
-    expect(() => normalizeArxivLimit(26, 5, 25)).toThrow('<= 25');
-  });
-});
diff --git a/plugins/arxiv/utils.js b/plugins/arxiv/utils.js
deleted file mode 100644
index de8f0780..00000000
--- a/plugins/arxiv/utils.js
+++ /dev/null
@@ -1,112 +0,0 @@
-/**
- * arXiv adapter utilities.
- *
- * arXiv exposes a public Atom/XML API — no key required.
- * https://info.arxiv.org/help/api/index.html
- */
-import { ArgumentError, CommandExecutionError } from '@agentrhq/webcmd/errors';
-export const ARXIV_BASE = 'https://export.arxiv.org/api/query';
-const ARXIV_CATEGORY_PATTERN = /^[a-z]+(?:-[a-z]+)*(?:\.[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*)?$/;
-export async function arxivFetch(params) {
-    const resp = await fetch(`${ARXIV_BASE}?${params}`);
-    if (!resp.ok) {
-        throw new CommandExecutionError(`arXiv API HTTP ${resp.status}`, 'Check your search term or paper ID');
-    }
-    return resp.text();
-}
-export function normalizeArxivLimit(value, defaultValue, maxValue, label = 'limit') {
-    const raw = value ?? defaultValue;
-    const limit = Number(raw);
-    if (!Number.isInteger(limit) || limit <= 0) {
-        throw new ArgumentError(`arxiv ${label} must be a positive integer`);
-    }
-    if (limit > maxValue) {
-        throw new ArgumentError(`arxiv ${label} must be <= ${maxValue}`);
-    }
-    return limit;
-}
-export function normalizeArxivCategory(value) {
-    const category = String(value || '').trim();
-    if (!ARXIV_CATEGORY_PATTERN.test(category)) {
-        throw new ArgumentError(`Invalid arXiv category "${value}". Examples: cs.CL, cs.LG, math.PR, q-bio.NC, physics.comp-ph`);
-    }
-    return category;
-}
-/** Decode the small set of XML entities arXiv emits in text fields. */
-function decodeEntities(s) {
-    return s
-        .replace(/&/g, '&')
-        .replace(/</g, '<')
-        .replace(/>/g, '>')
-        .replace(/"/g, '"')
-        .replace(/'/g, "'")
-        .replace(/'/g, "'");
-}
-/** Extract the text content of the first matching XML tag. */
-function extract(xml, tag) {
-    const m = xml.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`));
-    return m ? m[1].trim() : '';
-}
-/** Extract all text contents of a repeated XML tag. */
-function extractAll(xml, tag) {
-    const re = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'g');
-    const results = [];
-    let m;
-    while ((m = re.exec(xml)) !== null)
-        results.push(m[1].trim());
-    return results;
-}
-/** Extract the value of a named attribute from the first matching tag (open or self-closing). */
-function extractAttr(xml, tag, attr) {
-    const m = xml.match(new RegExp(`<${tag}\\b[^>]*?\\b${attr}="([^"]*)"`));
-    return m ? m[1] : '';
-}
-/** Extract all values of a named attribute across repeated tags. */
-function extractAllAttr(xml, tag, attr) {
-    const re = new RegExp(`<${tag}\\b[^>]*?\\b${attr}="([^"]*)"`, 'g');
-    const out = [];
-    let m;
-    while ((m = re.exec(xml)) !== null)
-        out.push(m[1]);
-    return out;
-}
-/** Find the href of the first  tag matching a given rel. */
-function findLinkHref(xml, rel) {
-    const re = /]*)\/?>/g;
-    let m;
-    while ((m = re.exec(xml)) !== null) {
-        const attrs = m[1];
-        if (new RegExp(`\\brel="${rel}"`).test(attrs)) {
-            const h = attrs.match(/\bhref="([^"]*)"/);
-            if (h)
-                return h[1];
-        }
-    }
-    return '';
-}
-/** Parse Atom XML feed into structured entries. */
-export function parseEntries(xml) {
-    const entryRe = /([\s\S]*?)<\/entry>/g;
-    const entries = [];
-    let m;
-    while ((m = entryRe.exec(xml)) !== null) {
-        const e = m[1];
-        const rawId = extract(e, 'id');
-        const arxivId = rawId.replace(/^https?:\/\/arxiv\.org\/abs\//, '').replace(/v\d+$/, '');
-        const pdf = findLinkHref(e, 'related') || `https://arxiv.org/pdf/${arxivId}`;
-        entries.push({
-            id: arxivId,
-            title: decodeEntities(extract(e, 'title').replace(/\s+/g, ' ')),
-            authors: decodeEntities(extractAll(e, 'name').join(', ')),
-            abstract: decodeEntities(extract(e, 'summary').replace(/\s+/g, ' ')),
-            published: extract(e, 'published').slice(0, 10),
-            updated: extract(e, 'updated').slice(0, 10),
-            primary_category: extractAttr(e, 'arxiv:primary_category', 'term'),
-            categories: extractAllAttr(e, 'category', 'term').join(', '),
-            comment: decodeEntities(extract(e, 'arxiv:comment').replace(/\s+/g, ' ')),
-            pdf,
-            url: `https://arxiv.org/abs/${arxivId}`,
-        });
-    }
-    return entries;
-}
diff --git a/plugins/arxiv/webcmd-plugin.json b/plugins/arxiv/webcmd-plugin.json
deleted file mode 100644
index 08661c06..00000000
--- a/plugins/arxiv/webcmd-plugin.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
-  "name": "arxiv",
-  "version": "0.1.0",
-  "description": "Webcmd commands for arxiv",
-  "webcmd": ">=0.5.3",
-  "author": {
-    "name": "WebCMD Agent",
-    "handle": "agentrhq"
-  }
-}
diff --git a/plugins/band/README.md b/plugins/band/README.md
deleted file mode 100644
index 4a06d833..00000000
--- a/plugins/band/README.md
+++ /dev/null
@@ -1,20 +0,0 @@
-# webcmd-plugin-band
-
-Webcmd commands for band.
-
-## Install
-
-```bash
-webcmd plugin install github:agentrhq/webcmd/band
-```
-
-## Commands
-
-| Command | Description |
-| --- | --- |
-| `webcmd band bands` | List all Bands you belong to |
-| `webcmd band login` | Open band login |
-| `webcmd band mentions` | Show Band notifications where you are @mentioned |
-| `webcmd band post` | Export full content of a post including comments |
-| `webcmd band posts` | List posts from a Band |
-| `webcmd band whoami` | Show the current logged-in band account |
diff --git a/plugins/band/auth.js b/plugins/band/auth.js
deleted file mode 100644
index f0491029..00000000
--- a/plugins/band/auth.js
+++ /dev/null
@@ -1,56 +0,0 @@
-import { AuthRequiredError, CommandExecutionError } from '@agentrhq/webcmd/errors';
-import { registerSiteAuthCommands } from '@agentrhq/webcmd/plugin-runtime';
-
-async function hasBandSessionCookie(page) {
-  const cookies = await page.getCookies({ url: 'https://www.band.us' });
-  return cookies.some(c => c.name === 'band_session' && c.value);
-}
-
-async function verifyBandIdentity(page) {
-  if (!await hasBandSessionCookie(page)) {
-    throw new AuthRequiredError('band.us', 'Band band_session cookie missing');
-  }
-  await page.goto('https://www.band.us/feed');
-  await page.wait(2);
-  const probe = await page.evaluate(`
-    (() => {
-      if (/auth\\.band\\.us\\/login/.test(location.href)) {
-        return { kind: 'auth', detail: 'Band /feed redirected to auth login' };
-      }
-      let userId = '';
-      try {
-        const stack = [window.__INITIAL_STATE__, window.__BAND_STORE__].filter(Boolean);
-        const seen = new Set();
-        while (stack.length) {
-          const node = stack.pop();
-          if (!node || typeof node !== 'object' || seen.has(node)) continue;
-          seen.add(node);
-          if (Array.isArray(node)) { stack.push(...node); continue; }
-          const u = node.user || node.me || node.currentUser;
-          if (u && (u.user_no || u.user_id || u.userId || u.id)) {
-            userId = String(u.user_no || u.user_id || u.userId || u.id);
-            break;
-          }
-          for (const v of Object.values(node)) if (v && typeof v === 'object') stack.push(v);
-        }
-      } catch {}
-      if (!userId) {
-        const el = document.querySelector('[data-user-no], [data-user_no]');
-        userId = el?.getAttribute('data-user-no') || el?.getAttribute('data-user_no') || '';
-      }
-      return { ok: true, user_id: userId };
-    })()
-  `);
-  if (probe?.kind === 'auth') throw new AuthRequiredError('band.us', probe.detail);
-  if (!probe?.ok) throw new CommandExecutionError(`Unexpected Band probe: ${JSON.stringify(probe)}`);
-  return { user_id: probe.user_id };
-}
-
-registerSiteAuthCommands({
-  site: 'band',
-  domain: 'band.us',
-  loginUrl: 'https://auth.band.us/login',
-  columns: ['user_id'],
-  quickCheck: hasBandSessionCookie,
-  verify: verifyBandIdentity,
-});
diff --git a/plugins/band/bands.js b/plugins/band/bands.js
deleted file mode 100644
index 4472bb0f..00000000
--- a/plugins/band/bands.js
+++ /dev/null
@@ -1,73 +0,0 @@
-import { AuthRequiredError, EmptyResultError } from '@agentrhq/webcmd/errors';
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-/**
- * band bands — List all Bands you belong to.
- *
- * Band.us renders the full band list in the left sidebar of the home page for
- * logged-in users, so we can extract everything we need from the DOM without
- * XHR interception or any secondary navigation.
- *
- * Each sidebar item is an  link whose text and
- * data attributes carry the band name and member count.
- */
-cli({
-    site: 'band',
-    name: 'bands',
-    access: 'read',
-    description: 'List all Bands you belong to',
-    domain: 'www.band.us',
-    strategy: Strategy.COOKIE,
-    browser: true,
-    args: [],
-    columns: ['band_no', 'name', 'members'],
-    func: async (page, _kwargs) => {
-        const cookies = await page.getCookies({ domain: 'band.us' });
-        const isLoggedIn = cookies.some(c => c.name === 'band_session');
-        if (!isLoggedIn)
-            throw new AuthRequiredError('band.us', 'Not logged in to Band');
-        // Extract the band list from the sidebar. Poll until at least one band card
-        // appears (React hydration may take a moment after navigation).
-        // Sidebar band cards use class "bandCover _link" with hrefs like /band/{id}/post.
-        const bands = await page.evaluate(`
-      (async () => {
-        const sleep = ms => new Promise(r => setTimeout(r, ms));
-
-        // Wait up to 9 s for sidebar band cards to render.
-        for (let i = 0; i < 30; i++) {
-          if (document.querySelector('a.bandCover._link')) break;
-          await sleep(300);
-        }
-
-        const norm = s => (s || '').replace(/\\s+/g, ' ').trim();
-        const seen = new Set();
-        const results = [];
-
-        for (const a of Array.from(document.querySelectorAll('a.bandCover._link'))) {
-          // Extract band_no from href: /band/{id} or /band/{id}/post only.
-          const m = (a.getAttribute('href') || '').match(/^\\/band\\/(\\d+)(?:\\/post)?\\/?$/);
-          if (!m) continue;
-          const bandNo = Number(m[1]);
-          if (seen.has(bandNo)) continue;
-          seen.add(bandNo);
-
-          // Band name lives in p.uriText inside div.bandName.
-          const nameEl = a.querySelector('p.uriText');
-          const name = nameEl ? norm(nameEl.textContent) : '';
-          if (!name) continue;
-
-          // Member count is the  inside span.member.
-          const memberEl = a.querySelector('span.member em');
-          const members = memberEl ? parseInt((memberEl.textContent || '').replace(/[^0-9]/g, ''), 10) || 0 : 0;
-
-          results.push({ band_no: bandNo, name, members });
-        }
-
-        return results;
-      })()
-    `);
-        if (!bands || bands.length === 0) {
-            throw new EmptyResultError('band bands', 'No bands found in sidebar — are you logged in?');
-        }
-        return bands;
-    },
-});
diff --git a/plugins/band/mentions.js b/plugins/band/mentions.js
deleted file mode 100644
index 60d1dd1e..00000000
--- a/plugins/band/mentions.js
+++ /dev/null
@@ -1,128 +0,0 @@
-import { AuthRequiredError, EmptyResultError, selectorError } from '@agentrhq/webcmd/errors';
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-/**
- * band mentions — Show Band notifications where you were @mentioned.
- *
- * Band.us signs every API request with a per-request HMAC (`md` header) generated
- * by its own JavaScript, so we cannot replicate it externally. Instead we use
- * Strategy.INTERCEPT: install an XHR interceptor, open the notification panel by
- * clicking the bell to trigger the get_news XHR call, then apply client-side
- * filtering to extract notifications matching the requested filter/unread options.
- */
-cli({
-    site: 'band',
-    name: 'mentions',
-    access: 'read',
-    description: 'Show Band notifications where you are @mentioned',
-    domain: 'www.band.us',
-    strategy: Strategy.INTERCEPT,
-    browser: true,
-    args: [
-        {
-            name: 'filter',
-            default: 'mentioned',
-            choices: ['mentioned', 'all', 'post', 'comment'],
-            help: 'Filter: mentioned (default) | all | post | comment',
-        },
-        { name: 'limit', type: 'int', default: 20, help: 'Max results' },
-        { name: 'unread', type: 'bool', default: false, help: 'Show only unread notifications' },
-    ],
-    columns: ['time', 'band', 'type', 'from', 'text', 'url'],
-    func: async (page, kwargs) => {
-        const filter = kwargs.filter;
-        const limit = kwargs.limit;
-        const unreadOnly = kwargs.unread;
-        // Navigate with a timestamp param to force a fresh page load each run.
-        // Without this, same-URL navigation may skip the reload (preserving the JS context
-        // and leaving the notification panel open from a previous run).
-        await page.goto(`https://www.band.us/?_=${Date.now()}`);
-        const cookies = await page.getCookies({ domain: 'band.us' });
-        const isLoggedIn = cookies.some(c => c.name === 'band_session');
-        if (!isLoggedIn)
-            throw new AuthRequiredError('band.us', 'Not logged in to Band');
-        // Install XHR interceptor before any clicks so all get_news responses are captured.
-        await page.installInterceptor('get_news');
-        // Wait for the bell button to appear (React hydration) instead of a fixed sleep.
-        let bellReady = false;
-        for (let i = 0; i < 20; i++) {
-            const exists = await page.evaluate(`() => !!document.querySelector('button._btnWidgetIcon')`);
-            if (exists) {
-                bellReady = true;
-                break;
-            }
-            await page.wait(0.5);
-        }
-        if (!bellReady) {
-            throw selectorError('button._btnWidgetIcon', 'Notification bell not found. The Band.us UI may have changed.');
-        }
-        // Poll until a capture containing result_data.news arrives, up to maxSecs seconds.
-        // getInterceptedRequests() clears the array on each call, so captures are accumulated
-        // locally. The interceptor pattern 'get_news' also matches 'get_news_count' responses
-        // which don't have result_data.news — keep polling until the real news response arrives.
-        const waitForOneCapture = async (maxSecs = 8) => {
-            const captures = [];
-            for (let i = 0; i < maxSecs * 2; i++) {
-                await page.wait(0.5); // 0.5 seconds per iteration (page.wait takes seconds)
-                const reqs = await page.getInterceptedRequests();
-                if (reqs.length > 0) {
-                    captures.push(...reqs);
-                    if (captures.some((r) => Array.isArray(r?.result_data?.news)))
-                        return captures;
-                }
-            }
-            return captures;
-        };
-        // Click the bell. Guard against the element disappearing between the readiness
-        // check and the click (e.g. due to a React re-render) to surface a clear error.
-        const bellClicked = await page.evaluate(`() => {
-      const el = document.querySelector('button._btnWidgetIcon');
-      if (!el) return false;
-      el.click();
-      return true;
-    }`);
-        if (!bellClicked) {
-            throw selectorError('button._btnWidgetIcon', 'Notification bell disappeared before click. The Band.us UI may have changed.');
-        }
-        const requests = await waitForOneCapture();
-        // Find the get_news response (has result_data.news); get_news_count responses do not.
-        const newsReq = requests.find((r) => Array.isArray(r?.result_data?.news));
-        if (!newsReq) {
-            throw new EmptyResultError('band mentions', 'Failed to capture get_news response from Band.us. Try running the command again.');
-        }
-        let items = newsReq.result_data.news ?? [];
-        if (items.length === 0) {
-            throw new EmptyResultError('band mentions', 'No notifications found');
-        }
-        // Apply filters client-side from the full notification list.
-        if (unreadOnly) {
-            items = items.filter((n) => n.is_new === true);
-        }
-        if (filter === 'mentioned') {
-            // 'filters' is Band's server-side tag array; 'referred' means you were @mentioned.
-            items = items.filter((n) => n.filters?.includes('referred'));
-        }
-        else if (filter === 'post') {
-            items = items.filter((n) => n.category === 'post');
-        }
-        else if (filter === 'comment') {
-            items = items.filter((n) => n.category === 'comment');
-        }
-        // Band markup tags (, , etc.) appear in
-        // notification text; strip them to get plain readable content.
-        const stripBandTags = (s) => s.replace(/<\/?band:[^>]+>/g, '');
-        return items.slice(0, limit).map((n) => {
-            const ts = n.created_at ? new Date(n.created_at) : null;
-            return {
-                time: ts
-                    ? ts.toLocaleString('ja-JP', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
-                    : '',
-                band: n.band?.name ?? '',
-                // 'filters' is Band's server-side tag array; 'referred' means you were @mentioned.
-                type: n.filters?.includes('referred') ? '@mention' : n.category ?? '',
-                from: n.actor?.name ?? '',
-                text: stripBandTags(n.subtext ?? '').slice(0, 100),
-                url: n.action?.pc ?? '',
-            };
-        });
-    },
-});
diff --git a/plugins/band/package.json b/plugins/band/package.json
deleted file mode 100644
index 9c7d0748..00000000
--- a/plugins/band/package.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
-  "name": "webcmd-plugin-band",
-  "version": "0.1.0",
-  "type": "module",
-  "description": "Webcmd commands for band",
-  "peerDependencies": {
-    "@agentrhq/webcmd": ">=0.5.3"
-  }
-}
diff --git a/plugins/band/post.js b/plugins/band/post.js
deleted file mode 100644
index 84ec497b..00000000
--- a/plugins/band/post.js
+++ /dev/null
@@ -1,176 +0,0 @@
-import { AuthRequiredError, EmptyResultError } from '@agentrhq/webcmd/errors';
-import { formatCookieHeader } from '@agentrhq/webcmd/download';
-import { downloadMedia } from '@agentrhq/webcmd/download/media-download';
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-/**
- * band post — Export full content of a Band post: body, comments, and optional photo download.
- *
- * Navigates directly to the post URL and extracts everything from the DOM.
- * No XHR interception needed — Band renders the full post for logged-in users.
- *
- * Output rows:
- *   type=post    → the post itself (author, date, body text)
- *   type=comment → top-level comment
- *   type=reply   → reply to a comment (nested under its parent)
- *
- * Photo thumbnail URLs carry a ?type=sNNN suffix; stripping it yields full-res.
- */
-cli({
-    site: 'band',
-    name: 'post',
-    access: 'read',
-    description: 'Export full content of a post including comments',
-    domain: 'www.band.us',
-    strategy: Strategy.COOKIE,
-    navigateBefore: false,
-    browser: true,
-    args: [
-        { name: 'band_no', positional: true, required: true, type: 'int', help: 'Band number' },
-        { name: 'post_no', positional: true, required: true, type: 'int', help: 'Post number' },
-        { name: 'output', type: 'str', default: '', help: 'Directory to save attached photos' },
-        { name: 'comments', type: 'bool', default: true, help: 'Include comments (default: true)' },
-    ],
-    columns: ['type', 'author', 'date', 'text'],
-    func: async (page, kwargs) => {
-        const bandNo = Number(kwargs.band_no);
-        const postNo = Number(kwargs.post_no);
-        const outputDir = kwargs.output;
-        const withComments = kwargs.comments;
-        await page.goto(`https://www.band.us/band/${bandNo}/post/${postNo}`);
-        const cookies = await page.getCookies({ domain: 'band.us' });
-        const isLoggedIn = cookies.some(c => c.name === 'band_session');
-        if (!isLoggedIn)
-            throw new AuthRequiredError('band.us', 'Not logged in to Band');
-        const data = await page.evaluate(`
-      (async () => {
-        const withComments = ${withComments};
-        const sleep = ms => new Promise(r => setTimeout(r, ms));
-        const norm = s => (s || '').replace(/\\s+/g, ' ').trim();
-        // Band embeds , , etc. in content — strip to plain text.
-        const stripTags = s => s.replace(/<\\/?band:[^>]+>/g, '');
-
-        // Wait up to 9 s for the post content to render (poll for the author link,
-        // which appears after React hydration fills the post header).
-        for (let i = 0; i < 30; i++) {
-          if (document.querySelector('._postWrapper a.text')) break;
-          await sleep(300);
-        }
-
-        const postCard = document.querySelector('._postWrapper');
-        const commentSection = postCard?.querySelector('.dPostCommentMainView');
-
-        // Author and date live in the post header, above the comment section.
-        // Exclude any matches inside the comment section to avoid picking up comment authors.
-        let author = '', date = '';
-        for (const el of (postCard?.querySelectorAll('a.text') || [])) {
-          if (!commentSection?.contains(el)) { author = norm(el.textContent); break; }
-        }
-        for (const el of (postCard?.querySelectorAll('time.time') || [])) {
-          if (!commentSection?.contains(el)) { date = norm(el.textContent); break; }
-        }
-
-        const bodyEl = postCard?.querySelector('.postText._postText');
-        const text = bodyEl ? stripTags(norm(bodyEl.innerText || bodyEl.textContent)) : '';
-
-        // Photo thumbnails have a ?type=sNNN query param; strip it for full-res URL.
-        // Use location.href as base so protocol-relative or relative URLs resolve correctly.
-        const photos = Array.from(postCard?.querySelectorAll('img._imgRecentPhoto, img._imgPhoto') || [])
-          .map(img => {
-            const src = img.getAttribute('src') || '';
-            if (!src) return '';
-            try { const u = new URL(src, location.href); return u.origin + u.pathname; }
-            catch { return ''; }
-          })
-          .filter(Boolean);
-
-        if (!withComments) return { author, date, text, photos, comments: [] };
-
-        // Wait up to 6 s for the comment list container to render.
-        // Wait for the container itself (not .cComment) so posts with zero comments
-        // don't incur a fixed 6s delay waiting for an element that never appears.
-        for (let i = 0; i < 20; i++) {
-          if (postCard?.querySelector('.sCommentList._heightDetectAreaForComment')) break;
-          await sleep(300);
-        }
-
-        // Recursively collect comments and their replies.
-        // Replies live in .sReplyList > .sCommentList, not in ._replyRegion.
-        function extractComments(container, depth) {
-          const results = [];
-          for (const el of container.querySelectorAll(':scope > .cComment')) {
-            results.push({
-              depth,
-              author: norm(el.querySelector('strong.name')?.textContent),
-              date:   norm(el.querySelector('time.time')?.textContent),
-              text:   stripTags(norm(el.querySelector('p.txt._commentContent')?.innerText || '')),
-            });
-            const replyList = el.querySelector('.sReplyList .sCommentList._heightDetectAreaForComment');
-            if (replyList) results.push(...extractComments(replyList, depth + 1));
-          }
-          return results;
-        }
-
-        const commentList = postCard?.querySelector('.sCommentList._heightDetectAreaForComment');
-        const comments = commentList ? extractComments(commentList, 0) : [];
-
-        return { author, date, text, photos, comments };
-      })()
-    `);
-        if (!data?.text && !data?.comments?.length && !data?.photos?.length) {
-            throw new EmptyResultError('band post', 'Post not found or not accessible');
-        }
-        const photos = data.photos ?? [];
-        // Download photos when --output is specified, using the shared downloadMedia utility
-        // which handles redirects, timeouts, and stream errors correctly.
-        // Pass browser cookies so Band's login-protected photo URLs don't fail with 401/403.
-        if (outputDir && photos.length > 0) {
-            // Only send Band cookies to Band-hosted URLs; avoid leaking auth cookies to third-party CDNs.
-            // Use a global index across both batches so filenames don't collide (photo_1, photo_2, ...).
-            const cookieHeader = formatCookieHeader(await page.getCookies({ url: 'https://www.band.us' }));
-            const isBandUrl = (u) => { try {
-                const h = new URL(u).hostname;
-                return h === 'band.us' || h.endsWith('.band.us');
-            }
-            catch {
-                return false;
-            } };
-            // Derive extension from URL path so downloaded files have correct extensions (e.g. photo_1.jpg).
-            const urlExt = (u) => { try {
-                return new URL(u).pathname.match(/\.(\w+)$/)?.[1] ?? 'jpg';
-            }
-            catch {
-                return 'jpg';
-            } };
-            let globalIndex = 1;
-            const bandPhotos = photos.filter(isBandUrl);
-            const otherPhotos = photos.filter(u => !isBandUrl(u));
-            if (bandPhotos.length > 0) {
-                await downloadMedia(bandPhotos.map(url => ({ type: 'image', url, filename: `photo_${globalIndex++}.${urlExt(url)}` })), { output: outputDir, verbose: false, cookies: cookieHeader });
-            }
-            if (otherPhotos.length > 0) {
-                await downloadMedia(otherPhotos.map(url => ({ type: 'image', url, filename: `photo_${globalIndex++}.${urlExt(url)}` })), { output: outputDir, verbose: false });
-            }
-        }
-        const rows = [];
-        // Post row — append photo URLs inline when not downloading to disk.
-        rows.push({
-            type: 'post',
-            author: data.author ?? '',
-            date: data.date ?? '',
-            text: [
-                data.text ?? '',
-                ...(outputDir ? [] : photos.map((u, i) => `[photo${i + 1}] ${u}`)),
-            ].filter(Boolean).join('\n'),
-        });
-        // Comment rows — depth=0 → type 'comment', depth≥1 → type 'reply'.
-        for (const c of data.comments ?? []) {
-            rows.push({
-                type: c.depth === 0 ? 'comment' : 'reply',
-                author: c.author ?? '',
-                date: c.date ?? '',
-                text: c.depth > 0 ? '  '.repeat(c.depth) + '└ ' + (c.text ?? '') : (c.text ?? ''),
-            });
-        }
-        return rows;
-    },
-});
diff --git a/plugins/band/posts.js b/plugins/band/posts.js
deleted file mode 100644
index 8a78efe1..00000000
--- a/plugins/band/posts.js
+++ /dev/null
@@ -1,95 +0,0 @@
-import { AuthRequiredError, EmptyResultError } from '@agentrhq/webcmd/errors';
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-/**
- * band posts — List posts from a specific Band.
- *
- * Band.us renders the post list in the DOM for logged-in users, so we navigate
- * directly to the band's post page and extract everything from the DOM — no XHR
- * interception or home-page detour required.
- */
-cli({
-    site: 'band',
-    name: 'posts',
-    access: 'read',
-    description: 'List posts from a Band',
-    domain: 'www.band.us',
-    strategy: Strategy.COOKIE,
-    navigateBefore: false,
-    browser: true,
-    args: [
-        {
-            name: 'band_no',
-            positional: true,
-            required: true,
-            type: 'int',
-            help: 'Band number (get it from: band bands)',
-        },
-        { name: 'limit', type: 'int', default: 20, help: 'Max results' },
-    ],
-    columns: ['date', 'author', 'content', 'comments', 'url'],
-    func: async (page, kwargs) => {
-        const bandNo = Number(kwargs.band_no);
-        const limit = Number(kwargs.limit);
-        // Navigate directly to the band's post page — no home-page detour needed.
-        await page.goto(`https://www.band.us/band/${bandNo}/post`);
-        const cookies = await page.getCookies({ domain: 'band.us' });
-        const isLoggedIn = cookies.some(c => c.name === 'band_session');
-        if (!isLoggedIn)
-            throw new AuthRequiredError('band.us', 'Not logged in to Band');
-        // Extract post list from the DOM. Poll until post items appear (React hydration).
-        const posts = await page.evaluate(`
-      (async () => {
-        const sleep = ms => new Promise(r => setTimeout(r, ms));
-        const norm = s => (s || '').replace(/\\s+/g, ' ').trim();
-        const limit = ${limit};
-
-        // Wait up to 9 s for post items to render.
-        for (let i = 0; i < 30; i++) {
-          if (document.querySelector('article.cContentsCard._postMainWrap')) break;
-          await sleep(300);
-        }
-
-        // Band embeds custom , , etc. tags in content.
-        const stripTags = s => s.replace(/<\\/?band:[^>]+>/g, '');
-
-        const results = [];
-        const postEls = Array.from(
-          document.querySelectorAll('article.cContentsCard._postMainWrap')
-        );
-
-        for (const el of postEls) {
-          // URL: first post permalink link (absolute or relative).
-          const linkEl = el.querySelector('a[href*="/post/"]');
-          const href = linkEl?.getAttribute('href') || '';
-          if (!href) continue;
-          const url = href.startsWith('http') ? href : 'https://www.band.us' + href;
-
-          // Author name — a.text in the post header area.
-          const author = norm(el.querySelector('a.text')?.textContent);
-
-          // Date / timestamp.
-          const date = norm(el.querySelector('time')?.textContent);
-
-          // Post body text (strip Band markup tags, truncate for listing).
-          const bodyEl = el.querySelector('.postText._postText');
-          const content = bodyEl
-            ? stripTags(norm(bodyEl.innerText || bodyEl.textContent)).slice(0, 120)
-            : '';
-
-          // Comment count is in span.count inside the count area.
-          const commentEl = el.querySelector('span.count');
-          const comments = commentEl ? parseInt((commentEl.textContent || '').replace(/[^0-9]/g, ''), 10) || 0 : 0;
-
-          if (results.length >= limit) break;
-          results.push({ date, author, content, comments, url });
-        }
-
-        return results;
-      })()
-    `);
-        if (!posts || posts.length === 0) {
-            throw new EmptyResultError('band posts', 'No posts found in this Band');
-        }
-        return posts;
-    },
-});
diff --git a/plugins/band/webcmd-plugin.json b/plugins/band/webcmd-plugin.json
deleted file mode 100644
index 7d271a7b..00000000
--- a/plugins/band/webcmd-plugin.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
-  "name": "band",
-  "version": "0.1.0",
-  "description": "Webcmd commands for band",
-  "webcmd": ">=0.5.3",
-  "author": {
-    "name": "WebCMD Agent",
-    "handle": "agentrhq"
-  }
-}
diff --git a/plugins/barchart/README.md b/plugins/barchart/README.md
deleted file mode 100644
index a963e990..00000000
--- a/plugins/barchart/README.md
+++ /dev/null
@@ -1,18 +0,0 @@
-# webcmd-plugin-barchart
-
-Webcmd commands for barchart.
-
-## Install
-
-```bash
-webcmd plugin install github:agentrhq/webcmd/barchart
-```
-
-## Commands
-
-| Command | Description |
-| --- | --- |
-| `webcmd barchart flow` | Barchart unusual options activity / options flow |
-| `webcmd barchart greeks` | Barchart options greeks overview (IV, delta, gamma, theta, vega) |
-| `webcmd barchart options` | Barchart options chain with greeks, IV, volume, and open interest |
-| `webcmd barchart quote` | Barchart stock quote with price, volume, and key metrics |
diff --git a/plugins/barchart/flow.js b/plugins/barchart/flow.js
deleted file mode 100644
index 9fba551f..00000000
--- a/plugins/barchart/flow.js
+++ /dev/null
@@ -1,116 +0,0 @@
-/**
- * Barchart unusual options activity (options flow).
- * Shows high volume/OI ratio trades that may indicate institutional activity.
- * Auth: CSRF token from  + session cookies.
- */
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-cli({
-    site: 'barchart',
-    name: 'flow',
-    access: 'read',
-    description: 'Barchart unusual options activity / options flow',
-    domain: 'www.barchart.com',
-    strategy: Strategy.COOKIE,
-    args: [
-        { name: 'type', type: 'str', default: 'all', help: 'Filter: all, call, or put', choices: ['all', 'call', 'put'] },
-        { name: 'limit', type: 'int', default: 20, help: 'Number of results' },
-    ],
-    columns: [
-        'symbol', 'type', 'strike', 'expiration', 'last',
-        'volume', 'openInterest', 'volOiRatio', 'iv',
-    ],
-    func: async (page, kwargs) => {
-        const optionType = kwargs.type || 'all';
-        const limit = kwargs.limit ?? 20;
-        await page.goto('https://www.barchart.com/options/unusual-activity/stocks');
-        await page.wait(5);
-        const data = await page.evaluate(`
-      (async () => {
-        const limit = ${limit};
-        const typeFilter = ${JSON.stringify(optionType)}.toLowerCase();
-
-        // Wait for CSRF token to appear (Angular may inject it after initial render)
-        let csrf = '';
-        for (let i = 0; i < 10; i++) {
-          csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
-          if (csrf) break;
-          await new Promise(r => setTimeout(r, 500));
-        }
-        if (!csrf) return { error: 'no-csrf' };
-
-        const headers = { 'X-CSRF-TOKEN': csrf };
-        const fields = [
-          'baseSymbol','strikePrice','expirationDate','optionType',
-          'lastPrice','volume','openInterest','volumeOpenInterestRatio','volatility',
-        ].join(',');
-
-        // Fetch extra rows when filtering by type since server-side filter doesn't work
-        const fetchLimit = typeFilter !== 'all' ? limit * 3 : limit;
-
-        // Try unusual_activity first, fall back to mostActive (unusual_activity is
-        // empty outside market hours)
-        const lists = [
-          'options.unusual_activity.stocks.us',
-          'options.mostActive.us',
-        ];
-
-        for (const list of lists) {
-          try {
-            const url = '/proxies/core-api/v1/options/get?list=' + list
-              + '&fields=' + fields
-              + '&orderBy=volumeOpenInterestRatio&orderDir=desc'
-              + '&raw=1&limit=' + fetchLimit;
-
-            const resp = await fetch(url, { credentials: 'include', headers });
-            if (!resp.ok) continue;
-            const d = await resp.json();
-            let items = d?.data || [];
-            if (items.length === 0) continue;
-
-            // Apply client-side type filter
-            if (typeFilter !== 'all') {
-              items = items.filter(i => {
-                const t = ((i.raw || i).optionType || '').toLowerCase();
-                return t === typeFilter;
-              });
-            }
-            return items.slice(0, limit).map(i => {
-              const r = i.raw || i;
-              return {
-                symbol: r.baseSymbol || r.symbol,
-                type: r.optionType,
-                strike: r.strikePrice,
-                expiration: r.expirationDate,
-                last: r.lastPrice,
-                volume: r.volume,
-                openInterest: r.openInterest,
-                volOiRatio: r.volumeOpenInterestRatio,
-                iv: r.volatility,
-              };
-            });
-          } catch(e) {}
-        }
-
-        return [];
-      })()
-    `);
-        if (!data)
-            return [];
-        if (data.error === 'no-csrf') {
-            throw new Error('Could not extract CSRF token from barchart.com. Make sure you are logged in.');
-        }
-        if (!Array.isArray(data))
-            return [];
-        return data.slice(0, limit).map(r => ({
-            symbol: r.symbol || '',
-            type: r.type || '',
-            strike: r.strike,
-            expiration: r.expiration ?? null,
-            last: r.last != null ? Number(Number(r.last).toFixed(2)) : null,
-            volume: r.volume,
-            openInterest: r.openInterest,
-            volOiRatio: r.volOiRatio != null ? Number(Number(r.volOiRatio).toFixed(2)) : null,
-            iv: r.iv != null ? Number(Number(r.iv).toFixed(2)) + '%' : null,
-        }));
-    },
-});
diff --git a/plugins/barchart/greeks.js b/plugins/barchart/greeks.js
deleted file mode 100644
index fcb826fc..00000000
--- a/plugins/barchart/greeks.js
+++ /dev/null
@@ -1,208 +0,0 @@
-/**
- * Barchart options greeks overview — IV, delta, gamma, theta, vega, rho
- * for near-the-money options on a given symbol.
- * Auth: CSRF token from  + session cookies.
- */
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { ArgumentError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors';
-
-const DEFAULT_LIMIT = 10;
-const MIN_LIMIT = 1;
-const MAX_LIMIT = 100;
-
-function normalizeSymbol(value) {
-    const symbol = String(value ?? '').trim().toUpperCase();
-    if (!symbol) throw new ArgumentError('symbol is required');
-    return symbol;
-}
-
-function normalizeExpiration(value) {
-    const expiration = String(value ?? '').trim();
-    if (!expiration) return '';
-    if (!/^\d{4}-\d{2}-\d{2}$/.test(expiration)) {
-        throw new ArgumentError('--expiration must use YYYY-MM-DD format');
-    }
-    const parsed = new Date(`${expiration}T00:00:00Z`);
-    if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== expiration) {
-        throw new ArgumentError('--expiration must be a valid calendar date');
-    }
-    return expiration;
-}
-
-function parseLimit(value) {
-    if (value === undefined || value === null || value === '') return DEFAULT_LIMIT;
-    const limit = Number(value);
-    if (!Number.isInteger(limit) || limit < MIN_LIMIT || limit > MAX_LIMIT) {
-        throw new ArgumentError(`--limit must be an integer between ${MIN_LIMIT} and ${MAX_LIMIT}`);
-    }
-    return limit;
-}
-
-function unwrapBrowserResult(value) {
-    if (value && typeof value === 'object' && 'session' in value && 'data' in value) {
-        return value.data;
-    }
-    return value;
-}
-
-cli({
-    site: 'barchart',
-    name: 'greeks',
-    access: 'read',
-    description: 'Barchart options greeks overview (IV, delta, gamma, theta, vega)',
-    domain: 'www.barchart.com',
-    strategy: Strategy.COOKIE,
-    args: [
-        { name: 'symbol', required: true, positional: true, help: 'Stock ticker (e.g. AAPL)' },
-        { name: 'expiration', type: 'str', help: 'Expiration date (YYYY-MM-DD). Defaults to the nearest available expiration.' },
-        { name: 'limit', type: 'int', default: DEFAULT_LIMIT, help: 'Number of near-the-money strikes per type (1-100)' },
-    ],
-    columns: [
-        'type', 'strike', 'last', 'iv', 'delta', 'gamma', 'theta', 'vega', 'rho',
-        'volume', 'openInterest', 'expiration',
-    ],
-    func: async (page, kwargs) => {
-        const symbol = normalizeSymbol(kwargs.symbol);
-        const expiration = normalizeExpiration(kwargs.expiration);
-        const limit = parseLimit(kwargs.limit);
-        await page.goto(`https://www.barchart.com/stocks/quotes/${encodeURIComponent(symbol)}/options`);
-        await page.wait(4);
-        const data = unwrapBrowserResult(await page.evaluate(`
-      (async () => {
-        const sym = ${JSON.stringify(symbol)};
-        const expDate = ${JSON.stringify(expiration)};
-        const limit = ${limit};
-        const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
-        const headers = { 'X-CSRF-TOKEN': csrf };
-
-        try {
-          const fields = [
-            'strikePrice','lastPrice','volume','openInterest',
-            'volatility','delta','gamma','theta','vega','rho',
-            'expirationDate','optionType','percentFromLast',
-          ].join(',');
-
-          let url = '/proxies/core-api/v1/options/chain?symbol=' + encodeURIComponent(sym)
-            + '&fields=' + fields + '&raw=1';
-          if (expDate) url += '&expirationDate=' + encodeURIComponent(expDate);
-          const resp = await fetch(url, { credentials: 'include', headers });
-          if (!resp.ok) {
-            return { ok: false, reason: 'http', status: resp.status, statusText: resp.statusText || '' };
-          }
-
-          const d = await resp.json();
-          const allItems = d?.data;
-          if (!Array.isArray(allItems)) {
-            return { ok: false, reason: 'malformed' };
-          }
-          let items = allItems;
-
-          if (!expDate) {
-            const expirations = items
-              .map(i => (i.raw || i).expirationDate || null)
-              .filter(Boolean)
-              .sort((a, b) => {
-                const aTime = Date.parse(a);
-                const bTime = Date.parse(b);
-                if (Number.isNaN(aTime) && Number.isNaN(bTime)) return 0;
-                if (Number.isNaN(aTime)) return 1;
-                if (Number.isNaN(bTime)) return -1;
-                return aTime - bTime;
-              });
-            const nearestExpiration = expirations[0];
-            if (nearestExpiration) {
-              items = items.filter(i => ((i.raw || i).expirationDate || null) === nearestExpiration);
-            }
-          }
-
-          // Separate calls and puts, sort by distance from current price.
-          const calls = items
-            .filter(i => ((i.raw || i).optionType || '').toLowerCase() === 'call')
-            .sort((a, b) => Math.abs((a.raw || a).percentFromLast || 999) - Math.abs((b.raw || b).percentFromLast || 999))
-            .slice(0, limit);
-          const puts = items
-            .filter(i => ((i.raw || i).optionType || '').toLowerCase() === 'put')
-            .sort((a, b) => Math.abs((a.raw || a).percentFromLast || 999) - Math.abs((b.raw || b).percentFromLast || 999))
-            .slice(0, limit);
-          const selected = [...calls, ...puts];
-
-          if (items.length > 0 && selected.length === 0) {
-            return { ok: false, reason: 'malformed', message: 'options rows did not include call or put identities' };
-          }
-
-          return {
-            ok: true,
-            rows: selected.map(i => {
-              const r = i.raw || i;
-              return {
-                type: r.optionType,
-                strike: r.strikePrice,
-                last: r.lastPrice,
-                iv: r.volatility,
-                delta: r.delta,
-                gamma: r.gamma,
-                theta: r.theta,
-                vega: r.vega,
-                rho: r.rho,
-                volume: r.volume,
-                openInterest: r.openInterest,
-                expiration: r.expirationDate,
-              };
-            })
-          };
-        } catch(e) {
-          return { ok: false, reason: 'exception', message: e?.message || String(e) };
-        }
-      })()
-    `));
-        if (!data || data.ok !== true) {
-            if (data?.reason === 'http') {
-                throw new CommandExecutionError(`Barchart greeks request failed: HTTP ${data.status}${data.statusText ? ` ${data.statusText}` : ''}`);
-            }
-            if (data?.reason === 'malformed') {
-                throw new CommandExecutionError(`Barchart greeks returned an unreadable options payload${data.message ? `: ${data.message}` : ''}`);
-            }
-            if (data?.reason === 'exception') {
-                throw new CommandExecutionError(`Barchart greeks request failed: ${data.message || 'unknown error'}`);
-            }
-            throw new CommandExecutionError(`Failed to fetch Barchart greeks for ${symbol}`);
-        }
-        if (!Array.isArray(data.rows)) {
-            throw new CommandExecutionError('Barchart greeks returned an unreadable options payload');
-        }
-        if (data.rows.length === 0) {
-            throw new EmptyResultError('barchart greeks', `No option greeks were returned for ${symbol}. Confirm the symbol, expiration, and Barchart login state.`);
-        }
-        return data.rows.map(r => {
-            if (!r || typeof r !== 'object' || Array.isArray(r)) {
-                throw new CommandExecutionError('Barchart greeks returned a malformed option row');
-            }
-            const type = String(r.type || '').trim();
-            const expirationValue = String(r.expiration || '').trim();
-            if (!/^(call|put)$/i.test(type) || r.strike === undefined || r.strike === null || r.strike === '' || !expirationValue) {
-                throw new CommandExecutionError('Barchart greeks returned a malformed option row identity');
-            }
-            return {
-                type,
-                strike: r.strike,
-                last: r.last != null ? Number(Number(r.last).toFixed(2)) : null,
-                iv: r.iv != null ? Number(Number(r.iv).toFixed(2)) + '%' : null,
-                delta: r.delta != null ? Number(Number(r.delta).toFixed(4)) : null,
-                gamma: r.gamma != null ? Number(Number(r.gamma).toFixed(4)) : null,
-                theta: r.theta != null ? Number(Number(r.theta).toFixed(4)) : null,
-                vega: r.vega != null ? Number(Number(r.vega).toFixed(4)) : null,
-                rho: r.rho != null ? Number(Number(r.rho).toFixed(4)) : null,
-                volume: r.volume,
-                openInterest: r.openInterest,
-                expiration: expirationValue,
-            };
-        });
-    },
-});
-
-export const __test__ = {
-    normalizeSymbol,
-    normalizeExpiration,
-    parseLimit,
-    unwrapBrowserResult,
-};
diff --git a/plugins/barchart/options.js b/plugins/barchart/options.js
deleted file mode 100644
index 7b8760e3..00000000
--- a/plugins/barchart/options.js
+++ /dev/null
@@ -1,107 +0,0 @@
-/**
- * Barchart options chain — strike, bid/ask, volume, OI, greeks, IV.
- * Auth: CSRF token from  + session cookies.
- */
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-cli({
-    site: 'barchart',
-    name: 'options',
-    access: 'read',
-    description: 'Barchart options chain with greeks, IV, volume, and open interest',
-    domain: 'www.barchart.com',
-    strategy: Strategy.COOKIE,
-    args: [
-        { name: 'symbol', required: true, positional: true, help: 'Stock ticker (e.g. AAPL)' },
-        { name: 'type', type: 'str', default: 'Call', help: 'Option type: Call or Put', choices: ['Call', 'Put'] },
-        { name: 'limit', type: 'int', default: 20, help: 'Max number of strikes to return' },
-    ],
-    columns: [
-        'strike', 'bid', 'ask', 'last', 'change', 'volume', 'openInterest',
-        'iv', 'delta', 'gamma', 'theta', 'vega', 'expiration',
-    ],
-    func: async (page, kwargs) => {
-        const symbol = kwargs.symbol.toUpperCase().trim();
-        const optType = kwargs.type || 'Call';
-        const limit = kwargs.limit ?? 20;
-        await page.goto(`https://www.barchart.com/stocks/quotes/${encodeURIComponent(symbol)}/options`);
-        await page.wait(4);
-        const data = await page.evaluate(`
-      (async () => {
-        const sym = ${JSON.stringify(symbol)};
-        const type = ${JSON.stringify(optType)};
-        const limit = ${limit};
-        const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
-        const headers = { 'X-CSRF-TOKEN': csrf };
-
-        // API: options chain with greeks
-        try {
-          const fields = [
-            'strikePrice','bidPrice','askPrice','lastPrice','priceChange',
-            'volume','openInterest','volatility',
-            'delta','gamma','theta','vega',
-            'expirationDate','optionType','percentFromLast',
-          ].join(',');
-
-          const url = '/proxies/core-api/v1/options/chain?symbol=' + encodeURIComponent(sym)
-            + '&fields=' + fields + '&raw=1';
-          const resp = await fetch(url, { credentials: 'include', headers });
-          if (resp.ok) {
-            const d = await resp.json();
-            let items = d?.data || [];
-
-            // Filter by type
-            items = items.filter(i => {
-              const t = (i.raw || i).optionType || '';
-              return t.toLowerCase() === type.toLowerCase();
-            });
-
-            // Sort by closeness to current price
-            items.sort((a, b) => {
-              const aD = Math.abs((a.raw || a).percentFromLast || 999);
-              const bD = Math.abs((b.raw || b).percentFromLast || 999);
-              return aD - bD;
-            });
-
-            return items.slice(0, limit).map(i => {
-              const r = i.raw || i;
-              return {
-                strike: r.strikePrice,
-                bid: r.bidPrice,
-                ask: r.askPrice,
-                last: r.lastPrice,
-                change: r.priceChange,
-                volume: r.volume,
-                openInterest: r.openInterest,
-                iv: r.volatility,
-                delta: r.delta,
-                gamma: r.gamma,
-                theta: r.theta,
-                vega: r.vega,
-                expiration: r.expirationDate,
-              };
-            });
-          }
-        } catch(e) {}
-
-        return [];
-      })()
-    `);
-        if (!data || !Array.isArray(data))
-            return [];
-        return data.map(r => ({
-            strike: r.strike,
-            bid: r.bid != null ? Number(Number(r.bid).toFixed(2)) : null,
-            ask: r.ask != null ? Number(Number(r.ask).toFixed(2)) : null,
-            last: r.last != null ? Number(Number(r.last).toFixed(2)) : null,
-            change: r.change != null ? Number(Number(r.change).toFixed(2)) : null,
-            volume: r.volume,
-            openInterest: r.openInterest,
-            iv: r.iv != null ? Number(Number(r.iv).toFixed(2)) + '%' : null,
-            delta: r.delta != null ? Number(Number(r.delta).toFixed(4)) : null,
-            gamma: r.gamma != null ? Number(Number(r.gamma).toFixed(4)) : null,
-            theta: r.theta != null ? Number(Number(r.theta).toFixed(4)) : null,
-            vega: r.vega != null ? Number(Number(r.vega).toFixed(4)) : null,
-            expiration: r.expiration ?? null,
-        }));
-    },
-});
diff --git a/plugins/barchart/package.json b/plugins/barchart/package.json
deleted file mode 100644
index cfd3a63f..00000000
--- a/plugins/barchart/package.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
-  "name": "webcmd-plugin-barchart",
-  "version": "0.1.0",
-  "type": "module",
-  "description": "Webcmd commands for barchart",
-  "peerDependencies": {
-    "@agentrhq/webcmd": ">=0.5.3"
-  }
-}
diff --git a/plugins/barchart/quote.js b/plugins/barchart/quote.js
deleted file mode 100644
index 593c9e47..00000000
--- a/plugins/barchart/quote.js
+++ /dev/null
@@ -1,135 +0,0 @@
-/**
- * Barchart stock quote — price, volume, market cap, P/E, EPS, and key metrics.
- * Auth: CSRF token from  + session cookies.
- */
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { CommandExecutionError } from '@agentrhq/webcmd/errors';
-cli({
-    site: 'barchart',
-    name: 'quote',
-    access: 'read',
-    description: 'Barchart stock quote with price, volume, and key metrics',
-    domain: 'www.barchart.com',
-    strategy: Strategy.COOKIE,
-    args: [
-        { name: 'symbol', required: true, positional: true, help: 'Stock ticker (e.g. AAPL, MSFT, TSLA)' },
-    ],
-    columns: [
-        'symbol', 'name', 'price', 'change', 'changePct',
-        'open', 'high', 'low', 'prevClose', 'volume',
-        'avgVolume', 'marketCap', 'peRatio', 'eps',
-    ],
-    func: async (page, kwargs) => {
-        const symbol = kwargs.symbol.toUpperCase().trim();
-        await page.goto(`https://www.barchart.com/stocks/quotes/${encodeURIComponent(symbol)}/overview`);
-        await page.wait(4);
-        const data = await page.evaluate(`
-      (async () => {
-        const sym = ${JSON.stringify(symbol)};
-        const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
-
-        // Strategy 1: internal proxy API with CSRF token
-        try {
-          const fields = [
-            'symbol','symbolName','lastPrice','priceChange','percentChange',
-            'highPrice','lowPrice','openPrice','previousPrice','volume','averageVolume',
-            'marketCap','peRatio','earningsPerShare','tradeTime',
-          ].join(',');
-          const url = '/proxies/core-api/v1/quotes/get?symbol=' + encodeURIComponent(sym) + '&fields=' + fields;
-          const resp = await fetch(url, {
-            credentials: 'include',
-            headers: { 'X-CSRF-TOKEN': csrf },
-          });
-          if (resp.ok) {
-            const d = await resp.json();
-            const row = d?.data?.[0] || null;
-            if (row) {
-              return { source: 'api', row };
-            }
-          }
-        } catch(e) {}
-
-        // Strategy 2: parse from DOM
-        try {
-          const priceEl = document.querySelector('span.last-change');
-          const price = priceEl ? priceEl.textContent.trim() : null;
-
-          // Change values are sibling spans inside .pricechangerow > .last-change
-          const changeParent = priceEl?.parentElement;
-          const changeSpans = changeParent ? changeParent.querySelectorAll('span') : [];
-          let change = null;
-          let changePct = null;
-          for (const s of changeSpans) {
-            const t = s.textContent.trim();
-            if (s === priceEl) continue;
-            if (t.includes('%')) changePct = t.replace(/[()]/g, '');
-            else if (t.match(/^[+-]?[\\d.]+$/)) change = t;
-          }
-
-          // Financial data rows
-          const rows = document.querySelectorAll('.financial-data-row');
-          const fdata = {};
-          for (const row of rows) {
-            const spans = row.querySelectorAll('span');
-            if (spans.length >= 2) {
-              const label = spans[0].textContent.trim();
-              const valSpan = row.querySelector('span.right span:not(.ng-hide)');
-              fdata[label] = valSpan ? valSpan.textContent.trim() : '';
-            }
-          }
-
-          // Day high/low from row chart
-          const dayLow = document.querySelector('.bc-quote-row-chart .small-6:first-child .inline:not(.ng-hide)');
-          const dayHigh = document.querySelector('.bc-quote-row-chart .text-right .inline:not(.ng-hide)');
-          const openEl = document.querySelector('.mark span');
-          const openText = openEl ? openEl.textContent.trim().replace('Open ', '') : null;
-
-          const name = document.querySelector('h1 span.symbol');
-
-          return {
-            source: 'dom',
-            row: {
-              symbol: sym,
-              symbolName: name ? name.textContent.trim() : sym,
-              lastPrice: price,
-              priceChange: change,
-              percentChange: changePct,
-              open: openText,
-              highPrice: dayHigh ? dayHigh.textContent.trim() : null,
-              lowPrice: dayLow ? dayLow.textContent.trim() : null,
-              previousClose: fdata['Previous Close'] || null,
-              volume: fdata['Volume'] || null,
-              averageVolume: fdata['Average Volume'] || null,
-              marketCap: null,
-              peRatio: null,
-              earningsPerShare: null,
-            }
-          };
-        } catch(e) {
-          return { error: 'Could not fetch quote for ' + sym + ': ' + e.message };
-        }
-      })()
-    `);
-        if (!data || data.error)
-            throw new CommandExecutionError(data?.error || `Failed to fetch quote for ${symbol}`);
-        const r = data.row || {};
-        // API returns formatted strings like "+1.41" and "+0.56%"; use raw if available
-        const raw = r.raw || {};
-        return [{
-                symbol: r.symbol || symbol,
-                name: r.symbolName || r.name || symbol,
-                price: r.lastPrice ?? null,
-                change: r.priceChange ?? null,
-                changePct: r.percentChange ?? null,
-                open: r.openPrice ?? r.open ?? null,
-                high: r.highPrice ?? null,
-                low: r.lowPrice ?? null,
-                prevClose: r.previousPrice ?? r.previousClose ?? null,
-                volume: r.volume ?? null,
-                avgVolume: r.averageVolume ?? null,
-                marketCap: r.marketCap ?? null,
-                peRatio: r.peRatio ?? null,
-                eps: r.earningsPerShare ?? null,
-            }];
-    },
-});
diff --git a/plugins/barchart/test/greeks.test.js b/plugins/barchart/test/greeks.test.js
deleted file mode 100644
index 69010868..00000000
--- a/plugins/barchart/test/greeks.test.js
+++ /dev/null
@@ -1,138 +0,0 @@
-import { describe, expect, it, vi } from 'vitest';
-import { getRegistry } from '@agentrhq/webcmd/registry';
-import { ArgumentError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors';
-import '../greeks.js';
-
-const { normalizeExpiration, normalizeSymbol, parseLimit, unwrapBrowserResult } = await import('../greeks.js').then((m) => m.__test__);
-
-function makePage(evaluateResult) {
-    return {
-        goto: vi.fn().mockResolvedValue(undefined),
-        wait: vi.fn().mockResolvedValue(undefined),
-        evaluate: vi.fn().mockResolvedValue(evaluateResult),
-    };
-}
-
-describe('barchart greeks command', () => {
-    const command = getRegistry().get('barchart/greeks');
-
-    it('registers with the expected shape', () => {
-        expect(command).toBeDefined();
-        expect(command.access).toBe('read');
-        expect(command.browser).toBe(true);
-        expect(command.columns).toEqual([
-            'type', 'strike', 'last', 'iv', 'delta', 'gamma', 'theta', 'vega', 'rho',
-            'volume', 'openInterest', 'expiration',
-        ]);
-    });
-
-    it('maps returned option rows without changing the declared output shape', async () => {
-        const page = makePage({
-            session: 'site:barchart',
-            data: {
-            ok: true,
-            rows: [
-                {
-                    type: 'Call',
-                    strike: 190,
-                    last: 3.456,
-                    iv: 21.234,
-                    delta: 0.56789,
-                    gamma: 0.01234,
-                    theta: -0.12345,
-                    vega: 0.23456,
-                    rho: 0.03456,
-                    volume: 123,
-                    openInterest: 456,
-                    expiration: '2026-06-19',
-                },
-            ],
-            },
-        });
-
-        const rows = await command.func(page, { symbol: 'aapl', limit: 1 });
-
-        expect(page.goto).toHaveBeenCalledWith('https://www.barchart.com/stocks/quotes/AAPL/options');
-        expect(page.wait).toHaveBeenCalledWith(4);
-        expect(rows).toEqual([
-            {
-                type: 'Call',
-                strike: 190,
-                last: 3.46,
-                iv: '21.23%',
-                delta: 0.5679,
-                gamma: 0.0123,
-                theta: -0.1235,
-                vega: 0.2346,
-                rho: 0.0346,
-                volume: 123,
-                openInterest: 456,
-                expiration: '2026-06-19',
-            },
-        ]);
-    });
-
-    it('validates args before browser navigation and unwraps bridge envelopes', async () => {
-        expect(normalizeSymbol(' aapl ')).toBe('AAPL');
-        expect(normalizeExpiration('2026-06-19')).toBe('2026-06-19');
-        expect(parseLimit(undefined)).toBe(10);
-        expect(parseLimit(100)).toBe(100);
-        expect(unwrapBrowserResult({ session: 'site:barchart', data: { ok: true } })).toEqual({ ok: true });
-
-        await expect(command.func(makePage({ ok: true, rows: [] }), { symbol: '', limit: 1 }))
-            .rejects.toBeInstanceOf(ArgumentError);
-        await expect(command.func(makePage({ ok: true, rows: [] }), { symbol: 'AAPL', expiration: '2026-02-30', limit: 1 }))
-            .rejects.toBeInstanceOf(ArgumentError);
-        await expect(command.func(makePage({ ok: true, rows: [] }), { symbol: 'AAPL', limit: 101 }))
-            .rejects.toBeInstanceOf(ArgumentError);
-    });
-
-    it('embeds expiration and limit in the browser-side request script', async () => {
-        const page = makePage({
-            ok: true,
-            rows: [{
-                type: 'Put',
-                strike: 185,
-                last: null,
-                iv: null,
-                delta: null,
-                gamma: null,
-                theta: null,
-                vega: null,
-                rho: null,
-                volume: 0,
-                openInterest: 0,
-                expiration: '2026-07-17',
-            }],
-        });
-
-        await command.func(page, { symbol: 'MSFT', expiration: '2026-07-17', limit: 7 });
-        const script = page.evaluate.mock.calls[0][0];
-
-        expect(script).toContain('const expDate = "2026-07-17"');
-        expect(script).toContain('const limit = 7');
-        expect(script).toContain("url += '&expirationDate=' + encodeURIComponent(expDate)");
-    });
-
-    it('throws CommandExecutionError for HTTP, malformed, exception, and missing payload states', async () => {
-        await expect(command.func(makePage({ ok: false, reason: 'http', status: 403, statusText: 'Forbidden' }), { symbol: 'AAPL' }))
-            .rejects.toBeInstanceOf(CommandExecutionError);
-        await expect(command.func(makePage({ ok: false, reason: 'malformed' }), { symbol: 'AAPL' }))
-            .rejects.toBeInstanceOf(CommandExecutionError);
-        await expect(command.func(makePage({ ok: false, reason: 'exception', message: 'network down' }), { symbol: 'AAPL' }))
-            .rejects.toBeInstanceOf(CommandExecutionError);
-        await expect(command.func(makePage({ ok: false, reason: 'malformed', message: 'options rows did not include call or put identities' }), { symbol: 'AAPL' }))
-            .rejects.toThrow('call or put identities');
-        await expect(command.func(makePage(null), { symbol: 'AAPL' }))
-            .rejects.toBeInstanceOf(CommandExecutionError);
-        await expect(command.func(makePage({ ok: true, rows: 'bad' }), { symbol: 'AAPL' }))
-            .rejects.toBeInstanceOf(CommandExecutionError);
-        await expect(command.func(makePage({ ok: true, rows: [{ type: 'Call', strike: null, expiration: '' }] }), { symbol: 'AAPL' }))
-            .rejects.toThrow('malformed option row identity');
-    });
-
-    it('throws EmptyResultError when Barchart returns no greeks rows', async () => {
-        await expect(command.func(makePage({ ok: true, rows: [] }), { symbol: 'AAPL' }))
-            .rejects.toBeInstanceOf(EmptyResultError);
-    });
-});
diff --git a/plugins/barchart/webcmd-plugin.json b/plugins/barchart/webcmd-plugin.json
deleted file mode 100644
index 18cc916f..00000000
--- a/plugins/barchart/webcmd-plugin.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
-  "name": "barchart",
-  "version": "0.1.0",
-  "description": "Webcmd commands for barchart",
-  "webcmd": ">=0.5.3",
-  "author": {
-    "name": "WebCMD Agent",
-    "handle": "agentrhq"
-  }
-}
diff --git a/plugins/bbc/README.md b/plugins/bbc/README.md
deleted file mode 100644
index fbf47abd..00000000
--- a/plugins/bbc/README.md
+++ /dev/null
@@ -1,16 +0,0 @@
-# webcmd-plugin-bbc
-
-Webcmd commands for bbc.
-
-## Install
-
-```bash
-webcmd plugin install github:agentrhq/webcmd/bbc
-```
-
-## Commands
-
-| Command | Description |
-| --- | --- |
-| `webcmd bbc news` | BBC News headlines (RSS) |
-| `webcmd bbc topic` | BBC News headlines for a specific section (RSS feed) |
diff --git a/plugins/bbc/news.js b/plugins/bbc/news.js
deleted file mode 100644
index 5a680530..00000000
--- a/plugins/bbc/news.js
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * BBC News headlines — public RSS feed, no browser needed.
- */
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-cli({
-    site: 'bbc',
-    name: 'news',
-    access: 'read',
-    description: 'BBC News headlines (RSS)',
-    domain: 'www.bbc.com',
-    strategy: Strategy.PUBLIC,
-    args: [
-        { name: 'limit', type: 'int', default: 20, help: 'Number of headlines (max 50)' },
-    ],
-    columns: ['rank', 'title', 'description', 'url'],
-    func: async (kwargs) => {
-        const count = Math.min(kwargs.limit || 20, 50);
-        const resp = await fetch('https://feeds.bbci.co.uk/news/rss.xml');
-        if (!resp.ok)
-            return [];
-        const xml = await resp.text();
-        // Simple XML parsing without DOMParser (works in Node)
-        const items = [];
-        const itemRegex = /([\s\S]*?)<\/item>/g;
-        let match;
-        while ((match = itemRegex.exec(xml)) && items.length < count) {
-            const block = match[1];
-            const title = block.match(/<!\[CDATA\[(.*?)\]\]>|<title>(.*?)<\/title>/)?.[1] || block.match(/<title>(.*?)<\/title>/)?.[1] || '';
-            const desc = block.match(/<description><!\[CDATA\[(.*?)\]\]>|<description>(.*?)<\/description>/)?.[1] || block.match(/<description>(.*?)<\/description>/)?.[1] || '';
-            const link = block.match(/<link>(.*?)<\/link>/)?.[1] || block.match(/<guid[^>]*>(.*?)<\/guid>/)?.[1] || '';
-            if (title) {
-                items.push({
-                    rank: items.length + 1,
-                    title: title.trim(),
-                    description: desc.trim().substring(0, 200),
-                    url: link.trim(),
-                });
-            }
-        }
-        return items;
-    },
-});
diff --git a/plugins/bbc/package.json b/plugins/bbc/package.json
deleted file mode 100644
index 9c75185d..00000000
--- a/plugins/bbc/package.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
-  "name": "webcmd-plugin-bbc",
-  "version": "0.1.0",
-  "type": "module",
-  "description": "Webcmd commands for bbc",
-  "peerDependencies": {
-    "@agentrhq/webcmd": ">=0.5.3"
-  }
-}
diff --git a/plugins/bbc/topic.js b/plugins/bbc/topic.js
deleted file mode 100644
index b87c8f2e..00000000
--- a/plugins/bbc/topic.js
+++ /dev/null
@@ -1,57 +0,0 @@
-// bbc topic — BBC News headlines for a specific category, via public RSS.
-//
-// BBC publishes per-section RSS feeds at
-// `https://feeds.bbci.co.uk/news/<topic>/rss.xml`. We expose the eight
-// canonical sections and reject anything else with a typed argument error
-// so the user knows the supported set.
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { ArgumentError, EmptyResultError } from '@agentrhq/webcmd/errors';
-import { bbcFetchRss, parseRssItems, pubDateToIso, requireBoundedInt } from './utils.js';
-
-const TOPICS = [
-    'world',
-    'business',
-    'politics',
-    'health',
-    'education',
-    'science_and_environment',
-    'technology',
-    'entertainment_and_arts',
-];
-
-cli({
-    site: 'bbc',
-    name: 'topic',
-    access: 'read',
-    description: 'BBC News headlines for a specific section (RSS feed)',
-    domain: 'www.bbc.com',
-    strategy: Strategy.PUBLIC,
-    browser: false,
-    args: [
-        { name: 'topic', positional: true, required: true, help: `Section name (${TOPICS.join(' / ')})` },
-        { name: 'limit', type: 'int', default: 20, help: 'Max headlines (1-50)' },
-    ],
-    columns: ['rank', 'title', 'description', 'pubDate', 'url'],
-    func: async (args) => {
-        const raw = String(args.topic ?? '').trim().toLowerCase().replace(/[\s-]+/g, '_');
-        if (!TOPICS.includes(raw)) {
-            throw new ArgumentError(
-                `bbc topic "${args.topic}" is not supported`,
-                `Supported topics: ${TOPICS.join(', ')}`,
-            );
-        }
-        const limit = requireBoundedInt(args.limit, 20, 50);
-        const xml = await bbcFetchRss(`${raw}/rss.xml`, `bbc topic ${raw}`);
-        const items = parseRssItems(xml);
-        if (!items.length) {
-            throw new EmptyResultError('bbc topic', `BBC ${raw} feed returned no items.`);
-        }
-        return items.slice(0, limit).map((it, i) => ({
-            rank: i + 1,
-            title: it.title,
-            description: it.description,
-            pubDate: pubDateToIso(it.pubDate),
-            url: it.link,
-        }));
-    },
-});
diff --git a/plugins/bbc/utils.js b/plugins/bbc/utils.js
deleted file mode 100644
index 1a0b3a31..00000000
--- a/plugins/bbc/utils.js
+++ /dev/null
@@ -1,79 +0,0 @@
-// Shared helpers for the bbc adapters that hit BBC's public RSS feeds.
-import { ArgumentError, CommandExecutionError } from '@agentrhq/webcmd/errors';
-
-export const BBC_FEED_BASE = 'https://feeds.bbci.co.uk/news';
-const UA = 'webcmd-bbc-adapter (+https://github.com/agentrhq/webcmd)';
-
-const HTML_ENTITIES = {
-    '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ''': "'", ' ': ' ',
-};
-
-export function decodeHtmlEntities(value) {
-    return String(value ?? '')
-        .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
-        .replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10)))
-        .replace(/&(amp|lt|gt|quot|apos|#39|nbsp);/g, (m) => HTML_ENTITIES[m] || m);
-}
-
-/** Extract `<tag>…</tag>` (CDATA-aware) from a block. */
-export function extractRssTag(block, tag) {
-    const cdata = block.match(new RegExp(`<${tag}[^>]*>\\s*<!\\[CDATA\\[([\\s\\S]*?)\\]\\]>\\s*<\\/${tag}>`));
-    if (cdata) return cdata[1];
-    const plain = block.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`));
-    return plain ? plain[1] : '';
-}
-
-export function parseRssItems(xml) {
-    const out = [];
-    const re = /<item[^>]*>([\s\S]*?)<\/item>/g;
-    let m;
-    while ((m = re.exec(String(xml || ''))) !== null) {
-        const block = m[1];
-        out.push({
-            title: decodeHtmlEntities(extractRssTag(block, 'title')).trim(),
-            description: decodeHtmlEntities(extractRssTag(block, 'description')).trim(),
-            link: decodeHtmlEntities(extractRssTag(block, 'link')).trim(),
-            pubDate: decodeHtmlEntities(extractRssTag(block, 'pubDate')).trim(),
-            guid: decodeHtmlEntities(extractRssTag(block, 'guid')).trim(),
-        });
-    }
-    return out;
-}
-
-export function requireBoundedInt(value, defaultValue, maxValue, label = 'limit') {
-    const raw = value ?? defaultValue;
-    const n = typeof raw === 'number' ? raw : Number(raw);
-    if (!Number.isInteger(n) || n <= 0) {
-        throw new ArgumentError(`bbc ${label} must be a positive integer`);
-    }
-    if (n > maxValue) {
-        throw new ArgumentError(`bbc ${label} must be <= ${maxValue}`);
-    }
-    return n;
-}
-
-export async function bbcFetchRss(path, label) {
-    const url = `${BBC_FEED_BASE}/${path}`;
-    let resp;
-    try {
-        resp = await fetch(url, { headers: { 'user-agent': UA, accept: 'application/rss+xml, application/xml' } });
-    }
-    catch (err) {
-        throw new CommandExecutionError(
-            `${label} request failed: ${err?.message ?? err}`,
-            'Check that feeds.bbci.co.uk is reachable from this network.',
-        );
-    }
-    if (!resp.ok) {
-        throw new CommandExecutionError(`${label} returned HTTP ${resp.status} (${url})`);
-    }
-    return resp.text();
-}
-
-/** Convert RFC-822 pubDate to ISO `YYYY-MM-DD`; empty string on parse failure. */
-export function pubDateToIso(value) {
-    if (!value) return '';
-    const d = new Date(value);
-    if (Number.isNaN(d.getTime())) return '';
-    return d.toISOString().slice(0, 10);
-}
diff --git a/plugins/bbc/webcmd-plugin.json b/plugins/bbc/webcmd-plugin.json
deleted file mode 100644
index 0344cf8e..00000000
--- a/plugins/bbc/webcmd-plugin.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
-  "name": "bbc",
-  "version": "0.1.0",
-  "description": "Webcmd commands for bbc",
-  "webcmd": ">=0.5.3",
-  "author": {
-    "name": "WebCMD Agent",
-    "handle": "agentrhq"
-  }
-}
diff --git a/plugins/bigbasket/README.md b/plugins/bigbasket/README.md
deleted file mode 100644
index edf23ca9..00000000
--- a/plugins/bigbasket/README.md
+++ /dev/null
@@ -1,21 +0,0 @@
-# webcmd-plugin-bigbasket
-
-Webcmd commands for bigbasket.
-
-## Install
-
-```bash
-webcmd plugin install github:agentrhq/webcmd/bigbasket
-```
-
-## Commands
-
-| Command | Description |
-| --- | --- |
-| `webcmd bigbasket add-to-cart` | Add a BigBasket product to cart |
-| `webcmd bigbasket cart` | Read BigBasket cart line items |
-| `webcmd bigbasket category` | Read BigBasket category product cards |
-| `webcmd bigbasket checkout` | Open BigBasket checkout review without placing an order |
-| `webcmd bigbasket location` | Show the selected BigBasket delivery location |
-| `webcmd bigbasket product` | Read BigBasket product details |
-| `webcmd bigbasket search` | Search BigBasket products |
diff --git a/plugins/bigbasket/add-to-cart.js b/plugins/bigbasket/add-to-cart.js
deleted file mode 100644
index 5f11d60e..00000000
--- a/plugins/bigbasket/add-to-cart.js
+++ /dev/null
@@ -1,82 +0,0 @@
-import { CommandExecutionError } from '@agentrhq/webcmd/errors';
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { DOMAIN, parseQuantityArg, resolveProductInput, safeGoto, SITE } from './utils.js';
-
-function addToCartEvaluate(productId, quantity) {
-  return `
-    (async () => {
-      const productId = ${JSON.stringify(productId)};
-      const quantity = ${Number(quantity) || 1};
-      const clean = (value) => value == null ? '' : String(value).replace(/\\s+/g, ' ').trim();
-      const text = clean(document.body?.innerText || '');
-      if (/select\\s+(?:size|weight|pack|option)|choose\\s+(?:size|weight|pack|option)/i.test(text)) {
-        return { ok: false, message: 'OPTION_REQUIRED' };
-      }
-
-      const quantityInput = document.querySelector('input[type="number"], input[aria-label*="quantity" i]');
-      if (quantityInput && quantity > 1) {
-        quantityInput.value = String(quantity);
-        quantityInput.dispatchEvent(new Event('input', { bubbles: true }));
-        quantityInput.dispatchEvent(new Event('change', { bubbles: true }));
-      }
-
-      const buttons = Array.from(document.querySelectorAll('button, [role="button"], a'));
-      const button = buttons.find((node) => {
-        const label = clean(node.textContent || node.getAttribute('aria-label') || node.getAttribute('title'));
-        return /^(add|add to basket|add to cart|basket)$/i.test(label) || /add\\s*(to)?\\s*(basket|cart)/i.test(label);
-      });
-      if (!button) return { ok: false, message: 'ADD_BUTTON_NOT_FOUND' };
-      button.click();
-      await new Promise((resolve) => setTimeout(resolve, 1500));
-
-      const afterText = clean(document.body?.innerText || '');
-      const ok = /added|basket|cart/i.test(afterText) && !/failed|error/i.test(afterText);
-      return {
-        ok,
-        message: ok ? 'SUCCESS' : 'UNCONFIRMED',
-        product_id: productId,
-        url: location.href,
-      };
-    })()
-  `;
-}
-
-cli({
-  site: SITE,
-  name: 'add-to-cart',
-  access: 'write',
-  description: 'Add a BigBasket product to cart',
-  domain: DOMAIN,
-  strategy: Strategy.COOKIE,
-  browser: true,
-  args: [
-    { name: 'product', required: true, positional: true, help: 'Product ID or URL' },
-    { name: 'quantity', type: 'int', default: 1, help: 'Quantity to add (max 20)' },
-  ],
-  columns: ['ok', 'product_id', 'quantity', 'url', 'message'],
-  func: async (page, kwargs) => {
-    const product = resolveProductInput(kwargs.product);
-    const quantity = parseQuantityArg(kwargs.quantity, 1, 20);
-    await safeGoto(page, product.url, 'bigbasket add-to-cart');
-    if (page.wait) await page.wait(2);
-    const result = await page.evaluate(addToCartEvaluate(product.productId, quantity)).catch((error) => {
-      throw new CommandExecutionError(`bigbasket add-to-cart evaluation failed: ${error?.message || error}`);
-    });
-    if (result?.message === 'OPTION_REQUIRED') {
-      throw new CommandExecutionError('This BigBasket product requires option selection and is not supported in v1.');
-    }
-    if (result?.message === 'ADD_BUTTON_NOT_FOUND') {
-      throw new CommandExecutionError('Could not find a BigBasket add-to-cart button.');
-    }
-    if (!result?.ok) {
-      throw new CommandExecutionError('Failed to confirm BigBasket add-to-cart success.');
-    }
-    return [{
-      ok: true,
-      product_id: product.productId,
-      quantity,
-      url: result.url || product.url,
-      message: 'Added to cart',
-    }];
-  },
-});
diff --git a/plugins/bigbasket/cart.js b/plugins/bigbasket/cart.js
deleted file mode 100644
index 32bfa754..00000000
--- a/plugins/bigbasket/cart.js
+++ /dev/null
@@ -1,81 +0,0 @@
-import { AuthRequiredError, EmptyResultError } from '@agentrhq/webcmd/errors';
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { CART_URL, DOMAIN, parseMoney, safeGoto, SITE, toBigBasketUrl } from './utils.js';
-
-export const CART_EVALUATE = `
-  (() => {
-    const clean = (value) => value == null ? '' : String(value).replace(/\\s+/g, ' ').trim();
-    const bodyText = clean(document.body?.innerText || document.body?.textContent || '');
-    if (/Login\\/ Sign up|Login\\/ Sign Up|Enter Phone number|Using OTP/i.test(bodyText)) {
-      return { authRequired: true, rows: [], href: location.href };
-    }
-    if (!/my basket|basket|cart|checkout|subtotal|total/i.test(bodyText) || /My Smart Basket/i.test(bodyText)) {
-      return { rows: [], notCart: true, href: location.href, text: bodyText };
-    }
-    const itemRoots = Array.from(document.querySelectorAll('[data-testid*="cart"], [class*="Cart"], [class*="cart"], [class*="Basket"], [class*="basket"], li, article'))
-      .filter((node) => /₹|rs\\.?|qty|quantity/i.test(clean(node.textContent || '')));
-    const seen = new Set();
-    const rows = [];
-
-    for (const root of itemRoots) {
-      const bucket = root.closest('section, [class*="Recommendation"], [class*="recommend"], [class*="Carousel"], [class*="carousel"]');
-      if (/before you checkout|recommend|you may also like|frequently bought/i.test(clean(bucket?.textContent || ''))) continue;
-      const link = root.querySelector('a[href*="/pd/"]');
-      const href = link?.href || link?.getAttribute('href') || '';
-      if (/beforeyoucheckout/i.test(href)) continue;
-      const productId = href.match(/\\/pd\\/(\\d{4,})/)?.[1] || '';
-      const title = clean(link?.textContent) || clean(root.querySelector('h1,h2,h3,[class*="name"],[class*="Name"]')?.textContent);
-      if (!productId || !title || seen.has(productId)) continue;
-      seen.add(productId);
-      const text = clean(root.textContent || '');
-      const money = text.match(/(?:₹|Rs\\.?)[\\s\\d,.]+/gi) || [];
-      const quantity = Number(text.match(/(?:qty|quantity)\\D*(\\d+)/i)?.[1] || 1);
-      rows.push({
-        product_id: productId,
-        title,
-        quantity,
-        price: money[0] || '',
-        line_total: money[money.length - 1] || money[0] || '',
-        availability: /out of stock|unavailable/i.test(text) ? 'Out of stock' : '',
-        url: href,
-      });
-    }
-    return { rows, href: location.href, text: bodyText };
-  })()
-`;
-
-cli({
-  site: SITE,
-  name: 'cart',
-  access: 'read',
-  description: 'Read BigBasket cart line items',
-  domain: DOMAIN,
-  strategy: Strategy.COOKIE,
-  browser: true,
-  args: [],
-  columns: ['product_id', 'title', 'quantity', 'price', 'line_total', 'availability', 'url'],
-  func: async (page) => {
-    await safeGoto(page, CART_URL, 'bigbasket cart');
-    if (page.wait) await page.wait(2);
-    const result = await page.evaluate(CART_EVALUATE);
-    if (result?.authRequired) {
-      throw new AuthRequiredError('bigbasket.com', 'Log into BigBasket in the Webcmd browser session to read cart items.');
-    }
-    const rows = (result?.rows || []).map((row) => ({
-      product_id: row.product_id || '',
-      title: row.title || '',
-      quantity: row.quantity || 1,
-      price: parseMoney(row.price),
-      line_total: parseMoney(row.line_total),
-      availability: row.availability || '',
-      url: toBigBasketUrl(row.url),
-    })).filter((row) => row.product_id && row.title);
-    if (!rows.length) {
-      const hint = result?.notCart
-        ? 'BigBasket did not expose a cart view in the current session.'
-        : 'BigBasket cart is empty or no visible cart items were found.';
-      throw new EmptyResultError('bigbasket cart', hint);
-    }
-    return rows;
-  },
-});
diff --git a/plugins/bigbasket/category.js b/plugins/bigbasket/category.js
deleted file mode 100644
index 74bd9116..00000000
--- a/plugins/bigbasket/category.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import { EmptyResultError } from '@agentrhq/webcmd/errors';
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { DOMAIN, normalizeProductRow, parseLimitArg, productCardsEvaluate, resolveCategoryUrl, safeGoto, SITE } from './utils.js';
-
-cli({
-  site: SITE,
-  name: 'category',
-  access: 'read',
-  description: 'Read BigBasket category product cards',
-  domain: DOMAIN,
-  strategy: Strategy.COOKIE,
-  browser: true,
-  args: [
-    { name: 'category', required: true, positional: true, help: 'Category URL or slug' },
-    { name: 'limit', type: 'int', default: 20, help: 'Maximum products to return (max 50)' },
-  ],
-  columns: ['rank', 'product_id', 'title', 'brand', 'pack_size', 'price', 'mrp', 'discount', 'availability', 'url'],
-  func: async (page, kwargs) => {
-    const url = resolveCategoryUrl(kwargs.category);
-    const limit = parseLimitArg(kwargs.limit, 20, 50);
-    await safeGoto(page, url, 'bigbasket category');
-    if (page.wait) await page.wait(2);
-    const result = await page.evaluate(productCardsEvaluate(limit));
-    const rows = (result?.rows || []).map(normalizeProductRow).filter((row) => row.product_id && row.title);
-    if (!rows.length) {
-      throw new EmptyResultError('bigbasket category', `No BigBasket products found at ${url}.`);
-    }
-    return rows;
-  },
-});
diff --git a/plugins/bigbasket/checkout.js b/plugins/bigbasket/checkout.js
deleted file mode 100644
index d2373d6c..00000000
--- a/plugins/bigbasket/checkout.js
+++ /dev/null
@@ -1,71 +0,0 @@
-import { AuthRequiredError } from '@agentrhq/webcmd/errors';
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { CART_URL, DOMAIN, parseMoney, safeGoto, SITE } from './utils.js';
-
-export const CHECKOUT_REVIEW_EVALUATE = `
-  (async () => {
-    const clean = (value) => value == null ? '' : String(value).replace(/\\s+/g, ' ').trim();
-    const initialText = clean(document.body?.innerText || '');
-    if (/Login\\/ Sign up|Login\\/ Sign Up|Enter Phone number|Using OTP/i.test(initialText)) {
-      return { ok: false, stage: 'login', url: location.href };
-    }
-    const button = Array.from(document.querySelectorAll('button, [role="button"], a')).find((node) => {
-      const label = clean(node.textContent || node.getAttribute('aria-label') || node.getAttribute('title'));
-      return /checkout|proceed/i.test(label);
-    });
-    if (button) {
-      button.click();
-      await new Promise((resolve) => setTimeout(resolve, 2000));
-    }
-    const text = clean(document.body?.innerText || '');
-    const money = text.match(/(?:₹|Rs\\.?)[\\s\\d,.]+/gi) || [];
-    const addressReady = /address|deliver/i.test(text) && !/add\\s+address|select\\s+address/i.test(text);
-    const deliveryReady = /delivery|slot/i.test(text) && !/select\\s+(?:delivery|slot)/i.test(text);
-    const paymentReady = /payment|upi|card|cash/i.test(text);
-    const stage = /login|sign\\s*in|mobile number/i.test(text) ? 'login' :
-      /address/i.test(text) ? 'address' :
-      /delivery|slot/i.test(text) ? 'delivery' :
-      /payment|upi|card|cash/i.test(text) ? 'payment-review' :
-      'cart';
-    return {
-      ok: Boolean(button || /checkout|payment|address|delivery/i.test(text)),
-      stage,
-      cart_total: money[money.length - 1] || '',
-      address_ready: addressReady,
-      delivery_ready: deliveryReady,
-      payment_ready: paymentReady,
-      next_action: paymentReady ? 'Review payment options manually; command stops before final submission.' : 'Complete the visible checkout requirement manually.',
-      url: location.href,
-    };
-  })()
-`;
-
-cli({
-  site: SITE,
-  name: 'checkout',
-  access: 'write',
-  description: 'Open BigBasket checkout review without placing an order',
-  domain: DOMAIN,
-  strategy: Strategy.COOKIE,
-  browser: true,
-  args: [],
-  columns: ['ok', 'stage', 'cart_total', 'address_ready', 'delivery_ready', 'payment_ready', 'next_action', 'url'],
-  func: async (page) => {
-    await safeGoto(page, CART_URL, 'bigbasket checkout');
-    if (page.wait) await page.wait(2);
-    const result = await page.evaluate(CHECKOUT_REVIEW_EVALUATE);
-    if ((result?.stage === 'login' && result?.ok === false) || result?.authRequired === true) {
-      throw new AuthRequiredError('bigbasket.com', 'Log into BigBasket in the Webcmd browser session to open checkout review.');
-    }
-    return [{
-      ok: Boolean(result?.ok),
-      stage: result?.stage || 'cart',
-      cart_total: parseMoney(result?.cart_total),
-      address_ready: Boolean(result?.address_ready),
-      delivery_ready: Boolean(result?.delivery_ready),
-      payment_ready: Boolean(result?.payment_ready),
-      next_action: result?.next_action || 'Open BigBasket checkout manually.',
-      url: result?.url || CART_URL,
-    }];
-  },
-});
diff --git a/plugins/bigbasket/location.js b/plugins/bigbasket/location.js
deleted file mode 100644
index 9c9340a7..00000000
--- a/plugins/bigbasket/location.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { DOMAIN, HOME_URL, normalizeLocationState, safeGoto, SITE } from './utils.js';
-
-const LOCATION_EVALUATE = `
-  (() => {
-    const read = (key) => localStorage.getItem(key) || sessionStorage.getItem(key) || '';
-    return {
-      selectedAddressInfo: read('selectedAddressInfo'),
-      selected_address_id: read('selected_address_id'),
-      pin: read('pin') || read('pincode'),
-    };
-  })()
-`;
-
-cli({
-  site: SITE,
-  name: 'location',
-  access: 'read',
-  description: 'Show the selected BigBasket delivery location',
-  domain: DOMAIN,
-  strategy: Strategy.COOKIE,
-  browser: true,
-  args: [],
-  columns: ['selected', 'label', 'area', 'city', 'pincode', 'source'],
-  func: async (page) => {
-    await safeGoto(page, HOME_URL, 'bigbasket location');
-    if (page.wait) await page.wait(1);
-    return [normalizeLocationState(await page.evaluate(LOCATION_EVALUATE))];
-  },
-});
diff --git a/plugins/bigbasket/package.json b/plugins/bigbasket/package.json
deleted file mode 100644
index 3ddadfb3..00000000
--- a/plugins/bigbasket/package.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
-  "name": "webcmd-plugin-bigbasket",
-  "version": "0.1.0",
-  "type": "module",
-  "description": "Webcmd commands for bigbasket",
-  "peerDependencies": {
-    "@agentrhq/webcmd": ">=0.5.3"
-  }
-}
diff --git a/plugins/bigbasket/product.js b/plugins/bigbasket/product.js
deleted file mode 100644
index c20844a2..00000000
--- a/plugins/bigbasket/product.js
+++ /dev/null
@@ -1,79 +0,0 @@
-import { CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors';
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { DOMAIN, normalizeProductRow, resolveProductInput, safeGoto, SITE } from './utils.js';
-
-function productDetailEvaluate(productId) {
-  return `
-    (() => {
-      const productId = ${JSON.stringify(productId)};
-      const clean = (value) => value == null ? '' : String(value).replace(/\\s+/g, ' ').trim();
-      const jsonLd = Array.from(document.querySelectorAll('script[type="application/ld+json"]'))
-        .map((node) => {
-          try { return JSON.parse(node.textContent || 'null'); } catch { return null; }
-        })
-        .flatMap((doc) => Array.isArray(doc) ? doc : [doc])
-        .find((doc) => /product/i.test(String(doc?.['@type'] || '')));
-      const offers = Array.isArray(jsonLd?.offers) ? jsonLd.offers[0] : jsonLd?.offers;
-      const text = clean(document.body?.innerText || '');
-      const title = clean(jsonLd?.name) || clean(document.querySelector('h1,h2,[class*="ProductName"],[class*="product-name"]')?.textContent);
-      const image = Array.isArray(jsonLd?.image) ? jsonLd.image[0] : jsonLd?.image;
-      const priceText = clean(offers?.price) || clean(document.querySelector('[class*="price"], [class*="Price"]')?.textContent);
-      const mrpText = clean(document.querySelector('del,[class*="mrp"],[class*="MRP"]')?.textContent);
-      const packSize = text.match(/\\b\\d+(?:\\.\\d+)?\\s*(?:kg|g|gm|ml|l|ltr|pcs?|pack)\\b/i)?.[0] || '';
-      const availability = /out of stock|unavailable/i.test(text) ? 'Out of stock' : clean(offers?.availability || '');
-      const delivery = text.match(/Delivery\\s+in\\s+\\d+\\s*(?:mins?|minutes?|hours?)/i)?.[0] || '';
-      return {
-        product_id: productId,
-        title,
-        brand: clean(jsonLd?.brand?.name || jsonLd?.brand),
-        pack_size: packSize,
-        price: priceText,
-        mrp: mrpText,
-        discount: text.match(/\\d+%\\s*off/i)?.[0] || '',
-        availability,
-        delivery,
-        image_url: clean(image),
-        url: location.href,
-      };
-    })()
-  `;
-}
-
-cli({
-  site: SITE,
-  name: 'product',
-  access: 'read',
-  description: 'Read BigBasket product details',
-  domain: DOMAIN,
-  strategy: Strategy.COOKIE,
-  browser: true,
-  args: [
-    { name: 'product', required: true, positional: true, help: 'Product ID or URL' },
-  ],
-  columns: ['product_id', 'title', 'brand', 'pack_size', 'price', 'mrp', 'discount', 'availability', 'delivery', 'image_url', 'url'],
-  func: async (page, kwargs) => {
-    const product = resolveProductInput(kwargs.product);
-    await safeGoto(page, product.url, 'bigbasket product');
-    if (page.wait) await page.wait(2);
-    const raw = await page.evaluate(productDetailEvaluate(product.productId)).catch((error) => {
-      throw new CommandExecutionError(`bigbasket product extraction failed: ${error?.message || error}`);
-    });
-    const row = normalizeProductRow(raw, 0);
-    if (!row.product_id || !row.title) {
-      throw new EmptyResultError('bigbasket product', `No product details found for ${product.productId}.`);
-    }
-    return [{
-      product_id: row.product_id,
-      title: row.title,
-      brand: row.brand,
-      pack_size: row.pack_size,
-      price: row.price,
-      mrp: row.mrp,
-      discount: row.discount,
-      availability: row.availability,
-      delivery: raw.delivery || '',
-      image_url: raw.image_url || '',
-      url: row.url,
-    }];
-  },
-});
diff --git a/plugins/bigbasket/search.js b/plugins/bigbasket/search.js
deleted file mode 100644
index 6bd99e2b..00000000
--- a/plugins/bigbasket/search.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import { EmptyResultError } from '@agentrhq/webcmd/errors';
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { buildSearchUrl, DOMAIN, normalizeProductRow, parseLimitArg, productCardsEvaluate, safeGoto, SITE } from './utils.js';
-
-cli({
-  site: SITE,
-  name: 'search',
-  tags: ['search'],
-  access: 'read',
-  description: 'Search BigBasket products',
-  domain: DOMAIN,
-  strategy: Strategy.COOKIE,
-  browser: true,
-  args: [
-    { name: 'query', required: true, positional: true, help: 'Search query' },
-    { name: 'limit', type: 'int', default: 20, help: 'Maximum products to return (max 50)' },
-  ],
-  columns: ['rank', 'product_id', 'title', 'brand', 'pack_size', 'price', 'mrp', 'discount', 'availability', 'url'],
-  func: async (page, kwargs) => {
-    const url = buildSearchUrl(kwargs.query);
-    const limit = parseLimitArg(kwargs.limit, 20, 50);
-    await safeGoto(page, url, 'bigbasket search');
-    if (page.wait) await page.wait(2);
-    const result = await page.evaluate(productCardsEvaluate(limit));
-    const rows = (result?.rows || []).map(normalizeProductRow).filter((row) => row.product_id && row.title);
-    if (!rows.length) {
-      throw new EmptyResultError('bigbasket search', `No BigBasket products matched "${kwargs.query}".`);
-    }
-    return rows;
-  },
-});
diff --git a/plugins/bigbasket/test/bigbasket.test.js b/plugins/bigbasket/test/bigbasket.test.js
deleted file mode 100644
index a7a1d874..00000000
--- a/plugins/bigbasket/test/bigbasket.test.js
+++ /dev/null
@@ -1,255 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { JSDOM } from 'jsdom';
-import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@agentrhq/webcmd/errors';
-import { getRegistry } from '@agentrhq/webcmd/registry';
-import '../search.js';
-import '../category.js';
-import '../product.js';
-import '../add-to-cart.js';
-import '../cart.js';
-import '../checkout.js';
-import '../location.js';
-import { CART_EVALUATE } from '../cart.js';
-import { CHECKOUT_REVIEW_EVALUATE } from '../checkout.js';
-import {
-  buildSearchUrl,
-  normalizeLocationState,
-  normalizeProductRow,
-  parseLimitArg,
-  parseQuantityArg,
-  productCardsEvaluate,
-  resolveCategoryUrl,
-  resolveProductInput,
-} from '../utils.js';
-
-describe('bigbasket helpers', () => {
-  it('builds search and category URLs', () => {
-    expect(buildSearchUrl('amul milk')).toContain('/ps/?q=amul%20milk');
-    expect(resolveCategoryUrl('/pc/fruits-vegetables/vegetables/')).toBe('https://www.bigbasket.com/pc/fruits-vegetables/vegetables/');
-    expect(resolveCategoryUrl('fruits-vegetables/vegetables')).toBe('https://www.bigbasket.com/pc/fruits-vegetables/vegetables/');
-  });
-
-  it('parses product ids and URLs', () => {
-    expect(resolveProductInput('https://www.bigbasket.com/pd/40022638/fresho-banana-robusta-1-kg/')).toMatchObject({
-      productId: '40022638',
-      url: 'https://www.bigbasket.com/pd/40022638/fresho-banana-robusta-1-kg/',
-    });
-    expect(resolveProductInput('40022638')).toEqual({
-      productId: '40022638',
-      url: 'https://www.bigbasket.com/pd/40022638/',
-    });
-  });
-
-  it('fails fast on bad numbers and malformed inputs', () => {
-    expect(() => buildSearchUrl('   ')).toThrow(ArgumentError);
-    expect(() => resolveProductInput('banana')).toThrow(ArgumentError);
-    expect(() => parseLimitArg(0, 20, 50)).toThrow(ArgumentError);
-    expect(() => parseLimitArg(51, 20, 50)).toThrow(ArgumentError);
-    expect(() => parseQuantityArg(0, 1, 20)).toThrow(ArgumentError);
-  });
-
-  it('normalizes product rows', () => {
-    expect(normalizeProductRow({
-      product_id: '40022638',
-      title: 'Fresho Banana',
-      brand: 'Fresho',
-      pack_size: '1 kg',
-      price: '₹54',
-      mrp: '₹70',
-      discount: '23% OFF',
-      availability: 'In stock',
-      url: '/pd/40022638/fresho-banana/',
-    }, 0)).toEqual({
-      rank: 1,
-      product_id: '40022638',
-      title: 'Fresho Banana',
-      brand: 'Fresho',
-      pack_size: '1 kg',
-      price: 54,
-      mrp: 70,
-      discount: '23% OFF',
-      availability: 'In stock',
-      url: 'https://www.bigbasket.com/pd/40022638/fresho-banana/',
-    });
-  });
-
-  it('normalizes schema availability URLs', () => {
-    expect(normalizeProductRow({
-      product_id: '40090893',
-      title: 'Amul Gold Full Cream Milk',
-      availability: 'https://schema.org/InStock',
-      url: '/pd/40090893/amul-amul-gold/',
-    }, 0).availability).toBe('In stock');
-  });
-
-  it('normalizes selected location without leaking full address fields', () => {
-    expect(normalizeLocationState({
-      selectedAddressInfo: JSON.stringify({
-        nick: 'Home',
-        area: 'NTPC Township',
-        city_name: 'Noida',
-        pin: 201307,
-        address1: 'Block C-03 / 79',
-        address2: 'Samridhi',
-        landmark: 'Near something',
-        lat: 28.586165986798466,
-        lng: 77.35627826303244,
-        member: { full_name: 'Private Name' },
-      }),
-      selected_address_id: '218320015',
-    })).toEqual({
-      selected: true,
-      label: 'Home',
-      area: 'NTPC Township',
-      city: 'Noida',
-      pincode: '201307',
-      source: 'selectedAddressInfo',
-    });
-  });
-
-  it('extracts product title when image anchor appears before title anchor', () => {
-    const dom = new JSDOM(`
-      <ul>
-        <li>
-          <div><a href="/pd/40090893/amul-amul-gold-500-ml-pouch/"><img alt="milk"></a></div>
-          <h3>
-            <a href="/pd/40090893/amul-amul-gold-500-ml-pouch/">
-              <span>Amul</span>
-              <div><h3>Gold Full Cream Milk</h3></div>
-            </a>
-          </h3>
-          <span>500 ml - Pouch</span>
-          <span>₹34.00</span>
-          <button>Add</button>
-        </li>
-      </ul>
-    `, { runScripts: 'outside-only', url: 'https://www.bigbasket.com/ps/?q=milk' });
-
-    const result = dom.window.eval(productCardsEvaluate(3));
-
-    expect(result.rows[0]).toMatchObject({
-      product_id: '40090893',
-      brand: 'Amul',
-      title: 'Gold Full Cream Milk',
-      pack_size: '500 ml - Pouch',
-      price: '₹34.00',
-    });
-  });
-
-  it('uses listing URL title param when visible anchor text is only the brand', () => {
-    const dom = new JSDOM(`
-      <ul>
-        <li>
-          <a href="/pd/40090894/amul-taaza-500-ml-pouch/?t_s=Taaza+Milk"><span>Amul</span></a>
-          <span>500 ml</span>
-          <span>₹26.00</span>
-          <button>Add</button>
-        </li>
-      </ul>
-    `, { runScripts: 'outside-only', url: 'https://www.bigbasket.com/ps/?q=milk' });
-
-    const result = dom.window.eval(productCardsEvaluate(3));
-
-    expect(result.rows[0]).toMatchObject({
-      product_id: '40090894',
-      brand: 'Amul',
-      title: 'Taaza Milk',
-    });
-  });
-});
-
-describe('bigbasket registry shape', () => {
-  it('registers approved commands with expected access classes', () => {
-    expect(getRegistry().get('bigbasket/search').access).toBe('read');
-    expect(getRegistry().get('bigbasket/product').access).toBe('read');
-    expect(getRegistry().get('bigbasket/category').access).toBe('read');
-    expect(getRegistry().get('bigbasket/add-to-cart').access).toBe('write');
-    expect(getRegistry().get('bigbasket/cart').access).toBe('read');
-    expect(getRegistry().get('bigbasket/checkout').access).toBe('write');
-    expect(getRegistry().get('bigbasket/location').access).toBe('read');
-  });
-
-  it('keeps checkout in review mode', () => {
-    const checkout = getRegistry().get('bigbasket/checkout');
-    expect(checkout.columns).toEqual([
-      'ok', 'stage', 'cart_total', 'address_ready', 'delivery_ready', 'payment_ready', 'next_action', 'url',
-    ]);
-  });
-});
-
-describe('bigbasket read commands', () => {
-  it('rejects invalid read arguments before navigation', async () => {
-    const fakePage = { goto: () => { throw new Error('should not navigate'); } };
-
-    await expect(getRegistry().get('bigbasket/search').func(fakePage, { query: '   ' })).rejects.toThrow(ArgumentError);
-    await expect(getRegistry().get('bigbasket/category').func(fakePage, { category: 'bad' })).rejects.toThrow(ArgumentError);
-    await expect(getRegistry().get('bigbasket/product').func(fakePage, { product: 'banana' })).rejects.toThrow(ArgumentError);
-  });
-
-  it('wraps read navigation failures as CommandExecutionError', async () => {
-    const fakePage = { goto: () => Promise.reject(new Error('browser down')) };
-
-    await expect(getRegistry().get('bigbasket/search').func(fakePage, { query: 'milk' })).rejects.toThrow(CommandExecutionError);
-    await expect(getRegistry().get('bigbasket/category').func(fakePage, { category: 'fruits-vegetables/vegetables' })).rejects.toThrow(CommandExecutionError);
-    await expect(getRegistry().get('bigbasket/product').func(fakePage, { product: '40022638' })).rejects.toThrow(CommandExecutionError);
-  });
-});
-
-describe('bigbasket cart and checkout commands', () => {
-  it('rejects invalid add-to-cart arguments before navigation', async () => {
-    const fakePage = { goto: () => { throw new Error('should not navigate'); } };
-
-    await expect(getRegistry().get('bigbasket/add-to-cart').func(fakePage, {})).rejects.toThrow(ArgumentError);
-    await expect(getRegistry().get('bigbasket/add-to-cart').func(fakePage, { product: 'banana' })).rejects.toThrow(ArgumentError);
-    await expect(getRegistry().get('bigbasket/add-to-cart').func(fakePage, { product: '40022638', quantity: 0 })).rejects.toThrow(ArgumentError);
-  });
-
-  it('wraps cart and add-to-cart navigation failures as CommandExecutionError', async () => {
-    const fakePage = { goto: () => Promise.reject(new Error('browser down')) };
-
-    await expect(getRegistry().get('bigbasket/add-to-cart').func(fakePage, { product: '40022638' })).rejects.toThrow(CommandExecutionError);
-    await expect(getRegistry().get('bigbasket/cart').func(fakePage, {})).rejects.toThrow(CommandExecutionError);
-    await expect(getRegistry().get('bigbasket/checkout').func(fakePage, {})).rejects.toThrow(CommandExecutionError);
-  });
-
-  it('reports auth-required state for cart and checkout instead of recommendation rows', async () => {
-    const fakePage = {
-      goto: () => Promise.resolve(),
-      wait: () => Promise.resolve(),
-      evaluate: () => Promise.resolve({ authRequired: true, rows: [] }),
-    };
-
-    await expect(getRegistry().get('bigbasket/cart').func(fakePage, {})).rejects.toThrow(AuthRequiredError);
-    await expect(getRegistry().get('bigbasket/checkout').func(fakePage, {})).rejects.toThrow(AuthRequiredError);
-  });
-
-  it('keeps before-checkout recommendations out of cart rows', () => {
-    const dom = new JSDOM(`
-      <main>
-        <section>
-          <h1>My Basket</h1>
-          <article>
-            <a href="/pd/40090893/amul-amul-gold/">Amul Gold Full Cream Milk</a>
-            <span>Qty 1</span>
-            <span>₹36</span>
-          </article>
-        </section>
-        <section>
-          <article>
-            <a href="/pd/40321402/rubiks-cube/?nc=beforeyoucheckout">Rubik's Cube</a>
-            <span>₹449</span>
-          </article>
-        </section>
-      </main>
-    `, { runScripts: 'outside-only', url: 'https://www.bigbasket.com/basket/' });
-
-    const result = dom.window.eval(CART_EVALUATE);
-
-    expect(result.rows).toHaveLength(1);
-    expect(result.rows[0].product_id).toBe('40090893');
-  });
-
-  it('keeps checkout extractor free of final payment/order submission clicks', () => {
-    expect(CHECKOUT_REVIEW_EVALUATE).not.toMatch(/place\\s*order|submit\\s*payment|pay\\s*now|make\\s*payment/i);
-  });
-});
diff --git a/plugins/bigbasket/utils.js b/plugins/bigbasket/utils.js
deleted file mode 100644
index b23ee5bc..00000000
--- a/plugins/bigbasket/utils.js
+++ /dev/null
@@ -1,207 +0,0 @@
-import { ArgumentError, CommandExecutionError } from '@agentrhq/webcmd/errors';
-
-export const SITE = 'bigbasket';
-export const DOMAIN = 'www.bigbasket.com';
-export const HOME_URL = 'https://www.bigbasket.com/';
-export const CART_URL = 'https://www.bigbasket.com/basket/';
-export const CHECKOUT_URL = 'https://www.bigbasket.com/checkout/';
-
-export function cleanText(value) {
-  return typeof value === 'string'
-    ? value.replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim()
-    : '';
-}
-
-export function parseMoney(value) {
-  const text = cleanText(String(value ?? ''));
-  if (!text) return null;
-  const match = text.replace(/,/g, '').match(/(\d+(?:\.\d+)?)/);
-  if (!match) return null;
-  const num = Number(match[1]);
-  return Number.isFinite(num) ? num : null;
-}
-
-function normalizeAvailability(value) {
-  const text = cleanText(value);
-  if (/instock$/i.test(text) || /\bin stock\b/i.test(text)) return 'In stock';
-  if (/outofstock$/i.test(text) || /out of stock|unavailable/i.test(text)) return 'Out of stock';
-  return text;
-}
-
-export function parseLimitArg(raw, fallback, max) {
-  if (raw === undefined || raw === null || raw === '') return fallback;
-  const num = Number(raw);
-  if (!Number.isInteger(num) || num < 1 || num > max) {
-    throw new ArgumentError(`--limit must be an integer between 1 and ${max} (got ${raw})`);
-  }
-  return num;
-}
-
-export function parseQuantityArg(raw, fallback, max) {
-  if (raw === undefined || raw === null || raw === '') return fallback;
-  const num = Number(raw);
-  if (!Number.isInteger(num) || num < 1 || num > max) {
-    throw new ArgumentError(`--quantity must be an integer between 1 and ${max} (got ${raw})`);
-  }
-  return num;
-}
-
-export function buildSearchUrl(query) {
-  const normalized = cleanText(query);
-  if (!normalized) throw new ArgumentError('bigbasket search query cannot be empty');
-  return `${HOME_URL}ps/?q=${encodeURIComponent(normalized)}`;
-}
-
-export function toBigBasketUrl(value) {
-  const text = cleanText(value);
-  if (!text) return '';
-  try {
-    const url = new URL(text.startsWith('http') ? text : text.startsWith('/') ? text : `/${text}`, HOME_URL);
-    if (url.hostname !== DOMAIN) {
-      throw new Error('not bigbasket');
-    }
-    url.hash = '';
-    return url.toString();
-  } catch {
-    return '';
-  }
-}
-
-export function resolveCategoryUrl(input) {
-  const text = cleanText(input);
-  if (!text) throw new ArgumentError('bigbasket category requires a category URL or slug');
-  if (!text.startsWith('http') && !text.includes('/')) {
-    throw new ArgumentError('bigbasket category expects a category path such as fruits-vegetables/vegetables');
-  }
-  const prefixed = text.startsWith('http') || text.startsWith('/pc/') ? text : `/pc/${text.replace(/^\/+/, '')}/`;
-  const url = toBigBasketUrl(prefixed);
-  if (!url || !new URL(url).pathname.startsWith('/pc/')) {
-    throw new ArgumentError('bigbasket category expects a BigBasket /pc/<category>/ URL or slug');
-  }
-  return url;
-}
-
-export function resolveProductInput(input) {
-  const text = cleanText(input);
-  if (!text) throw new ArgumentError('bigbasket product requires a product ID or URL');
-  if (/^\d{4,}$/.test(text)) {
-    return { productId: text, url: `${HOME_URL}pd/${text}/` };
-  }
-  const url = toBigBasketUrl(text);
-  if (!url) throw new ArgumentError('bigbasket product expects a BigBasket product ID or /pd/<id>/ URL');
-  const match = new URL(url).pathname.match(/^\/pd\/(\d{4,})(?:\/|$)/);
-  if (!match) throw new ArgumentError('bigbasket product expects a BigBasket product ID or /pd/<id>/ URL');
-  return { productId: match[1], url };
-}
-
-export function normalizeProductRow(raw, rank) {
-  const url = toBigBasketUrl(raw.url || raw.href || '');
-  const productId = cleanText(raw.product_id || raw.productId || raw.id || url.match(/\/pd\/(\d{4,})/)?.[1] || '');
-  return {
-    rank: rank + 1,
-    product_id: productId,
-    title: cleanText(raw.title || raw.name),
-    brand: cleanText(raw.brand),
-    pack_size: cleanText(raw.pack_size || raw.packSize || raw.size),
-    price: parseMoney(raw.price),
-    mrp: parseMoney(raw.mrp || raw.original_price || raw.originalPrice),
-    discount: cleanText(raw.discount),
-    availability: normalizeAvailability(raw.availability || raw.stock),
-    url,
-  };
-}
-
-function parseJsonObject(raw) {
-  try {
-    const value = typeof raw === 'string' ? JSON.parse(raw) : raw;
-    return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
-  } catch {
-    return {};
-  }
-}
-
-export function normalizeLocationState(raw = {}) {
-  const info = parseJsonObject(raw.selectedAddressInfo || raw.selected_address_info || raw.address);
-  const pincode = cleanText(String(info.pin || info.pincode || raw.pin || raw.pincode || ''));
-  return {
-    selected: Boolean(raw.selected_address_id || raw.selectedAddressId || info.id || pincode),
-    label: cleanText(info.nick || info.nickname || raw.label || raw.nick),
-    area: cleanText(info.area || info.locality || raw.area),
-    city: cleanText(info.city_name || info.city || raw.city),
-    pincode,
-    source: raw.selectedAddressInfo || raw.selected_address_info ? 'selectedAddressInfo' : '',
-  };
-}
-
-export async function safeGoto(page, url, label) {
-  await page.goto(url).catch((error) => {
-    throw new CommandExecutionError(`${label} navigation failed: ${error?.message || error}`);
-  });
-}
-
-export function productCardsEvaluate(limit) {
-  return `
-    (() => {
-      const limit = ${Number(limit) || 20};
-      const clean = (value) => value == null ? '' : String(value).replace(/\\s+/g, ' ').trim();
-      const leafText = (root) => Array.from(root.querySelectorAll('*'))
-        .filter((node) => node.children.length === 0)
-        .map((node) => clean(node.textContent))
-        .filter(Boolean);
-      const anchors = Array.from(document.querySelectorAll('a[href*="/pd/"]'));
-      const byProductId = new Map();
-      const rows = [];
-
-      for (const anchor of anchors) {
-        const href = anchor.href || anchor.getAttribute('href') || '';
-        const productId = href.match(/\\/pd\\/(\\d{4,})/)?.[1] || '';
-        if (!productId) continue;
-
-        const root = anchor.closest('li, article, [data-testid], [class*="Product"], [class*="product"], [class*="SKU"], [class*="sku"]') || anchor;
-        const productAnchors = Array.from(root.querySelectorAll('a[href*="/pd/"]'))
-          .filter((node) => (node.href || node.getAttribute('href') || '').includes('/pd/' + productId + '/'));
-        const titleAnchor = productAnchors.find((node) => clean(node.textContent)) || anchor;
-        const titleParam = (() => {
-          try { return clean(new URL(href, location.origin).searchParams.get('t_s')); } catch { return ''; }
-        })();
-        const texts = leafText(root);
-        const joined = texts.join(' | ');
-        const brand = clean(titleAnchor.querySelector('span')?.textContent);
-        const visibleTitle =
-          clean(titleAnchor.getAttribute('title')) ||
-          clean(titleAnchor.querySelector('h1,h2,h3,[class*="name"],[class*="Name"]')?.textContent) ||
-          clean(titleAnchor.textContent).replace(brand, '').trim() ||
-          texts.find((text) => /[A-Za-z]/.test(text) && !/₹|rs\\.?|add|cart|off/i.test(text)) ||
-          '';
-        const title = !visibleTitle || visibleTitle === brand ? titleParam : visibleTitle;
-        const price = texts.find((text) => /₹|rs\\.?/i.test(text) && !/mrp/i.test(text)) || '';
-        const mrp = texts.find((text) => /mrp/i.test(text)) || '';
-        const discount = texts.find((text) => /%\\s*off|save/i.test(text)) || '';
-        const packSize = texts.find((text) => /\\b\\d+(?:\\.\\d+)?\\s*(?:kg|g|gm|ml|l|ltr|pcs?|pack)\\b/i.test(text)) || '';
-        const availability = /out of stock|unavailable/i.test(joined) ? 'Out of stock' : '';
-
-        const row = {
-          product_id: productId,
-          title,
-          brand,
-          pack_size: packSize,
-          price,
-          mrp,
-          discount,
-          availability,
-          url: href,
-        };
-        const existing = byProductId.get(productId);
-        if (!existing || (!existing.title && row.title)) byProductId.set(productId, row);
-      }
-
-      for (const row of byProductId.values()) {
-        rows.push(row);
-        if (rows.length >= limit) break;
-      }
-
-      const bodyText = clean(document.body?.innerText || '');
-      return { rows, bodyText, href: location.href };
-    })()
-  `;
-}
diff --git a/plugins/bigbasket/webcmd-plugin.json b/plugins/bigbasket/webcmd-plugin.json
deleted file mode 100644
index 9dd2b7a3..00000000
--- a/plugins/bigbasket/webcmd-plugin.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
-  "name": "bigbasket",
-  "version": "0.1.0",
-  "description": "Webcmd commands for bigbasket",
-  "webcmd": ">=0.5.3",
-  "author": {
-    "name": "WebCMD Agent",
-    "handle": "agentrhq"
-  }
-}
diff --git a/plugins/binance/README.md b/plugins/binance/README.md
deleted file mode 100644
index 8f0ca64a..00000000
--- a/plugins/binance/README.md
+++ /dev/null
@@ -1,25 +0,0 @@
-# webcmd-plugin-binance
-
-Webcmd commands for binance.
-
-## Install
-
-```bash
-webcmd plugin install github:agentrhq/webcmd/binance
-```
-
-## Commands
-
-| Command | Description |
-| --- | --- |
-| `webcmd binance asks` | Order book ask prices for a trading pair |
-| `webcmd binance depth` | Order book bid and ask prices for a trading pair |
-| `webcmd binance gainers` | Top gaining trading pairs by 24h price change |
-| `webcmd binance klines` | Candlestick/kline data for a trading pair |
-| `webcmd binance losers` | Top losing trading pairs by 24h price change |
-| `webcmd binance pairs` | List active trading pairs on Binance |
-| `webcmd binance price` | Quick price check for a trading pair |
-| `webcmd binance prices` | Latest prices for all trading pairs |
-| `webcmd binance ticker` | 24h ticker statistics for top trading pairs by volume |
-| `webcmd binance top` | Top trading pairs by 24h volume on Binance |
-| `webcmd binance trades` | Recent trades for a trading pair |
diff --git a/plugins/binance/asks.js b/plugins/binance/asks.js
deleted file mode 100644
index abb5df56..00000000
--- a/plugins/binance/asks.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-
-cli({
-  site: 'binance',
-  name: 'asks',
-    access: 'read',
-  description: 'Order book ask prices for a trading pair',
-  domain: 'data-api.binance.vision',
-  strategy: Strategy.PUBLIC,
-  browser: false,
-  args: [
-    { name: 'symbol', type: 'str', required: true, positional: true, help: 'Trading pair symbol (e.g. BTCUSDT, ETHUSDT)' },
-    { name: 'limit', type: 'int', default: 10, help: 'Number of price levels (5, 10, 20, 50, 100)' },
-  ],
-  columns: ['rank', 'ask_price', 'ask_qty'],
-  pipeline: [
-    { fetch: { url: 'https://data-api.binance.vision/api/v3/depth?symbol=${{ args.symbol }}&limit=${{ args.limit }}' } },
-    { select: 'asks' },
-    { map: { rank: '${{ index + 1 }}', ask_price: '${{ item.0 }}', ask_qty: '${{ item.1 }}' } },
-    { limit: '${{ args.limit }}' },
-  ],
-});
diff --git a/plugins/binance/depth.js b/plugins/binance/depth.js
deleted file mode 100644
index dd88dbc8..00000000
--- a/plugins/binance/depth.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-
-cli({
-  site: 'binance',
-  name: 'depth',
-    access: 'read',
-  description: 'Order book bid and ask prices for a trading pair',
-  domain: 'data-api.binance.vision',
-  strategy: Strategy.PUBLIC,
-  browser: false,
-  args: [
-    { name: 'symbol', type: 'str', required: true, positional: true, help: 'Trading pair symbol (e.g. BTCUSDT, ETHUSDT)' },
-    { name: 'limit', type: 'int', default: 10, help: 'Number of price levels (5, 10, 20, 50, 100)' },
-  ],
-  columns: ['rank', 'bid_price', 'bid_qty', 'ask_price', 'ask_qty'],
-  pipeline: [
-    { fetch: { url: 'https://data-api.binance.vision/api/v3/depth?symbol=${{ args.symbol }}&limit=${{ args.limit }}' } },
-    { map: { select: 'bids', rank: '${{ index + 1 }}', bid_price: '${{ item[0] }}', bid_qty: '${{ item[1] }}', ask_price: '${{ root.asks[index]?.[0] ?? "" }}', ask_qty: '${{ root.asks[index]?.[1] ?? "" }}' } },
-    { limit: '${{ args.limit }}' },
-  ],
-});
diff --git a/plugins/binance/gainers.js b/plugins/binance/gainers.js
deleted file mode 100644
index cada72b3..00000000
--- a/plugins/binance/gainers.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-
-cli({
-  site: 'binance',
-  name: 'gainers',
-    access: 'read',
-  description: 'Top gaining trading pairs by 24h price change',
-  domain: 'data-api.binance.vision',
-  strategy: Strategy.PUBLIC,
-  browser: false,
-  args: [
-    { name: 'limit', type: 'int', default: 10, help: 'Number of trading pairs' },
-  ],
-  columns: ['rank', 'symbol', 'price', 'change_24h', 'volume'],
-  pipeline: [
-    { fetch: { url: 'https://data-api.binance.vision/api/v3/ticker/24hr' } },
-    { filter: 'item.priceChangePercent' },
-    { map: { symbol: '${{ item.symbol }}', price: '${{ item.lastPrice }}', change_24h: '${{ item.priceChangePercent }}', volume: '${{ item.quoteVolume }}', sort_change: '${{ Number(item.priceChangePercent) }}' } },
-    { sort: { by: 'sort_change', order: 'desc' } },
-    { map: { rank: '${{ index + 1 }}', symbol: '${{ item.symbol }}', price: '${{ item.lastPrice }}', change_24h: '${{ item.priceChangePercent }}', volume: '${{ item.quoteVolume }}' } },
-    { limit: '${{ args.limit }}' },
-  ],
-});
diff --git a/plugins/binance/klines.js b/plugins/binance/klines.js
deleted file mode 100644
index 077e5522..00000000
--- a/plugins/binance/klines.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-
-cli({
-  site: 'binance',
-  name: 'klines',
-    access: 'read',
-  description: 'Candlestick/kline data for a trading pair',
-  domain: 'data-api.binance.vision',
-  strategy: Strategy.PUBLIC,
-  browser: false,
-  args: [
-    { name: 'symbol', type: 'str', required: true, positional: true, help: 'Trading pair symbol (e.g. BTCUSDT, ETHUSDT)' },
-    { name: 'interval', type: 'str', default: '1d', help: 'Kline interval (1m, 5m, 15m, 1h, 4h, 1d, 1w, 1M)' },
-    { name: 'limit', type: 'int', default: 10, help: 'Number of klines (max 1000)' },
-  ],
-  columns: ['open', 'high', 'low', 'close', 'volume'],
-  pipeline: [
-    { fetch: { url: 'https://data-api.binance.vision/api/v3/klines?symbol=${{ args.symbol }}&interval=${{ args.interval }}&limit=${{ args.limit }}' } },
-    { map: { open: '${{ item.1 }}', high: '${{ item.2 }}', low: '${{ item.3 }}', close: '${{ item.4 }}', volume: '${{ item.5 }}' } },
-    { limit: '${{ args.limit }}' },
-  ],
-});
diff --git a/plugins/binance/losers.js b/plugins/binance/losers.js
deleted file mode 100644
index c99a363e..00000000
--- a/plugins/binance/losers.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-
-cli({
-  site: 'binance',
-  name: 'losers',
-    access: 'read',
-  description: 'Top losing trading pairs by 24h price change',
-  domain: 'data-api.binance.vision',
-  strategy: Strategy.PUBLIC,
-  browser: false,
-  args: [
-    { name: 'limit', type: 'int', default: 10, help: 'Number of trading pairs' },
-  ],
-  columns: ['rank', 'symbol', 'price', 'change_24h', 'volume'],
-  pipeline: [
-    { fetch: { url: 'https://data-api.binance.vision/api/v3/ticker/24hr' } },
-    { filter: 'item.priceChangePercent' },
-    { map: { symbol: '${{ item.symbol }}', price: '${{ item.lastPrice }}', change_24h: '${{ item.priceChangePercent }}', volume: '${{ item.quoteVolume }}', sort_change: '${{ Number(item.priceChangePercent) }}' } },
-    { sort: { by: 'sort_change' } },
-    { map: { rank: '${{ index + 1 }}', symbol: '${{ item.symbol }}', price: '${{ item.lastPrice }}', change_24h: '${{ item.priceChangePercent }}', volume: '${{ item.quoteVolume }}' } },
-    { limit: '${{ args.limit }}' },
-  ],
-});
diff --git a/plugins/binance/package.json b/plugins/binance/package.json
deleted file mode 100644
index 35ac8b8e..00000000
--- a/plugins/binance/package.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
-  "name": "webcmd-plugin-binance",
-  "version": "0.1.0",
-  "type": "module",
-  "description": "Webcmd commands for binance",
-  "peerDependencies": {
-    "@agentrhq/webcmd": ">=0.5.3"
-  }
-}
diff --git a/plugins/binance/pairs.js b/plugins/binance/pairs.js
deleted file mode 100644
index ef54b8e4..00000000
--- a/plugins/binance/pairs.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-
-cli({
-  site: 'binance',
-  name: 'pairs',
-    access: 'read',
-  description: 'List active trading pairs on Binance',
-  domain: 'data-api.binance.vision',
-  strategy: Strategy.PUBLIC,
-  browser: false,
-  args: [
-    { name: 'limit', type: 'int', default: 20, help: 'Number of trading pairs' },
-  ],
-  columns: ['symbol', 'base', 'quote', 'status'],
-  pipeline: [
-    { fetch: { url: 'https://data-api.binance.vision/api/v3/exchangeInfo' } },
-    { select: 'symbols' },
-    { filter: 'item.status === \'TRADING\'' },
-    { map: { symbol: '${{ item.symbol }}', base: '${{ item.baseAsset }}', quote: '${{ item.quoteAsset }}', status: '${{ item.status }}' } },
-    { limit: '${{ args.limit }}' },
-  ],
-});
diff --git a/plugins/binance/price.js b/plugins/binance/price.js
deleted file mode 100644
index bd338359..00000000
--- a/plugins/binance/price.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-
-cli({
-  site: 'binance',
-  name: 'price',
-    access: 'read',
-  description: 'Quick price check for a trading pair',
-  domain: 'data-api.binance.vision',
-  strategy: Strategy.PUBLIC,
-  browser: false,
-  args: [
-    { name: 'symbol', type: 'str', required: true, positional: true, help: 'Trading pair symbol (e.g. BTCUSDT, ETHUSDT)' },
-  ],
-  columns: ['symbol', 'price', 'change', 'change_pct', 'high', 'low', 'volume', 'quote_volume', 'trades'],
-  pipeline: [
-    { fetch: { url: 'https://data-api.binance.vision/api/v3/ticker/24hr?symbol=${{ args.symbol }}' } },
-    { map: { symbol: '${{ item.symbol }}', price: '${{ item.lastPrice }}', change: '${{ item.priceChange }}', change_pct: '${{ item.priceChangePercent }}', high: '${{ item.highPrice }}', low: '${{ item.lowPrice }}', volume: '${{ item.volume }}', quote_volume: '${{ item.quoteVolume }}', trades: '${{ item.count }}' } },
-  ],
-});
diff --git a/plugins/binance/prices.js b/plugins/binance/prices.js
deleted file mode 100644
index 0736a1a4..00000000
--- a/plugins/binance/prices.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-
-cli({
-  site: 'binance',
-  name: 'prices',
-    access: 'read',
-  description: 'Latest prices for all trading pairs',
-  domain: 'data-api.binance.vision',
-  strategy: Strategy.PUBLIC,
-  browser: false,
-  args: [
-    { name: 'limit', type: 'int', default: 20, help: 'Number of prices' },
-  ],
-  columns: ['rank', 'symbol', 'price'],
-  pipeline: [
-    { fetch: { url: 'https://data-api.binance.vision/api/v3/ticker/price' } },
-    { map: { rank: '${{ index + 1 }}', symbol: '${{ item.symbol }}', price: '${{ item.price }}' } },
-    { limit: '${{ args.limit }}' },
-  ],
-});
diff --git a/plugins/binance/test/commands.test.js b/plugins/binance/test/commands.test.js
deleted file mode 100644
index eca74ede..00000000
--- a/plugins/binance/test/commands.test.js
+++ /dev/null
@@ -1,70 +0,0 @@
-import { getRegistry } from '@agentrhq/webcmd/registry';
-import { afterEach, describe, expect, it, vi } from 'vitest';
-import { executePipeline } from '@agentrhq/webcmd/pipeline';
-
-// Import all binance adapters to register them
-import '../top.js';
-import '../gainers.js';
-import '../pairs.js';
-
-function loadPipeline(name) {
-  const cmd = getRegistry().get(`binance/${name}`);
-  if (!cmd?.pipeline) throw new Error(`Command binance/${name} not found or has no pipeline`);
-  return cmd.pipeline;
-}
-
-function mockJsonOnce(payload) {
-  vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
-    ok: true,
-    status: 200,
-    statusText: 'OK',
-    json: vi.fn().mockResolvedValue(payload),
-  }));
-}
-
-afterEach(() => {
-  vi.unstubAllGlobals();
-  vi.restoreAllMocks();
-});
-
-describe('binance adapters', () => {
-  it('sorts top pairs by numeric quote volume', async () => {
-    mockJsonOnce([
-      { symbol: 'SMALL', lastPrice: '1', priceChangePercent: '1.2', highPrice: '1', lowPrice: '1', quoteVolume: '9.9' },
-      { symbol: 'LARGE', lastPrice: '2', priceChangePercent: '2.3', highPrice: '2', lowPrice: '2', quoteVolume: '100.0' },
-      { symbol: 'MID', lastPrice: '3', priceChangePercent: '3.4', highPrice: '3', lowPrice: '3', quoteVolume: '11.0' },
-    ]);
-
-    const result = await executePipeline(null, loadPipeline('top'), { args: { limit: 3 } });
-
-    expect(result.map((item) => item.symbol)).toEqual(['LARGE', 'MID', 'SMALL']);
-    expect(result.map((item) => item.rank)).toEqual([1, 2, 3]);
-  });
-
-  it('sorts gainers by numeric percent change', async () => {
-    mockJsonOnce([
-      { symbol: 'TEN', lastPrice: '1', priceChangePercent: '10.0', quoteVolume: '100' },
-      { symbol: 'NINE', lastPrice: '1', priceChangePercent: '9.5', quoteVolume: '100' },
-      { symbol: 'HUNDRED', lastPrice: '1', priceChangePercent: '100.0', quoteVolume: '100' },
-    ]);
-
-    const result = await executePipeline(null, loadPipeline('gainers'), { args: { limit: 3 } });
-
-    expect(result.map((item) => item.symbol)).toEqual(['HUNDRED', 'TEN', 'NINE']);
-  });
-
-  it('keeps only TRADING pairs', async () => {
-    mockJsonOnce({
-      symbols: [
-        { symbol: 'BTCUSDT', baseAsset: 'BTC', quoteAsset: 'USDT', status: 'TRADING' },
-        { symbol: 'OLDPAIR', baseAsset: 'OLD', quoteAsset: 'USDT', status: 'BREAK' },
-      ],
-    });
-
-    const result = await executePipeline(null, loadPipeline('pairs'), { args: { limit: 10 } });
-
-    expect(result).toEqual([
-      { symbol: 'BTCUSDT', base: 'BTC', quote: 'USDT', status: 'TRADING' },
-    ]);
-  });
-});
diff --git a/plugins/binance/ticker.js b/plugins/binance/ticker.js
deleted file mode 100644
index b613c7a6..00000000
--- a/plugins/binance/ticker.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-
-cli({
-  site: 'binance',
-  name: 'ticker',
-    access: 'read',
-  description: '24h ticker statistics for top trading pairs by volume',
-  domain: 'data-api.binance.vision',
-  strategy: Strategy.PUBLIC,
-  browser: false,
-  args: [
-    { name: 'limit', type: 'int', default: 20, help: 'Number of tickers' },
-  ],
-  columns: ['symbol', 'price', 'change_pct', 'high', 'low', 'volume', 'quote_vol', 'trades'],
-  pipeline: [
-    { fetch: { url: 'https://data-api.binance.vision/api/v3/ticker/24hr' } },
-    { map: { symbol: '${{ item.symbol }}', price: '${{ item.lastPrice }}', change_pct: '${{ item.priceChangePercent }}', high: '${{ item.highPrice }}', low: '${{ item.lowPrice }}', volume: '${{ item.volume }}', quote_vol: '${{ item.quoteVolume }}', trades: '${{ item.count }}', sort_volume: '${{ Number(item.quoteVolume) }}' } },
-    { sort: { by: 'sort_volume', order: 'desc' } },
-    { map: { symbol: '${{ item.symbol }}', price: '${{ item.lastPrice }}', change_pct: '${{ item.priceChangePercent }}', high: '${{ item.highPrice }}', low: '${{ item.lowPrice }}', volume: '${{ item.volume }}', quote_vol: '${{ item.quoteVolume }}', trades: '${{ item.count }}' } },
-    { limit: '${{ args.limit }}' },
-  ],
-});
diff --git a/plugins/binance/top.js b/plugins/binance/top.js
deleted file mode 100644
index 092daa4d..00000000
--- a/plugins/binance/top.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-
-cli({
-  site: 'binance',
-  name: 'top',
-    access: 'read',
-  description: 'Top trading pairs by 24h volume on Binance',
-  domain: 'data-api.binance.vision',
-  strategy: Strategy.PUBLIC,
-  browser: false,
-  args: [
-    { name: 'limit', type: 'int', default: 20, help: 'Number of trading pairs' },
-  ],
-  columns: ['rank', 'symbol', 'price', 'change_24h', 'high', 'low', 'volume'],
-  pipeline: [
-    { fetch: { url: 'https://data-api.binance.vision/api/v3/ticker/24hr' } },
-    { map: { symbol: '${{ item.symbol }}', price: '${{ item.lastPrice }}', change_24h: '${{ item.priceChangePercent }}', high: '${{ item.highPrice }}', low: '${{ item.lowPrice }}', volume: '${{ item.quoteVolume }}', sort_volume: '${{ Number(item.quoteVolume) }}' } },
-    { sort: { by: 'sort_volume', order: 'desc' } },
-    { map: { rank: '${{ index + 1 }}', symbol: '${{ item.symbol }}', price: '${{ item.lastPrice }}', change_24h: '${{ item.priceChangePercent }}', high: '${{ item.highPrice }}', low: '${{ item.lowPrice }}', volume: '${{ item.quoteVolume }}' } },
-    { limit: '${{ args.limit }}' },
-  ],
-});
diff --git a/plugins/binance/trades.js b/plugins/binance/trades.js
deleted file mode 100644
index 529f3b01..00000000
--- a/plugins/binance/trades.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-
-cli({
-  site: 'binance',
-  name: 'trades',
-    access: 'read',
-  description: 'Recent trades for a trading pair',
-  domain: 'data-api.binance.vision',
-  strategy: Strategy.PUBLIC,
-  browser: false,
-  args: [
-    { name: 'symbol', type: 'str', required: true, positional: true, help: 'Trading pair symbol (e.g. BTCUSDT, ETHUSDT)' },
-    { name: 'limit', type: 'int', default: 20, help: 'Number of trades (max 1000)' },
-  ],
-  columns: ['id', 'price', 'qty', 'quote_qty', 'buyer_maker'],
-  pipeline: [
-    { fetch: { url: 'https://data-api.binance.vision/api/v3/trades?symbol=${{ args.symbol }}&limit=${{ args.limit }}' } },
-    { map: { id: '${{ item.id }}', price: '${{ item.price }}', qty: '${{ item.qty }}', quote_qty: '${{ item.quoteQty }}', buyer_maker: '${{ item.isBuyerMaker }}' } },
-    { limit: '${{ args.limit }}' },
-  ],
-});
diff --git a/plugins/binance/webcmd-plugin.json b/plugins/binance/webcmd-plugin.json
deleted file mode 100644
index 041f9087..00000000
--- a/plugins/binance/webcmd-plugin.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
-  "name": "binance",
-  "version": "0.1.0",
-  "description": "Webcmd commands for binance",
-  "webcmd": ">=0.5.3",
-  "author": {
-    "name": "WebCMD Agent",
-    "handle": "agentrhq"
-  }
-}
diff --git a/plugins/blinkit/README.md b/plugins/blinkit/README.md
deleted file mode 100644
index 55d060d7..00000000
--- a/plugins/blinkit/README.md
+++ /dev/null
@@ -1,23 +0,0 @@
-# webcmd-plugin-blinkit
-
-Webcmd commands for blinkit.
-
-## Install
-
-```bash
-webcmd plugin install github:agentrhq/webcmd/blinkit
-```
-
-## Commands
-
-| Command | Description |
-| --- | --- |
-| `webcmd blinkit add-to-cart` | Add a Blinkit product to cart |
-| `webcmd blinkit cart` | Show the current Blinkit cart |
-| `webcmd blinkit checkout` | Review Blinkit checkout totals and blockers without placing an order |
-| `webcmd blinkit location` | Show the selected Blinkit delivery location |
-| `webcmd blinkit login` | Open blinkit login |
-| `webcmd blinkit place-order` | Submit the visible Blinkit final order/payment action. Requires --confirm. |
-| `webcmd blinkit product` | Read Blinkit product details for a delivery location |
-| `webcmd blinkit search` | Search Blinkit products for a delivery location |
-| `webcmd blinkit whoami` | Show the current logged-in blinkit account |
diff --git a/plugins/blinkit/add-to-cart.js b/plugins/blinkit/add-to-cart.js
deleted file mode 100644
index 5a1998bf..00000000
--- a/plugins/blinkit/add-to-cart.js
+++ /dev/null
@@ -1,123 +0,0 @@
-import { CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors';
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import {
-  DOMAIN,
-  ensureCartHasItems,
-  openCartPanel,
-  parseQuantity,
-  readCartState,
-  requireProductId,
-  resolveCoordinates,
-  summarizeCartResponse,
-} from './utils.js';
-
-function buildCartItemEvaluate(productId, lat, lon) {
-  return `
-    (async () => {
-      const headers = { lat: ${JSON.stringify(lat)}, lon: ${JSON.stringify(lon)}, app_client: 'consumer_web' };
-      const resp = await fetch('/v1/layout/product/' + ${JSON.stringify(productId)}, { method: 'POST', credentials: 'include', headers });
-      const json = await resp.json().catch(() => null);
-      if (!resp.ok || !json) return { ok: false, status: resp.status };
-      const snippets = json?.response?.snippets || [];
-      const strip = snippets.find((snippet) => snippet?.widget_type === 'product_atc_strip')?.data || {};
-      const action = strip.stepper_data_v2?.increment_actions?.default?.find((item) => item?.add_to_cart)
-        || strip.rfc_actions_v2?.default?.find((item) => item?.remove_from_cart);
-      const cartItem = action?.add_to_cart?.cart_item || action?.remove_from_cart?.cart_item || null;
-      return [Boolean(cartItem), resp.status, cartItem, strip.inventory, strip.is_sold_out === true];
-    })()
-  `;
-}
-
-function buildWriteCartEvaluate(cartItem, quantity) {
-  return `
-    (() => {
-      const cartItem = ${JSON.stringify(cartItem)};
-      const quantity = ${quantity};
-      const readJson = (key, fallback) => {
-        try { return JSON.parse(localStorage.getItem(key) || 'null') || fallback; } catch { return fallback; }
-      };
-      const cart = readJson('cart', { count: 0, total: 0, chargeableDeliveryCost: 0, items: {}, promoInfo: [], paymentMode: null, step: [], version: 1, promo_id: '', CartAddressScreenVisible: false, uniqueSkuInCart: 0, cart_type: '', cart_state: 'invalid' });
-      const id = String(cartItem.product_id);
-      const current = cart.items?.[id]?.quantity || 0;
-      cart.items = cart.items || {};
-      cart.items[id] = {
-        product: {
-          product_id: cartItem.product_id,
-          price: cartItem.price,
-          image_url: cartItem.image_url,
-          unit: cartItem.unit,
-          mrp: cartItem.mrp,
-          group_id: cartItem.group_id,
-          merchant_id: cartItem.merchant_id,
-          name: cartItem.product_name || cartItem.display_name,
-          brand: cartItem.brand
-        },
-        quantity: current + quantity
-      };
-      const values = Object.values(cart.items);
-      cart.count = values.reduce((sum, item) => sum + Number(item.quantity || 0), 0);
-      cart.total = values.reduce((sum, item) => sum + Number(item.quantity || 0) * Number(item.product?.price || 0), 0);
-      cart.uniqueSkuInCart = values.length;
-      cart.version = 1;
-      localStorage.setItem('cart', JSON.stringify(cart));
-      window.__reduxStore__?.dispatch?.({ type: 'SYNC_CART', cart });
-      return [true, id, cart.items[id].quantity, cart.count, cart.total];
-    })()
-  `;
-}
-
-cli({
-  site: 'blinkit',
-  name: 'add-to-cart',
-  access: 'write',
-  description: 'Add a Blinkit product to cart',
-  domain: DOMAIN,
-  strategy: Strategy.COOKIE,
-  browser: true,
-  navigateBefore: false,
-  args: [
-    { name: 'productId', required: true, positional: true, help: 'Blinkit product id' },
-    { name: 'quantity', type: 'int', default: 1, help: 'Quantity to add (default 1, max 12)' },
-    { name: 'lat', help: 'Delivery latitude (defaults to current Blinkit browser location)' },
-    { name: 'lon', help: 'Delivery longitude (defaults to current Blinkit browser location)' },
-  ],
-  columns: ['status', 'productId', 'quantity', 'itemCount', 'itemsTotal', 'payable', 'message'],
-  func: async (page, kwargs) => {
-    const productId = requireProductId(kwargs.productId);
-    const quantity = parseQuantity(kwargs.quantity);
-
-    await page.goto(`https://blinkit.com/prn/x/prid/${productId}`).catch((error) => {
-      throw new CommandExecutionError(`blinkit add-to-cart navigation failed: ${error?.message || error}`);
-    });
-    const { lat, lon } = await resolveCoordinates(page, kwargs);
-    const found = await page.evaluate(buildCartItemEvaluate(productId, lat, lon)).catch((error) => {
-      throw new CommandExecutionError(`blinkit add-to-cart product read failed: ${error?.message || error}`);
-    });
-    const [foundOk, , cartItem, , soldOut] = Array.isArray(found) ? found : [];
-    if (!foundOk || !cartItem) throw new EmptyResultError('blinkit add-to-cart', `No cart payload for product ${productId}`);
-    if (soldOut) throw new CommandExecutionError(`Product ${productId} is sold out`);
-
-    const updated = await page.evaluate(buildWriteCartEvaluate(cartItem, quantity)).catch((error) => {
-      throw new CommandExecutionError(`blinkit add-to-cart write failed: ${error?.message || error}`);
-    });
-    const [updatedOk, , updatedQuantity] = Array.isArray(updated) ? updated : [];
-    if (!updatedOk) throw new CommandExecutionError(`Could not add product ${productId} to cart`);
-
-    await openCartPanel(page);
-    const summary = summarizeCartResponse(await readCartState(page));
-    ensureCartHasItems(summary);
-    return [{
-      status: 'added',
-      productId,
-      quantity: updatedQuantity,
-      itemCount: summary.itemCount,
-      itemsTotal: summary.itemsTotal,
-      payable: summary.payable,
-      message: `Added ${quantity}`,
-    }];
-  },
-});
-
-export const __test__ = {
-  buildWriteCartEvaluate,
-};
diff --git a/plugins/blinkit/auth.js b/plugins/blinkit/auth.js
deleted file mode 100644
index d39a7408..00000000
--- a/plugins/blinkit/auth.js
+++ /dev/null
@@ -1,72 +0,0 @@
-import { AuthRequiredError } from '@agentrhq/webcmd/errors';
-import { registerSiteAuthCommands } from '@agentrhq/webcmd/plugin-runtime';
-import { BASE, DOMAIN } from './utils.js';
-
-async function probeBlinkitIdentity(page) {
-  const probe = await page.evaluate(`
-    (() => {
-      const readJson = (key) => {
-        try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch { return null; }
-      };
-      const state = window.__reduxStore__?.getState?.();
-      const auth = state?.data?.auth || readJson('auth') || {};
-      const user = state?.data?.user || readJson('user') || {};
-      const text = document.body.innerText || '';
-      if (!auth.accessToken && /^Login$/m.test(text)) return { kind: 'auth', detail: 'Blinkit login button is still visible' };
-      if (!auth.accessToken) return { kind: 'auth', detail: 'Blinkit auth access token missing' };
-      return {
-        ok: true,
-        phone: auth.phoneNumber || user.phone || user.profile?.phone || '',
-        user_id: user.id || user.user_id || user.profile?.id || ''
-      };
-    })()
-  `);
-  if (probe?.kind === 'auth') throw new AuthRequiredError(DOMAIN, probe.detail);
-  return { phone: probe?.phone || '', user_id: probe?.user_id || '' };
-}
-
-function buildOpenLoginEvaluate() {
-  return `
-    (() => {
-      const readJson = (key) => {
-        try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch { return null; }
-      };
-      const state = window.__reduxStore__?.getState?.();
-      const auth = state?.data?.auth || readJson('auth') || {};
-      if (auth.accessToken) return { opened: false, detail: 'already_logged_in' };
-
-      const dialogText = document.querySelector('[role="dialog"]')?.innerText || '';
-      if (/Enter mobile number|Log in or Sign up/i.test(dialogText)) {
-        return { opened: true, detail: 'login_dialog_visible' };
-      }
-
-      const target = Array.from(document.querySelectorAll('button, [role="button"], a, div'))
-        .find((node) => (node.innerText || node.textContent || '').trim() === 'Login');
-      target?.dispatchEvent?.(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
-      return { opened: Boolean(target), detail: target ? 'clicked_login' : 'login_button_missing' };
-    })()
-  `;
-}
-
-registerSiteAuthCommands({
-  site: 'blinkit',
-  domain: DOMAIN,
-  loginUrl: BASE,
-  columns: ['phone', 'user_id'],
-  verify: async (page) => {
-    await page.goto(BASE);
-    await page.wait(1);
-    return probeBlinkitIdentity(page);
-  },
-  openLogin: async (page) => {
-    await page.goto(BASE);
-    await page.wait(1);
-    const result = await page.evaluate(buildOpenLoginEvaluate());
-    if (!result?.opened) throw new Error(`Blinkit login dialog did not open: ${result?.detail || 'unknown reason'}`);
-  },
-});
-
-export const __test__ = {
-  buildOpenLoginEvaluate,
-  probeBlinkitIdentity,
-};
diff --git a/plugins/blinkit/cart.js b/plugins/blinkit/cart.js
deleted file mode 100644
index 7c81cfc8..00000000
--- a/plugins/blinkit/cart.js
+++ /dev/null
@@ -1,34 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { DOMAIN, openCartPanel, readCartState, summarizeCartResponse } from './utils.js';
-
-cli({
-  site: 'blinkit',
-  name: 'cart',
-  access: 'read',
-  description: 'Show the current Blinkit cart',
-  domain: DOMAIN,
-  strategy: Strategy.COOKIE,
-  browser: true,
-  navigateBefore: false,
-  args: [],
-  columns: ['status', 'productId', 'name', 'variant', 'price', 'quantity', 'total', 'itemCount', 'payable', 'cartState'],
-  func: async (page) => {
-    await openCartPanel(page);
-    const summary = summarizeCartResponse(await readCartState(page));
-    if (!summary.items.length) {
-      return [{ status: 'empty', itemCount: 0, payable: summary.payable, cartState: summary.cartState }];
-    }
-    return summary.items.map((item) => ({
-      status: summary.status,
-      productId: item.productId,
-      name: item.name,
-      variant: item.variant,
-      price: item.price,
-      quantity: item.quantity,
-      total: item.total,
-      itemCount: summary.itemCount,
-      payable: summary.payable,
-      cartState: summary.cartState,
-    }));
-  },
-});
diff --git a/plugins/blinkit/checkout.js b/plugins/blinkit/checkout.js
deleted file mode 100644
index 823e7b17..00000000
--- a/plugins/blinkit/checkout.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { DOMAIN, openCartPanel, readCartState, summarizeCartResponse } from './utils.js';
-
-cli({
-  site: 'blinkit',
-  name: 'checkout',
-  access: 'read',
-  description: 'Review Blinkit checkout totals and blockers without placing an order',
-  domain: DOMAIN,
-  strategy: Strategy.COOKIE,
-  browser: true,
-  navigateBefore: false,
-  args: [],
-  columns: ['status', 'itemCount', 'itemsTotal', 'deliveryCharge', 'handlingCharge', 'payable', 'cartState', 'checkoutBlocked', 'validations'],
-  func: async (page) => {
-    await openCartPanel(page);
-    const state = await readCartState(page);
-    const summary = summarizeCartResponse(state);
-    return [{
-      status: state.loggedIn ? summary.status : 'login_required',
-      itemCount: summary.itemCount,
-      itemsTotal: summary.itemsTotal,
-      deliveryCharge: summary.deliveryCharge,
-      handlingCharge: summary.handlingCharge,
-      payable: summary.payable,
-      cartState: summary.cartState,
-      checkoutBlocked: summary.checkoutBlocked,
-      validations: summary.validations,
-    }];
-  },
-});
diff --git a/plugins/blinkit/location.js b/plugins/blinkit/location.js
deleted file mode 100644
index 6353559e..00000000
--- a/plugins/blinkit/location.js
+++ /dev/null
@@ -1,28 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { DOMAIN, gotoBlinkit, normalizeLocationState } from './utils.js';
-
-const LOCATION_EVALUATE = `
-  (() => {
-    const readJson = (key) => {
-      try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch { return null; }
-    };
-    return window.__reduxStore__?.getState?.()?.data?.location || readJson('location') || {};
-  })()
-`;
-
-cli({
-  site: 'blinkit',
-  name: 'location',
-  access: 'read',
-  description: 'Show the selected Blinkit delivery location',
-  domain: DOMAIN,
-  strategy: Strategy.COOKIE,
-  browser: true,
-  args: [],
-  columns: ['selected', 'label', 'area', 'city', 'pincode', 'hasCoordinates', 'source'],
-  func: async (page) => {
-    await gotoBlinkit(page, '/');
-    if (page.wait) await page.wait(1);
-    return [normalizeLocationState(await page.evaluate(LOCATION_EVALUATE))];
-  },
-});
diff --git a/plugins/blinkit/package.json b/plugins/blinkit/package.json
deleted file mode 100644
index d7e6192d..00000000
--- a/plugins/blinkit/package.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
-  "name": "webcmd-plugin-blinkit",
-  "version": "0.1.0",
-  "type": "module",
-  "description": "Webcmd commands for blinkit",
-  "peerDependencies": {
-    "@agentrhq/webcmd": ">=0.5.3"
-  }
-}
diff --git a/plugins/blinkit/place-order.js b/plugins/blinkit/place-order.js
deleted file mode 100644
index 1f703d9a..00000000
--- a/plugins/blinkit/place-order.js
+++ /dev/null
@@ -1,77 +0,0 @@
-import { ArgumentError, CommandExecutionError } from '@agentrhq/webcmd/errors';
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { DOMAIN, ensureCartHasItems, ensureLoggedIn, openCartPanel, readCartState, summarizeCartResponse } from './utils.js';
-
-function buildPlaceOrderEvaluate() {
-  return `
-    (async () => {
-      const finalLabels = [/^place order$/i, /^pay( now)?$/i, /^cash on delivery$/i];
-      const buttons = Array.from(document.querySelectorAll('button, [role="button"], a'));
-      const target = buttons.find((node) => {
-        const text = (node.innerText || node.textContent || '').trim().replace(/\\s+/g, ' ');
-        return text && finalLabels.some((pattern) => pattern.test(text));
-      });
-      if (!target) {
-        return { ok: false, status: 'blocked', message: 'No final place-order/payment button is visible. Complete address/payment selection in the browser checkout first.' };
-      }
-      target.click();
-      await new Promise((resolve) => setTimeout(resolve, 3500));
-      const text = document.body.innerText || '';
-      const orderMatch = text.match(/order(?:\\s+id)?[:#\\s-]*([A-Z0-9-]{6,})/i);
-      if (/payment failed|try again|could not/i.test(text)) {
-        return { ok: false, status: 'failed', message: 'Blinkit reported a payment/order failure', url: location.href };
-      }
-      return { ok: true, status: orderMatch ? 'placed' : 'submitted', orderId: orderMatch?.[1] || '', url: location.href };
-    })()
-  `;
-}
-
-cli({
-  site: 'blinkit',
-  name: 'place-order',
-  access: 'write',
-  description: 'Submit the visible Blinkit final order/payment action. Requires --confirm.',
-  domain: DOMAIN,
-  strategy: Strategy.COOKIE,
-  browser: true,
-  navigateBefore: false,
-  args: [
-    { name: 'confirm', type: 'bool', default: false, help: 'Required acknowledgement that this may place/pay for a real order' },
-  ],
-  columns: ['status', 'confirmed', 'itemCount', 'payable', 'orderId', 'url', 'message'],
-  func: async (page, kwargs) => {
-    if (!kwargs.confirm) {
-      return [{
-        status: 'no-op',
-        confirmed: false,
-        message: 'Pass --confirm to submit a real Blinkit order/payment action.',
-      }];
-    }
-    if (kwargs.confirm !== true) throw new ArgumentError('--confirm must be a boolean flag');
-
-    await openCartPanel(page);
-    const state = await readCartState(page);
-    ensureLoggedIn(state, 'blinkit place-order');
-    const summary = summarizeCartResponse(state);
-    ensureCartHasItems(summary);
-    if (summary.checkoutBlocked) throw new CommandExecutionError('Blinkit checkout is blocked for this cart');
-
-    const result = await page.evaluate(buildPlaceOrderEvaluate()).catch((error) => {
-      throw new CommandExecutionError(`blinkit place-order failed: ${error?.message || error}`);
-    });
-    if (!result?.status) throw new CommandExecutionError('blinkit place-order returned no status');
-    return [{
-      status: result.status,
-      confirmed: true,
-      itemCount: summary.itemCount,
-      payable: summary.payable,
-      orderId: result.orderId || '',
-      url: result.url || '',
-      message: result.message || '',
-    }];
-  },
-});
-
-export const __test__ = {
-  buildPlaceOrderEvaluate,
-};
diff --git a/plugins/blinkit/product.js b/plugins/blinkit/product.js
deleted file mode 100644
index 86cd7dd5..00000000
--- a/plugins/blinkit/product.js
+++ /dev/null
@@ -1,63 +0,0 @@
-import { CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors';
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { DOMAIN, normalizeProductSnippet, requireProductId, resolveCoordinates } from './utils.js';
-
-function buildProductEvaluate(productId, lat, lon) {
-  return `
-    (async () => {
-      const headers = { lat: ${JSON.stringify(lat)}, lon: ${JSON.stringify(lon)}, app_client: 'consumer_web' };
-      const resp = await fetch('/v1/layout/product/' + ${JSON.stringify(productId)}, { method: 'POST', credentials: 'include', headers });
-      const raw = await resp.text();
-      let json;
-      try { json = JSON.parse(raw); } catch { return { ok: false, status: resp.status, error: raw.slice(0, 200) }; }
-      if (!resp.ok || json.error) return { ok: false, status: resp.status, error: json.error || json.message || raw.slice(0, 200) };
-      return { ok: true, snippets: json?.response?.snippets || [] };
-    })()
-  `;
-}
-
-function normalizeProduct(snippets, productId) {
-  const title = snippets.find((snippet) => snippet?.widget_type === 'text_right_icons_rating_snippet_type');
-  const strip = snippets.find((snippet) => snippet?.widget_type === 'product_atc_strip');
-  const row = normalizeProductSnippet(strip) ?? normalizeProductSnippet(title);
-  if (!row) return null;
-  const titleText = title?.data?.title?.text;
-  return { ...row, productId, name: titleText || row.name };
-}
-
-cli({
-  site: 'blinkit',
-  name: 'product',
-  access: 'read',
-  description: 'Read Blinkit product details for a delivery location',
-  domain: DOMAIN,
-  strategy: Strategy.COOKIE,
-  browser: true,
-  navigateBefore: false,
-  args: [
-    { name: 'productId', required: true, positional: true, help: 'Blinkit product id' },
-    { name: 'lat', help: 'Delivery latitude (defaults to current Blinkit browser location)' },
-    { name: 'lon', help: 'Delivery longitude (defaults to current Blinkit browser location)' },
-  ],
-  columns: ['productId', 'name', 'brand', 'variant', 'price', 'mrp', 'currency', 'inventory', 'available', 'imageUrl', 'url'],
-  func: async (page, kwargs) => {
-    const productId = requireProductId(kwargs.productId);
-    await page.goto(`https://blinkit.com/prn/x/prid/${productId}`).catch((error) => {
-      throw new CommandExecutionError(`blinkit product navigation failed: ${error?.message || error}`);
-    });
-    const { lat, lon } = await resolveCoordinates(page, kwargs);
-    const result = await page.evaluate(buildProductEvaluate(productId, lat, lon)).catch((error) => {
-      throw new CommandExecutionError(`blinkit product request failed: ${error?.message || error}`);
-    });
-    if (!result?.ok) {
-      throw new CommandExecutionError(`blinkit product failed: HTTP ${result?.status ?? 'unknown'} ${result?.error ?? ''}`.trim());
-    }
-    const row = normalizeProduct(result.snippets ?? [], productId);
-    if (!row) throw new EmptyResultError('blinkit product', `No product data for ${productId}`);
-    return [row];
-  },
-});
-
-export const __test__ = {
-  normalizeProduct,
-};
diff --git a/plugins/blinkit/search.js b/plugins/blinkit/search.js
deleted file mode 100644
index cfbc53c6..00000000
--- a/plugins/blinkit/search.js
+++ /dev/null
@@ -1,90 +0,0 @@
-import { ArgumentError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors';
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { BASE, MAX_LIMIT, normalizeProductSnippet, parseCoordinate, parseLimit, parsePrice, resolveCoordinates } from './utils.js';
-
-function buildSearchEvaluate(query, limit, lat, lon) {
-  return `
-    (async () => {
-      const query = ${JSON.stringify(query)};
-      const limit = ${limit};
-      const headers = {
-        lat: ${JSON.stringify(lat)},
-        lon: ${JSON.stringify(lon)},
-        app_client: 'consumer_web'
-      };
-      const rows = [];
-      let url = '/v1/layout/search?q=' + encodeURIComponent(query) + '&search_type=type_to_search';
-
-      for (let page = 0; url && rows.length < limit && page < 8; page += 1) {
-        const resp = await fetch(url, { method: 'POST', credentials: 'include', headers });
-        const raw = await resp.text();
-        let json;
-        try {
-          json = JSON.parse(raw);
-        } catch {
-          return { ok: false, status: resp.status, error: raw.slice(0, 200) };
-        }
-        if (!resp.ok || json.error) {
-          return { ok: false, status: resp.status, error: json.error || json.message || raw.slice(0, 200) };
-        }
-        const snippets = Array.isArray(json?.response?.snippets) ? json.response.snippets : [];
-        rows.push(...snippets.filter((snippet) => snippet?.widget_type === 'product_card_snippet_type_2'));
-        url = json?.response?.pagination?.next_url || '';
-      }
-
-      return { ok: true, rows: rows.slice(0, limit) };
-    })()
-  `;
-}
-
-cli({
-  site: 'blinkit',
-  name: 'search',
-  tags: ['search'],
-  access: 'read',
-  description: 'Search Blinkit products for a delivery location',
-  domain: 'blinkit.com',
-  strategy: Strategy.COOKIE,
-  browser: true,
-  navigateBefore: false,
-  args: [
-    { name: 'query', required: true, positional: true, help: 'Search keyword' },
-    { name: 'limit', type: 'int', default: 20, help: `Max results (max ${MAX_LIMIT})` },
-    { name: 'lat', help: 'Delivery latitude (defaults to current Blinkit browser location)' },
-    { name: 'lon', help: 'Delivery longitude (defaults to current Blinkit browser location)' },
-  ],
-  columns: ['rank', 'productId', 'name', 'brand', 'variant', 'price', 'mrp', 'currency', 'inventory', 'available', 'imageUrl', 'url'],
-  func: async (page, kwargs) => {
-    const query = String(kwargs.query ?? '').trim();
-    if (!query) throw new ArgumentError('query cannot be empty');
-
-    const limit = parseLimit(kwargs.limit);
-    const pageUrl = `${BASE}/s/?q=${encodeURIComponent(query)}`;
-    await page.goto(pageUrl).catch((error) => {
-      throw new CommandExecutionError(`blinkit search navigation failed: ${error?.message || error}`);
-    });
-    const { lat, lon } = await resolveCoordinates(page, kwargs);
-
-    const result = await page.evaluate(buildSearchEvaluate(query, limit, lat, lon)).catch((error) => {
-      throw new CommandExecutionError(`blinkit search request failed: ${error?.message || error}`);
-    });
-    if (!result?.ok) {
-      throw new CommandExecutionError(`blinkit search failed: HTTP ${result?.status ?? 'unknown'} ${result?.error ?? ''}`.trim());
-    }
-
-    const rows = (result.rows ?? [])
-      .map((snippet, index) => normalizeProductSnippet(snippet, index + 1))
-      .filter(Boolean);
-    if (!rows.length) {
-      throw new EmptyResultError('blinkit search', `No products matched "${query}" at ${lat},${lon}`);
-    }
-    return rows;
-  },
-});
-
-export const __test__ = {
-  normalizeSnippet: normalizeProductSnippet,
-  parseCoordinate,
-  parseLimit,
-  parsePrice,
-};
diff --git a/plugins/blinkit/test/blinkit.test.js b/plugins/blinkit/test/blinkit.test.js
deleted file mode 100644
index 9fb6d619..00000000
--- a/plugins/blinkit/test/blinkit.test.js
+++ /dev/null
@@ -1,205 +0,0 @@
-import { describe, expect, it, vi } from 'vitest';
-import { ArgumentError } from '@agentrhq/webcmd/errors';
-import { getRegistry } from '@agentrhq/webcmd/registry';
-import { __test__ as authTest } from '../auth.js';
-import { __test__ as searchTest } from '../search.js';
-import { __test__ as productTest } from '../product.js';
-import { __test__ as addToCartTest } from '../add-to-cart.js';
-import { __test__ as placeOrderTest } from '../place-order.js';
-import { normalizeLocationState, resolveCoordinates } from '../utils.js';
-import '../cart.js';
-import '../checkout.js';
-import '../location.js';
-
-describe('blinkit helpers', () => {
-  it('rejects invalid external args before browser work', () => {
-    expect(() => searchTest.parseLimit(0)).toThrow(ArgumentError);
-    expect(() => searchTest.parseLimit(49)).toThrow(ArgumentError);
-    expect(() => searchTest.parseCoordinate('91', 'lat', '28.413333')).toThrow(ArgumentError);
-    expect(() => searchTest.parseCoordinate('181', 'lon', '77.072833')).toThrow(ArgumentError);
-  });
-
-  it('normalizes product snippets from Blinkit layout search', () => {
-    const row = searchTest.normalizeSnippet({
-      widget_type: 'product_card_snippet_type_2',
-      data: {
-        identity: { id: '19512' },
-        name: { text: 'Amul Taaza Toned Milk' },
-        brand_name: { text: 'Amul' },
-        variant: { text: '500 ml' },
-        normal_price: { text: '₹30' },
-        inventory: 12,
-        merchant_id: '31719',
-        eta_tag: { title: { text: 'earliest' } },
-        image: { url: 'https://cdn.grofers.com/product.png' },
-      },
-    }, 1);
-
-    expect(row).toMatchObject({
-      rank: 1,
-      productId: '19512',
-      name: 'Amul Taaza Toned Milk',
-      brand: 'Amul',
-      variant: '500 ml',
-      price: 30,
-      currency: 'INR',
-      inventory: 12,
-      available: true,
-      url: 'https://blinkit.com/prn/x/prid/19512',
-    });
-  });
-
-  it('normalizes product detail snippets', () => {
-    const row = productTest.normalizeProduct([
-      { widget_type: 'text_right_icons_rating_snippet_type', data: { title: { text: 'Amul Taaza Toned Milk' } } },
-      {
-        widget_type: 'product_atc_strip',
-        data: {
-          identity: { id: '19512' },
-          variant: { text: '500 ml' },
-          normal_price: { text: '₹30' },
-          product_id: '19512',
-          stepper_data_v2: {
-            increment_actions: {
-              default: [{ add_to_cart: { cart_item: { product_id: 19512, product_name: 'Amul Taaza Toned Milk', price: 30, mrp: 30, unit: '500 ml' } } }],
-            },
-          },
-        },
-      },
-    ], '19512');
-    expect(row).toMatchObject({ productId: '19512', name: 'Amul Taaza Toned Milk', price: 30 });
-  });
-
-  it('keeps place-order no-op unless --confirm is passed', async () => {
-    const command = getRegistry().get('blinkit/place-order');
-    const fakePage = { goto: () => { throw new Error('should not navigate'); } };
-    await expect(command.func(fakePage, {})).resolves.toMatchObject([{ status: 'no-op', confirmed: false }]);
-  });
-
-  it('builds the cart write script from product payload', () => {
-    const script = addToCartTest.buildWriteCartEvaluate({ product_id: 19512, price: 30, mrp: 30, unit: '500 ml' }, 2);
-    expect(script).toContain('localStorage.setItem');
-    expect(script).toContain('SYNC_CART');
-  });
-
-  it('opens the Blinkit login dialog before waiting for OTP', () => {
-    const script = authTest.buildOpenLoginEvaluate();
-    expect(script).toContain('Login');
-    expect(script).toContain('click');
-    expect(script).toContain('Enter mobile number');
-  });
-
-  it('uses the current Blinkit browser location when coordinates are not explicit', async () => {
-    const page = {
-      evaluate: async () => ({ lat: 12.9110953, lon: 77.6292907 }),
-    };
-
-    await expect(resolveCoordinates(page, {})).resolves.toEqual({ lat: '12.9110953', lon: '77.6292907' });
-    await expect(resolveCoordinates(page, { lat: '1', lon: '2' })).resolves.toEqual({ lat: '1', lon: '2' });
-  });
-
-  it('normalizes selected location without leaking exact address or coordinates', () => {
-    expect(normalizeLocationState({
-      address: 'Block C-03 / 79, Private Building',
-      city: 'Bengaluru',
-      locality: 'HSR Layout',
-      pinCode: '560102',
-      coords: { lat: 12.9110953, lon: 77.6292907 },
-    })).toEqual({
-      selected: true,
-      label: '',
-      area: 'HSR Layout',
-      city: 'Bengaluru',
-      pincode: '560102',
-      hasCoordinates: true,
-      source: 'browser',
-    });
-  });
-
-  it('reports empty checkout instead of failing before items are added', async () => {
-    const command = getRegistry().get('blinkit/checkout');
-    let readCartState = false;
-    const fakePage = {
-      goto: async () => {},
-      wait: async () => {},
-      evaluate: async (script) => {
-        if (script.includes('loggedIn')) {
-          readCartState = true;
-          return {
-            ok: true,
-            loggedIn: true,
-            storedCart: { items: {}, count: 0, total: 0, cart_state: 'valid' },
-          };
-        }
-        if (script.includes('cartResponse')) return false;
-        return true;
-      },
-    };
-
-    await expect(command.func(fakePage, {})).resolves.toMatchObject([{ status: 'empty', itemCount: 0 }]);
-    expect(readCartState).toBe(true);
-  });
-});
-
-describe('blinkit registry shape', () => {
-  it('registers the buying-path commands', () => {
-    for (const name of ['login', 'whoami', 'location', 'search', 'product', 'add-to-cart', 'cart', 'checkout', 'place-order']) {
-      expect(getRegistry().get(`blinkit/${name}`)).toBeDefined();
-    }
-  });
-
-  it('opens login without waiting for manual authentication', async () => {
-    const login = getRegistry().get('blinkit/login');
-    const whoami = getRegistry().get('blinkit/whoami');
-    const page = {
-      goto: vi.fn().mockResolvedValue(undefined),
-      wait: vi.fn().mockResolvedValue(undefined),
-      evaluate: vi.fn()
-        .mockResolvedValueOnce({ kind: 'auth', detail: 'Blinkit login button is still visible' })
-        .mockResolvedValueOnce({ opened: true }),
-    };
-
-    expect(login.args).toEqual([]);
-    expect(login.columns).toEqual(expect.arrayContaining(['action', 'verify_command']));
-    expect(whoami).toBeDefined();
-    await expect(login.func(page, {})).resolves.toEqual([expect.objectContaining({
-      status: 'action_required',
-      logged_in: false,
-      site: 'blinkit',
-      verify_command: 'webcmd blinkit whoami',
-    })]);
-    expect(page.wait).not.toHaveBeenCalledWith(2);
-  });
-
-  it('rejects when the Blinkit login dialog cannot be opened', async () => {
-    const login = getRegistry().get('blinkit/login');
-    const page = {
-      goto: vi.fn().mockResolvedValue(undefined),
-      wait: vi.fn().mockResolvedValue(undefined),
-      evaluate: vi.fn()
-        .mockResolvedValueOnce({ kind: 'auth', detail: 'login required' })
-        .mockResolvedValueOnce({ opened: false, detail: 'login_button_missing' }),
-    };
-
-    await expect(login.func(page, {}))
-      .rejects.toThrow('Blinkit login dialog did not open: login_button_missing');
-  });
-
-  it('marks only cart-changing commands as write', () => {
-    expect(getRegistry().get('blinkit/search').access).toBe('read');
-    expect(getRegistry().get('blinkit/product').access).toBe('read');
-    expect(getRegistry().get('blinkit/location').access).toBe('read');
-    expect(getRegistry().get('blinkit/cart').access).toBe('read');
-    expect(getRegistry().get('blinkit/checkout').access).toBe('read');
-    expect(getRegistry().get('blinkit/login').access).toBe('write');
-    expect(getRegistry().get('blinkit/add-to-cart').access).toBe('write');
-    expect(getRegistry().get('blinkit/place-order').access).toBe('write');
-  });
-
-  it('place-order script only targets final order/payment buttons', () => {
-    const script = placeOrderTest.buildPlaceOrderEvaluate();
-    expect(script).toContain('place order');
-    expect(script).toContain('cash on delivery');
-    expect(script).not.toContain('Proceed');
-  });
-});
diff --git a/plugins/blinkit/utils.js b/plugins/blinkit/utils.js
deleted file mode 100644
index 4e2a4e61..00000000
--- a/plugins/blinkit/utils.js
+++ /dev/null
@@ -1,223 +0,0 @@
-import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors';
-
-export const BASE = 'https://blinkit.com';
-export const DOMAIN = 'blinkit.com';
-export const DEFAULT_LAT = '28.413333';
-export const DEFAULT_LON = '77.072833';
-export const MAX_LIMIT = 48;
-
-export function parseLimit(raw) {
-  if (raw === undefined || raw === null || raw === '') return 20;
-  const limit = Number(raw);
-  if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
-    throw new ArgumentError(`--limit must be an integer between 1 and ${MAX_LIMIT}`);
-  }
-  return limit;
-}
-
-export function parseQuantity(raw) {
-  const quantity = raw === undefined || raw === null || raw === '' ? 1 : Number(raw);
-  if (!Number.isInteger(quantity) || quantity < 1 || quantity > 12) {
-    throw new ArgumentError('--quantity must be an integer between 1 and 12');
-  }
-  return quantity;
-}
-
-export function parseCoordinate(raw, label, fallback) {
-  const value = raw === undefined || raw === null || raw === '' ? fallback : String(raw);
-  const n = Number(value);
-  const max = label === 'lat' ? 90 : 180;
-  if (!Number.isFinite(n) || n < -max || n > max) {
-    throw new ArgumentError(`--${label} must be a number between ${-max} and ${max}`);
-  }
-  return String(n);
-}
-
-export async function resolveCoordinates(page, kwargs = {}) {
-  const explicitLat = kwargs.lat !== undefined && kwargs.lat !== null && kwargs.lat !== '';
-  const explicitLon = kwargs.lon !== undefined && kwargs.lon !== null && kwargs.lon !== '';
-  const location = explicitLat && explicitLon ? null : await page.evaluate(`
-    (() => {
-      const readJson = (key) => {
-        try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch { return null; }
-      };
-      const coords = window.__reduxStore__?.getState?.()?.data?.location?.coords || readJson('location')?.coords || {};
-      return { lat: coords.lat, lon: coords.lon };
-    })()
-  `).catch(() => null);
-  return {
-    lat: parseCoordinate(explicitLat ? kwargs.lat : location?.lat, 'lat', DEFAULT_LAT),
-    lon: parseCoordinate(explicitLon ? kwargs.lon : location?.lon, 'lon', DEFAULT_LON),
-  };
-}
-
-export function normalizeLocationState(location = {}) {
-  const coords = location.coords || {};
-  return {
-    selected: Boolean(location.locality || location.area || location.city || location.pinCode || location.pincode || coords.lat || coords.lon),
-    label: String(location.label || location.name || location.type || '').trim(),
-    area: String(location.locality || location.area || location.landmark || '').trim(),
-    city: String(location.city || location.cityName || '').trim(),
-    pincode: String(location.pinCode || location.pincode || location.pin || '').trim(),
-    hasCoordinates: Boolean(coords.lat || coords.lon),
-    source: 'browser',
-  };
-}
-
-export function requireProductId(raw) {
-  const value = String(raw ?? '').trim();
-  const match = value.match(/(?:prid\/)?(\d{3,})$/) || value.match(/\/prid\/(\d{3,})/);
-  if (!match) throw new ArgumentError('productId must be a Blinkit product id, for example 19512');
-  return match[1];
-}
-
-export function parsePrice(text) {
-  const match = String(text ?? '').replace(/,/g, '').match(/(\d+(?:\.\d+)?)/);
-  return match ? Number(match[1]) : null;
-}
-
-export function text(node) {
-  return String(node?.text ?? '').trim();
-}
-
-export function productUrl(productId) {
-  return productId ? `${BASE}/prn/x/prid/${productId}` : '';
-}
-
-export function normalizeProductSnippet(snippet, rank = undefined) {
-  const data = snippet?.data ?? {};
-  const cart = data.atc_action?.add_to_cart?.cart_item
-    ?? data.stepper_data_v2?.increment_actions?.default?.find((action) => action?.add_to_cart)?.add_to_cart?.cart_item
-    ?? data.rfc_actions_v2?.default?.find((action) => action?.remove_from_cart)?.remove_from_cart?.cart_item
-    ?? {};
-  const productId = String(data.product_id ?? data.meta?.product_id ?? cart.product_id ?? data.identity?.id ?? '').trim();
-  const name = text(data.display_name) || text(data.name) || text(data.title) || String(cart.product_name ?? cart.display_name ?? '').trim();
-  if (!productId || !name) return null;
-
-  const price = cart.price ?? parsePrice(text(data.normal_price) || text(data.info_text));
-  const mrp = cart.mrp ?? parsePrice(text(data.mrp));
-  return {
-    ...(rank === undefined ? {} : { rank }),
-    productId,
-    name,
-    brand: text(data.brand_name) || String(cart.brand ?? '').trim(),
-    variant: text(data.variant) || String(cart.unit ?? '').trim(),
-    price: Number.isFinite(Number(price)) ? Number(price) : null,
-    mrp: Number.isFinite(Number(mrp)) ? Number(mrp) : null,
-    currency: 'INR',
-    inventory: Number.isFinite(Number(data.inventory ?? cart.inventory)) ? Number(data.inventory ?? cart.inventory) : null,
-    available: data.is_sold_out === true ? false : data.product_state !== 'unavailable',
-    imageUrl: data.image?.url || cart.image_url || '',
-    url: productUrl(productId),
-  };
-}
-
-export function normalizeCartItem(item, fallbackId = '') {
-  const product = item?.product ?? item ?? {};
-  const productId = String(product.product_id ?? item?.product_id ?? fallbackId ?? '').trim();
-  const quantity = Number(item?.quantity ?? product.quantity ?? 0);
-  if (!productId || !quantity) return null;
-  const price = Number(product.price ?? product.unit_price ?? parsePrice(product.total_price));
-  return {
-    productId,
-    name: String(product.name ?? product.product_name ?? product.display_name ?? '').trim(),
-    variant: String(product.unit ?? '').trim(),
-    price: Number.isFinite(price) ? price : null,
-    quantity,
-    total: Number.isFinite(price) ? price * quantity : null,
-    inventory: Number.isFinite(Number(product.inventory_limit ?? product.inventory)) ? Number(product.inventory_limit ?? product.inventory) : null,
-    merchantId: String(product.merchant_id ?? '').trim(),
-    imageUrl: product.image_url || product.image_url_v2 || product.png_image_url || '',
-  };
-}
-
-export function summarizeCartResponse(response) {
-  const cartData = response?.cart_data ?? {};
-  const bill = cartData.bill_details ?? {};
-  const items = Array.isArray(cartData.items)
-    ? cartData.items.map((item) => normalizeCartItem(item)).filter(Boolean)
-    : [];
-  const storedItems = response?.storedCart?.items && typeof response.storedCart.items === 'object'
-    ? Object.entries(response.storedCart.items).map(([id, item]) => normalizeCartItem(item, id)).filter(Boolean)
-    : [];
-  const finalItems = items.length ? items : storedItems;
-  return {
-    status: finalItems.length ? 'ok' : 'empty',
-    itemCount: Number(bill.total_items ?? response?.storedCart?.count ?? finalItems.reduce((sum, item) => sum + item.quantity, 0) ?? 0),
-    itemsTotal: Number(bill.total_cost ?? response?.storedCart?.total ?? 0),
-    deliveryCharge: Number(bill.delivery_charge ?? 0),
-    handlingCharge: Number(bill.additional_charge ?? 0),
-    payable: Number(bill.payable_amount ?? bill.bill_total ?? response?.storedCart?.total ?? 0),
-    cartState: response?.cart_state ?? cartData.cart_state ?? response?.storedCart?.cart_state ?? '',
-    checkoutBlocked: Boolean(cartData.checkout_block_details),
-    validations: (cartData.validations ?? []).map((validation) => validation?.code).filter(Boolean).join(','),
-    items: finalItems,
-  };
-}
-
-export async function gotoBlinkit(page, path = '/') {
-  await page.goto(`${BASE}${path}`).catch((error) => {
-    throw new CommandExecutionError(`blinkit navigation failed: ${error?.message || error}`);
-  });
-}
-
-export async function openCartPanel(page) {
-  await gotoBlinkit(page, '/');
-  await page.wait(1);
-  await page.evaluate(`
-    (() => {
-      const nodes = Array.from(document.querySelectorAll('[role="button"], header div, header button, header a'));
-      const target = nodes
-        .filter((node) => {
-          const value = node.innerText || node.textContent || '';
-          return /My Cart/i.test(value) || (/\\d+\\s+items?/i.test(value) && /₹\\s*\\d+/i.test(value));
-        })
-        .sort((a, b) => (a.innerText || a.textContent || '').length - (b.innerText || b.textContent || '').length)[0];
-      target?.dispatchEvent?.(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
-      return Boolean(target);
-    })()
-  `).catch((error) => {
-    throw new CommandExecutionError(`blinkit cart open failed: ${error?.message || error}`);
-  });
-  for (let attempt = 0; attempt < 5; attempt += 1) {
-    await page.wait(1);
-    const ready = await page.evaluate(`
-      (() => Boolean(window.__reduxStore__?.getState?.()?.ui?.cart?.cartScreen?.cartResponse?.cart_data))
-    `).catch(() => false);
-    if (ready) return;
-  }
-}
-
-export async function readCartState(page) {
-  const result = await page.evaluate(`
-    (() => {
-      const readJson = (key) => {
-        try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch { return null; }
-      };
-      const state = window.__reduxStore__?.getState?.();
-      const response = state?.ui?.cart?.cartScreen?.cartResponse || null;
-      return {
-        ok: true,
-        loggedIn: !/^Login$/m.test(document.body.innerText || '') && Boolean(readJson('auth')?.accessToken || state?.data?.auth?.accessToken),
-        response,
-        storedCart: state?.data?.cart || readJson('cart') || null,
-        checkout: state?.ui?.checkout || readJson('checkout') || null,
-        location: state?.data?.location || readJson('location') || null,
-      };
-    })()
-  `).catch((error) => {
-    throw new CommandExecutionError(`blinkit cart state read failed: ${error?.message || error}`);
-  });
-  if (!result?.ok) throw new CommandExecutionError('blinkit cart state read failed');
-  return result;
-}
-
-export function ensureLoggedIn(state, action = 'Blinkit command') {
-  if (!state?.loggedIn) {
-    throw new AuthRequiredError(DOMAIN, `${action} requires a logged-in Blinkit browser session. Run webcmd blinkit login first.`);
-  }
-}
-
-export function ensureCartHasItems(summary) {
-  if (!summary.itemCount) throw new EmptyResultError('blinkit cart', 'Cart is empty');
-}
diff --git a/plugins/blinkit/webcmd-plugin.json b/plugins/blinkit/webcmd-plugin.json
deleted file mode 100644
index 6fa4e409..00000000
--- a/plugins/blinkit/webcmd-plugin.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
-  "name": "blinkit",
-  "version": "0.1.0",
-  "description": "Webcmd commands for blinkit",
-  "webcmd": ">=0.5.3",
-  "author": {
-    "name": "WebCMD Agent",
-    "handle": "agentrhq"
-  }
-}
diff --git a/plugins/bloomberg/README.md b/plugins/bloomberg/README.md
deleted file mode 100644
index 7b4da5de..00000000
--- a/plugins/bloomberg/README.md
+++ /dev/null
@@ -1,27 +0,0 @@
-# webcmd-plugin-bloomberg
-
-Webcmd commands for bloomberg.
-
-## Install
-
-```bash
-webcmd plugin install github:agentrhq/webcmd/bloomberg
-```
-
-## Commands
-
-| Command | Description |
-| --- | --- |
-| `webcmd bloomberg businessweek` | Bloomberg Businessweek top stories |
-| `webcmd bloomberg crypto` | Bloomberg Crypto top stories (RSS) |
-| `webcmd bloomberg economics` | Bloomberg Economics top stories (RSS) |
-| `webcmd bloomberg feeds` | List the Bloomberg RSS feed aliases used by the adapter |
-| `webcmd bloomberg green` | Bloomberg Green (climate & energy) top stories (RSS) |
-| `webcmd bloomberg industries` | Bloomberg Industries top stories (RSS) |
-| `webcmd bloomberg main` | Bloomberg homepage top stories (RSS) |
-| `webcmd bloomberg markets` | Bloomberg Markets top stories (RSS) |
-| `webcmd bloomberg news` | Read a Bloomberg story/article page and return title, full content, and media links |
-| `webcmd bloomberg opinions` | Bloomberg Opinion top stories (RSS) |
-| `webcmd bloomberg politics` | Bloomberg Politics top stories (RSS) |
-| `webcmd bloomberg pursuits` | Bloomberg Pursuits (lifestyle) top stories (RSS) |
-| `webcmd bloomberg tech` | Bloomberg Tech top stories (RSS) |
diff --git a/plugins/bloomberg/businessweek.js b/plugins/bloomberg/businessweek.js
deleted file mode 100644
index 36872d63..00000000
--- a/plugins/bloomberg/businessweek.js
+++ /dev/null
@@ -1,125 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { ArgumentError, CliError } from '@agentrhq/webcmd/errors';
-
-const SECTION_URL = 'https://www.bloomberg.com/businessweek';
-
-export function parseBusinessweekLimit(value) {
-    const limit = value == null || value === '' ? 1 : Number(value);
-    if (!Number.isInteger(limit) || limit < 1 || limit > 20) {
-        throw new ArgumentError('bloomberg businessweek --limit must be an integer between 1 and 20', 'Example: webcmd bloomberg businessweek --limit 5');
-    }
-    return limit;
-}
-
-export function normalizeBusinessweekStoryPath(path) {
-    const raw = typeof path === 'string' ? path.trim() : '';
-    if (!raw)
-        return '';
-    let url;
-    try {
-        url = new URL(raw, 'https://www.bloomberg.com');
-    }
-    catch {
-        return '';
-    }
-    if (url.protocol !== 'https:' || url.hostname !== 'www.bloomberg.com')
-        return '';
-    if (!/^\/(?:news|features)\//.test(url.pathname))
-        return '';
-    return `${url.pathname}${url.search}`;
-}
-
-export function extractBusinessweekStoriesFromNextData(data) {
-    const modules = data && data.props && data.props.pageProps
-        && data.props.pageProps.initialState && data.props.pageProps.initialState.modulesById;
-    if (!modules || typeof modules !== 'object')
-        return null;
-    const seen = new Set();
-    const stories = [];
-    for (const mod of Object.values(modules)) {
-        const items = mod && Array.isArray(mod.items) ? mod.items : [];
-        for (const it of items) {
-            const headline = it && typeof it.headline === 'string' ? it.headline.trim() : '';
-            const storyPath = normalizeBusinessweekStoryPath(it && typeof it.url === 'string' ? it.url : '');
-            if (!headline || !storyPath)
-                continue;
-            const key = storyPath.split('?')[0];
-            if (seen.has(key))
-                continue;
-            seen.add(key);
-            const summary = (it.summary && String(it.summary).trim())
-                || (it.eyebrow && it.eyebrow.text ? String(it.eyebrow.text).trim() : '');
-            const img = (it.image && (it.image.baseUrl || it.image.url))
-                || (it.lede && (it.lede.baseUrl || it.lede.url)) || '';
-            stories.push({
-                title: headline,
-                summary,
-                link: `https://www.bloomberg.com${storyPath}`,
-                mediaLinks: img ? [img] : [],
-            });
-        }
-    }
-    return stories;
-}
-
-// Bloomberg now serves the Businessweek RSS feed empty (feeds.bloomberg.com/businessweek/news.rss
-// returns a maintained-but-item-less channel), while the Businessweek section page keeps
-// publishing. Like `bloomberg news`, the page ships its data as Next.js __NEXT_DATA__; the
-// section's stories live under props.pageProps.initialState.modulesById[*].items[]. So we read
-// the section page in the browser and pull the story list out of the embedded SSR state.
-export const command = cli({
-    site: 'bloomberg',
-    name: 'businessweek',
-    access: 'read',
-    description: 'Bloomberg Businessweek top stories',
-    domain: 'www.bloomberg.com',
-    strategy: Strategy.PUBLIC,
-    browser: true,
-    args: [
-        { name: 'limit', type: 'int', default: 1, help: 'Number of stories to return (max 20)' },
-    ],
-    columns: ['title', 'summary', 'link', 'mediaLinks'],
-    func: async (page, kwargs) => {
-        const count = parseBusinessweekLimit(kwargs.limit);
-        await page.goto(SECTION_URL);
-        await page.wait({ selector: '#__NEXT_DATA__', timeout: 8 });
-        const normalizeStoryPathSource = normalizeBusinessweekStoryPath.toString();
-        const extractStoriesSource = extractBusinessweekStoriesFromNextData.toString();
-        const loadStories = async () => page.evaluate(`(() => {
-      ${normalizeStoryPathSource}
-      ${extractStoriesSource}
-      const el = document.getElementById('__NEXT_DATA__');
-      if (!el) return { ok: false, error: 'NO_NEXT_DATA', title: document.title };
-      let data;
-      try { data = JSON.parse(el.textContent); }
-      catch (err) { return { ok: false, error: 'BAD_NEXT_DATA', message: String(err) }; }
-      const stories = extractBusinessweekStoriesFromNextData(data);
-      if (!stories) return { ok: false, error: 'NO_MODULES' };
-      return { ok: true, stories };
-    })()`);
-        let result = await loadStories();
-        // Next.js sometimes hydrates slowly — retry once before giving up.
-        if (result && result.ok === false && (result.error === 'NO_NEXT_DATA' || result.error === 'NO_MODULES')) {
-            await page.wait(4);
-            result = await loadStories();
-        }
-        if (!result || typeof result !== 'object') {
-            throw new CliError('PARSE_ERROR', 'Bloomberg Businessweek page returned malformed story data', 'Bloomberg may have changed the page structure.');
-        }
-        if (result.ok === false) {
-            throw new CliError('PARSE_ERROR', `Bloomberg Businessweek page did not expose story data (${result.error})`, 'Bloomberg may have changed the page structure.');
-        }
-        const stories = Array.isArray(result.stories) ? result.stories : [];
-        if (!stories.length) {
-            throw new CliError('NOT_FOUND', 'No Bloomberg Businessweek stories found', 'Bloomberg may have changed the page structure.');
-        }
-        return stories.slice(0, count);
-    },
-});
-
-export const __test__ = {
-    command,
-    parseBusinessweekLimit,
-    normalizeBusinessweekStoryPath,
-    extractBusinessweekStoriesFromNextData,
-};
diff --git a/plugins/bloomberg/crypto.js b/plugins/bloomberg/crypto.js
deleted file mode 100644
index b84c88f5..00000000
--- a/plugins/bloomberg/crypto.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { fetchBloombergFeed } from './utils.js';
-cli({
-    site: 'bloomberg',
-    name: 'crypto',
-    access: 'read',
-    description: 'Bloomberg Crypto top stories (RSS)',
-    domain: 'feeds.bloomberg.com',
-    strategy: Strategy.PUBLIC,
-    browser: false,
-    args: [
-        { name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
-    ],
-    columns: ['title', 'summary', 'link', 'mediaLinks'],
-    func: async (kwargs) => {
-        return fetchBloombergFeed('crypto', kwargs.limit ?? 1);
-    },
-});
diff --git a/plugins/bloomberg/economics.js b/plugins/bloomberg/economics.js
deleted file mode 100644
index c8ae0ca2..00000000
--- a/plugins/bloomberg/economics.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { fetchBloombergFeed } from './utils.js';
-cli({
-    site: 'bloomberg',
-    name: 'economics',
-    access: 'read',
-    description: 'Bloomberg Economics top stories (RSS)',
-    domain: 'feeds.bloomberg.com',
-    strategy: Strategy.PUBLIC,
-    browser: false,
-    args: [
-        { name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
-    ],
-    columns: ['title', 'summary', 'link', 'mediaLinks'],
-    func: async (kwargs) => {
-        return fetchBloombergFeed('economics', kwargs.limit ?? 1);
-    },
-});
diff --git a/plugins/bloomberg/feeds.js b/plugins/bloomberg/feeds.js
deleted file mode 100644
index b7fad84f..00000000
--- a/plugins/bloomberg/feeds.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { BLOOMBERG_FEEDS } from './utils.js';
-cli({
-    site: 'bloomberg',
-    name: 'feeds',
-    access: 'read',
-    description: 'List the Bloomberg RSS feed aliases used by the adapter',
-    domain: 'feeds.bloomberg.com',
-    strategy: Strategy.PUBLIC,
-    browser: false,
-    args: [],
-    columns: ['name', 'url'],
-    func: async () => {
-        return Object.entries(BLOOMBERG_FEEDS).map(([name, url]) => ({ name, url }));
-    },
-});
diff --git a/plugins/bloomberg/green.js b/plugins/bloomberg/green.js
deleted file mode 100644
index a6f7991b..00000000
--- a/plugins/bloomberg/green.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { fetchBloombergFeed } from './utils.js';
-cli({
-    site: 'bloomberg',
-    name: 'green',
-    access: 'read',
-    description: 'Bloomberg Green (climate & energy) top stories (RSS)',
-    domain: 'feeds.bloomberg.com',
-    strategy: Strategy.PUBLIC,
-    browser: false,
-    args: [
-        { name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
-    ],
-    columns: ['title', 'summary', 'link', 'mediaLinks'],
-    func: async (kwargs) => {
-        return fetchBloombergFeed('green', kwargs.limit ?? 1);
-    },
-});
diff --git a/plugins/bloomberg/industries.js b/plugins/bloomberg/industries.js
deleted file mode 100644
index dc2f1847..00000000
--- a/plugins/bloomberg/industries.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { fetchBloombergFeed } from './utils.js';
-cli({
-    site: 'bloomberg',
-    name: 'industries',
-    access: 'read',
-    description: 'Bloomberg Industries top stories (RSS)',
-    domain: 'feeds.bloomberg.com',
-    strategy: Strategy.PUBLIC,
-    browser: false,
-    args: [
-        { name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
-    ],
-    columns: ['title', 'summary', 'link', 'mediaLinks'],
-    func: async (kwargs) => {
-        return fetchBloombergFeed('industries', kwargs.limit ?? 1);
-    },
-});
diff --git a/plugins/bloomberg/main.js b/plugins/bloomberg/main.js
deleted file mode 100644
index 923bc6a2..00000000
--- a/plugins/bloomberg/main.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { fetchBloombergFeed } from './utils.js';
-cli({
-    site: 'bloomberg',
-    name: 'main',
-    access: 'read',
-    description: 'Bloomberg homepage top stories (RSS)',
-    domain: 'feeds.bloomberg.com',
-    strategy: Strategy.PUBLIC,
-    browser: false,
-    args: [
-        { name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
-    ],
-    columns: ['title', 'summary', 'link', 'mediaLinks'],
-    func: async (kwargs) => {
-        return fetchBloombergFeed('main', kwargs.limit ?? 1);
-    },
-});
diff --git a/plugins/bloomberg/markets.js b/plugins/bloomberg/markets.js
deleted file mode 100644
index 0d71fa87..00000000
--- a/plugins/bloomberg/markets.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { fetchBloombergFeed } from './utils.js';
-cli({
-    site: 'bloomberg',
-    name: 'markets',
-    access: 'read',
-    description: 'Bloomberg Markets top stories (RSS)',
-    domain: 'feeds.bloomberg.com',
-    strategy: Strategy.PUBLIC,
-    browser: false,
-    args: [
-        { name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
-    ],
-    columns: ['title', 'summary', 'link', 'mediaLinks'],
-    func: async (kwargs) => {
-        return fetchBloombergFeed('markets', kwargs.limit ?? 1);
-    },
-});
diff --git a/plugins/bloomberg/news.js b/plugins/bloomberg/news.js
deleted file mode 100644
index 42805a58..00000000
--- a/plugins/bloomberg/news.js
+++ /dev/null
@@ -1,106 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { CliError } from '@agentrhq/webcmd/errors';
-import { extractStoryMediaLinks, renderStoryBody, validateBloombergLink, } from './utils.js';
-cli({
-    site: 'bloomberg',
-    name: 'news',
-    access: 'read',
-    description: 'Read a Bloomberg story/article page and return title, full content, and media links',
-    domain: 'www.bloomberg.com',
-    strategy: Strategy.COOKIE,
-    browser: true,
-    args: [
-        { name: 'link', positional: true, required: true, help: 'Bloomberg story/article URL or relative Bloomberg path' },
-    ],
-    columns: ['title', 'summary', 'link', 'mediaLinks', 'content'],
-    func: async (page, kwargs) => {
-        const url = validateBloombergLink(kwargs.link);
-        // Navigate and wait for the page to hydrate before extracting story data.
-        await page.goto(url);
-        await page.wait({ selector: 'article', timeout: 5 });
-        const loadStory = async () => page.evaluate(`(() => {
-      const isRobot = /Are you a robot/i.test(document.title)
-        || /unusual activity/i.test(document.body.innerText)
-        || /click the box below to let us know you're not a robot/i.test(document.body.innerText);
-
-      if (isRobot) {
-        return {
-          errorCode: 'ROBOT_PAGE',
-          title: document.title,
-          preview: document.body.innerText.slice(0, 400),
-        };
-      }
-
-      const raw = document.querySelector('#__NEXT_DATA__')?.textContent;
-      if (!raw) {
-        return {
-          errorCode: 'NO_NEXT_DATA',
-          title: document.title,
-          preview: document.body.innerText.slice(0, 400),
-        };
-      }
-
-      let parsed;
-      try {
-        parsed = JSON.parse(raw);
-      } catch (err) {
-        return {
-          errorCode: 'BAD_NEXT_DATA',
-          title: document.title,
-          preview: document.body.innerText.slice(0, 400),
-          message: String(err),
-        };
-      }
-
-      const story = parsed?.props?.pageProps?.story;
-      if (!story) {
-        return {
-          errorCode: 'NO_STORY',
-          title: document.title,
-          preview: document.body.innerText.slice(0, 400),
-        };
-      }
-
-      return {
-        story: {
-          headline: story.headline || story.seoHeadline || story.seoTitle || document.querySelector('h1')?.textContent?.trim() || document.title,
-          summary: story.summary || story.socialDescription || story.seoDescription || document.querySelector('meta[name="description"]')?.getAttribute('content') || '',
-          url: story.url || story.readingUrl || location.href,
-          body: story.body || null,
-          lede: story.lede || null,
-          ledeImageUrl: story.ledeImageUrl || null,
-          socialImageUrl: story.socialImageUrl || null,
-          imageAttachments: story.imageAttachments || {},
-          videoAttachments: story.videoAttachments || {},
-        }
-      };
-    })()`);
-        let result = await loadStory();
-        // Retry once — Bloomberg pages sometimes hydrate slowly.
-        if (result?.errorCode === 'NO_NEXT_DATA' || result?.errorCode === 'NO_STORY') {
-            await page.wait(4);
-            result = await loadStory();
-        }
-        if (result?.errorCode === 'ROBOT_PAGE') {
-            throw new CliError('FETCH_ERROR', 'Bloomberg served the bot-protection page instead of article content', 'Try again later or open the article in a regular Chrome session first, then rerun the command. This command uses your current Bloomberg access and does not bypass paywall or entitlement checks.');
-        }
-        if (result?.errorCode) {
-            throw new CliError('PARSE_ERROR', `Bloomberg page did not expose article story data (${result.errorCode})`, 'This command currently works on standard Bloomberg story/article pages that expose __NEXT_DATA__. Audio, video, newsletter, or other non-standard/blocked pages may not work. Access still depends on your current Bloomberg session.');
-        }
-        const story = result?.story;
-        if (!story) {
-            throw new CliError('PARSE_ERROR', 'Failed to extract Bloomberg story data', 'Bloomberg may have changed the page structure.');
-        }
-        const content = renderStoryBody(story.body);
-        if (!content) {
-            throw new CliError('PARSE_ERROR', 'Bloomberg article body was empty after parsing', 'Bloomberg may have changed the story-body format, the URL may not point to a standard article page, or the page may not be accessible in your current Bloomberg session.');
-        }
-        return [{
-                title: story.headline || '',
-                summary: story.summary || '',
-                link: story.url || url,
-                mediaLinks: extractStoryMediaLinks(story),
-                content,
-            }];
-    },
-});
diff --git a/plugins/bloomberg/opinions.js b/plugins/bloomberg/opinions.js
deleted file mode 100644
index 558cb92b..00000000
--- a/plugins/bloomberg/opinions.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { fetchBloombergFeed } from './utils.js';
-cli({
-    site: 'bloomberg',
-    name: 'opinions',
-    access: 'read',
-    description: 'Bloomberg Opinion top stories (RSS)',
-    domain: 'feeds.bloomberg.com',
-    strategy: Strategy.PUBLIC,
-    browser: false,
-    args: [
-        { name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
-    ],
-    columns: ['title', 'summary', 'link', 'mediaLinks'],
-    func: async (kwargs) => {
-        return fetchBloombergFeed('opinions', kwargs.limit ?? 1);
-    },
-});
diff --git a/plugins/bloomberg/package.json b/plugins/bloomberg/package.json
deleted file mode 100644
index 6acbfa4c..00000000
--- a/plugins/bloomberg/package.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
-  "name": "webcmd-plugin-bloomberg",
-  "version": "0.1.0",
-  "type": "module",
-  "description": "Webcmd commands for bloomberg",
-  "peerDependencies": {
-    "@agentrhq/webcmd": ">=0.5.3"
-  }
-}
diff --git a/plugins/bloomberg/politics.js b/plugins/bloomberg/politics.js
deleted file mode 100644
index 7129fbf3..00000000
--- a/plugins/bloomberg/politics.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { fetchBloombergFeed } from './utils.js';
-cli({
-    site: 'bloomberg',
-    name: 'politics',
-    access: 'read',
-    description: 'Bloomberg Politics top stories (RSS)',
-    domain: 'feeds.bloomberg.com',
-    strategy: Strategy.PUBLIC,
-    browser: false,
-    args: [
-        { name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
-    ],
-    columns: ['title', 'summary', 'link', 'mediaLinks'],
-    func: async (kwargs) => {
-        return fetchBloombergFeed('politics', kwargs.limit ?? 1);
-    },
-});
diff --git a/plugins/bloomberg/pursuits.js b/plugins/bloomberg/pursuits.js
deleted file mode 100644
index da39b50a..00000000
--- a/plugins/bloomberg/pursuits.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { fetchBloombergFeed } from './utils.js';
-cli({
-    site: 'bloomberg',
-    name: 'pursuits',
-    access: 'read',
-    description: 'Bloomberg Pursuits (lifestyle) top stories (RSS)',
-    domain: 'feeds.bloomberg.com',
-    strategy: Strategy.PUBLIC,
-    browser: false,
-    args: [
-        { name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
-    ],
-    columns: ['title', 'summary', 'link', 'mediaLinks'],
-    func: async (kwargs) => {
-        return fetchBloombergFeed('pursuits', kwargs.limit ?? 1);
-    },
-});
diff --git a/plugins/bloomberg/tech.js b/plugins/bloomberg/tech.js
deleted file mode 100644
index 7898bb83..00000000
--- a/plugins/bloomberg/tech.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { cli, Strategy } from '@agentrhq/webcmd/registry';
-import { fetchBloombergFeed } from './utils.js';
-cli({
-    site: 'bloomberg',
-    name: 'tech',
-    access: 'read',
-    description: 'Bloomberg Tech top stories (RSS)',
-    domain: 'feeds.bloomberg.com',
-    strategy: Strategy.PUBLIC,
-    browser: false,
-    args: [
-        { name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
-    ],
-    columns: ['title', 'summary', 'link', 'mediaLinks'],
-    func: async (kwargs) => {
-        return fetchBloombergFeed('tech', kwargs.limit ?? 1);
-    },
-});
diff --git a/plugins/bloomberg/test/businessweek.test.js b/plugins/bloomberg/test/businessweek.test.js
deleted file mode 100644
index 35cd38b7..00000000
--- a/plugins/bloomberg/test/businessweek.test.js
+++ /dev/null
@@ -1,149 +0,0 @@
-import { describe, expect, it, vi } from 'vitest';
-import { ArgumentError, CliError } from '@agentrhq/webcmd/errors';
-import { __test__ } from '../businessweek.js';
-
-const {
-    command,
-    extractBusinessweekStoriesFromNextData,
-    normalizeBusinessweekStoryPath,
-    parseBusinessweekLimit,
-} = __test__;
-
-function makePage(evaluateResults) {
-    const results = Array.isArray(evaluateResults) ? evaluateResults : [evaluateResults];
-    return {
-        goto: vi.fn().mockResolvedValue(undefined),
-        wait: vi.fn().mockResolvedValue(undefined),
-        evaluate: vi.fn()
-            .mockImplementation(() => Promise.resolve(results.shift())),
-    };
-}
-
-function nextDataWithItems(items) {
-    return {
-        props: {
-            pageProps: {
-                initialState: {
-                    modulesById: {
-                        lede_story_large: { items },
-                    },
-                },
-            },
-        },
-    };
-}
-
-describe('Bloomberg Businessweek section feed', () => {
-    it('registers as a public browser read command with stable columns', () => {
-        expect(command.site).toBe('bloomberg');
-        expect(command.name).toBe('businessweek');
-        expect(command.access).toBe('read');
-        expect(command.browser).toBe(true);
-        expect(command.strategy).toBe('public');
-        expect(command.domain).toBe('www.bloomberg.com');
-        expect(command.columns).toEqual(['title', 'summary', 'link', 'mediaLinks']);
-    });
-
-    it('validates --limit instead of silently clamping invalid values', () => {
-        expect(parseBusinessweekLimit(undefined)).toBe(1);
-        expect(parseBusinessweekLimit('')).toBe(1);
-        expect(parseBusinessweekLimit('20')).toBe(20);
-        expect(() => parseBusinessweekLimit(0)).toThrow(ArgumentError);
-        expect(() => parseBusinessweekLimit(21)).toThrow(ArgumentError);
-        expect(() => parseBusinessweekLimit(1.5)).toThrow(ArgumentError);
-        expect(() => parseBusinessweekLimit('abc')).toThrow(ArgumentError);
-    });
-
-    it('accepts Bloomberg news and feature story paths from the section page only', () => {
-        expect(normalizeBusinessweekStoryPath('/news/features/2026-06-08/story?srnd=phx-businessweek'))
-            .toBe('/news/features/2026-06-08/story?srnd=phx-businessweek');
-        expect(normalizeBusinessweekStoryPath('/features/2026-ice-detention-center/?srnd=phx-businessweek'))
-            .toBe('/features/2026-ice-detention-center/?srnd=phx-businessweek');
-        expect(normalizeBusinessweekStoryPath('https://www.bloomberg.com/features/2026-story/'))
-            .toBe('/features/2026-story/');
-        expect(normalizeBusinessweekStoryPath('https://example.com/features/2026-story/')).toBe('');
-        expect(normalizeBusinessweekStoryPath('/markets')).toBe('');
-        expect(normalizeBusinessweekStoryPath('javascript:alert(1)')).toBe('');
-    });
-
-    it('extracts current section-page stories, including /features paths, and dedupes by canonical path', () => {
-        const rows = extractBusinessweekStoriesFromNextData(nextDataWithItems([
-            {
-                headline: 'SpaceX IPO Demands Trust',
-                summary: 'A feature summary',
-                url: '/news/features/2026-06-08/spacex-ipo?srnd=phx-businessweek',
-                image: { baseUrl: 'https://assets.bwbx.io/spacex.jpg' },
-            },
-            {
-                headline: 'ICE Warehouse Jails',
-                eyebrow: { text: 'Feature' },
-                url: '/features/2026-dhs-pennsylvania-warehouse-ice-detention-center/?srnd=phx-businessweek',
-                lede: { url: 'https://assets.bwbx.io/ice.jpg' },
-            },
-            {
-                headline: 'Duplicate without query',
-                url: '/features/2026-dhs-pennsylvania-warehouse-ice-detention-center/',
-            },
-            {
-                headline: 'Non-story module link',
-                url: '/markets',
-            },
-        ]));
-
-        expect(rows).toEqual([
-            {
-                title: 'SpaceX IPO Demands Trust',
-                summary: 'A feature summary',
-                link: 'https://www.bloomberg.com/news/features/2026-06-08/spacex-ipo?srnd=phx-businessweek',
-                mediaLinks: ['https://assets.bwbx.io/spacex.jpg'],
-            },
-            {
-                title: 'ICE Warehouse Jails',
-                summary: 'Feature',
-                link: 'https://www.bloomberg.com/features/2026-dhs-pennsylvania-warehouse-ice-detention-center/?srnd=phx-businessweek',
-                mediaLinks: ['https://assets.bwbx.io/ice.jpg'],
-            },
-        ]);
-    });
-
-    it('returns rows from the browser section payload and respects validated limit', async () => {
-        const page = makePage({
-            ok: true,
-            stories: [
-                { title: 'One', summary: 'A', link: 'https://www.bloomberg.com/news/a', mediaLinks: [] },
-                { title: 'Two', summary: 'B', link: 'https://www.bloomberg.com/features/b', mediaLinks: [] },
-            ],
-        });
-
-        await expect(command.func(page, { limit: 1 })).resolves.toEqual([
-            { title: 'One', summary: 'A', link: 'https://www.bloomberg.com/news/a', mediaLinks: [] },
-        ]);
-        expect(page.goto).toHaveBeenCalledWith('https://www.bloomberg.com/businessweek');
-        expect(page.wait).toHaveBeenCalledWith({ selector: '#__NEXT_DATA__', timeout: 8 });
-    });
-
-    it('retries slow hydration diagnostics before failing', async () => {
-        const page = makePage([
-            { ok: false, error: 'NO_NEXT_DATA', title: 'Businessweek' },
-            {
-                ok: true,
-                stories: [
-                    { title: 'Hydrated', summary: '', link: 'https://www.bloomberg.com/news/hydrated', mediaLinks: [] },
-                ],
-            },
-        ]);
-
-        await expect(command.func(page, { limit: 5 })).resolves.toEqual([
-            { title: 'Hydrated', summary: '', link: 'https://www.bloomberg.com/news/hydrated', mediaLinks: [] },
-        ]);
-        expect(page.wait).toHaveBeenCalledWith(4);
-        expect(page.evaluate).toHaveBeenCalledTimes(2);
-    });
-
-    it('fails typed for malformed section payloads', async () => {
-        const page = makePage({ ok: false, error: 'NO_MODULES' });
-
-        await expect(command.func(page, { limit: 5 })).rejects.toBeInstanceOf(CliError);
-        await expect(command.func(makePage({ ok: true, stories: [] }), { limit: 5 })).rejects.toBeInstanceOf(CliError);
-    });
-});
diff --git a/plugins/bloomberg/test/utils.test.js b/plugins/bloomberg/test/utils.test.js
deleted file mode 100644
index bc367203..00000000
--- a/plugins/bloomberg/test/utils.test.js
+++ /dev/null
@@ -1,129 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { extractStoryMediaLinks, parseBloombergRss, renderStoryBody } from '../utils.js';
-describe('Bloomberg utils', () => {
-    it('parses Bloomberg RSS items with summary, link, and deduped media links', () => {
-        const xml = `<?xml version="1.0" encoding="UTF-8"?>
-      <rss xmlns:media="http://search.yahoo.com/mrss/">
-        <channel>
-          <item>
-            <title><![CDATA[Headline One]]>
-            One & more]]>
-            https://www.bloomberg.com/news/articles/2026-03-19/example-one
-            
-              
-            
-          
-          
-            Headline Two
-            Summary Two
-            https://www.bloomberg.com/news/articles/2026-03-19/example-two
-            
-          
-        
-      `;
-        const items = parseBloombergRss(xml);
-        expect(items).toHaveLength(2);
-        expect(items[0]).toEqual({
-            title: 'Headline One',
-            summary: 'Summary One & more',
-            link: 'https://www.bloomberg.com/news/articles/2026-03-19/example-one',
-            mediaLinks: ['https://assets.bwbx.io/example-one.jpg'],
-        });
-        expect(items[1]).toEqual({
-            title: 'Headline Two',
-            summary: 'Summary Two',
-            link: 'https://www.bloomberg.com/news/articles/2026-03-19/example-two',
-            mediaLinks: ['https://assets.bwbx.io/example-two.png'],
-        });
-    });
-    it('renders Bloomberg story rich-text body into readable text', () => {
-        const body = {
-            type: 'document',
-            content: [
-                { type: 'inline-newsletter', data: { position: 'top' }, content: [] },
-                {
-                    type: 'paragraph',
-                    data: {},
-                    content: [
-                        { type: 'text', value: 'Lead paragraph with ' },
-                        { type: 'entity', content: [{ type: 'text', value: 'linked text' }] },
-                        { type: 'text', value: '.' },
-                    ],
-                },
-                {
-                    type: 'heading',
-                    data: { level: 2 },
-                    content: [{ type: 'text', value: 'Key Points' }],
-                },
-                {
-                    type: 'list',
-                    data: { style: 'unordered' },
-                    content: [
-                        {
-                            type: 'list-item',
-                            content: [
-                                { type: 'paragraph', content: [{ type: 'text', value: 'Point one' }] },
-                            ],
-                        },
-                        {
-                            type: 'list-item',
-                            content: [
-                                { type: 'paragraph', content: [{ type: 'text', value: 'Point two' }] },
-                            ],
-                        },
-                    ],
-                },
-                {
-                    type: 'blockquote',
-                    content: [{ type: 'text', value: 'Quoted line' }],
-                },
-                {
-                    type: 'media',
-                    data: {
-                        attachment: {
-                            caption: '

Chart caption

', - }, - }, - }, - { type: 'ad', data: { num: 1 }, content: [] }, - ], - }; - expect(renderStoryBody(body)).toBe([ - 'Lead paragraph with linked text.', - '## Key Points', - '- Point one\n- Point two', - '> Quoted line', - 'Chart caption', - ].join('\n\n')); - }); - it('collects deduped story media links from lede, attachments, and body media', () => { - const story = { - ledeImageUrl: 'https://assets.bwbx.io/lede.webp', - lede: { url: 'https://assets.bwbx.io/lede.webp' }, - socialImageUrl: 'https://assets.bwbx.io/social.png', - imageAttachments: { - one: { url: 'https://assets.bwbx.io/figure.jpg' }, - }, - body: { - content: [ - { - type: 'media', - data: { - chart: { - src: 'https://resource.bloomberg.com/images/chart.png', - fallback: 'https://assets.bwbx.io/chart-fallback.png', - }, - }, - }, - ], - }, - }; - expect(extractStoryMediaLinks(story)).toEqual([ - 'https://assets.bwbx.io/lede.webp', - 'https://assets.bwbx.io/social.png', - 'https://assets.bwbx.io/figure.jpg', - 'https://resource.bloomberg.com/images/chart.png', - 'https://assets.bwbx.io/chart-fallback.png', - ]); - }); -}); diff --git a/plugins/bloomberg/utils.js b/plugins/bloomberg/utils.js deleted file mode 100644 index 33bd52ed..00000000 --- a/plugins/bloomberg/utils.js +++ /dev/null @@ -1,380 +0,0 @@ -import { CliError } from '@agentrhq/webcmd/errors'; -export const BLOOMBERG_FEEDS = { - main: 'https://feeds.bloomberg.com/news.rss', - markets: 'https://feeds.bloomberg.com/markets/news.rss', - economics: 'https://feeds.bloomberg.com/economics/news.rss', - industries: 'https://feeds.bloomberg.com/industries/news.rss', - tech: 'https://feeds.bloomberg.com/technology/news.rss', - politics: 'https://feeds.bloomberg.com/politics/news.rss', - opinions: 'https://feeds.bloomberg.com/bview/news.rss', - green: 'https://feeds.bloomberg.com/green/news.rss', - crypto: 'https://feeds.bloomberg.com/crypto/news.rss', - pursuits: 'https://feeds.bloomberg.com/pursuits/news.rss', -}; -// Note: the Businessweek RSS feed (feeds.bloomberg.com/businessweek/news.rss) is now served -// empty by Bloomberg, so the `businessweek` command reads the section page instead (see -// businessweek.js). Other sections still publish working RSS feeds. -const DEFAULT_USER_AGENT = 'Mozilla/5.0 (compatible; webcmd)'; -// Bloomberg's edge occasionally serves a transient empty/non-OK RSS response under load; a -// couple of quick retries turn those intermittent misses into a successful fetch instead of a -// hard NOT_FOUND. A feed that is genuinely empty still surfaces NOT_FOUND after the retries. -export async function fetchBloombergFeed(name, limit = 1) { - const feedUrl = BLOOMBERG_FEEDS[name]; - if (!feedUrl) { - throw new CliError('ARGUMENT', `Unknown Bloomberg feed: ${name}`); - } - let lastError; - for (let attempt = 0; attempt < 3; attempt += 1) { - if (attempt > 0) { - await new Promise((resolve) => setTimeout(resolve, 400 * attempt)); - } - const resp = await fetch(feedUrl, { - headers: { 'User-Agent': DEFAULT_USER_AGENT }, - }); - if (!resp.ok) { - lastError = new CliError('FETCH_ERROR', `Bloomberg RSS HTTP ${resp.status}`, 'Bloomberg may be temporarily unavailable; try again later.'); - continue; - } - const xml = await resp.text(); - const items = parseBloombergRss(xml); - if (items.length) { - const count = Math.max(1, Math.min(Number(limit) || 1, 20)); - return items.slice(0, count); - } - lastError = new CliError('NOT_FOUND', 'Bloomberg RSS feed returned no items', 'Bloomberg may have changed the feed format.'); - } - throw lastError; -} -export function parseBloombergRss(xml) { - const items = []; - const itemRegex = /]*>([\s\S]*?)<\/item>/gi; - let match; - while ((match = itemRegex.exec(xml))) { - const block = match[1]; - const title = extractTagText(block, 'title'); - const summary = extractTagText(block, 'description'); - const link = extractTagText(block, 'link') || extractTagText(block, 'guid'); - const mediaLinks = extractMediaLinksFromRssItem(block); - if (!title || !link) - continue; - items.push({ - title, - summary, - link, - mediaLinks, - }); - } - return items; -} -export function normalizeBloombergLink(input) { - const raw = String(input || '').trim(); - if (!raw) { - throw new CliError('ARGUMENT', 'A Bloomberg link is required'); - } - if (raw.startsWith('/')) - return `https://www.bloomberg.com${raw}`; - return raw; -} -export function validateBloombergLink(input) { - const normalized = normalizeBloombergLink(input); - let url; - try { - url = new URL(normalized); - } - catch { - throw new CliError('ARGUMENT', `Invalid Bloomberg link: ${input}`, 'Pass a full https://www.bloomberg.com/... URL or a relative Bloomberg path.'); - } - if (!/(?:\.|^)bloomberg\.com$/i.test(url.hostname)) { - throw new CliError('ARGUMENT', `Expected a bloomberg.com link, got: ${url.hostname}`, 'Pass a Bloomberg article URL from bloomberg.com.'); - } - return url.toString(); -} -export function renderStoryBody(body) { - const blocks = Array.isArray(body?.content) ? body.content : []; - const parts = blocks - .map((block) => renderBlock(block, 0)) - .map((part) => normalizeBlockText(part)) - .filter(Boolean); - return parts.join('\n\n').replace(/\n{3,}/g, '\n\n').trim(); -} -export function extractStoryMediaLinks(story) { - const urls = new Set(); - collectMediaUrls(story?.ledeImageUrl, urls); - collectMediaUrls(story?.socialImageUrl, urls); - collectMediaUrls(story?.lede, urls); - collectMediaUrls(story?.imageAttachments, urls); - collectMediaUrls(story?.videoAttachments, urls); - const mediaBlocks = Array.isArray(story?.body?.content) - ? story.body.content.filter((block) => block?.type === 'media') - : []; - collectMediaUrls(mediaBlocks, urls); - return [...urls]; -} -function renderBlock(block, depth) { - if (!block || typeof block !== 'object') - return ''; - switch (block.type) { - case 'paragraph': - return renderInlineNodes(block.content || []); - case 'heading': { - const text = renderInlineNodes(block.content || []); - if (!text) - return ''; - const level = Number(block.data?.level ?? block.data?.weight ?? 2); - const prefix = level <= 1 ? '# ' : level === 2 ? '## ' : '### '; - return `${prefix}${text}`; - } - case 'blockquote': { - const text = renderInlineNodes(block.content || []); - if (!text) - return ''; - return text.split('\n').map((line) => line ? `> ${line}` : '>').join('\n'); - } - case 'list': - return renderListBlock(block, depth); - case 'tabularData': - return renderTabularDataBlock(block); - case 'media': - return renderMediaBlock(block); - case 'inline-newsletter': - case 'newsletter': - case 'ad': - return ''; - default: { - if (Array.isArray(block.content) && block.content.length > 0) { - const inlineText = renderInlineNodes(block.content); - if (inlineText) - return inlineText; - const nested = block.content.map((child) => renderBlock(child, depth + 1)).filter(Boolean); - if (nested.length) - return nested.join('\n'); - } - return extractGenericText(block); - } - } -} -function renderInlineNodes(nodes) { - return nodes.map((node) => renderInlineNode(node)).join(''); -} -function renderInlineNode(node) { - if (node == null) - return ''; - if (typeof node === 'string') - return decodeXmlEntities(node); - switch (node.type) { - case 'text': - return decodeXmlEntities(node.value || ''); - case 'linebreak': - return '\n'; - case 'link': - case 'entity': - case 'strong': - case 'emphasis': - case 'italic': - case 'underline': - case 'span': - if (Array.isArray(node.content) && node.content.length > 0) { - return renderInlineNodes(node.content); - } - return decodeXmlEntities(node.value || ''); - default: - if (Array.isArray(node.content) && node.content.length > 0) { - return renderInlineNodes(node.content); - } - if (typeof node.value === 'string') - return decodeXmlEntities(node.value); - return ''; - } -} -function renderListBlock(block, depth) { - const items = Array.isArray(block.content) ? block.content : []; - if (!items.length) - return ''; - const listStyle = String(block.subType || block.data?.style || block.data?.listType || ''); - const ordered = /\bordered\b|\bnumber(?:ed)?\b/i.test(listStyle); - let index = 1; - return items - .map((item) => { - const prefix = ordered ? `${index++}. ` : '- '; - return renderListItem(item, prefix, depth); - }) - .filter(Boolean) - .join('\n'); -} -function renderListItem(item, prefix, depth) { - const indent = ' '.repeat(depth); - const body = normalizeBlockText(renderListItemBody(item, depth + 1)); - if (!body) - return ''; - const lines = body.split('\n'); - const head = `${indent}${prefix}${lines[0]}`; - if (lines.length === 1) - return head; - const continuationIndent = `${indent}${' '.repeat(prefix.length)}`; - const tail = lines.slice(1).map((line) => `${continuationIndent}${line}`).join('\n'); - return `${head}\n${tail}`; -} -function renderListItemBody(item, depth) { - if (!item || typeof item !== 'object') - return ''; - if (item.type === 'list-item' && Array.isArray(item.content)) { - const parts = item.content - .map((child) => child?.type === 'paragraph' - ? renderInlineNodes(child.content || []) - : renderBlock(child, depth)) - .map((part) => normalizeBlockText(part)) - .filter(Boolean); - return parts.join('\n'); - } - return renderBlock(item, depth); -} -function renderTabularDataBlock(block) { - const rows = block?.data?.rows ?? block?.data?.table?.rows ?? block?.content; - if (!Array.isArray(rows) || !rows.length) { - return extractGenericText(block.data || block.content || block); - } - const lines = rows - .map((row) => extractGenericText(row)) - .map((line) => normalizeBlockText(line)) - .filter(Boolean); - return lines.join('\n'); -} -function renderMediaBlock(block) { - const candidates = [ - block?.data?.chart?.caption, - block?.data?.attachment?.caption, - block?.data?.attachment?.title, - block?.data?.attachment?.subtitle, - block?.data?.video?.caption, - ]; - const caption = candidates - .map((value) => normalizeBlockText(stripHtml(String(value || '')))) - .find(Boolean); - return caption || ''; -} -function extractGenericText(value) { - const parts = []; - collectText(value, parts); - return parts.join(' ').replace(/\s+/g, ' ').trim(); -} -function collectText(value, out) { - if (value == null) - return; - if (typeof value === 'string') { - const text = normalizeBlockText(stripHtml(decodeXmlEntities(value))); - if (text) - out.push(text); - return; - } - if (Array.isArray(value)) { - for (const item of value) - collectText(item, out); - return; - } - if (typeof value === 'object') { - if (typeof value.value === 'string') { - const text = normalizeBlockText(stripHtml(decodeXmlEntities(value.value))); - if (text) - out.push(text); - return; - } - if (Array.isArray(value.content)) { - collectText(value.content, out); - return; - } - for (const entry of Object.values(value)) - collectText(entry, out); - } -} -function extractTagText(block, tag) { - const safeTag = escapeRegExp(tag); - const match = block.match(new RegExp(`<${safeTag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${safeTag}>`, 'i')); - if (!match) - return ''; - return normalizeBlockText(stripHtml(decodeXmlEntities(stripCdata(match[1])))); -} -function extractMediaLinksFromRssItem(block) { - const links = new Set(); - const mediaRegex = /<(?:media:content|media:thumbnail|enclosure)\b[^>]*\burl="([^"]+)"[^>]*>/gi; - let match; - while ((match = mediaRegex.exec(block))) { - const url = decodeXmlEntities(match[1] || '').trim(); - if (url) - links.add(url); - } - return [...links]; -} -function collectMediaUrls(value, out, seen = new WeakSet()) { - if (value == null) - return; - if (typeof value === 'string') { - const normalized = normalizeMediaUrl(value); - if (normalized) - out.add(normalized); - return; - } - if (Array.isArray(value)) { - for (const item of value) - collectMediaUrls(item, out, seen); - return; - } - if (typeof value === 'object') { - if (seen.has(value)) - return; - seen.add(value); - for (const key of ['url', 'src', 'fallback', 'poster']) { - const candidate = value[key]; - if (typeof candidate === 'string') { - const normalized = normalizeMediaUrl(candidate); - if (normalized) - out.add(normalized); - } - } - for (const entry of Object.values(value)) { - collectMediaUrls(entry, out, seen); - } - } -} -function normalizeMediaUrl(value) { - const url = decodeXmlEntities(String(value || '')).trim(); - if (!/^https?:\/\//i.test(url)) - return null; - if (!looksLikeMediaUrl(url)) - return null; - return url; -} -function looksLikeMediaUrl(url) { - return /(?:assets\.bwbx\.io|resource\.bloomberg\.com|media\.bloomberg\.com)/i.test(url) - || /\.(?:jpg|jpeg|png|webp|gif|svg|mp4|m3u8)(?:[?#].*)?$/i.test(url); -} -function stripCdata(value) { - const match = value.match(/^$/); - return match ? match[1] : value; -} -function stripHtml(value) { - return String(value || '').replace(/<[^>]+>/g, ' '); -} -function decodeXmlEntities(value) { - return String(value || '') - .replace(//g, '$1') - .replace(/&#(\d+);/g, (_m, code) => String.fromCodePoint(Number(code))) - .replace(/&#x([0-9a-f]+);/gi, (_m, code) => String.fromCodePoint(parseInt(code, 16))) - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/'/g, "'") - .replace(/ /g, ' '); -} -function normalizeBlockText(value) { - return String(value || '') - .replace(/\r/g, '') - .replace(/[ \t]+\n/g, '\n') - .replace(/\n[ \t]+/g, '\n') - .replace(/[ \t]{2,}/g, ' ') - .trim(); -} -function escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} diff --git a/plugins/bloomberg/webcmd-plugin.json b/plugins/bloomberg/webcmd-plugin.json deleted file mode 100644 index 1d2c9bdd..00000000 --- a/plugins/bloomberg/webcmd-plugin.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "bloomberg", - "version": "0.1.0", - "description": "Webcmd commands for bloomberg", - "webcmd": ">=0.5.3", - "author": { - "name": "WebCMD Agent", - "handle": "agentrhq" - } -} diff --git a/plugins/bluesky/README.md b/plugins/bluesky/README.md deleted file mode 100644 index 70a3f093..00000000 --- a/plugins/bluesky/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# webcmd-plugin-bluesky - -Webcmd commands for bluesky. - -## Install - -```bash -webcmd plugin install github:agentrhq/webcmd/bluesky -``` - -## Commands - -| Command | Description | -| --- | --- | -| `webcmd bluesky feeds` | Popular Bluesky feed generators | -| `webcmd bluesky followers` | List followers of a Bluesky user | -| `webcmd bluesky following` | List accounts a Bluesky user is following | -| `webcmd bluesky profile` | Get Bluesky user profile info | -| `webcmd bluesky search` | Search Bluesky users | -| `webcmd bluesky starter-packs` | Get starter packs created by a Bluesky user | -| `webcmd bluesky thread` | Get a Bluesky post thread with replies | -| `webcmd bluesky trending` | Trending topics on Bluesky | -| `webcmd bluesky user` | Get recent posts from a Bluesky user | diff --git a/plugins/bluesky/feeds.js b/plugins/bluesky/feeds.js deleted file mode 100644 index 03eb5c82..00000000 --- a/plugins/bluesky/feeds.js +++ /dev/null @@ -1,28 +0,0 @@ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -cli({ - site: 'bluesky', - name: 'feeds', - access: 'read', - description: 'Popular Bluesky feed generators', - domain: 'public.api.bsky.app', - strategy: Strategy.PUBLIC, - browser: false, - args: [ - { name: 'limit', type: 'int', default: 20, help: 'Number of feeds' }, - ], - columns: ['rank', 'name', 'likes', 'creator', 'description'], - pipeline: [ - { fetch: { - url: 'https://public.api.bsky.app/xrpc/app.bsky.unspecced.getPopularFeedGenerators?limit=${{ args.limit }}', - } }, - { select: 'feeds' }, - { map: { - rank: '${{ index + 1 }}', - name: '${{ item.displayName }}', - likes: '${{ item.likeCount }}', - creator: '${{ item.creator.handle }}', - description: '${{ item.description }}', - } }, - { limit: '${{ args.limit }}' }, - ], -}); diff --git a/plugins/bluesky/followers.js b/plugins/bluesky/followers.js deleted file mode 100644 index de2ddcad..00000000 --- a/plugins/bluesky/followers.js +++ /dev/null @@ -1,28 +0,0 @@ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -cli({ - site: 'bluesky', - name: 'followers', - access: 'read', - description: 'List followers of a Bluesky user', - domain: 'public.api.bsky.app', - strategy: Strategy.PUBLIC, - browser: false, - args: [ - { name: 'handle', required: true, positional: true, help: 'Bluesky handle' }, - { name: 'limit', type: 'int', default: 20, help: 'Number of followers' }, - ], - columns: ['rank', 'handle', 'name', 'description'], - pipeline: [ - { fetch: { - url: 'https://public.api.bsky.app/xrpc/app.bsky.graph.getFollowers?actor=${{ args.handle }}&limit=${{ args.limit }}', - } }, - { select: 'followers' }, - { map: { - rank: '${{ index + 1 }}', - handle: '${{ item.handle }}', - name: '${{ item.displayName }}', - description: '${{ item.description }}', - } }, - { limit: '${{ args.limit }}' }, - ], -}); diff --git a/plugins/bluesky/following.js b/plugins/bluesky/following.js deleted file mode 100644 index 029793e4..00000000 --- a/plugins/bluesky/following.js +++ /dev/null @@ -1,28 +0,0 @@ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -cli({ - site: 'bluesky', - name: 'following', - access: 'read', - description: 'List accounts a Bluesky user is following', - domain: 'public.api.bsky.app', - strategy: Strategy.PUBLIC, - browser: false, - args: [ - { name: 'handle', required: true, positional: true, help: 'Bluesky handle' }, - { name: 'limit', type: 'int', default: 20, help: 'Number of accounts' }, - ], - columns: ['rank', 'handle', 'name', 'description'], - pipeline: [ - { fetch: { - url: 'https://public.api.bsky.app/xrpc/app.bsky.graph.getFollows?actor=${{ args.handle }}&limit=${{ args.limit }}', - } }, - { select: 'follows' }, - { map: { - rank: '${{ index + 1 }}', - handle: '${{ item.handle }}', - name: '${{ item.displayName }}', - description: '${{ item.description }}', - } }, - { limit: '${{ args.limit }}' }, - ], -}); diff --git a/plugins/bluesky/package.json b/plugins/bluesky/package.json deleted file mode 100644 index 07d1f33b..00000000 --- a/plugins/bluesky/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "webcmd-plugin-bluesky", - "version": "0.1.0", - "type": "module", - "description": "Webcmd commands for bluesky", - "peerDependencies": { - "@agentrhq/webcmd": ">=0.5.3" - } -} diff --git a/plugins/bluesky/profile.js b/plugins/bluesky/profile.js deleted file mode 100644 index c0d614f0..00000000 --- a/plugins/bluesky/profile.js +++ /dev/null @@ -1,30 +0,0 @@ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -cli({ - site: 'bluesky', - name: 'profile', - access: 'read', - description: 'Get Bluesky user profile info', - domain: 'public.api.bsky.app', - strategy: Strategy.PUBLIC, - browser: false, - args: [ - { - name: 'handle', - required: true, - positional: true, - help: 'Bluesky handle (e.g. bsky.app, jay.bsky.team)', - }, - ], - columns: ['handle', 'name', 'followers', 'following', 'posts', 'description'], - pipeline: [ - { fetch: { url: 'https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${{ args.handle }}' } }, - { map: { - handle: '${{ item.handle }}', - name: '${{ item.displayName }}', - followers: '${{ item.followersCount }}', - following: '${{ item.followsCount }}', - posts: '${{ item.postsCount }}', - description: '${{ item.description }}', - } }, - ], -}); diff --git a/plugins/bluesky/search.js b/plugins/bluesky/search.js deleted file mode 100644 index 95c051b5..00000000 --- a/plugins/bluesky/search.js +++ /dev/null @@ -1,30 +0,0 @@ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -cli({ - site: 'bluesky', - name: 'search', - tags: ['search'], - access: 'read', - description: 'Search Bluesky users', - domain: 'public.api.bsky.app', - strategy: Strategy.PUBLIC, - browser: false, - args: [ - { name: 'query', required: true, positional: true, help: 'Search query' }, - { name: 'limit', type: 'int', default: 10, help: 'Number of results' }, - ], - columns: ['rank', 'handle', 'name', 'followers', 'description'], - pipeline: [ - { fetch: { - url: 'https://public.api.bsky.app/xrpc/app.bsky.actor.searchActors?q=${{ args.query }}&limit=${{ args.limit }}', - } }, - { select: 'actors' }, - { map: { - rank: '${{ index + 1 }}', - handle: '${{ item.handle }}', - name: '${{ item.displayName }}', - followers: '${{ item.followersCount }}', - description: '${{ item.description }}', - } }, - { limit: '${{ args.limit }}' }, - ], -}); diff --git a/plugins/bluesky/starter-packs.js b/plugins/bluesky/starter-packs.js deleted file mode 100644 index 87818801..00000000 --- a/plugins/bluesky/starter-packs.js +++ /dev/null @@ -1,29 +0,0 @@ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -cli({ - site: 'bluesky', - name: 'starter-packs', - access: 'read', - description: 'Get starter packs created by a Bluesky user', - domain: 'public.api.bsky.app', - strategy: Strategy.PUBLIC, - browser: false, - args: [ - { name: 'handle', required: true, positional: true, help: 'Bluesky handle' }, - { name: 'limit', type: 'int', default: 10, help: 'Number of starter packs' }, - ], - columns: ['rank', 'name', 'description', 'members', 'joins'], - pipeline: [ - { fetch: { - url: 'https://public.api.bsky.app/xrpc/app.bsky.graph.getActorStarterPacks?actor=${{ args.handle }}&limit=${{ args.limit }}', - } }, - { select: 'starterPacks' }, - { map: { - rank: '${{ index + 1 }}', - name: '${{ item.record.name }}', - description: '${{ item.record.description }}', - members: '${{ item.listItemCount }}', - joins: '${{ item.joinedAllTimeCount }}', - } }, - { limit: '${{ args.limit }}' }, - ], -}); diff --git a/plugins/bluesky/thread.js b/plugins/bluesky/thread.js deleted file mode 100644 index 142c2ffa..00000000 --- a/plugins/bluesky/thread.js +++ /dev/null @@ -1,31 +0,0 @@ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -cli({ - site: 'bluesky', - name: 'thread', - access: 'read', - description: 'Get a Bluesky post thread with replies', - domain: 'public.api.bsky.app', - strategy: Strategy.PUBLIC, - browser: false, - args: [ - { - name: 'uri', - required: true, - positional: true, - help: 'Post AT URI (at://did:.../app.bsky.feed.post/...) or bsky.app URL', - }, - { name: 'limit', type: 'int', default: 20, help: 'Number of replies' }, - ], - columns: ['author', 'text', 'likes', 'reposts', 'replies_count'], - pipeline: [ - { fetch: { url: 'https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=${{ args.uri }}&depth=2' } }, - { select: 'thread' }, - { map: { - author: '${{ item.post.author.handle }}', - text: '${{ item.post.record.text }}', - likes: '${{ item.post.likeCount }}', - reposts: '${{ item.post.repostCount }}', - replies_count: '${{ item.post.replyCount }}', - } }, - ], -}); diff --git a/plugins/bluesky/trending.js b/plugins/bluesky/trending.js deleted file mode 100644 index c208923c..00000000 --- a/plugins/bluesky/trending.js +++ /dev/null @@ -1,20 +0,0 @@ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -cli({ - site: 'bluesky', - name: 'trending', - access: 'read', - description: 'Trending topics on Bluesky', - domain: 'public.api.bsky.app', - strategy: Strategy.PUBLIC, - browser: false, - args: [ - { name: 'limit', type: 'int', default: 20, help: 'Number of topics' }, - ], - columns: ['rank', 'topic', 'link'], - pipeline: [ - { fetch: { url: 'https://public.api.bsky.app/xrpc/app.bsky.unspecced.getTrendingTopics' } }, - { select: 'topics' }, - { map: { rank: '${{ index + 1 }}', topic: '${{ item.topic }}', link: '${{ item.link }}' } }, - { limit: '${{ args.limit }}' }, - ], -}); diff --git a/plugins/bluesky/user.js b/plugins/bluesky/user.js deleted file mode 100644 index fd589409..00000000 --- a/plugins/bluesky/user.js +++ /dev/null @@ -1,35 +0,0 @@ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -cli({ - site: 'bluesky', - name: 'user', - access: 'read', - description: 'Get recent posts from a Bluesky user', - domain: 'public.api.bsky.app', - strategy: Strategy.PUBLIC, - browser: false, - args: [ - { - name: 'handle', - required: true, - positional: true, - help: 'Bluesky handle (e.g. bsky.app)', - }, - { name: 'limit', type: 'int', default: 20, help: 'Number of posts' }, - ], - columns: ['rank', 'uri', 'text', 'likes', 'reposts', 'replies'], - pipeline: [ - { fetch: { - url: 'https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=${{ args.handle }}&limit=${{ args.limit }}', - } }, - { select: 'feed' }, - { map: { - rank: '${{ index + 1 }}', - uri: '${{ item.post.uri }}', - text: '${{ item.post.record.text }}', - likes: '${{ item.post.likeCount }}', - reposts: '${{ item.post.repostCount }}', - replies: '${{ item.post.replyCount }}', - } }, - { limit: '${{ args.limit }}' }, - ], -}); diff --git a/plugins/bluesky/webcmd-plugin.json b/plugins/bluesky/webcmd-plugin.json deleted file mode 100644 index a0a2f351..00000000 --- a/plugins/bluesky/webcmd-plugin.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "bluesky", - "version": "0.1.0", - "description": "Webcmd commands for bluesky", - "webcmd": ">=0.5.3", - "author": { - "name": "WebCMD Agent", - "handle": "agentrhq" - } -} diff --git a/plugins/bmwblog/README.md b/plugins/bmwblog/README.md deleted file mode 100644 index c1506394..00000000 --- a/plugins/bmwblog/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# webcmd-plugin-bmwblog - -BMWBLOG article discovery commands for Webcmd. - -## Install - -```bash -webcmd plugin install github:agentrhq/webcmd/bmwblog -``` - -## Commands - -- `webcmd bmwblog latest` -- `webcmd bmwblog search ` -- `webcmd bmwblog article ` diff --git a/plugins/bmwblog/article.js b/plugins/bmwblog/article.js deleted file mode 100644 index 716055db..00000000 --- a/plugins/bmwblog/article.js +++ /dev/null @@ -1,32 +0,0 @@ -import { CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors'; -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { fetchPosts, mapArticle, parseArticleSlug } from './utils.js'; - -cli({ - site: 'bmwblog', - name: 'article', - access: 'read', - description: 'Read a BMWBLOG article by URL or slug', - domain: 'www.bmwblog.com', - strategy: Strategy.PUBLIC, - browser: false, - args: [ - { name: 'url-or-slug', required: true, positional: true, help: 'BMWBLOG article URL or slug' }, - ], - columns: ['title', 'date', 'author', 'sections', 'excerpt', 'url', 'content'], - func: async (args) => { - const slug = parseArticleSlug(args['url-or-slug']); - const posts = await fetchPosts({ slug, per_page: 1 }, 'bmwblog article'); - if (!posts.length) { - throw new EmptyResultError('bmwblog article', `Article "${slug}" was not found`); - } - const article = mapArticle(posts[0]); - if (!article.title || !article.url) { - throw new CommandExecutionError('bmwblog article returned an unexpected article shape'); - } - if (!article.content) { - throw new EmptyResultError('bmwblog article', `Article "${slug}" has no readable content`); - } - return [article]; - }, -}); diff --git a/plugins/bmwblog/latest.js b/plugins/bmwblog/latest.js deleted file mode 100644 index cff7cf2b..00000000 --- a/plugins/bmwblog/latest.js +++ /dev/null @@ -1,26 +0,0 @@ -import { EmptyResultError } from '@agentrhq/webcmd/errors'; -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { fetchPosts, mapPost, parseLimit } from './utils.js'; - -cli({ - site: 'bmwblog', - name: 'latest', - access: 'read', - description: 'List the latest BMWBLOG articles', - domain: 'www.bmwblog.com', - strategy: Strategy.PUBLIC, - browser: false, - args: [ - { name: 'limit', type: 'int', default: 10, help: 'Number of articles (1-50)' }, - ], - columns: ['rank', 'title', 'date', 'author', 'section', 'excerpt', 'url'], - func: async (args) => { - const limit = parseLimit(args.limit); - const posts = await fetchPosts({ per_page: limit, orderby: 'date', order: 'desc' }, 'bmwblog latest'); - const rows = posts.map((post, index) => mapPost(post, index + 1)).filter((row) => row.title && row.url); - if (!rows.length) { - throw new EmptyResultError('bmwblog latest', 'BMWBLOG returned no published articles'); - } - return rows; - }, -}); diff --git a/plugins/bmwblog/package.json b/plugins/bmwblog/package.json deleted file mode 100644 index 5f1c807f..00000000 --- a/plugins/bmwblog/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "webcmd-plugin-bmwblog", - "version": "0.1.0", - "type": "module", - "description": "BMWBLOG article discovery commands for Webcmd", - "peerDependencies": { - "@agentrhq/webcmd": ">=0.3.4" - } -} diff --git a/plugins/bmwblog/search.js b/plugins/bmwblog/search.js deleted file mode 100644 index b2db36e5..00000000 --- a/plugins/bmwblog/search.js +++ /dev/null @@ -1,29 +0,0 @@ -import { EmptyResultError } from '@agentrhq/webcmd/errors'; -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { fetchPosts, mapPost, parseLimit, requireQuery } from './utils.js'; - -cli({ - site: 'bmwblog', - name: 'search', - tags: ['search'], - access: 'read', - description: 'Search BMWBLOG articles', - domain: 'www.bmwblog.com', - strategy: Strategy.PUBLIC, - browser: false, - args: [ - { name: 'query', required: true, positional: true, help: 'Search query' }, - { name: 'limit', type: 'int', default: 10, help: 'Number of results (1-50)' }, - ], - columns: ['rank', 'title', 'date', 'author', 'section', 'excerpt', 'url'], - func: async (args) => { - const query = requireQuery(args.query); - const limit = parseLimit(args.limit); - const posts = await fetchPosts({ search: query, per_page: limit }, 'bmwblog search'); - const rows = posts.map((post, index) => mapPost(post, index + 1)).filter((row) => row.title && row.url); - if (!rows.length) { - throw new EmptyResultError('bmwblog search', `No BMWBLOG articles matched "${query}"`); - } - return rows; - }, -}); diff --git a/plugins/bmwblog/utils.js b/plugins/bmwblog/utils.js deleted file mode 100644 index 86882205..00000000 --- a/plugins/bmwblog/utils.js +++ /dev/null @@ -1,164 +0,0 @@ -import { ArgumentError, CommandExecutionError } from '@agentrhq/webcmd/errors'; - -const API_BASE = 'https://www.bmwblog.com/wp-json/wp/v2/posts'; -const MIN_LIMIT = 1; -const MAX_LIMIT = 50; - -export function parseLimit(raw, fallback = 10) { - if (raw === undefined || raw === null || raw === '') return fallback; - const value = Number(raw); - if (!Number.isInteger(value) || value < MIN_LIMIT || value > MAX_LIMIT) { - throw new ArgumentError(`--limit must be an integer between ${MIN_LIMIT} and ${MAX_LIMIT}, got ${JSON.stringify(raw)}`); - } - return value; -} - -export function requireQuery(raw) { - const query = String(raw ?? '').trim(); - if (!query) throw new ArgumentError('Search query cannot be empty'); - return query; -} - -export function parseArticleSlug(raw) { - const value = String(raw ?? '').trim(); - if (!value) throw new ArgumentError('Article URL or slug cannot be empty'); - - let slug = value; - if (/^https?:\/\//i.test(value)) { - let parsed; - try { - parsed = new URL(value); - } catch { - throw new ArgumentError(`Invalid article URL: ${value}`); - } - if (!['bmwblog.com', 'www.bmwblog.com'].includes(parsed.hostname.toLowerCase())) { - throw new ArgumentError(`Article URL must be on bmwblog.com, got ${parsed.hostname}`); - } - const parts = parsed.pathname.split('/').filter(Boolean); - slug = parts.at(-1) || ''; - } - - try { - slug = decodeURIComponent(slug); - } catch { - throw new ArgumentError(`Invalid article slug: ${slug}`); - } - if (!/^[a-z0-9][a-z0-9-]*$/i.test(slug)) { - throw new ArgumentError(`Invalid BMWBLOG article slug: ${slug}`); - } - return slug.toLowerCase(); -} - -export async function fetchPosts(params, command) { - const url = new URL(API_BASE); - for (const [key, value] of Object.entries(params)) { - url.searchParams.set(key, String(value)); - } - - let response; - try { - response = await fetch(url, { - headers: { - Accept: 'application/json', - 'User-Agent': 'webcmd/1.0 (+https://github.com/agentrhq/webcmd)', - }, - }); - } catch (error) { - throw new CommandExecutionError(`${command} request failed: ${error?.message || error}`); - } - - if (!response.ok) { - throw new CommandExecutionError(`${command} request failed: HTTP ${response.status}`); - } - const contentType = response.headers.get('content-type') || ''; - if (!contentType.toLowerCase().includes('application/json')) { - throw new CommandExecutionError(`${command} returned an unexpected non-JSON response`); - } - - let data; - try { - data = await response.json(); - } catch (error) { - throw new CommandExecutionError(`${command} returned invalid JSON: ${error?.message || error}`); - } - if (!Array.isArray(data)) { - throw new CommandExecutionError(`${command} returned an unexpected response shape`); - } - return data; -} - -function decodeEntities(value) { - const named = { - amp: '&', apos: "'", gt: '>', hellip: '…', laquo: '«', ldquo: '“', - lsquo: '‘', lt: '<', mdash: '—', nbsp: ' ', ndash: '–', quot: '"', - raquo: '»', rdquo: '”', rsquo: '’', shy: '', - }; - return String(value ?? '').replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, entity) => { - if (entity[0] === '#') { - const hex = entity[1]?.toLowerCase() === 'x'; - const code = Number.parseInt(entity.slice(hex ? 2 : 1), hex ? 16 : 10); - return Number.isFinite(code) ? String.fromCodePoint(code) : match; - } - return named[entity.toLowerCase()] ?? match; - }); -} - -export function htmlToText(html) { - return decodeEntities(String(html ?? '') - .replace(/<(script|style|iframe|svg|figure)\b[^>]*>[\s\S]*?<\/\1>/gi, ' ') - .replace(//gi, '\n') - .replace(/<\/(p|div|h[1-6]|li|blockquote)>/gi, '\n') - .replace(/]*>/gi, '- ') - .replace(/<[^>]+>/g, ' ')) - .replace(/[ \t]+/g, ' ') - .replace(/ *\n */g, '\n') - .replace(/\n{3,}/g, '\n\n') - .trim(); -} - -function articleSchema(post) { - const graph = post?.yoast_head_json?.schema?.['@graph']; - return Array.isArray(graph) - ? graph.find((entry) => entry?.['@type'] === 'Article') || null - : null; -} - -function authorName(post, article) { - if (typeof article?.author?.name === 'string') return article.author.name.trim(); - const author = post?.yoast_head_json?.author; - return typeof author === 'string' ? author.trim() : ''; -} - -function datePublished(post, article) { - if (article?.datePublished) return article.datePublished; - if (post?.date_gmt) return `${post.date_gmt}Z`; - return post?.date || ''; -} - -export function mapPost(post, rank) { - const article = articleSchema(post); - const sections = Array.isArray(article?.articleSection) ? article.articleSection.filter(Boolean) : []; - return { - rank, - title: htmlToText(post?.title?.rendered), - date: datePublished(post, article), - author: authorName(post, article), - section: sections.join(', '), - excerpt: htmlToText(post?.excerpt?.rendered), - url: String(post?.link || article?.mainEntityOfPage?.['@id'] || '').trim(), - }; -} - -export function mapArticle(post) { - const article = articleSchema(post); - const sections = Array.isArray(article?.articleSection) ? article.articleSection.filter(Boolean) : []; - return { - title: htmlToText(post?.title?.rendered), - date: datePublished(post, article), - author: authorName(post, article), - sections: sections.join(', '), - excerpt: htmlToText(post?.excerpt?.rendered), - url: String(post?.link || article?.mainEntityOfPage?.['@id'] || '').trim(), - content: htmlToText(post?.content?.rendered), - }; -} diff --git a/plugins/bmwblog/webcmd-plugin.json b/plugins/bmwblog/webcmd-plugin.json deleted file mode 100644 index 74449836..00000000 --- a/plugins/bmwblog/webcmd-plugin.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "bmwblog", - "version": "0.1.0", - "description": "BMWBLOG article discovery commands for Webcmd", - "webcmd": ">=0.3.4", - "author": { - "name": "WebCMD Agent", - "handle": "agentrhq" - } -} diff --git a/plugins/booking/README.md b/plugins/booking/README.md deleted file mode 100644 index f88bb8b2..00000000 --- a/plugins/booking/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# webcmd-plugin-booking - -Webcmd commands for booking. - -## Install - -```bash -webcmd plugin install github:agentrhq/webcmd/booking -``` - -## Commands - -| Command | Description | -| --- | --- | -| `webcmd booking search` | Search Booking.com hotels by destination and dates (server-rendered card scrape). | diff --git a/plugins/booking/package.json b/plugins/booking/package.json deleted file mode 100644 index cd6666ee..00000000 --- a/plugins/booking/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "webcmd-plugin-booking", - "version": "0.1.0", - "type": "module", - "description": "Webcmd commands for booking", - "peerDependencies": { - "@agentrhq/webcmd": ">=0.5.3" - } -} diff --git a/plugins/booking/search.js b/plugins/booking/search.js deleted file mode 100644 index 394fcf63..00000000 --- a/plugins/booking/search.js +++ /dev/null @@ -1,352 +0,0 @@ -import { - ArgumentError, - CommandExecutionError, - EmptyResultError, -} from '@agentrhq/webcmd/errors'; -import { cli, Strategy } from '@agentrhq/webcmd/registry'; - -const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; - -function normalizePositiveInt(value, defaultValue, label, max) { - const raw = value ?? defaultValue; - const n = Number(raw); - if (!Number.isInteger(n) || n <= 0) { - throw new ArgumentError(`${label} must be a positive integer`); - } - if (typeof max === 'number' && n > max) { - throw new ArgumentError(`${label} must be <= ${max}`); - } - return n; -} - -function normalizeNonNegativeInt(value, defaultValue, label, max) { - const raw = value ?? defaultValue; - const n = Number(raw); - if (!Number.isInteger(n) || n < 0) { - throw new ArgumentError(`${label} must be a non-negative integer`); - } - if (typeof max === 'number' && n > max) { - throw new ArgumentError(`${label} must be <= ${max}`); - } - return n; -} - -function normalizeDate(value, label) { - const v = String(value || '').trim(); - if (!v) { - throw new ArgumentError(`${label} is required (YYYY-MM-DD)`); - } - if (!DATE_RE.test(v)) { - throw new ArgumentError(`${label} must be YYYY-MM-DD, got ${JSON.stringify(value)}`); - } - const [year, month, day] = v.split('-').map(Number); - const d = new Date(Date.UTC(year, month - 1, day)); - if ( - Number.isNaN(d.getTime()) || - d.getUTCFullYear() !== year || - d.getUTCMonth() !== month - 1 || - d.getUTCDate() !== day - ) { - throw new ArgumentError(`${label} is not a valid calendar date: ${v}`); - } - return v; -} - -function normalizeCurrency(value) { - if (value == null || value === '') return ''; - const v = String(value).trim().toUpperCase(); - if (!/^[A-Z]{3}$/.test(v)) { - throw new ArgumentError(`currency must be a 3-letter ISO code (e.g. USD, JPY, CNY), got ${JSON.stringify(value)}`); - } - return v; -} - -const ALLOWED_LANGS = new Set([ - 'en-us', 'en-gb', 'zh-cn', 'zh-tw', 'ja', 'ko', 'de', 'fr', 'es', 'it', - 'pt-br', 'pt-pt', 'ru', 'th', 'vi', 'tr', 'pl', 'nl', 'ar', -]); - -function normalizeLang(value) { - if (value == null || value === '') return ''; - const v = String(value).trim().toLowerCase(); - if (!ALLOWED_LANGS.has(v)) { - throw new ArgumentError(`lang must be one of: ${[...ALLOWED_LANGS].join(', ')}`); - } - return v; -} - -function hasPositiveResultCount(text) { - const value = String(text || '').replace(/\u00a0/g, ' '); - const resultCount = value.match(/\b([1-9][0-9,.\s]*)\s+(?:properties|property|stays|stay|hotels|hotel)\b/i); - if (!resultCount) return false; - const digits = resultCount[1].replace(/\D/g, ''); - return Boolean(digits) && Number(digits) > 0; -} - -function buildSearchUrl({ - destination, - checkin, - checkout, - adults, - rooms, - children, - offset, - currency, - lang, -}) { - const file = lang ? `searchresults.${lang}.html` : 'searchresults.html'; - const params = new URLSearchParams(); - params.set('ss', destination); - params.set('checkin', checkin); - params.set('checkout', checkout); - params.set('group_adults', String(adults)); - params.set('no_rooms', String(rooms)); - params.set('group_children', String(children)); - if (offset > 0) params.set('offset', String(offset)); - if (currency) params.set('selected_currency', currency); - return `https://www.booking.com/${file}?${params.toString()}`; -} - -const EXTRACTOR = ` - (() => { - const trim = (v) => (v == null ? '' : String(v).replace(/\\s+/g, ' ').trim()); - const cards = Array.from(document.querySelectorAll('[data-testid=property-card]')); - - // Detect blocking / captcha pages: no cards but body shows a verification prompt. - if (cards.length === 0) { - const text = [ - (document.title || ''), - (document.body && document.body.innerText) || '', - (location && location.pathname) || '', - ].join(' '); - const blocked = /captcha|challenge|verify\\s*you\\s*are|access\\s*denied|forbidden|robot|unusual\\s*traffic/i.test(text); - const totalEl = document.querySelector('h1'); - const totalText = trim(totalEl && totalEl.textContent); - return { ok: true, items: [], blocked, totalText }; - } - - const items = cards.map((card) => { - const titleEl = card.querySelector('[data-testid=title]'); - const link = card.querySelector('a[data-testid=title-link]'); - const href = (link && link.href) || ''; - let country = ''; - let slug = ''; - let canonicalUrl = ''; - try { - const u = new URL(href, 'https://www.booking.com'); - const m = u.pathname.match(/^\\/hotel\\/([a-z]{2})\\/([^./]+)/); - if (m) { - country = m[1]; - slug = m[2]; - canonicalUrl = 'https://www.booking.com/hotel/' + country + '/' + slug + '.html'; - } - } catch (_) {} - - const reviewTextRaw = trim(card.querySelector('[data-testid=review-score]')?.textContent); - // Booking renders the score twice (a11y + visual), text reads like "Scored 8.6 8.6 Very Good 6,151 reviews" - // or "rating8.68.6great 6,151guest reviews". Take only the first numeric occurrence. - const scoreMatch = reviewTextRaw.match(/(\\d{1,2})\\.(\\d)/); - const reviewScore = scoreMatch ? Number(scoreMatch[1] + '.' + scoreMatch[2]) : null; - - const countMatch = reviewTextRaw.match(/([0-9][0-9,]*)\\s*(?:reviews|reseñas|avis|recensioni|guest reviews|comments|レビュー|리뷰)/i); - const reviewCount = countMatch ? Number(countMatch[1].replace(/,/g, '')) : null; - - // Star rating: aria-label often "5 out of 5" / "4 localized text (full score 5 localized text)" / "Hôtel 4 étoiles" - let starRating = null; - const starEl = card.querySelector('[data-testid=rating-stars], [data-testid=quality-rating]'); - if (starEl) { - const aria = starEl.getAttribute('aria-label') || starEl.textContent || ''; - const m = aria.match(/(\\d)(?:\\s*(?:out of|\\/|localized text|stars|stars?|étoiles?)|\\s*$)/i); - if (m) starRating = Number(m[1]); - if (starRating == null) { - const count = starEl.querySelectorAll('svg, [aria-hidden=true]').length; - if (count >= 1 && count <= 5) starRating = count; - } - } - - const priceEl = card.querySelector('[data-testid=price-and-discounted-price]'); - const priceText = trim(priceEl && priceEl.textContent); - - // currency symbol → ISO best-effort - const currencySymbolMap = { - '$': 'USD', 'US$': 'USD', 'A$': 'AUD', 'C$': 'CAD', 'HK$': 'HKD', - '€': 'EUR', '£': 'GBP', '¥': 'JPY', '¥': 'CNY', '₹': 'INR', '₩': 'KRW', - 'CN¥': 'CNY', 'CN¥': 'CNY', 'NT$': 'TWD', 'S$': 'SGD', - }; - let priceCurrency = ''; - let priceAmount = null; - const sym = priceText.match(/(US\\$|A\\$|C\\$|HK\\$|NT\\$|S\\$|CN¥|CN¥|[$€£¥¥₹₩])/); - if (sym) priceCurrency = currencySymbolMap[sym[1]] || ''; - const num = priceText.replace(/,/g, '').match(/(\\d+(?:\\.\\d+)?)/); - if (num) priceAmount = Number(num[1]); - - return { - name: trim(titleEl?.textContent), - country, - slug, - url: canonicalUrl, - distance: trim(card.querySelector('[data-testid=distance]')?.textContent), - review_score: reviewScore, - review_count: reviewCount, - star_rating: starRating, - price_currency: priceCurrency, - price_amount: priceAmount, - recommended_room: trim(card.querySelector('[data-testid=recommended-units]')?.textContent), - }; - }); - - const totalEl = document.querySelector('h1'); - const totalText = trim(totalEl && totalEl.textContent); - return { ok: true, items, blocked: false, totalText }; - })() -`; - -cli({ - site: 'booking', - name: 'search', - tags: ['search'], - description: 'Search Booking.com hotels by destination and dates (server-rendered card scrape).', - access: 'read', - example: 'webcmd booking search Tokyo --checkin 2026-06-15 --checkout 2026-06-17 -f yaml', - domain: 'www.booking.com', - strategy: Strategy.PUBLIC, - browser: true, - args: [ - { name: 'destination', required: true, positional: true, help: 'Destination keyword (city, district, or hotel name)' }, - { name: 'checkin', required: true, help: 'Check-in date YYYY-MM-DD' }, - { name: 'checkout', required: true, help: 'Check-out date YYYY-MM-DD' }, - { name: 'adults', type: 'int', default: 2, help: 'Number of adults (1-30)' }, - { name: 'rooms', type: 'int', default: 1, help: 'Number of rooms (1-30)' }, - { name: 'children', type: 'int', default: 0, help: 'Number of children (0-10)' }, - { name: 'currency', required: false, help: 'Force result currency (e.g. USD, JPY, CNY)' }, - { name: 'lang', required: false, help: 'Force result language (e.g. en-us, zh-cn, ja)' }, - { name: 'limit', type: 'int', default: 25, help: 'Max rows to return (1-100; Booking pages 25 per request)' }, - { name: 'offset', type: 'int', default: 0, help: 'Result offset for pagination (multiple of 25)' }, - ], - columns: [ - 'rank', - 'name', - 'country', - 'slug', - 'star_rating', - 'review_score', - 'review_count', - 'price_amount', - 'price_currency', - 'distance', - 'recommended_room', - 'url', - ], - func: async (page, kwargs) => { - const destination = String(kwargs.destination || '').trim(); - if (!destination) throw new ArgumentError('destination is required'); - const checkin = normalizeDate(kwargs.checkin, 'checkin'); - const checkout = normalizeDate(kwargs.checkout, 'checkout'); - if (checkin >= checkout) { - throw new ArgumentError(`checkout (${checkout}) must be after checkin (${checkin})`); - } - const adults = normalizePositiveInt(kwargs.adults, 2, 'adults', 30); - const rooms = normalizePositiveInt(kwargs.rooms, 1, 'rooms', 30); - const children = normalizeNonNegativeInt(kwargs.children, 0, 'children', 10); - const currency = normalizeCurrency(kwargs.currency); - const lang = normalizeLang(kwargs.lang); - const limit = normalizePositiveInt(kwargs.limit, 25, 'limit', 100); - const offset = normalizeNonNegativeInt(kwargs.offset, 0, 'offset', 1000); - - const url = buildSearchUrl({ destination, checkin, checkout, adults, rooms, children, offset, currency, lang }); - - try { - await page.goto(url); - } catch (err) { - throw new CommandExecutionError(`Failed to load Booking.com search page: ${err?.message || err}`); - } - - // Booking lazy-loads price cells; wait for at least the first card price to settle. - try { - await page.wait('selector', '[data-testid=property-card]', { timeoutMs: 20000 }); - } catch (_) { - // selector wait is best-effort — extractor handles empty case explicitly - } - - let raw; - try { - raw = await page.evaluate(EXTRACTOR); - } catch (err) { - throw new CommandExecutionError(`Failed to extract Booking.com cards: ${err?.message || err}`); - } - - if (raw && typeof raw === 'object' && raw.data && raw.session) { - raw = raw.data; - } - if (!raw || typeof raw !== 'object') { - throw new CommandExecutionError('Booking.com page returned no extractable data'); - } - if (raw.blocked) { - throw new CommandExecutionError('Booking.com served a verification / captcha page; retry later or change profile'); - } - - if (raw.ok !== true) { - throw new CommandExecutionError('Booking.com extractor returned an invalid status'); - } - if (!Array.isArray(raw.items)) { - throw new CommandExecutionError('Booking.com extractor returned malformed items'); - } - - const items = raw.items; - if (items.length === 0) { - const totalText = String(raw.totalText || '').trim(); - if (hasPositiveResultCount(totalText)) { - throw new CommandExecutionError( - `Booking.com page declared results but no property cards were parsed: ${totalText}`, - ); - } - throw new EmptyResultError( - `booking search ${JSON.stringify(destination)}`, - totalText - ? `No hotels rendered (${totalText}). Try a broader destination, different dates, or check the URL in a browser.` - : 'No hotels rendered. Try a broader destination, different dates, or check the URL in a browser.', - ); - } - - return items.slice(0, limit).map((it, i) => { - if (!it || typeof it !== 'object') { - throw new CommandExecutionError('Booking.com extractor returned malformed hotel row'); - } - const name = String(it.name || '').trim(); - const country = String(it.country || '').trim(); - const slug = String(it.slug || '').trim(); - const urlValue = String(it.url || '').trim(); - const expectedUrl = country && slug - ? `https://www.booking.com/hotel/${country}/${slug}.html` - : ''; - if (!name || !/^[a-z]{2}$/.test(country) || !slug || urlValue !== expectedUrl) { - throw new CommandExecutionError('Booking.com hotel row is missing stable name/url identity'); - } - return { - rank: offset + i + 1, - name, - country, - slug, - star_rating: it.star_rating, - review_score: it.review_score, - review_count: it.review_count, - price_amount: it.price_amount, - price_currency: it.price_amount == null ? '' : (currency || it.price_currency || ''), - distance: it.distance, - recommended_room: it.recommended_room, - url: urlValue, - }; - }); - }, -}); - -export const __test__ = { - normalizePositiveInt, - normalizeNonNegativeInt, - normalizeDate, - normalizeCurrency, - normalizeLang, - hasPositiveResultCount, - buildSearchUrl, - EXTRACTOR, -}; diff --git a/plugins/booking/test/booking.test.js b/plugins/booking/test/booking.test.js deleted file mode 100644 index b555754e..00000000 --- a/plugins/booking/test/booking.test.js +++ /dev/null @@ -1,356 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { ArgumentError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors'; -import { getRegistry } from '@agentrhq/webcmd/registry'; -import '../search.js'; -import { __test__ } from '../search.js'; - -const { - normalizePositiveInt, - normalizeNonNegativeInt, - normalizeDate, - normalizeCurrency, - normalizeLang, - hasPositiveResultCount, - buildSearchUrl, -} = __test__; - -describe('booking helpers — normalizePositiveInt (no silent clamp)', () => { - it('returns default when value is undefined/null/empty', () => { - expect(normalizePositiveInt(undefined, 2, 'adults', 30)).toBe(2); - expect(normalizePositiveInt(null, 2, 'adults', 30)).toBe(2); - }); - - it('accepts integers in range', () => { - expect(normalizePositiveInt(1, 2, 'adults', 30)).toBe(1); - expect(normalizePositiveInt(30, 2, 'adults', 30)).toBe(30); - }); - - it('rejects zero / negative / out-of-range / non-integer (no silent clamp)', () => { - expect(() => normalizePositiveInt(0, 2, 'adults', 30)).toThrow(ArgumentError); - expect(() => normalizePositiveInt(-1, 2, 'adults', 30)).toThrow(ArgumentError); - expect(() => normalizePositiveInt(31, 2, 'adults', 30)).toThrow(ArgumentError); - expect(() => normalizePositiveInt(1.5, 2, 'adults', 30)).toThrow(ArgumentError); - expect(() => normalizePositiveInt('abc', 2, 'adults', 30)).toThrow(ArgumentError); - }); -}); - -describe('booking helpers — normalizeNonNegativeInt', () => { - it('accepts zero', () => { - expect(normalizeNonNegativeInt(0, 0, 'children', 10)).toBe(0); - }); - - it('rejects negative / out-of-range (no silent clamp)', () => { - expect(() => normalizeNonNegativeInt(-1, 0, 'children', 10)).toThrow(ArgumentError); - expect(() => normalizeNonNegativeInt(11, 0, 'children', 10)).toThrow(ArgumentError); - }); -}); - -describe('booking helpers — normalizeDate', () => { - it('accepts YYYY-MM-DD', () => { - expect(normalizeDate('2026-06-15', 'checkin')).toBe('2026-06-15'); - }); - - it('rejects bad format / nonsense dates with ArgumentError', () => { - expect(() => normalizeDate('', 'checkin')).toThrow(ArgumentError); - expect(() => normalizeDate('06/15/2026', 'checkin')).toThrow(ArgumentError); - expect(() => normalizeDate('2026-13-40', 'checkin')).toThrow(ArgumentError); - expect(() => normalizeDate('2026-02-31', 'checkin')).toThrow(ArgumentError); - }); -}); - -describe('booking helpers — normalizeCurrency', () => { - it('passes 3-letter codes uppercased', () => { - expect(normalizeCurrency('usd')).toBe('USD'); - expect(normalizeCurrency('JPY')).toBe('JPY'); - }); - - it('returns empty for unset', () => { - expect(normalizeCurrency(undefined)).toBe(''); - expect(normalizeCurrency('')).toBe(''); - }); - - it('rejects non-3-letter codes', () => { - expect(() => normalizeCurrency('US')).toThrow(ArgumentError); - expect(() => normalizeCurrency('US$')).toThrow(ArgumentError); - expect(() => normalizeCurrency('USDX')).toThrow(ArgumentError); - }); -}); - -describe('booking helpers — normalizeLang whitelist', () => { - it('lowercases supported langs', () => { - expect(normalizeLang('EN-US')).toBe('en-us'); - expect(normalizeLang('zh-cn')).toBe('zh-cn'); - }); - - it('rejects unknown langs', () => { - expect(() => normalizeLang('xx-yy')).toThrow(ArgumentError); - expect(() => normalizeLang('en')).toThrow(ArgumentError); - }); -}); - -describe('booking helpers — buildSearchUrl', () => { - it('constructs canonical search URL with required params', () => { - const url = buildSearchUrl({ - destination: 'Tokyo', - checkin: '2026-06-15', - checkout: '2026-06-17', - adults: 2, - rooms: 1, - children: 0, - offset: 0, - currency: 'USD', - lang: 'en-us', - }); - expect(url).toContain('https://www.booking.com/searchresults.en-us.html'); - expect(url).toContain('ss=Tokyo'); - expect(url).toContain('checkin=2026-06-15'); - expect(url).toContain('checkout=2026-06-17'); - expect(url).toContain('group_adults=2'); - expect(url).toContain('no_rooms=1'); - expect(url).toContain('group_children=0'); - expect(url).toContain('selected_currency=USD'); - expect(url).not.toContain('offset='); - }); - - it('omits lang file segment when lang is empty', () => { - const url = buildSearchUrl({ - destination: 'Paris', checkin: '2026-06-15', checkout: '2026-06-17', - adults: 2, rooms: 1, children: 0, offset: 0, currency: '', lang: '', - }); - expect(url).toMatch(/booking\.com\/searchresults\.html\?/); - }); - - it('emits offset only when > 0', () => { - const url = buildSearchUrl({ - destination: 'Paris', checkin: '2026-06-15', checkout: '2026-06-17', - adults: 2, rooms: 1, children: 0, offset: 25, currency: '', lang: '', - }); - expect(url).toContain('offset=25'); - }); -}); - -describe('booking helpers — hasPositiveResultCount', () => { - it('detects positive Booking result-count evidence', () => { - expect(hasPositiveResultCount('Tokyo: 1,234 properties found')).toBe(true); - expect(hasPositiveResultCount('1 stay found')).toBe(true); - }); - - it('does not treat no-results text as positive evidence', () => { - expect(hasPositiveResultCount('No properties found')).toBe(false); - expect(hasPositiveResultCount('0 properties found')).toBe(false); - }); -}); - -describe('booking adapter registry shape', () => { - it('search is registered as read with id-shaped column for round-trip', () => { - const search = getRegistry().get('booking/search'); - expect(search).toBeDefined(); - expect(search.access).toBe('read'); - expect(search.browser).toBe(true); - // slug + country together form the round-trip identity (URL: /hotel//.html) - expect(search.columns).toContain('slug'); - expect(search.columns).toContain('country'); - expect(search.columns).toContain('url'); - }); - - it('search columns stay <= 12 to honor agent-native row shape', () => { - const search = getRegistry().get('booking/search'); - expect(search.columns.length).toBeLessThanOrEqual(12); - }); -}); - -describe('booking search — typed errors (no silent fallback)', () => { - const fakePage = { goto: () => { throw new Error('should not navigate'); } }; - - it('rejects empty destination with ArgumentError', async () => { - const search = getRegistry().get('booking/search'); - await expect(search.func(fakePage, { destination: ' ', checkin: '2026-06-15', checkout: '2026-06-17' })).rejects.toThrow(ArgumentError); - }); - - it('rejects missing checkin/checkout with ArgumentError', async () => { - const search = getRegistry().get('booking/search'); - await expect(search.func(fakePage, { destination: 'Tokyo' })).rejects.toThrow(ArgumentError); - await expect(search.func(fakePage, { destination: 'Tokyo', checkin: '2026-06-15' })).rejects.toThrow(ArgumentError); - }); - - it('rejects checkout <= checkin with ArgumentError', async () => { - const search = getRegistry().get('booking/search'); - await expect(search.func(fakePage, { destination: 'Tokyo', checkin: '2026-06-17', checkout: '2026-06-15' })).rejects.toThrow(ArgumentError); - await expect(search.func(fakePage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-15' })).rejects.toThrow(ArgumentError); - }); - - it('rejects out-of-range --limit with ArgumentError (no silent clamp to 100)', async () => { - const search = getRegistry().get('booking/search'); - await expect(search.func(fakePage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17', limit: 999 })).rejects.toThrow(ArgumentError); - }); - - it('rejects negative --offset with ArgumentError', async () => { - const search = getRegistry().get('booking/search'); - await expect(search.func(fakePage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17', offset: -1 })).rejects.toThrow(ArgumentError); - }); - - it('rejects unsupported --lang with ArgumentError', async () => { - const search = getRegistry().get('booking/search'); - await expect(search.func(fakePage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17', lang: 'xx-yy' })).rejects.toThrow(ArgumentError); - }); - - it('rejects malformed --currency with ArgumentError', async () => { - const search = getRegistry().get('booking/search'); - await expect(search.func(fakePage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17', currency: 'US$' })).rejects.toThrow(ArgumentError); - }); - - it('wraps browser navigation failures as CommandExecutionError', async () => { - const search = getRegistry().get('booking/search'); - const downPage = { goto: () => Promise.reject(new Error('browser down')) }; - await expect(search.func(downPage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17' })).rejects.toThrow(CommandExecutionError); - }); - - it('throws EmptyResultError when extractor returns no cards', async () => { - const search = getRegistry().get('booking/search'); - const emptyPage = { - goto: async () => {}, - wait: async () => {}, - evaluate: async () => ({ ok: true, items: [], blocked: false, totalText: 'No properties found' }), - }; - await expect(search.func(emptyPage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17' })).rejects.toThrow(EmptyResultError); - }); - - it('throws CommandExecutionError when result-count evidence exists but no cards were parsed', async () => { - const search = getRegistry().get('booking/search'); - const driftPage = { - goto: async () => {}, - wait: async () => {}, - evaluate: async () => ({ ok: true, items: [], blocked: false, totalText: 'Tokyo: 1,234 properties found' }), - }; - await expect(search.func(driftPage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17' })).rejects.toThrow(CommandExecutionError); - }); - - it('throws CommandExecutionError when captcha is detected', async () => { - const search = getRegistry().get('booking/search'); - const blockedPage = { - goto: async () => {}, - wait: async () => {}, - evaluate: async () => ({ ok: true, items: [], blocked: true, totalText: 'Verify you are human' }), - }; - await expect(search.func(blockedPage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17' })).rejects.toThrow(CommandExecutionError); - }); - - it('throws CommandExecutionError when extractor payload is malformed instead of treating it as empty', async () => { - const search = getRegistry().get('booking/search'); - const malformedPage = { - goto: async () => {}, - wait: async () => {}, - evaluate: async () => ({ ok: true, blocked: false, totalText: 'Tokyo hotels' }), - }; - await expect(search.func(malformedPage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17' })).rejects.toThrow(CommandExecutionError); - }); - - it('throws CommandExecutionError when rendered cards lack stable hotel URL identity', async () => { - const search = getRegistry().get('booking/search'); - const driftPage = { - goto: async () => {}, - wait: async () => {}, - evaluate: async () => ({ - ok: true, - blocked: false, - totalText: 'Tokyo hotels', - items: [{ - name: 'Unlinked Hotel', - country: '', - slug: '', - url: '', - distance: '', - review_score: null, - review_count: null, - star_rating: null, - price_currency: '', - price_amount: null, - recommended_room: '', - }], - }), - }; - await expect(search.func(driftPage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17' })).rejects.toThrow(CommandExecutionError); - }); - - it('unwraps {session, data} envelope from CDP bridge before validating', async () => { - const search = getRegistry().get('booking/search'); - const envelopePage = { - goto: async () => {}, - wait: async () => {}, - evaluate: async () => ({ - session: 1, - data: { - ok: true, - blocked: false, - totalText: '', - items: [{ - name: 'Test Hotel', - country: 'jp', - slug: 'test-hotel', - url: 'https://www.booking.com/hotel/jp/test-hotel.html', - distance: '1 km from centre', - review_score: 8.6, - review_count: 100, - star_rating: 4, - price_currency: 'USD', - price_amount: 120, - recommended_room: 'Standard double', - }], - }, - }), - }; - const rows = await search.func(envelopePage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17' }); - expect(rows).toHaveLength(1); - expect(rows[0].rank).toBe(1); - expect(rows[0].slug).toBe('test-hotel'); - expect(rows[0].url).toBe('https://www.booking.com/hotel/jp/test-hotel.html'); - }); - - it('uses requested selected_currency as the output source when price is present', async () => { - const search = getRegistry().get('booking/search'); - const currencyPage = { - goto: async () => {}, - wait: async () => {}, - evaluate: async () => ({ - ok: true, - blocked: false, - totalText: '', - items: [{ - name: 'Currency Hotel', - country: 'cn', - slug: 'currency-hotel', - url: 'https://www.booking.com/hotel/cn/currency-hotel.html', - distance: '', - review_score: null, - review_count: null, - star_rating: null, - price_currency: 'JPY', - price_amount: 880, - recommended_room: '', - }], - }), - }; - const rows = await search.func(currencyPage, { destination: 'Shanghai', checkin: '2026-06-15', checkout: '2026-06-17', currency: 'CNY' }); - expect(rows[0].price_currency).toBe('CNY'); - }); - - it('respects offset for rank numbering when paginating', async () => { - const search = getRegistry().get('booking/search'); - const pagedPage = { - goto: async () => {}, - wait: async () => {}, - evaluate: async () => ({ - ok: true, - blocked: false, - totalText: '', - items: [ - { name: 'A', country: 'jp', slug: 'a', url: 'https://www.booking.com/hotel/jp/a.html', distance: '', review_score: null, review_count: null, star_rating: null, price_currency: '', price_amount: null, recommended_room: '' }, - { name: 'B', country: 'jp', slug: 'b', url: 'https://www.booking.com/hotel/jp/b.html', distance: '', review_score: null, review_count: null, star_rating: null, price_currency: '', price_amount: null, recommended_room: '' }, - ], - }), - }; - const rows = await search.func(pagedPage, { destination: 'Tokyo', checkin: '2026-06-15', checkout: '2026-06-17', offset: 50 }); - expect(rows[0].rank).toBe(51); - expect(rows[1].rank).toBe(52); - }); -}); diff --git a/plugins/booking/webcmd-plugin.json b/plugins/booking/webcmd-plugin.json deleted file mode 100644 index 83262771..00000000 --- a/plugins/booking/webcmd-plugin.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "booking", - "version": "0.1.0", - "description": "Webcmd commands for booking", - "webcmd": ">=0.5.3", - "author": { - "name": "WebCMD Agent", - "handle": "agentrhq" - } -} diff --git a/plugins/brave/README.md b/plugins/brave/README.md deleted file mode 100644 index 41dfbda7..00000000 --- a/plugins/brave/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# webcmd-plugin-brave - -Webcmd commands for brave. - -## Install - -```bash -webcmd plugin install github:agentrhq/webcmd/brave -``` - -## Commands - -| Command | Description | -| --- | --- | -| `webcmd brave search` | Search Brave Search | diff --git a/plugins/brave/package.json b/plugins/brave/package.json deleted file mode 100644 index 8fe2d9ee..00000000 --- a/plugins/brave/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "webcmd-plugin-brave", - "version": "0.1.0", - "type": "module", - "description": "Webcmd commands for brave", - "peerDependencies": { - "@agentrhq/webcmd": ">=0.5.3" - } -} diff --git a/plugins/brave/search.js b/plugins/brave/search.js deleted file mode 100644 index 50d54fe0..00000000 --- a/plugins/brave/search.js +++ /dev/null @@ -1,81 +0,0 @@ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { - emptySearchResults, - requireBoundedInteger, - requireNonNegativeInteger, - requireRows, - requireSearchQuery, - runBrowserStep, - toHttpsUrl, -} from '@agentrhq/webcmd/plugin-runtime'; - -function buildExtractorJs(limit) { - return ` -(function() { - var results = []; - var seen = {}; - var items = document.querySelectorAll('.snippet'); - for (var i = 0; i < items.length; i++) { - if (results.length >= ${limit}) break; - var el = items[i]; - if (el.classList.contains('standalone') || el.classList.contains('ad')) continue; - var titleEl = el.querySelector('.search-snippet-title'); - var snippetEl = el.querySelector('.generic-snippet .content'); - var linkEl = el.querySelector('.result-content a'); - if (!titleEl) continue; - var title = titleEl.textContent.trim(); - var href = linkEl ? linkEl.getAttribute('href') || '' : ''; - var snippet = snippetEl ? snippetEl.textContent.trim() : ''; - if (!title || !href || seen[href]) continue; - if (href.indexOf('/') === 0) continue; - seen[href] = true; - results.push([title, href, snippet]); - } - return results; -})()`; -} - -const command = cli({ - site: 'brave', - name: 'search', - tags: ['search'], - access: 'read', - description: 'Search Brave Search', - domain: 'search.brave.com', - strategy: Strategy.PUBLIC, - browser: true, - args: [ - { name: 'keyword', positional: true, required: true, help: 'Search query' }, - { name: 'limit', type: 'int', default: 10, help: 'Number of results per page (max 18)' }, - { name: 'offset', type: 'int', default: 0, help: 'Page offset (0, 1, 2...). Brave returns ~18 results per page' }, - ], - columns: ['rank', 'title', 'url', 'snippet'], - func: async (page, kwargs) => { - const limit = requireBoundedInteger(kwargs.limit, 10, 1, 18, '--limit'); - const query = requireSearchQuery(kwargs.keyword); - const keyword = encodeURIComponent(query); - const offset = requireNonNegativeInteger(kwargs.offset, 0, '--offset'); - let url = `https://search.brave.com/search?q=${keyword}`; - if (offset > 0) url += `&offset=${offset}`; - await runBrowserStep('brave search navigation', () => page.goto(url)); - try { - await page.wait({ selector: '.snippet', timeout: 10 }); - } catch { - await page.wait(3).catch(function() {}); - } - const raw = await runBrowserStep('brave search extraction', () => page.evaluate(buildExtractorJs(limit))); - const results = requireRows(raw, 'brave search'); - if (results.length === 0) { - throw emptySearchResults('Brave', query); - } - const rows = results - .map(function(r, index) { - return { rank: index + 1 + offset * 18, title: r[0], url: toHttpsUrl(r[1], 'https://search.brave.com'), snippet: r[2] }; - }) - .filter((row) => row.url); - if (rows.length === 0) throw emptySearchResults('Brave', query); - return rows; - }, -}); - -export const __test__ = { command }; diff --git a/plugins/brave/test/search.test.js b/plugins/brave/test/search.test.js deleted file mode 100644 index f34b7c00..00000000 --- a/plugins/brave/test/search.test.js +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; - -const { __test__ } = await import('../search.js'); -const command = __test__.command; - -function createPageMock(evaluateResult = []) { - return { - goto: vi.fn().mockResolvedValue(undefined), - wait: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn().mockResolvedValue(evaluateResult), - }; -} - -describe('brave search', () => { - it('should register as a valid command', () => { - expect(command).toBeDefined(); - expect(command.site).toBe('brave'); - expect(command.name).toBe('search'); - expect(command.access).toBe('read'); - expect(command.browser).toBe(true); - expect(command.strategy).toBe('public'); - expect(command.domain).toBe('search.brave.com'); - }); - - it('should define keyword positional arg', () => { - const kwArg = command.args.find(a => a.name === 'keyword'); - expect(kwArg).toBeDefined(); - expect(kwArg.positional).toBe(true); - expect(kwArg.required).toBe(true); - }); - - it('should define limit arg with default 10', () => { - const limitArg = command.args.find(a => a.name === 'limit'); - expect(limitArg).toBeDefined(); - expect(limitArg.type).toBe('int'); - expect(limitArg.default).toBe(10); - }); - - it('should define output columns', () => { - expect(command.columns).toContain('rank'); - expect(command.columns).toContain('title'); - expect(command.columns).toContain('url'); - expect(command.columns).toContain('snippet'); - }); - - it('rejects empty query, invalid limit, and invalid offset before navigation', async () => { - const page = createPageMock(); - await expect(command.func(page, { keyword: '', limit: 5 })).rejects.toMatchObject({ code: 'ARGUMENT' }); - await expect(command.func(page, { keyword: 'webcmd', limit: 19 })).rejects.toMatchObject({ code: 'ARGUMENT' }); - await expect(command.func(page, { keyword: 'webcmd', limit: 5, offset: -1 })).rejects.toMatchObject({ code: 'ARGUMENT' }); - expect(page.goto).not.toHaveBeenCalled(); - }); - - it('unwraps browser envelopes and returns ranked HTTPS rows', async () => { - const page = createPageMock({ - session: 'site:brave', - data: [['Webcmd', 'https://github.com/agentrhq/webcmd', 'CLI browser tooling']], - }); - - await expect(command.func(page, { keyword: 'webcmd', limit: 1, offset: 1 })).resolves.toEqual([{ - rank: 19, - title: 'Webcmd', - url: 'https://github.com/agentrhq/webcmd', - snippet: 'CLI browser tooling', - }]); - }); - - it('fails typed instead of silently returning [] for malformed extraction payloads', async () => { - const page = createPageMock({ rows: [] }); - - await expect(command.func(page, { keyword: 'webcmd', limit: 1 })).rejects.toMatchObject({ - code: 'COMMAND_EXEC', - message: expect.stringContaining('payload shape'), - }); - }); -}); diff --git a/plugins/brave/webcmd-plugin.json b/plugins/brave/webcmd-plugin.json deleted file mode 100644 index 5f817d83..00000000 --- a/plugins/brave/webcmd-plugin.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "brave", - "version": "0.1.0", - "description": "Webcmd commands for brave", - "webcmd": ">=0.5.3", - "author": { - "name": "WebCMD Agent", - "handle": "agentrhq" - } -} diff --git a/plugins/chatgpt-app/README.md b/plugins/chatgpt-app/README.md deleted file mode 100644 index 20b2f374..00000000 --- a/plugins/chatgpt-app/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# webcmd-plugin-chatgpt-app - -Webcmd commands for chatgpt-app. - -## Install - -```bash -webcmd plugin install github:agentrhq/webcmd/chatgpt-app -``` - -## Commands - -| Command | Description | -| --- | --- | -| `webcmd chatgpt-app ask` | Send a prompt and wait for the AI response (send + wait + read) | -| `webcmd chatgpt-app model` | Switch ChatGPT Desktop model/mode (auto, instant, thinking, 5.2-instant, 5.2-thinking) | -| `webcmd chatgpt-app new` | Open a new chat in ChatGPT Desktop App | -| `webcmd chatgpt-app read` | Read the last visible message from the focused ChatGPT Desktop window | -| `webcmd chatgpt-app send` | Send a message to the active ChatGPT Desktop App window | -| `webcmd chatgpt-app status` | Check if ChatGPT Desktop App is running natively on macOS | diff --git a/plugins/chatgpt-app/ask.js b/plugins/chatgpt-app/ask.js deleted file mode 100644 index d97e9bd8..00000000 --- a/plugins/chatgpt-app/ask.js +++ /dev/null @@ -1,87 +0,0 @@ -import { statSync } from 'node:fs'; -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { ArgumentError, ConfigError, TimeoutError } from '@agentrhq/webcmd/errors'; -import { activateChatGPT, getVisibleChatMessages, selectModel, MODEL_CHOICES, isGenerating, sendPrompt } from './ax.js'; -export const askCommand = cli({ - site: 'chatgpt-app', - name: 'ask', - access: 'write', - description: 'Send a prompt and wait for the AI response (send + wait + read)', - domain: 'localhost', - strategy: Strategy.PUBLIC, - browser: false, - args: [ - { name: 'text', required: true, positional: true, help: 'Prompt to send' }, - { name: 'model', required: false, help: 'Model/mode to use: auto, instant, thinking, 5.2-instant, 5.2-thinking', choices: MODEL_CHOICES }, - { name: 'timeout', type: 'int', required: false, help: 'Max seconds to wait for response (default: 30)', default: 30 }, - { name: 'image', required: false, help: 'Path to local image to attach (optional)' }, - ], - columns: ['Role', 'Text'], - func: async (kwargs) => { - if (process.platform !== 'darwin') { - throw new ConfigError('ChatGPT Desktop integration requires macOS (osascript is not available on this platform)'); - } - const text = kwargs.text; - const model = kwargs.model; - const timeout = kwargs.timeout; - const image = kwargs.image; - if (image) { - let stat; - try { - stat = statSync(image); - } - catch { - throw new ArgumentError(`The specified image path does not exist: ${image}`); - } - if (!stat.isFile()) { - throw new ArgumentError(`The specified image path is not a file: ${image}`); - } - } - if (!Number.isInteger(timeout) || timeout < 1) { - throw new ArgumentError('--timeout must be a positive integer (seconds)'); - } - // Switch model before sending if requested - if (model) { - activateChatGPT(); - selectModel(model); - } - const messagesBefore = getVisibleChatMessages(); - // Send the message - activateChatGPT(); - sendPrompt(text, image); - // Wait for response: poll until ChatGPT stops generating ("Stop generating" button disappears), - // then read the final response text. - const pollInterval = 2; - const maxPolls = Math.ceil(timeout / pollInterval); - let response = ''; - let generationStarted = false; - for (let i = 0; i < maxPolls; i++) { - await new Promise((resolve) => setTimeout(resolve, pollInterval * 1000)); - const generating = isGenerating(); - if (generating) { - generationStarted = true; - continue; - } - // Generation finished (or never started yet) - if (!generationStarted && i < 3) - continue; // give it a moment to start - // Read final response - activateChatGPT(0.3); - const messagesNow = getVisibleChatMessages(); - if (messagesNow.length > messagesBefore.length) { - const newMessages = messagesNow.slice(messagesBefore.length); - const candidate = [...newMessages].reverse().find((message) => message !== text); - if (candidate) - response = candidate; - } - break; - } - if (!response) { - throw new TimeoutError('chatgpt-app/ask', timeout, 'ChatGPT may still be generating; rerun read or increase --timeout'); - } - return [ - { Role: 'User', Text: text }, - { Role: 'Assistant', Text: response }, - ]; - }, -}); diff --git a/plugins/chatgpt-app/ax.js b/plugins/chatgpt-app/ax.js deleted file mode 100644 index bd647da2..00000000 --- a/plugins/chatgpt-app/ax.js +++ /dev/null @@ -1,603 +0,0 @@ -import { execFileSync, execSync } from 'node:child_process'; -const AX_READ_SCRIPT = ` -import Cocoa -import ApplicationServices - -func attr(_ el: AXUIElement, _ name: String) -> AnyObject? { - var value: CFTypeRef? - guard AXUIElementCopyAttributeValue(el, name as CFString, &value) == .success else { return nil } - return value as AnyObject? -} - -func s(_ el: AXUIElement, _ name: String) -> String? { - if let v = attr(el, name) as? String, !v.isEmpty { return v } - return nil -} - -func children(_ el: AXUIElement) -> [AXUIElement] { - (attr(el, kAXChildrenAttribute as String) as? [AnyObject] ?? []).map { $0 as! AXUIElement } -} - -func collectLists(_ el: AXUIElement, into out: inout [AXUIElement]) { - let role = s(el, kAXRoleAttribute as String) ?? "" - if role == kAXListRole as String { out.append(el) } - for c in children(el) { collectLists(c, into: &out) } -} - -func collectTexts(_ el: AXUIElement, into out: inout [String]) { - let role = s(el, kAXRoleAttribute as String) ?? "" - if role == kAXStaticTextRole as String { - if let text = s(el, kAXDescriptionAttribute as String), !text.isEmpty { - out.append(text) - } - } - for c in children(el) { collectTexts(c, into: &out) } -} - -guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: "com.openai.chat").first else { - fputs("ChatGPT not running\\n", stderr) - exit(1) -} - -let axApp = AXUIElementCreateApplication(app.processIdentifier) -var targetWin: AXUIElement? = nil -if let focused = attr(axApp, kAXFocusedWindowAttribute as String) { - targetWin = (focused as! AXUIElement) -} -if targetWin == nil { - if let windows = attr(axApp, kAXWindowsAttribute as String) as? [AXUIElement], !windows.isEmpty { - targetWin = windows.first - } -} -guard let win = targetWin else { - fputs("Could not find or focus any ChatGPT window\\n", stderr) - exit(1) -} - -var lists: [AXUIElement] = [] -collectLists(win, into: &lists) - -var best: [String] = [] -for list in lists { - var texts: [String] = [] - collectTexts(list, into: &texts) - if texts.count > best.count { - best = texts - } -} - -let data = try! JSONSerialization.data(withJSONObject: best, options: []) -print(String(data: data, encoding: .utf8)!) -`; -const AX_SEND_SCRIPT = ` -import Cocoa -import ApplicationServices - -func attr(_ el: AXUIElement, _ name: String) -> AnyObject? { - var value: CFTypeRef? - guard AXUIElementCopyAttributeValue(el, name as CFString, &value) == .success else { return nil } - return value as AnyObject? -} - -func s(_ el: AXUIElement, _ name: String) -> String? { - if let v = attr(el, name) as? String { return v } - return nil -} - -func isEnabled(_ el: AXUIElement) -> Bool { - (attr(el, kAXEnabledAttribute as String) as? Bool) ?? true -} - -func children(_ el: AXUIElement) -> [AXUIElement] { - (attr(el, kAXChildrenAttribute as String) as? [AnyObject] ?? []).map { $0 as! AXUIElement } -} - -func collectEditableInputs(_ el: AXUIElement, into out: inout [AXUIElement], depth: Int = 0) { - guard depth < 25 else { return } - let role = s(el, kAXRoleAttribute as String) ?? "" - if (role == kAXTextAreaRole as String || role == kAXTextFieldRole as String) && isEnabled(el) { - out.append(el) - } - for c in children(el) { collectEditableInputs(c, into: &out, depth: depth + 1) } -} - -func isInput(_ el: AXUIElement) -> Bool { - let role = s(el, kAXRoleAttribute as String) ?? "" - return role == kAXTextAreaRole as String || role == kAXTextFieldRole as String -} - -func focusedInput(_ axApp: AXUIElement) -> AXUIElement? { - guard let focused = attr(axApp, kAXFocusedUIElementAttribute as String) else { - return nil - } - let focusedEl = focused as! AXUIElement - return isInput(focusedEl) && isEnabled(focusedEl) ? focusedEl : nil -} - -func findByDescriptions(_ el: AXUIElement, _ targets: [String], depth: Int = 0) -> AXUIElement? { - guard depth < 25 else { return nil } - let role = s(el, kAXRoleAttribute as String) ?? "" - let desc = s(el, kAXDescriptionAttribute as String) ?? "" - if role == "AXButton" && targets.contains(desc) && isEnabled(el) { return el } - for c in children(el) { - if let found = findByDescriptions(c, targets, depth: depth + 1) { return found } - } - return nil -} - -func attachmentEvidenceCount(_ el: AXUIElement, fileName: String, depth: Int = 0) -> Int { - guard depth < 25 else { return 0 } - let role = s(el, kAXRoleAttribute as String) ?? "" - let desc = s(el, kAXDescriptionAttribute as String) ?? "" - let title = s(el, kAXTitleAttribute as String) ?? "" - let value = s(el, kAXValueAttribute as String) ?? "" - let help = s(el, kAXHelpAttribute as String) ?? "" - let haystack = [desc, title, value, help].joined(separator: " ") - var count = role == kAXImageRole as String ? 1 : 0 - if !fileName.isEmpty && haystack.localizedCaseInsensitiveContains(fileName) { - count += 1 - } - for c in children(el) { - count += attachmentEvidenceCount(c, fileName: fileName, depth: depth + 1) - } - return count -} - -func press(_ el: AXUIElement) { - AXUIElementPerformAction(el, kAXPressAction as CFString) -} - -let args = CommandLine.arguments -guard args.count > 1 else { - fputs("Missing prompt text\\n", stderr) - exit(1) -} -let text = args[1] -let imagePath = args.count > 2 ? args[2] : "" - -guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: "com.openai.chat").first else { - fputs("ChatGPT not running\\n", stderr) - exit(1) -} - -let axApp = AXUIElementCreateApplication(app.processIdentifier) -var targetWin: AXUIElement? = nil -if let focused = attr(axApp, kAXFocusedWindowAttribute as String) { - targetWin = (focused as! AXUIElement) -} -if targetWin == nil { - if let windows = attr(axApp, kAXWindowsAttribute as String) as? [AXUIElement], !windows.isEmpty { - targetWin = windows.first - } -} -guard let win = targetWin else { - fputs("Could not find or focus any ChatGPT window\\n", stderr) - exit(1) -} - -var inputs: [AXUIElement] = [] -collectEditableInputs(win, into: &inputs) -guard let input = focusedInput(axApp) ?? inputs.last else { - fputs("Could not find editable input area\\n", stderr) - exit(1) -} - -guard AXUIElementSetAttributeValue(input, kAXValueAttribute as CFString, text as CFTypeRef) == .success else { - fputs("Failed to set input value\\n", stderr) - exit(1) -} - -Thread.sleep(forTimeInterval: 0.2) - -guard s(input, kAXValueAttribute as String) == text else { - fputs("Failed to verify input value after AX set\\n", stderr) - exit(1) -} - -if !imagePath.isEmpty { - guard let image = NSImage(contentsOfFile: imagePath) else { - fputs("Failed to load image from path: \(imagePath)\\n", stderr) - exit(1) - } - let fileName = URL(fileURLWithPath: imagePath).lastPathComponent - let attachmentCountBefore = attachmentEvidenceCount(win, fileName: fileName) - - // Safeguard Clipboard: Backup existing clipboard items - let pasteboard = NSPasteboard.general - var savedItems: [NSPasteboardItem] = [] - if let items = pasteboard.pasteboardItems { - for item in items { - let savedItem = NSPasteboardItem() - for type in item.types { - if let data = item.data(forType: type) { - savedItem.setData(data, forType: type) - } - } - savedItems.append(savedItem) - } - } - func restorePasteboard() { - pasteboard.clearContents() - if !savedItems.isEmpty { - pasteboard.writeObjects(savedItems) - } - } - - pasteboard.clearContents() - pasteboard.writeObjects([image]) - - AXUIElementSetAttributeValue(input, kAXFocusedAttribute as CFString, true as CFTypeRef) - Thread.sleep(forTimeInterval: 0.2) - - // Simulate paste command targeted directly to ChatGPT's PID to prevent global interference - let src = CGEventSource(stateID: .hidSystemState) - let cmdDown = CGEvent(keyboardEventSource: src, virtualKey: 0x37, keyDown: true) - cmdDown?.flags = .maskCommand - cmdDown?.postToPid(app.processIdentifier) - - let vDown = CGEvent(keyboardEventSource: src, virtualKey: 0x09, keyDown: true) - vDown?.flags = .maskCommand - vDown?.postToPid(app.processIdentifier) - - let vUp = CGEvent(keyboardEventSource: src, virtualKey: 0x09, keyDown: false) - vUp?.flags = .maskCommand - vUp?.postToPid(app.processIdentifier) - - let cmdUp = CGEvent(keyboardEventSource: src, virtualKey: 0x37, keyDown: false) - cmdUp?.postToPid(app.processIdentifier) - - var attachmentReady = false - for _ in 0..<80 { - Thread.sleep(forTimeInterval: 0.1) - if attachmentEvidenceCount(win, fileName: fileName) > attachmentCountBefore { - attachmentReady = true - break - } - } - - // Safeguard Clipboard: Restore user clipboard content after the paste flow. - restorePasteboard() - - guard attachmentReady else { - fputs("Image attachment did not appear in ChatGPT before send\\n", stderr) - exit(1) - } -} - -let valueBeforeSend = s(input, kAXValueAttribute as String) ?? "" - -guard let sendButton = findByDescriptions(win, ["Send", "Send", "Send"]) else { - fputs("Could not find send button\\n", stderr) - exit(1) -} - -press(sendButton) - -var submitted = false -for _ in 0..<15 { - Thread.sleep(forTimeInterval: 0.1) - if (s(input, kAXValueAttribute as String) ?? "") != valueBeforeSend { - submitted = true - break - } -} - -guard submitted else { - fputs("Prompt did not leave input after pressing send\\n", stderr) - exit(1) -} - -print("Sent") -`; -const AX_MODEL_SCRIPT = ` -import Cocoa -import ApplicationServices - -func attr(_ el: AXUIElement, _ name: String) -> AnyObject? { - var value: CFTypeRef? - guard AXUIElementCopyAttributeValue(el, name as CFString, &value) == .success else { return nil } - return value as AnyObject? -} - -func s(_ el: AXUIElement, _ name: String) -> String? { - if let v = attr(el, name) as? String, !v.isEmpty { return v } - return nil -} - -func children(_ el: AXUIElement) -> [AXUIElement] { - (attr(el, kAXChildrenAttribute as String) as? [AnyObject] ?? []).map { $0 as! AXUIElement } -} - -func press(_ el: AXUIElement) { - AXUIElementPerformAction(el, kAXPressAction as CFString) -} - -func findByDesc(_ el: AXUIElement, _ target: String, prefix: Bool = false, depth: Int = 0) -> AXUIElement? { - guard depth < 20 else { return nil } - let desc = s(el, kAXDescriptionAttribute as String) ?? "" - if prefix ? desc.hasPrefix(target) : (desc == target) { return el } - for c in children(el) { - if let found = findByDesc(c, target, prefix: prefix, depth: depth + 1) { return found } - } - return nil -} - -func findPopover(_ el: AXUIElement, depth: Int = 0) -> AXUIElement? { - guard depth < 20 else { return nil } - let role = s(el, kAXRoleAttribute as String) ?? "" - if role == "AXPopover" { return el } - for c in children(el) { - if let found = findPopover(c, depth: depth + 1) { return found } - } - return nil -} - -func pressEscape() { - let src = CGEventSource(stateID: .combinedSessionState) - if let esc = CGEvent(keyboardEventSource: src, virtualKey: 0x35, keyDown: true) { esc.post(tap: .cghidEventTap) } - if let esc = CGEvent(keyboardEventSource: src, virtualKey: 0x35, keyDown: false) { esc.post(tap: .cghidEventTap) } -} - -func waitForElement(timeout: TimeInterval = 1.2, check: () -> AXUIElement?) -> AXUIElement? { - let start = Date() - while Date().timeIntervalSince(start) < timeout { - if let el = check() { return el } - Thread.sleep(forTimeInterval: 0.05) - } - return nil -} - -guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: "com.openai.chat").first else { - fputs("ChatGPT not running\\n", stderr); exit(1) -} -let axApp = AXUIElementCreateApplication(app.processIdentifier) -var targetWin: AXUIElement? = nil -if let focused = attr(axApp, kAXFocusedWindowAttribute as String) { - targetWin = (focused as! AXUIElement) -} -if targetWin == nil { - if let windows = attr(axApp, kAXWindowsAttribute as String) as? [AXUIElement], !windows.isEmpty { - targetWin = windows.first - } -} -guard let win = targetWin else { - fputs("Could not find or focus any ChatGPT window\\n", stderr); exit(1) -} - -let args = CommandLine.arguments -let target = args.count > 1 ? args[1] : "" -let needsLegacy = args.count > 2 && args[2] == "legacy" - -// Step 1: Click the "Options" button to open the popover (support English, Simplified and Traditional Chinese UI) -var optionsBtn: AXUIElement? = nil -for label in ["Options", "Options", "Options"] { - if let btn = findByDesc(win, label) { - optionsBtn = btn - break - } -} -guard let options = optionsBtn else { - fputs("Could not find Options button\\n", stderr); exit(1) -} -press(options) - -// Step 2: Find the popover that appeared, search ONLY within it (utilizing dynamic polling helper) -guard let popover = waitForElement(check: { findPopover(win) }) else { - pressEscape() - fputs("Popover did not appear\\n", stderr); exit(1) -} - -// Step 3: If legacy, click "Legacy models" to expand submenu (supports EN/CN/TW localizations) -if needsLegacy { - var legacyBtn: AXUIElement? = nil - for label in ["Legacy models", "Legacy models", "Legacy models"] { - if let btn = findByDesc(popover, label) { - legacyBtn = btn - break - } - } - guard let btn = legacyBtn else { - pressEscape() - fputs("Could not find Legacy models button\\n", stderr); exit(1) - } - press(btn) -} - -// Step 4: Click the target model button within the popover (prefix match via dynamic polling helper) -guard let modelBtn = waitForElement(check: { findByDesc(popover, target, prefix: true) }) else { - pressEscape() - fputs("Could not find button starting with '\(target)'\\n", stderr); exit(1) -} -press(modelBtn) -print("Selected: \(target)") -`; -const AX_GENERATING_SCRIPT = ` -import Cocoa -import ApplicationServices - -func attr(_ el: AXUIElement, _ name: String) -> AnyObject? { - var value: CFTypeRef? - guard AXUIElementCopyAttributeValue(el, name as CFString, &value) == .success else { return nil } - return value as AnyObject? -} - -func s(_ el: AXUIElement, _ name: String) -> String? { - if let v = attr(el, name) as? String, !v.isEmpty { return v } - return nil -} - -func children(_ el: AXUIElement) -> [AXUIElement] { - (attr(el, kAXChildrenAttribute as String) as? [AnyObject] ?? []).map { $0 as! AXUIElement } -} - -func hasButton(_ el: AXUIElement, desc target: String, depth: Int = 0) -> Bool { - guard depth < 15 else { return false } - let role = s(el, kAXRoleAttribute as String) ?? "" - let desc = s(el, kAXDescriptionAttribute as String) ?? "" - if role == "AXButton" && desc == target { return true } - for c in children(el) { - if hasButton(c, desc: target, depth: depth + 1) { return true } - } - return false -} - -guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: "com.openai.chat").first else { - print("false"); exit(0) -} -let axApp = AXUIElementCreateApplication(app.processIdentifier) -var targetWin: AXUIElement? = nil -if let focused = attr(axApp, kAXFocusedWindowAttribute as String) { - targetWin = (focused as! AXUIElement) -} -if targetWin == nil { - if let windows = attr(axApp, kAXWindowsAttribute as String) as? [AXUIElement], !windows.isEmpty { - targetWin = windows.first - } -} -guard let win = targetWin else { - print("false"); exit(0) -} -let targets = ["Stop generating", "Stop generating", "Stop generating", "Stop sending"] -print(targets.contains(where: { hasButton(win, desc: $0) }) ? "true" : "false") -`; -const AX_TEMPORARY_CHAT_SCRIPT = ` -import Cocoa -import ApplicationServices - -func attr(_ el: AXUIElement, _ name: String) -> AnyObject? { - var value: CFTypeRef? - guard AXUIElementCopyAttributeValue(el, name as CFString, &value) == .success else { return nil } - return value as AnyObject? -} - -func s(_ el: AXUIElement, _ name: String) -> String? { - if let v = attr(el, name) as? String, !v.isEmpty { return v } - return nil -} - -func children(_ el: AXUIElement) -> [AXUIElement] { - (attr(el, kAXChildrenAttribute as String) as? [AnyObject] ?? []).map { $0 as! AXUIElement } -} - -func hasTemporaryChatText(_ el: AXUIElement, depth: Int = 0) -> Bool { - guard depth < 25 else { return false } - let haystack = [ - s(el, kAXDescriptionAttribute as String) ?? "", - s(el, kAXTitleAttribute as String) ?? "", - s(el, kAXValueAttribute as String) ?? "", - s(el, kAXHelpAttribute as String) ?? "", - ].joined(separator: " ") - let labels = ["Temporary Chat", "Temporary chat", "Temporary chat", "Temporary chat", "Temporary chat"] - if labels.contains(where: { haystack.localizedCaseInsensitiveContains($0) }) { - return true - } - for c in children(el) { - if hasTemporaryChatText(c, depth: depth + 1) { return true } - } - return false -} - -guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: "com.openai.chat").first else { - print("false"); exit(0) -} -let axApp = AXUIElementCreateApplication(app.processIdentifier) -var targetWin: AXUIElement? = nil -if let focused = attr(axApp, kAXFocusedWindowAttribute as String) { - targetWin = (focused as! AXUIElement) -} -if targetWin == nil { - if let windows = attr(axApp, kAXWindowsAttribute as String) as? [AXUIElement], !windows.isEmpty { - targetWin = windows.first - } -} -guard let win = targetWin else { - print("false"); exit(0) -} -print(hasTemporaryChatText(win) ? "true" : "false") -`; -const MODEL_MAP = { - 'auto': { desc: 'Auto' }, - 'instant': { desc: 'Instant' }, - 'thinking': { desc: 'Thinking' }, - '5.2-instant': { desc: 'GPT-5.2 Instant', legacy: true }, - '5.2-thinking': { desc: 'GPT-5.2 Thinking', legacy: true }, -}; -export const MODEL_CHOICES = Object.keys(MODEL_MAP); -export function activateChatGPT(delaySeconds = 0.5) { - execSync("osascript -e 'tell application \"ChatGPT\" to activate'"); - execSync(`osascript -e 'delay ${delaySeconds}'`); -} -export function selectModel(model) { - const entry = MODEL_MAP[model]; - if (!entry) { - throw new Error(`Unknown model "${model}". Choose from: ${MODEL_CHOICES.join(', ')}`); - } - const swiftArgs = ['-', entry.desc]; - if (entry.legacy) - swiftArgs.push('legacy'); - const output = execFileSync('swift', swiftArgs, { - input: AX_MODEL_SCRIPT, - encoding: 'utf-8', - maxBuffer: 10 * 1024 * 1024, - }).trim(); - return output; -} -export function sendPrompt(text, imagePath = '') { - const args = ['-', text]; - if (imagePath) { - args.push(imagePath); - } - return execFileSync('swift', args, { - input: AX_SEND_SCRIPT, - encoding: 'utf-8', - maxBuffer: 10 * 1024 * 1024, - }).trim(); -} -export function isGenerating() { - try { - const output = execFileSync('swift', ['-'], { - input: AX_GENERATING_SCRIPT, - encoding: 'utf-8', - maxBuffer: 10 * 1024 * 1024, - }).trim(); - return output === 'true'; - } - catch { - return false; - } -} -export function isTemporaryChatVisible() { - try { - const output = execFileSync('swift', ['-'], { - input: AX_TEMPORARY_CHAT_SCRIPT, - encoding: 'utf-8', - maxBuffer: 10 * 1024 * 1024, - }).trim(); - return output === 'true'; - } - catch { - return false; - } -} -export function getVisibleChatMessages() { - const output = execFileSync('swift', ['-'], { - input: AX_READ_SCRIPT, - encoding: 'utf-8', - maxBuffer: 10 * 1024 * 1024, - }).trim(); - if (!output) - return []; - const parsed = JSON.parse(output); - if (!Array.isArray(parsed)) - return []; - return parsed - .filter((item) => typeof item === 'string') - .map((item) => item.replace(/[\uFFFC\u200B-\u200D\uFEFF]/g, '').trim()) - .filter((item) => item.length > 0); -} -export const __test__ = { - AX_SEND_SCRIPT, - AX_MODEL_SCRIPT, - AX_GENERATING_SCRIPT, - AX_TEMPORARY_CHAT_SCRIPT, -}; diff --git a/plugins/chatgpt-app/model.js b/plugins/chatgpt-app/model.js deleted file mode 100644 index 4f31d4b3..00000000 --- a/plugins/chatgpt-app/model.js +++ /dev/null @@ -1,25 +0,0 @@ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { ConfigError } from '@agentrhq/webcmd/errors'; -import { activateChatGPT, selectModel, MODEL_CHOICES } from './ax.js'; -export const modelCommand = cli({ - site: 'chatgpt-app', - name: 'model', - access: 'read', - description: 'Switch ChatGPT Desktop model/mode (auto, instant, thinking, 5.2-instant, 5.2-thinking)', - domain: 'localhost', - strategy: Strategy.PUBLIC, - browser: false, - args: [ - { name: 'model', required: true, positional: true, help: 'Model to switch to', choices: MODEL_CHOICES }, - ], - columns: ['Status', 'Model'], - func: async (kwargs) => { - if (process.platform !== 'darwin') { - throw new ConfigError('ChatGPT Desktop integration requires macOS'); - } - const model = kwargs.model; - activateChatGPT(); - const result = selectModel(model); - return [{ Status: 'Success', Model: result }]; - }, -}); diff --git a/plugins/chatgpt-app/new.js b/plugins/chatgpt-app/new.js deleted file mode 100644 index 4034e969..00000000 --- a/plugins/chatgpt-app/new.js +++ /dev/null @@ -1,61 +0,0 @@ -import { execSync } from 'node:child_process'; -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { CommandExecutionError, ConfigError, getErrorMessage } from '@agentrhq/webcmd/errors'; -import { isTemporaryChatVisible } from './ax.js'; -export const newCommand = cli({ - site: 'chatgpt-app', - name: 'new', - access: 'write', - description: 'Open a new chat in ChatGPT Desktop App', - domain: 'localhost', - strategy: Strategy.PUBLIC, - browser: false, - args: [ - { name: 'temp', type: 'boolean', default: false, help: 'Open a temporary chat with privacy protection' } - ], - columns: ['Status'], - func: async (kwargs) => { - if (process.platform !== 'darwin') { - throw new ConfigError('ChatGPT Desktop integration requires macOS (osascript is not available on this platform)'); - } - try { - execSync("osascript -e 'tell application \"ChatGPT\" to activate'"); - execSync("osascript -e 'delay 0.5'"); - if (kwargs.temp) { - const appleScript = [ - 'tell application "System Events"', - ' tell process "ChatGPT"', - ' try', - ' click menu item "New temporary chat" of menu "File" of menu bar 1', - ' on error', - ' try', - ' click menu item "New temporary chat" of menu "File" of menu bar 1', - ' on error', - ' try', - ' click menu item "New Temporary Chat" of menu "File" of menu bar 1', - ' on error', - ' error "Unable to locate Temporary Chat menu item. Ensure Accessibility permissions are granted and the language is supported."' , - ' end try', - ' end try', - ' end try', - ' end tell', - 'end tell' - ].map(line => `-e '${line.replace(/'/g, "'\\''")}'`).join(' '); - execSync(`osascript ${appleScript}`); - execSync("osascript -e 'delay 0.8'"); - if (!isTemporaryChatVisible()) { - throw new CommandExecutionError('Temporary chat did not become visible after selecting the menu item'); - } - } else { - execSync("osascript -e 'tell application \"System Events\" to keystroke \"n\" using command down'"); - } - return [{ Status: 'Success' }]; - } - catch (err) { - if (err instanceof CommandExecutionError) { - throw err; - } - throw new CommandExecutionError("Failed to open ChatGPT chat: " + getErrorMessage(err)); - } - }, -}); diff --git a/plugins/chatgpt-app/package.json b/plugins/chatgpt-app/package.json deleted file mode 100644 index 16f40a66..00000000 --- a/plugins/chatgpt-app/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "webcmd-plugin-chatgpt-app", - "version": "0.1.0", - "type": "module", - "description": "Webcmd commands for chatgpt-app", - "peerDependencies": { - "@agentrhq/webcmd": ">=0.5.3" - } -} diff --git a/plugins/chatgpt-app/read.js b/plugins/chatgpt-app/read.js deleted file mode 100644 index d4de36dd..00000000 --- a/plugins/chatgpt-app/read.js +++ /dev/null @@ -1,32 +0,0 @@ -import { execSync } from 'node:child_process'; -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { CommandExecutionError, ConfigError, getErrorMessage } from '@agentrhq/webcmd/errors'; -import { getVisibleChatMessages } from './ax.js'; -export const readCommand = cli({ - site: 'chatgpt-app', - name: 'read', - access: 'read', - description: 'Read the last visible message from the focused ChatGPT Desktop window', - domain: 'localhost', - strategy: Strategy.PUBLIC, - browser: false, - args: [], - columns: ['Role', 'Text'], - func: async () => { - if (process.platform !== 'darwin') { - throw new ConfigError('ChatGPT Desktop integration requires macOS (osascript is not available on this platform)'); - } - try { - execSync("osascript -e 'tell application \"ChatGPT\" to activate'"); - execSync("osascript -e 'delay 0.3'"); - const messages = getVisibleChatMessages(); - if (!messages.length) { - return [{ Role: 'System', Text: 'No visible chat messages were found in the current ChatGPT window.' }]; - } - return [{ Role: 'Assistant', Text: messages[messages.length - 1] }]; - } - catch (err) { - throw new CommandExecutionError("Failed to read from ChatGPT: " + getErrorMessage(err)); - } - }, -}); diff --git a/plugins/chatgpt-app/send.js b/plugins/chatgpt-app/send.js deleted file mode 100644 index ec0218cb..00000000 --- a/plugins/chatgpt-app/send.js +++ /dev/null @@ -1,37 +0,0 @@ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { CommandExecutionError, ConfigError, getErrorMessage } from '@agentrhq/webcmd/errors'; -import { activateChatGPT, selectModel, MODEL_CHOICES, sendPrompt } from './ax.js'; -export const sendCommand = cli({ - site: 'chatgpt-app', - name: 'send', - access: 'write', - description: 'Send a message to the active ChatGPT Desktop App window', - domain: 'localhost', - strategy: Strategy.PUBLIC, - browser: false, - args: [ - { name: 'text', required: true, positional: true, help: 'Message to send' }, - { name: 'model', required: false, help: 'Model/mode to use: auto, instant, thinking, 5.2-instant, 5.2-thinking', choices: MODEL_CHOICES }, - ], - columns: ['Status'], - func: async (kwargs) => { - if (process.platform !== 'darwin') { - throw new ConfigError('ChatGPT Desktop integration requires macOS (osascript is not available on this platform)'); - } - const text = kwargs.text; - const model = kwargs.model; - try { - // Switch model before sending if requested - if (model) { - activateChatGPT(); - selectModel(model); - } - activateChatGPT(); - sendPrompt(text); - return [{ Status: 'Success' }]; - } - catch (err) { - throw new CommandExecutionError("Failed to send ChatGPT message: " + getErrorMessage(err)); - } - }, -}); diff --git a/plugins/chatgpt-app/status.js b/plugins/chatgpt-app/status.js deleted file mode 100644 index 4540cef4..00000000 --- a/plugins/chatgpt-app/status.js +++ /dev/null @@ -1,26 +0,0 @@ -import { execSync } from 'node:child_process'; -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { CommandExecutionError, ConfigError } from '@agentrhq/webcmd/errors'; -export const statusCommand = cli({ - site: 'chatgpt-app', - name: 'status', - access: 'read', - description: 'Check if ChatGPT Desktop App is running natively on macOS', - domain: 'localhost', - strategy: Strategy.PUBLIC, - browser: false, - args: [], - columns: ['Status'], - func: async () => { - if (process.platform !== 'darwin') { - throw new ConfigError('ChatGPT Desktop integration requires macOS (osascript is not available on this platform)'); - } - try { - const output = execSync("osascript -e 'application \"ChatGPT\" is running'", { encoding: 'utf-8' }).trim(); - return [{ Status: output === 'true' ? 'Running' : 'Stopped' }]; - } - catch { - throw new CommandExecutionError('Error querying ChatGPT application state'); - } - }, -}); diff --git a/plugins/chatgpt-app/test/ax.test.js b/plugins/chatgpt-app/test/ax.test.js deleted file mode 100644 index cc138243..00000000 --- a/plugins/chatgpt-app/test/ax.test.js +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { __test__ } from '../ax.js'; - -describe('chatgpt-app AX send script', () => { - it('prefers the focused composer before falling back to the last editable input', () => { - expect(__test__.AX_SEND_SCRIPT).toContain('kAXFocusedUIElementAttribute'); - }); - - it('fails fast when the AX set does not round-trip into the composer value', () => { - expect(__test__.AX_SEND_SCRIPT).toContain('Failed to verify input value after AX set'); - }); - - it('does not report success until the prompt leaves the composer after send', () => { - expect(__test__.AX_SEND_SCRIPT).toContain('Prompt did not leave input after pressing send'); - }); - - it('supports english, zh-CN, and zh-TW send button labels', () => { - expect(__test__.AX_SEND_SCRIPT).toContain('["Send", "Send", "Send"]'); - }); - - it('supports loading an optional image and writing it to the general pasteboard', () => { - expect(__test__.AX_SEND_SCRIPT).toContain('NSImage(contentsOfFile: imagePath)'); - expect(__test__.AX_SEND_SCRIPT).toContain('NSPasteboard.general'); - expect(__test__.AX_SEND_SCRIPT).toContain('pasteboard.clearContents()'); - expect(__test__.AX_SEND_SCRIPT).toContain('pasteboard.writeObjects([image])'); - }); - - it('simulates Cmd + V paste via CGEvent targeted directly to the ChatGPT process', () => { - expect(__test__.AX_SEND_SCRIPT).toContain('CGEventSource(stateID: .hidSystemState)'); - expect(__test__.AX_SEND_SCRIPT).toContain('let cmdDown = CGEvent'); - expect(__test__.AX_SEND_SCRIPT).toContain('.maskCommand'); - expect(__test__.AX_SEND_SCRIPT).toContain('virtualKey: 0x09'); // 'V' - expect(__test__.AX_SEND_SCRIPT).toContain('virtualKey: 0x37'); // 'Cmd' - expect(__test__.AX_SEND_SCRIPT).toContain('postToPid(app.processIdentifier)'); - }); - - it('uses a dynamic submission check with valueBeforeSend to handle rich content correctly', () => { - expect(__test__.AX_SEND_SCRIPT).toContain('let valueBeforeSend = s(input, kAXValueAttribute as String)'); - expect(__test__.AX_SEND_SCRIPT).toContain('(s(input, kAXValueAttribute as String) ?? "") != valueBeforeSend'); - }); - - it('safeguards user clipboard by backing up and restoring pasteboard contents', () => { - expect(__test__.AX_SEND_SCRIPT).toContain('pasteboard.pasteboardItems'); - expect(__test__.AX_SEND_SCRIPT).toContain('NSPasteboardItem()'); - expect(__test__.AX_SEND_SCRIPT).toContain('savedItems.append'); - expect(__test__.AX_SEND_SCRIPT).toContain('func restorePasteboard()'); - expect(__test__.AX_SEND_SCRIPT).toContain('restorePasteboard()'); - }); - - it('requires visible attachment evidence before pressing send with an image', () => { - expect(__test__.AX_SEND_SCRIPT).toContain('attachmentEvidenceCount'); - expect(__test__.AX_SEND_SCRIPT).toContain('let attachmentCountBefore = attachmentEvidenceCount(win, fileName: fileName)'); - expect(__test__.AX_SEND_SCRIPT).toContain('attachmentEvidenceCount(win, fileName: fileName) > attachmentCountBefore'); - expect(__test__.AX_SEND_SCRIPT).toContain('Image attachment did not appear in ChatGPT before send'); - expect(__test__.AX_SEND_SCRIPT.indexOf('Image attachment did not appear in ChatGPT before send')) - .toBeLessThan(__test__.AX_SEND_SCRIPT.indexOf('guard let sendButton')); - }); - - it('uses safe casting and fallback window search to prevent runtime crashes', () => { - expect(__test__.AX_SEND_SCRIPT).toContain('as! AXUIElement'); - expect(__test__.AX_SEND_SCRIPT).toContain('kAXWindowsAttribute'); - }); -}); - -describe('chatgpt-app AX model script', () => { - it('supports english, zh-CN, and zh-TW options button labels', () => { - expect(__test__.AX_MODEL_SCRIPT).toContain('["Options", "Options", "Options"]'); - }); - - it('utilizes dynamic element polling helper to prevent rigid sleep delays', () => { - expect(__test__.AX_MODEL_SCRIPT).toContain('waitForElement'); - }); - - it('supports localized legacy model menus for Chinese systems', () => { - expect(__test__.AX_MODEL_SCRIPT).toContain('["Legacy models", "Legacy models", "Legacy models"]'); - }); -}); - -describe('chatgpt-app generating detection', () => { - it('supports english, zh-CN, and zh-TW stop-generating labels', () => { - expect(__test__.AX_GENERATING_SCRIPT).toContain('Stop generating'); - expect(__test__.AX_GENERATING_SCRIPT).toContain('Stop generating'); - expect(__test__.AX_GENERATING_SCRIPT).toContain('Stop generating'); - expect(__test__.AX_GENERATING_SCRIPT).toContain('Stop sending'); - }); -}); - -describe('chatgpt-app temporary chat detection', () => { - it('looks for localized temporary-chat state text in the active window', () => { - expect(__test__.AX_TEMPORARY_CHAT_SCRIPT).toContain('Temporary Chat'); - expect(__test__.AX_TEMPORARY_CHAT_SCRIPT).toContain('Temporary chat'); - expect(__test__.AX_TEMPORARY_CHAT_SCRIPT).toContain('Temporary chat'); - expect(__test__.AX_TEMPORARY_CHAT_SCRIPT).toContain('hasTemporaryChatText'); - }); -}); diff --git a/plugins/chatgpt-app/test/commands.test.js b/plugins/chatgpt-app/test/commands.test.js deleted file mode 100644 index d95dd581..00000000 --- a/plugins/chatgpt-app/test/commands.test.js +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { getRegistry } from '@agentrhq/webcmd/registry'; -import '../ask.js'; -import '../new.js'; -import '../send.js'; -import '../read.js'; -import '../status.js'; -import '../model.js'; - -describe('chatgpt-app desktop command registration', () => { - it('registers the baseline desktop chat commands with localhost scope', () => { - const expectedAccess = { - ask: 'write', - send: 'write', - read: 'read', - new: 'write', - status: 'read', - model: 'read', - }; - - for (const [name, access] of Object.entries(expectedAccess)) { - const cmd = getRegistry().get(`chatgpt-app/${name}`); - expect(cmd, `chatgpt-app/${name}`).toBeDefined(); - expect(cmd.site).toBe('chatgpt-app'); - expect(cmd.domain).toBe('localhost'); - expect(cmd.strategy).toBe('public'); - expect(cmd.browser).toBe(false); - expect(cmd.access).toBe(access); - } - }); - - it('defines the --temp boolean argument in the new command', () => { - const newCmd = getRegistry().get('chatgpt-app/new'); - expect(newCmd.args).toEqual(expect.arrayContaining([ - expect.objectContaining({ name: 'temp', type: 'boolean', default: false }), - ])); - }); - - it('defines the --image argument in the ask command', () => { - const askCmd = getRegistry().get('chatgpt-app/ask'); - expect(askCmd.args).toEqual(expect.arrayContaining([ - expect.objectContaining({ name: 'image', required: false }), - ])); - }); -}); diff --git a/plugins/chatgpt-app/webcmd-plugin.json b/plugins/chatgpt-app/webcmd-plugin.json deleted file mode 100644 index b5163d3b..00000000 --- a/plugins/chatgpt-app/webcmd-plugin.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "chatgpt-app", - "version": "0.1.0", - "description": "Webcmd commands for chatgpt-app", - "webcmd": ">=0.5.3", - "author": { - "name": "WebCMD Agent", - "handle": "agentrhq" - } -} diff --git a/plugins/chatgpt/README.md b/plugins/chatgpt/README.md deleted file mode 100644 index f82b721f..00000000 --- a/plugins/chatgpt/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# webcmd-plugin-chatgpt - -Webcmd commands for chatgpt. - -## Install - -```bash -webcmd plugin install github:agentrhq/webcmd/chatgpt -``` - -## Commands - -| Command | Description | -| --- | --- | -| `webcmd chatgpt ask` | Send a prompt to ChatGPT web and wait for the response | -| `webcmd chatgpt deep-research-result` | Read a ChatGPT Deep Research report or progress from the conversation payload | -| `webcmd chatgpt detail` | Open a ChatGPT web conversation by ID and read its messages | -| `webcmd chatgpt history` | List visible ChatGPT web conversation history from the sidebar | -| `webcmd chatgpt image` | Generate images with ChatGPT web and save them locally | -| `webcmd chatgpt login` | Open chatgpt login | -| `webcmd chatgpt model` | Switch ChatGPT web model or intelligence level (GPT-5.6 Pro, fast, balanced, advanced, very-high, pro) | -| `webcmd chatgpt new` | Start a new ChatGPT web conversation | -| `webcmd chatgpt project-file-add` | Upload files to a ChatGPT project as project knowledge (not just conversation attachments) | -| `webcmd chatgpt project-list` | List visible ChatGPT projects from the sidebar | -| `webcmd chatgpt read` | Read messages in the current ChatGPT web conversation | -| `webcmd chatgpt send` | Send a prompt to ChatGPT web without waiting for the response | -| `webcmd chatgpt status` | Check ChatGPT web page availability and login state | -| `webcmd chatgpt whoami` | Show the current logged-in chatgpt account | diff --git a/plugins/chatgpt/ask.js b/plugins/chatgpt/ask.js deleted file mode 100644 index 93ed5d7b..00000000 --- a/plugins/chatgpt/ask.js +++ /dev/null @@ -1,129 +0,0 @@ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { ArgumentError, CommandExecutionError } from '@agentrhq/webcmd/errors'; -import { - CHATGPT_DOMAIN, - CHATGPT_URL, - currentChatGPTUrl, - ensureChatGPTComposer, - ensureOnChatGPT, - getChatGPTResponsePairCounts, - getVisibleMessages, - normalizeBooleanFlag, - openChatGPTConversation, - requireNonEmptyPrompt, - requirePositiveInt, - parseChatGPTConversationId, - sendChatGPTMessage, - selectChatGPTTool, - isGenerating, - startNewChat, - navigateToProject, - waitForChatGPTResponse, -} from './utils.js'; - -async function waitForConversationUrl(page, timeoutSeconds = 30) { - const startTime = Date.now(); - while (Date.now() - startTime < timeoutSeconds * 1000) { - const conversationUrl = await currentChatGPTUrl(page); - try { - const conversationId = parseChatGPTConversationId(conversationUrl); - return { conversationId, conversationUrl }; - } catch { - await page.wait(1); - } - } - throw new CommandExecutionError('ChatGPT did not create a conversation URL after sending the message.'); -} - -export const askCommand = cli({ - site: 'chatgpt', - name: 'ask', - access: 'write', - description: 'Send a prompt to ChatGPT web and wait for the response', - domain: CHATGPT_DOMAIN, - strategy: Strategy.COOKIE, - browser: true, - siteSession: 'persistent', - navigateBefore: false, - args: [ - { name: 'prompt', positional: true, required: true, help: 'Prompt to send' }, - { name: 'timeout', type: 'int', default: 120, help: 'Max seconds to wait for response' }, - { name: 'new', type: 'boolean', default: false, help: 'Start a new chat before sending' }, - { name: 'conversation', valueRequired: true, help: 'Continue an existing ChatGPT conversation ID or /c/ URL' }, - { name: 'project', valueRequired: true, help: 'Start a new chat inside a ChatGPT project ID or /g/g-p- URL' }, - { name: 'wait', type: 'boolean', default: true, help: 'Wait for the assistant response after sending' }, - { name: 'deep-research', type: 'boolean', default: false, help: 'Enable ChatGPT Deep Research (Deep Research)' }, - { name: 'web-search', type: 'boolean', default: false, help: 'Enable ChatGPT Web Search (Web Search)' }, - ], - columns: ['conversationId', 'conversationUrl', 'tool', 'response'], - func: async (page, kwargs) => { - const prompt = requireNonEmptyPrompt(kwargs.prompt, 'chatgpt ask'); - const timeout = requirePositiveInt( - Number(kwargs.timeout ?? 120), - 'chatgpt ask --timeout', - 'Example: webcmd chatgpt ask "hello" --timeout 120', - ); - const useDeepResearch = normalizeBooleanFlag(kwargs['deep-research'], false); - const useWebSearch = normalizeBooleanFlag(kwargs['web-search'], false); - const shouldWait = normalizeBooleanFlag(kwargs.wait, true); - if (useDeepResearch && useWebSearch) { - throw new ArgumentError( - 'chatgpt ask cannot enable both --deep-research and --web-search', - 'Choose one ChatGPT composer tool for this message.', - ); - } - if (normalizeBooleanFlag(kwargs.new) && kwargs.conversation) { - throw new ArgumentError( - 'chatgpt ask cannot use --new and --conversation together', - 'Choose either a new chat or an existing conversation.', - ); - } - if (kwargs.project && kwargs.conversation) { - throw new ArgumentError( - 'chatgpt ask cannot use --project and --conversation together', - 'Choose either a project new chat or an existing conversation.', - ); - } - const tool = useDeepResearch ? 'deep-research' : (useWebSearch ? 'web-search' : null); - - if (kwargs.conversation) { - await openChatGPTConversation(page, kwargs.conversation); - } else if (kwargs.project) { - await navigateToProject(page, kwargs.project); - } else if (normalizeBooleanFlag(kwargs.new)) { - await startNewChat(page); - } else { - await ensureOnChatGPT(page); - } - // startNewChat / ensureOnChatGPT now wait for the composer selector - // after navigating, so the previous standalone 2 s settle is redundant. - await ensureChatGPTComposer(page, 'ChatGPT ask requires a logged-in ChatGPT session with a visible composer.'); - const selectedTool = tool ? await selectChatGPTTool(page, tool) : null; - - const settleStart = Date.now(); - while (await isGenerating(page)) { - if (Date.now() - settleStart > timeout * 1000) { - throw new CommandExecutionError('ChatGPT conversation is still generating; wait for it to finish before sending another message.'); - } - await page.sleep(3); - } - - const baselineMessages = await getVisibleMessages(page); - const baseline = baselineMessages.length; - const baselinePairCounts = getChatGPTResponsePairCounts(baselineMessages, prompt); - const sent = await sendChatGPTMessage(page, prompt); - if (!sent) { - throw new CommandExecutionError('Failed to send message to ChatGPT', `Open ${CHATGPT_URL} and verify the composer is ready.`); - } - - const { conversationId, conversationUrl } = await waitForConversationUrl(page); - if (!shouldWait) { - return [{ conversationId, conversationUrl, tool: selectedTool?.Tool ?? '', response: '' }]; - } - const response = await waitForChatGPTResponse(page, baseline, prompt, timeout, { - baselinePairCounts, - conversationUrl, - }); - return [{ conversationId, conversationUrl, tool: selectedTool?.Tool ?? '', response }]; - }, -}); diff --git a/plugins/chatgpt/auth.js b/plugins/chatgpt/auth.js deleted file mode 100644 index 71501f6a..00000000 --- a/plugins/chatgpt/auth.js +++ /dev/null @@ -1,46 +0,0 @@ -import { AuthRequiredError, CommandExecutionError } from '@agentrhq/webcmd/errors'; -import { registerSiteAuthCommands } from '@agentrhq/webcmd/plugin-runtime'; - -async function hasChatgptSessionCookie(page) { - const cookies = await page.getCookies({ url: 'https://chatgpt.com' }); - return cookies.some(c => c.name === '__Secure-next-auth.session-token' && c.value); -} - -async function verifyChatgptIdentity(page) { - if (!await hasChatgptSessionCookie(page)) { - throw new AuthRequiredError('chatgpt.com', 'ChatGPT __Secure-next-auth.session-token cookie missing'); - } - await page.goto('https://chatgpt.com/'); - await page.wait(2); - const result = await page.evaluate(`(async () => { - try { - const res = await fetch('/api/auth/session', { credentials: 'include' }); - if (res.status === 401 || res.status === 403) { - return { kind: 'auth', detail: 'ChatGPT /api/auth/session HTTP ' + res.status }; - } - if (!res.ok) return { kind: 'http', httpStatus: res.status }; - const d = await res.json(); - const user = d && d.user; - if (!user || !user.id) { - return { kind: 'auth', detail: 'ChatGPT /api/auth/session has no user — anonymous' }; - } - return { ok: true, user_id: String(user.id), name: String(user.name || '') }; - } catch (e) { - return { kind: 'exception', detail: String(e && e.message || e) }; - } - })()`); - if (result?.kind === 'auth') throw new AuthRequiredError('chatgpt.com', result.detail); - if (result?.kind === 'http') throw new CommandExecutionError(`HTTP ${result.httpStatus} from /api/auth/session`); - if (result?.kind === 'exception') throw new CommandExecutionError(`ChatGPT whoami failed: ${result.detail}`); - if (!result?.ok) throw new CommandExecutionError(`Unexpected ChatGPT probe: ${JSON.stringify(result)}`); - return { user_id: result.user_id, name: result.name }; -} - -registerSiteAuthCommands({ - site: 'chatgpt', - domain: 'chatgpt.com', - loginUrl: 'https://auth.openai.com/log-in', - columns: ['user_id', 'name'], - quickCheck: hasChatgptSessionCookie, - verify: verifyChatgptIdentity, -}); diff --git a/plugins/chatgpt/deep-research-result.js b/plugins/chatgpt/deep-research-result.js deleted file mode 100644 index a4bd0f93..00000000 --- a/plugins/chatgpt/deep-research-result.js +++ /dev/null @@ -1,122 +0,0 @@ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { EmptyResultError } from '@agentrhq/webcmd/errors'; -import { - CHATGPT_DOMAIN, - CHATGPT_URL, - currentChatGPTUrl, - ensureChatGPTLogin, - getChatGPTDeepResearchResult, - normalizeBooleanFlag, - parseChatGPTConversationId, - requireNonNegativeInt, - requirePositiveInt, - waitForChatGPTDeepResearchResult, -} from './utils.js'; - -function hasDeepResearchProgress(result) { - return !!result - && result.status !== 'completed' - && result.progress - && typeof result.progress === 'object' - && !Array.isArray(result.progress) - && Object.keys(result.progress).length > 0; -} - -export const deepResearchResultCommand = cli({ - site: 'chatgpt', - name: 'deep-research-result', - tags: ['search'], - access: 'read', - description: 'Read a ChatGPT Deep Research report or progress from the conversation payload', - domain: CHATGPT_DOMAIN, - strategy: Strategy.COOKIE, - browser: true, - siteSession: 'persistent', - navigateBefore: false, - args: [ - { name: 'id', positional: true, required: true, help: 'Conversation ID or full /c/ URL' }, - { name: 'wait', type: 'boolean', default: false, help: 'Wait until Deep Research completes or becomes extractable' }, - { name: 'timeout', type: 'int', default: 120, help: 'Max seconds to wait when --wait is true' }, - { name: 'stable', type: 'int', default: 6, help: 'Seconds the report text must remain unchanged when --wait is true' }, - ], - columns: [ - 'conversationId', - 'status', - 'report', - 'sources', - 'progress', - 'asyncTaskConversationId', - 'widgetSessionId', - 'asyncStatus', - 'venusMessageType', - 'venusStatus', - 'waitingForUserUntil', - 'planTitle', - 'planId', - 'url', - 'method', - 'diagnostics', - ], - func: async (page, kwargs) => { - const id = parseChatGPTConversationId(kwargs.id); - const shouldWait = normalizeBooleanFlag(kwargs.wait, false); - const timeout = requirePositiveInt( - Number(kwargs.timeout ?? 120), - 'chatgpt deep-research-result --timeout', - 'Example: webcmd chatgpt deep-research-result --wait true --timeout 600', - ); - const stableSeconds = requireNonNegativeInt( - Number(kwargs.stable ?? 6), - 'chatgpt deep-research-result --stable', - 'Example: webcmd chatgpt deep-research-result --wait true --stable 6', - ); - const targetUrl = `${CHATGPT_URL}/c/${id}`; - await page.readNetworkCapture?.().catch(() => []); - const currentUrl = await currentChatGPTUrl(page).catch(() => ''); - if (currentUrl.startsWith(targetUrl)) { - await page.goto(`${CHATGPT_URL}/?webcmd_dr_result=${Date.now()}`, { waitUntil: 'none' }); - await page.wait(1); - } - await page.startNetworkCapture?.('/backend-api/conversation/').catch(() => false); - await page.goto(targetUrl, { waitUntil: 'none' }); - await page.wait(3); - await ensureChatGPTLogin(page, 'ChatGPT deep-research-result requires a logged-in ChatGPT session.'); - - const result = shouldWait - ? await waitForChatGPTDeepResearchResult(page, { conversationId: id, timeoutSeconds: timeout, stableSeconds }) - : await getChatGPTDeepResearchResult(page, { conversationId: id, useBridgeProbes: true }); - - if (result.status !== 'completed' && !hasDeepResearchProgress(result)) { - throw new EmptyResultError( - 'chatgpt deep-research-result', - `No completed Deep Research report was found for conversation ${id}.`, - ); - } - - if (result.status === 'completed' && !result.report) { - throw new EmptyResultError( - 'chatgpt deep-research-result', - `No completed Deep Research report was found for conversation ${id}.`, - ); - } - - return [{ - conversationId: id, - status: result.status, - report: result.report || '', - sources: result.sources || [], - progress: result.progress || {}, - asyncTaskConversationId: result.asyncTaskConversationId || '', - widgetSessionId: result.widgetSessionId || '', - asyncStatus: result.asyncStatus ?? '', - venusMessageType: result.venusMessageType || '', - venusStatus: result.venusStatus || '', - waitingForUserUntil: result.waitingForUserUntil || '', - planTitle: result.planTitle || '', - planId: result.planId || '', - url: result.url || targetUrl, - method: result.method || '', - diagnostics: result.diagnostics || {}, - }]; - }, -}); diff --git a/plugins/chatgpt/detail.js b/plugins/chatgpt/detail.js deleted file mode 100644 index 0267d141..00000000 --- a/plugins/chatgpt/detail.js +++ /dev/null @@ -1,63 +0,0 @@ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { EmptyResultError } from '@agentrhq/webcmd/errors'; -import { - CHATGPT_DOMAIN, - CHATGPT_URL, - CONVERSATION_MESSAGE_SELECTOR, - ensureChatGPTLogin, - getChatGPTDetailRows, - normalizeBooleanFlag, - parseChatGPTConversationId, - requireNonNegativeInt, - requirePositiveInt, - waitForChatGPTDetailRows, -} from './utils.js'; - -export const detailCommand = cli({ - site: 'chatgpt', - name: 'detail', - access: 'read', - description: 'Open a ChatGPT web conversation by ID and read its messages', - domain: CHATGPT_DOMAIN, - strategy: Strategy.COOKIE, - browser: true, - siteSession: 'persistent', - navigateBefore: false, - args: [ - { name: 'id', positional: true, required: true, help: 'Conversation ID or full /c/ URL' }, - { name: 'markdown', type: 'boolean', default: false, help: 'Emit assistant replies as markdown' }, - { name: 'wait', type: 'boolean', default: false, help: 'Wait until the conversation stops generating and stabilizes' }, - { name: 'timeout', type: 'int', default: 120, help: 'Max seconds to wait when --wait is true' }, - { name: 'stable', type: 'int', default: 6, help: 'Seconds the final messages must remain unchanged when --wait is true' }, - ], - columns: ['Index', 'Role', 'Text', 'Generating', 'StableSeconds'], - func: async (page, kwargs) => { - const id = parseChatGPTConversationId(kwargs.id); - const wantMarkdown = normalizeBooleanFlag(kwargs.markdown, false); - const shouldWait = normalizeBooleanFlag(kwargs.wait, false); - const timeout = requirePositiveInt( - Number(kwargs.timeout ?? 120), - 'chatgpt detail --timeout', - 'Example: webcmd chatgpt detail --wait true --timeout 600', - ); - const stableSeconds = requireNonNegativeInt( - Number(kwargs.stable ?? 6), - 'chatgpt detail --stable', - 'Example: webcmd chatgpt detail --wait true --stable 6', - ); - await page.goto(`${CHATGPT_URL}/c/${id}`, { settleMs: 2000 }); - try { - await page.wait({ selector: CONVERSATION_MESSAGE_SELECTOR, timeout: 10 }); - } catch { - // Empty conversation, missing access, or login redirect — handled by ensureChatGPTLogin / EmptyResultError below. - } - await ensureChatGPTLogin(page, 'ChatGPT detail requires a logged-in ChatGPT session.'); - const { messages, rows } = shouldWait - ? await waitForChatGPTDetailRows(page, { wantMarkdown, timeoutSeconds: timeout, stableSeconds }) - : await getChatGPTDetailRows(page, { wantMarkdown }); - if (!messages.length) { - throw new EmptyResultError('chatgpt detail', `No visible ChatGPT messages were found for conversation ${id}.`); - } - return rows; - }, -}); diff --git a/plugins/chatgpt/history.js b/plugins/chatgpt/history.js deleted file mode 100644 index 3dc89dc0..00000000 --- a/plugins/chatgpt/history.js +++ /dev/null @@ -1,39 +0,0 @@ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { EmptyResultError } from '@agentrhq/webcmd/errors'; -import { - CHATGPT_DOMAIN, - ensureChatGPTLogin, - ensureOnChatGPT, - getConversationList, - requirePositiveInt, -} from './utils.js'; - -export const historyCommand = cli({ - site: 'chatgpt', - name: 'history', - access: 'read', - description: 'List visible ChatGPT web conversation history from the sidebar', - domain: CHATGPT_DOMAIN, - strategy: Strategy.COOKIE, - browser: true, - siteSession: 'persistent', - navigateBefore: false, - args: [ - { name: 'limit', type: 'int', default: 20, help: 'Max conversations to show' }, - ], - columns: ['Index', 'Id', 'Title', 'Url'], - func: async (page, kwargs) => { - const limit = requirePositiveInt( - Number(kwargs.limit ?? 20), - 'chatgpt history --limit', - 'Example: webcmd chatgpt history --limit 20', - ); - await ensureOnChatGPT(page); - await ensureChatGPTLogin(page, 'ChatGPT history requires a logged-in ChatGPT session.'); - const conversations = await getConversationList(page); - if (!conversations.length) { - throw new EmptyResultError('chatgpt history', 'No ChatGPT conversation links were visible in the sidebar.'); - } - return conversations.slice(0, limit); - }, -}); diff --git a/plugins/chatgpt/image.js b/plugins/chatgpt/image.js deleted file mode 100644 index 72068660..00000000 --- a/plugins/chatgpt/image.js +++ /dev/null @@ -1,187 +0,0 @@ -import * as os from 'node:os'; -import * as path from 'node:path'; -import * as fs from 'node:fs'; -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { saveBase64ToFile } from '@agentrhq/webcmd/utils'; -import { ArgumentError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors'; -import { clearChatGPTDraft, getChatGPTVisibleImageUrls, navigateToProject, normalizeBooleanFlag, prepareChatGPTImagePaths, sendChatGPTMessage, unwrapEvaluateResult, waitForChatGPTImages, getChatGPTImageAssets, uploadChatGPTImages } from './utils.js'; - -const CHATGPT_DOMAIN = 'chatgpt.com'; - -function extFromMime(mime) { - if (mime.includes('png')) return '.png'; - if (mime.includes('webp')) return '.webp'; - if (mime.includes('gif')) return '.gif'; - return '.jpg'; -} - -function displayPath(filePath) { - const home = os.homedir(); - return filePath.startsWith(home) ? `~${filePath.slice(home.length)}` : filePath; -} - -export function resolveOutputDir(value) { - const raw = String(value || '').trim(); - if (!raw) return path.join(os.homedir(), 'Pictures', 'chatgpt'); - if (raw === '~') return os.homedir(); - if (raw.startsWith('~/')) return path.join(os.homedir(), raw.slice(2)); - return path.resolve(raw); -} - -export function nextAvailablePath(dir, baseName, ext, existsSync = fs.existsSync) { - let candidate = path.join(dir, `${baseName}${ext}`); - for (let index = 1; existsSync(candidate); index += 1) { - candidate = path.join(dir, `${baseName}_${index}${ext}`); - } - return candidate; -} - -export function parseImagePaths(value) { - if (Array.isArray(value)) { - return value.flatMap(item => parseImagePaths(item)); - } - return String(value ?? '') - .split(',') - .map(item => item.trim()) - .filter(Boolean); -} - -function buildPrompt(prompt, imageCount) { - if (imageCount > 0) { - return `Edit the attached image${imageCount === 1 ? '' : 's'}: ${prompt}`; - } - return `Generate an image of: ${prompt}`; -} - -async function currentChatGPTLink(page) { - const url = unwrapEvaluateResult(await page.evaluate('window.location.href').catch(() => '')); - return typeof url === 'string' && url ? url : 'https://chatgpt.com'; -} - -export const imageCommand = cli({ - site: 'chatgpt', - name: 'image', - access: 'write', - description: 'Generate images with ChatGPT web and save them locally', - domain: CHATGPT_DOMAIN, - strategy: Strategy.COOKIE, - browser: true, - siteSession: 'persistent', - navigateBefore: false, - defaultFormat: 'plain', - args: [ - { name: 'prompt', positional: true, required: true, help: 'Image prompt to send to ChatGPT' }, - { - name: 'image', - help: 'Local image path to attach before prompting; comma-separated paths are supported', - file: { - direction: 'input', - pathKind: 'file', - multiple: true, - separator: ',', - contentTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], - maxBytes: 26_214_400, - }, - }, - { name: 'project', valueRequired: true, help: 'Start image generation inside a ChatGPT project ID or /g/g-p- URL' }, - { - name: 'op', - help: 'Output directory (default: ~/Pictures/chatgpt)', - file: { - direction: 'output', - pathKind: 'directory', - multiple: false, - defaultPath: '~/Pictures/chatgpt', - }, - }, - { name: 'sd', type: 'boolean', default: false, help: 'Skip download shorthand; only show ChatGPT link' }, - { name: 'timeout', type: 'int', required: false, default: 240, help: 'Max seconds for the overall command (default: 240)' }, - ], - columns: ['status', 'file', 'link'], - func: async (page, kwargs) => { - const prompt = kwargs.prompt; - const imagePaths = parseImagePaths(kwargs.image); - const outputDir = resolveOutputDir(kwargs.op); - const skipDownloadRaw = kwargs.sd; - const skipDownload = skipDownloadRaw === '' || skipDownloadRaw === true || normalizeBooleanFlag(skipDownloadRaw); - const timeout = kwargs.timeout; - if (!Number.isInteger(timeout) || timeout < 1) { - throw new ArgumentError('--timeout must be a positive integer (seconds)'); - } - const preparedImages = imagePaths.length ? await prepareChatGPTImagePaths(imagePaths) : { ok: true, paths: [] }; - if (!preparedImages.ok) { - throw new ArgumentError(preparedImages.reason); - } - - // Navigate with full reload to clear React sidebar state before editing the draft. - if (kwargs.project) { - await navigateToProject(page, kwargs.project); - } else { - await page.goto(`https://${CHATGPT_DOMAIN}/new`, { settleMs: 2000 }); - } - await clearChatGPTDraft(page); - - if (imagePaths.length) { - let upload; - try { - upload = await uploadChatGPTImages(page, preparedImages.paths); - } catch (err) { - throw new CommandExecutionError(`Failed to upload image to ChatGPT: ${err instanceof Error ? err.message : String(err)}`); - } - if (!upload?.ok) throw new CommandExecutionError(upload?.reason || 'Failed to upload image to ChatGPT'); - } - - const beforeUrls = await getChatGPTVisibleImageUrls(page); - - // Send an explicit generation/editing prompt so ChatGPT returns image assets. - const sent = await sendChatGPTMessage(page, buildPrompt(prompt, imagePaths.length)); - if (!sent) { - throw new CommandExecutionError( - 'Failed to send image prompt to ChatGPT', - `Open ${await currentChatGPTLink(page)} and verify the composer is ready.`, - ); - } - - // ChatGPT briefly navigates to /c/{id} after sending, then may - // redirect back to the home page. Poll until we capture the /c/ URL. - let convUrl = ''; - for (let ci = 0; ci < 10; ci++) { - const url = await currentChatGPTLink(page); - if (url.includes('/c/')) { convUrl = url; break; } - await page.wait(2); - } - if (!convUrl) { - convUrl = await currentChatGPTLink(page); - } - - const urls = await waitForChatGPTImages(page, beforeUrls, timeout, convUrl); - const link = convUrl; - - if (!urls.length) { - throw new EmptyResultError('chatgpt image', `No generated images were detected before timeout. Open ${link} and verify whether ChatGPT finished generating the image.`); - } - - if (skipDownload) { - return [{ status: '🎨 generated', file: '📁 -', link: `🔗 ${link}` }]; - } - - // Export and save images - const assets = await getChatGPTImageAssets(page, urls); - if (!assets.length) { - throw new CommandExecutionError('Failed to export generated ChatGPT image assets', `Open ${link} and verify the generated images are visible, then retry.`); - } - - const stamp = Date.now(); - const results = []; - for (let index = 0; index < assets.length; index += 1) { - const asset = assets[index]; - const base64 = asset.dataUrl.replace(/^data:[^;]+;base64,/, ''); - const suffix = assets.length > 1 ? `_${index + 1}` : ''; - const ext = extFromMime(asset.mimeType); - const filePath = nextAvailablePath(outputDir, `chatgpt_${stamp}${suffix}`, ext); - await saveBase64ToFile(base64, filePath); - results.push({ status: '✅ saved', file: `📁 ${displayPath(filePath)}`, link: `🔗 ${link}` }); - } - return results; - }, -}); diff --git a/plugins/chatgpt/model.js b/plugins/chatgpt/model.js deleted file mode 100644 index 5f18d179..00000000 --- a/plugins/chatgpt/model.js +++ /dev/null @@ -1,31 +0,0 @@ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { - CHATGPT_DOMAIN, - CHATGPT_MODEL_CHOICES, - navigateToProject, - selectChatGPTModel, -} from './utils.js'; - -export const modelCommand = cli({ - site: 'chatgpt', - name: 'model', - access: 'write', - description: 'Switch ChatGPT web model or intelligence level (GPT-5.6 Pro, fast, balanced, advanced, very-high, pro)', - domain: CHATGPT_DOMAIN, - strategy: Strategy.COOKIE, - browser: true, - siteSession: 'persistent', - navigateBefore: false, - args: [ - { name: 'model', required: true, positional: true, help: 'ChatGPT model or intelligence level to switch to', choices: CHATGPT_MODEL_CHOICES }, - { name: 'project', valueRequired: true, help: 'Open a ChatGPT project ID or /g/g-p- URL before switching intelligence level' }, - ], - columns: ['Status', 'Model'], - func: async (page, kwargs) => { - if (kwargs.project) { - await navigateToProject(page, kwargs.project); - } - const result = await selectChatGPTModel(page, kwargs.model); - return [{ Status: result.Status, Model: result.Model }]; - }, -}); diff --git a/plugins/chatgpt/new.js b/plugins/chatgpt/new.js deleted file mode 100644 index 92bac577..00000000 --- a/plugins/chatgpt/new.js +++ /dev/null @@ -1,32 +0,0 @@ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { - CHATGPT_DOMAIN, - ensureChatGPTComposer, - startNewChat, - navigateToProject, -} from './utils.js'; - -export const newCommand = cli({ - site: 'chatgpt', - name: 'new', - access: 'read', - description: 'Start a new ChatGPT web conversation', - domain: CHATGPT_DOMAIN, - strategy: Strategy.COOKIE, - browser: true, - siteSession: 'persistent', - navigateBefore: false, - args: [ - { name: 'project', valueRequired: true, help: 'Start a new chat inside a ChatGPT project ID or /g/g-p- URL' }, - ], - columns: ['Status'], - func: async (page, kwargs = {}) => { - if (kwargs.project) { - await navigateToProject(page, kwargs.project); - } else { - await startNewChat(page); - } - await ensureChatGPTComposer(page, 'ChatGPT new requires a logged-in ChatGPT session with a visible composer.'); - return [{ Status: 'New chat started' }]; - }, -}); diff --git a/plugins/chatgpt/package.json b/plugins/chatgpt/package.json deleted file mode 100644 index 6b1df665..00000000 --- a/plugins/chatgpt/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "webcmd-plugin-chatgpt", - "version": "0.1.0", - "type": "module", - "description": "Webcmd commands for chatgpt", - "peerDependencies": { - "@agentrhq/webcmd": ">=0.5.3" - } -} diff --git a/plugins/chatgpt/project-file-add.js b/plugins/chatgpt/project-file-add.js deleted file mode 100644 index e073d948..00000000 --- a/plugins/chatgpt/project-file-add.js +++ /dev/null @@ -1,97 +0,0 @@ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { ArgumentError, CommandExecutionError } from '@agentrhq/webcmd/errors'; -import { - CHATGPT_DOMAIN, - CHATGPT_URL, - parseChatGPTProjectId, - uploadChatGPTProjectFiles, -} from './utils.js'; - -function parseFilePaths(value) { - if (Array.isArray(value)) { - return value.flatMap(item => parseFilePaths(item)); - } - return String(value ?? '') - .split(',') - .map(item => item.trim()) - .filter(Boolean); -} - -export const projectFileAddCommand = cli({ - site: 'chatgpt', - name: 'project-file-add', - access: 'write', - description: 'Upload files to a ChatGPT project as project knowledge (not just conversation attachments)', - domain: CHATGPT_DOMAIN, - strategy: Strategy.COOKIE, - browser: true, - siteSession: 'persistent', - navigateBefore: false, - args: [ - { - name: 'file', - positional: true, - required: true, - help: 'Local file path(s) to upload; comma-separated paths are supported', - file: { - direction: 'input', - pathKind: 'file', - multiple: true, - separator: ',', - contentTypes: [ - 'application/pdf', - 'text/plain', - 'text/markdown', - 'text/csv', - 'application/json', - 'image/jpeg', - 'image/png', - 'image/gif', - 'image/webp', - ], - maxBytes: 26_214_400, - }, - }, - { name: 'id', required: true, help: 'Project ID or /g/g-p- URL' }, - ], - columns: ['Status', 'File'], - func: async (page, kwargs) => { - const filePaths = parseFilePaths(kwargs.file); - if (!filePaths.length) { - throw new ArgumentError( - 'chatgpt project-file-add requires at least one file path', - 'Example: webcmd chatgpt project-file-add report.pdf --id 12345678', - ); - } - - const projectId = parseChatGPTProjectId(kwargs.id); - - let upload; - try { - upload = await uploadChatGPTProjectFiles(page, projectId, filePaths); - } catch (err) { - throw new CommandExecutionError( - `Failed to upload file to ChatGPT project knowledge: ${err instanceof Error ? err.message : String(err)}`, - ); - } - - if (upload?.inputError) { - throw new ArgumentError( - upload.reason || 'Invalid project file path', - 'Provide an existing local file path that ChatGPT project knowledge can upload.', - ); - } - - if (!upload?.ok) { - throw new CommandExecutionError( - upload?.reason || 'Failed to upload file to ChatGPT project knowledge', - `Open ${CHATGPT_URL}/g/g-p-${projectId} and verify the project accepts file uploads. If your browser needs a proxy, configure it outside this command.`, - ); - } - - return upload.files.map(file => ({ - Status: '📄 uploaded to project knowledge', - File: file, - })); - }, -}); diff --git a/plugins/chatgpt/project-list.js b/plugins/chatgpt/project-list.js deleted file mode 100644 index fae3b904..00000000 --- a/plugins/chatgpt/project-list.js +++ /dev/null @@ -1,37 +0,0 @@ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { EmptyResultError } from '@agentrhq/webcmd/errors'; -import { - CHATGPT_DOMAIN, - ensureChatGPTLogin, - getProjectList, - requirePositiveInt, -} from './utils.js'; - -export const projectListCommand = cli({ - site: 'chatgpt', - name: 'project-list', - access: 'read', - description: 'List visible ChatGPT projects from the sidebar', - domain: CHATGPT_DOMAIN, - strategy: Strategy.COOKIE, - browser: true, - siteSession: 'persistent', - navigateBefore: false, - args: [ - { name: 'limit', type: 'int', default: 20, help: 'Max projects to show' }, - ], - columns: ['Index', 'Id', 'Title', 'Url'], - func: async (page, kwargs) => { - const limit = requirePositiveInt( - Number(kwargs.limit ?? 20), - 'chatgpt project-list --limit', - 'Example: webcmd chatgpt project-list --limit 20', - ); - await ensureChatGPTLogin(page, 'ChatGPT project-list requires a logged-in ChatGPT session.'); - const projects = await getProjectList(page); - if (!projects.length) { - throw new EmptyResultError('chatgpt project-list', 'No ChatGPT project links were visible in the sidebar.'); - } - return projects.slice(0, limit); - }, -}); diff --git a/plugins/chatgpt/read.js b/plugins/chatgpt/read.js deleted file mode 100644 index aff28896..00000000 --- a/plugins/chatgpt/read.js +++ /dev/null @@ -1,44 +0,0 @@ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { EmptyResultError } from '@agentrhq/webcmd/errors'; -import { - CHATGPT_DOMAIN, - ensureChatGPTLogin, - ensureOnChatGPT, - getVisibleMessages, - messageHtmlToMarkdown, - normalizeBooleanFlag, -} from './utils.js'; - -export const readCommand = cli({ - site: 'chatgpt', - name: 'read', - access: 'read', - description: 'Read messages in the current ChatGPT web conversation', - domain: CHATGPT_DOMAIN, - strategy: Strategy.COOKIE, - browser: true, - siteSession: 'persistent', - navigateBefore: false, - args: [ - { name: 'markdown', type: 'boolean', default: false, help: 'Emit assistant replies as markdown' }, - ], - columns: ['Index', 'Role', 'Text'], - func: async (page, kwargs) => { - const wantMarkdown = normalizeBooleanFlag(kwargs.markdown, false); - // ensureOnChatGPT now waits for the composer selector after navigating, - // so the previous standalone 2 s settle is redundant. - await ensureOnChatGPT(page); - await ensureChatGPTLogin(page, 'ChatGPT read requires a logged-in ChatGPT session.'); - const messages = await getVisibleMessages(page); - if (!messages.length) { - throw new EmptyResultError('chatgpt read', 'No visible ChatGPT messages were found in the current conversation.'); - } - return messages.map((message) => ({ - Index: message.Index, - Role: message.Role, - Text: wantMarkdown && message.Role === 'Assistant' && message.Html - ? (messageHtmlToMarkdown(message.Html) || message.Text) - : message.Text, - })); - }, -}); diff --git a/plugins/chatgpt/send.js b/plugins/chatgpt/send.js deleted file mode 100644 index 0191f0b9..00000000 --- a/plugins/chatgpt/send.js +++ /dev/null @@ -1,68 +0,0 @@ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { ArgumentError, CommandExecutionError } from '@agentrhq/webcmd/errors'; -import { - CHATGPT_DOMAIN, - CHATGPT_URL, - ensureChatGPTComposer, - ensureOnChatGPT, - normalizeBooleanFlag, - openChatGPTConversation, - requireNonEmptyPrompt, - sendChatGPTMessage, - startNewChat, - navigateToProject, -} from './utils.js'; - -export const sendCommand = cli({ - site: 'chatgpt', - name: 'send', - access: 'write', - description: 'Send a prompt to ChatGPT web without waiting for the response', - domain: CHATGPT_DOMAIN, - strategy: Strategy.COOKIE, - browser: true, - siteSession: 'persistent', - navigateBefore: false, - args: [ - { name: 'prompt', positional: true, required: true, help: 'Prompt to send' }, - { name: 'new', type: 'boolean', default: false, help: 'Start a new chat before sending' }, - { name: 'conversation', valueRequired: true, help: 'Continue an existing ChatGPT conversation ID or /c/ URL' }, - { name: 'project', valueRequired: true, help: 'Start a new chat inside a ChatGPT project ID or /g/g-p- URL' }, - ], - columns: ['Status', 'InjectedText'], - func: async (page, kwargs) => { - const prompt = requireNonEmptyPrompt(kwargs.prompt, 'chatgpt send'); - - if (normalizeBooleanFlag(kwargs.new) && kwargs.conversation) { - throw new ArgumentError( - 'chatgpt send cannot use --new and --conversation together', - 'Choose either a new chat or an existing conversation.', - ); - } - if (kwargs.project && kwargs.conversation) { - throw new ArgumentError( - 'chatgpt send cannot use --project and --conversation together', - 'Choose either a project new chat or an existing conversation.', - ); - } - - if (kwargs.conversation) { - await openChatGPTConversation(page, kwargs.conversation); - } else if (kwargs.project) { - await navigateToProject(page, kwargs.project); - } else if (normalizeBooleanFlag(kwargs.new)) { - await startNewChat(page); - } else { - await ensureOnChatGPT(page); - } - // startNewChat / ensureOnChatGPT now wait for the composer selector - // after navigating, so the previous standalone 2 s settle is redundant. - await ensureChatGPTComposer(page, 'ChatGPT send requires a logged-in ChatGPT session with a visible composer.'); - - const sent = await sendChatGPTMessage(page, prompt); - if (!sent) { - throw new CommandExecutionError('Failed to send message to ChatGPT', `Open ${CHATGPT_URL} and verify the composer is ready.`); - } - return [{ Status: 'Success', InjectedText: prompt }]; - }, -}); diff --git a/plugins/chatgpt/status.js b/plugins/chatgpt/status.js deleted file mode 100644 index bedf6c09..00000000 --- a/plugins/chatgpt/status.js +++ /dev/null @@ -1,29 +0,0 @@ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { - CHATGPT_DOMAIN, - ensureOnChatGPT, - getPageState, -} from './utils.js'; - -export const statusCommand = cli({ - site: 'chatgpt', - name: 'status', - access: 'read', - description: 'Check ChatGPT web page availability and login state', - domain: CHATGPT_DOMAIN, - strategy: Strategy.COOKIE, - browser: true, - siteSession: 'persistent', - navigateBefore: false, - args: [], - columns: ['Status', 'Login', 'Url'], - func: async (page) => { - await ensureOnChatGPT(page); - const state = await getPageState(page); - return [{ - Status: state.hasComposer ? 'Connected' : 'Page not ready', - Login: state.isLoggedIn && !state.hasLoginGate ? 'Yes' : 'No', - Url: state.url, - }]; - }, -}); diff --git a/plugins/chatgpt/test/ask.test.js b/plugins/chatgpt/test/ask.test.js deleted file mode 100644 index baa56831..00000000 --- a/plugins/chatgpt/test/ask.test.js +++ /dev/null @@ -1,11 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { askCommand } from '../ask.js'; - -describe('chatgpt ask polling', () => { - it('uses pure sleep while waiting for an active generation to finish', () => { - const source = askCommand.func.toString(); - - expect(source).toContain('await page.sleep(3)'); - expect(source).not.toContain('await page.wait(3)'); - }); -}); diff --git a/plugins/chatgpt/test/commands.test.js b/plugins/chatgpt/test/commands.test.js deleted file mode 100644 index 3884781e..00000000 --- a/plugins/chatgpt/test/commands.test.js +++ /dev/null @@ -1,435 +0,0 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors'; -import { getRegistry } from '@agentrhq/webcmd/registry'; -import '../ask.js'; -import '../send.js'; -import '../read.js'; -import '../history.js'; -import '../detail.js'; -import '../deep-research-result.js'; -import '../new.js'; -import '../status.js'; -import '../image.js'; -import '../model.js'; -import '../project-list.js'; -import '../project-file-add.js'; - -const tempDirs = []; - -afterEach(() => { - vi.restoreAllMocks(); - while (tempDirs.length) { - fs.rmSync(tempDirs.pop(), { recursive: true, force: true }); - } -}); - -function createProjectUploadPageMock() { - return { - goto: vi.fn().mockResolvedValue(undefined), - wait: vi.fn().mockResolvedValue(undefined), - setFileInput: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn((script) => { - const s = String(script); - if (s.includes('isVisible') && s.includes('hasComposer') && s.includes('isLoggedIn')) { - return Promise.resolve({ session: 'test', data: { url: 'https://chatgpt.com/g/g-p-12345678', title: 'Project', hasComposer: true, isLoggedIn: true, hasLoginGate: false } }); - } - if (s.includes('expectedFileNames')) return Promise.resolve({ ok: true }); - if (s.includes('Add files')) return Promise.resolve(true); - if (s.includes('role="dialog"')) return Promise.resolve(true); - return Promise.resolve(undefined); - }), - }; -} - -describe('chatgpt browser command registration', () => { - it('registers the baseline web chat commands with persistent site sessions', () => { - const expectedAccess = { - ask: 'write', - send: 'write', - read: 'read', - history: 'read', - detail: 'read', - 'deep-research-result': 'read', - new: 'read', - status: 'read', - image: 'write', - model: 'write', - 'project-list': 'read', - 'project-file-add': 'write', - }; - - for (const [name, access] of Object.entries(expectedAccess)) { - const cmd = getRegistry().get(`chatgpt/${name}`); - expect(cmd, `chatgpt/${name}`).toBeDefined(); - expect(cmd.site).toBe('chatgpt'); - expect(cmd.domain).toBe('chatgpt.com'); - expect(cmd.strategy).toBe('cookie'); - expect(cmd.browser).toBe(true); - expect(cmd.siteSession).toBe('persistent'); - expect(cmd.navigateBefore).toBe(false); - expect(cmd.access).toBe(access); - } - }); - - it('keeps ask timeout as the runtime-visible integer timeout arg', () => { - const ask = getRegistry().get('chatgpt/ask'); - expect(ask.args).toEqual(expect.arrayContaining([ - expect.objectContaining({ name: 'timeout', type: 'int', default: 120 }), - expect.objectContaining({ name: 'new', type: 'boolean', default: false }), - expect.objectContaining({ name: 'conversation', valueRequired: true }), - expect.objectContaining({ name: 'project', valueRequired: true }), - expect.objectContaining({ name: 'wait', type: 'boolean', default: true }), - expect.objectContaining({ name: 'deep-research', type: 'boolean', default: false }), - expect.objectContaining({ name: 'web-search', type: 'boolean', default: false }), - ])); - expect(ask.columns).toEqual(['conversationId', 'conversationUrl', 'tool', 'response']); - }); - - it('registers send conversation and project routing options', () => { - const send = getRegistry().get('chatgpt/send'); - expect(send.args).toEqual(expect.arrayContaining([ - expect.objectContaining({ name: 'new', type: 'boolean', default: false }), - expect.objectContaining({ name: 'conversation', valueRequired: true }), - expect.objectContaining({ name: 'project', valueRequired: true }), - ])); - }); - - it('rejects using project and conversation routing together', async () => { - const ask = getRegistry().get('chatgpt/ask'); - const send = getRegistry().get('chatgpt/send'); - const page = { - goto: () => { - throw new Error('should not navigate'); - }, - }; - - await expect(ask.func(page, { prompt: 'hello', project: '12345678', conversation: 'abcdefghi' })) - .rejects.toMatchObject({ code: 'ARGUMENT' }); - await expect(send.func(page, { prompt: 'hello', project: '12345678', conversation: 'abcdefghi' })) - .rejects.toMatchObject({ code: 'ARGUMENT' }); - }); - - it('registers detail wait options and generation state columns', () => { - const detail = getRegistry().get('chatgpt/detail'); - expect(detail.args).toEqual(expect.arrayContaining([ - expect.objectContaining({ name: 'wait', type: 'boolean', default: false }), - expect.objectContaining({ name: 'timeout', type: 'int', default: 120 }), - expect.objectContaining({ name: 'stable', type: 'int', default: 6 }), - ])); - expect(detail.columns).toEqual(['Index', 'Role', 'Text', 'Generating', 'StableSeconds']); - }); - - it('registers deep research result command with wait options', () => { - const command = getRegistry().get('chatgpt/deep-research-result'); - expect(command.args).toEqual(expect.arrayContaining([ - expect.objectContaining({ name: 'id', positional: true, required: true }), - expect.objectContaining({ name: 'wait', type: 'boolean', default: false }), - expect.objectContaining({ name: 'timeout', type: 'int', default: 120 }), - expect.objectContaining({ name: 'stable', type: 'int', default: 6 }), - ])); - expect(command.columns).toEqual([ - 'conversationId', - 'status', - 'report', - 'sources', - 'progress', - 'asyncTaskConversationId', - 'widgetSessionId', - 'asyncStatus', - 'venusMessageType', - 'venusStatus', - 'waitingForUserUntil', - 'planTitle', - 'planId', - 'url', - 'method', - 'diagnostics', - ]); - }); - - it('does not return a success row when no completed deep research report exists', async () => { - const command = getRegistry().get('chatgpt/deep-research-result'); - const page = { - goto: vi.fn().mockResolvedValue(undefined), - wait: vi.fn().mockResolvedValue(undefined), - startNetworkCapture: vi.fn().mockResolvedValue(true), - readNetworkCapture: vi.fn().mockResolvedValue([]), - getCookies: vi.fn().mockResolvedValue([]), - evaluate: vi.fn((script) => { - const s = String(script); - if (s === 'window.location.href') return Promise.resolve('https://chatgpt.com/'); - if (s.includes("fetch('/backend-api/conversation/requested123'")) { - return Promise.resolve({ - ok: true, - status: 200, - contentType: 'application/json', - text: JSON.stringify({ mapping: {} }), - }); - } - if (s.includes("document.querySelectorAll('iframe')")) { - return Promise.resolve({ - url: 'https://chatgpt.com/c/requested123', - title: 'ChatGPT', - iframes: [], - deepResearchIframe: null, - }); - } - if (s.includes('composerSelectors') && s.includes('hasComposer')) { - return Promise.resolve({ - url: 'https://chatgpt.com/c/requested123', - title: 'ChatGPT', - hasComposer: true, - isLoggedIn: true, - hasLoginGate: false, - }); - } - if (s.includes('Stop generating') || s.includes('Thinking')) return Promise.resolve(false); - return Promise.resolve(undefined); - }), - }; - - await expect(command.func(page, { id: 'requested123' })) - .rejects.toBeInstanceOf(EmptyResultError); - }); - - it('returns structured deep research progress without a completed report', async () => { - const command = getRegistry().get('chatgpt/deep-research-result'); - const payload = { - conversation_id: 'requested123', - mapping: { - progress_node: { - message: { - metadata: { - chatgpt_sdk: { - widget_state: JSON.stringify({ - status: 'waiting_for_user_response_on_plan', - waiting_for_user_response_on_plan_until: '2026-07-02T02:29:48.298274Z', - plan: { plan_id: 'plan-demo', title: 'Research plan' }, - }), - response_metadata: { - async_task_conversation_id: 'async-conversation-123', - 'openai/widgetSessionId': 'widget-session-123', - 'openai/asyncStatus': 7, - venus_message_type: 'initial_loading_message', - }, - }, - }, - }, - }, - }, - }; - const page = { - goto: vi.fn().mockResolvedValue(undefined), - wait: vi.fn().mockResolvedValue(undefined), - startNetworkCapture: vi.fn().mockResolvedValue(true), - readNetworkCapture: vi.fn().mockResolvedValue([]), - getCookies: vi.fn().mockResolvedValue([]), - evaluate: vi.fn((script) => { - const source = String(script); - if (source === 'window.location.href') return Promise.resolve('https://chatgpt.com/'); - if (source.includes("fetch('/backend-api/conversation/requested123'")) { - return Promise.resolve({ - ok: true, - status: 200, - contentType: 'application/json', - text: JSON.stringify(payload), - }); - } - if (source.includes("document.querySelectorAll('iframe')")) { - return Promise.resolve({ - url: 'https://chatgpt.com/c/requested123', - title: 'ChatGPT', - iframes: [], - deepResearchIframe: null, - }); - } - if (source.includes('composerSelectors') && source.includes('hasComposer')) { - return Promise.resolve({ - url: 'https://chatgpt.com/c/requested123', - title: 'ChatGPT', - hasComposer: true, - isLoggedIn: true, - hasLoginGate: false, - }); - } - if (source.includes('Stop generating') || source.includes('Thinking')) return Promise.resolve(false); - return Promise.resolve(undefined); - }), - }; - - await expect(command.func(page, { id: 'requested123' })).resolves.toEqual([ - expect.objectContaining({ - conversationId: 'requested123', - status: 'waiting_for_user', - report: '', - sources: [], - asyncTaskConversationId: 'async-conversation-123', - widgetSessionId: 'widget-session-123', - asyncStatus: 7, - venusMessageType: 'initial_loading_message', - venusStatus: 'waiting_for_user_response_on_plan', - waitingForUserUntil: '2026-07-02T02:29:48.298274Z', - planTitle: 'Research plan', - planId: 'plan-demo', - method: 'conversation-widget-progress', - }), - ]); - }); - - it('typed-fails malformed deep research source rows instead of falling back to empty success', async () => { - const command = getRegistry().get('chatgpt/deep-research-result'); - const report = `# Executive Summary\n\n${'Completed Deep Research report paragraph with enough detail to pass extraction heuristics. '.repeat(12)}\n\n## Sources`; - const payload = { - conversation_id: 'requested123', - mapping: { - report_node: { - message: { - metadata: { - chatgpt_sdk: { - widget_state: JSON.stringify({ - status: 'completed', - report_message: { - id: 'report-msg', - content: { parts: [report] }, - metadata: { - search_result_groups: [ - { entries: [{ title: 'Source without URL' }] }, - ], - }, - }, - }), - }, - }, - }, - }, - }, - }; - const page = { - goto: vi.fn().mockResolvedValue(undefined), - wait: vi.fn().mockResolvedValue(undefined), - startNetworkCapture: vi.fn().mockResolvedValue(true), - readNetworkCapture: vi.fn().mockResolvedValue([]), - getCookies: vi.fn().mockResolvedValue([]), - evaluate: vi.fn((script) => { - const s = String(script); - if (s === 'window.location.href') return Promise.resolve('https://chatgpt.com/'); - if (s.includes("fetch('/backend-api/conversation/requested123'")) { - return Promise.resolve({ - ok: true, - status: 200, - contentType: 'application/json', - text: JSON.stringify(payload), - }); - } - if (s.includes("document.querySelectorAll('iframe')")) { - return Promise.resolve({ - url: 'https://chatgpt.com/c/requested123', - title: 'ChatGPT', - iframes: [], - deepResearchIframe: null, - }); - } - if (s.includes('composerSelectors') && s.includes('hasComposer')) { - return Promise.resolve({ - url: 'https://chatgpt.com/c/requested123', - title: 'ChatGPT', - hasComposer: true, - isLoggedIn: true, - hasLoginGate: false, - }); - } - if (s.includes('Stop generating') || s.includes('Thinking')) return Promise.resolve(false); - return Promise.resolve(undefined); - }), - }; - - await expect(command.func(page, { id: 'requested123' })) - .rejects.toBeInstanceOf(CommandExecutionError); - }); - - it('registers project routing on chat-starting commands', () => { - for (const name of ['new', 'image', 'model']) { - const cmd = getRegistry().get(`chatgpt/${name}`); - expect(cmd.args).toEqual(expect.arrayContaining([ - expect.objectContaining({ name: 'project', valueRequired: true }), - ])); - } - }); - - it('starts a new chat inside a project when new receives project routing', async () => { - const cmd = getRegistry().get('chatgpt/new'); - const page = createProjectUploadPageMock(); - - await expect(cmd.func(page, { project: '12345678' })) - .resolves.toEqual([{ Status: 'New chat started' }]); - expect(page.goto).toHaveBeenCalledWith('https://chatgpt.com/g/g-p-12345678', { settleMs: 2000 }); - }); - - it('registers chatgpt model with web model choices', () => { - const model = getRegistry().get('chatgpt/model'); - expect(model.args).toEqual(expect.arrayContaining([ - expect.objectContaining({ - name: 'model', - positional: true, - required: true, - choices: expect.arrayContaining(['fast', 'speed', 'instant', 'balanced', 'balance', 'advanced', 'high', 'thinking', 'very-high', 'ultra', 'xhigh', 'x-high', 'pro', 'professional']), - }), - expect.objectContaining({ name: 'project', valueRequired: true }), - ])); - expect(model.columns).toEqual(['Status', 'Model']); - }); - - it('rejects off-domain conversation URLs before ask/send can navigate', async () => { - const ask = getRegistry().get('chatgpt/ask'); - const send = getRegistry().get('chatgpt/send'); - const page = { - goto: () => { - throw new Error('should not navigate'); - }, - }; - - await expect(ask.func(page, { prompt: 'hello', conversation: 'https://evil.test/c/abc_123-def' })) - .rejects.toMatchObject({ code: 'ARGUMENT' }); - await expect(send.func(page, { prompt: 'hello', conversation: 'https://evil.test/c/abc_123-def' })) - .rejects.toMatchObject({ code: 'ARGUMENT' }); - }); - - it('does not expose command-level system proxy mutation for project-file-add', () => { - const cmd = getRegistry().get('chatgpt/project-file-add'); - expect(cmd.args.map(arg => arg.name)).toEqual(['file', 'id']); - }); - - it('rejects empty project-file-add file input', async () => { - const cmd = getRegistry().get('chatgpt/project-file-add'); - await expect(cmd.func(createProjectUploadPageMock(), { file: ' , ', id: '12345678' })) - .rejects.toMatchObject({ code: 'ARGUMENT' }); - }); - - it('maps successful project-file-add uploads to table rows', async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-chatgpt-')); - tempDirs.push(dir); - const filePath = path.join(dir, 'report.pdf'); - fs.writeFileSync(filePath, 'fake-pdf'); - - const cmd = getRegistry().get('chatgpt/project-file-add'); - await expect(cmd.func(createProjectUploadPageMock(), { file: filePath, id: '12345678' })) - .resolves.toEqual([ - { - Status: '📄 uploaded to project knowledge', - File: filePath, - }, - ]); - }); - - it('maps project-file-add local file validation failures to argument errors', async () => { - const cmd = getRegistry().get('chatgpt/project-file-add'); - await expect(cmd.func(createProjectUploadPageMock(), { file: '/no/such/report.pdf', id: '12345678' })) - .rejects.toMatchObject({ - code: 'ARGUMENT', - message: expect.stringContaining('File not found'), - }); - }); -}); diff --git a/plugins/chatgpt/test/envelope.test.js b/plugins/chatgpt/test/envelope.test.js deleted file mode 100644 index fea05b97..00000000 --- a/plugins/chatgpt/test/envelope.test.js +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { CommandExecutionError } from '@agentrhq/webcmd/errors'; -import { - requireArrayEvaluateResult, - requireBooleanEvaluateResult, - requireObjectEvaluateResult, - unwrapEvaluateResult, -} from '../utils.js'; - -describe('chatgpt page.evaluate envelope helpers', () => { - describe('unwrapEvaluateResult', () => { - it('unwraps a { session, data } envelope produced by the browser bridge', () => { - const envelope = { session: 'site:chatgpt:abc', data: [{ id: 'msg-1' }] }; - expect(unwrapEvaluateResult(envelope)).toEqual([{ id: 'msg-1' }]); - }); - - it('passes raw arrays through unchanged (back-compat with older bridge versions)', () => { - const raw = [1, 2, 3]; - expect(unwrapEvaluateResult(raw)).toBe(raw); - }); - - it('passes primitive return values (URL strings, booleans) through unchanged', () => { - expect(unwrapEvaluateResult('https://chatgpt.com/c/abc')).toBe('https://chatgpt.com/c/abc'); - expect(unwrapEvaluateResult(true)).toBe(true); - expect(unwrapEvaluateResult(0)).toBe(0); - }); - - it('passes plain non-envelope objects through unchanged', () => { - const obj = { ok: true, reason: 'all good' }; - expect(unwrapEvaluateResult(obj)).toBe(obj); - }); - - it('handles null and undefined defensively', () => { - expect(unwrapEvaluateResult(null)).toBe(null); - expect(unwrapEvaluateResult(undefined)).toBe(undefined); - }); - }); - - describe('requireArrayEvaluateResult', () => { - it('returns the payload when it is an array', () => { - const rows = [{ id: 1 }, { id: 2 }]; - expect(requireArrayEvaluateResult(rows, 'chatgpt test')).toBe(rows); - }); - - it('throws a typed CommandExecutionError when the payload is the raw envelope (caller forgot to unwrap)', () => { - const envelope = { session: 'site:chatgpt:abc', data: [{ id: 1 }] }; - expect(() => requireArrayEvaluateResult(envelope, 'chatgpt visible image url extraction')) - .toThrowError(CommandExecutionError); - expect(() => requireArrayEvaluateResult(envelope, 'chatgpt visible image url extraction')) - .toThrow(/malformed extraction payload/); - }); - - it('surfaces the inner error message when the payload carries an `error` field', () => { - const errPayload = { error: 'image generator returned 500' }; - expect(() => requireArrayEvaluateResult(errPayload, 'chatgpt image asset export')) - .toThrow(/chatgpt image asset export: image generator returned 500/); - }); - - it('throws when the payload is null or a primitive', () => { - expect(() => requireArrayEvaluateResult(null, 'chatgpt test')).toThrowError(CommandExecutionError); - expect(() => requireArrayEvaluateResult('a string', 'chatgpt test')).toThrowError(CommandExecutionError); - }); - }); - - describe('requireObjectEvaluateResult', () => { - it('returns the payload when it is a plain object', () => { - const obj = { url: 'https://chatgpt.com', isLoggedIn: true }; - expect(requireObjectEvaluateResult(obj, 'chatgpt page state')).toBe(obj); - }); - - it('throws when the payload is an array or a primitive', () => { - expect(() => requireObjectEvaluateResult([], 'chatgpt page state')).toThrowError(CommandExecutionError); - expect(() => requireObjectEvaluateResult('string', 'chatgpt page state')).toThrowError(CommandExecutionError); - expect(() => requireObjectEvaluateResult(null, 'chatgpt page state')).toThrowError(CommandExecutionError); - }); - }); - - describe('requireBooleanEvaluateResult', () => { - it('returns booleans and rejects wrong-shape values', () => { - expect(requireBooleanEvaluateResult(true, 'chatgpt generation state')).toBe(true); - expect(requireBooleanEvaluateResult(false, 'chatgpt generation state')).toBe(false); - expect(() => requireBooleanEvaluateResult({ ok: true }, 'chatgpt generation state')) - .toThrowError(CommandExecutionError); - }); - }); - - describe('end-to-end envelope sweep', () => { - // The bridge envelope is shaped like { session, data } where `session` is - // any string and `data` is the actual return value. Verify the helpers - // chain correctly: unwrap → require* yields the inner shape. - it('unwrap + requireArray pipes an envelope through to the underlying array', () => { - const envelope = { - session: 'site:chatgpt:img-export', - data: [ - { url: 'https://a.example/1.png', dataUrl: 'data:image/png;base64,xxx', mimeType: 'image/png' }, - ], - }; - expect(requireArrayEvaluateResult(unwrapEvaluateResult(envelope), 'chatgpt image asset export')) - .toEqual(envelope.data); - }); - - it('unwrap + requireObject pipes an envelope through to the underlying object', () => { - const envelope = { session: 'site:chatgpt:state', data: { url: 'https://chatgpt.com', isLoggedIn: true } }; - expect(requireObjectEvaluateResult(unwrapEvaluateResult(envelope), 'chatgpt page state')) - .toEqual(envelope.data); - }); - }); -}); diff --git a/plugins/chatgpt/test/image.test.js b/plugins/chatgpt/test/image.test.js deleted file mode 100644 index b4ebed2e..00000000 --- a/plugins/chatgpt/test/image.test.js +++ /dev/null @@ -1,207 +0,0 @@ -import * as os from 'node:os'; -import * as path from 'node:path'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const mocks = vi.hoisted(() => ({ - getChatGPTVisibleImageUrls: vi.fn(), - clearChatGPTDraft: vi.fn(), - prepareChatGPTImagePaths: vi.fn(), - sendChatGPTMessage: vi.fn(), - uploadChatGPTImages: vi.fn(), - navigateToProject: vi.fn(), - waitForChatGPTImages: vi.fn(), - getChatGPTImageAssets: vi.fn(), - saveBase64ToFile: vi.fn(), -})); - -vi.mock('../utils.js', () => ({ - clearChatGPTDraft: mocks.clearChatGPTDraft, - getChatGPTVisibleImageUrls: mocks.getChatGPTVisibleImageUrls, - navigateToProject: mocks.navigateToProject, - normalizeBooleanFlag: (value, fallback = false) => { - if (typeof value === 'boolean') return value; - if (value == null || value === '') return fallback; - const normalized = String(value).trim().toLowerCase(); - return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on'; - }, - prepareChatGPTImagePaths: mocks.prepareChatGPTImagePaths, - sendChatGPTMessage: mocks.sendChatGPTMessage, - unwrapEvaluateResult: (payload) => { - if (payload && !Array.isArray(payload) && typeof payload === 'object' && 'session' in payload && 'data' in payload) { - return payload.data; - } - return payload; - }, - uploadChatGPTImages: mocks.uploadChatGPTImages, - waitForChatGPTImages: mocks.waitForChatGPTImages, - getChatGPTImageAssets: mocks.getChatGPTImageAssets, -})); - -vi.mock('@agentrhq/webcmd/utils', () => ({ - saveBase64ToFile: mocks.saveBase64ToFile, -})); - -const { imageCommand, nextAvailablePath, parseImagePaths, resolveOutputDir } = await import('../image.js'); - -function createPage() { - return { - goto: vi.fn().mockResolvedValue(undefined), - wait: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn().mockResolvedValue('https://chatgpt.com/c/test-conversation'), - }; -} - -beforeEach(() => { - vi.restoreAllMocks(); - mocks.clearChatGPTDraft.mockReset().mockResolvedValue(undefined); - mocks.prepareChatGPTImagePaths.mockReset().mockImplementation(async (paths) => ({ ok: true, paths })); - mocks.getChatGPTVisibleImageUrls.mockReset().mockResolvedValue([]); - mocks.sendChatGPTMessage.mockReset().mockResolvedValue(true); - mocks.uploadChatGPTImages.mockReset().mockResolvedValue({ ok: true }); - mocks.navigateToProject.mockReset().mockResolvedValue(undefined); - mocks.waitForChatGPTImages.mockReset().mockResolvedValue(['https://images.example/generated.png']); - mocks.getChatGPTImageAssets.mockReset().mockResolvedValue([{ - url: 'https://images.example/generated.png', - dataUrl: 'data:image/png;base64,aGVsbG8=', - mimeType: 'image/png', - }]); - mocks.saveBase64ToFile.mockReset().mockResolvedValue(undefined); -}); - -describe('chatgpt image output paths', () => { - it('expands the default and explicit home-relative output directories', () => { - expect(resolveOutputDir()).toBe(path.join(os.homedir(), 'Pictures', 'chatgpt')); - expect(resolveOutputDir('~/tmp/chatgpt-images')).toBe(path.join(os.homedir(), 'tmp', 'chatgpt-images')); - expect(resolveOutputDir('~')).toBe(os.homedir()); - }); - - it('generates a non-overwriting file path when a timestamp collision exists', () => { - const dir = '/tmp/chatgpt'; - const taken = new Set([ - path.join(dir, 'chatgpt_123.png'), - path.join(dir, 'chatgpt_123_1.png'), - ]); - - expect(nextAvailablePath(dir, 'chatgpt_123', '.png', (file) => taken.has(file))).toBe(path.join(dir, 'chatgpt_123_2.png')); - }); - - it('parses comma-separated image paths', () => { - expect(parseImagePaths('/tmp/a.png, /tmp/b.jpg')).toEqual(['/tmp/a.png', '/tmp/b.jpg']); - expect(parseImagePaths([' /tmp/a.png ', '/tmp/b.jpg,/tmp/c.webp'])).toEqual(['/tmp/a.png', '/tmp/b.jpg', '/tmp/c.webp']); - }); -}); - -describe('chatgpt image upload flow', () => { - it('starts image generation inside a specified project', async () => { - const page = createPage(); - await imageCommand.func(page, { - prompt: 'cat in a lab', - project: '12345678', - op: '', - sd: true, - timeout: 240, - }); - - expect(mocks.navigateToProject).toHaveBeenCalledWith(page, '12345678'); - expect(page.goto).not.toHaveBeenCalled(); - expect(mocks.clearChatGPTDraft).toHaveBeenCalled(); - expect(mocks.sendChatGPTMessage).toHaveBeenCalledWith(page, 'Generate an image of: cat in a lab'); - }); - - it('uploads local images before sending an edit prompt', async () => { - mocks.prepareChatGPTImagePaths.mockResolvedValue({ ok: true, paths: ['/abs/cat.png', '/abs/dog.jpg'] }); - await imageCommand.func(createPage(), { - prompt: 'make the background blue', - image: '/tmp/cat.png,/tmp/dog.jpg', - op: '', - sd: true, - timeout: 240, - }); - - expect(mocks.clearChatGPTDraft).toHaveBeenCalled(); - expect(mocks.uploadChatGPTImages).toHaveBeenCalledWith(expect.anything(), ['/abs/cat.png', '/abs/dog.jpg']); - expect(mocks.uploadChatGPTImages.mock.invocationCallOrder[0]).toBeLessThan( - mocks.getChatGPTVisibleImageUrls.mock.invocationCallOrder[0], - ); - expect(mocks.sendChatGPTMessage).toHaveBeenCalledWith(expect.anything(), 'Edit the attached images: make the background blue'); - }); - - it('rejects invalid local image paths before browser navigation', async () => { - mocks.prepareChatGPTImagePaths.mockResolvedValue({ ok: false, reason: 'Image not found: /tmp/missing.png' }); - const page = createPage(); - - await expect(imageCommand.func(page, { - prompt: 'make the background blue', - image: '/tmp/missing.png', - op: '', - sd: false, - timeout: 240, - })).rejects.toMatchObject({ - code: 'ARGUMENT', - message: expect.stringContaining('Image not found'), - }); - expect(page.goto).not.toHaveBeenCalled(); - expect(mocks.uploadChatGPTImages).not.toHaveBeenCalled(); - }); - - it('surfaces upload failures as command execution errors', async () => { - mocks.uploadChatGPTImages.mockResolvedValue({ ok: false, reason: 'image upload preview did not appear' }); - - await expect(imageCommand.func(createPage(), { - prompt: 'make the background blue', - image: '/tmp/cat.png', - op: '', - sd: false, - timeout: 240, - })).rejects.toMatchObject({ - code: 'COMMAND_EXEC', - message: expect.stringContaining('image upload preview did not appear'), - }); - }); -}); - -describe('chatgpt image failure contracts', () => { - it('fails fast when the image prompt cannot be sent', async () => { - mocks.sendChatGPTMessage.mockResolvedValue(false); - - await expect(imageCommand.func(createPage(), { - prompt: 'cat', - op: '', - sd: false, - timeout: 240, - })).rejects.toMatchObject({ - code: 'COMMAND_EXEC', - message: expect.stringContaining('Failed to send image prompt to ChatGPT'), - }); - expect(mocks.waitForChatGPTImages).not.toHaveBeenCalled(); - }); - - it('fails fast when image generation detection finds no new images', async () => { - mocks.waitForChatGPTImages.mockResolvedValue([]); - - await expect(imageCommand.func(createPage(), { - prompt: 'cat', - op: '', - sd: false, - timeout: 240, - })).rejects.toMatchObject({ - code: 'EMPTY_RESULT', - message: expect.stringContaining('chatgpt image returned no data'), - hint: expect.stringContaining('No generated images were detected'), - }); - }); - - it('fails fast when generated image assets cannot be exported', async () => { - mocks.getChatGPTImageAssets.mockResolvedValue([]); - - await expect(imageCommand.func(createPage(), { - prompt: 'cat', - op: '', - sd: false, - timeout: 240, - })).rejects.toMatchObject({ - code: 'COMMAND_EXEC', - message: expect.stringContaining('Failed to export generated ChatGPT image assets'), - }); - }); -}); diff --git a/plugins/chatgpt/test/model.test.js b/plugins/chatgpt/test/model.test.js deleted file mode 100644 index be7002cf..00000000 --- a/plugins/chatgpt/test/model.test.js +++ /dev/null @@ -1,42 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const mocks = vi.hoisted(() => ({ - navigateToProject: vi.fn(), - selectChatGPTModel: vi.fn(), -})); - -vi.mock('../utils.js', () => ({ - CHATGPT_DOMAIN: 'chatgpt.com', - CHATGPT_MODEL_CHOICES: ['fast', 'speed', 'instant', 'balanced', 'advanced', 'high', 'thinking', 'very-high', 'pro', 'gpt-5.6-pro'], - navigateToProject: mocks.navigateToProject, - selectChatGPTModel: mocks.selectChatGPTModel, -})); - -const { modelCommand } = await import('../model.js'); - -beforeEach(() => { - vi.restoreAllMocks(); - mocks.navigateToProject.mockReset().mockResolvedValue(undefined); - mocks.selectChatGPTModel.mockReset().mockResolvedValue({ Status: 'Success', Model: 'High' }); -}); - -describe('chatgpt model project routing', () => { - it('documents exact model targets alongside intelligence levels', () => { - expect(modelCommand.description).toContain('GPT-5.6 Pro'); - expect(modelCommand.args[0].help).toContain('model or intelligence level'); - expect(modelCommand.args[0].choices).toContain('gpt-5.6-pro'); - }); - - it('opens a project before selecting the requested model', async () => { - const page = {}; - - await expect(modelCommand.func(page, { model: 'high', project: '12345678' })) - .resolves.toEqual([{ Status: 'Success', Model: 'High' }]); - - expect(mocks.navigateToProject).toHaveBeenCalledWith(page, '12345678'); - expect(mocks.navigateToProject.mock.invocationCallOrder[0]).toBeLessThan( - mocks.selectChatGPTModel.mock.invocationCallOrder[0], - ); - expect(mocks.selectChatGPTModel).toHaveBeenCalledWith(page, 'high'); - }); -}); diff --git a/plugins/chatgpt/test/utils.test.js b/plugins/chatgpt/test/utils.test.js deleted file mode 100644 index 6104f096..00000000 --- a/plugins/chatgpt/test/utils.test.js +++ /dev/null @@ -1,2001 +0,0 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { JSDOM } from 'jsdom'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@agentrhq/webcmd/errors'; -import { CHATGPT_MODEL_CHOICES, __test__, getChatGPTDetailRows, getChatGPTImageAssets, getChatGPTResponsePairCounts, getChatGPTVisibleImageUrls, getCurrentChatGPTModel, getCurrentChatGPTTool, getVisibleMessages, isGenerating, navigateToProject, openChatGPTConversation, prepareChatGPTImagePaths, selectChatGPTModel, selectChatGPTTool, sendChatGPTMessage, uploadChatGPTImages, waitForChatGPTDeepResearchResult, waitForChatGPTDetailRows, waitForChatGPTImages, waitForChatGPTResponse } from '../utils.js'; - -const tempDirs = []; - -afterEach(() => { - vi.restoreAllMocks(); - while (tempDirs.length) { - fs.rmSync(tempDirs.pop(), { recursive: true, force: true }); - } -}); - -function createPageMock({ location = '', generating = [], imageUrls = [] } = {}) { - let generatingIndex = 0; - let imageIndex = 0; - return { - wait: vi.fn().mockResolvedValue(undefined), - sleep: vi.fn().mockResolvedValue(undefined), - goto: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn((script) => { - if (script === 'window.location.href') return Promise.resolve(location); - if (script.includes('Stop generating') || script.includes('Thinking')) { - const value = generating[Math.min(generatingIndex, generating.length - 1)] ?? false; - generatingIndex += 1; - return Promise.resolve(value); - } - if (script.includes("document.querySelectorAll('img')")) { - const value = imageUrls[Math.min(imageIndex, imageUrls.length - 1)] ?? []; - imageIndex += 1; - return Promise.resolve(value); - } - return Promise.resolve(undefined); - }), - }; -} - -function createDomEvaluatePage(html) { - const dom = new JSDOM(html, { - url: 'https://chatgpt.com/', - runScripts: 'outside-only', - }); - for (const node of dom.window.document.querySelectorAll('form, button, [role="menuitemradio"], [role="menuitem"], [role="option"], #prompt-textarea, [data-testid]')) { - node.getBoundingClientRect = () => ({ width: 120, height: 36 }); - node.scrollIntoView = () => {}; - } - return { - dom, - goto: vi.fn().mockResolvedValue(undefined), - wait: vi.fn().mockResolvedValue(undefined), - sleep: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn((script) => Promise.resolve(dom.window.eval(script))), - }; -} - -describe('chatgpt image wait contract', () => { - it('does not periodically reload the conversation while generation is still active', async () => { - const convUrl = 'https://chatgpt.com/c/demo'; - const page = createPageMock({ - location: convUrl, - generating: [true, true, true, true, true, true], - }); - - await expect(waitForChatGPTImages(page, [], 18, convUrl)).resolves.toEqual([]); - expect(page.goto).not.toHaveBeenCalled(); - }); - - it('jumps back to the captured conversation when the page drifts away', async () => { - const convUrl = 'https://chatgpt.com/c/demo'; - const page = createPageMock({ - location: 'https://chatgpt.com/', - generating: [false], - imageUrls: [['https://cdn.openai.com/generated/demo.png']], - }); - - await expect(waitForChatGPTImages(page, [], 3, convUrl)).resolves.toEqual([ - 'https://cdn.openai.com/generated/demo.png', - ]); - expect(page.goto).toHaveBeenCalledWith(convUrl); - }); - - it('treats query and hash variants as the same conversation', () => { - expect(__test__.isSameChatGPTConversation( - 'https://chatgpt.com/c/demo?model=gpt-image-1', - 'https://chatgpt.com/c/demo', - )).toBe(true); - expect(__test__.isSameChatGPTConversation( - 'https://chatgpt.com/c/other', - 'https://chatgpt.com/c/demo', - )).toBe(false); - }); -}); - -describe('chatgpt conversation id parsing', () => { - it('accepts ids and chatgpt conversation URLs', () => { - expect(__test__.parseChatGPTConversationId('abc_123-def')).toBe('abc_123-def'); - expect(__test__.parseChatGPTConversationId('https://chatgpt.com/c/abc_123-def?model=gpt-5')).toBe('abc_123-def'); - expect(__test__.parseChatGPTConversationId('https://chat.openai.chatgpt.com/c/abc_123-def')).toBe('abc_123-def'); - expect(__test__.parseChatGPTConversationId('https://chatgpt.com/g/g-p-12345678-demo/c/abc_123-def')).toBe('abc_123-def'); - expect(__test__.parseChatGPTConversationId('/c/abc_123-def')).toBe('abc_123-def'); - expect(__test__.parseChatGPTConversationId('/g/g-p-12345678-demo/c/abc_123-def')).toBe('abc_123-def'); - }); - - it('rejects invalid detail ids', () => { - expect(() => __test__.parseChatGPTConversationId('')).toThrow(/conversation id/); - expect(() => __test__.parseChatGPTConversationId('https://chatgpt.com/')).toThrow(/conversation id/); - }); - - it('rejects off-domain or ambiguous conversation URLs before routing writes', () => { - expect(() => __test__.parseChatGPTConversationId('https://evil.test/c/abc_123-def')).toThrow(/chatgpt\.com/); - expect(() => __test__.parseChatGPTConversationId('http://chatgpt.com/c/abc_123-def')).toThrow(/chatgpt\.com/); - expect(() => __test__.parseChatGPTConversationId('https://chatgpt.com.evil.test/c/abc_123-def')).toThrow(/chatgpt\.com/); - expect(() => __test__.parseChatGPTConversationId('/c/abc_123-def/extra')).toThrow(/conversation id/); - expect(() => __test__.parseChatGPTConversationId('prefix https://chatgpt.com/c/abc_123-def')).toThrow(/conversation id/); - }); -}); - -describe('chatgpt conversation navigation', () => { - it('opens conversation URLs by parsed id', async () => { - const page = { - goto: vi.fn().mockResolvedValue(undefined), - wait: vi.fn().mockResolvedValue(undefined), - }; - - await expect(openChatGPTConversation(page, 'https://chatgpt.com/c/abc_123-def?model=gpt-5')) - .resolves.toBe('abc_123-def'); - expect(page.goto).toHaveBeenCalledWith('https://chatgpt.com/c/abc_123-def', { settleMs: 2000 }); - expect(page.wait).toHaveBeenCalledWith({ selector: '#prompt-textarea, [data-testid="prompt-textarea"]', timeout: 8 }); - }); -}); - -function makeDeepResearchReport() { - return [ - '# Executive Summary', - '', - 'This completed Deep Research report is intentionally long enough to pass extraction heuristics.', - 'It summarizes findings, constraints, evidence, and recommendations from multiple public sources.', - 'The extraction path should read this markdown from metadata.chatgpt_sdk.widget_state.report_message.content.parts[0].', - 'Using the conversation payload avoids the cross-origin internal deep research iframe boundary.', - 'The report body includes repeated detail so the parser treats it as a real report, not a short UI preview.', - 'Findings show that reliable automation should prefer captured backend conversation JSON over iframe DOM access.', - 'Recommendations include returning diagnostics when no report is present and bounding source extraction.', - 'References and Sources are represented in metadata content references, safe URLs, and search result groups.', - 'Additional detail confirms that source de-duplication should key by URL and keep a readable title.', - 'This paragraph pads the fixture with realistic report text for the minimum-length guard.', - 'Another paragraph pads the fixture with realistic report text for the minimum-length guard.', - 'A final paragraph pads the fixture with realistic report text for the minimum-length guard.', - '', - '## Sources', - '', - '- Example source', - ].join('\n'); -} - -function makeDeepResearchPayload(report = makeDeepResearchReport(), { conversationId = '' } = {}) { - const payload = { - mapping: { - report_node: { - message: { - metadata: { - chatgpt_sdk: { - widget_state: JSON.stringify({ - status: 'completed', - report_message: { - id: 'report-msg', - content: { parts: [report] }, - metadata: { - content_references: [ - { title: 'Reference A', url: 'https://example.com/a' }, - { matched_text: 'Matched B', url: 'https://example.com/b' }, - ], - safe_urls: ['https://example.com/c'], - search_result_groups: [ - { entries: [{ title: 'Reference D', url: 'https://example.com/d' }] }, - ], - }, - }, - }), - }, - }, - }, - }, - }, - }; - if (conversationId) payload.conversation_id = conversationId; - return payload; -} - -function makeDeepResearchProgressPayload(status = 'waiting_for_user_response_on_plan') { - return { - mapping: { - progress_node: { - message: { - metadata: { - chatgpt_sdk: { - widget_state: JSON.stringify({ - status, - waiting_for_user_response_on_plan_until: '2026-07-02T02:29:48.298274Z', - plan: { - plan_id: 'plan-demo', - title: 'Research plan', - steps: [{ id: 'step-1', title: 'Collect sources', status: 'pending' }], - }, - step_statuses_by_plan: { 'step-1': 'pending' }, - }), - response_metadata: { - async_task_conversation_id: 'async-conversation-123', - 'openai/widgetSessionId': 'widget-session-123', - 'openai/asyncStatus': 7, - venus_message_type: 'initial_loading_message', - }, - }, - }, - }, - }, - }, - }; -} - -describe('chatgpt deep research result extraction', () => { - it('extracts report markdown and sources from conversation widget_state', () => { - const result = __test__.extractDeepResearchFromConversationPayload(makeDeepResearchPayload()); - - expect(result).toMatchObject({ - status: 'completed', - method: 'conversation-widget-state', - reportMessageId: 'report-msg', - reportLength: expect.any(Number), - }); - expect(result.report).toContain('Executive Summary'); - expect(result.sources).toEqual(expect.arrayContaining([ - { title: 'Reference A', url: 'https://example.com/a' }, - { title: 'Matched B', url: 'https://example.com/b' }, - { title: '', url: 'https://example.com/c' }, - { title: 'Reference D', url: 'https://example.com/d' }, - ])); - }); - - it('extracts the requested report from captured conversation network entries', () => { - const shorterReport = `${makeDeepResearchReport()}\n\nshort`; - const longerReport = `${makeDeepResearchReport()}\n\nAdditional longer section.`; - const result = __test__.extractDeepResearchFromNetworkEntries([ - { url: 'https://chatgpt.com/backend-api/bootstrap', responsePreview: '{}' }, - { - url: 'https://chatgpt.com/backend-api/conversation/requested123', - responsePreview: JSON.stringify(makeDeepResearchPayload(shorterReport, { conversationId: 'requested123' })), - }, - { - url: 'https://chatgpt.com/backend-api/conversation/stale45678', - responsePreview: JSON.stringify(makeDeepResearchPayload(longerReport, { conversationId: 'stale45678' })), - }, - ], { expectedConversationId: 'requested123' }); - - expect(result.method).toBe('network-conversation-widget-state'); - expect(result.networkUrl).toContain('/conversation/requested123'); - expect(result.report).not.toContain('Additional longer section'); - }); - - it('typed-fails when the conversation payload id does not match the requested id', () => { - expect(() => __test__.extractDeepResearchFromConversationPayload( - makeDeepResearchPayload(makeDeepResearchReport(), { conversationId: 'stale45678' }), - { expectedConversationId: 'requested123' }, - )).toThrow(CommandExecutionError); - }); - - it('typed-fails malformed source rows instead of silently dropping them', () => { - const payload = makeDeepResearchPayload(); - const widget = JSON.parse(payload.mapping.report_node.message.metadata.chatgpt_sdk.widget_state); - widget.report_message.metadata.search_result_groups = [ - { entries: [{ title: 'Source without URL' }] }, - ]; - payload.mapping.report_node.message.metadata.chatgpt_sdk.widget_state = JSON.stringify(widget); - - expect(() => __test__.extractDeepResearchFromConversationPayload(payload)) - .toThrow(CommandExecutionError); - }); - - it('typed-fails malformed conversation payloads instead of treating them as empty reports', () => { - expect(() => __test__.extractDeepResearchFromConversationPayload({})) - .toThrow(CommandExecutionError); - }); - - it('extracts waiting-for-user progress from widget metadata without a report', () => { - const result = __test__.extractDeepResearchFromConversationPayload(makeDeepResearchProgressPayload()); - - expect(result).toMatchObject({ - status: 'waiting_for_user', - method: 'conversation-widget-progress', - asyncTaskConversationId: 'async-conversation-123', - widgetSessionId: 'widget-session-123', - asyncStatus: 7, - venusMessageType: 'initial_loading_message', - venusStatus: 'waiting_for_user_response_on_plan', - waitingForUserUntil: '2026-07-02T02:29:48.298274Z', - planId: 'plan-demo', - planTitle: 'Research plan', - }); - expect(result.report).toBe(''); - expect(result.progress.planSteps).toEqual([ - { id: 'step-1', title: 'Collect sources', status: 'pending' }, - ]); - }); - - it('ignores unrelated SDK response metadata without Deep Research identity', () => { - const payload = { - mapping: { - app_widget: { - message: { - metadata: { - chatgpt_sdk: { - response_metadata: { - 'openai/widgetSessionId': 'unrelated-widget', - 'openai/asyncStatus': 1, - }, - }, - }, - }, - }, - }, - }; - - expect(__test__.extractDeepResearchFromConversationPayload(payload)).toBeNull(); - }); - - it('prefers the current Deep Research progress over stale actionable progress', () => { - const stale = makeDeepResearchProgressPayload().mapping.progress_node; - stale.message.create_time = 1; - const current = makeDeepResearchProgressPayload('running').mapping.progress_node; - current.message.create_time = 2; - const payload = { - current_node: 'running_node', - mapping: { - waiting_node: { ...stale, parent: null }, - running_node: { ...current, parent: 'waiting_node' }, - }, - }; - - expect(__test__.extractDeepResearchFromConversationPayload(payload)).toMatchObject({ - status: 'running', - venusStatus: 'running', - }); - }); - - it('ignores short widget previews that are not completed reports', () => { - expect(__test__.extractDeepResearchFromConversationPayload(makeDeepResearchPayload('short preview'))).toBeNull(); - }); - - it('stops waiting immediately when widget state needs user input', async () => { - const page = { - getCookies: vi.fn().mockResolvedValue([]), - wait: vi.fn().mockResolvedValue(undefined), - sleep: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn((script) => { - const source = String(script); - if (source.includes("document.querySelectorAll('iframe')")) { - return Promise.resolve({ - url: 'https://chatgpt.com/c/requested123', - title: 'ChatGPT', - iframes: [], - deepResearchIframe: null, - }); - } - if (source.includes('Stop generating') || source.includes('Thinking')) return Promise.resolve(false); - if (source.includes('/backend-api/conversation/')) { - return Promise.resolve({ - ok: true, - status: 200, - contentType: 'application/json', - text: JSON.stringify(makeDeepResearchProgressPayload()), - }); - } - return Promise.resolve(undefined); - }), - }; - - const result = await waitForChatGPTDeepResearchResult(page, { - conversationId: 'requested123', - timeoutSeconds: 180, - stableSeconds: 3, - }); - - expect(result.status).toBe('waiting_for_user'); - expect(result.venusStatus).toBe('waiting_for_user_response_on_plan'); - expect(page.sleep).not.toHaveBeenCalled(); - }); - - it('continues waiting through a transient missing state before progress appears', async () => { - let conversationReads = 0; - const page = { - getCookies: vi.fn().mockResolvedValue([]), - wait: vi.fn().mockResolvedValue(undefined), - sleep: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn((script) => { - const source = String(script); - if (source.includes("document.querySelectorAll('iframe')")) { - return Promise.resolve({ - url: 'https://chatgpt.com/c/requested123', - title: 'ChatGPT', - iframes: [], - deepResearchIframe: null, - }); - } - if (source.includes('Stop generating') || source.includes('Thinking')) return Promise.resolve(false); - if (source.includes('/backend-api/conversation/')) { - conversationReads += 1; - return Promise.resolve({ - ok: true, - status: 200, - contentType: 'application/json', - text: JSON.stringify(conversationReads === 1 ? { mapping: {} } : makeDeepResearchProgressPayload()), - }); - } - return Promise.resolve(undefined); - }), - }; - - const result = await waitForChatGPTDeepResearchResult(page, { - conversationId: 'requested123', - timeoutSeconds: 180, - stableSeconds: 3, - }); - - expect(result.status).toBe('waiting_for_user'); - expect(page.sleep).toHaveBeenCalledWith(3); - expect(conversationReads).toBe(2); - }); -}); - -describe('chatgpt model selection validation', () => { - it('offers practical GPT-5.6 Pro aliases to CLI callers', () => { - expect(CHATGPT_MODEL_CHOICES).toEqual(expect.arrayContaining([ - 'gpt-5.6-pro', - 'gpt-5-6-pro', - 'gpt-5.6-sol-pro', - 'gpt-5.6', - '5.6', - ])); - }); - - it('rejects unknown model names', async () => { - await expect(selectChatGPTModel({ nativeClick: vi.fn() }, 'unknown')) - .rejects.toBeInstanceOf(ArgumentError); - await expect(selectChatGPTModel({ nativeClick: vi.fn() }, 'unknown')) - .rejects.toThrow('Unknown ChatGPT model "unknown"'); - }); - - it('requires native browser click support', async () => { - await expect(selectChatGPTModel({}, 'pro')) - .rejects.toBeInstanceOf(CommandExecutionError); - await expect(selectChatGPTModel({}, 'pro')) - .rejects.toThrow('ChatGPT model selection requires native browser click support.'); - }); - - it('clicks the model selector and verifies the selected postcondition', async () => { - let objectCall = 0; - const page = { - goto: vi.fn().mockResolvedValue(undefined), - wait: vi.fn().mockResolvedValue(undefined), - nativeClick: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn((script) => { - if (script === 'window.location.href') return Promise.resolve('https://chatgpt.com/c/demo'); - objectCall += 1; - if (objectCall === 1) return Promise.resolve({ isLoggedIn: true, hasLoginGate: false, hasComposer: true }); - if (objectCall === 2) return Promise.resolve({ model: 'balanced', label: 'Balanced' }); - if (objectCall === 3) return Promise.resolve({ found: true, x: 10, y: 20 }); - if (objectCall === 4) return Promise.resolve({ found: true, x: 30, y: 40 }); - if (objectCall === 5) return Promise.resolve({ model: 'fast', label: 'Fast' }); - return Promise.resolve({}); - }), - }; - - await expect(selectChatGPTModel(page, 'fast')).resolves.toEqual({ Status: 'Success', Model: 'Fast' }); - expect(page.nativeClick).toHaveBeenNthCalledWith(1, 10, 20); - expect(page.nativeClick).toHaveBeenNthCalledWith(2, 30, 40); - }); - - it('sets Advanced through the ChatGPT model config API when browser cookies are available', async () => { - const fetchMock = vi.spyOn(globalThis, 'fetch') - .mockResolvedValueOnce(new Response(JSON.stringify({ accessToken: 'token' }), { status: 200 })) - .mockResolvedValueOnce(new Response(JSON.stringify({ success: true }), { status: 200 })); - let objectCall = 0; - const page = { - goto: vi.fn().mockResolvedValue(undefined), - wait: vi.fn().mockResolvedValue(undefined), - nativeClick: vi.fn().mockResolvedValue(undefined), - getCookies: vi.fn().mockResolvedValue([{ name: '__Secure-next-auth.session-token', value: 'cookie', domain: '.chatgpt.com' }]), - evaluate: vi.fn((script) => { - if (script === 'window.location.href') return Promise.resolve('https://chatgpt.com/c/demo'); - if (String(script).includes('oai-last-model-config')) return Promise.resolve(true); - objectCall += 1; - if (objectCall === 1) return Promise.resolve({ isLoggedIn: true, hasLoginGate: false, hasComposer: true }); - if (objectCall === 2) return Promise.resolve({ model: 'balanced', label: 'Balanced' }); - if (objectCall === 3) return Promise.resolve({ isLoggedIn: true, hasLoginGate: false, hasComposer: true }); - if (objectCall === 4) return Promise.resolve({ model: 'advanced', label: 'Advanced' }); - return Promise.resolve({}); - }), - }; - - await expect(selectChatGPTModel(page, 'thinking')).resolves.toEqual({ Status: 'Success', Model: 'Advanced' }); - expect(fetchMock.mock.calls[1][0]).toContain('/backend-api/settings/user_last_used_model_config'); - expect(fetchMock.mock.calls[1][0]).toContain('model_slug=gpt-5-5-thinking'); - expect(fetchMock.mock.calls[1][0]).toContain('thinking_effort=extended'); - expect(page.nativeClick).not.toHaveBeenCalled(); - }); - - it('sets GPT-5.6 Pro through the exact ChatGPT model config slug', async () => { - const fetchMock = vi.spyOn(globalThis, 'fetch') - .mockResolvedValueOnce(new Response(JSON.stringify({ accessToken: 'token' }), { status: 200 })) - .mockResolvedValueOnce(new Response(JSON.stringify({ success: true }), { status: 200 })); - let objectCall = 0; - const page = { - goto: vi.fn().mockResolvedValue(undefined), - wait: vi.fn().mockResolvedValue(undefined), - nativeClick: vi.fn().mockResolvedValue(undefined), - getCookies: vi.fn().mockResolvedValue([{ name: '__Secure-next-auth.session-token', value: 'cookie', domain: '.chatgpt.com' }]), - evaluate: vi.fn((script) => { - if (script === 'window.location.href') return Promise.resolve('https://chatgpt.com/c/demo'); - if (String(script).includes('oai-last-model-config')) return Promise.resolve(true); - objectCall += 1; - if (objectCall === 1) return Promise.resolve({ isLoggedIn: true, hasLoginGate: false, hasComposer: true }); - if (objectCall === 2) return Promise.resolve({ model: 'balanced', label: 'Balanced' }); - if (objectCall === 3) return Promise.resolve({ isLoggedIn: true, hasLoginGate: false, hasComposer: true }); - if (objectCall === 4) return Promise.resolve({ model: 'gpt-5.6-pro', label: 'GPT-5.6 Pro' }); - return Promise.resolve({}); - }), - }; - - await expect(selectChatGPTModel(page, 'gpt-5.6-pro')) - .resolves.toEqual({ Status: 'Success', Model: 'GPT-5.6 Pro' }); - expect(fetchMock.mock.calls[1][0]).toContain('model_slug=gpt-5-6-pro'); - expect(fetchMock.mock.calls[1][0]).toContain('thinking_effort=standard'); - expect(page.nativeClick).not.toHaveBeenCalled(); - }); - - it('does not accept generic Pro read-back as proof of GPT-5.6 Pro selection', async () => { - vi.spyOn(globalThis, 'fetch') - .mockResolvedValueOnce(new Response(JSON.stringify({ accessToken: 'token' }), { status: 200 })) - .mockResolvedValueOnce(new Response(JSON.stringify({ success: true }), { status: 200 })); - let objectCall = 0; - const page = { - goto: vi.fn().mockResolvedValue(undefined), - wait: vi.fn().mockResolvedValue(undefined), - nativeClick: vi.fn().mockResolvedValue(undefined), - getCookies: vi.fn().mockResolvedValue([{ name: '__Secure-next-auth.session-token', value: 'cookie', domain: '.chatgpt.com' }]), - evaluate: vi.fn((script) => { - if (script === 'window.location.href') return Promise.resolve('https://chatgpt.com/c/demo'); - if (String(script).includes('oai-last-model-config')) return Promise.resolve(true); - objectCall += 1; - if (objectCall === 1) return Promise.resolve({ isLoggedIn: true, hasLoginGate: false, hasComposer: true }); - if (objectCall === 2) return Promise.resolve({ model: 'pro', label: 'Pro' }); - if (objectCall === 3) return Promise.resolve({ isLoggedIn: true, hasLoginGate: false, hasComposer: true }); - if (objectCall === 4) return Promise.resolve({ model: 'pro', label: 'Pro' }); - if (objectCall === 5) return Promise.resolve({ found: true, x: 10, y: 20 }); - return Promise.resolve({ found: false }); - }), - }; - - await expect(selectChatGPTModel(page, 'gpt-5.6')) - .rejects.toBeInstanceOf(CommandExecutionError); - expect(page.nativeClick).toHaveBeenCalledWith(10, 20); - }); - - it('falls back to the visible picker when the model config API does not prove selection', async () => { - vi.spyOn(globalThis, 'fetch') - .mockResolvedValueOnce(new Response(JSON.stringify({ accessToken: 'token' }), { status: 200 })) - .mockResolvedValueOnce(new Response(JSON.stringify({ success: true }), { status: 200 })); - let objectCall = 0; - const page = { - goto: vi.fn().mockResolvedValue(undefined), - wait: vi.fn().mockResolvedValue(undefined), - nativeClick: vi.fn().mockResolvedValue(undefined), - getCookies: vi.fn().mockResolvedValue([{ name: '__Secure-next-auth.session-token', value: 'cookie', domain: '.chatgpt.com' }]), - evaluate: vi.fn((script) => { - if (script === 'window.location.href') return Promise.resolve('https://chatgpt.com/c/demo'); - if (String(script).includes('oai-last-model-config')) return Promise.resolve(true); - objectCall += 1; - if (objectCall === 1) return Promise.resolve({ isLoggedIn: true, hasLoginGate: false, hasComposer: true }); - if (objectCall === 2) return Promise.resolve({ model: 'balanced', label: 'Balanced' }); - if (objectCall === 3) return Promise.resolve({ isLoggedIn: true, hasLoginGate: false, hasComposer: true }); - if (objectCall === 4) return Promise.resolve({ model: 'balanced', label: 'Balanced' }); - if (objectCall === 5) return Promise.resolve({ found: true, x: 10, y: 20 }); - if (objectCall === 6) return Promise.resolve({ found: true, x: 30, y: 40 }); - if (objectCall === 7) return Promise.resolve({ model: 'advanced', label: 'Advanced' }); - return Promise.resolve({}); - }), - }; - - await expect(selectChatGPTModel(page, 'advanced')).resolves.toEqual({ Status: 'Success', Model: 'Advanced' }); - expect(page.nativeClick).toHaveBeenNthCalledWith(1, 10, 20); - expect(page.nativeClick).toHaveBeenNthCalledWith(2, 30, 40); - }); - - it('falls back to the picker when the session API response is malformed', async () => { - vi.spyOn(globalThis, 'fetch') - .mockResolvedValueOnce(new Response('{', { status: 200 })); - let objectCall = 0; - const page = { - goto: vi.fn().mockResolvedValue(undefined), - wait: vi.fn().mockResolvedValue(undefined), - nativeClick: vi.fn().mockResolvedValue(undefined), - getCookies: vi.fn().mockResolvedValue([{ name: '__Secure-next-auth.session-token', value: 'cookie', domain: '.chatgpt.com' }]), - evaluate: vi.fn((script) => { - if (script === 'window.location.href') return Promise.resolve('https://chatgpt.com/c/demo'); - objectCall += 1; - if (objectCall === 1) return Promise.resolve({ isLoggedIn: true, hasLoginGate: false, hasComposer: true }); - if (objectCall === 2) return Promise.resolve({ model: 'balanced', label: 'Balanced' }); - if (objectCall === 3) return Promise.resolve({ found: true, x: 10, y: 20 }); - if (objectCall === 4) return Promise.resolve({ found: true, x: 30, y: 40 }); - if (objectCall === 5) return Promise.resolve({ model: 'advanced', label: 'Advanced' }); - return Promise.resolve({}); - }), - }; - - await expect(selectChatGPTModel(page, 'advanced')).resolves.toEqual({ Status: 'Success', Model: 'Advanced' }); - expect(page.nativeClick).toHaveBeenCalledTimes(2); - }); - - it('maps ChatGPT preference API auth rejection to AuthRequiredError', async () => { - vi.spyOn(globalThis, 'fetch') - .mockResolvedValueOnce(new Response(JSON.stringify({ accessToken: 'token' }), { status: 200 })) - .mockResolvedValueOnce(new Response(JSON.stringify({ error: 'unauthorized' }), { status: 401 })); - let objectCall = 0; - const page = { - goto: vi.fn().mockResolvedValue(undefined), - wait: vi.fn().mockResolvedValue(undefined), - nativeClick: vi.fn().mockResolvedValue(undefined), - getCookies: vi.fn().mockResolvedValue([{ name: '__Secure-next-auth.session-token', value: 'cookie', domain: '.chatgpt.com' }]), - evaluate: vi.fn((script) => { - if (script === 'window.location.href') return Promise.resolve('https://chatgpt.com/c/demo'); - objectCall += 1; - if (objectCall === 1) return Promise.resolve({ isLoggedIn: true, hasLoginGate: false, hasComposer: true }); - if (objectCall === 2) return Promise.resolve({ model: 'balanced', label: 'Balanced' }); - return Promise.resolve({}); - }), - }; - - await expect(selectChatGPTModel(page, 'advanced')).rejects.toBeInstanceOf(AuthRequiredError); - }); - - it('selects current Chinese intelligence options by exact visible menu text', async () => { - const page = createDomEvaluatePage(` -
- -
-
-
-
fast
-
balanced
-
advanced
-
very high
-
professional
-
- `); - let clickCount = 0; - page.wait = vi.fn().mockResolvedValue(undefined); - page.nativeClick = vi.fn().mockImplementation(async () => { - clickCount += 1; - if (clickCount === 2) { - page.evaluate(`document.querySelector('[data-testid="model-switcher-dropdown-button"]').textContent = 'GPT-5.5 very high'`); - } - }); - - await expect(selectChatGPTModel(page, 'very-high')).resolves.toEqual({ Status: 'Success', Model: 'Very High' }); - expect(page.nativeClick).toHaveBeenCalledTimes(2); - }); - - it('verifies selection from stable model test id when the current visible label is unknown', async () => { - const page = createDomEvaluatePage(` -
- -
-
-
-
fast
-
- `); - let clickCount = 0; - page.nativeClick = vi.fn().mockImplementation(async () => { - clickCount += 1; - if (clickCount === 2) { - await page.evaluate(` - const button = document.querySelector('[data-testid="model-switcher-dropdown-button"]'); - button.innerHTML = 'Mode rapide'; - `); - for (const node of page.dom.window.document.querySelectorAll('[data-testid]')) { - node.getBoundingClientRect = () => ({ width: 120, height: 36 }); - node.scrollIntoView = () => {}; - } - } - }); - - await expect(selectChatGPTModel(page, 'instant')).resolves.toEqual({ Status: 'Success', Model: 'Fast' }); - expect(page.nativeClick).toHaveBeenCalledTimes(2); - }); - - it('selects actual English intelligence options by visible menu text', async () => { - const page = createDomEvaluatePage(` -
- -
-
-
-
-
Instant
-
Medium
-
High
-
Extra High
-
Pro
-
-
- `); - let clickCount = 0; - page.nativeClick = vi.fn().mockImplementation(async () => { - clickCount += 1; - if (clickCount === 2) { - page.evaluate(`document.querySelector('[data-testid="model-switcher-dropdown-button"]').textContent = 'Extra High'`); - } - }); - - await expect(selectChatGPTModel(page, 'extra-high')).resolves.toEqual({ Status: 'Success', Model: 'Very High' }); - expect(page.nativeClick).toHaveBeenCalledTimes(2); - }); - - it('selects Instant when the current precise level is Medium', async () => { - const page = createDomEvaluatePage(` -
- -
-
-
-
-
Instant
-
Medium
-
High
-
Extra High
-
Pro
-
-
- `); - let clickCount = 0; - page.nativeClick = vi.fn().mockImplementation(async () => { - clickCount += 1; - if (clickCount === 2) { - page.evaluate(`document.querySelector('[data-testid="model-switcher-dropdown-button"]').textContent = 'Instant'`); - } - }); - - await expect(selectChatGPTModel(page, 'instant')).resolves.toEqual({ Status: 'Success', Model: 'Fast' }); - expect(page.nativeClick).toHaveBeenCalledTimes(2); - }); - - it('selects Balanced when the current precise level is Extra High', async () => { - const page = createDomEvaluatePage(` -
- -
-
-
-
-
Instant
-
Medium
-
High
-
Extra High
-
Pro
-
-
- `); - let clickCount = 0; - page.nativeClick = vi.fn().mockImplementation(async () => { - clickCount += 1; - if (clickCount === 2) { - page.evaluate(`document.querySelector('[data-testid="model-switcher-dropdown-button"]').textContent = 'Medium'`); - } - }); - - await expect(selectChatGPTModel(page, 'balanced')).resolves.toEqual({ Status: 'Success', Model: 'Balanced' }); - expect(page.nativeClick).toHaveBeenCalledTimes(2); - }); - - it('uses guarded intelligence menu order for unknown localized labels', async () => { - const page = createDomEvaluatePage(` -
- -
-
-
-
-
L0
-
L1
-
L2
-
L3
-
L4
-
-
- `); - let clickCount = 0; - page.nativeClick = vi.fn().mockImplementation(async () => { - clickCount += 1; - if (clickCount === 2) { - const options = page.dom.window.document.querySelectorAll('[role="menuitemradio"]'); - for (const option of options) option.setAttribute('aria-checked', 'false'); - options[3].setAttribute('aria-checked', 'true'); - } - }); - - await expect(selectChatGPTModel(page, 'extra-high')).resolves.toEqual({ Status: 'Success', Model: 'Very High' }); - expect(page.nativeClick).toHaveBeenCalledTimes(4); - }); - - it('does not use order fallback outside the guarded five-option intelligence picker', async () => { - const page = createDomEvaluatePage(` -
- -
-
-
-
-
L0
-
L1
-
L2
-
L3
-
L4
-
-
- `); - page.nativeClick = vi.fn().mockResolvedValue(undefined); - - await expect(selectChatGPTModel(page, 'extra-high')).rejects.toMatchObject({ - code: 'COMMAND_EXEC', - message: expect.stringContaining('Could not click the ChatGPT Very High model option'), - }); - }); - - it('does not use order fallback when the intelligence picker does not expose exactly five options', async () => { - const page = createDomEvaluatePage(` -
- -
-
-
-
-
L0
-
L1
-
L2
-
L3
-
-
- `); - page.nativeClick = vi.fn().mockResolvedValue(undefined); - - await expect(selectChatGPTModel(page, 'very-high')).rejects.toMatchObject({ - code: 'COMMAND_EXEC', - message: expect.stringContaining('Could not click the ChatGPT Very High model option'), - }); - }); - - it('fails closed when the postcondition does not prove the requested model', async () => { - let objectCall = 0; - const page = { - goto: vi.fn().mockResolvedValue(undefined), - wait: vi.fn().mockResolvedValue(undefined), - nativeClick: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn((script) => { - if (script === 'window.location.href') return Promise.resolve('https://chatgpt.com/c/demo'); - objectCall += 1; - if (objectCall === 1) return Promise.resolve({ isLoggedIn: true, hasLoginGate: false, hasComposer: true }); - if (objectCall === 2) return Promise.resolve({ model: 'balanced', label: 'Balanced' }); - if (objectCall === 3) return Promise.resolve({ found: true, x: 10, y: 20 }); - if (objectCall === 4) return Promise.resolve({ found: true, x: 30, y: 40 }); - if (objectCall === 5) return Promise.resolve({ model: 'balanced', label: 'Balanced' }); - return Promise.resolve({}); - }), - }; - - await expect(selectChatGPTModel(page, 'fast')).rejects.toMatchObject({ - code: 'COMMAND_EXEC', - message: expect.stringContaining('did not switch to Fast'), - }); - }); -}); - -describe('chatgpt tool selection validation', () => { - it('rejects unknown tool names', async () => { - await expect(selectChatGPTTool({ nativeClick: vi.fn() }, 'unknown')) - .rejects.toBeInstanceOf(ArgumentError); - await expect(selectChatGPTTool({ nativeClick: vi.fn() }, 'unknown')) - .rejects.toThrow('Unknown ChatGPT tool "unknown"'); - }); - - it('requires native browser click support', async () => { - await expect(selectChatGPTTool({}, 'deep-research')) - .rejects.toBeInstanceOf(CommandExecutionError); - await expect(selectChatGPTTool({}, 'deep-research')) - .rejects.toThrow('ChatGPT tool selection requires native browser click support.'); - }); -}); - -describe('chatgpt detail completion state', () => { - function createDetailPageMock({ generating = false, messages = [] } = {}) { - return { - wait: vi.fn().mockResolvedValue(undefined), - sleep: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn((script) => { - if (script.includes('Stop generating') || script.includes('Thinking')) { - return Promise.resolve(generating); - } - if (script.includes('data-message-author-role')) { - return Promise.resolve(messages.map((message) => ({ - role: message.Role, - text: message.Text, - html: message.Html ?? message.Text, - }))); - } - return Promise.resolve(undefined); - }), - }; - } - - it('adds generation state to detail rows', async () => { - const page = createDetailPageMock({ - generating: true, - messages: [ - { Role: 'User', Text: 'question' }, - { Role: 'Assistant', Text: 'working' }, - ], - }); - - await expect(getChatGPTDetailRows(page)).resolves.toMatchObject({ - generating: true, - rows: [ - { Index: 1, Role: 'User', Text: 'question', Generating: true, StableSeconds: 0 }, - { Index: 2, Role: 'Assistant', Text: 'working', Generating: true, StableSeconds: 0 }, - ], - }); - }); - - it('waits until assistant output is stable', async () => { - const page = createDetailPageMock({ - generating: false, - messages: [ - { Role: 'User', Text: 'question' }, - { Role: 'Assistant', Text: 'done' }, - ], - }); - - const result = await waitForChatGPTDetailRows(page, { timeoutSeconds: 5, stableSeconds: 0 }); - - expect(result.rows.at(-1)).toMatchObject({ - Role: 'Assistant', - Text: 'done', - Generating: false, - StableSeconds: 0, - }); - }); -}); - -describe('chatgpt ask response extraction boundary', () => { - function createResponseWaitPage(messageSets, { url = 'https://chatgpt.com/c/demo' } = {}) { - let messageIndex = 0; - return { - wait: vi.fn().mockResolvedValue(undefined), - sleep: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn((script) => { - if (script === 'window.location.href') return Promise.resolve(url); - if (script.includes('Stop generating') || script.includes('Thinking')) { - return Promise.resolve(false); - } - if (script.includes('data-message-author-role')) { - const messages = messageSets[Math.min(messageIndex, messageSets.length - 1)] ?? []; - messageIndex += 1; - return Promise.resolve(messages.map((message) => ({ - role: message.Role, - text: message.Text, - html: message.Text, - }))); - } - return Promise.resolve(undefined); - }), - }; - } - - function mockAdvancingClock(stepMs = 1000) { - let now = 0; - vi.spyOn(Date, 'now').mockImplementation(() => { - now += stepMs; - return now; - }); - } - - it('does not return a stale baseline assistant pair for repeated prompts', async () => { - mockAdvancingClock(); - const baselineMessages = [ - { Role: 'User', Text: 'repeat this prompt' }, - { Role: 'Assistant', Text: 'old answer' }, - ]; - const page = createResponseWaitPage([ - baselineMessages, - baselineMessages, - baselineMessages, - ]); - - await expect(waitForChatGPTResponse(page, baselineMessages.length, 'repeat this prompt', 4, { - baselinePairCounts: getChatGPTResponsePairCounts(baselineMessages, 'repeat this prompt'), - conversationUrl: 'https://chatgpt.com/c/demo', - })).rejects.toThrow(/chatgpt ask timed out/); - }); - - it('requires an exact normalized user prompt match instead of substring matching', async () => { - mockAdvancingClock(); - const staleMessages = [ - { Role: 'User', Text: 'write a short story' }, - { Role: 'Assistant', Text: 'old answer' }, - ]; - const page = createResponseWaitPage([ - staleMessages, - staleMessages, - staleMessages, - ]); - - await expect(waitForChatGPTResponse(page, 0, 'short', 4, { - baselinePairKeys: new Set(), - conversationUrl: 'https://chatgpt.com/c/demo', - })).rejects.toThrow(/chatgpt ask timed out/); - }); - - it('does not collapse punctuation-distinct prompts into the requested prompt', async () => { - mockAdvancingClock(); - const staleMessages = [ - { Role: 'User', Text: 'what now?' }, - { Role: 'Assistant', Text: 'old answer' }, - ]; - const page = createResponseWaitPage([ - staleMessages, - staleMessages, - staleMessages, - ]); - - await expect(waitForChatGPTResponse(page, 0, 'what now!', 4, { - baselinePairCounts: getChatGPTResponsePairCounts([], 'what now!'), - conversationUrl: 'https://chatgpt.com/c/demo', - })).rejects.toThrow(/chatgpt ask timed out/); - }); - - it('returns a stable assistant response for the new prompt pair', async () => { - mockAdvancingClock(); - const baselineMessages = [ - { Role: 'User', Text: 'repeat this prompt' }, - { Role: 'Assistant', Text: 'old answer' }, - ]; - const newMessages = [ - ...baselineMessages, - { Role: 'User', Text: 'repeat this prompt' }, - { Role: 'Assistant', Text: 'new answer' }, - ]; - const page = createResponseWaitPage([ - newMessages, - newMessages, - newMessages, - ]); - - await expect(waitForChatGPTResponse(page, baselineMessages.length, 'repeat this prompt', 10, { - baselinePairCounts: getChatGPTResponsePairCounts(baselineMessages, 'repeat this prompt'), - conversationUrl: 'https://chatgpt.com/c/demo', - })).resolves.toBe('new answer'); - }); - - it('accepts a repeated prompt when the new response text matches a visible baseline answer', async () => { - mockAdvancingClock(); - const baselineMessages = [ - { Role: 'User', Text: 'repeat this prompt' }, - { Role: 'Assistant', Text: 'same answer' }, - ]; - const newMessages = [ - ...baselineMessages, - { Role: 'User', Text: 'repeat this prompt' }, - { Role: 'Assistant', Text: 'same answer' }, - ]; - const page = createResponseWaitPage([ - newMessages, - newMessages, - newMessages, - ]); - - await expect(waitForChatGPTResponse(page, baselineMessages.length, 'repeat this prompt', 10, { - baselinePairCounts: getChatGPTResponsePairCounts(baselineMessages, 'repeat this prompt'), - conversationUrl: 'https://chatgpt.com/c/demo', - })).resolves.toBe('same answer'); - }); - - it('fails closed when the browser drifts to another conversation while waiting', async () => { - mockAdvancingClock(); - const page = createResponseWaitPage([], { url: 'https://chatgpt.com/c/other' }); - - await expect(waitForChatGPTResponse(page, 0, 'hello', 4, { - conversationUrl: 'https://chatgpt.com/c/demo', - })).rejects.toThrow(/navigated away from the target conversation/); - }); - - it('polls with pure sleeps instead of DOM-stable numeric waits', async () => { - mockAdvancingClock(); - const page = createResponseWaitPage([[], [], []]); - - await expect(waitForChatGPTResponse(page, 0, 'unmatched prompt', 4, {})) - .rejects.toThrow(/chatgpt ask timed out/); - - expect(page.sleep).toHaveBeenCalled(); - expect(page.wait).not.toHaveBeenCalled(); - }); - - it('uses textContent instead of layout-triggering innerText in text-only polls', async () => { - const page = createDomEvaluatePage(` -
-
-
done answer
-
-
- `); - for (const node of page.dom.window.document.querySelectorAll('*')) { - node.getBoundingClientRect = () => ({ width: 120, height: 36 }); - } - Object.defineProperty(page.dom.window.HTMLElement.prototype, 'innerText', { - configurable: true, - get() { - throw new Error('innerText should not be read during text-only polls'); - }, - }); - - await expect(getVisibleMessages(page, { textOnly: true })).resolves.toEqual([{ - Index: 1, - Role: 'Assistant', - Text: 'done answer', - Html: '', - }]); - }); -}); - -describe('chatgpt generation state', () => { - it('detects zh-CN thinking status text', async () => { - const page = { - evaluate: vi.fn((script) => { - expect(script).toContain('Thinking'); - return Promise.resolve(true); - }), - }; - - await expect(isGenerating(page)).resolves.toBe(true); - }); - - it('detects a plain-text Thinking pill in the latest message turn', async () => { - const page = createDomEvaluatePage(` -
-
partial answer
-
Thinking
-
- `); - - await expect(isGenerating(page)).resolves.toBe(true); - }); - - it('stays idle when Thinking is only the selected composer model', async () => { - const page = createDomEvaluatePage(` -
-
done answer
-
-
-
- -
- `); - - await expect(isGenerating(page)).resolves.toBe(false); - }); - - it('ignores finished answer text that merely mentions Thinking', async () => { - const page = createDomEvaluatePage(` -
-
-

The answer discusses Thinking mode.

-
-
- `); - Object.defineProperty(page.dom.window.document.body, 'innerText', { - configurable: true, - get: () => 'The answer discusses Thinking mode.', - }); - - await expect(isGenerating(page)).resolves.toBe(false); - }); -}); - -describe('chatgpt current model detection', () => { - it.each([ - ['Instant', { model: 'fast', label: 'Fast' }], - ['Medium', { model: 'balanced', label: 'Balanced' }], - ['Thinking', { model: 'advanced', label: 'Advanced' }], - ['High', { model: 'advanced', label: 'Advanced' }], - ['Extra High', { model: 'very-high', label: 'Very High' }], - ['Pro', { model: 'pro', label: 'Pro' }], - ['GPT-5.5 fast', { model: 'fast', label: 'Fast' }], - ['GPT-5.5 balanced', { model: 'balanced', label: 'Balanced' }], - ['intelligence level advanced', { model: 'advanced', label: 'Advanced' }], - ['GPT-5.5 very high', { model: 'very-high', label: 'Very High' }], - ['GPT-5.5 professional', { model: 'pro', label: 'Pro' }], - ['professional', { model: 'pro', label: 'Pro' }], - ])('detects the visible %s model label', async (label, expected) => { - const page = createDomEvaluatePage(`
`); - - await expect(getCurrentChatGPTModel(page)).resolves.toEqual(expected); - }); - - it('uses model-specific test ids before visible text labels', async () => { - const page = createDomEvaluatePage(` -
- -
- `); - - await expect(getCurrentChatGPTModel(page)).resolves.toEqual({ model: 'pro', label: 'Pro' }); - }); - - it('distinguishes the GPT-5.6 Pro test id from the generic Pro level', async () => { - const page = createDomEvaluatePage(` -
- -
- `); - - await expect(getCurrentChatGPTModel(page)) - .resolves.toEqual({ model: 'gpt-5.6-pro', label: 'GPT-5.6 Pro' }); - }); - - it('recognizes the GPT-5.6 Sol Pro visible label', async () => { - const page = createDomEvaluatePage(` -
- -
- `); - - await expect(getCurrentChatGPTModel(page)) - .resolves.toEqual({ model: 'gpt-5.6-pro', label: 'GPT-5.6 Pro' }); - }); - - it('returns null fields when the model selector is missing', async () => { - const page = createDomEvaluatePage('
'); - - await expect(getCurrentChatGPTModel(page)).resolves.toEqual({ - model: null, - label: null, - }); - }); -}); - -describe('chatgpt current tool detection', () => { - it.each([ - ['Deep Research', { tool: 'deep-research', label: 'Deep Research' }], - ['Deep Research', { tool: 'deep-research', label: 'Deep Research' }], - ['Web Search', { tool: 'web-search', label: 'Web Search' }], - ['Search', { tool: 'web-search', label: 'Web Search' }], - ['Web Search', { tool: 'web-search', label: 'Web Search' }], - ])('detects the visible %s tool label', async (label, expected) => { - const page = createDomEvaluatePage(`
`); - - await expect(getCurrentChatGPTTool(page)).resolves.toEqual(expected); - }); - - it('returns null fields when no supported tool is selected', async () => { - const page = createDomEvaluatePage('
'); - - await expect(getCurrentChatGPTTool(page)).resolves.toEqual({ - tool: null, - label: null, - }); - }); -}); - -describe('chatgpt send selectors', () => { - it('inlines the composer locator without returning before caller code runs', () => { - const dom = new JSDOM('
', { - url: 'https://chatgpt.com/', - runScripts: 'outside-only', - }); - const composer = dom.window.document.querySelector('#prompt-textarea'); - composer.getBoundingClientRect = () => ({ width: 320, height: 48 }); - - const result = dom.window.eval(` - (() => { - ${__test__.buildComposerLocatorScript()} - const composer = findComposer(); - return !!composer && composer.getAttribute(markerAttr) === '1'; - })() - `); - - expect(result).toBe(true); - }); - - it('keeps locale-independent send-button selector before aria-label fallbacks', async () => { - const page = { - wait: vi.fn().mockResolvedValue(undefined), - nativeClick: vi.fn().mockResolvedValue(undefined), - nativeType: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn((script) => { - if (script.includes('findComposer')) return Promise.resolve({ ready: true, x: 12, y: 34 }); - if (script.includes('sendBtnFound')) { - expect(script).toContain('data-testid=\\\"send-button\\\"'); - return Promise.resolve({ sendBtnFound: true }); - } - if (script.includes('if (sendBtn) sendBtn.click')) { - expect(script).toContain('data-testid=\\\"send-button\\\"'); - } - return Promise.resolve(undefined); - }), - }; - - await expect(sendChatGPTMessage(page, 'hello')).resolves.toBe(true); - expect(page.nativeClick).toHaveBeenCalledWith(12, 34); - }); - - it('uses the composer submit fallback consistently for readiness and click', async () => { - const page = { - wait: vi.fn().mockResolvedValue(undefined), - nativeType: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn((script) => { - if (script.includes('findComposer')) return Promise.resolve({ ready: true, x: 12, y: 34 }); - if (script.includes('sendBtnFound')) { - expect(script).toContain('#composer-submit-button:not([disabled])'); - return Promise.resolve({ sendBtnFound: true }); - } - if (script.includes('if (sendBtn) sendBtn.click')) { - expect(script).toContain('#composer-submit-button:not([disabled])'); - } - return Promise.resolve(undefined); - }), - }; - - await expect(sendChatGPTMessage(page, 'hello')).resolves.toBe(true); - }); - - it('keeps zh-CN aria and placeholder fallbacks without replacing English selectors', () => { - expect(__test__.COMPOSER_SELECTORS).toEqual(expect.arrayContaining([ - '[aria-label="Chat with ChatGPT"]', - '[aria-label="localized text ChatGPT chat"]', - '[placeholder="Ask anything"]', - '[placeholder="Ask anything,ask away"]', - '[data-testid="prompt-textarea"]', - ])); - expect(__test__.SEND_BUTTON_SELECTOR).toBe('button[data-testid="send-button"]:not([disabled])'); - expect(__test__.SEND_BUTTON_FALLBACK_SELECTORS).toContain('#composer-submit-button:not([disabled])'); - expect(__test__.SEND_BUTTON_LABELS).toEqual(expect.arrayContaining(['Send prompt', 'Send message', 'Send', 'Send', 'Send message', 'Send prompt'])); - expect(__test__.CLOSE_SIDEBAR_LABELS).toEqual(expect.arrayContaining(['Close sidebar', 'Close sidebar'])); - }); -}); - -describe('chatgpt generated image detection', () => { - function createDomPage(html, setup = () => { }) { - const dom = new JSDOM(html, { - url: 'https://chatgpt.com/c/demo', - runScripts: 'outside-only', - }); - setup(dom.window); - return { - evaluate: vi.fn((script) => Promise.resolve(dom.window.eval(String(script)))), - }; - } - - it('detects visible CSS background images when ChatGPT does not render a plain img', async () => { - const page = createDomPage(` - -
-
- -
- `, (window) => { - for (const el of window.document.querySelectorAll('div, button')) { - el.getBoundingClientRect = () => ({ width: 512, height: 512 }); - } - }); - - await expect(getChatGPTVisibleImageUrls(page)).resolves.toEqual([ - 'https://chatgpt.com/backend-api/generated/foo.webp', - ]); - }); - - it('detects visible generated canvases as data URLs when they contain pixels', async () => { - const page = createDomPage('', (window) => { - const canvas = window.document.querySelector('canvas'); - canvas.getBoundingClientRect = () => ({ width: 512, height: 512 }); - canvas.getContext = () => ({ - getImageData: () => ({ data: new Uint8ClampedArray([255, 0, 0, 255]) }), - }); - canvas.toDataURL = () => 'data:image/png;base64,ZmFrZQ=='; - }); - - await expect(getChatGPTVisibleImageUrls(page)).resolves.toEqual([ - 'data:image/png;base64,ZmFrZQ==', - ]); - }); - - it('samples generated canvas content outside the top-left corner', async () => { - const page = createDomPage('', (window) => { - const canvas = window.document.querySelector('canvas'); - canvas.getBoundingClientRect = () => ({ width: 512, height: 512 }); - canvas.getContext = () => ({ - getImageData: (x, y) => ({ - data: x > 480 && y > 480 - ? new Uint8ClampedArray([255, 0, 0, 255]) - : new Uint8ClampedArray([0, 0, 0, 0]), - }), - }); - canvas.toDataURL = () => 'data:image/png;base64,lower-right'; - }); - - await expect(getChatGPTVisibleImageUrls(page)).resolves.toEqual([ - 'data:image/png;base64,lower-right', - ]); - }); - - it('samples generated canvas content near the center', async () => { - const page = createDomPage('', (window) => { - const canvas = window.document.querySelector('canvas'); - canvas.getBoundingClientRect = () => ({ width: 512, height: 512 }); - canvas.getContext = () => ({ - getImageData: (x, y) => { - const inCenter = x >= 240 && x <= 272 && y >= 240 && y <= 272; - return { data: new Uint8ClampedArray(inCenter ? [0, 80, 200, 255] : [255, 255, 255, 255]) }; - }, - }); - canvas.toDataURL = () => 'data:image/png;base64,center'; - }); - - await expect(getChatGPTVisibleImageUrls(page)).resolves.toEqual([ - 'data:image/png;base64,center', - ]); - }); - - it('ignores transparent placeholder canvases', async () => { - const page = createDomPage('', (window) => { - const canvas = window.document.querySelector('canvas'); - canvas.getBoundingClientRect = () => ({ width: 512, height: 512 }); - canvas.getContext = () => ({ - getImageData: () => ({ data: new Uint8ClampedArray([0, 0, 0, 0]) }), - }); - canvas.toDataURL = () => 'data:image/png;base64,blank'; - }); - - await expect(getChatGPTVisibleImageUrls(page)).resolves.toEqual([]); - }); - - it('ignores user-uploaded reference image previews', async () => { - const page = createDomPage(` - -
-

You said:

- -
-
-

ChatGPT said:

- generated image -
- `, (window) => { - for (const img of window.document.querySelectorAll('img')) { - Object.defineProperty(img, 'naturalWidth', { configurable: true, value: 512 }); - Object.defineProperty(img, 'naturalHeight', { configurable: true, value: 512 }); - img.getBoundingClientRect = () => ({ width: 512, height: 512 }); - } - }); - - await expect(getChatGPTVisibleImageUrls(page)).resolves.toEqual([ - 'https://chatgpt.com/backend-api/generated/foo.webp', - ]); - }); - - it('keeps assistant generated images even when they are inside an open-image button', async () => { - const page = createDomPage(` - -
-

ChatGPT said:

- -
- `, (window) => { - const img = window.document.querySelector('img'); - Object.defineProperty(img, 'naturalWidth', { configurable: true, value: 512 }); - Object.defineProperty(img, 'naturalHeight', { configurable: true, value: 512 }); - img.getBoundingClientRect = () => ({ width: 512, height: 512 }); - }); - - await expect(getChatGPTVisibleImageUrls(page)).resolves.toEqual([ - 'https://chatgpt.com/backend-api/generated/foo.webp', - ]); - }); - - it('exports assets for generated CSS background images', async () => { - const imageUrl = 'https://chatgpt.com/backend-api/generated/foo.webp'; - const page = createDomPage(` - - - `, (window) => { - const button = window.document.querySelector('button'); - button.getBoundingClientRect = () => ({ width: 512, height: 512 }); - window.fetch = vi.fn().mockResolvedValue({ - ok: true, - blob: async () => new window.Blob(['fake-image'], { type: 'image/webp' }), - }); - }); - - await expect(getChatGPTImageAssets(page, [imageUrl])).resolves.toEqual([ - expect.objectContaining({ - url: imageUrl, - mimeType: 'image/webp', - width: 512, - height: 512, - }), - ]); - }); -}); - -describe('chatgpt image upload helper', () => { - it('validates local images without a browser page', async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-chatgpt-')); - tempDirs.push(dir); - const filePath = path.join(dir, 'cat.png'); - fs.writeFileSync(filePath, 'fake-png'); - - await expect(prepareChatGPTImagePaths([filePath])).resolves.toEqual({ ok: true, paths: [filePath] }); - await expect(prepareChatGPTImagePaths([path.join(dir, 'missing.png')])).resolves.toMatchObject({ - ok: false, - reason: expect.stringContaining('Image not found'), - }); - }); - - it('prefers Browser Bridge file input upload and waits for a preview', async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-chatgpt-')); - tempDirs.push(dir); - const filePath = path.join(dir, 'cat.png'); - fs.writeFileSync(filePath, 'fake-png'); - - const page = { - setFileInput: vi.fn().mockResolvedValue(undefined), - wait: vi.fn().mockResolvedValue(undefined), - sleep: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn().mockResolvedValue(true), - }; - - const result = await uploadChatGPTImages(page, [filePath]); - - expect(result).toEqual({ ok: true, files: [filePath] }); - expect(page.setFileInput).toHaveBeenCalledWith([filePath], 'input[type="file"]'); - }); - - it('rejects missing files before touching the page', async () => { - const page = { - setFileInput: vi.fn(), - wait: vi.fn(), - evaluate: vi.fn(), - }; - - const result = await uploadChatGPTImages(page, ['/no/such/cat.png']); - - expect(result.ok).toBe(false); - expect(result.reason).toContain('Image not found'); - expect(page.setFileInput).not.toHaveBeenCalled(); - }); - - it('rejects non-image extensions', async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-chatgpt-')); - tempDirs.push(dir); - const filePath = path.join(dir, 'report.pdf'); - fs.writeFileSync(filePath, 'fake'); - - const page = { - setFileInput: vi.fn(), - wait: vi.fn(), - evaluate: vi.fn(), - }; - - const result = await uploadChatGPTImages(page, [filePath]); - - expect(result.ok).toBe(false); - expect(result.reason).toContain('Unsupported image type'); - expect(page.setFileInput).not.toHaveBeenCalled(); - }); - - it('passes a React-compatible change event in fallback upload', async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-chatgpt-')); - tempDirs.push(dir); - const filePath = path.join(dir, 'cat.png'); - fs.writeFileSync(filePath, 'fake-png'); - - const page = { - setFileInput: vi.fn().mockRejectedValue(new Error('No element found')), - wait: vi.fn().mockResolvedValue(undefined), - sleep: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn((script) => { - if (String(script).includes('new DataTransfer()')) { - return Promise.resolve({ ok: true }); - } - return Promise.resolve(true); - }), - }; - - const result = await uploadChatGPTImages(page, [filePath]); - - expect(result).toEqual({ ok: true, files: [filePath] }); - const fallbackScript = page.evaluate.mock.calls - .map(([script]) => String(script)) - .find(script => script.includes('new DataTransfer()')); - expect(fallbackScript).toContain('preventDefault()'); - expect(fallbackScript).toContain('stopPropagation()'); - }); - - it('does not treat generic upload controls as uploaded image previews', async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-chatgpt-')); - tempDirs.push(dir); - const filePath = path.join(dir, 'cat.png'); - fs.writeFileSync(filePath, 'fake-png'); - - const dom = new JSDOM(` - -
-
- -
-
- `, { url: 'https://chatgpt.com/new', runScripts: 'outside-only' }); - const page = { - setFileInput: vi.fn().mockResolvedValue(undefined), - wait: vi.fn().mockResolvedValue(undefined), - sleep: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn((script) => Promise.resolve(dom.window.eval(String(script)))), - }; - - const result = await uploadChatGPTImages(page, [filePath]); - - expect(result.ok).toBe(false); - expect(result.reason).toContain('image upload preview did not appear'); - }); - - it('accepts a real uploaded media preview even when the filename text is absent', async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-chatgpt-')); - tempDirs.push(dir); - const filePath = path.join(dir, 'cat.png'); - fs.writeFileSync(filePath, 'fake-png'); - - const dom = new JSDOM(` - -
-
- -
-
- `, { url: 'https://chatgpt.com/new', runScripts: 'outside-only' }); - const img = dom.window.document.querySelector('img'); - Object.defineProperty(img, 'naturalWidth', { configurable: true, value: 512 }); - Object.defineProperty(img, 'naturalHeight', { configurable: true, value: 512 }); - img.getBoundingClientRect = () => ({ width: 512, height: 512 }); - const page = { - setFileInput: vi.fn().mockResolvedValue(undefined), - wait: vi.fn().mockResolvedValue(undefined), - sleep: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn((script) => Promise.resolve(dom.window.eval(String(script)))), - }; - - await expect(uploadChatGPTImages(page, [filePath])).resolves.toEqual({ ok: true, files: [filePath] }); - }); - - it('exposes image MIME inference for fallback upload', () => { - expect(__test__.imageMimeFromPath('/tmp/a.png')).toBe('image/png'); - expect(__test__.imageMimeFromPath('/tmp/a.webp')).toBe('image/webp'); - expect(__test__.imageMimeFromPath('/tmp/a.jpg')).toBe('image/jpeg'); - }); -}); - -describe('chatgpt project id parsing', () => { - it('accepts project hex ids and /g/g-p- URLs', () => { - expect(__test__.parseChatGPTProjectId('12345678abcdef90')).toBe('12345678abcdef90'); - expect(__test__.parseChatGPTProjectId('https://chatgpt.com/g/g-p-12345678abcdef90')).toBe('12345678abcdef90'); - expect(__test__.parseChatGPTProjectId('/g/g-p-abcdef0123456789')).toBe('abcdef0123456789'); - }); - - it('accepts g-p-{hex_id}-{slug} pattern', () => { - expect(__test__.parseChatGPTProjectId('g-p-12345678-my-project')).toBe('12345678'); - }); - - it('rejects invalid project ids', () => { - expect(() => __test__.parseChatGPTProjectId('')).toThrow(/project/); - expect(() => __test__.parseChatGPTProjectId('https://chatgpt.com/')).toThrow(/project/); - expect(() => __test__.parseChatGPTProjectId('https://evil.test/g/g-p-12345678')).toThrow(/project/); - expect(() => __test__.parseChatGPTProjectId('http://chatgpt.com/g/g-p-12345678')).toThrow(/project/); - expect(() => __test__.parseChatGPTProjectId('g-p-a-short')).toThrow(/project/); - expect(() => __test__.parseChatGPTProjectId('https://chatgpt.com/g/g-p-a-short')).toThrow(/project/); - }); -}); - -describe('chatgpt project navigation', () => { - it('verifies the current URL stays bound to the requested project', async () => { - const page = { - goto: vi.fn().mockResolvedValue(undefined), - wait: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn().mockResolvedValue({ - url: 'https://chatgpt.com/g/g-p-deadbeef', - title: 'Other Project', - hasComposer: true, - isLoggedIn: true, - hasLoginGate: false, - }), - }; - - await expect(navigateToProject(page, '12345678')).rejects.toBeInstanceOf(CommandExecutionError); - }); - - it('maps project login redirects to AuthRequiredError', async () => { - const page = { - goto: vi.fn().mockResolvedValue(undefined), - wait: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn().mockResolvedValue({ - url: 'https://chatgpt.com/auth/login', - title: 'Log in', - hasComposer: false, - isLoggedIn: false, - hasLoginGate: true, - }), - }; - - await expect(navigateToProject(page, '12345678')).rejects.toBeInstanceOf(AuthRequiredError); - }); -}); - -describe('chatgpt file path validation', () => { - it('validates local files for project upload (any type)', async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-chatgpt-')); - tempDirs.push(dir); - const pdfPath = path.join(dir, 'report.pdf'); - fs.writeFileSync(pdfPath, 'fake-pdf'); - const docxPath = path.join(dir, 'notes.docx'); - fs.writeFileSync(docxPath, 'fake-docx'); - - const { prepareChatGPTFilePaths } = await import('../utils.js'); - await expect(prepareChatGPTFilePaths([pdfPath])).resolves.toEqual({ ok: true, paths: [pdfPath] }); - await expect(prepareChatGPTFilePaths([pdfPath, docxPath])).resolves.toEqual({ ok: true, paths: [pdfPath, docxPath] }); - await expect(prepareChatGPTFilePaths([path.join(dir, 'missing.txt')])).resolves.toMatchObject({ - ok: false, - reason: expect.stringContaining('File not found'), - }); - }); -}); - -describe('chatgpt project file upload helper', () => { - it('exposes mimeFromFilePath for fallback upload', () => { - expect(__test__.mimeFromFilePath('/tmp/report.pdf')).toBe('application/pdf'); - expect(__test__.mimeFromFilePath('/tmp/notes.docx')).toBe('application/vnd.openxmlformats-officedocument.wordprocessingml.document'); - expect(__test__.mimeFromFilePath('/tmp/data.csv')).toBe('text/csv'); - expect(__test__.mimeFromFilePath('/tmp/code.py')).toBe('text/x-python'); - expect(__test__.mimeFromFilePath('/tmp/image.png')).toBe('image/png'); - expect(__test__.mimeFromFilePath('/tmp/unknown.xyz')).toBe('application/octet-stream'); - }); - - it('exposes PROJECT_LINK_SELECTOR for project link extraction', () => { - expect(__test__.PROJECT_LINK_SELECTOR).toBe('a[href*="/g/g-p-"]'); - }); - - it('extracts visible project anchors from the sidebar without React Fiber internals', async () => { - const dom = new JSDOM(` - -
- - Project Alpha - - - - Duplicate Alpha - - - - Project Beta - - - - Evil Project - - - - Short Bait - - `, { - url: 'https://chatgpt.com/', - runScripts: 'outside-only', - }); - for (const el of dom.window.document.querySelectorAll('[data-sidebar-item="true"]')) { - el.getBoundingClientRect = () => ({ width: 240, height: 32 }); - } - - const page = { - wait: vi.fn().mockResolvedValue(undefined), - goto: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn((script) => Promise.resolve(dom.window.eval(String(script)))), - }; - - const { getProjectList } = await import('../utils.js'); - await expect(getProjectList(page)).resolves.toEqual([ - { - Index: 1, - Id: '12345678', - Title: 'Project Alpha', - Url: 'https://chatgpt.com/g/g-p-12345678-alpha', - }, - { - Index: 2, - Id: 'abcdef90', - Title: 'Project Beta', - Url: 'https://chatgpt.com/g/g-p-abcdef90', - }, - ]); - }); - - it('opens project knowledge dialog by finding an "Add files" button', async () => { - const page = { - wait: vi.fn().mockResolvedValue(undefined), - setFileInput: vi.fn(), - evaluate: vi.fn((script) => { - if (String(script).includes('Add files')) { - return Promise.resolve(true); - } - if (String(script).includes('role="dialog"')) { - return Promise.resolve(true); - } - return Promise.resolve(undefined); - }), - }; - - const { openProjectKnowledgeDialog } = await import('../utils.js'); - const result = await openProjectKnowledgeDialog(page); - expect(result).toBe(true); - }); - - it('reports failure when no Add files button is found', async () => { - const page = { - wait: vi.fn().mockResolvedValue(undefined), - setFileInput: vi.fn(), - evaluate: vi.fn().mockResolvedValue(false), - }; - - const { openProjectKnowledgeDialog } = await import('../utils.js'); - const result = await openProjectKnowledgeDialog(page); - expect(result).toBe(false); - }); - - it('opens the live project Sources tab upload surface when no Add files dialog exists', async () => { - const dom = new JSDOM(` - - - -
- `, { - url: 'https://chatgpt.com/g/g-p-12345678-demo/project', - runScripts: 'outside-only', - }); - const sourcesTab = dom.window.document.querySelector('#project-home-tabs-demo-sources'); - sourcesTab.getBoundingClientRect = () => ({ width: 96, height: 32 }); - sourcesTab.addEventListener('click', () => { - sourcesTab.setAttribute('aria-selected', 'true'); - sourcesTab.dataset.clicked = 'true'; - }); - - const page = { - wait: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn((script) => Promise.resolve(dom.window.eval(String(script)))), - }; - - const { openProjectKnowledgeDialog } = await import('../utils.js'); - await expect(openProjectKnowledgeDialog(page)).resolves.toBe(true); - expect(sourcesTab.dataset.clicked).toBe('true'); - }); - - it('projects file upload uses dialog file input selectors and waits for filename confirmation', async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-chatgpt-')); - tempDirs.push(dir); - const filePath = path.join(dir, 'report.pdf'); - fs.writeFileSync(filePath, 'fake-pdf'); - - const page = { - goto: vi.fn().mockResolvedValue(undefined), - wait: vi.fn().mockResolvedValue(undefined), - nativeType: vi.fn(), - setFileInput: vi.fn().mockRejectedValue(new Error('No element found')), - evaluate: vi.fn((script) => { - const s = String(script); - // getPageState returns a specific object - if (s.includes('isVisible') && s.includes('hasComposer') && s.includes('isLoggedIn')) { - return Promise.resolve({ session: 'test', data: { url: 'https://chatgpt.com/g/g-p-12345678', title: 'Project', hasComposer: true, isLoggedIn: true, hasLoginGate: false } }); - } - if (s.includes('expectedFileNames')) { - return Promise.resolve({ ok: true }); - } - if (s.includes('new DataTransfer()')) { - return Promise.resolve({ ok: true }); - } - if (s.includes('Add files')) return Promise.resolve(true); - if (s.includes('role="dialog"')) return Promise.resolve(true); - return Promise.resolve(undefined); - }), - }; - - const { uploadChatGPTProjectFiles } = await import('../utils.js'); - const result = await uploadChatGPTProjectFiles(page, '12345678', [filePath]); - - expect(result).toEqual({ ok: true, files: [filePath] }); - expect(page.goto).toHaveBeenCalledWith( - expect.stringContaining('/g/g-p-12345678'), - expect.any(Object), - ); - expect(page.evaluate.mock.calls.some(([script]) => String(script).includes('expectedFileNames'))).toBe(true); - }); - - it('returns failure when project upload confirmation does not appear', async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-chatgpt-')); - tempDirs.push(dir); - const filePath = path.join(dir, 'missing-confirmation.pdf'); - fs.writeFileSync(filePath, 'fake-pdf'); - - const page = { - goto: vi.fn().mockResolvedValue(undefined), - wait: vi.fn().mockResolvedValue(undefined), - setFileInput: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn((script) => { - const s = String(script); - if (s.includes('isVisible') && s.includes('hasComposer') && s.includes('isLoggedIn')) { - return Promise.resolve({ session: 'test', data: { url: 'https://chatgpt.com/g/g-p-12345678', title: 'Project', hasComposer: true, isLoggedIn: true, hasLoginGate: false } }); - } - if (s.includes('expectedFileNames')) return Promise.resolve({ ok: false, reason: 'uploaded file did not appear in project knowledge' }); - if (s.includes('Add files')) return Promise.resolve(true); - if (s.includes('role="dialog"')) return Promise.resolve(true); - return Promise.resolve(undefined); - }), - }; - - const { uploadChatGPTProjectFiles } = await import('../utils.js'); - const result = await uploadChatGPTProjectFiles(page, '12345678', [filePath]); - - expect(result).toMatchObject({ - ok: false, - reason: expect.stringContaining('uploaded file did not appear'), - }); - }); - - it('does not treat composer/body filename text as project knowledge confirmation', async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-chatgpt-')); - tempDirs.push(dir); - const filePath = path.join(dir, 'composer-only.pdf'); - fs.writeFileSync(filePath, 'fake-pdf'); - - const dom = new JSDOM(` - -
-
- - composer-only.pdf -
-
- `, { - url: 'https://chatgpt.com/g/g-p-12345678', - runScripts: 'outside-only', - }); - - const page = { - goto: vi.fn().mockResolvedValue(undefined), - wait: vi.fn().mockResolvedValue(undefined), - setFileInput: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn((script) => { - const s = String(script); - if (s.includes('isVisible') && s.includes('hasComposer') && s.includes('isLoggedIn')) { - return Promise.resolve({ session: 'test', data: { url: 'https://chatgpt.com/g/g-p-12345678', title: 'Project', hasComposer: true, isLoggedIn: true, hasLoginGate: false } }); - } - if (s.includes('expectedFileNames')) { - return Promise.resolve(dom.window.eval(s)); - } - if (s.includes('Add files')) return Promise.resolve(true); - if (s.includes('role="dialog"')) return Promise.resolve(true); - return Promise.resolve(undefined); - }), - }; - - const { uploadChatGPTProjectFiles } = await import('../utils.js'); - const result = await uploadChatGPTProjectFiles(page, '12345678', [filePath]); - - expect(result).toMatchObject({ - ok: false, - reason: expect.stringContaining('project knowledge surface'), - }); - }); -}); diff --git a/plugins/chatgpt/utils.js b/plugins/chatgpt/utils.js deleted file mode 100644 index 7e063f2b..00000000 --- a/plugins/chatgpt/utils.js +++ /dev/null @@ -1,3231 +0,0 @@ -/** - * ChatGPT web browser automation helpers. - * Cross-platform: works on Linux/macOS/Windows via Webcmd's CDP browser automation. - */ - -import { htmlToMarkdown } from '@agentrhq/webcmd/utils'; -import { ArgumentError, AuthRequiredError, CommandExecutionError, TimeoutError } from '@agentrhq/webcmd/errors'; - -export const CHATGPT_DOMAIN = 'chatgpt.com'; -export const CHATGPT_URL = 'https://chatgpt.com'; - -const CHATGPT_MODEL_TARGETS = { - fast: { - label: 'Fast', - labels: ['Fast', 'Speed', 'Instant', 'fast', 'localized text'], - optionLabels: ['Fast', 'Speed', 'Instant', 'fast', 'localized text'], - testIds: ['model-switcher-gpt-5-5'], - intelligenceOrder: 0, - aliases: ['speed', 'instant', 'fast'], - }, - balanced: { - label: 'Balanced', - labels: ['Balanced', 'Balance', 'Medium', 'balanced'], - optionLabels: ['Balanced', 'Balance', 'Medium', 'balanced'], - testIds: [], - intelligenceOrder: 1, - aliases: ['balance', 'medium', 'balanced'], - }, - advanced: { - label: 'Advanced', - labels: ['Advanced', 'High', 'Thinking', 'advanced', 'thinking'], - optionLabels: ['Advanced', 'High', 'Thinking', 'advanced', 'thinking'], - testIds: ['model-switcher-gpt-5-5-thinking'], - intelligenceOrder: 2, - aliases: ['high', 'thinking', 'advanced'], - modelConfig: { modelSlug: 'gpt-5-5-thinking', effort: 'extended' }, - }, - 'very-high': { - label: 'Very High', - labels: ['Very High', 'Extra High', 'Ultra', 'XHigh', 'X-High', 'very high'], - optionLabels: ['Very High', 'Extra High', 'Ultra', 'XHigh', 'X-High', 'very high'], - testIds: [], - intelligenceOrder: 3, - aliases: ['ultra', 'xhigh', 'x-high', 'extra-high', 'very high'], - }, - 'gpt-5.6-pro': { - label: 'GPT-5.6 Pro', - labels: ['GPT-5.6 Pro', 'GPT-5.6 Sol Pro'], - optionLabels: ['GPT-5.6 Pro', 'GPT-5.6 Sol Pro'], - testIds: ['model-switcher-gpt-5-6-pro'], - aliases: ['gpt-5-6-pro', 'gpt-5.6-sol-pro', 'gpt-5-6-sol-pro', 'gpt-5.6', 'gpt-5-6', '5.6-pro', '5.6'], - modelConfig: { modelSlug: 'gpt-5-6-pro', effort: 'standard' }, - }, - pro: { - label: 'Pro', - labels: ['Pro', 'Professional', 'professional', 'professional'], - optionLabels: ['professional', 'Pro', 'Professional', 'professional'], - testIds: ['model-switcher-gpt-5-5-pro'], - intelligenceOrder: 4, - aliases: ['professional', 'professional'], - modelConfig: { modelSlug: 'gpt-5-5-pro', effort: 'standard' }, - }, -}; -const CHATGPT_MODEL_ALIASES = Object.fromEntries(Object.entries(CHATGPT_MODEL_TARGETS).flatMap(([key, target]) => [ - [key, key], - ...(target.aliases || []).map((alias) => [String(alias).toLowerCase(), key]), -])); -export const CHATGPT_MODEL_CHOICES = Object.keys(CHATGPT_MODEL_ALIASES); - -function debugChatGPTModel(message) { - if (process?.env?.WEBCMD_CHATGPT_MODEL_DEBUG) { - console.error(`[chatgpt/model] ${message}`); - } -} - -const CHATGPT_TOOL_OPTIONS = { - 'deep-research': { label: 'Deep Research', labels: ['Deep Research', 'Deep Research'] }, - 'web-search': { label: 'Web Search', labels: ['Web Search', 'Search', 'Web Search', 'Search'] }, -}; -export const CHATGPT_TOOL_CHOICES = Object.keys(CHATGPT_TOOL_OPTIONS); - -// Selectors -const COMPOSER_SELECTORS = [ - '[contenteditable="true"][role="textbox"]', - '#prompt-textarea[contenteditable="true"]', - '[aria-label="Chat with ChatGPT"]', - '[aria-label="localized text ChatGPT chat"]', - '[placeholder="Ask anything"]', - '[placeholder="Ask anything,ask away"]', - '#prompt-textarea', - '[data-testid="prompt-textarea"]', -]; -const SEND_BUTTON_SELECTOR = 'button[data-testid="send-button"]:not([disabled])'; -const SEND_BUTTON_FALLBACK_SELECTORS = [ - '#composer-submit-button:not([disabled])', -]; -const SEND_BUTTON_LABELS = [ - 'Send prompt', - 'Send message', - 'Send', - 'Send', - 'Send message', - 'Send prompt', -]; -const CLOSE_SIDEBAR_LABELS = [ - 'Close sidebar', - 'Close sidebar', -]; - -function isSameChatGPTConversation(currentUrl, expectedUrl) { - if (!currentUrl || !expectedUrl) return false; - return currentUrl === expectedUrl - || currentUrl.startsWith(`${expectedUrl}?`) - || currentUrl.startsWith(`${expectedUrl}#`); -} - -function buildComposerLocatorScript() { - const markerAttr = 'data-webcmd-chatgpt-composer'; - return ` - const isVisible = (el) => { - if (!(el instanceof HTMLElement)) return false; - const style = window.getComputedStyle(el); - if (style.display === 'none' || style.visibility === 'hidden') return false; - const rect = el.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; - }; - - const markerAttr = ${JSON.stringify(markerAttr)}; - const clearMarkers = (active) => { - document.querySelectorAll('[' + markerAttr + ']').forEach(node => { - if (node !== active) node.removeAttribute(markerAttr); - }); - }; - - const findComposer = () => { - for (const selector of ${JSON.stringify(COMPOSER_SELECTORS)}) { - const candidates = Array.from(document.querySelectorAll(selector)).filter(c => c instanceof HTMLElement && isVisible(c)); - const node = candidates.find(c => c.isContentEditable) || candidates[0]; - if (node instanceof HTMLElement) { - clearMarkers(node); - node.setAttribute(markerAttr, '1'); - return node; - } - } - return null; - }; - - findComposer.toString = () => 'findComposer'; - `; -} - -export function normalizeBooleanFlag(value, fallback = false) { - if (typeof value === 'boolean') return value; - if (value == null || value === '') return fallback; - const normalized = String(value).trim().toLowerCase(); - return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on'; -} - -export function requireNonEmptyPrompt(prompt, commandName) { - const text = String(prompt ?? '').trim(); - if (!text) { - throw new ArgumentError( - `${commandName} prompt cannot be empty`, - `Example: webcmd ${commandName} "hello"`, - ); - } - return text; -} - -export function requirePositiveInt(value, flagLabel, hint) { - if (!Number.isInteger(value) || value < 1) { - throw new ArgumentError(`${flagLabel} must be a positive integer`, hint); - } - return value; -} - -export function requireNonNegativeInt(value, flagLabel, hint) { - if (!Number.isInteger(value) || value < 0) { - throw new ArgumentError(`${flagLabel} must be a non-negative integer`, hint); - } - return value; -} - -// ───────────────────────────────────────────────────────────────────────────── -// page.evaluate envelope helpers. -// -// The browser bridge wraps every `page.evaluate(...)` return value in a -// `{ session, data }` envelope. Adapters that read `.length` or -// `Array.isArray(payload)` directly on the envelope silently see "no data" — -// this matches the failure mode fixed in earlier adapter regressions. -// -// `unwrapEvaluateResult` is a defensive ternary: it unwraps when the payload -// looks like an envelope, otherwise passes the value through unchanged so -// older bridge versions and primitive return values still work. -// ───────────────────────────────────────────────────────────────────────────── -export function unwrapEvaluateResult(payload) { - if (payload && !Array.isArray(payload) && typeof payload === 'object' && 'session' in payload && 'data' in payload) { - return payload.data; - } - return payload; -} - -export function requireArrayEvaluateResult(payload, label) { - if (!Array.isArray(payload)) { - if (payload && typeof payload === 'object' && 'error' in payload) { - throw new CommandExecutionError(`${label}: ${String(payload.error)}`); - } - throw new CommandExecutionError(`${label} returned malformed extraction payload`); - } - return payload; -} - -export function requireObjectEvaluateResult(payload, label) { - if (!payload || Array.isArray(payload) || typeof payload !== 'object') { - throw new CommandExecutionError(`${label} returned malformed extraction payload`); - } - return payload; -} - -export function requireBooleanEvaluateResult(payload, label) { - if (typeof payload !== 'boolean') { - throw new CommandExecutionError(`${label} returned malformed extraction payload`); - } - return payload; -} - -function isTrustedChatGPTHost(hostname) { - return hostname === CHATGPT_DOMAIN || hostname.endsWith(`.${CHATGPT_DOMAIN}`); -} - -function projectIdFromPathname(pathname) { - const match = String(pathname || '').match(/^\/g\/g-p-([a-f0-9]{8,})(?:[-/]|$)/i); - return match ? match[1].toLowerCase() : ''; -} - -function projectIdFromUrl(value) { - try { - const url = new URL(String(value || ''), CHATGPT_URL); - if (url.protocol !== 'https:' || !isTrustedChatGPTHost(url.hostname)) return ''; - return projectIdFromPathname(url.pathname); - } catch { - return ''; - } -} - -export function parseChatGPTConversationId(value) { - const raw = String(value ?? '').trim(); - if (/^https?:\/\//i.test(raw)) { - try { - const parsed = new URL(raw); - if (parsed.protocol !== 'https:' || (parsed.hostname !== CHATGPT_DOMAIN && !parsed.hostname.endsWith(`.${CHATGPT_DOMAIN}`))) { - throw new Error('off-domain'); - } - const match = parsed.pathname.match(/^\/(?:g\/g-p-[^/]+\/)?c\/([A-Za-z0-9_-]{8,})$/); - if (match) return match[1]; - } catch { - // Fall through to the shared typed ArgumentError below. - } - throw new ArgumentError( - 'chatgpt detail requires a conversation id or chatgpt.com /c/ URL', - 'Example: webcmd chatgpt detail https://chatgpt.com/c/123e4567-e89b-12d3-a456-426614174000', - ); - } - const pathMatch = raw.match(/^\/(?:g\/g-p-[^/]+\/)?c\/([A-Za-z0-9_-]{8,})(?:[?#].*)?$/); - if (pathMatch) return pathMatch[1]; - if (/^[A-Za-z0-9_-]{8,}$/.test(raw)) return raw; - throw new ArgumentError( - 'chatgpt detail requires a conversation id or chatgpt.com /c/ URL', - 'Example: webcmd chatgpt detail 123e4567-e89b-12d3-a456-426614174000', - ); -} - -export async function currentChatGPTUrl(page) { - const url = unwrapEvaluateResult(await page.evaluate('window.location.href').catch(() => '')); - return typeof url === 'string' ? url : ''; -} - -export async function isOnChatGPT(page) { - const url = await currentChatGPTUrl(page); - if (!url) return false; - try { - const host = new URL(url).hostname; - return host === CHATGPT_DOMAIN || host.endsWith(`.${CHATGPT_DOMAIN}`); - } catch { - return false; - } -} - -// Comma-joined CSS selector list passed to page.wait({ selector }) so the -// wait succeeds as soon as any composer flavour mounts (querySelectorAll -// matches all of them). Tracks the most stable subset of COMPOSER_SELECTORS; -// we only need to know "the composer is ready", not which variant rendered. -const COMPOSER_WAIT_SELECTOR = '#prompt-textarea, [data-testid="prompt-textarea"]'; -const CONVERSATION_LINK_SELECTOR = 'a[href*="/c/"]'; -const PROJECT_LINK_SELECTOR = 'a[href*="/g/g-p-"]'; -// Selector used by detail.js to wait for at least one rendered message bubble -// after navigating to /c/; mirrors the markup queried by getVisibleMessages. -export const CONVERSATION_MESSAGE_SELECTOR = '[data-message-author-role], article[data-testid*="conversation-turn"]'; - -export async function ensureOnChatGPT(page) { - if (await isOnChatGPT(page)) return false; - await page.goto(CHATGPT_URL, { settleMs: 2000 }); - try { - await page.wait({ selector: COMPOSER_WAIT_SELECTOR, timeout: 8 }); - } catch { - // Composer didn't mount; downstream ensureChatGPTLogin / ensureChatGPTComposer surfaces a typed error. - } - return true; -} - -export async function startNewChat(page) { - await page.goto(`${CHATGPT_URL}/new`, { settleMs: 2000 }); - try { - await page.wait({ selector: COMPOSER_WAIT_SELECTOR, timeout: 8 }); - } catch { - // Composer didn't mount; downstream ensureChatGPTComposer surfaces a typed error. - } -} - -export async function openChatGPTConversation(page, value) { - const id = parseChatGPTConversationId(value); - await page.goto(`${CHATGPT_URL}/c/${id}`, { settleMs: 2000 }); - try { - await page.wait({ selector: COMPOSER_WAIT_SELECTOR, timeout: 8 }); - } catch { - // Composer didn't mount; downstream ensureChatGPTLogin / ensureChatGPTComposer surfaces a typed error. - } - return id; -} - -export async function getPageState(page) { - return requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => { - const isVisible = (el) => { - if (!(el instanceof HTMLElement)) return false; - const style = window.getComputedStyle(el); - if (style.display === 'none' || style.visibility === 'hidden') return false; - const rect = el.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; - }; - const composerSelectors = ${JSON.stringify(COMPOSER_SELECTORS)}; - const hasComposer = composerSelectors.some((selector) => - Array.from(document.querySelectorAll(selector)).some((node) => isVisible(node)) - ); - const text = (document.body?.innerText || '').replace(/\\s+/g, ' ').trim(); - const loginLink = Array.from(document.querySelectorAll('a, button')).find((node) => { - const label = ((node.innerText || node.textContent || '') + ' ' + (node.getAttribute('aria-label') || '')).trim().toLowerCase(); - return isVisible(node) && /^(log in|login|sign up|sign in)$/.test(label); - }); - const userMenu = document.querySelector('[data-testid="profile-button"], [aria-label*="Profile"], [aria-label*="Account"], button[id*="headlessui-menu-button"]'); - const hasLoginGate = !!loginLink || /log in to chatgpt|sign up to chatgpt|welcome to chatgpt/i.test(text); - return { - url: window.location.href, - title: document.title, - hasComposer, - isLoggedIn: hasComposer || !!userMenu || !hasLoginGate, - hasLoginGate, - }; - })()`)), 'chatgpt page state'); -} - -export async function ensureChatGPTLogin(page, message = 'ChatGPT requires a logged-in browser session.') { - const state = await getPageState(page); - if (!state.isLoggedIn || state.hasLoginGate) { - throw new AuthRequiredError(CHATGPT_DOMAIN, message); - } - return state; -} - -export async function ensureChatGPTComposer(page, message = 'ChatGPT composer is not available on the current page.') { - const state = await ensureChatGPTLogin(page, message); - if (!state.hasComposer) { - throw new CommandExecutionError(message); - } - return state; -} - -function requireKnownChatGPTModel(model) { - const key = String(model ?? '').trim().toLowerCase(); - const targetKey = CHATGPT_MODEL_ALIASES[key] || key; - const option = CHATGPT_MODEL_TARGETS[targetKey]; - if (!option) { - throw new ArgumentError( - `Unknown ChatGPT model "${model}"`, - `Choose one of: ${CHATGPT_MODEL_CHOICES.join(', ')}`, - ); - } - return { key: targetKey, alias: key !== targetKey ? key : null, ...option }; -} - -function requireKnownChatGPTTool(tool) { - const key = String(tool ?? '').trim().toLowerCase(); - const option = CHATGPT_TOOL_OPTIONS[key]; - if (!option) { - throw new ArgumentError( - `Unknown ChatGPT tool "${tool}"`, - `Choose one of: ${CHATGPT_TOOL_CHOICES.join(', ')}`, - ); - } - return { key, ...option }; -} - -export async function getCurrentChatGPTModel(page) { - return requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => { - const isVisible = (el) => { - if (!(el instanceof HTMLElement)) return false; - const style = window.getComputedStyle(el); - if (style.display === 'none' || style.visibility === 'hidden') return false; - const rect = el.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; - }; - const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim(); - const escapeRegExp = (value) => String(value).replace(/[|\\\\{}()[\\]^$+*?.]/g, '\\\\$&'); - const textMatchesLabel = (text, label) => { - const normalizedText = normalize(text); - const normalizedLabel = normalize(label); - if (!normalizedText || !normalizedLabel) return false; - if (normalizedText === normalizedLabel) return true; - if (/^[\\x00-\\x7F]+$/.test(normalizedLabel)) { - return new RegExp('(^|\\\\b)' + escapeRegExp(normalizedLabel) + '(\\\\b|$)', 'i').test(normalizedText); - } - return normalizedText.replace(/\\s+/g, '').includes(normalizedLabel.replace(/\\s+/g, '')); - }; - const labels = ${JSON.stringify(CHATGPT_MODEL_TARGETS)}; - const findEntryForText = (text) => { - const matches = []; - for (const [key, value] of Object.entries(labels)) { - for (const item of value.labels || []) { - if (textMatchesLabel(text, item)) { - matches.push({ key, value, length: normalize(item).length }); - } - } - } - matches.sort((a, b) => b.length - a.length); - return matches[0] || null; - }; - const form = Array.from(document.querySelectorAll('form')).find((node) => node instanceof HTMLElement && isVisible(node)); - const testIdNode = form - ? Array.from(form.querySelectorAll('[data-testid]')).find((node) => { - if (!(node instanceof HTMLElement) || !isVisible(node)) return false; - const testId = node.getAttribute('data-testid'); - return Object.values(labels).some((entry) => (entry.testIds || []).includes(testId)); - }) - : null; - const testId = testIdNode?.getAttribute('data-testid') || ''; - const testIdEntry = Object.entries(labels).find(([, value]) => (value.testIds || []).includes(testId)); - if (testIdEntry) { - return { - model: testIdEntry[0], - label: testIdEntry[1].label, - }; - } - const button = Array.from((form || document).querySelectorAll('button')).find((node) => { - if (!isVisible(node)) return false; - const text = normalize(node.textContent); - return Object.values(labels).some((entry) => entry.labels.some((label) => textMatchesLabel(text, label))); - }); - const label = normalize(button?.textContent || ''); - const entry = findEntryForText(label); - return { - model: entry?.key ?? null, - label: entry?.value?.label ?? null, - }; - })()`)), 'chatgpt current model'); -} - -async function buildChatGPTBackendHeaders(page, { includeAuthorization = false } = {}) { - if (typeof page.getCookies !== 'function') { - return { ok: false, status: 0, reason: 'missing-cookie-api' }; - } - const cookieLists = await Promise.all([ - page.getCookies({ url: CHATGPT_URL }).catch(() => []), - page.getCookies({ url: `${CHATGPT_URL}/api/auth/session` }).catch(() => []), - page.getCookies({ domain: CHATGPT_DOMAIN }).catch(() => []), - page.getCookies({ domain: `.${CHATGPT_DOMAIN}` }).catch(() => []), - page.getCookies().catch(() => []), - ]); - const cookiesByName = new Map(); - for (const cookie of cookieLists.flat()) { - if (!cookie?.name || typeof cookie.value !== 'string') continue; - if (!cookiesByName.has(cookie.name) || cookie.domain === CHATGPT_DOMAIN || cookie.domain === `.${CHATGPT_DOMAIN}`) { - cookiesByName.set(cookie.name, cookie); - } - } - const cookieHeader = Array.from(cookiesByName.values()) - .map((cookie) => `${cookie.name}=${cookie.value}`) - .join('; '); - if (!cookieHeader) return { ok: false, status: 0, reason: 'missing-cookies' }; - const headers = { - accept: 'application/json', - cookie: cookieHeader, - origin: CHATGPT_URL, - referer: `${CHATGPT_URL}/`, - 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8', - 'sec-fetch-dest': 'empty', - 'sec-fetch-mode': 'cors', - 'sec-fetch-site': 'same-origin', - 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36', - }; - if (!includeAuthorization) return { ok: true, status: 200, headers }; - - const sessionResponse = await fetch(`${CHATGPT_URL}/api/auth/session`, { - headers, - signal: AbortSignal.timeout(10000), - }); - if (!sessionResponse.ok) { - return { ok: false, status: sessionResponse.status, reason: 'session' }; - } - let session = null; - try { - session = await sessionResponse.json(); - } catch { - return { ok: false, status: sessionResponse.status, reason: 'session-json' }; - } - const accessToken = session?.accessToken; - if (!accessToken) return { ok: false, status: 0, reason: 'missing-access-token' }; - return { - ok: true, - status: 200, - headers: { - ...headers, - authorization: `Bearer ${accessToken}`, - }, - }; -} - -async function setChatGPTModelConfig(page, target) { - if (!target.modelConfig) return null; - const auth = await buildChatGPTBackendHeaders(page, { includeAuthorization: true }); - if (!auth.ok) return auth; - - const modelSlug = target.modelConfig.modelSlug; - const effort = target.modelConfig.effort; - const patchUrl = `${CHATGPT_URL}/backend-api/settings/user_last_used_model_config` - + `?model_slug=${encodeURIComponent(modelSlug)}` - + `&thinking_effort=${encodeURIComponent(effort)}`; - const response = await fetch(patchUrl, { - method: 'PATCH', - headers: auth.headers, - signal: AbortSignal.timeout(10000), - }); - let body = null; - try { body = await response.json(); } catch {} - if (!response.ok || body?.success !== true) { - return { ok: false, status: response.status, reason: 'patch', body }; - } - await page.evaluate(`(() => { - const value = encodeURIComponent(JSON.stringify({ model: ${JSON.stringify(modelSlug)}, effort: ${JSON.stringify(effort)} })); - for (const domain of ['; domain=.chatgpt.com', '; domain=chatgpt.com', '']) { - document.cookie = 'oai-last-model-config=; path=/' + domain + '; max-age=0; SameSite=Lax'; - } - document.cookie = 'oai-last-model-config=' + value + '; path=/; domain=.chatgpt.com; max-age=31536000; SameSite=Lax'; - document.cookie = 'oai-last-model-config=' + value + '; path=/; max-age=31536000; SameSite=Lax'; - if (window.location.pathname === '/new') window.location.reload(); - else window.location.assign('/new'); - return true; - })()`).catch(() => true); - return { ok: true, status: response.status, modelSlug, effort }; -} - -export async function selectChatGPTModel(page, model) { - const target = requireKnownChatGPTModel(model); - debugChatGPTModel(`target=${target.key}`); - if (typeof page.nativeClick !== 'function') { - throw new CommandExecutionError('ChatGPT model selection requires native browser click support.'); - } - await ensureOnChatGPT(page); - debugChatGPTModel('ensured chatgpt'); - const currentUrl = await currentChatGPTUrl(page).catch(() => ''); - debugChatGPTModel(`url=${currentUrl}`); - if (!currentUrl.startsWith(`${CHATGPT_URL}/new`)) { - await page.goto(`${CHATGPT_URL}/new`, { waitUntil: 'none' }); - await page.wait(2); - } - await ensureChatGPTComposer(page, 'ChatGPT model selection requires a logged-in ChatGPT session with a visible composer.'); - debugChatGPTModel('composer ok'); - - const before = await getCurrentChatGPTModel(page); - debugChatGPTModel(`before=${before.model || 'none'}`); - if (before.model === target.key) { - return { Status: 'Already selected', Model: target.label }; - } - const apiResult = await setChatGPTModelConfig(page, target); - debugChatGPTModel(`api=${apiResult ? JSON.stringify({ ok: apiResult.ok, status: apiResult.status, reason: apiResult.reason }) : 'none'}`); - if (apiResult) { - if (!apiResult.ok) { - if (apiResult.status === 401 || apiResult.status === 403) { - throw new AuthRequiredError(CHATGPT_DOMAIN, `ChatGPT model preference API rejected the current session while selecting ${target.label}.`); - } - debugChatGPTModel(`falling back to picker after api failure: ${apiResult.reason || 'unknown'}`); - } else { - debugChatGPTModel('config cookie set and reload scheduled'); - await page.wait(2); - await ensureChatGPTComposer(page, 'ChatGPT model selection requires a logged-in ChatGPT session with a visible composer.'); - const afterApi = await getCurrentChatGPTModel(page); - debugChatGPTModel(`after-api=${afterApi.model || 'none'}`); - if (afterApi.model === target.key) { - return { Status: 'Success', Model: target.label }; - } - debugChatGPTModel('api did not prove selection; falling back to visible picker'); - } - } - await page.wait(2); - - const menuButton = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => { - const isVisible = (el) => { - if (!(el instanceof HTMLElement)) return false; - const style = window.getComputedStyle(el); - if (style.display === 'none' || style.visibility === 'hidden') return false; - const rect = el.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; - }; - const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim(); - const escapeRegExp = (value) => String(value).replace(/[|\\\\{}()[\\]^$+*?.]/g, '\\\\$&'); - const textMatchesLabel = (text, label) => { - const normalizedText = normalize(text); - const normalizedLabel = normalize(label); - if (!normalizedText || !normalizedLabel) return false; - if (normalizedText === normalizedLabel) return true; - if (/^[\\x00-\\x7F]+$/.test(normalizedLabel)) { - return new RegExp('(^|\\\\b)' + escapeRegExp(normalizedLabel) + '(\\\\b|$)', 'i').test(normalizedText); - } - return normalizedText.replace(/\\s+/g, '').includes(normalizedLabel.replace(/\\s+/g, '')); - }; - const labels = ${JSON.stringify(Object.values(CHATGPT_MODEL_TARGETS).flatMap((entry) => entry.labels))}; - const menuButtonSelectors = [ - 'button[data-testid="model-switcher-dropdown-button"]', - 'button[aria-label*="model" i]', - 'button[aria-label*="model"]', - 'button[aria-label*="smart"]', - ]; - let button = Array.from(document.querySelectorAll('form button')).find((node) => - isVisible(node) && labels.some((label) => textMatchesLabel(node.textContent, label)) - ); - if (!button) { - button = menuButtonSelectors - .map((selector) => document.querySelector(selector)) - .find((node) => node instanceof HTMLElement && isVisible(node)); - } - if (!button) return { found: false }; - button.scrollIntoView({ block: 'center', inline: 'center' }); - const rect = button.getBoundingClientRect(); - return { - found: true, - x: Math.round(rect.left + rect.width / 2), - y: Math.round(rect.top + rect.height / 2), - }; - })()`)), 'chatgpt model menu button'); - if (!menuButton.found) { - throw new CommandExecutionError('Could not find the ChatGPT model selector in the composer.'); - } - await page.nativeClick(Number(menuButton.x), Number(menuButton.y)); - await page.wait(0.5); - - let optionCenter = null; - for (let attempt = 0; attempt < 10; attempt += 1) { - optionCenter = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => { - const isVisible = (el) => { - if (!(el instanceof HTMLElement)) return false; - const style = window.getComputedStyle(el); - if (style.display === 'none' || style.visibility === 'hidden') return false; - const rect = el.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; - }; - const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim(); - const escapeRegExp = (value) => String(value).replace(/[|\\\\{}()[\\]^$+*?.]/g, '\\\\$&'); - const textMatchesLabel = (text, label) => { - const normalizedText = normalize(text); - const normalizedLabel = normalize(label); - if (!normalizedText || !normalizedLabel) return false; - if (normalizedText === normalizedLabel) return true; - if (/^[\\x00-\\x7F]+$/.test(normalizedLabel)) { - return new RegExp('(^|\\\\b)' + escapeRegExp(normalizedLabel) + '(\\\\b|$)', 'i').test(normalizedText); - } - return normalizedText.replace(/\\s+/g, '').includes(normalizedLabel.replace(/\\s+/g, '')); - }; - const target = ${JSON.stringify(target)}; - const clickableSelector = '[role="menuitemradio"], [role="menuitem"], [role="option"], button, [data-testid^="model-switcher"]'; - const intelligenceContent = document.querySelector('[data-testid="composer-intelligence-picker-content"]'); - const intelligenceOptions = intelligenceContent - ? Array.from(intelligenceContent.querySelectorAll('[role="menuitemradio"]')).filter(isVisible) - : []; - let option = null; - for (const testId of target.testIds || []) { - option = Array.from(document.querySelectorAll('[data-testid]')).find((candidate) => - candidate instanceof HTMLElement && candidate.getAttribute('data-testid') === testId - ) || null; - if (option instanceof HTMLElement && isVisible(option)) break; - option = null; - } - const clickables = intelligenceOptions.length ? intelligenceOptions : Array.from(document.querySelectorAll(clickableSelector)); - for (const label of target.optionLabels || target.labels || []) { - option = clickables.find((candidate) => - candidate instanceof HTMLElement - && isVisible(candidate) - && textMatchesLabel(candidate.textContent, label) - ) || null; - if (option) break; - - const labelRoot = intelligenceContent || document; - const labelNode = Array.from(labelRoot.querySelectorAll('span, div, p')).find((candidate) => - candidate instanceof HTMLElement - && isVisible(candidate) - && textMatchesLabel(candidate.textContent, label) - ); - option = labelNode?.closest(clickableSelector) || null; - if (option instanceof HTMLElement && isVisible(option)) break; - option = null; - } - if (!option && Number.isInteger(target.intelligenceOrder)) { - if (intelligenceOptions.length === 5) { - option = intelligenceOptions[target.intelligenceOrder] || null; - } - } - if (!(option instanceof HTMLElement) || !isVisible(option)) return { found: false }; - option.scrollIntoView({ block: 'center', inline: 'center' }); - const rect = option.getBoundingClientRect(); - return { - found: true, - x: Math.round(rect.left + rect.width / 2), - y: Math.round(rect.top + rect.height / 2), - }; - })()`)), 'chatgpt model option click'); - if (optionCenter.found) break; - await page.wait(0.5); - } - if (!optionCenter?.found) { - throw new CommandExecutionError(`Could not click the ChatGPT ${target.label} model option.`); - } - await page.nativeClick(Number(optionCenter.x), Number(optionCenter.y)); - - await page.wait(0.5); - const after = await getCurrentChatGPTModel(page); - if (after.model !== target.key) { - await page.nativeClick(Number(menuButton.x), Number(menuButton.y)); - await page.wait(0.5); - const checked = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => { - const isVisible = (el) => { - if (!(el instanceof HTMLElement)) return false; - const style = window.getComputedStyle(el); - if (style.display === 'none' || style.visibility === 'hidden') return false; - const rect = el.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; - }; - const target = ${JSON.stringify(target)}; - const intelligenceContent = document.querySelector('[data-testid="composer-intelligence-picker-content"]'); - const options = intelligenceContent - ? Array.from(intelligenceContent.querySelectorAll('[role="menuitemradio"]')).filter(isVisible) - : []; - const checkedIndex = options.findIndex((node) => node.getAttribute('aria-checked') === 'true'); - return { - recognized: options.length === 5 && Number.isInteger(target.intelligenceOrder), - checkedIndex, - }; - })()`)), 'chatgpt model checked intelligence option'); - if (checked.recognized && checked.checkedIndex === target.intelligenceOrder) { - await page.nativeClick(Number(menuButton.x), Number(menuButton.y)); - return { Status: 'Success', Model: target.label }; - } - await page.nativeClick(Number(menuButton.x), Number(menuButton.y)); - throw new CommandExecutionError(`ChatGPT model did not switch to ${target.label}.`); - } - return { Status: 'Success', Model: target.label }; -} - -export async function getCurrentChatGPTTool(page) { - return requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => { - const isVisible = (el) => { - if (!(el instanceof HTMLElement)) return false; - const style = window.getComputedStyle(el); - if (style.display === 'none' || style.visibility === 'hidden') return false; - const rect = el.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; - }; - const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim(); - const labels = ${JSON.stringify(CHATGPT_TOOL_OPTIONS)}; - const form = Array.from(document.querySelectorAll('form')).find((node) => node instanceof HTMLElement && isVisible(node)); - const root = form || document.body; - const nodes = Array.from(root.querySelectorAll('button, [role="button"], [role="menuitemradio"], span, div')); - const node = nodes.find((candidate) => { - if (!isVisible(candidate)) return false; - const text = normalize(candidate.textContent); - return Object.values(labels).some((entry) => entry.labels.includes(text)); - }); - const label = normalize(node?.textContent || ''); - const entry = Object.entries(labels).find(([, value]) => value.labels.includes(label)); - return { - tool: entry?.[0] ?? null, - label: entry?.[1]?.label ?? null, - }; - })()`)), 'chatgpt current tool'); -} - -export async function selectChatGPTTool(page, tool) { - const target = requireKnownChatGPTTool(tool); - if (typeof page.nativeClick !== 'function') { - throw new CommandExecutionError('ChatGPT tool selection requires native browser click support.'); - } - await ensureOnChatGPT(page); - await ensureChatGPTComposer(page, 'ChatGPT tool selection requires a logged-in ChatGPT session with a visible composer.'); - - const before = await getCurrentChatGPTTool(page); - if (before.tool === target.key) { - return { Status: 'Already selected', Tool: target.label }; - } - - const menuButton = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => { - const isVisible = (el) => { - if (!(el instanceof HTMLElement)) return false; - const style = window.getComputedStyle(el); - if (style.display === 'none' || style.visibility === 'hidden') return false; - const rect = el.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; - }; - const button = document.querySelector('button[data-testid="composer-plus-btn"]'); - if (!(button instanceof HTMLElement) || !isVisible(button)) return { found: false }; - button.scrollIntoView({ block: 'center', inline: 'center' }); - const rect = button.getBoundingClientRect(); - return { - found: true, - x: Math.round(rect.left + rect.width / 2), - y: Math.round(rect.top + rect.height / 2), - }; - })()`)), 'chatgpt tools menu button'); - if (!menuButton.found) { - throw new CommandExecutionError('Could not find the ChatGPT tools menu button in the composer.'); - } - await page.nativeClick(Number(menuButton.x), Number(menuButton.y)); - await page.wait(0.5); - - let optionCenter = null; - for (let attempt = 0; attempt < 10; attempt += 1) { - optionCenter = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => { - const isVisible = (el) => { - if (!(el instanceof HTMLElement)) return false; - const style = window.getComputedStyle(el); - if (style.display === 'none' || style.visibility === 'hidden') return false; - const rect = el.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; - }; - const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim(); - const labels = ${JSON.stringify(target.labels)}; - const options = Array.from(document.querySelectorAll('[role="menuitemradio"]')); - const option = options.find((node) => node instanceof HTMLElement && isVisible(node) && labels.includes(normalize(node.textContent))); - if (!(option instanceof HTMLElement)) return { found: false }; - const checked = option.getAttribute('aria-checked') === 'true'; - option.scrollIntoView({ block: 'center', inline: 'center' }); - const rect = option.getBoundingClientRect(); - return { - found: true, - checked, - x: Math.round(rect.left + rect.width / 2), - y: Math.round(rect.top + rect.height / 2), - }; - })()`)), 'chatgpt tool option click'); - if (optionCenter.found) break; - await page.wait(0.5); - } - if (!optionCenter?.found) { - throw new CommandExecutionError(`Could not find the ChatGPT ${target.label} tool option.`); - } - if (!optionCenter.checked) { - await page.nativeClick(Number(optionCenter.x), Number(optionCenter.y)); - } - - await page.wait(0.5); - const after = await getCurrentChatGPTTool(page); - if (after.tool !== target.key) { - throw new CommandExecutionError(`ChatGPT tool did not switch to ${target.label}.`); - } - return { Status: optionCenter.checked ? 'Already selected' : 'Success', Tool: target.label }; -} - -export async function clearChatGPTDraft(page) { - await page.evaluate(` - (() => { - const removeLabels = [/^remove file/i, /^Remove file/]; - for (let pass = 0; pass < 10; pass += 1) { - const button = Array.from(document.querySelectorAll('button')).find((node) => { - const label = node.getAttribute('aria-label') || ''; - return removeLabels.some((pattern) => pattern.test(label)); - }); - if (!button) break; - button.click(); - } - - const selectors = ${JSON.stringify(COMPOSER_SELECTORS)}; - for (const selector of selectors) { - for (const node of document.querySelectorAll(selector)) { - if (!(node instanceof HTMLElement)) continue; - if (node instanceof HTMLTextAreaElement || node instanceof HTMLInputElement) { - node.value = ''; - } else if (node.isContentEditable) { - node.textContent = ''; - node.innerHTML = '


'; - } else { - node.textContent = ''; - } - node.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward', data: null })); - node.dispatchEvent(new Event('change', { bubbles: true })); - } - } - })() - `); - await page.wait(0.5); -} - -export function parseChatGPTProjectId(value) { - const raw = String(value ?? '').trim(); - if (/^https?:\/\//i.test(raw) || raw.startsWith('/')) { - const id = projectIdFromUrl(raw); - if (id) return id; - throw new ArgumentError( - 'chatgpt project commands require a chatgpt.com project id or /g/g-p- URL', - 'Example: webcmd chatgpt project-file-add report.pdf --id 12345678', - ); - } - // Accept project slug pattern: g-p-{hex_id}-{slug} or just hex id - const slugMatch = raw.match(/^g-p-([a-f0-9]{8,})/i); - if (slugMatch) return slugMatch[1].toLowerCase(); - if (/^[a-f0-9]{8,}$/i.test(raw)) return raw.toLowerCase(); - throw new ArgumentError( - 'chatgpt project commands require a project id or /g/g-p- URL', - 'Example: webcmd chatgpt project-file-add report.pdf --id 12345678', - ); -} - -/** - * Send a message to the ChatGPT composer and submit it. - * Returns true if the message was sent successfully. - */ -export async function sendChatGPTMessage(page, text) { - // Close sidebar if open (it can cover the chat composer) - await page.evaluate(` - (() => { - const labels = ${JSON.stringify(CLOSE_SIDEBAR_LABELS)}; - const closeBtn = Array.from(document.querySelectorAll('button')).find(b => labels.includes(b.getAttribute('aria-label') || '')); - if (closeBtn) closeBtn.click(); - })() - `); - // The previous 0.5 s + 1.5 s pre-composer settles are dropped: the next - // page.evaluate roundtrip flushes the close-sidebar React update and - // findComposer() retries inside a single CDP call, so no fixed sleep is - // needed before reading the composer. - - const typeResult = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(` - (() => { - ${buildComposerLocatorScript()} - const composer = findComposer(); - if (!composer) return { ready: false }; - composer.focus(); - if (composer instanceof HTMLTextAreaElement || composer instanceof HTMLInputElement) { - composer.value = ''; - } else if (composer.isContentEditable) { - composer.textContent = ''; - composer.innerHTML = '


'; - } else { - composer.textContent = ''; - } - composer.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward', data: null })); - composer.dispatchEvent(new Event('change', { bubbles: true })); - composer.scrollIntoView({ block: 'center', inline: 'center' }); - const rect = composer.getBoundingClientRect(); - return { - ready: true, - x: Math.round(rect.left + Math.max(8, Math.min(rect.width / 2, rect.width - 8))), - y: Math.round(rect.top + Math.max(8, Math.min(rect.height / 2, rect.height - 8))), - }; - })() - `)), 'chatgpt composer readiness'); - - if (!typeResult.ready) return false; - - // Use page.type() which is Playwright's native method - try { - if (page.nativeType) { - if (typeof page.nativeClick === 'function') { - await page.nativeClick(Number(typeResult.x), Number(typeResult.y)); - await page.wait(0.2); - } - await page.nativeType(text); - } else { - throw new Error('nativeType unavailable'); - } - } catch (e) { - // Fallback: use execCommand - await page.evaluate(` - (() => { - var composer = null; - var sels = ${JSON.stringify(COMPOSER_SELECTORS)}; - for (var si = 0; si < sels.length; si++) { composer = document.querySelector(sels[si]); if (composer) break; } - if (!composer) return; - composer.focus(); - document.execCommand('insertText', false, ${JSON.stringify(text)}); - })() - `); - } - - let sent = null; - for (let attempt = 0; attempt < 20; attempt += 1) { - await page.wait(0.5); - sent = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(` - (() => { - const isVisible = (el) => { - if (!(el instanceof HTMLElement)) return false; - const style = window.getComputedStyle(el); - if (style.display === 'none' || style.visibility === 'hidden') return false; - const rect = el.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; - }; - const isUsable = (button) => button - && isVisible(button) - && !button.disabled - && button.getAttribute('aria-disabled') !== 'true'; - const form = Array.from(document.querySelectorAll('form')).find((node) => node instanceof HTMLElement && isVisible(node)); - const root = form || document.body; - const primary = root.querySelector(${JSON.stringify(SEND_BUTTON_SELECTOR)}) - || ${JSON.stringify(SEND_BUTTON_FALLBACK_SELECTORS)}.map(selector => root.querySelector(selector)).find(Boolean); - const btns = Array.from(root.querySelectorAll('button')); - const labels = ${JSON.stringify(SEND_BUTTON_LABELS)}; - const looksLikeSend = (button) => { - const label = button.getAttribute('aria-label') || ''; - const text = (button.innerText || button.textContent || '').replace(/\\s+/g, ' ').trim(); - return labels.includes(label) || labels.includes(text) || /send|Send/i.test(label) || /send|Send/i.test(text); - }; - const sendBtn = isUsable(primary) - ? primary - : btns.find(b => looksLikeSend(b) && isUsable(b)); - return { sendBtnFound: !!sendBtn }; - })() - `)), 'chatgpt send button readiness'); - if (sent?.sendBtnFound) break; - } - - if (!sent?.sendBtnFound) { - return false; - } - - await page.evaluate(` - (() => { - const isVisible = (el) => { - if (!(el instanceof HTMLElement)) return false; - const style = window.getComputedStyle(el); - if (style.display === 'none' || style.visibility === 'hidden') return false; - const rect = el.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; - }; - const isUsable = (button) => button - && isVisible(button) - && !button.disabled - && button.getAttribute('aria-disabled') !== 'true'; - const form = Array.from(document.querySelectorAll('form')).find((node) => node instanceof HTMLElement && isVisible(node)); - const root = form || document.body; - const primary = root.querySelector(${JSON.stringify(SEND_BUTTON_SELECTOR)}) - || ${JSON.stringify(SEND_BUTTON_FALLBACK_SELECTORS)}.map(selector => root.querySelector(selector)).find(Boolean); - const labels = ${JSON.stringify(SEND_BUTTON_LABELS)}; - const looksLikeSend = (button) => { - const label = button.getAttribute('aria-label') || ''; - const text = (button.innerText || button.textContent || '').replace(/\\s+/g, ' ').trim(); - return labels.includes(label) || labels.includes(text) || /send|Send/i.test(label) || /send|Send/i.test(text); - }; - const sendBtn = isUsable(primary) - ? primary - : Array.from(root.querySelectorAll('button')).find(b => looksLikeSend(b) && isUsable(b)); - if (sendBtn) sendBtn.click(); - })() - `); - return true; -} - -export async function getVisibleMessages(page, { textOnly = false } = {}) { - const includeHtml = textOnly ? 'false' : 'true'; - const result = requireArrayEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => { - const includeHtml = ${includeHtml}; - const isVisible = (el) => { - if (!(el instanceof HTMLElement)) return false; - const style = window.getComputedStyle(el); - if (style.display === 'none' || style.visibility === 'hidden') return false; - const rect = el.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; - }; - const normalize = (value) => String(value || '').replace(/\\u00a0/g, ' ').replace(/[ \\t]+\\n/g, '\\n').replace(/\\n{3,}/g, '\\n\\n').trim(); - const roleOf = (node) => { - const attr = node.getAttribute('data-message-author-role') || node.getAttribute('data-author') || ''; - if (/assistant/i.test(attr)) return 'Assistant'; - if (/user/i.test(attr)) return 'User'; - const testid = node.getAttribute('data-testid') || ''; - if (/assistant/i.test(testid)) return 'Assistant'; - if (/user/i.test(testid)) return 'User'; - const label = node.getAttribute('aria-label') || ''; - if (/assistant|chatgpt/i.test(label)) return 'Assistant'; - if (/you|user/i.test(label)) return 'User'; - return ''; - }; - - let nodes = Array.from(document.querySelectorAll('[data-message-author-role], article[data-testid*="conversation-turn"]')); - nodes = nodes.filter((node) => node instanceof HTMLElement && isVisible(node)); - - const rows = []; - const seen = new Set(); - for (const node of nodes) { - let role = roleOf(node); - const roleNode = node.querySelector('[data-message-author-role], [data-author]'); - if (!role && roleNode) role = roleOf(roleNode); - if (!role) continue; - - const contentNode = node.querySelector('[data-message-author-role] .markdown') - || node.querySelector('.markdown') - || node.querySelector('[data-message-author-role]') - || node; - const html = includeHtml && contentNode instanceof HTMLElement ? (contentNode.innerHTML || '') : ''; - const rawText = contentNode instanceof HTMLElement - ? (includeHtml ? (contentNode.innerText || contentNode.textContent || '') : (contentNode.textContent || '')) - : ''; - const text = normalize(rawText); - if (!text) continue; - const key = role + '\\n' + text; - if (seen.has(key)) continue; - seen.add(key); - rows.push({ role, text, html }); - } - return rows; - })()`)), 'chatgpt visible messages'); - return result.map((item, index) => ({ - Index: index + 1, - Role: item?.role === 'Assistant' ? 'Assistant' : 'User', - Text: String(item?.text || '').trim(), - Html: String(item?.html || ''), - })).filter((item) => item.Text); -} - -function formatChatGPTDetailMessages(messages, { wantMarkdown, generating, stableSeconds }) { - return messages.map((message) => ({ - Index: message.Index, - Role: message.Role, - Text: wantMarkdown && message.Role === 'Assistant' && message.Html - ? (messageHtmlToMarkdown(message.Html) || message.Text) - : message.Text, - Generating: generating, - StableSeconds: stableSeconds, - })); -} - -export async function getChatGPTDetailRows(page, { wantMarkdown = false, stableSeconds = 0 } = {}) { - const generating = await isGenerating(page); - const messages = await getVisibleMessages(page); - return { - messages, - rows: formatChatGPTDetailMessages(messages, { wantMarkdown, generating, stableSeconds }), - generating, - }; -} - -export async function waitForChatGPTDetailRows(page, { wantMarkdown = false, timeoutSeconds = 120, stableSeconds = 6 } = {}) { - const startTime = Date.now(); - let lastKey = ''; - let stableStartedAt = 0; - - while (Date.now() - startTime < timeoutSeconds * 1000) { - const generating = await isGenerating(page); - const messages = await getVisibleMessages(page); - const key = JSON.stringify(messages.map((message) => [message.Role, message.Text])); - if (!generating && messages.length && messages[messages.length - 1]?.Role === 'Assistant') { - if (key === lastKey) { - if (!stableStartedAt) stableStartedAt = Date.now(); - const elapsedSeconds = Math.floor((Date.now() - stableStartedAt) / 1000); - if (elapsedSeconds >= stableSeconds) { - return { - messages, - rows: formatChatGPTDetailMessages(messages, { - wantMarkdown, - generating: false, - stableSeconds: elapsedSeconds, - }), - generating: false, - }; - } - } else { - lastKey = key; - stableStartedAt = Date.now(); - } - } else { - lastKey = key; - stableStartedAt = 0; - } - await page.sleep(3); - } - - throw new TimeoutError( - 'chatgpt detail', - timeoutSeconds, - 'Conversation did not finish or stabilize before timeout. Re-run with a higher --timeout if it is still generating.', - ); -} - -function normalizeDeepResearchText(value) { - return String(value || '') - .replace(/\u00a0/g, ' ') - .replace(/[ \t]+\n/g, '\n') - .replace(/\n{3,}/g, '\n\n') - .trim(); -} - -function looksLikeDeepResearchReport(text) { - const normalized = normalizeDeepResearchText(text); - if (normalized.length < 500) return false; - return /(^|\n)\s*#{1,3}\s+\S|Sources|References|References|sources|Conclusion|Suggestion|Executive Summary|Summary/i.test(normalized); -} - -function parseJsonMaybe(value) { - if (!value) return null; - if (typeof value === 'object') return value; - if (typeof value !== 'string') return null; - try { - return JSON.parse(value); - } catch { - return null; - } -} - -function extractDeepResearchSourcesFromReportMessage(reportMessage) { - const metadata = reportMessage?.metadata && typeof reportMessage.metadata === 'object' - ? reportMessage.metadata - : {}; - const references = Array.isArray(metadata.content_references) ? metadata.content_references : []; - const safeUrls = Array.isArray(metadata.safe_urls) ? metadata.safe_urls : []; - const groups = Array.isArray(metadata.search_result_groups) ? metadata.search_result_groups : []; - const byUrl = new Map(); - - const addSource = (source = {}, label = 'source') => { - if (!source || typeof source !== 'object') { - throw new CommandExecutionError(`Malformed ChatGPT Deep Research ${label}: expected object source row.`); - } - const rawUrl = String(source.url || source.href || source.safe_url || '').trim(); - const title = String(source.title || source.name || source.text || '').trim(); - if (!rawUrl) { - if (title || source.matched_text || source.metadata) { - throw new CommandExecutionError(`Malformed ChatGPT Deep Research ${label}: missing source URL.`); - } - return; - } - if (!/^https?:\/\//i.test(rawUrl)) { - throw new CommandExecutionError(`Malformed ChatGPT Deep Research ${label}: invalid source URL.`); - } - if (!byUrl.has(rawUrl)) { - byUrl.set(rawUrl, { title, url: rawUrl }); - } else if (title && !byUrl.get(rawUrl).title) { - byUrl.get(rawUrl).title = title; - } - }; - - for (const reference of references) { - const hasDirectSource = reference && typeof reference === 'object' - && (reference.url || reference.href || reference.safe_url || reference.title || reference.name || reference.text || reference.matched_text); - if (hasDirectSource) addSource(reference, 'content reference'); - if (reference?.matched_text) addSource({ title: reference.matched_text, url: reference.url }, 'matched content reference'); - if (reference?.metadata) addSource(reference.metadata, 'content reference metadata'); - } - for (const url of safeUrls) addSource(typeof url === 'string' ? { url } : url, 'safe URL'); - for (const group of groups) { - for (const entry of [ - ...(Array.isArray(group?.entries) ? group.entries : []), - ...(Array.isArray(group?.results) ? group.results : []), - ...(Array.isArray(group?.items) ? group.items : []), - ]) { - addSource(entry, 'search result'); - } - } - return [...byUrl.values()].slice(0, 200); -} - -function pickFirstObject(...values) { - for (const value of values) { - const parsed = parseJsonMaybe(value); - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed; - } - return {}; -} - -function stringOrEmpty(value) { - return value === undefined || value === null ? '' : String(value); -} - -function compactDeepResearchPlanSteps(plan, stepStatusesByPlan) { - const steps = Array.isArray(plan?.steps) ? plan.steps : []; - return steps.slice(0, 50).map((step, index) => { - const id = stringOrEmpty(step?.id || step?.step_id || step?.plan_step_id || step?.key || index); - return { - id, - title: stringOrEmpty(step?.title || step?.name || step?.summary || step?.description), - status: stringOrEmpty(step?.status || step?.step_status || stepStatusesByPlan?.[id] || ''), - }; - }).filter((step) => step.id || step.title || step.status); -} - -function deepResearchProgressStatus(progress) { - const venusStatus = String(progress.venusStatus || '').toLowerCase(); - if (/waiting_for_user|user_response/.test(venusStatus)) return 'waiting_for_user'; - if (/needs_user|user_action|action_required|requires_action/.test(venusStatus)) return 'needs_user_action'; - if (/running|in_progress|loading|generating|researching|queued|started|processing/.test(venusStatus)) return 'running'; - - const venusMessageType = String(progress.venusMessageType || '').toLowerCase(); - if (/loading|running|generating|research|progress/.test(venusMessageType)) return 'running'; - - if (progress.asyncTaskConversationId - || progress.widgetSessionId - || progress.asyncStatus !== undefined - || progress.venusStatus - || progress.planId - || progress.planTitle) { - return 'not_ready'; - } - return ''; -} - -function buildDeepResearchProgressResult(state, responseMetadata, source) { - const widgetState = state && typeof state === 'object' ? state : {}; - const response = responseMetadata && typeof responseMetadata === 'object' ? responseMetadata : {}; - const plan = pickFirstObject(widgetState.plan, widgetState.current_plan, widgetState.research_plan); - const stepStatusesByPlan = pickFirstObject( - widgetState.step_statuses_by_plan, - widgetState.stepStatusesByPlan, - widgetState.step_statuses, - ); - const progress = { - asyncTaskConversationId: stringOrEmpty( - response.async_task_conversation_id - || response.asyncTaskConversationId - || response['openai/asyncTaskConversationId'], - ), - widgetSessionId: stringOrEmpty( - response['openai/widgetSessionId'] - || response.widget_session_id - || response.widgetSessionId, - ), - asyncStatus: response['openai/asyncStatus'] ?? response.async_status ?? response.asyncStatus, - venusMessageType: stringOrEmpty(response.venus_message_type || response.venusMessageType), - venusStatus: stringOrEmpty(widgetState.status || widgetState.venus_status || widgetState.venusStatus), - waitingForUserUntil: stringOrEmpty( - widgetState.waiting_for_user_response_on_plan_until - || widgetState.waitingForUserResponseOnPlanUntil - || widgetState.waiting_for_user_until, - ), - planId: stringOrEmpty(plan.plan_id || plan.planId || plan.id), - planTitle: stringOrEmpty(plan.title || plan.name), - planSteps: compactDeepResearchPlanSteps(plan, stepStatusesByPlan), - stepStatusesByPlan, - }; - const status = deepResearchProgressStatus(progress); - if (!status) return null; - - if (/^completed$/i.test(progress.venusStatus) - && !progress.asyncTaskConversationId - && !progress.widgetSessionId - && progress.asyncStatus === undefined - && !progress.venusMessageType - && !progress.planId - && !progress.planTitle - && !progress.planSteps.length - && !Object.keys(progress.stepStatusesByPlan || {}).length) { - return null; - } - - return { - status, - report: '', - html: '', - method: source.includes('widget-state') ? source.replace('widget-state', 'widget-progress') : `${source}-progress`, - sources: [], - progress, - asyncTaskConversationId: progress.asyncTaskConversationId, - widgetSessionId: progress.widgetSessionId, - asyncStatus: progress.asyncStatus, - venusMessageType: progress.venusMessageType, - venusStatus: progress.venusStatus, - waitingForUserUntil: progress.waitingForUserUntil, - planId: progress.planId, - planTitle: progress.planTitle, - }; -} - -function deepResearchCandidateScore(candidate) { - if (!candidate) return 0; - if (candidate.status === 'completed') return 1000000 + (candidate.reportLength || candidate.report?.length || 0); - if (candidate.status === 'waiting_for_user' || candidate.status === 'needs_user_action') return 500000; - if (candidate.status === 'running') return 400000; - if (candidate.status === 'not_ready') return 300000; - return 1; -} - -function selectDeepResearchCandidate(candidates, payload, mapping) { - if (!candidates.length) return null; - const lineage = new Set(); - let nodeId = String(payload.current_node || payload.currentNode || ''); - while (nodeId && !lineage.has(nodeId)) { - lineage.add(nodeId); - nodeId = String(mapping[nodeId]?.parent || ''); - } - const currentBranch = lineage.size - ? candidates.filter((candidate) => lineage.has(candidate.conversationMessageId)) - : []; - const pool = currentBranch.length ? currentBranch : candidates; - pool.sort((a, b) => { - const createdDelta = b._createdAt - a._createdAt; - if (createdDelta) return createdDelta; - const orderDelta = b._candidateOrder - a._candidateOrder; - if (currentBranch.length && orderDelta) return orderDelta; - const scoreDelta = deepResearchCandidateScore(b) - deepResearchCandidateScore(a); - return scoreDelta || orderDelta; - }); - const { _createdAt, _candidateOrder, ...selected } = pool[0]; - return selected; -} - -function extractDeepResearchFromWidgetState(widgetState, source = 'conversation-widget-state', responseMetadata = null) { - const state = parseJsonMaybe(widgetState); - if ((!state || typeof state !== 'object') && !responseMetadata) return null; - const widgetStateObject = state && typeof state === 'object' ? state : {}; - const reportMessage = widgetStateObject.report_message || widgetStateObject.reportMessage || null; - const parts = Array.isArray(reportMessage?.content?.parts) ? reportMessage.content.parts : []; - const report = normalizeDeepResearchText(parts.filter((part) => typeof part === 'string').join('\n\n')); - if (looksLikeDeepResearchReport(report)) { - return { - status: 'completed', - report, - html: '', - method: source, - sources: extractDeepResearchSourcesFromReportMessage(reportMessage), - widgetStatus: String(widgetStateObject.status || ''), - reportMessageId: String(reportMessage?.id || ''), - reportLength: report.length, - }; - } - return buildDeepResearchProgressResult(widgetStateObject, pickFirstObject(responseMetadata), source); -} - -function extractDeepResearchFromConversationPayload(payload, { expectedConversationId = '' } = {}) { - if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { - throw new CommandExecutionError('Malformed ChatGPT conversation payload for Deep Research extraction.'); - } - const payloadConversationId = String(payload.conversation_id || payload.conversationId || payload.id || '').trim(); - if (expectedConversationId && payloadConversationId && payloadConversationId !== expectedConversationId) { - throw new CommandExecutionError( - `ChatGPT conversation payload id mismatch: expected ${expectedConversationId}, got ${payloadConversationId}.`, - ); - } - const mapping = payload?.mapping && typeof payload.mapping === 'object' ? payload.mapping : {}; - if (!payload.mapping || typeof payload.mapping !== 'object' || Array.isArray(payload.mapping)) { - throw new CommandExecutionError('Malformed ChatGPT conversation payload for Deep Research extraction: missing mapping.'); - } - const candidates = []; - for (const [messageId, node] of Object.entries(mapping)) { - const message = node?.message || {}; - const metadata = message?.metadata || {}; - const sdk = metadata?.chatgpt_sdk || {}; - const responseMetadata = pickFirstObject( - sdk?.response_metadata, - sdk?.responseMetadata, - metadata?.response_metadata, - metadata?.responseMetadata, - ); - for (const widgetState of [ - sdk?.widget_state, - sdk?.widgetState, - metadata?.widget_state, - metadata?.widgetState, - ]) { - if (widgetState === undefined || widgetState === null) continue; - const extracted = extractDeepResearchFromWidgetState(widgetState, 'conversation-widget-state', responseMetadata); - if (extracted) { - candidates.push({ - ...extracted, - conversationMessageId: messageId, - _createdAt: Number(message.create_time || message.createTime || metadata.create_time || 0) || 0, - _candidateOrder: candidates.length, - }); - } - } - } - return selectDeepResearchCandidate(candidates, payload, mapping); -} - -function conversationIdFromBackendConversationUrl(url) { - const match = String(url || '').match(/\/backend-api\/conversation\/([^/?#]+)/); - return match?.[1] ? decodeURIComponent(match[1]) : ''; -} - -function extractDeepResearchFromNetworkEntries(entries, { expectedConversationId = '' } = {}) { - const candidates = []; - for (const entry of Array.isArray(entries) ? entries : []) { - const url = String(entry?.url || ''); - if (!/\/backend-api\/conversation\//.test(url)) continue; - const entryConversationId = conversationIdFromBackendConversationUrl(url); - if (expectedConversationId && entryConversationId !== expectedConversationId) continue; - const body = parseJsonMaybe(entry?.responsePreview) || parseJsonMaybe(entry?.body) || null; - if (!body) { - throw new CommandExecutionError(`Malformed ChatGPT conversation network payload for ${entryConversationId || 'unknown conversation'}.`); - } - const extracted = extractDeepResearchFromConversationPayload(body, { expectedConversationId }); - if (extracted) { - candidates.push({ - ...extracted, - method: extracted.status === 'completed' - ? 'network-conversation-widget-state' - : 'network-conversation-widget-progress', - networkUrl: url, - }); - } - } - candidates.sort((a, b) => deepResearchCandidateScore(b) - deepResearchCandidateScore(a)); - return candidates[0] || null; -} - -function conversationIdFromUrl(url) { - const match = String(url || '').match(/\/c\/([a-zA-Z0-9_-]+)/); - return match?.[1] || ''; -} - -async function buildChatGPTConversationHeaders(page, { includeAuthorization = false } = {}) { - if (typeof page.getCookies !== 'function') { - return { ok: false, status: 0, reason: 'missing-cookie-api' }; - } - const cookieLists = await Promise.all([ - page.getCookies({ url: CHATGPT_URL }).catch(() => []), - page.getCookies({ url: `${CHATGPT_URL}/api/auth/session` }).catch(() => []), - page.getCookies({ domain: CHATGPT_DOMAIN }).catch(() => []), - page.getCookies({ domain: `.${CHATGPT_DOMAIN}` }).catch(() => []), - page.getCookies().catch(() => []), - ]); - const cookiesByName = new Map(); - for (const cookie of cookieLists.flat()) { - if (!cookie?.name || typeof cookie.value !== 'string') continue; - if (!cookiesByName.has(cookie.name) || cookie.domain === CHATGPT_DOMAIN || cookie.domain === `.${CHATGPT_DOMAIN}`) { - cookiesByName.set(cookie.name, cookie); - } - } - const cookieHeader = Array.from(cookiesByName.values()) - .map((cookie) => `${cookie.name}=${cookie.value}`) - .join('; '); - if (!cookieHeader) return { ok: false, status: 0, reason: 'missing-cookies' }; - const headers = { - accept: 'application/json', - cookie: cookieHeader, - origin: CHATGPT_URL, - referer: `${CHATGPT_URL}/`, - 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8', - 'sec-fetch-dest': 'empty', - 'sec-fetch-mode': 'cors', - 'sec-fetch-site': 'same-origin', - 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36', - }; - if (!includeAuthorization) return { ok: true, status: 200, headers }; - - const sessionResponse = await fetch(`${CHATGPT_URL}/api/auth/session`, { - headers, - signal: AbortSignal.timeout(10000), - }); - if (!sessionResponse.ok) { - return { ok: false, status: sessionResponse.status, reason: 'session' }; - } - const session = await sessionResponse.json(); - const accessToken = session?.accessToken; - if (!accessToken) return { ok: false, status: 0, reason: 'missing-access-token' }; - return { - ok: true, - status: 200, - headers: { - ...headers, - authorization: `Bearer ${accessToken}`, - }, - }; -} - -async function fetchChatGPTConversationPayload(page, conversationId) { - if (!conversationId) return null; - const errors = []; - try { - const cookieAuth = await buildChatGPTConversationHeaders(page, { includeAuthorization: false }); - if (cookieAuth.ok) { - const response = await fetch(`${CHATGPT_URL}/backend-api/conversation/${conversationId}`, { - headers: { - ...cookieAuth.headers, - referer: `${CHATGPT_URL}/c/${conversationId}`, - }, - signal: AbortSignal.timeout(8000), - }); - const text = await response.text(); - if (response.ok) { - const payload = parseJsonMaybe(text); - if (payload) { - return { - payload, - status: response.status, - contentType: response.headers.get('content-type') || '', - transport: 'node-fetch-cookie', - }; - } - errors.push('node fetch returned non-json'); - } else if (response.status === 401 || response.status === 403) { - errors.push(`node cookie fetch status ${response.status}`); - const bearerAuth = await buildChatGPTConversationHeaders(page, { includeAuthorization: true }); - if (bearerAuth.ok) { - const bearerResponse = await fetch(`${CHATGPT_URL}/backend-api/conversation/${conversationId}`, { - headers: { - ...bearerAuth.headers, - referer: `${CHATGPT_URL}/c/${conversationId}`, - }, - signal: AbortSignal.timeout(8000), - }); - const bearerText = await bearerResponse.text(); - if (bearerResponse.ok) { - const payload = parseJsonMaybe(bearerText); - if (payload) { - return { - payload, - status: bearerResponse.status, - contentType: bearerResponse.headers.get('content-type') || '', - transport: 'node-fetch-bearer', - }; - } - errors.push('node bearer fetch returned non-json'); - } else { - errors.push(`node bearer fetch status ${bearerResponse.status}`); - } - } else { - errors.push(`node bearer auth ${bearerAuth.reason || bearerAuth.status || 'failed'}`); - } - } else { - errors.push(`node fetch status ${response.status}`); - } - } else { - errors.push(`node cookie auth ${cookieAuth.reason || cookieAuth.status || 'failed'}`); - } - } catch (error) { - errors.push(`node fetch ${String(error?.message || error)}`); - } - - const result = unwrapEvaluateResult(await withTimeout(page.evaluate(`(async () => { - const response = await fetch('/backend-api/conversation/${conversationId}', { - credentials: 'include', - headers: { accept: 'application/json' }, - }); - const text = await response.text(); - return { - ok: response.ok, - status: response.status, - contentType: response.headers.get('content-type') || '', - text, - }; - })()`), 8000, 'conversation fetch')); - if (!result?.ok) { - return { error: [...errors, `page fetch status ${result?.status || 0}`].join('; ') }; - } - const payload = parseJsonMaybe(result.text); - if (!payload) return { error: [...errors, 'page fetch returned non-json'].join('; ') }; - return { payload, status: result.status, contentType: result.contentType, transport: 'page-fetch' }; -} - -function collectAxText(tree) { - const nodes = Array.isArray(tree?.nodes) ? tree.nodes : []; - const pieces = []; - for (const node of nodes) { - const role = String(node?.role?.value || node?.role || ''); - if (/StaticText|InlineTextBox|heading|paragraph|link|button|text/i.test(role)) { - const value = String(node?.name?.value || node?.name || '').trim(); - if (value) pieces.push(value); - } - } - return normalizeDeepResearchText(pieces.join('\n')); -} - -function withTimeout(promise, ms, label) { - return Promise.race([ - promise, - new Promise((_, reject) => setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)), - ]); -} - -export async function getChatGPTDeepResearchResult(page, { conversationId = '', useBridgeProbes = false } = {}) { - const iframeState = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => { - const isVisible = (el) => { - if (!(el instanceof HTMLElement)) return false; - const style = window.getComputedStyle(el); - if (style.display === 'none' || style.visibility === 'hidden') return false; - const rect = el.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; - }; - const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim(); - const iframes = Array.from(document.querySelectorAll('iframe')).map((frame, index) => { - const rect = frame.getBoundingClientRect(); - const title = frame.getAttribute('title') || ''; - const src = frame.getAttribute('src') || frame.src || ''; - const deepResearch = /deep-research|connector_openai_deep_research/i.test(title + ' ' + src); - let accessible = false; - let text = ''; - let html = ''; - let accessError = ''; - try { - const doc = frame.contentDocument || frame.contentWindow?.document; - accessible = !!doc; - text = normalize(doc?.body?.innerText || doc?.body?.textContent || ''); - html = String(doc?.body?.innerHTML || ''); - } catch (error) { - accessError = String(error?.name || error); - } - return { - index, - title, - src, - visible: isVisible(frame), - width: Math.round(rect.width), - height: Math.round(rect.height), - deepResearch, - accessible, - accessError, - text, - html, - }; - }); - const matched = iframes.find((frame) => frame.deepResearch) || null; - return { - url: window.location.href, - title: document.title, - iframes, - deepResearchIframe: matched, - }; - })()`)), 'chatgpt deep research iframe state'); - - const generating = await isGenerating(page).catch(() => false); - const iframe = iframeState.deepResearchIframe; - const currentConversationId = conversationIdFromUrl(iframeState.url); - if (conversationId) { - if (!currentConversationId) { - throw new CommandExecutionError( - `ChatGPT deep-research-result did not stay on requested conversation ${conversationId}.`, - ); - } - if (currentConversationId !== conversationId) { - throw new CommandExecutionError( - `ChatGPT deep-research-result conversation mismatch: expected ${conversationId}, current page is ${currentConversationId}.`, - ); - } - } - let progressCandidate = null; - const diagnostics = { - iframeCount: Array.isArray(iframeState.iframes) ? iframeState.iframes.length : 0, - iframe: iframe ? { - index: iframe.index, - title: iframe.title, - src: iframe.src, - visible: iframe.visible, - width: iframe.width, - height: iframe.height, - accessible: iframe.accessible, - accessError: iframe.accessError || '', - } : null, - methodsTried: ['main-document-iframe'], - methodsSkipped: useBridgeProbes ? [] : ['browser-frames', 'cdp-accessibility', 'network-capture'], - }; - - if (useBridgeProbes && typeof page.readNetworkCapture === 'function') { - diagnostics.methodsTried.push('network-conversation-widget-state'); - try { - const entries = await withTimeout(page.readNetworkCapture(), 5000, 'network capture read'); - const relevantEntries = (Array.isArray(entries) ? entries : []) - .filter((entry) => /\/backend-api\/conversation\/|deep|research|oaiusercontent|ecosystem|widget/i.test(String(entry?.url || ''))); - diagnostics.networkEntries = relevantEntries - .slice(-20) - .map((entry) => ({ - url: entry.url, - status: entry.responseStatus ?? entry.status ?? 0, - contentType: entry.responseContentType ?? '', - preview: String(entry.responsePreview || '').slice(0, 500), - bodySize: Number(entry.responseBodyFullSize || 0) || undefined, - bodyTruncated: entry.responseBodyTruncated === true || undefined, - })); - const extracted = extractDeepResearchFromNetworkEntries(relevantEntries, { expectedConversationId: conversationId }); - if (extracted) { - diagnostics.networkConversation = { - foundReport: extracted.status === 'completed', - foundProgress: extracted.status !== 'completed', - status: extracted.status, - reportLength: extracted.reportLength || extracted.report.length, - sourceCount: Array.isArray(extracted.sources) ? extracted.sources.length : 0, - venusStatus: extracted.venusStatus || '', - asyncTaskConversationId: extracted.asyncTaskConversationId || '', - }; - if (extracted.status === 'completed') { - return { - status: 'completed', - report: extracted.report, - html: '', - url: iframeState.url, - method: extracted.method, - sources: extracted.sources || [], - diagnostics, - }; - } - progressCandidate = extracted; - } - } catch (error) { - if (error instanceof CommandExecutionError) throw error; - diagnostics.networkConversationError = String(error?.message || error); - } - } - - const fetchConversationId = conversationId || currentConversationId; - if (fetchConversationId) { - diagnostics.methodsTried.push('conversation-widget-state'); - try { - const conversation = await fetchChatGPTConversationPayload(page, fetchConversationId); - if (conversation?.error) { - diagnostics.conversationError = conversation.error; - } else { - const extracted = extractDeepResearchFromConversationPayload(conversation?.payload, { - expectedConversationId: fetchConversationId, - }); - diagnostics.conversation = { - status: conversation?.status || 0, - contentType: conversation?.contentType || '', - transport: conversation?.transport || '', - foundReport: extracted?.status === 'completed', - foundProgress: !!extracted && extracted.status !== 'completed', - deepResearchStatus: extracted?.status || '', - reportLength: extracted?.reportLength || 0, - widgetStatus: extracted?.widgetStatus || '', - venusStatus: extracted?.venusStatus || '', - asyncTaskConversationId: extracted?.asyncTaskConversationId || '', - sourceCount: Array.isArray(extracted?.sources) ? extracted.sources.length : 0, - }; - if (extracted?.status === 'completed') { - return { - status: 'completed', - report: extracted.report, - html: '', - url: iframeState.url, - method: extracted.method, - sources: extracted.sources || [], - diagnostics, - }; - } - if (extracted) progressCandidate = extracted; - } - } catch (error) { - if (error instanceof CommandExecutionError) throw error; - diagnostics.conversationError = String(error?.message || error); - } - } - - if (progressCandidate) { - return { - ...progressCandidate, - url: iframeState.url, - diagnostics, - }; - } - - if (iframe?.text && looksLikeDeepResearchReport(iframe.text)) { - return { - status: 'completed', - report: normalizeDeepResearchText(iframe.text), - html: iframe.html || '', - url: iframeState.url, - method: 'same-origin-iframe-dom', - sources: [], - diagnostics, - }; - } - - if (!iframe) { - return { - status: generating ? 'running' : 'not_found', - report: '', - html: '', - url: iframeState.url, - method: 'main-document-dom', - sources: [], - diagnostics, - }; - } - - if (useBridgeProbes && typeof page.frames === 'function' && typeof page.evaluateInFrame === 'function') { - try { - const frames = await withTimeout(page.frames(), 3000, 'browser frames'); - diagnostics.frames = Array.isArray(frames) ? frames : []; - for (let index = 0; index < diagnostics.frames.length; index += 1) { - const frameInfo = diagnostics.frames[index]; - const frameText = unwrapEvaluateResult(await withTimeout( - page.evaluateInFrame('document.body?.innerText || document.body?.textContent || ""', index), - 3000, - 'frame eval', - )); - const text = normalizeDeepResearchText(frameText); - if ((/deep-research|connector_openai_deep_research/i.test(String(frameInfo?.url || '')) || looksLikeDeepResearchReport(text)) - && looksLikeDeepResearchReport(text)) { - return { - status: 'completed', - report: text, - html: '', - url: iframeState.url, - method: 'browser-frame-dom', - sources: [], - diagnostics, - }; - } - } - } catch (error) { - diagnostics.frameError = String(error?.message || error); - } - } - - if (useBridgeProbes && typeof page.cdp === 'function') { - try { - const frameTree = await withTimeout(page.cdp('Page.getFrameTree', {}), 5000, 'Page.getFrameTree'); - diagnostics.frameTree = frameTree; - const stack = [frameTree?.frameTree].filter(Boolean); - const frames = []; - while (stack.length) { - const node = stack.shift(); - const frame = node?.frame; - const url = String(frame?.url || frame?.unreachableUrl || ''); - if (frame?.id && /deep-research|connector_openai_deep_research|oaiusercontent/i.test(url)) { - frames.push({ frameId: frame.id, url }); - } - for (const child of node?.childFrames || []) stack.push(child); - } - diagnostics.cdpFrames = frames; - for (const frame of frames) { - const tree = await withTimeout(page.cdp('Accessibility.getFullAXTree', { - frameId: frame.frameId, - sessionId: 'target', - targetUrl: frame.url, - }), 5000, 'Accessibility.getFullAXTree').catch(() => null); - const text = collectAxText(tree); - if (looksLikeDeepResearchReport(text)) { - return { - status: 'completed', - report: text, - html: '', - url: iframeState.url, - method: 'cdp-accessibility-frame', - sources: [], - diagnostics, - }; - } - } - } catch (error) { - diagnostics.cdpError = String(error?.message || error); - } - } - - if (useBridgeProbes && typeof page.readNetworkCapture === 'function' && !diagnostics.networkEntries) { - try { - const entries = await withTimeout(page.readNetworkCapture(), 3000, 'network capture read'); - diagnostics.networkEntries = (Array.isArray(entries) ? entries : []) - .filter((entry) => /deep|research|oaiusercontent|ecosystem|widget/i.test(String(entry?.url || ''))) - .slice(-20) - .map((entry) => ({ - url: entry.url, - status: entry.responseStatus ?? entry.status ?? 0, - contentType: entry.responseContentType ?? '', - preview: String(entry.responsePreview || '').slice(0, 500), - })); - const candidate = diagnostics.networkEntries - .map((entry) => entry.preview) - .find((preview) => looksLikeDeepResearchReport(preview)); - if (candidate) { - return { - status: 'completed', - report: normalizeDeepResearchText(candidate), - html: '', - url: iframeState.url, - method: 'network-capture', - sources: [], - diagnostics, - }; - } - } catch (error) { - diagnostics.networkError = String(error?.message || error); - } - } - - return { - status: generating ? 'running' : 'unavailable', - report: '', - html: '', - url: iframeState.url, - method: 'cross-origin-iframe-detected', - sources: [], - diagnostics, - }; -} - -export async function waitForChatGPTDeepResearchResult(page, { conversationId = '', timeoutSeconds = 120, stableSeconds = 6 } = {}) { - const startTime = Date.now(); - let lastReport = ''; - let stableStartedAt = 0; - - while (Date.now() - startTime < timeoutSeconds * 1000) { - const result = await getChatGPTDeepResearchResult(page, { conversationId, useBridgeProbes: true }); - if (result.status === 'completed' && result.report) { - if (/conversation-widget-state/.test(result.method || '')) { - return { ...result, stableSeconds: 0 }; - } - if (result.report === lastReport) { - if (!stableStartedAt) stableStartedAt = Date.now(); - const elapsedSeconds = Math.floor((Date.now() - stableStartedAt) / 1000); - if (elapsedSeconds >= stableSeconds) { - return { ...result, stableSeconds: elapsedSeconds }; - } - } else { - lastReport = result.report; - stableStartedAt = Date.now(); - } - } else if (result.status === 'waiting_for_user' || result.status === 'needs_user_action') { - return result; - } - await page.sleep(3); - } - - throw new TimeoutError( - 'chatgpt deep-research-result', - timeoutSeconds, - 'Deep Research did not complete or become extractable before timeout.', - ); -} - -export function messageHtmlToMarkdown(html) { - try { - return htmlToMarkdown(html).trim(); - } catch { - return String(html || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); - } -} - -export async function getBubbleCount(page) { - const messages = await getVisibleMessages(page); - return messages.length; -} - -function cleanPromptText(str) { - return String(str || '') - .replace(/\s+/g, ' ') - .trim(); -} - -function responsePairKey(user, assistant) { - return JSON.stringify([ - cleanPromptText(user?.Text), - String(assistant?.Text || '').trim(), - ]); -} - -export function getChatGPTResponsePairKeys(messages, prompt) { - const promptKey = cleanPromptText(prompt); - if (!promptKey) return []; - const keys = []; - for (let index = 0; index < messages.length; index += 1) { - const user = messages[index]; - if (user?.Role !== 'User' || cleanPromptText(user.Text) !== promptKey) continue; - const assistant = messages.slice(index + 1).find((message) => message?.Role === 'Assistant'); - if (!assistant || !String(assistant.Text || '').trim()) continue; - keys.push(responsePairKey(user, assistant)); - } - return keys; -} - -export function getChatGPTResponsePairCounts(messages, prompt) { - const counts = new Map(); - for (const key of getChatGPTResponsePairKeys(messages, prompt)) { - counts.set(key, (counts.get(key) || 0) + 1); - } - return counts; -} - -function normalizeBaselinePairCounts(options) { - if (options.baselinePairCounts instanceof Map) return options.baselinePairCounts; - return new Map(Array.from(options.baselinePairKeys || []).map((key) => [key, 1])); -} - -function findLatestNewAssistantResponse(messages, prompt, baselinePairCounts) { - const promptKey = cleanPromptText(prompt); - if (!promptKey) return ''; - const currentPairCounts = getChatGPTResponsePairCounts(messages, prompt); - for (let index = messages.length - 1; index >= 0; index -= 1) { - const user = messages[index]; - if (user?.Role !== 'User' || cleanPromptText(user.Text) !== promptKey) continue; - const assistantIndex = messages.findIndex((message, candidateIndex) => ( - candidateIndex > index - && message?.Role === 'Assistant' - && String(message.Text || '').trim() - )); - if (assistantIndex < 0) continue; - const assistant = messages[assistantIndex]; - const key = responsePairKey(user, assistant); - if ((currentPairCounts.get(key) || 0) <= (baselinePairCounts.get(key) || 0)) continue; - return String(assistant.Text || '').trim(); - } - return ''; -} - -export async function waitForChatGPTResponse(page, baselineCount, prompt, timeoutSeconds, options = {}) { - const startTime = Date.now(); - let lastText = ''; - let stableCount = 0; - const baselinePairCounts = normalizeBaselinePairCounts(options); - - while (Date.now() - startTime < timeoutSeconds * 1000) { - await page.sleep(3); - if (options.conversationUrl) { - const currentUrl = await currentChatGPTUrl(page); - if (currentUrl && !isSameChatGPTConversation(currentUrl, options.conversationUrl)) { - throw new CommandExecutionError( - `ChatGPT navigated away from the target conversation (${options.conversationUrl}); current URL is ${currentUrl}`, - ); - } - } - if (await isGenerating(page)) { - stableCount = 0; - continue; - } - - const messages = await getVisibleMessages(page, { textOnly: true }); - const candidate = findLatestNewAssistantResponse(messages, prompt, baselinePairCounts); - if (!candidate || candidate === String(prompt || '').trim()) continue; - - if (candidate === lastText) { - stableCount += 1; - if (stableCount >= 2) return candidate; - } else { - lastText = candidate; - stableCount = 0; - } - } - - throw new TimeoutError( - 'chatgpt ask', - timeoutSeconds, - 'No ChatGPT response appeared before timeout. Re-run with a higher --timeout if it is still generating.', - ); -} - -export async function getConversationList(page) { - // ensureOnChatGPT already waits for the composer selector after navigation, - // so the previous standalone 2 s settle is redundant. - await ensureOnChatGPT(page); - - const openSidebar = requireBooleanEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => { - const button = Array.from(document.querySelectorAll('button')) - .find((node) => /open sidebar/i.test(node.getAttribute('aria-label') || '')); - if (button instanceof HTMLElement) { - button.click(); - return true; - } - return false; - })()`)), 'chatgpt sidebar open state'); - if (openSidebar) { - try { - await page.wait({ selector: CONVERSATION_LINK_SELECTOR, timeout: 3 }); - } catch { - // Sidebar slide-in didn't surface conversation links; extractConversationLinks below tolerates empty and falls back to home goto. - } - } - - let items = await extractConversationLinks(page); - if (!items.length) { - await page.goto(CHATGPT_URL, { settleMs: 2000 }); - try { - await page.wait({ selector: CONVERSATION_LINK_SELECTOR, timeout: 8 }); - } catch { - // No conversation links visible after fallback goto; extractConversationLinks returns empty. - } - items = await extractConversationLinks(page); - } - - return items; -} - -async function extractConversationLinks(page) { - const items = requireArrayEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => { - const isVisible = (el) => { - if (!(el instanceof HTMLElement)) return false; - const style = window.getComputedStyle(el); - if (style.display === 'none' || style.visibility === 'hidden') return false; - const rect = el.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; - }; - const links = Array.from(document.querySelectorAll('a[href*="/c/"]')) - .filter((link) => link instanceof HTMLAnchorElement && isVisible(link)); - const seen = new Set(); - const rows = []; - for (const link of links) { - const href = link.getAttribute('href') || ''; - const match = href.match(/\\/c\\/([^/?#]+)/); - if (!match || seen.has(match[1])) continue; - seen.add(match[1]); - const title = (link.innerText || link.textContent || '').replace(/\\s+/g, ' ').trim() || '(untitled)'; - rows.push({ - Id: match[1], - Title: title, - Url: href.startsWith('http') ? href : ('${CHATGPT_URL}' + href), - }); - } - return rows; - })()`)), 'chatgpt conversation link extraction'); - return items.map((item, index) => ({ - Index: index + 1, - Id: String(item?.Id || ''), - Title: String(item?.Title || '(untitled)').trim() || '(untitled)', - Url: String(item?.Url || ''), - })).filter((item) => item.Id); -} - -function imageMimeFromPath(filePath) { - const lower = String(filePath || '').toLowerCase(); - if (lower.endsWith('.png')) return 'image/png'; - if (lower.endsWith('.webp')) return 'image/webp'; - if (lower.endsWith('.gif')) return 'image/gif'; - if (lower.endsWith('.heic')) return 'image/heic'; - if (lower.endsWith('.heif')) return 'image/heif'; - return 'image/jpeg'; -} - -export async function prepareChatGPTImagePaths(imagePaths) { - const fs = await import('node:fs'); - const path = await import('node:path'); - const absPaths = imagePaths.map(filePath => path.default.resolve(filePath)); - const allowedExts = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.heic', '.heif']); - - for (const absPath of absPaths) { - if (!fs.default.existsSync(absPath)) { - return { ok: false, reason: `Image not found: ${absPath}` }; - } - const stat = fs.default.statSync(absPath); - if (!stat.isFile()) { - return { ok: false, reason: `Not a file: ${absPath}` }; - } - if (stat.size > 25 * 1024 * 1024) { - return { ok: false, reason: `Image too large (${(stat.size / 1024 / 1024).toFixed(1)} MB). Max: 25 MB` }; - } - const ext = path.default.extname(absPath).toLowerCase(); - if (!allowedExts.has(ext)) { - return { ok: false, reason: `Unsupported image type: ${absPath}` }; - } - } - - return { ok: true, paths: absPaths }; -} - -async function waitForChatGPTUploadPreview(page, fileNames) { - const namesJson = JSON.stringify(fileNames); - for (let attempt = 0; attempt < 10; attempt += 1) { - await page.sleep(1); - const ready = requireBooleanEvaluateResult(unwrapEvaluateResult(await page.evaluate(` - (() => { - const names = ${namesJson}; - const text = document.body ? (document.body.innerText || '') : ''; - const matchedNames = names.filter(name => text.includes(name)).length; - if (matchedNames >= names.length) return true; - - const composer = document.querySelector('[aria-label="Chat with ChatGPT"], [placeholder="Ask anything"], #prompt-textarea'); - let root = composer; - for (let i = 0; i < 6 && root && root.parentElement; i += 1) root = root.parentElement; - const scope = root || document.body; - if (!scope) return false; - - const isVisibleMedia = (node) => { - if (!(node instanceof HTMLElement)) return false; - const style = window.getComputedStyle(node); - if (style.display === 'none' || style.visibility === 'hidden') return false; - const rect = node.getBoundingClientRect(); - const width = node.naturalWidth || node.videoWidth || rect.width || 0; - const height = node.naturalHeight || node.videoHeight || rect.height || 0; - if (width > 32 && height > 32) return true; - const backgroundImage = style.backgroundImage || ''; - return /url\\(/.test(backgroundImage) && rect.width > 32 && rect.height > 32; - }; - const previewNodes = Array.from(scope.querySelectorAll('img[src], canvas, video, [style*="background-image"]')).filter(isVisibleMedia); - return previewNodes.length >= names.length; - })() - `)), 'chatgpt upload preview detection'); - if (ready) return true; - } - return false; -} - -export async function uploadChatGPTImages(page, imagePaths) { - const fs = await import('node:fs'); - const path = await import('node:path'); - const prepared = await prepareChatGPTImagePaths(imagePaths); - if (!prepared.ok) return prepared; - const absPaths = prepared.paths; - - const fileNames = absPaths.map(filePath => path.default.basename(filePath)); - - let uploaded = false; - if (page.setFileInput) { - try { - await page.setFileInput(absPaths, 'input[type="file"]'); - uploaded = true; - } catch (err) { - const msg = String(err?.message || err); - if (!msg.includes('Unknown action') && !msg.includes('not supported') && !msg.includes('Not allowed') && !msg.includes('No element found')) { - throw err; - } - } - } - - if (!uploaded) { - const files = absPaths.map(absPath => ({ - name: path.default.basename(absPath), - mime: imageMimeFromPath(absPath), - base64: fs.default.readFileSync(absPath).toString('base64'), - })); - const fallbackResult = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(` - (() => { - const files = ${JSON.stringify(files)}; - const input = document.querySelector('input[type="file"]'); - if (!(input instanceof HTMLInputElement)) { - return { ok: false, reason: 'file input not found' }; - } - - const dt = new DataTransfer(); - for (const item of files) { - const binary = atob(item.base64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); - dt.items.add(new File([bytes], item.name, { type: item.mime })); - } - input.files = dt.files; - - const propsKey = Object.keys(input).find(key => key.startsWith('__reactProps$')); - if (propsKey && input[propsKey] && typeof input[propsKey].onChange === 'function') { - const nativeEvent = new Event('change', { bubbles: true }); - input[propsKey].onChange({ - target: input, - currentTarget: input, - nativeEvent, - preventDefault() {}, - stopPropagation() {}, - isDefaultPrevented() { return false; }, - isPropagationStopped() { return false; }, - persist() {}, - }); - } else { - input.dispatchEvent(new Event('input', { bubbles: true })); - input.dispatchEvent(new Event('change', { bubbles: true })); - } - return { ok: true }; - })() - `)), 'chatgpt image upload fallback'); - if (fallbackResult && !fallbackResult.ok) return fallbackResult; - } - - const ready = await waitForChatGPTUploadPreview(page, fileNames); - if (!ready) return { ok: false, reason: 'image upload preview did not appear' }; - - return { ok: true, files: absPaths }; -} - -/** - * Check if ChatGPT is still generating a response. - */ -export async function isGenerating(page) { - return requireBooleanEvaluateResult(unwrapEvaluateResult(await page.evaluate(` - (() => { - if (document.querySelector('[data-testid="stop-button"]')) return true; - - const controls = Array.from(document.querySelectorAll('button, [role="button"], [aria-label]')); - for (const control of controls) { - const label = control.getAttribute('aria-label') || ''; - if (label.includes('Stop generating')) return true; - } - - const scopes = []; - const turns = document.querySelectorAll('article[data-testid*="conversation-turn"]'); - const messages = turns.length ? turns : document.querySelectorAll('[data-message-author-role]'); - if (messages.length) { - scopes.push([messages[messages.length - 1], /Thinking|Stop generating/]); - } - const composer = document.querySelector('#prompt-textarea, [aria-label="Chat with ChatGPT"]'); - if (composer) { - let root = composer; - for (let i = 0; i < 4 && root.parentElement; i += 1) root = root.parentElement; - scopes.push([root, /Stop generating/]); - } - - for (const [scope, pattern] of scopes) { - for (const el of [scope, ...scope.querySelectorAll('*')]) { - if (el.children.length) continue; - if (el.closest('.markdown, pre, code')) continue; - const text = (el.textContent || '').trim(); - if (text && text.length <= 40 && pattern.test(text)) return true; - } - } - return false; - })() - `)), 'chatgpt generation state'); -} - -/** - * Get visible image URLs from the ChatGPT page (excluding profile/avatar images). - */ -export async function getChatGPTVisibleImageUrls(page) { - return requireArrayEvaluateResult(unwrapEvaluateResult(await page.evaluate(` - (() => { - const isVisible = (el) => { - if (!(el instanceof HTMLElement)) return false; - const style = window.getComputedStyle(el); - if (style.display === 'none' || style.visibility === 'hidden') return false; - const rect = el.getBoundingClientRect(); - return rect.width > 32 && rect.height > 32; - }; - - const urls = []; - const seen = new Set(); - const normalizeUrl = (value) => { - const raw = String(value || '').trim(); - if (!raw || raw === 'none') return ''; - if (/^(?:https?:|blob:|data:)/i.test(raw)) return raw; - try { - return new URL(raw, window.location.href).href; - } catch { - return raw; - } - }; - const addUrl = (value) => { - const src = normalizeUrl(value); - if (!src || seen.has(src)) return; - seen.add(src); - urls.push(src); - }; - const isDecorative = (el, src = '') => { - const alt = (el.getAttribute('alt') || '').toLowerCase(); - const cls = String(el.className || '').toLowerCase(); - const testId = (el.getAttribute('data-testid') || '').toLowerCase(); - const label = (el.getAttribute('aria-label') || '').toLowerCase(); - const text = [alt, cls, testId, label, src.toLowerCase()].join(' '); - return /avatar|profile|logo|icon/.test(text); - }; - const isUserUploadPreview = (img) => { - const alt = (img.getAttribute('alt') || '').toLowerCase(); - const turn = img.closest('section[data-testid^="conversation-turn"]'); - const heading = (turn?.querySelector('h4')?.innerText || '').toLowerCase(); - if (/you said|You said/.test(heading)) return true; - if (/chatgpt|assistant|Assistant/.test(heading)) return false; - const openButtonLabel = (img.closest('button[aria-label^="Open image:"]')?.getAttribute('aria-label') || '').toLowerCase(); - const previewText = [alt, openButtonLabel].join(' '); - return /\.(png|jpe?g|webp|gif|heic|heif)(?:\b|$)/i.test(previewText) - || /ref-|reference|References|upload|uploaded|attachment/.test(previewText); - }; - - const imgs = Array.from(document.querySelectorAll('img')).filter(img => - img instanceof HTMLImageElement && isVisible(img) - ); - - for (const img of imgs) { - const src = img.currentSrc || img.src || ''; - const width = img.naturalWidth || img.width || 0; - const height = img.naturalHeight || img.height || 0; - - if (!src) continue; - if (isDecorative(img, src)) continue; - if (isUserUploadPreview(img)) continue; - if (width < 128 && height < 128) continue; - addUrl(src); - } - - // ChatGPT occasionally renders generated images as CSS background - // thumbnails instead of plain nodes. Treat visible, large - // background images as generated-image candidates too. - for (const el of Array.from(document.querySelectorAll('[style*="background-image"], [style*="background"]'))) { - if (!(el instanceof HTMLElement) || !isVisible(el) || isDecorative(el)) continue; - const rect = el.getBoundingClientRect(); - if (rect.width < 128 && rect.height < 128) continue; - const backgroundImage = window.getComputedStyle(el).backgroundImage || ''; - for (const match of backgroundImage.matchAll(/url\\((['"]?)(.*?)\\1\\)/g)) { - const src = match[2]; - if (!src || isDecorative(el, src)) continue; - addUrl(src); - } - } - - // Some ChatGPT image surfaces mount large transparent canvases as - // placeholders/overlays before the real backend image is ready. If - // those data URLs are accepted as generated assets, the adapter can - // save a blank transparent PNG while reporting success. Prefer real - // /background URLs; only keep a canvas if it contains at least - // one non-transparent/non-white sampled pixel. - for (const canvas of Array.from(document.querySelectorAll('canvas'))) { - if (!(canvas instanceof HTMLCanvasElement) || !isVisible(canvas) || isDecorative(canvas)) continue; - const width = canvas.width || canvas.getBoundingClientRect().width || 0; - const height = canvas.height || canvas.getBoundingClientRect().height || 0; - if (width < 128 && height < 128) continue; - try { - const ctx = canvas.getContext('2d', { willReadFrequently: true }); - if (!ctx) continue; - const sourceWidth = Math.max(1, Math.floor(canvas.width || width)); - const sourceHeight = Math.max(1, Math.floor(canvas.height || height)); - const xCount = Math.min(sourceWidth, 16); - const yCount = Math.min(sourceHeight, 16); - let hasContent = false; - for (let yi = 0; yi < yCount && !hasContent; yi += 1) { - const y = Math.min(sourceHeight - 1, Math.floor((yi + 0.5) * sourceHeight / yCount)); - for (let xi = 0; xi < xCount && !hasContent; xi += 1) { - const x = Math.min(sourceWidth - 1, Math.floor((xi + 0.5) * sourceWidth / xCount)); - const pixel = ctx.getImageData(x, y, 1, 1).data; - const r = pixel[0]; - const g = pixel[1]; - const b = pixel[2]; - const a = pixel[3]; - if (a > 0 && !(r > 248 && g > 248 && b > 248)) { - hasContent = true; - break; - } - } - } - if (hasContent) addUrl(canvas.toDataURL('image/png')); - } catch { } - } - return urls; - })() - `)), 'chatgpt visible image url extraction'); -} - -/** - * Wait for new images to appear after sending a prompt. - */ -export async function waitForChatGPTImages(page, beforeUrls, timeoutSeconds, convUrl) { - const beforeSet = new Set(beforeUrls); - const pollIntervalSeconds = 3; - const maxPolls = Math.max(1, Math.ceil(timeoutSeconds / pollIntervalSeconds)); - let lastUrls = []; - let stableCount = 0; - - for (let i = 0; i < maxPolls; i++) { - await page.sleep(i === 0 ? 3 : pollIntervalSeconds); - - let currentUrl = ''; - if (convUrl && convUrl.includes('/c/')) { - currentUrl = unwrapEvaluateResult(await page.evaluate('window.location.href').catch(() => '')); - if (currentUrl && !isSameChatGPTConversation(currentUrl, convUrl)) { - await page.goto(convUrl); - await page.wait(3); - } - } - - const generating = await isGenerating(page); - if (generating) continue; - - if (convUrl && convUrl.includes('/c/') && i > 0 && i % 5 === 0) { - const onConversation = !currentUrl || isSameChatGPTConversation(currentUrl, convUrl); - if (onConversation) { - await page.goto(convUrl); - await page.wait(3); - } - } - - const urls = (await getChatGPTVisibleImageUrls(page)).filter(url => !beforeSet.has(url)); - if (urls.length === 0) continue; - - const key = urls.join('\n'); - const prevKey = lastUrls.join('\n'); - if (key === prevKey) { - stableCount += 1; - } else { - lastUrls = urls; - stableCount = 1; - } - - if (stableCount >= 2 || i === maxPolls - 1) { - return lastUrls; - } - } - return lastUrls; -} - -/** - * Get the list of ChatGPT Projects from the sidebar. - * Navigates to chatgpt.com if not already there, opens the sidebar, - * and extracts project links (matching /g/g-p-*). - */ -export async function getProjectList(page) { - await ensureOnChatGPT(page); - - // Ensure sidebar is open - const openSidebar = requireBooleanEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => { - const button = Array.from(document.querySelectorAll('button')) - .find((node) => /open sidebar/i.test(node.getAttribute('aria-label') || '')); - if (button instanceof HTMLElement) { - button.click(); - return true; - } - return false; - })()`)), 'chatgpt sidebar open state'); - if (openSidebar) { - await page.wait(0.5); - } - - // Click "Show more" to reveal all projects - await page.evaluate(`(() => { - var btn = Array.from(document.querySelectorAll('button')).find(function(b) { - var text = (b.innerText || '').trim(); - return text === 'Show more' || text === 'Show more' || text === 'See more'; - }); - if (btn instanceof HTMLElement) { - btn.click(); - } - })()`); - await page.wait(0.5); - - let items = await extractProjectLinks(page); - if (!items.length) { - await page.goto(CHATGPT_URL, { settleMs: 2000 }); - await page.wait(1); - // Try clicking Show more again on fresh page - await page.evaluate(`(() => { - var btn = Array.from(document.querySelectorAll('button')).find(function(b) { - var text = (b.innerText || '').trim(); - return text === 'Show more' || text === 'Show more' || text === 'See more'; - }); - if (btn instanceof HTMLElement) { - btn.click(); - } - })()`); - await page.wait(0.5); - items = await extractProjectLinks(page); - } - - return items; -} - -async function extractProjectLinks(page) { - const items = requireArrayEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => { - const projectLinkSelector = ${JSON.stringify(PROJECT_LINK_SELECTOR)}; - const isVisible = (el) => { - if (!(el instanceof HTMLElement)) return false; - const style = window.getComputedStyle(el); - if (style.display === 'none' || style.visibility === 'hidden') return false; - const rect = el.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; - }; - const cleanText = (value) => String(value || '').replace(new RegExp('\\\\s+', 'g'), ' ').trim(); - const trustedHost = (hostname) => hostname === '${CHATGPT_DOMAIN}' || hostname.endsWith('.${CHATGPT_DOMAIN}'); - const projectIdFromPathname = (pathname) => { - const match = String(pathname || '').match(new RegExp('^/g/g-p-([a-f0-9]{8,})(?:[-/]|$)', 'i')); - return match ? match[1].toLowerCase() : ''; - }; - const parseProjectId = (value) => { - const raw = String(value || '').trim(); - if (new RegExp('^https?://', 'i').test(raw) || raw.startsWith('/')) { - try { - const url = new URL(raw, '${CHATGPT_URL}'); - if (url.protocol !== 'https:' || !trustedHost(url.hostname)) return ''; - return projectIdFromPathname(url.pathname); - } catch { - return ''; - } - } - const slugMatch = raw.match(new RegExp('^g-p-([a-f0-9]{8,})', 'i')); - if (slugMatch) return slugMatch[1].toLowerCase(); - if (new RegExp('^[a-f0-9]{8,}$', 'i').test(raw)) return raw.toLowerCase(); - return ''; - }; - const normalizeProjectUrl = (href, projectId) => { - try { - const url = new URL(href, '${CHATGPT_URL}'); - if (url.protocol !== 'https:' || !trustedHost(url.hostname)) return ''; - if (projectIdFromPathname(url.pathname) !== projectId) return ''; - url.search = ''; - url.hash = ''; - return url.href.endsWith('/') ? url.href.slice(0, -1) : url.href; - } catch { - return '${CHATGPT_URL}' + '/g/g-p-' + projectId; - } - }; - - var seen = new Set(); - var rows = []; - const addRow = (projectId, title, url) => { - if (!projectId || seen.has(projectId)) return; - seen.add(projectId); - rows.push({ - Id: projectId, - Title: cleanText(title) || '(untitled project)', - Url: url || ('${CHATGPT_URL}' + '/g/g-p-' + projectId), - }); - }; - - // Prefer explicit project anchors when the sidebar exposes them. This is - // stable across React internals and matches the URL shape documented by - // PROJECT_LINK_SELECTOR. - for (const link of Array.from(document.querySelectorAll(projectLinkSelector))) { - if (!isVisible(link)) continue; - const href = link.getAttribute('href') || link.href || ''; - const projectId = parseProjectId(href); - if (!projectId) continue; - const container = link.closest('[data-sidebar-item="true"]') || link; - addRow(projectId, cleanText(container.innerText || container.textContent || link.textContent), normalizeProjectUrl(href, projectId)); - } - - // Fallback for ChatGPT sidebar builds that render project rows without - // anchors but keep gizmo data on React Fiber props. - const projectEls = Array.from(document.querySelectorAll('[data-sidebar-item="true"]')) - .filter(function(el) { - if (!isVisible(el)) return false; - var icon = el.querySelector('[data-testid="project-folder-icon"]'); - if (!icon) return false; - var text = cleanText(el.innerText || el.textContent); - if (!text) return false; - if (el.getAttribute('data-testid') === 'accounts-profile-button') return false; - return true; - }); - - for (var i = 0; i < projectEls.length; i++) { - var el = projectEls[i]; - var title = cleanText(el.innerText || el.textContent); - var projectId = ''; - var shortUrl = ''; - var fiberKey = Object.keys(el).find(function(k) { return k.startsWith('__reactFiber$'); }); - if (fiberKey) { - var f = el[fiberKey]; - for (var d = 0; f && d < 15; d++) { - var props = f.memoizedProps || f.pendingProps; - if (props && props.gizmo) { - var g = props.gizmo; - var gId = g.gizmo && g.gizmo.id ? g.gizmo.id : g.id; - var gIdMatch = String(gId || '').match(new RegExp('^g-p-([a-f0-9]{8,})(?:-|$)', 'i')); - if (gIdMatch) { - projectId = gIdMatch[1].toLowerCase(); - shortUrl = String(g.short_url || g.gizmo && g.gizmo.short_url || ''); - break; - } - } - f = f.return; - } - } - if (!projectId) continue; - var url = shortUrl ? '${CHATGPT_URL}' + '/g/' + shortUrl : '${CHATGPT_URL}' + '/g/g-p-' + projectId; - addRow(projectId, title, url); - } - - return rows; - })()`)), 'chatgpt project link extraction'); - return items.map(function(item, index) { - return { - Index: index + 1, - Id: String(item?.Id || ''), - Title: String(item?.Title || '(untitled project)').trim() || '(untitled project)', - Url: String(item?.Url || ''), - }; - }).filter(function(item) { return item.Id; }); -} - -/** - * Navigate to a ChatGPT project page. - */ -const PROJECT_ADD_FILES_LABELS = [ - 'Add files', - 'Add sources', - 'Add file', - 'Project files', - 'Project files', -]; - -const PROJECT_ADD_FILES_DIALOG_SELECTORS = [ - '[role="tabpanel"][data-state="active"] [data-project-home-sources-surface="true"] input[type="file"]:not([accept])', - '[data-project-home-sources-surface="true"] input[type="file"]:not([accept])', - '[role="dialog"] input[type="file"]', - '[data-testid*="project-files"] input[type="file"]', - '[data-testid*="project"] input[type="file"]', -]; - -/** - * Navigate to a ChatGPT project page. - */ -export async function navigateToProject(page, projectId) { - const id = parseChatGPTProjectId(projectId); - await page.goto(`${CHATGPT_URL}/g/g-p-${id}`, { settleMs: 2000 }); - try { - await page.wait({ selector: COMPOSER_WAIT_SELECTOR, timeout: 10 }); - } catch { - // Composer may not mount if project requires login; downstream ensureChatGPTLogin handles it. - } - const state = await getPageState(page); - if (projectIdFromUrl(state.url) === id) return id; - if (state.hasLoginGate || !state.isLoggedIn) { - throw new AuthRequiredError(CHATGPT_DOMAIN, 'ChatGPT project requires a logged-in ChatGPT session.'); - } - throw new CommandExecutionError( - `ChatGPT did not open the requested project ${id}.`, - `Current URL: ${state.url || '(unknown)'}`, - ); -} - -/** - * Open the Project knowledge files dialog by clicking the "Add files" button - * in the project header area (NOT the chat composer's plus button). - * Returns true if the dialog appeared. - */ -export async function openProjectKnowledgeDialog(page) { - const rawOpenResult = unwrapEvaluateResult(await page.evaluate(` - (() => { - const labels = ${JSON.stringify(PROJECT_ADD_FILES_LABELS)}; - const isVisible = (el) => { - if (!(el instanceof HTMLElement)) return false; - const style = window.getComputedStyle(el); - if (style.display === 'none' || style.visibility === 'hidden') return false; - const rect = el.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; - }; - - // Current ChatGPT project pages expose project knowledge under a - // Sources tab. Prefer that surface when present; it contains the - // project-source file input and avoids the chat composer's plus menu. - const sourceInput = document.querySelector('[data-project-home-sources-surface="true"] input[type="file"]:not([accept])'); - if (sourceInput instanceof HTMLInputElement) return { ok: true }; - - const sourcesTab = Array.from(document.querySelectorAll('[role="tab"], button')).find(el => { - const text = (el.innerText || el.textContent || '').trim(); - const id = el.id || ''; - return text === 'Sources' || text === 'Sources' || id.includes('-sources'); - }); - if (sourcesTab instanceof HTMLElement) { - if (sourcesTab.getAttribute('aria-selected') === 'true') return { ok: true }; - const rect = sourcesTab.getBoundingClientRect(); - const centerX = rect.left + rect.width / 2; - const centerY = rect.top + rect.height / 2; - const nativeClick = rect.width > 0 && rect.height > 0 && Number.isFinite(centerX) && Number.isFinite(centerY) ? { - x: centerX, - y: centerY, - } : null; - - // Radix-powered tabs on the live ChatGPT project page do not - // respond reliably to HTMLElement.click(); they activate after - // the same pointer/mouse sequence a real browser click emits. - const eventInit = { - bubbles: true, - cancelable: true, - composed: true, - view: window, - clientX: nativeClick ? nativeClick.x : 0, - clientY: nativeClick ? nativeClick.y : 0, - button: 0, - buttons: 1, - }; - for (const type of ['pointerover', 'pointerenter', 'mouseover', 'mouseenter', 'pointermove', 'mousemove', 'pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) { - const Ctor = type.startsWith('pointer') && typeof PointerEvent !== 'undefined' ? PointerEvent : MouseEvent; - sourcesTab.dispatchEvent(new Ctor(type, eventInit)); - } - return { ok: true, nativeClick }; - } - - // Older project pages opened a dedicated project files dialog. - // Strategy 1: aria-label or data-testid - const byAttr = Array.from(document.querySelectorAll('button, a, [role="button"]')).find(el => { - if (!isVisible(el)) return false; - if (el.closest('[role="textbox"], #prompt-textarea, [data-testid="composer"], form[data-type="unified-composer"]')) return false; - const aria = (el.getAttribute('aria-label') || '').toLowerCase(); - const testid = (el.getAttribute('data-testid') || '').toLowerCase(); - const text = (el.innerText || el.textContent || '').trim(); - if (aria.includes('add sources') || aria.includes('project files')) return true; - if (aria === 'add files') return true; - if (testid.includes('add-files') || testid.includes('project-files')) return true; - if (labels.some(l => text === l)) return true; - return false; - }); - if (byAttr instanceof HTMLElement) { byAttr.click(); return { ok: true }; } - - // Strategy 2: look for buttons that contain "Add files"/"Add sources" - // text, but exclude the composer plus button (which has a different role). - const allButtons = Array.from(document.querySelectorAll('button')); - for (const btn of allButtons) { - if (!isVisible(btn)) continue; - const text = (btn.innerText || btn.textContent || '').trim(); - if (labels.some(l => text === l) && !btn.closest('[role="textbox"], #prompt-textarea, [data-testid="composer"]')) { - btn.click(); - return { ok: true }; - } - } - - return { ok: false }; - })() - `)); - const openResult = typeof rawOpenResult === 'boolean' - ? { ok: rawOpenResult } - : requireObjectEvaluateResult(rawOpenResult, 'chatgpt project knowledge dialog open'); - - if (openResult.ok) { - if (openResult.nativeClick && typeof page.nativeClick === 'function') { - try { await page.nativeClick(openResult.nativeClick.x, openResult.nativeClick.y); } catch {} - } - if (openResult.nativeClick && typeof page.click === 'function') { - try { await page.click('[role="tab"][id$="-sources"]'); } catch {} - } - // Wait for the dialog or Sources tab content to appear - await page.wait(1); - try { - await page.wait({ selector: '[role="dialog"], [data-project-home-sources-surface="true"] input[type="file"]', timeout: 5 }); - } catch { - // Dialog/source input may use a different shape; upload selectors surface the precise failure. - } - return true; - } - return false; -} - -/** - * Upload files to a ChatGPT Project's knowledge base. - * This navigates to the project page, opens the knowledge files dialog, - * and uploads files through the dialog's file input. - */ -export async function uploadChatGPTProjectFiles(page, projectId, filePaths) { - const id = parseChatGPTProjectId(projectId); - const fs = await import('node:fs'); - const path = await import('node:path'); - - const prepared = await prepareChatGPTFilePaths(filePaths); - if (!prepared.ok) return { ...prepared, inputError: true }; - const absPaths = prepared.paths; - - // Navigate to project and open knowledge dialog - await navigateToProject(page, id); - await ensureChatGPTLogin(page, 'ChatGPT project file upload requires a logged-in ChatGPT session.'); - - const dialogOpened = await openProjectKnowledgeDialog(page); - if (!dialogOpened) { - return { ok: false, reason: 'could not find or click the project "Add files" button' }; - } - - // Try uploading via dialog file input (multiple selector patterns) - const fileNames = absPaths.map(fp => path.default.basename(fp)); - - let uploaded = false; - if (page.setFileInput) { - for (const selector of PROJECT_ADD_FILES_DIALOG_SELECTORS) { - try { - await page.setFileInput(absPaths, selector); - uploaded = true; - break; - } catch (err) { - const msg = String(err?.message || err); - if (!msg.includes('Unknown action') && !msg.includes('not supported') && !msg.includes('Not allowed') && !msg.includes('No element found')) { - throw err; - } - } - } - } - - if (!uploaded) { - // Fallback: try all dialog file inputs via evaluate - const files = absPaths.map(absPath => ({ - name: path.default.basename(absPath), - mime: mimeFromFilePath(absPath), - base64: fs.default.readFileSync(absPath).toString('base64'), - })); - const fallbackResult = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(` - (() => { - const files = ${JSON.stringify(files)}; - - // Look for file input inside a dialog or the project area - const selectors = ${JSON.stringify(PROJECT_ADD_FILES_DIALOG_SELECTORS)}; - let input = null; - for (const sel of selectors) { - input = document.querySelector(sel); - if (input instanceof HTMLInputElement) break; - } - // Last resort: stay scoped to project knowledge containers. Do - // not fall back to arbitrary page inputs, because the composer - // attachment input can also accept files but uploads them to - // the conversation instead of project knowledge. - if (!(input instanceof HTMLInputElement)) { - const allFileInputs = document.querySelectorAll('[data-project-home-sources-surface="true"] input[type="file"], [role="dialog"] input[type="file"], [data-testid*="project"] input[type="file"]'); - for (const fi of allFileInputs) { - input = fi; - break; - } - } - if (!(input instanceof HTMLInputElement)) { - return { ok: false, reason: 'project file input not found' }; - } - - const dt = new DataTransfer(); - for (const item of files) { - const binary = atob(item.base64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); - dt.items.add(new File([bytes], item.name, { type: item.mime })); - } - input.files = dt.files; - - const propsKey = Object.keys(input).find(key => key.startsWith('__reactProps$')); - if (propsKey && input[propsKey] && typeof input[propsKey].onChange === 'function') { - const nativeEvent = new Event('change', { bubbles: true }); - input[propsKey].onChange({ - target: input, - currentTarget: input, - nativeEvent, - preventDefault() {}, - stopPropagation() {}, - isDefaultPrevented() { return false; }, - isPropagationStopped() { return false; }, - persist() {}, - }); - } else { - input.dispatchEvent(new Event('input', { bubbles: true })); - input.dispatchEvent(new Event('change', { bubbles: true })); - } - return { ok: true }; - })() - `)), 'chatgpt project file upload fallback'); - if (fallbackResult && !fallbackResult.ok) return fallbackResult; - } - - const confirmation = await waitForChatGPTProjectUploadConfirmation(page, fileNames); - if (!confirmation.ok) return confirmation; - - return { ok: true, files: absPaths }; -} - -async function waitForChatGPTProjectUploadConfirmation(page, fileNames) { - const expectedFileNames = fileNames.map(name => String(name || '').trim()).filter(Boolean); - if (!expectedFileNames.length) return { ok: true }; - - let lastReason = 'uploaded file did not appear in project knowledge'; - for (let attempt = 0; attempt < 10; attempt += 1) { - const result = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(` - (() => { - const expectedFileNames = ${JSON.stringify(expectedFileNames)}; - const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim(); - const root = document.querySelector('[role="dialog"]') - || document.querySelector('[data-project-home-sources-surface="true"]') - || document.querySelector('[role="tabpanel"][data-state="active"]'); - if (!root) { - return { ok: false, pending: true, reason: 'project knowledge surface was not visible after upload' }; - } - const text = normalize(root?.innerText || root?.textContent || ''); - const errorNode = Array.from((root || document).querySelectorAll('[role="alert"], [data-testid*="error"], [class*="error"]')).find((node) => { - const label = normalize(node.innerText || node.textContent || node.getAttribute('aria-label') || ''); - return /failed|error|unable|could not|too large|unsupported/i.test(label); - }); - if (errorNode) { - return { ok: false, reason: normalize(errorNode.innerText || errorNode.textContent || errorNode.getAttribute('aria-label') || 'project upload failed') }; - } - const missing = expectedFileNames.filter((name) => !text.includes(name)); - if (!missing.length) return { ok: true }; - return { ok: false, pending: true, reason: 'uploaded file did not appear in project knowledge: ' + missing.join(', ') }; - })() - `)), 'chatgpt project upload confirmation'); - - if (result.ok === true) return { ok: true }; - lastReason = String(result.reason || lastReason); - if (!result.pending) return { ok: false, reason: lastReason }; - await page.wait(0.5); - } - - return { ok: false, reason: lastReason }; -} - -/** - * Validate local file paths for project file upload. - * Accepts all file types with a 512 MB per-file limit (matching ChatGPT's project limit). - */ -export async function prepareChatGPTFilePaths(filePaths) { - const fs = await import('node:fs'); - const path = await import('node:path'); - const absPaths = filePaths.map(filePath => path.default.resolve(filePath)); - - for (const absPath of absPaths) { - if (!fs.default.existsSync(absPath)) { - return { ok: false, reason: `File not found: ${absPath}` }; - } - const stat = fs.default.statSync(absPath); - if (!stat.isFile()) { - return { ok: false, reason: `Not a file: ${absPath}` }; - } - if (stat.size > 512 * 1024 * 1024) { - return { ok: false, reason: `File too large (${(stat.size / 1024 / 1024).toFixed(1)} MB). Max: 512 MB` }; - } - } - - return { ok: true, paths: absPaths }; -} - -function mimeFromFilePath(filePath) { - const lower = String(filePath || '').toLowerCase(); - if (lower.endsWith('.pdf')) return 'application/pdf'; - if (lower.endsWith('.doc')) return 'application/msword'; - if (lower.endsWith('.docx')) return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; - if (lower.endsWith('.xls')) return 'application/vnd.ms-excel'; - if (lower.endsWith('.xlsx')) return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; - if (lower.endsWith('.ppt')) return 'application/vnd.ms-powerpoint'; - if (lower.endsWith('.pptx')) return 'application/vnd.openxmlformats-officedocument.presentationml.presentation'; - if (lower.endsWith('.csv')) return 'text/csv'; - if (lower.endsWith('.txt')) return 'text/plain'; - if (lower.endsWith('.json')) return 'application/json'; - if (lower.endsWith('.xml')) return 'application/xml'; - if (lower.endsWith('.html') || lower.endsWith('.htm')) return 'text/html'; - if (lower.endsWith('.md')) return 'text/markdown'; - if (lower.endsWith('.py')) return 'text/x-python'; - if (lower.endsWith('.js')) return 'text/javascript'; - if (lower.endsWith('.ts')) return 'application/typescript'; - if (lower.endsWith('.jsx')) return 'text/jsx'; - if (lower.endsWith('.tsx')) return 'text/tsx'; - if (lower.endsWith('.png')) return 'image/png'; - if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg'; - if (lower.endsWith('.gif')) return 'image/gif'; - if (lower.endsWith('.webp')) return 'image/webp'; - if (lower.endsWith('.svg')) return 'image/svg+xml'; - return 'application/octet-stream'; -} - -export const __test__ = { - COMPOSER_SELECTORS, - SEND_BUTTON_SELECTOR, - SEND_BUTTON_FALLBACK_SELECTORS, - SEND_BUTTON_LABELS, - CLOSE_SIDEBAR_LABELS, - buildComposerLocatorScript, - isSameChatGPTConversation, - parseChatGPTConversationId, - parseChatGPTProjectId, - extractDeepResearchFromConversationPayload, - extractDeepResearchFromNetworkEntries, - extractDeepResearchFromWidgetState, - looksLikeDeepResearchReport, - imageMimeFromPath, - mimeFromFilePath, - PROJECT_LINK_SELECTOR, -}; - -/** - * Export images by URL: fetch from ChatGPT backend API and convert to base64 data URLs. - */ -export async function getChatGPTImageAssets(page, urls) { - const urlsJson = JSON.stringify(urls); - return requireArrayEvaluateResult(unwrapEvaluateResult(await page.evaluate(` - (async (targetUrls) => { - const blobToDataUrl = (blob) => new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onloadend = () => resolve(String(reader.result || '')); - reader.onerror = () => reject(new Error('Failed to read blob')); - reader.readAsDataURL(blob); - }); - - const inferMime = (value, fallbackUrl) => { - if (value) return value; - const lower = String(fallbackUrl || '').toLowerCase(); - if (lower.includes('.png')) return 'image/png'; - if (lower.includes('.webp')) return 'image/webp'; - if (lower.includes('.gif')) return 'image/gif'; - return 'image/jpeg'; - }; - - const results = []; - - for (const targetUrl of targetUrls) { - let dataUrl = ''; - let mimeType = 'image/jpeg'; - let width = 0; - let height = 0; - - // Try to find the img element for size info - const img = Array.from(document.querySelectorAll('img')).find(el => - (el.currentSrc || el.src || '') === targetUrl - ); - if (img) { - width = img.naturalWidth || img.width || 0; - height = img.naturalHeight || img.height || 0; - } else { - const backgroundEl = Array.from(document.querySelectorAll('[style*="background-image"], [style*="background"]')).find(el => { - if (!(el instanceof HTMLElement)) return false; - const backgroundImage = window.getComputedStyle(el).backgroundImage || ''; - return Array.from(backgroundImage.matchAll(/url\\((['"]?)(.*?)\\1\\)/g)).some(match => { - const raw = String(match[2] || '').trim(); - if (!raw) return false; - if (raw === targetUrl) return true; - try { - return new URL(raw, window.location.href).href === targetUrl; - } catch { - return false; - } - }); - }); - if (backgroundEl) { - const rect = backgroundEl.getBoundingClientRect(); - width = Math.round(rect.width || 0); - height = Math.round(rect.height || 0); - } - } - - try { - if (String(targetUrl).startsWith('data:')) { - dataUrl = String(targetUrl); - mimeType = (String(targetUrl).match(/^data:([^;]+);/i) || [])[1] || 'image/png'; - } else { - // Try to fetch via CORS from the page's origin - const res = await fetch(targetUrl, { credentials: 'include' }); - if (res.ok) { - const blob = await res.blob(); - mimeType = inferMime(blob.type, targetUrl); - dataUrl = await blobToDataUrl(blob); - } - } - } catch (e) { - // If fetch fails (CORS), try canvas approach via img element - } - - // Fallback: draw img to canvas - if (!dataUrl && img && img instanceof HTMLImageElement) { - try { - const canvas = document.createElement('canvas'); - canvas.width = img.naturalWidth || img.width || 512; - canvas.height = img.naturalHeight || img.height || 512; - const ctx = canvas.getContext('2d'); - if (ctx) { - ctx.drawImage(img, 0, 0); - dataUrl = canvas.toDataURL('image/png'); - mimeType = 'image/png'; - } - } catch (e) { } - } - - if (dataUrl) { - results.push({ url: String(targetUrl), dataUrl, mimeType, width, height }); - } - } - - return results; - })(${urlsJson}) - `)), 'chatgpt image asset export'); -} diff --git a/plugins/chatgpt/webcmd-plugin.json b/plugins/chatgpt/webcmd-plugin.json deleted file mode 100644 index d19eea66..00000000 --- a/plugins/chatgpt/webcmd-plugin.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "chatgpt", - "version": "0.1.0", - "description": "Webcmd commands for chatgpt", - "webcmd": ">=0.5.3", - "author": { - "name": "WebCMD Agent", - "handle": "agentrhq" - } -} diff --git a/plugins/chatwise/README.md b/plugins/chatwise/README.md deleted file mode 100644 index d7d327f3..00000000 --- a/plugins/chatwise/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# webcmd-plugin-chatwise - -Webcmd commands for chatwise. - -## Install - -```bash -webcmd plugin install github:agentrhq/webcmd/chatwise -``` - -## Commands - -| Command | Description | -| --- | --- | -| `webcmd chatwise ask` | Send a prompt and wait for the AI response (send + wait + read) | -| `webcmd chatwise export` | Export the current ChatWise conversation to a Markdown file | -| `webcmd chatwise history` | List conversation history in ChatWise sidebar | -| `webcmd chatwise model` | Get or switch the active AI model in ChatWise | -| `webcmd chatwise new` | Start a new ChatWise conversation session | -| `webcmd chatwise read` | Read the current ChatWise conversation history | -| `webcmd chatwise screenshot` | Capture a snapshot of the current ChatWise window (DOM + Accessibility tree) | -| `webcmd chatwise send` | Send a message to the active ChatWise conversation | -| `webcmd chatwise status` | Check active CDP connection to ChatWise Desktop | diff --git a/plugins/chatwise/ask.js b/plugins/chatwise/ask.js deleted file mode 100644 index 1b389eaa..00000000 --- a/plugins/chatwise/ask.js +++ /dev/null @@ -1,54 +0,0 @@ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { selectorError, TimeoutError } from '@agentrhq/webcmd/errors'; -import { - buildChatwiseInjectTextJs, - buildChatwiseMessageCountJs, - buildChatwiseResponseAfterJs, - requirePositiveTimeout, -} from './utils.js'; -export const askCommand = cli({ - site: 'chatwise', - name: 'ask', - access: 'write', - description: 'Send a prompt and wait for the AI response (send + wait + read)', - domain: 'localhost', - strategy: Strategy.UI, - browser: true, - args: [ - { name: 'text', required: true, positional: true, help: 'Prompt to send' }, - { name: 'timeout', type: 'int', required: false, help: 'Max seconds to wait (default: 30)', default: 30 }, - ], - columns: ['Role', 'Text'], - func: async (page, kwargs) => { - const text = kwargs.text; - const timeout = requirePositiveTimeout(kwargs.timeout); - // Snapshot content length - const beforeLen = await page.evaluate(buildChatwiseMessageCountJs()); - // Send message - const injected = await page.evaluate(buildChatwiseInjectTextJs(text)); - if (!injected) - throw selectorError('ChatWise input element'); - await page.wait(0.5); - await page.pressKey('Enter'); - // Poll for response - const pollInterval = 2; - const maxPolls = Math.ceil(timeout / pollInterval); - let response = ''; - for (let i = 0; i < maxPolls; i++) { - await page.wait(pollInterval); - const result = await page.evaluate(buildChatwiseResponseAfterJs(beforeLen, text)); - if (result) { - const next = String(result).trim(); - if (next === response) break; - response = next; - } - } - if (!response) { - throw new TimeoutError('ChatWise response', timeout, 'Confirm ChatWise is done generating, then retry with a larger --timeout if needed.'); - } - return [ - { Role: 'User', Text: text }, - { Role: 'Assistant', Text: response }, - ]; - }, -}); diff --git a/plugins/chatwise/export.js b/plugins/chatwise/export.js deleted file mode 100644 index 54577b51..00000000 --- a/plugins/chatwise/export.js +++ /dev/null @@ -1,47 +0,0 @@ -import * as fs from 'node:fs'; -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -export const exportCommand = cli({ - site: 'chatwise', - name: 'export', - access: 'read', - description: 'Export the current ChatWise conversation to a Markdown file', - domain: 'localhost', - strategy: Strategy.UI, - browser: true, - args: [ - { name: 'output', required: false, help: 'Output file (default: /tmp/chatwise-export.md)' }, - ], - columns: ['Status', 'File', 'Messages'], - func: async (page, kwargs) => { - const outputPath = kwargs.output || '/tmp/chatwise-export.md'; - const md = await page.evaluate(` - (function() { - const selectors = [ - '[data-message-id]', - '[class*="message"]', - '[class*="chat-item"]', - '[class*="bubble"]', - ]; - - for (const sel of selectors) { - const nodes = document.querySelectorAll(sel); - if (nodes.length > 0) { - return Array.from(nodes).map((n, i) => '## Message ' + (i + 1) + '\\n\\n' + (n.innerText || n.textContent).trim()).join('\\n\\n---\\n\\n'); - } - } - - const main = document.querySelector('main, [role="main"], [class*="chat-container"]'); - if (main) return main.innerText || main.textContent; - return document.body.innerText; - })() - `); - fs.writeFileSync(outputPath, '# ChatWise Conversation Export\\n\\n' + md); - return [ - { - Status: 'Success', - File: outputPath, - Messages: md.split('## Message').length - 1, - }, - ]; - }, -}); diff --git a/plugins/chatwise/history.js b/plugins/chatwise/history.js deleted file mode 100644 index 41cd459c..00000000 --- a/plugins/chatwise/history.js +++ /dev/null @@ -1,61 +0,0 @@ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -export const historyCommand = cli({ - site: 'chatwise', - name: 'history', - access: 'read', - description: 'List conversation history in ChatWise sidebar', - domain: 'localhost', - strategy: Strategy.UI, - browser: true, - args: [], - columns: ['Index', 'Title'], - func: async (page) => { - const items = await page.evaluate(` - (function() { - const results = []; - const selectors = [ - '[class*="sidebar"] [class*="item"]', - '[class*="conversation-list"] a', - '[class*="chat-list"] > *', - 'nav a', - 'aside a', - '[role="listbox"] [role="option"]', - ]; - - for (const sel of selectors) { - const nodes = document.querySelectorAll(sel); - if (nodes.length > 0) { - nodes.forEach((n, i) => { - const text = (n.textContent || '').trim().substring(0, 100); - if (text) results.push({ Index: i + 1, Title: text }); - }); - break; - } - } - - return results; - })() - `); - if (items.length === 0) { - return [{ Index: 0, Title: 'No history found. Ensure the sidebar is visible.' }]; - } - const dateHeaders = /^(today|yesterday|last week|last month|last year|this week|this month|older|previous \d+ days|\d+ days ago)$/i; - const numericOnly = /^[\d\s]+$/; - const modelPath = /^[\w.-]+\/[\w.-]/; - const seen = new Set(); - const deduped = items.filter((item) => { - const t = item.Title.trim(); - if (dateHeaders.test(t)) - return false; - if (numericOnly.test(t)) - return false; - if (modelPath.test(t)) - return false; - if (seen.has(t)) - return false; - seen.add(t); - return true; - }).map((item, i) => ({ Index: i + 1, Title: item.Title })); - return deduped; - }, -}); diff --git a/plugins/chatwise/model.js b/plugins/chatwise/model.js deleted file mode 100644 index 5e2c73b7..00000000 --- a/plugins/chatwise/model.js +++ /dev/null @@ -1,85 +0,0 @@ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { selectorError } from '@agentrhq/webcmd/errors'; -export const modelCommand = cli({ - site: 'chatwise', - name: 'model', - access: 'read', - description: 'Get or switch the active AI model in ChatWise', - domain: 'localhost', - strategy: Strategy.UI, - browser: true, - args: [ - { name: 'model-name', required: false, positional: true, help: 'Model to switch to (e.g. gpt-4, claude-3)' }, - ], - columns: ['Status', 'Model'], - func: async (page, kwargs) => { - const desiredModel = kwargs['model-name']; - if (!desiredModel) { - // Read current model - const currentModel = await page.evaluate(` - (function() { - // ChatWise is a multi-LLM client, it typically shows the model name in a dropdown or header - const selectors = [ - '[class*="model"] span', - '[class*="Model"] span', - '[data-testid*="model"]', - 'button[class*="model"]', - '[aria-label*="Model"]', - '[aria-label*="model"]', - ]; - - for (const sel of selectors) { - const el = document.querySelector(sel); - if (el) { - const text = (el.textContent || el.getAttribute('title') || '').trim(); - if (text) return text; - } - } - - return 'Unknown or Not Found'; - })() - `); - return [{ Status: 'Active', Model: currentModel }]; - } - else { - // Try to switch model - const opened = await page.evaluate(` - (function(target) { - const selectors = [ - '[class*="model"]', - '[class*="Model"]', - 'button[class*="model"]', - ]; - - for (const sel of selectors) { - const el = document.querySelector(sel); - if (el) { el.click(); return true; } - } - return false; - })(${JSON.stringify(desiredModel)}) - `); - if (!opened) - throw selectorError('ChatWise model selector'); - await page.wait(0.5); - // Find and click the target model in the dropdown - const found = await page.evaluate(` - (function(target) { - const options = document.querySelectorAll('[role="option"], [role="menuitem"], [class*="dropdown-item"], li'); - for (const opt of options) { - if ((opt.textContent || '').toLowerCase().includes(target.toLowerCase())) { - opt.click(); - return true; - } - } - return false; - })(${JSON.stringify(desiredModel)}) - `); - return [ - { - Status: found ? 'Switched' : 'Dropdown opened but model not found', - Model: desiredModel, - }, - ]; - } - }, -}); diff --git a/plugins/chatwise/new.js b/plugins/chatwise/new.js deleted file mode 100644 index 6314abfd..00000000 --- a/plugins/chatwise/new.js +++ /dev/null @@ -1,2 +0,0 @@ -import { makeNewCommand } from '@agentrhq/webcmd/plugin-runtime'; -export const newCommand = makeNewCommand('chatwise', 'ChatWise conversation'); diff --git a/plugins/chatwise/package.json b/plugins/chatwise/package.json deleted file mode 100644 index d67deaf3..00000000 --- a/plugins/chatwise/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "webcmd-plugin-chatwise", - "version": "0.1.0", - "type": "module", - "description": "Webcmd commands for chatwise", - "peerDependencies": { - "@agentrhq/webcmd": ">=0.5.3" - } -} diff --git a/plugins/chatwise/read.js b/plugins/chatwise/read.js deleted file mode 100644 index 81b0ed16..00000000 --- a/plugins/chatwise/read.js +++ /dev/null @@ -1,40 +0,0 @@ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -export const readCommand = cli({ - site: 'chatwise', - name: 'read', - access: 'read', - description: 'Read the current ChatWise conversation history', - domain: 'localhost', - strategy: Strategy.UI, - browser: true, - args: [], - columns: ['Content'], - func: async (page) => { - const content = await page.evaluate(` - (function() { - // Try common chat message selectors - const selectors = [ - '[data-message-id]', - '[class*="message"]', - '[class*="chat-item"]', - '[class*="bubble"]', - '[role="log"] > *', - ]; - - for (const sel of selectors) { - const nodes = document.querySelectorAll(sel); - if (nodes.length > 0) { - return Array.from(nodes).map(n => (n.innerText || n.textContent).trim()).filter(Boolean).join('\\n\\n---\\n\\n'); - } - } - - // Fallback: main content area - const main = document.querySelector('main, [role="main"], [class*="chat-container"], [class*="conversation"]'); - if (main) return main.innerText || main.textContent; - - return document.body.innerText; - })() - `); - return [{ Content: content }]; - }, -}); diff --git a/plugins/chatwise/screenshot.js b/plugins/chatwise/screenshot.js deleted file mode 100644 index 6ed17129..00000000 --- a/plugins/chatwise/screenshot.js +++ /dev/null @@ -1,2 +0,0 @@ -import { makeScreenshotCommand } from '@agentrhq/webcmd/plugin-runtime'; -export const screenshotCommand = makeScreenshotCommand('chatwise', 'ChatWise'); diff --git a/plugins/chatwise/send.js b/plugins/chatwise/send.js deleted file mode 100644 index 4cd2fe21..00000000 --- a/plugins/chatwise/send.js +++ /dev/null @@ -1,28 +0,0 @@ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { selectorError } from '@agentrhq/webcmd/errors'; -import { buildChatwiseInjectTextJs } from './utils.js'; -export const sendCommand = cli({ - site: 'chatwise', - name: 'send', - access: 'write', - description: 'Send a message to the active ChatWise conversation', - domain: 'localhost', - strategy: Strategy.UI, - browser: true, - args: [{ name: 'text', required: true, positional: true, help: 'Message to send' }], - columns: ['Status', 'InjectedText'], - func: async (page, kwargs) => { - const text = kwargs.text; - const injected = await page.evaluate(buildChatwiseInjectTextJs(text)); - if (!injected) - throw selectorError('ChatWise input element'); - await page.wait(0.5); - await page.pressKey('Enter'); - return [ - { - Status: 'Success', - InjectedText: text, - }, - ]; - }, -}); diff --git a/plugins/chatwise/status.js b/plugins/chatwise/status.js deleted file mode 100644 index a96b3659..00000000 --- a/plugins/chatwise/status.js +++ /dev/null @@ -1,2 +0,0 @@ -import { makeStatusCommand } from '@agentrhq/webcmd/plugin-runtime'; -export const statusCommand = makeStatusCommand('chatwise', 'ChatWise Desktop'); diff --git a/plugins/chatwise/test/composer.test.js b/plugins/chatwise/test/composer.test.js deleted file mode 100644 index f02fff2e..00000000 --- a/plugins/chatwise/test/composer.test.js +++ /dev/null @@ -1,186 +0,0 @@ -import { JSDOM } from 'jsdom'; -import { describe, expect, it, vi } from 'vitest'; -import { ArgumentError, TimeoutError } from '@agentrhq/webcmd/errors'; -import { - buildChatwiseInjectTextJs, - buildChatwiseMessageCountJs, - buildChatwiseResponseAfterJs, - requirePositiveTimeout, - scoreChatwiseComposerCandidate, - selectBestChatwiseComposer, -} from '../utils.js'; -import { askCommand } from '../ask.js'; - -function candidate(overrides = {}) { - return { - index: 0, - hidden: false, - role: 'textbox', - classes: 'cm-content cm-lineWrapping', - editorClasses: 'cm-editor', - ariaLabel: '', - placeholder: '', - text: '', - rect: { y: 0, h: 30 }, - ...overrides, - }; -} - -function runBrowserScript(html, script) { - const dom = new JSDOM(html, { url: 'app://chatwise.local/', runScripts: 'outside-only' }); - Object.defineProperty(dom.window.HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 400 }); - Object.defineProperty(dom.window.HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 32 }); - dom.window.HTMLElement.prototype.getClientRects = () => [{ length: 1 }]; - dom.window.document.execCommand = () => false; - return { dom, result: dom.window.eval(script) }; -} - -function makePage(evaluateResults = []) { - const evaluate = vi.fn(); - for (const result of evaluateResults) evaluate.mockResolvedValueOnce(result); - evaluate.mockResolvedValue(null); - return { - evaluate, - wait: vi.fn().mockResolvedValue(undefined), - pressKey: vi.fn().mockResolvedValue(undefined), - }; -} - -describe('chatwise composer selection', () => { - it('prefers the main composer over auxiliary contenteditable editors', () => { - const mainComposer = candidate({ - index: 0, - placeholder: 'placeholder Enter a message here, press ⏎ to send', - rect: { y: 860, h: 32 }, - }); - const optionalDescription = candidate({ - index: 1, - placeholder: 'placeholder Optional description', - editorClasses: 'cm-editor simple-editor', - rect: { y: 400, h: 32 }, - }); - const userContext = candidate({ - index: 2, - text: '# User Context Document', - editorClasses: 'cm-editor simple-editor', - rect: { y: 460, h: 1200 }, - }); - - expect(scoreChatwiseComposerCandidate(mainComposer, 900)).toBeGreaterThan( - scoreChatwiseComposerCandidate(optionalDescription, 900), - ); - expect(scoreChatwiseComposerCandidate(mainComposer, 900)).toBeGreaterThan( - scoreChatwiseComposerCandidate(userContext, 900), - ); - - expect(selectBestChatwiseComposer([ - optionalDescription, - userContext, - mainComposer, - ], 900)?.index).toBe(0); - }); - - it('rejects hidden or low-confidence candidates instead of injecting into the wrong editor', () => { - expect(selectBestChatwiseComposer([ - candidate({ - index: 0, - hidden: true, - placeholder: 'Enter a message here, press ⏎ to send', - rect: { y: 860, h: 32 }, - }), - ], 900)).toBeNull(); - - expect(selectBestChatwiseComposer([ - candidate({ - index: 1, - placeholder: 'Optional description', - editorClasses: 'cm-editor simple-editor', - rect: { y: 860, h: 32 }, - }), - candidate({ - index: 2, - text: '# User Context Document', - editorClasses: 'cm-editor simple-editor', - rect: { y: 870, h: 32 }, - }), - ], 900)).toBeNull(); - }); - - it('injects text into the scored main composer instead of the last contenteditable', () => { - const html = ` -
-
Optional description
-
-
-
-
Enter a message here, press ⏎ to send
-
-
-
-
# User Context Document
-
- `; - - const { dom, result } = runBrowserScript(html, buildChatwiseInjectTextJs('hello')); - - expect(result).toBe(true); - expect(dom.window.document.querySelector('#main')?.textContent).toBe('hello'); - expect(dom.window.document.querySelector('#optional')?.textContent).toBe(''); - expect(dom.window.document.querySelector('#context')?.textContent).toBe('# User Context Document'); - }); - - it('fails injection when only auxiliary editors are present', () => { - const html = ` -
-
Optional description
-
-
-
-
# User Context Document
-
- `; - - const { dom, result } = runBrowserScript(html, buildChatwiseInjectTextJs('hello')); - - expect(result).toBe(false); - expect(dom.window.document.querySelector('#optional')?.textContent).toBe(''); - expect(dom.window.document.querySelector('#context')?.textContent).toBe('# User Context Document'); - }); - - it('reads only real message wrapper content after the previous count', () => { - const html = ` -
old message
-
12:00
-
new assistant answer
- `; - - expect(runBrowserScript(html, buildChatwiseMessageCountJs()).result).toBe(2); - expect(runBrowserScript(html, buildChatwiseResponseAfterJs(1, 'user prompt')).result).toBe('new assistant answer'); - }); - - it('does not treat the user prompt wrapper as an assistant response', () => { - const html = ` -
old message
-
user prompt
- `; - - expect(runBrowserScript(html, buildChatwiseResponseAfterJs(1, 'user prompt')).result).toBeNull(); - }); - - it('validates timeout explicitly', () => { - expect(requirePositiveTimeout(30)).toBe(30); - expect(() => requirePositiveTimeout('30')).toThrow(ArgumentError); - expect(() => requirePositiveTimeout(0)).toThrow(ArgumentError); - }); - - it('fails fast when ask times out instead of returning a System success row', async () => { - const page = makePage([ - 1, - true, - null, - ]); - - await expect(askCommand.func(page, { text: 'hello', timeout: 1 })) - .rejects.toBeInstanceOf(TimeoutError); - }); -}); diff --git a/plugins/chatwise/utils.js b/plugins/chatwise/utils.js deleted file mode 100644 index 0ef1725a..00000000 --- a/plugins/chatwise/utils.js +++ /dev/null @@ -1,143 +0,0 @@ -import { ArgumentError } from '@agentrhq/webcmd/errors'; - -export const MESSAGE_WRAPPER_SELECTOR = '[class*="group/message"]'; -export const MIN_COMPOSER_SCORE = 120; - -export function requirePositiveTimeout(value) { - const timeout = value; - if (!Number.isInteger(timeout) || timeout <= 0) { - throw new ArgumentError('--timeout must be a positive integer (seconds)'); - } - return timeout; -} - -export function scoreChatwiseComposerCandidate(candidate, viewportHeight = 0) { - if (candidate.hidden) return -1000; - - let score = 0; - const normalizedRole = String(candidate.role || '').toLowerCase(); - if (normalizedRole === 'textbox') score += 10; - - const normalizedClasses = `${candidate.classes || ''} ${candidate.editorClasses || ''} ${candidate.ariaLabel || ''}`.toLowerCase(); - if (normalizedClasses.includes('cm-content')) score += 20; - if (normalizedClasses.includes('cm-editor')) score += 30; - if (normalizedClasses.includes('simple-editor')) score -= 140; - - const searchableText = `${candidate.placeholder || ''} ${candidate.ariaLabel || ''} ${candidate.text || ''}`.toLowerCase(); - if (searchableText.includes('enter a message here')) score += 220; - if (searchableText.includes('press ⏎ to send')) score += 80; - if (searchableText.includes('press enter to send')) score += 80; - if (searchableText.includes('message')) score += 20; - if (searchableText.includes('optional description')) score -= 140; - if (searchableText.includes('user context document')) score -= 220; - - if (viewportHeight > 0 && candidate.rect) { - const bottom = candidate.rect.y + candidate.rect.h; - const distanceFromBottom = Math.abs(viewportHeight - bottom); - score += Math.max(0, 80 - distanceFromBottom / 8); - } - - return score; -} - -export function selectBestChatwiseComposer(candidates, viewportHeight = 0, minScore = MIN_COMPOSER_SCORE) { - if (!Array.isArray(candidates) || candidates.length === 0) return null; - const best = [...candidates] - .sort((left, right) => { - const delta = scoreChatwiseComposerCandidate(right, viewportHeight) - - scoreChatwiseComposerCandidate(left, viewportHeight); - return delta !== 0 ? delta : left.index - right.index; - })[0] ?? null; - if (!best || scoreChatwiseComposerCandidate(best, viewportHeight) < minScore) return null; - return best; -} - -export function buildChatwiseInjectTextJs(text) { - const scoreFn = scoreChatwiseComposerCandidate.toString(); - const selectFn = selectBestChatwiseComposer.toString(); - const textJs = JSON.stringify(String(text ?? '')); - - return ` - (function(text) { - const scoreChatwiseComposerCandidate = ${scoreFn}; - const selectBestChatwiseComposer = ${selectFn}; - const MIN_COMPOSER_SCORE = ${MIN_COMPOSER_SCORE}; - - const composers = Array.from(document.querySelectorAll([ - 'textarea[aria-label*="message" i]', - 'textarea[placeholder*="message" i]', - '[contenteditable="true"][role="textbox"]', - '[contenteditable="true"]' - ].join(','))); - const candidates = composers.map((el, index) => { - const rect = el.getBoundingClientRect(); - const editor = el.closest('.cm-editor'); - const placeholderEl = editor?.querySelector('.cm-placeholder'); - return { - index, - hidden: !(el.offsetWidth || el.offsetHeight || el.getClientRects().length), - role: el.getAttribute('role'), - classes: el.className || '', - editorClasses: editor?.className || '', - ariaLabel: el.getAttribute('aria-label') || '', - placeholder: placeholderEl?.getAttribute('aria-label') || placeholderEl?.textContent || el.getAttribute('placeholder') || '', - text: (el.textContent || '').trim(), - rect: { y: rect.y, h: rect.height }, - }; - }); - - const best = selectBestChatwiseComposer(candidates, window.innerHeight, MIN_COMPOSER_SCORE); - if (!best) return false; - - const composer = composers[best.index]; - composer.focus(); - - if (composer.tagName === 'TEXTAREA') { - const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set; - if (setter) setter.call(composer, text); - else composer.value = text; - composer.dispatchEvent(new Event('input', { bubbles: true })); - return true; - } - - const selection = window.getSelection(); - const range = document.createRange(); - range.selectNodeContents(composer); - selection?.removeAllRanges(); - selection?.addRange(range); - - const inserted = document.execCommand?.('insertText', false, text); - if (!inserted) { - composer.textContent = text; - } - composer.dispatchEvent(new Event('input', { bubbles: true })); - return true; - })(${textJs}) - `; -} - -export function buildChatwiseMessageCountJs() { - return ` - (function() { - return Array.from(document.querySelectorAll(${JSON.stringify(MESSAGE_WRAPPER_SELECTOR)})) - .map(node => (node.innerText || node.textContent || '').trim()) - .filter(Boolean) - .length; - })() - `; -} - -export function buildChatwiseResponseAfterJs(previousCount, userText) { - return ` - (function(previousCount, userText) { - const messages = Array.from(document.querySelectorAll(${JSON.stringify(MESSAGE_WRAPPER_SELECTOR)})) - .map(node => (node.innerText || node.textContent || '').trim()) - .filter(Boolean); - if (messages.length <= previousCount) return null; - const fresh = messages.slice(previousCount) - .filter(text => text && text !== userText); - if (fresh.length === 0) return null; - return fresh[fresh.length - 1]; - })(${Number(previousCount) || 0}, ${JSON.stringify(String(userText ?? ''))}) - `; -} diff --git a/plugins/chatwise/webcmd-plugin.json b/plugins/chatwise/webcmd-plugin.json deleted file mode 100644 index 1f2fe225..00000000 --- a/plugins/chatwise/webcmd-plugin.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "chatwise", - "version": "0.1.0", - "description": "Webcmd commands for chatwise", - "webcmd": ">=0.5.3", - "author": { - "name": "WebCMD Agent", - "handle": "agentrhq" - } -} diff --git a/plugins/chess/README.md b/plugins/chess/README.md deleted file mode 100644 index 4d41f8cc..00000000 --- a/plugins/chess/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# webcmd-plugin-chess - -Webcmd commands for chess. - -## Install - -```bash -webcmd plugin install github:agentrhq/webcmd/chess -``` - -## Commands - -| Command | Description | -| --- | --- | -| `webcmd chess analyze` | Open a Chess.com game in the browser analysis board | -| `webcmd chess game` | Chess.com single-game detail (white, black, result, ECO, time control) by full game URL | -| `webcmd chess games` | Chess.com recent games for a player, newest first | -| `webcmd chess stats` | Chess.com player ratings + win/loss record across game kinds | diff --git a/plugins/chess/analyze.js b/plugins/chess/analyze.js deleted file mode 100644 index cab3f755..00000000 --- a/plugins/chess/analyze.js +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Open a Chess.com game in the browser's analysis view. Thin wrapper: - * navigates the bound session to the `/analysis` form of the game URL - * and reports the resolved page URL. - */ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { CommandExecutionError } from '@agentrhq/webcmd/errors'; -import { parseGameUrl } from './utils.js'; - -cli({ - site: 'chess', - name: 'analyze', - access: 'read', - description: 'Open a Chess.com game in the browser analysis board', - domain: 'www.chess.com', - strategy: Strategy.UI, - browser: true, - navigateBefore: false, - args: [ - { name: 'game-url', type: 'string', required: true, positional: true, help: 'Full game URL, e.g. https://www.chess.com/game/live/168842570216' }, - ], - columns: ['kind', 'game_id', 'analysis_url'], - func: async (page, kwargs) => { - if (!page) throw new CommandExecutionError('Browser session required for chess analyze'); - const { kind, id } = parseGameUrl(kwargs['game-url']); - const analysisUrl = `https://www.chess.com/analysis/game/${kind}/${id}`; - try { - await page.goto(analysisUrl); - await page.wait(2); - } catch (error) { - throw new CommandExecutionError(`Failed to open Chess.com analysis board: ${error?.message || error}`); - } - return [{ kind, game_id: id, analysis_url: analysisUrl }]; - }, -}); diff --git a/plugins/chess/game.js b/plugins/chess/game.js deleted file mode 100644 index f703d99e..00000000 --- a/plugins/chess/game.js +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Chess.com single-game detail by URL, via the internal callback - * endpoint `/callback/{live|daily}/game/{id}`. Returns the canonical - * PGN headers + move data plus per-player metadata. - */ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors'; -import { UA, formatDate, isPlainObject, parseGameUrl } from './utils.js'; - -const CALLBACK_BASE = 'https://www.chess.com/callback'; - -function stringOrEmpty(value) { - return typeof value === 'string' ? value : ''; -} - -function scalarOrEmpty(value) { - return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ? value : ''; -} - -export function summarizeGame({ kind, id, payload }) { - if (!isPlainObject(payload) || !isPlainObject(payload.game)) { - throw new CommandExecutionError('Chess.com callback returned no game payload'); - } - const g = payload.game; - if (g.pgnHeaders !== undefined && !isPlainObject(g.pgnHeaders)) { - throw new CommandExecutionError('Chess.com callback returned malformed PGN headers'); - } - if (payload.players !== undefined && !isPlainObject(payload.players)) { - throw new CommandExecutionError('Chess.com callback returned malformed player metadata'); - } - const players = payload.players || {}; - const byColor = {}; - for (const slot of ['top', 'bottom']) { - const p = players[slot]; - if (p !== undefined && !isPlainObject(p)) { - throw new CommandExecutionError('Chess.com callback returned malformed player metadata'); - } - if (p?.color) byColor[p.color] = p; - } - const white = byColor.white || {}; - const black = byColor.black || {}; - const headers = g.pgnHeaders || {}; - const whiteName = stringOrEmpty(white.username) || stringOrEmpty(headers.White); - const blackName = stringOrEmpty(black.username) || stringOrEmpty(headers.Black); - const result = stringOrEmpty(headers.Result); - if (!whiteName || !blackName || !result) { - throw new CommandExecutionError('Chess.com callback payload is missing stable game summary fields'); - } - const headerDate = stringOrEmpty(headers.Date); - return { - kind, - game_id: id, - date: headerDate ? headerDate.replace(/\./g, '-') : formatDate(g.endTime), - white: whiteName, - white_rating: scalarOrEmpty(white.rating) || scalarOrEmpty(headers.WhiteElo), - black: blackName, - black_rating: scalarOrEmpty(black.rating) || scalarOrEmpty(headers.BlackElo), - result, - winner_color: stringOrEmpty(g.colorOfWinner), - termination: stringOrEmpty(headers.Termination) || stringOrEmpty(g.resultMessage), - eco: stringOrEmpty(headers.ECO), - time_control: stringOrEmpty(headers.TimeControl) || (typeof g.daysPerTurn === 'number' ? `${g.daysPerTurn}d/turn` : ''), - rated: g.isRated === true, - ply_count: g.plyCount ?? '', - url: `https://www.chess.com/game/${kind}/${id}`, - }; -} - -cli({ - site: 'chess', - name: 'game', - access: 'read', - description: 'Chess.com single-game detail (white, black, result, ECO, time control) by full game URL', - domain: 'www.chess.com', - strategy: Strategy.PUBLIC, - browser: false, - args: [ - { name: 'game-url', type: 'string', required: true, positional: true, help: 'Full game URL, e.g. https://www.chess.com/game/live/168842570216' }, - ], - columns: [ - 'kind', 'game_id', 'date', - 'white', 'white_rating', 'black', 'black_rating', - 'result', 'winner_color', 'termination', - 'eco', 'time_control', 'rated', 'ply_count', 'url', - ], - func: async (kwargs) => { - const { kind, id } = parseGameUrl(kwargs['game-url']); - const url = `${CALLBACK_BASE}/${kind}/game/${id}`; - let resp; - try { - resp = await fetch(url, { headers: { 'User-Agent': UA, accept: 'application/json' } }); - } catch (error) { - throw new CommandExecutionError(`Failed to fetch Chess.com callback ${url}: ${error?.message || error}`); - } - if (!resp || typeof resp !== 'object') { - throw new CommandExecutionError(`Chess.com callback returned an invalid response object for ${url}`); - } - if (resp.status === 404) { - throw new EmptyResultError(`Chess.com has no ${kind} game with id ${id}`); - } - if (!resp.ok) { - throw new CommandExecutionError(`Chess.com callback returned HTTP ${resp.status} for ${url}`); - } - let payload; - try { - payload = await resp.json(); - } catch (error) { - throw new CommandExecutionError(`Chess.com callback returned malformed JSON for ${url}: ${error?.message || error}`); - } - return [summarizeGame({ kind, id, payload })]; - }, -}); - -export const __test__ = { parseGameUrl, summarizeGame }; diff --git a/plugins/chess/games.js b/plugins/chess/games.js deleted file mode 100644 index 9d7dc1c3..00000000 --- a/plugins/chess/games.js +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Chess.com recent games from monthly archives. Walks the archive - * list newest-first and fetches as few months as needed to fill --limit. - */ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { ArgumentError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors'; -import { chessApi, validateUsername, mapGameRow } from './utils.js'; - -const MAX_LIMIT = 100; -const MAX_ARCHIVE_FETCHES = 6; - -function parseLimit(value) { - if (value === undefined || value === null || value === '') return 10; - const limit = Number(value); - if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) { - throw new ArgumentError(`--limit must be an integer between 1 and ${MAX_LIMIT}`); - } - return limit; -} - -cli({ - site: 'chess', - name: 'games', - access: 'read', - description: 'Chess.com recent games for a player, newest first', - domain: 'api.chess.com', - strategy: Strategy.PUBLIC, - browser: false, - args: [ - { name: 'username', type: 'string', required: true, positional: true, help: 'Chess.com username' }, - { name: 'limit', type: 'int', default: 10, help: `Number of recent games (1-${MAX_LIMIT})` }, - ], - columns: ['date', 'time_class', 'rated', 'my_color', 'my_rating', 'my_result', 'opponent', 'opponent_rating', 'accuracy_white', 'accuracy_black', 'eco', 'opening_name', 'url'], - func: async (kwargs) => { - const username = validateUsername(kwargs.username); - const limit = parseLimit(kwargs.limit); - const archivesList = await chessApi(`/player/${encodeURIComponent(username)}/games/archives`); - if (!Array.isArray(archivesList.archives)) { - throw new CommandExecutionError('Chess.com archives payload is missing archives array'); - } - const archives = archivesList.archives.slice().reverse(); - if (archives.length === 0) { - throw new EmptyResultError(`Chess.com has no game archives for ${username}`); - } - const rows = []; - for (let i = 0; i < archives.length && i < MAX_ARCHIVE_FETCHES && rows.length < limit; i++) { - if (typeof archives[i] !== 'string' || !archives[i].startsWith('https://api.chess.com/pub/player/')) { - throw new CommandExecutionError('Chess.com archives payload contains an unexpected archive URL'); - } - const monthly = await chessApi(archives[i]); - if (!Array.isArray(monthly.games)) { - throw new CommandExecutionError('Chess.com monthly archive payload is missing games array'); - } - const games = monthly.games.slice().reverse(); - for (const g of games) { - rows.push(mapGameRow(g, username)); - if (rows.length >= limit) break; - } - } - if (rows.length === 0) { - throw new EmptyResultError(`Chess.com has games archives for ${username} but no games in the most recent ${MAX_ARCHIVE_FETCHES} months`); - } - return rows.slice(0, limit); - }, -}); - -export const __test__ = { parseLimit }; diff --git a/plugins/chess/package.json b/plugins/chess/package.json deleted file mode 100644 index 354ce6b1..00000000 --- a/plugins/chess/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "webcmd-plugin-chess", - "version": "0.1.0", - "type": "module", - "description": "Webcmd commands for chess", - "peerDependencies": { - "@agentrhq/webcmd": ">=0.5.3" - } -} diff --git a/plugins/chess/stats.js b/plugins/chess/stats.js deleted file mode 100644 index 9d9dd5ad..00000000 --- a/plugins/chess/stats.js +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Chess.com player stats across game kinds (rapid / blitz / bullet / - * daily / chess960 / etc) via the public stats endpoint. - */ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { EmptyResultError } from '@agentrhq/webcmd/errors'; -import { chessApi, validateUsername, summarizeStats } from './utils.js'; - -const KINDS = ['chess_rapid', 'chess_blitz', 'chess_bullet', 'chess_daily', 'chess960_daily', 'chess_daily_960']; - -cli({ - site: 'chess', - name: 'stats', - access: 'read', - description: 'Chess.com player ratings + win/loss record across game kinds', - domain: 'api.chess.com', - strategy: Strategy.PUBLIC, - browser: false, - args: [ - { name: 'username', type: 'string', required: true, positional: true, help: 'Chess.com username (case-insensitive)' }, - ], - columns: ['kind', 'rating_current', 'rating_best', 'wins', 'losses', 'draws'], - func: async (kwargs) => { - const username = validateUsername(kwargs.username); - const stats = await chessApi(`/player/${encodeURIComponent(username)}/stats`); - const rows = KINDS.map((k) => summarizeStats(stats, k)).filter(Boolean); - if (rows.length === 0) { - throw new EmptyResultError(`Chess.com returned no stats for ${username}`); - } - return rows; - }, -}); diff --git a/plugins/chess/test/analyze.test.js b/plugins/chess/test/analyze.test.js deleted file mode 100644 index 709cf7c1..00000000 --- a/plugins/chess/test/analyze.test.js +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { getRegistry } from '@agentrhq/webcmd/registry'; -import { ArgumentError, CommandExecutionError } from '@agentrhq/webcmd/errors'; -import { readFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import '../analyze.js'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const manifestPath = resolve(__dirname, '../../../plugin-command-manifest.json'); - -function loadManifestCommand(name) { - const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); - return manifest.find(cmd => cmd.site === 'chess' && cmd.name === name); -} - -function makePage() { - return { - goto: vi.fn().mockResolvedValue(undefined), - wait: vi.fn().mockResolvedValue(undefined), - }; -} - -describe('chess analyze command', () => { - it('navigates to /analysis/game// and reports the URL', async () => { - const cmd = getRegistry().get('chess/analyze'); - const page = makePage(); - const rows = await cmd.func(page, { 'game-url': 'https://www.chess.com/game/live/42' }); - expect(rows).toEqual([{ kind: 'live', game_id: '42', analysis_url: 'https://www.chess.com/analysis/game/live/42' }]); - expect(page.goto).toHaveBeenCalledWith('https://www.chess.com/analysis/game/live/42'); - }); - - it('preserves daily kind in the analysis URL', async () => { - const cmd = getRegistry().get('chess/analyze'); - const page = makePage(); - const rows = await cmd.func(page, { 'game-url': 'https://www.chess.com/game/daily/123' }); - expect(rows[0].analysis_url).toBe('https://www.chess.com/analysis/game/daily/123'); - }); - - it('rejects invalid URL with ArgumentError before navigation', async () => { - const cmd = getRegistry().get('chess/analyze'); - const page = makePage(); - await expect(cmd.func(page, { 'game-url': 'not-a-url' })).rejects.toBeInstanceOf(ArgumentError); - expect(page.goto).not.toHaveBeenCalled(); - }); - - it('throws CommandExecutionError without a browser page', async () => { - const cmd = getRegistry().get('chess/analyze'); - await expect(cmd.func(null, { 'game-url': 'https://www.chess.com/game/live/42' })) - .rejects.toBeInstanceOf(CommandExecutionError); - }); - - it('throws CommandExecutionError when browser navigation fails', async () => { - const cmd = getRegistry().get('chess/analyze'); - const page = makePage(); - page.goto.mockRejectedValue(new Error('navigation failed')); - await expect(cmd.func(page, { 'game-url': 'https://www.chess.com/game/live/42' })) - .rejects.toBeInstanceOf(CommandExecutionError); - }); - - it('registers with the expected columns + browser flag', () => { - const cmd = getRegistry().get('chess/analyze'); - expect(cmd?.columns).toEqual(['kind', 'game_id', 'analysis_url']); - expect(cmd?.browser).toBe(true); - expect(cmd?.navigateBefore).toBe(false); - }); - - it('build manifest keeps analyze pre-navigation disabled and game source attribution stable', () => { - expect(loadManifestCommand('analyze')).toMatchObject({ - navigateBefore: false, - modulePath: 'plugins/chess/analyze.js', - sourceFile: 'plugins/chess/analyze.js', - }); - expect(loadManifestCommand('game')).toMatchObject({ - modulePath: 'plugins/chess/game.js', - sourceFile: 'plugins/chess/game.js', - }); - }); -}); diff --git a/plugins/chess/test/game.test.js b/plugins/chess/test/game.test.js deleted file mode 100644 index 71695abf..00000000 --- a/plugins/chess/test/game.test.js +++ /dev/null @@ -1,178 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { getRegistry } from '@agentrhq/webcmd/registry'; -import { ArgumentError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors'; -import '../game.js'; - -const { summarizeGame } = await import('../game.js').then((m) => m.__test__); - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -function mockFetch(payload, status = 200) { - return vi.fn().mockResolvedValue({ - ok: status === 200, - status, - json: () => Promise.resolve(payload), - }); -} - -describe('chess game command', () => { - it('summarizeGame maps the callback payload to the canonical row shape', () => { - const row = summarizeGame({ - kind: 'live', - id: '999', - payload: { - game: { - pgnHeaders: { - Date: '2026.05.17', - White: 'Hikaru', - Black: 'tactic', - Result: '1-0', - ECO: 'A01', - WhiteElo: 3454, - BlackElo: 2869, - TimeControl: '180', - Termination: 'Hikaru won by resignation', - }, - colorOfWinner: 'white', - isRated: true, - plyCount: 111, - endTime: 1747584400, - }, - players: { - top: { username: 'tactic', color: 'black', rating: 2869 }, - bottom: { username: 'Hikaru', color: 'white', rating: 3454 }, - }, - }, - }); - expect(row).toMatchObject({ - kind: 'live', - game_id: '999', - date: '2026-05-17', - white: 'Hikaru', - white_rating: 3454, - black: 'tactic', - black_rating: 2869, - result: '1-0', - winner_color: 'white', - termination: 'Hikaru won by resignation', - eco: 'A01', - time_control: '180', - rated: true, - ply_count: 111, - url: 'https://www.chess.com/game/live/999', - }); - }); - - it('summarizeGame falls back to pgnHeaders when players are missing', () => { - const row = summarizeGame({ - kind: 'daily', - id: '1', - payload: { - game: { - pgnHeaders: { White: 'A', Black: 'B', Result: '1/2-1/2', WhiteElo: 1200, BlackElo: 1300 }, - colorOfWinner: '', - isRated: false, - daysPerTurn: 3, - }, - players: {}, - }, - }); - expect(row.white).toBe('A'); - expect(row.black_rating).toBe(1300); - expect(row.time_control).toBe('3d/turn'); - expect(row.rated).toBe(false); - }); - - it('summarizeGame throws CommandExecutionError on missing game payload', () => { - expect(() => summarizeGame({ kind: 'live', id: '1', payload: {} })).toThrow(CommandExecutionError); - expect(() => summarizeGame({ kind: 'live', id: '1', payload: null })).toThrow(CommandExecutionError); - }); - - it('summarizeGame throws CommandExecutionError on malformed nested payloads', () => { - expect(() => summarizeGame({ - kind: 'live', - id: '1', - payload: { game: { pgnHeaders: [] } }, - })).toThrow(CommandExecutionError); - expect(() => summarizeGame({ - kind: 'live', - id: '1', - payload: { game: { pgnHeaders: { White: 'A', Black: 'B' } }, players: [] }, - })).toThrow(CommandExecutionError); - }); - - it('summarizeGame requires stable players and result evidence', () => { - expect(() => summarizeGame({ - kind: 'live', - id: '1', - payload: { game: { pgnHeaders: { White: 'A', Black: 'B' } }, players: {} }, - })).toThrow(CommandExecutionError); - expect(() => summarizeGame({ - kind: 'live', - id: '1', - payload: { game: { pgnHeaders: { White: 'A', Result: '1-0' } }, players: {} }, - })).toThrow(CommandExecutionError); - }); - - it('command fetches the callback URL and returns a single row', async () => { - const fetchMock = mockFetch({ - game: { pgnHeaders: { White: 'A', Black: 'B', Result: '1-0', WhiteElo: 100, BlackElo: 90 } }, - players: {}, - }); - vi.stubGlobal('fetch', fetchMock); - const cmd = getRegistry().get('chess/game'); - const rows = await cmd.func({ 'game-url': 'https://www.chess.com/game/live/42' }); - expect(rows).toHaveLength(1); - expect(rows[0].url).toBe('https://www.chess.com/game/live/42'); - expect(fetchMock).toHaveBeenCalledWith( - 'https://www.chess.com/callback/live/game/42', - expect.objectContaining({ headers: expect.any(Object) }), - ); - }); - - it('command surfaces 404 as EmptyResultError', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 404 })); - const cmd = getRegistry().get('chess/game'); - await expect(cmd.func({ 'game-url': 'https://www.chess.com/game/live/1' })) - .rejects.toBeInstanceOf(EmptyResultError); - }); - - it('command surfaces non-2xx as CommandExecutionError', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 })); - const cmd = getRegistry().get('chess/game'); - await expect(cmd.func({ 'game-url': 'https://www.chess.com/game/live/1' })) - .rejects.toBeInstanceOf(CommandExecutionError); - }); - - it('command maps fetch and JSON failures to CommandExecutionError', async () => { - const cmd = getRegistry().get('chess/game'); - vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('network down'))); - await expect(cmd.func({ 'game-url': 'https://www.chess.com/game/live/1' })) - .rejects.toBeInstanceOf(CommandExecutionError); - - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: () => Promise.reject(new SyntaxError('bad json')), - })); - await expect(cmd.func({ 'game-url': 'https://www.chess.com/game/live/1' })) - .rejects.toBeInstanceOf(CommandExecutionError); - }); - - it('command maps wrong-shape callback JSON to CommandExecutionError', async () => { - vi.stubGlobal('fetch', mockFetch([])); - const cmd = getRegistry().get('chess/game'); - await expect(cmd.func({ 'game-url': 'https://www.chess.com/game/live/1' })) - .rejects.toBeInstanceOf(CommandExecutionError); - }); - - it('rejects invalid URL with ArgumentError before any fetch', async () => { - const fetchMock = vi.fn(); - vi.stubGlobal('fetch', fetchMock); - const cmd = getRegistry().get('chess/game'); - await expect(cmd.func({ 'game-url': 'not-a-url' })).rejects.toBeInstanceOf(ArgumentError); - expect(fetchMock).not.toHaveBeenCalled(); - }); -}); diff --git a/plugins/chess/test/games.test.js b/plugins/chess/test/games.test.js deleted file mode 100644 index e822d36d..00000000 --- a/plugins/chess/test/games.test.js +++ /dev/null @@ -1,164 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { getRegistry } from '@agentrhq/webcmd/registry'; -import { ArgumentError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors'; -import '../games.js'; - -const { parseLimit } = await import('../games.js').then((m) => m.__test__); - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -function fetchFor(map) { - return vi.fn().mockImplementation((url) => { - if (map.has(url)) { - return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(map.get(url)) }); - } - return Promise.resolve({ ok: false, status: 404 }); - }); -} - -function game(white, whiteRating, black, blackRating, endTime, extra = {}) { - return { - url: `https://www.chess.com/game/live/${endTime}`, - end_time: endTime, - time_class: 'blitz', - rated: true, - eco: 'C50', - white: { username: white, rating: whiteRating, result: 'win' }, - black: { username: black, rating: blackRating, result: 'resigned' }, - ...extra, - }; -} - -describe('chess games command', () => { - it('parseLimit accepts 1-100, rejects everything else', () => { - expect(parseLimit(undefined)).toBe(10); - expect(parseLimit(1)).toBe(1); - expect(parseLimit(100)).toBe(100); - expect(() => parseLimit(0)).toThrow(ArgumentError); - expect(() => parseLimit(101)).toThrow(ArgumentError); - expect(() => parseLimit(1.5)).toThrow(ArgumentError); - expect(() => parseLimit('abc')).toThrow(ArgumentError); - }); - - it('returns recent games newest-first sliced to --limit', async () => { - const map = new Map([ - ['https://api.chess.com/pub/player/hikaru/games/archives', { - archives: ['https://api.chess.com/pub/player/hikaru/games/2026/04', 'https://api.chess.com/pub/player/hikaru/games/2026/05'], - }], - ['https://api.chess.com/pub/player/hikaru/games/2026/05', { - games: [ - game('Hikaru', 3286, 'A', 2900, 1777737000), - game('Hikaru', 3286, 'B', 2950, 1777737500), - game('Hikaru', 3286, 'C', 3000, 1777737900), - ], - }], - ]); - vi.stubGlobal('fetch', fetchFor(map)); - const cmd = getRegistry().get('chess/games'); - const rows = await cmd.func({ username: 'Hikaru', limit: 2 }); - expect(rows).toHaveLength(2); - // archive is reversed (newest month first), games within are reversed - // so the first row corresponds to the LAST game in the JSON array. - expect(rows[0].opponent).toBe('C'); - expect(rows[1].opponent).toBe('B'); - }); - - it('walks multiple months until --limit is filled', async () => { - const map = new Map([ - ['https://api.chess.com/pub/player/hikaru/games/archives', { - archives: ['https://api.chess.com/pub/player/hikaru/games/2026/03', 'https://api.chess.com/pub/player/hikaru/games/2026/04'], - }], - ['https://api.chess.com/pub/player/hikaru/games/2026/04', { - games: [game('Hikaru', 3286, 'A', 2900, 1777737000)], - }], - ['https://api.chess.com/pub/player/hikaru/games/2026/03', { - games: [game('Hikaru', 3286, 'B', 2950, 1774000000)], - }], - ]); - vi.stubGlobal('fetch', fetchFor(map)); - const cmd = getRegistry().get('chess/games'); - const rows = await cmd.func({ username: 'Hikaru', limit: 2 }); - expect(rows.map((r) => r.opponent)).toEqual(['A', 'B']); - }); - - it('throws EmptyResultError when archives list is empty', async () => { - const map = new Map([ - ['https://api.chess.com/pub/player/someuser/games/archives', { archives: [] }], - ]); - vi.stubGlobal('fetch', fetchFor(map)); - const cmd = getRegistry().get('chess/games'); - await expect(cmd.func({ username: 'someuser', limit: 5 })).rejects.toBeInstanceOf(EmptyResultError); - }); - - it('throws CommandExecutionError when archives payload is wrong-shape', async () => { - const map = new Map([ - ['https://api.chess.com/pub/player/someuser/games/archives', { archives: {} }], - ]); - vi.stubGlobal('fetch', fetchFor(map)); - const cmd = getRegistry().get('chess/games'); - await expect(cmd.func({ username: 'someuser', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError); - }); - - it('throws CommandExecutionError when monthly archive games payload is wrong-shape', async () => { - const map = new Map([ - ['https://api.chess.com/pub/player/hikaru/games/archives', { - archives: ['https://api.chess.com/pub/player/hikaru/games/2026/05'], - }], - ['https://api.chess.com/pub/player/hikaru/games/2026/05', { games: null }], - ]); - vi.stubGlobal('fetch', fetchFor(map)); - const cmd = getRegistry().get('chess/games'); - await expect(cmd.func({ username: 'Hikaru', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError); - }); - - it('throws CommandExecutionError when a game row lacks stable identity', async () => { - const map = new Map([ - ['https://api.chess.com/pub/player/hikaru/games/archives', { - archives: ['https://api.chess.com/pub/player/hikaru/games/2026/05'], - }], - ['https://api.chess.com/pub/player/hikaru/games/2026/05', { - games: [{ - end_time: 1777737000, - white: { username: 'Hikaru', rating: 3286, result: 'win' }, - black: { username: 'A', rating: 2900, result: 'resigned' }, - }], - }], - ]); - vi.stubGlobal('fetch', fetchFor(map)); - const cmd = getRegistry().get('chess/games'); - await expect(cmd.func({ username: 'Hikaru', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError); - }); - - it('throws CommandExecutionError when a game row does not include the requested player', async () => { - const map = new Map([ - ['https://api.chess.com/pub/player/hikaru/games/archives', { - archives: ['https://api.chess.com/pub/player/hikaru/games/2026/05'], - }], - ['https://api.chess.com/pub/player/hikaru/games/2026/05', { - games: [game('A', 2900, 'B', 2800, 1777737000)], - }], - ]); - vi.stubGlobal('fetch', fetchFor(map)); - const cmd = getRegistry().get('chess/games'); - await expect(cmd.func({ username: 'Hikaru', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError); - }); - - it('throws ArgumentError on invalid username before any fetch', async () => { - const fetchMock = vi.fn(); - vi.stubGlobal('fetch', fetchMock); - const cmd = getRegistry().get('chess/games'); - await expect(cmd.func({ username: 'a b', limit: 5 })).rejects.toBeInstanceOf(ArgumentError); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('registers with the expected columns', () => { - const cmd = getRegistry().get('chess/games'); - expect(cmd?.columns).toEqual([ - 'date', 'time_class', 'rated', 'my_color', 'my_rating', 'my_result', - 'opponent', 'opponent_rating', 'accuracy_white', 'accuracy_black', - 'eco', 'opening_name', 'url', - ]); - }); -}); diff --git a/plugins/chess/test/stats.test.js b/plugins/chess/test/stats.test.js deleted file mode 100644 index 53a82a3d..00000000 --- a/plugins/chess/test/stats.test.js +++ /dev/null @@ -1,79 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { getRegistry } from '@agentrhq/webcmd/registry'; -import { ArgumentError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors'; -import '../stats.js'; - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -function mockFetch(body) { - return vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: () => Promise.resolve(body), - }); -} - -describe('chess stats command', () => { - it('rejects empty username via validateUsername', async () => { - const cmd = getRegistry().get('chess/stats'); - await expect(cmd.func({ username: '' })).rejects.toBeInstanceOf(ArgumentError); - }); - - it('rejects invalid username characters', async () => { - const cmd = getRegistry().get('chess/stats'); - await expect(cmd.func({ username: 'user name' })).rejects.toBeInstanceOf(ArgumentError); - }); - - it('returns one row per known game kind populated in the stats response', async () => { - const fetchMock = mockFetch({ - chess_rapid: { last: { rating: 1700 }, best: { rating: 1800 }, record: { win: 50, loss: 20, draw: 5 } }, - chess_blitz: { last: { rating: 1500 }, best: { rating: 1600 }, record: { win: 100, loss: 80, draw: 10 } }, - }); - vi.stubGlobal('fetch', fetchMock); - const cmd = getRegistry().get('chess/stats'); - const rows = await cmd.func({ username: 'someuser' }); - expect(rows).toHaveLength(2); - expect(rows[0].kind).toBe('rapid'); - expect(rows[1].kind).toBe('blitz'); - expect(fetchMock).toHaveBeenCalledWith( - 'https://api.chess.com/pub/player/someuser/stats', - expect.objectContaining({ headers: expect.any(Object) }), - ); - }); - - it('lowercases username in the URL', async () => { - const fetchMock = mockFetch({ chess_rapid: { last: { rating: 1 }, best: {}, record: {} } }); - vi.stubGlobal('fetch', fetchMock); - const cmd = getRegistry().get('chess/stats'); - await cmd.func({ username: 'MixedCase' }); - expect(fetchMock).toHaveBeenCalledWith( - 'https://api.chess.com/pub/player/mixedcase/stats', - expect.any(Object), - ); - }); - - it('throws EmptyResultError when the stats response has no known kinds', async () => { - vi.stubGlobal('fetch', mockFetch({})); - const cmd = getRegistry().get('chess/stats'); - await expect(cmd.func({ username: 'someuser' })).rejects.toBeInstanceOf(EmptyResultError); - }); - - it('throws CommandExecutionError when a populated stats kind is malformed', async () => { - vi.stubGlobal('fetch', mockFetch({ chess_rapid: 'bad' })); - const cmd = getRegistry().get('chess/stats'); - await expect(cmd.func({ username: 'someuser' })).rejects.toBeInstanceOf(CommandExecutionError); - }); - - it('throws EmptyResultError on HTTP 404', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 404 })); - const cmd = getRegistry().get('chess/stats'); - await expect(cmd.func({ username: 'someuser' })).rejects.toBeInstanceOf(EmptyResultError); - }); - - it('registers with the expected columns', () => { - const cmd = getRegistry().get('chess/stats'); - expect(cmd?.columns).toEqual(['kind', 'rating_current', 'rating_best', 'wins', 'losses', 'draws']); - }); -}); diff --git a/plugins/chess/test/utils.test.js b/plugins/chess/test/utils.test.js deleted file mode 100644 index e21c7390..00000000 --- a/plugins/chess/test/utils.test.js +++ /dev/null @@ -1,230 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { ArgumentError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors'; -import { __test__ } from '../utils.js'; - -const { validateUsername, parseGameUrl, chessApi, summarizeStats, formatDate, mapGameRow, openingName } = __test__; - -describe('chess utils', () => { - it('validateUsername lowercases and accepts 3-25 char usernames', () => { - expect(validateUsername('Hikaru')).toBe('hikaru'); - expect(validateUsername('MagnusCarlsen')).toBe('magnuscarlsen'); - expect(validateUsername('a-b_c')).toBe('a-b_c'); - }); - - it('validateUsername rejects empty / too-short / invalid chars', () => { - expect(() => validateUsername('')).toThrow(ArgumentError); - expect(() => validateUsername('ab')).toThrow(ArgumentError); - expect(() => validateUsername('user name')).toThrow(ArgumentError); - expect(() => validateUsername('a'.repeat(30))).toThrow(ArgumentError); - }); - - it('parseGameUrl parses both live and daily game URL forms', () => { - expect(parseGameUrl('https://www.chess.com/game/live/168842570216')) - .toEqual({ kind: 'live', id: '168842570216' }); - expect(parseGameUrl('https://www.chess.com/game/daily/947761777')) - .toEqual({ kind: 'daily', id: '947761777' }); - expect(parseGameUrl('https://www.chess.com/game/LIVE/1')) - .toEqual({ kind: 'live', id: '1' }); - }); - - it('parseGameUrl strips trailing path / query off the URL', () => { - expect(parseGameUrl('https://www.chess.com/game/live/123/something?ref=share')) - .toEqual({ kind: 'live', id: '123' }); - }); - - it('parseGameUrl rejects empty / non-URL / unsupported-kind inputs', () => { - expect(() => parseGameUrl('')).toThrow(ArgumentError); - expect(() => parseGameUrl(' ')).toThrow(ArgumentError); - expect(() => parseGameUrl('123')).toThrow(ArgumentError); - expect(() => parseGameUrl('https://www.chess.com/club/123')).toThrow(ArgumentError); - expect(() => parseGameUrl('https://lichess.org/abc')).toThrow(ArgumentError); - }); - - it('summarizeStats projects rating + record fields', () => { - const stats = { - chess_rapid: { - last: { rating: 1600 }, - best: { rating: 1800 }, - record: { win: 100, loss: 50, draw: 10 }, - }, - }; - expect(summarizeStats(stats, 'chess_rapid')).toEqual({ - kind: 'rapid', - rating_current: 1600, - rating_best: 1800, - wins: 100, - losses: 50, - draws: 10, - }); - }); - - it('summarizeStats returns null for missing kind', () => { - expect(summarizeStats({}, 'chess_rapid')).toBeNull(); - expect(summarizeStats({ chess_blitz: {} }, 'chess_rapid')).toBeNull(); - }); - - it('summarizeStats typed-fails malformed populated kind objects', () => { - expect(() => summarizeStats({ chess_rapid: 'bad' }, 'chess_rapid')).toThrow(CommandExecutionError); - expect(() => summarizeStats({ chess_rapid: { record: [] } }, 'chess_rapid')).toThrow(CommandExecutionError); - }); - - it('summarizeStats coerces missing numeric fields to empty string', () => { - const row = summarizeStats({ chess_daily: { last: {}, record: {} } }, 'chess_daily'); - expect(row).toEqual({ - kind: 'daily', - rating_current: '', - rating_best: '', - wins: '', - losses: '', - draws: '', - }); - }); - - it('formatDate converts epoch seconds to YYYY-MM-DD', () => { - expect(formatDate(1777737679)).toBe('2026-05-02'); - expect(formatDate(0)).toBe(''); - expect(formatDate(null)).toBe(''); - expect(formatDate('not-a-number')).toBe(''); - }); - - it('mapGameRow returns rows from the viewer perspective when viewer is white', () => { - const game = { - url: 'https://www.chess.com/game/live/123', - end_time: 1777737679, - time_class: 'blitz', - rated: true, - eco: 'C50', - accuracies: { white: 87.73, black: 80.23 }, - white: { username: 'Hikaru', rating: 3286, result: 'win' }, - black: { username: 'Magnus', rating: 2900, result: 'resigned' }, - }; - expect(mapGameRow(game, 'Hikaru')).toEqual({ - date: '2026-05-02', - time_class: 'blitz', - rated: true, - my_color: 'white', - my_rating: 3286, - my_result: 'win', - opponent: 'Magnus', - opponent_rating: 2900, - accuracy_white: 87.73, - accuracy_black: 80.23, - eco: 'C50', - opening_name: '', - url: 'https://www.chess.com/game/live/123', - }); - }); - - it('mapGameRow leaves accuracy fields empty when chess.com did not compute them', () => { - const game = { - url: 'https://www.chess.com/game/live/123', - white: { username: 'A', rating: 1, result: 'win' }, - black: { username: 'B', rating: 1, result: 'resigned' }, - }; - const row = mapGameRow(game, 'A'); - expect(row.accuracy_white).toBe(''); - expect(row.accuracy_black).toBe(''); - }); - - it('mapGameRow parses opening_name from the chess.com eco URL', () => { - const game = { - url: 'https://www.chess.com/game/live/123', - eco: 'https://www.chess.com/openings/Reti-Opening-Nimzo-Larsen-Variation-2...g6-3.Bb2-Bg7-4.d4', - white: { username: 'A', rating: 1, result: 'win' }, - black: { username: 'B', rating: 1, result: 'resigned' }, - }; - const row = mapGameRow(game, 'A'); - expect(row.opening_name).toBe('Reti Opening Nimzo Larsen Variation'); - }); - - it('openingName helper returns clean human-readable name from URL form', () => { - expect(openingName('https://www.chess.com/openings/Sicilian-Defense')).toBe('Sicilian Defense'); - expect(openingName('https://www.chess.com/openings/Kings-Indian-Defense-Semi-Classical-Variation...7.O-O')) - .toBe('Kings Indian Defense Semi Classical Variation'); - expect(openingName('https://www.chess.com/openings/French-Defense-Advance-Variation-3...c5-4.c3')) - .toBe('French Defense Advance Variation'); - }); - - it('openingName returns empty for short-code eco or missing input', () => { - expect(openingName('A01')).toBe(''); - expect(openingName('')).toBe(''); - expect(openingName(undefined)).toBe(''); - expect(openingName(null)).toBe(''); - }); - - it('mapGameRow flips perspective when viewer is black', () => { - const game = { - url: 'https://www.chess.com/game/live/123', - white: { username: 'Hikaru', rating: 3286, result: 'win' }, - black: { username: 'Magnus', rating: 2900, result: 'resigned' }, - }; - const row = mapGameRow(game, 'Magnus'); - expect(row.my_color).toBe('black'); - expect(row.my_result).toBe('resigned'); - expect(row.opponent).toBe('Hikaru'); - }); - - it('mapGameRow matches viewer case-insensitively', () => { - const game = { - url: 'https://www.chess.com/game/live/123', - white: { username: 'Hikaru', rating: 3286, result: 'win' }, - black: { username: 'Magnus', rating: 2900, result: 'resigned' }, - }; - expect(mapGameRow(game, 'hikaru').my_color).toBe('white'); - expect(mapGameRow(game, 'MAGNUS').my_color).toBe('black'); - }); - - it('mapGameRow typed-fails when viewer is neither player', () => { - const game = { - url: 'https://www.chess.com/game/live/123', - white: { username: 'A', rating: 1000, result: 'win' }, - black: { username: 'B', rating: 1100, result: 'resigned' }, - }; - expect(() => mapGameRow(game, 'C')).toThrow(CommandExecutionError); - }); - - it('mapGameRow handles missing optional fields without throwing', () => { - const row = mapGameRow({ - url: 'https://www.chess.com/game/live/123', - white: { username: 'x' }, - black: { username: 'y' }, - }, 'x'); - expect(row.date).toBe(''); - expect(row.url).toBe('https://www.chess.com/game/live/123'); - expect(row.eco).toBe(''); - }); - - it('mapGameRow typed-fails missing stable URL or player identity', () => { - expect(() => mapGameRow({ - white: { username: 'x' }, - black: { username: 'y' }, - }, 'x')).toThrow(CommandExecutionError); - expect(() => mapGameRow({ - url: 'https://www.chess.com/game/live/123', - white: { username: 'x' }, - black: {}, - }, 'x')).toThrow(CommandExecutionError); - }); - - it('chessApi maps network, malformed JSON, and wrong-shape payloads to typed errors', async () => { - await expect(chessApi('/x', async () => { throw new TypeError('network down'); })) - .rejects.toBeInstanceOf(CommandExecutionError); - await expect(chessApi('/x', async () => ({ - ok: true, - status: 200, - json: async () => { throw new SyntaxError('bad json'); }, - }))).rejects.toBeInstanceOf(CommandExecutionError); - await expect(chessApi('/x', async () => ({ - ok: true, - status: 200, - json: async () => [], - }))).rejects.toBeInstanceOf(CommandExecutionError); - }); - - it('chessApi preserves 404 as empty and non-2xx as command execution errors', async () => { - await expect(chessApi('/x', async () => ({ ok: false, status: 404 }))) - .rejects.toBeInstanceOf(EmptyResultError); - await expect(chessApi('/x', async () => ({ ok: false, status: 500 }))) - .rejects.toBeInstanceOf(CommandExecutionError); - }); -}); diff --git a/plugins/chess/utils.js b/plugins/chess/utils.js deleted file mode 100644 index f16dc4cd..00000000 --- a/plugins/chess/utils.js +++ /dev/null @@ -1,170 +0,0 @@ -/** - * Shared helpers for the public Chess.com REST API - * (https://api.chess.com/pub/). No auth, no rate-limit headers. - */ -import { ArgumentError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors'; - -export const API_BASE = 'https://api.chess.com/pub'; -export const UA = 'Mozilla/5.0 (compatible; webcmd/1.0)'; - -const USERNAME_RE = /^[a-zA-Z0-9_-]{3,25}$/; -const GAME_URL_RE = /^https:\/\/www\.chess\.com\/game\/(live|daily)\/(\d+)/i; - -export function isPlainObject(value) { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - -function isOptionalPlainObject(value) { - return value === undefined || value === null || isPlainObject(value); -} - -export function validateUsername(value) { - const s = String(value ?? '').trim().toLowerCase(); - if (!s) throw new ArgumentError(' is required'); - if (!USERNAME_RE.test(s)) { - throw new ArgumentError(`Invalid Chess.com username "${value}"`, 'Usernames are 3-25 chars: a-z, 0-9, hyphen, underscore.'); - } - return s; -} - -export function parseGameUrl(value) { - const s = String(value ?? '').trim(); - if (!s) throw new ArgumentError(' is required'); - const m = s.match(GAME_URL_RE); - if (!m) { - throw new ArgumentError( - `Invalid Chess.com game URL: "${value}"`, - 'Expected https://www.chess.com/game/live/ or https://www.chess.com/game/daily/.', - ); - } - return { kind: m[1].toLowerCase(), id: m[2] }; -} - -export async function chessApi(path, fetchImpl = fetch) { - const url = path.startsWith('http') ? path : `${API_BASE}${path}`; - let resp; - try { - resp = await fetchImpl(url, { headers: { 'User-Agent': UA, accept: 'application/json' } }); - } catch (error) { - throw new CommandExecutionError(`Failed to fetch Chess.com API ${url}: ${error?.message || error}`); - } - if (!resp || typeof resp !== 'object') { - throw new CommandExecutionError(`Chess.com API returned an invalid response object for ${url}`); - } - if (resp.status === 404) throw new EmptyResultError(`Chess.com returned 404 for ${url}`); - if (!resp.ok) throw new CommandExecutionError(`Chess.com API returned HTTP ${resp.status} for ${url}`); - let payload; - try { - payload = await resp.json(); - } catch (error) { - throw new CommandExecutionError(`Chess.com API returned malformed JSON for ${url}: ${error?.message || error}`); - } - if (!isPlainObject(payload)) { - throw new CommandExecutionError(`Chess.com API returned an unexpected payload shape for ${url}`); - } - return payload; -} - -/** Pull rating + record fields out of a stats sub-object (`chess_rapid` etc). */ -export function summarizeStats(stats, kind) { - const k = stats?.[kind]; - if (!k) return null; - if (!isPlainObject(k)) { - throw new CommandExecutionError(`Chess.com stats payload for ${kind} is not an object`); - } - if (!isOptionalPlainObject(k.last)) { - throw new CommandExecutionError(`Chess.com stats payload for ${kind}.last is not an object`); - } - if (!isOptionalPlainObject(k.best)) { - throw new CommandExecutionError(`Chess.com stats payload for ${kind}.best is not an object`); - } - if (!isOptionalPlainObject(k.record)) { - throw new CommandExecutionError(`Chess.com stats payload for ${kind}.record is not an object`); - } - const record = isPlainObject(k.record) ? k.record : {}; - return { - kind: kind.replace(/^chess_/, ''), - rating_current: k.last?.rating ?? '', - rating_best: k.best?.rating ?? '', - wins: record.win ?? '', - losses: record.loss ?? '', - draws: record.draw ?? '', - }; -} - -/** Parse an end_time epoch (seconds) into YYYY-MM-DD. */ -export function formatDate(epochSeconds) { - if (!epochSeconds || typeof epochSeconds !== 'number') return ''; - return new Date(epochSeconds * 1000).toISOString().slice(0, 10); -} - -/** - * Pull "Reti Opening: Nimzo-Larsen Variation" out of the Chess.com eco URL - * (`https://www.chess.com/openings/Reti-Opening-Nimzo-Larsen-Variation-2...g6-...`). - * Returns '' for short-code eco values (`A01`) where no name is encoded. - */ -export function openingName(eco) { - if (typeof eco !== 'string' || !eco.startsWith('http')) return ''; - const tail = eco.replace(/\/+$/, '').split('/').pop() || ''; - if (!tail) return ''; - const namePart = tail.match(/^([^.]+?)(?:-\d|\.\.\.|$)/); - const cleaned = (namePart ? namePart[1] : tail).replace(/-/g, ' ').trim(); - return cleaned; -} - -/** - * Map a Chess.com game record (from the monthly archive) to a flat row. - * The viewer perspective controls win/loss orientation. - */ -export function mapGameRow(game, viewerUsername) { - if (!isPlainObject(game)) { - throw new CommandExecutionError('Chess.com game archive entry is not an object'); - } - if (typeof game.url !== 'string' || !/^https:\/\/www\.chess\.com\/game\/(?:live|daily)\/\d+(?:$|[/?#])/i.test(game.url)) { - throw new CommandExecutionError('Chess.com game archive entry is missing a stable game URL'); - } - const white = game?.white || {}; - const black = game?.black || {}; - if (!isPlainObject(white) || !isPlainObject(black)) { - throw new CommandExecutionError('Chess.com game archive entry has malformed player objects'); - } - if (typeof white.username !== 'string' || !white.username.trim() || typeof black.username !== 'string' || !black.username.trim()) { - throw new CommandExecutionError('Chess.com game archive entry is missing stable player identities'); - } - const viewerLower = String(viewerUsername || '').toLowerCase(); - const viewerIsWhite = String(white.username || '').toLowerCase() === viewerLower; - const viewerIsBlack = String(black.username || '').toLowerCase() === viewerLower; - if (!viewerIsWhite && !viewerIsBlack) { - throw new CommandExecutionError('Chess.com game archive entry does not include the requested player'); - } - const me = viewerIsWhite ? white : black; - const opp = viewerIsWhite ? black : white; - const eco = game?.eco || ''; - return { - date: formatDate(game?.end_time), - time_class: game?.time_class || '', - rated: game?.rated === true, - my_color: viewerIsWhite ? 'white' : 'black', - my_rating: me?.rating ?? '', - my_result: me?.result || '', - opponent: opp?.username || '', - opponent_rating: opp?.rating ?? '', - accuracy_white: typeof game?.accuracies?.white === 'number' ? game.accuracies.white : '', - accuracy_black: typeof game?.accuracies?.black === 'number' ? game.accuracies.black : '', - eco, - opening_name: openingName(eco), - url: game?.url || '', - }; -} - -export const __test__ = { - validateUsername, - parseGameUrl, - isPlainObject, - isOptionalPlainObject, - chessApi, - summarizeStats, - formatDate, - mapGameRow, - openingName, -}; diff --git a/plugins/chess/webcmd-plugin.json b/plugins/chess/webcmd-plugin.json deleted file mode 100644 index 66c5d6bf..00000000 --- a/plugins/chess/webcmd-plugin.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "chess", - "version": "0.1.0", - "description": "Webcmd commands for chess", - "webcmd": ">=0.5.3", - "author": { - "name": "WebCMD Agent", - "handle": "agentrhq" - } -} diff --git a/plugins/cincinnati/README.md b/plugins/cincinnati/README.md deleted file mode 100644 index 99f14204..00000000 --- a/plugins/cincinnati/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# webcmd-plugin-cincinnati - -University of Cincinnati postgraduate course export adapter. - -## Install - -```bash -webcmd plugin install github:agentrhq/webcmd/cincinnati -``` - -## Command - -| Command | Description | -| --- | --- | -| `webcmd cincinnati export-postgraduate-courses --count 10 -f csv` | Export postgraduate courses in the shared 52-column CSV schema | - -## Examples - -```bash -webcmd cincinnati export-postgraduate-courses --count 10 -f json -webcmd cincinnati export-postgraduate-courses --degree-level masters -f csv -``` diff --git a/plugins/cincinnati/export-postgraduate-courses.js b/plugins/cincinnati/export-postgraduate-courses.js deleted file mode 100644 index 21f19b3a..00000000 --- a/plugins/cincinnati/export-postgraduate-courses.js +++ /dev/null @@ -1,451 +0,0 @@ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { - ArgumentError, - CommandExecutionError, -} from '@agentrhq/webcmd/errors'; - -const BASE = 'https://www.grad.uc.edu'; -const UNIVERSITY = 'University of Cincinnati'; -const CHECKED_DATE = '2026-08-03'; -const PROGRAM_FINDER = `${BASE}/`; -const PROGRAM_JSON = `${BASE}/content/grad/jcr:content/main/responsive_section_2/par/program-finder.ListAllPrograms.json`; -const REQUIREMENTS = 'https://www.admissions.uc.edu/apply/graduate/requirements.html'; -const GRAD_ADMISSION = 'https://www.admissions.uc.edu/apply/graduate.html'; -const DEGREE_LEVELS = new Set(['all', 'masters', 'certificate', 'diploma', 'professional', 'doctorate']); -const REQUEST_TIMEOUT_MS = 25000; -const DETAIL_CONCURRENCY = 10; -const UC_ENGLISH_WAIVER_COUNTRIES = 'Anguilla | Antigua and Barbuda | Australia | Bahamas | Barbados | Belize | Bermuda | Botswana | Cameroon | Canada (except Quebec) | Cayman Islands | Denmark | Dominica | Fiji | Finland | Gambia | Ghana | Gibraltar | Grenada | Guyana | Ireland | Jamaica | Kenya | Lesotho | Liberia | Malawi | Malta | Mauritius | Montserrat | Namibia | Netherlands | New Zealand | Nigeria | Norway | Papua New Guinea | Rwanda | Scotland | Seychelles | Sierra Leone | Singapore | Solomon Islands | South Africa | St. Kitts and Nevis | St. Lucia | St. Vincent and the Grenadines | Swaziland | Sweden | Tanzania | Tonga | Trinidad and Tobago | Turks and Caicos Islands | Uganda | United States | United Kingdom | Vanuatu | Virgin Islands | Wales | Zambia | Zimbabwe'; - -const COLUMNS = [ - 'Course Name', - 'Course URL', - 'University \nname', - 'Intake Month', - 'Substream/\nSpecialisation', - 'App fees', - 'Degree Level', - 'Study Level', - 'Duration\n(in months)', - 'Study option', - 'Program Type', - 'Partner', - 'Tution fees \n(per year)', - 'Total Tution \nFees', - 'IELTS \n(Overall & Subscores)', - 'ielts_reading_score', - 'ielts_writing_score', - 'ielts_listening_score', - 'ielts_speaking_score', - 'TOEFL\n(Overall & Subscores)', - 'toefl_reading_score', - 'toefl_writing_score', - 'toefl_listening_score', - 'toefl_speaking_score', - 'PTE\n(Overall & Subscores)', - 'pte_reading_score', - 'pte_writing_score', - 'pte_listening_score', - 'pte_speaking_score', - 'Duolingo\n(Overall & Subscores)', - 'duolingo_comprehension_score', - 'duolingo_literacy_score', - 'duolingo_conversation_score', - 'duolingo_production_score', - 'Is Waiver \nProvided?', - 'Waiver Info', - 'Is MOI \naccepted?', - 'Share list, if any', - 'GRE Required', - 'GMAT Required', - 'GRE/GMAT Scores', - '12th scores', - 'Min UG score', - '15 years of\nEducation Allowed?', - 'Gap Years', - 'Backlogs', - 'Work \nExperience \nRequired?', - 'Main Entry \nRequirements', - 'Status', - 'Intake status(open/close)\n(eg: Fall (september)-Open\nSpring(January)- Closed)', - 'Remarks (if any)', - 'Reference Links (if any)', -]; - -const CSV_OUTPUT = process.argv.some((arg, index, argv) => - /^(?:-f|--format)=csv$/.test(arg) || ((arg === '-f' || arg === '--format') && argv[index + 1] === 'csv') -); -const OUTPUT_COLUMNS = CSV_OUTPUT - ? COLUMNS.map((column) => /[,"\r\n]/.test(column) ? `"${column.replace(/"/g, '""')}"` : column) - : COLUMNS; - -function decodeHtml(value = '') { - return String(value) - .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n))) - .replace(/&#x([\da-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16))) - .replace(/ /gi, ' ') - .replace(/&/gi, '&') - .replace(/"/gi, '"') - .replace(/'|�*39;|’/gi, "'") - .replace(/‘/gi, "'") - .replace(/–|—/gi, '-') - .replace(/</gi, '<') - .replace(/>/gi, '>'); -} - -function text(value = '') { - return decodeHtml(String(value) - .replace(/]*>[\s\S]*?<\/script>/gi, ' ') - .replace(/]*>[\s\S]*?<\/style>/gi, ' ') - .replace(/<[^>]+>/g, ' ')) - .replace(/\s+/g, ' ') - .trim(); -} - -function parseOptions(args) { - const degreeLevel = String(args['degree-level'] ?? 'all').toLowerCase(); - if (!DEGREE_LEVELS.has(degreeLevel)) { - throw new ArgumentError(`--degree-level must be one of: ${[...DEGREE_LEVELS].join(', ')}`); - } - if (args.count === undefined || args.count === null || args.count === '') return { degreeLevel, count: null }; - const count = Number(args.count); - if (!Number.isInteger(count) || count <= 0) throw new ArgumentError('--count must be a positive integer'); - return { degreeLevel, count }; -} - -function canonicalUrl(value) { - const url = new URL(value, BASE); - if (!/(^|\.)uc\.edu$/i.test(url.hostname)) return ''; - url.protocol = 'https:'; - url.hash = ''; - return url.href; -} - -function durationMonths(duration, unit) { - const n = Number(duration); - if (!Number.isFinite(n) || n <= 0) return ''; - const u = String(unit || '').toLowerCase(); - if (u.startsWith('year')) return String(Math.round(n * 12)); - if (u.startsWith('month')) return String(Math.round(n)); - if (u.startsWith('semester')) return String(Math.round(n * 4)); - return ''; -} - -function degreeTags(program) { - const degree = String(program.degree || '').toUpperCase(); - const bucket = String(program.degreeBucket || '').toLowerCase(); - const name = String(program.planDescription || '').toLowerCase(); - const tags = new Set(); - - if (bucket.includes('master') || /^M/.test(degree) || ['EDS', 'LLM'].includes(degree)) tags.add('masters'); - if (bucket.includes('certificate') || ['GC', 'GCM', 'PB', 'MC'].includes(degree)) tags.add('certificate'); - if (bucket.includes('doctoral') || /doctor/.test(name) || ['PHD', 'EDD', 'DNP', 'DMA', 'DCLS', 'OTD', 'PHARMD', 'DPT', 'SLPD'].includes(degree)) tags.add('doctorate'); - if (degree === 'AD' || /diploma/.test(name)) tags.add('diploma'); - if (['AUD', 'DNP', 'DPT', 'JD', 'LLM', 'MBA', 'MHA', 'MPH', 'MSN', 'MSW', 'OTD', 'PHARMD', 'SLPD'].includes(degree)) tags.add('professional'); - - return [...tags]; -} - -function applicationFee(program) { - const org = String(program.organizationDescription || ''); - if (program.degreeBucket === 'Graduate Certificate') return 'USD 20 domestic / USD 25 international for Graduate Certificates'; - if (org.includes('Engineering')) return 'USD 75 domestic / USD 80 international for CEAS'; - if (/Physiology/i.test(program.planDescription || '') && program.degree === 'MS') return 'USD 140 for Physiology (MS)'; - return 'USD 65 domestic / USD 70 international for most graduate degree programs'; -} - -async function fetchText(url, marker = '') { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); - try { - const response = await fetch(url, { - headers: { 'User-Agent': 'Mozilla/5.0 (compatible; Webcmd Cincinnati public data export)' }, - redirect: 'follow', - signal: controller.signal, - }); - const contentType = response.headers.get('content-type') || ''; - if (!response.ok || !contentType.includes('text/html')) { - throw new CommandExecutionError(`UC page request failed for ${url}: HTTP ${response.status}, ${contentType || 'unknown content type'}`); - } - const html = await response.text(); - if (marker && !html.includes(marker)) throw new CommandExecutionError(`UC page structure changed for ${url}: missing ${marker}`); - return { html, finalUrl: response.url }; - } catch (error) { - if (error instanceof CommandExecutionError) throw error; - throw new CommandExecutionError(`UC page request failed for ${url}: ${error.message}`); - } finally { - clearTimeout(timeout); - } -} - -async function fetchPrograms() { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); - try { - const response = await fetch(PROGRAM_JSON, { - headers: { 'User-Agent': 'Mozilla/5.0 (compatible; Webcmd Cincinnati public data export)' }, - redirect: 'follow', - signal: controller.signal, - }); - if (!response.ok) throw new CommandExecutionError(`UC program finder request failed: HTTP ${response.status}`); - const data = await response.json(); - const programs = data?.summaryArray; - if (!Array.isArray(programs) || programs.length < 300) { - throw new CommandExecutionError(`UC program finder JSON shape changed: rows=${programs?.length ?? 'missing'}`); - } - return programs - .map((item) => ({ ...(item.baseInfo || {}), generalInterestAreas: item.generalInterestAreas || [] })) - .filter((item) => ['Graduate', 'Law'].includes(item.career)); - } catch (error) { - if (error instanceof CommandExecutionError) throw error; - throw new CommandExecutionError(`UC program finder request failed: ${error.message}`); - } finally { - clearTimeout(timeout); - } -} - -function absoluteUcUrl(value, baseUrl) { - try { - return canonicalUrl(new URL(decodeHtml(value), baseUrl).href); - } catch { - return ''; - } -} - -function linksFrom(html, baseUrl) { - return [...String(html).matchAll(/]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi)] - .map((match) => ({ url: absoluteUcUrl(match[1], baseUrl), label: text(match[2]) })) - .filter((link) => link.url && link.label); -} - -function blocks(html) { - return decodeHtml(String(html) - .replace(/]*>([\s\S]*?)<\/h\1>/gi, (_, level, content) => `\n\n@@H${level} ${text(content)}\n`) - .replace(/]*>/gi, '\n- ') - .replace(/<\/(?:p|div|section|tr|table)>/gi, '\n') - .replace(//gi, '\n') - .replace(/]*>[\s\S]*?<\/script>/gi, ' ') - .replace(/]*>[\s\S]*?<\/style>/gi, ' ') - .replace(/<[^>]+>/g, ' ')) - .replace(/[ \t]+/g, ' ') - .replace(/\n[ \t]+/g, '\n') - .trim(); -} - -function sectionFrom(blockText, headingPattern) { - const lines = blockText.split(/\n+/).map((line) => line.trim()).filter(Boolean); - const start = lines.findIndex((line) => /^@@H[1-6]\s+/.test(line) && headingPattern.test(line.replace(/^@@H[1-6]\s+/, ''))); - if (start < 0) return ''; - const section = []; - for (const line of lines.slice(start + 1)) { - if (/^@@H[1-6]\s+/.test(line)) break; - section.push(line); - } - return section.join(' | ').replace(/\s+/g, ' ').trim(); -} - -function firstMatch(value, pattern) { - return String(value || '').match(pattern)?.[0]?.trim() || ''; -} - -function programSpecificEnglish(sourceText) { - const t = String(sourceText || ''); - return { - ielts: firstMatch(t, /IELTS:?\s*(?:minimum score of\s*)?6\.5 overall band/i), - toefl: firstMatch(t, /TOEFL:?\s*(?:minimum score of\s*)?4\.5 \(on the 1-6 scale\) or 80 iBT \(on the 0-120 scale\)/i), - pte: firstMatch(t, /PTE:?\s*(?:minimum score of\s*)?54/i), - duolingo: firstMatch(t, /Duolingo \(DET\):?\s*(?:minimum score of\s*)?110/i), - }; -} - -function programSpecificFees(sourceText) { - const fees = String(sourceText || '').split('|') - .map((line) => line.replace(/^-\s*/, '').replace(/\s+/g, ' ').trim()) - .filter((line) => /(?:application fee|matriculation fee)/i.test(line) && /\$[0-9]/.test(line)); - return [...new Set(fees)].join(' | '); -} - -function deadlineSummary(sourceText) { - const textValue = String(sourceText || ''); - const rows = [...textValue.matchAll(/(Spring|Summer|Fall)\s+20\d{2}\s+\|\s+[^|]+\|\s+[^|]+\|\s+[^|]+/gi)] - .map((match) => match[0].replace(/\s+/g, ' ').trim()); - return rows.slice(0, 4).join(' | '); -} - -function admissionLinkFor(html, detailUrl) { - const detail = new URL(detailUrl); - const detailDir = detail.pathname.replace(/\/[^/]*$/, '/'); - const candidates = linksFrom(html, detailUrl) - .filter((link) => /admission|deadline|requirement/i.test(link.label) && !/apply$/i.test(link.label)) - .map((link) => ({ - ...link, - score: - (new URL(link.url).pathname.startsWith(detailDir) ? 10 : 0) - + (/learn about admissions|view application deadlines/i.test(link.label) ? 5 : 0) - + (/admissions?\.html$/i.test(link.url) ? 2 : 0), - })) - .sort((a, b) => b.score - a.score); - return candidates[0]; -} - -async function enrichProgram(program) { - const detailUrl = canonicalUrl(program.learnMorePath); - if (!detailUrl) return { ...program, detailUrl: '', admissionUrl: '', detailText: '', admissionText: '', detailError: 'Missing official program detail URL' }; - try { - const detail = await fetchText(detailUrl, ' [column, ''])); - const url = program.detailUrl || canonicalUrl(program.learnMorePath); - const interestAreas = (program.generalInterestAreas || []).map((area) => area.name).filter(Boolean); - const degree = [program.degreeBucket, program.degree ? `(${program.degree})` : ''].filter(Boolean).join(' '); - const admissionRequirements = sectionFrom(program.admissionText, /^Admission Requirements$/i); - const applicationProcess = sectionFrom(program.admissionText, /^Application Process$/i); - const deadlines = sectionFrom(program.admissionText, /^Application Deadlines$/i); - const combinedAdmissionText = [program.admissionText, program.detailText].filter(Boolean).join(' | '); - const english = programSpecificEnglish(combinedAdmissionText); - const feeDetails = programSpecificFees(combinedAdmissionText); - const references = [ - `Course: ${url}`, - program.admissionUrl ? `Program admissions/deadlines: ${program.admissionUrl}` : '', - `Graduate Program Finder: ${PROGRAM_FINDER}`, - `Program JSON: ${PROGRAM_JSON}`, - `Graduate Admission: ${GRAD_ADMISSION}`, - `Graduate Requirements: ${REQUIREMENTS}`, - ].filter(Boolean); - - row['Course Name'] = text(program.planDescription); - row['Course URL'] = url; - row['University \nname'] = UNIVERSITY; - row['Substream/\nSpecialisation'] = interestAreas.join(' | '); - row['App fees'] = feeDetails || applicationFee(program); - row['Degree Level'] = degree; - row['Study Level'] = 'PG'; - row['Duration\n(in months)'] = durationMonths(program.duration, program.durationUnit); - row['Study option'] = text(program.location); - row['Program Type'] = text(program.organizationDescription); - row['Tution fees \n(per year)'] = 'Not available as a single value in official UC program data'; - row['Total Tution \nFees'] = 'Not available as an official total in UC program data'; - row['IELTS \n(Overall & Subscores)'] = english.ielts || 'UC minimum: IELTS 6.5 overall band; program page did not publish a higher value'; - row['TOEFL\n(Overall & Subscores)'] = english.toefl || 'UC minimum: TOEFL 80 iBT or 4.5 on TOEFL 1-6 scale; program page did not publish a higher value'; - row['PTE\n(Overall & Subscores)'] = english.pte || 'UC minimum: PTE 54; program page did not publish a higher value'; - row['Duolingo\n(Overall & Subscores)'] = english.duolingo || 'UC minimum: Duolingo English Test 110; program page did not publish a higher value'; - row['Is Waiver \nProvided?'] = 'Yes'; - row['Waiver Info'] = 'Automatic English-proficiency waiver for listed English-speaking countries; additional waiver options include qualifying English-instructing institution documentation.'; - row['Is MOI \naccepted?'] = 'Waiver may be requested with documentation that the entire institution is English-instructing'; - row['Share list, if any'] = UC_ENGLISH_WAIVER_COUNTRIES; - row['GRE Required'] = 'Not available as a single value on official program page'; - row['GMAT Required'] = 'Not available as a single value on official program page'; - row['GRE/GMAT Scores'] = 'Not available as a single value on official program page'; - row['12th scores'] = 'Not applicable (graduate admission)'; - row['Min UG score'] = "Bachelor's degree or higher; at least a B average is recommended"; - row['15 years of\nEducation Allowed?'] = 'Reduced-credit bachelor’s degrees may be accepted; determination is at the program level'; - row['Work \nExperience \nRequired?'] = /resume|cv/i.test(applicationProcess) ? 'Resume/CV required by official program admissions page' : 'Not available as a single value on official program page'; - row['Main Entry \nRequirements'] = [admissionRequirements, applicationProcess].filter(Boolean).join(' | ') - || "Bachelor's degree or higher from an accredited institution or international equivalent | Application and fee | Transcripts | Program-specific requirements and deadlines"; - row['Status'] = 'Active'; - row['Intake status(open/close)\n(eg: Fall (september)-Open\nSpring(January)- Closed)'] = deadlineSummary(deadlines) || 'Not available as a single value on official program page'; - const notPublished = 'Not available on official UC program page'; - for (const column of [ - 'Substream/\nSpecialisation', - 'Intake Month', - 'Partner', - 'ielts_reading_score', - 'ielts_writing_score', - 'ielts_listening_score', - 'ielts_speaking_score', - 'toefl_reading_score', - 'toefl_writing_score', - 'toefl_listening_score', - 'toefl_speaking_score', - 'pte_reading_score', - 'pte_writing_score', - 'pte_listening_score', - 'pte_speaking_score', - 'duolingo_comprehension_score', - 'duolingo_literacy_score', - 'duolingo_conversation_score', - 'duolingo_production_score', - 'Gap Years', - 'Backlogs', - ]) { - if (!row[column]) row[column] = column === 'Partner' ? 'Not applicable unless listed by official program source' : notPublished; - } - row['Remarks (if any)'] = [ - `Checked ${CHECKED_DATE}; official UC program-finder JSON, program detail page, linked program admissions/deadline pages, and UC graduate admissions requirements.`, - program.detailError ? `Program detail enrichment unavailable: ${program.detailError}` : '', - 'University-wide English scores are used only when the official program page does not publish a higher/specific value. Exact test subscores, gap years, and backlogs are not available university-wide.', - ].filter(Boolean).join(' | '); - row['Reference Links (if any)'] = references.join(' | '); - return row; -} - -function validateRecord(row) { - for (const column of ['Course Name', 'Course URL', 'University \nname', 'Degree Level', 'Study Level', 'Reference Links (if any)']) { - if (!row[column]?.trim()) throw new CommandExecutionError(`UC row is missing required field: ${column}`); - } - if (row['Study Level'] !== 'PG') throw new CommandExecutionError('UC row Study Level must be PG'); - if (!/^https:\/\/(?:www\.)?.*uc\.edu\//i.test(row['Course URL'])) { - throw new CommandExecutionError(`UC row has invalid Course URL: ${row['Course URL']}`); - } - return row; -} - -cli({ - site: 'cincinnati', - name: 'export-postgraduate-courses', - description: 'Export University of Cincinnati graduate and professional programs from official public sources.', - access: 'read', - example: 'webcmd cincinnati export-postgraduate-courses --degree-level masters --count 10 -f csv', - domain: 'www.grad.uc.edu', - strategy: Strategy.PUBLIC, - browser: false, - args: [ - { name: 'degree-level', type: 'string', default: 'all', help: 'all, masters, certificate, diploma, professional, or doctorate' }, - { name: 'count', type: 'int', required: false, help: 'Positive maximum number of programs after filtering and deduplication' }, - ], - columns: OUTPUT_COLUMNS, - func: async (args) => { - const { degreeLevel, count } = parseOptions(args); - const selected = []; - const seen = new Set(); - const candidates = []; - for (const program of await fetchPrograms()) { - const tags = degreeTags(program); - if (!tags.length || (degreeLevel !== 'all' && !tags.includes(degreeLevel))) continue; - candidates.push(program); - if (count !== null && candidates.length >= count) break; - } - for (const program of await enrichPrograms(candidates)) { - const row = validateRecord(normalizeRecord(program)); - if (seen.has(row['Course URL'])) continue; - seen.add(row['Course URL']); - selected.push(CSV_OUTPUT - ? Object.fromEntries(COLUMNS.map((column, index) => [OUTPUT_COLUMNS[index], row[column]])) - : row); - } - return selected; - }, -}); diff --git a/plugins/cincinnati/package.json b/plugins/cincinnati/package.json deleted file mode 100644 index eb8253c9..00000000 --- a/plugins/cincinnati/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "webcmd-plugin-cincinnati", - "version": "0.1.0", - "type": "module", - "description": "University of Cincinnati postgraduate course export adapter", - "peerDependencies": { - "@agentrhq/webcmd": ">=0.5.2" - } -} diff --git a/plugins/cincinnati/webcmd-plugin.json b/plugins/cincinnati/webcmd-plugin.json deleted file mode 100644 index 5d546eaa..00000000 --- a/plugins/cincinnati/webcmd-plugin.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "cincinnati", - "version": "0.1.0", - "description": "University of Cincinnati postgraduate course export adapter", - "webcmd": ">=0.5.2", - "author": { - "name": "WebCMD Agent", - "handle": "agentrhq" - } -} diff --git a/plugins/claude/README.md b/plugins/claude/README.md deleted file mode 100644 index 5159a9f6..00000000 --- a/plugins/claude/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# webcmd-plugin-claude - -Webcmd commands for claude. - -## Install - -```bash -webcmd plugin install github:agentrhq/webcmd/claude -``` - -## Commands - -| Command | Description | -| --- | --- | -| `webcmd claude ask` | Send a prompt to Claude and get the response | -| `webcmd claude detail` | Open a Claude conversation by ID and read its messages | -| `webcmd claude history` | List conversation history from Claude /recents | -| `webcmd claude login` | Open claude login | -| `webcmd claude new` | Start a new conversation in Claude | -| `webcmd claude read` | Read the current Claude conversation | -| `webcmd claude send` | Send a prompt to Claude without waiting for the response | -| `webcmd claude status` | Check Claude page availability and login state | -| `webcmd claude whoami` | Show the current logged-in claude account | diff --git a/plugins/claude/ask.js b/plugins/claude/ask.js deleted file mode 100644 index 6ac06b37..00000000 --- a/plugins/claude/ask.js +++ /dev/null @@ -1,164 +0,0 @@ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { ArgumentError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors'; -import { - CLAUDE_DOMAIN, CLAUDE_URL, COMPOSER_SELECTOR, MESSAGE_SELECTOR, - ensureOnClaude, selectModel, setAdaptiveThinking, - sendMessage, sendWithFile, getBubbleCount, waitForResponse, parseBoolFlag, withRetry, - ensureClaudeComposer, requireNonEmptyPrompt, requirePositiveInt, -} from './utils.js'; - -export const askCommand = cli({ - site: 'claude', - name: 'ask', - access: 'write', - description: 'Send a prompt to Claude and get the response', - domain: CLAUDE_DOMAIN, - strategy: Strategy.COOKIE, - browser: true, - siteSession: 'persistent', - navigateBefore: false, - args: [ - { name: 'prompt', positional: true, required: true, help: 'Prompt to send' }, - { name: 'timeout', type: 'int', default: 120, help: 'Max seconds to wait for response' }, - { name: 'new', type: 'boolean', default: false, help: 'Start a new chat before sending' }, - { name: 'model', default: 'sonnet', choices: ['sonnet', 'opus', 'haiku'], help: 'Model to use: sonnet, opus, or haiku' }, - { name: 'think', type: 'boolean', default: false, help: 'Enable Adaptive thinking' }, - { - name: 'file', - help: 'Attach a file (image, PDF, text) with the prompt', - file: { - direction: 'input', - pathKind: 'file', - multiple: false, - contentTypes: [ - 'application/pdf', - 'text/plain', - 'text/markdown', - 'text/csv', - 'application/json', - 'image/jpeg', - 'image/png', - 'image/gif', - 'image/webp', - ], - maxBytes: 26_214_400, - }, - }, - ], - columns: ['response'], - - func: async (page, kwargs) => { - const prompt = requireNonEmptyPrompt(kwargs.prompt, 'claude ask'); - const timeoutSeconds = requirePositiveInt( - Number(kwargs.timeout ?? 120), - 'claude ask --timeout', - 'Example: webcmd claude ask "hello" --timeout 120', - ); - const timeoutMs = timeoutSeconds * 1000; - const wantThink = parseBoolFlag(kwargs.think); - - if (parseBoolFlag(kwargs.new)) { - await page.goto(CLAUDE_URL); - try { - await page.wait({ selector: COMPOSER_SELECTOR, timeout: 8 }); - } catch { - // Composer didn't mount; ensureClaudeComposer below surfaces a typed error. - } - } else { - const navigated = await ensureOnClaude(page); - if (navigated) { - // Workspace was recycled; try to resume the most recent - // conversation instead of starting a new one. - await page.evaluate(`(() => { - var link = document.querySelector('a[href*="/chat/"]'); - if (link) link.click(); - })()`); - // Wait for the resumed conversation to render messages, or - // fall through if the link click had no effect (no recents). - try { - await page.wait({ selector: MESSAGE_SELECTOR, timeout: 5 }); - } catch { - // No prior conversation; ensureClaudeComposer still requires composer below. - } - } - } - - // ensureClaudeComposer reads composer presence directly via getPageState, - // so the previous standalone 2 s settle is redundant. - await withRetry(() => ensureClaudeComposer(page, 'Claude ask requires a visible composer on the current page.')); - - // Model selector is only available on the new-chat page, not inside - // an existing conversation. Skip it when we resumed a prior thread. - const currentUrl = await page.evaluate('window.location.href') || ''; - const inConversation = currentUrl.includes('/chat/'); - const modelExplicit = kwargs.__webcmdOptionSources?.model === 'cli'; - - const wantModel = kwargs.model || 'sonnet'; - if (inConversation && modelExplicit) { - throw new ArgumentError( - `Cannot switch to ${wantModel} model inside an existing conversation.`, - 'Re-run with --new to start a fresh chat before selecting a model.', - ); - } - - if (!inConversation) { - const modelResult = await withRetry(() => selectModel(page, wantModel)); - if (!modelResult?.ok) { - if (modelResult?.upgrade) { - throw new ArgumentError( - `${wantModel} model requires a paid Claude plan.`, - 'Pick --model sonnet or --model haiku, or upgrade your account.', - ); - } - throw new CommandExecutionError(`Could not switch to ${wantModel} model`); - } - // Post-toggle settle dropped — the next CDP eval (setAdaptiveThinking) gives - // React enough time to flush aria-checked updates between rountrips. - } - - const thinkResult = await withRetry(() => setAdaptiveThinking(page, wantThink)); - if (!thinkResult?.ok && wantThink) { - throw new CommandExecutionError('Could not enable Adaptive thinking'); - } - // Post-toggle settle dropped — the next CDP eval (sendMessage / sendWithFile) - // gives React enough time to flush aria-checked updates. - - if (kwargs.file) { - const baseline = await withRetry(() => getBubbleCount(page)); - try { - const fileResult = await sendWithFile(page, kwargs.file, prompt); - if (fileResult && !fileResult.ok) { - throw new CommandExecutionError(fileResult.reason || 'Failed to attach file'); - } - } catch (err) { - // SPA navigates after send; "Promise was collected" means send succeeded - if (!String(err?.message || err).includes('Promise was collected')) throw err; - } - // Pre-waitForResponse settle dropped — waitForResponse's first 3 s polling - // tick covers the same window without an unconditional sleep. - const result = await waitForResponse(page, baseline, prompt, timeoutMs); - if (!result) { - throw new EmptyResultError( - 'claude ask', - `No Claude response appeared within ${timeoutSeconds}s. Re-run with a higher --timeout if the model is still generating.`, - ); - } - return [{ response: result }]; - } - - const baseline = await withRetry(() => getBubbleCount(page)); - const sendResult = await withRetry(() => sendMessage(page, prompt)); - if (!sendResult?.ok) { - throw new CommandExecutionError(sendResult?.reason || 'Failed to send message'); - } - - const result = await waitForResponse(page, baseline, prompt, timeoutMs); - if (!result) { - throw new EmptyResultError( - 'claude ask', - `No Claude response appeared within ${timeoutSeconds}s. Re-run with a higher --timeout if the model is still generating.`, - ); - } - return [{ response: result }]; - }, -}); diff --git a/plugins/claude/auth.js b/plugins/claude/auth.js deleted file mode 100644 index 173eee0c..00000000 --- a/plugins/claude/auth.js +++ /dev/null @@ -1,49 +0,0 @@ -import { AuthRequiredError, CommandExecutionError } from '@agentrhq/webcmd/errors'; -import { registerSiteAuthCommands } from '@agentrhq/webcmd/plugin-runtime'; - -async function hasClaudeSessionCookie(page) { - const cookies = await page.getCookies({ url: 'https://claude.ai' }); - return cookies.some(c => c.name === 'sessionKey' && c.value); -} - -async function verifyClaudeIdentity(page) { - if (!await hasClaudeSessionCookie(page)) { - throw new AuthRequiredError('claude.ai', 'Claude sessionKey cookie missing'); - } - await page.goto('https://claude.ai/'); - await page.wait(2); - const result = await page.evaluate(`(async () => { - try { - const res = await fetch('/api/organizations', { credentials: 'include' }); - if (res.status === 401 || res.status === 403) { - return { kind: 'auth', detail: 'Claude /api/organizations HTTP ' + res.status }; - } - if (!res.ok) return { kind: 'http', httpStatus: res.status }; - const d = await res.json(); - if (!Array.isArray(d) || d.length === 0) { - return { kind: 'auth', detail: 'Claude /api/organizations empty' }; - } - const userIdCookie = (document.cookie.split('; ').find(c => c.startsWith('ajs_user_id=')) || '').split('=')[1] || ''; - const activeOrgCookie = (document.cookie.split('; ').find(c => c.startsWith('lastActiveOrg=')) || '').split('=')[1] || ''; - const activeOrg = d.find(o => o.uuid === activeOrgCookie) || d[0]; - return { ok: true, user_id: userIdCookie, org_name: activeOrg.name || '', org_uuid: activeOrg.uuid || '' }; - } catch (e) { - return { kind: 'exception', detail: String(e && e.message || e) }; - } - })()`); - if (result?.kind === 'auth') throw new AuthRequiredError('claude.ai', result.detail); - if (result?.kind === 'http') throw new CommandExecutionError(`HTTP ${result.httpStatus} from /api/organizations`); - if (result?.kind === 'exception') throw new CommandExecutionError(`Claude whoami failed: ${result.detail}`); - if (!result?.ok) throw new CommandExecutionError(`Unexpected Claude probe: ${JSON.stringify(result)}`); - if (!result.user_id) throw new AuthRequiredError('claude.ai', 'Claude session incomplete — ajs_user_id cookie missing'); - return { user_id: String(result.user_id), org_name: String(result.org_name), org_uuid: String(result.org_uuid) }; -} - -registerSiteAuthCommands({ - site: 'claude', - domain: 'claude.ai', - loginUrl: 'https://claude.ai/login', - columns: ['user_id', 'org_name', 'org_uuid'], - quickCheck: hasClaudeSessionCookie, - verify: verifyClaudeIdentity, -}); diff --git a/plugins/claude/detail.js b/plugins/claude/detail.js deleted file mode 100644 index 46b38b0a..00000000 --- a/plugins/claude/detail.js +++ /dev/null @@ -1,38 +0,0 @@ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { EmptyResultError } from '@agentrhq/webcmd/errors'; -import { CLAUDE_DOMAIN, MESSAGE_SELECTOR, getVisibleMessages, ensureClaudeLogin, requireConversationId } from './utils.js'; - -export const detailCommand = cli({ - site: 'claude', - name: 'detail', - access: 'read', - description: 'Open a Claude conversation by ID and read its messages', - domain: CLAUDE_DOMAIN, - strategy: Strategy.COOKIE, - browser: true, - siteSession: 'persistent', - navigateBefore: false, - args: [ - { name: 'id', positional: true, required: true, help: 'Conversation ID (UUID from /chat/)' }, - ], - columns: ['Index', 'Role', 'Text'], - - func: async (page, kwargs) => { - const id = requireConversationId(kwargs.id); - - await page.goto(`https://claude.ai/chat/${id}`); - // Wait for the first assistant message bubble to render instead of a - // fixed 4 s sleep. Swallow the timeout so empty conversations and - // login redirects fall through to ensureClaudeLogin / EmptyResultError. - try { - await page.wait({ selector: MESSAGE_SELECTOR, timeout: 10 }); - } catch { - // Empty conversation, missing access, or login redirect — handled below. - } - await ensureClaudeLogin(page, 'Claude detail requires a logged-in Claude session.'); - - const messages = await getVisibleMessages(page); - if (messages.length > 0) return messages; - throw new EmptyResultError('claude detail', `No visible Claude messages were found for conversation ${id}.`); - }, -}); diff --git a/plugins/claude/history.js b/plugins/claude/history.js deleted file mode 100644 index d230d7f2..00000000 --- a/plugins/claude/history.js +++ /dev/null @@ -1,33 +0,0 @@ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { EmptyResultError } from '@agentrhq/webcmd/errors'; -import { CLAUDE_DOMAIN, getConversationList, ensureClaudeLogin, requirePositiveInt } from './utils.js'; - -export const historyCommand = cli({ - site: 'claude', - name: 'history', - access: 'read', - description: 'List conversation history from Claude /recents', - domain: CLAUDE_DOMAIN, - strategy: Strategy.COOKIE, - browser: true, - siteSession: 'persistent', - navigateBefore: false, - args: [ - { name: 'limit', type: 'int', default: 20, help: 'Max conversations to show' }, - ], - columns: ['Index', 'Id', 'Title', 'Url'], - - func: async (page, kwargs) => { - const limit = requirePositiveInt( - Number(kwargs.limit ?? 20), - 'claude history --limit', - 'Example: webcmd claude history --limit 20', - ); - const conversations = await getConversationList(page); - await ensureClaudeLogin(page, 'Claude history requires a logged-in Claude session.'); - if (conversations.length === 0) { - throw new EmptyResultError('claude history', 'No Claude conversation history was visible on /recents.'); - } - return conversations.slice(0, limit); - }, -}); diff --git a/plugins/claude/new.js b/plugins/claude/new.js deleted file mode 100644 index a5474d48..00000000 --- a/plugins/claude/new.js +++ /dev/null @@ -1,29 +0,0 @@ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { CLAUDE_DOMAIN, CLAUDE_URL, COMPOSER_SELECTOR, ensureClaudeComposer } from './utils.js'; - -export const newCommand = cli({ - site: 'claude', - name: 'new', - access: 'read', - description: 'Start a new conversation in Claude', - domain: CLAUDE_DOMAIN, - strategy: Strategy.COOKIE, - browser: true, - siteSession: 'persistent', - navigateBefore: false, - args: [], - columns: ['Status'], - - func: async (page) => { - await page.goto(CLAUDE_URL); - // Wait for the composer to mount instead of a fixed 2 s sleep. If it - // never mounts, swallow and let ensureClaudeComposer surface a typed error. - try { - await page.wait({ selector: COMPOSER_SELECTOR, timeout: 8 }); - } catch { - // Login or error page — ensureClaudeComposer below throws AuthRequiredError / CommandExecutionError. - } - await ensureClaudeComposer(page, 'Claude new requires a logged-in Claude session with a visible composer.'); - return [{ Status: 'New chat started' }]; - }, -}); diff --git a/plugins/claude/package.json b/plugins/claude/package.json deleted file mode 100644 index b676c54f..00000000 --- a/plugins/claude/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "webcmd-plugin-claude", - "version": "0.1.0", - "type": "module", - "description": "Webcmd commands for claude", - "peerDependencies": { - "@agentrhq/webcmd": ">=0.5.3" - } -} diff --git a/plugins/claude/read.js b/plugins/claude/read.js deleted file mode 100644 index c6348ed2..00000000 --- a/plugins/claude/read.js +++ /dev/null @@ -1,27 +0,0 @@ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { EmptyResultError } from '@agentrhq/webcmd/errors'; -import { CLAUDE_DOMAIN, ensureOnClaude, getVisibleMessages, ensureClaudeLogin } from './utils.js'; - -export const readCommand = cli({ - site: 'claude', - name: 'read', - access: 'read', - description: 'Read the current Claude conversation', - domain: CLAUDE_DOMAIN, - strategy: Strategy.COOKIE, - browser: true, - siteSession: 'persistent', - navigateBefore: false, - args: [], - columns: ['Index', 'Role', 'Text'], - - func: async (page) => { - // ensureOnClaude now waits for the composer selector; the previous post-nav - // 3 s settle is covered by that event-based wait. - await ensureOnClaude(page); - await ensureClaudeLogin(page, 'Claude read requires a logged-in Claude session.'); - const messages = await getVisibleMessages(page); - if (messages.length > 0) return messages; - throw new EmptyResultError('claude read', 'No visible Claude messages were found in the current conversation.'); - }, -}); diff --git a/plugins/claude/send.js b/plugins/claude/send.js deleted file mode 100644 index ab55dbcf..00000000 --- a/plugins/claude/send.js +++ /dev/null @@ -1,48 +0,0 @@ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { CommandExecutionError } from '@agentrhq/webcmd/errors'; -import { CLAUDE_DOMAIN, CLAUDE_URL, COMPOSER_SELECTOR, ensureOnClaude, sendMessage, parseBoolFlag, withRetry, ensureClaudeComposer, requireNonEmptyPrompt } from './utils.js'; - -export const sendCommand = cli({ - site: 'claude', - name: 'send', - access: 'write', - description: 'Send a prompt to Claude without waiting for the response', - domain: CLAUDE_DOMAIN, - strategy: Strategy.COOKIE, - browser: true, - siteSession: 'persistent', - navigateBefore: false, - args: [ - { name: 'prompt', positional: true, required: true, help: 'Prompt to send' }, - { name: 'new', type: 'boolean', default: false, help: 'Start a new chat before sending' }, - ], - columns: ['Status', 'SubmittedBy', 'InjectedText'], - - func: async (page, kwargs) => { - const prompt = requireNonEmptyPrompt(kwargs.prompt, 'claude send'); - - if (parseBoolFlag(kwargs.new)) { - await page.goto(CLAUDE_URL); - try { - await page.wait({ selector: COMPOSER_SELECTOR, timeout: 8 }); - } catch { - // Composer didn't mount; ensureClaudeComposer below surfaces a typed error. - } - } else { - // ensureOnClaude now waits for the composer selector; the previous - // post-nav 2 s settle is covered by that event-based wait. - await ensureOnClaude(page); - } - await withRetry(() => ensureClaudeComposer(page, 'Claude send requires a visible composer on the current page.')); - - const sendResult = await withRetry(() => sendMessage(page, prompt)); - if (!sendResult?.ok) { - throw new CommandExecutionError(sendResult?.reason || 'Failed to send message'); - } - return [{ - Status: 'Success', - SubmittedBy: sendResult.method || 'send-button', - InjectedText: prompt, - }]; - }, -}); diff --git a/plugins/claude/status.js b/plugins/claude/status.js deleted file mode 100644 index 55df5267..00000000 --- a/plugins/claude/status.js +++ /dev/null @@ -1,26 +0,0 @@ -import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { CLAUDE_DOMAIN, ensureOnClaude, getPageState } from './utils.js'; - -export const statusCommand = cli({ - site: 'claude', - name: 'status', - access: 'read', - description: 'Check Claude page availability and login state', - domain: CLAUDE_DOMAIN, - strategy: Strategy.COOKIE, - browser: true, - siteSession: 'persistent', - navigateBefore: false, - args: [], - columns: ['Status', 'Login', 'Url'], - - func: async (page) => { - await ensureOnClaude(page); - const state = await getPageState(page); - return [{ - Status: state.hasComposer ? 'Connected' : 'Page not ready', - Login: state.isLoggedIn ? 'Yes' : 'No', - Url: state.url, - }]; - }, -}); diff --git a/plugins/claude/test/ask.test.js b/plugins/claude/test/ask.test.js deleted file mode 100644 index 0eb425bc..00000000 --- a/plugins/claude/test/ask.test.js +++ /dev/null @@ -1,338 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { ArgumentError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors'; - -const { - mockEnsureOnClaude, - mockEnsureClaudeComposer, - mockSelectModel, - mockSetAdaptiveThinking, - mockSendMessage, - mockSendWithFile, - mockGetBubbleCount, - mockWaitForResponse, - mockParseBoolFlag, - mockRequireNonEmptyPrompt, - mockRequirePositiveInt, - mockWithRetry, -} = vi.hoisted(() => ({ - mockEnsureOnClaude: vi.fn(), - mockEnsureClaudeComposer: vi.fn(), - mockSelectModel: vi.fn(), - mockSetAdaptiveThinking: vi.fn(), - mockSendMessage: vi.fn(), - mockSendWithFile: vi.fn(), - mockGetBubbleCount: vi.fn(), - mockWaitForResponse: vi.fn(), - mockParseBoolFlag: vi.fn((v) => v === true || v === 'true'), - mockRequireNonEmptyPrompt: vi.fn((v) => String(v ?? '')), - mockRequirePositiveInt: vi.fn((v) => Number(v)), - mockWithRetry: vi.fn(async (fn) => fn()), -})); - -vi.mock('../utils.js', () => ({ - CLAUDE_DOMAIN: 'claude.ai', - CLAUDE_URL: 'https://claude.ai/new', - ensureOnClaude: mockEnsureOnClaude, - ensureClaudeComposer: mockEnsureClaudeComposer, - selectModel: mockSelectModel, - setAdaptiveThinking: mockSetAdaptiveThinking, - sendMessage: mockSendMessage, - sendWithFile: mockSendWithFile, - getBubbleCount: mockGetBubbleCount, - waitForResponse: mockWaitForResponse, - parseBoolFlag: mockParseBoolFlag, - requireNonEmptyPrompt: mockRequireNonEmptyPrompt, - requirePositiveInt: mockRequirePositiveInt, - withRetry: mockWithRetry, -})); - -import { askCommand } from '../ask.js'; - -describe('claude ask basic flow', () => { - const page = { - wait: vi.fn().mockResolvedValue(undefined), - goto: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn().mockResolvedValue('https://claude.ai/new'), - }; - - beforeEach(() => { - vi.clearAllMocks(); - page.evaluate.mockResolvedValue('https://claude.ai/new'); - mockEnsureOnClaude.mockResolvedValue(false); - mockEnsureClaudeComposer.mockResolvedValue({ isLoggedIn: true, hasComposer: true }); - mockSelectModel.mockResolvedValue({ ok: true, toggled: false }); - mockSetAdaptiveThinking.mockResolvedValue({ ok: true, toggled: false }); - mockSendMessage.mockResolvedValue({ ok: true }); - mockSendWithFile.mockResolvedValue({ ok: true }); - mockGetBubbleCount.mockResolvedValue(0); - mockWaitForResponse.mockResolvedValue('hello there'); - mockRequireNonEmptyPrompt.mockImplementation((v) => String(v ?? '')); - mockRequirePositiveInt.mockImplementation((v) => Number(v)); - }); - - it('returns the assistant response on a fresh chat', async () => { - const rows = await askCommand.func(page, { - prompt: 'hi', - timeout: 120, - new: false, - model: 'sonnet', - think: false, - }); - - expect(rows).toEqual([{ response: 'hello there' }]); - expect(mockSendMessage).toHaveBeenCalledWith(page, 'hi'); - expect(mockWaitForResponse).toHaveBeenCalledWith(page, 0, 'hi', 120000); - }); - - it('navigates to /new when --new is set', async () => { - await askCommand.func(page, { - prompt: 'hi', - timeout: 120, - new: true, - model: 'sonnet', - think: false, - }); - - expect(page.goto).toHaveBeenCalledWith('https://claude.ai/new'); - expect(mockEnsureOnClaude).not.toHaveBeenCalled(); - }); - - it('throws EmptyResultError when waitForResponse yields nothing', async () => { - mockWaitForResponse.mockResolvedValue(null); - - await expect(askCommand.func(page, { - prompt: 'hi', - timeout: 60, - new: false, - model: 'sonnet', - think: false, - })).rejects.toThrow(EmptyResultError); - }); - - it('throws CommandExecutionError when send fails', async () => { - mockSendMessage.mockResolvedValue({ ok: false, reason: 'composer not found' }); - - await expect(askCommand.func(page, { - prompt: 'hi', - timeout: 120, - new: false, - model: 'sonnet', - think: false, - })).rejects.toThrow(/composer not found/); - }); -}); - -describe('claude ask --model handling', () => { - const page = { - wait: vi.fn().mockResolvedValue(undefined), - goto: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn(), - }; - - beforeEach(() => { - vi.clearAllMocks(); - mockEnsureOnClaude.mockResolvedValue(false); - mockSetAdaptiveThinking.mockResolvedValue({ ok: true, toggled: false }); - mockSendMessage.mockResolvedValue({ ok: true }); - mockGetBubbleCount.mockResolvedValue(0); - mockWaitForResponse.mockResolvedValue('reply'); - }); - - it('rejects --model opus on free tier with usage-error guidance', async () => { - page.evaluate.mockResolvedValue('https://claude.ai/new'); - mockSelectModel.mockResolvedValue({ ok: false, upgrade: true }); - - await expect(askCommand.func(page, { - prompt: 'hi', - timeout: 120, - new: false, - model: 'opus', - think: false, - })).rejects.toMatchObject(new ArgumentError( - 'opus model requires a paid Claude plan.', - 'Pick --model sonnet or --model haiku, or upgrade your account.', - )); - }); - - it('skips model selection inside an existing conversation', async () => { - page.evaluate.mockResolvedValue('https://claude.ai/chat/abc-123'); - - const rows = await askCommand.func(page, { - prompt: 'continue', - timeout: 120, - new: false, - model: 'sonnet', - think: false, - }); - - expect(rows).toEqual([{ response: 'reply' }]); - expect(mockSelectModel).not.toHaveBeenCalled(); - }); - - it('fails fast when --model is explicit inside an existing conversation', async () => { - page.evaluate.mockResolvedValue('https://claude.ai/chat/abc-123'); - - await expect(askCommand.func(page, { - prompt: 'continue', - timeout: 120, - new: false, - model: 'opus', - think: false, - __webcmdOptionSources: { model: 'cli' }, - })).rejects.toMatchObject(new ArgumentError( - 'Cannot switch to opus model inside an existing conversation.', - 'Re-run with --new to start a fresh chat before selecting a model.', - )); - - expect(mockSelectModel).not.toHaveBeenCalled(); - }); -}); - -describe('claude ask --think', () => { - const page = { - wait: vi.fn().mockResolvedValue(undefined), - goto: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn().mockResolvedValue('https://claude.ai/new'), - }; - - beforeEach(() => { - vi.clearAllMocks(); - mockEnsureOnClaude.mockResolvedValue(false); - mockSelectModel.mockResolvedValue({ ok: true, toggled: false }); - mockSendMessage.mockResolvedValue({ ok: true }); - mockGetBubbleCount.mockResolvedValue(0); - mockWaitForResponse.mockResolvedValue('reply'); - }); - - it('toggles Adaptive thinking when --think is set', async () => { - mockSetAdaptiveThinking.mockResolvedValue({ ok: true, toggled: true }); - - await askCommand.func(page, { - prompt: 'reason carefully', - timeout: 120, - new: false, - model: 'sonnet', - think: true, - }); - - expect(mockSetAdaptiveThinking).toHaveBeenCalledWith(page, true); - }); - - it('throws when --think requested but toggle fails', async () => { - mockSetAdaptiveThinking.mockResolvedValue({ ok: false }); - - await expect(askCommand.func(page, { - prompt: 'reason carefully', - timeout: 120, - new: false, - model: 'sonnet', - think: true, - })).rejects.toThrow(/Adaptive thinking/); - }); - - it('does not throw when --think is false and toggle returns ok=false', async () => { - mockSetAdaptiveThinking.mockResolvedValue({ ok: false }); - - await expect(askCommand.func(page, { - prompt: 'hi', - timeout: 120, - new: false, - model: 'sonnet', - think: false, - })).resolves.toEqual([{ response: 'reply' }]); - }); - - it('fails fast when prompt validation rejects an empty prompt', async () => { - mockRequireNonEmptyPrompt.mockImplementation(() => { - throw new ArgumentError('claude ask prompt cannot be empty'); - }); - - await expect(askCommand.func(page, { - prompt: '', - timeout: 120, - new: false, - model: 'sonnet', - think: false, - })).rejects.toThrow(ArgumentError); - }); - - it('fails fast when timeout validation rejects a non-positive value', async () => { - mockRequirePositiveInt.mockImplementation(() => { - throw new ArgumentError('claude ask --timeout must be a positive integer'); - }); - - await expect(askCommand.func(page, { - prompt: 'hi', - timeout: 0, - new: false, - model: 'sonnet', - think: false, - })).rejects.toThrow(ArgumentError); - }); -}); - -describe('claude ask --file', () => { - const page = { - wait: vi.fn().mockResolvedValue(undefined), - goto: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn().mockResolvedValue('https://claude.ai/new'), - }; - - beforeEach(() => { - vi.clearAllMocks(); - mockEnsureOnClaude.mockResolvedValue(false); - mockSelectModel.mockResolvedValue({ ok: true, toggled: false }); - mockSetAdaptiveThinking.mockResolvedValue({ ok: true, toggled: false }); - mockSendWithFile.mockResolvedValue({ ok: true }); - mockGetBubbleCount.mockResolvedValue(3); - mockWaitForResponse.mockResolvedValue('the image shows a cat'); - mockEnsureClaudeComposer.mockResolvedValue({ isLoggedIn: true, hasComposer: true }); - mockRequireNonEmptyPrompt.mockImplementation((v) => String(v ?? '')); - mockRequirePositiveInt.mockImplementation((v) => Number(v)); - }); - - it('routes through sendWithFile and captures baseline before sending', async () => { - const rows = await askCommand.func(page, { - prompt: 'describe this', - timeout: 120, - new: false, - model: 'sonnet', - think: false, - file: '/tmp/cat.png', - }); - - expect(rows).toEqual([{ response: 'the image shows a cat' }]); - expect(mockGetBubbleCount).toHaveBeenCalledTimes(1); - expect(mockSendWithFile).toHaveBeenCalledWith(page, '/tmp/cat.png', 'describe this'); - expect(mockSendMessage).not.toHaveBeenCalled(); - expect(mockWaitForResponse).toHaveBeenCalledWith(page, 3, 'describe this', 120000); - }); - - it('surfaces file upload failure as CommandExecutionError', async () => { - mockSendWithFile.mockResolvedValue({ ok: false, reason: 'file preview did not appear' }); - - await expect(askCommand.func(page, { - prompt: 'describe this', - timeout: 120, - new: false, - model: 'sonnet', - think: false, - file: '/tmp/cat.png', - })).rejects.toThrow(/file preview did not appear/); - }); - - it('absorbs "Promise was collected" SPA navigation error after send', async () => { - mockSendWithFile.mockRejectedValue(new Error('Promise was collected')); - - const rows = await askCommand.func(page, { - prompt: 'describe this', - timeout: 120, - new: false, - model: 'sonnet', - think: false, - file: '/tmp/cat.png', - }); - - expect(rows).toEqual([{ response: 'the image shows a cat' }]); - }); -}); diff --git a/plugins/claude/test/commands.test.js b/plugins/claude/test/commands.test.js deleted file mode 100644 index 70d8e72e..00000000 --- a/plugins/claude/test/commands.test.js +++ /dev/null @@ -1,118 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { ArgumentError, AuthRequiredError, EmptyResultError } from '@agentrhq/webcmd/errors'; - -const { - mockEnsureOnClaude, - mockEnsureClaudeComposer, - mockEnsureClaudeLogin, - mockSendMessage, - mockParseBoolFlag, - mockRequireNonEmptyPrompt, - mockGetVisibleMessages, - mockGetConversationList, - mockRequirePositiveInt, - mockRequireConversationId, - mockWithRetry, -} = vi.hoisted(() => ({ - mockEnsureOnClaude: vi.fn(), - mockEnsureClaudeComposer: vi.fn(), - mockEnsureClaudeLogin: vi.fn(), - mockSendMessage: vi.fn(), - mockParseBoolFlag: vi.fn((v) => v === true || v === 'true'), - mockRequireNonEmptyPrompt: vi.fn((v) => String(v ?? '')), - mockGetVisibleMessages: vi.fn(), - mockGetConversationList: vi.fn(), - mockRequirePositiveInt: vi.fn((v) => Number(v)), - mockRequireConversationId: vi.fn((v) => String(v ?? '').trim()), - mockWithRetry: vi.fn(async (fn) => fn()), -})); - -vi.mock('../utils.js', () => ({ - CLAUDE_DOMAIN: 'claude.ai', - CLAUDE_URL: 'https://claude.ai/new', - ensureOnClaude: mockEnsureOnClaude, - ensureClaudeComposer: mockEnsureClaudeComposer, - ensureClaudeLogin: mockEnsureClaudeLogin, - sendMessage: mockSendMessage, - parseBoolFlag: mockParseBoolFlag, - requireNonEmptyPrompt: mockRequireNonEmptyPrompt, - getVisibleMessages: mockGetVisibleMessages, - getConversationList: mockGetConversationList, - requirePositiveInt: mockRequirePositiveInt, - requireConversationId: mockRequireConversationId, - withRetry: mockWithRetry, -})); - -import { sendCommand } from '../send.js'; -import { newCommand } from '../new.js'; -import { readCommand } from '../read.js'; -import { historyCommand } from '../history.js'; -import { detailCommand } from '../detail.js'; - -describe('claude command-level fail-fast contracts', () => { - const page = { - goto: vi.fn().mockResolvedValue(undefined), - wait: vi.fn().mockResolvedValue(undefined), - }; - - beforeEach(() => { - vi.clearAllMocks(); - mockEnsureOnClaude.mockResolvedValue(false); - mockEnsureClaudeComposer.mockResolvedValue({ isLoggedIn: true, hasComposer: true }); - mockEnsureClaudeLogin.mockResolvedValue({ isLoggedIn: true }); - mockSendMessage.mockResolvedValue({ ok: true }); - mockRequireNonEmptyPrompt.mockImplementation((v) => String(v ?? '')); - mockGetVisibleMessages.mockResolvedValue([{ Index: 0, Role: 'assistant', Text: 'hi' }]); - mockGetConversationList.mockResolvedValue([{ Index: 1, Id: 'abc', Title: 'Hi', Url: 'https://claude.ai/chat/abc' }]); - mockRequirePositiveInt.mockImplementation((v) => Number(v)); - mockRequireConversationId.mockImplementation((v) => String(v ?? '').trim()); - }); - - it('send rejects empty prompt via ArgumentError', async () => { - mockRequireNonEmptyPrompt.mockImplementation(() => { - throw new ArgumentError('claude send prompt cannot be empty'); - }); - - await expect(sendCommand.func(page, { prompt: '', new: false })).rejects.toThrow(ArgumentError); - }); - - it('send surfaces auth failure from composer readiness', async () => { - mockEnsureClaudeComposer.mockRejectedValue(new AuthRequiredError('claude.ai', 'Claude send requires a logged-in Claude session.')); - - await expect(sendCommand.func(page, { prompt: 'hi', new: false })).rejects.toThrow(AuthRequiredError); - }); - - it('new no longer false-succeeds on login wall', async () => { - mockEnsureClaudeComposer.mockRejectedValue(new AuthRequiredError('claude.ai', 'Claude new requires a logged-in Claude session with a visible composer.')); - - await expect(newCommand.func(page)).rejects.toThrow(AuthRequiredError); - }); - - it('read throws EmptyResultError instead of a placeholder row', async () => { - mockGetVisibleMessages.mockResolvedValue([]); - - await expect(readCommand.func(page)).rejects.toThrow(EmptyResultError); - }); - - it('history rejects invalid --limit values instead of silently coercing them', async () => { - mockRequirePositiveInt.mockImplementation(() => { - throw new ArgumentError('claude history --limit must be a positive integer'); - }); - - await expect(historyCommand.func(page, { limit: 0 })).rejects.toThrow(ArgumentError); - }); - - it('history throws EmptyResultError on an empty /recents page', async () => { - mockGetConversationList.mockResolvedValue([]); - - await expect(historyCommand.func(page, { limit: 20 })).rejects.toThrow(EmptyResultError); - }); - - it('detail rejects a missing conversation id', async () => { - mockRequireConversationId.mockImplementation(() => { - throw new ArgumentError('claude detail requires a conversation id'); - }); - - await expect(detailCommand.func(page, { id: '' })).rejects.toThrow(ArgumentError); - }); -}); diff --git a/plugins/claude/test/utils.test.js b/plugins/claude/test/utils.test.js deleted file mode 100644 index ab77ebeb..00000000 --- a/plugins/claude/test/utils.test.js +++ /dev/null @@ -1,151 +0,0 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { ArgumentError } from '@agentrhq/webcmd/errors'; -import { parseBoolFlag, sendWithFile, selectModel, requireConversationId, requireNonEmptyPrompt, requirePositiveInt } from '../utils.js'; - -describe('claude parseBoolFlag', () => { - it('returns booleans unchanged', () => { - expect(parseBoolFlag(true)).toBe(true); - expect(parseBoolFlag(false)).toBe(false); - }); - - it('treats only "true" string (case-insensitive) as true', () => { - expect(parseBoolFlag('true')).toBe(true); - expect(parseBoolFlag('TRUE')).toBe(true); - expect(parseBoolFlag('1')).toBe(false); - expect(parseBoolFlag('yes')).toBe(false); - expect(parseBoolFlag('')).toBe(false); - expect(parseBoolFlag(null)).toBe(false); - expect(parseBoolFlag(undefined)).toBe(false); - }); -}); - -describe('claude argument helpers', () => { - it('rejects blank prompts', () => { - expect(() => requireNonEmptyPrompt(' ', 'claude ask')).toThrow(ArgumentError); - }); - - it('rejects non-positive integers for numeric flags', () => { - expect(() => requirePositiveInt(0, 'claude ask --timeout')).toThrow(ArgumentError); - expect(() => requirePositiveInt(-1, 'claude history --limit')).toThrow(ArgumentError); - }); - - it('rejects missing conversation ids', () => { - expect(() => requireConversationId(' ')).toThrow(ArgumentError); - }); -}); - -describe('claude sendWithFile', () => { - const tempDirs = []; - - afterEach(() => { - vi.restoreAllMocks(); - while (tempDirs.length) { - fs.rmSync(tempDirs.pop(), { recursive: true, force: true }); - } - }); - - it('prefers page.setFileInput, then sends after preview appears', async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-claude-')); - tempDirs.push(dir); - const filePath = path.join(dir, 'cat.png'); - fs.writeFileSync(filePath, 'fake'); - - const page = { - nativeType: vi.fn().mockResolvedValue(undefined), - setFileInput: vi.fn().mockResolvedValue(undefined), - wait: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn() - .mockResolvedValueOnce({ ok: true, via: 'react' }) // React onChange fired after setFileInput - .mockResolvedValueOnce(true) // waitForFilePreview hit - .mockResolvedValueOnce(true) // composer ready - .mockResolvedValueOnce({ ok: true }), // send button click - }; - - const result = await sendWithFile(page, filePath, 'describe this'); - - expect(result).toEqual({ ok: true }); - expect(page.setFileInput).toHaveBeenCalledWith([filePath], 'input[data-testid="file-upload"]'); - expect(page.nativeType).toHaveBeenCalledWith('describe this'); - }); - - it('returns file-not-found when path does not exist', async () => { - const page = { setFileInput: vi.fn(), evaluate: vi.fn(), wait: vi.fn() }; - const result = await sendWithFile(page, '/no/such/file.png', 'hi'); - expect(result.ok).toBe(false); - expect(result.reason).toContain('File not found'); - }); - - it('rejects oversized files before any upload attempt', async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-claude-')); - tempDirs.push(dir); - const filePath = path.join(dir, 'big.bin'); - // Sparse: the guard only reads stats.size, and writing 31 MB for real - // timed out the 5s default on Windows CI. - fs.writeFileSync(filePath, ''); - fs.truncateSync(filePath, 31 * 1024 * 1024); - - const page = { setFileInput: vi.fn(), evaluate: vi.fn(), wait: vi.fn() }; - const result = await sendWithFile(page, filePath, 'hi'); - - expect(result.ok).toBe(false); - expect(result.reason).toMatch(/too large/); - expect(page.setFileInput).not.toHaveBeenCalled(); - }); -}); - -describe('claude selectModel', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('rejects unknown model keys without touching the page', async () => { - const page = { evaluate: vi.fn() }; - - const result = await selectModel(page, 'gpt5'); - - expect(result).toEqual({ ok: false }); - expect(page.evaluate).not.toHaveBeenCalled(); - }); - - it('returns toggled=false when the dropdown already shows the requested model', async () => { - const page = { - evaluate: vi.fn().mockResolvedValueOnce({ ok: true, toggled: false }), - wait: vi.fn(), - }; - - const result = await selectModel(page, 'sonnet'); - - expect(result).toEqual({ ok: true, toggled: false }); - expect(page.wait).not.toHaveBeenCalled(); - }); - - it('opens the dropdown and clicks the matching radio', async () => { - const page = { - evaluate: vi.fn() - .mockResolvedValueOnce({ ok: true, opened: true }) - .mockResolvedValueOnce({ ok: true, toggled: true }), - wait: vi.fn().mockResolvedValue(undefined), - }; - - const result = await selectModel(page, 'haiku'); - - expect(result).toEqual({ ok: true, toggled: true }); - expect(page.evaluate).toHaveBeenCalledTimes(2); - }); - - it('flags upgrade-required when picking a paid model on free tier', async () => { - const page = { - evaluate: vi.fn() - .mockResolvedValueOnce({ ok: true, opened: true }) - .mockResolvedValueOnce({ ok: false, upgrade: true }), - wait: vi.fn().mockResolvedValue(undefined), - }; - - const result = await selectModel(page, 'opus'); - - expect(result).toEqual({ ok: false, upgrade: true }); - }); -}); diff --git a/plugins/claude/utils.js b/plugins/claude/utils.js deleted file mode 100644 index 7c2aff59..00000000 --- a/plugins/claude/utils.js +++ /dev/null @@ -1,463 +0,0 @@ -import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@agentrhq/webcmd/errors'; - -export const CLAUDE_DOMAIN = 'claude.ai'; -export const CLAUDE_URL = 'https://claude.ai/new'; -export const COMPOSER_SELECTOR = '[data-testid="chat-input"]'; -export const MESSAGE_SELECTOR = '.font-claude-response'; -export const MODEL_DROPDOWN_SELECTOR = '[data-testid="model-selector-dropdown"]'; - -const MODEL_DISPLAY_NAMES = { - sonnet: 'Sonnet 4.6', - opus: 'Opus 4.7', - haiku: 'Haiku 4.5', -}; - -export async function isOnClaude(page) { - const url = await page.evaluate('window.location.href').catch(() => ''); - if (typeof url !== 'string' || !url) return false; - try { - const h = new URL(url).hostname; - return h === CLAUDE_DOMAIN || h.endsWith(`.${CLAUDE_DOMAIN}`); - } catch { - return false; - } -} - -export async function ensureOnClaude(page) { - if (await isOnClaude(page)) return false; - await page.goto(CLAUDE_URL); - // Wait for the composer textarea instead of a fixed 3 s sleep. On the login - // page it never mounts; swallow the timeout so callers (read / detail / - // send) can still inspect page state and produce typed errors. - try { - await page.wait({ selector: COMPOSER_SELECTOR, timeout: 8 }); - } catch { - // Login or error page — downstream ensureClaudeLogin / ensureClaudeComposer surfaces a typed error. - } - return true; -} - -export async function getPageState(page) { - return page.evaluate(`(() => { - var composer = document.querySelector('${COMPOSER_SELECTOR}'); - var userMenu = document.querySelector('[data-testid="user-menu-button"]'); - return { - url: window.location.href, - title: document.title, - hasComposer: !!composer, - isLoggedIn: !!userMenu, - }; - })()`); -} - -export async function ensureClaudeLogin(page, message = 'Claude requires a logged-in browser session.') { - const state = await getPageState(page); - if (!state.isLoggedIn) { - throw new AuthRequiredError(CLAUDE_DOMAIN, message); - } - return state; -} - -export async function ensureClaudeComposer(page, message = 'Claude composer is not available on the current page.') { - const state = await ensureClaudeLogin(page, message); - if (!state.hasComposer) { - throw new CommandExecutionError(message); - } - return state; -} - -export function requireNonEmptyPrompt(prompt, commandName) { - const text = String(prompt ?? '').trim(); - if (!text) { - throw new ArgumentError( - `${commandName} prompt cannot be empty`, - `Example: webcmd ${commandName} "hello"`, - ); - } - return text; -} - -export function requirePositiveInt(value, flagLabel, hint) { - if (!Number.isInteger(value) || value < 1) { - throw new ArgumentError(`${flagLabel} must be a positive integer`, hint); - } - return value; -} - -export function requireConversationId(value) { - const id = String(value ?? '').trim(); - if (!id) { - throw new ArgumentError( - 'claude detail requires a conversation id', - 'Example: webcmd claude detail 123e4567-e89b-12d3-a456-426614174000', - ); - } - return id; -} - -export async function getVisibleMessages(page) { - const result = await page.evaluate(`(() => { - var nodes = document.querySelectorAll('[data-testid="user-message"], ${MESSAGE_SELECTOR}'); - var rows = []; - Array.from(nodes).forEach(function(el) { - var isUser = el.getAttribute('data-testid') === 'user-message'; - var raw = (el.innerText || '').trim(); - if (!isUser) { - var parts = raw.split(/\\n\\n+/); - while (parts.length > 1 && /^(Thought|View)\\b/i.test(parts[0])) parts.shift(); - raw = parts.join('\\n\\n').trim(); - } - if (raw) rows.push({ role: isUser ? 'user' : 'assistant', text: raw }); - }); - return rows; - })()`); - if (!Array.isArray(result)) return []; - return result.map(function(r, i) { return { Index: i, Role: r.role, Text: r.text }; }); -} - -export async function getConversationList(page) { - if (!(await isOnClaude(page)) || !(await page.evaluate('window.location.href') || '').includes('/recents')) { - await page.goto('https://claude.ai/recents'); - // Recents list mounts ; an empty history is also - // valid (returns []), so swallow the timeout instead of raising. - try { - await page.wait({ selector: 'a[href*="/chat/"]', timeout: 8 }); - } catch { - // Empty history or login page — downstream evaluate returns []. - } - } - const items = await page.evaluate(`(() => { - var links = Array.from(document.querySelectorAll('a[href*="/chat/"]')); - return links.map(function(link, i) { - var href = link.getAttribute('href') || ''; - var idMatch = href.match(/\\/chat\\/([a-f0-9-]+)/); - return { - Index: i + 1, - Id: idMatch ? idMatch[1] : href, - Title: (link.innerText || '').trim().split('\\n')[0].trim() || '(untitled)', - Url: href.startsWith('http') ? href : ('https://claude.ai' + href), - }; - }); - })()`); - return Array.isArray(items) ? items : []; -} - -export async function selectModel(page, modelName) { - const display = MODEL_DISPLAY_NAMES[String(modelName).toLowerCase()]; - if (!display) return { ok: false }; - - const opened = await page.evaluate(`(() => { - var trigger = document.querySelector('${MODEL_DROPDOWN_SELECTOR}'); - if (!trigger) return { ok: false }; - var label = trigger.getAttribute('aria-label') || ''; - if (label.indexOf(${JSON.stringify(display)}) >= 0) { - return { ok: true, toggled: false }; - } - trigger.click(); - return { ok: true, opened: true }; - })()`); - - if (!opened?.ok) return opened; - if (!opened.opened) return opened; - - // Wait for the dropdown menu items to mount instead of a fixed 0.6 s sleep. - try { - await page.wait({ selector: 'div[role="menuitemradio"]', timeout: 3 }); - } catch { - // Dropdown didn't open — next evaluate finds no target and returns { ok: false }. - } - - return page.evaluate(`(() => { - var items = Array.from(document.querySelectorAll('div[role="menuitemradio"]')); - var target = items.find(function(el) { return (el.innerText || '').indexOf(${JSON.stringify(display)}) >= 0; }); - if (!target) return { ok: false }; - // Free-tier locked options carry an inline "Upgrade" button next to the label. - var upgrade = target.querySelector('button'); - if (upgrade && (upgrade.innerText || '').toLowerCase().indexOf('upgrade') >= 0) { - document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); - return { ok: false, upgrade: true }; - } - var alreadySelected = target.getAttribute('aria-checked') === 'true'; - if (!alreadySelected) target.click(); - else document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); - return { ok: true, toggled: !alreadySelected }; - })()`); -} - -export async function setAdaptiveThinking(page, enabled) { - const opened = await page.evaluate(`(() => { - var trigger = document.querySelector('${MODEL_DROPDOWN_SELECTOR}'); - if (!trigger) return { ok: false }; - trigger.click(); - return { ok: true }; - })()`); - if (!opened?.ok) return { ok: false }; - - // Wait for the dropdown menu items to mount instead of a fixed 0.6 s sleep. - try { - await page.wait({ selector: 'div[role="menuitem"]', timeout: 3 }); - } catch { - // Dropdown didn't open — next evaluate finds no target and returns { ok: false }. - } - - return page.evaluate(`(() => { - var items = Array.from(document.querySelectorAll('div[role="menuitem"]')); - var target = items.find(function(el) { return (el.innerText || '').indexOf('Adaptive thinking') >= 0; }); - if (!target) { - document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); - return { ok: false }; - } - var isActive = target.getAttribute('aria-checked') === 'true'; - if (${enabled} !== isActive) target.click(); - else document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); - return { ok: true, toggled: ${enabled} !== isActive }; - })()`); -} - -export async function sendMessage(page, prompt) { - const promptJson = JSON.stringify(prompt); - const composerReady = await page.evaluate(`(() => { - var box = document.querySelector('${COMPOSER_SELECTOR}'); - if (!box) return false; - box.focus(); - // ProseMirror editors hold content in nested

; clear via Range/delete - // rather than .value or textContent, which the editor won't notice. - var sel = window.getSelection(); - sel.removeAllRanges(); - var range = document.createRange(); - range.selectNodeContents(box); - sel.addRange(range); - document.execCommand('delete', false); - return true; - })()`); - if (!composerReady) return { ok: false, reason: 'composer not found' }; - - let typedNatively = false; - if (page.nativeType) { - try { - await page.nativeType(prompt); - typedNatively = true; - } catch (err) { - const msg = String(err?.message || err); - if (!msg.includes('Unknown action') && !msg.includes('not supported')) throw err; - } - } - if (!typedNatively) { - await page.evaluate(`(() => { - var box = document.querySelector('${COMPOSER_SELECTOR}'); - if (!box) return; - box.focus(); - document.execCommand('insertText', false, ${promptJson}); - })()`); - } - - await page.wait(1.2); - - return page.evaluate(`(() => { - var ariaCandidates = [ - 'button[aria-label="Send Message"]', - 'button[aria-label="Send message"]', - 'button[aria-label="Send"]', - 'button[aria-label*="Send"]', - ]; - for (var i = 0; i < ariaCandidates.length; i++) { - var btn = document.querySelector(ariaCandidates[i]); - if (btn && !btn.disabled) { btn.click(); return { ok: true }; } - } - // Fallback: rightmost enabled button with an svg in the composer container. - var box = document.querySelector('${COMPOSER_SELECTOR}'); - if (box) { - var c = box.parentElement; - for (var hop = 0; hop < 6 && c; hop++) { - var btns = Array.from(c.querySelectorAll('button')).filter(function(b) { return !b.disabled && b.querySelector('svg'); }); - if (btns.length) { btns[btns.length - 1].click(); return { ok: true, method: 'fallback' }; } - c = c.parentElement; - } - } - var box2 = document.querySelector('${COMPOSER_SELECTOR}'); - if (box2) { - box2.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true })); - return { ok: true, method: 'enter' }; - } - return { ok: false, reason: 'send button not found' }; - })()`); -} - -export async function getBubbleCount(page) { - const count = await page.evaluate(`(() => { - return document.querySelectorAll('${MESSAGE_SELECTOR}').length; - })()`); - return count || 0; -} - -export async function waitForResponse(page, baselineCount, prompt, timeoutMs) { - const startTime = Date.now(); - let lastText = ''; - let stableCount = 0; - - while (Date.now() - startTime < timeoutMs) { - await page.wait(3); - - let result; - try { - result = await page.evaluate(`(() => { - var bubbles = document.querySelectorAll('${MESSAGE_SELECTOR}'); - // Adaptive thinking renders "Thought process" labels at the top - // of the response (often duplicated for the expand/collapse widget). - // Strip them so the row value is the actual answer text. - var texts = Array.from(bubbles).map(function(b) { - var raw = (b.innerText || '').trim(); - // Drop leading paragraphs that are widget labels: - // "Thought process" / "Thought for Xs" — Adaptive thinking expand widget - // "View uploaded image" / "View attachment" — file thumbnail label - // These render twice (collapsed + expanded) and are followed by a blank line. - var parts = raw.split(/\\n\\n+/); - while (parts.length > 1 && /^(Thought|View)\\b/i.test(parts[0])) parts.shift(); - return parts.join('\\n\\n').trim(); - }).filter(Boolean); - return { - count: texts.length, - last: texts[texts.length - 1] || '', - streaming: !!document.querySelector('[data-is-streaming="true"]'), - }; - })()`); - } catch { - continue; - } - - if (!result) continue; - - const candidate = result.last; - if (!candidate || candidate === prompt.trim()) continue; - if (result.count <= baselineCount) continue; - if (result.streaming) { - lastText = candidate; - stableCount = 0; - continue; - } - - if (candidate === lastText) { - stableCount++; - if (stableCount >= 3) return candidate; - } else { - stableCount = 0; - lastText = candidate; - } - } - - return lastText || null; -} - -async function waitForFilePreview(page, fileName) { - for (let attempt = 0; attempt < 12; attempt++) { - await page.wait(1); - const ready = await page.evaluate(`(() => { - // Claude renders attachments as data-testid="file-thumbnail" cards with - // a sibling Remove button. Either signal indicates the file took. - if (document.querySelector('[data-testid="file-thumbnail"]')) return true; - var removeBtn = Array.from(document.querySelectorAll('button')) - .find(function(b) { return (b.getAttribute('aria-label') || '') === 'Remove'; }); - return !!removeBtn; - })()`); - if (ready) return true; - } - return false; -} - -export async function sendWithFile(page, filePath, prompt) { - const fs = await import('node:fs'); - const path = await import('node:path'); - const absPath = path.default.resolve(filePath); - - if (!fs.default.existsSync(absPath)) { - return { ok: false, reason: `File not found: ${absPath}` }; - } - - const stats = fs.default.statSync(absPath); - if (stats.size > 30 * 1024 * 1024) { - return { ok: false, reason: `File too large (${(stats.size / 1024 / 1024).toFixed(1)} MB). Max: 30 MB` }; - } - - const fileName = path.default.basename(absPath); - - let uploaded = false; - if (page.setFileInput) { - try { - // Upload via CDP so the file content does not cross the daemon body - // limit, then trigger React's controlled onChange manually because - // CDP assigns .files without firing the synthetic event React listens for. - await page.setFileInput([absPath], 'input[data-testid="file-upload"]'); - const fired = await page.evaluate(`(() => { - var inp = document.querySelector('input[data-testid="file-upload"]'); - if (!inp) return { ok: false, reason: 'file input not found' }; - var propsKey = Object.keys(inp).find(function(k) { return k.startsWith('__reactProps$'); }); - if (propsKey && typeof inp[propsKey].onChange === 'function') { - inp[propsKey].onChange({ target: { files: inp.files } }); - return { ok: true, via: 'react' }; - } - inp.dispatchEvent(new Event('change', { bubbles: true })); - return { ok: true, via: 'native' }; - })()`); - if (!fired?.ok) return fired; - uploaded = true; - } catch (err) { - const msg = String(err?.message || err); - if (!msg.includes('Unknown action') && !msg.includes('not supported') && !msg.includes('Not allowed')) { - throw err; - } - } - } - - if (!uploaded) { - const content = fs.default.readFileSync(absPath); - const base64 = content.toString('base64'); - const fallbackResult = await page.evaluate(`(async () => { - var binary = atob('${base64}'); - var bytes = new Uint8Array(binary.length); - for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - - var file = new File([bytes], ${JSON.stringify(fileName)}); - var dt = new DataTransfer(); - dt.items.add(file); - - var inp = document.querySelector('input[data-testid="file-upload"]'); - if (!inp) return { ok: false, reason: 'file input not found' }; - - var propsKey = Object.keys(inp).find(function(k) { return k.startsWith('__reactProps$'); }); - if (!propsKey || typeof inp[propsKey].onChange !== 'function') { - return { ok: false, reason: 'React onChange not found' }; - } - - inp.files = dt.files; - inp[propsKey].onChange({ target: { files: inp.files } }); - return { ok: true }; - })()`); - if (fallbackResult && !fallbackResult.ok) return fallbackResult; - } - - const ready = await waitForFilePreview(page, fileName); - if (!ready) return { ok: false, reason: 'file preview did not appear' }; - - return sendMessage(page, prompt); -} - -// Retries on CDP "Promise was collected" errors caused by Claude SPA route changes. -export async function withRetry(fn, retries = 2) { - for (let i = 0; i <= retries; i++) { - try { - return await fn(); - } catch (err) { - const msg = String(err?.message || err); - if (i < retries && msg.includes('Promise was collected')) { - await new Promise(r => setTimeout(r, 2000)); - continue; - } - throw err; - } - } -} - -export function parseBoolFlag(value) { - if (typeof value === 'boolean') return value; - return String(value ?? '').trim().toLowerCase() === 'true'; -} diff --git a/plugins/claude/webcmd-plugin.json b/plugins/claude/webcmd-plugin.json deleted file mode 100644 index 79ee9c2d..00000000 --- a/plugins/claude/webcmd-plugin.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "claude", - "version": "0.1.0", - "description": "Webcmd commands for claude", - "webcmd": ">=0.5.3", - "author": { - "name": "WebCMD Agent", - "handle": "agentrhq" - } -} diff --git a/plugins/codex/README.md b/plugins/codex/README.md deleted file mode 100644 index b66ca526..00000000 --- a/plugins/codex/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# webcmd-plugin-codex - -Webcmd commands for codex. - -## Install - -```bash -webcmd plugin install github:agentrhq/webcmd/codex -``` - -## Commands - -| Command | Description | -| --- | --- | -| `webcmd codex archive` | Archive (Codex's term for delete) the selected conversation via the Chat actions header menu. No confirmation in UI — pass --yes to actually archive. | -| `webcmd codex ask` | Send a prompt to the current or selected Codex conversation and wait for the AI response | -| `webcmd codex dump` | Dump the DOM and Accessibility tree of codex for reverse-engineering | -| `webcmd codex export` | Export the current Codex conversation to a Markdown file | -| `webcmd codex extract-diff` | Extract visual code review diff patches from Codex | -| `webcmd codex history` | List visible Codex conversation threads grouped by project | -| `webcmd codex model` | Read, list, or switch the active model / reasoning level in Codex Desktop. The composer toolbar button toggles a menu that mixes model variants (GPT-5.5, Speed) with reasoning levels (Low/Medium/High/Extra High). | -| `webcmd codex new` | Start a new Codex conversation session | -| `webcmd codex pin` | Pin the selected Codex conversation via the Chat actions header menu. | -| `webcmd codex projects` | List Codex projects and visible conversations from the sidebar | -| `webcmd codex read` | Read the contents of the current or selected Codex conversation thread | -| `webcmd codex rename` | Rename the selected Codex conversation. Opens the Chat actions menu → "Rename chat", then types the new title. | -| `webcmd codex screenshot` | Capture a snapshot of the current Codex window (DOM + Accessibility tree) | -| `webcmd codex send` | Send text/commands to the current or selected Codex AI composer | -| `webcmd codex status` | Check active CDP connection to OpenAI Codex App | -| `webcmd codex unpin` | Unpin the selected Codex conversation via the Chat actions header menu. | diff --git a/plugins/codex/_actions.js b/plugins/codex/_actions.js deleted file mode 100644 index af550360..00000000 --- a/plugins/codex/_actions.js +++ /dev/null @@ -1,270 +0,0 @@ -// Shared helpers for Codex conversation management (pin/unpin/archive/rename). -// -// Codex App exposes 8 actions via the "Chat actions" header dropdown on the -// currently-active chat. We use that path because it's the only one that -// works regardless of window visibility: -// -// - The per-row sidebar buttons (Pin chat / Archive chat) are React -// hover-only — they're LAZILY MOUNTED only when the row is hovered, -// AND only when `document.visibilityState === 'visible'`. When the -// Codex window is hidden / minimized, even programmatic mouseenter -// won't surface them. -// -// - The Chat actions menu mounts its items on click, doesn't care about -// window visibility, and supports all the operations we need: -// Unpin chat ⌥⌘P (or "Pin chat" when not pinned) -// Rename chat ⌥⌘R -// Archive chat ⇧⌘A -// Open side chat, Copy, Fork, Add automation…, Open in new window -// -// Caveat: this means each action targets the ACTIVE chat. We select the -// target first via openCodexConversation (using --project / --conversation -// / --index / --thread-id), then trigger the menu and click. - -import { CommandExecutionError } from '@agentrhq/webcmd/errors'; -import { - collectCodexProjectsFromDocument, - conversationSelectionArgs, - hasConversationTarget, - openCodexConversation, -} from './sidebar.js'; - -export { conversationSelectionArgs }; - -export function unwrapEvaluateResult(result) { - if (result && typeof result === 'object' && 'data' in result && 'session' in result) { - return result.data; - } - return result; -} - -function cleanText(value) { - return String(value ?? '').replace(/\s+/g, ' ').trim(); -} - -function sameProject(a, b) { - const left = cleanText(a).toLowerCase(); - const right = cleanText(b).toLowerCase(); - return !left || !right || left === right; -} - -export function findCodexConversation(projects, ref) { - if (!Array.isArray(projects)) { - return null; - } - for (const project of projects) { - for (const conversation of project.conversations || []) { - if (ref.threadId && conversation.threadId === ref.threadId) { - return { project, conversation }; - } - if (!ref.threadId - && ref.conversation - && cleanText(conversation.title) === cleanText(ref.conversation) - && sameProject(project.project, ref.project)) { - return { project, conversation }; - } - } - } - return null; -} - -export function findActiveCodexConversation(projects) { - const active = []; - for (const project of projects || []) { - for (const conversation of project.conversations || []) { - if (conversation.active) { - active.push({ project, conversation }); - } - } - } - return active.length === 1 ? active[0] : null; -} - -export async function readConversationProjects(page) { - const projects = unwrapEvaluateResult(await page.evaluate(`(${collectCodexProjectsFromDocument.toString()})()`)); - if (!Array.isArray(projects)) { - throw new CommandExecutionError('Codex sidebar extraction returned an invalid payload.'); - } - return projects; -} - -export async function resolveActionConversation(page, kwargs) { - const selected = await openCodexConversation(page, kwargs); - const projects = await readConversationProjects(page); - const resolved = selected - ? findCodexConversation(projects, selected) - : findActiveCodexConversation(projects); - if (!resolved) { - const hint = hasConversationTarget(kwargs) - ? 'The selected Codex conversation was not visible after selection.' - : 'Pass --project/--conversation/--index/--thread-id, or keep the active conversation visible in the sidebar.'; - throw new CommandExecutionError('Could not resolve a stable Codex conversation identity.', hint); - } - if (!resolved.conversation.threadId) { - throw new CommandExecutionError( - 'Could not resolve a stable Codex conversation identity.', - 'The selected sidebar row is missing its Codex thread id; selectors may have drifted.', - ); - } - return { - project: resolved.project.project, - projectPath: resolved.project.projectPath, - conversation: resolved.conversation.title, - threadId: resolved.conversation.threadId, - pinned: resolved.conversation.pinned, - index: resolved.conversation.index, - }; -} - -function conversationRefForError(ref) { - return ref.threadId || `${ref.project || '(unknown project)'}/${ref.conversation || '(unknown conversation)'}`; -} - -/** - * Open the "Chat actions" header menu on the currently-active chat and - * click the menu item whose visible text matches one of `labelOptions`. - * - * Single-evaluate so the menu stays mounted while we click — and uses - * the full pointer-event chain because radix's menu trigger only responds - * to pointerdown/up sequences, not bare .click(). - * - * Returns { ok, clicked? , reason?, detail? }. - */ -export async function clickChatActionsMenuItem(page, labelOptions) { - const labelsJson = JSON.stringify(labelOptions); - - const result = unwrapEvaluateResult(await page.evaluate(`(async () => { - const wait = (ms) => new Promise((r) => setTimeout(r, ms)); - const labels = ${labelsJson}; - - const trigger = document.querySelector('button[aria-label="Chat actions"]'); - if (!(trigger instanceof HTMLButtonElement)) { - return { ok: false, reason: 'Chat actions button not found in the chat header.' }; - } - - // Radix listens to pointer events — bare .click() is silently ignored. - const rect = trigger.getBoundingClientRect(); - const init = { - bubbles: true, cancelable: true, button: 0, buttons: 1, - clientX: Math.round(rect.left + rect.width / 2), - clientY: Math.round(rect.top + rect.height / 2), - }; - trigger.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerType: 'mouse' })); - trigger.dispatchEvent(new MouseEvent('mousedown', init)); - trigger.dispatchEvent(new PointerEvent('pointerup', { ...init, pointerType: 'mouse' })); - trigger.dispatchEvent(new MouseEvent('mouseup', init)); - trigger.dispatchEvent(new MouseEvent('click', init)); - - // Poll for menu items to mount (typically < 300ms). - let menuItems = []; - for (let attempt = 0; attempt < 20; attempt += 1) { - await wait(75); - menuItems = Array.from(document.querySelectorAll('[role="menuitem"]')) - .filter((it) => it instanceof HTMLElement && it.offsetParent); - if (menuItems.length) break; - } - if (!menuItems.length) { - return { ok: false, reason: 'Chat actions menu did not open after pointer click.' }; - } - - // Match by label — menu items render as "