@levnikolaevich/hex-line-mcp 1.3.2 → 1.3.4

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 DELETED
@@ -1,466 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Unified hook for hex-line-mcp.
4
- *
5
- * Handles three events:
6
- *
7
- * PreToolUse:
8
- * - Tool redirect: blocks Read/Edit/Write/Grep for text files,
9
- * redirecting to hex-line MCP equivalents.
10
- * - Bash redirect: blocks simple cat/head/tail/ls/grep/sed/diff
11
- * commands, redirecting to hex-line MCP equivalents.
12
- * - Dangerous command blocker: blocks rm -rf /, force push,
13
- * hard reset, DROP, chmod 777, mkfs, dd, etc.
14
- *
15
- * PostToolUse:
16
- * - RTK output filter: compresses verbose Bash output
17
- * (npm install, test, build, pip, git). Stderr shown to
18
- * Claude as feedback.
19
- *
20
- * SessionStart:
21
- * - Injects tool preference list into agent context.
22
- *
23
- * Exit 0 = approve / no feedback / systemMessage
24
- * Exit 2 = block (PreToolUse) or stderr feedback (PostToolUse)
25
- */
26
-
27
- import { normalizeOutput } from "./lib/normalize.mjs";
28
- import { readFileSync } from "node:fs";
29
- import { resolve } from "node:path";
30
- import { homedir } from "node:os";
31
-
32
- // ---- Constants ----
33
-
34
- const BINARY_EXT = new Set([
35
- ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg", ".ico",
36
- ".pdf", ".ipynb",
37
- ".zip", ".tar", ".gz", ".7z", ".rar",
38
- ".exe", ".dll", ".so", ".dylib", ".wasm",
39
- ".mp3", ".mp4", ".wav", ".avi", ".mkv",
40
- ".ttf", ".otf", ".woff", ".woff2",
41
- ]);
42
-
43
- const REVERSE_TOOL_HINTS = {
44
- "mcp__hex-line__read_file": "Read (file_path, offset, limit)",
45
- "mcp__hex-line__edit_file": "Edit (anchor-based hash edits only)",
46
- "mcp__hex-line__write_file": "Write (file_path, content)",
47
- "mcp__hex-line__grep_search": "Grep (pattern, path)",
48
- "mcp__hex-line__directory_tree": "Glob (pattern) or Bash(ls)",
49
- "mcp__hex-line__get_file_info": "Bash(stat/wc)",
50
- "mcp__hex-line__outline": "Read with offset/limit",
51
- "mcp__hex-line__verify": "Read the file again with Read",
52
- "mcp__hex-line__changes": "Bash(git diff)",
53
- "mcp__hex-line__bulk_replace": "Edit (text rename/refactor across files)",
54
- "mcp__hex-line__setup_hooks": "Not available (hex-line disabled)",
55
- };
56
-
57
- const TOOL_HINTS = {
58
- Read: "mcp__hex-line__read_file (not Read). For writing: write_file (no prior Read needed)",
59
- Edit: "mcp__hex-line__edit_file for hash-verified edits (not Edit). For text rename use bulk_replace",
60
- Write: "mcp__hex-line__write_file (not Write). No prior Read needed",
61
- Grep: "mcp__hex-line__grep_search (not Grep). Params: output, literal, context_before, context_after, multiline",
62
- cat: "mcp__hex-line__read_file (not cat/head/tail/less/more)",
63
- head: "mcp__hex-line__read_file with limit param (not head)",
64
- tail: "mcp__hex-line__read_file with offset param (not tail)",
65
- ls: "mcp__hex-line__directory_tree with pattern param (not ls/find/tree). E.g. pattern='*-mcp' type='dir'",
66
- stat: "mcp__hex-line__get_file_info (not stat/wc/file)",
67
- grep: "mcp__hex-line__grep_search (not grep/rg). Params: output, literal, context_before, context_after, multiline",
68
- sed: "mcp__hex-line__edit_file for hash edits, or mcp__hex-line__bulk_replace for text rename (not sed -i)",
69
- diff: "mcp__hex-line__changes (not diff). Git-based semantic diff",
70
- outline: "mcp__hex-line__outline (before reading large code files)",
71
- verify: "mcp__hex-line__verify (staleness check without re-read)",
72
- changes: "mcp__hex-line__changes (semantic AST diff)",
73
- bulk: "mcp__hex-line__bulk_replace (multi-file search-replace)",
74
- setup: "mcp__hex-line__setup_hooks (configure hooks for agents)",
75
- };
76
-
77
- const BASH_REDIRECTS = [
78
- { regex: /^cat\s+\S+/, key: "cat" },
79
- { regex: /^head\s+/, key: "head" },
80
- { regex: /^tail\s+(?!-[fF])/, key: "tail" },
81
- { regex: /^(less|more)\s+/, key: "cat" },
82
- { regex: /^ls\s+-\S*R(\s|$)/, key: "ls" }, // ls -R, ls -laR (recursive only)
83
- { regex: /^dir\s+\/[sS](\s|$)/, key: "ls" }, // dir /s, dir /S (recursive only)
84
- { regex: /^tree\s+/, key: "ls" },
85
- { regex: /^find\s+/, key: "ls" },
86
- { regex: /^(stat|wc)\s+/, key: "stat" },
87
- { regex: /^(grep|rg)\s+/, key: "grep" },
88
- { regex: /^sed\s+-i/, key: "sed" },
89
- ];
90
-
91
- const TOOL_REDIRECT_MAP = {
92
- Read: "Read",
93
- Edit: "Edit",
94
- Write: "Write",
95
- Grep: "Grep",
96
- };
97
-
98
- const DANGEROUS_PATTERNS = [
99
- {
100
- regex: /rm\s+(-[rf]+\s+)*[/~]/,
101
- reason: "rm -rf on root/home directory",
102
- },
103
- {
104
- regex: /git\s+push\s+(-f|--force)/,
105
- reason: "force push can overwrite remote history",
106
- },
107
- {
108
- regex: /git\s+reset\s+--hard/,
109
- reason: "hard reset discards uncommitted changes",
110
- },
111
- {
112
- regex: /DROP\s+(TABLE|DATABASE)/i,
113
- reason: "DROP destroys data permanently",
114
- },
115
- {
116
- regex: /chmod\s+777/,
117
- reason: "chmod 777 removes all access restrictions",
118
- },
119
- {
120
- regex: /mkfs/,
121
- reason: "filesystem format destroys all data",
122
- },
123
- {
124
- regex: /dd\s+if=\/dev\/zero/,
125
- reason: "direct disk write destroys data",
126
- },
127
- ];
128
-
129
- const COMPOUND_OPERATORS = /[|]|>>?|&&|\|\||;/;
130
-
131
- const CMD_PATTERNS = [
132
- [/npm (install|ci|update|add)/i, "npm-install"],
133
- [/npm test|jest|vitest|mocha|pytest|cargo test/i, "test"],
134
- [/npm run build|tsc|webpack|vite build|cargo build/i, "build"],
135
- [/pip install/i, "pip-install"],
136
- [/git (log|diff|status)/i, "git"],
137
- ];
138
-
139
- const LINE_THRESHOLD = 50;
140
- const HEAD_LINES = 15;
141
- const TAIL_LINES = 15;
142
-
143
- // ---- Helpers ----
144
-
145
- function extOf(filePath) {
146
- const dot = filePath.lastIndexOf(".");
147
- return dot !== -1 ? filePath.slice(dot).toLowerCase() : "";
148
- }
149
-
150
- function detectCommandType(cmd) {
151
- for (const [re, type] of CMD_PATTERNS) {
152
- if (re.test(cmd)) return type;
153
- }
154
- return "generic";
155
- }
156
-
157
- function extractBashText(response) {
158
- if (typeof response === "string") return response;
159
- if (response && typeof response === "object") {
160
- // Combine stdout + stderr in stable order
161
- const parts = [];
162
- if (response.stdout) parts.push(response.stdout);
163
- if (response.stderr) parts.push(response.stderr);
164
- return parts.join("\n") || "";
165
- }
166
- return ""; // unknown shape \u2192 fail open
167
- }
168
-
169
- /** Cache: null = not computed yet */
170
- let _hexLineDisabled = null;
171
-
172
- /**
173
- * Check if hex-line MCP is disabled for the current project.
174
- * Reads ~/.claude.json → projects.{cwd}.disabledMcpServers.
175
- * Fail-open: returns false on any error.
176
- */
177
- function isHexLineDisabled(configPath) {
178
- if (_hexLineDisabled !== null) return _hexLineDisabled;
179
- _hexLineDisabled = false;
180
- try {
181
- const p = configPath || resolve(homedir(), ".claude.json");
182
- const claudeJson = JSON.parse(readFileSync(p, "utf-8"));
183
- const projects = claudeJson.projects;
184
- if (!projects || typeof projects !== "object") return _hexLineDisabled;
185
- const cwd = process.cwd().replace(/\\/g, "/").replace(/\/$/, "").toLowerCase();
186
- for (const [path, config] of Object.entries(projects)) {
187
- if (path.replace(/\\/g, "/").replace(/\/$/, "").toLowerCase() === cwd) {
188
- const disabled = config.disabledMcpServers;
189
- if (Array.isArray(disabled) && disabled.includes("hex-line")) {
190
- _hexLineDisabled = true;
191
- }
192
- break;
193
- }
194
- }
195
- } catch { /* fail open */ }
196
- return _hexLineDisabled;
197
- }
198
-
199
- /** Reset cache (for testing). */
200
- function _resetHexLineDisabledCache() { _hexLineDisabled = null; }
201
-
202
- function block(reason, context) {
203
- const output = {
204
- hookSpecificOutput: {
205
- hookEventName: "PreToolUse",
206
- permissionDecision: "deny",
207
- permissionDecisionReason: reason,
208
- }
209
- };
210
- if (context) output.hookSpecificOutput.additionalContext = context;
211
- process.stdout.write(JSON.stringify(output));
212
- process.exit(2);
213
- }
214
-
215
- // ---- PreToolUse handler ----
216
-
217
- function handlePreToolUse(data) {
218
- const toolName = data.tool_name || "";
219
- const toolInput = data.tool_input || {};
220
-
221
- // Already using hex-line - approve silently
222
- if (toolName.startsWith("mcp__hex-line__")) {
223
- process.exit(0);
224
- }
225
-
226
- // Tool redirect: Read / Edit / Write / Grep
227
- const hintKey = TOOL_REDIRECT_MAP[toolName];
228
- if (hintKey) {
229
- const filePath = toolInput.file_path || toolInput.path || "";
230
-
231
- // Skip binary extensions
232
- if (BINARY_EXT.has(extOf(filePath))) {
233
- process.exit(0);
234
- }
235
-
236
- // Skip plan-mode and system paths (normalize backslashes for Windows)
237
- const normalPath = filePath.replace(/\\/g, "/");
238
- if (normalPath.includes(".claude/plans/") || normalPath.includes("AppData")) {
239
- process.exit(0);
240
- }
241
-
242
- // Skip Claude config files (settings.json, settings.local.json)
243
- const ALLOWED_CONFIGS = new Set(["settings.json", "settings.local.json"]);
244
- const fileName = normalPath.split("/").pop();
245
- if (ALLOWED_CONFIGS.has(fileName)) {
246
- let candidate = filePath;
247
- if (candidate.startsWith("~/")) {
248
- candidate = homedir().replace(/\\/g, "/") + candidate.slice(1);
249
- }
250
- const absPath = resolve(process.cwd(), candidate).replace(/\\/g, "/");
251
- const projectClaude = resolve(process.cwd(), ".claude").replace(/\\/g, "/") + "/";
252
- const globalClaude = resolve(homedir(), ".claude").replace(/\\/g, "/") + "/";
253
- const cmp = process.platform === "win32"
254
- ? (a, b) => a.toLowerCase().startsWith(b.toLowerCase())
255
- : (a, b) => a.startsWith(b);
256
- if (cmp(absPath, projectClaude) || cmp(absPath, globalClaude)) {
257
- process.exit(0);
258
- }
259
- }
260
-
261
- // Block with redirect — include extracted path for instant retry
262
- const hint = TOOL_HINTS[hintKey];
263
- const toolName2 = hint.split(" (")[0];
264
- const pathNote = filePath ? ` with path="${filePath}"` : "";
265
- block(`Use ${toolName2}${pathNote}`, hint);
266
- }
267
-
268
- // Bash tool checks
269
- if (toolName === "Bash") {
270
- const command = (toolInput.command || "").trim();
271
-
272
- // User-confirmed bypass
273
- if (command.includes("# hex-confirmed")) {
274
- process.exit(0);
275
- }
276
-
277
- // Strip heredoc bodies to avoid false positives on data content
278
- // e.g. gh api -f body="$(cat <<'EOF'\n...rm -rf...\nEOF)"
279
- const cmdCheck = command.replace(/<<['"]?(\w+)['"]?\s*\n[\s\S]*?\n\1/g, "");
280
-
281
- // Dangerous command blocker
282
- for (const { regex, reason } of DANGEROUS_PATTERNS) {
283
- if (regex.test(cmdCheck)) {
284
- block(
285
- `DANGEROUS: ${reason}. Ask user to confirm, then retry with: # hex-confirmed`,
286
- `Original command: ${command.slice(0, 100)}`
287
- );
288
- }
289
- }
290
-
291
- // Compound commands: check first command in pipe before skipping
292
- if (COMPOUND_OPERATORS.test(command)) {
293
- const firstCmd = command.split(/\s*[|;&>]\s*/)[0].trim();
294
- for (const { regex, key } of BASH_REDIRECTS) {
295
- if (regex.test(firstCmd)) {
296
- const hint = TOOL_HINTS[key];
297
- const toolName2 = hint.split(" (")[0];
298
- block(`Use ${toolName2} instead of piped command`, hint);
299
- }
300
- }
301
- process.exit(0);
302
- }
303
-
304
- // Simple command redirect — extract args for instant retry
305
- for (const { regex, key } of BASH_REDIRECTS) {
306
- if (regex.test(command)) {
307
- const hint = TOOL_HINTS[key];
308
- const toolName2 = hint.split(" (")[0];
309
- const args = command.split(/\s+/).slice(1).join(" ");
310
- const argsNote = args ? ` — args: "${args}"` : "";
311
- block(`Use ${toolName2}${argsNote}`, hint);
312
- }
313
- }
314
- }
315
-
316
- // Everything else - approve
317
- process.exit(0);
318
- }
319
-
320
- // ---- PreToolUse REVERSE handler (hex-line disabled) ----
321
-
322
- function handlePreToolUseReverse(data) {
323
- const toolName = data.tool_name || "";
324
-
325
- // Agent tries hex-line tool that's disabled → redirect to built-in
326
- if (toolName.startsWith("mcp__hex-line__")) {
327
- const builtIn = REVERSE_TOOL_HINTS[toolName];
328
- if (builtIn) {
329
- const target = builtIn.split(" ")[0];
330
- block(
331
- `hex-line is disabled in this project. Use ${target}`,
332
- `hex-line disabled. Use built-in: ${builtIn}`
333
- );
334
- }
335
- block("hex-line is disabled in this project", "Disabled via project settings");
336
- }
337
-
338
- // All built-in tools — approve silently
339
- process.exit(0);
340
- }
341
-
342
- // ---- PostToolUse handler ----
343
-
344
- function handlePostToolUse(data) {
345
- const toolName = data.tool_name || "";
346
-
347
- // Only filter Bash output
348
- if (toolName !== "Bash") {
349
- process.exit(0);
350
- }
351
-
352
- const toolInput = data.tool_input || {};
353
- const rawText = extractBashText(data.tool_response);
354
- const command = toolInput.command || "";
355
-
356
- // Nothing to filter
357
- if (!rawText) {
358
- process.exit(0);
359
- }
360
-
361
- const lines = rawText.split("\n");
362
- const originalCount = lines.length;
363
-
364
- // Short output - no filtering
365
- if (originalCount < LINE_THRESHOLD) {
366
- process.exit(0);
367
- }
368
-
369
- const type = detectCommandType(command);
370
-
371
- // Pipeline: normalize -> deduplicate -> smart truncate
372
- const filtered = normalizeOutput(lines.join("\n"), { headLines: HEAD_LINES, tailLines: TAIL_LINES });
373
- const filteredCount = filtered.split("\n").length;
374
-
375
- const header = `RTK FILTERED: ${type} (${originalCount} lines -> ${filteredCount} lines)`;
376
-
377
- const output = [
378
- "=".repeat(50),
379
- header,
380
- "=".repeat(50),
381
- "",
382
- filtered,
383
- "",
384
- "-".repeat(50),
385
- `Original: ${originalCount} lines | Filtered: ${filteredCount} lines`,
386
- "=".repeat(50),
387
- ].join("\n");
388
-
389
- process.stderr.write(output);
390
- process.exit(2);
391
- }
392
-
393
- // ---- SessionStart: inject tool preferences ----
394
-
395
- function handleSessionStart() {
396
- // Check if hex-line output style is active (skip full hints if so)
397
- const settingsFiles = [
398
- resolve(process.cwd(), ".claude/settings.local.json"),
399
- resolve(process.cwd(), ".claude/settings.json"),
400
- resolve(homedir(), ".claude/settings.json"),
401
- ];
402
- let styleActive = false;
403
- for (const f of settingsFiles) {
404
- try {
405
- const config = JSON.parse(readFileSync(f, "utf-8"));
406
- if (config.outputStyle === "hex-line") { styleActive = true; break; }
407
- if (config.outputStyle) break; // another style overrides
408
- } catch { /* file missing or invalid */ }
409
- }
410
-
411
- if (styleActive) {
412
- process.stdout.write(JSON.stringify({ systemMessage: "hex-line Output Style active." }));
413
- process.exit(0);
414
- }
415
-
416
- // Full hints when output style is not active
417
- const seen = new Set();
418
- const lines = [];
419
- for (const hint of Object.values(TOOL_HINTS)) {
420
- const tool = hint.split(" ")[0];
421
- if (!seen.has(tool)) {
422
- seen.add(tool);
423
- lines.push(`- ${hint}`);
424
- }
425
- }
426
- lines.push("Exceptions: images, PDFs, notebooks, .claude/settings.json, .claude/settings.local.json \u2192 built-in Read; Glob always OK");
427
- lines.push("Bash OK for: npm/node/git/docker/curl, pipes, scripts");
428
- const msg = "Hex-line MCP available. Workflow:\n- Discovery: read_file, grep_search, outline, directory_tree\n- Hash edits: edit_file (set_line, replace_lines, insert_after)\n- Text rename: bulk_replace (multi-file search-replace)\n- Write new: write_file\n" + lines.join("\n");
429
- process.stdout.write(JSON.stringify({ systemMessage: msg }));
430
- process.exit(0);
431
- }
432
-
433
- // ---- Main: read stdin, route by hook_event_name ----
434
- // Guard: only run when executed directly, not when imported for testing
435
-
436
- import { fileURLToPath } from "node:url";
437
-
438
- if (process.argv[1] === fileURLToPath(import.meta.url)) {
439
- let input = "";
440
- process.stdin.on("data", (chunk) => {
441
- input += chunk;
442
- });
443
- process.stdin.on("end", () => {
444
- try {
445
- const data = JSON.parse(input);
446
- const event = data.hook_event_name || "";
447
-
448
- if (isHexLineDisabled()) {
449
- // REVERSE MODE: block hex-line calls, approve everything else
450
- if (event === "PreToolUse") handlePreToolUseReverse(data);
451
- process.exit(0); // SessionStart, PostToolUse — silent exit
452
- }
453
-
454
- // NORMAL MODE
455
- if (event === "SessionStart") handleSessionStart();
456
- else if (event === "PreToolUse") handlePreToolUse(data);
457
- else if (event === "PostToolUse") handlePostToolUse(data);
458
- else process.exit(0);
459
- } catch {
460
- process.exit(0);
461
- }
462
- });
463
- }
464
-
465
- // ---- Exports for testing ----
466
- export { isHexLineDisabled, _resetHexLineDisabledCache };