Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion docs-yml.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/<skill-name>/…` so that\n`npx skills add https://<docs-domain>` works. Nothing is written back to the repo."
},
"title": {
"oneOf": [
{
Expand Down Expand Up @@ -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",
Expand Down
15 changes: 12 additions & 3 deletions fern/apis/docs-yml/definition/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>
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/<skill-name>/…` so that
`npx skills add https://<docs-domain>` works. Nothing is written back to the repo.
title:
type: optional<string>
docs: Overrides the modal title.
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/cli/changes/unreleased/feat-skills-declared-path.yml
Original file line number Diff line number Diff line change
@@ -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/<name>/…` so that
`npx skills add https://<docs-domain>` 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
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟥 Skills path can expose host files

An absolute or escaping skills.path makes discoverDeclaredSkills read outside the repository. A matching SKILL.md lets publishing serve that directory's files publicly.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't a trust boundary the CLI can enforce: docs.yml is authored by the same person who owns the repo and runs fern generate, and they can already publish any host file by copying it into fern/ (or referencing it as an image). ../ is intentionally allowed so a fern/docs.yml can point at a repo-root .agents/skills/ shared with local coding agents, and clamping to the git root would break the monorepo case where fern/ and the skills live in different sub-packages. Publishing also requires a SKILL.md with valid frontmatter in every served directory, so accidentally pointing at / or ~ fails validation rather than uploading. Leaving as-is; happy to add a warning for absolute paths if reviewers want a nudge.

}
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/<skill-name>/…` so that
* `npx skills add https://<docs-domain>` works. Nothing is written back to the repo.
*/
path?: string;
/** Overrides the modal title. */
title?: string;
/** Overrides the modal description/blurb. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand All @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/docs-resolver/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand Down
Loading
Loading