forked from bai/curriculum-project-hub
feat(admin): web-based skill management with file editor
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.
This commit is contained in:
+102
-8
@@ -34,40 +34,134 @@ export async function importSkillDirectory(input: {
|
||||
readonly sourceDir: string;
|
||||
readonly storeRoot: string;
|
||||
}): Promise<ImportedSkillContent> {
|
||||
const source = await inspectSkillDirectory(input.sourceDir);
|
||||
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;
|
||||
}
|
||||
|
||||
const temporary = join(versionsRoot, `.tmp-${randomUUID()}`);
|
||||
try {
|
||||
await cp(input.sourceDir, temporary, { recursive: true, force: false, errorOnExist: true });
|
||||
const copied = await inspectSkillDirectory(temporary);
|
||||
if (copied.contentDigest !== source.contentDigest || copied.name !== source.name) {
|
||||
throw new Error(`skill changed while importing: ${source.name}`);
|
||||
}
|
||||
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;
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user