cli-surf 0.11.0 → 0.12.1

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 CHANGED
@@ -14,10 +14,13 @@ import os from 'node:os';
14
14
  import path from 'node:path';
15
15
  import readline from 'node:readline/promises';
16
16
  import { stdin as input, stdout as output } from 'node:process';
17
+ import { fileURLToPath } from 'node:url';
17
18
  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';
19
+ import { Spinner, MdStream, usageCard, chatBanner, answerFooter, assistantHead, completer, chatHelp, paint, OPUS_MODEL_IDS, statusLabel, selectArrows, selectNumbered, canUseArrows, modelDisplayName, PICKABLE_MODEL_IDS, normModelKey, MODEL_HINTS, setAccentColor, defaultAccentHex, setCliLanguage, tr, isRussian, } from './cliUi.js';
20
+ const VERSION = '0.12.1';
21
+ // Production API used by fresh installations. Local development can still
22
+ // override it with --server http://localhost:3001 or SURF_SERVER.
23
+ const DEFAULT_SERVER = 'https://surf-app.xyz';
21
24
  const CONFIG_DIR = path.join(os.homedir(), '.surf-cli');
22
25
  const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
23
26
  const DEFAULT_PERMISSION_MODE = 'important';
@@ -34,6 +37,7 @@ function loadConfig() {
34
37
  permissionMode: parsed.permissionMode === 'always' || parsed.permissionMode === 'never'
35
38
  ? parsed.permissionMode
36
39
  : DEFAULT_PERMISSION_MODE,
40
+ shell: typeof parsed.shell === 'string' && parsed.shell ? parsed.shell : undefined,
37
41
  };
38
42
  }
