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/README.md +7 -3
- package/cli.js +740 -259
- package/cliMcp.js +553 -0
- package/cliPrompt.js +104 -17
- package/cliTools.js +333 -13
- package/cliUi.js +77 -22
- package/opusModels.js +169 -16
- package/package.json +2 -1
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.
|
|
20
|
-
|
|
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
|
|
593
|
-
const
|
|
594
|
-
if (!
|
|
595
|
-
console.error(`Не удалось скачать ${raw}
|
|
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
|
|
600
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
723
|
-
|
|
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
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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
|
-
|
|
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
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
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 (
|
|
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
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
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
|
-
|
|
871
|
-
|
|
941
|
+
if (msg === '/skills') {
|
|
942
|
+
printSkills(codeCtx.cwd);
|
|
943
|
+
rep();
|
|
944
|
+
continue;
|
|
872
945
|
}
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
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 (
|
|
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
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
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
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
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
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
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
|
-
|
|
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
|
-
/** Пул моделей для пикера: фильтр по префиксу/подстроке или весь список
|
|
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
|
-
|
|
989
|
-
|
|
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
|
|
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
|
|
1096
|
-
const
|
|
1097
|
-
|
|
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
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
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
|
-
|
|
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
|