feat: support npx skills discovery (#3233)

This commit is contained in:
Patrick Erichsen
2026-07-22 22:37:34 -07:00
committed by GitHub
parent 97bc586209
commit 7aff40d26a
17 changed files with 1203 additions and 10 deletions
+4
View File
@@ -8,6 +8,7 @@
* @module
*/
import type * as agentSkillsHttp from "../agentSkillsHttp.js";
import type * as appMeta from "../appMeta.js";
import type * as auth from "../auth.js";
import type * as catalogClassification from "../catalogClassification.js";
@@ -50,6 +51,7 @@ import type * as httpApiV1_whoamiV1 from "../httpApiV1/whoamiV1.js";
import type * as httpPreflight from "../httpPreflight.js";
import type * as leaderboards from "../leaderboards.js";
import type * as lib_access from "../lib/access.js";
import type * as lib_agentSkillsDiscovery from "../lib/agentSkillsDiscovery.js";
import type * as lib_apiTokenAuth from "../lib/apiTokenAuth.js";
import type * as lib_artifactModeration from "../lib/artifactModeration.js";
import type * as lib_artifactText from "../lib/artifactText.js";
@@ -184,6 +186,7 @@ import type {
} from "convex/server";
declare const fullApi: ApiFromModules<{
agentSkillsHttp: typeof agentSkillsHttp;
appMeta: typeof appMeta;
auth: typeof auth;
catalogClassification: typeof catalogClassification;
@@ -226,6 +229,7 @@ declare const fullApi: ApiFromModules<{
httpPreflight: typeof httpPreflight;
leaderboards: typeof leaderboards;
"lib/access": typeof lib_access;
"lib/agentSkillsDiscovery": typeof lib_agentSkillsDiscovery;
"lib/apiTokenAuth": typeof lib_apiTokenAuth;
"lib/artifactModeration": typeof lib_artifactModeration;
"lib/artifactText": typeof lib_artifactText;
+358
View File
@@ -0,0 +1,358 @@
/* @vitest-environment node */
import { unzipSync } from "fflate";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ActionCtx } from "./_generated/server";
import { stripGitHubZipRoot } from "./lib/githubImport";
import { computeGitHubSkillFolderContentHash } from "./lib/githubSkillSync";
import { buildDeterministicZip } from "./lib/skillZip";
vi.mock("./lib/githubImport", async (importOriginal) => {
const original = await importOriginal<typeof import("./lib/githubImport")>();
return {
...original,
fetchGitHubZipBytes: vi.fn(),
};
});
const { agentSkillsHttpHandler } = await import("./agentSkillsHttp");
const { fetchGitHubZipBytes } = await import("./lib/githubImport");
const baseSkill = {
_id: "skills:demo",
slug: "demo",
displayName: "Demo",
latestVersionId: "skillVersions:demo",
latestVersionSummary: { version: "1.0.0" },
};
function makeCtx(partial: Record<string, unknown>) {
return partial as unknown as ActionCtx;
}
function hostedRunQuery({
publicSkill = true,
moderationInfo,
llmAnalysis,
}: {
publicSkill?: boolean;
moderationInfo?: {
isPendingScan?: boolean;
isMalwareBlocked?: boolean;
isHiddenByMod?: boolean;
isRemoved?: boolean;
sourceVersionId?: string;
};
llmAnalysis?: {
status?: string;
verdict?: string;
};
} = {}) {
return vi
.fn()
.mockResolvedValueOnce(baseSkill)
.mockResolvedValueOnce(
publicSkill
? {
skill: {
_id: "skills:demo",
displayName: "Demo",
summary: "A demo skill.",
},
latestVersion: {
version: "1.0.0",
files: [
{ path: "SKILL.md", size: 25, sha256: "skill-hash" },
{ path: "references/proof.txt", size: 5, sha256: "proof-hash" },
],
},
moderationInfo,
}
: null,
)
.mockResolvedValueOnce({
_id: "skillVersions:demo",
skillId: "skills:demo",
version: "1.0.0",
files: [
{ path: "SKILL.md", storageId: "_storage:skill" },
{ path: "references/proof.txt", storageId: "_storage:proof" },
],
llmAnalysis,
});
}
describe("Agent Skills discovery HTTP handler", () => {
afterEach(() => {
vi.mocked(fetchGitHubZipBytes).mockReset();
});
it("returns a discovery document for a public hosted skill", async () => {
const response = await agentSkillsHttpHandler(
makeCtx({
runQuery: hostedRunQuery(),
storage: {
get: vi
.fn()
.mockResolvedValueOnce(new Blob(["---\nname: demo\n---\n"]))
.mockResolvedValueOnce(new Blob(["proof"])),
},
}),
new Request("https://api.example/api/v1/agent-skills/openclaw/demo/index.json"),
);
expect(response.status).toBe(200);
expect(response.headers.get("Cache-Control")).toContain("max-age=60");
expect(await response.json()).toEqual({
$schema: "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
skills: [
{
name: "demo",
type: "archive",
description: "A demo skill.",
url: "https://api.example/api/v1/agent-skills/openclaw/demo/archive?version=1.0.0",
digest: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
},
],
});
});
it("serves a normalized hosted archive only for the pinned version", async () => {
const storageGet = vi
.fn()
.mockResolvedValueOnce(new Blob(["---\nname: demo\n---\n"]))
.mockResolvedValueOnce(new Blob(["proof"]));
const response = await agentSkillsHttpHandler(
makeCtx({
runQuery: hostedRunQuery(),
storage: { get: storageGet },
}),
new Request("https://api.example/api/v1/agent-skills/openclaw/demo/archive?version=1.0.0"),
);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("application/zip");
expect(response.headers.get("Cache-Control")).toBe("no-store");
expect(Object.keys(unzipSync(new Uint8Array(await response.arrayBuffer()))).sort()).toEqual([
"SKILL.md",
"references/proof.txt",
]);
const staleResponse = await agentSkillsHttpHandler(
makeCtx({
runQuery: hostedRunQuery(),
storage: { get: vi.fn() },
}),
new Request("https://api.example/api/v1/agent-skills/openclaw/demo/archive?version=0.9.0"),
);
expect(staleResponse.status).toBe(404);
expect(await staleResponse.text()).toBe("Skill version not available");
});
it("serves a cached hosted archive after a newer version becomes current", async () => {
const runQuery = vi
.fn()
.mockResolvedValueOnce({
...baseSkill,
latestVersionId: "skillVersions:current",
latestVersionSummary: { version: "2.0.0" },
})
.mockResolvedValueOnce({
skill: {
_id: "skills:demo",
displayName: "Demo",
summary: "A demo skill.",
},
latestVersion: { version: "2.0.0" },
})
.mockResolvedValueOnce({
_id: "skillVersions:historical",
skillId: "skills:demo",
version: "1.0.0",
files: [{ path: "SKILL.md", storageId: "_storage:historical" }],
});
const response = await agentSkillsHttpHandler(
makeCtx({
runQuery,
storage: {
get: vi.fn().mockResolvedValue(new Blob(["---\nname: demo\n---\n"])),
},
}),
new Request("https://api.example/api/v1/agent-skills/openclaw/demo/archive?version=1.0.0"),
);
expect(response.status).toBe(200);
expect(Object.keys(unzipSync(new Uint8Array(await response.arrayBuffer())))).toEqual([
"SKILL.md",
]);
});
it("does not count archive HEAD probes as downloads", async () => {
const runAfter = vi.fn();
const response = await agentSkillsHttpHandler(
makeCtx({
runQuery: hostedRunQuery(),
auth: { getUserIdentity: vi.fn().mockResolvedValue(null) },
scheduler: { runAfter },
storage: {
get: vi
.fn()
.mockResolvedValueOnce(new Blob(["---\nname: demo\n---\n"]))
.mockResolvedValueOnce(new Blob(["proof"])),
},
}),
new Request("https://api.example/api/v1/agent-skills/openclaw/demo/archive?version=1.0.0", {
method: "HEAD",
headers: { "x-forwarded-for": "203.0.113.10" },
}),
);
expect(response.status).toBe(200);
expect(runAfter).not.toHaveBeenCalled();
});
it("fails instead of caching an incomplete hosted archive", async () => {
const response = await agentSkillsHttpHandler(
makeCtx({
runQuery: hostedRunQuery(),
storage: {
get: vi
.fn()
.mockResolvedValueOnce(new Blob(["---\nname: demo\n---\n"]))
.mockResolvedValueOnce(null),
},
}),
new Request("https://api.example/api/v1/agent-skills/openclaw/demo/archive?version=1.0.0"),
);
expect(response.status).toBe(410);
expect(response.headers.get("Cache-Control")).toBeNull();
expect(await response.text()).toBe("Skill archive file missing from storage");
});
it("returns not found for malformed percent escapes", async () => {
const runQuery = vi.fn();
const response = await agentSkillsHttpHandler(
makeCtx({ runQuery, storage: { get: vi.fn() } }),
new Request("https://api.example/api/v1/agent-skills/openclaw/%ZZ/index.json"),
);
expect(response.status).toBe(404);
expect(runQuery).not.toHaveBeenCalled();
});
it("does not expose a skill that is unavailable through the public query", async () => {
const response = await agentSkillsHttpHandler(
makeCtx({
runQuery: hostedRunQuery({ publicSkill: false }),
storage: { get: vi.fn() },
}),
new Request("https://api.example/api/v1/agent-skills/openclaw/demo/index.json"),
);
expect(response.status).toBe(404);
expect(await response.text()).toBe("Skill not found");
});
it("does not expose a hosted version blocked by moderation or security scanning", async () => {
const pendingResponse = await agentSkillsHttpHandler(
makeCtx({
runQuery: hostedRunQuery({
moderationInfo: {
isPendingScan: true,
sourceVersionId: "skillVersions:demo",
},
}),
storage: { get: vi.fn() },
}),
new Request("https://api.example/api/v1/agent-skills/openclaw/demo/index.json"),
);
expect(pendingResponse.status).toBe(423);
expect(await pendingResponse.text()).toContain("pending a ClawScan security review");
const maliciousResponse = await agentSkillsHttpHandler(
makeCtx({
runQuery: hostedRunQuery({
llmAnalysis: { verdict: "malicious" },
}),
storage: { get: vi.fn() },
}),
new Request("https://api.example/api/v1/agent-skills/openclaw/demo/index.json"),
);
expect(maliciousResponse.status).toBe(403);
expect(await maliciousResponse.text()).toContain("flagged as malicious");
});
it("normalizes a pinned GitHub-backed skill subtree", async () => {
const sourceArchive = buildDeterministicZip([
{
path: "repo-abc123/skills/demo/SKILL.md",
bytes: new TextEncoder().encode("---\nname: demo\n---\n"),
},
{
path: "repo-abc123/skills/demo/references/proof.txt",
bytes: new TextEncoder().encode("proof"),
},
{
path: "repo-abc123/README.md",
bytes: new TextEncoder().encode("outside the skill"),
},
]);
const archiveEntries = stripGitHubZipRoot(unzipSync(sourceArchive));
const contentHash = await computeGitHubSkillFolderContentHash(archiveEntries, "skills/demo");
vi.mocked(fetchGitHubZipBytes).mockResolvedValue(sourceArchive);
const githubSkill = {
...baseSkill,
installKind: "github",
githubSourceId: "githubSkillSources:demo",
githubPath: "skills/demo",
githubCurrentCommit: "def456",
githubCurrentContentHash: "current-content",
githubCurrentStatus: "present",
githubScanStatus: "clean",
};
const runQuery = vi
.fn()
.mockResolvedValueOnce(githubSkill)
.mockResolvedValueOnce({
skill: {
_id: "skills:demo",
displayName: "Demo",
summary: "A GitHub-backed demo skill.",
},
latestVersion: null,
})
.mockResolvedValueOnce({
githubSourceId: "githubSkillSources:demo",
contentHash,
commit: "def456",
path: "skills/demo",
status: "clean",
})
.mockResolvedValueOnce({ repo: "openclaw/openclaw", defaultBranch: "main" });
const response = await agentSkillsHttpHandler(
makeCtx({ runQuery, storage: { get: vi.fn() } }),
new Request(
`https://api.example/api/v1/agent-skills/openclaw/demo/archive?commit=abc123&contentHash=${contentHash}`,
),
);
expect(response.status).toBe(200);
expect(Object.keys(unzipSync(new Uint8Array(await response.arrayBuffer()))).sort()).toEqual([
"SKILL.md",
"references/proof.txt",
]);
expect(fetchGitHubZipBytes).toHaveBeenCalledWith(
expect.objectContaining({
owner: "openclaw",
repo: "openclaw",
commit: "abc123",
path: "skills/demo",
}),
fetch,
);
});
});
+405
View File
@@ -0,0 +1,405 @@
import { unzipSync } from "fflate";
import { api, internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import type { ActionCtx } from "./_generated/server";
import { scheduleSkillDownloadMetric } from "./downloads";
import { httpAction } from "./functions";
import {
buildAgentSkillsDiscoveryDocument,
buildNormalizedAgentSkillArchive,
} from "./lib/agentSkillsDiscovery";
import { fetchGitHubZipBytes, stripGitHubZipRoot } from "./lib/githubImport";
import { computeGitHubSkillFolderContentHash } from "./lib/githubSkillSync";
import {
buildSkillInstallResolution,
type InstallResolverSkill,
type InstallResolverSource,
type SkillInstallResolution,
} from "./lib/installResolver";
import {
getPublicSkillFileAccessBlock,
getPublicSkillVersionDownloadBlock,
type SkillFileModerationInfo,
} from "./lib/skillFileAccess";
const ROUTE_PREFIX = "/api/v1/agent-skills/";
type AgentSkillsCtx = ActionCtx;
type HostedVersion = {
_id: Id<"skillVersions">;
skillId: Id<"skills">;
version: string;
files: Array<{ path: string; storageId: Id<"_storage"> }>;
publicationStatus?: "pending" | "published" | "blocked";
softDeletedAt?: number;
ownerDeletedAt?: number;
llmAnalysis?: {
status?: string | null;
verdict?: string | null;
} | null;
};
type ResolvedSkill = {
skillId: Id<"skills">;
displayName: string;
description?: string | null;
resolution: Extract<SkillInstallResolution, { ok: true }>;
hostedVersion: HostedVersion | null;
};
export async function agentSkillsHttpHandler(ctx: AgentSkillsCtx, request: Request) {
const route = parseRoute(request);
if (!route) return text("Not found", 404);
const archivePin = route.action === "archive" ? parseArchivePin(request) : null;
if (route.action === "archive" && !archivePin) {
return text("Archive pin is missing or invalid; fetch the discovery document again.", 409);
}
const resolved = await resolveSkill(ctx, request, route.ownerHandle, route.slug, archivePin);
if (!resolved.ok) return text(resolved.message, resolved.status);
if (route.action === "index.json") {
const archiveResult = await buildArchive(ctx, resolved.skill);
if (!archiveResult.ok) return text(archiveResult.message, archiveResult.status);
const digest = await sha256Hex(archiveResult.archive);
const pin =
resolved.skill.resolution.installKind === "archive"
? { version: resolved.skill.resolution.archive.version }
: {
commit: resolved.skill.resolution.github.commit,
contentHash: resolved.skill.resolution.github.contentHash,
};
return Response.json(
buildAgentSkillsDiscoveryDocument({
origin: new URL(request.url).origin,
ownerHandle: route.ownerHandle,
slug: route.slug,
displayName: resolved.skill.displayName,
description: resolved.skill.description,
digest,
...pin,
}),
{
headers: {
"Cache-Control": "public, max-age=60, stale-while-revalidate=300",
"Content-Type": "application/json; charset=utf-8",
},
},
);
}
const archiveResult = await buildArchive(ctx, resolved.skill);
if (!archiveResult.ok) return text(archiveResult.message, archiveResult.status);
if (request.method !== "HEAD") {
await scheduleSkillDownloadMetric(ctx, request, resolved.skill.skillId);
}
return new Response(new Blob([archiveResult.archive], { type: "application/zip" }), {
headers: {
"Cache-Control": "no-store",
"Content-Disposition": `attachment; filename="${route.slug}.zip"`,
"Content-Type": "application/zip",
},
});
}
export const agentSkillsHttp = httpAction(agentSkillsHttpHandler);
function parseRoute(request: Request) {
const pathname = new URL(request.url).pathname;
if (!pathname.startsWith(ROUTE_PREFIX)) return null;
let segments: string[];
try {
segments = pathname
.slice(ROUTE_PREFIX.length)
.split("/")
.filter(Boolean)
.map((segment) => decodeURIComponent(segment));
} catch {
return null;
}
if (segments.length !== 3) return null;
const [ownerHandle, slug, action] = segments;
if (!ownerHandle || !slug || (action !== "index.json" && action !== "archive")) return null;
return { ownerHandle, slug: slug.toLowerCase(), action };
}
async function resolveSkill(
ctx: AgentSkillsCtx,
request: Request,
ownerHandle: string,
slug: string,
archivePin: ArchivePin | null,
): Promise<{ ok: true; skill: ResolvedSkill } | { ok: false; status: number; message: string }> {
const skill = (await ctx.runQuery(internal.skills.getSkillBySlugInternal, {
slug,
ownerHandle,
})) as
| (InstallResolverSkill & {
_id: Id<"skills">;
githubSourceId?: Id<"githubSkillSources">;
latestVersionId?: Id<"skillVersions">;
})
| null;
if (!skill) return { ok: false, status: 404, message: "Skill not found" };
const publicResult = (await ctx.runQuery(api.skills.getBySlug, {
slug,
ownerHandle,
})) as {
skill: { _id: Id<"skills">; displayName: string; summary?: string | null } | null;
moderationInfo?: SkillFileModerationInfo | null;
latestVersion: {
version: string;
} | null;
} | null;
if (!publicResult?.skill || publicResult.skill._id !== skill._id) {
return { ok: false, status: 404, message: "Skill not found" };
}
let hostedVersion: ResolvedSkill["hostedVersion"] = null;
let resolution: Extract<SkillInstallResolution, { ok: true }>;
if (archivePin?.kind === "hosted") {
const version = (await ctx.runQuery(internal.skills.getVersionBySkillAndVersionInternal, {
skillId: skill._id,
version: archivePin.version,
})) as HostedVersion | null;
if (
!version ||
version.skillId !== skill._id ||
version.version !== archivePin.version ||
version.softDeletedAt ||
version.ownerDeletedAt ||
(version.publicationStatus !== undefined && version.publicationStatus !== "published")
) {
return { ok: false, status: 404, message: "Skill version not available" };
}
const moderationBlock = getPublicSkillVersionDownloadBlock(
publicResult.moderationInfo,
version,
skill.latestVersionId,
);
if (moderationBlock) {
return {
ok: false,
status: moderationBlock.status,
message: moderationBlock.message,
};
}
hostedVersion = version;
resolution = {
ok: true,
slug: skill.slug,
installKind: "archive",
archive: {
version: version.version,
downloadUrl: "",
},
};
} else if (archivePin?.kind === "github") {
const scan = (await ctx.runQuery(
internal.githubSkillSync.getArchiveScanBySkillAndContentHashInternal,
{
skillId: skill._id,
contentHash: archivePin.contentHash,
},
)) as {
githubSourceId: Id<"githubSkillSources">;
contentHash: string;
commit: string;
path: string;
status: "clean" | "suspicious" | "malicious" | "pending" | "failed";
} | null;
if (
!scan ||
scan.contentHash !== archivePin.contentHash ||
(scan.status !== "clean" && scan.status !== "suspicious")
) {
return { ok: false, status: 404, message: "GitHub skill archive not available" };
}
const source = (await ctx.runQuery(internal.githubSkillSources.getByIdInternal, {
sourceId: scan.githubSourceId,
})) as InstallResolverSource | null;
if (!source) {
return { ok: false, status: 404, message: "GitHub skill archive not available" };
}
const moderationBlock = getPublicSkillFileAccessBlock(publicResult.moderationInfo);
if (moderationBlock) {
return {
ok: false,
status: moderationBlock.status,
message: moderationBlock.message,
};
}
resolution = {
ok: true,
slug: skill.slug,
installKind: "github",
github: {
repo: source.repo,
path: scan.path,
commit: archivePin.commit,
contentHash: scan.contentHash,
sourceUrl: `https://github.com/${source.repo}/tree/${archivePin.commit}/${scan.path}`,
},
};
} else {
const source =
skill.installKind === "github" && skill.githubSourceId
? ((await ctx.runQuery(internal.githubSkillSources.getByIdInternal, {
sourceId: skill.githubSourceId,
})) as InstallResolverSource | null)
: null;
const currentResolution = buildSkillInstallResolution({
origin: new URL(request.url).origin,
skill,
source,
ownerHandle,
});
if (!currentResolution.ok) {
return {
ok: false,
status: currentResolution.status,
message: currentResolution.message,
};
}
resolution = currentResolution;
if (resolution.installKind === "archive") {
if (
!skill.latestVersionId ||
publicResult.latestVersion?.version !== resolution.archive.version
) {
return { ok: false, status: 404, message: "Skill version not available" };
}
const version = (await ctx.runQuery(internal.skills.getVersionByIdInternal, {
versionId: skill.latestVersionId,
})) as HostedVersion | null;
if (
!version ||
version.skillId !== skill._id ||
version.version !== resolution.archive.version ||
version.softDeletedAt ||
version.ownerDeletedAt ||
(version.publicationStatus !== undefined && version.publicationStatus !== "published")
) {
return { ok: false, status: 404, message: "Skill version not available" };
}
const moderationBlock = getPublicSkillVersionDownloadBlock(
publicResult.moderationInfo,
version,
skill.latestVersionId,
);
if (moderationBlock) {
return {
ok: false,
status: moderationBlock.status,
message: moderationBlock.message,
};
}
hostedVersion = version;
} else {
const moderationBlock = getPublicSkillFileAccessBlock(publicResult.moderationInfo);
if (moderationBlock) {
return {
ok: false,
status: moderationBlock.status,
message: moderationBlock.message,
};
}
}
}
return {
ok: true,
skill: {
skillId: skill._id,
displayName: publicResult.skill.displayName,
description: publicResult.skill.summary,
resolution,
hostedVersion,
},
};
}
async function buildArchive(ctx: AgentSkillsCtx, resolved: ResolvedSkill) {
if (resolved.resolution.installKind === "archive") {
const entries: Record<string, Uint8Array> = {};
for (const file of resolved.hostedVersion?.files ?? []) {
const blob = await ctx.storage.get(file.storageId);
if (!blob) {
return {
ok: false as const,
status: 410,
message: "Skill archive file missing from storage",
};
}
entries[file.path] = new Uint8Array(await blob.arrayBuffer());
}
return {
ok: true as const,
archive: buildNormalizedAgentSkillArchive(entries),
};
}
const github = resolved.resolution.github;
const [owner, repo] = github.repo.split("/");
if (!owner || !repo) throw new Error("GitHub-backed skill source metadata is incomplete");
const zip = await fetchGitHubZipBytes(
{
owner,
repo,
ref: github.commit,
commit: github.commit,
path: github.path,
repoUrl: `https://github.com/${github.repo}`,
originalUrl: github.sourceUrl,
},
fetch,
);
const entries = stripGitHubZipRoot(unzipSync(zip));
const contentHash = await computeGitHubSkillFolderContentHash(entries, github.path);
if (contentHash !== github.contentHash) {
return {
ok: false as const,
status: 409,
message: "GitHub skill archive no longer matches its pinned content hash",
};
}
return {
ok: true as const,
archive: buildNormalizedAgentSkillArchive(entries, github.path),
};
}
type ArchivePin =
| { kind: "hosted"; version: string }
| { kind: "github"; commit: string; contentHash: string };
function parseArchivePin(request: Request): ArchivePin | null {
const url = new URL(request.url);
const version = url.searchParams.get("version")?.trim();
const commit = url.searchParams.get("commit")?.trim();
const contentHash = url.searchParams.get("contentHash")?.trim();
if (version && !commit && !contentHash) {
return { kind: "hosted", version };
}
if (!version && commit && contentHash) {
return { kind: "github", commit, contentHash };
}
return null;
}
async function sha256Hex(bytes: Uint8Array) {
const digest = await crypto.subtle.digest("SHA-256", new Uint8Array(bytes));
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
function text(message: string, status: number) {
return new Response(message, {
status,
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
+1 -1
View File
@@ -198,7 +198,7 @@ async function githubDownloadHandoffResponse(
});
}
async function scheduleSkillDownloadMetric(
export async function scheduleSkillDownloadMetric(
ctx: DownloadCtx,
request: Request,
skillId: Id<"skills">,
+15
View File
@@ -217,6 +217,21 @@ const githubSkillScanStatusValidator = v.union(
v.literal("failed"),
);
export const getArchiveScanBySkillAndContentHashInternal = internalQuery({
args: {
skillId: v.id("skills"),
contentHash: v.string(),
},
handler: async (ctx, args) => {
return await ctx.db
.query("githubSkillScans")
.withIndex("by_skill_and_content_hash", (q) =>
q.eq("skillId", args.skillId).eq("contentHash", args.contentHash),
)
.unique();
},
});
export const getSourceByRepoInternal = internalQuery({
args: { repo: v.string() },
handler: async (ctx, args): Promise<SourceForSync | null> => {
+8
View File
@@ -1,5 +1,6 @@
import { ApiRoutes, LegacyApiRoutes } from "clawhub-schema";
import { httpRouter } from "convex/server";
import { agentSkillsHttp } from "./agentSkillsHttp";
import { auth } from "./auth";
import { downloadZip } from "./downloads";
import {
@@ -74,6 +75,13 @@ const http = installRateLimitedRoutes(httpRouter());
auth.addHttpRoutes(http);
// Convex routes HEAD through the matching GET action and strips the body.
http.route({
pathPrefix: "/api/v1/agent-skills/",
method: "GET",
handler: agentSkillsHttp,
});
http.route({
path: ApiRoutes.download,
method: "GET",
+48
View File
@@ -0,0 +1,48 @@
import { strFromU8, unzipSync } from "fflate";
import { describe, expect, it } from "vitest";
import {
buildAgentSkillsDiscoveryDocument,
buildNormalizedAgentSkillArchive,
} from "./agentSkillsDiscovery";
describe("Agent Skills discovery", () => {
it("builds a pinned v0.2 discovery document", () => {
expect(
buildAgentSkillsDiscoveryDocument({
origin: "https://clawhub.ai",
ownerHandle: "openclaw",
slug: "demo",
displayName: "Demo",
description: "Install the demo skill.",
digest: "a".repeat(64),
version: "1.2.3",
}),
).toEqual({
$schema: "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
skills: [
{
name: "demo",
type: "archive",
description: "Install the demo skill.",
url: "https://clawhub.ai/api/v1/agent-skills/openclaw/demo/archive?version=1.2.3",
digest: `sha256:${"a".repeat(64)}`,
},
],
});
});
it("normalizes a GitHub skill subtree into an installable root archive", () => {
const archive = buildNormalizedAgentSkillArchive(
{
"skills/demo/skills.md": new TextEncoder().encode("# Demo"),
"skills/demo/references/setup.md": new TextEncoder().encode("Setup"),
"skills/other/SKILL.md": new TextEncoder().encode("# Other"),
},
"skills/demo",
);
const files = unzipSync(archive);
expect(Object.keys(files).sort()).toEqual(["SKILL.md", "references/setup.md"]);
expect(strFromU8(files["SKILL.md"]!)).toBe("# Demo");
});
});
+79
View File
@@ -0,0 +1,79 @@
import { buildDeterministicZip, validateFilePath } from "./skillZip";
const AGENT_SKILLS_DISCOVERY_SCHEMA = "https://schemas.agentskills.io/discovery/0.2.0/schema.json";
type DiscoveryPin =
| { version: string; commit?: never; contentHash?: never }
| { version?: never; commit: string; contentHash: string };
export function buildAgentSkillsDiscoveryDocument(
args: {
origin: string;
ownerHandle: string;
slug: string;
displayName: string;
description?: string | null;
digest: string;
} & DiscoveryPin,
) {
const archiveUrl = new URL(
`/api/v1/agent-skills/${encodeURIComponent(args.ownerHandle)}/${encodeURIComponent(args.slug)}/archive`,
args.origin,
);
if (args.version !== undefined) {
archiveUrl.searchParams.set("version", args.version);
} else {
archiveUrl.searchParams.set("commit", args.commit!);
archiveUrl.searchParams.set("contentHash", args.contentHash!);
}
return {
$schema: AGENT_SKILLS_DISCOVERY_SCHEMA,
skills: [
{
name: args.slug,
type: "archive" as const,
description: normalizeDescription(args.description, args.displayName),
url: archiveUrl.toString(),
digest: `sha256:${args.digest}`,
},
],
};
}
export function buildNormalizedAgentSkillArchive(
entries: Record<string, Uint8Array>,
rootPath = "",
) {
const normalizedRoot = rootPath.replace(/^\/+|\/+$/g, "");
const rootPrefix = normalizedRoot ? `${normalizedRoot}/` : "";
const selected = Object.entries(entries)
.flatMap(([rawPath, bytes]) => {
const path = rawPath.replace(/^\/+/, "");
if (rootPrefix && !path.startsWith(rootPrefix)) return [];
const relativePath = rootPrefix ? path.slice(rootPrefix.length) : path;
if (!relativePath || !validateFilePath(relativePath)) return [];
return [{ path: relativePath, bytes: new Uint8Array(bytes) }];
})
.sort((a, b) => a.path.localeCompare(b.path));
const skillFile =
selected.find((entry) => entry.path === "SKILL.md") ??
selected.find((entry) => entry.path.toLowerCase() === "skill.md") ??
selected.find((entry) => entry.path.toLowerCase() === "skills.md");
if (!skillFile) {
throw new Error("Skill archive is missing SKILL.md");
}
const normalized = selected
.filter((entry) => entry !== skillFile && entry.path !== "SKILL.md")
.map((entry) => ({ path: entry.path, bytes: entry.bytes }));
normalized.push({ path: "SKILL.md", bytes: skillFile.bytes });
return buildDeterministicZip(normalized);
}
function normalizeDescription(description: string | null | undefined, displayName: string) {
const normalized = description?.trim() || `${displayName} from ClawHub.`;
return normalized.slice(0, 1024);
}
+131
View File
@@ -0,0 +1,131 @@
/* @vitest-environment node */
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import { buildAgentSkillsDiscoveryDocument } from "../convex/lib/agentSkillsDiscovery";
import { buildDeterministicZip } from "../convex/lib/skillZip";
const execFileAsync = promisify(execFile);
const tempDirs: string[] = [];
const servers: Array<ReturnType<typeof createServer>> = [];
afterEach(async () => {
await Promise.all(
servers
.splice(0)
.map(
(server) =>
new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
),
),
);
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
describe("Agent Skills CLI compatibility", () => {
it("installs a ClawHub skill page URL with the real npx skills CLI", async () => {
const skillMarkdown = `---
name: demo
description: Demonstrates ClawHub Agent Skills discovery.
---
# Demo
Installed from a ClawHub skill page URL.
`;
const archive = buildDeterministicZip([
{ path: "SKILL.md", bytes: new TextEncoder().encode(skillMarkdown) },
{
path: "references/proof.txt",
bytes: new TextEncoder().encode("supporting file installed"),
},
]);
const digest = createHash("sha256").update(archive).digest("hex");
const server = createServer((request, response) => {
const origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
const url = new URL(request.url ?? "/", origin);
if (url.pathname === "/openclaw/skills/demo/.well-known/agent-skills/index.json") {
response.writeHead(200, { "Content-Type": "application/json" });
response.end(
JSON.stringify(
buildAgentSkillsDiscoveryDocument({
origin,
ownerHandle: "openclaw",
slug: "demo",
displayName: "Demo",
description: "Demonstrates ClawHub Agent Skills discovery.",
digest,
version: "1.0.0",
}),
),
);
return;
}
if (
url.pathname === "/api/v1/agent-skills/openclaw/demo/archive" &&
url.searchParams.get("version") === "1.0.0"
) {
response.writeHead(200, { "Content-Type": "application/zip" });
response.end(Buffer.from(archive));
return;
}
response.writeHead(404, { "Content-Type": "text/plain" });
response.end("not found");
});
servers.push(server);
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const projectDir = await mkdtemp(join(tmpdir(), "clawhub-agent-skills-e2e-"));
tempDirs.push(projectDir);
await writeFile(join(projectDir, "package.json"), '{"private":true}\n', "utf8");
const origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
const result = await execFileAsync(
"npx",
[
"--yes",
"skills@1.5.20",
"add",
`${origin}/openclaw/skills/demo`,
"--agent",
"codex",
"--skill",
"demo",
"--yes",
"--copy",
],
{
cwd: projectDir,
encoding: "utf8",
timeout: 90_000,
maxBuffer: 1024 * 1024,
env: {
...process.env,
CI: "1",
DO_NOT_TRACK: "1",
NO_COLOR: "1",
},
},
);
expect(result.stderr).not.toContain("Error");
expect(await readFile(join(projectDir, ".agents/skills/demo/SKILL.md"), "utf8")).toContain(
"Installed from a ClawHub skill page URL.",
);
expect(
await readFile(join(projectDir, ".agents/skills/demo/references/proof.txt"), "utf8"),
).toBe("supporting file installed");
}, 120_000);
});
+1 -1
View File
@@ -14,7 +14,7 @@
"check": "bun run lint",
"check:peers": "bun scripts/check-peer-deps.ts",
"check:release-workflow-action-pins": "node scripts/check-release-workflow-action-pins.mjs",
"ci:e2e-http": "bun run test:e2e:prod-http && bunx vitest run -c vitest.e2e.config.ts e2e/clawhub.e2e.test.ts --testNamePattern \"prints CLI version|search endpoint returns a results array|cli search does not error|cli scan rejects local folders|cli scan download fetches a stored submitted-version scan report|package publish --dry-run from a GitHub repo|package publish --dry-run --json|package publish exits non-zero when Plugin Inspector hard errors block publish|package publish exits zero and prints Plugin Inspector warnings|package publish help shows|skill verify help omits the redundant json flag|skill verify accepts the legacy json flag\" && bunx vitest run -c vitest.e2e.config.ts e2e/permissions.e2e.test.ts",
"ci:e2e-http": "bun run test:e2e:prod-http && bunx vitest run -c vitest.e2e.config.ts e2e/agent-skills-discovery.e2e.test.ts && bunx vitest run -c vitest.e2e.config.ts e2e/clawhub.e2e.test.ts --testNamePattern \"prints CLI version|search endpoint returns a results array|cli search does not error|cli scan rejects local folders|cli scan download fetches a stored submitted-version scan report|package publish --dry-run from a GitHub repo|package publish --dry-run --json|package publish exits non-zero when Plugin Inspector hard errors block publish|package publish exits zero and prints Plugin Inspector warnings|package publish help shows|skill verify help omits the redundant json flag|skill verify accepts the legacy json flag\" && bunx vitest run -c vitest.e2e.config.ts e2e/permissions.e2e.test.ts",
"ci:packages": "bun run --cwd packages/schema build && bun run --cwd packages/clawhub verify && bun run --cwd packages/clawhub-admin verify",
"ci:playwright": "VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run build && VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run test:pw",
"ci:playwright-smoke": "VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run build && VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run test:pw -- --project=chromium e2e/ci-smoke.pw.test.ts e2e/public-routes-smoke.pw.test.ts",
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { proxyAgentSkillsDiscoveryResponse } from "../routes/$owner/skills/$slug/[.]well-known/agent-skills/index[.]json";
describe("Agent Skills discovery route", () => {
it("does not forward stale compression or transport headers", async () => {
const upstream = new Response('{"skills":[]}', {
status: 200,
headers: {
"Cache-Control": "public, max-age=60",
Connection: "keep-alive",
"Content-Encoding": "gzip",
"Content-Length": "123",
"Content-Type": "application/json; charset=utf-8",
},
});
const response = await proxyAgentSkillsDiscoveryResponse(upstream);
expect(response.status).toBe(200);
expect(await response.text()).toBe('{"skills":[]}');
expect(response.headers.get("Cache-Control")).toBe("public, max-age=60");
expect(response.headers.get("Content-Type")).toBe("application/json; charset=utf-8");
expect(response.headers.get("Connection")).toBeNull();
expect(response.headers.get("Content-Encoding")).toBeNull();
expect(response.headers.get("Content-Length")).toBeNull();
});
it("returns the discovery headers without a body for HEAD requests", async () => {
const upstream = new Response('{"skills":[]}', {
status: 200,
headers: {
"Cache-Control": "public, max-age=60",
"Content-Type": "application/json; charset=utf-8",
},
});
const response = await proxyAgentSkillsDiscoveryResponse(upstream, false);
expect(response.status).toBe(200);
expect(await response.text()).toBe("");
expect(response.headers.get("Cache-Control")).toBe("public, max-age=60");
expect(response.headers.get("Content-Type")).toBe("application/json; charset=utf-8");
});
});
+7
View File
@@ -1218,7 +1218,14 @@ describe("SkillDetailPage", () => {
expect(screen.queryByText("npx clawhub@latest install @steipete/weather")).toBeNull();
expect(screen.queryByRole("tab", { name: "ClawHub" })).toBeNull();
expect(screen.getByRole("button", { name: "CLI" }).getAttribute("aria-pressed")).toBe("true");
const skillsCliButton = screen.getByRole("button", { name: "npx skills" });
expect(skillsCliButton).toBeTruthy();
expect(screen.getByRole("button", { name: "Prompt" })).toBeTruthy();
fireEvent.click(skillsCliButton);
expect(
screen.getByText("npx skills add https://clawhub.ai/steipete/skills/weather"),
).toBeTruthy();
expect(skillsCliButton.getAttribute("aria-pressed")).toBe("true");
expect(screen.queryByText(/After install, inspect the skill metadata/i)).toBeNull();
expect(screen.getAllByText("Security audit").length).toBeGreaterThan(0);
expect(screen.getByRole("link", { name: "View Security Audit" }).getAttribute("href")).toBe(
+35 -8
View File
@@ -5,8 +5,10 @@ import type { Id } from "../../convex/_generated/dataModel";
import { copyText, InstallCopyButton } from "./InstallCopyButton";
import {
buildSkillInstallTarget,
buildSkillPageUrl,
formatOpenClawInstallCommand,
formatOpenClawPrompt,
formatSkillsCliInstallCommand,
type SkillPromptMode,
} from "./skillDetailUtils";
import { Button } from "./ui/button";
@@ -193,10 +195,13 @@ export function SkillCommandLineCard({
clawdis,
}: SkillInstallSurfaceProps) {
const headingId = useId();
const [activeInstallTab, setActiveInstallTab] = useState<"cli" | "prompt">("cli");
type InstallTab = "cli" | "skills" | "prompt";
const [activeInstallTab, setActiveInstallTab] = useState<InstallTab>("cli");
const [installTabDirection, setInstallTabDirection] = useState<"left" | "right">("right");
const installTarget = buildSkillInstallTarget(ownerHandle, ownerId, slug);
const openClawCommand = formatOpenClawInstallCommand(installTarget);
const skillPageUrl = buildSkillPageUrl(ownerHandle, ownerId, slug);
const skillsCliCommand = skillPageUrl ? formatSkillsCliInstallCommand(skillPageUrl) : null;
const promptPreview = formatOpenClawPrompt({
mode: "install-and-setup",
skillName: displayName,
@@ -205,13 +210,21 @@ export function SkillCommandLineCard({
ownerId,
clawdis,
});
const activeInstallText = activeInstallTab === "prompt" ? promptPreview : openClawCommand;
const selectInstallTab = (tab: "cli" | "prompt") => {
const activeInstallText =
activeInstallTab === "prompt"
? promptPreview
: activeInstallTab === "skills" && skillsCliCommand
? skillsCliCommand
: openClawCommand;
const installTabOrder: InstallTab[] = ["cli", "skills", "prompt"];
const selectInstallTab = (tab: InstallTab) => {
if (tab === activeInstallTab) {
return;
}
setInstallTabDirection(tab === "prompt" ? "right" : "left");
setInstallTabDirection(
installTabOrder.indexOf(tab) > installTabOrder.indexOf(activeInstallTab) ? "right" : "left",
);
setActiveInstallTab(tab);
};
@@ -230,6 +243,16 @@ export function SkillCommandLineCard({
>
CLI
</button>
{skillsCliCommand ? (
<button
type="button"
className={`skill-install-tab${activeInstallTab === "skills" ? " is-active" : ""}`}
aria-pressed={activeInstallTab === "skills"}
onClick={() => selectInstallTab("skills")}
>
npx skills
</button>
) : null}
<button
type="button"
className={`skill-install-tab${activeInstallTab === "prompt" ? " is-active" : ""}`}
@@ -244,10 +267,10 @@ export function SkillCommandLineCard({
<div className="skill-install-command-wrap">
<div
className={`skill-install-command-shell${
activeInstallTab === "cli" ? " skill-install-command-shell-cli" : ""
activeInstallTab !== "prompt" ? " skill-install-command-shell-cli" : ""
}`}
>
{activeInstallTab === "cli" ? (
{activeInstallTab !== "prompt" ? (
<span className="skill-install-command-prompt" aria-hidden="true">
$
</span>
@@ -260,7 +283,7 @@ export function SkillCommandLineCard({
} skill-install-command-reveal`}
tabIndex={0}
>
{activeInstallTab === "cli" ? (
{activeInstallTab !== "prompt" ? (
<OpenClawCliInstallCommand command={activeInstallText} />
) : (
<code translate="no">{activeInstallText}</code>
@@ -269,7 +292,11 @@ export function SkillCommandLineCard({
<InstallCopyButton
text={activeInstallText}
ariaLabel={
activeInstallTab === "prompt" ? "Copy OpenClaw prompt" : "Copy OpenClaw CLI command"
activeInstallTab === "prompt"
? "Copy OpenClaw prompt"
: activeInstallTab === "skills"
? "Copy npx skills command"
: "Copy OpenClaw CLI command"
}
className="skill-install-command-inline-button"
showLabel={false}
+4
View File
@@ -7,6 +7,7 @@ import {
formatClawHubInstallCommand,
formatOpenClawInstallCommand,
formatOpenClawPrompt,
formatSkillsCliInstallCommand,
} from "./skillDetailUtils";
describe("skill detail install helpers", () => {
@@ -36,6 +37,9 @@ describe("skill detail install helpers", () => {
expect(formatClawHubInstallCommand("@steipete/weather", "bun")).toBe(
"bunx clawhub@latest install @steipete/weather",
);
expect(formatSkillsCliInstallCommand("https://clawhub.ai/steipete/skills/weather")).toBe(
"npx skills add https://clawhub.ai/steipete/skills/weather",
);
});
it("builds the install-and-setup prompt from known metadata only", () => {
+4
View File
@@ -181,6 +181,10 @@ export function formatOpenClawInstallCommand(slug: string) {
return `openclaw skills install ${slug}`;
}
export function formatSkillsCliInstallCommand(skillPageUrl: string) {
return `npx skills add ${skillPageUrl}`;
}
export function formatClawHubInstallCommand(slug: string, pm: SkillPackageManager) {
switch (pm) {
case "npm":
+23
View File
@@ -61,6 +61,7 @@ import { Route as SkillsShOwnerRepoSlugRouteImport } from './routes/skills-sh/$o
import { Route as OwnerPluginsSlugSecurityScannerRouteImport } from './routes/$owner/plugins/$slug/security/$scanner'
import { Route as OwnerSkillsSlugSecurityScannerRouteImport } from './routes/$owner/skills/$slug/security/$scanner'
import { Route as PluginsScopeNameSecurityScannerRouteImport } from './routes/plugins/$scope/$name/security/$scanner'
import { Route as OwnerSkillsSlugDotwellKnownAgentSkillsIndexDotjsonRouteImport } from './routes/$owner/skills/$slug/[.]well-known/agent-skills/index[.]json'
const IndexRoute = IndexRouteImport.update({
id: '/',
@@ -331,6 +332,12 @@ const PluginsScopeNameSecurityScannerRoute =
path: '/security/$scanner',
getParentRoute: () => PluginsScopeNameRoute,
} as any)
const OwnerSkillsSlugDotwellKnownAgentSkillsIndexDotjsonRoute =
OwnerSkillsSlugDotwellKnownAgentSkillsIndexDotjsonRouteImport.update({
id: '/.well-known/agent-skills/index.json',
path: '/.well-known/agent-skills/index.json',
getParentRoute: () => OwnerSkillsSlugRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
@@ -385,6 +392,7 @@ export interface FileRoutesByFullPath {
'/$owner/plugins/$slug/security/$scanner': typeof OwnerPluginsSlugSecurityScannerRoute
'/$owner/skills/$slug/security/$scanner': typeof OwnerSkillsSlugSecurityScannerRoute
'/plugins/$scope/$name/security/$scanner': typeof PluginsScopeNameSecurityScannerRoute
'/$owner/skills/$slug/.well-known/agent-skills/index.json': typeof OwnerSkillsSlugDotwellKnownAgentSkillsIndexDotjsonRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@@ -439,6 +447,7 @@ export interface FileRoutesByTo {
'/$owner/plugins/$slug/security/$scanner': typeof OwnerPluginsSlugSecurityScannerRoute
'/$owner/skills/$slug/security/$scanner': typeof OwnerSkillsSlugSecurityScannerRoute
'/plugins/$scope/$name/security/$scanner': typeof PluginsScopeNameSecurityScannerRoute
'/$owner/skills/$slug/.well-known/agent-skills/index.json': typeof OwnerSkillsSlugDotwellKnownAgentSkillsIndexDotjsonRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -494,6 +503,7 @@ export interface FileRoutesById {
'/$owner/plugins/$slug/security/$scanner': typeof OwnerPluginsSlugSecurityScannerRoute
'/$owner/skills/$slug/security/$scanner': typeof OwnerSkillsSlugSecurityScannerRoute
'/plugins/$scope/$name/security/$scanner': typeof PluginsScopeNameSecurityScannerRoute
'/$owner/skills/$slug/.well-known/agent-skills/index.json': typeof OwnerSkillsSlugDotwellKnownAgentSkillsIndexDotjsonRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -550,6 +560,7 @@ export interface FileRouteTypes {
| '/$owner/plugins/$slug/security/$scanner'
| '/$owner/skills/$slug/security/$scanner'
| '/plugins/$scope/$name/security/$scanner'
| '/$owner/skills/$slug/.well-known/agent-skills/index.json'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@@ -604,6 +615,7 @@ export interface FileRouteTypes {
| '/$owner/plugins/$slug/security/$scanner'
| '/$owner/skills/$slug/security/$scanner'
| '/plugins/$scope/$name/security/$scanner'
| '/$owner/skills/$slug/.well-known/agent-skills/index.json'
id:
| '__root__'
| '/'
@@ -658,6 +670,7 @@ export interface FileRouteTypes {
| '/$owner/plugins/$slug/security/$scanner'
| '/$owner/skills/$slug/security/$scanner'
| '/plugins/$scope/$name/security/$scanner'
| '/$owner/skills/$slug/.well-known/agent-skills/index.json'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -1069,6 +1082,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PluginsScopeNameSecurityScannerRouteImport
parentRoute: typeof PluginsScopeNameRoute
}
'/$owner/skills/$slug/.well-known/agent-skills/index.json': {
id: '/$owner/skills/$slug/.well-known/agent-skills/index.json'
path: '/.well-known/agent-skills/index.json'
fullPath: '/$owner/skills/$slug/.well-known/agent-skills/index.json'
preLoaderRoute: typeof OwnerSkillsSlugDotwellKnownAgentSkillsIndexDotjsonRouteImport
parentRoute: typeof OwnerSkillsSlugRoute
}
}
}
@@ -1119,12 +1139,15 @@ interface OwnerSkillsSlugRouteChildren {
OwnerSkillsSlugSecurityAuditRoute: typeof OwnerSkillsSlugSecurityAuditRoute
OwnerSkillsSlugSettingsRoute: typeof OwnerSkillsSlugSettingsRoute
OwnerSkillsSlugSecurityScannerRoute: typeof OwnerSkillsSlugSecurityScannerRoute
OwnerSkillsSlugDotwellKnownAgentSkillsIndexDotjsonRoute: typeof OwnerSkillsSlugDotwellKnownAgentSkillsIndexDotjsonRoute
}
const OwnerSkillsSlugRouteChildren: OwnerSkillsSlugRouteChildren = {
OwnerSkillsSlugSecurityAuditRoute: OwnerSkillsSlugSecurityAuditRoute,
OwnerSkillsSlugSettingsRoute: OwnerSkillsSlugSettingsRoute,
OwnerSkillsSlugSecurityScannerRoute: OwnerSkillsSlugSecurityScannerRoute,
OwnerSkillsSlugDotwellKnownAgentSkillsIndexDotjsonRoute:
OwnerSkillsSlugDotwellKnownAgentSkillsIndexDotjsonRoute,
}
const OwnerSkillsSlugRouteWithChildren = OwnerSkillsSlugRoute._addFileChildren(
@@ -0,0 +1,36 @@
import { createFileRoute } from "@tanstack/react-router";
import { publicApiUrl } from "../../../../../../lib/publicApiUrl";
export const Route = createFileRoute("/$owner/skills/$slug/.well-known/agent-skills/index.json")({
server: {
handlers: {
GET: ({ params }) => fetchAgentSkillsDiscovery(params.owner, params.slug, "GET"),
HEAD: ({ params }) => fetchAgentSkillsDiscovery(params.owner, params.slug, "HEAD"),
},
},
});
async function fetchAgentSkillsDiscovery(owner: string, slug: string, method: "GET" | "HEAD") {
const upstream = publicApiUrl(
`/api/v1/agent-skills/${encodeURIComponent(owner)}/${encodeURIComponent(slug)}/index.json`,
);
const response = await fetch(upstream, {
method,
headers: { Accept: "application/json" },
});
return proxyAgentSkillsDiscoveryResponse(response, method === "GET");
}
export async function proxyAgentSkillsDiscoveryResponse(response: Response, includeBody = true) {
const headers = new Headers();
const contentType = response.headers.get("Content-Type");
const cacheControl = response.headers.get("Cache-Control");
if (contentType) headers.set("Content-Type", contentType);
if (cacheControl) headers.set("Cache-Control", cacheControl);
return new Response(includeBody ? await response.arrayBuffer() : null, {
status: response.status,
statusText: response.statusText,
headers,
});
}