Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ pnpm db:migrate:remote # Apply migrations to remote D1
|---------|------|------|
| `/` | home.tsx | None |
| `/skills/:slug` | skill-detail.tsx | None |
| `/skills/<slug>.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 |
Expand Down
123 changes: 123 additions & 0 deletions apps/web/app/components/compatibility-panel.tsx
Original file line number Diff line number Diff line change
@@ -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<string, BadgeTone> = {
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 (
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium ${tone.className}`}
title={`${summary.runtime}: ${summary.status}`}
>
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
<span className="font-mono">{summary.runtime}</span>
<span>{tone.label}</span>
{summary.versions ? (
<span className="font-mono text-[11px] opacity-70">{summary.versions}</span>
) : null}
</span>
);
}

/**
* 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 (
<section className="rounded-lg border border-amber-400/20 bg-amber-400/5 p-4">
<h2 className="text-sm font-semibold text-amber-200">Compatibility not declared</h2>
<p className="mt-1 text-sm text-slate-300">
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.
</p>
</section>
);
}

return (
<section className="rounded-lg border border-slate-700/60 bg-slate-900 p-4">
<h2 className="text-sm font-semibold text-white">Compatibility</h2>
<div className="mt-3 flex flex-wrap gap-2">
{summaries.map((summary) => (
<CompatibilityBadge key={summary.runtime} summary={summary} />
))}
</div>
<dl className="mt-3 space-y-2 text-sm">
{summaries.map((summary) => (
<div key={summary.runtime} className="border-t border-slate-800 pt-2 first:border-0 first:pt-0">
<dt className="font-mono text-xs text-slate-400">{summary.runtime}</dt>
<dd className="text-slate-300">
{summary.verifiedAt ? (
<>
Verified against this artifact on{" "}
<time dateTime={summary.verifiedAt}>{summary.verifiedAt.slice(0, 10)}</time>
{summary.probeId ? <> · probe <span className="font-mono">{summary.probeId}</span></> : null}
</>
) : (
<>
Declared by the publisher. No probe of this exact artifact is recorded
{summary.reasonCodes.length > 0 ? (
<> · <span className="font-mono text-xs">{summary.reasonCodes.join(", ")}</span></>
) : null}
</>
)}
</dd>
</div>
))}
</dl>
</section>
);
}
58 changes: 58 additions & 0 deletions apps/web/app/components/copy-markdown-button.tsx
Original file line number Diff line number Diff line change
@@ -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<CopyState>("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 (
<button
type="button"
onClick={copy}
aria-live="polite"
className="inline-flex items-center gap-1.5 rounded-md border border-sx-border px-2 py-1 text-xs text-sx-fg-muted transition-colors hover:border-mint/40 hover:text-mint"
>
{state === "copied" ? <Check size={12} /> : <Copy size={12} />}
<span>{text}</span>
</button>
);
}
4 changes: 3 additions & 1 deletion apps/web/app/components/docs/docs-sub-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<nav aria-label="Documentation sections" className="mb-8 flex gap-1 rounded-lg border border-sx-border bg-sx-bg-elevated p-1">
<nav aria-label="Documentation sections" className="mb-8 flex flex-wrap gap-1 rounded-lg border border-sx-border bg-sx-bg-elevated p-1">
{TABS.map((tab) => {
const active = pathname === tab.to;
return (
Expand Down
32 changes: 32 additions & 0 deletions apps/web/app/components/risk-warning-banner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Risk banner for a scanned listing.
*
* `danger` and `caution` stay separate rather than collapsing into one
* "warning": the first says the scanner found suspicious patterns, the second
* says some content deserves a look. Merging them would tell a reader the two
* mean the same thing.
*/

import { ShieldAlert } from "lucide-react";

export function RiskWarningBanner({ riskLabel }: { riskLabel?: string | null }) {
if (riskLabel === "danger") {
return (
<div className="mb-6 rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-400">
<ShieldAlert className="mr-2 inline h-4 w-4" />
Suspicious content patterns detected. Review carefully before use.
</div>
);
}

if (riskLabel === "caution") {
return (
<div className="mb-6 rounded-lg border border-yellow-500/30 bg-yellow-500/10 px-4 py-3 text-sm text-yellow-400">
<ShieldAlert className="mr-2 inline h-4 w-4" />
Some content patterns flagged for review.
</div>
);
}

return null;
}
27 changes: 27 additions & 0 deletions apps/web/app/components/webmcp-bridge.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions apps/web/app/lib/catalog/payload-boundary.guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
];
Expand Down
Loading
Loading