Skip to content
Open
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
25 changes: 14 additions & 11 deletions docs/content/en/4.ai/3.skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
::

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -160,31 +160,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

Expand Down
25 changes: 14 additions & 11 deletions docs/content/fr/4.ai/3.skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
::

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -160,31 +160,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

Expand Down
84 changes: 66 additions & 18 deletions layer/modules/skills/index.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,33 @@
import { addPrerenderRoutes, addServerHandler, createResolver, defineNuxtModule, logger } from '@nuxt/kit'
import { defu } from 'defu'
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 type { NitroConfig } from 'nitropack'
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'

export interface SkillsModuleOptions {
dir?: string
}

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')

Expand All @@ -35,37 +45,36 @@ export default defineNuxtModule<SkillsModuleOptions>({
const skillsDir = join(nuxt.options.rootDir, options.dir)
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)

const onNitroConfig = nuxt.hook as (name: 'nitro:config', cb: (nitroConfig: NitroConfig) => void) => void
onNitroConfig('nitro:config', (nitroConfig) => {
nitroConfig.serverAssets ||= []
nitroConfig.serverAssets.push({ baseName: 'skills', dir: skillsDir })
nitroConfig.serverAssets.push({ baseName: 'agent-skills', dir: artifactsDir })
})

const prerenderRoutes = ['/.well-known/skills/index.json']
const prerenderRoutes = [`${WELL_KNOWN_PREFIX}/index.json`]
for (const skill of catalog) {
for (const file of skill.files) {
prerenderRoutes.push(`/.well-known/skills/${skill.name}/${file}`)
}
prerenderRoutes.push(skill.url)
}
addPrerenderRoutes(prerenderRoutes)

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'),
})
},
})
Expand Down Expand Up @@ -97,24 +106,59 @@ 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<string[]> {
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<SkillEntry[]> {
async function createSkillArtifact(skillDir: string, outputDir: string, name: string, files: string[]): Promise<Pick<SkillEntry, 'type' | 'url' | 'digest'>> {
if (files.length === 1 && files[0] === 'SKILL.md') {
await mkdir(join(outputDir, name), { recursive: true })
const content = await readFile(join(skillDir, 'SKILL.md'))
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)) }
}

async function scanSkills(skillsDir: string, artifactsDir: string): Promise<SkillEntry[]> {
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
Expand All @@ -134,15 +178,18 @@ async function scanSkills(skillsDir: string): Promise<SkillEntry[]> {

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,
})
}

Expand All @@ -152,6 +199,7 @@ async function scanSkills(skillsDir: string): Promise<SkillEntry[]> {
declare module 'nuxt/schema' {
interface RuntimeConfig {
skills: {
schema: string
catalog: SkillEntry[]
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,49 +1,50 @@
const WELL_KNOWN_PREFIX = '/.well-known/agent-skills/'

const CONTENT_TYPES: Record<string, string> = {
'.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<string>(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<Buffer | string>(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
})
Loading
Loading