Skip to content

feat(cli): publish agent skills from a declared docs.yml path - #17751

Open
matlegault wants to merge 3 commits into
mainfrom
devin/1789492095-skills-declared-path
Open

matlegault wants to merge 3 commits into
mainfrom
devin/1789492095-skills-declared-path

Conversation

@matlegault

@matlegault matlegault commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Description

Description

Linear ticket: Refs — none

Rebase of #16510 (closed, stale since July) onto current main, with the review gaps from that PR addressed.

Adds page-actions.options.skills.path to docs.yml. It points at a directory of agent skills in the docs repo (resolved relative to the folder containing docs.yml; ../ allowed, e.g. a repo-root .agents/skills/). At fern generate --docs the CLI:

  1. discovers every <path>/<name>/SKILL.md, validates the bundle (kebab-case name ≤64 chars matching its directory, non-empty description ≤1024 chars, unique names, no markdown references escaping the skill dir),
  2. generates the .well-known/skills/index.json discovery manifest ({ skills: [{ name, description, files }] }, the v0.1.0 layout npx skills add <docs-url> consumes),
  3. uploads every skill file at .well-known/skills/<name>/… alongside the docs bundle.

Nothing is written back to the repo. The existing raw passthrough of fern/.well-known/skills/ still applies when no path is declared; when one is declared, a hand-populated .well-known/skills/ folder is ignored with a warning.

path is CLI-only: parseDocsConfiguration resolves it to pageActions.options.skillsDirectory (an AbsoluteFilePath that never reaches FDR), and skills on the wire keeps the same shape as before.

