cdsa-harness 0.20.0 → 0.21.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdsa-harness",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "AI 에이전트의 내부 동작을 단계별로 드러내는 교육용 터미널 하네스. 실시간 스트리밍 + OpenAI/Claude/OpenRouter + MCP + npm 플러그인·크로스포맷 스킬.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/builtins.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // 스킬/플러그인/버전을 바꾼 뒤 `node scripts/gen-builtins.mjs` 로 재생성하세요.
3
3
  import p0 from "../plugins/hwpx_read.mjs";
4
4
 
5
- export const VERSION = "0.20.0";
5
+ export const VERSION = "0.21.0";
6
6
 
7
7
  export const BUILTIN_PLUGINS = [p0];
8
8
 
package/src/cli.js CHANGED
@@ -28,6 +28,7 @@ import { loadSkills, renderSkill } from "./skills.js";
28
28
  import { Toolbox } from "./tools.js";
29
29
  import { c, panel, renderDiff, renderMarkdown, setColor } from "./ui.js";
30
30
  import { makeCompleter } from "./completion.js";
31
+ import { selectMenu } from "./select.js";
31
32
 
32
33
  // VERSION 은 src/builtins.js(생성물)에서 가져온다 — npm/exe 양쪽에서 동일.
33
34
 
@@ -198,9 +199,17 @@ function makeApproval(ask, cfg) {
198
199
  } else {
199
200
  console.log(panel([JSON.stringify(req.args)], { title: `🔐 ${req.toolLabel}`, color: "yellow" }));
200
201
  }
201
- const raw = await ask(c.yellow(" 작업을 승인하시겠습니까? [y/N] "));
202
+ const raw = await ask(c.yellow("승인할까요? ") + c.green("[y=승인") + c.yellow(" / n=거부 / ") + c.cyan("a=승인+이후 자동수락]") + " ");
202
203
  const ans = (raw || "").trim().toLowerCase();
204
+ if (ans === "a" || ans === "always") {
205
+ cfg.approval_mode = "auto";
206
+ console.log(c.green("✅ 승인 + 자동 수락 ON — 이후엔 묻지 않아요. (해제: /auto)"));
207
+ return { approved: true, reason: "" };
208
+ }
203
209
  const approved = ans === "y" || ans === "yes";
210
+ if (!approved) {
211
+ console.log(c.yellow("→ 거부로 처리했어요.") + c.dim(" (엔터는 거부예요 — 승인은 y, 항상 승인은 a 또는 /auto)"));
212
+ }
204
213
  streak = approved ? streak + 1 : 0;
