From eb35724869ba9df93ff198be7acf5edf19d18014 Mon Sep 17 00:00:00 2001 From: "mathieu.legault" Date: Tue, 15 Sep 2026 17:08:16 +0000 Subject: [PATCH 1/3] feat(cli): publish agent skills from a declared docs.yml path (page-actions.options.skills.path) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- docs-yml.schema.json | 13 +- fern/apis/docs-yml/definition/docs.yml | 15 +- .../unreleased/feat-skills-declared-path.yml | 13 + .../__test__/skillsPageAction.test.ts | 32 ++ .../src/docs-yml/parseDocsConfiguration.ts | 3 +- .../src/docs-yml/ParsedDocsConfiguration.ts | 7 + .../docs/types/SkillsPageActionConfig.ts | 15 +- .../docs/types/SkillsPageActionConfig.ts | 2 + .../src/DocsDefinitionResolver.ts | 161 +++++++- packages/cli/docs-resolver/src/index.ts | 10 + .../collectAgentSkillsUploads.test.ts | 140 +++++++ .../collectWellKnownSkillsFiles.test.ts | 2 +- .../src/utils/__test__/declaredSkills.test.ts | 248 ++++++++++++ .../__test__/skills-page-action.test.ts | 5 +- .../src/utils/collectWellKnownSkillsFiles.ts | 18 +- .../docs-resolver/src/utils/declaredSkills.ts | 383 ++++++++++++++++++ .../workspace/loader/src/docs-yml.schema.json | 13 +- .../yaml/docs-validator/src/getAllRules.ts | 2 +- .../valid-skills-page-action.ts | 7 + .../__test__/valid-well-known-skills.test.ts | 126 +++++- .../valid-well-known-skills.ts | 153 ++++--- .../cli/yaml/docs-validator/vitest.config.ts | 17 +- 22 files changed, 1284 insertions(+), 101 deletions(-) create mode 100644 packages/cli/cli/changes/unreleased/feat-skills-declared-path.yml create mode 100644 packages/cli/docs-resolver/src/utils/__test__/collectAgentSkillsUploads.test.ts rename packages/cli/docs-resolver/src/{ => utils}/__test__/collectWellKnownSkillsFiles.test.ts (95%) create mode 100644 packages/cli/docs-resolver/src/utils/__test__/declaredSkills.test.ts rename packages/cli/docs-resolver/src/{ => utils}/__test__/skills-page-action.test.ts (96%) create mode 100644 packages/cli/docs-resolver/src/utils/declaredSkills.ts diff --git a/docs-yml.schema.json b/docs-yml.schema.json index 58fe19d42696..dcabf5f84209 100644 --- a/docs-yml.schema.json +++ b/docs-yml.schema.json @@ -4387,6 +4387,17 @@ "docs.SkillsPageActionConfig": { "type": "object", "properties": { + "path": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Path to a directory of agent skills in this repo, resolved relative to the folder\ncontaining this docs.yml (`../` is allowed, e.g. a repo-root `.agents/skills/`\nfolder). Every subdirectory containing a `SKILL.md` is published as one skill: the CLI\nvalidates the bundle, generates the `/.well-known/skills/index.json` discovery\nmanifest, and uploads every file at `.well-known/skills//…` so that\n`npx skills add https://` works. Nothing is written back to the repo." + }, "title": { "oneOf": [ { @@ -4458,7 +4469,7 @@ } }, "additionalProperties": false, - "description": "Configures the \"Install skills\" page action and its modal. Sites that ship agent skills\npoint at where those skills live and how to install them. If the docs site also serves a\n`/.well-known` skills manifest, the modal replaces the hand-listed `skills` array with the\nserved manifest; `title`, `description`, `learn-more-url`, `repository`, and\n`install-command` always come from this config." + "description": "Configures the \"Install skills\" page action and its modal. Sites that ship agent skills\npoint at where those skills live (`path`) and how to install them. If the docs site also\nserves a `/.well-known` skills manifest, the modal replaces the hand-listed `skills` array\nwith the served manifest; `title`, `description`, `learn-more-url`, `repository`, and\n`install-command` always come from this config." }, "docs.PageActionOptions": { "type": "object", diff --git a/fern/apis/docs-yml/definition/docs.yml b/fern/apis/docs-yml/definition/docs.yml index b9064ebea4ab..044ac83f10d8 100644 --- a/fern/apis/docs-yml/definition/docs.yml +++ b/fern/apis/docs-yml/definition/docs.yml @@ -505,11 +505,20 @@ types: SkillsPageActionConfig: docs: | Configures the "Install skills" page action and its modal. Sites that ship agent skills - point at where those skills live and how to install them. If the docs site also serves a - `/.well-known` skills manifest, the modal replaces the hand-listed `skills` array with the - served manifest; `title`, `description`, `learn-more-url`, `repository`, and + point at where those skills live (`path`) and how to install them. If the docs site also + serves a `/.well-known` skills manifest, the modal replaces the hand-listed `skills` array + with the served manifest; `title`, `description`, `learn-more-url`, `repository`, and `install-command` always come from this config. properties: + path: + type: optional + docs: | + Path to a directory of agent skills in this repo, resolved relative to the folder + containing this docs.yml (`../` is allowed, e.g. a repo-root `.agents/skills/` + folder). Every subdirectory containing a `SKILL.md` is published as one skill: the CLI + validates the bundle, generates the `/.well-known/skills/index.json` discovery + manifest, and uploads every file at `.well-known/skills//…` so that + `npx skills add https://` works. Nothing is written back to the repo. title: type: optional docs: Overrides the modal title. diff --git a/packages/cli/cli/changes/unreleased/feat-skills-declared-path.yml b/packages/cli/cli/changes/unreleased/feat-skills-declared-path.yml new file mode 100644 index 000000000000..4cbb6ccf0778 --- /dev/null +++ b/packages/cli/cli/changes/unreleased/feat-skills-declared-path.yml @@ -0,0 +1,13 @@ +- summary: | + Agent skills can now be declared in docs.yml via `page-actions.options.skills.path`, + pointing at a directory of skills in the docs repo (resolved relative to the folder + containing docs.yml; `../` is allowed). At `fern generate --docs`, every subdirectory + containing a SKILL.md is published as one skill: the CLI validates the bundle + (kebab-case names matching directories, non-empty descriptions, unique names, no + references escaping a skill's directory), generates the `.well-known/skills/index.json` + discovery manifest, and uploads every file at `.well-known/skills//…` so that + `npx skills add https://` works — without writing anything back to the + repo. A declared path wins over a hand-populated `fern/.well-known/skills/` folder + (warned on conflict); the raw well-known passthrough still applies when no path is + declared. + type: feat diff --git a/packages/cli/configuration-loader/src/docs-yml/__test__/skillsPageAction.test.ts b/packages/cli/configuration-loader/src/docs-yml/__test__/skillsPageAction.test.ts index b4bdc604e1c8..51857f828743 100644 --- a/packages/cli/configuration-loader/src/docs-yml/__test__/skillsPageAction.test.ts +++ b/packages/cli/configuration-loader/src/docs-yml/__test__/skillsPageAction.test.ts @@ -105,6 +105,38 @@ describe("parseDocsConfiguration — page-actions.options.skills", () => { ]); }); + it("resolves `path` against the docs.yml directory and keeps it off the FDR-bound config", async () => { + const parsed = await parseRawDocsYml({ + instances: [], + navigation: [], + "page-actions": { options: { skills: { path: "./agent-skills" } } } + }); + + expect(parsed.pageActions?.options.skillsDirectory).toEqual("/fern/agent-skills"); + // `path` is CLI-only: the docs site reads the served manifest, not the repo path + expect(parsed.pageActions?.options.skills).toEqual({}); + }); + + it("resolves a `../` path (e.g. a repo-root .agents/skills folder shared with coding agents)", async () => { + const parsed = await parseRawDocsYml({ + instances: [], + navigation: [], + "page-actions": { options: { skills: { path: "../.agents/skills" } } } + }); + + expect(parsed.pageActions?.options.skillsDirectory).toEqual("/.agents/skills"); + }); + + it("leaves skillsDirectory undefined when no path is declared", async () => { + const parsed = await parseRawDocsYml({ + instances: [], + navigation: [], + "page-actions": { options: { skills: {} } } + }); + + expect(parsed.pageActions?.options.skillsDirectory).toBeUndefined(); + }); + it("rejects a skill entry without a name", async () => { await expect( parseRawDocsYml({ diff --git a/packages/cli/configuration-loader/src/docs-yml/parseDocsConfiguration.ts b/packages/cli/configuration-loader/src/docs-yml/parseDocsConfiguration.ts index 7b662f8379aa..fb8f51f4d2fe 100644 --- a/packages/cli/configuration-loader/src/docs-yml/parseDocsConfiguration.ts +++ b/packages/cli/configuration-loader/src/docs-yml/parseDocsConfiguration.ts @@ -452,7 +452,8 @@ function convertPageActions( custom: (pageActions.options?.custom ?? []).map((action) => convertCustomPageAction(action, absoluteFilepathToDocsConfig) ), - skills: convertSkillsPageAction(pageActions.options?.skills) + skills: convertSkillsPageAction(pageActions.options?.skills), + skillsDirectory: resolveFilepath(pageActions.options?.skills?.path, absoluteFilepathToDocsConfig) } }; } diff --git a/packages/cli/configuration/src/docs-yml/ParsedDocsConfiguration.ts b/packages/cli/configuration/src/docs-yml/ParsedDocsConfiguration.ts index 6e7c0974707e..1c197cc9d824 100644 --- a/packages/cli/configuration/src/docs-yml/ParsedDocsConfiguration.ts +++ b/packages/cli/configuration/src/docs-yml/ParsedDocsConfiguration.ts @@ -39,6 +39,13 @@ export interface ParsedPageActionsConfig { mcp: boolean; custom: ParsedCustomPageAction[]; skills: CjsFdrSdk.docs.v1.commons.PageActionOptions["skills"]; + /** + * Resolved `skills.path` — a same-repo directory of agent skills. At publish time the + * CLI discovers every subdirectory containing a SKILL.md, generates the index.json + * discovery manifest, and uploads the bundle at `.well-known/skills/…`. CLI-only: + * never sent to FDR (the docs site reads the served manifest, not this path). + */ + skillsDirectory: AbsoluteFilePath | undefined; }; } diff --git a/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/SkillsPageActionConfig.ts b/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/SkillsPageActionConfig.ts index e609881f1b7f..c91260722f9b 100644 --- a/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/SkillsPageActionConfig.ts +++ b/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/SkillsPageActionConfig.ts @@ -4,12 +4,21 @@ import type * as FernDocsConfig from "../../../index.js"; /** * Configures the "Install skills" page action and its modal. Sites that ship agent skills - * point at where those skills live and how to install them. If the docs site also serves a - * `/.well-known` skills manifest, the modal replaces the hand-listed `skills` array with the - * served manifest; `title`, `description`, `learn-more-url`, `repository`, and + * point at where those skills live (`path`) and how to install them. If the docs site also + * serves a `/.well-known` skills manifest, the modal replaces the hand-listed `skills` array + * with the served manifest; `title`, `description`, `learn-more-url`, `repository`, and * `install-command` always come from this config. */ export interface SkillsPageActionConfig { + /** + * Path to a directory of agent skills in this repo, resolved relative to the folder + * containing this docs.yml (`../` is allowed, e.g. a repo-root `.agents/skills/` + * folder). Every subdirectory containing a `SKILL.md` is published as one skill: the CLI + * validates the bundle, generates the `/.well-known/skills/index.json` discovery + * manifest, and uploads every file at `.well-known/skills//…` so that + * `npx skills add https://` works. Nothing is written back to the repo. + */ + path?: string; /** Overrides the modal title. */ title?: string; /** Overrides the modal description/blurb. */ diff --git a/packages/cli/configuration/src/docs-yml/schemas/sdk/serialization/resources/docs/types/SkillsPageActionConfig.ts b/packages/cli/configuration/src/docs-yml/schemas/sdk/serialization/resources/docs/types/SkillsPageActionConfig.ts index a74333db500b..04489cf7deb0 100644 --- a/packages/cli/configuration/src/docs-yml/schemas/sdk/serialization/resources/docs/types/SkillsPageActionConfig.ts +++ b/packages/cli/configuration/src/docs-yml/schemas/sdk/serialization/resources/docs/types/SkillsPageActionConfig.ts @@ -10,6 +10,7 @@ export const SkillsPageActionConfig: core.serialization.ObjectSchema< serializers.SkillsPageActionConfig.Raw, FernDocsConfig.SkillsPageActionConfig > = core.serialization.object({ + path: core.serialization.string().optional(), title: core.serialization.string().optional(), description: core.serialization.string().optional(), learnMoreUrl: core.serialization.property("learn-more-url", core.serialization.string().optional()), @@ -20,6 +21,7 @@ export const SkillsPageActionConfig: core.serialization.ObjectSchema< export declare namespace SkillsPageActionConfig { export interface Raw { + path?: string | null; title?: string | null; description?: string | null; "learn-more-url"?: string | null; diff --git a/packages/cli/docs-resolver/src/DocsDefinitionResolver.ts b/packages/cli/docs-resolver/src/DocsDefinitionResolver.ts index 6feb7d981098..b39e6b30a86c 100644 --- a/packages/cli/docs-resolver/src/DocsDefinitionResolver.ts +++ b/packages/cli/docs-resolver/src/DocsDefinitionResolver.ts @@ -34,10 +34,12 @@ import { AbstractAPIWorkspace, DocsWorkspace, FernWorkspace } from "@fern-api/wo import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; import { existsSync } from "fs"; -import { readFile } from "fs/promises"; +import { mkdtemp, readFile, writeFile } from "fs/promises"; import matter from "gray-matter"; import jsYaml from "js-yaml"; import { camelCase, kebabCase } from "lodash-es"; +import { tmpdir } from "os"; +import path from "path"; /** * Shape of navigation nodes in `_navigation.yml`, generated by `library-docs-generator`. @@ -80,10 +82,19 @@ import { ChangelogNodeConverter } from "./ChangelogNodeConverter.js"; import { NodeIdGenerator } from "./NodeIdGenerator.js"; import { maybeBundleMdxComponent } from "./utils/bundleMdxComponent.js"; import { collectMdxComponentFiles } from "./utils/collectMdxComponentFiles.js"; -import { collectWellKnownSkillsFiles } from "./utils/collectWellKnownSkillsFiles.js"; +import { + collectWellKnownSkillsFiles, + WELL_KNOWN_AGENT_SKILLS_DIRECTORY, + WELL_KNOWN_SKILLS_DIRECTORY +} from "./utils/collectWellKnownSkillsFiles.js"; import { convertDocsAvailability } from "./utils/convertDocsAvailability.js"; import { convertDocsSnippetsConfigToFdr } from "./utils/convertDocsSnippetsConfigToFdr.js"; import { convertIrToApiDefinition } from "./utils/convertIrToApiDefinition.js"; +import { + DECLARED_SKILLS_UPLOAD_DIRECTORY, + discoverDeclaredSkills, + generateSkillsIndexManifest +} from "./utils/declaredSkills.js"; import { collectFilesFromDocsConfig } from "./utils/getImageFilepathsToUpload.js"; import { resolveLinksInObject, updateApiDefinitionIdInTree } from "./utils/resolveDescriptionLinks.js"; import { visitNavigationAst } from "./visitNavigationAst.js"; @@ -193,6 +204,14 @@ export class DocsDefinitionResolver { private taskContext: TaskContext; private editThisPage?: docsYml.RawSchemas.EditThisPageConfig; private uploadFiles: UploadFilesFn; + /** + * True only when a real `uploadFiles` was supplied (publish / preview) — i.e. when the + * resolved files are actually served. Validation rules (valid-markdown-links, + * missing-redirects) construct the resolver with no `uploadFiles` purely to read the + * navigation tree; in that mode we skip Agent Skills collection so its validation errors + * don't surface as a misattributed failure of an unrelated rule. + */ + private readonly uploadsEnabled: boolean; private registerApi: RegisterApiFn; private targetAudiences?: string[]; private buildTranslatedApiDefinitions: boolean; @@ -216,7 +235,7 @@ export class DocsDefinitionResolver { apiWorkspaces, taskContext, editThisPage, - uploadFiles = defaultUploadFiles, + uploadFiles, registerApi = defaultRegisterApi, targetAudiences, buildTranslatedApiDefinitions = false, @@ -230,7 +249,8 @@ export class DocsDefinitionResolver { this.apiWorkspaces = apiWorkspaces; this.taskContext = taskContext; this.editThisPage = editThisPage; - this.uploadFiles = uploadFiles; + this.uploadsEnabled = uploadFiles != null; + this.uploadFiles = uploadFiles ?? defaultUploadFiles; this.registerApi = registerApi; this.targetAudiences = targetAudiences; this.buildTranslatedApiDefinitions = buildTranslatedApiDefinitions; @@ -587,14 +607,15 @@ export class DocsDefinitionResolver { const collectTime = performance.now() - collectStart; this.taskContext.logger.debug(`Collected ${filesToUploadSet.size} files in ${collectTime.toFixed(0)}ms`); - // upload author-supplied Agent Skills bundles so the docs site can serve them - // at /.well-known/skills/… and /.well-known/agent-skills/… - const wellKnownSkillsFiles = await collectWellKnownSkillsFiles({ - absolutePathToFernFolder: this.docsWorkspace.absoluteFilePath - }); - if (wellKnownSkillsFiles.length > 0) { - this.taskContext.logger.debug(`Collected ${wellKnownSkillsFiles.length} well-known agent skills files`); - wellKnownSkillsFiles.forEach((filepath) => filesToUploadSet.add(filepath)); + // collect Agent Skills uploads — generated from the docs.yml-declared skills path, + // or the raw .well-known passthrough when no path is declared — so the docs site + // serves them at /.well-known/skills/… and /.well-known/agent-skills/…. Skipped when + // files aren't being served (validation resolves): skills are validated by the + // valid-well-known-skills rule, and collecting here would let its errors surface as a + // failure of whichever rule triggered this resolve. + const agentSkillsUploads: FilePathPair[] = this.uploadsEnabled ? await this.collectAgentSkillsUploads() : []; + if (agentSkillsUploads.length > 0) { + this.taskContext.logger.debug(`Collected ${agentSkillsUploads.length} agent skills files`); } // preprocess markdown files to extract image paths @@ -629,12 +650,17 @@ export class DocsDefinitionResolver { const imageParseTime = performance.now() - imageParseStart; this.taskContext.logger.debug(`Parsed image paths in ${imageParseTime.toFixed(0)}ms`); - const filesToUpload: FilePathPair[] = Array.from(filesToUploadSet).map( - (absoluteFilePath): FilePathPair => ({ - absoluteFilePath, - relativeFilePath: this.toRelativeFilepath(absoluteFilePath) - }) - ); + // agent skills first: if a skill file is also referenced by docs content (and thus in + // filesToUploadSet), the content upload comes later and wins in collectedFileIds + const filesToUpload: FilePathPair[] = [ + ...agentSkillsUploads, + ...Array.from(filesToUploadSet).map( + (absoluteFilePath): FilePathPair => ({ + absoluteFilePath, + relativeFilePath: this.toRelativeFilepath(absoluteFilePath) + }) + ) + ]; this.taskContext.logger.info(`Uploading ${filesToUpload.length} files...`); const uploadStart = performance.now(); @@ -837,6 +863,105 @@ export class DocsDefinitionResolver { return resolve(this.docsWorkspace.absoluteFilePath, unresolvedFilepath); } + /** + * Collects the Agent Skills files to publish so the docs site serves them at + * `/.well-known/skills/…`, making `npx skills add https://` work. + * + * When docs.yml declares `page-actions.options.skills.path`, every subdirectory of that + * path containing a SKILL.md is one skill. The CLI validates the bundle, generates the + * `index.json` discovery manifest (written to a temp directory — nothing is written back + * to the repo), and re-homes every file in the publish payload at + * `.well-known/skills//`, regardless of where it lives in the repo. + * The declared path wins over a hand-populated `fern/.well-known/skills/` folder, which is + * skipped with a warning; the `.well-known/agent-skills/` passthrough never conflicts with + * the generated bundle and is uploaded either way. + * + * Without a declared path, both raw `.well-known` folders are uploaded verbatim + * (legacy passthrough, kept for back-compat). + */ + private async collectAgentSkillsUploads(): Promise { + const skillsDirectory = this.parsedDocsConfig.pageActions?.options.skillsDirectory; + if (skillsDirectory == null) { + const passthroughFiles = await collectWellKnownSkillsFiles({ + absolutePathToFernFolder: this.docsWorkspace.absoluteFilePath + }); + return passthroughFiles.map( + (absoluteFilePath): FilePathPair => ({ + absoluteFilePath, + relativeFilePath: this.toRelativeFilepath(absoluteFilePath) + }) + ); + } + + const { skills, violations } = await discoverDeclaredSkills({ + absolutePathToSkillsDirectory: skillsDirectory + }); + for (const warning of violations.filter((violation) => violation.severity === "warning")) { + this.taskContext.logger.warn(warning.message); + } + const errors = violations.filter((violation) => violation.severity === "error"); + if (errors.length > 0) { + throw new CliError({ + message: [ + `Invalid agent skills under ${skillsDirectory} (declared via page-actions.options.skills.path):`, + ...errors.map((violation) => + violation.absoluteFilePath != null + ? ` ${this.toRelativeFilepath(violation.absoluteFilePath)}: ${violation.message}` + : ` ${violation.message}` + ) + ].join("\n"), + code: CliError.Code.ValidationError + }); + } + + if (existsSync(join(this.docsWorkspace.absoluteFilePath, WELL_KNOWN_SKILLS_DIRECTORY))) { + this.taskContext.logger.warn( + `Ignoring ${WELL_KNOWN_SKILLS_DIRECTORY}/ because page-actions.options.skills.path is declared in docs.yml — ` + + `the skills bundle is generated from ${skillsDirectory} instead.` + ); + } + + const uploads: FilePathPair[] = []; + + const temporaryDirectory = await mkdtemp(path.join(tmpdir(), "fern-agent-skills-")); + const indexJsonAbsoluteFilePath = AbsoluteFilePath.of(path.join(temporaryDirectory, "index.json")); + await writeFile(indexJsonAbsoluteFilePath, generateSkillsIndexManifest(skills)); + uploads.push({ + absoluteFilePath: indexJsonAbsoluteFilePath, + relativeFilePath: RelativeFilePath.of(`${DECLARED_SKILLS_UPLOAD_DIRECTORY}/index.json`) + }); + + for (const skill of skills) { + for (const file of skill.files) { + uploads.push({ + absoluteFilePath: file.absoluteFilePath, + relativeFilePath: RelativeFilePath.of( + `${DECLARED_SKILLS_UPLOAD_DIRECTORY}/${skill.name}/${file.relativeFilePathInSkill}` + ) + }); + } + } + + this.taskContext.logger.debug( + `Publishing ${skills.length} agent skill(s) from ${skillsDirectory} at /${DECLARED_SKILLS_UPLOAD_DIRECTORY}/` + ); + + const agentSkillsPassthroughFiles = await collectWellKnownSkillsFiles({ + absolutePathToFernFolder: this.docsWorkspace.absoluteFilePath, + directories: [WELL_KNOWN_AGENT_SKILLS_DIRECTORY] + }); + uploads.push( + ...agentSkillsPassthroughFiles.map( + (absoluteFilePath): FilePathPair => ({ + absoluteFilePath, + relativeFilePath: this.toRelativeFilepath(absoluteFilePath) + }) + ) + ); + + return uploads; + } + private toRelativeFilepath(filepath: AbsoluteFilePath): RelativeFilePath; private toRelativeFilepath(filepath: AbsoluteFilePath | undefined): RelativeFilePath | undefined; private toRelativeFilepath(filepath: AbsoluteFilePath | undefined): RelativeFilePath | undefined { diff --git a/packages/cli/docs-resolver/src/index.ts b/packages/cli/docs-resolver/src/index.ts index 47a339fe1bbe..9932b825aab4 100644 --- a/packages/cli/docs-resolver/src/index.ts +++ b/packages/cli/docs-resolver/src/index.ts @@ -27,6 +27,16 @@ export { } from "./DocsDefinitionResolver.js"; export { stitchGlobalTheme } from "./stitchGlobalTheme.js"; export { convertIrToApiDefinition } from "./utils/convertIrToApiDefinition.js"; +export { + DECLARED_SKILLS_UPLOAD_DIRECTORY, + type DeclaredSkill, + type DeclaredSkillFile, + type DeclaredSkillsViolation, + type DiscoveredDeclaredSkills, + discoverDeclaredSkills, + generateSkillsIndexManifest, + validateSkillFrontmatter +} from "./utils/declaredSkills.js"; export { filterOssWorkspaces } from "./utils/filterOssWorkspaces.js"; export { generateFdrFromOpenApiWorkspaceV3 } from "./utils/generateFdrFromOpenAPIWorkspaceV3.js"; export { updateApiDefinitionIdInTree } from "./utils/resolveDescriptionLinks.js"; diff --git a/packages/cli/docs-resolver/src/utils/__test__/collectAgentSkillsUploads.test.ts b/packages/cli/docs-resolver/src/utils/__test__/collectAgentSkillsUploads.test.ts new file mode 100644 index 000000000000..45e09b7b8a06 --- /dev/null +++ b/packages/cli/docs-resolver/src/utils/__test__/collectAgentSkillsUploads.test.ts @@ -0,0 +1,140 @@ +import { docsYml } from "@fern-api/configuration"; +import { AbsoluteFilePath } from "@fern-api/fs-utils"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "fs/promises"; +import { tmpdir } from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { DocsDefinitionResolver, FilePathPair } from "../../DocsDefinitionResolver.js"; + +describe("DocsDefinitionResolver collectAgentSkillsUploads", () => { + let fernFolder: AbsoluteFilePath; + let skillsFolder: AbsoluteFilePath; + let warn: ReturnType; + + beforeEach(async () => { + fernFolder = AbsoluteFilePath.of(await mkdtemp(path.join(tmpdir(), "fern-folder-"))); + skillsFolder = AbsoluteFilePath.of(await mkdtemp(path.join(tmpdir(), "fern-skills-"))); + warn = vi.fn(); + }); + + afterEach(async () => { + await rm(fernFolder, { recursive: true, force: true }); + await rm(skillsFolder, { recursive: true, force: true }); + }); + + async function writeFileAt(root: AbsoluteFilePath, relativePath: string, contents = "contents"): Promise { + const absolutePath = path.join(root, relativePath); + await mkdir(path.dirname(absolutePath), { recursive: true }); + await writeFile(absolutePath, contents); + } + + function collectUploadsFor(skillsDirectory: AbsoluteFilePath | undefined): Promise { + const pageActions: docsYml.ParsedPageActionsConfig = { + default: undefined, + options: { + askAi: true, + copyPage: true, + viewAsMarkdown: true, + openAi: true, + claude: true, + cursor: true, + claudeCode: true, + vscode: false, + mcp: true, + custom: [], + skills: {}, + skillsDirectory + } + }; + + const resolver = Object.create(DocsDefinitionResolver.prototype) as DocsDefinitionResolver; + Reflect.set(resolver, "_parsedDocsConfig", { pageActions }); + Reflect.set(resolver, "docsWorkspace", { absoluteFilePath: fernFolder }); + Reflect.set(resolver, "taskContext", { logger: { warn, debug: vi.fn() } }); + + const collectAgentSkillsUploads = Reflect.get(resolver, "collectAgentSkillsUploads") as () => Promise< + FilePathPair[] + >; + return collectAgentSkillsUploads.call(resolver); + } + + function skillMarkdown(name: string): string { + return `---\nname: ${name}\ndescription: Does ${name} things.\n---\n`; + } + + it("re-homes declared skills at .well-known/skills//… and generates index.json", async () => { + await writeFileAt(skillsFolder, "best-practices/SKILL.md", skillMarkdown("best-practices")); + await writeFileAt(skillsFolder, "best-practices/references/api.md", "# API\n"); + await writeFileAt(skillsFolder, "upgrade-guide/SKILL.md", skillMarkdown("upgrade-guide")); + + const uploads = await collectUploadsFor(skillsFolder); + + expect(uploads.map((upload) => String(upload.relativeFilePath))).toEqual([ + ".well-known/skills/index.json", + ".well-known/skills/best-practices/SKILL.md", + ".well-known/skills/best-practices/references/api.md", + ".well-known/skills/upgrade-guide/SKILL.md" + ]); + + // the generated manifest lives in a temp directory — nothing is written back to the repo + const indexJson = uploads[0]; + expect(indexJson?.absoluteFilePath.startsWith(fernFolder)).toBe(false); + expect(indexJson?.absoluteFilePath.startsWith(skillsFolder)).toBe(false); + expect(JSON.parse(await readFile(indexJson?.absoluteFilePath ?? "", "utf-8"))).toEqual({ + skills: [ + { + name: "best-practices", + description: "Does best-practices things.", + files: ["SKILL.md", "references/api.md"] + }, + { name: "upgrade-guide", description: "Does upgrade-guide things.", files: ["SKILL.md"] } + ] + }); + + // skill files upload from their original location in the repo + expect(uploads[1]?.absoluteFilePath).toEqual(path.join(skillsFolder, "best-practices/SKILL.md")); + }); + + it("warns and skips a hand-populated .well-known/skills folder when a path is declared", async () => { + await writeFileAt(skillsFolder, "my-skill/SKILL.md", skillMarkdown("my-skill")); + await writeFileAt(fernFolder, ".well-known/skills/index.json", JSON.stringify({ skills: [] })); + await writeFileAt(fernFolder, ".well-known/skills/old-skill/SKILL.md", skillMarkdown("old-skill")); + await writeFileAt(fernFolder, ".well-known/agent-skills/index.json", JSON.stringify({ skills: [] })); + + const uploads = await collectUploadsFor(skillsFolder); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining("Ignoring .well-known/skills/")); + expect(uploads.map((upload) => String(upload.relativeFilePath))).toEqual([ + ".well-known/skills/index.json", + ".well-known/skills/my-skill/SKILL.md", + // the agent-skills passthrough never conflicts with the generated bundle + ".well-known/agent-skills/index.json" + ]); + // the served index.json is the generated manifest, not the hand-populated one + expect(uploads[0]?.absoluteFilePath.startsWith(fernFolder)).toBe(false); + }); + + it("fails the publish when the declared skills are invalid", async () => { + await writeFileAt(skillsFolder, "my-skill/SKILL.md", skillMarkdown("other-name")); + + await expect(collectUploadsFor(skillsFolder)).rejects.toThrow( + /Invalid agent skills under .*must match its parent directory/s + ); + }); + + it("uploads raw .well-known folders verbatim when no path is declared (legacy passthrough)", async () => { + await writeFileAt(fernFolder, ".well-known/skills/index.json", JSON.stringify({ skills: [] })); + await writeFileAt(fernFolder, ".well-known/skills/my-skill/SKILL.md", skillMarkdown("my-skill")); + await writeFileAt(fernFolder, ".well-known/agent-skills/index.json", JSON.stringify({ skills: [] })); + + const uploads = await collectUploadsFor(undefined); + + expect(uploads.map((upload) => String(upload.relativeFilePath)).sort()).toEqual([ + ".well-known/agent-skills/index.json", + ".well-known/skills/index.json", + ".well-known/skills/my-skill/SKILL.md" + ]); + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/docs-resolver/src/__test__/collectWellKnownSkillsFiles.test.ts b/packages/cli/docs-resolver/src/utils/__test__/collectWellKnownSkillsFiles.test.ts similarity index 95% rename from packages/cli/docs-resolver/src/__test__/collectWellKnownSkillsFiles.test.ts rename to packages/cli/docs-resolver/src/utils/__test__/collectWellKnownSkillsFiles.test.ts index 27e31cc6f78c..6b6ebaa4f3d1 100644 --- a/packages/cli/docs-resolver/src/__test__/collectWellKnownSkillsFiles.test.ts +++ b/packages/cli/docs-resolver/src/utils/__test__/collectWellKnownSkillsFiles.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "os"; import path from "path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { collectWellKnownSkillsFiles } from "../utils/collectWellKnownSkillsFiles.js"; +import { collectWellKnownSkillsFiles } from "../collectWellKnownSkillsFiles.js"; describe("collectWellKnownSkillsFiles", () => { let fernFolder: AbsoluteFilePath; diff --git a/packages/cli/docs-resolver/src/utils/__test__/declaredSkills.test.ts b/packages/cli/docs-resolver/src/utils/__test__/declaredSkills.test.ts new file mode 100644 index 000000000000..308cc915326d --- /dev/null +++ b/packages/cli/docs-resolver/src/utils/__test__/declaredSkills.test.ts @@ -0,0 +1,248 @@ +import { AbsoluteFilePath, relative } from "@fern-api/fs-utils"; +import { mkdir, mkdtemp, rm, writeFile } from "fs/promises"; +import { tmpdir } from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { discoverDeclaredSkills, generateSkillsIndexManifest } from "../declaredSkills.js"; + +describe("discoverDeclaredSkills", () => { + let skillsDirectory: AbsoluteFilePath; + + beforeEach(async () => { + skillsDirectory = AbsoluteFilePath.of(await mkdtemp(path.join(tmpdir(), "fern-declared-skills-"))); + }); + + afterEach(async () => { + await rm(skillsDirectory, { recursive: true, force: true }); + }); + + async function writeFileAt(relativePath: string, contents = "contents"): Promise { + const absolutePath = path.join(skillsDirectory, relativePath); + await mkdir(path.dirname(absolutePath), { recursive: true }); + await writeFile(absolutePath, contents); + } + + function skillMarkdown(name: string, body = "# Skill\n"): string { + return `---\nname: ${name}\ndescription: Does ${name} things.\n---\n\n${body}`; + } + + it("discovers multiple skills with reference files and builds the manifest", async () => { + await writeFileAt( + "best-practices/SKILL.md", + skillMarkdown("best-practices", "See [the API reference](references/api.md).\n") + ); + await writeFileAt("best-practices/references/api.md", "# API\n"); + await writeFileAt("upgrade-guide/SKILL.md", skillMarkdown("upgrade-guide")); + + const { skills, violations } = await discoverDeclaredSkills({ absolutePathToSkillsDirectory: skillsDirectory }); + + expect(violations).toEqual([]); + expect(skills.map((skill) => skill.name)).toEqual(["best-practices", "upgrade-guide"]); + expect(skills[0]?.files.map((file) => String(file.relativeFilePathInSkill))).toEqual([ + "SKILL.md", + "references/api.md" + ]); + + expect(JSON.parse(generateSkillsIndexManifest(skills))).toEqual({ + skills: [ + { + name: "best-practices", + description: "Does best-practices things.", + files: ["SKILL.md", "references/api.md"] + }, + { + name: "upgrade-guide", + description: "Does upgrade-guide things.", + files: ["SKILL.md"] + } + ] + }); + }); + + it("discovers a single skill", async () => { + await writeFileAt("my-skill/SKILL.md", skillMarkdown("my-skill")); + + const { skills, violations } = await discoverDeclaredSkills({ absolutePathToSkillsDirectory: skillsDirectory }); + + expect(violations).toEqual([]); + expect(skills).toHaveLength(1); + expect(skills[0]?.name).toEqual("my-skill"); + expect(skills[0]?.description).toEqual("Does my-skill things."); + }); + + it("discovers skills nested under grouping directories", async () => { + await writeFileAt("group-a/my-skill/SKILL.md", skillMarkdown("my-skill")); + + const { skills, violations } = await discoverDeclaredSkills({ absolutePathToSkillsDirectory: skillsDirectory }); + + expect(violations).toEqual([]); + expect(skills.map((skill) => skill.name)).toEqual(["my-skill"]); + }); + + it("errors when the declared path does not exist", async () => { + const missing = AbsoluteFilePath.of(path.join(skillsDirectory, "does-not-exist")); + const { skills, violations } = await discoverDeclaredSkills({ absolutePathToSkillsDirectory: missing }); + + expect(skills).toEqual([]); + expect(violations).toHaveLength(1); + expect(violations[0]?.severity).toEqual("error"); + expect(violations[0]?.message).toContain("does not exist"); + }); + + it("errors when the declared path contains no skills", async () => { + await writeFileAt("notes.md", "# Not a skill\n"); + + const { skills, violations } = await discoverDeclaredSkills({ absolutePathToSkillsDirectory: skillsDirectory }); + + expect(skills).toEqual([]); + expect(violations).toHaveLength(1); + expect(violations[0]?.severity).toEqual("error"); + expect(violations[0]?.message).toContain("No skills found"); + }); + + it("errors when the frontmatter name does not match the directory name", async () => { + await writeFileAt("my-skill/SKILL.md", skillMarkdown("other-name")); + + const { skills, violations } = await discoverDeclaredSkills({ absolutePathToSkillsDirectory: skillsDirectory }); + + expect(skills).toEqual([]); + expect(violations).toHaveLength(1); + expect(violations[0]?.message).toContain('must match its parent directory "my-skill"'); + expect(violations[0]?.absoluteFilePath).toEqual(path.join(skillsDirectory, "my-skill/SKILL.md")); + }); + + it("errors on a non-kebab-case name and an empty description", async () => { + await writeFileAt("My_Skill/SKILL.md", "---\nname: My_Skill\ndescription: ''\n---\n"); + + const { violations } = await discoverDeclaredSkills({ absolutePathToSkillsDirectory: skillsDirectory }); + + expect(violations.map((violation) => violation.message)).toEqual([ + 'Skill name "My_Skill" must be kebab-case (lowercase letters, numbers, and hyphens).', + "SKILL.md frontmatter is missing a non-empty `description`." + ]); + }); + + it("errors on duplicate skill names across grouping directories", async () => { + await writeFileAt("group-a/my-skill/SKILL.md", skillMarkdown("my-skill")); + await writeFileAt("group-b/my-skill/SKILL.md", skillMarkdown("my-skill")); + + const { violations } = await discoverDeclaredSkills({ absolutePathToSkillsDirectory: skillsDirectory }); + + expect(violations).toHaveLength(1); + expect(violations[0]?.severity).toEqual("error"); + expect(violations[0]?.message).toContain('Duplicate skill name "my-skill"'); + }); + + it("errors when a markdown reference escapes the skill's directory", async () => { + await writeFileAt("my-skill/SKILL.md", skillMarkdown("my-skill", "See [shared notes](../shared/notes.md).\n")); + await writeFileAt("shared/notes.md", "# Shared\n"); + + const { skills, violations } = await discoverDeclaredSkills({ absolutePathToSkillsDirectory: skillsDirectory }); + + expect(skills).toEqual([]); + expect(violations).toHaveLength(1); + expect(violations[0]?.message).toContain('Reference "../shared/notes.md" escapes the skill\'s directory'); + }); + + it("allows external, site-absolute, anchor, and within-skill references", async () => { + await writeFileAt( + "my-skill/SKILL.md", + skillMarkdown( + "my-skill", + [ + "[external](https://example.com/docs)", + "[mail](mailto:support@example.com)", + "[site absolute](/docs/page)", + "[anchor](#section)", + "[within](references/api.md#section)", + "![image](assets/diagram.png)" + ].join("\n") + ) + ); + await writeFileAt("my-skill/references/api.md", "# API\n"); + await writeFileAt("my-skill/assets/diagram.png", "png"); + + const { violations } = await discoverDeclaredSkills({ absolutePathToSkillsDirectory: skillsDirectory }); + + expect(violations).toEqual([]); + }); + + it("does not flag `...` placeholders or `..`-prefixed filenames as escaping references", async () => { + await writeFileAt( + "my-skill/SKILL.md", + skillMarkdown( + "my-skill", + [ + "See the [rate limit](...) docs.", // ellipsis placeholder — a filename, not `../` + "[weird](..foo.md)", // in-directory file whose name starts with `..` + "[nested](references/..bar.md)" + ].join("\n") + ) + ); + await writeFileAt("my-skill/..foo.md", "# foo\n"); + await writeFileAt("my-skill/references/..bar.md", "# bar\n"); + await writeFileAt("my-skill/...", "placeholder\n"); + + const { skills, violations } = await discoverDeclaredSkills({ absolutePathToSkillsDirectory: skillsDirectory }); + + expect(violations).toEqual([]); + expect(skills.map((skill) => skill.name)).toEqual(["my-skill"]); + }); + + it("checks references in every markdown file of the skill, not just SKILL.md", async () => { + await writeFileAt("my-skill/SKILL.md", skillMarkdown("my-skill")); + await writeFileAt("my-skill/references/api.md", "[escape](../../outside.md)\n"); + + const { violations } = await discoverDeclaredSkills({ absolutePathToSkillsDirectory: skillsDirectory }); + + expect(violations).toHaveLength(1); + expect(violations[0]?.message).toContain("escapes the skill's directory"); + expect(relative(skillsDirectory, violations[0]?.absoluteFilePath ?? AbsoluteFilePath.of("/unset"))).toEqual( + "my-skill/references/api.md" + ); + }); + + it("errors instead of crashing when the declared path is a file, not a directory", async () => { + await writeFileAt("AGENTS.md", "# not a directory\n"); + const filePath = AbsoluteFilePath.of(path.join(skillsDirectory, "AGENTS.md")); + + const { skills, violations } = await discoverDeclaredSkills({ absolutePathToSkillsDirectory: filePath }); + + expect(skills).toEqual([]); + expect(violations).toHaveLength(1); + expect(violations[0]?.severity).toEqual("error"); + expect(violations[0]?.message).toContain("is not a directory"); + }); + + it("ignores escaping references shown inside code blocks and inline code", async () => { + await writeFileAt( + "my-skill/SKILL.md", + skillMarkdown( + "my-skill", + ["An example link:", "```md", "[guide](../guide.md)", "```", "And inline `[x](../y.md)` too.\n"].join( + "\n" + ) + ) + ); + + const { skills, violations } = await discoverDeclaredSkills({ absolutePathToSkillsDirectory: skillsDirectory }); + + expect(violations).toEqual([]); + expect(skills.map((skill) => skill.name)).toEqual(["my-skill"]); + }); + + it("does not treat files inside a skill as nested skills", async () => { + await writeFileAt("my-skill/SKILL.md", skillMarkdown("my-skill")); + await writeFileAt("my-skill/examples/SKILL.md", "not frontmatter"); + + const { skills, violations } = await discoverDeclaredSkills({ absolutePathToSkillsDirectory: skillsDirectory }); + + expect(violations).toEqual([]); + expect(skills.map((skill) => skill.name)).toEqual(["my-skill"]); + expect(skills[0]?.files.map((file) => String(file.relativeFilePathInSkill))).toEqual([ + "SKILL.md", + "examples/SKILL.md" + ]); + }); +}); diff --git a/packages/cli/docs-resolver/src/__test__/skills-page-action.test.ts b/packages/cli/docs-resolver/src/utils/__test__/skills-page-action.test.ts similarity index 96% rename from packages/cli/docs-resolver/src/__test__/skills-page-action.test.ts rename to packages/cli/docs-resolver/src/utils/__test__/skills-page-action.test.ts index 53a0369e0df1..2b9df21079a3 100644 --- a/packages/cli/docs-resolver/src/__test__/skills-page-action.test.ts +++ b/packages/cli/docs-resolver/src/utils/__test__/skills-page-action.test.ts @@ -1,7 +1,7 @@ import { docsYml } from "@fern-api/configuration"; import { describe, expect, it } from "vitest"; -import { DocsDefinitionResolver } from "../DocsDefinitionResolver.js"; +import { DocsDefinitionResolver } from "../../DocsDefinitionResolver.js"; function convertPageActionsFor(pageActions: docsYml.ParsedPageActionsConfig | undefined): unknown { const resolver = Object.create(DocsDefinitionResolver.prototype) as DocsDefinitionResolver; @@ -28,7 +28,8 @@ function makeParsedPageActions( vscode: false, mcp: true, custom: [], - skills + skills, + skillsDirectory: undefined } }; } diff --git a/packages/cli/docs-resolver/src/utils/collectWellKnownSkillsFiles.ts b/packages/cli/docs-resolver/src/utils/collectWellKnownSkillsFiles.ts index a54629782e1c..98c7fb2e95f3 100644 --- a/packages/cli/docs-resolver/src/utils/collectWellKnownSkillsFiles.ts +++ b/packages/cli/docs-resolver/src/utils/collectWellKnownSkillsFiles.ts @@ -5,19 +5,29 @@ import { AbsoluteFilePath, doesPathExist, getAllFilesInDirectory, join, Relative * author-supplied Agent Skills at `/.well-known/skills/…` (legacy v0.1.0 layout) and * `/.well-known/agent-skills/…` (current spec layout), making * `npx skills add https://` work against Fern-hosted docs. + * + * This raw passthrough is the quiet fallback for sites that hand-build their bundles. When + * docs.yml declares `page-actions.options.skills.path`, the CLI generates the + * `.well-known/skills/…` bundle from that path instead and a hand-populated + * `.well-known/skills/` folder is ignored (see {@link DECLARED_SKILLS_UPLOAD_DIRECTORY}). */ +export const WELL_KNOWN_SKILLS_DIRECTORY = RelativeFilePath.of(".well-known/skills"); +export const WELL_KNOWN_AGENT_SKILLS_DIRECTORY = RelativeFilePath.of(".well-known/agent-skills"); + export const WELL_KNOWN_SKILLS_DIRECTORIES: RelativeFilePath[] = [ - RelativeFilePath.of(".well-known/skills"), - RelativeFilePath.of(".well-known/agent-skills") + WELL_KNOWN_SKILLS_DIRECTORY, + WELL_KNOWN_AGENT_SKILLS_DIRECTORY ]; export async function collectWellKnownSkillsFiles({ - absolutePathToFernFolder + absolutePathToFernFolder, + directories = WELL_KNOWN_SKILLS_DIRECTORIES }: { absolutePathToFernFolder: AbsoluteFilePath; + directories?: RelativeFilePath[]; }): Promise { const filepaths: AbsoluteFilePath[] = []; - for (const directory of WELL_KNOWN_SKILLS_DIRECTORIES) { + for (const directory of directories) { const absoluteDirectory = join(absolutePathToFernFolder, directory); if (await doesPathExist(absoluteDirectory)) { const files = await getAllFilesInDirectory(absoluteDirectory); diff --git a/packages/cli/docs-resolver/src/utils/declaredSkills.ts b/packages/cli/docs-resolver/src/utils/declaredSkills.ts new file mode 100644 index 000000000000..782892e71cb1 --- /dev/null +++ b/packages/cli/docs-resolver/src/utils/declaredSkills.ts @@ -0,0 +1,383 @@ +import { + AbsoluteFilePath, + basename, + dirname, + doesPathExist, + getAllFilesInDirectory, + join, + RelativeFilePath, + relative, + resolve +} from "@fern-api/fs-utils"; +import { readdir, readFile } from "fs/promises"; +import grayMatter from "gray-matter"; + +/** + * Upload destination for agent skills declared via `page-actions.options.skills.path` in + * docs.yml. Every discovered file is re-homed here in the publish payload — regardless of + * where it lives in the repo — so the docs site serves it at `/.well-known/skills/…` and + * `npx skills add https://` works. Nothing is written back to the repo. + */ +export const DECLARED_SKILLS_UPLOAD_DIRECTORY = RelativeFilePath.of(".well-known/skills"); + +export const MAX_SKILL_NAME_LENGTH = 64; +export const MAX_SKILL_DESCRIPTION_LENGTH = 1024; +export const SKILL_NAME_KEBAB_CASE_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/; + +const SKILL_MARKDOWN_FILENAME = "SKILL.md"; +const MARKDOWN_FILE_EXTENSIONS = [".md", ".mdx"]; +/** Matches markdown link and image targets: `[text](target)` and `![alt](target)`. */ +const MARKDOWN_LINK_TARGET_REGEX = /!?\[[^\]]*\]\(\s*\s]+)>?\s*(?:"[^"]*")?\)/g; + +export interface DeclaredSkillFile { + absoluteFilePath: AbsoluteFilePath; + /** Path of the file within its skill directory, e.g. `SKILL.md` or `references/api.md`. */ + relativeFilePathInSkill: RelativeFilePath; +} + +export interface DeclaredSkill { + /** The skill identifier — frontmatter `name`, always equal to the directory name. */ + name: string; + /** Frontmatter `description`. */ + description: string; + absoluteDirectory: AbsoluteFilePath; + /** Every file in the skill directory, SKILL.md first. */ + files: DeclaredSkillFile[]; +} + +export interface DeclaredSkillsViolation { + severity: "error" | "warning"; + message: string; + /** The file the violation refers to, when there is one. */ + absoluteFilePath: AbsoluteFilePath | undefined; +} + +export interface DiscoveredDeclaredSkills { + skills: DeclaredSkill[]; + violations: DeclaredSkillsViolation[]; +} + +/** + * Discovers and validates agent skills under a directory declared via + * `page-actions.options.skills.path` in docs.yml. Every subdirectory containing a `SKILL.md` + * is one skill; frontmatter `name` + `description` are its metadata. Validation failures are + * returned as violations (severity `error` blocks publishing via the + * `valid-well-known-skills` rule and `DocsDefinitionResolver`): + * + * - the declared path must exist and contain at least one skill + * - frontmatter `name` must be kebab-case (max 64 characters) and match the skill's directory name + * - frontmatter `description` must be non-empty (max 1024 characters) + * - skill names must be unique across the directory + * - markdown references must not escape the skill's directory (`../`), since installed + * bundles only contain the files under `.well-known/skills//…` + */ +export async function discoverDeclaredSkills({ + absolutePathToSkillsDirectory +}: { + absolutePathToSkillsDirectory: AbsoluteFilePath; +}): Promise { + if (!(await doesPathExist(absolutePathToSkillsDirectory, "directory"))) { + return { + skills: [], + violations: [ + { + severity: "error", + message: + `Skills path "${absolutePathToSkillsDirectory}" does not exist or is not a directory. ` + + "page-actions.options.skills.path must point to a directory of agent skills in this repo.", + absoluteFilePath: undefined + } + ] + }; + } + + const skillDirectories = await findSkillDirectories(absolutePathToSkillsDirectory); + if (skillDirectories.length === 0) { + return { + skills: [], + violations: [ + { + severity: "error", + message: + `No skills found under "${absolutePathToSkillsDirectory}". ` + + "Every subdirectory containing a SKILL.md is published as one skill.", + absoluteFilePath: undefined + } + ] + }; + } + + const skills: DeclaredSkill[] = []; + const violations: DeclaredSkillsViolation[] = []; + for (const skillDirectory of skillDirectories) { + const { skill, violations: skillViolations } = await loadSkill(skillDirectory); + violations.push(...skillViolations); + if (skill != null) { + skills.push(skill); + } + } + + const seenNames = new Set(); + for (const skill of skills) { + if (seenNames.has(skill.name)) { + violations.push({ + severity: "error", + message: + `Duplicate skill name "${skill.name}". ` + + "Skill names must be unique — each is served at .well-known/skills//.", + absoluteFilePath: join(skill.absoluteDirectory, RelativeFilePath.of(SKILL_MARKDOWN_FILENAME)) + }); + } + seenNames.add(skill.name); + } + + return { skills, violations }; +} + +/** + * Renders the `.well-known/skills/index.json` discovery manifest (v0.1.0 layout — the format + * served by Stripe and consumed by `npx skills add`): `{ skills: [{ name, description, files }] }`. + */ +export function generateSkillsIndexManifest(skills: DeclaredSkill[]): string { + return JSON.stringify( + { + skills: skills.map((skill) => ({ + name: skill.name, + description: skill.description, + files: skill.files.map((file) => String(file.relativeFilePathInSkill)) + })) + }, + undefined, + 2 + ); +} + +/** + * Finds every directory under `directory` (recursively) that contains a SKILL.md. A skill + * directory owns everything beneath it, so the walk does not descend into one looking for + * nested skills. + */ +async function findSkillDirectories(directory: AbsoluteFilePath): Promise { + const skillDirectories: AbsoluteFilePath[] = []; + const entries = await readdir(directory, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + const subdirectory = join(directory, RelativeFilePath.of(entry.name)); + if (await doesPathExist(join(subdirectory, RelativeFilePath.of(SKILL_MARKDOWN_FILENAME)), "file")) { + skillDirectories.push(subdirectory); + } else { + skillDirectories.push(...(await findSkillDirectories(subdirectory))); + } + } + return skillDirectories.sort(); +} + +async function loadSkill( + skillDirectory: AbsoluteFilePath +): Promise<{ skill: DeclaredSkill | undefined; violations: DeclaredSkillsViolation[] }> { + const skillMarkdownPath = join(skillDirectory, RelativeFilePath.of(SKILL_MARKDOWN_FILENAME)); + const violations: DeclaredSkillsViolation[] = []; + + let frontmatter: Record; + try { + frontmatter = grayMatter(await readFile(skillMarkdownPath, "utf-8")).data; + } catch (error) { + return { + skill: undefined, + violations: [ + { + severity: "error", + message: `Failed to parse frontmatter: ${error instanceof Error ? error.message : String(error)}`, + absoluteFilePath: skillMarkdownPath + } + ] + }; + } + + violations.push( + ...validateSkillFrontmatter({ frontmatter, skillDirectoryName: basename(skillDirectory) }).map( + (message): DeclaredSkillsViolation => ({ + severity: "error", + message, + absoluteFilePath: skillMarkdownPath + }) + ) + ); + + const files = await collectSkillFiles(skillDirectory); + violations.push(...(await findReferencesEscapingSkillDirectory({ skillDirectory, files }))); + + if (violations.some((violation) => violation.severity === "error")) { + return { skill: undefined, violations }; + } + + return { + skill: { + // frontmatter `name` is validated above to be a non-empty string matching the directory + name: basename(skillDirectory), + description: typeof frontmatter.description === "string" ? frontmatter.description : "", + absoluteDirectory: skillDirectory, + files + }, + violations + }; +} + +/** + * Validates SKILL.md frontmatter for a skill directory, returning human-readable error + * messages. Shared by declared-path discovery and the `valid-well-known-skills` rule's + * raw `.well-known/` passthrough validation so the two cannot drift. + */ +export function validateSkillFrontmatter({ + frontmatter, + skillDirectoryName +}: { + frontmatter: Record; + skillDirectoryName: string; +}): string[] { + const messages: string[] = []; + + const name = frontmatter.name; + if (typeof name !== "string" || name.length === 0) { + messages.push("SKILL.md frontmatter is missing a `name`."); + } else { + if (!SKILL_NAME_KEBAB_CASE_REGEX.test(name)) { + messages.push(`Skill name "${name}" must be kebab-case (lowercase letters, numbers, and hyphens).`); + } + if (name.length > MAX_SKILL_NAME_LENGTH) { + messages.push(`Skill name "${name}" exceeds ${MAX_SKILL_NAME_LENGTH} characters.`); + } + if (name !== skillDirectoryName) { + messages.push(`Skill name "${name}" must match its parent directory "${skillDirectoryName}".`); + } + } + + const description = frontmatter.description; + if (typeof description !== "string" || description.trim().length === 0) { + messages.push("SKILL.md frontmatter is missing a non-empty `description`."); + } else if (description.length > MAX_SKILL_DESCRIPTION_LENGTH) { + messages.push(`Skill description exceeds ${MAX_SKILL_DESCRIPTION_LENGTH} characters.`); + } + + return messages; +} + +async function collectSkillFiles(skillDirectory: AbsoluteFilePath): Promise { + const files = (await getAllFilesInDirectory(skillDirectory)).map( + (absoluteFilePath): DeclaredSkillFile => ({ + absoluteFilePath: AbsoluteFilePath.of(absoluteFilePath), + relativeFilePathInSkill: relative(skillDirectory, AbsoluteFilePath.of(absoluteFilePath)) + }) + ); + // SKILL.md first (manifest convention), then the rest alphabetically + return files.sort((a, b) => { + if (a.relativeFilePathInSkill === SKILL_MARKDOWN_FILENAME) { + return -1; + } + if (b.relativeFilePathInSkill === SKILL_MARKDOWN_FILENAME) { + return 1; + } + return a.relativeFilePathInSkill < b.relativeFilePathInSkill ? -1 : 1; + }); +} + +/** + * Flags markdown references that resolve outside the skill's directory. An installed bundle + * only contains the files under `.well-known/skills//…`, so a `../` reference would + * dangle for everyone who installs the skill. + */ +async function findReferencesEscapingSkillDirectory({ + skillDirectory, + files +}: { + skillDirectory: AbsoluteFilePath; + files: DeclaredSkillFile[]; +}): Promise { + const violations: DeclaredSkillsViolation[] = []; + for (const file of files) { + if (!MARKDOWN_FILE_EXTENSIONS.some((extension) => file.absoluteFilePath.endsWith(extension))) { + continue; + } + const contents = await readFile(file.absoluteFilePath, "utf-8"); + for (const target of extractRelativeMarkdownReferences(contents)) { + const resolved = resolve(dirname(file.absoluteFilePath), target); + if (referenceEscapesSkillDirectory({ skillDirectory, resolved })) { + violations.push({ + severity: "error", + message: + `Reference "${target}" escapes the skill's directory. ` + + "Installed skills only include files within the skill's own folder, so this reference would break after `npx skills add`.", + absoluteFilePath: file.absoluteFilePath + }); + } + } + } + return violations; +} + +/** + * True when `resolved` lands outside `skillDirectory`. Escaping is about a leading `..` path + * *segment*, not a `..` string prefix — a plain `startsWith("..")` would false-positive on + * in-directory targets like `...` (the ellipsis placeholder in `[x](...)`) or `..foo`. + * `relative` from fs-utils always uses forward slashes, so the segment separator is `/`. + */ +function referenceEscapesSkillDirectory({ + skillDirectory, + resolved +}: { + skillDirectory: AbsoluteFilePath; + resolved: AbsoluteFilePath; +}): boolean { + const relativeToSkill = relative(skillDirectory, resolved); + return relativeToSkill === ".." || relativeToSkill.startsWith("../"); +} + +/** + * Removes fenced code blocks (``` / ~~~) and inline code spans (`…`) so that link and image + * syntax shown as an *example* inside documentation isn't mistaken for a live reference. A + * `../foo` in a code sample is not a real link and must not be flagged as escaping the skill. + */ +function stripCodeSpansAndFences(markdown: string): string { + const lines = markdown.split("\n"); + const stripped: string[] = []; + let openFence: string | undefined; + for (const line of lines) { + const fenceChar = line.match(/^\s*(`{3,}|~{3,})/)?.[1]?.[0]; + if (openFence == null) { + if (fenceChar != null) { + openFence = fenceChar; + continue; + } + // drop inline code spans (`code`, ``co`de``) before this line is scanned + stripped.push(line.replace(/`+[^`]*`+/g, "")); + } else if (fenceChar === openFence) { + openFence = undefined; + } + } + return stripped.join("\n"); +} + +function extractRelativeMarkdownReferences(markdown: string): string[] { + const targets: string[] = []; + for (const match of stripCodeSpansAndFences(markdown).matchAll(MARKDOWN_LINK_TARGET_REGEX)) { + const rawTarget = match[1]; + if (rawTarget == null) { + continue; + } + // strip anchors and query strings + const target = rawTarget.split("#")[0]?.split("?")[0] ?? ""; + if (target.length === 0 || isExternalOrAbsoluteReference(target)) { + continue; + } + targets.push(target); + } + return targets; +} + +function isExternalOrAbsoluteReference(target: string): boolean { + // scheme-qualified (https:, mailto:, etc.), protocol-relative, or site-absolute references + // never resolve against the skill directory + return /^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("//") || target.startsWith("/"); +} diff --git a/packages/cli/workspace/loader/src/docs-yml.schema.json b/packages/cli/workspace/loader/src/docs-yml.schema.json index 58fe19d42696..dcabf5f84209 100644 --- a/packages/cli/workspace/loader/src/docs-yml.schema.json +++ b/packages/cli/workspace/loader/src/docs-yml.schema.json @@ -4387,6 +4387,17 @@ "docs.SkillsPageActionConfig": { "type": "object", "properties": { + "path": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Path to a directory of agent skills in this repo, resolved relative to the folder\ncontaining this docs.yml (`../` is allowed, e.g. a repo-root `.agents/skills/`\nfolder). Every subdirectory containing a `SKILL.md` is published as one skill: the CLI\nvalidates the bundle, generates the `/.well-known/skills/index.json` discovery\nmanifest, and uploads every file at `.well-known/skills//…` so that\n`npx skills add https://` works. Nothing is written back to the repo." + }, "title": { "oneOf": [ { @@ -4458,7 +4469,7 @@ } }, "additionalProperties": false, - "description": "Configures the \"Install skills\" page action and its modal. Sites that ship agent skills\npoint at where those skills live and how to install them. If the docs site also serves a\n`/.well-known` skills manifest, the modal replaces the hand-listed `skills` array with the\nserved manifest; `title`, `description`, `learn-more-url`, `repository`, and\n`install-command` always come from this config." + "description": "Configures the \"Install skills\" page action and its modal. Sites that ship agent skills\npoint at where those skills live (`path`) and how to install them. If the docs site also\nserves a `/.well-known` skills manifest, the modal replaces the hand-listed `skills` array\nwith the served manifest; `title`, `description`, `learn-more-url`, `repository`, and\n`install-command` always come from this config." }, "docs.PageActionOptions": { "type": "object", diff --git a/packages/cli/yaml/docs-validator/src/getAllRules.ts b/packages/cli/yaml/docs-validator/src/getAllRules.ts index d3e4dd021a00..c278db8aae7f 100644 --- a/packages/cli/yaml/docs-validator/src/getAllRules.ts +++ b/packages/cli/yaml/docs-validator/src/getAllRules.ts @@ -49,7 +49,7 @@ const allRules = [ ValidFrontmatter, TranslationDirectoriesExistRule, ValidChangelogSlugRule, - ValidWellKnownSkillsRule, // Validate author-supplied Agent Skills bundles under .well-known/ + ValidWellKnownSkillsRule, // Validate Agent Skills (docs.yml-declared path, or raw .well-known/ bundles) ValidSkillsPageActionRule // Validate the "Install skills" page action display config // ValidMarkdownFileReferences ]; diff --git a/packages/cli/yaml/docs-validator/src/rules/valid-skills-page-action/valid-skills-page-action.ts b/packages/cli/yaml/docs-validator/src/rules/valid-skills-page-action/valid-skills-page-action.ts index 7ed4283f305a..f6f7b7002dbc 100644 --- a/packages/cli/yaml/docs-validator/src/rules/valid-skills-page-action/valid-skills-page-action.ts +++ b/packages/cli/yaml/docs-validator/src/rules/valid-skills-page-action/valid-skills-page-action.ts @@ -29,6 +29,13 @@ export function validateSkillsPageAction( const violations: RuleViolation[] = []; + if (skills.path != null && skills.path.trim().length === 0) { + violations.push({ + severity: "error", + message: "page-actions.options.skills.path must not be empty." + }); + } + if (skills.installCommand != null) { const installCommands = typeof skills.installCommand === "string" ? [skills.installCommand] : skills.installCommand; diff --git a/packages/cli/yaml/docs-validator/src/rules/valid-well-known-skills/__test__/valid-well-known-skills.test.ts b/packages/cli/yaml/docs-validator/src/rules/valid-well-known-skills/__test__/valid-well-known-skills.test.ts index 6e0eeeedfd21..d4658f8e5092 100644 --- a/packages/cli/yaml/docs-validator/src/rules/valid-well-known-skills/__test__/valid-well-known-skills.test.ts +++ b/packages/cli/yaml/docs-validator/src/rules/valid-well-known-skills/__test__/valid-well-known-skills.test.ts @@ -4,7 +4,11 @@ import { tmpdir } from "os"; import path from "path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { validateSkillMarkdown, validateWellKnownSkillsDirectory } from "../valid-well-known-skills.js"; +import { + validateDeclaredSkillsPath, + validateSkillMarkdown, + validateWellKnownSkillsDirectory +} from "../valid-well-known-skills.js"; const SKILLS_DIRECTORY = RelativeFilePath.of(".well-known/skills"); @@ -100,6 +104,126 @@ describe("validateWellKnownSkillsDirectory", () => { }); }); +describe("validateDeclaredSkillsPath", () => { + let fernFolder: AbsoluteFilePath; + + beforeEach(async () => { + fernFolder = AbsoluteFilePath.of(await mkdtemp(path.join(tmpdir(), "fern-declared-skills-rule-"))); + }); + + afterEach(async () => { + await rm(fernFolder, { recursive: true, force: true }); + }); + + async function writeFileAt(relativePath: string, contents: string): Promise { + const absolutePath = path.join(fernFolder, relativePath); + await mkdir(path.dirname(absolutePath), { recursive: true }); + await writeFile(absolutePath, contents); + } + + function declaredSkillsDirectory(relativePath: string): AbsoluteFilePath { + return AbsoluteFilePath.of(path.join(fernFolder, relativePath)); + } + + it("returns no violations for a valid declared skills directory (no index.json required)", async () => { + await writeFileAt( + "agent-skills/best-practices/SKILL.md", + "---\nname: best-practices\ndescription: How to use the API well.\n---\n" + ); + + const violations = await validateDeclaredSkillsPath({ + absolutePathToFernFolder: fernFolder, + absolutePathToSkillsDirectory: declaredSkillsDirectory("agent-skills") + }); + expect(violations).toEqual([]); + }); + + it("errors when the declared path does not exist", async () => { + const violations = await validateDeclaredSkillsPath({ + absolutePathToFernFolder: fernFolder, + absolutePathToSkillsDirectory: declaredSkillsDirectory("agent-skills") + }); + expect(violations).toHaveLength(1); + expect(violations[0]?.severity).toEqual("error"); + expect(violations[0]?.message).toContain("does not exist"); + }); + + it("maps discovery errors onto workspace-relative file paths", async () => { + await writeFileAt( + "agent-skills/best-practices/SKILL.md", + "---\nname: other-name\ndescription: How to use the API well.\n---\n" + ); + + const violations = await validateDeclaredSkillsPath({ + absolutePathToFernFolder: fernFolder, + absolutePathToSkillsDirectory: declaredSkillsDirectory("agent-skills") + }); + expect(violations).toHaveLength(1); + expect(violations[0]?.severity).toEqual("error"); + expect(violations[0]?.message).toContain('must match its parent directory "best-practices"'); + expect(violations[0]?.relativeFilepath).toEqual("agent-skills/best-practices/SKILL.md"); + }); + + it("warns when a hand-populated .well-known/skills folder is also present (declared path wins)", async () => { + await writeFileAt( + "agent-skills/best-practices/SKILL.md", + "---\nname: best-practices\ndescription: How to use the API well.\n---\n" + ); + await writeFileAt(".well-known/skills/index.json", JSON.stringify({ skills: [] })); + + const violations = await validateDeclaredSkillsPath({ + absolutePathToFernFolder: fernFolder, + absolutePathToSkillsDirectory: declaredSkillsDirectory("agent-skills") + }); + expect(violations).toHaveLength(1); + expect(violations[0]?.severity).toEqual("warning"); + expect(violations[0]?.message).toContain(".well-known/skills/ is ignored"); + }); + + it("still validates the .well-known/agent-skills/ passthrough when a skills path is declared", async () => { + await writeFileAt( + "agent-skills/best-practices/SKILL.md", + "---\nname: best-practices\ndescription: How to use the API well.\n---\n" + ); + // the agent-skills passthrough is still uploaded verbatim, so a malformed bundle there + // must be caught even though the declared path is the source of the generated bundle + await writeFileAt(".well-known/agent-skills/index.json", "{ not json"); + + const violations = await validateDeclaredSkillsPath({ + absolutePathToFernFolder: fernFolder, + absolutePathToSkillsDirectory: declaredSkillsDirectory("agent-skills") + }); + expect( + violations.some( + (violation) => + violation.severity === "error" && + violation.message.includes(".well-known/agent-skills/index.json is not valid JSON") + ) + ).toBe(true); + }); + + it("names files outside the fern folder in the message instead of a relative path", async () => { + await writeFileAt( + "agent-skills/best-practices/SKILL.md", + "---\nname: best-practices\ndescription: How to use the API well.\n---\n" + ); + + // validate from a deeper "fern folder" so the skills directory is outside it (../) + const nestedFernFolder = AbsoluteFilePath.of(path.join(fernFolder, "docs", "fern")); + await mkdir(nestedFernFolder, { recursive: true }); + await writeFileAt("agent-skills/wrong-name/SKILL.md", "---\nname: nope\ndescription: d.\n---\n"); + + const violations = await validateDeclaredSkillsPath({ + absolutePathToFernFolder: nestedFernFolder, + absolutePathToSkillsDirectory: declaredSkillsDirectory("agent-skills") + }); + expect(violations).toHaveLength(1); + expect(violations[0]?.relativeFilepath).toBeUndefined(); + expect(violations[0]?.message).toContain("agent-skills/wrong-name/SKILL.md"); + expect(violations[0]?.message).toContain('must match its parent directory "wrong-name"'); + }); +}); + describe("validateSkillMarkdown", () => { const relativeFilepath = RelativeFilePath.of(".well-known/skills/my-skill/SKILL.md"); diff --git a/packages/cli/yaml/docs-validator/src/rules/valid-well-known-skills/valid-well-known-skills.ts b/packages/cli/yaml/docs-validator/src/rules/valid-well-known-skills/valid-well-known-skills.ts index 36eabee4acb6..1d57a372aa56 100644 --- a/packages/cli/yaml/docs-validator/src/rules/valid-well-known-skills/valid-well-known-skills.ts +++ b/packages/cli/yaml/docs-validator/src/rules/valid-well-known-skills/valid-well-known-skills.ts @@ -1,25 +1,49 @@ -import { AbsoluteFilePath, doesPathExist, join, RelativeFilePath } from "@fern-api/fs-utils"; +import { discoverDeclaredSkills, validateSkillFrontmatter } from "@fern-api/docs-resolver"; +import { + AbsoluteFilePath, + dirname, + doesPathExist, + join, + RelativeFilePath, + relative, + resolve +} from "@fern-api/fs-utils"; import { readdir, readFile } from "fs/promises"; import grayMatter from "gray-matter"; import { Rule, RuleViolation } from "../../Rule.js"; /** - * Validates author-supplied Agent Skills bundles placed under `.well-known/skills/` (legacy - * v0.1.0 layout) or `.well-known/agent-skills/` (current spec layout) in the docs folder. - * These files are uploaded verbatim and served by the docs site so that - * `npx skills add https://` works; serving is passthrough and never validates, - * so the CLI validates the bundle at upload time instead: + * Validates the Agent Skills a docs site publishes for `npx skills add https://`. + * Serving is passthrough and never validates, so the CLI validates at check/publish time: * - * - an `index.json` discovery manifest must exist at the root of the directory and parse as JSON - * - each `/SKILL.md` must have frontmatter with a kebab-case `name` (max 64 - * characters) matching its parent directory, and a non-empty `description` (max 1024 characters) + * - When docs.yml declares `page-actions.options.skills.path`, the declared directory is the + * source of truth: every subdirectory containing a `SKILL.md` is one skill, validated by + * `discoverDeclaredSkills` (path exists and contains skills, kebab-case `name` matching the + * directory, non-empty `description`, unique names, no references escaping a skill's + * directory). The CLI generates the discovery manifest at publish time, so a hand-populated + * `.well-known/skills/` folder is ignored — flagged here as a warning. + * + * - Without a declared path, author-supplied bundles under `.well-known/skills/` (legacy + * v0.1.0 layout) and `.well-known/agent-skills/` (current spec layout) are uploaded + * verbatim, so each must ship its own `index.json` and valid `/SKILL.md`s. */ export const ValidWellKnownSkillsRule: Rule = { name: "valid-well-known-skills", create: ({ workspace }) => { return { - file: async () => { + file: async ({ config }) => { + const declaredSkillsPath = config.pageActions?.options?.skills?.path; + if (declaredSkillsPath != null) { + return validateDeclaredSkillsPath({ + absolutePathToFernFolder: workspace.absoluteFilePath, + absolutePathToSkillsDirectory: resolve( + dirname(workspace.absoluteFilepathToDocsConfig), + declaredSkillsPath + ) + }); + } + const violations: RuleViolation[] = []; for (const wellKnownDirectory of WELL_KNOWN_SKILLS_DIRECTORIES) { violations.push( @@ -35,14 +59,57 @@ export const ValidWellKnownSkillsRule: Rule = { } }; -const WELL_KNOWN_SKILLS_DIRECTORIES = [ - RelativeFilePath.of(".well-known/skills"), - RelativeFilePath.of(".well-known/agent-skills") -]; +const WELL_KNOWN_SKILLS_DIRECTORY = RelativeFilePath.of(".well-known/skills"); +const WELL_KNOWN_AGENT_SKILLS_DIRECTORY = RelativeFilePath.of(".well-known/agent-skills"); +const WELL_KNOWN_SKILLS_DIRECTORIES = [WELL_KNOWN_SKILLS_DIRECTORY, WELL_KNOWN_AGENT_SKILLS_DIRECTORY]; -const MAX_SKILL_NAME_LENGTH = 64; -const MAX_SKILL_DESCRIPTION_LENGTH = 1024; -const KEBAB_CASE_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/; +export async function validateDeclaredSkillsPath({ + absolutePathToFernFolder, + absolutePathToSkillsDirectory +}: { + absolutePathToFernFolder: AbsoluteFilePath; + absolutePathToSkillsDirectory: AbsoluteFilePath; +}): Promise { + const { violations } = await discoverDeclaredSkills({ absolutePathToSkillsDirectory }); + + const ruleViolations: RuleViolation[] = violations.map((violation): RuleViolation => { + if (violation.absoluteFilePath == null) { + return { severity: violation.severity, message: violation.message }; + } + const relativeFilepath = relative(absolutePathToFernFolder, violation.absoluteFilePath); + // files outside the fern folder (e.g. a repo-root ../.agents/skills path) don't have a + // meaningful workspace-relative path, so name them in the message instead + if (relativeFilepath.startsWith("..")) { + return { + severity: violation.severity, + message: `${violation.absoluteFilePath}: ${violation.message}` + }; + } + return { severity: violation.severity, message: violation.message, relativeFilepath }; + }); + + // the declared path wins over a hand-populated .well-known/skills folder + if (await doesPathExist(join(absolutePathToFernFolder, WELL_KNOWN_SKILLS_DIRECTORY))) { + ruleViolations.push({ + severity: "warning", + message: + `${WELL_KNOWN_SKILLS_DIRECTORY}/ is ignored because page-actions.options.skills.path is declared in docs.yml — ` + + "the skills bundle is generated from the declared path instead. Remove the folder to silence this warning.", + relativeFilepath: WELL_KNOWN_SKILLS_DIRECTORY + }); + } + + // a declared path only generates the .well-known/skills/ bundle; .well-known/agent-skills/ + // is still uploaded verbatim, so it must keep being validated here too + ruleViolations.push( + ...(await validateWellKnownSkillsDirectory({ + absolutePathToFernFolder, + wellKnownDirectory: WELL_KNOWN_AGENT_SKILLS_DIRECTORY + })) + ); + + return ruleViolations; +} export async function validateWellKnownSkillsDirectory({ absolutePathToFernFolder, @@ -130,53 +197,11 @@ export function validateSkillMarkdown({ ]; } - const violations: RuleViolation[] = []; - - const name = frontmatter.name; - if (typeof name !== "string" || name.length === 0) { - violations.push({ - severity: "error", - message: "SKILL.md frontmatter is missing a `name`.", - relativeFilepath: skillMarkdownRelativePath - }); - } else { - if (!KEBAB_CASE_REGEX.test(name)) { - violations.push({ - severity: "error", - message: `Skill name "${name}" must be kebab-case (lowercase letters, numbers, and hyphens).`, - relativeFilepath: skillMarkdownRelativePath - }); - } - if (name.length > MAX_SKILL_NAME_LENGTH) { - violations.push({ - severity: "error", - message: `Skill name "${name}" exceeds ${MAX_SKILL_NAME_LENGTH} characters.`, - relativeFilepath: skillMarkdownRelativePath - }); - } - if (name !== skillDirectoryName) { - violations.push({ - severity: "error", - message: `Skill name "${name}" must match its parent directory "${skillDirectoryName}".`, - relativeFilepath: skillMarkdownRelativePath - }); - } - } - - const description = frontmatter.description; - if (typeof description !== "string" || description.trim().length === 0) { - violations.push({ - severity: "error", - message: "SKILL.md frontmatter is missing a non-empty `description`.", - relativeFilepath: skillMarkdownRelativePath - }); - } else if (description.length > MAX_SKILL_DESCRIPTION_LENGTH) { - violations.push({ + return validateSkillFrontmatter({ frontmatter, skillDirectoryName }).map( + (message): RuleViolation => ({ severity: "error", - message: `Skill description exceeds ${MAX_SKILL_DESCRIPTION_LENGTH} characters.`, + message, relativeFilepath: skillMarkdownRelativePath - }); - } - - return violations; + }) + ); } diff --git a/packages/cli/yaml/docs-validator/vitest.config.ts b/packages/cli/yaml/docs-validator/vitest.config.ts index efbce2018779..d3a8fc208133 100644 --- a/packages/cli/yaml/docs-validator/vitest.config.ts +++ b/packages/cli/yaml/docs-validator/vitest.config.ts @@ -1 +1,16 @@ -export { default } from "@fern-api/configs/vitest/base.mjs"; +import { defaultConfig, defineConfig, mergeConfig } from "@fern-api/configs/vitest/base.mjs"; + +export default mergeConfig( + defaultConfig, + defineConfig({ + test: { + server: { + deps: { + // @fern-api/ui-core-utils (pulled in via @fern-api/docs-resolver → fdr-sdk) + // ships extensionless ESM imports that Node can't resolve without bundling + inline: ["@fern-api/ui-core-utils"] + } + } + } + }) +); From ca4a84f6e5d0fb039c83c5d5ffc4694d2b4f2226 Mon Sep 17 00:00:00 2001 From: "mathieu.legault" Date: Tue, 15 Sep 2026 17:11:40 +0000 Subject: [PATCH 2/3] fix(cli): use async doesPathExist for well-known skills conflict check Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- packages/cli/docs-resolver/src/DocsDefinitionResolver.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/docs-resolver/src/DocsDefinitionResolver.ts b/packages/cli/docs-resolver/src/DocsDefinitionResolver.ts index b39e6b30a86c..46c6559f50e8 100644 --- a/packages/cli/docs-resolver/src/DocsDefinitionResolver.ts +++ b/packages/cli/docs-resolver/src/DocsDefinitionResolver.ts @@ -21,7 +21,7 @@ import { transformAtPrefixImports } from "@fern-api/docs-markdown-utils"; import { APIV1Write, DocsV1Write, FdrAPI, FernNavigation } from "@fern-api/fdr-sdk"; -import { AbsoluteFilePath, join, RelativeFilePath, relative, resolve } from "@fern-api/fs-utils"; +import { AbsoluteFilePath, doesPathExist, join, RelativeFilePath, relative, resolve } from "@fern-api/fs-utils"; import { GraphQLConverter, type GraphQlOperationExamplesInput } from "@fern-api/graphql-to-fdr"; import { generateIntermediateRepresentation } from "@fern-api/ir-generator"; import { IntermediateRepresentation } from "@fern-api/ir-sdk"; @@ -914,7 +914,7 @@ export class DocsDefinitionResolver { }); } - if (existsSync(join(this.docsWorkspace.absoluteFilePath, WELL_KNOWN_SKILLS_DIRECTORY))) { + if (await doesPathExist(join(this.docsWorkspace.absoluteFilePath, WELL_KNOWN_SKILLS_DIRECTORY))) { this.taskContext.logger.warn( `Ignoring ${WELL_KNOWN_SKILLS_DIRECTORY}/ because page-actions.options.skills.path is declared in docs.yml — ` + `the skills bundle is generated from ${skillsDirectory} instead.` From 1d9e16b9a10dc7f6f60ca885f35a9ab8eacbef63 Mon Sep 17 00:00:00 2001 From: "mathieu.legault" Date: Tue, 15 Sep 2026 17:20:45 +0000 Subject: [PATCH 3/3] fix(cli): parse skill markdown with mdast and share one manifest temp dir - Replace the regex link scanner with an mdast (GFM) parse so angle-bracket destinations, balanced parentheses, percent-encoding, and reference definitions are all checked for escaping the skill directory. - Write the generated index.json into one content-addressed temp directory per process instead of a fresh mkdtemp per rebuild. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- packages/cli/docs-resolver/package.json | 5 ++ .../src/DocsDefinitionResolver.ts | 11 +-- .../src/utils/__test__/declaredSkills.test.ts | 56 +++++++++++- .../docs-resolver/src/utils/declaredSkills.ts | 85 ++++++++++++------- pnpm-lock.yaml | 15 ++++ 5 files changed, 132 insertions(+), 40 deletions(-) diff --git a/packages/cli/docs-resolver/package.json b/packages/cli/docs-resolver/package.json index 6e5beca3035e..1f28fa6a1e37 100644 --- a/packages/cli/docs-resolver/package.json +++ b/packages/cli/docs-resolver/package.json @@ -58,13 +58,18 @@ "js-yaml": "catalog:", "mime-types": "catalog:", "lodash-es": "catalog:", + "mdast-util-from-markdown": "catalog:", + "mdast-util-gfm": "catalog:", + "micromark-extension-gfm": "catalog:", "openapi-types": "^12.1.3", "tmp-promise": "catalog:", + "unist-util-visit": "catalog:", "url-join": "catalog:" }, "devDependencies": { "@fern-api/configs": "workspace:*", "@types/js-yaml": "catalog:", + "@types/mdast": "catalog:", "@types/mime-types": "catalog:", "@types/lodash-es": "catalog:", "@types/node": "catalog:", diff --git a/packages/cli/docs-resolver/src/DocsDefinitionResolver.ts b/packages/cli/docs-resolver/src/DocsDefinitionResolver.ts index 46c6559f50e8..69b8fd364f09 100644 --- a/packages/cli/docs-resolver/src/DocsDefinitionResolver.ts +++ b/packages/cli/docs-resolver/src/DocsDefinitionResolver.ts @@ -34,12 +34,10 @@ import { AbstractAPIWorkspace, DocsWorkspace, FernWorkspace } from "@fern-api/wo import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; import { existsSync } from "fs"; -import { mkdtemp, readFile, writeFile } from "fs/promises"; +import { readFile } from "fs/promises"; import matter from "gray-matter"; import jsYaml from "js-yaml"; import { camelCase, kebabCase } from "lodash-es"; -import { tmpdir } from "os"; -import path from "path"; /** * Shape of navigation nodes in `_navigation.yml`, generated by `library-docs-generator`. @@ -93,7 +91,7 @@ import { convertIrToApiDefinition } from "./utils/convertIrToApiDefinition.js"; import { DECLARED_SKILLS_UPLOAD_DIRECTORY, discoverDeclaredSkills, - generateSkillsIndexManifest + writeSkillsIndexManifest } from "./utils/declaredSkills.js"; import { collectFilesFromDocsConfig } from "./utils/getImageFilepathsToUpload.js"; import { resolveLinksInObject, updateApiDefinitionIdInTree } from "./utils/resolveDescriptionLinks.js"; @@ -923,11 +921,8 @@ export class DocsDefinitionResolver { const uploads: FilePathPair[] = []; - const temporaryDirectory = await mkdtemp(path.join(tmpdir(), "fern-agent-skills-")); - const indexJsonAbsoluteFilePath = AbsoluteFilePath.of(path.join(temporaryDirectory, "index.json")); - await writeFile(indexJsonAbsoluteFilePath, generateSkillsIndexManifest(skills)); uploads.push({ - absoluteFilePath: indexJsonAbsoluteFilePath, + absoluteFilePath: await writeSkillsIndexManifest(skills), relativeFilePath: RelativeFilePath.of(`${DECLARED_SKILLS_UPLOAD_DIRECTORY}/index.json`) }); diff --git a/packages/cli/docs-resolver/src/utils/__test__/declaredSkills.test.ts b/packages/cli/docs-resolver/src/utils/__test__/declaredSkills.test.ts index 308cc915326d..3a7cab29b1b2 100644 --- a/packages/cli/docs-resolver/src/utils/__test__/declaredSkills.test.ts +++ b/packages/cli/docs-resolver/src/utils/__test__/declaredSkills.test.ts @@ -1,10 +1,10 @@ import { AbsoluteFilePath, relative } from "@fern-api/fs-utils"; -import { mkdir, mkdtemp, rm, writeFile } from "fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "fs/promises"; import { tmpdir } from "os"; import path from "path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { discoverDeclaredSkills, generateSkillsIndexManifest } from "../declaredSkills.js"; +import { discoverDeclaredSkills, generateSkillsIndexManifest, writeSkillsIndexManifest } from "../declaredSkills.js"; describe("discoverDeclaredSkills", () => { let skillsDirectory: AbsoluteFilePath; @@ -232,6 +232,43 @@ describe("discoverDeclaredSkills", () => { expect(skills.map((skill) => skill.name)).toEqual(["my-skill"]); }); + it("flags escaping references written with angle brackets, balanced parentheses, or link definitions", async () => { + await writeFileAt( + "my-skill/SKILL.md", + skillMarkdown( + "my-skill", + [ + "[spaces](<../shared notes.md>)", + "[parens](../shared/notes%20(draft).md)", + "![image][logo]", + "", + "[logo]: ../assets/logo.png" + ].join("\n") + ) + ); + + const { violations } = await discoverDeclaredSkills({ absolutePathToSkillsDirectory: skillsDirectory }); + + expect(violations.map((violation) => violation.message)).toEqual([ + expect.stringContaining('Reference "../shared notes.md" escapes'), + expect.stringContaining('Reference "../shared/notes (draft).md" escapes'), + expect.stringContaining('Reference "../assets/logo.png" escapes') + ]); + }); + + it("parses prompt-style markdown that is not valid MDX", async () => { + await writeFileAt( + "my-skill/SKILL.md", + skillMarkdown("my-skill", "Replace {placeholder} with and see [notes](notes.md).\n") + ); + await writeFileAt("my-skill/notes.md", "# notes\n"); + + const { skills, violations } = await discoverDeclaredSkills({ absolutePathToSkillsDirectory: skillsDirectory }); + + expect(violations).toEqual([]); + expect(skills.map((skill) => skill.name)).toEqual(["my-skill"]); + }); + it("does not treat files inside a skill as nested skills", async () => { await writeFileAt("my-skill/SKILL.md", skillMarkdown("my-skill")); await writeFileAt("my-skill/examples/SKILL.md", "not frontmatter"); @@ -245,4 +282,19 @@ describe("discoverDeclaredSkills", () => { "examples/SKILL.md" ]); }); + + it("writes the manifest to one shared temp directory, reusing the file for identical content", async () => { + await writeFileAt("my-skill/SKILL.md", skillMarkdown("my-skill")); + await writeFileAt("other-skill/SKILL.md", skillMarkdown("other-skill")); + const { skills } = await discoverDeclaredSkills({ absolutePathToSkillsDirectory: skillsDirectory }); + + const first = await writeSkillsIndexManifest(skills); + const again = await writeSkillsIndexManifest(skills); + const different = await writeSkillsIndexManifest(skills.slice(0, 1)); + + expect(again).toEqual(first); + expect(different).not.toEqual(first); + expect(path.dirname(different)).toEqual(path.dirname(first)); + expect(await readFile(first, "utf-8")).toEqual(generateSkillsIndexManifest(skills)); + }); }); diff --git a/packages/cli/docs-resolver/src/utils/declaredSkills.ts b/packages/cli/docs-resolver/src/utils/declaredSkills.ts index 782892e71cb1..edabb54c5827 100644 --- a/packages/cli/docs-resolver/src/utils/declaredSkills.ts +++ b/packages/cli/docs-resolver/src/utils/declaredSkills.ts @@ -9,8 +9,16 @@ import { relative, resolve } from "@fern-api/fs-utils"; -import { readdir, readFile } from "fs/promises"; +import { createHash } from "crypto"; +import { mkdtemp, readdir, readFile, writeFile } from "fs/promises"; import grayMatter from "gray-matter"; +import type { Root as MdastRoot } from "mdast"; +import { fromMarkdown } from "mdast-util-from-markdown"; +import { gfmFromMarkdown } from "mdast-util-gfm"; +import { gfm } from "micromark-extension-gfm"; +import { tmpdir } from "os"; +import path from "path"; +import { visit } from "unist-util-visit"; /** * Upload destination for agent skills declared via `page-actions.options.skills.path` in @@ -26,8 +34,6 @@ export const SKILL_NAME_KEBAB_CASE_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/; const SKILL_MARKDOWN_FILENAME = "SKILL.md"; const MARKDOWN_FILE_EXTENSIONS = [".md", ".mdx"]; -/** Matches markdown link and image targets: `[text](target)` and `![alt](target)`. */ -const MARKDOWN_LINK_TARGET_REGEX = /!?\[[^\]]*\]\(\s*\s]+)>?\s*(?:"[^"]*")?\)/g; export interface DeclaredSkillFile { absoluteFilePath: AbsoluteFilePath; @@ -152,6 +158,26 @@ export function generateSkillsIndexManifest(skills: DeclaredSkill[]): string { ); } +let manifestDirectory: Promise | undefined; + +/** + * Writes the generated `index.json` to disk so it can join the publish payload like any other + * file. Uploaders read the path later (ledger blob upload, `fern docs dev` serving `/_local/…`), + * so the file must outlive this call: one temp directory is shared for the whole process and + * manifests are content-addressed, so `fern docs dev` rebuilds reuse a file instead of piling up + * a new directory per reload. + */ +export async function writeSkillsIndexManifest(skills: DeclaredSkill[]): Promise { + manifestDirectory ??= mkdtemp(path.join(tmpdir(), "fern-agent-skills-")); + const manifest = generateSkillsIndexManifest(skills); + const contentHash = createHash("sha256").update(manifest).digest("hex").slice(0, 16); + const manifestPath = AbsoluteFilePath.of(path.join(await manifestDirectory, `index-${contentHash}.json`)); + if (!(await doesPathExist(manifestPath, "file"))) { + await writeFile(manifestPath, manifest); + } + return manifestPath; +} + /** * Finds every directory under `directory` (recursively) that contains a SKILL.md. A skill * directory owns everything beneath it, so the walk does not descend into one looking for @@ -335,39 +361,29 @@ function referenceEscapesSkillDirectory({ } /** - * Removes fenced code blocks (``` / ~~~) and inline code spans (`…`) so that link and image - * syntax shown as an *example* inside documentation isn't mistaken for a live reference. A - * `../foo` in a code sample is not a real link and must not be flagged as escaping the skill. + * Skills are plain (GFM) markdown, not MDX: parsing them with the docs MDX pipeline would choke + * on prompt text like `{placeholder}` or ``. The parser handles code fences and inline + * code natively, so link syntax shown as an example is never mistaken for a live reference. */ -function stripCodeSpansAndFences(markdown: string): string { - const lines = markdown.split("\n"); - const stripped: string[] = []; - let openFence: string | undefined; - for (const line of lines) { - const fenceChar = line.match(/^\s*(`{3,}|~{3,})/)?.[1]?.[0]; - if (openFence == null) { - if (fenceChar != null) { - openFence = fenceChar; - continue; - } - // drop inline code spans (`code`, ``co`de``) before this line is scanned - stripped.push(line.replace(/`+[^`]*`+/g, "")); - } else if (fenceChar === openFence) { - openFence = undefined; - } - } - return stripped.join("\n"); +function parseSkillMarkdown(markdown: string): MdastRoot { + return fromMarkdown(grayMatter(markdown).content, { + extensions: [gfm()], + mdastExtensions: [gfmFromMarkdown()] + }); } function extractRelativeMarkdownReferences(markdown: string): string[] { - const targets: string[] = []; - for (const match of stripCodeSpansAndFences(markdown).matchAll(MARKDOWN_LINK_TARGET_REGEX)) { - const rawTarget = match[1]; - if (rawTarget == null) { - continue; + const rawTargets: string[] = []; + visit(parseSkillMarkdown(markdown), ["link", "image", "definition"], (node) => { + if (node.type === "link" || node.type === "image" || node.type === "definition") { + rawTargets.push(node.url); } + }); + + const targets: string[] = []; + for (const rawTarget of rawTargets) { // strip anchors and query strings - const target = rawTarget.split("#")[0]?.split("?")[0] ?? ""; + const target = decodePathSegmentsIfPossible(rawTarget.split("#")[0]?.split("?")[0] ?? ""); if (target.length === 0 || isExternalOrAbsoluteReference(target)) { continue; } @@ -376,6 +392,15 @@ function extractRelativeMarkdownReferences(markdown: string): string[] { return targets; } +/** Markdown destinations may be percent-encoded (`my%20notes.md`); malformed escapes are kept verbatim. */ +function decodePathSegmentsIfPossible(target: string): string { + try { + return decodeURIComponent(target); + } catch { + return target; + } +} + function isExternalOrAbsoluteReference(target: string): boolean { // scheme-qualified (https:, mailto:, etc.), protocol-relative, or site-absolute references // never resolve against the skill directory diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5032f64dc3a5..e591a9203b9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5621,6 +5621,15 @@ importers: lodash-es: specifier: 'catalog:' version: 4.18.1 + mdast-util-from-markdown: + specifier: 'catalog:' + version: 2.0.3 + mdast-util-gfm: + specifier: 'catalog:' + version: 3.1.0 + micromark-extension-gfm: + specifier: 'catalog:' + version: 3.0.0 mime-types: specifier: 'catalog:' version: 2.1.35 @@ -5630,6 +5639,9 @@ importers: tmp-promise: specifier: 'catalog:' version: 3.0.3 + unist-util-visit: + specifier: 'catalog:' + version: 5.1.0 url-join: specifier: ^4.0.1 version: 4.0.1 @@ -5643,6 +5655,9 @@ importers: '@types/lodash-es': specifier: 'catalog:' version: 4.17.12 + '@types/mdast': + specifier: 'catalog:' + version: 4.0.4 '@types/mime-types': specifier: 'catalog:' version: 2.1.4