@remcp/runtime 0.2.0 → 0.2.4

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.
@@ -1,29 +1,52 @@
1
1
  import path from 'node:path';
2
- import { constants } from 'node:fs';
3
- import { access, copyFile, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { createHash } from 'node:crypto';
5
+ import { constants, createReadStream } from 'node:fs';
6
+ import { access, copyFile, cp, mkdir, open, readFile, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
7
+ import { pipeline } from 'node:stream/promises';
4
8
  import { runtimeConfig } from '../config.mjs';
9
+ import { diffStats, unifiedDiff } from '../diff.mjs';
5
10
  import { countEvent, recordEvent } from '../telemetry.mjs';
6
- import { clampInteger, displayPath, fail, looksBinary, pageLines, resolveSafePath, splitLines, text } from '../util.mjs';
11
+ import { clampInteger, decodeText, displayPath, fail, globToRegExp, image, looksBinary, multi, pageLines, resolveSafePath, splitLines, text } from '../util.mjs';
7
12
 
8
13
  const MAX_INLINE_FILE_BYTES = 5 * 1024 * 1024;
14
+ const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
15
+ const MAX_BINARY_CHUNK_BYTES = 512 * 1024;
16
+ const IMAGE_TYPES = new Map([
17
+ ['.png', 'image/png'], ['.jpg', 'image/jpeg'], ['.jpeg', 'image/jpeg'], ['.gif', 'image/gif'],
18
+ ['.webp', 'image/webp'], ['.bmp', 'image/bmp'], ['.svg', 'image/svg+xml'], ['.avif', 'image/avif'],
19
+ ]);
20
+
21
+ function detectEol(content) {
22
+ const crlf = (content.match(/\r\n/g) || []).length;
23
+ const lf = (content.match(/(?<!\r)\n/g) || []).length;
24
+ return crlf > lf ? '\r\n' : '\n';
25
+ }
9
26
 
10
27
  async function readTextFile(absolute) {
11
28
  const info = await stat(absolute).catch(() => fail(`File not found: ${displayPath(absolute)}`));
12
29
  if (info.isDirectory()) fail(`${displayPath(absolute)} is a directory, not a file`);
13
30
  if (info.size > MAX_INLINE_FILE_BYTES) fail(`File is too large to read inline (${info.size} bytes)`);
14
31
  const buffer = await readFile(absolute);
15
- if (looksBinary(buffer)) fail(`${displayPath(absolute)} looks like a binary file and cannot be read as text`);
16
- return { info, content: buffer.toString('utf8') };
32
+ const decoded = decodeText(buffer);
33
+ if (decoded.encoding === 'utf8' && looksBinary(buffer)) {
34
+ fail(`${displayPath(absolute)} looks like a binary file and cannot be read as text. Use read_image for images, or get_file_info and hash_file for other binaries.`);
35
+ }
36
+ return { info, content: decoded.text, encoding: decoded.encoding, eol: detectEol(decoded.text) };
17
37
  }
18
38
 
19
39
  export async function readFileTool(args) {
20
40
  const absolute = await resolveSafePath(args.path);
21
- const { content } = await readTextFile(absolute);
41
+ const { content, encoding, eol } = await readTextFile(absolute);
22
42
  const lines = splitLines(content);
23
43
  const offset = Number.isFinite(Number(args.offset)) ? Math.trunc(Number(args.offset)) : 0;
24
44
  const length = clampInteger(args.length, runtimeConfig.maxReadLines, 1, 10000);
25
45
  const { start, end, slice } = pageLines(lines, offset, length);
26
- const header = lines.length ? `${displayPath(absolute)} (lines ${start + 1}-${end} of ${lines.length})` : `${displayPath(absolute)} (empty file)`;
46
+ const notes = `${encoding === 'utf8' ? '' : ` ${encoding}`}${eol === '\r\n' ? ' CRLF' : ''}`;
47
+ const header = lines.length
48
+ ? `${displayPath(absolute)} (lines ${start + 1}-${end} of ${lines.length}${notes})`
49
+ : `${displayPath(absolute)} (empty file)`;
27
50
  return text(`${header}\n${slice.join('\n')}`);
28
51
  }
29
52
 
@@ -53,18 +76,54 @@ export async function readMultipleFilesTool(args) {
53
76
  return text(sections.join('\n\n'));
54
77
  }
55
78
 
56
- async function listEntry(base, depth, maxDepth, prefix) {
57
- const entries = await readdir(base, { withFileTypes: true });
79
+ export async function readImageTool(args) {
80
+ const absolute = await resolveSafePath(args.path);
81
+ const info = await stat(absolute).catch(() => fail(`File not found: ${displayPath(absolute)}`));
82
+ if (info.isDirectory()) fail(`${displayPath(absolute)} is a directory, not an image`);
83
+ if (info.size > MAX_IMAGE_BYTES) fail(`Image is ${info.size} bytes, above the ${MAX_IMAGE_BYTES}-byte inline limit`);
84
+ const mimeType = IMAGE_TYPES.get(path.extname(absolute).toLowerCase());
85
+ if (!mimeType) fail(`${displayPath(absolute)} is not a supported image type (${[...IMAGE_TYPES.keys()].join(', ')})`);
86
+ if (mimeType === 'image/svg+xml') {
87
+ const { content } = await readTextFile(absolute);
88
+ return text(`SVG image ${displayPath(absolute)} (${info.size} bytes):\n${content}`);
89
+ }
90
+ const buffer = await readFile(absolute);
91
+ return multi([
92
+ { type: 'text', text: `${displayPath(absolute)} — ${mimeType}, ${info.size} bytes` },
93
+ image(buffer.toString('base64'), mimeType),
94
+ ]);
95
+ }
96
+
97
+ export async function hashFileTool(args) {
98
+ const absolute = await resolveSafePath(args.path);
99
+ const info = await stat(absolute).catch(() => fail(`File not found: ${displayPath(absolute)}`));
100
+ if (info.isDirectory()) fail(`${displayPath(absolute)} is a directory, not a file`);
101
+ const algorithm = String(args.algorithm || 'sha256').toLowerCase();
102
+ if (!['sha256', 'sha1', 'md5'].includes(algorithm)) fail('algorithm must be sha256, sha1, or md5');
103
+ const hash = createHash(algorithm);
104
+ await pipeline(createReadStream(absolute), hash);
105
+ return text(`${algorithm} ${hash.digest('hex')} ${displayPath(absolute)} (${info.size} bytes)`);
106
+ }
107
+
108
+ async function listEntry(base, depth, maxDepth, prefix, pattern) {
109
+ let entries;
110
+ try {
111
+ entries = await readdir(base, { withFileTypes: true });
112
+ } catch (error) {
113
+ // One unreadable subdirectory must not abort the whole listing.
114
+ return [`${prefix}[DENIED] ${path.basename(base)} (${error instanceof Error ? error.code || error.message : 'unreadable'})`];
115
+ }
58
116
  entries.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name));
59
117
  const rows = [];
60
118
  for (const entry of entries) {
61
119
  const child = path.join(base, entry.name);
62
120
  if (entry.isDirectory()) {
63
121
  rows.push(`${prefix}[DIR] ${entry.name}`);
64
- if (depth < maxDepth) rows.push(...await listEntry(child, depth + 1, maxDepth, `${prefix} `.replace(/ {2}$/, '') + ' '));
122
+ if (depth < maxDepth) rows.push(...await listEntry(child, depth + 1, maxDepth, `${prefix} `.replace(/ {2}$/, '') + ' ', pattern));
65
123
  } else if (entry.isSymbolicLink()) {
66
124
  rows.push(`${prefix}[LINK] ${entry.name}`);
67
125
  } else {
126
+ if (pattern && !pattern.test(entry.name)) continue;
68
127
  const info = await stat(child).catch(() => null);
69
128
  rows.push(`${prefix}[FILE] ${entry.name}${info ? ` (${info.size} bytes)` : ''}`);
70
129
  }
@@ -77,8 +136,9 @@ export async function listDirectoryTool(args) {
77
136
  const info = await stat(absolute).catch(() => fail(`Path not found: ${displayPath(absolute)}`));
78
137
  if (!info.isDirectory()) fail(`${displayPath(absolute)} is not a directory`);
79
138
  const depth = clampInteger(args.depth, 1, 1, 5);
80
- const rows = await listEntry(absolute, 1, depth, '');
81
- return text(`${displayPath(absolute)}\n${rows.join('\n') || '(empty)'}`);
139
+ const pattern = typeof args.pattern === 'string' && args.pattern.trim() ? globToRegExp(args.pattern.trim()) : null;
140
+ const rows = await listEntry(absolute, 1, depth, '', pattern);
141
+ return text(`${displayPath(absolute)}${pattern ? ` · matching ${args.pattern}` : ''}\n${rows.join('\n') || '(empty)'}`);
82
142
  }
83
143
 
84
144
  export async function getFileInfoTool(args) {
@@ -94,10 +154,15 @@ export async function getFileInfoTool(args) {
94
154
  };
95
155
  if (info.isFile() && info.size <= MAX_INLINE_FILE_BYTES) {
96
156
  const buffer = await readFile(absolute).catch(() => null);
97
- if (buffer && !looksBinary(buffer)) {
98
- const lines = splitLines(buffer.toString('utf8'));
99
- payload.lineCount = lines.length;
100
- payload.lastLine = Math.max(0, lines.length - 1);
157
+ if (buffer) {
158
+ const decoded = decodeText(buffer);
159
+ if (decoded.encoding !== 'utf8') payload.encoding = decoded.encoding;
160
+ if (decoded.encoding !== 'utf8' || !looksBinary(buffer)) {
161
+ const lines = splitLines(decoded.text);
162
+ payload.lineCount = lines.length;
163
+ payload.lastLine = Math.max(0, lines.length - 1);
164
+ payload.eol = detectEol(decoded.text) === '\r\n' ? 'CRLF' : 'LF';
165
+ }
101
166
  }
102
167
  }
103
168
  return text(JSON.stringify(payload, null, 2));
@@ -116,15 +181,80 @@ function assertWritableSize(content) {
116
181
  export async function writeFileTool(args) {
117
182
  const absolute = await resolveSafePath(args.path);
118
183
  const content = typeof args.content === 'string' ? args.content : fail('content must be a string');
119
- const mode = String(args.mode || 'rewrite').toLowerCase();
120
- if (!['rewrite', 'append'].includes(mode)) fail('mode must be rewrite or append');
121
- const bytes = assertWritableSize(content);
184
+ const providedMode = typeof args.mode === 'string' && args.mode.trim() ? args.mode.trim().toLowerCase() : '';
185
+ if (providedMode && !['rewrite', 'append'].includes(providedMode)) fail('mode must be rewrite or append');
186
+ const mode = providedMode || 'rewrite';
187
+ const bytes = Buffer.byteLength(content, 'utf8');
188
+ if (mode === 'rewrite') {
189
+ assertWritableSize(content);
190
+ if (content.includes('\0')) fail('content contains NUL bytes. For binary data pass encoding: "base64" (or use write_binary) so the file is written byte for byte.');
191
+ } else {
192
+ const existing = await stat(absolute).catch(() => null);
193
+ const existingSize = existing?.isFile() ? existing.size : 0;
194
+ if (existingSize + bytes > runtimeConfig.maxWriteBytes) {
195
+ countEvent('writeDenials');
196
+ recordEvent('write_denied', { reason: 'size_limit' });
197
+ fail(`Appending ${bytes} bytes would grow ${displayPath(absolute)} to ${existingSize + bytes} bytes, above the ${runtimeConfig.maxWriteBytes}-byte write limit for this device`);
198
+ }
199
+ }
122
200
  await mkdir(path.dirname(absolute), { recursive: true });
123
201
  await writeFile(absolute, content, mode === 'append' ? { encoding: 'utf8', flag: 'a' } : 'utf8');
124
202
  countEvent('bytesWritten', bytes);
125
203
  return text(`${mode === 'append' ? 'Appended' : 'Wrote'} ${bytes} bytes to ${displayPath(absolute)}.`);
126
204
  }
127
205
 
206
+ // Binary transfer in both directions, in chunks: the relay carries MCP results, so a
207
+ // large file is read as a sequence of base64 slices and written back the same way.
208
+ export async function readBinaryTool(args) {
209
+ const absolute = await resolveSafePath(args.path);
210
+ const info = await stat(absolute).catch(() => fail(`File not found: ${displayPath(absolute)}`));
211
+ if (info.isDirectory()) fail(`${displayPath(absolute)} is a directory, not a file`);
212
+ const offset = Math.max(0, Number.isFinite(Number(args.offset_bytes)) ? Math.trunc(Number(args.offset_bytes)) : 0);
213
+ const length = clampInteger(args.length_bytes, MAX_BINARY_CHUNK_BYTES, 1, MAX_BINARY_CHUNK_BYTES);
214
+ const start = Math.min(offset, info.size);
215
+ const end = Math.min(info.size, start + length);
216
+ const handle = await open(absolute, 'r');
217
+ try {
218
+ const buffer = Buffer.alloc(end - start);
219
+ if (buffer.length) await handle.read(buffer, 0, buffer.length, start);
220
+ const payload = JSON.stringify({
221
+ path: displayPath(absolute),
222
+ size: info.size,
223
+ offsetBytes: start,
224
+ lengthBytes: buffer.length,
225
+ nextOffsetBytes: end < info.size ? end : null,
226
+ complete: end >= info.size,
227
+ encoding: 'base64',
228
+ data: buffer.toString('base64'),
229
+ });
230
+ return text(payload);
231
+ } finally {
232
+ await handle.close();
233
+ }
234
+ }
235
+
236
+ export async function writeBinaryTool(args) {
237
+ const absolute = await resolveSafePath(args.path);
238
+ const data = typeof args.data === 'string' ? args.data : fail('data must be a base64 string');
239
+ const mode = String(args.mode || 'rewrite').toLowerCase();
240
+ if (!['rewrite', 'append'].includes(mode)) fail('mode must be rewrite or append');
241
+ let buffer;
242
+ try {
243
+ buffer = Buffer.from(data.replace(/\s+/g, ''), 'base64');
244
+ } catch {
245
+ fail('data must be valid base64');
246
+ }
247
+ if (buffer.length > runtimeConfig.maxWriteBytes) {
248
+ countEvent('writeDenials');
249
+ recordEvent('write_denied', { reason: 'size_limit' });
250
+ fail(`Decoded content is ${buffer.length} bytes, above the ${runtimeConfig.maxWriteBytes}-byte write limit for this device`);
251
+ }
252
+ await mkdir(path.dirname(absolute), { recursive: true });
253
+ await writeFile(absolute, buffer, mode === 'append' ? { flag: 'a' } : undefined);
254
+ countEvent('bytesWritten', buffer.length);
255
+ return text(`${mode === 'append' ? 'Appended' : 'Wrote'} ${buffer.length} bytes to ${displayPath(absolute)}.`);
256
+ }
257
+
128
258
  function normalizeForFuzzy(value) {
129
259
  return splitLines(value).map(line => line.replace(/[ \t]+/g, ' ').trim());
130
260
  }
@@ -152,15 +282,23 @@ export async function editBlockTool(args) {
152
282
  if (!oldString) fail('old_string must not be empty');
153
283
  if (oldString === newString) fail('old_string and new_string are identical');
154
284
  const allowFuzzy = args.allow_fuzzy !== false;
285
+ const dryRun = args.dry_run === true;
155
286
  const expected = Number.isInteger(Number(args.expected_replacements)) ? Math.max(1, Math.trunc(Number(args.expected_replacements))) : 1;
156
- const { content } = await readTextFile(absolute);
287
+ const { content, eol } = await readTextFile(absolute);
157
288
  const occurrences = content.split(oldString).length - 1;
289
+
290
+ const present = (updated, how) => {
291
+ const stats = diffStats(content, updated);
292
+ const summary = `${how} in ${displayPath(absolute)} (+${stats.added}/-${stats.removed} lines)`;
293
+ if (!dryRun) return `${summary}.`;
294
+ return `${summary}\n(dry run: nothing was written)\n${unifiedDiff(content, updated, { oldLabel: displayPath(absolute), newLabel: 'after' })}`;
295
+ };
296
+
158
297
  if (occurrences === expected) {
159
298
  const updated = content.split(oldString).join(newString);
160
299
  assertWritableSize(updated);
161
- await writeFile(absolute, updated, 'utf8');
162
- const delta = splitLines(updated).length - splitLines(content).length;
163
- return text(`Replaced ${occurrences} occurrence(s) in ${displayPath(absolute)} (${delta >= 0 ? '+' : ''}${delta} lines).`);
300
+ if (!dryRun) await writeFile(absolute, updated, 'utf8');
301
+ return text(present(updated, `Replaced ${occurrences} occurrence(s)`));
164
302
  }
165
303
  if (occurrences > 0) {
166
304
  fail(`Expected ${expected} occurrence(s) of old_string but found ${occurrences}. Add more surrounding context.`);
@@ -177,17 +315,354 @@ export async function editBlockTool(args) {
177
315
  const replacement = splitLines(newString);
178
316
  const endsWithNewline = /\n$/.test(content);
179
317
  for (const start of [...starts].reverse()) lines.splice(start, target.length, ...replacement);
180
- const updated = `${lines.join('\n')}${endsWithNewline && lines.length ? '\n' : ''}`;
318
+ // Rebuild with the file's own line ending: hard-coding \n silently rewrote every CRLF
319
+ // file to LF and turned a one-line change into a whole-file diff on Windows.
320
+ const updated = `${lines.join(eol)}${endsWithNewline && lines.length ? eol : ''}`;
181
321
  assertWritableSize(updated);
182
- await writeFile(absolute, updated, 'utf8');
183
- const delta = lines.length - splitLines(content).length;
184
- return text(`Replaced ${starts.length} occurrence(s) in ${displayPath(absolute)} using whitespace-tolerant matching (${delta >= 0 ? '+' : ''}${delta} lines). Re-read the file if exact formatting matters.`);
322
+ if (!dryRun) await writeFile(absolute, updated, 'utf8');
323
+ return text(present(updated, `Replaced ${starts.length} occurrence(s) using whitespace-tolerant matching (line endings kept as ${eol === '\r\n' ? 'CRLF' : 'LF'})`));
185
324
  }
186
325
 
187
- export async function createDirectoryTool(args) {
326
+ export async function replaceLinesTool(args) {
327
+ const absolute = await resolveSafePath(args.path);
328
+ const startLine = Number(args.start_line);
329
+ const endLine = Number(args.end_line);
330
+ if (!Number.isInteger(startLine) || startLine < 1) fail('start_line must be a positive integer (1-based)');
331
+ if (!Number.isInteger(endLine) || endLine < startLine) fail('end_line must be an integer greater than or equal to start_line');
332
+ const content = typeof args.content === 'string' ? args.content : fail('content must be a string');
333
+ const dryRun = args.dry_run === true;
334
+ const { content: original, eol } = await readTextFile(absolute);
335
+ const lines = splitLines(original);
336
+ if (startLine > lines.length) fail(`${displayPath(absolute)} has ${lines.length} lines; start_line ${startLine} is past the end`);
337
+ const endsWithNewline = /\n$/.test(original);
338
+ const replacement = splitLines(content);
339
+ const updated = [...lines.slice(0, startLine - 1), ...replacement, ...lines.slice(Math.min(endLine, lines.length))];
340
+ const updatedText = `${updated.join(eol)}${endsWithNewline && updated.length ? eol : ''}`;
341
+ assertWritableSize(updatedText);
342
+ const stats = diffStats(original, updatedText);
343
+ const summary = `Replaced lines ${startLine}-${Math.min(endLine, lines.length)} of ${displayPath(absolute)} (+${stats.added}/-${stats.removed} lines)`;
344
+ if (dryRun) {
345
+ return text(`${summary}\n(dry run: nothing was written)\n${unifiedDiff(original, updatedText, { oldLabel: displayPath(absolute), newLabel: 'after' })}`);
346
+ }
347
+ await writeFile(absolute, updatedText, 'utf8');
348
+ return text(`${summary}.`);
349
+ }
350
+
351
+ export async function replaceInFilesTool(args) {
352
+ const root = await resolveSafePath(args.path);
353
+ const pattern = typeof args.pattern === 'string' && args.pattern ? args.pattern : fail('pattern is required');
354
+ const replacement = typeof args.replacement === 'string' ? args.replacement : fail('replacement must be a string');
355
+ const filePattern = typeof args.filePattern === 'string' && args.filePattern.trim() ? args.filePattern.trim() : null;
356
+ const isRegex = args.regex === true;
357
+ // Applying is the default: the agent is expected to act, and a dry run is available
358
+ // when a caller explicitly wants a preview.
359
+ const dryRun = args.dry_run === true;
360
+ const maxFiles = clampInteger(args.maxFiles, 100, 1, 500);
361
+ let matcher = null;
362
+ if (isRegex) {
363
+ try { matcher = new RegExp(pattern, 'g'); } catch (error) {
364
+ fail(`pattern is not a valid regular expression (${error instanceof Error ? error.message : String(error)})`);
365
+ }
366
+ }
367
+ const info = await stat(root).catch(() => fail(`Path not found: ${displayPath(root)}`));
368
+ const files = [];
369
+ async function collect(target) {
370
+ if (files.length > maxFiles) return;
371
+ const entryInfo = await stat(target).catch(() => null);
372
+ if (!entryInfo) return;
373
+ if (entryInfo.isFile()) { files.push(target); return; }
374
+ if (!entryInfo.isDirectory()) return;
375
+ const entries = await readdir(target, { withFileTypes: true }).catch(() => []);
376
+ for (const entry of entries) {
377
+ if (files.length > maxFiles) return;
378
+ if (entry.name === '.git' || entry.name === 'node_modules' || entry.name.startsWith('.remcp-trash')) continue;
379
+ await collect(path.join(target, entry.name));
380
+ }
381
+ }
382
+ if (info.isFile()) files.push(root);
383
+ else await collect(root);
384
+ const glob = filePattern ? globToRegExp(filePattern) : null;
385
+ const changed = [];
386
+ let scanned = 0;
387
+ for (const file of files) {
388
+ if (changed.length >= maxFiles) break;
389
+ if (glob && !glob.test(path.basename(file))) continue;
390
+ const fileInfo = await stat(file).catch(() => null);
391
+ if (!fileInfo || fileInfo.size > MAX_INLINE_FILE_BYTES) continue;
392
+ const buffer = await readFile(file).catch(() => null);
393
+ if (!buffer) continue;
394
+ const decoded = decodeText(buffer);
395
+ if (decoded.encoding === 'utf8' && looksBinary(buffer)) continue;
396
+ scanned += 1;
397
+ const original = decoded.text;
398
+ const count = isRegex ? (original.match(matcher) || []).length : original.split(pattern).length - 1;
399
+ if (!count) continue;
400
+ if (isRegex) matcher.lastIndex = 0;
401
+ const updated = isRegex ? original.replace(matcher, replacement) : original.split(pattern).join(replacement);
402
+ if (updated === original) continue;
403
+ assertWritableSize(updated);
404
+ if (!dryRun) await writeFile(file, updated, 'utf8');
405
+ const stats = diffStats(original, updated);
406
+ changed.push({ file: displayPath(file), replacements: count, added: stats.added, removed: stats.removed });
407
+ }
408
+ if (!changed.length) return text(`No matches for ${JSON.stringify(pattern)} in ${displayPath(root)} (${scanned} text files scanned).`);
409
+ const rows = changed.map(entry => `${dryRun ? 'would change' : 'changed'} ${entry.file} · ${entry.replacements} replacement(s) · +${entry.added}/-${entry.removed} lines`);
410
+ const header = `${dryRun ? 'Dry run' : 'Applied'}: ${changed.length} file(s), ${changed.reduce((sum, entry) => sum + entry.replacements, 0)} replacement(s)`;
411
+ const hint = dryRun ? '\nNothing was written. Call again with dry_run: false to apply.' : '';
412
+ return text(`${header}\n${rows.join('\n')}${hint}`);
413
+ }
414
+
415
+ export async function diffFilesTool(args) {
416
+ const left = await resolveSafePath(args.left, 'left');
417
+ const right = await resolveSafePath(args.right, 'right');
418
+ const context = clampInteger(args.context_lines, 3, 0, 20);
419
+ const a = await readTextFile(left);
420
+ const b = await readTextFile(right);
421
+ const diff = unifiedDiff(a.content, b.content, { oldLabel: displayPath(left), newLabel: displayPath(right), context });
422
+ if (!diff) return text(`${displayPath(left)} and ${displayPath(right)} are identical (${a.content.length} bytes).`);
423
+ const stats = diffStats(a.content, b.content);
424
+ return text(`${displayPath(left)} → ${displayPath(right)} (+${stats.added}/-${stats.removed} lines)\n${diff}`);
425
+ }
426
+
427
+ function trashDirectoryFor() {
428
+ if (process.platform === 'darwin') return path.join(os.homedir(), '.Trash');
429
+ if (process.platform === 'win32') return null;
430
+ return path.join(os.homedir(), '.local', 'share', 'Trash', 'files');
431
+ }
432
+
433
+ export async function moveToTrashTool(args) {
434
+ const source = await resolveSafePath(args.source, 'source');
435
+ const info = await stat(source).catch(() => fail(`Path not found: ${displayPath(source)}`));
436
+ const trash = trashDirectoryFor();
437
+ let destination = null;
438
+ if (trash) {
439
+ // The trash lives outside the allowed roots, so only use it when confinement permits
440
+ // it; otherwise fall back to a trash folder beside the file.
441
+ try {
442
+ await resolveSafePath(trash, 'trash');
443
+ destination = trash;
444
+ } catch { destination = null; }
445
+ }
446
+ if (!destination) destination = path.join(path.dirname(source), '.remcp-trash');
447
+ await mkdir(destination, { recursive: true });
448
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
449
+ let target = path.join(destination, `${stamp}-${path.basename(source)}`);
450
+ let counter = 1;
451
+ while (await access(target, constants.F_OK).then(() => true, () => false)) {
452
+ target = path.join(destination, `${stamp}-${counter}-${path.basename(source)}`);
453
+ counter += 1;
454
+ }
455
+ await rename(source, target).catch(async error => {
456
+ if (error?.code !== 'EXDEV') throw error;
457
+ if (info.isDirectory()) fail('Moving a directory to the trash across filesystems is not supported');
458
+ await copyFile(source, target);
459
+ await unlink(source);
460
+ });
461
+ return text(`Moved ${displayPath(source)} to ${displayPath(target)}. Restore it with move_file if this was a mistake.`);
462
+ }
463
+
464
+ export async function readFilesTool(args) {
465
+ // Glob-first bulk read: one call fills the model's context with every file that matters
466
+ // instead of one round trip per path.
467
+ const root = await resolveSafePath(args.path || '.');
468
+ const pattern = typeof args.pattern === 'string' && args.pattern.trim() ? args.pattern.trim() : '**/*';
469
+ const maxFiles = clampInteger(args.max_files, 50, 1, 200);
470
+ const maxLinesPerFile = clampInteger(args.max_lines_per_file, runtimeConfig.maxReadLines, 1, 10000);
471
+ const includeIgnored = args.include_ignored === true;
472
+ const matcher = globToRegExp(pattern);
473
+ const files = [];
474
+ async function collect(target) {
475
+ if (files.length > maxFiles) return;
476
+ const info = await stat(target).catch(() => null);
477
+ if (!info) return;
478
+ if (info.isFile()) {
479
+ const relative = path.relative(root, target) || path.basename(target);
480
+ if (matcher.test(relative.split(path.sep).join('/')) || matcher.test(path.basename(target))) files.push(target);
481
+ return;
482
+ }
483
+ if (!info.isDirectory()) return;
484
+ const entries = await readdir(target, { withFileTypes: true }).catch(() => []);
485
+ for (const entry of entries) {
486
+ if (files.length > maxFiles) return;
487
+ if (entry.name.startsWith('.remcp-trash')) continue;
488
+ if (!includeIgnored && (entry.name === 'node_modules' || entry.name === '.git')) continue;
489
+ await collect(path.join(target, entry.name));
490
+ }
491
+ }
492
+ if ((await stat(root).catch(() => null))?.isFile()) {
493
+ files.push(root);
494
+ } else {
495
+ await collect(root);
496
+ }
497
+ if (!files.length) return text(`No files matched ${pattern} under ${displayPath(root)}.`);
498
+ const sections = [];
499
+ let skipped = 0;
500
+ for (const file of files.slice(0, maxFiles)) {
501
+ try {
502
+ const { content, encoding } = await readTextFile(file);
503
+ const lines = splitLines(content);
504
+ const slice = lines.slice(0, maxLinesPerFile);
505
+ const suffix = lines.length > maxLinesPerFile ? `\n… ${lines.length - maxLinesPerFile} more lines (use read_file with offset)` : '';
506
+ sections.push(`===== ${displayPath(file)} (${lines.length} lines${encoding === 'utf8' ? '' : `, ${encoding}`}) =====\n${slice.join('\n')}${suffix}`);
507
+ } catch (error) {
508
+ skipped += 1;
509
+ sections.push(`===== ${displayPath(file)} =====\n(skipped: ${error instanceof Error ? error.message : String(error)})`);
510
+ }
511
+ }
512
+ const header = `${files.length} file(s) matched ${pattern} under ${displayPath(root)}${files.length > maxFiles ? ` (showing the first ${maxFiles})` : ''}${skipped ? `, ${skipped} skipped` : ''}`;
513
+ return text(`${header}\n\n${sections.join('\n\n')}`);
514
+ }
515
+
516
+ export async function writeFilesTool(args) {
517
+ // Bulk write for scaffolding: one call creates or replaces many files.
518
+ const files = Array.isArray(args.files) ? args.files : fail('files must be an array of { path, content } objects');
519
+ if (!files.length) fail('files must not be empty');
520
+ if (files.length > 200) fail('files accepts at most 200 entries per call');
521
+ const results = [];
522
+ let totalBytes = 0;
523
+ for (const entry of files) {
524
+ const target = typeof entry?.path === 'string' ? entry.path : null;
525
+ if (!target) { results.push('skipped: entry without a path'); continue; }
526
+ if (typeof entry.content !== 'string') { results.push(`skipped ${target}: content must be a string`); continue; }
527
+ try {
528
+ const absolute = await resolveSafePath(target);
529
+ const content = entry.content;
530
+ if (content.includes('\0')) throw new Error('content contains NUL bytes; use write_binary for binary data');
531
+ const bytes = assertWritableSize(content);
532
+ totalBytes += bytes;
533
+ if (totalBytes > runtimeConfig.maxWriteBytes * 4) fail(`This call would write ${totalBytes} bytes, above the ${runtimeConfig.maxWriteBytes * 4}-byte batch limit`);
534
+ await mkdir(path.dirname(absolute), { recursive: true });
535
+ await writeFile(absolute, content, entry.mode === 'append' ? { encoding: 'utf8', flag: 'a' } : 'utf8');
536
+ results.push(`${entry.mode === 'append' ? 'appended' : 'wrote'} ${displayPath(absolute)} (${bytes} bytes)`);
537
+ } catch (error) {
538
+ results.push(`failed ${target}: ${error instanceof Error ? error.message : String(error)}`);
539
+ }
540
+ }
541
+ countEvent('bytesWritten', totalBytes);
542
+ const failed = results.filter(line => line.startsWith('failed') || line.startsWith('skipped')).length;
543
+ return text(`${results.length - failed}/${results.length} file(s) written, ${totalBytes} bytes total\n${results.join('\n')}`, failed > 0);
544
+ }
545
+
546
+ export async function deletePathTool(args) {
188
547
  const absolute = await resolveSafePath(args.path);
189
- await mkdir(absolute, { recursive: true });
190
- return text(`Directory ready: ${displayPath(absolute)}`);
548
+ const info = await stat(absolute).catch(() => fail(`Path not found: ${displayPath(absolute)}`));
549
+ if (path.dirname(absolute) === absolute) fail(`Refusing to delete the filesystem root ${displayPath(absolute)}`);
550
+ const recursive = args.recursive !== false;
551
+ if (info.isDirectory() && !recursive) {
552
+ const entries = await readdir(absolute).catch(() => []);
553
+ if (entries.length) fail(`Directory is not empty: ${displayPath(absolute)}. Pass recursive: true to delete it with its contents.`);
554
+ }
555
+ const entries = info.isDirectory() ? await readdir(absolute).catch(() => []) : [];
556
+ await rm(absolute, { recursive: true, force: false });
557
+ return text(`Deleted ${info.isDirectory() ? 'directory' : 'file'} ${displayPath(absolute)}${info.isDirectory() ? ` and its ${entries.length} top-level entr${entries.length === 1 ? 'y' : 'ies'}` : ''}.`);
558
+ }
559
+
560
+ export async function deletePathsTool(args) {
561
+ const paths = Array.isArray(args.paths) ? args.paths : fail('paths must be an array of absolute paths');
562
+ if (!paths.length) fail('paths must not be empty');
563
+ if (paths.length > 500) fail('paths accepts at most 500 entries per call');
564
+ const recursive = args.recursive !== false;
565
+ const results = [];
566
+ let deleted = 0;
567
+ for (const entry of paths) {
568
+ try {
569
+ const absolute = await resolveSafePath(entry, 'paths[]');
570
+ if (path.dirname(absolute) === absolute) throw new Error('refusing to delete the filesystem root');
571
+ const info = await stat(absolute).catch(() => null);
572
+ if (!info) throw new Error('not found');
573
+ if (info.isDirectory() && !recursive) {
574
+ const children = await readdir(absolute).catch(() => []);
575
+ if (children.length) throw new Error('directory is not empty (pass recursive: true)');
576
+ }
577
+ await rm(absolute, { recursive: true, force: false });
578
+ deleted += 1;
579
+ results.push(`deleted ${displayPath(absolute)}`);
580
+ } catch (error) {
581
+ results.push(`failed ${entry}: ${error instanceof Error ? error.message : String(error)}`);
582
+ }
583
+ }
584
+ return text(`${deleted}/${paths.length} path(s) deleted\n${results.join('\n')}`, deleted !== paths.length);
585
+ }
586
+
587
+ // Recursive copy for files and whole directories, so a project or a backup can be
588
+ // duplicated in one call.
589
+ export async function copyPathsTool(args) {
590
+ const pairs = Array.isArray(args.paths) ? args.paths : fail('paths must be an array of { source, destination } objects');
591
+ if (!pairs.length) fail('paths must not be empty');
592
+ if (pairs.length > 200) fail('paths accepts at most 200 entries per call');
593
+ const overwrite = args.overwrite !== false;
594
+ const results = [];
595
+ let copied = 0;
596
+ for (const entry of pairs) {
597
+ try {
598
+ const source = await resolveSafePath(entry?.source, 'paths[].source');
599
+ const destination = await resolveSafePath(entry?.destination, 'paths[].destination');
600
+ if (source === destination) throw new Error('source and destination are the same path');
601
+ const info = await stat(source).catch(() => null);
602
+ if (!info) throw new Error('source not found');
603
+ const existing = await stat(destination).catch(() => null);
604
+ if (existing && !overwrite) throw new Error('destination already exists (pass overwrite: true)');
605
+ await mkdir(path.dirname(destination), { recursive: true });
606
+ await cp(source, destination, { recursive: true, force: overwrite, errorOnExist: !overwrite });
607
+ copied += 1;
608
+ results.push(`copied ${displayPath(source)} → ${displayPath(destination)}`);
609
+ } catch (error) {
610
+ results.push(`failed ${entry?.source ?? '?'}: ${error instanceof Error ? error.message : String(error)}`);
611
+ }
612
+ }
613
+ return text(`${copied}/${pairs.length} path(s) copied\n${results.join('\n')}`, copied !== pairs.length);
614
+ }
615
+
616
+ export async function movePathsTool(args) {
617
+ const pairs = Array.isArray(args.paths) ? args.paths : fail('paths must be an array of { source, destination } objects');
618
+ if (!pairs.length) fail('paths must not be empty');
619
+ if (pairs.length > 200) fail('paths accepts at most 200 entries per call');
620
+ const overwrite = args.overwrite !== false;
621
+ const results = [];
622
+ let moved = 0;
623
+ for (const entry of pairs) {
624
+ try {
625
+ const source = await resolveSafePath(entry?.source, 'paths[].source');
626
+ const destination = await resolveSafePath(entry?.destination, 'paths[].destination');
627
+ if (source === destination) throw new Error('source and destination are the same path');
628
+ const info = await stat(source).catch(() => null);
629
+ if (!info) throw new Error('source not found');
630
+ const existing = await stat(destination).catch(() => null);
631
+ if (existing && !overwrite) throw new Error('destination already exists (pass overwrite: true)');
632
+ await mkdir(path.dirname(destination), { recursive: true });
633
+ try {
634
+ await rename(source, destination);
635
+ } catch (error) {
636
+ if (error?.code !== 'EXDEV') throw error;
637
+ await cp(source, destination, { recursive: true, force: overwrite, errorOnExist: !overwrite });
638
+ await rm(source, { recursive: true, force: true });
639
+ }
640
+ moved += 1;
641
+ results.push(`moved ${displayPath(source)} → ${displayPath(destination)}`);
642
+ } catch (error) {
643
+ results.push(`failed ${entry?.source ?? '?'}: ${error instanceof Error ? error.message : String(error)}`);
644
+ }
645
+ }
646
+ return text(`${moved}/${pairs.length} path(s) moved\n${results.join('\n')}`, moved !== pairs.length);
647
+ }
648
+
649
+ export async function createDirectoryTool(args) {
650
+ const list = Array.isArray(args.paths) ? args.paths : [args.path];
651
+ if (!list.filter(Boolean).length) fail('path (or paths) is required');
652
+ if (list.length > 200) fail('paths accepts at most 200 entries per call');
653
+ const created = [];
654
+ const failed = [];
655
+ for (const entry of list) {
656
+ try {
657
+ const absolute = await resolveSafePath(entry);
658
+ await mkdir(absolute, { recursive: true });
659
+ created.push(displayPath(absolute));
660
+ } catch (error) {
661
+ failed.push(`${entry}: ${error instanceof Error ? error.message : String(error)}`);
662
+ }
663
+ }
664
+ const header = `${created.length} director${created.length === 1 ? 'y' : 'ies'} ready`;
665
+ return text([header, ...created, ...failed.map(line => `failed ${line}`)].join('\n'), failed.length > 0);
191
666
  }
192
667
 
193
668
  async function pathExists(target) {
@@ -199,7 +674,13 @@ export async function moveFileTool(args) {
199
674
  const destination = await resolveSafePath(args.destination, 'destination');
200
675
  if (source === destination) fail('source and destination are the same path');
201
676
  await stat(source).catch(() => fail(`Source not found: ${displayPath(source)}`));
202
- if (await pathExists(destination)) fail(`Destination already exists: ${displayPath(destination)}. Remove it first or pick another name.`);
677
+ const overwrite = args.overwrite !== false;
678
+ const existing = await stat(destination).catch(() => null);
679
+ if (existing && !overwrite) fail(`Destination already exists: ${displayPath(destination)}. Pass overwrite: true to replace it.`);
680
+ if (existing?.isDirectory()) {
681
+ const entries = await readdir(destination).catch(() => []);
682
+ if (entries.length) fail(`Destination is a non-empty directory: ${displayPath(destination)}. Move it aside or pick another name.`);
683
+ }
203
684
  await mkdir(path.dirname(destination), { recursive: true });
204
685
  try {
205
686
  await rename(source, destination);
@@ -219,21 +700,159 @@ export async function copyFileTool(args) {
219
700
  if (source === destination) fail('source and destination are the same path');
220
701
  const info = await stat(source).catch(() => fail(`Source not found: ${displayPath(source)}`));
221
702
  if (info.isDirectory()) fail('copy_file copies single files only; create the directory and copy its files individually');
222
- const overwrite = args.overwrite === true;
703
+ const overwrite = args.overwrite !== false;
223
704
  if (!overwrite && await pathExists(destination)) fail(`Destination already exists: ${displayPath(destination)}. Pass overwrite: true to replace it.`);
224
705
  await mkdir(path.dirname(destination), { recursive: true });
225
706
  await copyFile(source, destination, overwrite ? 0 : constants.COPYFILE_EXCL);
226
707
  return text(`Copied ${displayPath(source)} to ${displayPath(destination)} (${info.size} bytes).`);
227
708
  }
228
709
 
710
+ // --- archives -------------------------------------------------------------------------
711
+ function archiveTool() {
712
+ const probe = name => {
713
+ const result = spawnSync(name, ['--version'], { encoding: 'utf8' });
714
+ return !result.error && result.status === 0 ? name : null;
715
+ };
716
+ return { tar: probe('tar'), zip: probe('zip'), unzip: probe('unzip') };
717
+ }
718
+
719
+ export async function createArchiveTool(args) {
720
+ const tools = archiveTool();
721
+ const sources = Array.isArray(args.paths) ? args.paths : [args.paths].filter(Boolean);
722
+ if (!sources.length) fail('paths must list at least one file or directory');
723
+ const resolved = [];
724
+ for (const entry of sources) resolved.push(await resolveSafePath(entry, 'paths[]'));
725
+ const destination = await resolveSafePath(args.destination, 'destination');
726
+ const format = String(args.format || (destination.endsWith('.zip') ? 'zip' : 'tar.gz')).toLowerCase();
727
+ await mkdir(path.dirname(destination), { recursive: true });
728
+ const baseDir = path.dirname(resolved[0]);
729
+ const names = resolved.map(entry => path.relative(baseDir, entry));
730
+ // An archive written inside the tree it packs makes tar abort with "file changed as we
731
+ // read it" (the directory mtime moves while it is being read), so build it outside the
732
+ // tree first and move it into place afterwards.
733
+ const destinationRelative = path.relative(baseDir, destination);
734
+ const selfInside = !destinationRelative.startsWith('..') && !path.isAbsolute(destinationRelative);
735
+ const suffix = format === 'zip' ? '.zip' : format === 'tar' ? '.tar' : '.tar.gz';
736
+ const staging = selfInside ? path.join(os.tmpdir(), `remcp-archive-${Date.now()}-${process.pid}${suffix}`) : destination;
737
+ const output = staging;
738
+ if (format === 'zip') {
739
+ if (!tools.zip) fail('zip is not installed on this device; use format "tar.gz"');
740
+ const result = spawnSync(tools.zip, ['-r', '-q', output, ...names], { cwd: baseDir, encoding: 'utf8' });
741
+ if (result.status !== 0) fail(`zip failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
742
+ } else if (format === 'tar' || format === 'tar.gz' || format === 'tgz') {
743
+ if (!tools.tar) fail('tar is not installed on this device');
744
+ const flags = format === 'tar' ? '-cf' : '-czf';
745
+ const result = spawnSync(tools.tar, [flags, output, ...names], { cwd: baseDir, encoding: 'utf8' });
746
+ if (result.status !== 0) fail(`tar failed: ${(result.stderr || '').trim() || `exit ${result.status}`}`);
747
+ } else {
748
+ fail('format must be tar, tar.gz, or zip');
749
+ }
750
+ if (selfInside) {
751
+ await mkdir(path.dirname(destination), { recursive: true });
752
+ await rename(staging, destination);
753
+ }
754
+ const info = await stat(destination).catch(() => null);
755
+ const note = selfInside ? ' (built outside the tree so it does not include itself)' : '';
756
+ return text(`Created ${displayPath(destination)} (${format}, ${info?.size ?? 0} bytes) from ${resolved.length} path(s)${note}.`);
757
+ }
758
+
759
+ export async function extractArchiveTool(args) {
760
+ const tools = archiveTool();
761
+ const archive = await resolveSafePath(args.archive, 'archive');
762
+ const destination = await resolveSafePath(args.destination || path.dirname(archive), 'destination');
763
+ await mkdir(destination, { recursive: true });
764
+ if (/\.zip$/i.test(archive)) {
765
+ if (!tools.unzip) fail('unzip is not installed on this device');
766
+ const result = spawnSync(tools.unzip, ['-o', '-q', archive, '-d', destination], { encoding: 'utf8' });
767
+ if (result.status !== 0) fail(`unzip failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
768
+ } else {
769
+ if (!tools.tar) fail('tar is not installed on this device');
770
+ const flags = /\.(tar\.gz|tgz)$/i.test(archive) ? '-xzf' : /\.(tar\.bz2|tbz2?)$/i.test(archive) ? '-xjf' : /\.tar\.xz$/i.test(archive) ? '-xJf' : '-xf';
771
+ const result = spawnSync(tools.tar, [flags, archive, '-C', destination], { encoding: 'utf8' });
772
+ if (result.status !== 0) fail(`tar failed: ${(result.stderr || '').trim() || `exit ${result.status}`}`);
773
+ }
774
+ const entries = await readdir(destination).catch(() => []);
775
+ return text(`Extracted ${displayPath(archive)} into ${displayPath(destination)} (${entries.length} top-level entries).`);
776
+ }
777
+
778
+ // --- screenshots ----------------------------------------------------------------------
779
+ const SCREENSHOT_COMMANDS = [
780
+ { command: 'grim', args: file => [file] },
781
+ { command: 'gnome-screenshot', args: file => ['-f', file] },
782
+ { command: 'spectacle', args: file => ['-b', '-n', '-o', file] },
783
+ { command: 'scrot', args: file => ['-o', file] },
784
+ { command: 'import', args: file => ['-window', 'root', file] },
785
+ { command: 'screencapture', args: file => ['-x', file] },
786
+ ];
787
+
788
+ function windowsScreenshotScript(file) {
789
+ return [
790
+ 'Add-Type -AssemblyName System.Windows.Forms,System.Drawing',
791
+ '$b = [System.Windows.Forms.SystemInformation]::VirtualScreen',
792
+ '$bmp = New-Object System.Drawing.Bitmap $b.Width, $b.Height',
793
+ '$g = [System.Drawing.Graphics]::FromImage($bmp)',
794
+ '$g.CopyFromScreen($b.Left, $b.Top, 0, 0, $bmp.Size)',
795
+ `$bmp.Save('${file.replace(/'/g, "''")}', [System.Drawing.Imaging.ImageFormat]::Png)`,
796
+ ].join('; ');
797
+ }
798
+
799
+ export async function takeScreenshotTool(args) {
800
+ const directory = await resolveSafePath(args.directory || os.tmpdir(), 'directory');
801
+ await mkdir(directory, { recursive: true });
802
+ const file = path.join(directory, `remcp-screenshot-${Date.now()}.png`);
803
+ const attempts = [];
804
+ if (process.platform === 'win32') {
805
+ const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', windowsScreenshotScript(file)], { encoding: 'utf8', timeout: 30000 });
806
+ attempts.push(`powershell: ${(result.stderr || '').trim() || `exit ${result.status}`}`);
807
+ } else {
808
+ for (const candidate of SCREENSHOT_COMMANDS) {
809
+ if (spawnSync('which', [candidate.command], { encoding: 'utf8' }).status !== 0) continue;
810
+ const result = spawnSync(candidate.command, candidate.args(file), { encoding: 'utf8', timeout: 30000 });
811
+ if (result.status === 0 && await pathExists(file)) break;
812
+ attempts.push(`${candidate.command}: ${(result.stderr || '').trim() || `exit ${result.status}`}`);
813
+ }
814
+ }
815
+ if (!await pathExists(file)) {
816
+ fail(`Could not capture the screen. Install one of grim, gnome-screenshot, spectacle, scrot, or ImageMagick import (tried: ${attempts.join('; ') || 'none available'}).`);
817
+ }
818
+ const info = await stat(file);
819
+ if (info.size > MAX_IMAGE_BYTES) {
820
+ await rm(file, { force: true });
821
+ fail(`Screenshot is ${info.size} bytes, above the ${MAX_IMAGE_BYTES}-byte inline limit`);
822
+ }
823
+ const buffer = await readFile(file);
824
+ if (args.keep !== true) await rm(file, { force: true });
825
+ return multi([
826
+ { type: 'text', text: `Screenshot of ${os.hostname()} (${info.size} bytes)${args.keep === true ? ` saved at ${displayPath(file)}` : ''}` },
827
+ image(buffer.toString('base64'), 'image/png'),
828
+ ]);
829
+ }
830
+
229
831
  export const fileToolHandlers = {
230
832
  read_file: readFileTool,
833
+ read_files: readFilesTool,
231
834
  read_multiple_files: readMultipleFilesTool,
835
+ read_image: readImageTool,
836
+ read_binary: readBinaryTool,
837
+ hash_file: hashFileTool,
232
838
  list_directory: listDirectoryTool,
233
839
  get_file_info: getFileInfoTool,
234
840
  write_file: writeFileTool,
841
+ write_files: writeFilesTool,
842
+ write_binary: writeBinaryTool,
235
843
  edit_block: editBlockTool,
844
+ replace_lines: replaceLinesTool,
845
+ replace_in_files: replaceInFilesTool,
846
+ diff_files: diffFilesTool,
236
847
  create_directory: createDirectoryTool,
848
+ delete_path: deletePathTool,
849
+ delete_paths: deletePathsTool,
237
850
  move_file: moveFileTool,
851
+ move_paths: movePathsTool,
238
852
  copy_file: copyFileTool,
853
+ copy_paths: copyPathsTool,
854
+ move_to_trash: moveToTrashTool,
855
+ create_archive: createArchiveTool,
856
+ extract_archive: extractArchiveTool,
857
+ take_screenshot: takeScreenshotTool,
239
858
  };