deel-local-cli 1.5.7 → 1.6.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 (57) hide show
  1. package/README.en.md +24 -13
  2. package/README.md +23 -12
  3. package/bin/deel.js +10 -0
  4. package/package.json +2 -2
  5. package/src/acp/map.js +143 -0
  6. package/src/acp/serve.js +160 -13
  7. package/src/agent/commit.js +511 -0
  8. package/src/agent/compact.js +269 -226
  9. package/src/agent/loop.js +154 -12
  10. package/src/agent/mention.js +56 -10
  11. package/src/agent/review.js +192 -0
  12. package/src/agent/session.js +20 -2
  13. package/src/agent/threads.js +1 -1
  14. package/src/backend/adapter.js +342 -280
  15. package/src/backend/azure.js +151 -0
  16. package/src/backend/ctxsize.js +19 -0
  17. package/src/backend/detect.js +106 -0
  18. package/src/backend/http.js +350 -30
  19. package/src/backend/probe.js +48 -1
  20. package/src/backend/proxy.js +151 -0
  21. package/src/backend/quota.js +133 -0
  22. package/src/backend/retry.js +132 -0
  23. package/src/backend/vision.js +185 -0
  24. package/src/commands.js +341 -8
  25. package/src/completion.js +264 -0
  26. package/src/config.js +117 -1
  27. package/src/i18n/en.js +13 -1
  28. package/src/i18n/index.js +22 -3
  29. package/src/i18n/ja.js +266 -0
  30. package/src/i18n/ko.js +12 -0
  31. package/src/i18n/zh.js +266 -0
  32. package/src/oneshot.js +28 -3
  33. package/src/pack/sbom.js +25 -0
  34. package/src/pack/selfpack.js +9 -3
  35. package/src/plugins/manage.js +34 -7
  36. package/src/repl.js +113 -15
  37. package/src/report.js +26 -2
  38. package/src/safety/guard.js +82 -3
  39. package/src/safety/keystore.js +237 -0
  40. package/src/safety/network.js +76 -14
  41. package/src/safety/policy.js +209 -0
  42. package/src/safety/secrets.js +32 -0
  43. package/src/setup.js +40 -7
  44. package/src/tools/clipboard.js +178 -0
  45. package/src/tools/fastgrep.js +217 -0
  46. package/src/tools/fsutil.js +55 -12
  47. package/src/tools/ignore.js +192 -0
  48. package/src/tools/index.js +282 -39
  49. package/src/tools/jobs.js +4 -12
  50. package/src/tools/outline.js +3 -1
  51. package/src/tools/pdf.js +1326 -0
  52. package/src/tools/shell.js +123 -0
  53. package/src/tools/verify.js +20 -4
  54. package/src/tools/webfetch.js +18 -5
  55. package/src/ui/inputbox.js +2 -2
  56. package/src/ui/screen.js +4 -1
  57. package/src/ui/status.js +22 -0
@@ -0,0 +1,209 @@
1
+ // 무엇을 늘 허락하고 무엇을 절대 안 할지, 그리고 그것을 누가 정하는지.
2
+ //
3
+ // ── 왜 필요한가 ────────────────────────────────────────────────────────
4
+ //
5
+ // 지금까지 승인은 세 가지 모드뿐이었다 — 안 묻거나(auto), 파일을 바꾸는
6
+ // 명령만 묻거나(confirm), 바꾸는 도구를 다 묻거나(strict). 그 사이가 없다.
7
+ //
8
+ // `npm test` 는 하루에 스무 번 돌린다. 스무 번 묻는다.
9
+ // `curl` 은 한 번도 돌게 하고 싶지 않다. 그런데 물어보기만 한다 — 물어보는
10
+ // 것은 막는 것이 아니다. 사람은 스무 번 y 를 친 손으로 스물한 번째도 친다.
11
+ //
12
+ // 그래서 규칙을 적어 둘 수 있게 한다.
13
+ //
14
+ // "permissions": {
15
+ // "allow": ["Bash(npm test*)", "Read", "Grep"],
16
+ // "deny": ["Bash(curl*)", "Bash(rm -rf*)", "WebFetch"]
17
+ // }
18
+ //
19
+ // ── 그리고 그것을 누가 정하는가 ────────────────────────────────────────
20
+ //
21
+ // 설정 파일은 **쓰는 사람의 것**이다. 지우고 고칠 수 있다. 그래서 회사가
22
+ // "이번 배포 동안은 이 게이트웨이만" 이라고 정해야 할 때 쓸 자리가 아니다.
23
+ // 그 자리가 관리 정책 파일이다 — 사용자가 못 고치는 곳에 둔다.
24
+ //
25
+ // %ProgramData%\deel\policy.json /etc/deel/policy.json $DEEL_POLICY
26
+ //
27
+ // 정책은 설정을 **이긴다.** 그리고 정책은 넓히지 못한다 — 금지를 더할 수는
28
+ // 있어도 사용자가 적어 둔 금지를 풀어 주지는 못한다. 관리자가 실수로,
29
+ // 또는 누가 정책 파일을 바꿔치기해서 안전장치가 헐거워지는 길을 안 낸다.
30
+ //
31
+ // ── 순서 ───────────────────────────────────────────────────────────────
32
+ //
33
+ // 금지 > 허락 > 모드
34
+ //
35
+ // 금지가 제일 세다. 허락과 금지에 같이 걸리면 금지다. 둘 다 아니면 예전처럼
36
+ // 모드가 정한다. 그리고 무엇 때문에 막혔는지 **어디에 적힌 규칙인지까지**
37
+ // 화면에 말한다 — 그 말이 없으면 사람은 제 설정을 고칠 수도, 관리자에게
38
+ // 무엇을 풀어 달라고 할 수도 없다.
39
+ import { readFileSync, existsSync } from 'node:fs';
40
+ import { join } from 'node:path';
41
+
42
+ /** 관리 정책 파일을 찾을 자리들. 앞에서부터 처음 있는 것 하나만 쓴다. */
43
+ export function 정책자리(env = process.env, platform = process.platform) {
44
+ const 것들 = [];
45
+ if (env.DEEL_POLICY) 것들.push(env.DEEL_POLICY);
46
+ if (platform === 'win32') {
47
+ 것들.push(join(env.ProgramData || 'C:\\ProgramData', 'deel', 'policy.json'));
48
+ } else {
49
+ 것들.push('/etc/deel/policy.json');
50
+ }
51
+ return 것들;
52
+ }
53
+
54
+ let 읽은정책 = null; // { 값, 곳 } — 한 번 읽고 이 프로세스 동안 안 다시 읽는다
55
+
56
+ /*
57
+ * 관리 정책을 읽는다.
58
+ *
59
+ * 못 읽거나 망가져 있으면 **없는 것으로 친다.** 여기서 프로그램을 멈추면
60
+ * 정책 파일 하나가 깨진 것으로 그 PC 의 deel 이 통째로 안 뜬다. 대신 왜
61
+ * 못 읽었는지를 들고 있다가 화면에 적는다 — 조용히 무시하면 관리자는
62
+ * 정책이 걸린 줄 알고, 사용자는 안 걸린 채로 쓴다.
63
+ */
64
+ export function 정책읽기({ env = process.env, platform = process.platform, 다시 = false } = {}) {
65
+ if (읽은정책 && !다시) return 읽은정책;
66
+ for (const 곳 of 정책자리(env, platform)) {
67
+ if (!existsSync(곳)) continue;
68
+ try {
69
+ const 값 = JSON.parse(readFileSync(곳, 'utf8'));
70
+ 읽은정책 = { 값: 값 && typeof 값 === 'object' ? 값 : {}, 곳, 탈: null };
71
+ return 읽은정책;
72
+ } catch (err) {
73
+ 읽은정책 = { 값: {}, 곳, 탈: `정책 파일을 못 읽었습니다 (${err.message})` };
74
+ return 읽은정책;
75
+ }
76
+ }
77
+ 읽은정책 = { 값: {}, 곳: null, 탈: null };
78
+ return 읽은정책;
79
+ }
80
+
81
+ /** 검사가 원래대로 돌려놓을 때. */
82
+ export function 정책잊기() { 읽은정책 = null; }
83
+
84
+ /*
85
+ * `Tool(무늬)` 를 읽는다.
86
+ *
87
+ * Bash — Bash 는 전부
88
+ * Bash(npm test*) — 명령이 `npm test` 로 시작하는 것
89
+ * Bash(*rm -rf*) — 어디에 있든 그 글자가 든 것
90
+ * Read(src/**) — 경로 무늬
91
+ *
92
+ * 무늬가 없으면 그 도구 전체다. 별표만 특별하고 나머지는 글자 그대로다 —
93
+ * 정규식을 받으면 적는 사람이 실수하기 쉽고, 실수한 금지 규칙은 안 걸린다.
94
+ */
95
+ export function 규칙읽기(줄) {
96
+ const s = String(줄 ?? '').trim();
97
+ if (!s) return null;
98
+ const m = /^([A-Za-z_][\w-]*)\s*\((.*)\)\s*$/s.exec(s);
99
+ if (!m) return { 도구: s, 무늬: null, 원문: s };
100
+ return { 도구: m[1], 무늬: m[2].trim(), 원문: s };
101
+ }
102
+
103
+ function 무늬맞나(무늬, 값) {
104
+ if (!무늬) return true;
105
+ const 글 = String(값 ?? '');
106
+ // 별표만 뜻을 갖는다. 나머지는 글자 그대로.
107
+ const re = new RegExp(`^${무늬.split('*').map((x) => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*')}$`, 's');
108
+ return re.test(글);
109
+ }
110
+
111
+ /*
112
+ * 이 규칙이 이 호출에 걸리나.
113
+ *
114
+ * 무엇을 무늬에 맞춰 보는지는 도구마다 다르다. Bash 는 명령, 파일 도구는
115
+ * 경로다. 도구가 여러 인자를 받는 경우 **첫 번째로 뜻이 통하는 것**을 본다.
116
+ */
117
+ export function 걸리나(규칙, 도구, 인자) {
118
+ if (!규칙 || 규칙.도구 !== 도구) return false;
119
+ if (!규칙.무늬) return true;
120
+ const a = 인자 ?? {};
121
+ const 볼것 = [a.command, a.file_path, a.path, a.pattern, a.url, a.query]
122
+ .filter((x) => typeof x === 'string');
123
+ if (!볼것.length) return false;
124
+ return 볼것.some((v) => 무늬맞나(규칙.무늬, v));
125
+ }
126
+
127
+ function 목록(값) {
128
+ return (Array.isArray(값) ? 값 : []).map(규칙읽기).filter(Boolean);
129
+ }
130
+
131
+ /**
132
+ * 지금 걸려 있는 규칙 전부. 어디에 적힌 것인지를 같이 들고 다닌다.
133
+ *
134
+ * @returns {{allow:Array, deny:Array, 정책곳:string|null, 탈:string|null, baseUrl:string|null, offline:boolean}}
135
+ */
136
+ export function 규칙모으기(cfg, { env = process.env, platform = process.platform } = {}) {
137
+ const 정책 = 정책읽기({ env, platform });
138
+ const p = 정책.값?.permissions ?? {};
139
+ const c = cfg?.permissions ?? {};
140
+ return {
141
+ allow: [
142
+ ...목록(c.allow).map((r) => ({ ...r, 출처: '설정' })),
143
+ // 정책은 허락도 적을 수 있다 — 사내 표준 명령을 미리 풀어 두는 자리다.
144
+ ...목록(p.allow).map((r) => ({ ...r, 출처: '관리 정책' })),
145
+ ],
146
+ deny: [
147
+ ...목록(c.deny).map((r) => ({ ...r, 출처: '설정' })),
148
+ ...목록(p.deny).map((r) => ({ ...r, 출처: '관리 정책' })),
149
+ ],
150
+ 정책곳: 정책.곳,
151
+ 탈: 정책.탈,
152
+ baseUrl: typeof 정책.값?.baseUrl === 'string' ? 정책.값.baseUrl : null,
153
+ offline: 정책.값?.offline === true,
154
+ };
155
+ }
156
+
157
+ /**
158
+ * 이 도구 호출을 어떻게 할까.
159
+ *
160
+ * @returns {{답: 'deny'|'allow'|'모름', 출처: string|null, 규칙: string|null}}
161
+ * deny — 하지 않는다. 물어보지도 않는다.
162
+ * allow — 모드가 뭐든 안 묻고 한다.
163
+ * 모름 — 예전대로 모드가 정한다.
164
+ */
165
+ export function 어떻게할까(규칙들, 도구, 인자) {
166
+ // 금지가 먼저다. 허락과 금지에 같이 걸리면 금지다 — 반대로 하면 규칙 하나를
167
+ // 잘못 적어 둔 것으로 금지가 통째로 무력해진다.
168
+ for (const r of 규칙들?.deny ?? []) {
169
+ if (걸리나(r, 도구, 인자)) return { 답: 'deny', 출처: r.출처, 규칙: r.원문 };
170
+ }
171
+ for (const r of 규칙들?.allow ?? []) {
172
+ if (걸리나(r, 도구, 인자)) return { 답: 'allow', 출처: r.출처, 규칙: r.원문 };
173
+ }
174
+ return { 답: '모름', 출처: null, 규칙: null };
175
+ }
176
+
177
+ /**
178
+ * 늘 허락할 것에 한 줄 더한다 (ACP 의 '이건 앞으로 묻지 마세요').
179
+ *
180
+ * 이미 금지에 적혀 있으면 **안 더한다.** 더해 봐야 금지가 이기니 아무 일도
181
+ * 안 일어나는데, 목록에는 허락으로 적혀 있어 사람이 풀린 줄 안다.
182
+ * 그 어긋남이 규칙표를 못 믿게 만든다.
183
+ */
184
+ export function 늘허락(cfg, 줄, 규칙들 = null) {
185
+ const 규칙 = 규칙읽기(줄);
186
+ if (!규칙) return { ok: false, 왜: '규칙이 비었습니다' };
187
+ if (규칙들) {
188
+ for (const d of 규칙들.deny ?? []) {
189
+ if (d.도구 === 규칙.도구 && (!d.무늬 || d.무늬 === 규칙.무늬)) {
190
+ return { ok: false, 왜: `${d.출처}의 금지(${d.원문})와 부딪칩니다 — 금지가 이깁니다` };
191
+ }
192
+ }
193
+ }
194
+ cfg.permissions = cfg.permissions ?? {};
195
+ cfg.permissions.allow = Array.isArray(cfg.permissions.allow) ? cfg.permissions.allow : [];
196
+ if (!cfg.permissions.allow.includes(규칙.원문)) cfg.permissions.allow.push(규칙.원문);
197
+ return { ok: true, 규칙: 규칙.원문 };
198
+ }
199
+
200
+ /** 화면에 한 줄로 적을 말. 아무것도 안 걸려 있으면 빈 글. */
201
+ export function 규칙말(규칙들) {
202
+ if (!규칙들) return '';
203
+ const 조각 = [];
204
+ if (규칙들.allow.length) 조각.push(`허락 ${규칙들.allow.length}`);
205
+ if (규칙들.deny.length) 조각.push(`금지 ${규칙들.deny.length}`);
206
+ if (규칙들.정책곳) 조각.push(`관리 정책 ${규칙들.정책곳}`);
207
+ if (규칙들.탈) 조각.push(규칙들.탈);
208
+ return 조각.join(' · ');
209
+ }
@@ -196,6 +196,38 @@ export function 아는열쇠(것들) {
196
196
  return out.sort((a, b) => b.length - a.length);
197
197
  }
