From b2a974ecef8c56d723e5331d619b9783c2c71d48 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:48:03 +0000 Subject: [PATCH 1/2] feat: list Agent Skills a repo publishes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit usage.sh now detects `skills//SKILL.md` and lists what it finds, the layout from https://agentskills.io/specification. Detecting the convention that already exists means a repo shipping skills for Claude Code, Cursor or Copilot gets a page here for free, with no second place to publish to. This is the part of jdx/mise#9479 that does not need a packaging channel. The objection to bridging tool-bundled skills was that 86.5% of mise's registry is binary tarballs with nowhere to put markdown, and that auto-injecting third-party prose into an agent's context is a supply-chain surface. Serving them from a repo sidesteps both: nothing ships in the tarball, and the agent pulls a skill for a CLI it asked about rather than having prose pushed at it for every installed tool. So the page serves skills, and does not rank, recommend or merge them. Provenance stays attached — the name, the license, and a link to the file in the repo it came from — and the section says the vendor published it, not usage.sh. skills.ts reads the four scalar fields the spec defines and skips the rest. It is deliberately not a YAML parser: the fields are all scalars, so it handles plain, quoted, folded and literal values and drops anything it cannot read, which costs a missing subtitle where a general parser would cost far more bundle than four fields justify. A skill without a description — which the spec requires — is skipped rather than shown half-parsed. Verified against real published files, not just fixtures: anthropics/skills brand-guidelines, crazyguitar/pysheeet, and tanweai/pua, which between them cover a bare description, a quoted one containing em-dashes and CJK, and a license field. Reading skills is capped per repo, since the cost is one fetch each on a cold hit for any repo anyone visits. Co-Authored-By: Claude Opus 5 --- src/forges/github.ts | 50 ++++++++++++ src/forges/index.ts | 3 + src/lib/data.ts | 7 +- src/pages/gh/[owner]/[repo].astro | 42 ++++++++++ src/skills.test.ts | 131 ++++++++++++++++++++++++++++++ src/skills.ts | 108 ++++++++++++++++++++++++ src/styles/global.css | 23 ++++++ 7 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 src/skills.test.ts create mode 100644 src/skills.ts diff --git a/src/forges/github.ts b/src/forges/github.ts index 88afe21..a15a73f 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,47 @@ 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/`. + */ + 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()); + }), + ); + + const skills = loaded.filter((s): s is Skill => s !== null); + return skills.length ? skills : 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..87898de 100644 --- a/src/pages/gh/[owner]/[repo].astro +++ b/src/pages/gh/[owner]/[repo].astro @@ -194,6 +194,48 @@ const fmtMs = (n: number) => `${n.toFixed(2)}ms`; } +
+

Skills

+ { + data.skills ? ( + <> +
+ {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 + +
+ + ) : ( +
+ 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..bf48f58 --- /dev/null +++ b/src/skills.test.ts @@ -0,0 +1,131 @@ +/** + * 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 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..6e26b10 --- /dev/null +++ b/src/skills.ts @@ -0,0 +1,108 @@ +/** + * 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, '"'); + } + + 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; From 8eb0451232111478f080b939d0cdc3ba650498a5 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:53:17 +0000 Subject: [PATCH 2/2] fix: tell the two empty skill states apart, reject blank descriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `skills()` returned null both when there was no `skills/` directory and when there was one that yielded nothing readable, so the page claimed a repo had no skills while the API had just listed the directory — loose files instead of subdirectories, a missing SKILL.md, or frontmatter that would not parse all landed on the wrong message. Null now means the directory is absent and an empty array means it is there but unreadable, and the page says so, pointing at what a SKILL.md needs. Separately, a quoted or block `description` holding only whitespace passed the required-field check, because the value was tested for truthiness before being trimmed. `description: " "` listed a skill with no description at all. Values are trimmed before the check now, covered both ways. Co-Authored-By: Claude Opus 5 --- src/forges/github.ts | 8 ++++++-- src/pages/gh/[owner]/[repo].astro | 10 +++++++++- src/skills.test.ts | 6 ++++++ src/skills.ts | 1 + 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/forges/github.ts b/src/forges/github.ts index a15a73f..ea31225 100644 --- a/src/forges/github.ts +++ b/src/forges/github.ts @@ -98,6 +98,11 @@ export const github: Forge = { * 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`, { @@ -129,8 +134,7 @@ export const github: Forge = { }), ); - const skills = loaded.filter((s): s is Skill => s !== null); - return skills.length ? skills : null; + return loaded.filter((s): s is Skill => s !== null); }, async releases(owner, repo, ctx): Promise { diff --git a/src/pages/gh/[owner]/[repo].astro b/src/pages/gh/[owner]/[repo].astro index 87898de..9019999 100644 --- a/src/pages/gh/[owner]/[repo].astro +++ b/src/pages/gh/[owner]/[repo].astro @@ -197,7 +197,7 @@ const fmtMs = (n: number) => `${n.toFixed(2)}ms`;

Skills

{ - data.skills ? ( + data.skills?.length ? ( <>
{data.skills.map((skill) => ( @@ -225,6 +225,14 @@ const fmtMs = (n: number) => `${n.toFixed(2)}ms`;
+ ) : 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.{" "} diff --git a/src/skills.test.ts b/src/skills.test.ts index bf48f58..563dcee 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -116,6 +116,12 @@ test("rejects a file with no description", () => { 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); }); diff --git a/src/skills.ts b/src/skills.ts index 6e26b10..77f4f81 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -78,6 +78,7 @@ function parseFrontmatter(src: string): { if (line.includes('"')) value = value.replace(/\\"/g, '"'); } + value = value.trim(); if (value) fields[key] = value; }