forked from EduCraft/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:
@@ -2,7 +2,12 @@ import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promis
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { importSkillDirectory, prepareRunSkillPlugin } from "../../src/agent/skillStore.js";
|
||||
import {
|
||||
importSkillDirectory,
|
||||
importSkillFromFiles,
|
||||
prepareRunSkillPlugin,
|
||||
readSkillFiles,
|
||||
} from "../../src/agent/skillStore.js";
|
||||
|
||||
describe("content-addressed Agent skill store", () => {
|
||||
const roots: string[] = [];
|
||||
@@ -55,6 +60,47 @@ describe("content-addressed Agent skill store", () => {
|
||||
})).rejects.toThrow(/content digest mismatch/);
|
||||
});
|
||||
|
||||
it("imports a skill from an in-memory file list and reads it back", async () => {
|
||||
const root = await makeRoot();
|
||||
const storeRoot = join(root, "store");
|
||||
const manifest = `---\nname: web-skill\ndescription: Web uploaded\n---\n# web-skill\n`;
|
||||
const files = [
|
||||
{ path: "SKILL.md", bytes: Buffer.from(manifest, "utf8") },
|
||||
{ path: "reference.md", bytes: Buffer.from("ref\n", "utf8") },
|
||||
];
|
||||
const installed = await importSkillFromFiles({ files, storeRoot });
|
||||
expect(installed).toMatchObject({ name: "web-skill", description: "Web uploaded" });
|
||||
expect(installed.contentDigest).toMatch(/^[a-f0-9]{64}$/);
|
||||
|
||||
const readBack = await readSkillFiles({ storeRoot, contentDigest: installed.contentDigest });
|
||||
expect(readBack).toHaveLength(2);
|
||||
const byPath = Object.fromEntries(readBack.map((f) => [f.path, f.content]));
|
||||
expect(byPath["SKILL.md"]).toContain("name: web-skill");
|
||||
expect(byPath["reference.md"]).toBe("ref\n");
|
||||
});
|
||||
|
||||
it("rejects file paths that escape the skill root", async () => {
|
||||
const root = await makeRoot();
|
||||
const storeRoot = join(root, "store");
|
||||
const manifest = `---\nname: escape\ndescription: test\n---\n# escape\n`;
|
||||
const files = [
|
||||
{ path: "SKILL.md", bytes: Buffer.from(manifest, "utf8") },
|
||||
{ path: "../escape.md", bytes: Buffer.from("x", "utf8") },
|
||||
];
|
||||
await expect(importSkillFromFiles({ files, storeRoot })).rejects.toThrow(/must not escape/);
|
||||
});
|
||||
|
||||
it("rejects duplicate file paths in importSkillFromFiles", async () => {
|
||||
const root = await makeRoot();
|
||||
const storeRoot = join(root, "store");
|
||||
const manifest = `---\nname: dup\ndescription: test\n---\n# dup\n`;
|
||||
const files = [
|
||||
{ path: "SKILL.md", bytes: Buffer.from(manifest, "utf8") },
|
||||
{ path: "SKILL.md", bytes: Buffer.from(manifest, "utf8") },
|
||||
];
|
||||
await expect(importSkillFromFiles({ files, storeRoot })).rejects.toThrow(/duplicate skill file path/);
|
||||
});
|
||||
|
||||
async function makeRoot(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), "cph-skill-store-"));
|
||||
roots.push(root);
|
||||
|
||||
Reference in New Issue
Block a user