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.
@@ -0,0 +1,317 @@
1
+ // 도구 6종. 이름과 인자를 Claude Code 와 같게 맞춘다 —
2
+ // 그래야 그 관례로 쓰인 스킬·명령이 그대로 먹는다.
3
+ import { writeFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
4
+ import { dirname } from 'node:path';
5
+ import { execFile } from 'node:child_process';
6
+ import { globToRegex, walk, readText, SKIP_DIRS } from './fsutil.js';
7
+ import { checkCommand } from '../safety/guard.js';
8
+ import { findMatch, applySpans, reindent, TIER_LABELS } from './edit-match.js';
9
+ import { loadSkill } from '../skills/discover.js';
10
+
11
+ const MAX_READ_LINES = 2000;
12
+ const MAX_OUT = 30000;
13
+
14
+ function clip(s, n = MAX_OUT) {
15
+ const t = String(s);
16
+ return t.length > n ? t.slice(0, n) + `\n… (${t.length - n}자 잘림)` : t;
17
+ }
18
+
19
+ export const TOOLS = {
20
+ Read: {
21
+ schema: {
22
+ name: 'Read',
23
+ description: '파일 하나를 읽는다. 줄 번호가 붙어 돌아온다. 고치기 전에는 반드시 먼저 읽어야 한다.',
24
+ parameters: {
25
+ type: 'object',
26
+ properties: {
27
+ file_path: { type: 'string', description: '읽을 파일 경로' },
28
+ offset: { type: 'number', description: '시작 줄 (1부터). 큰 파일에서만 쓴다' },
29
+ limit: { type: 'number', description: '읽을 줄 수' },
30
+ },
31
+ required: ['file_path'],
32
+ },
33
+ },
34
+ run(args, ctx) {
35
+ const abs = ctx.scope.resolve(args.file_path);
36
+ if (!existsSync(abs)) return { error: `파일이 없습니다: ${args.file_path}` };
37
+ if (statSync(abs).isDirectory()) return { error: `폴더입니다. Glob 을 쓰세요: ${args.file_path}` };
38
+ const text = readText(abs);
39
+ const lines = text.split('\n');
40
+ const start = Math.max(0, (args.offset ?? 1) - 1);
41
+ const count = Math.min(args.limit ?? MAX_READ_LINES, MAX_READ_LINES);
42
+ const slice = lines.slice(start, start + count);
43
+ const body = slice.map((l, i) => `${String(start + i + 1).padStart(6)}\t${l}`).join('\n');
44
+ const more = lines.length > start + count ? `\n… 전체 ${lines.length}줄 중 ${start + count}줄까지` : '';
45
+ ctx.seen.add(abs);
46
+ return { content: clip(body + more), summary: `${lines.length}줄` };
47
+ },
48
+ },
49
+
50
+ Write: {
51
+ schema: {
52
+ name: 'Write',
53
+ description: '파일을 새로 쓰거나 통째로 덮어쓴다. 일부만 고칠 때는 Edit 을 쓴다.',
54
+ parameters: {
55
+ type: 'object',
56
+ properties: {
57
+ file_path: { type: 'string', description: '쓸 파일 경로' },
58
+ content: { type: 'string', description: '파일 전체 내용' },
59
+ },
60
+ required: ['file_path', 'content'],
61
+ },
62
+ },
63
+ run(args, ctx) {
64
+ const abs = ctx.scope.resolve(args.file_path);
65
+ if (typeof args.content !== 'string') return { error: 'content 가 문자열이 아닙니다' };
66
+ ctx.history.snapshot(abs, 'Write');
67
+ mkdirSync(dirname(abs), { recursive: true });
68
+ const existed = existsSync(abs);
69
+ writeFileSync(abs, args.content, 'utf8');
70
+ ctx.seen.add(abs);
71
+ const n = args.content.split('\n').length;
72
+ return { content: `${existed ? '덮어씀' : '새로 만듦'}: ${ctx.scope.show(abs)} (${n}줄)`, summary: `${n}줄`, changed: abs };
73
+ },
74
+ },
75
+
76
+ Edit: {
77
+ schema: {
78
+ name: 'Edit',
79
+ description: '파일에서 정확히 일치하는 문자열 하나를 바꾼다. 먼저 Read 로 읽어야 한다.',
80
+ parameters: {
81
+ type: 'object',
82
+ properties: {
83
+ file_path: { type: 'string', description: '고칠 파일 경로' },
84
+ old_string: { type: 'string', description: '바꿀 대상. 파일에서 유일해야 한다' },
85
+ new_string: { type: 'string', description: '바꿀 내용' },
86
+ replace_all: { type: 'boolean', description: '모두 바꾸려면 true' },
87
+ },
88
+ required: ['file_path', 'old_string', 'new_string'],
89
+ },
90
+ },
91
+ run(args, ctx) {
92
+ const abs = ctx.scope.resolve(args.file_path);
93
+ if (!existsSync(abs)) return { error: `파일이 없습니다: ${args.file_path}` };
94
+ if (!ctx.seen.has(abs)) return { error: `먼저 Read 로 읽어야 합니다: ${args.file_path}` };
95
+ if (args.old_string === args.new_string) return { error: 'old_string 과 new_string 이 같습니다' };
96
+
97
+ const text = readText(abs);
98
+ const m = findMatch(text, args.old_string, { replaceAll: !!args.replace_all });
99
+
100
+ if (!m.ok) {
101
+ if (m.reason === 'ambiguous') {
102
+ return { error: `${m.count}군데에서 발견됐습니다 (${TIER_LABELS[m.tier]}). 앞뒤로 더 넓게 잡아 하나만 가리키거나 replace_all 을 쓰세요.` };
103
+ }
104
+ const hint = m.near
105
+ ? `\n 파일의 ${m.near.line}번 줄이 가장 비슷합니다:\n ${m.near.text.trim().slice(0, 120)}\n 이 줄을 그대로 옮겨 담아 다시 시도하세요.`
106
+ : '\n Read 로 다시 읽어 실제 내용을 확인하세요.';
107
+ return { error: `찾지 못했습니다.${hint}` };
108
+ }
109
+
110
+ ctx.history.snapshot(abs, 'Edit');
111
+ const next = applySpans(text, m.spans, (matched) =>
112
+ m.tier === 'exact' ? args.new_string : reindent(args.new_string, matched, args.old_string));
113
+ writeFileSync(abs, next, 'utf8');
114
+
115
+ const n = m.spans.length;
116
+ const how = m.tier === 'exact' ? '' : ` · ${TIER_LABELS[m.tier]}`;
117
+ return {
118
+ content: `고침: ${ctx.scope.show(abs)} (${n}군데${how})`,
119
+ summary: `${n}군데${how}`,
120
+ changed: abs,
121
+ tier: m.tier,
122
+ };
123
+ },
124
+ },
125
+
126
+ Glob: {
127
+ schema: {
128
+ name: 'Glob',
129
+ description: '이름 패턴으로 파일을 찾는다. 예: **/*.js, src/**/*.{ts,tsx}',
130
+ parameters: {
131
+ type: 'object',
132
+ properties: {
133
+ pattern: { type: 'string', description: 'glob 패턴' },
134
+ path: { type: 'string', description: '찾기 시작할 폴더. 없으면 작업 폴더 전체' },
135
+ },
136
+ required: ['pattern'],
137
+ },
138
+ },
139
+ run(args, ctx) {
140
+ const root = args.path ? ctx.scope.resolve(args.path) : ctx.scope.root;
141
+ const re = globToRegex(args.pattern);
142
+ const files = walk(root)
143
+ .filter((f) => re.test(f.rel) || re.test(f.rel.split('/').pop()))
144
+ .sort((a, b) => b.mtime - a.mtime)
145
+ .slice(0, 200);
146
+ if (!files.length) return { content: `찾은 파일 없음: ${args.pattern}`, summary: '0개' };
147
+ return {
148
+ content: files.map((f) => ctx.scope.show(f.path)).join('\n'),
149
+ summary: `${files.length}개`,
150
+ };
151
+ },
152
+ },
153
+
154
+ Grep: {
155
+ schema: {
156
+ name: 'Grep',
157
+ description: '파일 내용에서 정규식으로 검색한다.',
158
+ parameters: {
159
+ type: 'object',
160
+ properties: {
161
+ pattern: { type: 'string', description: '정규식' },
162
+ path: { type: 'string', description: '검색할 폴더나 파일' },
163
+ glob: { type: 'string', description: '대상 파일 제한. 예: **/*.js' },
164
+ output_mode: { type: 'string', enum: ['content', 'files_with_matches', 'count'], description: '기본 files_with_matches' },
165
+ '-i': { type: 'boolean', description: '대소문자 무시' },
166
+ '-n': { type: 'boolean', description: '줄 번호 표시' },
167
+ head_limit: { type: 'number', description: '결과 개수 제한' },
168
+ },
169
+ required: ['pattern'],
170
+ },
171
+ },
172
+ run(args, ctx) {
173
+ let re;
174
+ try { re = new RegExp(args.pattern, args['-i'] ? 'i' : ''); }
175
+ catch (err) { return { error: `정규식이 잘못됐습니다: ${err.message}` }; }
176
+
177
+ const root = args.path ? ctx.scope.resolve(args.path) : ctx.scope.root;
178
+ const isFile = existsSync(root) && statSync(root).isFile();
179
+ let files = isFile
180
+ ? [{ path: root, rel: ctx.scope.show(root) }]
181
+ : walk(root);
182
+ if (args.glob) {
183
+ const g = globToRegex(args.glob);
184
+ files = files.filter((f) => g.test(f.rel) || g.test(f.rel.split('/').pop()));
185
+ }
186
+
187
+ const mode = args.output_mode ?? 'files_with_matches';
188
+ const limit = args.head_limit ?? 250;
189
+ const hitFiles = [];
190
+ const lines = [];
191
+ let total = 0;
192
+
193
+ for (const f of files) {
194
+ let text;
195
+ try { text = readText(f.path); } catch { continue; }
196
+ const ls = text.split('\n');
197
+ let n = 0;
198
+ for (let i = 0; i < ls.length; i++) {
199
+ if (!re.test(ls[i])) continue;
200
+ n++; total++;
201
+ if (mode === 'content' && lines.length < limit) {
202
+ const num = args['-n'] === false ? '' : `:${i + 1}`;
203
+ lines.push(`${ctx.scope.show(f.path)}${num}: ${ls[i].trim().slice(0, 200)}`);
204
+ }
205
+ }
206
+ if (n) hitFiles.push({ rel: ctx.scope.show(f.path), n });
207
+ if (mode !== 'content' && hitFiles.length >= limit) break;
208
+ }
209
+
210
+ if (!total) return { content: `일치 없음: ${args.pattern}`, summary: '0건' };
211
+ if (mode === 'content') return { content: clip(lines.join('\n')), summary: `${total}건` };
212
+ if (mode === 'count') {
213
+ return { content: hitFiles.map((f) => `${f.n}\t${f.rel}`).join('\n'), summary: `${hitFiles.length}개 파일` };
214
+ }
215
+ return { content: hitFiles.map((f) => f.rel).join('\n'), summary: `${hitFiles.length}개 파일 · ${total}건` };
216
+ },
217
+ },
218
+
219
+ Skill: {
220
+ schema: {
221
+ name: 'Skill',
222
+ description: '스킬 하나를 펼쳐 읽는다. 목록에 이름과 설명만 올라와 있으니, 필요한 것을 골라 이걸로 본문을 받는다.',
223
+ parameters: {
224
+ type: 'object',
225
+ properties: { name: { type: 'string', description: '스킬 이름 (목록에 있는 그대로)' } },
226
+ required: ['name'],
227
+ },
228
+ },
229
+ run(args, ctx) {
230
+ const want = String(args.name ?? '').trim();
231
+ const list = ctx.skills ?? [];
232
+ if (!list.length) return { error: '이 PC 에서 찾은 스킬이 없습니다.' };
233
+
234
+ const hit = list.find((s) => s.name === want)
235
+ ?? list.find((s) => s.name.toLowerCase() === want.toLowerCase())
236
+ ?? list.find((s) => s.name.split(':').pop() === want);
237
+ if (!hit) {
238
+ const near = list.filter((s) => s.name.includes(want) || want.includes(s.name.split(':').pop()))
239
+ .slice(0, 5).map((s) => s.name);
240
+ return { error: `그런 스킬이 없습니다: ${want}` + (near.length ? `\n 비슷한 것: ${near.join(', ')}` : '') };
241
+ }
242
+ const { body, error, cut } = loadSkill(hit, { maxChars: ctx.maxSkillChars ?? 8000 });
243
+ if (error) return { error: `스킬을 읽지 못했습니다: ${error}` };
244
+ ctx.loadedSkills?.add(hit.name);
245
+ return {
246
+ content: `# 스킬: ${hit.name}\n\n${body}`,
247
+ summary: `${Math.round(body.length / 100) / 10}k자${cut ? ' (일부 잘림)' : ''}`,
248
+ };
249
+ },
250
+ },
251
+
252
+ Bash: {
253
+ schema: {
254
+ name: 'Bash',
255
+ description: '명령을 실행한다. 되돌릴 수 없는 명령은 막힌다.',
256
+ parameters: {
257
+ type: 'object',
258
+ properties: {
259
+ command: { type: 'string', description: '실행할 명령' },
260
+ description: { type: 'string', description: '무엇을 하는 명령인지 한 줄' },
261
+ timeout: { type: 'number', description: '제한 시간(ms). 기본 120000' },
262
+ },
263
+ required: ['command'],
264
+ },
265
+ },
266
+ async run(args, ctx) {
267
+ const cmd = String(args.command ?? '').trim();
268
+ if (!cmd) return { error: '명령이 비었습니다' };
269
+ try { checkCommand(cmd); }
270
+ catch (err) { ctx.audit.blocked(err.message, cmd); return { error: `막힘 — ${err.message}` }; }
271
+
272
+ const shell = process.platform === 'win32'
273
+ ? { file: process.env.COMSPEC ?? 'cmd.exe', args: ['/d', '/s', '/c', cmd] }
274
+ : { file: '/bin/sh', args: ['-c', cmd] };
275
+
276
+ return new Promise((done) => {
277
+ execFile(shell.file, shell.args, {
278
+ cwd: ctx.scope.root,
279
+ timeout: args.timeout ?? 120000,
280
+ maxBuffer: 8 * 1024 * 1024,
281
+ windowsHide: true,
282
+ encoding: 'utf8',
283
+ }, (err, stdout, stderr) => {
284
+ const out = [stdout, stderr].filter(Boolean).join('\n').trim();
285
+ if (err && err.killed) return done({ error: `시간 초과로 중단됨 (${args.timeout ?? 120000}ms)`, content: clip(out) });
286
+ const code = err?.code ?? 0;
287
+ done({
288
+ content: clip(out || '(출력 없음)'),
289
+ summary: code === 0 ? '성공' : `종료코드 ${code}`,
290
+ failed: code !== 0,
291
+ });
292
+ });
293
+ });
294
+ },
295
+ },
296
+ };
297
+
298
+ // 모델에게 넘길 도구 정의 목록.
299
+ // 스킬이 없으면 Skill 도구는 빼서 자리를 아낀다.
300
+ export function toolSchemas(names = null, { hasSkills = false } = {}) {
301
+ const list = names ?? Object.keys(TOOLS).filter((n) => n !== 'Skill' || hasSkills);
302
+ return list.map((n) => ({ type: 'function', function: TOOLS[n].schema }));
303
+ }
304
+
305
+ export async function runTool(name, args, ctx) {
306
+ const t = TOOLS[name];
307
+ if (!t) return { error: `모르는 도구: ${name}` };
308
+ try {
309
+ const r = await t.run(args ?? {}, ctx);
310
+ ctx.audit.tool(name, args, r);
311
+ return r;
312
+ } catch (err) {
313
+ const r = { error: err.message };
314
+ ctx.audit.tool(name, args, r);
315
+ return r;
316
+ }
317
+ }
package/src/ui/ansi.js ADDED
@@ -0,0 +1,75 @@
1
+ // 화면 출력 기본기 — 색, 커서, 폭 계산. 외부 의존성 없음.
2
+
3
+ // 파이프로 넘길 때도 색을 보고 싶으면 FORCE_COLOR=1
4
+ const ON = (process.stdout.isTTY || process.env.FORCE_COLOR === '1') && process.env.NO_COLOR === undefined;
5
+
6
+ const E = (n) => (s) => (ON ? `\x1b[${n}m${s}\x1b[0m` : String(s));
7
+
8
+ export const c = {
9
+ dim: E(2),
10
+ bold: E(1),
11
+ red: E(31),
12
+ green: E(32),
13
+ yellow: E(33),
14
+ blue: E(34),
15
+ magenta: E(35),
16
+ cyan: E(36),
17
+ gray: E(90),
18
+ bgRed: E(41),
19
+ bgGreen: E(42),
20
+ };
21
+
22
+ export const cursor = {
23
+ hide: () => ON && process.stdout.write('\x1b[?25l'),
24
+ show: () => ON && process.stdout.write('\x1b[?25h'),
25
+ up: (n = 1) => ON && process.stdout.write(`\x1b[${n}A`),
26
+ clearLine: () => ON && process.stdout.write('\x1b[2K\r'),
27
+ };
28
+
29
+ // 한글·한자·가나는 터미널에서 두 칸을 차지한다. 표 정렬이 이걸 모르면 어긋난다.
30
+ export function width(str) {
31
+ let w = 0;
32
+ for (const ch of String(str).replace(/\x1b\[[0-9;]*m/g, '')) {
33
+ const cp = ch.codePointAt(0);
34
+ if (
35
+ (cp >= 0x1100 && cp <= 0x115f) ||
36
+ (cp >= 0x2e80 && cp <= 0xa4cf) ||
37
+ (cp >= 0xac00 && cp <= 0xd7a3) ||
38
+ (cp >= 0xf900 && cp <= 0xfaff) ||
39
+ (cp >= 0xfe30 && cp <= 0xfe6f) ||
40
+ (cp >= 0xff00 && cp <= 0xff60) ||
41
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
42
+ (cp >= 0x1f300 && cp <= 0x1f9ff)
43
+ ) w += 2;
44
+ else w += 1;
45
+ }
46
+ return w;
47
+ }
48
+
49
+ export function pad(str, target, align = 'left') {
50
+ const gap = Math.max(0, target - width(str));
51
+ return align === 'right' ? ' '.repeat(gap) + str : str + ' '.repeat(gap);
52
+ }
53
+
54
+ export const say = (s = '') => process.stdout.write(s + '\n');
55
+
56
+ export function rule(label = '', total = 64) {
57
+ if (!label) return say(c.gray('─'.repeat(total)));
58
+ const left = '── ' + label + ' ';
59
+ say(c.gray(left + '─'.repeat(Math.max(0, total - width(left)))));
60
+ }
61
+
62
+ export function bar(used, total, cells = 32) {
63
+ const ratio = total > 0 ? Math.min(1, used / total) : 0;
64
+ const filled = Math.round(ratio * cells);
65
+ const tone = ratio > 0.85 ? c.red : ratio > 0.6 ? c.yellow : c.green;
66
+ return tone('█'.repeat(filled)) + c.gray('░'.repeat(cells - filled));
67
+ }
68
+
69
+ export const mark = {
70
+ ok: c.green('✓'),
71
+ no: c.red('✗'),
72
+ warn: c.yellow('⚠'),
73
+ dot: c.cyan('⏺'),
74
+ arrow: c.gray('›'),
75
+ };
@@ -0,0 +1,76 @@
1
+ // 입력 받기 — 한 줄, 비밀번호(가림), 목록 선택, 예/아니오.
2
+ import { c, say, mark, cursor } from './ansi.js';
3
+
4
+ function raw() {
5
+ return process.stdin.isTTY ? process.stdin.setRawMode.bind(process.stdin) : null;
6
+ }
7
+
8
+ function readKeys(onKey) {
9
+ return new Promise((resolve) => {
10
+ const setRaw = raw();
11
+ if (setRaw) setRaw(true);
12
+ process.stdin.resume();
13
+ process.stdin.setEncoding('utf8');
14
+ const handler = (chunk) => {
15
+ const done = onKey(chunk, (value) => {
16
+ process.stdin.off('data', handler);
17
+ if (setRaw) setRaw(false);
18
+ process.stdin.pause();
19
+ resolve(value);
20
+ });
21
+ return done;
22
+ };
23
+ process.stdin.on('data', handler);
24
+ });
25
+ }
26
+
27
+ // 한 줄 입력. mask=true 면 ● 로 가린다.
28
+ export function ask(label, { mask = false, def = '' } = {}) {
29
+ const prefix = ` ${c.gray('›')} ${label} `;
30
+ process.stdout.write(prefix + (def ? c.gray(`[${def}] `) : ''));
31
+ let buf = '';
32
+ return readKeys((ch, done) => {
33
+ for (const ch1 of ch) {
34
+ const code = ch1.charCodeAt(0);
35
+ if (code === 3) { say(''); process.exit(130); } // Ctrl+C
36
+ if (code === 13 || code === 10) { // Enter
37
+ say('');
38
+ return done(buf.length ? buf : def);
39
+ }
40
+ if (code === 127 || code === 8) { // Backspace
41
+ if (buf.length) {
42
+ buf = buf.slice(0, -1);
43
+ process.stdout.write('\b \b');
44
+ }
45
+ continue;
46
+ }
47
+ if (code < 32) continue;
48
+ buf += ch1;
49
+ process.stdout.write(mask ? c.gray('●') : ch1);
50
+ }
51
+ });
52
+ }
53
+
54
+ // 목록에서 번호로 고르기.
55
+ // REPL 안에서는 readline 이 stdin 을 쥐고 있으므로 ask 를 갈아끼워 쓴다.
56
+ export async function pick(label, items, { def = 0, ask: askFn = ask } = {}) {
57
+ say('');
58
+ say(` ${c.bold(label)}`);
59
+ items.forEach((it, i) => {
60
+ const tag = i === def ? c.cyan(' ←기본') : '';
61
+ const line = typeof it === 'string' ? it : it.label;
62
+ const note = typeof it === 'object' && it.note ? c.gray(' ' + it.note) : '';
63
+ say(` ${c.cyan(String(i + 1).padStart(2))} ${line}${note}${tag}`);
64
+ });
65
+ say('');
66
+ const raw = await askFn('번호', { def: String(def + 1) });
67
+ const n = parseInt(raw, 10);
68
+ if (!Number.isFinite(n) || n < 1 || n > items.length) return def;
69
+ return n - 1;
70
+ }
71
+
72
+ export async function confirm(label, def = true) {
73
+ const hint = def ? '(Y/n)' : '(y/N)';
74
+ const a = (await ask(`${label} ${c.gray(hint)}`, { def: def ? 'y' : 'n' })).trim().toLowerCase();
75
+ return a === 'y' || a === 'yes' || a === '예' || a === 'ㅇ';
76
+ }
@@ -0,0 +1,32 @@
1
+ // 진행 중 표시. TTY가 아니면 조용히 한 줄만 남긴다.
2
+ import { c, cursor } from './ansi.js';
3
+
4
+ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
5
+
6
+ export function spin(label) {
7
+ // TTY가 아니면(로그로 넘길 때, 사내망 캡처용) 움직이지 않고 줄만 남긴다.
8
+ if (!process.stdout.isTTY) {
9
+ process.stdout.write(` ${label}\n`);
10
+ return {
11
+ stop(finalLine) {
12
+ if (finalLine) process.stdout.write(finalLine + '\n');
13
+ },
14
+ };
15
+ }
16
+ let i = 0;
17
+ cursor.hide();
18
+ const tick = () => {
19
+ cursor.clearLine();
20
+ process.stdout.write(` ${c.cyan(FRAMES[i++ % FRAMES.length])} ${c.gray(label)}`);
21
+ };
22
+ tick();
23
+ const timer = setInterval(tick, 80);
24
+ return {
25
+ stop(finalLine) {
26
+ clearInterval(timer);
27
+ cursor.clearLine();
28
+ cursor.show();
29
+ if (finalLine) process.stdout.write(finalLine + '\n');
30
+ },
31
+ };
32
+ }