deel-local-cli 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -680,6 +680,28 @@ node 프로젝트 (deel-local-cli) · git main
680
680
  | 답하는 중 | 답 쓰는 중 · 정리해서 말하는 중 |
681
681
  | **45초가 넘으면** | 아직 하는 중 · 조금만 더 · 생각보다 오래 걸리는 중 |
682
682
 
683
+ ### 왼쪽 그림도 같이 움직입니다
684
+
685
+ 문구 옆에서 도는 것은 돌림표가 아니라 **지금 하는 일을 그린 작은 그림**입니다.
686
+
687
+ | 하는 일 | 한 바퀴 | 그림 |
688
+ |---|---|---|
689
+ | 생각 | `⠀⠶⠀` `⠰⣿⠆` `⢾⣿⡷` `⠰⣿⠆` | 부풀었다 줄어든다 |
690
+ | 읽기 | `⠉⠉⠉` `⠒⠒⠒` `⠤⠤⠤` `⣀⣀⣀` | 훑는 줄이 내려간다 |
691
+ | 쓰기 | `⡼⠭⠧` `⠼⡯⠧` `⠼⠿⡧` `⠼⡭⠧` | **노트북이 자판을 두드린다** |
692
+ | 명령 | `⠉⠀⠀` `⠉⠈⠀` `⠛⠊⠀` `⠿⠮⠄` | 결과가 한 줄씩 쌓인다 |
693
+ | 답 | `⠉⠀⠀` `⠛⠉⠀` `⠿⠛⠉` `⣿⠿⠛` | 글이 차오른다 |
694
+ | 웹 | `⣀⣀⣀` `⣤⣀⣀` `⣶⣤⣀` `⣿⣶⣤` | 신호 막대가 자란다 |
695
+ | 접기 | `⣿⣿⣿` `⣶⣶⣶` `⣤⣤⣤` `⣀⣀⣀` | 눌려서 한 줄로 모인다 |
696
+ | 45초 넘으면 | `⠶⠀⠀` `⠰⠆⠀` `⠀⠶⠀` `⠀⠰⠆` | 왔다 갔다 — '기다리는 중' |
697
+
698
+ 점자만 씁니다. 점자 한 칸은 **가로 2 · 세로 4 점**이라 세 칸이면 6×4 격자가 되고,
699
+ 어느 터미널에서나 정확히 한 칸입니다. 이모지나 `●` `▪` 는 동아시아 로캘에서 두 칸으로
700
+ 잡히는 것이 섞여 있어 90ms마다 테두리가 어긋납니다.
701
+
702
+ 그림이 안 나오거나 화면 읽기 프로그램을 쓰신다면 `DEEL_NO_MOTION=1` 로 끄면
703
+ 예전처럼 한 칸짜리 돌림표만 돕니다.
704
+
683
705
  같은 갈래 안에서도 4초마다 다른 문구로 넘어갑니다. 같은 글자가 30초 동안 그대로
684
706
  있으면 그것도 멈춘 것처럼 보이기 때문입니다. 오른쪽에는 **걸린 시간**과, 모델이
685
707
  추론 중이면 **지금까지 생각한 글자 수**가 같이 뜹니다 — 진짜로 늘어나는 숫자가
@@ -1244,12 +1266,74 @@ utf8 로 받으면 한글이 깨집니다. 바이트로 받아서 풉니다.
1244
1266
 
1245
1267
  ---
1246
1268
 