Compatibility with fern-platform today

  • Platform (well-known-skills-transport.ts, docs-router/skills.ts) serves both .well-known/agent-skills (v0.2.0) and .well-known/skills (v0.1.0) from the uploaded files; the skills CLI probes agent-skills first and falls back to skills. Emitting the v0.1.0 layout is therefore still consumable end to end; moving the generated output to v0.2.0 (url + digest per skill) is a possible follow-up, not a blocker.
  • Because the resolver passes .well-known/agent-skills through verbatim even when path is declared, valid-well-known-skills now validates that directory too (this was the open finding on feat(cli): generate .well-known agent skills bundle from docs.yml skills path #16510).
  • The original PR deprecated/dropped repository from the skills page-action config. That is reverted here: SkillsModal.tsx on fern-platform/app still renders config.repository as the "View source" link, so removing it from the CLI would silently break existing sites. repository stays accepted, validated (URL), and forwarded.

Changes Made

  • fern/apis/docs-yml/definition/docs.yml + generated schemas/types: new optional skills.path
  • configuration-loader/parseDocsConfiguration.ts: resolves pathskillsDirectory
  • docs-resolver/utils/declaredSkills.ts (new): discovery, validation, manifest generation. Markdown references are extracted from the GFM AST (mdast-util-from-markdown + mdast-util-gfm), not a regex, so <…> targets, balanced parentheses, percent-encoding and [ref]: … definitions are all checked. The generated index.json is written once per content hash into a single memoized temp dir per process.
  • docs-resolver/utils/collectWellKnownSkillsFiles.ts: named constants for both well-known dirs; collector accepts a directory list
  • docs-resolver/DocsDefinitionResolver.ts: uploads generated skills + agent-skills passthrough (only when real uploads are enabled, so validation-only reads don't touch the FS)
  • docs-validator rules valid-well-known-skills (declared-path validation, agent-skills validation, conflict warning) and valid-skills-page-action (empty path error)
  • packages/cli/cli/changes/unreleased/feat-skills-declared-path.yml
  • Updated README.md generator (not applicable)

Testing

  • Unit tests added/updated — pnpm turbo run test --filter @fern-api/docs-resolver --filter @fern-api/docs-validator --filter @fern-api/configuration-loader: 21/21 files each, 338 + 148 + 234 tests passing
  • pnpm turbo run compile for the affected packages; biome check/biome lint --error-on-warnings/biome format on the changed files; prettier on changed yml
  • fern check --warnings on Document page-actions.options.skills.path and publish the fern-docs skill from this site docs#6961 with the CLI built from this branch (pnpm fern:build): Found 0 errors (warnings pre-existing)
  • Post-deploy test against a dev docs site: proposed as a fern-dev-tests/tests/agent-skills-declared-path.spec.ts spec in fern-platform; can only go green once fern-dev ships this change

Companion docs change: fern-api/docs#6961 (draft; blocked on the CLI release that ships this, since fern.config.json there pins 5.100.0).

Link to Devin session: https://app.devin.ai/sessions/d920802193244dc58438f544c0cfb233
Open in Devin Desktop: https://app.devin.ai/desktop/session/d920802193244dc58438f544c0cfb233?variant=devin
Requested by: @matlegault


Devin Review

…ctions.options.skills.path)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@nitpickybot nitpickybot Bot left a comment

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.

AI Review Summary

Adds a declared skills path to docs.yml with discovery/validation/manifest generation and upload wiring. The core design looks reasonable, but the uploadFiles default change silently turns publish paths that previously relied on the default into non-upload mode for skills, and the generated index.json temp file is never cleaned up. A couple of smaller robustness/readability items too.

  • 🟡 1 warning(s)
  • 🔵 2 suggestion(s)

AI Review

🟡 warning — packages/cli/docs-resolver/src/DocsDefinitionResolver.ts (line 253)

Changing uploadFiles from a defaulted parameter to an optional one makes uploadsEnabled a proxy for "caller explicitly passed uploadFiles". Any existing caller that relied on defaultUploadFiles (i.e. omitted the arg but does publish) now silently loses agent-skills uploads, and the filesToUploadSet files still get uploaded via the default — so the failure is partial and silent.

Safer to key off an explicit intent flag (e.g. a skipAgentSkills/validationOnly option set by the validation rules) rather than inferring it from argument presence.

🔵 suggestion — packages/cli/docs-resolver/src/DocsDefinitionResolver.ts (line 917)

existsSync(join(fern, WELL_KNOWN_SKILLS_DIRECTORY)) is a sync FS call in an otherwise async method; doesPathExist (already used by collectWellKnownSkillsFiles) keeps it consistent and non-blocking.


This review is complete for the current scope. Addressing all critical and warning findings above satisfies the review; suggestions are optional. Request another review only if subsequent changes introduce new behavior or materially expand the pull request's scope.

Comment on lines +926 to +928
const temporaryDirectory = await mkdtemp(path.join(tmpdir(), "fern-agent-skills-"));
const indexJsonAbsoluteFilePath = AbsoluteFilePath.of(path.join(temporaryDirectory, "index.json"));
await writeFile(indexJsonAbsoluteFilePath, generateSkillsIndexManifest(skills));

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.

🔵 suggestion

The mkdtemp directory holding the generated index.json is never removed. Each fern generate --docs run leaks a temp dir into the OS tmp dir (small, but unbounded across CI runs). Consider registering cleanup after the upload completes, or writing the manifest into an existing run-scoped temp dir that's already cleaned up.

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.

Deliberately left alone: in ledger mode publishDocs doesn't read file contents inside uploadFiles — it records ledgerFilePaths (hash → absolute path) and re-reads the files later in uploadMissingBlobs, after resolve() has returned. Removing the temp dir inside the resolver would therefore delete index.json before it's actually uploaded. The dir holds a single small JSON file under the OS tmp dir (which CI runners discard per job), so I'd rather leave the lifetime to the OS than add a process-exit hook here. Happy to revisit if publishDocs grows a run-scoped temp dir we can reuse.

Switched the existsSync call to doesPathExist in ca4a84f.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor

Re the uploadFiles warning: checked every constructor call site. The only two callers that actually publish (remote-workspace-runner/publishDocs.ts, docs-preview/previewDocs.ts) pass an explicit uploadFiles; write-docs-definition, valid-markdown-link, missing-redirects and the resolver tests all pass undefined. defaultUploadFiles never uploads anything — it just echoes relativeFilePath back as the fileId — so "omitted the arg but does publish" is not a combination that exists today. Keeping the inference-from-presence for now to avoid widening the constructor surface; if a third publish path appears, an explicit flag is the right move.

@devin-ai-integration devin-ai-integration Bot left a comment

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.

Devin Review found 5 potential issues.

2 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

Comment on lines +655 to +657
const filesToUpload: FilePathPair[] = [
...agentSkillsUploads,
...Array.from(filesToUploadSet).map(

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.

🟡 Shared skill images disappear

When docs content references a skill image, filesToUpload submits one source for two destinations. Image measurement retains only the docs destination. The published skill omits the image, breaking its installed reference.

Learn more

The resolver now emits two upload records when the same absolute file is both a declared skill asset and docs content. The publish uploader builds filesMap by absolute path, then image measurement deduplicates those paths in publishDocs. Only the last destination reaches the image manifest, while both records are excluded from the non-image path. The generated well-known destination therefore receives no upload.

Example: A skill contains assets/logo.png, and a docs page embeds that same repository file. The docs image path uploads, but .well-known/skills/my-skill/assets/logo.png does not. Installing the skill leaves its logo reference broken.

Recommended fix: Preserve uploads by destination rather than deduplicating images solely by absoluteFilePath. Each FilePathPair.relativeFilePath needs its own image manifest entry, while image dimensions and hashes can still be cached by source path.

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.

Confirmed the behaviour: publishDocs keys filesMap/measuredImages by absoluteFilePath, so when the same image is both a declared-skill asset and embedded in docs content, only the docs destination survives and the .well-known/skills/<name>/… copy is never uploaded. It only triggers when a docs page reaches into the skills directory (e.g. ../.agents/skills/plant-care/assets/logo.png), which is unusual, and the fix belongs in the uploader (dedupe measurement/hashing by source path, emit one manifest entry per destination) rather than in the resolver. Since that touches the publish path for every site, I'm flagging it for the maintainers to decide whether it goes here or in a follow-up rather than folding it into this PR silently.

Comment on lines +267 to +272
async function collectSkillFiles(skillDirectory: AbsoluteFilePath): Promise<DeclaredSkillFile[]> {
const files = (await getAllFilesInDirectory(skillDirectory)).map(
(absoluteFilePath): DeclaredSkillFile => ({
absoluteFilePath: AbsoluteFilePath.of(absoluteFilePath),
relativeFilePathInSkill: relative(skillDirectory, AbsoluteFilePath.of(absoluteFilePath))
})

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.

🟡 Declared skill dotfiles are omitted

When a skill contains a dotfile, collectSkillFiles silently excludes it. The shared directory walker filters dot-prefixed files, producing incomplete installed skills.

Learn more

Declared skills use a general-purpose directory walker that intentionally drops any file whose basename starts with a dot. The generated manifest derives directly from this returned list, and the upload loop uses the same list. Hidden directories are traversed, but hidden files themselves disappear without validation or a warning.

Example: A skill contains SKILL.md and .tool-config. Discovery succeeds, but index.json lists only SKILL.md, and installation never receives .tool-config.

Recommended fix: Add a skill-specific recursive collector that includes dotfiles while retaining the intended symbolic-link policy. Use its complete result for both manifest generation and uploads.

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.

Intentional, and consistent with the existing .well-known/skills passthrough, which uses the same getAllFilesInDirectory walker and has always dropped dotfiles (and symlinks). Skills are consumed by npx skills add, which fetches exactly the files listed in index.json, and the dotfiles that show up in practice in a skills directory are .DS_Store/.gitkeep, which shouldn't be published. If a real need for hidden files in a bundle surfaces, the fix is a skill-specific collector as suggested — I'd rather not diverge from the passthrough behaviour speculatively in this PR.

Comment thread packages/cli/docs-resolver/src/utils/declaredSkills.ts Outdated
Comment thread packages/cli/docs-resolver/src/DocsDefinitionResolver.ts Outdated
),
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.

… 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>
@github-actions

Copy link
Copy Markdown
Contributor

Docs Generation Benchmark Results

Comparing PR branch against median of 5 nightly run(s) on main (latest: 2026-09-15T04:06:32Z).

Fixture main PR Delta
docs 269.3s (n=5) 283.5s (35 versions) +14.2s (+5.3%)

Docs generation runs fern generate --docs --preview end-to-end against the benchmark fixture with 35 API versions (each version: markdown processing + OpenAPI-to-IR + FDR upload).
Delta is computed against the nightly baseline on main.
Baseline from nightly run(s) on main (latest: 2026-09-15T04:06:32Z). Trigger benchmark-baseline to refresh.
Last updated: 2026-09-15 18:00 UTC

@github-actions

Copy link
Copy Markdown
Contributor

SDK Generation Benchmark Results

Comparing PR branch against median of 5 nightly run(s) on main (latest: 2026-09-15T04:06:32Z).

Full benchmark table (click to expand)
Generator Spec main (generator) main (E2E) PR (generator) Delta
csharp-sdk square 101s (n=5) 116s (n=4) 70s -31s (-30.7%)
go-sdk square 137s (n=5) 304s (n=5) 90s -47s (-34.3%)
java-sdk square 221s (n=5) 272s (n=5) 193s -28s (-12.7%)
php-sdk square 80s (n=5) N/A 56s -24s (-30.0%)
python-sdk square 158s (n=5) 256s (n=5) 138s -20s (-12.7%)
ruby-sdk-v2 square 105s (n=5) 150s (n=5) 80s -25s (-23.8%)
rust-sdk square 187s (n=5) 214s (n=5) 162s -25s (-13.4%)
swift-sdk square 66s (n=4) 450s (n=5) 60s -6s (-9.1%)
ts-sdk square 178s (n=4) 185s (n=5) 117s -61s (-34.3%)

main (generator): generator-only time via --skip-scripts (includes Docker image build, container startup, IR parsing, and code generation — this is the same Docker-based flow customers use via fern generate). main (E2E): full customer-observable time including build/test scripts (nightly baseline, informational). Delta is computed against generator-only baseline.
⚠️ = generation exited with a non-zero exit code (timing may not reflect a successful run).
Baseline from nightly runs on main (latest: 2026-09-15T04:06:32Z). Trigger benchmark-baseline to refresh.
Last updated: 2026-09-15 18:01 UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant