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

116
packages/cli/README.md Normal file
View file

@ -0,0 +1,116 @@
# designpaca
**웹 디자인 파이프라인 스킬을 Claude Code · Codex · Cursor 에 한 줄로 설치한다.**
```bash
npx designpaca
```
---
## 무엇인가
designpaca 는 AI 코딩 에이전트에게 **웹 디자인을 순서대로 하게 만드는 스킬**이다.
"예쁘게 만들어줘"라고 하면 에이전트는 자기가 아는 기본값을 쏟아낸다. 보라-파랑 그라디언트, Inter, 3열 아이콘 카드, `rounded-2xl shadow-lg`. 못 만들어서가 아니라 **결정을 안 해서** 그렇게 된다. designpaca 는 그 결정을 강제한다.
### 7단계 파이프라인
| 단계 | 하는 일 | 통과 조건 |
|---|---|---|
| 0 | 브리프 게이트 | 무엇을·누구에게·제약 3줄 |
| 1 | **레퍼런스 조사** | 실제 사이트 3개를 서로 다른 층위에서 |
| 2 | 방향 결정 | 한 문장 컨셉 + 프리셋 + 감수할 리스크 하나 |
| 3 | 디자인 토큰 | 타입·색·간격·모션을 실제 값으로 + 성능 예산 |
| 4 | 구현 | 레이아웃 → 재질(SVG 필터) → 입체(three.js) → 모션 |
| 5 | 프리플라이트 | 하드 게이트 12개 + 슬롭 지문 grep |
| 6 | 결정 기록 | `design.md` 로 다음 실행에 인계 |
1단계를 건너뛴 디자인은 5단계에서 실패 처리된다. **머릿속 기본값이 아니라 실제로 존재하는 사이트에서 시작하게 만드는 것**이 이 스킬의 핵심이다.
### 무엇이 들어 있나
- **레퍼런스 갤러리 라우팅** — 브리프별로 어디를 볼지. Awwwards 수상작이 럭셔리·자동차에 몰려 있어 SaaS 브리프에 이식하면 안 된다는 것까지
- **AI 슬롭 지문 목록** — 실측 검출률과 함께. 보라 CTA 10.7%, 전체 대문자 헤드라인 10.5%, 번호 매긴 1·2·3 단계 9.4%. grep 으로 기계 검출 가능
- **SVG 필터를 재질로** — 그레인·굴절·잉크 번짐·수차. 장식이 아니라 "이 디자인의 표면은 무엇인가"를 정하는 도구로
- **three.js를 예산 안에서** — 지연 로드, DPR 클램프, 품질 티어, 폴백. 캔버스를 LCP 요소로 만들지 않는 규칙
- **인터랙티브 모션** — 이징의 의미론, 마이크로 인터랙션, `prefers-reduced-motion` 의 올바른 대응
- **한글 조판** — `word-break: keep-all`, line-height 1.6~1.8, 음수 자간 금지, 한글 폰트 페어링. 서구 레퍼런스에 없는 정보
- **HTML-in-Canvas** — Chrome 실험 API. 폴백을 완성한 뒤에만 얹도록 격리
## 설치
```bash
npx designpaca # 대화형 온보딩 (권장)
```
설치 대상을 자동 감지해서 기본 선택해준다.
### 비대화형
```bash
npx designpaca install -t claude-code,codex -s user -y
npx designpaca install -t cursor -s project
```
| 대상 | 설치 위치 |
|---|---|
| `claude-code` | `~/.claude/skills/designpaca/` (또는 프로젝트 `.claude/`) |
| `codex` | `~/.codex/skills/designpaca/` |
| `cursor` | `.cursor/rules/designpaca.mdc` (프로젝트 단위) |
| `agents-md` | `AGENTS.md` 에 블록 주입 |
## 사용
설치 후 에이전트에게 그냥 말하면 된다.
```
랜딩 페이지 만들어줘 — 수제 커피 로스터리, 원두 정기구독이 목적
```
Claude Code 에서는 `/designpaca` 로 명시적으로 부를 수도 있다.
## 명령어
```bash
npx designpaca update # 최신 스킬로 갱신
npx designpaca doctor # 설치 상태 진단
npx designpaca list # 설치 가능 대상과 현재 설치 목록
npx designpaca uninstall # 제거
```
### 업데이트가 내 수정을 덮지 않는다
스킬 파일을 직접 고쳤다면 `update` 는 **그 파일을 건드리지 않고 건너뛴다.** 설치 시점의 해시를 기록해두고 비교하기 때문이다.
강제로 최신 내용에 맞추려면 `--force` 를 쓴다. 이때도 원본은 `.orig` 로 남는다.
```bash
npx designpaca update --force
```
`AGENTS.md` 처럼 사용자 문서에 주입하는 경우, 마커 블록(`<!-- designpaca:start -->`) **안쪽만** 교체한다. 문서의 나머지는 손대지 않는다. 제거할 때도 블록만 빼간다.
## 옵션
| 옵션 | 설명 |
|---|---|
| `-t, --target <id[,id]>` | `claude-code` `codex` `cursor` `agents-md` |
| `-s, --scope <범위>` | `user` (전역) \| `project` |
| `-y, --yes` | 확인 없이 진행 |
| `-f, --force` | 직접 수정한 파일도 덮어쓴다 (`.orig` 백업) |
| `--dry-run` | 쓰지 않고 계획만 출력 |
| `--no-update-check` | 새 버전 확인 생략 |
## 요구사항
Node 20.11 이상.
## 링크
- 소개 페이지 — https://designpaca.chanpaca.net
- 저장소 — https://git.chanpaca.net/yunchan/designpaca
## 라이선스
MIT

