designpaca/packages/core/test/installer.test.ts
Yun Chan e4ac43f0b3
All checks were successful
ci / build (push) Successful in 25s
release / release (push) Successful in 40s
feat(core): Antigravity 등 .agents/skills 규약을 읽는 하네스를 지원한다
Antigravity 에서 스킬로 인식되지 않는다는 제보를 받고 확인했다.
설치 경로가 없었던 것이 원인이다.

공식 문서 기준 경로:
  워크스페이스  <root>/.agents/skills/<이름>/
  전역         ~/.gemini/config/skills/<이름>/

.agents/skills 는 특정 제품 경로가 아니라 중립 경로라, 같은 규약을 쓰는
다른 하네스도 함께 읽는다. SKILL.md 구조(프론트매터 + references/)가
Claude Code 와 같아서 변환 없이 그대로 복사하면 된다.

프론트매터 요구사항도 맞는 것을 확인했다 — description 필수, name 선택.
우리 SKILL.md 는 둘 다 갖고 있다.

- targets/antigravity.ts 추가, 설치 대상 5 → 6종
- 하위호환 경로(.agent/skills, 단수)는 감지에만 쓰고 설치는 복수형에만 한다.
  두 곳에 깔면 스킬이 두 번 잡힌다
- 테스트 2개 추가: 범위별 경로, 변환 없이 싣는지 (테스트 25 → 27개)
- 소개 페이지 설치 대상 목록에 반영
2026-08-21 03:16:53 +09:00

189 lines
8.6 KiB
TypeScript

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 는 user 범위를 거부한다", async () => {
const plan = await planInstall("cursor", "user", skill, env);
assert.ok(plan.blocked);
await assert.rejects(() => applyPlan(plan, skill.version, { env }));
});
test("cursor: .mdc 프론트매터로 변환하고 참조 경로를 재작성한다", async () => {
const plan = await planInstall("cursor", "project", skill, env);
const res = await applyPlan(plan, skill.version, { env });
const mdc = await fs.readFile(path.join(cwd, ".cursor", "rules", "designpaca.mdc"), "utf8");
assert.ok(mdc.startsWith("---\ndescription: 테스트용"));
assert.ok(mdc.includes("alwaysApply: false"));
assert.ok(mdc.includes("# 본문"));
// 본문과 references 가 다른 디렉터리에 놓이므로 경로가 재작성돼야 한다
assert.ok(
mdc.includes(".cursor/rules/designpaca/references/tokens.md"),
"참조 경로가 재작성되지 않았다",
);
await fs.access(path.join(cwd, ".cursor", "rules", "designpaca", "references", "tokens.md"));
await removeInstall(res.record, { env });
});
test("windsurf: Cursor 와 다른 경로·프론트매터를 쓴다", async () => {
const plan = await planInstall("windsurf", "project", skill, env);
const res = await applyPlan(plan, skill.version, { env });
const rule = await fs.readFile(path.join(cwd, ".windsurf", "rules", "designpaca.md"), "utf8");
// always_on 이면 디자인 스킬이 모든 메시지의 시스템 프롬프트에 상주한다
assert.ok(rule.includes("trigger: model_decision"));
assert.ok(!rule.includes("alwaysApply"));
assert.ok(rule.includes(".windsurf/rules/designpaca/references/tokens.md"));
await removeInstall(res.record, { env });
});
test("windsurf: 12,000자 상한을 넘으면 설치를 막는다", async () => {
const huge: SkillSource = { ...skill, body: "가".repeat(13_000) };
const plan = await planInstall("windsurf", "project", huge, env);
assert.ok(plan.blocked, "상한 초과인데 막지 않았다");
assert.match(plan.blocked, /상한/);
});
test("antigravity: 프로젝트는 .agents/skills, 전역은 ~/.gemini/config/skills", async () => {
// 두 경로가 다른 제품 규약이라 하나만 맞으면 다른 쪽에서 스킬이 안 잡힌다.
const proj = await planInstall("antigravity", "project", skill, env);
assert.equal(proj.root, path.join(cwd, ".agents", "skills", "designpaca"));
const user = await planInstall("antigravity", "user", skill, env);
assert.equal(user.root, path.join(home, ".gemini", "config", "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 });
});