Skip to content
Merged
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
6 changes: 3 additions & 3 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
],
Expand Down Expand Up @@ -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": [
Expand All @@ -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.

Expand Down
92 changes: 86 additions & 6 deletions src/server/routes/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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=<id> (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
// <url>/.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 {
Comment on lines +54 to 60

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

1. Web spec install drift 🐞 Bug ⚙ Maintainability

The server now returns installCommand as npx skills add <base>/api/v1/skills/<source>/<slug>,
but the OpenSpec web-directory requirement still mandates a --skill <source>/<slug> command,
making the spec inaccurate and likely to mislead future UI/conformance work.
Agent Prompt
## Issue description
The OpenSpec web-directory requirements still specify the old install command format (`npx skills add <server-url> --skill <source>/<slug>`), but the implementation now emits a per-skill discovery URL (`npx skills add …/api/v1/skills/:source/:slug`). This spec mismatch can cause future UI changes, conformance checks, or documentation to reintroduce the wrong command.

## Issue Context
- The PR changed `installCommand` generation to point at the per-skill discovery URL.
- OpenSpec web-directory specs still reference the old `--skill` form.

## Fix Focus Areas
- openspec/specs/web-directory/spec.md[17-20]
- openspec/changes/archive/2026-06-26-rhess-enterprise-skills-server/specs/web-directory/spec.md[17-20]
- src/server/routes/skills.ts[54-60]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

id: skill.id,
source: skill.sourceSlug,
Expand Down Expand Up @@ -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 <host> --skill <slug> command to install this skill via well-known discovery",
description:
"npx skills add <skill-discovery-url> command; the URL serves a single-entry well-known index",
},
lastModified: { type: "string", description: "ISO 8601 timestamp of last update" },
},
Expand Down Expand Up @@ -269,6 +272,83 @@ const skillsPlugin: FastifyPluginAsync<SkillsRouteOptions> = async (fastify, opt
}
);

// Per-skill discovery index — registered before /:source/:slug.
// Consumed by `npx skills add <base>/api/v1/skills/:source/:slug`, which probes
// `<url>/.well-known/agent-skills/index.json` before falling back to the host root.
fastify.get(
"/:source/:slug/.well-known/agent-skills/index.json",
{
Comment on lines +275 to +280

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

2. Openapi not regenerated 🐞 Bug ⚙ Maintainability

A new discovery endpoint (GET /api/v1/skills/:source/:slug/.well-known/agent-skills/index.json)
was added, but the committed openapi.yaml (generated via npm run openapi) does not document it,
so tooling/consumers relying on the checked-in spec will miss the endpoint.
Agent Prompt
## Issue description
The PR adds a new per-skill discovery endpoint, but the repository’s committed OpenAPI spec (`openapi.yaml`) appears stale and does not include that route. Since the repo has a dedicated generator script, the expected workflow is to regenerate and commit the updated spec when routes/schemas change.

## Issue Context
- `scripts/generate-openapi.ts` generates `openapi.yaml` from Fastify’s runtime schema.
- `openapi.yaml` currently documents only the root well-known index under `/.well-known/agent-skills/index.json` and does not include the new per-skill index route.

## Fix Focus Areas
- src/server/routes/skills.ts[275-350]
- scripts/generate-openapi.ts[1-17]
- openapi.yaml[710-755]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

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",
Expand Down
23 changes: 2 additions & 21 deletions src/server/routes/wellKnown.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -42,26 +42,7 @@ const wellKnownPlugin: FastifyPluginAsync<WellKnownOptions> = 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));
});
};

Expand Down
45 changes: 45 additions & 0 deletions src/server/utils/discoveryIndex.ts
Original file line number Diff line number Diff line change
@@ -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),
};
}
26 changes: 23 additions & 3 deletions test/server/routes/skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
);
});

Expand All @@ -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);
Expand Down
Loading