39
43
  catch {
@@ -185,6 +189,7 @@ function printHelp() {
185
189
  surf ask <текст...> [опции] Один вопрос и ответ
186
190
  surf chat [опции] Интерактивный чат (стриминг, история)
187
191
  surf get <url...> [--out путь] [--open] Скачать вложения Opus (нужен тот же токен)
192
+ surf mcp [list|add|remove|test] MCP-серверы для агента (как в Codex)
188
193
 
189
194
  Опции ask/chat:
190
195
  --server URL Адрес API (или env SURF_SERVER, по умолчанию ${DEFAULT_SERVER})
@@ -192,6 +197,7 @@ function printHelp() {
192
197
  --no-stream Не стримить, дождаться полного ответа
193
198
  --attach <path> Прикрепить файл к вопросу (ask; в chat — команда /attach)
194
199
  --chat-id <N> Писать в чат мессенджера N (по умолчанию без сохранения — расход лимита тот же)
200
+ --open Автоматически открыть созданный файл (Excel, PDF и т.д.)
195
201
  --json Вывести сырой JSON ответа
196
202
  --yes, -y Не спрашивать разрешений в этом запуске
197
203
 
@@ -200,7 +206,7 @@ function printHelp() {
200
206
  --open Открыть скачанное в программе по умолчанию
201
207
 
202
208
  Команды внутри chat:
203
- /usage, /model [id], /permissions, /clear, /help, /logout, /exit, ! — задача с файлами
209
+ /usage, /model [id], /permissions, /open [файл], /get <url>, /clear, /help, /logout, /exit, ! — задача с файлами
204
210
  ` : `Surf Opus CLI v${VERSION} — terminal access to the same limits as the messenger.
205
211
  Usage is shared: the CLI uses /api/ai/process under the same user_id and writes
206
212
  to the same ai_token_usage table. What is spent in the terminal is visible in the messenger.
@@ -216,6 +222,7 @@ Usage:
216
222
  surf ask <text...> [options] Ask one question
217
223
  surf chat [options] Interactive chat (streaming, history)
218
224
  surf get <url...> [--out path] [--open] Download Opus attachments
225
+ surf mcp [list|add|remove|test] MCP servers for the agent (like Codex)
219
226
 
220
227
  ask/chat options:
221
228
  --server URL API address (or SURF_SERVER; default ${DEFAULT_SERVER})
@@ -223,6 +230,7 @@ ask/chat options:
223
230
  --no-stream Wait for the complete response
224
231
  --attach <path> Attach a file (use /attach in chat)
225
232
  --chat-id <N> Write to messenger chat N (without persistence by default)
233
+ --open Open created file automatically
226
234
  --json Print the raw JSON response
227
235
  --yes, -y Do not ask for agent permissions for this run
228
236
 
@@ -231,18 +239,20 @@ get options:
231
239
  --open Open with the default application
232
240
 
233
241
  Chat commands:
234
- /usage, /model [id], /permissions, /clear, /help, /logout, /exit, ! — task with files
242
+ /usage, /model [id], /permissions, /open [file], /get <url>, /clear, /help, /logout, /exit, ! — task with files
235
243
  `);
236
244
  }
237
245
  function printModels() {
238
246
  console.log(paint(tr('Модели Opus (лимит общий для всех моделей):', 'Opus models (the limit is shared by all models):'), 'gray'));
239
247
  for (const id of OPUS_MODEL_IDS) {
240
248
  const normalized = normalizeOpusModelId(id);
249
+ const hintObj = MODEL_HINTS[id];
250
+ const hint = hintObj ? ` [${tr(hintObj.ru, hintObj.en)}]` : '';
241
251
  if (id === 'auto') {
242
252
  console.log(` ${paint('Auto', 'white')} ${paint(tr('(auto) — сама подбирает, по умолчанию', '(auto) — chooses automatically; default'), 'gray')} ${paint(`→ ${opusProviderModel(normalized)}`, 'gray')}`);
243
253
  continue;
244
254
  }
245
- console.log(` ${paint(modelDisplayName(id), 'white')} ${paint(`(${id})`, 'gray')} ${paint(`→ ${opusProviderModel(normalized)}`, 'gray')}`);
255
+ console.log(` ${paint(modelDisplayName(id), 'white')} ${paint(`(${id})${hint}`, 'gray')} ${paint(`→ ${opusProviderModel(normalized)}`, 'gray')}`);
246
256
  }
247
257
  console.log(paint(tr('\nfree-план всегда отвечает дешёвой моделью; pro выбирает любую.', '\nThe free plan always uses an economy model; Pro can choose any.'), 'gray'));
248
258
  console.log(paint(tr('При исчерпании месячного лимита включается cheap-mode (GPT-5 Nano).', 'When the monthly limit is exhausted, cheap mode (GPT-5 Nano) is enabled.'), 'gray'));
@@ -420,11 +430,12 @@ async function uploadInlineAttachments(server, token, atts, live) {
420
430
  }
421
431
  return out;
422
432
  }
423
- async function postProcessStream(server, token, body, handlers, stream) {
433
+ async function postProcessStream(server, token, body, handlers, stream, signal) {
424
434
  const res = await fetch(resolveUrl(server, '/api/ai/process'), {
425
435
  method: 'POST',
426
436
  headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
427
437
  body: JSON.stringify(stream ? { ...body, stream: true } : body),
438
+ signal,
428
439
  });
429
440
  if (!res.ok) {
430
441
  const payload = await res.json().catch(() => ({}));
@@ -481,47 +492,6 @@ async function postProcessStream(server, token, body, handlers, stream) {
481
492
  throw new Error(tr('Стрим оборвался без done', 'The stream ended without done'));
482
493
  return done;
483
494
  }
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
495
  /** Красивое имя: opus-file-<uuid>__Отчёт.xlsx → Отчёт.xlsx */
526
496
  function prettyFileName(urlPath) {
527
497
  const base = path.basename(urlPath.split('?')[0]) || 'file';
@@ -564,6 +534,102 @@ async function openWithDefaultApp(target) {
564
534
  child.on('error', () => { });
565
535
  child.unref();
566
536
  }
537
+ /** Скачать вложение Opus и сохранить локально. */
538
+ async function downloadAttachment(server, token, rawUrl, destFolder = process.cwd(), customName, autoOpen = false) {
539
+ const url = resolveUrl(server, rawUrl);
540
+ const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
541
+ if (!res.ok)
542
+ return null;
543
+ const urlPath = new URL(url).pathname;
544
+ const fallbackName = customName || prettyFileName(urlPath);
545
+ const name = fileNameFromDisposition(res.headers.get('content-disposition'), fallbackName);
546
+ let dest = path.join(destFolder, name);
547
+ try {
548
+ const stat = fs.existsSync(dest) ? fs.statSync(dest) : null;
549
+ if (stat?.isDirectory() || (!stat && !path.extname(dest))) {
550
+ dest = path.join(dest, name);
551
+ }
552
+ }
553
+ catch { /* ignore */ }
554
+ if (fs.existsSync(dest)) {
555
+ const ext = path.extname(name);
556
+ const baseName = path.basename(name, ext);
557
+ let counter = 1;
558
+ const dir = path.dirname(dest);
559
+ while (fs.existsSync(path.join(dir, `${baseName} (${counter})${ext}`))) {
560
+ counter += 1;
561
+ }
562
+ dest = path.join(dir, `${baseName} (${counter})${ext}`);
563
+ }
564
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
565
+ const buffer = Buffer.from(await res.arrayBuffer());
566
+ fs.writeFileSync(dest, buffer);
567
+ if (autoOpen) {
568
+ await openWithDefaultApp(dest);
569
+ }
570
+ return { path: dest, size: buffer.length };
571
+ }
572
+ /** Вывод форматированного ответа с авто-скачиванием созданных файлов прямо в рабочую папку. */
573
+ async function handleRichPayload(payload, server, token, autoOpen = false, destFolder = process.cwd()) {
574
+ const savedFiles = [];
575
+ const webSearch = payload.webSearch;
576
+ if (webSearch?.sources?.length) {
577
+ console.log(`\n${paint(tr('— Источники веб-поиска:', '— Web search sources:'), 'accent')}`);
578
+ for (const s of webSearch.sources.slice(0, 8))
579
+ console.log(` ${paint('•', 'accent')} ${s.title} — ${paint(s.url, 'blue')}`);
580
+ }
581
+ const images = payload.images;
582
+ if (images?.length) {
583
+ console.log(`\n${paint(tr('— Картинки:', '— Images:'), 'accent')}`);
584
+ for (const img of images)
585
+ console.log(` ${paint('•', 'accent')} ${paint(resolveUrl(server, img.url), 'blue')}${img.alt ? paint(` (${img.alt})`, 'gray') : ''}`);
586
+ }
587
+ const attachments = payload.attachments;
588
+ if (attachments?.length) {
589
+ console.log(`\n${paint(tr('— Созданные файлы:', '— Created files:'), 'accent')}`);
590
+ for (const f of attachments) {
591
+ if (token) {
592
+ try {
593
+ const saved = await downloadAttachment(server, token, f.url, destFolder, f.name, autoOpen);
594
+ if (saved) {
595
+ savedFiles.push(saved.path);
596
+ const rel = path.relative(process.cwd(), saved.path) || saved.path;
597
+ const formatted = rel.startsWith('.') || path.isAbsolute(rel) ? rel : `./${rel}`;
598
+ const sizeStr = saved.size < 1024 * 1024
599
+ ? `${(saved.size / 1024).toFixed(1)} КБ`
600
+ : `${(saved.size / (1024 * 1024)).toFixed(1)} МБ`;
601
+ console.log(` ${paint('✔', 'green')} ${paint(f.name, 'white', 'bold')} → ${paint(formatted, 'accent')} ${paint(`(${sizeStr})`, 'gray')}`);
602
+ continue;
603
+ }
604
+ }
605
+ catch { /* fallback to link below */ }
606
+ }
607
+ console.log(` ${paint('•', 'accent')} ${paint(f.name, 'white')}: ${paint(resolveUrl(server, f.url), 'blue')}`);
608
+ console.log(paint(tr('Скачать: surf get <url> [--open]', 'Download: surf get <url> [--open]'), 'gray'));
609
+ }
610
+ }
611
+ if (payload.map)
612
+ console.log(`\n${paint(tr('— К ответу приложена интерактивная карта (смотри в мессенджере).', '— An interactive map is attached to the reply (view it in the messenger).'), 'gray')}`);
613
+ return savedFiles;
614
+ }
615
+ function handleProcessError(err) {
616
+ const e = err;
617
+ if (e?.status === 429 && e.payload?.code === 'ai_token_limit') {
618
+ console.error(`\n${paint(tr('Лимит Opus исчерпан (общий с мессенджером).', 'The Opus limit is exhausted (shared with the messenger).'), 'red')}`);
619
+ const usage = e.payload?.usage;
620
+ if (usage)
621
+ console.error(usageCard(usage));
622
+ process.exitCode = 2;
623
+ return;
624
+ }
625
+ if (e?.status === 401) {
626
+ console.error(tr('Не авторизован: токен протух. Выполни: surf login', 'Unauthorized: the token expired. Run: surf login'));
627
+ process.exitCode = 1;
628
+ return;
629
+ }
630
+ console.error(tr(`Ошибка: ${e?.message || err}`, `Error: ${e?.message || err}`));
631
+ process.exitCode = 1;
632
+ }
567
633
  /**
568
634
  * Скачивание вложений Opus. /uploads отдают файлы только авторизованным
569
635
  * (Bearer-токен того же пользователя), поэтому обычная вставка ссылки
@@ -589,32 +655,17 @@ async function cmdGet(args) {
589
655
  if (!urls.length)
590
656
  throw new Error('Использование: surf get <url|/uploads/...> [--out путь] [--open]');
591
657
  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}`);
658
+ const destFolder = outArg || process.cwd();
659
+ const saved = await downloadAttachment(server, token, raw, destFolder, undefined, open);
660
+ if (!saved) {
661
+ console.error(paint(tr(`Не удалось скачать ${raw}`, `Failed to download ${raw}`), 'red'));
596
662
  process.exitCode = 1;
597
663
  continue;
598
664
  }
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} байт)`);
665
+ const rel = path.relative(process.cwd(), saved.path) || saved.path;
666
+ console.log(paint(`✔ ${tr('Сохранено:', 'Saved:')} ${rel} (${saved.size} байт)`, 'green'));
615
667
  if (open) {
616
- await openWithDefaultApp(dest);
617
- console.log(`Открыто: ${dest}`);
668
+ console.log(paint(`✔ ${tr('Открыто:', 'Opened:')} ${rel}`, 'green'));
618
669
  }
619
670
  }
620
671
  }
@@ -622,10 +673,11 @@ async function cmdGet(args) {
622
673
  * Общий цикл ответа: спиннер мышления со статусами → заголовок Opus →
623
674
  * построчный markdown-стрим. Спиннер пишет в stderr, ответ — в stdout.
624
675
  */
625
- async function streamAnswer(server, token, body, model, showHead) {
676
+ async function streamAnswer(server, token, body, model, showHead, signal) {
626
677
  const spinner = new Spinner();
627
678
  const md = new MdStream();
628
679
  let headed = false;
680
+ let receivedDelta = false;
629
681
  const ensureHead = () => {
630
682
  if (headed)
631
683
  return;
@@ -642,11 +694,21 @@ async function streamAnswer(server, token, body, model, showHead) {
642
694
  spinner.update(statusLabel(s));
643
695
  },
644
696
  onDelta: t => {
697
+ receivedDelta = true;
645
698
  ensureHead();
646
699
  process.stdout.write(md.push(t));
647
700
  },
648
- }, true);
701
+ onReset: () => {
702
+ receivedDelta = false;
703
+ },
704
+ }, true, signal);
649
705
  ensureHead();
706
+ if (!receivedDelta) {
707
+ const resp = typeof payload?.response === 'string' ? payload.response : '';
708
+ if (resp) {
709
+ process.stdout.write(md.push(resp));
710
+ }
711
+ }
650
712
  const tail = md.flush();
651
713
  if (tail)
652
714
  process.stdout.write(tail);
@@ -658,16 +720,18 @@ async function streamAnswer(server, token, body, model, showHead) {
658
720
  }
659
721
  }
660
722
  async function cmdAsk(args) {
723
+ loadProjectEnv(process.cwd());
661
724
  const server = resolveServer(getArgValue(args, '--server'));
662
725
  const token = await ensureToken(server);
663
726
  const model = resolveModel(getArgValue(args, '--model', '-m'));
664
727
  const stream = !hasFlag(args, '--no-stream');
665
728
  const json = hasFlag(args, '--json');
729
+ const open = hasFlag(args, '--open');
666
730
  const chatIdRaw = getArgValue(args, '--chat-id');
667
731
  const chatId = chatIdRaw ? Number(chatIdRaw) : undefined;
668
732
  const attachPath = getArgValue(args, '--attach', '--file', '-f');
669
733
  // Простой сбор текста: всё после 'ask', кроме значений известных флагов.
670
- const skip = new Set(['--model', '-m', '--attach', '--file', '-f', '--chat-id', '--server']);
734
+ const skip = new Set(['--model', '-m', '--attach', '--file', '-f', '--chat-id', '--server', '--open']);
671
735
  const words = [];
672
736
  const rest = args;
673
737
  for (let i = 0; i < rest.length; i += 1) {
@@ -682,12 +746,12 @@ async function cmdAsk(args) {
682
746
  }
683
747
  const text = words.join(' ').trim();
684
748
  if (!text && !attachPath)
685
- throw new Error('Использование: surf ask <текст> [--model auto] [--attach файл]');
749
+ throw new Error('Использование: surf ask <текст> [--model auto] [--attach файл] [--open]');
686
750
  const attachments = attachPath ? [await uploadAttachment(server, token, attachPath)] : undefined;
687
751
  try {
688
752
  if (stream && !json) {
689
753
  const payload = await streamAnswer(server, token, { text: text || 'Опиши прикреплённое.', history: [], model, ...(chatId ? { chatId } : {}), ...(attachments ? { attachments } : {}) }, model, false);
690
- printRichPayload(payload, server);
754
+ await handleRichPayload(payload, server, token, open);
691
755
  }
692
756
  else {
693
757
  const payload = await postProcessStream(server, token, { text: text || 'Опиши прикреплённое.', history: [], model, ...(chatId ? { chatId } : {}), ...(attachments ? { attachments } : {}) }, {}, false);
@@ -697,7 +761,7 @@ async function cmdAsk(args) {
697
761
  }
698
762
  const md = new MdStream();
699
763
  process.stdout.write(md.push(String(payload.response || '')) + md.flush() + '\n');
700
- printRichPayload(payload, server);
764
+ await handleRichPayload(payload, server, token, open);
701
765
  }
702
766
  const u = await api(server, token, 'GET', '/api/ai/usage').catch(() => null);
703
767
  if (u && !json)
@@ -715,12 +779,16 @@ async function cmdChat(args) {
715
779
  const chatId = chatIdRaw ? Number(chatIdRaw) : undefined;
716
780
  const history = [];
717
781
  let pendingAttachments;
782
+ let lastDownloadedFile = null;
718
783
  // Агентные задачи с файлами — инлайн, в том же чате: свой контекст
719
784
  // инструментов и своя история диалогов с моделью-агентом.
720
- // Заодно создаём папку пользовательских скиллов, если её нет.
785
+ // Заодно подгружаем .env проекта и создаём папку пользовательских скиллов.
786
+ loadProjectEnv(process.cwd());
721
787
  ensureUserSkillsDir();
722
- let permissionMode = loadConfig().permissionMode;
723
- const codeCtx = createToolContext(process.cwd(), hasFlag(args, '--yes', '-y'), permissionMode);
788
+ const cliCfg = loadConfig();
789
+ let permissionMode = cliCfg.permissionMode;
790
+ const codeCtx = createToolContext(process.cwd(), hasFlag(args, '--yes', '-y'), permissionMode, cliCfg.shell);
791
+ await initMcp(codeCtx);
724
792
  const agentHistory = [{ role: 'system', content: CODE_SYSTEM(codeCtx.cwd, process.platform) }];
725
793
  console.log(chatBanner({
726
794
  version: VERSION,
@@ -782,215 +850,318 @@ async function cmdChat(args) {
782
850
  rlClosed = true;
783
851
  }
784
852
  };
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);
853
+ let activeAbortController = null;
854
+ const onSigint = () => {
855
+ if (activeAbortController) {
856
+ activeAbortController.abort();
857
+ process.stdout.write(paint(tr('\n(прервано по Ctrl+C)\n', '\n(interrupted by Ctrl+C)\n'), 'yellow'));
796
858
  }
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;
859
+ else {
860
+ process.exit(0);
836
861
  }
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')}`);
862
+ };
863
+ process.on('SIGINT', onSigint);
864
+ try {
865
+ for (;;) {
866
+ const submitted = interactive
867
+ ? await promptMenuLine({ prompt: paint('› ', 'accent'), history: chatHistory, complete: completeChatLine, noArgCommands: CHAT_NO_ARG_COMMANDS })
868
+ : { line: await nextLine(), attachments: [] };
869
+ if (submitted.line === null)
870
+ break;
871
+ if (interactive && submitted.line.trim()) {
872
+ chatHistory.push(submitted.line.trim());
873
+ if (chatHistory.length > 200)
874
+ chatHistory.shift();
875
+ appendHistory(submitted.line);
876
+ }
877
+ // Загружаем только вложения, чьи токены остались в строке
878
+ // (стёртые Backspace из текста не отправляем).
879
+ const liveTokens = new Set((submitted.line.match(/\[(image|file) \d+\]/g) ?? [])
880
+ .filter(t => submitted.attachments.some(a => a.token === t)));
881
+ const inlineUploads = liveTokens.size > 0
882
+ ? await uploadInlineAttachments(server, token, submitted.attachments, liveTokens)
883
+ : [];
884
+ pendingAttachments = [...(pendingAttachments || []), ...inlineUploads];
885
+ // Токены загруженных файлов вычищаем из текста.
886
+ let msg = submitted.line.trim();
887
+ if (inlineUploads.length > 0) {
888
+ for (const u of inlineUploads)
889
+ msg = msg.split(u.token).join('');
890
+ msg = msg.replace(/\s+/g, ' ').trim();
891
+ }
892
+ if (!msg) {
893
+ if (inlineUploads.length > 0)
894
+ console.log(paint(tr('(файлы прикреплены — напишите сообщение)', '(files attached — write a message)'), 'gray'));
844
895
  rep();
845
896
  continue;
846
897
  }
847
- if (res === null) {
898
+ if (msg === '/exit' || msg === '/quit')
899
+ break;
900
+ if (msg === '/logout') {
901
+ cmdLogout();
902
+ break;
903
+ }
904
+ if (msg === '/clear') {
905
+ history.length = 0;
906
+ agentHistory.length = 0;
907
+ agentHistory.push({ role: 'system', content: CODE_SYSTEM(codeCtx.cwd, process.platform) });
908
+ console.log(paint(tr('(история очищена)', '(history cleared)'), 'gray'));
848
909
  rep();
849
910
  continue;
850
911
  }
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')}`);
912
+ if (msg === '/help') {
913
+ console.log(chatHelp());
914
+ rep();
915
+ continue;
857
916
  }
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'));
917
+ if (msg === '/permissions') {
918
+ const res = await pickPermissionMode(permissionMode, () => nextLine());
919
+ if (res && res !== 'arrows') {
920
+ permissionMode = res;
921
+ codeCtx.permissionMode = res;
922
+ savePermissionMode(res);
923
+ console.log(`${paint(tr('Разрешения:', 'Permissions:'), 'gray')} ${paint(permissionModeLabel(res), 'white')}`);
924
+ rep();
925
+ continue;
926
+ }
927
+ if (res === null) {
928
+ rep();
929
+ continue;
930
+ }
931
+ const idx = await selectArrows(tr('Разрешения для coding-агента (↑↓ выбор, Enter — ok, Esc — отмена):', 'Coding-agent permissions (↑↓ choose, Enter — confirm, Esc — cancel):'), permissionOptions(permissionMode));
932
+ if (idx !== null) {
933
+ permissionMode = PERMISSION_OPTION_IDS[idx];
934
+ codeCtx.permissionMode = permissionMode;
935
+ savePermissionMode(permissionMode);
936
+ console.log(`${paint(tr('Разрешения:', 'Permissions:'), 'gray')} ${paint(permissionModeLabel(permissionMode), 'white')}`);
937
+ }
938
+ rep();
939
+ continue;
869
940
  }
870
- catch (err) {
871
- handleProcessError(err);
941
+ if (msg === '/skills') {
942
+ printSkills(codeCtx.cwd);
943
+ rep();
944
+ continue;
872
945
  }
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));
946
+ if (msg === '/usage') {
947
+ try {
948
+ printUsage(await api(server, token, 'GET', '/api/ai/usage'));
949
+ }
950
+ catch (err) {
951
+ handleProcessError(err);
952
+ }
882
953
  rep();
883
954
  continue;
884
955
  }
885
- if (res === null) {
956
+ if (msg === '/model' || msg.startsWith('/model ')) {
957
+ const res = await pickModel(msg, model, () => nextLine());
958
+ if (res && res !== 'arrows') {
959
+ model = res;
960
+ saveModel(model);
961
+ console.log(confirmModelLine(model));
962
+ rep();
963
+ continue;
964
+ }
965
+ if (res === null) {
966
+ rep();
967
+ continue;
968
+ }
969
+ // Стрелочный пикер требует голый stdin: закрываем интерфейс,
970
+ // выбираем, создаём заново.
971
+ if (rl)
972
+ rl.close();
973
+ const options = modelOptions(msg, model);
974
+ const idx = await selectArrows(tr('Модель — общий лимит для всех (↑↓ выбор, Enter — ok, Esc — отмена):', 'Model — shared limit for all (↑↓ choose, Enter — confirm, Esc — cancel):'), options);
975
+ if (!interactive) {
976
+ rl = readline.createInterface({ input, output, completer, prompt: paint('› ', 'accent') });
977
+ wireRl(rl);
978
+ }
979
+ rlClosed = false;
980
+ if (idx !== null) {
981
+ model = normalizeOpusModelId(options[idx].id);
982
+ saveModel(model);
983
+ console.log(confirmModelLine(model));
984
+ }
886
985
  rep();
887
986
  continue;
888
987
  }
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);
988
+ if (msg.startsWith('/open')) {
989
+ const target = msg.slice(5).trim() || lastDownloadedFile;
990
+ if (!target) {
991
+ console.log(paint(tr('Укажите путь к файлу или скачайте файл: /open [файл]', 'Specify file path or download a file first: /open [file]'), 'yellow'));
992
+ }
993
+ else {
994
+ const full = path.resolve(process.cwd(), target);
995
+ if (!fs.existsSync(full)) {
996
+ console.log(paint(tr(`Файл не найден: ${target}`, `File not found: ${target}`), 'red'));
997
+ }
998
+ else {
999
+ console.log(paint(tr(`Открываю ${path.basename(full)}…`, `Opening ${path.basename(full)}…`), 'gray'));
1000
+ await openWithDefaultApp(full);
1001
+ }
1002
+ }
1003
+ rep();
1004
+ continue;
898
1005
  }
899
- rlClosed = false;
900
- if (idx !== null) {
901
- model = normalizeOpusModelId(options[idx].id);
902
- saveModel(model);
903
- console.log(confirmModelLine(model));
1006
+ if (msg.startsWith('/get')) {
1007
+ const getUrl = msg.slice(4).trim();
1008
+ if (!getUrl) {
1009
+ console.log(paint(tr('Использование: /get <url>', 'Usage: /get <url>'), 'yellow'));
1010
+ }
1011
+ else {
1012
+ try {
1013
+ const saved = await downloadAttachment(server, token, getUrl, process.cwd(), undefined, false);
1014
+ if (saved) {
1015
+ lastDownloadedFile = saved.path;
1016
+ const rel = path.relative(process.cwd(), saved.path) || saved.path;
1017
+ console.log(paint(`✔ ${tr('Сохранено:', 'Saved:')} ${rel} (${saved.size} байт)`, 'green'));
1018
+ }
1019
+ }
1020
+ catch (e) {
1021
+ console.error(paint(tr(`Ошибка скачивания: ${e.message}`, `Download error: ${e.message}`), 'red'));
1022
+ }
1023
+ }
1024
+ rep();
1025
+ continue;
904
1026
  }
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) || '(задача выполнена)' });
1027
+ if (msg.startsWith('/')) {
1028
+ // Вызов скилла как slash-команды: /имя-скилла [текст задачи]
1029
+ const skillName = msg.slice(1).split(/\s+/)[0]?.toLowerCase() ?? '';
1030
+ const skillArgs = msg.slice(1 + skillName.length).trim();
1031
+ const skill = skillName ? loadSkill(codeCtx.cwd, skillName) : null;
1032
+ if (skill) {
1033
+ const taskText = skillArgs || tr('Следуй инструкциям навыка.', 'Follow the skill instructions.');
1034
+ console.log(paint(tr(`— навык ${skill.meta.name}: работаю…`, `— skill ${skill.meta.name}: working…`), 'gray'));
1035
+ const skillTask = `<skill name="${skill.meta.name}">\n${skill.instructions}\n</skill>\n\n${tr('Задача пользователя:', 'User task:')} ${taskText}`;
1036
+ const abortCtrl = new AbortController();
1037
+ activeAbortController = abortCtrl;
1038
+ try {
1039
+ const lastText = await runAgentTask(server, token, model, codeCtx, agentHistory, skillTask, 25, abortCtrl.signal);
1040
+ if (!abortCtrl.signal.aborted) {
1041
+ history.push({ role: 'user', text: msg });
1042
+ history.push({ role: 'ai', text: lastText.slice(0, 1500) || '(задача выполнена)' });
1043
+ }
1044
+ }
1045
+ catch (err) {
1046
+ if (abortCtrl.signal.aborted || err.name === 'AbortError') {
1047
+ rep();
1048
+ continue;
1049
+ }
1050
+ handleProcessError(err);
1051
+ }
1052
+ finally {
1053
+ activeAbortController = null;
1054
+ }
1055
+ rep();
1056
+ continue;
1057
+ }
1058
+ console.log(paint(tr('Неизвестная команда. /help — список.', 'Unknown command. /help lists commands.'), 'gray'));
920
1059
  rep();
921
1060
  continue;
922
1061
  }
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) {
1062
+ // Один режим на всё: похоже на задачу с файлами/кодом — агентный цикл
1063
+ // с инструментами прямо здесь; «!» в начале форсирует. Вложенные файлы
1064
+ // всегда идут обычным чатом.
1065
+ const forceAgent = msg.startsWith('!');
1066
+ if ((forceAgent || looksLikeCodeTask(msg)) && !pendingAttachments?.length) {
1067
+ const taskText = (forceAgent ? msg.slice(1) : msg).trim();
1068
+ if (!taskText) {
1069
+ rep();
1070
+ continue;
1071
+ }
1072
+ console.log(paint(tr('— агент: работаю с файлами…', '— agent: working with files…'), 'gray'));
1073
+ const abortCtrl = new AbortController();
1074
+ activeAbortController = abortCtrl;
1075
+ try {
1076
+ const lastText = await runAgentTask(server, token, model, codeCtx, agentHistory, taskText, 25, abortCtrl.signal);
1077
+ if (!abortCtrl.signal.aborted) {
1078
+ history.push({ role: 'user', text: msg });
1079
+ history.push({ role: 'ai', text: lastText.slice(0, 1500) || '(задача выполнена)' });
1080
+ }
1081
+ }
1082
+ catch (err) {
1083
+ if (abortCtrl.signal.aborted || err.name === 'AbortError') {
1084
+ rep();
1085
+ continue;
1086
+ }
1087
+ handleProcessError(err);
1088
+ }
1089
+ finally {
1090
+ activeAbortController = null;
1091
+ }
934
1092
  rep();
935
1093
  continue;
936
1094
  }
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) || '(задача выполнена)' });
1095
+ const attachments = pendingAttachments;
1096
+ pendingAttachments = undefined;
1097
+ const abortCtrl = new AbortController();
1098
+ activeAbortController = abortCtrl;
1099
+ try {
1100
+ const payload = await streamAnswer(server, token, { text: msg, history: history.slice(-10), model, ...(chatId ? { chatId } : {}), ...(attachments?.length ? { attachments } : {}) }, model, true, abortCtrl.signal);
1101
+ if (!abortCtrl.signal.aborted) {
1102
+ const savedFiles = await handleRichPayload(payload, server, token, false);
1103
+ if (savedFiles.length > 0) {
1104
+ lastDownloadedFile = savedFiles[savedFiles.length - 1];
1105
+ }
1106
+ history.push({ role: 'user', text: msg });
1107
+ history.push({ role: 'ai', text: String(payload.response || '') });
1108
+ const u = await api(server, token, 'GET', '/api/ai/usage').catch(() => null);
1109
+ const footer = answerFooter(modelDisplayName(model), u);
1110
+ if (footer)
1111
+ console.log(footer);
1112
+ }
1113
+ }
1114
+ catch (err) {
1115
+ if (abortCtrl.signal.aborted || err.name === 'AbortError') {
1116
+ rep();
1117
+ continue;
1118
+ }
1119
+ handleProcessError(err);
1120
+ if (err.status === 429)
1121
+ break;
1122
+ }
1123
+ finally {
1124
+ activeAbortController = null;
1125
+ }
941
1126
  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
