cdsa-harness 0.20.1 → 0.21.1
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 +0 -1
- package/package.json +1 -1
- package/src/builtins.js +1 -1
- package/src/cli.js +64 -44
- package/src/config.js +8 -2
- package/src/loop.js +6 -0
- package/src/select.js +113 -0
package/README.md
CHANGED
package/package.json
CHANGED
package/src/builtins.js
CHANGED
package/src/cli.js
CHANGED
|
@@ -28,6 +28,7 @@ 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
30
|
import { makeCompleter } from "./completion.js";
|
|
31
|
+
import { selectMenu } from "./select.js";
|
|
31
32
|
|
|
32
33
|
// VERSION 은 src/builtins.js(생성물)에서 가져온다 — npm/exe 양쪽에서 동일.
|
|
33
34
|
|
|
@@ -333,6 +334,12 @@ async function pickModel(ask, cfg, query = "") {
|
|
|
333
334
|
console.log(c.yellow(`'${query}' 에 맞는 모델이 없습니다.`) + c.dim(" 예: /models claude · /models gpt"));
|
|
334
335
|
return null;
|
|
335
336
|
}
|
|
337
|
+
// ↑↓ 방향키 메뉴(TTY) — 파이프/CI 에선 아래 번호 입력으로 폴백
|
|
338
|
+
const menuSel = await selectMenu(list.slice(0, 50), {
|
|
339
|
+
title: `🤖 모델 선택 — ${cfg.provider}${live ? " (실시간)" : " (추천)"}${list.length > 50 ? ` · 상위 50/${list.length} — 검색어로 좁히세요` : ""}`,
|
|
340
|
+
current: cfg.model,
|
|
341
|
+
});
|
|
342
|
+
if (menuSel !== undefined) return menuSel; // 선택(문자열) 또는 취소(null)
|
|
336
343
|
const shown = list.slice(0, 15);
|
|
337
344
|
const lines = shown.map((m, i) => {
|
|
338
345
|
const cur = m === cfg.model ? c.green(" ← 현재") : "";
|
|
@@ -519,20 +526,24 @@ function cleanKey(s) {
|
|
|
519
526
|
// 대화형 연결 설정. 키는 환경변수가 있으면 그걸 우선 안내(파일 저장 안 함).
|
|
520
527
|
// ask 가 null 을 주면(Ctrl+C) 조용히 취소.
|
|
521
528
|
async function runSetup(ask, cfg) {
|
|
522
|
-
|
|
523
|
-
[
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
)
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
529
|
+
const PROVIDER_ITEMS = [
|
|
530
|
+
["openai", "openai — GPT (키: OPENAI_API_KEY)"],
|
|
531
|
+
["anthropic", "anthropic — Claude (키: ANTHROPIC_API_KEY)"],
|
|
532
|
+
["openrouter", "openrouter — 여러 모델 중계 (키: OPENROUTER_API_KEY)"],
|
|
533
|
+
["ollama", "ollama — 로컬·폐쇄망 LLM, 키 불필요 🏢"],
|
|
534
|
+
["mock", "mock — 키 없이 연습"],
|
|
535
|
+
];
|
|
536
|
+
let provider = null;
|
|
537
|
+
const provSel = await selectMenu(PROVIDER_ITEMS.map(([, l]) => l), { title: "🔌 어떤 AI 에 연결할까요? (/setup)", current: (PROVIDER_ITEMS.find(([k]) => k === cfg.provider) || [])[1] });
|
|
538
|
+
if (provSel === null) { console.log(c.yellow("취소했습니다.")); return false; }
|
|
539
|
+
if (provSel !== undefined) {
|
|
540
|
+
provider = PROVIDER_ITEMS[PROVIDER_ITEMS.findIndex(([, l]) => l === provSel)][0];
|
|
541
|
+
} else {
|
|
542
|
+
console.log(panel(PROVIDER_ITEMS.map(([, l], i) => ` ${c.bold(String(i + 1))}) ${l}`), { title: "🔌 연결 설정 (/setup)", color: "cyan" }));
|
|
543
|
+
const pickRaw = await ask(c.cyan("제공자 번호 [1-5] (취소: Enter): "));
|
|
544
|
+
if (pickRaw === null) return false;
|
|
545
|
+
provider = (PROVIDER_ITEMS[parseInt(pickRaw.trim(), 10) - 1] || [])[0];
|
|
546
|
+
}
|
|
536
547
|
if (!provider) {
|
|
537
548
|
console.log(c.yellow("취소했습니다."));
|
|
538
549
|
return false;
|
|
@@ -835,31 +846,7 @@ export async function main(argv = []) {
|
|
|
835
846
|
// 첫 실행 온보딩(한 번만 — ~/.cdsa_harness/.welcomed 표시): 작업 폴더 설정 + 튜토리얼
|
|
836
847
|
const markerPath = path.join(configDir(), ".welcomed");
|
|
837
848
|
if (stdin.isTTY && !fs.existsSync(markerPath)) {
|
|
838
|
-
console.log(
|
|
839
|
-
[
|
|
840
|
-
"AI 가 파일을 다룰 ‘작업 폴더’를 정하세요.",
|
|
841
|
-
c.dim("이 폴더 밖은 절대 건드리지 않아요(안전장치)."),
|
|
842
|
-
"",
|
|
843
|
-
` ${c.bold("엔터")} ${c.cyan("지금 이 폴더")} 를 그대로 사용 (터미널답게, 기본)`,
|
|
844
|
-
` ${c.bold("경로")} 따로 격리하고 싶으면 입력 — 예) ${c.cyan("./sandbox")} 또는 ${c.cyan("C:\\작업\\프로젝트")}`,
|
|
845
|
-
],
|
|
846
|
-
{ title: "📁 작업 폴더 설정 (처음 한 번)", color: "cyan" }
|
|
847
|
-
));
|
|
848
|
-
const wsAns = await ask(c.cyan("작업 폴더 [엔터=기본]: "));
|
|
849
|
-
if (wsAns !== null && wsAns.trim()) {
|
|
850
|
-
cfg.workspace = wsAns.trim();
|
|
851
|
-
const rebuilt = await buildExtensions(cfg, mcp);
|
|
852
|
-
toolbox = rebuilt.toolbox;
|
|
853
|
-
skills = rebuilt.skills;
|
|
854
|
-
loop.toolbox = toolbox;
|
|
855
|
-
loop.reset();
|
|
856
|
-
}
|
|
857
|
-
try {
|
|
858
|
-
saveConfig(cfg);
|
|
859
|
-
} catch {
|
|
860
|
-
/* 저장 실패 무시 */
|
|
861
|
-
}
|
|
862
|
-
console.log(c.green(`작업 폴더: ${cfg.workspacePath()}`) + c.dim(" (나중에 /workspace 로 변경 가능)\n"));
|
|
849
|
+
console.log(c.dim(`작업 폴더 = 지금 이 폴더 (${cfg.workspacePath()}) — 바꾸려면 /workspace <경로>\n`));
|
|
863
850
|
|
|
864
851
|
const a = await ask(c.cyan("짧은 튜토리얼을 볼까요? [Y/n] "));
|
|
865
852
|
if (a !== null && ["", "y", "yes"].includes(a.trim().toLowerCase())) await runTutorial(ask);
|
|
@@ -871,8 +858,16 @@ export async function main(argv = []) {
|
|
|
871
858
|
}
|
|
872
859
|
}
|
|
873
860
|
|
|
861
|
+
let pendingInput = null; // 메뉴에서 고른 명령을 다음 루프에서 입력처럼 실행
|
|
874
862
|
while (true) {
|
|
875
|
-
|
|
863
|
+
let raw;
|
|
864
|
+
if (pendingInput !== null) {
|
|
865
|
+
raw = pendingInput;
|
|
866
|
+
pendingInput = null;
|
|
867
|
+
console.log(c.bold(c.cyan("› ")) + raw);
|
|
868
|
+
} else {
|
|
869
|
+
raw = await ask(c.bold(c.cyan("› ")));
|
|
870
|
+
}
|
|
876
871
|
if (raw === null) break; // Ctrl+D / Ctrl+C / 스트림 종료
|
|
877
872
|
const user = raw.trim();
|
|
878
873
|
if (!user) continue;
|
|
@@ -880,7 +875,27 @@ export async function main(argv = []) {
|
|
|
880
875
|
|
|
881
876
|
if (["/quit", "/exit", "quit", "exit", ":q"].includes(low)) break;
|
|
882
877
|
if (user === "/") {
|
|
878
|
+
// ↑↓ 메뉴로 명령 선택 → 바로 실행 (Tab 몰라도 됨)
|
|
879
|
+
const CMD_DESC = {
|
|
880
|
+
"/new": "새 대화", "/compact": "대화를 모델이 요약·압축", "/resume": "지난 대화 이어가기",
|
|
881
|
+
"/undo": "마지막 파일 변경 되돌리기", "/context": "모델에 보내는 컨텍스트 보기",
|
|
882
|
+
"/init": "모델이 AGENT.md 규칙 생성", "/memory": "규칙 파일 보기", "/workspace": "작업 폴더 보기/변경",
|
|
883
|
+
"/setup": "AI 연결 마법사", "/models": "모델 목록에서 선택", "/auto": "자동 수락 토글",
|
|
884
|
+
"/status": "상태·누적 토큰", "/update": "최신 버전으로 업데이트", "/teach": "교육 모드 토글",
|
|
885
|
+
"/stream": "스트리밍 토글", "/skills": "스킬 목록", "/plugins": "플러그인 목록", "/mcp": "MCP 서버",
|
|
886
|
+
"/guide": "빠른 시작 안내", "/tutorial": "튜토리얼", "/help": "전체 도움말", "/about": "정보", "/quit": "종료",
|
|
887
|
+
};
|
|
883
888
|
const skillNames = Object.keys(skills).sort();
|
|
889
|
+
const menuItems = [
|
|
890
|
+
...Object.entries(CMD_DESC).map(([k, v]) => `${k} ${v}`),
|
|
891
|
+
...skillNames.map((s) => `/${s} ${(skills[s].description || "").slice(0, 34)}`),
|
|
892
|
+
];
|
|
893
|
+
const sel = await selectMenu(menuItems, { title: "⌨️ 명령 선택 — ↑↓ 이동 · Enter 실행" , window: 14 });
|
|
894
|
+
if (sel !== undefined) {
|
|
895
|
+
if (sel) pendingInput = sel.split(/\s{2,}/)[0];
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
// (비TTY 폴백) 텍스트 팔레트
|
|
884
899
|
console.log(panel([
|
|
885
900
|
c.bold("대화")+" "+c.cyan("/new /compact /resume /undo /context"),
|
|
886
901
|
c.bold("프로젝트")+" "+c.cyan("/init /memory /workspace"),
|
|
@@ -1185,12 +1200,17 @@ export async function main(argv = []) {
|
|
|
1185
1200
|
await runTurn(renderSkill(skills[name], argStr));
|
|
1186
1201
|
} else {
|
|
1187
1202
|
const { BUILTIN_COMMANDS } = await import("./completion.js");
|
|
1188
|
-
const all = [...BUILTIN_COMMANDS, ...Object.keys(skills).map((s) => "/" + s)];
|
|
1189
|
-
const near = all.filter((n) => n.
|
|
1203
|
+
const all = [...new Set([...BUILTIN_COMMANDS, ...Object.keys(skills).map((s) => "/" + s)])].sort();
|
|
1204
|
+
const near = all.filter((n) => n.slice(1).startsWith(name) || n.includes(name.slice(0, 3)));
|
|
1205
|
+
if (near.length) {
|
|
1206
|
+
const sel = await selectMenu(near.slice(0, 20), { title: `'/${name}' — 이 중에 찾으시나요? (↑↓·Enter)` });
|
|
1207
|
+
if (sel) { pendingInput = sel; continue; }
|
|
1208
|
+
if (sel === null) continue; // 취소
|
|
1209
|
+
}
|
|
1190
1210
|
console.log(
|
|
1191
1211
|
c.yellow(`알 수 없는 명령: /${name}`) +
|
|
1192
|
-
(near.length ? c.dim(" 혹시? ") + c.cyan(near.join(" ")) : "") +
|
|
1193
|
-
c.dim(" · '/'
|
|
1212
|
+
(near.length ? c.dim(" 혹시? ") + c.cyan(near.slice(0, 4).join(" ")) : "") +
|
|
1213
|
+
c.dim(" · '/' 입력=명령 메뉴")
|
|
1194
1214
|
);
|
|
1195
1215
|
}
|
|
1196
1216
|
continue;
|
package/src/config.js
CHANGED
|
@@ -91,7 +91,10 @@ export class Config {
|
|
|
91
91
|
|
|
92
92
|
toJSON() {
|
|
93
93
|
const out = {};
|
|
94
|
-
for (const key of Object.keys(DEFAULTS))
|
|
94
|
+
for (const key of Object.keys(DEFAULTS)) {
|
|
95
|
+
if (key === "workspace") continue; // 실행 폴더가 곧 워크스페이스 — 저장하지 않는다
|
|
96
|
+
out[key] = this[key];
|
|
97
|
+
}
|
|
95
98
|
return out;
|
|
96
99
|
}
|
|
97
100
|
}
|
|
@@ -100,7 +103,10 @@ export function loadConfig() {
|
|
|
100
103
|
const p = configPath();
|
|
101
104
|
try {
|
|
102
105
|
if (fs.existsSync(p)) {
|
|
103
|
-
|
|
106
|
+
const data = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
107
|
+
// workspace 는 '실행한 폴더'가 진리 — 파일에 저장된 값(옛 버전 잔재)은 무시한다.
|
|
108
|
+
delete data.workspace;
|
|
109
|
+
return new Config(data);
|
|
104
110
|
}
|
|
105
111
|
} catch {
|
|
106
112
|
// 손상된 설정은 무시하고 기본값
|
package/src/loop.js
CHANGED
|
@@ -154,6 +154,12 @@ export class AgentLoop {
|
|
|
154
154
|
: "- 당신은 상위 에이전트가 위임한 하위 작업을 수행하는 서브에이전트다. 주어진 작업만 완수하고, 결과를 명확한 텍스트로 보고하라.",
|
|
155
155
|
pluginNames ? `- 추가 도구: ${pluginNames}` : null,
|
|
156
156
|
"",
|
|
157
|
+
"# CDSA Harness 자체 사용법 — 사용자가 이 도구의 설정·사용법을 물으면 아래로 정확히 답하라",
|
|
158
|
+
"- 설정 파일: 홈폴더/.cdsa_harness/config.json (실행 폴더에 config.json 이 있으면 그쪽 우선).",
|
|
159
|
+
'- MCP 연결: 설정 파일에 \"mcpServers\": { \"이름\": { \"command\": \"npx\", \"args\": [\"-y\",\"서버패키지\"] } } — Claude Code/Cursor 와 동일 형식. 저장 후 재시작하면 /mcp 에 표시.',
|
|
160
|
+
"- 주요 명령: /setup 연결마법사 · /models 모델선택 · /workspace 작업폴더 변경 · /auto 자동승인 · /update 업데이트 · /mcp /plugins /skills 목록 · /help 전체 · /guide 시작안내.",
|
|
161
|
+
"- 확장: 스킬 = 작업폴더/.cdsa/skills/*.md (또는 홈/.cdsa_harness/skills), 플러그인 = .cdsa/plugins/*.mjs 또는 npm 'cdsa-harness-plugin-*' 설치.",
|
|
162
|
+
"",
|
|
157
163
|
"# 응답 스타일",
|
|
158
164
|
"- 한국어로, 간결하게. 불필요한 서론·사과 없이 핵심부터.",
|
|
159
165
|
"- 작업이 끝나면 도구를 더 호출하지 말고 무엇을 했는지 요약한다.",
|
package/src/select.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// ↑↓ 방향키 인터랙티브 선택 메뉴 (의존성 0, raw keypress).
|
|
2
|
+
// TTY 가 아니면 null 을 즉시 반환 → 호출측이 번호 입력 폴백을 쓴다(파이프/CI 호환).
|
|
3
|
+
import readline from "node:readline";
|
|
4
|
+
import { c } from "./ui.js";
|
|
5
|
+
|
|
6
|
+
const HIDE = "\x1b[?25l";
|
|
7
|
+
const SHOW = "\x1b[?25h";
|
|
8
|
+
|
|
9
|
+
export function selectMenu(items, { title = "", current = null, window = 12 } = {}) {
|
|
10
|
+
const stdin = process.stdin;
|
|
11
|
+
const out = process.stdout;
|
|
12
|
+
if (!stdin.isTTY || !out.isTTY || !items.length) return Promise.resolve(undefined); // 미지원 → 호출측 폴백
|
|
13
|
+
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
let idx = items.indexOf(current);
|
|
16
|
+
if (idx < 0) idx = 0;
|
|
17
|
+
let rendered = 0;
|
|
18
|
+
let digits = "";
|
|
19
|
+
let done = false;
|
|
20
|
+
|
|
21
|
+
const clampTop = () => {
|
|
22
|
+
let top = idx - Math.floor(window / 2);
|
|
23
|
+
top = Math.max(0, Math.min(top, items.length - window));
|
|
24
|
+
return Math.max(0, top);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const render = () => {
|
|
28
|
+
if (rendered) out.write(`\x1b[${rendered}A`);
|
|
29
|
+
let n = 0;
|
|
30
|
+
const line = (s) => {
|
|
31
|
+
out.write("\x1b[2K" + s + "\n");
|
|
32
|
+
n++;
|
|
33
|
+
};
|
|
34
|
+
if (title) line(c.bold(c.cyan(title)));
|
|
35
|
+
const top = clampTop();
|
|
36
|
+
const end = Math.min(items.length, top + window);
|
|
37
|
+
if (top > 0) line(c.dim(` ↑ ${top}개 더`));
|
|
38
|
+
for (let i = top; i < end; i++) {
|
|
39
|
+
const isCur = i === idx;
|
|
40
|
+
const tag = items[i] === current ? c.green(" ← 현재") : "";
|
|
41
|
+
const num = c.dim(String(i + 1).padStart(2) + " ");
|
|
42
|
+
if (isCur) line(c.cyan(c.bold("▸ ")) + num + c.bold(c.cyan(items[i])) + tag);
|
|
43
|
+
else line(" " + num + items[i] + tag);
|
|
44
|
+
}
|
|
45
|
+
if (end < items.length) line(c.dim(` ↓ ${items.length - end}개 더`));
|
|
46
|
+
line(c.dim("↑↓ 이동 · Enter 선택 · 숫자 바로이동 · Esc/q 취소"));
|
|
47
|
+
rendered = n;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// 기존 readline(rl)의 keypress 리스너를 잠시 떼어내 이중 처리를 막는다.
|
|
51
|
+
readline.emitKeypressEvents(stdin);
|
|
52
|
+
const saved = stdin.rawListeners("keypress");
|
|
53
|
+
for (const l of saved) stdin.removeListener("keypress", l);
|
|
54
|
+
const wasRaw = stdin.isRaw;
|
|
55
|
+
try {
|
|
56
|
+
stdin.setRawMode(true);
|
|
57
|
+
} catch { /* 일부 환경 */ }
|
|
58
|
+
stdin.resume();
|
|
59
|
+
out.write(HIDE);
|
|
60
|
+
|
|
61
|
+
const finish = (value) => {
|
|
62
|
+
if (done) return;
|
|
63
|
+
done = true;
|
|
64
|
+
stdin.removeListener("keypress", onKey);
|
|
65
|
+
try {
|
|
66
|
+
stdin.setRawMode(Boolean(wasRaw));
|
|
67
|
+
} catch { /* */ }
|
|
68
|
+
for (const l of saved) stdin.on("keypress", l);
|
|
69
|
+
// 메뉴 지우고 선택 결과 한 줄만 남긴다
|
|
70
|
+
if (rendered) out.write(`\x1b[${rendered}A`);
|
|
71
|
+
for (let i = 0; i < rendered; i++) out.write("\x1b[2K\n");
|
|
72
|
+
out.write(`\x1b[${rendered}A`);
|
|
73
|
+
out.write(SHOW);
|
|
74
|
+
if (value !== null) out.write(c.dim("선택: ") + c.green(value) + "\n");
|
|
75
|
+
else out.write(c.dim("(선택 취소)") + "\n");
|
|
76
|
+
resolve(value);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const onKey = (str, key) => {
|
|
80
|
+
if (done) return;
|
|
81
|
+
const name = key && key.name;
|
|
82
|
+
if ((key && key.ctrl && name === "c") || name === "escape" || str === "q") return finish(null);
|
|
83
|
+
if (name === "return" || name === "enter") {
|
|
84
|
+
if (digits) {
|
|
85
|
+
const n = parseInt(digits, 10);
|
|
86
|
+
if (n >= 1 && n <= items.length) idx = n - 1;
|
|
87
|
+
digits = "";
|
|
88
|
+
}
|
|
89
|
+
return finish(items[idx]);
|
|
90
|
+
}
|
|
91
|
+
if (name === "up" || str === "k") {
|
|
92
|
+
idx = (idx - 1 + items.length) % items.length;
|
|
93
|
+
digits = "";
|
|
94
|
+
} else if (name === "down" || str === "j") {
|
|
95
|
+
idx = (idx + 1) % items.length;
|
|
96
|
+
digits = "";
|
|
97
|
+
} else if (str >= "0" && str <= "9") {
|
|
98
|
+
digits += str;
|
|
99
|
+
const n = parseInt(digits, 10);
|
|
100
|
+
if (n >= 1 && n <= items.length) idx = n - 1;
|
|
101
|
+
else digits = str; // 범위 밖이면 새로 시작
|
|
102
|
+
const n2 = parseInt(digits, 10);
|
|
103
|
+
if (n2 >= 1 && n2 <= items.length) idx = n2 - 1;
|
|
104
|
+
} else {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
render();
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
stdin.on("keypress", onKey);
|
|
111
|
+
render();
|
|
112
|
+
});
|
|
113
|
+
}
|