Skip to content

Commit dd089a8

Browse files
authored
Merge pull request #320 from vercel-labs/zeejab/agent-skills-discovery-index
feat: ✨ publish Agent Skills discovery index
2 parents b8caa26 + 9da6e5a commit dd089a8

6 files changed

Lines changed: 251 additions & 0 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
name: Agent Skills Discovery
2+
3+
on:
4+
pull_request:
5+
branches: [main]
6+
paths:
7+
- 'skills/**'
8+
- 'scripts/build-discovery-index.mjs'
9+
- '.github/workflows/agent-skills-discovery.yml'
10+
push:
11+
branches: [main]
12+
paths:
13+
- 'skills/**'
14+
- 'scripts/build-discovery-index.mjs'
15+
- '.github/workflows/agent-skills-discovery.yml'
16+
17+
jobs:
18+
validate:
19+
runs-on: ubuntu-latest
20+
permissions:
21+
contents: read
22+
steps:
23+
- uses: actions/checkout@v4
24+
- run: npm ci --ignore-scripts
25+
- run: node scripts/build-discovery-index.mjs https://example.com/skills
26+
27+
publish:
28+
if: github.event_name == 'push'
29+
needs: validate
30+
concurrency:
31+
group: agent-skills-discovery-publish
32+
cancel-in-progress: true
33+
runs-on: ubuntu-latest
34+
permissions:
35+
contents: write
36+
env:
37+
GH_TOKEN: ${{ github.token }}
38+
RELEASE_TAG: agent-skills-${{ github.sha }}
39+
steps:
40+
- uses: actions/checkout@v4
41+
- run: npm ci --ignore-scripts
42+
- run: node scripts/build-discovery-index.mjs "https://github.com/${{ github.repository }}/releases/download/${RELEASE_TAG}"
43+
- run: |
44+
gh release view "$RELEASE_TAG" >/dev/null 2>&1 ||
45+
gh release create "$RELEASE_TAG" --draft --target "$GITHUB_SHA" \
46+
--title "Agent Skills ${{ github.sha }}" \
47+
--notes "Automated Agent Skills discovery release for ${{ github.sha }}."
48+
gh release upload "$RELEASE_TAG" dist/* --clobber
49+
gh release edit "$RELEASE_TAG" --draft=false --latest

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
.DS_Store
22
.vercel
33
.env*.local
4+
dist
5+
node_modules

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,17 @@ Review this React component for performance issues
215215
Help me optimize this Next.js page
216216
```
217217

218+
## Discovery index
219+
220+
Every change to a skill on `main` publishes an immutable GitHub release with
221+
an Agent Skills discovery index and one artifact per skill. Build the same
222+
artifacts locally with:
223+
224+
```bash
225+
npm ci --ignore-scripts
226+
node scripts/build-discovery-index.mjs https://example.com/skills
227+
```
228+
218229
## Skill Structure
219230

220231
Each skill contains:

package-lock.json

Lines changed: 27 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"name": "@vercel-labs/agent-skills",
3+
"private": true,
4+
"type": "module",
5+
"dependencies": {
6+
"yaml": "2.9.0"
7+
}
8+
}

scripts/build-discovery-index.mjs

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
#!/usr/bin/env node
2+
3+
import { createHash } from 'node:crypto';
4+
import { execFileSync } from 'node:child_process';
5+
import {
6+
mkdirSync,
7+
rmSync,
8+
writeFileSync,
9+
} from 'node:fs';
10+
import { join } from 'node:path';
11+
import { parse } from 'yaml';
12+
13+
const schema = 'https://schemas.agentskills.io/discovery/0.2.0/schema.json';
14+
const baseUrl = process.argv[2];
15+
const outputDirectory = 'dist';
16+
const archiveEnvironment = {
17+
...process.env,
18+
GIT_AUTHOR_DATE: '2000-01-01T00:00:00Z',
19+
GIT_AUTHOR_EMAIL: 'agent-skills@vercel.com',
20+
GIT_AUTHOR_NAME: 'Agent Skills',
21+
GIT_COMMITTER_DATE: '2000-01-01T00:00:00Z',
22+
GIT_COMMITTER_EMAIL: 'agent-skills@vercel.com',
23+
GIT_COMMITTER_NAME: 'Agent Skills',
24+
};
25+
26+
if (!baseUrl) {
27+
throw new Error(
28+
'Usage: node scripts/build-discovery-index.mjs <artifact-base-url>',
29+
);
30+
}
31+
32+
const readMetadata = (directory) => {
33+
const path = `skills/${directory}/SKILL.md`;
34+
const source = execFileSync('git', ['show', `HEAD:${path}`], {
35+
encoding: 'utf8',
36+
});
37+
const frontmatter = source.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1];
38+
if (!frontmatter) throw new Error(`Missing frontmatter in ${path}`);
39+
40+
let metadata;
41+
try {
42+
metadata = parse(frontmatter);
43+
} catch (error) {
44+
throw new Error(`Invalid frontmatter in ${path}`, { cause: error });
45+
}
46+
47+
if (!metadata || typeof metadata !== 'object') {
48+
throw new Error(`Invalid frontmatter in ${path}`);
49+
}
50+
51+
const { name, description } = metadata;
52+
if (typeof name !== 'string' || typeof description !== 'string') {
53+
throw new Error(`Missing name or description in ${path}`);
54+
}
55+
56+
if (
57+
name.length === 0 ||
58+
name.length > 64 ||
59+
!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name) ||
60+
!description ||
61+
description.length > 1024
62+
) {
63+
throw new Error(`Invalid name or description in ${path}`);
64+
}
65+
66+
return { name, description };
67+
};
68+
69+
const createArchive = (directory) => {
70+
const tree = execFileSync(
71+
'git',
72+
['rev-parse', `HEAD:skills/${directory}`],
73+
{ encoding: 'utf8' },
74+
).trim();
75+
const commit = execFileSync('git', ['commit-tree', tree], {
76+
encoding: 'utf8',
77+
env: archiveEnvironment,
78+
input: 'Agent Skills archive\n',
79+
}).trim();
80+
const tar = execFileSync('git', ['archive', '--format=tar', commit]);
81+
return execFileSync('gzip', ['-n', '-9', '-c'], { input: tar });
82+
};
83+
84+
const createArtifact = (directory) => {
85+
const entries = execFileSync(
86+
'git',
87+
['ls-tree', '-r', `HEAD:skills/${directory}`],
88+
{ encoding: 'utf8' },
89+
)
90+
.trim()
91+
.split('\n');
92+
if (entries.some((entry) => !/^100(?:644|755) blob /.test(entry))) {
93+
throw new Error(`Unsupported archive entry in skills/${directory}`);
94+
}
95+
96+
const files = entries.map((entry) => entry.slice(entry.indexOf('\t') + 1));
97+
98+
if (files.length === 1 && files[0] === 'SKILL.md') {
99+
return {
100+
content: execFileSync('git', [
101+
'show',
102+
`HEAD:skills/${directory}/SKILL.md`,
103+
]),
104+
extension: 'md',
105+
type: 'skill-md',
106+
};
107+
}
108+
109+
return {
110+
content: createArchive(directory),
111+
extension: 'tar.gz',
112+
type: 'archive',
113+
};
114+
};
115+
116+
rmSync(outputDirectory, { force: true, recursive: true });
117+
mkdirSync(outputDirectory);
118+
119+
const directories = execFileSync(
120+
'git',
121+
['ls-tree', '-d', '--name-only', 'HEAD:skills'],
122+
{ encoding: 'utf8' },
123+
)
124+
.trim()
125+
.split('\n');
126+
127+
const skills = directories
128+
.map((directory) => {
129+
const metadata = readMetadata(directory);
130+
const artifact = createArtifact(directory);
131+
const filename = `${metadata.name}.${artifact.extension}`;
132+
writeFileSync(join(outputDirectory, filename), artifact.content);
133+
134+
return {
135+
...metadata,
136+
type: artifact.type,
137+
url: `${baseUrl.replace(/\/$/, '')}/${filename}`,
138+
digest: `sha256:${createHash('sha256')
139+
.update(artifact.content)
140+
.digest('hex')}`,
141+
};
142+
})
143+
.sort((a, b) => a.name.localeCompare(b.name));
144+
145+
if (new Set(skills.map(({ name }) => name)).size !== skills.length) {
146+
throw new Error('Skill names must be unique');
147+
}
148+
149+
writeFileSync(
150+
join(outputDirectory, 'index.json'),
151+
`${JSON.stringify({ $schema: schema, skills }, null, 2)}\n`,
152+
);
153+
154+
console.log(`Published ${skills.length} skills to ${outputDirectory}`);

0 commit comments

Comments
 (0)