diff --git a/CLAUDE.md b/CLAUDE.md index dc879b6..008bb6f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ Every helper is extracted from a real consumer, not speculated. | `@agent-score/commerce/identity/{hono,express,fastify,nextjs,web}` | Trust gate middleware (KYC, age, sanctions, jurisdiction) | | `@agent-score/commerce/identity/policy` | Framework-agnostic per-product / per-tier compliance policy helpers — `PolicyBlock`, `policyToGateOptions`, `runGateWithEnforcement`, `shippingCountryAllowed`, `shippingStateAllowed` | | `@agent-score/commerce/payment` | Networks/USDC/rails registries, paymentauth.org directive builders, x402 server factory + scheme dual-register, MPP server factory, dispatch-by-network, signer extraction, WWW-Authenticate header, Settlement-Overrides header | -| `@agent-score/commerce/discovery` | Discovery probe middleware, Bazaar wrapper, `/.well-known/mpp.json` builder, `llms.txt` builder, OpenAPI snippets, `noindexNonDiscoveryPaths` Hono middleware | +| `@agent-score/commerce/discovery` | Discovery probe middleware, Bazaar wrapper, `/.well-known/mpp.json` builder, `llms.txt` builder, `skill.md` builder (Claude-Skill-compatible agent-discovery manifest), OpenAPI snippets, `noindexNonDiscoveryPaths` Hono middleware | | `@agent-score/commerce/challenge` | 402-body builders: accepted_methods, identity metadata, how_to_pay, agent_instructions, build402Body, `buildValidationError` (4xx body builder) | | `@agent-score/commerce/stripe-multichain` | Multichain PaymentIntent helper, deposit-address lookup, testnet simulator, mppx Stripe wrapper | | `@agent-score/commerce/api` | Re-exports `AgentScore` + `AgentScoreError` from `@agent-score/sdk` | @@ -25,7 +25,7 @@ Single TypeScript package, tsup-built CJS + ESM with subpath exports. Per-framew | `src/identity/` | Per-framework gate adapters (hono, express, fastify, nextjs, web) | | `src/core.ts` | Shared assess/session/cache/captureWallet (framework-agnostic) | | `src/payment/` | Payment-protocol helpers (`networks.ts`, `usdc.ts`, `rails.ts`, `directive.ts`, `dispatch.ts`, `signer.ts`, `wwwauthenticate.ts`, `settlement_override.ts`, `x402.ts`, `x402_server.ts`, `mppx_server.ts`) | -| `src/discovery/` | Probe + Bazaar + `/.well-known/mpp.json` + `llms.txt` + OpenAPI | +| `src/discovery/` | Probe + Bazaar + `/.well-known/mpp.json` + `llms.txt` + `skill.md` + OpenAPI | | `src/challenge/` | 402-body builders | | `src/stripe-multichain/` | Stripe multichain PaymentIntent helpers | | `src/api/` | `AgentScore` re-export from sdk | diff --git a/README.md b/README.md index 9f33867..1c7386a 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ npm install hono mppx @x402/core @x402/evm @x402/svm stripe # whatever your st |---|---| | `/identity/{hono,express,fastify,nextjs,web}` | Trust gate middleware: KYC, sanctions, age, jurisdiction. `agentscoreGate(...)`, `getAgentScoreData(c)`, `captureWallet(...)`, `verifyWalletSignerMatch(...)`. Plus shared denial helpers: `denialReasonStatus`, `denialReasonToBody`, `buildSignerMismatchBody`, `buildContactSupportNextSteps`, `verificationAgentInstructions`, `isFixableDenial`, `FIXABLE_DENIAL_REASONS`. | | `/payment` | `networks`, `USDC`, `rails` registries; `paymentDirective`, `buildPaymentDirective`, `wwwAuthenticateHeader`, `paymentRequiredHeader`, `aliasAmountFields` (v1↔v2 amount field shim — emits both `amount` and `maxAmountRequired` so v1-only x402 parsers like Coinbase awal can read v2 bodies), `settlementOverrideHeader`, `dispatchSettlementByNetwork`, `extractPaymentSigner` (returns `{address, network}`); `createX402Server`, `createMppxServer`; drop-in x402 helpers: `validateX402NetworkConfig` (boot-time guard), `verifyX402Request` (parse + validate inbound X-Payment), `processX402Settle` (verify-then-settle with one call). | -| `/discovery` | `isDiscoveryProbeRequest`, `buildDiscoveryProbeResponse` (with optional `x402Sample` for x402-aware crawlers — `awal x402 details` etc.), `sampleX402AcceptForNetwork` (USDC sample-accept builder for known CAIP-2 networks), `buildWellKnownMpp`, `buildLlmsTxt` + `llmsTxtIdentitySection` + `llmsTxtPaymentSection` (compact + verbose modes), `agentscoreOpenApiSnippets`, `createBazaarDiscovery`, `noindexNonDiscoveryPaths` (Hono 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 `isDiscoveryPath` + `defaultDiscoveryPaths` for non-Hono frameworks). | +| `/discovery` | `isDiscoveryProbeRequest`, `buildDiscoveryProbeResponse` (with optional `x402Sample` for x402-aware crawlers — `awal x402 details` etc.), `sampleX402AcceptForNetwork` (USDC sample-accept builder for known CAIP-2 networks), `buildWellKnownMpp`, `buildLlmsTxt` + `llmsTxtIdentitySection` + `llmsTxtPaymentSection` (compact + verbose modes), `buildSkillMd` (Claude-Skill-compatible `/skill.md` agent-discovery manifest — strictly agent-facing data only, no internal posture), `agentscoreOpenApiSnippets`, `createBazaarDiscovery`, `noindexNonDiscoveryPaths` (Hono 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 `isDiscoveryPath` + `defaultDiscoveryPaths` for non-Hono frameworks). | | `/challenge` | `build402Body`, `buildAcceptedMethods`, `buildIdentityMetadata`, `buildHowToPay`, `buildAgentInstructions` (auto-emits per-rail `compatible_clients` — smoke-verified CLIs the agent should use; vendor override supported), `buildPricingBlock`, `firstEncounterAgentMemory`, `OrderReceipt`; `respond402` — drop-in 402 emit that preserves mppx's `WWW-Authenticate` and layers x402's `PAYMENT-REQUIRED`. `buildValidationError` — 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. | | `/stripe-multichain` | `createMultichainPaymentIntent`, `getDepositAddress`, `simulateCryptoDeposit`, `createMppxStripe`; `createPiCache` (TTL'd PI / deposit-address cache, Redis-backed when `redisUrl` set, in-memory otherwise), `simulateDepositIfTestMode` (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`. | | `/api` | Everything from `@agent-score/sdk` re-exported in one place: `AgentScore` + `AgentScoreError`, `AGENTSCORE_TEST_ADDRESSES` + `isAgentScoreTestAddress`. **Don't add `@agent-score/sdk` as a separate dep** — the two can drift versions and cause subtle type mismatches. | diff --git a/package.json b/package.json index a1c480f..dd6a89a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agent-score/commerce", - "version": "1.1.0", + "version": "1.2.0", "description": "Agent commerce SDK — identity middleware (Hono, Express, Fastify, Next.js, Web Fetch) + payment helpers + 402 builders + discovery + Stripe multichain. The full merchant-side toolkit for AgentScore-powered agent commerce.", "main": "./dist/index.js", "module": "./dist/index.mjs", diff --git a/src/challenge/agent_instructions.ts b/src/challenge/agent_instructions.ts index 53f9e57..0c5a5bf 100644 --- a/src/challenge/agent_instructions.ts +++ b/src/challenge/agent_instructions.ts @@ -76,15 +76,36 @@ function defaultWarnings(howToPay: HowToPayBlock): string[] { * 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. */ -function defaultCompatibleClients(howToPay: HowToPayBlock): CompatibleClients | undefined { +/** Symbolic rail keys agent-facing surfaces use to talk about a rail without spelling out + * network/scheme details. Same keys as `CompatibleClients` map keys. */ +export type RailKey = 'tempo_mpp' | 'x402_base' | 'x402_solana' | 'stripe'; + +const RAIL_CLIENTS: Record = { + 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'], +}; + +/** Returns the 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 (`defaultCompatibleClients`) and by discovery surfaces (skill.md, + * llms.txt, etc.). Update here, every surface inherits. */ +export function compatibleClientsByRails(rails: readonly RailKey[]): CompatibleClients | undefined { const out: CompatibleClients = {}; - if (howToPay.tempo) out.tempo_mpp = ['agentscore-pay', 'tempo request', 'x402-proxy']; - if (howToPay.x402_base) out.x402_base = ['agentscore-pay', 'x402-proxy', 'purl (omit --network flag)']; - if (howToPay.x402_solana) out.x402_solana = ['agentscore-pay']; - if (howToPay.stripe) out.stripe = ['link-cli']; + for (const r of rails) out[r] = [...RAIL_CLIENTS[r]]; return Object.keys(out).length === 0 ? undefined : out; } +function defaultCompatibleClients(howToPay: HowToPayBlock): CompatibleClients | undefined { + const rails: RailKey[] = []; + if (howToPay.tempo) rails.push('tempo_mpp'); + if (howToPay.x402_base) rails.push('x402_base'); + if (howToPay.x402_solana) rails.push('x402_solana'); + if (howToPay.stripe) rails.push('stripe'); + return compatibleClientsByRails(rails); +} + /** * Build the agent_instructions object for the 402 body. Combines how_to_pay with * recommended tools, warnings, wallet-compatibility note, and timeout. diff --git a/src/discovery/index.ts b/src/discovery/index.ts index 301a7c5..050b3c6 100644 --- a/src/discovery/index.ts +++ b/src/discovery/index.ts @@ -4,3 +4,4 @@ export * from './well_known_mpp'; export * from './llms_txt'; export * from './openapi'; export * from './robots_tag'; +export * from './skill_md'; diff --git a/src/discovery/robots_tag.ts b/src/discovery/robots_tag.ts index b04e48a..82c5344 100644 --- a/src/discovery/robots_tag.ts +++ b/src/discovery/robots_tag.ts @@ -12,6 +12,8 @@ export const defaultDiscoveryPaths: ReadonlySet = new Set([ '/openapi.json', '/llms.txt', + '/skill.md', + '/SKILL.md', '/.well-known/mpp.json', '/.well-known/agent-card.json', '/.well-known/ucp', diff --git a/src/discovery/skill_md.ts b/src/discovery/skill_md.ts new file mode 100644 index 0000000..30228ea --- /dev/null +++ b/src/discovery/skill_md.ts @@ -0,0 +1,344 @@ +import { compatibleClientsByRails } from '../challenge/agent_instructions'; +import type { CompatibleClients, RailKey } from '../challenge/agent_instructions'; + +export type { CompatibleClients, RailKey } from '../challenge/agent_instructions'; +export { compatibleClientsByRails } from '../challenge/agent_instructions'; + +export interface SkillMdEndpoint { + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + path: string; + authRequired: boolean; + description: string; +} + +export interface SkillMdIdentityRequirements { + /** Whether KYC is required for gated routes. */ + kycRequired?: boolean; + /** Minimum age (e.g. 21 for alcohol). */ + minAge?: number; + /** Allowed-jurisdictions list (ISO 3166-1 alpha-2 country codes). */ + allowedJurisdictions?: string[]; + /** Whether sanctions screening is enforced. */ + sanctionsClear?: boolean; +} + +export interface SkillMdShippingPolicy { + /** Allowed shipping countries (ISO 3166-1 alpha-2). */ + allowedCountries?: string[]; + /** Blocked US states (2-letter codes). */ + blockedStates?: string[]; +} + +export interface SkillMdLink { + label: string; + url: string; +} + +export interface BuildSkillMdInput { + /** 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 throw. e.g. 'martin-estate-wine-commerce'. */ + name: string; + /** 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 throws. */ + description: string; + /** Merchant homepage (or domain root). Emitted as `metadata.homepage` per spec + * (top-level non-spec fields go under metadata). */ + homepage: string; + /** Skill schema version — increment when the skill body materially changes. Emitted as + * a quoted string under `metadata.version` per agentskills.io spec (metadata values + * must be strings). Accepts string or number; numbers are converted. Default "1". */ + version?: string | number; + + /** Optional license name or path to a bundled license file. Emitted as top-level + * frontmatter `license:` per spec. */ + license?: string; + /** Optional environment-requirements note (max 500 chars). e.g. "Requires Node 20+". + * Emitted as top-level frontmatter `compatibility:` per spec. */ + compatibility?: string; + /** Optional space-separated string of pre-approved tools (experimental per spec). */ + allowedTools?: string; + /** Additional caller-defined metadata entries — flat key/value strings nested under + * `metadata:`. Spec requires string values. */ + metadata?: Record; + + /** Human display name (e.g. "Martin Estate Winery"). */ + merchantName: string; + /** Optional one-line tagline appearing under the title. */ + tagline?: string; + /** Optional short prose intro describing what the merchant offers. Renders below the title. */ + intro?: string; + + /** 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). */ + files?: SkillMdLink[]; + + /** Rails the merchant accepts. Drives the Payment + Compatible Clients sections. Order + * is preserved in render. Default to the rails actually declared on the merchant's + * `respond402` config — keep these in sync. */ + acceptedRails: RailKey[]; + /** Override the per-rail compatible-clients matrix. When omitted, derives from + * `acceptedRails` via the SDK's smoke-verified default. Override keys not in + * `acceptedRails` are dropped (the rail isn't accepted, so the row isn't rendered). */ + compatibleClients?: CompatibleClients; + + /** Identity requirements as agent-observable outcomes (kyc / age / jurisdiction / + * sanctions). Internal posture (`failOpen`, mount strategy, KYC vendor) is intentionally + * not part of this shape — agents act on outcomes, not implementation. */ + identity?: SkillMdIdentityRequirements; + /** 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. */ + identityBootstrapUrl?: string; + + /** Shipping policy, for physical-goods merchants. Omit for digital merchants. */ + shipping?: SkillMdShippingPolicy; + + /** Agent-facing endpoints — path, method, whether auth is required, brief purpose. */ + endpoints: SkillMdEndpoint[]; + + /** When this skill should fire (skill loader uses for trigger matching). */ + triggers: string[]; + + /** Optional numbered onboarding steps. Each entry renders as a numbered list item; + * may include shell snippets in markdown code fences. */ + onboardingSteps?: string[]; + + /** Support / homepage / docs links rendered in the "Support" section. */ + supportLinks?: SkillMdLink[]; + + /** When true (default), append a footer noting clients can refresh skill.md to pick + * up new endpoints. Set to false to suppress. */ + refreshFooter?: boolean; +} + +const RAIL_LABELS: Record = { + tempo_mpp: 'MPP on Tempo', + x402_base: 'x402 on Base', + x402_solana: 'x402 on Solana', + stripe: 'Stripe Shared Payment Token', +}; + +const RAIL_NOTES: Record = { + 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.', +}; + +const NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/; +const NAME_MAX = 64; +const DESCRIPTION_MAX = 1024; +const COMPATIBILITY_MAX = 500; + +function validateInput(input: BuildSkillMdInput): void { + if (!input.name || input.name.length === 0 || input.name.length > NAME_MAX) { + throw new Error(`buildSkillMd: name must be 1-${NAME_MAX} characters (got ${input.name?.length ?? 0})`); + } + if (!NAME_RE.test(input.name)) { + throw new Error( + `buildSkillMd: name "${input.name}" is invalid — must be lowercase alphanumeric and hyphens, no leading/trailing/consecutive hyphens (agentskills.io spec)`, + ); + } + if (!input.description || input.description.length === 0) { + throw new Error('buildSkillMd: description is required and must be non-empty (agentskills.io spec)'); + } + if (input.description.length > DESCRIPTION_MAX) { + throw new Error( + `buildSkillMd: description must be ≤${DESCRIPTION_MAX} characters (got ${input.description.length})`, + ); + } + if (input.compatibility && input.compatibility.length > COMPATIBILITY_MAX) { + throw new Error( + `buildSkillMd: compatibility must be ≤${COMPATIBILITY_MAX} characters (got ${input.compatibility.length})`, + ); + } +} + +/** Quote a value as a YAML double-quoted scalar — escape `\\`, `"`, and newlines. 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. */ +function quoteYaml(value: string): string { + return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`; +} + +/** Sanitize a string for inclusion in a markdown table cell — escape backslashes first + * (so existing `\` aren't treated as escapes), then escape pipes (which would otherwise + * terminate the cell). */ +function tableCell(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/\|/g, '\\|'); +} + +function frontmatter(input: BuildSkillMdInput): string { + const lines: string[] = ['---']; + lines.push(`name: ${input.name}`); + lines.push(`description: ${quoteYaml(input.description)}`); + if (input.license) lines.push(`license: ${quoteYaml(input.license)}`); + if (input.compatibility) lines.push(`compatibility: ${quoteYaml(input.compatibility)}`); + if (input.allowedTools) lines.push(`allowed-tools: ${quoteYaml(input.allowedTools)}`); + + const meta: Array<[string, string]> = []; + meta.push(['version', String(input.version ?? '1')]); + meta.push(['homepage', input.homepage]); + for (const [k, v] of Object.entries(input.metadata ?? {})) { + if (k === 'version' || k === 'homepage') continue; + meta.push([k, String(v)]); + } + lines.push('metadata:'); + for (const [k, v] of meta) { + lines.push(` ${k}: ${quoteYaml(v)}`); + } + lines.push('---'); + return lines.join('\n'); +} + +function importantFiles(input: BuildSkillMdInput): string { + const skillUrl = `${input.homepage.replace(/\/$/, '')}/skill.md`; + const rows: string[] = [ + '| File | URL |', + '|------|-----|', + `| **SKILL.md** (this file) | \`${skillUrl}\` |`, + ]; + for (const f of input.files ?? []) { + rows.push(`| ${tableCell(f.label)} | \`${tableCell(f.url)}\` |`); + } + return ['## Important Files', '', ...rows].join('\n'); +} + +function paymentSection(input: BuildSkillMdInput): string { + const override = input.compatibleClients; + const defaults = compatibleClientsByRails(input.acceptedRails) ?? {}; + // Override entries only apply to rails actually accepted; ignore stragglers. + const clients: CompatibleClients = {}; + for (const r of input.acceptedRails) { + clients[r] = override?.[r] ?? defaults[r] ?? []; + } + const rows: string[] = ['| Rail | Notes | Compatible clients |', '|---|---|---|']; + for (const r of input.acceptedRails) { + const list = (clients[r] ?? []).join(', ') || '—'; + rows.push(`| **${RAIL_LABELS[r]}** | ${RAIL_NOTES[r]} | ${list} |`); + } + return [ + '## Payment', + '', + '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.', + '', + ...rows, + ].join('\n'); +} + +function identitySection(input: BuildSkillMdInput): string { + const id = input.identity; + if (!id) return ''; + const reqs: string[] = []; + if (id.kycRequired) reqs.push('KYC verified Passport'); + if (id.minAge) reqs.push(`age ${id.minAge}+`); + if (id.allowedJurisdictions?.length) reqs.push(`${id.allowedJurisdictions.join('/')} only`); + if (id.sanctionsClear) reqs.push('sanctions clear'); + if (reqs.length === 0) return ''; + const bootstrap = input.identityBootstrapUrl + ? `\n\nIf you don't have a Passport, fetch \`${input.identityBootstrapUrl}\` and follow the onboarding there first. Bring back the \`opc_...\` operator token in \`X-Operator-Token\` on every gated request.` + : ''; + return [ + '## Identity Prerequisite', + '', + `This merchant uses AgentScore identity. Required: ${reqs.join(', ')}.${bootstrap}`, + '', + '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.', + ].join('\n'); +} + +function shippingSection(input: BuildSkillMdInput): string { + const s = input.shipping; + if (!s || (!s.allowedCountries?.length && !s.blockedStates?.length)) return ''; + const lines: string[] = ['## Shipping', '']; + if (s.allowedCountries?.length) { + lines.push(`Ships to: ${s.allowedCountries.join(', ')}.`); + } + if (s.blockedStates?.length) { + if (lines.length > 2) lines.push(''); + lines.push(`Blocked US states: ${s.blockedStates.join(', ')}.`); + } + return lines.join('\n'); +} + +function endpointsSection(input: BuildSkillMdInput): string { + if (input.endpoints.length === 0) return ''; + const rows = ['| Method | Path | Auth | Purpose |', '|---|---|---|---|']; + for (const e of input.endpoints) { + rows.push( + `| ${e.method} | \`${tableCell(e.path)}\` | ${e.authRequired ? 'identity required' : 'anonymous'} | ${tableCell(e.description)} |`, + ); + } + return ['## Endpoints', '', ...rows].join('\n'); +} + +function onboardingSection(input: BuildSkillMdInput): string { + if (!input.onboardingSteps?.length) return ''; + const rows = input.onboardingSteps.map((step, i) => `${i + 1}. ${step}`); + return ['## Onboarding Flow', '', ...rows].join('\n'); +} + +function triggersSection(input: BuildSkillMdInput): string { + if (input.triggers.length === 0) return ''; + const rows = input.triggers.map((t) => `- ${t}`); + return ['## Triggers', '', 'Use this skill when the user wants to:', '', ...rows].join('\n'); +} + +function supportSection(input: BuildSkillMdInput): string { + if (!input.supportLinks?.length) return ''; + const rows = input.supportLinks.map((l) => `- **${l.label}**: ${l.url}`); + return ['## Support', '', ...rows].join('\n'); +} + +function refreshFooter(input: BuildSkillMdInput): string { + if (input.refreshFooter === false) return ''; + return '_Re-fetch this file periodically to pick up new endpoints, rails, or policies._'; +} + +function titleBlock(input: BuildSkillMdInput): string { + const parts: string[] = [`# ${input.merchantName}`]; + if (input.tagline) parts.push(`_${input.tagline}_`); + if (input.intro) parts.push(input.intro); + return parts.join('\n\n'); +} + +/** + * Render an agentskills.io-compatible `skill.md` for an agent-commerce merchant. + * + * Output is YAML frontmatter (`name` / `description` / optional `license` / + * `compatibility` / `allowed-tools` / `metadata`) followed by markdown sections + * describing payment rails, identity requirements, endpoints, triggers, and support + * links — strictly the agent-facing contract, with no internal posture (no `failOpen`, + * no mount-strategy names, no KYC vendor, no defense parameters). + * + * Spec compliance: + * - `name` validated against the agentskills.io 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 + * (`compatibleClientsByRails`) that drives the live 402 body's `compatible_clients` + * field, so updating a smoke-verified client in one place propagates to every surface. + */ +export function buildSkillMd(input: BuildSkillMdInput): string { + validateInput(input); + const sections = [ + frontmatter(input), + titleBlock(input), + importantFiles(input), + identitySection(input), + paymentSection(input), + shippingSection(input), + onboardingSection(input), + endpointsSection(input), + triggersSection(input), + supportSection(input), + refreshFooter(input), + ].filter((s) => s !== ''); + return sections.join('\n\n').replace(/\n{3,}/g, '\n\n').trim() + '\n'; +} diff --git a/tests/discovery/robots_tag.test.ts b/tests/discovery/robots_tag.test.ts index 260b029..3bc3557 100644 --- a/tests/discovery/robots_tag.test.ts +++ b/tests/discovery/robots_tag.test.ts @@ -14,6 +14,8 @@ describe('defaultDiscoveryPaths', () => { for (const p of [ '/openapi.json', '/llms.txt', + '/skill.md', + '/SKILL.md', '/.well-known/mpp.json', '/.well-known/agent-card.json', '/.well-known/ucp', @@ -29,6 +31,8 @@ describe('isDiscoveryPath', () => { it('matches paths in the default set', () => { expect(isDiscoveryPath('/openapi.json')).toBe(true); expect(isDiscoveryPath('/llms.txt')).toBe(true); + expect(isDiscoveryPath('/skill.md')).toBe(true); + expect(isDiscoveryPath('/SKILL.md')).toBe(true); expect(isDiscoveryPath('/.well-known/mpp.json')).toBe(true); }); diff --git a/tests/discovery/skill_md.test.ts b/tests/discovery/skill_md.test.ts new file mode 100644 index 0000000..fbb474b --- /dev/null +++ b/tests/discovery/skill_md.test.ts @@ -0,0 +1,428 @@ +import { describe, expect, it } from 'vitest'; +import { buildSkillMd } from '../../src/discovery/skill_md'; + +describe('buildSkillMd', () => { + const baseInput = { + name: 'martin-estate-wine-commerce', + description: 'Buy wine from Martin Estate via an AI agent', + homepage: 'https://martin-estate.com', + merchantName: 'Martin Estate', + acceptedRails: ['tempo_mpp', 'x402_base', 'x402_solana', 'stripe'] as const, + endpoints: [ + { method: 'GET' as const, path: '/api/v1/wines', authRequired: false, description: 'Wine catalog' }, + { method: 'POST' as const, path: '/api/v1/orders', authRequired: true, description: 'Place order' }, + ], + triggers: ['User wants to buy wine from Martin Estate'], + }; + + describe('frontmatter (agentskills.io spec)', () => { + it('emits valid YAML frontmatter with name + quoted description + metadata', () => { + const out = buildSkillMd(baseInput); + expect(out.startsWith('---\n')).toBe(true); + expect(out).toContain('name: martin-estate-wine-commerce'); + expect(out).toContain('description: "Buy wine from Martin Estate via an AI agent"'); + expect(out).toContain('metadata:'); + expect(out).toContain(' version: "1"'); + expect(out).toContain(' homepage: "https://martin-estate.com"'); + }); + + it('emits version as a quoted string per spec (string keys to string values)', () => { + const out = buildSkillMd({ ...baseInput, version: 7 }); + expect(out).toContain(' version: "7"'); + const out2 = buildSkillMd({ ...baseInput, version: '2.0.1' }); + expect(out2).toContain(' version: "2.0.1"'); + }); + + it('passes version: 0 through unchanged (nullish-coalescing default, not falsy)', () => { + const out = buildSkillMd({ ...baseInput, version: 0 }); + expect(out).toContain(' version: "0"'); + }); + + it("quotes description containing colons (the spec's primary YAML pitfall)", () => { + const out = buildSkillMd({ ...baseInput, description: 'Use when: buying premium wine' }); + expect(out).toContain('description: "Use when: buying premium wine"'); + }); + + it('escapes embedded double-quotes in description', () => { + const out = buildSkillMd({ ...baseInput, description: 'Buy "Estate" wine' }); + expect(out).toContain('description: "Buy \\"Estate\\" wine"'); + }); + + it('escapes embedded newlines in description', () => { + const out = buildSkillMd({ ...baseInput, description: 'line one\nline two' }); + expect(out).toContain('description: "line one\\nline two"'); + }); + + it('emits optional license / compatibility / allowed-tools when set', () => { + const out = buildSkillMd({ + ...baseInput, + license: 'Apache-2.0', + compatibility: 'Requires Node 20+', + allowedTools: 'Bash(curl:*)', + }); + expect(out).toContain('license: "Apache-2.0"'); + expect(out).toContain('compatibility: "Requires Node 20+"'); + expect(out).toContain('allowed-tools: "Bash(curl:*)"'); + }); + + it('omits license / compatibility / allowed-tools by default', () => { + const out = buildSkillMd(baseInput); + expect(out).not.toMatch(/^license:/m); + expect(out).not.toMatch(/^compatibility:/m); + expect(out).not.toMatch(/^allowed-tools:/m); + }); + + it('merges caller-supplied metadata entries (string values, version/homepage protected)', () => { + const out = buildSkillMd({ + ...baseInput, + metadata: { author: 'agentscore', vendor_id: 'me-001', version: 'IGNORED', homepage: 'IGNORED' }, + }); + expect(out).toContain(' author: "agentscore"'); + expect(out).toContain(' vendor_id: "me-001"'); + expect(out).toContain(' version: "1"'); + expect(out).toContain(' homepage: "https://martin-estate.com"'); + expect(out).not.toContain('IGNORED'); + }); + }); + + describe('name + description validation (spec)', () => { + it('rejects empty name', () => { + expect(() => buildSkillMd({ ...baseInput, name: '' })).toThrow(/1-64/); + }); + + it('rejects name exceeding 64 characters', () => { + expect(() => buildSkillMd({ ...baseInput, name: 'a'.repeat(65) })).toThrow(/1-64/); + }); + + it('rejects name with uppercase characters', () => { + expect(() => buildSkillMd({ ...baseInput, name: 'Martin-Estate' })).toThrow(/lowercase/); + }); + + it('rejects name with leading hyphen', () => { + expect(() => buildSkillMd({ ...baseInput, name: '-foo' })).toThrow(/hyphens/); + }); + + it('rejects name with trailing hyphen', () => { + expect(() => buildSkillMd({ ...baseInput, name: 'foo-' })).toThrow(/hyphens/); + }); + + it('rejects name with consecutive hyphens', () => { + expect(() => buildSkillMd({ ...baseInput, name: 'foo--bar' })).toThrow(/hyphens/); + }); + + it('rejects empty description', () => { + expect(() => buildSkillMd({ ...baseInput, description: '' })).toThrow(/non-empty/); + }); + + it('rejects description exceeding 1024 characters', () => { + expect(() => buildSkillMd({ ...baseInput, description: 'a'.repeat(1025) })).toThrow(/1024/); + }); + + it('rejects compatibility exceeding 500 characters', () => { + expect(() => buildSkillMd({ ...baseInput, compatibility: 'a'.repeat(501) })).toThrow(/500/); + }); + }); + + describe('title block', () => { + it('renders merchant name as h1', () => { + const out = buildSkillMd(baseInput); + expect(out).toContain('\n# Martin Estate\n'); + }); + + it('renders title + tagline + intro with single blank line between each', () => { + const out = buildSkillMd({ + ...baseInput, + tagline: 'A classic is forever', + intro: 'Napa Valley winery, family-run.', + }); + expect(out).toContain('# Martin Estate\n\n_A classic is forever_\n\nNapa Valley winery, family-run.'); + }); + + it('renders tagline only when provided', () => { + const out = buildSkillMd({ ...baseInput, tagline: 'A classic is forever' }); + expect(out).toContain('# Martin Estate\n\n_A classic is forever_'); + }); + + it('renders intro only when provided', () => { + const out = buildSkillMd({ ...baseInput, intro: 'Napa Valley winery.' }); + expect(out).toContain('# Martin Estate\n\nNapa Valley winery.'); + }); + }); + + describe('Important Files section', () => { + it('emits the SKILL.md self-reference', () => { + const out = buildSkillMd(baseInput); + expect(out).toContain('## Important Files'); + expect(out).toContain('| **SKILL.md** (this file) | `https://martin-estate.com/skill.md` |'); + }); + + it('appends caller-supplied files', () => { + const out = buildSkillMd({ + ...baseInput, + files: [ + { label: 'llms.txt', url: 'https://martin-estate.com/llms.txt' }, + { label: 'OpenAPI', url: 'https://martin-estate.com/openapi.json' }, + ], + }); + expect(out).toContain('| llms.txt | `https://martin-estate.com/llms.txt` |'); + expect(out).toContain('| OpenAPI | `https://martin-estate.com/openapi.json` |'); + }); + + it('strips trailing slash from homepage when computing skill.md URL', () => { + const out = buildSkillMd({ ...baseInput, homepage: 'https://martin-estate.com/' }); + expect(out).toContain('`https://martin-estate.com/skill.md`'); + expect(out).not.toContain('//skill.md'); + }); + + it('escapes pipe characters in file labels and URLs to keep tables intact', () => { + const out = buildSkillMd({ + ...baseInput, + files: [{ label: 'a|b', url: 'https://x.example/foo|bar' }], + }); + expect(out).toContain('| a\\|b | `https://x.example/foo\\|bar` |'); + }); + + it('escapes backslashes before pipes (so existing `\\` are not consumed as escapes)', () => { + const out = buildSkillMd({ + ...baseInput, + files: [{ label: 'a\\|b', url: 'https://x.example/c\\d' }], + }); + // Backslash escaped first → `\\`, then pipe → `\|`. Combined: `a\\\|b`. + expect(out).toContain('| a\\\\\\|b | `https://x.example/c\\\\d` |'); + }); + }); + + describe('payment section', () => { + it('renders one row per accepted rail with default smoke-verified clients', () => { + const out = buildSkillMd(baseInput); + expect(out).toContain('## Payment'); + expect(out).toContain('**MPP on Tempo**'); + expect(out).toContain('agentscore-pay, tempo request, x402-proxy'); + expect(out).toContain('**x402 on Base**'); + expect(out).toContain('agentscore-pay, x402-proxy, purl (omit --network flag)'); + expect(out).toContain('**x402 on Solana**'); + expect(out).toContain('**Stripe Shared Payment Token**'); + expect(out).toContain('link-cli'); + }); + + it('omits rails not declared in acceptedRails', () => { + const out = buildSkillMd({ + ...baseInput, + acceptedRails: ['tempo_mpp', 'x402_base', 'x402_solana'], + }); + expect(out).toContain('**MPP on Tempo**'); + expect(out).not.toContain('**Stripe Shared Payment Token**'); + expect(out).not.toContain('link-cli'); + }); + + it('honors compatibleClients override per rail', () => { + const out = buildSkillMd({ + ...baseInput, + acceptedRails: ['x402_base'], + compatibleClients: { x402_base: ['agentscore-pay', 'merchant-custom-cli'] }, + }); + expect(out).toContain('agentscore-pay, merchant-custom-cli'); + expect(out).not.toContain('purl'); + }); + + it('drops compatibleClients overrides for rails not in acceptedRails', () => { + const out = buildSkillMd({ + ...baseInput, + acceptedRails: ['x402_base'], + compatibleClients: { + x402_base: ['agentscore-pay'], + stripe: ['rogue-cli'], + }, + }); + expect(out).not.toContain('rogue-cli'); + expect(out).not.toContain('Stripe Shared Payment Token'); + }); + + it("renders '—' when a rail's compatible-clients list is explicitly empty", () => { + const out = buildSkillMd({ + ...baseInput, + acceptedRails: ['x402_base'], + compatibleClients: { x402_base: [] }, + }); + expect(out).toMatch(/x402 on Base.+\| —/); + }); + }); + + describe('identity section', () => { + it('omits the section when identity is not declared', () => { + const out = buildSkillMd(baseInput); + expect(out).not.toContain('## Identity Prerequisite'); + }); + + it('renders KYC + age + jurisdictions + sanctions when declared', () => { + const out = buildSkillMd({ + ...baseInput, + identity: { kycRequired: true, minAge: 21, allowedJurisdictions: ['US'], sanctionsClear: true }, + }); + expect(out).toContain('## Identity Prerequisite'); + expect(out).toContain('KYC verified Passport'); + expect(out).toContain('age 21+'); + expect(out).toContain('US only'); + expect(out).toContain('sanctions clear'); + }); + + it('renders bootstrap pointer when identityBootstrapUrl is set', () => { + const out = buildSkillMd({ + ...baseInput, + identity: { kycRequired: true }, + identityBootstrapUrl: 'https://identity.example.com/skill.md', + }); + expect(out).toContain('`https://identity.example.com/skill.md`'); + expect(out).toContain('X-Operator-Token'); + }); + + it('omits the section when every requirement flag is falsy', () => { + const out = buildSkillMd({ + ...baseInput, + identity: { kycRequired: false, sanctionsClear: false }, + }); + expect(out).not.toContain('## Identity Prerequisite'); + }); + + it('does not leak failOpen, mount-posture, or KYC vendor names', () => { + const out = buildSkillMd({ + ...baseInput, + identity: { kycRequired: true, minAge: 21, allowedJurisdictions: ['US'], sanctionsClear: true }, + }); + expect(out).not.toContain('failOpen'); + expect(out).not.toContain('fail-open'); + expect(out).not.toContain('gate-conditional'); + expect(out).not.toContain('gate-first'); + expect(out).not.toContain('Persona'); + expect(out).not.toContain('Stripe Identity'); + }); + }); + + describe('shipping section', () => { + it('omits the section for digital merchants (no shipping)', () => { + const out = buildSkillMd(baseInput); + expect(out).not.toContain('## Shipping'); + }); + + it('renders allowed countries and blocked states', () => { + const out = buildSkillMd({ + ...baseInput, + shipping: { allowedCountries: ['US'], blockedStates: ['AK', 'HI', 'MS'] }, + }); + expect(out).toContain('## Shipping'); + expect(out).toContain('Ships to: US.'); + expect(out).toContain('Blocked US states: AK, HI, MS.'); + }); + + it('renders only allowed countries when blocked is omitted', () => { + const out = buildSkillMd({ + ...baseInput, + shipping: { allowedCountries: ['US'] }, + }); + expect(out).toContain('Ships to: US.'); + expect(out).not.toContain('Blocked US states'); + }); + + it('renders only blocked states when allowed is omitted', () => { + const out = buildSkillMd({ + ...baseInput, + shipping: { blockedStates: ['UT', 'AK'] }, + }); + expect(out).toContain('## Shipping'); + expect(out).toContain('Blocked US states: UT, AK.'); + expect(out).not.toContain('Ships to:'); + }); + }); + + describe('endpoints section', () => { + it('emits a row per endpoint with auth label', () => { + const out = buildSkillMd(baseInput); + expect(out).toContain('## Endpoints'); + expect(out).toContain('| GET | `/api/v1/wines` | anonymous | Wine catalog |'); + expect(out).toContain('| POST | `/api/v1/orders` | identity required | Place order |'); + }); + + it('omits the endpoints section when the list is empty', () => { + const out = buildSkillMd({ ...baseInput, endpoints: [] }); + expect(out).not.toContain('## Endpoints'); + }); + + it('escapes pipes in endpoint paths and descriptions', () => { + const out = buildSkillMd({ + ...baseInput, + endpoints: [ + { method: 'GET', path: '/foo|bar', authRequired: false, description: 'a|b' }, + ], + }); + expect(out).toContain('| GET | `/foo\\|bar` | anonymous | a\\|b |'); + }); + }); + + describe('triggers section', () => { + it('emits each trigger as a bullet', () => { + const out = buildSkillMd({ + ...baseInput, + triggers: ['Buy wine from Martin Estate', 'Check order status'], + }); + expect(out).toContain('## Triggers'); + expect(out).toContain('- Buy wine from Martin Estate'); + expect(out).toContain('- Check order status'); + }); + + it('omits the triggers section when triggers is empty', () => { + const out = buildSkillMd({ ...baseInput, triggers: [] }); + expect(out).not.toContain('## Triggers'); + }); + }); + + describe('onboarding + support', () => { + it('emits numbered onboarding steps', () => { + const out = buildSkillMd({ + ...baseInput, + onboardingSteps: ['Install agentscore-pay', 'Get a Passport', 'Pay any 402'], + }); + expect(out).toContain('## Onboarding Flow'); + expect(out).toContain('1. Install agentscore-pay'); + expect(out).toContain('2. Get a Passport'); + expect(out).toContain('3. Pay any 402'); + }); + + it('emits support links as bullets', () => { + const out = buildSkillMd({ + ...baseInput, + supportLinks: [ + { label: 'Homepage', url: 'https://martin-estate.com' }, + { label: 'Pay CLI', url: 'https://github.com/agentscore/pay' }, + ], + }); + expect(out).toContain('## Support'); + expect(out).toContain('- **Homepage**: https://martin-estate.com'); + expect(out).toContain('- **Pay CLI**: https://github.com/agentscore/pay'); + }); + }); + + describe('refresh footer', () => { + it('appends the refresh footer by default', () => { + const out = buildSkillMd(baseInput); + expect(out).toContain('Re-fetch this file'); + }); + + it('suppresses the refresh footer when set to false', () => { + const out = buildSkillMd({ ...baseInput, refreshFooter: false }); + expect(out).not.toContain('Re-fetch this file'); + }); + }); + + describe('output hygiene', () => { + it('ends with a single trailing newline', () => { + const out = buildSkillMd(baseInput); + expect(out.endsWith('\n')).toBe(true); + expect(out.endsWith('\n\n')).toBe(false); + }); + + it('collapses runs of more than two consecutive newlines', () => { + const out = buildSkillMd(baseInput); + expect(out).not.toMatch(/\n{3,}/); + }); + }); +});