diff --git a/CLAUDE.md b/CLAUDE.md index 6eba381..e44fc67 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ Every helper is extracted from a real consumer, not speculated. |---|---| | `agentscore_commerce.identity.{fastapi,flask,django,aiohttp,sanic,middleware}` | Trust gate middleware (KYC, age, sanctions, jurisdiction) | | `agentscore_commerce.payment` | Networks/USDC/rails registries, paymentauth.org directive builders, `create_x402_server` (wraps official `x402[evm,svm]>=2.8` peer dep with v1+v2 dual-register + bazaar extension), `create_mppx_server` (wraps `pympp[server,tempo,stripe]>=0.6` peer dep with Tempo charge/session + Stripe SPT helpers), dispatch-by-network, signer extraction, WWW-Authenticate header, Settlement-Overrides header | -| `agentscore_commerce.discovery` | Discovery probe, Bazaar wrapper, `/.well-known/mpp.json`, `llms.txt` builder, OpenAPI snippets, `NoindexNonDiscoveryMiddleware` ASGI middleware | +| `agentscore_commerce.discovery` | Discovery probe, Bazaar wrapper, `/.well-known/mpp.json`, `llms.txt` builder, `skill.md` builder (Claude-Skill-compatible agent-discovery manifest), OpenAPI snippets, `NoindexNonDiscoveryMiddleware` ASGI middleware | | `agentscore_commerce.challenge` | 402-body builders: accepted_methods, identity_metadata, how_to_pay, agent_instructions, build_402_body, `build_validation_error` (4xx body builder) | | `agentscore_commerce.stripe_multichain` | Multichain PaymentIntent helper, deposit-address lookup, testnet simulator, mppx Stripe wrapper | | `agentscore_commerce.api` | Re-exports `AgentScore` from `agentscore` SDK | diff --git a/README.md b/README.md index 146c382..5f5a705 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ pip install agentscore-commerce[fastapi] # or [flask], [django], [aiohttp], [s | `agentscore_commerce.identity.{fastapi,flask,django,aiohttp,sanic,middleware}` | Trust gate middleware: KYC, sanctions, age, jurisdiction. `AgentScoreGate(...)` (or `agentscore_gate(app, ...)` on Flask/Sanic), `get_assess_data(...)`, `capture_wallet(...)`, `verify_wallet_signer_match(...)`. | | `agentscore_commerce.identity` (package level) | Re-exports the denial helpers: `denial_reason_status`, `denial_reason_to_body`, `build_signer_mismatch_body`, `build_contact_support_next_steps`, `verification_agent_instructions`, `is_fixable_denial`, `FIXABLE_DENIAL_REASONS`. Also re-exports the per-product policy helpers: `PolicyBlock`, `GateResult`, `EnforcementMode`, `IdentityStatus`, `build_gate_from_policy`, `run_gate_with_enforcement`, `shipping_country_allowed`, `shipping_state_allowed` — for multi-product merchants where each product carries its own compliance config (hard gate vs soft vs none, per-product shipping allowlists). | | `agentscore_commerce.payment` | `networks`, `USDC`, `rails` registries; `payment_directive`, `build_payment_directive`, `www_authenticate_header`, `payment_required_header`, `alias_amount_fields` (v1↔v2 amount field shim — emits both `amount` and `maxAmountRequired` so v1-only x402 parsers like Coinbase awal can read v2 bodies), `settlement_override_header`, `dispatch_settlement_by_network`, `extract_payment_signer` (returns `PaymentSigner({address, network})`), `register_x402_schemes_v1_v2`; drop-in x402 helpers: `validate_x402_network_config` (boot-time guard), `verify_x402_request` (parse + validate inbound X-Payment), `process_x402_settle` (verify-then-settle with one call). | -| `agentscore_commerce.discovery` | `is_discovery_probe_request`, `build_discovery_probe_response` (with optional `x402_sample` for x402-aware crawlers — `awal x402 details` etc.), `sample_x402_accept_for_network` (USDC sample-accept builder for known CAIP-2 networks), `build_well_known_mpp`, `build_llms_txt` + `llms_txt_identity_section` + `llms_txt_payment_section` (compact + verbose modes), `agentscore_openapi_snippets`, `build_bazaar_discovery_payload`, `NoindexNonDiscoveryMiddleware` (ASGI middleware that emits `X-Robots-Tag: noindex` on every path except the agent-discovery surfaces — defaults cover `/openapi.json`, `/llms.txt`, `/.well-known/{mpp.json,agent-card.json,ucp}`, `/favicon.{png,ico}`; pure helpers `is_discovery_path` + `DEFAULT_DISCOVERY_PATHS` for non-ASGI frameworks). | +| `agentscore_commerce.discovery` | `is_discovery_probe_request`, `build_discovery_probe_response` (with optional `x402_sample` for x402-aware crawlers — `awal x402 details` etc.), `sample_x402_accept_for_network` (USDC sample-accept builder for known CAIP-2 networks), `build_well_known_mpp`, `build_llms_txt` + `llms_txt_identity_section` + `llms_txt_payment_section` (compact + verbose modes), `build_skill_md` (Claude-Skill-compatible `/skill.md` agent-discovery manifest — strictly agent-facing data only, no internal posture), `agentscore_openapi_snippets`, `build_bazaar_discovery_payload`, `NoindexNonDiscoveryMiddleware` (ASGI middleware that emits `X-Robots-Tag: noindex` on every path except the agent-discovery surfaces — defaults cover `/openapi.json`, `/llms.txt`, `/skill.md`, `/.well-known/{mpp.json,agent-card.json,ucp}`, `/favicon.{png,ico}`; pure helpers `is_discovery_path` + `DEFAULT_DISCOVERY_PATHS` for non-ASGI frameworks). | | `agentscore_commerce.challenge` | `build_402_body`, `build_accepted_methods`, `build_identity_metadata`, `build_how_to_pay`, `build_agent_instructions` (auto-emits per-rail `compatible_clients` — smoke-verified CLIs the agent should use; vendor override supported), `build_pricing_block` (cents → dollar-string with optional shipping/tax), `first_encounter_agent_memory` (cross-merchant hint, returns the canonical block or `None` based on a per-merchant first-seen flag), `OrderReceipt` (dataclass for the post-settlement 200 response shape); `respond_402` — drop-in 402 emit that preserves pympp's `WWW-Authenticate` and layers x402's `PAYMENT-REQUIRED`. `build_validation_error` — structured 4xx body builder (`{error: {code, message}, required_fields?, example_body?, next_steps?, ...extra}`) so vendors compose body shapes by name instead of inlining at every validation site. | | `agentscore_commerce.stripe_multichain` | `create_multichain_payment_intent`, `get_deposit_address`, `simulate_crypto_deposit`; `create_pi_cache` (TTL'd PI / deposit-address cache, Redis-backed when `redis_url` set, in-memory otherwise), `simulate_deposit_if_test_mode` (gates on `sk_test_` and looks up the PI for you), `STRIPE_TEST_TX_HASH_SUCCESS` / `STRIPE_TEST_TX_HASH_FAILED` constants. Peer dep on `stripe`. | | `agentscore_commerce.api` | Everything from `agentscore-py` re-exported in one place: `AgentScore` + `AgentScoreError`, `AGENTSCORE_TEST_ADDRESSES` + `is_agentscore_test_address`. **Don't add `agentscore-py` as a separate dep** — the two can drift versions and cause subtle type mismatches. | diff --git a/agentscore_commerce/challenge/agent_instructions.py b/agentscore_commerce/challenge/agent_instructions.py index dadabfb..eb312bc 100644 --- a/agentscore_commerce/challenge/agent_instructions.py +++ b/agentscore_commerce/challenge/agent_instructions.py @@ -1,7 +1,8 @@ """agent_instructions block builder for the 402 body.""" +from collections.abc import Iterable from dataclasses import dataclass, field -from typing import Any +from typing import Any, Literal _TEMPO_WARNING = ( "Do NOT use `tempo wallet transfer` to pay to the address above. That moves USDC on-chain but does not " @@ -46,28 +47,48 @@ def _default_warnings(how_to_pay: dict[str, Any]) -> list[str]: return w +RailKey = Literal["tempo_mpp", "x402_base", "x402_solana", "stripe"] + +_RAIL_CLIENTS: dict[str, list[str]] = { + "tempo_mpp": ["agentscore-pay", "tempo request", "x402-proxy"], + "x402_base": ["agentscore-pay", "x402-proxy", "purl (omit --network flag)"], + "x402_solana": ["agentscore-pay"], + "stripe": ["link-cli"], +} + + +def compatible_clients_by_rails(rails: Iterable[str]) -> dict[str, list[str]] | None: + """Smoke-verified client list for a set of rail keys. + + The single source of truth for "which CLIs we've verified end-to-end on each rail" — + consumed both by the 402-body builder (``build_agent_instructions``) and by discovery + surfaces (skill.md, llms.txt, etc.). Update here, every surface inherits. + """ + out: dict[str, list[str]] = {} + for r in rails: + clients = _RAIL_CLIENTS.get(r) + if clients is not None: + out[r] = list(clients) + return out or None + + def _default_compatible_clients(how_to_pay: dict[str, Any]) -> dict[str, list[str]] | None: """Default ``compatible_clients`` derived from the rails declared in ``how_to_pay``. - Lists clients the AgentScore team has smoke-verified end-to-end against an - ``agentscore-commerce`` merchant; entries appear only for rails the vendor actually - offers. Vendors override this in ``BuildAgentInstructionsInput(compatible_clients=...)`` + Vendors override this in ``BuildAgentInstructionsInput(compatible_clients=...)`` to add their own tested clients or remove entries that don't fit their endpoint. - - Verified state as of the SDK release. The same data is also published as a docs page - for humans (rationale, per-rail commands, why some clients don't fully work, last - verified date) — this default keeps the merchant-side surface in sync. + Verified state as of the SDK release. """ - out: dict[str, list[str]] = {} + rails: list[str] = [] if "tempo" in how_to_pay: - out["tempo_mpp"] = ["agentscore-pay", "tempo request", "x402-proxy"] + rails.append("tempo_mpp") if "x402_base" in how_to_pay: - out["x402_base"] = ["agentscore-pay", "x402-proxy", "purl (omit --network flag)"] + rails.append("x402_base") if "x402_solana" in how_to_pay: - out["x402_solana"] = ["agentscore-pay"] + rails.append("x402_solana") if "stripe" in how_to_pay: - out["stripe"] = ["link-cli"] - return out or None + rails.append("stripe") + return compatible_clients_by_rails(rails) @dataclass diff --git a/agentscore_commerce/discovery/__init__.py b/agentscore_commerce/discovery/__init__.py index 7b1e742..b526cf2 100644 --- a/agentscore_commerce/discovery/__init__.py +++ b/agentscore_commerce/discovery/__init__.py @@ -33,6 +33,16 @@ install_flask_noindex, is_discovery_path, ) +from agentscore_commerce.discovery.skill_md import ( + BuildSkillMdInput, + RailKey, + SkillMdEndpoint, + SkillMdIdentityRequirements, + SkillMdLink, + SkillMdShippingPolicy, + build_skill_md, + compatible_clients_by_rails, +) from agentscore_commerce.discovery.well_known_mpp import ( PaymentMethodConfig, WellKnownMppInput, @@ -45,6 +55,7 @@ "BazaarDiscoveryConfig", "BuildAgentScoreOpenApiSnippetsInput", "BuildLlmsTxtInput", + "BuildSkillMdInput", "DiscoveryProbeOptions", "DiscoveryProbeResponse", "DjangoNoindexMiddleware", @@ -53,6 +64,11 @@ "LlmsTxtSection", "NoindexNonDiscoveryMiddleware", "PaymentMethodConfig", + "RailKey", + "SkillMdEndpoint", + "SkillMdIdentityRequirements", + "SkillMdLink", + "SkillMdShippingPolicy", "WellKnownMppInput", "X402SampleProbe", "agentscore_denial_schemas", @@ -62,7 +78,9 @@ "build_bazaar_discovery_payload", "build_discovery_probe_response", "build_llms_txt", + "build_skill_md", "build_well_known_mpp", + "compatible_clients_by_rails", "install_flask_noindex", "is_discovery_path", "is_discovery_probe_request", diff --git a/agentscore_commerce/discovery/robots_tag.py b/agentscore_commerce/discovery/robots_tag.py index 7ae3e93..8f32169 100644 --- a/agentscore_commerce/discovery/robots_tag.py +++ b/agentscore_commerce/discovery/robots_tag.py @@ -21,6 +21,8 @@ { "/openapi.json", "/llms.txt", + "/skill.md", + "/SKILL.md", "/.well-known/mpp.json", "/.well-known/agent-card.json", "/.well-known/ucp", diff --git a/agentscore_commerce/discovery/skill_md.py b/agentscore_commerce/discovery/skill_md.py new file mode 100644 index 0000000..7004c69 --- /dev/null +++ b/agentscore_commerce/discovery/skill_md.py @@ -0,0 +1,406 @@ +"""skill.md renderer — agentskills.io-compatible agent-discovery surface. + +Emits a YAML-frontmatter + markdown body manifest describing a merchant's agent-facing +contract: payment rails, compatible clients per rail, identity requirements as outcomes, +shipping policy, endpoints, triggers, support links. + +Renders strictly agent-facing data — no ``fail_open``, no mount-strategy names, no KYC +vendor names, no defense parameters, no idempotency construction. Internal posture stays +in merchant runtime config. + +Spec compliance (https://agentskills.io/specification): + * ``name`` validated against the spec regex (lowercase alphanumeric + hyphens, no + leading/trailing/consecutive hyphens, ≤64 chars). + * ``description`` length capped at 1024. + * ``metadata`` values always emitted as quoted strings. + * ``description`` (and other user scalars) double-quoted to defuse the colon / + newline / quote pitfall the spec explicitly warns about. + +The compatible-clients-per-rail table sources from the same SDK constant +(``compatible_clients_by_rails`` in ``challenge.agent_instructions``) that drives the live +402 body's ``compatible_clients`` field — single source of truth across surfaces. +""" + +import re +from dataclasses import dataclass, field +from typing import Literal + +from agentscore_commerce.challenge.agent_instructions import ( + RailKey, + compatible_clients_by_rails, +) + +__all__ = [ + "BuildSkillMdInput", + "RailKey", + "SkillMdEndpoint", + "SkillMdIdentityRequirements", + "SkillMdLink", + "SkillMdShippingPolicy", + "build_skill_md", + "compatible_clients_by_rails", +] + +HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE"] + + +@dataclass +class SkillMdEndpoint: + method: HttpMethod + path: str + auth_required: bool + description: str + + +@dataclass +class SkillMdIdentityRequirements: + """Agent-observable identity requirements only (kyc / age / jurisdictions / sanctions). + + Internal posture (``fail_open``, mount strategy, KYC vendor) is intentionally not + part of this shape — agents act on outcomes, not implementation. + """ + + kyc_required: bool = False + min_age: int | None = None + allowed_jurisdictions: list[str] | None = None + sanctions_clear: bool = False + + +@dataclass +class SkillMdShippingPolicy: + allowed_countries: list[str] | None = None + blocked_states: list[str] | None = None + + +@dataclass +class SkillMdLink: + label: str + url: str + + +@dataclass +class BuildSkillMdInput: + """Inputs for ``build_skill_md``. + + Required fields: ``name``, ``description``, ``homepage``, ``merchant_name``, + ``accepted_rails``, ``endpoints``, ``triggers``. + """ + + # Required frontmatter / body + name: str + """Skill manifest identifier — kebab-case per agentskills.io spec: 1-64 chars, + lowercase alphanumeric + hyphens, no leading/trailing/consecutive hyphens. Validated + at build time; invalid names raise ``ValueError``.""" + description: str + """Skill description — agentskills.io spec: 1-1024 chars, non-empty. Should describe + both what the skill does AND when to use it; imperative phrasing recommended + ("Use when…"). Validated at build time; over-length raises ``ValueError``.""" + homepage: str + """Merchant homepage (or domain root). Emitted as ``metadata.homepage`` per spec + (top-level non-spec fields go under metadata).""" + merchant_name: str + """Human display name (e.g. 'Martin Estate Winery').""" + accepted_rails: list[RailKey] + """Rails the merchant accepts. Drives the Payment + Compatible Clients sections. + Order is preserved in render.""" + endpoints: list[SkillMdEndpoint] + """Agent-facing endpoints — path, method, whether auth is required, brief purpose.""" + triggers: list[str] + """When this skill should fire (skill loader uses for trigger matching).""" + + # Optional frontmatter + version: str | int = 1 + """Skill schema version — emitted as a quoted string under ``metadata.version`` per + spec (metadata values must be strings). Accepts string or int; ints are converted.""" + license: str | None = None + """Optional ``license:`` frontmatter — license name or path to a bundled license file.""" + compatibility: str | None = None + """Optional ``compatibility:`` frontmatter — environment requirements (max 500 chars). + e.g. 'Requires Python 3.11+'.""" + allowed_tools: str | None = None + """Optional ``allowed-tools:`` frontmatter — space-separated string of pre-approved + tools (experimental per spec).""" + metadata: dict[str, str | int] = field(default_factory=dict) + """Additional caller-defined metadata entries — flat string keys/values nested under + ``metadata:``. Spec requires string values; ints are converted. ``version`` and + ``homepage`` keys are always sourced from the dedicated fields, never from this + mapping.""" + + # Optional body + tagline: str | None = None + """Optional one-line tagline appearing under the title.""" + intro: str | None = None + """Optional short prose intro describing what the merchant offers.""" + files: list[SkillMdLink] = field(default_factory=list) + """Discovery surface URLs surfaced under the 'Important Files' table. The skill.md + URL itself is added automatically — list other surfaces (llms.txt, mpp.json, + openapi.json, agent-card.json).""" + compatible_clients: dict[str, list[str]] | None = None + """Override the per-rail compatible-clients matrix. When omitted, derives from + ``accepted_rails`` via the SDK's smoke-verified default. Override entries for rails + not in ``accepted_rails`` are ignored (the rail isn't accepted, so the row isn't + rendered).""" + identity: SkillMdIdentityRequirements | None = None + identity_bootstrap_url: str | None = None + """URL to the identity-bootstrap skill. Linked from the Identity Prerequisite section + so an agent without a Passport can follow the bootstrap before attempting purchase.""" + shipping: SkillMdShippingPolicy | None = None + """Physical-goods shipping policy. Omit for digital merchants.""" + onboarding_steps: list[str] = field(default_factory=list) + """Optional numbered onboarding steps.""" + support_links: list[SkillMdLink] = field(default_factory=list) + """Support / homepage / docs links rendered in the Support section.""" + refresh_footer: bool = True + """When True (default), append a footer noting clients can refresh skill.md to pick + up new endpoints.""" + + +_RAIL_LABELS: dict[str, str] = { + "tempo_mpp": "MPP on Tempo", + "x402_base": "x402 on Base", + "x402_solana": "x402 on Solana", + "stripe": "Stripe Shared Payment Token", +} + +_RAIL_NOTES: dict[str, str] = { + "tempo_mpp": ( + "USDC. Use `agentscore-pay --chain tempo` (or `tempo request`); " + "MPP credential goes in `Authorization: Payment`." + ), + "x402_base": "USDC (EIP-3009). Use `agentscore-pay`; X-Payment header carries the signed credential.", + "x402_solana": "USDC (SPL). Use `agentscore-pay`; X-Payment header carries the signed credential.", + "stripe": ( + "Card via Link wallet. Use `@stripe/link-cli` — `agentscore-pay` emits the " + "handoff hint when this rail is picked." + ), +} + +_NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") +_NAME_MAX = 64 +_DESCRIPTION_MAX = 1024 +_COMPATIBILITY_MAX = 500 + + +def _validate(input: BuildSkillMdInput) -> None: + n = input.name + if not n or len(n) > _NAME_MAX: + raise ValueError(f"build_skill_md: name must be 1-{_NAME_MAX} characters (got {len(n) if n else 0})") + if not _NAME_RE.match(n): + raise ValueError( + f'build_skill_md: name "{n}" is invalid — must be lowercase alphanumeric and hyphens, ' + "no leading/trailing/consecutive hyphens (agentskills.io spec)" + ) + if not input.description: + raise ValueError("build_skill_md: description is required and must be non-empty (agentskills.io spec)") + if len(input.description) > _DESCRIPTION_MAX: + raise ValueError( + f"build_skill_md: description must be ≤{_DESCRIPTION_MAX} characters (got {len(input.description)})" + ) + if input.compatibility and len(input.compatibility) > _COMPATIBILITY_MAX: + raise ValueError( + f"build_skill_md: compatibility must be ≤{_COMPATIBILITY_MAX} characters (got {len(input.compatibility)})" + ) + + +def _quote_yaml(value: str) -> str: + r"""YAML double-quoted scalar with backslash, double-quote, and newlines escaped. + + The agentskills.io spec calls out unquoted colons in ``description`` as the most + common parse failure across clients; emit every user-supplied scalar quoted to + be safe. + """ + escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") + return f'"{escaped}"' + + +def _table_cell(value: str) -> str: + r"""Sanitize a string for a markdown table cell. + + Escape backslashes first (so existing ``\\`` aren't treated as escapes), then escape + pipes (which would otherwise terminate the cell). + """ + return value.replace("\\", "\\\\").replace("|", "\\|") + + +def _frontmatter(input: BuildSkillMdInput) -> str: + lines = ["---", f"name: {input.name}", f"description: {_quote_yaml(input.description)}"] + if input.license: + lines.append(f"license: {_quote_yaml(input.license)}") + if input.compatibility: + lines.append(f"compatibility: {_quote_yaml(input.compatibility)}") + if input.allowed_tools: + lines.append(f"allowed-tools: {_quote_yaml(input.allowed_tools)}") + + meta: list[tuple[str, str]] = [ + ("version", str(input.version)), + ("homepage", input.homepage), + ] + for k, v in input.metadata.items(): + if k in ("version", "homepage"): + continue + meta.append((k, str(v))) + lines.append("metadata:") + for k, v in meta: + lines.append(f" {k}: {_quote_yaml(v)}") + lines.append("---") + return "\n".join(lines) + + +def _title_block(input: BuildSkillMdInput) -> str: + parts = [f"# {input.merchant_name}"] + if input.tagline: + parts.append(f"_{input.tagline}_") + if input.intro: + parts.append(input.intro) + return "\n\n".join(parts) + + +def _important_files(input: BuildSkillMdInput) -> str: + skill_url = f"{input.homepage.rstrip('/')}/skill.md" + rows = [ + "| File | URL |", + "|------|-----|", + f"| **SKILL.md** (this file) | `{skill_url}` |", + ] + for f in input.files: + rows.append(f"| {_table_cell(f.label)} | `{_table_cell(f.url)}` |") + return "\n".join(["## Important Files", "", *rows]) + + +def _payment_section(input: BuildSkillMdInput) -> str: + override = input.compatible_clients + defaults = compatible_clients_by_rails(input.accepted_rails) or {} + clients: dict[str, list[str]] = {} + for r in input.accepted_rails: + if override is not None and r in override: + clients[r] = override[r] + else: + clients[r] = defaults.get(r, []) + rows = ["| Rail | Notes | Compatible clients |", "|---|---|---|"] + for r in input.accepted_rails: + client_list = ", ".join(clients.get(r, [])) or "—" + rows.append(f"| **{_RAIL_LABELS[r]}** | {_RAIL_NOTES[r]} | {client_list} |") + intro = ( + "Each gated route returns a 402 with `WWW-Authenticate` + `PAYMENT-REQUIRED` " + "body listing the rails below with current pricing. Pick whichever your wallet " + "is funded for." + ) + return "\n".join(["## Payment", "", intro, "", *rows]) + + +def _identity_section(input: BuildSkillMdInput) -> str: + id_ = input.identity + if id_ is None: + return "" + reqs: list[str] = [] + if id_.kyc_required: + reqs.append("KYC verified Passport") + if id_.min_age: + reqs.append(f"age {id_.min_age}+") + if id_.allowed_jurisdictions: + reqs.append(f"{'/'.join(id_.allowed_jurisdictions)} only") + if id_.sanctions_clear: + reqs.append("sanctions clear") + if not reqs: + return "" + bootstrap = "" + if input.identity_bootstrap_url: + bootstrap = ( + f"\n\nIf you don't have a Passport, fetch `{input.identity_bootstrap_url}` and " + "follow the onboarding there first. Bring back the `opc_...` operator token in " + "`X-Operator-Token` on every gated request." + ) + denial_note = ( + "Denial bodies carry an `agent_instructions` block describing the recovery action " + "— read the `action` field and follow it. See the identity-bootstrap skill for the " + "canonical denial-code → action table." + ) + return "\n".join( + [ + "## Identity Prerequisite", + "", + f"This merchant uses AgentScore identity. Required: {', '.join(reqs)}.{bootstrap}", + "", + denial_note, + ] + ) + + +def _shipping_section(input: BuildSkillMdInput) -> str: + s = input.shipping + if s is None or (not s.allowed_countries and not s.blocked_states): + return "" + lines = ["## Shipping", ""] + if s.allowed_countries: + lines.append(f"Ships to: {', '.join(s.allowed_countries)}.") + if s.blocked_states: + if len(lines) > 2: + lines.append("") + lines.append(f"Blocked US states: {', '.join(s.blocked_states)}.") + return "\n".join(lines) + + +def _endpoints_section(input: BuildSkillMdInput) -> str: + if not input.endpoints: + return "" + rows = ["| Method | Path | Auth | Purpose |", "|---|---|---|---|"] + for e in input.endpoints: + auth_label = "identity required" if e.auth_required else "anonymous" + rows.append(f"| {e.method} | `{_table_cell(e.path)}` | {auth_label} | {_table_cell(e.description)} |") + return "\n".join(["## Endpoints", "", *rows]) + + +def _onboarding_section(input: BuildSkillMdInput) -> str: + if not input.onboarding_steps: + return "" + rows = [f"{i + 1}. {step}" for i, step in enumerate(input.onboarding_steps)] + return "\n".join(["## Onboarding Flow", "", *rows]) + + +def _triggers_section(input: BuildSkillMdInput) -> str: + if not input.triggers: + return "" + rows = [f"- {t}" for t in input.triggers] + return "\n".join(["## Triggers", "", "Use this skill when the user wants to:", "", *rows]) + + +def _support_section(input: BuildSkillMdInput) -> str: + if not input.support_links: + return "" + rows = [f"- **{link.label}**: {link.url}" for link in input.support_links] + return "\n".join(["## Support", "", *rows]) + + +def _refresh_footer(input: BuildSkillMdInput) -> str: + if not input.refresh_footer: + return "" + return "_Re-fetch this file periodically to pick up new endpoints, rails, or policies._" + + +def build_skill_md(input: BuildSkillMdInput) -> str: + """Render an agentskills.io-compatible ``skill.md`` for an agent-commerce merchant. + + Output is YAML frontmatter (``name`` / ``description`` / optional ``license`` / + ``compatibility`` / ``allowed-tools`` / ``metadata``) followed by markdown sections + describing payment rails, identity requirements, endpoints, triggers, and support + links — exactly the agent-facing contract, with no internal posture (no + ``fail_open``, no mount-strategy names, no KYC vendor, no defense parameters). + """ + _validate(input) + sections = [ + _frontmatter(input), + _title_block(input), + _important_files(input), + _identity_section(input), + _payment_section(input), + _shipping_section(input), + _onboarding_section(input), + _endpoints_section(input), + _triggers_section(input), + _support_section(input), + _refresh_footer(input), + ] + body = "\n\n".join(s for s in sections if s) + while "\n\n\n" in body: + body = body.replace("\n\n\n", "\n\n") + return body.rstrip() + "\n" diff --git a/pyproject.toml b/pyproject.toml index d299654..9e471a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "agentscore-commerce" -version = "1.1.0" +version = "1.2.0" description = "Agent commerce SDK for Python — identity middleware (FastAPI, Flask, Django, AIOHTTP, Sanic, ASGI) + payment helpers + 402 builders + discovery + Stripe multichain. The full merchant-side toolkit for AgentScore-powered agent commerce." readme = "README.md" license = "MIT" diff --git a/tests/test_skill_md.py b/tests/test_skill_md.py new file mode 100644 index 0000000..ebcddae --- /dev/null +++ b/tests/test_skill_md.py @@ -0,0 +1,438 @@ +"""Tests for the skill.md discovery builder (agentskills.io spec compliance).""" + +import re + +import pytest + +from agentscore_commerce.discovery import ( + BuildSkillMdInput, + SkillMdEndpoint, + SkillMdIdentityRequirements, + SkillMdLink, + SkillMdShippingPolicy, + build_skill_md, +) + + +def _base() -> BuildSkillMdInput: + return BuildSkillMdInput( + name="martin-estate-wine-commerce", + description="Buy wine from Martin Estate via an AI agent", + homepage="https://martin-estate.com", + merchant_name="Martin Estate", + accepted_rails=["tempo_mpp", "x402_base", "x402_solana", "stripe"], + endpoints=[ + SkillMdEndpoint(method="GET", path="/api/v1/wines", auth_required=False, description="Wine catalog"), + SkillMdEndpoint(method="POST", path="/api/v1/orders", auth_required=True, description="Place order"), + ], + triggers=["User wants to buy wine from Martin Estate"], + ) + + +class TestFrontmatter: + def test_emits_yaml_block_with_required_fields(self) -> None: + out = build_skill_md(_base()) + assert out.startswith("---\n") + assert "name: martin-estate-wine-commerce" in out + assert 'description: "Buy wine from Martin Estate via an AI agent"' in out + assert "metadata:" in out + assert ' version: "1"' in out + assert ' homepage: "https://martin-estate.com"' in out + + def test_version_emitted_as_quoted_string(self) -> None: + cfg = _base() + cfg.version = 7 + out = build_skill_md(cfg) + assert ' version: "7"' in out + cfg.version = "2.0.1" + out2 = build_skill_md(cfg) + assert ' version: "2.0.1"' in out2 + + def test_version_zero_passes_through(self) -> None: + """Parity lock: Node uses ?? (nullish coalescing); Python uses str(); both pass 0 through.""" + cfg = _base() + cfg.version = 0 + out = build_skill_md(cfg) + assert ' version: "0"' in out + + def test_quotes_description_with_colons(self) -> None: + cfg = _base() + cfg.description = "Use when: buying premium wine" + out = build_skill_md(cfg) + assert 'description: "Use when: buying premium wine"' in out + + def test_escapes_double_quotes_in_description(self) -> None: + cfg = _base() + cfg.description = 'Buy "Estate" wine' + out = build_skill_md(cfg) + assert 'description: "Buy \\"Estate\\" wine"' in out + + def test_escapes_newlines_in_description(self) -> None: + cfg = _base() + cfg.description = "line one\nline two" + out = build_skill_md(cfg) + assert 'description: "line one\\nline two"' in out + + def test_emits_optional_license_compatibility_allowed_tools(self) -> None: + cfg = _base() + cfg.license = "Apache-2.0" + cfg.compatibility = "Requires Python 3.11+" + cfg.allowed_tools = "Bash(curl:*)" + out = build_skill_md(cfg) + assert 'license: "Apache-2.0"' in out + assert 'compatibility: "Requires Python 3.11+"' in out + assert 'allowed-tools: "Bash(curl:*)"' in out + + def test_omits_optional_fields_by_default(self) -> None: + out = build_skill_md(_base()) + assert not re.search(r"^license:", out, re.MULTILINE) + assert not re.search(r"^compatibility:", out, re.MULTILINE) + assert not re.search(r"^allowed-tools:", out, re.MULTILINE) + + def test_metadata_extras_with_protected_keys(self) -> None: + cfg = _base() + cfg.metadata = {"author": "agentscore", "vendor_id": "me-001", "version": "IGNORED", "homepage": "IGNORED"} + out = build_skill_md(cfg) + assert ' author: "agentscore"' in out + assert ' vendor_id: "me-001"' in out + assert ' version: "1"' in out + assert ' homepage: "https://martin-estate.com"' in out + assert "IGNORED" not in out + + +class TestValidation: + def test_rejects_empty_name(self) -> None: + cfg = _base() + cfg.name = "" + with pytest.raises(ValueError, match=r"1-64"): + build_skill_md(cfg) + + def test_rejects_name_over_64_chars(self) -> None: + cfg = _base() + cfg.name = "a" * 65 + with pytest.raises(ValueError, match=r"1-64"): + build_skill_md(cfg) + + def test_rejects_uppercase_name(self) -> None: + cfg = _base() + cfg.name = "Martin-Estate" + with pytest.raises(ValueError, match=r"lowercase"): + build_skill_md(cfg) + + def test_rejects_leading_hyphen(self) -> None: + cfg = _base() + cfg.name = "-foo" + with pytest.raises(ValueError, match=r"hyphens"): + build_skill_md(cfg) + + def test_rejects_trailing_hyphen(self) -> None: + cfg = _base() + cfg.name = "foo-" + with pytest.raises(ValueError, match=r"hyphens"): + build_skill_md(cfg) + + def test_rejects_consecutive_hyphens(self) -> None: + cfg = _base() + cfg.name = "foo--bar" + with pytest.raises(ValueError, match=r"hyphens"): + build_skill_md(cfg) + + def test_rejects_empty_description(self) -> None: + cfg = _base() + cfg.description = "" + with pytest.raises(ValueError, match=r"non-empty"): + build_skill_md(cfg) + + def test_rejects_description_over_1024_chars(self) -> None: + cfg = _base() + cfg.description = "a" * 1025 + with pytest.raises(ValueError, match=r"1024"): + build_skill_md(cfg) + + def test_rejects_compatibility_over_500_chars(self) -> None: + cfg = _base() + cfg.compatibility = "a" * 501 + with pytest.raises(ValueError, match=r"500"): + build_skill_md(cfg) + + +class TestTitleBlock: + def test_renders_merchant_name_as_h1(self) -> None: + out = build_skill_md(_base()) + assert "\n# Martin Estate\n" in out + + def test_renders_title_tagline_intro_with_blank_lines(self) -> None: + cfg = _base() + cfg.tagline = "A classic is forever" + cfg.intro = "Napa Valley winery, family-run." + out = build_skill_md(cfg) + assert "# Martin Estate\n\n_A classic is forever_\n\nNapa Valley winery, family-run." in out + + def test_renders_tagline_only(self) -> None: + cfg = _base() + cfg.tagline = "A classic is forever" + out = build_skill_md(cfg) + assert "# Martin Estate\n\n_A classic is forever_" in out + + def test_renders_intro_only(self) -> None: + cfg = _base() + cfg.intro = "Napa Valley winery." + out = build_skill_md(cfg) + assert "# Martin Estate\n\nNapa Valley winery." in out + + +class TestImportantFiles: + def test_emits_self_reference(self) -> None: + out = build_skill_md(_base()) + assert "## Important Files" in out + assert "| **SKILL.md** (this file) | `https://martin-estate.com/skill.md` |" in out + + def test_appends_caller_files(self) -> None: + cfg = _base() + cfg.files = [ + SkillMdLink(label="llms.txt", url="https://martin-estate.com/llms.txt"), + SkillMdLink(label="OpenAPI", url="https://martin-estate.com/openapi.json"), + ] + out = build_skill_md(cfg) + assert "| llms.txt | `https://martin-estate.com/llms.txt` |" in out + assert "| OpenAPI | `https://martin-estate.com/openapi.json` |" in out + + def test_strips_trailing_slash_from_homepage(self) -> None: + cfg = _base() + cfg.homepage = "https://martin-estate.com/" + out = build_skill_md(cfg) + assert "`https://martin-estate.com/skill.md`" in out + assert "//skill.md" not in out + + def test_escapes_pipes_in_files(self) -> None: + cfg = _base() + cfg.files = [SkillMdLink(label="a|b", url="https://x.example/foo|bar")] + out = build_skill_md(cfg) + assert "| a\\|b | `https://x.example/foo\\|bar` |" in out + + def test_escapes_backslashes_before_pipes(self) -> None: + """Backslashes must escape first, otherwise existing `\\` consumes the pipe escape.""" + cfg = _base() + cfg.files = [SkillMdLink(label="a\\|b", url="https://x.example/c\\d")] + out = build_skill_md(cfg) + # Backslash → `\\`, then pipe → `\|`. Combined for `a\|b`: `a\\\|b`. + assert "| a\\\\\\|b | `https://x.example/c\\\\d` |" in out + + +class TestPaymentSection: + def test_renders_one_row_per_rail(self) -> None: + out = build_skill_md(_base()) + assert "## Payment" in out + assert "**MPP on Tempo**" in out + assert "agentscore-pay, tempo request, x402-proxy" in out + assert "**x402 on Base**" in out + assert "agentscore-pay, x402-proxy, purl (omit --network flag)" in out + assert "**x402 on Solana**" in out + assert "**Stripe Shared Payment Token**" in out + assert "link-cli" in out + + def test_omits_unaccepted_rails(self) -> None: + cfg = _base() + cfg.accepted_rails = ["tempo_mpp", "x402_base", "x402_solana"] + out = build_skill_md(cfg) + assert "**MPP on Tempo**" in out + assert "**Stripe Shared Payment Token**" not in out + assert "link-cli" not in out + + def test_honors_compatible_clients_override(self) -> None: + cfg = _base() + cfg.accepted_rails = ["x402_base"] + cfg.compatible_clients = {"x402_base": ["agentscore-pay", "merchant-custom-cli"]} + out = build_skill_md(cfg) + assert "agentscore-pay, merchant-custom-cli" in out + assert "purl" not in out + + def test_drops_overrides_for_rails_not_in_accepted(self) -> None: + cfg = _base() + cfg.accepted_rails = ["x402_base"] + cfg.compatible_clients = {"x402_base": ["agentscore-pay"], "stripe": ["rogue-cli"]} + out = build_skill_md(cfg) + assert "rogue-cli" not in out + assert "Stripe Shared Payment Token" not in out + + def test_renders_em_dash_when_clients_empty(self) -> None: + cfg = _base() + cfg.accepted_rails = ["x402_base"] + cfg.compatible_clients = {"x402_base": []} + out = build_skill_md(cfg) + assert re.search(r"x402 on Base.+\| —", out) + + +class TestIdentitySection: + def test_omits_when_not_declared(self) -> None: + out = build_skill_md(_base()) + assert "## Identity Prerequisite" not in out + + def test_renders_kyc_age_jurisdictions_sanctions(self) -> None: + cfg = _base() + cfg.identity = SkillMdIdentityRequirements( + kyc_required=True, min_age=21, allowed_jurisdictions=["US"], sanctions_clear=True + ) + out = build_skill_md(cfg) + assert "## Identity Prerequisite" in out + assert "KYC verified Passport" in out + assert "age 21+" in out + assert "US only" in out + assert "sanctions clear" in out + + def test_renders_bootstrap_pointer(self) -> None: + cfg = _base() + cfg.identity = SkillMdIdentityRequirements(kyc_required=True) + cfg.identity_bootstrap_url = "https://identity.example.com/skill.md" + out = build_skill_md(cfg) + assert "`https://identity.example.com/skill.md`" in out + assert "X-Operator-Token" in out + + def test_omits_when_all_flags_falsy(self) -> None: + cfg = _base() + cfg.identity = SkillMdIdentityRequirements(kyc_required=False, sanctions_clear=False) + out = build_skill_md(cfg) + assert "## Identity Prerequisite" not in out + + def test_no_internal_posture_leak(self) -> None: + cfg = _base() + cfg.identity = SkillMdIdentityRequirements( + kyc_required=True, min_age=21, allowed_jurisdictions=["US"], sanctions_clear=True + ) + out = build_skill_md(cfg) + for forbidden in [ + "fail_open", + "fail-open", + "failOpen", + "gate-conditional", + "gate-first", + "Persona", + "Stripe Identity", + ]: + assert forbidden not in out, f"leaked: {forbidden}" + + +class TestShippingSection: + def test_omits_when_no_shipping(self) -> None: + out = build_skill_md(_base()) + assert "## Shipping" not in out + + def test_renders_both_halves(self) -> None: + cfg = _base() + cfg.shipping = SkillMdShippingPolicy(allowed_countries=["US"], blocked_states=["AK", "HI", "MS"]) + out = build_skill_md(cfg) + assert "## Shipping" in out + assert "Ships to: US." in out + assert "Blocked US states: AK, HI, MS." in out + + def test_renders_only_allowed(self) -> None: + cfg = _base() + cfg.shipping = SkillMdShippingPolicy(allowed_countries=["US"]) + out = build_skill_md(cfg) + assert "Ships to: US." in out + assert "Blocked US states" not in out + + def test_renders_only_blocked(self) -> None: + cfg = _base() + cfg.shipping = SkillMdShippingPolicy(blocked_states=["UT", "AK"]) + out = build_skill_md(cfg) + assert "## Shipping" in out + assert "Blocked US states: UT, AK." in out + assert "Ships to:" not in out + + +class TestEndpointsSection: + def test_emits_one_row_per_endpoint(self) -> None: + out = build_skill_md(_base()) + assert "## Endpoints" in out + assert "| GET | `/api/v1/wines` | anonymous | Wine catalog |" in out + assert "| POST | `/api/v1/orders` | identity required | Place order |" in out + + def test_omits_when_empty(self) -> None: + cfg = _base() + cfg.endpoints = [] + out = build_skill_md(cfg) + assert "## Endpoints" not in out + + def test_escapes_pipes_in_endpoints(self) -> None: + cfg = _base() + cfg.endpoints = [SkillMdEndpoint(method="GET", path="/foo|bar", auth_required=False, description="a|b")] + out = build_skill_md(cfg) + assert "| GET | `/foo\\|bar` | anonymous | a\\|b |" in out + + +class TestTriggersSection: + def test_emits_each_trigger(self) -> None: + cfg = _base() + cfg.triggers = ["Buy wine from Martin Estate", "Check order status"] + out = build_skill_md(cfg) + assert "## Triggers" in out + assert "- Buy wine from Martin Estate" in out + assert "- Check order status" in out + + def test_omits_when_empty(self) -> None: + cfg = _base() + cfg.triggers = [] + out = build_skill_md(cfg) + assert "## Triggers" not in out + + +class TestOnboardingAndSupport: + def test_emits_numbered_onboarding(self) -> None: + cfg = _base() + cfg.onboarding_steps = ["Install agentscore-pay", "Get a Passport", "Pay any 402"] + out = build_skill_md(cfg) + assert "## Onboarding Flow" in out + assert "1. Install agentscore-pay" in out + assert "2. Get a Passport" in out + assert "3. Pay any 402" in out + + def test_emits_support_links(self) -> None: + cfg = _base() + cfg.support_links = [ + SkillMdLink(label="Homepage", url="https://martin-estate.com"), + SkillMdLink(label="Pay CLI", url="https://github.com/agentscore/pay"), + ] + out = build_skill_md(cfg) + assert "## Support" in out + assert "- **Homepage**: https://martin-estate.com" in out + assert "- **Pay CLI**: https://github.com/agentscore/pay" in out + + +class TestRefreshFooter: + def test_appends_by_default(self) -> None: + out = build_skill_md(_base()) + assert "Re-fetch this file" in out + + def test_suppresses_when_disabled(self) -> None: + cfg = _base() + cfg.refresh_footer = False + out = build_skill_md(cfg) + assert "Re-fetch this file" not in out + + +class TestOutputHygiene: + def test_ends_with_single_trailing_newline(self) -> None: + out = build_skill_md(_base()) + assert out.endswith("\n") + assert not out.endswith("\n\n") + + def test_no_triple_newline_runs(self) -> None: + out = build_skill_md(_base()) + assert "\n\n\n" not in out + + +@pytest.mark.parametrize( + "rail,expected_label", + [ + ("tempo_mpp", "MPP on Tempo"), + ("x402_base", "x402 on Base"), + ("x402_solana", "x402 on Solana"), + ("stripe", "Stripe Shared Payment Token"), + ], +) +def test_each_rail_label(rail: str, expected_label: str) -> None: + cfg = _base() + cfg.accepted_rails = [rail] + out = build_skill_md(cfg) + assert f"**{expected_label}**" in out diff --git a/uv.lock b/uv.lock index 972b0c4..7027ea3 100644 --- a/uv.lock +++ b/uv.lock @@ -10,7 +10,7 @@ resolution-markers = [ [[package]] name = "agentscore-commerce" -version = "1.1.0" +version = "1.2.0" source = { editable = "." } dependencies = [ { name = "agentscore-py" },