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 +25 -4
- package/package.json +2 -2
- package/src/cli.js +94 -40
- package/src/config.js +4 -2
- package/src/llm.js +10 -1
- package/src/mcp.js +148 -0
- package/src/skills.js +44 -12
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
|
-
-
|
|
15
|
-
-
|
|
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/`
|
|
114
|
-
|
|
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
|
-
"description": "AI 에이전트의 내부 동작을 단계별로 드러내는 교육용 터미널 하네스. 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
|
@@ -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(
|
|
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
|
-
|
|
164
|
-
|
|
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")} 플러그인 목록(
|
|
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
|
-
|
|
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
|
|
248
|
-
|
|
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
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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 =
|
|
271
|
-
|
|
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
|
|
276
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
369
|
-
if (
|
|
370
|
-
if (
|
|
371
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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(
|
|
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.
|
|
15
|
-
"google/gemini-2.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
57
|
+
let entries = [];
|
|
36
58
|
try {
|
|
37
59
|
if (!fs.existsSync(dir)) continue;
|
|
38
|
-
|
|
60
|
+
entries = fs.readdirSync(dir).sort();
|
|
39
61
|
} catch {
|
|
40
62
|
continue;
|
|
41
63
|
}
|
|
42
|
-
for (const
|
|
43
|
-
const
|
|
64
|
+
for (const e of entries) {
|
|
65
|
+
const full = path.join(dir, e);
|
|
66
|
+
let stat;
|
|
44
67
|
try {
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
}
|