205
214
  if (approved && streak >= 3 && !hinted && cfg.approval_mode === "manual") {
206
215
  hinted = true;
@@ -325,6 +334,12 @@ async function pickModel(ask, cfg, query = "") {
325
334
  console.log(c.yellow(`'${query}' 에 맞는 모델이 없습니다.`) + c.dim(" 예: /models claude · /models gpt"));
326
335
  return null;
327
336
  }
337
+ // ↑↓ 방향키 메뉴(TTY) — 파이프/CI 에선 아래 번호 입력으로 폴백
338
+ const menuSel = await selectMenu(list.slice(0, 50), {
339
+ title: `🤖 모델 선택 — ${cfg.provider}${live ? " (실시간)" : " (추천)"}${list.length > 50 ? ` · 상위 50/${list.length} — 검색어로 좁히세요` : ""}`,
340
+ current: cfg.model,
341
+ });
342
+ if (menuSel !== undefined) return menuSel; // 선택(문자열) 또는 취소(null)
328
343
  const shown = list.slice(0, 15);
329
344
  const lines = shown.map((m, i) => {
330
345
  const cur = m === cfg.model ? c.green(" ← 현재") : "";
@@ -376,7 +391,7 @@ function printIntro(cfg) {
376
391
  const lines = rows.map(([k, v]) => `${c.grey(k.padEnd(9))} ${c.bold(v)}`);
377
392
  console.log(panel(lines, { title: "⚙️ CDSA Harness 설정", color: "cyan" }));
378
393
  console.log(
379
- c.bold(c.cyan("👉 처음이세요? /guide ")) + c.dim("입력하면 빠른 시작 안내가 떠요.")
394
+ c.bold(c.cyan("👉 처음이세요? /guide ")) + c.dim("· '/' 치면 명령 팔레트 · ") + c.cyan("Tab") + c.dim(" 키로 /명령·@파일 자동완성")
380
395
  );
381
396
  console.log(
382
397
  c.dim("명령: ") +
@@ -511,20 +526,24 @@ function cleanKey(s) {
511
526
  // 대화형 연결 설정. 키는 환경변수가 있으면 그걸 우선 안내(파일 저장 안 함).
512
527
  // ask 가 null 을 주면(Ctrl+C) 조용히 취소.
513
528
  async function runSetup(ask, cfg) {
514
- console.log(panel(
515
- [
516
- "어떤 AI 연결할까요? 번호를 입력하세요.",
517
- ` ${c.bold("1")}) openai (GPT, 키: ${ENV_KEYS.openai})`,
518
- ` ${c.bold("2")}) anthropic (Claude, 키: ${ENV_KEYS.anthropic})`,
519
- ` ${c.bold("3")}) openrouter (여러 모델 중계, 키: ${ENV_KEYS.openrouter})`,
520
- ` ${c.bold("4")}) ollama (로컬·폐쇄망 LLM, 키 불필요) 🏢`,
521
- ` ${c.bold("5")}) mock (키 없이 연습)`,
522
- ],
523
- { title: "🔌 연결 설정 (/setup)", color: "cyan" }
524
- ));
525
- const pickRaw = await ask(c.cyan("제공자 번호 [1-5] (취소: Enter): "));
526
- if (pickRaw === null) return false;
527
- const provider = { "1": "openai", "2": "anthropic", "3": "openrouter", "4": "ollama", "5": "mock" }[pickRaw.trim()];
529
+ const PROVIDER_ITEMS = [
530
+ ["openai", "openai — GPT (키: OPENAI_API_KEY)"],
531
+ ["anthropic", "anthropic Claude (키: ANTHROPIC_API_KEY)"],
532
+ ["openrouter", "openrouter — 여러 모델 중계 (키: OPENROUTER_API_KEY)"],
533
+ ["ollama", "ollama — 로컬·폐쇄망 LLM, 불필요 🏢"],
534
+ ["mock", "mock 없이 연습"],
535
+ ];
536
+ let provider = null;
537
+ const provSel = await selectMenu(PROVIDER_ITEMS.map(([, l]) => l), { title: "🔌 어떤 AI 에 연결할까요? (/setup)", current: (PROVIDER_ITEMS.find(([k]) => k === cfg.provider) || [])[1] });
538
+ if (provSel === null) { console.log(c.yellow("취소했습니다.")); return false; }
539
+ if (provSel !== undefined) {
540
+ provider = PROVIDER_ITEMS[PROVIDER_ITEMS.findIndex(([, l]) => l === provSel)][0];
541
+ } else {
542
+ console.log(panel(PROVIDER_ITEMS.map(([, l], i) => ` ${c.bold(String(i + 1))}) ${l}`), { title: "🔌 연결 설정 (/setup)", color: "cyan" }));
543
+ const pickRaw = await ask(c.cyan("제공자 번호 [1-5] (취소: Enter): "));
544
+ if (pickRaw === null) return false;
545
+ provider = (PROVIDER_ITEMS[parseInt(pickRaw.trim(), 10) - 1] || [])[0];
546
+ }
528
547
  if (!provider) {
529
548
  console.log(c.yellow("취소했습니다."));
530
549
  return false;
@@ -871,6 +890,21 @@ export async function main(argv = []) {
871
890
  const low = user.toLowerCase();
872
891
 
873
892
  if (["/quit", "/exit", "quit", "exit", ":q"].includes(low)) break;
893
+ if (user === "/") {
894
+ const skillNames = Object.keys(skills).sort();
895
+ console.log(panel([
896
+ c.bold("대화")+" "+c.cyan("/new /compact /resume /undo /context"),
897
+ c.bold("프로젝트")+" "+c.cyan("/init /memory /workspace"),
898
+ c.bold("모델·설정")+" "+c.cyan("/setup /models /model /provider /auto /status /teach /stream /update"),
899
+ c.bold("확장")+" "+c.cyan("/skills /plugins /mcp"),
900
+ c.bold("기타")+" "+c.cyan("/guide /tutorial /about /config /quit"),
901
+ "",
902
+ c.bold(`스킬 ${skillNames.length}개`)+" "+c.grey(skillNames.slice(0,10).map(s=>"/"+s).join(" ")+(skillNames.length>10?" …":"")),
903
+ "",
904
+ c.dim("💡 Tab 키로 자동완성 — 예: /mi[Tab], /provider o[Tab], @파일[Tab]"),
905
+ ], { title: "⌨️ 명령 팔레트", color: "cyan" }));
906
+ continue;
907
+ }
874
908
  if (low === "/help") { printHelp(); continue; }
875
909
  if (low === "/guide" || low === "/start") { printGuide(); continue; }
876
910
  if (low === "/tutorial") { await runTutorial(ask); continue; }
@@ -1161,7 +1195,14 @@ export async function main(argv = []) {
1161
1195
  console.log(c.dim(`(스킬 '/${name}' 실행)`));
1162
1196
  await runTurn(renderSkill(skills[name], argStr));
1163
1197
  } else {
1164
- console.log(c.yellow(`알 없는 명령/스킬: /${name} ${c.cyan("/help")}, ${c.cyan("/skills")} 참고`));
1198
+ const { BUILTIN_COMMANDS } = await import("./completion.js");
1199
+ const all = [...BUILTIN_COMMANDS, ...Object.keys(skills).map((s) => "/" + s)];
1200
+ const near = all.filter((n) => n.includes(name.slice(0, 3)) || n.slice(1).startsWith(name[0] || "")).slice(0, 4);
1201
+ console.log(
1202
+ c.yellow(`알 수 없는 명령: /${name}`) +
1203
+ (near.length ? c.dim(" 혹시? ") + c.cyan(near.join(" ")) : "") +
1204
+ c.dim(" · '/' 입력=팔레트 · Tab=자동완성")
1205
+ );
1165
1206
  }
1166
1207
  continue;
1167
1208
  }
package/src/loop.js CHANGED
@@ -154,6 +154,12 @@ export class AgentLoop {
154
154
  : "- 당신은 상위 에이전트가 위임한 하위 작업을 수행하는 서브에이전트다. 주어진 작업만 완수하고, 결과를 명확한 텍스트로 보고하라.",
155
155
  pluginNames ? `- 추가 도구: ${pluginNames}` : null,
156
156
  "",
157
+ "# CDSA Harness 자체 사용법 — 사용자가 이 도구의 설정·사용법을 물으면 아래로 정확히 답하라",
158
+ "- 설정 파일: 홈폴더/.cdsa_harness/config.json (실행 폴더에 config.json 이 있으면 그쪽 우선).",
159
+ '- MCP 연결: 설정 파일에 \"mcpServers\": { \"이름\": { \"command\": \"npx\", \"args\": [\"-y\",\"서버패키지\"] } } — Claude Code/Cursor 와 동일 형식. 저장 후 재시작하면 /mcp 에 표시.',
160
+ "- 주요 명령: /setup 연결마법사 · /models 모델선택 · /workspace 작업폴더 변경 · /auto 자동승인 · /update 업데이트 · /mcp /plugins /skills 목록 · /help 전체 · /guide 시작안내.",
161
+ "- 확장: 스킬 = 작업폴더/.cdsa/skills/*.md (또는 홈/.cdsa_harness/skills), 플러그인 = .cdsa/plugins/*.mjs 또는 npm 'cdsa-harness-plugin-*' 설치.",
162
+ "",
157
163
  "# 응답 스타일",
158
164
  "- 한국어로, 간결하게. 불필요한 서론·사과 없이 핵심부터.",
159
165
  "- 작업이 끝나면 도구를 더 호출하지 말고 무엇을 했는지 요약한다.",
package/src/select.js ADDED
@@ -0,0 +1,113 @@
1
+ // ↑↓ 방향키 인터랙티브 선택 메뉴 (의존성 0, raw keypress).
2
+ // TTY 가 아니면 null 을 즉시 반환 → 호출측이 번호 입력 폴백을 쓴다(파이프/CI 호환).
3
+ import readline from "node:readline";
4
+ import { c } from "./ui.js";
5
+
6
+ const HIDE = "\x1b[?25l";
7
+ const SHOW = "\x1b[?25h";
8
+
9
+ export function selectMenu(items, { title = "", current = null, window = 12 } = {}) {
10
+ const stdin = process.stdin;
11
+ const out = process.stdout;
12
+ if (!stdin.isTTY || !out.isTTY || !items.length) return Promise.resolve(undefined); // 미지원 → 호출측 폴백
13
+
14
+ return new Promise((resolve) => {
15
+ let idx = items.indexOf(current);
16
+ if (idx < 0) idx = 0;
17
+ let rendered = 0;
18
+ let digits = "";
19
+ let done = false;
20
+
21
+ const clampTop = () => {
22
+ let top = idx - Math.floor(window / 2);
23
+ top = Math.max(0, Math.min(top, items.length - window));
24
+ return Math.max(0, top);
25
+ };
26
+
27
+ const render = () => {
28
+ if (rendered) out.write(`\x1b[${rendered}A`);
29
+ let n = 0;
30
+ const line = (s) => {
31
+ out.write("\x1b[2K" + s + "\n");
32
+ n++;
33
+ };
34
+ if (title) line(c.bold(c.cyan(title)));
35
+ const top = clampTop();
36
+ const end = Math.min(items.length, top + window);
37
+ if (top > 0) line(c.dim(` ↑ ${top}개 더`));
38
+ for (let i = top; i < end; i++) {
39
+ const isCur = i === idx;
40
+ const tag = items[i] === current ? c.green(" ← 현재") : "";
41
+ const num = c.dim(String(i + 1).padStart(2) + " ");
42
+ if (isCur) line(c.cyan(c.bold("▸ ")) + num + c.bold(c.cyan(items[i])) + tag);
43
+ else line(" " + num + items[i] + tag);
44
+ }
45
+ if (end < items.length) line(c.dim(` ↓ ${items.length - end}개 더`));
46
+ line(c.dim("↑↓ 이동 · Enter 선택 · 숫자 바로이동 · Esc/q 취소"));
47
+ rendered = n;
48
+ };
49
+
50
+ // 기존 readline(rl)의 keypress 리스너를 잠시 떼어내 이중 처리를 막는다.
51
+ readline.emitKeypressEvents(stdin);
52
+ const saved = stdin.rawListeners("keypress");
53
+ for (const l of saved) stdin.removeListener("keypress", l);
54
+ const wasRaw = stdin.isRaw;
55
+ try {
56
+ stdin.setRawMode(true);
57
+ } catch { /* 일부 환경 */ }
58
+ stdin.resume();
59
+ out.write(HIDE);
60
+
61
+ const finish = (value) => {
62
+ if (done) return;
63
+ done = true;
64
+ stdin.removeListener("keypress", onKey);
65
+ try {
66
+ stdin.setRawMode(Boolean(wasRaw));
67
+ } catch { /* */ }
68
+ for (const l of saved) stdin.on("keypress", l);
69
+ // 메뉴 지우고 선택 결과 한 줄만 남긴다
70
+ if (rendered) out.write(`\x1b[${rendered}A`);
71
+ for (let i = 0; i < rendered; i++) out.write("\x1b[2K\n");
72
+ out.write(`\x1b[${rendered}A`);
73
+ out.write(SHOW);
74
+ if (value !== null) out.write(c.dim("선택: ") + c.green(value) + "\n");
75
+ else out.write(c.dim("(선택 취소)") + "\n");
76
+ resolve(value);
77
+ };
78
+
79
+ const onKey = (str, key) => {
80
+ if (done) return;
81
+ const name = key && key.name;
82
+ if ((key && key.ctrl && name === "c") || name === "escape" || str === "q") return finish(null);
83
+ if (name === "return" || name === "enter") {
84
+ if (digits) {
85
+ const n = parseInt(digits, 10);
86
+ if (n >= 1 && n <= items.length) idx = n - 1;
87
+ digits = "";
88
+ }
89
+ return finish(items[idx]);
90
+ }
91
+ if (name === "up" || str === "k") {
92
+ idx = (idx - 1 + items.length) % items.length;
93
+ digits = "";
94
+ } else if (name === "down" || str === "j") {
95
+ idx = (idx + 1) % items.length;
96
+ digits = "";
97
+ } else if (str >= "0" && str <= "9") {
98
+ digits += str;
99
+ const n = parseInt(digits, 10);
100
+ if (n >= 1 && n <= items.length) idx = n - 1;
101
+ else digits = str; // 범위 밖이면 새로 시작
102
+ const n2 = parseInt(digits, 10);
103
+ if (n2 >= 1 && n2 <= items.length) idx = n2 - 1;
104
+ } else {
105
+ return;
106
+ }
107
+ render();
108
+ };
109
+
110
+ stdin.on("keypress", onKey);
111
+ render();
112
+ });
113
+ }