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 +4 -1
- package/package.json +1 -1
- package/src/builtins.js +1 -1
- package/src/cli.js +96 -25
- package/src/completion.js +56 -0
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`)
|
|
84
|
+
| `/status` (=`/cost`) | 상태 · 세션 누적 토큰 |
|
|
85
|
+
| `/models [검색어]` | **실시간 모델 목록에서 번호로 선택**(4개 제공자 모두) |
|
|
83
86
|
| `/guide` · `/tutorial` | 빠른 시작 안내 · 단계별 인터랙티브 튜토리얼 |
|
|
84
87
|
| `/workspace <폴더>` | 작업 폴더 보기/변경 (`.` = 현재 폴더) |
|
|
85
88
|
| `/color` | 색상 켜기/끄기(흑백) |
|
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}개`));
|
|
@@ -919,23 +996,17 @@ export async function main(argv = []) {
|
|
|
919
996
|
);
|
|
920
997
|
continue;
|
|
921
998
|
}
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
+
}
|