From 4e1e5f007314b2339e23969d9204f72626c3dc18 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Fri, 21 Aug 2026 03:05:06 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=B8=8C=EB=A6=AC=ED=94=84=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=EB=B7=B0=20=EB=8B=A8=EA=B3=84=EC=99=80=20=EB=B8=8C?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=EC=A0=80=20=EB=8F=84=EA=B5=AC=EB=A5=BC=20?= =?UTF-8?q?=EB=84=A3=EA=B3=A0=20=EC=98=A8=EB=B3=B4=EB=94=A9=20=EB=A7=90?= =?UTF-8?q?=ED=88=AC=EB=A5=BC=20=EA=B3=A0=EC=B9=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 세 가지를 고친다. 전부 실제 사용에서 드러난 것이다. 1) 0단계에서 가정으로 채우던 것을 인터뷰로 바꾼다 "꽃집 사이트 만들어보자"를 받고 업종 성격·목표 행동·톤·이름을 혼자 정했다. 물어보니 넷 중 넷이 달랐다(일상 구독 → 하이엔드 스튜디오, 문의 하나 → 넷 다, 톤 미정 → 에디토리얼, 이름 지어냄 → 목요일의 화원). 그대로 갔으면 레퍼런스 세 개를 전부 틀린 방향에서 골랐다. SKILL.md 0단계에 질문 도구로 한 번에 묻는 절차를 넣었다. 무엇을 묻고 무엇을 묻지 않는지, 답이 모순될 때 어떻게 정리하는지까지 적었다. 2) 레퍼런스가 막히면 브라우저를 띄운다 Aesop(403) → Kinto(404) → Hasami(DNS) → MUJI(타임아웃)로 네 번 왕복하고 톤 레퍼런스를 하나도 못 얻었다. 좋은 레퍼런스일수록 봇을 막는다. galleries.md 에 headed 브라우저로 직접 열어 스크린샷과 실측값을 받는 방법을 넣고, 접근 실패 2회면 바로 전환하도록 규칙을 세웠다. Playwright 를 선택 의존성으로 잡았다(core/tools.ts). - 온보딩 마지막에 설치 여부를 묻는다. 건너뛰어도 스킬은 동작한다 - `designpaca tools` 로 상태 확인, `--yes` 로 설치 - 수백 MB 라 --yes 없이는 상태만 보여준다 - MCP 서버는 설치만 하고 등록 명령은 안내만 한다(에이전트 설정을 대신 건드리지 않는다) 3) 온보딩 말투 "이제 브리프를 던져라", "설치해라", "건너뛴다" — 사용자를 향한 문구가 명령조였다. 스킬 문서의 단정한 반말은 의도지만 CLI UI 는 다르다. 전부 존댓말로 바꾸고, 각 단계가 왜 필요한지 설명을 붙였다. 명령조가 다시 섞이지 않도록 검사하는 테스트를 넣었다(테스트 22 → 25개). --- packages/cli/package.json | 31 ++++-- packages/cli/src/commands/doctor.ts | 4 +- packages/cli/src/commands/install.ts | 4 +- packages/cli/src/commands/tools.ts | 67 ++++++++++++ packages/cli/src/commands/uninstall.ts | 2 +- packages/cli/src/commands/update.ts | 4 +- packages/cli/src/index.ts | 19 ++-- packages/cli/src/tui/onboard.ts | 136 +++++++++++++++++++++---- packages/cli/test/tools.test.ts | 29 ++++++ packages/core/package.json | 2 +- packages/core/src/index.ts | 1 + packages/core/src/tools.ts | 129 +++++++++++++++++++++++ packages/skill/SKILL.md | 48 ++++++++- packages/skill/package.json | 2 +- packages/skill/references/galleries.md | 47 +++++++++ 15 files changed, 486 insertions(+), 39 deletions(-) create mode 100644 packages/cli/src/commands/tools.ts create mode 100644 packages/cli/test/tools.test.ts create mode 100644 packages/core/src/tools.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index fd9903d..6dfc998 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,16 +1,35 @@ { "name": "designpaca", - "version": "0.1.0", + "version": "0.2.0", "description": "웹 디자인 파이프라인 스킬 — Claude Code · Codex · Cursor 에 한 줄로 설치한다", - "keywords": ["design", "web-design", "claude-code", "codex", "cursor", "agent-skill", "svg-filter", "threejs"], + "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" }, + "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" }, + "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", diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 66f1e0c..6a7b7fb 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -18,7 +18,7 @@ export async function runDoctor(): Promise { if (pruned > 0) console.log(info(`사라진 설치 기록 ${pruned}건을 정리했다`)); if (manifest.installs.length === 0) { - console.log(warn("설치된 곳이 없다. `npx designpaca install` 로 설치해라.")); + console.log(warn("아직 설치된 곳이 없습니다. `npx designpaca install` 로 설치하실 수 있습니다.")); return 0; } @@ -40,7 +40,7 @@ export async function runDoctor(): Promise { if (rec.version !== version) problems++; if (missing.length > 0) { problems++; - console.log(bad(`파일 ${missing.length}개가 사라졌다 — \`designpaca update --force\` 로 복구해라`)); + 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) { diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts index 5d717e5..fa64382 100644 --- a/packages/cli/src/commands/install.ts +++ b/packages/cli/src/commands/install.ts @@ -60,7 +60,7 @@ export function printOutcomes(outcomes: InstallOutcome[], dryRun = false): void const verb = dryRun ? "쓸 파일" : "설치됨"; console.log(ok(`${o.target} — ${verb} ${o.written}개 ${dim(tildify(o.root))}`)); if (o.skipped.length > 0) { - console.log(warn(` 직접 수정한 파일이라 건드리지 않았다 (--force 로 덮어쓴다):`)); + console.log(warn(` 직접 수정하신 파일이라 그대로 두었습니다 (--force 로 덮어쓸 수 있습니다):`)); for (const s of fold(o.skipped)) console.log(dim(` ${tildify(s)}`)); } } @@ -70,7 +70,7 @@ export function printOutcomes(outcomes: InstallOutcome[], dryRun = false): void export function printNextSteps(targets: TargetId[]): void { console.log(heading("다음 단계")); if (targets.includes("claude-code")) { - console.log(info(`Claude Code 를 새로 열고 ${dim("/designpaca")} 를 실행해라`)); + console.log(info(`Claude Code 를 새로 연 뒤 ${dim("/designpaca")} 를 실행해 주세요`)); } if (targets.includes("codex")) { console.log(info(`Codex CLI 에서 designpaca 스킬이 자동으로 잡힌다`)); diff --git a/packages/cli/src/commands/tools.ts b/packages/cli/src/commands/tools.ts new file mode 100644 index 0000000..d71e996 --- /dev/null +++ b/packages/cli/src/commands/tools.ts @@ -0,0 +1,67 @@ +import { checkTools } from "@designpaca/core"; +import { bad, dim, heading, info, ok, table, warn } from "../ui.ts"; + +/** + * 선택 도구의 상태를 보여주고, 없는 것을 설치한다. + * + * 온보딩에서 건너뛴 사람과, 나중에 필요해진 사람 모두를 위한 자리다. + * `--yes` 없이 비대화형으로 부르면 **상태만 보여주고 아무것도 설치하지 않는다** — + * 도구 설치는 수백 MB 를 받는 일이라 조용히 시작하면 안 된다. + */ +export async function runTools(opts: { install: boolean }): Promise { + const status = await checkTools(); + + console.log(heading("브라우저 도구")); + console.log( + table( + status.map(({ tool, installed }) => [ + tool.label, + installed ? "설치됨" : dim("설치되지 않음"), + ]), + ), + ); + + const missing = status.filter((s) => !s.installed); + + if (missing.length === 0) { + console.log(ok("필요한 도구가 모두 준비돼 있습니다.")); + return 0; + } + + console.log(); + for (const { tool } of missing) { + console.log(`${tool.label} ${dim(`· ${tool.size}`)}`); + console.log(` ${dim(tool.why)}`); + } + + if (!opts.install) { + console.log(); + console.log(info("설치하시려면 `designpaca tools --yes` 를 실행해 주세요.")); + return 0; + } + + console.log(); + let failed = 0; + const followUps: string[] = []; + for (const { tool } of missing) { + process.stdout.write(`${tool.label} 설치 중… `); + try { + await tool.install(); + console.log("완료"); + if (tool.followUp) followUps.push(tool.followUp); + } catch (err) { + failed++; + console.log("실패"); + const first = err instanceof Error ? (err.message.split("\n")[0] ?? err.message) : String(err); + console.log(` ${bad(first)}`); + } + } + + if (followUps.length > 0) { + console.log(); + console.log(warn("아래는 에이전트 설정을 바꾸는 것이라 직접 실행해 주세요.")); + for (const f of followUps) console.log(` ${f}`); + } + + return failed > 0 ? 1 : 0; +} diff --git a/packages/cli/src/commands/uninstall.ts b/packages/cli/src/commands/uninstall.ts index 26aba00..fbe20c8 100644 --- a/packages/cli/src/commands/uninstall.ts +++ b/packages/cli/src/commands/uninstall.ts @@ -11,7 +11,7 @@ export async function runUninstall(opts: { ); if (targets.length === 0) { - console.log(warn("제거할 설치 기록이 없다")); + console.log(warn("제거할 설치 기록이 없습니다")); return 0; } diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index aeb3014..7f5980b 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -12,7 +12,7 @@ export async function runUpdate(opts: { force?: boolean } = {}): Promise const manifest = await readManifest(); if (manifest.installs.length === 0) { - console.log(warn("설치 기록이 없다. `npx designpaca install` 을 먼저 실행해라.")); + console.log(warn("설치 기록이 없습니다. `npx designpaca install` 을 먼저 실행해 주세요.")); return 1; } @@ -20,7 +20,7 @@ export async function runUpdate(opts: { force?: boolean } = {}): Promise const stale = manifest.installs.filter((i) => i.version !== version); if (stale.length === 0) { - console.log(ok("모든 설치가 이미 최신이다")); + console.log(ok("설치된 스킬이 모두 최신 버전입니다")); // 버전이 같아도 파일이 사라졌거나 수정됐을 수 있다. 조용히 넘기면 사용자가 모른다. let broken = 0; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index bd0d5a6..7855ce1 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,6 +3,7 @@ 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 { runTools } from "./commands/tools.ts"; import { runUpdate } from "./commands/update.ts"; import { runUninstall } from "./commands/uninstall.ts"; import { runList } from "./commands/list.ts"; @@ -70,14 +71,15 @@ ${bold("사용법")} npx designpaca uninstall 제거 npx designpaca doctor 설치 상태 진단 npx designpaca list 설치 가능 대상과 현재 설치 목록 + npx designpaca tools 브라우저 도구 상태 확인 ${dim("(--yes 로 설치)")} ${bold("옵션")} -t, --target 설치 대상: ${VALID_TARGETS.join(", ")} -s, --scope <범위> user | project ${dim("(기본: user)")} - -y, --yes 확인 없이 진행 - -f, --force 직접 수정한 파일도 덮어쓴다 ${dim("(.orig 로 백업)")} - --dry-run 쓰지 않고 계획만 출력 - --no-update-check 새 버전 확인을 건너뛴다 + -y, --yes 확인 없이 진행합니다 + -f, --force 직접 수정한 파일도 덮어씁니다 ${dim("(.orig 로 백업)")} + --dry-run 쓰지 않고 계획만 보여줍니다 + --no-update-check 새 버전 확인을 건너뜁니다 -h, --help 이 도움말 -v, --version 버전 @@ -85,6 +87,7 @@ ${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")} + ${dim("npx designpaca tools --yes")} `.trimStart(); } @@ -146,12 +149,16 @@ async function main(): Promise { case "doctor": code = await runDoctor(); break; + case "tools": + // 수백 MB 를 받는 일이라 --yes 없이는 상태만 보여준다 + code = await runTools({ install: args.yes }); + break; case "list": case "ls": code = await runList(); break; default: - console.error(bad(`알 수 없는 명령: ${args.command}`)); + console.error(bad(`알 수 없는 명령입니다: ${args.command}`)); console.log(usage(version)); return 2; } @@ -160,7 +167,7 @@ async function main(): Promise { const latest = await checkForUpdate(version); if (latest) { console.log( - "\n" + warn(`새 버전 ${bold(`v${latest}`)} 이 있다 — ${dim("npx designpaca@latest update")}`), + "\n" + warn(`새 버전 v${latest} 이 나왔습니다 — ${dim("npx designpaca@latest update")}`), ); } return code; diff --git a/packages/cli/src/tui/onboard.ts b/packages/cli/src/tui/onboard.ts index 4a621be..a9e3013 100644 --- a/packages/cli/src/tui/onboard.ts +++ b/packages/cli/src/tui/onboard.ts @@ -2,6 +2,7 @@ import * as p from "@clack/prompts"; import path from "node:path"; import { ADAPTERS, + checkTools, detectTargets, getAdapter, planInstall, @@ -27,19 +28,21 @@ export async function onboard(): Promise { p.note( [ - "브리프에서 시작해 레퍼런스 조사 · 방향 결정 · 디자인 토큰 ·", - "구현 · 셀프 감사까지 끌고 가는 웹 디자인 파이프라인 스킬.", + "웹 디자인을 순서대로 진행하게 만드는 스킬입니다.", + "브리프 정리 → 레퍼런스 조사 → 방향 결정 → 디자인 토큰 →", + "구현 → 셀프 감사까지, 단계마다 통과 조건을 두고 진행합니다.", "", - `${dim("SVG 필터 · three.js · 인터랙티브 모션을 기본 재료로 쓴다.")}`, + dim("설치하면 에이전트에서 /designpaca 로 부를 수 있습니다."), + dim("스킬 파일만 복사하며, 기존 설정은 건드리지 않습니다."), ].join("\n"), - "무엇을 설치하나", + "designpaca 는 무엇인가요", ); // 시스템에 흔적이 있는 도구를 기본 체크해 둔다 — 사용자가 매번 고르게 하지 않는다 const detected = await detectTargets(skill); const targets = await p.multiselect({ - message: "어디에 설치할까?", + message: "어디에 설치할까요?", options: ADAPTERS.map((a) => ({ value: a.id, label: a.label + (detected.includes(a.id) ? dim(" (감지됨)") : ""), @@ -51,10 +54,18 @@ export async function onboard(): Promise { if (p.isCancel(targets)) return cancel(); const wanted = await p.select({ - message: "설치 범위", + message: "어느 범위에 설치할까요?", options: [ - { value: "user", label: "전역", hint: "홈 디렉터리 — 모든 프로젝트에서 쓴다" }, - { value: "project", label: "이 프로젝트만", hint: tildify(process.cwd()) }, + { + value: "user", + label: "전역 (권장)", + hint: "홈 디렉터리에 두어 모든 프로젝트에서 쓸 수 있습니다", + }, + { + value: "project", + label: "이 프로젝트만", + hint: `${tildify(process.cwd())} · 팀과 함께 쓰려면 이쪽이 좋습니다`, + }, ], initialValue: "user", }); @@ -67,7 +78,7 @@ export async function onboard(): Promise { downgraded .map((t) => { const a = getAdapter(t); - return `${a.label} 은(는) ${a.scopes.join("/")} 범위만 지원한다 → ${effectiveScope(t, wanted)} 로 설치한다`; + return `${a.label} 은(는) ${a.scopes.join("/")} 범위만 지원해서, ${effectiveScope(t, wanted)} 범위로 설치됩니다`; }) .join("\n"), ); @@ -91,35 +102,126 @@ export async function onboard(): Promise { ].join("\n"), ); } - p.note(previews.join("\n\n"), "설치 계획"); + p.note( + [ + previews.join("\n\n"), + "", + dim("아래 경로에 스킬 파일을 복사합니다."), + dim("직접 수정하신 파일이 있으면 덮어쓰지 않고 알려드립니다."), + ].join("\n"), + "이렇게 설치됩니다", + ); - const go = await p.confirm({ message: "이대로 설치할까?", initialValue: true }); + const go = await p.confirm({ message: "이대로 진행할까요?", initialValue: true }); if (p.isCancel(go) || !go) return cancel(); const s = p.spinner(); - s.start("설치 중"); + s.start("설치하고 있습니다"); const outcomes = []; try { for (const t of targets) { const scope = effectiveScope(t, wanted); - s.message(`설치 중 — ${getAdapter(t).label}`); + s.message(`${getAdapter(t).label} 에 설치하고 있습니다`); outcomes.push(...(await runInstall({ targets: [t], scope }))); } - s.stop("설치 완료"); + s.stop("설치가 끝났습니다"); } catch (err) { - s.stop("설치 실패", 1); + s.stop("설치하지 못했습니다", 1); p.log.error(err instanceof Error ? err.message : String(err)); return 1; } printOutcomes(outcomes); + + await offerTools(); + printNextSteps(targets); - p.outro(`${accent("designpaca")} 준비됨 — 이제 브리프를 던져라`); + p.outro(`${accent("designpaca")} 준비됐습니다 — 만들고 싶은 것을 말씀해 주세요`); return 0; } +/** + * 선택 도구를 제안한다. 스킬 설치가 끝난 뒤에 묻는다 — + * 본 작업을 이것 때문에 붙잡지 않기 위해서다. 건너뛰어도 스킬은 돈다. + */ +export async function offerTools(): Promise { + const status = await checkTools(); + const missing = status.filter((s) => !s.installed); + + if (missing.length === 0) { + p.log.success( + `브라우저 도구가 이미 준비돼 있습니다 ${dim("— 레퍼런스 조사에서 바로 활용됩니다")}`, + ); + return; + } + + p.note( + [ + "이 스킬은 디자인을 시작하기 전에 실제 사이트를 찾아 분석합니다.", + "그런데 참고할 만한 사이트일수록 자동 접근을 막아두는 경우가 많습니다.", + "", + "브라우저 도구가 있으면 그런 사이트도 직접 열어서", + "화면을 캡처하고 글자 크기·여백 같은 값을 실제로 잴 수 있습니다.", + "", + dim("설치하지 않아도 스킬은 정상 동작합니다."), + dim("나중에 `designpaca tools` 로 언제든 추가하실 수 있습니다."), + ].join("\n"), + "브라우저 도구를 함께 설치할까요?", + ); + + const picked = await p.multiselect({ + message: "설치할 도구를 골라주세요 (스페이스로 선택 / 엔터로 확인)", + options: missing.map(({ tool }) => ({ + value: tool.id, + label: tool.label, + hint: `${tool.why} · ${tool.size}`, + })), + initialValues: missing.map((m) => m.tool.id), + required: false, + }); + if (p.isCancel(picked) || picked.length === 0) { + p.log.info( + `건너뛰겠습니다. ${dim("필요해지면 `designpaca tools` 로 설치하실 수 있습니다.")}`, + ); + return; + } + + const s = p.spinner(); + const followUps: string[] = []; + for (const id of picked) { + const entry = missing.find((m) => m.tool.id === id); + if (!entry) continue; + s.start(`${entry.tool.label} 을(를) 설치하고 있습니다 ${dim(`(${entry.tool.size})`)}`); + try { + await entry.tool.install(); + s.stop(`${entry.tool.label} 설치 완료`); + if (entry.tool.followUp) followUps.push(entry.tool.followUp); + } catch (err) { + // 도구 설치 실패로 스킬 설치까지 실패시키지 않는다. 스킬은 이미 깔렸다. + s.stop(`${entry.tool.label} 을(를) 설치하지 못했습니다`, 1); + p.log.warn( + [ + err instanceof Error ? err.message.split("\n")[0] : String(err), + dim("스킬 설치는 정상적으로 끝났습니다. 이 도구만 나중에 다시 시도하셔도 됩니다."), + ].join("\n"), + ); + } + } + + if (followUps.length > 0) { + p.note( + [ + "아래 명령은 에이전트 설정을 바꾸는 것이라 직접 실행해 주세요.", + "", + ...followUps, + ].join("\n"), + "마지막 한 단계", + ); + } +} + function cancel(): number { - p.cancel("설치를 취소했다. 아무것도 바꾸지 않았다."); + p.cancel("설치를 취소했습니다. 아무것도 변경하지 않았습니다."); return 130; } diff --git a/packages/cli/test/tools.test.ts b/packages/cli/test/tools.test.ts new file mode 100644 index 0000000..5f3f461 --- /dev/null +++ b/packages/cli/test/tools.test.ts @@ -0,0 +1,29 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const exec = promisify(execFile); +const CLI = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../dist/index.js"); + +test("tools 는 --yes 없이 아무것도 설치하지 않는다", async () => { + // 수백 MB 를 받는 명령이라 조용히 시작하면 안 된다. + // 상태만 보이거나, 이미 다 있으면 그렇다고 말해야 한다. + const { stdout } = await exec(process.execPath, [CLI, "tools", "--no-update-check"]); + assert.match(stdout, /브라우저 도구/); + assert.doesNotMatch(stdout, /설치 중/); +}); + +test("도움말에 tools 명령이 나온다", async () => { + const { stdout } = await exec(process.execPath, [CLI, "--help"]); + assert.match(stdout, /designpaca tools/); +}); + +test("사용자에게 보이는 문구가 명령조가 아니다", async () => { + // 스킬 문서는 단정한 반말이 의도지만, 사용자를 향한 CLI 문구는 다르다. + const { stdout } = await exec(process.execPath, [CLI, "--help"]); + const rude = /(해라|써라|봐라|던져라|가라)/; + assert.doesNotMatch(stdout, rude, "도움말에 명령조 표현이 남아 있다"); +}); diff --git a/packages/core/package.json b/packages/core/package.json index 28b40d3..02721d5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@designpaca/core", - "version": "0.1.0", + "version": "0.2.0", "private": true, "description": "designpaca 설치 엔진 — 타깃 어댑터, 매니페스트, 드리프트 감지", "type": "module", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1fe9b33..98f7958 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -5,4 +5,5 @@ export * from "./marker.ts"; export * from "./manifest.ts"; export * from "./skill-source.ts"; export * from "./installer.ts"; +export * from "./tools.ts"; export { ADAPTERS, getAdapter, MARKER } from "./targets/index.ts"; diff --git a/packages/core/src/tools.ts b/packages/core/src/tools.ts new file mode 100644 index 0000000..5c30fd6 --- /dev/null +++ b/packages/core/src/tools.ts @@ -0,0 +1,129 @@ +/** + * 선택 도구 — 스킬이 있으면 더 잘 작동하는 외부 도구들. + * + * 스킬 자체는 이것들 없이도 돈다. 다만 레퍼런스 조사에서 갤러리가 봇 차단(403)을 + * 걸거나 SPA 라 본문을 못 읽을 때, 텍스트 페처만으로는 방법이 없다. + * 실측에서 톤 레퍼런스 하나를 얻으려고 네 사이트를 돌고 전부 실패했다. + * headed 브라우저가 있으면 첫 번째에 끝난다. + * + * 그래서 **필수 의존성이 아니라 선택 의존성**이다. 설치는 사용자가 고른다. + */ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +const exec = promisify(execFile); + +export interface OptionalTool { + id: string; + label: string; + /** 이게 없으면 무엇을 못 하는가 — 고르는 사람이 판단할 수 있게 */ + why: string; + /** 설치 크기 어림값. 수백 MB 를 말없이 받게 하지 않는다 */ + size: string; + detect(): Promise; + install(): Promise; + /** 설치 후 사람이 직접 해야 하는 것이 있으면 여기에 */ + followUp?: string; +} + +/** npm 전역 루트. Windows 경로에 공백이 있어도 안전하게 얻는다 */ +async function npmRoot(): Promise { + try { + const { stdout } = await exec("npm", ["root", "-g"], { shell: true }); + return stdout.trim() || null; + } catch { + return null; + } +} + +async function exists(p: string): Promise { + try { + await fs.access(p); + return true; + } catch { + return false; + } +} + +/** + * Playwright — 레퍼런스 조사와 프리플라이트 실렌더 검사에 쓴다. + * + * 라이브러리만 있고 브라우저 바이너리가 없으면 실행 시점에 죽는다. + * 둘 다 있어야 설치된 것으로 친다. + */ +const playwright: OptionalTool = { + id: "playwright", + label: "Playwright (headed 브라우저)", + why: "봇 차단(403)·SPA 갤러리를 직접 열어 스크린샷과 실측값을 받는다. 5단계 실렌더 검사에도 쓴다", + size: "약 180MB (크로미움 포함)", + + async detect() { + const root = await npmRoot(); + if (!root || !(await exists(path.join(root, "playwright")))) return false; + // 브라우저 바이너리까지 확인한다 — 라이브러리만 있으면 첫 실행에서 실패한다 + const cacheDir = + process.platform === "win32" + ? path.join(os.homedir(), "AppData", "Local", "ms-playwright") + : process.platform === "darwin" + ? path.join(os.homedir(), "Library", "Caches", "ms-playwright") + : path.join(os.homedir(), ".cache", "ms-playwright"); + try { + const entries = await fs.readdir(cacheDir); + return entries.some((e) => e.startsWith("chromium")); + } catch { + return false; + } + }, + + async install() { + await exec("npm", ["install", "-g", "playwright"], { shell: true, maxBuffer: 1 << 24 }); + await exec("npx", ["--yes", "playwright", "install", "chromium"], { + shell: true, + maxBuffer: 1 << 24, + }); + }, +}; + +/** + * Playwright MCP — Claude Code 가 브라우저를 직접 조작하게 한다. + * + * 설치는 해주지만 **등록은 하지 않는다.** MCP 서버 등록은 사용자의 에이전트 설정을 + * 건드리는 일이고, 설정 파일 위치와 형식이 도구마다 다르다. 명령만 알려준다. + */ +const playwrightMcp: OptionalTool = { + id: "playwright-mcp", + label: "Playwright MCP 서버", + why: "에이전트가 브라우저를 직접 조작한다. 스크립트를 매번 쓰지 않아도 된다", + size: "약 2MB", + followUp: "claude mcp add playwright -- npx @playwright/mcp@latest", + + async detect() { + const root = await npmRoot(); + if (!root) return false; + return exists(path.join(root, "@playwright", "mcp")); + }, + + async install() { + await exec("npm", ["install", "-g", "@playwright/mcp"], { shell: true, maxBuffer: 1 << 24 }); + }, +}; + +export const OPTIONAL_TOOLS: OptionalTool[] = [playwright, playwrightMcp]; + +export interface ToolStatus { + tool: OptionalTool; + installed: boolean; +} + +export async function checkTools(): Promise { + return Promise.all( + OPTIONAL_TOOLS.map(async (tool) => ({ tool, installed: await tool.detect() })), + ); +} + +export function getTool(id: string): OptionalTool | undefined { + return OPTIONAL_TOOLS.find((t) => t.id === id); +} diff --git a/packages/skill/SKILL.md b/packages/skill/SKILL.md index 0c2fec3..6d0de95 100644 --- a/packages/skill/SKILL.md +++ b/packages/skill/SKILL.md @@ -88,7 +88,48 @@ description: "웹 디자인 전 과정을 끌고 가는 파이프라인 스킬. **애매하면 긴 쪽으로.** 단 국소 조건에 해당하면 국소로 가라 — 버튼 하나에 갤러리 3곳을 여는 것은 사용자가 이 스킬을 끄게 만든다. **국소로 시작했다가 조건이 깨지면 멈추고 올린다.** 토큰을 새로 정의하게 됐거나, 손댄 섹션이 3개를 넘었거나, 방향을 바꿔야 하면. **올렸다고 말해라. 조용히 국소에 머무는 것이 이 스킬의 최대 실패다.** -**막혔을 때**: 브리프가 비어 있으면 추측하지 말고 물어라. 단 **한 번에 다 묻지 마라.** 결과를 가장 크게 바꾸는 것 하나만 묻고, 나머지는 가정을 명시하고 진행한다. +**브리프가 비어 있으면 인터뷰해라. 가정으로 채우지 마라.** + +이 단계에서 추측한 것은 전부 기본값이고, 기본값의 총합이 슬롭이다. +실측 사례 — "꽃집 홍보 사이트 하나 만들어보자"를 받고 업종 성격·목표 행동·톤·이름을 +전부 혼자 정했다. 실제로 물어보니 **넷 중 넷이 달랐다.** + +| 내가 가정한 것 | 사용자의 실제 답 | +|---|---| +| 일상 꽃 · 정기구독 | **하이엔드 플로럴 스튜디오** | +| 문의 유도 하나 | 문의 · 구독 · 방문 · 인스타 **넷 다** | +| (묻지 않음) | **에디토리얼 · 잡지** | +| (내가 지어냄) | **목요일의 화원** | + +이 상태로 1단계에 들어갔으면 **레퍼런스 세 개를 전부 틀린 방향에서 골랐을 것이다.** + +**질문 도구(AskUserQuestion)로 한 번에 묻는다.** 하나씩 캐물으면 사용자가 지친다 — +결과를 크게 바꾸는 축을 골라 **선택지와 함께** 한 화면에 낸다. 각 선택지에는 +"이걸 고르면 무엇이 달라지는지"를 적어라. 고르는 사람이 결과를 예상할 수 있어야 한다. + +거의 모든 브리프에서 다음 넷이 결과를 가장 크게 바꾼다. + +| 축 | 무엇이 달라지는가 | +|---|---| +| **업종·성격** | 정보 구조 전체. 같은 "꽃집"도 구독형과 하이엔드 스튜디오는 다른 사이트다 | +| **목표 행동** | CTA 의 수와 위치, 어떤 섹션이 필요하고 어떤 게 군더더기인지 | +| **톤** | 2단계 프리셋과 감수할 리스크가 여기서 결정된다 | +| **고유명사·실제 값** | 이름·지역·가격·연락처. 지어내면 하드 게이트 11에 걸린다 | + +넷을 `multiSelect` 로 낼지 단일 선택으로 낼지 구분해라 — **목표 행동은 대개 복수**고, +톤과 성격은 하나여야 한다. 둘 다 고르면 방향이 서지 않는다. + +**물어도 되는 것과 물으면 안 되는 것** + +- 묻는다: 결과를 바꾸는 결정(위 표), 사용자만 아는 사실(실제 수치·이름·재고) +- 묻지 않는다: 검색하면 나오는 것, 코드를 읽으면 아는 것, 관례가 명확한 것 + +**답이 모순되면 그 자리에서 정리해라.** 위 사례에서 목표 행동 넷이 다 선택됐는데, +하이엔드 스튜디오에서 "정기구독"과 "문의 상담"은 성격이 다르다. +**우선순위를 제안하고 확인받는다** — 넷을 같은 무게로 놓으면 CTA 가 넷이 되고 페이지가 무너진다. + +예외: 사용자가 "알아서 해줘"라고 명시했거나, 되돌리기 쉬운 습작이면 가정하고 진행해도 된다. +**단 가정한 항목을 목록으로 말하고, 6단계 `design.md` 의 미확정 목록에 올린다.** **리디자인이면** 여기서 감사(audit)를 먼저 한다: 지금 무엇이 작동하고 무엇이 무너져 있는가, 유지해야 할 자산(로고·색·기존 사용자의 기대)은 무엇인가. 감사 없는 리디자인은 파괴다. @@ -115,6 +156,11 @@ R1과 R2를 같은 업종에서 고르면 결과는 그 업종의 평균이 된 **갤러리 목록 페이지가 아니라 원본 사이트를 열어라.** 이미지를 볼 수 없어도 구조는 읽을 수 있다. +**403·404·타임아웃을 두 번 만나면 텍스트 페처를 버리고 브라우저를 띄워라.** +좋은 레퍼런스일수록 봇을 막는다. 대체 갤러리를 찾아 헤매는 것은 시간 낭비이고, +찾아낸 대체 소스는 애초에 보려던 것보다 나쁘다. `designpaca tools` 로 설치된 +headed 브라우저가 있으면 스크린샷과 실측값을 바로 받는다 — 방법은 `galleries.md` §5. + > 통과 조건: R1/R2/R3 각각의 URL과, 그것에서 **무엇을 가져올지** 한 줄씩. 형식은 `reference-method.md` 참조. --- diff --git a/packages/skill/package.json b/packages/skill/package.json index 5b4fab1..aa145ab 100644 --- a/packages/skill/package.json +++ b/packages/skill/package.json @@ -1,6 +1,6 @@ { "name": "@designpaca/skill", - "version": "0.1.0", + "version": "0.2.0", "private": true, "description": "designpaca 스킬 원본 — SKILL.md 와 참조 문서", "scripts": { diff --git a/packages/skill/references/galleries.md b/packages/skill/references/galleries.md index 831af26..f6b103f 100644 --- a/packages/skill/references/galleries.md +++ b/packages/skill/references/galleries.md @@ -170,4 +170,51 @@ Awwwards는 **타이포 완성도와 야심의 상한선**으로만 쓰고, 섹 | adfolio.design | 429 | Love The Work More | | **commercecream.com** | **HTTPS 연결 거부 (방치 상태)** | **ecomm.design로 대체. 쓰지 마라** | +### 막히면 갤러리를 갈아타지 말고 **브라우저로 직접 들어가라** + +위 표는 "대체 소스"를 적어뒀지만, 그건 차선책이다. **보려던 그 사이트를 봐야 한다.** +403·404·타임아웃을 만나면 대체 갤러리를 찾아 헤매지 말고 실제 브라우저를 띄워라. +실측에서 Aesop(403) → Kinto(404) → Hasami(DNS 실패) → MUJI(타임아웃)로 네 번 왕복하고도 +**톤 레퍼런스를 하나도 못 얻었다.** 브라우저를 열었으면 첫 번째에 끝났다. + +```js +// playwright 가 없으면: npm i -g playwright && npx playwright install chromium +const path = require('path'); +const { chromium } = require(path.join(process.env.PW_ROOT, 'playwright')); +const b = await chromium.launch({ headless: false }); // headed 여야 봇 차단을 덜 받는다 +const p = await b.newPage({ viewport: { width: 1440, height: 900 } }); +await p.goto(url, { waitUntil: 'networkidle', timeout: 45000 }); +await p.screenshot({ path: 'ref-1.png', fullPage: true }); +``` + +**스크린샷은 텍스트 요약보다 낫다.** `WebFetch` 는 마크다운으로 바꾸면서 +여백·비율·크기 대비를 전부 버린다 — 6축 중 절반이 그 정보다. +그림을 보고 나서 **재라**: 히어로가 첫 화면의 몇 %인지, 제목이 몇 px 인지, 칼럼이 몇 개인지. + +```js +// 눈으로 보지 말고 숫자로 받아라 +await p.evaluate(() => { + const h1 = document.querySelector('h1'); + return { + h1px: h1 && getComputedStyle(h1).fontSize, + h1가족: h1 && getComputedStyle(h1).fontFamily.split(',')[0], + 본문px: getComputedStyle(document.body).fontSize, + 배경: getComputedStyle(document.body).backgroundColor, + 첫화면_이미지비율: (() => { + const im = [...document.querySelectorAll('img,video,canvas')] + .filter(e => e.getBoundingClientRect().top < innerHeight); + const a = im.reduce((s, e) => { const r = e.getBoundingClientRect(); return s + r.width * r.height; }, 0); + return (a / (innerWidth * innerHeight) * 100).toFixed(0) + '%'; + })(), + }; +}); +``` + +**headed 로 띄우는 이유**: 헤드리스는 Cloudflare·Akamai 봇 판정에 자주 걸린다. +그래도 막히면 그때 대체 소스로 간다 — 순서가 반대다. + +> 규칙: **접근 실패 2회 → 브라우저를 띄운다.** 대체 갤러리 탐색은 브라우저도 막힌 뒤의 일이다. + +--- + > 근거: research/references/01-gallery-catalog.md, 03-trends-2026.md (조사일 2026-08-20)