deel-local-cli 1.20.10 → 1.20.12

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/src/reset.js CHANGED
@@ -31,6 +31,7 @@ import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
31
31
  import { c, say, mark, rule, pad } from './ui/ansi.js';
32
32
  import { ask, confirm } from './ui/prompt.js';
33
33
  import { homeDir } from './config.js';
34
+ import { pluginsDir } from './plugins/manage.js';
34
35
  import { 잠긴것인가, 잠금지우기, 보관방식 } from './safety/keystore.js';
35
36
 
36
37
  /** 사람이 칠 수 있는 이름. bin/deel.js 의 도움말·완성 목록과 같아야 한다. */
@@ -41,16 +42,28 @@ export const 갈래들 = ['model', 'memory', 'sessions', 'learned', 'plugins', '
41
42
  // 세다가 나는 탈은 전부 삼킨다. 이건 '무엇이 있나' 를 보여 주려는 것이지
42
43
  // 검사가 아니다 — 한 줄 못 셌다고 초기화 자체를 못 하게 하면 안 된다.
43
44
 
45
+ /*
46
+ * 다만 **없는 것과 못 읽은 것은 가른다.** 둘 다 0 으로 적으면, 지우기 전
47
+ * 확인 화면이 「기억 0줄 → 돌아오지 않습니다」 라고 말한다. 사람은 잃을 것이
48
+ * 없다고 읽고 넘기는데, 바로 다음 줄에서 폴더가 통째로 사라진다.
49
+ * 없으면 0, 못 읽으면 null 이다 — null 은 화면에서 「알 수 없음」 으로 뜬다
50
+ * (설정살피기 가 이미 그렇게 하고 있다).
51
+ */
44
52
  function 줄수(파일) {
53
+ if (!existsSync(파일)) return 0;
45
54
  try { return readFileSync(파일, 'utf8').split('\n').filter((l) => l.trim()).length; }
46
- catch { return 0; }
55
+ catch { return null; }
47
56
  }
48
57
 
49
58
  function 안에것(폴더, 거르기 = () => true) {
59
+ if (!existsSync(폴더)) return 0;
50
60
  try { return readdirSync(폴더, { withFileTypes: true }).filter(거르기).length; }
51
- catch { return 0; }
61
+ catch { return null; }
52
62
  }
53
63
 
64
+ /** 여러 자리를 센 것을 하나로 모은다. 한 자리라도 못 셌으면 못 센 것이다. */
65
+ const 셈모으기 = (값들) => (값들.some((n) => n === null) ? null : 값들.reduce((a, n) => a + n, 0));
66
+
54
67
  /**
55
68
  * 설정을 읽어 본다. **깨져 있어도 답한다.**
56
69
  *
@@ -68,10 +81,21 @@ export function 설정살피기(파일) {
68
81
  return { 있나: true, 프로필: null, 잠긴열쇠: [], 왜: `읽을 수 없습니다 (${한줄})` };
69
82
  }
70
83
  const 목록 = Array.isArray(j?.profiles) ? j.profiles : [];
84
+ /*
85
+ * 연결 말고 **다른 것**이 이 파일에 적혀 있나.
86
+ *
87
+ * 저장소의 `.deel/config.json` 에는 프로필 대신 `mode` 나
88
+ * `permissions.deny` 만 적어 두는 쓰임이 있다 (config.js 가 권하는 그
89
+ * 쓰임이다). 그런 파일을 `deel reset model` 이 통째로 지우면, 사람이 손으로
90
+ * 적어 둔 **금지 규칙**이 초기화 한 번에 없어진다. 이 파일 머리말이
91
+ * 「사람이 손으로 적은 것은 어떤 길로도 안 지운다」 고 적어 둔 그 약속이다.
92
+ */
93
+ const 연결칸 = new Set(['profiles', 'active', '정책주소', 'api-version']);
71
94
  return {
72
95
  있나: true,
73
96
  프로필: 목록.length,
74
97
  잠긴열쇠: 목록.map((p) => p?.apiKey).filter((k) => 잠긴것인가(k)),
98
+ 다른것: Object.keys(j ?? {}).filter((k) => !연결칸.has(k)),
75
99
  왜: '',
76
100
  };
77
101
  }
