import assert from "node:assert/strict"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { after, test } from "node:test"; import { applyPlan, planInstall, removeInstall } from "../src/installer.ts"; import { inspectDrift, readManifest } from "../src/manifest.ts"; import { extractBlock } from "../src/marker.ts"; import type { SkillSource } from "../src/types.ts"; const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "designpaca-test-")); const home = path.join(tmp, "home"); const cwd = path.join(tmp, "proj"); await fs.mkdir(home, { recursive: true }); await fs.mkdir(cwd, { recursive: true }); after(async () => { await fs.rm(tmp, { recursive: true, force: true }); }); /** 본문에 references 경로를 넣어 타깃별 경로 재작성을 검증할 수 있게 한다 */ const BODY = "# 본문\n\n토큰은 `references/tokens.md` 를 봐라."; const skill: SkillSource = { version: "9.9.9", skillMd: ['---', 'name: designpaca', 'description: "테스트용"', '---', '', BODY, ''].join("\n"), files: new Map([["references/tokens.md", "# 토큰\n"]]), body: BODY, description: "테스트용", }; const env = { home, cwd }; test("claude-code: 설치 → 드리프트 없음 → 제거", async () => { const plan = await planInstall("claude-code", "user", skill, env); assert.equal(plan.alreadyInstalled, false); assert.equal(plan.actions.length, 3); // SKILL.md + references 1개 + 버전 스탬프 const res = await applyPlan(plan, skill.version, { env }); assert.equal(res.written.length, 3); assert.equal(res.skipped.length, 0); const skillMd = await fs.readFile(path.join(plan.root, "SKILL.md"), "utf8"); assert.ok(skillMd.includes("name: designpaca")); // 디렉터리를 통째로 복사하는 타깃은 상대 경로가 그대로 맞다. 재작성하면 안 된다. assert.ok(skillMd.includes("`references/tokens.md`")); assert.ok(!skillMd.includes(".claude/skills")); const drift = await inspectDrift(res.record); assert.deepEqual(drift.modified, []); const removed = await removeInstall(res.record, { env }); assert.equal(removed.removed.length, 3); assert.equal(removed.keptModified.length, 0); assert.equal((await readManifest(home)).installs.length, 0); await assert.rejects(() => fs.access(plan.root)); }); test("사용자가 고친 파일은 update 가 건너뛴다", async () => { const plan = await planInstall("claude-code", "user", skill, env); const first = await applyPlan(plan, skill.version, { env }); const target = path.join(plan.root, "SKILL.md"); await fs.writeFile(target, "내가 고친 내용\n", "utf8"); const drift = await inspectDrift(first.record); assert.deepEqual(drift.modified, [target]); const again = await applyPlan(await planInstall("claude-code", "user", skill, env), skill.version, { env, }); assert.deepEqual(again.skipped, [target]); assert.equal(await fs.readFile(target, "utf8"), "내가 고친 내용\n"); // force 면 덮되 .orig 로 남긴다 const forced = await applyPlan(await planInstall("claude-code", "user", skill, env), skill.version, { env, force: true, }); assert.equal(forced.skipped.length, 0); assert.equal(forced.backedUp.length, 1); assert.equal(await fs.readFile(`${target}.orig`, "utf8"), "내가 고친 내용\n"); assert.ok((await fs.readFile(target, "utf8")).includes("name: designpaca")); await removeInstall(forced.record, { env }); await fs.rm(`${target}.orig`, { force: true }); }); test("agents-md: 포인터만 주입하고 본문은 별도 디렉터리에 푼다", async () => { const doc = path.join(cwd, "AGENTS.md"); await fs.writeFile(doc, "# 내 프로젝트\n\n내 지침.\n", "utf8"); const plan = await planInstall("agents-md", "project", skill, env); const res = await applyPlan(plan, skill.version, { env }); const after1 = await fs.readFile(doc, "utf8"); assert.ok(after1.includes("# 내 프로젝트")); assert.ok(after1.includes("내 지침.")); const block = extractBlock(after1, "designpaca") ?? ""; // AGENTS.md 는 항상 로드된다. 본문 전체가 아니라 포인터만 들어가야 한다. assert.ok(block.includes(".designpaca/SKILL.md"), "포인터가 없다"); assert.ok(!block.includes("토큰은"), "본문이 통째로 들어갔다"); assert.ok(block.length < 1000, `블록이 ${block.length}자로 너무 크다`); // 본문은 .designpaca/ 에 있고, 그 안의 참조 경로가 재작성돼 있어야 한다 const skillMd = await fs.readFile(path.join(cwd, ".designpaca", "SKILL.md"), "utf8"); assert.ok(skillMd.includes(".designpaca/references/tokens.md"), "참조 경로가 재작성되지 않았다"); await fs.access(path.join(cwd, ".designpaca", "references", "tokens.md")); // 문서의 다른 곳을 고쳐도 드리프트로 잡히면 안 된다 (블록 안쪽만 본다) await fs.writeFile(doc, after1.replace("내 지침.", "내 지침을 고쳤다."), "utf8"); assert.deepEqual((await inspectDrift(res.record)).modified, []); await removeInstall(res.record, { env }); const after2 = await fs.readFile(doc, "utf8"); assert.ok(after2.includes("내 지침을 고쳤다.")); assert.ok(!after2.includes("designpaca:start")); }); test("cursor: 네이티브 스킬 경로에 SKILL.md 그대로 싣는다 (전역·프로젝트)", async () => { const user = await planInstall("cursor", "user", skill, env); assert.equal(user.root, path.join(home, ".cursor", "skills", "designpaca")); const proj = await planInstall("cursor", "project", skill, env); assert.equal(proj.root, path.join(cwd, ".cursor", "skills", "designpaca")); const res = await applyPlan(proj, skill.version, { env }); const md = await fs.readFile(path.join(proj.root, "SKILL.md"), "utf8"); assert.match(md, /^---\n/, "프론트매터가 그대로 있어야 한다"); assert.match(md, /`references\/tokens\.md`/, "참조 경로를 재작성하면 안 된다"); await fs.access(path.join(proj.root, "references", "tokens.md")); await removeInstall(res.record, { env }); }); test("windsurf: 네이티브 스킬 경로에 싣는다 — 본문 크기 제한이 없다", async () => { const proj = await planInstall("windsurf", "project", skill, env); assert.equal(proj.root, path.join(cwd, ".windsurf", "skills", "designpaca")); const user = await planInstall("windsurf", "user", skill, env); assert.equal(user.root, path.join(home, ".codeium", "windsurf", "skills", "designpaca")); // 예전 규칙 파일 방식은 12,000자 상한 때문에 실제 스킬(본문 25KB)이 막혔다. // 네이티브 스킬은 본문을 통째로 프롬프트에 넣지 않으므로 큰 본문도 설치돼야 한다. const huge: SkillSource = { ...skill, body: "가".repeat(13_000) }; const hugePlan = await planInstall("windsurf", "project", huge, env); assert.ok(!hugePlan.blocked, "본문이 크다고 막히면 안 된다"); const res = await applyPlan(proj, skill.version, { env }); const md = await fs.readFile(path.join(proj.root, "SKILL.md"), "utf8"); assert.match(md, /`references\/tokens\.md`/, "참조 경로를 재작성하면 안 된다"); await fs.access(path.join(proj.root, "references", "tokens.md")); await removeInstall(res.record, { env }); }); test("antigravity: 프로젝트는 .agents/skills, 전역은 ~/.gemini/antigravity-cli/skills", async () => { // 두 경로가 다른 제품 규약이라 하나만 맞으면 다른 쪽에서 스킬이 안 잡힌다. const proj = await planInstall("antigravity", "project", skill, env); assert.equal(proj.root, path.join(cwd, ".agents", "skills", "designpaca")); // 전역은 공식 마이그레이션 문서가 정한 현재 경로다 (~/.gemini/config/skills 는 레거시). const user = await planInstall("antigravity", "user", skill, env); assert.equal(user.root, path.join(home, ".gemini", "antigravity-cli", "skills", "designpaca")); }); test("antigravity: SKILL.md 를 변환 없이 그대로 싣는다", async () => { // Claude Code 와 같은 규약이라 프론트매터도 참조 경로도 손대지 않아야 한다. // 여기서 경로를 재작성하면 references 가 존재하지 않는 곳을 가리킨다. const plan = await planInstall("antigravity", "project", skill, env); const res = await applyPlan(plan, skill.version, { env }); const md = await fs.readFile(path.join(plan.root, "SKILL.md"), "utf8"); assert.match(md, /^---\n/, "프론트매터가 있어야 한다"); assert.match(md, /description:/, "Antigravity 는 description 을 필수로 요구한다"); assert.match(md, /`references\/tokens\.md`/, "참조 경로를 재작성하면 안 된다"); const ref = await fs.readFile(path.join(plan.root, "references", "tokens.md"), "utf8"); assert.match(ref, /# 토큰/); await removeInstall(res.record, { env }); }); test("agents: 중립 경로 — 전역 ~/.agents/skills, 프로젝트 .agents/skills", async () => { const user = await planInstall("agents", "user", skill, env); assert.equal(user.root, path.join(home, ".agents", "skills", "designpaca")); const proj = await planInstall("agents", "project", skill, env); assert.equal(proj.root, path.join(cwd, ".agents", "skills", "designpaca")); const res = await applyPlan(proj, skill.version, { env }); const md = await fs.readFile(path.join(proj.root, "SKILL.md"), "utf8"); assert.match(md, /^---\n/, "프론트매터가 있어야 한다"); assert.match(md, /`references\/tokens\.md`/, "참조 경로를 재작성하면 안 된다"); await fs.access(path.join(proj.root, "references", "tokens.md")); await removeInstall(res.record, { env }); await assert.rejects(() => fs.access(proj.root)); }); test("gemini-cli: 전역 ~/.gemini/skills, 프로젝트 .gemini/skills", async () => { const user = await planInstall("gemini-cli", "user", skill, env); assert.equal(user.root, path.join(home, ".gemini", "skills", "designpaca")); const proj = await planInstall("gemini-cli", "project", skill, env); assert.equal(proj.root, path.join(cwd, ".gemini", "skills", "designpaca")); }); test("copilot: 전역 ~/.copilot/skills, 프로젝트 .github/skills", async () => { const user = await planInstall("copilot", "user", skill, env); assert.equal(user.root, path.join(home, ".copilot", "skills", "designpaca")); const proj = await planInstall("copilot", "project", skill, env); assert.equal(proj.root, path.join(cwd, ".github", "skills", "designpaca")); }); test("zcode: 전역은 ~/.zcode/skills, 프로젝트는 .zcode/skills", async () => { // ZCode 는 .zcode/skills 와 .agents/skills 를 모두 읽지만 중립 경로 쪽은 // agents 타깃이 쓰는 영역이라 zcode 타깃은 건드리지 않는다. const user = await planInstall("zcode", "user", skill, env); assert.equal(user.root, path.join(home, ".zcode", "skills", "designpaca")); const proj = await planInstall("zcode", "project", skill, env); assert.equal(proj.root, path.join(cwd, ".zcode", "skills", "designpaca")); }); test("zcode: SKILL.md 를 변환 없이 그대로 싣는다", async () => { const plan = await planInstall("zcode", "user", skill, env); const res = await applyPlan(plan, skill.version, { env }); const md = await fs.readFile(path.join(plan.root, "SKILL.md"), "utf8"); assert.match(md, /^---\n/, "프론트매터가 있어야 한다"); assert.match(md, /`references\/tokens\.md`/, "참조 경로를 재작성하면 안 된다"); await fs.access(path.join(plan.root, "references", "tokens.md")); await fs.access(path.join(plan.root, ".designpaca_version")); await removeInstall(res.record, { env }); await assert.rejects(() => fs.access(plan.root)); });