From ce9a8ec3170e257b50691451655e0935feef31d0 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Sat, 2 May 2026 09:38:47 -0700 Subject: [PATCH 1/4] feat(discovery): build_skill_md renderer for /skill.md agent surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new discovery builder mirroring node-commerce 1.2.0 — emits a Claude-Skill-compatible manifest (YAML frontmatter + markdown body) describing the 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 shape. Internal posture stays in merchant runtime config. The compatible-clients-per-rail table sources from the same SDK constant (compatible_clients_by_rails, extracted from challenge/agent_instructions.py) that drives the live 402 body's compatible_clients field — single source of truth across surfaces. Adds /skill.md to DEFAULT_DISCOVERY_PATHS so the existing noindex middleware / discovery-path predicate auto-recognizes the new surface. Bump to 1.2.0 to match node-commerce parity. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 2 +- README.md | 2 +- .../challenge/agent_instructions.py | 49 ++- agentscore_commerce/discovery/__init__.py | 14 + agentscore_commerce/discovery/robots_tag.py | 1 + agentscore_commerce/discovery/skill_md.py | 313 ++++++++++++++++++ pyproject.toml | 2 +- tests/test_skill_md.py | 308 +++++++++++++++++ uv.lock | 2 +- 9 files changed, 675 insertions(+), 18 deletions(-) create mode 100644 agentscore_commerce/discovery/skill_md.py create mode 100644 tests/test_skill_md.py 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..92db478 100644 --- a/agentscore_commerce/discovery/__init__.py +++ b/agentscore_commerce/discovery/__init__.py @@ -33,6 +33,14 @@ install_flask_noindex, is_discovery_path, ) +from agentscore_commerce.discovery.skill_md import ( + BuildSkillMdInput, + SkillMdEndpoint, + SkillMdIdentityRequirements, + SkillMdLink, + SkillMdShippingPolicy, + build_skill_md, +) from agentscore_commerce.discovery.well_known_mpp import ( PaymentMethodConfig, WellKnownMppInput, @@ -45,6 +53,7 @@ "BazaarDiscoveryConfig", "BuildAgentScoreOpenApiSnippetsInput", "BuildLlmsTxtInput", + "BuildSkillMdInput", "DiscoveryProbeOptions", "DiscoveryProbeResponse", "DjangoNoindexMiddleware", @@ -53,6 +62,10 @@ "LlmsTxtSection", "NoindexNonDiscoveryMiddleware", "PaymentMethodConfig", + "SkillMdEndpoint", + "SkillMdIdentityRequirements", + "SkillMdLink", + "SkillMdShippingPolicy", "WellKnownMppInput", "X402SampleProbe", "agentscore_denial_schemas", @@ -62,6 +75,7 @@ "build_bazaar_discovery_payload", "build_discovery_probe_response", "build_llms_txt", + "build_skill_md", "build_well_known_mpp", "install_flask_noindex", "is_discovery_path", diff --git a/agentscore_commerce/discovery/robots_tag.py b/agentscore_commerce/discovery/robots_tag.py index 7ae3e93..38c4ba3 100644 --- a/agentscore_commerce/discovery/robots_tag.py +++ b/agentscore_commerce/discovery/robots_tag.py @@ -21,6 +21,7 @@ { "/openapi.json", "/llms.txt", + "/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..0091b60 --- /dev/null +++ b/agentscore_commerce/discovery/skill_md.py @@ -0,0 +1,313 @@ +"""skill.md renderer — Claude-Skill-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. + +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. +""" + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Literal + +from agentscore_commerce.challenge.agent_instructions import compatible_clients_by_rails + +if TYPE_CHECKING: + from collections.abc import Iterable + +RailKey = Literal["tempo_mpp", "x402_base", "x402_solana", "stripe"] +HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE"] + + +@dataclass +class SkillMdEndpoint: + method: HttpMethod + path: str + auth_required: bool + description: str + + +@dataclass +class SkillMdIdentityRequirements: + 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``. + + Fields without defaults are required: ``name``, ``description``, ``homepage``, + ``merchant_name``, ``accepted_rails``, ``endpoints``, ``triggers``. + """ + + # Frontmatter + name: str + """Skill manifest identifier — kebab-case, e.g. 'martin-estate-wine-commerce'.""" + description: str + """One-line description of what this skill does. Surfaces in skill catalogs.""" + homepage: str + """Merchant homepage (or domain root) — appears in frontmatter.""" + 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. Keep these in sync with the merchant's ``respond_402`` + rail config.""" + 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).""" + + # Frontmatter (optional) + version: int = 1 + """Skill schema version — increment when the skill body materially changes. Default 1.""" + + # 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. Renders below the title.""" + + # Linked discovery surfaces + files: list[SkillMdLink] = field(default_factory=list) + """Files / well-known URLs surfaced under the 'Important Files' table. The skill.md URL + itself is added automatically — list other discovery surfaces (llms.txt, mpp.json, + openapi.json, agent-card.json).""" + + # Override the per-rail compatible-clients matrix. When omitted, derives from + # ``accepted_rails`` via the SDK's smoke-verified default. + compatible_clients: dict[str, list[str]] | None = None + + # Identity requirements as agent-observable outcomes (kyc / age / jurisdiction / + # sanctions). Internal posture (``fail_open``, mount strategy, KYC vendor) is + # intentionally not part of this shape — agents act on outcomes, not implementation. + identity: SkillMdIdentityRequirements | None = None + identity_bootstrap_url: str | None = None + """URL to the identity-bootstrap skill (typically ``https://agentscore.sh/skill.md``). + Linked from the Identity Prerequisite section so an agent without a Passport can + follow the bootstrap before attempting purchase.""" + + # Physical-goods policy. Omit for digital merchants. + shipping: SkillMdShippingPolicy | None = None + + # Optional numbered onboarding steps. Each entry renders as a numbered list item; + # may include shell snippets in markdown code fences. + onboarding_steps: list[str] = field(default_factory=list) + + # Support / homepage / docs links rendered in the Support section. + support_links: list[SkillMdLink] = field(default_factory=list) + + # When True (default), append a footer noting clients can refresh skill.md to pick + # up new endpoints. Set to False to suppress. + refresh_footer: bool = True + + +_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." + ), +} + + +def _frontmatter(input: BuildSkillMdInput) -> str: + return "\n".join( + [ + "---", + f"name: {input.name}", + f"description: {input.description}", + f"homepage: {input.homepage}", + "metadata:", + f" version: {input.version}", + "---", + ] + ) + + +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"| {f.label} | `{f.url}` |") + return "\n".join(["## Important Files", "", *rows]) + + +def _payment_section(input: BuildSkillMdInput) -> str: + clients = input.compatible_clients + if clients is None: + clients = compatible_clients_by_rails(input.accepted_rails) or {} + 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} | `{e.path}` | {auth_label} | {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 a Claude-Skill-compatible ``skill.md`` for an agent-commerce merchant. + + Output is YAML frontmatter (``name`` / ``description`` / ``homepage`` / + ``metadata.version``) 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). + + The compatible-clients-per-rail table sources from the same SDK constant + (``compatible_clients_by_rails``) that drives the live 402 body's + ``compatible_clients`` field — single source of truth across surfaces. + """ + title_block = [f"# {input.merchant_name}"] + if input.tagline: + title_block.append(f"\n_{input.tagline}_") + if input.intro: + title_block.append(f"\n{input.intro}") + + sections: Iterable[str] = ( + _frontmatter(input), + "\n".join(title_block), + _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..dba259c --- /dev/null +++ b/tests/test_skill_md.py @@ -0,0 +1,308 @@ +"""Tests for the skill.md discovery builder.""" + +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 re.search( + r"^---\nname: martin-estate-wine-commerce\ndescription: .+\nhomepage: https://martin-estate\.com\nmetadata:\n {2}version: 1\n---", + out, + ) + + def test_honors_version_override(self) -> None: + cfg = _base() + cfg.version = 7 + out = build_skill_md(cfg) + assert " version: 7" in out + + +class TestTitle: + def test_renders_merchant_name_as_h1(self) -> None: + out = build_skill_md(_base()) + assert "\n# Martin Estate\n" in out + + def test_renders_tagline_in_italics(self) -> None: + cfg = _base() + cfg.tagline = "A classic is forever" + out = build_skill_md(cfg) + assert "_A classic is forever_" in out + + def test_renders_intro_paragraph(self) -> None: + cfg = _base() + cfg.intro = "Napa Valley winery, family-run." + out = build_skill_md(cfg) + assert "Napa Valley winery, family-run." 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_supplied_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 + + +class TestPaymentSection: + def test_renders_one_row_per_accepted_rail_with_smoke_verified_clients(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_rails_not_declared(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_renders_em_dash_when_client_list_explicitly_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_identity_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://agentscore.sh/skill.md" + out = build_skill_md(cfg) + assert "`https://agentscore.sh/skill.md`" in out + assert "X-Operator-Token" in out + + def test_omits_when_every_flag_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_does_not_leak_internal_posture(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 internal: {forbidden}" + + +class TestShippingSection: + def test_omits_for_digital_merchants(self) -> None: + out = build_skill_md(_base()) + assert "## Shipping" not in out + + def test_renders_allowed_countries_and_blocked_states(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_populated_half(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_blocked_only_with_no_allowed(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_section_when_endpoints_empty(self) -> None: + cfg = _base() + cfg.endpoints = [] + out = build_skill_md(cfg) + assert "## Endpoints" not 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" }, From 9aa8033922c598bc964bb747319b05b190036686 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Sat, 2 May 2026 10:14:00 -0700 Subject: [PATCH 2/4] fix(discovery): agentskills.io spec compliance + parity hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors node-commerce — addresses spec violations + parity issues from end-to-end review against https://agentskills.io/specification: P0 — spec violations: - description / license / compatibility / allowed-tools / metadata-values now emitted as YAML double-quoted scalars. Fixes the colon / quote / newline parse failure the spec explicitly warns about. - metadata.version emitted as quoted string ("1") not int (1). Spec requires metadata values to be strings. P1 — validation + exports: - Validate name against the spec regex (1-64 chars, lowercase alphanumeric + hyphens, no leading / trailing / consecutive hyphens). - Validate description length ≤1024 + non-empty. - Validate compatibility length ≤500. - Title-block parity fix: tagline + intro now join with blank lines instead of being glued together (previously diverged from node output). - Re-export RailKey + compatible_clients_by_rails from agentscore_commerce .discovery (added to __all__). P2 — additional spec fields: - Surface optional license, compatibility, allowed_tools frontmatter fields. - Move homepage from top-level to metadata.homepage (spec-compliant). P3 — edge cases: - Drop compatible_clients overrides for rails not in accepted_rails. - Escape pipe characters in markdown table cells. - Add /SKILL.md (uppercase) to DEFAULT_DISCOVERY_PATHS for canonical-cased alias. Tests: 49 cases covering spec violations + edge cases + validation throws. Coverage 95.45% (over 95%). Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore_commerce/discovery/__init__.py | 4 + agentscore_commerce/discovery/robots_tag.py | 1 + agentscore_commerce/discovery/skill_md.py | 255 +++++++++++++------- tests/test_skill_md.py | 211 ++++++++++++---- 4 files changed, 340 insertions(+), 131 deletions(-) diff --git a/agentscore_commerce/discovery/__init__.py b/agentscore_commerce/discovery/__init__.py index 92db478..b526cf2 100644 --- a/agentscore_commerce/discovery/__init__.py +++ b/agentscore_commerce/discovery/__init__.py @@ -35,11 +35,13 @@ ) 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, @@ -62,6 +64,7 @@ "LlmsTxtSection", "NoindexNonDiscoveryMiddleware", "PaymentMethodConfig", + "RailKey", "SkillMdEndpoint", "SkillMdIdentityRequirements", "SkillMdLink", @@ -77,6 +80,7 @@ "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 38c4ba3..8f32169 100644 --- a/agentscore_commerce/discovery/robots_tag.py +++ b/agentscore_commerce/discovery/robots_tag.py @@ -22,6 +22,7 @@ "/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 index 0091b60..0b18b3f 100644 --- a/agentscore_commerce/discovery/skill_md.py +++ b/agentscore_commerce/discovery/skill_md.py @@ -1,4 +1,4 @@ -"""skill.md renderer — Claude-Skill-compatible agent-discovery surface. +"""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, @@ -8,20 +8,39 @@ 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 TYPE_CHECKING, Literal - -from agentscore_commerce.challenge.agent_instructions import compatible_clients_by_rails - -if TYPE_CHECKING: - from collections.abc import Iterable +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", +] -RailKey = Literal["tempo_mpp", "x402_base", "x402_solana", "stripe"] HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE"] @@ -35,6 +54,12 @@ class SkillMdEndpoint: @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 @@ -57,70 +82,77 @@ class SkillMdLink: class BuildSkillMdInput: """Inputs for ``build_skill_md``. - Fields without defaults are required: ``name``, ``description``, ``homepage``, - ``merchant_name``, ``accepted_rails``, ``endpoints``, ``triggers``. + Required fields: ``name``, ``description``, ``homepage``, ``merchant_name``, + ``accepted_rails``, ``endpoints``, ``triggers``. """ - # Frontmatter + # Required frontmatter / body name: str - """Skill manifest identifier — kebab-case, e.g. 'martin-estate-wine-commerce'.""" + """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 - """One-line description of what this skill does. Surfaces in skill catalogs.""" + """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) — appears in frontmatter.""" + """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. Keep these in sync with the merchant's ``respond_402`` - rail config.""" + 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).""" - # Frontmatter (optional) - version: int = 1 - """Skill schema version — increment when the skill body materially changes. Default 1.""" - - # Body + # 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. Renders below the title.""" - - # Linked discovery surfaces + """Optional short prose intro describing what the merchant offers.""" files: list[SkillMdLink] = field(default_factory=list) - """Files / well-known URLs surfaced under the 'Important Files' table. The skill.md URL - itself is added automatically — list other discovery surfaces (llms.txt, mpp.json, + """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).""" - - # Override the per-rail compatible-clients matrix. When omitted, derives from - # ``accepted_rails`` via the SDK's smoke-verified default. compatible_clients: dict[str, list[str]] | None = None - - # Identity requirements as agent-observable outcomes (kyc / age / jurisdiction / - # sanctions). Internal posture (``fail_open``, mount strategy, KYC vendor) is - # intentionally not part of this shape — agents act on outcomes, not implementation. + """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 (typically ``https://agentscore.sh/skill.md``). - Linked from the Identity Prerequisite section so an agent without a Passport can - follow the bootstrap before attempting purchase.""" - - # Physical-goods policy. Omit for digital merchants. + """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 - - # Optional numbered onboarding steps. Each entry renders as a numbered list item; - # may include shell snippets in markdown code fences. + """Physical-goods shipping policy. Omit for digital merchants.""" onboarding_steps: list[str] = field(default_factory=list) - - # Support / homepage / docs links rendered in the Support section. + """Optional numbered onboarding steps.""" support_links: list[SkillMdLink] = field(default_factory=list) - - # When True (default), append a footer noting clients can refresh skill.md to pick - # up new endpoints. Set to False to suppress. + """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] = { @@ -135,27 +167,88 @@ class BuildSkillMdInput: "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."), + "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: + """Sanitize a string for a markdown table cell — pipes break the row.""" + return value.replace("|", "\\|") + def _frontmatter(input: BuildSkillMdInput) -> str: - return "\n".join( - [ - "---", - f"name: {input.name}", - f"description: {input.description}", - f"homepage: {input.homepage}", - "metadata:", - f" version: {input.version}", - "---", - ] - ) + 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: @@ -166,14 +259,19 @@ def _important_files(input: BuildSkillMdInput) -> str: f"| **SKILL.md** (this file) | `{skill_url}` |", ] for f in input.files: - rows.append(f"| {f.label} | `{f.url}` |") + rows.append(f"| {_table_cell(f.label)} | `{_table_cell(f.url)}` |") return "\n".join(["## Important Files", "", *rows]) def _payment_section(input: BuildSkillMdInput) -> str: - clients = input.compatible_clients - if clients is None: - clients = compatible_clients_by_rails(input.accepted_rails) or {} + 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 "—" @@ -244,7 +342,7 @@ def _endpoints_section(input: BuildSkillMdInput) -> str: 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} | `{e.path}` | {auth_label} | {e.description} |") + rows.append(f"| {e.method} | `{_table_cell(e.path)}` | {auth_label} | {_table_cell(e.description)} |") return "\n".join(["## Endpoints", "", *rows]) @@ -276,27 +374,18 @@ def _refresh_footer(input: BuildSkillMdInput) -> str: def build_skill_md(input: BuildSkillMdInput) -> str: - """Render a Claude-Skill-compatible ``skill.md`` for an agent-commerce merchant. - - Output is YAML frontmatter (``name`` / ``description`` / ``homepage`` / - ``metadata.version``) 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). + """Render an agentskills.io-compatible ``skill.md`` for an agent-commerce merchant. - The compatible-clients-per-rail table sources from the same SDK constant - (``compatible_clients_by_rails``) that drives the live 402 body's - ``compatible_clients`` field — single source of truth across surfaces. + 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). """ - title_block = [f"# {input.merchant_name}"] - if input.tagline: - title_block.append(f"\n_{input.tagline}_") - if input.intro: - title_block.append(f"\n{input.intro}") - - sections: Iterable[str] = ( + _validate(input) + sections = [ _frontmatter(input), - "\n".join(title_block), + _title_block(input), _important_files(input), _identity_section(input), _payment_section(input), @@ -306,7 +395,7 @@ def build_skill_md(input: BuildSkillMdInput) -> str: _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") diff --git a/tests/test_skill_md.py b/tests/test_skill_md.py index dba259c..2be599f 100644 --- a/tests/test_skill_md.py +++ b/tests/test_skill_md.py @@ -1,4 +1,4 @@ -"""Tests for the skill.md discovery builder.""" +"""Tests for the skill.md discovery builder (agentskills.io spec compliance).""" import re @@ -22,18 +22,8 @@ def _base() -> BuildSkillMdInput: 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", - ), + 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"], ) @@ -43,34 +33,145 @@ class TestFrontmatter: def test_emits_yaml_block_with_required_fields(self) -> None: out = build_skill_md(_base()) assert out.startswith("---\n") - assert re.search( - r"^---\nname: martin-estate-wine-commerce\ndescription: .+\nhomepage: https://martin-estate\.com\nmetadata:\n {2}version: 1\n---", - out, - ) + 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_honors_version_override(self) -> None: + def test_version_emitted_as_quoted_string(self) -> None: cfg = _base() cfg.version = 7 out = build_skill_md(cfg) - assert " version: 7" in out + assert ' version: "7"' in out + cfg.version = "2.0.1" + out2 = build_skill_md(cfg) + assert ' version: "2.0.1"' in out2 + + 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) -class TestTitle: + 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_tagline_in_italics(self) -> None: + 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 "_A classic is forever_" in out + assert "# Martin Estate\n\n_A classic is forever_\n\nNapa Valley winery, family-run." in out - def test_renders_intro_paragraph(self) -> None: + def test_renders_tagline_only(self) -> None: cfg = _base() - cfg.intro = "Napa Valley winery, family-run." + cfg.tagline = "A classic is forever" out = build_skill_md(cfg) - assert "Napa Valley winery, family-run." in out + 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: @@ -79,7 +180,7 @@ def test_emits_self_reference(self) -> None: assert "## Important Files" in out assert "| **SKILL.md** (this file) | `https://martin-estate.com/skill.md` |" in out - def test_appends_caller_supplied_files(self) -> None: + def test_appends_caller_files(self) -> None: cfg = _base() cfg.files = [ SkillMdLink(label="llms.txt", url="https://martin-estate.com/llms.txt"), @@ -96,9 +197,15 @@ def test_strips_trailing_slash_from_homepage(self) -> None: 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 + class TestPaymentSection: - def test_renders_one_row_per_accepted_rail_with_smoke_verified_clients(self) -> None: + def test_renders_one_row_per_rail(self) -> None: out = build_skill_md(_base()) assert "## Payment" in out assert "**MPP on Tempo**" in out @@ -109,7 +216,7 @@ def test_renders_one_row_per_accepted_rail_with_smoke_verified_clients(self) -> assert "**Stripe Shared Payment Token**" in out assert "link-cli" in out - def test_omits_rails_not_declared(self) -> None: + def test_omits_unaccepted_rails(self) -> None: cfg = _base() cfg.accepted_rails = ["tempo_mpp", "x402_base", "x402_solana"] out = build_skill_md(cfg) @@ -125,7 +232,15 @@ def test_honors_compatible_clients_override(self) -> None: assert "agentscore-pay, merchant-custom-cli" in out assert "purl" not in out - def test_renders_em_dash_when_client_list_explicitly_empty(self) -> None: + 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": []} @@ -134,17 +249,14 @@ def test_renders_em_dash_when_client_list_explicitly_empty(self) -> None: class TestIdentitySection: - def test_omits_when_identity_not_declared(self) -> None: + 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, + kyc_required=True, min_age=21, allowed_jurisdictions=["US"], sanctions_clear=True ) out = build_skill_md(cfg) assert "## Identity Prerequisite" in out @@ -156,24 +268,21 @@ def test_renders_kyc_age_jurisdictions_sanctions(self) -> None: def test_renders_bootstrap_pointer(self) -> None: cfg = _base() cfg.identity = SkillMdIdentityRequirements(kyc_required=True) - cfg.identity_bootstrap_url = "https://agentscore.sh/skill.md" + cfg.identity_bootstrap_url = "https://identity.example.com/skill.md" out = build_skill_md(cfg) - assert "`https://agentscore.sh/skill.md`" in out + assert "`https://identity.example.com/skill.md`" in out assert "X-Operator-Token" in out - def test_omits_when_every_flag_falsy(self) -> None: + 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_does_not_leak_internal_posture(self) -> None: + 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, + kyc_required=True, min_age=21, allowed_jurisdictions=["US"], sanctions_clear=True ) out = build_skill_md(cfg) for forbidden in [ @@ -185,15 +294,15 @@ def test_does_not_leak_internal_posture(self) -> None: "Persona", "Stripe Identity", ]: - assert forbidden not in out, f"leaked internal: {forbidden}" + assert forbidden not in out, f"leaked: {forbidden}" class TestShippingSection: - def test_omits_for_digital_merchants(self) -> None: + def test_omits_when_no_shipping(self) -> None: out = build_skill_md(_base()) assert "## Shipping" not in out - def test_renders_allowed_countries_and_blocked_states(self) -> None: + 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) @@ -201,14 +310,14 @@ def test_renders_allowed_countries_and_blocked_states(self) -> None: assert "Ships to: US." in out assert "Blocked US states: AK, HI, MS." in out - def test_renders_only_populated_half(self) -> None: + 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_blocked_only_with_no_allowed(self) -> None: + def test_renders_only_blocked(self) -> None: cfg = _base() cfg.shipping = SkillMdShippingPolicy(blocked_states=["UT", "AK"]) out = build_skill_md(cfg) @@ -224,12 +333,18 @@ def test_emits_one_row_per_endpoint(self) -> None: assert "| GET | `/api/v1/wines` | anonymous | Wine catalog |" in out assert "| POST | `/api/v1/orders` | identity required | Place order |" in out - def test_omits_section_when_endpoints_empty(self) -> None: + 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: From 70847bc22b823f2c021e9b431e1a1e7dfd1cafc8 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Sat, 2 May 2026 10:27:37 -0700 Subject: [PATCH 3/4] =?UTF-8?q?test(discovery):=20lock=20version:=200=20?= =?UTF-8?q?=E2=86=92=20"0"=20parity=20behavior?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror node-commerce parity-lock test. End-to-end review flagged version: 0 as potential parity drift; verified both pass 0 through unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_skill_md.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_skill_md.py b/tests/test_skill_md.py index 2be599f..5762168 100644 --- a/tests/test_skill_md.py +++ b/tests/test_skill_md.py @@ -48,6 +48,13 @@ def test_version_emitted_as_quoted_string(self) -> None: 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" From 5b98d061c7693a8863701d35cfa762da0894fef7 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Sat, 2 May 2026 10:31:14 -0700 Subject: [PATCH 4/4] fix(discovery): _table_cell escapes backslashes before pipes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors node-commerce CodeQL fix. Prior implementation escaped \`|\` but not \`\\\`, so an input like \`a\\|b\` rendered as literal-backslash + cell-terminator — breaking the markdown table row. Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore_commerce/discovery/skill_md.py | 8 ++++++-- tests/test_skill_md.py | 8 ++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/agentscore_commerce/discovery/skill_md.py b/agentscore_commerce/discovery/skill_md.py index 0b18b3f..7004c69 100644 --- a/agentscore_commerce/discovery/skill_md.py +++ b/agentscore_commerce/discovery/skill_md.py @@ -214,8 +214,12 @@ def _quote_yaml(value: str) -> str: def _table_cell(value: str) -> str: - """Sanitize a string for a markdown table cell — pipes break the row.""" - return value.replace("|", "\\|") + 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: diff --git a/tests/test_skill_md.py b/tests/test_skill_md.py index 5762168..ebcddae 100644 --- a/tests/test_skill_md.py +++ b/tests/test_skill_md.py @@ -210,6 +210,14 @@ def test_escapes_pipes_in_files(self) -> None: 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: