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/cliPrompt.js
CHANGED
|
@@ -53,6 +53,38 @@ function stripAnsi(text) {
|
|
|
53
53
|
}
|
|
54
54
|
return out;
|
|
55
55
|
}
|
|
56
|
+
export function getCursorPos(text, cursor) {
|
|
57
|
+
const safeCursor = Math.max(0, Math.min(cursor, text.length));
|
|
58
|
+
const before = text.slice(0, safeCursor);
|
|
59
|
+
const lines = before.split('\n');
|
|
60
|
+
return {
|
|
61
|
+
line: lines.length - 1,
|
|
62
|
+
col: lines[lines.length - 1].length,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export function getCursorFromPos(text, pos) {
|
|
66
|
+
const lines = text.split('\n');
|
|
67
|
+
const targetLine = Math.max(0, Math.min(pos.line, lines.length - 1));
|
|
68
|
+
let idx = 0;
|
|
69
|
+
for (let i = 0; i < targetLine; i += 1) {
|
|
70
|
+
idx += lines[i].length + 1;
|
|
71
|
+
}
|
|
72
|
+
const targetCol = Math.max(0, Math.min(pos.col, lines[targetLine].length));
|
|
73
|
+
return idx + targetCol;
|
|
74
|
+
}
|
|
75
|
+
export function isNewlineKey(s) {
|
|
76
|
+
return (s === '\n' || // LF (Ctrl+J, or standard Ctrl+Enter in many terminals)
|
|
77
|
+
s === '\x1b\r' || // Alt+Enter / Meta+Enter
|
|
78
|
+
s === '\x1b\n' || // Alt+Ctrl+Enter
|
|
79
|
+
s === '\x1b[13;5u' || // Kitty keyboard protocol: Ctrl+Enter
|
|
80
|
+
s === '\x1b[13;2u' || // Kitty keyboard protocol: Shift+Enter
|
|
81
|
+
s === '\x1b[13;3u' || // Kitty keyboard protocol: Alt+Enter
|
|
82
|
+
s === '\x1b[27;5;13~' || // xterm / win32 modified: Ctrl+Enter
|
|
83
|
+
s === '\x1b[27;2;13~' || // xterm / win32 modified: Shift+Enter
|
|
84
|
+
s === '\x1b[27;3;13~' || // xterm / win32 modified: Alt+Enter
|
|
85
|
+
s === '\x0e' // Ctrl+N (convenient fallback)
|
|
86
|
+
);
|
|
87
|
+
}
|
|
56
88
|
/**
|
|
57
89
|
* Одна строка ввода с живыми подсказками.
|
|
58
90
|
* Возвращает текст и вложения из дропа (токены [image N]/[file N] в тексте).
|
|
@@ -124,6 +156,7 @@ export async function promptMenuLine(opts) {
|
|
|
124
156
|
return null;
|
|
125
157
|
};
|
|
126
158
|
let finish = null;
|
|
159
|
+
let cursorRow = 0;
|
|
127
160
|
const done = (value) => {
|
|
128
161
|
stdin.removeListener('data', onData);
|
|
129
162
|
try {
|
|
@@ -131,19 +164,32 @@ export async function promptMenuLine(opts) {
|
|
|
131
164
|
}
|
|
132
165
|
catch { /* уже не raw */ }
|
|
133
166
|
stdin.pause();
|
|
167
|
+
stdout.write('\x1b[?2004l'); // отключаем bracketed paste
|
|
134
168
|
stdout.write('\x1b[?25h');
|
|
135
169
|
if (value === null) {
|
|
136
170
|
eraseDrawn();
|
|
137
171
|
stdout.write('\r\x1b[2K');
|
|
138
172
|
}
|
|
139
173
|
else {
|
|
140
|
-
|
|
141
|
-
|
|
174
|
+
if (menuOpen) {
|
|
175
|
+
menuOpen = false;
|
|
176
|
+
render();
|
|
177
|
+
}
|
|
178
|
+
const bufLines = buffer.split('\n');
|
|
179
|
+
const targetRow = bufLines.length - 1;
|
|
180
|
+
const distDown = targetRow - cursorRow;
|
|
181
|
+
if (distDown > 0)
|
|
182
|
+
stdout.write(`\x1b[${distDown}B`);
|
|
183
|
+
stdout.write(`\r\x1b[${pw + bufLines[targetRow].length}C\n`);
|
|
142
184
|
stdout.write('\x1b[J');
|
|
143
185
|
}
|
|
144
186
|
finish?.({ line: value, attachments });
|
|
145
187
|
};
|
|
146
188
|
const eraseDrawn = () => {
|
|
189
|
+
if (cursorRow > 0) {
|
|
190
|
+
stdout.write(`\x1b[${cursorRow}A\r`);
|
|
191
|
+
cursorRow = 0;
|
|
192
|
+
}
|
|
147
193
|
if (drawn === 0)
|
|
148
194
|
return;
|
|
149
195
|
let s = '';
|
|
@@ -155,7 +201,12 @@ export async function promptMenuLine(opts) {
|
|
|
155
201
|
};
|
|
156
202
|
const render = () => {
|
|
157
203
|
eraseDrawn();
|
|
158
|
-
const
|
|
204
|
+
const bufLines = buffer.split('\n');
|
|
205
|
+
const indent = ' '.repeat(pw);
|
|
206
|
+
const rows = bufLines.map((line, idx) => {
|
|
207
|
+
const pfx = idx === 0 ? prompt : indent;
|
|
208
|
+
return `${pfx}${paintTokens(line)}`;
|
|
209
|
+
});
|
|
159
210
|
if (menuOpen) {
|
|
160
211
|
matches.slice(menuOffset, menuOffset + MAX_ROWS).forEach((m, row) => {
|
|
161
212
|
const i = menuOffset + row;
|
|
@@ -167,11 +218,13 @@ export async function promptMenuLine(opts) {
|
|
|
167
218
|
});
|
|
168
219
|
}
|
|
169
220
|
stdout.write(`\r${rows.map(l => `\x1b[K${l}`).join('\n')}`);
|
|
170
|
-
drawn =
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
221
|
+
drawn = rows.length - 1;
|
|
222
|
+
const pos = getCursorPos(buffer, cursor);
|
|
223
|
+
cursorRow = pos.line;
|
|
224
|
+
const distUp = (rows.length - 1) - cursorRow;
|
|
225
|
+
if (distUp > 0)
|
|
226
|
+
stdout.write(`\x1b[${distUp}A`);
|
|
227
|
+
stdout.write(`\r\x1b[${pw + pos.col}C`);
|
|
175
228
|
};
|
|
176
229
|
const insert = (text) => {
|
|
177
230
|
buffer = buffer.slice(0, cursor) + text + buffer.slice(cursor);
|
|
@@ -180,6 +233,10 @@ export async function promptMenuLine(opts) {
|
|
|
180
233
|
refreshMenu();
|
|
181
234
|
render();
|
|
182
235
|
};
|
|
236
|
+
const insertNewline = () => {
|
|
237
|
+
menuOpen = false;
|
|
238
|
+
insert('\n');
|
|
239
|
+
};
|
|
183
240
|
const submit = () => {
|
|
184
241
|
if (menuOpen && matches[sel] && buffer !== matches[sel].value) {
|
|
185
242
|
// Как в Claude: Enter дополняет недопечатанное, а не отправляет.
|
|
@@ -239,10 +296,14 @@ export async function promptMenuLine(opts) {
|
|
|
239
296
|
render();
|
|
240
297
|
};
|
|
241
298
|
function onData(buf) {
|
|
242
|
-
const
|
|
243
|
-
|
|
299
|
+
const raw = buf.toString('utf8');
|
|
300
|
+
const cleanInput = raw.replace(/\x1b\[200~/g, '').replace(/\x1b\[201~/g, '');
|
|
301
|
+
if (isNewlineKey(cleanInput)) {
|
|
302
|
+
insertNewline();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
switch (cleanInput) {
|
|
244
306
|
case '\r':
|
|
245
|
-
case '\n':
|
|
246
307
|
submit();
|
|
247
308
|
return;
|
|
248
309
|
case '\t':
|
|
@@ -283,7 +344,14 @@ export async function promptMenuLine(opts) {
|
|
|
283
344
|
render();
|
|
284
345
|
}
|
|
285
346
|
else {
|
|
286
|
-
|
|
347
|
+
const pos = getCursorPos(buffer, cursor);
|
|
348
|
+
if (pos.line > 0) {
|
|
349
|
+
cursor = getCursorFromPos(buffer, { line: pos.line - 1, col: pos.col });
|
|
350
|
+
render();
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
moveHist(-1);
|
|
354
|
+
}
|
|
287
355
|
}
|
|
288
356
|
return;
|
|
289
357
|
case '\x1b[B':
|
|
@@ -294,7 +362,15 @@ export async function promptMenuLine(opts) {
|
|
|
294
362
|
render();
|
|
295
363
|
}
|
|
296
364
|
else {
|
|
297
|
-
|
|
365
|
+
const pos = getCursorPos(buffer, cursor);
|
|
366
|
+
const bufLines = buffer.split('\n');
|
|
367
|
+
if (pos.line < bufLines.length - 1) {
|
|
368
|
+
cursor = getCursorFromPos(buffer, { line: pos.line + 1, col: pos.col });
|
|
369
|
+
render();
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
moveHist(1);
|
|
373
|
+
}
|
|
298
374
|
}
|
|
299
375
|
return;
|
|
300
376
|
case '\x1b[C':
|
|
@@ -355,7 +431,12 @@ export async function promptMenuLine(opts) {
|
|
|
355
431
|
killWord();
|
|
356
432
|
return;
|
|
357
433
|
case '\x03':
|
|
358
|
-
|
|
434
|
+
if (buffer.length > 0) {
|
|
435
|
+
resetLine('');
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
done(null);
|
|
439
|
+
}
|
|
359
440
|
return;
|
|
360
441
|
case '\x04':
|
|
361
442
|
if (buffer.length === 0)
|
|
@@ -366,12 +447,15 @@ export async function promptMenuLine(opts) {
|
|
|
366
447
|
}
|
|
367
448
|
// Неизвестные escape-последовательности (F1-F12 и т.п.) — игнор целиком,
|
|
368
449
|
// иначе их хвост впечатается в строку (как было с PgUp → «[5~»).
|
|
369
|
-
if (
|
|
450
|
+
if (cleanInput.charCodeAt(0) === 0x1b)
|
|
370
451
|
return;
|
|
371
452
|
// Одиночные управляющие — игнор; остальное (включая вставки) — в буфер.
|
|
372
|
-
if (
|
|
453
|
+
if (cleanInput.length === 1 && (cleanInput.charCodeAt(0) < 0x20 || cleanInput.charCodeAt(0) === 0x7f))
|
|
373
454
|
return;
|
|
374
|
-
const
|
|
455
|
+
const normalized = cleanInput.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
456
|
+
const clean = [...normalized].filter(c => {
|
|
457
|
+
if (c === '\n')
|
|
458
|
+
return true;
|
|
375
459
|
const n = c.codePointAt(0) ?? 0;
|
|
376
460
|
return (n >= 0x20 && n < 0x7f) || n >= 0xa0;
|
|
377
461
|
}).join('');
|
|
@@ -403,6 +487,7 @@ export async function promptMenuLine(opts) {
|
|
|
403
487
|
stdin.on('data', onData);
|
|
404
488
|
stdin.setRawMode(true);
|
|
405
489
|
stdin.resume();
|
|
490
|
+
stdout.write('\x1b[?2004h'); // включаем bracketed paste
|
|
406
491
|
stdout.write('\x1b[?25l');
|
|
407
492
|
});
|
|
408
493
|
}
|
|
@@ -410,6 +495,8 @@ export const CHAT_COMMANDS = [
|
|
|
410
495
|
{ value: '/usage', desc: { ru: 'общий лимит', en: 'shared limit' }, takesArg: false },
|
|
411
496
|
{ value: '/model', desc: { ru: 'выбор модели', en: 'choose a model' }, takesArg: true },
|
|
412
497
|
{ value: '/permissions', desc: { ru: 'разрешения агента', en: 'agent permissions' }, takesArg: false },
|
|
498
|
+
{ value: '/open', desc: { ru: 'открыть файл', en: 'open file' }, takesArg: true },
|
|
499
|
+
{ value: '/get', desc: { ru: 'скачать файл', en: 'download file' }, takesArg: true },
|
|
413
500
|
{ value: '/clear', desc: { ru: 'очистить историю', en: 'clear history' }, takesArg: false },
|
|
414
501
|
{ value: '/help', desc: { ru: 'команды', en: 'commands' }, takesArg: false },
|
|
415
502
|
{ value: '/skills', desc: { ru: 'навыки агента', en: 'agent skills' }, takesArg: false },
|
package/cliTools.js
CHANGED
|
@@ -11,6 +11,7 @@ import readline from 'node:readline/promises';
|
|
|
11
11
|
import { stdin as input, stdout as output } from 'node:process';
|
|
12
12
|
import { paint } from './cliUi.js';
|
|
13
13
|
import { discoverSkills, loadSkill, builtinSkillsDir, userSkillsDir, projectSkillsDir } from './cliSkills.js';
|
|
14
|
+
import { loadMcpConfig, connectMcpServer } from './cliMcp.js';
|
|
14
15
|
export const CODE_TOOLS = [
|
|
15
16
|
{
|
|
16
17
|
type: 'function',
|
|
@@ -122,8 +123,231 @@ export const CODE_TOOLS = [
|
|
|
122
123
|
},
|
|
123
124
|
},
|
|
124
125
|
];
|
|
125
|
-
|
|
126
|
-
|
|
126
|
+
/**
|
|
127
|
+
* Парсинг содержимого .env файла:
|
|
128
|
+
* - Игнорирует пустые строки и комментарии (#)
|
|
129
|
+
* - Поддерживает KEY=VALUE, KEY="VAL\nUE", KEY='VALUE'
|
|
130
|
+
* - Отрезает инлайн-комментарии (#) для значений без кавычек
|
|
131
|
+
*/
|
|
132
|
+
export function parseEnv(content) {
|
|
133
|
+
const result = {};
|
|
134
|
+
const lines = content.split(/\r?\n/);
|
|
135
|
+
for (const rawLine of lines) {
|
|
136
|
+
const line = rawLine.trim();
|
|
137
|
+
if (!line || line.startsWith('#'))
|
|
138
|
+
continue;
|
|
139
|
+
const eqIdx = line.indexOf('=');
|
|
140
|
+
if (eqIdx <= 0)
|
|
141
|
+
continue;
|
|
142
|
+
const key = line.slice(0, eqIdx).trim();
|
|
143
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
144
|
+
continue;
|
|
145
|
+
let val = line.slice(eqIdx + 1).trim();
|
|
146
|
+
if ((val.startsWith('"') && val.endsWith('"') && val.length >= 2) ||
|
|
147
|
+
(val.startsWith("'") && val.endsWith("'") && val.length >= 2)) {
|
|
148
|
+
const isDouble = val.startsWith('"');
|
|
149
|
+
val = val.slice(1, -1);
|
|
150
|
+
if (isDouble) {
|
|
151
|
+
val = val.replace(/\\n/g, '\n').replace(/\\r/g, '\r').replace(/\\t/g, '\t');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
const hashIdx = val.indexOf(' #');
|
|
156
|
+
if (hashIdx >= 0) {
|
|
157
|
+
val = val.slice(0, hashIdx).trim();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
result[key] = val;
|
|
161
|
+
}
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Подгрузка .env и .env.local из папки проекта в process.env без перезаписи существующих переменных.
|
|
166
|
+
*/
|
|
167
|
+
export function loadProjectEnv(cwd) {
|
|
168
|
+
const loaded = {};
|
|
169
|
+
const envFiles = ['.env', '.env.local'];
|
|
170
|
+
for (const file of envFiles) {
|
|
171
|
+
const fullPath = path.resolve(cwd, file);
|
|
172
|
+
try {
|
|
173
|
+
if (fs.existsSync(fullPath)) {
|
|
174
|
+
const text = fs.readFileSync(fullPath, 'utf8');
|
|
175
|
+
const parsed = parseEnv(text);
|
|
176
|
+
for (const [key, val] of Object.entries(parsed)) {
|
|
177
|
+
if (!(key in process.env)) {
|
|
178
|
+
process.env[key] = val;
|
|
179
|
+
loaded[key] = val;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
catch { /* игнорируем ошибки чтения .env */ }
|
|
185
|
+
}
|
|
186
|
+
return loaded;
|
|
187
|
+
}
|
|
188
|
+
function findWindowsGitBash() {
|
|
189
|
+
if (process.platform !== 'win32')
|
|
190
|
+
return null;
|
|
191
|
+
const candidates = [
|
|
192
|
+
'C:\\Program Files\\Git\\bin\\bash.exe',
|
|
193
|
+
'C:\\Program Files\\Git\\usr\\bin\\bash.exe',
|
|
194
|
+
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
|
|
195
|
+
'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe',
|
|
196
|
+
];
|
|
197
|
+
const localAppData = process.env.LOCALAPPDATA;
|
|
198
|
+
if (localAppData) {
|
|
199
|
+
candidates.push(path.join(localAppData, 'Programs', 'Git', 'bin', 'bash.exe'));
|
|
200
|
+
candidates.push(path.join(localAppData, 'Programs', 'Git', 'usr', 'bin', 'bash.exe'));
|
|
201
|
+
}
|
|
202
|
+
for (const c of candidates) {
|
|
203
|
+
try {
|
|
204
|
+
if (fs.existsSync(c))
|
|
205
|
+
return c;
|
|
206
|
+
}
|
|
207
|
+
catch { /* ignore */ }
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
function findWindowsPowerShell() {
|
|
212
|
+
if (process.platform !== 'win32')
|
|
213
|
+
return null;
|
|
214
|
+
const sysRoot = process.env.SystemRoot || 'C:\\Windows';
|
|
215
|
+
const winPs = path.join(sysRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
216
|
+
try {
|
|
217
|
+
if (fs.existsSync(winPs))
|
|
218
|
+
return winPs;
|
|
219
|
+
}
|
|
220
|
+
catch { /* ignore */ }
|
|
221
|
+
return 'powershell.exe';
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Определение оптимальной оболочки для запуска команд.
|
|
225
|
+
* На Windows отдаёт предпочтение Git Bash (для поддержки POSIX-команд моделей LLM:
|
|
226
|
+
* export, grep, rm -rf, ls -la, touch, cat, pipes), затем PowerShell Core / Windows PowerShell, затем cmd.exe.
|
|
227
|
+
* На Unix использует process.env.SHELL или /bin/sh.
|
|
228
|
+
*/
|
|
229
|
+
export function resolveShell(customShell) {
|
|
230
|
+
if (customShell && customShell.trim()) {
|
|
231
|
+
const trimmed = customShell.trim();
|
|
232
|
+
const lower = trimmed.toLowerCase();
|
|
233
|
+
if (lower === 'bash' || lower.endsWith('bash.exe') || lower.endsWith('/bash')) {
|
|
234
|
+
const found = findWindowsGitBash();
|
|
235
|
+
return { shell: found || (process.platform === 'win32' ? 'bash.exe' : '/bin/bash'), isBash: true, type: 'bash' };
|
|
236
|
+
}
|
|
237
|
+
if (lower === 'powershell' || lower === 'powershell.exe') {
|
|
238
|
+
return { shell: 'powershell.exe', isBash: false, type: 'powershell' };
|
|
239
|
+
}
|
|
240
|
+
if (lower === 'pwsh' || lower === 'pwsh.exe') {
|
|
241
|
+
return { shell: 'pwsh.exe', isBash: false, type: 'pwsh' };
|
|
242
|
+
}
|
|
243
|
+
if (lower === 'cmd' || lower === 'cmd.exe') {
|
|
244
|
+
return { shell: process.env.ComSpec || 'cmd.exe', isBash: false, type: 'cmd' };
|
|
245
|
+
}
|
|
246
|
+
const isBash = lower.includes('bash');
|
|
247
|
+
return { shell: trimmed, isBash, type: isBash ? 'bash' : 'custom' };
|
|
248
|
+
}
|
|
249
|
+
if (process.platform !== 'win32') {
|
|
250
|
+
const sh = process.env.SHELL || '/bin/sh';
|
|
251
|
+
return { shell: sh, isBash: sh.includes('bash'), type: 'unix' };
|
|
252
|
+
}
|
|
253
|
+
const gitBash = findWindowsGitBash();
|
|
254
|
+
if (gitBash) {
|
|
255
|
+
return { shell: gitBash, isBash: true, type: 'bash' };
|
|
256
|
+
}
|
|
257
|
+
const psPath = findWindowsPowerShell();
|
|
258
|
+
if (psPath) {
|
|
259
|
+
return { shell: psPath, isBash: false, type: psPath.includes('pwsh') ? 'pwsh' : 'powershell' };
|
|
260
|
+
}
|
|
261
|
+
return { shell: process.env.ComSpec || 'cmd.exe', isBash: false, type: 'cmd' };
|
|
262
|
+
}
|
|
263
|
+
export function createToolContext(cwd, yesAll, permissionMode = 'important', shell) {
|
|
264
|
+
loadProjectEnv(cwd);
|
|
265
|
+
return { cwd: path.resolve(cwd), yesAll, permissionMode, readFiles: new Set(), mcpClients: new Map(), mcpTools: [], shell };
|
|
266
|
+
}
|
|
267
|
+
/** Имя инструмента MCP в общем пространстве: mcp__server__tool. */
|
|
268
|
+
export function mcpToolName(server, tool) {
|
|
269
|
+
return `mcp__${server}__${tool}`;
|
|
270
|
+
}
|
|
271
|
+
function splitMcpName(name) {
|
|
272
|
+
const m = name.match(/^mcp__([A-Za-z0-9][A-Za-z0-9_-]*)__(.+)$/);
|
|
273
|
+
if (!m)
|
|
274
|
+
return null;
|
|
275
|
+
return { server: m[1], tool: m[2] };
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Подключить все настроенные MCP-серверы, собрать их инструменты.
|
|
279
|
+
* Вызывать раз за сессию чата; падающие серверы пропускаем с предупреждением.
|
|
280
|
+
*/
|
|
281
|
+
export async function initMcp(ctx) {
|
|
282
|
+
const { servers } = loadMcpConfig(ctx.cwd);
|
|
283
|
+
const names = Object.keys(servers);
|
|
284
|
+
if (names.length === 0)
|
|
285
|
+
return [];
|
|
286
|
+
const defs = [];
|
|
287
|
+
for (const name of names) {
|
|
288
|
+
const cfg = servers[name];
|
|
289
|
+
try {
|
|
290
|
+
const client = await connectMcpServer(name, cfg);
|
|
291
|
+
ctx.mcpClients.set(name, client);
|
|
292
|
+
for (const tool of client.tools) {
|
|
293
|
+
let schemaJson = '';
|
|
294
|
+
try {
|
|
295
|
+
schemaJson = JSON.stringify(tool.inputSchema);
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (schemaJson.length > 7000) {
|
|
301
|
+
console.error(`${paint('MCP', 'gray')} ${paint(name, 'white')}: инструмент ${tool.name} пропущен (схема > 7КБ)`);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
defs.push({
|
|
305
|
+
type: 'function',
|
|
306
|
+
function: {
|
|
307
|
+
name: mcpToolName(name, tool.name),
|
|
308
|
+
description: `[MCP ${name}] ${tool.description || tool.name}`.slice(0, 1900),
|
|
309
|
+
parameters: tool.inputSchema,
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
console.error(`${paint('MCP', 'gray')} ${paint(name, 'white')}: ${paint(`${client.tools.length} инструментов`, 'gray')}`);
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
console.error(`${paint('MCP', 'gray')} ${paint(name, 'white')}: ${paint(`не подключился: ${err.message}`, 'yellow')}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
ctx.mcpTools = defs;
|
|
320
|
+
return defs;
|
|
321
|
+
}
|
|
322
|
+
export function closeMcpClients(ctx) {
|
|
323
|
+
for (const [, client] of ctx.mcpClients) {
|
|
324
|
+
try {
|
|
325
|
+
client.close();
|
|
326
|
+
}
|
|
327
|
+
catch { /* ignore */ }
|
|
328
|
+
}
|
|
329
|
+
ctx.mcpClients.clear();
|
|
330
|
+
}
|
|
331
|
+
async function toolMcpCall(ctx, server, tool, args) {
|
|
332
|
+
const client = ctx.mcpClients.get(server);
|
|
333
|
+
if (!client)
|
|
334
|
+
return `ошибка: MCP-сервер «${server}» не подключён`;
|
|
335
|
+
const cfg = loadMcpConfig(ctx.cwd).servers[server];
|
|
336
|
+
const timeoutMs = cfg?.timeout ?? 60000;
|
|
337
|
+
const run = () => client.call(tool, args, timeoutMs);
|
|
338
|
+
if (!requiresApproval(ctx, 'mcp'))
|
|
339
|
+
return run();
|
|
340
|
+
if (!process.stdin.isTTY)
|
|
341
|
+
return 'отклонено: неинтерактивный режим без --yes';
|
|
342
|
+
const decision = await askApproval(`MCP ${server}.${tool} — выполнить?`, ctx);
|
|
343
|
+
if (decision === 'no')
|
|
344
|
+
return 'отклонено пользователем';
|
|
345
|
+
try {
|
|
346
|
+
return await run();
|
|
347
|
+
}
|
|
348
|
+
catch (err) {
|
|
349
|
+
return `ошибка MCP: ${err.message}`;
|
|
350
|
+
}
|
|
127
351
|
}
|
|
128
352
|
const RESULT_CAP = 12000;
|
|
129
353
|
function truncateResult(text) {
|
|
@@ -323,6 +547,9 @@ function requiresApproval(ctx, kind, bashCommand) {
|
|
|
323
547
|
return false;
|
|
324
548
|
if (ctx.permissionMode === 'always')
|
|
325
549
|
return true;
|
|
550
|
+
// В режиме «только важное»: MCP — сторонние сайд-эффекты, всегда спрашиваем.
|
|
551
|
+
if (kind === 'mcp')
|
|
552
|
+
return true;
|
|
326
553
|
return kind === 'bash' && !!bashCommand && isImportantShellCommand(bashCommand);
|
|
327
554
|
}
|
|
328
555
|
async function askApproval(question, ctx) {
|
|
@@ -344,6 +571,60 @@ async function askApproval(question, ctx) {
|
|
|
344
571
|
rl.close();
|
|
345
572
|
}
|
|
346
573
|
}
|
|
574
|
+
export function matchAndNormalizeEdit(content, oldString, newString) {
|
|
575
|
+
if (oldString === newString)
|
|
576
|
+
return { error: 'oldString и newString одинаковые' };
|
|
577
|
+
// 1. Прямая проверка точного совпадения
|
|
578
|
+
const directCount = content.split(oldString).length - 1;
|
|
579
|
+
if (directCount > 0) {
|
|
580
|
+
return { matchedOld: oldString, matchedNew: newString, occurrences: directCount };
|
|
581
|
+
}
|
|
582
|
+
// 2. Нормализация окончаний строк CRLF (\r\n) <-> LF (\n)
|
|
583
|
+
const isCrlf = content.includes('\r\n');
|
|
584
|
+
const normContent = content.replace(/\r\n/g, '\n');
|
|
585
|
+
const normOld = oldString.replace(/\r\n/g, '\n');
|
|
586
|
+
const normNew = newString.replace(/\r\n/g, '\n');
|
|
587
|
+
const normCount = normContent.split(normOld).length - 1;
|
|
588
|
+
if (normCount > 0) {
|
|
589
|
+
const targetOld = isCrlf ? normOld.replace(/\n/g, '\r\n') : normOld;
|
|
590
|
+
const targetNew = isCrlf ? normNew.replace(/\n/g, '\r\n') : normNew;
|
|
591
|
+
const targetCount = content.split(targetOld).length - 1;
|
|
592
|
+
if (targetCount > 0) {
|
|
593
|
+
return { matchedOld: targetOld, matchedNew: targetNew, occurrences: targetCount };
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
// 3. Нечувствительность к хвостовым пробелам в строках (trailing whitespace)
|
|
597
|
+
const stripTrailing = (s) => s.split('\n').map(l => l.trimEnd()).join('\n');
|
|
598
|
+
const strippedNormOld = stripTrailing(normOld);
|
|
599
|
+
const strippedNormContent = stripTrailing(normContent);
|
|
600
|
+
if (strippedNormContent.includes(strippedNormOld)) {
|
|
601
|
+
const contentLines = normContent.split('\n');
|
|
602
|
+
const oldLines = normOld.split('\n');
|
|
603
|
+
const matches = [];
|
|
604
|
+
for (let i = 0; i <= contentLines.length - oldLines.length; i += 1) {
|
|
605
|
+
let matched = true;
|
|
606
|
+
for (let j = 0; j < oldLines.length; j += 1) {
|
|
607
|
+
if (contentLines[i + j].trimEnd() !== oldLines[j].trimEnd()) {
|
|
608
|
+
matched = false;
|
|
609
|
+
break;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
if (matched) {
|
|
613
|
+
const rawSlice = contentLines.slice(i, i + oldLines.length).join(isCrlf ? '\r\n' : '\n');
|
|
614
|
+
matches.push(rawSlice);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (matches.length > 0) {
|
|
618
|
+
const first = matches[0];
|
|
619
|
+
const targetNew = isCrlf ? normNew.replace(/\n/g, '\r\n') : normNew;
|
|
620
|
+
const exactCount = content.split(first).length - 1;
|
|
621
|
+
if (exactCount > 0) {
|
|
622
|
+
return { matchedOld: first, matchedNew: targetNew, occurrences: exactCount };
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return { error: 'oldString не найден' };
|
|
627
|
+
}
|
|
347
628
|
function toolEdit(ctx, args) {
|
|
348
629
|
const abs = resolveInCwd(ctx, String(args.path ?? ''));
|
|
349
630
|
const oldString = typeof args.oldString === 'string' ? args.oldString : null;
|
|
@@ -360,23 +641,22 @@ function toolEdit(ctx, args) {
|
|
|
360
641
|
}
|
|
361
642
|
if (!ctx.readFiles.has(abs))
|
|
362
643
|
return Promise.resolve('ошибка: сначала прочитайте файл инструментом read');
|
|
363
|
-
|
|
364
|
-
|
|
644
|
+
const matchRes = matchAndNormalizeEdit(content, oldString, newString);
|
|
645
|
+
if ('error' in matchRes)
|
|
646
|
+
return Promise.resolve(`ошибка: ${matchRes.error}`);
|
|
647
|
+
const { matchedOld, matchedNew, occurrences } = matchRes;
|
|
365
648
|
const replaceAll = args.replaceAll === true;
|
|
366
|
-
const occurrences = content.split(oldString).length - 1;
|
|
367
|
-
if (occurrences === 0)
|
|
368
|
-
return Promise.resolve('ошибка: oldString не найден');
|
|
369
649
|
if (occurrences > 1 && !replaceAll) {
|
|
370
650
|
return Promise.resolve(`ошибка: найдено совпадений: ${occurrences}, уточните контекст или используйте replaceAll`);
|
|
371
651
|
}
|
|
372
|
-
const at = content.indexOf(
|
|
652
|
+
const at = content.indexOf(matchedOld);
|
|
373
653
|
const before = content.slice(0, at).split('\n');
|
|
374
654
|
const startLine = Math.max(0, before.length - 4);
|
|
375
|
-
const oldBlock =
|
|
376
|
-
const newBlock =
|
|
655
|
+
const oldBlock = matchedOld.split('\n');
|
|
656
|
+
const newBlock = matchedNew.split('\n');
|
|
377
657
|
const preview = diffPreview(content.split('\n'), startLine, oldBlock, newBlock);
|
|
378
658
|
const apply = () => {
|
|
379
|
-
const next = replaceAll ? content.split(
|
|
659
|
+
const next = replaceAll ? content.split(matchedOld).join(matchedNew) : content.replace(matchedOld, matchedNew);
|
|
380
660
|
fs.writeFileSync(abs, next);
|
|
381
661
|
ctx.readFiles.add(abs);
|
|
382
662
|
return `готово: заменено ${replaceAll ? occurrences : 1} в ${path.relative(ctx.cwd, abs)}`;
|
|
@@ -432,8 +712,21 @@ function toolBash(ctx, args) {
|
|
|
432
712
|
return Promise.resolve(`ошибка: ${err.message}`);
|
|
433
713
|
}
|
|
434
714
|
}
|
|
715
|
+
const { shell } = resolveShell(ctx.shell);
|
|
435
716
|
const run = () => new Promise(resolve => {
|
|
436
|
-
|
|
717
|
+
let killed = false;
|
|
718
|
+
const child = exec(command, {
|
|
719
|
+
cwd: workdir,
|
|
720
|
+
timeout,
|
|
721
|
+
maxBuffer: 1024 * 1024,
|
|
722
|
+
windowsHide: true,
|
|
723
|
+
shell,
|
|
724
|
+
env: { ...process.env },
|
|
725
|
+
}, (err, stdout, stderr) => {
|
|
726
|
+
if (killed) {
|
|
727
|
+
resolve('команда прервана пользователем (Ctrl+C)');
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
437
730
|
const out = `${stdout || ''}${stderr ? `\n[stderr]\n${stderr}` : ''}`.trim().slice(0, 30000);
|
|
438
731
|
if (err) {
|
|
439
732
|
const code = err.code;
|
|
@@ -443,6 +736,26 @@ function toolBash(ctx, args) {
|
|
|
443
736
|
resolve(out || '(пустой вывод)');
|
|
444
737
|
}
|
|
445
738
|
});
|
|
739
|
+
if (ctx.abortSignal) {
|
|
740
|
+
const onAbort = () => {
|
|
741
|
+
killed = true;
|
|
742
|
+
try {
|
|
743
|
+
if (process.platform === 'win32' && child.pid) {
|
|
744
|
+
exec(`taskkill /pid ${child.pid} /T /F`, { windowsHide: true }, () => { });
|
|
745
|
+
}
|
|
746
|
+
else {
|
|
747
|
+
child.kill('SIGINT');
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
catch { /* ignore */ }
|
|
751
|
+
};
|
|
752
|
+
if (ctx.abortSignal.aborted) {
|
|
753
|
+
onAbort();
|
|
754
|
+
}
|
|
755
|
+
else {
|
|
756
|
+
ctx.abortSignal.addEventListener('abort', onAbort, { once: true });
|
|
757
|
+
}
|
|
758
|
+
}
|
|
446
759
|
});
|
|
447
760
|
if (!requiresApproval(ctx, 'bash', command))
|
|
448
761
|
return run();
|
|
@@ -472,6 +785,14 @@ export async function executeAgentTool(ctx, call) {
|
|
|
472
785
|
return 'ошибка: arguments не JSON';
|
|
473
786
|
}
|
|
474
787
|
try {
|
|
788
|
+
if (call.name === 'skill')
|
|
789
|
+
return truncateResult(toolSkill(ctx, args));
|
|
790
|
+
if (call.name.startsWith('mcp__')) {
|
|
791
|
+
const split = splitMcpName(call.name);
|
|
792
|
+
if (!split)
|
|
793
|
+
return `ошибка: неверное имя MCP-инструмента ${call.name}`;
|
|
794
|
+
return truncateResult(await toolMcpCall(ctx, split.server, split.tool, args));
|
|
795
|
+
}
|
|
475
796
|
switch (call.name) {
|
|
476
797
|
case 'read': return truncateResult(toolRead(ctx, args));
|
|
477
798
|
case 'glob': return truncateResult(toolGlob(ctx, args));
|
|
@@ -479,7 +800,6 @@ export async function executeAgentTool(ctx, call) {
|
|
|
479
800
|
case 'edit': return truncateResult(await toolEdit(ctx, args));
|
|
480
801
|
case 'write': return truncateResult(await toolWrite(ctx, args));
|
|
481
802
|
case 'bash': return truncateResult(await toolBash(ctx, args));
|
|
482
|
-
case 'skill': return truncateResult(toolSkill(ctx, args));
|
|
483
803
|
default: return `ошибка: неизвестный инструмент ${call.name}`;
|
|
484
804
|
}
|
|
485
805
|
}
|