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/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 ( -