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 +27 -4
- package/package.json +2 -2
- package/src/cli.js +150 -51
- package/src/config.js +5 -2
- package/src/llm.js +161 -5
- package/src/loop.js +6 -2
- package/src/mcp.js +148 -0
- package/src/skills.js +44 -12
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
|
-
-
|
|
15
|
-
-
|
|
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/`
|
|
114
|
-
|
|
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
|
-
"description": "AI 에이전트의 내부 동작을 단계별로 드러내는 교육용 터미널 하네스. OpenAI/Claude/OpenRouter +
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
164
|
-
|
|
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")} 플러그인 목록(
|
|
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
|
-
|
|
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
|
|
248
|
-
|
|
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
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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 =
|
|
271
|
-
|
|
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
|
|
276
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
369
|
-
if (
|
|
370
|
-
if (
|
|
371
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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(
|
|
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.
|
|
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
|
};
|
|
@@ -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
|
-
|
|
27
|
-
|
|
28
|
-
if (this.provider === "
|
|
29
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
}
|