@levnikolaevich/hex-line-mcp 1.1.1 → 1.2.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/hook.mjs +12 -7
- package/lib/edit.mjs +26 -61
- package/lib/search.mjs +1 -0
- package/lib/setup.mjs +22 -16
- package/output-style.md +15 -1
- package/package.json +1 -1
- package/server.mjs +8 -7
package/hook.mjs
CHANGED
|
@@ -43,13 +43,13 @@ const TOOL_HINTS = {
|
|
|
43
43
|
Read: "mcp__hex-line__read_file (not Read). For writing: write_file (no prior Read needed)",
|
|
44
44
|
Edit: "mcp__hex-line__edit_file (not Edit, not sed -i). read_file first for hashes",
|
|
45
45
|
Write: "mcp__hex-line__write_file (not Write). No prior Read needed",
|
|
46
|
-
Grep: "mcp__hex-line__grep_search (not Grep,
|
|
46
|
+
Grep: "mcp__hex-line__grep_search (not Grep). Params: case_insensitive, smart_case",
|
|
47
47
|
cat: "mcp__hex-line__read_file (not cat/head/tail/less/more)",
|
|
48
48
|
head: "mcp__hex-line__read_file with limit param (not head)",
|
|
49
49
|
tail: "mcp__hex-line__read_file with offset param (not tail)",
|
|
50
50
|
ls: "mcp__hex-line__directory_tree with pattern param (not ls/find/tree). E.g. pattern='*-mcp' type='dir'",
|
|
51
51
|
stat: "mcp__hex-line__get_file_info (not stat/wc/file)",
|
|
52
|
-
grep: "mcp__hex-line__grep_search (not grep/rg)",
|
|
52
|
+
grep: "mcp__hex-line__grep_search (not grep/rg). Params: case_insensitive, smart_case",
|
|
53
53
|
sed: "mcp__hex-line__edit_file (not sed -i). read_file first for hashes",
|
|
54
54
|
diff: "mcp__hex-line__changes (not diff). Git-based semantic diff",
|
|
55
55
|
outline: "mcp__hex-line__outline (before reading large code files)",
|
|
@@ -62,17 +62,14 @@ const TOOL_HINTS = {
|
|
|
62
62
|
const BASH_REDIRECTS = [
|
|
63
63
|
{ regex: /^cat\s+\S+/, key: "cat" },
|
|
64
64
|
{ regex: /^head\s+/, key: "head" },
|
|
65
|
-
{ regex: /^tail\s
|
|
65
|
+
{ regex: /^tail\s+(?!-[fF])/, key: "tail" },
|
|
66
66
|
{ regex: /^(less|more)\s+/, key: "cat" },
|
|
67
67
|
{ regex: /^(ls|dir)(\s+-\S+)*\s+/, key: "ls" },
|
|
68
68
|
{ regex: /^tree\s+/, key: "ls" },
|
|
69
69
|
{ regex: /^find\s+/, key: "ls" },
|
|
70
|
-
{ regex: /^du\s+/, key: "ls" },
|
|
71
70
|
{ regex: /^(stat|wc)\s+/, key: "stat" },
|
|
72
|
-
{ regex: /^file\s+/, key: "stat" },
|
|
73
71
|
{ regex: /^(grep|rg)\s+/, key: "grep" },
|
|
74
72
|
{ regex: /^sed\s+-i/, key: "sed" },
|
|
75
|
-
{ regex: /^diff\s+/, key: "diff" },
|
|
76
73
|
];
|
|
77
74
|
|
|
78
75
|
const TOOL_REDIRECT_MAP = {
|
|
@@ -211,8 +208,16 @@ function handlePreToolUse(data) {
|
|
|
211
208
|
}
|
|
212
209
|
}
|
|
213
210
|
|
|
214
|
-
//
|
|
211
|
+
// Compound commands: check first command in pipe before skipping
|
|
215
212
|
if (COMPOUND_OPERATORS.test(command)) {
|
|
213
|
+
const firstCmd = command.split(/\s*[|;&>]\s*/)[0].trim();
|
|
214
|
+
for (const { regex, key } of BASH_REDIRECTS) {
|
|
215
|
+
if (regex.test(firstCmd)) {
|
|
216
|
+
const hint = TOOL_HINTS[key];
|
|
217
|
+
const toolName2 = hint.split(" (")[0];
|
|
218
|
+
block(`Use ${toolName2} instead of piped command`, hint);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
216
221
|
process.exit(0);
|
|
217
222
|
}
|
|
218
223
|
|
package/lib/edit.mjs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { diffLines } from "diff";
|
|
13
|
-
import { fnv1a, lineTag } from "./hash.mjs";
|
|
13
|
+
import { fnv1a, lineTag, rangeChecksum } from "./hash.mjs";
|
|
14
14
|
import { validatePath } from "./security.mjs";
|
|
15
15
|
import { getGraphDB, blastRadius, getRelativePath } from "./graph-enrich.mjs";
|
|
16
16
|
|
|
@@ -242,6 +242,9 @@ function textReplace(content, oldText, newText, all) {
|
|
|
242
242
|
const normOld = oldText.replace(/\r\n/g, "\n");
|
|
243
243
|
const normNew = newText.replace(/\r\n/g, "\n");
|
|
244
244
|
|
|
245
|
+
if (!all) {
|
|
246
|
+
throw new Error("replace requires all:true (rename-all mode). For single replacements, use set_line or replace_lines with hash anchors.");
|
|
247
|
+
}
|
|
245
248
|
let idx = norm.indexOf(normOld);
|
|
246
249
|
let confusableMatch = false;
|
|
247
250
|
if (idx === -1) {
|
|
@@ -267,74 +270,25 @@ function textReplace(content, oldText, newText, all) {
|
|
|
267
270
|
// Determine the match length in original content (same as normOld.length for both paths)
|
|
268
271
|
const matchLen = normOld.length;
|
|
269
272
|
|
|
270
|
-
if (all) {
|
|
271
|
-
if (confusableMatch) {
|
|
272
|
-
// Replace all via normalized matching
|
|
273
|
-
const normContent = normalizeConfusables(norm);
|
|
274
|
-
const normSearch = normalizeConfusables(normOld);
|
|
275
|
-
let result = "";
|
|
276
|
-
let pos = 0;
|
|
277
|
-
let searchIdx = normContent.indexOf(normSearch, pos);
|
|
278
|
-
while (searchIdx !== -1) {
|
|
279
|
-
result += norm.slice(pos, searchIdx) + normNew;
|
|
280
|
-
pos = searchIdx + matchLen;
|
|
281
|
-
searchIdx = normContent.indexOf(normSearch, pos);
|
|
282
|
-
}
|
|
283
|
-
result += norm.slice(pos);
|
|
284
|
-
return result;
|
|
285
|
-
}
|
|
286
|
-
return norm.split(normOld).join(normNew);
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
// Check for multiple matches — return hash-hint instead of opaque error
|
|
290
|
-
const positions = [];
|
|
291
273
|
if (confusableMatch) {
|
|
274
|
+
// Replace all via normalized matching
|
|
292
275
|
const normContent = normalizeConfusables(norm);
|
|
293
276
|
const normSearch = normalizeConfusables(normOld);
|
|
294
|
-
let
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
while ((searchPos = norm.indexOf(normOld, searchPos)) !== -1) {
|
|
302
|
-
positions.push(searchPos);
|
|
303
|
-
searchPos += normOld.length;
|
|
277
|
+
let result = "";
|
|
278
|
+
let pos = 0;
|
|
279
|
+
let searchIdx = normContent.indexOf(normSearch, pos);
|
|
280
|
+
while (searchIdx !== -1) {
|
|
281
|
+
result += norm.slice(pos, searchIdx) + normNew;
|
|
282
|
+
pos = searchIdx + matchLen;
|
|
283
|
+
searchIdx = normContent.indexOf(normSearch, pos);
|
|
304
284
|
}
|
|
285
|
+
result += norm.slice(pos);
|
|
286
|
+
return result;
|
|
305
287
|
}
|
|
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
|
-
);
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
return norm.slice(0, idx) + normNew + norm.slice(idx + matchLen);
|
|
288
|
+
return norm.split(normOld).join(normNew);
|
|
334
289
|
}
|
|
335
290
|
|
|
336
291
|
|
|
337
|
-
|
|
338
292
|
/**
|
|
339
293
|
* Apply edits to a file.
|
|
340
294
|
*
|
|
@@ -390,6 +344,17 @@ export function editFile(filePath, edits, opts = {}) {
|
|
|
390
344
|
const en = parseRef(e.replace_lines.end_anchor);
|
|
391
345
|
const si = findLine(lines, s.line, s.tag, hashIndex);
|
|
392
346
|
const ei = findLine(lines, en.line, en.tag, hashIndex);
|
|
347
|
+
|
|
348
|
+
// Range checksum verification (mandatory)
|
|
349
|
+
const rc = e.replace_lines.range_checksum;
|
|
350
|
+
if (!rc) throw new Error("range_checksum required for replace_lines. Read the range first via read_file, then pass its checksum.");
|
|
351
|
+
const rcHex = rc.includes(":") ? rc.split(":")[1] : rc;
|
|
352
|
+
const lineHashes = [];
|
|
353
|
+
for (let i = si; i <= ei; i++) lineHashes.push(fnv1a(lines[i]));
|
|
354
|
+
const actual = rangeChecksum(lineHashes, s.line, en.line);
|
|
355
|
+
const actualHex = actual.split(":")[1];
|
|
356
|
+
if (rcHex !== actualHex) throw new Error(`Range checksum mismatch: expected ${rc}, got ${actual}. File changed \u2014 re-read lines ${s.line}-${en.line}.`);
|
|
357
|
+
|
|
393
358
|
const txt = e.replace_lines.new_text;
|
|
394
359
|
if (!txt && txt !== 0) {
|
|
395
360
|
lines.splice(si, ei - si + 1);
|
package/lib/search.mjs
CHANGED
|
@@ -30,6 +30,7 @@ export function grepSearch(pattern, opts = {}) {
|
|
|
30
30
|
const plain = !!opts.plain;
|
|
31
31
|
|
|
32
32
|
if (opts.caseInsensitive) args.push("-i");
|
|
33
|
+
else if (opts.smartCase) args.push("-S");
|
|
33
34
|
if (opts.context && opts.context > 0) args.push("-C", String(opts.context));
|
|
34
35
|
if (opts.glob) args.push("--glob", opts.glob);
|
|
35
36
|
if (opts.type) args.push("--type", opts.type);
|
package/lib/setup.mjs
CHANGED
|
@@ -171,26 +171,32 @@ function installOutputStyle() {
|
|
|
171
171
|
mkdirSync(dirname(target), { recursive: true });
|
|
172
172
|
writeFileSync(target, readFileSync(source, "utf-8"), "utf-8");
|
|
173
173
|
|
|
174
|
-
//
|
|
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
|
|
174
|
+
// Set hex-line at user (global) level
|
|
189
175
|
const userSettings = resolve(homedir(), ".claude/settings.json");
|
|
190
176
|
const config = readJson(userSettings) || {};
|
|
177
|
+
const prev = config.outputStyle;
|
|
191
178
|
config.outputStyle = "hex-line";
|
|
192
179
|
writeJson(userSettings, config);
|
|
193
|
-
|
|
180
|
+
|
|
181
|
+
// Remove outputStyle from local/project scopes so global is not overridden
|
|
182
|
+
const overrides = [
|
|
183
|
+
resolve(process.cwd(), ".claude/settings.local.json"),
|
|
184
|
+
resolve(process.cwd(), ".claude/settings.json"),
|
|
185
|
+
];
|
|
186
|
+
const cleared = [];
|
|
187
|
+
for (const p of overrides) {
|
|
188
|
+
const c = readJson(p);
|
|
189
|
+
if (c && c.outputStyle) {
|
|
190
|
+
cleared.push(`${c.outputStyle} (${p.includes("local") ? "local" : "project"})`);
|
|
191
|
+
delete c.outputStyle;
|
|
192
|
+
writeJson(p, c);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let msg = "Output style 'hex-line' installed and activated globally";
|
|
197
|
+
if (prev && prev !== "hex-line") msg += ` (was '${prev}')`;
|
|
198
|
+
if (cleared.length) msg += `. Removed overrides: ${cleared.join(", ")}`;
|
|
199
|
+
return msg;
|
|
194
200
|
}
|
|
195
201
|
|
|
196
202
|
// ---- Agent configurators ----
|
package/output-style.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: hex-line
|
|
3
|
-
description:
|
|
3
|
+
description: hex-line MCP tool preferences + explanatory coding style with insights
|
|
4
4
|
keep-coding-instructions: true
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -25,3 +25,17 @@ NEVER read a large file in full — outline+targeted read saves 75% tokens.
|
|
|
25
25
|
|
|
26
26
|
Bash OK for: npm/node/git/docker/curl, pipes, compound commands.
|
|
27
27
|
**Exceptions** (use built-in Read): images, PDFs, Jupyter notebooks.
|
|
28
|
+
|
|
29
|
+
# Explanatory Style
|
|
30
|
+
|
|
31
|
+
Provide educational insights about the codebase alongside task completion. When providing insights, you may exceed typical length constraints, but remain focused and relevant.
|
|
32
|
+
|
|
33
|
+
## Insights
|
|
34
|
+
|
|
35
|
+
Before and after writing code, provide brief educational explanations about implementation choices using:
|
|
36
|
+
|
|
37
|
+
"`\u2736 Insight \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`
|
|
38
|
+
[2-3 key educational points]
|
|
39
|
+
`\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`"
|
|
40
|
+
|
|
41
|
+
Focus on insights specific to the codebase or the code just written, not general programming concepts.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@levnikolaevich/hex-line-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.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",
|
package/server.mjs
CHANGED
|
@@ -54,7 +54,7 @@ try {
|
|
|
54
54
|
process.exit(1);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
const server = new McpServer({ name: "hex-line-mcp", version: "1.
|
|
57
|
+
const server = new McpServer({ name: "hex-line-mcp", version: "1.2.0" });
|
|
58
58
|
|
|
59
59
|
|
|
60
60
|
// ==================== read_file ====================
|
|
@@ -109,9 +109,9 @@ server.registerTool("edit_file", {
|
|
|
109
109
|
edits: z.string().describe(
|
|
110
110
|
'JSON array. Examples:\n' +
|
|
111
111
|
'{"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\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' +
|
|
113
113
|
'{"insert_after":{"anchor":"ab.20","text":"inserted"}} — insert below\n' +
|
|
114
|
-
'{"replace":{"old_text":"find","new_text":"replace","all":
|
|
114
|
+
'{"replace":{"old_text":"find","new_text":"replace","all":true}} — rename-all (all:true required)',
|
|
115
115
|
),
|
|
116
116
|
dry_run: flexBool().describe("Preview changes without writing"),
|
|
117
117
|
restore_indent: flexBool().describe("Auto-fix indentation to match anchor (default: false)"),
|
|
@@ -166,17 +166,18 @@ server.registerTool("grep_search", {
|
|
|
166
166
|
path: z.string().optional().describe("Search dir/file (default: cwd)"),
|
|
167
167
|
glob: z.string().optional().describe('Glob filter (e.g. "*.ts")'),
|
|
168
168
|
type: z.string().optional().describe('File type (e.g. "js", "py")'),
|
|
169
|
-
case_insensitive: flexBool().describe("Ignore case"),
|
|
169
|
+
case_insensitive: flexBool().describe("Ignore case (-i)"),
|
|
170
|
+
smart_case: flexBool().describe("CI when pattern is all lowercase, CS if it has uppercase (-S)"),
|
|
170
171
|
context: flexNum().describe("Context lines around matches"),
|
|
171
172
|
limit: flexNum().describe("Max matches per file (default: 100)"),
|
|
172
173
|
plain: flexBool().describe("Omit hash tags, return file:line:content"),
|
|
173
174
|
}),
|
|
174
175
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
|
175
176
|
}, async (rawParams) => {
|
|
176
|
-
const { pattern, path: p, glob, type, case_insensitive, context, limit, plain } = coerceParams(rawParams);
|
|
177
|
+
const { pattern, path: p, glob, type, case_insensitive, smart_case, context, limit, plain } = coerceParams(rawParams);
|
|
177
178
|
try {
|
|
178
179
|
const result = await grepSearch(pattern, {
|
|
179
|
-
path: p, glob, type, caseInsensitive: case_insensitive, context, limit, plain,
|
|
180
|
+
path: p, glob, type, caseInsensitive: case_insensitive, smartCase: smart_case, context, limit, plain,
|
|
180
181
|
});
|
|
181
182
|
return { content: [{ type: "text", text: result }] };
|
|
182
183
|
} catch (e) {
|
|
@@ -364,4 +365,4 @@ server.registerTool("bulk_replace", {
|
|
|
364
365
|
|
|
365
366
|
const transport = new StdioServerTransport();
|
|
366
367
|
await server.connect(transport);
|
|
367
|
-
void checkForUpdates("@levnikolaevich/hex-line-mcp", "1.
|
|
368
|
+
void checkForUpdates("@levnikolaevich/hex-line-mcp", "1.2.0");
|