Skip to content

Commit 0974caf

Browse files
authored
feat: add agent skills discovery via /.well-known/skills (#19)
1 parent 2850056 commit 0974caf

13 files changed

Lines changed: 813 additions & 322 deletions

File tree

README.md

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Content lives as Markdown in your repo and is served **at request time** — par
1111
- **Instant production content** — GitHub-sourced content pinned to a commit SHA, ISR-cached HTML, revalidated on push by a GitHub webhook (`/api/revalidate`).
1212
- **Versioned previews** — any branch (`/tree/:branch`) or commit (`/blob/:sha`) can be previewed through versioned URLs.
1313
- Docs UI built with [Nuxt UI](https://ui.nuxt.com): sidebar navigation, search (`⌘K`), TOC, prev/next links, version history panel.
14-
- SEO & AEO out of the box: sitemap, robots, canonical URLs, OG images (Satori), JSON-LD, `llms.txt` / `llms-full.txt`, raw markdown mirrors (`/raw/**`), RSS, MCP server (`/mcp`).
14+
- SEO & AEO out of the box: sitemap, robots, canonical URLs, OG images (Satori), JSON-LD, `llms.txt` / `llms-full.txt`, raw markdown mirrors (`/raw/**`), RSS, MCP server (`/mcp`), Agent Skills discovery (`/.well-known/skills/`).
1515

1616
## Usage
1717

@@ -58,7 +58,7 @@ export default defineAppConfig({
5858

5959
> Nuxt merges `app.config.ts` across layers with [defu](https://github.com/unjs/defu), which **concatenates arrays**. A consumer's list is *appended to* the layer's, not substituted for it — which is why every array default in the layer is empty. Keep it that way.
6060
61-
`comarkDocs` in `nuxt.config.ts` covers `isr` (`false` disables the generated ISR route rules), `codeExplorer.allowRepos`, and `contentDir`. The GitHub repo, branch and content directory are inferred from the local git checkout and `VERCEL_GIT_*`; override them at runtime with `NUXT_DOCS_*` env vars (`NUXT_DOCS_GITHUB_OWNER`, `NUXT_DOCS_GITHUB_REPO`, `NUXT_DOCS_GITHUB_BRANCH`, …).
61+
`comarkDocs` in `nuxt.config.ts` covers `isr` (`false` disables the generated ISR route rules), `codeExplorer.allowRepos`, `contentDir`, and `skills.dir`. The GitHub repo, branch and content directory are inferred from the local git checkout and `VERCEL_GIT_*`; override them at runtime with `NUXT_DOCS_*` env vars (`NUXT_DOCS_GITHUB_OWNER`, `NUXT_DOCS_GITHUB_REPO`, `NUXT_DOCS_GITHUB_BRANCH`, …).
6262

6363
> **Builds without `.git`.** The content directory is stored relative to the *repository* root, since that's what the GitHub source, the edit links and the push webhook all need — an app in `docs/` becomes `docs/content`. That's derived by relativising against the git root, so a build that can't see `.git` (a shallow or context-limited Docker build, an exported tarball) can only assume the app *is* the repository root. For a single-app repo that's correct; for an app in a subdirectory it silently points every production content read at a path that doesn't exist, and dev won't show it because dev reads the absolute path. The build warns when it has to assume. Set `comarkDocs.contentDir` (or `NUXT_DOCS_CONTENT_DIR`) to silence it authoritatively.
6464
@@ -81,6 +81,33 @@ The wordmarks (`LogoComark`, `LogoComarkContent`) live in the layer because each
8181

8282
Components can still be replaced by shipping a same-named one (`AppHeader`, `AppFooter`, `AppHeaderBrand`, `OgImage/OgImageDocs.satori.vue`), but neither site needs to.
8383

84+
### Agent Skills
85+
86+
Drop skills into a `skills/` directory at the app root and the layer serves them at `/.well-known/skills/`, following the [Cloudflare Agent Skills Discovery RFC](https://github.com/cloudflare/agent-skills-discovery-rfc) (v0.1). Users install them with:
87+
88+
```bash
89+
npx skills add https://your-docs-domain.com
90+
```
91+
92+
```
93+
my-docs/
94+
└─ skills/
95+
└─ my-product/
96+
├─ SKILL.md
97+
└─ references/
98+
└─ api.md
99+
```
100+
101+
Each skill needs a `SKILL.md` whose frontmatter includes a `description`. `name` defaults to the directory name and must match the [Agent Skills naming spec](https://agentskills.io/specification#name-field) (lowercase letters, numbers and hyphens). Discovery:
102+
103+
```
104+
GET /.well-known/skills/index.json
105+
GET /.well-known/skills/{skill-name}/SKILL.md
106+
GET /.well-known/skills/{skill-name}/references/api.md
107+
```
108+
109+
Override the directory with `comarkDocs.skills.dir` if it isn't `skills/`. Skills are scanned at build time from the filesystem (they ship with the app, not with GitHub-sourced content), so a skill change needs a redeploy.
110+
84111
### Keyboard shortcuts
85112

86113
| Keys | Action |

modules/config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@ export interface ComarkDocsOptions {
2626
/** GitHub repos (`owner/name`) `/api/code-explorer` may read. Defaults to the content repo only. */
2727
allowRepos?: string[]
2828
}
29+
skills?: {
30+
/**
31+
* Directory, relative to the app root, scanned at build time for Agent Skills.
32+
* Each subdirectory with a `SKILL.md` is published at `/.well-known/skills/`.
33+
* @default 'skills'
34+
*/
35+
dir?: string
36+
}
2937
}
3038

3139
export default defineNuxtModule<ComarkDocsOptions>({

modules/skills/index.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { addPrerenderRoutes, addServerHandler, createResolver, defineNuxtModule, useLogger } from '@nuxt/kit'
2+
import { defu } from 'defu'
3+
import { join } from 'pathe'
4+
import { scanSkills } from './utils'
5+
import type { ComarkDocsOptions } from '../config'
6+
7+
const logger = useLogger('comark-docs')
8+
9+
export default defineNuxtModule({
10+
meta: {
11+
name: 'comark-docs/skills',
12+
},
13+
async setup(_options, nuxt) {
14+
const comarkDocs = (nuxt.options as typeof nuxt.options & { comarkDocs?: ComarkDocsOptions }).comarkDocs
15+
const skillsDir = join(nuxt.options.rootDir, comarkDocs?.skills?.dir || 'skills')
16+
17+
const { catalog, warnings } = await scanSkills(skillsDir)
18+
for (const warning of warnings) logger.warn(warning)
19+
if (!catalog.length) return
20+
21+
logger.info(`Found ${catalog.length} agent skill${catalog.length > 1 ? 's' : ''}: ${catalog.map((s) => s.name).join(', ')}`)
22+
nuxt.options.runtimeConfig.skills = { catalog }
23+
24+
const { resolve } = createResolver(import.meta.url)
25+
const handler = resolve('./runtime/server/routes/skills-files')
26+
27+
nuxt.hook('nitro:config', (nitroConfig) => {
28+
nitroConfig.serverAssets ||= []
29+
nitroConfig.serverAssets.push({ baseName: 'skills', dir: skillsDir })
30+
})
31+
32+
const prerenderRoutes = ['/.well-known/skills', '/.well-known/skills/', '/.well-known/skills/index.json']
33+
for (const skill of catalog) {
34+
for (const file of skill.files) {
35+
prerenderRoutes.push(`/.well-known/skills/${skill.name}/${file}`)
36+
}
37+
}
38+
addPrerenderRoutes(prerenderRoutes)
39+
40+
if (!nuxt.options.dev && comarkDocs?.isr !== false) {
41+
nuxt.options.routeRules = defu(nuxt.options.routeRules, {
42+
'/.well-known/skills/**': { isr: true },
43+
}) as typeof nuxt.options.routeRules
44+
}
45+
46+
addServerHandler({ route: '/.well-known/skills', handler })
47+
addServerHandler({ route: '/.well-known/skills/**', handler })
48+
},
49+
})
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { resolveSkillFilePath, type SkillEntry } from '../../../utils'
2+
3+
const PREFIX = '/.well-known/skills/'
4+
const CONTENT_TYPES: Record<string, string> = {
5+
'.md': 'text/markdown; charset=utf-8',
6+
'.json': 'application/json; charset=utf-8',
7+
'.yaml': 'text/yaml; charset=utf-8',
8+
'.yml': 'text/yaml; charset=utf-8',
9+
'.txt': 'text/plain; charset=utf-8',
10+
'.py': 'text/plain; charset=utf-8',
11+
'.sh': 'text/plain; charset=utf-8',
12+
'.js': 'text/javascript; charset=utf-8',
13+
'.ts': 'text/plain; charset=utf-8',
14+
}
15+
16+
function contentType(path: string): string {
17+
const dot = path.lastIndexOf('.')
18+
return (dot === -1 ? undefined : CONTENT_TYPES[path.slice(dot)]) || 'application/octet-stream'
19+
}
20+
21+
export default defineEventHandler(async (event) => {
22+
const url = getRequestURL(event)
23+
const idx = url.pathname.indexOf(PREFIX)
24+
const filePath = idx === -1 ? '' : decodeURIComponent(url.pathname.slice(idx + PREFIX.length))
25+
const { skills } = useRuntimeConfig(event)
26+
27+
if (!filePath || filePath === 'index.json') {
28+
setHeader(event, 'content-type', 'application/json')
29+
setHeader(event, 'cache-control', 'public, max-age=3600')
30+
return { skills: skills.catalog }
31+
}
32+
33+
const resolved = resolveSkillFilePath(filePath)
34+
if (!resolved) {
35+
throw createError({ statusCode: 400, statusMessage: 'Bad Request' })
36+
}
37+
38+
const catalog = skills.catalog as SkillEntry[]
39+
const skill = catalog.find((entry) => entry.name === resolved.skillName)
40+
if (!skill || !skill.files.includes(resolved.relativeFile)) {
41+
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
42+
}
43+
44+
const storagePath = `${resolved.skillName}/${resolved.relativeFile}`
45+
const content = await useStorage('assets:skills').getItemRaw(storagePath)
46+
if (!content) {
47+
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
48+
}
49+
50+
setHeader(event, 'content-type', contentType(storagePath))
51+
setHeader(event, 'cache-control', 'public, max-age=3600')
52+
return content
53+
})

modules/skills/test/skills.test.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { mkdir, mkdtemp, symlink, writeFile } from 'node:fs/promises'
2+
import { tmpdir } from 'node:os'
3+
import { join } from 'pathe'
4+
import { describe, expect, it } from 'vitest'
5+
import { resolveSkillFilePath, scanSkills } from '../utils'
6+
7+
async function skillsRoot(): Promise<string> {
8+
return mkdtemp(join(tmpdir(), 'comark-skills-'))
9+
}
10+
11+
async function writeSkill(root: string, name: string, skillMd: string, extra: Record<string, string> = {}) {
12+
const dir = join(root, name)
13+
await mkdir(dir, { recursive: true })
14+
await writeFile(join(dir, 'SKILL.md'), skillMd)
15+
for (const [rel, body] of Object.entries(extra)) {
16+
const path = join(dir, rel)
17+
await mkdir(join(path, '..'), { recursive: true })
18+
await writeFile(path, body)
19+
}
20+
}
21+
22+
describe('scanSkills', () => {
23+
it('catalogues a valid skill with supporting files, SKILL.md first', async () => {
24+
const root = await skillsRoot()
25+
await writeSkill(
26+
root,
27+
'my-product',
28+
'---\nname: my-product\ndescription: >\n Build apps with My Product.\n---\n',
29+
{
30+
'references/api.md': '# API\n',
31+
'scripts/setup.sh': '#!/bin/sh\n',
32+
}
33+
)
34+
35+
const { catalog, warnings } = await scanSkills(root)
36+
expect(warnings).toEqual([])
37+
expect(catalog).toEqual([
38+
{
39+
name: 'my-product',
40+
description: 'Build apps with My Product.\n',
41+
files: ['SKILL.md', 'references/api.md', 'scripts/setup.sh'],
42+
},
43+
])
44+
})
45+
46+
it('defaults name to the directory when frontmatter omits it', async () => {
47+
const root = await skillsRoot()
48+
await writeSkill(root, 'create-project', '---\ndescription: Scaffold a project.\n---\n')
49+
expect((await scanSkills(root)).catalog[0]?.name).toBe('create-project')
50+
})
51+
52+
it('skips skills without a description, with an invalid name, or with a name/dir mismatch', async () => {
53+
const root = await skillsRoot()
54+
await writeSkill(root, 'no-desc', '---\nname: no-desc\n---\n')
55+
await writeSkill(root, 'BadName', '---\nname: BadName\ndescription: Nope.\n---\n')
56+
await writeSkill(root, 'mismatch', '---\nname: other\ndescription: Nope.\n---\n')
57+
await writeSkill(root, 'ok-skill', '---\ndescription: Fine.\n---\n')
58+
59+
const { catalog, warnings } = await scanSkills(root)
60+
expect(catalog.map((s) => s.name)).toEqual(['ok-skill'])
61+
expect(warnings).toHaveLength(3)
62+
})
63+
64+
it('omits hidden files from the catalog', async () => {
65+
const root = await skillsRoot()
66+
await writeSkill(root, 'my-skill', '---\ndescription: Hidden files stay private.\n---\n', {
67+
'.secret': 'nope',
68+
'refs/.cache': 'nope',
69+
})
70+
expect((await scanSkills(root)).catalog[0]?.files).toEqual(['SKILL.md'])
71+
})
72+
73+
it('returns an empty catalog when the directory is missing', async () => {
74+
expect(await scanSkills(join(tmpdir(), 'comark-skills-missing'))).toEqual({
75+
catalog: [],
76+
warnings: [],
77+
})
78+
})
79+
80+
it('skips a skill whose SKILL.md is a directory, without aborting the scan', async () => {
81+
const root = await skillsRoot()
82+
await mkdir(join(root, 'broken', 'SKILL.md'), { recursive: true })
83+
await writeSkill(root, 'ok-skill', '---\ndescription: Fine.\n---\n')
84+
85+
const { catalog, warnings } = await scanSkills(root)
86+
expect(catalog.map((s) => s.name)).toEqual(['ok-skill'])
87+
expect(warnings.some((w) => w.includes('broken') && w.includes('not a file'))).toBe(true)
88+
})
89+
90+
it('does not list a symlink that points outside the skill directory', async () => {
91+
const root = await skillsRoot()
92+
const outside = await mkdtemp(join(tmpdir(), 'comark-skills-outside-'))
93+
await writeFile(join(outside, 'secret.md'), 'leaked')
94+
await writeSkill(root, 'my-skill', '---\ndescription: Fine.\n---\n', {
95+
'references/api.md': '# API\n',
96+
})
97+
await symlink(join(outside, 'secret.md'), join(root, 'my-skill', 'leaked.md'))
98+
await symlink(outside, join(root, 'my-skill', 'escape'))
99+
100+
expect((await scanSkills(root)).catalog[0]?.files).toEqual(['SKILL.md', 'references/api.md'])
101+
})
102+
})
103+
104+
describe('resolveSkillFilePath', () => {
105+
it('normalises in-skill `.` / `..` segments', () => {
106+
expect(resolveSkillFilePath('my-skill/refs/../SKILL.md')).toEqual({
107+
skillName: 'my-skill',
108+
relativeFile: 'SKILL.md',
109+
})
110+
expect(resolveSkillFilePath('my-skill/./references/api.md')).toEqual({
111+
skillName: 'my-skill',
112+
relativeFile: 'references/api.md',
113+
})
114+
})
115+
116+
it('rejects paths that escape or have no file', () => {
117+
expect(resolveSkillFilePath('../etc/passwd')).toBeNull()
118+
expect(resolveSkillFilePath('my-skill/../../etc/passwd')).toBeNull()
119+
expect(resolveSkillFilePath('/etc/passwd')).toBeNull()
120+
expect(resolveSkillFilePath('my-skill/SKILL.md\0.png')).toBeNull()
121+
expect(resolveSkillFilePath('my-skill')).toBeNull()
122+
expect(resolveSkillFilePath('')).toBeNull()
123+
})
124+
})

0 commit comments

Comments
 (0)