mcp-fs-shell-windows 0.2.7 → 0.2.19
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/README.md +41 -12
- package/dist/append_files/handler.js +2 -2
- package/dist/checksum_files/handler.js +2 -2
- package/dist/checksum_files_verif/handler.js +2 -2
- package/dist/convert_encoding/handler.js +40 -0
- package/dist/convert_encoding/schema.js +19 -0
- package/dist/copy_files/handler.js +2 -2
- package/dist/count_lines/handler.js +3 -3
- package/dist/create_directories/handler.js +2 -2
- package/dist/create_link/handler.js +90 -0
- package/dist/create_link/schema.js +25 -0
- package/dist/delete_files/handler.js +205 -14
- package/dist/delete_files/schema.js +4 -0
- package/dist/delete_files_by_pattern/handler.js +7 -7
- package/dist/dir_diff/handler.js +155 -0
- package/dist/dir_diff/schema.js +7 -0
- package/dist/directory_tree/helpers.js +5 -5
- package/dist/edit_files/handler.js +2 -2
- package/dist/file_diff/handler.js +2 -2
- package/dist/file_info/handler.js +39 -3
- package/dist/fuzzy_find_files/handler.js +2 -2
- package/dist/helpers/encoding.js +154 -0
- package/dist/helpers/path.js +133 -6
- package/dist/launch_file/handler.js +6 -4
- package/dist/list_directory/handler.js +16 -9
- package/dist/move_files/handler.js +2 -2
- package/dist/patch_files/handler.js +2 -2
- package/dist/patch_files/helpers.js +24 -4
- package/dist/read_bytes/handler.js +43 -0
- package/dist/read_bytes/schema.js +6 -0
- package/dist/read_files/handler.js +18 -9
- package/dist/read_files/schema.js +37 -3
- package/dist/replace_regex/handler.js +105 -0
- package/dist/replace_regex/schema.js +23 -0
- package/dist/search_files/handler.js +2 -2
- package/dist/search_glob/handler.js +2 -2
- package/dist/search_regex/handler.js +63 -40
- package/dist/search_regex/schema.js +1 -0
- package/dist/server.js +183 -9
- package/dist/wait_for_file/handler.js +91 -0
- package/dist/wait_for_file/schema.js +7 -0
- package/dist/write_bytes/handler.js +49 -0
- package/dist/write_bytes/schema.js +7 -0
- package/dist/write_new_files/handler.js +6 -3
- package/dist/write_new_files/schema.js +1 -0
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { spawn } from "child_process";
|
|
4
|
-
import { validatePath } from "../helpers/path.js";
|
|
4
|
+
import { validatePath, stripLongPath } from "../helpers/path.js";
|
|
5
5
|
/**
|
|
6
6
|
* Launch a file, directory, or web link in its Windows-associated
|
|
7
7
|
* application (the OS default: e.g. VLC for .mkv/.mp4, Explorer for
|
|
@@ -132,10 +132,12 @@ export async function handleLaunchFile(target, allowedDirectories) {
|
|
|
132
132
|
stats = await fs.stat(resolved);
|
|
133
133
|
}
|
|
134
134
|
catch {
|
|
135
|
-
throw new Error(`Cannot launch: path does not exist: ${resolved}`);
|
|
135
|
+
throw new Error(`Cannot launch: path does not exist: ${stripLongPath(resolved)}`);
|
|
136
136
|
}
|
|
137
137
|
try {
|
|
138
|
-
|
|
138
|
+
// The opener (shell) receives the plain path: Windows shell APIs handle
|
|
139
|
+
// long paths themselves, while the \\?\ form can break association lookup.
|
|
140
|
+
await launchDetached(opener, stripLongPath(resolved));
|
|
139
141
|
}
|
|
140
142
|
catch (err) {
|
|
141
143
|
throw new Error(`Failed to launch target: ${errorMessage(err)}`);
|
|
@@ -143,7 +145,7 @@ export async function handleLaunchFile(target, allowedDirectories) {
|
|
|
143
145
|
return JSON.stringify({
|
|
144
146
|
target: trimmed,
|
|
145
147
|
kind: stats.isDirectory() ? "directory" : "file",
|
|
146
|
-
resolved,
|
|
148
|
+
resolved: stripLongPath(resolved),
|
|
147
149
|
dispatched: true,
|
|
148
150
|
});
|
|
149
151
|
}
|
|
@@ -1,25 +1,32 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
2
|
import path from "path";
|
|
3
|
-
import { validatePath } from "../helpers/path.js";
|
|
3
|
+
import { validatePath, stripLongPath } from "../helpers/path.js";
|
|
4
4
|
import { minimatch } from "minimatch";
|
|
5
5
|
export async function handleListDirectory(directoryPath, allowedDirectories, type = "all", ignore = [], offset = 1, limit = 100, includeSizes = true) {
|
|
6
6
|
const validPath = await validatePath(directoryPath, allowedDirectories);
|
|
7
7
|
const entries = await fs.readdir(validPath, { withFileTypes: true });
|
|
8
8
|
let filtered = entries.filter((entry) => {
|
|
9
|
-
if (
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
if (entry.isSymbolicLink()) {
|
|
10
|
+
// Links (junctions/symlinks) are only listed with type "all"
|
|
11
|
+
if (type !== "all")
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
if (type === "files" && !entry.isFile())
|
|
16
|
+
return false;
|
|
17
|
+
if (type === "directories" && !entry.isDirectory())
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
13
20
|
return !ignore.some((pattern) => minimatch(entry.name, pattern, { dot: true }));
|
|
14
21
|
});
|
|
15
22
|
// Stable ordering for pagination
|
|
16
23
|
filtered = filtered.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
|
|
17
24
|
const total = filtered.length;
|
|
18
25
|
if (total === 0) {
|
|
19
|
-
return `No entries in ${validPath}`;
|
|
26
|
+
return `No entries in ${stripLongPath(validPath)}`;
|
|
20
27
|
}
|
|
21
28
|
if (offset > total) {
|
|
22
|
-
return `No entries in ${validPath} (offset ${offset} is beyond ${total} entries)`;
|
|
29
|
+
return `No entries in ${stripLongPath(validPath)} (offset ${offset} is beyond ${total} entries)`;
|
|
23
30
|
}
|
|
24
31
|
const start = offset - 1;
|
|
25
32
|
const shown = filtered.slice(start, start + limit);
|
|
@@ -35,8 +42,8 @@ export async function handleListDirectory(directoryPath, allowedDirectories, typ
|
|
|
35
42
|
sizeText = "";
|
|
36
43
|
}
|
|
37
44
|
}
|
|
38
|
-
lines.push(`${entry.isDirectory() ? "[DIR]" : "[FILE]"} ${entry.name}${sizeText}`);
|
|
45
|
+
lines.push(`${entry.isSymbolicLink() ? "[LINK]" : entry.isDirectory() ? "[DIR]" : "[FILE]"} ${entry.name}${sizeText}`);
|
|
39
46
|
}
|
|
40
47
|
const end = start + shown.length;
|
|
41
|
-
return `Showing ${offset}-${end} of ${total} entries in ${validPath}\n` + lines.join("\n");
|
|
48
|
+
return `Showing ${offset}-${end} of ${total} entries in ${stripLongPath(validPath)}\n` + lines.join("\n");
|
|
42
49
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
2
|
import path from "path";
|
|
3
|
-
import { validatePath, ensureDirectoryExists } from "../helpers/path.js";
|
|
3
|
+
import { validatePath, ensureDirectoryExists, stripLongPathAll } from "../helpers/path.js";
|
|
4
4
|
export async function handleMoveFiles(items, overwrite, allowedDirectories) {
|
|
5
5
|
const results = [];
|
|
6
6
|
const errors = [];
|
|
@@ -51,7 +51,7 @@ export async function handleMoveFiles(items, overwrite, allowedDirectories) {
|
|
|
51
51
|
results.push(`Successfully moved ${item.source} to ${item.destination}`);
|
|
52
52
|
}
|
|
53
53
|
catch (error) {
|
|
54
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
54
|
+
const errorMessage = stripLongPathAll(error instanceof Error ? error.message : String(error));
|
|
55
55
|
errors.push(`Failed to move ${item.source} to ${item.destination}: ${errorMessage}`);
|
|
56
56
|
}
|
|
57
57
|
}));
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { validatePath } from "../helpers/path.js";
|
|
1
|
+
import { validatePath, stripLongPathAll } from "../helpers/path.js";
|
|
2
2
|
import { applyFilePatches } from "./helpers.js";
|
|
3
3
|
export async function handlePatchFiles(files, dryRun, options, allowedDirectories) {
|
|
4
4
|
const results = [];
|
|
@@ -12,7 +12,7 @@ export async function handlePatchFiles(files, dryRun, options, allowedDirectorie
|
|
|
12
12
|
results.push(`File: ${file.path}\n${result}`);
|
|
13
13
|
}
|
|
14
14
|
catch (error) {
|
|
15
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
15
|
+
const errorMessage = stripLongPathAll(error instanceof Error ? error.message : String(error));
|
|
16
16
|
errors.push(`Failed to patch ${file.path}: ${errorMessage}`);
|
|
17
17
|
}
|
|
18
18
|
}));
|
|
@@ -1,8 +1,27 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
2
|
import { createUnifiedDiff, normalizeLineEndings } from "../helpers/diff.js";
|
|
3
|
+
import { stripLongPath } from "../helpers/path.js";
|
|
4
|
+
// Detect the dominant line ending of the raw file content so it can be
|
|
5
|
+
// restored on write (mirrors the edit_files approach). CRLF is chosen only
|
|
6
|
+
// when it is the strict majority of the file's newlines; otherwise the file
|
|
7
|
+
// is treated as LF (also the outcome for a CRLF/LF tie, matching the
|
|
8
|
+
// previous LF-normalizing default).
|
|
9
|
+
export function detectDominantEol(text) {
|
|
10
|
+
const crlf = (text.match(/\r\n/g) || []).length;
|
|
11
|
+
const others = (text.replace(/\r\n/g, "").match(/[\r\n]/g) || []).length;
|
|
12
|
+
return crlf > others ? "\r\n" : "\n";
|
|
13
|
+
}
|
|
14
|
+
// Re-apply the dominant line ending to LF-normalized content (no-op for LF).
|
|
15
|
+
export function fromLf(text, eol) {
|
|
16
|
+
return eol === "\n" ? text : text.replace(/\n/g, eol);
|
|
17
|
+
}
|
|
3
18
|
export async function applyFilePatches(filePath, patches, dryRun = false, options = { preserveIndentation: true }) {
|
|
4
|
-
// Read file
|
|
5
|
-
|
|
19
|
+
// Read the raw file, detect its dominant line ending, and normalize to LF
|
|
20
|
+
// for patching. The dominant ending is restored on write so the file keeps
|
|
21
|
+
// its original EOL convention.
|
|
22
|
+
const raw = await fs.readFile(filePath, "utf-8");
|
|
23
|
+
const eol = detectDominantEol(raw);
|
|
24
|
+
const content = normalizeLineEndings(raw);
|
|
6
25
|
// Split content into lines
|
|
7
26
|
const contentLines = content.split("\n");
|
|
8
27
|
// Sort patches by line number in descending order so every patch references
|
|
@@ -77,7 +96,7 @@ export async function applyFilePatches(filePath, patches, dryRun = false, option
|
|
|
77
96
|
}
|
|
78
97
|
const modifiedContent = modifiedContentLines.join("\n");
|
|
79
98
|
// Create unified diff
|
|
80
|
-
const diff = createUnifiedDiff(content, modifiedContent, filePath, filePath);
|
|
99
|
+
const diff = createUnifiedDiff(content, modifiedContent, stripLongPath(filePath), stripLongPath(filePath));
|
|
81
100
|
// Format diff with appropriate number of backticks
|
|
82
101
|
let numBackticks = 3;
|
|
83
102
|
while (diff.includes("`".repeat(numBackticks))) {
|
|
@@ -97,7 +116,8 @@ export async function applyFilePatches(filePath, patches, dryRun = false, option
|
|
|
97
116
|
}
|
|
98
117
|
});
|
|
99
118
|
if (!dryRun) {
|
|
100
|
-
|
|
119
|
+
// Write back with the file's original dominant line ending.
|
|
120
|
+
await fs.writeFile(filePath, fromLf(modifiedContent, eol), "utf-8");
|
|
101
121
|
}
|
|
102
122
|
return resultText;
|
|
103
123
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import { validatePath } from "../helpers/path.js";
|
|
3
|
+
export async function handleReadBytes(filePath, offset, length, allowedDirectories) {
|
|
4
|
+
const validPath = await validatePath(filePath, allowedDirectories);
|
|
5
|
+
let stats;
|
|
6
|
+
try {
|
|
7
|
+
stats = await fs.stat(validPath);
|
|
8
|
+
}
|
|
9
|
+
catch (error) {
|
|
10
|
+
const code = error.code;
|
|
11
|
+
if (code === "ENOENT") {
|
|
12
|
+
throw new Error(`File does not exist: ${filePath}`);
|
|
13
|
+
}
|
|
14
|
+
throw error;
|
|
15
|
+
}
|
|
16
|
+
if (!stats.isFile()) {
|
|
17
|
+
throw new Error(`Not a file: ${filePath}`);
|
|
18
|
+
}
|
|
19
|
+
const size = stats.size;
|
|
20
|
+
if (offset >= size) {
|
|
21
|
+
throw new Error(`offset beyond EOF: offset ${offset} >= file size ${size}`);
|
|
22
|
+
}
|
|
23
|
+
const actualLength = Math.min(length, size - offset);
|
|
24
|
+
const buffer = Buffer.alloc(actualLength);
|
|
25
|
+
const handle = await fs.open(validPath, "r");
|
|
26
|
+
try {
|
|
27
|
+
const { bytesRead } = await handle.read(buffer, 0, actualLength, offset);
|
|
28
|
+
if (bytesRead !== actualLength) {
|
|
29
|
+
throw new Error(`Short read: expected ${actualLength} bytes at offset ${offset}, got ${bytesRead}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
await handle.close();
|
|
34
|
+
}
|
|
35
|
+
return JSON.stringify({
|
|
36
|
+
path: filePath,
|
|
37
|
+
offset,
|
|
38
|
+
length,
|
|
39
|
+
actualLength,
|
|
40
|
+
size,
|
|
41
|
+
dataBase64: buffer.toString("base64"),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const ReadBytesArgsSchema = z.object({
|
|
3
|
+
path: z.string().describe("Path to the file to read"),
|
|
4
|
+
offset: z.number().int().min(0).describe("Byte offset to start reading from (0-indexed). Must be less than the file size."),
|
|
5
|
+
length: z.number().int().min(1).describe("Number of bytes to read. If offset+length exceeds the file size, the read is clamped to EOF."),
|
|
6
|
+
});
|
|
@@ -1,34 +1,43 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
|
-
import { validatePath } from "../helpers/path.js";
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
import { validatePath, stripLongPathAll } from "../helpers/path.js";
|
|
3
|
+
import { decodeText, detectEncoding, } from "../helpers/encoding.js";
|
|
4
|
+
export async function handleReadFiles(fileItems, allowedDirectories, offset, limit) {
|
|
5
|
+
const results = await Promise.all(fileItems.map(async (item) => {
|
|
6
|
+
const filePath = typeof item === "string" ? item : item.path;
|
|
5
7
|
try {
|
|
6
8
|
const validPath = await validatePath(filePath, allowedDirectories);
|
|
7
|
-
const
|
|
9
|
+
const raw = await fs.readFile(validPath);
|
|
10
|
+
// Decode with the file's explicit encoding when given, otherwise
|
|
11
|
+
// auto-detect from the raw bytes (BOM, then strict UTF-8, else cp1252).
|
|
12
|
+
const encoding = (typeof item !== "string" && item.encoding) || detectEncoding(raw);
|
|
13
|
+
const content = decodeText(raw, encoding);
|
|
14
|
+
// Per-file offset/limit (object form) fall back to the global ones.
|
|
15
|
+
const effOffset = (typeof item !== "string" && item.offset) || offset;
|
|
16
|
+
const effLimit = (typeof item !== "string" && item.limit) || limit;
|
|
8
17
|
// Split into lines, keeping original line numbers
|
|
9
18
|
const lines = content.split("\n");
|
|
10
19
|
const total = lines.length;
|
|
11
20
|
let startLine = 1;
|
|
12
21
|
let endLine = total;
|
|
13
|
-
if (
|
|
14
|
-
startLine =
|
|
22
|
+
if (effOffset !== undefined || effLimit !== undefined) {
|
|
23
|
+
startLine = effOffset ?? 1;
|
|
15
24
|
if (startLine > total) {
|
|
16
25
|
throw new Error(`offset ${startLine} is beyond end of file (${total} lines)`);
|
|
17
26
|
}
|
|
18
|
-
endLine =
|
|
27
|
+
endLine = effLimit !== undefined ? Math.min(total, startLine + effLimit - 1) : total;
|
|
19
28
|
}
|
|
20
29
|
// Format content with line numbers
|
|
21
30
|
const numberedContent = lines
|
|
22
31
|
.slice(startLine - 1, endLine)
|
|
23
32
|
.map((line, idx) => `${startLine + idx}: ${line}`)
|
|
24
33
|
.join("\n");
|
|
25
|
-
const header =
|
|
34
|
+
const header = effOffset !== undefined || effLimit !== undefined
|
|
26
35
|
? `${filePath}: [lines ${startLine}-${endLine} of ${total}]\n`
|
|
27
36
|
: `${filePath}:\n`;
|
|
28
37
|
return `${header}${numberedContent}\n`;
|
|
29
38
|
}
|
|
30
39
|
catch (error) {
|
|
31
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
40
|
+
const errorMessage = stripLongPathAll(error instanceof Error ? error.message : String(error));
|
|
32
41
|
return `${filePath}: Error - ${errorMessage}`;
|
|
33
42
|
}
|
|
34
43
|
}));
|
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
export const ReadFileItemSchema = z.union([
|
|
3
|
+
z.string().describe("Path to the file to read"),
|
|
4
|
+
z.object({
|
|
5
|
+
path: z.string().describe("Path to the file to read"),
|
|
6
|
+
encoding: z
|
|
7
|
+
.enum(["utf8", "utf16le", "utf16be", "cp1252"])
|
|
8
|
+
.optional()
|
|
9
|
+
.describe("Decoding to use for this file (a matching BOM is stripped). Omit to auto-detect from the file's bytes (BOM FF FE -> utf16le, FE FF -> utf16be, EF BB BF -> utf8, otherwise strict UTF-8 -> utf8, else cp1252)"),
|
|
10
|
+
offset: z
|
|
11
|
+
.number()
|
|
12
|
+
.int()
|
|
13
|
+
.min(1)
|
|
14
|
+
.optional()
|
|
15
|
+
.describe("Start reading from this line number (1-indexed). Overrides the global offset for this file."),
|
|
16
|
+
limit: z
|
|
17
|
+
.number()
|
|
18
|
+
.int()
|
|
19
|
+
.min(1)
|
|
20
|
+
.optional()
|
|
21
|
+
.describe("Maximum number of lines to read for this file. Overrides the global limit."),
|
|
22
|
+
}),
|
|
23
|
+
]);
|
|
2
24
|
export const ReadFilesArgsSchema = z.object({
|
|
3
|
-
paths: z
|
|
4
|
-
|
|
5
|
-
|
|
25
|
+
paths: z
|
|
26
|
+
.array(ReadFileItemSchema)
|
|
27
|
+
.describe("Files to read. Each item is a plain path string, or an object {path, encoding?, offset?, limit?}."),
|
|
28
|
+
offset: z
|
|
29
|
+
.number()
|
|
30
|
+
.int()
|
|
31
|
+
.min(1)
|
|
32
|
+
.optional()
|
|
33
|
+
.describe("Start reading from this line number (1-indexed). Omit to read from the beginning."),
|
|
34
|
+
limit: z
|
|
35
|
+
.number()
|
|
36
|
+
.int()
|
|
37
|
+
.min(1)
|
|
38
|
+
.optional()
|
|
39
|
+
.describe("Maximum number of lines to read per file. Omit to read to the end."),
|
|
6
40
|
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import { validatePath } from "../helpers/path.js";
|
|
3
|
+
import { createUnifiedDiff, normalizeLineEndings } from "../helpers/diff.js";
|
|
4
|
+
import { detectDominantEol, fromLf } from "../patch_files/helpers.js";
|
|
5
|
+
// Ensure the 'g' (global) flag is present in the flag set (deduplicated) and
|
|
6
|
+
// add it if the caller omitted it, so that every match is replaced. Caller
|
|
7
|
+
// flags such as 'i' are preserved.
|
|
8
|
+
function withGlobalFlag(flags) {
|
|
9
|
+
const set = new Set();
|
|
10
|
+
for (const ch of flags || "")
|
|
11
|
+
set.add(ch);
|
|
12
|
+
set.add("g");
|
|
13
|
+
return Array.from(set).join("");
|
|
14
|
+
}
|
|
15
|
+
export async function handleReplaceRegex(paths, pattern, replacement, flags, dryRun, allowedDirectories) {
|
|
16
|
+
const effectiveFlags = withGlobalFlag(flags || "");
|
|
17
|
+
// Validate the regex up front. If it is invalid (bad pattern or bad flags),
|
|
18
|
+
// throw so that NOTHING is written for this call.
|
|
19
|
+
try {
|
|
20
|
+
new RegExp(pattern, effectiveFlags);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
throw new Error(`Invalid regular expression "${pattern}" (flags "${effectiveFlags}"): ${error instanceof Error ? error.message : String(error)}`);
|
|
24
|
+
}
|
|
25
|
+
// Phase 1: validate every path and compute all results WITHOUT writing.
|
|
26
|
+
// Any missing path, directory, or unreadable file throws, so NOTHING is
|
|
27
|
+
// written for the whole call (all-or-nothing). Writes happen only in Phase 2,
|
|
28
|
+
// after this loop completes successfully.
|
|
29
|
+
const computed = [];
|
|
30
|
+
let totalReplacements = 0;
|
|
31
|
+
for (const requestedPath of paths) {
|
|
32
|
+
const resolvedPath = await validatePath(requestedPath, allowedDirectories);
|
|
33
|
+
let stat;
|
|
34
|
+
try {
|
|
35
|
+
stat = await fs.stat(resolvedPath);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
throw new Error(`Path not found: ${requestedPath}`);
|
|
39
|
+
}
|
|
40
|
+
if (stat.isDirectory()) {
|
|
41
|
+
throw new Error(`Directory not accepted: ${requestedPath}. replace_regex operates on files only - list the specific file path(s) to change.`);
|
|
42
|
+
}
|
|
43
|
+
if (!stat.isFile()) {
|
|
44
|
+
throw new Error(`Not a regular file: ${requestedPath}`);
|
|
45
|
+
}
|
|
46
|
+
const raw = await fs.readFile(resolvedPath, "utf-8");
|
|
47
|
+
const eol = detectDominantEol(raw);
|
|
48
|
+
// Run the replacement in LF-normalized space, then restore the file's
|
|
49
|
+
// dominant line ending on write so a pure-CRLF file stays fully CRLF
|
|
50
|
+
// (byte-level; no mixed endings). Same approach as patch_files.
|
|
51
|
+
const content = normalizeLineEndings(raw);
|
|
52
|
+
// Count matches (fresh global regex; advance on zero-length matches to
|
|
53
|
+
// avoid an infinite loop, mirroring search_regex).
|
|
54
|
+
const countRegex = new RegExp(pattern, effectiveFlags);
|
|
55
|
+
let matches = 0;
|
|
56
|
+
let m;
|
|
57
|
+
while ((m = countRegex.exec(content)) !== null) {
|
|
58
|
+
matches++;
|
|
59
|
+
if (m[0].length === 0)
|
|
60
|
+
countRegex.lastIndex++;
|
|
61
|
+
}
|
|
62
|
+
// Perform the replacement (fresh global regex; $1/${name}/$&/$' etc. are
|
|
63
|
+
// handled by the engine). The count of matches equals the number of
|
|
64
|
+
// replacements for a global pattern.
|
|
65
|
+
const replaceRegex = new RegExp(pattern, effectiveFlags);
|
|
66
|
+
const newContent = content.replace(replaceRegex, replacement);
|
|
67
|
+
totalReplacements += matches;
|
|
68
|
+
const entry = {
|
|
69
|
+
requestedPath,
|
|
70
|
+
resolvedPath,
|
|
71
|
+
matches,
|
|
72
|
+
writeText: fromLf(newContent, eol),
|
|
73
|
+
};
|
|
74
|
+
if (dryRun) {
|
|
75
|
+
entry.preview =
|
|
76
|
+
matches === 0
|
|
77
|
+
? "(no matches - no change)"
|
|
78
|
+
: createUnifiedDiff(content, newContent, requestedPath, requestedPath);
|
|
79
|
+
}
|
|
80
|
+
computed.push(entry);
|
|
81
|
+
}
|
|
82
|
+
// Phase 2: apply writes (only when not a dry run). All Phase-1 computation
|
|
83
|
+
// above has already succeeded, so every file can be written safely.
|
|
84
|
+
if (!dryRun) {
|
|
85
|
+
for (const entry of computed) {
|
|
86
|
+
await fs.writeFile(entry.resolvedPath, entry.writeText, "utf-8");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Build the text output.
|
|
90
|
+
const mode = dryRun
|
|
91
|
+
? "Dry run (preview only - NO files were written)"
|
|
92
|
+
: "Applied (files written in place)";
|
|
93
|
+
let output = `${mode}: ${computed.length} file(s), ${totalReplacements} replacement(s) total.\n`;
|
|
94
|
+
for (const entry of computed) {
|
|
95
|
+
output += `\nFile: ${entry.requestedPath}\n`;
|
|
96
|
+
output += ` matches: ${entry.matches}\n`;
|
|
97
|
+
if (dryRun && entry.preview !== undefined) {
|
|
98
|
+
output += ` preview:\n${entry.preview}\n`;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (dryRun) {
|
|
102
|
+
output += `\n(No changes were written. Re-run with dryRun=false to apply.)`;
|
|
103
|
+
}
|
|
104
|
+
return output;
|
|
105
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const ReplaceRegexArgsSchema = z.object({
|
|
3
|
+
paths: z
|
|
4
|
+
.array(z.string())
|
|
5
|
+
.min(1)
|
|
6
|
+
.describe("Paths to the files to replace regex matches in. One or many FILES may be listed; directories are NOT accepted (a directory path is an error and nothing is written)."),
|
|
7
|
+
pattern: z
|
|
8
|
+
.string()
|
|
9
|
+
.describe("Regular expression pattern to match in each file (JavaScript regex syntax)."),
|
|
10
|
+
replacement: z
|
|
11
|
+
.string()
|
|
12
|
+
.describe("Replacement text for each match. Standard regex replacement syntax is supported (e.g. $1, ${name}, $&, $')."),
|
|
13
|
+
flags: z
|
|
14
|
+
.string()
|
|
15
|
+
.optional()
|
|
16
|
+
.default("")
|
|
17
|
+
.describe("Additional regex flags (e.g. 'i' case-insensitive, 'm', 's', 'u'). The 'g' (global) flag is ALWAYS applied so every match is replaced; if you omit 'g' it is forced on."),
|
|
18
|
+
dryRun: z
|
|
19
|
+
.boolean()
|
|
20
|
+
.optional()
|
|
21
|
+
.default(true)
|
|
22
|
+
.describe("When true (default), preview the WOULD-BE change per file (unified diff) without writing anything. When false, apply the replacements in place and report per-file match counts plus a total."),
|
|
23
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { validatePath } from "../helpers/path.js";
|
|
1
|
+
import { validatePath, stripLongPath } from "../helpers/path.js";
|
|
2
2
|
import { searchFiles } from "./helpers.js";
|
|
3
3
|
export async function handleSearchFiles(directoryPath, pattern, excludePatterns, maxResults, offset, allowedDirectories) {
|
|
4
4
|
const validPath = await validatePath(directoryPath, allowedDirectories);
|
|
@@ -11,5 +11,5 @@ export async function handleSearchFiles(directoryPath, pattern, excludePatterns,
|
|
|
11
11
|
}
|
|
12
12
|
const end = offset + shown.length - 1;
|
|
13
13
|
const totalText = stopped ? `at least ${results.length}` : `${results.length}`;
|
|
14
|
-
return `Found ${totalText} files matching "${pattern}" (showing ${offset}-${end})\n\n` + shown.join("\n");
|
|
14
|
+
return `Found ${totalText} files matching "${pattern}" (showing ${offset}-${end})\n\n` + shown.map(stripLongPath).join("\n");
|
|
15
15
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
2
|
import path from "path";
|
|
3
|
-
import { validatePath } from "../helpers/path.js";
|
|
3
|
+
import { validatePath, stripLongPath } from "../helpers/path.js";
|
|
4
4
|
import { minimatch } from "minimatch";
|
|
5
5
|
export async function handleSearchGlob(searchPath, pattern, excludePatterns, maxResults, offset, allowedDirectories) {
|
|
6
6
|
// Validate root path
|
|
@@ -70,7 +70,7 @@ export async function handleSearchGlob(searchPath, pattern, excludePatterns, max
|
|
|
70
70
|
}
|
|
71
71
|
output += "\n\n";
|
|
72
72
|
for (const result of shown) {
|
|
73
|
-
output += `${result}\n`;
|
|
73
|
+
output += `${stripLongPath(result)}\n`;
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
return output;
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
2
|
import path from "path";
|
|
3
|
-
import { validatePath } from "../helpers/path.js";
|
|
3
|
+
import { validatePath, stripLongPath } from "../helpers/path.js";
|
|
4
4
|
import { minimatch } from "minimatch";
|
|
5
|
-
export async function handleSearchRegex(searchPath, pattern, filePatterns, excludePatterns, maxResults, caseSensitive, offset, allowedDirectories) {
|
|
5
|
+
export async function handleSearchRegex(searchPath, pattern, filePatterns, excludePatterns, maxResults, caseSensitive, offset, allowedDirectories, countOnly) {
|
|
6
6
|
// Validate root path (may be a directory or a single file)
|
|
7
7
|
const validRootPath = await validatePath(searchPath, allowedDirectories);
|
|
8
8
|
const results = [];
|
|
9
|
+
const perFileCounts = new Map();
|
|
10
|
+
const filesSearchedList = [];
|
|
9
11
|
let filesSearched = 0;
|
|
10
|
-
let
|
|
11
|
-
let searchAborted = false;
|
|
12
|
+
let totalMatches = 0;
|
|
12
13
|
const effectiveCap = offset - 1 + maxResults;
|
|
13
14
|
// Create regex pattern
|
|
14
15
|
const regexFlags = caseSensitive ? "mg" : "img";
|
|
@@ -20,38 +21,40 @@ export async function handleSearchRegex(searchPath, pattern, filePatterns, exclu
|
|
|
20
21
|
throw new Error(`Invalid regular expression: ${pattern}`);
|
|
21
22
|
}
|
|
22
23
|
async function scanFile(fullPath) {
|
|
23
|
-
if (searchAborted)
|
|
24
|
-
return;
|
|
25
24
|
try {
|
|
26
25
|
filesSearched++;
|
|
26
|
+
filesSearchedList.push(fullPath);
|
|
27
27
|
const content = await fs.readFile(fullPath, "utf-8");
|
|
28
28
|
const lines = content.split("\n");
|
|
29
29
|
regex.lastIndex = 0;
|
|
30
30
|
let match;
|
|
31
|
+
// Every occurrence is counted (a line containing the pattern 4 times
|
|
32
|
+
// counts as 4 matches). Line results are collected only while under the
|
|
33
|
+
// pagination cap; counting always runs to completion so the reported
|
|
34
|
+
// total is exact.
|
|
31
35
|
while ((match = regex.exec(content)) !== null) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
charCount
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
36
|
+
totalMatches++;
|
|
37
|
+
perFileCounts.set(fullPath, (perFileCounts.get(fullPath) ?? 0) + 1);
|
|
38
|
+
if (!countOnly && results.length < effectiveCap) {
|
|
39
|
+
// Find the line number for this match
|
|
40
|
+
const matchPosition = match.index;
|
|
41
|
+
let lineNumber = 0;
|
|
42
|
+
let charCount = 0;
|
|
43
|
+
for (let i = 0; i < lines.length; i++) {
|
|
44
|
+
charCount += lines[i].length + 1; // +1 for the newline
|
|
45
|
+
if (charCount > matchPosition) {
|
|
46
|
+
lineNumber = i + 1;
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
42
49
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
});
|
|
52
|
-
if (results.length >= effectiveCap) {
|
|
53
|
-
searchAborted = true;
|
|
54
|
-
break;
|
|
50
|
+
// Get the line content
|
|
51
|
+
const lineContent = lines[lineNumber - 1]?.trim() || "";
|
|
52
|
+
results.push({
|
|
53
|
+
file: fullPath,
|
|
54
|
+
line: lineNumber,
|
|
55
|
+
content: lineContent,
|
|
56
|
+
match: match[0],
|
|
57
|
+
});
|
|
55
58
|
}
|
|
56
59
|
// Guard against zero-length matches (e.g. pattern "a*") looping forever
|
|
57
60
|
if (match[0].length === 0) {
|
|
@@ -64,13 +67,9 @@ export async function handleSearchRegex(searchPath, pattern, filePatterns, exclu
|
|
|
64
67
|
}
|
|
65
68
|
}
|
|
66
69
|
async function searchDirectory(dirPath) {
|
|
67
|
-
if (searchAborted)
|
|
68
|
-
return;
|
|
69
70
|
try {
|
|
70
71
|
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
71
72
|
for (const entry of entries) {
|
|
72
|
-
if (searchAborted)
|
|
73
|
-
break;
|
|
74
73
|
const fullPath = path.join(dirPath, entry.name);
|
|
75
74
|
try {
|
|
76
75
|
// Validate each path before processing
|
|
@@ -122,22 +121,44 @@ export async function handleSearchRegex(searchPath, pattern, filePatterns, exclu
|
|
|
122
121
|
else {
|
|
123
122
|
await searchDirectory(validRootPath);
|
|
124
123
|
}
|
|
124
|
+
// Total-match summary (always included), with per-file counts for the
|
|
125
|
+
// files that contain at least one match, in first-match (walk) order.
|
|
126
|
+
const matchedFileEntries = [...perFileCounts.entries()];
|
|
127
|
+
const matchedFilesCount = matchedFileEntries.length;
|
|
128
|
+
let matchedFilesList = "";
|
|
129
|
+
for (const [file, count] of matchedFileEntries) {
|
|
130
|
+
matchedFilesList += ` ${stripLongPath(file)}: ${count}\n`;
|
|
131
|
+
}
|
|
132
|
+
const totalLine = `Total matches: ${totalMatches} (${matchedFilesCount} files)\n`;
|
|
125
133
|
// Format the results
|
|
126
134
|
let output = "";
|
|
127
|
-
const shown = results.slice(offset - 1);
|
|
128
|
-
if (
|
|
135
|
+
const shown = countOnly ? [] : results.slice(offset - 1);
|
|
136
|
+
if (countOnly) {
|
|
137
|
+
// Counts only: total header, files searched, then a per-file count line
|
|
138
|
+
// for EVERY searched file (0 for files with no matches).
|
|
139
|
+
output = totalLine;
|
|
140
|
+
output += `Searched ${filesSearched} files\n`;
|
|
141
|
+
if (totalMatches > 0) {
|
|
142
|
+
output += "\n";
|
|
143
|
+
for (const file of filesSearchedList) {
|
|
144
|
+
output += ` ${stripLongPath(file)}: ${perFileCounts.get(file) ?? 0}\n`;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
else if (totalMatches === 0) {
|
|
129
149
|
output = `No matches found for regex: ${pattern}\n`;
|
|
130
150
|
output += `Searched ${filesSearched} files\n`;
|
|
151
|
+
output += totalLine;
|
|
131
152
|
}
|
|
132
153
|
else if (shown.length === 0) {
|
|
133
|
-
output = `Found ${
|
|
154
|
+
output = `Found ${totalMatches} matches in ${results.length} locations (offset ${offset} is beyond the collected range)\n`;
|
|
155
|
+
output += "\n";
|
|
156
|
+
output += totalLine;
|
|
157
|
+
output += matchedFilesList;
|
|
134
158
|
}
|
|
135
159
|
else {
|
|
136
160
|
const end = offset + shown.length - 1;
|
|
137
|
-
output = `Found ${
|
|
138
|
-
if (searchAborted) {
|
|
139
|
-
output += ` (search stopped at ${effectiveCap} collected matches)`;
|
|
140
|
-
}
|
|
161
|
+
output = `Found ${totalMatches} matches in ${results.length} locations (showing ${offset}-${end})`;
|
|
141
162
|
output += "\n\n";
|
|
142
163
|
// Group by file for more readable output
|
|
143
164
|
const fileGroups = new Map();
|
|
@@ -148,12 +169,14 @@ export async function handleSearchRegex(searchPath, pattern, filePatterns, exclu
|
|
|
148
169
|
fileGroups.get(result.file)?.push(result);
|
|
149
170
|
}
|
|
150
171
|
for (const [file, fileResults] of fileGroups.entries()) {
|
|
151
|
-
output += `File: ${file}\n`;
|
|
172
|
+
output += `File: ${stripLongPath(file)}\n`;
|
|
152
173
|
for (const result of fileResults) {
|
|
153
174
|
output += ` Line ${result.line}: ${result.content}\n`;
|
|
154
175
|
}
|
|
155
176
|
output += "\n";
|
|
156
177
|
}
|
|
178
|
+
output += totalLine;
|
|
179
|
+
output += matchedFilesList;
|
|
157
180
|
}
|
|
158
181
|
return output;
|
|
159
182
|
}
|
|
@@ -7,4 +7,5 @@ export const SearchRegexArgsSchema = z.object({
|
|
|
7
7
|
maxResults: z.number().optional().default(100).describe("Maximum number of results to return"),
|
|
8
8
|
offset: z.number().int().min(1).optional().default(1).describe("1-indexed match number to start from. Default: 1."),
|
|
9
9
|
caseSensitive: z.boolean().optional().default(false).describe("Whether the search should be case-sensitive"),
|
|
10
|
+
countOnly: z.boolean().optional().default(false).describe("When true, return only per-file match counts and the total (no matching line text). Default: false."),
|
|
10
11
|
});
|