32
packages/cli/package.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "designpaca",
"version": "0.1.0",
"description": "웹 디자인 파이프라인 스킬 — Claude Code · Codex · Cursor 에 한 줄로 설치한다",
"keywords": ["design", "web-design", "claude-code", "codex", "cursor", "agent-skill", "svg-filter", "threejs"],
"license": "MIT",
"author": "Yun Chan",
"homepage": "https://designpaca.chanpaca.net",
"repository": { "type": "git", "url": "git+https://git.chanpaca.net/yunchan/designpaca.git" },
"type": "module",
"bin": { "designpaca": "./dist/index.js" },
"files": ["dist", "README.md"],
"engines": { "node": ">=20.11" },
"scripts": {
"build": "node scripts/bundle-skill.mjs && tsup",
"dev": "node scripts/bundle-skill.mjs && tsup --watch",
"typecheck": "tsc --noEmit",
"test": "node --test --experimental-strip-types --disable-warning=ExperimentalWarning test/*.test.ts",
"prepublishOnly": "node scripts/verify-package.mjs"
},
"dependencies": {
"@clack/prompts": "^0.9.1",
"picocolors": "^1.1.1"
},
"devDependencies": {
"@designpaca/core": "workspace:*",
"@designpaca/skill": "workspace:*",
"@types/node": "^22.10.2",
"tsup": "^8.3.5",
"typescript": "^5.7.2"
}
}

View file

@ -0,0 +1,30 @@
// packages/skill 의 내용을 CLI 배포물 안(dist/skill)으로 복사한다.
// 스킬 본문이 npm 패키지에 함께 실려야 npx 한 번으로 설치가 끝난다.
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const here = path.dirname(fileURLToPath(import.meta.url));
const src = path.resolve(here, "../../skill");
const dest = path.resolve(here, "../dist/skill");
async function copyDir(from, to) {
await fs.mkdir(to, { recursive: true });
for (const e of await fs.readdir(from, { withFileTypes: true })) {
if (e.name === "node_modules" || e.name === "package.json") continue;
const s = path.join(from, e.name);
const d = path.join(to, e.name);
if (e.isDirectory()) await copyDir(s, d);
else await fs.copyFile(s, d);
}
}
await fs.rm(dest, { recursive: true, force: true });
await copyDir(src, dest);
// 스킬 버전은 CLI 버전과 항상 같다(changesets fixed 그룹) — 여기서 스탬프를 찍어 런타임 조회를 없앤다
const pkg = JSON.parse(await fs.readFile(path.resolve(here, "../package.json"), "utf8"));
await fs.writeFile(path.join(dest, ".designpaca_version"), `${pkg.version}\n`, "utf8");
const count = (await fs.readdir(dest, { recursive: true })).length;
console.log(`스킬 번들 완료: ${count}개 항목 → dist/skill`);

View file

@ -0,0 +1,29 @@
// publish 직전 최종 점검 — 스킬이 빠진 채로 배포되는 사고를 막는다.
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const must = ["dist/index.js", "dist/skill/SKILL.md", "dist/skill/.designpaca_version"];
const missing = [];
for (const rel of must) {
try {
await fs.access(path.join(root, rel));
} catch {
missing.push(rel);
}
}
if (missing.length) {
console.error(`배포물에 빠진 파일:\n${missing.map((m) => ` - ${m}`).join("\n")}`);
console.error("pnpm build 를 먼저 실행해라.");
process.exit(1);
}
const pkg = JSON.parse(await fs.readFile(path.join(root, "package.json"), "utf8"));
const stamp = (await fs.readFile(path.join(root, "dist/skill/.designpaca_version"), "utf8")).trim();
if (stamp !== pkg.version) {
console.error(`버전 불일치: package.json=${pkg.version} / 스킬 스탬프=${stamp}`);
process.exit(1);
}
console.log(`배포 점검 통과 (v${pkg.version})`);

