cdsa-harness 0.2.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,6 +11,9 @@
11
11
  - **의존성 0개** — Node 18+ 내장 기능만(`fetch`/`readline`/`node:test`)
12
12
  - **실제 LLM 연결** — OpenAI · Anthropic(Claude) · OpenRouter, 또는 키 없이 `mock`
13
13
  - **교육 모드** — 매 반복마다 모델에 보내는 메시지 구성·추정 토큰·시스템 프롬프트, 실제 토큰 사용량/응답시간까지 그대로 표시
14
+ - **MCP 클라이언트** — Claude Code·Cursor 등과 **공용 표준**. MCP 서버를 그대로 붙여 도구로 사용
15
+ - **플러그인** — npm 으로 설치하거나 `.cdsa/plugins/` 에 JS 파일 → **도구 자동 등록**
16
+ - **크로스포맷 스킬** — `.cdsa/skills/` 뿐 아니라 `.claude/commands/`·`.opencode/command/` 의 스킬도 인식
14
17
 
15
18
  ---
16
19
 
@@ -68,6 +71,74 @@ export OPENROUTER_API_KEY=sk-or-...
68
71
 
69
72
  ---
70
73
 
74
+ ## 🔌 플러그인 (추가 도구)
75
+
76
+ ### 방법 A — npm 으로 설치 (권장)
77
+
78
+ `cdsa-harness-plugin-*` 이름의 패키지를 설치하면 **자동으로 발견·로드**됩니다.
79
+
80
+ ```bash
81
+ cdsa-harness add cdsa-harness-plugin-git # = npm install 후 자동 로드
82
+ # 또는 직접: npm install cdsa-harness-plugin-git
83
+ cdsa-harness # 실행 → /plugins 에 자동 등록됨
84
+ ```
85
+
86
+ - cwd 의 `node_modules` 와 전역 설치 위치를 모두 탐색합니다.
87
+ - 이름 규칙과 무관하게 강제 로드하려면 `config.json` 의 `"plugins": ["패키지명"]` 에 추가.
88
+ - 플러그인 패키지는 default export 로 `플러그인 def` · `def 배열` · `{ tools:[...], skills:[...] }` 중 하나를 제공.
89
+
90
+ > **npm vs npx**: `npm install`(=설치, 보관) 으로 플러그인을 **추가**하고, `npx`(=설치 없이 실행) 또는 설치된 `cdsa-harness` 로 **실행**합니다.
91
+
92
+ ### 방법 B — 로컬 파일 (실험용)
93
+
94
+ `.cdsa/plugins/` (작업 폴더) 또는 `~/.cdsa_harness/plugins/` 에 `.js`/`.mjs` 파일을 두면 자동 등록.
95
+
96
+ ```js
97
+ // .cdsa/plugins/word_count.mjs
98
+ import fs from "node:fs";
99
+ import path from "node:path";
100
+ export default {
101
+ name: "word_count",
102
+ description: "텍스트 파일의 글자/줄/단어 수를 센다",
103
+ parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] },
104
+ mutating: false, // true 면 실행 전 승인
105
+ async handler(args, ctx) { // ctx.workspace = 작업 폴더 절대경로
106
+ const text = fs.readFileSync(path.resolve(ctx.workspace, args.path), "utf8");
107
+ return `글자 ${text.length}, 줄 ${text.split("\n").length}`;
108
+ },
109
+ };
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
+
129
+ ## 🎯 스킬 (프롬프트 템플릿)
130
+
131
+ `.cdsa/skills/` 에 마크다운을 두면 `/파일명` 으로 실행됩니다. 본문의 `$ARGUMENTS`(또는 `{{args}}`)가 치환됩니다.
132
+ **다른 에이전트의 스킬도 인식** — `.claude/commands/`, `.claude/skills/<이름>/SKILL.md`, `.opencode/command/` 등을 함께 읽습니다. `/skills` 로 목록 확인.
133
+
134
+ ```markdown
135
+ ---
136
+ description: 파일을 읽고 3줄로 요약
137
+ ---
138
+ $ARGUMENTS 파일을 read_file 로 읽고 핵심을 한국어 3줄로 요약해줘.
139
+ ```
140
+ 실행: `/summarize notes.txt`
141
+
71
142
  ## 설정 (config.json)
72
143
 
73
144
  `~/.cdsa_harness/config.json` (실행 폴더에 `config.json` 있으면 우선).
@@ -112,6 +183,9 @@ node-cli/
112
183
  │ ├── llm.js # OpenAI/Anthropic/OpenRouter + mock, 응답 정규화(토큰·지연)
113
184
  │ ├── tools.js # 도구 + sandbox + diff
114
185
  │ ├── loop.js # ⭐ Agent Loop + 교육용 이벤트(컨텍스트/되먹임)
186
+ │ ├── mcp.js # MCP 클라이언트(stdio JSON-RPC) — 다른 에이전트와 공용
187
+ │ ├── plugins.js # 파일·npm 플러그인 발견/로드
188
+ │ ├── skills.js # 크로스포맷 스킬 로더
115
189
  │ ├── session.js # 세션 로그(JSONL)
116
190
  │ ├── banner.js / ui.js # 배너 · ANSI/박스/diff 렌더
117
191
  │ └── cli.js # REPL + 교육 모드 렌더 + /setup
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cdsa-harness",
3
- "version": "0.2.0",
4
- "description": "AI 에이전트의 내부 동작(컨텍스트·API요청·토큰·도구·되먹임)을 단계별로 드러내는 교육용 터미널 하네스. OpenAI/Claude/OpenRouter 지원.",
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
@@ -13,9 +13,14 @@ import {
13
13
  loadConfig,
14
14
  saveConfig,
15
15
  } from "./config.js";
16
+ import { execFileSync } from "node:child_process";
17
+
16
18
  import { AgentLoop, Step } from "./loop.js";
17
19
  import { LLMClient } from "./llm.js";
20
+ import { connectMcpServers } from "./mcp.js";
21
+ import { discoverNpmExtensions, loadPlugins } from "./plugins.js";
18
22
  import { SessionLog, sessionsDir } from "./session.js";
23
+ import { loadSkills, renderSkill } from "./skills.js";
19
24
  import { Toolbox } from "./tools.js";
20
25
  import { c, panel, renderDiff } from "./ui.js";
21
26
 
@@ -144,7 +149,7 @@ function printCompact(ev) {
144
149
  console.log(line);
145
150
  }
146
151
 
147
- function makeApproval(rl) {
152
+ function makeApproval(ask) {
148
153
  return async (req) => {
149
154
  if (req.toolName === "write_file") {
150
155
  console.log(panel(renderDiff(req.diff || "(변경 미리보기 없음)"), {
@@ -156,12 +161,8 @@ function makeApproval(rl) {
156
161
  } else {
157
162
  console.log(panel([JSON.stringify(req.args)], { title: `🔐 ${req.toolLabel}`, color: "yellow" }));
158
163
  }
159
- let ans = "";
160
- try {
161
- ans = (await rl.question(c.yellow("이 작업을 승인하시겠습니까? [y/N] "))).trim().toLowerCase();
162
- } catch {
163
- ans = "";
164
- }
164
+ const raw = await ask(c.yellow("이 작업을 승인하시겠습니까? [y/N] "));
165
+ const ans = (raw || "").trim().toLowerCase();
165
166
  const approved = ans === "y" || ans === "yes";
166
167
  return { approved, reason: approved ? "" : "사용자가 거부했습니다." };
167
168
  };
@@ -213,6 +214,9 @@ function printHelp() {
213
214
  ` ${c.cyan("/model")} <이름> 모델 변경`,
214
215
  ` ${c.cyan("/teach")} 교육 모드 켜기/끄기(내부 과정 펼쳐보기)`,
215
216
  ` ${c.cyan("/context")} 지금 모델에 보내는 컨텍스트 들여다보기`,
217
+ ` ${c.cyan("/skills")} 스킬 목록(.cdsa/skills 의 /명령들)`,
218
+ ` ${c.cyan("/plugins")} 플러그인 목록(파일·npm 추가 도구)`,
219
+ ` ${c.cyan("/mcp")} 연결된 MCP 서버/도구(다른 에이전트와 공용)`,
216
220
  ` ${c.cyan("/reset")} 대화/컨텍스트 초기화`,
217
221
  ` ${c.cyan("/config")} 현재 설정값`,
218
222
  ` ${c.cyan("/quit")} 종료 (Ctrl+D)`,
@@ -226,8 +230,14 @@ function printHelp() {
226
230
  );
227
231
  }
228
232
 
233
+ // 붙여넣기한 키에 섞이기 쉬운 따옴표/공백/줄바꿈을 정리.
234
+ function cleanKey(s) {
235
+ return (s || "").trim().replace(/^["']|["']$/g, "").trim();
236
+ }
237
+
229
238
  // 대화형 연결 설정. 키는 환경변수가 있으면 그걸 우선 안내(파일 저장 안 함).
230
- async function runSetup(rl, cfg) {
239
+ // ask 가 null 을 주면(Ctrl+C) 조용히 취소.
240
+ async function runSetup(ask, cfg) {
231
241
  console.log(panel(
232
242
  [
233
243
  "어떤 AI 에 연결할까요? 번호를 입력하세요.",
@@ -238,8 +248,9 @@ async function runSetup(rl, cfg) {
238
248
  ],
239
249
  { title: "🔌 연결 설정 (/setup)", color: "cyan" }
240
250
  ));
241
- const pick = (await rl.question(c.cyan("제공자 번호 [1-4]: "))).trim();
242
- 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()];
243
254
  if (!provider) {
244
255
  console.log(c.yellow("취소했습니다."));
245
256
  return false;
@@ -251,27 +262,36 @@ async function runSetup(rl, cfg) {
251
262
  } else {
252
263
  const envName = ENV_KEYS[provider];
253
264
  const envVal = (process.env[envName] || "").trim();
265
+ let useExistingEnv = false;
254
266
  if (envVal) {
255
- const useEnv = (await rl.question(c.cyan(`환경변수 ${envName} 에서 키를 찾았어요. 사용할까요? [Y/n] `))).trim().toLowerCase();
256
- if (useEnv === "" || useEnv === "y" || useEnv === "yes") {
257
- cfg.api_key = ""; // 환경변수 사용 → 파일엔 저장 안 함
258
- } else {
259
- const k = (await rl.question(c.cyan("API 붙여넣기(입력이 보일 있음): "))).trim();
260
- cfg.api_key = k;
261
- }
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 = ""; // 환경변수 사용 파일엔 저장 안 함
262
272
  } else {
263
- console.log(c.dim(`(또는 종료 후 환경변수 ${envName} 에 키를 넣어두면 파일에 저장하지 않아도 됩니다)`));
264
- const k = (await rl.question(c.cyan("API 키 붙여넣기(입력이 보일 수 있음): "))).trim();
265
- 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);
266
280
  }
267
281
  const sugg = SUGGESTED_MODELS[provider] || [];
268
282
  const def = sugg[0] || "";
269
- const m = (await rl.question(c.cyan(`모델 [${def}] (추천: ${sugg.join(", ")}): `))).trim();
270
- 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;
271
290
  }
272
291
 
273
292
  const saved = saveConfig(cfg);
274
293
  console.log(c.green(`설정을 저장했습니다 → ${saved}`));
294
+ console.log(c.dim(`provider=${cfg.provider} · model=${cfg.model}`));
275
295
  if (!cfg.isReady()) console.log(c.yellow("⚠️ 아직 키가 없어 호출이 실패할 수 있어요. 환경변수나 /setup 으로 키를 넣어주세요."));
276
296
  return true;
277
297
  }
@@ -298,6 +318,7 @@ export async function main(argv = []) {
298
318
  console.log(
299
319
  "CDSA Harness — AI 에이전트 내부를 드러내는 교육용 하네스 (터미널)\n\n" +
300
320
  "사용법: cdsa-harness [옵션]\n" +
321
+ " cdsa-harness add <npm-패키지> 플러그인 설치(이후 자동 로드)\n" +
301
322
  " --provider <openai|anthropic|openrouter|mock>\n" +
302
323
  " --model <모델명>\n" +
303
324
  " --workspace <폴더경로>\n" +
@@ -310,6 +331,24 @@ export async function main(argv = []) {
310
331
  return 0;
311
332
  }
312
333
 
334
+ // `cdsa-harness add <패키지>` — 플러그인을 npm 으로 설치(이후 자동 로드).
335
+ if (args._[0] === "add" || args._[0] === "install") {
336
+ const pkgs = args._.slice(1);
337
+ if (!pkgs.length) {
338
+ console.log("사용법: cdsa-harness add <npm-패키지...> 예) cdsa-harness add cdsa-harness-plugin-git");
339
+ return 1;
340
+ }
341
+ console.log(c.cyan(`npm install ${pkgs.join(" ")} ...`));
342
+ try {
343
+ execFileSync("npm", ["install", ...pkgs], { stdio: "inherit", cwd: process.cwd() });
344
+ console.log(c.green("설치 완료. 다음 실행부터 플러그인이 자동으로 로드됩니다 (/plugins 로 확인)."));
345
+ return 0;
346
+ } catch (e) {
347
+ console.log(c.red(`설치 실패: ${e.message}`));
348
+ return 1;
349
+ }
350
+ }
351
+
313
352
  const cfg = loadConfig();
314
353
  if (args.provider) cfg.provider = args.provider;
315
354
  if (args.model) cfg.model = args.model;
@@ -318,24 +357,73 @@ export async function main(argv = []) {
318
357
  if (args.noTeach) cfg.teach_mode = false;
319
358
 
320
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
+ };
321
380
 
322
381
  if (args.setup) {
323
- await runSetup(rl, cfg);
382
+ await runSetup(ask, cfg);
324
383
  } else if (!cfg.isReady() && cfg.provider !== "mock") {
325
384
  console.log(c.yellow(`provider=${cfg.provider} 인데 API 키가 없습니다. 연결 설정을 시작합니다.\n`));
326
- await runSetup(rl, cfg);
385
+ await runSetup(ask, cfg);
327
386
  }
328
387
 
329
388
  printIntro(cfg);
330
389
 
331
- const toolbox = new Toolbox(cfg.workspacePath(), cfg.allow_shell);
332
- const session = SessionLog.create();
390
+ // 플러그인(추가 도구)·스킬(프롬프트 템플릿) 불러온다:
391
+ // ① 파일: .cdsa/plugins · .cdsa/skills (작업폴더/홈)
392
+ // ② npm 패키지: cdsa-harness-plugin-* 자동 발견 + config.plugins 강제 로드
393
+ const filePlugins = await loadPlugins(cfg.workspacePath());
394
+ const npm = await discoverNpmExtensions(process.cwd(), cfg.plugins || []);
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
+ ];
407
+ const skills = {};
408
+ for (const s of npm.skills) skills[s.name] = { name: s.name, description: s.description || "", body: s.body, source: "(npm)" };
409
+ Object.assign(skills, loadSkills(cfg.workspacePath())); // 로컬 파일 스킬이 우선
410
+ const toolbox = new Toolbox(cfg.workspacePath(), cfg.allow_shell, plugins);
411
+ if (toolbox.plugins.length || Object.keys(skills).length || toolbox.pluginErrors.length || mcp.servers.length) {
412
+ const bits = [];
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");
418
+ }
419
+
420
+ session = SessionLog.create();
333
421
  const loop = new AgentLoop({
334
422
  config: cfg,
335
423
  client: makeClient(cfg),
336
424
  toolbox,
337
425
  onEvent: makePrinter(cfg),
338
- approvalCallback: makeApproval(rl),
426
+ approvalCallback: makeApproval(ask),
339
427
  session,
340
428
  });
341
429
  loop.reset();
@@ -343,12 +431,9 @@ export async function main(argv = []) {
343
431
  const rule = () => console.log(c.grey("─".repeat(Math.min(80, stdout.columns || 80))));
344
432
 
345
433
  while (true) {
346
- let user;
347
- try {
348
- user = (await rl.question(c.bold(c.cyan("› ")))).trim();
349
- } catch {
350
- break;
351
- }
434
+ const raw = await ask(c.bold(c.cyan("› ")));
435
+ if (raw === null) break; // Ctrl+D / Ctrl+C / 스트림 종료
436
+ const user = raw.trim();
352
437
  if (!user) continue;
353
438
  const low = user.toLowerCase();
354
439
 
@@ -363,7 +448,7 @@ export async function main(argv = []) {
363
448
  continue;
364
449
  }
365
450
  if (low === "/setup" || low === "/login") {
366
- await runSetup(rl, cfg);
451
+ await runSetup(ask, cfg);
367
452
  loop.client = makeClient(cfg);
368
453
  loop.reset();
369
454
  continue;
@@ -394,6 +479,55 @@ export async function main(argv = []) {
394
479
  console.log(panel(clip(ctx.systemPrompt, 800).split("\n"), { title: "📜 시스템 프롬프트", color: "grey" }));
395
480
  continue;
396
481
  }
482
+ if (low === "/plugins") {
483
+ const lines = [];
484
+ if (!toolbox.plugins.length) lines.push(c.dim("등록된 플러그인이 없습니다."));
485
+ for (const p of toolbox.plugins) {
486
+ const src = p.source && p.source.includes("/") ? "📄 " + p.source.split("/").slice(-1)[0] : "📦 " + (p.source || "npm");
487
+ lines.push(`${c.bold(p.name)}${p.mutating ? c.yellow(" (승인필요)") : ""} ${c.grey(p.description || "")} ${c.dim(src)}`);
488
+ }
489
+ for (const e of toolbox.pluginErrors) lines.push(c.red("✖ " + e));
490
+ lines.push(c.dim("추가: npm 패키지 'cdsa-harness-plugin-*' 설치 → 자동 로드 (cdsa-harness add <pkg>)"));
491
+ lines.push(c.dim("또는: <작업폴더>/.cdsa/plugins/ 에 .js/.mjs 파일"));
492
+ console.log(panel(lines, { title: "🔌 플러그인 (모델이 쓸 수 있는 추가 도구)", color: "blue" }));
493
+ continue;
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
+ }
505
+ if (low === "/skills") {
506
+ const names = Object.keys(skills);
507
+ const lines = names.length ? names.map((n) => `${c.cyan("/" + n)} ${c.grey(skills[n].description || "")}`) : [c.dim("등록된 스킬이 없습니다.")];
508
+ lines.push(c.dim("위치: <작업폴더>/.cdsa/skills/ 또는 ~/.cdsa_harness/skills/ (.md). 본문의 $ARGUMENTS 치환."));
509
+ console.log(panel(lines, { title: "🎯 스킬 (프롬프트 템플릿, /이름 으로 실행)", color: "cyan" }));
510
+ continue;
511
+ }
512
+
513
+ // 위 내장 명령에 안 걸린 '/...' → 스킬이면 실행, 아니면 안내.
514
+ if (user.startsWith("/")) {
515
+ const name = low.slice(1).split(/\s+/)[0];
516
+ if (skills[name]) {
517
+ const argStr = user.split(/\s+/).slice(1).join(" ");
518
+ console.log(c.dim(`(스킬 '/${name}' 실행)`));
519
+ rule();
520
+ try {
521
+ await loop.run(renderSkill(skills[name], argStr));
522
+ } catch (e) {
523
+ console.log(c.red(`실행 오류: ${e?.message || e}`));
524
+ }
525
+ rule();
526
+ } else {
527
+ console.log(c.yellow(`알 수 없는 명령/스킬: /${name} — ${c.cyan("/help")}, ${c.cyan("/skills")} 참고`));
528
+ }
529
+ continue;
530
+ }
397
531
 
398
532
  rule();
399
533
  try {
@@ -406,6 +540,7 @@ export async function main(argv = []) {
406
540
 
407
541
  rl.close();
408
542
  session.close();
543
+ mcp.closeAll();
409
544
  console.log(c.dim("\n종료합니다. 안녕히 가세요!"));
410
545
  return 0;
411
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
  };
@@ -37,6 +38,8 @@ const DEFAULTS = {
37
38
  temperature: 0.2,
38
39
  max_tokens: 1024,
39
40
  teach_mode: true,
41
+ plugins: [], // 추가로 불러올 npm 플러그인 패키지 이름(이름 규칙과 무관하게 강제 로드)
42
+ mcpServers: {}, // MCP 서버 설정 (Claude Code/Cursor 와 동일한 형식)
40
43
  };
41
44
 
42
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/loop.js CHANGED
@@ -7,7 +7,7 @@ import fs from "node:fs";
7
7
  import path from "node:path";
8
8
 
9
9
  import { LLMError } from "./llm.js";
10
- import { MUTATING_TOOLS, TOOL_LABELS, ToolError, toolSchemas } from "./tools.js";
10
+ import { TOOL_LABELS, ToolError, toolSchemas } from "./tools.js";
11
11
 
12
12
  // 단계(Step) 상수
13
13
  export const Step = {
@@ -145,7 +145,7 @@ export class AgentLoop {
145
145
  "규칙 파일 + 작업 폴더 내용을 시스템 프롬프트로 묶어 모델에 전달합니다."
146
146
  );
147
147
 
148
- const tools = toolSchemas(this.config.allow_shell);
148
+ const tools = toolSchemas(this.config.allow_shell, this.toolbox.plugins);
149
149
  const toolNames = tools.map((t) => t.function.name);
150
150
  let finalText = "";
151
151
 
@@ -227,8 +227,8 @@ export class AgentLoop {
227
227
  }
228
228
 
229
229
  async _handleToolCall(tc) {
230
- const label = TOOL_LABELS[tc.name] || tc.name;
231
- const needsApproval = MUTATING_TOOLS.has(tc.name);
230
+ const label = this.toolbox.label ? this.toolbox.label(tc.name) : TOOL_LABELS[tc.name] || tc.name;
231
+ const needsApproval = this.toolbox.isMutating(tc.name);
232
232
 
233
233
  if (needsApproval && this.config.approval_mode === "manual") {
234
234
  const req = this._buildApprovalRequest(tc);
@@ -250,7 +250,7 @@ export class AgentLoop {
250
250
 
251
251
  this._emit(Step.TOOL_RUN, `도구 실행: ${label}`, JSON.stringify(tc.args).slice(0, 2000));
252
252
  try {
253
- const result = this.toolbox.execute(tc.name, tc.args);
253
+ const result = await this.toolbox.execute(tc.name, tc.args);
254
254
  this._emit(Step.TOOL_RESULT, `결과 반영: ${label}`, (result.output || "").slice(0, 4000));
255
255
  return result.output;
256
256
  } catch (e) {
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/plugins.js ADDED
@@ -0,0 +1,191 @@
1
+ // 플러그인 시스템 — OpenCode 스타일 확장성.
2
+ // `.cdsa/plugins/` (작업 폴더) 와 `~/.cdsa_harness/plugins/` (전역) 의 .js/.mjs 파일을
3
+ // 불러와 '새 도구'로 등록한다. 각 파일은 아래 형태의 객체를 default export 한다:
4
+ //
5
+ // export default {
6
+ // name: "word_count",
7
+ // description: "텍스트 파일의 글자/줄 수를 센다",
8
+ // parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] },
9
+ // mutating: false, // true 면 실행 전 사용자 승인 필요
10
+ // async handler(args, ctx) { ... } // ctx = { workspace }
11
+ // }
12
+ import fs from "node:fs";
13
+ import { createRequire } from "node:module";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import { fileURLToPath, pathToFileURL } from "node:url";
17
+
18
+ export function pluginDirs(workspace) {
19
+ return [
20
+ path.join(os.homedir(), ".cdsa_harness", "plugins"),
21
+ path.join(workspace, ".cdsa", "plugins"),
22
+ ];
23
+ }
24
+
25
+ export async function loadPlugins(workspace) {
26
+ const plugins = [];
27
+ for (const dir of pluginDirs(workspace)) {
28
+ let files = [];
29
+ try {
30
+ if (!fs.existsSync(dir)) continue;
31
+ files = fs.readdirSync(dir).filter((f) => /\.(mjs|js)$/.test(f)).sort();
32
+ } catch {
33
+ continue;
34
+ }
35
+ for (const f of files) {
36
+ const full = path.join(dir, f);
37
+ try {
38
+ const mod = await import(pathToFileURL(full).href);
39
+ const def = mod.default || mod.plugin || mod;
40
+ if (!def || !def.name || typeof def.handler !== "function") {
41
+ plugins.push({ error: `${f}: name/handler 가 없습니다` });
42
+ continue;
43
+ }
44
+ plugins.push({
45
+ name: def.name,
46
+ description: def.description || "",
47
+ parameters: def.parameters || { type: "object", properties: {} },
48
+ mutating: Boolean(def.mutating),
49
+ handler: def.handler,
50
+ source: full,
51
+ });
52
+ } catch (e) {
53
+ plugins.push({ error: `${f}: ${e.message}` });
54
+ }
55
+ }
56
+ }
57
+ return plugins;
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // npm 패키지로 설치한 플러그인 자동 발견.
62
+ // - 이름이 `cdsa-harness-plugin-*` (또는 `@scope/cdsa-harness-plugin-*`)
63
+ // - 또는 package.json 에 keywords: ["cdsa-harness-plugin"] / "cdsaHarness" 필드
64
+ // 인 패키지를 node_modules 에서 찾아 불러온다.
65
+ // 패키지는 다음 중 하나를 default export:
66
+ // · 플러그인 def 객체 · def 배열 · { tools:[...], skills:[{name,description,body}] }
67
+ // ---------------------------------------------------------------------------
68
+ function isPluginPackage(pkgJson, name) {
69
+ if (/^(@[^/]+\/)?cdsa-harness-plugin-/.test(name)) return true;
70
+ if (pkgJson && pkgJson.cdsaHarness) return true;
71
+ const kw = (pkgJson && pkgJson.keywords) || [];
72
+ return Array.isArray(kw) && kw.includes("cdsa-harness-plugin");
73
+ }
74
+
75
+ async function importPackage(nmDir, name) {
76
+ const req = createRequire(path.join(nmDir, "__cdsa_resolve__.js"));
77
+ const entry = req.resolve(name);
78
+ return import(pathToFileURL(entry).href);
79
+ }
80
+
81
+ function normalizeModule(mod, source) {
82
+ const out = { plugins: [], skills: [] };
83
+ const def = (mod && (mod.default ?? mod)) || null;
84
+ if (!def) return out;
85
+ let tools = [];
86
+ let skills = [];
87
+ if (Array.isArray(def)) tools = def;
88
+ else if (def.tools || def.skills) {
89
+ tools = def.tools || [];
90
+ skills = def.skills || [];
91
+ } else if (def.name && typeof def.handler === "function") tools = [def];
92
+ for (const t of tools) {
93
+ if (t && t.name && typeof t.handler === "function") {
94
+ out.plugins.push({
95
+ name: t.name,
96
+ description: t.description || "",
97
+ parameters: t.parameters || { type: "object", properties: {} },
98
+ mutating: Boolean(t.mutating),
99
+ handler: t.handler,
100
+ source,
101
+ });
102
+ }
103
+ }
104
+ for (const s of skills) if (s && s.name && s.body) out.skills.push(s);
105
+ return out;
106
+ }
107
+
108
+ // 한 node_modules 디렉터리를 훑어 플러그인 패키지를 모은다.
109
+ export async function scanNodeModules(nmDir) {
110
+ const result = { plugins: [], skills: [], errors: [] };
111
+ let entries = [];
112
+ try {
113
+ if (!fs.existsSync(nmDir)) return result;
114
+ entries = fs.readdirSync(nmDir);
115
+ } catch {
116
+ return result;
117
+ }
118
+ const names = [];
119
+ for (const e of entries) {
120
+ if (e.startsWith(".")) continue;
121
+ if (e.startsWith("@")) {
122
+ try {
123
+ for (const sub of fs.readdirSync(path.join(nmDir, e))) names.push(`${e}/${sub}`);
124
+ } catch {
125
+ /* ignore */
126
+ }
127
+ } else names.push(e);
128
+ }
129
+ for (const name of names) {
130
+ let pkgJson = {};
131
+ try {
132
+ pkgJson = JSON.parse(fs.readFileSync(path.join(nmDir, name, "package.json"), "utf8"));
133
+ } catch {
134
+ continue;
135
+ }
136
+ if (!isPluginPackage(pkgJson, name)) continue;
137
+ try {
138
+ const mod = await importPackage(nmDir, name);
139
+ const norm = normalizeModule(mod, name);
140
+ result.plugins.push(...norm.plugins);
141
+ result.skills.push(...norm.skills);
142
+ } catch (e) {
143
+ result.errors.push(`${name}: ${e.message}`);
144
+ }
145
+ }
146
+ return result;
147
+ }
148
+
149
+ // cwd 의 node_modules + cdsa-harness 자신의 node_modules(전역 설치 시 형제 패키지) 를 훑고,
150
+ // config.plugins 에 적힌 패키지는 이름 규칙과 무관하게 강제로 불러온다.
151
+ export async function discoverNpmExtensions(cwd, explicitNames = []) {
152
+ const here = path.dirname(fileURLToPath(import.meta.url));
153
+ const nmDirs = [];
154
+ const add = (d) => {
155
+ const r = path.resolve(d);
156
+ if (!nmDirs.includes(r)) nmDirs.push(r);
157
+ };
158
+ add(path.join(cwd, "node_modules"));
159
+ add(path.resolve(here, "..", "..")); // .../node_modules/cdsa-harness/src → .../node_modules
160
+
161
+ const merged = { plugins: [], skills: [], errors: [] };
162
+ for (const nm of nmDirs) {
163
+ const r = await scanNodeModules(nm);
164
+ merged.plugins.push(...r.plugins);
165
+ merged.skills.push(...r.skills);
166
+ merged.errors.push(...r.errors);
167
+ }
168
+ for (const name of explicitNames) {
169
+ if (merged.plugins.some((p) => p.source === name)) continue;
170
+ let loaded = false;
171
+ for (const nm of nmDirs) {
172
+ try {
173
+ const norm = normalizeModule(await importPackage(nm, name), name);
174
+ if (norm.plugins.length || norm.skills.length) {
175
+ merged.plugins.push(...norm.plugins);
176
+ merged.skills.push(...norm.skills);
177
+ loaded = true;
178
+ break;
179
+ }
180
+ } catch {
181
+ /* try next dir */
182
+ }
183
+ }
184
+ if (!loaded) merged.errors.push(`${name}: 불러올 수 없음(설치되어 있나요? npm i ${name})`);
185
+ }
186
+ // 이름 중복 제거(먼저 발견된 것 우선)
187
+ const byName = new Map();
188
+ for (const p of merged.plugins) if (!byName.has(p.name)) byName.set(p.name, p);
189
+ merged.plugins = [...byName.values()];
190
+ return merged;
191
+ }
package/src/skills.js ADDED
@@ -0,0 +1,93 @@
1
+ // 스킬 시스템 — OpenCode 의 커스텀 커맨드와 같은 개념.
2
+ // `.cdsa/skills/` (작업 폴더) 와 `~/.cdsa_harness/skills/` (전역) 의 .md 파일을 불러온다.
3
+ // 파일명이 곧 스킬 이름이고(`/이름` 으로 실행), 본문은 모델에 전달할 프롬프트 템플릿이다.
4
+ // 본문 안의 `$ARGUMENTS` 는 `/이름 뒤에 붙인 텍스트` 로 치환된다.
5
+ //
6
+ // ---
7
+ // description: 파일을 읽고 3줄로 요약
8
+ // ---
9
+ // $ARGUMENTS 파일을 read_file 로 읽고 핵심을 한국어 3줄로 요약해줘.
10
+ import fs from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+
14
+ // 우리 폴더 + 다른 코딩 에이전트들의 커맨드/스킬 폴더도 함께 읽어 상호운용한다.
15
+ // (Claude Code, OpenCode 등은 스킬이 결국 frontmatter 달린 마크다운이라 그대로 호환)
16
+ export function skillDirs(workspace) {
17
+ const home = os.homedir();
18
+ return [
19
+ // 전역
20
+ path.join(home, ".cdsa_harness", "skills"),
21
+ path.join(home, ".claude", "commands"),
22
+ path.join(home, ".config", "opencode", "command"),
23
+ // 작업 폴더
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"),
29
+ ];
30
+ }
31
+
32
+ function parseFrontmatter(text) {
33
+ const m = /^---\n([\s\S]*?)\n---\n?/.exec(text);
34
+ if (!m) return { meta: {}, body: text.trim() };
35
+ const meta = {};
36
+ for (const line of m[1].split("\n")) {
37
+ const i = line.indexOf(":");
38
+ if (i > 0) meta[line.slice(0, i).trim()] = line.slice(i + 1).trim();
39
+ }
40
+ return { meta, body: text.slice(m[0].length).trim() };
41
+ }
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
+
54
+ export function loadSkills(workspace) {
55
+ const skills = {};
56
+ for (const dir of skillDirs(workspace)) {
57
+ let entries = [];
58
+ try {
59
+ if (!fs.existsSync(dir)) continue;
60
+ entries = fs.readdirSync(dir).sort();
61
+ } catch {
62
+ continue;
63
+ }
64
+ for (const e of entries) {
65
+ const full = path.join(dir, e);
66
+ let stat;
67
+ try {
68
+ stat = fs.statSync(full);
69
+ } catch {
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);
78
+ }
79
+ }
80
+ }
81
+ return skills;
82
+ }
83
+
84
+ // 스킬 본문에 인자를 채워 최종 프롬프트를 만든다. $ARGUMENTS / {{args}} 둘 다 지원.
85
+ export function renderSkill(skill, args) {
86
+ const argStr = (args || "").trim();
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;
93
+ }
package/src/tools.js CHANGED
@@ -51,10 +51,27 @@ export function diffLines(oldText, newText) {
51
51
  }
52
52
 
53
53
  export class Toolbox {
54
- constructor(workspace, allowShell = false) {
54
+ constructor(workspace, allowShell = false, plugins = []) {
55
55
  this.workspace = path.resolve(workspace);
56
56
  fs.mkdirSync(this.workspace, { recursive: true });
57
57
  this.allowShell = allowShell;
58
+ // 정상 로드된 플러그인만 도구로 사용(로드 에러는 따로 보관해 표시).
59
+ this.plugins = plugins.filter((p) => p && p.name && typeof p.handler === "function");
60
+ this.pluginErrors = plugins.filter((p) => p && p.error).map((p) => p.error);
61
+ this._pluginMap = new Map(this.plugins.map((p) => [p.name, p]));
62
+ }
63
+
64
+ // 실행 전 사용자 승인이 필요한 도구인가? (환경을 바꾸는 내장 도구 + mutating 플러그인)
65
+ isMutating(name) {
66
+ if (MUTATING_TOOLS.has(name)) return true;
67
+ const p = this._pluginMap.get(name);
68
+ return Boolean(p && p.mutating);
69
+ }
70
+
71
+ label(name) {
72
+ if (TOOL_LABELS[name]) return TOOL_LABELS[name];
73
+ const p = this._pluginMap.get(name);
74
+ return p ? `플러그인:${name}` : name;
58
75
  }
59
76
 
60
77
  _resolve(rel) {
@@ -148,16 +165,27 @@ export class Toolbox {
148
165
  return { ok: code === 0, output: `$ ${command}\n(exit=${code})\n${out}` };
149
166
  }
150
167
 
151
- execute(name, args = {}) {
168
+ async execute(name, args = {}) {
152
169
  if (name === "list_dir") return this.listDir(args.path || ".");
153
170
  if (name === "read_file") return this.readFile(args.path || "");
154
171
  if (name === "write_file") return this.writeFile(args.path || "", args.content || "");
155
172
  if (name === "run_shell") return this.runShell(args.command || "");
173
+ const plugin = this._pluginMap.get(name);
174
+ if (plugin) {
175
+ let result;
176
+ try {
177
+ result = await plugin.handler(args, { workspace: this.workspace });
178
+ } catch (e) {
179
+ throw new ToolError(`플러그인 '${name}' 실행 오류: ${e.message}`);
180
+ }
181
+ const output = typeof result === "string" ? result : result?.output ?? JSON.stringify(result);
182
+ return { ok: true, output: String(output) };
183
+ }
156
184
  throw new ToolError(`알 수 없는 도구입니다: ${name}`);
157
185
  }
158
186
  }
159
187
 
160
- export function toolSchemas(allowShell = false) {
188
+ export function toolSchemas(allowShell = false, plugins = []) {
161
189
  const schemas = [
162
190
  {
163
191
  type: "function",
@@ -216,5 +244,17 @@ export function toolSchemas(allowShell = false) {
216
244
  },
217
245
  });
218
246
  }
247
+ // 플러그인이 제공하는 도구를 모델에게도 노출한다.
248
+ for (const p of plugins) {
249
+ if (!p || !p.name || typeof p.handler !== "function") continue;
250
+ schemas.push({
251
+ type: "function",
252
+ function: {
253
+ name: p.name,
254
+ description: (p.description || `플러그인 도구 ${p.name}`) + (p.mutating ? " (승인 필요)" : ""),
255
+ parameters: p.parameters || { type: "object", properties: {} },
256
+ },
257
+ });
258
+ }
219
259
  return schemas;
220
260
  }
@@ -0,0 +1,31 @@
1
+ // 예시 플러그인: 작업 폴더 안 텍스트 파일의 글자/줄/단어 수를 센다.
2
+ // 이 파일을 .cdsa/plugins/ 에 두면 자동으로 'word_count' 도구가 등록되어
3
+ // 모델이 호출할 수 있게 된다. (읽기 전용이라 mutating: false → 승인 불필요)
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+
7
+ export default {
8
+ name: "word_count",
9
+ description: "작업 폴더 안 텍스트 파일의 글자수/줄수/단어수를 센다.",
10
+ parameters: {
11
+ type: "object",
12
+ properties: { path: { type: "string", description: "작업 폴더 기준 상대 경로" } },
13
+ required: ["path"],
14
+ },
15
+ mutating: false,
16
+ async handler(args, ctx) {
17
+ const rel = (args.path || "").trim();
18
+ const target = path.resolve(ctx.workspace, rel);
19
+ if (target !== ctx.workspace && !target.startsWith(ctx.workspace + path.sep)) {
20
+ return "작업 폴더 밖 경로에는 접근할 수 없습니다.";
21
+ }
22
+ if (!fs.existsSync(target) || !fs.statSync(target).isFile()) {
23
+ return `파일이 없습니다: ${rel}`;
24
+ }
25
+ const text = fs.readFileSync(target, "utf8");
26
+ const chars = text.length;
27
+ const lines = text.split("\n").length;
28
+ const words = (text.trim().match(/\S+/g) || []).length;
29
+ return `${rel} → 글자 ${chars}, 줄 ${lines}, 단어 ${words}`;
30
+ },
31
+ };
@@ -0,0 +1,5 @@
1
+ ---
2
+ description: 지정한 파일을 읽고 한국어 3줄로 요약
3
+ ---
4
+ $ARGUMENTS 파일을 read_file 도구로 읽은 다음, 핵심 내용을 한국어 3줄로 요약해줘.
5
+ 파일을 수정하지는 말고, 요약만 보여줘.