diff --git a/agentscore_commerce/discovery/__init__.py b/agentscore_commerce/discovery/__init__.py index 05abeee..e1d1789 100644 --- a/agentscore_commerce/discovery/__init__.py +++ b/agentscore_commerce/discovery/__init__.py @@ -1,20 +1,15 @@ """Discovery helpers — probe responder, Bazaar payload builder, .well-known/mpp.json, llms.txt, OpenAPI snippets.""" -from agentscore_commerce.discovery.bazaar import BazaarDiscoveryConfig, build_bazaar_discovery_payload +from agentscore_commerce.discovery.bazaar import build_bazaar_discovery_payload from agentscore_commerce.discovery.llms_txt import ( - BuildLlmsTxtInput, - LlmsTxtIdentitySectionInput, - LlmsTxtPaymentSectionInput, LlmsTxtSection, build_llms_txt, llms_txt_identity_section, llms_txt_payment_section, ) from agentscore_commerce.discovery.openapi import ( - BuildAgentScoreOpenApiSnippetsInput, XPaymentInfoDynamicPrice, XPaymentInfoFixedPrice, - XPaymentInfoInput, XPaymentInfoMpp, agentscore_denial_schemas, agentscore_openapi_snippets, @@ -25,7 +20,6 @@ x_payment_info_extension, ) from agentscore_commerce.discovery.probe import ( - DiscoveryProbeOptions, DiscoveryProbeResponse, X402SampleProbe, build_discovery_probe_response, @@ -41,7 +35,6 @@ is_discovery_path, ) from agentscore_commerce.discovery.skill_md import ( - BuildSkillMdInput, RailKey, SkillMdEndpoint, SkillMdIdentityRequirements, @@ -52,11 +45,9 @@ ) from agentscore_commerce.discovery.well_known_mpp import ( PaymentMethodConfig, - WellKnownMppInput, build_well_known_mpp, ) from agentscore_commerce.discovery.well_known_x402 import ( - BuildWellKnownX402Input, WellKnownX402Resource, build_well_known_x402, ) @@ -64,16 +55,8 @@ __all__ = [ "DEFAULT_DISCOVERY_PATHS", "DEFAULT_ROBOTS_TAG", - "BazaarDiscoveryConfig", - "BuildAgentScoreOpenApiSnippetsInput", - "BuildLlmsTxtInput", - "BuildSkillMdInput", - "BuildWellKnownX402Input", - "DiscoveryProbeOptions", "DiscoveryProbeResponse", "DjangoNoindexMiddleware", - "LlmsTxtIdentitySectionInput", - "LlmsTxtPaymentSectionInput", "LlmsTxtSection", "NoindexNonDiscoveryMiddleware", "PaymentMethodConfig", @@ -82,12 +65,10 @@ "SkillMdIdentityRequirements", "SkillMdLink", "SkillMdShippingPolicy", - "WellKnownMppInput", "WellKnownX402Resource", "X402SampleProbe", "XPaymentInfoDynamicPrice", "XPaymentInfoFixedPrice", - "XPaymentInfoInput", "XPaymentInfoMpp", "agentscore_denial_schemas", "agentscore_openapi_snippets", diff --git a/agentscore_commerce/discovery/bazaar.py b/agentscore_commerce/discovery/bazaar.py index 4c30304..b55ba95 100644 --- a/agentscore_commerce/discovery/bazaar.py +++ b/agentscore_commerce/discovery/bazaar.py @@ -5,26 +5,24 @@ This helper documents the expected shape and is a placeholder for a future Python-native binding. """ -from dataclasses import dataclass, field from typing import Any -@dataclass -class BazaarDiscoveryConfig: - body_type: str | None = None - input: dict[str, Any] | None = None - output: dict[str, Any] | None = None - extra: dict[str, Any] = field(default_factory=dict) - - -def build_bazaar_discovery_payload(config: BazaarDiscoveryConfig) -> dict[str, Any]: +def build_bazaar_discovery_payload( + *, + body_type: str | None = None, + input: dict[str, Any] | None = None, + output: dict[str, Any] | None = None, + extra: dict[str, Any] | None = None, +) -> dict[str, Any]: """Build the JSON document a Bazaar discovery endpoint should serve.""" out: dict[str, Any] = {} - if config.body_type: - out["bodyType"] = config.body_type - if config.input is not None: - out["input"] = config.input - if config.output is not None: - out["output"] = config.output - out.update(config.extra) + if body_type: + out["bodyType"] = body_type + if input is not None: + out["input"] = input + if output is not None: + out["output"] = output + if extra: + out.update(extra) return out diff --git a/agentscore_commerce/discovery/llms_txt.py b/agentscore_commerce/discovery/llms_txt.py index d6f3eac..b7011db 100644 --- a/agentscore_commerce/discovery/llms_txt.py +++ b/agentscore_commerce/discovery/llms_txt.py @@ -1,23 +1,27 @@ """llms.txt builders — identity section + payment section + full document assembler.""" import re -from dataclasses import dataclass, field -from typing import Any +from typing import Any, TypedDict -@dataclass -class LlmsTxtIdentitySectionInput: - agentscore: bool = False - compliance: dict[str, Any] | None = None +class LlmsTxtSection(TypedDict): + """One ``## Heading`` block in :func:`build_llms_txt`.""" + heading: str + content: str -def llms_txt_identity_section(input: LlmsTxtIdentitySectionInput) -> str: + +def llms_txt_identity_section( + *, + agentscore: bool = False, + compliance: dict[str, Any] | None = None, +) -> str: """Generate the standard "Choose your identity header" section for an AgentScore-gated merchant's llms.txt.""" - if not input.agentscore: + if not agentscore: return "" compliance_note = "" - if input.compliance: - c = input.compliance + if compliance: + c = compliance parts: list[str] = [] if c.get("require_kyc"): parts.append("KYC required") @@ -55,28 +59,30 @@ def llms_txt_identity_section(input: LlmsTxtIdentitySectionInput) -> str: ) -@dataclass -class LlmsTxtPaymentSectionInput: - rails: list[str] - app_url: str - verbose: bool = False - """Emit the verbose multi-step variant (setup commands per rail + full command examples + warnings). - Default False (one-line bullet per rail). Use this when llms.txt is the primary integration doc.""" - tempo_network_name: str = "tempo-mainnet" - """Verbose mode only — Tempo network name to mention in prerequisites.""" - tempo_chain_id: int = 4217 - """Verbose mode only — Tempo chain id to mention in prerequisites.""" - - -def llms_txt_payment_section(input: LlmsTxtPaymentSectionInput) -> str: +def llms_txt_payment_section( + *, + rails: list[str], + app_url: str, + verbose: bool = False, + tempo_network_name: str = "tempo-mainnet", + tempo_chain_id: int = 4217, +) -> str: """Generate the standard "## Payment" section. - Pass `verbose=True` for the rich variant — multi-step setup + full command examples + + Pass ``verbose=True`` for the rich variant — multi-step setup + full command examples + exact-amount warnings. Default is the compact one-bullet-per-rail form. + + ``tempo_network_name`` / ``tempo_chain_id`` are surfaced in the verbose-mode prerequisites; + ignored in compact mode. """ - if input.verbose: - return _llms_txt_payment_section_verbose(input) - return _llms_txt_payment_section_compact(input) + if verbose: + return _llms_txt_payment_section_verbose( + rails=rails, + app_url=app_url, + tempo_network_name=tempo_network_name, + tempo_chain_id=tempo_chain_id, + ) + return _llms_txt_payment_section_compact(rails=rails, app_url=app_url) def _has_rail_family(rails: list[str], prefix: str) -> bool: @@ -90,25 +96,25 @@ def _is_testnet_rail(rails: list[str], prefix: str) -> bool: return any(r.startswith(prefix) and _TESTNET_MARKER.search(r) for r in rails) -def _llms_txt_payment_section_compact(input: LlmsTxtPaymentSectionInput) -> str: +def _llms_txt_payment_section_compact(*, rails: list[str], app_url: str) -> str: lines: list[str] = ["## Payment", ""] - rails = list(input.rails) - if _has_rail_family(rails, "tempo-"): + rails_list = list(rails) + if _has_rail_family(rails_list, "tempo-"): lines.append( "- **Tempo USDC via MPP** — " - f"`tempo request -X POST -H \"X-Operator-Token: opc_...\" --json '{{...}}' --max-spend N {input.app_url}`" + f"`tempo request -X POST -H \"X-Operator-Token: opc_...\" --json '{{...}}' --max-spend N {app_url}`" ) - if _has_rail_family(rails, "x402-base-"): + if _has_rail_family(rails_list, "x402-base-"): lines.append( - f"- **x402 USDC on Base** (EIP-3009) — `agentscore-pay pay POST {input.app_url} --chain base " + f"- **x402 USDC on Base** (EIP-3009) — `agentscore-pay pay POST {app_url} --chain base " "-H \"X-Operator-Token: opc_...\" -d '{...}'`" ) - if _has_rail_family(rails, "mpp-solana-"): + if _has_rail_family(rails_list, "mpp-solana-"): lines.append( - f"- **x402 USDC on Solana** (SPL Token) — `agentscore-pay pay POST {input.app_url} --chain solana " + f"- **x402 USDC on Solana** (SPL Token) — `agentscore-pay pay POST {app_url} --chain solana " "-H \"X-Operator-Token: opc_...\" -d '{...}'`" ) - if "stripe-spt" in rails: + if "stripe-spt" in rails_list: lines.append( "- **Stripe Shared Payment Token** — agent mints SPT (own Stripe account scoped to networkId, " "OR `link-cli spend-request create --credential-type shared_payment_token --network-id " @@ -123,14 +129,20 @@ def _llms_txt_payment_section_compact(input: LlmsTxtPaymentSectionInput) -> str: return "\n".join(lines) -def _llms_txt_payment_section_verbose(input: LlmsTxtPaymentSectionInput) -> str: - rails = list(input.rails) - has_tempo = _has_rail_family(rails, "tempo-") - has_base = _has_rail_family(rails, "x402-base-") - has_solana = _has_rail_family(rails, "mpp-solana-") - has_stripe = "stripe-spt" in rails - base_network_name = "Base Sepolia" if _is_testnet_rail(rails, "x402-base-") else "Base" - solana_network_name = "Solana devnet" if _is_testnet_rail(rails, "mpp-solana-") else "Solana" +def _llms_txt_payment_section_verbose( + *, + rails: list[str], + app_url: str, + tempo_network_name: str, + tempo_chain_id: int, +) -> str: + rails_list = list(rails) + has_tempo = _has_rail_family(rails_list, "tempo-") + has_base = _has_rail_family(rails_list, "x402-base-") + has_solana = _has_rail_family(rails_list, "mpp-solana-") + has_stripe = "stripe-spt" in rails_list + base_network_name = "Base Sepolia" if _is_testnet_rail(rails_list, "x402-base-") else "Base" + solana_network_name = "Solana devnet" if _is_testnet_rail(rails_list, "mpp-solana-") else "Solana" lines: list[str] = ["## Payment", ""] lines.append( @@ -159,8 +171,8 @@ def _llms_txt_payment_section_verbose(input: LlmsTxtPaymentSectionInput) -> str: lines.append("1. Install the Tempo CLI: curl -fsSL https://tempo.xyz/install | bash") lines.append("2. Log in to your Tempo Wallet: tempo wallet login (passkey auth in browser)") lines.append( - f"3. Confirm your balance: tempo wallet whoami (need USDC.e on {input.tempo_network_name}, " - f"chain {input.tempo_chain_id})" + f"3. Confirm your balance: tempo wallet whoami (need USDC.e on {tempo_network_name}, " + f"chain {tempo_chain_id})" ) lines.append("4. If balance is zero, fund it: tempo wallet fund") lines.append("") @@ -171,11 +183,11 @@ def _llms_txt_payment_section_verbose(input: LlmsTxtPaymentSectionInput) -> str: lines.append(' -H "Content-Type: application/json" \\') lines.append(" --json '{...}' \\") lines.append(" --max-spend N \\") - lines.append(f" {input.app_url}") + lines.append(f" {app_url}") lines.append("") lines.append( f"`tempo request` handles the full MPP handshake: sends the POST, receives the 402 challenge, " - f"signs the payment on {input.tempo_network_name}, submits the credential, and returns the " + f"signs the payment on {tempo_network_name}, submits the credential, and returns the " "completed order." ) lines.append("") @@ -203,7 +215,7 @@ def _llms_txt_payment_section_verbose(input: LlmsTxtPaymentSectionInput) -> str: lines.append("") lines.append("Then submit the paid purchase:") lines.append("") - lines.append(f"agentscore-pay pay POST {input.app_url} \\") + lines.append(f"agentscore-pay pay POST {app_url} \\") lines.append(f" {'--chain base' if has_base else '--chain solana'} \\") lines.append(' -H "X-Operator-Token: opc_your_credential" \\') lines.append(' -H "Content-Type: application/json" \\') @@ -247,35 +259,33 @@ def _llms_txt_payment_section_verbose(input: LlmsTxtPaymentSectionInput) -> str: return "\n".join(lines) -@dataclass -class LlmsTxtSection: - heading: str - content: str - +def build_llms_txt( + *, + merchant_name: str, + sections: list[LlmsTxtSection] | None = None, + tagline: str | None = None, + agentscore_identity: dict[str, Any] | None = None, + payment: dict[str, Any] | None = None, +) -> str: + """Assemble a complete llms.txt document with optional AgentScore identity + payment boilerplate. -@dataclass -class BuildLlmsTxtInput: - merchant_name: str - sections: list[LlmsTxtSection] = field(default_factory=list) - tagline: str | None = None - agentscore_identity: LlmsTxtIdentitySectionInput | None = None - payment: LlmsTxtPaymentSectionInput | None = None - - -def build_llms_txt(input: BuildLlmsTxtInput) -> str: - """Assemble a complete llms.txt document with optional AgentScore identity + payment boilerplate.""" - parts: list[str] = [f"# {input.merchant_name}"] - if input.tagline: - parts.append(f"> {input.tagline}") + ``agentscore_identity`` is a dict forwarded to :func:`llms_txt_identity_section` + (keys: ``agentscore``, ``compliance``). ``payment`` is a dict forwarded to + :func:`llms_txt_payment_section` (keys: ``rails``, ``app_url``, ``verbose``, + ``tempo_network_name``, ``tempo_chain_id``). + """ + parts: list[str] = [f"# {merchant_name}"] + if tagline: + parts.append(f"> {tagline}") parts.append("") - for s in input.sections: - parts.append(f"## {s.heading}") + for s in sections or []: + parts.append(f"## {s['heading']}") parts.append("") - parts.append(s.content) + parts.append(s["content"]) parts.append("") - if input.agentscore_identity: - parts.append(llms_txt_identity_section(input.agentscore_identity)) + if agentscore_identity: + parts.append(llms_txt_identity_section(**agentscore_identity)) parts.append("") - if input.payment: - parts.append(llms_txt_payment_section(input.payment)) + if payment: + parts.append(llms_txt_payment_section(**payment)) return "\n".join(parts) diff --git a/agentscore_commerce/discovery/openapi.py b/agentscore_commerce/discovery/openapi.py index dcb7ba5..cdc46ca 100644 --- a/agentscore_commerce/discovery/openapi.py +++ b/agentscore_commerce/discovery/openapi.py @@ -78,30 +78,22 @@ class XPaymentInfoMpp: currency: str -@dataclass -class XPaymentInfoInput: - """Per-operation `x-payment-info` extension input, per the x402scan discovery spec. - - ``protocols`` is a list of single-key dicts. Use ``{"x402": {}}`` for x402, - ``{"mpp": {"method": ..., "intent": ..., "currency": ...}}`` for MPP. Order is - preserved. - """ - - price: XPaymentInfoPrice - protocols: list[dict[str, Any]] - - -def x_payment_info_extension(input: XPaymentInfoInput) -> dict[str, Any]: +def x_payment_info_extension( + *, + price: XPaymentInfoPrice, + protocols: list[dict[str, Any]], +) -> dict[str, Any]: """Wrap a price + protocols block under ``x-payment-info``. - For spreading into an OpenAPI operation object. + For spreading into an OpenAPI operation object. ``protocols`` is a list of + single-key dicts: ``{"x402": {}}`` for x402, ``{"mpp": {"method": ..., + "intent": ..., "currency": ...}}`` for MPP. Order is preserved. """ - price = input.price if isinstance(price, XPaymentInfoFixedPrice): price_dict: dict[str, Any] = {"mode": "fixed", "currency": price.currency, "amount": price.amount} else: price_dict = {"mode": "dynamic", "currency": price.currency, "min": price.min, "max": price.max} - return {"x-payment-info": {"price": price_dict, "protocols": input.protocols}} + return {"x-payment-info": {"price": price_dict, "protocols": protocols}} def x_guidance_extension(text: str) -> dict[str, str]: @@ -216,24 +208,21 @@ def agentscore_payment_required_schema() -> dict[str, Any]: } -@dataclass -class BuildAgentScoreOpenApiSnippetsInput: - security: bool = True - denials: bool = True - payment_required: bool = True - - -def agentscore_openapi_snippets(opts: BuildAgentScoreOpenApiSnippetsInput | None = None) -> dict[str, Any]: +def agentscore_openapi_snippets( + *, + security: bool = True, + denials: bool = True, + payment_required: bool = True, +) -> dict[str, Any]: """Returns a `components` snippet ready to merge into an OpenAPI document.""" - o = opts or BuildAgentScoreOpenApiSnippetsInput() out: dict[str, Any] = {} - if o.security: + if security: out["securitySchemes"] = agentscore_security_schemes() - if o.denials or o.payment_required: + if denials or payment_required: schemas: dict[str, Any] = {} - if o.denials: + if denials: schemas.update(agentscore_denial_schemas()) - if o.payment_required: + if payment_required: schemas.update(agentscore_payment_required_schema()) out["schemas"] = schemas return out diff --git a/agentscore_commerce/discovery/probe.py b/agentscore_commerce/discovery/probe.py index d2e17ab..3e8e471 100644 --- a/agentscore_commerce/discovery/probe.py +++ b/agentscore_commerce/discovery/probe.py @@ -2,7 +2,7 @@ import base64 import json -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import UTC, datetime, timedelta from typing import Any, Literal @@ -92,19 +92,6 @@ class X402SampleProbe: resource_url: str | None = None -@dataclass -class DiscoveryProbeOptions: - realm: str - sample_rail: str - sample_amount_usd: float - sample_recipient: str - intent: str = "charge" - ttl_seconds: int = 300 - docs_url: str | None = None - message: str | None = None - x402_sample: X402SampleProbe | None = field(default=None) - - @dataclass class DiscoveryProbeResponse: status: int @@ -112,45 +99,54 @@ class DiscoveryProbeResponse: body: str -def build_discovery_probe_response(opts: DiscoveryProbeOptions) -> DiscoveryProbeResponse: +def build_discovery_probe_response( + *, + realm: str, + sample_rail: str, + sample_amount_usd: float, + sample_recipient: str, + intent: str = "charge", + ttl_seconds: int = 300, + docs_url: str | None = None, + message: str | None = None, + x402_sample: X402SampleProbe | None = None, +) -> DiscoveryProbeResponse: """Build a 402 response advertising a sample Payment challenge for crawler indexing.""" probe_id = f"probe_{int(datetime.now(UTC).timestamp() * 1000)}" - expires = (datetime.now(UTC) + timedelta(seconds=opts.ttl_seconds)).isoformat().replace("+00:00", "Z") - request = build_payment_request_blob( - rail=opts.sample_rail, amount_usd=opts.sample_amount_usd, recipient=opts.sample_recipient - ) + expires = (datetime.now(UTC) + timedelta(seconds=ttl_seconds)).isoformat().replace("+00:00", "Z") + request = build_payment_request_blob(rail=sample_rail, amount_usd=sample_amount_usd, recipient=sample_recipient) directive = payment_directive( - rail=opts.sample_rail, id=probe_id, realm=opts.realm, intent=opts.intent, expires=expires, request=request + rail=sample_rail, id=probe_id, realm=realm, intent=intent, expires=expires, request=request ) body_obj: dict[str, Any] = { "error": { "code": "payment_required", - "message": opts.message + "message": message or "This endpoint requires payment. Send a valid request body to receive a full challenge.", }, "discovery": True, } - if opts.docs_url: - body_obj["docs"] = opts.docs_url + if docs_url: + body_obj["docs"] = docs_url headers: dict[str, str] = {"content-type": "application/json", "www-authenticate": directive} - if opts.x402_sample is not None: - x402v = opts.x402_sample.version - if opts.x402_sample.accepts is not None: - sample_accepts: list[Any] = opts.x402_sample.accepts + if x402_sample is not None: + x402v = x402_sample.version + if x402_sample.accepts is not None: + sample_accepts: list[Any] = x402_sample.accepts else: sample_accepts = [ e - for n in (opts.x402_sample.networks or []) - for e in [sample_x402_accept_for_network(n, opts.x402_sample.amount_atomic)] + for n in (x402_sample.networks or []) + for e in [sample_x402_accept_for_network(n, x402_sample.amount_atomic)] if e is not None ] # payment_required_header internally runs alias_amount_fields, so v1+v2 # parsers both find their expected field name on the header decode. header_kwargs: dict[str, Any] = {"x402_version": x402v, "accepts": sample_accepts} - if opts.x402_sample.resource_url: + if x402_sample.resource_url: header_kwargs["resource"] = { - "url": opts.x402_sample.resource_url, + "url": x402_sample.resource_url, "mimeType": "application/json", } encoded = payment_required_header(**header_kwargs) diff --git a/agentscore_commerce/discovery/skill_md.py b/agentscore_commerce/discovery/skill_md.py index 1af2391..9b6d280 100644 --- a/agentscore_commerce/discovery/skill_md.py +++ b/agentscore_commerce/discovery/skill_md.py @@ -23,7 +23,7 @@ import re from dataclasses import dataclass, field -from typing import Literal +from typing import Literal, TypedDict from agentscore_commerce.challenge.agent_instructions import ( RailKey, @@ -31,7 +31,6 @@ ) __all__ = [ - "BuildSkillMdInput", "RailKey", "SkillMdEndpoint", "SkillMdIdentityRequirements", @@ -44,115 +43,64 @@ HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE"] -@dataclass -class SkillMdEndpoint: +class SkillMdEndpoint(TypedDict): + """One row in the ``## Endpoints`` table.""" + method: HttpMethod path: str auth_required: bool description: str -@dataclass -class SkillMdIdentityRequirements: +class SkillMdIdentityRequirements(TypedDict, total=False): """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 + kyc_required: bool + min_age: int | None + allowed_jurisdictions: list[str] | None + sanctions_clear: bool -@dataclass -class SkillMdShippingPolicy: - allowed_countries: list[str] | None = None - blocked_states: list[str] | None = None +class SkillMdShippingPolicy(TypedDict, total=False): + allowed_countries: list[str] | None + blocked_states: list[str] | None -@dataclass -class SkillMdLink: +class SkillMdLink(TypedDict): 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 +# Internal aggregate so the 11 private section helpers don't need 14 kwargs each; +# `build_skill_md` flattens the public API and constructs this once. +@dataclass(frozen=True) +class _SkillCtx: 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. 'Example Merchant').""" 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] = { @@ -185,7 +133,7 @@ class BuildSkillMdInput: _COMPATIBILITY_MAX = 500 -def _validate(input: BuildSkillMdInput) -> None: +def _validate(input: _SkillCtx) -> 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})") @@ -226,7 +174,7 @@ def _table_cell(value: str) -> str: return value.replace("\\", "\\\\").replace("|", "\\|") -def _frontmatter(input: BuildSkillMdInput) -> str: +def _frontmatter(input: _SkillCtx) -> str: lines = ["---", f"name: {input.name}", f"description: {_quote_yaml(input.description)}"] if input.license: lines.append(f"license: {_quote_yaml(input.license)}") @@ -250,7 +198,7 @@ def _frontmatter(input: BuildSkillMdInput) -> str: return "\n".join(lines) -def _title_block(input: BuildSkillMdInput) -> str: +def _title_block(input: _SkillCtx) -> str: parts = [f"# {input.merchant_name}"] if input.tagline: parts.append(f"_{input.tagline}_") @@ -259,7 +207,7 @@ def _title_block(input: BuildSkillMdInput) -> str: return "\n\n".join(parts) -def _important_files(input: BuildSkillMdInput) -> str: +def _important_files(input: _SkillCtx) -> str: skill_url = f"{input.homepage.rstrip('/')}/skill.md" rows = [ "| File | URL |", @@ -267,11 +215,11 @@ def _important_files(input: BuildSkillMdInput) -> str: f"| **SKILL.md** (this file) | `{skill_url}` |", ] for f in input.files: - rows.append(f"| {_table_cell(f.label)} | `{_table_cell(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: +def _payment_section(input: _SkillCtx) -> str: override = input.compatible_clients defaults = compatible_clients_by_rails(input.accepted_rails) or {} clients: dict[str, list[str]] = {} @@ -292,18 +240,20 @@ def _payment_section(input: BuildSkillMdInput) -> str: return "\n".join(["## Payment", "", intro, "", *rows]) -def _identity_section(input: BuildSkillMdInput) -> str: +def _identity_section(input: _SkillCtx) -> str: id_ = input.identity if id_ is None: return "" reqs: list[str] = [] - if id_.kyc_required: + if id_.get("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: + min_age = id_.get("min_age") + if min_age: + reqs.append(f"age {min_age}+") + allowed = id_.get("allowed_jurisdictions") + if allowed: + reqs.append(f"{'/'.join(allowed)} only") + if id_.get("sanctions_clear"): reqs.append("sanctions clear") if not reqs: return "" @@ -330,58 +280,86 @@ def _identity_section(input: BuildSkillMdInput) -> str: ) -def _shipping_section(input: BuildSkillMdInput) -> str: +def _shipping_section(input: _SkillCtx) -> str: s = input.shipping - if s is None or (not s.allowed_countries and not s.blocked_states): + if s is None: + return "" + allowed_countries = s.get("allowed_countries") + blocked_states = s.get("blocked_states") + if not allowed_countries and not blocked_states: return "" lines = ["## Shipping", ""] - if s.allowed_countries: - lines.append(f"Ships to: {', '.join(s.allowed_countries)}.") - if s.blocked_states: + if allowed_countries: + lines.append(f"Ships to: {', '.join(allowed_countries)}.") + if blocked_states: if len(lines) > 2: lines.append("") - lines.append(f"Blocked US states: {', '.join(s.blocked_states)}.") + lines.append(f"Blocked US states: {', '.join(blocked_states)}.") return "\n".join(lines) -def _endpoints_section(input: BuildSkillMdInput) -> str: +def _endpoints_section(input: _SkillCtx) -> 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)} |") + 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: +def _onboarding_section(input: _SkillCtx) -> 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: +def _triggers_section(input: _SkillCtx) -> 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: +def _support_section(input: _SkillCtx) -> str: if not input.support_links: return "" - rows = [f"- **{link.label}**: {link.url}" for link in input.support_links] + rows = [f"- **{link['label']}**: {link['url']}" for link in input.support_links] return "\n".join(["## Support", "", *rows]) -def _refresh_footer(input: BuildSkillMdInput) -> str: +def _refresh_footer(input: _SkillCtx) -> 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: +def build_skill_md( + *, + name: str, + description: str, + homepage: str, + merchant_name: str, + accepted_rails: list[RailKey], + endpoints: list[SkillMdEndpoint], + triggers: list[str], + version: str | int = 1, + license: str | None = None, + compatibility: str | None = None, + allowed_tools: str | None = None, + metadata: dict[str, str | int] | None = None, + tagline: str | None = None, + intro: str | None = None, + files: list[SkillMdLink] | None = None, + compatible_clients: dict[str, list[str]] | None = None, + identity: SkillMdIdentityRequirements | None = None, + identity_bootstrap_url: str | None = None, + shipping: SkillMdShippingPolicy | None = None, + onboarding_steps: list[str] | None = None, + support_links: list[SkillMdLink] | None = None, + refresh_footer: bool = True, +) -> str: """Render an agentskills.io-compatible ``skill.md`` for an agent-commerce merchant. Output is YAML frontmatter (``name`` / ``description`` / optional ``license`` / @@ -390,19 +368,43 @@ def build_skill_md(input: BuildSkillMdInput) -> str: 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) + ctx = _SkillCtx( + name=name, + description=description, + homepage=homepage, + merchant_name=merchant_name, + accepted_rails=accepted_rails, + endpoints=endpoints, + triggers=triggers, + version=version, + license=license, + compatibility=compatibility, + allowed_tools=allowed_tools, + metadata=metadata or {}, + tagline=tagline, + intro=intro, + files=files or [], + compatible_clients=compatible_clients, + identity=identity, + identity_bootstrap_url=identity_bootstrap_url, + shipping=shipping, + onboarding_steps=onboarding_steps or [], + support_links=support_links or [], + refresh_footer=refresh_footer, + ) + _validate(ctx) 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), + _frontmatter(ctx), + _title_block(ctx), + _important_files(ctx), + _identity_section(ctx), + _payment_section(ctx), + _shipping_section(ctx), + _onboarding_section(ctx), + _endpoints_section(ctx), + _triggers_section(ctx), + _support_section(ctx), + _refresh_footer(ctx), ] body = "\n\n".join(s for s in sections if s) while "\n\n\n" in body: diff --git a/agentscore_commerce/discovery/well_known_mpp.py b/agentscore_commerce/discovery/well_known_mpp.py index 019a5c9..bf2e57b 100644 --- a/agentscore_commerce/discovery/well_known_mpp.py +++ b/agentscore_commerce/discovery/well_known_mpp.py @@ -6,6 +6,8 @@ @dataclass class PaymentMethodConfig: + """``purchase`` block input for :func:`build_well_known_mpp`.""" + methods: list[str] x402: dict[str, Any] | None = None identity: list[str] | None = None @@ -16,49 +18,53 @@ class PaymentMethodConfig: extra: dict[str, Any] = field(default_factory=dict) -@dataclass -class WellKnownMppInput: - name: str - url: str - endpoints: dict[str, dict[str, str]] - purchase: PaymentMethodConfig - description: str | None = None - openapi: str | None = None - catalog: dict[str, Any] | None = None - shipping: dict[str, Any] | None = None - extra: dict[str, Any] = field(default_factory=dict) - +def build_well_known_mpp( + *, + name: str, + url: str, + endpoints: dict[str, dict[str, str]], + purchase: PaymentMethodConfig, + description: str | None = None, + openapi: str | None = None, + catalog: dict[str, Any] | None = None, + shipping: dict[str, Any] | None = None, + extra: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build the standard `.well-known/mpp.json` discovery document. -def build_well_known_mpp(input: WellKnownMppInput) -> dict[str, Any]: - """Build the standard `.well-known/mpp.json` discovery document.""" - out: dict[str, Any] = {"name": input.name} - if input.description: - out["description"] = input.description - out["url"] = input.url - if input.openapi: - out["openapi"] = input.openapi - out["endpoints"] = input.endpoints - if input.catalog: - out["catalog"] = input.catalog + ``purchase`` carries the payment-methods + identity-paths + compliance config. + Use :class:`PaymentMethodConfig` since it carries nested structure that vendors + construct discriminated; the surrounding wrapper has been flattened. + """ + out: dict[str, Any] = {"name": name} + if description: + out["description"] = description + out["url"] = url + if openapi: + out["openapi"] = openapi + out["endpoints"] = endpoints + if catalog: + out["catalog"] = catalog - purchase: dict[str, Any] = {} - if input.purchase.required_fields: - purchase["required_fields"] = input.purchase.required_fields - if input.purchase.optional_fields: - purchase["optional_fields"] = input.purchase.optional_fields - purchase.update(input.purchase.extra) - if input.purchase.identity: - purchase["identity"] = input.purchase.identity - if input.purchase.identity_paths: - purchase["identity_paths"] = input.purchase.identity_paths - purchase["payment_methods"] = input.purchase.methods - if input.purchase.x402: - purchase["x402"] = input.purchase.x402 - if input.purchase.compliance: - purchase["compliance"] = input.purchase.compliance - out["purchase"] = purchase + purchase_block: dict[str, Any] = {} + if purchase.required_fields: + purchase_block["required_fields"] = purchase.required_fields + if purchase.optional_fields: + purchase_block["optional_fields"] = purchase.optional_fields + purchase_block.update(purchase.extra) + if purchase.identity: + purchase_block["identity"] = purchase.identity + if purchase.identity_paths: + purchase_block["identity_paths"] = purchase.identity_paths + purchase_block["payment_methods"] = purchase.methods + if purchase.x402: + purchase_block["x402"] = purchase.x402 + if purchase.compliance: + purchase_block["compliance"] = purchase.compliance + out["purchase"] = purchase_block - if input.shipping: - out["shipping"] = input.shipping - out.update(input.extra) + if shipping: + out["shipping"] = shipping + if extra: + out.update(extra) return out diff --git a/agentscore_commerce/discovery/well_known_x402.py b/agentscore_commerce/discovery/well_known_x402.py index e787262..1dede7d 100644 --- a/agentscore_commerce/discovery/well_known_x402.py +++ b/agentscore_commerce/discovery/well_known_x402.py @@ -18,12 +18,10 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import Any +from typing import Any, TypedDict -@dataclass -class WellKnownX402Resource: +class WellKnownX402Resource(TypedDict): """Entry in the ``resources`` list.""" #: HTTP method, uppercase: ``GET | POST | PUT | PATCH | DELETE``. @@ -32,14 +30,13 @@ class WellKnownX402Resource: path: str -@dataclass -class BuildWellKnownX402Input: - #: Invocable, payment-required routes. Each entry becomes ``"METHOD /path"``. - resources: list[WellKnownX402Resource] +def build_well_known_x402(*, resources: list[WellKnownX402Resource]) -> dict[str, Any]: + """Emit the ``/.well-known/x402`` discovery body. - -def build_well_known_x402(input: BuildWellKnownX402Input) -> dict[str, Any]: + ``resources`` is a list of ``{"method": ..., "path": ...}`` dicts; each becomes + a ``"METHOD /path"`` entry in the output. + """ return { "version": 1, - "resources": [f"{r.method.upper()} {r.path}" for r in input.resources], + "resources": [f"{r['method'].upper()} {r['path']}" for r in resources], } diff --git a/examples/api_provider.py b/examples/api_provider.py index 1b838e7..d9a3862 100644 --- a/examples/api_provider.py +++ b/examples/api_provider.py @@ -38,7 +38,6 @@ from fastapi.responses import JSONResponse from agentscore_commerce.discovery import ( - DiscoveryProbeOptions, NoindexNonDiscoveryMiddleware, X402SampleProbe, build_discovery_probe_response, @@ -82,19 +81,17 @@ async def search(request: Request): # Discovery probe — empty-body POST without any payment header → return sample 402. if await is_discovery_probe_request(request.method, auth, body_text): probe = build_discovery_probe_response( - DiscoveryProbeOptions( - realm=REALM, - sample_rail=_TEMPO_RAIL, - sample_amount_usd=PRICE_USDC, - sample_recipient=os.environ["TEMPO_RECIPIENT"], - # Advertise x402 support so crawlers (e.g. ``awal x402 details``) - # can find it on an empty-body POST. Commerce synthesizes USDC - # sample accepts from the registry per CAIP-2 network passed. - x402_sample=X402SampleProbe( - networks=[X402_BASE_NETWORK, SOLANA_NETWORK_CAIP2], - resource_url=f"{REALM}/search", - ), - ) + realm=REALM, + sample_rail=_TEMPO_RAIL, + sample_amount_usd=PRICE_USDC, + sample_recipient=os.environ["TEMPO_RECIPIENT"], + # Advertise x402 support so crawlers (e.g. ``awal x402 details``) + # can find it on an empty-body POST. Commerce synthesizes USDC + # sample accepts from the registry per CAIP-2 network passed. + x402_sample=X402SampleProbe( + networks=[X402_BASE_NETWORK, SOLANA_NETWORK_CAIP2], + resource_url=f"{REALM}/search", + ), ) return JSONResponse(json.loads(probe.body), status_code=probe.status, headers=probe.headers) diff --git a/tests/test_coverage_fillers.py b/tests/test_coverage_fillers.py index db935e8..cc0cd18 100644 --- a/tests/test_coverage_fillers.py +++ b/tests/test_coverage_fillers.py @@ -7,11 +7,7 @@ build_how_to_pay, ) from agentscore_commerce.discovery import ( - BazaarDiscoveryConfig, - LlmsTxtIdentitySectionInput, - LlmsTxtPaymentSectionInput, PaymentMethodConfig, - WellKnownMppInput, build_bazaar_discovery_payload, build_well_known_mpp, llms_txt_identity_section, @@ -21,12 +17,10 @@ def test_bazaar_payload_includes_all_optional_fields(): payload = build_bazaar_discovery_payload( - BazaarDiscoveryConfig( - body_type="json", - input={"q": "string"}, - output={"results": "array"}, - extra={"version": "1.0"}, - ) + body_type="json", + input={"q": "string"}, + output={"results": "array"}, + extra={"version": "1.0"}, ) assert payload == { "bodyType": "json", @@ -37,28 +31,26 @@ def test_bazaar_payload_includes_all_optional_fields(): def test_bazaar_payload_omits_empty_fields(): - payload = build_bazaar_discovery_payload(BazaarDiscoveryConfig()) + payload = build_bazaar_discovery_payload() assert payload == {} def test_well_known_mpp_includes_all_optional_blocks(): out = build_well_known_mpp( - WellKnownMppInput( - name="Ex", - description="A merchant", - url="https://ex.com", - openapi="https://ex.com/openapi.json", - endpoints={"buy": {"method": "POST", "url": "/buy"}}, - catalog={"categories": ["wine"]}, - purchase=PaymentMethodConfig( - methods=["tempo"], - required_fields=["product_id", "qty"], - optional_fields=["gift_note"], - x402={"networks": ["base"]}, - compliance={"require_kyc": True}, - ), - shipping={"countries": ["US"]}, - ) + name="Ex", + description="A merchant", + url="https://ex.com", + openapi="https://ex.com/openapi.json", + endpoints={"buy": {"method": "POST", "url": "/buy"}}, + catalog={"categories": ["wine"]}, + purchase=PaymentMethodConfig( + methods=["tempo"], + required_fields=["product_id", "qty"], + optional_fields=["gift_note"], + x402={"networks": ["base"]}, + compliance={"require_kyc": True}, + ), + shipping={"countries": ["US"]}, ) assert out["description"] == "A merchant" assert out["openapi"].endswith("openapi.json") @@ -71,20 +63,18 @@ def test_well_known_mpp_includes_all_optional_blocks(): def test_llms_txt_identity_section_returns_empty_when_agentscore_false(): - assert llms_txt_identity_section(LlmsTxtIdentitySectionInput(agentscore=False)) == "" + assert llms_txt_identity_section(agentscore=False) == "" def test_llms_txt_identity_section_includes_compliance_note(): section = llms_txt_identity_section( - LlmsTxtIdentitySectionInput( - agentscore=True, - compliance={ - "require_kyc": True, - "min_age": 21, - "allowed_jurisdictions": ["US", "CA"], - "require_sanctions_clear": True, - }, - ) + agentscore=True, + compliance={ + "require_kyc": True, + "min_age": 21, + "allowed_jurisdictions": ["US", "CA"], + "require_sanctions_clear": True, + }, ) assert "Compliance:" in section assert "KYC required" in section @@ -95,10 +85,8 @@ def test_llms_txt_identity_section_includes_compliance_note(): def test_llms_txt_payment_section_includes_all_rails(): section = llms_txt_payment_section( - LlmsTxtPaymentSectionInput( - rails=["tempo-mainnet", "x402-base-mainnet", "mpp-solana-mainnet", "stripe-spt"], - app_url="https://ex.com/buy", - ) + rails=["tempo-mainnet", "x402-base-mainnet", "mpp-solana-mainnet", "stripe-spt"], + app_url="https://ex.com/buy", ) assert "Tempo USDC via MPP" in section assert "x402 USDC on Base" in section diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 5dc68e3..c25ab37 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -3,18 +3,11 @@ import pytest from agentscore_commerce.discovery import ( - BuildAgentScoreOpenApiSnippetsInput, - BuildWellKnownX402Input, - DiscoveryProbeOptions, - LlmsTxtIdentitySectionInput, - LlmsTxtPaymentSectionInput, LlmsTxtSection, PaymentMethodConfig, - WellKnownMppInput, WellKnownX402Resource, XPaymentInfoDynamicPrice, XPaymentInfoFixedPrice, - XPaymentInfoInput, agentscore_openapi_snippets, agentscore_security_schemes, build_discovery_probe_response, @@ -31,9 +24,7 @@ def test_build_discovery_probe_response_returns_402_with_directive(): resp = build_discovery_probe_response( - DiscoveryProbeOptions( - realm="ex.com", sample_rail="tempo-mainnet", sample_amount_usd=1.0, sample_recipient="0xabc" - ) + realm="ex.com", sample_rail="tempo-mainnet", sample_amount_usd=1.0, sample_recipient="0xabc" ) assert resp.status == 402 assert resp.headers["content-type"] == "application/json" @@ -56,12 +47,10 @@ async def test_is_discovery_probe_request_false_for_get_or_with_payment_or_with_ def test_build_well_known_mpp_assembles_purchase_block(): out = build_well_known_mpp( - WellKnownMppInput( - name="Ex", - url="https://ex.com", - endpoints={"buy": {"method": "POST", "url": "/buy"}}, - purchase=PaymentMethodConfig(methods=["tempo", "x402"], identity=["X-Operator-Token"]), - ) + name="Ex", + url="https://ex.com", + endpoints={"buy": {"method": "POST", "url": "/buy"}}, + purchase=PaymentMethodConfig(methods=["tempo", "x402"], identity=["X-Operator-Token"]), ) assert out["name"] == "Ex" assert out["purchase"]["payment_methods"] == ["tempo", "x402"] @@ -70,13 +59,11 @@ def test_build_well_known_mpp_assembles_purchase_block(): def test_build_well_known_mpp_passes_through_extras(): out = build_well_known_mpp( - WellKnownMppInput( - name="Ex", - url="https://ex.com", - endpoints={}, - purchase=PaymentMethodConfig(methods=["tempo"], extra={"gift_note": {"max_length": 200}}), - extra={"version": "1.0"}, - ) + name="Ex", + url="https://ex.com", + endpoints={}, + purchase=PaymentMethodConfig(methods=["tempo"], extra={"gift_note": {"max_length": 200}}), + extra={"version": "1.0"}, ) assert out["purchase"]["gift_note"]["max_length"] == 200 assert out["version"] == "1.0" @@ -84,17 +71,11 @@ def test_build_well_known_mpp_passes_through_extras(): def test_build_llms_txt_assembles_full_document(): doc = build_llms_txt( - type( - "I", - (), - { - "merchant_name": "Ex", - "tagline": "Wines", - "sections": [LlmsTxtSection("About", "We sell wine.")], - "agentscore_identity": LlmsTxtIdentitySectionInput(agentscore=True), - "payment": LlmsTxtPaymentSectionInput(rails=["tempo-mainnet"], app_url="https://ex.com"), - }, - )() + merchant_name="Ex", + tagline="Wines", + sections=[LlmsTxtSection(heading="About", content="We sell wine.")], + agentscore_identity={"agentscore": True}, + payment={"rails": ["tempo-mainnet"], "app_url": "https://ex.com"}, ) assert "# Ex" in doc assert "## About" in doc @@ -110,7 +91,7 @@ def test_agentscore_openapi_snippets_includes_security_and_schemas(): def test_agentscore_openapi_snippets_can_disable_sections(): - snip = agentscore_openapi_snippets(BuildAgentScoreOpenApiSnippetsInput(security=False, payment_required=False)) + snip = agentscore_openapi_snippets(security=False, payment_required=False) assert "securitySchemes" not in snip assert "AgentScoreDenialReason" in snip["schemas"] @@ -118,13 +99,11 @@ def test_agentscore_openapi_snippets_can_disable_sections(): class TestLlmsTxtPaymentSectionVerbose: def test_emits_multi_step_setup_per_rail(self): section = llms_txt_payment_section( - LlmsTxtPaymentSectionInput( - rails=["tempo-mainnet", "x402-base-mainnet"], - app_url="https://my.merchant", - verbose=True, - tempo_network_name="tempo-mainnet", - tempo_chain_id=4217, - ) + rails=["tempo-mainnet", "x402-base-mainnet"], + app_url="https://my.merchant", + verbose=True, + tempo_network_name="tempo-mainnet", + tempo_chain_id=4217, ) assert "### How to pay with Tempo" in section assert "curl -fsSL https://tempo.xyz/install" in section @@ -137,30 +116,22 @@ def test_emits_multi_step_setup_per_rail(self): assert "agentscore-pay pay POST https://my.merchant" in section def test_omits_sections_for_unconfigured_rails(self): - section = llms_txt_payment_section( - LlmsTxtPaymentSectionInput(rails=["tempo-mainnet"], app_url="https://x", verbose=True) - ) + section = llms_txt_payment_section(rails=["tempo-mainnet"], app_url="https://x", verbose=True) assert "Tempo USDC" in section assert "### How to pay with x402" not in section assert "### How to pay with Stripe" not in section def test_emits_exact_amount_warning_for_x402(self): - section = llms_txt_payment_section( - LlmsTxtPaymentSectionInput(rails=["x402-base-mainnet"], app_url="https://x", verbose=True) - ) + section = llms_txt_payment_section(rails=["x402-base-mainnet"], app_url="https://x", verbose=True) assert "exact amount specified in the 402 challenge" in section def test_emits_stripe_section(self): - section = llms_txt_payment_section( - LlmsTxtPaymentSectionInput(rails=["stripe-spt"], app_url="https://x", verbose=True) - ) + section = llms_txt_payment_section(rails=["stripe-spt"], app_url="https://x", verbose=True) assert "### How to pay with Stripe SPT" in section assert "SharedPaymentToken" in section def test_solana_only_no_base(self): - section = llms_txt_payment_section( - LlmsTxtPaymentSectionInput(rails=["mpp-solana-mainnet"], app_url="https://x", verbose=True) - ) + section = llms_txt_payment_section(rails=["mpp-solana-mainnet"], app_url="https://x", verbose=True) assert "### How to pay with x402 (Solana)" in section assert "--chain solana" in section assert "--chain base" not in section @@ -217,9 +188,7 @@ def test_sample_accept_unknown_network_returns_none() -> None: # ── build_discovery_probe_response: x402 sample paths ─────────────────────── -def _probe_opts(**overrides: object): - from agentscore_commerce.discovery.probe import DiscoveryProbeOptions - +def _probe_opts(**overrides: object) -> dict[str, object]: base: dict[str, object] = { "realm": "https://example.com", "sample_rail": "tempo-mainnet", @@ -227,13 +196,13 @@ def _probe_opts(**overrides: object): "sample_recipient": "0x0000000000000000000000000000000000000001", } base.update(overrides) - return DiscoveryProbeOptions(**base) # type: ignore[arg-type] + return base def test_probe_response_without_x402_sample() -> None: from agentscore_commerce.discovery.probe import build_discovery_probe_response - resp = build_discovery_probe_response(_probe_opts()) + resp = build_discovery_probe_response(**_probe_opts()) assert resp.status == 402 assert "www-authenticate" in resp.headers assert "payment-required" not in resp.headers @@ -245,7 +214,7 @@ def test_probe_response_with_x402_sample_via_networks_shorthand() -> None: from agentscore_commerce.discovery.probe import X402SampleProbe, build_discovery_probe_response resp = build_discovery_probe_response( - _probe_opts(x402_sample=X402SampleProbe(networks=["eip155:84532", "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"])) + **_probe_opts(x402_sample=X402SampleProbe(networks=["eip155:84532", "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"])) ) assert resp.status == 402 assert "payment-required" in resp.headers @@ -260,7 +229,7 @@ def test_probe_response_with_explicit_accepts_overrides_networks_shorthand() -> from agentscore_commerce.discovery.probe import X402SampleProbe, build_discovery_probe_response custom = [{"scheme": "exact", "network": "fake", "asset": "X", "payTo": "Y"}] - resp = build_discovery_probe_response(_probe_opts(x402_sample=X402SampleProbe(accepts=custom, version=1))) + resp = build_discovery_probe_response(**_probe_opts(x402_sample=X402SampleProbe(accepts=custom, version=1))) body = _json.loads(resp.body) assert body["x402Version"] == 1 assert body["accepts"][0]["network"] == "fake" @@ -273,7 +242,7 @@ def test_probe_response_with_resource_url() -> None: from agentscore_commerce.discovery.probe import X402SampleProbe, build_discovery_probe_response resp = build_discovery_probe_response( - _probe_opts(x402_sample=X402SampleProbe(networks=["eip155:84532"], resource_url="https://example.com/api")) + **_probe_opts(x402_sample=X402SampleProbe(networks=["eip155:84532"], resource_url="https://example.com/api")) ) decoded = _json.loads(base64.b64decode(resp.headers["payment-required"]).decode()) assert decoded["resource"]["url"] == "https://example.com/api" @@ -284,7 +253,7 @@ def test_probe_response_with_docs_url() -> None: from agentscore_commerce.discovery.probe import build_discovery_probe_response - resp = build_discovery_probe_response(_probe_opts(docs_url="https://docs.example.com")) + resp = build_discovery_probe_response(**_probe_opts(docs_url="https://docs.example.com")) body = _json.loads(resp.body) assert body["docs"] == "https://docs.example.com" @@ -295,7 +264,7 @@ def test_probe_response_unknown_network_filtered_out() -> None: from agentscore_commerce.discovery.probe import X402SampleProbe, build_discovery_probe_response resp = build_discovery_probe_response( - _probe_opts(x402_sample=X402SampleProbe(networks=["eip155:84532", "eip155:99999"])) + **_probe_opts(x402_sample=X402SampleProbe(networks=["eip155:84532", "eip155:99999"])) ) body = _json.loads(resp.body) assert len(body["accepts"]) == 1 @@ -341,23 +310,21 @@ async def test_is_probe_with_real_body_rejected() -> None: def test_build_well_known_x402_emits_v1_shape(): doc = build_well_known_x402( - BuildWellKnownX402Input( - resources=[ - WellKnownX402Resource(method="POST", path="/purchase"), - WellKnownX402Resource(method="GET", path="/catalog"), - ] - ) + resources=[ + WellKnownX402Resource(method="POST", path="/purchase"), + WellKnownX402Resource(method="GET", path="/catalog"), + ] ) assert doc == {"version": 1, "resources": ["POST /purchase", "GET /catalog"]} def test_build_well_known_x402_uppercases_methods(): - doc = build_well_known_x402(BuildWellKnownX402Input(resources=[WellKnownX402Resource(method="post", path="/x")])) + doc = build_well_known_x402(resources=[WellKnownX402Resource(method="post", path="/x")]) assert doc["resources"] == ["POST /x"] def test_build_well_known_x402_empty_resources(): - assert build_well_known_x402(BuildWellKnownX402Input(resources=[])) == {"version": 1, "resources": []} + assert build_well_known_x402(resources=[]) == {"version": 1, "resources": []} def test_siwx_security_scheme_is_http_bearer_siwx(): @@ -375,10 +342,8 @@ def test_agentscore_security_schemes_includes_siwx(): def test_x_payment_info_extension_fixed_price(): ext = x_payment_info_extension( - XPaymentInfoInput( - price=XPaymentInfoFixedPrice(currency="USD", amount="0.10"), - protocols=[{"x402": {}}], - ) + price=XPaymentInfoFixedPrice(currency="USD", amount="0.10"), + protocols=[{"x402": {}}], ) assert ext["x-payment-info"]["price"] == {"mode": "fixed", "currency": "USD", "amount": "0.10"} assert ext["x-payment-info"]["protocols"] == [{"x402": {}}] @@ -386,13 +351,11 @@ def test_x_payment_info_extension_fixed_price(): def test_x_payment_info_extension_dynamic_price_with_mpp(): ext = x_payment_info_extension( - XPaymentInfoInput( - price=XPaymentInfoDynamicPrice(currency="USD", min="0.01", max="5.00"), - protocols=[ - {"x402": {}}, - {"mpp": {"method": "tempo/charge", "intent": "pay", "currency": "USD"}}, - ], - ) + price=XPaymentInfoDynamicPrice(currency="USD", min="0.01", max="5.00"), + protocols=[ + {"x402": {}}, + {"mpp": {"method": "tempo/charge", "intent": "pay", "currency": "USD"}}, + ], ) assert ext["x-payment-info"]["price"]["mode"] == "dynamic" assert ext["x-payment-info"]["price"]["min"] == "0.01" diff --git a/tests/test_skill_md.py b/tests/test_skill_md.py index 296e640..10b6902 100644 --- a/tests/test_skill_md.py +++ b/tests/test_skill_md.py @@ -5,7 +5,6 @@ import pytest from agentscore_commerce.discovery import ( - BuildSkillMdInput, SkillMdEndpoint, SkillMdIdentityRequirements, SkillMdLink, @@ -14,24 +13,24 @@ ) -def _base() -> BuildSkillMdInput: - return BuildSkillMdInput( - name="example-merchant-commerce", - description="Buy from Example Merchant via an AI agent", - homepage="https://example.com", - merchant_name="Example Merchant", - accepted_rails=["tempo_mpp", "x402_base", "solana_mpp", "stripe"], - endpoints=[ +def _base() -> dict: + return { + "name": "example-merchant-commerce", + "description": "Buy from Example Merchant via an AI agent", + "homepage": "https://example.com", + "merchant_name": "Example Merchant", + "accepted_rails": ["tempo_mpp", "x402_base", "solana_mpp", "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 from Example Merchant"], - ) + "triggers": ["User wants to buy from Example Merchant"], + } class TestFrontmatter: def test_emits_yaml_block_with_required_fields(self) -> None: - out = build_skill_md(_base()) + out = build_skill_md(**_base()) assert out.startswith("---\n") assert "name: example-merchant-commerce" in out assert 'description: "Buy from Example Merchant via an AI agent"' in out @@ -41,58 +40,58 @@ def test_emits_yaml_block_with_required_fields(self) -> None: def test_version_emitted_as_quoted_string(self) -> None: cfg = _base() - cfg.version = 7 - out = build_skill_md(cfg) + cfg["version"] = 7 + out = build_skill_md(**cfg) assert ' version: "7"' in out - cfg.version = "2.0.1" - out2 = build_skill_md(cfg) + 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) + 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) + 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) + 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) + 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) + 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) + out = build_skill_md(**_base()) + assert not re.search("^license:", out, re.MULTILINE) + assert not re.search("^compatibility:", out, re.MULTILINE) + assert not re.search("^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) + 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 @@ -103,125 +102,124 @@ def test_metadata_extras_with_protected_keys(self) -> None: 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) + cfg["name"] = "" + with pytest.raises(ValueError, match="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) + cfg["name"] = "a" * 65 + with pytest.raises(ValueError, match="1-64"): + build_skill_md(**cfg) def test_rejects_uppercase_name(self) -> None: cfg = _base() - cfg.name = "Example-Merchant" - with pytest.raises(ValueError, match=r"lowercase"): - build_skill_md(cfg) + cfg["name"] = "Example-Merchant" + with pytest.raises(ValueError, match="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) + cfg["name"] = "-foo" + with pytest.raises(ValueError, match="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) + cfg["name"] = "foo-" + with pytest.raises(ValueError, match="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) + cfg["name"] = "foo--bar" + with pytest.raises(ValueError, match="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) + cfg["description"] = "" + with pytest.raises(ValueError, match="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) + cfg["description"] = "a" * 1025 + with pytest.raises(ValueError, match="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) + cfg["compatibility"] = "a" * 501 + with pytest.raises(ValueError, match="500"): + build_skill_md(**cfg) class TestTitleBlock: def test_renders_merchant_name_as_h1(self) -> None: - out = build_skill_md(_base()) + out = build_skill_md(**_base()) assert "\n# Example Merchant\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) + cfg["tagline"] = "A classic is forever" + cfg["intro"] = "Napa Valley winery, family-run." + out = build_skill_md(**cfg) assert "# Example Merchant\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) + cfg["tagline"] = "A classic is forever" + out = build_skill_md(**cfg) assert "# Example Merchant\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) + cfg["intro"] = "Napa Valley winery." + out = build_skill_md(**cfg) assert "# Example Merchant\n\nNapa Valley winery." in out class TestImportantFiles: def test_emits_self_reference(self) -> None: - out = build_skill_md(_base()) + out = build_skill_md(**_base()) assert "## Important Files" in out assert "| **SKILL.md** (this file) | `https://example.com/skill.md` |" in out def test_appends_caller_files(self) -> None: cfg = _base() - cfg.files = [ + cfg["files"] = [ SkillMdLink(label="llms.txt", url="https://example.com/llms.txt"), SkillMdLink(label="OpenAPI", url="https://example.com/openapi.json"), ] - out = build_skill_md(cfg) + out = build_skill_md(**cfg) assert "| llms.txt | `https://example.com/llms.txt` |" in out assert "| OpenAPI | `https://example.com/openapi.json` |" in out def test_strips_trailing_slash_from_homepage(self) -> None: cfg = _base() - cfg.homepage = "https://example.com/" - out = build_skill_md(cfg) + cfg["homepage"] = "https://example.com/" + out = build_skill_md(**cfg) assert "`https://example.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) + 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`. + cfg["files"] = [SkillMdLink(label="a\\|b", url="https://x.example/c\\d")] + out = build_skill_md(**cfg) 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()) + out = build_skill_md(**_base()) assert "## Payment" in out assert "**MPP on Tempo**" in out assert "agentscore-pay, tempo request, x402-proxy" in out @@ -233,47 +231,47 @@ def test_renders_one_row_per_rail(self) -> None: def test_omits_unaccepted_rails(self) -> None: cfg = _base() - cfg.accepted_rails = ["tempo_mpp", "x402_base", "solana_mpp"] - out = build_skill_md(cfg) + cfg["accepted_rails"] = ["tempo_mpp", "x402_base", "solana_mpp"] + 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) + 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) + 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) + cfg["accepted_rails"] = ["x402_base"] + cfg["compatible_clients"] = {"x402_base": []} + out = build_skill_md(**cfg) + assert re.search("x402 on Base.+\\| —", out) class TestIdentitySection: def test_omits_when_not_declared(self) -> None: - out = build_skill_md(_base()) + 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( + cfg["identity"] = SkillMdIdentityRequirements( kyc_required=True, min_age=21, allowed_jurisdictions=["US"], sanctions_clear=True ) - out = build_skill_md(cfg) + out = build_skill_md(**cfg) assert "## Identity Prerequisite" in out assert "KYC verified Passport" in out assert "age 21+" in out @@ -282,24 +280,24 @@ 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://identity.example.com/skill.md" - out = build_skill_md(cfg) + 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) + 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( + cfg["identity"] = SkillMdIdentityRequirements( kyc_required=True, min_age=21, allowed_jurisdictions=["US"], sanctions_clear=True ) - out = build_skill_md(cfg) + out = build_skill_md(**cfg) for forbidden in [ "fail_open", "fail-open", @@ -314,28 +312,28 @@ def test_no_internal_posture_leak(self) -> None: class TestShippingSection: def test_omits_when_no_shipping(self) -> None: - out = build_skill_md(_base()) + 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) + 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) + 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) + 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 @@ -343,45 +341,45 @@ def test_renders_only_blocked(self) -> None: class TestEndpointsSection: def test_emits_one_row_per_endpoint(self) -> None: - out = build_skill_md(_base()) + 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) + 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) + 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 from Example Merchant", "Check order status"] - out = build_skill_md(cfg) + cfg["triggers"] = ["Buy from Example Merchant", "Check order status"] + out = build_skill_md(**cfg) assert "## Triggers" in out assert "- Buy from Example Merchant" in out assert "- Check order status" in out def test_omits_when_empty(self) -> None: cfg = _base() - cfg.triggers = [] - out = build_skill_md(cfg) + 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) + 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 @@ -389,11 +387,11 @@ def test_emits_numbered_onboarding(self) -> None: def test_emits_support_links(self) -> None: cfg = _base() - cfg.support_links = [ + cfg["support_links"] = [ SkillMdLink(label="Homepage", url="https://example.com"), SkillMdLink(label="Pay CLI", url="https://github.com/agentscore/pay"), ] - out = build_skill_md(cfg) + out = build_skill_md(**cfg) assert "## Support" in out assert "- **Homepage**: https://example.com" in out assert "- **Pay CLI**: https://github.com/agentscore/pay" in out @@ -401,24 +399,24 @@ def test_emits_support_links(self) -> None: class TestRefreshFooter: def test_appends_by_default(self) -> None: - out = build_skill_md(_base()) + 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) + 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()) + 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()) + out = build_skill_md(**_base()) assert "\n\n\n" not in out @@ -433,6 +431,6 @@ def test_no_triple_newline_runs(self) -> None: ) def test_each_rail_label(rail: str, expected_label: str) -> None: cfg = _base() - cfg.accepted_rails = [rail] - out = build_skill_md(cfg) + cfg["accepted_rails"] = [rail] + out = build_skill_md(**cfg) assert f"**{expected_label}**" in out