#!/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` 모드는 한 턴을 실행하고 종료하므로
// 턴이 끝날 때까지 stdout NDJSON 을 그대로 기록하고, 턴마다 작업 디렉터리 스냅샷
// 차이를 eval_turn_end 표식으로 남긴다. 질문 여부·턴 종료 판정은 run.mjs 가
// lib/judge.mjs 로 원본 스트림을 다시 읽어 내린다(러너는 판정하지 않는다).
//
// 격리: --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 { readFollowUps } from "./lib/followups.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;
}
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;
const followUps = readFollowUps(args["follow-ups"]);
if (!workdir || !prompt || !outPath) {
console.error("사용법: run-claude.mjs --workdir
--scenario --prompt --out [--log ] [--model ] [--permission-mode ] [--timeout-ms ] [--follow-ups ]");
process.exit(2);
}
const result = {
scenario,
runner: "claude",
model,
permissionMode: permissionMode || "manual(default)",
sessionId: null,
turnsRun: 0,
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;
// 한 턴 = `claude -p` 한 번. 질문이 나와도 죽이지 않고 턴이 끝날 때까지 관찰한다(01 F13: 질문 직후
// 같은 턴에서 파일을 쓰는 규칙 4 위반은 죽이면 측정되지 않는다). 후속 답은 --resume 으로 이어 간다.
async function runTurn(text, resumeId) {
const claudeArgs = ["-p", text, "--output-format", "stream-json", "--verbose", "--setting-sources", "project", "--strict-mcp-config", "--model", model];
if (permissionMode) claudeArgs.push("--permission-mode", permissionMode);
if (resumeId) claudeArgs.push("--resume", resumeId);
const snapshotBefore = snapshotDir(workdir);
const child = spawn(claudeBin, claudeArgs, { cwd: workdir, stdio: ["ignore", "pipe", "pipe"], shell: useShell });
const timer = setTimeout(() => {
result.killedReason = "timeout";
try {
child.kill("SIGTERM");
} catch {
// 무시
}
setTimeout(() => {
try {
if (!child.killed) child.kill("SIGKILL");
} catch {
// 무시
}
}, 3000);
}, timeoutMs);
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 = result.initTools || evt.tools || null;
result.sessionId = result.sessionId || evt.session_id || null;
} else if (evt.type === "assistant") {
for (const block of evt.message?.content || []) if (block.type === "tool_use") result.toolsUsed.push(block.name);
} else if (evt.type === "result") {
result.finalMessageText = evt.result || null;
result.sessionId = result.sessionId || evt.session_id || 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(timer);
const changed = diffSnapshots(snapshotBefore, snapshotDir(workdir));
if (logStream) logStream.write(JSON.stringify({ type: "eval_turn_end", filesChanged: [...changed.added, ...changed.modified] }) + "\n");
if (!result.error && stderrBuf.trim()) result.stderrTail = stderrBuf.slice(-2000);
result.turnsRun++;
}
await runTurn(prompt, null);
for (const answer of followUps) {
if (result.error || result.killedReason === "timeout" || !result.sessionId) break;
if (logStream) logStream.write(JSON.stringify({ type: "eval_user", text: answer, via: "resume" }) + "\n");
await runTurn(answer, result.sessionId);
}
if (logStream) await new Promise((r) => logStream.end(r));
result.durationMs = Date.now() - startedAt;
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);
});