cdsa-harness 0.5.1 → 0.11.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 CHANGED
@@ -9,6 +9,7 @@
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
  - **MCP 클라이언트** — Claude Code·Cursor 등과 **공용 표준**. MCP 서버를 그대로 붙여 도구로 사용
@@ -17,13 +18,31 @@
17
18
 
18
19
  ---
19
20
 
20
- ## 설치 / 실행
21
+ ## 설치 / 실행 — 둘 중 하나 선택
21
22
 
23
+ ### 방법 1) Node.js + npm (개발자·일반)
24
+ Node 18+ 가 있으면:
22
25
  ```bash
23
26
  npx cdsa-harness # 설치 없이 즉시 (키 없으면 mock)
24
27
  npm install -g cdsa-harness # 전역 설치 → 'cdsa-harness' / 'cdsa'
25
28
  ```
26
29
 
30
+ ### 방법 2) 단일 실행파일 다운로드 (Node 불필요 · 폐쇄망)
31
+ **Node 설치 없이** 파일 하나만 받아 실행합니다. 공공/폐쇄망에 적합.
32
+ 1. [Releases](https://github.com/cdsassj00/miniharness/releases) 에서 OS 에 맞는 파일 다운로드
33
+ - Windows: `cdsa-harness-win.exe`
34
+ - macOS: `cdsa-harness-macos`
35
+ - Linux: `cdsa-harness-linux`
36
+ 2. 실행:
37
+ ```bash
38
+ # Windows (PowerShell)
39
+ .\cdsa-harness-win.exe
40
+ # macOS / Linux (실행권한 부여 후)
41
+ chmod +x ./cdsa-harness-linux && ./cdsa-harness-linux
42
+ ```
43
+ > 이 바이너리는 Node 런타임을 포함(약 100MB+)하므로 Node 설치가 필요 없습니다.
44
+ > 내장 스킬·플러그인(HWPX 포함)도 모두 들어있어 단독 실행됩니다.
45
+
27
46
  ## 실제 AI 연결하기
28
47
 
29
48
  가장 쉬운 길 — 실행 후 `/setup` 입력(대화형으로 제공자·키·모델 선택):
@@ -53,6 +72,7 @@ export OPENROUTER_API_KEY=sk-or-...
53
72
  | `/provider <이름>` | openai · anthropic · openrouter · mock 전환 |
54
73
  | `/model <이름>` | 모델 변경 |
55
74
  | `/teach` | 교육 모드 켜기/끄기 |
75
+ | `/stream` | 실시간 스트리밍 출력 켜기/끄기 |
56
76
  | `/context` | 지금 모델에 보내는 컨텍스트 들여다보기 |
57
77
  | `/reset` | 대화/컨텍스트 초기화 |
58
78
  | `/config` | 현재 설정값 |
@@ -129,7 +149,19 @@ export default {
129
149
  ## 🎯 스킬 (프롬프트 템플릿)
130
150
 
131
151
  `.cdsa/skills/` 에 마크다운을 두면 `/파일명` 으로 실행됩니다. 본문의 `$ARGUMENTS`(또는 `{{args}}`)가 치환됩니다.
132
- **다른 에이전트의 스킬도 인식** — `.claude/commands/`, `.claude/skills/<이름>/SKILL.md`, `.opencode/command/` 등을 함께 읽습니다. `/skills` 로 목록 확인.
152
+
153
+ - **기본 내장 스킬**(설치하면 누구에게나 제공):
154
+ - 실용: `/explain` 쉽게 설명 · `/review` 코드 리뷰 · `/summarize` 3줄 요약 · `/tour` 프로젝트 브리핑 · `/todo` 미완성(TODO) 수집 · `/plan` 실행 전 계획만
155
+ - 교육/재미: `/eli5` 5살도 알게 · `/rubberduck` 질문으로 디버깅 · `/quiz` 학습 퀴즈 · `/haiku` 하이쿠 · `/loop` 이 요청을 에이전트 루프로 어떻게 처리할지 해설
156
+ - **🇰🇷 공공(대한민국)**: `/minwon` 민원분류 · `/gongmun` 공문 기안 · `/privacy` 개인정보 점검 · `/press` 보도자료 · `/report` 개조식 보고 · `/minutes` 회의록 · `/policyqa` 정책 Q&A · `/hwpx` 한컴 문서 요약
157
+
158
+ ## 🇰🇷 공공 특화 + HWPX
159
+
160
+ - **공공 스킬** — 위 `/minwon` `/gongmun` `/privacy` 등으로 민원·공문·개인정보·보도자료 등 행정 업무를 바로 시도.
161
+ - **HWPX 내장 도구(`hwpx_read`)** — 한컴 `.hwpx` 문서(zip+xml)에서 본문 텍스트를 **의존성 없이** 추출. `/hwpx 파일.hwpx` 로 읽어 요약.
162
+ - 구버전 `.hwp`(바이너리)는 한컴오피스에서 `.hwpx` 로 저장 후 사용.
163
+ - **다른 에이전트의 스킬도 인식** — `.claude/commands/`, `.claude/skills/<이름>/SKILL.md`, `.opencode/command/` 등을 함께 읽습니다.
164
+ - **남과 공유**하려면: 로컬 스킬 파일은 본인만 쓰지만, 플러그인 패키지(`{ skills: [...] }`)로 npm 배포하면 `cdsa-harness add` 한 모두가 사용. `/skills` 로 목록 확인.
133
165
 
134
166
  ```markdown
135
167
  ---
@@ -139,6 +171,31 @@ $ARGUMENTS 파일을 read_file 로 읽고 핵심을 한국어 3줄로 요약해
139
171
  ```
140
172
  실행: `/summarize notes.txt`
141
173
 
174
+ ## 🏢 폐쇄망 / 오프라인 설치
175
+
176
+ 의존성이 **0개**라 폐쇄망 배포가 쉽습니다(추가 다운로드 없음).
177
+
178
+ **Node 가 이미 있는 경우 — tgz 반입 후 오프라인 설치:**
179
+ ```bash
180
+ # (외부망) 패키지 묶기
181
+ npm pack cdsa-harness # → cdsa-harness-x.y.z.tgz 생성
182
+ # (폐쇄망으로 파일 반입 후)
183
+ npm install -g ./cdsa-harness-x.y.z.tgz # 인터넷 불필요
184
+ ```
185
+
186
+ **사내/폐쇄망 LLM (OpenAI 호환 서버 — vLLM, 내부 게이트웨이 등):**
187
+ `config.json` 의 `base_url` 에 **전체 엔드포인트**를 적으면 그쪽으로 호출합니다.
188
+ ```json
189
+ {
190
+ "provider": "openai",
191
+ "base_url": "https://llm.내부망.local/v1/chat/completions",
192
+ "api_key": "사내토큰",
193
+ "model": "사내모델명"
194
+ }
195
+ ```
196
+
197
+ > Node 자체를 못 까는 환경은 **단일 실행파일(.exe)** 빌드가 답입니다(로드맵).
198
+
142
199
  ## 설정 (config.json)
143
200
 
144
201
  `~/.cdsa_harness/config.json` (실행 폴더에 `config.json` 있으면 우선).
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cdsa-harness",
3
- "version": "0.5.1",
4
- "description": "AI 에이전트의 내부 동작을 단계별로 드러내는 교육용 터미널 하네스. OpenAI/Claude/OpenRouter + MCP(다른 에이전트와 공용) + npm 플러그인·크로스포맷 스킬.",
3
+ "version": "0.11.0",
4
+ "description": "AI 에이전트의 내부 동작을 단계별로 드러내는 교육용 터미널 하네스. 실시간 스트리밍 + OpenAI/Claude/OpenRouter + MCP + npm 플러그인·크로스포맷 스킬.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "cdsa-harness": "bin/cdsa-harness.js",
@@ -12,11 +12,16 @@
12
12
  },
13
13
  "scripts": {
14
14
  "start": "node bin/cdsa-harness.js",
15
- "test": "node --test"
15
+ "test": "node --test",
16
+ "gen": "node scripts/gen-builtins.mjs",
17
+ "build:exe": "node scripts/build-exe.mjs",
18
+ "prepack": "node scripts/gen-builtins.mjs"
16
19
  },
17
20
  "files": [
18
21
  "bin",
19
22
  "src",
23
+ "skills",
24
+ "plugins",
20
25
  "workspace",
21
26
  "README.md"
22
27
  ],
@@ -30,7 +35,7 @@
30
35
  "llm",
31
36
  "education"
32
37
  ],
33
- "author": "cdsassj00",
38
+ "author": "CDSA",
34
39
  "license": "MIT",
35
40
  "repository": {
36
41
  "type": "git",
@@ -40,5 +45,9 @@
40
45
  "homepage": "https://github.com/cdsassj00/miniharness/tree/main/node-cli#readme",
41
46
  "bugs": {
42
47
  "url": "https://github.com/cdsassj00/miniharness/issues"
48
+ },
49
+ "devDependencies": {
50
+ "esbuild": "^0.28.1",
51
+ "postject": "^1.0.0-alpha.6"
43
52
  }
44
53
  }