View file

@ -0,0 +1,55 @@
import { inspectDrift, pruneDeadInstalls, readManifest, tildify } from "@designpaca/core";
import { getVersion } from "../skill.ts";
import { bad, dim, heading, info, ok, table, warn } from "../ui.ts";
export async function runDoctor(): Promise<number> {
const version = await getVersion();
const pruned = await pruneDeadInstalls();
const manifest = await readManifest();
console.log(heading("designpaca 진단"));
console.log(
table([
["CLI 버전", version],
["Node", process.version],
["설치 기록", `${manifest.installs.length}건`],
]),
);
if (pruned > 0) console.log(info(`사라진 설치 기록 ${pruned}건을 정리했다`));
if (manifest.installs.length === 0) {
console.log(warn("설치된 곳이 없다. `npx designpaca install` 로 설치해라."));
return 0;
}
let problems = 0;
for (const rec of manifest.installs) {
const drift = await inspectDrift(rec);
const missing = drift.files.filter((f) => f.status === "missing");
const modified = drift.files.filter((f) => f.status === "modified");
console.log(heading(`${rec.target} (${rec.scope})`));
console.log(
table([
["위치", tildify(rec.root)],
["버전", rec.version === version ? rec.version : `${rec.version} → ${version} 업데이트 가능`],
["파일", `${rec.files.length}개`],
]),
);
if (rec.version !== version) problems++;
if (missing.length > 0) {
problems++;
console.log(bad(`파일 ${missing.length}개가 사라졌다 — \`designpaca update --force\` 로 복구해라`));
for (const m of missing.slice(0, 5)) console.log(dim(` ${tildify(m.path)}`));
}
if (modified.length > 0) {
console.log(warn(`직접 수정한 파일 ${modified.length}개 — 업데이트가 이 파일들을 건너뛴다`));
for (const m of modified.slice(0, 5)) console.log(dim(` ${tildify(m.path)}`));
}
if (missing.length === 0 && modified.length === 0 && rec.version === version) {
console.log(ok("정상"));
}
}
return problems > 0 ? 1 : 0;
}

View file

@ -0,0 +1,90 @@
import {
applyPlan,
planInstall,
tildify,
type Scope,
type TargetId,
} from "@designpaca/core";
import { getSkill } from "../skill.ts";
import { bad, dim, fold, heading, info, ok, warn } from "../ui.ts";
export interface InstallOptions {
targets: TargetId[];
scope: Scope;
force?: boolean;
/** 실제로 쓰지 않고 계획만 출력 */
dryRun?: boolean;
}
export interface InstallOutcome {
target: TargetId;
root: string;
written: number;
skipped: string[];
blocked?: string;
}
/** install/update/TUI 가 공유하는 실제 설치 실행부 */
export async function runInstall(opts: InstallOptions): Promise<InstallOutcome[]> {
const skill = await getSkill();
const outcomes: InstallOutcome[] = [];
for (const target of opts.targets) {
const plan = await planInstall(target, opts.scope, skill);
if (plan.blocked) {
outcomes.push({ target, root: "", written: 0, skipped: [], blocked: plan.blocked });
continue;
}
if (opts.dryRun) {
outcomes.push({ target, root: plan.root, written: plan.actions.length, skipped: [] });
continue;
}
const res = await applyPlan(plan, skill.version, { force: opts.force });
outcomes.push({
target,
root: plan.root,
written: res.written.length,
skipped: res.skipped,
});
}
return outcomes;
}
export function printOutcomes(outcomes: InstallOutcome[], dryRun = false): void {
console.log(heading(dryRun ? "설치 계획" : "설치 결과"));
for (const o of outcomes) {
if (o.blocked) {
console.log(bad(`${o.target}: ${o.blocked}`));
continue;
}
const verb = dryRun ? "쓸 파일" : "설치됨";
console.log(ok(`${o.target} — ${verb} ${o.written}개 ${dim(tildify(o.root))}`));
if (o.skipped.length > 0) {
console.log(warn(` 직접 수정한 파일이라 건드리지 않았다 (--force 로 덮어쓴다):`));
for (const s of fold(o.skipped)) console.log(dim(` ${tildify(s)}`));
}
}
}
/** 설치 후 안내 — 여기서 끝내지 말고 다음 행동을 알려준다 */
export function printNextSteps(targets: TargetId[]): void {
console.log(heading("다음 단계"));
if (targets.includes("claude-code")) {
console.log(info(`Claude Code 를 새로 열고 ${dim("/designpaca")} 를 실행해라`));
}
if (targets.includes("codex")) {
console.log(info(`Codex CLI 에서 designpaca 스킬이 자동으로 잡힌다`));
}
if (targets.includes("cursor")) {
console.log(info(`Cursor 는 ${dim(".cursor/rules/designpaca.mdc")} 를 프로젝트 열 때 읽는다`));
}
if (targets.includes("windsurf")) {
console.log(info(`Windsurf 는 ${dim(".windsurf/rules/designpaca.md")} 를 읽는다`));
}
if (targets.includes("agents-md")) {
console.log(
info(`AGENTS.md 에는 포인터만 넣었다 — 본문은 ${dim(".designpaca/SKILL.md")} 에 있다`),
);
}
console.log(info(`문서: ${dim("https://designpaca.chanpaca.net")}`));
}

