From 5c93d79e57599f6fcd6341e8d6ec2e03c49f9b5e Mon Sep 17 00:00:00 2001 From: onmax Date: Fri, 17 Apr 2026 15:04:10 +0200 Subject: [PATCH 1/2] fix(skills): update discovery to v0.2 --- docs/content/en/4.ai/3.skills.md | 25 +++-- docs/content/fr/4.ai/3.skills.md | 25 +++-- layer/modules/skills/index.ts | 100 ++++++++++++++---- ...ills-files.ts => agent-skills-artifact.ts} | 41 +++---- ...{skills-index.ts => agent-skills-index.ts} | 6 +- layer/package.json | 1 + playground/skills/docus-playground/SKILL.md | 7 +- .../docus-playground/references/example.md | 2 +- pnpm-lock.yaml | 3 + 9 files changed, 144 insertions(+), 66 deletions(-) rename layer/modules/skills/runtime/server/routes/{skills-files.ts => agent-skills-artifact.ts} (54%) rename layer/modules/skills/runtime/server/routes/{skills-index.ts => agent-skills-index.ts} (62%) diff --git a/docs/content/en/4.ai/3.skills.md b/docs/content/en/4.ai/3.skills.md index a852cfb1d..1c3cbcb4c 100644 --- a/docs/content/en/4.ai/3.skills.md +++ b/docs/content/en/4.ai/3.skills.md @@ -7,11 +7,11 @@ navigation: ## About Agent Skills -Docus automatically discovers skills in your `skills/` directory and serves them at `/.well-known/skills/`, following the [Cloudflare Agent Skills Discovery RFC](https://github.com/cloudflare/agent-skills-discovery-rfc). This makes your skills installable from any documentation URL with a single command. +Docus automatically discovers skills in your `skills/` directory and serves them at `/.well-known/agent-skills/`, following the [Cloudflare Agent Skills Discovery RFC](https://github.com/cloudflare/agent-skills-discovery-rfc). This makes your skills installable from any documentation URL with a single command. [Agent Skills](https://agentskills.io/) are a lightweight, open format for giving AI agents specialized knowledge and workflows. A skill is a `SKILL.md` file with YAML frontmatter that describes what agents can do with your product, along with optional supporting reference files. -::note{to="https://docus.dev/.well-known/skills/index.json"} +::note{to="https://docus.dev/.well-known/agent-skills/index.json"} See the skills published on this documentation site. :: @@ -52,7 +52,7 @@ npx create-my-product my-app ### Deploy -Deploy your documentation. Docus automatically serves your skills at `/.well-known/skills/`. +Deploy your documentation. Docus automatically serves your skills at `/.well-known/agent-skills/`. ### Share with users @@ -82,7 +82,7 @@ skills/ └── config.template.yaml ``` -All files are automatically listed in the `index.json` catalog and served at their respective paths under `/.well-known/skills/{skill-name}/`. +Single-file skills are served as `skill-md` artifacts. Skills with supporting files are bundled as `.tar.gz` archives so agents can download the complete skill directory in one verified artifact. ::tip Keep your main `SKILL.md` under 500 lines. Move detailed reference material to separate files in `references/` — agents load these on demand, so smaller files mean less context usage. @@ -146,31 +146,34 @@ Docus scans your `skills/` directory at build time and generates two types of en ### Discovery index ``` -GET /.well-known/skills/index.json +GET /.well-known/agent-skills/index.json ``` -Returns a JSON catalog listing all available skills with their descriptions and files: +Returns a JSON catalog listing all available skills with their descriptions, artifact URLs, and SHA-256 digests: ```json { + "$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json", "skills": [ { "name": "my-product", + "type": "archive", "description": "Build and deploy apps with My Product.", - "files": ["SKILL.md", "references/api.md"] + "url": "/.well-known/agent-skills/my-product.tar.gz", + "digest": "sha256:c4d5e6f7..." } ] } ``` -### Skill files +### Skill artifacts ``` -GET /.well-known/skills/{skill-name}/SKILL.md -GET /.well-known/skills/{skill-name}/references/api.md +GET /.well-known/agent-skills/{skill-name}/SKILL.md +GET /.well-known/agent-skills/{skill-name}.tar.gz ``` -Individual skill files are served with appropriate content types (`text/markdown` for `.md` files, `application/json` for `.json`, etc.). +Skills that only contain `SKILL.md` are served directly with `type: "skill-md"` and `text/markdown`. Skills with `references/`, `scripts/`, `assets/`, or other support files are served as `type: "archive"` with `application/gzip`. ## Comparison with llms.txt diff --git a/docs/content/fr/4.ai/3.skills.md b/docs/content/fr/4.ai/3.skills.md index a34113f1c..b2d6e62eb 100644 --- a/docs/content/fr/4.ai/3.skills.md +++ b/docs/content/fr/4.ai/3.skills.md @@ -7,11 +7,11 @@ navigation: ## À propos des Agent Skills -Docus découvre automatiquement les skills dans votre dossier `skills/` et les sert à `/.well-known/skills/`, en suivant la [RFC Agent Skills Discovery de Cloudflare](https://github.com/cloudflare/agent-skills-discovery-rfc). Vos skills sont ainsi installables depuis n'importe quelle URL de documentation avec une seule commande. +Docus découvre automatiquement les skills dans votre dossier `skills/` et les sert à `/.well-known/agent-skills/`, en suivant la [RFC Agent Skills Discovery de Cloudflare](https://github.com/cloudflare/agent-skills-discovery-rfc). Vos skills sont ainsi installables depuis n'importe quelle URL de documentation avec une seule commande. Les [Agent Skills](https://agentskills.io/) sont un format ouvert et léger pour donner aux agents IA des connaissances spécialisées et des workflows. Un skill est un fichier `SKILL.md` avec un frontmatter YAML qui décrit ce que les agents peuvent faire avec votre produit, accompagné de fichiers de référence optionnels. -::note{to="https://docus.dev/.well-known/skills/index.json"} +::note{to="https://docus.dev/.well-known/agent-skills/index.json"} Voir les skills publiés sur ce site de documentation. :: @@ -52,7 +52,7 @@ npx create-my-product my-app ### Déployer -Déployez votre documentation. Docus sert automatiquement vos skills à `/.well-known/skills/`. +Déployez votre documentation. Docus sert automatiquement vos skills à `/.well-known/agent-skills/`. ### Partager avec vos utilisateurs @@ -82,7 +82,7 @@ skills/ └── config.template.yaml ``` -Tous les fichiers sont automatiquement listés dans le catalogue `index.json` et servis à leurs chemins respectifs sous `/.well-known/skills/{skill-name}/`. +Les skills composés uniquement d'un `SKILL.md` sont servis comme artefacts `skill-md`. Les skills avec des fichiers de support sont regroupés en archives `.tar.gz` afin que les agents puissent télécharger le dossier complet dans un seul artefact vérifié. ::tip Gardez votre `SKILL.md` principal sous 500 lignes. Déplacez le matériel de référence détaillé dans des fichiers séparés dans `references/` — les agents les chargent à la demande, donc des fichiers plus petits signifient moins d'utilisation de contexte. @@ -146,31 +146,34 @@ Docus scanne votre dossier `skills/` au moment du build et génère deux types d ### Index de découverte ``` -GET /.well-known/skills/index.json +GET /.well-known/agent-skills/index.json ``` -Retourne un catalogue JSON listant tous les skills disponibles avec leurs descriptions et fichiers : +Retourne un catalogue JSON listant tous les skills disponibles avec leurs descriptions, URLs d'artefacts et digests SHA-256 : ```json { + "$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json", "skills": [ { "name": "my-product", + "type": "archive", "description": "Build and deploy apps with My Product.", - "files": ["SKILL.md", "references/api.md"] + "url": "/.well-known/agent-skills/my-product.tar.gz", + "digest": "sha256:c4d5e6f7..." } ] } ``` -### Fichiers de skills +### Artefacts de skills ``` -GET /.well-known/skills/{skill-name}/SKILL.md -GET /.well-known/skills/{skill-name}/references/api.md +GET /.well-known/agent-skills/{skill-name}/SKILL.md +GET /.well-known/agent-skills/{skill-name}.tar.gz ``` -Les fichiers individuels sont servis avec les types de contenu appropriés (`text/markdown` pour les `.md`, `application/json` pour les `.json`, etc.). +Les skills qui ne contiennent que `SKILL.md` sont servis directement avec `type: "skill-md"` et `text/markdown`. Les skills avec `references/`, `scripts/`, `assets/` ou d'autres fichiers de support sont servis avec `type: "archive"` et `application/gzip`. ## Comparaison avec llms.txt diff --git a/layer/modules/skills/index.ts b/layer/modules/skills/index.ts index bb9f453b8..1f30a5990 100644 --- a/layer/modules/skills/index.ts +++ b/layer/modules/skills/index.ts @@ -1,17 +1,26 @@ import { addServerHandler, createResolver, defineNuxtModule, logger } from '@nuxt/kit' +import { createHash } from 'node:crypto' import { existsSync } from 'node:fs' -import { readdir, readFile } from 'node:fs/promises' +import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' +import { create as createTar } from 'tar' import { parse as parseYaml } from 'yaml' +type SkillArtifactType = 'skill-md' | 'archive' + interface SkillEntry { name: string + type: SkillArtifactType description: string - files: string[] + url: string + digest: string } +const SCHEMA_URI = 'https://schemas.agentskills.io/discovery/0.2.0/schema.json' const SKILL_NAME_REGEX = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/ const MAX_NAME_LENGTH = 64 +const MAX_DESCRIPTION_LENGTH = 1024 +const WELL_KNOWN_PREFIX = '/.well-known/agent-skills' const log = logger.withTag('Docus') @@ -23,37 +32,36 @@ export default defineNuxtModule({ const skillsDir = join(nuxt.options.rootDir, 'skills') if (!existsSync(skillsDir)) return - const catalog = await scanSkills(skillsDir) + const artifactsDir = join(nuxt.options.rootDir, '.data', 'docus-agent-skills') + const catalog = await scanSkills(skillsDir, artifactsDir) if (!catalog.length) return log.info(`Found ${catalog.length} agent skill${catalog.length > 1 ? 's' : ''}: ${catalog.map(s => s.name).join(', ')}`) - nuxt.options.runtimeConfig.skills = { catalog } + nuxt.options.runtimeConfig.skills = { schema: SCHEMA_URI, catalog } const { resolve } = createResolver(import.meta.url) nuxt.hook('nitro:config', (nitroConfig) => { nitroConfig.serverAssets ||= [] - nitroConfig.serverAssets.push({ baseName: 'skills', dir: skillsDir }) + nitroConfig.serverAssets.push({ baseName: 'agent-skills', dir: artifactsDir }) nitroConfig.prerender ||= {} nitroConfig.prerender.routes ||= [] - nitroConfig.prerender.routes.push('/.well-known/skills/index.json') + nitroConfig.prerender.routes.push(`${WELL_KNOWN_PREFIX}/index.json`) for (const skill of catalog) { - for (const file of skill.files) { - nitroConfig.prerender.routes.push(`/.well-known/skills/${skill.name}/${file}`) - } + nitroConfig.prerender.routes.push(skill.url) } }) addServerHandler({ - route: '/.well-known/skills/index.json', - handler: resolve('./runtime/server/routes/skills-index'), + route: `${WELL_KNOWN_PREFIX}/index.json`, + handler: resolve('./runtime/server/routes/agent-skills-index'), }) addServerHandler({ - route: '/.well-known/skills/**', - handler: resolve('./runtime/server/routes/skills-files'), + route: `${WELL_KNOWN_PREFIX}/**`, + handler: resolve('./runtime/server/routes/agent-skills-artifact'), }) }, }) @@ -85,24 +93,76 @@ function validateSkillName(name: string, dirName: string): boolean { return true } +function validateDescription(description: string, name: string): boolean { + if (description.length > MAX_DESCRIPTION_LENGTH) { + log.warn(`Skipping skill "${name}": description exceeds ${MAX_DESCRIPTION_LENGTH} character limit`) + return false + } + return true +} + +function digest(content: Buffer): string { + return `sha256:${createHash('sha256').update(content).digest('hex')}` +} + +function sortSkillFiles(files: string[]): string[] { + return ['SKILL.md', ...files.filter(file => file !== 'SKILL.md').sort()] +} + async function listFilesRecursively(dir: string, base: string = ''): Promise { const files: string[] = [] const entries = await readdir(dir, { withFileTypes: true }) for (const entry of entries) { const relPath = base ? `${base}/${entry.name}` : entry.name + if (entry.name.startsWith('.')) continue if (entry.isDirectory()) { files.push(...await listFilesRecursively(join(dir, entry.name), relPath)) } - else { + else if (entry.isFile()) { files.push(relPath) } + else { + log.warn(`Skipping unsupported skill file "${relPath}"`) + } } return files } -async function scanSkills(skillsDir: string): Promise { +async function createSkillArtifact(skillDir: string, outputDir: string, name: string, files: string[]): Promise> { + if (files.length === 1 && files[0] === 'SKILL.md') { + const outputPath = join(outputDir, name, 'SKILL.md') + await mkdir(join(outputDir, name), { recursive: true }) + const content = await readFile(join(skillDir, 'SKILL.md')) + await writeFile(outputPath, content) + + return { + type: 'skill-md', + url: `${WELL_KNOWN_PREFIX}/${name}/SKILL.md`, + digest: digest(await readFile(outputPath)), + } + } + + const outputPath = join(outputDir, `${name}.tar.gz`) + await createTar({ + cwd: skillDir, + file: outputPath, + gzip: true, + noMtime: true, + portable: true, + }, files) + + return { + type: 'archive', + url: `${WELL_KNOWN_PREFIX}/${name}.tar.gz`, + digest: digest(await readFile(outputPath)), + } +} + +async function scanSkills(skillsDir: string, artifactsDir: string): Promise { const catalog: SkillEntry[] = [] const entries = await readdir(skillsDir, { withFileTypes: true }) + await rm(artifactsDir, { recursive: true, force: true }) + await mkdir(artifactsDir, { recursive: true }) for (const entry of entries) { if (!entry.isDirectory()) continue @@ -122,15 +182,18 @@ async function scanSkills(skillsDir: string): Promise { const name = frontmatter.name || entry.name if (!validateSkillName(name, entry.name)) continue + if (!validateDescription(frontmatter.description, name)) continue const allFiles = await listFilesRecursively(skillDir) - const files = allFiles.filter(f => !f.split('/').some(s => s.startsWith('.'))) - const sortedFiles = ['SKILL.md', ...files.filter(f => f !== 'SKILL.md')] + const sortedFiles = sortSkillFiles(allFiles) + const artifact = await createSkillArtifact(skillDir, artifactsDir, name, sortedFiles) catalog.push({ name, + type: artifact.type, description: frontmatter.description, - files: sortedFiles, + url: artifact.url, + digest: artifact.digest, }) } @@ -140,6 +203,7 @@ async function scanSkills(skillsDir: string): Promise { declare module 'nuxt/schema' { interface RuntimeConfig { skills: { + schema: string catalog: SkillEntry[] } } diff --git a/layer/modules/skills/runtime/server/routes/skills-files.ts b/layer/modules/skills/runtime/server/routes/agent-skills-artifact.ts similarity index 54% rename from layer/modules/skills/runtime/server/routes/skills-files.ts rename to layer/modules/skills/runtime/server/routes/agent-skills-artifact.ts index 17d912e58..38b898541 100644 --- a/layer/modules/skills/runtime/server/routes/skills-files.ts +++ b/layer/modules/skills/runtime/server/routes/agent-skills-artifact.ts @@ -1,49 +1,50 @@ +const WELL_KNOWN_PREFIX = '/.well-known/agent-skills/' + const CONTENT_TYPES: Record = { - '.md': 'text/markdown; charset=utf-8', '.json': 'application/json; charset=utf-8', + '.md': 'text/markdown; charset=utf-8', + '.txt': 'text/plain; charset=utf-8', '.yaml': 'text/yaml; charset=utf-8', '.yml': 'text/yaml; charset=utf-8', - '.txt': 'text/plain; charset=utf-8', - '.py': 'text/plain; charset=utf-8', - '.sh': 'text/plain; charset=utf-8', - '.js': 'text/javascript; charset=utf-8', - '.ts': 'text/plain; charset=utf-8', } function getContentType(path: string): string { + if (path.endsWith('.tar.gz')) return 'application/gzip' + const ext = path.slice(path.lastIndexOf('.')) return CONTENT_TYPES[ext] || 'application/octet-stream' } export default defineEventHandler(async (event) => { const url = getRequestURL(event) - const prefix = '/.well-known/skills/' - const idx = url.pathname.indexOf(prefix) - if (idx === -1) { + if (!url.pathname.startsWith(WELL_KNOWN_PREFIX)) { throw createError({ statusCode: 404, statusMessage: 'Not Found' }) } - const filePath = decodeURIComponent(url.pathname.slice(idx + prefix.length)) - - if (!filePath || filePath.includes('..')) { + const artifactPath = decodeURIComponent(url.pathname.slice(WELL_KNOWN_PREFIX.length)) + if (!artifactPath || artifactPath.split('/').includes('..')) { throw createError({ statusCode: 400, statusMessage: 'Bad Request' }) } const { skills } = useRuntimeConfig(event) - const skillName = filePath.split('/')[0] - if (!skills.catalog.some((s: { name: string }) => s.name === skillName)) { + const allowedArtifacts = new Set( + skills.catalog.map((skill: { url: string }) => skill.url.slice(WELL_KNOWN_PREFIX.length)), + ) + + if (!allowedArtifacts.has(artifactPath)) { throw createError({ statusCode: 404, statusMessage: 'Not Found' }) } - const storage = useStorage('assets:skills') - const content = await storage.getItemRaw(filePath) + setResponseHeader(event, 'content-type', getContentType(artifactPath)) + setResponseHeader(event, 'cache-control', 'public, max-age=3600') + setResponseHeader(event, 'access-control-allow-origin', '*') + + const storage = useStorage('assets:agent-skills') + const content = await storage.getItemRaw(artifactPath) - if (!content) { + if (content == null) { throw createError({ statusCode: 404, statusMessage: 'Not Found' }) } - setResponseHeader(event, 'content-type', getContentType(filePath)) - setResponseHeader(event, 'cache-control', 'public, max-age=3600') - return content }) diff --git a/layer/modules/skills/runtime/server/routes/skills-index.ts b/layer/modules/skills/runtime/server/routes/agent-skills-index.ts similarity index 62% rename from layer/modules/skills/runtime/server/routes/skills-index.ts rename to layer/modules/skills/runtime/server/routes/agent-skills-index.ts index 30995a27a..3c653682c 100644 --- a/layer/modules/skills/runtime/server/routes/skills-index.ts +++ b/layer/modules/skills/runtime/server/routes/agent-skills-index.ts @@ -3,6 +3,10 @@ export default defineEventHandler((event) => { setResponseHeader(event, 'content-type', 'application/json') setResponseHeader(event, 'cache-control', 'public, max-age=3600') + setResponseHeader(event, 'access-control-allow-origin', '*') - return { skills: skills.catalog } + return { + $schema: skills.schema, + skills: skills.catalog, + } }) diff --git a/layer/package.json b/layer/package.json index 0d4323955..8f093b016 100644 --- a/layer/package.json +++ b/layer/package.json @@ -53,6 +53,7 @@ "scule": "^1.3.0", "shiki-stream": "^0.1.4", "tailwindcss": "^4.2.2", + "tar": "^7.5.13", "ufo": "^1.6.3", "yaml": "^2.7.1", "zod": "^4.3.6", diff --git a/playground/skills/docus-playground/SKILL.md b/playground/skills/docus-playground/SKILL.md index f7587ed72..29635eb6d 100644 --- a/playground/skills/docus-playground/SKILL.md +++ b/playground/skills/docus-playground/SKILL.md @@ -1,6 +1,6 @@ --- name: docus-playground -description: Sample skill for testing the Docus agent skills discovery feature. Use to verify that /.well-known/skills/ routes work correctly. +description: Sample skill for testing the Docus agent skills discovery feature. Use to verify that /.well-known/agent-skills/ routes work correctly. metadata: author: docus version: "1.0" @@ -14,8 +14,7 @@ This is a sample skill used to test the agent skills discovery feature in the Do Check these endpoints: -- `GET /.well-known/skills/index.json` -- should list this skill -- `GET /.well-known/skills/docus-playground/SKILL.md` -- should return this file -- `GET /.well-known/skills/docus-playground/references/example.md` -- should return the reference file +- `GET /.well-known/agent-skills/index.json` -- should list this skill +- `GET /.well-known/agent-skills/docus-playground.tar.gz` -- should return an archive containing this file and its references For more details, see [references/example.md](references/example.md). diff --git a/playground/skills/docus-playground/references/example.md b/playground/skills/docus-playground/references/example.md index 2b31630d0..cc7d26470 100644 --- a/playground/skills/docus-playground/references/example.md +++ b/playground/skills/docus-playground/references/example.md @@ -1,3 +1,3 @@ # Example Reference -This is a reference file for testing nested file serving via `/.well-known/skills/`. +This is a reference file for testing archive distribution via `/.well-known/agent-skills/`. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3cdbad432..e6cae7ce5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -204,6 +204,9 @@ importers: tailwindcss: specifier: ^4.2.2 version: 4.2.2 + tar: + specifier: ^7.5.13 + version: 7.5.13 ufo: specifier: ^1.6.3 version: 1.6.3 From 04494f3e61dcac8400f798136569e05c501e4ab6 Mon Sep 17 00:00:00 2001 From: onmax Date: Fri, 17 Apr 2026 17:07:11 +0200 Subject: [PATCH 2/2] perf(skills): reuse content buffer for digest --- layer/modules/skills/index.ts | 25 ++++--------------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/layer/modules/skills/index.ts b/layer/modules/skills/index.ts index 1f30a5990..fa07a781a 100644 --- a/layer/modules/skills/index.ts +++ b/layer/modules/skills/index.ts @@ -130,32 +130,15 @@ async function listFilesRecursively(dir: string, base: string = ''): Promise> { if (files.length === 1 && files[0] === 'SKILL.md') { - const outputPath = join(outputDir, name, 'SKILL.md') await mkdir(join(outputDir, name), { recursive: true }) const content = await readFile(join(skillDir, 'SKILL.md')) - await writeFile(outputPath, content) - - return { - type: 'skill-md', - url: `${WELL_KNOWN_PREFIX}/${name}/SKILL.md`, - digest: digest(await readFile(outputPath)), - } + await writeFile(join(outputDir, name, 'SKILL.md'), content) + return { type: 'skill-md', url: `${WELL_KNOWN_PREFIX}/${name}/SKILL.md`, digest: digest(content) } } const outputPath = join(outputDir, `${name}.tar.gz`) - await createTar({ - cwd: skillDir, - file: outputPath, - gzip: true, - noMtime: true, - portable: true, - }, files) - - return { - type: 'archive', - url: `${WELL_KNOWN_PREFIX}/${name}.tar.gz`, - digest: digest(await readFile(outputPath)), - } + await createTar({ cwd: skillDir, file: outputPath, gzip: true, noMtime: true, portable: true }, files) + return { type: 'archive', url: `${WELL_KNOWN_PREFIX}/${name}.tar.gz`, digest: digest(await readFile(outputPath)) } } async function scanSkills(skillsDir: string, artifactsDir: string): Promise {