1127
  }
961
- rep();
1128
+ }
1129
+ finally {
1130
+ process.removeListener('SIGINT', onSigint);
962
1131
  }
963
1132
  if (rl)
964
1133
  rl.close();
1134
+ closeMcpClients(codeCtx);
965
1135
  console.log(paint(tr('Пока!', 'Bye!'), 'accent'));
966
1136
  }
967
- /** Пул моделей для пикера: фильтр по префиксу/подстроке или весь список без auto. */
1137
+ /** Пул моделей для пикера: фильтр по префиксу/подстроке имени/id или весь список как в мессенджере. */
968
1138
  function modelPool(msg) {
969
1139
  const key = normModelKey(msg.slice('/model'.length).trim());
970
1140
  const pool = key
971
- ? PICKABLE_MODEL_IDS.filter(id => id.startsWith(key) || id.includes(key))
972
- : PICKABLE_MODEL_IDS;
1141
+ ? PICKABLE_MODEL_IDS.filter(id => id.startsWith(key) || id.includes(key) || normModelKey(modelDisplayName(id)).includes(key))
1142
+ : [...PICKABLE_MODEL_IDS];
973
1143
  if (key && pool.length === 0) {
974
1144
  console.log(paint(tr(`Модель «${key}» не найдена. Выберите из списка:`, `Model “${key}” was not found. Choose from the list:`), 'yellow'));
975
1145
  }
976
- return pool.length > 0 ? pool : PICKABLE_MODEL_IDS;
1146
+ return pool.length > 0 ? pool : [...PICKABLE_MODEL_IDS];
977
1147
  }