View file

@ -0,0 +1,30 @@
import { ADAPTERS, readManifest, tildify } from "@designpaca/core";
import { getVersion } from "../skill.ts";
import { dim, heading, info, ok, padDisplay, table } from "../ui.ts";
export async function runList(): Promise<number> {
const version = await getVersion();
const manifest = await readManifest();
console.log(heading("설치 가능한 대상"));
for (const a of ADAPTERS) {
console.log(` ${padDisplay(a.label, 18)} ${dim(a.hint)} ${dim(`[${a.scopes.join("|")}]`)}`);
}
console.log(heading(`현재 설치 (CLI v${version})`));
if (manifest.installs.length === 0) {
console.log(info("없음"));
return 0;
}
for (const rec of manifest.installs) {
console.log(ok(`${rec.target} (${rec.scope})`));
console.log(
table([
["위치", tildify(rec.root)],
["버전", rec.version],
["설치 시각", new Date(rec.installedAt).toLocaleString("ko-KR")],
]),
);
}
return 0;
}

View file

@ -0,0 +1,30 @@
import { readManifest, removeInstall, tildify, type TargetId } from "@designpaca/core";
import { dim, heading, ok, warn } from "../ui.ts";
export async function runUninstall(opts: {
targets?: TargetId[];
force?: boolean;
}): Promise<number> {
const manifest = await readManifest();
const targets = manifest.installs.filter(
(i) => !opts.targets || opts.targets.length === 0 || opts.targets.includes(i.target),
);
if (targets.length === 0) {
console.log(warn("제거할 설치 기록이 없다"));
return 0;
}
console.log(heading("제거"));
for (const rec of targets) {
const res = await removeInstall(rec, { force: opts.force });
console.log(ok(`${rec.target} (${rec.scope}) — ${res.removed.length}개 제거 ${dim(tildify(rec.root))}`));
if (res.keptModified.length > 0) {
console.log(
warn(` 직접 수정한 파일 ${res.keptModified.length}개는 남겼다 (--force 로 함께 지운다)`),
);
for (const p of res.keptModified.slice(0, 5)) console.log(dim(` ${tildify(p)}`));
}
}
return 0;
}

View file

@ -0,0 +1,55 @@
import { inspectDrift, readManifest, tildify } from "@designpaca/core";
import { getVersion } from "../skill.ts";
import { runInstall, printOutcomes } from "./install.ts";
import { dim, heading, info, ok, warn } from "../ui.ts";
/**
* 설치 기록을 그대로 따라가며 재설치한다.
* 사용자가 고친 파일은 기본적으로 보존된다(applyPlan 이 판단) — 강제로 맞추려면 --force.
*/
export async function runUpdate(opts: { force?: boolean } = {}): Promise<number> {
const version = await getVersion();
const manifest = await readManifest();
if (manifest.installs.length === 0) {
console.log(warn("설치 기록이 없다. `npx designpaca install` 을 먼저 실행해라."));
return 1;
}
console.log(heading(`업데이트 → v${version}`));
const stale = manifest.installs.filter((i) => i.version !== version);
if (stale.length === 0) {
console.log(ok("모든 설치가 이미 최신이다"));
// 버전이 같아도 파일이 사라졌거나 수정됐을 수 있다. 조용히 넘기면 사용자가 모른다.
let broken = 0;
for (const rec of manifest.installs) {
const drift = await inspectDrift(rec);
const missing = drift.files.filter((f) => f.status === "missing");
if (missing.length > 0) {
broken += missing.length;
console.log(warn(`${rec.target}: 파일 ${missing.length}개가 사라졌다 ${dim(tildify(rec.root))}`));
}
if (drift.modified.length > 0) {
console.log(info(`${rec.target}: 직접 수정한 파일 ${drift.modified.length}개는 그대로 둔다`));
}
}
if (!opts.force) {
console.log(dim(broken > 0 ? " 사라진 파일을 복구하려면 --force" : " 강제로 다시 쓰려면 --force"));
return broken > 0 ? 1 : 0;
}
}
for (const rec of manifest.installs) {
console.log(info(`${rec.target} (${rec.scope}) ${dim(tildify(rec.root))} — ${rec.version} → ${version}`));
const outcomes = await runInstall({
targets: [rec.target],
scope: rec.scope,
force: opts.force,
});
printOutcomes(outcomes);
}
return 0;
}

177
packages/cli/src/index.ts Normal file
View file

@ -0,0 +1,177 @@
import { ADAPTERS, type Scope, type TargetId } from "@designpaca/core";
import { getVersion } from "./skill.ts";
import { checkForUpdate } from "./update-check.ts";
import { printNextSteps, printOutcomes, runInstall } from "./commands/install.ts";
import { runDoctor } from "./commands/doctor.ts";
import { runUpdate } from "./commands/update.ts";
import { runUninstall } from "./commands/uninstall.ts";
import { runList } from "./commands/list.ts";
import { onboard } from "./tui/onboard.ts";
import { accent, bad, bold, dim, warn } from "./ui.ts";
const VALID_TARGETS = ADAPTERS.map((a) => a.id) as string[];
interface Args {
command: string;
targets: TargetId[];
scope: Scope;
yes: boolean;
force: boolean;
dryRun: boolean;
help: boolean;
version: boolean;
}
function parse(argv: string[]): Args {
const out: Args = {
command: "",
targets: [],
scope: "user",
yes: false,
force: false,
dryRun: false,
help: false,
version: false,
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i] as string;
if (a === "--help" || a === "-h") out.help = true;
else if (a === "--version" || a === "-v") out.version = true;
else if (a === "--yes" || a === "-y") out.yes = true;
else if (a === "--force" || a === "-f") out.force = true;
else if (a === "--dry-run") out.dryRun = true;
else if (a === "--no-update-check") process.env["DESIGNPACA_NO_UPDATE_CHECK"] = "1";
else if (a === "--target" || a === "-t") {
const v = argv[++i];
if (v) out.targets.push(...(v.split(",") as TargetId[]));
} else if (a.startsWith("--target=")) {
out.targets.push(...(a.slice(9).split(",") as TargetId[]));
} else if (a === "--scope" || a === "-s") {
const v = argv[++i];
if (v) out.scope = v as Scope;
} else if (a.startsWith("--scope=")) {
out.scope = a.slice(8) as Scope;
} else if (!a.startsWith("-") && !out.command) {
out.command = a;
}
}
return out;
}
function usage(version: string): string {
return `
${accent("designpaca")} ${dim(`v${version}`)} — 웹 디자인 파이프라인 스킬 설치기
${bold("사용법")}
npx designpaca 대화형 온보딩 (권장)
npx designpaca install 설치
npx designpaca update 최신 스킬로 갱신
npx designpaca uninstall 제거
npx designpaca doctor 설치 상태 진단
npx designpaca list 설치 가능 대상과 현재 설치 목록
${bold("옵션")}
-t, --target <id[,id]> 설치 대상: ${VALID_TARGETS.join(", ")}
-s, --scope <범위> user | project ${dim("(기본: user)")}
-y, --yes 확인 없이 진행
-f, --force 직접 수정한 파일도 덮어쓴다 ${dim("(.orig 로 백업)")}
--dry-run 쓰지 않고 계획만 출력
--no-update-check 새 버전 확인을 건너뛴다
-h, --help 이 도움말
-v, --version 버전
${bold("예시")}
${dim("npx designpaca install -t claude-code,codex -s user -y")}
${dim("npx designpaca install -t cursor -s project")}
${dim("npx designpaca update --force")}
`.trimStart();
}
async function main(): Promise<number> {
const args = parse(process.argv.slice(2));
const version = await getVersion();
if (args.version) {
console.log(version);
return 0;
}
if (args.help) {
console.log(usage(version));
return 0;
}
const bad_ = args.targets.filter((t) => !VALID_TARGETS.includes(t));
if (bad_.length > 0) {
console.error(bad(`알 수 없는 대상: ${bad_.join(", ")}`));
console.error(dim(` 가능한 값: ${VALID_TARGETS.join(", ")}`));
return 2;
}
if (args.scope !== "user" && args.scope !== "project") {
console.error(bad(`알 수 없는 범위: ${args.scope} (user | project)`));
return 2;
}
// 대화형으로 쓸 수 있는 환경이면 인자 없이 실행했을 때 온보딩으로 보낸다
const interactive = process.stdout.isTTY && !args.yes;
if (!args.command || args.command === "onboard") {
if (interactive) return onboard();
console.log(usage(version));
return 0;
}
let code = 0;
switch (args.command) {
case "install": {
const targets = args.targets.length > 0 ? args.targets : (["claude-code"] as TargetId[]);
if (interactive && args.targets.length === 0) return onboard();
const outcomes = await runInstall({
targets,
scope: args.scope,
force: args.force,
dryRun: args.dryRun,
});
printOutcomes(outcomes, args.dryRun);
if (!args.dryRun) printNextSteps(targets);
code = outcomes.some((o) => o.blocked) ? 1 : 0;
break;
}
case "update":
code = await runUpdate({ force: args.force });
break;
case "uninstall":
case "remove":
code = await runUninstall({ targets: args.targets, force: args.force });
break;
case "doctor":
code = await runDoctor();
break;
case "list":
case "ls":
code = await runList();
break;
default:
console.error(bad(`알 수 없는 명령: ${args.command}`));
console.log(usage(version));
return 2;
}
// 명령을 마친 뒤에만 알린다 — 시작을 지연시키지 않는다
const latest = await checkForUpdate(version);
if (latest) {
console.log(
"\n" + warn(`새 버전 ${bold(`v${latest}`)} 이 있다 — ${dim("npx designpaca@latest update")}`),
);
}
return code;
}
main()
.then((code) => {
process.exitCode = code;
})
.catch((err: unknown) => {
console.error(bad(err instanceof Error ? err.message : String(err)));
if (process.env["DESIGNPACA_DEBUG"] === "1" && err instanceof Error) console.error(err.stack);
process.exitCode = 1;
});

