feat: 브리프 인터뷰 단계와 브라우저 도구를 넣고 온보딩 말투를 고친다
All checks were successful
ci / build (push) Successful in 25s
release / release (push) Successful in 46s

세 가지를 고친다. 전부 실제 사용에서 드러난 것이다.

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개).
This commit is contained in:
Yun Chan 2026-08-21 03:05:06 +09:00
parent eb9f61e907
commit 4e1e5f0073
15 changed files with 486 additions and 39 deletions

View file

@ -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",

View file

@ -18,7 +18,7 @@ export async function runDoctor(): Promise<number> {
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<number> {
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) {

View file

@ -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 스킬이 자동으로 잡힌다`));

View file

@ -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<number> {
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;
}

View file

@ -11,7 +11,7 @@ export async function runUninstall(opts: {
);
if (targets.length === 0) {
console.log(warn("제거할 설치 기록이 없다"));
console.log(warn("제거할 설치 기록이 없습니다"));
return 0;
}

View file

@ -12,7 +12,7 @@ export async function runUpdate(opts: { force?: boolean } = {}): Promise<number>
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<number>
const stale = manifest.installs.filter((i) => i.version !== version);
if (stale.length === 0) {
console.log(ok("모든 설치가 이미 최신이다"));
console.log(ok("설치된 스킬이 모두 최신 버전입니다"));
// 버전이 같아도 파일이 사라졌거나 수정됐을 수 있다. 조용히 넘기면 사용자가 모른다.
let broken = 0;

View file

@ -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 <id[,id]> 대상: ${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<number> {
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<number> {
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;

View file

@ -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<number> {
p.note(
[
"브리프에서 시작해 레퍼런스 조사 · 방향 결정 · 디자인 토큰 ·",
"구현 · 셀프 감사까지 끌고 가는 웹 디자인 파이프라인 스킬.",
"웹 디자인을 순서대로 진행하게 만드는 스킬입니다.",
"브리프 정리 → 레퍼런스 조사 → 방향 결정 → 디자인 토큰 →",
"구현 → 셀프 감사까지, 단계마다 통과 조건을 두고 진행합니다.",
"",
`${dim("SVG 필터 · three.js · 인터랙티브 모션을 기본 재료로 쓴다.")}`,
dim("설치하면 에이전트에서 /designpaca 로 부를 수 있습니다."),
dim("스킬 파일만 복사하며, 기존 설정은 건드리지 않습니다."),
].join("\n"),
"무엇을 설치하나",
"designpaca 는 무엇인가요",
);
// 시스템에 흔적이 있는 도구를 기본 체크해 둔다 — 사용자가 매번 고르게 하지 않는다
const detected = await detectTargets(skill);
const targets = await p.multiselect<TargetId>({
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<number> {
if (p.isCancel(targets)) return cancel();
const wanted = await p.select<Scope>({
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<number> {
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<number> {
].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<void> {
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<string>({
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;
}

View file

@ -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, "도움말에 명령조 표현이 남아 있다");
});