deel-local-cli 0.5.0 → 0.9.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.
@@ -1,4 +1,8 @@
1
- // HTTP 한 겹. 시간 제한과 오류 정규화만 담당한다.
1
+ // HTTP 한 겹. 시간 제한과 오류 정규화, 그리고 나가도 되는 곳인지 확인한다.
2
+ //
3
+ // 이 파일이 프로그램에서 바깥으로 나가는 유일한 문이다.
4
+ // 나가기 전에 반드시 safety/network.js 의 문지기에게 물어본다.
5
+ import { checkUrl, NetBlocked } from '../safety/network.js';
2
6
 
3
7
  export const AUTH_STYLES = [
4
8
  { id: 'bearer', label: 'Authorization: Bearer', apply: (h, k) => { h['Authorization'] = `Bearer ${k}`; } },
@@ -7,6 +11,29 @@ export const AUTH_STYLES = [
7
11
  { id: 'none', label: '인증 없음', apply: () => {} },
8
12
  ];
9
13
 
14
+ /**
15
+ * 열어 둔 연결을 닫는다. 프로그램을 끝내기 직전에 부른다.
16
+ *
17
+ * fetch 는 연결을 재사용하려고 소켓을 살려 둔다(keep-alive). 그래서 할 일이
18
+ * 끝나도 프로세스가 저절로 안 끝난다. 예전에는 그걸 process.exit() 으로
19
+ * 잘라 냈는데, 윈도우에서 닫는 중인 핸들을 두고 끊으면 libuv 가 abort 한다.
20
+ *
21
+ * Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), src\win\async.c
22
+ *
23
+ * 실제로 `deel scan` 이 결과를 다 찍고 나서 이렇게 죽었다. 화면에는 정상으로
24
+ * 보이는데 종료코드는 3221226505(0xC0000409) 였다.
25
+ *
26
+ * 그래서 잘라 내는 대신 닫는다. 이 자리는 Node 내부 이름이라 없을 수도 있으므로,
27
+ * 없으면 조용히 넘어간다 — 그때는 부르는 쪽의 시간제한이 받아 준다.
28
+ */
29
+ export function closeConnections() {
30
+ try {
31
+ const d = globalThis[Symbol.for('undici.globalDispatcher.1')];
32
+ if (d && typeof d.close === 'function') return d.close().catch(() => {});
33
+ } catch { /* 없으면 그만 */ }
34
+ return Promise.resolve();
35
+ }
36
+
10
37
  export function headersFor(authStyle, key, extra = {}) {
11
38
  const h = { 'Content-Type': 'application/json', Accept: 'application/json', ...extra };
12
39
  const style = AUTH_STYLES.find((s) => s.id === authStyle) ?? AUTH_STYLES[0];
@@ -14,14 +41,19 @@ export function headersFor(authStyle, key, extra = {}) {
14
41
  return h;
15
42
  }
16
43
 
17
- export async function req(url, { method = 'GET', headers = {}, body, timeout = 20000, stream = false } = {}) {
44
+ export async function req(url, { method = 'GET', headers = {}, body, timeout = 20000, stream = false, signal = null } = {}) {
18
45
  const started = Date.now();
19
46
  try {
47
+ checkUrl(url); // 허용된 자리가 아니면 여기서 끝난다. 본문은 만들어지지도 않는다.
48
+ // 시간 초과와 '사용자가 Ctrl+C' 를 둘 다 듣는다. 둘 중 먼저 오는 쪽이 끊는다.
49
+ const sig = signal
50
+ ? (AbortSignal.any ? AbortSignal.any([AbortSignal.timeout(timeout), signal]) : signal)
51
+ : AbortSignal.timeout(timeout);
20
52
  const res = await fetch(url, {
21
53
  method,
22
54
  headers,
23
55
  body: body === undefined ? undefined : JSON.stringify(body),
24
- signal: AbortSignal.timeout(timeout),
56
+ signal: sig,
25
57
  redirect: 'follow',
26
58
  });
27
59
  const ms = Date.now() - started;
@@ -33,10 +65,19 @@ export async function req(url, { method = 'GET', headers = {}, body, timeout = 2
33
65
  return { ok: res.ok, status: res.status, json, text, ms, headers: res.headers };
34
66
  } catch (err) {
35
67
  const ms = Date.now() - started;
68
+ // 막힌 것은 통신 실패와 다르다. 조용히 넘기면 자물쇠가 있는지도 모른다.
69
+ if (err instanceof NetBlocked) throw err;
70
+ // 사용자가 끊은 것도 실패가 아니다. 오류 화면을 띄우면 안 된다.
71
+ if (signal?.aborted) throw new Aborted();
36
72
  return { ok: false, status: 0, error: normalizeError(err), ms };
37
73
  }
38
74
  }
39
75
 
76
+ // 사용자가 Ctrl+C 로 끊었다는 뜻. 통신 오류와 구분하려고 따로 둔다.
77
+ export class Aborted extends Error {
78
+ constructor() { super('사용자가 중단했습니다'); this.name = 'Aborted'; }
79
+ }
80
+
40
81
  function normalizeError(err) {
41
82
  const m = String(err?.message ?? err);
42
83
  if (err?.name === 'TimeoutError' || /timed? ?out/i.test(m)) return '시간 초과 — 응답이 없습니다';
@@ -5,6 +5,7 @@
5
5
  // warn 되긴 하는데 조건이 붙음
6
6
  // skip 앞 검사가 실패해 확인 불가
7
7
  import { req, headersFor, serverMessage } from './http.js';
8
+ import { probeCtx } from './ctxsize.js';
8
9
 
9
10
  const READ_TOOL = {
10
11
  type: 'function',
@@ -146,7 +147,7 @@ export async function probe(conn, onStep = () => {}) {
146
147
  label: '시스템 메시지',
147
148
  status: sys.ok ? (sysHit ? 'ok' : 'warn') : 'no',
148
149
  detail: sys.ok
149
- ? sysHit ? '지시를 따름' : '전달은 되나 모델이 잘 안 따름 — 규칙·스킬이 약하게 먹습니다'
150
+ ? sysHit ? '지시를 따름' : '전달은 되나 모델이 잘 안 따름 — 규칙·스킬이 약하게 적용됩니다'
150
151
  : serverMessage(sys),
151
152
  ms: sys.ms,
152
153
  });
@@ -330,26 +331,26 @@ export async function probe(conn, onStep = () => {}) {
330
331
  facts.think = differs;
331
332
 
332
333
  // 8. 컨텍스트 길이 — 파일을 몇 개까지 한 번에 읽힐 수 있느냐.
333
- let ctx = null;
334
- let ctxNote = '';
335
- if (shape === 'ollama') {
336
- const show = await req(`${base}/api/show`, { method: 'POST', headers: H(), body: { model }, timeout: 20000 });
337
- const info = show.json?.model_info ?? {};
338
- const k = Object.keys(info).find((x) => /context_length/.test(x));
339
- if (k) { ctx = info[k]; ctxNote = '모델 정보에서 읽음'; }
340
- } else {
341
- const m = await req(`${base}/models/${encodeURIComponent(model)}`, { headers: H(), timeout: 15000 });
342
- ctx = m.json?.context_window ?? m.json?.max_context_length ?? m.json?.context_length ?? null;
343
- if (ctx) ctxNote = '모델 정보에서 읽음';
344
- }
334
+ //
335
+ // 자리만 보지 않는다. 서버마다 이름도 자리도 다르다 (ctxsize.js 참고).
336
+ // 여기서 작게 잡으면 프로그램 전체가 작아진다 — 답 길이 상한까지 이 값에서 나온다.
337
+ const 길이 = await probeCtx({ kind: shape, base, auth, key, model });
338
+ const ctx = 길이.value;
345
339
  add({
346
340
  id: 'ctx',
347
341
  label: '컨텍스트 길이',
348
342
  status: ctx ? 'ok' : 'warn',
349
- detail: ctx ? `${ctx.toLocaleString()} 토큰 (${ctxNote})` : '서버가 알려주지 않음 — 설정에서 직접 지정합니다',
343
+ detail: ctx
344
+ ? `${ctx.toLocaleString()} 토큰 (${길이.source ?? '모델 정보'}에서 읽음)`
345
+ + (길이.max && 길이.loaded && 길이.max > 길이.loaded
346
+ ? ` · 이 모델은 ${길이.max.toLocaleString()} 까지 되는데 지금 ${길이.loaded.toLocaleString()} 로 올려 두셨습니다`
347
+ : '')
348
+ : '서버가 알려주지 않음 — /ctx 로 직접 지정하세요',
350
349
  ms: 0,
351
350
  });
352
351
  facts.ctx = ctx;
352
+ facts.ctxMax = 길이.max ?? null;
353
+ facts.ctxLoaded = 길이.loaded ?? null;
353
354
 
354
355
  return { facts, results };
355
356
  }
@@ -0,0 +1,256 @@
1
+ // 이 PC 에 떠 있는 로컬 모델 서버를 전부 찾는다.
2
+ //
3
+ // 로컬 런타임은 하나만 쓰지 않는다. Ollama 로 작은 모델을 돌리면서
4
+ // LM Studio 로 큰 모델을 띄워 두는 식이 흔하다. 그래서 하나만 물어보지 않고
5
+ // 알려진 자리를 한꺼번에 두드려서 있는 대로 다 등록해 둔다.
6
+ //
7
+ // 두드리는 곳은 전부 이 컴퓨터(127.0.0.1) 다. 바깥으로 나가지 않는다.
8
+ import { execFileSync } from 'node:child_process';
9
+ import { readFileSync } from 'node:fs';
10
+ import { req, headersFor } from './http.js';
11
+ import { allowTemporarily } from '../safety/network.js';
12
+
13
+ // 포트는 겹칠 수 있다(llama.cpp 와 LocalAI 가 둘 다 8080). 그래서 포트로 단정하지 않고
14
+ // 응답을 보고 정한다. 아래 이름은 '그 포트에서 흔한 것' 이라는 힌트일 뿐이다.
15
+ export const KNOWN = [
16
+ { port: 11434, hint: 'Ollama' },
17
+ { port: 1234, hint: 'LM Studio' },
18
+ { port: 8080, hint: 'llama.cpp · LocalAI' },
19
+ { port: 8000, hint: 'vLLM · SGLang' },
20
+ { port: 1337, hint: 'Jan' },
21
+ { port: 5001, hint: 'KoboldCpp' },
22
+ { port: 5000, hint: 'text-generation-webui · TabbyAPI' },
23
+ { port: 4891, hint: 'GPT4All' },
24
+ { port: 9997, hint: 'Xinference' },
25
+ { port: 11435, hint: 'Ollama (두 번째)' },
26
+ { port: 8081, hint: 'llama.cpp (두 번째)' },
27
+ { port: 3000, hint: 'Open WebUI · LiteLLM' },
28
+ { port: 4000, hint: 'LiteLLM' },
29
+ ];
30
+
31
+ const fmtSize = (bytes) => {
32
+ if (!bytes) return '';
33
+ const gb = bytes / 1024 ** 3;
34
+ return gb >= 1 ? `${gb.toFixed(1)}GB` : `${Math.round(bytes / 1024 ** 2)}MB`;
35
+ };
36
+
37
+ /**
38
+ * 이 컴퓨터에서 실제로 듣고 있는 TCP 포트.
39
+ *
40
+ * 왜 필요한가:
41
+ * 알려진 자리 13곳만 두드리면, 직접 세운 프록시나 사내 게이트웨이를 못 찾는다.
42
+ * 그런 것들은 아무 포트나 쓴다. "안 잡히는데요" 의 대부분이 이 경우다.
43
+ *
44
+ * 그래서 운영체제에게 물어본다. 이미 열려 있는 걸 아는 포트라서 두드려도
45
+ * 기다릴 일이 없다 — 죽은 포트를 찍는 것보다 오히려 빠르다.
46
+ *
47
+ * 못 물어봐도 그냥 넘어간다. 알려진 자리 훑기는 그대로 되기 때문이다.
48
+ */
49
+ export function listeningPorts({ timeout = 4000 } = {}) {
50
+ const 명령 = process.platform === 'win32'
51
+ ? { file: 'netstat.exe', args: ['-ano', '-p', 'TCP'] }
52
+ : process.platform === 'darwin'
53
+ ? { file: 'netstat', args: ['-an', '-p', 'tcp'] }
54
+ : { file: 'ss', args: ['-H', '-ltn'] };
55
+
56
+ let out = '';
57
+ try {
58
+ out = execFileSync(명령.file, 명령.args, { timeout, encoding: 'latin1', windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
59
+ } catch {
60
+ // 리눅스에 ss 가 없을 수 있다. 그때는 커널이 직접 알려주는 표를 읽는다.
61
+ if (process.platform === 'linux') return procNetTcp();
62
+ return [];
63
+ }
64
+
65
+ const 포트 = new Set();
66
+ for (const line of out.split(/\r?\n/)) {
67
+ if (!/LISTEN/i.test(line)) continue;
68
+ // 주소는 `0.0.0.0:8080` `[::]:8080` `*.8080` 중 하나로 온다.
69
+ const m = line.match(/[\s[](?:[\d.]+|::[\da-f:%]*|\*)[\]]?[:.](\d{1,5})\s/i)
70
+ ?? line.match(/:(\d{1,5})\s/);
71
+ const p = Number(m?.[1]);
72
+ if (p > 0 && p < 65536) 포트.add(p);
73
+ }
74
+ return [...포트].sort((a, b) => a - b);
75
+ }
76
+
77
+ function procNetTcp() {
78
+ const 포트 = new Set();
79
+ for (const f of ['/proc/net/tcp', '/proc/net/tcp6']) {
80
+ let text;
81
+ try { text = readFileSync(f, 'utf8'); } catch { continue; }
82
+ for (const line of text.split('\n').slice(1)) {
83
+ const col = line.trim().split(/\s+/);
84
+ if (col.length < 4) continue;
85
+ if (col[3] !== '0A') continue; // 0A = LISTEN
86
+ const p = parseInt(col[1].split(':')[1], 16);
87
+ if (p > 0 && p < 65536) 포트.add(p);
88
+ }
89
+ }
90
+ return [...포트].sort((a, b) => a - b);
91
+ }
92
+
93
+ // 살아 있는지부터 짧게 본다. 죽은 포트에서 오래 기다리면 훑기가 하염없이 느려진다.
94
+ async function probeOllama(origin, timeout) {
95
+ const v = await req(`${origin}/api/version`, { timeout });
96
+ if (!v.ok || !v.json?.version) return null;
97
+ const tags = await req(`${origin}/api/tags`, { timeout: timeout * 2 });
98
+ return {
99
+ kind: 'ollama',
100
+ runtime: 'Ollama',
101
+ version: v.json.version,
102
+ base: origin,
103
+ auth: 'none',
104
+ models: (tags.json?.models ?? []).map((m) => ({
105
+ id: m.name ?? m.model,
106
+ note: [m.details?.parameter_size, fmtSize(m.size)].filter(Boolean).join(' · '),
107
+ })),
108
+ ms: v.ms,
109
+ };
110
+ }
111
+
112
+ // OpenAI 호환 서버가 놓일 수 있는 자리들.
113
+ //
114
+ // 대부분은 `/v1` 이다. 그런데 사내 게이트웨이나 직접 세운 프록시는 앞에 뭔가를
115
+ // 더 붙이는 경우가 많다 — 라우터 뒤에 붙이거나 여러 서비스를 한 포트에 모을 때
116
+ // 그렇게 된다. 그래서 흔한 모양 몇 가지를 더 본다.
117
+ // 죽은 포트에서는 첫 요청이 바로 실패하므로 훑는 시간에는 거의 영향이 없다.
118
+ const BASES = ['/v1', '', '/api/v1', '/openai/v1', '/api', '/llm/v1', '/proxy/v1'];
119
+
120
+ async function probeOpenAI(origin, timeout, key) {
121
+ for (const base of BASES.map((b) => `${origin}${b}`)) {
122
+ const r = await req(`${base}/models`, { headers: headersFor(key ? 'bearer' : 'none', key), timeout });
123
+ if (!r.ok || !r.json) {
124
+ // 규격은 맞는데 키가 없어 막힌 경우 — 서버가 있다는 사실은 알려 준다.
125
+ if (r.status === 401 || r.status === 403) {
126
+ return { kind: 'openai', runtime: null, base, auth: null, models: [], locked: true, ms: r.ms };
127
+ }
128
+ continue;
129
+ }
130
+ const list = r.json.data ?? r.json.models ?? [];
131
+ if (!Array.isArray(list)) continue;
132
+ return {
133
+ kind: 'openai',
134
+ runtime: null,
135
+ base,
136
+ auth: key ? 'bearer' : 'none',
137
+ models: list
138
+ .map((m) => (typeof m === 'string' ? { id: m } : { id: m.id ?? m.name ?? m.model, note: m.owned_by ?? '' }))
139
+ .filter((m) => m.id),
140
+ ms: r.ms,
141
+ };
142
+ }
143
+ return null;
144
+ }
145
+
146
+ // 어떤 런타임인지 티 나는 자국을 찾는다. 못 찾으면 포트 힌트로 적고 '추정' 이라고 밝힌다.
147
+ async function fingerprint(origin, hit, timeout) {
148
+ if (hit.runtime) return hit.runtime;
149
+ const props = await req(`${origin}/props`, { timeout }); // llama.cpp
150
+ if (props.ok && props.json?.default_generation_settings) return 'llama.cpp';
151
+ const lms = await req(`${origin}/api/v0/models`, { timeout }); // LM Studio
152
+ if (lms.ok && Array.isArray(lms.json?.data)) return 'LM Studio';
153
+ const ver = await req(`${origin}/version`, { timeout }); // vLLM
154
+ if (ver.ok && ver.json?.version) return 'vLLM';
155
+ return null;
156
+ }
157
+
158
+ /**
159
+ * 이 PC 를 훑는다.
160
+ * @param {object} o
161
+ * @param {string} o.host 기본 127.0.0.1
162
+ * @param {number[]} o.ports 더 볼 포트
163
+ * @param {number} o.timeout 포트 하나당 기다릴 시간(ms)
164
+ * @param {(x)=>void} o.onFind 하나 찾을 때마다 알림
165
+ */
166
+ export async function scanLocal({ host = '127.0.0.1', ports = [], timeout = 1200, key = '', onFind, listening = true, maxListening = 80 } = {}) {
167
+ const list = [...KNOWN];
168
+ for (const p of ports) if (!list.some((x) => x.port === p)) list.push({ port: p, hint: '직접 지정' });
169
+
170
+ // 이 컴퓨터에서 실제로 듣고 있는 포트도 같이 본다.
171
+ // 직접 세운 프록시는 알려진 자리에 없기 때문이다. 이 PC 안에서만 한다 —
172
+ // 남의 컴퓨터 포트를 훑는 것은 이 도구가 할 일이 아니다.
173
+ const 잘린것 = [];
174
+ if (listening && (host === '127.0.0.1' || host === 'localhost' || host === '::1')) {
175
+ const 열린것 = listeningPorts().filter((p) => !list.some((x) => x.port === p));
176
+ const 볼것 = 열린것.slice(0, maxListening);
177
+ if (열린것.length > 볼것.length) 잘린것.push(...열린것.slice(maxListening));
178
+ // 이름표는 '무엇인지' 를 적어야 한다. 어느 런타임인지 못 알아보면
179
+ // 규격만이라도 적어 준다 — '열려 있는 자리' 는 사용자에게 아무 정보가 아니다.
180
+ for (const p of 볼것) list.push({ port: p, hint: 'OpenAI 호환 서버' });
181
+ }
182
+
183
+ const jobs = list.map(async ({ port, hint }) => {
184
+ const origin = `http://${host}:${port}`;
185
+ // 훑는 동안만 그 자리를 연다. 끝나면 바로 닫아서 자물쇠를 원래대로 둔다.
186
+ const close = allowTemporarily(origin);
187
+ let hit;
188
+ let runtime;
189
+ try {
190
+ // 먼저 'HTTP 로 말은 하는가' 만 짧게 본다.
191
+ //
192
+ // 이게 없으면 열려 있지만 HTTP 가 아닌 자리(파일 공유, RPC 같은)에서
193
+ // 요청마다 시간 초과를 기다리게 된다. 자리 하나에 여러 길을 보므로
194
+ // 그 기다림이 곱해져서, 훑기가 몇 초가 아니라 몇 분이 된다.
195
+ //
196
+ // 상태 0 은 '아무 답도 못 받았다' 는 뜻이다. 연결이 거절됐거나,
197
+ // 열려 있어도 HTTP 로 답하지 않는 자리다. 둘 다 여기서 접는다.
198
+ const 인사 = await req(`${origin}/`, { timeout: Math.min(timeout, 800) });
199
+ if (인사.status === 0) return null;
200
+
201
+ hit = await probeOllama(origin, timeout);
202
+ if (!hit) hit = await probeOpenAI(origin, timeout, key);
203
+ if (!hit) return null;
204
+ runtime = await fingerprint(origin, hit, timeout);
205
+ } finally { close(); }
206
+ const found = {
207
+ ...hit,
208
+ port,
209
+ host,
210
+ runtime: runtime ?? hint,
211
+ guessed: !runtime && hit.kind !== 'ollama',
212
+ hint,
213
+ };
214
+ onFind?.(found);
215
+ return found;
216
+ });
217
+
218
+ const found = (await Promise.all(jobs)).filter(Boolean);
219
+ // 모델이 많은 쪽을 위로. 잠긴 것은 아래로.
220
+ found.sort((a, b) => (a.locked ? 1 : 0) - (b.locked ? 1 : 0) || b.models.length - a.models.length || a.port - b.port);
221
+ // 몇 자리를 안 봤는지 알려 준다. 조용히 자르면 '다 봤다' 로 읽힌다.
222
+ found.훑은자리 = list.length;
223
+ found.안본자리 = 잘린것;
224
+ return found;
225
+ }
226
+
227
+ // 찾은 것을 설정 프로필 모양으로 바꾼다. 같은 자리가 이미 있으면 그걸 되쓴다.
228
+ export function toProfiles(found, existing = []) {
229
+ const out = [];
230
+ for (const f of found) {
231
+ for (const m of f.models.length ? f.models : [{ id: null }]) {
232
+ if (!m.id) continue;
233
+ const id = `${slugRuntime(f.runtime)}-${String(m.id).replace(/[^a-zA-Z0-9._-]+/g, '-')}`.slice(0, 60).toLowerCase();
234
+ const prev = existing.find((p) => p.baseUrl === f.base && p.model === m.id);
235
+ out.push({
236
+ id: prev?.id ?? id,
237
+ name: `${f.runtime} · ${m.id}`,
238
+ kind: f.kind,
239
+ baseUrl: f.base,
240
+ auth: f.auth ?? 'none',
241
+ model: m.id,
242
+ apiKey: prev?.apiKey ?? '',
243
+ ctx: prev?.ctx ?? null,
244
+ streaming: prev?.streaming ?? true,
245
+ tools: prev?.tools ?? false,
246
+ json: prev?.json ?? false,
247
+ think: prev?.think ?? false,
248
+ note: m.note ?? '',
249
+ local: true,
250
+ });
251
+ }
252
+ }
253
+ return out;
254
+ }
255
+
256
+ const slugRuntime = (r) => String(r ?? 'local').split(/[\s·]+/)[0].toLowerCase().replace(/[^a-z0-9]+/g, '');
@@ -0,0 +1,147 @@
1
+ // deel scan — 이 PC 의 로컬 모델 서버를 훑어 보여 주고, 원하면 전부 등록한다.
2
+ import { c, say, rule, pad, mark, clip, width } from '../ui/ansi.js';
3
+ import { pick } from '../ui/prompt.js';
4
+ import { spin } from '../ui/spinner.js';
5
+ import { scanLocal, toProfiles, KNOWN } from './scan.js';
6
+ import { load, save, upsert } from '../config.js';
7
+
8
+ // 코딩 에이전트로 쓰려면 도구 호출이 되어야 한다. 진단 전이라 확실히는 모르니,
9
+ // 이름에 드러난 단서로 짐작해서 권한다. 틀릴 수 있다는 것을 문구에 밝힌다.
10
+ const 도구잘함 = /(qwen|llama-?3|llama3|mistral|devstral|hermes|command-?r|firefunction|granite|gpt-?oss|glm|kimi|minimax|seed-?oss)/i;
11
+ const 코딩 = /(cod(e|er|ing)|devstral|starcoder|deepseek|qwen.*cod|granite.*cod)/i;
12
+ const 작음 = /(0\.5b|1\.5b|1b|2b|3b|tiny|mini|small)/i;
13
+
14
+ function recommend(found) {
15
+ const all = [];
16
+ for (const f of found) for (const m of f.models) all.push({ ...f, model: m.id, note: m.note ?? '' });
17
+ if (!all.length) return null;
18
+
19
+ const 점수 = (x) => {
20
+ let s = 0;
21
+ if (코딩.test(x.model)) s += 3;
22
+ if (도구잘함.test(x.model)) s += 2;
23
+ if (!작음.test(x.model)) s += 1; // 너무 작으면 도구 호출을 잘 못 한다
24
+ if (x.kind === 'ollama') s += 1; // 규격이 확실히 확인된 쪽
25
+ return s;
26
+ };
27
+ const best = all.map((x) => ({ x, s: 점수(x) })).sort((a, b) => b.s - a.s)[0];
28
+ if (!best || best.s < 2) return null;
29
+
30
+ const 이유 = [];
31
+ if (코딩.test(best.x.model)) 이유.push('코딩용 모델');
32
+ if (도구잘함.test(best.x.model)) 이유.push('도구 호출을 잘하는 계열');
33
+ if (작음.test(best.x.model)) 이유.push(c.yellow('다만 작은 모델이라 도구 호출이 불안할 수 있습니다'));
34
+ 이유.push(c.gray('실제로 되는지는 deel diagnose 로 확인하세요'));
35
+ return { ...best.x, why: 이유.join(' · ') };
36
+ }
37
+
38
+ export async function runScan(flags = {}) {
39
+ const host = flags.host ? String(flags.host) : '127.0.0.1';
40
+ const ports = String(flags.ports ?? '')
41
+ .split(/[,\s]+/).map((x) => parseInt(x, 10)).filter(Boolean);
42
+ const timeout = flags.timeout ? parseInt(String(flags.timeout), 10) : 1200;
43
+
44
+ say('');
45
+ rule('로컬 모델 서버 훑기', 74);
46
+ say(` ${c.gray(`${host} 의 알려진 자리 ${KNOWN.length + ports.length}곳을 두드립니다. 바깥으로는 나가지 않습니다.`)}`);
47
+ say('');
48
+
49
+ const s = spin('찾는 중…');
50
+ const found = await scanLocal({ host, ports, timeout, key: flags.key ? String(flags.key) : '' });
51
+ s.stop(` ${found.length ? mark.ok : mark.warn} ${found.length}곳 찾음`);
52
+ say('');
53
+
54
+ if (!found.length) {
55
+ say(` ${c.gray('떠 있는 로컬 서버가 없습니다.')}`);
56
+ say('');
57
+ say(` ${c.gray('확인해 볼 것')}`);
58
+ say(` ${c.gray('· Ollama 를 켰나요?')} ${c.cyan('ollama serve')}`);
59
+ say(` ${c.gray('· LM Studio 의 로컬 서버를 켰나요?')} ${c.gray('(Developer → Start Server)')}`);
60
+ say(` ${c.gray('· 다른 포트를 쓴다면')} ${c.cyan('deel scan --ports 9000,9100')}`);
61
+ say('');
62
+ return 0;
63
+ }
64
+
65
+ // ── 찾은 것 표로 ─────────────────────────────────────────────────────
66
+ const wRun = Math.max(10, ...found.map((f) => width(f.runtime)));
67
+ for (const f of found) {
68
+ const 자리 = `${f.host}:${f.port}`;
69
+ const 규격 = f.kind === 'ollama' ? 'Ollama 규격' : 'OpenAI 호환';
70
+ const 표시 = f.guessed ? c.gray(' (추정)') : '';
71
+ say(` ${c.hcyan('◆')} ${c.bold(pad(f.runtime, wRun))}${표시} ${c.gray(pad(자리, 22))}${c.gray(pad(규격, 14))}${c.gray(f.ms + 'ms')}`);
72
+ if (f.locked) {
73
+ say(` ${c.yellow('키가 필요합니다')} ${c.gray('— deel scan --key <키> 로 다시 훑어 보세요')}`);
74
+ continue;
75
+ }
76
+ if (!f.models.length) {
77
+ say(` ${c.gray('모델이 하나도 없습니다 — 런타임에서 먼저 모델을 받아 두세요')}`);
78
+ continue;
79
+ }
80
+ for (const m of f.models.slice(0, 12)) {
81
+ say(` ${c.gray('·')} ${pad(clip(m.id, 40), 42)}${c.gray(m.note ?? '')}`);
82
+ }
83
+ if (f.models.length > 12) say(` ${c.gray(`… 그 밖에 ${f.models.length - 12}개`)}`);
84
+ }
85
+ say('');
86
+
87
+ const 모델수 = found.reduce((n, f) => n + f.models.length, 0);
88
+ say(` ${c.gray('합계')} 서버 ${c.bold(String(found.length))}곳 · 모델 ${c.bold(String(모델수))}개`);
89
+ say('');
90
+
91
+ // ── 추천 ─────────────────────────────────────────────────────────────
92
+ // 도구 호출이 되는 모델이라야 코딩 에이전트로 쓸 수 있다. 이름으로 짐작해 권한다.
93
+ const 추천 = recommend(found);
94
+ if (추천) {
95
+ say(` ${c.hgreen('추천')} ${c.bold(추천.runtime)} ${c.gray('·')} ${c.bold(추천.model)}`);
96
+ say(` ${c.gray(추천.why)}`);
97
+ say('');
98
+ }
99
+
100
+ // ── 등록 ─────────────────────────────────────────────────────────────
101
+ if (!flags.save && !flags.pick) {
102
+ say(` ${c.gray('전부 등록하려면')} ${c.cyan('deel scan --save')}`);
103
+ say(` ${c.gray('골라서 등록하려면')} ${c.cyan('deel scan --pick')}`);
104
+ say(` ${c.gray('등록 뒤에는 대화 중')} ${c.cyan('/model')} ${c.gray('로 바꿔 씁니다.')}`);
105
+ say('');
106
+ return 0;
107
+ }
108
+
109
+ const cfg = load();
110
+ let profiles = toProfiles(found, cfg.profiles);
111
+
112
+ // 골라 담기 — 사용자가 쓸 것만 등록한다.
113
+ if (flags.pick && profiles.length > 1) {
114
+ const items = profiles.map((p) => ({
115
+ label: `${pad(clip(p.name, 44), 46)}${c.gray(p.note ?? '')}`,
116
+ note: 추천 && p.model === 추천.model && p.baseUrl.includes(String(추천.port)) ? '추천' : '',
117
+ }));
118
+ const i = await pick('어느 것을 쓰시겠습니까', items, {
119
+ def: Math.max(0, profiles.findIndex((p) => 추천 && p.model === 추천.model)),
120
+ });
121
+ profiles = [profiles[i]];
122
+ cfg.active = null; // 고른 것을 지금 쓰는 것으로
123
+ }
124
+ if (!profiles.length) {
125
+ say(` ${mark.warn} 등록할 모델이 없습니다.`);
126
+ say('');
127
+ return 1;
128
+ }
129
+ let 새로 = 0;
130
+ for (const p of profiles) {
131
+ if (!cfg.profiles.some((x) => x.baseUrl === p.baseUrl && x.model === p.model)) 새로++;
132
+ upsert(cfg, p);
133
+ }
134
+ if (!cfg.active) cfg.active = profiles[0].id;
135
+ const at = save(cfg);
136
+
137
+ say(` ${mark.ok} ${profiles.length}개 등록 ${c.gray(`(새로 ${새로}개)`)}`);
138
+ say(` ${c.gray(at)}`);
139
+ say('');
140
+ say(` ${c.gray('지금 쓰는 것')} ${c.bold(cfg.profiles.find((x) => x.id === cfg.active)?.name ?? cfg.active)}`);
141
+ say(` ${c.gray('바꾸려면 대화 중')} ${c.cyan('/model')}`);
142
+ say('');
143
+ say(` ${c.gray('도구 호출·스트리밍이 되는지는 아직 확인 전입니다.')}`);
144
+ say(` ${c.gray('쓸 모델을 고른 뒤')} ${c.cyan('deel diagnose')} ${c.gray('를 한 번 돌리세요.')}`);
145
+ say('');
146
+ return 0;
147
+ }