cdsa-harness 0.14.2 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -3
- package/package.json +1 -1
- package/src/builtins.js +1 -1
- package/src/cli.js +395 -68
- package/src/completion.js +56 -0
- package/src/config.js +4 -1
- package/src/llm.js +12 -2
- package/src/loop.js +118 -19
- package/src/tools.js +147 -3
- package/src/ui.js +51 -0
package/src/cli.js
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
loadConfig,
|
|
18
18
|
saveConfig,
|
|
19
19
|
} from "./config.js";
|
|
20
|
-
import { execFileSync } from "node:child_process";
|
|
20
|
+
import { execFileSync, execSync } from "node:child_process";
|
|
21
21
|
|
|
22
22
|
import { AgentLoop, Step } from "./loop.js";
|
|
23
23
|
import { LLMClient } from "./llm.js";
|
|
@@ -26,7 +26,8 @@ import { discoverNpmExtensions, loadPlugins } from "./plugins.js";
|
|
|
26
26
|
import { SessionLog, sessionsDir } from "./session.js";
|
|
27
27
|
import { loadSkills, renderSkill } from "./skills.js";
|
|
28
28
|
import { Toolbox } from "./tools.js";
|
|
29
|
-
import { c, panel, renderDiff, setColor } from "./ui.js";
|
|
29
|
+
import { c, panel, renderDiff, renderMarkdown, setColor } from "./ui.js";
|
|
30
|
+
import { makeCompleter } from "./completion.js";
|
|
30
31
|
|
|
31
32
|
// VERSION 은 src/builtins.js(생성물)에서 가져온다 — npm/exe 양쪽에서 동일.
|
|
32
33
|
|
|
@@ -87,8 +88,8 @@ function printTeach(ev, stream) {
|
|
|
87
88
|
lines.push(` ${roleColor(m.role.padEnd(9))} ${c.grey(`${m.chars}자${m.extra || ""}`)}`);
|
|
88
89
|
}
|
|
89
90
|
lines.push(c.grey(`제공 도구(${d.tools?.length || 0}): ${(d.tools || []).join(", ")}`));
|
|
90
|
-
console.log(panel(lines, { title:
|
|
91
|
-
if (d.systemPrompt) {
|
|
91
|
+
console.log(panel(lines, { title: `${d.sub ? "┆ " : ""}🧠 ② LLM 호출 — 반복 ${d.iteration}`, color: "magenta" }));
|
|
92
|
+
if (d.systemPrompt && !d.sub) {
|
|
92
93
|
console.log(panel(clip(d.systemPrompt, 600).split("\n"), {
|
|
93
94
|
title: "📜 시스템 프롬프트 (규칙+폴더가 여기 주입됨)",
|
|
94
95
|
color: "grey",
|
|
@@ -112,14 +113,14 @@ function printTeach(ev, stream) {
|
|
|
112
113
|
return;
|
|
113
114
|
}
|
|
114
115
|
const lines = [];
|
|
115
|
-
if (ev.detail && ev.detail !== "(텍스트 없음)") lines.push(...clip(ev.detail, 1200)
|
|
116
|
+
if (ev.detail && ev.detail !== "(텍스트 없음)") lines.push(...renderMarkdown(clip(ev.detail, 1200)));
|
|
116
117
|
for (const tc of d.toolCalls || []) {
|
|
117
118
|
lines.push(c.yellow(`↳ 도구 호출 요청: ${c.bold(tc.name)}(${clip(JSON.stringify(tc.args), 200)})`));
|
|
118
119
|
}
|
|
119
120
|
const meta = replyMetaLine(d);
|
|
120
121
|
if (meta) lines.push(c.grey("─ " + meta));
|
|
121
122
|
else lines.push(c.dim("(mock: 토큰/지연 측정 없음)"));
|
|
122
|
-
console.log(panel(lines.length ? lines : ["(빈 응답)"], { title: "🤖 ③ 모델 응답 (원본 판단)
|
|
123
|
+
console.log(panel(lines.length ? lines : ["(빈 응답)"], { title: `${d.sub ? "┆ " : ""}🤖 ③ 모델 응답 (원본 판단)`, color: "green" }));
|
|
123
124
|
return;
|
|
124
125
|
}
|
|
125
126
|
|
|
@@ -145,7 +146,8 @@ function printTeach(ev, stream) {
|
|
|
145
146
|
return;
|
|
146
147
|
|
|
147
148
|
case Step.DONE:
|
|
148
|
-
|
|
149
|
+
if (d.sub) { console.log(c.grey("┆ ") + c.green("✅ 서브에이전트 완료")); return; }
|
|
150
|
+
console.log(panel(renderMarkdown(ev.detail || "완료"), { title: "✅ 완료", color: "green" }));
|
|
149
151
|
return;
|
|
150
152
|
|
|
151
153
|
case Step.ERROR:
|
|
@@ -166,10 +168,13 @@ function printCompact(ev, stream) {
|
|
|
166
168
|
for (const tc of d.toolCalls || []) console.log(c.yellow(` ↳ ${tc.name}(${clip(JSON.stringify(tc.args), 120)})`));
|
|
167
169
|
return;
|
|
168
170
|
}
|
|
169
|
-
if (ev.detail && ev.detail !== "(텍스트 없음)") console.log(panel(ev.detail
|
|
171
|
+
if (ev.detail && ev.detail !== "(텍스트 없음)") console.log(panel(renderMarkdown(ev.detail), { title: `${d.sub ? "┆ " : ""}🤖 모델`, color: "green" }));
|
|
170
172
|
return;
|
|
171
173
|
}
|
|
172
|
-
if (ev.step === Step.DONE)
|
|
174
|
+
if (ev.step === Step.DONE) {
|
|
175
|
+
if (ev.data && ev.data.sub) return console.log(c.grey("┆ ") + c.green("✅ 서브에이전트 완료"));
|
|
176
|
+
return console.log(panel(renderMarkdown(ev.detail || "완료"), { title: "✅ 완료", color: "green" }));
|
|
177
|
+
}
|
|
173
178
|
if (ev.step === Step.ERROR) return console.log(panel((ev.detail || "").split("\n"), { title: `❌ ${ev.title}`, color: "red" }));
|
|
174
179
|
if (ev.step === Step.FEEDBACK) return;
|
|
175
180
|
let detail = clip((ev.detail || "").trim().replace(/\s+/g, " "), 110);
|
|
@@ -178,11 +183,14 @@ function printCompact(ev, stream) {
|
|
|
178
183
|
console.log(line);
|
|
179
184
|
}
|
|
180
185
|
|
|
181
|
-
function makeApproval(ask) {
|
|
186
|
+
function makeApproval(ask, cfg) {
|
|
187
|
+
let streak = 0; // 연속 승인 횟수 — 신뢰가 쌓이면 자동 수락을 제안한다
|
|
188
|
+
let hinted = false;
|
|
182
189
|
return async (req) => {
|
|
183
|
-
if (req.toolName === "write_file") {
|
|
190
|
+
if (req.toolName === "write_file" || req.toolName === "edit_file") {
|
|
191
|
+
const label = req.toolName === "edit_file" ? "부분 수정 제안" : "파일 쓰기 제안";
|
|
184
192
|
console.log(panel(renderDiff(req.diff || "(변경 미리보기 없음)"), {
|
|
185
|
-
title: `🔐
|
|
193
|
+
title: `🔐 ${label} — ${req.path}`,
|
|
186
194
|
color: "yellow",
|
|
187
195
|
}));
|
|
188
196
|
} else if (req.toolName === "run_shell") {
|
|
@@ -193,6 +201,11 @@ function makeApproval(ask) {
|
|
|
193
201
|
const raw = await ask(c.yellow("이 작업을 승인하시겠습니까? [y/N] "));
|
|
194
202
|
const ans = (raw || "").trim().toLowerCase();
|
|
195
203
|
const approved = ans === "y" || ans === "yes";
|
|
204
|
+
streak = approved ? streak + 1 : 0;
|
|
205
|
+
if (approved && streak >= 3 && !hinted && cfg.approval_mode === "manual") {
|
|
206
|
+
hinted = true;
|
|
207
|
+
console.log(c.dim("💡 계속 승인 중이시네요 — 신뢰가 쌓였다면 ") + c.cyan("/auto") + c.dim(" 로 자동 수락을 켤 수 있어요."));
|
|
208
|
+
}
|
|
196
209
|
return { approved, reason: approved ? "" : "사용자가 거부했습니다." };
|
|
197
210
|
};
|
|
198
211
|
}
|
|
@@ -256,6 +269,78 @@ async function buildExtensions(cfg, mcp) {
|
|
|
256
269
|
return { toolbox, skills };
|
|
257
270
|
}
|
|
258
271
|
|
|
272
|
+
async function fetchJson(url, headers = {}, timeoutMs = 4000) {
|
|
273
|
+
const ctrl = new AbortController();
|
|
274
|
+
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
275
|
+
try {
|
|
276
|
+
const res = await fetch(url, { headers, signal: ctrl.signal });
|
|
277
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
278
|
+
return await res.json();
|
|
279
|
+
} finally {
|
|
280
|
+
clearTimeout(t);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// 제공자별 '쓸 수 있는 모델' 목록. 가능하면 실시간(live), 실패 시 추천 목록으로 폴백.
|
|
285
|
+
async function fetchProviderModels(cfg, query = "") {
|
|
286
|
+
const q = query.trim().toLowerCase();
|
|
287
|
+
const key = cfg.resolvedKey();
|
|
288
|
+
try {
|
|
289
|
+
if (cfg.provider === "openrouter") {
|
|
290
|
+
const r = await fetchJson("https://openrouter.ai/api/v1/models");
|
|
291
|
+
let ids = (r.data || []).map((m) => m.id).sort();
|
|
292
|
+
if (q) ids = ids.filter((id) => id.toLowerCase().includes(q));
|
|
293
|
+
return { list: ids, live: true };
|
|
294
|
+
}
|
|
295
|
+
if (cfg.provider === "ollama") {
|
|
296
|
+
const host = (cfg.base_url || "http://localhost:11434/v1/chat/completions").replace(/\/v1\/.*$/, "");
|
|
297
|
+
const r = await fetchJson(`${host}/api/tags`, {}, 2500);
|
|
298
|
+
let ids = (r.models || []).map((m) => m.name);
|
|
299
|
+
if (q) ids = ids.filter((id) => id.toLowerCase().includes(q));
|
|
300
|
+
return { list: ids, live: true };
|
|
301
|
+
}
|
|
302
|
+
if (cfg.provider === "openai" && key) {
|
|
303
|
+
const r = await fetchJson("https://api.openai.com/v1/models", { Authorization: `Bearer ${key}` });
|
|
304
|
+
let ids = (r.data || []).map((m) => m.id).filter((id) => /^(gpt|o\d)/.test(id)).sort();
|
|
305
|
+
if (q) ids = ids.filter((id) => id.toLowerCase().includes(q));
|
|
306
|
+
return { list: ids, live: true };
|
|
307
|
+
}
|
|
308
|
+
if (cfg.provider === "anthropic" && key) {
|
|
309
|
+
const r = await fetchJson("https://api.anthropic.com/v1/models", { "x-api-key": key, "anthropic-version": "2023-06-01" });
|
|
310
|
+
let ids = (r.data || []).map((m) => m.id);
|
|
311
|
+
if (q) ids = ids.filter((id) => id.toLowerCase().includes(q));
|
|
312
|
+
return { list: ids, live: true };
|
|
313
|
+
}
|
|
314
|
+
} catch { /* 폴백으로 */ }
|
|
315
|
+
let list = SUGGESTED_MODELS[cfg.provider] || [];
|
|
316
|
+
if (q) list = list.filter((m) => m.toLowerCase().includes(q));
|
|
317
|
+
return { list, live: false };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// 번호로 고르는 모델 선택 UI. 선택하면 모델명, 취소하면 null 반환.
|
|
321
|
+
async function pickModel(ask, cfg, query = "") {
|
|
322
|
+
process.stdout.write(c.dim("모델 목록을 불러오는 중…\r"));
|
|
323
|
+
const { list, live } = await fetchProviderModels(cfg, query);
|
|
324
|
+
if (!list.length) {
|
|
325
|
+
console.log(c.yellow(`'${query}' 에 맞는 모델이 없습니다.`) + c.dim(" 예: /models claude · /models gpt"));
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
const shown = list.slice(0, 15);
|
|
329
|
+
const lines = shown.map((m, i) => {
|
|
330
|
+
const cur = m === cfg.model ? c.green(" ← 현재") : "";
|
|
331
|
+
return ` ${c.bold(String(i + 1).padStart(2))}) ${m}${cur}`;
|
|
332
|
+
});
|
|
333
|
+
if (list.length > shown.length) lines.push(c.dim(` … 외 ${list.length - shown.length}개 — '/models <검색어>' 로 좁히세요`));
|
|
334
|
+
lines.push(c.dim(live ? " (실시간 목록)" : " (추천 목록 — 키 연결 시 실시간 조회)"));
|
|
335
|
+
console.log(panel(lines, { title: `🤖 모델 선택 — ${cfg.provider}`, color: "cyan" }));
|
|
336
|
+
const a = await ask(c.cyan("번호 또는 모델명 입력 (엔터=취소): "));
|
|
337
|
+
if (a === null || !a.trim()) return null;
|
|
338
|
+
const s = a.trim();
|
|
339
|
+
const n = parseInt(s, 10);
|
|
340
|
+
if (Number.isInteger(n) && n >= 1 && n <= shown.length) return shown[n - 1];
|
|
341
|
+
return s; // 직접 입력한 이름 허용
|
|
342
|
+
}
|
|
343
|
+
|
|
259
344
|
function makeClient(cfg) {
|
|
260
345
|
return new LLMClient({
|
|
261
346
|
provider: cfg.provider,
|
|
@@ -271,7 +356,11 @@ function printIntro(cfg) {
|
|
|
271
356
|
console.log(renderBanner());
|
|
272
357
|
console.log(c.dim("AI 에이전트의 내부 동작을 단계별로 드러내는 교육용 하네스") + " " + c.cyan("· made by CDSA"));
|
|
273
358
|
console.log();
|
|
274
|
-
const keySource =
|
|
359
|
+
const keySource =
|
|
360
|
+
cfg.provider === "mock" ? "-"
|
|
361
|
+
: cfg.provider === "ollama" ? "불필요(로컬)"
|
|
362
|
+
: cfg.api_key ? "config.json"
|
|
363
|
+
: ENV_KEYS[cfg.provider] && process.env[ENV_KEYS[cfg.provider]] ? `환경변수 ${ENV_KEYS[cfg.provider]}` : c.red("없음");
|
|
275
364
|
const rows = [
|
|
276
365
|
["버전", `v${VERSION}`],
|
|
277
366
|
["provider", cfg.provider],
|
|
@@ -384,25 +473,26 @@ function printHelp() {
|
|
|
384
473
|
[
|
|
385
474
|
c.bold("사용법"),
|
|
386
475
|
`시키고 싶은 일을 한국어로 입력하세요. 예) ${c.cyan("notes.txt 맨 아래에 할 일 3개 추가해줘")}`,
|
|
476
|
+
`${c.cyan("@파일명")} 을 쓰면 그 파일 내용이 자동 첨부됩니다. 예) ${c.cyan("@memo.txt 이거 요약해줘")}`,
|
|
477
|
+
"",
|
|
478
|
+
c.bold("대화"),
|
|
479
|
+
` ${c.cyan("/new")}(=/clear) 새 대화 · ${c.cyan("/compact")} 대화를 모델이 요약해 압축 · ${c.cyan("/resume")} 지난 대화 이어가기`,
|
|
480
|
+
` ${c.cyan("/undo")} 마지막 파일 변경 되돌리기 · ${c.cyan("/context")} 모델에 보내는 컨텍스트 보기`,
|
|
387
481
|
"",
|
|
388
|
-
c.bold("
|
|
389
|
-
` ${c.cyan("/
|
|
390
|
-
` ${c.cyan("/tutorial")} 단계별 인터랙티브 튜토리얼`,
|
|
391
|
-
` ${c.cyan("/color")} 색상 켜기/끄기(흑백)`,
|
|
392
|
-
` ${c.cyan("/about")} 이 도구 정보(made by CDSA)`,
|
|
393
|
-
` ${c.cyan("/setup")} 제공자·API 키·모델 연결(대화형)`,
|
|
394
|
-
` ${c.cyan("/provider")} <openai|anthropic|openrouter|mock> 제공자 변경`,
|
|
395
|
-
` ${c.cyan("/model")} <이름> 모델 변경`,
|
|
396
|
-
` ${c.cyan("/teach")} 교육 모드 켜기/끄기(내부 과정 펼쳐보기)`,
|
|
397
|
-
` ${c.cyan("/stream")} 실시간 스트리밍 출력 켜기/끄기`,
|
|
398
|
-
` ${c.cyan("/context")} 지금 모델에 보내는 컨텍스트 들여다보기`,
|
|
482
|
+
c.bold("프로젝트"),
|
|
483
|
+
` ${c.cyan("/init")} 모델이 폴더를 파악해 AGENT.md(프로젝트 규칙) 생성 · ${c.cyan("/memory")} 규칙 파일 보기`,
|
|
399
484
|
` ${c.cyan("/workspace")} <폴더> 작업 폴더 보기/변경 ('.' = 현재 폴더)`,
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
` ${c.cyan("/
|
|
403
|
-
` ${c.cyan("/
|
|
404
|
-
` ${c.cyan("/
|
|
405
|
-
|
|
485
|
+
"",
|
|
486
|
+
c.bold("모델·설정"),
|
|
487
|
+
` ${c.cyan("/setup")} 연결 마법사 · ${c.cyan("/model")} <이름> 변경 · ${c.cyan("/models")} 목록(ollama 는 설치분) · ${c.cyan("/provider")} 전환`,
|
|
488
|
+
` ${c.cyan("/auto")} 자동 수락 토글 · ${c.cyan("/status")}(=/cost) 상태·누적 토큰 · ${c.cyan("/update")} 자기 업데이트`,
|
|
489
|
+
` ${c.cyan("/teach")} 내부과정 펼치기 · ${c.cyan("/stream")} 실시간 출력 · ${c.cyan("/color")} 색상`,
|
|
490
|
+
"",
|
|
491
|
+
c.bold("확장"),
|
|
492
|
+
` ${c.cyan("/skills")} 스킬 목록 · ${c.cyan("/plugins")} 플러그인 · ${c.cyan("/mcp")} MCP 서버`,
|
|
493
|
+
"",
|
|
494
|
+
c.bold("기타"),
|
|
495
|
+
` ${c.cyan("/guide")} 빠른 시작 · ${c.cyan("/tutorial")} 튜토리얼 · ${c.cyan("/about")} 정보 · ${c.cyan("/quit")} 종료(Ctrl+D)`,
|
|
406
496
|
"",
|
|
407
497
|
c.bold("교육 모드에서 보이는 단계"),
|
|
408
498
|
" ① 입력 → ② LLM 호출(컨텍스트·도구) → ③ 모델 응답(토큰·지연) →",
|
|
@@ -427,13 +517,14 @@ async function runSetup(ask, cfg) {
|
|
|
427
517
|
` ${c.bold("1")}) openai (GPT, 키: ${ENV_KEYS.openai})`,
|
|
428
518
|
` ${c.bold("2")}) anthropic (Claude, 키: ${ENV_KEYS.anthropic})`,
|
|
429
519
|
` ${c.bold("3")}) openrouter (여러 모델 중계, 키: ${ENV_KEYS.openrouter})`,
|
|
430
|
-
` ${c.bold("4")})
|
|
520
|
+
` ${c.bold("4")}) ollama (로컬·폐쇄망 LLM, 키 불필요) 🏢`,
|
|
521
|
+
` ${c.bold("5")}) mock (키 없이 연습)`,
|
|
431
522
|
],
|
|
432
523
|
{ title: "🔌 연결 설정 (/setup)", color: "cyan" }
|
|
433
524
|
));
|
|
434
|
-
const pickRaw = await ask(c.cyan("제공자 번호 [1-
|
|
525
|
+
const pickRaw = await ask(c.cyan("제공자 번호 [1-5] (취소: Enter): "));
|
|
435
526
|
if (pickRaw === null) return false;
|
|
436
|
-
const provider = { "1": "openai", "2": "anthropic", "3": "openrouter", "4": "mock" }[pickRaw.trim()];
|
|
527
|
+
const provider = { "1": "openai", "2": "anthropic", "3": "openrouter", "4": "ollama", "5": "mock" }[pickRaw.trim()];
|
|
437
528
|
if (!provider) {
|
|
438
529
|
console.log(c.yellow("취소했습니다."));
|
|
439
530
|
return false;
|
|
@@ -442,6 +533,29 @@ async function runSetup(ask, cfg) {
|
|
|
442
533
|
|
|
443
534
|
if (provider === "mock") {
|
|
444
535
|
cfg.model = "mock-agent";
|
|
536
|
+
} else if (provider === "ollama") {
|
|
537
|
+
const hostAns = await ask(c.cyan("Ollama 주소 [http://localhost:11434]: "));
|
|
538
|
+
if (hostAns === null) return false;
|
|
539
|
+
const host = (hostAns.trim() || "http://localhost:11434").replace(/\/+$/, "");
|
|
540
|
+
cfg.base_url = `${host}/v1/chat/completions`;
|
|
541
|
+
cfg.api_key = "ollama"; // 로컬 LLM 은 키가 필요 없다(호환용 자리표시)
|
|
542
|
+
// 설치된 모델 자동 감지(/api/tags) — 실패해도 조용히 추천 목록 사용
|
|
543
|
+
let installed = [];
|
|
544
|
+
try {
|
|
545
|
+
const ctrl = new AbortController();
|
|
546
|
+
const t = setTimeout(() => ctrl.abort(), 2000);
|
|
547
|
+
const res = await fetch(`${host}/api/tags`, { signal: ctrl.signal });
|
|
548
|
+
clearTimeout(t);
|
|
549
|
+
if (res.ok) installed = ((await res.json()).models || []).map((m) => m.name);
|
|
550
|
+
} catch {
|
|
551
|
+
console.log(c.yellow(" (Ollama 에 연결하지 못했어요 — `ollama serve` 실행 여부를 확인하세요. 계속 진행합니다.)"));
|
|
552
|
+
}
|
|
553
|
+
if (installed.length) console.log(c.green(` 설치된 모델: ${installed.slice(0, 8).join(", ")}`));
|
|
554
|
+
const sugg = installed.length ? installed : SUGGESTED_MODELS.ollama;
|
|
555
|
+
const def = sugg[0];
|
|
556
|
+
const m = await ask(c.cyan(`모델 [${def}]: `));
|
|
557
|
+
if (m === null) return false;
|
|
558
|
+
cfg.model = m.trim() || def;
|
|
445
559
|
} else {
|
|
446
560
|
const envName = ENV_KEYS[provider];
|
|
447
561
|
const envVal = (process.env[envName] || "").trim();
|
|
@@ -463,13 +577,9 @@ async function runSetup(ask, cfg) {
|
|
|
463
577
|
}
|
|
464
578
|
const sugg = SUGGESTED_MODELS[provider] || [];
|
|
465
579
|
const def = sugg[0] || "";
|
|
466
|
-
const
|
|
467
|
-
|
|
468
|
-
if (
|
|
469
|
-
console.log(c.yellow("취소했습니다."));
|
|
470
|
-
return false;
|
|
471
|
-
}
|
|
472
|
-
cfg.model = m.trim() || def;
|
|
580
|
+
const picked = await pickModel(ask, cfg); // 실시간 목록에서 번호로 선택
|
|
581
|
+
cfg.model = picked || def;
|
|
582
|
+
if (!picked) console.log(c.dim(`기본 모델 사용: ${def}`));
|
|
473
583
|
}
|
|
474
584
|
|
|
475
585
|
const saved = saveConfig(cfg);
|
|
@@ -504,7 +614,7 @@ export async function main(argv = []) {
|
|
|
504
614
|
"CDSA Harness — AI 에이전트 내부를 드러내는 교육용 하네스 (터미널)\n\n" +
|
|
505
615
|
"사용법: cdsa-harness [옵션]\n" +
|
|
506
616
|
" cdsa-harness add <npm-패키지> 플러그인 설치(이후 자동 로드)\n" +
|
|
507
|
-
" --provider <openai|anthropic|openrouter|mock>\n" +
|
|
617
|
+
" --provider <openai|anthropic|openrouter|ollama|mock>\n" +
|
|
508
618
|
" --model <모델명>\n" +
|
|
509
619
|
" --workspace <폴더경로>\n" +
|
|
510
620
|
" --setup 대화형 연결 설정 실행\n" +
|
|
@@ -546,12 +656,33 @@ export async function main(argv = []) {
|
|
|
546
656
|
if (args.noColor) cfg.no_color = true;
|
|
547
657
|
if (cfg.no_color) setColor(false); // 색상 끄기(흑백)
|
|
548
658
|
|
|
549
|
-
|
|
659
|
+
// 명령 히스토리(↑↓)를 세션 간에도 기억한다.
|
|
660
|
+
const histPath = path.join(configDir(), "history");
|
|
661
|
+
let savedHistory = [];
|
|
662
|
+
try {
|
|
663
|
+
savedHistory = fs.readFileSync(histPath, "utf8").split("\n").filter(Boolean).slice(0, 200);
|
|
664
|
+
} catch { /* 첫 실행 */ }
|
|
665
|
+
// Tab 자동완성: 슬래시 명령(내장+스킬) · /provider 인자 · @파일 멘션
|
|
666
|
+
let toolbox = null;
|
|
667
|
+
let skills = {};
|
|
668
|
+
const completer = makeCompleter({
|
|
669
|
+
skills: () => skills,
|
|
670
|
+
workspace: () => (toolbox ? toolbox.workspace : null),
|
|
671
|
+
providers: PROVIDERS,
|
|
672
|
+
});
|
|
673
|
+
const rl = readline.createInterface({ input: stdin, output: stdout, history: savedHistory, historySize: 200, completer });
|
|
674
|
+
const saveHistory = () => {
|
|
675
|
+
try {
|
|
676
|
+
fs.mkdirSync(configDir(), { recursive: true });
|
|
677
|
+
fs.writeFileSync(histPath, (rl.history || []).slice(0, 200).join("\n"), "utf8");
|
|
678
|
+
} catch { /* ignore */ }
|
|
679
|
+
};
|
|
550
680
|
let session = null;
|
|
551
681
|
let mcp = { tools: [], servers: [], errors: [], closeAll: () => {} };
|
|
552
682
|
|
|
553
683
|
// Ctrl+C → 깔끔하게 종료(스택트레이스 없이). 어디서 누르든 안전.
|
|
554
684
|
const gracefulExit = () => {
|
|
685
|
+
saveHistory();
|
|
555
686
|
try { rl.close(); } catch { /* */ }
|
|
556
687
|
try { session && session.close(); } catch { /* */ }
|
|
557
688
|
try { mcp && mcp.closeAll && mcp.closeAll(); } catch { /* */ }
|
|
@@ -559,14 +690,50 @@ export async function main(argv = []) {
|
|
|
559
690
|
process.exit(0);
|
|
560
691
|
};
|
|
561
692
|
rl.on("SIGINT", gracefulExit);
|
|
562
|
-
// 프롬프트
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
693
|
+
// 프롬프트 헬퍼. TTY 면 rl.question 그대로(히스토리·편집 지원).
|
|
694
|
+
// 파이프/스크립트 입력(non-TTY)은 줄을 큐로 받아 어떤 타이밍에 물어도 유실되지 않게 한다
|
|
695
|
+
// (모델 호출 중 도착한 승인 답변 줄이 사라지던 문제 해결 — CI/데모 자동화 가능).
|
|
696
|
+
let ask;
|
|
697
|
+
if (stdin.isTTY) {
|
|
698
|
+
ask = async (q) => {
|
|
699
|
+
try {
|
|
700
|
+
return await rl.question(q);
|
|
701
|
+
} catch {
|
|
702
|
+
return null;
|
|
703
|
+
}
|
|
704
|
+
};
|
|
705
|
+
} else {
|
|
706
|
+
const lineQueue = [];
|
|
707
|
+
let pendingResolve = null;
|
|
708
|
+
let closed = false;
|
|
709
|
+
rl.on("line", (l) => {
|
|
710
|
+
if (pendingResolve) {
|
|
711
|
+
const r = pendingResolve;
|
|
712
|
+
pendingResolve = null;
|
|
713
|
+
r(l);
|
|
714
|
+
} else lineQueue.push(l);
|
|
715
|
+
});
|
|
716
|
+
rl.on("close", () => {
|
|
717
|
+
closed = true;
|
|
718
|
+
if (pendingResolve) {
|
|
719
|
+
const r = pendingResolve;
|
|
720
|
+
pendingResolve = null;
|
|
721
|
+
r(null);
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
ask = (q) => {
|
|
725
|
+
process.stdout.write(q);
|
|
726
|
+
if (lineQueue.length) {
|
|
727
|
+
const l = lineQueue.shift();
|
|
728
|
+
process.stdout.write(l + "\n");
|
|
729
|
+
return Promise.resolve(l);
|
|
730
|
+
}
|
|
731
|
+
if (closed) return Promise.resolve(null);
|
|
732
|
+
return new Promise((res) => {
|
|
733
|
+
pendingResolve = res;
|
|
734
|
+
});
|
|
735
|
+
};
|
|
736
|
+
}
|
|
570
737
|
|
|
571
738
|
if (args.setup) {
|
|
572
739
|
await runSetup(ask, cfg);
|
|
@@ -582,7 +749,7 @@ export async function main(argv = []) {
|
|
|
582
749
|
if (newer) {
|
|
583
750
|
console.log(
|
|
584
751
|
c.yellow(`⬆️ 새 버전 v${newer} 가 나왔어요!`) +
|
|
585
|
-
c.dim(`
|
|
752
|
+
c.dim(` 지금 바로: `) + c.cyan("/update") + c.dim(` · exe 는 Releases 에서 새로 받기`) +
|
|
586
753
|
"\n"
|
|
587
754
|
);
|
|
588
755
|
}
|
|
@@ -593,7 +760,7 @@ export async function main(argv = []) {
|
|
|
593
760
|
mcp = await connectMcpServers(cfg.mcpServers);
|
|
594
761
|
}
|
|
595
762
|
// 플러그인·스킬·도구상자를 작업 폴더 기준으로 구성(작업 폴더 변경 시 재사용)
|
|
596
|
-
|
|
763
|
+
({ toolbox, skills } = await buildExtensions(cfg, mcp));
|
|
597
764
|
if (toolbox.plugins.length || Object.keys(skills).length || toolbox.pluginErrors.length || mcp.servers.length) {
|
|
598
765
|
const bits = [];
|
|
599
766
|
if (toolbox.plugins.length) bits.push(c.green(`도구 +${toolbox.plugins.length}개`));
|
|
@@ -618,7 +785,7 @@ export async function main(argv = []) {
|
|
|
618
785
|
client: makeClient(cfg),
|
|
619
786
|
toolbox,
|
|
620
787
|
onEvent: makePrinter(cfg, stream),
|
|
621
|
-
approvalCallback: makeApproval(ask),
|
|
788
|
+
approvalCallback: makeApproval(ask, cfg),
|
|
622
789
|
session,
|
|
623
790
|
onToken,
|
|
624
791
|
});
|
|
@@ -626,6 +793,37 @@ export async function main(argv = []) {
|
|
|
626
793
|
|
|
627
794
|
const rule = () => console.log(c.grey("─".repeat(Math.min(80, stdout.columns || 80))));
|
|
628
795
|
|
|
796
|
+
// @파일 멘션: 입력 속 @경로 가 작업 폴더의 실제 파일이면 내용을 프롬프트에 첨부한다.
|
|
797
|
+
const expandMentions = (text) => {
|
|
798
|
+
const tokens = [...text.matchAll(/@([\w가-힣./\\-]+)/g)].map((m) => m[1]);
|
|
799
|
+
let out = text;
|
|
800
|
+
for (const t of [...new Set(tokens)]) {
|
|
801
|
+
try {
|
|
802
|
+
const r = toolbox.readFile(t);
|
|
803
|
+
const clipped = (r.output || "").slice(0, 4000);
|
|
804
|
+
out += `\n\n[첨부 @${t}]\n${clipped}`;
|
|
805
|
+
console.log(c.dim(`📎 @${t} 첨부됨 (${clipped.length}자)`));
|
|
806
|
+
} catch { /* 파일이 아니면 무시(이메일 등) */ }
|
|
807
|
+
}
|
|
808
|
+
return out;
|
|
809
|
+
};
|
|
810
|
+
|
|
811
|
+
// 대화 자동저장(/resume 용) + 멘션 확장을 겸하는 실행 헬퍼.
|
|
812
|
+
const convPath = path.join(configDir(), "last_session.json");
|
|
813
|
+
const runTurn = async (promptText) => {
|
|
814
|
+
rule();
|
|
815
|
+
try {
|
|
816
|
+
await loop.run(expandMentions(promptText));
|
|
817
|
+
} catch (e) {
|
|
818
|
+
console.log(c.red(`실행 오류: ${e?.message || e}`));
|
|
819
|
+
}
|
|
820
|
+
rule();
|
|
821
|
+
try {
|
|
822
|
+
fs.mkdirSync(configDir(), { recursive: true });
|
|
823
|
+
fs.writeFileSync(convPath, JSON.stringify({ time: Date.now(), provider: cfg.provider, model: cfg.model, messages: loop.messages }), "utf8");
|
|
824
|
+
} catch { /* 저장 실패 무시 */ }
|
|
825
|
+
};
|
|
826
|
+
|
|
629
827
|
// 첫 실행 온보딩(한 번만 — ~/.cdsa_harness/.welcomed 표시): 작업 폴더 설정 + 튜토리얼
|
|
630
828
|
const markerPath = path.join(configDir(), ".welcomed");
|
|
631
829
|
if (stdin.isTTY && !fs.existsSync(markerPath)) {
|
|
@@ -695,7 +893,148 @@ export async function main(argv = []) {
|
|
|
695
893
|
], { title: "ℹ️ about", color: "cyan" }));
|
|
696
894
|
continue;
|
|
697
895
|
}
|
|
698
|
-
|
|
896
|
+
// /clear·/new — Claude Code·OpenCode 컨벤션(= 기존 /reset)
|
|
897
|
+
if (low === "/reset" || low === "/clear" || low === "/new") {
|
|
898
|
+
loop.reset();
|
|
899
|
+
console.log(c.green("새 대화를 시작합니다(컨텍스트 초기화)."));
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
// /compact — 대화를 '모델이 직접' 요약해 컨텍스트를 압축 (Claude Code 방식, 모델-퍼스트)
|
|
903
|
+
if (low === "/compact") {
|
|
904
|
+
if (loop.messages.length < 4) {
|
|
905
|
+
console.log(c.dim("아직 압축할 대화가 충분하지 않습니다."));
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
const before = loop.messages.length;
|
|
909
|
+
console.log(c.dim("모델에게 대화 요약을 맡겨 컨텍스트를 압축합니다…"));
|
|
910
|
+
try {
|
|
911
|
+
const req = [
|
|
912
|
+
...loop.messages,
|
|
913
|
+
{
|
|
914
|
+
role: "user",
|
|
915
|
+
content:
|
|
916
|
+
"지금까지의 대화를 다음 작업에 꼭 필요한 것만 남긴 한국어 요약으로 만들어줘: 확인된 사실, 내린 결정, 파일 변경 내역, 미완료 작업. 요약문만 출력해.",
|
|
917
|
+
},
|
|
918
|
+
];
|
|
919
|
+
const r = await loop.client.chat(req, []);
|
|
920
|
+
const summary = (r.content || "").trim();
|
|
921
|
+
if (!summary) throw new Error("빈 요약");
|
|
922
|
+
loop.reset();
|
|
923
|
+
loop.messages.push(
|
|
924
|
+
{ role: "user", content: `[이전 대화 요약]\n${summary}` },
|
|
925
|
+
{ role: "assistant", content: "요약을 확인했습니다. 이어서 진행하겠습니다." }
|
|
926
|
+
);
|
|
927
|
+
console.log(c.green(`압축 완료 — 메시지 ${before}개 → ${loop.messages.length}개`));
|
|
928
|
+
} catch (e) {
|
|
929
|
+
console.log(c.yellow(`압축 실패: ${e.message}`));
|
|
930
|
+
}
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
933
|
+
// /undo — 마지막 파일 변경 1건 되돌리기 (OpenCode 컨벤션)
|
|
934
|
+
if (low === "/undo") {
|
|
935
|
+
try {
|
|
936
|
+
console.log(c.green(toolbox.undoLast().output));
|
|
937
|
+
} catch (e) {
|
|
938
|
+
console.log(c.yellow(e.message));
|
|
939
|
+
}
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
// /update — 자기 자신을 최신 버전으로 업데이트(npm 설치본). exe 는 다운로드 안내.
|
|
943
|
+
if (low === "/update") {
|
|
944
|
+
let isSea = false;
|
|
945
|
+
try {
|
|
946
|
+
isSea = (await import("node:sea")).isSea();
|
|
947
|
+
} catch { /* 구버전 Node — SEA 아님 */ }
|
|
948
|
+
if (isSea) {
|
|
949
|
+
console.log(c.yellow("단일 실행파일(exe) 버전은 파일 교체 방식이라 자동 업데이트가 없어요."));
|
|
950
|
+
console.log(c.dim("최신 exe 다운로드: ") + c.cyan("https://github.com/cdsassj00/miniharness/releases/latest"));
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
console.log(c.cyan("최신 버전으로 업데이트합니다… (npm i -g cdsa-harness@latest)"));
|
|
954
|
+
try {
|
|
955
|
+
execSync("npm i -g cdsa-harness@latest", { stdio: "inherit" });
|
|
956
|
+
console.log(c.green("✅ 업데이트 완료! /quit 후 다시 실행하면 새 버전이 적용됩니다."));
|
|
957
|
+
} catch (e) {
|
|
958
|
+
console.log(c.red(`업데이트 실패: ${e.message}`));
|
|
959
|
+
console.log(c.dim("직접 실행: npm i -g cdsa-harness@latest"));
|
|
960
|
+
}
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
// /auto — 자동 수락 토글(신뢰 전환). Claude Code 의 auto-accept 에 해당.
|
|
964
|
+
if (low === "/auto") {
|
|
965
|
+
cfg.approval_mode = cfg.approval_mode === "auto" ? "manual" : "auto";
|
|
966
|
+
console.log(
|
|
967
|
+
cfg.approval_mode === "auto"
|
|
968
|
+
? c.green("자동 수락 ON — 파일 수정·셸을 묻지 않고 실행합니다. (해제: /auto)")
|
|
969
|
+
: c.green("자동 수락 OFF — 변경 전 diff 를 보여주고 승인을 받습니다.")
|
|
970
|
+
);
|
|
971
|
+
continue;
|
|
972
|
+
}
|
|
973
|
+
// /init — '모델에게' 폴더를 파악시켜 AGENT.md 프로젝트 규칙을 만들게 한다 (Claude Code /init)
|
|
974
|
+
if (low === "/init") {
|
|
975
|
+
await runTurn(
|
|
976
|
+
"이 작업 폴더를 list_dir/read_file/search_files 로 파악한 뒤, 앞으로의 에이전트 작업 지침이 될 " +
|
|
977
|
+
"AGENT.md 파일을 write_file 로 생성해줘. 포함: ①프로젝트/폴더 개요 ②작업 시 지켜야 할 규칙(말투·제약·주의) " +
|
|
978
|
+
"③자주 할 작업 예시. 이미 AGENT.md 가 있으면 읽고 부족한 부분만 개선해."
|
|
979
|
+
);
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
// /status·/cost — 현재 상태 + 세션 누적 토큰 (Claude Code 컨벤션)
|
|
983
|
+
if (low === "/status" || low === "/cost") {
|
|
984
|
+
const u = loop.usage;
|
|
985
|
+
console.log(
|
|
986
|
+
panel(
|
|
987
|
+
[
|
|
988
|
+
`${c.grey("모델".padEnd(6))} ${c.bold(`${cfg.provider} · ${cfg.model}`)}`,
|
|
989
|
+
`${c.grey("폴더".padEnd(6))} ${cfg.workspacePath()}`,
|
|
990
|
+
`${c.grey("승인".padEnd(6))} ${cfg.approval_mode === "auto" ? c.yellow("자동 수락") : "수동(diff 확인)"} · 셸 ${cfg.allow_shell ? "허용" : "차단"}`,
|
|
991
|
+
`${c.grey("대화".padEnd(6))} 메시지 ${loop.messages.length}개 (/compact 로 압축 가능)`,
|
|
992
|
+
`${c.grey("토큰".padEnd(6))} 입력 ${u.input.toLocaleString()} · 출력 ${u.output.toLocaleString()} · 합계 ${c.bold(u.total.toLocaleString())} (호출 ${u.calls}회)`,
|
|
993
|
+
],
|
|
994
|
+
{ title: "📊 상태 (/status)", color: "cyan" }
|
|
995
|
+
)
|
|
996
|
+
);
|
|
997
|
+
continue;
|
|
998
|
+
}
|
|
999
|
+
// /models [검색어] — 모델 목록에서 번호로 선택(제공자별 실시간 조회)
|
|
1000
|
+
if (low === "/models" || low.startsWith("/models ")) {
|
|
1001
|
+
const query = user.split(/\s+/).slice(1).join(" ");
|
|
1002
|
+
const picked = await pickModel(ask, cfg, query);
|
|
1003
|
+
if (picked) {
|
|
1004
|
+
cfg.model = picked;
|
|
1005
|
+
loop.client = makeClient(cfg);
|
|
1006
|
+
saveConfig(cfg);
|
|
1007
|
+
console.log(c.green(`모델 변경 → ${picked}`) + c.dim(" (저장됨)"));
|
|
1008
|
+
} else {
|
|
1009
|
+
console.log(c.dim("변경하지 않았습니다."));
|
|
1010
|
+
}
|
|
1011
|
+
continue;
|
|
1012
|
+
}
|
|
1013
|
+
// /memory — 프로젝트 규칙 파일(AGENT.md) 보기 (Claude Code /memory)
|
|
1014
|
+
if (low === "/memory") {
|
|
1015
|
+
const candidates = ["AGENT.md", "AGENTS.md", "CLAUDE.md", "rules.md"].map((n) => path.join(cfg.workspacePath(), n));
|
|
1016
|
+
const found = candidates.find((p) => fs.existsSync(p));
|
|
1017
|
+
if (found) {
|
|
1018
|
+
console.log(panel(fs.readFileSync(found, "utf8").split("\n").slice(0, 40), { title: `🧠 프로젝트 규칙 — ${path.basename(found)}`, color: "cyan" }));
|
|
1019
|
+
console.log(c.dim(`전체 경로: ${found} (매 턴 시스템 프롬프트에 주입됩니다)`));
|
|
1020
|
+
} else {
|
|
1021
|
+
console.log(c.yellow("프로젝트 규칙 파일이 없습니다. ") + c.cyan("/init") + c.dim(" 을 실행하면 모델이 폴더를 파악해 AGENT.md 를 만들어줍니다."));
|
|
1022
|
+
}
|
|
1023
|
+
continue;
|
|
1024
|
+
}
|
|
1025
|
+
if (low === "/resume") {
|
|
1026
|
+
try {
|
|
1027
|
+
const saved = JSON.parse(fs.readFileSync(convPath, "utf8"));
|
|
1028
|
+
if (!Array.isArray(saved.messages) || !saved.messages.length) throw new Error("빈 세션");
|
|
1029
|
+
loop.messages = saved.messages;
|
|
1030
|
+
const when = new Date(saved.time).toLocaleString("ko-KR");
|
|
1031
|
+
console.log(c.green(`지난 대화를 이어갑니다 — ${when} · ${saved.model} · 메시지 ${saved.messages.length}개 복원`));
|
|
1032
|
+
console.log(c.dim("(새로 시작하려면 /reset)"));
|
|
1033
|
+
} catch {
|
|
1034
|
+
console.log(c.yellow("이어갈 지난 대화가 없습니다."));
|
|
1035
|
+
}
|
|
1036
|
+
continue;
|
|
1037
|
+
}
|
|
699
1038
|
if (low === "/config") { printIntro(cfg); console.log(c.dim(`config.json: ${configPath()}`)); continue; }
|
|
700
1039
|
if (low.startsWith("/workspace") || low.startsWith("/cd")) {
|
|
701
1040
|
const arg = user.split(/\s+/).slice(1).join(" ").trim();
|
|
@@ -821,26 +1160,14 @@ export async function main(argv = []) {
|
|
|
821
1160
|
continue;
|
|
822
1161
|
}
|
|
823
1162
|
console.log(c.dim(`(스킬 '/${name}' 실행)`));
|
|
824
|
-
|
|
825
|
-
try {
|
|
826
|
-
await loop.run(renderSkill(skills[name], argStr));
|
|
827
|
-
} catch (e) {
|
|
828
|
-
console.log(c.red(`실행 오류: ${e?.message || e}`));
|
|
829
|
-
}
|
|
830
|
-
rule();
|
|
1163
|
+
await runTurn(renderSkill(skills[name], argStr));
|
|
831
1164
|
} else {
|
|
832
1165
|
console.log(c.yellow(`알 수 없는 명령/스킬: /${name} — ${c.cyan("/help")}, ${c.cyan("/skills")} 참고`));
|
|
833
1166
|
}
|
|
834
1167
|
continue;
|
|
835
1168
|
}
|
|
836
1169
|
|
|
837
|
-
|
|
838
|
-
try {
|
|
839
|
-
await loop.run(user);
|
|
840
|
-
} catch (e) {
|
|
841
|
-
console.log(c.red(`실행 오류: ${e?.message || e}`));
|
|
842
|
-
}
|
|
843
|
-
rule();
|
|
1170
|
+
await runTurn(user);
|
|
844
1171
|
}
|
|
845
1172
|
|
|
846
1173
|
rl.close();
|