@levnikolaevich/hex-line-mcp 1.3.0 → 1.3.1

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/lib/tree.mjs CHANGED
@@ -1,20 +1,24 @@
1
1
  /**
2
- * Compact directory tree with .gitignore support.
2
+ * Compact directory tree with root .gitignore support.
3
3
  *
4
4
  * Skips common build/cache dirs by default.
5
- * Parses .gitignore patterns (simple subset: globs, comments, negation).
5
+ * Uses `ignore` package for spec-compliant .gitignore matching (path-based, negation, dir-only).
6
+ * Only root .gitignore is loaded — nested .gitignore files are not supported.
6
7
  */
7
8
 
8
9
  import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
9
10
  import { resolve, basename, join, relative } from "node:path";
11
+ import { formatSize, relativeTime, countFileLines } from "./format.mjs";
12
+ import { normalizePath } from "./security.mjs";
13
+ import ignore from "ignore";
10
14
 
11
15
  const SKIP_DIRS = new Set([
12
16
  "node_modules", ".git", "dist", "build", "__pycache__", ".next", "coverage",
13
17
  ]);
14
18
 
15
19
  /**
16
- * Convert a simple glob pattern to a RegExp.
17
- * Supports: * (any non-slash), ** (any), ? (single char).
20
+ * Convert a simple glob pattern to a RegExp for name matching.
21
+ * Used by pattern-mode to match entry names.
18
22
  */
19
23
  function globToRegex(pat) {
20
24
  return new RegExp(
@@ -27,62 +31,30 @@ function globToRegex(pat) {
27
31
  }
28
32
 
29
33
  /**
30
- * Parse .gitignore into match functions.
31
- * Supports: comments (#), negation (!), wildcards (*), dir-only trailing /.
34
+ * Load root .gitignore into an `ignore` instance.
35
+ * @param {string} rootDir - absolute path to tree root
36
+ * @returns {ReturnType<typeof ignore>|null}
32
37
  */
33
- function parseGitignore(content) {
34
- const lines = content.replace(/\r\n/g, "\n").split("\n");
35
- const patterns = [];
36
- for (const raw of lines) {
37
- const line = raw.trim();
38
- if (!line || line.startsWith("#")) continue;
39
- const negate = line.startsWith("!");
40
- let pat = negate ? line.slice(1) : line;
41
- // Strip leading /
42
- if (pat.startsWith("/")) pat = pat.slice(1);
43
- // Strip trailing /
44
- const dirOnly = pat.endsWith("/");
45
- if (dirOnly) pat = pat.slice(0, -1);
46
- const re = globToRegex(pat);
47
- patterns.push({ re, negate, dirOnly });
48
- }
49
- return patterns;
50
- }
51
- function isIgnored(name, isDir, patterns) {
52
- let ignored = false;
53
- for (const { re, negate, dirOnly } of patterns) {
54
- if (dirOnly && !isDir) continue;
55
- if (re.test(name)) ignored = !negate;
56
- }
57
- return ignored;
58
- }
59
-
60
- function formatSize(bytes) {
61
- if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
62
- if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)}KB`;
63
- return `${bytes}B`;
64
- }
65
-
66
- function countFileLines(filePath, size) {
67
- if (size === 0 || size > 1_000_000) return null;
38
+ function loadGitignore(rootDir) {
39
+ const gi = join(rootDir, ".gitignore");
40
+ if (!existsSync(gi)) return null;
68
41
  try {
69
- const buf = readFileSync(filePath);
70
- const checkLen = Math.min(buf.length, 8192);
71
- for (let i = 0; i < checkLen; i++) if (buf[i] === 0) return null; // binary
72
- let count = 1;
73
- for (let i = 0; i < buf.length; i++) if (buf[i] === 0x0A) count++;
74
- return count;
42
+ const content = readFileSync(gi, "utf-8");
43
+ return ignore().add(content);
75
44
  } catch { return null; }
76
45
  }
77
46
 
78
- function relativeTime(mtime) {
79
- const sec = (Date.now() - mtime.getTime()) / 1000;
80
- if (sec < 60) return "now";
81
- if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
82
- if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`;
83
- if (sec < 604800) return `${Math.floor(sec / 86400)}d ago`;
84
- if (sec < 2592000) return `${Math.floor(sec / 604800)}w ago`;
85
- return `${Math.floor(sec / 2592000)}mo ago`;
47
+ /**
48
+ * Check if a relative path should be ignored.
49
+ * @param {ReturnType<typeof ignore>|null} ig - ignore instance (null = no gitignore)
50
+ * @param {string} relPath - POSIX relative path from tree root
51
+ * @param {boolean} isDir - true if directory
52
+ * @returns {boolean}
53
+ */
54
+ function isIgnored(ig, relPath, isDir) {
55
+ if (!ig) return false;
56
+ // ignore package expects dir paths to end with /
57
+ return ig.ignores(isDir ? relPath + "/" : relPath);
86
58
  }
87
59
 
88
60
  /**
@@ -95,22 +67,12 @@ function findByPattern(dirPath, opts) {
95
67
  const re = globToRegex(opts.pattern);
96
68
  const filterType = opts.type || "all";
97
69
  const maxDepth = opts.max_depth ?? 20;
98
- const useGitignore = opts.gitignore ?? true;
99
70
 
100
- const normalized = (process.platform === "win32" && /^\/[a-zA-Z]\//.test(dirPath))
101
- ? dirPath[1] + ":" + dirPath.slice(2) : dirPath;
102
- const abs = resolve(normalized);
71
+ const abs = resolve(normalizePath(dirPath));
103
72
  if (!existsSync(abs)) throw new Error(`DIRECTORY_NOT_FOUND: ${abs}`);
104
73
  if (!statSync(abs).isDirectory()) throw new Error(`Not a directory: ${abs}`);
105
74
 
106
- let patterns = [];
107
- if (useGitignore) {
108
- const gi = join(abs, ".gitignore");
109
- if (existsSync(gi)) {
110
- try { patterns = parseGitignore(readFileSync(gi, "utf-8")); } catch { /* skip */ }
111
- }
112
- }
113
-
75
+ const ig = (opts.gitignore ?? true) ? loadGitignore(abs) : null;
114
76
  const matches = [];
