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 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`) · `/models` | 상태·세션 누적 토큰 · 모델 목록(ollama 는 설치분 실시간) |
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": "./workspace",
234
+ "workspace": ".",
232
235
  "approval_mode": "manual",
233
236
  "allow_shell": false,
234
237
  "max_steps": 8,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdsa-harness",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "AI 에이전트의 내부 동작을 단계별로 드러내는 교육용 터미널 하네스. 실시간 스트리밍 + OpenAI/Claude/OpenRouter + MCP + npm 플러그인·크로스포맷 스킬.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/builtins.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // 스킬/플러그인/버전을 바꾼 뒤 `node scripts/gen-builtins.mjs` 로 재생성하세요.
3
3
  import p0 from "../plugins/hwpx_read.mjs";
4
4
 
5
- export const VERSION = "0.18.0";
5
+ export const VERSION = "0.20.0";
6
6
 
7
7
  export const BUILTIN_PLUGINS = [p0];
8
8
 
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 note = provider === "openrouter" ? c.dim(" (OpenRouter 'provider/model' 형식)") : "";
508
- const m = await ask(c.cyan(`모델 [${def}]${note}\n 추천: ${sugg.join(", ")}\n > `));
509
- if (m === null) {
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
- const rl = readline.createInterface({ input: stdin, output: stdout, history: savedHistory, historySize: 200 });
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
- let { toolbox, skills } = await buildExtensions(cfg, mcp);
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("엔터")} 기본값 ${c.cyan("./workspace")} (하위 폴더 자동 생성)`,
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 — 있는 모델 나열 (ollama 는 설치된 모델 실시간 조회)
923
- if (low === "/models") {
924
- if (cfg.provider === "ollama") {
925
- const host = (cfg.base_url || "http://localhost:11434/v1/chat/completions").replace(/\/v1\/.*$/, "");
926
- try {
927
- const ctrl = new AbortController();
928
- const t = setTimeout(() => ctrl.abort(), 2000);
929
- const res = await fetch(`${host}/api/tags`, { signal: ctrl.signal });
930
- clearTimeout(t);
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
- const sugg = SUGGESTED_MODELS[cfg.provider] || [];
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: "./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 || "./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
- `작업 폴더 밖 경로에는 접근할 수 없습니다: ${rel} (허용 루트: ${this.workspace})`
89
+ `작업 폴더 밖 경로입니다: ${rel} 현재 작업 폴더는 ${this.workspace} 입니다. 다른 폴더에서 작업하려면 사용자가 /workspace <경로> 로 변경할 수 있다고 안내하세요.`
90
90
  );
91
91
  }
92
92
  return candidate;