feat: make agent roles and skills dynamic

This commit is contained in:
2026-07-11 12:55:05 +08:00
parent 17c0536958
commit d36b00bbec
48 changed files with 1389 additions and 1749 deletions
@@ -0,0 +1,90 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { OrganizationAgentConfiguration } from "../../src/agent/configuration.js";
import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization } from "./helpers.js";
describe("Organization Agent configuration management", () => {
let root: string;
let configuration: OrganizationAgentConfiguration;
beforeEach(async () => {
await resetDb();
root = await mkdtemp(join(tmpdir(), "cph-agent-config-"));
configuration = new OrganizationAgentConfiguration(prisma, join(root, "store"));
});
afterAll(async () => {
await prisma.$disconnect();
});
it("installs versioned skills and selects them as part of a dynamic role bundle", async () => {
const typst = await makeSkill(root, "typst");
const outline = await makeSkill(root, "outline");
await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: typst, version: "0.15.0" });
await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: outline, version: "1" });
await configuration.upsertRole({
organizationId: DEFAULT_ORG_ID,
roleId: "draft",
label: "课程草稿",
defaultModel: "anthropic/claude-sonnet-5",
systemPrompt: "write carefully",
tools: ["read_file", "write_file", "cph_build"],
sortOrder: 10,
});
await prisma.project.create({
data: { id: "project-a", organizationId: DEFAULT_ORG_ID, name: "A", workspaceDir: "/tmp/a" },
});
await prisma.agentSession.create({
data: {
id: "session-old-role-config",
projectId: "project-a",
provider: "openrouter",
roleId: "draft",
model: "anthropic/claude-sonnet-5",
metadata: {},
},
});
await configuration.setRoleSkills({
organizationId: DEFAULT_ORG_ID,
roleId: "draft",
skillNames: ["outline", "typst"],
});
const role = await prisma.organizationAgentRole.findUniqueOrThrow({
where: { organizationId_roleId: { organizationId: DEFAULT_ORG_ID, roleId: "draft" } },
include: { skillBindings: { orderBy: { sortOrder: "asc" }, include: { skill: true } } },
});
expect(role).toMatchObject({ label: "课程草稿", systemPrompt: "write carefully" });
expect(role.tools).toEqual(["read_file", "write_file", "cph_build"]);
expect(role.skillBindings.map((binding) => binding.skill.name)).toEqual(["outline", "typst"]);
await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: "session-old-role-config" } }))
.resolves.toMatchObject({ archivedAt: expect.any(Date) });
});
it("rejects unknown, disabled and cross-Organization skills", async () => {
await seedTestOrganization("org_other", "other");
const typst = await makeSkill(root, "typst");
await configuration.installSkill({ organizationId: "org_other", sourceDir: typst, version: "1" });
await configuration.upsertRole({
organizationId: DEFAULT_ORG_ID,
roleId: "draft",
label: "Draft",
tools: [],
});
await expect(configuration.setRoleSkills({
organizationId: DEFAULT_ORG_ID,
roleId: "draft",
skillNames: ["typst"],
})).rejects.toThrow("active skills not found in organization");
});
async function makeSkill(parent: string, name: string): Promise<string> {
const source = join(parent, "sources", name);
await mkdir(source, { recursive: true });
await writeFile(join(source, "SKILL.md"), `---\nname: ${name}\ndescription: ${name} skill\n---\n# ${name}\n`);
return source;
}
});
@@ -0,0 +1,121 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { DatabaseRuntimeSettings } from "../../src/settings/runtime.js";
import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization, testSecretEnvelope } from "./helpers.js";
describe("Organization-scoped Agent runtime configuration", () => {
beforeEach(async () => {
await resetDb();
});
afterAll(async () => {
await prisma.$disconnect();
});
it("resolves role prompt, model, tools and skills from the project Organization", async () => {
await seedTestOrganization("org_other", "other");
await Promise.all([
prisma.project.create({
data: { id: "project-a", organizationId: DEFAULT_ORG_ID, name: "A", workspaceDir: "/tmp/a" },
}),
prisma.project.create({
data: { id: "project-b", organizationId: "org_other", name: "B", workspaceDir: "/tmp/b" },
}),
]);
const [skillA, skillB] = await Promise.all([
prisma.organizationAgentSkill.create({
data: {
id: "skill-a",
organizationId: DEFAULT_ORG_ID,
name: "typst",
version: "0.15.0",
contentDigest: "a".repeat(64),
},
}),
prisma.organizationAgentSkill.create({
data: {
id: "skill-b",
organizationId: "org_other",
name: "typst",
version: "other",
contentDigest: "b".repeat(64),
},
}),
]);
const [roleA, roleB] = await Promise.all([
prisma.organizationAgentRole.create({
data: {
id: "role-a",
organizationId: DEFAULT_ORG_ID,
roleId: "draft",
label: "A Draft",
defaultModel: "anthropic/claude-sonnet-5",
systemPrompt: "prompt-a",
tools: ["read_file", "cph_build"],
},
}),
prisma.organizationAgentRole.create({
data: {
id: "role-b",
organizationId: "org_other",
roleId: "draft",
label: "B Draft",
systemPrompt: "prompt-b",
tools: [],
},
}),
]);
await Promise.all([
prisma.organizationAgentRoleSkill.create({
data: { organizationId: DEFAULT_ORG_ID, agentRoleId: roleA.id, agentSkillId: skillA.id },
}),
prisma.organizationAgentRoleSkill.create({
data: { organizationId: "org_other", agentRoleId: roleB.id, agentSkillId: skillB.id },
}),
]);
const settings = new DatabaseRuntimeSettings(prisma, testSecretEnvelope, {});
const registryA = await settings.modelRegistry({ projectId: "project-a" });
const registryB = await settings.modelRegistry({ projectId: "project-b" });
expect(registryA.role("draft")).toMatchObject({
label: "A Draft",
systemPrompt: "prompt-a",
tools: ["read_file", "cph_build"],
skills: [{ name: "typst", version: "0.15.0", contentDigest: "a".repeat(64) }],
});
expect(registryB.role("draft")).toMatchObject({
label: "B Draft",
systemPrompt: "prompt-b",
tools: [],
skills: [{ name: "typst", version: "other", contentDigest: "b".repeat(64) }],
});
});
it("fails closed for missing scope and disabled role skills", async () => {
await prisma.project.create({
data: { id: "project-a", organizationId: DEFAULT_ORG_ID, name: "A", workspaceDir: "/tmp/a" },
});
const skill = await prisma.organizationAgentSkill.create({
data: {
id: "skill-disabled",
organizationId: DEFAULT_ORG_ID,
name: "typst",
version: "0.15.0",
contentDigest: "c".repeat(64),
disabledAt: new Date(),
},
});
const role = await prisma.organizationAgentRole.create({
data: { id: "role-a", organizationId: DEFAULT_ORG_ID, roleId: "draft", label: "Draft" },
});
await prisma.organizationAgentRoleSkill.create({
data: { organizationId: DEFAULT_ORG_ID, agentRoleId: role.id, agentSkillId: skill.id },
});
const settings = new DatabaseRuntimeSettings(prisma, testSecretEnvelope, {});
await expect(settings.modelRegistry()).rejects.toThrow("projectId is required");
await expect(settings.modelRegistry({ projectId: "project-a" })).rejects.toThrow(
"role draft selects disabled skill typst",
);
});
});
@@ -7,6 +7,7 @@ import { join } from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import { runAgent, type StreamEvent } from "../../src/agent/runner.js";
import { importSkillDirectory } from "../../src/agent/skillStore.js";
const execFileAsync = promisify(execFile);
const originalEnv = new Map<string, string | undefined>();
@@ -52,7 +53,9 @@ describe("real Claude SDK sandbox boundary", () => {
const workspace = join(workspaceRoot, "a", `p_${nonce.slice(0, 8)}`);
const sibling = join(workspaceRoot, "b", `p_${nonce.slice(8, 16)}`);
const serviceSecret = join(root, `s_${nonce.slice(16, 24)}`);
roots.push(workspace, sibling, serviceSecret);
const skillSource = join(root, `k_${nonce.slice(24, 28)}`);
const skillStore = join(root, `ks_${nonce.slice(28, 32)}`);
roots.push(workspace, sibling, serviceSecret, skillSource, skillStore);
await Promise.all([
mkdir(workspace, { recursive: true }),
mkdir(sibling, { recursive: true }),
@@ -62,6 +65,9 @@ describe("real Claude SDK sandbox boundary", () => {
writeFile(join(sibling, "secret.txt"), "sibling-secret\n"),
writeFile(serviceSecret, "platform-secret\n"),
]);
await mkdir(skillSource, { recursive: true });
await writeFile(join(skillSource, "SKILL.md"), "---\nname: outline\ndescription: Outline\n---\n");
const installedSkill = await importSkillDirectory({ sourceDir: skillSource, storeRoot: skillStore });
const untrustedSkill = join(workspace, ".claude", "skills", "untrusted");
await mkdir(untrustedSkill, { recursive: true });
await writeFile(join(untrustedSkill, "SKILL.md"), "---\nname: untrusted\ndescription: must never load\n---\n");
@@ -86,6 +92,7 @@ describe("real Claude SDK sandbox boundary", () => {
DATABASE_URL: "postgresql://platform-secret",
FEISHU_APP_SECRET: "feishu-secret",
HUB_SESSION_SECRET: "session-secret",
HUB_SKILL_STORE_ROOT: skillStore,
});
const bashCommand = [
@@ -133,6 +140,7 @@ describe("real Claude SDK sandbox boundary", () => {
ANTHROPIC_API_KEY: "",
},
tools: ["bash"],
skills: [{ name: "outline", version: "1", contentDigest: installedSkill.contentDigest }],
maxTurns: 3,
runId: "sandbox-run",
sessionId: "sandbox-session",
@@ -150,9 +158,7 @@ describe("real Claude SDK sandbox boundary", () => {
).toBe("completed");
expect(stub.requestCount()).toBeGreaterThanOrEqual(3);
expect(new Set(result.initializedSkillIds)).toEqual(new Set([
"cph-curated:outline",
"cph-curated:lesson-project",
"cph-curated:data-processing-spec",
"cph-runtime:outline",
]));
const toolResults = streamEvents.filter((event) => event.type === "tool-result");
expect(toolResults).toHaveLength(2);
+3
View File
@@ -35,6 +35,9 @@ export const prisma = new PrismaClient({
/** Truncate all tables before each test for isolation. */
export async function resetDb(): Promise<void> {
const tables = [
"OrganizationAgentRoleSkill",
"OrganizationAgentRole",
"OrganizationAgentSkill",
"FeishuEventReceipt",
"FeishuUserIdentity",
"FeishuApplicationCredentialVersion",
@@ -53,6 +53,14 @@ describe("Alpha Silo bootstrap", () => {
expect(await prisma.team.count({ where: { slug: "teachers", archivedAt: null } })).toBe(1);
expect(await prisma.teamMembership.count({ where: { revokedAt: null } })).toBe(1);
expect(await prisma.organizationProviderConnection.count({ where: { status: "ACTIVE" } })).toBe(1);
await expect(prisma.organizationAgentRole.findMany({
where: { organizationId: "org_alpha", disabledAt: null },
orderBy: { sortOrder: "asc" },
select: { roleId: true, label: true },
})).resolves.toEqual([
{ roleId: "draft", label: "草稿" },
{ roleId: "review", label: "审校" },
]);
const persisted = JSON.stringify({
feishu: await prisma.feishuApplicationCredentialVersion.findMany(),
+8 -8
View File
@@ -14,6 +14,7 @@ describe("agent subprocess security policy", () => {
it("passes only the run proxy capability and safe runtime variables and protects the capability from tools", async () => {
const { workspaceRoot, workspace } = await makeWorkspace();
const policy = await createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot,
workspaceDir: workspace,
providerProxyEnv: {
@@ -51,14 +52,8 @@ describe("agent subprocess security policy", () => {
expect(policy.env.TEMP).toBe(policy.env.TMPDIR);
expect(policy.env.TMPDIR).toBe(join(canonicalWorkspace, ".cph", "t"));
expect(Buffer.byteLength(policy.env.TMPDIR!)).toBeLessThanOrEqual(56);
expect(policy.skillIds).toEqual([
"cph-curated:outline",
"cph-curated:lesson-project",
"cph-curated:data-processing-spec",
]);
expect(policy.skillPluginRoot.startsWith(canonicalWorkspace)).toBe(false);
expect(policy.sandbox.filesystem.allowRead).toContain(policy.skillPluginRoot);
expect(policy.sandbox.filesystem.allowWrite).not.toContain(policy.skillPluginRoot);
expect(policy.skillIds).toEqual([]);
expect(policy.skillPluginRoot).toBeUndefined();
expect(policy.sandbox).toMatchObject({
enabled: true,
@@ -83,6 +78,7 @@ describe("agent subprocess security policy", () => {
const { workspaceRoot, workspace } = await makeWorkspace();
await expect(createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot,
workspaceDir: workspace,
providerProxyEnv: {
@@ -96,6 +92,7 @@ describe("agent subprocess security policy", () => {
it("keeps every SDK temp variable on a short path inside the project workspace", async () => {
const { workspaceRoot, workspace } = await makeWorkspace();
const policy = await createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot,
workspaceDir: workspace,
hostEnv: { PATH: "/usr/bin:/bin" },
@@ -121,6 +118,7 @@ describe("agent subprocess security policy", () => {
await mkdir(workspace, { recursive: true });
await expect(createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot,
workspaceDir: workspace,
hostEnv: { PATH: "/usr/bin:/bin" },
@@ -135,6 +133,7 @@ describe("agent subprocess security policy", () => {
await symlink(outside, linked);
await expect(createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot,
workspaceDir: linked,
providerProxyEnv: { ANTHROPIC_AUTH_TOKEN: "run-proxy-capability" },
@@ -150,6 +149,7 @@ describe("agent subprocess security policy", () => {
await symlink(sibling, linked);
await expect(createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot,
workspaceDir: linked,
providerProxyEnv: { ANTHROPIC_AUTH_TOKEN: "run-proxy-capability" },
-71
View File
@@ -1,71 +0,0 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
CURATED_SKILL_IDS,
CURATED_SKILL_NAMES,
CURATED_SKILL_PLUGIN_NAME,
validateCuratedSkillPlugin,
} from "../../src/agent/curatedSkills.js";
describe("validateCuratedSkillPlugin", () => {
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
it("accepts exactly the release-owned plugin and returns qualified skill ids", async () => {
const root = await skillPluginFixture();
await expect(validateCuratedSkillPlugin(root)).resolves.toEqual({
root,
skillIds: CURATED_SKILL_IDS,
});
});
it("fails closed when a curated skill is absent from the release", async () => {
const root = await skillPluginFixture();
await rm(join(root, "skills", "outline"), { recursive: true });
await expect(validateCuratedSkillPlugin(root)).rejects.toThrow(/curated plugin entry missing: skills\/outline/);
});
it("fails closed when a skill manifest name does not match the allowlist", async () => {
const root = await skillPluginFixture();
await writeFile(join(root, "skills", "outline", "SKILL.md"), "---\nname: other\n---\n");
await expect(validateCuratedSkillPlugin(root)).rejects.toThrow(/curated skill manifest name mismatch/);
});
it("rejects an extra skill directory", async () => {
const root = await skillPluginFixture();
await mkdir(join(root, "skills", "extra"));
await expect(validateCuratedSkillPlugin(root)).rejects.toThrow(/unexpected curated plugin entry: skills\/extra/);
});
it("rejects plugin capabilities outside the reviewed skill catalog", async () => {
const root = await skillPluginFixture();
await mkdir(join(root, "hooks"));
await expect(validateCuratedSkillPlugin(root)).rejects.toThrow(/unexpected curated plugin entry: hooks/);
});
async function skillPluginFixture(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "cph-skills-"));
roots.push(root);
await mkdir(join(root, ".claude-plugin"), { recursive: true });
await writeFile(
join(root, ".claude-plugin", "plugin.json"),
JSON.stringify({ name: CURATED_SKILL_PLUGIN_NAME }),
);
for (const name of CURATED_SKILL_NAMES) {
const source = join(root, "skills", name);
await mkdir(source, { recursive: true });
await writeFile(join(source, "SKILL.md"), `---\nname: ${name}\n---\n# ${name}\n`);
}
return root;
}
});
+39 -9
View File
@@ -1,8 +1,9 @@
import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises";
import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { runAgent } from "../../src/agent/runner.js";
import { importSkillDirectory } from "../../src/agent/skillStore.js";
const queryMock = vi.hoisted(() => vi.fn());
@@ -76,7 +77,7 @@ describe("runAgent", () => {
workspaceRoot = await realpath(workspaceRoot);
workspace = await realpath(workspace);
previousSecrets = Object.fromEntries(
["DATABASE_URL", "FEISHU_APP_SECRET", "HUB_SESSION_SECRET"].map((name) => [name, process.env[name]]),
["DATABASE_URL", "FEISHU_APP_SECRET", "HUB_SESSION_SECRET", "HUB_SKILL_STORE_ROOT"].map((name) => [name, process.env[name]]),
);
});
@@ -113,8 +114,7 @@ describe("runAgent", () => {
allowDangerouslySkipPermissions: true,
settingSources: [],
settings: { disableBundledSkills: true },
plugins: [expect.objectContaining({ type: "local", skipMcpDiscovery: true })],
skills: ["cph-curated:outline", "cph-curated:lesson-project", "cph-curated:data-processing-spec"],
skills: [],
strictMcpConfig: true,
sandbox: expect.objectContaining({
enabled: true,
@@ -149,7 +149,7 @@ describe("runAgent", () => {
it("returns the skills actually reported by SDK initialization", async () => {
queryMock.mockReturnValue(messages(
initMessage(["cph-curated:outline"]),
initMessage(["cph-runtime:outline"]),
assistantMessage("fresh"),
resultMessage("sdk-session-1"),
));
@@ -164,7 +164,7 @@ describe("runAgent", () => {
prisma: stubPrisma,
});
expect(result.initializedSkillIds).toEqual(["cph-curated:outline"]);
expect(result.initializedSkillIds).toEqual(["cph-runtime:outline"]);
});
it("maps role tool ids to the Claude SDK tool whitelist", async () => {
@@ -183,13 +183,13 @@ describe("runAgent", () => {
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
options: {
tools: ["Read", "Bash", "Skill"],
tools: ["Read", "Bash"],
allowedTools: ["Read", "Bash", "mcp__cph_hub__send_file"],
},
});
});
it("keeps only the curated Skill dispatcher for an empty role tool whitelist", async () => {
it("disables SDK tools for an empty role tool and skill selection", async () => {
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
await runAgent({
@@ -205,12 +205,42 @@ describe("runAgent", () => {
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
options: {
tools: ["Skill"],
tools: [],
allowedTools: [],
},
});
});
it("loads only the dynamic skills selected by the role", async () => {
const source = join(root, "skill-source");
const storeRoot = join(root, "skill-store");
await mkdir(source);
await writeFile(join(source, "SKILL.md"), "---\nname: typst\ndescription: Typst\n---\n");
const installed = await importSkillDirectory({ sourceDir: source, storeRoot });
process.env["HUB_SKILL_STORE_ROOT"] = storeRoot;
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
await runAgent({
prompt: "排版",
model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
systemPrompt: undefined,
tools: [],
skills: [{ name: "typst", version: "0.15.0", contentDigest: installed.contentDigest }],
runId: "run-skill",
sessionId: "hub-session-1",
prisma: stubPrisma,
});
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
options: {
tools: ["Skill"],
plugins: [expect.objectContaining({ type: "local", skipMcpDiscovery: true })],
skills: ["cph-runtime:typst"],
},
});
});
it("returns SDK-reported cost when present", async () => {
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1", 0.0042)));
+71
View File
@@ -0,0 +1,71 @@
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, prepareRunSkillPlugin } 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/);
});
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;
}