Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ tmp
# Generated files
public/**/*.txt
!public/21a10fefb8713c3f667a2e94460f9357.txt
public/.well-known/agent-skills/
/generated
tsconfig.tsbuildinfo

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
137 changes: 137 additions & 0 deletions scripts/generate-agent-skills-index.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
const token = process.env.GITHUB_TOKEN;
return token ? { Authorization: `Bearer ${token}` } : {};
}

async function resolveHeadCommit(): Promise<string> {
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<Buffer> {
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<string, { description: string; path: string }>;
};

async function readCatalog(commit: string): Promise<SkillArtifact[]> {
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}`);
}
}
70 changes: 70 additions & 0 deletions tests/agent-skills-index.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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);
});
});
Loading