deel-local-cli 0.5.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/LICENSE +21 -0
- package/README.en.md +246 -0
- package/README.md +322 -0
- package/bin/deel.js +94 -0
- package/package.json +52 -0
- package/src/agent/loop.js +125 -0
- package/src/agent/session.js +136 -0
- package/src/backend/adapter.js +173 -0
- package/src/backend/detect.js +75 -0
- package/src/backend/http.js +60 -0
- package/src/backend/probe.js +355 -0
- package/src/commands.js +327 -0
- package/src/config.js +61 -0
- package/src/repl.js +222 -0
- package/src/report.js +112 -0
- package/src/safety/audit.js +40 -0
- package/src/safety/guard.js +56 -0
- package/src/safety/undo.js +78 -0
- package/src/setup.js +172 -0
- package/src/skills/discover.js +219 -0
- package/src/tools/edit-match.js +146 -0
- package/src/tools/fsutil.js +90 -0
- package/src/tools/index.js +317 -0
- package/src/ui/ansi.js +75 -0
- package/src/ui/prompt.js +76 -0
- package/src/ui/spinner.js +32 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// 연결 프로필 저장/읽기. ~/.deel/config.json (프로젝트 폴더의 .deel/config.json 이 우선)
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join, dirname } from 'node:path';
|
|
4
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from 'node:fs';
|
|
5
|
+
|
|
6
|
+
const USER_DIR = join(homedir(), '.deel');
|
|
7
|
+
const PROJECT_DIR = join(process.cwd(), '.deel');
|
|
8
|
+
|
|
9
|
+
export function configPath() {
|
|
10
|
+
const local = join(PROJECT_DIR, 'config.json');
|
|
11
|
+
if (existsSync(local)) return local;
|
|
12
|
+
return join(USER_DIR, 'config.json');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const EMPTY = { version: 1, active: null, profiles: [] };
|
|
16
|
+
|
|
17
|
+
export function load() {
|
|
18
|
+
const p = configPath();
|
|
19
|
+
if (!existsSync(p)) return structuredClone(EMPTY);
|
|
20
|
+
try {
|
|
21
|
+
const raw = JSON.parse(readFileSync(p, 'utf8'));
|
|
22
|
+
return { ...structuredClone(EMPTY), ...raw };
|
|
23
|
+
} catch (err) {
|
|
24
|
+
throw new Error(`설정 파일을 읽지 못했습니다: ${p}\n ${err.message}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function save(cfg, { toProject = false } = {}) {
|
|
29
|
+
const dir = toProject ? PROJECT_DIR : USER_DIR;
|
|
30
|
+
const p = join(dir, 'config.json');
|
|
31
|
+
mkdirSync(dir, { recursive: true });
|
|
32
|
+
writeFileSync(p, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
|
|
33
|
+
// 키가 들어 있는 파일이므로 가능한 환경에서는 본인만 읽게 잠근다.
|
|
34
|
+
try { chmodSync(p, 0o600); } catch {}
|
|
35
|
+
return p;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function activeProfile(cfg = load()) {
|
|
39
|
+
if (!cfg.profiles.length) return null;
|
|
40
|
+
return cfg.profiles.find((x) => x.id === cfg.active) ?? cfg.profiles[0];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 환경변수가 있으면 파일보다 우선한다 — 사내망에서 키를 파일에 안 남기고 싶을 때 쓴다.
|
|
44
|
+
export function resolveKey(profile) {
|
|
45
|
+
const byName = profile?.id ? process.env[`DEEL_KEY_${profile.id.toUpperCase()}`] : null;
|
|
46
|
+
return byName || process.env.DEEL_API_KEY || profile?.apiKey || '';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function upsert(cfg, profile) {
|
|
50
|
+
const i = cfg.profiles.findIndex((x) => x.id === profile.id);
|
|
51
|
+
if (i >= 0) cfg.profiles[i] = { ...cfg.profiles[i], ...profile };
|
|
52
|
+
else cfg.profiles.push(profile);
|
|
53
|
+
if (!cfg.active) cfg.active = profile.id;
|
|
54
|
+
return cfg;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function slug(name) {
|
|
58
|
+
const base = String(name).trim().toLowerCase()
|
|
59
|
+
.replace(/[^a-z0-9가-힣]+/g, '-').replace(/^-+|-+$/g, '');
|
|
60
|
+
return base || 'profile';
|
|
61
|
+
}
|
package/src/repl.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// 대화 화면. 루프가 보내는 이벤트를 Claude Code 풍으로 그린다.
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { c, say, mark, cursor, width } from './ui/ansi.js';
|
|
4
|
+
import { handle, COMMANDS } from './commands.js';
|
|
5
|
+
import { run } from './agent/loop.js';
|
|
6
|
+
import { Session } from './agent/session.js';
|
|
7
|
+
import { makeScope } from './safety/guard.js';
|
|
8
|
+
import { History } from './safety/undo.js';
|
|
9
|
+
import { Audit } from './safety/audit.js';
|
|
10
|
+
import { activeProfile, load, resolveKey } from './config.js';
|
|
11
|
+
import { discover } from './skills/discover.js';
|
|
12
|
+
|
|
13
|
+
// 도구 호출을 한 줄로 요약 — Read(src/a.js) 처럼.
|
|
14
|
+
function toolLabel(name, args) {
|
|
15
|
+
const a = args ?? {};
|
|
16
|
+
const first =
|
|
17
|
+
a.file_path ?? a.pattern ?? a.path ??
|
|
18
|
+
(a.command ? String(a.command).slice(0, 48) : '') ?? '';
|
|
19
|
+
return `${name}(${c.gray(String(first))})`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function toolResultLine(result, ms) {
|
|
23
|
+
if (result?.error) return `${c.red('└')} ${c.red(String(result.error).split('\n')[0])}`;
|
|
24
|
+
const s = result?.summary ?? '완료';
|
|
25
|
+
const t = ms > 1000 ? c.gray(` ${(ms / 1000).toFixed(1)}초`) : '';
|
|
26
|
+
return `${c.gray('└')} ${c.gray(s)}${t}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function chatLoop(opts = {}) {
|
|
30
|
+
const cfg = load();
|
|
31
|
+
const prof = activeProfile(cfg);
|
|
32
|
+
if (!prof) {
|
|
33
|
+
say('');
|
|
34
|
+
say(` ${mark.warn} 저장된 연결이 없습니다. ${c.cyan('deel setup')} 을 먼저 실행하세요.`);
|
|
35
|
+
say('');
|
|
36
|
+
return 1;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const root = opts.root ? opts.root : process.cwd();
|
|
40
|
+
const conn = {
|
|
41
|
+
kind: prof.kind, base: prof.baseUrl, auth: prof.auth,
|
|
42
|
+
key: resolveKey(prof), model: prof.model,
|
|
43
|
+
ctx: prof.ctx ?? 32768, streaming: prof.streaming ?? false,
|
|
44
|
+
tools: prof.tools ?? false, json: prof.json ?? false, think: prof.think ?? false,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const session = new Session(conn, {
|
|
48
|
+
root,
|
|
49
|
+
mode: opts.mode ?? 'auto',
|
|
50
|
+
think: opts.think ?? 'medium',
|
|
51
|
+
maxSteps: opts.maxSteps ?? 24,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// 이 PC 에 있는 스킬·명령·플러그인을 찾아 붙인다. 품고 다니지 않는다.
|
|
55
|
+
const found = discover(root);
|
|
56
|
+
session.skills = found.skills;
|
|
57
|
+
session.commands = found.commands;
|
|
58
|
+
session.plugins = found.plugins;
|
|
59
|
+
|
|
60
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout, historySize: 200 });
|
|
61
|
+
|
|
62
|
+
// 입력을 큐로 받는다. rl.question 을 겹쳐 쓰면 파이프로 넣을 때 닫혀 버린다.
|
|
63
|
+
const queue = [];
|
|
64
|
+
let waiter = null;
|
|
65
|
+
let closed = false;
|
|
66
|
+
const echo = !process.stdin.isTTY; // 파이프·기록용일 때는 입력을 되비춘다
|
|
67
|
+
|
|
68
|
+
rl.on('line', (l) => {
|
|
69
|
+
if (echo) say(l);
|
|
70
|
+
if (waiter) { const w = waiter; waiter = null; w(l); }
|
|
71
|
+
else queue.push(l);
|
|
72
|
+
});
|
|
73
|
+
rl.on('close', () => {
|
|
74
|
+
closed = true;
|
|
75
|
+
if (waiter) { const w = waiter; waiter = null; w(null); }
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const nextLine = () => {
|
|
79
|
+
if (queue.length) return Promise.resolve(queue.shift());
|
|
80
|
+
if (closed) return Promise.resolve(null);
|
|
81
|
+
return new Promise((res) => { waiter = res; });
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const ask = async (label, o = {}) => {
|
|
85
|
+
process.stdout.write(` ${c.gray('›')} ${label} ${o.def ? c.gray(`[${o.def}] `) : ''}`);
|
|
86
|
+
const a = await nextLine();
|
|
87
|
+
if (a === null) return o.def ?? '';
|
|
88
|
+
return a.trim() || o.def || '';
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const ctx = {
|
|
92
|
+
scope: makeScope(root),
|
|
93
|
+
history: new History(root),
|
|
94
|
+
audit: new Audit(root),
|
|
95
|
+
seen: new Set(),
|
|
96
|
+
skills: found.skills,
|
|
97
|
+
loadedSkills: new Set(),
|
|
98
|
+
ask,
|
|
99
|
+
confirm: async (name, args) => {
|
|
100
|
+
say('');
|
|
101
|
+
say(` ${c.yellow('?')} ${toolLabel(name, args)}`);
|
|
102
|
+
const a = (await ask('실행할까요? (y/n)', { def: 'y' })).toLowerCase();
|
|
103
|
+
return a === 'y' || a === 'yes' || a === 'ㅇ';
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// 머리말
|
|
108
|
+
say('');
|
|
109
|
+
say(` ${c.cyan('deel')} ${c.gray(prof.model)} ${c.gray('·')} ${c.gray(ctx.scope.root)}`);
|
|
110
|
+
const warn = [];
|
|
111
|
+
if (!conn.tools) warn.push('도구 호출 미확인');
|
|
112
|
+
if (!conn.streaming) warn.push('스트리밍 없음');
|
|
113
|
+
if (warn.length) say(` ${c.yellow('⚠')} ${c.gray(warn.join(' · '))}`);
|
|
114
|
+
if (found.skills.length || found.commands.length) {
|
|
115
|
+
say(` ${c.gray(`스킬 ${found.skills.length}개 · 명령 ${found.commands.length}개 찾음`)}${found.plugins.length ? c.gray(` (플러그인 ${found.plugins.length}개)`) : ''}`);
|
|
116
|
+
}
|
|
117
|
+
say(` ${c.gray('/help 로 명령 목록. Ctrl+C 로 끝냅니다.')}`);
|
|
118
|
+
say('');
|
|
119
|
+
|
|
120
|
+
let interrupted = false;
|
|
121
|
+
rl.on('SIGINT', () => {
|
|
122
|
+
if (interrupted) { rl.close(); return; }
|
|
123
|
+
interrupted = true;
|
|
124
|
+
say('');
|
|
125
|
+
say(` ${c.gray('한 번 더 Ctrl+C 를 누르면 끝냅니다.')}`);
|
|
126
|
+
process.stdout.write(`\n${c.cyan('›')} `);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
for (;;) {
|
|
130
|
+
process.stdout.write(`\n${c.cyan('›')} `);
|
|
131
|
+
const line = await nextLine();
|
|
132
|
+
if (line === null) break; // 입력이 끝났다 (파이프 종료 / Ctrl+D)
|
|
133
|
+
interrupted = false;
|
|
134
|
+
const text = line.trim();
|
|
135
|
+
if (!text) continue;
|
|
136
|
+
|
|
137
|
+
const cmd = await handle(text, session, ctx);
|
|
138
|
+
if (cmd.exit) break;
|
|
139
|
+
if (cmd.handled) continue;
|
|
140
|
+
const toSend = cmd.text ?? text; // 슬래시 명령이면 펼쳐진 내용을 보낸다
|
|
141
|
+
|
|
142
|
+
say('');
|
|
143
|
+
const started = Date.now();
|
|
144
|
+
let tools = 0;
|
|
145
|
+
let thinkChars = 0;
|
|
146
|
+
let streamed = false;
|
|
147
|
+
let thinkingShown = false;
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
for await (const ev of run(session, ctx, toSend)) {
|
|
151
|
+
switch (ev.type) {
|
|
152
|
+
case 'waiting':
|
|
153
|
+
process.stdout.write(` ${c.gray('생각 중…')}\r`);
|
|
154
|
+
break;
|
|
155
|
+
|
|
156
|
+
case 'thinking':
|
|
157
|
+
thinkChars += ev.text.length;
|
|
158
|
+
if (process.stdout.isTTY) {
|
|
159
|
+
cursor.clearLine();
|
|
160
|
+
process.stdout.write(` ${c.magenta('✻')} ${c.gray(`생각 중… ${thinkChars}자`)}`);
|
|
161
|
+
thinkingShown = true;
|
|
162
|
+
}
|
|
163
|
+
break;
|
|
164
|
+
|
|
165
|
+
case 'content':
|
|
166
|
+
if (thinkingShown) { cursor.clearLine(); thinkingShown = false; }
|
|
167
|
+
if (!streamed) { streamed = true; process.stdout.write(' '); }
|
|
168
|
+
process.stdout.write(ev.text.replace(/\n/g, '\n '));
|
|
169
|
+
break;
|
|
170
|
+
|
|
171
|
+
case 'tool_start':
|
|
172
|
+
if (thinkingShown) { cursor.clearLine(); thinkingShown = false; }
|
|
173
|
+
if (streamed) { say(''); streamed = false; }
|
|
174
|
+
say('');
|
|
175
|
+
say(` ${c.cyan('⏺')} ${toolLabel(ev.name, ev.args)}`);
|
|
176
|
+
break;
|
|
177
|
+
|
|
178
|
+
case 'tool':
|
|
179
|
+
tools++;
|
|
180
|
+
say(` ${toolResultLine(ev.result, ev.ms ?? 0)}`);
|
|
181
|
+
break;
|
|
182
|
+
|
|
183
|
+
case 'trimmed':
|
|
184
|
+
say(` ${c.gray(`(컨텍스트가 차서 오래된 대화 ${ev.dropped}개를 줄였습니다)`)}`);
|
|
185
|
+
break;
|
|
186
|
+
|
|
187
|
+
case 'limit':
|
|
188
|
+
say('');
|
|
189
|
+
say(` ${c.yellow('⚠')} 도구 호출 ${ev.steps}회에서 멈췄습니다. ${c.gray('이어서 하려면 다시 말씀하세요.')}`);
|
|
190
|
+
break;
|
|
191
|
+
|
|
192
|
+
case 'error':
|
|
193
|
+
if (thinkingShown) cursor.clearLine();
|
|
194
|
+
say('');
|
|
195
|
+
say(` ${c.red('✗')} ${ev.text}`);
|
|
196
|
+
break;
|
|
197
|
+
|
|
198
|
+
case 'done':
|
|
199
|
+
if (streamed) say('');
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
} catch (err) {
|
|
204
|
+
say('');
|
|
205
|
+
say(` ${c.red('✗')} ${err.message}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 꼬리말 — 이번 턴 요약
|
|
209
|
+
const secs = ((Date.now() - started) / 1000).toFixed(1);
|
|
210
|
+
const bits = [`${secs}초`];
|
|
211
|
+
if (tools) bits.push(`도구 ${tools}회`);
|
|
212
|
+
if (session.usage.out) bits.push(`${session.usage.out.toLocaleString()}토큰`);
|
|
213
|
+
say('');
|
|
214
|
+
say(` ${c.gray('─ ' + bits.join(' · '))}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
rl.close();
|
|
218
|
+
say('');
|
|
219
|
+
say(` ${c.gray('끝냅니다.')} ${c.gray(`도구 시간 ${(session.usage.ms / 1000).toFixed(1)}초 · 모델 호출 ${session.usage.calls}회`)}`);
|
|
220
|
+
say('');
|
|
221
|
+
return 0;
|
|
222
|
+
}
|
package/src/report.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// 진단 결과를 화면에 표로 그리고, "이걸로 돌릴 수 있는지" 판정한다.
|
|
2
|
+
import { c, say, rule, pad, width, mark } from './ui/ansi.js';
|
|
3
|
+
|
|
4
|
+
const ICON = { ok: mark.ok, no: mark.no, warn: mark.warn, skip: c.gray('·') };
|
|
5
|
+
const WORD = { ok: c.green('됨'), no: c.red('안됨'), warn: c.yellow('조건부'), skip: c.gray('확인불가') };
|
|
6
|
+
|
|
7
|
+
export function renderLine(r) {
|
|
8
|
+
const icon = ICON[r.status] ?? ' ';
|
|
9
|
+
const label = pad(r.label, 20);
|
|
10
|
+
const time = r.ms ? c.gray(pad(`${r.ms}ms`, 8, 'right')) : ' '.repeat(8);
|
|
11
|
+
say(` ${icon} ${label} ${pad(WORD[r.status], 12)} ${time} ${c.gray(r.detail)}`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function renderHeader(facts) {
|
|
15
|
+
say('');
|
|
16
|
+
rule('연결', 74);
|
|
17
|
+
const kindName = facts.shape === 'ollama' ? 'Ollama 자체 규격' : 'OpenAI 호환';
|
|
18
|
+
const rows = [
|
|
19
|
+
['규격', kindName],
|
|
20
|
+
['주소', facts.base],
|
|
21
|
+
['인증', facts.auth === 'none' ? '없음' : facts.auth],
|
|
22
|
+
['모델', facts.model],
|
|
23
|
+
];
|
|
24
|
+
for (const [k, v] of rows) say(` ${c.gray(pad(k, 8))} ${v}`);
|
|
25
|
+
say('');
|
|
26
|
+
rule('검사', 74);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 판정 — 무엇을 할 수 있고 무엇이 막히는지 사람 말로.
|
|
30
|
+
export function verdict(facts, results) {
|
|
31
|
+
const by = Object.fromEntries(results.map((r) => [r.id, r]));
|
|
32
|
+
const get = (id) => by[id]?.status;
|
|
33
|
+
const notes = [];
|
|
34
|
+
let level;
|
|
35
|
+
|
|
36
|
+
if (get('chat') !== 'ok') {
|
|
37
|
+
level = 'stop';
|
|
38
|
+
notes.push('기본 대화가 안 됩니다. 주소·키·모델 이름을 먼저 확인해야 합니다.');
|
|
39
|
+
return { level, notes };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const toolsOk = get('tools') === 'ok' && get('toolresult') === 'ok';
|
|
43
|
+
const toolsPartial = ['ok', 'warn'].includes(get('tools'));
|
|
44
|
+
|
|
45
|
+
if (!toolsPartial) {
|
|
46
|
+
level = 'blocked';
|
|
47
|
+
notes.push('도구 호출이 안 됩니다 — 이 상태로는 파일을 읽거나 고칠 수 없습니다.');
|
|
48
|
+
notes.push('게이트웨이가 tools 필드를 지우는지 관리자에게 확인이 필요합니다.');
|
|
49
|
+
} else if (toolsOk) {
|
|
50
|
+
level = 'ready';
|
|
51
|
+
notes.push('도구 호출과 결과 되돌리기가 모두 확인됐습니다. 에이전트 루프를 그대로 올릴 수 있습니다.');
|
|
52
|
+
} else {
|
|
53
|
+
level = 'limited';
|
|
54
|
+
notes.push('도구는 부르는데 인자나 결과 활용이 불안정합니다. 3단계(편집 신뢰성)에 시간을 더 씁니다.');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (get('json') !== 'ok') notes.push('구조적 출력이 약합니다 — 편집 형식을 프롬프트로 강제하고 검사를 붙입니다.');
|
|
58
|
+
if (get('stream') !== 'ok') notes.push('스트리밍이 없습니다 — 화면은 스피너로 대체하고 기능은 동일하게 갑니다.');
|
|
59
|
+
if (get('system') === 'warn') notes.push('시스템 지시를 약하게 따릅니다 — 스킬을 적게, 짧게 올려야 합니다.');
|
|
60
|
+
if (get('think') === 'ok') notes.push('추론 강도가 모델 층에서 먹습니다 — /think 로 바로 조절됩니다.');
|
|
61
|
+
else notes.push('추론 강도는 루프 층(계획 강제·도구 호출 상한·자기검증 횟수)으로 조절합니다.');
|
|
62
|
+
if (!facts.ctx) notes.push('컨텍스트 길이를 서버가 안 알려줍니다 — 설정에서 직접 넣어야 합니다.');
|
|
63
|
+
|
|
64
|
+
return { level, notes };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const VERDICT_STYLE = {
|
|
68
|
+
ready: { tag: c.green(' 준비됨 '), line: '이 연결로 deel 를 돌릴 수 있습니다.' },
|
|
69
|
+
limited: { tag: c.yellow(' 제한적 '), line: '돌아가긴 합니다. 편집 신뢰성 보강이 필요합니다.' },
|
|
70
|
+
blocked: { tag: c.red(' 막힘 '), line: '핵심 기능이 막혀 있습니다. 아래를 먼저 해결해야 합니다.' },
|
|
71
|
+
stop: { tag: c.red(' 연결실패 '), line: '연결 자체가 되지 않았습니다.' },
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export function renderVerdict(v) {
|
|
75
|
+
const s = VERDICT_STYLE[v.level];
|
|
76
|
+
say('');
|
|
77
|
+
rule('판정', 74);
|
|
78
|
+
say(` ${s.tag} ${c.bold(s.line)}`);
|
|
79
|
+
say('');
|
|
80
|
+
for (const n of v.notes) say(` ${c.gray('•')} ${n}`);
|
|
81
|
+
say('');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 사내망에서 캡처 대신 파일로 가져올 수 있게 — 색 없는 평문.
|
|
85
|
+
export function plainReport(facts, results, v) {
|
|
86
|
+
const at = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
|
87
|
+
const lines = [
|
|
88
|
+
'deel 연결 진단 보고서',
|
|
89
|
+
`생성 시각 ${at}`,
|
|
90
|
+
'',
|
|
91
|
+
`규격 ${facts.shape === 'ollama' ? 'Ollama 자체 규격' : 'OpenAI 호환'}`,
|
|
92
|
+
`주소 ${facts.base}`,
|
|
93
|
+
`인증 ${facts.auth === 'none' ? '없음' : facts.auth}`,
|
|
94
|
+
`모델 ${facts.model}`,
|
|
95
|
+
`컨텍스트 ${facts.ctx ? facts.ctx.toLocaleString() + ' 토큰' : '미상'}`,
|
|
96
|
+
'',
|
|
97
|
+
'검사 결과',
|
|
98
|
+
'-'.repeat(70),
|
|
99
|
+
];
|
|
100
|
+
const plain = { ok: '됨', no: '안됨', warn: '조건부', skip: '확인불가' };
|
|
101
|
+
for (const r of results) {
|
|
102
|
+
const label = r.label + ' '.repeat(Math.max(0, 20 - width(r.label)));
|
|
103
|
+
const state = plain[r.status] + ' '.repeat(Math.max(0, 10 - width(plain[r.status])));
|
|
104
|
+
const t = r.ms ? `${r.ms}ms` : '';
|
|
105
|
+
const time = ' '.repeat(Math.max(0, 9 - t.length)) + t;
|
|
106
|
+
lines.push(` ${label} ${state} ${time} ${r.detail}`);
|
|
107
|
+
}
|
|
108
|
+
lines.push('-'.repeat(70), '', `판정: ${v.level}`, '');
|
|
109
|
+
for (const n of v.notes) lines.push(` - ${n}`);
|
|
110
|
+
lines.push('');
|
|
111
|
+
return lines.join('\n');
|
|
112
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// 감사 로그. 무엇을 언제 어떻게 했는지 전부 남긴다.
|
|
2
|
+
// 자율 실행을 사내에 설득할 때 이 파일이 근거가 된다.
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { appendFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
|
|
6
|
+
export class Audit {
|
|
7
|
+
constructor(root) {
|
|
8
|
+
const dir = join(root, '.deel');
|
|
9
|
+
mkdirSync(dir, { recursive: true });
|
|
10
|
+
this.file = join(dir, 'audit.jsonl');
|
|
11
|
+
this.session = `${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '')}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
write(kind, data) {
|
|
15
|
+
const rec = { at: new Date().toISOString(), session: this.session, kind, ...data };
|
|
16
|
+
try { appendFileSync(this.file, JSON.stringify(rec) + '\n', 'utf8'); } catch {}
|
|
17
|
+
return rec;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
tool(name, args, result) {
|
|
21
|
+
return this.write('tool', {
|
|
22
|
+
tool: name,
|
|
23
|
+
target: args?.file_path ?? args?.path ?? args?.pattern ?? args?.command ?? null,
|
|
24
|
+
ok: !result?.error,
|
|
25
|
+
note: result?.error ?? result?.summary ?? null,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
turn(text) { return this.write('turn', { text: String(text).slice(0, 500) }); }
|
|
30
|
+
blocked(why, what) { return this.write('blocked', { why, what: String(what).slice(0, 300) }); }
|
|
31
|
+
undo(info) { return this.write('undo', info); }
|
|
32
|
+
|
|
33
|
+
recent(n = 20) {
|
|
34
|
+
if (!existsSync(this.file)) return [];
|
|
35
|
+
return readFileSync(this.file, 'utf8')
|
|
36
|
+
.split('\n').filter(Boolean).slice(-n)
|
|
37
|
+
.map((l) => { try { return JSON.parse(l); } catch { return null; } })
|
|
38
|
+
.filter(Boolean);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// 자율 실행의 울타리.
|
|
2
|
+
// 승인 프롬프트를 안 쓰는 대신 (1) 작업 범위 밖은 못 건드리고
|
|
3
|
+
// (2) 되돌릴 수 없는 명령만 막는다. 나머지는 전부 통과시킨다.
|
|
4
|
+
import { resolve, relative, isAbsolute, sep } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export class ScopeError extends Error {}
|
|
7
|
+
export class BlockedError extends Error {}
|
|
8
|
+
|
|
9
|
+
// 작업 범위 — deel 를 띄운 폴더. 그 밖은 읽기도 쓰기도 막는다.
|
|
10
|
+
export function makeScope(root) {
|
|
11
|
+
const base = resolve(root);
|
|
12
|
+
return {
|
|
13
|
+
root: base,
|
|
14
|
+
resolve(p) {
|
|
15
|
+
if (!p || typeof p !== 'string') throw new ScopeError('경로가 비었습니다');
|
|
16
|
+
const abs = isAbsolute(p) ? resolve(p) : resolve(base, p);
|
|
17
|
+
const rel = relative(base, abs);
|
|
18
|
+
if (rel.startsWith('..' + sep) || rel === '..' || (isAbsolute(rel) && rel !== '')) {
|
|
19
|
+
throw new ScopeError(`작업 범위 밖입니다: ${p}\n 범위: ${base}`);
|
|
20
|
+
}
|
|
21
|
+
return abs;
|
|
22
|
+
},
|
|
23
|
+
show(abs) {
|
|
24
|
+
const rel = relative(base, abs);
|
|
25
|
+
return rel === '' ? '.' : rel.split(sep).join('/');
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 되돌릴 수 없는 것만 막는다. 목록이 길어지면 도구가 쓸모없어진다.
|
|
31
|
+
const BLOCKED = [
|
|
32
|
+
{ re: /\brm\s+(-[a-z]*[rR][a-z]*f|-[a-z]*f[a-z]*[rR])\b[^|;&]*\s(\/|~|\$HOME)\s*$/i, why: '뿌리 폴더를 통째로 지우려 합니다' },
|
|
33
|
+
{ re: /\b(mkfs|fdisk|diskpart)\b/i, why: '디스크를 초기화하는 명령입니다' },
|
|
34
|
+
{ re: /\bformat\s+[a-z]:/i, why: '드라이브를 포맷하는 명령입니다' },
|
|
35
|
+
{ re: /\b(rd|rmdir)\s+\/s\b/i, why: '폴더를 통째로 지웁니다 — 정션이 있으면 원본까지 딸려 갑니다' },
|
|
36
|
+
{ re: /\bdel\s+\/[sq]\b.*\\\*/i, why: '하위 폴더까지 전부 지웁니다' },
|
|
37
|
+
{ re: /git\s+push\b[^|;&]*--force(?!-with-lease)/i, why: '원격 이력을 덮어씁니다 (--force-with-lease 를 쓰세요)' },
|
|
38
|
+
{ re: /\bshutdown\b|\breboot\b/i, why: '시스템을 끕니다' },
|
|
39
|
+
{ re: /curl[^|]*\|\s*(ba)?sh/i, why: '받은 스크립트를 그대로 실행합니다' },
|
|
40
|
+
{ re: /\biwr\b[^|]*\|\s*iex\b/i, why: '받은 스크립트를 그대로 실행합니다' },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
export function checkCommand(cmd) {
|
|
44
|
+
const s = String(cmd);
|
|
45
|
+
for (const b of BLOCKED) {
|
|
46
|
+
if (b.re.test(s)) throw new BlockedError(`${b.why}\n 막힌 명령: ${s.slice(0, 120)}`);
|
|
47
|
+
}
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 변경성 동작은 실패해도 다시 실행하지 않는다 — 두 번 실행되면 사고다. (RPA 에서 얻은 원칙)
|
|
52
|
+
const MUTATING = /\b(git\s+(commit|push|merge|rebase|reset)|npm\s+(publish|install)|pip\s+install|mv|cp|del|rm|move|copy|curl\s+-X\s*(POST|PUT|DELETE|PATCH))\b/i;
|
|
53
|
+
|
|
54
|
+
export function isMutating(cmd) {
|
|
55
|
+
return MUTATING.test(String(cmd));
|
|
56
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// 되돌리기. 승인 프롬프트를 안 쓰는 대신 이게 안전망이다.
|
|
2
|
+
// 파일을 고치기 전에 항상 이전 내용을 떠 놓고, /undo 로 턴 단위로 되돌린다.
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, appendFileSync, readdirSync } from 'node:fs';
|
|
5
|
+
|
|
6
|
+
export class History {
|
|
7
|
+
constructor(root) {
|
|
8
|
+
this.dir = join(root, '.deel', 'history');
|
|
9
|
+
mkdirSync(this.dir, { recursive: true });
|
|
10
|
+
this.file = join(this.dir, 'edits.jsonl');
|
|
11
|
+
this.turn = 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// 새 턴 시작 — /undo 는 턴 하나를 통째로 되돌린다.
|
|
15
|
+
nextTurn() {
|
|
16
|
+
this.turn = Date.now();
|
|
17
|
+
return this.turn;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// 파일을 고치기 직전에 부른다. 없던 파일이면 before 는 null.
|
|
21
|
+
snapshot(absPath, label) {
|
|
22
|
+
const before = existsSync(absPath) ? safeRead(absPath) : null;
|
|
23
|
+
const rec = { turn: this.turn, at: new Date().toISOString(), path: absPath, before, label };
|
|
24
|
+
appendFileSync(this.file, JSON.stringify(rec) + '\n', 'utf8');
|
|
25
|
+
return rec;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
all() {
|
|
29
|
+
if (!existsSync(this.file)) return [];
|
|
30
|
+
return readFileSync(this.file, 'utf8')
|
|
31
|
+
.split('\n').filter(Boolean)
|
|
32
|
+
.map((l) => { try { return JSON.parse(l); } catch { return null; } })
|
|
33
|
+
.filter(Boolean);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
turns() {
|
|
37
|
+
const seen = [];
|
|
38
|
+
for (const r of this.all()) if (!seen.includes(r.turn)) seen.push(r.turn);
|
|
39
|
+
return seen;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 최근 n 개 턴을 되돌린다. 되돌린 파일 목록을 반환.
|
|
43
|
+
undo(n = 1) {
|
|
44
|
+
const recs = this.all();
|
|
45
|
+
const turns = this.turns().slice(-n);
|
|
46
|
+
if (!turns.length) return { restored: [], turns: 0 };
|
|
47
|
+
|
|
48
|
+
const target = recs.filter((r) => turns.includes(r.turn));
|
|
49
|
+
// 같은 파일이 여러 번 바뀌었으면 가장 이른 상태로 되돌려야 한다.
|
|
50
|
+
const first = new Map();
|
|
51
|
+
for (const r of target) if (!first.has(r.path)) first.set(r.path, r);
|
|
52
|
+
|
|
53
|
+
const restored = [];
|
|
54
|
+
for (const [path, rec] of first) {
|
|
55
|
+
try {
|
|
56
|
+
if (rec.before === null) {
|
|
57
|
+
if (existsSync(path)) { rmSync(path, { force: true }); restored.push({ path, how: '삭제됨(원래 없던 파일)' }); }
|
|
58
|
+
} else {
|
|
59
|
+
writeFileSync(path, rec.before, 'utf8');
|
|
60
|
+
restored.push({ path, how: '되돌림' });
|
|
61
|
+
}
|
|
62
|
+
} catch (err) {
|
|
63
|
+
restored.push({ path, how: `실패: ${err.message}` });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// 되돌린 기록은 잘라낸다.
|
|
67
|
+
const keep = recs.filter((r) => !turns.includes(r.turn));
|
|
68
|
+
writeFileSync(this.file, keep.map((r) => JSON.stringify(r)).join('\n') + (keep.length ? '\n' : ''), 'utf8');
|
|
69
|
+
return { restored, turns: turns.length };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function safeRead(p) {
|
|
74
|
+
const buf = readFileSync(p);
|
|
75
|
+
// 바이너리는 되돌리기 대상에서 뺀다 — 텍스트만 다룬다.
|
|
76
|
+
if (buf.includes(0)) return null;
|
|
77
|
+
return buf.toString('utf8');
|
|
78
|
+
}
|