deel-local-cli 1.10.0 → 1.13.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.
Files changed (55) hide show
  1. package/README.ko.md +1167 -1166
  2. package/README.md +1224 -1223
  3. package/bin/deel.js +1 -1
  4. package/package.json +2 -2
  5. package/src/acp/map.js +367 -362
  6. package/src/acp/serve.js +810 -769
  7. package/src/agent/budget.js +156 -153
  8. package/src/agent/commit.js +534 -511
  9. package/src/agent/compact.js +158 -31
  10. package/src/agent/effort.js +308 -197
  11. package/src/agent/evolve.js +263 -213
  12. package/src/agent/filemem.js +16 -2
  13. package/src/agent/loop.js +1728 -1516
  14. package/src/agent/memory.js +156 -152
  15. package/src/agent/mention.js +210 -210
  16. package/src/agent/recall.js +222 -209
  17. package/src/agent/session.js +1056 -948
  18. package/src/backend/adapter.js +1355 -798
  19. package/src/backend/cachemark.js +223 -0
  20. package/src/backend/detect.js +327 -288
  21. package/src/backend/http.js +460 -458
  22. package/src/backend/mcp.js +407 -395
  23. package/src/backend/price.js +260 -197
  24. package/src/backend/probe.js +487 -473
  25. package/src/backend/quota.js +274 -157
  26. package/src/backend/retry.js +30 -6
  27. package/src/backend/tokens.js +59 -0
  28. package/src/backend/toolfit.js +352 -325
  29. package/src/backend/wire.js +715 -0
  30. package/src/commands.js +3184 -2998
  31. package/src/config.js +283 -244
  32. package/src/i18n/en.js +545 -520
  33. package/src/i18n/ja.js +498 -473
  34. package/src/i18n/ko.js +592 -567
  35. package/src/i18n/zh.js +498 -473
  36. package/src/oneshot.js +625 -549
  37. package/src/pack/tar.js +154 -132
  38. package/src/pack/zip.js +235 -217
  39. package/src/plugins/manage.js +416 -336
  40. package/src/providers/bedrock.js +17 -0
  41. package/src/repl.js +2616 -2515
  42. package/src/safety/audit.js +148 -129
  43. package/src/safety/guard.js +641 -526
  44. package/src/safety/network.js +200 -157
  45. package/src/tools/fastgrep.js +229 -214
  46. package/src/tools/fsutil.js +291 -250
  47. package/src/tools/index.js +2560 -2523
  48. package/src/tools/jobs.js +876 -795
  49. package/src/tools/label.js +52 -0
  50. package/src/tools/spawn.js +213 -99
  51. package/src/tools/verify.js +358 -357
  52. package/src/tools/webfetch.js +418 -393
  53. package/src/ui/export.js +233 -217
  54. package/src/ui/pick.js +115 -115
  55. package/src/ui/status.js +617 -610
