import { createHash, randomUUID } from "node:crypto"; import { cp, lstat, mkdir, readFile, readdir, rename, rm, writeFile, } from "node:fs/promises"; import { join, relative, resolve } from "node:path"; import type { RoleSkillEntry } from "./models.js"; const MAX_SKILL_FILES = 512; const MAX_SKILL_BYTES = 16 * 1024 * 1024; const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/; const DIGEST_PATTERN = /^[a-f0-9]{64}$/; const RUNTIME_PLUGIN_NAME = "cph-runtime"; export interface ImportedSkillContent { readonly name: string; readonly description: string | undefined; readonly contentDigest: string; } export interface RunSkillPlugin { readonly root: string; readonly skillIds: readonly string[]; cleanup(): Promise; } export async function importSkillDirectory(input: { readonly sourceDir: string; readonly storeRoot: string; }): Promise { return commitSkillContent({ storeRoot: input.storeRoot, prepare: async (dir) => { await cp(input.sourceDir, dir, { recursive: true, force: false, errorOnExist: true }); return inspectSkillDirectory(dir); }, }); } /** * Import a skill from an in-memory file list (web upload path). Each file * path is relative to the skill root and must be a simple relative path * (no absolute, no `..`). A `SKILL.md` manifest is required. */ export async function importSkillFromFiles(input: { readonly files: readonly SkillFileInput[]; readonly storeRoot: string; }): Promise { const seen = new Set(); for (const file of input.files) { validateSkillFilePath(file.path); if (seen.has(file.path)) throw new Error(`duplicate skill file path: ${file.path}`); seen.add(file.path); } return commitSkillContent({ storeRoot: input.storeRoot, prepare: async (dir) => { for (const file of input.files) { const dest = join(dir, file.path); const parent = join(dest, ".."); await mkdir(parent, { recursive: true, mode: 0o750 }); await writeFile(dest, file.bytes, { mode: 0o640 }); } return inspectSkillDirectory(dir); }, }); } /** * Read all files from a stored skill version (by content digest). Returns * relative paths and UTF-8 decoded content for the web editor. */ export async function readSkillFiles(input: { readonly storeRoot: string; readonly contentDigest: string; }): Promise { if (!DIGEST_PATTERN.test(input.contentDigest)) { throw new Error(`invalid content digest: ${input.contentDigest}`); } const dir = join(input.storeRoot, "versions", input.contentDigest); const files: Array<{ readonly path: string; readonly bytes: Buffer }> = []; await walk(resolve(dir), resolve(dir), files); return files.sort((a, b) => a.path.localeCompare(b.path)).map((f) => ({ path: f.path, content: f.bytes.toString("utf8"), })); } export interface SkillFileInput { readonly path: string; readonly bytes: Buffer; } export interface SkillFileOutput { readonly path: string; readonly content: string; } /** * Shared commit logic: populate a temp dir, inspect it, deduplicate against * an existing `versions//` destination, and atomically rename. * * `prepare(dir)` writes the skill content into `dir` and returns the inspected * result (name, description, contentDigest). The caller owns the write; * commitSkillContent owns the move. */ async function commitSkillContent(input: { readonly storeRoot: string; readonly prepare: (dir: string) => Promise; }): Promise { const versionsRoot = join(input.storeRoot, "versions"); await mkdir(versionsRoot, { recursive: true, mode: 0o750 }); const temporary = join(versionsRoot, `.tmp-${randomUUID()}`); let source: ImportedSkillContent; try { source = await input.prepare(temporary); } catch (error) { await rm(temporary, { recursive: true, force: true }); throw error; } const destination = join(versionsRoot, source.contentDigest); // If the destination already exists with identical content, reuse it. try { const existing = await inspectSkillDirectory(destination); if (existing.contentDigest !== source.contentDigest || existing.name !== source.name) { throw new Error(`stored skill content digest mismatch: ${source.name}`); } await rm(temporary, { recursive: true, force: true }); return source; } catch (error) { if (!isMissingPath(error)) throw error; } try { await rename(temporary, destination); } catch (error) { await rm(temporary, { recursive: true, force: true }); if (isDestinationExists(error)) { const existing = await inspectSkillDirectory(destination); if (existing.contentDigest === source.contentDigest && existing.name === source.name) { return source; } } throw error; } return source; } function validateSkillFilePath(path: string): void { if (path === "") throw new Error("skill file path is empty"); if (path.startsWith("/")) throw new Error(`skill file path must be relative: ${path}`); if (path.includes("..")) throw new Error(`skill file path must not escape: ${path}`); if (path.startsWith("\\")) throw new Error(`skill file path must be relative: ${path}`); } export async function prepareRunSkillPlugin(input: { readonly storeRoot: string; readonly runId: string; readonly skills: readonly RoleSkillEntry[]; }): Promise { if (input.skills.length === 0) return null; const names = new Set(); for (const skill of input.skills) { requireSkillName(skill.name); if (!DIGEST_PATTERN.test(skill.contentDigest)) { throw new Error(`skill ${skill.name} has invalid content digest`); } if (names.has(skill.name)) throw new Error(`duplicate role skill: ${skill.name}`); names.add(skill.name); } const runtimeRoot = join(input.storeRoot, "runtime"); await mkdir(runtimeRoot, { recursive: true, mode: 0o750 }); const pluginRoot = join(runtimeRoot, `run-${randomUUID()}`); try { await mkdir(join(pluginRoot, ".claude-plugin"), { recursive: true, mode: 0o750 }); await mkdir(join(pluginRoot, "skills"), { recursive: true, mode: 0o750 }); await writeFile( join(pluginRoot, ".claude-plugin", "plugin.json"), `${JSON.stringify({ name: RUNTIME_PLUGIN_NAME, description: `Runtime skill snapshot for ${input.runId}`, version: "1", }, null, 2)}\n`, { mode: 0o640 }, ); for (const skill of input.skills) { const sourceDir = join(input.storeRoot, "versions", skill.contentDigest); const stored = await inspectSkillDirectory(sourceDir); if (stored.contentDigest !== skill.contentDigest) { throw new Error(`skill ${skill.name} content digest mismatch`); } if (stored.name !== skill.name) { throw new Error(`skill name mismatch: expected ${skill.name}, got ${stored.name}`); } await cp(sourceDir, join(pluginRoot, "skills", skill.name), { recursive: true, force: false, errorOnExist: true, }); } } catch (error) { await rm(pluginRoot, { recursive: true, force: true }); throw error; } return { root: pluginRoot, skillIds: input.skills.map((skill) => `${RUNTIME_PLUGIN_NAME}:${skill.name}`), async cleanup() { await rm(pluginRoot, { recursive: true, force: true }); }, }; } export function readSkillStoreRoot(env: Readonly> = process.env): string { const configured = env["HUB_SKILL_STORE_ROOT"]?.trim(); if (configured !== undefined && configured !== "") return resolve(configured); const stateRoot = env["XDG_STATE_HOME"]?.trim(); if (stateRoot === undefined || stateRoot === "") { throw new Error("HUB_SKILL_STORE_ROOT or XDG_STATE_HOME is required"); } return resolve(stateRoot, "skills"); } export async function verifyStoredSkill(input: { readonly storeRoot: string; readonly name: string; readonly contentDigest: string; }): Promise { requireSkillName(input.name); if (!DIGEST_PATTERN.test(input.contentDigest)) { throw new Error(`skill ${input.name} has invalid content digest`); } const stored = await inspectSkillDirectory(join(input.storeRoot, "versions", input.contentDigest)); if (stored.name !== input.name || stored.contentDigest !== input.contentDigest) { throw new Error(`stored skill verification failed: ${input.name}`); } } async function inspectSkillDirectory(directory: string): Promise { const root = resolve(directory); const rootStat = await lstat(root); if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) { throw new Error(`skill root must be a real directory: ${root}`); } const files: Array<{ readonly path: string; readonly bytes: Buffer }> = []; await walk(root, root, files); if (files.length > MAX_SKILL_FILES) throw new Error(`skill has too many files: ${files.length}`); const totalBytes = files.reduce((sum, file) => sum + file.bytes.byteLength, 0); if (totalBytes > MAX_SKILL_BYTES) throw new Error(`skill is too large: ${totalBytes} bytes`); const manifest = files.find((file) => file.path === "SKILL.md"); if (manifest === undefined) throw new Error(`skill manifest missing: ${join(root, "SKILL.md")}`); const frontmatter = manifest.bytes.toString("utf8"); const name = /^name:\s*['"]?([^'"\r\n]+)['"]?\s*$/m.exec(frontmatter)?.[1]?.trim(); if (name === undefined) throw new Error("skill manifest name missing"); requireSkillName(name); const description = /^description:\s*['"]?([^'"\r\n]+)['"]?\s*$/m.exec(frontmatter)?.[1]?.trim(); const hash = createHash("sha256"); for (const file of files.sort((left, right) => left.path.localeCompare(right.path))) { hash.update(`${Buffer.byteLength(file.path)}:`); hash.update(file.path); hash.update(`${file.bytes.byteLength}:`); hash.update(file.bytes); } return { name, description, contentDigest: hash.digest("hex") }; } async function walk( root: string, directory: string, files: Array<{ readonly path: string; readonly bytes: Buffer }>, ): Promise { const entries = await readdir(directory, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(directory, entry.name); if (entry.isSymbolicLink()) throw new Error(`skill symlink is forbidden: ${fullPath}`); if (entry.isDirectory()) { await walk(root, fullPath, files); continue; } if (!entry.isFile()) throw new Error(`skill contains unsupported filesystem entry: ${fullPath}`); const relativePath = relative(root, fullPath); files.push({ path: relativePath, bytes: await readFile(fullPath) }); if (files.length > MAX_SKILL_FILES) throw new Error(`skill has too many files: ${files.length}`); } } function requireSkillName(name: string): void { if (!SKILL_NAME_PATTERN.test(name)) throw new Error(`invalid skill name: ${name}`); } function isMissingPath(error: unknown): boolean { return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT"; } function isDestinationExists(error: unknown): boolean { return typeof error === "object" && error !== null && "code" in error && (error.code === "EEXIST" || error.code === "ENOTEMPTY"); }