Skip to content

Commit 903dcfc

Browse files
authored
feat(skills): add .well-known/skills module (#31)
* feat(skills): add .well-known/skills module Port the agent skills discovery module from upstream docus, exposing Anthropic Agent Skills under `.well-known/skills/`: - `packages/layer/modules/skills/index.ts` — scans `<rootDir>/<dir>/` for skill folders, validates against the Anthropic naming spec (`^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$`, max 64 chars), and registers the directory as Nitro `serverAssets` so files are served from runtime storage even on serverless deployments. Configurable via the `skills.dir` module option (default `skills`). - `runtime/server/routes/.well-known/skills/index.get.ts` — scans the asset storage for `SKILL.md` files, parses YAML frontmatter, and returns `{ skills: { catalog: [{ name, description, path }] } }`. - `runtime/server/routes/.well-known/skills/[...path].get.ts` — serves any file under a skill folder with content-type detection. Blocks path traversal and dotfile access. Every skill file path is registered in `nitro.prerender.routes` so static builds materialise them up-front. Ported from upstream docus: - 2f7861bd feat(skills): add agent skills discovery via .well-known - 764329f5 feat(skills): make directory configurable via module options See docs/docus-upstream-changes.md item #7. * fix(skills): apply review suggestions - index route now reads the pre-built catalog from runtimeConfig instead of re-scanning storage and re-parsing YAML on every request (per gemini-code-assist review). - Require `name` in SKILL.md frontmatter rather than silently falling back to the folder name (per cubic-dev-ai review). - Enforce the 200-character description cap from the Agent Skills spec before adding a skill to the catalog (per cubic-dev-ai review). - File handler now returns raw bytes for non-string payloads so binary skill files (e.g. images, archives) are served untouched instead of being mangled by UTF-8 decoding (per cubic-dev-ai review).
1 parent 9df234a commit 903dcfc

6 files changed

Lines changed: 332 additions & 4 deletions

File tree

‎bun.lock‎

Lines changed: 5 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import { existsSync } from 'node:fs'
2+
import { readdir, readFile } from 'node:fs/promises'
3+
import { join } from 'node:path'
4+
import { addServerHandler, createResolver, defineNuxtModule, useLogger } from '@nuxt/kit'
5+
import { parse as parseYaml } from 'yaml'
6+
7+
/**
8+
* Skills module — exposes Anthropic Agent Skills via `.well-known/skills`.
9+
*
10+
* Ported from upstream docus:
11+
* - `2f7861bd` feat(skills): add agent skills discovery via `.well-known`
12+
* - `764329f5` feat(skills): make directory configurable via module options
13+
*
14+
* Skills live under `<rootDir>/<dir>/<name>/SKILL.md`, each with YAML
15+
* frontmatter `{ name, description }`. Both fields are required; `name`
16+
* must satisfy the Anthropic Agent Skills spec
17+
* (`/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/`, max 64 chars) and `description`
18+
* is capped at 200 chars.
19+
*/
20+
21+
export interface ModuleOptions {
22+
/**
23+
* Directory (relative to project rootDir) that contains skill folders.
24+
* Each subfolder is treated as a skill and must contain a `SKILL.md` file.
25+
* @default 'skills'
26+
*/
27+
dir?: string
28+
}
29+
30+
interface SkillEntry {
31+
name: string
32+
description: string
33+
files: string[]
34+
}
35+
36+
const SKILL_NAME_REGEX = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/
37+
const MAX_NAME_LENGTH = 64
38+
const MAX_DESCRIPTION_LENGTH = 200
39+
40+
export default defineNuxtModule<ModuleOptions>({
41+
meta: {
42+
name: 'skills',
43+
configKey: 'skills',
44+
},
45+
defaults: {
46+
dir: 'skills',
47+
},
48+
async setup(options, nuxt) {
49+
const log = useLogger('docs-skills')
50+
const dir = options.dir ?? 'skills'
51+
const skillsDir = join(nuxt.options.rootDir, dir)
52+
53+
if (!existsSync(skillsDir)) {
54+
return
55+
}
56+
57+
const catalog = await scanSkills(skillsDir, log)
58+
if (!catalog.length) {
59+
return
60+
}
61+
62+
log.info(
63+
`Found ${catalog.length} agent skill${catalog.length > 1 ? 's' : ''}: ${catalog.map(s => s.name).join(', ')}`,
64+
)
65+
66+
// Expose the catalog at runtime so the index route doesn't need to re-scan
67+
// the source tree (which may be unavailable in serverless deployments).
68+
nuxt.options.runtimeConfig.skills = { catalog }
69+
70+
const { resolve } = createResolver(import.meta.url)
71+
72+
nuxt.hook('nitro:config', (nitroConfig) => {
73+
// Ship the skill files as Nitro server assets so they are readable
74+
// from runtime handlers via `useStorage('assets:skills')`.
75+
nitroConfig.serverAssets ||= []
76+
nitroConfig.serverAssets.push({ baseName: 'skills', dir: skillsDir })
77+
78+
// Register every skill file path so static builds prerender them.
79+
nitroConfig.prerender ||= {}
80+
nitroConfig.prerender.routes ||= []
81+
nitroConfig.prerender.routes.push('/.well-known/skills/index.json')
82+
for (const skill of catalog) {
83+
for (const file of skill.files) {
84+
nitroConfig.prerender.routes.push(`/.well-known/skills/${skill.name}/${file}`)
85+
}
86+
}
87+
})
88+
89+
addServerHandler({
90+
route: '/.well-known/skills/index.json',
91+
handler: resolve('./runtime/server/routes/.well-known/skills/index.get'),
92+
})
93+
94+
addServerHandler({
95+
route: '/.well-known/skills/**',
96+
handler: resolve('./runtime/server/routes/.well-known/skills/[...path].get'),
97+
})
98+
},
99+
})
100+
101+
function parseFrontmatter(content: string): { name?: string, description?: string } | null {
102+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
103+
if (!match?.[1]) {
104+
return null
105+
}
106+
try {
107+
const parsed: unknown = parseYaml(match[1])
108+
if (!parsed || typeof parsed !== 'object') {
109+
return null
110+
}
111+
const obj = parsed as Record<string, unknown>
112+
return {
113+
name: typeof obj.name === 'string' ? obj.name : undefined,
114+
description: typeof obj.description === 'string' ? obj.description : undefined,
115+
}
116+
}
117+
catch {
118+
return null
119+
}
120+
}
121+
122+
function validateSkillName(name: string, dirName: string, log: ReturnType<typeof useLogger>): boolean {
123+
if (name.length > MAX_NAME_LENGTH) {
124+
log.warn(`Skill "${name}" exceeds ${MAX_NAME_LENGTH} character limit`)
125+
return false
126+
}
127+
if (!SKILL_NAME_REGEX.test(name) || name.includes('--')) {
128+
log.warn(`Skill name "${name}" does not match the Agent Skills naming spec`)
129+
return false
130+
}
131+
if (name !== dirName) {
132+
log.warn(`Skill name "${name}" does not match directory name "${dirName}"`)
133+
return false
134+
}
135+
return true
136+
}
137+
138+
async function listFilesRecursively(dir: string, base: string = ''): Promise<string[]> {
139+
const files: string[] = []
140+
const entries = await readdir(dir, { withFileTypes: true })
141+
for (const entry of entries) {
142+
const relPath = base ? `${base}/${entry.name}` : entry.name
143+
if (entry.isDirectory()) {
144+
files.push(...await listFilesRecursively(join(dir, entry.name), relPath))
145+
}
146+
else {
147+
files.push(relPath)
148+
}
149+
}
150+
return files
151+
}
152+
153+
async function scanSkills(skillsDir: string, log: ReturnType<typeof useLogger>): Promise<SkillEntry[]> {
154+
const catalog: SkillEntry[] = []
155+
const entries = await readdir(skillsDir, { withFileTypes: true })
156+
157+
for (const entry of entries) {
158+
if (!entry.isDirectory()) {
159+
continue
160+
}
161+
162+
const skillDir = join(skillsDir, entry.name)
163+
const skillMdPath = join(skillDir, 'SKILL.md')
164+
165+
if (!existsSync(skillMdPath)) {
166+
continue
167+
}
168+
169+
const content = await readFile(skillMdPath, 'utf-8')
170+
const frontmatter = parseFrontmatter(content)
171+
172+
if (!frontmatter?.name) {
173+
log.warn(`Skipping skill "${entry.name}": missing name in SKILL.md frontmatter`)
174+
continue
175+
}
176+
177+
if (!frontmatter.description) {
178+
log.warn(`Skipping skill "${entry.name}": missing description in SKILL.md frontmatter`)
179+
continue
180+
}
181+
182+
if (frontmatter.description.length > MAX_DESCRIPTION_LENGTH) {
183+
log.warn(
184+
`Skipping skill "${entry.name}": description exceeds ${MAX_DESCRIPTION_LENGTH} character limit`,
185+
)
186+
continue
187+
}
188+
189+
const name = frontmatter.name
190+
if (!validateSkillName(name, entry.name, log)) {
191+
continue
192+
}
193+
194+
const allFiles = await listFilesRecursively(skillDir)
195+
// Drop dot-prefixed paths (e.g. .DS_Store, .git directories).
196+
const files = allFiles.filter(f => !f.split('/').some(s => s.startsWith('.')))
197+
const sortedFiles = ['SKILL.md', ...files.filter(f => f !== 'SKILL.md')]
198+
199+
catalog.push({
200+
name,
201+
description: frontmatter.description,
202+
files: sortedFiles,
203+
})
204+
}
205+
206+
return catalog
207+
}
208+
209+
declare module 'nuxt/schema' {
210+
interface RuntimeConfig {
211+
skills: {
212+
catalog: SkillEntry[]
213+
}
214+
}
215+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
const CONTENT_TYPES: Record<string, string> = {
2+
'.md': 'text/markdown; charset=utf-8',
3+
'.markdown': 'text/markdown; charset=utf-8',
4+
'.json': 'application/json; charset=utf-8',
5+
'.yaml': 'application/yaml; charset=utf-8',
6+
'.yml': 'application/yaml; charset=utf-8',
7+
'.txt': 'text/plain; charset=utf-8',
8+
'.py': 'text/plain; charset=utf-8',
9+
'.sh': 'text/plain; charset=utf-8',
10+
'.bash': 'text/plain; charset=utf-8',
11+
'.js': 'text/javascript; charset=utf-8',
12+
'.mjs': 'text/javascript; charset=utf-8',
13+
'.cjs': 'text/javascript; charset=utf-8',
14+
'.ts': 'text/plain; charset=utf-8',
15+
'.tsx': 'text/plain; charset=utf-8',
16+
'.jsx': 'text/plain; charset=utf-8',
17+
'.html': 'text/html; charset=utf-8',
18+
'.css': 'text/css; charset=utf-8',
19+
'.toml': 'text/plain; charset=utf-8',
20+
'.xml': 'application/xml; charset=utf-8',
21+
}
22+
23+
function getContentType(path: string): string {
24+
// Only consider the basename so a path like `dir.x/file` doesn't pick up
25+
// an extension from a parent directory.
26+
const slash = path.lastIndexOf('/')
27+
const basename = slash === -1 ? path : path.slice(slash + 1)
28+
const dot = basename.lastIndexOf('.')
29+
if (dot === -1) return 'application/octet-stream'
30+
const ext = basename.slice(dot).toLowerCase()
31+
return CONTENT_TYPES[ext] || 'application/octet-stream'
32+
}
33+
34+
export default defineEventHandler(async (event) => {
35+
const url = getRequestURL(event)
36+
const prefix = '/.well-known/skills/'
37+
const idx = url.pathname.indexOf(prefix)
38+
39+
if (idx === -1) {
40+
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
41+
}
42+
43+
let filePath: string
44+
try {
45+
filePath = decodeURIComponent(url.pathname.slice(idx + prefix.length))
46+
}
47+
catch {
48+
throw createError({ statusCode: 400, statusMessage: 'Bad Request' })
49+
}
50+
51+
// The index.json route is served by a dedicated handler — this one should
52+
// never see it, but guard anyway.
53+
if (!filePath || filePath === 'index.json') {
54+
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
55+
}
56+
57+
// Block path traversal and dotfile access.
58+
const segments = filePath.split('/')
59+
if (segments.some(seg => seg === '' || seg === '..' || seg === '.' || seg.startsWith('.'))) {
60+
throw createError({ statusCode: 400, statusMessage: 'Bad Request' })
61+
}
62+
63+
const storage = useStorage('assets:skills')
64+
65+
// Use getItemRaw so JSON/YAML files are returned as their raw bytes/string
66+
// instead of being deserialized by destr.
67+
const raw = await storage.getItemRaw<unknown>(filePath)
68+
69+
if (raw === null || raw === undefined) {
70+
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
71+
}
72+
73+
setResponseHeader(event, 'content-type', getContentType(filePath))
74+
setResponseHeader(event, 'cache-control', 'public, max-age=3600')
75+
76+
// Return raw bytes for non-string payloads so binary files (images,
77+
// archives, etc.) are served untouched. UTF-8 decoding would corrupt
78+
// them and produces unnecessary overhead for text payloads.
79+
if (typeof raw === 'string') {
80+
return raw
81+
}
82+
if (raw instanceof Uint8Array) {
83+
return raw
84+
}
85+
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(raw)) {
86+
return raw
87+
}
88+
89+
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
90+
})
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* Returns the agent skills catalog from `runtimeConfig.skills.catalog`,
3+
* which the module builds at startup. This avoids re-scanning storage and
4+
* re-parsing YAML frontmatter on every request.
5+
*/
6+
export default defineEventHandler((event) => {
7+
const config = useRuntimeConfig(event)
8+
const catalog = (config.skills?.catalog ?? [])
9+
.map(skill => ({
10+
name: skill.name,
11+
description: skill.description,
12+
path: `/.well-known/skills/${skill.name}/SKILL.md`,
13+
}))
14+
.sort((a, b) => a.name.localeCompare(b.name))
15+
16+
setResponseHeader(event, 'content-type', 'application/json; charset=utf-8')
17+
setResponseHeader(event, 'cache-control', 'public, max-age=3600')
18+
19+
return { skills: { catalog } }
20+
})

‎packages/layer/nuxt.config.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export default defineNuxtConfig({
88
resolve('./modules/config'),
99
resolve('./modules/css'),
1010
resolve('./modules/shadcn'),
11+
resolve('./modules/skills'),
1112
'@nuxtjs/color-mode',
1213
'@nuxt/content',
1314
'@nuxt/image',

‎packages/layer/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
"ufo": "^1.6.1",
7474
"unified": "^11.0.5",
7575
"unist-util-visit": "^5.0.0",
76+
"yaml": "^2.8.2",
7677
"zod": "^4.2.1",
7778
"zod-to-json-schema": "^3.25.0"
7879
},

0 commit comments

Comments
 (0)