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/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
- eraseDrawn();
141
- stdout.write(`\r\x1b[${pw + buffer.length}C\n`);
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 rows = [`${prompt}${paintTokens(buffer)}`];
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 = menuOpen ? Math.min(matches.length, MAX_ROWS) : 0;
171
- const up = rows.length - 1;
172
- if (up > 0)
173
- stdout.write(`\x1b[${up}A`);
174
- stdout.write(`\r\x1b[${pw + cursor}C`);
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 s = buf.toString('utf8');
243
- switch (s) {
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
- moveHist(-1);
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
- moveHist(1);
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
- done(null);
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 (s.charCodeAt(0) === 0x1b)
450
+ if (cleanInput.charCodeAt(0) === 0x1b)
370
451
  return;
371
452
  // Одиночные управляющие — игнор; остальное (включая вставки) — в буфер.
372
- if (s.length === 1 && (s.charCodeAt(0) < 0x20 || s.charCodeAt(0) === 0x7f))
453
+ if (cleanInput.length === 1 && (cleanInput.charCodeAt(0) < 0x20 || cleanInput.charCodeAt(0) === 0x7f))
373
454
  return;
374
- const clean = [...s].filter(c => {
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
- export function createToolContext(cwd, yesAll, permissionMode = 'important') {
126
- return { cwd: path.resolve(cwd), yesAll, permissionMode, readFiles: new Set() };
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
- if (oldString === newString)
364
- return Promise.resolve('ошибка: oldString и newString одинаковые');
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(oldString);
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 = oldString.split('\n');
376
- const newBlock = newString.split('\n');
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(oldString).join(newString) : content.replace(oldString, newString);
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
- exec(command, { cwd: workdir, timeout, maxBuffer: 1024 * 1024, windowsHide: true }, (err, stdout, stderr) => {
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
  }