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.
Files changed (46) hide show
  1. package/README.md +41 -12
  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 +6 -4
  25. package/dist/list_directory/handler.js +16 -9
  26. package/dist/move_files/handler.js +2 -2
  27. package/dist/patch_files/handler.js +2 -2
  28. package/dist/patch_files/helpers.js +24 -4
  29. package/dist/read_bytes/handler.js +43 -0
  30. package/dist/read_bytes/schema.js +6 -0
  31. package/dist/read_files/handler.js +18 -9
  32. package/dist/read_files/schema.js +37 -3
  33. package/dist/replace_regex/handler.js +105 -0
  34. package/dist/replace_regex/schema.js +23 -0
  35. package/dist/search_files/handler.js +2 -2
  36. package/dist/search_glob/handler.js +2 -2
  37. package/dist/search_regex/handler.js +63 -40
  38. package/dist/search_regex/schema.js +1 -0
  39. package/dist/server.js +183 -9
  40. package/dist/wait_for_file/handler.js +91 -0
  41. package/dist/wait_for_file/schema.js +7 -0
  42. package/dist/write_bytes/handler.js +49 -0
  43. package/dist/write_bytes/schema.js +7 -0
  44. package/dist/write_new_files/handler.js +6 -3
  45. package/dist/write_new_files/schema.js +1 -0
  46. 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
- await launchDetached(opener, resolved);
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 (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;
@@ -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 matchesFound = 0;
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
- matchesFound++;
33
- // Find the line number for this match
34
- const matchPosition = match.index;
35
- let lineNumber = 0;
36
- let charCount = 0;
37
- for (let i = 0; i < lines.length; i++) {
38
- charCount += lines[i].length + 1; // +1 for the newline
39
- if (charCount > matchPosition) {
40
- lineNumber = i + 1;
41
- break;
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
- // Get the line content
45
- const lineContent = lines[lineNumber - 1]?.trim() || "";
46
- results.push({
47
- file: fullPath,
48
- line: lineNumber,
49
- content: lineContent,
50
- match: match[0],
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 (matchesFound === 0) {
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 ${matchesFound} matches in ${results.length} locations (offset ${offset} is beyond the collected range)\n`;
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 ${matchesFound} matches in ${results.length} locations (showing ${offset}-${end})`;
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
  });