@remcp/runtime 0.2.0 → 0.2.5

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