cdsa-harness 0.1.1 → 0.4.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/src/cli.js CHANGED
@@ -1,17 +1,29 @@
1
1
  // CDSA Harness TUI 본체 — 터미널 REPL.
2
- // Python ui/tui 동일한 흐름을 Node 내장 모듈만으로 구현.
2
+ // 핵심 차별점: '교육 모드' 실제 API 붙여도 에이전트 내부에서 벌어지는 일
3
+ // (컨텍스트 구성 → API 요청 → 모델 판단 → 토큰/지연 → 도구 실행 → 결과 되먹임)을 단계별로 드러낸다.
3
4
  import readline from "node:readline/promises";
4
5
  import { stdin, stdout } from "node:process";
5
6
 
6
7
  import { renderBanner } from "./banner.js";
7
- import { Config, configPath, loadConfig, saveConfig } from "./config.js";
8
- import { AgentLoop, Step, STEP_LABELS } from "./loop.js";
8
+ import {
9
+ ENV_KEYS,
10
+ PROVIDERS,
11
+ SUGGESTED_MODELS,
12
+ configPath,
13
+ loadConfig,
14
+ saveConfig,
15
+ } from "./config.js";
16
+ import { execFileSync } from "node:child_process";
17
+
18
+ import { AgentLoop, Step } from "./loop.js";
9
19
  import { LLMClient } from "./llm.js";
20
+ import { discoverNpmExtensions, loadPlugins } from "./plugins.js";
10
21
  import { SessionLog, sessionsDir } from "./session.js";
22
+ import { loadSkills, renderSkill } from "./skills.js";
11
23
  import { Toolbox } from "./tools.js";
12
24
  import { c, panel, renderDiff } from "./ui.js";
13
25
 
14
- const VERSION = "0.1.0";
26
+ const VERSION = "0.2.0";
15
27
 
