Files
hongjr03 79f72ecca8 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.
2026-07-16 01:11:49 +08:00

118 lines
5.1 KiB
TypeScript

import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
importSkillDirectory,
importSkillFromFiles,
prepareRunSkillPlugin,
readSkillFiles,
} from "../../src/agent/skillStore.js";
describe("content-addressed Agent skill store", () => {
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
it("imports a skill into an immutable digest directory and materializes a selected run plugin", async () => {
const root = await makeRoot();
const source = await makeSkill(root, "typst", "Typst help");
const storeRoot = join(root, "store");
const installed = await importSkillDirectory({ sourceDir: source, storeRoot });
expect(installed).toMatchObject({ name: "typst", description: "Typst help" });
expect(installed.contentDigest).toMatch(/^[a-f0-9]{64}$/);
await expect(readFile(join(storeRoot, "versions", installed.contentDigest, "SKILL.md"), "utf8"))
.resolves.toContain("name: typst");
const plugin = await prepareRunSkillPlugin({
storeRoot,
runId: "run-1",
skills: [{ name: "typst", version: "0.15.0", contentDigest: installed.contentDigest }],
});
expect(plugin).not.toBeNull();
expect(plugin?.skillIds).toEqual(["cph-runtime:typst"]);
await expect(readFile(join(plugin!.root, "skills", "typst", "reference.md"), "utf8"))
.resolves.toBe("reference\n");
await plugin?.cleanup();
await expect(readFile(join(plugin!.root, ".claude-plugin", "plugin.json"), "utf8"))
.rejects.toMatchObject({ code: "ENOENT" });
});
it("rejects symlinks and detects content tampering before a run", async () => {
const root = await makeRoot();
const source = await makeSkill(root, "outline", "Outline");
await symlink(join(source, "reference.md"), join(source, "link.md"));
await expect(importSkillDirectory({ sourceDir: source, storeRoot: join(root, "store") }))
.rejects.toThrow(/symlink/);
await rm(join(source, "link.md"));
const storeRoot = join(root, "store");
const installed = await importSkillDirectory({ sourceDir: source, storeRoot });
await writeFile(join(storeRoot, "versions", installed.contentDigest, "reference.md"), "tampered\n");
await expect(prepareRunSkillPlugin({
storeRoot,
runId: "run-2",
skills: [{ name: "outline", version: "1", contentDigest: installed.contentDigest }],
})).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);
return root;
}
});
async function makeSkill(root: string, name: string, description: string): Promise<string> {
const source = join(root, "source", name);
await mkdir(source, { recursive: true });
await writeFile(join(source, "SKILL.md"), `---\nname: ${name}\ndescription: ${description}\n---\n# ${name}\n`);
await writeFile(join(source, "reference.md"), "reference\n");
return source;
}