@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 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
- claude mcp add -s user hex-line -- node path/to/mcp/hex-line-mcp/server.mjs
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
- Or manual add to `.claude/settings.local.json`:
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+/, key: "tail" },
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(command)) {
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
- // Skip compound commands pipes, redirects, chains are intentional
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 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;
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
- // 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
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
- return "Output style 'hex-line' installed and activated in ~/.claude/settings.json";
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: Prefer hex-line MCP tools over built-in Read/Edit/Write/Grep
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.0",
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.0" });
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":false}} — text match',
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.0.0");
367
+ void checkForUpdates("@levnikolaevich/hex-line-mcp", "1.1.2");