cdsa-harness 0.1.1 → 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/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
+ };
@@ -0,0 +1,5 @@
1
+ ---
2
+ description: 지정한 파일을 읽고 한국어 3줄로 요약
3
+ ---
4
+ $ARGUMENTS 파일을 read_file 도구로 읽은 다음, 핵심 내용을 한국어 3줄로 요약해줘.
5
+ 파일을 수정하지는 말고, 요약만 보여줘.