forked from EduCraft/curriculum-project-hub
79f72ecca8
Add full skill lifecycle to the org-admin web surface: create, read, edit, disable. Skills are directories (SKILL.md manifest + supporting files), content-addressed by SHA-256 in an immutable store. Backend: - skillStore: extract commitSkillContent (shared populate→inspect→ dedup→atomic rename); add importSkillFromFiles (in-memory file list ingestion) and readSkillFiles (read stored version back as UTF-8) - configuration: add installSkillFromFiles, readSkillFiles, disableSkill (soft-delete + archive bound role sessions), updateSkillDescription (label-only, no archival); refactor installSkill to share commitInstalledSkill - agentConfigRoutes: wire skillStoreRoot; add GET /agent-skills/:name/files, PUT /agent-skills/:name (create/replace), PATCH /agent-skills/:name (description/disable) - orgRoutes: pass readSkillStoreRoot() to agent config routes Frontend: - api.ts: agentSkillFiles, installAgentSkill, patchAgentSkill methods - SkillEditor.svelte: file tree + text editor + version/description form - skills/+page.svelte: skill list, create form (generates SKILL.md template), per-skill editor - layout: add 技能 nav item ADR-0018: update Decision to reflect web surface joining host-console CLI in the shared content-addressed ingestion pipeline. Spec (AgentRole.lean): unchanged — storage mechanism is OPEN, web installation is one implementation of it.
312 lines
11 KiB
TypeScript
312 lines
11 KiB
TypeScript
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<void>;
|
|
}
|
|
|
|
export async function importSkillDirectory(input: {
|
|
readonly sourceDir: string;
|
|
readonly storeRoot: string;
|
|
}): Promise<ImportedSkillContent> {
|
|
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<ImportedSkillContent> {
|
|
const seen = new Set<string>();
|
|
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<readonly SkillFileOutput[]> {
|
|
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/<digest>/` 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<ImportedSkillContent>;
|
|
}): Promise<ImportedSkillContent> {
|
|
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<RunSkillPlugin | null> {
|
|
if (input.skills.length === 0) return null;
|
|
const names = new Set<string>();
|
|
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<Record<string, string | undefined>> = 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<void> {
|
|
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<ImportedSkillContent> {
|
|
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<void> {
|
|
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");
|
|
}
|