28
packages/cli/src/skill.ts Normal file
View file

@ -0,0 +1,28 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { loadSkillSource, type SkillSource } from "@designpaca/core";
/** 배포물 안에 함께 실린 스킬 원본 위치 (dist/skill) */
export function bundledSkillRoot(): string {
return fileURLToPath(new URL("./skill", import.meta.url));
}
let cached: SkillSource | null = null;
export async function getSkill(): Promise<SkillSource> {
if (cached) return cached;
const root = bundledSkillRoot();
let version = "0.0.0";
try {
version = (await fs.readFile(path.join(root, ".designpaca_version"), "utf8")).trim();
} catch {
/* 스탬프가 없으면 0.0.0 으로 두고 진행한다 — 설치 자체를 막을 이유는 없다 */
}
cached = await loadSkillSource(root, version);
return cached;
}
export async function getVersion(): Promise<string> {
return (await getSkill()).version;
}

View file

@ -0,0 +1,125 @@
import * as p from "@clack/prompts";
import path from "node:path";
import {
ADAPTERS,
detectTargets,
getAdapter,
planInstall,
tildify,
type Scope,
type TargetId,
} from "@designpaca/core";
import { getSkill } from "../skill.ts";
import { runInstall, printNextSteps, printOutcomes } from "../commands/install.ts";
import { accent, bold, dim } from "../ui.ts";
/** 선택한 범위를 어댑터가 지원하지 않으면 지원 가능한 범위로 낮춘다 */
function effectiveScope(target: TargetId, wanted: Scope): Scope {
const a = getAdapter(target);
return a.scopes.includes(wanted) ? wanted : (a.scopes[0] as Scope);
}
export async function onboard(): Promise<number> {
const skill = await getSkill();
console.clear();
p.intro(`${accent("◆ designpaca")} ${dim(`v${skill.version}`)}`);
p.note(
[
"브리프에서 시작해 레퍼런스 조사 · 방향 결정 · 디자인 토큰 ·",
"구현 · 셀프 감사까지 끌고 가는 웹 디자인 파이프라인 스킬.",
"",
`${dim("SVG 필터 · three.js · 인터랙티브 모션을 기본 재료로 쓴다.")}`,
].join("\n"),
"무엇을 설치하나",
);
// 시스템에 흔적이 있는 도구를 기본 체크해 둔다 — 사용자가 매번 고르게 하지 않는다
const detected = await detectTargets(skill);
const targets = await p.multiselect<TargetId>({
message: "어디에 설치할까?",
options: ADAPTERS.map((a) => ({
value: a.id,
label: a.label + (detected.includes(a.id) ? dim(" (감지됨)") : ""),
hint: a.hint,
})),
initialValues: detected.length > 0 ? detected : ["claude-code"],
required: true,
});
if (p.isCancel(targets)) return cancel();
const wanted = await p.select<Scope>({
message: "설치 범위",
options: [
{ value: "user", label: "전역", hint: "홈 디렉터리 — 모든 프로젝트에서 쓴다" },
{ value: "project", label: "이 프로젝트만", hint: tildify(process.cwd()) },
],
initialValue: "user",
});
if (p.isCancel(wanted)) return cancel();
// 범위가 낮춰진 타깃이 있으면 미리 알린다 — 설치 후에 "왜 여기 깔렸지"가 되지 않게
const downgraded = targets.filter((t) => effectiveScope(t, wanted) !== wanted);
if (downgraded.length > 0) {
p.log.warn(
downgraded
.map((t) => {
const a = getAdapter(t);
return `${a.label} 은(는) ${a.scopes.join("/")} 범위만 지원한다 → ${effectiveScope(t, wanted)} 로 설치한다`;
})
.join("\n"),
);
}
// 무엇이 어디에 쓰이는지 먼저 보여준다
const previews: string[] = [];
for (const t of targets) {
const scope = effectiveScope(t, wanted);
const plan = await planInstall(t, scope, skill);
if (plan.blocked) {
previews.push(`${bold(getAdapter(t).label)}\n ${dim(plan.blocked)}`);
continue;
}
const dirs = new Set(plan.actions.map((a) => path.dirname(a.path)));
previews.push(
[
`${bold(getAdapter(t).label)} ${plan.alreadyInstalled ? dim("(재설치)") : ""}`,
` ${dim(tildify(plan.root))}`,
` ${dim(`파일 ${plan.actions.length}개 · 디렉터리 ${dirs.size}개`)}`,
].join("\n"),
);
}
p.note(previews.join("\n\n"), "설치 계획");
const go = await p.confirm({ message: "이대로 설치할까?", initialValue: true });
if (p.isCancel(go) || !go) return cancel();
const s = p.spinner();
s.start("설치 중");
const outcomes = [];
try {
for (const t of targets) {
const scope = effectiveScope(t, wanted);
s.message(`설치 중 — ${getAdapter(t).label}`);
outcomes.push(...(await runInstall({ targets: [t], scope })));
}
s.stop("설치 완료");
} catch (err) {
s.stop("설치 실패", 1);
p.log.error(err instanceof Error ? err.message : String(err));
return 1;
}
printOutcomes(outcomes);
printNextSteps(targets);
p.outro(`${accent("designpaca")} 준비됨 — 이제 브리프를 던져라`);
return 0;
}
function cancel(): number {
p.cancel("설치를 취소했다. 아무것도 바꾸지 않았다.");
return 130;
}