1269
+ ## 만든 웹을 그 자리에서 띄웁니다
1270
+
1271
+ ```
1272
+ ❯ /preview
1273
+
1274
+ ▶ 띄웠습니다 http://127.0.0.1:56801/
1275
+ 보여 주는 것 .
1276
+ 파일을 고치면 화면이 저절로 새로 뜹니다.
1277
+ 이 컴퓨터에서만 열립니다(127.0.0.1). 다른 PC 에서는 안 보입니다.
1278
+ 끄려면 /preview off · deel 을 끝내면 같이 꺼집니다.
1279
+ ```
1280
+
1281
+ 브라우저가 같이 열립니다. `/preview 폴더` 로 자리를 정하고, `/preview off` 로 끕니다.
1282
+
1283
+ **파일을 두 번 눌러 여는 것(`file://`)과 다릅니다.** `file://` 에서는 아래가 전부
1284
+ 막힙니다 — 그런데 오류는 콘솔에만 나오고 화면은 그냥 희어서, 만든 사람은 제 코드를
1285
+ 의심하며 시간을 씁니다. 진짜 HTTP 서버라 다 돕니다:
1286
+
1287
+ | | `file://` | `/preview` |
1288
+ |---|---|---|
1289
+ | `<script type="module">` · `import` | 막힘 (CORS) | **됨** |
1290
+ | `fetch('./자료.json')` | 막힘 | **됨** |
1291
+ | `new Worker(...)` | 막힘 | **됨** |
1292
+ | `WebAssembly.compileStreaming` | 막힘 (형식) | **됨** |
1293
+ | 텍스처·`getImageData` | 캔버스 오염 | **됨** |
1294
+ | `.glb` / `.gltf` (Three.js) | 형식 없음 → 조용히 안 그려짐 | **됨** |
1295
+
1296
+ 실제 크롬에서 이 일곱 가지를 돌려 **7/7** 을 확인했습니다.
1297
+
1298
+ 라우터를 쓰는 앱(React Router 등)은 안쪽 주소에서 새로고침해도 첫 장이 나옵니다.
1299
+ 다만 확장자가 있는 요청(`app.js`)에는 절대 안 그럽니다 — 없는 스크립트에 HTML 을
1300
+ 돌려주면 `Unexpected token '<'` 로 죽는데, 그게 진짜 원인(파일 이름 오타)을 가립니다.
1301
+
1302
+ ### 열어 주는 만큼만 엽니다
1303
+
1304
+ 서버를 띄운다는 것은 내 디스크를 남에게 열어 주는 일입니다.
1305
+
1306
+ - **`127.0.0.1` 에만** 묶습니다. `0.0.0.0` 은 아예 못 씁니다 — 같은 사무실 망에서
1307
+ 아무나 내 소스를 읽게 됩니다.
1308
+ - 포트는 **0**(커널이 빈 것을 줍니다). 고정 포트는 남이 쓰던 것을 뺏습니다.
1309
+ - 경로는 작업 범위 밖으로 못 나갑니다. `../` · `%2e%2e` · 두 번 인코딩 · 절대 경로 ·
1310
+ 널바이트 · 심볼릭 링크 — 여덟 가지를 검사로 막아 뒀습니다.
1311
+ - **주기만 합니다.** `POST` · `PUT` · `DELETE` 는 405 로 거절합니다.
1312
+ - `deel` 을 끝내면 같이 꺼집니다.
1313
+
1247
1314
  ## 스킬·플러그인
1248
1315
 
1249
- **deel 스킬을 품고 다니지 않습니다.** 켜질 때 그 PC 를 훑어 있는 것을 그대로 씁니다.
1250
- 빈 PC 에 놓으면 0개, 스킬이 깔린 PC 에 놓으면 그 PC 의 것이 잡힙니다.
1316
+ **남의 스킬은 품고 다니지 않습니다.** 켜질 때 그 PC 를 훑어 있는 것을 그대로 씁니다.
1317
+
1318
+ **다만 일하는 방법 일곱 가지는 품고 다닙니다.** 사내에서 새로 받은 PC 에는
1319
+ `~/.claude/skills` 도 플러그인도 없습니다. 거기서는 방법론이 0개라 모델이 매번
1320
+ 제 나름대로 했고, 시킨 것만 겨우 하고 끝나는 얄팍한 결과가 거기서 나왔습니다.
1321
+
1322
+ | 스킬 | 언제 |
1323
+ |---|---|
1324
+ | `깊이있게-만들기` | 고도화·보고용·선포용 — 최소치만 내고 끝내지 않게 |
1325
+ | `끝까지-하기` | 시킨 것이 여러 조각일 때. 빼먹은 것은 말하게 |
1326
+ | `차근차근-디버깅` | 재현 → 좁히기 → 원인 → 고치기 → 증명 |
1327
+ | `검사-먼저` | 고치기 전에 실패하는 검사부터 |
1328
+ | `찔러보기` | 되는지 모를 때 시간 정해 작게 찔러 보고 버리기 |
1329
+ | `코드-줄이기` | 동작은 그대로 두고 읽기 쉽게 |
1330
+ | `스스로-검토` | 다 됐다고 말하기 전에. 못 잰 것까지 말하게 |
1331
+
1332
+ 목록으로 늘 나가는 몫은 354토큰입니다(8k 창의 4%). 본문은 `Skill` 로 부를 때만 나갑니다.
1333
+ 같은 이름을 직접 만들면 그쪽이 이깁니다 — 품고 다니는 것이 제일 낮은 자리입니다.
1251
1334
 
