Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c1a6479
feat(contracts): resolve catalog compatibility through one precedence…
mrgoonie Sep 22, 2026
95a169e
feat(db): add skills.compatibility_json and split auth tables
mrgoonie Sep 22, 2026
d02a897
feat(search): make search and detail compatibility-aware
mrgoonie Sep 22, 2026
033f2d2
feat(cli): add inspect and check, plus a machine-readable envelope
mrgoonie Sep 22, 2026
aec3d1b
feat(web): show compatibility with its evidence on the skill page
mrgoonie Sep 22, 2026
c6aa7b9
feat(web): expose skills as Markdown and add llms.txt
mrgoonie Sep 22, 2026
f31c9ed
feat(web): copy a skill as Markdown, and test the Markdown route
mrgoonie Sep 22, 2026
35081ce
docs(plans): record slice status and what slice 5 does not cover
mrgoonie Sep 22, 2026
89b5e6f
feat(mcp): add a remote read-only MCP server
mrgoonie Sep 22, 2026
b09f539
feat(webmcp): expose read tools to in-page agents
mrgoonie Sep 22, 2026
f0ee904
test(parity): compare the compatibility surfaces against each other
mrgoonie Sep 22, 2026
b581b72
docs(plans): record slices 6-7, the remote migration, and task 14
mrgoonie Sep 22, 2026
af62ab3
fix(cli): report the published version instead of a hardcoded literal
mrgoonie Sep 22, 2026
562de21
ci: verify pull requests, and typecheck every package
mrgoonie Sep 22, 2026
d62fccd
feat(llms): let an agent choose its slice, and cache per host
mrgoonie Sep 22, 2026
78287ea
test(search): cover the search route contract at the HTTP boundary
mrgoonie Sep 22, 2026
8c3cd8c
docs(web): add publisher and harness landing pages
mrgoonie Sep 22, 2026
335e4b5
test(contracts): pin the status vocabulary the CLI mirrors by hand
mrgoonie Sep 22, 2026
337bbbf
docs(plans): record the post-review hardening
mrgoonie Sep 22, 2026
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
64 changes: 64 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
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