From aa461ec77c601378b42524bd1bf618b12adbd0e1 Mon Sep 17 00:00:00 2001 From: bobo Date: Thu, 28 May 2026 16:47:57 +0800 Subject: [PATCH 01/18] Align CLI with x402 0.6 gateway flow --- README.md | 2 ++ pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2ff03db..2f17e96 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ 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 developer tool for one-off payment tests. For production provider onboarding with `providers/**/provider.yml`, use [`x402-gateway`](https://github.com/BofAI/x402-gateway). Both projects depend on the same [`bankofai-x402`](https://github.com/BofAI/x402) SDK. + ## 1. Install ```bash diff --git a/pyproject.toml b/pyproject.toml index 7a95811..73de389 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ 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", # CLI's own runtime stack — only libs that cli source directly imports. # Bound major version to defend against breaking bumps. From c09f1e2ccca4e106a2528d5f645a438d7e273ead Mon Sep 17 00:00:00 2001 From: bobo Date: Thu, 28 May 2026 17:09:27 +0800 Subject: [PATCH 02/18] Add gateway catalog search command --- README.md | 9 ++ src/bankofai/x402_cli/cli.py | 93 +++++++++++++ src/bankofai/x402_cli/gateway_search.py | 174 ++++++++++++++++++++++++ tests/test_gateway_search.py | 97 +++++++++++++ 4 files changed, 373 insertions(+) create mode 100644 src/bankofai/x402_cli/gateway_search.py create mode 100644 tests/test_gateway_search.py diff --git a/README.md b/README.md index 2f17e96..3dee062 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,15 @@ 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 gateway search `** | API consumer / agent runtime | Searches an x402-gateway catalog (`dist/skills.json`) to find a matching paid capability before calling it. | + +Gateway catalog search can read a local file or HTTPS URL: + +```bash +export X402_GATEWAY_CATALOG=https://gateway.example.com/dist/skills.json +x402-cli gateway search "weather" +x402-cli gateway search "weather" --json +``` ## 4. Copy-paste: a USDT transfer on TRON mainnet diff --git a/src/bankofai/x402_cli/cli.py b/src/bankofai/x402_cli/cli.py index 1a5706f..825ae24 100644 --- a/src/bankofai/x402_cli/cli.py +++ b/src/bankofai/x402_cli/cli.py @@ -2,6 +2,7 @@ """x402-cli — serve or pay x402 endpoints.""" import asyncio +import json import logging import subprocess import time @@ -9,6 +10,7 @@ import click from bankofai.x402_cli import __version__, _tron_patch +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 +59,97 @@ def cli() -> None: setup_logging() +@cli.group() +def gateway() -> None: + """Discover and use x402-gateway provider catalogs.""" + + +@gateway.command("search") +@click.argument("query") +@click.option( + "--catalog", + type=str, + default=None, + help=( + "Catalog source: local dist/skills.json or HTTPS URL. " + "Defaults to $X402_GATEWAY_CATALOG or dist/skills.json." + ), +) +@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 "weather" + """ + 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("") + + @cli.command() @click.option( "--pay-to", diff --git a/src/bankofai/x402_cli/gateway_search.py b/src/bankofai/x402_cli/gateway_search.py new file mode 100644 index 0000000..c08a8f3 --- /dev/null +++ b/src/bankofai/x402_cli/gateway_search.py @@ -0,0 +1,174 @@ +"""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 + 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, + "tags": self.tags, + "score": self.score, + "matchedFields": self.matched_fields, + "endpoints": self.endpoints, + } + + +def default_catalog() -> str: + return os.environ.get("X402_GATEWAY_CATALOG", "dist/skills.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() + return response.json() + return json.loads(Path(source).read_text()) + + +def _provider_detail_source(catalog_source: str, fqn: str) -> str: + filename = f"{fqn.replace('/', '__')}.json" + if catalog_source.startswith(("http://", "https://")): + return urljoin(catalog_source, 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, + "category": 6, + "endpoints": 6, + "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, 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, 1) * count + matched.append(field) + 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 ""), + ] + ) + 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("tags") or provider.get("tags") or []) + endpoints = list(detail.get("endpoints") or []) + fields = { + "fqn": [fqn], + "title": [str(detail.get("title") or provider.get("title") or "")], + "category": [str(detail.get("category") or provider.get("category") or "")], + "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 "")], + "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, + tags=tags, + endpoints=endpoints, + score=score, + matched_fields=matched, + ) + ) + + hits.sort(key=lambda hit: (-hit.score, hit.fqn)) + return hits[:limit] diff --git a/tests/test_gateway_search.py b/tests/test_gateway_search.py new file mode 100644 index 0000000..e759e8c --- /dev/null +++ b/tests/test_gateway_search.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from click.testing import CliRunner + +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", + "category": "data", + "description": "Current weather data", + "use_case": "Look up current weather for a city", + "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 + + +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 From cae69cf536fd108f83d96259f936f93b629f7204 Mon Sep 17 00:00:00 2001 From: bobo Date: Fri, 29 May 2026 15:01:32 +0800 Subject: [PATCH 03/18] Document gateway catalog search flow --- README.md | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3dee062..8dbf302 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ agent-wallet start raw_secret \ | **`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 gateway search `** | API consumer / agent runtime | Searches an x402-gateway catalog (`dist/skills.json`) to find a matching paid capability before calling it. | -Gateway catalog search can read a local file or HTTPS URL: +Gateway catalog search can read a local file or HTTPS 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_GATEWAY_CATALOG=https://gateway.example.com/dist/skills.json @@ -42,6 +42,30 @@ x402-cli gateway search "weather" x402-cli gateway search "weather" --json ``` +For local gateway development: + +```bash +cd ../x402-gateway +docker compose up --build -d gateway +docker compose --profile tools run --rm catalog-build + +cd ../x402-cli +PYTHONPATH=src python -m bankofai.x402_cli.cli gateway search \ + "weather" \ + --catalog ../x402-gateway/dist/skills.json +``` + +Expected flow with the gateway: + +```text +Natural-language intent + -> x402-cli gateway search + -> provider endpoint from the catalog + -> x402-cli pay + -> x402 SDK handles the 402 challenge and payment retry + -> upstream API result +``` + ## 4. Copy-paste: a USDT transfer on TRON mainnet Replace `` with a real `T...` address and run: From 11b94a677d9bff7001ecb41fd0f6d34bc2dc5db8 Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Thu, 4 Jun 2026 17:28:26 +0800 Subject: [PATCH 04/18] Add public catalog commands --- src/bankofai/x402_cli/catalog_cmd.py | 310 ++++++++++++++++++++++++ src/bankofai/x402_cli/cli.py | 4 + src/bankofai/x402_cli/gateway_search.py | 31 ++- 3 files changed, 338 insertions(+), 7 deletions(-) create mode 100644 src/bankofai/x402_cli/catalog_cmd.py diff --git a/src/bankofai/x402_cli/catalog_cmd.py b/src/bankofai/x402_cli/catalog_cmd.py new file mode 100644 index 0000000..71defee --- /dev/null +++ b/src/bankofai/x402_cli/catalog_cmd.py @@ -0,0 +1,310 @@ +"""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() + return response.json() + return json.loads(Path(source).read_text(encoding="utf-8")) + + +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 _catalog_source(catalog: str | None) -> str: + if catalog: + return catalog + cached = cached_catalog_path() + if cached.exists(): + return str(cached) + return default_catalog() + + +def _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}") + return str(Path(catalog_source).parent / "providers" / filename) + + +def _pay_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"pay/{filename}") + return str(Path(catalog_source).parent / "pay" / filename) + + +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://catalog.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", []): + lines.extend( + [ + f"### {endpoint.get('method')} {endpoint.get('path')}", + "", + endpoint.get("description") or "", + "", + f"- URL: `{endpoint.get('url')}`", + f"- Price: `${endpoint.get('min_price_usd')}`", + "", + "```bash", + f"x402-cli pay '{endpoint.get('url')}'", + "```", + "", + ] + ) + 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) + result = { + "source": source, + "path": str(cached_catalog_path()), + "providerCount": payload.get("provider_count", len(payload.get("providers", []))), + } + if output_json: + click.echo(json.dumps(result, indent=2, sort_keys=True)) + return + click.echo(f"cached {result['providerCount']} provider(s) from {source}") + 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 825ae24..4f83a66 100644 --- a/src/bankofai/x402_cli/cli.py +++ b/src/bankofai/x402_cli/cli.py @@ -10,6 +10,7 @@ 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 @@ -59,6 +60,9 @@ def cli() -> None: setup_logging() +cli.add_command(catalog_app, name="catalog") + + @cli.group() def gateway() -> None: """Discover and use x402-gateway provider catalogs.""" diff --git a/src/bankofai/x402_cli/gateway_search.py b/src/bankofai/x402_cli/gateway_search.py index c08a8f3..5213819 100644 --- a/src/bankofai/x402_cli/gateway_search.py +++ b/src/bankofai/x402_cli/gateway_search.py @@ -41,7 +41,10 @@ def to_dict(self) -> dict[str, Any]: def default_catalog() -> str: - return os.environ.get("X402_GATEWAY_CATALOG", "dist/skills.json") + return os.environ.get( + "X402_CATALOG", + os.environ.get("X402_GATEWAY_CATALOG", "https://catalog.bankofai.io/api/catalog.json"), + ) def _read_json(source: str) -> dict[str, Any]: @@ -55,7 +58,8 @@ def _read_json(source: str) -> dict[str, Any]: def _provider_detail_source(catalog_source: str, fqn: str) -> str: filename = f"{fqn.replace('/', '__')}.json" if catalog_source.startswith(("http://", "https://")): - return urljoin(catalog_source, f"providers/{filename}") + 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) @@ -83,14 +87,14 @@ def _read_provider_detail(catalog_source: str, fqn: str) -> dict[str, Any]: def _score(terms: list[str], fields: dict[str, list[str]]) -> tuple[int, list[str]]: score = 0 matched: list[str] = [] - for field, values in fields.items(): + 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, 1) * count - matched.append(field) + score += FIELD_WEIGHTS.get(field_name, 1) * count + matched.append(field_name) return score, matched @@ -113,6 +117,13 @@ def _endpoint_fields(endpoints: list[dict[str, Any]]) -> list[str]: 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 @@ -138,7 +149,13 @@ def search_gateway_catalog( continue detail = _read_provider_detail(catalog_source, fqn) - tags = list(detail.get("tags") or provider.get("tags") or []) + 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 []) fields = { "fqn": [fqn], @@ -148,7 +165,7 @@ def search_gateway_catalog( 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 "")], + "use_case": [str(detail.get("use_case") or detail.get("useCase") or "")], "tags": [str(tag) for tag in tags], "endpoints": _endpoint_fields(endpoints), } From 846e177cbbc083f265e9d57c4b93447f403faece Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Thu, 4 Jun 2026 22:33:23 +0800 Subject: [PATCH 05/18] Test and document catalog commands --- README.md | 38 ++++-- src/bankofai/x402_cli/catalog_cmd.py | 20 ++- tests/test_catalog_cmd.py | 180 +++++++++++++++++++++++++++ 3 files changed, 225 insertions(+), 13 deletions(-) create mode 100644 tests/test_catalog_cmd.py diff --git a/README.md b/README.md index 8dbf302..9a38335 100644 --- a/README.md +++ b/README.md @@ -32,14 +32,18 @@ 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 gateway search `** | API consumer / agent runtime | Searches an x402-gateway catalog (`dist/skills.json`) to find a matching paid capability before calling it. | +| **`x402-cli catalog search `** | API consumer / agent runtime | Searches the public x402 catalog to find a matching paid capability before calling it. | +| **`x402-cli catalog export-gateway --provider `** | API provider | Exports public `catalog.json` and `pay.md` files from a self-hosted gateway for PR submission. | -Gateway catalog search can read a local file or HTTPS 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. +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_GATEWAY_CATALOG=https://gateway.example.com/dist/skills.json -x402-cli gateway search "weather" -x402-cli gateway search "weather" --json +export X402_CATALOG=https://catalog.bankofai.io/api/catalog.json +x402-cli catalog update +x402-cli catalog search "weather" +x402-cli catalog show acme-weather +x402-cli catalog endpoints acme-weather +x402-cli catalog pay-json acme-weather ``` For local gateway development: @@ -50,22 +54,40 @@ docker compose up --build -d gateway docker compose --profile tools run --rm catalog-build cd ../x402-cli -PYTHONPATH=src python -m bankofai.x402_cli.cli gateway search \ +PYTHONPATH=src python -m bankofai.x402_cli.cli catalog search \ "weather" \ - --catalog ../x402-gateway/dist/skills.json + --catalog ../x402-catelog/dist/catalog.json ``` Expected flow with the gateway: ```text Natural-language intent - -> x402-cli gateway search + -> 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 catalog export-gateway https://gateway.example.com \ + --provider acme-weather \ + --output-dir providers/acme-weather +``` + +The command writes public PR files only: + +```text +providers/acme-weather/catalog.json +providers/acme-weather/pay.md +``` + +Do not submit `provider.yml`, `.env`, upstream API keys, bearer tokens, or passwords. + ## 4. Copy-paste: a USDT transfer on TRON mainnet Replace `` with a real `T...` address and run: diff --git a/src/bankofai/x402_cli/catalog_cmd.py b/src/bankofai/x402_cli/catalog_cmd.py index 71defee..a0886aa 100644 --- a/src/bankofai/x402_cli/catalog_cmd.py +++ b/src/bankofai/x402_cli/catalog_cmd.py @@ -140,6 +140,8 @@ def _pay_markdown_from_detail(detail: dict[str, Any]) -> str: "", ] 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')}", @@ -147,14 +149,22 @@ def _pay_markdown_from_detail(detail: dict[str, Any]) -> str: endpoint.get("description") or "", "", f"- URL: `{endpoint.get('url')}`", - f"- Price: `${endpoint.get('min_price_usd')}`", - "", - "```bash", - f"x402-cli pay '{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", diff --git a/tests/test_catalog_cmd.py b/tests/test_catalog_cmd.py new file mode 100644 index 0000000..1f9a1ba --- /dev/null +++ b/tests/test_catalog_cmd.py @@ -0,0 +1,180 @@ +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() + (dist / "catalog.json").write_text( + json.dumps( + { + "version": 1, + "provider_count": 1, + "providers": [ + { + "fqn": "acme-weather", + "title": "Acme Weather API", + "subtitle": "City-level weather", + "description": "Current weather data", + "use_case": "Look up weather by city", + "category": "data", + "service_url": "https://gw.example.com/providers/acme-weather", + "featured_tags": ["weather"], + } + ], + } + ) + ) + (dist / "providers" / "acme-weather.json").write_text( + json.dumps( + { + "fqn": "acme-weather", + "title": "Acme Weather API", + "subtitle": "City-level weather", + "description": "Current weather data", + "use_case": "Look up weather by city", + "category": "data", + "service_url": "https://gw.example.com/providers/acme-weather", + "featured_tags": ["weather"], + "chains": ["tron:mainnet"], + "endpoints": [ + { + "method": "GET", + "path": "/v1/current", + "url": "https://gw.example.com/providers/acme-weather/v1/current", + "description": "Current weather for a city", + "metered": True, + "min_price_usd": 0.002, + "max_price_usd": 0.002, + } + ], + } + ) + ) + (dist / "pay" / "acme-weather.json").write_text( + json.dumps( + { + "version": 1, + "fqn": "acme-weather", + "service_url": "https://gw.example.com/providers/acme-weather", + "endpoints": [ + { + "method": "GET", + "path": "/v1/current", + "url": "https://gw.example.com/providers/acme-weather/v1/current", + } + ], + } + ) + ) + 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", "weather", "--catalog", str(catalog), "--json"], + ) + assert search.exit_code == 0 + assert json.loads(search.output)["results"][0]["fqn"] == "acme-weather" + + show = runner.invoke( + cli, + ["catalog", "show", "acme-weather", "--catalog", str(catalog), "--json"], + ) + assert show.exit_code == 0 + assert json.loads(show.output)["service_url"].endswith("/providers/acme-weather") + + endpoints = runner.invoke( + cli, + ["catalog", "endpoints", "acme-weather", "--catalog", str(catalog), "--json"], + ) + assert endpoints.exit_code == 0 + assert json.loads(endpoints.output)["endpoints"][0]["path"] == "/v1/current" + + pay_json = runner.invoke( + cli, + ["catalog", "pay-json", "acme-weather", "--catalog", str(catalog)], + ) + assert pay_json.exit_code == 0 + assert json.loads(pay_json.output)["fqn"] == "acme-weather" + + +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 (cache_root / "catalog.json").exists() + + +def test_catalog_export_gateway_writes_pr_files(tmp_path: Path, monkeypatch) -> None: + detail = { + "fqn": "acme-weather", + "title": "Acme Weather API", + "subtitle": "City-level weather", + "description": "Current weather data", + "use_case": "Look up weather by city", + "category": "data", + "service_url": "https://gw.example.com/providers/acme-weather", + "chains": ["tron:mainnet"], + "endpoints": [ + { + "method": "GET", + "path": "/v1/current", + "url": "https://gw.example.com/providers/acme-weather/v1/current", + "description": "Current weather for a city", + "metered": True, + "min_price_usd": 0.002, + "max_price_usd": 0.002, + } + ], + } + 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", + "acme-weather", + "--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"] == "acme-weather" + assert payload["endpoints"][0]["path"] == "/v1/current" + pay_md = (out / "pay.md").read_text() + assert "x402-cli pay 'https://gw.example.com/providers/acme-weather/v1/current'" in pay_md + assert "provider.yml" in pay_md From 3cc507605d263385f7461e5212627cb437cd4982 Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Fri, 5 Jun 2026 10:30:34 +0800 Subject: [PATCH 06/18] Release 0.6.1 beta --- pyproject.toml | 2 +- src/bankofai/x402_cli/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 73de389..e304abf 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.1b0" 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" } diff --git a/src/bankofai/x402_cli/__init__.py b/src/bankofai/x402_cli/__init__.py index a43c6d2..5ca655f 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.1b0" From 214bd0dcc610d030c517c7321d41a407d646bc77 Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Fri, 5 Jun 2026 11:21:24 +0800 Subject: [PATCH 07/18] Expose gateway commands through x402-cli --- README.md | 32 ++++++++++++------ pyproject.toml | 3 +- src/bankofai/x402_cli/__init__.py | 2 +- src/bankofai/x402_cli/cli.py | 55 +++++++++++++++++++++++++++++-- tests/test_gateway_search.py | 43 ++++++++++++++++++++++++ 5 files changed, 120 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 9a38335..a7a0744 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,12 @@ 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 developer tool for one-off payment tests. For production provider onboarding with `providers/**/provider.yml`, use [`x402-gateway`](https://github.com/BofAI/x402-gateway). Both projects depend on the same [`bankofai-x402`](https://github.com/BofAI/x402) SDK. +`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`. ## 1. Install ```bash -pip install --pre bankofai-x402-cli +pip install bankofai-x402-cli==0.6.1b1 x402-cli --version ``` @@ -33,6 +33,7 @@ agent-wallet start raw_secret \ | **`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. @@ -49,14 +50,12 @@ x402-cli catalog pay-json acme-weather For local gateway development: ```bash -cd ../x402-gateway -docker compose up --build -d gateway -docker compose --profile tools run --rm catalog-build - -cd ../x402-cli -PYTHONPATH=src python -m bankofai.x402_cli.cli catalog search \ - "weather" \ - --catalog ../x402-catelog/dist/catalog.json +x402-cli gateway scaffold acme-weather \ + --output-dir providers/acme-weather \ + --forward-url https://api.example.com + +x402-cli gateway check providers/acme-weather/provider.yml +x402-cli gateway start --providers-dir providers --host 0.0.0.0 --port 4020 ``` Expected flow with the gateway: @@ -74,6 +73,9 @@ Natural-language intent Provider onboarding flow: ```bash +x402-cli gateway check providers/acme-weather/provider.yml +x402-cli gateway start --providers-dir providers --host 0.0.0.0 --port 4020 + x402-cli catalog export-gateway https://gateway.example.com \ --provider acme-weather \ --output-dir providers/acme-weather @@ -88,6 +90,16 @@ providers/acme-weather/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/acme-weather/provider.yml +x402-cli gateway catalog pay-assets providers/acme-weather/provider.yml +x402-cli gateway catalog check providers +x402-cli gateway catalog build providers --dist-dir dist +x402-cli gateway catalog search providers weather +``` + ## 4. Copy-paste: a USDT transfer on TRON mainnet Replace `` with a real `T...` address and run: diff --git a/pyproject.toml b/pyproject.toml index e304abf..2d90f0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "bankofai-x402-cli" -version = "0.6.1b0" +version = "0.6.1b1" 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" } @@ -25,6 +25,7 @@ dependencies = [ # owns those version ranges (cli code never imports them directly). "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/src/bankofai/x402_cli/__init__.py b/src/bankofai/x402_cli/__init__.py index 5ca655f..343689b 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.6.1b0" +__version__ = "0.6.1b1" diff --git a/src/bankofai/x402_cli/cli.py b/src/bankofai/x402_cli/cli.py index 4f83a66..ea38a20 100644 --- a/src/bankofai/x402_cli/cli.py +++ b/src/bankofai/x402_cli/cli.py @@ -5,6 +5,7 @@ import json import logging import subprocess +import sys import time import click @@ -62,10 +63,27 @@ def cli() -> None: 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": [], +} -@cli.group() -def gateway() -> None: - """Discover and use x402-gateway provider catalogs.""" + +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") @@ -154,6 +172,37 @@ def gateway_search( 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/tests/test_gateway_search.py b/tests/test_gateway_search.py index e759e8c..4ed9315 100644 --- a/tests/test_gateway_search.py +++ b/tests/test_gateway_search.py @@ -2,9 +2,11 @@ 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 @@ -95,3 +97,44 @@ def test_gateway_search_cli_no_match(tmp_path: Path) -> None: 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", + ], + ] From 78dba36458eba94e72204789f02ef322685613a5 Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Fri, 5 Jun 2026 14:56:18 +0800 Subject: [PATCH 08/18] Add community x402-cli use cases --- README.md | 2 + docs/manual-test-guide.md | 4 +- examples/README.md | 107 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 examples/README.md diff --git a/README.md b/README.md index a7a0744..b339938 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ The BankofAI command-line client for the x402 protocol — pay any x402-protecte `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 diff --git a/docs/manual-test-guide.md b/docs/manual-test-guide.md index 9726cb2..578bc2c 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.1b1 x402-cli --version agent-wallet --help | head -3 # confirm agent-wallet ships with the CLI ``` @@ -341,7 +341,7 @@ Verify on BscScan: `https://testnet.bscscan.com/tx/`. ``` # install -pip install --pre bankofai-x402-cli +pip install bankofai-x402-cli==0.6.1b1 # 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..d728735 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,107 @@ +# 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.1b1 +x402-cli --version +``` + +Expected: + +```text +x402-cli, version 0.6.1b1 +``` + +## 2. Find a Paid API + +Search the public catalog: + +```bash +x402-cli catalog update +x402-cli catalog search "weather" +x402-cli catalog show acme-weather +x402-cli catalog endpoints acme-weather +x402-cli catalog pay-json acme-weather +``` + +Use a local catalog during development: + +```bash +x402-cli catalog search "weather" \ + --catalog ../x402-catelog/dist/catalog.json \ + --json +``` + +## 3. Call a Paid API + +After choosing an endpoint from the catalog: + +```bash +x402-cli pay 'https://gateway.bankofai.io/providers/acme-weather/v1/current?city=Shanghai' +``` + +For a dry run that reads the payment requirement without signing: + +```bash +x402-cli pay \ + 'https://gateway.bankofai.io/providers/acme-weather/v1/current?city=Shanghai' \ + --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 "current weather for a city" --json +x402-cli catalog pay-json acme-weather +x402-cli pay 'https://gateway.bankofai.io/providers/acme-weather/v1/current?city=Shanghai' +``` + +The catalog response gives the provider FQN, endpoint URL, price range, chains, +and human/agent-readable usage text. From 98458ac1644ff24b0004802681920626fb093b1c Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Mon, 8 Jun 2026 11:18:41 +0800 Subject: [PATCH 09/18] Harden CLI CI workflow --- .github/workflows/test.yml | 25 +++++++++++++++---------- src/bankofai/x402_cli/catalog_cmd.py | 8 ++++++-- src/bankofai/x402_cli/gateway_search.py | 8 ++++++-- 3 files changed, 27 insertions(+), 14 deletions(-) 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/src/bankofai/x402_cli/catalog_cmd.py b/src/bankofai/x402_cli/catalog_cmd.py index a0886aa..191ea54 100644 --- a/src/bankofai/x402_cli/catalog_cmd.py +++ b/src/bankofai/x402_cli/catalog_cmd.py @@ -25,8 +25,12 @@ def _read_json(source: str) -> dict[str, Any]: if source.startswith(("http://", "https://")): response = httpx.get(source, timeout=15.0) response.raise_for_status() - return response.json() - return json.loads(Path(source).read_text(encoding="utf-8")) + 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: diff --git a/src/bankofai/x402_cli/gateway_search.py b/src/bankofai/x402_cli/gateway_search.py index 5213819..dd367b0 100644 --- a/src/bankofai/x402_cli/gateway_search.py +++ b/src/bankofai/x402_cli/gateway_search.py @@ -51,8 +51,12 @@ def _read_json(source: str) -> dict[str, Any]: if source.startswith(("http://", "https://")): response = httpx.get(source, timeout=10.0) response.raise_for_status() - return response.json() - return json.loads(Path(source).read_text()) + 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: From 9bf997505aeaac14e605068bd30f818e62acfa3d Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Tue, 9 Jun 2026 10:28:53 +0800 Subject: [PATCH 10/18] Improve catalog search metadata matching --- src/bankofai/x402_cli/gateway_search.py | 78 ++++++++++++++++++++++++- tests/test_gateway_search.py | 41 +++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/src/bankofai/x402_cli/gateway_search.py b/src/bankofai/x402_cli/gateway_search.py index dd367b0..cab70c0 100644 --- a/src/bankofai/x402_cli/gateway_search.py +++ b/src/bankofai/x402_cli/gateway_search.py @@ -20,6 +20,13 @@ class GatewaySearchHit: 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 @@ -33,6 +40,13 @@ def to_dict(self) -> dict[str, Any]: "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, @@ -80,8 +94,12 @@ def _read_provider_detail(catalog_source: str, fqn: str) -> dict[str, Any]: "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, @@ -131,6 +149,33 @@ def _endpoint_fields(endpoints: list[dict[str, Any]]) -> list[str]: 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, *, @@ -161,10 +206,34 @@ def search_gateway_catalog( 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 "")], + "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 "") ], @@ -184,6 +253,13 @@ def search_gateway_catalog( 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, diff --git a/tests/test_gateway_search.py b/tests/test_gateway_search.py index 4ed9315..9045c49 100644 --- a/tests/test_gateway_search.py +++ b/tests/test_gateway_search.py @@ -36,9 +36,35 @@ def _write_catalog(tmp_path: Path) -> Path: { "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": [ @@ -75,6 +101,21 @@ def test_search_gateway_catalog_reads_dist_details(tmp_path: Path) -> None: 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: From c4211eeea9aa685584ee04aa3a9196c71b528086 Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Wed, 10 Jun 2026 10:00:50 +0800 Subject: [PATCH 11/18] Add bilingual CLI documentation --- README.md | 39 +++++++++++++++++++++++++++++++++++++++ examples/README.md | 27 +++++++++++++++++++++++++++ specs/README.md | 15 +++++++++++++++ 3 files changed, 81 insertions(+) diff --git a/README.md b/README.md index b339938..6e77ce7 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,44 @@ # `x402-cli` +## 中文说明 + +`x402-cli` 是用户侧唯一需要记住的命令入口。它集成了三类能力: + +- `x402-cli pay `:调用 x402 付费接口,自动处理 402 challenge、签名和重试。 +- `x402-cli catalog ...`:搜索公开 Catalog,找到适合的 API、endpoint、价格和调用说明。 +- `x402-cli gateway ...`:服务方本地启动 Gateway、校验 provider、导出公开 Catalog PR 文件。 + +安装: + +```bash +pip install bankofai-x402-cli==0.6.1b1 +x402-cli --version +``` + +典型使用流程: + +```bash +x402-cli catalog update +x402-cli catalog search "weather" +x402-cli catalog show acme-weather +x402-cli catalog endpoints acme-weather +x402-cli pay 'https://gateway.example.com/providers/acme-weather/v1/current?city=Shanghai' +``` + +服务方提交流程: + +```bash +x402-cli gateway check providers/acme-weather/provider.yml +x402-cli gateway start --providers-dir providers --host 0.0.0.0 --port 4020 +x402-cli catalog export-gateway https://gateway.example.com \ + --provider acme-weather \ + --output-dir providers/acme-weather +``` + +只把导出的 `catalog.json` 和 `pay.md` 提交到 `x402-catelog`。不要提交 `provider.yml`、`.env`、API key、bearer token 或钱包私钥。 + +## English + 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`. diff --git a/examples/README.md b/examples/README.md index d728735..5cb1668 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,5 +1,32 @@ # x402-cli Community Examples +## 中文说明 + +这里是社区用户使用 `x402-cli` 的复制粘贴示例。`x402-cli` 是唯一用户入口,支付、Catalog 搜索、Gateway 操作都在这一个命令下。 + +常用流程: + +```bash +pip install bankofai-x402-cli==0.6.1b1 +x402-cli catalog update +x402-cli catalog search "weather" +x402-cli catalog show acme-weather +x402-cli catalog endpoints acme-weather +x402-cli pay 'https://gateway.bankofai.io/providers/acme-weather/v1/current?city=Shanghai' +``` + +服务方导出公开 PR 文件: + +```bash +x402-cli catalog export-gateway https://gateway.example.com \ + --provider acme-weather \ + --output-dir providers/acme-weather +``` + +只提交 `catalog.json` 和 `pay.md`,不要提交 `provider.yml`、`.env` 或任何密钥。 + +## English + 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`. 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 From 678e3b8cd2bb262a79c674ad725d56ef1a1f2bc1 Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Thu, 11 Jun 2026 15:36:38 +0800 Subject: [PATCH 12/18] Add x402 MCP server --- CHANGELOG.md | 11 + README.md | 4 +- docs/manual-test-guide.md | 4 +- examples/README.md | 6 +- mcp/README.md | 47 ++++ pyproject.toml | 3 +- src/bankofai/x402_cli/__init__.py | 2 +- src/bankofai/x402_cli/mcp_server.py | 347 ++++++++++++++++++++++++++++ tests/test_mcp_server.py | 106 +++++++++ 9 files changed, 521 insertions(+), 9 deletions(-) create mode 100644 mcp/README.md create mode 100644 src/bankofai/x402_cli/mcp_server.py create mode 100644 tests/test_mcp_server.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a4ff8d0..2593c73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ 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.1b2] — 2026-06-11 + +### Added + +- **`x402-mcp` stdio server** — exposes the existing catalog and payment flows as MCP tools for agents. Tools include `catalog_search`, `catalog_show`, `catalog_endpoints`, `catalog_pay_json`, `x402_pay`, and `wallet_status`. +- **MCP setup guide** — `mcp/README.md` documents Claude Code and Codex CLI configuration using the same `bankofai-x402-cli` package. + +### Notes + +- The MCP server reuses `x402-cli` and Agent Wallet behavior; there is no separate payment implementation or wallet path. + ## [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 6e77ce7..6fe80e8 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ 安装: ```bash -pip install bankofai-x402-cli==0.6.1b1 +pip install bankofai-x402-cli==0.6.1b2 x402-cli --version ``` @@ -48,7 +48,7 @@ Community copy-paste examples live in [`examples/README.md`](examples/README.md) ## 1. Install ```bash -pip install bankofai-x402-cli==0.6.1b1 +pip install bankofai-x402-cli==0.6.1b2 x402-cli --version ``` diff --git a/docs/manual-test-guide.md b/docs/manual-test-guide.md index 578bc2c..54bebbb 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 bankofai-x402-cli==0.6.1b1 +pip install bankofai-x402-cli==0.6.1b2 x402-cli --version agent-wallet --help | head -3 # confirm agent-wallet ships with the CLI ``` @@ -341,7 +341,7 @@ Verify on BscScan: `https://testnet.bscscan.com/tx/`. ``` # install -pip install bankofai-x402-cli==0.6.1b1 +pip install bankofai-x402-cli==0.6.1b2 # wallet (one-time, plaintext for testing) export AGENT_WALLET_DIR=/tmp/x402-test-wallet diff --git a/examples/README.md b/examples/README.md index 5cb1668..5f25946 100644 --- a/examples/README.md +++ b/examples/README.md @@ -7,7 +7,7 @@ 常用流程: ```bash -pip install bankofai-x402-cli==0.6.1b1 +pip install bankofai-x402-cli==0.6.1b2 x402-cli catalog update x402-cli catalog search "weather" x402-cli catalog show acme-weather @@ -34,14 +34,14 @@ gateway operations all live under `x402-cli`. ## 1. Install ```bash -pip install bankofai-x402-cli==0.6.1b1 +pip install bankofai-x402-cli==0.6.1b2 x402-cli --version ``` Expected: ```text -x402-cli, version 0.6.1b1 +x402-cli, version 0.6.1b2 ``` ## 2. Find a Paid API diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 0000000..e7cc8af --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,47 @@ +# x402 MCP + +`x402-mcp` exposes the existing `x402-cli` catalog and payment flows as MCP tools for coding agents. + +## Install + +```bash +pip install bankofai-x402-cli==0.6.1b2 +x402-cli --version +python -c "import bankofai.x402_cli.mcp_server; print('x402-mcp ready')" +``` + +Configure Agent Wallet once before paying protected endpoints: + +```bash +npm i -g @bankofai/agent-wallet +agent-wallet start raw_secret --wallet-id payer --private-key 0x... +``` + +## Claude Code + +```bash +claude mcp add x402 \ + --scope user \ + -- x402-mcp +``` + +## Codex CLI + +Add a server entry to `~/.codex/config.toml`: + +```toml +[mcp.servers.x402] +command = "x402-mcp" +args = [] +``` + +Restart Codex after changing MCP configuration. + +## Tools + +- `catalog_search`: search Bank of AI x402 catalog providers. +- `catalog_show`: show provider details. +- `catalog_endpoints`: list provider endpoints, prices, and x402 routes. +- `catalog_pay_json`: return provider pay metadata. +- `x402_pay`: pay an x402-protected URL through the configured Agent Wallet. +- `wallet_status`: check whether Agent Wallet is installed and can resolve addresses. diff --git a/pyproject.toml b/pyproject.toml index 2d90f0a..c3c971c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "bankofai-x402-cli" -version = "0.6.1b1" +version = "0.6.1b2" 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" } @@ -48,6 +48,7 @@ dev = [ [project.scripts] x402-cli = "bankofai.x402_cli.cli:main" +x402-mcp = "bankofai.x402_cli.mcp_server:main" [project.urls] Homepage = "https://github.com/BofAI/x402-cli#readme" diff --git a/src/bankofai/x402_cli/__init__.py b/src/bankofai/x402_cli/__init__.py index 343689b..90cac8e 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.6.1b1" +__version__ = "0.6.1b2" diff --git a/src/bankofai/x402_cli/mcp_server.py b/src/bankofai/x402_cli/mcp_server.py new file mode 100644 index 0000000..698ca70 --- /dev/null +++ b/src/bankofai/x402_cli/mcp_server.py @@ -0,0 +1,347 @@ +"""Minimal MCP stdio server for x402-cli. + +The server intentionally reuses the existing catalog and pay implementation so +agent integrations behave like the command line users already test. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import io +import json +import subprocess +import sys +from typing import Any, Callable + +from bankofai.x402_cli import __version__, _tron_patch +from bankofai.x402_cli.catalog_cmd import ( + _catalog_source, + _detail_source, + _pay_source, + _read_json, +) +from bankofai.x402_cli.client_cmd import cmd_client +from bankofai.x402_cli.gateway_search import search_gateway_catalog + +_tron_patch.install() + +JsonObject = dict[str, Any] + + +def _json_text(payload: Any) -> str: + return json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + + +def _tool_result(payload: Any) -> JsonObject: + return {"content": [{"type": "text", "text": _json_text(payload)}]} + + +def _text_result(text: str) -> JsonObject: + return {"content": [{"type": "text", "text": text}]} + + +def _require_str(args: JsonObject, key: str) -> str: + value = args.get(key) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{key} is required") + return value + + +def _optional_str(args: JsonObject, key: str) -> str | None: + value = args.get(key) + if value is None: + return None + if not isinstance(value, str): + raise ValueError(f"{key} must be a string") + return value + + +def _optional_int(args: JsonObject, key: str, default: int) -> int: + value = args.get(key, default) + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"{key} must be an integer") + return value + + +def _catalog_search(args: JsonObject) -> JsonObject: + query = _require_str(args, "query") + catalog = _optional_str(args, "catalog") + limit = _optional_int(args, "limit", 10) + hits = search_gateway_catalog(query, catalog=_catalog_source(catalog), limit=limit) + return _tool_result( + { + "query": query, + "count": len(hits), + "results": [hit.to_dict() for hit in hits], + } + ) + + +def _catalog_show(args: JsonObject) -> JsonObject: + fqn = _require_str(args, "fqn") + catalog = _catalog_source(_optional_str(args, "catalog")) + return _tool_result(_read_json(_detail_source(catalog, fqn))) + + +def _catalog_endpoints(args: JsonObject) -> JsonObject: + fqn = _require_str(args, "fqn") + catalog = _catalog_source(_optional_str(args, "catalog")) + detail = _read_json(_detail_source(catalog, fqn)) + return _tool_result({"fqn": fqn, "endpoints": detail.get("endpoints", [])}) + + +def _catalog_pay_json(args: JsonObject) -> JsonObject: + fqn = _require_str(args, "fqn") + catalog = _catalog_source(_optional_str(args, "catalog")) + return _tool_result(_read_json(_pay_source(catalog, fqn))) + + +async def _x402_pay(args: JsonObject) -> JsonObject: + url = _require_str(args, "url") + method = str(args.get("method") or "GET").upper() + network = _optional_str(args, "network") + token = _optional_str(args, "token") or "USDT" + scheme = _optional_str(args, "scheme") + max_amount = _optional_str(args, "max_amount") + max_raw_amount = _optional_str(args, "max_raw_amount") + body = args.get("json") + raw_body = args.get("body") + headers_arg = args.get("headers") or {} + dry_run = bool(args.get("dry_run", False)) + + if body is not None and raw_body is not None: + raise ValueError("json and body are mutually exclusive") + if body is not None: + raw_body = json.dumps(body, ensure_ascii=False) + + headers: list[str] = [] + if headers_arg: + if not isinstance(headers_arg, dict): + raise ValueError("headers must be an object") + headers = [f"{key}: {value}" for key, value in headers_arg.items()] + if body is not None and not any(header.lower().startswith("content-type:") for header in headers): + headers.append("Content-Type: application/json") + + output = io.StringIO() + with contextlib.redirect_stdout(output): + await cmd_client( + url=url, + max_raw_amount=max_raw_amount, + max_amount=max_amount, + network=network, + token=token, + scheme=scheme, + method=method, + headers=tuple(headers), + body=raw_body if isinstance(raw_body, str) else None, + dry_run=dry_run, + output_mode="json", + ) + text = output.getvalue().strip() + try: + return _tool_result(json.loads(text)) + except json.JSONDecodeError: + return _text_result(text) + + +def _wallet_status(args: JsonObject) -> JsonObject: + timeout = _optional_int(args, "timeout_seconds", 10) + commands = [ + ["agent-wallet", "list"], + ["agent-wallet", "resolve-address"], + ] + result: dict[str, Any] = {} + for command in commands: + key = command[1].replace("-", "_") + try: + proc = subprocess.run( + command, + check=False, + capture_output=True, + text=True, + timeout=timeout, + ) + result[key] = { + "ok": proc.returncode == 0, + "exit_code": proc.returncode, + "stdout": proc.stdout.strip(), + "stderr": proc.stderr.strip(), + } + except FileNotFoundError: + result[key] = { + "ok": False, + "exit_code": None, + "stdout": "", + "stderr": "agent-wallet command not found", + } + break + return _tool_result(result) + + +TOOLS: dict[str, Callable[[JsonObject], JsonObject] | Callable[[JsonObject], Any]] = { + "catalog_search": _catalog_search, + "catalog_show": _catalog_show, + "catalog_endpoints": _catalog_endpoints, + "catalog_pay_json": _catalog_pay_json, + "x402_pay": _x402_pay, + "wallet_status": _wallet_status, +} + + +TOOL_DEFINITIONS: list[JsonObject] = [ + { + "name": "catalog_search", + "description": "Search the Bank of AI x402 catalog by use case, category, endpoint, chain, or tag.", + "inputSchema": { + "type": "object", + "properties": { + "query": {"type": "string"}, + "catalog": {"type": "string"}, + "limit": {"type": "integer", "default": 10}, + }, + "required": ["query"], + }, + }, + { + "name": "catalog_show", + "description": "Show detailed metadata for a catalog provider.", + "inputSchema": { + "type": "object", + "properties": {"fqn": {"type": "string"}, "catalog": {"type": "string"}}, + "required": ["fqn"], + }, + }, + { + "name": "catalog_endpoints", + "description": "List callable endpoints, prices, and x402 routes for a provider.", + "inputSchema": { + "type": "object", + "properties": {"fqn": {"type": "string"}, "catalog": {"type": "string"}}, + "required": ["fqn"], + }, + }, + { + "name": "catalog_pay_json", + "description": "Return the machine-readable pay.json for a provider.", + "inputSchema": { + "type": "object", + "properties": {"fqn": {"type": "string"}, "catalog": {"type": "string"}}, + "required": ["fqn"], + }, + }, + { + "name": "x402_pay", + "description": "Pay an x402-protected URL using x402-cli and the configured agent-wallet.", + "inputSchema": { + "type": "object", + "properties": { + "url": {"type": "string"}, + "method": {"type": "string", "default": "GET"}, + "network": {"type": "string"}, + "token": {"type": "string", "default": "USDT"}, + "scheme": {"type": "string"}, + "max_amount": {"type": "string"}, + "max_raw_amount": {"type": "string"}, + "headers": {"type": "object"}, + "json": {}, + "body": {"type": "string"}, + "dry_run": {"type": "boolean", "default": False}, + }, + "required": ["url"], + }, + }, + { + "name": "wallet_status", + "description": "Check whether agent-wallet is installed and can resolve active wallet addresses.", + "inputSchema": { + "type": "object", + "properties": {"timeout_seconds": {"type": "integer", "default": 10}}, + }, + }, +] + + +async def handle_message(message: JsonObject) -> JsonObject | None: + method = message.get("method") + request_id = message.get("id") + if request_id is None: + return None + + try: + result: JsonObject + if method == "initialize": + result = { + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "x402-mcp", "version": __version__}, + } + elif method == "tools/list": + result = {"tools": TOOL_DEFINITIONS} + elif method == "tools/call": + params = message.get("params") or {} + if not isinstance(params, dict): + raise ValueError("params must be an object") + name = _require_str(params, "name") + arguments = params.get("arguments") or {} + if not isinstance(arguments, dict): + raise ValueError("arguments must be an object") + tool = TOOLS.get(name) + if tool is None: + raise ValueError(f"unknown tool: {name}") + maybe_result = tool(arguments) + result = await maybe_result if asyncio.iscoroutine(maybe_result) else maybe_result + else: + raise ValueError(f"unsupported method: {method}") + return {"jsonrpc": "2.0", "id": request_id, "result": result} + except Exception as exc: + return { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32000, "message": str(exc)}, + } + + +def _read_frame(stdin: Any) -> JsonObject | None: + headers: dict[str, str] = {} + while True: + line = stdin.buffer.readline() + if not line: + return None + if line in {b"\r\n", b"\n"}: + break + key, _, value = line.decode("ascii").partition(":") + headers[key.lower()] = value.strip() + length = int(headers.get("content-length", "0")) + if length <= 0: + return None + body = stdin.buffer.read(length) + payload = json.loads(body.decode("utf-8")) + if not isinstance(payload, dict): + raise ValueError("MCP frame body must be a JSON object") + return payload + + +def _write_frame(stdout: Any, payload: JsonObject) -> None: + body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + stdout.buffer.write(f"Content-Length: {len(body)}\r\n\r\n".encode("ascii")) + stdout.buffer.write(body) + stdout.buffer.flush() + + +async def run_stdio() -> None: + while True: + message = _read_frame(sys.stdin) + if message is None: + return + response = await handle_message(message) + if response is not None: + _write_frame(sys.stdout, response) + + +def main() -> None: + asyncio.run(run_stdio()) + + +if __name__ == "__main__": + main() diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py new file mode 100644 index 0000000..1ba73d4 --- /dev/null +++ b/tests/test_mcp_server.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from bankofai.x402_cli import mcp_server +from tests.test_catalog_cmd import _write_public_catalog + + +@pytest.mark.asyncio +async def test_mcp_lists_tools() -> None: + response = await mcp_server.handle_message( + {"jsonrpc": "2.0", "id": 1, "method": "tools/list"} + ) + + assert response is not None + assert response["id"] == 1 + names = {tool["name"] for tool in response["result"]["tools"]} + assert {"catalog_search", "catalog_show", "x402_pay", "wallet_status"} <= names + + +@pytest.mark.asyncio +async def test_mcp_catalog_tools_use_public_catalog(tmp_path: Path) -> None: + catalog = _write_public_catalog(tmp_path) + + search = await mcp_server.handle_message( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "catalog_search", + "arguments": {"query": "weather", "catalog": str(catalog)}, + }, + } + ) + assert search is not None + search_payload = json.loads(search["result"]["content"][0]["text"]) + assert search_payload["results"][0]["fqn"] == "acme-weather" + + endpoints = await mcp_server.handle_message( + { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "catalog_endpoints", + "arguments": {"fqn": "acme-weather", "catalog": str(catalog)}, + }, + } + ) + assert endpoints is not None + endpoints_payload = json.loads(endpoints["result"]["content"][0]["text"]) + assert endpoints_payload["endpoints"][0]["path"] == "/v1/current" + + +@pytest.mark.asyncio +async def test_mcp_unknown_tool_returns_error() -> None: + response = await mcp_server.handle_message( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "missing", "arguments": {}}, + } + ) + + assert response is not None + assert "error" in response + assert "unknown tool" in response["error"]["message"] + + +@pytest.mark.asyncio +async def test_mcp_x402_pay_returns_cli_json(monkeypatch) -> None: + async def fake_cmd_client(**kwargs): + print(json.dumps({"ok": True, "command": "client", "result": kwargs})) + + monkeypatch.setattr(mcp_server, "cmd_client", fake_cmd_client) + + response = await mcp_server.handle_message( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "x402_pay", + "arguments": { + "url": "https://gw.example.com/providers/acme/v1/models", + "network": "eip155:97", + "token": "USDT", + "scheme": "exact_permit", + "json": {"hello": "world"}, + }, + }, + } + ) + + assert response is not None + payload = json.loads(response["result"]["content"][0]["text"]) + assert payload["ok"] is True + result = payload["result"] + assert result["url"] == "https://gw.example.com/providers/acme/v1/models" + assert result["headers"] == ["Content-Type: application/json"] + assert json.loads(result["body"]) == {"hello": "world"} From d498090d56ddeff5eb71dd348c80408c680ecd70 Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Thu, 11 Jun 2026 16:31:43 +0800 Subject: [PATCH 13/18] Point CLI defaults at test catalog --- CHANGELOG.md | 7 +++++++ README.md | 6 +++--- docs/manual-test-guide.md | 4 ++-- examples/README.md | 14 +++++++------- mcp/README.md | 2 +- pyproject.toml | 2 +- src/bankofai/x402_cli/__init__.py | 2 +- src/bankofai/x402_cli/catalog_cmd.py | 2 +- src/bankofai/x402_cli/gateway_search.py | 2 +- 9 files changed, 24 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2593c73..6729f06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ 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.1b3] — 2026-06-11 + +### Changed + +- Point the default hosted catalog URL at `https://tm-x402-catelog.bankofai.io/api/catalog.json`. +- Refresh Bank of AI gateway examples to `https://tm-x402-gateway.bankofai.io`. + ## [0.6.1b2] — 2026-06-11 ### Added diff --git a/README.md b/README.md index 6fe80e8..6f2ac3a 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ 安装: ```bash -pip install bankofai-x402-cli==0.6.1b2 +pip install bankofai-x402-cli==0.6.1b3 x402-cli --version ``` @@ -48,7 +48,7 @@ Community copy-paste examples live in [`examples/README.md`](examples/README.md) ## 1. Install ```bash -pip install bankofai-x402-cli==0.6.1b2 +pip install bankofai-x402-cli==0.6.1b3 x402-cli --version ``` @@ -80,7 +80,7 @@ agent-wallet start raw_secret \ 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://catalog.bankofai.io/api/catalog.json +export X402_CATALOG=https://tm-x402-catelog.bankofai.io/api/catalog.json x402-cli catalog update x402-cli catalog search "weather" x402-cli catalog show acme-weather diff --git a/docs/manual-test-guide.md b/docs/manual-test-guide.md index 54bebbb..34441c7 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 bankofai-x402-cli==0.6.1b2 +pip install bankofai-x402-cli==0.6.1b3 x402-cli --version agent-wallet --help | head -3 # confirm agent-wallet ships with the CLI ``` @@ -341,7 +341,7 @@ Verify on BscScan: `https://testnet.bscscan.com/tx/`. ``` # install -pip install bankofai-x402-cli==0.6.1b2 +pip install bankofai-x402-cli==0.6.1b3 # wallet (one-time, plaintext for testing) export AGENT_WALLET_DIR=/tmp/x402-test-wallet diff --git a/examples/README.md b/examples/README.md index 5f25946..d347167 100644 --- a/examples/README.md +++ b/examples/README.md @@ -7,12 +7,12 @@ 常用流程: ```bash -pip install bankofai-x402-cli==0.6.1b2 +pip install bankofai-x402-cli==0.6.1b3 x402-cli catalog update x402-cli catalog search "weather" x402-cli catalog show acme-weather x402-cli catalog endpoints acme-weather -x402-cli pay 'https://gateway.bankofai.io/providers/acme-weather/v1/current?city=Shanghai' +x402-cli pay 'https://tm-x402-gateway.bankofai.io/providers/acme-weather/v1/current?city=Shanghai' ``` 服务方导出公开 PR 文件: @@ -34,14 +34,14 @@ gateway operations all live under `x402-cli`. ## 1. Install ```bash -pip install bankofai-x402-cli==0.6.1b2 +pip install bankofai-x402-cli==0.6.1b3 x402-cli --version ``` Expected: ```text -x402-cli, version 0.6.1b2 +x402-cli, version 0.6.1b3 ``` ## 2. Find a Paid API @@ -69,14 +69,14 @@ x402-cli catalog search "weather" \ After choosing an endpoint from the catalog: ```bash -x402-cli pay 'https://gateway.bankofai.io/providers/acme-weather/v1/current?city=Shanghai' +x402-cli pay 'https://tm-x402-gateway.bankofai.io/providers/acme-weather/v1/current?city=Shanghai' ``` For a dry run that reads the payment requirement without signing: ```bash x402-cli pay \ - 'https://gateway.bankofai.io/providers/acme-weather/v1/current?city=Shanghai' \ + 'https://tm-x402-gateway.bankofai.io/providers/acme-weather/v1/current?city=Shanghai' \ --dry-run \ --json ``` @@ -127,7 +127,7 @@ Agents should use the catalog first, then call the selected endpoint: ```bash x402-cli catalog search "current weather for a city" --json x402-cli catalog pay-json acme-weather -x402-cli pay 'https://gateway.bankofai.io/providers/acme-weather/v1/current?city=Shanghai' +x402-cli pay 'https://tm-x402-gateway.bankofai.io/providers/acme-weather/v1/current?city=Shanghai' ``` The catalog response gives the provider FQN, endpoint URL, price range, chains, diff --git a/mcp/README.md b/mcp/README.md index e7cc8af..8a6f16d 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -5,7 +5,7 @@ ## Install ```bash -pip install bankofai-x402-cli==0.6.1b2 +pip install bankofai-x402-cli==0.6.1b3 x402-cli --version python -c "import bankofai.x402_cli.mcp_server; print('x402-mcp ready')" ``` diff --git a/pyproject.toml b/pyproject.toml index c3c971c..a41c21c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "bankofai-x402-cli" -version = "0.6.1b2" +version = "0.6.1b3" 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" } diff --git a/src/bankofai/x402_cli/__init__.py b/src/bankofai/x402_cli/__init__.py index 90cac8e..4de7efa 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.6.1b2" +__version__ = "0.6.1b3" diff --git a/src/bankofai/x402_cli/catalog_cmd.py b/src/bankofai/x402_cli/catalog_cmd.py index 191ea54..00a1f05 100644 --- a/src/bankofai/x402_cli/catalog_cmd.py +++ b/src/bankofai/x402_cli/catalog_cmd.py @@ -88,7 +88,7 @@ def _submission_catalog(detail: dict[str, Any]) -> dict[str, Any]: "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://catalog.bankofai.io/assets/providers/default.png", + "logo": detail.get("logo") or "https://tm-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")), diff --git a/src/bankofai/x402_cli/gateway_search.py b/src/bankofai/x402_cli/gateway_search.py index cab70c0..0ee0b99 100644 --- a/src/bankofai/x402_cli/gateway_search.py +++ b/src/bankofai/x402_cli/gateway_search.py @@ -57,7 +57,7 @@ def to_dict(self) -> dict[str, Any]: def default_catalog() -> str: return os.environ.get( "X402_CATALOG", - os.environ.get("X402_GATEWAY_CATALOG", "https://catalog.bankofai.io/api/catalog.json"), + os.environ.get("X402_GATEWAY_CATALOG", "https://tm-x402-catelog.bankofai.io/api/catalog.json"), ) From 44d7b6885fa2acb8b654ff210178b330790e402a Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Thu, 11 Jun 2026 17:01:40 +0800 Subject: [PATCH 14/18] Cache catalog detail files and return pay responses --- CHANGELOG.md | 3 +- README.md | 4 +- docs/manual-test-guide.md | 4 +- examples/README.md | 6 +-- mcp/README.md | 2 +- pyproject.toml | 2 +- src/bankofai/x402_cli/__init__.py | 2 +- src/bankofai/x402_cli/catalog_cmd.py | 79 ++++++++++++++++++++++++++-- src/bankofai/x402_cli/client_cmd.py | 15 +++++- src/bankofai/x402_cli/output.py | 6 ++- tests/test_catalog_cmd.py | 55 +++++++++++++++++++ 11 files changed, 160 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6729f06..b0698be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,13 @@ 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.1b3] — 2026-06-11 +## [0.6.1b4] — 2026-06-11 ### Changed - Point the default hosted catalog URL at `https://tm-x402-catelog.bankofai.io/api/catalog.json`. - Refresh Bank of AI gateway examples to `https://tm-x402-gateway.bankofai.io`. +- 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.6.1b2] — 2026-06-11 diff --git a/README.md b/README.md index 6f2ac3a..a20a0ba 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ 安装: ```bash -pip install bankofai-x402-cli==0.6.1b3 +pip install bankofai-x402-cli==0.6.1b4 x402-cli --version ``` @@ -48,7 +48,7 @@ Community copy-paste examples live in [`examples/README.md`](examples/README.md) ## 1. Install ```bash -pip install bankofai-x402-cli==0.6.1b3 +pip install bankofai-x402-cli==0.6.1b4 x402-cli --version ``` diff --git a/docs/manual-test-guide.md b/docs/manual-test-guide.md index 34441c7..84f349d 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 bankofai-x402-cli==0.6.1b3 +pip install bankofai-x402-cli==0.6.1b4 x402-cli --version agent-wallet --help | head -3 # confirm agent-wallet ships with the CLI ``` @@ -341,7 +341,7 @@ Verify on BscScan: `https://testnet.bscscan.com/tx/`. ``` # install -pip install bankofai-x402-cli==0.6.1b3 +pip install bankofai-x402-cli==0.6.1b4 # wallet (one-time, plaintext for testing) export AGENT_WALLET_DIR=/tmp/x402-test-wallet diff --git a/examples/README.md b/examples/README.md index d347167..aceac6e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -7,7 +7,7 @@ 常用流程: ```bash -pip install bankofai-x402-cli==0.6.1b3 +pip install bankofai-x402-cli==0.6.1b4 x402-cli catalog update x402-cli catalog search "weather" x402-cli catalog show acme-weather @@ -34,14 +34,14 @@ gateway operations all live under `x402-cli`. ## 1. Install ```bash -pip install bankofai-x402-cli==0.6.1b3 +pip install bankofai-x402-cli==0.6.1b4 x402-cli --version ``` Expected: ```text -x402-cli, version 0.6.1b3 +x402-cli, version 0.6.1b4 ``` ## 2. Find a Paid API diff --git a/mcp/README.md b/mcp/README.md index 8a6f16d..a520e62 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -5,7 +5,7 @@ ## Install ```bash -pip install bankofai-x402-cli==0.6.1b3 +pip install bankofai-x402-cli==0.6.1b4 x402-cli --version python -c "import bankofai.x402_cli.mcp_server; print('x402-mcp ready')" ``` diff --git a/pyproject.toml b/pyproject.toml index a41c21c..6d5540e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "bankofai-x402-cli" -version = "0.6.1b3" +version = "0.6.1b4" 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" } diff --git a/src/bankofai/x402_cli/__init__.py b/src/bankofai/x402_cli/__init__.py index 4de7efa..1e45213 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.6.1b3" +__version__ = "0.6.1b4" diff --git a/src/bankofai/x402_cli/catalog_cmd.py b/src/bankofai/x402_cli/catalog_cmd.py index 00a1f05..4d30927 100644 --- a/src/bankofai/x402_cli/catalog_cmd.py +++ b/src/bankofai/x402_cli/catalog_cmd.py @@ -41,6 +41,10 @@ def _write_json(path: Path, payload: dict[str, Any]) -> None: ) +def _provider_filename(fqn: str) -> str: + return f"{fqn.replace('/', '__')}.json" + + def _catalog_source(catalog: str | None) -> str: if catalog: return catalog @@ -50,20 +54,82 @@ def _catalog_source(catalog: str | None) -> str: 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 = f"{fqn.replace('/', '__')}.json" + filename = _provider_filename(fqn) if catalog_source.startswith(("http://", "https://")): base = catalog_source.rsplit("/", 1)[0].rstrip("/") + "/" return urljoin(base, f"providers/{filename}") - return str(Path(catalog_source).parent / "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 = f"{fqn.replace('/', '__')}.json" + filename = _provider_filename(fqn) if catalog_source.startswith(("http://", "https://")): base = catalog_source.rsplit("/", 1)[0].rstrip("/") + "/" return urljoin(base, f"pay/{filename}") - return str(Path(catalog_source).parent / "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]: @@ -192,15 +258,20 @@ def update(catalog_url: str | None, output_json: bool) -> None: 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())) 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/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 index 1f9a1ba..821b796 100644 --- a/tests/test_catalog_cmd.py +++ b/tests/test_catalog_cmd.py @@ -17,6 +17,7 @@ def _write_public_catalog(tmp_path: Path) -> Path: json.dumps( { "version": 1, + "base_url": "https://catalog.example.com/api", "provider_count": 1, "providers": [ { @@ -127,9 +128,63 @@ def test_catalog_update_caches_catalog( 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" / "acme-weather.json").read_text()) + pay_payload = json.loads((tmp_path / "dist" / "pay" / "acme-weather.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/acme-weather.json": + return detail_payload + if source == "https://catalog.example.com/api/pay/acme-weather.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" / "acme-weather.json").exists() + assert (cache_root / "pay" / "acme-weather.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" / "acme-weather.json").unlink() + (tmp_path / "dist" / "pay" / "acme-weather.json").unlink() + assert ( + catalog_cmd._detail_source(str(catalog), "acme-weather") + == "https://catalog.example.com/api/providers/acme-weather.json" + ) + assert ( + catalog_cmd._pay_source(str(catalog), "acme-weather") + == "https://catalog.example.com/api/pay/acme-weather.json" + ) + + def test_catalog_export_gateway_writes_pr_files(tmp_path: Path, monkeypatch) -> None: detail = { "fqn": "acme-weather", From 48ebdb5dcdc5968ecf8bcdf2f4b0481a758ca3dd Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Fri, 12 Jun 2026 11:51:39 +0800 Subject: [PATCH 15/18] Release CLI beta b6 with transfer failure hint --- CHANGELOG.md | 12 ++++++++++++ README.md | 4 ++-- docs/manual-test-guide.md | 5 +++-- pyproject.toml | 2 +- src/bankofai/x402_cli/__init__.py | 2 +- src/bankofai/x402_cli/errors.py | 15 +++++++++++++++ tests/test_errors.py | 10 ++++++++++ 7 files changed, 44 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0698be..954bbaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ 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.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 diff --git a/README.md b/README.md index a20a0ba..a867e5f 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ 安装: ```bash -pip install bankofai-x402-cli==0.6.1b4 +pip install bankofai-x402-cli==0.6.1b6 x402-cli --version ``` @@ -48,7 +48,7 @@ Community copy-paste examples live in [`examples/README.md`](examples/README.md) ## 1. Install ```bash -pip install bankofai-x402-cli==0.6.1b4 +pip install bankofai-x402-cli==0.6.1b6 x402-cli --version ``` diff --git a/docs/manual-test-guide.md b/docs/manual-test-guide.md index 84f349d..6665689 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 bankofai-x402-cli==0.6.1b4 +pip install bankofai-x402-cli==0.6.1b6 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 bankofai-x402-cli==0.6.1b4 +pip install bankofai-x402-cli==0.6.1b6 # wallet (one-time, plaintext for testing) export AGENT_WALLET_DIR=/tmp/x402-test-wallet diff --git a/pyproject.toml b/pyproject.toml index 6d5540e..716700c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "bankofai-x402-cli" -version = "0.6.1b4" +version = "0.6.1b6" 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" } diff --git a/src/bankofai/x402_cli/__init__.py b/src/bankofai/x402_cli/__init__.py index 1e45213..d2fad91 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.6.1b4" +__version__ = "0.6.1b6" 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/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" From 07c43095869d732753c11ccdb1c7abc61df43b64 Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Wed, 24 Jun 2026 19:35:20 +0800 Subject: [PATCH 16/18] Align CLI catalog defaults with production --- CHANGELOG.md | 8 ++ README.md | 62 ++++---- examples/README.md | 49 ++++--- pyproject.toml | 2 +- src/bankofai/x402_cli/__init__.py | 2 +- src/bankofai/x402_cli/catalog_cmd.py | 2 +- src/bankofai/x402_cli/cli.py | 6 +- src/bankofai/x402_cli/gateway_search.py | 2 +- tests/test_catalog_cmd.py | 179 ++++++++++++++---------- tests/test_mcp_server.py | 8 +- 10 files changed, 188 insertions(+), 132 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 954bbaf..c591740 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ 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.1b7] — 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 diff --git a/README.md b/README.md index a867e5f..25c6cfa 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ 安装: ```bash -pip install bankofai-x402-cli==0.6.1b6 +pip install bankofai-x402-cli==0.6.1b7 x402-cli --version ``` @@ -19,20 +19,26 @@ x402-cli --version ```bash x402-cli catalog update -x402-cli catalog search "weather" -x402-cli catalog show acme-weather -x402-cli catalog endpoints acme-weather -x402-cli pay 'https://gateway.example.com/providers/acme-weather/v1/current?city=Shanghai' +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 +x402-cli pay 'https://x402-gateway.bankofai.io/providers/sunpump-token-launch-tron/pump-api/ai/agentTokenLaunch' \ + --method POST \ + --network tron:mainnet \ + --scheme exact_permit \ + --token USDT \ + --json '{"name":"TestAutoLaunch","symbol":"TAL","description":"sun flower 666","imageBase64":"","twitterUrl":"","telegramUrl":"","websiteUrl":"","tweetUsername":""}' ``` 服务方提交流程: ```bash -x402-cli gateway check providers/acme-weather/provider.yml +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://gateway.example.com \ - --provider acme-weather \ - --output-dir providers/acme-weather +x402-cli catalog export-gateway https://x402-gateway.bankofai.io \ + --provider sunpump-token-launch-tron \ + --output-dir providers/sunpump-token-launch-tron ``` 只把导出的 `catalog.json` 和 `pay.md` 提交到 `x402-catelog`。不要提交 `provider.yml`、`.env`、API key、bearer token 或钱包私钥。 @@ -48,7 +54,7 @@ Community copy-paste examples live in [`examples/README.md`](examples/README.md) ## 1. Install ```bash -pip install bankofai-x402-cli==0.6.1b6 +pip install bankofai-x402-cli==0.6.1b7 x402-cli --version ``` @@ -80,22 +86,22 @@ agent-wallet start raw_secret \ 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://tm-x402-catelog.bankofai.io/api/catalog.json +export X402_CATALOG=https://x402-catelog.bankofai.io/api/catalog.json x402-cli catalog update -x402-cli catalog search "weather" -x402-cli catalog show acme-weather -x402-cli catalog endpoints acme-weather -x402-cli catalog pay-json acme-weather +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 acme-weather \ - --output-dir providers/acme-weather \ - --forward-url https://api.example.com +x402-cli gateway scaffold sunpump-token-launch-tron \ + --output-dir providers/sunpump-token-launch-tron \ + --forward-url https://tn-api.sunpump.meme -x402-cli gateway check providers/acme-weather/provider.yml +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 ``` @@ -114,19 +120,19 @@ Natural-language intent Provider onboarding flow: ```bash -x402-cli gateway check providers/acme-weather/provider.yml +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://gateway.example.com \ - --provider acme-weather \ - --output-dir providers/acme-weather +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/acme-weather/catalog.json -providers/acme-weather/pay.md +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. @@ -134,11 +140,11 @@ Do not submit `provider.yml`, `.env`, upstream API keys, bearer tokens, or passw Provider catalog build commands are also under `x402-cli`: ```bash -x402-cli gateway catalog generate providers/acme-weather/provider.yml -x402-cli gateway catalog pay-assets providers/acme-weather/provider.yml +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 weather +x402-cli gateway catalog search providers sunpump ``` ## 4. Copy-paste: a USDT transfer on TRON mainnet diff --git a/examples/README.md b/examples/README.md index aceac6e..92512f2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -7,12 +7,17 @@ 常用流程: ```bash -pip install bankofai-x402-cli==0.6.1b4 +pip install bankofai-x402-cli==0.6.1b7 x402-cli catalog update -x402-cli catalog search "weather" -x402-cli catalog show acme-weather -x402-cli catalog endpoints acme-weather -x402-cli pay 'https://tm-x402-gateway.bankofai.io/providers/acme-weather/v1/current?city=Shanghai' +x402-cli catalog search "defillama" +x402-cli catalog show defillama +x402-cli catalog endpoints 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 ``` 服务方导出公开 PR 文件: @@ -34,14 +39,14 @@ gateway operations all live under `x402-cli`. ## 1. Install ```bash -pip install bankofai-x402-cli==0.6.1b4 +pip install bankofai-x402-cli==0.6.1b7 x402-cli --version ``` Expected: ```text -x402-cli, version 0.6.1b4 +x402-cli, version 0.6.1b7 ``` ## 2. Find a Paid API @@ -50,16 +55,16 @@ Search the public catalog: ```bash x402-cli catalog update -x402-cli catalog search "weather" -x402-cli catalog show acme-weather -x402-cli catalog endpoints acme-weather -x402-cli catalog pay-json acme-weather +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 "weather" \ +x402-cli catalog search "defillama" \ --catalog ../x402-catelog/dist/catalog.json \ --json ``` @@ -69,14 +74,19 @@ x402-cli catalog search "weather" \ After choosing an endpoint from the catalog: ```bash -x402-cli pay 'https://tm-x402-gateway.bankofai.io/providers/acme-weather/v1/current?city=Shanghai' +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://tm-x402-gateway.bankofai.io/providers/acme-weather/v1/current?city=Shanghai' \ + 'https://x402-gateway.bankofai.io/providers/defillama-tvl-tron/protocols' \ --dry-run \ --json ``` @@ -125,9 +135,14 @@ Submit only these public files to `BofAI/x402-catelog`. Agents should use the catalog first, then call the selected endpoint: ```bash -x402-cli catalog search "current weather for a city" --json -x402-cli catalog pay-json acme-weather -x402-cli pay 'https://tm-x402-gateway.bankofai.io/providers/acme-weather/v1/current?city=Shanghai' +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, diff --git a/pyproject.toml b/pyproject.toml index 716700c..03d6131 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "bankofai-x402-cli" -version = "0.6.1b6" +version = "0.6.1b7" 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" } diff --git a/src/bankofai/x402_cli/__init__.py b/src/bankofai/x402_cli/__init__.py index d2fad91..a14ff99 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.6.1b6" +__version__ = "0.6.1b7" diff --git a/src/bankofai/x402_cli/catalog_cmd.py b/src/bankofai/x402_cli/catalog_cmd.py index 4d30927..5529147 100644 --- a/src/bankofai/x402_cli/catalog_cmd.py +++ b/src/bankofai/x402_cli/catalog_cmd.py @@ -154,7 +154,7 @@ def _submission_catalog(detail: dict[str, Any]) -> dict[str, Any]: "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://tm-x402-catelog.bankofai.io/assets/providers/default.png", + "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")), diff --git a/src/bankofai/x402_cli/cli.py b/src/bankofai/x402_cli/cli.py index ea38a20..680cbdb 100644 --- a/src/bankofai/x402_cli/cli.py +++ b/src/bankofai/x402_cli/cli.py @@ -93,8 +93,8 @@ def _run_gateway_command(*args: str) -> None: type=str, default=None, help=( - "Catalog source: local dist/skills.json or HTTPS URL. " - "Defaults to $X402_GATEWAY_CATALOG or dist/skills.json." + "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.") @@ -114,7 +114,7 @@ def gateway_search( """Search an x402-gateway catalog for capabilities. Example: - x402-cli gateway search "weather" + x402-cli gateway search "token launch" """ catalog_source = catalog or default_catalog() try: diff --git a/src/bankofai/x402_cli/gateway_search.py b/src/bankofai/x402_cli/gateway_search.py index 0ee0b99..4d4f4c9 100644 --- a/src/bankofai/x402_cli/gateway_search.py +++ b/src/bankofai/x402_cli/gateway_search.py @@ -57,7 +57,7 @@ def to_dict(self) -> dict[str, Any]: def default_catalog() -> str: return os.environ.get( "X402_CATALOG", - os.environ.get("X402_GATEWAY_CATALOG", "https://tm-x402-catelog.bankofai.io/api/catalog.json"), + os.environ.get("X402_GATEWAY_CATALOG", "https://x402-catelog.bankofai.io/api/catalog.json"), ) diff --git a/tests/test_catalog_cmd.py b/tests/test_catalog_cmd.py index 821b796..1e585cf 100644 --- a/tests/test_catalog_cmd.py +++ b/tests/test_catalog_cmd.py @@ -13,6 +13,42 @@ 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( { @@ -21,58 +57,43 @@ def _write_public_catalog(tmp_path: Path) -> Path: "provider_count": 1, "providers": [ { - "fqn": "acme-weather", - "title": "Acme Weather API", - "subtitle": "City-level weather", - "description": "Current weather data", - "use_case": "Look up weather by city", - "category": "data", - "service_url": "https://gw.example.com/providers/acme-weather", - "featured_tags": ["weather"], + "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" / "acme-weather.json").write_text( + (dist / "providers" / f"{fqn}.json").write_text( json.dumps( { - "fqn": "acme-weather", - "title": "Acme Weather API", - "subtitle": "City-level weather", - "description": "Current weather data", - "use_case": "Look up weather by city", - "category": "data", - "service_url": "https://gw.example.com/providers/acme-weather", - "featured_tags": ["weather"], - "chains": ["tron:mainnet"], - "endpoints": [ - { - "method": "GET", - "path": "/v1/current", - "url": "https://gw.example.com/providers/acme-weather/v1/current", - "description": "Current weather for a city", - "metered": True, - "min_price_usd": 0.002, - "max_price_usd": 0.002, - } - ], + "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" / "acme-weather.json").write_text( + (dist / "pay" / f"{fqn}.json").write_text( json.dumps( { "version": 1, - "fqn": "acme-weather", - "service_url": "https://gw.example.com/providers/acme-weather", - "endpoints": [ - { - "method": "GET", - "path": "/v1/current", - "url": "https://gw.example.com/providers/acme-weather/v1/current", - } - ], + "fqn": fqn, + "service_url": service_url, + "endpoints": [endpoint], } ) ) @@ -85,31 +106,31 @@ def test_catalog_search_show_endpoints_and_pay_json(tmp_path: Path) -> None: search = runner.invoke( cli, - ["catalog", "search", "weather", "--catalog", str(catalog), "--json"], + ["catalog", "search", "token launch", "--catalog", str(catalog), "--json"], ) assert search.exit_code == 0 - assert json.loads(search.output)["results"][0]["fqn"] == "acme-weather" + assert json.loads(search.output)["results"][0]["fqn"] == "sunpump-token-launch" show = runner.invoke( cli, - ["catalog", "show", "acme-weather", "--catalog", str(catalog), "--json"], + ["catalog", "show", "sunpump-token-launch", "--catalog", str(catalog), "--json"], ) assert show.exit_code == 0 - assert json.loads(show.output)["service_url"].endswith("/providers/acme-weather") + assert json.loads(show.output)["service_url"] == "https://sunpump.meme" endpoints = runner.invoke( cli, - ["catalog", "endpoints", "acme-weather", "--catalog", str(catalog), "--json"], + ["catalog", "endpoints", "sunpump-token-launch", "--catalog", str(catalog), "--json"], ) assert endpoints.exit_code == 0 - assert json.loads(endpoints.output)["endpoints"][0]["path"] == "/v1/current" + assert json.loads(endpoints.output)["endpoints"][0]["path"] == "/pump-api/ai/agentTokenLaunch" pay_json = runner.invoke( cli, - ["catalog", "pay-json", "acme-weather", "--catalog", str(catalog)], + ["catalog", "pay-json", "sunpump-token-launch", "--catalog", str(catalog)], ) assert pay_json.exit_code == 0 - assert json.loads(pay_json.output)["fqn"] == "acme-weather" + assert json.loads(pay_json.output)["fqn"] == "sunpump-token-launch" def test_catalog_update_caches_catalog( @@ -138,15 +159,15 @@ def test_catalog_update_caches_remote_detail_and_pay_files(tmp_path: Path, monke 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" / "acme-weather.json").read_text()) - pay_payload = json.loads((tmp_path / "dist" / "pay" / "acme-weather.json").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/acme-weather.json": + if source == "https://catalog.example.com/api/providers/sunpump-token-launch.json": return detail_payload - if source == "https://catalog.example.com/api/pay/acme-weather.json": + if source == "https://catalog.example.com/api/pay/sunpump-token-launch.json": return pay_payload raise AssertionError(source) @@ -167,43 +188,46 @@ def fake_read_json(source: str): payload = json.loads(result.output) assert payload["detailCount"] == 1 assert payload["payCount"] == 1 - assert (cache_root / "providers" / "acme-weather.json").exists() - assert (cache_root / "pay" / "acme-weather.json").exists() + 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" / "acme-weather.json").unlink() - (tmp_path / "dist" / "pay" / "acme-weather.json").unlink() + (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), "acme-weather") - == "https://catalog.example.com/api/providers/acme-weather.json" + 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), "acme-weather") - == "https://catalog.example.com/api/pay/acme-weather.json" + 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": "acme-weather", - "title": "Acme Weather API", - "subtitle": "City-level weather", - "description": "Current weather data", - "use_case": "Look up weather by city", - "category": "data", - "service_url": "https://gw.example.com/providers/acme-weather", + "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": "GET", - "path": "/v1/current", - "url": "https://gw.example.com/providers/acme-weather/v1/current", - "description": "Current weather for a city", + "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.002, - "max_price_usd": 0.002, + "min_price_usd": 0.001, + "max_price_usd": 0.001, } ], } @@ -217,7 +241,7 @@ def test_catalog_export_gateway_writes_pr_files(tmp_path: Path, monkeypatch) -> "export-gateway", "https://gw.example.com", "--provider", - "acme-weather", + "sunpump-token-launch-tron", "--output-dir", str(out), "--json", @@ -228,8 +252,11 @@ def test_catalog_export_gateway_writes_pr_files(tmp_path: Path, monkeypatch) -> assert (out / "catalog.json").exists() assert (out / "pay.md").exists() payload = json.loads((out / "catalog.json").read_text()) - assert payload["fqn"] == "acme-weather" - assert payload["endpoints"][0]["path"] == "/v1/current" + 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/acme-weather/v1/current'" in pay_md + 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_mcp_server.py b/tests/test_mcp_server.py index 1ba73d4..6d5ba69 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -32,13 +32,13 @@ async def test_mcp_catalog_tools_use_public_catalog(tmp_path: Path) -> None: "method": "tools/call", "params": { "name": "catalog_search", - "arguments": {"query": "weather", "catalog": str(catalog)}, + "arguments": {"query": "token launch", "catalog": str(catalog)}, }, } ) assert search is not None search_payload = json.loads(search["result"]["content"][0]["text"]) - assert search_payload["results"][0]["fqn"] == "acme-weather" + assert search_payload["results"][0]["fqn"] == "sunpump-token-launch" endpoints = await mcp_server.handle_message( { @@ -47,13 +47,13 @@ async def test_mcp_catalog_tools_use_public_catalog(tmp_path: Path) -> None: "method": "tools/call", "params": { "name": "catalog_endpoints", - "arguments": {"fqn": "acme-weather", "catalog": str(catalog)}, + "arguments": {"fqn": "sunpump-token-launch", "catalog": str(catalog)}, }, } ) assert endpoints is not None endpoints_payload = json.loads(endpoints["result"]["content"][0]["text"]) - assert endpoints_payload["endpoints"][0]["path"] == "/v1/current" + assert endpoints_payload["endpoints"][0]["path"] == "/pump-api/ai/agentTokenLaunch" @pytest.mark.asyncio From 7bb464ba53476c7f620eab4fbdda2fa3dd8f03c5 Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Wed, 24 Jun 2026 20:00:48 +0800 Subject: [PATCH 17/18] Prepare CLI 0.6.1 release --- CHANGELOG.md | 6 +-- README.md | 62 ++++--------------------------- docs/manual-test-guide.md | 4 +- examples/README.md | 36 +----------------- pyproject.toml | 2 +- src/bankofai/x402_cli/__init__.py | 2 +- 6 files changed, 16 insertions(+), 96 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c591740..a9edf26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ 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.1b7] — 2026-06-24 +## [0.6.1] — 2026-06-24 ### Changed @@ -28,8 +28,8 @@ and this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Point the default hosted catalog URL at `https://tm-x402-catelog.bankofai.io/api/catalog.json`. -- Refresh Bank of AI gateway examples to `https://tm-x402-gateway.bankofai.io`. +- 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.6.1b2] — 2026-06-11 diff --git a/README.md b/README.md index 25c6cfa..be33477 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,5 @@ # `x402-cli` -## 中文说明 - -`x402-cli` 是用户侧唯一需要记住的命令入口。它集成了三类能力: - -- `x402-cli pay `:调用 x402 付费接口,自动处理 402 challenge、签名和重试。 -- `x402-cli catalog ...`:搜索公开 Catalog,找到适合的 API、endpoint、价格和调用说明。 -- `x402-cli gateway ...`:服务方本地启动 Gateway、校验 provider、导出公开 Catalog PR 文件。 - -安装: - -```bash -pip install bankofai-x402-cli==0.6.1b7 -x402-cli --version -``` - -典型使用流程: - -```bash -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 -x402-cli pay 'https://x402-gateway.bankofai.io/providers/sunpump-token-launch-tron/pump-api/ai/agentTokenLaunch' \ - --method POST \ - --network tron:mainnet \ - --scheme exact_permit \ - --token USDT \ - --json '{"name":"TestAutoLaunch","symbol":"TAL","description":"sun flower 666","imageBase64":"","twitterUrl":"","telegramUrl":"","websiteUrl":"","tweetUsername":""}' -``` - -服务方提交流程: - -```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 -``` - -只把导出的 `catalog.json` 和 `pay.md` 提交到 `x402-catelog`。不要提交 `provider.yml`、`.env`、API key、bearer token 或钱包私钥。 - -## English - 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`. @@ -54,23 +9,22 @@ Community copy-paste examples live in [`examples/README.md`](examples/README.md) ## 1. Install ```bash -pip install bankofai-x402-cli==0.6.1b7 +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 @@ -99,7 +53,7 @@ For local gateway development: ```bash x402-cli gateway scaffold sunpump-token-launch-tron \ --output-dir providers/sunpump-token-launch-tron \ - --forward-url https://tn-api.sunpump.meme + --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 @@ -179,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 @@ -187,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 6665689..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 bankofai-x402-cli==0.6.1b6 +pip install bankofai-x402-cli==0.6.1 x402-cli --version agent-wallet --help | head -3 # confirm agent-wallet ships with the CLI ``` @@ -342,7 +342,7 @@ Verify on BscScan: `https://testnet.bscscan.com/tx/`. ``` # install -pip install bankofai-x402-cli==0.6.1b6 +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 index 92512f2..62b54bc 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,37 +1,5 @@ # x402-cli Community Examples -## 中文说明 - -这里是社区用户使用 `x402-cli` 的复制粘贴示例。`x402-cli` 是唯一用户入口,支付、Catalog 搜索、Gateway 操作都在这一个命令下。 - -常用流程: - -```bash -pip install bankofai-x402-cli==0.6.1b7 -x402-cli catalog update -x402-cli catalog search "defillama" -x402-cli catalog show defillama -x402-cli catalog endpoints 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 -``` - -服务方导出公开 PR 文件: - -```bash -x402-cli catalog export-gateway https://gateway.example.com \ - --provider acme-weather \ - --output-dir providers/acme-weather -``` - -只提交 `catalog.json` 和 `pay.md`,不要提交 `provider.yml`、`.env` 或任何密钥。 - -## English - 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`. @@ -39,14 +7,14 @@ gateway operations all live under `x402-cli`. ## 1. Install ```bash -pip install bankofai-x402-cli==0.6.1b7 +pip install bankofai-x402-cli==0.6.1 x402-cli --version ``` Expected: ```text -x402-cli, version 0.6.1b7 +x402-cli, version 0.6.1 ``` ## 2. Find a Paid API diff --git a/pyproject.toml b/pyproject.toml index 03d6131..dae88bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "bankofai-x402-cli" -version = "0.6.1b7" +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" } diff --git a/src/bankofai/x402_cli/__init__.py b/src/bankofai/x402_cli/__init__.py index a14ff99..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.6.1b7" +__version__ = "0.6.1" From 58eba036b72103728585584dd453e395ba6321eb Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Thu, 25 Jun 2026 10:35:43 +0800 Subject: [PATCH 18/18] Remove MCP server from CLI release --- CHANGELOG.md | 11 - mcp/README.md | 47 ---- pyproject.toml | 1 - src/bankofai/x402_cli/mcp_server.py | 347 ---------------------------- tests/test_mcp_server.py | 106 --------- 5 files changed, 512 deletions(-) delete mode 100644 mcp/README.md delete mode 100644 src/bankofai/x402_cli/mcp_server.py delete mode 100644 tests/test_mcp_server.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a9edf26..b782d0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,17 +32,6 @@ and this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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.6.1b2] — 2026-06-11 - -### Added - -- **`x402-mcp` stdio server** — exposes the existing catalog and payment flows as MCP tools for agents. Tools include `catalog_search`, `catalog_show`, `catalog_endpoints`, `catalog_pay_json`, `x402_pay`, and `wallet_status`. -- **MCP setup guide** — `mcp/README.md` documents Claude Code and Codex CLI configuration using the same `bankofai-x402-cli` package. - -### Notes - -- The MCP server reuses `x402-cli` and Agent Wallet behavior; there is no separate payment implementation or wallet path. - ## [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/mcp/README.md b/mcp/README.md deleted file mode 100644 index a520e62..0000000 --- a/mcp/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# x402 MCP - -`x402-mcp` exposes the existing `x402-cli` catalog and payment flows as MCP tools for coding agents. - -## Install - -```bash -pip install bankofai-x402-cli==0.6.1b4 -x402-cli --version -python -c "import bankofai.x402_cli.mcp_server; print('x402-mcp ready')" -``` - -Configure Agent Wallet once before paying protected endpoints: - -```bash -npm i -g @bankofai/agent-wallet -agent-wallet start raw_secret --wallet-id payer --private-key 0x... -``` - -## Claude Code - -```bash -claude mcp add x402 \ - --scope user \ - -- x402-mcp -``` - -## Codex CLI - -Add a server entry to `~/.codex/config.toml`: - -```toml -[mcp.servers.x402] -command = "x402-mcp" -args = [] -``` - -Restart Codex after changing MCP configuration. - -## Tools - -- `catalog_search`: search Bank of AI x402 catalog providers. -- `catalog_show`: show provider details. -- `catalog_endpoints`: list provider endpoints, prices, and x402 routes. -- `catalog_pay_json`: return provider pay metadata. -- `x402_pay`: pay an x402-protected URL through the configured Agent Wallet. -- `wallet_status`: check whether Agent Wallet is installed and can resolve addresses. diff --git a/pyproject.toml b/pyproject.toml index dae88bf..4d336f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,6 @@ dev = [ [project.scripts] x402-cli = "bankofai.x402_cli.cli:main" -x402-mcp = "bankofai.x402_cli.mcp_server:main" [project.urls] Homepage = "https://github.com/BofAI/x402-cli#readme" diff --git a/src/bankofai/x402_cli/mcp_server.py b/src/bankofai/x402_cli/mcp_server.py deleted file mode 100644 index 698ca70..0000000 --- a/src/bankofai/x402_cli/mcp_server.py +++ /dev/null @@ -1,347 +0,0 @@ -"""Minimal MCP stdio server for x402-cli. - -The server intentionally reuses the existing catalog and pay implementation so -agent integrations behave like the command line users already test. -""" - -from __future__ import annotations - -import asyncio -import contextlib -import io -import json -import subprocess -import sys -from typing import Any, Callable - -from bankofai.x402_cli import __version__, _tron_patch -from bankofai.x402_cli.catalog_cmd import ( - _catalog_source, - _detail_source, - _pay_source, - _read_json, -) -from bankofai.x402_cli.client_cmd import cmd_client -from bankofai.x402_cli.gateway_search import search_gateway_catalog - -_tron_patch.install() - -JsonObject = dict[str, Any] - - -def _json_text(payload: Any) -> str: - return json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) - - -def _tool_result(payload: Any) -> JsonObject: - return {"content": [{"type": "text", "text": _json_text(payload)}]} - - -def _text_result(text: str) -> JsonObject: - return {"content": [{"type": "text", "text": text}]} - - -def _require_str(args: JsonObject, key: str) -> str: - value = args.get(key) - if not isinstance(value, str) or not value.strip(): - raise ValueError(f"{key} is required") - return value - - -def _optional_str(args: JsonObject, key: str) -> str | None: - value = args.get(key) - if value is None: - return None - if not isinstance(value, str): - raise ValueError(f"{key} must be a string") - return value - - -def _optional_int(args: JsonObject, key: str, default: int) -> int: - value = args.get(key, default) - if isinstance(value, bool) or not isinstance(value, int): - raise ValueError(f"{key} must be an integer") - return value - - -def _catalog_search(args: JsonObject) -> JsonObject: - query = _require_str(args, "query") - catalog = _optional_str(args, "catalog") - limit = _optional_int(args, "limit", 10) - hits = search_gateway_catalog(query, catalog=_catalog_source(catalog), limit=limit) - return _tool_result( - { - "query": query, - "count": len(hits), - "results": [hit.to_dict() for hit in hits], - } - ) - - -def _catalog_show(args: JsonObject) -> JsonObject: - fqn = _require_str(args, "fqn") - catalog = _catalog_source(_optional_str(args, "catalog")) - return _tool_result(_read_json(_detail_source(catalog, fqn))) - - -def _catalog_endpoints(args: JsonObject) -> JsonObject: - fqn = _require_str(args, "fqn") - catalog = _catalog_source(_optional_str(args, "catalog")) - detail = _read_json(_detail_source(catalog, fqn)) - return _tool_result({"fqn": fqn, "endpoints": detail.get("endpoints", [])}) - - -def _catalog_pay_json(args: JsonObject) -> JsonObject: - fqn = _require_str(args, "fqn") - catalog = _catalog_source(_optional_str(args, "catalog")) - return _tool_result(_read_json(_pay_source(catalog, fqn))) - - -async def _x402_pay(args: JsonObject) -> JsonObject: - url = _require_str(args, "url") - method = str(args.get("method") or "GET").upper() - network = _optional_str(args, "network") - token = _optional_str(args, "token") or "USDT" - scheme = _optional_str(args, "scheme") - max_amount = _optional_str(args, "max_amount") - max_raw_amount = _optional_str(args, "max_raw_amount") - body = args.get("json") - raw_body = args.get("body") - headers_arg = args.get("headers") or {} - dry_run = bool(args.get("dry_run", False)) - - if body is not None and raw_body is not None: - raise ValueError("json and body are mutually exclusive") - if body is not None: - raw_body = json.dumps(body, ensure_ascii=False) - - headers: list[str] = [] - if headers_arg: - if not isinstance(headers_arg, dict): - raise ValueError("headers must be an object") - headers = [f"{key}: {value}" for key, value in headers_arg.items()] - if body is not None and not any(header.lower().startswith("content-type:") for header in headers): - headers.append("Content-Type: application/json") - - output = io.StringIO() - with contextlib.redirect_stdout(output): - await cmd_client( - url=url, - max_raw_amount=max_raw_amount, - max_amount=max_amount, - network=network, - token=token, - scheme=scheme, - method=method, - headers=tuple(headers), - body=raw_body if isinstance(raw_body, str) else None, - dry_run=dry_run, - output_mode="json", - ) - text = output.getvalue().strip() - try: - return _tool_result(json.loads(text)) - except json.JSONDecodeError: - return _text_result(text) - - -def _wallet_status(args: JsonObject) -> JsonObject: - timeout = _optional_int(args, "timeout_seconds", 10) - commands = [ - ["agent-wallet", "list"], - ["agent-wallet", "resolve-address"], - ] - result: dict[str, Any] = {} - for command in commands: - key = command[1].replace("-", "_") - try: - proc = subprocess.run( - command, - check=False, - capture_output=True, - text=True, - timeout=timeout, - ) - result[key] = { - "ok": proc.returncode == 0, - "exit_code": proc.returncode, - "stdout": proc.stdout.strip(), - "stderr": proc.stderr.strip(), - } - except FileNotFoundError: - result[key] = { - "ok": False, - "exit_code": None, - "stdout": "", - "stderr": "agent-wallet command not found", - } - break - return _tool_result(result) - - -TOOLS: dict[str, Callable[[JsonObject], JsonObject] | Callable[[JsonObject], Any]] = { - "catalog_search": _catalog_search, - "catalog_show": _catalog_show, - "catalog_endpoints": _catalog_endpoints, - "catalog_pay_json": _catalog_pay_json, - "x402_pay": _x402_pay, - "wallet_status": _wallet_status, -} - - -TOOL_DEFINITIONS: list[JsonObject] = [ - { - "name": "catalog_search", - "description": "Search the Bank of AI x402 catalog by use case, category, endpoint, chain, or tag.", - "inputSchema": { - "type": "object", - "properties": { - "query": {"type": "string"}, - "catalog": {"type": "string"}, - "limit": {"type": "integer", "default": 10}, - }, - "required": ["query"], - }, - }, - { - "name": "catalog_show", - "description": "Show detailed metadata for a catalog provider.", - "inputSchema": { - "type": "object", - "properties": {"fqn": {"type": "string"}, "catalog": {"type": "string"}}, - "required": ["fqn"], - }, - }, - { - "name": "catalog_endpoints", - "description": "List callable endpoints, prices, and x402 routes for a provider.", - "inputSchema": { - "type": "object", - "properties": {"fqn": {"type": "string"}, "catalog": {"type": "string"}}, - "required": ["fqn"], - }, - }, - { - "name": "catalog_pay_json", - "description": "Return the machine-readable pay.json for a provider.", - "inputSchema": { - "type": "object", - "properties": {"fqn": {"type": "string"}, "catalog": {"type": "string"}}, - "required": ["fqn"], - }, - }, - { - "name": "x402_pay", - "description": "Pay an x402-protected URL using x402-cli and the configured agent-wallet.", - "inputSchema": { - "type": "object", - "properties": { - "url": {"type": "string"}, - "method": {"type": "string", "default": "GET"}, - "network": {"type": "string"}, - "token": {"type": "string", "default": "USDT"}, - "scheme": {"type": "string"}, - "max_amount": {"type": "string"}, - "max_raw_amount": {"type": "string"}, - "headers": {"type": "object"}, - "json": {}, - "body": {"type": "string"}, - "dry_run": {"type": "boolean", "default": False}, - }, - "required": ["url"], - }, - }, - { - "name": "wallet_status", - "description": "Check whether agent-wallet is installed and can resolve active wallet addresses.", - "inputSchema": { - "type": "object", - "properties": {"timeout_seconds": {"type": "integer", "default": 10}}, - }, - }, -] - - -async def handle_message(message: JsonObject) -> JsonObject | None: - method = message.get("method") - request_id = message.get("id") - if request_id is None: - return None - - try: - result: JsonObject - if method == "initialize": - result = { - "protocolVersion": "2024-11-05", - "capabilities": {"tools": {}}, - "serverInfo": {"name": "x402-mcp", "version": __version__}, - } - elif method == "tools/list": - result = {"tools": TOOL_DEFINITIONS} - elif method == "tools/call": - params = message.get("params") or {} - if not isinstance(params, dict): - raise ValueError("params must be an object") - name = _require_str(params, "name") - arguments = params.get("arguments") or {} - if not isinstance(arguments, dict): - raise ValueError("arguments must be an object") - tool = TOOLS.get(name) - if tool is None: - raise ValueError(f"unknown tool: {name}") - maybe_result = tool(arguments) - result = await maybe_result if asyncio.iscoroutine(maybe_result) else maybe_result - else: - raise ValueError(f"unsupported method: {method}") - return {"jsonrpc": "2.0", "id": request_id, "result": result} - except Exception as exc: - return { - "jsonrpc": "2.0", - "id": request_id, - "error": {"code": -32000, "message": str(exc)}, - } - - -def _read_frame(stdin: Any) -> JsonObject | None: - headers: dict[str, str] = {} - while True: - line = stdin.buffer.readline() - if not line: - return None - if line in {b"\r\n", b"\n"}: - break - key, _, value = line.decode("ascii").partition(":") - headers[key.lower()] = value.strip() - length = int(headers.get("content-length", "0")) - if length <= 0: - return None - body = stdin.buffer.read(length) - payload = json.loads(body.decode("utf-8")) - if not isinstance(payload, dict): - raise ValueError("MCP frame body must be a JSON object") - return payload - - -def _write_frame(stdout: Any, payload: JsonObject) -> None: - body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") - stdout.buffer.write(f"Content-Length: {len(body)}\r\n\r\n".encode("ascii")) - stdout.buffer.write(body) - stdout.buffer.flush() - - -async def run_stdio() -> None: - while True: - message = _read_frame(sys.stdin) - if message is None: - return - response = await handle_message(message) - if response is not None: - _write_frame(sys.stdout, response) - - -def main() -> None: - asyncio.run(run_stdio()) - - -if __name__ == "__main__": - main() diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py deleted file mode 100644 index 6d5ba69..0000000 --- a/tests/test_mcp_server.py +++ /dev/null @@ -1,106 +0,0 @@ -from __future__ import annotations - -import json -from pathlib import Path - -import pytest - -from bankofai.x402_cli import mcp_server -from tests.test_catalog_cmd import _write_public_catalog - - -@pytest.mark.asyncio -async def test_mcp_lists_tools() -> None: - response = await mcp_server.handle_message( - {"jsonrpc": "2.0", "id": 1, "method": "tools/list"} - ) - - assert response is not None - assert response["id"] == 1 - names = {tool["name"] for tool in response["result"]["tools"]} - assert {"catalog_search", "catalog_show", "x402_pay", "wallet_status"} <= names - - -@pytest.mark.asyncio -async def test_mcp_catalog_tools_use_public_catalog(tmp_path: Path) -> None: - catalog = _write_public_catalog(tmp_path) - - search = await mcp_server.handle_message( - { - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": { - "name": "catalog_search", - "arguments": {"query": "token launch", "catalog": str(catalog)}, - }, - } - ) - assert search is not None - search_payload = json.loads(search["result"]["content"][0]["text"]) - assert search_payload["results"][0]["fqn"] == "sunpump-token-launch" - - endpoints = await mcp_server.handle_message( - { - "jsonrpc": "2.0", - "id": 2, - "method": "tools/call", - "params": { - "name": "catalog_endpoints", - "arguments": {"fqn": "sunpump-token-launch", "catalog": str(catalog)}, - }, - } - ) - assert endpoints is not None - endpoints_payload = json.loads(endpoints["result"]["content"][0]["text"]) - assert endpoints_payload["endpoints"][0]["path"] == "/pump-api/ai/agentTokenLaunch" - - -@pytest.mark.asyncio -async def test_mcp_unknown_tool_returns_error() -> None: - response = await mcp_server.handle_message( - { - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": {"name": "missing", "arguments": {}}, - } - ) - - assert response is not None - assert "error" in response - assert "unknown tool" in response["error"]["message"] - - -@pytest.mark.asyncio -async def test_mcp_x402_pay_returns_cli_json(monkeypatch) -> None: - async def fake_cmd_client(**kwargs): - print(json.dumps({"ok": True, "command": "client", "result": kwargs})) - - monkeypatch.setattr(mcp_server, "cmd_client", fake_cmd_client) - - response = await mcp_server.handle_message( - { - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": { - "name": "x402_pay", - "arguments": { - "url": "https://gw.example.com/providers/acme/v1/models", - "network": "eip155:97", - "token": "USDT", - "scheme": "exact_permit", - "json": {"hello": "world"}, - }, - }, - } - ) - - assert response is not None - payload = json.loads(response["result"]["content"][0]["text"]) - assert payload["ok"] is True - result = payload["result"] - assert result["url"] == "https://gw.example.com/providers/acme/v1/models" - assert result["headers"] == ["Content-Type: application/json"] - assert json.loads(result["body"]) == {"hello": "world"}