diff --git a/.agents/agents/catalogue-auditor.agent.md b/.agents/agents/catalogue-auditor.agent.md index 8db4b47..d57a94b 100644 --- a/.agents/agents/catalogue-auditor.agent.md +++ b/.agents/agents/catalogue-auditor.agent.md @@ -54,7 +54,8 @@ Load and apply these skills: exclusions that cover `_Sidebar.md`/`index.md` stubs, and a non-zero aggregated page count. 4. **Stability** — `status` matches the repository `archived` flag and the NuGet release channel. 5. **Metadata and tags** — repository description present and useful, topics non-empty (they are the - site's tag chips), homepage/link targets correct, `discussions` matches the repository flag. + site's tag chips), homepage/link targets correct, and `discussions` left unset (it is detected + from the repository, so only an explicit override may disagree with it). 6. **Packages** — every declared package id exists on NuGet with published versions, exactly one `primary`, and `targetFrameworks` are plausible for the repository. 7. **Use cases** — at least one per non-archived project, valid audiences, `code` paired with diff --git a/.agents/agents/catalogue-onboarder.agent.md b/.agents/agents/catalogue-onboarder.agent.md index bea933d..48acbc2 100644 --- a/.agents/agents/catalogue-onboarder.agent.md +++ b/.agents/agents/catalogue-onboarder.agent.md @@ -58,8 +58,8 @@ The most important rules are: `category` and `status` from the schema vocabulary (never invent values). 4. **Author** — add the record in the correct `order`, with `docs`, `packages`, `targetFrameworks`, `install`, `related`/`acknowledgments` where they apply, and at least one use case. -5. **Fix upstream metadata** — set GitHub topics (tags), make the description useful, enable - discussions only when `discussions: true` is declared. +5. **Fix upstream metadata** — set GitHub topics (tags), make the description useful, and leave + `discussions` unset: the site detects it from the repository. 6. **Integrate and prove** — run `just data-sync`, then `just check-projects`, `bun run typecheck`, `bun run test`, `just check-generated`, and finally `just validate`. Confirm the docs page count, the sidebar topic, the `/projects//llms.txt` bundle, and the catalogue/use-case cards. diff --git a/.agents/skills/add-catalogue-project/SKILL.md b/.agents/skills/add-catalogue-project/SKILL.md index f728454..73a7521 100644 --- a/.agents/skills/add-catalogue-project/SKILL.md +++ b/.agents/skills/add-catalogue-project/SKILL.md @@ -121,7 +121,6 @@ Add the entry as `src/src/data/projects/.yml` (see `project-manifest-referen evidence: >- docsPage: - discussions: false ``` Every non-archived project needs at least **one** use case with `code` + `language` and an @@ -133,7 +132,8 @@ Every non-archived project needs at least **one** use case with `code` + `langua `github-repo-metadata` for the exact command and the taxonomy guidance. - The repository description must be genuinely descriptive: it backs the `shortDescription` fallback and social/`og:` metadata. -- Only set `discussions: true` once GitHub Discussions is actually enabled on the repository. +- Leave `discussions` unset: the loader detects it from the repository (`hasDiscussions`). Set + `discussions: false` only to opt a repository out, or `true` to force the link. ## Step 5 — integrate and verify diff --git a/.agents/skills/github-repo-metadata/SKILL.md b/.agents/skills/github-repo-metadata/SKILL.md index 9f7a78b..c346bc1 100644 --- a/.agents/skills/github-repo-metadata/SKILL.md +++ b/.agents/skills/github-repo-metadata/SKILL.md @@ -31,7 +31,7 @@ Everything else in the table below is build-time. | Successor/archived messaging | GitHub **archived** flag + `status`/`supersededBy` | | | Version numbers, release dates, badges | GitHub releases + NuGet flat-container index | | | Downloads / deprecated / listed | NuGet search entry | Rendered on the release tables | -| Discussions link | manifest `discussions` **and** repository discussion settings | The loader only builds the URL when `discussions: true` | +| Discussions link | repository discussion settings (detected) | `hasDiscussions` from the cache; a record's `discussions` is only an override | `search` corpora on `/projects/` deliberately include the topics, so missing topics make a project undiscoverable by its domain keywords. diff --git a/.agents/skills/project-manifest-reference/SKILL.md b/.agents/skills/project-manifest-reference/SKILL.md index d2bd5cd..b8dd136 100644 --- a/.agents/skills/project-manifest-reference/SKILL.md +++ b/.agents/skills/project-manifest-reference/SKILL.md @@ -51,12 +51,13 @@ Adding a value to any of these lists is a schema change and requires an ADR. `ex | `install` | no | Default `nuget` | | `targetFrameworks` | no | e.g. `net8.0`, `net9.0`, `net10.0`, `netstandard2.0`; `just check-projects` reports (advisory) when it does not match the union of the runtime packages' published NuGet TFMs | | `packages` | no | `{ id, description?, primary?, targetFrameworks? }[]`; defaults to `[]` | +| `skills` | no | `{ name, path?, description? }[]` — Agent Skills the project publishes from `skills//SKILL.md` (ADR 0010); mirrored to `/skills///` and rendered with the shared install block; defaults to `[]` | | `assets` | no | `{ path, output, description? }[]`; repository files mirrored into `public/` at build time (`path` is repo-relative, `output` is site-relative); defaults to `[]` | | `related` | no | Known project ids; rendered in the project page aside | | `useCases` | no | Default `[]`; at least one required for non-archived projects | | `acknowledgments` | no | `{ name, url, description? }[]`; upstream work the project credits | | `supersedes` / `supersededBy` | no | Known project ids; used for archived/successor messaging | -| `discussions` | no | Default `false`; when true the page links to repository Discussions (the repository must have them enabled) | +| `discussions` | no | **Detected, not authored**: the page links repository Discussions when the repository has them enabled (`hasDiscussions` in the release cache). Set it only to override — `false` opts a repository out | ## `docs` (a discriminated union on `source`) @@ -127,8 +128,8 @@ docs: ## Defaults applied by the loader `featured: false`, `order: 1000`, `packages: []`, `related: []`, `useCases: []`, -`acknowledgments: []`, `discussions: false`, `install: 'nuget'`, `experimental: false`. Derived values (`repoOwner`, -`repoName`, `sourceUrl`, `issuesUrl`, `discussionsUrl`, `changelogUrl`, `releasesUrl`) are computed, +`acknowledgments: []`, `install: 'nuget'`, `experimental: false`. Derived values (`repoOwner`, +`repoName`, `sourceUrl`, `issuesUrl`, `discussions`, `discussionsUrl`, `changelogUrl`, `releasesUrl`) are computed, never authored. ## `externalProjects[]` fields diff --git a/.gitignore b/.gitignore index 69c72f8..b04df9a 100644 --- a/.gitignore +++ b/.gitignore @@ -699,3 +699,6 @@ sketch BenchmarkDotNet.Artifacts/ .tools/purview-build/** + +# Generated: Agent Skills mirrored from the product repositories by `just data-sync`. +src/public/skills/ diff --git a/AGENTS.md b/AGENTS.md index c007a1a..1a47e9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,7 @@ from the repository root and delegated to the package. | `src/src/content/docs/**` | Aggregated documentation mirror (gitignored) | `just data-sync` | | `src/.cache/docs`, `src/.cache/releases` | Typed docs/release caches (gitignored) | `just data-sync` / `just fetch-releases` | | `src/public/schemas/**` | Mirrored project assets declared in `assets:` (gitignored) | `just data-sync` | +| `src/public/skills/**` | Mirrored Agent Skills declared in `skills:` (gitignored) | `just data-sync` | | `src/dist/**` | Production build output (gitignored) | `just build` | ### Authoritative validation @@ -90,6 +91,11 @@ prints advisory observations (which never fail the build) when a project's decla automatic: the prerelease channel is withheld once a stable release (GitHub release or NuGet version) catches up with the prerelease line, and returns with the next prerelease — never delete the declaration to stop publishing it. + - Agent Skills a project publishes are declared in `skills:` (ADR 0010) at + `skills//SKILL.md`; the sync mirrors the whole directory to + `/skills///` and every page renders the same install block + (`npx skills add /`). Never ship a skill in a package or copy it + into an agent directory at build time, and never hand-edit `src/public/skills/`. - `order` values are unique and control the catalogue order. - `related`, `supersedes`, `supersededBy` must reference known ids. - `status` must match reality: an archived repository is `archived`; prerelease-only tooling is diff --git a/README.md b/README.md index f873bf0..003ef06 100644 --- a/README.md +++ b/README.md @@ -330,6 +330,31 @@ writes a `noindex` meta-refresh page for each one that points at the live page with the same slug (GitHub Pages serves files only, so there is no redirect config to declare). +## Agent skills + +Purview projects publish Agent Skills at `skills//SKILL.md` and deliver +them with the skills CLI (`npx skills add /`); nothing ships a +skill in a NuGet package. The catalogue declares the skills a project authors, +`just data-sync` mirrors each whole skill directory to `public/skills// +/…`, and the site presents them two ways (ADR 0010): + +- `/skills/` lists every skill grouped by project, with the install instructions + and a direct link to each `SKILL.md`; +- `/skills.json` is the machine-readable index (install commands, URLs, files). + +The install block is one component (`components/SkillInstall.astro`), rendered +unchanged on `/skills/` and on every project page that declares skills, so the +instructions are identical across the suite: + +```yaml +skills: + - name: pm-park + - name: pm-shape +``` + +`just check-projects` fails when a declared skill has no mirror, and +`just check-generated` fails when one is missing from the build. + ## Release data Release data is fetched at build time — never from the visitor's browser. diff --git a/docs/decisions/0010-agent-skills.md b/docs/decisions/0010-agent-skills.md new file mode 100644 index 0000000..2a60edb --- /dev/null +++ b/docs/decisions/0010-agent-skills.md @@ -0,0 +1,67 @@ +# 0010 — Agent Skills on the portal + +- **Status:** Accepted +- **Date:** 2026-03-11 +- **Supersedes:** none + +## Context + +Purview projects publish Agent Skills: guidance an AI coding agent loads on +demand. The ecosystem already agrees on the shape (the engineering playbook's +*Agent Skills Distribution* records it): + +- a skill is authored **once**, at `skills//SKILL.md` in the repository + that owns the knowledge, with optional `references/`, `templates/` and peer + files beside it; +- it reaches consumers by **one** route — the skills CLI + (`npx skills add /`), which clones the repository, writes into + each agent's own directories (`-a `, `-g` for user level) and records + what it installed in `skills-lock.json`; +- nothing packs a skill into a NuGet package, and no build mirrors skills into + an agent directory. + +The portal had no skills story at all. `pm-skills` is a pure skills project whose +project page shows only its README, and several product projects author skills of +their own (`aspire-resourcekit`, `results`, …) without the site saying so. A +reader therefore met two different answers ("install the package" on one page, +nothing on another) and could not read a skill without cloning its repository. + +## Decision + +1. **The catalogue declares the skills a project publishes.** `skills[].name` + (a slug) and an optional `path` (default `skills/`) name the directory; + `description` may override the skill's own front matter. Names are unique per + project and validated by the loader. +2. **The build mirrors every declared skill to purview.dev.** + `just data-sync` reads the repository tree, copies the whole skill directory + to `public/skills///…` and caches it under `.cache/skills/`, + so `https://purview.dev/skills///SKILL.md` (and its + references) is a real, stable URL. `public/skills/` is generated and + gitignored like `public/schemas/`. +3. **One shared instruction block, everywhere.** A single component + (`components/SkillInstall.astro`) renders the command, the `#` pinning + syntax and the `skills-lock.json` restore note. It is used unchanged on + `/skills/`, on each project page that declares skills, and in + `/skills.json` — so the instructions cannot drift between projects. +4. **`/skills/` is the browse and install page**, grouped by project, and + `/skills.json` is the machine-readable index (name, description, directory, + entry URL, file list, install commands). +5. **Guards, not hopes.** `just check-projects` fails when a declared skill has + no mirror, `just check-generated` fails when a declared skill is missing from + the built site, and the sync fails when a declared skill resolves to no files + at its repository path. + +## Consequences + +- `pm-skills` gains fourteen declarations; `aspire-resourcekit` and `results` gain + five each, so the same install block now appears on their project pages and on + `/skills/` with their own repositories. +- The site never claims to *host* the CLI source: it serves the skill files. The + CLI still installs from the git repository, which is what records a pinnable + ref; the portal URLs exist so a skill can be read, reviewed or fetched without + a clone. +- `public/skills/**` is generated output. Editing it is as wrong as editing the + docs mirror; `just data-sync` owns it and `just clean` removes it. +- A new skill is a manifest entry plus the repository directory. A skill that + moves breaks the build until the manifest follows, which is the intended + failure mode. diff --git a/package.json b/package.json index 1e0db2f..c110a3c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "purview-dev", - "version": "0.7.1", + "version": "0.8.0", "private": true, "description": "Purview-Dev public website and unified documentation portal (workspace root).", "license": "MIT", diff --git a/src/scripts/check-generated.ts b/src/scripts/check-generated.ts index 51b4d57..bcb3688 100644 --- a/src/scripts/check-generated.ts +++ b/src/scripts/check-generated.ts @@ -13,6 +13,7 @@ import { import { extractDescription } from '../src/lib/docs/frontmatter'; import { loadProjects } from '../src/lib/manifest/load'; import { isReleaseCache, readReleaseCache } from '../src/lib/releases/cache'; +import { listMirroredSkills } from '../src/lib/skills'; const DIST = resolve('dist'); @@ -233,6 +234,31 @@ function validateProjectAssets(): void { } } +/** + * Every declared Agent Skill is mirrored from its repository into `public/` and + * copied into the built site (ADR 0010), so a missing mirror means `/skills/` + * and the project page link to a 404. + */ +function validateProjectSkills(): void { + for (const project of loadProjects()) { + const skills = listMirroredSkills(project.id); + if (skills.length === 0) { + if (project.skills.length > 0) { + fail(`No skills are mirrored for "${project.id}" although the manifest declares them.`); + } + continue; + } + for (const skill of skills) { + if (!existsSync(resolve(DIST, 'skills', project.id, skill.name, 'SKILL.md'))) { + fail( + `Missing mirrored skill: skills/${project.id}/${skill.name}/SKILL.md ` + + `(declared by "${project.id}"). Re-run \`just data-sync\`.`, + ); + } + } + } +} + async function scanForSecrets(): Promise { const files = await glob('**/*', { cwd: DIST, onlyFiles: true }); for (const file of files) { @@ -281,6 +307,7 @@ async function run(): Promise { validateProjectLlmsBundles(); validateProjectAssets(); + validateProjectSkills(); const llmsFull = readFileSync(resolve(DIST, 'llms-full.txt'), 'utf8'); const llms = readFileSync(resolve(DIST, 'llms.txt'), 'utf8'); diff --git a/src/scripts/check-projects.ts b/src/scripts/check-projects.ts index a935e7d..b092af5 100644 --- a/src/scripts/check-projects.ts +++ b/src/scripts/check-projects.ts @@ -24,6 +24,7 @@ import { } from '../src/lib/releases/target-frameworks'; import { projectReleaseSummary, projectVersionRollup } from '../src/lib/releases/transform'; import { OWNER } from '../src/lib/site'; +import { listMirroredSkills } from '../src/lib/skills'; /** * Deterministic catalogue guard (ADR 0003). @@ -291,6 +292,29 @@ function checkDocs( return pages; } +/** + * Declared Agent Skills must be mirrored (ADR 0010). The mirror is what makes a + * skill readable at `purview.dev/skills///SKILL.md`, so a + * declaration without one is a broken install link. + */ +function checkSkills(project: ResolvedProject): void { + // Skills are detected from the repository by the sync, so a project with none + // is normal. Only an explicit declaration promises a mirror. + if (project.skills.length === 0) { + return; + } + if (listMirroredSkills(project.id).length === 0) { + fail({ + project: project.id, + field: 'skills', + observed: 'no mirrored skills', + expected: `at least one skill under public/skills/${project.id}/`, + remediation: + "run `just data-sync` (live) so the skills are discovered at the project's documentation ref, or check that `skills/` exists in the repository.", + }); + } +} + /** * The `experimental` flag is an intent marker, so it stays orthogonal to the * release channel (ADR 0004). The one combination that is legal but worth a @@ -642,22 +666,16 @@ function checkRepoMetadata(project: ResolvedProject, data: ReleaseCacheData): vo }); } + // Discussions are detected from the repository, so a resolved `true` can only + // come from an explicit override that disagrees with the repository. if (project.discussions && !repo.hasDiscussions) { fail({ project: project.id, field: 'discussions', - observed: 'discussions: true', + observed: 'discussions: true (explicit override)', expected: 'GitHub Discussions enabled on the repository', - remediation: 'enable Discussions, or set `discussions: false`.', - }); - } - if (!project.discussions && repo.hasDiscussions && project.status !== 'archived') { - warn({ - project: project.id, - field: 'discussions', - observed: 'repository Discussions enabled, manifest links none', - expected: 'consider `discussions: true`', - remediation: `set \`discussions: true\` to surface ${project.repository}/discussions.`, + remediation: + 'remove the override so the link is detected automatically, or enable Discussions on the repository.', }); } } @@ -729,6 +747,7 @@ export function checkProjects(): { errors: Finding[]; warnings: Finding[] } { for (const project of projects) { const docsPages = checkDocs(project, docsManifest, data); checkUseCases(project, docsPages); + checkSkills(project); checkExperimental(project); if (data) { checkPackages(project, data); diff --git a/src/scripts/clean.ts b/src/scripts/clean.ts index 961dc79..55368ac 100644 --- a/src/scripts/clean.ts +++ b/src/scripts/clean.ts @@ -16,6 +16,7 @@ const TARGETS = [ 'public/icon-512.png', 'public/og', 'public/schemas', + 'public/skills', ]; if (import.meta.main) { diff --git a/src/scripts/data-sync.ts b/src/scripts/data-sync.ts index da30ebb..48185cd 100644 --- a/src/scripts/data-sync.ts +++ b/src/scripts/data-sync.ts @@ -1,11 +1,13 @@ import { existsSync } from 'node:fs'; import { resolve } from 'node:path'; -import { readReleaseCache, readReleaseFixture, writeReleaseCache } from '../src/lib/releases/cache'; +import { readReleaseCache, readReleaseFixture } from '../src/lib/releases/cache'; +import { publishReleaseData } from '../src/lib/releases/runtime'; import { fetchReleaseData } from './fetch-releases'; import { processBranding } from './process-branding'; import { syncAssets } from './sync-assets'; import { hasCurrentDocsMirror, hasDocsMirror, syncDocs } from './sync-docs'; +import { syncSkills } from './sync-skills'; const RELEASES_CACHE_FILE = resolve('.cache/releases/releases.json'); @@ -39,8 +41,9 @@ async function syncReleases(mode: Mode): Promise { return; } if (mode === 'live') { - const data = await fetchReleaseData(); - writeReleaseCache(data); + // `publishReleaseData` also refreshes the memoized index: the fetch resolved + // the repository/package list through `loadProjects`, which snapshots it. + publishReleaseData(await fetchReleaseData()); console.log('Releases: fetched live data.'); return; } @@ -50,8 +53,7 @@ async function syncReleases(mode: Mode): Promise { return; } try { - const data = await fetchReleaseData(); - writeReleaseCache(data); + publishReleaseData(await fetchReleaseData()); console.log('Releases: fetched live data.'); return; } catch (error) { @@ -102,6 +104,7 @@ export async function dataSync(): Promise { await syncReleases(mode); await syncDocsMirror(mode); await syncAssets(mode); + await syncSkills(mode); if (!existsSync(RELEASES_CACHE_FILE) && !readReleaseFixture('index')) { console.warn( 'Note: release data is unavailable. The releases page will be empty until `just fetch-releases` runs.', diff --git a/src/scripts/fetch-releases.ts b/src/scripts/fetch-releases.ts index 1a6995f..03bf7c2 100644 --- a/src/scripts/fetch-releases.ts +++ b/src/scripts/fetch-releases.ts @@ -1,7 +1,7 @@ import type { ReleaseCacheData } from '../src/lib/releases/types'; import { loadProjects } from '../src/lib/manifest/load'; -import { readReleaseCache, writeReleaseCache } from '../src/lib/releases/cache'; +import { readReleaseCache } from '../src/lib/releases/cache'; import { fetchGitHubReleases, fetchGitHubRepo, @@ -9,6 +9,7 @@ import { fetchNuGetNuspec, fetchNuGetSearch, } from '../src/lib/releases/github'; +import { publishReleaseData } from '../src/lib/releases/runtime'; import { selectVersionForTargetFrameworks } from '../src/lib/releases/target-frameworks'; import { RELEASE_CACHE_SCHEMA_VERSION } from '../src/lib/releases/types'; @@ -127,7 +128,7 @@ export async function fetchReleaseData(): Promise { if (import.meta.main) { const data = await fetchReleaseData(); - writeReleaseCache(data); + publishReleaseData(data); console.log( `Wrote release cache: ${Object.keys(data.repos).length} repositories, ${Object.keys(data.packages).length} packages.`, ); diff --git a/src/scripts/sync-skills.ts b/src/scripts/sync-skills.ts new file mode 100644 index 0000000..3452010 --- /dev/null +++ b/src/scripts/sync-skills.ts @@ -0,0 +1,169 @@ +import type { ResolvedProject } from '../src/lib/manifest/load'; +import type { DataMode } from './sync-assets'; + +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; + +import { fetchRawFile, listRepositoryFiles } from '../src/lib/docs/aggregate'; +import { resolveDocChannels } from '../src/lib/docs/channels'; +import { loadProjects } from '../src/lib/manifest/load'; +import { getReleaseIndex } from '../src/lib/releases/runtime'; +import { declaresSkills, skillSourcePath, SKILLS_PUBLIC_DIR } from '../src/lib/skills'; + +/** + * Mirror the Agent Skills each project publishes (ADR 0010). + * + * The skill set and its contents are read from the project's **documentation + * ref** — the live release tag when there is a live channel, otherwise the + * prerelease ref — so the skills on purview.dev always match the release the + * documentation describes. The catalogue only says "this project publishes + * skills" (`skills: true`); the list is discovered on every build, which is why + * a new skill needs no site change. + */ +export const SKILLS_CACHE_DIR = resolve('.cache/skills'); + +export interface SkillTarget { + projectId: string; + repository: string; + name: string; + sourcePath: string; + ref: string; +} + +/** Declared and/or discovered skills for one project at a given ref. */ +export function discoverSkills( + project: ResolvedProject, + tree: string[], + ref: string, +): SkillTarget[] { + const targets = new Map(); + const add = (name: string, sourcePath: string): void => { + targets.set(name, { + projectId: project.id, + repository: project.repository, + name, + sourcePath: sourcePath.replace(/\/+$/, ''), + ref, + }); + }; + if (project.discoverSkills) { + for (const path of tree) { + const match = /^skills\/([a-z0-9-]+)\/SKILL\.md$/.exec(path); + if (match?.[1]) { + add(match[1], `skills/${match[1]}`); + } + } + } + for (const skill of project.skills) { + add(skill.name, skillSourcePath(skill)); + } + return [...targets.values()].toSorted((a, b) => a.name.localeCompare(b.name)); +} + +function write(target: string, content: string): void { + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, content, 'utf8'); +} + +/** Relative paths of every file inside a directory, recursively. */ +function listFiles(directory: string, prefix = ''): string[] { + const files: string[] = []; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.isDirectory()) { + files.push(...listFiles(join(directory, entry.name), `${prefix}${entry.name}/`)); + continue; + } + files.push(`${prefix}${entry.name}`); + } + return files; +} + +function publishCached(cache: string, target: string): string[] { + if (!existsSync(cache)) { + return []; + } + const files = listFiles(cache); + for (const path of files) { + write(join(target, ...path.split('/')), readFileSync(join(cache, ...path.split('/')), 'utf8')); + } + return files; +} + +async function fetchAndPublish(skill: SkillTarget, tree: string[]): Promise { + const written: string[] = []; + const cache = resolve(SKILLS_CACHE_DIR, skill.projectId, skill.name); + const target = resolve(SKILLS_PUBLIC_DIR, skill.projectId, skill.name); + for (const path of tree.filter((entry) => entry.startsWith(`${skill.sourcePath}/`))) { + const raw = await fetchRawFile(skill.repository, skill.ref, path); + if (!raw) { + continue; + } + const relativePath = path.slice(skill.sourcePath.length + 1); + write(join(cache, ...relativePath.split('/')), raw.content); + write(join(target, ...relativePath.split('/')), raw.content); + written.push(relativePath); + } + return written; +} + +/** + * Mirror every project's skills. The cache records the ref it was fetched from, + * so a new release invalidates it automatically; `live` always re-reads. + */ +export async function syncSkills(mode: DataMode): Promise { + const releaseData = getReleaseIndex().data; + const projects = loadProjects().filter( + (project) => project.status !== 'archived' && declaresSkills(project), + ); + if (projects.length === 0) { + console.log('Skills: none declared.'); + return 0; + } + + rmSync(SKILLS_PUBLIC_DIR, { recursive: true, force: true }); + let mirrored = 0; + for (const project of projects) { + const channel = resolveDocChannels(project, releaseData).channels[0]; + const ref = channel?.ref ?? 'main'; + const tree = await listRepositoryFiles(project.repository, ref); + + for (const skill of discoverSkills(project, tree, ref)) { + const cache = resolve(SKILLS_CACHE_DIR, project.id, skill.name); + const marker = resolve(SKILLS_CACHE_DIR, project.id, `${skill.name}.ref`); + const cached = existsSync(marker) && readFileSync(marker, 'utf8').trim() === ref; + const target = resolve(SKILLS_PUBLIC_DIR, project.id, skill.name); + + let files: string[]; + if (mode !== 'live' && cached) { + files = publishCached(cache, target); + } else if (mode === 'cache' || mode === 'fixture') { + throw new Error( + `Skill ${project.id}/${skill.name} is not cached for ref "${ref}". ` + + 'Run `just live-data-sync` once so it can be fetched.', + ); + } else { + files = await fetchAndPublish(skill, tree); + } + + if (files.length === 0) { + throw new Error( + `Skill not found: ${skill.repository}/${skill.sourcePath}@${ref} ` + + `(project "${project.id}"). Publish the skill at that path or correct the manifest.`, + ); + } + write(marker, ref); + mirrored += 1; + console.log(` skill ${project.id}/${skill.name}@${ref}: ${files.length} file(s)`); + } + } + console.log(`Skills mirrored: ${mirrored} skill(s).`); + return mirrored; +} + +if (import.meta.main) { + const requested = (process.env.DATA_MODE ?? 'auto').toLowerCase(); + const mode: DataMode = ['auto', 'live', 'cache', 'fixture'].includes(requested) + ? (requested as DataMode) + : 'auto'; + await syncSkills(mode); +} diff --git a/src/src/components/SiteNav.astro b/src/src/components/SiteNav.astro index 539237c..fe57e11 100644 --- a/src/src/components/SiteNav.astro +++ b/src/src/components/SiteNav.astro @@ -17,6 +17,7 @@ export const NAV_ITEMS: readonly NavItem[] = [ { label: 'Projects', href: '/projects/' }, { label: 'Use cases', href: '/use-cases/' }, { label: 'Documentation', href: '/docs/' }, + { label: 'Skills', href: '/skills/' }, { label: 'Releases', href: '/releases/' }, { label: 'About', href: '/about/' }, ]; diff --git a/src/src/components/SkillInstall.astro b/src/src/components/SkillInstall.astro new file mode 100644 index 0000000..5ee139e --- /dev/null +++ b/src/src/components/SkillInstall.astro @@ -0,0 +1,70 @@ +--- +import { skillEntryPath, skillsInstallCommand } from '~/lib/skills'; +import { withBase } from '~/lib/urls'; + +interface Props { + /** `owner/repository` the skills are published from. */ + repository: string; + projectId: string; + skills: { name: string; description?: string }[]; + /** Render the per-skill "fetch it directly" links (default: true). */ + showLinks?: boolean; +} + +const { repository, projectId, skills, showLinks = true } = Astro.props; + +// One set of instructions for every project that authors skills (ADR 0010). +// The command, the pinning syntax and the lockfile note are identical wherever +// this component renders, so a reader never meets two versions of the truth. +const command = skillsInstallCommand(repository); +const pinned = `${command}#`; +const restore = 'npx skills experimental_install'; +--- + +
+

+ Skills are installed explicitly with the + skills CLI. Nothing ships them inside a NuGet package, and no build writes into your agent's directories: + installation is a decision you make. +

+
{command}
+
    +
  • + Installs at project level by default. Add -g for a user-level install, or + -a <agent> to target one agent. +
  • +
  • + To match the release you use, pin a commit or tag: {pinned}. +
  • +
  • + The CLI records what it installed in skills-lock.json; restore it with + {restore}. +
  • +
+ { + showLinks && skills.length > 0 && ( +

+ Every skill is also published from this site, so a tool or a reviewer can read it without + cloning the repository: + + {skills.map((skill) => ( + + {skill.name}/SKILL.md + + ))} + +

+ ) + } +
diff --git a/src/src/data/projects/openapi-xmlcomments.yml b/src/src/data/projects/openapi-xmlcomments.yml new file mode 100644 index 0000000..7e27232 --- /dev/null +++ b/src/src/data/projects/openapi-xmlcomments.yml @@ -0,0 +1,123 @@ +$schema: ../../lib/manifest/schema.ts + +id: openapi-xmlcomments +name: OpenAPI XmlComments +shortDescription: Keeps the ASP.NET Core OpenAPI XML-comment generator working when one documentation ID arrives twice. +description: >- + An MSBuild package for APIs that reference Microsoft.AspNetCore.OpenApi. The XML-comment source + generator builds one dictionary from the documentation of every project reference and of the API's + own compilation, and de-duplicates neither, so a documented type that arrives from more than one + assembly makes /openapi/v1.json answer 500 with "An item with the same key has already been + added". This package removes every colliding documentation ID from the referenced XML + documentation before compile, and warns (POAX001) about an ID it can only see once the API has + been built. The generator stays on, so every XML description stays in the OpenAPI document. +origin: >- + The same 500 appeared in several APIs at once as soon as a source generator started injecting the + same attribute into every assembly (dotnet/aspnetcore#67192). The usual workaround — removing the + generator — also removed every description from the document, so the fix became two MSBuild + targets that filter only what actually collides. +useWhen: >- + Your API references Microsoft.AspNetCore.OpenApi with its XML-comment generator on, and a + documented type with the same full name is compiled into more than one of its project references — + typically an attribute or polyfill a source generator injects into every assembly. +avoidWhen: >- + The duplicate never passes through AdditionalFiles, or both copies come from the API's own + compilation: the package cannot see those, and a type that is genuinely duplicated public API needs + one of the declarations renamed rather than filtered. +repository: purview-dev/openapi-xmlcomments +category: build-tooling +status: preview +order: 65 +docs: + source: github-path + path: docs/wiki + rootPage: Getting-Started.md + exclude: + - _Sidebar.md + - Home.md + - index.md +skills: true +targetFrameworks: + - msbuild +packages: + - id: Purview.OpenAPI.XmlComments + description: The deduplication and overlap-check MSBuild tasks, their props and targets, and POAX001; a development dependency with no runtime footprint. + primary: true + targetFrameworks: + - netstandard2.0 +related: + - results +useCases: + - audience: developer + title: Keep the generator on and filter only what collides + scenario: >- + /openapi/v1.json answers 500 with "An item with the same key has already been added" because a + type your API documents is also documented in more than one referenced project, and the only + workaround you have found turns the XML-comment generator off. + code: | + + + language: xml + outcome: >- + Every colliding documentation ID is removed from the referenced documentation before compile, the + generator runs on the filtered copies, and the description of every other endpoint and schema + stays in the document. + evidence: >- + Every occurrence of a colliding ID goes, not all but the first, because the API usually declares + the injected type as well: keeping a reference's copy would still collide with it. A duplicated + ID is ambient code rather than API surface, and a copy is only rewritten when its content + changes, so a no-change build still skips CoreCompile. + - audience: developer + title: List every colliding ID rather than the first one + scenario: >- + The exception names one documentation ID, and the build says nothing about the rest, so you do + not know whether one exclude is enough or whether the generator fails again on the next build. + code: | + pwsh skills/purview-openapi-xmlcomments-duplicates/scripts/find-duplicate-documentation-ids.ps1 ` + -Project path/to/Api + language: powershell + outcome: >- + The script asks MSBuild for exactly the documentation files the generator reads and reports every + colliding ID, where each copy comes from, whether the current configuration already handles it, + and the item to add when it does not. + evidence: >- + Each finding is classified: BetweenReferences is handled automatically by the package, while + ReferenceAndProject needs the PurviewOpenAPIXmlCommentExclude item the script prints. It exits 0 + when nothing would throw, 1 when something would, and 2 when it could not inspect the project. + docsPage: troubleshooting + - audience: team-lead + title: Fail the build on a collision instead of a 500 in production + scenario: >- + The overlap is only visible after the API compiles, so a warning is easy to ship, and the failure + then surfaces as a 500 on the OpenAPI document rather than as a red build. + code: | + + + $(MSBuildWarningsAsErrors);POAX001 + + language: xml + outcome: >- + POAX001 fails the build and names the exact PurviewOpenAPIXmlCommentExclude item to add, so an + unhandled collision is a pull-request failure instead of a runtime error. + evidence: >- + The check runs after CoreCompile and compares the API's fresh documentation file with the filtered + reference documentation, which is the one case the before-compile pass cannot see: an ID + documented in the API and in exactly one referenced project. + docsPage: diagnostics + - audience: architect + title: One repository-wide reference that costs nothing at run time + scenario: >- + Several API projects in the same repository reference the same generators, and you do not want + per-project wiring or a build fix that becomes a runtime dependency. + code: | + + + language: xml + outcome: >- + One declaration covers every project and does nothing in a project that does not reference + Microsoft.AspNetCore.OpenApi, so libraries in the same repository need no exemption. + evidence: >- + The package ships only props, targets and a netstandard2.0 task assembly under tasks/; lib/, ref/ + and runtimes/ are forbidden by pack validation, so it adds no runtime dependency and copies + nothing into the consumer's build output. + docsPage: configuration diff --git a/src/src/lib/discovery/resources.ts b/src/src/lib/discovery/resources.ts index 2b4f5c4..f5f880f 100644 --- a/src/src/lib/discovery/resources.ts +++ b/src/src/lib/discovery/resources.ts @@ -246,6 +246,16 @@ export function buildResources( if (normalized === '404' || normalized === '500') { continue; } + // Non-HTML endpoints (`/skills.json`, …) are machine-readable resources, not + // pages: they are linked from the pages that describe them and stay out of + // the HTML sitemap, like `discover.json`. + if ( + normalized.endsWith('.json') || + normalized.endsWith('.xml') || + normalized.endsWith('.txt') + ) { + continue; + } const classified = classifyPath(normalized, projectIds); let lastModified: string | undefined; if (classified.kind === 'project' && classified.projectId) { diff --git a/src/src/lib/docs/aggregate.ts b/src/src/lib/docs/aggregate.ts index 6f686e7..8f2de15 100644 --- a/src/src/lib/docs/aggregate.ts +++ b/src/src/lib/docs/aggregate.ts @@ -193,7 +193,7 @@ export async function fetchRawFile( return { path, content, lastModified }; } -async function listRepositoryFiles(repository: string, branch: string): Promise { +export async function listRepositoryFiles(repository: string, branch: string): Promise { const tree = (await githubJson(`/repos/${repository}/git/trees/${branch}?recursive=1`)) as { tree?: { path?: string }[]; }; diff --git a/src/src/lib/manifest/load.ts b/src/src/lib/manifest/load.ts index 447e82a..d9d5a9d 100644 --- a/src/src/lib/manifest/load.ts +++ b/src/src/lib/manifest/load.ts @@ -1,10 +1,12 @@ -import type { ExternalProjectRecord, ProjectManifest, ProjectRecord } from './schema'; +import type { GitHubRepoInfo } from '../releases/types'; +import type { ExternalProjectRecord, ProjectManifest, ProjectRecord, ProjectSkill } from './schema'; import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { parse } from 'yaml'; import { z } from 'zod'; +import { getReleaseIndex } from '../releases/runtime'; import { OWNER } from '../site'; import { PROJECT_DEFAULTS, manifestSchema } from './schema'; @@ -30,6 +32,10 @@ export interface ResolvedProject extends ProjectRecord { experimental: boolean; order: number; packages: NonNullable; + /** True when the catalogue says "publish every skill under `skills/`". */ + discoverSkills: boolean; + /** Explicit skill declarations (overrides/legacy); discovery is preferred. */ + skills: ProjectSkill[]; assets: NonNullable; related: NonNullable; useCases: NonNullable; @@ -57,26 +63,51 @@ export class ManifestValidationError extends Error { } } -function withDefaults(record: ProjectRecord): ResolvedProject { +/** The repository flags the release cache records, keyed by `owner/repository`. */ +type RepositoryFlags = Record>; + +/** + * Resolve whether the project links its GitHub Discussions. + * + * GitHub already knows, so the catalogue does not carry the setting: the + * release cache records `hasDiscussions` for every declared repository. An + * explicit value in the record is an override — `false` opts out (a fork, or a + * repository whose Discussions should not be surfaced) and `true` links them + * even before the repository has a cache record. + */ +function resolveDiscussions( + record: ProjectRecord, + repository: Pick | undefined, +): boolean { + if (record.discussions === false) { + return false; + } + return repository?.hasDiscussions ?? record.discussions ?? PROJECT_DEFAULTS.discussions; +} + +function withDefaults(record: ProjectRecord, repositories: RepositoryFlags): ResolvedProject { const [repoOwner = OWNER, repoName = ''] = record.repository.split('/'); const repo = `https://github.com/${record.repository}`; + const discussions = resolveDiscussions(record, repositories[record.repository]); return { ...record, featured: record.featured ?? PROJECT_DEFAULTS.featured, experimental: record.experimental ?? PROJECT_DEFAULTS.experimental, order: record.order ?? PROJECT_DEFAULTS.order, packages: record.packages ?? PROJECT_DEFAULTS.packages, + skills: Array.isArray(record.skills) ? record.skills : PROJECT_DEFAULTS.skills, + discoverSkills: record.skills !== false, assets: record.assets ?? PROJECT_DEFAULTS.assets, related: record.related ?? PROJECT_DEFAULTS.related, useCases: record.useCases ?? PROJECT_DEFAULTS.useCases, acknowledgments: record.acknowledgments ?? PROJECT_DEFAULTS.acknowledgments, - discussions: record.discussions ?? PROJECT_DEFAULTS.discussions, + discussions, install: record.install ?? PROJECT_DEFAULTS.install, repoOwner, repoName, sourceUrl: repo, issuesUrl: `${repo}/issues`, - discussionsUrl: record.discussions ? `${repo}/discussions` : null, + discussionsUrl: discussions ? `${repo}/discussions` : null, changelogUrl: `${repo}/blob/${'main'}/CHANGELOG.md`, releasesUrl: `${repo}/releases`, }; @@ -104,8 +135,16 @@ function issuePath(raw: unknown, path: readonly PropertyKey[]): string { * Validate a raw manifest object against the typed schema. * Throws a ManifestValidationError describing the source file, the invalid * property, the expected shape, and how to remediate the record. + * + * `repositories` feeds the values that are detected from GitHub rather than + * authored (today: whether Discussions are enabled); it defaults to the release + * cache, so a record never repeats what the repository already says. */ -export function parseManifest(raw: unknown, source: string): ResolvedProject[] { +export function parseManifest( + raw: unknown, + source: string, + repositories: RepositoryFlags = getReleaseIndex().data.repos, +): ResolvedProject[] { let parsed: ProjectManifest; try { parsed = manifestSchema.parse(raw); @@ -126,7 +165,7 @@ export function parseManifest(raw: unknown, source: string): ResolvedProject[] { throw error; } - const projects = parsed.projects.map(withDefaults); + const projects = parsed.projects.map((record) => withDefaults(record, repositories)); const ids = new Set(projects.map((p) => p.id)); const packageOwners = new Map(); const assetOutputs = new Map(); diff --git a/src/src/lib/manifest/schema.ts b/src/src/lib/manifest/schema.ts index 9197780..0277040 100644 --- a/src/src/lib/manifest/schema.ts +++ b/src/src/lib/manifest/schema.ts @@ -134,6 +134,23 @@ const docsSchema = z.discriminatedUnion('source', [ docsReadmeSchema, ]); +/** + * An Agent Skill the project publishes from its repository root. + * + * The ecosystem convention (see the engineering playbook's *Agent Skills + * Distribution*) is one directory per skill at `skills//SKILL.md`, and the + * skills CLI is the only delivery route: nothing ships a skill inside a NuGet + * package or writes into an agent directory during a build. The catalogue + * records the skills so the site can mirror them to stable `purview.dev` URLs + * and show the same install instructions everywhere (ADR 0010). + */ +const skillSchema = z.object({ + name: z.string().regex(/^[a-z0-9-]+$/, 'must be a lowercase slug using [a-z0-9-] only'), + /** Repository directory holding the skill; defaults to `skills/`. */ + path: z.string().min(1, 'must be a non-empty repository path').optional(), + description: z.string().min(1, 'must be a short description').optional(), +}); + const packageSchema = z.object({ id: z.string().min(1, 'must be a non-empty NuGet package id'), description: z.string().optional(), @@ -196,18 +213,30 @@ const projectSchema = z install: z.enum(INSTALL_KINDS).optional(), targetFrameworks: z.array(z.string()).optional(), packages: z.array(packageSchema).optional(), + skills: z.union([z.boolean(), z.array(skillSchema)]).optional(), assets: z.array(assetSchema).optional(), related: z.array(z.string()).optional(), useCases: z.array(useCaseSchema).optional(), acknowledgments: z.array(acknowledgmentSchema).optional(), supersedes: z.string().optional(), supersededBy: z.string().optional(), + /** + * Whether the project links its GitHub Discussions. Detected from the + * repository (`hasDiscussions` in the release cache); set it only to + * override the detection — `false` opts a repository out. + */ discussions: z.boolean().optional(), }) .refine((value) => !(value.experimental === true && value.status === 'archived'), { message: 'cannot be true when `status` is archived: an archived project is not an experiment', path: ['experimental'], - }); + }) + .refine( + (value) => + !Array.isArray(value.skills) || + new Set(value.skills.map((skill) => skill.name)).size === value.skills.length, + { message: 'must not repeat a skill name', path: ['skills'] }, + ); const externalProjectSchema = z.object({ id: z.string().regex(/^[a-z0-9-]+$/, 'must be a lowercase slug using [a-z0-9-] only'), @@ -238,6 +267,7 @@ export type ProjectRecord = z.infer; export type ExternalProjectRecord = z.infer; export type ProjectDocsConfig = z.infer; export type ProjectDocsChannel = z.infer; +export type ProjectSkill = z.infer; export type ProjectPackage = z.infer; export type ProjectAsset = z.infer; export type ProjectAcknowledgment = z.infer; @@ -248,6 +278,7 @@ export const PROJECT_DEFAULTS = { featured: false, order: 1000, packages: [] as ProjectPackage[], + skills: [] as ProjectSkill[], assets: [] as ProjectAsset[], related: [] as string[], useCases: [] as ProjectUseCase[], @@ -259,6 +290,7 @@ export const PROJECT_DEFAULTS = { featured: boolean; order: number; packages: ProjectPackage[]; + skills: ProjectSkill[]; assets: ProjectAsset[]; related: string[]; useCases: ProjectUseCase[]; diff --git a/src/src/lib/releases/runtime.ts b/src/src/lib/releases/runtime.ts index e07f109..b62b1fb 100644 --- a/src/src/lib/releases/runtime.ts +++ b/src/src/lib/releases/runtime.ts @@ -1,7 +1,7 @@ import type { DataSource } from './transform'; import type { ReleaseCacheData } from './types'; -import { readReleaseCache, readReleaseFixture } from './cache'; +import { readReleaseCache, readReleaseFixture, writeReleaseCache } from './cache'; import { RELEASE_CACHE_SCHEMA_VERSION } from './types'; export interface ReleaseIndex { @@ -11,6 +11,32 @@ export interface ReleaseIndex { let cached: ReleaseIndex | null = null; +/** + * Forget the memoized index so the next call re-reads the cache and fixtures. + * + * The memo is what makes rendering cheap, but it also snapshots whichever source + * existed at the *first* call. During `just data-sync` that first call happens + * inside the release fetch itself (`loadProjects` reads repository flags through + * this accessor), i.e. before the freshly fetched cache is written — so a process + * that only resets on demand would otherwise aggregate documentation from the + * committed fixtures while the catalogue guard read the new cache, and every + * channel-promotion decision (ADR 0009) would disagree between the two. + */ +export function resetReleaseIndex(): void { + cached = null; +} + +/** + * Publish freshly fetched release data: write the cache and make this process + * read it. Every writer must go through here — a bare `writeReleaseCache` leaves + * the memo pointing at the previous source. + */ +export function publishReleaseData(data: ReleaseCacheData): ReleaseIndex { + writeReleaseCache(data); + cached = { data, source: 'cache' }; + return cached; +} + /** * Build-time accessor for release data. Prefers the local cache written by * `just fetch-releases`, then committed fixtures. Never triggers network I/O diff --git a/src/src/lib/skills.ts b/src/src/lib/skills.ts new file mode 100644 index 0000000..1a09fe3 --- /dev/null +++ b/src/src/lib/skills.ts @@ -0,0 +1,136 @@ +import type { ProjectSkill } from './manifest/schema'; + +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; + +/** + * Agent Skills (ADR 0010). + * + * Every Purview project that authors skills does so at `skills//SKILL.md` + * in its own repository, and the skills CLI is the only delivery route — no + * package ships them and no build writes into an agent directory. The site + * mirrors each declared skill to stable `purview.dev` URLs so the skills can be + * fetched directly, and shows the **same** install instructions wherever skills + * appear (pm-skills today, plus every product project that authors its own). + */ +export const SKILLS_PUBLIC_DIR = resolve('public/skills'); + +/** Site path of a project's mirrored skills: `/skills//`. */ +export function skillsProjectPath(projectId: string): string { + return `/skills/${projectId}/`; +} + +/** Site path of one mirrored skill directory: `/skills///`. */ +export function skillSitePath(projectId: string, name: string): string { + return `/skills/${projectId}/${name}/`; +} + +/** Site URL of a skill's entry point (`SKILL.md`). */ +export function skillEntryPath(projectId: string, name: string): string { + return `${skillSitePath(projectId, name)}SKILL.md`; +} + +/** Repository directory of a declared skill; the convention is `skills/`. */ +export function skillSourcePath(skill: ProjectSkill): string { + return (skill.path ?? `skills/${skill.name}`).replace(/^\/+|\/+$/g, ''); +} + +/** + * The canonical install command. Every page that offers skills prints exactly + * this line, so a reader never has to reconcile two sets of instructions. + */ +export function skillsInstallCommand(repository: string): string { + return `npx skills add ${repository}`; +} + +/** The same command pinned to a git ref, for repositories that need a fixed version. */ +export function skillsPinnedInstallCommand(repository: string, ref: string): string { + return `npx skills add ${repository}#${ref}`; +} + +export interface MirroredSkillFile { + /** Path relative to the skill directory (`SKILL.md`, `references/x.md`). */ + path: string; + size: number; +} + +/** Every file mirrored for a skill, sorted by path. Empty when it was not synced. */ +export function listMirroredSkillFiles( + projectId: string, + name: string, + root = SKILLS_PUBLIC_DIR, +): MirroredSkillFile[] { + const directory = join(root, projectId, name); + if (!existsSync(directory)) { + return []; + } + const files: MirroredSkillFile[] = []; + const walk = (current: string): void => { + for (const entry of readdirSync(current, { withFileTypes: true })) { + const path = join(current, entry.name); + if (entry.isDirectory()) { + walk(path); + continue; + } + files.push({ + path: relative(directory, path).split('\\').join('/'), + size: statSync(path).size, + }); + } + }; + walk(directory); + return files.toSorted((a, b) => a.path.localeCompare(b.path)); +} + +export interface MirroredSkill { + name: string; + description: string | null; + files: MirroredSkillFile[]; +} + +/** + * The skills actually mirrored for a project — which is the published list, not + * the manifest text. `just data-sync` discovers them from the project's own + * documentation ref (the live tag when there is a live channel), so the site can + * never advertise a skill set that is older than the release it documents. + */ +export function listMirroredSkills(projectId: string, root = SKILLS_PUBLIC_DIR): MirroredSkill[] { + const directory = join(root, projectId); + if (!existsSync(directory)) { + return []; + } + return readdirSync(directory, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ + name: entry.name, + description: readSkillDescription(projectId, entry.name, root), + files: listMirroredSkillFiles(projectId, entry.name, root), + })) + .toSorted((a, b) => a.name.localeCompare(b.name)); +} + +/** True when the catalogue says this project publishes skills. */ +export function declaresSkills(project: { + discoverSkills: boolean; + skills: { name: string }[]; +}): boolean { + return project.discoverSkills || project.skills.length > 0; +} + +/** + * The skill's own `description:` front matter, which is what the CLI shows and + * what the project catalogue uses when it does not override it. + */ +export function readSkillDescription( + projectId: string, + name: string, + root = SKILLS_PUBLIC_DIR, +): string | null { + const file = join(root, projectId, name, 'SKILL.md'); + if (!existsSync(file)) { + return null; + } + const match = /^---\s*\r?\n([\s\S]*?)\r?\n---/.exec(readFileSync(file, 'utf8')); + const description = match?.[1] ? /^description:\s*(.+)$/m.exec(match[1])?.[1] : undefined; + return description ? description.trim().replace(/^["']|["']$/g, '') : null; +} diff --git a/src/src/pages/projects/[project].astro b/src/src/pages/projects/[project].astro index 1ecad7f..58ce70c 100644 --- a/src/src/pages/projects/[project].astro +++ b/src/src/pages/projects/[project].astro @@ -1,6 +1,7 @@ --- import SiteLayout from '~/layouts/SiteLayout.astro'; import GitHubIcon from '~/components/GitHubIcon.astro'; +import SkillInstall from '~/components/SkillInstall.astro'; import InstallPanel from '~/components/islands/InstallPanel.tsx'; import PackageVersions, { type PackageRow } from '~/components/islands/PackageVersions.tsx'; import StarOnGitHub from '~/components/StarOnGitHub.astro'; @@ -15,6 +16,7 @@ import { projectRepoEnrichment } from '~/lib/releases/repo'; import { projectReleaseSummary } from '~/lib/releases/transform'; import { getReleaseIndex } from '~/lib/releases/runtime'; import { SITE } from '~/lib/site'; +import { listMirroredSkills } from '~/lib/skills'; import { displayStatus } from '~/lib/status'; import { absoluteUrl, nugetPackageUrl, withBase } from '~/lib/urls'; @@ -54,6 +56,13 @@ const supersededBy = loadProjects().find((candidate) => candidate.id === project.supersededBy) : undefined; const primaryPackage = project.packages.find((pkg) => pkg.primary) ?? project.packages[0]; +// Agent skills (ADR 0010): the catalogue names them, the mirrored SKILL.md +// supplies the description the CLI shows, and one shared component renders the +// same install instructions here as on /skills/. +const skills = listMirroredSkills(project.id).map((skill) => ({ + name: skill.name, + description: skill.description ?? undefined, +})); const docsPath = project.docs ? withBase(`/docs/${project.id}/`) : null; // Per-project machine-readable bundles, emitted by the discovery integration at // /projects//llms.txt and /projects//llms-full.txt (the llms.txt @@ -448,6 +457,25 @@ const packageRows: PackageRow[] = releases.packages.map((pkg) => ({ ) } + + { + skills.length > 0 && ( +
+

Agent skills

+

+ {project.name} authors agent skills for coding agents. They are installed explicitly + with the skills CLI and are not part of any NuGet package. +

+
+ +
+
+ ) + }