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 +74 -0
- package/package.json +2 -2
- package/src/cli.js +169 -34
- package/src/config.js +5 -2
- package/src/llm.js +10 -1
- package/src/loop.js +5 -5
- package/src/mcp.js +148 -0
- package/src/plugins.js +191 -0
- package/src/skills.js +93 -0
- package/src/tools.js +43 -3
- package/workspace/.cdsa/plugins/word_count.mjs +31 -0
- package/workspace/.cdsa/skills/summarize.md +5 -0
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.
|
|
4
|
-
"description": "AI 에이전트의 내부
|
|
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(
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
|
242
|
-
|
|
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
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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 =
|
|
265
|
-
|
|
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
|
|
270
|
-
|
|
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(
|
|
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(
|
|
385
|
+
await runSetup(ask, cfg);
|
|
327
386
|
}
|
|
328
387
|
|
|
329
388
|
printIntro(cfg);
|
|
330
389
|
|
|
331
|
-
|
|
332
|
-
|
|
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(
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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(
|
|
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.
|
|
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,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
|
-
|
|
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 {
|
|
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 =
|
|
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
|
+
};
|