forked from EduCraft/curriculum-project-hub
88 lines
3.4 KiB
TypeScript
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}`);
|
|
}
|
|
}
|
|
}
|