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

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 };
}