cdsa-harness 0.18.0 → 0.19.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,7 +81,8 @@ 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
87
  | `/workspace <폴더>` | 작업 폴더 보기/변경 (`.` = 현재 폴더) |
85
88
  | `/color` | 색상 켜기/끄기(흑백) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdsa-harness",
3
- "version": "0.18.0",
3
+ "version": "0.19.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.19.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}개`));
@@ -919,23 +996,17 @@ export async function main(argv = []) {
919
996
  );
920
997
  continue;
921
998
  }
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
- }
999
+ // /models [검색어] 모델 목록에서 번호로 선택(제공자별 실시간 조회)
1000
+ if (low === "/models" || low.startsWith("/models ")) {
1001
+ const query = user.split(/\s+/).slice(1).join(" ");
1002
+ const picked = await pickModel(ask, cfg, query);
1003
+ if (picked) {
1004
+ cfg.model = picked;
1005
+ loop.client = makeClient(cfg);
1006
+ saveConfig(cfg);
1007
+ console.log(c.green(`모델 변경 → ${picked}`) + c.dim(" (저장됨)"));
936
1008
  } 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" }));
1009
+ console.log(c.dim("변경하지 않았습니다."));
939
1010
  }
940
1011
  continue;
941
1012
  }
@@ -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
+ }