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:
Yun Chan 2026-08-20 10:48:00 +09:00
commit 8808c672dc
135 changed files with 38838 additions and 0 deletions

65
packages/core/src/fsx.ts Normal file
View file

@ -0,0 +1,65 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
export function sha256(content: string): string {
// 개행 정규화 — Windows 체크아웃(autocrlf)에서 해시가 흔들리는 것을 막는다
return createHash("sha256").update(content.replace(/\r\n/g, "\n"), "utf8").digest("hex");
}
export async function exists(p: string): Promise<boolean> {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}
export async function readIfExists(p: string): Promise<string | null> {
try {
return await fs.readFile(p, "utf8");
} catch {
return null;
}
}
/** 임시 파일에 쓴 뒤 rename — 중간에 죽어도 반쯤 쓰인 파일이 남지 않는다 */
export async function writeAtomic(p: string, content: string): Promise<void> {
await fs.mkdir(path.dirname(p), { recursive: true });
const tmp = `${p}.${process.pid}.tmp`;
await fs.writeFile(tmp, content, "utf8");
await fs.rename(tmp, p);
}
/** 사용자가 고친 파일을 덮기 전에 원본을 남겨둔다 */
export async function backup(p: string): Promise<string | null> {
const cur = await readIfExists(p);
if (cur === null) return null;
const dest = `${p}.orig`;
await fs.writeFile(dest, cur, "utf8");
return dest;
}
/** 파일을 지우고, 비게 된 상위 디렉터리를 stopAt 까지 정리한다 */
export async function removeFileAndPrune(p: string, stopAt: string): Promise<void> {
await fs.rm(p, { force: true });
let dir = path.dirname(p);
const stop = path.resolve(stopAt);
while (path.resolve(dir).startsWith(stop) && path.resolve(dir) !== stop) {
try {
const rest = await fs.readdir(dir);
if (rest.length > 0) break;
await fs.rmdir(dir);
} catch {
break;
}
dir = path.dirname(dir);
}
// stopAt 자체도 비었으면 함께 정리한다
try {
if ((await fs.readdir(stop)).length === 0) await fs.rmdir(stop);
} catch {
/* 남아있으면 그대로 둔다 */
}
}

View file

@ -0,0 +1,8 @@
export * from "./types.ts";
export * from "./paths.ts";
export * from "./fsx.ts";
export * from "./marker.ts";
export * from "./manifest.ts";
export * from "./skill-source.ts";
export * from "./installer.ts";
export { ADAPTERS, getAdapter, MARKER } from "./targets/index.ts";

View file

@ -0,0 +1,185 @@
import os from "node:os";
import path from "node:path";
import { backup, readIfExists, removeFileAndPrune, sha256, writeAtomic } from "./fsx.ts";
import { removeBlock, upsertBlock } from "./marker.ts";
import { dropInstall, findInstall, inspectDrift, readManifest, upsertInstall } from "./manifest.ts";
import { ADAPTERS, getAdapter } from "./targets/index.ts";
import type {
InstalledFile,
InstallPlan,
InstallRecord,
Scope,
SkillSource,
TargetContext,
TargetId,
} from "./types.ts";
export interface EnvOptions {
home?: string;
cwd?: string;
}
function ctxOf(scope: Scope, skill: SkillSource, env: EnvOptions = {}): TargetContext {
return {
scope,
home: env.home ?? os.homedir(),
cwd: env.cwd ?? process.cwd(),
skill,
};
}
/** 시스템에 흔적이 있는 타깃을 골라준다 — TUI 의 기본 체크 상태로 쓴다 */
export async function detectTargets(skill: SkillSource, env: EnvOptions = {}): Promise<TargetId[]> {
const found: TargetId[] = [];
for (const a of ADAPTERS) {
const scope: Scope = a.scopes.includes("user") ? "user" : "project";
if (await a.detect(ctxOf(scope, skill, env))) found.push(a.id);
}
return found;
}
export async function planInstall(
target: TargetId,
scope: Scope,
skill: SkillSource,
env: EnvOptions = {},
): Promise<InstallPlan> {
const adapter = getAdapter(target);
if (!adapter.scopes.includes(scope)) {
const only = adapter.scopes.join("/");
return {
target,
scope,
root: "",
actions: [],
alreadyInstalled: false,
blocked: `${adapter.label} 은(는) ${only} 범위만 지원한다`,
};
}
return adapter.plan(ctxOf(scope, skill, env));
}
export interface ApplyResult {
record: InstallRecord;
written: string[];
backedUp: string[];
/** 사용자가 고쳐서 건너뛴 파일 */
skipped: string[];
}
/**
* .
*
* 안전장치: 이전 ,
* (force , .orig ).
*/
export async function applyPlan(
plan: InstallPlan,
version: string,
opts: { force?: boolean; env?: EnvOptions } = {},
): Promise<ApplyResult> {
if (plan.blocked) throw new Error(plan.blocked);
const home = opts.env?.home;
const prev = findInstall(await readManifest(home), plan.target, plan.scope, plan.root);
const modified = new Set<string>();
if (prev) {
const drift = await inspectDrift(prev);
for (const p of drift.modified) modified.add(p);
}
const files: InstalledFile[] = [];
const written: string[] = [];
const backedUp: string[] = [];
const skipped: string[] = [];
for (const action of plan.actions) {
const isDirty = modified.has(action.path);
if (isDirty && !opts.force) {
// 건너뛰더라도 매니페스트에서 빠지면 uninstall 이 이 파일을 놓친다 — 기록은 남긴다.
// 이때 "현재" 해시를 쓰면 안 된다. 그러면 다음 update 에서 드리프트가 사라져
// 사용자가 고친 파일을 조용히 덮어쓰게 된다. 설치 당시 해시를 그대로 유지한다.
const kept = prev?.files.find((f) => f.path === action.path);
files.push(
kept ?? {
path: action.path,
sha256: sha256(action.kind === "inject" ? action.content.trim() : action.content),
...(action.kind === "inject" ? { marker: action.marker } : {}),
},
);
skipped.push(action.path);
continue;
}
if (isDirty && opts.force) {
const b = await backup(action.path);
if (b) backedUp.push(b);
}
if (action.kind === "write") {
await writeAtomic(action.path, action.content);
files.push({ path: action.path, sha256: sha256(action.content) });
} else {
const cur = (await readIfExists(action.path)) ?? "";
const next = upsertBlock(cur, action.marker, action.content);
await writeAtomic(action.path, next);
// 마커 방식은 블록 안쪽만 해시한다 — 문서의 다른 부분은 사용자 자유다
files.push({ path: action.path, sha256: sha256(action.content.trim()), marker: action.marker });
}
written.push(action.path);
}
const record: InstallRecord = {
target: plan.target,
scope: plan.scope,
root: plan.root,
version,
installedAt: new Date().toISOString(),
files,
};
await upsertInstall(record, home);
return { record, written, backedUp, skipped };
}
export interface RemoveResult {
removed: string[];
keptModified: string[];
}
/** 매니페스트에 기록된 것만 되돌린다. 기록에 없는 파일은 손대지 않는다. */
export async function removeInstall(
record: InstallRecord,
opts: { force?: boolean; env?: EnvOptions } = {},
): Promise<RemoveResult> {
const drift = await inspectDrift(record);
const dirty = new Set(drift.modified);
const removed: string[] = [];
const keptModified: string[] = [];
for (const f of record.files) {
if (dirty.has(f.path) && !opts.force) {
keptModified.push(f.path);
continue;
}
if (f.marker) {
const cur = await readIfExists(f.path);
if (cur === null) continue;
const next = removeBlock(cur, f.marker);
// 블록만 남아 있던 문서라면 파일째 지운다
if (next.trim().length === 0) await removeFileAndPrune(f.path, path.dirname(f.path));
else await writeAtomic(f.path, next);
} else {
await removeFileAndPrune(f.path, record.root);
}
removed.push(f.path);
}
if (keptModified.length === 0) {
await dropInstall(record.target, record.scope, record.root, opts.env?.home);
} else {
// 일부만 남았으면 기록도 남은 것만 유지한다
await upsertInstall({ ...record, files: record.files.filter((f) => dirty.has(f.path)) }, opts.env?.home);
}
return { removed, keptModified };
}

View file

@ -0,0 +1,105 @@
import fs from "node:fs/promises";
import path from "node:path";
import { manifestPath } from "./paths.ts";
import { readIfExists, sha256, writeAtomic } from "./fsx.ts";
import { extractBlock } from "./marker.ts";
import type { DriftReport, InstallRecord, Manifest, Scope, TargetId } from "./types.ts";
const EMPTY: Manifest = { schema: 1, installs: [] };
export async function readManifest(home?: string): Promise<Manifest> {
const raw = await readIfExists(manifestPath(home));
if (!raw) return structuredClone(EMPTY);
try {
const parsed = JSON.parse(raw) as Manifest;
if (parsed.schema !== 1 || !Array.isArray(parsed.installs)) return structuredClone(EMPTY);
return parsed;
} catch {
// 손상된 매니페스트로 설치 전체가 막히지 않도록 빈 것으로 되돌린다
return structuredClone(EMPTY);
}
}
export async function writeManifest(m: Manifest, home?: string): Promise<void> {
await writeAtomic(manifestPath(home), JSON.stringify(m, null, 2) + "\n");
}
function sameInstall(a: InstallRecord, target: TargetId, scope: Scope, root: string): boolean {
return a.target === target && a.scope === scope && path.resolve(a.root) === path.resolve(root);
}
export function findInstall(
m: Manifest,
target: TargetId,
scope: Scope,
root: string,
): InstallRecord | undefined {
return m.installs.find((i) => sameInstall(i, target, scope, root));
}
export async function upsertInstall(record: InstallRecord, home?: string): Promise<void> {
const m = await readManifest(home);
const idx = m.installs.findIndex((i) => sameInstall(i, record.target, record.scope, record.root));
if (idx >= 0) m.installs[idx] = record;
else m.installs.push(record);
await writeManifest(m, home);
}
export async function dropInstall(
target: TargetId,
scope: Scope,
root: string,
home?: string,
): Promise<void> {
const m = await readManifest(home);
m.installs = m.installs.filter((i) => !sameInstall(i, target, scope, root));
await writeManifest(m, home);
}
/**
* .
*
* , update .
*/
export async function inspectDrift(record: InstallRecord): Promise<DriftReport> {
const files: DriftReport["files"] = [];
for (const f of record.files) {
const cur = await readIfExists(f.path);
if (cur === null) {
files.push({ path: f.path, status: "missing" });
continue;
}
const target = f.marker ? (extractBlock(cur, f.marker) ?? "") : cur;
files.push({ path: f.path, status: sha256(target) === f.sha256 ? "ok" : "modified" });
}
return {
record,
files,
modified: files.filter((f) => f.status === "modified").map((f) => f.path),
};
}
/** 매니페스트가 가리키는 경로가 실제로 살아있는지 확인한다 */
export async function pruneDeadInstalls(home?: string): Promise<number> {
const m = await readManifest(home);
const before = m.installs.length;
const alive: InstallRecord[] = [];
for (const rec of m.installs) {
const anyAlive = await Promise.all(
rec.files.map(async (f) => {
try {
await fs.access(f.path);
return true;
} catch {
return false;
}
}),
);
if (anyAlive.some(Boolean)) alive.push(rec);
}
if (alive.length !== before) {
m.installs = alive;
await writeManifest(m, home);
}
return before - alive.length;
}

View file

@ -0,0 +1,51 @@
/**
* AGENTS.md .
* .
*/
export function startTag(marker: string): string {
return `<!-- ${marker}:start -->`;
}
export function endTag(marker: string): string {
return `<!-- ${marker}:end -->`;
}
export function hasBlock(doc: string, marker: string): boolean {
return doc.includes(startTag(marker)) && doc.includes(endTag(marker));
}
/** 블록이 있으면 내용만 교체, 없으면 문서 끝에 덧붙인다 */
export function upsertBlock(doc: string, marker: string, content: string): string {
const s = startTag(marker);
const e = endTag(marker);
const block = `${s}\n${content.trim()}\n${e}`;
const si = doc.indexOf(s);
const ei = doc.indexOf(e);
if (si !== -1 && ei !== -1 && ei > si) {
return doc.slice(0, si) + block + doc.slice(ei + e.length);
}
const base = doc.trimEnd();
return base.length > 0 ? `${base}\n\n${block}\n` : `${block}\n`;
}
/** 블록만 제거하고 나머지 문서는 보존한다 */
export function removeBlock(doc: string, marker: string): string {
const s = startTag(marker);
const e = endTag(marker);
const si = doc.indexOf(s);
const ei = doc.indexOf(e);
if (si === -1 || ei === -1 || ei < si) return doc;
const out = doc.slice(0, si) + doc.slice(ei + e.length);
return out.replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
}
/** 블록 내부 내용만 뽑아낸다 (드리프트 판정용) */
export function extractBlock(doc: string, marker: string): string | null {
const s = startTag(marker);
const e = endTag(marker);
const si = doc.indexOf(s);
const ei = doc.indexOf(e);
if (si === -1 || ei === -1 || ei < si) return null;
return doc.slice(si + s.length, ei).trim();
}

View file

@ -0,0 +1,25 @@
import os from "node:os";
import path from "node:path";
/** designpaca 자체 상태 디렉터리 (~/.designpaca) */
export function stateDir(home = os.homedir()): string {
return path.join(home, ".designpaca");
}
export function manifestPath(home = os.homedir()): string {
return path.join(stateDir(home), "manifest.json");
}
/** 업데이트 확인 캐시 */
export function updateCachePath(home = os.homedir()): string {
return path.join(stateDir(home), "update-check.json");
}
/** 홈 경로를 ~ 로 줄여 표시한다 */
export function tildify(p: string, home = os.homedir()): string {
const rel = path.relative(home, p);
if (!rel.startsWith("..") && !path.isAbsolute(rel)) {
return path.posix.join("~", rel.split(path.sep).join("/"));
}
return p;
}

View file

@ -0,0 +1,62 @@
import type { Dirent } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import type { SkillSource } from "./types.ts";
/** 프론트매터를 본문과 분리한다. yaml 파서를 끌어오지 않으려고 필요한 필드만 얕게 읽는다. */
export function splitFrontmatter(md: string): { fm: Record<string, string>; body: string } {
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(md);
if (!m) return { fm: {}, body: md };
const fm: Record<string, string> = {};
for (const line of (m[1] ?? "").split(/\r?\n/)) {
const kv = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
if (!kv) continue;
let v = (kv[2] ?? "").trim();
// 따옴표로 감싼 값의 따옴표만 벗긴다
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
v = v.slice(1, -1);
}
fm[kv[1] as string] = v;
}
return { fm, body: md.slice(m[0].length) };
}
async function walk(dir: string, base = dir): Promise<string[]> {
const out: string[] = [];
let entries: Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return out;
}
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) out.push(...(await walk(full, base)));
else out.push(path.relative(base, full).split(path.sep).join("/"));
}
return out.sort();
}
/**
* .
* SKILL.md , (references/ ) .
*/
export async function loadSkillSource(root: string, version: string): Promise<SkillSource> {
const skillMd = await fs.readFile(path.join(root, "SKILL.md"), "utf8");
const { fm, body } = splitFrontmatter(skillMd);
const files = new Map<string, string>();
for (const rel of await walk(root)) {
if (rel === "SKILL.md") continue;
if (rel.endsWith(".orig") || rel === ".designpaca_version") continue;
files.set(rel, await fs.readFile(path.join(root, rel), "utf8"));
}
return {
version,
skillMd,
files,
body: body.trim(),
description: fm["description"] ?? "웹 디자인 파이프라인 스킬",
};
}

View file

@ -0,0 +1,71 @@
import path from "node:path";
import { exists } from "../fsx.ts";
import type { FileAction, TargetAdapter, TargetContext } from "../types.ts";
import { MARKER, rewriteRefPaths, skillDirActions } from "./common.ts";
/**
* AGENTS.md .
* . .
*
* ** .** AGENTS.md ,
* Codex 32KiB . 14KB
* , .
* .designpaca/ .
*/
export const agentsMd: TargetAdapter = {
id: "agents-md",
label: "범용 AGENTS.md",
hint: "AGENTS.md 에 포인터 주입 — 에이전트 무관",
scopes: ["project", "user"],
async detect(ctx: TargetContext) {
// 항상 로드되는 자리라 기본 선택은 보수적으로 — AGENTS.md 가 실제로 있을 때만.
return (
(await exists(path.join(ctx.cwd, "AGENTS.md"))) ||
(await exists(path.join(ctx.home, ".codex", "AGENTS.md")))
);
},
async plan(ctx: TargetContext) {
const isUser = ctx.scope === "user";
const doc = isUser
? path.join(ctx.home, ".codex", "AGENTS.md")
: path.join(ctx.cwd, "AGENTS.md");
// user/project 가 같은 구조를 갖도록 둘 다 .designpaca 를 루트로 쓴다
const refRoot = isUser
? path.join(ctx.home, ".designpaca", "skill")
: path.join(ctx.cwd, ".designpaca");
const refPrefix = isUser ? "~/.designpaca/skill" : ".designpaca";
const block = [
"## designpaca — 웹 디자인 파이프라인",
"",
"이 리포에서 UI 를 새로 만들거나 다시 디자인할 때는, 마크업·스타일을 쓰기 전에",
`\`${refPrefix}/SKILL.md\` 를 읽고 그 파이프라인(0~6단계)을 따른다.`,
"",
"- 프로젝트 루트에 `design.md` 가 있으면 그것이 최상위다. 스킬 기본값을 덮는다.",
"- 참조 문서는 각 단계에서 지시하는 것만 그때 연다. 처음부터 전부 읽지 마라.",
"",
`<!-- designpaca v${ctx.skill.version}\`npx designpaca update\` 가 관리한다. 직접 고치면 업데이트가 멈춘다. -->`,
].join("\n");
// 본문은 별도 디렉터리에 통째로 푼다. 경로 재작성이 필요하다.
const skillActions = skillDirActions(refRoot, {
...ctx.skill,
skillMd: rewriteRefPaths(ctx.skill.skillMd, refPrefix),
});
const actions: FileAction[] = [
{ kind: "inject", path: doc, marker: MARKER, content: block },
...skillActions,
];
return {
target: this.id,
scope: ctx.scope,
root: refRoot,
actions,
alreadyInstalled: await exists(path.join(refRoot, "SKILL.md")),
};
},
};

View file

@ -0,0 +1,34 @@
import path from "node:path";
import { exists } from "../fsx.ts";
import type { TargetAdapter, TargetContext } from "../types.ts";
import { anyExists, scopeRoot, skillDirActions } from "./common.ts";
/**
* Claude Code ~/.claude/skills/designpaca/ () .claude/skills/designpaca/ ().
* SKILL.md .
*/
export const claudeCode: TargetAdapter = {
id: "claude-code",
label: "Claude Code",
hint: "~/.claude/skills/designpaca — SKILL.md 그대로",
scopes: ["user", "project"],
async detect(ctx: TargetContext) {
return anyExists([
path.join(ctx.home, ".claude"),
path.join(ctx.cwd, ".claude"),
path.join(ctx.home, ".claude.json"),
]);
},
async plan(ctx: TargetContext) {
const root = scopeRoot(ctx, [".claude", "skills", "designpaca"], [".claude", "skills", "designpaca"]);
return {
target: this.id,
scope: ctx.scope,
root,
actions: skillDirActions(root, ctx.skill),
alreadyInstalled: await exists(path.join(root, "SKILL.md")),
};
},
};

View file

@ -0,0 +1,34 @@
import path from "node:path";
import { exists } from "../fsx.ts";
import type { TargetAdapter, TargetContext } from "../types.ts";
import { anyExists, scopeRoot, skillDirActions } from "./common.ts";
/**
* Codex CLI ~/.codex/skills/designpaca/.
* Codex Claude Code SKILL.md (: ~/.codex/skills/frontend-design ).
*/
export const codex: TargetAdapter = {
id: "codex",
label: "Codex CLI",
hint: "~/.codex/skills/designpaca — SKILL.md 그대로",
scopes: ["user", "project"],
async detect(ctx: TargetContext) {
return anyExists([
path.join(ctx.home, ".codex"),
path.join(ctx.home, ".codex", "AGENTS.md"),
path.join(ctx.cwd, ".codex"),
]);
},
async plan(ctx: TargetContext) {
const root = scopeRoot(ctx, [".codex", "skills", "designpaca"], [".codex", "skills", "designpaca"]);
return {
target: this.id,
scope: ctx.scope,
root,
actions: skillDirActions(root, ctx.skill),
alreadyInstalled: await exists(path.join(root, "SKILL.md")),
};
},
};

View file

@ -0,0 +1,60 @@
import path from "node:path";
import { exists } from "../fsx.ts";
import type { FileAction, SkillSource, TargetContext } from "../types.ts";
/** 매니페스트·마커에 쓰는 고정 식별자 */
export const MARKER = "designpaca";
/** 스킬 디렉터리를 통째로 쓰는 타깃(Claude Code·Codex)이 공유하는 파일 목록 */
export function skillDirActions(root: string, skill: SkillSource): FileAction[] {
const actions: FileAction[] = [
{ kind: "write", path: path.join(root, "SKILL.md"), content: skill.skillMd },
];
for (const [rel, content] of skill.files) {
actions.push({ kind: "write", path: path.join(root, ...rel.split("/")), content });
}
// 업그레이드 판정에 쓰는 버전 스탬프
actions.push({
kind: "write",
path: path.join(root, ".designpaca_version"),
content: `${skill.version}\n`,
});
return actions;
}
/** references 를 별도 디렉터리로 내보내는 타깃(Cursor·AGENTS.md)용 */
export function referenceActions(root: string, skill: SkillSource): FileAction[] {
const actions: FileAction[] = [];
for (const [rel, content] of skill.files) {
actions.push({ kind: "write", path: path.join(root, ...rel.split("/")), content });
}
actions.push({
kind: "write",
path: path.join(root, ".designpaca_version"),
content: `${skill.version}\n`,
});
return actions;
}
/**
* `references/...` .
*
* Claude Code·Codex .
* Cursor·Windsurf·AGENTS.md references
* , .
*/
export function rewriteRefPaths(body: string, prefix: string): string {
return body.replaceAll("references/", `${prefix}/references/`);
}
/** 어느 한 경로라도 있으면 그 도구가 설치돼 있다고 본다 */
export async function anyExists(paths: string[]): Promise<boolean> {
for (const p of paths) if (await exists(p)) return true;
return false;
}
export function scopeRoot(ctx: TargetContext, userRel: string[], projectRel: string[]): string {
return ctx.scope === "user"
? path.join(ctx.home, ...userRel)
: path.join(ctx.cwd, ...projectRel);
}

View file

