designpaca 초기 구현 — 스킬 · 설치 CLI · 배포 파이프라인
웹 디자인 파이프라인 스킬과 이를 5개 에이전트에 설치하는 CLI 를 담은 모노레포. 스킬 (packages/skill) - SKILL.md 261줄 + 참조 문서 16개 3,349줄. progressive disclosure 로 본문은 절차와 인덱스만, 지식은 references/ 로 분리 - 0~6단계 파이프라인. 규모에 따라 전체·연장·국소 세 경로로 분기 - 하드 게이트 12개는 grep·카운트로 검증 가능한 것만. 취향 판단은 제외 - 미학 프리셋 5종, AI 슬롭 지문 목록, 한글 조판 규칙, SVG 필터·three.js·인터랙티브 모션·HTML-in-Canvas 실전 지침 설치 CLI (packages/cli, packages/core) - npx designpaca 온보딩 TUI. Claude Code · Codex · Cursor · Windsurf · AGENTS.md - 매니페스트에 설치 시점 해시를 기록해 사용자가 고친 파일은 update 가 건너뛴다 - 타깃별로 본문의 references/ 경로를 실제 설치 위치로 재작성 - AGENTS.md 는 항상 로드되므로 본문 대신 303자 포인터만 주입 - Windsurf 는 12,000자 상한 초과 시 설치를 차단 배포 (build/ci, .forgejo/workflows) - 태그 v* → 검사·테스트·빌드 → npmjs 배포 + Forgejo 레지스트리 미러 → draft 릴리스 → Cloudflare Pages. 재실행 멱등 근거 (research/) - 약 250개 웹 소스 조사 결과와 도그푸딩 검증 2건. 스킬의 모든 수치는 여기서 나온다 테스트 22개 통과 (core 16 · cli 6)
This commit is contained in:
commit
8808c672dc
135 changed files with 38838 additions and 0 deletions
163
packages/core/test/installer.test.ts
Normal file
163
packages/core/test/installer.test.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
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, /상한/);
|
||||
});
|
||||
50
packages/core/test/marker.test.ts
Normal file
50
packages/core/test/marker.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { extractBlock, hasBlock, removeBlock, upsertBlock } from "../src/marker.ts";
|
||||
|
||||
const M = "designpaca";
|
||||
|
||||
test("빈 문서에 블록을 넣는다", () => {
|
||||
const out = upsertBlock("", M, "본문");
|
||||
assert.ok(hasBlock(out, M));
|
||||
assert.equal(extractBlock(out, M), "본문");
|
||||
});
|
||||
|
||||
test("기존 문서 뒤에 붙이고 원문을 보존한다", () => {
|
||||
const doc = "# 내 지침\n\n건드리지 마라.\n";
|
||||
const out = upsertBlock(doc, M, "우리 블록");
|
||||
assert.ok(out.startsWith("# 내 지침"));
|
||||
assert.ok(out.includes("건드리지 마라."));
|
||||
assert.equal(extractBlock(out, M), "우리 블록");
|
||||
});
|
||||
|
||||
test("두 번째 호출은 블록 내용만 교체한다", () => {
|
||||
const first = upsertBlock("# 문서\n\n앞부분\n", M, "v1");
|
||||
const second = upsertBlock(first, M, "v2");
|
||||
assert.equal(extractBlock(second, M), "v2");
|
||||
assert.ok(!second.includes("v1"));
|
||||
assert.ok(second.includes("앞부분"));
|
||||
// 블록이 두 개로 늘어나면 안 된다
|
||||
assert.equal(second.split("designpaca:start").length - 1, 1);
|
||||
});
|
||||
|
||||
test("블록 뒤에 사용자가 쓴 내용도 보존한다", () => {
|
||||
const doc = upsertBlock("앞\n", M, "블록") + "\n뒤에 쓴 내용\n";
|
||||
const out = upsertBlock(doc, M, "새 블록");
|
||||
assert.ok(out.includes("앞"));
|
||||
assert.ok(out.includes("뒤에 쓴 내용"));
|
||||
assert.equal(extractBlock(out, M), "새 블록");
|
||||
});
|
||||
|
||||
test("제거하면 우리 블록만 사라진다", () => {
|
||||
const doc = upsertBlock("# 문서\n\n내용\n", M, "블록");
|
||||
const out = removeBlock(doc, M);
|
||||
assert.ok(!hasBlock(out, M));
|
||||
assert.ok(out.includes("# 문서"));
|
||||
assert.ok(out.includes("내용"));
|
||||
});
|
||||
|
||||
test("블록이 없는 문서를 제거해도 그대로다", () => {
|
||||
const doc = "# 문서\n";
|
||||
assert.equal(removeBlock(doc, M), doc);
|
||||
});
|
||||
23
packages/core/test/skill-source.test.ts
Normal file
23
packages/core/test/skill-source.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { splitFrontmatter } from "../src/skill-source.ts";
|
||||
|
||||
test("프론트매터와 본문을 나눈다", () => {
|
||||
const { fm, body } = splitFrontmatter(
|
||||
['---', 'name: designpaca', 'description: "따옴표 있는 값"', '---', '', '# 제목', '내용'].join("\n"),
|
||||
);
|
||||
assert.equal(fm["name"], "designpaca");
|
||||
assert.equal(fm["description"], "따옴표 있는 값");
|
||||
assert.ok(body.trim().startsWith("# 제목"));
|
||||
});
|
||||
|
||||
test("프론트매터가 없으면 전체가 본문이다", () => {
|
||||
const { fm, body } = splitFrontmatter("# 제목만 있다");
|
||||
assert.deepEqual(fm, {});
|
||||
assert.equal(body, "# 제목만 있다");
|
||||
});
|
||||
|
||||
test("CRLF 문서도 처리한다", () => {
|
||||
const { fm } = splitFrontmatter("---\r\nname: designpaca\r\n---\r\n본문");
|
||||
assert.equal(fm["name"], "designpaca");
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue