cdsa-harness 0.18.0 → 0.20.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 +6 -3
- package/package.json +1 -1
- package/src/builtins.js +1 -1
- package/src/cli.js +98 -28
- package/src/completion.js +56 -0
- package/src/config.js +2 -2
- package/src/loop.js +2 -1
- package/src/tools.js +1 -1
package/README.md
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
- **`/resume`** — 지난 대화 자동 저장·이어가기, ↑↓ 명령 히스토리도 세션 간 유지
|
|
17
17
|
- **서브에이전트** — 모델이 큰 작업을 `spawn_agent` 로 하위 에이전트에 위임(화면엔 `┆` 로 구분, 중첩 위임 차단)
|
|
18
18
|
- **`/update`** — 명령 한 번으로 자기 자신을 최신 버전으로 업데이트(npm 설치본)
|
|
19
|
+
- **Tab 자동완성** — `/명령`(내장+스킬) · `/provider` 인자 · `@파일명` 멘션까지 Tab 으로 완성
|
|
20
|
+
- **모델 선택 UI** — `/models [검색어]` 로 제공자별 **실시간 모델 목록**에서 번호로 선택(OpenRouter·OpenAI·Anthropic·Ollama)
|
|
19
21
|
- **교육 모드** — 매 반복마다 모델에 보내는 메시지 구성·추정 토큰·시스템 프롬프트, 실제 토큰 사용량/응답시간까지 그대로 표시
|
|
20
22
|
- **MCP 클라이언트** — Claude Code·Cursor 등과 **공용 표준**. MCP 서버를 그대로 붙여 도구로 사용
|
|
21
23
|
- **플러그인** — npm 으로 설치하거나 `.cdsa/plugins/` 에 JS 파일 → **도구 자동 등록**
|
|
@@ -79,9 +81,10 @@ Claude Code·OpenCode 컨벤션을 따릅니다.
|
|
|
79
81
|
| `/undo` | 마지막 파일 변경 되돌리기 |
|
|
80
82
|
| `/init` · `/memory` | **모델이 폴더를 파악해 AGENT.md 생성** · 규칙 파일 보기 |
|
|
81
83
|
| `/auto` | 자동 수락 토글 (신뢰가 쌓이면 diff 승인 생략) |
|
|
82
|
-
| `/status` (=`/cost`)
|
|
84
|
+
| `/status` (=`/cost`) | 상태 · 세션 누적 토큰 |
|
|
85
|
+
| `/models [검색어]` | **실시간 모델 목록에서 번호로 선택**(4개 제공자 모두) |
|
|
83
86
|
| `/guide` · `/tutorial` | 빠른 시작 안내 · 단계별 인터랙티브 튜토리얼 |
|
|
84
|
-
| `/workspace <폴더>` | 작업 폴더 보기/변경
|
|
87
|
+
| `/workspace <폴더>` | 작업 폴더 보기/변경 — **기본은 현재 폴더(cwd)**, 터미널답게 |
|
|
85
88
|
| `/color` | 색상 켜기/끄기(흑백) |
|
|
86
89
|
| `/setup` | 제공자·API 키·모델 대화형 연결 |
|
|
87
90
|
| `/provider <이름>` | openai · anthropic · openrouter · mock 전환 |
|
|
@@ -228,7 +231,7 @@ cdsa-harness
|
|
|
228
231
|
"provider": "openai",
|
|
229
232
|
"api_key": "",
|
|
230
233
|
"model": "gpt-4o-mini",
|
|
231
|
-
"workspace": "
|
|
234
|
+
"workspace": ".",
|
|
232
235
|
"approval_mode": "manual",
|
|
233
236
|
"allow_shell": false,
|
|
234
237
|
"max_steps": 8,
|
package/package.json
CHANGED
package/src/builtins.js
CHANGED
package/src/cli.js
CHANGED
|
@@ -27,6 +27,7 @@ import { SessionLog, sessionsDir } from "./session.js";
|
|
|
27
27
|
import { loadSkills, renderSkill } from "./skills.js";
|
|
28
28
|
import { Toolbox } from "./tools.js";
|
|
29
29
|
import { c, panel, renderDiff, renderMarkdown, setColor } from "./ui.js";
|
|
30
|
+
import { makeCompleter } from "./completion.js";
|
|
30
31
|
|
|
31
32
|
// VERSION 은 src/builtins.js(생성물)에서 가져온다 — npm/exe 양쪽에서 동일.
|
|
32
33
|
|
|
@@ -268,6 +269,78 @@ async function buildExtensions(cfg, mcp) {
|
|
|
268
269
|
return { toolbox, skills };
|
|
269
270
|
}
|
|
270
271
|
|
|
272
|
+
async function fetchJson(url, headers = {}, timeoutMs = 4000) {
|
|
273
|
+
const ctrl = new AbortController();
|
|
274
|
+
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
275
|
+
try {
|
|
276
|
+
const res = await fetch(url, { headers, signal: ctrl.signal });
|
|
277
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
278
|
+
return await res.json();
|
|
279
|
+
} finally {
|
|
280
|
+
clearTimeout(t);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// 제공자별 '쓸 수 있는 모델' 목록. 가능하면 실시간(live), 실패 시 추천 목록으로 폴백.
|
|
285
|
+
async function fetchProviderModels(cfg, query = "") {
|
|
286
|
+
const q = query.trim().toLowerCase();
|
|
287
|
+
const key = cfg.resolvedKey();
|
|
288
|
+
try {
|
|
289
|
+
if (cfg.provider === "openrouter") {
|
|
290
|
+
const r = await fetchJson("https://openrouter.ai/api/v1/models");
|
|
291
|
+
let ids = (r.data || []).map((m) => m.id).sort();
|
|
292
|
+
if (q) ids = ids.filter((id) => id.toLowerCase().includes(q));
|
|
293
|
+
return { list: ids, live: true };
|
|
294
|
+
}
|
|
295
|
+
if (cfg.provider === "ollama") {
|
|
296
|
+
const host = (cfg.base_url || "http://localhost:11434/v1/chat/completions").replace(/\/v1\/.*$/, "");
|
|
297
|
+
const r = await fetchJson(`${host}/api/tags`, {}, 2500);
|
|
298
|
+
let ids = (r.models || []).map((m) => m.name);
|
|
299
|
+
if (q) ids = ids.filter((id) => id.toLowerCase().includes(q));
|
|
300
|
+
return { list: ids, live: true };
|
|
301
|
+
}
|
|
302
|
+
if (cfg.provider === "openai" && key) {
|
|
303
|
+
const r = await fetchJson("https://api.openai.com/v1/models", { Authorization: `Bearer ${key}` });
|
|
304
|
+
let ids = (r.data || []).map((m) => m.id).filter((id) => /^(gpt|o\d)/.test(id)).sort();
|
|
305
|
+
if (q) ids = ids.filter((id) => id.toLowerCase().includes(q));
|
|
306
|
+
return { list: ids, live: true };
|
|
307
|
+
}
|
|
308
|
+
if (cfg.provider === "anthropic" && key) {
|
|
309
|
+
const r = await fetchJson("https://api.anthropic.com/v1/models", { "x-api-key": key, "anthropic-version": "2023-06-01" });
|
|
310
|
+
let ids = (r.data || []).map((m) => m.id);
|
|
311
|
+
if (q) ids = ids.filter((id) => id.toLowerCase().includes(q));
|
|
312
|
+
return { list: ids, live: true };
|
|
313
|
+
}
|
|
314
|
+
} catch { /* 폴백으로 */ }
|
|
315
|
+
let list = SUGGESTED_MODELS[cfg.provider] || [];
|
|
316
|
+
if (q) list = list.filter((m) => m.toLowerCase().includes(q));
|
|
317
|
+
return { list, live: false };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// 번호로 고르는 모델 선택 UI. 선택하면 모델명, 취소하면 null 반환.
|
|
321
|
+
async function pickModel(ask, cfg, query = "") {
|
|
322
|
+
process.stdout.write(c.dim("모델 목록을 불러오는 중…\r"));
|
|
323
|
+
const { list, live } = await fetchProviderModels(cfg, query);
|
|
324
|
+
if (!list.length) {
|
|
325
|
+
console.log(c.yellow(`'${query}' 에 맞는 모델이 없습니다.`) + c.dim(" 예: /models claude · /models gpt"));
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
const shown = list.slice(0, 15);
|
|
329
|
+
const lines = shown.map((m, i) => {
|
|
330
|
+
const cur = m === cfg.model ? c.green(" ← 현재") : "";
|
|
331
|
+
return ` ${c.bold(String(i + 1).padStart(2))}) ${m}${cur}`;
|
|
332
|
+
});
|
|
333
|
+
if (list.length > shown.length) lines.push(c.dim(` … 외 ${list.length - shown.length}개 — '/models <검색어>' 로 좁히세요`));
|
|
334
|
+
lines.push(c.dim(live ? " (실시간 목록)" : " (추천 목록 — 키 연결 시 실시간 조회)"));
|
|
335
|
+
console.log(panel(lines, { title: `🤖 모델 선택 — ${cfg.provider}`, color: "cyan" }));
|
|
336
|
+
const a = await ask(c.cyan("번호 또는 모델명 입력 (엔터=취소): "));
|
|
337
|
+
if (a === null || !a.trim()) return null;
|
|
338
|
+
const s = a.trim();
|
|
339
|
+
const n = parseInt(s, 10);
|
|
340
|
+
if (Number.isInteger(n) && n >= 1 && n <= shown.length) return shown[n - 1];
|
|
341
|
+
return s; // 직접 입력한 이름 허용
|
|
342
|
+
}
|
|
343
|
+
|
|
271
344
|
function makeClient(cfg) {
|
|
272
345
|
return new LLMClient({
|
|
273
346
|
provider: cfg.provider,
|
|
@@ -504,13 +577,9 @@ async function runSetup(ask, cfg) {
|
|
|
504
577
|
}
|
|
505
578
|
const sugg = SUGGESTED_MODELS[provider] || [];
|
|
506
579
|
const def = sugg[0] || "";
|
|
507
|
-
const
|
|
508
|
-
|
|
509
|
-
if (
|
|
510
|
-
console.log(c.yellow("취소했습니다."));
|
|
511
|
-
return false;
|
|
512
|
-
}
|
|
513
|
-
cfg.model = m.trim() || def;
|
|
580
|
+
const picked = await pickModel(ask, cfg); // 실시간 목록에서 번호로 선택
|
|
581
|
+
cfg.model = picked || def;
|
|
582
|
+
if (!picked) console.log(c.dim(`기본 모델 사용: ${def}`));
|
|
514
583
|
}
|
|
515
584
|
|
|
516
585
|
const saved = saveConfig(cfg);
|
|
@@ -593,7 +662,15 @@ export async function main(argv = []) {
|
|
|
593
662
|
try {
|
|
594
663
|
savedHistory = fs.readFileSync(histPath, "utf8").split("\n").filter(Boolean).slice(0, 200);
|
|
595
664
|
} catch { /* 첫 실행 */ }
|
|
596
|
-
|
|
665
|
+
// Tab 자동완성: 슬래시 명령(내장+스킬) · /provider 인자 · @파일 멘션
|
|
666
|
+
let toolbox = null;
|
|
667
|
+
let skills = {};
|
|
668
|
+
const completer = makeCompleter({
|
|
669
|
+
skills: () => skills,
|
|
670
|
+
workspace: () => (toolbox ? toolbox.workspace : null),
|
|
671
|
+
providers: PROVIDERS,
|
|
672
|
+
});
|
|
673
|
+
const rl = readline.createInterface({ input: stdin, output: stdout, history: savedHistory, historySize: 200, completer });
|
|
597
674
|
const saveHistory = () => {
|
|
598
675
|
try {
|
|
599
676
|
fs.mkdirSync(configDir(), { recursive: true });
|
|
@@ -683,7 +760,7 @@ export async function main(argv = []) {
|
|
|
683
760
|
mcp = await connectMcpServers(cfg.mcpServers);
|
|
684
761
|
}
|
|
685
762
|
// 플러그인·스킬·도구상자를 작업 폴더 기준으로 구성(작업 폴더 변경 시 재사용)
|
|
686
|
-
|
|
763
|
+
({ toolbox, skills } = await buildExtensions(cfg, mcp));
|
|
687
764
|
if (toolbox.plugins.length || Object.keys(skills).length || toolbox.pluginErrors.length || mcp.servers.length) {
|
|
688
765
|
const bits = [];
|
|
689
766
|
if (toolbox.plugins.length) bits.push(c.green(`도구 +${toolbox.plugins.length}개`));
|
|
@@ -755,9 +832,8 @@ export async function main(argv = []) {
|
|
|
755
832
|
"AI 가 파일을 다룰 ‘작업 폴더’를 정하세요.",
|
|
756
833
|
c.dim("이 폴더 밖은 절대 건드리지 않아요(안전장치)."),
|
|
757
834
|
"",
|
|
758
|
-
` ${c.bold("엔터")}
|
|
759
|
-
` ${c.bold("
|
|
760
|
-
` ${c.bold("경로")} 예) ${c.cyan("./문서")} 또는 ${c.cyan("C:\\작업\\프로젝트")}`,
|
|
835
|
+
` ${c.bold("엔터")} ${c.cyan("지금 이 폴더")} 를 그대로 사용 (터미널답게, 기본)`,
|
|
836
|
+
` ${c.bold("경로")} 따로 격리하고 싶으면 입력 — 예) ${c.cyan("./sandbox")} 또는 ${c.cyan("C:\\작업\\프로젝트")}`,
|
|
761
837
|
],
|
|
762
838
|
{ title: "📁 작업 폴더 설정 (처음 한 번)", color: "cyan" }
|
|
763
839
|
));
|
|
@@ -919,23 +995,17 @@ export async function main(argv = []) {
|
|
|
919
995
|
);
|
|
920
996
|
continue;
|
|
921
997
|
}
|
|
922
|
-
// /models —
|
|
923
|
-
if (low === "/models") {
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
const models = ((await res.json()).models || []).map((m) => m.name);
|
|
932
|
-
console.log(panel(models.length ? models.map((m) => (m === cfg.model ? c.green("● " + m) : " " + m)) : [c.dim("설치된 모델 없음 — ollama pull <모델>")], { title: `🤖 Ollama 모델 (${host})`, color: "cyan" }));
|
|
933
|
-
} catch {
|
|
934
|
-
console.log(c.yellow(`Ollama(${host})에 연결하지 못했습니다 — ollama serve 확인.`));
|
|
935
|
-
}
|
|
998
|
+
// /models [검색어] — 모델 목록에서 번호로 선택(제공자별 실시간 조회)
|
|
999
|
+
if (low === "/models" || low.startsWith("/models ")) {
|
|
1000
|
+
const query = user.split(/\s+/).slice(1).join(" ");
|
|
1001
|
+
const picked = await pickModel(ask, cfg, query);
|
|
1002
|
+
if (picked) {
|
|
1003
|
+
cfg.model = picked;
|
|
1004
|
+
loop.client = makeClient(cfg);
|
|
1005
|
+
saveConfig(cfg);
|
|
1006
|
+
console.log(c.green(`모델 변경 → ${picked}`) + c.dim(" (저장됨)"));
|
|
936
1007
|
} else {
|
|
937
|
-
|
|
938
|
-
console.log(panel(sugg.map((m) => (m === cfg.model ? c.green("● " + m) : " " + m)), { title: `🤖 추천 모델 (${cfg.provider}) — 변경: /model <이름>`, color: "cyan" }));
|
|
1008
|
+
console.log(c.dim("변경하지 않았습니다."));
|
|
939
1009
|
}
|
|
940
1010
|
continue;
|
|
941
1011
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Tab 자동완성 — readline completer (의존성 0).
|
|
2
|
+
// 지원: /슬래시명령(내장+스킬) · /provider 인자 · @파일명 멘션(작업 폴더 최상위)
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
|
|
5
|
+
export const BUILTIN_COMMANDS = [
|
|
6
|
+
"/help", "/guide", "/tutorial", "/about",
|
|
7
|
+
"/setup", "/provider", "/model", "/models", "/update",
|
|
8
|
+
"/teach", "/stream", "/color", "/auto",
|
|
9
|
+
"/context", "/status", "/cost", "/config", "/sessions",
|
|
10
|
+
"/workspace", "/cd", "/init", "/memory",
|
|
11
|
+
"/skills", "/plugins", "/mcp",
|
|
12
|
+
"/new", "/clear", "/reset", "/compact", "/resume", "/undo",
|
|
13
|
+
"/quit", "/exit",
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
// ctx: { skills(): {name:...}, workspace(): string|null, providers: string[] }
|
|
17
|
+
export function makeCompleter(ctx) {
|
|
18
|
+
return (line) => {
|
|
19
|
+
// 1) /provider <인자> 자동완성
|
|
20
|
+
if (line.startsWith("/provider ")) {
|
|
21
|
+
const frag = line.slice("/provider ".length);
|
|
22
|
+
const hits = (ctx.providers || [])
|
|
23
|
+
.filter((p) => p.startsWith(frag))
|
|
24
|
+
.map((p) => "/provider " + p);
|
|
25
|
+
return [hits, line];
|
|
26
|
+
}
|
|
27
|
+
// 2) 슬래시 명령(내장 + 스킬) — 첫 단어 입력 중일 때
|
|
28
|
+
if (line.startsWith("/") && !line.includes(" ")) {
|
|
29
|
+
let skills = {};
|
|
30
|
+
try {
|
|
31
|
+
skills = ctx.skills() || {};
|
|
32
|
+
} catch { /* 초기화 전 */ }
|
|
33
|
+
const names = [...new Set([...BUILTIN_COMMANDS, ...Object.keys(skills).map((s) => "/" + s)])].sort();
|
|
34
|
+
const hits = names.filter((n) => n.startsWith(line));
|
|
35
|
+
return [hits.length ? hits : names, line];
|
|
36
|
+
}
|
|
37
|
+
// 3) @파일명 멘션 — 작업 폴더 최상위 파일
|
|
38
|
+
const m = /@([\w가-힣./\\-]*)$/.exec(line);
|
|
39
|
+
if (m) {
|
|
40
|
+
let ws = null;
|
|
41
|
+
try {
|
|
42
|
+
ws = ctx.workspace();
|
|
43
|
+
} catch { /* 초기화 전 */ }
|
|
44
|
+
if (ws) {
|
|
45
|
+
let files = [];
|
|
46
|
+
try {
|
|
47
|
+
files = fs.readdirSync(ws).filter((f) => !f.startsWith("."));
|
|
48
|
+
} catch { /* ignore */ }
|
|
49
|
+
const head = line.slice(0, m.index);
|
|
50
|
+
const hits = files.filter((f) => f.startsWith(m[1])).map((f) => head + "@" + f);
|
|
51
|
+
return [hits, line];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return [[], line];
|
|
55
|
+
};
|
|
56
|
+
}
|
package/src/config.js
CHANGED
|
@@ -34,7 +34,7 @@ const DEFAULTS = {
|
|
|
34
34
|
api_key: "",
|
|
35
35
|
base_url: "", // OpenAI 호환 사내/폐쇄망 LLM 의 전체 엔드포인트 직접 지정(있으면 우선)
|
|
36
36
|
model: "mock-agent",
|
|
37
|
-
workspace: "
|
|
37
|
+
workspace: ".", // 기본 = 현재 폴더(cwd). Claude Code/OpenCode 와 동일 — 별도 폴더를 만들지 않는다.
|
|
38
38
|
approval_mode: "manual",
|
|
39
39
|
allow_shell: false,
|
|
40
40
|
max_steps: 8,
|
|
@@ -70,7 +70,7 @@ export class Config {
|
|
|
70
70
|
|
|
71
71
|
// CLI 도구이므로 작업 폴더 상대경로는 '현재 폴더' 기준으로 해석한다(직관적).
|
|
72
72
|
workspacePath() {
|
|
73
|
-
let p = this.workspace || "
|
|
73
|
+
let p = this.workspace || ".";
|
|
74
74
|
if (!path.isAbsolute(p)) p = path.resolve(process.cwd(), p);
|
|
75
75
|
return p;
|
|
76
76
|
}
|
package/src/loop.js
CHANGED
|
@@ -135,7 +135,8 @@ export class AgentLoop {
|
|
|
135
135
|
const parts = [
|
|
136
136
|
"# 정체성",
|
|
137
137
|
"당신은 CDSA Harness(made by CDSA) 안에서 동작하는 코딩·업무 에이전트입니다.",
|
|
138
|
-
"
|
|
138
|
+
"작업 폴더의 파일을 도구(list_dir/read_file/search_files/edit_file/write_file)로 직접 읽고·검색하고·수정할 수 있습니다.",
|
|
139
|
+
"'파일에 접근할 수 없다'고 답하지 마세요 — 필요한 도구를 호출하면 됩니다. 도구가 곧 당신의 손입니다.",
|
|
139
140
|
"",
|
|
140
141
|
"# 환경",
|
|
141
142
|
`- 작업 폴더(루트): ${ws}`,
|
package/src/tools.js
CHANGED
|
@@ -86,7 +86,7 @@ export class Toolbox {
|
|
|
86
86
|
const root = this.workspace + path.sep;
|
|
87
87
|
if (candidate !== this.workspace && !candidate.startsWith(root)) {
|
|
88
88
|
throw new ToolError(
|
|
89
|
-
`작업 폴더 밖
|
|
89
|
+
`작업 폴더 밖 경로입니다: ${rel} — 현재 작업 폴더는 ${this.workspace} 입니다. 다른 폴더에서 작업하려면 사용자가 /workspace <경로> 로 변경할 수 있다고 안내하세요.`
|
|
90
90
|
);
|
|
91
91
|
}
|
|
92
92
|
return candidate;
|