@levnikolaevich/hex-line-mcp 1.0.0 → 1.1.0

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.
@@ -23,6 +23,8 @@ export function bulkReplace(rootDir, globPattern, replacements, opts = {}) {
23
23
 
24
24
  const results = [];
25
25
  let changed = 0, skipped = 0, errors = 0;
26
+ const MAX_OUTPUT = 80000;
27
+ let totalChars = 0;
26
28
 
27
29
  for (const file of files) {
28
30
  try {
@@ -44,6 +46,12 @@ export function bulkReplace(rootDir, globPattern, replacements, opts = {}) {
44
46
  const relPath = file.replace(abs, "").replace(/^[/\\]/, "");
45
47
  results.push(`--- ${relPath}\n${diff || "(no visible diff)"}`);
46
48
  changed++;
49
+ totalChars += results[results.length - 1].length;
50
+ if (totalChars > MAX_OUTPUT) {
51
+ const remaining = files.length - files.indexOf(file) - 1;
52
+ if (remaining > 0) results.push(`OUTPUT_CAPPED: ${remaining} more files not shown. Output exceeded ${MAX_OUTPUT} chars.`);
53
+ break;
54
+ }
47
55
  } catch (e) {
48
56
  results.push(`ERROR: ${file}: ${e.message}`);
49
57
  errors++;
package/lib/coerce.mjs CHANGED
@@ -24,10 +24,15 @@ const ALIASES = {
24
24
  // edit_file
25
25
  dryRun: "dry_run",
26
26
  "dry-run": "dry_run",
27
+ restoreIndent: "restore_indent",
28
+ "restore-indent": "restore_indent",
27
29
 
28
30
  // directory_tree
29
31
  maxDepth: "max_depth",
30
32
  depth: "max_depth",
33
+ name: "pattern",
34
+ filter: "pattern",
35
+ entry_type: "type",
31
36
  };
32
37
 
33
38
  export function coerceParams(params) {
package/lib/edit.mjs CHANGED
@@ -65,7 +65,23 @@ function buildHashIndex(lines) {
65
65
  function findLine(lines, lineNum, expectedTag, hashIndex) {
66
66
  const idx = lineNum - 1;
67
67
  if (idx < 0 || idx >= lines.length) {
68
- throw new Error(`Line ${lineNum} out of range (1-${lines.length})`);
68
+ const start = idx >= lines.length
69
+ ? Math.max(0, lines.length - 10)
70
+ : 0;
71
+ const end = idx >= lines.length
72
+ ? lines.length
73
+ : Math.min(lines.length, 10);
74
+ const snippet = lines.slice(start, end).map((line, i) => {
75
+ const num = start + i + 1;
76
+ const tag = lineTag(fnv1a(line));
77
+ return `${tag}.${num}\t${line}`;
78
+ }).join("\n");
79
+
80
+ throw new Error(
81
+ `Line ${lineNum} out of range (1-${lines.length}).\n\n` +
82
+ `Current content (lines ${start + 1}-${end}):\n${snippet}\n\n` +
83
+ `Tip: Use updated hashes above for retry.`
84
+ );
69
85
  }
70
86
 
71
87
  const actual = lineTag(fnv1a(lines[idx]));
@@ -212,7 +228,8 @@ function buildSnippet(norm, charPos) {
212
228
  const end = Math.min(lines.length, start + 10);
213
229
  const snippetLines = [];
214
230
  for (let i = start; i < end; i++) {
215
- snippetLines.push(` ${i + 1}| ${lines[i]}`);
231
+ const tag = lineTag(fnv1a(lines[i]));
232
+ snippetLines.push(`${tag}.${i + 1}\t${lines[i]}`);
216
233
  }
217
234
  return { start: start + 1, end, text: snippetLines.join("\n") };
218
235
  }
@@ -242,7 +259,7 @@ function textReplace(content, oldText, newText, all) {
242
259
  throw new Error(
243
260
  `TEXT_NOT_FOUND: "${normOld.slice(0, 100)}..." not found.\n\n` +
244
261
  `Nearest content (lines ${snip.start}-${snip.end}):\n${snip.text}\n\n` +
245
- `Tip: Re-read file or adjust old_text to match actual content.`
262
+ `Tip: Use hashes above for anchor-based edit, or adjust old_text.`
246
263
  );
247
264
  }
248
265
  }
@@ -269,37 +286,54 @@ function textReplace(content, oldText, newText, all) {
269
286
  return norm.split(normOld).join(normNew);
270
287
  }
271
288
 
272
- // Check for multiple matches
289
+ // Check for multiple matches — return hash-hint instead of opaque error
290
+ const positions = [];
273
291
  if (confusableMatch) {
274
292
  const normContent = normalizeConfusables(norm);
275
293
  const normSearch = normalizeConfusables(normOld);
276
- if (normContent.indexOf(normSearch, idx + 1) !== -1) {
277
- throw new Error("MULTIPLE_MATCHES: Found multiple occurrences. Use all:true or add more context for unique match.");
294
+ let searchPos = 0;
295
+ while ((searchPos = normContent.indexOf(normSearch, searchPos)) !== -1) {
296
+ positions.push(searchPos);
297
+ searchPos += normSearch.length;
298
+ }
299
+ } else {
300
+ let searchPos = 0;
301
+ while ((searchPos = norm.indexOf(normOld, searchPos)) !== -1) {
302
+ positions.push(searchPos);
303
+ searchPos += normOld.length;
278
304
  }
279
- } else if (norm.indexOf(normOld, idx + 1) !== -1) {
280
- throw new Error("MULTIPLE_MATCHES: Found multiple occurrences. Use all:true or add more context for unique match.");
305
+ }
306
+
307
+ if (positions.length > 1) {
308
+ const allLines = norm.split("\n");
309
+ const matchLineCount = normOld.split("\n").length;
310
+ const snippets = positions.map((charPos, i) => {
311
+ let cumLen = 0, matchLine = 0;
312
+ for (let l = 0; l < allLines.length; l++) {
313
+ cumLen += allLines[l].length + 1;
314
+ if (cumLen > charPos) { matchLine = l; break; }
315
+ }
316
+ const start = Math.max(0, matchLine - 1);
317
+ const end = Math.min(allLines.length, matchLine + matchLineCount + 1);
318
+ const lines = allLines.slice(start, end).map((line, j) => {
319
+ const num = start + j + 1;
320
+ const tag = lineTag(fnv1a(line));
321
+ return `${tag}.${num}\t${line}`;
322
+ });
323
+ return `Match ${i + 1} (lines ${start + 1}-${end}):\n${lines.join("\n")}`;
324
+ });
325
+
326
+ throw new Error(
327
+ `HASH_HINT: Found ${positions.length} match(es) for replace. Use anchor-based edit for precision.\n\n` +
328
+ snippets.join("\n\n") +
329
+ `\n\nRetry with: [{"set_line":{"anchor":"XX.NN","new_text":"..."}}] or [{"replace_lines":{"start_anchor":"XX.NN","end_anchor":"YY.MM","new_text":"..."}}]`
330
+ );
281
331
  }
282
332
 
283
333
  return norm.slice(0, idx) + normNew + norm.slice(idx + matchLen);
284
334
  }
285
335
 
286
- /**
287
- * Strip boundary echo lines from replacement text.
288
- * Agents often echo the start/end anchor lines in their replacement — strip them
289
- * to avoid duplicating boundary content.
290
- */
291
- function stripBoundaryEcho(lines, startIdx, endIdx, newLines) {
292
- let result = [...newLines];
293
- // Strip start boundary echo
294
- if (result.length > 0 && lines[startIdx].trim() === result[0].trim()) {
295
- result = result.slice(1);
296
- }
297
- // Strip end boundary echo
298
- if (result.length > 0 && lines[endIdx].trim() === result[result.length - 1].trim()) {
299
- result = result.slice(0, -1);
300
- }
301
- return result;
302
- }
336
+
303
337
 
304
338
  /**
305
339
  * Apply edits to a file.
@@ -347,7 +381,8 @@ export function editFile(filePath, edits, opts = {}) {
347
381
  lines.splice(idx, 1);
348
382
  } else {
349
383
  const origLine = [lines[idx]];
350
- const newLines = restoreIndent(origLine, String(txt).split("\n"));
384
+ const raw = String(txt).split("\n");
385
+ const newLines = opts.restoreIndent ? restoreIndent(origLine, raw) : raw;
351
386
  lines.splice(idx, 1, ...newLines);
352
387
  }
353
388
  } else if (e.replace_lines) {
@@ -360,14 +395,16 @@ export function editFile(filePath, edits, opts = {}) {
360
395
  lines.splice(si, ei - si + 1);
361
396
  } else {
362
397
  const origLines = lines.slice(si, ei + 1);
363
- let newLines = stripBoundaryEcho(lines, si, ei, String(txt).split("\n"));
364
- newLines = restoreIndent(origLines, newLines);
398
+ let newLines = String(txt).split("\n");
399
+ if (opts.restoreIndent) newLines = restoreIndent(origLines, newLines);
365
400
  lines.splice(si, ei - si + 1, ...newLines);
366
401
  }
367
402
  } else if (e.insert_after) {
368
403
  const { tag, line } = parseRef(e.insert_after.anchor);
369
404
  const idx = findLine(lines, line, tag, hashIndex);
370
- lines.splice(idx + 1, 0, ...e.insert_after.text.split("\n"));
405
+ let insertLines = e.insert_after.text.split("\n");
406
+ if (opts.restoreIndent) insertLines = restoreIndent([lines[idx]], insertLines);
407
+ lines.splice(idx + 1, 0, ...insertLines);
371
408
  }
372
409
  }
373
410
 
@@ -379,10 +416,13 @@ export function editFile(filePath, edits, opts = {}) {
379
416
  }
380
417
 
381
418
  if (original === content) {
382
- throw new Error("NOOP_EDIT: All edits produced identical content. File unchanged. Re-read to verify current state.");
419
+ throw new Error("NOOP_EDIT: File already contains the desired content. No changes needed.");
383
420
  }
384
421
 
385
- const diff = simpleDiff(origLines, content.split("\n"));
422
+ let diff = simpleDiff(origLines, content.split("\n"));
423
+ if (diff && diff.length > 80000) {
424
+ diff = diff.slice(0, 80000) + `\n... (diff truncated, ${diff.length} chars total)`;
425
+ }
386
426
 
387
427
  if (opts.dryRun) {
388
428
  let msg = `Dry run: ${filePath} would change (${content.split("\n").length} lines)`;
package/lib/read.mjs CHANGED
@@ -29,6 +29,7 @@ function relativeTime(date) {
29
29
  }
30
30
 
31
31
  const DEFAULT_LIMIT = 2000;
32
+ const MAX_OUTPUT_CHARS = 80000;
32
33
 
33
34
  /**
34
35
  * Read a file with hash-annotated lines.
@@ -67,32 +68,44 @@ export function readFile(filePath, opts = {}) {
67
68
 
68
69
  const parts = [];
69
70
 
71
+ let cappedAtLine = 0;
72
+
70
73
  for (const range of ranges) {
71
74
  const selected = lines.slice(range.start - 1, range.end);
72
75
  const lineHashes = [];
76
+ const formatted = [];
77
+ let charCount = 0;
78
+
79
+ for (let i = 0; i < selected.length; i++) {
80
+ const line = selected[i];
81
+ const num = range.start + i;
82
+ const hash32 = fnv1a(line);
83
+ const entry = opts.plain
84
+ ? `${num}|${line}`
85
+ : `${lineTag(hash32)}.${num}\t${line}`;
73
86
 
74
- let formatted;
75
- if (opts.plain) {
76
- formatted = selected.map((line, i) => {
77
- const num = range.start + i;
78
- lineHashes.push(fnv1a(line));
79
- return `${num}|${line}`;
80
- }).join("\n");
81
- } else {
82
- formatted = selected.map((line, i) => {
83
- const num = range.start + i;
84
- const hash32 = fnv1a(line);
85
- lineHashes.push(hash32);
86
- const tag = lineTag(hash32);
87
- return `${tag}.${num}\t${line}`;
88
- }).join("\n");
87
+ if (charCount + entry.length > MAX_OUTPUT_CHARS && formatted.length > 0) {
88
+ cappedAtLine = num;
89
+ break;
90
+ }
91
+ lineHashes.push(hash32);
92
+ formatted.push(entry);
93
+ charCount += entry.length + 1;
89
94
  }
90
95
 
91
- parts.push(formatted);
96
+ // Update range end to actual lines shown
97
+ const actualEnd = formatted.length > 0
98
+ ? range.start + formatted.length - 1
99
+ : range.start;
100
+ range.end = actualEnd;
101
+
102
+ parts.push(formatted.join("\n"));
92
103
 
93
- // Range checksum
94
- const cs = rangeChecksum(lineHashes, range.start, range.end);
104
+ // Range checksum (only for lines actually shown)
105
+ const cs = rangeChecksum(lineHashes, range.start, actualEnd);
95
106
  parts.push(`\nchecksum: ${cs}`);
107
+
108
+ if (cappedAtLine) break;
96
109
  }
97
110
 
98
111
  // Header
@@ -125,5 +138,17 @@ export function readFile(filePath, opts = {}) {
125
138
  }
126
139
  }
127
140
 
128
- return `${header}${graphLine}\n\n\`\`\`\n${parts.join("\n")}\n\`\`\``;
141
+ let result = `${header}${graphLine}\n\n\`\`\`\n${parts.join("\n")}\n\`\`\``;
142
+
143
+ // Auto-hint for large files read from start without offset
144
+ if (total > 200 && (!opts.offset || opts.offset <= 1) && !cappedAtLine) {
145
+ result += `\n\n\u26A1 Tip: This file has ${total} lines. Use outline first, then read_file with offset/limit for 75% fewer tokens.`;
146
+ }
147
+
148
+ // Character cap notice
149
+ if (cappedAtLine) {
150
+ result += `\n\nOUTPUT_CAPPED at line ${cappedAtLine} (${MAX_OUTPUT_CHARS} char limit). Use offset=${cappedAtLine} to continue reading.`;
151
+ }
152
+
153
+ return result;
129
154
  }
package/lib/setup.mjs CHANGED
@@ -2,13 +2,33 @@
2
2
  * Setup hex-line hooks for CLI agents.
3
3
  *
4
4
  * Idempotent: re-running with same config produces no changes.
5
- * Supports: claude (hooks in settings.local.json), gemini, codex (info only).
5
+ * Supports: claude (hooks in ~/.claude/settings.json global), gemini, codex (info only).
6
+ * Cleanup: removes old per-project hooks from .claude/settings.local.json.
6
7
  */
7
8
 
8
9
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
9
10
  import { resolve, dirname } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { homedir } from "node:os";
10
13
 
11
- const HOOK_COMMAND = "node mcp/hex-line-mcp/hook.mjs";
14
+ // Resolve absolute path to hook.mjs at module load time.
15
+ // setup.mjs is in lib/, hook.mjs is one level up (sibling of lib/).
16
+ const __filename = fileURLToPath(import.meta.url);
17
+ const __dirname = dirname(__filename);
18
+ const HOOK_SCRIPT = resolve(__dirname, "..", "hook.mjs").replace(/\\/g, "/");
19
+ const HOOK_COMMAND = `node ${HOOK_SCRIPT}`;
20
+
21
+ // Legacy relative command — needed to find and remove old per-project hooks.
22
+ const OLD_HOOK_COMMAND = "node mcp/hex-line-mcp/hook.mjs";
23
+
24
+ // Substring that identifies any hex-line hook command (old relative or new absolute).
25
+ const HOOK_SIGNATURE = "hex-line-mcp/hook.mjs";
26
+
27
+ const NPX_MARKERS = ["_npx", "npx-cache", ".npm/_npx"];
28
+
29
+ function isEphemeralInstall(scriptPath) {
30
+ return NPX_MARKERS.some((m) => scriptPath.includes(m));
31
+ }
12
32
 
13
33
  const CLAUDE_HOOKS = {
14
34
  SessionStart: {
@@ -38,21 +58,21 @@ function writeJson(filePath, data) {
38
58
  }
39
59
 
40
60
  /**
41
- * Find existing hook entry index by command substring.
42
- * @param {Array} entries - Array of {matcher, hooks[]} objects
43
- * @param {string} command - Command string to match
44
- * @returns {number} Index or -1
61
+ * Find existing hook entry index by hex-line signature substring.
62
+ * Catches both old relative ("node mcp/hex-line-mcp/hook.mjs") and
63
+ * new absolute ("node d:/.../hex-line-mcp/hook.mjs") commands.
45
64
  */
46
- function findEntryByCommand(entries, command) {
65
+ function findEntryByCommand(entries) {
47
66
  return entries.findIndex(
48
- (e) => Array.isArray(e.hooks) && e.hooks.some((h) => h.command === command)
67
+ (e) => Array.isArray(e.hooks) && e.hooks.some((h) =>
68
+ typeof h.command === "string" && h.command.includes(HOOK_SIGNATURE)
69
+ )
49
70
  );
50
71
  }
51
72
 
52
- // ---- Agent configurators ----
73
+ // ---- Core: write hooks to a settings file ----
53
74
 
54
- function setupClaude() {
55
- const settingsPath = resolve(process.cwd(), ".claude/settings.local.json");
75
+ function writeHooksToFile(settingsPath, label) {
56
76
  const config = readJson(settingsPath) || {};
57
77
 
58
78
  if (!config.hooks || typeof config.hooks !== "object") {
@@ -67,17 +87,17 @@ function setupClaude() {
67
87
  }
68
88
 
69
89
  const entries = config.hooks[event];
70
- const idx = findEntryByCommand(entries, HOOK_COMMAND);
90
+ const idx = findEntryByCommand(entries);
71
91
 
72
92
  if (idx >= 0) {
73
- // Entry exists — check if matcher and timeout match
74
93
  const existing = entries[idx];
75
94
  if (existing.matcher === desired.matcher &&
76
95
  existing.hooks.length === desired.hooks.length &&
96
+ existing.hooks[0].command === HOOK_COMMAND &&
77
97
  existing.hooks[0].timeout === desired.hooks[0].timeout) {
78
98
  continue; // Already configured exactly
79
99
  }
80
- // Update in place
100
+ // Update in place (path changed or config updated)
81
101
  entries[idx] = { matcher: desired.matcher, hooks: [...desired.hooks] };
82
102
  changed = true;
83
103
  } else {
@@ -92,11 +112,108 @@ function setupClaude() {
92
112
  }
93
113
 
94
114
  if (!changed) {
95
- return "Claude: already configured, no changes";
115
+ return `Claude (${label}): already configured`;
96
116
  }
97
117
 
98
118
  writeJson(settingsPath, config);
99
- return "Claude: PreToolUse + PostToolUse -> mcp/hex-line-mcp/hook.mjs OK";
119
+ return `Claude (${label}): hooks -> ${HOOK_SCRIPT} OK`;
120
+ }
121
+
122
+ // ---- Cleanup: remove hex-line hooks from per-project file ----
123
+
124
+ function cleanLocalHooks() {
125
+ const localPath = resolve(process.cwd(), ".claude/settings.local.json");
126
+ const config = readJson(localPath);
127
+
128
+ if (!config || !config.hooks || typeof config.hooks !== "object") {
129
+ return "local: clean";
130
+ }
131
+
132
+ let changed = false;
133
+
134
+ for (const event of Object.keys(CLAUDE_HOOKS)) {
135
+ if (!Array.isArray(config.hooks[event])) continue;
136
+
137
+ const entries = config.hooks[event];
138
+ const idx = findEntryByCommand(entries);
139
+
140
+ if (idx >= 0) {
141
+ entries.splice(idx, 1);
142
+ changed = true;
143
+ }
144
+
145
+ // Remove empty arrays
146
+ if (entries.length === 0) {
147
+ delete config.hooks[event];
148
+ }
149
+ }
150
+
151
+ // Remove empty hooks object
152
+ if (Object.keys(config.hooks).length === 0) {
153
+ delete config.hooks;
154
+ }
155
+
156
+ if (!changed) {
157
+ return "local: clean";
158
+ }
159
+
160
+ writeJson(localPath, config);
161
+ return "local: removed old hex-line hooks";
162
+ }
163
+
164
+ // ---- Output Style installer ----
165
+
166
+ function installOutputStyle() {
167
+ const source = resolve(dirname(fileURLToPath(import.meta.url)), "..", "output-style.md");
168
+ const target = resolve(homedir(), ".claude", "output-styles", "hex-line.md");
169
+
170
+ // Copy output-style.md to ~/.claude/output-styles/
171
+ mkdirSync(dirname(target), { recursive: true });
172
+ writeFileSync(target, readFileSync(source, "utf-8"), "utf-8");
173
+
174
+ // Check outputStyle in all scopes (Local > Project > User)
175
+ const scopes = [
176
+ { path: resolve(process.cwd(), ".claude/settings.local.json"), label: "local" },
177
+ { path: resolve(process.cwd(), ".claude/settings.json"), label: "project" },
178
+ { path: resolve(homedir(), ".claude/settings.json"), label: "user" },
179
+ ];
180
+
181
+ for (const scope of scopes) {
182
+ const config = readJson(scope.path);
183
+ if (config && config.outputStyle) {
184
+ return `Output style 'hex-line' installed to ~/.claude/output-styles/. Current style '${config.outputStyle}' preserved (scope: ${scope.label}). Switch via /config > Output style`;
185
+ }
186
+ }
187
+
188
+ // No outputStyle set anywhere — activate hex-line at user level
189
+ const userSettings = resolve(homedir(), ".claude/settings.json");
190
+ const config = readJson(userSettings) || {};
191
+ config.outputStyle = "hex-line";
192
+ writeJson(userSettings, config);
193
+ return "Output style 'hex-line' installed and activated in ~/.claude/settings.json";
194
+ }
195
+
196
+ // ---- Agent configurators ----
197
+
198
+ function setupClaude() {
199
+ if (isEphemeralInstall(HOOK_SCRIPT)) {
200
+ return "Claude: SKIPPED — hook.mjs is in npx cache (ephemeral). " +
201
+ "Install permanently: npm i -g @levnikolaevich/hex-line-mcp, then re-run setup_hooks.";
202
+ }
203
+
204
+ const results = [];
205
+
206
+ // Phase A: write hooks to global ~/.claude/settings.json
207
+ const globalPath = resolve(homedir(), ".claude/settings.json");
208
+ results.push(writeHooksToFile(globalPath, "global"));
209
+
210
+ // Phase B: remove hex-line hooks from per-project settings.local.json
211
+ results.push(cleanLocalHooks());
212
+
213
+ // Phase C: install Output Style
214
+ results.push(installOutputStyle());
215
+
216
+ return results.join(" | ");
100
217
  }
101
218
 
102
219
  function setupGemini() {
@@ -113,6 +230,7 @@ const AGENTS = { claude: setupClaude, gemini: setupGemini, codex: setupCodex };
113
230
 
114
231
  /**
115
232
  * Configure hex-line hooks for one or all supported agents.
233
+ * Claude: writes to ~/.claude/settings.json (global), cleans per-project hooks.
116
234
  * @param {string} [agent="all"] - "claude", "gemini", "codex", or "all"
117
235
  * @returns {string} Status report
118
236
  */
package/lib/tree.mjs CHANGED
@@ -6,12 +6,26 @@
6
6
  */
7
7
 
8
8
  import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
9
- import { resolve, basename, join } from "node:path";
9
+ import { resolve, basename, join, relative } from "node:path";
10
10
 
11
11
  const SKIP_DIRS = new Set([
12
12
  "node_modules", ".git", "dist", "build", "__pycache__", ".next", "coverage",
13
13
  ]);
14
14
 
15
+ /**
16
+ * Convert a simple glob pattern to a RegExp.
17
+ * Supports: * (any non-slash), ** (any), ? (single char).
18
+ */
19
+ function globToRegex(pat) {
20
+ return new RegExp(
21
+ "^" + pat.replace(/[.+^${}()|[\]\\]/g, "\\$&")
22
+ .replace(/\*\*/g, "\0")
23
+ .replace(/\*/g, "[^/]*")
24
+ .replace(/\0/g, ".*")
25
+ .replace(/\?/g, ".") + "$"
26
+ );
27
+ }
28
+
15
29
  /**
16
30
  * Parse .gitignore into match functions.
17
31
  * Supports: comments (#), negation (!), wildcards (*), dir-only trailing /.
@@ -29,15 +43,11 @@ function parseGitignore(content) {
29
43
  // Strip trailing /
30
44
  const dirOnly = pat.endsWith("/");
31
45
  if (dirOnly) pat = pat.slice(0, -1);
32
- // Convert glob to regex
33
- const re = new RegExp(
34
- "^" + pat.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\0").replace(/\*/g, "[^/]*").replace(/\0/g, ".*").replace(/\?/g, ".") + "$"
35
- );
46
+ const re = globToRegex(pat);
36
47
  patterns.push({ re, negate, dirOnly });
37
48
  }
38
49
  return patterns;
39
50
  }
40
-
41
51
  function isIgnored(name, isDir, patterns) {
42
52
  let ignored = false;
43
53
  for (const { re, negate, dirOnly } of patterns) {
@@ -54,12 +64,77 @@ function formatSize(bytes) {
54
64
  }
55
65
 
56
66
  /**
57
- * Build directory tree recursively.
67
+ * Find files/dirs by glob pattern. Returns flat list of relative paths.
68
+ * @param {string} dirPath - Root directory to search
69
+ * @param {object} opts - { pattern, type, max_depth, gitignore }
70
+ * @returns {string} Formatted match list
71
+ */
72
+ function findByPattern(dirPath, opts) {
73
+ const re = globToRegex(opts.pattern);
74
+ const filterType = opts.type || "all";
75
+ const maxDepth = opts.max_depth ?? 20;
76
+ const useGitignore = opts.gitignore ?? true;
77
+
78
+ const normalized = (process.platform === "win32" && /^\/[a-zA-Z]\//.test(dirPath))
79
+ ? dirPath[1] + ":" + dirPath.slice(2) : dirPath;
80
+ const abs = resolve(normalized);
81
+ if (!existsSync(abs)) throw new Error(`DIRECTORY_NOT_FOUND: ${abs}`);
82
+ if (!statSync(abs).isDirectory()) throw new Error(`Not a directory: ${abs}`);
83
+
84
+ let patterns = [];
85
+ if (useGitignore) {
86
+ const gi = join(abs, ".gitignore");
87
+ if (existsSync(gi)) {
88
+ try { patterns = parseGitignore(readFileSync(gi, "utf-8")); } catch { /* skip */ }
89
+ }
90
+ }
91
+
92
+ const matches = [];
93
+
94
+ function walk(dir, depth) {
95
+ if (depth > maxDepth) return;
96
+ let entries;
97
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
98
+
99
+ for (const entry of entries) {
100
+ const isDir = entry.isDirectory();
101
+ if (SKIP_DIRS.has(entry.name) && isDir) continue;
102
+ if (isIgnored(entry.name, isDir, patterns)) continue;
103
+
104
+ const full = join(dir, entry.name);
105
+
106
+ if (re.test(entry.name)) {
107
+ if (filterType === "all" ||
108
+ (filterType === "dir" && isDir) ||
109
+ (filterType === "file" && !isDir)) {
110
+ const rel = relative(abs, full).replace(/\\/g, "/");
111
+ matches.push(isDir ? rel + "/" : rel);
112
+ }
113
+ }
114
+
115
+ if (isDir) walk(full, depth + 1);
116
+ }
117
+ }
118
+
119
+ walk(abs, 1);
120
+ matches.sort();
121
+
122
+ const rootName = basename(abs);
123
+ if (matches.length === 0) {
124
+ return `No matches for "${opts.pattern}" in ${rootName}/`;
125
+ }
126
+ return `Found ${matches.length} match${matches.length === 1 ? "" : "es"} for "${opts.pattern}" in ${rootName}/\n\n${matches.join("\n")}`;
127
+ }
128
+
129
+ /**
130
+ * Build directory tree recursively, or find by pattern.
58
131
  * @param {string} dirPath - Absolute directory path
59
- * @param {object} opts - { max_depth, gitignore, format }
60
- * @returns {string} Formatted tree
132
+ * @param {object} opts - { max_depth, gitignore, format, pattern, type }
133
+ * @returns {string} Formatted tree or match list
61
134
  */
62
135
  export function directoryTree(dirPath, opts = {}) {
136
+ if (opts.pattern) return findByPattern(dirPath, opts);
137
+
63
138
  const compact = opts.format === "compact";
64
139
  const maxDepth = compact ? 1 : (opts.max_depth ?? 3);
65
140
  const useGitignore = opts.gitignore ?? true;
@@ -0,0 +1,27 @@
1
+ ---
2
+ name: hex-line
3
+ description: Prefer hex-line MCP tools over built-in Read/Edit/Write/Grep
4
+ keep-coding-instructions: true
5
+ ---
6
+
7
+ # MCP Tool Preferences
8
+
9
+ When `hex-line` MCP is available, **always prefer it** over built-in file tools:
10
+
11
+ | Instead of | Use | Why |
12
+ |-----------|-----|-----|
13
+ | Read | `mcp__hex-line__read_file` | Hash-annotated, edit-ready |
14
+ | Edit | `mcp__hex-line__edit_file` | Hash-verified anchors |
15
+ | Write | `mcp__hex-line__write_file` | Consistent workflow |
16
+ | Grep | `mcp__hex-line__grep_search` | Hash-annotated matches |
17
+
18
+ ## Efficient File Reading
19
+
20
+ For code files >100 lines, ALWAYS:
21
+ 1. `outline` first (10-20 lines of structure)
22
+ 2. `read_file` with offset/limit for the specific section you need
23
+
24
+ NEVER read a large file in full — outline+targeted read saves 75% tokens.
25
+
26
+ Bash OK for: npm/node/git/docker/curl, pipes, compound commands.
27
+ **Exceptions** (use built-in Read): images, PDFs, Jupyter notebooks.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@levnikolaevich/hex-line-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
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",
@@ -12,7 +12,8 @@
12
12
  "hook.mjs",
13
13
  "benchmark.mjs",
14
14
  "lib/",
15
- "README.md"
15
+ "README.md",
16
+ "output-style.md"
16
17
  ],
17
18
  "scripts": {
18
19
  "start": "node server.mjs",