198
198
 
199
+ /*
200
+ * 주소에 실린 자격증명을 가린다.
201
+ *
202
+ * 게이트웨이 주소는 화면·진단 보고서·대화 기록 파일에 그대로 적힌다. 보통은
203
+ * 그래도 되는데, 물음표 뒤에 열쇠를 싣는 앞단이 있다 —
204
+ * Azure API Management 는 `?subscription-key=`, Azure Functions 앞단은
205
+ * `?code=` 로 받는다. 사람이 포털에서 복사한 주소를 그대로 넣으라고 우리가
206
+ * 권하고 있으니, 그런 주소가 들어오는 것은 예외가 아니라 예정된 일이다.
207
+ *
208
+ * 그래서 물음표 뒤의 **값은 전부** 가린다. 이름은 남긴다 — 어떤 것이 붙어
209
+ * 있는지는 사람이 봐야 하고, 이름만으로는 아무것도 못 연다.
210
+ *
211
+ * 예외를 두지 않는다. `api-version` 만 남기고 나머지를 가리는 식으로 목록을
212
+ * 관리하기 시작하면, 새로 나온 이름 하나가 조용히 평문으로 새어 나간다.
213
+ */
214
+ export function 주소가리기(주소) {
215
+ const s = String(주소 ?? '');
216
+ const i = s.indexOf('?');
217
+ if (i < 0) return s;
218
+ const 앞 = s.slice(0, i);
219
+ const 뒤 = s.slice(i + 1);
220
+ const 가린 = 뒤.split('&').map((쌍) => {
221
+ const j = 쌍.indexOf('=');
222
+ if (j < 0) return 쌍;
223
+ const 이름 = 쌍.slice(0, j);
224
+ // 판 번호는 비밀이 아니고, 이게 안 보이면 무엇이 잘못됐는지 알 수 없다.
225
+ if (/^api-version$/i.test(이름)) return 쌍;
226
+ return `${이름}=«가림»`;
227
+ }).join('&');
228
+ return `${앞}?${가린}`;
229
+ }
230
+
199
231
  /**
200
232
  * 이 도구의 결과를 가려도 되는가.
201
233
  *
package/src/setup.js CHANGED
@@ -1,11 +1,14 @@
1
1
  // 첫 실행 마법사 + 진단 실행.
2
2
  import { c, say, rule, mark } from './ui/ansi.js';
3
+ import { 주소가리기 } from './safety/secrets.js';
3
4
  import { ask, pick } from './ui/prompt.js';
4
5
  import { spin } from './ui/spinner.js';
5
6
  import { detect } from './backend/detect.js';
6
7
  import { probe } from './backend/probe.js';
7
8
  import { renderHeader, renderLine, verdict, renderVerdict, plainReport } from './report.js';
8
9
  import { load, save, upsert, slug, resolveKey, activeProfile, configPath } from './config.js';
10
+ import { 보관방식 } from './safety/keystore.js';
11
+ import { 애저풀기, 애저base } from './backend/azure.js';
9
12
  import { allowEndpoint } from './safety/network.js';
10
13
  import { writeFileSync } from 'node:fs';
11
14
 
@@ -18,14 +21,22 @@ export function banner() {
18
21
  // 주소와 키를 받아 연결을 찾아낸다. 실패하면 null.
19
22
  async function connect(url, key) {
20
23
  const s = spin(`${url} 확인 중...`);
21
- // 사용자가 방금 적어 넣은 주소다. 확인하는 동안만 문을 연다.
22
- allowEndpoint(/^https?:\/\//i.test(url) ? url : 'http://' + url);
24
+ /*
25
+ * 사용자가 방금 적어 넣은 주소다. 확인하는 동안만 문을 연다.
26
+ *
27
+ * 스킴을 안 적었으면 **두 가지 다 열어 둔다.** 자리마다 기본으로 붙이는
28
+ * 스킴이 다르기 때문이다 — 로컬 서버를 찾는 쪽은 `http://` 를, Azure 쪽은
29
+ * `https://` 를 붙인다. 한쪽만 열어 두면 우리가 만든 주소를 우리 자물쇠가
30
+ * 막고, 화면에는 "허용되지 않은 주소" 만 뜬다. 여는 것은 사람이 적어 넣은
31
+ * 그 호스트 하나뿐이라 넓어지는 것이 아니다.
32
+ */
33
+ allowEndpoint(/^https?:\/\//i.test(url) ? url : [`http://${url}`, `https://${url}`]);
23
34
  const found = await detect(url, key);
24
35
  if (!found.kind) {
25
36
  s.stop(` ${mark.no} ${c.red('연결 실패')}`);
26
37
  say('');
27
38
  say(` 시도한 주소:`);
28
- for (const t of found.tried) say(` ${c.gray(t + '/models')}`);
39
+ for (const t of found.tried) say(` ${c.gray(주소가리기(t.includes('?') ? t : `${t}/models`))}`);
29
40
  say('');
30
41
  say(` ${c.gray('확인할 것 — 주소·포트가 맞는지, 프록시가 필요한지,')}`);
31
42
  say(` ${c.gray('사내 인증서라면 NODE_EXTRA_CA_CERTS 환경변수가 필요합니다.')}`);
@@ -34,7 +45,8 @@ async function connect(url, key) {
34
45
  }
35
46
  const kindName = found.kind === 'ollama' ? `Ollama ${found.version ?? ''}`.trim() : 'OpenAI 호환';
36
47
  s.stop(` ${mark.ok} ${c.green('연결됨')} ${c.gray(`${kindName} · ${found.ms}ms`)}`);
37
- say(` ${c.gray('주소')} ${found.base}`);
48
+ // 물음표 뒤에 열쇠를 싣는 앞단이 있다 (safety/secrets.js 의 주소가리기).
49
+ say(` ${c.gray('주소')} ${주소가리기(found.base)}`);
38
50
  say(` ${c.gray('인증')} ${found.auth === 'none' ? '없음' : found.auth}`);
39
51
  say(` ${c.gray('모델')} ${found.models.length}개 발견`);
40
52
  if (found.warn) say(` ${mark.warn} ${c.yellow(found.warn)}`);
@@ -70,6 +82,10 @@ export async function runProbe(conn, { out = null } = {}) {
70
82
 
71
83
  export async function runSetup() {
72
84
  banner();
85
+ // 설정에 적어 둔 api-version 을 **붙기 전에** 읽는다. load() 가 애저정하기()를
86
+ // 부른다. 이걸 뒤에서 하면 사내에서 판을 고정해 둔 곳이 확인만 GA판으로 하고,
87
+ // 문서에 적어 둔 대로 안 도는 셈이 된다.
88
+ load();
73
89
  say(` ${c.gray('모델 연결을 설정합니다. 주소와 키만 있으면 됩니다.')}`);
74
90
  say('');
75
91
 
@@ -85,6 +101,18 @@ export async function runSetup() {
85
101
  const model = await chooseModel(found);
86
102
  if (!model) { say(` ${mark.no} 모델이 비었습니다.`); return 1; }
87
103
 
104
+ /*
105
+ * Azure 는 모델 이름이 **주소 안에** 있다.
106
+ *
107
+ * 목록에서 다른 배포를 골랐으면 주소도 그 배포로 바꿔야 한다. 안 바꾸면
108
+ * 화면에는 고른 이름이 보이는데 요청은 처음 주소의 배포로 나간다 —
109
+ * 사람이 알아챌 방법이 없는 어긋남이다.
110
+ */
111
+ if (found.azure) {
112
+ const 푼것 = 애저풀기(found.base);
113
+ if (푼것) found.base = 애저base(푼것.origin, model, 푼것.판);
114
+ }
115
+
88
116
  const conn = { kind: found.kind, base: found.base, auth: found.auth, key, model };
89
117
  const { facts } = await runProbe(conn);
90
118
 
@@ -102,14 +130,18 @@ export async function runSetup() {
102
130
  tools: facts.tools ?? false,
103
131
  json: facts.json ?? false,
104
132
  think: facts.think ?? false,
133
+ vision: facts.vision ?? false,
105
134
  });
106
135
  cfg.active = id;
107
136
  const p = save(cfg);
108
137
 
109
138
  say(` ${mark.ok} 저장됨 ${c.cyan(p)}`);
110
139
  if (key) {
111
- say(` ${c.gray('키가 파일에 들어 있습니다. 남기고 싶지 않으면 파일에서 지우고')}`);
112
- say(` ${c.gray('환경변수 DEEL_API_KEY 넣으세요 그쪽이 우선합니다.')}`);
140
+ // 열쇠가 파일에 '어떤 꼴로' 들어갔는지를 그대로 적는다. 잠겼는지 아닌지를
141
+ // 사람이 짐작하게 두면, 잠긴 파일을 잠긴 줄 알고 아무 데나 둔다.
142
+ const 저장한것 = activeProfile(load())?.apiKey ?? null;
143
+ say(` ${c.gray(`열쇠 보관 — ${보관방식(저장한것)}`)}`);
144
+ say(` ${c.gray('파일에 아예 안 남기려면 환경변수 DEEL_API_KEY 로 넣으세요 — 그쪽이 우선합니다.')}`);
113
145
  }
114
146
  say('');
115
147
  return 0;
@@ -121,6 +153,7 @@ export async function runDiagnose(flags) {
121
153
  let conn;
122
154
 
123
155
  if (flags.url) {
156
+ load(); // 설정의 api-version 을 먼저 읽는다 (runSetup 과 같은 이유)
124
157
  const key = flags.key ?? process.env.DEEL_API_KEY ?? '';
125
158
  const found = await connect(flags.url, key);
126
159
  if (!found) return 1;
@@ -167,7 +200,7 @@ export async function showStatus() {
167
200
  p.think ? c.green('추론') : c.gray('추론'),
168
201
  ].join(c.gray('·'));
169
202
  say(` ${c.bold(p.name)} ${c.gray(p.model)} ${caps}${here}`);
170
- say(` ${c.gray(p.baseUrl)}`);
203
+ say(` ${c.gray(주소가리기(p.baseUrl))}`);
171
204
  }
172
205
  say('');
173
206
  say(` ${c.gray('설정 파일')} ${configPath()}`);
@@ -0,0 +1,178 @@
1
+ /**
2
+ * 클립보드에 든 그림 꺼내기 — 화면 캡처 → 바로 붙이기.
3
+ *
4
+ * ── 왜 필요한가 ─────────────────────────────────────────────────────────
5
+ *
6
+ * 눈이 달린 모델에게 화면을 보여 줄 수 있게 됐는데(backend/vision.js),
7
+ * 보여 주려면 먼저 **파일로 저장해야** 했다. 오류 화면 하나를 물어보려고
8
+ * 캡처 → 그림판 → 저장 → 경로 찾기를 거친다. 그 네 걸음이 「그냥 말로
9
+ * 설명하자」로 사람을 되돌린다.
10
+ *
11
+ * 캡처는 이미 클립보드에 있다. 꺼내 오기만 하면 된다.
12
+ *
13
+ * ── 왜 의존성 없이 되나 ─────────────────────────────────────────────────
14
+ *
15
+ * 세 운영체제 모두 **이미 깔려 있는 것**으로 꺼낸다. 새로 깔라고 하지 않는다.
16
+ *
17
+ * 윈도우 파워셸의 .NET Clipboard (윈도우에 늘 있다)
18
+ * 맥 osascript (맥에 늘 있다)
19
+ * 리눅스 wl-paste 나 xclip — 배포판에 따라 없을 수 있다
20
+ *
21
+ * ── 없으면 없다고 한다 ──────────────────────────────────────────────────
22
+ *
23
+ * 리눅스에서 둘 다 없으면 **어떻게 하면 되는지**를 같이 준다. 「안 됩니다」
24
+ * 로 끝내면 사람은 이 기능이 고장 난 줄 알지, 도구 하나 깔면 되는 줄 모른다.
25
+ * 클립보드에 글만 있을 때도 「그림이 없다」와 「못 꺼냈다」를 갈라 말한다 —
26
+ * 앞의 것은 사람이 캡처를 다시 하면 되고, 뒤의 것은 우리 탓이다.
27
+ */
28
+ import { spawnSync } from 'node:child_process';
29
+ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync, rmSync, statSync } from 'node:fs';
30
+ import { tmpdir } from 'node:os';
31
+ import { join } from 'node:path';
32
+
33
+ /** 이보다 큰 그림은 안 받는다. 화면 캡처 한 장은 보통 1MB 안쪽이다. */
34
+ export const 그림한도 = 12 * 1024 * 1024;
35
+
36
+ const 있나 = (이름, 인자) => {
37
+ const r = spawnSync(이름, 인자, { encoding: 'utf8', timeout: 5000, windowsHide: true });
38
+ return !r.error && r.status === 0;
39
+ };
40
+
41
+ /*
42
+ * 윈도우.
43
+ *
44
+ * 파워셸을 **파일로 적어 두고** 부른다. `-Command` 뒤에 길게 붙이면 따옴표가
45
+ * 셸을 두 번 거치면서 깨지고, 그 깨짐이 PC 마다 다르게 나타난다. 스크립트는
46
+ * ASCII 로만 쓴다 — Windows PowerShell 5.1 은 BOM 없는 .ps1 을 그 PC 의 옛
47
+ * 코드페이지로 읽어서, 한글이 섞이면 문법이 무너진다(src/completion.js 참고).
48
+ */
49
+ function 윈도우에서(방) {
50
+ const 낼곳 = join(방, 'clip.png');
51
+ const 스크립트 = join(방, 'grab.ps1');
52
+ const 글 = [
53
+ '$ErrorActionPreference = "Stop"',
54
+ 'Add-Type -AssemblyName System.Windows.Forms',
55
+ 'Add-Type -AssemblyName System.Drawing',
56
+ '$img = [System.Windows.Forms.Clipboard]::GetImage()',
57
+ 'if ($img -eq $null) { Write-Output "NOIMAGE"; exit 0 }',
58
+ `$img.Save(${JSON.stringify(낼곳)}, [System.Drawing.Imaging.ImageFormat]::Png)`,
59
+ 'Write-Output "OK"',
60
+ ].join('\n');
61
+ // Buffer 로 적어서 인코딩이 끼어들 자리를 없앤다.
62
+ writeFileSync(스크립트, Buffer.from(글, 'latin1'));
63
+
64
+ const r = spawnSync('powershell', [
65
+ '-NoProfile', '-NonInteractive', '-STA', '-ExecutionPolicy', 'Bypass', '-File', 스크립트,
66
+ ], { encoding: 'utf8', timeout: 20000, windowsHide: true });
67
+
68
+ if (r.error) return { ok: false, 왜: `파워셸을 못 불렀습니다: ${r.error.message}` };
69
+ const 낸말 = String(r.stdout ?? '').trim();
70
+ if (/NOIMAGE/.test(낸말)) return { ok: false, 없음: true };
71
+ if (!existsSync(낼곳)) {
72
+ const 탈 = String(r.stderr ?? '').split('\n').find((l) => l.trim()) ?? '';
73
+ return { ok: false, 왜: 탈 ? `클립보드를 못 읽었습니다: ${탈.trim()}` : '클립보드를 못 읽었습니다' };
74
+ }
75
+ return { ok: true, buf: readFileSync(낼곳), mime: 'image/png' };
76
+ }
77
+
78
+ /*
79
+ * 맥.
80
+ *
81
+ * osascript 로 PNG 를 꺼내면 «data PNGf89504e47…» 꼴의 16진수 글이 나온다.
82
+ * 그림이 없으면 오류를 내므로, 그것으로 「없음」을 가른다.
83
+ */
84
+ function 맥에서() {
85
+ const r = spawnSync('osascript', ['-e', 'the clipboard as «class PNGf»'], {
86
+ encoding: 'utf8', timeout: 20000,
87
+ });
88
+ if (r.error) return { ok: false, 왜: `osascript 를 못 불렀습니다: ${r.error.message}` };
89
+ const 낸말 = String(r.stdout ?? '');
90
+ const m = /«data PNGf([0-9A-Fa-f]+)»/.exec(낸말);
91
+ if (!m) {
92
+ // 그림이 아니면 osascript 가 오류를 낸다. 그건 「없음」이지 고장이 아니다.
93
+ if (r.status !== 0) return { ok: false, 없음: true };
94
+ return { ok: false, 없음: true };
95
+ }
96
+ return { ok: true, buf: Buffer.from(m[1], 'hex'), mime: 'image/png' };
97
+ }
98
+
99
+ /*
100
+ * 리눅스.
101
+ *
102
+ * 웨이랜드면 wl-paste, X11 이면 xclip. 배포판이 기본으로 안 깔아 주는 것이라
103
+ * 없을 수 있고, 그때는 **무엇을 깔면 되는지** 알려 준다.
104
+ */
105
+ function 리눅스에서() {
106
+ const 후보 = [
107
+ { 이름: 'wl-paste', 볼것: ['--list-types'], 꺼내기: ['--type', 'image/png'] },
108
+ { 이름: 'xclip', 볼것: ['-selection', 'clipboard', '-t', 'TARGETS', '-o'], 꺼내기: ['-selection', 'clipboard', '-t', 'image/png', '-o'] },
109
+ ];
110
+ const 있는것 = 후보.filter((x) => 있나(x.이름, ['--version']) || 있나(x.이름, ['-version']));
111
+ if (!있는것.length) {
112
+ return {
113
+ ok: false,
114
+ 왜: '클립보드에서 그림을 꺼낼 도구가 없습니다.\n'
115
+ + ' 웨이랜드면 sudo apt install wl-clipboard\n'
116
+ + ' X11 이면 sudo apt install xclip\n'
117
+ + ' 둘 다 안 되면 캡처를 파일로 저장한 뒤 @경로 로 붙이세요.',
118
+ };
119
+ }
120
+ for (const x of 있는것) {
121
+ const 종류 = spawnSync(x.이름, x.볼것, { encoding: 'utf8', timeout: 10000 });
122
+ if (!/image\/png/i.test(String(종류.stdout ?? ''))) continue;
123
+ const r = spawnSync(x.이름, x.꺼내기, { timeout: 20000, maxBuffer: 64 * 1024 * 1024 });
124
+ if (r.error || !r.stdout?.length) continue;
125
+ return { ok: true, buf: Buffer.from(r.stdout), mime: 'image/png' };
126
+ }
127
+ return { ok: false, 없음: true };
128
+ }
129
+
130
+ /**
131
+ * 클립보드에 그림이 있으면 꺼낸다.
132
+ *
133
+ * @returns {{ok:true, buf:Buffer, mime:string}
134
+ * | {ok:false, 없음:true} 그림이 없다 (사람이 다시 캡처하면 된다)
135
+ * | {ok:false, 왜:string}} 못 꺼냈다 (까닭과 길을 같이 준다)
136
+ */
137
+ export function 클립보드그림({ platform = process.platform } = {}) {
138
+ let 방 = null;
139
+ try {
140
+ if (platform === 'win32') {
141
+ 방 = mkdtempSync(join(tmpdir(), 'deel-clip-'));
142
+ const r = 윈도우에서(방);
143
+ return 잰다(r);
144
+ }
145
+ if (platform === 'darwin') return 잰다(맥에서());
146
+ if (platform === 'linux') return 잰다(리눅스에서());
147
+ return { ok: false, 왜: `${platform} 에서는 아직 클립보드 그림을 못 꺼냅니다 — 파일로 저장한 뒤 @경로 로 붙이세요.` };
148
+ } catch (err) {
149
+ return { ok: false, 왜: `클립보드를 읽다 막혔습니다: ${err.message}` };
150
+ } finally {
151
+ if (방) { try { rmSync(방, { recursive: true, force: true }); } catch { /* 치우다 실패는 넘어간다 */ } }
152
+ }
153
+ }
154
+
155
+ /** 크기를 여기서 한 번만 잰다 — 운영체제마다 따로 재면 한 군데를 빠뜨린다. */
156
+ function 잰다(r) {
157
+ if (!r?.ok) return r;
158
+ if (!r.buf?.length) return { ok: false, 없음: true };
159
+ if (r.buf.length > 그림한도) {
160
+ return { ok: false, 왜: `그림이 너무 큽니다 (${(r.buf.length / 1048576).toFixed(1)}MB · 한도 ${그림한도 / 1048576}MB)` };
161
+ }
162
+ return r;
163
+ }
164
+
165
+ /**
166
+ * 꺼낸 그림을 파일로 앉힌다.
167
+ *
168
+ * 살림 폴더(.deel/붙인그림/) 안에 둔다. 작업 폴더에 흩뿌리면 사람이 지우기
169
+ * 전에 커밋에 딸려 들어가고, 임시 폴더에 두면 대화를 다시 열었을 때 사라진다.
170
+ */
171
+ export function 그림앉히기(buf, 살림, { 이제 = new Date() } = {}) {
172
+ const 방 = join(살림, '붙인그림');
173
+ mkdirSync(방, { recursive: true });
174
+ const 이름 = `${이제.toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19)}.png`;
175
+ const 자리 = join(방, 이름);
176
+ writeFileSync(자리, buf);
177
+ return { 자리, 이름, 바이트: statSync(자리).size };
178
+ }