978
1148
  function modelOptions(msg, current) {
979
1149
  const pool = modelPool(msg);
980
- // Auto/fast/advanced обычно скрыты из ручного списка. Но если один из них
981
- // уже выбран, он должен быть отмечен текущим: иначе Enter в пикере молча
982
- // применяет первый видимый вариант (сейчас это GPT-5.6 Sol).
983
1150
  const ids = !msg.slice('/model'.length).trim() && OPUS_MODEL_IDS.includes(current) && !pool.includes(current)
984
1151
  ? [current, ...pool]
985
1152
  : pool;
986
- return ids.map(id => ({
987
- id,
988
- label: modelDisplayName(id),
989
- current: id === current,
990
- }));
1153
+ return ids.map(id => {
1154
+ const hintObj = MODEL_HINTS[id];
1155
+ return {
1156
+ id,
1157
+ label: modelDisplayName(id),
1158
+ hint: hintObj ? tr(hintObj.ru, hintObj.en) : undefined,
1159
+ current: id === current,
1160
+ };
1161
+ });
991
1162
  }
992
1163
  import { promptMenuLine, loadHistory, appendHistory, completeChatLine, CHAT_NO_ARG_COMMANDS, looksLikeCodeTask, } from './cliPrompt.js';
993
- import { CODE_TOOLS, createToolContext, executeAgentTool, } from './cliTools.js';
1164
+ import { CODE_TOOLS, createToolContext, executeAgentTool, initMcp, closeMcpClients, loadProjectEnv, } from './cliTools.js';
994
1165
  import { skillsCatalog, loadSkill, discoverSkills, ensureUserSkillsDir, } from './cliSkills.js';
