deel-local-cli 1.5.8 → 1.6.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.
Files changed (59) hide show
  1. package/README.en.md +30 -14
  2. package/README.md +28 -12
  3. package/bin/deel.js +10 -0
  4. package/package.json +2 -2
  5. package/src/acp/map.js +143 -0
  6. package/src/acp/serve.js +166 -13
  7. package/src/agent/commit.js +511 -0
  8. package/src/agent/compact.js +269 -226
  9. package/src/agent/loop.js +196 -12
  10. package/src/agent/mention.js +56 -10
  11. package/src/agent/review.js +192 -0
  12. package/src/agent/session.js +22 -2
  13. package/src/agent/threads.js +1 -1
  14. package/src/backend/adapter.js +342 -280
  15. package/src/backend/azure.js +151 -0
  16. package/src/backend/ctxsize.js +19 -0
  17. package/src/backend/detect.js +106 -0
  18. package/src/backend/http.js +350 -30
  19. package/src/backend/probe.js +48 -1
  20. package/src/backend/proxy.js +151 -0
  21. package/src/backend/quota.js +133 -0
  22. package/src/backend/retry.js +132 -0
  23. package/src/backend/vision.js +185 -0
  24. package/src/commands.js +341 -8
  25. package/src/completion.js +264 -0
  26. package/src/config.js +117 -1
  27. package/src/i18n/en.js +13 -1
  28. package/src/i18n/index.js +22 -3
  29. package/src/i18n/ja.js +266 -0
  30. package/src/i18n/ko.js +12 -0
  31. package/src/i18n/zh.js +266 -0
  32. package/src/oneshot.js +28 -3
  33. package/src/pack/sbom.js +25 -0
  34. package/src/pack/selfpack.js +9 -3
  35. package/src/plugins/manage.js +34 -7
  36. package/src/repl.js +92 -6
  37. package/src/report.js +26 -2
  38. package/src/safety/guard.js +74 -2
  39. package/src/safety/keystore.js +237 -0
  40. package/src/safety/network.js +76 -14
  41. package/src/safety/policy.js +209 -0
  42. package/src/safety/secrets.js +32 -0
  43. package/src/setup.js +40 -7
  44. package/src/tools/clipboard.js +178 -0
  45. package/src/tools/convert.js +287 -0
  46. package/src/tools/docs.js +72 -1
  47. package/src/tools/fastgrep.js +217 -0
  48. package/src/tools/fsutil.js +55 -12
  49. package/src/tools/ignore.js +192 -0
  50. package/src/tools/index.js +391 -41
  51. package/src/tools/jobs.js +4 -12
  52. package/src/tools/outline.js +3 -1
  53. package/src/tools/pdf.js +1368 -0
  54. package/src/tools/shell.js +123 -0
  55. package/src/tools/verify.js +20 -4
  56. package/src/tools/webfetch.js +18 -5
  57. package/src/ui/inputbox.js +2 -2
  58. package/src/ui/screen.js +4 -1
  59. package/src/ui/status.js +22 -0
@@ -1,12 +1,14 @@
1
1
  // 도구. 이름과 인자를 Claude Code 와 같게 맞춘다 —
2
2
  // 그래야 그 관례로 쓰인 스킬·명령이 그대로 먹는다.
3
3
  import { writeFileSync, appendFileSync, readFileSync, existsSync, mkdirSync, statSync, renameSync, cpSync, rmSync } from 'node:fs';
4
- import { dirname, join, relative, sep } from 'node:path';
4
+ import { dirname, extname, join, relative, sep } from 'node:path';
5
5
  import { execFile } from 'node:child_process';
6
6
  import { globToRegex, walk, readText, readTextFull, 내부살림 } from './fsutil.js';
7
+ import { 건너뜀말 } from './ignore.js';
7
8
  import { encode, label as encLabel, decode as decodeBytes, consoleCodepage, looksBinary } from './encoding.js';
8
9
  import { checkCommand, checkPaths, isMutating } from '../safety/guard.js';
9
10
  import { 띄우기, JOBS_TOOL } from './jobs.js';
11
+ import { 셸명령 } from './shell.js';
10
12
  import { findMatch, applySpans, reindent, TIER_LABELS } from './edit-match.js';
11
13
  import { loadSkill } from '../skills/discover.js';
12
14
  import { WEB_FETCH_TOOL } from './webfetch.js';
@@ -21,9 +23,13 @@ import { allow as allowedIn } from '../agent/modes.js';
21
23
  import { 도구정의, 이름풀기 } from '../backend/mcp.js';
22
24
  import { isExcelPath, readExcel, toText as excelText, summarize as excelSummary } from './excel.js';
23
25
  import { isDocPath, readDoc, toText as docText, summarize as docSummary, looksOldHwp, 옛hwp안내, 문서는못고침 } from './docs.js';
26
+ import { 바꿔볼까, 직접못읽나, 변환기찾기, 글로바꾸기, 못바꿈말 } from './convert.js';
27
+ import { isPdfPath, readPdf, toText as pdfText, summarize as pdfSummary, 못읽은말, pdf는못고침, 한쪽도못읽음말 } from './pdf.js';
24
28
  import { diffLines } from '../ui/diff.js';
25
29
  import { 읽을줄수, 찾을개수, 찾을줄수, 설명길이 } from '../agent/budget.js';
26
30
  import { 도구설명EN } from './desc.en.js';
31
+ import { 그림인가, 그림읽기, 크기말, 기본한도 } from '../backend/vision.js';
32
+ import { 빠르게찾기, 엔진말, 안볼정규식 } from './fastgrep.js';
27
33
  import { 지시말 } from '../i18n/index.js';
28
34
 
