#!/usr/bin/env node // Claude Code CLI 러너 — designpaca 인터뷰 게이트 평가. // // Claude Agent SDK(@anthropic-ai/claude-agent-sdk)는 공식 quickstart 기준 // ANTHROPIC_API_KEY 인증을 요구한다(code.claude.com/docs/en/agent-sdk/quickstart). // 이 저장소가 도는 환경에는 그 키가 없으므로, 이미 로그인된 `claude` CLI를 // 비대화형(-p)으로 직접 호출한다. `-p` 모드는 한 턴을 실행하고 종료하므로 // SDK의 canUseTool 훅 없이도 "질문이 오면 그 시점에서 측정을 끝낸다"를 // stdout NDJSON을 실시간으로 읽으면서 흉내 낸다: 질문으로 보이는 이벤트가 // 나오는 즉시 작업 디렉터리를 스냅샷하고 프로세스를 죽인다. // // 격리: --setting-sources project 로 사용자(user)·로컬(local) 설정을 빼고 // 프로젝트 설정만 읽는다. 이 작업 디렉터리에는 우리가 넣은 // .claude/skills/designpaca 하나만 있어야 하므로, 전역 ~/.claude/CLAUDE.md와 // 전역에 설치된 designpaca가 섞이지 않는다. --strict-mcp-config로 잡다한 // MCP 서버 연결도 끊는다. // // 단독 실행: // node build/eval/interview/run-claude.mjs \ // --workdir <절대경로> --scenario S1 --prompt "꽃집 홍보 사이트 하나 만들어줘." \ // --model sonnet --permission-mode default --out result.json --log raw.ndjson import { spawn, execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import readline from "node:readline"; import { snapshotDir, diffSnapshots } from "./lib/fsutil.mjs"; import { scoreQuestionText } from "./lib/text-heuristic.mjs"; /** * Windows 에서 `spawn(..., {shell:true})`는 target이 .exe라도 항상 cmd.exe를 * 한 겹 거친다. cmd.exe의 명령줄 재파싱이 공백·따옴표·일부 영문 대문자 * 조합이 섞인 한국어 프롬프트를 실측으로 깨뜨렸다(예: "개인 포트폴리오... * UX 디자이너야." 가 "개인" 한 단어로 잘려 들어간 사례를 baseline 실행에서 * 발견했다). `claude` 는 npm .cmd 셈이 아니라 네이티브 .exe라서, `where`로 * 실제 .exe 경로를 찾아 셸 없이(shell:false) 직접 spawn하면 이 문제를 피한다. */ function resolveClaudeBinaryWindows() { try { const out = execFileSync("where", ["claude"], { encoding: "utf8" }); const exe = out.split(/\r?\n/).map((l) => l.trim()).find((l) => l.toLowerCase().endsWith(".exe")); return exe || null; } catch { return null; } } function parseArgs(argv) { const out = {}; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a.startsWith("--")) { const key = a.slice(2); const next = argv[i + 1]; if (next === undefined || next.startsWith("--")) { out[key] = true; } else { out[key] = next; i++; } } } return out; } const WRITE_LIKE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]); async function main() { const args = parseArgs(process.argv.slice(2)); const workdir = args.workdir; const scenario = args.scenario || "unknown"; const prompt = args.prompt; const model = args.model || "sonnet"; const permissionMode = args["permission-mode"] || null; // 생략하면 claude 기본값(manual) const timeoutMs = Number(args["timeout-ms"] || 300000); const outPath = args.out; const logPath = args.log; if (!workdir || !prompt || !outPath) { console.error("사용법: run-claude.mjs --workdir --scenario --prompt --out [--log ] [--model ] [--permission-mode ] [--timeout-ms ]"); process.exit(2); } const claudeArgs = [ "-p", prompt, "--output-format", "stream-json", "--verbose", "--setting-sources", "project", "--strict-mcp-config", "--model", model, ]; if (permissionMode) { claudeArgs.push("--permission-mode", permissionMode); } const snapshotBefore = snapshotDir(workdir); const result = { scenario, runner: "claude", model, permissionMode: permissionMode || "manual(default)", asked: false, askChannel: null, questionText: null, wroteBeforeAnswer: null, firstVisibleAction: null, finalMessageText: null, toolsUsed: [], initTools: null, durationMs: null, killedReason: null, exitCode: null, error: null, }; const logStream = logPath ? fs.createWriteStream(logPath, { flags: "a" }) : null; const startedAt = Date.now(); const isWin = process.platform === "win32"; let claudeBin = "claude"; let useShell = isWin; if (isWin) { const resolved = resolveClaudeBinaryWindows(); if (resolved) { claudeBin = resolved; useShell = false; } } result.claudeBinaryUsed = claudeBin; const child = spawn(claudeBin, claudeArgs, { cwd: workdir, stdio: ["ignore", "pipe", "pipe"], shell: useShell, }); let killedByUs = false; function killChild(reason) { if (killedByUs || child.killed) return; killedByUs = true; result.killedReason = reason; try { child.kill("SIGTERM"); } catch { // 이미 종료된 경우 무시 } setTimeout(() => { try { if (!child.killed) child.kill("SIGKILL"); } catch { // 무시 } }, 3000); } const overallTimeout = setTimeout(() => killChild("timeout"), timeoutMs); function markAskedIfNeeded(channel, text) { if (result.asked) return; result.asked = true; result.askChannel = channel; result.questionText = text; const after = snapshotDir(workdir); result.wroteBeforeAnswer = diffSnapshots(snapshotBefore, after).count; killChild("asked"); } let stderrBuf = ""; child.stderr.on("data", (chunk) => { stderrBuf += chunk.toString(); }); const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity }); rl.on("line", (line) => { if (logStream) logStream.write(line + "\n"); if (!line.trim()) return; let evt; try { evt = JSON.parse(line); } catch { return; } if (evt.type === "system" && evt.subtype === "init") { result.initTools = evt.tools || null; return; } if (evt.type === "assistant") { const content = (evt.message && evt.message.content) || []; for (const block of content) { if (block.type === "text" && block.text) { if (!result.firstVisibleAction) { result.firstVisibleAction = `text:${block.text.slice(0, 120)}`; } const score = scoreQuestionText(block.text); if (score.isQuestion) { markAskedIfNeeded("text", block.text); } } else if (block.type === "tool_use") { result.toolsUsed.push(block.name); if (!result.firstVisibleAction) { result.firstVisibleAction = `tool:${block.name}`; } if (block.name === "AskUserQuestion") { const qText = JSON.stringify(block.input); markAskedIfNeeded("tool", qText); } } } return; } if (evt.type === "result") { result.finalMessageText = evt.result || null; } }); await new Promise((resolve) => { child.on("close", (code) => { result.exitCode = code; resolve(); }); child.on("error", (err) => { result.error = String(err && err.message ? err.message : err); resolve(); }); }); clearTimeout(overallTimeout); if (logStream) logStream.end(); if (result.wroteBeforeAnswer === null) { // 끝까지 질문이 없었던 경우: 전체 실행 동안의 변경 개수를 기록한다. const after = snapshotDir(workdir); result.wroteBeforeAnswer = diffSnapshots(snapshotBefore, after).count; } result.durationMs = Date.now() - startedAt; if (!result.error && stderrBuf.trim()) { result.stderrTail = stderrBuf.slice(-2000); } fs.mkdirSync(path.dirname(outPath), { recursive: true }); fs.writeFileSync(outPath, JSON.stringify(result, null, 2)); console.log(JSON.stringify(result)); } main().catch((err) => { console.error("run-claude.mjs 실패:", err); process.exit(1); });