cdsa-harness 0.4.0 → 0.6.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 CHANGED
@@ -9,10 +9,12 @@
9
9
  ```
10
10
 
11
11
  - **의존성 0개** — Node 18+ 내장 기능만(`fetch`/`readline`/`node:test`)
12
+ - **실시간 스트리밍** — 모델 응답이 토큰 단위로 흐름(`/stream` 토글, OpenAI·Claude·mock)
12
13
  - **실제 LLM 연결** — OpenAI · Anthropic(Claude) · OpenRouter, 또는 키 없이 `mock`
13
14
  - **교육 모드** — 매 반복마다 모델에 보내는 메시지 구성·추정 토큰·시스템 프롬프트, 실제 토큰 사용량/응답시간까지 그대로 표시
14
- - **플러그인**`.cdsa/plugins/` JS 파일을 두면 **새 도구가 자동 등록**되어 모델이 사용
15
- - **스킬** — `.cdsa/skills/` 에 마크다운을 두면 `/이름` 으로 부르는 **프롬프트 템플릿**
15
+ - **MCP 클라이언트** Claude Code·Cursor 등과 **공용 표준**. MCP 서버를 그대로 붙여 도구로 사용
16
+ - **플러그인**npm 으로 설치하거나 `.cdsa/plugins/` 에 JS 파일 **도구 자동 등록**
17
+ - **크로스포맷 스킬** — `.cdsa/skills/` 뿐 아니라 `.claude/commands/`·`.opencode/command/` 의 스킬도 인식
16
18
 
17
19
  ---
18
20
 
@@ -52,6 +54,7 @@ export OPENROUTER_API_KEY=sk-or-...
52
54
  | `/provider <이름>` | openai · anthropic · openrouter · mock 전환 |
53
55
  | `/model <이름>` | 모델 변경 |
54
56
  | `/teach` | 교육 모드 켜기/끄기 |
57
+ | `/stream` | 실시간 스트리밍 출력 켜기/끄기 |
55
58
  | `/context` | 지금 모델에 보내는 컨텍스트 들여다보기 |
56
59
  | `/reset` | 대화/컨텍스트 초기화 |
57
60
  | `/config` | 현재 설정값 |
@@ -108,10 +111,27 @@ export default {
108
111
  };
109
112
  ```
110
113
 
