cdsa-harness 0.2.0 → 0.4.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 +53 -0
- package/package.json +2 -2
- package/src/cli.js +82 -1
- package/src/config.js +1 -0
- package/src/loop.js +5 -5
- package/src/plugins.js +191 -0
- package/src/skills.js +61 -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,8 @@
|
|
|
11
11
|
- **의존성 0개** — Node 18+ 내장 기능만(`fetch`/`readline`/`node:test`)
|
|
12
12
|
- **실제 LLM 연결** — OpenAI · Anthropic(Claude) · OpenRouter, 또는 키 없이 `mock`
|
|
13
13
|
- **교육 모드** — 매 반복마다 모델에 보내는 메시지 구성·추정 토큰·시스템 프롬프트, 실제 토큰 사용량/응답시간까지 그대로 표시
|
|
14
|
+
- **플러그인** — `.cdsa/plugins/` 에 JS 파일을 두면 **새 도구가 자동 등록**되어 모델이 사용
|
|
15
|
+
- **스킬** — `.cdsa/skills/` 에 마크다운을 두면 `/이름` 으로 부르는 **프롬프트 템플릿**
|
|
14
16
|
|
|
15
17
|
---
|
|
16
18
|
|
|
@@ -68,6 +70,57 @@ export OPENROUTER_API_KEY=sk-or-...
|
|
|
68
70
|
|
|
69
71
|
---
|
|
70
72
|
|
|
73
|
+
## 🔌 플러그인 (추가 도구)
|
|
74
|
+
|
|
75
|
+
### 방법 A — npm 으로 설치 (권장)
|
|
76
|
+
|
|
77
|
+
`cdsa-harness-plugin-*` 이름의 패키지를 설치하면 **자동으로 발견·로드**됩니다.
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
cdsa-harness add cdsa-harness-plugin-git # = npm install 후 자동 로드
|
|
81
|
+
# 또는 직접: npm install cdsa-harness-plugin-git
|
|
82
|
+
cdsa-harness # 실행 → /plugins 에 자동 등록됨
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
- cwd 의 `node_modules` 와 전역 설치 위치를 모두 탐색합니다.
|
|
86
|
+
- 이름 규칙과 무관하게 강제 로드하려면 `config.json` 의 `"plugins": ["패키지명"]` 에 추가.
|
|
87
|
+
- 플러그인 패키지는 default export 로 `플러그인 def` · `def 배열` · `{ tools:[...], skills:[...] }` 중 하나를 제공.
|
|
88
|
+
|
|
89
|
+
> **npm vs npx**: `npm install`(=설치, 보관) 으로 플러그인을 **추가**하고, `npx`(=설치 없이 실행) 또는 설치된 `cdsa-harness` 로 **실행**합니다.
|
|
90
|
+
|
|
91
|
+
### 방법 B — 로컬 파일 (실험용)
|
|
92
|
+
|
|
93
|
+
`.cdsa/plugins/` (작업 폴더) 또는 `~/.cdsa_harness/plugins/` 에 `.js`/`.mjs` 파일을 두면 자동 등록.
|
|
94
|
+
|
|
95
|
+
```js
|
|
96
|
+
// .cdsa/plugins/word_count.mjs
|
|
97
|
+
import fs from "node:fs";
|
|
98
|
+
import path from "node:path";
|
|
99
|
+
export default {
|
|
100
|
+
name: "word_count",
|
|
101
|
+
description: "텍스트 파일의 글자/줄/단어 수를 센다",
|
|
102
|
+
parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] },
|
|
103
|
+
mutating: false, // true 면 실행 전 승인
|
|
104
|
+
async handler(args, ctx) { // ctx.workspace = 작업 폴더 절대경로
|
|
105
|
+
const text = fs.readFileSync(path.resolve(ctx.workspace, args.path), "utf8");
|
|
106
|
+
return `글자 ${text.length}, 줄 ${text.split("\n").length}`;
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## 🎯 스킬 (프롬프트 템플릿)
|
|
112
|
+
|
|
113
|
+
`.cdsa/skills/` 또는 `~/.cdsa_harness/skills/` 에 마크다운을 두면 `/파일명` 으로 실행됩니다.
|
|
114
|
+
본문의 `$ARGUMENTS` 는 명령 뒤 텍스트로 치환됩니다. `/skills` 로 목록 확인.
|
|
115
|
+
|
|
116
|
+
```markdown
|
|
117
|
+
---
|
|
118
|
+
description: 파일을 읽고 3줄로 요약
|
|
119
|
+
---
|
|
120
|
+
$ARGUMENTS 파일을 read_file 로 읽고 핵심을 한국어 3줄로 요약해줘.
|
|
121
|
+
```
|
|
122
|
+
실행: `/summarize notes.txt`
|
|
123
|
+
|
|
71
124
|
## 설정 (config.json)
|
|
72
125
|
|
|
73
126
|
`~/.cdsa_harness/config.json` (실행 폴더에 `config.json` 있으면 우선).
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cdsa-harness",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "AI 에이전트의 내부
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "AI 에이전트의 내부 동작을 단계별로 드러내는 교육용 터미널 하네스. OpenAI/Claude/OpenRouter + npm 으로 설치하는 플러그인(추가 도구)·스킬 확장.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"cdsa-harness": "bin/cdsa-harness.js",
|
package/src/cli.js
CHANGED
|
@@ -13,9 +13,13 @@ 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 { discoverNpmExtensions, loadPlugins } from "./plugins.js";
|
|
18
21
|
import { SessionLog, sessionsDir } from "./session.js";
|
|
22
|
+
import { loadSkills, renderSkill } from "./skills.js";
|
|
19
23
|
import { Toolbox } from "./tools.js";
|
|
20
24
|
import { c, panel, renderDiff } from "./ui.js";
|
|
21
25
|
|
|
@@ -213,6 +217,8 @@ function printHelp() {
|
|
|
213
217
|
` ${c.cyan("/model")} <이름> 모델 변경`,
|
|
214
218
|
` ${c.cyan("/teach")} 교육 모드 켜기/끄기(내부 과정 펼쳐보기)`,
|
|
215
219
|
` ${c.cyan("/context")} 지금 모델에 보내는 컨텍스트 들여다보기`,
|
|
220
|
+
` ${c.cyan("/skills")} 스킬 목록(.cdsa/skills 의 /명령들)`,
|
|
221
|
+
` ${c.cyan("/plugins")} 플러그인 목록(.cdsa/plugins 의 추가 도구)`,
|
|
216
222
|
` ${c.cyan("/reset")} 대화/컨텍스트 초기화`,
|
|
217
223
|
` ${c.cyan("/config")} 현재 설정값`,
|
|
218
224
|
` ${c.cyan("/quit")} 종료 (Ctrl+D)`,
|
|
@@ -298,6 +304,7 @@ export async function main(argv = []) {
|
|
|
298
304
|
console.log(
|
|
299
305
|
"CDSA Harness — AI 에이전트 내부를 드러내는 교육용 하네스 (터미널)\n\n" +
|
|
300
306
|
"사용법: cdsa-harness [옵션]\n" +
|
|
307
|
+
" cdsa-harness add <npm-패키지> 플러그인 설치(이후 자동 로드)\n" +
|
|
301
308
|
" --provider <openai|anthropic|openrouter|mock>\n" +
|
|
302
309
|
" --model <모델명>\n" +
|
|
303
310
|
" --workspace <폴더경로>\n" +
|
|
@@ -310,6 +317,24 @@ export async function main(argv = []) {
|
|
|
310
317
|
return 0;
|
|
311
318
|
}
|
|
312
319
|
|
|
320
|
+
// `cdsa-harness add <패키지>` — 플러그인을 npm 으로 설치(이후 자동 로드).
|
|
321
|
+
if (args._[0] === "add" || args._[0] === "install") {
|
|
322
|
+
const pkgs = args._.slice(1);
|
|
323
|
+
if (!pkgs.length) {
|
|
324
|
+
console.log("사용법: cdsa-harness add <npm-패키지...> 예) cdsa-harness add cdsa-harness-plugin-git");
|
|
325
|
+
return 1;
|
|
326
|
+
}
|
|
327
|
+
console.log(c.cyan(`npm install ${pkgs.join(" ")} ...`));
|
|
328
|
+
try {
|
|
329
|
+
execFileSync("npm", ["install", ...pkgs], { stdio: "inherit", cwd: process.cwd() });
|
|
330
|
+
console.log(c.green("설치 완료. 다음 실행부터 플러그인이 자동으로 로드됩니다 (/plugins 로 확인)."));
|
|
331
|
+
return 0;
|
|
332
|
+
} catch (e) {
|
|
333
|
+
console.log(c.red(`설치 실패: ${e.message}`));
|
|
334
|
+
return 1;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
313
338
|
const cfg = loadConfig();
|
|
314
339
|
if (args.provider) cfg.provider = args.provider;
|
|
315
340
|
if (args.model) cfg.model = args.model;
|
|
@@ -328,7 +353,24 @@ export async function main(argv = []) {
|
|
|
328
353
|
|
|
329
354
|
printIntro(cfg);
|
|
330
355
|
|
|
331
|
-
|
|
356
|
+
// 플러그인(추가 도구)·스킬(프롬프트 템플릿)을 불러온다:
|
|
357
|
+
// ① 파일: .cdsa/plugins · .cdsa/skills (작업폴더/홈)
|
|
358
|
+
// ② npm 패키지: cdsa-harness-plugin-* 자동 발견 + config.plugins 강제 로드
|
|
359
|
+
const filePlugins = await loadPlugins(cfg.workspacePath());
|
|
360
|
+
const npm = await discoverNpmExtensions(process.cwd(), cfg.plugins || []);
|
|
361
|
+
const plugins = [...filePlugins, ...npm.plugins, ...npm.errors.map((e) => ({ error: e }))];
|
|
362
|
+
const skills = {};
|
|
363
|
+
for (const s of npm.skills) skills[s.name] = { name: s.name, description: s.description || "", body: s.body, source: "(npm)" };
|
|
364
|
+
Object.assign(skills, loadSkills(cfg.workspacePath())); // 로컬 파일 스킬이 우선
|
|
365
|
+
const toolbox = new Toolbox(cfg.workspacePath(), cfg.allow_shell, plugins);
|
|
366
|
+
if (toolbox.plugins.length || Object.keys(skills).length || toolbox.pluginErrors.length) {
|
|
367
|
+
const bits = [];
|
|
368
|
+
if (toolbox.plugins.length) bits.push(c.green(`플러그인 ${toolbox.plugins.length}개`) + c.grey(` (${toolbox.plugins.map((p) => p.name).join(", ")})`));
|
|
369
|
+
if (Object.keys(skills).length) bits.push(c.green(`스킬 ${Object.keys(skills).length}개`) + c.grey(` (${Object.keys(skills).map((s) => "/" + s).join(", ")})`));
|
|
370
|
+
if (toolbox.pluginErrors.length) bits.push(c.red(`플러그인 오류 ${toolbox.pluginErrors.length}개`));
|
|
371
|
+
console.log("🔌 " + bits.join(" · ") + "\n");
|
|
372
|
+
}
|
|
373
|
+
|
|
332
374
|
const session = SessionLog.create();
|
|
333
375
|
const loop = new AgentLoop({
|
|
334
376
|
config: cfg,
|
|
@@ -394,6 +436,45 @@ export async function main(argv = []) {
|
|
|
394
436
|
console.log(panel(clip(ctx.systemPrompt, 800).split("\n"), { title: "📜 시스템 프롬프트", color: "grey" }));
|
|
395
437
|
continue;
|
|
396
438
|
}
|
|
439
|
+
if (low === "/plugins") {
|
|
440
|
+
const lines = [];
|
|
441
|
+
if (!toolbox.plugins.length) lines.push(c.dim("등록된 플러그인이 없습니다."));
|
|
442
|
+
for (const p of toolbox.plugins) {
|
|
443
|
+
const src = p.source && p.source.includes("/") ? "📄 " + p.source.split("/").slice(-1)[0] : "📦 " + (p.source || "npm");
|
|
444
|
+
lines.push(`${c.bold(p.name)}${p.mutating ? c.yellow(" (승인필요)") : ""} ${c.grey(p.description || "")} ${c.dim(src)}`);
|
|
445
|
+
}
|
|
446
|
+
for (const e of toolbox.pluginErrors) lines.push(c.red("✖ " + e));
|
|
447
|
+
lines.push(c.dim("추가: npm 패키지 'cdsa-harness-plugin-*' 설치 → 자동 로드 (cdsa-harness add <pkg>)"));
|
|
448
|
+
lines.push(c.dim("또는: <작업폴더>/.cdsa/plugins/ 에 .js/.mjs 파일"));
|
|
449
|
+
console.log(panel(lines, { title: "🔌 플러그인 (모델이 쓸 수 있는 추가 도구)", color: "blue" }));
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
if (low === "/skills") {
|
|
453
|
+
const names = Object.keys(skills);
|
|
454
|
+
const lines = names.length ? names.map((n) => `${c.cyan("/" + n)} ${c.grey(skills[n].description || "")}`) : [c.dim("등록된 스킬이 없습니다.")];
|
|
455
|
+
lines.push(c.dim("위치: <작업폴더>/.cdsa/skills/ 또는 ~/.cdsa_harness/skills/ (.md). 본문의 $ARGUMENTS 치환."));
|
|
456
|
+
console.log(panel(lines, { title: "🎯 스킬 (프롬프트 템플릿, /이름 으로 실행)", color: "cyan" }));
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// 위 내장 명령에 안 걸린 '/...' → 스킬이면 실행, 아니면 안내.
|
|
461
|
+
if (user.startsWith("/")) {
|
|
462
|
+
const name = low.slice(1).split(/\s+/)[0];
|
|
463
|
+
if (skills[name]) {
|
|
464
|
+
const argStr = user.split(/\s+/).slice(1).join(" ");
|
|
465
|
+
console.log(c.dim(`(스킬 '/${name}' 실행)`));
|
|
466
|
+
rule();
|
|
467
|
+
try {
|
|
468
|
+
await loop.run(renderSkill(skills[name], argStr));
|
|
469
|
+
} catch (e) {
|
|
470
|
+
console.log(c.red(`실행 오류: ${e?.message || e}`));
|
|
471
|
+
}
|
|
472
|
+
rule();
|
|
473
|
+
} else {
|
|
474
|
+
console.log(c.yellow(`알 수 없는 명령/스킬: /${name} — ${c.cyan("/help")}, ${c.cyan("/skills")} 참고`));
|
|
475
|
+
}
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
397
478
|
|
|
398
479
|
rule();
|
|
399
480
|
try {
|
package/src/config.js
CHANGED
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/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,61 @@
|
|
|
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
|
+
export function skillDirs(workspace) {
|
|
15
|
+
return [
|
|
16
|
+
path.join(os.homedir(), ".cdsa_harness", "skills"),
|
|
17
|
+
path.join(workspace, ".cdsa", "skills"),
|
|
18
|
+
];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseFrontmatter(text) {
|
|
22
|
+
const m = /^---\n([\s\S]*?)\n---\n?/.exec(text);
|
|
23
|
+
if (!m) return { meta: {}, body: text.trim() };
|
|
24
|
+
const meta = {};
|
|
25
|
+
for (const line of m[1].split("\n")) {
|
|
26
|
+
const i = line.indexOf(":");
|
|
27
|
+
if (i > 0) meta[line.slice(0, i).trim()] = line.slice(i + 1).trim();
|
|
28
|
+
}
|
|
29
|
+
return { meta, body: text.slice(m[0].length).trim() };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function loadSkills(workspace) {
|
|
33
|
+
const skills = {};
|
|
34
|
+
for (const dir of skillDirs(workspace)) {
|
|
35
|
+
let files = [];
|
|
36
|
+
try {
|
|
37
|
+
if (!fs.existsSync(dir)) continue;
|
|
38
|
+
files = fs.readdirSync(dir).filter((f) => f.endsWith(".md")).sort();
|
|
39
|
+
} catch {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
for (const f of files) {
|
|
43
|
+
const name = f.replace(/\.md$/, "");
|
|
44
|
+
try {
|
|
45
|
+
const raw = fs.readFileSync(path.join(dir, f), "utf8");
|
|
46
|
+
const { meta, body } = parseFrontmatter(raw);
|
|
47
|
+
skills[name] = { name, description: meta.description || "", body, source: path.join(dir, f) };
|
|
48
|
+
} catch {
|
|
49
|
+
/* ignore unreadable skill */
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return skills;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 스킬 본문에 인자를 채워 최종 프롬프트를 만든다.
|
|
57
|
+
export function renderSkill(skill, args) {
|
|
58
|
+
const argStr = (args || "").trim();
|
|
59
|
+
if (skill.body.includes("$ARGUMENTS")) return skill.body.replace(/\$ARGUMENTS/g, argStr);
|
|
60
|
+
return argStr ? `${skill.body}\n\n[추가 입력]\n${argStr}` : skill.body;
|
|
61
|
+
}
|
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
|
+
};
|