@ -0,0 +1,57 @@
import path from "node:path";
import { exists } from "../fsx.ts";
import type { FileAction, TargetAdapter, TargetContext } from "../types.ts";
import { anyExists, referenceActions, rewriteRefPaths } from "./common.ts";
/** 본문과 references 가 함께 놓이는 루트 (본문 경로 재작성 기준) */
const REF_PREFIX = ".cursor/rules/designpaca";
/**
* Cursor .cursor/rules/designpaca.mdc.
* .mdc SKILL.md (description/globs/alwaysApply) .
*
* Windsurf (.windsurf/rules/*.md ) .
*/
export const cursor: TargetAdapter = {
id: "cursor",
label: "Cursor",
hint: ".cursor/rules/designpaca.mdc — 프로젝트 단위",
// Cursor 의 전역 규칙은 파일이 아니라 앱 설정(User Rules)이라 프로젝트 범위만 지원한다
scopes: ["project"],
async detect(ctx: TargetContext) {
return anyExists([path.join(ctx.cwd, ".cursor"), path.join(ctx.home, ".cursor")]);
},
async plan(ctx: TargetContext) {
const rulesDir = path.join(ctx.cwd, ".cursor", "rules");
const mdc = path.join(rulesDir, "designpaca.mdc");
const refRoot = path.join(rulesDir, "designpaca");
const header = [
"---",
`description: ${ctx.skill.description}`,
"globs:",
"alwaysApply: false",
"---",
"",
].join("\n");
const actions: FileAction[] = [
{
kind: "write",
path: mdc,
content: header + rewriteRefPaths(ctx.skill.body, REF_PREFIX),
},
...referenceActions(refRoot, ctx.skill),
];
return {
target: this.id,
scope: ctx.scope,
root: rulesDir,
actions,
alreadyInstalled: await exists(mdc),
};
},
};

View file

@ -0,0 +1,17 @@
import type { TargetAdapter, TargetId } from "../types.ts";
import { claudeCode } from "./claude-code.ts";
import { codex } from "./codex.ts";
import { cursor } from "./cursor.ts";
import { windsurf } from "./windsurf.ts";
import { agentsMd } from "./agents-md.ts";
export const ADAPTERS: TargetAdapter[] = [claudeCode, codex, cursor, windsurf, agentsMd];
export function getAdapter(id: TargetId): TargetAdapter {
const a = ADAPTERS.find((x) => x.id === id);
if (!a) throw new Error(`알 수 없는 설치 대상: ${id}`);
return a;
}
export { MARKER, rewriteRefPaths } from "./common.ts";
export { claudeCode, codex, cursor, windsurf, agentsMd };

View file

@ -0,0 +1,66 @@
import path from "node:path";
import { exists } from "../fsx.ts";
import type { FileAction, TargetAdapter, TargetContext } from "../types.ts";
import { anyExists, referenceActions, rewriteRefPaths } from "./common.ts";
const REF_PREFIX = ".windsurf/rules/designpaca";
/** Windsurf 규칙 파일의 하드 상한. 넘으면 잘려서 조용히 망가진다. */
const WINDSURF_CHAR_LIMIT = 12_000;
/**
* Windsurf .windsurf/rules/designpaca.md.
* Cursor · (trigger/globs, .mdc ).
*
* trigger model_decision . always_on
* .
*/
export const windsurf: TargetAdapter = {
id: "windsurf",
label: "Windsurf",
hint: ".windsurf/rules/designpaca.md — 프로젝트 단위",
scopes: ["project"],
async detect(ctx: TargetContext) {
return anyExists([
path.join(ctx.cwd, ".windsurf"),
path.join(ctx.cwd, ".windsurfrules"),
path.join(ctx.home, ".windsurf"),
]);
},
async plan(ctx: TargetContext) {
const rulesDir = path.join(ctx.cwd, ".windsurf", "rules");
const rule = path.join(rulesDir, "designpaca.md");
const refRoot = path.join(rulesDir, "designpaca");
const header = [
"---",
"trigger: model_decision",
`description: ${ctx.skill.description}`,
"---",
"",
].join("\n");
const body = header + rewriteRefPaths(ctx.skill.body, REF_PREFIX);
const actions: FileAction[] = [
{ kind: "write", path: rule, content: body },
...referenceActions(refRoot, ctx.skill),
];
return {
target: this.id,
scope: ctx.scope,
root: rulesDir,
actions,
alreadyInstalled: await exists(rule),
// 상한을 넘으면 설치는 되지만 Windsurf 가 뒷부분을 버린다. 조용히 깨지느니 막는다.
...(body.length > WINDSURF_CHAR_LIMIT
? {
blocked: `규칙 본문이 ${body.length}자로 Windsurf 상한(${WINDSURF_CHAR_LIMIT}자)을 넘는다. SKILL.md 를 줄여야 한다.`,
}
: {}),
};
},
};

