@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.
- package/README.md +76 -19
- package/benchmark.mjs +524 -598
- package/hook.mjs +62 -19
- package/lib/benchmark-helpers.mjs +541 -0
- package/lib/bulk-replace.mjs +8 -0
- package/lib/coerce.mjs +5 -0
- package/lib/edit.mjs +71 -31
- package/lib/read.mjs +44 -19
- package/lib/setup.mjs +134 -16
- package/lib/tree.mjs +84 -9
- package/output-style.md +27 -0
- package/package.json +3 -2
- package/server.mjs +23 -24
package/lib/bulk-replace.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
277
|
-
|
|
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
|
-
}
|
|
280
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
419
|
+
throw new Error("NOOP_EDIT: File already contains the desired content. No changes needed.");
|
|
383
420
|
}
|
|
384
421
|
|
|
385
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
|
65
|
+
function findEntryByCommand(entries) {
|
|
47
66
|
return entries.findIndex(
|
|
48
|
-
(e) => Array.isArray(e.hooks) && e.hooks.some((h) =>
|
|
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
|
-
// ----
|
|
73
|
+
// ---- Core: write hooks to a settings file ----
|
|
53
74
|
|
|
54
|
-
function
|
|
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
|
|
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
|
|
115
|
+
return `Claude (${label}): already configured`;
|
|
96
116
|
}
|
|
97
117
|
|
|
98
118
|
writeJson(settingsPath, config);
|
|
99
|
-
return
|
|
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
|
-
|
|
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
|
-
*
|
|
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;
|
package/output-style.md
ADDED
|
@@ -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.
|
|
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",
|