29
35
  /*
@@ -41,7 +47,9 @@ const MAX_OUT = 30000;
41
47
  // 한 파일에서 몇십 초를 쓰면 그동안 화면이 멈춘 것처럼 보인다.
42
48
  const GREP_MAX_FILE = 2 * 1024 * 1024;
43
49
  // 정규식으로 찾을 것이 없는 파일들. 열어 봐야 시간만 든다.
44
- 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;
50
+ // 목록은 tools/fastgrep.js 에 한 벌만 둔다 — rg 도 같은 목록으로 걸러야
51
+ // 엔진이 달라도 같은 파일을 본다.
52
+ const 안읽을확장자 = 안볼정규식;
45
53
  // 이보다 큰 파일은 바뀐 자리를 안 재고 넘어간다. 화면에 못 담을 양이기도 하고,
46
54
  // 재는 값보다 기다리는 값이 커진다.
47
55
  const MAX_DIFF_CHARS = 4_000_000;
@@ -116,6 +124,56 @@ export function 붙박이그림줄이기(줄들) {
116
124
  return { 줄들: 나온것, 알림, 줄인바이트, 몇개 };
117
125
  }
118
126
 
127
+ /*
128
+ * ── 안 보여 준 것을 지우게 두면 안 된다 ─────────────────────────────────
129
+ *
130
+ * 위 붙박이그림줄이기 는 **읽을 때만** 그림을 뺀다. 파일은 그대로다. 그런데
131
+ * 그 다음이 문제였다 — 모델은 그렇게 받은 글을 손봐서 Write 로 통째로 덮어쓴다.
132
+ * 그러면 제가 못 본 그림은 새 내용에 없다. **사용자 파일에서 그림이 사라진다.**
133
+ *
134
+ * 실제로 이렇게 났다 (사내 보고서 HTML):
135
+ *
136
+ * ◧ Read(보고서.html) 1425줄 · 그림 8.0MB 생략
137
+ * ◈ Write(보고서.html) 1420줄 ← 8MB 짜리 그림 일곱 개가 여기서 없어진다
138
+ *
139
+ * 오류는 없다. 화면도 멀쩡하다. 파일을 열어 봐야 안다 — 그림이 있던 자리에
140
+ * 깨진 아이콘만 남는다. 몇 주 뒤에 알아채면 되돌릴 방법도 없다.
141
+ *
142
+ * 안 보여 준 것에 대한 책임은 우리에게 있다. 모델은 최선을 다한 것이다 —
143
+ * 있는 줄 몰랐던 것을 지킬 수는 없다. 그러니 여기서 막는다.
144
+ *
145
+ * 세는 방식이 중요하다. **생략 표시가 있나** 로만 보면 모자란다. 모델이 그
146
+ * 줄을 통째로 지워 버리는 경우가 더 흔하고, 그때는 표시도 같이 사라진다.
147
+ * 그래서 **개수를 견준다** — 있던 것보다 줄었으면 잃은 것이다.
148
+ */
149
+ export function 박힌그림수(글) {
150
+ return (String(글 ?? '').match(그림자리) ?? []).length;
151
+ }
152
+
153
+ /**
154
+ * 이 덮어쓰기가 박힌 그림을 잃게 하나.
155
+ *
156
+ * @returns {string|null} 막을 이유. 잃는 것이 없으면 null.
157
+ */
158
+ function 그림잃나(abs, 새내용) {
159
+ if (!existsSync(abs)) return null;
160
+ let 옛것;
161
+ try { 옛것 = readTextFull(abs).text; } catch { return null; }
162
+ const 있던것 = 박힌그림수(옛것);
163
+ if (!있던것) return null;
164
+ const 남는것 = 박힌그림수(새내용);
165
+ if (남는것 >= 있던것) return null;
166
+
167
+ const 잃는것 = 있던것 - 남는것;
168
+ const 생략표시 = /…\([\d.]+\s*[KMG]?B 생략\)…/.test(새내용);
169
+ return `이 파일에 박혀 있는 그림 ${있던것}개 중 ${잃는것}개가 새 내용에 없습니다 — 덮어쓰면 사라집니다.\n`
170
+ + (생략표시
171
+ ? ' Read 가 보여준 「…(생략)…」 표시를 그대로 되쓰셨습니다. 그건 그림이 아니라 자리 표시입니다.\n'
172
+ : ' Read 는 그림 자리를 빼고 보여줍니다. 못 본 것이라 새로 쓸 때 빠진 것입니다.\n')
173
+ + ' 통째로 덮어쓰지 말고 **Edit 으로 고칠 자리만** 바꾸세요. 그러면 그림은 파일에 그대로 남습니다.\n'
174
+ + ' 정말 그림을 빼는 것이 목적이면 사용자에게 먼저 확인하세요.';
175
+ }
176
+
119
177
  // 엑셀 파일에 쓰려 할 때 하는 말. 왜 안 되는지와, 그럼 어떻게 하는지를 같이 준다.
120
178
  /**
121
179
  * 이 자리에 글을 써 넣으면 안 되는 파일인가.
@@ -272,7 +330,18 @@ function 엑셀은못고침(보인이름) {
272
330
  */
