mcp-fs-shell-windows 0.2.6 → 0.2.18

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.
Files changed (47) hide show
  1. package/README.md +12 -2
  2. package/dist/append_files/handler.js +2 -2
  3. package/dist/checksum_files/handler.js +2 -2
  4. package/dist/checksum_files_verif/handler.js +2 -2
  5. package/dist/convert_encoding/handler.js +40 -0
  6. package/dist/convert_encoding/schema.js +19 -0
  7. package/dist/copy_files/handler.js +2 -2
  8. package/dist/count_lines/handler.js +3 -3
  9. package/dist/create_directories/handler.js +2 -2
  10. package/dist/create_link/handler.js +90 -0
  11. package/dist/create_link/schema.js +25 -0
  12. package/dist/delete_files/handler.js +205 -14
  13. package/dist/delete_files/schema.js +4 -0
  14. package/dist/delete_files_by_pattern/handler.js +7 -7
  15. package/dist/dir_diff/handler.js +155 -0
  16. package/dist/dir_diff/schema.js +7 -0
  17. package/dist/directory_tree/helpers.js +5 -5
  18. package/dist/edit_files/handler.js +2 -2
  19. package/dist/file_diff/handler.js +2 -2
  20. package/dist/file_info/handler.js +39 -3
  21. package/dist/fuzzy_find_files/handler.js +2 -2
  22. package/dist/helpers/encoding.js +154 -0
  23. package/dist/helpers/path.js +133 -6
  24. package/dist/launch_file/handler.js +154 -0
  25. package/dist/launch_file/schema.js +4 -0
  26. package/dist/list_directory/handler.js +16 -9
  27. package/dist/move_files/handler.js +2 -2
  28. package/dist/patch_files/handler.js +2 -2
  29. package/dist/patch_files/helpers.js +24 -4
  30. package/dist/read_bytes/handler.js +43 -0
  31. package/dist/read_bytes/schema.js +6 -0
  32. package/dist/read_files/handler.js +18 -9
  33. package/dist/read_files/schema.js +37 -3
  34. package/dist/replace_regex/handler.js +105 -0
  35. package/dist/replace_regex/schema.js +23 -0
  36. package/dist/search_files/handler.js +2 -2
  37. package/dist/search_glob/handler.js +2 -2
  38. package/dist/search_regex/handler.js +63 -40
  39. package/dist/search_regex/schema.js +1 -0
  40. package/dist/server.js +203 -9
  41. package/dist/wait_for_file/handler.js +91 -0
  42. package/dist/wait_for_file/schema.js +7 -0
  43. package/dist/write_bytes/handler.js +49 -0
  44. package/dist/write_bytes/schema.js +7 -0
  45. package/dist/write_new_files/handler.js +6 -3
  46. package/dist/write_new_files/schema.js +1 -0
  47. package/package.json +1 -1