View file

@ -0,0 +1,96 @@
/** 설치 대상 식별자 */
export type TargetId = "claude-code" | "codex" | "cursor" | "windsurf" | "agents-md";
/** 설치 범위 — user: 홈 디렉터리 전역, project: 현재 프로젝트 */
export type Scope = "user" | "project";
/** 한 파일에 대한 설치 동작 */
export type FileAction =
| { kind: "write"; path: string; content: string }
/** 마커 블록으로 감싼 영역만 교체(사용자 문서 보존) */
| { kind: "inject"; path: string; marker: string; content: string };
/** 설치 전 사용자에게 보여줄 계획 */
export interface InstallPlan {
target: TargetId;
scope: Scope;
/** 이 타깃이 파일을 쓰는 루트 (표시용) */
root: string;
actions: FileAction[];
/** 이미 설치돼 있는가 */
alreadyInstalled: boolean;
/** 설치를 막는 사유. 있으면 apply 하지 않는다 */
blocked?: string;
}
/** 매니페스트에 기록되는 개별 파일 */
export interface InstalledFile {
path: string;
/** 설치 시점 내용의 sha256 — 사용자 수정(드리프트) 감지에 쓴다 */
sha256: string;
/** 마커 주입 방식으로 설치된 파일인가 */
marker?: string;
}
export interface InstallRecord {
target: TargetId;
scope: Scope;
root: string;
version: string;
installedAt: string;
files: InstalledFile[];
}
export interface Manifest {
/** 매니페스트 스키마 버전 — 향후 마이그레이션 판단용 */
schema: 1;
installs: InstallRecord[];
}
/** doctor/update 가 쓰는 드리프트 판정 */
export type DriftStatus = "ok" | "modified" | "missing";
export interface DriftReport {
record: InstallRecord;
files: { path: string; status: DriftStatus }[];
/** 사용자가 수정한 파일 경로 */
modified: string[];
}
/** 타깃 어댑터가 구현해야 하는 인터페이스 */
export interface TargetAdapter {
id: TargetId;
/** TUI 에 표시할 이름 */
label: string;
/** 한 줄 설명 */
hint: string;
/** 이 타깃이 지원하는 범위 */
scopes: Scope[];
/** 시스템에 이 도구가 설치돼 있는 흔적이 있는가 (기본 선택 여부 판단) */
detect(ctx: TargetContext): Promise<boolean>;
/** 설치 계획 수립 — 파일을 쓰지 않는다 */
plan(ctx: TargetContext): Promise<InstallPlan>;
}
export interface TargetContext {
scope: Scope;
/** 홈 디렉터리 */
home: string;
/** 현재 작업 디렉터리(프로젝트 범위일 때 기준) */
cwd: string;
/** 번들된 스킬 소스 */
skill: SkillSource;
}
/** 번들에 포함된 스킬 원본 */
export interface SkillSource {
version: string;
/** SKILL.md 본문 (프론트매터 포함) */
skillMd: string;
/** references/ 이하 상대경로 → 내용 */
files: Map<string, string>;
/** 프론트매터를 제외한 본문 — 프론트매터를 쓰지 않는 포맷용 */
body: string;
/** 프론트매터에서 뽑은 description */
description: string;
}