115
77
 
116
78
  function walk(dir, depth) {
@@ -121,15 +83,15 @@ function findByPattern(dirPath, opts) {
121
83
  for (const entry of entries) {
122
84
  const isDir = entry.isDirectory();
123
85
  if (SKIP_DIRS.has(entry.name) && isDir) continue;
124
- if (isIgnored(entry.name, isDir, patterns)) continue;
125
86
 
126
87
  const full = join(dir, entry.name);
88
+ const rel = relative(abs, full).replace(/\\/g, "/");
89
+ if (isIgnored(ig, rel, isDir)) continue;
127
90
 
128
91
  if (re.test(entry.name)) {
129
92
  if (filterType === "all" ||
130
93
  (filterType === "dir" && isDir) ||
131
94
  (filterType === "file" && !isDir)) {
132
- const rel = relative(abs, full).replace(/\\/g, "/");
133
95
  matches.push(isDir ? rel + "/" : rel);
134
96
  }
135
97
  }
@@ -159,35 +121,28 @@ export function directoryTree(dirPath, opts = {}) {
159
121
 
160
122
  const compact = opts.format === "compact";
161
123
  const maxDepth = compact ? 1 : (opts.max_depth ?? 3);
162
- const useGitignore = opts.gitignore ?? true;
163
124
 
164
- // Convert Git Bash /c/path → c:/path on Windows
165
- const normalized = (process.platform === "win32" && /^\/[a-zA-Z]\//.test(dirPath))
166
- ? dirPath[1] + ":" + dirPath.slice(2) : dirPath;
167
- const abs = resolve(normalized);
125
+ const abs = resolve(normalizePath(dirPath));
168
126
  if (!existsSync(abs)) throw new Error(`DIRECTORY_NOT_FOUND: ${abs}. Check path or use directory_tree on parent directory.`);
169
127
  const rootStat = statSync(abs);
170
128
  if (!rootStat.isDirectory()) throw new Error(`Not a directory: ${abs}`);
171
129
 
172
- // Load .gitignore
173
- let patterns = [];
174
- if (useGitignore) {
175
- const gi = join(abs, ".gitignore");
176
- if (existsSync(gi)) {
177
- try { patterns = parseGitignore(readFileSync(gi, "utf-8")); } catch { /* skip */ }
178
- }
179
- }
130
+ const ig = (opts.gitignore ?? true) ? loadGitignore(abs) : null;
180
131
 
181
132
  let totalFiles = 0;
182
133
  let totalSize = 0;
183
134
  const lines = [];
184
135
 
136
+ /**
137
+ * Recursive walk. Returns total file count for entire subtree
138
+ * (including beyond maxDepth — count is always full, display is depth-limited).
139
+ * Output order: pre-order (dir line before children).
140
+ */
185
141
  function walk(dir, prefix, depth) {
186
- if (depth > maxDepth) return;
187
142
  let entries;
188
143
  try {
189
144
  entries = readdirSync(dir, { withFileTypes: true });
190
- } catch { return; }
145
+ } catch { return 0; }
191
146
 
192
147
  // Sort: directories first, then files, alphabetical
193
148
  entries.sort((a, b) => {
@@ -197,27 +152,35 @@ export function directoryTree(dirPath, opts = {}) {
197
152
  return a.name.localeCompare(b.name);
198
153
  });
199
154
 
155
+ let subTotal = 0;
156
+
200
157
  for (const entry of entries) {
201
158
  const name = entry.name;
202
159
  const isDir = entry.isDirectory();
203
160
 
204
161
  if (SKIP_DIRS.has(name) && isDir) continue;
205
- if (isIgnored(name, isDir, patterns)) continue;
206
162
 
207
163
  const full = join(dir, name);
164
+ const rel = relative(abs, full).replace(/\\/g, "/");
165
+ if (isIgnored(ig, rel, isDir)) continue;
208
166
 
209
167
  if (isDir) {
210
168
  if (compact) {
211
169
  lines.push(`${prefix}${name}/`);
212
170
  } else {
213
- // Count files in subdirectory
214
- const subInfo = { files: 0 };
215
- countFiles(full, subInfo);
216
- lines.push(`${prefix}${name}/ (${subInfo.files} files)`);
171
+ // Pre-order: placeholder for dir line, patch after recursion
172
+ const lineIdx = lines.length;
173
+ lines.push("");
174
+ const count = depth < maxDepth
175
+ ? walk(full, prefix + " ", depth + 1)
176
+ : countSubtreeFiles(full, ig, abs);
177
+ lines[lineIdx] = `${prefix}${name}/ (${count} files)`;
178
+ subTotal += count;
217
179
  }
218
- walk(full, prefix + " ", depth + 1);
180
+ if (compact) walk(full, prefix + " ", depth + 1);
219
181
  } else {
220
182
  totalFiles++;
183
+ subTotal++;
221
184
  if (compact) {
222
185
  lines.push(`${prefix}${name}`);
223
186
  } else {
@@ -232,26 +195,35 @@ export function directoryTree(dirPath, opts = {}) {
232
195
  const parts = [];
233
196
  if (lineCount !== null) parts.push(`${lineCount}L`);
234
197
  parts.push(formatSize(size));
235
- if (mtime) parts.push(relativeTime(mtime));
198
+ if (mtime) parts.push(relativeTime(mtime, true));
236
199
  lines.push(`${prefix}${name} (${parts.join(", ")})`);
237
200
  }
238
201
  }
239
202
  }
203
+
204
+ return subTotal;
240
205
  }
241
206
 
242
- function countFiles(dir, info, depth = 0) {
243
- if (depth > 10) return; // safety limit for deep trees
207
+ /**
208
+ * Count files in subtree without emitting lines (for dirs beyond maxDepth).
209
+ */
210
+ function countSubtreeFiles(dir, ig, rootAbs, depth = 0) {
211
+ if (depth > 10) return 0;
244
212
  let entries;
245
- try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
213
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return 0; }
214
+ let count = 0;
246
215
  for (const entry of entries) {
247
216
  if (SKIP_DIRS.has(entry.name) && entry.isDirectory()) continue;
248
- if (isIgnored(entry.name, entry.isDirectory(), patterns)) continue;
217
+ const full = join(dir, entry.name);
218
+ const rel = relative(rootAbs, full).replace(/\\/g, "/");
219
+ if (isIgnored(ig, rel, entry.isDirectory())) continue;
249
220
  if (entry.isDirectory()) {
250
- countFiles(join(dir, entry.name), info, depth + 1);
221
+ count += countSubtreeFiles(full, ig, rootAbs, depth + 1);
251
222
  } else {
252
- info.files++;
223
+ count++;
253
224
  }
254
225
  }
226
+ return count;
255
227
  }
256
228
 
257
229
  const rootName = basename(abs);
package/lib/verify.mjs CHANGED
@@ -3,9 +3,9 @@
3
3
  * Validates range checksums from prior reads.
4
4
  */
5
5
 
6
- import { readFileSync } from "node:fs";
7
6
  import { fnv1a, rangeChecksum, parseChecksum } from "./hash.mjs";
8
7
  import { validatePath } from "./security.mjs";
8
+ import { readText } from "./format.mjs";
9
9
 
10
10
  /**
11
11
  * Verify checksums against current file state.
@@ -16,7 +16,7 @@ import { validatePath } from "./security.mjs";
16
16
  */
17
17
  export function verifyChecksums(filePath, checksums) {
18
18
  const real = validatePath(filePath);
19
- const content = readFileSync(real, "utf-8").replace(/\r\n/g, "\n");
19
+ const content = readText(real);
20
20
  const lines = content.split("\n");
21
21
 
22
22
  // Pre-compute all line hashes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@levnikolaevich/hex-line-mcp",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "type": "module",
5
5
  "description": "Hash-verified file editing MCP + token efficiency hook for AI coding agents. 11 tools: read, edit, write, grep, outline, verify, directory_tree, file_info, setup_hooks, changes, bulk_replace.",
6
6
  "main": "server.mjs",
@@ -25,8 +25,10 @@
25
25
  "dependencies": {
26
26
  "@modelcontextprotocol/sdk": "^1.17.0",
27
27
  "diff": "^8.0.3",
28
+ "ignore": "^7.0.5",
28
29
  "tree-sitter-wasms": "^0.1.0",
29
- "web-tree-sitter": "^0.25.0"
30
+ "web-tree-sitter": "^0.25.0",
31
+ "zod": "^3.24.0"
30
32
  },
31
33
  "author": "Lev Nikolaevich <https://github.com/levnikolaevich>",
32
34
  "license": "MIT",
package/server.mjs CHANGED
@@ -10,6 +10,8 @@
10
10
 
11
11
  import { writeFileSync, mkdirSync } from "node:fs";
12
12
  import { dirname } from "node:path";
13
+ import { createRequire } from "node:module";
14
+ const { version } = createRequire(import.meta.url)("./package.json");
13
15
  import { z } from "zod";
14
16
  // LLM clients may send booleans as strings ("true"/"false").
15
17
  // z.coerce.boolean() is unsafe: Boolean("false") === true.
@@ -54,7 +56,7 @@ try {
54
56
  process.exit(1);
55
57
  }
56
58
 
57
- const server = new McpServer({ name: "hex-line-mcp", version: "1.3.0" });
59
+ const server = new McpServer({ name: "hex-line-mcp", version });
58
60
 
59
61
 
60
62
  // ==================== read_file ====================
@@ -101,17 +103,19 @@ server.registerTool("read_file", {
101
103
  server.registerTool("edit_file", {
102
104
  title: "Edit File",
103
105
  description:
104
- "Edit a file using hash-verified anchors or text replacement. Returns diff. " +
105
- "new_text replaces anchor range exactly include boundary lines if you want to keep them. " +
106
- "Preserve indentation from read_file. For anchor edits, read_file first to get hashes.",
106
+ "Edit a file using hash-verified anchors or text replacement. Returns diff + post-edit checksums. " +
107
+ "Batch multiple edits in ONE call for atomicity (bottom-to-top auto-sorted). " +
108
+ "set_line: single line (from grep/read). replace_lines: range with checksum. " +
109
+ "replace: unique text match, or all:true for rename. insert_after: add below anchor.",
107
110
  inputSchema: z.object({
108
111
  path: z.string().describe("File to edit"),
109
112
  edits: z.string().describe(
110
113
  'JSON array. Examples:\n' +
111
114
  '{"set_line":{"anchor":"ab.12","new_text":"new"}} — replace line\n' +
112
- '{"replace_lines":{"start_anchor":"ab.10","end_anchor":"cd.15","new_text":"...","range_checksum":"10-15:a1b2c3d4"}} — range (range_checksum from read_file required)\n' +
115
+ '{"replace_lines":{"start_anchor":"ab.10","end_anchor":"cd.15","new_text":"...","range_checksum":"10-15:a1b2c3d4"}} — range\n' +
113
116
  '{"insert_after":{"anchor":"ab.20","text":"inserted"}} — insert below\n' +
114
- '{"replace":{"old_text":"find","new_text":"replace","all":true}} — rename-all (all:true required)',
117
+ '{"replace":{"old_text":"find","new_text":"replace"}} — unique match\n' +
118
+ '{"replace":{"old_text":"find","new_text":"replace","all":true}} — replace all',
115
119
  ),
116
120
  dry_run: flexBool().describe("Preview changes without writing"),
117
121
  restore_indent: flexBool().describe("Auto-fix indentation to match anchor (default: false)"),
@@ -159,25 +163,36 @@ server.registerTool("write_file", {
159
163
  server.registerTool("grep_search", {
160
164
  title: "Search Files",
161
165
  description:
162
- "Search file contents with ripgrep. Returns hash-annotated matches for direct editing. " +
163
- "ALWAYS prefer over shell grep/rg/findstr. Use to find code before read_file or edit_file.",
166
+ "Search file contents with ripgrep. Returns hash-annotated matches with per-group checksums for direct editing. " +
167
+ "Output modes: content (default, edit-ready hashes+checksums), files (paths only), count (match counts). " +
168
+ "For single-line edits: grep -> set_line directly. For range edits: use checksum from grep output. " +
169
+ "ALWAYS prefer over shell grep/rg/findstr.",
164
170
  inputSchema: z.object({
165
- pattern: z.string().describe("Regex search pattern"),
171
+ pattern: z.string().describe("Search pattern (regex by default, literal if literal:true)"),
166
172
  path: z.string().optional().describe("Search dir/file (default: cwd)"),
167
173
  glob: z.string().optional().describe('Glob filter (e.g. "*.ts")'),
168
174
  type: z.string().optional().describe('File type (e.g. "js", "py")'),
175
+ output: z.enum(["content", "files", "count"]).optional().describe('Output format (default: content)'),
169
176
  case_insensitive: flexBool().describe("Ignore case (-i)"),
170
- smart_case: flexBool().describe("CI when pattern is all lowercase, CS if it has uppercase (-S)"),
171
- context: flexNum().describe("Context lines around matches"),
177
+ smart_case: flexBool().describe("CI when pattern is all lowercase, CS if uppercase (-S)"),
178
+ literal: flexBool().describe("Literal string search, no regex (-F)"),
179
+ multiline: flexBool().describe("Pattern can span multiple lines (-U)"),
180
+ context: flexNum().describe("Symmetric context lines around matches (-C)"),
181
+ context_before: flexNum().describe("Context lines BEFORE match (-B)"),
182
+ context_after: flexNum().describe("Context lines AFTER match (-A)"),
172
183
  limit: flexNum().describe("Max matches per file (default: 100)"),
184
+ total_limit: flexNum().describe("Total match events across all files; multiline matches count as 1 (0 = unlimited)"),
173
185
  plain: flexBool().describe("Omit hash tags, return file:line:content"),
174
186
  }),
175
187
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
176
188
  }, async (rawParams) => {
177
- const { pattern, path: p, glob, type, case_insensitive, smart_case, context, limit, plain } = coerceParams(rawParams);
189
+ const { pattern, path: p, glob, type, output, case_insensitive, smart_case, literal, multiline,
190
+ context, context_before, context_after, limit, total_limit, plain } = coerceParams(rawParams);
178
191
  try {
179
192
  const result = await grepSearch(pattern, {
180
- path: p, glob, type, caseInsensitive: case_insensitive, smartCase: smart_case, context, limit, plain,
193
+ path: p, glob, type, output, caseInsensitive: case_insensitive, smartCase: smart_case,
194
+ literal, multiline, context, contextBefore: context_before, contextAfter: context_after,
195
+ limit, totalLimit: total_limit, plain,
181
196
  });
182
197
  return { content: [{ type: "text", text: result }] };
183
198
  } catch (e) {
@@ -238,7 +253,7 @@ server.registerTool("verify", {
238
253
  server.registerTool("directory_tree", {
239
254
  title: "Directory Tree",
240
255
  description:
241
- "Compact directory tree with .gitignore support. " +
256
+ "Compact directory tree with root .gitignore support (path-based rules, negation). " +
242
257
  "Supports pattern glob to find files/dirs by name (like find -name). " +
243
258
  "Use to understand repo structure or find specific files/dirs. " +
244
259
  "Skips node_modules, .git, dist by default.",
@@ -247,7 +262,7 @@ server.registerTool("directory_tree", {
247
262
  pattern: z.string().optional().describe('Glob filter on names (e.g. "*-mcp", "*.mjs"). Returns flat match list instead of tree'),
248
263
  type: z.enum(["file", "dir", "all"]).optional().describe('"file", "dir", or "all" (default). Like find -type f/d'),
249
264
  max_depth: flexNum().describe("Max recursion depth (default: 3, or 20 in pattern mode)"),
250
- gitignore: flexBool().describe("Respect .gitignore patterns (default: true)"),
265
+ gitignore: flexBool().describe("Respect root .gitignore patterns (default: true). Nested .gitignore not supported"),
251
266
  format: z.enum(["compact", "full"]).optional().describe('"compact" = names only, no sizes, depth 1. "full" = default with sizes'),
252
267
  }),
253
268
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
@@ -365,4 +380,4 @@ server.registerTool("bulk_replace", {
365
380
 
366
381
  const transport = new StdioServerTransport();
367
382
  await server.connect(transport);
368
- void checkForUpdates("@levnikolaevich/hex-line-mcp", "1.3.0");
383
+ void checkForUpdates("@levnikolaevich/hex-line-mcp", version);