feat(skills): add .well-known/skills module#31
Conversation
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.
cac65cf to
34ce435
Compare
There was a problem hiding this comment.
Code Review
This pull request introduces a new skills module to expose Anthropic Agent Skills via .well-known/skills by scanning directories, parsing YAML frontmatter, and registering routes. The review feedback highlights two critical issues: first, the server handler file [...path].get.ts registered in the module is missing from the changes, which will cause runtime errors; second, the index.get.ts handler redundantly re-scans and re-parses files on every request instead of utilizing the pre-built catalog from the runtime configuration.
There was a problem hiding this comment.
4 issues found across 6 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/layer/modules/skills/runtime/server/routes/.well-known/skills/[...path].get.ts">
<violation number="1" location="packages/layer/modules/skills/runtime/server/routes/.well-known/skills/[...path].get.ts:39">
P2: Guard the decode. A malformed `%` sequence here can throw a `URIError` and turn a bad request into a 500 instead of a clean 400/404.</violation>
</file>
Architecture diagram
sequenceDiagram
participant Build as Nuxt Build
participant Module as Skills Module
participant FS as File System (skills/)
participant AssetStore as Nitro Asset Store (assets:skills)
participant Runtime as Nuxt Runtime
participant Client as HTTP Client
Note over Build,Client: Build-Time: Skill Discovery & Registration
Build->>Module: setup() with options { dir: 'skills' }
Module->>FS: Scan subdirectories for SKILL.md
FS-->>Module: List of skill directories
loop For each skill subdirectory
Module->>FS: Read SKILL.md
FS-->>Module: Raw content with YAML frontmatter
Module->>Module: Parse frontmatter (name, description)
alt Invalid name (spec violation, max 64 chars, dir mismatch)
Module->>Module: Log warning, skip skill
else Valid skill
Module->>FS: Recursively list skill files
FS-->>Module: File list (filtered, dotfiles excluded)
Module->>Module: Build catalog entry { name, description, files }
end
end
alt No skills directory or empty catalog
Module->>Build: Return early, no routes registered
else Skills found
Module->>Build: Set runtimeConfig.skills.catalog
Module->>Build: Register serverAsset 'skills' -> skillsDir
Module->>Build: Add prerender routes (index.json + all skill files)
Module->>Build: addServerHandler('/.well-known/skills/index.json')
Module->>Build: addServerHandler('/.well-known/skills/**')
Build-->>Module: Handlers registered
end
Note over Build,Client: Runtime: Request Handling
Client->>Runtime: GET /.well-known/skills/index.json
Runtime->>AssetStore: getKeys() via assets:skills
AssetStore-->>Runtime: All storage keys (skill:SKILL.md pairs)
Runtime->>Runtime: Filter top-level SKILL.md keys
loop For each top-level skill
Runtime->>AssetStore: getItemRaw('skill:SKILL.md')
AssetStore-->>Runtime: Raw file content
Runtime->>Runtime: Parse YAML frontmatter, validate name
alt Invalid (missing description, bad name)
Runtime->>Runtime: Log warning, skip entry
else Valid
Runtime->>Runtime: Add to catalog { name, description, path }
end
end
Runtime-->>Client: 200 JSON { skills: { catalog: [...] } }
Note over Runtime: Cache-Control: public, max-age=3600
Client->>Runtime: GET /.well-known/skills/<name>/<file>
Runtime->>Runtime: Extract path from URL
alt Path traversal detected (.., ., dotfiles) or empty/index.json
Runtime-->>Client: 400 Bad Request
else Valid path
Runtime->>AssetStore: getItemRaw('<name>:<file>')
alt File not found (null/undefined)
AssetStore-->>Runtime: null
Runtime-->>Client: 404 Not Found
else File found
AssetStore-->>Runtime: Raw bytes or string
Runtime->>Runtime: Convert to string, detect content-type
Runtime-->>Client: 200 with file content + content-type header
Note over Runtime: Cache-Control: public, max-age=3600
end
end
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
- 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).
|
* feat(layer): add Vercel markdown-rewrite module
Adds a Nuxt module that injects Vercel build-output rewrite rules so
that AI agents (anything sending `Accept: text/markdown` or
`User-Agent: curl/*`) get served raw markdown instead of the SPA shell.
Behaviour:
- No-op on every non-Vercel preset (matches `vercel`, `vercel-edge`,
`vercel-static`, etc. via `preset.startsWith('vercel')`).
- On Vercel: read `<output.publicDir>/../config.json`, validate that
`llms.txt` was emitted, and unshift route pairs onto `routes` so
they fire before the SPA fallback.
- Rules:
- `/` -> `/llms.txt`
- `/<locale>` -> `/llms.txt` (per `runtimeConfig.public.i18n.locales`)
- `/<page>` -> `/raw<page>.md` for every `/raw/...md` link found
in `llms.txt`
- Vercel's `has` array is AND-ed, so OR semantics between the
`Accept` and `User-Agent` matchers require emitting two rules per
`src` -> `dest` pair.
- Locale codes are regex-escaped before being joined into the
alternation so exotic codes can't break the pattern.
Ports upstream docus commits `6fd8686b` and `9ceafe6f` -- see
`docs/docus-upstream-changes.md` item #9.
Verification:
- `bun lint` -> clean
- `bun typecheck` -> no new errors in `markdown-rewrite.ts`
- `NITRO_PRESET=vercel bun --filter @pleaseai/docs-site build`
injects two routes (homepage / Accept + User-Agent) into
`.vercel/output/config.json`.
- Default (cloudflare) build is unchanged; module bails silently.
* fix(layer): apply review suggestions
Apply review feedback from cubic-dev-ai and gemini-code-assist on
the markdown-rewrite module:
- Read locale config from `nuxt.options.i18n` instead of
`runtimeConfig.public.i18n` (matching the pattern used by the
`nitro:config` hook in nuxt.config.ts). The i18n module does
not always populate `runtimeConfig.public.i18n`, so the previous
source could silently miss locale routes. (cubic)
- Decode the URL pathname with `decodeURIComponent` so that paths
with URL-encoded characters (e.g. `%20`) match Vercel's router,
which compares against decoded request pathnames. (gemini)
- Allow an optional trailing slash on docs page route patterns
(`/?$` instead of `$`) so requests like
`/en/getting-started/installation/` are matched consistently
with the per-locale homepage routes. (cubic, gemini)
* Merge branch 'main' into worktree-agent-a1a3a89e87faf9e6d
Resolve modules-array conflict in packages/layer/nuxt.config.ts by
keeping both new module registrations from #29/#30/#31 and this PR's
markdown-rewrite module.



