cli-surf 0.11.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.
package/cli.js ADDED
@@ -0,0 +1,1239 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Surf / Opus CLI — терминальный клиент мессенджера.
4
+ *
5
+ * Ключевая идея совмещённых лимитов: CLI НЕ имеет своей квоты.
6
+ * Он логинится тем же пользователем (тот же Bearer-токен / user_id)
7
+ * и ходит в те же эндпоинты, что и веб-мессенджер:
8
+ * GET /api/ai/usage — общий месячный бюджет (ai_token_usage)
9
+ * POST /api/ai/process — чат, расходует тот же бюджет через recordAiTokenUsage
10
+ * Поэтому кредиты, потраченные в терминале, видны в мессенджере, и наоборот.
11
+ */
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import readline from 'node:readline/promises';
16
+ import { stdin as input, stdout as output } from 'node:process';
17
+ import { normalizeOpusModelId, opusProviderModel } from './opusModels.js';
18
+ import { Spinner, MdStream, usageCard, chatBanner, answerFooter, assistantHead, completer, chatHelp, paint, OPUS_MODEL_IDS, statusLabel, selectArrows, selectNumbered, canUseArrows, modelDisplayName, PICKABLE_MODEL_IDS, normModelKey, setAccentColor, defaultAccentHex, setCliLanguage, tr, isRussian, } from './cliUi.js';
19
+ const VERSION = '0.11.0';
20
+ const DEFAULT_SERVER = 'http://localhost:3001';
21
+ const CONFIG_DIR = path.join(os.homedir(), '.surf-cli');
22
+ const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
23
+ const DEFAULT_PERMISSION_MODE = 'important';
24
+ function loadConfig() {
25
+ try {
26
+ const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
27
+ const parsed = JSON.parse(raw);
28
+ return {
29
+ server: typeof parsed.server === 'string' && parsed.server ? parsed.server : DEFAULT_SERVER,
30
+ token: typeof parsed.token === 'string' && parsed.token ? parsed.token : null,
31
+ accent: typeof parsed.accent === 'string' && parsed.accent ? parsed.accent : null,
32
+ language: ['en', 'English'].includes(String(parsed.language)) ? 'en' : 'ru',
33
+ model: typeof parsed.model === 'string' && parsed.model ? parsed.model : null,
34
+ permissionMode: parsed.permissionMode === 'always' || parsed.permissionMode === 'never'
35
+ ? parsed.permissionMode
36
+ : DEFAULT_PERMISSION_MODE,
37
+ };
38
+ }
39
+ catch {
40
+ return { server: DEFAULT_SERVER, token: null, accent: null, language: 'ru', model: null, permissionMode: DEFAULT_PERMISSION_MODE };
41
+ }
42
+ }
43
+ function saveConfig(cfg) {
44
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
45
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), { mode: 0o600 });
46
+ }
47
+ /**
48
+ * Акцент интерфейса — как в мессенджере: берём accentColor из профиля
49
+ * (там же, где его меняет пользователь), иначе дефолт мессенджера.
50
+ * Кэшируем в конфиге, чтобы цвета были свои с первого кадра.
51
+ */
52
+ function applyAccentFromMe(me) {
53
+ const settings = me?.uiSettings && typeof me.uiSettings === 'object'
54
+ ? me.uiSettings
55
+ : {};
56
+ const raw = settings.accentColor;
57
+ const hex = typeof raw === 'string' && raw.trim() ? raw.trim() : defaultAccentHex();
58
+ setAccentColor(hex);
59
+ const language = settings.language === 'en' || settings.language === 'English' ? 'en' : 'ru';
60
+ setCliLanguage(language);
61
+ const cfg = loadConfig();
62
+ if (cfg.accent !== hex || cfg.language !== language) {
63
+ cfg.accent = hex;
64
+ cfg.language = language;
65
+ saveConfig(cfg);
66
+ }
67
+ }
68
+ /** Применить закэшированные настройки мессенджера до любых запросов. */
69
+ function loadSavedAccent() {
70
+ const { accent, language } = loadConfig();
71
+ if (accent)
72
+ setAccentColor(accent);
73
+ setCliLanguage(language);
74
+ }
75
+ function resolveServer(flagServer) {
76
+ const fromFlag = flagServer?.trim();
77
+ if (fromFlag)
78
+ return fromFlag.replace(/\/+$/, '');
79
+ const fromEnv = process.env.SURF_SERVER?.trim() || process.env.SURF_API?.trim();
80
+ if (fromEnv)
81
+ return fromEnv.replace(/\/+$/, '');
82
+ return loadConfig().server.replace(/\/+$/, '');
83
+ }
84
+ function getArgValue(args, ...names) {
85
+ for (let i = 0; i < args.length; i += 1) {
86
+ for (const name of names) {
87
+ if (args[i] === name && i + 1 < args.length)
88
+ return args[i + 1];
89
+ if (args[i].startsWith(`${name}=`))
90
+ return args[i].slice(name.length + 1);
91
+ }
92
+ }
93
+ return undefined;
94
+ }
95
+ function hasFlag(args, ...names) {
96
+ return args.some(a => names.includes(a));
97
+ }
98
+ function resolveUrl(server, p) {
99
+ if (/^https?:\/\//i.test(p))
100
+ return p;
101
+ return `${server}${p.startsWith('/') ? p : `/${p}`}`;
102
+ }
103
+ async function readPassword(prompt) {
104
+ const stdin = process.stdin;
105
+ if (!stdin.isTTY) {
106
+ // Пароль piped через stdin (скрипты): скрыть ввод нельзя, читаем строку.
107
+ const rl = readline.createInterface({ input: stdin, output });
108
+ const answer = (await rl.question(prompt)).trim();
109
+ rl.close();
110
+ return answer;
111
+ }
112
+ // Скрытый ввод пароля без внешних зависимостей.
113
+ return new Promise(resolve => {
114
+ process.stdout.write(prompt);
115
+ let value = '';
116
+ const wasRaw = stdin.isRaw;
117
+ if (stdin.isTTY)
118
+ stdin.setRawMode(true);
119
+ stdin.resume();
120
+ const onData = (buf) => {
121
+ const ch = buf.toString('utf8');
122
+ if (ch === '\r' || ch === '\n' || ch === '\u0004') {
123
+ if (stdin.isTTY)
124
+ stdin.setRawMode(!!wasRaw);
125
+ stdin.pause();
126
+ stdin.removeListener('data', onData);
127
+ process.stdout.write('\n');
128
+ resolve(value);
129
+ }
130
+ else if (ch === '\u0003') {
131
+ process.stdout.write('\n');
132
+ process.exit(130);
133
+ }
134
+ else if (ch === '\u007f' || ch === '\b') {
135
+ value = value.slice(0, -1);
136
+ }
137
+ else {
138
+ value += ch;
139
+ }
140
+ };
141
+ stdin.on('data', onData);
142
+ });
143
+ }
144
+ async function api(server, token, method, p, body) {
145
+ const res = await fetch(resolveUrl(server, p), {
146
+ method,
147
+ headers: {
148
+ 'Content-Type': 'application/json',
149
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
150
+ },
151
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
152
+ });
153
+ const text = await res.text();
154
+ let payload;
155
+ try {
156
+ payload = text ? JSON.parse(text) : {};
157
+ }
158
+ catch {
159
+ payload = { error: text };
160
+ }
161
+ if (!res.ok) {
162
+ const err = new Error(payload.error || `HTTP ${res.status}`);
163
+ err.status = res.status;
164
+ err.payload = payload;
165
+ throw err;
166
+ }
167
+ return payload;
168
+ }
169
+ function printUsage(u) {
170
+ console.log(usageCard(u));
171
+ }
172
+ function printHelp() {
173
+ console.log(isRussian() ? `Surf Opus CLI v${VERSION} — терминал поверх тех же лимитов, что и мессенджер.
174
+ Расход один на всех: CLI ходит в /api/ai/process тем же user_id, траты пишет
175
+ в общую таблицу ai_token_usage. Что потратил в терминале — видно в мессенджере.
176
+
177
+ Использование:
178
+ surf Чат (если не залогинен — вход через браузер)
179
+ surf login [--server URL] [--password] Войти через браузер (с --password — пароль в терминале)
180
+ surf token <jwt> [--server URL] Вставить готовый токен из мессенджера
181
+ surf logout Удалить сохранённый токен
182
+ surf whoami [--server URL] Текущий пользователь
183
+ surf usage [--server URL] [--json] Общий лимит/остаток (тот же экран, что в мессенджере)
184
+ surf models Доступные модели Opus
185
+ surf ask <текст...> [опции] Один вопрос и ответ
186
+ surf chat [опции] Интерактивный чат (стриминг, история)
187
+ surf get <url...> [--out путь] [--open] Скачать вложения Opus (нужен тот же токен)
188
+
189
+ Опции ask/chat:
190
+ --server URL Адрес API (или env SURF_SERVER, по умолчанию ${DEFAULT_SERVER})
191
+ --model <название> GPT-5 Mini, o3, Gemini… (по умолчанию auto)
192
+ --no-stream Не стримить, дождаться полного ответа
193
+ --attach <path> Прикрепить файл к вопросу (ask; в chat — команда /attach)
194
+ --chat-id <N> Писать в чат мессенджера N (по умолчанию без сохранения — расход лимита тот же)
195
+ --json Вывести сырой JSON ответа
196
+ --yes, -y Не спрашивать разрешений в этом запуске
197
+
198
+ Опции get:
199
+ --out <путь> Куда сохранить (файл или папка, по умолчанию текущая папка)
200
+ --open Открыть скачанное в программе по умолчанию
201
+
202
+ Команды внутри chat:
203
+ /usage, /model [id], /permissions, /clear, /help, /logout, /exit, ! — задача с файлами
204
+ ` : `Surf Opus CLI v${VERSION} — terminal access to the same limits as the messenger.
205
+ Usage is shared: the CLI uses /api/ai/process under the same user_id and writes
206
+ to the same ai_token_usage table. What is spent in the terminal is visible in the messenger.
207
+
208
+ Usage:
209
+ surf Chat (opens browser sign-in when needed)
210
+ surf login [--server URL] [--password] Sign in in a browser (--password uses the terminal)
211
+ surf token <jwt> [--server URL] Save a messenger token
212
+ surf logout Remove the saved token
213
+ surf whoami [--server URL] Show the current user
214
+ surf usage [--server URL] [--json] Shared usage/remaining limit
215
+ surf models Available Opus models
216
+ surf ask <text...> [options] Ask one question
217
+ surf chat [options] Interactive chat (streaming, history)
218
+ surf get <url...> [--out path] [--open] Download Opus attachments
219
+
220
+ ask/chat options:
221
+ --server URL API address (or SURF_SERVER; default ${DEFAULT_SERVER})
222
+ --model <name> GPT-5 Mini, o3, Gemini… (default: auto)
223
+ --no-stream Wait for the complete response
224
+ --attach <path> Attach a file (use /attach in chat)
225
+ --chat-id <N> Write to messenger chat N (without persistence by default)
226
+ --json Print the raw JSON response
227
+ --yes, -y Do not ask for agent permissions for this run
228
+
229
+ get options:
230
+ --out <path> Output file or folder (default: current folder)
231
+ --open Open with the default application
232
+
233
+ Chat commands:
234
+ /usage, /model [id], /permissions, /clear, /help, /logout, /exit, ! — task with files
235
+ `);
236
+ }
237
+ function printModels() {
238
+ console.log(paint(tr('Модели Opus (лимит общий для всех моделей):', 'Opus models (the limit is shared by all models):'), 'gray'));
239
+ for (const id of OPUS_MODEL_IDS) {
240
+ const normalized = normalizeOpusModelId(id);
241
+ if (id === 'auto') {
242
+ console.log(` ${paint('Auto', 'white')} ${paint(tr('(auto) — сама подбирает, по умолчанию', '(auto) — chooses automatically; default'), 'gray')} ${paint(`→ ${opusProviderModel(normalized)}`, 'gray')}`);
243
+ continue;
244
+ }
245
+ console.log(` ${paint(modelDisplayName(id), 'white')} ${paint(`(${id})`, 'gray')} ${paint(`→ ${opusProviderModel(normalized)}`, 'gray')}`);
246
+ }
247
+ console.log(paint(tr('\nfree-план всегда отвечает дешёвой моделью; pro выбирает любую.', '\nThe free plan always uses an economy model; Pro can choose any.'), 'gray'));
248
+ console.log(paint(tr('При исчерпании месячного лимита включается cheap-mode (GPT-5 Nano).', 'When the monthly limit is exhausted, cheap mode (GPT-5 Nano) is enabled.'), 'gray'));
249
+ }
250
+ async function cmdLogin(args) {
251
+ const server = resolveServer(getArgValue(args, '--server'));
252
+ // По умолчанию — вход через браузер (пароль в терминале не нужен).
253
+ // Старый способ остался для скриптов: surf login --password [email].
254
+ if (!hasFlag(args, '--password', '-p')) {
255
+ await startDeviceFlow(server);
256
+ return;
257
+ }
258
+ const positional = args.filter(a => !a.startsWith('--'));
259
+ let identifier = positional[0];
260
+ if (!identifier) {
261
+ const rl = readline.createInterface({ input, output });
262
+ identifier = (await rl.question(tr('Email или username: ', 'Email or username: '))).trim();
263
+ rl.close();
264
+ }
265
+ if (!identifier)
266
+ throw new Error(tr('Нужен email или username', 'Email or username is required'));
267
+ const password = await readPassword(tr('Пароль: ', 'Password: '));
268
+ if (!password)
269
+ throw new Error(tr('Нужен пароль', 'Password is required'));
270
+ const data = await api(server, null, 'POST', '/api/auth/login', { identifier, password });
271
+ const cfg = loadConfig();
272
+ cfg.server = server;
273
+ cfg.token = data.token;
274
+ saveConfig(cfg);
275
+ console.log(tr(`Вошли на ${server}. Токен сохранён в ${CONFIG_PATH}.`, `Signed in to ${server}. Token saved to ${CONFIG_PATH}.`));
276
+ const me = await api(server, data.token, 'GET', '/api/users/me');
277
+ applyAccentFromMe(me);
278
+ console.log(tr(`Пользователь: ${me.name} <${me.email}>${me.isPro ? ' [PRO]' : ''}`, `User: ${me.name} <${me.email}>${me.isPro ? ' [PRO]' : ''}`));
279
+ }
280
+ /**
281
+ * Вход через браузер (device-flow): терминал показывает ссылку, человек
282
+ * подтверждает на странице /cli-login (там же входит в мессенджер, если
283
+ * нужно), терминал забирает обычный токен пользователя polling'ом.
284
+ */
285
+ async function startDeviceFlow(server) {
286
+ const challenge = await api(server, null, 'POST', '/api/cli/device');
287
+ console.log(tr('\nДля входа откройте в браузере:', '\nOpen this link in your browser to sign in:'));
288
+ console.log(` ${challenge.verification_url}`);
289
+ console.log(tr(`Код должен совпасть с терминалом: ${challenge.user_code}`, `The code must match the terminal: ${challenge.user_code}`));
290
+ try {
291
+ await openWithDefaultApp(challenge.verification_url);
292
+ }
293
+ catch { /* нет GUI — ссылку уже показали */ }
294
+ console.log(tr('Ожидаю подтверждения в браузере… (Ctrl+C — отмена)', 'Waiting for browser confirmation… (Ctrl+C — cancel)'));
295
+ const deadline = Date.now() + challenge.expires_in * 1000;
296
+ const intervalMs = Math.max(1000, (challenge.poll_interval || 3) * 1000);
297
+ for (;;) {
298
+ await new Promise(r => setTimeout(r, intervalMs));
299
+ const poll = await api(server, null, 'GET', `/api/cli/device?device_code=${challenge.device_code}`).then((data) => ({ ok: true, data: data }), (err) => ({ ok: false, status: err.status }));
300
+ if (!poll.ok) {
301
+ if (poll.status === 410)
302
+ throw new Error(tr('Время вышло. Запустите surf заново.', 'Timed out. Start surf again.'));
303
+ throw new Error(tr('Не удалось связаться с сервером', 'Could not reach the server'));
304
+ }
305
+ const status = poll.data;
306
+ if (status.status === 'approved' && status.token) {
307
+ const cfg = loadConfig();
308
+ cfg.server = server;
309
+ cfg.token = status.token;
310
+ saveConfig(cfg);
311
+ const me = await api(server, status.token, 'GET', '/api/users/me');
312
+ applyAccentFromMe(me);
313
+ console.log(tr(`\nВошли как ${me.name} <${me.email}>${me.isPro ? ' [PRO]' : ''}.`, `\nSigned in as ${me.name} <${me.email}>${me.isPro ? ' [PRO]' : ''}.`));
314
+ return status.token;
315
+ }
316
+ if (Date.now() >= deadline)
317
+ throw new Error(tr('Время вышло. Запустите surf заново.', 'Timed out. Start surf again.'));
318
+ }
319
+ }
320
+ /**
321
+ * Гарантирует токен: проверяет сохранённый, иначе (в интерактивном терминале)
322
+ * предлагает войти через браузер. В скриптах без TTY — сразу ошибка.
323
+ */
324
+ async function ensureToken(server) {
325
+ const saved = loadConfig().token;
326
+ if (saved) {
327
+ const check = await api(server, saved, 'GET', '/api/users/me').then((data) => ({ ok: true, data }), (err) => ({ ok: false, status: err.status }));
328
+ if (check.ok) {
329
+ applyAccentFromMe(check.data);
330
+ return saved;
331
+ }
332
+ if (check.status !== 401) {
333
+ throw new Error(tr('Не удалось связаться с сервером для проверки токена', 'Could not reach the server to verify the token'));
334
+ }
335
+ // Токен протух — идём на вход заново.
336
+ }
337
+ if (!process.stdin.isTTY) {
338
+ throw new Error(tr('Нет действующего токена. Выполните: surf login', 'No valid token. Run: surf login'));
339
+ }
340
+ const rl = readline.createInterface({ input, output });
341
+ const answer = (await rl.question(tr('Нажмите Enter для входа через браузер (Log in) или q для выхода: ', 'Press Enter to sign in through a browser, or q to exit: '))).trim().toLowerCase();
342
+ rl.close();
343
+ if (answer === 'q' || answer === 'й' || answer === 'n' || answer === 'т' || answer === 'нет' || answer === 'выход') {
344
+ throw new Error(tr('Вход отменён.', 'Sign-in cancelled.'));
345
+ }
346
+ return startDeviceFlow(server);
347
+ }
348
+ async function cmdToken(args) {
349
+ const server = resolveServer(getArgValue(args, '--server'));
350
+ const positional = args.filter(a => !a.startsWith('--'));
351
+ const jwt = positional[0];
352
+ if (!jwt)
353
+ throw new Error(tr('Использование: surf token <jwt> [--server URL]', 'Usage: surf token <jwt> [--server URL]'));
354
+ // Проверяем токен сразу, чтобы не сохранять битый.
355
+ const checked = await api(server, jwt, 'GET', '/api/users/me');
356
+ applyAccentFromMe(checked);
357
+ const cfg = loadConfig();
358
+ cfg.server = server;
359
+ cfg.token = jwt;
360
+ saveConfig(cfg);
361
+ console.log(tr(`Токен сохранён (${CONFIG_PATH}).`, `Token saved (${CONFIG_PATH}).`));
362
+ }
363
+ function cmdLogout() {
364
+ const cfg = loadConfig();
365
+ cfg.token = null;
366
+ cfg.accent = null;
367
+ saveConfig(cfg);
368
+ setAccentColor(null);
369
+ console.log(tr('Вышли: токен удалён.', 'Signed out: token removed.'));
370
+ }
371
+ async function cmdWhoami(args) {
372
+ const server = resolveServer(getArgValue(args, '--server'));
373
+ const token = await ensureToken(server);
374
+ const me = await api(server, token, 'GET', '/api/users/me');
375
+ applyAccentFromMe(me);
376
+ console.log(`${me.name} ${me.surname || ''} (@${me.username}, id ${me.id}, ${me.email})${me.isPro ? ' [PRO]' : ''}`);
377
+ }
378
+ async function cmdUsage(args) {
379
+ const server = resolveServer(getArgValue(args, '--server'));
380
+ const token = await ensureToken(server);
381
+ const u = await api(server, token, 'GET', '/api/ai/usage');
382
+ if (hasFlag(args, '--json'))
383
+ console.log(JSON.stringify(u, null, 2));
384
+ else
385
+ printUsage(u);
386
+ }
387
+ async function uploadAttachment(server, token, filePath) {
388
+ const stat = fs.statSync(filePath);
389
+ if (!stat.isFile())
390
+ throw new Error(tr(`Не файл: ${filePath}`, `Not a file: ${filePath}`));
391
+ if (stat.size > 25 * 1024 * 1024)
392
+ throw new Error(tr('Файл больше 25 МБ', 'File is larger than 25 MB'));
393
+ const buffer = fs.readFileSync(filePath);
394
+ const form = new FormData();
395
+ form.append('file', new Blob([buffer]), path.basename(filePath));
396
+ const res = await fetch(resolveUrl(server, '/api/upload/file'), {
397
+ method: 'POST',
398
+ headers: { Authorization: `Bearer ${token}` },
399
+ body: form,
400
+ });
401
+ if (!res.ok)
402
+ throw new Error(tr(`Загрузка не удалась: HTTP ${res.status} ${(await res.text()).slice(0, 200)}`, `Upload failed: HTTP ${res.status} ${(await res.text()).slice(0, 200)}`));
403
+ const data = await res.json();
404
+ return { url: data.url, type: data.type, name: path.basename(filePath) };
405
+ }
406
+ /** Загрузка инлайн-вложений из строки (токены [image N]/[file N]). */
407
+ async function uploadInlineAttachments(server, token, atts, live) {
408
+ const out = [];
409
+ for (const a of atts) {
410
+ if (!live.has(a.token))
411
+ continue;
412
+ try {
413
+ const up = await uploadAttachment(server, token, a.path);
414
+ out.push({ ...up, token: a.token });
415
+ console.log(`${paint(tr('Прикреплено:', 'Attached:'), 'green')} ${paint(up.name, 'white')}`);
416
+ }
417
+ catch (err) {
418
+ console.error(`${paint(tr('Не удалось прикрепить:', 'Could not attach:'), 'red')} ${a.path}: ${err.message}`);
419
+ }
420
+ }
421
+ return out;
422
+ }
423
+ async function postProcessStream(server, token, body, handlers, stream) {
424
+ const res = await fetch(resolveUrl(server, '/api/ai/process'), {
425
+ method: 'POST',
426
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
427
+ body: JSON.stringify(stream ? { ...body, stream: true } : body),
428
+ });
429
+ if (!res.ok) {
430
+ const payload = await res.json().catch(() => ({}));
431
+ const err = new Error(payload.error || `HTTP ${res.status}`);
432
+ err.status = res.status;
433
+ err.payload = payload;
434
+ throw err;
435
+ }
436
+ const contentType = res.headers.get('content-type') || '';
437
+ if (!stream || !contentType.includes('text/event-stream') || !res.body) {
438
+ return res.json();
439
+ }
440
+ const reader = res.body.getReader();
441
+ const decoder = new TextDecoder();
442
+ let carry = '';
443
+ let done = null;
444
+ const handleBlock = (block) => {
445
+ let event = 'message';
446
+ const lines = [];
447
+ for (const raw of block.split('\n')) {
448
+ const line = raw.replace(/\r$/, '');
449
+ if (line.startsWith('event:'))
450
+ event = line.slice(6).trim();
451
+ else if (line.startsWith('data:'))
452
+ lines.push(line.slice(5).trimStart());
453
+ }
454
+ if (!lines.length)
455
+ return;
456
+ const data = JSON.parse(lines.join('\n'));
457
+ if (event === 'delta' && data.text)
458
+ handlers.onDelta?.(data.text);
459
+ else if (event === 'status' && data.status)
460
+ handlers.onStatus?.(data.status);
461
+ else if (event === 'reset')
462
+ handlers.onReset?.();
463
+ else if (event === 'done')
464
+ done = data;
465
+ else if (event === 'error')
466
+ throw new Error(data.error || 'stream error');
467
+ };
468
+ for (;;) {
469
+ const { done: finished, value } = await reader.read();
470
+ if (finished)
471
+ break;
472
+ carry += decoder.decode(value, { stream: true });
473
+ const blocks = carry.split('\n\n');
474
+ carry = blocks.pop() || '';
475
+ for (const b of blocks)
476
+ handleBlock(b);
477
+ }
478
+ if (carry.trim())
479
+ handleBlock(carry);
480
+ if (!done)
481
+ throw new Error(tr('Стрим оборвался без done', 'The stream ended without done'));
482
+ return done;
483
+ }
484
+ function printRichPayload(payload, server) {
485
+ const webSearch = payload.webSearch;
486
+ if (webSearch?.sources?.length) {
487
+ console.log(`\n${paint(tr('— Источники веб-поиска:', '— Web search sources:'), 'accent')}`);
488
+ for (const s of webSearch.sources.slice(0, 8))
489
+ console.log(` ${paint('•', 'accent')} ${s.title} — ${paint(s.url, 'blue')}`);
490
+ }
491
+ const images = payload.images;
492
+ if (images?.length) {
493
+ console.log(`\n${paint(tr('— Картинки:', '— Images:'), 'accent')}`);
494
+ for (const img of images)
495
+ console.log(` ${paint('•', 'accent')} ${paint(resolveUrl(server, img.url), 'blue')}${img.alt ? paint(` (${img.alt})`, 'gray') : ''}`);
496
+ }
497
+ const attachments = payload.attachments;
498
+ if (attachments?.length) {
499
+ console.log(`\n${paint(tr('— Файлы:', '— Files:'), 'accent')}`);
500
+ for (const f of attachments)
501
+ console.log(` ${paint('•', 'accent')} ${paint(f.name, 'white')}: ${paint(resolveUrl(server, f.url), 'blue')}`);
502
+ console.log(paint(tr('Скачать: surf get <url> [--open]', 'Download: surf get <url> [--open]'), 'gray'));
503
+ }
504
+ if (payload.map)
505
+ console.log(`\n${paint(tr('— К ответу приложена интерактивная карта (смотри в мессенджере).', '— An interactive map is attached to the reply (view it in the messenger).'), 'gray')}`);
506
+ }
507
+ function handleProcessError(err) {
508
+ const e = err;
509
+ if (e?.status === 429 && e.payload?.code === 'ai_token_limit') {
510
+ console.error(`\n${paint(tr('Лимит Opus исчерпан (общий с мессенджером).', 'The Opus limit is exhausted (shared with the messenger).'), 'red')}`);
511
+ const usage = e.payload?.usage;
512
+ if (usage)
513
+ console.error(usageCard(usage));
514
+ process.exitCode = 2;
515
+ return;
516
+ }
517
+ if (e?.status === 401) {
518
+ console.error(tr('Не авторизован: токен протух. Выполни: surf login', 'Unauthorized: the token expired. Run: surf login'));
519
+ process.exitCode = 1;
520
+ return;
521
+ }
522
+ console.error(tr(`Ошибка: ${e?.message || err}`, `Error: ${e?.message || err}`));
523
+ process.exitCode = 1;
524
+ }
525
+ /** Красивое имя: opus-file-<uuid>__Отчёт.xlsx → Отчёт.xlsx */
526
+ function prettyFileName(urlPath) {
527
+ const base = path.basename(urlPath.split('?')[0]) || 'file';
528
+ if (base.startsWith('opus-file-')) {
529
+ const sep = base.indexOf('__');
530
+ if (sep !== -1 && sep + 2 < base.length)
531
+ return base.slice(sep + 2);
532
+ }
533
+ return base;
534
+ }
535
+ function fileNameFromDisposition(header, fallback) {
536
+ if (!header)
537
+ return fallback;
538
+ const star = header.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
539
+ if (star) {
540
+ try {
541
+ return decodeURIComponent(star[1].trim().replace(/["']/g, ''));
542
+ }
543
+ catch { /* fallback ниже */ }
544
+ }
545
+ const plain = header.match(/filename\s*=\s*"?([^";]+)"?/i);
546
+ if (plain)
547
+ return plain[1].trim();
548
+ return fallback;
549
+ }
550
+ async function openWithDefaultApp(target) {
551
+ const { spawn } = await import('node:child_process');
552
+ // URL открываем как есть. path.resolve() — только для файлов: иначе
553
+ // http://... превращается в «C:\...\http://…» (см. диалог Windows).
554
+ const abs = /^https?:\/\//i.test(target) ? target : path.resolve(target);
555
+ // На Windows вся команда — одна /c-строка с явным title: пустой title
556
+ // отдельным argv ненадёжен, и start принимает URL за имя программы —
557
+ // Windows ищет «C:\...\http://…» вместо открытия браузера.
558
+ const child = process.platform === 'win32'
559
+ ? spawn('cmd.exe', ['/d', '/s', '/c', `start "" "${abs}"`], { detached: true, stdio: 'ignore', windowsHide: true })
560
+ : process.platform === 'darwin'
561
+ ? spawn('open', [abs], { detached: true, stdio: 'ignore' })
562
+ : spawn('xdg-open', [abs], { detached: true, stdio: 'ignore' });
563
+ // Fire-and-forget: ссылку уже напечатали, fallback — открыть вручную.
564
+ child.on('error', () => { });
565
+ child.unref();
566
+ }
567
+ /**
568
+ * Скачивание вложений Opus. /uploads отдают файлы только авторизованным
569
+ * (Bearer-токен того же пользователя), поэтому обычная вставка ссылки
570
+ * в браузер без сессии мессенджера не сработает — качаем с токеном.
571
+ */
572
+ async function cmdGet(args) {
573
+ const server = resolveServer(getArgValue(args, '--server'));
574
+ const token = await ensureToken(server);
575
+ const outArg = getArgValue(args, '--out', '-o');
576
+ const open = hasFlag(args, '--open');
577
+ const skip = new Set(['--server', '--out', '-o']);
578
+ const urls = [];
579
+ for (let i = 0; i < args.length; i += 1) {
580
+ const a = args[i];
581
+ if (skip.has(a)) {
582
+ i += 1;
583
+ continue;
584
+ }
585
+ if (a.startsWith('--'))
586
+ continue;
587
+ urls.push(a);
588
+ }
589
+ if (!urls.length)
590
+ throw new Error('Использование: surf get <url|/uploads/...> [--out путь] [--open]');
591
+ for (const raw of urls) {
592
+ const url = resolveUrl(server, raw);
593
+ const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
594
+ if (!res.ok) {
595
+ console.error(`Не удалось скачать ${raw}: HTTP ${res.status}`);
596
+ process.exitCode = 1;
597
+ continue;
598
+ }
599
+ const urlPath = new URL(url).pathname;
600
+ const name = fileNameFromDisposition(res.headers.get('content-disposition'), prettyFileName(urlPath));
601
+ let dest = outArg || path.join(process.cwd(), name);
602
+ try {
603
+ const stat = fs.existsSync(dest) ? fs.statSync(dest) : null;
604
+ if (stat?.isDirectory() || (!stat && !path.extname(dest))) {
605
+ dest = path.join(dest, name);
606
+ }
607
+ }
608
+ catch {
609
+ dest = path.join(process.cwd(), name);
610
+ }
611
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
612
+ const buffer = Buffer.from(await res.arrayBuffer());
613
+ fs.writeFileSync(dest, buffer);
614
+ console.log(`Сохранено: ${dest} (${buffer.length} байт)`);
615
+ if (open) {
616
+ await openWithDefaultApp(dest);
617
+ console.log(`Открыто: ${dest}`);
618
+ }
619
+ }
620
+ }
621
+ /**
622
+ * Общий цикл ответа: спиннер мышления со статусами → заголовок Opus →
623
+ * построчный markdown-стрим. Спиннер пишет в stderr, ответ — в stdout.
624
+ */
625
+ async function streamAnswer(server, token, body, model, showHead) {
626
+ const spinner = new Spinner();
627
+ const md = new MdStream();
628
+ let headed = false;
629
+ const ensureHead = () => {
630
+ if (headed)
631
+ return;
632
+ headed = true;
633
+ spinner.stop();
634
+ if (showHead)
635
+ process.stdout.write(`${assistantHead(modelDisplayName(model))}\n`);
636
+ };
637
+ try {
638
+ const payload = await postProcessStream(server, token, body, {
639
+ onStatus: s => {
640
+ if (!headed)
641
+ spinner.start(statusLabel(s));
642
+ spinner.update(statusLabel(s));
643
+ },
644
+ onDelta: t => {
645
+ ensureHead();
646
+ process.stdout.write(md.push(t));
647
+ },
648
+ }, true);
649
+ ensureHead();
650
+ const tail = md.flush();
651
+ if (tail)
652
+ process.stdout.write(tail);
653
+ process.stdout.write('\n');
654
+ return payload;
655
+ }
656
+ finally {
657
+ spinner.stop();
658
+ }
659
+ }
660
+ async function cmdAsk(args) {
661
+ const server = resolveServer(getArgValue(args, '--server'));
662
+ const token = await ensureToken(server);
663
+ const model = resolveModel(getArgValue(args, '--model', '-m'));
664
+ const stream = !hasFlag(args, '--no-stream');
665
+ const json = hasFlag(args, '--json');
666
+ const chatIdRaw = getArgValue(args, '--chat-id');
667
+ const chatId = chatIdRaw ? Number(chatIdRaw) : undefined;
668
+ const attachPath = getArgValue(args, '--attach', '--file', '-f');
669
+ // Простой сбор текста: всё после 'ask', кроме значений известных флагов.
670
+ const skip = new Set(['--model', '-m', '--attach', '--file', '-f', '--chat-id', '--server']);
671
+ const words = [];
672
+ const rest = args;
673
+ for (let i = 0; i < rest.length; i += 1) {
674
+ const a = rest[i];
675
+ if (skip.has(a)) {
676
+ i += 1;
677
+ continue;
678
+ }
679
+ if (a.startsWith('--'))
680
+ continue;
681
+ words.push(a);
682
+ }
683
+ const text = words.join(' ').trim();
684
+ if (!text && !attachPath)
685
+ throw new Error('Использование: surf ask <текст> [--model auto] [--attach файл]');
686
+ const attachments = attachPath ? [await uploadAttachment(server, token, attachPath)] : undefined;
687
+ try {
688
+ if (stream && !json) {
689
+ const payload = await streamAnswer(server, token, { text: text || 'Опиши прикреплённое.', history: [], model, ...(chatId ? { chatId } : {}), ...(attachments ? { attachments } : {}) }, model, false);
690
+ printRichPayload(payload, server);
691
+ }
692
+ else {
693
+ const payload = await postProcessStream(server, token, { text: text || 'Опиши прикреплённое.', history: [], model, ...(chatId ? { chatId } : {}), ...(attachments ? { attachments } : {}) }, {}, false);
694
+ if (json) {
695
+ console.log(JSON.stringify(payload, null, 2));
696
+ return;
697
+ }
698
+ const md = new MdStream();
699
+ process.stdout.write(md.push(String(payload.response || '')) + md.flush() + '\n');
700
+ printRichPayload(payload, server);
701
+ }
702
+ const u = await api(server, token, 'GET', '/api/ai/usage').catch(() => null);
703
+ if (u && !json)
704
+ console.error(`\n${answerFooter(modelDisplayName(model), u)}`);
705
+ }
706
+ catch (err) {
707
+ handleProcessError(err);
708
+ }
709
+ }
710
+ async function cmdChat(args) {
711
+ const server = resolveServer(getArgValue(args, '--server'));
712
+ const token = await ensureToken(server);
713
+ let model = resolveModel(getArgValue(args, '--model', '-m'));
714
+ const chatIdRaw = getArgValue(args, '--chat-id');
715
+ const chatId = chatIdRaw ? Number(chatIdRaw) : undefined;
716
+ const history = [];
717
+ let pendingAttachments;
718
+ // Агентные задачи с файлами — инлайн, в том же чате: свой контекст
719
+ // инструментов и своя история диалогов с моделью-агентом.
720
+ // Заодно создаём папку пользовательских скиллов, если её нет.
721
+ ensureUserSkillsDir();
722
+ let permissionMode = loadConfig().permissionMode;
723
+ const codeCtx = createToolContext(process.cwd(), hasFlag(args, '--yes', '-y'), permissionMode);
724
+ const agentHistory = [{ role: 'system', content: CODE_SYSTEM(codeCtx.cwd, process.platform) }];
725
+ console.log(chatBanner({
726
+ version: VERSION,
727
+ model: modelDisplayName(model) + (model === 'auto' ? tr(' (сама подбирает)', ' (chooses automatically)') : ''),
728
+ usage: await api(server, token, 'GET', '/api/ai/usage').catch(() => null),
729
+ }));
730
+ // Один интерфейс на весь чат + вечная очередь строк: readline роняет
731
+ // 'line', пришедшие без слушателя (pipe, вставки), поэтому копим всё.
732
+ // Для стрелочного пикера интерфейс закрываем и создаём заново (см. /model).
733
+ const interactive = !!process.stdin.isTTY && !!process.stdout.isTTY;
734
+ const chatHistory = loadHistory();
735
+ let rl = interactive ? null : readline.createInterface({ input, output, completer, prompt: paint('› ', 'accent') });
736
+ // pipe сам закрывает интерфейс по EOF раньше, чем кончается очередь.
737
+ let rlClosed = false;
738
+ const lineQueue = [];
739
+ let lineWaiter = null;
740
+ const wireRl = (r) => {
741
+ r.on('line', (l) => {
742
+ if (lineWaiter) {
743
+ const w = lineWaiter;
744
+ lineWaiter = null;
745
+ w(l);
746
+ }
747
+ else {
748
+ lineQueue.push(l);
749
+ }
750
+ });
751
+ r.on('close', () => {
752
+ rlClosed = true;
753
+ if (lineWaiter) {
754
+ const w = lineWaiter;
755
+ lineWaiter = null;
756
+ w(null);
757
+ }
758
+ });
759
+ };
760
+ if (rl)
761
+ wireRl(rl);
762
+ const nextLine = () => {
763
+ const queued = lineQueue.shift();
764
+ if (queued !== undefined)
765
+ return Promise.resolve(queued);
766
+ // Интерфейс мёртв и очередь пуста — дальше строк не будет.
767
+ if (rlClosed)
768
+ return Promise.resolve(null);
769
+ return new Promise(resolve => {
770
+ lineWaiter = resolve;
771
+ });
772
+ };
773
+ if (rl)
774
+ rl.prompt();
775
+ const rep = () => {
776
+ if (!rl || rlClosed)
777
+ return;
778
+ try {
779
+ rl.prompt();
780
+ }
781
+ catch {
782
+ rlClosed = true;
783
+ }
784
+ };
785
+ for (;;) {
786
+ const submitted = interactive
787
+ ? await promptMenuLine({ prompt: paint('› ', 'accent'), history: chatHistory, complete: completeChatLine, noArgCommands: CHAT_NO_ARG_COMMANDS })
788
+ : { line: await nextLine(), attachments: [] };
789
+ if (submitted.line === null)
790
+ break;
791
+ if (interactive && submitted.line.trim()) {
792
+ chatHistory.push(submitted.line.trim());
793
+ if (chatHistory.length > 200)
794
+ chatHistory.shift();
795
+ appendHistory(submitted.line);
796
+ }
797
+ // Загружаем только вложения, чьи токены остались в строке
798
+ // (стёртые Backspace из текста не отправляем).
799
+ const liveTokens = new Set((submitted.line.match(/\[(image|file) \d+\]/g) ?? [])
800
+ .filter(t => submitted.attachments.some(a => a.token === t)));
801
+ const inlineUploads = liveTokens.size > 0
802
+ ? await uploadInlineAttachments(server, token, submitted.attachments, liveTokens)
803
+ : [];
804
+ pendingAttachments = [...(pendingAttachments || []), ...inlineUploads];
805
+ // Токены загруженных файлов вычищаем из текста.
806
+ let msg = submitted.line.trim();
807
+ if (inlineUploads.length > 0) {
808
+ for (const u of inlineUploads)
809
+ msg = msg.split(u.token).join('');
810
+ msg = msg.replace(/\s+/g, ' ').trim();
811
+ }
812
+ if (!msg) {
813
+ if (inlineUploads.length > 0)
814
+ console.log(paint(tr('(файлы прикреплены — напишите сообщение)', '(files attached — write a message)'), 'gray'));
815
+ rep();
816
+ continue;
817
+ }
818
+ if (msg === '/exit' || msg === '/quit')
819
+ break;
820
+ if (msg === '/logout') {
821
+ cmdLogout();
822
+ break;
823
+ }
824
+ if (msg === '/clear') {
825
+ history.length = 0;
826
+ agentHistory.length = 0;
827
+ agentHistory.push({ role: 'system', content: CODE_SYSTEM(codeCtx.cwd, process.platform) });
828
+ console.log(paint(tr('(история очищена)', '(history cleared)'), 'gray'));
829
+ rep();
830
+ continue;
831
+ }
832
+ if (msg === '/help') {
833
+ console.log(chatHelp());
834
+ rep();
835
+ continue;
836
+ }
837
+ if (msg === '/permissions') {
838
+ const res = await pickPermissionMode(permissionMode, () => nextLine());
839
+ if (res && res !== 'arrows') {
840
+ permissionMode = res;
841
+ codeCtx.permissionMode = res;
842
+ savePermissionMode(res);
843
+ console.log(`${paint(tr('Разрешения:', 'Permissions:'), 'gray')} ${paint(permissionModeLabel(res), 'white')}`);
844
+ rep();
845
+ continue;
846
+ }
847
+ if (res === null) {
848
+ rep();
849
+ continue;
850
+ }
851
+ const idx = await selectArrows(tr('Разрешения для coding-агента (↑↓ выбор, Enter — ok, Esc — отмена):', 'Coding-agent permissions (↑↓ choose, Enter — confirm, Esc — cancel):'), permissionOptions(permissionMode));
852
+ if (idx !== null) {
853
+ permissionMode = PERMISSION_OPTION_IDS[idx];
854
+ codeCtx.permissionMode = permissionMode;
855
+ savePermissionMode(permissionMode);
856
+ console.log(`${paint(tr('Разрешения:', 'Permissions:'), 'gray')} ${paint(permissionModeLabel(permissionMode), 'white')}`);
857
+ }
858
+ rep();
859
+ continue;
860
+ }
861
+ if (msg === '/skills') {
862
+ printSkills(codeCtx.cwd);
863
+ rep();
864
+ continue;
865
+ }
866
+ if (msg === '/usage') {
867
+ try {
868
+ printUsage(await api(server, token, 'GET', '/api/ai/usage'));
869
+ }
870
+ catch (err) {
871
+ handleProcessError(err);
872
+ }
873
+ rep();
874
+ continue;
875
+ }
876
+ if (msg === '/model' || msg.startsWith('/model ')) {
877
+ const res = await pickModel(msg, model, () => nextLine());
878
+ if (res && res !== 'arrows') {
879
+ model = res;
880
+ saveModel(model);
881
+ console.log(confirmModelLine(model));
882
+ rep();
883
+ continue;
884
+ }
885
+ if (res === null) {
886
+ rep();
887
+ continue;
888
+ }
889
+ // Стрелочный пикер требует голый stdin: закрываем интерфейс,
890
+ // выбираем, создаём заново.
891
+ if (rl)
892
+ rl.close();
893
+ const options = modelOptions(msg, model);
894
+ const idx = await selectArrows(tr('Модель — общий лимит для всех (↑↓ выбор, Enter — ok, Esc — отмена):', 'Model — shared limit for all (↑↓ choose, Enter — confirm, Esc — cancel):'), options);
895
+ if (!interactive) {
896
+ rl = readline.createInterface({ input, output, completer, prompt: paint('› ', 'accent') });
897
+ wireRl(rl);
898
+ }
899
+ rlClosed = false;
900
+ if (idx !== null) {
901
+ model = normalizeOpusModelId(options[idx].id);
902
+ saveModel(model);
903
+ console.log(confirmModelLine(model));
904
+ }
905
+ rep();
906
+ continue;
907
+ }
908
+ if (msg.startsWith('/')) {
909
+ // Вызов скилла как slash-команды: /имя-скилла [текст задачи]
910
+ const skillName = msg.slice(1).split(/\s+/)[0]?.toLowerCase() ?? '';
911
+ const skillArgs = msg.slice(1 + skillName.length).trim();
912
+ const skill = skillName ? loadSkill(codeCtx.cwd, skillName) : null;
913
+ if (skill) {
914
+ const taskText = skillArgs || tr('Следуй инструкциям навыка.', 'Follow the skill instructions.');
915
+ console.log(paint(tr(`— навык ${skill.meta.name}: работаю…`, `— skill ${skill.meta.name}: working…`), 'gray'));
916
+ const skillTask = `<skill name="${skill.meta.name}">\n${skill.instructions}\n</skill>\n\n${tr('Задача пользователя:', 'User task:')} ${taskText}`;
917
+ const lastText = await runAgentTask(server, token, model, codeCtx, agentHistory, skillTask, 25);
918
+ history.push({ role: 'user', text: msg });
919
+ history.push({ role: 'ai', text: lastText.slice(0, 1500) || '(задача выполнена)' });
920
+ rep();
921
+ continue;
922
+ }
923
+ console.log(paint(tr('Неизвестная команда. /help — список.', 'Unknown command. /help lists commands.'), 'gray'));
924
+ rep();
925
+ continue;
926
+ }
927
+ // Один режим на всё: похоже на задачу с файлами/кодом — агентный цикл
928
+ // с инструментами прямо здесь; «!» в начале форсирует. Вложенные файлы
929
+ // всегда идут обычным чатом.
930
+ const forceAgent = msg.startsWith('!');
931
+ if ((forceAgent || looksLikeCodeTask(msg)) && !pendingAttachments?.length) {
932
+ const taskText = (forceAgent ? msg.slice(1) : msg).trim();
933
+ if (!taskText) {
934
+ rep();
935
+ continue;
936
+ }
937
+ console.log(paint(tr('— агент: работаю с файлами…', '— agent: working with files…'), 'gray'));
938
+ const lastText = await runAgentTask(server, token, model, codeCtx, agentHistory, taskText, 25);
939
+ history.push({ role: 'user', text: msg });
940
+ history.push({ role: 'ai', text: lastText.slice(0, 1500) || '(задача выполнена)' });
941
+ rep();
942
+ continue;
943
+ }
944
+ const attachments = pendingAttachments;
945
+ pendingAttachments = undefined;
946
+ try {
947
+ const payload = await streamAnswer(server, token, { text: msg, history: history.slice(-10), model, ...(chatId ? { chatId } : {}), ...(attachments?.length ? { attachments } : {}) }, model, true);
948
+ printRichPayload(payload, server);
949
+ history.push({ role: 'user', text: msg });
950
+ history.push({ role: 'ai', text: String(payload.response || '') });
951
+ const u = await api(server, token, 'GET', '/api/ai/usage').catch(() => null);
952
+ const footer = answerFooter(modelDisplayName(model), u);
953
+ if (footer)
954
+ console.log(footer);
955
+ }
956
+ catch (err) {
957
+ handleProcessError(err);
958
+ if (err.status === 429)
959
+ break;
960
+ }
961
+ rep();
962
+ }
963
+ if (rl)
964
+ rl.close();
965
+ console.log(paint(tr('Пока!', 'Bye!'), 'accent'));
966
+ }
967
+ /** Пул моделей для пикера: фильтр по префиксу/подстроке или весь список без auto. */
968
+ function modelPool(msg) {
969
+ const key = normModelKey(msg.slice('/model'.length).trim());
970
+ const pool = key
971
+ ? PICKABLE_MODEL_IDS.filter(id => id.startsWith(key) || id.includes(key))
972
+ : PICKABLE_MODEL_IDS;
973
+ if (key && pool.length === 0) {
974
+ console.log(paint(tr(`Модель «${key}» не найдена. Выберите из списка:`, `Model “${key}” was not found. Choose from the list:`), 'yellow'));
975
+ }
976
+ return pool.length > 0 ? pool : PICKABLE_MODEL_IDS;
977
+ }
978
+ function modelOptions(msg, current) {
979
+ const pool = modelPool(msg);
980
+ // Auto/fast/advanced обычно скрыты из ручного списка. Но если один из них
981
+ // уже выбран, он должен быть отмечен текущим: иначе Enter в пикере молча
982
+ // применяет первый видимый вариант (сейчас это GPT-5.6 Sol).
983
+ const ids = !msg.slice('/model'.length).trim() && OPUS_MODEL_IDS.includes(current) && !pool.includes(current)
984
+ ? [current, ...pool]
985
+ : pool;
986
+ return ids.map(id => ({
987
+ id,
988
+ label: modelDisplayName(id),
989
+ current: id === current,
990
+ }));
991
+ }
992
+ import { promptMenuLine, loadHistory, appendHistory, completeChatLine, CHAT_NO_ARG_COMMANDS, looksLikeCodeTask, } from './cliPrompt.js';
993
+ import { CODE_TOOLS, createToolContext, executeAgentTool, } from './cliTools.js';
994
+ import { skillsCatalog, loadSkill, discoverSkills, ensureUserSkillsDir, } from './cliSkills.js';
995
+ /** Печать установленных скиллов: имя, источник, описание. */
996
+ function printSkills(cwd) {
997
+ const { skills, invalid } = discoverSkills(cwd);
998
+ if (skills.length === 0) {
999
+ console.log(paint(tr('Навыков нет. Положи SKILL.md в .surf/skills/<имя>/ (проект) или ~/.surf-cli/skills/<имя>/', 'No skills found. Put SKILL.md in .surf/skills/<name>/ (project) or ~/.surf-cli/skills/<name>/.'), 'gray'));
1000
+ return;
1001
+ }
1002
+ console.log(paint(tr('Навыки агента (модель подхватывает сама, /имя — вызвать):', 'Agent skills (the model can use them automatically; /name runs one):'), 'gray'));
1003
+ for (const s of skills) {
1004
+ console.log(` ${paint(s.name, 'white')}${paint(` [${s.source}]`, 'gray')} — ${s.description}`);
1005
+ }
1006
+ if (invalid > 0)
1007
+ console.log(paint(tr(`Пропущено битых: ${invalid} (нужны name/description в SKILL.md)`, `Skipped invalid: ${invalid} (SKILL.md needs name and description)`), 'yellow'));
1008
+ }
1009
+ /** Строка подтверждения выбора: «Модель: GPT-5 Mini». */
1010
+ function confirmModelLine(model) {
1011
+ return `${paint(tr('Модель:', 'Model:'), 'gray')} ${paint(modelDisplayName(model), 'white')}`;
1012
+ }
1013
+ /**
1014
+ * Какая модель стартует: явный флаг --model, иначе запомненная в прошлый
1015
+ * раз (/model пишет её в конфиг), иначе auto. Удалённые с бэка id
1016
+ * отваливаются обратно в auto.
1017
+ */
1018
+ function resolveModel(flagValue) {
1019
+ if (flagValue)
1020
+ return normalizeOpusModelId(normModelKey(flagValue));
1021
+ const saved = loadConfig().model;
1022
+ if (saved && OPUS_MODEL_IDS.includes(saved))
1023
+ return normalizeOpusModelId(saved);
1024
+ return 'auto';
1025
+ }
1026
+ /** Запомнить выбор из /model для следующих запусков. */
1027
+ function saveModel(model) {
1028
+ const cfg = loadConfig();
1029
+ if (cfg.model === model)
1030
+ return;
1031
+ cfg.model = model;
1032
+ saveConfig(cfg);
1033
+ }
1034
+ const PERMISSION_OPTION_IDS = ['always', 'important', 'never'];
1035
+ function permissionOption(mode) {
1036
+ if (mode === 'always')
1037
+ return { id: mode, label: tr('Всегда спрашивать', 'Always ask'), hint: tr('подтверждать каждую правку и команду', 'confirm every edit and command') };
1038
+ if (mode === 'never')
1039
+ return { id: mode, label: tr('Не спрашивать', 'Never ask'), hint: tr('выполнять действия без подтверждений', 'run actions without confirmations') };
1040
+ return { id: mode, label: tr('Только важные действия', 'Important actions only'), hint: tr('обычные правки и проверки без вопроса', 'routine edits and checks without a prompt') };
1041
+ }
1042
+ function permissionModeLabel(mode) {
1043
+ return permissionOption(mode).label;
1044
+ }
1045
+ function savePermissionMode(mode) {
1046
+ const cfg = loadConfig();
1047
+ if (cfg.permissionMode === mode)
1048
+ return;
1049
+ cfg.permissionMode = mode;
1050
+ saveConfig(cfg);
1051
+ }
1052
+ function permissionOptions(current) {
1053
+ return PERMISSION_OPTION_IDS.map(id => {
1054
+ const option = permissionOption(id);
1055
+ return { ...option, current: id === current };
1056
+ });
1057
+ }
1058
+ async function pickPermissionMode(current, readLine) {
1059
+ if (canUseArrows())
1060
+ return 'arrows';
1061
+ const index = await selectNumbered(tr('Разрешения для coding-агента:', 'Coding-agent permissions:'), permissionOptions(current), readLine);
1062
+ return index === null ? null : PERMISSION_OPTION_IDS[index];
1063
+ }
1064
+ /**
1065
+ * Выбор модели без стрелок: точный id и однозначный префикс — сразу,
1066
+ * иначе пронумерованный список через живой rl чата.
1067
+ * Возвращает id, null (отмена) или 'arrows' (нужен стрелочный пикер).
1068
+ */
1069
+ async function pickModel(msg, current, readLine) {
1070
+ const key = normModelKey(msg.slice('/model'.length).trim());
1071
+ if (key && OPUS_MODEL_IDS.includes(key))
1072
+ return normalizeOpusModelId(key);
1073
+ const pool = key ? PICKABLE_MODEL_IDS.filter(id => id.startsWith(key) || id.includes(key)) : PICKABLE_MODEL_IDS;
1074
+ if (key && pool.length === 1)
1075
+ return normalizeOpusModelId(pool[0]);
1076
+ if (canUseArrows())
1077
+ return 'arrows';
1078
+ const options = modelOptions(msg, current);
1079
+ const idx = await selectNumbered(tr('Модель — общий лимит для всех:', 'Model — shared limit for all:'), options, readLine);
1080
+ if (idx === null)
1081
+ return null;
1082
+ return normalizeOpusModelId(options[idx].id);
1083
+ }
1084
+ const CODE_SYSTEM = (cwd, platform) => `You are Surf Code, an AI coding assistant running in the user's terminal.
1085
+ Working directory: ${cwd} (platform ${platform}). ALL file tools are jailed here — refuse paths outside it.
1086
+ You have tools: read (file or directory listing), glob (find files), grep (search code), edit (exact string replacement, the file MUST be read first), write (create/overwrite file), bash (shell commands). The local CLI applies the user's permission mode before mutating tools run.
1087
+ Rules:
1088
+ - Always answer in Russian. Be concise: say what you did and how to verify, skip the play-by-play.
1089
+ - If the user asks about files, code or the project: ALWAYS call read/glob/grep first. Never describe, list or guess file contents you have not read with tools in THIS session. An answer about files without tool calls is a failure.
1090
+ - Explore with read/glob/grep before changing anything. Prefer small exact edits over rewrites.
1091
+ - Verify code changes with the project's own scripts (package.json scripts, tests, build, lint).
1092
+ - Never commit, push, publish or delete user data unless explicitly asked. Never create files unless needed for the task.
1093
+ - If the task is unclear or destructive, ask first instead of guessing.
1094
+ - Work until done or blocked; on blockers, clearly report what is missing.`;
1095
+ async function agentTurn(server, token, model, messages) {
1096
+ const data = await api(server, token, 'POST', '/api/cli/agent', { model, messages, tools: CODE_TOOLS });
1097
+ return { message: data.message, usage: data.usage };
1098
+ }
1099
+ function trimAgentHistory(history) {
1100
+ // system всегда первый; остальное — скользящее окно.
1101
+ while (history.length > 41) {
1102
+ const idx = history.findIndex((m, i) => i > 0 && m.role !== 'system');
1103
+ if (idx < 0)
1104
+ break;
1105
+ history.splice(idx, 1);
1106
+ }
1107
+ }
1108
+ async function runAgentTask(server, token, model, ctx, history, userText, maxIters) {
1109
+ history.push({ role: 'user', content: userText });
1110
+ trimAgentHistory(history);
1111
+ let emptyStreak = 0;
1112
+ let lastText = '';
1113
+ const skills = skillsCatalog(ctx.cwd);
1114
+ const systemWithSkills = CODE_SYSTEM(ctx.cwd, process.platform) + (skills
1115
+ ? `\n\nУстановленные Agent Skills (модульные возможности):\n${skills}\n\nЕсли задача подходит под описание навыка — СНАЧАЛА вызови инструмент skill (action=read, name=<имя>) и строго следуй его инструкциям, включая запуск приложенных скриптов через bash. Актуальный список — через skill action=list.`
1116
+ : '');
1117
+ if (history[0]?.role === 'system')
1118
+ history[0] = { role: 'system', content: systemWithSkills };
1119
+ else
1120
+ history.unshift({ role: 'system', content: systemWithSkills });
1121
+ for (let iter = 1; iter <= maxIters; iter += 1) {
1122
+ const spinner = new Spinner();
1123
+ spinner.start(tr('Думаю…', 'Thinking…'));
1124
+ let turn;
1125
+ try {
1126
+ turn = await agentTurn(server, token, model, history);
1127
+ }
1128
+ catch (err) {
1129
+ spinner.stop();
1130
+ handleProcessError(err);
1131
+ return lastText;
1132
+ }
1133
+ spinner.stop();
1134
+ const msg = turn.message;
1135
+ history.push({
1136
+ role: 'assistant',
1137
+ content: msg.content || '',
1138
+ ...(msg.tool_calls?.length ? { tool_calls: msg.tool_calls } : {}),
1139
+ });
1140
+ if (msg.content) {
1141
+ lastText = msg.content;
1142
+ const md = new MdStream();
1143
+ process.stdout.write(`${assistantHead(modelDisplayName(model))}\n`);
1144
+ process.stdout.write(md.push(msg.content) + md.flush() + '\n');
1145
+ }
1146
+ const calls = msg.tool_calls ?? [];
1147
+ if (calls.length === 0) {
1148
+ if (!msg.content) {
1149
+ // Пустой ход без инструментов: подтолкнуть один раз, иначе стоп.
1150
+ emptyStreak += 1;
1151
+ if (emptyStreak >= 2) {
1152
+ console.log(paint(tr('(модель не отвечает по делу — останавливаю)', '(the model is not progressing — stopping)'), 'yellow'));
1153
+ break;
1154
+ }
1155
+ history.push({ role: 'user', content: 'Продолжай задачу только с помощью инструментов: прочитай нужные файлы.' });
1156
+ continue;
1157
+ }
1158
+ break;
1159
+ }
1160
+ emptyStreak = 0;
1161
+ for (const call of calls) {
1162
+ const rawArgs = call.function.arguments;
1163
+ const argsPreview = rawArgs.length > 200 ? `${rawArgs.slice(0, 200)}…` : rawArgs;
1164
+ console.log(` ${paint('tool', 'gray')} ${paint(call.function.name, 'white')} ${paint(argsPreview, 'gray')}`);
1165
+ const result = await executeAgentTool(ctx, { id: call.id, name: call.function.name, argsJson: rawArgs });
1166
+ if (result.startsWith('ошибка') || result.startsWith('отклонено')) {
1167
+ console.log(` ${paint(result.split('\n')[0] ?? result, 'yellow')}`);
1168
+ }
1169
+ history.push({ role: 'tool', tool_call_id: call.id, content: result });
1170
+ }
1171
+ trimAgentHistory(history);
1172
+ }
1173
+ return lastText;
1174
+ }
1175
+ async function main() {
1176
+ const [, , cmd, ...args] = process.argv;
1177
+ loadSavedAccent();
1178
+ try {
1179
+ if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
1180
+ printHelp();
1181
+ return;
1182
+ }
1183
+ if (!cmd) {
1184
+ // Просто `surf`: если не залогинен — предложим вход через браузер,
1185
+ // затем сразу откроем чат.
1186
+ await cmdChat(args);
1187
+ return;
1188
+ }
1189
+ if (cmd === '--version' || cmd === 'version') {
1190
+ console.log(VERSION);
1191
+ return;
1192
+ }
1193
+ if (cmd === 'login') {
1194
+ await cmdLogin(args);
1195
+ return;
1196
+ }
1197
+ if (cmd === 'token') {
1198
+ await cmdToken(args);
1199
+ return;
1200
+ }
1201
+ if (cmd === 'logout') {
1202
+ cmdLogout();
1203
+ return;
1204
+ }
1205
+ if (cmd === 'whoami') {
1206
+ await cmdWhoami(args);
1207
+ return;
1208
+ }
1209
+ if (cmd === 'usage' || cmd === 'limits') {
1210
+ await cmdUsage(args);
1211
+ return;
1212
+ }
1213
+ if (cmd === 'models') {
1214
+ printModels();
1215
+ return;
1216
+ }
1217
+ if (cmd === 'ask') {
1218
+ await cmdAsk(args);
1219
+ return;
1220
+ }
1221
+ if (cmd === 'chat') {
1222
+ await cmdChat(args);
1223
+ return;
1224
+ }
1225
+ if (cmd === 'get' || cmd === 'download') {
1226
+ await cmdGet(args);
1227
+ return;
1228
+ }
1229
+ console.error(tr(`Неизвестная команда: ${cmd}\n`, `Unknown command: ${cmd}\n`));
1230
+ printHelp();
1231
+ process.exitCode = 1;
1232
+ }
1233
+ catch (err) {
1234
+ console.error(tr(`Ошибка: ${err.message}`, `Error: ${err.message}`));
1235
+ process.exitCode = 1;
1236
+ }
1237
+ }
1238
+ void main();
1239
+ //# sourceMappingURL=cli.js.map