cdsa-harness 0.4.0 → 0.5.1

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
@@ -11,8 +11,9 @@
11
11
  - **의존성 0개** — Node 18+ 내장 기능만(`fetch`/`readline`/`node:test`)
12
12
  - **실제 LLM 연결** — OpenAI · Anthropic(Claude) · OpenRouter, 또는 키 없이 `mock`
13
13
  - **교육 모드** — 매 반복마다 모델에 보내는 메시지 구성·추정 토큰·시스템 프롬프트, 실제 토큰 사용량/응답시간까지 그대로 표시
14
- - **플러그인**`.cdsa/plugins/` JS 파일을 두면 **새 도구가 자동 등록**되어 모델이 사용
15
- - **스킬** — `.cdsa/skills/` 에 마크다운을 두면 `/이름` 으로 부르는 **프롬프트 템플릿**
14
+ - **MCP 클라이언트** Claude Code·Cursor 등과 **공용 표준**. MCP 서버를 그대로 붙여 도구로 사용
15
+ - **플러그인**npm 으로 설치하거나 `.cdsa/plugins/` 에 JS 파일 **도구 자동 등록**
16
+ - **크로스포맷 스킬** — `.cdsa/skills/` 뿐 아니라 `.claude/commands/`·`.opencode/command/` 의 스킬도 인식
16
17
 
17
18
  ---
18
19
 
@@ -108,10 +109,27 @@ export default {
108
109
  };
109
110
  ```
110
111
 
112
+ ## 🔗 MCP — 다른 에이전트와 플러그인 공유
113
+
114
+ [MCP](https://modelcontextprotocol.io) 서버는 Claude Code·Cursor·Zed 등이 함께 쓰는 **공용 도구 표준**입니다.
115
+ `config.json` 에 **그 도구들과 동일한 형식**으로 적으면, 다른 에이전트용으로 만든 MCP 서버를 cdsa-harness 에서 그대로 씁니다.
116
+
117
+ ```json
118
+ {
119
+ "mcpServers": {
120
+ "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path"] },
121
+ "github": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"], "env": { "GITHUB_TOKEN": "..." } }
122
+ }
123
+ }
124
+ ```
125
+
126
+ 연결되면 각 도구가 `mcp__서버__도구` 이름으로 모델에 노출됩니다. `/mcp` 로 확인.
127
+ (부수효과가 있을 수 있는 도구는 실행 전 승인을 받습니다 — `readOnlyHint` 가 있으면 자동.)
128
+
111
129
  ## 🎯 스킬 (프롬프트 템플릿)
112
130
 
113
- `.cdsa/skills/` 또는 `~/.cdsa_harness/skills/` 에 마크다운을 두면 `/파일명` 으로 실행됩니다.
114
- 본문의 `$ARGUMENTS` 명령 텍스트로 치환됩니다. `/skills` 로 목록 확인.
131
+ `.cdsa/skills/` 에 마크다운을 두면 `/파일명` 으로 실행됩니다. 본문의 `$ARGUMENTS`(또는 `{{args}}`)가 치환됩니다.
132
+ **다른 에이전트의 스킬도 인식** `.claude/commands/`, `.claude/skills/<이름>/SKILL.md`, `.opencode/command/` 등을 함께 읽습니다. `/skills` 로 목록 확인.
115
133
 
116
134
  ```markdown
117
135
  ---
@@ -165,6 +183,9 @@ node-cli/
165
183
  │ ├── llm.js # OpenAI/Anthropic/OpenRouter + mock, 응답 정규화(토큰·지연)
166
184
  │ ├── tools.js # 도구 + sandbox + diff
167
185
  │ ├── loop.js # ⭐ Agent Loop + 교육용 이벤트(컨텍스트/되먹임)
186
+ │ ├── mcp.js # MCP 클라이언트(stdio JSON-RPC) — 다른 에이전트와 공용
187
+ │ ├── plugins.js # 파일·npm 플러그인 발견/로드
188
+ │ ├── skills.js # 크로스포맷 스킬 로더
168
189
  │ ├── session.js # 세션 로그(JSONL)