16
28
  const STEP_STYLE = {
17
29
  [Step.USER_INPUT]: ["🧑", "cyan"],
@@ -22,39 +34,118 @@ const STEP_STYLE = {
22
34
  [Step.APPROVAL]: ["🔐", "yellow"],
23
35
  [Step.TOOL_RUN]: ["🔧", "blue"],
24
36
  [Step.TOOL_RESULT]: ["📄", "grey"],
37
+ [Step.FEEDBACK]: ["↩️", "grey"],
25
38
  [Step.DONE]: ["✅", "green"],
26
39
  [Step.ERROR]: ["❌", "red"],
27
40
  };
28
41
 
29
- function makePrinter() {
42
+ function clip(s, n) {
43
+ s = String(s ?? "");
44
+ return s.length > n ? s.slice(0, n) + " …" : s;
45
+ }
46
+
47
+ // cfg.teach_mode 를 실행 중 토글할 수 있으므로 closure 로 cfg 를 잡아둔다.
48
+ function makePrinter(cfg) {
30
49
  return (ev) => {
31
- const [icon, color] = STEP_STYLE[ev.step] || ["•", "cyan"];
32
- const paint = c[color] || ((x) => x);
50
+ if (cfg.teach_mode) return printTeach(ev);
51
+ return printCompact(ev);
52
+ };
53
+ }
33
54
 
34
- // 승인 단계 UI approvalCallback 직접 그린다(자동 승인 안내만 출력)
35
- if (ev.step === Step.APPROVAL && !ev.title.includes("자동 승인")) return;
55
+ // ---- 교육(teach) 렌더: 내부 과정을 패널로 펼쳐 보여준다 ----
56
+ function printTeach(ev) {
57
+ const d = ev.data || {};
58
+ switch (ev.step) {
59
+ case Step.USER_INPUT:
60
+ console.log(`${c.cyan("🧑 ①")} ${c.bold("사용자 입력")} ${c.grey(clip(ev.detail, 200))}`);
61
+ return;
62
+
63
+ case Step.BUILD_CONTEXT:
64
+ console.log(`${c.grey("🧱")} ${c.dim(ev.detail)}`);
65
+ return;
36
66
 
37
- if (ev.step === Step.MODEL_REPLY) {
38
- if (ev.detail && ev.detail !== "(텍스트 없음)") {
39
- console.log(panel(ev.detail.split("\n"), { title: "🤖 모델", color: "green" }));
67
+ case Step.MODEL_CALL: {
68
+ const lines = [];
69
+ lines.push(`${c.grey("provider/model")} ${c.bold(`${d.provider} · ${d.model}`)}`);
70
+ lines.push(c.grey(`모델에 보내는 메시지 ${d.messages?.length || 0}개 · 추정 ${d.estTokens} 토큰 · ${d.totalChars}자`));
71
+ for (const m of d.messages || []) {
72
+ const roleColor = m.role === "system" ? c.magenta : m.role === "user" ? c.cyan : m.role === "assistant" ? c.green : c.yellow;
73
+ lines.push(` ${roleColor(m.role.padEnd(9))} ${c.grey(`${m.chars}자${m.extra || ""}`)}`);
74
+ }
75
+ lines.push(c.grey(`제공 도구(${d.tools?.length || 0}): ${(d.tools || []).join(", ")}`));
76
+ console.log(panel(lines, { title: `🧠 ② LLM 호출 — 반복 ${d.iteration}`, color: "magenta" }));
77
+ if (d.systemPrompt) {
78
+ console.log(panel(clip(d.systemPrompt, 600).split("\n"), {
79
+ title: "📜 시스템 프롬프트 (규칙+폴더가 여기 주입됨)",
80
+ color: "grey",
81
+ }));
40
82
  }
41
83
  return;
42
84
  }
43
- if (ev.step === Step.DONE) {
44
- console.log(panel((ev.detail || "완료").split("\n"), { title: "✅ 완료", color: "green" }));
85
+
86
+ case Step.MODEL_REPLY: {
87
+ const lines = [];
88
+ if (ev.detail && ev.detail !== "(텍스트 없음)") lines.push(...clip(ev.detail, 1200).split("\n"));
89
+ for (const tc of d.toolCalls || []) {
90
+ lines.push(c.yellow(`↳ 도구 호출 요청: ${c.bold(tc.name)}(${clip(JSON.stringify(tc.args), 200)})`));
91
+ }
92
+ const meta = [];
93
+ if (d.latencyMs != null) meta.push(`응답 ${d.latencyMs}ms`);
94
+ if (d.usage) meta.push(`토큰 입력 ${d.usage.input ?? "?"}/출력 ${d.usage.output ?? "?"}/합계 ${d.usage.total ?? "?"}`);
95
+ if (d.request?.bodyBytes) meta.push(`요청 ${d.request.bodyBytes}B`);
96
+ if (meta.length) lines.push(c.grey("─ " + meta.join(" · ")));
97
+ else lines.push(c.dim("(mock: 토큰/지연 측정 없음)"));
98
+ console.log(panel(lines.length ? lines : ["(빈 응답)"], { title: "🤖 ③ 모델 응답 (원본 판단)", color: "green" }));
45
99
  return;
46
100
  }
47
- if (ev.step === Step.ERROR) {
101
+
102
+ case Step.TOOL_DECISION:
103
+ console.log(`${c.yellow("🤔 ④")} ${c.bold("도구 판단")} ${c.grey(clip(ev.detail, 200))}`);
104
+ return;
105
+
106
+ case Step.TOOL_RUN:
107
+ console.log(`${c.blue("🔧 ⑤")} ${c.bold(ev.title)} ${c.grey(clip(ev.detail, 200))}`);
108
+ return;
109
+
110
+ case Step.TOOL_RESULT:
111
+ console.log(panel(clip(ev.detail, 1500).split("\n"), { title: `📄 ${ev.title}`, color: "grey" }));
112
+ return;
113
+
114
+ case Step.FEEDBACK:
115
+ console.log(`${c.grey("↩️ ⑥ 결과 되먹임")} ${c.dim(clip(ev.detail, 200))}`);
116
+ console.log(c.dim(" └ 도구 결과가 컨텍스트에 더해진 채로 ②부터 다시 — 이 반복이 'Agent Loop' 입니다."));
117
+ return;
118
+
119
+ case Step.APPROVAL:
120
+ if (ev.title.includes("자동 승인")) console.log(`${c.yellow("🔓")} ${c.dim(ev.title)}`);
121
+ return;
122
+
123
+ case Step.DONE:
124
+ console.log(panel((ev.detail || "완료").split("\n"), { title: "✅ 완료", color: "green" }));
125
+ return;
126
+
127
+ case Step.ERROR:
48
128
  console.log(panel((ev.detail || "").split("\n"), { title: `❌ ${ev.title}`, color: "red" }));
49
129
  return;
50
- }
130
+ }
131
+ }
51
132
 
52
- let detail = (ev.detail || "").trim().replace(/\s+/g, " ");
53
- if (detail.length > 110) detail = detail.slice(0, 110) + " …";
54
- let line = `${paint(icon)} ${paint(c.bold(ev.title))}`;
55
- if (detail && ev.step !== Step.USER_INPUT) line += ` ${c.grey(detail)}`;
56
- console.log(line);
57
- };
133
+ // ---- 간결(compact) 렌더: 위주 ----
134
+ function printCompact(ev) {
135
+ const [icon, color] = STEP_STYLE[ev.step] || ["•", "cyan"];
136
+ const paint = c[color] || ((x) => x);
137
+ if (ev.step === Step.APPROVAL && !ev.title.includes("자동 승인")) return;
138
+ if (ev.step === Step.MODEL_REPLY) {
139
+ if (ev.detail && ev.detail !== "(텍스트 없음)") console.log(panel(ev.detail.split("\n"), { title: "🤖 모델", color: "green" }));
140
+ return;
141
+ }
142
+ if (ev.step === Step.DONE) return console.log(panel((ev.detail || "완료").split("\n"), { title: "✅ 완료", color: "green" }));
143
+ if (ev.step === Step.ERROR) return console.log(panel((ev.detail || "").split("\n"), { title: `❌ ${ev.title}`, color: "red" }));
144
+ if (ev.step === Step.FEEDBACK) return;
145
+ let detail = clip((ev.detail || "").trim().replace(/\s+/g, " "), 110);
146
+ let line = `${paint(icon)} ${paint(c.bold(ev.title))}`;
147
+ if (detail && ev.step !== Step.USER_INPUT) line += ` ${c.grey(detail)}`;
148
+ console.log(line);
58
149
  }
59
150
 
60
151
  function makeApproval(rl) {
@@ -80,23 +171,36 @@ function makeApproval(rl) {
80
171
  };
81
172
  }
82
173
 
174
+ function makeClient(cfg) {
175
+ return new LLMClient({
176
+ provider: cfg.provider,
177
+ apiKey: cfg.resolvedKey(),
178
+ model: cfg.model,
179
+ temperature: cfg.temperature,
180
+ maxTokens: cfg.max_tokens,
181
+ });
182
+ }
183
+
83
184
  function printIntro(cfg) {
84
185
  console.log(renderBanner());
85
- console.log(c.dim("좁은 의미의 하네스를 터미널에서 체험하는 미니 AI 에이전트 런타임"));
186
+ console.log(c.dim("AI 에이전트의 내부 동작을 단계별로 드러내는 교육용 하네스"));
86
187
  console.log();
188
+ const keySource = cfg.provider === "mock" ? "-" : cfg.api_key ? "config.json" : ENV_KEYS[cfg.provider] && process.env[ENV_KEYS[cfg.provider]] ? `환경변수 ${ENV_KEYS[cfg.provider]}` : c.red("없음");
87
189
  const rows = [
88
190
  ["버전", `v${VERSION}`],
89
191
  ["provider", cfg.provider],
90
192
  ["model", cfg.model],
193
+ ["API 키", keySource],
194
+ ["교육 모드", cfg.teach_mode ? c.green("ON (과정 펼쳐보기)") : "OFF"],
91
195
  ["작업 폴더", cfg.workspacePath()],
92
196
  ["승인 모드", cfg.approval_mode],
93
197
  ["셸 실행", cfg.allow_shell ? "허용" : "차단"],
94
198
  ];
95
- const lines = rows.map(([k, v]) => `${c.grey(k)} ${c.bold(v)}`);
199
+ const lines = rows.map(([k, v]) => `${c.grey(k.padEnd(9))} ${c.bold(v)}`);
96
200
  console.log(panel(lines, { title: "⚙️ CDSA Harness 설정", color: "cyan" }));
97
201
  console.log(
98
202
  c.dim("명령: ") +
99
- `${c.cyan("/help")} 도움말 · ${c.cyan("/reset")} 대화 · ${c.cyan("/config")} 설정값 · ${c.cyan("/quit")} 종료\n`
203
+ `${c.cyan("/setup")} 연결 · ${c.cyan("/teach")} 교육모드 · ${c.cyan("/context")} 컨텍스트 · ${c.cyan("/help")} 도움말 · ${c.cyan("/quit")} 종료\n`
100
204
  );
101
205
  }
102
206
 
@@ -105,29 +209,87 @@ function printHelp() {
105
209
  panel(
106
210
  [
107
211
  c.bold("사용법"),
108
- `그냥 시키고 싶은 일을 입력하면 됩니다. 예) ${c.cyan("notes.txt 맨 아래에 할 일 3개 추가해줘")}`,
212
+ `시키고 싶은 일을 한국어로 입력하세요. 예) ${c.cyan("notes.txt 맨 아래에 할 일 3개 추가해줘")}`,
109
213
  "",
110
214
  c.bold("슬래시 명령"),
111
- ` ${c.cyan("/help")} 도움말`,
112
- ` ${c.cyan("/reset")} 대화/컨텍스트 초기화(새 세션)`,
113
- ` ${c.cyan("/config")} 현재 설정값과 config.json 경로`,
114
- ` ${c.cyan("/sessions")} 세션 로그 폴더 경로`,
115
- ` ${c.cyan("/quit")} 종료 (Ctrl+D 가능)`,
215
+ ` ${c.cyan("/setup")} 제공자·API 키·모델 연결(대화형)`,
216
+ ` ${c.cyan("/provider")} <openai|anthropic|openrouter|mock> 제공자 변경`,
217
+ ` ${c.cyan("/model")} <이름> 모델 변경`,
218
+ ` ${c.cyan("/teach")} 교육 모드 켜기/끄기(내부 과정 펼쳐보기)`,
219
+ ` ${c.cyan("/context")} 지금 모델에 보내는 컨텍스트 들여다보기`,
220
+ ` ${c.cyan("/skills")} 스킬 목록(.cdsa/skills 의 /명령들)`,
221
+ ` ${c.cyan("/plugins")} 플러그인 목록(.cdsa/plugins 의 추가 도구)`,
222
+ ` ${c.cyan("/reset")} 대화/컨텍스트 초기화`,
223
+ ` ${c.cyan("/config")} 현재 설정값`,
224
+ ` ${c.cyan("/quit")} 종료 (Ctrl+D)`,
116
225
  "",
117
- c.bold("하네스 흐름 (각 단계가 색으로 표시됩니다)"),
118
- " 입력 → 컨텍스트 구성 → LLM 호출 → 도구 판단 승인 실행 → 결과 반영 → 반복",
226
+ c.bold("교육 모드에서 보이는 단계"),
227
+ " 입력 → LLM 호출(컨텍스트·도구) 모델 응답(토큰·지연) →",
228
+ " ④ 도구 판단 → ⑤ 실행/승인 → ⑥ 결과 되먹임 → (반복)",
119
229
  ],
120
230
  { title: "도움말", color: "cyan" }
121
231
  )
122
232
  );
123
233
  }
124
234
 
235
+ // 대화형 연결 설정. 키는 환경변수가 있으면 그걸 우선 안내(파일 저장 안 함).
236
+ async function runSetup(rl, cfg) {
237
+ console.log(panel(
238
+ [
239
+ "어떤 AI 에 연결할까요? 번호를 입력하세요.",
240
+ ` ${c.bold("1")}) openai (GPT, 키: ${ENV_KEYS.openai})`,
241
+ ` ${c.bold("2")}) anthropic (Claude, 키: ${ENV_KEYS.anthropic})`,
242
+ ` ${c.bold("3")}) openrouter (여러 모델 중계, 키: ${ENV_KEYS.openrouter})`,
243
+ ` ${c.bold("4")}) mock (키 없이 연습)`,
244
+ ],
245
+ { title: "🔌 연결 설정 (/setup)", color: "cyan" }
246
+ ));
247
+ const pick = (await rl.question(c.cyan("제공자 번호 [1-4]: "))).trim();
248
+ const provider = { "1": "openai", "2": "anthropic", "3": "openrouter", "4": "mock" }[pick];
249
+ if (!provider) {
250
+ console.log(c.yellow("취소했습니다."));
251
+ return false;
252
+ }
253
+ cfg.provider = provider;
254
+
255
+ if (provider === "mock") {
256
+ cfg.model = "mock-agent";
257
+ } else {
258
+ const envName = ENV_KEYS[provider];
259
+ const envVal = (process.env[envName] || "").trim();
260
+ if (envVal) {
261
+ const useEnv = (await rl.question(c.cyan(`환경변수 ${envName} 에서 키를 찾았어요. 사용할까요? [Y/n] `))).trim().toLowerCase();
262
+ if (useEnv === "" || useEnv === "y" || useEnv === "yes") {
263
+ cfg.api_key = ""; // 환경변수 사용 → 파일엔 저장 안 함
264
+ } else {
265
+ const k = (await rl.question(c.cyan("API 키 붙여넣기(입력이 보일 수 있음): "))).trim();
266
+ cfg.api_key = k;
267
+ }
268
+ } else {
269
+ console.log(c.dim(`(또는 종료 후 환경변수 ${envName} 에 키를 넣어두면 파일에 저장하지 않아도 됩니다)`));
270
+ const k = (await rl.question(c.cyan("API 키 붙여넣기(입력이 보일 수 있음): "))).trim();
271
+ cfg.api_key = k;
272
+ }
273
+ const sugg = SUGGESTED_MODELS[provider] || [];
274
+ const def = sugg[0] || "";
275
+ const m = (await rl.question(c.cyan(`모델 [${def}] (추천: ${sugg.join(", ")}): `))).trim();
276
+ cfg.model = m || def;
277
+ }
278
+
279
+ const saved = saveConfig(cfg);
280
+ console.log(c.green(`설정을 저장했습니다 → ${saved}`));
281
+ if (!cfg.isReady()) console.log(c.yellow("⚠️ 아직 키가 없어 호출이 실패할 수 있어요. 환경변수나 /setup 으로 키를 넣어주세요."));
282
+ return true;
283
+ }
284
+
125
285
  function parseArgs(argv) {
126
286
  const out = { _: [] };
127
287
  for (let i = 0; i < argv.length; i++) {
128
288
  const a = argv[i];
129
289
  if (a === "--auto") out.auto = true;
130
290
  else if (a === "--help" || a === "-h") out.help = true;
291
+ else if (a === "--setup") out.setup = true;
292
+ else if (a === "--no-teach") out.noTeach = true;
131
293
  else if (a === "--provider") out.provider = argv[++i];
132
294
  else if (a === "--model") out.model = argv[++i];
133
295
  else if (a === "--workspace") out.workspace = argv[++i];
@@ -140,49 +302,81 @@ export async function main(argv = []) {
140
302
  const args = parseArgs(argv);
141
303
  if (args.help) {
142
304
  console.log(
143
- "CDSA Harness — 미니 AI 에이전트 하네스 (터미널)\n\n" +
305
+ "CDSA Harness — AI 에이전트 내부를 드러내는 교육용 하네스 (터미널)\n\n" +
144
306
  "사용법: cdsa-harness [옵션]\n" +
145
- " --provider <openai|openrouter|mock>\n" +
307
+ " cdsa-harness add <npm-패키지> 플러그인 설치(이후 자동 로드)\n" +
308
+ " --provider <openai|anthropic|openrouter|mock>\n" +
146
309
  " --model <모델명>\n" +
147
310
  " --workspace <폴더경로>\n" +
311
+ " --setup 대화형 연결 설정 실행\n" +
312
+ " --no-teach 교육 모드 끄고 간결하게\n" +
148
313
  " --auto 승인 자동(approval_mode=auto)\n" +
149
- " -h, --help 도움말\n"
314
+ " -h, --help 도움말\n\n" +
315
+ "API 키는 환경변수로도 인식됩니다: OPENAI_API_KEY / ANTHROPIC_API_KEY / OPENROUTER_API_KEY\n"
150
316
  );
151
317
  return 0;
152
318
  }
153
319
 
320
+ // `cdsa-harness add <패키지>` — 플러그인을 npm 으로 설치(이후 자동 로드).
321
+ if (args._[0] === "add" || args._[0] === "install") {
322
+ const pkgs = args._.slice(1);
323
+ if (!pkgs.length) {
324
+ console.log("사용법: cdsa-harness add <npm-패키지...> 예) cdsa-harness add cdsa-harness-plugin-git");
325
+ return 1;
326
+ }
327
+ console.log(c.cyan(`npm install ${pkgs.join(" ")} ...`));
328
+ try {
329
+ execFileSync("npm", ["install", ...pkgs], { stdio: "inherit", cwd: process.cwd() });
330
+ console.log(c.green("설치 완료. 다음 실행부터 플러그인이 자동으로 로드됩니다 (/plugins 로 확인)."));
331
+ return 0;
332
+ } catch (e) {
333
+ console.log(c.red(`설치 실패: ${e.message}`));
334
+ return 1;
335
+ }
336
+ }
337
+
154
338
  const cfg = loadConfig();
155
339
  if (args.provider) cfg.provider = args.provider;
156
340
  if (args.model) cfg.model = args.model;
157
341
  if (args.workspace) cfg.workspace = args.workspace;
158
342
  if (args.auto) cfg.approval_mode = "auto";
343
+ if (args.noTeach) cfg.teach_mode = false;
159
344
 
160
- if (!cfg.isReady()) {
161
- console.log(
162
- c.yellow("API Key 가 없어 mock 모드로 실행합니다. ") +
163
- `실제 LLM 을 쓰려면 ${configPath()} 에 provider/api_key 를 설정하세요.\n`
164
- );
165
- cfg.provider = "mock";
166
- cfg.model = "mock-agent";
345
+ const rl = readline.createInterface({ input: stdin, output: stdout });
346
+
347
+ if (args.setup) {
348
+ await runSetup(rl, cfg);
349
+ } else if (!cfg.isReady() && cfg.provider !== "mock") {
350
+ console.log(c.yellow(`provider=${cfg.provider} 인데 API 키가 없습니다. 연결 설정을 시작합니다.\n`));
351
+ await runSetup(rl, cfg);
167
352
  }
168
353
 
169
354
  printIntro(cfg);
170
355
 
171
- const client = new LLMClient({
172
- provider: cfg.provider,
173
- apiKey: cfg.api_key,
174
- model: cfg.model,
175
- temperature: cfg.temperature,
176
- });
177
- const toolbox = new Toolbox(cfg.workspacePath(), cfg.allow_shell);
178
- const session = SessionLog.create();
179
- const rl = readline.createInterface({ input: stdin, output: stdout });
356
+ // 플러그인(추가 도구)·스킬(프롬프트 템플릿)을 불러온다:
357
+ // ① 파일: .cdsa/plugins · .cdsa/skills (작업폴더/홈)
358
+ // ② npm 패키지: cdsa-harness-plugin-* 자동 발견 + config.plugins 강제 로드
359
+ const filePlugins = await loadPlugins(cfg.workspacePath());
360
+ const npm = await discoverNpmExtensions(process.cwd(), cfg.plugins || []);
361
+ const plugins = [...filePlugins, ...npm.plugins, ...npm.errors.map((e) => ({ error: e }))];
362
+ const skills = {};
363
+ for (const s of npm.skills) skills[s.name] = { name: s.name, description: s.description || "", body: s.body, source: "(npm)" };
364
+ Object.assign(skills, loadSkills(cfg.workspacePath())); // 로컬 파일 스킬이 우선
365
+ const toolbox = new Toolbox(cfg.workspacePath(), cfg.allow_shell, plugins);
366
+ if (toolbox.plugins.length || Object.keys(skills).length || toolbox.pluginErrors.length) {
367
+ const bits = [];
368
+ if (toolbox.plugins.length) bits.push(c.green(`플러그인 ${toolbox.plugins.length}개`) + c.grey(` (${toolbox.plugins.map((p) => p.name).join(", ")})`));
369
+ if (Object.keys(skills).length) bits.push(c.green(`스킬 ${Object.keys(skills).length}개`) + c.grey(` (${Object.keys(skills).map((s) => "/" + s).join(", ")})`));
370
+ if (toolbox.pluginErrors.length) bits.push(c.red(`플러그인 오류 ${toolbox.pluginErrors.length}개`));
371
+ console.log("🔌 " + bits.join(" · ") + "\n");
372
+ }
180
373
 
374
+ const session = SessionLog.create();
181
375
  const loop = new AgentLoop({
182
376
  config: cfg,
183
- client,
377
+ client: makeClient(cfg),
184
378
  toolbox,
185
- onEvent: makePrinter(),
379
+ onEvent: makePrinter(cfg),
186
380
  approvalCallback: makeApproval(rl),
187
381
  session,
188
382
  });
@@ -195,27 +389,90 @@ export async function main(argv = []) {
195
389
  try {
196
390
  user = (await rl.question(c.bold(c.cyan("› ")))).trim();
197
391
  } catch {
198
- break; // Ctrl+D / 스트림 종료
392
+ break;
199
393
  }
200
394
  if (!user) continue;
201
395
  const low = user.toLowerCase();
396
+
202
397
  if (["/quit", "/exit", "quit", "exit", ":q"].includes(low)) break;
203
- if (low === "/help") {
204
- printHelp();
398
+ if (low === "/help") { printHelp(); continue; }
399
+ if (low === "/reset") { loop.reset(); console.log(c.green("컨텍스트를 초기화했습니다.")); continue; }
400
+ if (low === "/config") { printIntro(cfg); console.log(c.dim(`config.json: ${configPath()}`)); continue; }
401
+ if (low === "/sessions") { console.log(c.dim(`세션 로그: ${sessionsDir()}`)); continue; }
402
+ if (low === "/teach") {
403
+ cfg.teach_mode = !cfg.teach_mode;
404
+ console.log(c.green(`교육 모드 ${cfg.teach_mode ? "ON" : "OFF"}.`));
405
+ continue;
406
+ }
407
+ if (low === "/setup" || low === "/login") {
408
+ await runSetup(rl, cfg);
409
+ loop.client = makeClient(cfg);
410
+ loop.reset();
205
411
  continue;
206
412
  }
207
- if (low === "/reset") {
413
+ if (low.startsWith("/provider")) {
414
+ const p = user.split(/\s+/)[1];
415
+ if (!PROVIDERS.includes(p)) { console.log(c.yellow(`provider 는 ${PROVIDERS.join("/")} 중 하나.`)); continue; }
416
+ cfg.provider = p;
417
+ if (SUGGESTED_MODELS[p]?.length) cfg.model = SUGGESTED_MODELS[p][0];
418
+ loop.client = makeClient(cfg);
208
419
  loop.reset();
209
- console.log(c.green("컨텍스트를 초기화했습니다( 세션)."));
420
+ console.log(c.green(`provider=${p}, model=${cfg.model} (키: ${cfg.isReady() ? "OK" : c.red("없음 — /setup")})`));
210
421
  continue;
211
422
  }
212
- if (low === "/config") {
213
- printIntro(cfg);
214
- console.log(c.dim(`config.json: ${configPath()}`));
423
+ if (low.startsWith("/model")) {
424
+ const m = user.split(/\s+/).slice(1).join(" ").trim();
425
+ if (!m) { console.log(c.dim(`현재 모델: ${cfg.model} · 추천: ${(SUGGESTED_MODELS[cfg.provider] || []).join(", ")}`)); continue; }
426
+ cfg.model = m;
427
+ loop.client = makeClient(cfg);
428
+ console.log(c.green(`model=${m}`));
215
429
  continue;
216
430
  }
217
- if (low === "/sessions") {
218
- console.log(c.dim(`세션 로그 폴더: ${sessionsDir()}`));
431
+ if (low === "/context") {
432
+ const ctx = loop.contextSummary();
433
+ const lines = [c.grey(`메시지 ${ctx.rows.length}개 · 추정 ${ctx.estTokens} 토큰 · ${ctx.totalChars}자`)];
434
+ for (const m of ctx.rows) lines.push(` ${c.bold(m.role.padEnd(9))} ${c.grey(`${m.chars}자${m.extra || ""}`)}`);
435
+ console.log(panel(lines, { title: "🧩 현재 컨텍스트(다음 호출에 전송됨)", color: "magenta" }));
436
+ console.log(panel(clip(ctx.systemPrompt, 800).split("\n"), { title: "📜 시스템 프롬프트", color: "grey" }));
437
+ continue;
438
+ }
439
+ if (low === "/plugins") {
440
+ const lines = [];
441
+ if (!toolbox.plugins.length) lines.push(c.dim("등록된 플러그인이 없습니다."));
442
+ for (const p of toolbox.plugins) {
443
+ const src = p.source && p.source.includes("/") ? "📄 " + p.source.split("/").slice(-1)[0] : "📦 " + (p.source || "npm");
444
+ lines.push(`${c.bold(p.name)}${p.mutating ? c.yellow(" (승인필요)") : ""} ${c.grey(p.description || "")} ${c.dim(src)}`);
445
+ }
446
+ for (const e of toolbox.pluginErrors) lines.push(c.red("✖ " + e));
447
+ lines.push(c.dim("추가: npm 패키지 'cdsa-harness-plugin-*' 설치 → 자동 로드 (cdsa-harness add <pkg>)"));
448
+ lines.push(c.dim("또는: <작업폴더>/.cdsa/plugins/ 에 .js/.mjs 파일"));
449
+ console.log(panel(lines, { title: "🔌 플러그인 (모델이 쓸 수 있는 추가 도구)", color: "blue" }));
450
+ continue;
451
+ }
452
+ if (low === "/skills") {
453
+ const names = Object.keys(skills);
454
+ const lines = names.length ? names.map((n) => `${c.cyan("/" + n)} ${c.grey(skills[n].description || "")}`) : [c.dim("등록된 스킬이 없습니다.")];
455
+ lines.push(c.dim("위치: <작업폴더>/.cdsa/skills/ 또는 ~/.cdsa_harness/skills/ (.md). 본문의 $ARGUMENTS 치환."));
456
+ console.log(panel(lines, { title: "🎯 스킬 (프롬프트 템플릿, /이름 으로 실행)", color: "cyan" }));
457
+ continue;
458
+ }
459
+
460
+ // 위 내장 명령에 안 걸린 '/...' → 스킬이면 실행, 아니면 안내.
461
+ if (user.startsWith("/")) {
462
+ const name = low.slice(1).split(/\s+/)[0];
463
+ if (skills[name]) {
464
+ const argStr = user.split(/\s+/).slice(1).join(" ");
465
+ console.log(c.dim(`(스킬 '/${name}' 실행)`));
466
+ rule();
467
+ try {
468
+ await loop.run(renderSkill(skills[name], argStr));
469
+ } catch (e) {
470
+ console.log(c.red(`실행 오류: ${e?.message || e}`));
471
+ }
472
+ rule();
473
+ } else {
474
+ console.log(c.yellow(`알 수 없는 명령/스킬: /${name} — ${c.cyan("/help")}, ${c.cyan("/skills")} 참고`));
475
+ }
219
476
  continue;
220
477
  }
221
478
 
package/src/config.js CHANGED
@@ -4,18 +4,26 @@ import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
 
7
- export const PROVIDERS = ["openai", "openrouter", "mock"];
7
+ export const PROVIDERS = ["openai", "anthropic", "openrouter", "mock"];
8
8
 
9
9
  export const SUGGESTED_MODELS = {
10
- openai: ["gpt-4.1-mini", "gpt-4o-mini", "gpt-4.1"],
10
+ openai: ["gpt-4o-mini", "gpt-4.1-mini", "gpt-4.1"],
11
+ anthropic: ["claude-3-5-haiku-latest", "claude-3-5-sonnet-latest", "claude-sonnet-4-5"],
11
12
  openrouter: [
12
- "openai/gpt-4.1-mini",
13
+ "openai/gpt-4o-mini",
13
14
  "anthropic/claude-3.5-sonnet",
14
15
  "google/gemini-2.5-flash",
15
16
  ],
16
17
  mock: ["mock-agent"],
17
18
  };
18
19
 
20
+ // provider 별로 자동 감지하는 환경변수(파일에 키를 저장하지 않아도 됨)
21
+ export const ENV_KEYS = {
22
+ openai: "OPENAI_API_KEY",
23
+ anthropic: "ANTHROPIC_API_KEY",
24
+ openrouter: "OPENROUTER_API_KEY",
25
+ };
26
+
19
27
  export const APPROVAL_MODES = ["manual", "auto"];
20
28
 
21
29
  const DEFAULTS = {
@@ -27,6 +35,9 @@ const DEFAULTS = {
27
35
  allow_shell: false,
28
36
  max_steps: 8,
29
37
  temperature: 0.2,
38
+ max_tokens: 1024,
39
+ teach_mode: true,
40
+ plugins: [], // 추가로 불러올 npm 플러그인 패키지 이름(이름 규칙과 무관하게 강제 로드)
30
41
  };
31
42
 
32
43
  export function configDir() {
@@ -54,9 +65,17 @@ export class Config {
54
65
  return p;
55
66
  }
56
67
 
68
+ // 파일에 저장된 키가 없으면 환경변수에서 찾는다.
69
+ resolvedKey() {
70
+ const direct = (this.api_key || "").trim();
71
+ if (direct) return direct;
72
+ const envName = ENV_KEYS[this.provider];
73
+ return envName ? (process.env[envName] || "").trim() : "";
74
+ }
75
+
57
76
  isReady() {
58
77
  if (this.provider === "mock") return true;
59
- return Boolean((this.api_key || "").trim());
78
+ return Boolean(this.resolvedKey());
60
79
  }
61
80
 
62
81
  toJSON() {