diff --git a/CLAUDE.md b/CLAUDE.md index 3fed869..ddcd069 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,6 +81,10 @@ pnpm db:migrate:remote # Apply migrations to remote D1 |---------|------|------| | `/` | home.tsx | None | | `/skills/:slug` | skill-detail.tsx | None | +| `/skills/.md` | lib/markdown/skill-markdown-handler.ts, dispatched in `workers/app.ts` (React Router has no partial dynamic segment) | None; always anonymous, public cache | +| `/llms.txt`, `/llms-full.txt` | llms-txt.ts, llms-full-txt.ts (`?limit=` snaps to fixed sizes; KV-cached per origin and size) | None; protected bodies withheld | +| `/docs/harness`, `/docs/publishers` | docs-harness.tsx, docs-publishers.tsx | None | +| `/api/mcp` | api.mcp.ts (read-only MCP over JSON-RPC: `search_packages`, `get_package`, `get_release`, `check_compatibility`; 64 KiB body cap, `RELEASE_READ_LIMITER` under `mcp:`) | Optional Key/Session; `get_release` is always anonymous | | `/profile` | profile.tsx | Session | | `/settings` | settings.tsx | Session | | `/api/auth/*` | auth-catchall.tsx | Better Auth | 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/components/copy-markdown-button.tsx b/apps/web/app/components/copy-markdown-button.tsx new file mode 100644 index 0000000..5b11a2f --- /dev/null +++ b/apps/web/app/components/copy-markdown-button.tsx @@ -0,0 +1,58 @@ +/** + * "Copy as Markdown" for the skill page. + * + * The button copies the response of the page's own `/skills/:slug.md` resource + * rather than assembling Markdown in the browser. That choice is the security + * property: the route applies the protected payload boundary server-side, so a + * viewer without entitlement copies the withheld-body document and cannot + * accidentally be handed a payload the server would not have served them. It + * also means there is exactly one Markdown renderer, not two that can drift. + */ + +import { useState } from "react"; +import { Check, Copy } from "lucide-react"; + +type CopyState = "idle" | "copied" | "error"; + +export function CopyMarkdownButton({ + url, + label = "Copy as Markdown", +}: { + url: string; + label?: string; +}) { + const [state, setState] = useState("idle"); + + async function copy() { + try { + const response = await fetch(url); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + // Fetched as text and copied verbatim: the server decided what this + // viewer may read, and the button must not reshape that answer. + const markdown = await response.text(); + await navigator.clipboard.writeText(markdown); + setState("copied"); + } catch (error) { + // A failed copy is reported, not swallowed: silently doing nothing looks + // like a successful copy of an empty document. + console.error("Copy as Markdown failed:", error); + setState("error"); + } finally { + setTimeout(() => setState("idle"), 2000); + } + } + + const text = state === "copied" ? "Copied" : state === "error" ? "Copy failed" : label; + + return ( + + ); +} 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 ( -