1252
1335
  ```
1336
+ 품고 다님 (패키지 안) ← 제일 낮은 자리
1253
1337
  프로젝트 ./.deel/skills ./.claude/skills ./.deel/commands ./.claude/commands
1254
1338
  사용자 ~/.deel/skills ~/.claude/skills ~/.claude/commands
1255
1339
  플러그인 ~/.claude/plugins/** ~/.deel/plugins/**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deel-local-cli",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "로컬 모델·사내 게이트웨이 전용 코딩 에이전트 CLI — 외부 의존성 0개 / Zero-dependency coding agent CLI for local LLMs and private gateways",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -46,7 +46,7 @@
46
46
  "verify": "node test/no-bundle.test.js && node test/network.test.js && node test/web.test.js",
47
47
  "bench": "node test/edit-bench.js",
48
48
  "demo": "node test/demo.js",
49
- "check": "node --check bin/deel.js && node --check src/repl.js && node --check src/oneshot.js && node --check src/commands.js && node --check src/agent/loop.js && node --check src/agent/session.js && node --check src/agent/effort.js && node --check src/agent/modes.js && node --check src/agent/route.js && node --check src/agent/mention.js && node --check src/agent/project.js && node --check src/tools/encoding.js && node --check src/tools/excel-com.js && node --check src/tools/excel.js && node --check src/tools/xlsx.js && node --check src/agent/compact.js && node --check src/agent/store.js && node --check src/agent/sessionui.js && node --check src/backend/adapter.js && node --check src/backend/ctxsize.js && node --check src/backend/probe.js && node --check src/backend/detect.js && node --check src/backend/http.js && node --check src/backend/scan.js && node --check src/backend/scanui.js && node --check src/tools/index.js && node --check src/tools/fsutil.js && node --check src/tools/edit-match.js && node --check src/tools/webfetch.js && node --check src/tools/todo.js && node --check src/tools/jobs.js && node --check src/skills/discover.js && node --check src/plugins/manage.js && node --check src/pack/zip.js && node --check src/pack/tar.js && node --check src/pack/selfpack.js && node --check src/safety/guard.js && node --check src/safety/undo.js && node --check src/safety/audit.js && node --check src/safety/network.js && node --check src/setup.js && node --check src/report.js && node --check src/config.js && node --check src/ui/diff.js && node --check src/ui/ansi.js && node --check src/ui/status.js && node --check src/ui/level.js && node --check src/ui/prompt.js && node --check src/ui/spinner.js && echo OK",
49
+ "check": "node --check bin/deel.js && node --check src/repl.js && node --check src/oneshot.js && node --check src/commands.js && node --check src/agent/loop.js && node --check src/agent/session.js && node --check src/agent/effort.js && node --check src/agent/modes.js && node --check src/agent/route.js && node --check src/agent/mention.js && node --check src/agent/project.js && node --check src/tools/encoding.js && node --check src/tools/excel-com.js && node --check src/tools/excel.js && node --check src/tools/xlsx.js && node --check src/agent/compact.js && node --check src/agent/store.js && node --check src/agent/sessionui.js && node --check src/backend/adapter.js && node --check src/backend/ctxsize.js && node --check src/backend/probe.js && node --check src/backend/detect.js && node --check src/backend/http.js && node --check src/backend/scan.js && node --check src/backend/scanui.js && node --check src/tools/index.js && node --check src/tools/fsutil.js && node --check src/tools/edit-match.js && node --check src/tools/webfetch.js && node --check src/tools/todo.js && node --check src/tools/jobs.js && node --check src/skills/discover.js && node --check src/plugins/manage.js && node --check src/pack/zip.js && node --check src/pack/tar.js && node --check src/pack/selfpack.js && node --check src/safety/guard.js && node --check src/safety/undo.js && node --check src/safety/audit.js && node --check src/safety/network.js && node --check src/setup.js && node --check src/report.js && node --check src/config.js && node --check src/ui/diff.js && node --check src/ui/ansi.js && node --check src/ui/status.js && node --check src/ui/level.js && node --check src/ui/prompt.js && node --check src/ui/spinner.js && node --check src/ui/motion.js && node --check src/preview/serve.js && node --check src/ui/working.js && node --check src/ui/inputbox.js && echo OK",
50
50
  "coverage": "node test/coverage.mjs",
51
51
  "prepublishOnly": "npm run check && npm test"
52
52
  },
@@ -110,15 +110,66 @@ const 문턱 = {
110
110
  ask: 5,
111
111
  };
112
112
 
113
+ /*
114
+ * ── 겹친 요청 ───────────────────────────────────────────────────────────
115
+ *
116
+ * "계획해주고 만들어줘" 처럼 **계획과 실행이 한 말에 같이** 든 것.
117
+ *
118
+ * 이건 위의 점수표로는 못 고른다. '만들어' 가 code 3점 문턱을 그냥 넘어서
119
+ * 곧장 코드 모드로 가 버리고, 사람이 보자고 한 계획은 안 나온다.
120
+ * 실제로 겪었다 — "정리해서 만들어줘" 가 계획 한 줄 없이 파일부터 만들었다.
121
+ *
122
+ * 반대로 plan 으로 보내면 계획만 내고 멈춘다. 그것도 시킨 일의 절반이다.
123
+ * 그래서 따로 알아본다. 계획을 내고 → 승인을 받고 → 그대로 실행까지 잇는다.
124
+ *
125
+ * 좁게 잡는다. 사용자는 자율 실행을 원했고, 승인 창은 그 결정을 갉아먹는
126
+ * 쪽이다. '고쳐줘' 한마디에 창이 뜨면 그게 더 나쁘다.
127
+ * 그래서 **계획말 + 잇는말** 이 앞에 있고 그 **뒤에** 실행말이 와야만 겹침이다.
128
+ */
129
+ const 계획말 = '계획|플랜|설계|구상|기획|정리|검토|조사|파악|살펴|알아보|분석|로드맵|방향|순서|밑그림|초안';
130
+ // '정리하고' 처럼 잇는 꼴이어야 한다. 그냥 '정리' 하나로는 겹침이 아니다 —
131
+ // "코드 정리해줘" 는 계획이 아니라 그 자체가 할 일이다.
132
+ const 잇는말 = '해\\s*주고|해\\s*놓고|하고\\s*나서|하고서|한\\s*다음|한\\s*뒤|한\\s*후|하고|해서|잡고|짜고|세우고|낸\\s*뒤|낸\\s*다음';
133
+ const 실행말 = /만들어|만들자|만들어라|구현|작성|추가(해|하)|생성|써\s*(줘|주세요)|짜\s*(줘|주세요)|고쳐|세팅|설치|배포/;
134
+ const 계획먼저 = new RegExp(`(${계획말})\\s*(?:을|를)?\\s*(?:${잇는말})`);
135
+ // 영어도 같이 본다 — "plan and then build it" 처럼 붙여 넣는 일이 있다.
136
+ const 영어겹침 = /\b(plan|design|outline|draft)\b[\s\S]{0,60}?\b(?:and|then|,)\s*(?:then\s*)?\b(build|implement|create|write|make|code)\b/i;
137
+
138
+ /**
139
+ * 계획과 실행이 한 말에 같이 들었나.
140
+ *
141
+ * @returns {{겹침: boolean, why: string}} 겹침이 아니면 why 는 빈 글.
142
+ */
143
+ export function 겹친요청(text) {
144
+ const s = String(text ?? '');
145
+ const 영어 = s.match(영어겹침);
146
+ if (영어) return { 겹침: true, why: `'${영어[1]}' 과 '${영어[2]}'` };
147
+
148
+ const 앞 = s.match(계획먼저);
149
+ if (!앞) return { 겹침: false, why: '' };
150
+ // 실행말이 계획말 **뒤에** 와야 한다. "만들고 나서 정리해줘" 는 겹침이 아니다 —
151
+ // 그건 만드는 일이 먼저고, 계획을 미리 볼 것이 없다.
152
+ const 뒤 = s.slice(앞.index + 앞[0].length).match(실행말);
153
+ if (!뒤) return { 겹침: false, why: '' };
154
+ return { 겹침: true, why: `'${앞[0].trim()}' 뒤에 '${뒤[0].trim()}'` };
155
+ }
156
+
113
157
  /**
114
158
  * 이 한마디가 무슨 일인지 고른다.
115
159
  *
116
160
  * @param {string} text 사용자가 친 말
117
- * @returns {{ mode: string|null, score: number, why: string, 점수들: object }}
161
+ * @returns {{ mode: string|null, score: number, why: string, 겹침: boolean, 점수들: object }}
118
162
  * mode 가 null 이면 '모르겠다' 는 뜻이다 — 종합 모드에 그대로 둔다.
163
+ * 겹침이 true 면 계획을 내고 승인을 받아 실행까지 이어야 한다는 뜻이다.
119
164
  */
120
165
  export function route(text) {
121
166
  const s = String(text ?? '');
167
+
168
+ // 겹친 요청은 점수표보다 먼저 본다. 점수표에 맡기면 '만들어' 가 이겨서
169
+ // 계획을 건너뛴다 — 그게 바로 이 길을 만든 이유다.
170
+ const 겹 = 겹친요청(s);
171
+ if (겹.겹침) return { mode: 'plan', score: 0, why: 겹.why, 겹침: true, 점수들: {} };
172
+
122
173
  const 점수들 = {};
123
174
  const 근거 = {};
124
175
 
@@ -138,19 +189,20 @@ export function route(text) {
138
189
  const 순위 = Object.entries(점수들).sort((a, b) => b[1] - a[1]);
139
190
  const [으뜸, 점수] = 순위[0] ?? [null, 0];
140
191
  if (!으뜸 || 점수 < (문턱[으뜸] ?? 4)) {
141
- return { mode: null, score: 점수, why: '무슨 일인지 뚜렷하지 않음', 점수들 };
192
+ return { mode: null, score: 점수, why: '무슨 일인지 뚜렷하지 않음', 겹침: false, 점수들 };
142
193
  }
143
194
 
144
195
  // 1·2등이 붙으면 고르지 않는다. 반반인 것을 억지로 정하면 절반은 틀린다.
145
196
  const 둘째 = 순위[1]?.[1] ?? 0;
146
197
  if (점수 - 둘째 < 2 && 둘째 >= (문턱[순위[1][0]] ?? 4)) {
147
- return { mode: null, score: 점수, why: `${으뜸} 과 ${순위[1][0]} 가 비슷함`, 점수들 };
198
+ return { mode: null, score: 점수, why: `${으뜸} 과 ${순위[1][0]} 가 비슷함`, 겹침: false, 점수들 };
148
199
  }
149
200
 
150
201
  return {
151
202
  mode: 으뜸,
152
203
  score: 점수,
153
204
  why: 근거[으뜸].slice(0, 2).map((x) => `'${x}'`).join(', '),
205
+ 겹침: false,
154
206
  점수들,
155
207
  };
156
208
  }
@@ -89,13 +89,50 @@ function normalizeError(err) {
89
89
  return m;
90
90
  }
91
91
 
92
+ // 게이트웨이 앞단 프록시가 자격증명을 받아오다 실패한 것을 알아본다.
93
+ //
94
+ // 사내 게이트웨이는 앞에 프록시를 두고, 프록시가 매번 바깥 명령을 돌려 토큰을 받아 온다.
95
+ // 그 명령이 실패하면 이런 것이 본문에 실려 온다 —
96
+ //
97
+ // [PROXY ERROR] Command '['databricks', 'auth', 'token', '--host', 'dbc-...',
98
+ // '--profile', 'a@b.com', '-o', 'json']' returned non-zero exit status 1.
99
+ //
100
+ // 이걸 그대로 뱉으면 쓸모가 없다. 모델도 deel 도 아니고 **그 PC 의 로그인이 만료된 것**인데,
101
+ // 화면만 보고는 무엇을 해야 할지 알 수가 없다(실제로 겪었다).
102
+ // 그래서 명령·호스트·프로필을 뽑아 칠 것까지 만들어 준다. 못 알아보면 null 이다.
103
+ export function 프록시힌트(말) {
104
+ const s = String(말 ?? '');
105
+ if (!/returned non-zero exit status/i.test(s)) return null;
106
+ // 바깥 따옴표가 대괄호까지 감싸고 있어 그냥 따옴표로 끊으면 짝이 어긋난다. 대괄호 안만 본다.
107
+ // 단 앞머리의 `[PROXY ERROR]` 도 대괄호라 먼저 걸린다 — 따옴표로 시작하는 것만 골라야 한다.
108
+ const 인자 = [...(s.match(/\[(\s*'[^\]]*)\]/)?.[1] ?? '').matchAll(/'([^']*)'/g)].map((m) => m[1]);
109
+ const 도구 = 인자[0] || '외부 명령';
110
+ const 뒤 = (이름) => { const i = 인자.indexOf(이름); return i >= 0 ? (인자[i + 1] ?? null) : null; };
111
+
112
+ if (도구 === 'databricks') {
113
+ const host = 뒤('--host');
114
+ const profile = 뒤('--profile');
115
+ const 다시 = ['databricks auth login',
116
+ host ? `--host ${/^https?:\/\//.test(host) ? host : `https://${host}`}` : null,
117
+ profile ? `--profile ${profile}` : null].filter(Boolean).join(' ');
118
+ return `게이트웨이가 Databricks 토큰을 못 받았습니다 — 모델이 아니라 로그인이 만료된 것입니다.\n`
119
+ + `그 PC 에서 다시 로그인하세요: ${다시}`;
120
+ }
121
+ return `게이트웨이 앞단 프록시가 '${도구}' 를 돌리다 실패했습니다 — 모델이 아니라 프록시 쪽 문제입니다.`;
122
+ }
123
+
92
124
  // 서버가 준 오류 본문에서 사람이 읽을 문장만 뽑는다.