273
331
  async function 엑셀읽기(abs, args, ctx) {
274
332
  const r = await readExcel(abs, { askPassword: ctx.askPassword ?? null });
275
- if (!r.ok) return { error: r.error };
333
+ if (!r.ok) {
334
+ /*
335
+ * 옛 `.xls` 는 엑셀 COM 말고는 길이 없었다. 그래서 엑셀이 없는 PC —
336
+ * 맥과 리눅스가 전부 여기다 — 에서는 그냥 실패로 끝났다. 이 PC 에
337
+ * LibreOffice 가 있으면 그것이 곧 길이다. 표는 아니고 글이지만,
338
+ * 아무것도 못 읽는 것보다 낫다.
339
+ */
340
+ const 빌린것 = 빌려읽기(abs, ctx, r.error);
341
+ if (빌린것) return 빌린것;
342
+ if (직접못읽나(abs)) return { error: 못바꿈말(ctx.scope.show(abs), extname(abs)), 끝났다: true };
343
+ return { error: r.error };
344
+ }
276
345
 
277
346
  const { text, 잘림 } = excelText(r.sheets);
278
347
  const 말 = [...(r.notes ?? []), ...잘림];
@@ -292,9 +361,55 @@ async function 엑셀읽기(abs, args, ctx) {
292
361
  * 엑셀읽기와 같은 규칙 — ctx.seen 에 안 넣는다. 넣으면 Edit 이 '이 파일 고칠
293
362
  * 수 있다' 고 오해한다. 문서는 이 도구로 고치는 물건이 아니다.
294
363
  */
364
+ /**
365
+ * 우리가 못 읽는 문서를 **이미 깔린 변환기**로 한 번 더 해 본다 (tools/convert.js).
366
+ *
367
+ * `rg` 를 빌려 쓰는 것과 같은 원칙이다 — 아무것도 설치하지 않고, 있으면 쓰고,
368
+ * 없으면 없다고 말한다. 여기가 붙기 전에는 이렇게 끝났다:
369
+ *
370
+ * ◧ Read(보고서.pptx)
371
+ * └ pptx 모양이 아닙니다 — 깨졌거나 다른 형식입니다.
372
+ *
373
+ * 정작 그 PC 에는 LibreOffice 가 깔려 있었다. 사람이 그 파일을 열어 보는 바로
374
+ * 그 프로그램이다. 그런데 모델은 그걸 부를 수도 없었고(울타리에 막혔다),
375
+ * 있는지 볼 수도 없었다.
376
+ *
377
+ * @returns {object|null} 읽어냈으면 도구 결과. 못 하면 null (부르는 쪽이 원래 오류를 낸다)
378
+ */
379
+ function 빌려읽기(abs, ctx, 원래오류) {
380
+ if (!바꿔볼까(abs)) return null;
381
+ const root = ctx.scope?.root;
382
+ if (!root) return null;
383
+
384
+ // ctx.변환기 는 검사에서 가짜 변환기를 끼우는 자리다. 진짜 LibreOffice 를
385
+ // 깔아야만 확인되는 검사는 아무도 안 돌린다.
386
+ const 있는것 = ctx.변환기 ?? 변환기찾기();
387
+ if (!있는것.soffice && !있는것.textutil) return null;
388
+
389
+ const r = 글로바꾸기(abs, root, { 찾은것: 있는것 });
390
+ if (!r.ok || !r.text.trim()) return null;
391
+
392
+ const 줄수 = r.text.split('\n').length;
393
+ return {
394
+ content: clip(
395
+ `${r.text}\n\n(deel 이 직접 못 읽는 형식이라 이 PC 의 ${r.쓴것} 로 글만 뽑아 보여준 것입니다.`
396
+ + ' 원본은 한 글자도 안 바뀌었습니다. 이 파일은 Edit/Write 로 고칠 수 없습니다 —'
397
+ + ` 고쳐야 하면 새 파일에 쓰세요.)\n(원래 못 읽은 까닭: ${String(원래오류).split('\n')[0]})`,
398
+ ),
399
+ summary: `${r.쓴것} 로 바꿔 읽음 · ${줄수}줄`,
400
+ };
401
+ }
402
+
295
403
  function 문서읽기(abs, ctx) {
296
404
  const r = readDoc(abs);
297
- if (!r.ok) return { error: r.error };
405
+ if (!r.ok) {
406
+ // 우리가 못 읽는다고 끝이 아니다. 이 PC 에 변환기가 있으면 빌려 본다.
407
+ const 빌린것 = 빌려읽기(abs, ctx, r.error);
408
+ if (빌린것) return 빌린것;
409
+ // 끝났다 를 그대로 넘긴다. 여기서 떨구면 docs.js 가 「다시 열어도 같다」고
410
+ // 판정해 놓은 것이 루프까지 못 가서, 되풀이 억제가 안 걸린다.
411
+ return { error: r.error, ...(r.끝났다 ? { 끝났다: true } : {}) };
412
+ }
298
413
  const { text, 잘림 } = docText(r.덩이들);
299
414
  return {
300
415
  content: clip(
@@ -308,6 +423,44 @@ function 문서읽기(abs, ctx) {
308
423
  };
309
424
  }
310
425
 
426
+ /**
427
+ * PDF 를 쪽마다 글로 읽어 돌려준다.
428
+ *
429
+ * 문서읽기와 같은 규칙 — ctx.seen 에 안 넣는다(고칠 수 있는 물건이 아니다).
430
+ * 다른 점은 **못 읽은 쪽을 반드시 말한다**는 것이다. PDF 는 글이 아예 안 든
431
+ * 쪽(스캔본)이 흔해서, 빈 글을 그냥 돌려주면 「그런 내용이 없는 문서」로
432
+ * 읽힌다. 모델은 그걸 근거로 답하고, 사람은 그 답을 믿는다.
433
+ */
434
+ function pdf읽기(abs, ctx) {
435
+ const r = readPdf(abs);
436
+ if (!r.ok) return { error: r.error };
437
+ const { text, 잘림 } = pdfText(r);
438
+ const 못 = 못읽은말(r);
439
+ /*
440
+ * 한 쪽도 못 읽었으면 **실패다.** 성공으로 돌려주면 안 된다.
441
+ *
442
+ * 여태 「글이 하나도 없는 PDF 입니다」를 본문으로 돌려줬다. 그러면 도구는
443
+ * 성공한 것이 되어 되풀이 감지도, 배움도, 재시도 억제도 하나도 안 걸린다.
444
+ * 그래서 모델은 같은 파일을 몇 번씩 다시 열었고, 그때마다 같은 답을 받았다.
445
+ */
446
+ // text 로 재면 안 된다 — 못 읽은 쪽에도 `--- 3쪽 ---` 와 「못 읽었습니다」가
447
+ // 들어가서 글이 있는 것처럼 보인다. 실제 문단이 하나라도 나왔는지로 가른다.
448
+ const 글나온쪽 = (r.덩이들 ?? []).some((d) => (d.문단들 ?? []).some((p) => String(p).trim()));
449
+ if (!글나온쪽) {
450
+ return { error: 한쪽도못읽음말(r, ctx.scope.show(abs)), 끝났다: true };
451
+ }
452
+ return {
453
+ content: clip(
454
+ `${text || '(글이 하나도 없는 PDF 입니다.)'}
455
+
456
+ (PDF 를 쪽마다 글로 바꿔서 보여준 것입니다. 이 파일은 Edit/Write 로 고칠 수 없습니다.)`
457
+ + (못 ? `\n${못}` : '')
458
+ + (잘림.length ? `\n(${잘림.join(' · ')})` : ''),
459
+ ),
460
+ summary: pdfSummary(r) + (잘림.length ? ' · 일부만' : ''),
461
+ };
462
+ }
463
+
311
464
  /**
312
465
  * 파일 하나를 쓴다 — Write 의 알맹이.
313
466
  *
@@ -371,9 +524,19 @@ function 한개옮기기({ from, to, overwrite = false }, ctx) {
371
524
  * 그대로 되지만 되돌리기에는 안 잡힌다. 그런 폴더를 되돌리자고 수만 개를
372
525
  * 뜨는 쪽이 훨씬 나쁘다.
373
526
  */
527
+ // 옮길 때는 .gitignore 를 안 본다 — 옮겨지는 것은 전부이고, 되돌리기도 전부를 떠야 한다.
528
+ const 훑은것 = 폴더인가 ? walk(앞, { ignore: false }) : null;
374
529
  const 짝들 = 폴더인가
375
- ? walk(앞).map((f) => [f.path, join(뒤, relative(앞, f.path))])
530
+ ? 훑은것.map((f) => [f.path, join(뒤, relative(앞, f.path))])
376
531
  : [[앞, 뒤]];
532
+ /*
533
+ * 훑기 상한에 걸리면 **되돌리기가 반쪽이 된다.**
534
+ *
535
+ * 옮기는 것 자체는 renameSync 가 폴더째 하므로 전부 옮겨진다. 그런데 이력에
536
+ * 뜨는 것은 여기서 훑은 것뿐이라, 2만 개가 넘는 폴더를 옮기면 `/undo` 가
537
+ * 앞의 2만 개만 되돌린다. 그걸 말 안 하면 사람은 되돌렸다고 믿고 넘어간다.
538
+ */
539
+ const 되돌리기반쪽 = !!훑은것?.잘림;
377
540
  for (const [a, b] of 짝들) {
378
541
  ctx.history.snapshot(a, 'Move');
379
542
  ctx.history.snapshot(b, 'Move');
@@ -399,10 +562,22 @@ function 한개옮기기({ from, to, overwrite = false }, ctx) {
399
562
 
400
563
  for (const [, b] of 짝들) ctx.seen.add(b);
401
564
  const 무엇 = 폴더인가 ? `폴더 ${짝들.length}개 파일` : '';
565
+ const 경고 = 되돌리기반쪽
566
+ ? `\n(파일이 ${훑은것.상한.toLocaleString('en-US')}개를 넘어 되돌리기에는 앞부분만 떴습니다 — 옮기기는 전부 됐지만 /undo 는 다 못 되돌립니다)`
567
+ : '';
402
568
  return {
403
- content: `옮김: ${ctx.scope.show(앞)} → ${ctx.scope.show(뒤)}${무엇 ? ` (${무엇})` : ''}`,
569
+ content: `옮김: ${ctx.scope.show(앞)} → ${ctx.scope.show(뒤)}${무엇 ? ` (${무엇})` : ''}${경고}`,
404
570
  summary: `${ctx.scope.show(뒤)}`,
405
571
  changed: 뒤,
572
+ /*
573
+ * 폴더를 옮겼으면 **옮겨진 파일 하나하나**를 적어 준다.
574
+ *
575
+ * 닿은 자리가 이미 있던 폴더면(`Move('새것', 'src')`) 그 폴더에는 남이
576
+ * 고치던 파일도 산다. 「이 폴더가 바뀌었다」 로만 적어 두면 나중에
577
+ * /commit 이 그 폴더를 통째로 담고, 남의 변경이 이 커밋에 실린다.
578
+ * 무엇이 실제로 움직였는지는 지금 이 자리만 안다.
579
+ */
580
+ 바뀐것들: 폴더인가 ? 짝들.map(([, b]) => b) : null,
406
581
  };
407
582
  }
408
583
 
@@ -443,11 +618,15 @@ function 한파일쓰기(args, ctx) {
443
618
  // 문서(hwpx·docx·pptx)도 같은 이유로 또렷하게 거절한다. 일반 '바이너리'
444
619
  // 오류로 넘기면 왜 안 되는지가 안 실려서, 모델이 우회로를 찾는다.
445
620
  if (isDocPath(abs)) return { error: 문서는못고침(args.file_path) };
621
+ if (isPdfPath(abs)) return { error: pdf는못고침(args.file_path) };
446
622
  // 엑셀만 막아서는 모자란다. hwp·pdf·png·zip 도 똑같이 그 순간 끝난다.
447
623
  // 게다가 이런 파일은 되돌리기가 내용을 떠 놓지 못하는 종류라 되살릴 길이 없다.
448
624
  // 확장자로 고르지 않고 실제 내용으로 본다 — 사내 파일은 확장자가 제각각이다.
449
625
  const 바이너리막기 = 바이너리인가(abs);
450
626
  if (바이너리막기) return { error: 바이너리막기 };
627
+ // 우리가 안 보여 준 그림을 모델이 지우게 두지 않는다 (그림잃나 머리말).
628
+ const 그림막기 = 그림잃나(abs, args.content);
629
+ if (그림막기) return { error: 그림막기 };
451
630
  ctx.history.snapshot(abs, 'Write');
452
631
  const existed = existsSync(abs);
453
632
  // 덮어쓰기 전 내용. 바뀐 자리를 보여주려면 지금 떠 놔야 한다.
@@ -541,6 +720,7 @@ function 한군데고치기(args, ctx) {
541
720
  // '먼저 Read 로 읽어야 합니다' 라고만 하면 이미 읽은 쪽은 계속 헛돈다.
542
721
  if (isExcelPath(abs)) return { error: 엑셀은못고침(args.file_path) };
543
722
  if (isDocPath(abs)) return { error: 문서는못고침(args.file_path) };
723
+ if (isPdfPath(abs)) return { error: pdf는못고침(args.file_path) };
544
724
  if (!ctx.seen.has(abs)) return { error: `먼저 Read 로 읽어야 합니다: ${args.file_path}` };
545
725
  if (args.old_string === args.new_string) return { error: 'old_string 과 new_string 이 같습니다' };
546
726
 
@@ -654,6 +834,49 @@ function 여러군데고치기(목록, ctx) {
654
834
  };
655
835
  }
656
836
 
837
+ /*
838
+ * 그림 파일을 Read 로 열었을 때.
839
+ *
840
+ * 두 갈래다.
841
+ *
842
+ * 눈이 있는 모델 — 도구 결과에는 "열었다" 는 말만 남기고, 그림 자체는
843
+ * 루프가 바로 뒤에 사람 말로 붙인다 (vision.js 의 그림메시지 머리말 참고).
844
+ * 여기서 base64 를 content 에 넣으면 안 된다. 도구 결과는 글 한 덩어리라
845
+ * 그림으로 안 읽히고, 그냥 자리만 5MB 먹는다.
846
+ *
847
+ * 눈이 없는 모델 — **바이트를 아예 안 싣는다.** 대신 한 줄로 말한다.
848
+ * 안 보이는 모델에게 보내 봐야 400 이 오거나, 더 나쁘게는 서버가 조용히
849
+ * 무시하고 답을 지어낸다. 그러면 사람은 모델이 화면을 봤다고 믿는다.
850
+ */
851
+ function 그림보기(abs, ctx) {
852
+ const show = ctx.scope.show(abs);
853
+ const 눈있나 = !!ctx.눈있나;
854
+ const 것 = 그림읽기(abs, { 한도: 기본한도 });
855
+
856
+ if (!것.ok) {
857
+ return { error: `${show} — ${것.왜}` };
858
+ }
859
+ ctx.seen.add(abs);
860
+ const 잰것 = `그림 · ${크기말(것.bytes)} · ${것.mime}`;
861
+
862
+ if (!눈있나) {
863
+ return {
864
+ content: `${show} 는 그림입니다 (${잰것}).\n`
865
+ + '지금 붙어 있는 모델은 그림을 못 봅니다 — 그래서 보내지 않았습니다.\n'
866
+ + '이 파일을 코드처럼 읽으려 하지 마세요. 무엇이 찍혀 있는지 알아야 한다면'
867
+ + ' 사용자에게 말로 설명해 달라고 하세요.',
868
+ summary: `${잰것} · 이 모델은 못 봄`,
869
+ 그림없음: true,
870
+ };
871
+ }
872
+ return {
873
+ content: `${show} 를 열었습니다 (${잰것}). 그림은 이 다음 메시지에 붙어 있습니다.`,
874
+ summary: 잰것,
875
+ // 루프가 이걸 보고 사람 말 자리에 그림을 붙인다. 모델에게 가는 글이 아니다.
876
+ 그림: { b64: 것.b64, mime: 것.mime, bytes: 것.bytes, show },
877
+ };
878
+ }
879
+
657
880
  export const TOOLS = {
658
881
  Read: {
659
882
  schema: {
@@ -680,11 +903,15 @@ export const TOOLS = {
680
903
  const 막을이유 = 내부살림(abs);
681
904
  if (막을이유) return { error: 막을이유 };
682
905
 
906
+ // 그림은 글로 읽지 않는다. 읽으면 깨진 글자 수천 자가 대화에 실린다.
907
+ if (그림인가(abs)) return 그림보기(abs, ctx);
683
908
  // 엑셀 파일은 글이 아니라 압축 꾸러미다. 그냥 읽으면 '바이너리' 로 끝난다.
684
909
  // 여기서 표로 바꿔 돌려준다 — 사람이 손으로 CSV 로 내보낼 일이 없게.
685
910
  if (isExcelPath(abs)) return 엑셀읽기(abs, args, ctx);
686
911
  // hwpx·docx·pptx 도 같다 — 속이 ZIP+XML 이라 글로 바꿔 돌려준다 (docs.js).
687
912
  if (isDocPath(abs)) return 문서읽기(abs, ctx);
913
+ // PDF 도 같다. 속은 사전+흐름이라 zlib 만으로 쪽마다 글을 꺼낸다 (pdf.js).
914
+ if (isPdfPath(abs)) return pdf읽기(abs, ctx);
688
915
  /*
689
916
  * 구형 hwp 는 '바이너리' 로 끝내지 않는다. 그 오류에는 길이 없어서
690
917
  * 모델이 우회로(새로 쓰기)를 찾는다 — 실제로 그렇게 원본이 죽은 적이
@@ -693,10 +920,32 @@ export const TOOLS = {
693
920
  if (abs.toLowerCase().endsWith('.hwp')) {
694
921
  try {
695
922
  const 머리 = readFileSync(abs);
696
- if (looksOldHwp(abs, 머리)) return { error: 옛hwp안내(ctx.scope.show(abs)) };
923
+ if (looksOldHwp(abs, 머리)) {
924
+ // 여기도 먼저 빌려 본다. 이 PC 에 변환기가 있으면 안내문보다 글이 낫다.
925
+ const 안내 = 옛hwp안내(ctx.scope.show(abs));
926
+ return 빌려읽기(abs, ctx, 안내) ?? { error: 안내 };
927
+ }
697
928
  } catch { /* 아래 일반 읽기가 제 오류를 낸다 */ }
698
929
  }
699
930
 
931
+ /*
932
+ * 옛 Office(.ppt·.doc·.xls·.rtf·.odt…)가 여기서 걸린다.
933
+ *
934
+ * 위의 갈래들과 달리 우리는 이걸 아예 못 읽는다. 그냥 두면 아래 일반
935
+ * 읽기가 `바이너리 파일입니다` 로 끝냈다 — 까닭도 길도 없는 거절이라
936
+ * 모델이 같은 문을 계속 두드린다. 사내 자료에 제일 흔한 갈래가 하필
937
+ * 이것들이다.
938
+ *
939
+ * 그래서 여기서 두 갈래로 끝낸다. 이 PC 에 변환기가 있으면 빌려 읽고,
940
+ * 없으면 **없다고 못 박고 끝낸다**(끝났다: true → 되풀이 억제가 걸린다).
941
+ */
942
+ if (직접못읽나(abs)) {
943
+ const 보인이름 = ctx.scope.show(abs);
944
+ const 빌린것 = 빌려읽기(abs, ctx, `${보인이름} 을 deel 이 직접 못 읽습니다.`);
945
+ if (빌린것) return 빌린것;
946
+ return { error: 못바꿈말(보인이름, extname(abs)), 끝났다: true };
947
+ }
948
+
700
949
  const 읽음 = readTextFull(abs);
701
950
  // 무엇으로 읽었는지 기억해 둔다. 나중에 고칠 때 같은 것으로 되돌려 써야 한다.
702
951
  // 안 그러면 사내 CP949 문서가 한 번 고치는 것만으로 UTF-8 이 되어 버린다.
@@ -713,9 +962,23 @@ export const TOOLS = {
713
962
  const more = lines.length > start + count ? `\n… 전체 ${lines.length}줄 중 ${start + count}줄까지` : '';
714
963
  ctx.seen.add(abs);
715
964
  const 별난인코딩 = 읽음.encoding !== 'utf-8';
965
+ /*
966
+ * 요약에는 **실제로 건넨 줄 수**를 적는다.
967
+ *
968
+ * 여태 파일의 전체 줄 수를 적었다. 그래서 32k 모델에서 1,425줄짜리
969
+ * 파일을 열면 모델은 384줄만 받는데 화면에는 `1425줄` 이 찍혔다 —
970
+ * 사람은 다 읽은 줄 알고 "왜 저걸 못 고치지" 를 되풀이한다. 잘렸다는
971
+ * 말은 모델에게 가는 본문에만 있었고, 보는 사람에게는 없었다.
972
+ *
973
+ * budget.js 의 읽을줄수 머리말에 이 함정이 그대로 적혀 있는데
974
+ * (「화면에는 2,000줄이라고만 떠서 사람은 잘 읽은 줄 안다」) 정작
975
+ * 이 줄은 안 고쳐져 있었다.
976
+ */
977
+ const 준줄수 = slice.length;
978
+ const 다못줌 = start + count < lines.length || start > 0;
716
979
  return {
717
980
  content: clip(body + more + 줄인것.알림),
718
- summary: `${lines.length}줄`
981
+ summary: (다못줌 ? `${준줄수}/${lines.length}줄 (일부만)` : `${lines.length}줄`)
719
982
  + (줄인것.줄인바이트 ? ` · 그림 ${몇KB(줄인것.줄인바이트)} 생략` : '')
720
983
  + (별난인코딩 ? ` · ${encLabel(읽음.encoding)}` : ''),
721
984
  };
@@ -952,18 +1215,27 @@ export const TOOLS = {
952
1215
  run(args, ctx) {
953
1216
  const root = args.path ? ctx.scope.resolve(args.path) : ctx.scope.root;
954
1217
  const re = globToRegex(args.pattern);
955
- const 맞는것 = walk(root)
1218
+ const 전부 = walk(root);
1219
+ // .gitignore 로 건너뛴 것은 수를 말한다 — 조용히 빼면 '그 파일이 없다' 로 읽힌다 (tools/ignore.js).
1220
+ const 건너뜀 = 건너뜀말(전부.건너뜀, 전부.잘림, 전부.상한);
1221
+ const 맞는것 = 전부
956
1222
  .filter((f) => re.test(f.rel) || re.test(f.rel.split('/').pop()))
957
1223
  .sort((a, b) => b.mtime - a.mtime);
958
1224
  const files = 맞는것.slice(0, 찾을개수(ctx.모델컨텍스트));
959
- if (!files.length) return { content: `찾은 파일 없음: ${args.pattern}`, summary: '0개' };
1225
+ // 훑기 상한에서 멈췄으면 '없다' 아니라 '본 데까지는 없다' 다.
1226
+ if (!files.length) {
1227
+ return {
1228
+ content: `${전부.잘림 ? '본 데까지는 찾은 파일 없음' : '찾은 파일 없음'}: ${args.pattern}${건너뜀}`,
1229
+ summary: 전부.잘림 ? '0개 (다 못 봄)' : '0개',
1230
+ };
1231
+ }
960
1232
  // 잘랐으면 잘랐다고 말한다. 전에는 '200개' 라고만 해서, 모델이 그게 전부인 줄
961
1233
  // 알고 "전부 확인했습니다" 로 답을 맺었다. 실제로는 1,400개 중 200개였다.
962
1234
  const 잘림 = 맞는것.length > files.length
963
1235
  ? `\n\n… 모두 ${맞는것.length}개인데 최근 것 ${files.length}개만 보여 줍니다. 범위를 좁혀 다시 찾으세요.`
964
1236
  : '';
965
1237
  return {
966
- content: files.map((f) => ctx.scope.show(f.path)).join('\n') + 잘림,
1238
+ content: files.map((f) => ctx.scope.show(f.path)).join('\n') + 잘림 + 건너뜀,
967
1239
  summary: 맞는것.length > files.length ? `${files.length}/${맞는것.length}개` : `${files.length}개`,
968
1240
  };
969
1241
  },
@@ -994,20 +1266,88 @@ export const TOOLS = {
994
1266
 
995
1267
  const root = args.path ? ctx.scope.resolve(args.path) : ctx.scope.root;
996
1268
  const isFile = existsSync(root) && statSync(root).isFile();
1269
+
1270
+ const mode = args.output_mode ?? 'files_with_matches';
1271
+ const limit = args.head_limit ?? 찾을줄수(ctx.모델컨텍스트);
1272
+ const hitFiles = [];
1273
+ const lines = [];
1274
+ let total = 0;
1275
+
1276
+ /*
1277
+ * 이 PC 에 rg 나 git grep 이 이미 있으면 빌려 쓴다 (tools/fastgrep.js).
1278
+ *
1279
+ * 깔지는 않는다 — 없으면 아래 자바스크립트 길로 그대로 간다. 5만 개짜리
1280
+ * 저장소에서 수십 초가 1초 안쪽이 된다. 대신 **무엇으로 찾았는지 반드시
1281
+ * 적는다.** rg 의 정규식 문법은 자바스크립트와 조금 달라서, 결과가
1282
+ * 다르게 나왔을 때 무엇으로 찾은 것인지 모르면 사람은 코드를 의심한다.
1283
+ */
1284
+ const 무시파일 = join(ctx.scope.root, '.deelignore');
1285
+ const 빠른것 = isFile ? null : 빠르게찾기({
1286
+ 무늬: args.pattern,
1287
+ 자리: root,
1288
+ glob: args.glob ?? null,
1289
+ 대소문자무시: !!args['-i'],
1290
+ 무시파일: existsSync(무시파일) ? 무시파일 : null,
1291
+ 최대: Math.max(limit * 4, 2000),
1292
+ });
1293
+ if (빠른것) {
1294
+ const 파일별 = new Map();
1295
+ for (const x of 빠른것.줄들) {
1296
+ // 우리 규칙(글 아닌 것·큰 파일)은 rg 쪽 옵션으로 이미 걸었다. 여기서는 세기만.
1297
+ const rel = ctx.scope.show(x.파일);
1298
+ 파일별.set(rel, (파일별.get(rel) ?? 0) + 1);
1299
+ total += 1;
1300
+ if (mode === 'content' && lines.length < limit) {
1301
+ const num = args['-n'] === false ? '' : `:${x.줄}`;
1302
+ lines.push(`${rel}${num}: ${x.내용.trim().slice(0, 200)}`);
1303
+ }
1304
+ }
1305
+ for (const [rel, n] of 파일별) hitFiles.push({ rel, n });
1306
+ /*
1307
+ * 꼬리에 무엇을 적나.
1308
+ *
1309
+ * 자바스크립트 길은 '몇 개를 건너뛰었는지' 를 세어서 적는다. rg 는 그
1310
+ * 숫자를 안 알려준다. 그러면 **모르는 것을 안 적는다** — 지어낸 숫자를
1311
+ * 적느니 안 셌다고 말하는 편이 낫다.
1312
+ */
1313
+ const 꼬리2 = [
1314
+ 빠른것.잘림 ? '(결과가 많아 앞부분만 봤습니다 — 더 있을 수 있습니다)' : '',
1315
+ 엔진말(빠른것.엔진),
1316
+ '(.gitignore·.deelignore 는 지켰습니다. 건너뛴 수는 안 셌습니다)',
1317
+ ].filter(Boolean).join(' ');
1318
+ const 붙이기2 = (t) => (꼬리2 ? [t, '', 꼬리2].join('\n') : t);
1319
+ if (!total) return { content: 붙이기2(`일치 없음: ${args.pattern}`), summary: `0건 · ${빠른것.엔진}` };
1320
+ if (mode === 'content') return { content: 붙이기2(clip(lines.join('\n'))), summary: `${total}건 · ${빠른것.엔진}` };
1321
+ if (mode === 'count') {
1322
+ return {
1323
+ content: 붙이기2(hitFiles.slice(0, limit).map((f) => `${f.n}\t${f.rel}`).join('\n')),
1324
+ summary: `${hitFiles.length}개 파일 · ${빠른것.엔진}`,
1325
+ };
1326
+ }
1327
+ return {
1328
+ content: 붙이기2(hitFiles.slice(0, limit).map((f) => f.rel).join('\n')),
1329
+ summary: `${hitFiles.length}개 파일 · ${total}건 · ${빠른것.엔진}`,
1330
+ };
1331
+ }
1332
+
1333
+ /*
1334
+ * ── 여기서부터 예전 길 (자바스크립트로 하나씩 연다) ──
1335
+ *
1336
+ * 폴더를 훑는 일(`walk`)을 여기까지 미뤄 두었다. 빠른 엔진이 답을 준
1337
+ * 경우에는 훑을 까닭이 없는데, 전에는 위에서 먼저 훑고 있었다 —
1338
+ * 그러면 rg 를 빌려 쓰고도 제일 오래 걸리는 일을 그대로 한 셈이 된다.
1339
+ */
997
1340
  let files = isFile
998
1341
  ? [{ path: root, rel: ctx.scope.show(root) }]
999
1342
  : walk(root);
1343
+ const 안본것 = isFile ? '' : 건너뜀말(files.건너뜀, files.잘림, files.상한).trim(); // .gitignore 로 건너뛴 수 — 꼬리에 적는다
1344
+ // 훑기 상한에 걸렸으면 "일치 없음" 이라고 잘라 말하면 안 된다. 안 본 것이다.
1345
+ const 다못봄 = !isFile && !!files.잘림;
1000
1346
  if (args.glob) {
1001
1347
  const g = globToRegex(args.glob);
1002
1348
  files = files.filter((f) => g.test(f.rel) || g.test(f.rel.split('/').pop()));
1003
1349
  }
1004
1350
 
1005
- const mode = args.output_mode ?? 'files_with_matches';
1006
- const limit = args.head_limit ?? 찾을줄수(ctx.모델컨텍스트);
1007
- const hitFiles = [];
1008
- const lines = [];
1009
- let total = 0;
1010
-
1011
1351
  /*
1012
1352
  * 큰 파일과 글이 아닌 파일은 건너뛴다.
1013
1353
  *
@@ -1046,10 +1386,16 @@ export const TOOLS = {
1046
1386
  멈춤 === '중단' ? '(중단하셔서 여기까지만 찾았습니다)' : '',
1047
1387
  멈춤 === '상한' ? `(${limit}개에서 멈췄습니다 — 더 있을 수 있습니다)` : '',
1048
1388
  건너뛴것 ? `(글이 아니거나 너무 큰 파일 ${건너뛴것}개는 건너뛰었습니다)` : '',
1389
+ 안본것,
1049
1390
  ].filter(Boolean).join(' ');
1050
1391
  const 붙이기 = (s) => (꼬리 ? `${s}\n\n${꼬리}` : s);
1051
1392
 
1052
- if (!total) return { content: 붙이기(`일치 없음: ${args.pattern}`), summary: '0건' };
1393
+ if (!total) {
1394
+ return {
1395
+ content: 붙이기(다못봄 ? `본 데까지는 일치 없음: ${args.pattern}` : `일치 없음: ${args.pattern}`),
1396
+ summary: 다못봄 ? '0건 (다 못 봄)' : '0건',
1397
+ };
1398
+ }
1053
1399
  if (mode === 'content') return { content: 붙이기(clip(lines.join('\n'))), summary: `${total}건` };
1054
1400
  if (mode === 'count') {
1055
1401
  return { content: 붙이기(hitFiles.map((f) => `${f.n}\t${f.rel}`).join('\n')), summary: `${hitFiles.length}개 파일` };
@@ -1158,27 +1504,9 @@ export const TOOLS = {
1158
1504
  };
1159
1505
  }
1160
1506
 
1161
- /*
1162
- * 명령을 셸에 넘기는 방법. 윈도우에서 여기가 조용히 틀려 있었다.
1163
- *
1164
- * 무슨 일이 있었나:
1165
- * Node 는 인자를 넘길 때 따옴표를 \" 로 바꿔 준다. 그런데 cmd.exe 는
1166
- * \" 를 모른다. 그래서 따옴표가 든 명령이 통째로 뭉개졌다 —
1167
- * node -e "console.log(1)" → 아무것도 안 하고 **종료코드 0**
1168
- * 출력도 없고 오류도 없이 '성공' 이다. 모델은 잘된 줄 알고 넘어간다.
1169
- * `node -e`, `python -c`, `git commit -m "..."` 이 전부 이 자리였다.
1170
- *
1171
- * Node 의 exec() 가 안에서 하는 것과 똑같이 맞춘다 — 명령을 통째로
1172
- * 따옴표로 감싸고, 인자를 손대지 말라고(verbatim) 일러 준다.
1173
- * /s 는 그 감싼 따옴표 한 쌍을 벗기라는 뜻이라 짝이 맞는다.
1174
- */
1175
- const shell = process.platform === 'win32'
1176
- ? {
1177
- file: process.env.COMSPEC ?? 'cmd.exe',
1178
- args: ['/d', '/s', '/c', `"${cmd}"`],
1179
- verbatim: true,
1180
- }
1181
- : { file: '/bin/sh', args: ['-c', cmd] };
1507
+ // 어느 셸로 넘기나 — tools/shell.js 가 정한다. Jobs 와 같은 답이어야 하므로 한 군데다.
1508
+ // (윈도우 cmd 따옴표 문제와 해법도 거기 적혀 있다.)
1509
+ const shell = 셸명령(cmd);
1182
1510
 
1183
1511
  const 제한 = args.timeout ?? 120000;
1184
1512
  return new Promise((끝) => {
@@ -1582,6 +1910,26 @@ export function 설명줄이기(schema, 한도) {
1582
1910
  * 빈 설명을 내보내지 않는다. 설명 없는 도구는 모델이 언제 쓰는지 모른 채로
1583
1911
  * 목록에만 서 있게 되는데, 그건 없는 것보다 나쁘다.
1584
1912
  */
1913
+ /*
1914
+ * 그림 이야기는 **볼 수 있는 모델에게만** 한다.
1915
+ *
1916
+ * 못 보는 모델에게 "그림도 읽을 수 있다" 고 적어 두면 모델은 화면 사진을
1917
+ * 열려 들고, 열어 봐야 "이 모델은 못 봅니다" 를 받는다. 그 한 걸음이 매번
1918
+ * 헛간다 — 작은 로컬 모델일수록 그 한 걸음이 아깝다.
1919
+ *
1920
+ * 반대로 볼 수 있는데 안 적어 두면 그림 파일을 아예 안 연다. 사람이 화면
1921
+ * 사진을 폴더에 넣어 두고 "이거 봐" 라고 해도 이름만 보고 지나간다.
1922
+ */
1923
+ function 눈붙이기(fn, 이름, vision) {
1924
+ if (이름 !== 'Read' || !vision) return fn;
1925
+ // 화면 말을 그대로 본다. 설명 글자를 보고 짐작하면 안 된다 — 줄이기가 문장
1926
+ // 한복판을 자르므로 마지막 글자가 무엇일지 정해져 있지 않다.
1927
+ const 덧말 = 지시말() === 'en'
1928
+ ? ' Screenshots and images (.png/.jpg/.gif/.webp) can be opened too — this model can see them.'
1929
+ : ' 화면 사진·그림(.png/.jpg/.gif/.webp)도 그대로 열 수 있다 — 지금 붙어 있는 모델은 그림을 본다.';
1930
+ return { ...fn, description: String(fn.description ?? '') + 덧말 };
1931
+ }
1932
+
1585
1933
  function 영어설명(schema, 이름) {
1586
1934
  if (지시말() !== 'en') return schema;
1587
1935
  const 것 = 도구설명EN[이름];
@@ -1602,7 +1950,7 @@ function 영어설명(schema, 이름) {
1602
1950
 
1603
1951
  // 모델에게 넘길 도구 정의 목록.
1604
1952
  // 스킬이 없으면 Skill 도구는 빼서 자리를 아낀다.
1605
- export function toolSchemas(names = null, { hasSkills = false, web = true, work = null, mcp = null, ctx = null, lsp = false } = {}) {
1953
+ export function toolSchemas(names = null, { hasSkills = false, web = true, work = null, mcp = null, ctx = null, lsp = false, vision = false } = {}) {
1606
1954
  let list = names ?? Object.keys(TOOLS).filter((n) => {
1607
1955
  if (n === 'Skill') return hasSkills;
1608
1956
  if (n === 'WebFetch') return web;
@@ -1643,7 +1991,9 @@ export function toolSchemas(names = null, { hasSkills = false, web = true, work
1643
1991
  */
1644
1992
  const 우리것 = list.map((n) => ({
1645
1993
  type: 'function',
1646
- function: 설명줄이기(영어설명(TOOLS[n].schema, n), 한도),
1994
+ // 이야기는 줄인 **뒤에** 붙인다. 먼저 붙이면 한도에 걸려 그 한 문장이
1995
+ // 그대로 잘려 나간다 — 좁은 창일수록 정작 알려야 할 때 안 실린다.
1996
+ function: 눈붙이기(설명줄이기(영어설명(TOOLS[n].schema, n), 한도), n, vision),
1647
1997
  }));
1648
1998
 
1649
1999
  /*
package/src/tools/jobs.js CHANGED
@@ -25,6 +25,7 @@
25
25
  */
26
26
  import { spawn, execFileSync } from 'node:child_process';
27
27
  import { decode as decodeBytes, consoleCodepage } from './encoding.js';
28
+ import { 셸명령 } from './shell.js';
28
29
 
29
30
  // 한 일감이 **들고 있을** 출력 상한. 넘으면 앞을 버리고 뒤를 남긴다 —
30
31
  // 오래 도는 것에서 필요한 건 언제나 **방금** 나온 쪽이다.
@@ -61,18 +62,9 @@ const 끝난것상한 = 8;
61
62
  */
62
63
  const 죽는말기다림 = 700;
63
64
 
64
- /**
65
- * 명령을 셸에 넘기는 방법.
66
- *
67
- * 윈도우에서 여기가 조용히 틀리면 따옴표가 든 명령이 통째로 뭉개진다 —
68
- * 출력도 오류도 없이 **종료코드 0** 이다. Bash 쪽과 같은 값을 쓴다.
69
- * 한 군데서만 정의하는 것이 중요하다. 두 벌이 되면 한쪽만 고쳐진다.
70
- */
71
- export function 셸명령(cmd) {
72
- return process.platform === 'win32'
73
- ? { file: process.env.COMSPEC ?? 'cmd.exe', args: ['/d', '/s', '/c', `"${cmd}"`], verbatim: true }
74
- : { file: '/bin/sh', args: ['-c', cmd], verbatim: false };
75
- }
65
+ // 명령을 셸에 넘기는 방법은 tools/shell.js 한 군데서 정한다. Bash 도구와 같은 값이어야
66
+ // 한다 벌이 되면 한쪽만 고쳐지고, 윈도우에서 따옴표가 든 명령이 조용히 뭉개진다.
67
+ export { 셸명령 };
76
68
 
77
69
  /**
78
70
  * 어떻게 띄우나 — spawn 에 넘길 것들.