@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.
- package/CHANGELOG.md +23 -0
- package/README.md +96 -41
- package/package.json +3 -2
- package/src/catalog.mjs +341 -11
- package/src/config.mjs +43 -3
- package/src/diff.mjs +86 -0
- package/src/index.mjs +39 -6
- package/src/invoke.mjs +5 -2
- package/src/policy.mjs +71 -20
- package/src/sessions.mjs +58 -10
- package/src/telemetry.mjs +16 -2
- package/src/tools/files.mjs +651 -32
- package/src/tools/search.mjs +12 -5
- package/src/tools/system.mjs +58 -1
- package/src/tools/terminal.mjs +85 -32
- package/src/util.mjs +34 -2
package/src/tools/files.mjs
CHANGED
|
@@ -1,29 +1,52 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
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
|
-
|
|
16
|
-
|
|
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
|
|
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
|
|
57
|
-
const
|
|
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
|
|
81
|
-
|
|
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
|
|
98
|
-
const
|
|
99
|
-
payload.
|
|
100
|
-
|
|
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
|
|
120
|
-
if (!['rewrite', 'append'].includes(
|
|
121
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
190
|
-
|
|
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
|
-
|
|
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
|
|
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
|
};
|