114
+ ## 🔗 MCP — 다른 에이전트와 플러그인 공유
115
+
116
+ [MCP](https://modelcontextprotocol.io) 서버는 Claude Code·Cursor·Zed 등이 함께 쓰는 **공용 도구 표준**입니다.
117
+ `config.json` 에 **그 도구들과 동일한 형식**으로 적으면, 다른 에이전트용으로 만든 MCP 서버를 cdsa-harness 에서 그대로 씁니다.
118
+
119
+ ```json
120
+ {
121
+ "mcpServers": {
122
+ "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path"] },
123
+ "github": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"], "env": { "GITHUB_TOKEN": "..." } }
124
+ }
125
+ }
126
+ ```
127
+
128
+ 연결되면 각 도구가 `mcp__서버__도구` 이름으로 모델에 노출됩니다. `/mcp` 로 확인.
129
+ (부수효과가 있을 수 있는 도구는 실행 전 승인을 받습니다 — `readOnlyHint` 가 있으면 자동.)
130
+
111
131
  ## 🎯 스킬 (프롬프트 템플릿)
112
132
 
113
- `.cdsa/skills/` 또는 `~/.cdsa_harness/skills/` 에 마크다운을 두면 `/파일명` 으로 실행됩니다.
114
- 본문의 `$ARGUMENTS` 명령 텍스트로 치환됩니다. `/skills` 로 목록 확인.
133
+ `.cdsa/skills/` 에 마크다운을 두면 `/파일명` 으로 실행됩니다. 본문의 `$ARGUMENTS`(또는 `{{args}}`)가 치환됩니다.
134
+ **다른 에이전트의 스킬도 인식** `.claude/commands/`, `.claude/skills/<이름>/SKILL.md`, `.opencode/command/` 등을 함께 읽습니다. `/skills` 로 목록 확인.
115
135
 
116
136
  ```markdown
117
137
  ---
@@ -165,6 +185,9 @@ node-cli/
165
185
  │ ├── llm.js # OpenAI/Anthropic/OpenRouter + mock, 응답 정규화(토큰·지연)
166
186
  │ ├── tools.js # 도구 + sandbox + diff
167
187
  │ ├── loop.js # ⭐ Agent Loop + 교육용 이벤트(컨텍스트/되먹임)
188
+ │ ├── mcp.js # MCP 클라이언트(stdio JSON-RPC) — 다른 에이전트와 공용
189
+ │ ├── plugins.js # 파일·npm 플러그인 발견/로드
190
+ │ ├── skills.js # 크로스포맷 스킬 로더
168
191
  │ ├── session.js # 세션 로그(JSONL)
169
192
  │ ├── banner.js / ui.js # 배너 · ANSI/박스/diff 렌더
170
193
  │ └── cli.js # REPL + 교육 모드 렌더 + /setup
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cdsa-harness",
3
- "version": "0.4.0",
4
- "description": "AI 에이전트의 내부 동작을 단계별로 드러내는 교육용 터미널 하네스. OpenAI/Claude/OpenRouter + npm 으로 설치하는 플러그인(추가 도구)·스킬 확장.",
3
+ "version": "0.6.0",
4
+ "description": "AI 에이전트의 내부 동작을 단계별로 드러내는 교육용 터미널 하네스. 실시간 스트리밍 + OpenAI/Claude/OpenRouter + MCP + npm 플러그인·크로스포맷 스킬.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "cdsa-harness": "bin/cdsa-harness.js",
package/src/cli.js CHANGED
@@ -17,6 +17,7 @@ import { execFileSync } from "node:child_process";
17
17
 
18
18
  import { AgentLoop, Step } from "./loop.js";
19
19
  import { LLMClient } from "./llm.js";
20
+ import { connectMcpServers } from "./mcp.js";
20
21
  import { discoverNpmExtensions, loadPlugins } from "./plugins.js";
21
22
  import { SessionLog, sessionsDir } from "./session.js";
22
23
  import { loadSkills, renderSkill } from "./skills.js";
@@ -45,15 +46,24 @@ function clip(s, n) {
45
46
  }
46
47
 
47
48
  // cfg.teach_mode 를 실행 중 토글할 수 있으므로 closure 로 cfg 를 잡아둔다.
48
- function makePrinter(cfg) {
49
+ // stream.active 는 onToken 과 공유하는 스트리밍 상태.
50
+ function makePrinter(cfg, stream) {
49
51
  return (ev) => {
50
- if (cfg.teach_mode) return printTeach(ev);
51
- return printCompact(ev);
52
+ if (cfg.teach_mode) return printTeach(ev, stream);
53
+ return printCompact(ev, stream);
52
54
  };
53
55
  }
54
56
 
57
+ function replyMetaLine(d) {
58
+ const meta = [];
59
+ if (d.latencyMs != null) meta.push(`응답 ${d.latencyMs}ms`);
60
+ if (d.usage) meta.push(`토큰 입력 ${d.usage.input ?? "?"}/출력 ${d.usage.output ?? "?"}/합계 ${d.usage.total ?? "?"}`);
61
+ if (d.request?.bodyBytes) meta.push(`요청 ${d.request.bodyBytes}B`);
62
+ return meta.length ? meta.join(" · ") : "";
63
+ }
64
+
55
65
  // ---- 교육(teach) 렌더: 내부 과정을 패널로 펼쳐 보여준다 ----
56
- function printTeach(ev) {
66
+ function printTeach(ev, stream) {
57
67
  const d = ev.data || {};
58
68
  switch (ev.step) {
59
69
  case Step.USER_INPUT:
@@ -84,16 +94,26 @@ function printTeach(ev) {
84
94
  }
85
95
 
86
96
  case Step.MODEL_REPLY: {
97
+ // 스트리밍으로 이미 본문이 출력된 경우: 줄바꿈 후 메타/도구호출만 덧붙인다.
98
+ if (d.streamed) {
99
+ if (stream && stream.active) {
100
+ process.stdout.write("\n");
101
+ stream.active = false;
102
+ }
103
+ for (const tc of d.toolCalls || []) {
104
+ console.log(c.yellow(` ↳ 도구 호출 요청: ${c.bold(tc.name)}(${clip(JSON.stringify(tc.args), 200)})`));
105
+ }
106
+ const meta = replyMetaLine(d);
107
+ if (meta) console.log(c.grey(" ─ " + meta));
108
+ return;
109
+ }
87
110
  const lines = [];
88
111
  if (ev.detail && ev.detail !== "(텍스트 없음)") lines.push(...clip(ev.detail, 1200).split("\n"));
89
112
  for (const tc of d.toolCalls || []) {
90
113
  lines.push(c.yellow(`↳ 도구 호출 요청: ${c.bold(tc.name)}(${clip(JSON.stringify(tc.args), 200)})`));
91
114
  }
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(" · ")));
115
+ const meta = replyMetaLine(d);
116
+ if (meta) lines.push(c.grey("─ " + meta));
97
117
  else lines.push(c.dim("(mock: 토큰/지연 측정 없음)"));
98
118
  console.log(panel(lines.length ? lines : ["(빈 응답)"], { title: "🤖 ③ 모델 응답 (원본 판단)", color: "green" }));
99
119
  return;
@@ -131,11 +151,17 @@ function printTeach(ev) {
131
151
  }
132
152
 
133
153
  // ---- 간결(compact) 렌더: 한 줄 위주 ----
134
- function printCompact(ev) {
154
+ function printCompact(ev, stream) {
135
155
  const [icon, color] = STEP_STYLE[ev.step] || ["•", "cyan"];
136
156
  const paint = c[color] || ((x) => x);
137
157
  if (ev.step === Step.APPROVAL && !ev.title.includes("자동 승인")) return;
138
158
  if (ev.step === Step.MODEL_REPLY) {
159
+ const d = ev.data || {};
160
+ if (d.streamed) {
161
+ if (stream && stream.active) { process.stdout.write("\n"); stream.active = false; }
162
+ for (const tc of d.toolCalls || []) console.log(c.yellow(` ↳ ${tc.name}(${clip(JSON.stringify(tc.args), 120)})`));
163
+ return;
164
+ }
139
165
  if (ev.detail && ev.detail !== "(텍스트 없음)") console.log(panel(ev.detail.split("\n"), { title: "🤖 모델", color: "green" }));
140
166
  return;
141
167
  }
@@ -148,7 +174,7 @@ function printCompact(ev) {
148
174
  console.log(line);
149
175
  }
150
176
 
151
- function makeApproval(rl) {
177
+ function makeApproval(ask) {
152
178
  return async (req) => {
153
179
  if (req.toolName === "write_file") {
154
180
  console.log(panel(renderDiff(req.diff || "(변경 미리보기 없음)"), {
@@ -160,12 +186,8 @@ function makeApproval(rl) {
160
186
  } else {
161
187
  console.log(panel([JSON.stringify(req.args)], { title: `🔐 ${req.toolLabel}`, color: "yellow" }));
162
188
  }
163
- let ans = "";
164
- try {
165
- ans = (await rl.question(c.yellow("이 작업을 승인하시겠습니까? [y/N] "))).trim().toLowerCase();
166
- } catch {
167
- ans = "";
168
- }
189
+ const raw = await ask(c.yellow("이 작업을 승인하시겠습니까? [y/N] "));
190
+ const ans = (raw || "").trim().toLowerCase();
169
191
  const approved = ans === "y" || ans === "yes";
170
192
  return { approved, reason: approved ? "" : "사용자가 거부했습니다." };
171
193
  };
@@ -192,6 +214,7 @@ function printIntro(cfg) {
192
214
  ["model", cfg.model],
193
215
  ["API 키", keySource],
194
216
  ["교육 모드", cfg.teach_mode ? c.green("ON (과정 펼쳐보기)") : "OFF"],
217
+ ["스트리밍", cfg.stream ? c.green("ON (실시간)") : "OFF"],
195
218
  ["작업 폴더", cfg.workspacePath()],
196
219
  ["승인 모드", cfg.approval_mode],
197
220
  ["셸 실행", cfg.allow_shell ? "허용" : "차단"],
@@ -216,9 +239,11 @@ function printHelp() {
216
239
  ` ${c.cyan("/provider")} <openai|anthropic|openrouter|mock> 제공자 변경`,
217
240
  ` ${c.cyan("/model")} <이름> 모델 변경`,
218
241
  ` ${c.cyan("/teach")} 교육 모드 켜기/끄기(내부 과정 펼쳐보기)`,
242
+ ` ${c.cyan("/stream")} 실시간 스트리밍 출력 켜기/끄기`,
219
243
  ` ${c.cyan("/context")} 지금 모델에 보내는 컨텍스트 들여다보기`,
220
244
  ` ${c.cyan("/skills")} 스킬 목록(.cdsa/skills 의 /명령들)`,
221
- ` ${c.cyan("/plugins")} 플러그인 목록(.cdsa/plugins 추가 도구)`,
245
+ ` ${c.cyan("/plugins")} 플러그인 목록(파일·npm 추가 도구)`,
246
+ ` ${c.cyan("/mcp")} 연결된 MCP 서버/도구(다른 에이전트와 공용)`,
222
247
  ` ${c.cyan("/reset")} 대화/컨텍스트 초기화`,
223
248
  ` ${c.cyan("/config")} 현재 설정값`,
224
249
  ` ${c.cyan("/quit")} 종료 (Ctrl+D)`,
@@ -232,8 +257,14 @@ function printHelp() {
232
257
  );
233
258
  }
234
259
 
260
+ // 붙여넣기한 키에 섞이기 쉬운 따옴표/공백/줄바꿈을 정리.
261
+ function cleanKey(s) {
262
+ return (s || "").trim().replace(/^["']|["']$/g, "").trim();
263
+ }
264
+
235
265
  // 대화형 연결 설정. 키는 환경변수가 있으면 그걸 우선 안내(파일 저장 안 함).
236
- async function runSetup(rl, cfg) {
266
+ // ask 가 null 을 주면(Ctrl+C) 조용히 취소.
267
+ async function runSetup(ask, cfg) {
237
268
  console.log(panel(
238
269
  [
239
270
  "어떤 AI 에 연결할까요? 번호를 입력하세요.",
@@ -244,8 +275,9 @@ async function runSetup(rl, cfg) {
244
275
  ],
245
276
  { title: "🔌 연결 설정 (/setup)", color: "cyan" }
246
277
  ));
247
- const pick = (await rl.question(c.cyan("제공자 번호 [1-4]: "))).trim();
248
- const provider = { "1": "openai", "2": "anthropic", "3": "openrouter", "4": "mock" }[pick];
278
+ const pickRaw = await ask(c.cyan("제공자 번호 [1-4] (취소: Enter): "));
279
+ if (pickRaw === null) return false;
280
+ const provider = { "1": "openai", "2": "anthropic", "3": "openrouter", "4": "mock" }[pickRaw.trim()];
249
281
  if (!provider) {
250
282
  console.log(c.yellow("취소했습니다."));
251
283
  return false;
@@ -257,27 +289,36 @@ async function runSetup(rl, cfg) {
257
289
  } else {
258
290
  const envName = ENV_KEYS[provider];
259
291
  const envVal = (process.env[envName] || "").trim();
292
+ let useExistingEnv = false;
260
293
  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
- }
294
+ const useEnv = (await ask(c.cyan(`환경변수 ${envName} 에서 키를 찾았어요. 사용할까요? [Y/n] `))) || "";
295
+ useExistingEnv = ["", "y", "yes"].includes(useEnv.trim().toLowerCase());
296
+ }
297
+ if (useExistingEnv) {
298
+ cfg.api_key = ""; // 환경변수 사용 파일엔 저장 안 함
268
299
  } else {
269
- console.log(c.dim(`(또는 종료 후 환경변수 ${envName} 에 키를 넣어두면 파일에 저장하지 않아도 됩니다)`));
270
- const k = (await rl.question(c.cyan("API 키 붙여넣기(입력이 보일 수 있음): "))).trim();
271
- cfg.api_key = k;
300
+ if (!envVal) console.log(c.dim(`(또는 종료 후 환경변수 ${envName} 에 키를 넣어두면 파일에 저장하지 않아도 됩니다)`));
301
+ const k = await ask(c.cyan("API 키 붙여넣기(붙여넣기 Enter): "));
302
+ if (k === null) {
303
+ console.log(c.yellow("취소했습니다."));
304
+ return false;
305
+ }
306
+ cfg.api_key = cleanKey(k);
272
307
  }
273
308
  const sugg = SUGGESTED_MODELS[provider] || [];
274
309
  const def = sugg[0] || "";
275
- const m = (await rl.question(c.cyan(`모델 [${def}] (추천: ${sugg.join(", ")}): `))).trim();
276
- cfg.model = m || def;
310
+ const note = provider === "openrouter" ? c.dim(" (OpenRouter 는 'provider/model' 형식)") : "";
311
+ const m = await ask(c.cyan(`모델 [${def}]${note}\n 추천: ${sugg.join(", ")}\n > `));
312
+ if (m === null) {
313
+ console.log(c.yellow("취소했습니다."));
314
+ return false;
315
+ }
316
+ cfg.model = m.trim() || def;
277
317
  }
278
318
 
279
319
  const saved = saveConfig(cfg);
280
320
  console.log(c.green(`설정을 저장했습니다 → ${saved}`));
321
+ console.log(c.dim(`provider=${cfg.provider} · model=${cfg.model}`));
281
322
  if (!cfg.isReady()) console.log(c.yellow("⚠️ 아직 키가 없어 호출이 실패할 수 있어요. 환경변수나 /setup 으로 키를 넣어주세요."));
282
323
  return true;
283
324
  }
@@ -290,6 +331,7 @@ function parseArgs(argv) {
290
331
  else if (a === "--help" || a === "-h") out.help = true;
291
332
  else if (a === "--setup") out.setup = true;
292
333
  else if (a === "--no-teach") out.noTeach = true;
334
+ else if (a === "--no-stream") out.noStream = true;
293
335
  else if (a === "--provider") out.provider = argv[++i];
294
336
  else if (a === "--model") out.model = argv[++i];
295
337
  else if (a === "--workspace") out.workspace = argv[++i];
@@ -310,6 +352,7 @@ export async function main(argv = []) {
310
352
  " --workspace <폴더경로>\n" +
311
353
  " --setup 대화형 연결 설정 실행\n" +
312
354
  " --no-teach 교육 모드 끄고 간결하게\n" +
355
+ " --no-stream 실시간 스트리밍 끄기\n" +
313
356
  " --auto 승인 자동(approval_mode=auto)\n" +
314
357
  " -h, --help 도움말\n\n" +
315
358
  "API 키는 환경변수로도 인식됩니다: OPENAI_API_KEY / ANTHROPIC_API_KEY / OPENROUTER_API_KEY\n"
@@ -341,14 +384,35 @@ export async function main(argv = []) {
341
384
  if (args.workspace) cfg.workspace = args.workspace;
342
385
  if (args.auto) cfg.approval_mode = "auto";
343
386
  if (args.noTeach) cfg.teach_mode = false;
387
+ if (args.noStream) cfg.stream = false;
344
388
 
345
389
  const rl = readline.createInterface({ input: stdin, output: stdout });
390
+ let session = null;
391
+ let mcp = { tools: [], servers: [], errors: [], closeAll: () => {} };
392
+
393
+ // Ctrl+C → 깔끔하게 종료(스택트레이스 없이). 어디서 누르든 안전.
394
+ const gracefulExit = () => {
395
+ try { rl.close(); } catch { /* */ }
396
+ try { session && session.close(); } catch { /* */ }
397
+ try { mcp && mcp.closeAll && mcp.closeAll(); } catch { /* */ }
398
+ console.log(c.dim("\n종료합니다. 안녕히 가세요!"));
399
+ process.exit(0);
400
+ };
401
+ rl.on("SIGINT", gracefulExit);
402
+ // 프롬프트 헬퍼: Ctrl+C(AbortError) 등은 null 로 돌려 호출부가 취소로 처리.
403
+ const ask = async (q) => {
404
+ try {
405
+ return await rl.question(q);
406
+ } catch {
407
+ return null;
408
+ }
409
+ };
346
410
 
347
411
  if (args.setup) {
348
- await runSetup(rl, cfg);
412
+ await runSetup(ask, cfg);
349
413
  } else if (!cfg.isReady() && cfg.provider !== "mock") {
350
414
  console.log(c.yellow(`provider=${cfg.provider} 인데 API 키가 없습니다. 연결 설정을 시작합니다.\n`));
351
- await runSetup(rl, cfg);
415
+ await runSetup(ask, cfg);
352
416
  }
353
417
 
354
418
  printIntro(cfg);
@@ -358,39 +422,58 @@ export async function main(argv = []) {
358
422
  // ② npm 패키지: cdsa-harness-plugin-* 자동 발견 + config.plugins 강제 로드
359
423
  const filePlugins = await loadPlugins(cfg.workspacePath());
360
424
  const npm = await discoverNpmExtensions(process.cwd(), cfg.plugins || []);
361
- const plugins = [...filePlugins, ...npm.plugins, ...npm.errors.map((e) => ({ error: e }))];
425
+ // MCP 서버(다른 에이전트와 공용 표준) 도구도 플러그인처럼 등록
426
+ if (cfg.mcpServers && Object.keys(cfg.mcpServers).length) {
427
+ process.stdout.write(c.dim("MCP 서버 연결 중...\r"));
428
+ mcp = await connectMcpServers(cfg.mcpServers);
429
+ }
430
+ const plugins = [
431
+ ...filePlugins,
432
+ ...npm.plugins,
433
+ ...mcp.tools,
434
+ ...npm.errors.map((e) => ({ error: e })),
435
+ ...mcp.errors.map((e) => ({ error: `MCP ${e}` })),
436
+ ];
362
437
  const skills = {};
363
438
  for (const s of npm.skills) skills[s.name] = { name: s.name, description: s.description || "", body: s.body, source: "(npm)" };
364
439
  Object.assign(skills, loadSkills(cfg.workspacePath())); // 로컬 파일 스킬이 우선
365
440
  const toolbox = new Toolbox(cfg.workspacePath(), cfg.allow_shell, plugins);
366
- if (toolbox.plugins.length || Object.keys(skills).length || toolbox.pluginErrors.length) {
441
+ if (toolbox.plugins.length || Object.keys(skills).length || toolbox.pluginErrors.length || mcp.servers.length) {
367
442
  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");
443
+ if (toolbox.plugins.length) bits.push(c.green(`도구 +${toolbox.plugins.length}개`));
444
+ if (mcp.servers.length) bits.push(c.green(`MCP ${mcp.servers.length}개`) + c.grey(` (${mcp.servers.map((s) => s.name).join(", ")})`));
445
+ if (Object.keys(skills).length) bits.push(c.green(`스킬 ${Object.keys(skills).length}개`));
446
+ if (toolbox.pluginErrors.length) bits.push(c.red(`오류 ${toolbox.pluginErrors.length}개`));
447
+ console.log("🔌 " + bits.join(" · ") + c.dim(" (/plugins /skills /mcp 로 상세)") + "\n");
372
448
  }
373
449
 
374
- const session = SessionLog.create();
450
+ session = SessionLog.create();
451
+ // 스트리밍: 토큰이 도착하는 대로 실시간 출력(첫 토큰에 헤더 1회).
452
+ const stream = { active: false };
453
+ const onToken = (chunk) => {
454
+ if (!stream.active) {
455
+ process.stdout.write("\n" + c.green("🤖 ③ 모델 응답 (스트리밍)") + "\n");
456
+ stream.active = true;
457
+ }
458
+ process.stdout.write(c.green(chunk));
459
+ };
375
460
  const loop = new AgentLoop({
376
461
  config: cfg,
377
462
  client: makeClient(cfg),
378
463
  toolbox,
379
- onEvent: makePrinter(cfg),
380
- approvalCallback: makeApproval(rl),
464
+ onEvent: makePrinter(cfg, stream),
465
+ approvalCallback: makeApproval(ask),
381
466
  session,
467
+ onToken,
382
468
  });
383
469
  loop.reset();
384
470
 
385
471
  const rule = () => console.log(c.grey("─".repeat(Math.min(80, stdout.columns || 80))));
386
472
 
387
473
  while (true) {
388
- let user;
389
- try {
390
- user = (await rl.question(c.bold(c.cyan("› ")))).trim();
391
- } catch {
392
- break;
393
- }
474
+ const raw = await ask(c.bold(c.cyan("› ")));
475
+ if (raw === null) break; // Ctrl+D / Ctrl+C / 스트림 종료
476
+ const user = raw.trim();
394
477
  if (!user) continue;
395
478
  const low = user.toLowerCase();
396
479
 
@@ -404,8 +487,13 @@ export async function main(argv = []) {
404
487
  console.log(c.green(`교육 모드 ${cfg.teach_mode ? "ON" : "OFF"}.`));
405
488
  continue;
406
489
  }
490
+ if (low === "/stream") {
491
+ cfg.stream = !cfg.stream;
492
+ console.log(c.green(`스트리밍 ${cfg.stream ? "ON (실시간 출력)" : "OFF"}.`));
493
+ continue;
494
+ }
407
495
  if (low === "/setup" || low === "/login") {
408
- await runSetup(rl, cfg);
496
+ await runSetup(ask, cfg);
409
497
  loop.client = makeClient(cfg);
410
498
  loop.reset();
411
499
  continue;
@@ -449,6 +537,16 @@ export async function main(argv = []) {
449
537
  console.log(panel(lines, { title: "🔌 플러그인 (모델이 쓸 수 있는 추가 도구)", color: "blue" }));
450
538
  continue;
451
539
  }
540
+ if (low === "/mcp") {
541
+ const lines = [];
542
+ if (!mcp.servers.length) lines.push(c.dim("연결된 MCP 서버가 없습니다."));
543
+ for (const s of mcp.servers) lines.push(`${c.bold(s.name)} ${c.grey(`도구 ${s.count}개`)}`);
544
+ for (const t of mcp.tools) lines.push(` ${c.cyan(t.name)} ${c.grey(clip(t.description, 60))}`);
545
+ for (const e of mcp.errors) lines.push(c.red("✖ " + e));
546
+ lines.push(c.dim('설정: config.json 의 "mcpServers" (Claude Code/Cursor 와 동일 형식)'));
547
+ console.log(panel(lines, { title: "🔗 MCP 서버 (다른 에이전트와 공용)", color: "blue" }));
548
+ continue;
549
+ }
452
550
  if (low === "/skills") {
453
551
  const names = Object.keys(skills);
454
552
  const lines = names.length ? names.map((n) => `${c.cyan("/" + n)} ${c.grey(skills[n].description || "")}`) : [c.dim("등록된 스킬이 없습니다.")];
@@ -487,6 +585,7 @@ export async function main(argv = []) {
487
585
 
488
586
  rl.close();
489
587
  session.close();
588
+ mcp.closeAll();
490
589
  console.log(c.dim("\n종료합니다. 안녕히 가세요!"));
491
590
  return 0;
492
591
  }
package/src/config.js CHANGED
@@ -9,10 +9,11 @@ export const PROVIDERS = ["openai", "anthropic", "openrouter", "mock"];
9
9
  export const SUGGESTED_MODELS = {
10
10
  openai: ["gpt-4o-mini", "gpt-4.1-mini", "gpt-4.1"],
11
11
  anthropic: ["claude-3-5-haiku-latest", "claude-3-5-sonnet-latest", "claude-sonnet-4-5"],
12
+ // OpenRouter 는 반드시 'provider/model' 형식. (옛 anthropic/claude-3.5-sonnet 등은 404 가능)
12
13
  openrouter: [
13
14
  "openai/gpt-4o-mini",
14
- "anthropic/claude-3.5-sonnet",
15
- "google/gemini-2.5-flash",
15
+ "anthropic/claude-3.7-sonnet",
16
+ "google/gemini-2.0-flash-001",
16
17
  ],
17
18
  mock: ["mock-agent"],
18
19
  };
@@ -37,7 +38,9 @@ const DEFAULTS = {
37
38
  temperature: 0.2,
38
39
  max_tokens: 1024,
39
40
  teach_mode: true,
41
+ stream: true, // 모델 응답을 실시간(토큰 단위)으로 출력
40
42
  plugins: [], // 추가로 불러올 npm 플러그인 패키지 이름(이름 규칙과 무관하게 강제 로드)
43
+ mcpServers: {}, // MCP 서버 설정 (Claude Code/Cursor 와 동일한 형식)
41
44
  };
42
45
 
43
46
  export function configDir() {
package/src/llm.js CHANGED
@@ -23,13 +23,43 @@ export class LLMClient {
23
23
  this.timeout = timeout;
24
24
  }
25
25
 
26
- async chat(messages, tools) {
27
- if (this.provider === "mock") return mockChat(messages);
28
- if (this.provider === "anthropic") return this._anthropicChat(messages, tools);
29
- if (ENDPOINTS[this.provider]) return this._openaiChat(messages, tools);
26
+ // onToken(chunk) 를 주면 텍스트가 도착하는 대로 콜백한다(스트리밍).
27
+ async chat(messages, tools, onToken = null) {
28
+ if (this.provider === "mock") {
29
+ const r = mockChat(messages);
30
+ if (onToken && r.content) await streamText(r.content, onToken);
31
+ return r;
32
+ }
33
+ if (this.provider === "anthropic") {
34
+ return onToken ? this._anthropicStream(messages, tools, onToken) : this._anthropicChat(messages, tools);
35
+ }
36
+ if (ENDPOINTS[this.provider]) {
37
+ return onToken ? this._openaiStream(messages, tools, onToken) : this._openaiChat(messages, tools);
38
+ }
30
39
  throw new LLMError(`지원하지 않는 provider 입니다: ${this.provider}`);
31
40
  }
32
41
 
42
+ // 스트리밍용: 응답 객체(본문 스트림)를 그대로 받는다.
43
+ async _openStream(url, headers, body) {
44
+ const json = JSON.stringify(body);
45
+ const ctrl = new AbortController();
46
+ const timer = setTimeout(() => ctrl.abort(), this.timeout);
47
+ const started = Date.now();
48
+ let res;
49
+ try {
50
+ res = await fetch(url, { method: "POST", headers, body: json, signal: ctrl.signal });
51
+ } catch (e) {
52
+ clearTimeout(timer);
53
+ throw new LLMError(`네트워크 오류: ${e.message}`);
54
+ }
55
+ if (!res.ok) {
56
+ clearTimeout(timer);
57
+ const text = await res.text().catch(() => "");
58
+ throw new LLMError(httpErrorMessage(res.status, text || res.statusText));
59
+ }
60
+ return { res, started, timer, bodyBytes: Buffer.byteLength(json, "utf8") };
61
+ }
62
+
33
63
  async _post(url, headers, body) {
34
64
  const json = JSON.stringify(body);
35
65
  const ctrl = new AbortController();
@@ -46,7 +76,7 @@ export class LLMClient {
46
76
  const latencyMs = Date.now() - started;
47
77
  if (!res.ok) {
48
78
  const text = await res.text().catch(() => "");
49
- throw new LLMError(`API 오류 ${res.status}: ${trim(text) || res.statusText}`);
79
+ throw new LLMError(httpErrorMessage(res.status, text || res.statusText));
50
80
  }
51
81
  return { payload: await res.json(), latencyMs, bodyBytes: Buffer.byteLength(json, "utf8") };
52
82
  }
@@ -94,6 +124,87 @@ export class LLMClient {
94
124
  };
95
125
  }
96
126
 
127
+ // --- OpenAI/OpenRouter 스트리밍 ---
128
+ async _openaiStream(messages, tools, onToken) {
129
+ const url = ENDPOINTS[this.provider];
130
+ const body = { model: this.model, messages, temperature: this.temperature, stream: true, stream_options: { include_usage: true } };
131
+ if (tools && tools.length) {
132
+ body.tools = tools;
133
+ body.tool_choice = "auto";
134
+ }
135
+ const headers = { Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json" };
136
+ if (this.provider === "openrouter") {
137
+ headers["HTTP-Referer"] = "https://github.com/cdsassj00/miniharness";
138
+ headers["X-Title"] = "CDSA Harness";
139
+ }
140
+ const { res, started, timer, bodyBytes } = await this._openStream(url, headers, body);
141
+ let content = "";
142
+ const tcMap = new Map(); // index -> {id,name,args}
143
+ let usage = null;
144
+ try {
145
+ await readSSE(res, (data) => {
146
+ if (data === "[DONE]") return;
147
+ let json;
148
+ try { json = JSON.parse(data); } catch { return; }
149
+ if (json.usage) usage = { input: json.usage.prompt_tokens ?? null, output: json.usage.completion_tokens ?? null, total: json.usage.total_tokens ?? null };
150
+ const delta = json.choices?.[0]?.delta;
151
+ if (!delta) return;
152
+ if (delta.content) { content += delta.content; onToken(delta.content); }
153
+ for (const tc of delta.tool_calls || []) {
154
+ const i = tc.index ?? 0;
155
+ const cur = tcMap.get(i) || { id: tc.id || `call_${i}`, name: "", args: "" };
156
+ if (tc.id) cur.id = tc.id;
157
+ if (tc.function?.name) cur.name += tc.function.name;
158
+ if (tc.function?.arguments) cur.args += tc.function.arguments;
159
+ tcMap.set(i, cur);
160
+ }
161
+ });
162
+ } finally {
163
+ clearTimeout(timer);
164
+ }
165
+ const toolCalls = [...tcMap.values()].map((t) => ({ id: t.id, name: t.name, args: safeParse(t.args) }));
166
+ return { content: content || null, toolCalls, usage, latencyMs: Date.now() - started, request: this._meta(url, tools, bodyBytes) };
167
+ }
168
+
169
+ // --- Anthropic 스트리밍 ---
170
+ async _anthropicStream(messages, tools, onToken) {
171
+ const url = ENDPOINTS.anthropic;
172
+ const body = toAnthropicBody(messages, tools, this.model, this.temperature, this.maxTokens);
173
+ body.stream = true;
174
+ const headers = { "x-api-key": this.apiKey, "anthropic-version": "2023-06-01", "Content-Type": "application/json" };
175
+ const { res, started, timer, bodyBytes } = await this._openStream(url, headers, body);
176
+ let content = "";
177
+ const blocks = new Map(); // index -> {type,name,id,json}
178
+ let usage = { input: null, output: null, total: 0 };
179
+ try {
180
+ await readSSE(res, (data) => {
181
+ let ev;
182
+ try { ev = JSON.parse(data); } catch { return; }
183
+ if (ev.type === "message_start" && ev.message?.usage) usage.input = ev.message.usage.input_tokens ?? null;
184
+ else if (ev.type === "content_block_start") {
185
+ const b = ev.content_block || {};
186
+ blocks.set(ev.index, { type: b.type, name: b.name, id: b.id, json: "" });
187
+ } else if (ev.type === "content_block_delta") {
188
+ const d = ev.delta || {};
189
+ if (d.type === "text_delta") { content += d.text; onToken(d.text); }
190
+ else if (d.type === "input_json_delta") {
191
+ const cur = blocks.get(ev.index);
192
+ if (cur) cur.json += d.partial_json || "";
193
+ }
194
+ } else if (ev.type === "message_delta" && ev.usage) {
195
+ usage.output = ev.usage.output_tokens ?? usage.output;
196
+ }
197
+ });
198
+ } finally {
199
+ clearTimeout(timer);
200
+ }
201
+ usage.total = (usage.input || 0) + (usage.output || 0);
202
+ const toolCalls = [...blocks.values()]
203
+ .filter((b) => b.type === "tool_use")
204
+ .map((b) => ({ id: b.id, name: b.name, args: safeParse(b.json) }));
205
+ return { content: content || null, toolCalls, usage, latencyMs: Date.now() - started, request: this._meta(url, tools, bodyBytes) };
206
+ }
207
+
97
208
  _meta(endpoint, tools, bodyBytes) {
98
209
  return {
99
210
  provider: this.provider,
@@ -111,6 +222,51 @@ function trim(s) {
111
222
  return s.length > 400 ? s.slice(0, 400) + " …" : s;
112
223
  }
113
224
 
225
+ function safeParse(jsonStr) {
226
+ try {
227
+ return JSON.parse(jsonStr || "{}");
228
+ } catch {
229
+ return { _raw: jsonStr };
230
+ }
231
+ }
232
+
233
+ function httpErrorMessage(status, text) {
234
+ let msg = `API 오류 ${status}: ${trim(text)}`;
235
+ if (status === 404) {
236
+ msg += "\n ↳ 모델 이름을 확인하세요. /model 로 변경 가능. " +
237
+ "OpenRouter 는 'provider/model' 형식이어야 합니다 (예: openai/gpt-4o-mini, anthropic/claude-3.7-sonnet).";
238
+ } else if (status === 401 || status === 403) {
239
+ msg += "\n ↳ API 키가 잘못되었거나 권한이 없어요. /setup 으로 다시 연결하세요.";
240
+ } else if (status === 429) {
241
+ msg += "\n ↳ 요청이 너무 많거나 크레딧이 부족할 수 있어요(잠시 후 재시도).";
242
+ }
243
+ return msg;
244
+ }
245
+
246
+ // SSE(data: ...) 본문 스트림을 줄 단위로 콜백. onEvent(dataString) 호출(‘[DONE]’ 포함).
247
+ async function readSSE(res, onEvent) {
248
+ let buf = "";
249
+ for await (const chunk of res.body) {
250
+ buf += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
251
+ let nl;
252
+ while ((nl = buf.indexOf("\n")) >= 0) {
253
+ const line = buf.slice(0, nl).trim();
254
+ buf = buf.slice(nl + 1);
255
+ if (line.startsWith("data:")) onEvent(line.slice(5).trim());
256
+ }
257
+ }
258
+ if (buf.trim().startsWith("data:")) onEvent(buf.trim().slice(5).trim());
259
+ }
260
+
261
+ // mock/공통: 텍스트를 토큰처럼 쪼개 콜백(TTY 면 살짝 지연해 흐르는 효과).
262
+ async function streamText(text, onToken) {
263
+ const parts = String(text).match(/\S+\s*|\s+/g) || [String(text)];
264
+ for (const p of parts) {
265
+ onToken(p);
266
+ if (process.stdout.isTTY) await new Promise((r) => setTimeout(r, 10));
267
+ }
268
+ }
269
+
114
270
  function parseOpenAiReply(payload) {
115
271
  const msg = payload?.choices?.[0]?.message;
116
272
  if (!msg) {
package/src/loop.js CHANGED
@@ -79,13 +79,14 @@ function findRules(workspace) {
79
79
  }
80
80
 
81
81
  export class AgentLoop {
82
- constructor({ config, client, toolbox, onEvent, approvalCallback, session = null }) {
82
+ constructor({ config, client, toolbox, onEvent, approvalCallback, session = null, onToken = null }) {
83
83
  this.config = config;
84
84
  this.client = client;
85
85
  this.toolbox = toolbox;
86
86
  this.onEvent = onEvent;
87
87
  this.approvalCallback = approvalCallback;
88
88
  this.session = session;
89
+ this.onToken = onToken; // 스트리밍 토큰 콜백(있으면 실시간 출력)
89
90
  this.messages = [];
90
91
  }
91
92
 
@@ -168,9 +169,10 @@ export class AgentLoop {
168
169
  }
169
170
  );
170
171
 
172
+ const streaming = Boolean(this.onToken && this.config.stream);
171
173
  let reply;
172
174
  try {
173
- reply = await this.client.chat(this.messages, tools);
175
+ reply = await this.client.chat(this.messages, tools, streaming ? this.onToken : null);
174
176
  } catch (e) {
175
177
  if (e instanceof LLMError) {
176
178
  this._emit(Step.ERROR, "LLM 오류", e.message);
@@ -180,11 +182,13 @@ export class AgentLoop {
180
182
  }
181
183
 
182
184
  // ③ 모델의 원본 판단 + 실측 메타(응답시간/토큰/요청크기)를 드러낸다.
185
+ // streamed=true 면 텍스트는 이미 실시간 출력됨 → UI 는 메타만 덧붙인다.
183
186
  this._emit(Step.MODEL_REPLY, "모델 응답", reply.content || "(텍스트 없음)", {
184
187
  toolCalls: reply.toolCalls.map((tc) => ({ name: tc.name, args: tc.args })),
185
188
  usage: reply.usage || null,
186
189
  latencyMs: reply.latencyMs ?? null,
187
190
  request: reply.request || null,
191
+ streamed: streaming && Boolean(reply.content),
188
192
  });
189
193
  if (reply.content) finalText = reply.content;
190
194
 
package/src/mcp.js ADDED
@@ -0,0 +1,148 @@
1
+ // MCP (Model Context Protocol) 클라이언트 — 상호운용의 핵심.
2
+ // Claude Code · Cursor · Zed 등에서 쓰는 'MCP 서버'를 그대로 붙여 쓴다.
3
+ // 설정은 그 도구들과 동일한 형식:
4
+ // "mcpServers": { "이름": { "command": "npx", "args": ["-y","..."], "env": {} } }
5
+ //
6
+ // stdio 전송(JSON-RPC 2.0, 줄바꿈 구분). 외부 의존성 없음(node child_process).
7
+ import { spawn } from "node:child_process";
8
+
9
+ const PROTOCOL_VERSION = "2024-11-05";
10
+
11
+ export class McpServer {
12
+ constructor(name, spec) {
13
+ this.name = name;
14
+ this.spec = spec || {};
15
+ this.proc = null;
16
+ this.nextId = 1;
17
+ this.pending = new Map();
18
+ this.buf = "";
19
+ this.tools = [];
20
+ }
21
+
22
+ async start(timeout = 20000) {
23
+ if (!this.spec.command) throw new Error("command 가 없습니다");
24
+ this.proc = spawn(this.spec.command, this.spec.args || [], {
25
+ env: { ...process.env, ...(this.spec.env || {}) },
26
+ stdio: ["pipe", "pipe", "pipe"],
27
+ });
28
+ this.proc.stdout.on("data", (d) => this._onData(d));
29
+ this.proc.stderr.on("data", () => {}); // 서버 로그는 무시
30
+ this.proc.on("error", (e) => this._failAll(e));
31
+ this.proc.on("exit", () => this._failAll(new Error("MCP 서버 프로세스 종료")));
32
+
33
+ await this._request(
34
+ "initialize",
35
+ { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: "cdsa-harness", version: "0.5.0" } },
36
+ timeout
37
+ );
38
+ this._notify("notifications/initialized", {});
39
+ const list = await this._request("tools/list", {}, timeout);
40
+ this.tools = (list && list.tools) || [];
41
+ return this.tools;
42
+ }
43
+
44
+ _onData(chunk) {
45
+ this.buf += chunk.toString("utf8");
46
+ let nl;
47
+ while ((nl = this.buf.indexOf("\n")) >= 0) {
48
+ const line = this.buf.slice(0, nl);
49
+ this.buf = this.buf.slice(nl + 1);
50
+ if (!line.trim()) continue;
51
+ let msg;
52
+ try {
53
+ msg = JSON.parse(line);
54
+ } catch {
55
+ continue; // 프레이밍 깨진 줄 무시
56
+ }
57
+ if (msg.id != null && this.pending.has(msg.id)) {
58
+ const { resolve, reject } = this.pending.get(msg.id);
59
+ this.pending.delete(msg.id);
60
+ if (msg.error) reject(new Error(msg.error.message || JSON.stringify(msg.error)));
61
+ else resolve(msg.result);
62
+ }
63
+ }
64
+ }
65
+
66
+ _request(method, params, timeout = 20000) {
67
+ const id = this.nextId++;
68
+ const payload = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
69
+ return new Promise((resolve, reject) => {
70
+ const timer = setTimeout(() => {
71
+ this.pending.delete(id);
72
+ reject(new Error(`MCP ${method} 시간초과`));
73
+ }, timeout);
74
+ this.pending.set(id, {
75
+ resolve: (r) => { clearTimeout(timer); resolve(r); },
76
+ reject: (e) => { clearTimeout(timer); reject(e); },
77
+ });
78
+ try {
79
+ this.proc.stdin.write(payload);
80
+ } catch (e) {
81
+ clearTimeout(timer);
82
+ this.pending.delete(id);
83
+ reject(e);
84
+ }
85
+ });
86
+ }
87
+
88
+ _notify(method, params) {
89
+ try {
90
+ this.proc.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n");
91
+ } catch {
92
+ /* ignore */
93
+ }
94
+ }
95
+
96
+ _failAll(err) {
97
+ for (const { reject } of this.pending.values()) reject(err);
98
+ this.pending.clear();
99
+ }
100
+
101
+ async callTool(name, args) {
102
+ const res = await this._request("tools/call", { name, arguments: args || {} });
103
+ const parts = ((res && res.content) || []).map((b) =>
104
+ b.type === "text" ? b.text : b.type === "json" ? JSON.stringify(b.json) : `[${b.type}]`
105
+ );
106
+ const text = parts.join("\n") || (res && res.isError ? "(오류)" : "(빈 결과)");
107
+ return res && res.isError ? `MCP 오류: ${text}` : text;
108
+ }
109
+
110
+ stop() {
111
+ try {
112
+ this.proc && this.proc.kill();
113
+ } catch {
114
+ /* ignore */
115
+ }
116
+ }
117
+ }
118
+
119
+ // 여러 MCP 서버에 연결하고, 각 도구를 '플러그인 형식' tool def 로 변환해 돌려준다.
120
+ // (Toolbox/loop 가 플러그인과 동일하게 다룰 수 있음)
121
+ export async function connectMcpServers(servers = {}) {
122
+ const out = { tools: [], servers: [], errors: [], instances: [] };
123
+ for (const [name, spec] of Object.entries(servers)) {
124
+ if (!spec || spec.disabled) continue;
125
+ const srv = new McpServer(name, spec);
126
+ try {
127
+ const tools = await srv.start();
128
+ out.instances.push(srv);
129
+ out.servers.push({ name, count: tools.length });
130
+ for (const t of tools) {
131
+ const readOnly = t.annotations && t.annotations.readOnlyHint === true;
132
+ out.tools.push({
133
+ name: `mcp__${name}__${t.name}`,
134
+ description: t.description || `MCP(${name}) 도구 ${t.name}`,
135
+ parameters: t.inputSchema || { type: "object", properties: {} },
136
+ mutating: !readOnly, // 읽기전용 힌트가 없으면 보수적으로 승인 필요
137
+ handler: async (args) => srv.callTool(t.name, args),
138
+ source: `mcp:${name}`,
139
+ });
140
+ }
141
+ } catch (e) {
142
+ out.errors.push(`${name}: ${e.message}`);
143
+ srv.stop();
144
+ }
145
+ }
146
+ out.closeAll = () => out.instances.forEach((s) => s.stop());
147
+ return out;
148
+ }
package/src/skills.js CHANGED
@@ -11,10 +11,21 @@ import fs from "node:fs";
11
11
  import os from "node:os";
12
12
  import path from "node:path";
13
13
 
14
+ // 우리 폴더 + 다른 코딩 에이전트들의 커맨드/스킬 폴더도 함께 읽어 상호운용한다.
15
+ // (Claude Code, OpenCode 등은 스킬이 결국 frontmatter 달린 마크다운이라 그대로 호환)
14
16
  export function skillDirs(workspace) {
17
+ const home = os.homedir();
15
18
  return [
16
- path.join(os.homedir(), ".cdsa_harness", "skills"),
19
+ // 전역
20
+ path.join(home, ".cdsa_harness", "skills"),
21
+ path.join(home, ".claude", "commands"),
22
+ path.join(home, ".config", "opencode", "command"),
23
+ // 작업 폴더
17
24
  path.join(workspace, ".cdsa", "skills"),
25
+ path.join(workspace, ".claude", "commands"),
26
+ path.join(workspace, ".claude", "skills"),
27
+ path.join(workspace, ".opencode", "command"),
28
+ path.join(workspace, ".github", "prompts"),
18
29
  ];
19
30
  }
20
31
 
@@ -29,33 +40,54 @@ function parseFrontmatter(text) {
29
40
  return { meta, body: text.slice(m[0].length).trim() };
30
41
  }
31
42
 
43
+ function addSkill(skills, name, file) {
44
+ if (!name || skills[name]) return; // 먼저 발견된 것 우선
45
+ try {
46
+ const raw = fs.readFileSync(file, "utf8");
47
+ const { meta, body } = parseFrontmatter(raw);
48
+ skills[name] = { name, description: meta.description || "", body, source: file };
49
+ } catch {
50
+ /* ignore unreadable skill */
51
+ }
52
+ }
53
+
32
54
  export function loadSkills(workspace) {
33
55
  const skills = {};
34
56
  for (const dir of skillDirs(workspace)) {
35
- let files = [];
57
+ let entries = [];
36
58
  try {
37
59
  if (!fs.existsSync(dir)) continue;
38
- files = fs.readdirSync(dir).filter((f) => f.endsWith(".md")).sort();
60
+ entries = fs.readdirSync(dir).sort();
39
61
  } catch {
40
62
  continue;
41
63
  }
42
- for (const f of files) {
43
- const name = f.replace(/\.md$/, "");
64
+ for (const e of entries) {
65
+ const full = path.join(dir, e);
66
+ let stat;
44
67
  try {
45
- const raw = fs.readFileSync(path.join(dir, f), "utf8");
46
- const { meta, body } = parseFrontmatter(raw);
47
- skills[name] = { name, description: meta.description || "", body, source: path.join(dir, f) };
68
+ stat = fs.statSync(full);
48
69
  } catch {
49
- /* ignore unreadable skill */
70
+ continue;
71
+ }
72
+ if (stat.isFile() && e.endsWith(".md")) {
73
+ addSkill(skills, e.replace(/\.md$/, ""), full);
74
+ } else if (stat.isDirectory()) {
75
+ // Claude Code 스킬 형식: <이름>/SKILL.md
76
+ const skillMd = path.join(full, "SKILL.md");
77
+ if (fs.existsSync(skillMd)) addSkill(skills, e, skillMd);
50
78
  }
51
79
  }
52
80
  }
53
81
  return skills;
54
82
  }
55
83
 
56
- // 스킬 본문에 인자를 채워 최종 프롬프트를 만든다.
84
+ // 스킬 본문에 인자를 채워 최종 프롬프트를 만든다. $ARGUMENTS / {{args}} 둘 다 지원.
57
85
  export function renderSkill(skill, args) {
58
86
  const argStr = (args || "").trim();
59
- if (skill.body.includes("$ARGUMENTS")) return skill.body.replace(/\$ARGUMENTS/g, argStr);
60
- return argStr ? `${skill.body}\n\n[추가 입력]\n${argStr}` : skill.body;
87
+ let body = skill.body;
88
+ const hasPlaceholder = /\$ARGUMENTS|\{\{\s*args\s*\}\}/.test(body);
89
+ if (hasPlaceholder) {
90
+ return body.replace(/\$ARGUMENTS/g, argStr).replace(/\{\{\s*args\s*\}\}/g, argStr);
91
+ }
92
+ return argStr ? `${body}\n\n[추가 입력]\n${argStr}` : body;
61
93
  }