/** * On-disk behavior of an Agent's installed Skills: installSkill / * removeSkill / listInstalledSkills, and metadata injection via skillMetadataSection / * assembleSystemPrompt. */ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { librarySkill } from "@prismshadow/penguin-skills"; import { AGENTS_MD_PLACEHOLDER, DEFAULT_AGENT_ID, DEFAULT_PROJECT_ID, SKILL_METADATA_PLACEHOLDER, agentStateDir, assembleSystemPrompt, installSkill, listInstalledSkills, removeSkill, skillMetadataSection, skillsDir, } from "../src/state/index.js"; let tmpRoot: string; beforeEach(async () => { tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "penguin-skills-")); }); afterEach(async () => { await fs.rm(tmpRoot, { recursive: true, force: true }); }); const install = (name: string, content: string, icon?: string) => installSkill(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID, { name, content, ...(icon !== undefined ? { icon } : {}), }); const list = () => listInstalledSkills(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID); const skillFile = (name: string, file: string) => path.join(skillsDir(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID), name, file); const skillMd = (name: string) => skillFile(name, "SKILL.md"); const skillIcon = (name: string) => skillFile(name, "icon.svg"); describe("installSkill / removeSkill", () => { it("writes skills//SKILL.md verbatim with a trailing newline", async () => { const skill = librarySkill("penguin-cli")!; await install(skill.name, skill.content); expect(await fs.readFile(skillMd("penguin-cli"), "utf8")).toBe(skill.content); // Content without a trailing newline gets one appended; reinstalling overwrites. await install("penguin-cli", "---\nname: penguin-cli\nversion: 2\n---\n\nNew body"); expect(await fs.readFile(skillMd("penguin-cli"), "utf8")).toBe( "---\nname: penguin-cli\nversion: 2\n---\n\nNew body\n", ); expect((await list()).map((s) => s.version)).toEqual([2]); }); it("writes icon.svg alongside SKILL.md, and reinstalling without icon removes it", async () => { // A library skill with an icon: installing writes it to disk alongside SKILL.md. const skill = librarySkill("penguin-sdk")!; expect(skill.icon).toBeTruthy(); await install(skill.name, skill.content, skill.icon); expect(await fs.readFile(skillIcon("penguin-sdk"), "utf8")).toBe(skill.icon); // Overwrite semantics: this install has no icon -> the old icon.svg is removed, and the // directory matches this install's content exactly. await install("penguin-sdk", "---\nname: penguin-sdk\nversion: 2\n---\n\nNew body\n"); await expect(fs.access(skillIcon("penguin-sdk"))).rejects.toThrow(); expect(await fs.readFile(skillMd("penguin-sdk"), "utf8")).toContain("New body"); }); it("rejects invalid skill names (path traversal safety)", async () => { await expect(install("../evil", "x")).rejects.toThrow(/skill_name/); await expect(install("a/b", "x")).rejects.toThrow(/skill_name/); await expect(removeSkill(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID, "..")).rejects.toThrow( /skill_name/, ); }); it("removeSkill deletes the whole skill directory and is idempotent", async () => { const skill = librarySkill("penguin-sdk")!; await install(skill.name, skill.content); await removeSkill(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID, "penguin-sdk"); await expect(fs.access(skillMd("penguin-sdk"))).rejects.toThrow(); // Idempotent when it no longer exists: does not throw. await removeSkill(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID, "penguin-sdk"); expect(await list()).toEqual([]); }); }); describe("listInstalledSkills", () => { it("returns [] when the skills directory does not exist", async () => { expect(await list()).toEqual([]); }); it("parses frontmatter and sorts by name", async () => { await install( "zeta", "---\nname: zeta\ndescription: Z skill.\nversion: 3\nupdated: 2026-07-16\n---\n\nBody\n", ); await install( "alpha", "---\nname: alpha\ndescription: A skill.\nversion: 1\nupdated: 2026-07-16\n---\n\nBody\n", ); expect(await list()).toEqual([ { name: "alpha", description: "A skill.", version: 1, updated: "2026-07-16" }, { name: "zeta", description: "Z skill.", version: 3, updated: "2026-07-16" }, ]); }); it("returns icon.svg content and passes short description fields through", async () => { const icon = '\n'; await install( "with-extras", "---\nname: with-extras\ndescription: Long description here.\nshort_description: Short one.\nshort_description_zh: 短描述。\nversion: 1\nupdated: 2026-07-17\n---\n\nBody\n", icon, ); await install( "plain", "---\nname: plain\ndescription: Plain skill.\nversion: 1\nupdated: 2026-07-17\n---\n\nBody\n", ); const skills = await list(); expect(skills).toEqual([ { name: "plain", description: "Plain skill.", version: 1, updated: "2026-07-17" }, { name: "with-extras", description: "Long description here.", shortDescription: "Short one.", shortDescriptionZh: "短描述。", version: 1, updated: "2026-07-17", icon, }, ]); // Entries missing icon / short description omit the corresponding field (undefined does // not produce a key; the interface layer's conditional spread relies on this convention). expect("icon" in skills[0]!).toBe(false); expect("shortDescription" in skills[0]!).toBe(false); }); it("uses the directory name as the skill identity even when frontmatter name disagrees", async () => { // A hand-written or network-sourced skill may have a frontmatter name that differs from // its directory name: the directory name is the addressing key used for install, uninstall, // and Prompt lookup, so the listing must follow it (frontmatter fields are display-only). await install( "local-name", "---\nname: upstream-name\ndescription: Fetched skill.\nversion: 2\nupdated: 2026-07-01\n---\n\nBody\n", ); expect(await list()).toEqual([ { name: "local-name", description: "Fetched skill.", version: 2, updated: "2026-07-01" }, ]); }); it("falls back to directory-name metadata for broken frontmatter and skips non-skill entries", async () => { // A SKILL.md without frontmatter: falls back to directory name + empty description + version 1. await install("broken", "# No frontmatter here\n"); // Directories without a SKILL.md, and stray files, do not count as Skills. const dir = skillsDir(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID); await fs.mkdir(path.join(dir, "empty-dir"), { recursive: true }); await fs.writeFile(path.join(dir, "stray.md"), "stray", "utf8"); expect(await list()).toEqual([{ name: "broken", description: "", version: 1, updated: "" }]); }); }); describe("skillMetadataSection / assembleSystemPrompt injection", () => { it("renders one `- \\`name\\` — description` line per skill; empty input renders empty", () => { expect(skillMetadataSection([])).toBe(""); expect( skillMetadataSection([ { name: "a", description: "Does A.", version: 1, updated: "2026-07-16" }, { name: "b", description: "", version: 1, updated: "" }, ]), ).toBe("- `a` — Does A.\n- `b`"); }); it("replaces {{SKILL_METADATA}} with metadata lines, or an empty string when absent", () => { const state = { root: tmpRoot, projectId: DEFAULT_PROJECT_ID, agentId: DEFAULT_AGENT_ID, stateDir: agentStateDir(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID), systemConfig: { system_prompt: ["before", AGENTS_MD_PLACEHOLDER, SKILL_METADATA_PLACEHOLDER, "after"].join( "\n", ), }, agentsMd: "# Agent Rules", }; const prompt = assembleSystemPrompt(state, undefined, undefined, [ { name: "demo", description: "Demo skill.", version: 1, updated: "2026-07-16" }, ]); expect(prompt).toBe(["before", "# Agent Rules", "- `demo` — Demo skill.", "after"].join("\n")); // Not provided / empty list: the placeholder is replaced with an empty string, no residue left. const empty = assembleSystemPrompt(state); expect(empty).toBe(["before", "# Agent Rules", "", "after"].join("\n")); expect(empty).not.toContain(SKILL_METADATA_PLACEHOLDER); }); });