93
125
  export function serverMessage(r) {
94
126
  if (r.error) return r.error;
95
127
  const j = r.json;
96
128
  const cand = j?.error?.message ?? j?.error ?? j?.message ?? j?.detail;
97
- if (typeof cand === 'string') return cand;
98
- if (cand) return JSON.stringify(cand).slice(0, 200);
99
- if (r.text) return String(r.text).replace(/\s+/g, ' ').slice(0, 200);
100
- return `HTTP ${r.status}`;
129
+ let 말;
130
+ if (typeof cand === 'string') = cand;
131
+ else if (cand) = JSON.stringify(cand).slice(0, 200);
132
+ else if (r.text) 말 = String(r.text).replace(/\s+/g, ' ').slice(0, 200);
133
+ else return `HTTP ${r.status}`;
134
+ // 알아본 것이 있으면 원문 대신 그것을 앞에 세운다. 원문은 뒤에 한 줄로 남긴다 —
135
+ // 사내 담당자에게 그대로 보여 줘야 할 때가 있다.
136
+ const 힌트 = 프록시힌트(말);
137
+ return 힌트 ? `${힌트}\n원문: ${말.slice(0, 160)}` : 말;
101
138
  }
package/src/commands.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // 슬래시 명령. 이름은 Claude Code / Codex 관례에 맞춘다.
2
- import { writeFileSync, existsSync } from 'node:fs';
2
+ import { writeFileSync, existsSync, statSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { c, say, rule, pad, bar, mark, width, clip } from './ui/ansi.js';
5
5
  import { compact } from './agent/compact.js';
@@ -17,6 +17,7 @@ import { MODES as WORK_MODES, ORDER as WORK_ORDER, DEFAULT as WORK_DEFAULT, norm
17
17
  import { LEVELS, ORDER as LEVEL_ORDER, DEFAULT as LEVEL_DEFAULT, normalize as normLevel, shows as levelShows } from './ui/level.js';
18
18
  import { diffLines, renderDiff, shortStat } from './ui/diff.js';
19
19
  import { readTextFull } from './tools/fsutil.js';
20
+ import { 띄우기, 브라우저로 } from './preview/serve.js';
20
21
  const MODES = {
21
22
  auto: '자율 — 전부 알아서. 되돌리기가 안전망',
22
23
  confirm: '확인 — 되돌릴 수 없는 것만 물어봄',
@@ -46,6 +47,7 @@ export const COMMANDS = {
46
47
  orchestrator: { desc: '작업 모드 → 총괄 (큰 일을 쪼개서)' },
47
48
  undo: { desc: '직전 작업 되돌리기', arg: '[턴수]' },
48
49
  diff: { desc: '이번 대화에서 바뀐 파일 · 바뀐 자리 보기', arg: '[파일]' },
50
+ preview: { desc: '만든 웹을 이 자리에서 띄워 본다 — 올릴 필요 없음', arg: '[폴더|off]' },
49
51
  tools: { desc: '쓸 수 있는 도구 보기' },
50
52
  skills: { desc: '스킬 보기·검색·골라 올리기', arg: '[검색어|all|off]' },
51
53
  plugin: { desc: '플러그인 목록·설치·삭제·반입묶음', arg: '[install|remove|pack]' },
@@ -258,6 +260,9 @@ export async function handle(line, session, ctx) {
258
260
 
259
261
  case 'diff': return 바뀐것보기(session, ctx, arg), { handled: true };
260
262
 
263
+ case 'preview':
264
+ case 'serve': return await 미리보기(session, ctx, arg), { handled: true };
265
+
261
266
  case 'tools': {
262
267
  rule('도구', 70);
263
268
  for (const [n, t] of Object.entries(TOOLS)) {
@@ -677,6 +682,103 @@ async function doPlugin(session, arg) {
677
682
  say('');
678
683
  }
679
684
 
685
+ /*
686
+ * 만든 웹을 그 자리에서 띄운다.
687
+ *
688
+ * 여기 있는 것은 화면과 말뿐이고, 서버는 preview/serve.js 가 한다.
689
+ * 한 번에 하나만 띄운다 — 여러 개를 띄워 놓으면 어느 주소가 무엇인지
690
+ * 아무도 못 외우고, 끌 때도 뭘 껐는지 모른다.
691
+ */
692
+ let 미리보기중 = null;
693
+
694
+ /** 켜져 있으면 끈다. 프로그램을 끝낼 때도 이걸 부른다. */
695
+ export async function 미리보기끄기() {
696
+ if (!미리보기중) return null;
697
+ const 끈것 = 미리보기중;
698
+ 미리보기중 = null;
699
+ try { await 끈것.서버.닫기(); } catch { /* 이미 닫혔다 */ }
700
+ return 끈것;
701
+ }
702
+
703
+ /** 지금 띄워 둔 것. 상태줄·검사에서 본다. */
704
+ export const 미리보기상태 = () => (미리보기중
705
+ ? { url: 미리보기중.서버.url, 폴더: 미리보기중.보인이름, 되살아나나: 미리보기중.서버.되살아나나 }
706
+ : null);
707
+
708
+ async function 미리보기(session, ctx, arg) {
709
+ const 말 = String(arg ?? '').trim();
710
+
711
+ if (/^(off|끄기|중지|stop)$/i.test(말)) {
712
+ const 끈것 = await 미리보기끄기();
713
+ say('');
714
+ say(끈것 ? ` ${mark.ok} 미리보기를 껐습니다. ${c.gray(끈것.서버.url)}`
715
+ : ` ${c.gray('띄워 둔 것이 없습니다.')}`);
716
+ say('');
717
+ return;
718
+ }
719
+
720
+ // 이미 떠 있는데 또 치면, 주소를 다시 알려 주고 브라우저만 연다.
721
+ // 여기서 조용히 하나 더 띄우면 포트가 둘이 되고 어느 쪽이 진짜인지 모르게 된다.
722
+ if (미리보기중 && !말) {
723
+ say('');
724
+ say(` ${c.hgreen('▶')} 이미 띄워 뒀습니다 ${c.cyan(미리보기중.서버.url)} ${c.gray(미리보기중.보인이름)}`);
725
+ 브라우저로(미리보기중.서버.url);
726
+ say(` ${c.gray('끄려면')} ${c.cyan('/preview off')}`);
727
+ say('');
728
+ return;
729
+ }
730
+ // 다른 폴더를 주면 앞엣것은 끄고 새로 띄운다.
731
+ if (미리보기중) await 미리보기끄기();
732
+
733
+ let 뿌리;
734
+ try {
735
+ 뿌리 = ctx.scope.resolve(말 || '.');
736
+ } catch (err) {
737
+ say('');
738
+ say(` ${c.red('띄울 수 없습니다')} ${c.gray(err.message)}`);
739
+ say('');
740
+ return;
741
+ }
742
+
743
+ if (!existsSync(뿌리)) {
744
+ say('');
745
+ say(` ${c.red('그런 폴더가 없습니다')} ${c.gray(ctx.scope.show(뿌리))}`);
746
+ say('');
747
+ return;
748
+ }
749
+ // 파일 하나를 줬으면 그 파일이 든 폴더를 띄우고, 브라우저는 그 파일로 연다.
750
+ let 첫주소 = '';
751
+ if (!statSync(뿌리).isDirectory()) {
752
+ 첫주소 = encodeURIComponent(뿌리.split(/[\\/]/).pop());
753
+ 뿌리 = join(뿌리, '..');
754
+ 뿌리 = ctx.scope.resolve(뿌리);
755
+ }
756
+
757
+ let 서버;
758
+ try {
759
+ 서버 = await 띄우기({ 뿌리, scope: ctx.scope });
760
+ } catch (err) {
761
+ say('');
762
+ say(` ${c.red('못 띄웠습니다')} ${c.gray(err.message)}`);
763
+ say('');
764
+ return;
765
+ }
766
+ 미리보기중 = { 서버, 보인이름: ctx.scope.show(뿌리) };
767
+
768
+ const url = 서버.url + 첫주소;
769
+ say('');
770
+ say(` ${c.hgreen('▶')} ${c.bold('띄웠습니다')} ${c.cyan(url)}`);
771
+ say(` ${c.gray('보여 주는 것')} ${c.white(미리보기중.보인이름)}`);
772
+ say(서버.되살아나나
773
+ ? ` ${c.gray('파일을 고치면 화면이 저절로 새로 뜹니다.')}`
774
+ : ` ${c.yellow('⚠')} ${c.gray('이 자리에서는 파일 변화를 못 봅니다 — 브라우저를 손으로 새로 고치세요.')}`);
775
+ // 어디까지 열리는지 반드시 말한다. '서버를 띄웠다' 는 말은 사람마다 다르게 읽힌다.
776
+ say(` ${c.gray('이 컴퓨터에서만 열립니다(127.0.0.1). 다른 PC 에서는 안 보입니다.')}`);
777
+ say(` ${c.gray('끄려면')} ${c.cyan('/preview off')}${c.gray(' · deel 을 끝내면 같이 꺼집니다.')}`);
778
+ 브라우저로(url);
779
+ say('');
780
+ }
781
+
680
782
  function showSkills(session, arg) {
681
783
  const all = session.skills ?? [];
682
784
  if (!all.length) {
@@ -733,9 +835,9 @@ function showSkills(session, arg) {
733
835
  if (hits.length > 30) say(` ${c.gray(`… 그 밖에 ${hits.length - 30}개`)}`);
734
836
  say('');
735
837
 
736
- const bySource = { project: 0, user: 0, plugin: 0 };
838
+ const bySource = { project: 0, user: 0, plugin: 0, builtin: 0 };
737
839
  for (const s of all) bySource[s.source] = (bySource[s.source] ?? 0) + 1;
738
- say(` ${c.gray('전체')} ${all.length}개 ${c.gray('(프로젝트')} ${bySource.project} ${c.gray('· 사용자')} ${bySource.user} ${c.gray('· 플러그인')} ${bySource.plugin}${c.gray(')')}`);
840
+ say(` ${c.gray('전체')} ${all.length}개 ${c.gray('(프로젝트')} ${bySource.project} ${c.gray('· 사용자')} ${bySource.user} ${c.gray('· 플러그인')} ${bySource.plugin} ${c.gray('· 품고 다니는 것')} ${bySource.builtin}${c.gray(')')}`);
739
841
  say(` ${c.gray('프롬프트에 올라간 것')} ${session.listedSkills().length}개 ${c.gray(`(상한 ${session.maxSkillsListed})`)}`);
740
842
  if ((session.plugins ?? []).length) {
741
843
  say(` ${c.gray('플러그인')} ${session.plugins.filter((p) => p.skills > 0).map((p) => p.name).slice(0, 8).join(', ')}`);