deel-local-cli 0.8.0 → 1.0.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.
@@ -1,20 +1,47 @@
1
1
  // 도구 6종. 이름과 인자를 Claude Code 와 같게 맞춘다 —
2
2
  // 그래야 그 관례로 쓰인 스킬·명령이 그대로 먹는다.
3
- import { writeFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
3
+ import { writeFileSync, appendFileSync, readFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
4
4
  import { dirname } from 'node:path';
5
5
  import { execFile } from 'node:child_process';
6
- import { globToRegex, walk, readText, readTextFull, SKIP_DIRS } from './fsutil.js';
7
- import { encode, label as encLabel, decode as decodeBytes, consoleCodepage } from './encoding.js';
8
- import { checkCommand } from '../safety/guard.js';
6
+ import { globToRegex, walk, readText, readTextFull, SKIP_DIRS, 내부살림 } from './fsutil.js';
7
+ import { encode, label as encLabel, decode as decodeBytes, consoleCodepage, looksBinary } from './encoding.js';
8
+ import { checkCommand, checkPaths } from '../safety/guard.js';
9
9
  import { findMatch, applySpans, reindent, TIER_LABELS } from './edit-match.js';
10
10
  import { loadSkill } from '../skills/discover.js';
11
11
  import { WEB_FETCH_TOOL } from './webfetch.js';
12
12
  import { TODO_TOOL } from './todo.js';
13
13
  import { allow as allowedIn } from '../agent/modes.js';
14
+ import { 도구정의, 이름풀기 } from '../backend/mcp.js';
14
15
  import { isExcelPath, readExcel, toText as excelText, summarize as excelSummary } from './excel.js';
16
+ import { diffLines } from '../ui/diff.js';
15
17
 
16
18
  const MAX_READ_LINES = 2000;
17
19
  const MAX_OUT = 30000;
20
+ // Glob 이 한 번에 돌려줄 최대 개수. 넘으면 잘랐다고 말해 준다.
21
+ const GLOB_MAX = 200;
22
+ // Grep 이 열어 볼 파일 크기 상한. 이보다 크면 글 파일이라도 안 본다 —
23
+ // 한 파일에서 몇십 초를 쓰면 그동안 화면이 멈춘 것처럼 보인다.
24
+ const GREP_MAX_FILE = 2 * 1024 * 1024;
25
+ // 정규식으로 찾을 것이 없는 파일들. 열어 봐야 시간만 든다.
26
+ const 안읽을확장자 = /\.(png|jpe?g|gif|bmp|ico|webp|svgz|pdf|zip|gz|tgz|7z|rar|exe|dll|so|dylib|bin|dat|db|sqlite3?|woff2?|ttf|otf|eot|mp[34]|wav|avi|mov|mkv|class|jar|pyc|pyo|o|a|lib|pack|idx|map|min\.js|min\.css|lock)$/i;
27
+ // 이보다 큰 파일은 바뀐 자리를 안 재고 넘어간다. 화면에 못 담을 양이기도 하고,
28
+ // 재는 값보다 기다리는 값이 커진다.
29
+ const MAX_DIFF_CHARS = 4_000_000;
30
+
31
+ /**
32
+ * 고치기 전후를 견줘서 화면에 그릴 거리를 만든다.
33
+ *
34
+ * 여기서 절대 죽으면 안 된다 — 파일은 이미 고쳐졌다. 보여주다 터져서
35
+ * '고쳐졌는지 아닌지 모르는' 상태로 끝나는 게 최악이다. 그래서 통째로 감싼다.
36
+ */
37
+ function 바뀐자리(before, after) {
38
+ try {
39
+ const 양 = (before?.length ?? 0) + (after?.length ?? 0);
40
+ if (양 > MAX_DIFF_CHARS) return null;
41
+ const d = diffLines(before, after);
42
+ return d.changed ? d : null;
43
+ } catch { return null; }
44
+ }
18
45
 
19
46
  function clip(s, n = MAX_OUT) {
20
47
  const t = String(s);
@@ -22,6 +49,61 @@ function clip(s, n = MAX_OUT) {
22
49
  }
23
50
 
24
51
  // 엑셀 파일에 쓰려 할 때 하는 말. 왜 안 되는지와, 그럼 어떻게 하는지를 같이 준다.
52
+ /**
53
+ * 이 자리에 글을 써 넣으면 안 되는 파일인가.
54
+ *
55
+ * 그림·hwp·pdf·zip 처럼 글이 아닌 파일을 Write 로 덮어쓰면 그 파일은 그 순간
56
+ * 끝난다. 확장자만 그대로인 다른 물건이 되어 열리지도 않는다. 되돌리기도
57
+ * 이런 파일은 내용을 떠 놓지 못하니(undo.js safeRead) 되살릴 방법이 없다.
58
+ *
59
+ * 실제로 있던 길이 이랬다 — hwp 를 정리해 달라고 함 → Read 가 '바이너리' 로
60
+ * 실패 → 모델이 Write 로 새로 씀 → 원본 없어짐 → /undo → 잔해까지 사라짐.
61
+ *
62
+ * 확장자 목록으로 고르지 않는다. 사내 파일은 확장자가 제각각이고, 목록에 없는
63
+ * 것이 반드시 나온다. 내용을 보고 정하면 목록을 관리할 일이 없다.
64
+ * @returns {string|null} 막을 이유. 써도 되면 null.
65
+ */
66
+ function 바이너리인가(abs) {
67
+ if (!existsSync(abs)) return null;
68
+ let buf;
69
+ try { buf = readFileSync(abs); } catch { return null; }
70
+ if (!looksBinary(buf)) return null;
71
+ return `글이 아닌 파일입니다 — 덮어쓰면 되살릴 수 없습니다.\n`
72
+ + ' 그림·hwp·pdf·압축파일 같은 것을 글로 덮어쓰면 그 파일은 그대로 끝납니다.\n'
73
+ + ' 되돌리기도 이런 파일은 내용을 떠 두지 못해서 /undo 로도 못 되돌립니다.\n'
74
+ + ' 정말 이 자리를 바꿔야 한다면 사용자에게 직접 물어보고, 다른 이름으로 새로 만드세요.';
75
+ }
76
+
77
+ /** 이 파일이 무슨 인코딩인지. 아직 Read 로 안 읽은 파일을 이어 쓸 때 쓴다. */
78
+ function 재는인코딩(abs) {
79
+ try { return decodeBytes(readFileSync(abs)).encoding; } catch { return 'utf-8'; }
80
+ }
81
+
82
+ /**
83
+ * 이 파일이 지금 실제로 어떤 상태인가.
84
+ *
85
+ * 턴이 끝날 때 '만들었습니다' 라는 말이 사실인지 확인하는 데 쓴다. 모델은
86
+ * 도구가 실패해도 "만들었습니다" 라고 답하는 일이 있다. 사용자는 그 말을
87
+ * 믿고 다음 일로 넘어간다 — 파일은 없는데. 그러니 말이 아니라 디스크를 본다.
88
+ */
89
+ export function 파일현황(abs) {
90
+ try {
91
+ const st = statSync(abs);
92
+ if (st.isDirectory()) return { path: abs, dir: true };
93
+ return { path: abs, bytes: st.size, lines: 줄수(abs, null) };
94
+ } catch { return { path: abs, missing: true }; }
95
+ }
96
+
97
+ /** 지금 파일이 몇 줄인가. 붙인 뒤 '얼마나 찼는지' 를 사실로 말해 주려고 센다. */
98
+ function 줄수(abs, 인코딩) {
99
+ try {
100
+ const buf = readFileSync(abs);
101
+ if (looksBinary(buf)) return 0;
102
+ const t = 인코딩 && 인코딩 !== 'utf-8' ? decodeBytes(buf).text : buf.toString('utf8');
103
+ return t.split('\n').length - (t.endsWith('\n') ? 1 : 0);
104
+ } catch { return 0; }
105
+ }
106
+
25
107
  function 엑셀은못고침(보인이름) {
26
108
  return `엑셀 파일은 이 도구로 고칠 수 없습니다: ${보인이름}\n`
27
109
  + ' 읽기만 됩니다 (CSV 로 바꿔서 보여줍니다). 서식·수식·차트가 든 파일을\n'
@@ -72,6 +154,9 @@ export const TOOLS = {
72
154
  const abs = ctx.scope.resolve(args.file_path);
73
155
  if (!existsSync(abs)) return { error: `파일이 없습니다: ${args.file_path}` };
74
156
  if (statSync(abs).isDirectory()) return { error: `폴더입니다. Glob 을 쓰세요: ${args.file_path}` };
157
+ // 제 살림·남의 도구 살림은 안 읽는다 — fsutil 의 내부살림() 머리말 참고.
158
+ const 막을이유 = 내부살림(abs);
159
+ if (막을이유) return { error: 막을이유 };
75
160
 
76
161
  // 엑셀 파일은 글이 아니라 압축 꾸러미다. 그냥 읽으면 '바이너리' 로 끝난다.
77
162
  // 여기서 표로 바꿔 돌려준다 — 사람이 손으로 CSV 로 내보낼 일이 없게.
@@ -114,12 +199,25 @@ export const TOOLS = {
114
199
  run(args, ctx) {
115
200
  const abs = ctx.scope.resolve(args.file_path);
116
201
  if (typeof args.content !== 'string') return { error: 'content 가 문자열이 아닙니다' };
202
+ // 읽기만 막고 쓰기를 열어 두면 남의 도구 살림을 덮어쓸 수 있다.
203
+ // 제 설정(.deel/config.json)을 덮어쓰면 연결이 통째로 날아간다.
204
+ const 못쓰는이유 = 내부살림(abs);
205
+ if (못쓰는이유) return { error: 못쓰는이유 };
117
206
  // 엑셀 파일을 통째로 덮어쓰면 xlsx 가 아니라 그냥 글 파일이 된다.
118
207
  // 열리지도 않는 파일이 되고, 원본은 이미 없다. 아예 막는다.
119
208
  if (isExcelPath(abs)) return { error: 엑셀은못고침(args.file_path) };
209
+ // 엑셀만 막아서는 모자란다. hwp·pdf·png·zip 도 똑같이 그 순간 끝난다.
210
+ // 게다가 이런 파일은 되돌리기가 내용을 떠 놓지 못하는 종류라 되살릴 길이 없다.
211
+ // 확장자로 고르지 않고 실제 내용으로 본다 — 사내 파일은 확장자가 제각각이다.
212
+ const 바이너리막기 = 바이너리인가(abs);
213
+ if (바이너리막기) return { error: 바이너리막기 };
120
214
  ctx.history.snapshot(abs, 'Write');
121
- mkdirSync(dirname(abs), { recursive: true });
122
215
  const existed = existsSync(abs);
216
+ // 덮어쓰기 전 내용. 바뀐 자리를 보여주려면 지금 떠 놔야 한다.
217
+ // 읽다 터지는 파일(바이너리 등)이면 그냥 없던 셈 친다 — 쓰는 것 자체는 막지 않는다.
218
+ let 이전 = null;
219
+ if (existed) { try { 이전 = readTextFull(abs).text; } catch { 이전 = null; } }
220
+ mkdirSync(dirname(abs), { recursive: true });
123
221
 
124
222
  // 원래 있던 파일이면 그 파일이 쓰던 인코딩으로 되돌려 쓴다.
125
223
  // 새 파일이면 UTF-8 이다 — 요즘 만드는 파일까지 옛 인코딩으로 둘 이유가 없다.
@@ -140,6 +238,74 @@ export const TOOLS = {
140
238
  content: `${existed ? '덮어씀' : '새로 만듦'}: ${ctx.scope.show(abs)} (${n}줄${표기})`,
141
239
  summary: `${n}줄${표기}`,
142
240
  changed: abs,
241
+ diff: 바뀐자리(이전, args.content),
242
+ };
243
+ },
244
+ },
245
+
246
+ Append: {
247
+ schema: {
248
+ name: 'Append',
249
+ description: '파일 끝에 이어 붙인다. 큰 파일은 이렇게 나눠서 만든다.'
250
+ + ' 처음에는 Write 로 앞부분을 만들고, 그 뒤부터는 Append 를 여러 번 불러 끝까지 채운다.'
251
+ + ' 한 번에 다 담으려다 잘리는 것보다 나눠서 확실히 남기는 편이 낫다.'
252
+ + ' Read 로 먼저 읽지 않아도 된다 — 끝에 붙이는 것뿐이라 읽을 이유가 없다.',
253
+ parameters: {
254
+ type: 'object',
255
+ properties: {
256
+ file_path: { type: 'string', description: '이어 붙일 파일 경로' },
257
+ content: { type: 'string', description: '끝에 붙일 내용' },
258
+ },
259
+ required: ['file_path', 'content'],
260
+ },
261
+ },
262
+ run(args, ctx) {
263
+ const abs = ctx.scope.resolve(args.file_path);
264
+ if (typeof args.content !== 'string') return { error: 'content 가 문자열이 아닙니다' };
265
+ if (!args.content) return { error: 'content 가 비었습니다 — 붙일 내용이 없습니다' };
266
+ const 못쓰는이유 = 내부살림(abs);
267
+ if (못쓰는이유) return { error: 못쓰는이유 };
268
+ if (isExcelPath(abs)) return { error: 엑셀은못고침(args.file_path) };
269
+ const 바이너리막기 = 바이너리인가(abs);
270
+ if (바이너리막기) return { error: 바이너리막기 };
271
+
272
+ const existed = existsSync(abs);
273
+ if (existed && statSync(abs).isDirectory()) return { error: `폴더입니다: ${args.file_path}` };
274
+
275
+ // Append 는 한 턴에 여러 번 불리는 것이 정상이다. 그래도 되돌리기 이력에
276
+ // 사본이 쌓이지 않는다 — History.snapshot 이 턴마다 한 번만 뜬다(undo.js).
277
+ ctx.history.snapshot(abs, 'Append');
278
+
279
+ // 원래 있던 파일이면 그 파일이 쓰던 인코딩 그대로 이어 붙인다.
280
+ // 이어 붙이는 조각에는 앞머리 표식(BOM)이 들어가면 안 된다 — 파일 한가운데에
281
+ // BOM 이 박히면 그 자리가 이상한 글자로 보인다. 그래서 표식 없는 이름으로 바꾼다.
282
+ const 원래 = existed ? (ctx.enc?.get(abs) ?? 재는인코딩(abs)) : 'utf-8';
283
+ const 조각인코딩 = 원래 === 'utf-8-bom' ? 'utf-8' : 원래;
284
+ const 만든것 = encode(args.content, 조각인코딩);
285
+ if (만든것.lost.length) {
286
+ return {
287
+ error: `이 파일은 ${encLabel(원래)} 로 되어 있는데, 그 인코딩에 없는 글자가 있습니다: `
288
+ + `${만든것.lost.slice(0, 8).join(' ')}\n`
289
+ + ` 그대로 쓰면 그 글자들이 뭉개집니다. 해당 글자를 빼거나, 파일을 UTF-8 로 바꿔도 되는지 사용자에게 물어보세요.`,
290
+ };
291
+ }
292
+
293
+ mkdirSync(dirname(abs), { recursive: true });
294
+ if (existed) appendFileSync(abs, 만든것.buf);
295
+ else writeFileSync(abs, 만든것.buf);
296
+ ctx.seen.add(abs);
297
+
298
+ const 붙인줄 = args.content.split('\n').length - (args.content.endsWith('\n') ? 1 : 0);
299
+ const 전체줄 = 줄수(abs, 원래);
300
+ const 표기 = 원래 !== 'utf-8' ? ` · ${encLabel(원래)}` : '';
301
+ return {
302
+ content: `${existed ? '이어 붙임' : '새로 만듦'}: ${ctx.scope.show(abs)}`
303
+ + ` (+${붙인줄}줄, 지금 전체 ${전체줄}줄${표기})`,
304
+ summary: `+${붙인줄}줄 · 전체 ${전체줄}줄${표기}`,
305
+ changed: abs,
306
+ // 이어 붙이기는 앞부분이 그대로다. 전후를 통째로 견줄 이유가 없다 —
307
+ // 큰 파일에서 그 비용이 그대로 기다리는 시간이 된다.
308
+ diff: { changed: true, added: 붙인줄, removed: 0, appended: true },
143
309
  };
144
310
  },
145
311
  },
@@ -162,6 +328,8 @@ export const TOOLS = {
162
328
  run(args, ctx) {
163
329
  const abs = ctx.scope.resolve(args.file_path);
164
330
  if (!existsSync(abs)) return { error: `파일이 없습니다: ${args.file_path}` };
331
+ const 못고치는이유 = 내부살림(abs);
332
+ if (못고치는이유) return { error: 못고치는이유 };
165
333
  // 엑셀 파일은 Read 로 읽히긴 하지만 고칠 수 있는 물건이 아니다.
166
334
  // '먼저 Read 로 읽어야 합니다' 라고만 하면 이미 읽은 쪽은 계속 헛돈다.
167
335
  if (isExcelPath(abs)) return { error: 엑셀은못고침(args.file_path) };
@@ -205,6 +373,7 @@ export const TOOLS = {
205
373
  summary: `${n}군데${how}${표기}`,
206
374
  changed: abs,
207
375
  tier: m.tier,
376
+ diff: 바뀐자리(text, next),
208
377
  };
209
378
  },
210
379
  },
@@ -225,14 +394,19 @@ export const TOOLS = {
225
394
  run(args, ctx) {
226
395
  const root = args.path ? ctx.scope.resolve(args.path) : ctx.scope.root;
227
396
  const re = globToRegex(args.pattern);
228
- const files = walk(root)
397
+ const 맞는것 = walk(root)
229
398
  .filter((f) => re.test(f.rel) || re.test(f.rel.split('/').pop()))
230
- .sort((a, b) => b.mtime - a.mtime)
231
- .slice(0, 200);
399
+ .sort((a, b) => b.mtime - a.mtime);
400
+ const files = 맞는것.slice(0, GLOB_MAX);
232
401
  if (!files.length) return { content: `찾은 파일 없음: ${args.pattern}`, summary: '0개' };
402
+ // 잘랐으면 잘랐다고 말한다. 전에는 '200개' 라고만 해서, 모델이 그게 전부인 줄
403
+ // 알고 "전부 확인했습니다" 로 답을 맺었다. 실제로는 1,400개 중 200개였다.
404
+ const 잘림 = 맞는것.length > files.length
405
+ ? `\n\n… 모두 ${맞는것.length}개인데 최근 것 ${files.length}개만 보여 줍니다. 범위를 좁혀 다시 찾으세요.`
406
+ : '';
233
407
  return {
234
- content: files.map((f) => ctx.scope.show(f.path)).join('\n'),
235
- summary: `${files.length}개`,
408
+ content: files.map((f) => ctx.scope.show(f.path)).join('\n') + 잘림,
409
+ summary: 맞는것.length > files.length ? `${files.length}/${맞는것.length}개` : `${files.length}개`,
236
410
  };
237
411
  },
238
412
  },
@@ -276,9 +450,25 @@ export const TOOLS = {
276
450
  const lines = [];
277
451
  let total = 0;
278
452
 
453
+ /*
454
+ * 큰 파일과 글이 아닌 파일은 건너뛴다.
455
+ *
456
+ * 전에는 걸러내지 않고 전부 읽었다. node_modules 는 안 훑지만 dist 에 남은
457
+ * 8MB 번들 하나, .map 파일 몇 개, 그림 몇 장이면 30~60초가 그냥 간다.
458
+ * 그동안 화면은 멈춰 있고 Ctrl+C 도 안 먹는다 — 한 덩어리로 도는 코드라서다.
459
+ *
460
+ * 안에 든 것이 글이 아니면 정규식으로 찾을 것도 없다. 크기와 확장자로
461
+ * 먼저 걸러 내면 같은 결과를 훨씬 빨리 얻는다.
462
+ */
463
+ let 건너뛴것 = 0;
464
+ let 멈춤 = null;
279
465
  for (const f of files) {
466
+ // 도중에 Ctrl+C 를 눌렀으면 여기서 그만둔다. 찾은 데까지는 준다.
467
+ if (ctx.signal?.aborted) { 멈춤 = '중단'; break; }
468
+ if ((f.size ?? 0) > GREP_MAX_FILE) { 건너뛴것++; continue; }
469
+ if (안읽을확장자.test(f.rel)) { 건너뛴것++; continue; }
280
470
  let text;
281
- try { text = readText(f.path); } catch { continue; }
471
+ try { text = readText(f.path); } catch { 건너뛴것++; continue; }
282
472
  const ls = text.split('\n');
283
473
  let n = 0;
284
474
  for (let i = 0; i < ls.length; i++) {
@@ -290,15 +480,23 @@ export const TOOLS = {
290
480
  }
291
481
  }
292
482
  if (n) hitFiles.push({ rel: ctx.scope.show(f.path), n });
293
- if (mode !== 'content' && hitFiles.length >= limit) break;
483
+ if (mode !== 'content' && hitFiles.length >= limit) { 멈춤 = '상한'; break; }
294
484
  }
295
485
 
296
- if (!total) return { content: `일치 없음: ${args.pattern}`, summary: '0건' };
297
- if (mode === 'content') return { content: clip(lines.join('\n')), summary: `${total}건` };
486
+ // 무엇을 봤는지 말해 준다. 그러면 '없다' '못 봤다' 가 구분이 안 된다.
487
+ const 꼬리 = [
488
+ 멈춤 === '중단' ? '(중단하셔서 여기까지만 찾았습니다)' : '',
489
+ 멈춤 === '상한' ? `(${limit}개에서 멈췄습니다 — 더 있을 수 있습니다)` : '',
490
+ 건너뛴것 ? `(글이 아니거나 너무 큰 파일 ${건너뛴것}개는 건너뛰었습니다)` : '',
491
+ ].filter(Boolean).join(' ');
492
+ const 붙이기 = (s) => (꼬리 ? `${s}\n\n${꼬리}` : s);
493
+
494
+ if (!total) return { content: 붙이기(`일치 없음: ${args.pattern}`), summary: '0건' };
495
+ if (mode === 'content') return { content: 붙이기(clip(lines.join('\n'))), summary: `${total}건` };
298
496
  if (mode === 'count') {
299
- return { content: hitFiles.map((f) => `${f.n}\t${f.rel}`).join('\n'), summary: `${hitFiles.length}개 파일` };
497
+ return { content: 붙이기(hitFiles.map((f) => `${f.n}\t${f.rel}`).join('\n')), summary: `${hitFiles.length}개 파일` };
300
498
  }
301
- return { content: hitFiles.map((f) => f.rel).join('\n'), summary: `${hitFiles.length}개 파일 · ${total}건` };
499
+ return { content: 붙이기(hitFiles.map((f) => f.rel).join('\n')), summary: `${hitFiles.length}개 파일 · ${total}건` };
302
500
  },
303
501
  },
304
502
 
@@ -354,24 +552,59 @@ export const TOOLS = {
354
552
  if (!cmd) return { error: '명령이 비었습니다' };
355
553
  try { checkCommand(cmd); }
356
554
  catch (err) { ctx.audit.blocked(err.message, cmd); return { error: `막힘 — ${err.message}` }; }
555
+ // Read 에서 막아 둔 것을 Bash 로 우회할 수 있으면 막아 둔 뜻이 없다.
556
+ // 게이트웨이 열쇠가 든 .deel/config.json 이 그런 자리다. guard.js 머리말 참고.
557
+ try { checkPaths(cmd, ctx.scope); }
558
+ catch (err) { ctx.audit.blocked(err.message, cmd); return { error: `막힘 — ${err.message}` }; }
357
559
 
560
+ /*
561
+ * 명령을 셸에 넘기는 방법. 윈도우에서 여기가 조용히 틀려 있었다.
562
+ *
563
+ * 무슨 일이 있었나:
564
+ * Node 는 인자를 넘길 때 따옴표를 \" 로 바꿔 준다. 그런데 cmd.exe 는
565
+ * \" 를 모른다. 그래서 따옴표가 든 명령이 통째로 뭉개졌다 —
566
+ * node -e "console.log(1)" → 아무것도 안 하고 **종료코드 0**
567
+ * 출력도 없고 오류도 없이 '성공' 이다. 모델은 잘된 줄 알고 넘어간다.
568
+ * `node -e`, `python -c`, `git commit -m "..."` 이 전부 이 자리였다.
569
+ *
570
+ * Node 의 exec() 가 안에서 하는 것과 똑같이 맞춘다 — 명령을 통째로
571
+ * 따옴표로 감싸고, 인자를 손대지 말라고(verbatim) 일러 준다.
572
+ * /s 는 그 감싼 따옴표 한 쌍을 벗기라는 뜻이라 짝이 맞는다.
573
+ */
358
574
  const shell = process.platform === 'win32'
359
- ? { file: process.env.COMSPEC ?? 'cmd.exe', args: ['/d', '/s', '/c', cmd] }
575
+ ? {
576
+ file: process.env.COMSPEC ?? 'cmd.exe',
577
+ args: ['/d', '/s', '/c', `"${cmd}"`],
578
+ verbatim: true,
579
+ }
360
580
  : { file: '/bin/sh', args: ['-c', cmd] };
361
581
 
362
- return new Promise((done) => {
582
+ const 제한 = args.timeout ?? 120000;
583
+ return new Promise((끝) => {
584
+ /*
585
+ * 끝맺음은 한 번만. 그리고 **기다리지 않는다.**
586
+ *
587
+ * 끊었는데도 60초가 걸린 적이 있다. 자식을 죽여도 손자가 파이프를 물고
588
+ * 있으면 execFile 의 콜백이 안 불리기 때문이다. 그래서 죽이라고 시켜 놓고
589
+ * 여기서 바로 끝맺는다 — 뒷정리는 알아서 되게 두고, 사람은 안 기다린다.
590
+ */
591
+ let 끝났나 = false;
592
+ const done = (r) => { if (끝났나) return; 끝났나 = true; 끝(r); };
363
593
  // 출력은 글자가 아니라 바이트로 받는다.
364
594
  //
365
595
  // 윈도우 명령창은 UTF-8 이 아니다. 한국어 윈도우는 CP949 로 뱉는다.
366
596
  // 이걸 utf8 이라고 하고 받으면 한글이 통째로 깨진다 — '파싱 성공' 이
367
597
  // '�Ľ� ����' 이 된다. 바이트로 받아 이 컴퓨터가 쓰는 것으로 해독한다.
368
- execFile(shell.file, shell.args, {
598
+ const kid = execFile(shell.file, shell.args, {
369
599
  cwd: ctx.scope.root,
370
- timeout: args.timeout ?? 120000,
600
+ timeout: 제한,
371
601
  maxBuffer: 8 * 1024 * 1024,
372
602
  windowsHide: true,
603
+ windowsVerbatimArguments: shell.verbatim === true,
373
604
  encoding: 'buffer',
374
605
  }, (err, stdoutBuf, stderrBuf) => {
606
+ clearTimeout(뒷북);
607
+ ctx.signal?.removeEventListener?.('abort', 끊기);
375
608
  const 콘솔 = consoleCodepage() === 65001 ? 'utf-8' : null;
376
609
  const 풀기 = (b) => {
377
610
  if (!b || !b.length) return '';
@@ -381,14 +614,78 @@ export const TOOLS = {
381
614
  const stdout = 풀기(stdoutBuf);
382
615
  const stderr = 풀기(stderrBuf);
383
616
  const out = [stdout, stderr].filter(Boolean).join('\n').trim();
384
- if (err && err.killed) return done({ error: `시간 초과로 중단됨 (${args.timeout ?? 120000}ms)`, content: clip(out) });
385
- const code = err?.code ?? 0;
617
+
618
+ if (끊겼나) return done({ error: '사용자가 중단했습니다', content: clip(out) });
619
+ if (err && err.killed) return done({ error: `시간 초과로 중단됨 (${제한}ms)`, content: clip(out) });
620
+
621
+ /*
622
+ * 결과를 사실대로 말한다.
623
+ *
624
+ * 전에는 err.code ?? 0 이었다. 그런데 프로세스가 **시그널로 죽으면**
625
+ * code 가 없고 signal 만 있다 — 그러면 0 이 되어 '성공' 으로 넘어갔다.
626
+ * 빌드가 메모리 부족으로 죽었는데 모델은 "빌드 확인했습니다" 라고 답한다.
627
+ *
628
+ * 종료코드도 모델에게 준다. 전에는 화면에만 적고 대화에는 안 실었다.
629
+ * 그러면 모델은 명령이 실패한 줄 모른 채 다음 단계로 넘어간다.
630
+ */
631
+ const 시그널 = err?.signal ?? null;
632
+ const code = 시그널 ? null : (err?.code ?? 0);
633
+ const 잘됨 = !시그널 && code === 0;
634
+ const 꼬리 = 잘됨 ? '' : 시그널 ? `\n\n[${시그널} 시그널로 죽었습니다 — 정상 종료가 아닙니다]` : `\n\n[종료코드 ${code}]`;
386
635
  done({
387
- content: clip(out || '(출력 없음)'),
388
- summary: code === 0 ? '성공' : `종료코드 ${code}`,
389
- failed: code !== 0,
636
+ content: clip(out || '(출력 없음)') + 꼬리,
637
+ summary: 잘됨 ? '성공' : 시그널 ? `${시그널} 로 죽음` : `종료코드 ${code}`,
638
+ failed: !잘됨,
639
+ exitCode: code,
640
+ signal: 시그널,
390
641
  });
391
642
  });
643
+
644
+ /*
645
+ * Ctrl+C 로 도는 명령을 멈춘다.
646
+ *
647
+ * 전에는 못 멈췄다. `▶ Bash(npm run dev)` 뒤로 화면이 영영 멈춰 있고,
648
+ * Ctrl+C 는 다음 요청에나 반영됐다. 터미널을 닫는 수밖에 없었다.
649
+ */
650
+ let 끊겼나 = false;
651
+ const 죽이기 = () => {
652
+ // 윈도우에서는 자식만 죽이면 손자가 남는다. 트리째 끝내야 한다.
653
+ // 그리고 **트리 죽이기를 먼저** 한다 — cmd 를 먼저 죽이면 트리의 뿌리가
654
+ // 없어져서 taskkill 이 손자를 못 찾는다. 그러면 손자가 그대로 남아 돈다.
655
+ if (process.platform === 'win32' && kid.pid) {
656
+ try { execFile('taskkill', ['/pid', String(kid.pid), '/t', '/f'], { windowsHide: true }, () => {}); } catch {}
657
+ }
658
+ try { kid.kill(); } catch {}
659
+ // 죽이라고 시켰다고 곧바로 죽는 것은 아니다. 그동안 이 프로세스가
660
+ // 그 손을 붙들고 있으면 deel 을 끝내도 안 끝난다 — 실제로 검사가
661
+ // 60초를 더 기다렸다. 놓아 주고 우리 갈 길을 간다.
662
+ try { kid.unref(); } catch {}
663
+ try { kid.stdout?.destroy(); kid.stderr?.destroy(); } catch {}
664
+ };
665
+ const 끊기 = () => {
666
+ 끊겼나 = true;
667
+ 죽이기();
668
+ clearTimeout(뒷북);
669
+ // 콜백을 기다리지 않는다. 손자가 파이프를 물고 있으면 안 불릴 수도 있다.
670
+ done({ error: '사용자가 중단했습니다' });
671
+ };
672
+ if (ctx.signal?.aborted) 끊기();
673
+ else ctx.signal?.addEventListener?.('abort', 끊기, { once: true });
674
+
675
+ /*
676
+ * execFile 의 timeout 을 못 믿는 자리가 있다.
677
+ *
678
+ * 손자 프로세스가 파이프를 물고 있으면 부모를 죽여도 stdout 이 안 닫혀서
679
+ * 콜백이 안 불린다. 그러면 제 시간 제한이 있는데도 영영 안 끝난다.
680
+ * 그래서 우리 쪽에서도 시계를 하나 걸고, 넘으면 트리째 끝낸다.
681
+ */
682
+ const 뒷북 = setTimeout(() => {
683
+ if (끝났나 || 끊겼나) return;
684
+ 죽이기();
685
+ done({ error: `시간 초과로 중단됨 (${제한}ms) — 자식 프로세스가 안 끝나 강제로 끝냈습니다` });
686
+ }, 제한 + 2000);
687
+ // 이 시계 때문에 프로그램이 안 끝나면 안 된다.
688
+ 뒷북.unref?.();
392
689
  });
393
690
  },
394
691
  },
@@ -397,12 +694,111 @@ export const TOOLS = {
397
694
  WebFetch: WEB_FETCH_TOOL,
398
695
 
399
696
  // 긴 작업에서 시킨 것을 빠뜨리지 않게 붙잡아 두는 목록.
697
+ /*
698
+ * 지난 대화 찾기.
699
+ *
700
+ * 왜 도구로도 주나: 사람만 쓰는 /recall 로 두면 **모델이 스스로 못 찾는다.**
701
+ * "저번에 정한 대로 해줘" 같은 말에 모델이 할 수 있는 게 되묻는 것뿐이 된다.
702
+ * 도구로 주면 스스로 지난 대화를 뒤져 그때 정한 것을 갖고 온다 —
703
+ * 대화를 남기는 일이 그제서야 값을 한다.
704
+ *
705
+ * 이 폴더의 기록만 본다. 작업 범위 밖은 애초에 읽을 수 없다.
706
+ */
707
+ Recall: {
708
+ schema: {
709
+ name: 'Recall',
710
+ description: '이 폴더의 지난 대화에서 찾는다. "저번에" 처럼 앞선 대화를 가리키면 되묻지 말고 이걸 쓴다.'
711
+ + ' 파일 내용을 찾는 것이 아니다 — 파일은 Grep 이다.',
712
+ parameters: {
713
+ type: 'object',
714
+ properties: {
715
+ query: { type: 'string', description: '찾을 말. 낱말 두세 개 (예: "CP949 인코딩")' },
716
+ limit: { type: 'number', description: '가져올 개수 (기본 8)' },
717
+ tools: { type: 'boolean', description: '도구 결과까지 뒤질지 (기본 false)' },
718
+ },
719
+ required: ['query'],
720
+ },
721
+ },
722
+ async run(args, ctx) {
723
+ const { 찾기 } = await import('../agent/recall.js');
724
+ const q = String(args.query ?? '').trim();
725
+ if (!q) return { error: 'query 가 비었습니다' };
726
+
727
+ const r = 찾기(ctx.scope.root, q, {
728
+ limit: Math.min(20, Math.max(1, Number(args.limit) || 8)),
729
+ 도구결과까지: args.tools === true,
730
+ });
731
+
732
+ if (!r.맞은것.length) {
733
+ // 못 찾은 것과 안 찾아본 것은 다르다. 예산에 걸려 멈췄으면 그렇다고 말한다 —
734
+ // 안 그러면 모델이 "그런 대화 없었습니다" 라고 단정한다.
735
+ const 왜 = r.예산초과
736
+ ? `지난 대화 ${r.전체파일}개 중 ${r.본파일}개까지만 뒤졌습니다(양이 많아 멈춤). 못 찾았습니다`
737
+ : `지난 대화 ${r.본파일}개를 다 뒤졌지만 없습니다`;
738
+ return { summary: `${왜}: ${q}`, hits: [], searched: r.본파일, total: r.전체파일, partial: r.예산초과 };
739
+ }
740
+
741
+ const 줄들 = r.맞은것.map((h) => {
742
+ const 날 = h.언제 instanceof Date ? h.언제.toISOString().slice(0, 16).replace('T', ' ') : '';
743
+ return `[${h.세션} · ${날} · ${h.누구}] ${h.토막}`;
744
+ });
745
+ return {
746
+ summary: `지난 대화에서 ${r.전체맞음}건 중 ${r.맞은것.length}건`
747
+ + (r.예산초과 ? ` (${r.전체파일}개 중 ${r.본파일}개만 뒤짐)` : ''),
748
+ hits: r.맞은것.map((h) => ({ session: h.세션, when: h.언제, who: h.누구, text: h.토막 })),
749
+ text: 줄들.join('\n'),
750
+ searched: r.본파일,
751
+ total: r.전체파일,
752
+ partial: r.예산초과,
753
+ };
754
+ },
755
+ },
756
+
757
+ /*
758
+ * 기억하기.
759
+ *
760
+ * 왜 도구인가: 사람이 /memory 로 적게 하면 아무도 안 적는다. 지금 막 정한
761
+ * 것을 기억할지 말지 판단할 수 있는 것은 그 자리에 있는 모델뿐이다.
762
+ *
763
+ * 왜 짧게 쓰라고 못을 박나: 여기 적힌 것은 **매 요청마다** 통째로 나간다.
764
+ * 모델은 그걸 모르고 파일 내용을 통째로 넣으려 든다. 그러면 기억이
765
+ * 컨텍스트를 먹어 정작 일할 자리가 줄어든다.
766
+ */
767
+ Remember: {
768
+ schema: {
769
+ name: 'Remember',
770
+ description: '대화가 끝나도 남길 것을 한 줄로 적는다. 사용자가 정한 규칙·약속·되풀이하면 안 되는 실수.'
771
+ + ' 이번 일에서만 쓰는 것이나 파일을 읽으면 아는 것은 안 적는다.'
772
+ + ' 이 글은 앞으로 모든 요청에 실린다 — 한 문장으로.',
773
+ parameters: {
774
+ type: 'object',
775
+ properties: {
776
+ text: { type: 'string', description: '한 줄 (예: "사내 문서는 CP949 로 읽고 CP949 로 되돌려 쓴다")' },
777
+ },
778
+ required: ['text'],
779
+ },
780
+ },
781
+ async run(args, ctx) {
782
+ const { 더하기 } = await import('../agent/memory.js');
783
+ const r = 더하기(ctx.scope.root, args.text);
784
+ if (!r.ok) return { summary: r.why, remembered: false };
785
+ return {
786
+ summary: `기억했습니다 (${r.줄수}줄)` + (r.넘침 ? ' · 자리가 차서 오래된 것을 뺐습니다' : ''),
787
+ remembered: true,
788
+ line: r.줄,
789
+ // 화면에 무엇을 적었는지 보여 주려고 같이 넘긴다. 사람이 못 보면
790
+ // 틀린 기억이 조용히 쌓인다 — 그게 제일 나쁘다.
791
+ content: r.줄,
792
+ };
793
+ },
794
+ },
795
+
400
796
  TodoWrite: TODO_TOOL,
401
797
  };
402
798
 
403
799
  // 모델에게 넘길 도구 정의 목록.
404
800
  // 스킬이 없으면 Skill 도구는 빼서 자리를 아낀다.
405
- export function toolSchemas(names = null, { hasSkills = false, web = true, work = null } = {}) {
801
+ export function toolSchemas(names = null, { hasSkills = false, web = true, work = null, mcp = null } = {}) {
406
802
  let list = names ?? Object.keys(TOOLS).filter((n) => {
407
803
  if (n === 'Skill') return hasSkills;
408
804
  if (n === 'WebFetch') return web;
@@ -413,10 +809,27 @@ export function toolSchemas(names = null, { hasSkills = false, web = true, work
413
809
  // 설계·계획·묻기 모드에서 파일을 바꾸면 안 된다고 프롬프트로 부탁할 수도 있다.
414
810
  // 그런데 모델은 부탁을 잊는다. 목록에서 아예 빼면 잊을 것이 없다.
415
811
  if (work) list = allowedIn(work, list);
416
- return list.map((n) => ({ type: 'function', function: TOOLS[n].schema }));
812
+ const 우리것 = list.map((n) => ({ type: 'function', function: TOOLS[n].schema }));
813
+
814
+ /*
815
+ * 밖에서 붙인 도구(MCP)를 뒤에 붙인다.
816
+ *
817
+ * 읽기만 하는 모드(설계·계획·묻기)에서는 안 준다. MCP 서버가 무엇을 하는지
818
+ * 우리는 모른다 — 이름이 search 여도 파일을 쓸 수 있다. 파일을 안 바꾸기로
819
+ * 한 모드에서 '모르는 것' 을 쥐여 주면 그 약속이 약속이 아니게 된다.
820
+ */
821
+ if (mcp?.length && (!work || allowedIn(work, ['Write']).length)) 우리것.push(...도구정의(mcp));
822
+ return 우리것;
417
823
  }
418
824
 
419
825
  export async function runTool(name, args, ctx) {
826
+ // 밖에서 붙인 도구(MCP)는 이름 앞머리로 갈린다.
827
+ //
828
+ // 여기서 먼저 갈라야 하는 이유: MCP 서버는 우리 scope 를 안 지킨다.
829
+ // 남의 프로세스라 파일을 제 마음대로 읽고 쓸 수 있다. 우리 도구인 척
830
+ // 섞이면 "이 폴더 밖은 못 건드린다" 는 말이 거짓이 된다.
831
+ if (name.startsWith('mcp__')) return await runMcpTool(name, args, ctx);
832
+
420
833
  const t = TOOLS[name];
421
834
  if (!t) return { error: `모르는 도구: ${name}` };
422
835
  try {
@@ -429,3 +842,34 @@ export async function runTool(name, args, ctx) {
429
842
  return r;
430
843
  }
431
844
  }
845
+
846
+ /**
847
+ * MCP 서버가 준 도구를 부른다.
848
+ *
849
+ * 실패해도 턴을 죽이지 않는다. 남의 프로그램이라 언제든 죽을 수 있고, 그때마다
850
+ * 대화가 끝나 버리면 쓸 수가 없다. 오류를 **결과로** 돌려주면 모델이 그걸 읽고
851
+ * 다른 길을 찾는다.
852
+ *
853
+ * 감사기록에는 우리 도구와 똑같이 남긴다 — 오히려 이쪽이 더 남아야 한다.
854
+ * 남의 프로그램이 무엇을 했는지가 반입 심사에서 물어볼 바로 그것이다.
855
+ */
856
+ async function runMcpTool(name, args, ctx) {
857
+ const 갈린것 = 이름풀기(name);
858
+ const r = await (async () => {
859
+ if (!갈린것) return { error: `${name} 은 MCP 도구 이름 꼴이 아닙니다` };
860
+ const 서버 = (ctx.mcp ?? []).find((s) => s.이름 === 갈린것.서버);
861
+ if (!서버) return { error: `${갈린것.서버} 서버가 붙어 있지 않습니다 — /mcp 로 확인하세요` };
862
+ if (!서버.살아있나()) return { error: `${갈린것.서버} 서버가 죽었습니다: ${서버.죽음 ?? '이유 모름'}` };
863
+ try {
864
+ const out = await 서버.부르기(갈린것.도구, args);
865
+ if (out.isError) return { error: out.text || '도구가 오류를 냈습니다' };
866
+ const 글 = out.text ?? '';
867
+ const 줄 = 글 ? 글.split(/\r?\n/).length : 0;
868
+ return { summary: 글 ? `${줄}줄 · ${글.length.toLocaleString()}자` : '빈 답', content: clip(글) };
869
+ } catch (e) {
870
+ return { error: e.message };
871
+ }
872
+ })();
873
+ ctx.audit.tool(name, args, r);
874
+ return r;
875
+ }