diff --git a/docs/api.md b/docs/api.md index 3442924..c845438 100644 --- a/docs/api.md +++ b/docs/api.md @@ -57,7 +57,7 @@ List all indexed skills (paginated). "allowedTools": [], "skillPath": "skills/agent-ready/SKILL.md", "frontmatter": {}, - "installCommand": "npx skills add http://localhost:3000 --skill agent-ready", + "installCommand": "npx skills add http://localhost:3000/api/v1/skills/rhdh-skills/agent-ready", "lastModified": "2026-06-19T14:32:00Z" } ], @@ -133,7 +133,7 @@ Full skill detail including all files. "allowedTools": [], "skillPath": "skills/agent-ready/SKILL.md", "frontmatter": { "version": "1.0" }, - "installCommand": "npx skills add http://localhost:3000 --skill agent-ready", + "installCommand": "npx skills add http://localhost:3000/api/v1/skills/rhdh-skills/agent-ready", "lastModified": "2026-06-19T14:32:00Z", "content": "---\nname: agent-ready\n…", "files": [ @@ -155,7 +155,7 @@ Download the raw skill artifact. - Returns `text/markdown` for `skill-md` type. - Returns `application/gzip` (tar.gz) for `archive` type. -This is the URL referenced in the Agent Skills discovery index (`url` field). Install via the host + `--skill` form in `installCommand`, not this artifact URL directly. +This is the URL referenced in the Agent Skills discovery index (`url` field). Install via the per-skill discovery URL in `installCommand` (`npx skills add …/api/v1/skills/:source/:slug`), not this artifact URL directly. **Response `404`** — skill not found. diff --git a/src/server/routes/skills.ts b/src/server/routes/skills.ts index 72bf520..d0e174c 100644 --- a/src/server/routes/skills.ts +++ b/src/server/routes/skills.ts @@ -9,6 +9,7 @@ import type { Repositories } from "../db/init.js"; import { createAdminAuthHook } from "../plugins/adminAuth.js"; import { ingestSource } from "../ingestion/ingest.js"; import { resolveBaseUrl } from "../utils/resolveBaseUrl.js"; +import { buildDiscoveryIndex, toDiscoverySkillEntry } from "../utils/discoveryIndex.js"; /** * Decode a base64 tar.gz archive and return all contained files as @@ -50,11 +51,12 @@ async function expandArchiveToFiles( } function skillToResponse(skill: Skill, source: Source | undefined, baseUrl: string) { - // Point at the host so the CLI discovers via /.well-known/agent-skills/index.json, - // then select this skill. Artifact URLs are not a valid skills-add source by themselves - // and would make the CLI fetch every skill in the index. - // Use --skill= (single argv token) so odd slug characters cannot split the command. - const installCommand = `npx skills add ${baseUrl} --skill=${skill.slug}`; + // Point at the per-skill discovery URL. The CLI looks for + // /.well-known/agent-skills/index.json first; that single-entry index + // auto-selects this skill. Host root + `--skill=…` fails because the skills CLI + // does not parse the equals form of --skill, and would download the full catalog. + const installCommand = + `npx skills add ${baseUrl}/api/v1/skills/${encodeURIComponent(skill.sourceSlug)}/${encodeURIComponent(skill.slug)}`; return { id: skill.id, source: skill.sourceSlug, @@ -117,7 +119,8 @@ const skillSchema = { frontmatter: { type: "object", additionalProperties: true, description: "Full parsed frontmatter map (excluding name/description)" }, installCommand: { type: "string", - description: "npx skills add --skill command to install this skill via well-known discovery", + description: + "npx skills add command; the URL serves a single-entry well-known index", }, lastModified: { type: "string", description: "ISO 8601 timestamp of last update" }, }, @@ -269,6 +272,83 @@ const skillsPlugin: FastifyPluginAsync = async (fastify, opt } ); + // Per-skill discovery index — registered before /:source/:slug. + // Consumed by `npx skills add /api/v1/skills/:source/:slug`, which probes + // `/.well-known/agent-skills/index.json` before falling back to the host root. + fastify.get( + "/:source/:slug/.well-known/agent-skills/index.json", + { + schema: { + tags: ["Discovery"], + summary: "Single-skill Agent Skills discovery index", + description: + "Returns a one-entry discovery manifest for this skill so `npx skills add` " + + "can install it without downloading the full catalog.", + params: { + type: "object", + required: ["source", "slug"], + properties: { + source: { type: "string" }, + slug: { type: "string" }, + }, + }, + response: { + 200: { + type: "object", + properties: { + $schema: { type: "string" }, + skills: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + type: { type: "string", enum: ["skill-md", "archive"] }, + description: { type: "string" }, + url: { type: "string", format: "uri" }, + digest: { type: "string" }, + }, + required: ["name", "type", "description", "url", "digest"], + }, + }, + }, + }, + 404: errorSchema, + }, + }, + }, + async ( + req: FastifyRequest<{ Params: { source: string; slug: string } }>, + reply: FastifyReply + ) => { + const { source, slug } = req.params; + const skill = skills.findBySourceAndSlug(source, slug); + if (!skill) { + return reply.code(404).send({ + error: { code: "SKILL_NOT_FOUND", message: `Skill '${source}/${slug}' not found.` }, + }); + } + const baseUrl = resolveBaseUrl(req); + const discoverySkill = { + sourceSlug: skill.sourceSlug, + slug: skill.slug, + name: skill.name, + description: skill.description, + artifactType: skill.artifactType, + digest: skill.digest, + }; + if (!toDiscoverySkillEntry(discoverySkill, baseUrl)) { + return reply.code(404).send({ + error: { + code: "SKILL_NOT_INSTALLABLE", + message: `Skill '${source}/${slug}' has an install id that is incompatible with npx skills.`, + }, + }); + } + return reply.send(buildDiscoveryIndex([discoverySkill], baseUrl)); + } + ); + // Artifact download — registered before /:source/:slug (static suffix wins) fastify.get( "/:source/:slug/artifact", diff --git a/src/server/routes/wellKnown.ts b/src/server/routes/wellKnown.ts index f023430..2d2d6f3 100644 --- a/src/server/routes/wellKnown.ts +++ b/src/server/routes/wellKnown.ts @@ -1,7 +1,7 @@ import type { FastifyPluginAsync, FastifyRequest } from "fastify"; import type { SkillRepository } from "../db/types.js"; import { resolveBaseUrl } from "../utils/resolveBaseUrl.js"; -import { isValidSkillInstallId } from "../utils/skillInstallId.js"; +import { buildDiscoveryIndex } from "../utils/discoveryIndex.js"; interface WellKnownOptions { skills: SkillRepository; @@ -42,26 +42,7 @@ const wellKnownPlugin: FastifyPluginAsync = async (fastify, op }, }, async (req: FastifyRequest, reply) => { const baseUrl = resolveBaseUrl(req); - const entries = opts.skills.findAllDiscoveryEntries(); - return reply.send({ - // Opaque schema URI required by the Agent Skills discovery RFC / npx skills CLI. - // Clients match this exactly; a different v0.2.0-looking URI is rejected. - $schema: "https://schemas.agentskills.io/discovery/0.2.0/schema.json", - skills: entries - .filter((s) => isValidSkillInstallId(s.slug)) - .map((s) => ({ - // Discovery `name` must be a kebab-case install id ([a-z0-9-]+). Prefer slug - // over frontmatter display names that may contain spaces or mixed case. - name: s.slug, - type: s.artifactType, - // CLI rejects descriptions longer than 1024 characters. - description: s.description.length > 1024 - ? `${s.description.slice(0, 1023)}…` - : s.description, - url: `${baseUrl}/api/v1/skills/${encodeURIComponent(s.sourceSlug)}/${encodeURIComponent(s.slug)}/artifact`, - digest: `sha256:${s.digest}`, - })), - }); + return reply.send(buildDiscoveryIndex(opts.skills.findAllDiscoveryEntries(), baseUrl)); }); }; diff --git a/src/server/utils/discoveryIndex.ts b/src/server/utils/discoveryIndex.ts new file mode 100644 index 0000000..05989f1 --- /dev/null +++ b/src/server/utils/discoveryIndex.ts @@ -0,0 +1,45 @@ +import type { SkillDiscoveryEntry } from "../db/types.js"; +import { isValidSkillInstallId } from "./skillInstallId.js"; + +export const DISCOVERY_SCHEMA = + "https://schemas.agentskills.io/discovery/0.2.0/schema.json"; + +export interface DiscoverySkillEntry { + name: string; + type: "skill-md" | "archive"; + description: string; + url: string; + digest: string; +} + +/** Map a DB discovery row to a CLI-compatible index entry, or null if unusable. */ +export function toDiscoverySkillEntry( + skill: SkillDiscoveryEntry, + baseUrl: string, +): DiscoverySkillEntry | null { + if (!isValidSkillInstallId(skill.slug)) return null; + return { + // Discovery `name` must be a kebab-case install id ([a-z0-9-]+). + name: skill.slug, + type: skill.artifactType, + // CLI rejects descriptions longer than 1024 characters. + description: + skill.description.length > 1024 + ? `${skill.description.slice(0, 1023)}…` + : skill.description, + url: `${baseUrl}/api/v1/skills/${encodeURIComponent(skill.sourceSlug)}/${encodeURIComponent(skill.slug)}/artifact`, + digest: `sha256:${skill.digest}`, + }; +} + +export function buildDiscoveryIndex( + entries: SkillDiscoveryEntry[], + baseUrl: string, +): { $schema: string; skills: DiscoverySkillEntry[] } { + return { + $schema: DISCOVERY_SCHEMA, + skills: entries + .map((s) => toDiscoverySkillEntry(s, baseUrl)) + .filter((s): s is DiscoverySkillEntry => s !== null), + }; +} diff --git a/test/server/routes/skills.test.ts b/test/server/routes/skills.test.ts index 667cde4..0578e14 100644 --- a/test/server/routes/skills.test.ts +++ b/test/server/routes/skills.test.ts @@ -94,7 +94,7 @@ describe("GET /api/v1/skills", () => { expect(body.data[0]).toHaveProperty("allowedTools"); }); - it("installCommand targets the well-known host with --skill filter", async () => { + it("installCommand targets the per-skill discovery URL", async () => { const res = await app.inject({ method: "GET", url: "/api/v1/skills", @@ -103,7 +103,7 @@ describe("GET /api/v1/skills", () => { expect(res.statusCode).toBe(200); const skill = res.json().data.find((s: { slug: string }) => s.slug === "react-patterns"); expect(skill.installCommand).toBe( - "npx skills add http://localhost:3000 --skill=react-patterns", + "npx skills add http://localhost:3000/api/v1/skills/team-a/react-patterns", ); }); @@ -116,10 +116,30 @@ describe("GET /api/v1/skills", () => { expect(res.statusCode).toBe(200); for (const skill of res.json().data) { expect(skill.installCommand).not.toContain("attacker.test"); - expect(skill.installCommand).toMatch(/^npx skills add http:\/\/localhost --skill=/); + expect(skill.installCommand).toMatch( + /^npx skills add http:\/\/localhost\/api\/v1\/skills\//, + ); } }); + it("serves a single-entry well-known index under the skill path", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/v1/skills/team-a/react-patterns/.well-known/agent-skills/index.json", + headers: { host: "localhost:3000" }, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.$schema).toBe("https://schemas.agentskills.io/discovery/0.2.0/schema.json"); + expect(body.skills).toHaveLength(1); + expect(body.skills[0]).toMatchObject({ + name: "react-patterns", + type: "skill-md", + url: "http://localhost:3000/api/v1/skills/team-a/react-patterns/artifact", + }); + expect(body.skills[0].digest).toMatch(/^sha256:/); + }); + it("paginates correctly", async () => { const res = await app.inject({ method: "GET", url: "/api/v1/skills?page=1&per_page=2" }); expect(res.statusCode).toBe(200);