diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3b07f6c..4024cee 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,10 +1,11 @@ -name: Test +name: cli-ci on: push: - branches: [main, develop] + branches: [main, develop, feature/0.6.1-gateway-align] pull_request: branches: [main, develop] + workflow_dispatch: jobs: test: @@ -17,7 +18,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: pip @@ -28,20 +29,24 @@ jobs: pip install -e ".[dev]" - name: Lint with ruff - run: | - ruff check src tests + run: ruff check src tests - name: Type check with mypy - run: | - mypy src/bankofai/x402_cli + run: mypy src/bankofai/x402_cli - name: Run tests with pytest + run: pytest tests/ -v --cov=src/bankofai/x402_cli --cov-report=xml + + - name: CLI command smoke tests run: | - pytest tests/ -v --cov=src/bankofai/x402_cli --cov-report=xml + x402-cli --help + x402-cli catalog --help + x402-cli gateway --help - - name: Run smoke tests + - name: Build package run: | - bash .claude/smoke-test.sh + python -m pip install build + python -m build - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 diff --git a/CHANGELOG.md b/CHANGELOG.md index a4ff8d0..b782d0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ All notable changes to `bankofai-x402-cli` are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.6.1] — 2026-06-24 + +### Changed + +- Point the default hosted catalog URL at `https://x402-catelog.bankofai.io/api/catalog.json`. +- Refresh CLI README and community examples to use production `x402-gateway.bankofai.io` mainnet routes. +- Align catalog command fixtures with the merged production provider catalog: SunPump TRON/BSC mainnet `exact_permit` routes. + +## [0.6.1b6] — 2026-06-12 + +### Changed + +- Classify token `TRANSFER_FROM_FAILED` settlement reverts as `TOKEN_TRANSFER_FAILED` with a payer balance/token mismatch hint. + +## [0.6.1b5] — 2026-06-12 + +### Changed + +- Re-publish the CLI beta after PyPI accepted `0.6.1b2` through `0.6.1b4` files without exposing them in the simple package index. + +## [0.6.1b4] — 2026-06-11 + +### Changed + +- Point the default hosted catalog URL at the Bank of AI hosted catalog. +- Refresh Bank of AI gateway examples to the hosted gateway domain. +- Cache provider detail and pay JSON files during `x402-cli catalog update`, with remote fallback from catalog `base_url` when local detail files are missing. + ## [0.1.0] — 2026-05-08 First stable release. Consolidates everything from `0.1.0-beta.5` through `0.1.0-beta.17`. The package is `pip install bankofai-x402-cli` (no `--pre` needed) and the binary is `x402-cli`. diff --git a/README.md b/README.md index 2ff03db..be33477 100644 --- a/README.md +++ b/README.md @@ -2,26 +2,29 @@ The BankofAI command-line client for the x402 protocol — pay any x402-protected URL, run your own paywall, or test the full handshake locally. **No code required.** +`x402-cli` is the single user-facing entrypoint. It includes payment commands, public catalog discovery, and provider gateway operations under one command tree. The gateway runtime is packaged underneath the CLI, so most users only install and remember `x402-cli`. + +Community copy-paste examples live in [`examples/README.md`](examples/README.md). + ## 1. Install ```bash -pip install --pre bankofai-x402-cli +pip install bankofai-x402-cli==0.6.1 x402-cli --version ``` ## 2. Set up a wallet (one-time) -`x402-cli` delegates all signing to [`bankofai-agent-wallet`](https://github.com/BofAI/agent-wallet). Fastest path — import a 32-byte hex private key: +`x402-cli` delegates all signing to [`bankofai-agent-wallet`](https://github.com/BofAI/agent-wallet). Configure any supported wallet backend before paying. ```bash -agent-wallet start raw_secret \ - --wallet-id payer \ - --private-key 0x +agent-wallet list +agent-wallet resolve-address payer ``` > A single key derives both an EVM address and a TRON address. **You don't need a separate wallet per chain.** > -> Other setup paths (encrypted local store, mnemonic, Privy-managed): see [agent-wallet — Getting Started](https://github.com/BofAI/agent-wallet/blob/main/doc/getting-started.md). +> Setup paths (encrypted local store, mnemonic, raw secret, Privy-managed): see [agent-wallet — Getting Started](https://github.com/BofAI/agent-wallet/blob/main/doc/getting-started.md). Keep wallet secrets outside this repository. ## 3. What each command does @@ -30,6 +33,73 @@ agent-wallet start raw_secret \ | **`x402-cli pay `** | The payer | Hits a URL, and if the server returns `402 Payment Required`, the cli signs + submits the payment + retrieves the response. | | **`x402-cli serve`** | The recipient | Starts a local `402` paywall endpoint that only returns content after a valid payment is settled. | | **`x402-cli roundtrip`** | Self-test / one-shot transfer | Spins up a `serve` in the background, runs `pay` against it, and tears it down. **The fastest way to make a payment from the command line** — and the easiest way to verify your install end-to-end. | +| **`x402-cli catalog search `** | API consumer / agent runtime | Searches the public x402 catalog to find a matching paid capability before calling it. | +| **`x402-cli gateway start ...`** | API provider | Starts a self-hosted provider gateway from local `provider.yml` files. | +| **`x402-cli catalog export-gateway --provider `** | API provider | Exports public `catalog.json` and `pay.md` files from a self-hosted gateway for PR submission. | + +Catalog search can read the hosted catalog, a local `dist/catalog.json`, or a gateway-exported catalog URL. This is the discovery step for agents and local tooling: the user asks for a capability, the catalog search finds matching paid APIs, then the normal x402 payment client can call the selected gateway URL. + +```bash +export X402_CATALOG=https://x402-catelog.bankofai.io/api/catalog.json +x402-cli catalog update +x402-cli catalog search "token launch" +x402-cli catalog show sunpump-token-launch +x402-cli catalog endpoints sunpump-token-launch +x402-cli catalog pay-json sunpump-token-launch +``` + +For local gateway development: + +```bash +x402-cli gateway scaffold sunpump-token-launch-tron \ + --output-dir providers/sunpump-token-launch-tron \ + --forward-url https://api.example.com + +x402-cli gateway check providers/sunpump-token-launch-tron/provider.yml +x402-cli gateway start --providers-dir providers --host 0.0.0.0 --port 4020 +``` + +Expected flow with the gateway: + +```text +Natural-language intent + -> x402-cli catalog search + -> x402-cli catalog show/endpoints/pay-json + -> provider endpoint from the catalog + -> x402-cli pay + -> x402 SDK handles the 402 challenge and payment retry + -> upstream API result +``` + +Provider onboarding flow: + +```bash +x402-cli gateway check providers/sunpump-token-launch-tron/provider.yml +x402-cli gateway start --providers-dir providers --host 0.0.0.0 --port 4020 + +x402-cli catalog export-gateway https://x402-gateway.bankofai.io \ + --provider sunpump-token-launch-tron \ + --output-dir providers/sunpump-token-launch-tron +``` + +The command writes public PR files only: + +```text +providers/sunpump-token-launch/catalog.json +providers/sunpump-token-launch/pay.md +``` + +Do not submit `provider.yml`, `.env`, upstream API keys, bearer tokens, or passwords. + +Provider catalog build commands are also under `x402-cli`: + +```bash +x402-cli gateway catalog generate providers/sunpump-token-launch-tron/provider.yml +x402-cli gateway catalog pay-assets providers/sunpump-token-launch-tron/provider.yml +x402-cli gateway catalog check providers +x402-cli gateway catalog build providers --dist-dir dist +x402-cli gateway catalog search providers sunpump +``` ## 4. Copy-paste: a USDT transfer on TRON mainnet @@ -63,7 +133,7 @@ Verify on chain at `https://tronscan.org/#/transaction/`. > > *First-time only*: if this is your wallet's first payment for this token, the cli will ask you to sign and broadcast a one-time `approve` transaction (~6 TRX on mainnet) so the PaymentPermit contract can move tokens on your behalf later. After that, every payment is gas-free from your side. > -> **Don't have any TRX at all?** Add `--scheme exact_gasfree` to skip even that one-time approve — it routes everything through a GasFree relayer that fronts gas in exchange for a per-settlement fee deducted from a derived custodial address. Setup: [docs/manual-test-guide.md → Walkthrough A](docs/manual-test-guide.md#4-walkthrough-a--tron-nile--exact_gasfree). +> **Don't have any TRX at all?** Add `--scheme exact_gasfree` to skip even that one-time approve — it routes everything through a GasFree relayer that fronts gas in exchange for a per-settlement fee deducted from a derived custodial address. ### Templates for other networks @@ -71,8 +141,6 @@ Verify on chain at `https://tronscan.org/#/transaction/`. |---|---|---| | TRON mainnet (default permit) | `tron:mainnet` | Facilitator pays per-payment gas. One-time ~6 TRX approve when you first use a token from a fresh wallet. Add `--scheme exact_gasfree` to skip that too. | | BSC mainnet (USDT permit) | `eip155:56` | Same model — facilitator pays per-payment gas; one-time approve fee in BNB on first use. | -| TRON Nile (testnet) | `tron:nile` | [Faucet](https://nileex.io/join/getJoinPage) | -| BSC Testnet | `eip155:97` | [Faucet](https://testnet.bnbchain.org/faucet-smart) | To force a specific settlement scheme (instead of the auto-pick), add `--scheme exact_gasfree | exact_permit | exact`. diff --git a/docs/manual-test-guide.md b/docs/manual-test-guide.md index 9726cb2..7d9b233 100644 --- a/docs/manual-test-guide.md +++ b/docs/manual-test-guide.md @@ -37,7 +37,7 @@ Each walkthrough ends with a real on-chain transaction you can inspect on Tronsc ## 1. Install ```bash -pip install --pre bankofai-x402-cli +pip install bankofai-x402-cli==0.6.1 x402-cli --version agent-wallet --help | head -3 # confirm agent-wallet ships with the CLI ``` @@ -328,6 +328,7 @@ Verify on BscScan: `https://testnet.bscscan.com/tx/`. | `resolve_wallet could not find a wallet source` | No wallet config and no env var | Run step 2; fallback option is 2.D | | `Insufficient GasFree balance` (walkthrough A) | gasFreeAddress balance < amount + transferFee + (activateFee if first time) | Top up gasFreeAddress (4.2) and re-check (4.1) | | `GasFree account not activated` | First-time use of a gasFreeAddress | Make sure balance covers `activateFee`; first settlement auto-activates | +| `TRANSFER_FROM_FAILED` / `TOKEN_TRANSFER_FAILED` | Settlement reached token `transferFrom()`, but the payer cannot transfer the requested token amount | Check payer token balance, provider pay JSON `asset/network/scheme`, token contract, and try a smaller `--max-amount` | | `too many pending transfers` | GasFree relayer rate limit | Wait 30–60s, retry | | `429 Too Many Requests` from facilitator | Settlement endpoint rate limit | Wait 30–60s, retry | | Settlement reverts with `permit`-related error (walkthrough B/C) | Token contract's `permit` domain doesn't match SDK's | Use `exact_gasfree` on TRON, or `exact` on EVM if the token supports ERC-3009 | @@ -341,7 +342,7 @@ Verify on BscScan: `https://testnet.bscscan.com/tx/`. ``` # install -pip install --pre bankofai-x402-cli +pip install bankofai-x402-cli==0.6.1 # wallet (one-time, plaintext for testing) export AGENT_WALLET_DIR=/tmp/x402-test-wallet diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..62b54bc --- /dev/null +++ b/examples/README.md @@ -0,0 +1,117 @@ +# x402-cli Community Examples + +This directory shows the common ways a community user should use `x402-cli`. +The CLI is the only user-facing entrypoint: payment, catalog discovery, and +gateway operations all live under `x402-cli`. + +## 1. Install + +```bash +pip install bankofai-x402-cli==0.6.1 +x402-cli --version +``` + +Expected: + +```text +x402-cli, version 0.6.1 +``` + +## 2. Find a Paid API + +Search the public catalog: + +```bash +x402-cli catalog update +x402-cli catalog search "defillama" +x402-cli catalog show defillama +x402-cli catalog endpoints defillama +x402-cli catalog pay-json defillama +``` + +Use a local catalog during development: + +```bash +x402-cli catalog search "defillama" \ + --catalog ../x402-catelog/dist/catalog.json \ + --json +``` + +## 3. Call a Paid API + +After choosing an endpoint from the catalog: + +```bash +x402-cli pay 'https://x402-gateway.bankofai.io/providers/defillama-tvl-tron/protocols' \ + --method GET \ + --network tron:mainnet \ + --scheme exact_permit \ + --token USDT \ + --max-amount 0.001 +``` + +For a dry run that reads the payment requirement without signing: + +```bash +x402-cli pay \ + 'https://x402-gateway.bankofai.io/providers/defillama-tvl-tron/protocols' \ + --dry-run \ + --json +``` + +## 4. Start a Provider Gateway + +Create a local provider configuration: + +```bash +x402-cli gateway scaffold acme-weather \ + --output-dir providers/acme-weather \ + --forward-url https://api.example.com \ + --network tron:shasta +``` + +Validate and start it: + +```bash +x402-cli gateway check providers/acme-weather/provider.yml +x402-cli gateway start --providers-dir providers --host 0.0.0.0 --port 4020 +``` + +`provider.yml` is private. Do not submit it to the public catalog repository. + +## 5. Export Public Catalog PR Files + +After the gateway is reachable: + +```bash +x402-cli catalog export-gateway https://gateway.example.com \ + --provider acme-weather \ + --output-dir providers/acme-weather +``` + +This writes: + +```text +providers/acme-weather/catalog.json +providers/acme-weather/pay.md +``` + +Submit only these public files to `BofAI/x402-catelog`. + +## 6. Agent/Codex Usage + +Agents should use the catalog first, then call the selected endpoint: + +```bash +x402-cli catalog search "defillama tvl" --json +x402-cli catalog pay-json defillama +x402-cli pay 'https://x402-gateway.bankofai.io/providers/defillama-tvl-tron/protocols' \ + --method GET \ + --network tron:mainnet \ + --scheme exact_permit \ + --token USDT \ + --max-amount 0.001 +``` + +The catalog response gives the provider FQN, endpoint URL, price range, chains, +and human/agent-readable usage text. diff --git a/pyproject.toml b/pyproject.toml index 7a95811..4d336f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "bankofai-x402-cli" -version = "0.1.0" +version = "0.6.1" description = "x402-cli — one-shot BankofAI x402 CLI: serve a 402 payment endpoint, or pay one as a client." readme = "README.md" license = { text = "MIT" } @@ -23,8 +23,9 @@ dependencies = [ # TokenRegistry → AssetRegistry rename on the SDK's dev/v2 branch). # web3 / tronpy are pulled in via the SDK's [evm,tron] extras so the SDK # owns those version ranges (cli code never imports them directly). - "bankofai-x402[evm,tron]>=0.5.9,<0.6", + "bankofai-x402[evm,tron]>=0.6.0,<0.7", "bankofai-agent-wallet>=2.4,<3", + "bankofai-x402-gateway>=0.6.1b0,<0.7", # CLI's own runtime stack — only libs that cli source directly imports. # Bound major version to defend against breaking bumps. "click>=8.1.0,<10", diff --git a/specs/README.md b/specs/README.md index 9345883..7b165c3 100644 --- a/specs/README.md +++ b/specs/README.md @@ -1,5 +1,20 @@ # x402-cli Specifications +## 中文说明 + +这里是 `x402-cli` 的设计和协议参考文档,主要给维护者和贡献者使用。普通用户优先阅读仓库根目录的 `README.md` 和 `examples/README.md`。 + +阅读顺序: + +1. `../README.md`:了解安装、搜索、支付和 Gateway 操作。 +2. `server.md`:了解本地付费服务端如何暴露 payment requirement。 +3. `client.md`:了解客户端如何探测 402、签名并重试。 +4. `smoke-tests.md`:了解如何验证核心流程。 + +如果文档和当前 CLI 行为不一致,以代码、测试和 CHANGELOG 为准,然后再更新规格文档。 + +## English + Design documents and protocol specifications for the x402-cli CLI. ## Contents diff --git a/src/bankofai/x402_cli/__init__.py b/src/bankofai/x402_cli/__init__.py index a43c6d2..a31b8aa 100644 --- a/src/bankofai/x402_cli/__init__.py +++ b/src/bankofai/x402_cli/__init__.py @@ -1,3 +1,3 @@ """x402-cli — One-shot BankofAI x402 CLI for Python.""" -__version__ = "0.1.0" +__version__ = "0.6.1" diff --git a/src/bankofai/x402_cli/catalog_cmd.py b/src/bankofai/x402_cli/catalog_cmd.py new file mode 100644 index 0000000..5529147 --- /dev/null +++ b/src/bankofai/x402_cli/catalog_cmd.py @@ -0,0 +1,395 @@ +"""Public x402 catalog commands.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any +from urllib.parse import urljoin + +import click +import httpx + +from bankofai.x402_cli.gateway_search import default_catalog, search_gateway_catalog + + +def cache_dir() -> Path: + return Path.home() / ".cache" / "x402-cli" / "catalog" + + +def cached_catalog_path() -> Path: + return cache_dir() / "catalog.json" + + +def _read_json(source: str) -> dict[str, Any]: + if source.startswith(("http://", "https://")): + response = httpx.get(source, timeout=15.0) + response.raise_for_status() + payload = response.json() + else: + payload = json.loads(Path(source).read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError(f"expected JSON object from {source}") + return payload + + +def _write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def _provider_filename(fqn: str) -> str: + return f"{fqn.replace('/', '__')}.json" + + +def _catalog_source(catalog: str | None) -> str: + if catalog: + return catalog + cached = cached_catalog_path() + if cached.exists(): + return str(cached) + return default_catalog() + + +def _remote_base_from_catalog_payload(payload: dict[str, Any]) -> str | None: + base_url = payload.get("base_url") or payload.get("baseUrl") + if isinstance(base_url, str) and base_url.startswith(("http://", "https://")): + return base_url.rstrip("/") + "/" + return None + + +def _remote_base_from_source(catalog_source: str, payload: dict[str, Any] | None = None) -> str | None: + if payload is not None: + base_url = _remote_base_from_catalog_payload(payload) + if base_url: + return base_url + if catalog_source.startswith(("http://", "https://")): + return catalog_source.rsplit("/", 1)[0].rstrip("/") + "/" + try: + local_payload = _read_json(catalog_source) + except (OSError, ValueError, json.JSONDecodeError, httpx.HTTPError): + return None + return _remote_base_from_catalog_payload(local_payload) + + +def _detail_source(catalog_source: str, fqn: str) -> str: + filename = _provider_filename(fqn) + if catalog_source.startswith(("http://", "https://")): + base = catalog_source.rsplit("/", 1)[0].rstrip("/") + "/" + return urljoin(base, f"providers/{filename}") + path = Path(catalog_source).parent / "providers" / filename + if path.exists(): + return str(path) + remote_base = _remote_base_from_source(catalog_source) + if remote_base: + return urljoin(remote_base, f"providers/{filename}") + return str(path) + + +def _pay_source(catalog_source: str, fqn: str) -> str: + filename = _provider_filename(fqn) + if catalog_source.startswith(("http://", "https://")): + base = catalog_source.rsplit("/", 1)[0].rstrip("/") + "/" + return urljoin(base, f"pay/{filename}") + path = Path(catalog_source).parent / "pay" / filename + if path.exists(): + return str(path) + remote_base = _remote_base_from_source(catalog_source) + if remote_base: + return urljoin(remote_base, f"pay/{filename}") + return str(path) + + +def _cache_provider_assets(source: str, catalog_payload: dict[str, Any]) -> tuple[int, int]: + base = _remote_base_from_source(source, catalog_payload) + if not base: + return (0, 0) + + provider_count = 0 + pay_count = 0 + for provider in catalog_payload.get("providers", []): + if not isinstance(provider, dict): + continue + fqn = provider.get("fqn") + if not isinstance(fqn, str) or not fqn: + continue + filename = _provider_filename(fqn) + try: + detail = _read_json(urljoin(base, f"providers/{filename}")) + _write_json(cache_dir() / "providers" / filename, detail) + provider_count += 1 + except (ValueError, httpx.HTTPError): + pass + try: + pay_json = _read_json(urljoin(base, f"pay/{filename}")) + _write_json(cache_dir() / "pay" / filename, pay_json) + pay_count += 1 + except (ValueError, httpx.HTTPError): + pass + return (provider_count, pay_count) + + +def _zh_copy(title: str, subtitle: str, description: str, use_case: str) -> dict[str, str]: + return { + "title": title, + "subtitle": subtitle, + "description": description, + "useCase": use_case, + } + + +def _submission_catalog(detail: dict[str, Any]) -> dict[str, Any]: + title = str(detail.get("title") or detail["fqn"]) + subtitle = str(detail.get("subtitle") or detail.get("use_case") or title) + description = str(detail.get("description") or subtitle) + use_case = str(detail.get("use_case") or description) + return { + "version": 1, + "fqn": detail["fqn"], + "title": title, + "subtitle": subtitle, + "description": description, + "useCase": use_case, + "i18n": detail.get("i18n") or {"zh-CN": _zh_copy(title, subtitle, description, use_case)}, + "logo": detail.get("logo") or "https://x402-catelog.bankofai.io/assets/providers/default.png", + "category": detail.get("category") or "other", + "chains": detail.get("chains") or [], + "isFirstParty": bool(detail.get("is_first_party")), + "isFeatured": bool(detail.get("is_featured")), + "featuredTags": detail.get("featured_tags") or [], + "serviceUrl": detail.get("service_url"), + "endpoints": [ + { + "method": endpoint["method"], + "path": endpoint["path"], + "url": endpoint["url"], + "title": endpoint.get("title") or endpoint["path"], + "subtitle": endpoint.get("subtitle") or endpoint["path"], + "description": endpoint.get("description") or description, + "useCase": endpoint.get("use_case") or use_case, + "i18n": endpoint.get("i18n") + or { + "zh-CN": _zh_copy( + endpoint.get("title") or endpoint["path"], + endpoint.get("subtitle") or endpoint["path"], + endpoint.get("description") or description, + endpoint.get("use_case") or use_case, + ) + }, + "metered": bool(endpoint.get("metered")), + "minPriceUsd": endpoint.get("min_price_usd", 0), + "maxPriceUsd": endpoint.get("max_price_usd", 0), + } + for endpoint in detail.get("endpoints", []) + ], + "status": detail.get("status") + or { + "catalog": "draft", + "gateway": "unknown", + "payment": "unknown", + "upstream": "unknown", + }, + } + + +def _pay_markdown_from_detail(detail: dict[str, Any]) -> str: + lines = [ + f"# {detail.get('title') or detail['fqn']}", + "", + "## Service", + "", + f"- FQN: `{detail['fqn']}`", + f"- Service URL: `{detail.get('service_url')}`", + f"- Category: `{detail.get('category')}`", + f"- Chains: `{', '.join(detail.get('chains') or [])}`", + "", + "## Endpoints", + "", + ] + for endpoint in detail.get("endpoints", []): + metered = bool(endpoint.get("metered")) + price = endpoint.get("min_price_usd") + lines.extend( + [ + f"### {endpoint.get('method')} {endpoint.get('path')}", + "", + endpoint.get("description") or "", + "", + f"- URL: `{endpoint.get('url')}`", + f"- Metered: `{str(metered).lower()}`", + f"- Price: `${price}`", + "", + ] + ) + if metered: + lines.extend( + [ + "```bash", + f"x402-cli pay '{endpoint.get('url')}'", + "```", + "", + ] + ) + else: + lines.extend(["No payment required.", ""]) + lines.extend( + [ + "## Notes", + "", + "This file is public. Do not include upstream API keys, bearer tokens, provider.yml, `.env`, passwords, or private infrastructure URLs.", + ] + ) + return "\n".join(lines).rstrip() + "\n" + + +@click.group() +def catalog() -> None: + """Search and inspect public x402 provider catalog.""" + + +@catalog.command("update") +@click.option("--catalog", "catalog_url", default=None, help="Catalog URL or local catalog.json.") +@click.option("--json", "output_json", is_flag=True, help="Print machine-readable JSON.") +def update(catalog_url: str | None, output_json: bool) -> None: + """Cache the latest catalog index locally.""" + source = catalog_url or default_catalog() + payload = _read_json(source) + _write_json(cached_catalog_path(), payload) + detail_count, pay_count = _cache_provider_assets(source, payload) + result = { + "source": source, + "path": str(cached_catalog_path()), + "providerCount": payload.get("provider_count", len(payload.get("providers", []))), + "detailCount": detail_count, + "payCount": pay_count, + } + if output_json: + click.echo(json.dumps(result, indent=2, sort_keys=True)) + return + click.echo(f"cached {result['providerCount']} provider(s) from {source}") + if detail_count or pay_count: + click.echo(f"cached {detail_count} provider detail file(s), {pay_count} pay file(s)") + click.echo(str(cached_catalog_path())) + + +@catalog.command("search") +@click.argument("query") +@click.option("--catalog", default=None, help="Catalog URL or local catalog.json.") +@click.option("--limit", "-n", type=int, default=10, help="Maximum result count.") +@click.option("--json", "output_json", is_flag=True, help="Print machine-readable JSON.") +def search(query: str, catalog: str | None, limit: int, output_json: bool) -> None: + """Search providers by use case, category, endpoint, chain, or tag.""" + source = _catalog_source(catalog) + hits = search_gateway_catalog(query, catalog=source, limit=limit) + if output_json: + click.echo( + json.dumps( + { + "query": query, + "catalog": source, + "count": len(hits), + "results": [hit.to_dict() for hit in hits], + }, + indent=2, + sort_keys=True, + ) + ) + return + if not hits: + click.echo("no matches") + raise click.exceptions.Exit(code=1) + for hit in hits: + tags = ",".join(hit.tags) if hit.tags else "-" + click.echo(f"{hit.fqn:32s} score={hit.score:<3d} category={hit.category:12s} tags={tags}") + click.echo(f" {hit.title}") + if hit.description: + click.echo(f" {hit.description}") + if hit.service_url: + click.echo(f" service: {hit.service_url}") + + +@catalog.command("show") +@click.argument("fqn") +@click.option("--catalog", default=None, help="Catalog URL or local catalog.json.") +@click.option("--json", "output_json", is_flag=True, help="Print machine-readable JSON.") +def show(fqn: str, catalog: str | None, output_json: bool) -> None: + """Show provider details.""" + source = _catalog_source(catalog) + payload = _read_json(_detail_source(source, fqn)) + if output_json: + click.echo(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)) + return + click.echo(f"{payload['fqn']} - {payload['title']}") + click.echo(payload.get("subtitle") or "") + click.echo(payload.get("description") or "") + click.echo(f"service: {payload.get('service_url')}") + click.echo(f"category: {payload.get('category')} chains: {', '.join(payload.get('chains') or [])}") + + +@catalog.command("endpoints") +@click.argument("fqn") +@click.option("--catalog", default=None, help="Catalog URL or local catalog.json.") +@click.option("--json", "output_json", is_flag=True, help="Print machine-readable JSON.") +def endpoints(fqn: str, catalog: str | None, output_json: bool) -> None: + """List callable endpoints for a provider.""" + source = _catalog_source(catalog) + payload = _read_json(_detail_source(source, fqn)) + items = payload.get("endpoints", []) + if output_json: + click.echo(json.dumps({"fqn": fqn, "endpoints": items}, ensure_ascii=False, indent=2, sort_keys=True)) + return + for endpoint in items: + price = endpoint.get("min_price_usd") + click.echo(f"{endpoint.get('method', ''):6s} {endpoint.get('path', '')} ${price}") + click.echo(f" {endpoint.get('url')}") + if endpoint.get("description"): + click.echo(f" {endpoint['description']}") + + +@catalog.command("pay-json") +@click.argument("fqn") +@click.option("--catalog", default=None, help="Catalog URL or local catalog.json.") +def pay_json(fqn: str, catalog: str | None) -> None: + """Print provider pay.json for Agent or automation usage.""" + source = _catalog_source(catalog) + click.echo(json.dumps(_read_json(_pay_source(source, fqn)), ensure_ascii=False, indent=2, sort_keys=True)) + + +@catalog.command("export-gateway") +@click.argument("gateway_url") +@click.option("--provider", "provider_fqn", required=True, help="Provider FQN loaded in gateway.") +@click.option( + "--output-dir", + type=click.Path(file_okay=False, path_type=Path), + default=None, + help="Output directory. Defaults to providers/ under current directory.", +) +@click.option("--json", "output_json", is_flag=True, help="Print machine-readable JSON.") +def export_gateway( + gateway_url: str, + provider_fqn: str, + output_dir: Path | None, + output_json: bool, +) -> None: + """Export public catalog files from a running self-hosted gateway.""" + base = gateway_url.rstrip("/") + detail = _read_json(f"{base}/__402/catalog/providers/{provider_fqn}.json") + target = output_dir or Path("providers") / provider_fqn + target.mkdir(parents=True, exist_ok=True) + catalog_path = target / "catalog.json" + pay_md_path = target / "pay.md" + _write_json(catalog_path, _submission_catalog(detail)) + pay_md_path.write_text(_pay_markdown_from_detail(detail), encoding="utf-8") + result = { + "provider": provider_fqn, + "catalog": str(catalog_path), + "payMd": str(pay_md_path), + } + if output_json: + click.echo(json.dumps(result, indent=2, sort_keys=True)) + return + click.echo(f"wrote {catalog_path}") + click.echo(f"wrote {pay_md_path}") diff --git a/src/bankofai/x402_cli/cli.py b/src/bankofai/x402_cli/cli.py index 1a5706f..680cbdb 100644 --- a/src/bankofai/x402_cli/cli.py +++ b/src/bankofai/x402_cli/cli.py @@ -2,13 +2,17 @@ """x402-cli — serve or pay x402 endpoints.""" import asyncio +import json import logging import subprocess +import sys import time import click from bankofai.x402_cli import __version__, _tron_patch +from bankofai.x402_cli.catalog_cmd import catalog as catalog_app +from bankofai.x402_cli.gateway_search import default_catalog, search_gateway_catalog from bankofai.x402_cli.output import OutputMode from bankofai.x402_cli.server_cmd import cmd_server from bankofai.x402_cli.client_cmd import cmd_client @@ -57,6 +61,148 @@ def cli() -> None: setup_logging() +cli.add_command(catalog_app, name="catalog") + +gateway = click.Group( + name="gateway", + help=( + "Run a self-hosted x402 gateway and build provider onboarding assets. " + "Use `x402-cli catalog ...` for public marketplace search." + ), +) + +GATEWAY_FORWARD_CONTEXT = { + "ignore_unknown_options": True, + "allow_extra_args": True, + "help_option_names": [], +} + + +def _run_gateway_command(*args: str) -> None: + result = subprocess.run( + [sys.executable, "-m", "bankofai.x402_gateway", *args], + check=False, + ) + raise click.exceptions.Exit(code=result.returncode) + + +@gateway.command("search") +@click.argument("query") +@click.option( + "--catalog", + type=str, + default=None, + help=( + "Catalog source: local dist/catalog.json or HTTPS URL. " + "Defaults to $X402_CATALOG, $X402_GATEWAY_CATALOG, or the hosted catalog." + ), +) +@click.option("--limit", "-n", type=int, default=10, help="Maximum result count.") +@click.option( + "--include-blocked", + is_flag=True, + help="Include providers whose catalog verdict is blocked.", +) +@click.option("--json", "output_json", is_flag=True, help="Print machine-readable JSON.") +def gateway_search( + query: str, + catalog: str | None, + limit: int, + include_blocked: bool, + output_json: bool, +) -> None: + """Search an x402-gateway catalog for capabilities. + + Example: + x402-cli gateway search "token launch" + """ + catalog_source = catalog or default_catalog() + try: + hits = search_gateway_catalog( + query, + catalog=catalog_source, + limit=limit, + include_blocked=include_blocked, + ) + except Exception as exc: + raise click.ClickException(f"gateway search failed: {exc}") from exc + + if output_json: + click.echo( + json.dumps( + { + "query": query, + "catalog": catalog_source, + "count": len(hits), + "results": [hit.to_dict() for hit in hits], + }, + indent=2, + sort_keys=True, + ) + ) + return + + if not hits: + click.echo("no matches") + raise click.exceptions.Exit(code=1) + + for hit in hits: + tags = ",".join(hit.tags) if hit.tags else "-" + click.echo( + f"{hit.fqn:32s} score={hit.score:<3d} " + f"category={hit.category:12s} tags={tags}" + ) + click.echo(f" {hit.title}") + if hit.description: + click.echo(f" {hit.description}") + if hit.service_url: + click.echo(f" service: {hit.service_url}") + for endpoint in hit.endpoints[:3]: + method = str(endpoint.get("method") or "") + path = str(endpoint.get("path") or "") + paid = endpoint.get("paid") + suffix = "" + if isinstance(paid, dict): + suffix = ( + f" {paid.get('network', '')} " + f"{paid.get('currency', '')} " + f"{paid.get('amount_raw', '')}" + ).rstrip() + click.echo(f" {method:6s} {path}{suffix}") + click.echo("") + + +@gateway.command("start", context_settings=GATEWAY_FORWARD_CONTEXT) +@click.argument("args", nargs=-1, type=click.UNPROCESSED) +def gateway_start(args: tuple[str, ...]) -> None: + """Start a self-hosted provider gateway.""" + _run_gateway_command("server", "start", *args) + + +@gateway.command("check", context_settings=GATEWAY_FORWARD_CONTEXT) +@click.argument("args", nargs=-1, type=click.UNPROCESSED) +def gateway_check(args: tuple[str, ...]) -> None: + """Validate a local provider.yml file.""" + _run_gateway_command("server", "check", *args) + + +@gateway.command("scaffold", context_settings=GATEWAY_FORWARD_CONTEXT) +@click.argument("args", nargs=-1, type=click.UNPROCESSED) +def gateway_scaffold(args: tuple[str, ...]) -> None: + """Write a starter provider.yml file.""" + _run_gateway_command("server", "scaffold", *args) + + +@gateway.command("catalog", context_settings=GATEWAY_FORWARD_CONTEXT) +@click.argument("args", nargs=-1, type=click.UNPROCESSED) +def gateway_catalog(args: tuple[str, ...]) -> None: + """Run provider catalog build/check/pay-assets commands.""" + _run_gateway_command("catalog", *args) + + +cli.add_command(gateway, name="gateway") + + @cli.command() @click.option( "--pay-to", diff --git a/src/bankofai/x402_cli/client_cmd.py b/src/bankofai/x402_cli/client_cmd.py index a8d45b0..3231631 100644 --- a/src/bankofai/x402_cli/client_cmd.py +++ b/src/bankofai/x402_cli/client_cmd.py @@ -1,5 +1,6 @@ """Client command implementation.""" +import json import logging import os from typing import Any @@ -28,6 +29,16 @@ PAYMENT_RESPONSE_HEADER = "PAYMENT-RESPONSE" +def _response_payload(response: httpx.Response) -> Any: + content_type = response.headers.get("content-type", "") + if "json" in content_type.lower(): + try: + return response.json() + except ValueError: + return response.text + return response.text + + async def cmd_client( url: str, max_raw_amount: str | None, @@ -72,6 +83,7 @@ async def cmd_client( "url": url, "status": response.status_code, "message": "Not a payment-required endpoint", + "response": _response_payload(response), } emit( command="client", @@ -221,13 +233,13 @@ async def cmd_client( "asset": selected.asset, "amount": selected.amount, "paid": True, + "response": _response_payload(retry_response), } # Parse response header if available response_header = retry_response.headers.get(PAYMENT_RESPONSE_HEADER) if response_header: try: - import json import base64 decoded_bytes = base64.b64decode(response_header) response_data = json.loads(decoded_bytes.decode('utf-8')) @@ -301,4 +313,3 @@ def _register_client_mechanisms( client.register(network, mechanism) except Exception as err: logger.warning(f"Failed to register {scheme} mechanism for {network}: {err}") - diff --git a/src/bankofai/x402_cli/errors.py b/src/bankofai/x402_cli/errors.py index fc9e55d..43dcf4d 100644 --- a/src/bankofai/x402_cli/errors.py +++ b/src/bankofai/x402_cli/errors.py @@ -110,6 +110,21 @@ def classify(err: BaseException) -> FriendlyError: ), ) + # --- token transfer failed after permit/allowance path reached transferFrom --- + if "transfer_from_failed" in lower or "transferfrom failed" in lower: + return FriendlyError( + code="TOKEN_TRANSFER_FAILED", + message=msg, + hint=( + "The settlement contract reached token transferFrom(), but " + "the token transfer reverted. Check that the payer address " + "holds enough of the exact token advertised by the provider " + "on this network, and that the selected scheme/token matches " + "the provider pay JSON. On BSC Testnet, verify the token " + "contract and try a smaller --max-amount." + ), + ) + # --- rate limits --- if "429" in msg or "too many requests" in lower or "too many pending" in lower: return FriendlyError( diff --git a/src/bankofai/x402_cli/gateway_search.py b/src/bankofai/x402_cli/gateway_search.py new file mode 100644 index 0000000..4d4f4c9 --- /dev/null +++ b/src/bankofai/x402_cli/gateway_search.py @@ -0,0 +1,271 @@ +"""Search x402-gateway catalog artifacts from x402-cli.""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any +from urllib.parse import urljoin + +import httpx + + +@dataclass +class GatewaySearchHit: + fqn: str + title: str + category: str + service_url: str + description: str | None = None + use_case: str | None = None + title_zh: str | None = None + main_title: str | None = None + sub_title: str | None = None + category_meta: dict[str, Any] | None = None + chains: list[str] = field(default_factory=list) + chain_kinds: list[str] = field(default_factory=list) + chains_meta: list[dict[str, Any]] = field(default_factory=list) + tags: list[str] = field(default_factory=list) + endpoints: list[dict[str, Any]] = field(default_factory=list) + score: int = 0 + matched_fields: list[str] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return { + "fqn": self.fqn, + "title": self.title, + "category": self.category, + "serviceUrl": self.service_url, + "description": self.description, + "useCase": self.use_case, + "title_zh": self.title_zh, + "main_title": self.main_title, + "sub_title": self.sub_title, + "category_meta": self.category_meta, + "chains": self.chains, + "chain_kinds": self.chain_kinds, + "chains_meta": self.chains_meta, + "tags": self.tags, + "score": self.score, + "matchedFields": self.matched_fields, + "endpoints": self.endpoints, + } + + +def default_catalog() -> str: + return os.environ.get( + "X402_CATALOG", + os.environ.get("X402_GATEWAY_CATALOG", "https://x402-catelog.bankofai.io/api/catalog.json"), + ) + + +def _read_json(source: str) -> dict[str, Any]: + if source.startswith(("http://", "https://")): + response = httpx.get(source, timeout=10.0) + response.raise_for_status() + payload = response.json() + else: + payload = json.loads(Path(source).read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError(f"expected JSON object from {source}") + return payload + + +def _provider_detail_source(catalog_source: str, fqn: str) -> str: + filename = f"{fqn.replace('/', '__')}.json" + if catalog_source.startswith(("http://", "https://")): + base = catalog_source.rsplit("/", 1)[0].rstrip("/") + "/" + return urljoin(base, f"providers/{filename}") + catalog_path = Path(catalog_source) + return str(catalog_path.parent / "providers" / filename) + + +def _read_provider_detail(catalog_source: str, fqn: str) -> dict[str, Any]: + source = _provider_detail_source(catalog_source, fqn) + try: + return _read_json(source) + except (FileNotFoundError, httpx.HTTPError, json.JSONDecodeError): + return {} + + +FIELD_WEIGHTS = { + "fqn": 12, + "title": 10, + "tags": 8, + "chain_kinds": 8, + "chains": 8, + "category": 6, + "category_meta": 6, + "endpoints": 6, + "i18n": 5, + "description": 4, + "use_case": 4, + "service_url": 2, +} + + +def _score(terms: list[str], fields: dict[str, list[str]]) -> tuple[int, list[str]]: + score = 0 + matched: list[str] = [] + for field_name, values in fields.items(): + haystack = " ".join(str(value) for value in values if value is not None).lower() + if not haystack: + continue + count = sum(1 for term in terms if term in haystack) + if count: + score += FIELD_WEIGHTS.get(field_name, 1) * count + matched.append(field_name) + return score, matched + + +def _endpoint_fields(endpoints: list[dict[str, Any]]) -> list[str]: + values: list[str] = [] + for endpoint in endpoints: + values.extend( + [ + str(endpoint.get("method") or ""), + str(endpoint.get("path") or ""), + str(endpoint.get("probe_status") or ""), + ] + ) + paid = endpoint.get("paid") + if isinstance(paid, dict): + values.extend( + [ + str(paid.get("network") or ""), + str(paid.get("currency") or ""), + str(paid.get("amount_raw") or ""), + ] + ) + values.extend( + [ + str(endpoint.get("title") or ""), + str(endpoint.get("description") or ""), + str(endpoint.get("use_case") or endpoint.get("useCase") or ""), + ] + ) + return values + + +def _string_list(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + return [str(item) for item in value if item is not None] + + +def _dict_values(value: Any) -> list[str]: + if not isinstance(value, dict): + return [] + values: list[str] = [] + for child in value.values(): + if isinstance(child, dict): + values.extend(_dict_values(child)) + elif isinstance(child, list): + values.extend(str(item) for item in child if item is not None) + elif child is not None: + values.append(str(child)) + return values + + +def _chain_meta_values(chains_meta: list[dict[str, Any]]) -> list[str]: + values: list[str] = [] + for chain in chains_meta: + values.extend(_dict_values(chain)) + return values + + +def search_gateway_catalog( + query: str, + *, + catalog: str | None = None, + limit: int = 10, + include_blocked: bool = False, +) -> list[GatewaySearchHit]: + catalog_source = catalog or default_catalog() + index = _read_json(catalog_source) + terms = [term.lower() for term in query.split() if term.strip()] + if not terms: + return [] + + hits: list[GatewaySearchHit] = [] + for provider in index.get("providers", []): + if provider.get("block") and not include_blocked: + continue + fqn = str(provider.get("fqn") or "") + if not fqn: + continue + + detail = _read_provider_detail(catalog_source, fqn) + tags = list( + detail.get("featured_tags") + or provider.get("featured_tags") + or detail.get("tags") + or provider.get("tags") + or [] + ) + endpoints = list(detail.get("endpoints") or []) + category_meta = detail.get("category_meta") or provider.get("category_meta") + if not isinstance(category_meta, dict): + category_meta = None + chains = _string_list(detail.get("chains") or provider.get("chains")) + chain_kinds = _string_list(detail.get("chain_kinds") or provider.get("chain_kinds")) + chains_meta_raw = detail.get("chains_meta") or provider.get("chains_meta") or [] + chains_meta = [ + item for item in chains_meta_raw + if isinstance(item, dict) + ] if isinstance(chains_meta_raw, list) else [] + title_zh = str(detail.get("title_zh") or provider.get("title_zh") or "") + main_title = str(detail.get("main_title") or provider.get("main_title") or "") + sub_title = str(detail.get("sub_title") or provider.get("sub_title") or "") + fields = { + "fqn": [fqn], + "title": [ + str(detail.get("title") or provider.get("title") or ""), + main_title, + ], + "i18n": [ + title_zh, + sub_title, + *_dict_values(detail.get("i18n") or provider.get("i18n")), + ], + "category": [str(detail.get("category") or provider.get("category") or "")], + "category_meta": _dict_values(category_meta), + "chains": [*chains, *_chain_meta_values(chains_meta)], + "chain_kinds": chain_kinds, + "service_url": [ + str(detail.get("service_url") or provider.get("service_url") or "") + ], + "description": [str(detail.get("description") or "")], + "use_case": [str(detail.get("use_case") or detail.get("useCase") or "")], + "tags": [str(tag) for tag in tags], + "endpoints": _endpoint_fields(endpoints), + } + score, matched = _score(terms, fields) + if score == 0: + continue + hits.append( + GatewaySearchHit( + fqn=fqn, + title=fields["title"][0], + category=fields["category"][0], + service_url=fields["service_url"][0], + description=fields["description"][0] or None, + use_case=fields["use_case"][0] or None, + title_zh=title_zh or None, + main_title=main_title or None, + sub_title=sub_title or None, + category_meta=category_meta, + chains=chains, + chain_kinds=chain_kinds, + chains_meta=chains_meta, + tags=tags, + endpoints=endpoints, + score=score, + matched_fields=matched, + ) + ) + + hits.sort(key=lambda hit: (-hit.score, hit.fqn)) + return hits[:limit] diff --git a/src/bankofai/x402_cli/output.py b/src/bankofai/x402_cli/output.py index 5824b59..742c654 100644 --- a/src/bankofai/x402_cli/output.py +++ b/src/bankofai/x402_cli/output.py @@ -75,7 +75,11 @@ def emit_human( if result and isinstance(result, dict): for key, value in result.items(): - print(f" {key}: {value}") + if key == "response" and isinstance(value, (dict, list)): + print(" response:") + print(json.dumps(value, ensure_ascii=False, indent=2)) + else: + print(f" {key}: {value}") def emit( diff --git a/tests/test_catalog_cmd.py b/tests/test_catalog_cmd.py new file mode 100644 index 0000000..1e585cf --- /dev/null +++ b/tests/test_catalog_cmd.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from click.testing import CliRunner + +from bankofai.x402_cli import catalog_cmd +from bankofai.x402_cli.cli import cli + + +def _write_public_catalog(tmp_path: Path) -> Path: + dist = tmp_path / "dist" + (dist / "providers").mkdir(parents=True) + (dist / "pay").mkdir() + fqn = "sunpump-token-launch" + service_url = "https://sunpump.meme" + gateway_url = ( + "https://x402-gateway.bankofai.io/providers/" + "sunpump-token-launch-tron/pump-api/ai/agentTokenLaunch" + ) + endpoint = { + "method": "POST", + "path": "/pump-api/ai/agentTokenLaunch", + "url": gateway_url, + "description": ( + "Submit token metadata to SunPump after x402 payment settlement. " + "`imageBase64` can carry a base64-encoded token image; when it is empty " + "or omitted, SunPump generates an image automatically." + ), + "metered": True, + "min_price_usd": 0.001, + "max_price_usd": 0.001, + "x402_routes": [ + { + "network": "tron:mainnet", + "provider": "sunpump-token-launch-tron", + "scheme": "exact_permit", + "url": gateway_url, + }, + { + "network": "eip155:56", + "provider": "sunpump-token-launch-bsc", + "scheme": "exact_permit", + "url": ( + "https://x402-gateway.bankofai.io/providers/" + "sunpump-token-launch-bsc/pump-api/ai/agentTokenLaunch" + ), + }, + ], + } + (dist / "catalog.json").write_text( + json.dumps( + { + "version": 1, + "base_url": "https://catalog.example.com/api", + "provider_count": 1, + "providers": [ + { + "fqn": fqn, + "title": "SunPump Agent Token Launch API", + "subtitle": "Paid agent token creation through SunPump", + "description": "Launch a SunPump token from structured metadata.", + "use_case": "Create a token after a successful x402 payment.", + "category": "finance", + "service_url": service_url, + "featured_tags": ["sunpump", "token-launch", "tron", "bsc"], + } + ], + } + ) + ) + (dist / "providers" / f"{fqn}.json").write_text( + json.dumps( + { + "fqn": fqn, + "title": "SunPump Agent Token Launch API", + "subtitle": "Paid agent token creation through SunPump", + "description": "Launch a SunPump token from structured metadata.", + "use_case": "Create a token after a successful x402 payment.", + "category": "finance", + "service_url": service_url, + "featured_tags": ["sunpump", "token-launch", "tron", "bsc"], + "chains": ["tron:mainnet", "eip155:56"], + "chain_kinds": ["tron", "bnb"], + "endpoints": [endpoint], + } + ) + ) + (dist / "pay" / f"{fqn}.json").write_text( + json.dumps( + { + "version": 1, + "fqn": fqn, + "service_url": service_url, + "endpoints": [endpoint], + } + ) + ) + return dist / "catalog.json" + + +def test_catalog_search_show_endpoints_and_pay_json(tmp_path: Path) -> None: + catalog = _write_public_catalog(tmp_path) + runner = CliRunner() + + search = runner.invoke( + cli, + ["catalog", "search", "token launch", "--catalog", str(catalog), "--json"], + ) + assert search.exit_code == 0 + assert json.loads(search.output)["results"][0]["fqn"] == "sunpump-token-launch" + + show = runner.invoke( + cli, + ["catalog", "show", "sunpump-token-launch", "--catalog", str(catalog), "--json"], + ) + assert show.exit_code == 0 + assert json.loads(show.output)["service_url"] == "https://sunpump.meme" + + endpoints = runner.invoke( + cli, + ["catalog", "endpoints", "sunpump-token-launch", "--catalog", str(catalog), "--json"], + ) + assert endpoints.exit_code == 0 + assert json.loads(endpoints.output)["endpoints"][0]["path"] == "/pump-api/ai/agentTokenLaunch" + + pay_json = runner.invoke( + cli, + ["catalog", "pay-json", "sunpump-token-launch", "--catalog", str(catalog)], + ) + assert pay_json.exit_code == 0 + assert json.loads(pay_json.output)["fqn"] == "sunpump-token-launch" + + +def test_catalog_update_caches_catalog( + tmp_path: Path, + monkeypatch, +) -> None: + catalog = _write_public_catalog(tmp_path) + cache_root = tmp_path / "cache" + monkeypatch.setattr(catalog_cmd, "cache_dir", lambda: cache_root) + + result = CliRunner().invoke( + cli, + ["catalog", "update", "--catalog", str(catalog), "--json"], + ) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["providerCount"] == 1 + assert payload["detailCount"] == 0 + assert payload["payCount"] == 0 + assert (cache_root / "catalog.json").exists() + + +def test_catalog_update_caches_remote_detail_and_pay_files(tmp_path: Path, monkeypatch) -> None: + cache_root = tmp_path / "cache" + monkeypatch.setattr(catalog_cmd, "cache_dir", lambda: cache_root) + + catalog_payload = json.loads(_write_public_catalog(tmp_path).read_text()) + detail_payload = json.loads((tmp_path / "dist" / "providers" / "sunpump-token-launch.json").read_text()) + pay_payload = json.loads((tmp_path / "dist" / "pay" / "sunpump-token-launch.json").read_text()) + + def fake_read_json(source: str): + if source == "https://catalog.example.com/api/catalog.json": + return catalog_payload + if source == "https://catalog.example.com/api/providers/sunpump-token-launch.json": + return detail_payload + if source == "https://catalog.example.com/api/pay/sunpump-token-launch.json": + return pay_payload + raise AssertionError(source) + + monkeypatch.setattr(catalog_cmd, "_read_json", fake_read_json) + + result = CliRunner().invoke( + cli, + [ + "catalog", + "update", + "--catalog", + "https://catalog.example.com/api/catalog.json", + "--json", + ], + ) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["detailCount"] == 1 + assert payload["payCount"] == 1 + assert (cache_root / "providers" / "sunpump-token-launch.json").exists() + assert (cache_root / "pay" / "sunpump-token-launch.json").exists() + + +def test_catalog_detail_falls_back_to_base_url_when_local_detail_missing(tmp_path: Path) -> None: + catalog = _write_public_catalog(tmp_path) + (tmp_path / "dist" / "providers" / "sunpump-token-launch.json").unlink() + (tmp_path / "dist" / "pay" / "sunpump-token-launch.json").unlink() + assert ( + catalog_cmd._detail_source(str(catalog), "sunpump-token-launch") + == "https://catalog.example.com/api/providers/sunpump-token-launch.json" + ) + assert ( + catalog_cmd._pay_source(str(catalog), "sunpump-token-launch") + == "https://catalog.example.com/api/pay/sunpump-token-launch.json" + ) + + +def test_catalog_export_gateway_writes_pr_files(tmp_path: Path, monkeypatch) -> None: + detail = { + "fqn": "sunpump-token-launch-tron", + "title": "SunPump Agent Token Launch API", + "subtitle": "Paid agent token creation through SunPump", + "description": "Launch a SunPump token from structured metadata.", + "use_case": "Create a token after a successful x402 payment.", + "category": "finance", + "service_url": "https://gw.example.com/providers/sunpump-token-launch-tron", + "chains": ["tron:mainnet"], + "endpoints": [ + { + "method": "POST", + "path": "/pump-api/ai/agentTokenLaunch", + "url": ( + "https://gw.example.com/providers/sunpump-token-launch-tron/" + "pump-api/ai/agentTokenLaunch" + ), + "description": "Launch a SunPump token from metadata.", + "metered": True, + "min_price_usd": 0.001, + "max_price_usd": 0.001, + } + ], + } + monkeypatch.setattr(catalog_cmd, "_read_json", lambda source: detail) + out = tmp_path / "entry" + + result = CliRunner().invoke( + cli, + [ + "catalog", + "export-gateway", + "https://gw.example.com", + "--provider", + "sunpump-token-launch-tron", + "--output-dir", + str(out), + "--json", + ], + ) + + assert result.exit_code == 0 + assert (out / "catalog.json").exists() + assert (out / "pay.md").exists() + payload = json.loads((out / "catalog.json").read_text()) + assert payload["fqn"] == "sunpump-token-launch-tron" + assert payload["endpoints"][0]["path"] == "/pump-api/ai/agentTokenLaunch" + pay_md = (out / "pay.md").read_text() + assert ( + "x402-cli pay 'https://gw.example.com/providers/sunpump-token-launch-tron/" + "pump-api/ai/agentTokenLaunch'" in pay_md + ) + assert "provider.yml" in pay_md diff --git a/tests/test_errors.py b/tests/test_errors.py index 6395190..ceabfb5 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -59,6 +59,16 @@ def test_evm_insufficient_gas() -> None: assert classify(err).code == "INSUFFICIENT_GAS" +def test_token_transfer_failed() -> None: + err = RuntimeError( + "execution reverted: TRANSFER_FROM_FAILED: " + "0x08c379a0000000000000000000000000000000000000000000000000000" + ) + fe = classify(err) + assert fe.code == "TOKEN_TRANSFER_FAILED" + assert "payer address holds enough" in fe.hint + + def test_rate_limited_429() -> None: err = RuntimeError("HTTP 429 Too Many Requests") assert classify(err).code == "RATE_LIMITED" diff --git a/tests/test_gateway_search.py b/tests/test_gateway_search.py new file mode 100644 index 0000000..9045c49 --- /dev/null +++ b/tests/test_gateway_search.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace + +from click.testing import CliRunner + +import bankofai.x402_cli.cli as cli_module +from bankofai.x402_cli.cli import cli +from bankofai.x402_cli.gateway_search import search_gateway_catalog + + +def _write_catalog(tmp_path: Path) -> Path: + dist = tmp_path / "dist" + providers = dist / "providers" + providers.mkdir(parents=True) + (dist / "skills.json").write_text( + json.dumps( + { + "providers": [ + { + "fqn": "acme/weather", + "title": "Acme Weather", + "category": "data", + "service_url": "https://gw.example.com/providers/weather", + "tags": ["weather"], + "block": False, + } + ] + } + ) + ) + (providers / "acme__weather.json").write_text( + json.dumps( + { + "fqn": "acme/weather", + "title": "Acme Weather", + "title_zh": "Acme 天气", + "main_title": "Acme Weather", + "sub_title": "城市天气", + "category": "data", + "category_meta": { + "id": "data", + "label": "Data", + "label_zh": "数据", + }, + "chains": ["eip155:97"], + "chain_kinds": ["bnb"], + "chains_meta": [ + { + "id": "eip155:97", + "kind": "bnb", + "label": "BNB Smart Chain Testnet", + "label_zh": "BNB 测试网", + } + ], + "description": "Current weather data", + "use_case": "Look up current weather for a city", + "i18n": { + "zh-CN": { + "title": "Acme 天气", + "subtitle": "城市天气", + "description": "查询城市天气数据", + "useCase": "适合查询城市实时天气", + } + }, + "service_url": "https://gw.example.com/providers/weather", + "tags": ["weather", "forecast"], + "endpoints": [ + { + "method": "GET", + "path": "/v1/current", + "metered": True, + "probe_status": "ok", + "paid": { + "network": "tron:mainnet", + "currency": "USDT", + "amount_raw": "2000", + }, + } + ], + "verdict": { + "block": False, + "ok_count": 1, + "non_compat_count": 0, + "error_count": 0, + }, + } + ) + ) + return dist / "skills.json" + + +def test_search_gateway_catalog_reads_dist_details(tmp_path: Path) -> None: + catalog = _write_catalog(tmp_path) + + hits = search_gateway_catalog("current weather", catalog=str(catalog)) + + assert len(hits) == 1 + assert hits[0].fqn == "acme/weather" + assert hits[0].endpoints[0]["path"] == "/v1/current" + assert "description" in hits[0].matched_fields + assert hits[0].chain_kinds == ["bnb"] + assert hits[0].category_meta == {"id": "data", "label": "Data", "label_zh": "数据"} + + +def test_search_gateway_catalog_matches_frontend_metadata(tmp_path: Path) -> None: + catalog = _write_catalog(tmp_path) + + chain_hits = search_gateway_catalog("bnb", catalog=str(catalog)) + zh_hits = search_gateway_catalog("天气", catalog=str(catalog)) + category_hits = search_gateway_catalog("数据", catalog=str(catalog)) + + assert [hit.fqn for hit in chain_hits] == ["acme/weather"] + assert [hit.fqn for hit in zh_hits] == ["acme/weather"] + assert [hit.fqn for hit in category_hits] == ["acme/weather"] + assert "chain_kinds" in chain_hits[0].matched_fields + + +def test_gateway_search_cli_json(tmp_path: Path) -> None: + catalog = _write_catalog(tmp_path) + runner = CliRunner() + + result = runner.invoke(cli, ["gateway", "search", "weather", "--catalog", str(catalog), "--json"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["count"] == 1 + assert payload["results"][0]["fqn"] == "acme/weather" + + +def test_gateway_search_cli_no_match(tmp_path: Path) -> None: + catalog = _write_catalog(tmp_path) + runner = CliRunner() + + result = runner.invoke(cli, ["gateway", "search", "translation", "--catalog", str(catalog)]) + + assert result.exit_code == 1 + assert "no matches" in result.output + + +def test_gateway_commands_forward_to_gateway_module(monkeypatch) -> None: + calls: list[list[str]] = [] + + def fake_run(args, check=False): + calls.append(list(args)) + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(cli_module.subprocess, "run", fake_run) + runner = CliRunner() + + start = runner.invoke(cli, ["gateway", "start", "--providers-dir", "providers"]) + catalog = runner.invoke( + cli, + ["gateway", "catalog", "build", "providers", "--dist-dir", "dist"], + ) + + assert start.exit_code == 0 + assert catalog.exit_code == 0 + assert calls == [ + [ + cli_module.sys.executable, + "-m", + "bankofai.x402_gateway", + "server", + "start", + "--providers-dir", + "providers", + ], + [ + cli_module.sys.executable, + "-m", + "bankofai.x402_gateway", + "catalog", + "build", + "providers", + "--dist-dir", + "dist", + ], + ]