169
190
  │ ├── banner.js / ui.js # 배너 · ANSI/박스/diff 렌더
170
191
  │ └── 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.5.1",
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";
@@ -148,7 +149,7 @@ function printCompact(ev) {
148
149
  console.log(line);
149
150
  }
150
151
 
151
- function makeApproval(rl) {
152
+ function makeApproval(ask) {
152
153
  return async (req) => {
153
154
  if (req.toolName === "write_file") {
154
155
  console.log(panel(renderDiff(req.diff || "(변경 미리보기 없음)"), {
@@ -160,12 +161,8 @@ function makeApproval(rl) {
160
161
  } else {
161
162
  console.log(panel([JSON.stringify(req.args)], { title: `🔐 ${req.toolLabel}`, color: "yellow" }));
162
163
  }
163
- let ans = "";
164
- try {
165
- ans = (await rl.question(c.yellow("이 작업을 승인하시겠습니까? [y/N] "))).trim().toLowerCase();
166
- } catch {
167
- ans = "";
168
- }
164
+ const raw = await ask(c.yellow("이 작업을 승인하시겠습니까? [y/N] "));
165
+ const ans = (raw || "").trim().toLowerCase();
169
166
  const approved = ans === "y" || ans === "yes";
170
167
  return { approved, reason: approved ? "" : "사용자가 거부했습니다." };
171
168
  };
@@ -218,7 +215,8 @@ function printHelp() {
218
215
  ` ${c.cyan("/teach")} 교육 모드 켜기/끄기(내부 과정 펼쳐보기)`,
219
216
  ` ${c.cyan("/context")} 지금 모델에 보내는 컨텍스트 들여다보기`,
220
217
  ` ${c.cyan("/skills")} 스킬 목록(.cdsa/skills 의 /명령들)`,
221
- ` ${c.cyan("/plugins")} 플러그인 목록(.cdsa/plugins 추가 도구)`,
218
+ ` ${c.cyan("/plugins")} 플러그인 목록(파일·npm 추가 도구)`,
219
+ ` ${c.cyan("/mcp")} 연결된 MCP 서버/도구(다른 에이전트와 공용)`,
222
220
  ` ${c.cyan("/reset")} 대화/컨텍스트 초기화`,
223
221
  ` ${c.cyan("/config")} 현재 설정값`,
224
222
  ` ${c.cyan("/quit")} 종료 (Ctrl+D)`,
@@ -232,8 +230,14 @@ function printHelp() {
232
230
  );
233
231
  }
234
232
 
233
+ // 붙여넣기한 키에 섞이기 쉬운 따옴표/공백/줄바꿈을 정리.
234
+ function cleanKey(s) {
235
+ return (s || "").trim().replace(/^["']|["']$/g, "").trim();
236
+ }
237
+
235
238
  // 대화형 연결 설정. 키는 환경변수가 있으면 그걸 우선 안내(파일 저장 안 함).
236
- async function runSetup(rl, cfg) {
239
+ // ask 가 null 을 주면(Ctrl+C) 조용히 취소.
240
+ async function runSetup(ask, cfg) {
237
241
  console.log(panel(
238
242
  [
239
243
  "어떤 AI 에 연결할까요? 번호를 입력하세요.",
@@ -244,8 +248,9 @@ async function runSetup(rl, cfg) {
244
248
  ],
245
249
  { title: "🔌 연결 설정 (/setup)", color: "cyan" }
246
250
  ));
247
- const pick = (await rl.question(c.cyan("제공자 번호 [1-4]: "))).trim();
248
- const provider = { "1": "openai", "2": "anthropic", "3": "openrouter", "4": "mock" }[pick];
251
+ const pickRaw = await ask(c.cyan("제공자 번호 [1-4] (취소: Enter): "));
252
+ if (pickRaw === null) return false;
253
+ const provider = { "1": "openai", "2": "anthropic", "3": "openrouter", "4": "mock" }[pickRaw.trim()];
249
254
  if (!provider) {
250
255
  console.log(c.yellow("취소했습니다."));
251
256
  return false;
@@ -257,27 +262,36 @@ async function runSetup(rl, cfg) {
257
262
  } else {
258
263
  const envName = ENV_KEYS[provider];
259
264
  const envVal = (process.env[envName] || "").trim();
265
+ let useExistingEnv = false;
260
266
  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
- }
267
+ const useEnv = (await ask(c.cyan(`환경변수 ${envName} 에서 키를 찾았어요. 사용할까요? [Y/n] `))) || "";
268
+ useExistingEnv = ["", "y", "yes"].includes(useEnv.trim().toLowerCase());
269
+ }
270
+ if (useExistingEnv) {
271
+ cfg.api_key = ""; // 환경변수 사용 파일엔 저장 안 함
268
272
  } else {
269
- console.log(c.dim(`(또는 종료 후 환경변수 ${envName} 에 키를 넣어두면 파일에 저장하지 않아도 됩니다)`));
270
- const k = (await rl.question(c.cyan("API 키 붙여넣기(입력이 보일 수 있음): "))).trim();
271
- cfg.api_key = k;
273
+ if (!envVal) console.log(c.dim(`(또는 종료 후 환경변수 ${envName} 에 키를 넣어두면 파일에 저장하지 않아도 됩니다)`));
274
+ const k = await ask(c.cyan("API 키 붙여넣기(붙여넣기 Enter): "));
275
+ if (k === null) {
276
+ console.log(c.yellow("취소했습니다."));
277
+ return false;
278
+ }
279
+ cfg.api_key = cleanKey(k);
272
280
  }
273
281
  const sugg = SUGGESTED_MODELS[provider] || [];
274
282
  const def = sugg[0] || "";
275
- const m = (await rl.question(c.cyan(`모델 [${def}] (추천: ${sugg.join(", ")}): `))).trim();
276
- cfg.model = m || def;
283
+ const note = provider === "openrouter" ? c.dim(" (OpenRouter 는 'provider/model' 형식)") : "";
284
+ const m = await ask(c.cyan(`모델 [${def}]${note}\n 추천: ${sugg.join(", ")}\n > `));
285
+ if (m === null) {
286
+ console.log(c.yellow("취소했습니다."));
287
+ return false;
288
+ }
289
+ cfg.model = m.trim() || def;
277
290
  }
278
291
 
279
292
  const saved = saveConfig(cfg);
280
293
  console.log(c.green(`설정을 저장했습니다 → ${saved}`));
294
+ console.log(c.dim(`provider=${cfg.provider} · model=${cfg.model}`));
281
295
  if (!cfg.isReady()) console.log(c.yellow("⚠️ 아직 키가 없어 호출이 실패할 수 있어요. 환경변수나 /setup 으로 키를 넣어주세요."));
282
296
  return true;
283
297
  }
@@ -343,12 +357,32 @@ export async function main(argv = []) {
343
357
  if (args.noTeach) cfg.teach_mode = false;
344
358
 
345
359
  const rl = readline.createInterface({ input: stdin, output: stdout });
360
+ let session = null;
361
+ let mcp = { tools: [], servers: [], errors: [], closeAll: () => {} };
362
+
363
+ // Ctrl+C → 깔끔하게 종료(스택트레이스 없이). 어디서 누르든 안전.
364
+ const gracefulExit = () => {
365
+ try { rl.close(); } catch { /* */ }
366
+ try { session && session.close(); } catch { /* */ }
367
+ try { mcp && mcp.closeAll && mcp.closeAll(); } catch { /* */ }
368
+ console.log(c.dim("\n종료합니다. 안녕히 가세요!"));
369
+ process.exit(0);
370
+ };
371
+ rl.on("SIGINT", gracefulExit);
372
+ // 프롬프트 헬퍼: Ctrl+C(AbortError) 등은 null 로 돌려 호출부가 취소로 처리.
373
+ const ask = async (q) => {
374
+ try {
375
+ return await rl.question(q);
376
+ } catch {
377
+ return null;
378
+ }
379
+ };
346
380
 
347
381
  if (args.setup) {
348
- await runSetup(rl, cfg);
382
+ await runSetup(ask, cfg);
349
383
  } else if (!cfg.isReady() && cfg.provider !== "mock") {
350
384
  console.log(c.yellow(`provider=${cfg.provider} 인데 API 키가 없습니다. 연결 설정을 시작합니다.\n`));
351
- await runSetup(rl, cfg);
385
+ await runSetup(ask, cfg);
352
386
  }
353
387
 
354
388
  printIntro(cfg);
@@ -358,26 +392,38 @@ export async function main(argv = []) {
358
392
  // ② npm 패키지: cdsa-harness-plugin-* 자동 발견 + config.plugins 강제 로드
359
393
  const filePlugins = await loadPlugins(cfg.workspacePath());
360
394
  const npm = await discoverNpmExtensions(process.cwd(), cfg.plugins || []);
361
- const plugins = [...filePlugins, ...npm.plugins, ...npm.errors.map((e) => ({ error: e }))];
395
+ // MCP 서버(다른 에이전트와 공용 표준) 도구도 플러그인처럼 등록
396
+ if (cfg.mcpServers && Object.keys(cfg.mcpServers).length) {
397
+ process.stdout.write(c.dim("MCP 서버 연결 중...\r"));
398
+ mcp = await connectMcpServers(cfg.mcpServers);
399
+ }
400
+ const plugins = [
401
+ ...filePlugins,
402
+ ...npm.plugins,
403
+ ...mcp.tools,
404
+ ...npm.errors.map((e) => ({ error: e })),
405
+ ...mcp.errors.map((e) => ({ error: `MCP ${e}` })),
406
+ ];
362
407
  const skills = {};
363
408
  for (const s of npm.skills) skills[s.name] = { name: s.name, description: s.description || "", body: s.body, source: "(npm)" };
364
409
  Object.assign(skills, loadSkills(cfg.workspacePath())); // 로컬 파일 스킬이 우선
365
410
  const toolbox = new Toolbox(cfg.workspacePath(), cfg.allow_shell, plugins);
366
- if (toolbox.plugins.length || Object.keys(skills).length || toolbox.pluginErrors.length) {
411
+ if (toolbox.plugins.length || Object.keys(skills).length || toolbox.pluginErrors.length || mcp.servers.length) {
367
412
  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");
413
+ if (toolbox.plugins.length) bits.push(c.green(`도구 +${toolbox.plugins.length}개`));
414
+ if (mcp.servers.length) bits.push(c.green(`MCP ${mcp.servers.length}개`) + c.grey(` (${mcp.servers.map((s) => s.name).join(", ")})`));
415
+ if (Object.keys(skills).length) bits.push(c.green(`스킬 ${Object.keys(skills).length}개`));
416
+ if (toolbox.pluginErrors.length) bits.push(c.red(`오류 ${toolbox.pluginErrors.length}개`));
417
+ console.log("🔌 " + bits.join(" · ") + c.dim(" (/plugins /skills /mcp 로 상세)") + "\n");
372
418
  }
373
419
 
374
- const session = SessionLog.create();
420
+ session = SessionLog.create();
375
421
  const loop = new AgentLoop({
376
422
  config: cfg,
377
423
  client: makeClient(cfg),
378
424
  toolbox,
379
425
  onEvent: makePrinter(cfg),
380
- approvalCallback: makeApproval(rl),
426
+ approvalCallback: makeApproval(ask),
381
427
  session,
382
428
  });
383
429
  loop.reset();
@@ -385,12 +431,9 @@ export async function main(argv = []) {
385
431
  const rule = () => console.log(c.grey("─".repeat(Math.min(80, stdout.columns || 80))));
386
432
 
387
433
  while (true) {
388
- let user;
389
- try {
390
- user = (await rl.question(c.bold(c.cyan("› ")))).trim();
391
- } catch {
392
- break;
393
- }
434
+ const raw = await ask(c.bold(c.cyan("› ")));
435
+ if (raw === null) break; // Ctrl+D / Ctrl+C / 스트림 종료
436
+ const user = raw.trim();
394
437
  if (!user) continue;
395
438
  const low = user.toLowerCase();
396
439
 
@@ -405,7 +448,7 @@ export async function main(argv = []) {
405
448
  continue;
406
449
  }
407
450
  if (low === "/setup" || low === "/login") {
408
- await runSetup(rl, cfg);
451
+ await runSetup(ask, cfg);
409
452
  loop.client = makeClient(cfg);
410
453
  loop.reset();
411
454
  continue;
@@ -449,6 +492,16 @@ export async function main(argv = []) {
449
492
  console.log(panel(lines, { title: "🔌 플러그인 (모델이 쓸 수 있는 추가 도구)", color: "blue" }));
450
493
  continue;
451
494
  }
495
+ if (low === "/mcp") {
496
+ const lines = [];
497
+ if (!mcp.servers.length) lines.push(c.dim("연결된 MCP 서버가 없습니다."));
498
+ for (const s of mcp.servers) lines.push(`${c.bold(s.name)} ${c.grey(`도구 ${s.count}개`)}`);
499
+ for (const t of mcp.tools) lines.push(` ${c.cyan(t.name)} ${c.grey(clip(t.description, 60))}`);
500
+ for (const e of mcp.errors) lines.push(c.red("✖ " + e));
501
+ lines.push(c.dim('설정: config.json 의 "mcpServers" (Claude Code/Cursor 와 동일 형식)'));
502
+ console.log(panel(lines, { title: "🔗 MCP 서버 (다른 에이전트와 공용)", color: "blue" }));
503
+ continue;
504
+ }
452
505
  if (low === "/skills") {
453
506
  const names = Object.keys(skills);
454
507
  const lines = names.length ? names.map((n) => `${c.cyan("/" + n)} ${c.grey(skills[n].description || "")}`) : [c.dim("등록된 스킬이 없습니다.")];
@@ -487,6 +540,7 @@ export async function main(argv = []) {
487
540
 
488
541
  rl.close();
489
542
  session.close();
543
+ mcp.closeAll();
490
544
  console.log(c.dim("\n종료합니다. 안녕히 가세요!"));
491
545
  return 0;
492
546
  }
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
  };
@@ -38,6 +39,7 @@ const DEFAULTS = {
38
39
  max_tokens: 1024,
39
40
  teach_mode: true,
40
41
  plugins: [], // 추가로 불러올 npm 플러그인 패키지 이름(이름 규칙과 무관하게 강제 로드)
42
+ mcpServers: {}, // MCP 서버 설정 (Claude Code/Cursor 와 동일한 형식)
41
43
  };
42
44
 
43
45
  export function configDir() {
package/src/llm.js CHANGED
@@ -46,7 +46,16 @@ export class LLMClient {
46
46
  const latencyMs = Date.now() - started;
47
47
  if (!res.ok) {
48
48
  const text = await res.text().catch(() => "");
49
- throw new LLMError(`API 오류 ${res.status}: ${trim(text) || res.statusText}`);
49
+ let msg = `API 오류 ${res.status}: ${trim(text) || res.statusText}`;
50
+ if (res.status === 404) {
51
+ msg += "\n ↳ 모델 이름을 확인하세요. /model 로 변경 가능. " +
52
+ "OpenRouter 는 'provider/model' 형식이어야 합니다 (예: openai/gpt-4o-mini, anthropic/claude-3.7-sonnet).";
53
+ } else if (res.status === 401 || res.status === 403) {
54
+ msg += "\n ↳ API 키가 잘못되었거나 권한이 없어요. /setup 으로 다시 연결하세요.";
55
+ } else if (res.status === 429) {
56
+ msg += "\n ↳ 요청이 너무 많거나 크레딧이 부족할 수 있어요(잠시 후 재시도).";
57
+ }
58
+ throw new LLMError(msg);
50
59
  }
51
60
  return { payload: await res.json(), latencyMs, bodyBytes: Buffer.byteLength(json, "utf8") };
52
61
  }
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
  }