@@ -0,0 +1,95 @@
1
+ // 내장 플러그인: 한컴 HWPX 문서에서 본문 텍스트를 추출한다.
2
+ // HWPX 는 ZIP 컨테이너(안에 Contents/section*.xml). 의존성 없이 zlib 로 직접 푼다.
3
+ // (구버전 .hwp 는 바이너리 OLE 포맷이라 여기서는 안내만 한다 — .hwpx 로 저장 권장)
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import zlib from "node:zlib";
7
+
8
+ // --- 아주 작은 ZIP 리더 (method 0=저장, 8=deflate 지원) ---
9
+ function readZipEntries(buf) {
10
+ // End of Central Directory 찾기(뒤에서부터 시그니처 0x06054b50)
11
+ let eocd = -1;
12
+ for (let i = buf.length - 22; i >= 0 && i > buf.length - 22 - 65536; i--) {
13
+ if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
14
+ }
15
+ if (eocd < 0) throw new Error("ZIP(EOCD)을 찾을 수 없습니다 — HWPX 가 아닐 수 있어요");
16
+ const count = buf.readUInt16LE(eocd + 10);
17
+ let off = buf.readUInt32LE(eocd + 16); // central directory 시작
18
+ const entries = [];
19
+ for (let n = 0; n < count; n++) {
20
+ if (buf.readUInt32LE(off) !== 0x02014b50) break;
21
+ const method = buf.readUInt16LE(off + 10);
22
+ const compSize = buf.readUInt32LE(off + 20);
23
+ const nameLen = buf.readUInt16LE(off + 28);
24
+ const extraLen = buf.readUInt16LE(off + 30);
25
+ const commentLen = buf.readUInt16LE(off + 32);
26
+ const localOff = buf.readUInt32LE(off + 42);
27
+ const name = buf.toString("utf8", off + 46, off + 46 + nameLen);
28
+ entries.push({ name, method, compSize, localOff });
29
+ off += 46 + nameLen + extraLen + commentLen;
30
+ }
31
+ return entries;
32
+ }
33
+
34
+ function extractEntry(buf, entry) {
35
+ // local file header 에서 실제 데이터 시작 계산
36
+ const lo = entry.localOff;
37
+ if (buf.readUInt32LE(lo) !== 0x04034b50) throw new Error("로컬 헤더 손상");
38
+ const nameLen = buf.readUInt16LE(lo + 26);
39
+ const extraLen = buf.readUInt16LE(lo + 28);
40
+ const start = lo + 30 + nameLen + extraLen;
41
+ const data = buf.subarray(start, start + entry.compSize);
42
+ if (entry.method === 0) return data; // 저장(무압축)
43
+ if (entry.method === 8) return zlib.inflateRawSync(data); // deflate
44
+ throw new Error(`지원하지 않는 압축 방식(${entry.method})`);
45
+ }
46
+
47
+ // HWPX section XML 의 <hp:t>...</hp:t> 텍스트 런을 모아 평문으로.
48
+ function xmlToText(xml) {
49
+ const runs = [];
50
+ const re = /<hp:t[^>]*>([\s\S]*?)<\/hp:t>/g;
51
+ let m;
52
+ while ((m = re.exec(xml)) !== null) runs.push(m[1]);
53
+ let text = runs.join("");
54
+ if (!text) text = xml.replace(/<[^>]+>/g, ""); // 폴백: 모든 태그 제거
55
+ return text
56
+ .replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&")
57
+ .replace(/&quot;/g, '"').replace(/&#13;/g, "").replace(/&#10;/g, "\n")
58
+ .replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
59
+ }
60
+
61
+ export default {
62
+ name: "hwpx_read",
63
+ description: "한컴 HWPX(.hwpx) 문서에서 본문 텍스트를 추출한다. 구버전 .hwp 는 .hwpx 로 저장 후 사용.",
64
+ parameters: {
65
+ type: "object",
66
+ properties: { path: { type: "string", description: "작업 폴더 기준 .hwpx 상대 경로" } },
67
+ required: ["path"],
68
+ },
69
+ mutating: false,
70
+ async handler(args, ctx) {
71
+ const rel = (args.path || "").trim();
72
+ const target = path.resolve(ctx.workspace, rel);
73
+ if (target !== ctx.workspace && !target.startsWith(ctx.workspace + path.sep)) {
74
+ return "작업 폴더 밖 경로에는 접근할 수 없습니다.";
75
+ }
76
+ if (/\.hwp$/i.test(rel)) {
77
+ return "구버전 .hwp(바이너리)는 직접 지원하지 않습니다. 한컴오피스에서 '다른 이름으로 저장 → .hwpx' 후 다시 시도하세요.";
78
+ }
79
+ if (!fs.existsSync(target) || !fs.statSync(target).isFile()) return `파일이 없습니다: ${rel}`;
80
+ try {
81
+ const buf = fs.readFileSync(target);
82
+ const entries = readZipEntries(buf);
83
+ const sections = entries
84
+ .filter((e) => /Contents\/section\d+\.xml$/i.test(e.name))
85
+ .sort((a, b) => a.name.localeCompare(b.name));
86
+ if (!sections.length) return "HWPX 본문(Contents/section*.xml)을 찾지 못했습니다.";
87
+ const parts = sections.map((e) => xmlToText(extractEntry(buf, e).toString("utf8")));
88
+ const text = parts.join("\n\n").trim();
89
+ const clipped = text.length > 12000 ? text.slice(0, 12000) + "\n... (이후 생략)" : text;
90
+ return `[HWPX 본문 추출: ${rel}]\n\n${clipped || "(본문 텍스트 없음)"}`;
91
+ } catch (e) {
92
+ return `HWPX 파싱 실패: ${e.message}`;
93
+ }
94
+ },
95
+ };
package/skills/eli5.md ADDED
@@ -0,0 +1,6 @@
1
+ ---
2
+ description: 지정한 파일/개념을 5살도 알게 아주 쉽게 설명
3
+ ---
4
+ $ARGUMENTS 을(를) 다섯 살 아이도 이해할 수 있게 아주 쉬운 비유로 설명해줘.
5
+ 파일 이름이면 read_file 로 읽고 설명해. 전문용어는 풀어서 쓰고, 그림 그리듯 비유를 들어줘.
6
+ 파일은 수정하지 마.
@@ -0,0 +1,5 @@
1
+ ---
2
+ description: 파일/코드를 읽고 비개발자도 알게 쉽게 설명
3
+ ---
4
+ $ARGUMENTS 을(를) read_file 도구로 읽고, 무엇을 하는 코드/문서인지
5
+ 비개발자도 이해할 수 있게 한국어로 차근차근 설명해줘. 실제 수정은 하지 마.
@@ -0,0 +1,6 @@
1
+ ---
2
+ description: 공문서(기안문) 초안 작성 — 제목/수신/본문/붙임 [공공]
3
+ ---
4
+ "$ARGUMENTS" 내용을 바탕으로 대한민국 행정 공문서(기안문) 초안을 작성해줘.
5
+ 형식: 제목 / 수신 / (경유) / 본문(개조식, 'ㅇ' 글머리) / 붙임.
6
+ 정중한 공공 문체로. 사실이 불확실한 부분은 [ ] 로 비워 '채울 항목'임을 표시해줘.
@@ -0,0 +1,6 @@
1
+ ---
2
+ description: 파일이나 주제로 하이쿠(짧은 3줄 시) 짓기 — 재미용
3
+ ---
4
+ $ARGUMENTS (파일명이면 read_file 로 그 내용을 읽어)를 주제로,
5
+ 5-7-5 운율을 살린 한국어 하이쿠를 한 편 지어줘. 짧고 운치 있게.
6
+ 파일은 절대 건드리지 말고 시만 보여줘.
package/skills/hwpx.md ADDED
@@ -0,0 +1,6 @@
1
+ ---
2
+ description: 한컴 HWPX(.hwpx) 문서를 읽어 핵심을 개조식으로 요약 [공공]
3
+ ---
4
+ $ARGUMENTS 파일을 hwpx_read 도구로 읽어 본문 텍스트를 추출한 뒤,
5
+ 핵심 내용을 한국어 개조식('ㅇ' 글머리)으로 요약해줘. 파일은 수정하지 마.
6
+ (.hwp 구버전이면 .hwpx 로 저장 후 다시 시도하라고 안내해줘.)
package/skills/loop.md ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ description: 이 요청을 '에이전트 루프' 관점에서 어떻게 처리할지 메타 해설 (CDSA 특별)
3
+ ---
4
+ 다음 요청을 실제로 수행하기 전에, CDSA Harness 의 에이전트 루프
5
+ (컨텍스트 구성 → LLM 호출 → 도구 판단 → 실행 → 결과 되먹임) 관점에서
6
+ 어떤 도구를 어떤 순서로 부를 계획인지 단계별로 한국어로 먼저 설명해줘: $ARGUMENTS
7
+ 설명만 하고, 이번에는 실제 도구 호출(특히 write_file)은 하지 마.
@@ -0,0 +1,7 @@
1
+ ---
2
+ description: 회의 내용을 표준 회의록으로 정리(안건/논의/결정/조치) [공공]
3
+ ---
4
+ $ARGUMENTS (파일명이면 read_file 로 읽어)를 회의록으로 정리해줘.
5
+ 머리말: 일시[ ] / 장소[ ] / 참석[ ].
6
+ 본문: 안건별로 [논의내용] · [결정사항] · [조치사항(담당/기한)] 을 개조식으로.
7
+ 한국어로 깔끔하게. 파일은 수정하지 마.
@@ -0,0 +1,7 @@
1
+ ---
2
+ description: 민원 내용을 분류(분야·담당부서·긴급도)하고 처리 방향 제안 [공공]
3
+ ---
4
+ 다음 민원을 분석해줘($ARGUMENTS 가 파일명이면 read_file 로 읽어).
5
+ 표 형태로 한국어로 깔끔하게 정리해줘:
6
+ 1) 분야 분류 2) 담당 부서(추정) 3) 긴급도(상/중/하) 4) 핵심 요지 1줄 5) 처리 방향 제안
7
+ 개인정보(연락처·주민번호 등)가 보이면 마스킹해서 표시해줘. 파일은 수정하지 마.
package/skills/plan.md ADDED
@@ -0,0 +1,6 @@
1
+ ---
2
+ description: 시킨 작업을 '실행하지 말고' 단계별 계획만 세우기
3
+ ---
4
+ 다음 작업을 실제로 수행하지 말고, 어떻게 처리할지 단계별 계획만 세워줘: $ARGUMENTS
5
+ 필요하면 read_file / list_dir 로 사실만 확인하되, write_file 은 절대 호출하지 마.
6
+ 각 단계마다 '왜 그 단계가 필요한지'도 한 줄씩 덧붙여줘.
@@ -0,0 +1,6 @@
1
+ ---
2
+ description: 정책·제도 질문에 근거를 들어 설명(모르면 모른다고) [공공]
3
+ ---
4
+ 다음 질문에 공공 정책·행정 관점에서 한국어로 답해줘: $ARGUMENTS
5
+ 필요하면 작업 폴더의 관련 문서를 read_file 로 확인해 근거로 삼아.
6
+ 확실하지 않은 부분은 추측하지 말고 '확인 필요'로 표시하고, 어디서/누구에게 확인하면 되는지 안내해줘.
@@ -0,0 +1,6 @@
1
+ ---
2
+ description: 보도자료 초안 작성(제목/부제/리드/본문/문의처) [공공]
3
+ ---
4
+ "$ARGUMENTS" 를 주제로 공공기관 보도자료 초안을 작성해줘.
5
+ 구성: 제목 / 부제 / 리드(핵심 1문단) / 본문(육하원칙) / 기대효과 / 문의처[ ].
6
+ 객관적이고 간결한 보도 문체로 한국어로 작성해줘.
@@ -0,0 +1,7 @@
1
+ ---
2
+ description: 개인정보(주민번호·연락처·이메일·계좌 등) 점검 및 마스킹 제안 [공공]
3
+ ---
4
+ $ARGUMENTS (파일명이면 read_file 로 읽어)에서 개인정보로 보이는 항목
5
+ (주민등록번호, 휴대전화, 이메일, 주소, 계좌·카드번호, 여권번호 등)을 찾아
6
+ '유형 — 발견 위치 — 마스킹 예시(예: 010-****-1234)' 표로 정리해줘.
7
+ 하나도 없으면 없다고 명확히 말해줘. 파일은 절대 수정하지 말고 점검 보고만 해줘.
package/skills/quiz.md ADDED
@@ -0,0 +1,6 @@
1
+ ---
2
+ description: 작업 폴더 코드/문서로 학습 퀴즈 만들기
3
+ ---
4
+ 작업 폴더를 list_dir / read_file 로 살펴보고, 이 코드·문서를 공부할 수 있는
5
+ 객관식 퀴즈 3문제를 한국어로 만들어줘($ARGUMENTS 주제가 있으면 그쪽으로).
6
+ 각 문제에 보기 4개와 정답, 그리고 한 줄 해설을 달아줘. 파일은 수정하지 마.
@@ -0,0 +1,6 @@
1
+ ---
2
+ description: 개조식 1쪽 보고서로 요약(추진배경/현황/문제점/개선방안) [공공]
3
+ ---
4
+ $ARGUMENTS (파일명이면 read_file 로 읽어)를 대한민국 공공 보고서 양식의 개조식으로
5
+ 1쪽 분량으로 요약해줘. 항목: □ 추진배경 / □ 현황 / □ 문제점 / □ 개선방안 / □ 기대효과.
6
+ 각 항목은 'ㅇ' 글머리로 간결하게. 파일은 수정하지 마.
@@ -0,0 +1,5 @@
1
+ ---
2
+ description: 파일을 읽고 버그 위험·개선점을 리뷰
3
+ ---
4
+ $ARGUMENTS 파일을 read_file 도구로 읽고, 버그 위험·가독성·개선점을 한국어로 짚어줘.
5
+ 실제 수정은 하지 말고, 무엇을 어떻게 고치면 좋을지 제안만 해줘.
@@ -0,0 +1,7 @@
1
+ ---
2
+ description: 러버덕 디버깅 — 답 대신 질문으로 스스로 찾게 돕기
3
+ ---
4
+ 당신은 '러버덕(고무오리)' 디버깅 파트너입니다. 사용자의 고민: $ARGUMENTS
5
+ 바로 정답을 주지 말고, 문제를 좁히는 좋은 질문 3~5개를 한국어로 던져줘.
6
+ 필요하면 read_file 로 관련 파일을 살펴봐도 되지만, 절대 수정하지 마.
7
+ 마지막에 "이 중 어디부터 확인해볼까요?" 라고 물어줘.
@@ -0,0 +1,5 @@
1
+ ---
2
+ description: 지정한 파일을 읽고 한국어 3줄로 요약
3
+ ---
4
+ $ARGUMENTS 파일을 read_file 도구로 읽은 다음, 핵심 내용을 한국어 3줄로 요약해줘.
5
+ 파일을 수정하지는 말고, 요약만 보여줘.
package/skills/todo.md ADDED
@@ -0,0 +1,6 @@
1
+ ---
2
+ description: 작업 폴더에서 TODO/FIXME 주석을 모아 목록화
3
+ ---
4
+ 작업 폴더의 파일들을 list_dir / read_file 로 훑어 TODO, FIXME, XXX, HACK 같은
5
+ 미완성 표시가 있는 곳을 찾아 '파일 — 해당 줄 내용' 형태의 한국어 목록으로 정리해줘.
6
+ 하나도 없으면 없다고 분명히 말해줘. 파일은 수정하지 마.
package/skills/tour.md ADDED
@@ -0,0 +1,6 @@
1
+ ---
2
+ description: 작업 폴더를 둘러보고 이 프로젝트가 무엇인지 브리핑
3
+ ---
4
+ 작업 폴더를 list_dir 로 살펴보고, 핵심으로 보이는 파일 2~4개를 read_file 로 읽어
5
+ 이 프로젝트가 무엇이고 어떤 구조인지 한국어로 친절하게 브리핑해줘.
6
+ $ARGUMENTS 가 있으면 그 주제에 초점을 맞춰. 파일은 절대 수정하지 마.
@@ -0,0 +1,105 @@
1
+ // AUTO-GENERATED by scripts/gen-builtins.mjs — 직접 수정하지 마세요.
2
+ // 스킬/플러그인/버전을 바꾼 뒤 `node scripts/gen-builtins.mjs` 로 재생성하세요.
3
+ import p0 from "../plugins/hwpx_read.mjs";
4
+
5
+ export const VERSION = "0.11.0";
6
+
7
+ export const BUILTIN_PLUGINS = [p0];
8
+
9
+ export const BUILTIN_SKILLS = [
10
+ {
11
+ "name": "eli5",
12
+ "description": "지정한 파일/개념을 5살도 알게 아주 쉽게 설명",
13
+ "body": "$ARGUMENTS 을(를) 다섯 살 아이도 이해할 수 있게 아주 쉬운 비유로 설명해줘.\n파일 이름이면 read_file 로 읽고 설명해. 전문용어는 풀어서 쓰고, 그림 그리듯 비유를 들어줘.\n파일은 수정하지 마."
14
+ },
15
+ {
16
+ "name": "explain",
17
+ "description": "파일/코드를 읽고 비개발자도 알게 쉽게 설명",
18
+ "body": "$ARGUMENTS 을(를) read_file 도구로 읽고, 무엇을 하는 코드/문서인지\n비개발자도 이해할 수 있게 한국어로 차근차근 설명해줘. 실제 수정은 하지 마."
19
+ },
20
+ {
21
+ "name": "gongmun",
22
+ "description": "공문서(기안문) 초안 작성 — 제목/수신/본문/붙임 [공공]",
23
+ "body": "\"$ARGUMENTS\" 내용을 바탕으로 대한민국 행정 공문서(기안문) 초안을 작성해줘.\n형식: 제목 / 수신 / (경유) / 본문(개조식, 'ㅇ' 글머리) / 붙임.\n정중한 공공 문체로. 사실이 불확실한 부분은 [ ] 로 비워 '채울 항목'임을 표시해줘."
24
+ },
25
+ {
26
+ "name": "haiku",
27
+ "description": "파일이나 주제로 하이쿠(짧은 3줄 시) 짓기 — 재미용",
28
+ "body": "$ARGUMENTS (파일명이면 read_file 로 그 내용을 읽어)를 주제로,\n5-7-5 운율을 살린 한국어 하이쿠를 한 편 지어줘. 짧고 운치 있게.\n파일은 절대 건드리지 말고 시만 보여줘."
29
+ },
30
+ {
31
+ "name": "hwpx",
32
+ "description": "한컴 HWPX(.hwpx) 문서를 읽어 핵심을 개조식으로 요약 [공공]",
33
+ "body": "$ARGUMENTS 파일을 hwpx_read 도구로 읽어 본문 텍스트를 추출한 뒤,\n핵심 내용을 한국어 개조식('ㅇ' 글머리)으로 요약해줘. 파일은 수정하지 마.\n(.hwp 구버전이면 .hwpx 로 저장 후 다시 시도하라고 안내해줘.)"
34
+ },
35
+ {
36
+ "name": "loop",
37
+ "description": "이 요청을 '에이전트 루프' 관점에서 어떻게 처리할지 메타 해설 (CDSA 특별)",
38
+ "body": "다음 요청을 실제로 수행하기 전에, CDSA Harness 의 에이전트 루프\n(컨텍스트 구성 → LLM 호출 → 도구 판단 → 실행 → 결과 되먹임) 관점에서\n어떤 도구를 어떤 순서로 부를 계획인지 단계별로 한국어로 먼저 설명해줘: $ARGUMENTS\n설명만 하고, 이번에는 실제 도구 호출(특히 write_file)은 하지 마."
39
+ },
40
+ {
41
+ "name": "minutes",
42
+ "description": "회의 내용을 표준 회의록으로 정리(안건/논의/결정/조치) [공공]",
43
+ "body": "$ARGUMENTS (파일명이면 read_file 로 읽어)를 회의록으로 정리해줘.\n머리말: 일시[ ] / 장소[ ] / 참석[ ].\n본문: 안건별로 [논의내용] · [결정사항] · [조치사항(담당/기한)] 을 개조식으로.\n한국어로 깔끔하게. 파일은 수정하지 마."
44
+ },
45
+ {
46
+ "name": "minwon",
47
+ "description": "민원 내용을 분류(분야·담당부서·긴급도)하고 처리 방향 제안 [공공]",
48
+ "body": "다음 민원을 분석해줘($ARGUMENTS 가 파일명이면 read_file 로 읽어).\n표 형태로 한국어로 깔끔하게 정리해줘:\n1) 분야 분류 2) 담당 부서(추정) 3) 긴급도(상/중/하) 4) 핵심 요지 1줄 5) 처리 방향 제안\n개인정보(연락처·주민번호 등)가 보이면 마스킹해서 표시해줘. 파일은 수정하지 마."
49
+ },
50
+ {
51
+ "name": "plan",
52
+ "description": "시킨 작업을 '실행하지 말고' 단계별 계획만 세우기",
53
+ "body": "다음 작업을 실제로 수행하지 말고, 어떻게 처리할지 단계별 계획만 세워줘: $ARGUMENTS\n필요하면 read_file / list_dir 로 사실만 확인하되, write_file 은 절대 호출하지 마.\n각 단계마다 '왜 그 단계가 필요한지'도 한 줄씩 덧붙여줘."
54
+ },
55
+ {
56
+ "name": "policyqa",
57
+ "description": "정책·제도 질문에 근거를 들어 설명(모르면 모른다고) [공공]",
58
+ "body": "다음 질문에 공공 정책·행정 관점에서 한국어로 답해줘: $ARGUMENTS\n필요하면 작업 폴더의 관련 문서를 read_file 로 확인해 근거로 삼아.\n확실하지 않은 부분은 추측하지 말고 '확인 필요'로 표시하고, 어디서/누구에게 확인하면 되는지 안내해줘."
59
+ },
60
+ {
61
+ "name": "press",
62
+ "description": "보도자료 초안 작성(제목/부제/리드/본문/문의처) [공공]",
63
+ "body": "\"$ARGUMENTS\" 를 주제로 공공기관 보도자료 초안을 작성해줘.\n구성: 제목 / 부제 / 리드(핵심 1문단) / 본문(육하원칙) / 기대효과 / 문의처[ ].\n객관적이고 간결한 보도 문체로 한국어로 작성해줘."
64
+ },
65
+ {
66
+ "name": "privacy",
67
+ "description": "개인정보(주민번호·연락처·이메일·계좌 등) 점검 및 마스킹 제안 [공공]",
68
+ "body": "$ARGUMENTS (파일명이면 read_file 로 읽어)에서 개인정보로 보이는 항목\n(주민등록번호, 휴대전화, 이메일, 주소, 계좌·카드번호, 여권번호 등)을 찾아\n'유형 — 발견 위치 — 마스킹 예시(예: 010-****-1234)' 표로 정리해줘.\n하나도 없으면 없다고 명확히 말해줘. 파일은 절대 수정하지 말고 점검 보고만 해줘."
69
+ },
70
+ {
71
+ "name": "quiz",
72
+ "description": "작업 폴더 코드/문서로 학습 퀴즈 만들기",
73
+ "body": "작업 폴더를 list_dir / read_file 로 살펴보고, 이 코드·문서를 공부할 수 있는\n객관식 퀴즈 3문제를 한국어로 만들어줘($ARGUMENTS 주제가 있으면 그쪽으로).\n각 문제에 보기 4개와 정답, 그리고 한 줄 해설을 달아줘. 파일은 수정하지 마."
74
+ },
75
+ {
76
+ "name": "report",
77
+ "description": "개조식 1쪽 보고서로 요약(추진배경/현황/문제점/개선방안) [공공]",
78
+ "body": "$ARGUMENTS (파일명이면 read_file 로 읽어)를 대한민국 공공 보고서 양식의 개조식으로\n1쪽 분량으로 요약해줘. 항목: □ 추진배경 / □ 현황 / □ 문제점 / □ 개선방안 / □ 기대효과.\n각 항목은 'ㅇ' 글머리로 간결하게. 파일은 수정하지 마."
79
+ },
80
+ {
81
+ "name": "review",
82
+ "description": "파일을 읽고 버그 위험·개선점을 리뷰",
83
+ "body": "$ARGUMENTS 파일을 read_file 도구로 읽고, 버그 위험·가독성·개선점을 한국어로 짚어줘.\n실제 수정은 하지 말고, 무엇을 어떻게 고치면 좋을지 제안만 해줘."
84
+ },
85
+ {
86
+ "name": "rubberduck",
87
+ "description": "러버덕 디버깅 — 답 대신 질문으로 스스로 찾게 돕기",
88
+ "body": "당신은 '러버덕(고무오리)' 디버깅 파트너입니다. 사용자의 고민: $ARGUMENTS\n바로 정답을 주지 말고, 문제를 좁히는 좋은 질문 3~5개를 한국어로 던져줘.\n필요하면 read_file 로 관련 파일을 살펴봐도 되지만, 절대 수정하지 마.\n마지막에 \"이 중 어디부터 확인해볼까요?\" 라고 물어줘."
89
+ },
90
+ {
91
+ "name": "summarize",
92
+ "description": "지정한 파일을 읽고 한국어 3줄로 요약",
93
+ "body": "$ARGUMENTS 파일을 read_file 도구로 읽은 다음, 핵심 내용을 한국어 3줄로 요약해줘.\n파일을 수정하지는 말고, 요약만 보여줘."
94
+ },
95
+ {
96
+ "name": "todo",
97
+ "description": "작업 폴더에서 TODO/FIXME 주석을 모아 목록화",
98
+ "body": "작업 폴더의 파일들을 list_dir / read_file 로 훑어 TODO, FIXME, XXX, HACK 같은\n미완성 표시가 있는 곳을 찾아 '파일 — 해당 줄 내용' 형태의 한국어 목록으로 정리해줘.\n하나도 없으면 없다고 분명히 말해줘. 파일은 수정하지 마."
99
+ },
100
+ {
101
+ "name": "tour",
102
+ "description": "작업 폴더를 둘러보고 이 프로젝트가 무엇인지 브리핑",
103
+ "body": "작업 폴더를 list_dir 로 살펴보고, 핵심으로 보이는 파일 2~4개를 read_file 로 읽어\n이 프로젝트가 무엇이고 어떤 구조인지 한국어로 친절하게 브리핑해줘.\n$ARGUMENTS 가 있으면 그 주제에 초점을 맞춰. 파일은 절대 수정하지 마."
104
+ }
105
+ ];
package/src/cli.js CHANGED
@@ -2,9 +2,11 @@
2
2
  // 핵심 차별점: '교육 모드' — 실제 API 를 붙여도 에이전트 내부에서 벌어지는 일
