- Add POST/GET /skill-registry/skills/:sourceId/:slug/ratings routes - Import skillRatingService in skill-registry routes - Add upsertCommunityRatingsStub() in fetcher, called after each skill upsert - Import communityRatings from schema in fetcher - Update list() and getById() in skill-registry.ts to LEFT JOIN communityRatings - Include averageRating, ratingCount, taskCount, avgCostUsd, lastUsedAt in SkillListItem - Add agentSkills usage aggregation via LEFT JOIN + SUM/AVG/MAX - Add fire-and-forget recordUsageForAgent call in heartbeat after finalizeAgentStatus - Dynamic import keeps skill-registry-ratings off critical startup path - All 44 skill-registry tests pass, full server suite (536) green
411 lines
11 KiB
TypeScript
411 lines
11 KiB
TypeScript
import crypto from "node:crypto";
|
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
import { existsSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { eq } from "drizzle-orm";
|
|
import { getSkillRegistryDb, type SkillRegistryDb } from "./skill-registry-db.js";
|
|
import { skills, skillVersions, skillFiles, communityRatings } from "./skill-registry-schema.js";
|
|
import {
|
|
fetchText,
|
|
fetchJson,
|
|
resolveGitHubCommitSha,
|
|
resolveRawGitHubUrl,
|
|
} from "./github-skill-helpers.js";
|
|
import { resolveSkillCacheDir } from "../home-paths.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Source config
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type SkillSourceConfig = {
|
|
id: string;
|
|
type: "anthropic-marketplace" | "github-tree";
|
|
owner: string;
|
|
repo: string;
|
|
ref: string;
|
|
label: string;
|
|
};
|
|
|
|
export const BUILT_IN_SOURCES: SkillSourceConfig[] = [
|
|
{
|
|
id: "anthropic-official",
|
|
type: "anthropic-marketplace",
|
|
owner: "anthropics",
|
|
repo: "skills",
|
|
ref: "main",
|
|
label: "Anthropic Official",
|
|
},
|
|
{
|
|
id: "schwepps-skills",
|
|
type: "github-tree",
|
|
owner: "schwepps",
|
|
repo: "skills",
|
|
ref: "main",
|
|
label: "Schwepps Community",
|
|
},
|
|
{
|
|
id: "daymade-skills",
|
|
type: "github-tree",
|
|
owner: "daymade",
|
|
repo: "claude-code-skills",
|
|
ref: "main",
|
|
label: "Daymade Community",
|
|
},
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Frontmatter parsing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Parse YAML frontmatter from a SKILL.md string.
|
|
* Only extracts `name` and `description` fields.
|
|
*/
|
|
export function parseSkillFrontmatter(markdown: string): {
|
|
name?: string;
|
|
description?: string;
|
|
} {
|
|
const match = /^---\r?\n([\s\S]*?)\r?\n---/m.exec(markdown);
|
|
if (!match) return {};
|
|
|
|
const block = match[1] ?? "";
|
|
const nameMatch = /^name:\s*(.+)$/m.exec(block);
|
|
const descMatch = /^description:\s*(.+)$/m.exec(block);
|
|
|
|
const name = nameMatch?.[1]?.trim();
|
|
const description = descMatch?.[1]?.trim();
|
|
|
|
return {
|
|
name: name && name.length > 0 ? name : undefined,
|
|
description: description && description.length > 0 ? description : undefined,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Convert a path segment to a URL-safe skill slug.
|
|
* e.g. "My Skill Name" → "my-skill-name"
|
|
*/
|
|
export function slugFromPath(sourcePath: string): string {
|
|
// Take the last non-empty path segment (the directory name of the skill)
|
|
const parts = sourcePath.split("/").filter(Boolean);
|
|
const segment = parts[parts.length - 1] ?? sourcePath;
|
|
return segment
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9-]+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Core fetch helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type GitHubTreeEntry = {
|
|
path: string;
|
|
type: string;
|
|
size?: number;
|
|
};
|
|
|
|
type GitHubTreeResponse = {
|
|
tree: GitHubTreeEntry[];
|
|
};
|
|
|
|
type MarketplaceJson = {
|
|
skills: Array<{ path: string }>;
|
|
};
|
|
|
|
/**
|
|
* Upsert a skill row and return its id.
|
|
*/
|
|
async function upsertSkill(
|
|
db: SkillRegistryDb,
|
|
opts: {
|
|
skillId: string;
|
|
sourceId: string;
|
|
name: string;
|
|
description: string | undefined;
|
|
sourceUrl: string;
|
|
},
|
|
): Promise<void> {
|
|
const now = Date.now();
|
|
await db
|
|
.insert(skills)
|
|
.values({
|
|
id: opts.skillId,
|
|
sourceId: opts.sourceId,
|
|
name: opts.name,
|
|
description: opts.description ?? null,
|
|
sourceUrl: opts.sourceUrl,
|
|
activeVersionId: null,
|
|
removedAt: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: skills.id,
|
|
set: {
|
|
name: opts.name,
|
|
description: opts.description ?? null,
|
|
updatedAt: now,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Upsert a stub community_ratings row for a skill.
|
|
* This ensures list() and getById() JOINs always find a row.
|
|
* Real rating values are populated in v1.3 when community APIs are available.
|
|
*/
|
|
async function upsertCommunityRatingsStub(
|
|
db: SkillRegistryDb,
|
|
skillId: string,
|
|
sourceId: string,
|
|
): Promise<void> {
|
|
await db
|
|
.insert(communityRatings)
|
|
.values({
|
|
id: `${skillId}@${sourceId}`,
|
|
skillId,
|
|
fetchedAt: Date.now(),
|
|
averageRating: null,
|
|
ratingCount: null,
|
|
source: sourceId,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: communityRatings.id,
|
|
set: {
|
|
fetchedAt: Date.now(),
|
|
averageRating: null,
|
|
ratingCount: null,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check whether a version with this SHA already exists in the DB.
|
|
* Returns true if already present (skip download).
|
|
*/
|
|
async function versionExists(db: SkillRegistryDb, versionId: string): Promise<boolean> {
|
|
const existing = await db
|
|
.select({ id: skillVersions.id })
|
|
.from(skillVersions)
|
|
.where(eq(skillVersions.id, versionId));
|
|
return existing.length > 0;
|
|
}
|
|
|
|
/**
|
|
* Cache SKILL.md to disk and insert skill_versions + skill_files rows.
|
|
*/
|
|
async function cacheSkillVersion(
|
|
db: SkillRegistryDb,
|
|
opts: {
|
|
skillId: string;
|
|
sha: string;
|
|
skillMdContent: string;
|
|
skillMdUrl: string;
|
|
},
|
|
): Promise<void> {
|
|
const versionId = `${opts.skillId}@${opts.sha}`;
|
|
|
|
// Idempotency check — skip if version already cached
|
|
if (await versionExists(db, versionId)) {
|
|
return;
|
|
}
|
|
|
|
const cacheDir = resolveSkillCacheDir(opts.skillId, opts.sha);
|
|
await mkdir(cacheDir, { recursive: true });
|
|
|
|
const skillMdPath = path.join(cacheDir, "SKILL.md");
|
|
await writeFile(skillMdPath, opts.skillMdContent, "utf-8");
|
|
|
|
const now = Date.now();
|
|
|
|
// Insert skill_versions row
|
|
await db.insert(skillVersions).values({
|
|
id: versionId,
|
|
skillId: opts.skillId,
|
|
version: opts.sha,
|
|
fetchedAt: now,
|
|
cacheDir,
|
|
});
|
|
|
|
// Insert skill_files row for SKILL.md
|
|
const sizeBytes = Buffer.byteLength(opts.skillMdContent, "utf-8");
|
|
await db.insert(skillFiles).values({
|
|
id: crypto.randomUUID(),
|
|
versionId,
|
|
path: "SKILL.md",
|
|
kind: "skill",
|
|
sizeBytes,
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Source-type handlers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function fetchAnthropicMarketplace(
|
|
source: SkillSourceConfig,
|
|
db: SkillRegistryDb,
|
|
): Promise<number> {
|
|
const marketplaceUrl = resolveRawGitHubUrl(
|
|
source.owner,
|
|
source.repo,
|
|
source.ref,
|
|
".claude-plugin/marketplace.json",
|
|
);
|
|
|
|
const marketplaceText = await fetchText(marketplaceUrl);
|
|
const marketplace: MarketplaceJson = JSON.parse(marketplaceText);
|
|
const sha = await resolveGitHubCommitSha(source.owner, source.repo, source.ref);
|
|
|
|
let fetched = 0;
|
|
|
|
for (const entry of marketplace.skills ?? []) {
|
|
const skillPath = entry.path;
|
|
const slug = slugFromPath(skillPath);
|
|
const skillId = `${source.id}/${slug}`;
|
|
|
|
// Idempotency check before downloading — skip if version already cached
|
|
const versionId = `${skillId}@${sha}`;
|
|
if (await versionExists(db, versionId)) {
|
|
fetched++;
|
|
continue;
|
|
}
|
|
|
|
const skillMdUrl = resolveRawGitHubUrl(source.owner, source.repo, source.ref, `${skillPath}/SKILL.md`);
|
|
let skillMdContent: string;
|
|
try {
|
|
skillMdContent = await fetchText(skillMdUrl);
|
|
} catch {
|
|
// Skip skills that don't have a SKILL.md
|
|
continue;
|
|
}
|
|
|
|
const { name, description } = parseSkillFrontmatter(skillMdContent);
|
|
const sourceUrl = `https://github.com/${source.owner}/${source.repo}/tree/${source.ref}/${skillPath}`;
|
|
|
|
await upsertSkill(db, {
|
|
skillId,
|
|
sourceId: source.id,
|
|
name: name ?? slug,
|
|
description,
|
|
sourceUrl,
|
|
});
|
|
|
|
await cacheSkillVersion(db, {
|
|
skillId,
|
|
sha,
|
|
skillMdContent,
|
|
skillMdUrl,
|
|
});
|
|
|
|
await upsertCommunityRatingsStub(db, skillId, source.id);
|
|
|
|
fetched++;
|
|
}
|
|
|
|
return fetched;
|
|
}
|
|
|
|
async function fetchGitHubTree(
|
|
source: SkillSourceConfig,
|
|
db: SkillRegistryDb,
|
|
): Promise<number> {
|
|
const treeUrl = `https://api.github.com/repos/${source.owner}/${source.repo}/git/trees/${encodeURIComponent(source.ref)}?recursive=1`;
|
|
const treeResponse = await fetchJson<GitHubTreeResponse>(treeUrl);
|
|
|
|
const sha = await resolveGitHubCommitSha(source.owner, source.repo, source.ref);
|
|
|
|
// Find all SKILL.md files
|
|
const skillMdEntries = (treeResponse.tree ?? []).filter(
|
|
(entry) => entry.type === "blob" && entry.path.endsWith("SKILL.md"),
|
|
);
|
|
|
|
let fetched = 0;
|
|
|
|
for (const entry of skillMdEntries) {
|
|
// entry.path is like "code-review/SKILL.md" — dirname is the skill dir
|
|
const skillDir = path.posix.dirname(entry.path);
|
|
if (!skillDir || skillDir === ".") continue;
|
|
|
|
const slug = slugFromPath(skillDir);
|
|
const skillId = `${source.id}/${slug}`;
|
|
|
|
// Idempotency check before downloading — skip if version already cached
|
|
const versionId = `${skillId}@${sha}`;
|
|
if (await versionExists(db, versionId)) {
|
|
fetched++;
|
|
continue;
|
|
}
|
|
|
|
const skillMdUrl = resolveRawGitHubUrl(source.owner, source.repo, source.ref, entry.path);
|
|
let skillMdContent: string;
|
|
try {
|
|
skillMdContent = await fetchText(skillMdUrl);
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
const { name, description } = parseSkillFrontmatter(skillMdContent);
|
|
const sourceUrl = `https://github.com/${source.owner}/${source.repo}/tree/${source.ref}/${skillDir}`;
|
|
|
|
await upsertSkill(db, {
|
|
skillId,
|
|
sourceId: source.id,
|
|
name: name ?? slug,
|
|
description,
|
|
sourceUrl,
|
|
});
|
|
|
|
await cacheSkillVersion(db, {
|
|
skillId,
|
|
sha,
|
|
skillMdContent,
|
|
skillMdUrl,
|
|
});
|
|
|
|
await upsertCommunityRatingsStub(db, skillId, source.id);
|
|
|
|
fetched++;
|
|
}
|
|
|
|
return fetched;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type FetchAllSourcesResult = {
|
|
fetched: number;
|
|
errors: string[];
|
|
};
|
|
|
|
/**
|
|
* Fetch skills from all configured sources and populate the registry DB.
|
|
* Uses BUILT_IN_SOURCES if no sources are provided.
|
|
*/
|
|
export async function fetchAllSources(
|
|
sources: SkillSourceConfig[] = BUILT_IN_SOURCES,
|
|
): Promise<FetchAllSourcesResult> {
|
|
const db = await getSkillRegistryDb();
|
|
let fetched = 0;
|
|
const errors: string[] = [];
|
|
|
|
for (const source of sources) {
|
|
try {
|
|
if (source.type === "anthropic-marketplace") {
|
|
fetched += await fetchAnthropicMarketplace(source, db);
|
|
} else if (source.type === "github-tree") {
|
|
fetched += await fetchGitHubTree(source, db);
|
|
} else {
|
|
errors.push(`Unknown source type for ${source.id}`);
|
|
}
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
errors.push(`Source ${source.id}: ${message}`);
|
|
}
|
|
}
|
|
|
|
return { fetched, errors };
|
|
}
|