Summary
Ports the agent skills discovery module from upstream docus (survey item #7 in
docs/docus-upstream-changes.md). Adds a Nitro module that scans<rootDir>/skills/for Anthropic Agent Skills and exposes them under.well-known/skills/.Continues the porting work started in #27.
Upstream commits
2f7861bdfeat(skills): add agent skills discovery via.well-known764329f5feat(skills): make directory configurable via module optionsWhat's new
packages/layer/modules/skills/index.tsA
defineNuxtModulewith options{ dir?: string }(default'skills', configurable viaskills.dir). At setup time the module:<rootDir>/<dir>and bails out if it doesn't exist.SKILL.mdfile with YAML frontmatter{ name, description }./^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/, max 64 chars, no--, must match the directory name. Invalid entries are skipped with a warning.assets:skills) so files are accessible at runtime via storage even when the source tree is unavailable (serverless, etc.).nitro.prerender.routesfor static builds.addServerHandler.runtime/server/routes/.well-known/skills/index.get.tsScans the asset storage for top-level
SKILL.mdfiles, re-parses frontmatter, and returns:{ "skills": { "catalog": [ { "name": "...", "description": "...", "path": "/.well-known/skills/.../SKILL.md" } ] } }Sets
Cache-Control: public, max-age=3600.runtime/server/routes/.well-known/skills/[...path].get.tsServes any file under a skill folder with content-type detection (
.md→text/markdown,.json→application/json,.yaml/.yml→application/yaml, code files →text/plain, etc.). Blocks path traversal (..,.) and dotfile access. 404s when the file doesn't exist. Cache 1h.Both routes use
getItemRawto avoiddestrauto-parsing JSON payloads.Configuration changes
packages/layer/nuxt.config.ts— registers the new module after./modules/shadcn.packages/layer/package.json— addsyamlas a direct dependency (matches docus' approach and keeps the module independent of@nuxt/content).Verification
Local e2e against
apps/docs:Both endpoints return the expected payloads, including correct content-type headers and 404 for unknown paths. Tested
.jsonfiles round-trip correctly (initial draft usedgetItemwhich would have JSON-parsed them; switched togetItemRaw).bun lint— clean.cd packages/layer && bun typecheck— no new errors (pre-existing baseline errors unchanged).The sample skill was removed before committing.
References
docs/docus-upstream-changes.mditem fix(layer): add proper code block styling with Tailwind import #7Summary by cubic
Adds a Nuxt module that discovers Anthropic Agent Skills in
skills/and exposes them under/.well-known/skillsfor discovery and static builds. The index is served from a prebuilt catalog for faster responses.New Features
<rootDir>/<dir>(defaultskills) forSKILL.mdwith YAML frontmatter; requiresnameanddescription(200-char cap), validates names per the Anthropic spec, and skips invalid entries with warnings./.well-known/skills/index.jsonfrom runtime config with{ name, description, path }(cached 1h)./.well-known/skills/<name>/<file>serves any skill file with content-type detection, traversal/dotfile protection, and binary-safe responses (cached 1h).serverAssetsand prerenders all skill file routes for static builds.skills.dirinnuxt.config.Dependencies
yaml.Written for commit 207f398. Summary will update on new commits.