diff --git a/src/forges/github.ts b/src/forges/github.ts index 88afe21..ea31225 100644 --- a/src/forges/github.ts +++ b/src/forges/github.ts @@ -5,6 +5,7 @@ * that is plain git protocol and works against any host (see `../git.ts`). */ +import { parseSkill, type Skill } from "../skills"; import { parseSpec } from "../spec"; import type { Contributor, @@ -16,6 +17,14 @@ import type { } from "./index"; const API = "https://api.github.com"; +const RAW = "https://raw.githubusercontent.com"; + +/** + * Cap on skills read per repo. The cost is one fetch each, paid on a cold hit + * for any repo anyone visits, and nothing stops a repo having a hundred + * directories under `skills/`. + */ +const MAX_SKILLS = 25; function headers(ctx: ForgeCtx): Record { const h: Record = { @@ -83,6 +92,51 @@ export const github: Forge = { return { file: spec.name, raw, spec: parseSpec(raw) }; }, + /** + * `skills//SKILL.md`, per https://agentskills.io/specification. + * + * One listing plus one fetch per skill. Capped, because the cost is paid on + * a cold hit for any repo anyone visits and a repo can have any number of + * directories under `skills/`. + * + * `null` means there is no `skills/` directory; an empty array means there + * is one but nothing in it could be read. The page says different things + * for the two, because "no skills here" is wrong when the directory exists + * and the frontmatter is simply broken. + */ + async skills(owner, repo, ctx): Promise { + const res = await fetch(`${API}/repos/${owner}/${repo}/contents/skills`, { + headers: headers(ctx), + cf: { cacheTtl: 600, cacheEverything: true }, + }); + // 404 is the normal case: most repos ship no skills. + if (!res.ok) return null; + + const entries = (await res.json()) as Array<{ name: string; type: string }>; + if (!Array.isArray(entries)) return null; + + const dirs = entries + .filter((e) => e.type === "dir") + .map((e) => e.name) + .sort() + .slice(0, MAX_SKILLS); + + const loaded = await Promise.all( + dirs.map(async (dir) => { + const body = await fetch( + `${RAW}/${owner}/${repo}/HEAD/skills/${encodeURIComponent(dir)}/SKILL.md`, + { cf: { cacheTtl: 600, cacheEverything: true } }, + ).catch(() => null); + if (!body?.ok) return null; + // A skill whose frontmatter cannot be read is skipped rather than + // shown half-parsed; it is someone else's file, not ours to guess at. + return parseSkill(dir, await body.text()); + }), + ); + + return loaded.filter((s): s is Skill => s !== null); + }, + async releases(owner, repo, ctx): Promise { const res = await fetch( `${API}/repos/${owner}/${repo}/releases?per_page=100`, diff --git a/src/forges/index.ts b/src/forges/index.ts index 7b22dee..1f60909 100644 --- a/src/forges/index.ts +++ b/src/forges/index.ts @@ -19,6 +19,7 @@ * from `/gh/:owner/:repo` — both are three segments. */ +import type { Skill } from "../skills"; import type { Spec } from "../spec"; export interface RepoMeta { @@ -74,6 +75,8 @@ export interface Forge { repoMeta(owner: string, repo: string, ctx: ForgeCtx): Promise; /** A `*.usage.kdl` at the repo root, if present. */ usageSpec(owner: string, repo: string, ctx: ForgeCtx): Promise; + /** Agent Skills under `skills/`, following the agentskills layout. */ + skills(owner: string, repo: string, ctx: ForgeCtx): Promise; /** Release history, newest first. */ releases(owner: string, repo: string, ctx: ForgeCtx): Promise; /** Contributors, ranked by the forge's own notion of contribution. */ diff --git a/src/lib/data.ts b/src/lib/data.ts index b012c06..f0914ec 100644 --- a/src/lib/data.ts +++ b/src/lib/data.ts @@ -6,6 +6,7 @@ * Cloudflare runtime. */ +import type { Skill } from "../skills"; import { parse as parseToml } from "smol-toml"; import type { Contributor, Forge, ForgeCtx, RepoMeta, SpecFile } from "../forges"; @@ -69,6 +70,8 @@ export interface RepoData { /** Name in mise's registry, when it is in there at all. */ miseToolName: string | null; commands: SpecFile | null; + /** Agent Skills the repo publishes under `skills/`. */ + skills: Skill[] | null; performance: Performance | null; versions: Versions | null; contributors: Contributor[] | null; @@ -316,9 +319,10 @@ export async function repoData( const meta = await forge.repoMeta(owner, repo, ctx); if (!meta) return null; - const [commands, perf, toolName, contributors, forgeReleases] = + const [commands, skills, perf, toolName, contributors, forgeReleases] = await Promise.all([ forge.usageSpec(owner, repo, ctx).catch(() => null), + forge.skills(owner, repo, ctx).catch(() => null), performance(forge, owner, repo).catch(() => null), miseToolName(forge, owner, repo, cache).catch(() => null), forge.contributors(owner, repo, ctx).catch(() => null), @@ -338,6 +342,7 @@ export async function repoData( meta, miseToolName: toolName, commands, + skills, performance: perf, versions, contributors, diff --git a/src/pages/gh/[owner]/[repo].astro b/src/pages/gh/[owner]/[repo].astro index fd7e3fc..9019999 100644 --- a/src/pages/gh/[owner]/[repo].astro +++ b/src/pages/gh/[owner]/[repo].astro @@ -194,6 +194,56 @@ const fmtMs = (n: number) => `${n.toFixed(2)}ms`; } +
+

Skills

+ { + data.skills?.length ? ( + <> +
+ {data.skills.map((skill) => ( +
+
+ {skill.name} + {skill.license && {skill.license}} +
+

{skill.description}

+ {skill.compatibility && ( +

{skill.compatibility}

+ )} + + skills/{skill.dir}/SKILL.md + +
+ ))} +
+
+ + published by {data.slug}, not by usage.sh + +
+ + ) : data.skills ? ( +
+ This repository has a skills/ directory, but nothing in + it could be read as an{" "} + Agent Skill — each + one needs a SKILL.md with a name and a{" "} + description in its frontmatter. +
+ ) : ( +
+ No skills/ in this repository.{" "} + Agent Skills at{" "} + skills/<name>/SKILL.md are listed here + automatically. +
+ ) + } +
+

Performance

{ diff --git a/src/skills.test.ts b/src/skills.test.ts new file mode 100644 index 0000000..563dcee --- /dev/null +++ b/src/skills.test.ts @@ -0,0 +1,137 @@ +/** + * SKILL.md files come from other people's repositories, so the reader has to + * cope with the forms the spec allows and degrade on the ones it does not. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { parseSkill } from "./skills.ts"; + +test("reads the required fields", () => { + const s = parseSkill( + "pdf-processing", + `--- +name: pdf-processing +description: Extract PDF text and fill forms. Use when handling PDFs. +--- + +# PDF processing + +Body text. +`, + )!; + assert.equal(s.dir, "pdf-processing"); + assert.equal(s.name, "pdf-processing"); + assert.match(s.description, /Extract PDF text/); + assert.match(s.body, /^# PDF processing/); +}); + +test("reads the optional fields the spec defines", () => { + const s = parseSkill( + "x", + `--- +name: x +description: does a thing +license: Apache-2.0 +compatibility: Requires git and jq +allowed-tools: Bash(git:*) Read +--- +body +`, + )!; + assert.equal(s.license, "Apache-2.0"); + assert.equal(s.compatibility, "Requires git and jq"); + assert.equal(s.allowedTools, "Bash(git:*) Read"); +}); + +test("handles quoted values", () => { + const s = parseSkill( + "x", + `--- +name: "x" +description: 'Uses: colons, and commas' +--- +`, + )!; + assert.equal(s.name, "x"); + assert.equal(s.description, "Uses: colons, and commas"); +}); + +test("handles a folded description", () => { + const s = parseSkill( + "x", + `--- +name: x +description: > + One long sentence that the author + wrapped over several lines. +--- +`, + )!; + assert.equal( + s.description, + "One long sentence that the author wrapped over several lines.", + ); +}); + +test("handles a literal block, keeping its newlines", () => { + const s = parseSkill( + "x", + `--- +name: x +description: | + line one + line two +--- +`, + )!; + assert.equal(s.description, "line one\nline two"); +}); + +test("skips nested structures it does not model", () => { + // `metadata` is an arbitrary map; its keys must not leak into the fields. + const s = parseSkill( + "x", + `--- +name: x +metadata: + author: someone + version: "1.0" +description: after the nested block +--- +`, + )!; + assert.equal(s.description, "after the nested block"); + assert.equal(s.name, "x"); +}); + +test("falls back to the directory name when name is absent", () => { + const s = parseSkill("from-dir", `---\ndescription: d\n---\n`)!; + assert.equal(s.name, "from-dir"); +}); + +test("rejects a file with no description", () => { + // The spec requires it, and without one there is nothing worth listing. + assert.equal(parseSkill("x", `---\nname: x\n---\nbody`), null); +}); + +test("rejects a whitespace-only description", () => { + // The spec requires a non-empty description; quotes made this look present. + assert.equal(parseSkill("x", `---\nname: x\ndescription: " "\n---\nbody`), null); + assert.equal(parseSkill("x", `---\nname: x\ndescription: |\n \n---\nbody`), null); +}); + +test("rejects a file with no frontmatter", () => { + assert.equal(parseSkill("x", "# Just markdown\n"), null); +}); + +test("rejects an unterminated frontmatter block", () => { + assert.equal(parseSkill("x", "---\nname: x\ndescription: d\n"), null); +}); + +test("tolerates CRLF line endings", () => { + const s = parseSkill("x", "---\r\nname: x\r\ndescription: d\r\n---\r\nbody\r\n")!; + assert.equal(s.description, "d"); + assert.equal(s.body, "body"); +}); diff --git a/src/skills.ts b/src/skills.ts new file mode 100644 index 0000000..77f4f81 --- /dev/null +++ b/src/skills.ts @@ -0,0 +1,109 @@ +/** + * Agent Skills published alongside a CLI. + * + * Follows the layout at https://agentskills.io/specification — a repo puts + * `skills//SKILL.md` at its root, each file being YAML frontmatter and a + * Markdown body. Detecting the convention that already exists means a repo + * shipping skills for Claude Code, Cursor or Copilot gets a usage.sh page for + * free, with no second path to publish to. + * + * usage.sh serves these; it does not rank, recommend or merge them. They are + * someone else's prose, so provenance stays attached and the reader decides. + */ + +/** The frontmatter fields the spec defines. `metadata` is deliberately not read. */ +export interface Skill { + /** Directory name, which the spec requires to match the `name` field. */ + dir: string; + name: string; + description: string; + license?: string; + compatibility?: string; + /** Space-separated pre-approved tools. Experimental in the spec. */ + allowedTools?: string; + /** Markdown after the frontmatter. */ + body: string; +} + +/** + * Read the frontmatter fields this needs. + * + * Not a YAML parser, and not trying to be: the spec's fields are all scalars, + * so this handles plain, quoted, folded (`>`) and literal (`|`) values and + * ignores everything else. A field it cannot read is dropped rather than + * guessed at — the cost of that is a missing subtitle, where a general parser + * would cost far more bundle than the four fields justify. + */ +function parseFrontmatter(src: string): { + fields: Record; + body: string; +} { + const normalised = src.replace(/\r\n/g, "\n"); + if (!normalised.startsWith("---\n")) return { fields: {}, body: normalised }; + + const end = normalised.indexOf("\n---", 3); + if (end === -1) return { fields: {}, body: normalised }; + + const head = normalised.slice(4, end); + const body = normalised.slice(end + 4).replace(/^\n/, ""); + + const fields: Record = {}; + const lines = head.split("\n"); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + // Only top-level keys; anything indented belongs to a structure this + // does not model (`metadata:`), and is skipped with its block. + const match = /^([A-Za-z][\w-]*):[ \t]*(.*)$/.exec(line); + if (!match) continue; + + const key = match[1]; + let value = match[2].trim(); + + if (value === "|" || value === ">" || /^[|>][-+]?$/.test(value)) { + // Block scalar: take the indented lines that follow. + const folded = value.startsWith(">"); + const block: string[] = []; + while (i + 1 < lines.length && /^(\s+|$)/.test(lines[i + 1])) { + block.push(lines[++i].replace(/^\s{1,4}/, "")); + } + value = folded + ? block.join(" ").replace(/\s+/g, " ").trim() + : block.join("\n").trimEnd(); + } else if ( + (value.startsWith('"') && value.endsWith('"') && value.length > 1) || + (value.startsWith("'") && value.endsWith("'") && value.length > 1) + ) { + value = value.slice(1, -1); + if (line.includes('"')) value = value.replace(/\\"/g, '"'); + } + + value = value.trim(); + if (value) fields[key] = value; + } + + return { fields, body }; +} + +/** + * Build a Skill from one `SKILL.md`, or null when it is not usable. + * + * `name` and `description` are the spec's only required fields, and without a + * description there is nothing worth listing, so both are required here too. + */ +export function parseSkill(dir: string, source: string): Skill | null { + const { fields, body } = parseFrontmatter(source); + const name = fields.name ?? dir; + const description = fields.description; + if (!description) return null; + + return { + dir, + name, + description, + license: fields.license, + compatibility: fields.compatibility, + allowedTools: fields["allowed-tools"], + body: body.trim(), + }; +} diff --git a/src/styles/global.css b/src/styles/global.css index d708684..6ee8e9a 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -216,6 +216,29 @@ tbody tr:last-child td { .effect.write { color: var(--accent); } +/* Skills are third-party prose. They read as quoted material, not as + something usage.sh is asserting. */ +.cards { + display: grid; + gap: 0.75rem; +} +.skill-head { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 1rem; + margin-bottom: 0.35rem; +} +.skill-head code { + font-weight: 600; +} +.skill .desc { + margin: 0 0 0.4rem; +} +.small { + font-size: 0.75rem; +} + .effect.destructive { color: var(--bad); font-weight: 600;