3
3
  // (컨텍스트 구성 → API 요청 → 모델 판단 → 토큰/지연 → 도구 실행 → 결과 되먹임)을 단계별로 드러낸다.
4
4
  import readline from "node:readline/promises";
5
+ import path from "node:path";
5
6
  import { stdin, stdout } from "node:process";
6
7
 
7
8
  import { renderBanner } from "./banner.js";
9
+ import { VERSION } from "./builtins.js";
8
10
  import {
9
11
  ENV_KEYS,
10
12
  PROVIDERS,
@@ -24,7 +26,7 @@ import { loadSkills, renderSkill } from "./skills.js";
24
26
  import { Toolbox } from "./tools.js";
25
27
  import { c, panel, renderDiff } from "./ui.js";
26
28
 
27
- const VERSION = "0.2.0";
29
+ // VERSION src/builtins.js(생성물)에서 가져온다 — npm/exe 양쪽에서 동일.
28
30
 
29
31
  const STEP_STYLE = {
30
32
  [Step.USER_INPUT]: ["🧑", "cyan"],
@@ -46,15 +48,24 @@ function clip(s, n) {
46
48
  }
47
49
 
48
50
  // cfg.teach_mode 를 실행 중 토글할 수 있으므로 closure 로 cfg 를 잡아둔다.
49
- function makePrinter(cfg) {
51
+ // stream.active 는 onToken 과 공유하는 스트리밍 상태.
52
+ function makePrinter(cfg, stream) {
50
53
  return (ev) => {
51
- if (cfg.teach_mode) return printTeach(ev);
52
- return printCompact(ev);
54
+ if (cfg.teach_mode) return printTeach(ev, stream);
55
+ return printCompact(ev, stream);
53
56
  };
54
57
  }
55
58
 
59
+ function replyMetaLine(d) {
60
+ const meta = [];
61
+ if (d.latencyMs != null) meta.push(`응답 ${d.latencyMs}ms`);
62
+ if (d.usage) meta.push(`토큰 입력 ${d.usage.input ?? "?"}/출력 ${d.usage.output ?? "?"}/합계 ${d.usage.total ?? "?"}`);
63
+ if (d.request?.bodyBytes) meta.push(`요청 ${d.request.bodyBytes}B`);
64
+ return meta.length ? meta.join(" · ") : "";
65
+ }
66
+
56
67
  // ---- 교육(teach) 렌더: 내부 과정을 패널로 펼쳐 보여준다 ----
57
- function printTeach(ev) {
68
+ function printTeach(ev, stream) {
58
69
  const d = ev.data || {};
59
70
  switch (ev.step) {
60
71
  case Step.USER_INPUT:
@@ -85,16 +96,26 @@ function printTeach(ev) {
85
96
  }
86
97
 
87
98
  case Step.MODEL_REPLY: {
99
+ // 스트리밍으로 이미 본문이 출력된 경우: 줄바꿈 후 메타/도구호출만 덧붙인다.
100
+ if (d.streamed) {
101
+ if (stream && stream.active) {
102
+ process.stdout.write("\n");
103
+ stream.active = false;
104
+ }
105
+ for (const tc of d.toolCalls || []) {
106
+ console.log(c.yellow(` ↳ 도구 호출 요청: ${c.bold(tc.name)}(${clip(JSON.stringify(tc.args), 200)})`));
107
+ }
108
+ const meta = replyMetaLine(d);
109
+ if (meta) console.log(c.grey(" ─ " + meta));
110
+ return;
111
+ }
88
112
  const lines = [];
89
113
  if (ev.detail && ev.detail !== "(텍스트 없음)") lines.push(...clip(ev.detail, 1200).split("\n"));
90
114
  for (const tc of d.toolCalls || []) {
91
115
  lines.push(c.yellow(`↳ 도구 호출 요청: ${c.bold(tc.name)}(${clip(JSON.stringify(tc.args), 200)})`));
92
116
  }
93
- const meta = [];
94
- if (d.latencyMs != null) meta.push(`응답 ${d.latencyMs}ms`);
95
- if (d.usage) meta.push(`토큰 입력 ${d.usage.input ?? "?"}/출력 ${d.usage.output ?? "?"}/합계 ${d.usage.total ?? "?"}`);
96
- if (d.request?.bodyBytes) meta.push(`요청 ${d.request.bodyBytes}B`);
97
- if (meta.length) lines.push(c.grey("─ " + meta.join(" · ")));
117
+ const meta = replyMetaLine(d);
118
+ if (meta) lines.push(c.grey("─ " + meta));
98
119
  else lines.push(c.dim("(mock: 토큰/지연 측정 없음)"));
99
120
  console.log(panel(lines.length ? lines : ["(빈 응답)"], { title: "🤖 ③ 모델 응답 (원본 판단)", color: "green" }));
100
121
  return;
@@ -132,11 +153,17 @@ function printTeach(ev) {
132
153
  }
133
154
 
134
155
  // ---- 간결(compact) 렌더: 한 줄 위주 ----
135
- function printCompact(ev) {
156
+ function printCompact(ev, stream) {
136
157
  const [icon, color] = STEP_STYLE[ev.step] || ["•", "cyan"];
137
158
  const paint = c[color] || ((x) => x);
138
159
  if (ev.step === Step.APPROVAL && !ev.title.includes("자동 승인")) return;
139
160
  if (ev.step === Step.MODEL_REPLY) {
161
+ const d = ev.data || {};
162
+ if (d.streamed) {
163
+ if (stream && stream.active) { process.stdout.write("\n"); stream.active = false; }
164
+ for (const tc of d.toolCalls || []) console.log(c.yellow(` ↳ ${tc.name}(${clip(JSON.stringify(tc.args), 120)})`));
165
+ return;
166
+ }
140
167
  if (ev.detail && ev.detail !== "(텍스트 없음)") console.log(panel(ev.detail.split("\n"), { title: "🤖 모델", color: "green" }));
141
168
  return;
142
169
  }
@@ -175,20 +202,23 @@ function makeClient(cfg) {
175
202
  model: cfg.model,
176
203
  temperature: cfg.temperature,
177
204
  maxTokens: cfg.max_tokens,
205
+ baseUrl: cfg.base_url,
178
206
  });
179
207
  }
180
208
 
181
209
  function printIntro(cfg) {
182
210
  console.log(renderBanner());
183
- console.log(c.dim("AI 에이전트의 내부 동작을 단계별로 드러내는 교육용 하네스"));
211
+ console.log(c.dim("AI 에이전트의 내부 동작을 단계별로 드러내는 교육용 하네스") + " " + c.cyan("· made by CDSA"));
184
212
  console.log();
185
213
  const keySource = cfg.provider === "mock" ? "-" : cfg.api_key ? "config.json" : ENV_KEYS[cfg.provider] && process.env[ENV_KEYS[cfg.provider]] ? `환경변수 ${ENV_KEYS[cfg.provider]}` : c.red("없음");
186
214
  const rows = [
187
215
  ["버전", `v${VERSION}`],
188
216
  ["provider", cfg.provider],
189
217
  ["model", cfg.model],
218
+ ...(cfg.base_url ? [["엔드포인트", cfg.base_url]] : []),
190
219
  ["API 키", keySource],
191
220
  ["교육 모드", cfg.teach_mode ? c.green("ON (과정 펼쳐보기)") : "OFF"],
221
+ ["스트리밍", cfg.stream ? c.green("ON (실시간)") : "OFF"],
192
222
  ["작업 폴더", cfg.workspacePath()],
193
223
  ["승인 모드", cfg.approval_mode],
194
224
  ["셸 실행", cfg.allow_shell ? "허용" : "차단"],
@@ -209,10 +239,12 @@ function printHelp() {
209
239
  `시키고 싶은 일을 한국어로 입력하세요. 예) ${c.cyan("notes.txt 맨 아래에 할 일 3개 추가해줘")}`,
210
240
  "",
211
241
  c.bold("슬래시 명령"),
242
+ ` ${c.cyan("/about")} 이 도구 정보(made by CDSA)`,
212
243
  ` ${c.cyan("/setup")} 제공자·API 키·모델 연결(대화형)`,
213
244
  ` ${c.cyan("/provider")} <openai|anthropic|openrouter|mock> 제공자 변경`,
214
245
  ` ${c.cyan("/model")} <이름> 모델 변경`,
215
246
  ` ${c.cyan("/teach")} 교육 모드 켜기/끄기(내부 과정 펼쳐보기)`,
247
+ ` ${c.cyan("/stream")} 실시간 스트리밍 출력 켜기/끄기`,
216
248
  ` ${c.cyan("/context")} 지금 모델에 보내는 컨텍스트 들여다보기`,
217
249
  ` ${c.cyan("/skills")} 스킬 목록(.cdsa/skills 의 /명령들)`,
218
250
  ` ${c.cyan("/plugins")} 플러그인 목록(파일·npm 추가 도구)`,
@@ -304,6 +336,7 @@ function parseArgs(argv) {
304
336
  else if (a === "--help" || a === "-h") out.help = true;
305
337
  else if (a === "--setup") out.setup = true;
306
338
  else if (a === "--no-teach") out.noTeach = true;
339
+ else if (a === "--no-stream") out.noStream = true;
307
340
  else if (a === "--provider") out.provider = argv[++i];
308
341
  else if (a === "--model") out.model = argv[++i];
309
342
  else if (a === "--workspace") out.workspace = argv[++i];
@@ -324,6 +357,7 @@ export async function main(argv = []) {
324
357
  " --workspace <폴더경로>\n" +
325
358
  " --setup 대화형 연결 설정 실행\n" +
326
359
  " --no-teach 교육 모드 끄고 간결하게\n" +
360
+ " --no-stream 실시간 스트리밍 끄기\n" +
327
361
  " --auto 승인 자동(approval_mode=auto)\n" +
328
362
  " -h, --help 도움말\n\n" +
329
363
  "API 키는 환경변수로도 인식됩니다: OPENAI_API_KEY / ANTHROPIC_API_KEY / OPENROUTER_API_KEY\n"
@@ -355,6 +389,7 @@ export async function main(argv = []) {
355
389
  if (args.workspace) cfg.workspace = args.workspace;
356
390
  if (args.auto) cfg.approval_mode = "auto";
357
391
  if (args.noTeach) cfg.teach_mode = false;
392
+ if (args.noStream) cfg.stream = false;
358
393
 
359
394
  const rl = readline.createInterface({ input: stdin, output: stdout });
360
395
  let session = null;
@@ -406,7 +441,10 @@ export async function main(argv = []) {
406
441
  ];
407
442
  const skills = {};
408
443
  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())); // 로컬 파일 스킬이 우선
444
+ Object.assign(
445
+ skills,
446
+ loadSkills(cfg.workspacePath(), { importForeign: cfg.import_foreign_skills, extraDirs: cfg.skill_dirs || [] })
447
+ ); // 로컬 파일 스킬이 우선
410
448
  const toolbox = new Toolbox(cfg.workspacePath(), cfg.allow_shell, plugins);
411
449
  if (toolbox.plugins.length || Object.keys(skills).length || toolbox.pluginErrors.length || mcp.servers.length) {
412
450
  const bits = [];
@@ -418,13 +456,23 @@ export async function main(argv = []) {
418
456
  }
419
457
 
420
458
  session = SessionLog.create();
459
+ // 스트리밍: 토큰이 도착하는 대로 실시간 출력(첫 토큰에 헤더 1회).
460
+ const stream = { active: false };
461
+ const onToken = (chunk) => {
462
+ if (!stream.active) {
463
+ process.stdout.write("\n" + c.green("🤖 ③ 모델 응답 (스트리밍)") + "\n");
464
+ stream.active = true;
465
+ }
466
+ process.stdout.write(c.green(chunk));
467
+ };
421
468
  const loop = new AgentLoop({
422
469
  config: cfg,
423
470
  client: makeClient(cfg),
424
471
  toolbox,
425
- onEvent: makePrinter(cfg),
472
+ onEvent: makePrinter(cfg, stream),
426
473
  approvalCallback: makeApproval(ask),
427
474
  session,
475
+ onToken,
428
476
  });
429
477
  loop.reset();
430
478
 
@@ -439,6 +487,18 @@ export async function main(argv = []) {
439
487
 
440
488
  if (["/quit", "/exit", "quit", "exit", ":q"].includes(low)) break;
441
489
  if (low === "/help") { printHelp(); continue; }
490
+ if (low === "/about") {
491
+ console.log(panel([
492
+ c.bold("CDSA Harness") + c.grey(` v${VERSION}`),
493
+ c.dim("AI 에이전트가 내부에서 무슨 일을 하는지 단계별로 드러내는 공개 교육용 하네스."),
494
+ "",
495
+ `${c.grey("made by")} ${c.cyan(c.bold("CDSA"))}`,
496
+ `${c.grey("npm")} npm i -g cdsa-harness`,
497
+ `${c.grey("repo")} github.com/cdsassj00/miniharness`,
498
+ `${c.grey("license")} MIT · 의존성 0개(Node 18+)`,
499
+ ], { title: "ℹ️ about", color: "cyan" }));
500
+ continue;
501
+ }
442
502
  if (low === "/reset") { loop.reset(); console.log(c.green("컨텍스트를 초기화했습니다.")); continue; }
443
503
  if (low === "/config") { printIntro(cfg); console.log(c.dim(`config.json: ${configPath()}`)); continue; }
444
504
  if (low === "/sessions") { console.log(c.dim(`세션 로그: ${sessionsDir()}`)); continue; }
@@ -447,6 +507,11 @@ export async function main(argv = []) {
447
507
  console.log(c.green(`교육 모드 ${cfg.teach_mode ? "ON" : "OFF"}.`));
448
508
  continue;
449
509
  }
510
+ if (low === "/stream") {
511
+ cfg.stream = !cfg.stream;
512
+ console.log(c.green(`스트리밍 ${cfg.stream ? "ON (실시간 출력)" : "OFF"}.`));
513
+ continue;
514
+ }
450
515
  if (low === "/setup" || low === "/login") {
451
516
  await runSetup(ask, cfg);
452
517
  loop.client = makeClient(cfg);
@@ -503,9 +568,23 @@ export async function main(argv = []) {
503
568
  continue;
504
569
  }
505
570
  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 치환."));
571
+ const names = Object.keys(skills).sort();
572
+ const srcTag = (s) => {
573
+ const src = s.source || "";
574
+ if (src === "(내장)") return c.dim("⭐ 내장");
575
+ if (src === "(npm)") return c.dim("📦 npm");
576
+ if (src.includes(`${path.sep}.claude${path.sep}`)) return c.dim("🟣 claude");
577
+ if (src.includes(`${path.sep}.opencode${path.sep}`)) return c.dim("🟠 opencode");
578
+ if (src.includes(`${path.sep}node-cli${path.sep}skills`) || src.includes(`cdsa-harness${path.sep}skills`)) return c.dim("⭐ 내장");
579
+ if (src.includes(`.cdsa_harness${path.sep}skills`)) return c.dim("🏠 전역");
580
+ if (src.includes(`.cdsa${path.sep}skills`)) return c.dim("📂 프로젝트");
581
+ return c.dim("📄 파일");
582
+ };
583
+ const lines = names.length
584
+ ? names.map((n) => `${c.cyan("/" + n)} ${c.grey(skills[n].description || "")} ${srcTag(skills[n])}`)
585
+ : [c.dim("등록된 스킬이 없습니다.")];
586
+ lines.push(c.dim("추가: <작업폴더>/.cdsa/skills/*.md · ~/.cdsa_harness/skills/ · config.json 의 skill_dirs"));
587
+ lines.push(c.dim("외부(.claude/commands 등)는 import_foreign_skills 로 끌 수 있음"));
509
588
  console.log(panel(lines, { title: "🎯 스킬 (프롬프트 템플릿, /이름 으로 실행)", color: "cyan" }));
510
589
  continue;
511
590
  }
package/src/config.js CHANGED
@@ -30,6 +30,7 @@ export const APPROVAL_MODES = ["manual", "auto"];
30
30
  const DEFAULTS = {
31
31
  provider: "mock",
32
32
  api_key: "",
33
+ base_url: "", // OpenAI 호환 사내/폐쇄망 LLM 의 전체 엔드포인트 직접 지정(있으면 우선)
33
34
  model: "mock-agent",
34
35
  workspace: "./workspace",
35
36
  approval_mode: "manual",
@@ -38,6 +39,9 @@ const DEFAULTS = {
38
39
  temperature: 0.2,
39
40
  max_tokens: 1024,
40
41
  teach_mode: true,
42
+ stream: true, // 모델 응답을 실시간(토큰 단위)으로 출력
43
+ import_foreign_skills: true, // .claude/commands 등 외부 포맷 스킬도 읽기(프로젝트+전역)
44
+ skill_dirs: [], // 스킬을 추가로 읽어올 폴더(절대/상대 경로)
41
45
  plugins: [], // 추가로 불러올 npm 플러그인 패키지 이름(이름 규칙과 무관하게 강제 로드)
42
46
  mcpServers: {}, // MCP 서버 설정 (Claude Code/Cursor 와 동일한 형식)
43
47
  };
package/src/llm.js CHANGED
@@ -14,22 +14,57 @@ const ENDPOINTS = {
14
14
  };
15
15
 
16
16
  export class LLMClient {
17
- constructor({ provider, apiKey, model, temperature = 0.2, maxTokens = 1024, timeout = 60000 }) {
17
+ constructor({ provider, apiKey, model, temperature = 0.2, maxTokens = 1024, timeout = 60000, baseUrl = "" }) {
18
18
  this.provider = provider;
19
19
  this.apiKey = apiKey;
20
20
  this.model = model;
21
21
  this.temperature = temperature;
22
22
  this.maxTokens = maxTokens;
23
23
  this.timeout = timeout;
24
+ this.baseUrl = (baseUrl || "").trim(); // 있으면 ENDPOINTS 대신 이 엔드포인트 사용(폐쇄망 사내 LLM)
24
25
  }
25
26
 
26
- async chat(messages, tools) {
27
- if (this.provider === "mock") return mockChat(messages);
28
- if (this.provider === "anthropic") return this._anthropicChat(messages, tools);
29
- if (ENDPOINTS[this.provider]) return this._openaiChat(messages, tools);
27
+ _endpoint(provider) {
28
+ return this.baseUrl || ENDPOINTS[provider] || ENDPOINTS.anthropic;
29
+ }
30
+
31
+ // onToken(chunk) 를 주면 텍스트가 도착하는 대로 콜백한다(스트리밍).
32
+ async chat(messages, tools, onToken = null) {
33
+ if (this.provider === "mock") {
34
+ const r = mockChat(messages);
35
+ if (onToken && r.content) await streamText(r.content, onToken);
36
+ return r;
37
+ }
38
+ if (this.provider === "anthropic") {
39
+ return onToken ? this._anthropicStream(messages, tools, onToken) : this._anthropicChat(messages, tools);
40
+ }
41
+ if (ENDPOINTS[this.provider]) {
42
+ return onToken ? this._openaiStream(messages, tools, onToken) : this._openaiChat(messages, tools);
43
+ }
30
44
  throw new LLMError(`지원하지 않는 provider 입니다: ${this.provider}`);
31
45
  }
32
46
 
47
+ // 스트리밍용: 응답 객체(본문 스트림)를 그대로 받는다.
48
+ async _openStream(url, headers, body) {
49
+ const json = JSON.stringify(body);
50
+ const ctrl = new AbortController();
51
+ const timer = setTimeout(() => ctrl.abort(), this.timeout);
52
+ const started = Date.now();
53
+ let res;
54
+ try {
55
+ res = await fetch(url, { method: "POST", headers, body: json, signal: ctrl.signal });
56
+ } catch (e) {
57
+ clearTimeout(timer);
58
+ throw new LLMError(`네트워크 오류: ${e.message}`);
59
+ }
60
+ if (!res.ok) {
61
+ clearTimeout(timer);
62
+ const text = await res.text().catch(() => "");
63
+ throw new LLMError(httpErrorMessage(res.status, text || res.statusText));
64
+ }
65
+ return { res, started, timer, bodyBytes: Buffer.byteLength(json, "utf8") };
66
+ }
67
+
33
68
  async _post(url, headers, body) {
34
69
  const json = JSON.stringify(body);
35
70
  const ctrl = new AbortController();
@@ -46,23 +81,14 @@ export class LLMClient {
46
81
  const latencyMs = Date.now() - started;
47
82
  if (!res.ok) {
48
83
  const text = await res.text().catch(() => "");
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);
84
+ throw new LLMError(httpErrorMessage(res.status, text || res.statusText));
59
85
  }
60
86
  return { payload: await res.json(), latencyMs, bodyBytes: Buffer.byteLength(json, "utf8") };
61
87
  }
62
88
 
63
89
  // --- OpenAI / OpenRouter (chat/completions 형식) ---
64
90
  async _openaiChat(messages, tools) {
65
- const url = ENDPOINTS[this.provider];
91
+ const url = this._endpoint(this.provider);
66
92
  const body = { model: this.model, messages, temperature: this.temperature };
67
93
  if (tools && tools.length) {
68
94
  body.tools = tools;
@@ -87,7 +113,7 @@ export class LLMClient {
87
113
 
88
114
  // --- Anthropic (messages 형식) ---
89
115
  async _anthropicChat(messages, tools) {
90
- const url = ENDPOINTS.anthropic;
116
+ const url = this._endpoint("anthropic");
91
117
  const body = toAnthropicBody(messages, tools, this.model, this.temperature, this.maxTokens);
92
118
  const headers = {
93
119
  "x-api-key": this.apiKey,
@@ -103,6 +129,87 @@ export class LLMClient {
103
129
  };
104
130
  }
105
131
 
132
+ // --- OpenAI/OpenRouter 스트리밍 ---
133
+ async _openaiStream(messages, tools, onToken) {
134
+ const url = this._endpoint(this.provider);
135
+ const body = { model: this.model, messages, temperature: this.temperature, stream: true, stream_options: { include_usage: true } };
136
+ if (tools && tools.length) {
137
+ body.tools = tools;
138
+ body.tool_choice = "auto";
139
+ }
140
+ const headers = { Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json" };
141
+ if (this.provider === "openrouter") {
142
+ headers["HTTP-Referer"] = "https://github.com/cdsassj00/miniharness";
143
+ headers["X-Title"] = "CDSA Harness";
144
+ }
145
+ const { res, started, timer, bodyBytes } = await this._openStream(url, headers, body);
146
+ let content = "";
147
+ const tcMap = new Map(); // index -> {id,name,args}
148
+ let usage = null;
149
+ try {
150
+ await readSSE(res, (data) => {
151
+ if (data === "[DONE]") return;
152
+ let json;
153
+ try { json = JSON.parse(data); } catch { return; }
154
+ if (json.usage) usage = { input: json.usage.prompt_tokens ?? null, output: json.usage.completion_tokens ?? null, total: json.usage.total_tokens ?? null };
155
+ const delta = json.choices?.[0]?.delta;
156
+ if (!delta) return;
157
+ if (delta.content) { content += delta.content; onToken(delta.content); }
158
+ for (const tc of delta.tool_calls || []) {
159
+ const i = tc.index ?? 0;
160
+ const cur = tcMap.get(i) || { id: tc.id || `call_${i}`, name: "", args: "" };
161
+ if (tc.id) cur.id = tc.id;
162
+ if (tc.function?.name) cur.name += tc.function.name;
163
+ if (tc.function?.arguments) cur.args += tc.function.arguments;
164
+ tcMap.set(i, cur);
165
+ }
166
+ });
167
+ } finally {
168
+ clearTimeout(timer);
169
+ }
170
+ const toolCalls = [...tcMap.values()].map((t) => ({ id: t.id, name: t.name, args: safeParse(t.args) }));
171
+ return { content: content || null, toolCalls, usage, latencyMs: Date.now() - started, request: this._meta(url, tools, bodyBytes) };
172
+ }
173
+
174
+ // --- Anthropic 스트리밍 ---
175
+ async _anthropicStream(messages, tools, onToken) {
176
+ const url = this._endpoint("anthropic");
177
+ const body = toAnthropicBody(messages, tools, this.model, this.temperature, this.maxTokens);
178
+ body.stream = true;
179
+ const headers = { "x-api-key": this.apiKey, "anthropic-version": "2023-06-01", "Content-Type": "application/json" };
180
+ const { res, started, timer, bodyBytes } = await this._openStream(url, headers, body);
181
+ let content = "";
182
+ const blocks = new Map(); // index -> {type,name,id,json}
183
+ let usage = { input: null, output: null, total: 0 };
184
+ try {
185
+ await readSSE(res, (data) => {
186
+ let ev;
187
+ try { ev = JSON.parse(data); } catch { return; }
188
+ if (ev.type === "message_start" && ev.message?.usage) usage.input = ev.message.usage.input_tokens ?? null;
189
+ else if (ev.type === "content_block_start") {
190
+ const b = ev.content_block || {};
191
+ blocks.set(ev.index, { type: b.type, name: b.name, id: b.id, json: "" });
192
+ } else if (ev.type === "content_block_delta") {
193
+ const d = ev.delta || {};
194
+ if (d.type === "text_delta") { content += d.text; onToken(d.text); }
195
+ else if (d.type === "input_json_delta") {
196
+ const cur = blocks.get(ev.index);
197
+ if (cur) cur.json += d.partial_json || "";
198
+ }
199
+ } else if (ev.type === "message_delta" && ev.usage) {
200
+ usage.output = ev.usage.output_tokens ?? usage.output;
201
+ }
202
+ });
203
+ } finally {
204
+ clearTimeout(timer);
205
+ }
206
+ usage.total = (usage.input || 0) + (usage.output || 0);
207
+ const toolCalls = [...blocks.values()]
208
+ .filter((b) => b.type === "tool_use")
209
+ .map((b) => ({ id: b.id, name: b.name, args: safeParse(b.json) }));
210
+ return { content: content || null, toolCalls, usage, latencyMs: Date.now() - started, request: this._meta(url, tools, bodyBytes) };
211
+ }
212
+
106
213
  _meta(endpoint, tools, bodyBytes) {
107
214
  return {
108
215
  provider: this.provider,
@@ -120,6 +227,51 @@ function trim(s) {
120
227
  return s.length > 400 ? s.slice(0, 400) + " …" : s;
121
228
  }
122
229
 
230
+ function safeParse(jsonStr) {
231
+ try {
232
+ return JSON.parse(jsonStr || "{}");
233
+ } catch {
234
+ return { _raw: jsonStr };
235
+ }
236
+ }
237
+
238
+ function httpErrorMessage(status, text) {
239
+ let msg = `API 오류 ${status}: ${trim(text)}`;
240
+ if (status === 404) {
241
+ msg += "\n ↳ 모델 이름을 확인하세요. /model 로 변경 가능. " +
242
+ "OpenRouter 는 'provider/model' 형식이어야 합니다 (예: openai/gpt-4o-mini, anthropic/claude-3.7-sonnet).";
243
+ } else if (status === 401 || status === 403) {
244
+ msg += "\n ↳ API 키가 잘못되었거나 권한이 없어요. /setup 으로 다시 연결하세요.";
245
+ } else if (status === 429) {
246
+ msg += "\n ↳ 요청이 너무 많거나 크레딧이 부족할 수 있어요(잠시 후 재시도).";
247
+ }
248
+ return msg;
249
+ }
250
+
251
+ // SSE(data: ...) 본문 스트림을 줄 단위로 콜백. onEvent(dataString) 호출(‘[DONE]’ 포함).
252
+ async function readSSE(res, onEvent) {
253
+ let buf = "";
254
+ for await (const chunk of res.body) {
255
+ buf += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
256
+ let nl;
257
+ while ((nl = buf.indexOf("\n")) >= 0) {
258
+ const line = buf.slice(0, nl).trim();
259
+ buf = buf.slice(nl + 1);
260
+ if (line.startsWith("data:")) onEvent(line.slice(5).trim());
261
+ }
262
+ }
263
+ if (buf.trim().startsWith("data:")) onEvent(buf.trim().slice(5).trim());
264
+ }
265
+
266
+ // mock/공통: 텍스트를 토큰처럼 쪼개 콜백(TTY 면 살짝 지연해 흐르는 효과).
267
+ async function streamText(text, onToken) {
268
+ const parts = String(text).match(/\S+\s*|\s+/g) || [String(text)];
269
+ for (const p of parts) {
270
+ onToken(p);
271
+ if (process.stdout.isTTY) await new Promise((r) => setTimeout(r, 10));
272
+ }
273
+ }
274
+
123
275
  function parseOpenAiReply(payload) {
124
276
  const msg = payload?.choices?.[0]?.message;
125
277
  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/plugins.js CHANGED
@@ -15,6 +15,8 @@ import os from "node:os";
15
15
  import path from "node:path";
16
16
  import { fileURLToPath, pathToFileURL } from "node:url";
17
17
 
18
+ import { BUILTIN_PLUGINS } from "./builtins.js";
19
+
18
20
  export function pluginDirs(workspace) {
19
21
  return [
20
22
  path.join(os.homedir(), ".cdsa_harness", "plugins"),
@@ -23,7 +25,8 @@ export function pluginDirs(workspace) {
23
25
  }
24
26
 
25
27
  export async function loadPlugins(workspace) {
26
- const plugins = [];
28
+ // 패키지 내장 기본 플러그인(임베드) 먼저, 그다음 디스크의 사용자 플러그인.
29
+ const plugins = [...BUILTIN_PLUGINS.map((p) => ({ ...p, source: "(내장)" }))];
27
30
  for (const dir of pluginDirs(workspace)) {
28
31
  let files = [];
29
32
  try {
@@ -149,7 +152,12 @@ export async function scanNodeModules(nmDir) {
149
152
  // cwd 의 node_modules + cdsa-harness 자신의 node_modules(전역 설치 시 형제 패키지) 를 훑고,
150
153
  // config.plugins 에 적힌 패키지는 이름 규칙과 무관하게 강제로 불러온다.
151
154
  export async function discoverNpmExtensions(cwd, explicitNames = []) {
152
- const here = path.dirname(fileURLToPath(import.meta.url));
155
+ let here = cwd;
156
+ try {
157
+ here = path.dirname(fileURLToPath(import.meta.url));
158
+ } catch {
159
+ /* 번들/SEA 환경 등 import.meta.url 사용 불가 시 무시 */
160
+ }
153
161
  const nmDirs = [];
154
162
  const add = (d) => {
155
163
  const r = path.resolve(d);
package/src/skills.js CHANGED
@@ -11,22 +11,31 @@ 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 달린 마크다운이라 그대로 호환)
16
- export function skillDirs(workspace) {
14
+ import { BUILTIN_SKILLS } from "./builtins.js";
15
+
16
+ // 스킬 폴더 목록. 순서 = 우선순위(먼저 발견된 것이 이김).
17
+ // - importForeign: 다른 코딩 에이전트(Claude Code/OpenCode 등)의 커맨드 폴더도 읽기
18
+ // - extraDirs: 사용자가 config.skill_dirs 로 직접 지정한 폴더(상대경로는 cwd 기준)
19
+ export function skillDirs(workspace, importForeign = true, extraDirs = []) {
17
20
  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"),
21
+ const projectForeign = [
25
22
  path.join(workspace, ".claude", "commands"),
26
23
  path.join(workspace, ".claude", "skills"),
27
24
  path.join(workspace, ".opencode", "command"),
28
25
  path.join(workspace, ".github", "prompts"),
29
26
  ];
27
+ const globalForeign = [
28
+ path.join(home, ".claude", "commands"),
29
+ path.join(home, ".config", "opencode", "command"),
30
+ ];
31
+ const extras = extraDirs.map((d) => (path.isAbsolute(d) ? d : path.resolve(process.cwd(), d)));
32
+ return [
33
+ path.join(workspace, ".cdsa", "skills"), // 프로젝트(가장 우선)
34
+ ...extras, // 사용자가 명시한 폴더
35
+ ...(importForeign ? projectForeign : []), // 프로젝트의 외부 포맷
36
+ path.join(home, ".cdsa_harness", "skills"), // 우리 전역
37
+ ...(importForeign ? globalForeign : []), // 전역 외부 포맷(개인 커맨드 라이브러리)
38
+ ];
30
39
  }
31
40
 
32
41
  function parseFrontmatter(text) {
@@ -51,9 +60,9 @@ function addSkill(skills, name, file) {
51
60
  }
52
61
  }
53
62
 
54
- export function loadSkills(workspace) {
63
+ export function loadSkills(workspace, { importForeign = true, extraDirs = [] } = {}) {
55
64
  const skills = {};
56
- for (const dir of skillDirs(workspace)) {
65
+ for (const dir of skillDirs(workspace, importForeign, extraDirs)) {
57
66
  let entries = [];
58
67
  try {
59
68
  if (!fs.existsSync(dir)) continue;
@@ -78,6 +87,10 @@ export function loadSkills(workspace) {
78
87
  }
79
88
  }
80
89
  }
90
+ // 패키지 내장 기본 스킬(임베드) — 가장 낮은 우선순위(사용자 파일이 덮어씀)
91
+ for (const s of BUILTIN_SKILLS) {
92
+ if (!skills[s.name]) skills[s.name] = { name: s.name, description: s.description || "", body: s.body, source: "(내장)" };
93
+ }
81
94
  return skills;
82
95
  }
83
96
 
package/src/tools.js CHANGED
@@ -55,8 +55,11 @@ export class Toolbox {
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");
58
+ // 정상 로드된 플러그인만 도구로 사용(로드 에러는 따로 보관해 표시). 이름 중복은 먼저 것 우선.
59
+ const seen = new Set();
60
+ this.plugins = plugins.filter(
61
+ (p) => p && p.name && typeof p.handler === "function" && !seen.has(p.name) && seen.add(p.name)
62
+ );
60
63
  this.pluginErrors = plugins.filter((p) => p && p.error).map((p) => p.error);
61
64
  this._pluginMap = new Map(this.plugins.map((p) => [p.name, p]));
62
65
  }
package/src/ui.js CHANGED
@@ -54,14 +54,44 @@ function isWide(code) {
54
54
  );
55
55
  }
56
56
 
57
- // 둥근 박스 패널. lines: 문자열 배열(ANSI 포함 가능)
57
+ // 문자열을 표시폭 기준으로 줄바꿈(한글=2칸). ANSI 없는 평문 전용.
58
+ export function wrapToWidth(s, width) {
59
+ if (width < 4) width = 4;
60
+ const out = [];
61
+ for (const rawLine of String(s).split("\n")) {
62
+ let cur = "";
63
+ let w = 0;
64
+ for (const ch of rawLine) {
65
+ const cw = ch.codePointAt(0) > 0x1100 && isWide(ch.codePointAt(0)) ? 2 : 1;
66
+ if (w + cw > width) {
67
+ out.push(cur);
68
+ cur = "";
69
+ w = 0;
70
+ }
71
+ cur += ch;
72
+ w += cw;
73
+ }
74
+ out.push(cur);
75
+ }
76
+ return out;
77
+ }
78
+
79
+ // 둥근 박스 패널. lines: 문자열 배열(ANSI 포함 가능).
80
+ // 터미널 폭을 넘는 '평문' 줄은 자동 줄바꿈해 박스 밖으로 삐져나가지 않게 한다.
58
81
  export function panel(lines, { title = "", color = "cyan" } = {}) {
59
82
  const paint = c[color] || ((x) => x);
60
- const body = Array.isArray(lines) ? lines : String(lines).split("\n");
61
- const contentWidth = Math.max(
62
- visibleWidth(title) + 2,
63
- ...body.map((l) => visibleWidth(l)),
64
- 10
83
+ const raw = Array.isArray(lines) ? lines : String(lines).split("\n");
84
+ const maxInner = Math.max(20, (process.stdout.columns || 80) - 4);
85
+ // ANSI 가 없는 긴 줄만 줄바꿈(색칠된 줄은 그대로 둠)
86
+ const body = [];
87
+ for (const l of raw) {
88
+ if (!ANSI_RE.test(l) && visibleWidth(l) > maxInner) body.push(...wrapToWidth(l, maxInner));
89
+ else body.push(l);
90
+ ANSI_RE.lastIndex = 0;
91
+ }
92
+ const contentWidth = Math.min(
93
+ maxInner,
94
+ Math.max(visibleWidth(title) + 2, ...body.map((l) => visibleWidth(l)), 10)
65
95
  );
66
96
  const top = paint("╭─ ") + paint(c.bold(title)) + paint(" " + "─".repeat(Math.max(0, contentWidth - visibleWidth(title) - 2)) + "╮");
67
97
  const bottom = paint("╰" + "─".repeat(contentWidth + 2) + "╯");