@@ -91,12 +115,15 @@ export function 살펴보기({ home = homeDir(), root = process.cwd() } = {}) {
91
115
  const 집설정 = 설정살피기(집('config.json'));
92
116
  const 일설정 = 설정살피기(일('config.json'));
93
117
  const 열쇠들 = [...집설정.잠긴열쇠, ...일설정.잠긴열쇠];
118
+ // 저장소 설정에 연결 말고 다른 것이 적혀 있으면 그 파일은 안 건드린다.
119
+ const 일설정남길까 = 일설정.있나 && (일설정.프로필 === null || (일설정.다른것?.length ?? 0) > 0);
120
+ const 플러그인자리 = pluginsDir(home);
94
121
 
95
122
  const 항목 = [
96
123
  {
97
124
  키: 'model',
98
125
  이름: '연결·프로필',
99
- 자리: [집('config.json'), 일('config.json')],
126
+ 자리: [집('config.json'), ...(일설정남길까 ? [] : [일('config.json')])],
100
127
  몇: 집설정.프로필 === null || 일설정.프로필 === null
101
128
  ? null : (집설정.프로필 ?? 0) + (일설정.프로필 ?? 0),
102
129
  단위: '개',
@@ -139,7 +166,7 @@ export function 살펴보기({ home = homeDir(), root = process.cwd() } = {}) {
139
166
  키: '만든것',
140
167
  이름: '증거·내보낸 것·임시',
141
168
  자리: [일('증거'), 일('export'), 일('tmp'), 일('붙인그림')],
142
- 몇: [일('증거'), 일('export'), 일('tmp'), 일('붙인그림')].reduce((n, p) => n + 안에것(p), 0),
169
+ 몇: 셈모으기([일('증거'), 일('export'), 일('tmp'), 일('붙인그림')].map((p) => 안에것(p))),
143
170
  단위: '개',
144
171
  뒤: '안전합니다',
145
172
  all: true,
@@ -148,8 +175,11 @@ export function 살펴보기({ home = homeDir(), root = process.cwd() } = {}) {
148
175
  {
149
176
  키: 'plugins',
150
177
  이름: '플러그인',
151
- 자리: [집('plugins')],
152
- 몇: 안에것(집('plugins'), (e) => e.isDirectory() && !e.name.startsWith('.')),
178
+ // 깔리는 자리와 **같은 함수**로 고른다 (plugins/manage.js 의 pluginsDir).
179
+ // 두 자리가 갈리면 화면이 「0개 · 이미 비어 있습니다」 를 찍는데
180
+ // 플러그인은 그대로 살아 있다.
181
+ 자리: [플러그인자리],
182
+ 몇: 안에것(플러그인자리, (e) => e.isDirectory() && !e.name.startsWith('.')),
153
183
  단위: '개',
154
184
  뒤: c.yellow('다시 설치해야 합니다'),
155
185
  // 따로 고를 때만 지운다. 다시 받는 데 시간이 걸리고, 사람이 초기화하려는
@@ -181,6 +211,7 @@ export function 살펴보기({ home = homeDir(), root = process.cwd() } = {}) {
181
211
  // 어떤 길로도 안 지우는 것. 화면에 이름을 적어 둔다 — 「왜 이건 안 지웠지」
182
212
  // 를 나중에 묻게 하지 않으려고.
183
213
  const 안건드림 = [
214
+ { 이름: '.deel/config.json (연결 말고 다른 것이 적혀 있습니다)', 있나: 일설정남길까 },
184
215
  { 이름: '.deel/mcp.json', 있나: existsSync(일('mcp.json')) },
185
216
  { 이름: '.deelignore', 있나: existsSync(join(root, '.deelignore')) },
186
217
  { 이름: 'DEEL.md', 있나: existsSync(join(root, 'DEEL.md')) },
@@ -156,7 +156,10 @@ export function 훅펴기(raw, 출처) {
156
156
  if (Array.isArray(것)) {
157
157
  for (const x of 것) {
158
158
  const 자리 = 자리풀기(x?.때 ?? x?.자리 ?? x?.event);
159
- if (!자리) { 버린것.push(`모르는 자리: ${x?.때 ?? x?.event ?? '(없음)'}`); continue; }
159
+ // 바로 위에서 `때 · 자리 · event` 셋을 다 보고 골랐는데, 무엇이 틀렸는지
160
+ // 적을 때는 `자리` 만 빠져 있었다. `{자리: '오타'}` 로 적어 둔 사람은
161
+ // 「모르는 자리: (없음)」 을 받는다 — 제가 적은 글자가 화면에 안 나온다.
162
+ if (!자리) { 버린것.push(`모르는 자리: ${x?.때 ?? x?.자리 ?? x?.event ?? '(없음)'}`); continue; }
160
163
  const h = 한훅(자리, x?.도구 ?? x?.matcher ?? '*', x, 출처);
161
164
  if (h) 훅들.push(h); else 버린것.push(`${자리}: 명령이나 무늬가 잘못 적혀 있습니다`);
162
165
  }
@@ -89,6 +89,21 @@ export function 지금모드({ online = false, offline = false } = {}) {
89
89
  return 기본;
90
90
  }
91
91
 
92
+ /**
93
+ * 봉인이 켜져 있나 — 깃발·프로필·설정 **셋 중 하나라도** 켜져 있으면 켜진 것이다.
94
+ *
95
+ * 여태 부르는 자리마다 `깃발 ?? prof.offline ?? cfg.offline` 으로 골랐다.
96
+ * `??` 는 null·undefined 에서만 다음 칸으로 넘어가는데, 부르는 쪽이 깃발을
97
+ * **언제나 boolean 으로** 넘겼다 (bin/deel.js 의 `flags.offline === true || …`).
98
+ * 그래서 첫 칸이 늘 false 로 차 있었고 뒤의 둘은 한 번도 안 읽혔다 —
99
+ * 관리자가 정책에 못 박아 둔 봉인도, 사람이 제 손으로 설정에 적은 봉인도
100
+ * 아무 데도 안 붙어 있었다. 정책은 켜기만 하므로 여기서는 OR 로 모은다.
101
+ */
102
+ export function 봉인됐나({ 깃발 = undefined, prof = null, cfg = null } = {}) {
103
+ return 깃발 === true || 깃발 === 'true'
104
+ || prof?.offline === true || cfg?.offline === true;
105
+ }
106
+
92
107
  /**
93
108
  * 이 주소가 바깥인가.
94
109
  *
@@ -122,18 +122,54 @@ export class History {
122
122
  #maybePrune() {
123
123
  this.#writes = (this.#writes ?? 0) + 1;
124
124
  if (this.#writes % 20 !== 0) return;
125
+ // 앞서 못 줄였으면 한참 쉬었다가 다시 본다 (prune 의 주석).
126
+ if (this.#writes < this.#다음줄이기) return;
125
127
  try {
126
128
  if (statSync(this.file).size < MAX_BYTES) return;
127
129
  } catch { return; }
128
- this.prune();
130
+ /*
131
+ * 줄이다 터져도 **고치던 파일까지 말려들면 안 된다.**
132
+ *
133
+ * 이 함수는 snapshot() 안에서 돈다 — 즉 Write·Edit 이 파일을 쓰기
134
+ * 직전이다. prune() 이 이력을 읽다 던지면(EACCES·EBUSY) 그 예외가
135
+ * 도구 밖으로 새어 나가, 사람은 고치려던 파일과 아무 상관 없는
136
+ * 「EACCES: …/edits.jsonl」 을 보게 된다. 이력을 못 줄인 것은
137
+ * 위쪽(줄이기못함)에 남기고, 고치는 일은 그대로 이어 간다.
138
+ */
139
+ try { this.prune(); }
140
+ catch (err) {
141
+ this.줄이기못함 = err?.message ?? String(err);
142
+ this.#다음줄이기 = this.#writes + 500;
143
+ }
129
144
  }
130
145
 
146
+ #다음줄이기 = 0;
147
+ /** 이력을 못 줄였으면 그 까닭. 못 줄이면 안전망이 제 무게로 무너진다. */
148
+ 줄이기못함 = null;
149
+
131
150
  #writes = 0;
132
151
 
133
152
  /** 최근 keep 개 턴만 남기고 자른다. 버린 줄 수를 돌려준다. */
134
153
  prune({ keep = KEEP_TURNS } = {}) {
135
- const recs = this.all();
136
- const turns = this.turns();
154
+ /*
155
+ * ── **읽다 터지는 것**도 못 줄인 것이다 ────────────────────────────
156
+ *
157
+ * 아래 쓰기만 감싸 두고 여기 읽기는 맨몸이었다. 그런데 이력을 못 읽는
158
+ * 판(권한·잠김·자리가 폴더로 바뀜)은 못 쓰는 판만큼이나 흔하고, 그때
159
+ * 예외가 여기를 뚫고 나가면 부르는 쪽 — 파일을 쓰던 Write·Edit — 이
160
+ * 고치려던 파일과 아무 상관 없는 오류를 뒤집어쓴다.
161
+ *
162
+ * 못 줄인 것은 못 줄였다고 위쪽(줄이기못함)에 남기고 0을 돌려준다.
163
+ * 그게 이 함수가 실패를 말하는 유일한 방식이라 여기서도 같아야 한다.
164
+ */
165
+ let recs;
166
+ let turns;
167
+ try { recs = this.all(); turns = this.turns(); }
168
+ catch (err) {
169
+ this.줄이기못함 = err?.message ?? String(err);
170
+ this.#다음줄이기 = (this.#writes ?? 0) + 500;
171
+ return 0;
172
+ }
137
173
  if (turns.length <= keep) return 0;
138
174
  const 남길턴 = new Set(turns.slice(-keep));
139
175
  const 남길것 = recs.filter((r) => 남길턴.has(r.turn));
@@ -143,7 +179,22 @@ export class History {
143
179
  writeFileSync(this.file, 남길것.map((r) => JSON.stringify(r)).join('\n') + (남길것.length ? '\n' : ''), 'utf8');
144
180
  // 통째로 다시 쓰면 새 파일일 수 있다 — 빗장을 다시 건다 (agent/store.js 도 같다).
145
181
  this.#잠갔나 = false; this.#잠그기();
146
- } catch { return 0; }
182
+ this.줄이기못함 = null;
183
+ } catch (err) {
184
+ /*
185
+ * 못 줄인 것을 **0개 버렸다**와 같은 값으로 돌려주고 있었다.
186
+ *
187
+ * 부르는 쪽은 둘을 구별할 길이 없다. 그리고 실패는 대개 이어진다 —
188
+ * 읽기 전용이거나 디스크가 찬 것이라 다음 번에도 같다. 그러면 이력은
189
+ * 32MB 를 넘긴 채 끝없이 자라는데 화면에는 영영 안 뜬다.
190
+ *
191
+ * 곧바로 또 시도하지도 않는다. 32MB 짜리 파일을 스무 번 쓸 때마다
192
+ * 통째로 다시 읽는 셈이라, 실패가 이어지면 그 자체가 느려지는 원인이 된다.
193
+ */
194
+ this.줄이기못함 = err?.message ?? String(err);
195
+ this.#다음줄이기 = (this.#writes ?? 0) + 500;
196
+ return 0;
197
+ }
147
198
  return 버린수;
148
199
  }
149
200
 
@@ -152,12 +203,30 @@ export class History {
152
203
  try { return statSync(this.file).size; } catch { return 0; }
153
204
  }
154
205
 
206
+ /*
207
+ * ── 깨진 줄은 **되돌릴 수 없는 파일**이다 ────────────────────────────
208
+ *
209
+ * 여태 `catch { return null }` 로 조용히 버렸다. 그런데 이 파일의 한 줄은
210
+ * 곧 한 파일의 원문이다 — 줄이 깨졌다는 것은 그 파일을 되돌릴 길이
211
+ * 사라졌다는 뜻이다. 그걸 안 세면 `/undo` 는 「파일 3개를 되돌렸습니다」
212
+ * 라고만 하고, 넷째 파일이 왜 안 돌아왔는지는 아무 데도 안 남는다.
213
+ *
214
+ * 줄은 실제로 깨진다. 쓰다 죽으면 마지막 줄이 반만 적히고, 같은 폴더에서
215
+ * 창을 둘 띄워 놓으면 큰 줄 둘이 서로 끼어든다(appendFileSync 는 긴 줄을
216
+ * 통째로 보장하지 않는다).
217
+ */
218
+ /** 마지막으로 읽을 때 깨져 있던 줄 수. 0 이 아니면 그만큼 못 되돌린다. */
219
+ 깨진줄 = 0;
220
+
155
221
  all() {
156
222
  if (!existsSync(this.file)) return [];
157
- return readFileSync(this.file, 'utf8')
223
+ let 깨진 = 0;
224
+ const 것들 = readFileSync(this.file, 'utf8')
158
225
  .split('\n').filter(Boolean)
159
- .map((l) => { try { return JSON.parse(l); } catch { return null; } })
226
+ .map((l) => { try { return JSON.parse(l); } catch { 깨진 += 1; return null; } })
160
227
  .filter(Boolean);
228
+ this.깨진줄 = 깨진;
229
+ return 것들;
161
230
  }
162
231
 
163
232
  turns() {
@@ -232,10 +301,31 @@ export class History {
232
301
  */
233
302
  const 못한것 = new Set(restored.filter((x) => x.ok === false).map((x) => x.path));
234
303
  const keep = recs.filter((r) => !turns.includes(r.turn) || 못한것.has(r.path));
235
- writeFileSync(this.file, keep.map((r) => JSON.stringify(r)).join('\n') + (keep.length ? '\n' : ''), 'utf8');
236
- this.#잠갔나 = false; this.#잠그기();
304
+ /*
305
+ * ── 여기가 터지면 **파일은 이미 되돌아가 있다** ────────────────────
306
+ *
307
+ * 감싸지 않은 쓰기였다. 던지면 위에서 받아 「명령을 처리하다 막혔습니다」
308
+ * 한 줄만 남는다 — 그런데 디스크의 파일들은 방금 되돌아갔다. 화면이
309
+ * 실패라고 말하는데 실제로는 성공한, 이 저장소가 계속 잡아 온 어긋남이
310
+ * 방향만 뒤집힌 꼴이다.
311
+ *
312
+ * 게다가 이력을 못 줄였으니 그 턴이 그대로 남는다. 사람이 「실패했으니
313
+ * 다시」 하고 /undo 를 또 치면 **같은 턴**을 또 되돌린다 — 두 턴을
314
+ * 되돌린 줄 알지만 한 턴이다.
315
+ */
316
+ let 이력줄임 = { ok: true };
317
+ try {
318
+ writeFileSync(this.file, keep.map((r) => JSON.stringify(r)).join('\n') + (keep.length ? '\n' : ''), 'utf8');
319
+ this.#잠갔나 = false; this.#잠그기();
320
+ } catch (err) {
321
+ 이력줄임 = { ok: false, 왜: err?.message ?? String(err) };
322
+ }
237
323
  return {
238
324
  restored,
325
+ // 이력에서 그 턴을 지웠나. 못 지웠으면 같은 턴이 그대로 남아 있다.
326
+ 이력줄임,
327
+ // 이력에서 못 읽은 줄 수. 그만큼은 되돌릴 길이 애초에 없었다.
328
+ 깨진줄: this.깨진줄,
239
329
  // 화면·감사기록이 쓰는 수. **진짜로 되돌아간 것만** 센다.
240
330
  되돌린수: restored.filter((x) => x.ok === true).length,
241
331
  못한것: restored.filter((x) => x.ok === false),
package/src/setup.js CHANGED
@@ -12,7 +12,7 @@ import { 보관방식 } from './safety/keystore.js';
12
12
  import { 애저풀기, 애저base } from './backend/azure.js';
13
13
  import { allowEndpoint } from './safety/network.js';
14
14
  import { 인증서설정, 인증서등록 } from './backend/clientcert.js';
15
- import { 바깥인가 } from './safety/runmode.js';
15
+ import { 바깥인가, 봉인됐나 } from './safety/runmode.js';
16
16
  import { 제공자들, 제공자고르기, 어디것일까, 주소후보, 막힌까닭 } from './providers/index.js';
17
17
  import { writeFileSync } from 'node:fs';
18
18
 
@@ -107,8 +107,21 @@ async function chooseModel(found) {
107
107
  return items[i].label;
108
108
  }
109
109
 
110
- // 진단을 돌리고 결과를 화면에 그린다.
111
- export async function runProbe(conn, { out = null } = {}) {
110
+ /*
111
+ * 진단을 돌리고 결과를 화면에 그린다.
112
+ *
113
+ * **봉인은 진단에도 걸린다.** 여기가 빠져 있었다 — 관리자가 정책으로 밖을
114
+ * 막아 둔 PC 에서도 `deel diagnose` 는 그냥 나갔다. 자물쇠가 명령 하나로
115
+ * 열리면 자물쇠가 아니다. 사내망 주소는 봉인에서도 그대로 간다.
116
+ */
117
+ export async function runProbe(conn, { out = null, 봉인 = false } = {}) {
118
+ if (봉인 && 바깥인가(conn.base)) {
119
+ say('');
120
+ say(` ${mark.no} ${c.red('봉인되어 있어 이 주소는 안 두드립니다.')} ${c.gray(conn.base)}`);
121
+ say(` ${c.gray('관리 정책이나 설정에 offline 이 켜져 있습니다. 사내망 주소는 그대로 됩니다.')}`);
122
+ say('');
123
+ return { facts: null, results: null, v: null, 봉인: true };
124
+ }
112
125
  allowEndpoint(conn.base); // 진단도 이 주소 하나로만 나간다
113
126
  renderHeader({ shape: conn.kind, base: conn.base, auth: conn.auth, model: conn.model });
114
127
  const { facts, results } = await probe(conn, renderLine);
@@ -344,9 +357,11 @@ export async function runSetup() {
344
357
  export async function runDiagnose(flags) {
345
358
  banner();
346
359
  let conn;
360
+ let 쓴설정 = null;
361
+ let 쓴프로필 = null;
347
362
 
348
363
  if (flags.url) {
349
- load(); // 설정의 api-version 을 먼저 읽는다 (runSetup 과 같은 이유)
364
+ 쓴설정 = load(); // 설정의 api-version 을 먼저 읽는다 (runSetup 과 같은 이유)
350
365
  const key = flags.key ?? process.env.DEEL_API_KEY ?? '';
351
366
  const found = await connect(flags.url, key);
352
367
  if (!found) return 1;
@@ -356,6 +371,8 @@ export async function runDiagnose(flags) {
356
371
  } else {
357
372
  const cfg = load();
358
373
  const prof = activeProfile(cfg);
374
+ 쓴설정 = cfg;
375
+ 쓴프로필 = prof;
359
376
  if (!prof) {
360
377
  say(` ${mark.warn} 저장된 연결이 없습니다. ${c.cyan('deel setup')} 을 먼저 실행하세요.`);
361
378
  say(` ${c.gray('또는')} deel diagnose --url <주소> --key <키> --model <모델>`);
@@ -377,7 +394,11 @@ export async function runDiagnose(flags) {
377
394
  };
378
395
  }
379
396
 
380
- await runProbe(conn, { out: flags.out ?? null });
397
+ /*
398
+ * 봉인은 --url 로 주소를 직접 준 갈래에도 걸린다. 오히려 그쪽이 더 위험하다
399
+ * — 설정에 없는 주소를 그 자리에서 두드리는 길이기 때문이다.
400
+ */
401
+ await runProbe(conn, { out: flags.out ?? null, 봉인: 봉인됐나({ 깃발: flags.offline, prof: 쓴프로필, cfg: 쓴설정 }) });
381
402
  return 0;
382
403
  }
383
404
 
@@ -11,6 +11,8 @@ import { readdirSync, readFileSync, existsSync } from 'node:fs';
11
11
  import { join, basename, dirname, resolve } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { homedir } from 'node:os';
14
+ import { homeDir } from '../config.js';
15
+ import { pluginsDir } from '../plugins/manage.js';
14
16
  // 저장소에 딸려 온 스킬·명령이 시스템 글에 실리지 않게 한다 (discover 안구절 머리말).
15
17
  import { 믿나 } from '../safety/trust.js';
16
18
 
@@ -163,6 +165,7 @@ function countSkillDirs(rootDir) {
163
165
  */
164
166
  export function discover(root, opts = {}) {
165
167
  const home = opts.home ?? homedir();
168
+ const 살림 = opts.살림 ?? (opts.home ? join(opts.home, '.deel') : homeDir());
166
169
  const caps = { skills: opts.maxSkills ?? 400, commands: opts.maxCommands ?? 400 };
167
170
  const skills = [];
168
171
  const commands = [];
@@ -177,7 +180,13 @@ export function discover(root, opts = {}) {
177
180
  // 같은 플러그인이 cache/ 와 marketplaces/ 양쪽에 있을 수 있다.
178
181
  // 이름이 같으면 알맹이가 더 많은 쪽 하나만 쓴다.
179
182
  const roots = [];
180
- for (const base of [join(home, '.deel', 'plugins'), join(home, '.claude', 'plugins')]) {
183
+ /*
184
+ * 우리 플러그인은 **살림 자리** 밑이다 (`~/.deel` 또는 `DEEL_HOME`).
185
+ * OS 집 폴더에 `.deel` 을 직접 붙이면 `DEEL_HOME` 을 쓰는 휴대용 설치에서
186
+ * 깔아 놓은 플러그인의 스킬이 안 보인다 — 깔리는 자리(plugins/manage.js)와
187
+ * 여기가 갈리기 때문이다. 클로드 것(.claude)은 그쪽 규칙이라 집 폴더 그대로다.
188
+ */
189
+ for (const base of [pluginsDir(살림), join(home, '.claude', 'plugins')]) {
181
190
  if (existsSync(base)) roots.push(...findPluginRoots(base));
182
191
  }
183
192
  const best = new Map();
@@ -297,6 +297,8 @@ const 한숨 = () => new Promise((풀기) => setImmediate(풀기));
297
297
  export async function walk(root, { limit = 훑기상한(), skipDirs = SKIP_DIRS, ignore = true, signal = null } = {}) {
298
298
  const out = [];
299
299
  const 건너뜀 = { 폴더: 0, 파일: 0 };
300
+ // .gitignore 로 건너뛴 것과 다르다 — 이쪽은 우리가 늘 안 보는 살림 폴더다.
301
+ const 건너뛴살림 = [];
300
302
  let 끊김 = false;
301
303
  let 본것 = 0;
302
304
  const stack = [{ dir: root, rel: '', 규칙: ignore ? 뿌리규칙읽기(root) : [] }];
@@ -315,7 +317,10 @@ export async function walk(root, { limit = 훑기상한(), skipDirs = SKIP_DIRS,
315
317
  const full = join(dir, e.name);
316
318
  const erel = rel ? `${rel}/${e.name}` : e.name;
317
319
  if (e.isDirectory()) {
318
- if (skipDirs.has(e.name)) continue;
320
+ // 살림 폴더(node_modules·.git·dist…)는 안 훑는다. 다만 **몇 개를
321
+ // 안 봤는지는 센다** — Move 가 폴더를 통째로 옮길 때 이 수를 모르면
322
+ // 「폴더 12개 파일」 이라고 말해 놓고 3만 개를 옮기게 된다.
323
+ if (skipDirs.has(e.name)) { 건너뛴살림.push(erel); continue; }
319
324
  if (여기규칙.length && 걸리나(erel, true, 여기규칙)) { 건너뜀.폴더 += 1; continue; }
320
325
  stack.push({ dir: full, rel: erel, 규칙: 여기규칙 });
321
326
  } else if (e.isFile()) {
@@ -335,6 +340,7 @@ export async function walk(root, { limit = 훑기상한(), skipDirs = SKIP_DIRS,
335
340
  if (끊김) break;
336
341
  }
337
342
  Object.defineProperty(out, '건너뜀', { value: 건너뜀, enumerable: false });
343
+ Object.defineProperty(out, '건너뛴살림', { value: 건너뛴살림, enumerable: false });
338
344
  // 상한까지 찼으면 "여기서 멈췄다" 고 표시한다. 딱 맞아떨어져 끝난 경우까지
339
345
  // 잘렸다고 하게 되지만, 그쪽으로 틀리는 편이 낫다 — 덜 봤다고 말하는 것은
340
346
  // 사람을 한 번 더 보게 만들 뿐이고, 다 봤다고 말하는 것은 못 보게 만든다.