995
1166
  /** Печать установленных скиллов: имя, источник, описание. */
996
1167
  function printSkills(cwd) {
@@ -1070,7 +1241,9 @@ async function pickModel(msg, current, readLine) {
1070
1241
  const key = normModelKey(msg.slice('/model'.length).trim());
1071
1242
  if (key && OPUS_MODEL_IDS.includes(key))
1072
1243
  return normalizeOpusModelId(key);
1073
- const pool = key ? PICKABLE_MODEL_IDS.filter(id => id.startsWith(key) || id.includes(key)) : PICKABLE_MODEL_IDS;
1244
+ const pool = key
1245
+ ? PICKABLE_MODEL_IDS.filter(id => id.startsWith(key) || id.includes(key) || normModelKey(modelDisplayName(id)).includes(key))
1246
+ : PICKABLE_MODEL_IDS;
1074
1247
  if (key && pool.length === 1)
1075
1248
  return normalizeOpusModelId(pool[0]);
1076
1249
  if (canUseArrows())
@@ -1092,45 +1265,193 @@ Rules:
1092
1265
  - Never commit, push, publish or delete user data unless explicitly asked. Never create files unless needed for the task.
1093
1266
  - If the task is unclear or destructive, ask first instead of guessing.
1094
1267
  - 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 };
1268
+ async function postAgentStream(server, token, body, handlers, stream, signal) {
1269
+ const res = await fetch(resolveUrl(server, '/api/cli/agent'), {
1270
+ method: 'POST',
1271
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
1272
+ body: JSON.stringify(stream ? { ...body, stream: true } : body),
1273
+ signal,
1274
+ });
1275
+ if (!res.ok) {
1276
+ const payload = await res.json().catch(() => ({}));
1277
+ const err = new Error(payload.error || `HTTP ${res.status}`);
1278
+ err.status = res.status;
1279
+ err.payload = payload;
1280
+ throw err;
1281
+ }
1282
+ const contentType = res.headers.get('content-type') || '';
1283
+ if (!stream || !contentType.includes('text/event-stream') || !res.body) {
1284
+ return res.json();
1285
+ }
1286
+ const reader = res.body.getReader();
1287
+ const decoder = new TextDecoder();
1288
+ let carry = '';
1289
+ let done = null;
1290
+ const handleBlock = (block) => {
1291
+ let event = 'message';
1292
+ const lines = [];
1293
+ for (const raw of block.split('\n')) {
1294
+ const line = raw.replace(/\r$/, '');
1295
+ if (line.startsWith('event:'))
1296
+ event = line.slice(6).trim();
1297
+ else if (line.startsWith('data:'))
1298
+ lines.push(line.slice(5).trimStart());
1299
+ }
1300
+ if (!lines.length)
1301
+ return;
1302
+ const data = JSON.parse(lines.join('\n'));
1303
+ if (event === 'delta' && typeof data.text === 'string')
1304
+ handlers.onDelta?.(data.text);
1305
+ else if (event === 'status' && typeof data.status === 'string')
1306
+ handlers.onStatus?.(data.status);
1307
+ else if (event === 'reset')
1308
+ handlers.onReset?.();
1309
+ else if (event === 'done')
1310
+ done = data;
1311
+ else if (event === 'error')
1312
+ throw new Error(data.error || 'agent stream error');
1313
+ };
1314
+ for (;;) {
1315
+ const { done: finished, value } = await reader.read();
1316
+ if (finished)
1317
+ break;
1318
+ carry += decoder.decode(value, { stream: true });
1319
+ const blocks = carry.split('\n\n');
1320
+ carry = blocks.pop() || '';
1321
+ for (const b of blocks)
1322
+ handleBlock(b);
1323
+ }
1324
+ if (carry.trim())
1325
+ handleBlock(carry);
1326
+ if (!done)
1327
+ throw new Error(tr('Стрим агента оборвался без done', 'Agent stream ended without done'));
1328
+ return done;
1329
+ }
1330
+ function ensureCompactionNotice(history) {
1331
+ if (history.length <= 2)
1332
+ return;
1333
+ if (history[2]?.role === 'user' && typeof history[2].content === 'string' && history[2].content.startsWith('[контекст сжат'))
1334
+ return;
1335
+ history.splice(2, 0, {
1336
+ role: 'user',
1337
+ content: '[контекст сжат: ранние промежуточные шаги и вывод инструментов удалены для экономии лимита]',
1338
+ });
1098
1339
  }
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)
1340
+ export function trimAgentHistory(history, maxLimit = 41) {
1341
+ // system всегда первый; начальное задание пользователя (индекс 1) сохраняем,
1342
+ // чтобы агент не забывал исходную цель задачи при длинных цепочках шагов.
1343
+ if (history.length <= maxLimit)
1344
+ return;
1345
+ while (history.length > maxLimit && history.length > 2) {
1346
+ let startIdx = 2;
1347
+ if (history.length > 3 &&
1348
+ history[2].role === 'user' &&
1349
+ typeof history[2].content === 'string' &&
1350
+ history[2].content.startsWith('[контекст сжат')) {
1351
+ startIdx = 3;
1352
+ }
1353
+ if (startIdx >= history.length)
1104
1354
  break;
1105
- history.splice(idx, 1);
1355
+ const candidate = history[startIdx];
1356
+ if (candidate.role === 'user') {
1357
+ history.splice(startIdx, 1);
1358
+ ensureCompactionNotice(history);
1359
+ continue;
1360
+ }
1361
+ if (candidate.role === 'assistant') {
1362
+ const toolCallIds = new Set((candidate.tool_calls || []).map(tc => tc.id).filter(Boolean));
1363
+ let deleteCount = 1;
1364
+ while (startIdx + deleteCount < history.length) {
1365
+ const next = history[startIdx + deleteCount];
1366
+ if (next.role === 'tool' && (toolCallIds.size === 0 || (next.tool_call_id && toolCallIds.has(next.tool_call_id)))) {
1367
+ deleteCount += 1;
1368
+ }
1369
+ else {
1370
+ break;
1371
+ }
1372
+ }
1373
+ history.splice(startIdx, deleteCount);
1374
+ ensureCompactionNotice(history);
1375
+ continue;
1376
+ }
1377
+ // Если вдруг встретилось одинокое tool без assistant — удаляем его для валидности
1378
+ if (candidate.role === 'tool') {
1379
+ history.splice(startIdx, 1);
1380
+ ensureCompactionNotice(history);
1381
+ continue;
1382
+ }
1383
+ break;
1106
1384
  }
1107
1385
  }
