Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
50 changes: 50 additions & 0 deletions src/forges/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string, string> {
const h: Record<string, string> = {
Expand Down Expand Up @@ -83,6 +92,47 @@ export const github: Forge = {
return { file: spec.name, raw, spec: parseSpec(raw) };
},

/**
* `skills/<name>/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<Skill[] | null> {
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()
Comment on lines +117 to +121

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Cap excludes valid skill directories

When more than 25 directories exist under skills/, this truncates the sorted directory list before checking for SKILL.md, so unrelated or invalid directories can consume the limit and cause valid published skills to be omitted or the page to report that no skills exist.

Fix in Claude Code

.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;
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
},

async releases(owner, repo, ctx): Promise<Release[] | null> {
const res = await fetch(
`${API}/repos/${owner}/${repo}/releases?per_page=100`,
Expand Down
3 changes: 3 additions & 0 deletions src/forges/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -74,6 +75,8 @@ export interface Forge {
repoMeta(owner: string, repo: string, ctx: ForgeCtx): Promise<RepoMeta | null>;
/** A `*.usage.kdl` at the repo root, if present. */
usageSpec(owner: string, repo: string, ctx: ForgeCtx): Promise<SpecFile | null>;
/** Agent Skills under `skills/`, following the agentskills layout. */
skills(owner: string, repo: string, ctx: ForgeCtx): Promise<Skill[] | null>;
/** Release history, newest first. */
releases(owner: string, repo: string, ctx: ForgeCtx): Promise<Release[] | null>;
/** Contributors, ranked by the forge's own notion of contribution. */
Expand Down
7 changes: 6 additions & 1 deletion src/lib/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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),
Expand All @@ -338,6 +342,7 @@ export async function repoData(
meta,
miseToolName: toolName,
commands,
skills,
performance: perf,
versions,
contributors,
Expand Down
42 changes: 42 additions & 0 deletions src/pages/gh/[owner]/[repo].astro
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,48 @@ const fmtMs = (n: number) => `${n.toFixed(2)}ms`;
}
</section>

<section>
<h2>Skills</h2>
{
data.skills ? (
<>
<div class="cards">
{data.skills.map((skill) => (
<div class="card skill">
<div class="skill-head">
<code>{skill.name}</code>
{skill.license && <span class="m">{skill.license}</span>}
</div>
<p class="desc">{skill.description}</p>
{skill.compatibility && (
<p class="m small">{skill.compatibility}</p>
)}
<a
href={`${data.url}/blob/HEAD/skills/${skill.dir}/SKILL.md`}
rel="noopener"
>
skills/{skill.dir}/SKILL.md
</a>
</div>
))}
</div>
<div class="facts">
<span>
published by <b>{data.slug}</b>, not by usage.sh
</span>
</div>
</>
) : (
<div class="empty">
No <code>skills/</code> in this repository.{" "}
<a href="https://agentskills.io/specification">Agent Skills</a> at{" "}
<code>skills/&lt;name&gt;/SKILL.md</code> are listed here
automatically.
</div>
)
}
</section>

<section>
<h2>Performance</h2>
{
Expand Down
131 changes: 131 additions & 0 deletions src/skills.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
Loading
Loading