diff --git a/.gitignore b/.gitignore index be47af24..40b655c9 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ tmp # Generated files public/**/*.txt !public/21a10fefb8713c3f667a2e94460f9357.txt +public/.well-known/agent-skills/ /generated tsconfig.tsbuildinfo diff --git a/package.json b/package.json index 98096951..c7ea96ff 100644 --- a/package.json +++ b/package.json @@ -18,11 +18,12 @@ "spell:full": "cspell --show-context 'content/**/*.{md,mdx}'", "generate-openapi": "bun run ./scripts/fetch-openapi-specs.mts", "generate-llms": "bun run ./scripts/generate-llms-txt.ts", + "generate-agent-skills": "bun run ./scripts/generate-agent-skills-index.ts", "sync-cookbook": "bun run ./scripts/sync-cookbook.ts", "generate-changelog-draft": "bun run ./scripts/generate-changelog-draft.ts", "preview-changelog": "bun run ./scripts/generate-changelog-draft.ts --preview", "generate-changelog-image": "bun run ./scripts/changelog/imagegen/cli.ts", - "generate": "bun run generate-openapi && bun run generate-llms", + "generate": "bun run generate-openapi && bun run generate-llms && bun run generate-agent-skills", "test": "bun test tests/" }, "overrides": { diff --git a/scripts/generate-agent-skills-index.ts b/scripts/generate-agent-skills-index.ts new file mode 100644 index 00000000..e50751a7 --- /dev/null +++ b/scripts/generate-agent-skills-index.ts @@ -0,0 +1,137 @@ +#!/usr/bin/env bun +// ABOUTME: Generates the Agent Skills discovery index at public/.well-known/agent-skills/index.json. +// ABOUTME: Reads the steel-dev/skills catalog, pins each SKILL.md to a commit and digests its bytes. +import { createHash } from 'node:crypto'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +/** Discovery schema this index conforms to: cloudflare/agent-skills-discovery-rfc. */ +export const AGENT_SKILLS_SCHEMA = 'https://schemas.agentskills.io/discovery/0.2.0/schema.json'; + +const SKILLS_REPO = 'steel-dev/skills'; +const OUTPUT_PATH = path.join(process.cwd(), 'public', '.well-known', 'agent-skills', 'index.json'); + +// Limits from the discovery schema, enforced here so a bad catalog entry fails +// the build rather than publishing an index clients will reject. +const NAME_PATTERN = /^[a-z0-9-]{1,64}$/; +const MAX_DESCRIPTION_LENGTH = 1024; + +export type SkillArtifact = { + name: string; + path: string; + description: string; + content: Buffer; +}; + +export type AgentSkillsIndex = { + $schema: string; + skills: Array<{ + name: string; + type: 'skill-md'; + description: string; + url: string; + digest: string; + }>; +}; + +/** Builds the discovery index for skill artifacts read at a single commit. */ +export function buildAgentSkillsIndex( + commit: string, + artifacts: SkillArtifact[], +): AgentSkillsIndex { + if (artifacts.length === 0) { + throw new Error('No skills found in the catalog; refusing to publish an empty index'); + } + + return { + $schema: AGENT_SKILLS_SCHEMA, + skills: artifacts.map((artifact) => { + if (!NAME_PATTERN.test(artifact.name)) { + throw new Error(`Skill name "${artifact.name}" is not a valid discovery name`); + } + + if (artifact.description.length > MAX_DESCRIPTION_LENGTH) { + throw new Error( + `Skill "${artifact.name}" has a description longer than ${MAX_DESCRIPTION_LENGTH} characters`, + ); + } + + return { + name: artifact.name, + type: 'skill-md' as const, + description: artifact.description, + // Pinned to a commit so the URL and digest keep agreeing after an + // upstream edit; the next build re-pins to whatever main holds then. + url: `https://raw.githubusercontent.com/${SKILLS_REPO}/${commit}/${artifact.path}/SKILL.md`, + digest: `sha256:${createHash('sha256').update(artifact.content).digest('hex')}`, + }; + }), + }; +} + +function githubHeaders(): Record { + const token = process.env.GITHUB_TOKEN; + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +async function resolveHeadCommit(): Promise { + const response = await fetch(`https://api.github.com/repos/${SKILLS_REPO}/commits/main`, { + headers: { Accept: 'application/vnd.github+json', ...githubHeaders() }, + }); + + if (!response.ok) { + throw new Error(`Could not resolve ${SKILLS_REPO}@main: ${response.status}`); + } + + return ((await response.json()) as { sha: string }).sha; +} + +async function fetchRaw(commit: string, filePath: string): Promise { + const url = `https://raw.githubusercontent.com/${SKILLS_REPO}/${commit}/${filePath}`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Could not fetch ${url}: ${response.status}`); + } + + return Buffer.from(await response.arrayBuffer()); +} + +type Manifest = { + skills: Record; +}; + +async function readCatalog(commit: string): Promise { + const manifest = JSON.parse( + (await fetchRaw(commit, 'manifest.json')).toString('utf8'), + ) as Manifest; + + return Promise.all( + Object.entries(manifest.skills).map(async ([name, skill]) => ({ + name, + path: skill.path, + description: skill.description, + content: await fetchRaw(commit, `${skill.path}/SKILL.md`), + })), + ); +} + +async function main() { + const commit = await resolveHeadCommit(); + const index = buildAgentSkillsIndex(commit, await readCatalog(commit)); + + await fs.mkdir(path.dirname(OUTPUT_PATH), { recursive: true }); + await fs.writeFile(OUTPUT_PATH, `${JSON.stringify(index, null, 2)}\n`); + + console.log(`✔️ Wrote ${index.skills.length} skills to ${OUTPUT_PATH} at ${commit.slice(0, 8)}`); +} + +if (import.meta.main) { + try { + await main(); + } catch (error) { + // A docs deploy should not hinge on GitHub being reachable. Skipping leaves + // the index absent, which is honest, rather than stale or unverifiable. + console.warn(`⚠️ Skipped the Agent Skills index: ${(error as Error).message}`); + } +} diff --git a/tests/agent-skills-index.test.ts b/tests/agent-skills-index.test.ts new file mode 100644 index 00000000..16cb451a --- /dev/null +++ b/tests/agent-skills-index.test.ts @@ -0,0 +1,70 @@ +// ABOUTME: Tests for the Agent Skills discovery index builder, covering the schema +// ABOUTME: shape, commit-pinned artifact URLs and the SHA-256 digest of each SKILL.md. +import { describe, expect, test } from 'bun:test'; +import { createHash } from 'node:crypto'; + +import { + AGENT_SKILLS_SCHEMA, + buildAgentSkillsIndex, + type SkillArtifact, +} from '../scripts/generate-agent-skills-index'; + +const COMMIT = '0123456789abcdef0123456789abcdef01234567'; + +function artifact(overrides: Partial = {}): SkillArtifact { + return { + name: 'steel-browser', + path: 'steel-browser', + description: 'Skill for agent-driven web workflows using Steel cloud browsers.', + content: Buffer.from('---\nname: steel-browser\n---\n\nDrive a Steel browser.\n'), + ...overrides, + }; +} + +describe('buildAgentSkillsIndex', () => { + test('emits the discovery schema and one entry per skill', () => { + const index = buildAgentSkillsIndex(COMMIT, [ + artifact(), + artifact({ name: 'steel-developer', path: 'steel-developer' }), + ]); + + expect(index.$schema).toBe(AGENT_SKILLS_SCHEMA); + expect(index.skills).toHaveLength(2); + expect(index.skills.map((skill) => skill.name)).toEqual(['steel-browser', 'steel-developer']); + expect(index.skills[0]?.type).toBe('skill-md'); + }); + + test('pins each artifact URL to the commit it was read from', () => { + const [skill] = buildAgentSkillsIndex(COMMIT, [artifact()]).skills; + + // Pinning to a commit rather than a branch keeps the URL and the digest + // agreeing forever, so a later upstream edit cannot invalidate this index. + expect(skill?.url).toBe( + `https://raw.githubusercontent.com/steel-dev/skills/${COMMIT}/steel-browser/SKILL.md`, + ); + }); + + test('digests the exact bytes of the artifact', () => { + const content = Buffer.from('---\nname: steel-browser\n---\n\nExact bytes.\n'); + const [skill] = buildAgentSkillsIndex(COMMIT, [artifact({ content })]).skills; + + expect(skill?.digest).toBe(`sha256:${createHash('sha256').update(content).digest('hex')}`); + expect(skill?.digest).toMatch(/^sha256:[0-9a-f]{64}$/); + }); + + test('rejects a name the discovery schema would not accept', () => { + expect(() => buildAgentSkillsIndex(COMMIT, [artifact({ name: 'Steel Browser' })])).toThrow( + /name/i, + ); + }); + + test('rejects a description longer than the schema allows', () => { + expect(() => + buildAgentSkillsIndex(COMMIT, [artifact({ description: 'x'.repeat(1025) })]), + ).toThrow(/description/i); + }); + + test('rejects an empty skill set rather than publishing an empty index', () => { + expect(() => buildAgentSkillsIndex(COMMIT, [])).toThrow(/skill/i); + }); +});