feat: 브리프 인터뷰 단계와 브라우저 도구를 넣고 온보딩 말투를 고친다
세 가지를 고친다. 전부 실제 사용에서 드러난 것이다.
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:
parent
eb9f61e907
commit
4e1e5f0073
15 changed files with 486 additions and 39 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 스킬이 자동으로 잡힌다`));
|
||||
|
|
|
|||
67
packages/cli/src/commands/tools.ts
Normal file
67
packages/cli/src/commands/tools.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ export async function runUninstall(opts: {
|
|||
);
|
||||
|
||||
if (targets.length === 0) {
|
||||
console.log(warn("제거할 설치 기록이 없다"));
|
||||
console.log(warn("제거할 설치 기록이 없습니다"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
29
packages/cli/test/tools.test.ts
Normal file
29
packages/cli/test/tools.test.ts
Normal 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, "도움말에 명령조 표현이 남아 있다");
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@designpaca/core",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"description": "designpaca 설치 엔진 — 타깃 어댑터, 매니페스트, 드리프트 감지",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
129
packages/core/src/tools.ts
Normal file
129
packages/core/src/tools.ts
Normal file
|
|
@ -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<boolean>;
|
||||
install(): Promise<void>;
|
||||
/** 설치 후 사람이 직접 해야 하는 것이 있으면 여기에 */
|
||||
followUp?: string;
|
||||
}
|
||||
|
||||
/** npm 전역 루트. Windows 경로에 공백이 있어도 안전하게 얻는다 */
|
||||
async function npmRoot(): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await exec("npm", ["root", "-g"], { shell: true });
|
||||
return stdout.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function exists(p: string): Promise<boolean> {
|
||||
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<ToolStatus[]> {
|
||||
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);
|
||||
}
|
||||
|
|
@ -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` 참조.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@designpaca/skill",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"description": "designpaca 스킬 원본 — SKILL.md 와 참조 문서",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue