deel-local-cli 2.1.2 → 2.1.4

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.
@@ -327,6 +327,31 @@ export class History {
327
327
  return rec;
328
328
  }
329
329
 
330
+ /**
331
+ * 명령이 **이미 바꾼** 파일의 앞 모습을 적는다 (2.1.3 · safety/gitsnap.js).
332
+ *
333
+ * 앞 모습은 부르는 쪽이 준다 — git 이 들고 있던 것이나 명령 전에 떠 둔 사본. `앞` 이 Buffer 면 그 내용,
334
+ * null 이면 「원래 없던 자리」(되돌리면 지운다). 바이너리는 snapshot 과 같이 못 뜬 것으로 적고 크기를 남긴다.
335
+ * 이번 턴에 이미 뜬 자리면 안 적는다 — 그 기록이 턴 처음 모습이다.
336
+ */
337
+ 뒤늦은기록(absPath, 앞, label) {
338
+ if (this.떴나(absPath)) return null; // 이번 턴 처음 모습이 이미 있다
339
+ if (앞 === null) return this.없던자리기록(absPath, label);
340
+ const 뜬것 = 뜨기(Buffer.from(앞));
341
+ const rec = { turn: this.turn, at: new Date().toISOString(), path: absPath, before: 뜬것.before, label };
342
+ const 상대 = this.#상대(absPath);
343
+ if (상대 !== null) rec.rel = 상대;
344
+ if (뜬것.enc) rec.enc = 뜬것.enc;
345
+ if (뜬것.skipped) { rec.skipped = 뜬것.skipped; rec.크기 = 앞.length; }
346
+ this.#덧붙이기(rec);
347
+ this.#잠그기();
348
+ this.#이번턴.set(absPath, rec);
349
+ const 낮춘 = absPath.toLowerCase();
350
+ this.#낮춘이름.set(낮춘, [...(this.#낮춘이름.get(낮춘) ?? []), absPath]);
351
+ this.#maybePrune();
352
+ return rec;
353
+ }
354
+
330
355
  /*
331
356
  * ── 떠 놓고 **못 쓴** 기록을 거둔다 ───────────────────────────────────
332
357
  *
@@ -841,7 +866,11 @@ function 같은물건(p, q) {
841
866
  }
842
867
 
843
868
  function safeRead(p) {
844
- const buf = readFileSync(p);
869
+ return 뜨기(readFileSync(p));
870
+ }
871
+
872
+ /** 바이트를 이력에 적을 모양으로 (safeRead 와 gitsnap 의 앞 모습이 같이 쓴다). */
873
+ function 뜨기(buf) {
845
874
  // 바이너리는 내용을 담지 않는다. '없던 파일' 과는 다른 값으로 알린다 —
846
875
  // 이 둘을 같은 null 로 뭉쳐 놨던 것이 /undo 가 파일을 지우던 원인이다.
847
876
  if (looksBinary(buf)) return { before: null, skipped: '바이너리' };
@@ -0,0 +1,37 @@
1
+ // 실행 파일을 PATH 에서 **우리가** 찾는다 — 셸이나 운영체제가 찾게 두지 않는다.
2
+ //
3
+ // tools/verify.js 에서 떼어 냈다 (2.1.3). 되돌리기의 git 뜨기(safety/gitsnap.js)도 같은 자로 git 을 찾는다 —
4
+ // 두 벌이면 언젠가 한 벌만 고친다. 여기는 fs 와 path 만 부른다(되돌리기는 설정이 깨져도 돌아야 하는 자리다).
5
+
6
+ import { statSync } from 'node:fs';
7
+ import { resolve, join, delimiter, isAbsolute, relative } from 'node:path';
8
+
9
+ /**
10
+ * 이름으로 실행 파일을 PATH 에서 찾는다.
11
+ *
12
+ * 지금 폴더를 뜻하는 칸(빈 칸·`.`·상대 경로)과 작업 폴더 안의 칸은 건너뛴다. 그 자리의
13
+ * 파일은 저장소가 심을 수 있다. 윈도우에서는 `.exe` 만 본다 — `.cmd`·`.bat` 은 결국
14
+ * cmd.exe 를 부르는 것이라 셸을 안 거친다는 뜻이 없어진다.
15
+ *
16
+ * @returns {string|null} 절대 경로. 못 찾으면 null — 그러면 「도구가 없다」 로 말한다.
17
+ */
18
+ export function 경로에서찾기(이름들, { env = process.env, 뿌리 = null } = {}) {
19
+ const 윈 = process.platform === 'win32';
20
+ // 윈도우 환경 이름은 대소문자를 안 가린다(Path · PATH). 거른 환경 객체는 평범한 객체라 직접 찾는다.
21
+ const 칸 = Object.keys(env ?? {}).find((k) => (윈 ? k.toUpperCase() === 'PATH' : k === 'PATH'));
22
+ const 목록 = String((칸 && env[칸]) ?? '').split(delimiter);
23
+ const 뿌리abs = 뿌리 ? resolve(뿌리) : null;
24
+ for (const 이름 of 이름들) {
25
+ for (const 날것 of 목록) {
26
+ const 폴더 = 날것.trim().replace(/^"(.*)"$/, '$1');
27
+ if (!폴더 || !isAbsolute(폴더)) continue;
28
+ if (뿌리abs) {
29
+ const rel = relative(뿌리abs, resolve(폴더));
30
+ if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) continue;
31
+ }
32
+ const 후보 = join(폴더, 윈 ? `${이름}.exe` : 이름);
33
+ try { if (statSync(후보).isFile()) return 후보; } catch { /* 여기엔 없다 */ }
34
+ }
35
+ }
36
+ return null;
37
+ }
package/src/setup.js CHANGED
@@ -14,6 +14,7 @@ import { allowEndpoint } from './safety/network.js';
14
14
  import { 인증서설정, 인증서등록 } from './backend/clientcert.js';
15
15
  import { 바깥인가, 봉인됐나 } from './safety/runmode.js';
16
16
  import { 제공자들, 제공자고르기, 어디것일까, 주소후보, 막힌까닭, 열쇠다듬기 } from './providers/index.js';
17
+ import { 링크로그인, 원격인가 } from './login.js';
17
18
  import { writeFileSync } from 'node:fs';
18
19
 
19
20
  // 머리 한 줄은 ui/banner.js 에 있다 — 진입점이 이 파일(40개를 끌고 온다) 없이 쓰게 (2.1.2).
@@ -126,14 +127,38 @@ async function connect(url, key, { 조용히 = false, 제공자: 어디 = null }
126
127
  return found;
127
128
  }
128
129
 
130
+ // 한 화면에 보이는 모델 수. 넘으면 이름으로 좁히고, 목록 끝에 「직접 입력」 을 둔다.
131
+ const 모델한화면 = 40;
132
+
129
133
  async function chooseModel(found) {
130
134
  if (!found.models.length) {
131
135
  say('');
132
136
  say(` ${mark.warn} ${c.yellow('모델 목록을 못 받았습니다.')} ${c.gray('이름을 직접 넣어 주세요.')}`);
133
137
  return (await ask('모델 이름')).trim();
134
138
  }
135
- const items = found.models.slice(0, 40).map((m) => ({ label: m.id, note: m.note ?? '' }));
139
+ /*
140
+ * ── 목록이 길면 이름으로 좁힌다 (2.1.4) ─────────────────────────────────
141
+ *
142
+ * 앞 40개만 보여 주고 끝이었다. 사내 게이트웨이는 그걸로 됐는데 OpenRouter 는 모델이 수백 개라,
143
+ * 쓰려는 모델이 41번째 뒤에 있으면 **고를 길이 없었다.** 이름 일부로 좁히고, 그래도 없으면
144
+ * 목록 끝의 「직접 입력」 으로 빠진다 — 목록은 낡고, 빠져나갈 구멍은 늘 둔다(리전 고르기와 같다).
145
+ */
146
+ let 후보 = found.models;
147
+ const 길다 = 후보.length > 모델한화면;
148
+ if (길다) {
149
+ say('');
150
+ say(` ${c.gray(`모델이 ${후보.length}개입니다. 이름 일부로 좁힙니다 (예: claude · gpt · llama). 비우면 앞 ${모델한화면}개.`)}`);
151
+ const 찾을말 = (await ask('찾을 이름')).trim().toLowerCase();
152
+ if (찾을말) {
153
+ const 걸린것 = 후보.filter((m) => String(m.id).toLowerCase().includes(찾을말));
154
+ if (걸린것.length) 후보 = 걸린것;
155
+ else say(` ${mark.warn} ${c.yellow(`「${찾을말}」 이 든 모델 이름이 없습니다 — 맞는 모델이 없습니다.`)} ${c.gray(`앞 ${모델한화면}개를 보여 드립니다.`)}`);
156
+ }
157
+ }
158
+ const items = 후보.slice(0, 모델한화면).map((m) => ({ label: m.id, note: m.note ?? '' }));
159
+ if (길다) items.push({ label: '직접 입력', note: '목록에 없는 이름', 직접: true });
136
160
  const i = await pick('사용할 모델', items);
161
+ if (items[i].직접) return (await ask('모델 이름')).trim();
137
162
  return items[i].label;
138
163
  }
139
164
 
@@ -181,12 +206,20 @@ export async function runProbe(conn, { out = null, 봉인 = false } = {}) {
181
206
  * 「직접 넣기」 를 목록 위쪽에 둔다. 목록이 지원 명단처럼 보이면, 거기 없는
182
207
  * 회사는 안 되는 줄 알고 돌아선다 (providers/index.js 머리말).
183
208
  *
209
+ * 넷째 갈래 — **브라우저로 로그인** (2.1.4). 빈칸 0개다. 열쇠를 안 넣고 계정으로 받아 온다
210
+ * (src/login.js). 번호는 셋째에 둔다: 1·2 는 손에 익은 번호라 안 옮긴다.
211
+ *
212
+ * @param o.봉인 offline 이 켜져 있나 — 로그인은 여기서 바깥으로 나가므로 여기서 거른다
213
+ * @param o.원격 브라우저를 이 PC 에서 못 여나 (`--no-browser` · SSH)
184
214
  * @returns {{제공자, 주소들: string[], 열쇠: string, 이름: string} | null}
185
215
  */
186
- async function 붙일곳고르기() {
216
+ async function 붙일곳고르기({ 봉인 = false, 원격 = false } = {}) {
217
+ // 링크로 로그인하는 첫 제공자. 데이터에 그 칸이 있는 곳이면 누구든 같은 흐름을 탄다.
218
+ const 로그인곳 = 제공자들.find((p) => p.링크로그인);
187
219
  const 목록 = [
188
220
  { id: '열쇠먼저', label: '열쇠만 있습니다 — 어디 것인지 알아봐 주세요', note: '빈칸 1개' },
189
221
  { id: 'custom', label: '주소를 직접 넣기', note: '사내 게이트웨이 · 목록에 없는 곳' },
222
+ ...(로그인곳 ? [{ id: '링크로그인', label: `브라우저로 로그인 — ${로그인곳.이름}`, note: '빈칸 0개 · 열쇠를 안 넣습니다' }] : []),
190
223
  ...제공자들.filter((p) => p.id !== 'custom').map((p) => ({
191
224
  id: p.id,
192
225
  label: p.이름,
@@ -217,6 +250,8 @@ async function 붙일곳고르기() {
217
250
  const 나머지 = 제공자들.map((p) => ({ label: p.이름, note: p.한줄 }));
218
251
  제공자 = 제공자들[await pick('어디 열쇠인가요?', 나머지, { def: 0 })];
219
252
  }
253
+ } else if (고른.id === '링크로그인') {
254
+ 제공자 = 로그인곳;
220
255
  } else {
221
256
  제공자 = 제공자고르기(고른.id);
222
257
  }
@@ -281,9 +316,23 @@ async function 붙일곳고르기() {
281
316
  }
282
317
 
283
318
  // ── 열쇠 ─────────────────────────────────────────────────────────────
284
- if (!열쇠) {
319
+ //
320
+ // 로그인이 되는 곳은 열쇠를 비워 두면 로그인으로 간다 — 벤더 목록에서 골랐어도 같다.
321
+ if (!열쇠 && 고른.id !== '링크로그인') {
285
322
  if (제공자.열쇠받는곳) say(` ${c.gray('열쇠 받는 곳')} ${c.cyan(제공자.열쇠받는곳)}`);
286
- 열쇠 = 열쇠다듬기(await ask('API 키', { mask: true }));
323
+ 열쇠 = 열쇠다듬기(await ask('API 키' + (제공자.링크로그인 ? ' (비우면 브라우저로 로그인)' : ''), { mask: true }));
324
+ }
325
+ if (!열쇠 && 제공자.링크로그인) {
326
+ const 받은것 = await 링크로그인(제공자, { 봉인, 원격 });
327
+ if (!받은것.ok) {
328
+ say('');
329
+ say(` ${mark.no} ${c.yellow(받은것.까닭)}`);
330
+ say('');
331
+ return null;
332
+ }
333
+ 열쇠 = 받은것.열쇠;
334
+ // 받은 열쇠는 화면에 안 적는다. 어디에 어떻게 잠갔는지는 저장한 뒤에 말한다.
335
+ say(` ${mark.ok} ${c.green('로그인했습니다 — 열쇠를 받았습니다.')} ${c.gray('화면에 안 적고, 저장할 때 잠가 둡니다.')}`);
287
336
  }
288
337
 
289
338
  const 기본이름 = 제공자.id === 'custom' ? '사내게이트웨이' : 제공자.이름;
@@ -343,7 +392,14 @@ async function 설정받기(flags = {}) {
343
392
  // 주소를 손으로 치게 만드는 것은 우리 잘못이다.
344
393
  say(` ${c.gray('이 PC 에 로컬 모델이 떠 있다면')} ${c.cyan('deel scan --save')} ${c.gray('가 빈칸 없이 찾아 줍니다.')}`);
345
394
 
346
- const 고른것 = await 붙일곳고르기();
395
+ /*
396
+ * 봉인 판정은 **붙일 곳을 고르기 전에** 한다 (2.1.4). 브라우저로 로그인하는 갈래는 고르는
397
+ * 자리 안에서 바깥으로 나가기 때문이다 — 뒤에서 재면 이미 나간 뒤다. 판정은 깃발과 처음 읽은
398
+ * 설정만 보므로 앞으로 옮겨도 값이 같다.
399
+ */
400
+ const 봉인 = 봉인됐나({ 깃발: flags?.offline, prof: null, cfg: 첫설정 });
401
+
402
+ const 고른것 = await 붙일곳고르기({ 봉인, 원격: flags?.['no-browser'] === true || 원격인가() });
347
403
  if (!고른것) return 1;
348
404
  const { 제공자: 붙일곳, 주소들, 열쇠: key, 이름: name } = 고른것;
349
405
 
@@ -363,7 +419,7 @@ async function 설정받기(flags = {}) {
363
419
  // 적어 둔 봉인만 걸리고, 사람이 그 자리에서 친 `deel setup --offline` 은 안 걸렸다.
364
420
  // 깃발을 믿고 사내 게이트웨이 주소를 넣은 사람은 방금 적은 열쇠까지 실어 보낸 셈이 된다.
365
421
  // (diagnose 는 같은 판에서 `깃발: flags.offline` 을 넘긴다 — 두 문이 갈려 있었다.)
366
- const 봉인 = 봉인됐나({ 깃발: flags?.offline, prof: null, cfg: 첫설정 });
422
+ // 판정 자체는 위에서 했다 — 로그인 갈래가 고르는 자리에서 나가기 때문이다.
367
423
  const 두드릴것 = 봉인
368
424
  ? 주소들.filter((u) => !바깥인가(/^https?:\/\//i.test(u) ? u : `http://${u}`))
369
425
  : 주소들;
package/src/slash.js ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * 슬래시 명령을 이름으로 찾는 규칙 — 대화 화면(commands.js)과 `deel run`(oneshot.js)이 같이 쓴다.
3
+ *
4
+ * 여기가 commands.js 안에 있었다. 그래서 `deel run` 은 이 셋을 쓰려고 대화 화면의 명령 전부
5
+ * (화면 · 설정 · 플러그인 · 스캔 …)를 읽었다. 규칙은 한 벌로 두되, 가벼운 자리로 뺀다 (2.1.3).
6
+ * commands.js 는 여기서 받아 그대로 내보낸다 — 부르던 자리는 안 바뀐다.
7
+ */
8
+ /** `ext:ReviewCode` 의 꼬리 — 언제나 낮춰서 돌려준다. 찾기와 「비슷한 것」 이 같은 잣대를 쓰게 한다. */
9
+ const 꼬리이름 = (이름) => String(이름 ?? '').split(':').pop().toLowerCase();
10
+
11
+ /*
12
+ * ── 이 PC 에서 찾은 슬래시 명령을 이름으로 찾는다 ───────────────────────
13
+ *
14
+ * 대화 화면(commands.js 의 default 갈래)과 배치(oneshot.js)가 **같은 규칙**을 써야 한다. 같은 글자를
15
+ * 쳤는데 창에 따라 되고 안 되면 사람은 어느 쪽을 믿을지 정할 수 없다. 그래서 두 벌로 적지
16
+ * 않고 여기 한 군데에 둔다.
17
+ *
18
+ * 찾는 차례는 셋이다 — 적은 그대로 · 대소문자 무시 · 꼬리 이름.
19
+ * 마지막 칸이 `x.name.split(':').pop() === 낮춘` 이었다 (8회차 그밖 명령2). 왼쪽은 파일에
20
+ * 적힌 그대로고 오른쪽은 낮춘 말이라 둘이 만날 수가 없다. `ext:ReviewCode` 는 `/ReviewCode`
21
+ * 로도 `/reviewcode` 로도 「모르는 명령」 이었고, 「비슷한 것」 에도 안 떴다 — 깔려 있는
22
+ * 명령이 어느 길로도 안 보이는 자리다. 배치에서는 그대로 1 로 선다.
23
+ */
24
+ export function 슬래시명령찾기(목록, 부른이름) {
25
+ const 것들 = 목록 ?? [];
26
+ const 낮춘 = String(부른이름 ?? '').toLowerCase();
27
+ return 것들.find((x) => x.name === 부른이름)
28
+ ?? 것들.find((x) => x.name.toLowerCase() === 낮춘)
29
+ ?? 것들.find((x) => 꼬리이름(x.name) === 낮춘)
30
+ ?? null;
31
+ }
32
+
33
+ /** 못 찾았을 때 「비슷한 것」 으로 늘어놓을 것. 찾기와 같은 잣대(낮춘 꼬리)를 쓴다. */
34
+ export function 비슷한슬래시명령(목록, 부른이름, 몇 = 5) {
35
+ const 낮춘 = String(부른이름 ?? '').toLowerCase();
36
+ if (!낮춘) return [];
37
+ return (목록 ?? [])
38
+ .filter((x) => x.name.toLowerCase().includes(낮춘) || 낮춘.includes(꼬리이름(x.name)))
39
+ .slice(0, 몇);
40
+ }
41
+
42
+ /*
43
+ * 프롬프트 인자를 가른다. 빈칸으로 가르되 따옴표("…" · '…')로 묶은 것은 한 인자다 — 파일 이름에 빈칸이
44
+ * 든다. 마지막 인자는 남은 글을 **그대로** 받는다 — 여러 줄 코드를 넘기면 줄바꿈 · 들여쓰기가 살아야 한다.
45
+ * 남은 것이 통째로 한 따옴표 묶음이면 따옴표만 벗긴다.
46
+ */
47
+ function 인자가르기(글, 몇) {
48
+ const s = String(글 ?? '');
49
+ const 값들 = [];
50
+ let i = 0;
51
+ for (let k = 0; k < 몇; k++) {
52
+ while (i < s.length && /\s/.test(s[i])) i++;
53
+ if (i >= s.length) break;
54
+ if (k === 몇 - 1) {
55
+ const 남은 = s.slice(i).trimEnd();
56
+ const 묶음 = /^(["'])((?:(?!\1)[\s\S])*)\1$/.exec(남은);
57
+ 값들.push(묶음 ? 묶음[2] : 남은);
58
+ break;
59
+ }
60
+ const 끝 = s[i] === '"' || s[i] === "'" ? s.indexOf(s[i], i + 1) : -1;
61
+ if (끝 > i) { 값들.push(s.slice(i + 1, 끝)); i = 끝 + 1; continue; }
62
+ const 시작 = i;
63
+ while (i < s.length && !/\s/.test(s[i])) i++;
64
+ 값들.push(s.slice(시작, i));
65
+ }
66
+ return 값들;
67
+ }
68
+
69
+ /**
70
+ * `/mcp__<서버>__<이름> 인자…` — MCP 서버가 낸 프롬프트를 받아 글로 편다 (2.1.3 · backend/mcp.js 의 프롬프트받기).
71
+ *
72
+ * 대화 화면과 `deel run` 이 같은 것을 쓴다 — 슬래시명령찾기 를 한 벌로 둔 것과 같은 까닭이다.
73
+ * 인자는 빈칸으로 갈라 적힌 차례대로 채우고, 마지막 인자가 나머지를 다 받는다(한 문장을 통째로 넘기게 ·
74
+ * 인자가르기).
75
+ *
76
+ * @returns null — 그런 프롬프트가 없다(부르는 쪽은 여느 슬래시 명령을 찾는다)
77
+ * { 탈 } — 있는데 못 폈다 · { text, 서버이름, 이름 } — 모델에게 보낼 글
78
+ */
79
+ export async function MCP프롬프트펴기(서버들, 부른이름, 인자글 = '', { signal = null } = {}) {
80
+ const { 이름풀기 } = await import('./backend/mcp.js');
81
+ const 갈린것 = 이름풀기(부른이름);
82
+ if (!갈린것) return null;
83
+ const 서버 = (서버들 ?? []).find((s) => s?.이름 === 갈린것.서버);
84
+ const 프롬프트 = (서버?.프롬프트 ?? []).find((p) => p?.name === 갈린것.도구);
85
+ if (!프롬프트) return null;
86
+ const 인자이름들 = (프롬프트.arguments ?? []).map((a) => a.name);
87
+ const 값들 = 인자가르기(인자글, 인자이름들.length);
88
+ const 인자 = {};
89
+ 인자이름들.forEach((n, i) => { if (값들[i]) 인자[n] = 값들[i]; });
90
+ // hasOwn — 인자 이름이 constructor · toString 이면 `in` 은 Object 원형에서 찾아 「있다」 고 한다.
91
+ const 빠진것 = (프롬프트.arguments ?? []).filter((a) => a.required && !Object.hasOwn(인자, a.name)).map((a) => a.name);
92
+ if (빠진것.length) {
93
+ return { 탈: `인자가 모자랍니다 — /${부른이름} ${인자이름들.map((n) => `<${n}>`).join(' ')} (빠진 것: ${빠진것.join(' · ')})` };
94
+ }
95
+ if (!서버.쓸수있나()) return { 탈: `${서버.이름} 서버가 죽었습니다: ${서버.죽음 ?? '이유 모름'}` };
96
+ try {
97
+ const text = await 서버.프롬프트받기(프롬프트.name, 인자, { signal });
98
+ if (!text.trim()) return { 탈: `${서버.이름} 서버가 빈 프롬프트를 줬습니다` };
99
+ return { text, 서버이름: 서버.이름, 이름: 프롬프트.name };
100
+ } catch (e) {
101
+ return { 탈: `프롬프트를 못 받았습니다 — ${String(e?.message ?? e)}` };
102
+ }
103
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * 파일끼리 누가 누구를 불러 쓰나 — Outline 이 **많이 불리는 파일부터** 싣게 센다 (2.1.3).
3
+ *
4
+ * ── 왜 이게 필요한가 ───────────────────────────────────────────────────
5
+ *
6
+ * Outline 은 폴더의 뼈대를 최근에 손댄 순서로 싣고 창에 맞춰 뒤를 자른다. 그래서 오래 안 건드린 핵심 모듈 —
7
+ * 파일 수십 개가 불러 쓰는 것 — 이 큰 저장소에서 제일 먼저 잘렸다. 모델은 그 모듈이 있는 줄 모르고, 이미
8
+ * 있는 것을 새로 만든다.
9
+ *
10
+ * ── 무엇을 세나 ────────────────────────────────────────────────────────
11
+ *
12
+ * js · ts `import … from './x'` · `import './x'` · `import('./x')` · `require('./x')` · `export … from './x'`
13
+ * — 점으로 시작하는 것만. 꾸러미 이름(`react`)은 이 폴더의 파일이 아니다.
14
+ * 확장자를 빼고 적은 것 · 폴더(index) · `.js` 로 적고 `.ts` 인 것까지 짚는다.
15
+ * py `from a.b import c` · `from .b import c` · `import a.b` — 모듈 파일과 `__init__.py`.
16
+ *
17
+ * 한 파일이 같은 곳을 여러 번 불러도 한 번이다. 정규식이라 문자열 속 `require('x')` 를 셀 수도 있다 —
18
+ * Outline 머리말과 같은 까닭으로 괜찮다. 여기 셈은 **무엇을 먼저 보여 줄지**에만 쓰인다.
19
+ * 읽는 양에 상한을 둔다. 넘으면 넘은 파일은 안 세고(불린 수 0), 그 사실은 부르는 쪽이 적는다.
20
+ */
21
+ import { readFileSync, statSync } from 'node:fs';
22
+ import { dirname, extname, join, resolve } from 'node:path';
23
+
24
+ export const 셀파일상한 = 3000;
25
+ const 한개상한 = 256 * 1024;
26
+
27
+ const js확장 = ['.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '.mts', '.cts'];
28
+ const js꼴 = new Set(js확장);
29
+ const js무늬 = /(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|^\s*import\s+)(['"])(\.{1,2}\/[^'"\n]*|\.{1,2})\1/gm;
30
+ const py무늬from = /^\s*from\s+(\.*)([\w.]*)\s+import\s+\(?([^\n#)]*)/gm;
31
+ const py무늬import = /^\s*import\s+([\w.]+(?:\s+as\s+\w+)?(?:\s*,\s*[\w.]+(?:\s+as\s+\w+)?)*)/gm;
32
+
33
+ /**
34
+ * @param {string} 뿌리 작업 폴더 (파이썬 절대 가져오기의 기준)
35
+ * @param {string[]} 파일들 절대 경로. 이 안에서만 짚고 센다.
36
+ * @returns {Map<string, number>} 절대 경로 → 그 파일을 불러 쓰는 **다른 파일** 수. 넘친 것이 있으면 `.넘침` 에 수.
37
+ */
38
+ export function 들여옴세기(뿌리, 파일들, { 상한 = 셀파일상한 } = {}) {
39
+ const 셈 = new Map();
40
+ // 윈도우 · 맥은 대소문자를 안 가린다 — 적힌 글자와 디스크 글자가 달라도 같은 파일로 짚는다.
41
+ const 안가림 = process.platform === 'win32' || process.platform === 'darwin';
42
+ const 열쇠 = (p) => (안가림 ? p.toLowerCase() : p);
43
+ const 있는것 = new Map();
44
+ for (const p of 파일들) 있는것.set(열쇠(resolve(p)), resolve(p));
45
+ const 짚기 = (후보들) => { for (const c of 후보들) { const 찾음 = 있는것.get(열쇠(c)); if (찾음) return 찾음; } return null; };
46
+
47
+ const js짚기 = (파일, 적힌) => {
48
+ const t = resolve(dirname(파일), 적힌);
49
+ const 후보 = [t, ...js확장.map((e) => t + e), ...js확장.map((e) => join(t, `index${e}`))];
50
+ // ts 는 `./x.js` 로 적고 `x.ts` 를 둔다 (NodeNext).
51
+ const e = extname(t);
52
+ if (e === '.js' || e === '.mjs' || e === '.cjs') {
53
+ const 몸 = t.slice(0, -e.length);
54
+ 후보.push(`${몸}.ts`, `${몸}.tsx`, `${몸}${e === '.mjs' ? '.mts' : e === '.cjs' ? '.cts' : '.ts'}`);
55
+ }
56
+ return 짚기(후보);
57
+ };
58
+ const py모듈 = (기준, 이름) => {
59
+ const 몸 = join(기준, ...이름.split('.').filter(Boolean));
60
+ return 짚기([`${몸}.py`, `${몸}.pyi`, join(몸, '__init__.py')]);
61
+ };
62
+
63
+ let 본수 = 0;
64
+ let 넘침 = 0;
65
+ for (const 파일 of 파일들) {
66
+ const e = extname(파일).toLowerCase();
67
+ const js = js꼴.has(e);
68
+ const py = e === '.py' || e === '.pyi';
69
+ if (!js && !py) continue;
70
+ if (본수 >= 상한) { 넘침++; continue; }
71
+ 본수++;
72
+ let 글;
73
+ try {
74
+ if (statSync(파일).size > 한개상한) continue;
75
+ 글 = readFileSync(파일, 'utf8');
76
+ } catch { continue; }
77
+ const 나 = resolve(파일);
78
+ const 부른것 = new Set();
79
+ if (js) {
80
+ for (const m of 글.matchAll(js무늬)) { const t = js짚기(나, m[2]); if (t) 부른것.add(t); }
81
+ } else {
82
+ for (const m of 글.matchAll(py무늬from)) {
83
+ const 점 = m[1].length;
84
+ const 모듈 = m[2];
85
+ let 기준 = resolve(뿌리);
86
+ if (점) { 기준 = dirname(나); for (let i = 1; i < 점; i++) 기준 = dirname(기준); }
87
+ if (모듈) { const t = py모듈(기준, 모듈); if (t) 부른것.add(t); }
88
+ // `from pkg import util` 의 util 이 하위 모듈일 수 있다.
89
+ for (const 이름 of m[3].split(',').map((x) => x.trim().split(/\s+/)[0]).filter((x) => /^\w+$/.test(x))) {
90
+ const t = py모듈(기준, 모듈 ? `${모듈}.${이름}` : 이름);
91
+ if (t) 부른것.add(t);
92
+ }
93
+ }
94
+ for (const m of 글.matchAll(py무늬import)) {
95
+ for (const 조각 of m[1].split(',')) {
96
+ const 이름 = 조각.trim().split(/\s+/)[0];
97
+ const t = py모듈(resolve(뿌리), 이름);
98
+ if (t) 부른것.add(t);
99
+ }
100
+ }
101
+ }
102
+ 부른것.delete(나);
103
+ for (const t of 부른것) 셈.set(t, (셈.get(t) ?? 0) + 1);
104
+ }
105
+ 셈.넘침 = 넘침;
106
+ return 셈;
107
+ }
@@ -18,13 +18,14 @@ import { TODO_TOOL } from './todo.js';
18
18
  import { TASK_TOOL } from './task.js';
19
19
  import { 고를말 } from '../agent/agents.js';
20
20
  import { OUTLINE_TOOL } from './outline.js';
21
- import { VERIFY_TOOL } from './verify.js';
21
+ import { VERIFY_TOOL, 한파일문법 } from './verify.js';
22
22
  import { DEF_TOOL, REFS_TOOL } from './lsp.js';
23
23
  import { 편집후진단, 붙이기 as 진단붙이기, 데우기 } from '../lsp/diag.js';
24
24
  import { 프로젝트갈래 } from '../lsp/servers.js';
25
25
  import { allow as allowedIn } from '../agent/modes.js';
26
26
  import { 도구정의, 이름풀기 } from '../backend/mcp.js';
27
27
  import { 셸환경 } from '../safety/shellenv.js';
28
+ import { 깃앞보기, 깃뒤적기 } from '../safety/gitsnap.js';
28
29
  import { isExcelPath, readExcel, toText as excelText, summarize as excelSummary } from './excel.js';
29
30
  import { isDocPath, readDoc, toText as docText, summarize as docSummary, looksOldHwp, 옛hwp안내, 문서는못고침 } from './docs.js';
30
31
  import { hwpx만들기, 만든말 } from './hwpxwrite.js';
@@ -725,6 +726,12 @@ function 나중에생긴것적기(떠본것, ctx) {
725
726
  for (const abs of 떠본것?.없던채뜬것 ?? []) {
726
727
  try { if (existsSync(abs) && !statSync(abs).isDirectory()) 떠본것.뜬것.push(ctx.scope.show(abs)); } catch { /* 넘어간다 */ }
727
728
  }
729
+ // 낱말로 못 짚은 것 — git 으로 견줘 안 것 (2.1.3 · safety/gitsnap.js). 이번 턴에 이미 뜬 것은 거기서 거른다.
730
+ if (떠본것?.깃앞) {
731
+ const 깃것 = 깃뒤적기(떠본것.깃앞, { history: ctx.history });
732
+ for (const abs of 깃것.뜬것) 떠본것.뜬것.push(ctx.scope.show(abs));
733
+ for (const x of 깃것.못뜬것) 떠본것.못뜬것.push(`${ctx.scope.show(x.abs)} (${x.왜})`);
734
+ }
728
735
  }
729
736
 
730
737
  /** 지금 파일이 몇 줄인가. 붙인 뒤 '얼마나 찼는지' 를 사실로 말해 주려고 센다. */
@@ -3109,6 +3116,12 @@ export const TOOLS = {
3109
3116
  * 그래도 손으로 옮기고 지우는 흔한 자리는 이걸로 덮인다.
3110
3117
  */
3111
3118
  const 떠본것 = 바꾸기전스냅샷(cmd, ctx);
3119
+ /*
3120
+ * git 저장소면 명령 앞 모습도 떠 둔다 (2.1.3 · safety/gitsnap.js). 낱말로는 못 짚는 자리 — 와일드카드 ·
3121
+ * 스크립트 · 포매터가 바꾼 파일 — 를 명령 뒤에 견줘 되돌리기에 올린다. 뒤에서 도는 명령은 언제 끝날지
3122
+ * 몰라 견줄 자리가 없으니 안 뜬다.
3123
+ */
3124
+ if (args.background !== true && ctx.history?.뒤늦은기록) 떠본것.깃앞 = 깃앞보기(ctx.scope.root);
3112
3125
  const 뜬것 = 떠본것.뜬것;
3113
3126
  /*
3114
3127
  * 못 뜬 것과 상한에 걸린 것은 **결과에 실어 보낸다.**
@@ -4118,6 +4131,16 @@ async function 고친뒤진단(name, r, ctx) {
4118
4131
  데우기(뿌리, 볼것[0]);
4119
4132
 
4120
4133
  const 것들 = await Promise.all(볼것.map((abs) => 편집후진단(뿌리, abs)));
4134
+ /*
4135
+ * 언어 서버가 말을 못 줬으면(없거나 · 아직 데우는 중) 문법만이라도 본다 (2.1.3 · verify.js 의 한파일문법).
4136
+ * 서버가 답했으면 안 본다 — 오류 0개도 답이다. 두 번 보면 같은 탈을 두 줄로 적는다.
4137
+ */
4138
+ if (것들.some((d) => d === null)) {
4139
+ const env = 셸환경(process.env, { 남길것: ctx.셸남길것 ?? [] }).env;
4140
+ await Promise.all(볼것.map(async (abs, i) => {
4141
+ if (것들[i] === null) 것들[i] = await 한파일문법(abs, { 뿌리, env });
4142
+ }));
4143
+ }
4121
4144
  let 답 = r;
4122
4145
  for (let i = 0; i < 볼것.length; i++) {
4123
4146
  답 = 진단붙이기(답, 것들[i], ctx.scope.show(볼것[i]));
@@ -35,6 +35,7 @@ import { walk, SKIP_DIRS, globToRegex, 내부살림 } from './fsutil.js';
35
35
  import { 건너뜀말 } from './ignore.js';
36
36
  import { decode, looksBinary } from './encoding.js';
37
37
  import { 찾을개수, 뼈대줄수 } from '../agent/budget.js';
38
+ import { 들여옴세기 } from './importgraph.js';
38
39
  import { 말, 세말 } from '../i18n/index.js';
39
40
 
40
41
  /* 결과 한 줄을 잇는다 — 빈 조각은 버린다(tools/index.js 의 이어 와 같은 것). */
@@ -289,10 +290,26 @@ export const OUTLINE_TOOL = {
289
290
 
290
291
  if (!파일들.length) return { content: '볼 파일이 없습니다.', summary: 세말('count', 0) };
291
292
 
292
- // 최근에 손댄 것부터. 지금 하는 일과 가까울 가능성이 높다.
293
+ const 파일상한 = 찾을개수(ctx.모델컨텍스트);
294
+ /*
295
+ * ── 싣는 차례 (2.1.3) ────────────────────────────────────────────────
296
+ *
297
+ * 여태는 최근에 손댄 것부터였다. 그러면 오래 안 건드린 **핵심 모듈** — 파일 수십 개가 불러 쓰는 것 —
298
+ * 이 큰 저장소에서 제일 먼저 잘렸다(tools/importgraph.js 머리말). 이제 둘을 섞는다.
299
+ *
300
+ * 1. 지금 손대는 파일 — 30분 안에 바뀐 것, 몇 자리만. 지금 하는 일과 제일 가깝다.
301
+ * 다만 폴더의 절반 넘게가 30분 안이면 막 받아 온(clone) 저장소라 시각이 뜻이 없다 — 이 자리를 안 둔다.
302
+ * 2. 나머지는 많이 불리는 것부터, 같으면 최근 것부터.
303
+ */
304
+ const 셈 = 하나인가 ? new Map() : 들여옴세기(ctx.scope.root, 파일들.map((f) => f.path));
305
+ const 불림 = (f) => 셈.get(f.path) ?? 0;
293
306
  파일들.sort((a, b) => (b.mtime ?? 0) - (a.mtime ?? 0));
307
+ const 제일늦은 = 파일들[0]?.mtime ?? 0;
308
+ const 지금것 = 파일들.filter((f) => (f.mtime ?? 0) >= 제일늦은 - 30 * 60 * 1000);
309
+ const 앞자리 = 지금것.length * 2 > 파일들.length ? [] : 지금것.slice(0, Math.max(2, Math.floor(파일상한 / 8)));
310
+ const 앞 = new Set(앞자리);
311
+ 파일들 = [...앞자리, ...파일들.filter((f) => !앞.has(f)).sort((a, b) => 불림(b) - 불림(a) || (b.mtime ?? 0) - (a.mtime ?? 0))];
294
312
 
295
- const 파일상한 = 찾을개수(ctx.모델컨텍스트);
296
313
  const 줄상한 = 뼈대줄수(ctx.모델컨텍스트);
297
314
  const 볼것 = 파일들.slice(0, 파일상한);
298
315
 
@@ -328,7 +345,8 @@ export const OUTLINE_TOOL = {
328
345
  }
329
346
 
330
347
  뼈대있는파일++;
331
- 줄들.push(`${보인이름} (${줄수}줄)`);
348
+ // 몇 곳에서 불러 쓰나 — 모델이 「여기가 중심이구나」 를 이름만 보고 안다 (2.1.3).
349
+ 줄들.push(`${보인이름} (${줄수}줄${불림(f) ? ` · ${불림(f)}곳에서 불러 씀` : ''})`);
332
350
  쓴줄++;
333
351
  // 파일 하나가 목록을 통째로 먹지 않게 한다. 500개짜리 파일 하나가
334
352
  // 나머지 마흔 개를 밀어내면 '구조를 본다' 는 뜻이 없어진다.