@@ -1,336 +1,416 @@
1
- // 플러그인 설치·삭제·묶기.
2
- //
3
- // 온라인 기기에서 /plugin install 로 받고, /plugin pack 으로 묶어
4
- // 오프라인 기기에 반입한다. 오프라인에서는 압축만 풀면 그대로 인식된다.
5
- import { execFile } from 'node:child_process';
6
- import { homedir } from 'node:os';
7
- import { join, dirname, basename } from 'node:path';
8
- import {
9
- existsSync, mkdirSync, writeFileSync, readFileSync, rmSync,
10
- readdirSync, statSync,
11
- } from 'node:fs';
12
- import { untargz, stripTop, 밖을가리키는것 } from '../pack/tar.js';
13
- import { makeZip } from '../pack/zip.js';
14
- import { allowTemporarily, isOffline, NetBlocked } from '../safety/network.js';
15
- import { 원시요청, 몸읽기 } from '../backend/http.js';
16
-
17
- /**
18
- * 플러그인을 받다 되돌림(redirect)이 오면 따라가도 되는 곳인가. 던지면 안 따라간다.
19
- * github 가 제 다른 집으로 보내는 것만 따라간다 — https 이고, github.com · githubusercontent.com 뿐.
20
- */
21
- export function 플러그인되돌림(다음) {
22
- if (다음.protocol !== 'https:') throw new Error(`${다음.protocol} 로 되돌립니다 — 따라가지 않습니다`);
23
- if (!/(^|\.)github\.com$|(^|\.)githubusercontent\.com$/i.test(다음.hostname)) {
24
- throw new Error(`github 밖(${다음.hostname})으로 되돌립니다 — 따라가지 않습니다`);
25
- }
26
- }
27
- import { copyDir } from '../tools/fsutil.js';
28
-
29
- export const pluginsDir = (home = homedir()) => join(home, '.deel', 'plugins');
30
-
31
- // owner/repo · owner/repo#가지 · 전체 URL 을 모두 받는다.
32
- export function parseSpec(spec) {
33
- let s = String(spec).trim();
34
- let ref = null;
35
- const hash = s.lastIndexOf('#');
36
- if (hash > 0) { ref = s.slice(hash + 1); s = s.slice(0, hash); }
37
-
38
- s = s.replace(/\.git$/, '');
39
- const m = /^(?:https?:\/\/github\.com\/)?([\w.-]+)\/([\w.-]+)$/.exec(s);
40
- if (!m) return null;
41
- return { owner: m[1], repo: m[2], ref, url: `https://github.com/${m[1]}/${m[2]}.git` };
42
- }
43
-
44
- function has(cmd) {
45
- return new Promise((res) => {
46
- execFile(cmd, ['--version'], { timeout: 8000, windowsHide: true }, (err) => res(!err));
47
- });
48
- }
49
-
50
- function run(cmd, args, opts = {}) {
51
- return new Promise((res) => {
52
- execFile(cmd, args, { timeout: 180000, windowsHide: true, maxBuffer: 1 << 24, ...opts },
53
- (err, stdout, stderr) => res({ ok: !err, out: `${stdout}${stderr}`.trim() }));
54
- });
55
- }
56
-
57
- /**
58
- * 받은 묶음을 폴더에 푼다.
59
- *
60
- * ── 왜 따로 떼어 놨나 ───────────────────────────────────────────────────
61
- *
62
- * 이 몇 줄이 **남이 준 글자로 디스크에 파일을 쓰는 자리**다. 이 프로그램에서
63
- * 제일 조심해야 하는 대목인데, fetchInto 안에 묻혀 있어서 검사가 한 번도 안
64
- * 지나갔다. 거기까지 가려면 진짜로 네트워크에서 tarball 을 받아야 했기 때문이다.
65
- *
66
- * 그래서 「받는 일」 과 「푸는 일」 을 갈랐다. 푸는 일은 이제 파일 목록만 주면
67
- * 그대로 재 볼 수 있다 — 남이 노리고 만든 묶음을 먹여 보는 것을 포함해서.
68
- *
69
- * ── 무엇을 막나 ─────────────────────────────────────────────────────────
70
- *
71
- * tar 안의 이름은 남이 적은 글자다. `../../../..` 하나면 플러그인 폴더 밖에
72
- * 파일을 쓴다. 아래 mkdirSync 가 recursive 라 없는 폴더까지 만들어 가며 나간다.
73
- * 이 프로그램이 파는 문장이 「작업 폴더 밖은 안 만진다」 인데, 플러그인을 받는
74
- * 이 길에만 그 문장을 지키는 코드가 없었다.
75
- *
76
- * 나쁜 것만 골라 버리지 않고 **묶음째 거절한다.** 밖을 가리키는 이름이 든
77
- * 묶음은 실수가 아니라 노린 것이고, 나머지를 풀어 줄 까닭이 없다.
78
- * 그리고 **rmSync 앞에서** 막는다 — 거절할 묶음 때문에 이미 깔린 것을
79
- * 지워 버리면 안 된다.
80
- */
81
- export function 묶음풀기(dest, files) {
82
- if (!files?.length) return { error: '받은 묶음이 비어 있습니다' };
83
- const 밖엣것 = 밖을가리키는것(dest, files);
84
- if (밖엣것.length) {
85
- return { error: `묶음 안에 플러그인 폴더 밖을 가리키는 이름이 있습니다 — 풀지 않았습니다: ${밖엣것[0].name}` };
86
- }
87
- rmSync(dest, { recursive: true, force: true });
88
- for (const f of files) {
89
- const p = join(dest, f.name);
90
- mkdirSync(dirname(p), { recursive: true });
91
- writeFileSync(p, f.data);
92
- }
93
- return { 푼것: files.length };
94
- }
95
-
96
- // 받는 방법 두 가지. git 이 있으면 clone, 없으면 tarball 을 내려받아 푼다.
97
- async function fetchInto(spec, dest, onStep) {
98
- if (await has('git')) {
99
- onStep?.('git clone');
100
- rmSync(dest, { recursive: true, force: true });
101
- mkdirSync(dirname(dest), { recursive: true });
102
- const args = ['clone', '--depth', '1'];
103
- if (spec.ref) args.push('--branch', spec.ref);
104
- args.push(spec.url, dest);
105
- const r = await run('git', args);
106
- if (!r.ok) return { error: `git clone 실패 — ${r.out.split('\n').slice(-2).join(' ')}` };
107
- rmSync(join(dest, '.git'), { recursive: true, force: true });
108
- return { how: 'git' };
109
- }
110
-
111
- onStep?.('tarball 내려받기');
112
- if (isOffline()) return { error: '오프라인 모드입니다 — 받아 올 수 없습니다. 풀어 놓은 폴더 경로를 주세요.' };
113
- for (const branch of spec.ref ? [spec.ref] : ['main', 'master']) {
114
- const url = `https://codeload.github.com/${spec.owner}/${spec.repo}/tar.gz/refs/heads/${branch}`;
115
- // 사용자가 이 명령을 친 동안만 github 를 연다. 끝나면 바로 닫는다.
116
- const close = allowTemporarily(url);
117
- let gz;
118
- // 한 문(backend/http.js)으로 나간다 — 프록시를 거치고, 되돌림은 홉마다 문지기를 지난다.
119
- // github 는 받기 주소를 제 다른 집(githubusercontent 등)으로 되돌리므로 그 홉은 열어 준다 —
120
- // 그 밖의 집이면 안 따라가고, 왜 못 받았는지를 말한다 (조용히 '주소를 확인하세요' 로 끝내지 않는다).
121
- // 상한까지만 받는다. 플러그인 묶음이 64MB 를 넘을 일은 없고, 넘는 것을 다 받아 줄 이유도 없다.
122
- const 열어둔 = [];
123
- try {
124
- const res = await 원시요청(url, {
125
- timeout: 120000, stream: true,
126
- 되돌림: (다음) => { 플러그인되돌림(다음); 열어둔.push(allowTemporarily(다음.origin)); },
127
- });
128
- if (!res.ok) { await res.버리기?.(); continue; }
129
- gz = await 몸읽기(res.res.body, 64 * 1024 * 1024);
130
- } catch (err) {
131
- if (err instanceof NetBlocked || /되돌립니다/.test(String(err?.message))) return { error: `받지 못했습니다 — ${err.message}` };
132
- continue;
133
- } finally {
134
- close();
135
- for (const 닫기 of 열어둔) 닫기();
136
- }
137
- if (!gz) return { error: '받은 묶음이 너무 큽니다 (64MB 넘음) — 플러그인 저장소가 맞는지 확인하세요' };
138
- // 받는 크기만 막으면 압축 폭탄에 그대로 당한다 (pack/tar.js 푼것상한 머리말).
139
- // 던지는 것을 여기서 받아 화면에 올린다 — 안 받으면 명령이 통째로 죽는다.
140
- let 푼것;
141
- try { 푼것 = 묶음풀기(dest, stripTop(untargz(gz))); }
142
- catch (err) { return { error: `받은 묶음을 풀지 못했습니다 — ${String(err?.message ?? err)}` }; }
143
- if (푼것.error) return { error: 푼것.error };
144
- return { how: 'tarball', branch };
145
- }
146
- return { error: '받지 못했습니다 — 저장소 주소나 가지 이름을 확인하세요' };
147
- }
148
-
149
- function manifestOf(dir) {
150
- const f = join(dir, '.claude-plugin', 'plugin.json');
151
- if (!existsSync(f)) return null;
152
- try { return JSON.parse(readFileSync(f, 'utf8')); } catch { return null; }
153
- }
154
-
155
- function countIn(dir) {
156
- let skills = 0;
157
- let commands = 0;
158
- let hooks = 0;
159
- for (const d of ['skills', join('.agents', 'skills'), join('.claude', 'skills')]) {
160
- const p = join(dir, d);
161
- if (!existsSync(p)) continue;
162
- for (const e of readdirSync(p, { withFileTypes: true })) {
163
- if (e.isDirectory() && existsSync(join(p, e.name, 'SKILL.md'))) skills++;
164
- }
165
- }
166
- for (const d of ['commands', join('.claude', 'commands'), join('.agents', 'commands')]) {
167
- const p = join(dir, d);
168
- if (!existsSync(p)) continue;
169
- for (const e of readdirSync(p, { withFileTypes: true })) {
170
- if (e.isFile() && e.name.endsWith('.md')) commands++;
171
- }
172
- }
173
- hooks = walkCount(dir, (n) => /\.(js|cjs|mjs|sh|ps1|cmd|bat|py)$/i.test(n));
174
- return { skills, commands, hooks };
175
- }
176
-
177
- function walkCount(dir, match, depth = 5) {
178
- let n = 0;
179
- const stack = [{ d: dir, k: 0 }];
180
- while (stack.length) {
181
- const { d, k } = stack.pop();
182
- if (k > depth) continue;
183
- let es;
184
- try { es = readdirSync(d, { withFileTypes: true }); } catch { continue; }
185
- for (const e of es) {
186
- if (e.isDirectory()) { if (e.name !== 'node_modules' && e.name !== '.git') stack.push({ d: join(d, e.name), k: k + 1 }); }
187
- else if (match(e.name)) n++;
188
- }
189
- }
190
- return n;
191
- }
192
-
193
- export async function install(spec, { home = homedir(), onStep } = {}) {
194
- const base = pluginsDir(home);
195
-
196
- // 이미 풀어 놓은 폴더를 그대로 넣는 길. 오프라인 기기에서 이쪽을 쓴다.
197
- const asPath = String(spec).trim().replace(/^["']|["']$/g, '');
198
- const isLocal = existsSync(asPath) && statSync(asPath).isDirectory();
199
-
200
- const parsed = isLocal ? null : parseSpec(spec);
201
- if (!isLocal && !parsed) {
202
- return { error: `주소를 알아볼 수 없습니다: ${spec}\n 예: affaan-m/ECC · https://github.com/affaan-m/ECC · C:\\받은폴더\\ecc` };
203
- }
204
-
205
- const tmp = join(base, `.tmp-${isLocal ? 'local' : parsed.repo}`);
206
- let got;
207
- if (isLocal) {
208
- onStep?.('폴더 복사');
209
- rmSync(tmp, { recursive: true, force: true });
210
- mkdirSync(dirname(tmp), { recursive: true });
211
- copyDir(asPath, tmp);
212
- rmSync(join(tmp, '.git'), { recursive: true, force: true });
213
- got = { how: '폴더', from: asPath };
214
- } else {
215
- got = await fetchInto(parsed, tmp, onStep);
216
- }
217
- if (got.error) { rmSync(tmp, { recursive: true, force: true }); return { error: got.error }; }
218
-
219
- const info = manifestOf(tmp);
220
- const name = info?.name || (isLocal ? basename(asPath) : parsed.repo);
221
- const dest = join(base, name);
222
-
223
- const counts = countIn(tmp);
224
- if (!counts.skills && !counts.commands) {
225
- rmSync(tmp, { recursive: true, force: true });
226
- return { error: `스킬도 명령도 없습니다. 플러그인이 맞는지 확인하세요 (${isLocal ? asPath : parsed.owner + '/' + parsed.repo})` };
227
- }
228
-
229
- rmSync(dest, { recursive: true, force: true });
230
- mkdirSync(dirname(dest), { recursive: true });
231
- copyDir(tmp, dest);
232
- rmSync(tmp, { recursive: true, force: true });
233
-
234
- // 어디서 왔는지 남긴다 나중에 반입 심사에서 출처를 물어본다.
235
- writeFileSync(join(dest, '.deel-source.json'), JSON.stringify({
236
- from: isLocal ? asPath : `${parsed.owner}/${parsed.repo}`,
237
- ref: got.branch ?? parsed?.ref ?? null,
238
- how: got.how,
239
- at: new Date().toISOString(),
240
- license: info?.license ?? null,
241
- }, null, 2) + '\n', 'utf8');
242
-
243
- return { name, version: info?.version ?? '', license: info?.license ?? null, path: dest, ...counts, how: got.how };
244
- }
245
-
246
- export function list({ home = homedir() } = {}) {
247
- const base = pluginsDir(home);
248
- if (!existsSync(base)) return [];
249
- const out = [];
250
- for (const e of readdirSync(base, { withFileTypes: true })) {
251
- if (!e.isDirectory() || e.name.startsWith('.')) continue;
252
- const dir = join(base, e.name);
253
- const info = manifestOf(dir);
254
- let src = null;
255
- const sf = join(dir, '.deel-source.json');
256
- if (existsSync(sf)) { try { src = JSON.parse(readFileSync(sf, 'utf8')); } catch {} }
257
- out.push({
258
- name: info?.name || e.name,
259
- version: info?.version ?? '',
260
- license: info?.license ?? src?.license ?? null,
261
- from: src?.from ?? '(직접 넣음)',
262
- path: dir,
263
- ...countIn(dir),
264
- });
265
- }
266
- return out.sort((a, b) => b.skills - a.skills);
267
- }
268
-
269
- export function remove(name, { home = homedir() } = {}) {
270
- const dir = join(pluginsDir(home), name);
271
- if (!existsSync(dir)) return { error: `설치돼 있지 않습니다: ${name}` };
272
- rmSync(dir, { recursive: true, force: true });
273
- return { removed: name };
274
- }
275
-
276
- // 반입용 묶음. 실행 스크립트는 빼고 스킬·명령만 담는다.
277
- const PACK_SKIP_DIRS = new Set(['node_modules', '.git', 'test', 'tests', '__pycache__', '.github']);
278
- const PACK_SKIP_EXT = /\.(js|cjs|mjs|sh|ps1|cmd|bat|py|exe|dll|so|dylib)$/i;
279
-
280
- export function pack(outFile, { home = homedir(), only = null } = {}) {
281
- const base = pluginsDir(home);
282
- if (!existsSync(base)) return { error: '설치된 플러그인이 없습니다.' };
283
-
284
- const entries = [];
285
- const included = [];
286
- let skipped = 0;
287
-
288
- for (const p of list({ home })) {
289
- if (only?.length && !only.includes(p.name)) continue;
290
- let n = 0;
291
- const stack = [p.path];
292
- while (stack.length) {
293
- const dir = stack.pop();
294
- for (const e of readdirSync(dir, { withFileTypes: true })) {
295
- const full = join(dir, e.name);
296
- if (e.isDirectory()) {
297
- if (!PACK_SKIP_DIRS.has(e.name)) stack.push(full);
298
- continue;
299
- }
300
- if (PACK_SKIP_EXT.test(e.name)) { skipped++; continue; }
301
- const rel = full.slice(base.length + 1).split(/[\\/]/).join('/');
302
- entries.push({ name: rel, data: readFileSync(full), mtime: statSync(full).mtime });
303
- n++;
304
- }
305
- }
306
- included.push({ ...p, files: n });
307
- }
308
-
309
- if (!entries.length) return { error: '담을 파일이 없습니다.' };
310
-
311
- // 무엇이 들어 있는지 사람이 읽을 수 있게 같이 담는다 — 반입 심사에 쓴다.
312
- const manifest = [
313
- 'deel 플러그인 묶음',
314
- `만든 시각 ${new Date().toISOString().replace('T', ' ').slice(0, 19)}`,
315
- `플러그인 ${included.length}개`,
316
- `파일 ${entries.length}개`,
317
- `제외한 실행 스크립트 ${skipped}개 (js·sh·ps1·py 등은 담지 않습니다)`,
318
- '',
319
- '이름'.padEnd(24) + '판'.padEnd(10) + '라이선스'.padEnd(16) + '스킬 명령 출처',
320
- '-'.repeat(96),
321
- ...included.map((p) =>
322
- String(p.name).padEnd(24) + String(p.version || '-').padEnd(10) +
323
- String(p.license || '미상').padEnd(16) +
324
- String(p.skills).padStart(4) + String(p.commands).padStart(6) + ' ' + p.from),
325
- '',
326
- '푸는 법: 이 zip 을 오프라인 기기의 ~/.deel/plugins/ 에 풀면 됩니다.',
327
- ' deel 켜면 자동으로 인식합니다. 설치 명령은 필요 없습니다.',
328
- '',
329
- ].join('\n');
330
- entries.unshift({ name: '사용안내.txt', data: Buffer.from(manifest, 'utf8') });
331
-
332
- const zip = makeZip(entries);
333
- mkdirSync(dirname(outFile), { recursive: true });
334
- writeFileSync(outFile, zip);
335
- return { out: outFile, plugins: included, files: entries.length, skipped, bytes: zip.length, manifest };
336
- }
1
+ // 플러그인 설치·삭제·묶기.
2
+ //
3
+ // 온라인 기기에서 /plugin install 로 받고, /plugin pack 으로 묶어
4
+ // 오프라인 기기에 반입한다. 오프라인에서는 압축만 풀면 그대로 인식된다.
5
+ import { execFile } from 'node:child_process';
6
+ import { homedir } from 'node:os';
7
+ import { join, dirname, basename, resolve, sep } from 'node:path';
8
+ import {
9
+ existsSync, mkdirSync, writeFileSync, readFileSync, rmSync,
10
+ readdirSync, statSync,
11
+ } from 'node:fs';
12
+ import { untargz, stripTop, 밖을가리키는것 } from '../pack/tar.js';
13
+ import { makeZip } from '../pack/zip.js';
14
+ import { allowTemporarily, isOffline, NetBlocked } from '../safety/network.js';
15
+ import { 원시요청, 몸읽기 } from '../backend/http.js';
16
+
17
+ /**
18
+ * 플러그인을 받다 되돌림(redirect)이 오면 따라가도 되는 곳인가. 던지면 안 따라간다.
19
+ * github 가 제 다른 집으로 보내는 것만 따라간다 — https 이고, github.com · githubusercontent.com 뿐.
20
+ */
21
+ export function 플러그인되돌림(다음) {
22
+ if (다음.protocol !== 'https:') throw new Error(`${다음.protocol} 로 되돌립니다 — 따라가지 않습니다`);
23
+ if (!/(^|\.)github\.com$|(^|\.)githubusercontent\.com$/i.test(다음.hostname)) {
24
+ throw new Error(`github 밖(${다음.hostname})으로 되돌립니다 — 따라가지 않습니다`);
25
+ }
26
+ }
27
+ import { copyDir } from '../tools/fsutil.js';
28
+
29
+ export const pluginsDir = (home = homedir()) => join(home, '.deel', 'plugins');
30
+
31
+ // owner/repo · owner/repo#가지 · 전체 URL 을 모두 받는다.
32
+ export function parseSpec(spec) {
33
+ let s = String(spec).trim();
34
+ let ref = null;
35
+ const hash = s.lastIndexOf('#');
36
+ if (hash > 0) { ref = s.slice(hash + 1); s = s.slice(0, hash); }
37
+
38
+ s = s.replace(/\.git$/, '');
39
+ const m = /^(?:https?:\/\/github\.com\/)?([\w.-]+)\/([\w.-]+)$/.exec(s);
40
+ if (!m) return null;
41
+ return { owner: m[1], repo: m[2], ref, url: `https://github.com/${m[1]}/${m[2]}.git` };
42
+ }
43
+
44
+ function has(cmd) {
45
+ return new Promise((res) => {
46
+ execFile(cmd, ['--version'], { timeout: 8000, windowsHide: true }, (err) => res(!err));
47
+ });
48
+ }
49
+
50
+ function run(cmd, args, opts = {}) {
51
+ return new Promise((res) => {
52
+ execFile(cmd, args, { timeout: 180000, windowsHide: true, maxBuffer: 1 << 24, ...opts },
53
+ (err, stdout, stderr) => res({ ok: !err, out: `${stdout}${stderr}`.trim() }));
54
+ });
55
+ }
56
+
57
+ /**
58
+ * 받은 묶음을 폴더에 푼다.
59
+ *
60
+ * ── 왜 따로 떼어 놨나 ───────────────────────────────────────────────────
61
+ *
62
+ * 이 몇 줄이 **남이 준 글자로 디스크에 파일을 쓰는 자리**다. 이 프로그램에서
63
+ * 제일 조심해야 하는 대목인데, fetchInto 안에 묻혀 있어서 검사가 한 번도 안
64
+ * 지나갔다. 거기까지 가려면 진짜로 네트워크에서 tarball 을 받아야 했기 때문이다.
65
+ *
66
+ * 그래서 「받는 일」 과 「푸는 일」 을 갈랐다. 푸는 일은 이제 파일 목록만 주면
67
+ * 그대로 재 볼 수 있다 — 남이 노리고 만든 묶음을 먹여 보는 것을 포함해서.
68
+ *
69
+ * ── 무엇을 막나 ─────────────────────────────────────────────────────────
70
+ *
71
+ * tar 안의 이름은 남이 적은 글자다. `../../../..` 하나면 플러그인 폴더 밖에
72
+ * 파일을 쓴다. 아래 mkdirSync 가 recursive 라 없는 폴더까지 만들어 가며 나간다.
73
+ * 이 프로그램이 파는 문장이 「작업 폴더 밖은 안 만진다」 인데, 플러그인을 받는
74
+ * 이 길에만 그 문장을 지키는 코드가 없었다.
75
+ *
76
+ * 나쁜 것만 골라 버리지 않고 **묶음째 거절한다.** 밖을 가리키는 이름이 든
77
+ * 묶음은 실수가 아니라 노린 것이고, 나머지를 풀어 줄 까닭이 없다.
78
+ * 그리고 **rmSync 앞에서** 막는다 — 거절할 묶음 때문에 이미 깔린 것을
79
+ * 지워 버리면 안 된다.
80
+ */
81
+ export function 묶음풀기(dest, files) {
82
+ if (!files?.length) return { error: '받은 묶음이 비어 있습니다' };
83
+ const 밖엣것 = 밖을가리키는것(dest, files);
84
+ if (밖엣것.length) {
85
+ return { error: `묶음 안에 플러그인 폴더 밖을 가리키는 이름이 있습니다 — 풀지 않았습니다: ${밖엣것[0].name}` };
86
+ }
87
+ rmSync(dest, { recursive: true, force: true });
88
+ for (const f of files) {
89
+ const p = join(dest, f.name);
90
+ mkdirSync(dirname(p), { recursive: true });
91
+ writeFileSync(p, f.data);
92
+ }
93
+ return { 푼것: files.length };
94
+ }
95
+
96
+ // 받는 방법 두 가지. git 이 있으면 clone, 없으면 tarball 을 내려받아 푼다.
97
+ async function fetchInto(spec, dest, onStep) {
98
+ if (await has('git')) {
99
+ onStep?.('git clone');
100
+ rmSync(dest, { recursive: true, force: true });
101
+ mkdirSync(dirname(dest), { recursive: true });
102
+ const args = ['clone', '--depth', '1'];
103
+ if (spec.ref) args.push('--branch', spec.ref);
104
+ args.push(spec.url, dest);
105
+ const r = await run('git', args);
106
+ if (!r.ok) return { error: `git clone 실패 — ${r.out.split('\n').slice(-2).join(' ')}` };
107
+ rmSync(join(dest, '.git'), { recursive: true, force: true });
108
+ return { how: 'git' };
109
+ }
110
+
111
+ onStep?.('tarball 내려받기');
112
+ if (isOffline()) return { error: '오프라인 모드입니다 — 받아 올 수 없습니다. 풀어 놓은 폴더 경로를 주세요.' };
113
+ for (const branch of spec.ref ? [spec.ref] : ['main', 'master']) {
114
+ const url = `https://codeload.github.com/${spec.owner}/${spec.repo}/tar.gz/refs/heads/${branch}`;
115
+ // 사용자가 이 명령을 친 동안만 github 를 연다. 끝나면 바로 닫는다.
116
+ const close = allowTemporarily(url);
117
+ let gz;
118
+ // 한 문(backend/http.js)으로 나간다 — 프록시를 거치고, 되돌림은 홉마다 문지기를 지난다.
119
+ // github 는 받기 주소를 제 다른 집(githubusercontent 등)으로 되돌리므로 그 홉은 열어 준다 —
120
+ // 그 밖의 집이면 안 따라가고, 왜 못 받았는지를 말한다 (조용히 '주소를 확인하세요' 로 끝내지 않는다).
121
+ // 상한까지만 받는다. 플러그인 묶음이 64MB 를 넘을 일은 없고, 넘는 것을 다 받아 줄 이유도 없다.
122
+ const 열어둔 = [];
123
+ try {
124
+ const res = await 원시요청(url, {
125
+ timeout: 120000, stream: true,
126
+ 되돌림: (다음) => { 플러그인되돌림(다음); 열어둔.push(allowTemporarily(다음.origin)); },
127
+ });
128
+ if (!res.ok) { await res.버리기?.(); continue; }
129
+ gz = await 몸읽기(res.res.body, 64 * 1024 * 1024);
130
+ } catch (err) {
131
+ if (err instanceof NetBlocked || /되돌립니다/.test(String(err?.message))) return { error: `받지 못했습니다 — ${err.message}` };
132
+ continue;
133
+ } finally {
134
+ close();
135
+ for (const 닫기 of 열어둔) 닫기();
136
+ }
137
+ if (!gz) return { error: '받은 묶음이 너무 큽니다 (64MB 넘음) — 플러그인 저장소가 맞는지 확인하세요' };
138
+ // 받는 크기만 막으면 압축 폭탄에 그대로 당한다 (pack/tar.js 푼것상한 머리말).
139
+ // 던지는 것을 여기서 받아 화면에 올린다 — 안 받으면 명령이 통째로 죽는다.
140
+ let 푼것;
141
+ try { 푼것 = 묶음풀기(dest, stripTop(untargz(gz))); }
142
+ catch (err) { return { error: `받은 묶음을 풀지 못했습니다 — ${String(err?.message ?? err)}` }; }
143
+ if (푼것.error) return { error: 푼것.error };
144
+ return { how: 'tarball', branch };
145
+ }
146
+ return { error: '받지 못했습니다 — 저장소 주소나 가지 이름을 확인하세요' };
147
+ }
148
+
149
+ /**
150
+ * 플러그인 이름을 **폴더 이름 한 칸**으로 자른다.
151
+ *
152
+ * ── 있어야 하나 ────────────────────────────────────────────────────
153
+ *
154
+ * 설치할 자리를 `join(플러그인폴더, 이름)` 으로 정하는데, 이 **이름은 남이 적은
155
+ * 글자**다. `.claude-plugin/plugin.json` 의 `name` 을 그대로 읽어 쓰고, 그 파일은
156
+ * 플러그인을 만든 사람이 통째로 정한다.
157
+ *
158
+ * 그래서 이름이 `../../Desktop/뭐시기` 면 설치할 자리가 홈 폴더 밖으로 나간다.
159
+ * `join` `..` 정리해 막아 주지 않는다. 그 자리에 대고 우리가 하는
160
+ * 일이 하필 이것이다 —
161
+ *
162
+ * rmSync(dest, { recursive: true, force: true }) 통째로 지운다
163
+ * copyDir(tmp, dest) 자리에 파일을 놓는다
164
+ *
165
+ * 묶음 안의 파일 확장자는 안 가린다(반입 묶음을 만들 때만 가린다). 그러니
166
+ * 이름 줄로 **남의 폴더를 지우고 자리에 실행 파일을 놓을 수 있다.**
167
+ * 시작프로그램 폴더를 노리면 다음 로그인부터 그것이 돈다.
168
+ *
169
+ * 이름과 `.` 막아야 한다. 둘은 자리가 **플러그인 폴더 자신**이 되어,
170
+ * `rmSync` 설치해 둔 플러그인을 전부 지운다.
171
+ *
172
+ * 역슬래시도 칸막이로 본다. 윈도우에서 만든 묶음이 리눅스에서 이름 한 칸으로
173
+ * 통과하면, 묶음을 다시 윈도우로 옮겼을 때 그때 나간다 (pack/tar.js
174
+ * 안쪽인가 같은 판단이다).
175
+ *
176
+ * 이 함수가 없으면 tar 엔트리 이름을 아무리 잘 막아도 소용이 없다. 묶음이
177
+ * **제 이름으로** 나가기 때문이다.
178
+ */
179
+ export function 이름한칸(이름) {
180
+ const 글 = String(이름 ?? '').replace(/\\/g, '/').trim();
181
+ if (!글) return null;
182
+ // 마지막 칸만 쓴다. `a/b/../c` 같은 것도 여기서 한 칸이 된다.
183
+ const 한칸 = basename(글);
184
+ if (!한칸 || 한칸 === '.' || 한칸 === '..') return null;
185
+ // 드라이브 글자·칸막이가 남아 있으면 이름이 아니다.
186
+ if (/[/:]/.test(한칸)) return null;
187
+ return 한칸;
188
+ }
189
+
190
+ /**
191
+ * 그 자리가 정말 플러그인 폴더 **안**인가 — 이름을 자른 뒤에도 한 번 더 본다.
192
+ *
193
+ * 자르는 자가 언젠가 틀릴 있다. 그때 마지막으로 서는 그물이다. 지우고
194
+ * 쓰는 자리라 두 겹으로 막는다.
195
+ */
196
+ export function 플러그인자리인가(base, dest) {
197
+ const 뿌리 = resolve(base);
198
+ const 갈곳 = resolve(dest);
199
+ return 갈곳 !== 뿌리 && 갈곳.startsWith(뿌리 + sep);
200
+ }
201
+
202
+ function manifestOf(dir) {
203
+ const f = join(dir, '.claude-plugin', 'plugin.json');
204
+ if (!existsSync(f)) return null;
205
+ try { return JSON.parse(readFileSync(f, 'utf8')); } catch { return null; }
206
+ }
207
+
208
+ function countIn(dir) {
209
+ let skills = 0;
210
+ let commands = 0;
211
+ let hooks = 0;
212
+ for (const d of ['skills', join('.agents', 'skills'), join('.claude', 'skills')]) {
213
+ const p = join(dir, d);
214
+ if (!existsSync(p)) continue;
215
+ for (const e of readdirSync(p, { withFileTypes: true })) {
216
+ if (e.isDirectory() && existsSync(join(p, e.name, 'SKILL.md'))) skills++;
217
+ }
218
+ }
219
+ for (const d of ['commands', join('.claude', 'commands'), join('.agents', 'commands')]) {
220
+ const p = join(dir, d);
221
+ if (!existsSync(p)) continue;
222
+ for (const e of readdirSync(p, { withFileTypes: true })) {
223
+ if (e.isFile() && e.name.endsWith('.md')) commands++;
224
+ }
225
+ }
226
+ hooks = walkCount(dir, (n) => /\.(js|cjs|mjs|sh|ps1|cmd|bat|py)$/i.test(n));
227
+ return { skills, commands, hooks };
228
+ }
229
+
230
+ function walkCount(dir, match, depth = 5) {
231
+ let n = 0;
232
+ const stack = [{ d: dir, k: 0 }];
233
+ while (stack.length) {
234
+ const { d, k } = stack.pop();
235
+ if (k > depth) continue;
236
+ let es;
237
+ try { es = readdirSync(d, { withFileTypes: true }); } catch { continue; }
238
+ for (const e of es) {
239
+ if (e.isDirectory()) { if (e.name !== 'node_modules' && e.name !== '.git') stack.push({ d: join(d, e.name), k: k + 1 }); }
240
+ else if (match(e.name)) n++;
241
+ }
242
+ }
243
+ return n;
244
+ }
245
+
246
+ export async function install(spec, { home = homedir(), onStep } = {}) {
247
+ const base = pluginsDir(home);
248
+
249
+ // 이미 풀어 놓은 폴더를 그대로 넣는 길. 오프라인 기기에서 이쪽을 쓴다.
250
+ const asPath = String(spec).trim().replace(/^["']|["']$/g, '');
251
+ const isLocal = existsSync(asPath) && statSync(asPath).isDirectory();
252
+
253
+ const parsed = isLocal ? null : parseSpec(spec);
254
+ if (!isLocal && !parsed) {
255
+ return { error: `주소를 알아볼 수 없습니다: ${spec}\n 예: affaan-m/ECC · https://github.com/affaan-m/ECC · C:\\받은폴더\\ecc` };
256
+ }
257
+
258
+ const tmp = join(base, `.tmp-${isLocal ? 'local' : parsed.repo}`);
259
+ let got;
260
+ if (isLocal) {
261
+ onStep?.('폴더 복사');
262
+ rmSync(tmp, { recursive: true, force: true });
263
+ mkdirSync(dirname(tmp), { recursive: true });
264
+ copyDir(asPath, tmp);
265
+ rmSync(join(tmp, '.git'), { recursive: true, force: true });
266
+ got = { how: '폴더', from: asPath };
267
+ } else {
268
+ got = await fetchInto(parsed, tmp, onStep);
269
+ }
270
+ if (got.error) { rmSync(tmp, { recursive: true, force: true }); return { error: got.error }; }
271
+
272
+ const info = manifestOf(tmp);
273
+ /*
274
+ * 이름은 **남이 적은 글자**다. 자리 조립에 쓰기 전에 한 칸으로 자른다
275
+ * (이름한칸 머리말 — 안 자르면 이름 한 줄로 남의 폴더가 지워진다).
276
+ * 매니페스트 이름이 것이면 우리가 아는 이름(폴더명·저장소명)으로 간다.
277
+ */
278
+ const 적힌이름 = 이름한칸(info?.name);
279
+ const 뒷이름 = 이름한칸(isLocal ? basename(asPath) : parsed.repo);
280
+ const name = 적힌이름 || 뒷이름;
281
+ if (!name) {
282
+ rmSync(tmp, { recursive: true, force: true });
283
+ return { error: '플러그인 이름을 읽지 못했습니다 — 이름이 비었거나 폴더 이름으로 쓸 수 없는 글자입니다.' };
284
+ }
285
+ const dest = join(base, name);
286
+ // 겹째 그물. 여기서 걸리면 자르는 자가 틀린 것이므로 아예 멈춘다.
287
+ if (!플러그인자리인가(base, dest)) {
288
+ rmSync(tmp, { recursive: true, force: true });
289
+ return { error: `설치할 자리가 플러그인 폴더 밖입니다 — 설치하지 않았습니다 (${name}).` };
290
+ }
291
+
292
+ const counts = countIn(tmp);
293
+ if (!counts.skills && !counts.commands) {
294
+ rmSync(tmp, { recursive: true, force: true });
295
+ return { error: `스킬도 명령도 없습니다. 플러그인이 맞는지 확인하세요 (${isLocal ? asPath : parsed.owner + '/' + parsed.repo})` };
296
+ }
297
+
298
+ rmSync(dest, { recursive: true, force: true });
299
+ mkdirSync(dirname(dest), { recursive: true });
300
+ copyDir(tmp, dest);
301
+ rmSync(tmp, { recursive: true, force: true });
302
+
303
+ // 어디서 왔는지 남긴다 — 나중에 반입 심사에서 출처를 물어본다.
304
+ writeFileSync(join(dest, '.deel-source.json'), JSON.stringify({
305
+ from: isLocal ? asPath : `${parsed.owner}/${parsed.repo}`,
306
+ ref: got.branch ?? parsed?.ref ?? null,
307
+ how: got.how,
308
+ at: new Date().toISOString(),
309
+ license: info?.license ?? null,
310
+ }, null, 2) + '\n', 'utf8');
311
+
312
+ return { name, version: info?.version ?? '', license: info?.license ?? null, path: dest, ...counts, how: got.how };
313
+ }
314
+
315
+ export function list({ home = homedir() } = {}) {
316
+ const base = pluginsDir(home);
317
+ if (!existsSync(base)) return [];
318
+ const out = [];
319
+ for (const e of readdirSync(base, { withFileTypes: true })) {
320
+ if (!e.isDirectory() || e.name.startsWith('.')) continue;
321
+ const dir = join(base, e.name);
322
+ const info = manifestOf(dir);
323
+ let src = null;
324
+ const sf = join(dir, '.deel-source.json');
325
+ if (existsSync(sf)) { try { src = JSON.parse(readFileSync(sf, 'utf8')); } catch {} }
326
+ out.push({
327
+ name: info?.name || e.name,
328
+ version: info?.version ?? '',
329
+ license: info?.license ?? src?.license ?? null,
330
+ from: src?.from ?? '(직접 넣음)',
331
+ path: dir,
332
+ ...countIn(dir),
333
+ });
334
+ }
335
+ return out.sort((a, b) => b.skills - a.skills);
336
+ }
337
+
338
+ export function remove(name, { home = homedir() } = {}) {
339
+ /*
340
+ * 지우는 자리도 이름으로 정해진다. 그러니 여기도 한 칸으로 자른다.
341
+ *
342
+ * 사람이 직접 치는 자리라 안전할 것 같지만, 목록(list)이 보여 주는 이름을
343
+ * 그대로 복사해 붙이는 것이 보통이고 그 이름은 매니페스트에서 온다.
344
+ * 설치 때 막아도 이미 깔려 있던 것에는 못 쓴 이름이 남아 있을 수 있다.
345
+ */
346
+ const base = pluginsDir(home);
347
+ const 한칸 = 이름한칸(name);
348
+ const dir = 한칸 ? join(base, 한칸) : null;
349
+ if (!dir || !플러그인자리인가(base, dir) || !existsSync(dir)) {
350
+ return { error: `설치돼 있지 않습니다: ${name}` };
351
+ }
352
+ rmSync(dir, { recursive: true, force: true });
353
+ return { removed: 한칸 };
354
+ }
355
+
356
+ // 반입용 묶음. 실행 스크립트는 빼고 스킬·명령만 담는다.
357
+ const PACK_SKIP_DIRS = new Set(['node_modules', '.git', 'test', 'tests', '__pycache__', '.github']);
358
+ const PACK_SKIP_EXT = /\.(js|cjs|mjs|sh|ps1|cmd|bat|py|exe|dll|so|dylib)$/i;
359
+
360
+ export function pack(outFile, { home = homedir(), only = null } = {}) {
361
+ const base = pluginsDir(home);
362
+ if (!existsSync(base)) return { error: '설치된 플러그인이 없습니다.' };
363
+
364
+ const entries = [];
365
+ const included = [];
366
+ let skipped = 0;
367
+
368
+ for (const p of list({ home })) {
369
+ if (only?.length && !only.includes(p.name)) continue;
370
+ let n = 0;
371
+ const stack = [p.path];
372
+ while (stack.length) {
373
+ const dir = stack.pop();
374
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
375
+ const full = join(dir, e.name);
376
+ if (e.isDirectory()) {
377
+ if (!PACK_SKIP_DIRS.has(e.name)) stack.push(full);
378
+ continue;
379
+ }
380
+ if (PACK_SKIP_EXT.test(e.name)) { skipped++; continue; }
381
+ const rel = full.slice(base.length + 1).split(/[\\/]/).join('/');
382
+ entries.push({ name: rel, data: readFileSync(full), mtime: statSync(full).mtime });
383
+ n++;
384
+ }
385
+ }
386
+ included.push({ ...p, files: n });
387
+ }
388
+
389
+ if (!entries.length) return { error: '담을 파일이 없습니다.' };
390
+
391
+ // 무엇이 들어 있는지 사람이 읽을 수 있게 같이 담는다 — 반입 심사에 쓴다.
392
+ const manifest = [
393
+ 'deel 플러그인 묶음',
394
+ `만든 시각 ${new Date().toISOString().replace('T', ' ').slice(0, 19)}`,
395
+ `플러그인 ${included.length}개`,
396
+ `파일 ${entries.length}개`,
397
+ `제외한 실행 스크립트 ${skipped}개 (js·sh·ps1·py 등은 담지 않습니다)`,
398
+ '',
399
+ '이름'.padEnd(24) + '판'.padEnd(10) + '라이선스'.padEnd(16) + '스킬 명령 출처',
400
+ '-'.repeat(96),
401
+ ...included.map((p) =>
402
+ String(p.name).padEnd(24) + String(p.version || '-').padEnd(10) +
403
+ String(p.license || '미상').padEnd(16) +
404
+ String(p.skills).padStart(4) + String(p.commands).padStart(6) + ' ' + p.from),
405
+ '',
406
+ '푸는 법: 이 zip 을 오프라인 기기의 ~/.deel/plugins/ 에 풀면 됩니다.',
407
+ ' deel 을 켜면 자동으로 인식합니다. 설치 명령은 필요 없습니다.',
408
+ '',
409
+ ].join('\n');
410
+ entries.unshift({ name: '사용안내.txt', data: Buffer.from(manifest, 'utf8') });
411
+
412
+ const zip = makeZip(entries);
413
+ mkdirSync(dirname(outFile), { recursive: true });
414
+ writeFileSync(outFile, zip);
415
+ return { out: outFile, plugins: included, files: entries.length, skipped, bytes: zip.length, manifest };
416
+ }