55
packages/cli/src/ui.ts Normal file
View file

@ -0,0 +1,55 @@
import pc from "picocolors";
/** designpaca 의 강조색 — 한 가지만 쓴다 */
export const accent = (s: string) => pc.magenta(s);
export const dim = (s: string) => pc.dim(s);
export const bold = (s: string) => pc.bold(s);
export const ok = (s: string) => `${pc.green("✓")} ${s}`;
export const warn = (s: string) => `${pc.yellow("!")} ${s}`;
export const bad = (s: string) => `${pc.red("✗")} ${s}`;
export const info = (s: string) => `${pc.cyan("·")} ${s}`;
/**
* 터미널에서 차지하는 칸 수. 한글·한자·가나는 2칸을 먹는다.
* String.length 로 padEnd 하면 한글이 섞인 표가 어긋난다.
*/
export function displayWidth(s: string): number {
// ANSI 이스케이프는 폭을 차지하지 않는다
const plain = s.replace(/\[[0-9;]*m/g, "");
let w = 0;
for (const ch of plain) {
const cp = ch.codePointAt(0) ?? 0;
const wide =
(cp >= 0x1100 && cp <= 0x115f) || // 한글 자모
(cp >= 0x2e80 && cp <= 0xa4cf) || // CJK 부수 ~ 이
(cp >= 0xac00 && cp <= 0xd7a3) || // 한글 음절
(cp >= 0xf900 && cp <= 0xfaff) || // CJK 호환
(cp >= 0xfe30 && cp <= 0xfe6f) ||
(cp >= 0xff00 && cp <= 0xff60) || // 전각
(cp >= 0xffe0 && cp <= 0xffe6);
w += wide ? 2 : 1;
}
return w;
}
/** displayWidth 기준으로 오른쪽을 채운다 */
export function padDisplay(s: string, width: number): string {
const pad = width - displayWidth(s);
return pad > 0 ? s + " ".repeat(pad) : s;
}
export function heading(s: string): string {
return `\n${bold(s)}\n${dim("─".repeat(Math.min(s.length + 8, 56)))}`;
}
/** 긴 경로 목록을 접어서 보여준다 — 설치 계획이 화면을 삼키지 않게 */
export function fold(items: string[], max = 6): string[] {
if (items.length <= max) return items;
return [...items.slice(0, max), dim(`… 그 외 ${items.length - max}개`)];
}
export function table(rows: [string, string][]): string {
const w = Math.max(...rows.map(([k]) => k.length));
return rows.map(([k, v]) => ` ${k.padEnd(w)} ${dim(v)}`).join("\n");
}

View file

@ -0,0 +1,56 @@
import { readIfExists, updateCachePath, writeAtomic } from "@designpaca/core";
const DAY = 24 * 60 * 60 * 1000;
const REGISTRY = "https://registry.npmjs.org/designpaca/latest";
interface Cache {
checkedAt: number;
latest: string;
}
function isNewer(latest: string, current: string): boolean {
const norm = (v: string) => v.split("-")[0]!.split(".").map((n) => Number.parseInt(n, 10) || 0);
const [a, b] = [norm(latest), norm(current)];
for (let i = 0; i < 3; i++) {
const l = a[i] ?? 0;
const c = b[i] ?? 0;
if (l !== c) return l > c;
}
return false;
}
/**
* 하루 한 번만 레지스트리를 본다. 네트워크가 없거나 느리면 조용히 포기한다 —
* 업데이트 확인 때문에 CLI 가 멈추는 일은 없어야 한다.
*/
export async function checkForUpdate(current: string): Promise<string | null> {
if (process.env["DESIGNPACA_NO_UPDATE_CHECK"] === "1") return null;
const cachePath = updateCachePath();
const raw = await readIfExists(cachePath);
if (raw) {
try {
const c = JSON.parse(raw) as Cache;
if (Date.now() - c.checkedAt < DAY) {
return isNewer(c.latest, current) ? c.latest : null;
}
} catch {
/* 캐시가 깨졌으면 새로 받는다 */
}
}
try {
const res = await fetch(REGISTRY, {
headers: { accept: "application/vnd.npm.install-v1+json" },
signal: AbortSignal.timeout(2500),
});
if (!res.ok) return null;
const body = (await res.json()) as { version?: string };
const latest = body.version;
if (!latest) return null;
await writeAtomic(cachePath, JSON.stringify({ checkedAt: Date.now(), latest } satisfies Cache));
return isNewer(latest, current) ? latest : null;
} catch {
return null;
}
}

View file

@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { after, test } from "node:test";
const CLI = fileURLToPath(new URL("../dist/index.js", import.meta.url));
const built = await fs
.access(CLI)
.then(() => true)
.catch(() => false);
const home = await fs.mkdtemp(path.join(os.tmpdir(), "designpaca-cli-"));
after(async () => {
await fs.rm(home, { recursive: true, force: true });
});
/** 빌드 산출물을 사용자가 실행하는 방식 그대로 돌린다 */
function run(args: string[]): string {
return execFileSync(process.execPath, [CLI, ...args, "--no-update-check"], {
encoding: "utf8",
env: { ...process.env, HOME: home, USERPROFILE: home, FORCE_COLOR: "0" },
});
}
test("빌드 산출물이 있어야 한다", { skip: built ? false : "pnpm build 먼저 실행해라" }, () => {
assert.ok(built);
});
test("--version 은 스킬 버전과 같다", { skip: !built }, async () => {
const stamp = (
await fs.readFile(fileURLToPath(new URL("../dist/skill/.designpaca_version", import.meta.url)), "utf8")
).trim();
assert.equal(run(["--version"]).trim(), stamp);
});
test("도움말에 모든 명령이 나온다", { skip: !built }, () => {
const out = run(["--help"]);
for (const cmd of ["install", "update", "uninstall", "doctor", "list"]) {
assert.ok(out.includes(cmd), `도움말에 ${cmd} 가 없다`);
}
});
test("dry-run 은 아무 파일도 쓰지 않는다", { skip: !built }, async () => {
const before = await fs.readdir(home);
const out = run(["install", "-t", "claude-code", "-s", "user", "-y", "--dry-run"]);
assert.ok(out.includes("설치 계획"));
assert.deepEqual(await fs.readdir(home), before);
});
test("알 수 없는 대상은 종료 코드 2 로 거부한다", { skip: !built }, () => {
assert.throws(
() => run(["install", "-t", "emacs", "-y"]),
(err: { status?: number }) => err.status === 2,
);
});
test("설치 → doctor → 제거 왕복", { skip: !built }, async () => {
run(["install", "-t", "claude-code", "-s", "user", "-y"]);
const skillMd = path.join(home, ".claude", "skills", "designpaca", "SKILL.md");
assert.ok((await fs.readFile(skillMd, "utf8")).includes("name: designpaca"));
assert.ok(run(["doctor"]).includes("claude-code"));
run(["uninstall", "-y"]);
await assert.rejects(() => fs.access(skillMd));
});

View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["node"],
"noEmit": true,
"paths": { "@designpaca/core": ["../core/src/index.ts"] }
},
"include": ["src/**/*.ts", "scripts/**/*.mjs", "test/**/*.ts"]
}

View file

@ -0,0 +1,15 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: { index: "src/index.ts" },
format: ["esm"],
target: "node20",
platform: "node",
clean: false, // dist/skill 을 지우면 안 된다(bundle-skill 이 먼저 채운다)
dts: false,
sourcemap: false,
minify: false,
// 워크스페이스 패키지는 번들에 넣는다 — 사용자가 @designpaca/core 를 따로 받게 하지 않는다
noExternal: ["@designpaca/core"],
banner: { js: "#!/usr/bin/env node" },
});