@levnikolaevich/hex-line-mcp 1.1.2 → 1.3.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 +18 -12
- package/hook.mjs +81 -2
- package/lib/edit.mjs +26 -5
- package/lib/hash.mjs +1 -1
- package/lib/search.mjs +1 -0
- package/lib/setup.mjs +38 -2
- package/lib/tree.mjs +34 -7
- package/output-style.md +1 -1
- package/package.json +9 -3
- package/server.mjs +14 -13
package/README.md
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
Hash-verified file editing MCP + token efficiency hook for AI coding agents.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/@levnikolaevich/hex-line-mcp)
|
|
6
|
-
](https://www.npmjs.com/package/@levnikolaevich/hex-line-mcp)
|
|
7
|
+
[](./LICENSE)
|
|
8
|
+

|
|
7
9
|
|
|
8
10
|
Every line carries an FNV-1a content hash. Every edit must present those hashes back -- proving the agent is editing what it thinks it's editing. No stale context, no silent corruption.
|
|
9
11
|
|
|
@@ -106,6 +108,7 @@ Read a file with FNV-1a hash-annotated lines and range checksums. Supports direc
|
|
|
106
108
|
| Parameter | Type | Required | Description |
|
|
107
109
|
|-----------|------|----------|-------------|
|
|
108
110
|
| `path` | string | yes | File or directory path |
|
|
111
|
+
| `paths` | string[] | no | Array of file paths to read (batch mode) |
|
|
109
112
|
| `offset` | number | no | Start line, 1-indexed (default: 1) |
|
|
110
113
|
| `limit` | number | no | Max lines to return (default: 2000, 0 = all) |
|
|
111
114
|
| `plain` | boolean | no | Omit hashes, output `lineNum\|content` instead |
|
|
@@ -161,8 +164,10 @@ Search file contents using ripgrep with hash-annotated results.
|
|
|
161
164
|
| `glob` | string | no | Glob filter, e.g. `"*.ts"` |
|
|
162
165
|
| `type` | string | no | File type filter, e.g. `"js"`, `"py"` |
|
|
163
166
|
| `case_insensitive` | boolean | no | Ignore case |
|
|
167
|
+
| `smart_case` | boolean | no | Case-insensitive when pattern is all lowercase, case-sensitive if it has uppercase (`-S`) |
|
|
164
168
|
| `context` | number | no | Context lines around matches |
|
|
165
169
|
| `limit` | number | no | Max matches per file (default: 100) |
|
|
170
|
+
| `plain` | boolean | no | Omit hash tags, return `file:line:content` |
|
|
166
171
|
|
|
167
172
|
### outline
|
|
168
173
|
|
|
@@ -194,8 +199,11 @@ Compact directory tree with .gitignore support and file sizes.
|
|
|
194
199
|
| Parameter | Type | Required | Description |
|
|
195
200
|
|-----------|------|----------|-------------|
|
|
196
201
|
| `path` | string | yes | Directory path |
|
|
197
|
-
| `
|
|
202
|
+
| `pattern` | string | no | Glob filter on names (e.g. `"*-mcp"`, `"*.mjs"`). Returns flat match list instead of tree |
|
|
203
|
+
| `type` | string | no | `"file"`, `"dir"`, or `"all"` (default). Like `find -type f/d` |
|
|
204
|
+
| `max_depth` | number | no | Max recursion depth (default: 3, or 20 in pattern mode) |
|
|
198
205
|
| `gitignore` | boolean | no | Respect .gitignore patterns (default: true) |
|
|
206
|
+
| `format` | string | no | `"compact"` = names only, no sizes, depth 1. `"full"` = default with sizes |
|
|
199
207
|
|
|
200
208
|
Skips `node_modules`, `.git`, `dist`, `build`, `__pycache__`, `.next`, `coverage` by default.
|
|
201
209
|
|
|
@@ -283,16 +291,6 @@ FNV-1a accumulator over all line hashes in the range (little-endian byte feed).
|
|
|
283
291
|
- Write path validation (ancestor directory must exist)
|
|
284
292
|
- Directory restrictions delegated to Claude Code sandbox
|
|
285
293
|
|
|
286
|
-
## Differences from trueline-mcp
|
|
287
|
-
|
|
288
|
-
| Aspect | hex-line-mcp | trueline-mcp |
|
|
289
|
-
|--------|---------------|--------------|
|
|
290
|
-
| Hash algorithm | FNV-1a (pure JS, zero dependencies) | xxHash (native addon) |
|
|
291
|
-
| Diff output | Compact unified diff via `diff` npm package | Custom diff implementation |
|
|
292
|
-
| Hook | Unified `hook.mjs` (reminder + RTK filter) | Separate hook scripts |
|
|
293
|
-
| Path security | Canonicalization + binary detection, no ALLOWED_DIRS | Explicit ALLOWED_DIRS allowlist |
|
|
294
|
-
| Transport | stdio only | stdio |
|
|
295
|
-
| Outline | tree-sitter WASM (15+ languages) | tree-sitter WASM |
|
|
296
294
|
|
|
297
295
|
## FAQ
|
|
298
296
|
|
|
@@ -331,6 +329,14 @@ Yes. Remove the PreToolUse hook from `.claude/settings.local.json`. The MCP tool
|
|
|
331
329
|
|
|
332
330
|
</details>
|
|
333
331
|
|
|
332
|
+
## Hex Family
|
|
333
|
+
|
|
334
|
+
| Package | Purpose | npm |
|
|
335
|
+
|---------|---------|-----|
|
|
336
|
+
| [hex-line-mcp](https://www.npmjs.com/package/@levnikolaevich/hex-line-mcp) | Local file editing with hash verification + hooks | [](https://www.npmjs.com/package/@levnikolaevich/hex-line-mcp) |
|
|
337
|
+
| [hex-ssh-mcp](https://www.npmjs.com/package/@levnikolaevich/hex-ssh-mcp) | Remote file editing over SSH | [](https://www.npmjs.com/package/@levnikolaevich/hex-ssh-mcp) |
|
|
338
|
+
| [hex-graph-mcp](https://www.npmjs.com/package/@levnikolaevich/hex-graph-mcp) | Code knowledge graph with AST indexing | [](https://www.npmjs.com/package/@levnikolaevich/hex-graph-mcp) |
|
|
339
|
+
|
|
334
340
|
## License
|
|
335
341
|
|
|
336
342
|
MIT
|
package/hook.mjs
CHANGED
|
@@ -39,17 +39,31 @@ const BINARY_EXT = new Set([
|
|
|
39
39
|
".ttf", ".otf", ".woff", ".woff2",
|
|
40
40
|
]);
|
|
41
41
|
|
|
42
|
+
const REVERSE_TOOL_HINTS = {
|
|
43
|
+
"mcp__hex-line__read_file": "Read (file_path, offset, limit)",
|
|
44
|
+
"mcp__hex-line__edit_file": "Edit (file_path, old_string, new_string)",
|
|
45
|
+
"mcp__hex-line__write_file": "Write (file_path, content)",
|
|
46
|
+
"mcp__hex-line__grep_search": "Grep (pattern, path)",
|
|
47
|
+
"mcp__hex-line__directory_tree": "Glob (pattern) or Bash(ls)",
|
|
48
|
+
"mcp__hex-line__get_file_info": "Bash(stat/wc)",
|
|
49
|
+
"mcp__hex-line__outline": "Read with offset/limit",
|
|
50
|
+
"mcp__hex-line__verify": "Read the file again with Read",
|
|
51
|
+
"mcp__hex-line__changes": "Bash(git diff)",
|
|
52
|
+
"mcp__hex-line__bulk_replace": "Edit for each file",
|
|
53
|
+
"mcp__hex-line__setup_hooks": "Not available (hex-line disabled)",
|
|
54
|
+
};
|
|
55
|
+
|
|
42
56
|
const TOOL_HINTS = {
|
|
43
57
|
Read: "mcp__hex-line__read_file (not Read). For writing: write_file (no prior Read needed)",
|
|
44
58
|
Edit: "mcp__hex-line__edit_file (not Edit, not sed -i). read_file first for hashes",
|
|
45
59
|
Write: "mcp__hex-line__write_file (not Write). No prior Read needed",
|
|
46
|
-
Grep: "mcp__hex-line__grep_search (not Grep,
|
|
60
|
+
Grep: "mcp__hex-line__grep_search (not Grep). Params: case_insensitive, smart_case",
|
|
47
61
|
cat: "mcp__hex-line__read_file (not cat/head/tail/less/more)",
|
|
48
62
|
head: "mcp__hex-line__read_file with limit param (not head)",
|
|
49
63
|
tail: "mcp__hex-line__read_file with offset param (not tail)",
|
|
50
64
|
ls: "mcp__hex-line__directory_tree with pattern param (not ls/find/tree). E.g. pattern='*-mcp' type='dir'",
|
|
51
65
|
stat: "mcp__hex-line__get_file_info (not stat/wc/file)",
|
|
52
|
-
grep: "mcp__hex-line__grep_search (not grep/rg)",
|
|
66
|
+
grep: "mcp__hex-line__grep_search (not grep/rg). Params: case_insensitive, smart_case",
|
|
53
67
|
sed: "mcp__hex-line__edit_file (not sed -i). read_file first for hashes",
|
|
54
68
|
diff: "mcp__hex-line__changes (not diff). Git-based semantic diff",
|
|
55
69
|
outline: "mcp__hex-line__outline (before reading large code files)",
|
|
@@ -138,6 +152,39 @@ function detectCommandType(cmd) {
|
|
|
138
152
|
return "generic";
|
|
139
153
|
}
|
|
140
154
|
|
|
155
|
+
/** Cache: null = not computed yet */
|
|
156
|
+
let _hexLineDisabled = null;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Check if hex-line MCP is disabled for the current project.
|
|
160
|
+
* Reads ~/.claude.json → projects.{cwd}.disabledMcpServers.
|
|
161
|
+
* Fail-open: returns false on any error.
|
|
162
|
+
*/
|
|
163
|
+
function isHexLineDisabled(configPath) {
|
|
164
|
+
if (_hexLineDisabled !== null) return _hexLineDisabled;
|
|
165
|
+
_hexLineDisabled = false;
|
|
166
|
+
try {
|
|
167
|
+
const p = configPath || resolve(homedir(), ".claude.json");
|
|
168
|
+
const claudeJson = JSON.parse(readFileSync(p, "utf-8"));
|
|
169
|
+
const projects = claudeJson.projects;
|
|
170
|
+
if (!projects || typeof projects !== "object") return _hexLineDisabled;
|
|
171
|
+
const cwd = process.cwd().replace(/\\/g, "/").replace(/\/$/, "").toLowerCase();
|
|
172
|
+
for (const [path, config] of Object.entries(projects)) {
|
|
173
|
+
if (path.replace(/\\/g, "/").replace(/\/$/, "").toLowerCase() === cwd) {
|
|
174
|
+
const disabled = config.disabledMcpServers;
|
|
175
|
+
if (Array.isArray(disabled) && disabled.includes("hex-line")) {
|
|
176
|
+
_hexLineDisabled = true;
|
|
177
|
+
}
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
} catch { /* fail open */ }
|
|
182
|
+
return _hexLineDisabled;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Reset cache (for testing). */
|
|
186
|
+
function _resetHexLineDisabledCache() { _hexLineDisabled = null; }
|
|
187
|
+
|
|
141
188
|
function block(reason, context) {
|
|
142
189
|
const output = {
|
|
143
190
|
hookSpecificOutput: {
|
|
@@ -237,6 +284,28 @@ function handlePreToolUse(data) {
|
|
|
237
284
|
process.exit(0);
|
|
238
285
|
}
|
|
239
286
|
|
|
287
|
+
// ---- PreToolUse REVERSE handler (hex-line disabled) ----
|
|
288
|
+
|
|
289
|
+
function handlePreToolUseReverse(data) {
|
|
290
|
+
const toolName = data.tool_name || "";
|
|
291
|
+
|
|
292
|
+
// Agent tries hex-line tool that's disabled → redirect to built-in
|
|
293
|
+
if (toolName.startsWith("mcp__hex-line__")) {
|
|
294
|
+
const builtIn = REVERSE_TOOL_HINTS[toolName];
|
|
295
|
+
if (builtIn) {
|
|
296
|
+
const target = builtIn.split(" ")[0];
|
|
297
|
+
block(
|
|
298
|
+
`hex-line is disabled in this project. Use ${target}`,
|
|
299
|
+
`hex-line disabled. Use built-in: ${builtIn}`
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
block("hex-line is disabled in this project", "Disabled via project settings");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// All built-in tools — approve silently
|
|
306
|
+
process.exit(0);
|
|
307
|
+
}
|
|
308
|
+
|
|
240
309
|
// ---- PostToolUse handler ----
|
|
241
310
|
|
|
242
311
|
function handlePostToolUse(data) {
|
|
@@ -341,6 +410,13 @@ process.stdin.on("end", () => {
|
|
|
341
410
|
const data = JSON.parse(input);
|
|
342
411
|
const event = data.hook_event_name || "";
|
|
343
412
|
|
|
413
|
+
if (isHexLineDisabled()) {
|
|
414
|
+
// REVERSE MODE: block hex-line calls, approve everything else
|
|
415
|
+
if (event === "PreToolUse") handlePreToolUseReverse(data);
|
|
416
|
+
process.exit(0); // SessionStart, PostToolUse — silent exit
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// NORMAL MODE
|
|
344
420
|
if (event === "SessionStart") handleSessionStart();
|
|
345
421
|
else if (event === "PreToolUse") handlePreToolUse(data);
|
|
346
422
|
else if (event === "PostToolUse") handlePostToolUse(data);
|
|
@@ -349,3 +425,6 @@ process.stdin.on("end", () => {
|
|
|
349
425
|
process.exit(0);
|
|
350
426
|
}
|
|
351
427
|
});
|
|
428
|
+
|
|
429
|
+
// ---- Exports for testing ----
|
|
430
|
+
export { isHexLineDisabled, _resetHexLineDisabledCache };
|
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, rangeChecksum } from "./hash.mjs";
|
|
13
|
+
import { fnv1a, lineTag, rangeChecksum, parseChecksum } from "./hash.mjs";
|
|
14
14
|
import { validatePath } from "./security.mjs";
|
|
15
15
|
import { getGraphDB, blastRadius, getRelativePath } from "./graph-enrich.mjs";
|
|
16
16
|
|
|
@@ -348,12 +348,33 @@ export function editFile(filePath, edits, opts = {}) {
|
|
|
348
348
|
// Range checksum verification (mandatory)
|
|
349
349
|
const rc = e.replace_lines.range_checksum;
|
|
350
350
|
if (!rc) throw new Error("range_checksum required for replace_lines. Read the range first via read_file, then pass its checksum.");
|
|
351
|
-
|
|
351
|
+
|
|
352
|
+
// Checksum's range is authoritative (from read_file), not anchor range
|
|
353
|
+
const { start: csStart, end: csEnd, hex: csHex } = parseChecksum(rc);
|
|
354
|
+
|
|
355
|
+
// Coverage check: checksum range must contain ACTUAL edit range (after relocation)
|
|
356
|
+
const actualStart = si + 1;
|
|
357
|
+
const actualEnd = ei + 1;
|
|
358
|
+
if (csStart > actualStart || csEnd < actualEnd) {
|
|
359
|
+
throw new Error(
|
|
360
|
+
`Checksum range ${csStart}-${csEnd} does not cover edit range ${actualStart}-${actualEnd}. ` +
|
|
361
|
+
`Re-read lines ${actualStart}-${actualEnd} first.`
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Verify freshness over checksum's own range using origLines snapshot
|
|
366
|
+
const csStartIdx = csStart - 1;
|
|
367
|
+
const csEndIdx = csEnd - 1;
|
|
368
|
+
if (csStartIdx < 0 || csEndIdx >= origLines.length) {
|
|
369
|
+
throw new Error(`Checksum range ${csStart}-${csEnd} out of bounds (file has ${origLines.length} lines). Re-read the file.`);
|
|
370
|
+
}
|
|
352
371
|
const lineHashes = [];
|
|
353
|
-
for (let i =
|
|
354
|
-
const actual = rangeChecksum(lineHashes,
|
|
372
|
+
for (let i = csStartIdx; i <= csEndIdx; i++) lineHashes.push(fnv1a(origLines[i]));
|
|
373
|
+
const actual = rangeChecksum(lineHashes, csStart, csEnd);
|
|
355
374
|
const actualHex = actual.split(":")[1];
|
|
356
|
-
if (
|
|
375
|
+
if (csHex !== actualHex) {
|
|
376
|
+
throw new Error(`Range checksum mismatch: expected ${rc}, got ${actual}. File changed \u2014 re-read lines ${csStart}-${csEnd}.`);
|
|
377
|
+
}
|
|
357
378
|
|
|
358
379
|
const txt = e.replace_lines.new_text;
|
|
359
380
|
if (!txt && txt !== 0) {
|
package/lib/hash.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* FNV-1a hashing for hash-verified file editing.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 2-char tags from 32-symbol alphabet,
|
|
5
5
|
* range checksums as FNV-1a accumulator over line hashes.
|
|
6
6
|
*
|
|
7
7
|
* Line format: {tag}.{lineNum}\t{content}
|
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
|
@@ -36,7 +36,7 @@ const CLAUDE_HOOKS = {
|
|
|
36
36
|
hooks: [{ type: "command", command: HOOK_COMMAND, timeout: 5 }],
|
|
37
37
|
},
|
|
38
38
|
PreToolUse: {
|
|
39
|
-
matcher: "Read|Edit|Write|Grep|Bash",
|
|
39
|
+
matcher: "Read|Edit|Write|Grep|Bash|mcp__hex-line__.*",
|
|
40
40
|
hooks: [{ type: "command", command: HOOK_COMMAND, timeout: 5 }],
|
|
41
41
|
},
|
|
42
42
|
PostToolUse: {
|
|
@@ -230,18 +230,54 @@ function setupCodex() {
|
|
|
230
230
|
return "Codex: Not supported (Codex CLI does not support hooks. Add MCP Tool Preferences to AGENTS.md instead)";
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
+
// ---- Uninstall: remove hex-line hooks ----
|
|
234
|
+
|
|
235
|
+
function uninstallClaude() {
|
|
236
|
+
const globalPath = resolve(homedir(), ".claude/settings.json");
|
|
237
|
+
const config = readJson(globalPath);
|
|
238
|
+
if (!config || !config.hooks || typeof config.hooks !== "object") {
|
|
239
|
+
return "Claude: no hooks to remove";
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
let changed = false;
|
|
243
|
+
for (const event of Object.keys(CLAUDE_HOOKS)) {
|
|
244
|
+
if (!Array.isArray(config.hooks[event])) continue;
|
|
245
|
+
const idx = findEntryByCommand(config.hooks[event]);
|
|
246
|
+
if (idx >= 0) {
|
|
247
|
+
config.hooks[event].splice(idx, 1);
|
|
248
|
+
if (config.hooks[event].length === 0) delete config.hooks[event];
|
|
249
|
+
changed = true;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (Object.keys(config.hooks).length === 0) delete config.hooks;
|
|
254
|
+
|
|
255
|
+
if (!changed) return "Claude: no hex-line hooks found";
|
|
256
|
+
|
|
257
|
+
writeJson(globalPath, config);
|
|
258
|
+
return "Claude: hex-line hooks removed from global settings";
|
|
259
|
+
}
|
|
260
|
+
|
|
233
261
|
// ---- Public API ----
|
|
234
262
|
|
|
235
263
|
const AGENTS = { claude: setupClaude, gemini: setupGemini, codex: setupCodex };
|
|
264
|
+
const UNINSTALL_AGENTS = { claude: uninstallClaude };
|
|
236
265
|
|
|
237
266
|
/**
|
|
238
267
|
* Configure hex-line hooks for one or all supported agents.
|
|
239
268
|
* Claude: writes to ~/.claude/settings.json (global), cleans per-project hooks.
|
|
240
269
|
* @param {string} [agent="all"] - "claude", "gemini", "codex", or "all"
|
|
270
|
+
* @param {string} [action="install"] - "install" or "uninstall"
|
|
241
271
|
* @returns {string} Status report
|
|
242
272
|
*/
|
|
243
|
-
export function setupHooks(agent = "all") {
|
|
273
|
+
export function setupHooks(agent = "all", action = "install") {
|
|
244
274
|
const target = (agent || "all").toLowerCase();
|
|
275
|
+
const act = (action || "install").toLowerCase();
|
|
276
|
+
|
|
277
|
+
if (act === "uninstall") {
|
|
278
|
+
const result = uninstallClaude();
|
|
279
|
+
return `Hooks uninstalled:\n ${result}\n\nRestart Claude Code to apply changes.`;
|
|
280
|
+
}
|
|
245
281
|
|
|
246
282
|
if (target !== "all" && !AGENTS[target]) {
|
|
247
283
|
throw new Error(`UNKNOWN_AGENT: '${agent}'. Supported: claude, gemini, codex, all`);
|
package/lib/tree.mjs
CHANGED
|
@@ -63,6 +63,28 @@ function formatSize(bytes) {
|
|
|
63
63
|
return `${bytes}B`;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
function countFileLines(filePath, size) {
|
|
67
|
+
if (size === 0 || size > 1_000_000) return null;
|
|
68
|
+
try {
|
|
69
|
+
const buf = readFileSync(filePath);
|
|
70
|
+
const checkLen = Math.min(buf.length, 8192);
|
|
71
|
+
for (let i = 0; i < checkLen; i++) if (buf[i] === 0) return null; // binary
|
|
72
|
+
let count = 1;
|
|
73
|
+
for (let i = 0; i < buf.length; i++) if (buf[i] === 0x0A) count++;
|
|
74
|
+
return count;
|
|
75
|
+
} catch { return null; }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function relativeTime(mtime) {
|
|
79
|
+
const sec = (Date.now() - mtime.getTime()) / 1000;
|
|
80
|
+
if (sec < 60) return "now";
|
|
81
|
+
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
|
|
82
|
+
if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`;
|
|
83
|
+
if (sec < 604800) return `${Math.floor(sec / 86400)}d ago`;
|
|
84
|
+
if (sec < 2592000) return `${Math.floor(sec / 604800)}w ago`;
|
|
85
|
+
return `${Math.floor(sec / 2592000)}mo ago`;
|
|
86
|
+
}
|
|
87
|
+
|
|
66
88
|
/**
|
|
67
89
|
* Find files/dirs by glob pattern. Returns flat list of relative paths.
|
|
68
90
|
* @param {string} dirPath - Root directory to search
|
|
@@ -199,14 +221,19 @@ export function directoryTree(dirPath, opts = {}) {
|
|
|
199
221
|
if (compact) {
|
|
200
222
|
lines.push(`${prefix}${name}`);
|
|
201
223
|
} else {
|
|
202
|
-
let size = 0;
|
|
203
|
-
try {
|
|
224
|
+
let size = 0, mtime = null, lineCount = null;
|
|
225
|
+
try {
|
|
226
|
+
const st = statSync(full);
|
|
227
|
+
size = st.size;
|
|
228
|
+
mtime = st.mtime;
|
|
229
|
+
} catch { /* skip */ }
|
|
204
230
|
totalSize += size;
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
231
|
+
lineCount = countFileLines(full, size);
|
|
232
|
+
const parts = [];
|
|
233
|
+
if (lineCount !== null) parts.push(`${lineCount}L`);
|
|
234
|
+
parts.push(formatSize(size));
|
|
235
|
+
if (mtime) parts.push(relativeTime(mtime));
|
|
236
|
+
lines.push(`${prefix}${name} (${parts.join(", ")})`);
|
|
210
237
|
}
|
|
211
238
|
}
|
|
212
239
|
}
|
package/output-style.md
CHANGED
|
@@ -6,7 +6,7 @@ keep-coding-instructions: true
|
|
|
6
6
|
|
|
7
7
|
# MCP Tool Preferences
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
**MANDATORY:** NEVER use built-in Read, Edit, Write, Grep. Use hex-line MCP equivalents:
|
|
10
10
|
|
|
11
11
|
| Instead of | Use | Why |
|
|
12
12
|
|-----------|-----|-----|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@levnikolaevich/hex-line-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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",
|
|
@@ -28,7 +28,11 @@
|
|
|
28
28
|
"tree-sitter-wasms": "^0.1.0",
|
|
29
29
|
"web-tree-sitter": "^0.25.0"
|
|
30
30
|
},
|
|
31
|
+
"author": "Lev Nikolaevich <https://github.com/levnikolaevich>",
|
|
31
32
|
"license": "MIT",
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/levnikolaevich/claude-code-skills/issues"
|
|
35
|
+
},
|
|
32
36
|
"keywords": [
|
|
33
37
|
"mcp",
|
|
34
38
|
"hex-line",
|
|
@@ -40,8 +44,10 @@
|
|
|
40
44
|
"hash-verified",
|
|
41
45
|
"token-efficiency",
|
|
42
46
|
"hook",
|
|
43
|
-
"
|
|
44
|
-
"
|
|
47
|
+
"bulk-replace",
|
|
48
|
+
"claude",
|
|
49
|
+
"ai",
|
|
50
|
+
"llm"
|
|
45
51
|
],
|
|
46
52
|
"engines": {
|
|
47
53
|
"node": ">=18.0.0"
|
package/server.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* hex-line-mcp — MCP server for hash-verified file operations.
|
|
4
4
|
*
|
|
5
5
|
* 11 tools: read_file, edit_file, write_file, grep_search, outline, verify, directory_tree, get_file_info, setup_hooks, changes, bulk_replace
|
|
6
|
-
* FNV-1a 2-char tags + range checksums
|
|
6
|
+
* FNV-1a 2-char tags + range checksums
|
|
7
7
|
* Security: root policy, path validation, binary/size rejection
|
|
8
8
|
* Transport: stdio
|
|
9
9
|
*/
|
|
@@ -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.3.0" });
|
|
58
58
|
|
|
59
59
|
|
|
60
60
|
// ==================== read_file ====================
|
|
@@ -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) {
|
|
@@ -286,19 +287,19 @@ server.registerTool("get_file_info", {
|
|
|
286
287
|
server.registerTool("setup_hooks", {
|
|
287
288
|
title: "Setup Hooks",
|
|
288
289
|
description:
|
|
289
|
-
"
|
|
290
|
-
"
|
|
291
|
-
"removes
|
|
292
|
-
"
|
|
293
|
-
"Idempotent: re-running produces no changes if already configured.",
|
|
290
|
+
"Install or uninstall hex-line hooks in CLI agent settings. " +
|
|
291
|
+
"install: writes hooks to ~/.claude/settings.json, removes old per-project hooks. " +
|
|
292
|
+
"uninstall: removes hex-line hooks from global settings. " +
|
|
293
|
+
"Idempotent: re-running produces no changes if already in desired state.",
|
|
294
294
|
inputSchema: z.object({
|
|
295
295
|
agent: z.string().optional().describe('Target agent: "claude", "gemini", "codex", or "all" (default: "all")'),
|
|
296
|
+
action: z.string().optional().describe('"install" (default) or "uninstall"'),
|
|
296
297
|
}),
|
|
297
298
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
298
299
|
}, async (rawParams) => {
|
|
299
|
-
const { agent } = coerceParams(rawParams);
|
|
300
|
+
const { agent, action } = coerceParams(rawParams);
|
|
300
301
|
try {
|
|
301
|
-
return { content: [{ type: "text", text: setupHooks(agent) }] };
|
|
302
|
+
return { content: [{ type: "text", text: setupHooks(agent, action) }] };
|
|
302
303
|
} catch (e) {
|
|
303
304
|
return { content: [{ type: "text", text: e.message }], isError: true };
|
|
304
305
|
}
|
|
@@ -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.3.0");
|