Files
curriculum-project-hub/hub/src/agent/curatedSkills.ts
T

88 lines
3.4 KiB
TypeScript

import { lstat, readFile, readdir } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
export const CURATED_SKILL_PLUGIN_NAME = "cph-curated";
export const CURATED_SKILL_NAMES = [
"outline",
"lesson-project",
"data-processing-spec",
] as const;
export const CURATED_SKILL_IDS = CURATED_SKILL_NAMES.map(
(name) => `${CURATED_SKILL_PLUGIN_NAME}:${name}`,
);
export interface CuratedSkillPlugin {
readonly root: string;
readonly skillIds: readonly string[];
}
/** Validate the immutable, release-owned plugin before exposing it read-only. */
export async function validateCuratedSkillPlugin(
root = curatedSkillPluginRoot(),
): Promise<CuratedSkillPlugin> {
await assertExactDirectoryEntries(root, [".claude-plugin", "skills"], "");
await assertExactDirectoryEntries(join(root, ".claude-plugin"), ["plugin.json"], ".claude-plugin");
await assertExactDirectoryEntries(join(root, "skills"), CURATED_SKILL_NAMES, "skills");
const pluginManifest = join(root, ".claude-plugin", "plugin.json");
let pluginName: unknown;
try {
const stat = await lstat(pluginManifest);
if (!stat.isFile()) throw new Error(`not a regular file: ${pluginManifest}`);
pluginName = JSON.parse(await readFile(pluginManifest, "utf8")).name;
} catch (error) {
throw new Error(`curated skill plugin manifest invalid: ${pluginManifest}`, { cause: error });
}
if (pluginName !== CURATED_SKILL_PLUGIN_NAME) {
throw new Error(`curated skill plugin name mismatch: expected ${CURATED_SKILL_PLUGIN_NAME}, got ${String(pluginName)}`);
}
for (const name of CURATED_SKILL_NAMES) {
const manifest = join(root, "skills", name, "SKILL.md");
try {
const stat = await lstat(manifest);
if (!stat.isFile()) throw new Error(`not a regular file: ${manifest}`);
const contents = await readFile(manifest, "utf8");
const declaredName = /^name:\s*['"]?([^'"\r\n]+)['"]?\s*$/m.exec(contents)?.[1]?.trim();
if (declaredName !== name) {
throw new Error(`curated skill manifest name mismatch: expected ${name}, got ${declaredName ?? "missing"}`);
}
} catch (error) {
if (error instanceof Error && error.message.startsWith("curated skill manifest name mismatch:")) throw error;
throw new Error(`curated skill source missing: ${manifest}`, { cause: error });
}
}
return { root, skillIds: CURATED_SKILL_IDS };
}
export function curatedSkillPluginRoot(): string {
return fileURLToPath(new URL("../../curated-skills-plugin/", import.meta.url));
}
async function assertExactDirectoryEntries(
directory: string,
allowedNames: readonly string[],
relativeDirectory: string,
): Promise<void> {
let entries;
try {
entries = await readdir(directory, { withFileTypes: true });
} catch (error) {
throw new Error(`curated plugin directory missing: ${directory}`, { cause: error });
}
const allowed = new Set(allowedNames);
for (const entry of entries) {
if (!allowed.has(entry.name)) {
const relativePath = relativeDirectory === "" ? entry.name : `${relativeDirectory}/${entry.name}`;
throw new Error(`unexpected curated plugin entry: ${relativePath}`);
}
}
for (const name of allowedNames) {
if (!entries.some((entry) => entry.name === name)) {
const relativePath = relativeDirectory === "" ? name : `${relativeDirectory}/${name}`;
throw new Error(`curated plugin entry missing: ${relativePath}`);
}
}
}