fileditor-mcp 1.0.2 → 1.0.3
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/1781792324448_PixelTunnelSender.exe +0 -0
- package/LICENSE +21 -21
- package/README.md +128 -128
- package/docs/MCP-INTERFACE.cn.md +975 -1061
- package/docs/MCP-INTERFACE.en.md +881 -974
- package/docs/README.cn.md +128 -128
- package/package.json +30 -31
- package/src/handlers/applyDiff.js +298 -324
- package/src/handlers/insertContent.js +179 -153
- package/src/handlers/listFiles.js +65 -54
- package/src/handlers/readFile.js +109 -109
- package/src/handlers/searchAndReplace.js +198 -217
- package/src/handlers/setWorkspace.js +26 -26
- package/src/handlers/writeFile.js +97 -91
- package/src/index.js +22 -22
- package/src/server.js +85 -85
- package/src/tools/toolDefinitions.js +291 -301
- package/src/utils/fileUtils.js +238 -716
- package/test/ApplyDiffHandler.test.js +754 -754
- package/test/InsertContentHandler.test.js +371 -369
- package/test/ListFilesHandler.test.js +302 -300
- package/test/ReadFileHandler.test.js +213 -213
- package/test/SearchAndReplaceHandler.test.js +505 -505
- package/test/SetWorkspaceHandler.test.js +290 -290
- package/test/WriteFileHandler.test.js +289 -289
- package/test/runAllTests.js +233 -233
package/src/utils/fileUtils.js
CHANGED
|
@@ -1,716 +1,238 @@
|
|
|
1
|
-
import fs from 'fs/promises';
|
|
2
|
-
import { resolve, dirname, isAbsolute, join } from 'path';
|
|
3
|
-
import { existsSync, lstatSync } from 'fs';
|
|
4
|
-
import { cwd } from 'process';
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (
|
|
41
|
-
throw new Error(`
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
*
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
*
|
|
228
|
-
* @
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
} /**
|
|
240
|
-
* Normalize multi-file operation parameters
|
|
241
|
-
* @param {Object} params - Parameters object containing path, content, line, etc.
|
|
242
|
-
* @returns {Object} Normalized parameters with all values as arrays
|
|
243
|
-
*/
|
|
244
|
-
static normalizeMultiFileArgs(params) {
|
|
245
|
-
const { path, content, line, line_count } = params;
|
|
246
|
-
|
|
247
|
-
// Determine the primary parameter that defines the operation count
|
|
248
|
-
const paths = Array.isArray(path) ? path : [path];
|
|
249
|
-
const fileCount = paths.length;
|
|
250
|
-
|
|
251
|
-
// Normalize all parameters to arrays
|
|
252
|
-
const normalizeParam = (param, paramName, defaultValue = null) => {
|
|
253
|
-
if (Array.isArray(param)) {
|
|
254
|
-
if (param.length !== fileCount) {
|
|
255
|
-
// Generate specific error messages for backward compatibility
|
|
256
|
-
if (paramName === 'content') {
|
|
257
|
-
throw new Error(`Path count (${fileCount}) doesn't match content count (${param.length})`);
|
|
258
|
-
} else if (paramName === 'line_count') {
|
|
259
|
-
throw new Error(`Path count (${fileCount}) doesn't match line_count array length (${param.length})`);
|
|
260
|
-
} else {
|
|
261
|
-
throw new Error(`Parameter array length (${param.length}) must match file count (${fileCount})`);
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
return param;
|
|
265
|
-
} else {
|
|
266
|
-
// Single value: apply to all files
|
|
267
|
-
return new Array(fileCount).fill(param !== undefined ? param : defaultValue);
|
|
268
|
-
}
|
|
269
|
-
};
|
|
270
|
-
|
|
271
|
-
return {
|
|
272
|
-
paths,
|
|
273
|
-
contents: content !== undefined ? normalizeParam(content, 'content') : null,
|
|
274
|
-
lines: line !== undefined ? normalizeParam(line, 'line') : null,
|
|
275
|
-
lineCounts: line_count !== undefined ? normalizeParam(line_count, 'line_count') : null,
|
|
276
|
-
fileCount
|
|
277
|
-
};
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/**
|
|
281
|
-
* Read directory contents with detailed type information
|
|
282
|
-
* @param {string} dirPath - Directory path
|
|
283
|
-
* @param {boolean} recursive - Whether to read recursively
|
|
284
|
-
* @returns {Promise<Array>} Array of directory items
|
|
285
|
-
*/
|
|
286
|
-
static async readDirectory(dirPath, recursive = false) {
|
|
287
|
-
const securePath = FileUtils.getSecurePath(dirPath);
|
|
288
|
-
|
|
289
|
-
if (!existsSync(securePath)) {
|
|
290
|
-
throw new Error(`Directory not found: ${dirPath}`);
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
const stats = lstatSync(securePath);
|
|
294
|
-
if (!stats.isDirectory()) {
|
|
295
|
-
throw new Error(`Path is not a directory: ${dirPath}`);
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
const result = [];
|
|
299
|
-
|
|
300
|
-
if (recursive) {
|
|
301
|
-
await FileUtils._readDirectoryRecursive(securePath, result, '');
|
|
302
|
-
} else {
|
|
303
|
-
const items = await fs.readdir(securePath, { withFileTypes: true });
|
|
304
|
-
for (const item of items) {
|
|
305
|
-
const type = item.isDirectory() ? 'directory' : 'file';
|
|
306
|
-
result.push(`${type}: ${item.name}`);
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
return result;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/**
|
|
314
|
-
* Internal recursive directory reading helper
|
|
315
|
-
* @param {string} dirPath - Directory path
|
|
316
|
-
* @param {Array} result - Result array to populate
|
|
317
|
-
* @param {string} prefix - Path prefix for display
|
|
318
|
-
*/
|
|
319
|
-
static async _readDirectoryRecursive(dirPath, result, prefix) {
|
|
320
|
-
const items = await fs.readdir(dirPath, { withFileTypes: true });
|
|
321
|
-
|
|
322
|
-
for (const item of items) {
|
|
323
|
-
const itemPath = join(dirPath, item.name);
|
|
324
|
-
const displayPath = prefix ? `${prefix}/${item.name}` : item.name;
|
|
325
|
-
|
|
326
|
-
if (item.isDirectory()) {
|
|
327
|
-
result.push(`directory: ${displayPath}`);
|
|
328
|
-
await FileUtils._readDirectoryRecursive(itemPath, result, displayPath);
|
|
329
|
-
} else {
|
|
330
|
-
result.push(`file: ${displayPath}`);
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
} /**
|
|
334
|
-
* Check if a path is a directory
|
|
335
|
-
* @param {string} dirPath - Directory path
|
|
336
|
-
* @returns {boolean} Whether the path is a directory
|
|
337
|
-
*/
|
|
338
|
-
static isDirectory(dirPath) {
|
|
339
|
-
const securePath = FileUtils.getSecurePath(dirPath);
|
|
340
|
-
try {
|
|
341
|
-
return existsSync(securePath) && lstatSync(securePath).isDirectory();
|
|
342
|
-
} catch (error) {
|
|
343
|
-
return false;
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/**
|
|
348
|
-
* Check if a file/directory is hidden (starts with '.')
|
|
349
|
-
* @param {string} name - File or directory name
|
|
350
|
-
* @returns {boolean} Whether the item is hidden
|
|
351
|
-
*/
|
|
352
|
-
static isHidden(name) {
|
|
353
|
-
return name.startsWith('.');
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
/**
|
|
357
|
-
* Check if a directory is a git repository
|
|
358
|
-
* @param {string} dirPath - Directory path
|
|
359
|
-
* @returns {Promise<boolean>} Whether the directory is a git repository
|
|
360
|
-
*/
|
|
361
|
-
static async isGitRepository(dirPath) {
|
|
362
|
-
try {
|
|
363
|
-
const git = simpleGit(dirPath);
|
|
364
|
-
await git.checkIsRepo();
|
|
365
|
-
return true;
|
|
366
|
-
} catch (error) {
|
|
367
|
-
return false;
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
/**
|
|
372
|
-
* Find the git repository root for a given path
|
|
373
|
-
* @param {string} startPath - Starting path to search from
|
|
374
|
-
* @returns {Promise<string|null>} Git repository root path or null if not found
|
|
375
|
-
*/
|
|
376
|
-
static async findGitRoot(startPath) {
|
|
377
|
-
try {
|
|
378
|
-
const git = simpleGit(startPath);
|
|
379
|
-
const isRepo = await git.checkIsRepo();
|
|
380
|
-
if (isRepo) {
|
|
381
|
-
const rootPath = await git.revparse(['--show-toplevel']);
|
|
382
|
-
return rootPath.trim();
|
|
383
|
-
}
|
|
384
|
-
return null;
|
|
385
|
-
} catch (error) {
|
|
386
|
-
return null;
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
/**
|
|
391
|
-
* Get git tracked and untracked files in a directory
|
|
392
|
-
* @param {string} gitRoot - Git repository root path
|
|
393
|
-
* @param {string} targetPath - Target directory path
|
|
394
|
-
* @returns {Promise<{tracked: Set<string>, untracked: Set<string>}>} Sets of tracked and untracked file paths
|
|
395
|
-
*/
|
|
396
|
-
static async getGitFileStatus(gitRoot, targetPath) {
|
|
397
|
-
try {
|
|
398
|
-
const git = simpleGit(gitRoot);
|
|
399
|
-
|
|
400
|
-
// Get all files in the git repository
|
|
401
|
-
const allFiles = await git.raw(['ls-files']);
|
|
402
|
-
const trackedFiles = new Set();
|
|
403
|
-
|
|
404
|
-
if (allFiles.trim()) {
|
|
405
|
-
const files = allFiles.trim().split('\n');
|
|
406
|
-
for (const file of files) {
|
|
407
|
-
const fullPath = join(gitRoot, file.trim());
|
|
408
|
-
trackedFiles.add(fullPath);
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
// Get untracked files
|
|
413
|
-
const status = await git.status();
|
|
414
|
-
const untrackedFiles = new Set();
|
|
415
|
-
|
|
416
|
-
for (const file of status.not_added) {
|
|
417
|
-
const fullPath = join(gitRoot, file);
|
|
418
|
-
untrackedFiles.add(fullPath);
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
// Filter files within target path
|
|
422
|
-
const filteredTracked = new Set();
|
|
423
|
-
const filteredUntracked = new Set();
|
|
424
|
-
|
|
425
|
-
// Filter tracked files
|
|
426
|
-
for (const filePath of trackedFiles) {
|
|
427
|
-
if (FileUtils.isPathInTarget(filePath, targetPath)) {
|
|
428
|
-
filteredTracked.add(filePath);
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
// Filter untracked files
|
|
433
|
-
for (const filePath of untrackedFiles) {
|
|
434
|
-
if (FileUtils.isPathInTarget(filePath, targetPath)) {
|
|
435
|
-
filteredUntracked.add(filePath);
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
return {
|
|
440
|
-
tracked: filteredTracked,
|
|
441
|
-
untracked: filteredUntracked
|
|
442
|
-
};
|
|
443
|
-
} catch (error) {
|
|
444
|
-
// If git command fails, return empty sets
|
|
445
|
-
return {
|
|
446
|
-
tracked: new Set(),
|
|
447
|
-
untracked: new Set()
|
|
448
|
-
};
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
/**
|
|
453
|
-
* Check if a file path is within the target directory
|
|
454
|
-
* @param {string} filePath - File path to check
|
|
455
|
-
* @param {string} targetPath - Target directory path
|
|
456
|
-
* @returns {boolean} Whether the file is within the target directory
|
|
457
|
-
*/
|
|
458
|
-
static isPathInTarget(filePath, targetPath) {
|
|
459
|
-
const normalizedFile = resolve(filePath);
|
|
460
|
-
const normalizedTarget = resolve(targetPath);
|
|
461
|
-
|
|
462
|
-
return normalizedFile === normalizedTarget ||
|
|
463
|
-
pathIsInside(normalizedFile, normalizedTarget);
|
|
464
|
-
}/**
|
|
465
|
-
* Check if a file/directory is hidden (starts with '.')
|
|
466
|
-
* @param {string} name - File or directory name
|
|
467
|
-
* @returns {boolean} Whether the item is hidden
|
|
468
|
-
*/
|
|
469
|
-
static isHidden(name) {
|
|
470
|
-
return name.startsWith('.');
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
/**
|
|
474
|
-
* Check if a directory is a git repository
|
|
475
|
-
* @param {string} dirPath - Directory path
|
|
476
|
-
* @returns {Promise<boolean>} Whether the directory is a git repository
|
|
477
|
-
*/
|
|
478
|
-
static async isGitRepository(dirPath) {
|
|
479
|
-
try {
|
|
480
|
-
const gitPath = join(dirPath, '.git');
|
|
481
|
-
return existsSync(gitPath);
|
|
482
|
-
} catch (error) {
|
|
483
|
-
return false;
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
/**
|
|
488
|
-
* Find the git repository root for a given path
|
|
489
|
-
* @param {string} startPath - Starting path to search from
|
|
490
|
-
* @returns {Promise<string|null>} Git repository root path or null if not found
|
|
491
|
-
*/
|
|
492
|
-
static async findGitRoot(startPath) {
|
|
493
|
-
let currentPath = resolve(startPath);
|
|
494
|
-
|
|
495
|
-
while (currentPath !== dirname(currentPath)) {
|
|
496
|
-
if (await FileUtils.isGitRepository(currentPath)) {
|
|
497
|
-
return currentPath;
|
|
498
|
-
}
|
|
499
|
-
currentPath = dirname(currentPath);
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
return null;
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
/**
|
|
506
|
-
* Get relative path between two absolute paths
|
|
507
|
-
* @param {string} from - Base path
|
|
508
|
-
* @param {string} to - Target path
|
|
509
|
-
* @returns {string} Relative path
|
|
510
|
-
*/
|
|
511
|
-
static getRelativePath(from, to) {
|
|
512
|
-
const fromParts = resolve(from).split(/[/\\]/);
|
|
513
|
-
const toParts = resolve(to).split(/[/\\]/);
|
|
514
|
-
|
|
515
|
-
// Find common base
|
|
516
|
-
let commonLength = 0;
|
|
517
|
-
const minLength = Math.min(fromParts.length, toParts.length);
|
|
518
|
-
|
|
519
|
-
for (let i = 0; i < minLength; i++) {
|
|
520
|
-
if (fromParts[i].toLowerCase() === toParts[i].toLowerCase()) {
|
|
521
|
-
commonLength = i + 1;
|
|
522
|
-
} else {
|
|
523
|
-
break;
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
// Build relative path
|
|
528
|
-
const upLevels = fromParts.length - commonLength;
|
|
529
|
-
const downParts = toParts.slice(commonLength);
|
|
530
|
-
|
|
531
|
-
const relativeParts = [];
|
|
532
|
-
for (let i = 0; i < upLevels; i++) {
|
|
533
|
-
relativeParts.push('..');
|
|
534
|
-
}
|
|
535
|
-
relativeParts.push(...downParts);
|
|
536
|
-
|
|
537
|
-
return relativeParts.join('/');
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
/**
|
|
541
|
-
* Read directory contents with advanced filtering options
|
|
542
|
-
* @param {string} dirPath - Directory path
|
|
543
|
-
* @param {Object} options - Filtering options
|
|
544
|
-
* @returns {Promise<Array>} Array of directory items
|
|
545
|
-
*/
|
|
546
|
-
static async readDirectoryAdvanced(dirPath, options = {}) {
|
|
547
|
-
const {
|
|
548
|
-
recursive = false,
|
|
549
|
-
show_hidden = false,
|
|
550
|
-
git_filter = 'all'
|
|
551
|
-
} = options;
|
|
552
|
-
|
|
553
|
-
const securePath = FileUtils.getSecurePath(dirPath);
|
|
554
|
-
|
|
555
|
-
if (!existsSync(securePath)) {
|
|
556
|
-
throw new Error(`Directory not found: ${dirPath}`);
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
const stats = lstatSync(securePath);
|
|
560
|
-
if (!stats.isDirectory()) {
|
|
561
|
-
throw new Error(`Path is not a directory: ${dirPath}`);
|
|
562
|
-
} // Check for git repository if git filtering is needed
|
|
563
|
-
let gitRoot = null;
|
|
564
|
-
let trackedFiles = new Set();
|
|
565
|
-
let untrackedFiles = new Set(); if (git_filter !== 'all') {
|
|
566
|
-
gitRoot = await FileUtils.findGitRoot(securePath);
|
|
567
|
-
if (gitRoot) {
|
|
568
|
-
const gitStatus = await FileUtils.getGitFileStatus(gitRoot, securePath);
|
|
569
|
-
trackedFiles = gitStatus.tracked;
|
|
570
|
-
untrackedFiles = gitStatus.untracked;
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
const result = []; if (recursive) {
|
|
575
|
-
await FileUtils._readDirectoryRecursiveAdvanced(
|
|
576
|
-
securePath,
|
|
577
|
-
result,
|
|
578
|
-
'',
|
|
579
|
-
{ show_hidden, git_filter, gitRoot, trackedFiles, untrackedFiles }
|
|
580
|
-
);
|
|
581
|
-
} else {
|
|
582
|
-
const items = await fs.readdir(securePath, { withFileTypes: true });
|
|
583
|
-
for (const item of items) {
|
|
584
|
-
// Apply hidden file filter
|
|
585
|
-
if (!show_hidden && FileUtils.isHidden(item.name)) {
|
|
586
|
-
continue;
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
const fullItemPath = join(securePath, item.name); // Apply git filter
|
|
590
|
-
if (git_filter !== 'all' && gitRoot) {
|
|
591
|
-
const isTracked = trackedFiles.has(fullItemPath);
|
|
592
|
-
const isUntracked = untrackedFiles.has(fullItemPath);
|
|
593
|
-
|
|
594
|
-
if (git_filter === 'tracked' && !isTracked) {
|
|
595
|
-
continue;
|
|
596
|
-
}
|
|
597
|
-
if (git_filter === 'untracked' && !isUntracked) {
|
|
598
|
-
continue;
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
const type = item.isDirectory() ? 'directory' : 'file';
|
|
603
|
-
let displayName = item.name;
|
|
604
|
-
|
|
605
|
-
// Add git status indicator
|
|
606
|
-
if (git_filter === 'all' && gitRoot) {
|
|
607
|
-
const isTracked = trackedFiles.has(fullItemPath);
|
|
608
|
-
const isUntracked = untrackedFiles.has(fullItemPath);
|
|
609
|
-
|
|
610
|
-
if (isTracked) {
|
|
611
|
-
displayName += ' [tracked]';
|
|
612
|
-
} else if (isUntracked) {
|
|
613
|
-
displayName += ' [untracked]';
|
|
614
|
-
} else {
|
|
615
|
-
displayName += ' [ignored]';
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
result.push(`${type}: ${displayName}`);
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
return result;
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
/**
|
|
627
|
-
* Internal recursive directory reading helper with advanced options
|
|
628
|
-
* @param {string} dirPath - Directory path
|
|
629
|
-
* @param {Array} result - Result array to populate
|
|
630
|
-
* @param {string} prefix - Path prefix for display
|
|
631
|
-
* @param {Object} options - Filtering options
|
|
632
|
-
*/ static async _readDirectoryRecursiveAdvanced(dirPath, result, prefix, options) {
|
|
633
|
-
const { show_hidden, git_filter, gitRoot, trackedFiles, untrackedFiles } = options;
|
|
634
|
-
|
|
635
|
-
const items = await fs.readdir(dirPath, { withFileTypes: true });
|
|
636
|
-
|
|
637
|
-
for (const item of items) {
|
|
638
|
-
// Apply hidden file filter
|
|
639
|
-
if (!show_hidden && FileUtils.isHidden(item.name)) {
|
|
640
|
-
continue;
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
const itemPath = join(dirPath, item.name);
|
|
644
|
-
const displayPath = prefix ? `${prefix}/${item.name}` : item.name;
|
|
645
|
-
|
|
646
|
-
// Apply git filter
|
|
647
|
-
if (git_filter !== 'all' && gitRoot) {
|
|
648
|
-
const isTracked = trackedFiles.has(itemPath);
|
|
649
|
-
const isUntracked = untrackedFiles.has(itemPath);
|
|
650
|
-
|
|
651
|
-
if (git_filter === 'tracked' && !isTracked) {
|
|
652
|
-
continue;
|
|
653
|
-
}
|
|
654
|
-
if (git_filter === 'untracked' && !isUntracked) {
|
|
655
|
-
continue;
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
let displayName = displayPath;
|
|
660
|
-
|
|
661
|
-
// Add git status indicator
|
|
662
|
-
if (git_filter === 'all' && gitRoot) {
|
|
663
|
-
const isTracked = trackedFiles.has(itemPath);
|
|
664
|
-
const isUntracked = untrackedFiles.has(itemPath);
|
|
665
|
-
|
|
666
|
-
if (isTracked) {
|
|
667
|
-
displayName += ' [tracked]';
|
|
668
|
-
} else if (isUntracked) {
|
|
669
|
-
displayName += ' [untracked]';
|
|
670
|
-
} else {
|
|
671
|
-
displayName += ' [ignored]';
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
if (item.isDirectory()) {
|
|
676
|
-
result.push(`directory: ${displayName}`);
|
|
677
|
-
await FileUtils._readDirectoryRecursiveAdvanced(
|
|
678
|
-
itemPath,
|
|
679
|
-
result,
|
|
680
|
-
displayPath,
|
|
681
|
-
options
|
|
682
|
-
);
|
|
683
|
-
} else {
|
|
684
|
-
result.push(`file: ${displayName}`);
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
/**
|
|
690
|
-
* Validate line range parameters
|
|
691
|
-
* @param {Object} params - Parameters containing start_line, end_line, totalLines
|
|
692
|
-
* @throws {Error} If validation fails
|
|
693
|
-
*/
|
|
694
|
-
static validateLineRange(params) {
|
|
695
|
-
const { start_line, end_line, totalLines } = params;
|
|
696
|
-
|
|
697
|
-
if (start_line !== null && start_line !== undefined) {
|
|
698
|
-
if (start_line < 1) {
|
|
699
|
-
throw new Error(`Invalid start_line: ${start_line}. Line numbers start from 1.`);
|
|
700
|
-
}
|
|
701
|
-
if (totalLines && start_line > totalLines) {
|
|
702
|
-
throw new Error(`start_line (${start_line}) exceeds file length (${totalLines} lines)`);
|
|
703
|
-
}
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
if (end_line !== null && end_line !== undefined) {
|
|
707
|
-
if (end_line < 1) {
|
|
708
|
-
throw new Error(`Invalid end_line: ${end_line}. Line numbers start from 1.`);
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
if (start_line !== null && end_line !== null && start_line > end_line) {
|
|
713
|
-
throw new Error(`start_line (${start_line}) cannot be greater than end_line (${end_line})`);
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
}
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import { resolve, dirname, isAbsolute, join } from 'path';
|
|
3
|
+
import { existsSync, lstatSync } from 'fs';
|
|
4
|
+
import { cwd } from 'process';
|
|
5
|
+
import pathIsInside from 'path-is-inside';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* General file operation utility class
|
|
9
|
+
*/
|
|
10
|
+
export class FileUtils {
|
|
11
|
+
|
|
12
|
+
// Current workspace root directory, initially null, must be set via set_workspace
|
|
13
|
+
static WORKSPACE_ROOT = null; /**
|
|
14
|
+
* Set workspace root directory
|
|
15
|
+
* @param {string} workspaceRoot - New workspace root directory path
|
|
16
|
+
* @throws {Error} If directory does not exist or is not accessible
|
|
17
|
+
*/
|
|
18
|
+
static setWorkspaceRoot(workspaceRoot) {
|
|
19
|
+
// Input validation
|
|
20
|
+
if (!workspaceRoot || typeof workspaceRoot !== 'string') {
|
|
21
|
+
throw new Error('Invalid workspace root: must be a non-empty string');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Prevent null bytes and other dangerous characters
|
|
25
|
+
if (workspaceRoot.includes('\0') || workspaceRoot.includes('\x00')) {
|
|
26
|
+
throw new Error('Invalid workspace root: contains null bytes');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const resolvedPath = resolve(workspaceRoot);
|
|
30
|
+
|
|
31
|
+
if (!existsSync(resolvedPath)) {
|
|
32
|
+
throw new Error(`Workspace directory does not exist: ${workspaceRoot}`);
|
|
33
|
+
} // Verify it is a directory, not a file
|
|
34
|
+
try {
|
|
35
|
+
const stats = lstatSync(resolvedPath);
|
|
36
|
+
if (!stats.isDirectory()) {
|
|
37
|
+
throw new Error(`Workspace path is not a directory: ${workspaceRoot}`);
|
|
38
|
+
}
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (error.code !== 'ENOENT') {
|
|
41
|
+
throw new Error(`Cannot access workspace directory: ${error.message}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
FileUtils.WORKSPACE_ROOT = resolvedPath;
|
|
46
|
+
console.error(`Workspace root set to: ${FileUtils.WORKSPACE_ROOT}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Get current workspace root directory
|
|
51
|
+
* @returns {string} Current workspace root directory
|
|
52
|
+
*/
|
|
53
|
+
static getWorkspaceRoot() {
|
|
54
|
+
return FileUtils.WORKSPACE_ROOT;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Securely resolve file path, ensuring it does not go outside the workspace
|
|
59
|
+
* Uses a mature third-party library to prevent path traversal attacks
|
|
60
|
+
* @param {string} filePath - Input file path
|
|
61
|
+
* @returns {string} Secure absolute path
|
|
62
|
+
* @throws {Error} If path tries to access files outside the workspace or workspace is not set
|
|
63
|
+
*/
|
|
64
|
+
static getSecurePath(filePath) {
|
|
65
|
+
// First check if workspace is set
|
|
66
|
+
if (!FileUtils.WORKSPACE_ROOT) {
|
|
67
|
+
throw new Error('Workspace not set. Please call set_workspace first to establish a secure workspace root directory.');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Input validation
|
|
71
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
72
|
+
throw new Error('Invalid file path: must be a non-empty string');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Prevent null bytes and other dangerous characters
|
|
76
|
+
if (filePath.includes('\0') || filePath.includes('\x00')) {
|
|
77
|
+
throw new Error('Invalid file path: contains null bytes');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let targetPath;
|
|
81
|
+
|
|
82
|
+
if (isAbsolute(filePath)) {
|
|
83
|
+
// Absolute path: use directly
|
|
84
|
+
targetPath = filePath;
|
|
85
|
+
} else {
|
|
86
|
+
// Relative path: join to workspace root
|
|
87
|
+
targetPath = join(FileUtils.WORKSPACE_ROOT, filePath);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Resolve to canonical absolute path, handle symlinks
|
|
91
|
+
let resolvedPath;
|
|
92
|
+
try {
|
|
93
|
+
resolvedPath = resolve(targetPath);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
throw new Error(`Failed to resolve path '${filePath}': ${error.message}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Use mature third-party library to check if path is inside workspace
|
|
99
|
+
if (!pathIsInside(resolvedPath, FileUtils.WORKSPACE_ROOT) && resolvedPath !== FileUtils.WORKSPACE_ROOT) {
|
|
100
|
+
throw new Error(`Access denied: Path is outside the workspace boundary`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return resolvedPath;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Check if file exists
|
|
108
|
+
* @param {string} filePath - File path
|
|
109
|
+
* @returns {boolean} Whether file exists
|
|
110
|
+
*/
|
|
111
|
+
static fileExists(filePath) {
|
|
112
|
+
const securePath = FileUtils.getSecurePath(filePath);
|
|
113
|
+
return existsSync(securePath);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Read file content
|
|
118
|
+
* @param {string} filePath - File path
|
|
119
|
+
* @returns {Promise<string>} File content
|
|
120
|
+
*/
|
|
121
|
+
static async readFile(filePath) {
|
|
122
|
+
const securePath = FileUtils.getSecurePath(filePath);
|
|
123
|
+
if (!existsSync(securePath)) {
|
|
124
|
+
throw new Error(`File not found: ${securePath}`);
|
|
125
|
+
}
|
|
126
|
+
return await fs.readFile(securePath, 'utf8');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Write file content
|
|
131
|
+
* @param {string} filePath - File path
|
|
132
|
+
* @param {string} content - File content
|
|
133
|
+
*/
|
|
134
|
+
static async writeFile(filePath, content) {
|
|
135
|
+
const securePath = FileUtils.getSecurePath(filePath);
|
|
136
|
+
const dir = dirname(securePath);
|
|
137
|
+
|
|
138
|
+
// Ensure directory exists
|
|
139
|
+
await fs.mkdir(dir, { recursive: true });
|
|
140
|
+
await fs.writeFile(securePath, content, 'utf8');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Create MCP standard response format
|
|
145
|
+
* @param {string} text - Response text
|
|
146
|
+
* @returns {Object} MCP response format
|
|
147
|
+
*/
|
|
148
|
+
static createResponse(text) {
|
|
149
|
+
return {
|
|
150
|
+
content: [
|
|
151
|
+
{
|
|
152
|
+
type: "text",
|
|
153
|
+
text: text
|
|
154
|
+
}
|
|
155
|
+
]
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Create MCP error response format
|
|
161
|
+
* @param {string} errorMessage - Error message
|
|
162
|
+
* @returns {Object} MCP error response format
|
|
163
|
+
*/
|
|
164
|
+
static createErrorResponse(errorMessage) {
|
|
165
|
+
return {
|
|
166
|
+
isError: true,
|
|
167
|
+
content: [
|
|
168
|
+
{
|
|
169
|
+
type: "text",
|
|
170
|
+
text: `Error: ${errorMessage}`
|
|
171
|
+
}
|
|
172
|
+
]
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Count number of lines in a string
|
|
178
|
+
* @param {string} content - String content
|
|
179
|
+
* @returns {number} Number of lines
|
|
180
|
+
*/
|
|
181
|
+
static countLines(content) {
|
|
182
|
+
return content.split('\n').length;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Get specified line range from file
|
|
187
|
+
* @param {string} content - File content
|
|
188
|
+
* @param {string} lineRange - Line range, e.g. "1-10"
|
|
189
|
+
* @returns {string} Content of specified range
|
|
190
|
+
*/ static getLineRange(content, lineRange) {
|
|
191
|
+
// Validate line range format
|
|
192
|
+
if (!lineRange || !lineRange.includes('-')) {
|
|
193
|
+
throw new Error(`Invalid line range format: ${lineRange}. Expected format: 'start-end'`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const [start, end] = lineRange.split('-').map(Number);
|
|
197
|
+
|
|
198
|
+
// Validate line numbers are valid numbers
|
|
199
|
+
if (isNaN(start) || isNaN(end)) {
|
|
200
|
+
throw new Error(`Invalid line range: ${lineRange}. Start and end must be valid numbers`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Validate line numbers are positive
|
|
204
|
+
if (start < 1 || end < 1) {
|
|
205
|
+
throw new Error(`Invalid line range: ${lineRange}. Line numbers must be positive`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Validate start line cannot be greater than end line
|
|
209
|
+
if (start > end) {
|
|
210
|
+
throw new Error(`Invalid line range: ${lineRange}. Start line cannot be greater than end line`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const lines = content.split('\n');
|
|
214
|
+
|
|
215
|
+
// Validate line numbers do not exceed file range
|
|
216
|
+
if (start > lines.length) {
|
|
217
|
+
throw new Error(`Line range ${lineRange} exceeds file length (${lines.length} lines)`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const selectedLines = lines.slice(start - 1, end);
|
|
221
|
+
return selectedLines.join('\n');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Format content with line numbers
|
|
226
|
+
* @param {string} content - Original content
|
|
227
|
+
* @param {number} startLine - Starting line number, default is 1
|
|
228
|
+
* @returns {string} Formatted content with line numbers
|
|
229
|
+
*/
|
|
230
|
+
static formatWithLineNumbers(content, startLine = 1) {
|
|
231
|
+
const lines = content.split('\n');
|
|
232
|
+
const formattedLines = lines.map((line, index) => {
|
|
233
|
+
const lineNumber = startLine + index;
|
|
234
|
+
return `${lineNumber} | ${line}`;
|
|
235
|
+
});
|
|
236
|
+
return formattedLines.join('\n');
|
|
237
|
+
}
|
|
238
|
+
}
|