1108
- async function runAgentTask(server, token, model, ctx, history, userText, maxIters) {
1386
+ async function runAgentTask(server, token, model, ctx, history, userText, maxIters, signal) {
1387
+ ctx.abortSignal = signal;
1109
1388
  history.push({ role: 'user', content: userText });
1110
1389
  trimAgentHistory(history);
1111
1390
  let emptyStreak = 0;
1112
1391
  let lastText = '';
1113
1392
  const skills = skillsCatalog(ctx.cwd);
1393
+ const mcpLines = ctx.mcpTools.map(t => `- ${t.function.name}: ${t.function.description}`.slice(0, 200));
1114
1394
  const systemWithSkills = CODE_SYSTEM(ctx.cwd, process.platform) + (skills
1115
1395
  ? `\n\nУстановленные Agent Skills (модульные возможности):\n${skills}\n\nЕсли задача подходит под описание навыка — СНАЧАЛА вызови инструмент skill (action=read, name=<имя>) и строго следуй его инструкциям, включая запуск приложенных скриптов через bash. Актуальный список — через skill action=list.`
1396
+ : '') + (mcpLines.length > 0
1397
+ ? `\n\nПодключённые MCP-серверы, их инструменты уже доступны как mcp__server__tool:\n${mcpLines.join('\n')}\nИспользуй их когда уместно; это сторонние действия — подтверждение спросят отдельно.`
1116
1398
  : '');
1117
1399
  if (history[0]?.role === 'system')
1118
1400
  history[0] = { role: 'system', content: systemWithSkills };
1119
1401
  else
1120
1402
  history.unshift({ role: 'system', content: systemWithSkills });
1121
1403
  for (let iter = 1; iter <= maxIters; iter += 1) {
1404
+ if (signal?.aborted)
1405
+ break;
1122
1406
  const spinner = new Spinner();
1123
1407
  spinner.start(tr('Думаю…', 'Thinking…'));
1124
1408
  let turn;
1409
+ let headed = false;
1410
+ let receivedDelta = false;
1411
+ const md = new MdStream();
1412
+ const ensureHead = () => {
1413
+ if (headed)
1414
+ return;
1415
+ headed = true;
1416
+ spinner.stop();
1417
+ process.stdout.write(`${assistantHead(modelDisplayName(model))}\n`);
1418
+ };
1125
1419
  try {
1126
- turn = await agentTurn(server, token, model, history);
1420
+ turn = await postAgentStream(server, token, { model, messages: history, tools: [...CODE_TOOLS, ...ctx.mcpTools] }, {
1421
+ onStatus: s => {
1422
+ if (s === 'calling_tool') {
1423
+ if (headed) {
1424
+ const tail = md.flush();
1425
+ if (tail)
1426
+ process.stdout.write(tail);
1427
+ process.stdout.write('\n');
1428
+ }
1429
+ spinner.start(tr('Вызываю инструмент…', 'Calling tool…'));
1430
+ }
1431
+ else {
1432
+ spinner.update(statusLabel(s));
1433
+ }
1434
+ },
1435
+ onDelta: t => {
1436
+ receivedDelta = true;
1437
+ ensureHead();
1438
+ process.stdout.write(md.push(t));
1439
+ },
1440
+ }, true, signal);
1127
1441
  }
1128
1442
  catch (err) {
1129
1443
  spinner.stop();
1444
+ if (signal?.aborted || err.name === 'AbortError') {
1445
+ return lastText;
1446
+ }
1130
1447
  handleProcessError(err);
1131
1448
  return lastText;
1132
1449
  }
1133
- spinner.stop();
1450
+ finally {
1451
+ spinner.stop();
1452
+ }
1453
+ if (signal?.aborted)
1454
+ break;
1134
1455
  const msg = turn.message;
1135
1456
  history.push({
1136
1457
  role: 'assistant',
@@ -1139,9 +1460,14 @@ async function runAgentTask(server, token, model, ctx, history, userText, maxIte
1139
1460
  });
1140
1461
  if (msg.content) {
1141
1462
  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');
1463
+ if (!receivedDelta) {
1464
+ ensureHead();
1465
+ process.stdout.write(md.push(msg.content));
1466
+ }
1467
+ const tail = md.flush();
1468
+ if (tail)
1469
+ process.stdout.write(tail);
1470
+ process.stdout.write('\n');
1145
1471
  }
1146
1472
  const calls = msg.tool_calls ?? [];
1147
1473
  if (calls.length === 0) {
@@ -1159,6 +1485,8 @@ async function runAgentTask(server, token, model, ctx, history, userText, maxIte
1159
1485
  }
1160
1486
  emptyStreak = 0;
1161
1487
  for (const call of calls) {
1488
+ if (signal?.aborted)
1489
+ break;
1162
1490
  const rawArgs = call.function.arguments;
1163
1491
  const argsPreview = rawArgs.length > 200 ? `${rawArgs.slice(0, 200)}…` : rawArgs;
1164
1492
  console.log(` ${paint('tool', 'gray')} ${paint(call.function.name, 'white')} ${paint(argsPreview, 'gray')}`);
@@ -1172,6 +1500,136 @@ async function runAgentTask(server, token, model, ctx, history, userText, maxIte
1172
1500
  }
1173
1501
  return lastText;
1174
1502
  }
1503
+ import { loadMcpConfig, loadMcpFile, writeMcpServer, removeMcpServer, connectMcpServer, userMcpPath, projectMcpPath, validMcpName, } from './cliMcp.js';
1504
+ async function cmdMcp(args) {
1505
+ loadProjectEnv(process.cwd());
1506
+ const sub = args[0];
1507
+ const scopeArg = getArgValue(args, '--scope');
1508
+ const scope = scopeArg === 'project' ? 'project' : 'user';
1509
+ const scopeFile = scope === 'project' ? projectMcpPath(process.cwd()) : userMcpPath();
1510
+ if (!sub || sub === 'list') {
1511
+ const { servers, invalid } = loadMcpConfig(process.cwd());
1512
+ const names = Object.keys(servers);
1513
+ if (names.length === 0) {
1514
+ console.log(paint(tr('MCP-серверов нет. Добавить: surf mcp add <имя> -- <команда> [аргументы]', 'No MCP servers. Add one: surf mcp add <name> -- <command> [args]'), 'gray'));
1515
+ return;
1516
+ }
1517
+ const projectOnly = loadMcpFile(projectMcpPath(process.cwd())).servers;
1518
+ for (const name of names) {
1519
+ const cfg = servers[name];
1520
+ const scopeMark = name in projectOnly ? 'project' : 'user';
1521
+ const transportMark = cfg.url ? 'sse' : 'stdio';
1522
+ const detail = cfg.url ? cfg.url : `${cfg.command ?? ''} ${(cfg.args ?? []).join(' ')}`.trim();
1523
+ console.log(` ${paint(name, 'white')} ${paint(`[${scopeMark}]`, 'gray')} ${paint(`[${transportMark}]`, 'blue')} ${paint(detail, 'gray')}`);
1524
+ }
1525
+ if (invalid > 0)
1526
+ console.log(paint(tr(`Пропущено битых записей: ${invalid}`, `Skipped invalid entries: ${invalid}`), 'yellow'));
1527
+ return;
1528
+ }
1529
+ if (sub === 'add') {
1530
+ const name = args[1];
1531
+ if (!name || !validMcpName(name))
1532
+ throw new Error(tr('Нужно имя: surf mcp add <имя> ...', 'Name required: surf mcp add <name> ...'));
1533
+ const urlArg = getArgValue(args, '--url');
1534
+ const timeoutRaw = getArgValue(args, '--timeout');
1535
+ if (urlArg) {
1536
+ if (!/^https?:\/\/.+/i.test(urlArg))
1537
+ throw new Error(tr('Некорректный URL. Нужен http:// или https://', 'Invalid URL. http:// or https:// required'));
1538
+ const headers = {};
1539
+ for (let i = 0; i < args.length; i += 1) {
1540
+ if (args[i] === '--header' && i + 1 < args.length) {
1541
+ const h = args[i + 1];
1542
+ const colon = h.indexOf(':');
1543
+ const eq = h.indexOf('=');
1544
+ const sep = colon > 0 ? colon : eq;
1545
+ if (sep > 0) {
1546
+ headers[h.slice(0, sep).trim()] = h.slice(sep + 1).trim();
1547
+ }
1548
+ }
1549
+ }
1550
+ const cfg = { url: urlArg };
1551
+ if (Object.keys(headers).length > 0)
1552
+ cfg.headers = headers;
1553
+ if (timeoutRaw)
1554
+ cfg.timeout = Math.min(600000, Math.max(1000, Number(timeoutRaw) || 60000));
1555
+ writeMcpServer(scopeFile, name, cfg);
1556
+ console.log(`${paint(tr('MCP-сервер добавлен (SSE):', 'MCP server added (SSE):'), 'green')} ${paint(name, 'white')} ${paint(`(${scope})`, 'gray')}`);
1557
+ console.log(paint(tr('Проверь: surf mcp test <имя>', 'Verify: surf mcp test <name>').replace('<имя>', name).replace('<name>', name), 'gray'));
1558
+ return;
1559
+ }
1560
+ const dash = args.indexOf('--');
1561
+ if (dash < 0 || dash + 1 >= args.length)
1562
+ throw new Error(tr('Нужна команда после -- или флаг --url: surf mcp add github -- npx -y ...', 'Command required after -- or use --url: surf mcp add github -- npx -y ...'));
1563
+ const [command, ...cmdArgs] = args.slice(dash + 1);
1564
+ const envVars = {};
1565
+ for (let i = 0; i < args.length; i += 1) {
1566
+ if (args[i] === '--env' && i + 1 < args.length && i + 1 < dash) {
1567
+ const kv = args[i + 1];
1568
+ const eq = kv.indexOf('=');
1569
+ if (eq > 0)
1570
+ envVars[kv.slice(0, eq)] = kv.slice(eq + 1);
1571
+ }
1572
+ }
1573
+ const cwdRaw = getArgValue(args, '--cwd');
1574
+ const cfg = { command };
1575
+ if (cmdArgs.length > 0)
1576
+ cfg.args = cmdArgs;
1577
+ if (Object.keys(envVars).length > 0)
1578
+ cfg.env = envVars;
1579
+ if (cwdRaw)
1580
+ cfg.cwd = cwdRaw;
1581
+ if (timeoutRaw)
1582
+ cfg.timeout = Math.min(600000, Math.max(1000, Number(timeoutRaw) || 60000));
1583
+ writeMcpServer(scopeFile, name, cfg);
1584
+ console.log(`${paint(tr('MCP-сервер добавлен (stdio):', 'MCP server added (stdio):'), 'green')} ${paint(name, 'white')} ${paint(`(${scope})`, 'gray')}`);
1585
+ console.log(paint(tr('Проверь: surf mcp test <имя>', 'Verify: surf mcp test <name>').replace('<имя>', name).replace('<name>', name), 'gray'));
1586
+ return;
1587
+ }
1588
+ if (sub === 'remove') {
1589
+ const name = args[1];
1590
+ if (!name)
1591
+ throw new Error(tr('Нужно имя: surf mcp remove <имя>', 'Name required: surf mcp remove <name>'));
1592
+ const fromScope = scopeArg ? removeMcpServer(scopeFile, name) : (removeMcpServer(scopeFile, name) || removeMcpServer(scope === 'project' ? userMcpPath() : projectMcpPath(process.cwd()), name));
1593
+ if (!fromScope)
1594
+ throw new Error(tr(`Сервер «${name}» не найден`, `Server "${name}" not found`));
1595
+ console.log(`${paint(tr('MCP-сервер удалён:', 'MCP server removed:'), 'green')} ${paint(name, 'white')}`);
1596
+ return;
1597
+ }
1598
+ if (sub === 'test') {
1599
+ const name = args[1];
1600
+ if (!name)
1601
+ throw new Error(tr('Нужно имя: surf mcp test <имя>', 'Name required: surf mcp test <name>'));
1602
+ const { servers } = loadMcpConfig(process.cwd());
1603
+ const cfg = servers[name];
1604
+ if (!cfg)
1605
+ throw new Error(tr(`Сервер «${name}» не найден`, `Server "${name}" not found`));
1606
+ console.log(paint(tr('Подключаюсь…', 'Connecting…'), 'gray'));
1607
+ const client = await connectMcpServer(name, cfg);
1608
+ try {
1609
+ if (client.tools.length === 0) {
1610
+ console.log(paint(tr('Подключено, инструментов нет', 'Connected, no tools'), 'yellow'));
1611
+ }
1612
+ else {
1613
+ console.log(`${paint(tr('Инструментов:', 'Tools:'), 'green')} ${client.tools.length}`);
1614
+ for (const t of client.tools.slice(0, 30)) {
1615
+ console.log(` ${paint(t.name, 'white')} ${paint((t.description || '').slice(0, 100), 'gray')}`);
1616
+ }
1617
+ }
1618
+ }
1619
+ finally {
1620
+ client.close();
1621
+ }
1622
+ return;
1623
+ }
1624
+ console.log([
1625
+ paint(tr('Использование:', 'Usage:'), 'white'),
1626
+ ` surf mcp ${tr('список серверов', 'list servers')}`,
1627
+ ` surf mcp add <имя> --url <url> [--header "K: V" ...] [--timeout MS]`,
1628
+ ` surf mcp add <имя> [--scope user|project] [--env K=V ...] [--timeout MS] [--cwd DIR] -- <команда> [аргументы]`,
1629
+ ` surf mcp remove <имя> [--scope user|project]`,
1630
+ ` surf mcp test <имя>`,
1631
+ ].join('\n'));
1632
+ }
1175
1633
  async function main() {
1176
1634
  const [, , cmd, ...args] = process.argv;
1177
1635
  loadSavedAccent();
@@ -1226,6 +1684,10 @@ async function main() {
1226
1684
  await cmdGet(args);
1227
1685
  return;
1228
1686
  }
1687
+ if (cmd === 'mcp') {
1688
+ await cmdMcp(args);
1689
+ return;
1690
+ }
1229
1691
  console.error(tr(`Неизвестная команда: ${cmd}\n`, `Unknown command: ${cmd}\n`));
1230
1692
  printHelp();
1231
1693
  process.exitCode = 1;
@@ -1235,5 +1697,24 @@ async function main() {
1235
1697
  process.exitCode = 1;
1236
1698
  }
1237
1699
  }
1238
- void main();
1700
+ const isMain = () => {
1701
+ if (!process.argv[1])
1702
+ return false;
1703
+ try {
1704
+ const thisFile = path.resolve(fileURLToPath(import.meta.url));
1705
+ const runFile = path.resolve(process.argv[1]);
1706
+ if (runFile === thisFile)
1707
+ return true;
1708
+ if (runFile.replace(/\.js$/, '.ts') === thisFile || thisFile.replace(/\.js$/, '.ts') === runFile)
1709
+ return true;
1710
+ const baseRun = path.basename(runFile);
1711
+ return baseRun === 'surf' || baseRun === 'opus' || baseRun === 'cli.ts' || baseRun === 'cli.js';
1712
+ }
1713
+ catch {
1714
+ return false;
1715
+ }
1716
+ };
1717
+ if (isMain()) {
1718
+ void main();
1719
+ }
1239
1720
  //# sourceMappingURL=cli.js.map