@levnikolaevich/hex-line-mcp 1.1.0 → 1.1.2
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 +3 -17
- package/hook.mjs +15 -6
- package/lib/edit.mjs +26 -61
- package/lib/setup.mjs +22 -16
- package/output-style.md +15 -1
- package/package.json +1 -1
- package/server.mjs +4 -4
package/README.md
CHANGED
|
@@ -43,13 +43,8 @@ PreToolUse also intercepts simple Bash commands: cat, head, tail, ls, tree, find
|
|
|
43
43
|
### MCP Server
|
|
44
44
|
|
|
45
45
|
```bash
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
Then install dependencies:
|
|
50
|
-
|
|
51
|
-
```bash
|
|
52
|
-
cd mcp/hex-line-mcp && npm install
|
|
46
|
+
npm i -g @levnikolaevich/hex-line-mcp
|
|
47
|
+
claude mcp add -s user hex-line -- hex-line-mcp
|
|
53
48
|
```
|
|
54
49
|
|
|
55
50
|
### Hooks
|
|
@@ -60,16 +55,7 @@ Automatic setup (run once after MCP install):
|
|
|
60
55
|
mcp__hex-line__setup_hooks(agent="claude")
|
|
61
56
|
```
|
|
62
57
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
```json
|
|
66
|
-
{
|
|
67
|
-
"hooks": {
|
|
68
|
-
"PreToolUse": [{"matcher": "Read|Edit|Write|Grep|Bash", "hooks": [{"type": "command", "command": "node mcp/hex-line-mcp/hook.mjs", "timeout": 5}]}],
|
|
69
|
-
"PostToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "node mcp/hex-line-mcp/hook.mjs", "timeout": 10}]}]
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
```
|
|
58
|
+
Hooks are written to global `~/.claude/settings.json` with absolute path to `hook.mjs` from the global npm install. Manual configuration is not needed.
|
|
73
59
|
|
|
74
60
|
### Output Style
|
|
75
61
|
|
package/hook.mjs
CHANGED
|
@@ -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 = {
|
|
@@ -197,9 +194,13 @@ function handlePreToolUse(data) {
|
|
|
197
194
|
process.exit(0);
|
|
198
195
|
}
|
|
199
196
|
|
|
197
|
+
// Strip heredoc bodies to avoid false positives on data content
|
|
198
|
+
// e.g. gh api -f body="$(cat <<'EOF'\n...rm -rf...\nEOF)"
|
|
199
|
+
const cmdCheck = command.replace(/<<['"]?(\w+)['"]?\s*\n[\s\S]*?\n\1/g, "");
|
|
200
|
+
|
|
200
201
|
// Dangerous command blocker
|
|
201
202
|
for (const { regex, reason } of DANGEROUS_PATTERNS) {
|
|
202
|
-
if (regex.test(
|
|
203
|
+
if (regex.test(cmdCheck)) {
|
|
203
204
|
block(
|
|
204
205
|
`DANGEROUS: ${reason}. Ask user to confirm, then retry with: # hex-confirmed`,
|
|
205
206
|
`Original command: ${command.slice(0, 100)}`
|
|
@@ -207,8 +208,16 @@ function handlePreToolUse(data) {
|
|
|
207
208
|
}
|
|
208
209
|
}
|
|
209
210
|
|
|
210
|
-
//
|
|
211
|
+
// Compound commands: check first command in pipe before skipping
|
|
211
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
|
+
}
|
|
212
221
|
process.exit(0);
|
|
213
222
|
}
|
|
214
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/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.1.
|
|
3
|
+
"version": "1.1.2",
|
|
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.1.
|
|
57
|
+
const server = new McpServer({ name: "hex-line-mcp", version: "1.1.2" });
|
|
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)"),
|
|
@@ -364,4 +364,4 @@ server.registerTool("bulk_replace", {
|
|
|
364
364
|
|
|
365
365
|
const transport = new StdioServerTransport();
|
|
366
366
|
await server.connect(transport);
|
|
367
|
-
void checkForUpdates("@levnikolaevich/hex-line-mcp", "1.
|
|
367
|
+
void checkForUpdates("@levnikolaevich/hex-line-mcp", "1.1.2");
|