@@ -0,0 +1,154 @@
1
+ import fs from "fs/promises";
2
+ import path from "path";
3
+ import { spawn } from "child_process";
4
+ import { validatePath, stripLongPath } from "../helpers/path.js";
5
+ /**
6
+ * Launch a file, directory, or web link in its Windows-associated
7
+ * application (the OS default: e.g. VLC for .mkv/.mp4, Explorer for
8
+ * folders, the default browser for http(s) links).
9
+ *
10
+ * Windows-only. Mechanism: spawn the shell opener with the target as a
11
+ * single argv element and stdio ignored:
12
+ * %SystemRoot%\explorer.exe <target>
13
+ *
14
+ * - Default association: explorer.exe performs a ShellExecute "open" -
15
+ * exactly what a double-click does. Nothing is hardcoded.
16
+ * - No pipe inheritance: stdio is "ignore", so the launched process never
17
+ * receives the MCP server's stdin/stdout/stderr. The handler resolves
18
+ * the moment Windows has spawned the opener - it does NOT stay pending
19
+ * until the opened application exits (the exact bug the cmd+start route
20
+ * hit: a long-lived GUI child held the pipes open).
21
+ * - No intermediary console: the opener is a GUI process and the spawn
22
+ * uses windowsHide, so no blank cmd.exe / PowerShell window appears.
23
+ * - Verbatim arguments: the target is one argv element, so spaces,
24
+ * Unicode, apostrophes, ampersands, parentheses, and every other
25
+ * filename character are passed through exactly (no cmd/PowerShell
26
+ * quoting to get wrong).
27
+ * - Security policy: filesystem targets are routed through validatePath
28
+ * (same allowed-roots + symlink resolution as every other tool). Only
29
+ * http:// and https:// links are dispatched to the OS directly; every
30
+ * other URL scheme (file:, javascript:, ms-*, steam:, vlc:, ...) is
31
+ * rejected so the tool cannot reach arbitrary Windows protocol
32
+ * handlers or bypass the allowed-roots policy.
33
+ */
34
+ // Standard-form web links - the ONLY URLs dispatched to the OS opener.
35
+ const WEB_URL = /^https?:\/\//i;
36
+ // A Windows drive path: single letter, ':', then a separator or end of
37
+ // string, e.g. "M:\\...", "C:/...", or a bare "M:".
38
+ const WINDOWS_DRIVE = /^[A-Za-z]:([\\/]|$)/;
39
+ // A URI scheme: 2+ scheme chars then ':'. (A bare drive letter is exactly
40
+ // one char, so "M:foo" is NOT mistaken for a scheme and still resolves as
41
+ // a drive-relative path through validatePath.)
42
+ const URI_SCHEME = /^[A-Za-z][A-Za-z0-9+.-]+:/;
43
+ /**
44
+ * Windows-only guard; returns the full path to the shell opener.
45
+ *
46
+ * The opener defaults to %SystemRoot%\explorer.exe. The host-settable
47
+ * LAUNCH_FILE_OPENER environment variable overrides it - a test seam that
48
+ * makes the spawn-failure path testable end-to-end without touching the
49
+ * real environment (note: faking SystemRoot itself is impossible; Node's
50
+ * Windows CSPRNG init aborts unless SystemRoot is the real Windows dir).
51
+ * Only the opener executable changes; the target still passes the full
52
+ * URL/path policy below.
53
+ */
54
+ function openerPath() {
55
+ if (process.platform !== "win32") {
56
+ throw new Error("launch_file is supported only on Windows");
57
+ }
58
+ const override = process.env.LAUNCH_FILE_OPENER;
59
+ if (override) {
60
+ return override;
61
+ }
62
+ const sysRoot = process.env.SystemRoot || "C:\\Windows";
63
+ return path.join(sysRoot, "explorer.exe");
64
+ }
65
+ /**
66
+ * Spawn the opener fully detached and settle the promise only when
67
+ * Windows has actually created the process ("spawn" event). A failed
68
+ * process creation ("error" event, e.g. opener executable missing)
69
+ * REJECTS - it never falsely reports success. Never waits for the opener
70
+ * to exit.
71
+ *
72
+ * Both listeners are removed on settlement, so there is no double
73
+ * settlement and no listener leak. Exported so tests can exercise both
74
+ * settlement paths directly.
75
+ */
76
+ export function launchDetached(opener, target) {
77
+ return new Promise((resolve, reject) => {
78
+ let child;
79
+ try {
80
+ child = spawn(opener, [target], {
81
+ stdio: "ignore",
82
+ detached: true,
83
+ windowsHide: true,
84
+ });
85
+ }
86
+ catch (err) {
87
+ reject(err);
88
+ return;
89
+ }
90
+ const onSpawn = () => {
91
+ child.removeListener("error", onError);
92
+ child.unref();
93
+ resolve();
94
+ };
95
+ const onError = (err) => {
96
+ child.removeListener("spawn", onSpawn);
97
+ child.unref();
98
+ reject(err);
99
+ };
100
+ child.once("spawn", onSpawn);
101
+ child.once("error", onError);
102
+ });
103
+ }
104
+ export async function handleLaunchFile(target, allowedDirectories) {
105
+ const trimmed = (target ?? "").trim();
106
+ if (!trimmed) {
107
+ throw new Error("path must not be empty");
108
+ }
109
+ const opener = openerPath();
110
+ // --- Web link: only http/https; ask the OS to open it (default browser) ---
111
+ if (WEB_URL.test(trimmed)) {
112
+ try {
113
+ await launchDetached(opener, trimmed);
114
+ }
115
+ catch (err) {
116
+ throw new Error(`Failed to launch URL: ${errorMessage(err)}`);
117
+ }
118
+ return JSON.stringify({ target: trimmed, kind: "url", dispatched: true });
119
+ }
120
+ // --- Other URL schemes: reject. This keeps file: URLs (and any other
121
+ // registered protocol handler) from bypassing the filesystem policy. ---
122
+ if (URI_SCHEME.test(trimmed) && !WINDOWS_DRIVE.test(trimmed)) {
123
+ throw new Error("Unsupported URL: only http:// and https:// links can be launched. " +
124
+ 'Filesystem targets must be plain paths (e.g. "M:\\..." or "\\\\server\\share"), ' +
125
+ "not file: URLs or other protocol schemes.");
126
+ }
127
+ // --- Filesystem target: enforce the existing path/security policy ---
128
+ const resolved = await validatePath(trimmed, allowedDirectories);
129
+ // Useful error for a target that does not exist.
130
+ let stats;
131
+ try {
132
+ stats = await fs.stat(resolved);
133
+ }
134
+ catch {
135
+ throw new Error(`Cannot launch: path does not exist: ${stripLongPath(resolved)}`);
136
+ }
137
+ try {
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));
141
+ }
142
+ catch (err) {
143
+ throw new Error(`Failed to launch target: ${errorMessage(err)}`);
144
+ }
145
+ return JSON.stringify({
146
+ target: trimmed,
147
+ kind: stats.isDirectory() ? "directory" : "file",
148
+ resolved: stripLongPath(resolved),
149
+ dispatched: true,
150
+ });
151
+ }
152
+ function errorMessage(err) {
153
+ return err instanceof Error ? err.message : String(err);
154
+ }
@@ -0,0 +1,4 @@
1
+ import { z } from "zod";
2
+ export const LaunchFileArgsSchema = z.object({
3
+ path: z.string(),
4
+ });
@@ -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 (type === "files" && !entry.isFile())
10
- return false;
11
- if (type === "directories" && !entry.isDirectory())
12
- return false;
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 content and normalize line endings
5
- const content = normalizeLineEndings(await fs.readFile(filePath, "utf-8"));
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
- await fs.writeFile(filePath, modifiedContent, "utf-8");
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
- export async function handleReadFiles(filePaths, allowedDirectories, offset, limit) {
4
- const results = await Promise.all(filePaths.map(async (filePath) => {
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 content = await fs.readFile(validPath, "utf-8");
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 (offset !== undefined || limit !== undefined) {
14
- startLine = offset ?? 1;
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 = limit !== undefined ? Math.min(total, startLine + limit - 1) : total;
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 = offset !== undefined || limit !== undefined
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.array(z.string()),
4
- offset: z.number().int().min(1).optional().describe("Start reading from this line number (1-indexed). Omit to read from the beginning."),
5
- limit: z.number().int().min(1).optional().describe("Maximum number of lines to read per file. Omit to read to the end."),
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;