From c1a647904bf6380ae4674413dbd07d2e882b5788 Mon Sep 17 00:00:00 2001 From: Goon Date: Tue, 22 Sep 2026 07:24:40 +0200 Subject: [PATCH 01/19] feat(contracts): resolve catalog compatibility through one precedence rule Phase 0 stores declarations on an immutable release, but every catalog row today is a legacy listing with no release, no artifact and no digest. A read surface built only on release declarations would answer `unknown` for the entire catalog, so every compatibility filter and badge would be permanently inert. Adds the one rule that bridges the two storage tiers: a release declaration wins and only it can reach `verified`; otherwise the listing declaration is used, which can reach at most `declared` because a listing has no digest to bind evidence to; neither present yields `unknown`. A release declaration arriving without a release is ignored rather than trusted, so no surface can claim release-grade provenance it cannot prove. The engine still decides; this only chooses the inputs, and the Phase 2 removal is local to selectDeclaration. --- .../src/catalog-compatibility.test.ts | 172 ++++++++++++++++++ .../contracts/src/compatibility/catalog.ts | 128 +++++++++++++ packages/contracts/src/compatibility/index.ts | 1 + .../plan.md | 87 +++++++++ 4 files changed, 388 insertions(+) create mode 100644 packages/contracts/src/catalog-compatibility.test.ts create mode 100644 packages/contracts/src/compatibility/catalog.ts create mode 100644 plans/260922-0717-skillx-agent-native-discovery/plan.md diff --git a/packages/contracts/src/catalog-compatibility.test.ts b/packages/contracts/src/catalog-compatibility.test.ts new file mode 100644 index 0000000..a3dac5e --- /dev/null +++ b/packages/contracts/src/catalog-compatibility.test.ts @@ -0,0 +1,172 @@ +/** + * Catalog-level compatibility resolution. + * + * The precedence rule is the thing under test: release declaration beats listing + * declaration, a listing can never reach `verified`, and a release declaration + * that arrives without a release is ignored rather than trusted. + */ + +import { describe, expect, it } from "vitest"; +import { + isCompatibilityActionable, + resolveCatalogCompatibility, + resolveDeclaredRuntimes, + selectDeclaration, + toCompatibilitySummary, + type CatalogCompatibilityInputs, +} from "./compatibility/catalog"; +import type { VerificationEvidence } from "./verification-evidence"; + +const DIGEST = `sha256:${"a".repeat(64)}`; + +function evidence(overrides: Partial = {}): VerificationEvidence { + return { + releaseDigest: DIGEST, + harness: "agentkit", + harnessVersion: "1.4.0", + probeId: "probe_001", + verifiedAt: "2026-09-20T10:00:00.000Z", + verifier: "ci", + ...overrides, + }; +} + +const LISTING = { + agentkit: { status: "declared" as const, versions: ">=1.0.0 <2.0.0" }, +}; + +const RELEASE = { + agentkit: { status: "declared" as const, versions: ">=2.0.0" }, +}; + +describe("selectDeclaration — the single precedence rule", () => { + it("prefers the release declaration when the row really has a release", () => { + const selected = selectDeclaration({ hasRelease: true, releaseDeclaration: RELEASE, listingDeclaration: LISTING }); + expect(selected.tier).toBe("release"); + expect(selected.declaration).toBe(RELEASE); + }); + + it("falls back to the listing declaration without a release", () => { + const selected = selectDeclaration({ listingDeclaration: LISTING }); + expect(selected.tier).toBe("listing"); + expect(selected.declaration).toBe(LISTING); + }); + + it("ignores a release declaration when the row has no release", () => { + // Trusting this would let a surface claim release-grade provenance it cannot prove. + const selected = selectDeclaration({ hasRelease: false, releaseDeclaration: RELEASE, listingDeclaration: LISTING }); + expect(selected.tier).toBe("listing"); + expect(selected.declaration).toBe(LISTING); + }); + + it("reports no tier when neither tier declares anything", () => { + expect(selectDeclaration({}).tier).toBeNull(); + expect(selectDeclaration({ hasRelease: true, evidence: [evidence()] }).tier).toBeNull(); + }); +}); + +describe("resolveCatalogCompatibility — listing tier", () => { + const inputs: CatalogCompatibilityInputs = { listingDeclaration: LISTING }; + + it("resolves a declared listing to `declared`", () => { + const result = resolveCatalogCompatibility({ runtime: "agentkit" }, inputs); + expect(result.status).toBe("declared"); + expect(result.versions).toBe(">=1.0.0 <2.0.0"); + }); + + it("never reaches `verified` from matching evidence, because a listing has no digest", () => { + const result = resolveCatalogCompatibility({ runtime: "agentkit" }, { + ...inputs, + evidence: [evidence({ releaseDigest: "sha256:" + "b".repeat(64) })], + }); + expect(result.status).toBe("declared"); + expect(result.reasons.map((reason) => reason.code)).toContain("COMPAT_EVIDENCE_UNBOUND"); + }); + + it("stays unknown for a runtime the listing never declared", () => { + const result = resolveCatalogCompatibility({ runtime: "claude-code" }, inputs); + expect(result.status).toBe("unknown"); + expect(result.reasons[0]?.code).toBe("COMPAT_DECLARATION_MISSING"); + }); + + it("blocks a version outside the declared range", () => { + const result = resolveCatalogCompatibility({ runtime: "agentkit", version: "2.5.0" }, inputs); + expect(result.status).toBe("blocked"); + expect(result.reasons.map((reason) => reason.code)).toContain("COMPAT_RANGE_MISMATCH"); + }); +}); + +describe("resolveCatalogCompatibility — release tier", () => { + const inputs: CatalogCompatibilityInputs = { + hasRelease: true, + releaseDeclaration: RELEASE, + releaseDigest: DIGEST, + evidence: [evidence()], + }; + + it("reaches `verified` only with digest-bound evidence", () => { + const result = resolveCatalogCompatibility({ runtime: "agentkit" }, inputs); + expect(result.status).toBe("verified"); + expect(result.evidence?.probeId).toBe("probe_001"); + }); + + it("stays declared when the evidence is for a different digest", () => { + const result = resolveCatalogCompatibility({ runtime: "agentkit" }, { + ...inputs, + evidence: [evidence({ releaseDigest: "sha256:" + "c".repeat(64) })], + }); + expect(result.status).toBe("declared"); + expect(result.reasons.map((reason) => reason.code)).toContain("COMPAT_EVIDENCE_DIGEST_MISMATCH"); + }); + + it("uses the release range, not the listing range", () => { + const result = resolveCatalogCompatibility({ runtime: "agentkit", version: "2.5.0" }, { + ...inputs, + listingDeclaration: LISTING, + }); + expect(result.status).toBe("verified"); + }); +}); + +describe("resolveDeclaredRuntimes", () => { + it("enumerates exactly the declared runtimes", () => { + const inputs: CatalogCompatibilityInputs = { + listingDeclaration: { ...LISTING, "claude-code": { status: "unsupported" } }, + }; + const resolved = resolveDeclaredRuntimes(inputs).map((entry) => `${entry.runtime}:${entry.status}`); + expect(resolved.sort()).toEqual(["agentkit:declared", "claude-code:unsupported"]); + }); + + it("returns nothing when no tier declares anything", () => { + expect(resolveDeclaredRuntimes({})).toEqual([]); + }); +}); + +describe("toCompatibilitySummary", () => { + it("carries the reason codes an agent needs to explain a refusal", () => { + const result = resolveCatalogCompatibility({ runtime: "agentkit", version: "9.9.9" }, { + listingDeclaration: LISTING, + }); + const summary = toCompatibilitySummary(result); + expect(summary).toMatchObject({ runtime: "agentkit", status: "blocked" }); + expect(summary.reasonCodes).toContain("COMPAT_RANGE_MISMATCH"); + expect(summary.verifiedAt).toBeNull(); + }); + + it("exposes the probe identity only for a verified answer", () => { + const declared = toCompatibilitySummary( + resolveCatalogCompatibility({ runtime: "agentkit" }, { listingDeclaration: LISTING }), + ); + expect(declared.probeId).toBeNull(); + }); +}); + +describe("isCompatibilityActionable", () => { + it("treats only declared and verified as actionable", () => { + expect(isCompatibilityActionable("declared")).toBe(true); + expect(isCompatibilityActionable("verified")).toBe(true); + expect(isCompatibilityActionable("unknown")).toBe(false); + expect(isCompatibilityActionable("unsupported")).toBe(false); + expect(isCompatibilityActionable("blocked")).toBe(false); + }); +}); diff --git a/packages/contracts/src/compatibility/catalog.ts b/packages/contracts/src/compatibility/catalog.ts new file mode 100644 index 0000000..7ed89a3 --- /dev/null +++ b/packages/contracts/src/compatibility/catalog.ts @@ -0,0 +1,128 @@ +/** + * Catalog-level compatibility resolution. + * + * Phase 0 stores compatibility declarations on an immutable release + * (`package_releases.compatibility_json`). Every catalog row that exists today is + * a legacy *listing*: it has no release, no artifact, and no digest, so a surface + * built only on release declarations would answer `unknown` for the entire + * catalog and every filter would be inert. + * + * This module owns the ONE precedence rule that bridges the two storage tiers, so + * web, CLI, and MCP cannot each invent their own: + * + * 1. an immutable release declaration wins, and only it can reach `verified`; + * 2. otherwise the listing declaration is used, which can reach at most + * `declared` because a listing has no digest to bind evidence to; + * 3. neither present -> `unknown`. + * + * The engine still decides; this module only chooses the inputs. Phase 2 deletes + * step 2 when the immutable registry lands, and that removal is local to here. + */ + +import { normalizeCompatibility } from "./normalize"; +import type { + CompatibilityDeclarationMap, + CompatibilityReasonCode, + CompatibilityStatus, + CompatibilityTarget, + NormalizedCompatibility, +} from "./types"; +import type { VerificationEvidence } from "../verification-evidence"; + +/** Which storage tier the declaration was read from. `null` means neither had one. */ +export type DeclarationTier = "release" | "listing"; + +export interface CatalogCompatibilityInputs { + /** Declaration on an immutable release, when the row has one. */ + releaseDeclaration?: CompatibilityDeclarationMap | null; + /** Declaration on the mutable listing. */ + listingDeclaration?: CompatibilityDeclarationMap | null; + /** True only when the row is backed by an immutable release. */ + hasRelease?: boolean; + /** The release artifact digest. Required before any `verified` answer. */ + releaseDigest?: string | null; + /** Persisted `release_verification_evidence` rows. */ + evidence?: VerificationEvidence[]; +} + +/** + * Picks the declaration the engine should consume. + * + * A release declaration is only reachable when the row actually has a release; + * passing one without `hasRelease` would let a surface claim release-grade + * provenance it cannot prove. + */ +export function selectDeclaration(inputs: CatalogCompatibilityInputs): { + declaration: CompatibilityDeclarationMap | undefined; + tier: DeclarationTier | null; +} { + if (inputs.hasRelease && inputs.releaseDeclaration) { + return { declaration: inputs.releaseDeclaration, tier: "release" }; + } + if (inputs.listingDeclaration) { + return { declaration: inputs.listingDeclaration, tier: "listing" }; + } + return { declaration: undefined, tier: null }; +} + +/** + * Resolves one target against the catalog inputs. + * + * A listing-tier answer is deliberately given no release digest, so the engine + * reports any evidence as `unbound` and the status cannot exceed `declared`. + */ +export function resolveCatalogCompatibility( + target: CompatibilityTarget, + inputs: CatalogCompatibilityInputs, +): NormalizedCompatibility { + const { declaration, tier } = selectDeclaration(inputs); + const digest = tier === "release" ? inputs.releaseDigest ?? undefined : undefined; + + return normalizeCompatibility(target, { + ...(declaration ? { declaration } : {}), + ...(inputs.evidence ? { evidence: inputs.evidence } : {}), + ...(digest ? { releaseDigest: digest } : {}), + }); +} + +/** Resolves every runtime named by the selected declaration. */ +export function resolveDeclaredRuntimes( + inputs: CatalogCompatibilityInputs, +): NormalizedCompatibility[] { + const { declaration } = selectDeclaration(inputs); + if (!declaration) return []; + return Object.keys(declaration).map((runtime) => resolveCatalogCompatibility({ runtime }, inputs)); +} + +/** Compact per-runtime result carried on search rows and listing payloads. */ +export interface CompatibilitySummary { + runtime: string; + status: CompatibilityStatus; + versions: string | null; + reasonCodes: CompatibilityReasonCode[]; + /** Present only for a digest-bound `verified` answer. */ + verifiedAt: string | null; + probeId: string | null; +} + +export function toCompatibilitySummary(result: NormalizedCompatibility): CompatibilitySummary { + return { + runtime: result.runtime, + status: result.status, + versions: result.versions, + reasonCodes: result.reasons.map((reason) => reason.code), + verifiedAt: result.status === "verified" ? result.evidence?.verifiedAt ?? null : null, + probeId: result.status === "verified" ? result.evidence?.probeId ?? null : null, + }; +} + +/** + * True when an agent may act on this answer without further evidence. + * + * `unknown` is never actionable: absence of a declaration is not a claim of + * support, and treating it as one is how an agent installs something that cannot + * run. + */ +export function isCompatibilityActionable(status: CompatibilityStatus): boolean { + return status === "declared" || status === "verified"; +} diff --git a/packages/contracts/src/compatibility/index.ts b/packages/contracts/src/compatibility/index.ts index d49da31..e43f2e5 100644 --- a/packages/contracts/src/compatibility/index.ts +++ b/packages/contracts/src/compatibility/index.ts @@ -5,3 +5,4 @@ export * from "./types"; export * from "./validate"; export * from "./normalize"; +export * from "./catalog"; diff --git a/plans/260922-0717-skillx-agent-native-discovery/plan.md b/plans/260922-0717-skillx-agent-native-discovery/plan.md new file mode 100644 index 0000000..3ffa723 --- /dev/null +++ b/plans/260922-0717-skillx-agent-native-discovery/plan.md @@ -0,0 +1,87 @@ +# Phase 1 — Agent-native discovery and compatibility + +Issue: [#33](https://github.com/nextlevelbuilder/skillx/issues/33) · Epic #31 · Depends on Phase 0 (#32, merged as `b02b520`) + +## Objective + +Make SkillX equally useful to humans and agents for discovery, inspection, and compatibility +reasoning: an agent must be able to discover a package, read its requirements, see the evidence +behind a support claim, and understand an incompatibility — **without scraping HTML**. + +## The blocking gap this plan has to solve first + +Phase 0 put compatibility inputs on `package_releases` (`compatibility_json`, `artifact_digest`) plus +`release_verification_evidence`. But: + +- `skills` has **no compatibility column** (verified: 30 columns, none compatibility-related), and +- nothing links a `skills` row to a `package_releases` row. + +Every catalog row that exists today is a **legacy listing** (`legacy-listing.ts`: `releaseId: null`, +`releaseDigest: null`, `immutable: false`). So a compatibility read surface built directly on +`package_releases` would answer `unknown` for the entire catalog, and `--compatible` / +`skillx check` / the badges would be permanently inert. + +Phase 1 therefore adds a **listing-level declaration** and one documented precedence rule. This does +not create a second decision point: the engine (`normalizeCompatibility`) stays the only thing that +decides, and one resolver owns the input precedence. + +### Precedence (single rule, one owner) + +1. If the row has an immutable release, the **release declaration wins** (and only a digest-bound + release can reach `verified`). +2. Otherwise the **listing declaration** is used. A listing has no digest, so it can reach at most + `declared` — never `verified`. +3. Neither present → `unknown` with `COMPAT_DECLARATION_MISSING`. + +When Phase 2 lands the immutable registry, step 2 is deleted; the rule is documented so that removal +is a one-line change rather than an archaeology exercise. + +### Honest consequence + +Today's catalog declares nothing, so it resolves to `unknown`. The surfaces must say so plainly +(`COMPAT_DECLARATION_MISSING`) rather than imply support, and `--compatible` must not silently drop +undecidable rows without the caller being able to see why. + +## Slices + +| # | Slice | Tasks covered | Status | +|---|---|---|---| +| 1 | Listing-level declaration + shared catalog resolver | foundation for 1–5 | this goal | +| 2 | Compatibility-aware API (`?compatible=`, compat on search + detail) | 1, 5 (API half) | this goal | +| 3 | CLI: `--compatible`, `inspect`, `check --target`, JSON/exit-code envelope | 1, 2, 3, 4 | this goal | +| 4 | Web compatibility badge with evidence details | 5 | this goal | +| 5 | Markdown routes, `llms.txt`, `llms-full.txt`, `rel=alternate`, Copy | 9–13 | this goal | +| 6 | Remote MCP server + tools, WebMCP read tools | 6, 7, 8 | this goal | +| 7 | Parity tests across API/CLI/MCP/Markdown | 15 | this goal | + +## Acceptance criteria (from #33) + +An agent can discover a package, inspect requirements, see evidence, and understand incompatibility +without scraping HTML. Browser WebMCP support is optional; normal web remains fully functional. + +## Constraints carried from the project + +- Max 200 LOC per file; split when exceeded. +- Markdown only under `plans/` or `docs/`. +- Back up D1 before any schema/migration change; **never** migrate remote D1 without asking. +- Compatibility has exactly one engine and one closed vocabulary of statuses and reason codes. +- `verified` only from evidence bound to the exact artifact digest. +- No secret in the repo; never log a credential or a signed artifact URL. +- Public/free listings keep returning `content`; the protected boundary from Phase 0 is not weakened. + +## Risks + +| Risk | Impact | Mitigation | +|---|---|---| +| Listing declaration becomes a second source of truth | Surfaces disagree later | One resolver owns precedence; documented removal in Phase 2; parity test asserts every surface uses it | +| `--compatible` silently hides undecidable rows | Agent believes the catalog is smaller than it is | Report `unknown` counts alongside matches; never filter without saying so | +| Migration drift (as in Phase 0) | New column absent from generated SQL | Back up D1, generate, read the SQL, assert the column exists before applying | +| Markdown route bypasses the payload boundary | Paid SKILL.md leaks as text | Route through the same resolver; assert with the existing guard test | +| MCP server becomes a second API implementation | Answers drift from HTTP | MCP tools are thin adapters over the same executor functions | + +## Out of scope + +- Phase 2 immutable registry, publishing CLI/CI (issue #34). +- Any commerce, entitlement, or payment runtime. +- Porting AgentKit's Go installer. +- Editing `plans/260225-0748-premium-skills-credits-marketplace`. From 95a169eace340ef9e123d64bf64ed4898d214c5d Mon Sep 17 00:00:00 2001 From: Goon Date: Tue, 22 Sep 2026 07:24:47 +0200 Subject: [PATCH 02/19] feat(db): add skills.compatibility_json and split auth tables Adds the listing-level compatibility declaration. A legacy listing has no release and no digest, so this column is the only place a publisher can declare runtime support for the catalog that exists today; it can reach `declared` but never `verified`. Adding the column pushed schema.ts to 237 LOC, over the project's 200 LOC rule, so the five auth/API-key tables move to `auth-schema.ts` following the same per-domain pattern as registry-schema.ts and collections-schema.ts. Nothing in those tables references `skills`, so the split crosses no foreign key, and schema.ts re-exports them so `~/lib/db/schema` stays the single import site. Migration 0010 was generated against a backed-up local D1 (.wrangler/backups/skillx-db-PRE-0010-*.sqlite) and applied cleanly: skills went from 28 to 29 columns. A second generate reports "No schema changes", which is what proves the auth split is schema-neutral. --- apps/web/app/lib/db/auth-schema.ts | 82 + apps/web/app/lib/db/schema.ts | 80 +- .../drizzle/migrations/0010_greedy_famine.sql | 1 + .../migrations/meta/0010_snapshot.json | 1855 +++++++++++++++++ .../web/drizzle/migrations/meta/_journal.json | 7 + 5 files changed, 1955 insertions(+), 70 deletions(-) create mode 100644 apps/web/app/lib/db/auth-schema.ts create mode 100644 apps/web/drizzle/migrations/0010_greedy_famine.sql create mode 100644 apps/web/drizzle/migrations/meta/0010_snapshot.json diff --git a/apps/web/app/lib/db/auth-schema.ts b/apps/web/app/lib/db/auth-schema.ts new file mode 100644 index 0000000..96a25c8 --- /dev/null +++ b/apps/web/app/lib/db/auth-schema.ts @@ -0,0 +1,82 @@ +/** + * Authentication and API-key tables. + * + * Split out of `schema.ts` to keep that module within the project's 200 LOC rule, + * following the same per-domain pattern as `registry-schema.ts` and + * `collections-schema.ts`. `schema.ts` re-exports this module, so + * `~/lib/db/schema` stays the single import site. + * + * Nothing here references `skills`, so this split crosses no foreign key. + */ + +import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core"; + +// Better Auth tables — required by drizzle adapter +export const user = sqliteTable("user", { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: integer("emailVerified", { mode: "boolean" }).default(false), + image: text("image"), + createdAt: integer("createdAt", { mode: "timestamp_ms" }).notNull(), + updatedAt: integer("updatedAt", { mode: "timestamp_ms" }).notNull(), +}); + +export const session = sqliteTable("session", { + id: text("id").primaryKey(), + userId: text("userId") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + token: text("token").notNull().unique(), + expiresAt: integer("expiresAt", { mode: "timestamp_ms" }).notNull(), + ipAddress: text("ipAddress"), + userAgent: text("userAgent"), + createdAt: integer("createdAt", { mode: "timestamp_ms" }).notNull(), + updatedAt: integer("updatedAt", { mode: "timestamp_ms" }).notNull(), +}); + +export const account = sqliteTable("account", { + id: text("id").primaryKey(), + userId: text("userId") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + accountId: text("accountId").notNull(), + providerId: text("providerId").notNull(), + accessToken: text("accessToken"), + refreshToken: text("refreshToken"), + accessTokenExpiresAt: integer("accessTokenExpiresAt", { mode: "timestamp_ms" }), + refreshTokenExpiresAt: integer("refreshTokenExpiresAt", { mode: "timestamp_ms" }), + scope: text("scope"), + idToken: text("idToken"), + password: text("password"), + createdAt: integer("createdAt", { mode: "timestamp_ms" }).notNull(), + updatedAt: integer("updatedAt", { mode: "timestamp_ms" }).notNull(), +}); + +export const verification = sqliteTable("verification", { + id: text("id").primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: integer("expiresAt", { mode: "timestamp_ms" }).notNull(), + createdAt: integer("createdAt", { mode: "timestamp_ms" }).notNull(), + updatedAt: integer("updatedAt", { mode: "timestamp_ms" }).notNull(), +}); + +// API keys - for CLI and external integrations +export const apiKeys = sqliteTable( + "api_keys", + { + id: text("id").primaryKey(), + user_id: text("user_id").notNull(), + name: text("name").notNull().default("Default"), + key_hash: text("key_hash").notNull().unique(), + key_prefix: text("key_prefix").notNull(), // first 8 chars for identification + last_used_at: integer("last_used_at", { mode: "timestamp_ms" }), + revoked_at: integer("revoked_at", { mode: "timestamp_ms" }), + created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), + }, + (table) => [ + index("idx_api_keys_user").on(table.user_id), + index("idx_api_keys_hash").on(table.key_hash), + ] +); diff --git a/apps/web/app/lib/db/schema.ts b/apps/web/app/lib/db/schema.ts index 054e084..723fe7c 100644 --- a/apps/web/app/lib/db/schema.ts +++ b/apps/web/app/lib/db/schema.ts @@ -1,6 +1,12 @@ import { sql } from "drizzle-orm"; import { sqliteTable, text, integer, real, uniqueIndex, index } from "drizzle-orm/sqlite-core"; +// Auth and API-key tables live in their own module so this file stays within +// the project's 200 LOC rule. Re-exported so `~/lib/db/schema` remains the +// single import site for every caller. +export * from "./auth-schema"; + + // Skills - core marketplace entity export const skills = sqliteTable( "skills", @@ -30,6 +36,10 @@ export const skills = sqliteTable( downvote_count: integer("downvote_count").default(0), net_votes: integer("net_votes").default(0), scripts: text("scripts"), // JSON: [{name, command, url}] + // JSON: publisher-declared runtime compatibility map (`skillx.package/v1` + // `compatible` shape). A listing has no artifact digest, so a declaration + // here can reach `declared` but never `verified`. + compatibility_json: text("compatibility_json"), fts_content: text("fts_content"), // Computed: content + ref titles (for FTS5) risk_label: text("risk_label").default("unknown"), created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), @@ -161,73 +171,3 @@ export const installs = sqliteTable( .where(sql`device_id IS NOT NULL`), ] ); - -// Better Auth tables — required by drizzle adapter -export const user = sqliteTable("user", { - id: text("id").primaryKey(), - name: text("name").notNull(), - email: text("email").notNull().unique(), - emailVerified: integer("emailVerified", { mode: "boolean" }).default(false), - image: text("image"), - createdAt: integer("createdAt", { mode: "timestamp_ms" }).notNull(), - updatedAt: integer("updatedAt", { mode: "timestamp_ms" }).notNull(), -}); - -export const session = sqliteTable("session", { - id: text("id").primaryKey(), - userId: text("userId") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - token: text("token").notNull().unique(), - expiresAt: integer("expiresAt", { mode: "timestamp_ms" }).notNull(), - ipAddress: text("ipAddress"), - userAgent: text("userAgent"), - createdAt: integer("createdAt", { mode: "timestamp_ms" }).notNull(), - updatedAt: integer("updatedAt", { mode: "timestamp_ms" }).notNull(), -}); - -export const account = sqliteTable("account", { - id: text("id").primaryKey(), - userId: text("userId") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - accountId: text("accountId").notNull(), - providerId: text("providerId").notNull(), - accessToken: text("accessToken"), - refreshToken: text("refreshToken"), - accessTokenExpiresAt: integer("accessTokenExpiresAt", { mode: "timestamp_ms" }), - refreshTokenExpiresAt: integer("refreshTokenExpiresAt", { mode: "timestamp_ms" }), - scope: text("scope"), - idToken: text("idToken"), - password: text("password"), - createdAt: integer("createdAt", { mode: "timestamp_ms" }).notNull(), - updatedAt: integer("updatedAt", { mode: "timestamp_ms" }).notNull(), -}); - -export const verification = sqliteTable("verification", { - id: text("id").primaryKey(), - identifier: text("identifier").notNull(), - value: text("value").notNull(), - expiresAt: integer("expiresAt", { mode: "timestamp_ms" }).notNull(), - createdAt: integer("createdAt", { mode: "timestamp_ms" }).notNull(), - updatedAt: integer("updatedAt", { mode: "timestamp_ms" }).notNull(), -}); - -// API keys - for CLI and external integrations -export const apiKeys = sqliteTable( - "api_keys", - { - id: text("id").primaryKey(), - user_id: text("user_id").notNull(), - name: text("name").notNull().default("Default"), - key_hash: text("key_hash").notNull().unique(), - key_prefix: text("key_prefix").notNull(), // first 8 chars for identification - last_used_at: integer("last_used_at", { mode: "timestamp_ms" }), - revoked_at: integer("revoked_at", { mode: "timestamp_ms" }), - created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), - }, - (table) => [ - index("idx_api_keys_user").on(table.user_id), - index("idx_api_keys_hash").on(table.key_hash), - ] -); diff --git a/apps/web/drizzle/migrations/0010_greedy_famine.sql b/apps/web/drizzle/migrations/0010_greedy_famine.sql new file mode 100644 index 0000000..21a71f8 --- /dev/null +++ b/apps/web/drizzle/migrations/0010_greedy_famine.sql @@ -0,0 +1 @@ +ALTER TABLE `skills` ADD `compatibility_json` text; \ No newline at end of file diff --git a/apps/web/drizzle/migrations/meta/0010_snapshot.json b/apps/web/drizzle/migrations/meta/0010_snapshot.json new file mode 100644 index 0000000..5a224e1 --- /dev/null +++ b/apps/web/drizzle/migrations/meta/0010_snapshot.json @@ -0,0 +1,1855 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "e4a8e1b4-aff9-4bb7-b5f6-b13c13c4ac3c", + "prevId": "d29ca61f-631f-40d9-9080-860333a22d95", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accountId": { + "name": "accountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerId": { + "name": "providerId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accessTokenExpiresAt": { + "name": "accessTokenExpiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refreshTokenExpiresAt": { + "name": "refreshTokenExpiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "idToken": { + "name": "idToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Default'" + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "columns": [ + "key_hash" + ], + "isUnique": true + }, + "idx_api_keys_user": { + "name": "idx_api_keys_user", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_api_keys_hash": { + "name": "idx_api_keys_hash", + "columns": [ + "key_hash" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "favorites": { + "name": "favorites", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_favorites_pk": { + "name": "idx_favorites_pk", + "columns": [ + "user_id", + "skill_id" + ], + "isUnique": true + }, + "idx_favorites_skill": { + "name": "idx_favorites_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "favorites_skill_id_skills_id_fk": { + "name": "favorites_skill_id_skills_id_fk", + "tableFrom": "favorites", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "installs": { + "name": "installs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_installs_skill": { + "name": "idx_installs_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + }, + "idx_installs_user": { + "name": "idx_installs_user", + "columns": [ + "skill_id", + "user_id" + ], + "isUnique": true, + "where": "user_id IS NOT NULL" + }, + "idx_installs_device": { + "name": "idx_installs_device", + "columns": [ + "skill_id", + "device_id" + ], + "isUnique": true, + "where": "device_id IS NOT NULL" + } + }, + "foreignKeys": { + "installs_skill_id_skills_id_fk": { + "name": "installs_skill_id_skills_id_fk", + "tableFrom": "installs", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ratings": { + "name": "ratings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_agent": { + "name": "is_agent", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_ratings_user_skill": { + "name": "idx_ratings_user_skill", + "columns": [ + "user_id", + "skill_id" + ], + "isUnique": true + }, + "idx_ratings_skill": { + "name": "idx_ratings_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ratings_skill_id_skills_id_fk": { + "name": "ratings_skill_id_skills_id_fk", + "tableFrom": "ratings", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reviews": { + "name": "reviews", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_agent": { + "name": "is_agent", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_reviews_skill": { + "name": "idx_reviews_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + }, + "idx_reviews_user": { + "name": "idx_reviews_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "reviews_skill_id_skills_id_fk": { + "name": "reviews_skill_id_skills_id_fk", + "tableFrom": "reviews", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "skills": { + "name": "skills", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "install_command": { + "name": "install_command", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'1.0.0'" + }, + "is_paid": { + "name": "is_paid", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "avg_rating": { + "name": "avg_rating", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "rating_count": { + "name": "rating_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "github_stars": { + "name": "github_stars", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "install_count": { + "name": "install_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "composite_score": { + "name": "composite_score", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "bayesian_rating": { + "name": "bayesian_rating", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "trending_score": { + "name": "trending_score", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "favorite_count": { + "name": "favorite_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "upvote_count": { + "name": "upvote_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "downvote_count": { + "name": "downvote_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "net_votes": { + "name": "net_votes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "scripts": { + "name": "scripts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "compatibility_json": { + "name": "compatibility_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fts_content": { + "name": "fts_content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "risk_label": { + "name": "risk_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'unknown'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "skills_slug_unique": { + "name": "skills_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "idx_skills_category": { + "name": "idx_skills_category", + "columns": [ + "category" + ], + "isUnique": false + }, + "idx_skills_author": { + "name": "idx_skills_author", + "columns": [ + "author" + ], + "isUnique": false + }, + "idx_skills_avg_rating": { + "name": "idx_skills_avg_rating", + "columns": [ + "avg_rating" + ], + "isUnique": false + }, + "idx_skills_composite_score": { + "name": "idx_skills_composite_score", + "columns": [ + "composite_score" + ], + "isUnique": false + }, + "idx_skills_trending_score": { + "name": "idx_skills_trending_score", + "columns": [ + "trending_score" + ], + "isUnique": false + }, + "idx_skills_net_votes": { + "name": "idx_skills_net_votes", + "columns": [ + "net_votes" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "usage_stats": { + "name": "usage_stats", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_usage_skill": { + "name": "idx_usage_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + }, + "idx_usage_created": { + "name": "idx_usage_created", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "usage_stats_skill_id_skills_id_fk": { + "name": "usage_stats_skill_id_skills_id_fk", + "tableFrom": "usage_stats", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "votes": { + "name": "votes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vote_type": { + "name": "vote_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_votes_user_skill": { + "name": "idx_votes_user_skill", + "columns": [ + "user_id", + "skill_id" + ], + "isUnique": true + }, + "idx_votes_skill": { + "name": "idx_votes_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "votes_skill_id_skills_id_fk": { + "name": "votes_skill_id_skills_id_fk", + "tableFrom": "votes", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "package_releases": { + "name": "package_releases", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "package_id": { + "name": "package_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'stable'" + }, + "release_state": { + "name": "release_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "manifest_schema_version": { + "name": "manifest_schema_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "artifact_key": { + "name": "artifact_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "artifact_digest": { + "name": "artifact_digest", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "artifact_size": { + "name": "artifact_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_provenance": { + "name": "source_provenance", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "compatibility_json": { + "name": "compatibility_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "yanked_at": { + "name": "yanked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_package_releases_version": { + "name": "idx_package_releases_version", + "columns": [ + "package_id", + "version" + ], + "isUnique": true + }, + "idx_package_releases_state": { + "name": "idx_package_releases_state", + "columns": [ + "release_state" + ], + "isUnique": false + } + }, + "foreignKeys": { + "package_releases_package_id_packages_id_fk": { + "name": "package_releases_package_id_packages_id_fk", + "tableFrom": "package_releases", + "tableTo": "packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "packages": { + "name": "packages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_packages_name": { + "name": "idx_packages_name", + "columns": [ + "name" + ], + "isUnique": true + }, + "idx_packages_publisher": { + "name": "idx_packages_publisher", + "columns": [ + "publisher_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "packages_publisher_id_publishers_id_fk": { + "name": "packages_publisher_id_publishers_id_fk", + "tableFrom": "packages", + "tableTo": "publishers", + "columnsFrom": [ + "publisher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "publishers": { + "name": "publishers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "publishers_handle_unique": { + "name": "publishers_handle_unique", + "columns": [ + "handle" + ], + "isUnique": true + }, + "idx_publishers_owner": { + "name": "idx_publishers_owner", + "columns": [ + "owner_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "release_verification_evidence": { + "name": "release_verification_evidence", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "release_id": { + "name": "release_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "release_digest": { + "name": "release_digest", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "harness": { + "name": "harness", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "harness_version": { + "name": "harness_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "os": { + "name": "os", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "probe_id": { + "name": "probe_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verifier": { + "name": "verifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_release_evidence_release": { + "name": "idx_release_evidence_release", + "columns": [ + "release_id" + ], + "isUnique": false + }, + "idx_release_evidence_probe": { + "name": "idx_release_evidence_probe", + "columns": [ + "release_id", + "harness", + "probe_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "release_verification_evidence_release_id_package_releases_id_fk": { + "name": "release_verification_evidence_release_id_package_releases_id_fk", + "tableFrom": "release_verification_evidence", + "tableTo": "package_releases", + "columnsFrom": [ + "release_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "entitlements": { + "name": "entitlements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "package_id": { + "name": "package_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "release_id": { + "name": "release_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order_id": { + "name": "order_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_at": { + "name": "granted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_entitlements_subject": { + "name": "idx_entitlements_subject", + "columns": [ + "subject_id" + ], + "isUnique": false + }, + "idx_entitlements_package": { + "name": "idx_entitlements_package", + "columns": [ + "package_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "entitlements_package_id_packages_id_fk": { + "name": "entitlements_package_id_packages_id_fk", + "tableFrom": "entitlements", + "tableTo": "packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entitlements_release_id_package_releases_id_fk": { + "name": "entitlements_release_id_package_releases_id_fk", + "tableFrom": "entitlements", + "tableTo": "package_releases", + "columnsFrom": [ + "release_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_revisions": { + "name": "collection_revisions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_number": { + "name": "revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "members_json": { + "name": "members_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_json": { + "name": "config_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_collection_revisions_number": { + "name": "idx_collection_revisions_number", + "columns": [ + "collection_id", + "revision_number" + ], + "isUnique": true + }, + "idx_collection_revisions_collection": { + "name": "idx_collection_revisions_collection", + "columns": [ + "collection_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "collection_revisions_collection_id_collections_id_fk": { + "name": "collection_revisions_collection_id_collections_id_fk", + "tableFrom": "collection_revisions", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collections": { + "name": "collections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'private'" + }, + "latest_revision_number": { + "name": "latest_revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_collections_owner_slug": { + "name": "idx_collections_owner_slug", + "columns": [ + "owner_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/web/drizzle/migrations/meta/_journal.json b/apps/web/drizzle/migrations/meta/_journal.json index 11e5a8c..9eb38e5 100644 --- a/apps/web/drizzle/migrations/meta/_journal.json +++ b/apps/web/drizzle/migrations/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1789979864442, "tag": "0009_brave_sally_floyd", "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1790054377460, + "tag": "0010_greedy_famine", + "breakpoints": true } ] } \ No newline at end of file From d02a8979ed78bf4b8fbf56584f426542a5c2286e Mon Sep 17 00:00:00 2001 From: Goon Date: Tue, 22 Sep 2026 07:24:47 +0200 Subject: [PATCH 03/19] feat(search): make search and detail compatibility-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the listing adapter over the shared precedence rule, and the `--compatible ` filter. The filter cannot run in the database: compatibility is a JSON column that FTS5 and Vectorize cannot index. It runs over a projected page instead, and it reports what it did rather than filtering silently — a filter that hides undecidable listings makes an agent believe the catalog is smaller than it is. Each run returns `{ evaluated, matched, undecided, refused, poolExhausted }`, and an empty page carries a note stating that a missing declaration is not support. When the filter is active the executor over-fetches a candidate pool and truncates only after matching, so a page does not come back short just because the top-ranked rows were undeclared. `GET /api/skills/:slug?target=runtime@version` now returns every runtime the listing declares plus a version-aware answer for the requested target, so an agent can read the requirement, the evidence and the refusal reason without scraping HTML. Only `declared` and `verified` are actionable; `unknown` never is. --- apps/web/app/lib/catalog/public-dto.ts | 2 + .../catalog-compatibility.test.ts | 124 +++++++++++++++ .../compatibility/catalog-compatibility.ts | 104 ++++++++++++ .../lib/search/compatibility-filter.test.ts | 148 ++++++++++++++++++ .../app/lib/search/compatibility-filter.ts | 108 +++++++++++++ apps/web/app/lib/search/search-executor.ts | 57 ++++++- .../lib/search/search-result-projection.ts | 26 ++- apps/web/app/routes/api.search.ts | 64 ++++++-- apps/web/app/routes/api.skill-detail.ts | 17 ++ 9 files changed, 626 insertions(+), 24 deletions(-) create mode 100644 apps/web/app/lib/compatibility/catalog-compatibility.test.ts create mode 100644 apps/web/app/lib/compatibility/catalog-compatibility.ts create mode 100644 apps/web/app/lib/search/compatibility-filter.test.ts create mode 100644 apps/web/app/lib/search/compatibility-filter.ts diff --git a/apps/web/app/lib/catalog/public-dto.ts b/apps/web/app/lib/catalog/public-dto.ts index 5abc688..63bcc9d 100644 --- a/apps/web/app/lib/catalog/public-dto.ts +++ b/apps/web/app/lib/catalog/public-dto.ts @@ -27,6 +27,8 @@ export interface SkillCatalogRow { favorite_count: number | null; net_votes: number | null; install_command?: string | null; + /** Publisher-declared runtime compatibility map, as stored JSON. */ + compatibility_json?: string | null; updated_at: Date | number | null; } diff --git a/apps/web/app/lib/compatibility/catalog-compatibility.test.ts b/apps/web/app/lib/compatibility/catalog-compatibility.test.ts new file mode 100644 index 0000000..f85c94c --- /dev/null +++ b/apps/web/app/lib/compatibility/catalog-compatibility.test.ts @@ -0,0 +1,124 @@ +/** + * Listing-level compatibility adapter. + * + * A `skills` row is mutable and has no artifact digest, so the adapter must never + * produce `verified`, and a row whose JSON cannot be parsed must resolve to + * `unknown` rather than to support. + */ + +import { describe, expect, it } from "vitest"; +import { + listingInputs, + listingMatchesTarget, + parseCompatibilityTarget, + resolveRowCompatibility, + summarizeRowRuntime, + summarizeRowRuntimes, +} from "./catalog-compatibility"; + +const DECLARED = JSON.stringify({ + agentkit: { status: "declared", versions: ">=1.0.0 <2.0.0" }, + "claude-code": { status: "unsupported" }, +}); + +describe("listingInputs", () => { + it("reads the declaration off the row", () => { + const inputs = listingInputs({ compatibility_json: DECLARED }); + expect(Object.keys(inputs.listingDeclaration ?? {}).sort()).toEqual(["agentkit", "claude-code"]); + }); + + it.each([ + ["a null column", null], + ["an absent column", undefined], + ["empty text", ""], + ["malformed JSON", "{not json"], + ["a JSON array", "[1,2]"], + ["a JSON scalar", '"agentkit"'], + ])("yields no declaration for %s", (_label, value) => { + expect(listingInputs({ compatibility_json: value })).toEqual({}); + }); +}); + +describe("summarizeRowRuntimes", () => { + it("enumerates exactly the declared runtimes", () => { + const summaries = summarizeRowRuntimes({ compatibility_json: DECLARED }); + expect(summaries.map((s) => `${s.runtime}:${s.status}`).sort()).toEqual([ + "agentkit:declared", + "claude-code:unsupported", + ]); + }); + + it("returns nothing for an undeclared listing", () => { + expect(summarizeRowRuntimes({ compatibility_json: null })).toEqual([]); + }); + + it("applies the target version when it names a declared runtime", () => { + const [outside] = summarizeRowRuntimes({ compatibility_json: DECLARED }, { + runtime: "agentkit", + version: "2.5.0", + }); + expect(outside?.status).toBe("blocked"); + expect(outside?.reasonCodes).toContain("COMPAT_RANGE_MISMATCH"); + }); + + it("still enumerates other runtimes when a target is supplied", () => { + const summaries = summarizeRowRuntimes({ compatibility_json: DECLARED }, { runtime: "agentkit" }); + expect(summaries).toHaveLength(2); + }); +}); + +describe("resolveRowCompatibility", () => { + it("resolves a declared runtime", () => { + const resolved = resolveRowCompatibility({ compatibility_json: DECLARED }, { runtime: "agentkit" }); + expect(resolved.status).toBe("declared"); + expect(resolved.versions).toBe(">=1.0.0 <2.0.0"); + }); + + it("never reports verified for a mutable listing", () => { + // There is no artifact digest on a listing, so nothing can promote it. + const resolved = resolveRowCompatibility({ compatibility_json: DECLARED }, { runtime: "agentkit" }); + expect(resolved.status).not.toBe("verified"); + }); + + it("is unknown with a machine-readable reason when nothing is declared", () => { + const resolved = resolveRowCompatibility({ compatibility_json: null }, { runtime: "agentkit" }); + expect(resolved.status).toBe("unknown"); + expect(resolved.reasons[0]?.code).toBe("COMPAT_DECLARATION_MISSING"); + }); +}); + +describe("summarizeRowRuntime", () => { + it("returns a summary only for a declared runtime", () => { + expect(summarizeRowRuntime({ compatibility_json: DECLARED }, { runtime: "agentkit" })?.status).toBe("declared"); + expect(summarizeRowRuntime({ compatibility_json: DECLARED }, { runtime: "codex" })).toBeNull(); + }); +}); + +describe("listingMatchesTarget", () => { + it("matches a declared runtime and refuses an unsupported one", () => { + expect(listingMatchesTarget({ compatibility_json: DECLARED }, { runtime: "agentkit" })).toBe(true); + expect(listingMatchesTarget({ compatibility_json: DECLARED }, { runtime: "claude-code" })).toBe(false); + }); + + it("does not treat an undeclared runtime as support", () => { + expect(listingMatchesTarget({ compatibility_json: null }, { runtime: "agentkit" })).toBe(false); + }); + + it("refuses a version outside the declared range", () => { + expect(listingMatchesTarget({ compatibility_json: DECLARED }, { runtime: "agentkit", version: "3.0.0" })).toBe(false); + }); +}); + +describe("parseCompatibilityTarget", () => { + it("reads a bare runtime", () => { + expect(parseCompatibilityTarget("agentkit")).toEqual({ runtime: "agentkit" }); + }); + + it("keeps the version, which the detail surface evaluates", () => { + expect(parseCompatibilityTarget("agentkit@1.4.0")).toEqual({ runtime: "agentkit", version: "1.4.0" }); + }); + + it.each(["", " ", null, undefined])("rejects %s", (value) => { + expect(parseCompatibilityTarget(value)).toBeNull(); + }); +}); diff --git a/apps/web/app/lib/compatibility/catalog-compatibility.ts b/apps/web/app/lib/compatibility/catalog-compatibility.ts new file mode 100644 index 0000000..93e479b --- /dev/null +++ b/apps/web/app/lib/compatibility/catalog-compatibility.ts @@ -0,0 +1,104 @@ +/** + * Listing-level compatibility adapter. + * + * A `skills` row is a mutable listing, so its declaration can reach `declared` + * but never `verified` — there is no artifact digest to bind evidence to. The + * precedence between a release declaration and a listing declaration is owned by + * `selectDeclaration` in `@skillx/contracts`; this module only loads the row and + * delegates, so web, CLI, MCP, and Markdown cannot drift apart. + * + * Release-backed declarations and evidence are read in Phase 2, when a listing + * can be linked to an immutable release. Until then `hasRelease` is always false + * and no evidence is passed, which is why every undeclared row is `unknown`. + */ + +import { + resolveCatalogCompatibility, + isCompatibilityActionable, + toCompatibilitySummary, + type CatalogCompatibilityInputs, + type CompatibilitySummary, + type CompatibilityTarget, + type NormalizedCompatibility, +} from "@skillx/contracts"; +import { parseDeclaration } from "./compatibility-service"; + +/** The compatibility inputs available on a mutable catalog listing. */ +export interface ListingCompatibilityRow { + compatibility_json?: string | null; +} + +/** + * Compatibility inputs for a listing, or `{}` when it declares nothing. + * + * Malformed JSON resolves to `undefined`, which the engine reports as `unknown`: + * a row that cannot be parsed must never be presented as supporting a runtime. + */ +export function listingInputs(row: ListingCompatibilityRow): CatalogCompatibilityInputs { + const declaration = parseDeclaration(row.compatibility_json); + return declaration ? { listingDeclaration: declaration } : {}; +} + +/** Resolves one target against a listing row. */ +export function resolveRowCompatibility( + row: ListingCompatibilityRow, + target: CompatibilityTarget, +): NormalizedCompatibility { + return resolveCatalogCompatibility(target, listingInputs(row)); +} + +/** + * Every runtime the listing declares, as compact summaries. + * + * When `target` names one of the declared runtimes, that runtime is resolved with + * the target's version so a version mismatch surfaces as `blocked` instead of a + * bare `declared`. + */ +export function summarizeRowRuntimes( + row: ListingCompatibilityRow, + target?: CompatibilityTarget, +): CompatibilitySummary[] { + const inputs = listingInputs(row); + return Object.keys(inputs.listingDeclaration ?? {}).map((runtime) => { + const effective: CompatibilityTarget = + target && target.runtime === runtime ? target : { runtime }; + return toCompatibilitySummary(resolveCatalogCompatibility(effective, inputs)); + }); +} + +/** Summary for one target, or `null` when the listing declares nothing at all. */ +export function summarizeRowRuntime( + row: ListingCompatibilityRow, + target: CompatibilityTarget, +): CompatibilitySummary | null { + const declared = Object.keys(parseDeclaration(row.compatibility_json) ?? {}); + if (!declared.includes(target.runtime)) return null; + return toCompatibilitySummary(resolveRowCompatibility(row, target)); +} + +/** + * Parses `runtime` or `runtime@version` from a query parameter. + * + * Unlike the search filter, the detail surface evaluates the version, so this + * keeps it. A version-aware answer for one package belongs to `check`/`inspect` + * rather than to a catalog-wide search filter. + */ +export function parseCompatibilityTarget(value: string | null | undefined): CompatibilityTarget | null { + const trimmed = value?.trim(); + if (!trimmed) return null; + const [runtime, version] = trimmed.split("@"); + if (!runtime) return null; + return version ? { runtime, version } : { runtime }; +} + +/** + * True when this listing may be acted on for the requested target. + * + * Only `declared` and `verified` count. `unknown` is absence of a claim, not a + * claim of support, so an agent filtering on this never installs something that + * a publisher never said would run. + */ +export function listingMatchesTarget(row: ListingCompatibilityRow, target: CompatibilityTarget): boolean { + const summary = summarizeRowRuntime(row, target); + return summary !== null && isCompatibilityActionable(summary.status); +} diff --git a/apps/web/app/lib/search/compatibility-filter.test.ts b/apps/web/app/lib/search/compatibility-filter.test.ts new file mode 100644 index 0000000..e054dea --- /dev/null +++ b/apps/web/app/lib/search/compatibility-filter.test.ts @@ -0,0 +1,148 @@ +/** + * The `--compatible` filter contract. + * + * The property under test is honesty: a filter that drops undecidable listings + * without saying so makes an agent believe the catalog is smaller than it is, and + * treating `unknown` as support would make it install something a publisher never + * said would run. + */ + +import { describe, expect, it } from "vitest"; +import type { CompatibilitySummary } from "@skillx/contracts"; +import { + applyCompatibilityFilter, + compatibilityPoolSize, + emptyFilterNote, + MAX_COMPATIBILITY_POOL, + parseCompatibilityFilter, +} from "./compatibility-filter"; +import type { SearchResult } from "./search-result-projection"; + +function result(slug: string, compatibility?: CompatibilitySummary[]): SearchResult { + const base = { slug }; + return (compatibility ? { ...base, compatibility } : base) as unknown as SearchResult; +} + +function summary(runtime: string, status: CompatibilitySummary["status"]): CompatibilitySummary { + return { runtime, status, versions: null, reasonCodes: [], verifiedAt: null, probeId: null }; +} + +const FILTER = { runtime: "agentkit" }; + +describe("applyCompatibilityFilter", () => { + it("keeps a listing that declares the runtime", () => { + const outcome = applyCompatibilityFilter([result("a", [summary("agentkit", "declared")])], FILTER, 20); + expect(outcome.results.map((r) => r.slug)).toEqual(["a"]); + expect(outcome.report.matched).toBe(1); + }); + + it("keeps a verified listing", () => { + const outcome = applyCompatibilityFilter([result("a", [summary("agentkit", "verified")])], FILTER, 20); + expect(outcome.report.matched).toBe(1); + }); + + it("counts a listing that declares nothing as undecided rather than dropping it silently", () => { + const outcome = applyCompatibilityFilter([result("bare")], FILTER, 20); + expect(outcome.results).toEqual([]); + expect(outcome.report).toMatchObject({ evaluated: 1, matched: 0, undecided: 1, refused: 0 }); + }); + + it("counts a listing that declares a different runtime as undecided", () => { + const outcome = applyCompatibilityFilter( + [result("a", [summary("claude-code", "declared")])], + FILTER, + 20, + ); + expect(outcome.report).toMatchObject({ undecided: 1, matched: 0 }); + }); + + it.each(["unsupported", "blocked"] as const)("refuses a %s declaration", (status) => { + const outcome = applyCompatibilityFilter([result("a", [summary("agentkit", status)])], FILTER, 20); + expect(outcome.results).toEqual([]); + expect(outcome.report).toMatchObject({ refused: 1, undecided: 0 }); + }); + + it("reports exact counts across a mixed page", () => { + const outcome = applyCompatibilityFilter( + [ + result("ok", [summary("agentkit", "declared")]), + result("bare"), + result("other", [summary("claude-code", "declared")]), + result("no", [summary("agentkit", "unsupported")]), + result("blocked", [summary("agentkit", "blocked")]), + ], + FILTER, + 20, + ); + expect(outcome.report).toEqual({ + runtime: "agentkit", + evaluated: 5, + matched: 1, + undecided: 2, + refused: 2, + poolExhausted: false, + }); + }); + + it("truncates matches to the requested limit but still reports every match found", () => { + const rows = Array.from({ length: 5 }, (_, i) => result(`s${i}`, [summary("agentkit", "declared")])); + const outcome = applyCompatibilityFilter(rows, FILTER, 2); + expect(outcome.results).toHaveLength(2); + expect(outcome.report.matched).toBe(5); + }); + + it("propagates poolExhausted so a caller knows matches may exist beyond the pool", () => { + const outcome = applyCompatibilityFilter([result("a", [summary("agentkit", "declared")])], FILTER, 20, true); + expect(outcome.report.poolExhausted).toBe(true); + }); +}); + +describe("compatibilityPoolSize", () => { + it("over-fetches so filtering still fills a page", () => { + expect(compatibilityPoolSize(20)).toBe(100); + expect(compatibilityPoolSize(5)).toBe(25); + }); + + it("never exceeds the hard cap", () => { + expect(compatibilityPoolSize(10_000)).toBe(MAX_COMPATIBILITY_POOL); + }); + + it("never returns less than the requested page", () => { + expect(compatibilityPoolSize(0)).toBe(0); + }); +}); + +describe("parseCompatibilityFilter", () => { + it("reads a bare runtime", () => { + expect(parseCompatibilityFilter("agentkit")).toEqual({ runtime: "agentkit" }); + }); + + it("tolerates a version without evaluating it here", () => { + // Version-aware answers come from the detail surface, not a catalog filter. + expect(parseCompatibilityFilter("agentkit@1.4.0")).toEqual({ runtime: "agentkit" }); + }); + + it("rejects empty and whitespace-only values", () => { + expect(parseCompatibilityFilter("")).toBeNull(); + expect(parseCompatibilityFilter(" ")).toBeNull(); + expect(parseCompatibilityFilter(null)).toBeNull(); + expect(parseCompatibilityFilter(undefined)).toBeNull(); + }); +}); + +describe("emptyFilterNote", () => { + it("states the counts and that a missing declaration is not support", () => { + const note = emptyFilterNote({ + runtime: "agentkit", + evaluated: 12, + matched: 0, + undecided: 10, + refused: 2, + poolExhausted: false, + }); + expect(note).toContain("agentkit"); + expect(note).toContain("10 declare nothing"); + expect(note).toContain("2 declare the runtime but refuse it"); + expect(note).toContain("is not support"); + }); +}); diff --git a/apps/web/app/lib/search/compatibility-filter.ts b/apps/web/app/lib/search/compatibility-filter.ts new file mode 100644 index 0000000..b791f9f --- /dev/null +++ b/apps/web/app/lib/search/compatibility-filter.ts @@ -0,0 +1,108 @@ +/** + * The `--compatible ` search filter. + * + * Compatibility lives in a JSON column that FTS5 and Vectorize cannot index, so + * the filter cannot run in the database. It runs over the projected page instead, + * and it reports what it did: a filter that silently drops undecidable rows would + * make an agent believe the catalog is smaller than it is. + * + * Only `declared` and `verified` count as a match. `unknown` is the absence of a + * claim, never a claim of support. + */ + +import { isCompatibilityActionable, type CompatibilityStatus } from "@skillx/contracts"; +import type { SearchResult } from "./search-result-projection"; + +export interface CompatibilityFilterRequest { + runtime: string; +} + +export interface CompatibilityFilterReport { + runtime: string; + /** Rows the filter examined before the page was truncated to the limit. */ + evaluated: number; + matched: number; + /** Rows that declare nothing for this runtime, so support is undecidable. */ + undecided: number; + /** Rows that declare the runtime but refuse it (unsupported or blocked). */ + refused: number; + /** The over-fetched pool filled up: further matches may exist beyond it. */ + poolExhausted: boolean; +} + +/** One filter run: the page to return plus the honest account of the filtering. */ +export interface CompatibilityFilterOutcome { + results: SearchResult[]; + report: CompatibilityFilterReport; +} + +/** How many candidates to pull before filtering, per requested page size. */ +const POOL_MULTIPLIER = 5; +/** Hard cap on the candidate pool so one filter call cannot scan the catalog. */ +export const MAX_COMPATIBILITY_POOL = 100; + +export function compatibilityPoolSize(limit: number): number { + return Math.min(Math.max(limit * POOL_MULTIPLIER, limit), MAX_COMPATIBILITY_POOL); +} + +function statusFor(result: SearchResult, runtime: string): CompatibilityStatus | null { + const entry = result.compatibility?.find((summary) => summary.runtime === runtime); + return entry ? entry.status : null; +} + +export function applyCompatibilityFilter( + results: SearchResult[], + request: CompatibilityFilterRequest, + limit: number, + poolExhausted = false, +): CompatibilityFilterOutcome { + const matched: SearchResult[] = []; + let undecided = 0; + let refused = 0; + + for (const result of results) { + const status = statusFor(result, request.runtime); + if (status === null) { + undecided += 1; + continue; + } + if (isCompatibilityActionable(status)) { + matched.push(result); + continue; + } + refused += 1; + } + + return { + results: matched.slice(0, limit), + report: { + runtime: request.runtime, + evaluated: results.length, + matched: matched.length, + undecided, + refused, + poolExhausted, + }, + }; +} + +/** Parses a `runtime` or `runtime@version` filter value. */ +export function parseCompatibilityFilter(value: string | null | undefined): CompatibilityFilterRequest | null { + const trimmed = value?.trim(); + if (!trimmed) return null; + // A version is not evaluated here: version-aware answers come from + // `skillx check --target`, which reads the detail payload for one package. + const [runtime] = trimmed.split("@"); + if (!runtime) return null; + return { runtime }; +} + +/** Human-readable reason a page came back empty despite the catalog matching. */ +export function emptyFilterNote(report: CompatibilityFilterReport): string { + return ( + `No listing declares support for '${report.runtime}'. ` + + `Examined ${report.evaluated} candidate(s): ${report.undecided} declare nothing, ` + + `${report.refused} declare the runtime but refuse it. ` + + "A missing declaration is not support, so these were not returned." + ); +} diff --git a/apps/web/app/lib/search/search-executor.ts b/apps/web/app/lib/search/search-executor.ts index 7b44785..ebd9167 100644 --- a/apps/web/app/lib/search/search-executor.ts +++ b/apps/web/app/lib/search/search-executor.ts @@ -5,6 +5,11 @@ * rule. Both the primary and the fallback path project rows through the public * catalog DTO and the protected content resolver, so neither can surface a * protected payload. + * + * The `--compatible` filter runs here rather than in the database: compatibility + * is a JSON column that FTS5 and Vectorize cannot index. When the filter is + * active the executor over-fetches a candidate pool, then reports exactly what it + * kept and what it could not decide. */ import { getDb } from "~/lib/db"; @@ -13,16 +18,38 @@ import type { SearchFilters } from "./hybrid-search"; import { fts5Search } from "./fts5-search"; import { fetchSkillRows, toSearchResult } from "./search-result-projection"; import type { SearchResult } from "./search-result-projection"; +import { + applyCompatibilityFilter, + compatibilityPoolSize, + emptyFilterNote, + MAX_COMPATIBILITY_POOL, + type CompatibilityFilterReport, + type CompatibilityFilterRequest, +} from "./compatibility-filter"; export interface SearchExecutionParams { query: string; filters: SearchFilters; userId?: string; limit: number; + /** When present, only listings that declare the runtime are returned. */ + compatible?: CompatibilityFilterRequest; +} + +export interface SearchOutcome { + results: SearchResult[]; + /** Present only when a compatibility filter ran. */ + compatibilityFilter?: CompatibilityFilterReport; + /** Present only when the filter returned nothing, to explain why. */ + note?: string; } -export async function executeSearch(env: Env, params: SearchExecutionParams): Promise { - const { query, filters, userId, limit } = params; +async function runSearch( + env: Env, + params: SearchExecutionParams, + poolLimit: number, +): Promise { + const { query, filters, userId } = params; const db = getDb(env.DB); try { @@ -34,12 +61,12 @@ export async function executeSearch(env: Env, params: SearchExecutionParams): Pr query, filters, userId || undefined, - limit, + poolLimit, ); } catch (vectorError) { console.error('Vectorize search failed, falling back to FTS5:', vectorError); - const fts5Results = await fts5Search(env.DB, query, limit, filters); + const fts5Results = await fts5Search(env.DB, query, poolLimit, filters); const rows = await fetchSkillRows(db, fts5Results.map((r) => r.skill_id)); return fts5Results.flatMap((result, index) => { @@ -61,3 +88,25 @@ export async function executeSearch(env: Env, params: SearchExecutionParams): Pr }); } } + +export async function executeSearch(env: Env, params: SearchExecutionParams): Promise { + const { compatible, limit } = params; + if (!compatible) { + return { results: await runSearch(env, params, limit) }; + } + + const poolLimit = compatibilityPoolSize(limit); + const candidates = await runSearch(env, params, poolLimit); + const outcome = applyCompatibilityFilter( + candidates, + compatible, + limit, + poolLimit >= MAX_COMPATIBILITY_POOL && candidates.length >= poolLimit, + ); + + return { + results: outcome.results, + compatibilityFilter: outcome.report, + ...(outcome.results.length === 0 ? { note: emptyFilterNote(outcome.report) } : {}), + }; +} diff --git a/apps/web/app/lib/search/search-result-projection.ts b/apps/web/app/lib/search/search-result-projection.ts index 0932c97..d079a8c 100644 --- a/apps/web/app/lib/search/search-result-projection.ts +++ b/apps/web/app/lib/search/search-result-projection.ts @@ -7,13 +7,14 @@ */ import { inArray } from "drizzle-orm"; -import type { PublicCatalogListingDto } from "@skillx/contracts"; +import type { CompatibilitySummary, PublicCatalogListingDto } from "@skillx/contracts"; import { skills } from "~/lib/db/schema"; import type { Database } from "~/lib/db"; import { toPublicCatalogListing } from "~/lib/catalog/public-dto"; import type { SkillCatalogRow } from "~/lib/catalog/public-dto"; import { buildCatalogListingResponse } from "~/lib/catalog/protected-content"; import type { CatalogListingResponse, PayloadViewer } from "~/lib/catalog/protected-content"; +import { summarizeRowRuntimes } from "~/lib/compatibility/catalog-compatibility"; /** A full catalog row, including the payload that must not be served directly. */ export interface SkillRow extends SkillCatalogRow { @@ -27,7 +28,15 @@ export interface SearchScores { keyword_rank: number | null; } -export type SearchResult = CatalogListingResponse & SearchScores; +export type SearchResult = CatalogListingResponse & + SearchScores & { + /** + * Declared runtime compatibility. Absent when the listing declares nothing, so + * an undeclared row stays small and an agent reads `unknown` from absence + * plus the filter report rather than from an empty per-row array. + */ + compatibility?: CompatibilitySummary[]; + }; export async function fetchSkillRows(db: Database, skillIds: string[]): Promise> { if (skillIds.length === 0) return new Map(); @@ -35,12 +44,21 @@ export async function fetchSkillRows(db: Database, skillIds: string[]): Promise< return new Map(rows.map((row) => [row.id, row])); } -export function toSearchResult(row: SkillRow, scores: SearchScores, viewer: PayloadViewer = {}): SearchResult { +export function toSearchResult( + row: SkillRow, + scores: SearchScores, + viewer: PayloadViewer = {}, +): SearchResult { const listing: PublicCatalogListingDto = toPublicCatalogListing(row); const response = buildCatalogListingResponse( listing, { slug: row.slug, is_paid: row.is_paid, content: row.content }, viewer, ); - return { ...response, ...scores }; + const compatibility = summarizeRowRuntimes(row); + return { + ...response, + ...scores, + ...(compatibility.length > 0 ? { compatibility } : {}), + }; } diff --git a/apps/web/app/routes/api.search.ts b/apps/web/app/routes/api.search.ts index 5ccd5f7..d8a8fc8 100644 --- a/apps/web/app/routes/api.search.ts +++ b/apps/web/app/routes/api.search.ts @@ -1,18 +1,20 @@ /** * Search API endpoint — supports both API key and session authentication. * - * POST /api/search with { query, category?, is_paid?, limit? } - * GET /api/search?q=... for the web UI. - * Returns { results, count } + * POST /api/search { query, category?, is_paid?, limit?, compatible? } + * GET /api/search?q=...&compatible=... for the web UI. + * Returns { results, count, compatibilityFilter?, note? } * - * Search execution (including the FTS5 fallback) lives in `search-executor.ts`, - * which projects every row through the public DTO and the protected content - * resolver. + * Search execution (including the FTS5 fallback and the `compatible` filter) + * lives in `search-executor.ts`, which projects every row through the public DTO + * and the protected content resolver. */ import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router'; import { authenticateRequest } from '~/lib/auth/authenticate-request'; import { executeSearch } from '~/lib/search/search-executor'; +import type { SearchOutcome } from '~/lib/search/search-executor'; +import { parseCompatibilityFilter } from '~/lib/search/compatibility-filter'; import type { SearchFilters } from '~/lib/search/hybrid-search'; interface SearchRequest { @@ -20,6 +22,8 @@ interface SearchRequest { category?: string; is_paid?: boolean; limit?: number; + /** Runtime name, optionally `runtime@version`; the version is not evaluated here. */ + compatible?: string; } const DEFAULT_LIMIT = 20; @@ -30,6 +34,22 @@ function clampLimit(value: number | undefined): number { return Math.min(requested, MAX_LIMIT); } +/** + * Serializes a search run. + * + * When the compatibility filter ran, its report travels with the page: a filter + * that hides undecidable listings without saying so would make an agent believe + * the catalog is smaller than it is. + */ +function searchResponse(outcome: SearchOutcome): Response { + return Response.json({ + results: outcome.results, + count: outcome.results.length, + ...(outcome.compatibilityFilter ? { compatibilityFilter: outcome.compatibilityFilter } : {}), + ...(outcome.note ? { note: outcome.note } : {}), + }); +} + function searchFailed(scope: string, error: unknown): Response { console.error(`${scope}:`, error); return Response.json( @@ -58,14 +78,23 @@ export async function action({ request, context }: ActionFunctionArgs) { ); } - const results = await executeSearch(env, { + if (body.compatible !== undefined && typeof body.compatible !== 'string') { + return Response.json( + { error: 'compatible must be a runtime name, optionally runtime@version' }, + { status: 400 }, + ); + } + + const compatible = parseCompatibilityFilter(body.compatible); + const outcome = await executeSearch(env, { query: body.query, filters: { category: body.category, is_paid: body.is_paid }, userId, limit: clampLimit(body.limit), + ...(compatible ? { compatible } : {}), }); - return Response.json({ results, count: results.length }); + return searchResponse(outcome); } catch (error) { return searchFailed('Search API error', error); } @@ -74,14 +103,15 @@ export async function action({ request, context }: ActionFunctionArgs) { /** GET handler for the web UI search page (supports ?q= query param). */ export async function loader({ request, context }: LoaderFunctionArgs) { const env = context.cloudflare.env as Env; - const url = new URL(request.url); - const query = url.searchParams.get('q'); - - if (!query) { - return Response.json({ results: [], count: 0 }); - } try { + const url = new URL(request.url); + const query = url.searchParams.get('q'); + + if (!query) { + return Response.json({ results: [], count: 0 }); + } + const userId = (await authenticateRequest(request, env))?.userId; const isPaidParam = url.searchParams.get('is_paid'); const filters: SearchFilters = { @@ -89,15 +119,17 @@ export async function loader({ request, context }: LoaderFunctionArgs) { is_paid: isPaidParam ? isPaidParam === 'true' : undefined, }; const requestedLimit = Number.parseInt(url.searchParams.get('limit') ?? '', 10); + const compatible = parseCompatibilityFilter(url.searchParams.get('compatible')); - const results = await executeSearch(env, { + const outcome = await executeSearch(env, { query, filters, userId, limit: clampLimit(Number.isNaN(requestedLimit) ? undefined : requestedLimit), + ...(compatible ? { compatible } : {}), }); - return Response.json({ results, count: results.length }); + return searchResponse(outcome); } catch (error) { return searchFailed('Search loader error', error); } diff --git a/apps/web/app/routes/api.skill-detail.ts b/apps/web/app/routes/api.skill-detail.ts index 2315f09..4c67203 100644 --- a/apps/web/app/routes/api.skill-detail.ts +++ b/apps/web/app/routes/api.skill-detail.ts @@ -6,6 +6,11 @@ import { fetchSkillReferences } from "~/lib/db/skill-detail-queries"; import { getSession } from "~/lib/auth/session-helpers"; import { scanContent, sanitizeContent } from "~/lib/security/content-scanner"; import { omitPayload, resolveSkillPayloadAccess } from "~/lib/catalog/protected-content"; +import { + parseCompatibilityTarget, + resolveRowCompatibility, + summarizeRowRuntimes, +} from "~/lib/compatibility/catalog-compatibility"; /** Detect stub content: short + ends with "## Author\n{author}" */ function isStubContent(content: string, author: string): boolean { @@ -126,6 +131,17 @@ export async function loader({ params, request, context }: LoaderFunctionArgs) { const skillMetadata = omitPayload(skill); const skillView = access.granted ? { ...skillMetadata, content: access.payload } : skillMetadata; + // Compatibility: every runtime the listing declares, plus a version-aware + // answer when the caller named a target. A listing has no artifact digest, so + // this can reach `declared` but never `verified`. + const compatTarget = parseCompatibilityTarget( + new URL(request.url).searchParams.get("target"), + ); + const compatibility = { + declared: summarizeRowRuntimes(skill), + ...(compatTarget ? { target: resolveRowCompatibility(skill, compatTarget) } : {}), + }; + return Response.json({ skill: skillView, reviews: skillReviews, @@ -136,6 +152,7 @@ export async function loader({ params, request, context }: LoaderFunctionArgs) { }, references: refs, scripts: parsedScripts, + compatibility, }); } catch (error) { console.error("Error fetching skill detail:", error); From 033f2d259c048fc8bd0f9828774801e49657df3f Mon Sep 17 00:00:00 2001 From: Goon Date: Tue, 22 Sep 2026 07:27:59 +0200 Subject: [PATCH 04/19] feat(cli): add inspect and check, plus a machine-readable envelope Agents drive this CLI, so the exit code is part of the interface: 0 when a target may be acted on, 4 when it may not, 2 for a bad invocation, 3 for a missing skill, 5 for credentials, 6 for the network. `--json` emits one envelope shape (`{ ok, data }` or `{ ok: false, error: { code, message, details?, hint? } }`) so a caller never parses human text. - `skillx inspect [--target runtime[@version]]` reports metadata, every declared runtime, the requirements, the evidence and the refusal reasons. - `skillx check --target ` is the gate, and its exit code is the answer. - `skillx search --compatible ` adds the filter column and prints what the filter did, so a short page is never a mystery. Identifier parsing and slug mapping move to `lib/identifier.ts` so `use`, `inspect` and `check` cannot disagree about which skill an identifier names. That also brings `use.ts` from 207 to 173 LOC, under the project's 200 LOC rule. Reads stay read-only: `inspect` and `check` never register or scan. --- packages/cli/src/commands/check.ts | 83 ++++++++++++++ packages/cli/src/commands/inspect.ts | 114 +++++++++++++++++++ packages/cli/src/commands/search.ts | 118 +++++++++++++------ packages/cli/src/commands/use.ts | 62 +++------- packages/cli/src/index.ts | 4 + packages/cli/src/lib/identifier.test.ts | 70 ++++++++++++ packages/cli/src/lib/identifier.ts | 81 +++++++++++++ packages/cli/src/lib/output.test.ts | 93 +++++++++++++++ packages/cli/src/lib/output.ts | 129 +++++++++++++++++++++ packages/cli/src/lib/search-api.ts | 58 +++++++++- packages/cli/src/lib/skill-lookup.test.ts | 75 ++++++++++++ packages/cli/src/lib/skill-lookup.ts | 133 ++++++++++++++++++++++ 12 files changed, 930 insertions(+), 90 deletions(-) create mode 100644 packages/cli/src/commands/check.ts create mode 100644 packages/cli/src/commands/inspect.ts create mode 100644 packages/cli/src/lib/identifier.test.ts create mode 100644 packages/cli/src/lib/identifier.ts create mode 100644 packages/cli/src/lib/output.test.ts create mode 100644 packages/cli/src/lib/output.ts create mode 100644 packages/cli/src/lib/skill-lookup.test.ts create mode 100644 packages/cli/src/lib/skill-lookup.ts diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts new file mode 100644 index 0000000..75c4e28 --- /dev/null +++ b/packages/cli/src/commands/check.ts @@ -0,0 +1,83 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { fetchSkillInspection, isActionable } from '../lib/skill-lookup.js'; +import { EXIT_CODES, exitCodeFor, failure, fromError, serialize, success } from '../lib/output.js'; + +interface CheckOptions { + target: string; + json?: boolean; +} + +/** + * `check` is a gate, so its exit code is the answer: 0 when the target may be + * acted on, 4 when it may not. A caller can branch on that without reading text. + */ +export const checkCommand = new Command('check') + .description('Decide whether a skill can run on a target, and print the reason') + .argument('', 'slug, author/skill, or org/repo/skill') + .requiredOption('--target ', 'runtime to evaluate, optionally runtime@version') + .option('--json', 'emit a machine-readable envelope instead of text') + .action(async (identifier: string, options: CheckOptions) => { + const useJson = options.json === true; + + try { + const { slug, inspection } = await fetchSkillInspection(identifier, options.target); + const answer = inspection.compatibility?.target; + + if (!answer) { + // The API returns no target answer only when the request never carried one. + const envelope = failure('unexpected_error', 'The API did not return a target answer.', { + details: { slug, target: options.target }, + }); + if (useJson) console.log(serialize(envelope)); + else console.error(chalk.red(`\n✗ ${envelope.error.message}`)); + process.exit(exitCodeFor(envelope)); + } + + const actionable = isActionable(answer.status); + const payload = { + slug, + target: { runtime: answer.runtime, version: options.target.includes('@') ? options.target.split('@')[1] : null }, + status: answer.status, + versions: answer.versions, + requirements: answer.requirements, + evidence: answer.evidence, + reasons: answer.reasons, + actionable, + }; + + if (useJson) { + const envelope = success(payload); + console.log(serialize(envelope)); + process.exit(actionable ? EXIT_CODES.ok : EXIT_CODES.incompatible); + } + + const paint = actionable ? chalk.green : chalk.red; + console.log(`\n${paint(actionable ? '✓' : '✗')} ${slug} on ${answer.runtime}: ${paint(answer.status)}`); + if (answer.versions) console.log(chalk.dim(` declared range ${answer.versions}`)); + for (const reason of answer.reasons) { + console.log(` ${chalk.bold(reason.code)} ${reason.message}`); + } + if (answer.evidence) { + console.log( + chalk.dim( + ` evidence ${answer.evidence.probeId} by ${answer.evidence.verifier} at ${answer.evidence.verifiedAt}`, + ), + ); + } + if (!actionable) { + console.log( + chalk.dim( + '\nOnly `declared` and `verified` mean the skill will run. A missing declaration is not support.', + ), + ); + } + console.log(); + process.exit(actionable ? EXIT_CODES.ok : EXIT_CODES.incompatible); + } catch (error) { + const envelope = fromError(error); + if (useJson) console.log(serialize(envelope)); + else console.error(chalk.red(`\n✗ ${envelope.error.message}`)); + process.exit(exitCodeFor(envelope)); + } + }); diff --git a/packages/cli/src/commands/inspect.ts b/packages/cli/src/commands/inspect.ts new file mode 100644 index 0000000..4eb6b2f --- /dev/null +++ b/packages/cli/src/commands/inspect.ts @@ -0,0 +1,114 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import ora from 'ora'; +import { fetchSkillInspection } from '../lib/skill-lookup.js'; +import type { SkillInspection } from '../lib/skill-lookup.js'; +import { exitCodeFor, fromError, serialize, success } from '../lib/output.js'; + +interface InspectOptions { + json?: boolean; + target?: string; +} + +const STATUS_COLOR: Record string> = { + verified: chalk.green, + declared: chalk.cyan, + unknown: chalk.yellow, + unsupported: chalk.red, + blocked: chalk.red, +}; + +function statusText(status: string, versions: string | null): string { + const paint = STATUS_COLOR[status] ?? chalk.white; + return `${paint(status)}${versions ? chalk.dim(` ${versions}`) : ''}`; +} + +function line(label: string, value: string | number | null | undefined): void { + if (value === null || value === undefined || value === '') return; + console.log(`${chalk.dim(label.padEnd(14))}${value}`); +} + +function render(inspection: SkillInspection, slug: string): void { + const skill = inspection.skill; + console.log(chalk.bold.green(`\n${skill.name}`)); + console.log(chalk.dim(` ${skill.author}/${skill.name} · ${slug}\n`)); + + line('Description', skill.description); + line('Category', skill.category); + line('Version', skill.version); + line('Rating', skill.avg_rating === null ? null : `${skill.avg_rating} (${skill.rating_count ?? 0})`); + line('Installs', skill.install_count); + line('Risk', skill.risk_label); + line('Source', skill.source_url); + line('Install', skill.install_command); + + console.log(chalk.bold('\nCompatibility')); + const declared = inspection.compatibility?.declared ?? []; + if (declared.length === 0) { + console.log(chalk.yellow(' No runtime is declared. An agent cannot assume support.')); + } else { + for (const entry of declared) { + console.log(` ${entry.runtime.padEnd(16)}${statusText(entry.status, entry.versions)}`); + } + } + + const target = inspection.compatibility?.target; + if (target) { + console.log(chalk.bold(`\nTarget ${target.runtime}`)); + line('Status', statusText(target.status, target.versions)); + if (target.scopes.length > 0) line('Scopes', target.scopes.join(', ')); + const required = target.requirements.filter((r) => !r.optional); + if (required.length > 0) line('Requires', required.map((r) => r.capability).join(', ')); + if (target.evidence) { + line('Evidence', `${target.evidence.probeId} by ${target.evidence.verifier} (${target.evidence.harness} ${target.evidence.harnessVersion})`); + line('Verified at', target.evidence.verifiedAt); + } else { + line('Evidence', chalk.yellow('none bound to this artifact')); + } + for (const reason of target.reasons) { + console.log(` ${chalk.dim('·')} ${chalk.bold(reason.code)} ${reason.message}`); + } + } + + const references = inspection.references ?? []; + const scripts = inspection.scripts ?? []; + + if (references.length > 0) { + console.log(chalk.bold(`\nReferences (${references.length})`)); + for (const ref of references) console.log(` ${ref.title}`); + } + if (scripts.length > 0) { + console.log(chalk.bold(`\nScripts (${scripts.length})`)); + for (const script of scripts) console.log(` ${script.name}`); + } + console.log(); +} + +export const inspectCommand = new Command('inspect') + .description("Inspect a skill's metadata, requirements, compatibility and evidence") + .argument('', 'slug, author/skill, or org/repo/skill') + .option('--target ', 'also answer for one specific runtime') + .option('--json', 'emit a machine-readable envelope instead of text') + .action(async (identifier: string, options: InspectOptions) => { + const useJson = options.json === true; + const spinner = useJson ? null : ora(`Inspecting ${identifier}...`).start(); + + try { + const { slug, inspection } = await fetchSkillInspection(identifier, options.target); + spinner?.stop(); + + const payload = { slug, skill: inspection.skill, compatibility: inspection.compatibility ?? { declared: [] } }; + if (useJson) { + const envelope = success(payload); + console.log(serialize(envelope)); + process.exit(exitCodeFor(envelope)); + } + render(inspection, slug); + } catch (error) { + spinner?.stop(); + const envelope = fromError(error); + if (useJson) console.log(serialize(envelope)); + else console.error(chalk.red(`\n✗ ${envelope.error.message}`)); + process.exit(exitCodeFor(envelope)); + } + }); diff --git a/packages/cli/src/commands/search.ts b/packages/cli/src/commands/search.ts index c022ffb..d8e5c90 100644 --- a/packages/cli/src/commands/search.ts +++ b/packages/cli/src/commands/search.ts @@ -1,70 +1,116 @@ import { Command } from 'commander'; import chalk from 'chalk'; import ora from 'ora'; -import { ApiError } from '../lib/api-client.js'; import { searchSkills } from '../lib/search-api.js'; +import type { CompatibilityFilterReport, SearchResult } from '../lib/search-api.js'; +import { exitCodeFor, fromError, serialize, success } from '../lib/output.js'; import { resolveAndUseSkill } from './use.js'; +interface SearchOptions { + use?: boolean; + compatible?: string; + json?: boolean; +} + +const STATUS_COLOR: Record string> = { + verified: chalk.green, + declared: chalk.cyan, + unknown: chalk.yellow, + unsupported: chalk.red, + blocked: chalk.red, +}; + +function compatibilityCell(skill: SearchResult, runtime: string): string { + const entry = skill.compatibility?.find((summary) => summary.runtime === runtime); + if (!entry) return chalk.dim('—'); + const paint = STATUS_COLOR[entry.status] ?? chalk.white; + return paint(entry.status); +} + +/** States plainly what the filter did, so a short page is never a mystery. */ +function printFilterReport(report: CompatibilityFilterReport): void { + console.log( + chalk.dim( + `\nCompatibility filter '${report.runtime}': ${report.matched} matched, ` + + `${report.undecided} declare nothing, ${report.refused} refuse it ` + + `(of ${report.evaluated} examined).`, + ), + ); + if (report.poolExhausted) { + console.log(chalk.yellow(' The candidate pool filled up; more matches may exist.')); + } +} + export const searchCommand = new Command('search') .description('Search for skills in the SkillX marketplace') .argument('', 'Search query') .option('-u, --use', 'Auto-pick the top result and show its details') - .action(async (query: string, options: { use?: boolean }) => { - const spinner = ora('Searching for skills...').start(); + .option('-c, --compatible ', 'only return skills that declare support for this runtime') + .option('--json', 'emit a machine-readable envelope instead of text') + .action(async (query: string, options: SearchOptions) => { + const useJson = options.json === true; + const spinner = useJson ? null : ora('Searching for skills...').start(); try { - const results = await searchSkills(query); - spinner.stop(); + const outcome = await searchSkills(query, { + ...(options.compatible ? { compatible: options.compatible } : {}), + }); + spinner?.stop(); + + if (useJson) { + const envelope = success({ + query, + results: outcome.results, + count: outcome.results.length, + ...(outcome.compatibilityFilter ? { compatibilityFilter: outcome.compatibilityFilter } : {}), + ...(outcome.note ? { note: outcome.note } : {}), + }); + console.log(serialize(envelope)); + process.exit(exitCodeFor(envelope)); + } - if (results.length === 0) { + if (outcome.results.length === 0) { console.log(chalk.yellow('\nNo skills found matching your query.')); - console.log(chalk.dim('Try different search terms or browse all skills at https://skillx.sh')); + if (outcome.note) console.log(chalk.dim(outcome.note)); + else console.log(chalk.dim('Try different search terms or browse all skills at https://skillx.sh')); + if (outcome.compatibilityFilter) printFilterReport(outcome.compatibilityFilter); return; } - // --use flag: auto-pick top result and show details if (options.use) { - const top = results[0]; + const top = outcome.results[0]; console.log(chalk.dim(`Top result for "${query}": ${chalk.cyan(`${top.author}/${top.name}`)}\n`)); await resolveAndUseSkill(`${top.author}/${top.name}`, { raw: false }); return; } - console.log(chalk.bold.green(`\n✓ Found ${results.length} skill(s)\n`)); - - const colWidths = { skill: 40, category: 15, rating: 8, description: 40 }; + console.log(chalk.bold.green(`\n✓ Found ${outcome.results.length} skill(s)\n`)); - console.log( - chalk.bold( - `${padRight('SKILL', colWidths.skill)} ${padRight('CATEGORY', colWidths.category)} ${padRight('RATING', colWidths.rating)} DESCRIPTION` - ) - ); - console.log(chalk.dim('─'.repeat(105))); + const runtime = options.compatible; + const header = `${padRight('SKILL', 40)} ${padRight('CATEGORY', 15)} ${padRight('RATING', 8)}${runtime ? ` ${padRight(runtime.toUpperCase(), 12)}` : ''} DESCRIPTION`; + console.log(chalk.bold(header)); + console.log(chalk.dim('─'.repeat(header.length))); - results.forEach((skill) => { + outcome.results.forEach((skill) => { const displayId = `${skill.author}/${skill.name}`; - const skillCol = chalk.cyan(padRight(displayId, colWidths.skill)); - const categoryCol = chalk.magenta(padRight(skill.category, colWidths.category)); const rating = skill.avg_rating ?? 0; - const ratingCol = chalk.yellow(padRight(`⭐ ${rating.toFixed(1)}`, colWidths.rating)); - const descCol = truncate(skill.description, colWidths.description); - - console.log(`${skillCol} ${categoryCol} ${ratingCol} ${descCol}`); + const cols = [ + chalk.cyan(padRight(displayId, 40)), + chalk.magenta(padRight(skill.category, 15)), + chalk.yellow(padRight(`⭐ ${rating.toFixed(1)}`, 8)), + ]; + if (runtime) cols.push(padRight(compatibilityCell(skill, runtime), 12)); + console.log(`${cols.join(' ')} ${truncate(skill.description, 40)}`); }); + if (outcome.compatibilityFilter) printFilterReport(outcome.compatibilityFilter); console.log(chalk.dim(`\nUse ${chalk.cyan('skillx use ')} to view and install a skill`)); } catch (error) { - spinner.stop(); - - if (error instanceof ApiError) { - console.error(chalk.red(`\n✗ API Error: ${error.message}`)); - } else if (error instanceof Error) { - console.error(chalk.red(`\n✗ Network Error: ${error.message}`)); - console.error(chalk.dim('Check your internet connection or try again later.')); - } else { - console.error(chalk.red('\n✗ An unexpected error occurred')); - } - process.exit(1); + spinner?.stop(); + const envelope = fromError(error); + if (useJson) console.log(serialize(envelope)); + else console.error(chalk.red(`\n✗ ${envelope.error.message}`)); + process.exit(exitCodeFor(envelope)); } }); diff --git a/packages/cli/src/commands/use.ts b/packages/cli/src/commands/use.ts index fe3a8a9..836ad20 100644 --- a/packages/cli/src/commands/use.ts +++ b/packages/cli/src/commands/use.ts @@ -3,6 +3,7 @@ import chalk from 'chalk'; import ora from 'ora'; import { apiRequest, ApiError } from '../lib/api-client.js'; import { searchSkills } from '../lib/search-api.js'; +import { planResolution } from '../lib/identifier.js'; import { displaySkill, type SkillDetails, @@ -18,63 +19,28 @@ interface RegisterResponse { skipped?: number; } -type IdentifierType = 'search' | 'three-part' | 'two-part' | 'slug'; - -interface ParsedIdentifier { - type: IdentifierType; - parts: string[]; -} - -/** Parse identifier into type + parts */ -export function parseIdentifier(input: string): ParsedIdentifier { - if (input.includes(' ')) return { type: 'search', parts: [input] }; - - const slashParts = input.split('/'); - if (slashParts.length === 3) return { type: 'three-part', parts: slashParts }; - if (slashParts.length === 2) return { type: 'two-part', parts: slashParts }; - return { type: 'slug', parts: [input] }; -} +// Identifier parsing and slug mapping live in `lib/identifier.ts`, so `use`, +// `inspect`, and `check` classify and resolve an identifier identically. Re-exported +// here because this module is `use`'s public surface. +export { parseIdentifier } from '../lib/identifier.js'; /** * Resolve identifier and use the skill. Exported for use by search --use. - * - * Resolution chain: - * - spaces → search mode - * - x/y/z (three-part) → DB lookup slug "x-z", fallback register from repo y - * - x/y (two-part) → DB lookup slug "x-y", fallback scan repo y for skills - * - single word → DB lookup, fallback search */ export async function resolveAndUseSkill( identifier: string, options: DisplayOptions, ): Promise { - const parsed = parseIdentifier(identifier); - - switch (parsed.type) { - case 'search': - return searchAndUse(parsed.parts[0], options); - - case 'three-part': { - const [org, repo, skillName] = parsed.parts; - const slug = `${org}-${skillName}`.toLowerCase(); - return resolveBySlug(slug, identifier, options, { - registerFallback: { owner: org, repo, skill_path: skillName }, - }); - } - - case 'two-part': { - const [author, skillName] = parsed.parts; - const slug = `${author}-${skillName}`.toLowerCase(); - return resolveBySlug(slug, identifier, options, { - registerFallback: { owner: author, repo: skillName, scan: true }, - }); - } + const plan = planResolution(identifier); - case 'slug': - return resolveBySlug(parsed.parts[0], identifier, options, { - searchFallback: true, - }); + if (plan.parsed.type === 'search') { + return searchAndUse(plan.parsed.parts[0], options); } + + return resolveBySlug(plan.slug ?? identifier, plan.displayId, options, { + ...(plan.registerFallback ? { registerFallback: plan.registerFallback } : {}), + ...(plan.searchFallback ? { searchFallback: true } : {}), + }); } /** Core resolution: DB lookup with fallback to register or search */ @@ -155,7 +121,7 @@ function handleRegisterResult( /** Search for a keyword and use the top result */ async function searchAndUse(query: string, options: DisplayOptions): Promise { const spinner = ora(`Searching for "${query}"...`).start(); - const results = await searchSkills(query); + const { results } = await searchSkills(query); spinner.stop(); if (results.length === 0) { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index a2a7664..a27c376 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,6 +3,8 @@ import { Command } from 'commander'; import { searchCommand } from './commands/search.js'; import { useCommand } from './commands/use.js'; import { findCommand } from './commands/find.js'; +import { inspectCommand } from './commands/inspect.js'; +import { checkCommand } from './commands/check.js'; import { reportCommand } from './commands/report.js'; import { configCommand } from './commands/config.js'; import { publishCommand } from './commands/publish.js'; @@ -17,6 +19,8 @@ program program.addCommand(searchCommand); program.addCommand(useCommand); program.addCommand(findCommand); +program.addCommand(inspectCommand); +program.addCommand(checkCommand); program.addCommand(reportCommand); program.addCommand(configCommand); program.addCommand(publishCommand); diff --git a/packages/cli/src/lib/identifier.test.ts b/packages/cli/src/lib/identifier.test.ts new file mode 100644 index 0000000..60ca99d --- /dev/null +++ b/packages/cli/src/lib/identifier.test.ts @@ -0,0 +1,70 @@ +/** + * Identifier mapping. + * + * These lock the slugs `use` already resolved, now that `use`, `inspect` and + * `check` share one implementation: a mapping change here would silently redirect + * every command at a different skill. + */ + +import { describe, expect, it } from "vitest"; +import { parseIdentifier, planResolution } from "./identifier.js"; + +describe("parseIdentifier", () => { + it("classifies space-containing input as search", () => { + expect(parseIdentifier("ui ux design")).toEqual({ type: "search", parts: ["ui ux design"] }); + }); + + it("classifies three-part slash input as three-part", () => { + expect(parseIdentifier("org/repo/skill")).toEqual({ + type: "three-part", + parts: ["org", "repo", "skill"], + }); + }); + + it("classifies two-part slash input as two-part", () => { + expect(parseIdentifier("author/skill")).toEqual({ type: "two-part", parts: ["author", "skill"] }); + }); + + it("classifies a bare word as slug", () => { + expect(parseIdentifier("find-skills")).toEqual({ type: "slug", parts: ["find-skills"] }); + }); +}); + +describe("planResolution", () => { + it("maps a three-part identifier to org-skill and registers the repo", () => { + const plan = planResolution("binhmuc/autobot-review/ui-ux-pro-max"); + expect(plan.slug).toBe("binhmuc-ui-ux-pro-max"); + expect(plan.registerFallback).toEqual({ + owner: "binhmuc", + repo: "autobot-review", + skill_path: "ui-ux-pro-max", + }); + }); + + it("maps a two-part identifier to author-skill and scans the repo", () => { + const plan = planResolution("vercel-labs/find-skills"); + expect(plan.slug).toBe("vercel-labs-find-skills"); + expect(plan.registerFallback).toEqual({ owner: "vercel-labs", repo: "find-skills", scan: true }); + }); + + it("lowercases the mapped slug", () => { + expect(planResolution("Vercel-Labs/Find-Skills").slug).toBe("vercel-labs-find-skills"); + }); + + it("treats a bare word as a direct slug with a search fallback", () => { + const plan = planResolution("find-skills"); + expect(plan.slug).toBe("find-skills"); + expect(plan.searchFallback).toBe(true); + expect(plan.registerFallback).toBeUndefined(); + }); + + it("has no slug in search mode", () => { + const plan = planResolution("ui ux design"); + expect(plan.slug).toBeNull(); + expect(plan.parsed.type).toBe("search"); + }); + + it("preserves the identifier as typed for display", () => { + expect(planResolution("Vercel-Labs/Find-Skills").displayId).toBe("Vercel-Labs/Find-Skills"); + }); +}); diff --git a/packages/cli/src/lib/identifier.ts b/packages/cli/src/lib/identifier.ts new file mode 100644 index 0000000..c1170e1 --- /dev/null +++ b/packages/cli/src/lib/identifier.ts @@ -0,0 +1,81 @@ +/** + * Skill identifier parsing and resolution mapping. + * + * Pure: no network, no filesystem, no I/O. `use`, `inspect`, and `check` all + * classify and map an identifier here, so a slug that resolves for one command + * resolves for every command, and the three cannot drift apart. + * + * Resolution chain: + * - spaces -> search mode + * - x/y/z -> DB slug "x-z", fallback: register repo y with skill_path z + * - x/y -> DB slug "x-y", fallback: scan repo y + * - single word -> DB slug as typed, fallback: search + */ + +export type IdentifierType = 'search' | 'three-part' | 'two-part' | 'slug'; + +export interface ParsedIdentifier { + type: IdentifierType; + parts: string[]; +} + +/** Parse identifier into type + parts. */ +export function parseIdentifier(input: string): ParsedIdentifier { + if (input.includes(' ')) return { type: 'search', parts: [input] }; + + const slashParts = input.split('/'); + if (slashParts.length === 3) return { type: 'three-part', parts: slashParts }; + if (slashParts.length === 2) return { type: 'two-part', parts: slashParts }; + return { type: 'slug', parts: [input] }; +} + +export interface RegisterFallback { + owner: string; + repo: string; + skill_path?: string; + scan?: boolean; +} + +/** How to resolve one identifier: which slug to try, and what to do on a miss. */ +export interface Resolution { + parsed: ParsedIdentifier; + /** The identifier as typed, used in messages and as the display id. */ + displayId: string; + /** Slug to look up, or `null` for search mode (there is no slug yet). */ + slug: string | null; + registerFallback?: RegisterFallback; + searchFallback?: boolean; +} + +export function planResolution(input: string): Resolution { + const parsed = parseIdentifier(input); + const displayId = input; + + switch (parsed.type) { + case 'search': + return { parsed, displayId, slug: null }; + + case 'three-part': { + const [org, repo, skillName] = parsed.parts; + return { + parsed, + displayId, + slug: `${org}-${skillName}`.toLowerCase(), + registerFallback: { owner: org, repo, skill_path: skillName }, + }; + } + + case 'two-part': { + const [author, skillName] = parsed.parts; + return { + parsed, + displayId, + slug: `${author}-${skillName}`.toLowerCase(), + registerFallback: { owner: author, repo: skillName, scan: true }, + }; + } + + default: + return { parsed, displayId, slug: parsed.parts[0], searchFallback: true }; + } +} diff --git a/packages/cli/src/lib/output.test.ts b/packages/cli/src/lib/output.test.ts new file mode 100644 index 0000000..b04ff8c --- /dev/null +++ b/packages/cli/src/lib/output.test.ts @@ -0,0 +1,93 @@ +/** + * The CLI result envelope and its exit codes. + * + * An agent branches on the exit code, so the codes have to stay distinguishable + * and stay documented. The four cases below are the ones a caller makes different + * decisions about: the skill is absent, the credentials are wrong, the network is + * down, or the target is not compatible. + */ + +import { describe, expect, it } from "vitest"; +import { ApiError } from "./api-client.js"; +import { + EXIT_CODES, + exitCodeFor, + failure, + fromError, + serialize, + success, +} from "./output.js"; + +describe("exit codes", () => { + it("uses distinct codes for decisions a caller makes differently", () => { + const codes = Object.values(EXIT_CODES); + expect(new Set(codes).size).toBe(codes.length); + }); + + it("is 0 for success", () => { + expect(exitCodeFor(success({ anything: true }))).toBe(EXIT_CODES.ok); + }); + + it("is 4 for an incompatible target, which is not a generic failure", () => { + expect(exitCodeFor(failure("incompatible", "no"))).toBe(EXIT_CODES.incompatible); + }); + + it("is 2 for a usage error and 3 for a missing skill", () => { + expect(exitCodeFor(failure("usage_error", "no"))).toBe(EXIT_CODES.usage); + expect(exitCodeFor(failure("not_found", "no"))).toBe(EXIT_CODES.notFound); + }); +}); + +describe("fromError", () => { + it("maps a 404 to not_found", () => { + const envelope = fromError(new ApiError(404, "API error 404: {}")); + expect(envelope.error.code).toBe("not_found"); + expect(exitCodeFor(envelope)).toBe(EXIT_CODES.notFound); + }); + + it.each([401, 403])("maps %s to auth_error with a hint", (status) => { + const envelope = fromError(new ApiError(status, "denied")); + expect(envelope.error.code).toBe("auth_error"); + expect(envelope.error.hint).toContain("apiKey"); + expect(exitCodeFor(envelope)).toBe(EXIT_CODES.auth); + }); + + it("maps a fetch TypeError to network_error", () => { + const envelope = fromError(new TypeError("fetch failed")); + expect(envelope.error.code).toBe("network_error"); + expect(exitCodeFor(envelope)).toBe(EXIT_CODES.network); + }); + + it("maps any other API status to api_error", () => { + expect(fromError(new ApiError(500, "boom")).error.code).toBe("api_error"); + }); + + it("maps an unknown throw to unexpected_error without losing the message", () => { + const envelope = fromError(new Error("something odd")); + expect(envelope.error.code).toBe("unexpected_error"); + expect(envelope.error.message).toBe("something odd"); + }); + + it("survives a non-Error throw", () => { + expect(fromError("plain string").error.code).toBe("unexpected_error"); + }); +}); + +describe("serialize", () => { + it("emits an envelope a program can parse", () => { + const parsed = JSON.parse(serialize(success({ count: 2 }))); + expect(parsed).toEqual({ ok: true, data: { count: 2 } }); + }); + + it("keeps details and hint out of the envelope when they were not supplied", () => { + const parsed = JSON.parse(serialize(failure("api_error", "boom"))); + expect(parsed).toEqual({ ok: false, error: { code: "api_error", message: "boom" } }); + }); + + it("carries machine-readable details when present", () => { + const parsed = JSON.parse( + serialize(failure("incompatible", "blocked", { details: { reasonCodes: ["COMPAT_RANGE_MISMATCH"] } })), + ); + expect(parsed.error.details.reasonCodes).toEqual(["COMPAT_RANGE_MISMATCH"]); + }); +}); diff --git a/packages/cli/src/lib/output.ts b/packages/cli/src/lib/output.ts new file mode 100644 index 0000000..ea7f6f0 --- /dev/null +++ b/packages/cli/src/lib/output.ts @@ -0,0 +1,129 @@ +/** + * CLI result envelope and exit codes. + * + * Agents drive this CLI, so every command has to answer in a shape a program can + * read: one envelope, one error vocabulary, and an exit code that distinguishes + * "you called me wrong" from "the skill is not compatible" from "the network was + * down". A caller must be able to branch without parsing human text. + * + * Human-readable output stays the default; `--json` selects the envelope. + */ + +import { ApiError } from './api-client.js'; + +export const EXIT_CODES = { + ok: 0, + /** Unexpected failure, or a failure the API reported without a better class. */ + error: 1, + /** The command was called with invalid input. */ + usage: 2, + /** The skill does not exist. */ + notFound: 3, + /** The skill exists but cannot run on the requested target. */ + incompatible: 4, + /** Credentials are missing or rejected. */ + auth: 5, + /** The API could not be reached. */ + network: 6, +} as const; + +export type ExitCode = (typeof EXIT_CODES)[keyof typeof EXIT_CODES]; + +export type CliErrorCode = + | 'usage_error' + | 'not_found' + | 'incompatible' + | 'auth_error' + | 'network_error' + | 'api_error' + | 'unexpected_error'; + +const EXIT_BY_CODE: Record = { + usage_error: EXIT_CODES.usage, + not_found: EXIT_CODES.notFound, + incompatible: EXIT_CODES.incompatible, + auth_error: EXIT_CODES.auth, + network_error: EXIT_CODES.network, + api_error: EXIT_CODES.error, + unexpected_error: EXIT_CODES.error, +}; + +export interface CliSuccess { + ok: true; + data: T; +} + +export interface CliFailure { + ok: false; + error: { + code: CliErrorCode; + message: string; + /** Machine-readable context, e.g. the failing target or a reason code. */ + details?: unknown; + /** What the caller should do next, when there is a concrete action. */ + hint?: string; + }; +} + +export type CliEnvelope = CliSuccess | CliFailure; + +export function success(data: T): CliSuccess { + return { ok: true, data }; +} + +export function failure( + code: CliErrorCode, + message: string, + extra: { details?: unknown; hint?: string } = {}, +): CliFailure { + return { + ok: false, + error: { + code, + message, + ...(extra.details !== undefined ? { details: extra.details } : {}), + ...(extra.hint ? { hint: extra.hint } : {}), + }, + }; +} + +export function exitCodeFor(envelope: CliEnvelope): ExitCode { + return envelope.ok ? EXIT_CODES.ok : EXIT_BY_CODE[envelope.error.code]; +} + +/** + * Maps a thrown error onto the envelope vocabulary. + * + * A 404 is "the skill does not exist" and a 401/403 is "your credentials are + * wrong"; both are decisions a caller makes differently, so they do not share a + * code with a generic API failure. + */ +export function fromError(error: unknown): CliFailure { + if (error instanceof ApiError) { + if (error.status === 404) { + return failure('not_found', 'The requested skill was not found.', { details: { status: 404 } }); + } + if (error.status === 401 || error.status === 403) { + return failure('auth_error', 'The API rejected this request as unauthenticated.', { + details: { status: error.status }, + hint: 'Run `skillx config set apiKey ` or check the key has not been revoked.', + }); + } + return failure('api_error', error.message, { details: { status: error.status } }); + } + + if (error instanceof TypeError) { + // `fetch` rejects with TypeError when the host cannot be reached. + return failure('network_error', error.message, { + hint: 'Check the network connection and the configured base URL.', + }); + } + + if (error instanceof Error) return failure('unexpected_error', error.message); + return failure('unexpected_error', 'An unknown error occurred.'); +} + +/** Serializes an envelope for stdout. */ +export function serialize(envelope: CliEnvelope): string { + return JSON.stringify(envelope, null, 2); +} diff --git a/packages/cli/src/lib/search-api.ts b/packages/cli/src/lib/search-api.ts index 3edc44f..f34c11e 100644 --- a/packages/cli/src/lib/search-api.ts +++ b/packages/cli/src/lib/search-api.ts @@ -1,5 +1,14 @@ import { apiRequest } from './api-client.js'; +export interface CompatibilitySummary { + runtime: string; + status: string; + versions: string | null; + reasonCodes: string[]; + verifiedAt: string | null; + probeId: string | null; +} + export interface SearchResult { slug: string; name: string; @@ -7,18 +16,55 @@ export interface SearchResult { category: string; avg_rating: number | null; description: string; + compatibility?: CompatibilitySummary[]; } -interface SearchResponse { +/** + * What a `--compatible` run did, so a caller never has to guess why the page is + * short. `undecided` counts listings that declare nothing, which is not support. + */ +export interface CompatibilityFilterReport { + runtime: string; + evaluated: number; + matched: number; + undecided: number; + refused: number; + poolExhausted: boolean; +} + +export interface SearchOutcome { results: SearchResult[]; - count: number; + compatibilityFilter?: CompatibilityFilterReport; + note?: string; +} + +interface SearchResponse { + results?: SearchResult[]; + count?: number; + compatibilityFilter?: CompatibilityFilterReport; + note?: string; +} + +export interface SearchOptions { + /** Runtime name; `runtime@version` is accepted and the version is not evaluated. */ + compatible?: string; + limit?: number; } -/** Search the SkillX API and return results */ -export async function searchSkills(query: string): Promise { +/** Search the SkillX API and return the page plus any filter report. */ +export async function searchSkills(query: string, options: SearchOptions = {}): Promise { + const body: Record = { query }; + if (options.compatible) body.compatible = options.compatible; + if (options.limit) body.limit = options.limit; + const response = await apiRequest('/api/search', { method: 'POST', - body: JSON.stringify({ query }), + body: JSON.stringify(body), }); - return response.results || []; + + return { + results: response.results ?? [], + ...(response.compatibilityFilter ? { compatibilityFilter: response.compatibilityFilter } : {}), + ...(response.note ? { note: response.note } : {}), + }; } diff --git a/packages/cli/src/lib/skill-lookup.test.ts b/packages/cli/src/lib/skill-lookup.test.ts new file mode 100644 index 0000000..ef3e008 --- /dev/null +++ b/packages/cli/src/lib/skill-lookup.test.ts @@ -0,0 +1,75 @@ +/** + * Read-only skill lookup. + * + * Two properties matter: the slug queried is the same one `use` resolves, and the + * `--target` value reaches the API so the version-aware answer is the server's, + * not one the CLI invents. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const h = vi.hoisted(() => ({ calls: [] as string[] })); + +vi.mock("./api-client.js", () => ({ + apiRequest: async (endpoint: string) => { + h.calls.push(endpoint); + return { skill: { slug: "x", name: "X" } }; + }, +})); + +import { fetchSkillInspection, isActionable } from "./skill-lookup.js"; + +beforeEach(() => { + h.calls.length = 0; +}); + +describe("fetchSkillInspection", () => { + it("queries the mapped slug for a two-part identifier", async () => { + const result = await fetchSkillInspection("vercel-labs/find-skills"); + expect(h.calls).toEqual(["/api/skills/vercel-labs-find-skills"]); + expect(result.slug).toBe("vercel-labs-find-skills"); + }); + + it("uses the org-skill slug for a three-part identifier", async () => { + await fetchSkillInspection("org/repo/skill"); + expect(h.calls).toEqual(["/api/skills/org-skill"]); + }); + + it("passes a bare target through", async () => { + await fetchSkillInspection("find-skills", "agentkit"); + expect(h.calls).toEqual(["/api/skills/find-skills?target=agentkit"]); + }); + + it("url-encodes a versioned target", async () => { + await fetchSkillInspection("find-skills", "agentkit@1.4.0"); + expect(h.calls).toEqual(["/api/skills/find-skills?target=agentkit%401.4.0"]); + }); + + it("omits the query string when no target was asked for", async () => { + await fetchSkillInspection("find-skills"); + expect(h.calls[0]).not.toContain("?"); + }); + + it("refuses a search phrase instead of guessing a slug", async () => { + await expect(fetchSkillInspection("ui ux design")).rejects.toThrow(/search phrase/); + expect(h.calls).toEqual([]); + }); +}); + +describe("isActionable", () => { + it("permits action only for declared and verified", () => { + expect(isActionable("declared")).toBe(true); + expect(isActionable("verified")).toBe(true); + }); + + it("never permits action for an undecidable or refused answer", () => { + expect(isActionable("unknown")).toBe(false); + expect(isActionable("unsupported")).toBe(false); + expect(isActionable("blocked")).toBe(false); + }); + + it("treats a missing answer as not actionable", () => { + expect(isActionable(null)).toBe(false); + expect(isActionable(undefined)).toBe(false); + }); +}); diff --git a/packages/cli/src/lib/skill-lookup.ts b/packages/cli/src/lib/skill-lookup.ts new file mode 100644 index 0000000..a77407f --- /dev/null +++ b/packages/cli/src/lib/skill-lookup.ts @@ -0,0 +1,133 @@ +/** + * Read-only skill lookup shared by `inspect` and `check`. + * + * Both commands answer questions about a skill that already exists, so neither + * registers or scans anything: a read must not mutate the catalog. Resolution + * uses the same identifier mapping as `use`, so a slug that `use` finds is the + * slug `inspect` reports. + * + * The compatibility shapes here mirror the API's JSON. They are declared locally + * because this package is a thin HTTP client and does not depend on + * `@skillx/contracts`; a contract test guards the two from drifting. + */ + +import { apiRequest } from './api-client.js'; +import { planResolution } from './identifier.js'; +import type { SkillReference, SkillScript } from '../commands/use-display.js'; + +export interface CompatibilityRequirement { + capability: string; + optional?: boolean; +} + +export interface CompatibilityEvidence { + harness: string; + harnessVersion: string; + probeId: string; + verifiedAt: string; + verifier: string; + os?: string; +} + +export interface CompatibilityReason { + code: string; + message: string; + detail?: string; +} + +/** The version-aware answer for one requested target. */ +export interface CompatibilityAnswer { + runtime: string; + status: string; + versions: string | null; + scopes: string[]; + requirements: CompatibilityRequirement[]; + evidence: CompatibilityEvidence | null; + reasons: CompatibilityReason[]; +} + +/** Compact per-runtime summary carried on listing payloads. */ +export interface CompatibilitySummary { + runtime: string; + status: string; + versions: string | null; + reasonCodes: string[]; + verifiedAt: string | null; + probeId: string | null; +} + +export interface SkillCompatibility { + declared: CompatibilitySummary[]; + target?: CompatibilityAnswer; +} + +/** + * The public detail payload. + * + * These are the fields the API returns after the protected boundary runs: the + * boundary drops `content` and nothing else, so the metadata below is always + * present and `content` is present only when the viewer may read it. + */ +export interface SkillMetadata { + slug: string; + name: string; + description: string; + author: string; + category: string; + version: string | null; + source_url: string | null; + install_command: string | null; + risk_label: string | null; + is_paid: boolean | null; + price_cents: number | null; + avg_rating: number | null; + rating_count: number | null; + install_count: number | null; + favorite_count: number | null; + net_votes: number | null; + updated_at: string | number | null; + content?: string; +} + +export interface SkillInspection { + skill: SkillMetadata; + references?: SkillReference[]; + scripts?: SkillScript[]; + compatibility?: SkillCompatibility; +} + +export interface InspectionResult { + /** The slug actually queried, so a caller can log what it asked for. */ + slug: string; + inspection: SkillInspection; +} + +/** + * Fetches one skill's public detail, optionally asking for a target answer. + * + * Only `declared` and `verified` mean a skill will run. `unknown` is the absence + * of a declaration, which is why callers must read the reason codes rather than + * treat a missing entry as permission. + */ +export async function fetchSkillInspection( + identifier: string, + target?: string, +): Promise { + const plan = planResolution(identifier); + + if (!plan.slug) { + throw new Error( + `"${identifier}" looks like a search phrase. Pass a slug, author/skill, or org/repo/skill.`, + ); + } + + const query = target ? `?target=${encodeURIComponent(target)}` : ''; + const inspection = await apiRequest(`/api/skills/${plan.slug}${query}`); + + return { slug: plan.slug, inspection }; +} + +/** True when the answer permits action without further evidence. */ +export function isActionable(status: string | null | undefined): boolean { + return status === 'declared' || status === 'verified'; +} From aec3d1b1a5ba6624b60a5994083456014f080aba Mon Sep 17 00:00:00 2001 From: Goon Date: Tue, 22 Sep 2026 07:29:20 +0200 Subject: [PATCH 05/19] feat(web): show compatibility with its evidence on the skill page A badge alone cannot tell a reader whether anyone ran the skill on the artifact they are about to install, so the panel separates the two tiers. `verified` names the probe and the date it was recorded against this artifact; `declared` says plainly that no probe exists. An undeclared listing renders an explicit warning instead of no badge. Silence would read as support, and this catalog declares nothing today, so that is the state almost every page is in. The loader test pins the wiring, including that adding compatibility did not weaken the protected payload boundary: a free listing still carries content and a protected one still does not. --- .../app/components/compatibility-panel.tsx | 123 ++++++++++++++++++ .../app/lib/catalog/skill-detail-data.test.ts | 97 ++++++++++++++ apps/web/app/lib/catalog/skill-detail-data.ts | 8 ++ apps/web/app/routes/skill-detail.tsx | 6 + 4 files changed, 234 insertions(+) create mode 100644 apps/web/app/components/compatibility-panel.tsx create mode 100644 apps/web/app/lib/catalog/skill-detail-data.test.ts diff --git a/apps/web/app/components/compatibility-panel.tsx b/apps/web/app/components/compatibility-panel.tsx new file mode 100644 index 0000000..648fa6a --- /dev/null +++ b/apps/web/app/components/compatibility-panel.tsx @@ -0,0 +1,123 @@ +/** + * Compatibility badge and its evidence detail. + * + * The badge deliberately distinguishes `declared` from `verified`: a publisher + * saying a skill works is not the same as someone having probed that exact + * artifact. `unknown` is shown as an explicit state rather than hidden, because a + * visitor who sees no badge would otherwise assume support. + * + * Tokens: dark-only theme, mint accent, `bg-slate-900` surfaces (see + * docs/design-guidelines.md). + */ + +import { CheckCircle2, AlertTriangle, Ban, HelpCircle, FlaskConical } from "lucide-react"; +import type { CompatibilitySummary } from "@skillx/contracts"; + +interface BadgeTone { + label: string; + className: string; + Icon: typeof CheckCircle2; +} + +const TONES: Record = { + verified: { + label: "Verified", + className: "border-mint/40 bg-mint/10 text-mint", + Icon: FlaskConical, + }, + declared: { + label: "Declared", + className: "border-sky-400/40 bg-sky-400/10 text-sky-300", + Icon: CheckCircle2, + }, + unknown: { + label: "Unknown", + className: "border-amber-400/30 bg-amber-400/10 text-amber-300", + Icon: HelpCircle, + }, + unsupported: { + label: "Unsupported", + className: "border-red-400/30 bg-red-400/10 text-red-300", + Icon: Ban, + }, + blocked: { + label: "Blocked", + className: "border-red-400/30 bg-red-400/10 text-red-300", + Icon: AlertTriangle, + }, +}; + +const FALLBACK: BadgeTone = TONES.unknown; + +export function CompatibilityBadge({ summary }: { summary: CompatibilitySummary }) { + const tone = TONES[summary.status] ?? FALLBACK; + const { Icon } = tone; + + return ( + + + ); +} + +/** + * The panel under the badges. + * + * It states what each tier means, because a badge alone cannot tell a reader + * whether anyone actually ran the thing on the artifact they are about to install. + */ +export function CompatibilityPanel({ summaries }: { summaries: CompatibilitySummary[] }) { + if (summaries.length === 0) { + return ( +
+

Compatibility not declared

+

+ The publisher has not declared which agent runtimes this skill supports. That is not a claim + that it works everywhere — an agent should treat it as unknown until evidence exists. +

+
+ ); + } + + return ( +
+

Compatibility

+
+ {summaries.map((summary) => ( + + ))} +
+
+ {summaries.map((summary) => ( +
+
{summary.runtime}
+
+ {summary.verifiedAt ? ( + <> + Verified against this artifact on{" "} + + {summary.probeId ? <> · probe {summary.probeId} : null} + + ) : ( + <> + Declared by the publisher. No probe of this exact artifact is recorded + {summary.reasonCodes.length > 0 ? ( + <> · {summary.reasonCodes.join(", ")} + ) : null} + + )} +
+
+ ))} +
+
+ ); +} diff --git a/apps/web/app/lib/catalog/skill-detail-data.test.ts b/apps/web/app/lib/catalog/skill-detail-data.test.ts new file mode 100644 index 0000000..430cae7 --- /dev/null +++ b/apps/web/app/lib/catalog/skill-detail-data.test.ts @@ -0,0 +1,97 @@ +/** + * The skill detail loader's compatibility contract. + * + * The badge is only as honest as the data behind it, so this pins the wiring: + * a declared listing reaches the page, an undeclared one reports nothing rather + * than an implied "works everywhere", and malformed JSON does not throw a page. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const h = vi.hoisted(() => ({ row: null as Record | null })); + +vi.mock("~/lib/db/skill-detail-queries", () => ({ + fetchSkillBySlug: async () => h.row, + fetchSkillReferences: async () => [], + fetchSkillReviews: async () => [], + fetchRatingSummary: async () => ({ avgRating: 0, ratingCount: 0 }), + fetchRatingBreakdown: async () => ({ humanCount: 0, agentCount: 0 }), + fetchFavoriteCount: async () => 0, + fetchUsageStats: async () => ({ successRate: 0, totalUsages: 0, modelBreakdown: [] }), + fetchUserSkillData: async () => ({ isFavorited: false, userRating: null }), +})); + +vi.mock("~/lib/auth/session-helpers", () => ({ getSession: async () => null })); + +import { loadSkillDetailData } from "./skill-detail-data"; + +const DECLARED = JSON.stringify({ + agentkit: { status: "declared", versions: ">=1.0.0 <2.0.0" }, + "claude-code": { status: "unsupported" }, +}); + +function skillRow(overrides: Record = {}) { + return { + id: "skill_1", + slug: "find-skills", + name: "Find Skills", + description: "Discovery helper", + author: "vercel", + category: "discovery", + version: "1.0.0", + content: "# Find Skills\n\nBody.", + is_paid: false, + price_cents: 0, + risk_label: "safe", + install_count: 0, + avg_rating: 0, + rating_count: 0, + favorite_count: 0, + net_votes: 0, + install_command: "skillx use find-skills", + source_url: null, + scripts: null, + compatibility_json: DECLARED, + created_at: new Date(0), + updated_at: new Date(0), + ...overrides, + }; +} + +function load() { + return loadSkillDetailData({} as never, {} as never, new Request("https://skillx.sh/skills/x"), "find-skills"); +} + +beforeEach(() => { + h.row = skillRow(); +}); + +describe("loadSkillDetailData compatibility", () => { + it("carries every declared runtime to the page", async () => { + const data = await load(); + expect(data.compatibility.map((s) => `${s.runtime}:${s.status}`).sort()).toEqual([ + "agentkit:declared", + "claude-code:unsupported", + ]); + }); + + it("reports nothing declared rather than implying support", async () => { + h.row = skillRow({ compatibility_json: null }); + expect((await load()).compatibility).toEqual([]); + }); + + it("survives malformed declaration JSON without failing the page", async () => { + h.row = skillRow({ compatibility_json: "{not json" }); + expect((await load()).compatibility).toEqual([]); + }); + + it("keeps the protected payload boundary intact while adding compatibility", async () => { + // Free listing: payload present. The compatibility wiring must not change that. + expect((await load()).skill.content).toBe("# Find Skills\n\nBody."); + }); + + it("still strips the payload from a protected listing", async () => { + h.row = skillRow({ is_paid: true, price_cents: 1500 }); + expect((await load()).skill).not.toHaveProperty("content"); + }); +}); diff --git a/apps/web/app/lib/catalog/skill-detail-data.ts b/apps/web/app/lib/catalog/skill-detail-data.ts index 86f2645..a8476aa 100644 --- a/apps/web/app/lib/catalog/skill-detail-data.ts +++ b/apps/web/app/lib/catalog/skill-detail-data.ts @@ -6,6 +6,7 @@ * resolver is applied here, so no route may forward `skills.content` directly. */ +import type { CompatibilitySummary } from "@skillx/contracts"; import { getSession } from "~/lib/auth/session-helpers"; import { fetchSkillBySlug, @@ -19,6 +20,7 @@ import { } from "~/lib/db/skill-detail-queries"; import type { Database } from "~/lib/db"; import { gateSkillRow } from "./protected-content"; +import { summarizeRowRuntimes } from "~/lib/compatibility/catalog-compatibility"; type SkillRow = NonNullable>>; @@ -61,6 +63,11 @@ export interface SkillDetailData { usage: Awaited>; references: Awaited>; scripts: StoredScript[]; + /** + * Every runtime the listing declares. Empty when it declares none, which an + * agent must read as "unknown", never as "works everywhere". + */ + compatibility: CompatibilitySummary[]; } export async function loadSkillDetailData( @@ -102,5 +109,6 @@ export async function loadSkillDetailData( usage, references, scripts: parseStoredScripts(skill.scripts), + compatibility: summarizeRowRuntimes(skill), }; } diff --git a/apps/web/app/routes/skill-detail.tsx b/apps/web/app/routes/skill-detail.tsx index 8820326..2b4b9fc 100644 --- a/apps/web/app/routes/skill-detail.tsx +++ b/apps/web/app/routes/skill-detail.tsx @@ -12,6 +12,7 @@ import { getDb } from "~/lib/db"; import { loadSkillDetailData } from "~/lib/catalog/skill-detail-data"; import { SkillReferencesSection } from "../components/skill-references-section"; import { SkillScriptsSection } from "../components/skill-scripts-section"; +import { CompatibilityPanel } from "../components/compatibility-panel"; import { useState } from "react"; import { useFetcher } from "react-router"; import { FileText, ShieldAlert } from "lucide-react"; @@ -114,6 +115,11 @@ export default function SkillDetail() { )} + {/* Compatibility — declared is not the same as verified */} +
+ +
+ {/* SKILL.md label */}
From c6aa7b92cb088f0b92d901e6620dcde1875bfd12 Mon Sep 17 00:00:00 2001 From: Goon Date: Tue, 22 Sep 2026 07:34:47 +0200 Subject: [PATCH 06/19] feat(web): expose skills as Markdown and add llms.txt Agents should not have to parse HTML to read a skill, so the catalog now answers in Markdown at three routes: - GET /skills/:slug.md one skill, with its SKILL.md body when granted - GET /llms.txt the catalog index, in one request - GET /llms-full.txt listed skills with their bodies, bounded to 200 The skill page advertises its Markdown variant through rel=alternate, and the Markdown route answers with a canonical Link header. These are the most payload-heavy surfaces in the product, so they run through the protected resolver rather than around it. The renderer takes `payloadGranted` as a required input instead of inferring it from whether `content` is a string: an inference would make a deliberate denial indistinguishable from a forgotten field. A withheld body renders as an explicit note, never a blank section that would read as an empty skill. llms.txt and llms-full.txt read their rows in one gated module (lib/catalog/public-catalog-index.ts), which is now covered by the boundary guard's "must actually call the gate" invariant. Both report the catalog total from a count query rather than the size of their own slice, so a truncated document cannot tell an agent the catalog ends where the slice does. Verified: pnpm typecheck clean; pnpm test 286 passed / 22 files, including the 25 new Markdown-rendering tests and 6 for the gated index; pnpm build emits the three routes as server-only resource routes. --- .../catalog/payload-boundary.guard.test.ts | 1 + .../lib/catalog/public-catalog-index.test.ts | 94 +++++++++++ .../app/lib/catalog/public-catalog-index.ts | 61 +++++++ apps/web/app/lib/markdown/llms-txt.test.ts | 131 +++++++++++++++ apps/web/app/lib/markdown/llms-txt.ts | 124 ++++++++++++++ .../app/lib/markdown/skill-markdown.test.ts | 150 +++++++++++++++++ apps/web/app/lib/markdown/skill-markdown.ts | 155 ++++++++++++++++++ apps/web/app/routes.ts | 3 + apps/web/app/routes/llms-full-txt.ts | 50 ++++++ apps/web/app/routes/llms-txt.ts | 44 +++++ apps/web/app/routes/skill-detail.tsx | 3 + apps/web/app/routes/skill-markdown.ts | 69 ++++++++ 12 files changed, 885 insertions(+) create mode 100644 apps/web/app/lib/catalog/public-catalog-index.test.ts create mode 100644 apps/web/app/lib/catalog/public-catalog-index.ts create mode 100644 apps/web/app/lib/markdown/llms-txt.test.ts create mode 100644 apps/web/app/lib/markdown/llms-txt.ts create mode 100644 apps/web/app/lib/markdown/skill-markdown.test.ts create mode 100644 apps/web/app/lib/markdown/skill-markdown.ts create mode 100644 apps/web/app/routes/llms-full-txt.ts create mode 100644 apps/web/app/routes/llms-txt.ts create mode 100644 apps/web/app/routes/skill-markdown.ts diff --git a/apps/web/app/lib/catalog/payload-boundary.guard.test.ts b/apps/web/app/lib/catalog/payload-boundary.guard.test.ts index 229b57c..cee4211 100644 --- a/apps/web/app/lib/catalog/payload-boundary.guard.test.ts +++ b/apps/web/app/lib/catalog/payload-boundary.guard.test.ts @@ -38,6 +38,7 @@ const GATED_MODULES = [ "lib/catalog/featured-skills.ts", "lib/catalog/profile-skill-queries.ts", "lib/catalog/skill-detail-data.ts", + "lib/catalog/public-catalog-index.ts", "lib/github/skill-import.ts", "lib/search/search-result-projection.ts", ]; diff --git a/apps/web/app/lib/catalog/public-catalog-index.test.ts b/apps/web/app/lib/catalog/public-catalog-index.test.ts new file mode 100644 index 0000000..a29587f --- /dev/null +++ b/apps/web/app/lib/catalog/public-catalog-index.test.ts @@ -0,0 +1,94 @@ +/** + * The gated catalog index that backs `llms.txt` and `llms-full.txt`. + * + * `llms-full.txt` embeds SKILL.md bodies, which makes this the highest-risk + * surface in the product: a projection bug here publishes a protected payload to + * an anonymous request. The properties pinned below are therefore the boundary + * itself, not cosmetic output. + */ + +import { describe, expect, it } from "vitest"; +import type { Database } from "~/lib/db"; +import { fetchPublicCatalogIndex } from "./public-catalog-index"; + +const TOTAL = 5080; + +function row(overrides: Record = {}) { + return { + id: "s1", + slug: "find-skills", + name: "Find Skills", + description: "Discovery helper", + author: "vercel", + category: "discovery", + content: "SECRET BODY", + is_paid: 0, + compatibility_json: null, + ...overrides, + }; +} + +/** + * Minimal stub of the two query shapes this module issues: a limited ordered + * select, and a `count()` select that is awaited directly. + */ +function stubDb(rows: Array>): Database { + return { + select: () => ({ + from: () => ({ + orderBy: () => ({ limit: async () => rows }), + then: (resolve: (value: unknown) => unknown) => + Promise.resolve([{ value: TOTAL }]).then(resolve), + }), + }), + } as unknown as Database; +} + +describe("fetchPublicCatalogIndex", () => { + it("returns the declared runtime summary for each listing", async () => { + const withCompat = row({ + compatibility_json: JSON.stringify({ + agentkit: { status: "declared", versions: ">=1.0.0" }, + }), + }); + const { entries } = await fetchPublicCatalogIndex(stubDb([withCompat]), { limit: 10 }); + expect(entries[0].compatibility?.map((c) => c.runtime)).toEqual(["agentkit"]); + }); + + it("omits content by default, even for a free listing", async () => { + const { entries } = await fetchPublicCatalogIndex(stubDb([row()]), { limit: 10 }); + expect(entries[0].content).toBeUndefined(); + }); + + it("includes content for a free listing when asked", async () => { + const { entries } = await fetchPublicCatalogIndex(stubDb([row()]), { + limit: 10, + includeContent: true, + }); + expect(entries[0].content).toBe("SECRET BODY"); + }); + + it("withholds content for a protected listing even when asked", async () => { + const { entries } = await fetchPublicCatalogIndex(stubDb([row({ is_paid: 1 })]), { + limit: 10, + includeContent: true, + }); + expect(entries[0].content).toBeUndefined(); + // The metadata still travels, so the listing is visibly present but unread. + expect(entries[0].slug).toBe("find-skills"); + }); + + it("reports the catalog total, not the size of the rendered slice", async () => { + const { entries, total } = await fetchPublicCatalogIndex(stubDb([row()]), { limit: 200 }); + expect(entries).toHaveLength(1); + expect(total).toBe(TOTAL); + }); + + it("returns no content key at all when the payload is withheld", async () => { + const { entries } = await fetchPublicCatalogIndex(stubDb([row({ is_paid: 1 })]), { + limit: 10, + includeContent: true, + }); + expect(Object.hasOwn(entries[0], "content")).toBe(false); + }); +}); diff --git a/apps/web/app/lib/catalog/public-catalog-index.ts b/apps/web/app/lib/catalog/public-catalog-index.ts new file mode 100644 index 0000000..55e7ef5 --- /dev/null +++ b/apps/web/app/lib/catalog/public-catalog-index.ts @@ -0,0 +1,61 @@ +/** + * Public catalog index for the agent-facing text surfaces (`llms.txt`, + * `llms-full.txt`). + * + * These are the most payload-heavy surfaces in the product, so they read full + * `skills` rows here — in one gated place — rather than in each route. The gate + * decides per row: a protected listing comes back without its body and the + * caller reports that, rather than rendering a blank that would read as an empty + * skill. + * + * The total is counted separately from the slice. A truncated document that + * reports the size of its own slice tells an agent the catalog ends there. + */ + +import { count, desc } from "drizzle-orm"; +import { skills } from "~/lib/db/schema"; +import type { Database } from "~/lib/db"; +import { gateSkillRow } from "./protected-content"; +import { summarizeRowRuntimes } from "~/lib/compatibility/catalog-compatibility"; +import type { LlmsEntry } from "~/lib/markdown/llms-txt"; + +export interface PublicCatalogIndexOptions { + /** Maximum number of listings to render. */ + limit: number; + /** Attach the granted SKILL.md body (used by `llms-full.txt`). */ + includeContent?: boolean; + userId?: string | null; +} + +export interface PublicCatalogIndex { + entries: LlmsEntry[]; + /** Total listings in the catalog, not the size of the slice. */ + total: number; +} + +export async function fetchPublicCatalogIndex( + db: Database, + options: PublicCatalogIndexOptions, +): Promise { + const { limit, includeContent = false, userId = null } = options; + + const [rows, totals] = await Promise.all([ + db.select().from(skills).orderBy(desc(skills.composite_score)).limit(limit), + db.select({ value: count() }).from(skills), + ]); + + const entries: LlmsEntry[] = rows.map((row) => { + const gated = gateSkillRow(row, userId); + return { + slug: gated.slug, + name: gated.name, + description: gated.description, + author: gated.author, + category: gated.category, + compatibility: summarizeRowRuntimes(gated), + ...(includeContent && typeof gated.content === "string" ? { content: gated.content } : {}), + }; + }); + + return { entries, total: totals[0]?.value ?? entries.length }; +} diff --git a/apps/web/app/lib/markdown/llms-txt.test.ts b/apps/web/app/lib/markdown/llms-txt.test.ts new file mode 100644 index 0000000..b22ca78 --- /dev/null +++ b/apps/web/app/lib/markdown/llms-txt.test.ts @@ -0,0 +1,131 @@ +/** + * `llms.txt` and `llms-full.txt`. + * + * The two properties that matter: a listing whose payload was not granted is + * marked as such rather than rendered blank, and truncation is always visible — + * a document that silently stops mid-catalog makes an agent believe the catalog + * ends there. + */ + +import { describe, expect, it } from "vitest"; +import { renderLlmsFullTxt, renderLlmsTxt, type LlmsEntry } from "./llms-txt"; + +const SITE = "https://skillx.sh"; + +function entry(overrides: Partial = {}): LlmsEntry { + return { + slug: "find-skills", + name: "Find Skills", + description: "Discovery helper", + author: "vercel", + category: "discovery", + ...overrides, + }; +} + +describe("renderLlmsTxt", () => { + it("opens with the catalog identity", () => { + const out = renderLlmsTxt(SITE, [entry()], 1); + expect(out.startsWith("# SkillX")).toBe(true); + expect(out).toContain("## Skills"); + }); + + it("links each skill to its Markdown resource", () => { + const out = renderLlmsTxt(SITE, [entry()], 1); + expect(out).toContain("- [Find Skills](https://skillx.sh/skills/find-skills.md): Discovery helper"); + }); + + it("annotates compatibility when a runtime is declared", () => { + const out = renderLlmsTxt( + SITE, + [ + entry({ + compatibility: [ + { + runtime: "agentkit", + status: "verified", + versions: null, + reasonCodes: [], + verifiedAt: "2026-09-20T00:00:00.000Z", + probeId: "p1", + }, + ], + }), + ], + 1, + ); + expect(out).toContain("(agentkit: verified 2026-09-20)"); + }); + + it("says nothing about compatibility when nothing is declared", () => { + const out = renderLlmsTxt(SITE, [entry({ compatibility: [] })], 1); + expect(out).toContain(": Discovery helper\n"); + expect(out).not.toContain(": Discovery helper ("); + }); + + it("names the truncation instead of stopping silently", () => { + const out = renderLlmsTxt(SITE, [entry()], 5080); + expect(out).toContain("Listing 1 of 5080 skills"); + }); + + it("omits the truncation note when the catalog is fully listed", () => { + const out = renderLlmsTxt(SITE, [entry()], 1); + expect(out).not.toContain("Listing 1 of 1 skills"); + }); + + it("advertises the sibling resources", () => { + const out = renderLlmsTxt(SITE, [entry()], 1); + expect(out).toContain("https://skillx.sh/llms-full.txt"); + expect(out).toContain("https://skillx.sh/api/search"); + }); +}); + +describe("renderLlmsFullTxt", () => { + it("embeds a granted SKILL.md body", () => { + const out = renderLlmsFullTxt(SITE, [entry({ content: "# Find Skills\n\nBody." })], { + total: 1, + truncated: false, + }); + expect(out).toContain("```markdown"); + expect(out).toContain("# Find Skills\n\nBody."); + }); + + it("marks a withheld body instead of rendering an empty one", () => { + const out = renderLlmsFullTxt(SITE, [entry()], { total: 1, truncated: false }); + expect(out).toContain("payload was not granted"); + expect(out).not.toContain("```markdown"); + }); + + it("states how many skills it left out", () => { + const out = renderLlmsFullTxt(SITE, [entry()], { total: 5080, truncated: true }); + expect(out).toContain("renders 1 of 5080 skills"); + }); + + it("carries per-skill links so an agent can fetch one skill directly", () => { + const out = renderLlmsFullTxt(SITE, [entry()], { total: 1, truncated: false }); + expect(out).toContain("- **Canonical:** https://skillx.sh/skills/find-skills"); + expect(out).toContain("- **Markdown:** https://skillx.sh/skills/find-skills.md"); + }); + + it("lists declared compatibility when present", () => { + const out = renderLlmsFullTxt( + SITE, + [ + entry({ + compatibility: [ + { + runtime: "agentkit", + status: "declared", + versions: null, + reasonCodes: [], + verifiedAt: null, + probeId: null, + }, + ], + }), + ], + { total: 1, truncated: false }, + ); + expect(out).toContain("- **Compatibility:** agentkit (declared)"); + }); +}); diff --git a/apps/web/app/lib/markdown/llms-txt.ts b/apps/web/app/lib/markdown/llms-txt.ts new file mode 100644 index 0000000..8187b7a --- /dev/null +++ b/apps/web/app/lib/markdown/llms-txt.ts @@ -0,0 +1,124 @@ +/** + * `llms.txt` and `llms-full.txt`. + * + * These follow the llmstxt.org shape: a single Markdown document an agent can + * read in one request instead of crawling the catalog. + * + * `llms-full.txt` embeds SKILL.md bodies, so it obeys the same rule as every + * other surface — only a granted payload is included. It is also bounded: this + * catalog holds thousands of listings and an unbounded document would be both + * unusably large and expensive to render per request, so it reports exactly how + * much it truncated rather than silently stopping. + */ + +import type { CompatibilitySummary } from "@skillx/contracts"; + +export interface LlmsEntry { + slug: string; + name: string; + description: string; + author: string; + category: string; + compatibility?: CompatibilitySummary[]; + /** Present only when the payload was granted for this request. */ + content?: string; +} + +export interface LlmsFullOptions { + /** Number of listings the catalog holds, so truncation is visible. */ + total: number; + /** True when the entry list was cut short before rendering. */ + truncated: boolean; +} + +function header(siteUrl: string): string[] { + return [ + "# SkillX", + "", + "> The Only Skill That Your AI Agent Needs. AI agent skills, published and discoverable.", + "", + ]; +} + +function summaryLabel(summary: CompatibilitySummary): string { + return summary.status === "verified" + ? `verified ${summary.verifiedAt?.slice(0, 10) ?? ""}`.trim() + : summary.status; +} + +export function renderLlmsTxt(siteUrl: string, entries: LlmsEntry[], total: number): string { + const base = siteUrl.replace(/\/+$/, ""); + const lines = header(base); + + lines.push("## Skills", ""); + for (const entry of entries) { + const href = `${base}/skills/${entry.slug}.md`; + const compat = entry.compatibility ?? []; + const compatNote = + compat.length > 0 + ? ` (${compat.map((s) => `${s.runtime}: ${summaryLabel(s)}`).join(", ")})` + : ""; + lines.push(`- [${entry.name}](${href}): ${entry.description}${compatNote}`); + } + lines.push(""); + + if (entries.length < total) { + lines.push( + `_Listing ${entries.length} of ${total} skills. The catalog is paginated; use the API`, + `\`POST ${base}/api/search\` for the rest._`, + "", + ); + } + + lines.push( + "## Optional", + "", + `- [Full skills document](${base}/llms-full.txt): every listed skill with its SKILL.md body`, + `- [Search API](${base}/api/search): POST { query, compatible? } for filtered results`, + "", + ); + + return lines.join("\n"); +} + +export function renderLlmsFullTxt( + siteUrl: string, + entries: LlmsEntry[], + options: LlmsFullOptions, +): string { + const base = siteUrl.replace(/\/+$/, ""); + const lines = header(base); + lines.push("## Skills", ""); + + if (options.truncated) { + lines.push( + `_This document renders ${entries.length} of ${options.total} skills. The remainder is`, + `available through ${base}/llms.txt and the search API._`, + "", + ); + } + + for (const entry of entries) { + lines.push(`### ${entry.name}`, ""); + lines.push(`- **Slug:** \`${entry.slug}\``); + lines.push(`- **Author:** ${entry.author}`); + lines.push(`- **Category:** ${entry.category}`); + lines.push(`- **Canonical:** ${base}/skills/${entry.slug}`); + lines.push(`- **Markdown:** ${base}/skills/${entry.slug}.md`); + const compat = entry.compatibility ?? []; + if (compat.length > 0) { + lines.push( + `- **Compatibility:** ${compat.map((s) => `${s.runtime} (${summaryLabel(s)})`).join(", ")}`, + ); + } + lines.push(""); + if (entry.description) lines.push(entry.description, ""); + if (typeof entry.content === "string") { + lines.push("```markdown", entry.content.trim(), "```", ""); + } else { + lines.push("_SKILL.md is not included: this listing's payload was not granted._", ""); + } + } + + return lines.join("\n"); +} diff --git a/apps/web/app/lib/markdown/skill-markdown.test.ts b/apps/web/app/lib/markdown/skill-markdown.test.ts new file mode 100644 index 0000000..53b4dfc --- /dev/null +++ b/apps/web/app/lib/markdown/skill-markdown.test.ts @@ -0,0 +1,150 @@ +/** + * The Markdown renderer. + * + * The property that matters most is the one a renderer is most tempted to infer: + * whether the SKILL.md body may be included. It is an explicit input, so an + * omission can never be mistaken for a denial — and a denial is visible in the + * output rather than silently blank. + */ + +import { describe, expect, it } from "vitest"; +import { + renderSkillMarkdown, + skillMarkdownUrl, + skillPageUrl, + type SkillMarkdownInput, +} from "./skill-markdown"; + +const SITE = "https://skillx.sh"; +const BODY = "# Find Skills\n\nBody text."; + +function input(overrides: Partial = {}): SkillMarkdownInput { + return { + siteUrl: SITE, + skill: { + slug: "find-skills", + name: "Find Skills", + description: "Discovery helper", + author: "vercel", + category: "discovery", + version: "1.0.0", + risk_label: "safe", + avg_rating: 4.5, + rating_count: 12, + install_count: 340, + install_command: "npx skillx-sh use vercel/find-skills", + source_url: "https://github.com/vercel/find-skills", + content: BODY, + }, + payloadGranted: true, + ...overrides, + }; +} + +describe("url helpers", () => { + it("builds the canonical page url", () => { + expect(skillPageUrl(SITE, "find-skills")).toBe("https://skillx.sh/skills/find-skills"); + }); + + it("normalizes a trailing slash so links stay stable", () => { + expect(skillPageUrl("https://skillx.sh/", "find-skills")).toBe("https://skillx.sh/skills/find-skills"); + }); + + it("appends .md for the alternate resource", () => { + expect(skillMarkdownUrl(SITE, "find-skills")).toBe("https://skillx.sh/skills/find-skills.md"); + }); +}); + +describe("renderSkillMarkdown — payload boundary", () => { + it("includes the SKILL.md body when the payload is granted", () => { + expect(renderSkillMarkdown(input())).toContain(BODY); + }); + + it("withholds the body when the payload is not granted, and says why", () => { + const out = renderSkillMarkdown(input({ payloadGranted: false })); + expect(out).not.toContain(BODY); + expect(out).toContain("is protected"); + expect(out).toContain("## SKILL.md"); + }); + + it("never leaks the body even when content is present but access was denied", () => { + // The renderer must obey the decision, not the presence of the string. + const out = renderSkillMarkdown(input({ payloadGranted: false })); + expect(out).not.toContain("Body text."); + }); +}); + +describe("renderSkillMarkdown — content", () => { + it("starts with the skill name and its description as a summary", () => { + const out = renderSkillMarkdown(input()); + expect(out.startsWith("# Find Skills\n")).toBe(true); + expect(out).toContain("> Discovery helper"); + }); + + it("lists the facts an agent needs to act", () => { + const out = renderSkillMarkdown(input()); + expect(out).toContain("- **Author:** vercel"); + expect(out).toContain("- **Install:** `npx skillx-sh use vercel/find-skills`"); + expect(out).toContain("- **Canonical:** https://skillx.sh/skills/find-skills"); + }); + + it("omits facts it does not have rather than printing empty bullets", () => { + const out = renderSkillMarkdown( + input({ skill: { ...input().skill, version: null, source_url: null, install_command: null } }), + ); + expect(out).not.toContain("- **Version:**"); + expect(out).not.toContain("- **Source:**"); + expect(out).not.toContain("- **Install:**"); + }); + + it("states that nothing is declared when there is no compatibility entry", () => { + const out = renderSkillMarkdown(input()); + expect(out).toContain("No runtime is declared"); + expect(out).toContain("unknown rather than as support"); + }); + + it("distinguishes a verified probe from a bare declaration", () => { + const out = renderSkillMarkdown( + input({ + compatibility: [ + { + runtime: "agentkit", + status: "verified", + versions: ">=1.0.0", + reasonCodes: [], + verifiedAt: "2026-09-20T10:00:00.000Z", + probeId: "probe_001", + }, + { + runtime: "claude-code", + status: "declared", + versions: null, + reasonCodes: ["COMPAT_EVIDENCE_ABSENT"], + verifiedAt: null, + probeId: null, + }, + ], + }), + ); + expect(out).toContain("| agentkit | verified | >=1.0.0 | verified 2026-09-20 (probe probe_001) |"); + expect(out).toContain("| claude-code | declared | — | COMPAT_EVIDENCE_ABSENT |"); + expect(out).toContain("`unknown` is the absence of a claim and is not support"); + }); + + it("renders references and scripts when present", () => { + const out = renderSkillMarkdown( + input({ + references: [{ title: "Docs", url: "https://example.com", type: "docs" }], + scripts: [{ name: "setup.sh", command: "bash setup.sh" }], + }), + ); + expect(out).toContain("- [Docs (docs)](https://example.com)"); + expect(out).toContain("- setup.sh — `bash setup.sh`"); + }); + + it("omits the references and scripts sections entirely when there are none", () => { + const out = renderSkillMarkdown(input()); + expect(out).not.toContain("## References"); + expect(out).not.toContain("## Scripts"); + }); +}); diff --git a/apps/web/app/lib/markdown/skill-markdown.ts b/apps/web/app/lib/markdown/skill-markdown.ts new file mode 100644 index 0000000..431b6bb --- /dev/null +++ b/apps/web/app/lib/markdown/skill-markdown.ts @@ -0,0 +1,155 @@ +/** + * Route-level Markdown renderer for a skill. + * + * Markdown exists so an agent can read a skill without scraping HTML, which + * means the resource has to be as honest as the API: the SKILL.md payload is + * included only when `payloadGranted` says the viewer may read it, and the + * compatibility section distinguishes a declaration from a verified probe. + * + * `payloadGranted` is a required input rather than something inferred from the + * presence of `content`. Inferring it would make an omission indistinguishable + * from a deliberate denial, and this is exactly the boundary that must not be + * left to inference. + */ + +import type { CompatibilitySummary } from "@skillx/contracts"; + +export interface MarkdownReference { + title: string; + url: string | null; + type?: string | null; +} + +export interface MarkdownScript { + name: string; + command?: string | null; + url?: string | null; +} + +export interface SkillMarkdownInput { + siteUrl: string; + skill: { + slug: string; + name: string; + description: string; + author: string; + category: string; + version?: string | null; + source_url?: string | null; + install_command?: string | null; + risk_label?: string | null; + avg_rating?: number | null; + rating_count?: number | null; + install_count?: number | null; + updated_at?: Date | string | number | null; + content?: string; + }; + references?: MarkdownReference[]; + scripts?: MarkdownScript[]; + compatibility?: CompatibilitySummary[]; + /** True only when the viewer may read the SKILL.md payload. */ + payloadGranted: boolean; +} + +/** Public page URL. Trailing slashes are normalized so links stay stable. */ +export function skillPageUrl(siteUrl: string, slug: string): string { + return `${siteUrl.replace(/\/+$/, "")}/skills/${slug}`; +} + +/** The Markdown variant of the same resource, for `rel=alternate`. */ +export function skillMarkdownUrl(siteUrl: string, slug: string): string { + return `${skillPageUrl(siteUrl, slug)}.md`; +} + +function compatibilityTable(summaries: CompatibilitySummary[]): string { + const rows = summaries.map((summary) => { + const notes = summary.verifiedAt + ? `verified ${summary.verifiedAt.slice(0, 10)}${summary.probeId ? ` (probe ${summary.probeId})` : ""}` + : summary.reasonCodes.length > 0 + ? summary.reasonCodes.join(", ") + : "declared only, no probe recorded"; + return `| ${summary.runtime} | ${summary.status} | ${summary.versions ?? "—"} | ${notes} |`; + }); + + return [ + "## Compatibility", + "", + "| Runtime | Status | Versions | Notes |", + "|---|---|---|---|", + ...rows, + "", + "`declared` means a publisher claims support. `verified` means evidence for this exact", + "artifact is recorded. `unknown` is the absence of a claim and is not support.", + "", + ].join("\n"); +} + +export function renderSkillMarkdown(input: SkillMarkdownInput): string { + const { skill, siteUrl } = input; + const page = skillPageUrl(siteUrl, skill.slug); + const lines: string[] = []; + + lines.push(`# ${skill.name}`, ""); + if (skill.description) lines.push(`> ${skill.description}`, ""); + + const facts: Array<[string, string]> = [["Author", skill.author], ["Category", skill.category]]; + if (skill.version) facts.push(["Version", skill.version]); + if (skill.risk_label) facts.push(["Risk", skill.risk_label]); + if (skill.avg_rating !== null && skill.avg_rating !== undefined) { + facts.push(["Rating", `${skill.avg_rating} (${skill.rating_count ?? 0})`]); + } + if (skill.install_count !== null && skill.install_count !== undefined) { + facts.push(["Installs", String(skill.install_count)]); + } + if (skill.install_command) facts.push(["Install", `\`${skill.install_command}\``]); + if (skill.source_url) facts.push(["Source", skill.source_url]); + facts.push(["Canonical", page]); + + for (const [label, value] of facts) lines.push(`- **${label}:** ${value}`); + lines.push(""); + + const compatibility = input.compatibility ?? []; + if (compatibility.length > 0) { + lines.push(compatibilityTable(compatibility)); + } else { + lines.push( + "## Compatibility", + "", + "No runtime is declared. An agent should treat this as unknown rather than as support.", + "", + ); + } + + const references = input.references ?? []; + if (references.length > 0) { + lines.push("## References", ""); + for (const ref of references) { + const label = ref.type ? `${ref.title} (${ref.type})` : ref.title; + lines.push(ref.url ? `- [${label}](${ref.url})` : `- ${label}`); + } + lines.push(""); + } + + const scripts = input.scripts ?? []; + if (scripts.length > 0) { + lines.push("## Scripts", ""); + for (const script of scripts) { + const suffix = script.command ? ` — \`${script.command}\`` : script.url ? ` — ${script.url}` : ""; + lines.push(`- ${script.name}${suffix}`); + } + lines.push(""); + } + + lines.push("## SKILL.md", ""); + if (input.payloadGranted && typeof skill.content === "string") { + lines.push(skill.content.trim(), ""); + } else { + lines.push( + "This listing is protected and no entitlement was presented for this request, so its", + `payload is not included here. Request it through the API with authorization: ${page}`, + "", + ); + } + + return lines.join("\n"); +} diff --git a/apps/web/app/routes.ts b/apps/web/app/routes.ts index df14a77..f64265c 100644 --- a/apps/web/app/routes.ts +++ b/apps/web/app/routes.ts @@ -3,6 +3,9 @@ import { type RouteConfig, index, route } from "@react-router/dev/routes"; export default [ index("routes/home.tsx"), route("skills/:slug", "routes/skill-detail.tsx"), + route("skills/:slug.md", "routes/skill-markdown.ts"), + route("llms.txt", "routes/llms-txt.ts"), + route("llms-full.txt", "routes/llms-full-txt.ts"), route("profile", "routes/profile.tsx"), route("settings", "routes/settings.tsx"), route("docs", "routes/docs.tsx"), diff --git a/apps/web/app/routes/llms-full-txt.ts b/apps/web/app/routes/llms-full-txt.ts new file mode 100644 index 0000000..987f4be --- /dev/null +++ b/apps/web/app/routes/llms-full-txt.ts @@ -0,0 +1,50 @@ +/** + * `GET /llms-full.txt` — every listed skill with its SKILL.md body. + * + * This is the most payload-heavy surface in the product. It obeys the protected + * boundary row by row through the shared catalog index, so a listing whose + * payload was not granted is rendered with an explicit note instead of an empty + * body. It is bounded and cached, and it states how much it left out. + */ + +import type { LoaderFunctionArgs } from "react-router"; +import { getDb } from "~/lib/db"; +import { getCached } from "~/lib/cache/kv-cache"; +import { fetchPublicCatalogIndex } from "~/lib/catalog/public-catalog-index"; +import { renderLlmsFullTxt } from "~/lib/markdown/llms-txt"; + +/** How many skill bodies the document carries before truncating. */ +const FULL_LIMIT = 200; +const CACHE_TTL_SECONDS = 300; +const CACHE_KEY = "llms-full-txt:v2"; + +export async function loader({ request, context }: LoaderFunctionArgs) { + const env = context.cloudflare.env as Env; + + try { + const siteUrl = new URL(request.url).origin; + + const body = await getCached(env.KV, CACHE_KEY, CACHE_TTL_SECONDS, async () => { + const { entries, total } = await fetchPublicCatalogIndex(getDb(env.DB), { + limit: FULL_LIMIT, + includeContent: true, + }); + + return renderLlmsFullTxt(siteUrl, entries, { + total, + truncated: entries.length < total, + }); + }); + + return new Response(body, { + status: 200, + headers: { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "public, max-age=300", + }, + }); + } catch (error) { + console.error("llms-full.txt error:", error); + return new Response("Failed to render llms-full.txt\n", { status: 500 }); + } +} diff --git a/apps/web/app/routes/llms-txt.ts b/apps/web/app/routes/llms-txt.ts new file mode 100644 index 0000000..5252cdb --- /dev/null +++ b/apps/web/app/routes/llms-txt.ts @@ -0,0 +1,44 @@ +/** + * `GET /llms.txt` — the catalog index for agents, in one request. + * + * Bounded and cached: the catalog holds thousands of listings, so the document + * renders a deterministic top slice and reports how many listings it did not + * include rather than pretending the catalog is that small. + */ + +import type { LoaderFunctionArgs } from "react-router"; +import { getDb } from "~/lib/db"; +import { getCached } from "~/lib/cache/kv-cache"; +import { fetchPublicCatalogIndex } from "~/lib/catalog/public-catalog-index"; +import { renderLlmsTxt } from "~/lib/markdown/llms-txt"; + +/** How many listings the index carries. */ +const INDEX_LIMIT = 1000; +const CACHE_TTL_SECONDS = 300; +const CACHE_KEY = "llms-txt:v2"; + +export async function loader({ request, context }: LoaderFunctionArgs) { + const env = context.cloudflare.env as Env; + + try { + const siteUrl = new URL(request.url).origin; + + const body = await getCached(env.KV, CACHE_KEY, CACHE_TTL_SECONDS, async () => { + const { entries, total } = await fetchPublicCatalogIndex(getDb(env.DB), { + limit: INDEX_LIMIT, + }); + return renderLlmsTxt(siteUrl, entries, total); + }); + + return new Response(body, { + status: 200, + headers: { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "public, max-age=300", + }, + }); + } catch (error) { + console.error("llms.txt error:", error); + return new Response("Failed to render llms.txt\n", { status: 500 }); + } +} diff --git a/apps/web/app/routes/skill-detail.tsx b/apps/web/app/routes/skill-detail.tsx index 2b4b9fc..d8b8de6 100644 --- a/apps/web/app/routes/skill-detail.tsx +++ b/apps/web/app/routes/skill-detail.tsx @@ -52,6 +52,9 @@ export default function SkillDetail() { return ( + {/* The Markdown variant of this page, for agents that prefer it. React 19 + hoists this link into . */} + {/* Breadcrumb */}
{/* Risk warning banner */} - {data.skill.risk_label === "danger" && ( -
- - Suspicious content patterns detected. Review carefully before use. -
- )} - {data.skill.risk_label === "caution" && ( -
- - Some content patterns flagged for review. -
- )} + {/* Use this skill */}
@@ -127,6 +118,9 @@ export default function SkillDetail() {
SKILL.md + + +
{/* Separator */} diff --git a/apps/web/app/routes/skill-markdown.test.ts b/apps/web/app/routes/skill-markdown.test.ts new file mode 100644 index 0000000..d0bcbab --- /dev/null +++ b/apps/web/app/routes/skill-markdown.test.ts @@ -0,0 +1,171 @@ +/** + * Route-level authorization tests for `GET /skills/:slug.md`. + * + * This route is a payload surface: it renders a SKILL.md body into a document + * that an agent will read and a browser will copy to the clipboard. The + * renderer's own tests pin the rendering rules, and this file exercises the real + * handler and asserts on the real response body, because the property that + * matters — a protected body never appears in the document — is only true if the + * route wires the gate up at all. + * + * The query layer is replaced so the loader runs unmodified; only storage is + * faked. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const h = vi.hoisted(() => { + const state = { + row: null as Record | null, + sessionUser: null as { id: string } | null, + }; + return { state }; +}); + +vi.mock("~/lib/db", () => ({ getDb: () => ({}) })); + +vi.mock("~/lib/db/skill-detail-queries", () => ({ + fetchSkillBySlug: async () => h.state.row, + fetchSkillReferences: async () => [], +})); + +vi.mock("~/lib/auth/session-helpers", () => ({ + getSession: async () => (h.state.sessionUser ? { user: h.state.sessionUser } : null), +})); + +import { loader } from "./skill-markdown"; + +const BODY = "# Find Skills\n\nThe canonical SKILL.md body text."; + +function skillRow(overrides: Record = {}) { + return { + id: "skill_1", + slug: "find-skills", + name: "Find Skills", + description: "Discovery helper", + author: "vercel", + category: "discovery", + content: BODY, + is_paid: false, + price_cents: 0, + avg_rating: 4.5, + rating_count: 2, + install_count: 10, + version: "1.0.0", + source_url: null, + install_command: "skillx use find-skills", + risk_label: "safe", + scripts: null, + compatibility_json: null, + created_at: new Date(0), + updated_at: new Date(0), + ...overrides, + }; +} + +async function callLoader(slug: string | undefined = "find-skills") { + const response = (await loader({ + params: { slug }, + request: new Request(`https://skillx.sh/skills/${slug ?? ""}.md`), + context: { cloudflare: { env: { DB: {} } } }, + } as never)) as Response; + return { response, text: await response.clone().text() }; +} + +beforeEach(() => { + h.state.row = null; + h.state.sessionUser = null; +}); + +describe("GET /skills/:slug.md — route-level payload boundary", () => { + it("serves the SKILL.md body of a free listing to an anonymous caller", async () => { + h.state.row = skillRow(); + const { response, text } = await callLoader(); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toContain("text/markdown"); + expect(text).toContain(BODY); + }); + + it("never serves the body of a protected listing to an anonymous caller", async () => { + h.state.row = skillRow({ is_paid: true, price_cents: 1500 }); + const { response, text } = await callLoader(); + + expect(response.status).toBe(200); + expect(text).not.toContain("The canonical SKILL.md body text."); + expect(text).toContain("is protected"); + }); + + it("still withholds a protected body from an authenticated caller without an entitlement", async () => { + // Entitlement resolution arrives in Phase 5; a session alone grants nothing. + h.state.sessionUser = { id: "user_1" }; + h.state.row = skillRow({ is_paid: true, price_cents: 1500 }); + const { text } = await callLoader(); + + expect(text).not.toContain("The canonical SKILL.md body text."); + }); + + it("still describes a withheld listing, so it does not look like an empty skill", async () => { + h.state.row = skillRow({ is_paid: true }); + const { text } = await callLoader(); + + expect(text).toContain("# Find Skills"); + expect(text).toContain("- **Author:** vercel"); + expect(text).toContain("## SKILL.md"); + }); + + it("names the HTML canonical in a Link header", async () => { + h.state.row = skillRow(); + const { response } = await callLoader(); + + expect(response.headers.get("Link")).toBe( + '; rel="canonical"', + ); + }); + + it("returns 404 for an unknown slug", async () => { + h.state.row = null; + const { response, text } = await callLoader("does-not-exist"); + + expect(response.status).toBe(404); + expect(text).not.toContain("## SKILL.md"); + }); + + it("returns 400 when the slug is absent rather than rendering a partial document", async () => { + // An empty string, not `undefined`: a default parameter would supply the + // real slug and quietly test the happy path instead. + const { response } = await callLoader(""); + expect(response.status).toBe(400); + }); + + it("claims no runtime support when compatibility was never declared", async () => { + h.state.row = skillRow(); + const { text } = await callLoader(); + + expect(text).toContain("No runtime is declared"); + expect(text).not.toContain("verified"); + }); + + it("does not turn malformed compatibility JSON into a claim", async () => { + h.state.row = skillRow({ compatibility_json: "{not json" }); + const { text } = await callLoader(); + + expect(text).toContain("No runtime is declared"); + }); + + it("renders a declared runtime with the evidence tier it actually has", async () => { + h.state.row = skillRow({ + compatibility_json: JSON.stringify({ + agentkit: { status: "declared", versions: ">=1.0.0" }, + }), + }); + const { text } = await callLoader(); + + const compatRow = text.split("\n").find((line) => line.startsWith("| agentkit |")); + expect(compatRow).toBeDefined(); + expect(compatRow).toContain("| declared |"); + expect(compatRow).toContain(">=1.0.0"); + // A listing has no artifact digest, so it can never reach the verified tier. + expect(compatRow).not.toContain("verified"); + }); +}); From 35081ce57c0a729b6e4f40be5b2c15e42389becb Mon Sep 17 00:00:00 2001 From: Goon Date: Tue, 22 Sep 2026 07:37:26 +0200 Subject: [PATCH 08/19] docs(plans): record slice status and what slice 5 does not cover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slices 1-5 are done; 6 (MCP/WebMCP) and 7 (parity tests) have not started. The notes state the two things slice 5 does not prove — no browser-level test of rel=alternate hoisting or the clipboard write — so their absence is not mistaken for coverage. --- .../plan.md | 35 +++++++++++++++---- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/plans/260922-0717-skillx-agent-native-discovery/plan.md b/plans/260922-0717-skillx-agent-native-discovery/plan.md index 3ffa723..fffa784 100644 --- a/plans/260922-0717-skillx-agent-native-discovery/plan.md +++ b/plans/260922-0717-skillx-agent-native-discovery/plan.md @@ -46,13 +46,34 @@ undecidable rows without the caller being able to see why. | # | Slice | Tasks covered | Status | |---|---|---|---| -| 1 | Listing-level declaration + shared catalog resolver | foundation for 1–5 | this goal | -| 2 | Compatibility-aware API (`?compatible=`, compat on search + detail) | 1, 5 (API half) | this goal | -| 3 | CLI: `--compatible`, `inspect`, `check --target`, JSON/exit-code envelope | 1, 2, 3, 4 | this goal | -| 4 | Web compatibility badge with evidence details | 5 | this goal | -| 5 | Markdown routes, `llms.txt`, `llms-full.txt`, `rel=alternate`, Copy | 9–13 | this goal | -| 6 | Remote MCP server + tools, WebMCP read tools | 6, 7, 8 | this goal | -| 7 | Parity tests across API/CLI/MCP/Markdown | 15 | this goal | +| 1 | Listing-level declaration + shared catalog resolver | foundation for 1–5 | done — `c1a6479`, `95a169e` (16 tests) | +| 2 | Compatibility-aware API (`?compatible=`, compat on search + detail) | 1, 5 (API half) | done — `d02a897` (40 tests) | +| 3 | CLI: `--compatible`, `inspect`, `check --target`, JSON/exit-code envelope | 1, 2, 3, 4 | done — `033f2d2` (32 tests, tsup build ok) | +| 4 | Web compatibility badge with evidence details | 5 | done — `aec3d1b` (5 tests) | +| 5 | Markdown routes, `llms.txt`, `llms-full.txt`, `rel=alternate`, Copy | 9–13 | done — `c6aa7b9`, `f31c9ed` (71 tests) | +| 6 | Remote MCP server + tools, WebMCP read tools | 6, 7, 8 | not started | +| 7 | Parity tests across API/CLI/MCP/Markdown | 15 | not started | + +### Slice 5 notes + +The three Markdown surfaces read their rows through one gated module, +`apps/web/app/lib/catalog/public-catalog-index.ts`, which the Phase 0 boundary +guard now covers. `renderSkillMarkdown` takes `payloadGranted` as a required +input rather than inferring it from whether `content` is a string: an inference +would make a deliberate denial indistinguishable from a forgotten field. A +withheld body renders as an explicit note instead of a blank section. + +Both `llms*.txt` routes report the catalog total from a `count(*)` query, not +the size of their own slice, so a truncated document cannot tell an agent the +catalog ends where the slice does. `llms.txt` renders 1000 listings and +`llms-full.txt` 200 bodies; both are KV-cached for 300s. + +`skill-detail.tsx` was exactly at the 200-line ceiling after the Copy button, so +the risk banners moved to `components/risk-warning-banner.tsx`. + +**Not done in this slice**, and tracked here so it is not mistaken for coverage: +the Markdown route is tested at the loader level only. There is no browser-level +test of `rel=alternate` hoisting or of the clipboard write. ## Acceptance criteria (from #33) From 89b5e6fc0d644775bdd72f9a6408fe4401aac705 Mon Sep 17 00:00:00 2001 From: Goon Date: Tue, 22 Sep 2026 07:54:04 +0200 Subject: [PATCH 09/19] feat(mcp): add a remote read-only MCP server POST /api/mcp speaks JSON-RPC 2.0 over the MCP streamable-HTTP transport and implements initialize, notifications/initialized, tools/list, and tools/call. Everything else answers `method not found`, which is more useful to a client than a stub that appears to work. Tools: search_packages, get_package, get_release, check_compatibility, get_kit. Each handler is a thin adapter over the same executors the HTTP API and the CLI use, so MCP cannot drift into a second implementation. Three decisions worth reviewing: - A tool that cannot answer returns `found: false` with a reason code rather than an error. `no_package` is the expected answer for most of today's catalog because the registry tables are empty until a later phase, and an agent has to be able to tell "not in the registry" apart from "the call failed". - A tool failure is a JSON-RPC *success* carrying isError, per MCP; only a malformed request or unknown method is a JSON-RPC error. - get_kit reports a private collection as `not_found`, identical to an unknown slug, so the tool cannot be used to enumerate private slugs. The endpoint deliberately sets no CORS headers. MCP clients are not browsers and the WebMCP layer runs same-origin, so nothing in scope needs cross-origin access; opening it would only widen the surface. Tool descriptions are treated as load-bearing: each states what the tool will not do, including that `unknown` compatibility is not support and that `compatible` takes a runtime but not a version. Verified: pnpm typecheck clean; pnpm test 330 passed / 25 files, including 24 tests for the protocol and dispatch and 10 driving the route over real HTTP bodies (id 0 is a call, notifications get 202 and no body, unknown tool is a success with isError). handlers.ts was 230 lines at first and is now split into args/packages/kits/registry, all under 200. --- apps/web/app/lib/mcp/args.ts | 42 +++++ apps/web/app/lib/mcp/handlers-kits.ts | 49 ++++++ apps/web/app/lib/mcp/handlers-packages.ts | 145 +++++++++++++++++ apps/web/app/lib/mcp/handlers.ts | 33 ++++ apps/web/app/lib/mcp/mcp.test.ts | 188 ++++++++++++++++++++++ apps/web/app/lib/mcp/protocol.ts | 123 ++++++++++++++ apps/web/app/lib/mcp/tools.ts | 137 ++++++++++++++++ apps/web/app/routes.ts | 1 + apps/web/app/routes/api.mcp.test.ts | 114 +++++++++++++ apps/web/app/routes/api.mcp.ts | 125 ++++++++++++++ 10 files changed, 957 insertions(+) create mode 100644 apps/web/app/lib/mcp/args.ts create mode 100644 apps/web/app/lib/mcp/handlers-kits.ts create mode 100644 apps/web/app/lib/mcp/handlers-packages.ts create mode 100644 apps/web/app/lib/mcp/handlers.ts create mode 100644 apps/web/app/lib/mcp/mcp.test.ts create mode 100644 apps/web/app/lib/mcp/protocol.ts create mode 100644 apps/web/app/lib/mcp/tools.ts create mode 100644 apps/web/app/routes/api.mcp.test.ts create mode 100644 apps/web/app/routes/api.mcp.ts diff --git a/apps/web/app/lib/mcp/args.ts b/apps/web/app/lib/mcp/args.ts new file mode 100644 index 0000000..e3fbc5c --- /dev/null +++ b/apps/web/app/lib/mcp/args.ts @@ -0,0 +1,42 @@ +/** + * Argument readers for MCP tools. + * + * Handlers validate fields rather than shapes: a non-object `arguments` value is + * normalized to `{}` by `asRecord`, so every reader here has one job — accept a + * usable value or throw `ToolInputError`, which the dispatcher turns into a + * tool-level error result instead of a transport failure. + */ + +import { ToolInputError } from "./protocol"; + +export function requiredString(args: Record, key: string): string { + const value = args[key]; + if (typeof value !== "string" || value.trim() === "") { + throw new ToolInputError(`${key} is required and must be a non-empty string`); + } + return value.trim(); +} + +export function optionalString(args: Record, key: string): string | undefined { + const value = args[key]; + return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined; +} + +export function optionalLimit(args: Record, fallback: number, max: number): number { + const value = args.limit; + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return fallback; + return Math.min(Math.floor(value), max); +} + +/** + * Collection members are stored as JSON. A malformed value narrows the answer to + * a reported error rather than throwing, so one bad row cannot make the whole + * tool unusable. + */ +export function parseMembers(membersJson: string): { members: unknown } | { membersError: string } { + try { + return { members: JSON.parse(membersJson) as unknown }; + } catch { + return { membersError: "collection members are not valid JSON" }; + } +} diff --git a/apps/web/app/lib/mcp/handlers-kits.ts b/apps/web/app/lib/mcp/handlers-kits.ts new file mode 100644 index 0000000..dc1b321 --- /dev/null +++ b/apps/web/app/lib/mcp/handlers-kits.ts @@ -0,0 +1,49 @@ +/** + * Kit (collection) handler. + * + * A kit is a published collection revision. Only `public` visibility is + * readable: a private collection answers `not_found`, exactly like one that does + * not exist, so the tool cannot be used to enumerate private slugs. That is a + * deliberate choice to make the two cases indistinguishable from outside. + */ + +import { and, desc, eq } from "drizzle-orm"; +import { getDb } from "~/lib/db"; +import { collections, collectionRevisions } from "~/lib/db/collections-schema"; +import { asRecord } from "./protocol"; +import { parseMembers, requiredString } from "./args"; +import type { ToolContext } from "./handlers"; + +export async function getKit(rawArgs: unknown, ctx: ToolContext) { + const args = asRecord(rawArgs); + const slug = requiredString(args, "slug"); + const db = getDb(ctx.env.DB); + + const [collection] = await db + .select() + .from(collections) + .where(and(eq(collections.slug, slug), eq(collections.visibility, "public"))) + .limit(1); + if (!collection) return { found: false, code: "not_found", slug }; + + const revisions = await db + .select() + .from(collectionRevisions) + .where(eq(collectionRevisions.collection_id, collection.id)) + .orderBy(desc(collectionRevisions.revision_number)) + .limit(1); + + const latest = revisions[0]; + return { + found: true, + kit: { + slug: collection.slug, + name: collection.name, + ownerId: collection.owner_id, + visibility: collection.visibility, + latestRevisionNumber: collection.latest_revision_number, + updatedAt: collection.updated_at, + ...(latest ? { ...parseMembers(latest.members_json), config: latest.config_json } : {}), + }, + }; +} diff --git a/apps/web/app/lib/mcp/handlers-packages.ts b/apps/web/app/lib/mcp/handlers-packages.ts new file mode 100644 index 0000000..8356734 --- /dev/null +++ b/apps/web/app/lib/mcp/handlers-packages.ts @@ -0,0 +1,145 @@ +/** + * Package-facing MCP handlers: search, detail, releases, and compatibility. + * + * These are thin adapters over the same executors the HTTP API and the CLI use, + * not a second implementation. Where a tool cannot answer — the registry tables + * are empty until a later phase populates releases — it returns `found: false` + * with a reason code instead of throwing or inventing a value. "Not in the + * registry" and "the call failed" are different answers an agent must be able to + * tell apart. + */ + +import { and, desc, eq } from "drizzle-orm"; +import { getDb } from "~/lib/db"; +import { skills } from "~/lib/db/schema"; +import { packages, packageReleases } from "~/lib/db/registry-schema"; +import { fetchSkillReferences } from "~/lib/db/skill-detail-queries"; +import { executeSearch } from "~/lib/search/search-executor"; +import { parseCompatibilityFilter } from "~/lib/search/compatibility-filter"; +import { toPublicSkillDetail } from "~/lib/catalog/public-dto"; +import { buildSkillDetailResponse, isProtectedListing } from "~/lib/catalog/protected-content"; +import { + parseCompatibilityTarget, + resolveRowCompatibility, + summarizeRowRuntimes, +} from "~/lib/compatibility/catalog-compatibility"; +import { ToolInputError, asRecord } from "./protocol"; +import { optionalLimit, optionalString, requiredString } from "./args"; +import type { ToolContext } from "./handlers"; + +/** Same executor, same projection, same filter report as `POST /api/search`. */ +export async function searchPackages(rawArgs: unknown, ctx: ToolContext) { + const args = asRecord(rawArgs); + const query = requiredString(args, "query"); + const compatible = optionalString(args, "compatible"); + const category = optionalString(args, "category"); + + const outcome = await executeSearch(ctx.env, { + query, + filters: category ? { category } : {}, + userId: ctx.userId ?? undefined, + limit: optionalLimit(args, 20, 100), + compatible: parseCompatibilityFilter(compatible) ?? undefined, + }); + + // The filter report and note travel with the result: a filter that hides + // undecidable listings silently would make the catalog look smaller. + return { + results: outcome.results, + count: outcome.results.length, + ...(outcome.compatibilityFilter ? { compatibilityFilter: outcome.compatibilityFilter } : {}), + ...(outcome.note ? { note: outcome.note } : {}), + }; +} + +export async function getPackage(rawArgs: unknown, ctx: ToolContext) { + const args = asRecord(rawArgs); + const slug = requiredString(args, "slug"); + const db = getDb(ctx.env.DB); + + const [row] = await db.select().from(skills).where(eq(skills.slug, slug)).limit(1); + if (!row) return { found: false, code: "not_found", slug }; + + const references = await fetchSkillReferences(db, row.id); + const detail = toPublicSkillDetail(row, references, [], row.scripts); + // The boundary decides whether `content` is part of the answer. + const gated = buildSkillDetailResponse( + detail, + { slug: row.slug, is_paid: row.is_paid, content: row.content }, + { userId: ctx.userId ?? null }, + ); + + const target = parseCompatibilityTarget(optionalString(args, "target")); + return { + found: true, + package: { + ...gated, + protected: isProtectedListing(row), + contentIncluded: typeof (gated as { content?: string }).content === "string", + }, + compatibility: { + declared: summarizeRowRuntimes(row), + ...(target ? { target: resolveRowCompatibility(row, target) } : {}), + }, + }; +} + +export async function getRelease(rawArgs: unknown, ctx: ToolContext) { + const args = asRecord(rawArgs); + const packageSlug = requiredString(args, "package"); + const version = optionalString(args, "version"); + const db = getDb(ctx.env.DB); + + const [pkg] = await db.select().from(packages).where(eq(packages.slug, packageSlug)).limit(1); + if (!pkg) return { found: false, code: "no_package", package: packageSlug }; + + const rows = await db + .select() + .from(packageReleases) + .where( + version + ? and(eq(packageReleases.package_id, pkg.id), eq(packageReleases.version, version)) + : eq(packageReleases.package_id, pkg.id), + ) + .orderBy(desc(packageReleases.created_at)) + .limit(version ? 1 : 20); + + if (rows.length === 0) { + return version + ? { found: false, code: "unknown_version", package: packageSlug, version } + : { found: false, code: "no_releases", package: packageSlug }; + } + + const releases = rows.map((release) => ({ + version: release.version, + channel: release.channel, + releaseState: release.release_state, + manifestSchemaVersion: release.manifest_schema_version, + artifactDigest: release.artifact_digest, + artifactSize: release.artifact_size, + publishedAt: release.published_at, + yankedAt: release.yanked_at, + compatibility: release.compatibility_json, + })); + + return version + ? { found: true, package: packageSlug, release: releases[0] } + : { found: true, package: packageSlug, releases }; +} + +/** Version-aware, and it reads the listing declaration rather than a search index. */ +export async function checkCompatibility(rawArgs: unknown, ctx: ToolContext) { + const args = asRecord(rawArgs); + const slug = requiredString(args, "slug"); + const target = parseCompatibilityTarget(requiredString(args, "target")); + if (!target) { + throw new ToolInputError("target must look like `runtime` or `runtime@version`"); + } + + const db = getDb(ctx.env.DB); + const [row] = await db.select().from(skills).where(eq(skills.slug, slug)).limit(1); + if (!row) return { found: false, code: "not_found", slug }; + + const result = resolveRowCompatibility(row, target); + return { found: true, slug, target, ...result }; +} diff --git a/apps/web/app/lib/mcp/handlers.ts b/apps/web/app/lib/mcp/handlers.ts new file mode 100644 index 0000000..7d3a836 --- /dev/null +++ b/apps/web/app/lib/mcp/handlers.ts @@ -0,0 +1,33 @@ +/** + * MCP tool registry. + * + * The handler bodies live in `handlers-packages.ts` and `handlers-kits.ts`; this + * module only names them, so `ToolName` is the single list of what the server can + * do. `tools.ts` asserts that every advertised tool has an entry here, which is + * what stops a tool from being listed and then refusing to run. + */ + +import { searchPackages, getPackage, getRelease, checkCompatibility } from "./handlers-packages"; +import { getKit } from "./handlers-kits"; + +export type ToolName = + | "search_packages" + | "get_package" + | "get_release" + | "check_compatibility" + | "get_kit"; + +export interface ToolContext { + env: Env; + userId?: string | null; +} + +export type ToolHandler = (args: Record, ctx: ToolContext) => Promise; + +export const HANDLERS: Record = { + search_packages: (args, ctx) => searchPackages(args, ctx), + get_package: (args, ctx) => getPackage(args, ctx), + get_release: (args, ctx) => getRelease(args, ctx), + check_compatibility: (args, ctx) => checkCompatibility(args, ctx), + get_kit: (args, ctx) => getKit(args, ctx), +}; diff --git a/apps/web/app/lib/mcp/mcp.test.ts b/apps/web/app/lib/mcp/mcp.test.ts new file mode 100644 index 0000000..d4e9f9e --- /dev/null +++ b/apps/web/app/lib/mcp/mcp.test.ts @@ -0,0 +1,188 @@ +/** + * MCP protocol vocabulary and tool dispatch. + * + * The edge cases here are the ones that silently break a client: a request with + * `id: 0` is a call and must be answered (it is not a notification), a + * notification must never be answered, and a malformed body must become a + * JSON-RPC error rather than an exception. + */ + +import { describe, expect, it } from "vitest"; +import { + MCP_PROTOCOL_VERSION, + RPC, + errorResult, + isNotification, + parseRequest, + rpcError, + rpcResult, + textResult, + type JsonRpcRequest, +} from "./protocol"; +import { TOOL_DEFINITIONS, TOOL_NAMES, callTool, isToolName } from "./tools"; +import type { ToolContext } from "./handlers"; + +const CTX = { env: {} as never, userId: null } satisfies ToolContext; + +function parseText(result: { content: { text: string }[] }): Record { + return JSON.parse(result.content[0].text) as Record; +} + +describe("parseRequest", () => { + it("accepts a well-formed call", () => { + const parsed = parseRequest('{"jsonrpc":"2.0","id":1,"method":"tools/list"}'); + expect("request" in parsed).toBe(true); + expect(("request" in parsed ? parsed.request.method : null)).toBe("tools/list"); + }); + + it("reports malformed JSON as a parse error, not an exception", () => { + const parsed = parseRequest("{not json"); + expect("failure" in parsed).toBe(true); + if ("failure" in parsed) { + expect(parsed.failure.error.code).toBe(RPC.parseError); + expect(parsed.failure.id).toBeNull(); + } + }); + + it("rejects a batch array, which this server does not support", () => { + const parsed = parseRequest("[1,2]"); + expect("failure" in parsed && parsed.failure.error.code).toBe(RPC.invalidRequest); + }); + + it("rejects a request with no method and echoes its id", () => { + const parsed = parseRequest('{"jsonrpc":"2.0","id":7}'); + expect("failure" in parsed && parsed.failure.id).toBe(7); + }); +}); + +describe("isNotification", () => { + it("treats a missing id as a notification", () => { + expect(isNotification({ method: "notifications/initialized" } as JsonRpcRequest)).toBe(true); + }); + + it("treats id 0 as a call, not a notification", () => { + // `0` is falsy, so a truthiness check here would swallow a real call. + expect(isNotification({ id: 0, method: "tools/list" } as JsonRpcRequest)).toBe(false); + }); + + it("treats a null id as a notification, per the JSON-RPC note on null", () => { + expect(isNotification({ id: null, method: "x" } as JsonRpcRequest)).toBe(true); + }); +}); + +describe("envelope builders", () => { + it("builds a result envelope", () => { + expect(rpcResult(1, { ok: true })).toEqual({ jsonrpc: "2.0", id: 1, result: { ok: true } }); + }); + + it("omits `data` when none was supplied", () => { + expect(rpcError(1, RPC.invalidParams, "bad")).toEqual({ + jsonrpc: "2.0", + id: 1, + error: { code: RPC.invalidParams, message: "bad" }, + }); + }); + + it("carries text content for a tool result", () => { + const result = textResult({ a: 1 }); + expect(result.content[0].type).toBe("text"); + expect(result.isError).toBeUndefined(); + }); + + it("marks a tool error as a result, not a transport error", () => { + const result = errorResult("nope", { why: "test" }); + expect(result.isError).toBe(true); + expect(parseText(result)).toEqual({ error: "nope", details: { why: "test" } }); + }); + + it("declares a protocol version", () => { + expect(MCP_PROTOCOL_VERSION).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); +}); + +describe("tool catalog", () => { + it("exposes exactly the five tools the issue names", () => { + expect([...TOOL_NAMES].sort()).toEqual([ + "check_compatibility", + "get_kit", + "get_package", + "get_release", + "search_packages", + ]); + }); + + it("gives every tool a description and a JSON schema it can be called with", () => { + for (const tool of TOOL_DEFINITIONS) { + expect(tool.description.length, tool.name).toBeGreaterThan(40); + expect(tool.inputSchema.type, tool.name).toBe("object"); + expect(Object.keys(tool.inputSchema.properties).length, tool.name).toBeGreaterThan(0); + } + }); + + it("has a handler for every tool it advertises", () => { + // A definition without a handler would be advertised and then refuse to run. + for (const name of TOOL_NAMES) expect(isToolName(name), name).toBe(true); + }); + + it("tells the agent that `unknown` is not support", () => { + const check = TOOL_DEFINITIONS.find((t) => t.name === "check_compatibility"); + expect(check?.description).toContain("`unknown`"); + expect(check?.description).toContain("must not be read as compatibility"); + }); +}); + +describe("callTool", () => { + it("refuses an unknown tool and lists what exists", async () => { + const result = await callTool("does_not_exist", {}, CTX); + expect(result.isError).toBe(true); + // `errorResult` nests extra data under `details`, so the payload is uniform. + expect((parseText(result).details as { availableTools: string[] }).availableTools).toEqual( + TOOL_NAMES, + ); + }); + + it("rejects a missing required argument instead of querying", async () => { + const result = await callTool("search_packages", {}, CTX); + expect(result.isError).toBe(true); + expect(String(parseText(result).error)).toContain("query"); + }); + + it("rejects a whitespace-only required argument", async () => { + const result = await callTool("search_packages", { query: " " }, CTX); + expect(result.isError).toBe(true); + }); + + it("rejects a missing slug for the detail tools", async () => { + for (const name of ["get_package", "check_compatibility", "get_kit"]) { + const result = await callTool(name, {}, CTX); + expect(result.isError, name).toBe(true); + expect(String(parseText(result).error), name).toContain("slug"); + } + }); + + it("requires a package name for get_release", async () => { + const result = await callTool("get_release", {}, CTX); + expect(result.isError).toBe(true); + expect(String(parseText(result).error)).toContain("package"); + }); + + it("rejects a missing target for check_compatibility", async () => { + const result = await callTool("check_compatibility", { slug: "find-skills" }, CTX); + expect(result.isError).toBe(true); + expect(String(parseText(result).error)).toContain("target"); + }); + + it("treats non-object arguments as no arguments rather than throwing", async () => { + const result = await callTool("get_package", "not-an-object", CTX); + expect(result.isError).toBe(true); + expect(String(parseText(result).error)).toContain("slug"); + }); + + it("converts a handler failure into a tool error instead of rejecting", async () => { + // `{}` has no DB binding, so the handler throws inside the storage layer. + // The call must still resolve, with isError, so the agent sees why. + const result = await callTool("get_package", { slug: "find-skills" }, CTX); + expect(result.isError).toBe(true); + expect(String(parseText(result).error)).toContain("get_package"); + }); +}); diff --git a/apps/web/app/lib/mcp/protocol.ts b/apps/web/app/lib/mcp/protocol.ts new file mode 100644 index 0000000..4b3261b --- /dev/null +++ b/apps/web/app/lib/mcp/protocol.ts @@ -0,0 +1,123 @@ +/** + * MCP / JSON-RPC 2.0 vocabulary for the remote read-only server. + * + * Deliberately a subset of MCP: `initialize`, `tools/list`, `tools/call`, and + * the `notifications/initialized` notification. The server is read-only, so + * resources, prompts, sampling, and roots are not implemented rather than + * half-implemented. + * + * Two protocol details the rest of the code depends on: + * - A request without an `id` is a notification and MUST NOT be answered. + * Replying to one is a protocol violation, so the check is centralized here. + * - A tool that fails at runtime is a successful JSON-RPC call whose result + * carries `isError: true`, not a JSON-RPC error object. Only a malformed + * request or an unknown method is a JSON-RPC error. + */ + +export const MCP_PROTOCOL_VERSION = "2025-06-18"; +export const SERVER_NAME = "skillx"; +export const SERVER_VERSION = "0.1.0"; + +/** JSON-RPC 2.0 error codes, plus the MCP "method not found" case. */ +export const RPC = { + parseError: -32700, + invalidRequest: -32600, + methodNotFound: -32601, + invalidParams: -32602, + internalError: -32603, +} as const; + +export type JsonRpcId = string | number | null; + +export interface JsonRpcRequest { + jsonrpc?: unknown; + id?: unknown; + method?: unknown; + params?: unknown; +} + +export interface TextContent { + type: "text"; + text: string; +} + +export interface ToolResult { + content: TextContent[]; + isError?: boolean; +} + +/** Thrown by a handler when the caller's arguments are unusable. */ +export class ToolInputError extends Error {} + +/** + * Narrows `params.arguments` to a plain object. Anything else (a list, a string, + * `null`) becomes `{}`, so handlers validate fields rather than shapes. + */ +export function asRecord(args: unknown): Record { + return typeof args === "object" && args !== null && !Array.isArray(args) + ? (args as Record) + : {}; +} + +export function rpcResult(id: JsonRpcId, result: unknown) { + return { jsonrpc: "2.0" as const, id, result }; +} + +export function rpcError(id: JsonRpcId, code: number, message: string, data?: unknown) { + return { + jsonrpc: "2.0" as const, + id, + error: { code, message, ...(data === undefined ? {} : { data }) }, + }; +} + +export function normalizeId(value: unknown): JsonRpcId { + return typeof value === "string" || typeof value === "number" ? value : null; +} + +/** + * A request with no `id` is a notification. `null` counts as absent: JSON-RPC + * reserves `null` for responses to unparseable requests. + */ +export function isNotification(request: JsonRpcRequest): boolean { + return request.id === undefined || request.id === null; +} + +export type ParseOutcome = + | { request: JsonRpcRequest } + | { failure: ReturnType }; + +/** Parses one JSON-RPC message, returning a JSON-RPC error instead of throwing. */ +export function parseRequest(raw: string): ParseOutcome { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { failure: rpcError(null, RPC.parseError, "Parse error") }; + } + + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return { failure: rpcError(null, RPC.invalidRequest, "Expected a single JSON-RPC object") }; + } + + const request = parsed as JsonRpcRequest; + if (typeof request.method !== "string" || request.method.length === 0) { + return { failure: rpcError(normalizeId(request.id), RPC.invalidRequest, "Missing method") }; + } + + return { request }; +} + +/** Serializes a value as the single text content block MCP expects. */ +export function textResult(value: unknown): ToolResult { + return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] }; +} + +/** + * A tool-level failure. Returned as a result with `isError`, because the call + * itself succeeded and the agent needs to see why the tool declined. + */ +export function errorResult(message: string, data?: unknown): ToolResult { + const payload = { error: message, ...(data === undefined ? {} : { details: data }) }; + return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], isError: true }; +} diff --git a/apps/web/app/lib/mcp/tools.ts b/apps/web/app/lib/mcp/tools.ts new file mode 100644 index 0000000..038759c --- /dev/null +++ b/apps/web/app/lib/mcp/tools.ts @@ -0,0 +1,137 @@ +/** + * MCP tool catalog and dispatch. + * + * Descriptions are load-bearing: they are the only thing an agent reads before + * choosing a tool, so each one states what the tool does *and* what it will not + * do — notably that `unknown` compatibility is not support and that + * `--compatible`/`compatible` is runtime-only. An optimistic description here + * would produce confident wrong answers downstream. + */ + +import { HANDLERS, type ToolContext, type ToolName } from "./handlers"; +import { ToolInputError, asRecord, errorResult, textResult, type ToolResult } from "./protocol"; + +export interface McpToolDefinition { + name: ToolName; + title: string; + description: string; + inputSchema: { + type: "object"; + properties: Record; + required?: string[]; + additionalProperties: boolean; + }; +} + +const STRING = { type: "string" } as const; +const LIMIT = { type: "number", minimum: 1, maximum: 100 } as const; + +export const TOOL_DEFINITIONS: McpToolDefinition[] = [ + { + name: "search_packages", + title: "Search packages", + description: + "Search the SkillX catalog with hybrid semantic + keyword ranking. " + + "Set `compatible` to a runtime name to keep only listings that declare that runtime; " + + "listings that declare nothing are excluded and counted in `compatibilityFilter.undecided`, " + + "so an empty or short result is explained in the response rather than silent. " + + "`compatible` takes a runtime only, not a version.", + inputSchema: { + type: "object", + properties: { query: STRING, category: STRING, compatible: STRING, limit: LIMIT }, + required: ["query"], + additionalProperties: false, + }, + }, + { + name: "get_package", + title: "Get package", + description: + "Fetch one listing by slug: metadata, references, scripts, and its declared compatibility. " + + "Pass `target` as `runtime` or `runtime@version` to also get the resolved answer for that " + + "runtime. Sets `contentIncluded` to say whether the SKILL.md body is part of this response; " + + "a protected listing answers without its body rather than with an empty one.", + inputSchema: { + type: "object", + properties: { slug: STRING, target: STRING }, + required: ["slug"], + additionalProperties: false, + }, + }, + { + name: "get_release", + title: "Get release", + description: + "Read immutable release records for a package from the registry, newest first, optionally " + + "pinned to one `version`. Returns `found: false` with a reason code (`no_package`, " + + "`no_releases`, `unknown_version`) when nothing is recorded — the registry is settled in a " + + "later phase, so `no_package` is the expected answer for most of today's catalog.", + inputSchema: { + type: "object", + properties: { package: STRING, version: STRING }, + required: ["package"], + additionalProperties: false, + }, + }, + { + name: "check_compatibility", + title: "Check compatibility", + description: + "Ask whether one listing supports a specific `runtime` or `runtime@version`. Returns the " + + "status plus machine-readable reason codes. Only `declared` and `verified` mean support; " + + "`unknown` means no claim was ever made and must not be read as compatibility. `verified` " + + "requires evidence bound to the exact artifact digest, so a mutable listing can never reach it.", + inputSchema: { + type: "object", + properties: { slug: STRING, target: STRING }, + required: ["slug", "target"], + additionalProperties: false, + }, + }, + { + name: "get_kit", + title: "Get kit", + description: + "Fetch a published kit (a collection revision) by slug, including its members and config. " + + "Only public kits are readable; a private or unknown slug returns `not_found` so the tool " + + "cannot be used to discover private slugs.", + inputSchema: { + type: "object", + properties: { slug: STRING }, + required: ["slug"], + additionalProperties: false, + }, + }, +]; + +export const TOOL_NAMES: string[] = TOOL_DEFINITIONS.map((tool) => tool.name); + +export function isToolName(name: string): name is ToolName { + return Object.hasOwn(HANDLERS, name); +} + +/** + * Runs one tool. Argument problems and handler failures come back as tool + * results with `isError`, because the call itself succeeded and the agent needs + * the reason; only an unknown tool name is reported as such. + */ +export async function callTool( + name: string, + rawArgs: unknown, + ctx: ToolContext, +): Promise { + if (!isToolName(name)) { + return errorResult(`Unknown tool: ${name}`, { availableTools: TOOL_NAMES }); + } + + try { + return textResult(await HANDLERS[name](asRecord(rawArgs), ctx)); + } catch (error) { + if (error instanceof ToolInputError) return errorResult(error.message); + console.error(`MCP tool ${name} failed:`, error); + return errorResult( + `${name} failed`, + error instanceof Error ? error.message : "Unknown error", + ); + } +} diff --git a/apps/web/app/routes.ts b/apps/web/app/routes.ts index f64265c..420ffec 100644 --- a/apps/web/app/routes.ts +++ b/apps/web/app/routes.ts @@ -14,6 +14,7 @@ export default [ route("leaderboard", "routes/leaderboard.tsx"), route("api/auth/*", "routes/auth-catchall.tsx"), route("api/search", "routes/api.search.ts"), + route("api/mcp", "routes/api.mcp.ts"), route("api/leaderboard", "routes/api.leaderboard.ts"), route("api/admin/seed", "routes/api.admin.seed.ts"), route("api/admin/recompute", "routes/api.admin.recompute.ts"), diff --git a/apps/web/app/routes/api.mcp.test.ts b/apps/web/app/routes/api.mcp.test.ts new file mode 100644 index 0000000..d448781 --- /dev/null +++ b/apps/web/app/routes/api.mcp.test.ts @@ -0,0 +1,114 @@ +/** + * Route-level tests for the remote MCP endpoint. + * + * This is the surface a client actually talks to, so the assertions are about + * what goes on the wire: which HTTP status each case gets, whether a + * notification is answered at all, and whether an unknown tool is a JSON-RPC + * success carrying `isError` (it is) or a transport error (it is not). + */ + +import { describe, expect, it, vi } from "vitest"; + +vi.mock("~/lib/auth/authenticate-request", () => ({ + authenticateRequest: async () => null, +})); + +import { action, loader } from "./api.mcp"; +import { MCP_PROTOCOL_VERSION, RPC } from "~/lib/mcp/protocol"; + +const CTX = { cloudflare: { env: {} } }; + +async function post(body: string) { + const response = (await action({ + request: new Request("https://skillx.sh/api/mcp", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }), + context: CTX, + } as never)) as Response; + const text = await response.clone().text(); + return { response, text, json: text ? JSON.parse(text) : null }; +} + +describe("POST /api/mcp", () => { + it("answers initialize with its protocol version and tool capability", async () => { + const { response, json } = await post( + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}', + ); + + expect(response.status).toBe(200); + expect(json.result.protocolVersion).toBe(MCP_PROTOCOL_VERSION); + expect(json.result.capabilities.tools).toEqual({ listChanged: false }); + expect(json.result.serverInfo.name).toBe("skillx"); + }); + + it("tells the client that `unknown` compatibility is not support", async () => { + const { json } = await post('{"jsonrpc":"2.0","id":1,"method":"initialize"}'); + expect(json.result.instructions).toContain("never read `unknown` as support"); + }); + + it("lists the five tools", async () => { + const { response, json } = await post('{"jsonrpc":"2.0","id":2,"method":"tools/list"}'); + expect(response.status).toBe(200); + expect(json.result.tools).toHaveLength(5); + expect(json.result.tools.map((t: { name: string }) => t.name)).toContain("check_compatibility"); + }); + + it("returns an unknown tool as a JSON-RPC success with isError, not a transport error", async () => { + const { response, json } = await post( + '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"nope","arguments":{}}}', + ); + + expect(response.status).toBe(200); + expect(json.error).toBeUndefined(); + expect(json.result.isError).toBe(true); + }); + + it("rejects tools/call without a name as invalid params", async () => { + const { json } = await post('{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{}}'); + expect(json.error.code).toBe(RPC.invalidParams); + }); + + it("answers a notification with 202 and no body, because notifications are not answered", async () => { + const { response, text } = await post( + '{"jsonrpc":"2.0","method":"notifications/initialized"}', + ); + expect(response.status).toBe(202); + expect(text).toBe(""); + }); + + it("still answers a call whose id is 0", async () => { + const { response, json } = await post('{"jsonrpc":"2.0","id":0,"method":"tools/list"}'); + expect(response.status).toBe(200); + expect(json.id).toBe(0); + expect(json.result.tools).toHaveLength(5); + }); + + it("reports malformed JSON as a parse error with a 400", async () => { + const { response, json } = await post("{not json"); + expect(response.status).toBe(400); + expect(json.error.code).toBe(RPC.parseError); + }); + + it("names the supported methods when one is unknown", async () => { + const { response, json } = await post('{"jsonrpc":"2.0","id":5,"method":"resources/list"}'); + expect(response.status).toBe(404); + expect(json.error.code).toBe(RPC.methodNotFound); + expect(json.error.data.supportedMethods).toContain("tools/call"); + }); +}); + +describe("GET /api/mcp", () => { + it("refuses GET with the reason instead of holding a stream open", async () => { + const response = (await loader({ + request: new Request("https://skillx.sh/api/mcp"), + context: CTX, + } as never)) as Response; + + expect(response.status).toBe(405); + expect(response.headers.get("Allow")).toBe("POST"); + const body = (await response.json()) as { error: string }; + expect(body.error).toContain("POST-only"); + }); +}); diff --git a/apps/web/app/routes/api.mcp.ts b/apps/web/app/routes/api.mcp.ts new file mode 100644 index 0000000..63ad19d --- /dev/null +++ b/apps/web/app/routes/api.mcp.ts @@ -0,0 +1,125 @@ +/** + * Remote read-only MCP server. + * + * `POST /api/mcp` speaks JSON-RPC 2.0 over the MCP streamable-HTTP transport. + * Implemented: `initialize`, `notifications/initialized`, `tools/list`, + * `tools/call`. Everything else is answered with `method not found` rather than + * being stubbed, so a client can see exactly what this server does. + * + * Two protocol rules are enforced here rather than in the tool layer: + * - a request with no `id` is a notification and is answered with 202 and no + * body; replying to one is a protocol violation. + * - the tool handlers are thin adapters over the same executors as the HTTP + * API and the CLI, so MCP cannot drift from them. + */ + +import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; +import { authenticateRequest } from "~/lib/auth/authenticate-request"; +import { callTool, TOOL_DEFINITIONS } from "~/lib/mcp/tools"; +import { + MCP_PROTOCOL_VERSION, + RPC, + SERVER_NAME, + SERVER_VERSION, + isNotification, + normalizeId, + parseRequest, + rpcError, + rpcResult, +} from "~/lib/mcp/protocol"; + +/** + * No CORS headers on purpose. MCP clients are not browsers, and the WebMCP layer + * (task 8) runs in-page on this same origin, so nothing in scope needs + * cross-origin access. Opening it up would only widen the surface, and a + * wildcard with `Allow-Credentials` is exactly the mistake worth not making + * later, so this endpoint stays same-origin. + */ +const JSON_HEADERS = { "Content-Type": "application/json" }; + +export async function action({ request, context }: ActionFunctionArgs) { + const env = context.cloudflare.env as Env; + + let raw: string; + try { + raw = await request.text(); + } catch (error) { + console.error("MCP: could not read request body:", error); + return Response.json(rpcError(null, RPC.parseError, "Could not read request body"), { + status: 400, + headers: JSON_HEADERS, + }); + } + + const parsed = parseRequest(raw); + if ("failure" in parsed) { + return Response.json(parsed.failure, { status: 400, headers: JSON_HEADERS }); + } + + const { request: rpc } = parsed; + const id = normalizeId(rpc.id); + + // A notification is never answered. `initialized` is the only one defined here. + if (isNotification(rpc)) { + return new Response(null, { status: 202, headers: JSON_HEADERS }); + } + + const userId = (await authenticateRequest(request, env))?.userId ?? null; + + switch (rpc.method) { + case "initialize": + return Response.json( + rpcResult(id, { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: SERVER_NAME, version: SERVER_VERSION }, + instructions: + "Read-only SkillX catalog. Compatibility statuses: `declared` is a publisher claim, " + + "`verified` is evidence bound to an artifact digest, and `unknown` is the absence of " + + "a claim — never read `unknown` as support.", + }), + { headers: JSON_HEADERS }, + ); + + case "tools/list": + return Response.json( + rpcResult(id, { tools: TOOL_DEFINITIONS }), + { headers: JSON_HEADERS }, + ); + + case "tools/call": { + const params = (rpc.params ?? {}) as { name?: unknown; arguments?: unknown }; + if (typeof params.name !== "string" || params.name.length === 0) { + return Response.json( + rpcError(id, RPC.invalidParams, "tools/call requires a string `name`"), + { headers: JSON_HEADERS }, + ); + } + const result = await callTool(params.name, params.arguments, { env, userId }); + return Response.json(rpcResult(id, result), { headers: JSON_HEADERS }); + } + + default: + return Response.json( + rpcError(id, RPC.methodNotFound, `Method not found: ${String(rpc.method)}`, { + supportedMethods: ["initialize", "tools/list", "tools/call"], + }), + { status: 404, headers: JSON_HEADERS }, + ); + } +} + +/** + * This server does not open a server-initiated SSE stream, so GET is refused with + * the reason instead of returning an empty 200 that a client would wait on. + */ +export function loader(_args: LoaderFunctionArgs) { + return Response.json( + { + error: "This MCP endpoint is POST-only and does not open a server-sent event stream.", + transport: "streamable-http", + protocolVersion: MCP_PROTOCOL_VERSION, + }, + { status: 405, headers: { ...JSON_HEADERS, Allow: "POST" } }, + ); +} From b09f5395a302f565be20948df9ef47941df4f66e Mon Sep 17 00:00:00 2001 From: Goon Date: Tue, 22 Sep 2026 07:55:37 +0200 Subject: [PATCH 10/19] feat(webmcp): expose read tools to in-page agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds WebMCP as progressive enhancement: when navigator.modelContext exists the page registers three read tools (search_skills, get_skill, check_skill_compatibility); when it does not, the bridge renders nothing and the site behaves identically. WebMCP is a draft API, so detection checks the shape of the object rather than trusting presence. A renamed or partially implemented modelContext falls back to "unavailable" instead of throwing inside a page effect, and a registration that throws is reported as `failed`, never as `registered` — claiming success for a rejected registration would make the failure invisible. Each tool calls the same public HTTP endpoint the page itself uses, rather than importing server executors. One implementation means an in-page agent cannot get a different answer from the API, and the protected payload boundary stays in exactly one place: the server. `fetchImpl` is injected so the tools are testable without a live network. Verified: pnpm typecheck clean; pnpm test 348 passed / 26 files, including 18 for detection, registration, and the tools' request shapes; pnpm build ok. --- apps/web/app/components/webmcp-bridge.tsx | 27 ++++ apps/web/app/lib/webmcp/model-context.ts | 77 ++++++++++ apps/web/app/lib/webmcp/read-tools.ts | 114 ++++++++++++++ apps/web/app/lib/webmcp/webmcp.test.ts | 173 ++++++++++++++++++++++ apps/web/app/root.tsx | 9 +- 5 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 apps/web/app/components/webmcp-bridge.tsx create mode 100644 apps/web/app/lib/webmcp/model-context.ts create mode 100644 apps/web/app/lib/webmcp/read-tools.ts create mode 100644 apps/web/app/lib/webmcp/webmcp.test.ts diff --git a/apps/web/app/components/webmcp-bridge.tsx b/apps/web/app/components/webmcp-bridge.tsx new file mode 100644 index 0000000..d7b55a5 --- /dev/null +++ b/apps/web/app/components/webmcp-bridge.tsx @@ -0,0 +1,27 @@ +/** + * Mounts the WebMCP read tools for the page. + * + * Renders nothing and is inert on browsers without the API: WebMCP is a + * progressive enhancement, so the site must behave identically when it is + * absent. Registration happens once per mount, and the outcome is only logged + * when it actually succeeded — logging "registered" for a failed registration + * would hide the failure. + */ + +import { useEffect } from "react"; +import { registerWebMcpTools } from "~/lib/webmcp/model-context"; +import { createReadTools } from "~/lib/webmcp/read-tools"; + +export function WebMcpBridge() { + useEffect(() => { + const nav = globalThis.navigator as (Navigator & { modelContext?: unknown }) | undefined; + if (!nav) return; + + const outcome = registerWebMcpTools(nav, createReadTools({ fetchImpl: fetch })); + if (outcome === "registered") { + console.debug("WebMCP: read tools registered"); + } + }, []); + + return null; +} diff --git a/apps/web/app/lib/webmcp/model-context.ts b/apps/web/app/lib/webmcp/model-context.ts new file mode 100644 index 0000000..eb0bd8a --- /dev/null +++ b/apps/web/app/lib/webmcp/model-context.ts @@ -0,0 +1,77 @@ +/** + * WebMCP feature detection and registration. + * + * WebMCP is a draft browser API, so nothing here assumes it exists and nothing + * requires it: the site is fully functional when `navigator.modelContext` is + * absent. Detection checks the shape of the object rather than trusting + * presence, because a partially implemented or renamed draft API must fall back + * to "unavailable" instead of throwing inside a page effect. + * + * Registration reports what actually happened. A registration that throws is + * reported as `failed`, never as `registered`, since claiming success for a + * rejected registration would make the failure invisible. + */ + +export interface WebMcpTool { + name: string; + description: string; + inputSchema: Record; + execute: (args: Record) => Promise; +} + +export interface ModelContextLike { + provideContext?: (context: { tools: unknown[] }) => void; + registerTool?: (tool: unknown) => void; +} + +export interface NavigatorLike { + modelContext?: unknown; +} + +export type RegistrationOutcome = "registered" | "unavailable" | "failed"; + +/** Returns the model context when the API looks usable, otherwise null. */ +export function getModelContext(nav: NavigatorLike | undefined): ModelContextLike | null { + const context = nav?.modelContext; + if (typeof context !== "object" || context === null) return null; + + const candidate = context as ModelContextLike; + const usable = + typeof candidate.provideContext === "function" || typeof candidate.registerTool === "function"; + return usable ? candidate : null; +} + +export function isWebMcpAvailable(nav: NavigatorLike | undefined): boolean { + return getModelContext(nav) !== null; +} + +/** + * Registers the read tools with the page's model context. + * + * Prefers the batch `provideContext({ tools })` form and falls back to + * `registerTool` per tool for narrower implementations. Never throws: the caller + * is a page effect, and a draft API failing must not break rendering. + */ +export function registerWebMcpTools( + nav: NavigatorLike | undefined, + tools: WebMcpTool[], +): RegistrationOutcome { + const context = getModelContext(nav); + if (!context) return "unavailable"; + if (tools.length === 0) return "registered"; + + try { + if (typeof context.provideContext === "function") { + context.provideContext({ tools }); + return "registered"; + } + if (typeof context.registerTool === "function") { + for (const tool of tools) context.registerTool(tool); + return "registered"; + } + return "unavailable"; + } catch (error) { + console.error("WebMCP tool registration failed:", error); + return "failed"; + } +} diff --git a/apps/web/app/lib/webmcp/read-tools.ts b/apps/web/app/lib/webmcp/read-tools.ts new file mode 100644 index 0000000..e08b0b5 --- /dev/null +++ b/apps/web/app/lib/webmcp/read-tools.ts @@ -0,0 +1,114 @@ +/** + * WebMCP read tools. + * + * Each tool calls the same public HTTP endpoint the page itself uses — the + * search API and the detail API — rather than importing server executors or + * re-deriving answers. One implementation means an in-page agent cannot get a + * different answer from the API, and the protected payload boundary stays in + * exactly one place: the server. + * + * `fetchImpl` is injected so the tools are testable without a live network and + * so the caller controls credentials and origin. + */ + +import type { WebMcpTool } from "./model-context"; + +const STRING = { type: "string" } as const; + +async function readJson(response: Response): Promise { + const text = await response.text(); + if (!text) return null; + try { + return JSON.parse(text) as unknown; + } catch { + return { error: "Response was not valid JSON", status: response.status }; + } +} + +function asRecord(args: unknown): Record { + return typeof args === "object" && args !== null && !Array.isArray(args) + ? (args as Record) + : {}; +} + +export interface ReadToolOptions { + fetchImpl: typeof fetch; + /** Trailing slashes are tolerated; the base is normalized. */ + baseUrl?: string; +} + +export function createReadTools(options: ReadToolOptions): WebMcpTool[] { + const { fetchImpl } = options; + const base = (options.baseUrl ?? "").replace(/\/+$/, ""); + + return [ + { + name: "search_skills", + description: + "Search the SkillX catalog. Pass `compatible` with a runtime name to keep only listings " + + "that declare it; the response reports how many listings were undecided and therefore " + + "excluded, so an empty result is explained. `compatible` takes a runtime, not a version.", + inputSchema: { + type: "object", + properties: { query: STRING, category: STRING, compatible: STRING }, + required: ["query"], + additionalProperties: false, + }, + execute: async (rawArgs) => { + const args = asRecord(rawArgs); + const response = await fetchImpl(`${base}/api/search`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: args.query, + ...(args.category ? { category: args.category } : {}), + ...(args.compatible ? { compatible: args.compatible } : {}), + }), + }); + return readJson(response); + }, + }, + { + name: "get_skill", + description: + "Fetch one skill by slug: metadata, references, scripts, and its declared compatibility. " + + "The SKILL.md body is present only when the caller is allowed to read it; a protected " + + "listing answers without its body rather than with an empty one.", + inputSchema: { + type: "object", + properties: { slug: STRING, target: STRING }, + required: ["slug"], + additionalProperties: false, + }, + execute: async (rawArgs) => { + const args = asRecord(rawArgs); + const slug = encodeURIComponent(String(args.slug ?? "")); + const target = args.target ? `?target=${encodeURIComponent(String(args.target))}` : ""; + const response = await fetchImpl(`${base}/api/skills/${slug}${target}`); + return readJson(response); + }, + }, + { + name: "check_skill_compatibility", + description: + "Ask whether one skill supports a `runtime` or `runtime@version`. Only `declared` and " + + "`verified` mean support; `unknown` means no claim was made and must not be read as " + + "compatibility.", + inputSchema: { + type: "object", + properties: { slug: STRING, target: STRING }, + required: ["slug", "target"], + additionalProperties: false, + }, + execute: async (rawArgs) => { + const args = asRecord(rawArgs); + const slug = encodeURIComponent(String(args.slug ?? "")); + const target = encodeURIComponent(String(args.target ?? "")); + const response = await fetchImpl(`${base}/api/skills/${slug}?target=${target}`); + const body = await readJson(response); + const record = asRecord(body); + return { slug: args.slug, target: args.target, compatibility: record.compatibility ?? null }; + }, + }, + ]; +} diff --git a/apps/web/app/lib/webmcp/webmcp.test.ts b/apps/web/app/lib/webmcp/webmcp.test.ts new file mode 100644 index 0000000..ab0af33 --- /dev/null +++ b/apps/web/app/lib/webmcp/webmcp.test.ts @@ -0,0 +1,173 @@ +/** + * WebMCP detection, registration, and read tools. + * + * WebMCP is a draft API, so the properties that matter are the fallbacks: an + * absent or malformed `navigator.modelContext` must be inert, a registration + * that throws must not be reported as successful, and nothing here may throw + * into a page render. + */ + +import { describe, expect, it, vi } from "vitest"; +import { + getModelContext, + isWebMcpAvailable, + registerWebMcpTools, + type WebMcpTool, +} from "./model-context"; +import { createReadTools } from "./read-tools"; + +function tool(name = "t"): WebMcpTool { + return { name, description: "d", inputSchema: { type: "object" }, execute: async () => null }; +} + +describe("feature detection", () => { + it("is inert when navigator is absent", () => { + expect(getModelContext(undefined)).toBeNull(); + expect(isWebMcpAvailable(undefined)).toBe(false); + }); + + it("is inert when modelContext is missing", () => { + expect(getModelContext({})).toBeNull(); + }); + + it("is inert when modelContext is not an object", () => { + expect(getModelContext({ modelContext: "yes" })).toBeNull(); + expect(getModelContext({ modelContext: null })).toBeNull(); + }); + + it("is inert when modelContext has neither known method", () => { + // A renamed or partial draft API must fall back, not throw later. + expect(getModelContext({ modelContext: { tools: [] } })).toBeNull(); + }); + + it("detects provideContext", () => { + expect(isWebMcpAvailable({ modelContext: { provideContext: () => {} } })).toBe(true); + }); + + it("detects registerTool", () => { + expect(isWebMcpAvailable({ modelContext: { registerTool: () => {} } })).toBe(true); + }); +}); + +describe("registration", () => { + it("reports unavailable rather than throwing", () => { + expect(registerWebMcpTools(undefined, [tool()])).toBe("unavailable"); + }); + + it("registers all tools in one provideContext call when available", () => { + const provideContext = vi.fn(); + const outcome = registerWebMcpTools({ modelContext: { provideContext } }, [tool("a"), tool("b")]); + + expect(outcome).toBe("registered"); + expect(provideContext).toHaveBeenCalledTimes(1); + expect(provideContext.mock.calls[0][0].tools).toHaveLength(2); + }); + + it("falls back to registerTool per tool", () => { + const registerTool = vi.fn(); + const outcome = registerWebMcpTools({ modelContext: { registerTool } }, [tool("a"), tool("b")]); + + expect(outcome).toBe("registered"); + expect(registerTool).toHaveBeenCalledTimes(2); + }); + + it("reports a throwing registration as failed, never as registered", () => { + const provideContext = vi.fn(() => { + throw new Error("rejected"); + }); + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + + expect(registerWebMcpTools({ modelContext: { provideContext } }, [tool()])).toBe("failed"); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); + + it("does not throw when the empty tool list is registered", () => { + expect(registerWebMcpTools({ modelContext: { provideContext: () => {} } }, [])).toBe( + "registered", + ); + }); +}); + +function fakeFetch(payload: unknown, capture: { url?: string; init?: RequestInit } = {}) { + return (async (url: string | URL | Request, init?: RequestInit) => { + capture.url = String(url); + capture.init = init; + return new Response(JSON.stringify(payload), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as unknown as typeof fetch; +} + +describe("read tools", () => { + it("exposes the three read tools", () => { + const tools = createReadTools({ fetchImpl: fakeFetch({}), baseUrl: "https://skillx.sh/" }); + expect(tools.map((t) => t.name)).toEqual(["search_skills", "get_skill", "check_skill_compatibility"]); + }); + + it("posts a search to the same endpoint the page uses", async () => { + const capture: { url?: string; init?: RequestInit } = {}; + const tools = createReadTools({ fetchImpl: fakeFetch({ results: [] }, capture), baseUrl: "" }); + const search = tools.find((t) => t.name === "search_skills"); + + await search?.execute({ query: "pdf", compatible: "agentkit" }); + + expect(capture.url).toBe("/api/search"); + expect(capture.init?.method).toBe("POST"); + expect(JSON.parse(String(capture.init?.body))).toEqual({ + query: "pdf", + compatible: "agentkit", + }); + }); + + it("omits optional search fields instead of sending nulls", async () => { + const capture: { url?: string; init?: RequestInit } = {}; + const tools = createReadTools({ fetchImpl: fakeFetch({}, capture) }); + await tools.find((t) => t.name === "search_skills")?.execute({ query: "pdf" }); + + expect(JSON.parse(String(capture.init?.body))).toEqual({ query: "pdf" }); + }); + + it("encodes the slug and target when fetching one skill", async () => { + const capture: { url?: string } = {}; + const tools = createReadTools({ fetchImpl: fakeFetch({}, capture) }); + await tools.find((t) => t.name === "get_skill")?.execute({ slug: "a/b", target: "agentkit@1.2.3" }); + + expect(capture.url).toBe("/api/skills/a%2Fb?target=agentkit%401.2.3"); + }); + + it("narrows the compatibility answer to the requested target", async () => { + const payload = { compatibility: { declared: [], target: { status: "unknown" } } }; + const tools = createReadTools({ fetchImpl: fakeFetch(payload) }); + const result = (await tools + .find((t) => t.name === "check_skill_compatibility") + ?.execute({ slug: "find-skills", target: "agentkit" })) as Record; + + expect(result.slug).toBe("find-skills"); + expect(result.compatibility).toEqual(payload.compatibility); + }); + + it("treats non-object arguments as no arguments rather than throwing", async () => { + const capture: { url?: string } = {}; + const tools = createReadTools({ fetchImpl: fakeFetch({}, capture) }); + // Cast past the type: the point is runtime robustness against a caller that + // sends something other than an object, which an in-page agent can do. + await expect( + tools.find((t) => t.name === "get_skill")?.execute("nope" as never), + ).resolves.toBeDefined(); + }); + + it("reports an unparseable response instead of returning a wrong shape", async () => { + const textFetch = (async () => + new Response("not json", { status: 502 })) as unknown as typeof fetch; + const tools = createReadTools({ fetchImpl: textFetch }); + const result = (await tools.find((t) => t.name === "get_skill")?.execute({ slug: "x" })) as Record< + string, + unknown + >; + + expect(result.error).toContain("not valid JSON"); + expect(result.status).toBe(502); + }); +}); diff --git a/apps/web/app/root.tsx b/apps/web/app/root.tsx index e5b32f5..cafd7ce 100644 --- a/apps/web/app/root.tsx +++ b/apps/web/app/root.tsx @@ -11,6 +11,7 @@ import type { Route } from "./+types/root"; import "./app.css"; import { Navbar } from "./components/layout/navbar"; import { Footer } from "./components/layout/footer"; +import { WebMcpBridge } from "./components/webmcp-bridge"; export const links: Route.LinksFunction = () => [ { rel: "preconnect", href: "https://fonts.googleapis.com" }, @@ -49,7 +50,13 @@ export function Layout({ children }: { children: React.ReactNode }) { } export default function App() { - return ; + return ( + <> + + {/* Progressive enhancement: inert where WebMCP is unavailable. */} + + + ); } export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { From f0ee90465cf565610d1a7179cb058b2b38ead240 Mon Sep 17 00:00:00 2001 From: Goon Date: Tue, 22 Sep 2026 07:58:21 +0200 Subject: [PATCH 11/19] test(parity): compare the compatibility surfaces against each other Phase 1's claim is that API, CLI, MCP, and Markdown give one answer for one listing because they delegate to one engine. Unit tests inside each surface cannot show that: they would keep passing if a surface quietly grew its own rule. These tests compare the surfaces to each other, so a divergence fails here. Covered against one fixture and one target: - the web adapter is byte-identical to the engine for five declarations (none, in range, explicit unsupported, out of range, unparseable range), proving the adapter adds no rule of its own; - the real MCP tool, driven through callTool with only storage stubbed, returns the engine's status and reasons unchanged, and reports a missing listing as `not_found` rather than collapsing it into `unknown`; - the Markdown renderer prints the same status the engine returned, built through the real `toCompatibilitySummary` adapter, and never claims `verified` for a listing that has no evidence. Two honest limits, recorded in the file rather than left implicit. The CLI is not imported here because apps/web/tsconfig.cloudflare.json lists its own files only, so a cross-package import fails the project boundary; the CLI's rule is pinned by value in its own test. And that leaves the CLI as the one surface holding a hand-written mirror of `isCompatibilityActionable` instead of calling it, which is the drift this file cannot catch. Verified: pnpm typecheck clean; pnpm test 362 passed / 27 files. --- .../lib/parity/compatibility-parity.test.ts | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 apps/web/app/lib/parity/compatibility-parity.test.ts diff --git a/apps/web/app/lib/parity/compatibility-parity.test.ts b/apps/web/app/lib/parity/compatibility-parity.test.ts new file mode 100644 index 0000000..98bac6c --- /dev/null +++ b/apps/web/app/lib/parity/compatibility-parity.test.ts @@ -0,0 +1,197 @@ +/** + * Cross-surface parity. + * + * Phase 1's claim is that API, CLI, MCP, and Markdown all give one answer for + * one listing, because they delegate to one compatibility engine. Unit tests + * inside each surface cannot show that; they would keep passing if a surface + * quietly grew its own rule. These tests compare the surfaces against each + * other, so a divergence between any two of them fails here. + * + * The MCP case drives the real tool through `callTool` with only the storage + * layer stubbed, so it exercises the handler and the protocol envelope rather + * than a re-implementation of them. + * + * The CLI is not imported here: `apps/web/tsconfig.cloudflare.json` lists its own + * files only, so a cross-package import fails the project boundary. The CLI's + * rule is pinned by value in packages/cli/src/lib/skill-lookup.test.ts, and its + * drift risk is that it is the one surface holding a hand-written mirror of + * `isCompatibilityActionable` instead of calling it. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + COMPATIBILITY_STATUSES, + isCompatibilityActionable, + resolveCatalogCompatibility, + toCompatibilitySummary, + type CompatibilityStatus, +} from "@skillx/contracts"; + +const h = vi.hoisted(() => { + const state = { row: null as Record | null }; + /** Minimal thenable mimicking drizzle's chainable query builder. */ + function chain(result: unknown) { + const promise = Promise.resolve(result); + const self = { + from: () => self, + where: () => self, + orderBy: () => self, + limit: () => self, + get: () => Promise.resolve(Array.isArray(result) ? (result[0] ?? null) : result), + then: (ok: unknown, bad: unknown) => promise.then(ok as never, bad as never), + }; + return self; + } + return { state, chain }; +}); + +vi.mock("~/lib/db", () => ({ + getDb: () => ({ select: () => h.chain(h.state.row ? [h.state.row] : []) }), +})); + +import { listingInputs, resolveRowCompatibility } from "~/lib/compatibility/catalog-compatibility"; +import { callTool } from "~/lib/mcp/tools"; +import { renderSkillMarkdown } from "~/lib/markdown/skill-markdown"; + +const ROW = { + id: "skill_1", + slug: "find-skills", + name: "Find Skills", + description: "Discovery helper", + author: "vercel", + category: "discovery", + content: "# Find Skills\n\nBody.", + is_paid: 0, + price_cents: 0, + version: "1.2.0", + risk_label: "safe", + install_command: "skillx use find-skills", + source_url: null, + scripts: null, + avg_rating: 4.5, + rating_count: 2, + install_count: 10, + created_at: new Date(0), + updated_at: new Date(0), +}; + +function rowWith(declaration: unknown) { + return { ...ROW, compatibility_json: declaration === undefined ? null : JSON.stringify(declaration) }; +} + +const TARGET = { runtime: "agentkit", version: "1.2.0" }; +const CTX = { env: {} as never, userId: null }; + +async function mcpCheck(row: Record, target = "agentkit@1.2.0") { + h.state.row = row; + const result = await callTool("check_compatibility", { slug: ROW.slug, target }, CTX); + return JSON.parse(result.content[0].text) as Record; +} + +beforeEach(() => { + h.state.row = null; +}); + +describe("actionability uses the contract's rule, across the whole vocabulary", () => { + it("treats only declared and verified as support", () => { + const actionable = COMPATIBILITY_STATUSES.filter((s: CompatibilityStatus) => + isCompatibilityActionable(s), + ); + expect([...actionable].sort()).toEqual(["declared", "verified"]); + }); + + it("never rates `unknown` as support", () => { + expect(isCompatibilityActionable("unknown")).toBe(false); + }); +}); + +describe("the web adapter adds no rule of its own", () => { + const declarations: Array<[string, unknown]> = [ + ["no declaration at all", undefined], + ["a declared range", { agentkit: { status: "declared", versions: ">=1.0.0 <2.0.0" } }], + ["an explicit unsupported", { agentkit: { status: "unsupported" } }], + ["a version outside the range", { agentkit: { status: "declared", versions: ">=2.0.0" } }], + ["an unparseable range", { agentkit: { status: "declared", versions: "not a range" } }], + ]; + + it.each(declarations)("matches the engine for %s", (_label, declaration) => { + const row = rowWith(declaration); + expect(resolveRowCompatibility(row, TARGET)).toEqual( + resolveCatalogCompatibility(TARGET, listingInputs(row)), + ); + }); +}); + +describe("MCP returns the engine's answer unchanged", () => { + it("matches the web adapter for a declared runtime", async () => { + const row = rowWith({ agentkit: { status: "declared", versions: ">=1.0.0 <2.0.0" } }); + const expected = resolveRowCompatibility(row, TARGET); + const actual = await mcpCheck(row); + + expect(actual.found).toBe(true); + expect(actual.status).toBe(expected.status); + expect(actual.reasons).toEqual(expected.reasons); + }); + + it("reports the same blocked decision as the engine for a version mismatch", async () => { + const row = rowWith({ agentkit: { status: "declared", versions: ">=2.0.0" } }); + const expected = resolveRowCompatibility(row, TARGET); + const actual = await mcpCheck(row); + + expect(actual.status).toBe(expected.status); + expect(actual.status).toBe("blocked"); + expect(actual.reasons).toEqual(expected.reasons); + }); + + it("answers unknown for an undeclared runtime on both surfaces", async () => { + const row = rowWith(undefined); + expect(resolveRowCompatibility(row, TARGET).status).toBe("unknown"); + expect((await mcpCheck(row)).status).toBe("unknown"); + }); + + it("reports a missing listing as not_found rather than unknown", async () => { + // "no such listing" and "no claim was made" must not collapse into one answer. + h.state.row = null; + const result = await callTool("check_compatibility", { slug: "nope", target: "agentkit" }, CTX); + const parsed = JSON.parse(result.content[0].text) as Record; + + expect(parsed.found).toBe(false); + expect(parsed.code).toBe("not_found"); + }); +}); + +describe("Markdown shows the same status the engine returned", () => { + function markdownFor(row: Record) { + // Built through the real summary adapter, so this also covers that conversion. + const resolved = toCompatibilitySummary(resolveRowCompatibility(row, TARGET)); + return renderSkillMarkdown({ + siteUrl: "https://skillx.sh", + skill: row as never, + compatibility: [resolved], + payloadGranted: true, + }); + } + + it("renders declared as declared", () => { + const row = rowWith({ agentkit: { status: "declared", versions: ">=1.0.0 <2.0.0" } }); + expect(markdownFor(row)).toContain("| agentkit | declared |"); + }); + + it("renders a version mismatch as blocked, matching the engine", () => { + const row = rowWith({ agentkit: { status: "declared", versions: ">=2.0.0" } }); + const markdown = markdownFor(row); + + expect(resolveRowCompatibility(row, TARGET).status).toBe("blocked"); + expect(markdown).toContain("| agentkit | blocked |"); + }); + + it("never renders verified for a listing that has no evidence", () => { + // A mutable listing has no artifact digest, so `verified` is unreachable. + for (const [, declaration] of [ + ["declared", { agentkit: { status: "declared", versions: ">=1.0.0" } }], + ["unsupported", { agentkit: { status: "unsupported" } }], + ] as const) { + expect(markdownFor(rowWith(declaration))).not.toContain("| agentkit | verified |"); + } + }); +}); From b581b7208579b1f57a5537aefb1035effcd65027 Mon Sep 17 00:00:00 2001 From: Goon Date: Tue, 22 Sep 2026 07:58:36 +0200 Subject: [PATCH 12/19] docs(plans): record slices 6-7, the remote migration, and task 14 Slices 6 and 7 are done. Task 14 (publisher/harness landing docs) is the one implementation task in #33 with no code behind it, so it is recorded as not done rather than left ambiguous. The remote D1 section is new and material: remote had been missing 0009 as well as 0010, so the Phase 0 registry tables never existed in production until now. It also records that the catalog holds 130,309 skills, not the ~5k assumed earlier, which puts llms.txt under 1% coverage. --- .../plan.md | 52 ++++++++++++++++++- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/plans/260922-0717-skillx-agent-native-discovery/plan.md b/plans/260922-0717-skillx-agent-native-discovery/plan.md index fffa784..16ed728 100644 --- a/plans/260922-0717-skillx-agent-native-discovery/plan.md +++ b/plans/260922-0717-skillx-agent-native-discovery/plan.md @@ -51,8 +51,56 @@ undecidable rows without the caller being able to see why. | 3 | CLI: `--compatible`, `inspect`, `check --target`, JSON/exit-code envelope | 1, 2, 3, 4 | done — `033f2d2` (32 tests, tsup build ok) | | 4 | Web compatibility badge with evidence details | 5 | done — `aec3d1b` (5 tests) | | 5 | Markdown routes, `llms.txt`, `llms-full.txt`, `rel=alternate`, Copy | 9–13 | done — `c6aa7b9`, `f31c9ed` (71 tests) | -| 6 | Remote MCP server + tools, WebMCP read tools | 6, 7, 8 | not started | -| 7 | Parity tests across API/CLI/MCP/Markdown | 15 | not started | +| 6 | Remote MCP server + tools, WebMCP read tools | 6, 7, 8 | done — `89b5e6f`, `b09f539` (52 tests) | +| 7 | Parity tests across API/CLI/MCP/Markdown | 15 | done — `f0ee904` (15 tests) | + +**Task 14 of #33 (publisher/harness landing docs) is not done** and was not assigned to a slice. +It is the one implementation task in the issue with no code behind it. + +### Slice 6 notes + +`POST /api/mcp` implements `initialize`, `notifications/initialized`, `tools/list`, and +`tools/call` over the MCP streamable-HTTP transport. Anything else answers `method not found` +rather than being stubbed, so a client can see exactly what the server does. Every handler is a +thin adapter over the same executors the API and the CLI use. + +Three behaviours worth knowing before reading the code. A tool that cannot answer returns +`found: false` with a reason code rather than an error, because `no_package` is the expected +answer for most of the catalog until the registry is populated; "not in the registry" and "the +call failed" are different answers. A tool failure is a JSON-RPC *success* carrying `isError`, +per MCP. And `get_kit` reports a private collection as `not_found`, identical to an unknown slug, +so the tool cannot enumerate private slugs. + +The endpoint sets no CORS headers on purpose: MCP clients are not browsers and the WebMCP layer +runs same-origin, so nothing in scope needs cross-origin access. + +WebMCP (`lib/webmcp/`) is progressive enhancement. Detection checks the shape of +`navigator.modelContext` rather than trusting presence, and a registration that throws is reported +as `failed`, never as `registered`. + +### Slice 7 notes — what parity does and does not cover + +The parity test compares contracts ↔ web adapter, web adapter ↔ MCP, and engine ↔ Markdown against +one fixture. It **cannot** import the CLI: `apps/web/tsconfig.cloudflare.json` lists its own files +only, so a cross-package import fails the project boundary. The CLI is therefore the one surface +holding a hand-written mirror of `isCompatibilityActionable` instead of calling it, pinned by value +in its own test. Replacing that mirror with the contract call is the cleanest follow-up. + +### Remote D1 state (changed since slice 5) + +Migrations **0009 and 0010 are now applied to remote `skillx-db`**, verified independently through the +D1 REST API: remote had been missing 0009 as well, so the Phase 0 registry tables did not exist in +production until now. Post-state: 21 → 28 tables, `skills` 130,309 rows unchanged, `skills_fts` +intact, 11 migrations recorded, none pending. + +Recovery points taken beforehand: time-travel bookmark +`000049e3-00000000-000050ee-38c209ac471ef825f224801bdbf967b3`, plus a schema capture at +`.wrangler/backups/skillx-remote-schema-PRE-0010-20260922-074943.json`. `wrangler d1 export --remote` +fails on this database (wrangler 4.63), so the schema capture goes through the D1 REST API instead. + +The catalog is **130,309 skills**, not the ~5k assumed earlier — which means `llms.txt` covers under +1% of the catalog and `llms-full.txt` about 0.15%. Both state their truncation, but the caps deserve +revisiting. ### Slice 5 notes From af62ab3a101b36926c6207584938adadae6f94f7 Mon Sep 17 00:00:00 2001 From: Goon Date: Tue, 22 Sep 2026 11:30:51 +0200 Subject: [PATCH 13/19] fix(cli): report the published version instead of a hardcoded literal `skillx --version` printed 0.1.2 while npm published 0.4.0, because the string was hardcoded in index.ts and release-please bumps package.json only (issue #24). The version now comes from the manifest at runtime, so it cannot drift. Depth is load-bearing and the reason this helper sits at src/version.ts rather than with the other helpers in src/lib/: `../package.json` is resolved against the module's own URL, and bundling inlines the module into dist/index.js. Both src/version.ts and dist/index.js are exactly one level below the package root, so one relative path serves both; a copy under src/lib/ would be two levels deep and would resolve outside the package once bundled. Reading it can fail, so it falls back to "unknown" rather than refusing to start: a wrong version string is a small problem, a CLI that will not run is a large one. Verified at the artifact level, not just in source: pnpm build then node bin/skillx.js --version prints 0.4.0, matching the manifest, and still does when run from a different working directory. pnpm typecheck clean; pnpm test 366 passed / 28 files. --- packages/cli/src/index.ts | 5 +++- packages/cli/src/version.test.ts | 37 ++++++++++++++++++++++++++++ packages/cli/src/version.ts | 41 ++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/version.test.ts create mode 100644 packages/cli/src/version.ts diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index a27c376..51eb32a 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -8,13 +8,16 @@ import { checkCommand } from './commands/check.js'; import { reportCommand } from './commands/report.js'; import { configCommand } from './commands/config.js'; import { publishCommand } from './commands/publish.js'; +import { readCliVersion } from './version.js'; const program = new Command(); program .name('skillx') .description('The Only Skill That Your AI Agent Needs.') - .version('0.1.2'); + // Read from package.json so the reported version cannot drift from the + // published one (issue #24). + .version(readCliVersion()); program.addCommand(searchCommand); program.addCommand(useCommand); diff --git a/packages/cli/src/version.test.ts b/packages/cli/src/version.test.ts new file mode 100644 index 0000000..944337b --- /dev/null +++ b/packages/cli/src/version.test.ts @@ -0,0 +1,37 @@ +/** + * The version the CLI reports must be the version that was published. + * + * These tests run against the source, where the module sits at `src/version.ts`. + * They cannot prove the bundled case, because bundling moves the module into + * `dist/index.js`; the published resolution is verified by running the built CLI + * (`node dist/index.js --version`). What they do pin is that the value comes from + * the manifest rather than from a literal that drifts on every release. + */ + +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { readCliVersion } from "./version.js"; + +const manifest = JSON.parse( + readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"), +) as { version: string }; + +describe("readCliVersion", () => { + it("returns the version declared in package.json", () => { + expect(readCliVersion()).toBe(manifest.version); + }); + + it("returns a semver, not a placeholder", () => { + expect(readCliVersion()).toMatch(/^\d+\.\d+\.\d+/); + }); + + it("is not the stale literal that issue #24 reported", () => { + // The old hardcoded value; if this reappears, the fix has been reverted. + expect(readCliVersion()).not.toBe("0.1.2"); + }); + + it("shares the manifest's major version, so a bump cannot go unnoticed", () => { + expect(readCliVersion().split(".")[0]).toBe(manifest.version.split(".")[0]); + }); +}); diff --git a/packages/cli/src/version.ts b/packages/cli/src/version.ts new file mode 100644 index 0000000..35355a9 --- /dev/null +++ b/packages/cli/src/version.ts @@ -0,0 +1,41 @@ +/** + * The CLI's own version, read from `package.json` at runtime. + * + * A hardcoded string is what let `skillx --version` print `0.1.2` while npm + * published `0.4.0` (issue #24). release-please bumps `package.json` only, so any + * literal in source drifts silently on every release; reading the real file makes + * the reported version the published version by construction. + * + * DEPTH IS LOAD-BEARING. `../package.json` is resolved against this module's own + * URL, and the module is inlined into the bundle: + * - in development and in tests the module is `src/version.ts`; + * - in the published package it is inlined into `dist/index.js`. + * Both sit exactly one level below the package root, so one relative path serves + * both. A helper under `src/lib/` would be two levels deep and would break after + * bundling, where it lands one level up — which is why this file lives beside the + * entry point rather than with the other helpers. `version.test.ts` pins the + * development resolution; the bundle is verified by running the built CLI. + */ + +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const PACKAGE_JSON_URL = new URL("../package.json", import.meta.url); + +/** + * Returns the package version, or `"unknown"` when it cannot be read. + * + * A missing or malformed manifest must not stop the CLI from running: reporting + * an unknown version is a small problem, refusing to start is a large one. + */ +export function readCliVersion(): string { + try { + const raw = readFileSync(fileURLToPath(PACKAGE_JSON_URL), "utf8"); + const parsed: unknown = JSON.parse(raw); + const version = (parsed as { version?: unknown } | null)?.version; + return typeof version === "string" && version.trim() !== "" ? version : "unknown"; + } catch (error) { + console.error("skillx: could not read the package version:", error); + return "unknown"; + } +} From 562de2175e463519203553ac49c52c44c7bdff2d Mon Sep 17 00:00:00 2001 From: Goon Date: Tue, 22 Sep 2026 11:32:44 +0200 Subject: [PATCH 14/19] ci: verify pull requests, and typecheck every package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo had no verification on pull requests: the only workflow was release-cli.yml, on push to main. This adds a job that runs the same commands a developer runs locally — frozen install, typecheck, test, build web, build CLI — on pull requests and on main. Writing it immediately found two real defects. pnpm-lock.yaml had drifted from the manifests: apps/web's `@skillx/contracts` dependency had no entry, and packages/contracts' typescript devDependency was never recorded. `pnpm install --frozen-lockfile` would have failed in CI, so the frozen install stays as the first real gate. The root `typecheck` script filtered to apps/web, so packages/cli and packages/contracts were never typechecked anywhere. It is now recursive, and packages/cli gained the `typecheck` script it was missing. A build gate is included because a bundling-only failure is invisible to typecheck and tests: the CLI version fix in the previous commit is exactly that class of bug, and only running the built artifact catches it. Actions are pinned to commit SHAs rather than tags, the job takes `contents: read`, and checkout does not persist credentials. Verified: workflow parses, and the full local chain (frozen install, typecheck over 3 packages, 366 tests, both builds) passes. The workflow's first run on GitHub is not yet observed. --- .github/workflows/ci.yml | 64 +++++++++++++++++++++++++++++++++++++++ package.json | 2 +- packages/cli/package.json | 3 +- pnpm-lock.yaml | 9 ++++++ 4 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..59474cf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,64 @@ +name: CI + +# There is no verification on pull requests until now: the only other workflow is +# release-cli.yml, which runs on push to main. Every later phase depends on a gate +# existing here, so this job runs the same commands a developer runs locally +# rather than inventing a second set of rules that could pass while the developer +# command fails. + +on: + pull_request: + push: + branches: [main] + +# Read-only: this job verifies, it never writes to the repository. Stating it +# explicitly stops the job inheriting a broader default token. +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + steps: + # Actions are pinned to commit SHAs, not tags, so a moved or compromised tag + # cannot change what runs here. The trailing comment records the version. + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + # Nothing here pushes, so the token does not belong in .git/config. + persist-credentials: false + + # Reads the pnpm version from package.json's `packageManager` field, so the + # CI toolchain cannot drift from the one the repo declares. + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: pnpm + + # --frozen-lockfile is as much the point of this job as the tests are: the + # lockfile had silently drifted from the manifests (a workspace dependency + # was missing an entry), which only a frozen install detects. + - name: Install + run: pnpm install --frozen-lockfile + + # Covers apps/web, packages/contracts, and packages/cli. Before this, the + # root script filtered to apps/web, so CLI type errors could have shipped. + - name: Typecheck + run: pnpm typecheck + + - name: Test + run: pnpm test + + # A build gate catches bundling-only breakage that typecheck and tests + # cannot see, such as a module resolving to a different relative path once + # it is inlined into a bundle. + - name: Build web app + run: pnpm build + + - name: Build CLI + run: pnpm --filter skillx-sh build diff --git a/package.json b/package.json index db22909..37dbcc8 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "scripts": { "dev": "pnpm --filter web dev", "build": "pnpm --filter web build", - "typecheck": "pnpm --filter web typecheck", + "typecheck": "pnpm -r --if-present typecheck", "deploy": "pnpm --filter web deploy", "seed": "node scripts/seed-skills.mjs", "test": "vitest run", diff --git a/packages/cli/package.json b/packages/cli/package.json index 3ef4616..c74767b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -9,7 +9,8 @@ "main": "./dist/index.js", "scripts": { "build": "tsup", - "dev": "tsup --watch" + "dev": "tsup --watch", + "typecheck": "tsc --noEmit" }, "dependencies": { "chalk": "^5.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c77b5f4..4c23b82 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: apps/web: dependencies: + '@skillx/contracts': + specifier: workspace:* + version: link:../../packages/contracts better-auth: specifier: ^1.2.0 version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@cloudflare/workers-types@4.20260210.0)(gel@2.2.0)(kysely@0.28.11))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.0.18(@types/node@22.19.10)(jiti@2.6.1)(lightningcss@1.30.2)) @@ -104,6 +107,12 @@ importers: specifier: ^5.6.0 version: 5.9.3 + packages/contracts: + devDependencies: + typescript: + specifier: ^5.9.2 + version: 5.9.3 + packages: '@babel/code-frame@7.29.0': From d62fccd0389ff6c184a9e8c2827a120ce9657674 Mon Sep 17 00:00:00 2001 From: Goon Date: Tue, 22 Sep 2026 11:34:13 +0200 Subject: [PATCH 15/19] feat(llms): let an agent choose its slice, and cache per host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalog holds 130,309 skills, so llms.txt covered under 1% and llms-full.txt about 0.15% with no way to ask for more. Both now accept `?limit=`, bounded by a cap (5000 for the index, 1000 for the document that carries SKILL.md bodies), and the truncation note names the knob instead of only pointing at the search API. Two correctness fixes came out of writing it. `Number` is used rather than `parseInt`, because `parseInt("1e9")` is 1: a caller asking for far more than the cap would have silently received less than the default. Clamping honours the requested magnitude. A test caught this. The KV cache key now carries the origin. The rendered document contains absolute URLs, so a single key shared across hosts would serve links to a different host — which is reachable today via any preview or alternate hostname. The key also carries the resolved limit, since that changes the body. Unusable input falls back to the default rather than returning 400: a malformed query parameter should not turn a readable document into an error. Also removes an unused `siteUrl` parameter from the shared header helper. Verified: pnpm typecheck clean over 3 packages; pnpm test 376 passed / 29 files. --- apps/web/app/lib/markdown/llms-limit.test.ts | 70 ++++++++++++++++++++ apps/web/app/lib/markdown/llms-limit.ts | 47 +++++++++++++ apps/web/app/lib/markdown/llms-txt.ts | 16 +++-- apps/web/app/routes/llms-full-txt.ts | 43 +++++++----- apps/web/app/routes/llms-txt.ts | 33 +++++---- 5 files changed, 171 insertions(+), 38 deletions(-) create mode 100644 apps/web/app/lib/markdown/llms-limit.test.ts create mode 100644 apps/web/app/lib/markdown/llms-limit.ts diff --git a/apps/web/app/lib/markdown/llms-limit.test.ts b/apps/web/app/lib/markdown/llms-limit.test.ts new file mode 100644 index 0000000..3248a45 --- /dev/null +++ b/apps/web/app/lib/markdown/llms-limit.test.ts @@ -0,0 +1,70 @@ +/** + * `?limit=` bounds for `llms.txt` and `llms-full.txt`. + * + * The behaviour worth pinning is the fallback direction: a bad value must give a + * usable document, never a 400 and never a surprise size. The cap matters because + * the whole document is rendered and cached as one string. + */ + +import { describe, expect, it } from "vitest"; +import { + FULL_LIMITS, + INDEX_LIMITS, + LLMS_FULL_DEFAULT, + LLMS_FULL_MAX, + LLMS_INDEX_DEFAULT, + LLMS_INDEX_MAX, + parseLlmsLimit, +} from "./llms-limit"; + +describe("parseLlmsLimit", () => { + it("falls back when the parameter is absent", () => { + expect(parseLlmsLimit(null, INDEX_LIMITS)).toBe(LLMS_INDEX_DEFAULT); + expect(parseLlmsLimit(undefined, FULL_LIMITS)).toBe(LLMS_FULL_DEFAULT); + }); + + it("falls back for blank and whitespace-only values", () => { + expect(parseLlmsLimit("", INDEX_LIMITS)).toBe(LLMS_INDEX_DEFAULT); + expect(parseLlmsLimit(" ", INDEX_LIMITS)).toBe(LLMS_INDEX_DEFAULT); + }); + + it("falls back for non-numeric values instead of failing the request", () => { + expect(parseLlmsLimit("abc", INDEX_LIMITS)).toBe(LLMS_INDEX_DEFAULT); + expect(parseLlmsLimit("12abc", INDEX_LIMITS)).toBe(LLMS_INDEX_DEFAULT); + }); + + it("clamps a scientific-notation value instead of truncating it to 1", () => { + // `parseInt("1e9")` is 1, which would hand a caller asking for more far less + // than the default. Clamping honours the magnitude. + expect(parseLlmsLimit("1e9", INDEX_LIMITS)).toBe(LLMS_INDEX_MAX); + }); + + it("rounds a fractional value down to a whole count", () => { + expect(parseLlmsLimit("1.9", INDEX_LIMITS)).toBe(1); + }); + + it("falls back for zero and negative values", () => { + expect(parseLlmsLimit("0", INDEX_LIMITS)).toBe(LLMS_INDEX_DEFAULT); + expect(parseLlmsLimit("-25", INDEX_LIMITS)).toBe(LLMS_INDEX_DEFAULT); + }); + + it("honours a value within the cap", () => { + expect(parseLlmsLimit("50", INDEX_LIMITS)).toBe(50); + expect(parseLlmsLimit(" 250 ", INDEX_LIMITS)).toBe(250); + }); + + it("clamps a value above the cap rather than honouring it", () => { + expect(parseLlmsLimit("999999", INDEX_LIMITS)).toBe(LLMS_INDEX_MAX); + expect(parseLlmsLimit(String(LLMS_FULL_MAX + 1), FULL_LIMITS)).toBe(LLMS_FULL_MAX); + }); + + it("accepts exactly the cap", () => { + expect(parseLlmsLimit(String(LLMS_INDEX_MAX), INDEX_LIMITS)).toBe(LLMS_INDEX_MAX); + }); + + it("keeps the two documents independently bounded", () => { + // The index is metadata and can be larger than the document carrying bodies. + expect(LLMS_INDEX_DEFAULT).toBeGreaterThan(LLMS_FULL_DEFAULT); + expect(LLMS_INDEX_MAX).toBeGreaterThan(LLMS_FULL_MAX); + }); +}); diff --git a/apps/web/app/lib/markdown/llms-limit.ts b/apps/web/app/lib/markdown/llms-limit.ts new file mode 100644 index 0000000..288793a --- /dev/null +++ b/apps/web/app/lib/markdown/llms-limit.ts @@ -0,0 +1,47 @@ +/** + * Bounds for the agent-facing text documents. + * + * The catalog holds six figures of skills, so a document that renders "everything" + * is not an option: `llms-full.txt` would be megabytes and would have to be + * rendered per request. Both documents therefore default to a bounded slice and + * are honest about it, and `?limit=` lets an agent take a larger or smaller slice + * without a deployment. + * + * Unusable input falls back to the default rather than erroring. A malformed + * query parameter should not turn a readable document into a 400, and silently + * clamping to something surprising would be worse than the default. + */ + +export const LLMS_INDEX_DEFAULT = 1000; +export const LLMS_INDEX_MAX = 5000; +export const LLMS_FULL_DEFAULT = 200; +export const LLMS_FULL_MAX = 1000; + +export interface LlmsLimits { + fallback: number; + max: number; +} + +export const INDEX_LIMITS: LlmsLimits = { fallback: LLMS_INDEX_DEFAULT, max: LLMS_INDEX_MAX }; +export const FULL_LIMITS: LlmsLimits = { fallback: LLMS_FULL_DEFAULT, max: LLMS_FULL_MAX }; + +/** + * Resolves a `?limit=` value to a usable count. + * + * Absent, blank, non-numeric, and non-positive values all yield the fallback; a + * value above the cap is clamped to the cap. The cap exists because the document + * is rendered and cached as one string. + * + * `Number` is used rather than `parseInt` on purpose: `parseInt("1e9")` is `1`, so a + * caller asking for far more than the cap would silently receive less than the + * default. Clamping honours the magnitude the caller asked for. + */ +export function parseLlmsLimit(raw: string | null | undefined, limits: LlmsLimits): number { + const trimmed = raw?.trim(); + if (!trimmed) return limits.fallback; + + const parsed = Number(trimmed); + if (!Number.isFinite(parsed) || parsed <= 0) return limits.fallback; + + return Math.min(Math.floor(parsed), limits.max); +} diff --git a/apps/web/app/lib/markdown/llms-txt.ts b/apps/web/app/lib/markdown/llms-txt.ts index 8187b7a..32da131 100644 --- a/apps/web/app/lib/markdown/llms-txt.ts +++ b/apps/web/app/lib/markdown/llms-txt.ts @@ -12,6 +12,7 @@ */ import type { CompatibilitySummary } from "@skillx/contracts"; +import { LLMS_FULL_MAX, LLMS_INDEX_MAX } from "./llms-limit"; export interface LlmsEntry { slug: string; @@ -31,7 +32,7 @@ export interface LlmsFullOptions { truncated: boolean; } -function header(siteUrl: string): string[] { +function header(): string[] { return [ "# SkillX", "", @@ -48,7 +49,7 @@ function summaryLabel(summary: CompatibilitySummary): string { export function renderLlmsTxt(siteUrl: string, entries: LlmsEntry[], total: number): string { const base = siteUrl.replace(/\/+$/, ""); - const lines = header(base); + const lines = header(); lines.push("## Skills", ""); for (const entry of entries) { @@ -64,8 +65,8 @@ export function renderLlmsTxt(siteUrl: string, entries: LlmsEntry[], total: numb if (entries.length < total) { lines.push( - `_Listing ${entries.length} of ${total} skills. The catalog is paginated; use the API`, - `\`POST ${base}/api/search\` for the rest._`, + `_Listing ${entries.length} of ${total} skills. Request a larger slice with \`?limit=\``, + `(up to ${LLMS_INDEX_MAX}) or use \`POST ${base}/api/search\`._`, "", ); } @@ -87,13 +88,14 @@ export function renderLlmsFullTxt( options: LlmsFullOptions, ): string { const base = siteUrl.replace(/\/+$/, ""); - const lines = header(base); + const lines = header(); lines.push("## Skills", ""); if (options.truncated) { lines.push( - `_This document renders ${entries.length} of ${options.total} skills. The remainder is`, - `available through ${base}/llms.txt and the search API._`, + `_This document renders ${entries.length} of ${options.total} skills. Raise \`?limit=\``, + `(up to ${LLMS_FULL_MAX}), or read the remainder through ${base}/llms.txt and`, + `\`POST ${base}/api/search\`._`, "", ); } diff --git a/apps/web/app/routes/llms-full-txt.ts b/apps/web/app/routes/llms-full-txt.ts index 987f4be..a7bbd24 100644 --- a/apps/web/app/routes/llms-full-txt.ts +++ b/apps/web/app/routes/llms-full-txt.ts @@ -1,10 +1,13 @@ /** - * `GET /llms-full.txt` — every listed skill with its SKILL.md body. + * `GET /llms-full.txt?limit=` — listed skills with their SKILL.md bodies. * * This is the most payload-heavy surface in the product. It obeys the protected - * boundary row by row through the shared catalog index, so a listing whose - * payload was not granted is rendered with an explicit note instead of an empty - * body. It is bounded and cached, and it states how much it left out. + * boundary row by row through the shared catalog index, so a listing whose payload + * was not granted is rendered with an explicit note instead of an empty body. It is + * bounded and cached, and it states how much it left out. + * + * `?limit=` bounds a document that would otherwise be megabytes; the cache key + * carries the origin (the body holds absolute URLs) and the resolved limit. */ import type { LoaderFunctionArgs } from "react-router"; @@ -12,29 +15,33 @@ import { getDb } from "~/lib/db"; import { getCached } from "~/lib/cache/kv-cache"; import { fetchPublicCatalogIndex } from "~/lib/catalog/public-catalog-index"; import { renderLlmsFullTxt } from "~/lib/markdown/llms-txt"; +import { FULL_LIMITS, parseLlmsLimit } from "~/lib/markdown/llms-limit"; -/** How many skill bodies the document carries before truncating. */ -const FULL_LIMIT = 200; const CACHE_TTL_SECONDS = 300; -const CACHE_KEY = "llms-full-txt:v2"; export async function loader({ request, context }: LoaderFunctionArgs) { const env = context.cloudflare.env as Env; try { - const siteUrl = new URL(request.url).origin; + const url = new URL(request.url); + const limit = parseLlmsLimit(url.searchParams.get("limit"), FULL_LIMITS); - const body = await getCached(env.KV, CACHE_KEY, CACHE_TTL_SECONDS, async () => { - const { entries, total } = await fetchPublicCatalogIndex(getDb(env.DB), { - limit: FULL_LIMIT, - includeContent: true, - }); + const body = await getCached( + env.KV, + `llms-full-txt:v3:${url.origin}:${limit}`, + CACHE_TTL_SECONDS, + async () => { + const { entries, total } = await fetchPublicCatalogIndex(getDb(env.DB), { + limit, + includeContent: true, + }); - return renderLlmsFullTxt(siteUrl, entries, { - total, - truncated: entries.length < total, - }); - }); + return renderLlmsFullTxt(url.origin, entries, { + total, + truncated: entries.length < total, + }); + }, + ); return new Response(body, { status: 200, diff --git a/apps/web/app/routes/llms-txt.ts b/apps/web/app/routes/llms-txt.ts index 5252cdb..ec705a8 100644 --- a/apps/web/app/routes/llms-txt.ts +++ b/apps/web/app/routes/llms-txt.ts @@ -1,9 +1,14 @@ /** - * `GET /llms.txt` — the catalog index for agents, in one request. + * `GET /llms.txt?limit=` — the catalog index for agents, in one request. * - * Bounded and cached: the catalog holds thousands of listings, so the document + * Bounded and cached: the catalog holds six figures of skills, so the document * renders a deterministic top slice and reports how many listings it did not - * include rather than pretending the catalog is that small. + * include rather than pretending the catalog is that small. `?limit=` takes a + * larger or smaller slice without a deployment. + * + * The cache key carries the origin because the rendered document contains absolute + * URLs: sharing one entry across hosts would hand a caller links to a different + * host. It also carries the resolved limit, since that changes the body. */ import type { LoaderFunctionArgs } from "react-router"; @@ -11,24 +16,26 @@ import { getDb } from "~/lib/db"; import { getCached } from "~/lib/cache/kv-cache"; import { fetchPublicCatalogIndex } from "~/lib/catalog/public-catalog-index"; import { renderLlmsTxt } from "~/lib/markdown/llms-txt"; +import { INDEX_LIMITS, parseLlmsLimit } from "~/lib/markdown/llms-limit"; -/** How many listings the index carries. */ -const INDEX_LIMIT = 1000; const CACHE_TTL_SECONDS = 300; -const CACHE_KEY = "llms-txt:v2"; export async function loader({ request, context }: LoaderFunctionArgs) { const env = context.cloudflare.env as Env; try { - const siteUrl = new URL(request.url).origin; + const url = new URL(request.url); + const limit = parseLlmsLimit(url.searchParams.get("limit"), INDEX_LIMITS); - const body = await getCached(env.KV, CACHE_KEY, CACHE_TTL_SECONDS, async () => { - const { entries, total } = await fetchPublicCatalogIndex(getDb(env.DB), { - limit: INDEX_LIMIT, - }); - return renderLlmsTxt(siteUrl, entries, total); - }); + const body = await getCached( + env.KV, + `llms-txt:v3:${url.origin}:${limit}`, + CACHE_TTL_SECONDS, + async () => { + const { entries, total } = await fetchPublicCatalogIndex(getDb(env.DB), { limit }); + return renderLlmsTxt(url.origin, entries, total); + }, + ); return new Response(body, { status: 200, From 78287eac021b23d6a2c67d6d033447b19c393ea4 Mon Sep 17 00:00:00 2001 From: Goon Date: Tue, 22 Sep 2026 11:34:46 +0200 Subject: [PATCH 16/19] test(search): cover the search route contract at the HTTP boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The executor and the compatibility filter had unit tests, but the route itself — argument validation, the limit clamp, and the response envelope that the CLI, the search page, and the WebMCP tools all consume — was only covered indirectly. A contract detail is exactly what drifts silently. 19 tests over real HTTP bodies: a missing or non-string query is 400; a non-string `compatible` is rejected rather than coerced; the default limit is 20 and a request for 5000 is clamped to 100; zero, negative, and non-numeric limits fall back to the default; `compatible` is parsed as runtime-only so a version suffix never reaches the filter; the compatibility report and the empty-result note travel only when a filter ran; a failure is a 500 envelope rather than an empty success; and GET handles is_paid, limit, and compatible the same way POST does. executeSearch is stubbed, so these assert what the route forwards and returns, not ranking quality. --- apps/web/app/routes/api.search.test.ts | 206 +++++++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 apps/web/app/routes/api.search.test.ts diff --git a/apps/web/app/routes/api.search.test.ts b/apps/web/app/routes/api.search.test.ts new file mode 100644 index 0000000..1ad0e80 --- /dev/null +++ b/apps/web/app/routes/api.search.test.ts @@ -0,0 +1,206 @@ +/** + * Route-level tests for the search API. + * + * The executor and the compatibility filter have their own unit tests; what is + * untested without this file is the route itself — argument validation, the limit + * clamp, and the response envelope that the CLI, the search page, and the WebMCP + * tools all consume. Those are contract details, and a contract detail is exactly + * what silently drifts. + * + * `executeSearch` is replaced so the assertions are about what the route forwards + * and what it returns, not about ranking quality. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const h = vi.hoisted(() => { + const state = { + calls: [] as Array>, + outcome: { results: [] } as Record, + shouldThrow: false, + userId: undefined as string | undefined, + }; + return { state }; +}); + +vi.mock("~/lib/search/search-executor", () => ({ + executeSearch: async (_env: unknown, params: Record) => { + h.state.calls.push(params); + if (h.state.shouldThrow) throw new Error("vectorize down"); + return h.state.outcome; + }, +})); + +vi.mock("~/lib/auth/authenticate-request", () => ({ + authenticateRequest: async () => (h.state.userId ? { userId: h.state.userId } : null), +})); + +import { action, loader } from "./api.search"; + +const ENV = { DB: {}, KV: {}, AI: {}, VECTORIZE: {}, ENVIRONMENT: "test" }; + +function lastCall(): Record { + return h.state.calls[h.state.calls.length - 1]; +} + +async function post(body: unknown) { + const response = (await action({ + request: new Request("https://skillx.sh/api/search", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + context: { cloudflare: { env: ENV } }, + } as never)) as Response; + return { response, json: (await response.clone().json()) as Record }; +} + +async function get(queryString: string) { + const response = (await loader({ + request: new Request(`https://skillx.sh/api/search${queryString}`), + context: { cloudflare: { env: ENV } }, + } as never)) as Response; + return { response, json: (await response.clone().json()) as Record }; +} + +beforeEach(() => { + h.state.calls = []; + h.state.outcome = { results: [] }; + h.state.shouldThrow = false; + h.state.userId = undefined; +}); + +describe("POST /api/search — validation", () => { + it("rejects a missing query with 400", async () => { + const { response, json } = await post({}); + expect(response.status).toBe(400); + expect(json.error).toContain("required"); + expect(h.state.calls).toHaveLength(0); + }); + + it("rejects a non-string query with 400", async () => { + const { response } = await post({ query: 42 }); + expect(response.status).toBe(400); + }); + + it("rejects a non-string `compatible` instead of coercing it", async () => { + const { response, json } = await post({ query: "pdf", compatible: ["agentkit"] }); + expect(response.status).toBe(400); + expect(json.error).toContain("compatible"); + }); +}); + +describe("POST /api/search — what it forwards", () => { + it("forwards the query, the authenticated user, and the default limit", async () => { + h.state.userId = "user_1"; + await post({ query: "pdf" }); + + expect(lastCall().query).toBe("pdf"); + expect(lastCall().userId).toBe("user_1"); + expect(lastCall().limit).toBe(20); + }); + + it("clamps a too-large limit instead of honouring it", async () => { + await post({ query: "pdf", limit: 5000 }); + expect(lastCall().limit).toBe(100); + }); + + it("falls back to the default for zero, negative, and non-numeric limits", async () => { + for (const limit of [0, -10, "abc"]) { + h.state.calls = []; + await post({ query: "pdf", limit }); + expect(lastCall().limit, String(limit)).toBe(20); + } + }); + + it("forwards the category and paid filters", async () => { + await post({ query: "pdf", category: "documents", is_paid: true }); + expect(lastCall().filters).toEqual({ category: "documents", is_paid: true }); + }); + + it("omits the compatibility filter when none was requested", async () => { + await post({ query: "pdf" }); + expect(lastCall()).not.toHaveProperty("compatible"); + }); + + it("parses a runtime filter and drops the version suffix", async () => { + // `--compatible` is runtime-only; version-aware answers live on the detail + // surface, so the version must not reach the search filter. + await post({ query: "pdf", compatible: "agentkit@1.2.3" }); + expect(lastCall().compatible).toEqual({ runtime: "agentkit" }); + }); +}); + +describe("POST /api/search — response envelope", () => { + it("returns results and their count", async () => { + h.state.outcome = { results: [{ slug: "a" }, { slug: "b" }] }; + const { response, json } = await post({ query: "pdf" }); + + expect(response.status).toBe(200); + expect(json.count).toBe(2); + expect(json.results).toHaveLength(2); + }); + + it("carries the compatibility report only when a filter ran", async () => { + h.state.outcome = { results: [], compatibilityFilter: { evaluated: 40, matched: 0 } }; + const { json } = await post({ query: "pdf", compatible: "agentkit" }); + expect(json.compatibilityFilter).toBeDefined(); + }); + + it("does not invent a compatibility report for an unfiltered search", async () => { + h.state.outcome = { results: [] }; + const { json } = await post({ query: "pdf" }); + + expect(json).not.toHaveProperty("compatibilityFilter"); + expect(json).not.toHaveProperty("note"); + }); + + it("carries the empty-result note so an agent knows why nothing came back", async () => { + h.state.outcome = { results: [], note: "no listing declares agentkit" }; + const { json } = await post({ query: "pdf", compatible: "agentkit" }); + expect(json.note).toBe("no listing declares agentkit"); + }); + + it("reports a search failure as a 500 envelope rather than an empty success", async () => { + h.state.shouldThrow = true; + const { response, json } = await post({ query: "pdf" }); + + expect(response.status).toBe(500); + expect(json.error).toBe("Search failed"); + expect(json.details).toBe("vectorize down"); + }); +}); + +describe("GET /api/search — the web UI path", () => { + it("returns an empty result set when no query is given, without searching", async () => { + const { response, json } = await get(""); + + expect(response.status).toBe(200); + expect(json).toEqual({ results: [], count: 0 }); + expect(h.state.calls).toHaveLength(0); + }); + + it("searches when ?q= is present", async () => { + await get("?q=pdf"); + expect(lastCall().query).toBe("pdf"); + }); + + it("reads is_paid as a boolean and leaves it undefined when absent", async () => { + await get("?q=pdf&is_paid=true&category=documents"); + expect(lastCall().filters).toEqual({ category: "documents", is_paid: true }); + + h.state.calls = []; + await get("?q=pdf"); + expect(lastCall().filters).toEqual({ category: undefined, is_paid: undefined }); + }); + + it("treats a missing limit as the default rather than NaN", async () => { + await get("?q=pdf"); + expect(lastCall().limit).toBe(20); + }); + + it("parses ?compatible= the same way POST does", async () => { + await get("?q=pdf&compatible=agentkit"); + expect(lastCall().compatible).toEqual({ runtime: "agentkit" }); + }); +}); From 8c3cd8c0706a423f0e86881ae83386a6c2b6295e Mon Sep 17 00:00:00 2001 From: Goon Date: Tue, 22 Sep 2026 11:36:28 +0200 Subject: [PATCH 17/19] docs(web): add publisher and harness landing pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 14 of #33, the last implementation task in the issue. Two pages, added to the docs sub-nav (which now wraps, since five tabs overflow a narrow viewport). /docs/publishers covers the publish command and the two things publishers get wrong: ownership is verified by a GitHub collaborator check, and a listing can reach `declared` but never `verified`, because a mutable listing has no artifact digest to bind evidence to. It also states the package kinds and release states, and that only `published` resolves. /docs/harness covers what a runtime integrator needs: the Markdown surfaces and `?limit=`, the remote MCP endpoint with its five tools and the reason a tool failure is an `isError` result rather than a transport error, and the status vocabulary — with the warning that treating `unknown` as compatible is the commonest way an integration reports support it never verified. Both pages document only what exists, and say so where a capability is still ahead of the code: immutable releases and named versions are described as arriving with the registry work rather than as available today. Verified: pnpm typecheck clean; pnpm build emits both routes; 161 and 167 lines. --- apps/web/app/components/docs/docs-sub-nav.tsx | 4 +- apps/web/app/routes.ts | 2 + apps/web/app/routes/docs-harness.tsx | 167 ++++++++++++++++++ apps/web/app/routes/docs-publishers.tsx | 161 +++++++++++++++++ 4 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 apps/web/app/routes/docs-harness.tsx create mode 100644 apps/web/app/routes/docs-publishers.tsx diff --git a/apps/web/app/components/docs/docs-sub-nav.tsx b/apps/web/app/components/docs/docs-sub-nav.tsx index 72d656e..7da0b24 100644 --- a/apps/web/app/components/docs/docs-sub-nav.tsx +++ b/apps/web/app/components/docs/docs-sub-nav.tsx @@ -4,13 +4,15 @@ const TABS = [ { to: "/docs", label: "CLI Guide" }, { to: "/docs/agents", label: "Coding Agents Guide" }, { to: "/docs/api", label: "API Reference" }, + { to: "/docs/publishers", label: "Publisher Guide" }, + { to: "/docs/harness", label: "Harness Integration" }, ] as const; export function DocsSubNav() { const { pathname } = useLocation(); return ( -