@levnikolaevich/hex-line-mcp 1.2.0 → 1.3.1

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/lib/search.mjs CHANGED
@@ -1,47 +1,35 @@
1
1
  /**
2
2
  * File search via ripgrep with hash-annotated results.
3
3
  * Uses spawn with arg arrays (no shell string interpolation).
4
+ *
5
+ * Output modes:
6
+ * content (default) — hash-annotated lines with per-group checksums (uses rg --json)
7
+ * files — file paths only (rg -l)
8
+ * count — match counts per file (rg -c)
4
9
  */
5
10
 
6
11
  import { spawn } from "node:child_process";
7
12
  import { resolve } from "node:path";
8
- import { fnv1a, lineTag } from "./hash.mjs";
13
+ import { fnv1a, lineTag, rangeChecksum } from "./hash.mjs";
9
14
  import { getGraphDB, matchAnnotation, getRelativePath } from "./graph-enrich.mjs";
15
+ import { normalizePath } from "./security.mjs";
10
16
 
11
17
  const DEFAULT_LIMIT = 100;
12
18
  const MAX_OUTPUT = 10 * 1024 * 1024; // 10 MB
13
19
  const TIMEOUT = 30000; // 30s
14
20
 
21
+
22
+
15
23
  /**
16
- * Search files using ripgrep.
17
- *
18
- * @param {string} pattern - regex pattern
19
- * @param {object} opts - { path, glob, type, caseInsensitive, context, limit, plain }
20
- * @returns {Promise<string>} formatted results
24
+ * Spawn ripgrep and collect stdout.
25
+ * Returns { stdout, code, stderr, killed }.
21
26
  */
22
- export function grepSearch(pattern, opts = {}) {
27
+ function spawnRg(args) {
23
28
  return new Promise((resolve_, reject) => {
24
- // Convert Git Bash /c/path → c:/path on Windows
25
- const rawPath = opts.path || "";
26
- const normPath = (process.platform === "win32" && /^\/[a-zA-Z]\//.test(rawPath))
27
- ? rawPath[1] + ":" + rawPath.slice(2) : rawPath;
28
- const target = normPath ? resolve(normPath) : process.cwd();
29
- const args = ["-n", "--no-heading", "--with-filename"];
30
- const plain = !!opts.plain;
31
-
32
- if (opts.caseInsensitive) args.push("-i");
33
- else if (opts.smartCase) args.push("-S");
34
- if (opts.context && opts.context > 0) args.push("-C", String(opts.context));
35
- if (opts.glob) args.push("--glob", opts.glob);
36
- if (opts.type) args.push("--type", opts.type);
37
-
38
- const limit = (opts.limit && opts.limit > 0) ? opts.limit : DEFAULT_LIMIT;
39
- args.push("-m", String(limit));
40
- args.push("--", pattern, target);
41
-
42
29
  let stdout = "";
43
30
  let totalBytes = 0;
44
31
  let killed = false;
32
+ let stderrBuf = "";
45
33
 
46
34
  const child = spawn("rg", args, { timeout: TIMEOUT });
47
35
 
@@ -55,7 +43,6 @@ export function grepSearch(pattern, opts = {}) {
55
43
  stdout += chunk.toString("utf-8");
56
44
  });
57
45
 
58
- let stderrBuf = "";
59
46
  child.stderr.on("data", (chunk) => { stderrBuf += chunk.toString("utf-8"); });
60
47
 
61
48
  child.on("error", (err) => {
@@ -67,67 +54,216 @@ export function grepSearch(pattern, opts = {}) {
67
54
  });
68
55
 
69
56
  child.on("close", (code) => {
70
- if (killed) {
71
- resolve_("GREP_OUTPUT_TRUNCATED: exceeded 10MB. Use specific glob/path.");
72
- return;
73
- }
74
- if (code === 1) {
75
- resolve_("No matches found.");
76
- return;
57
+ resolve_({ stdout, code, stderr: stderrBuf, killed });
58
+ });
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Search files using ripgrep.
64
+ *
65
+ * @param {string} pattern - regex or literal pattern
66
+ * @param {object} opts
67
+ * @returns {Promise<string>} formatted results
68
+ */
69
+ export function grepSearch(pattern, opts = {}) {
70
+ const normPath = normalizePath(opts.path || "");
71
+ const target = normPath ? resolve(normPath) : process.cwd();
72
+ const output = opts.output || "content";
73
+ const plain = !!opts.plain;
74
+ const totalLimit = (opts.totalLimit && opts.totalLimit > 0) ? opts.totalLimit : 0;
75
+
76
+ // Branch by output mode
77
+ if (output === "files") return filesMode(pattern, target, opts);
78
+ if (output === "count") return countMode(pattern, target, opts);
79
+ return contentMode(pattern, target, opts, plain, totalLimit);
80
+ }
81
+
82
+ /**
83
+ * files mode: rg -l — just file paths.
84
+ */
85
+ async function filesMode(pattern, target, opts) {
86
+ // -l + shared flags (without -n/heading/-m since -l ignores them)
87
+ const realArgs = ["-l"];
88
+ if (opts.caseInsensitive) realArgs.push("-i");
89
+ else if (opts.smartCase) realArgs.push("-S");
90
+ if (opts.literal) realArgs.push("-F");
91
+ if (opts.multiline) realArgs.push("-U", "--multiline-dotall");
92
+ if (opts.glob) realArgs.push("--glob", opts.glob);
93
+ if (opts.type) realArgs.push("--type", opts.type);
94
+ realArgs.push("--", pattern, target);
95
+
96
+ const { stdout, code, stderr, killed } = await spawnRg(realArgs);
97
+ if (killed) return "GREP_OUTPUT_TRUNCATED: exceeded 10MB. Use specific glob/path.";
98
+ if (code === 1) return "No matches found.";
99
+ if (code !== 0 && code !== null) throw new Error(`GREP_ERROR: rg exit ${code} — ${stderr.trim() || "unknown error"}`);
100
+
101
+ const lines = stdout.trimEnd().split("\n").filter(Boolean);
102
+ const normalized = lines.map(l => l.replace(/\\/g, "/"));
103
+ return `\`\`\`\n${normalized.join("\n")}\n\`\`\``;
104
+ }
105
+
106
+ /**
107
+ * count mode: rg -c — match counts per file.
108
+ */
109
+ async function countMode(pattern, target, opts) {
110
+ const realArgs = ["-c"];
111
+ if (opts.caseInsensitive) realArgs.push("-i");
112
+ else if (opts.smartCase) realArgs.push("-S");
113
+ if (opts.literal) realArgs.push("-F");
114
+ if (opts.multiline) realArgs.push("-U", "--multiline-dotall");
115
+ if (opts.glob) realArgs.push("--glob", opts.glob);
116
+ if (opts.type) realArgs.push("--type", opts.type);
117
+ realArgs.push("--", pattern, target);
118
+
119
+ const { stdout, code, stderr, killed } = await spawnRg(realArgs);
120
+ if (killed) return "GREP_OUTPUT_TRUNCATED: exceeded 10MB. Use specific glob/path.";
121
+ if (code === 1) return "No matches found.";
122
+ if (code !== 0 && code !== null) throw new Error(`GREP_ERROR: rg exit ${code} — ${stderr.trim() || "unknown error"}`);
123
+
124
+ const lines = stdout.trimEnd().split("\n").filter(Boolean);
125
+ const normalized = lines.map(l => l.replace(/\\/g, "/"));
126
+ return `\`\`\`\n${normalized.join("\n")}\n\`\`\``;
127
+ }
128
+
129
+ /**
130
+ * content mode: rg --json — hash-annotated lines with per-group checksums.
131
+ */
132
+ async function contentMode(pattern, target, opts, plain, totalLimit) {
133
+ const realArgs = ["--json"];
134
+ if (opts.caseInsensitive) realArgs.push("-i");
135
+ else if (opts.smartCase) realArgs.push("-S");
136
+ if (opts.literal) realArgs.push("-F");
137
+ if (opts.multiline) realArgs.push("-U", "--multiline-dotall");
138
+ if (opts.glob) realArgs.push("--glob", opts.glob);
139
+ if (opts.type) realArgs.push("--type", opts.type);
140
+ if (opts.context && opts.context > 0) realArgs.push("-C", String(opts.context));
141
+ if (opts.contextBefore && opts.contextBefore > 0) realArgs.push("-B", String(opts.contextBefore));
142
+ if (opts.contextAfter && opts.contextAfter > 0) realArgs.push("-A", String(opts.contextAfter));
143
+
144
+ const limit = (opts.limit && opts.limit > 0) ? opts.limit : DEFAULT_LIMIT;
145
+ realArgs.push("-m", String(limit));
146
+ realArgs.push("--", pattern, target);
147
+
148
+ const { stdout, code, stderr, killed } = await spawnRg(realArgs);
149
+ if (killed) return "GREP_OUTPUT_TRUNCATED: exceeded 10MB. Use specific glob/path.";
150
+ if (code === 1) return "No matches found.";
151
+ if (code !== 0 && code !== null) throw new Error(`GREP_ERROR: rg exit ${code} — ${stderr.trim() || "unknown error"}`);
152
+
153
+ // Parse NDJSON output
154
+ const jsonLines = stdout.trimEnd().split("\n").filter(Boolean);
155
+ const formatted = [];
156
+ const db = getGraphDB(target);
157
+ const relCache = new Map();
158
+
159
+ // Track current group for checksums
160
+ let groupFile = null;
161
+ let groupLines = []; // { lineNum, hash32 }
162
+ let matchCount = 0;
163
+
164
+ function flushGroup() {
165
+ if (groupLines.length === 0) return;
166
+ const sorted = [...groupLines].sort((a, b) => a.lineNum - b.lineNum);
167
+ const start = sorted[0].lineNum;
168
+ const end = sorted[sorted.length - 1].lineNum;
169
+ const hashes = sorted.map(l => l.hash32);
170
+ const cs = rangeChecksum(hashes, start, end);
171
+ formatted.push(`checksum: ${cs}`);
172
+ groupLines = [];
173
+ }
174
+
175
+ for (const jl of jsonLines) {
176
+ let msg;
177
+ try { msg = JSON.parse(jl); } catch { continue; }
178
+
179
+ if (msg.type === "begin" || msg.type === "end" || msg.type === "summary") {
180
+ if (msg.type === "end") {
181
+ flushGroup();
182
+ groupFile = null;
77
183
  }
78
- if (code !== 0 && code !== null) {
79
- const reason = stderrBuf.trim() || "unknown error";
80
- reject(new Error(`GREP_ERROR: rg exit ${code} ${reason}`));
81
- return;
184
+ if (msg.type === "begin") {
185
+ // Separator between file groups
186
+ if (formatted.length > 0 && formatted[formatted.length - 1] !== "") {
187
+ formatted.push("");
188
+ }
82
189
  }
190
+ continue;
191
+ }
83
192
 
84
- // Format results with hash tags
85
- const resultLines = stdout.trimEnd().split("\n");
86
- const formatted = [];
87
- const db = getGraphDB(target);
88
- const relCache = new Map();
193
+ if (msg.type !== "match" && msg.type !== "context") continue;
89
194
 
90
- // Match line: file:42:content
91
- const matchRe = /^((?:[A-Za-z]:)?[^:]*):(\d+):(.*)$/;
92
- // Context line: file-42-content
93
- const ctxRe = /^((?:[A-Za-z]:)?[^-]*)-(\d+)-(.*)$/;
195
+ const data = msg.data;
196
+ const filePath = (data.path?.text || "").replace(/\\/g, "/");
197
+ const lineNum = data.line_number;
198
+ if (!lineNum) continue;
94
199
 
200
+ // Get line content (handle text vs bytes)
201
+ let content = data.lines?.text;
202
+ if (content === undefined && data.lines?.bytes) {
203
+ content = Buffer.from(data.lines.bytes, "base64").toString("utf-8");
204
+ }
205
+ if (content === undefined) continue;
206
+
207
+ // Trim trailing newline from rg JSON output
208
+ content = content.replace(/\n$/, "");
209
+
210
+ // Handle multiline: split into individual lines
211
+ const subLines = content.split("\n");
212
+
213
+ // Track group boundaries
214
+ if (filePath !== groupFile) {
215
+ flushGroup();
216
+ groupFile = filePath;
217
+ }
218
+
219
+ for (let i = 0; i < subLines.length; i++) {
220
+ const ln = lineNum + i;
221
+ const lineContent = subLines[i];
222
+ const hash32 = fnv1a(lineContent);
223
+ const tag = lineTag(hash32);
224
+
225
+ // Flush on line gap (disjoint match clusters get separate checksums)
226
+ if (groupLines.length > 0) {
227
+ const lastLn = groupLines[groupLines.length - 1].lineNum;
228
+ if (ln > lastLn + 1) flushGroup();
229
+ }
230
+ groupLines.push({ lineNum: ln, hash32 });
231
+
232
+ const isMatch = msg.type === "match";
95
233
  if (plain) {
96
- // Plain mode: file:line:content without hash tags
97
- for (const rl of resultLines) {
98
- formatted.push(rl);
99
- }
234
+ formatted.push(`${filePath}:${ln}:${lineContent}`);
100
235
  } else {
101
- for (const rl of resultLines) {
102
- if (!rl || rl === "--") { formatted.push(rl); continue; }
103
- // Normalize backslashes for consistent regex matching on Windows
104
- const nl = rl.replace(/\\/g, "/");
105
-
106
- const m = matchRe.exec(nl);
107
- if (m) {
108
- const tag = lineTag(fnv1a(m[3]));
109
- let anno = "";
110
- if (db) {
111
- let rel = relCache.get(m[1]);
112
- if (rel === undefined) { rel = getRelativePath(resolve(m[1])) || ""; relCache.set(m[1], rel); }
113
- if (rel) { const a = matchAnnotation(db, rel, +m[2]); if (a) anno = ` ${a}`; }
114
- }
115
- formatted.push(`${m[1]}:>>${tag}.${m[2]}\t${m[3]}${anno}`);
116
- continue;
236
+ let anno = "";
237
+ if (db && isMatch) {
238
+ let rel = relCache.get(filePath);
239
+ if (rel === undefined) {
240
+ rel = getRelativePath(resolve(filePath)) || "";
241
+ relCache.set(filePath, rel);
117
242
  }
118
-
119
- const c = ctxRe.exec(nl);
120
- if (c) {
121
- const tag = lineTag(fnv1a(c[3]));
122
- formatted.push(`${c[1]}: ${tag}.${c[2]}\t${c[3]}`);
123
- continue;
243
+ if (rel) {
244
+ const a = matchAnnotation(db, rel, ln);
245
+ if (a) anno = ` ${a}`;
124
246
  }
125
-
126
- formatted.push(rl);
127
247
  }
248
+ const prefix = isMatch ? ">>" : " ";
249
+ formatted.push(`${filePath}:${prefix}${tag}.${ln}\t${lineContent}${anno}`);
128
250
  }
129
251
 
130
- resolve_(`\`\`\`\n${formatted.join("\n")}\n\`\`\``);
131
- });
132
- });
252
+ }
253
+
254
+ // Count matches per rg event, not per subLine
255
+ if (msg.type === "match") {
256
+ matchCount++;
257
+ if (totalLimit > 0 && matchCount >= totalLimit) {
258
+ flushGroup();
259
+ formatted.push(`--- total_limit reached (${totalLimit}) ---`);
260
+ return `\`\`\`\n${formatted.join("\n")}\n\`\`\``;
261
+ }
262
+ }
263
+ }
264
+
265
+ // Flush last group
266
+ flushGroup();
267
+
268
+ return `\`\`\`\n${formatted.join("\n")}\n\`\`\``;
133
269
  }
package/lib/security.mjs CHANGED
@@ -6,8 +6,9 @@
6
6
  * binary file detection, and size limits.
7
7
  */
8
8
 
9
- import { realpathSync, statSync, existsSync, readdirSync, openSync, readSync, closeSync } from "node:fs";
9
+ import { realpathSync, statSync, existsSync, openSync, readSync, closeSync } from "node:fs";
10
10
  import { resolve, isAbsolute, dirname } from "node:path";
11
+ import { listDirectory } from "./format.mjs";
11
12
 
12
13
  const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
13
14
 
@@ -15,7 +16,7 @@ const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
15
16
  * Convert Git Bash /c/Users/... → c:/Users/... on Windows.
16
17
  * Node.js resolve() treats /c/ as absolute from current drive root, producing D:\c\Users.
17
18
  */
18
- function normalizePath(p) {
19
+ export function normalizePath(p) {
19
20
  if (process.platform === "win32" && /^\/[a-zA-Z]\//.test(p)) {
20
21
  return p[1] + ":" + p.slice(2);
21
22
  }
@@ -39,12 +40,9 @@ export function validatePath(filePath) {
39
40
  try {
40
41
  const parent = dirname(abs);
41
42
  if (existsSync(parent)) {
42
- const entries = readdirSync(parent, { withFileTypes: true });
43
- const listing = entries.slice(0, 20).map(e =>
44
- ` ${e.isDirectory() ? "d" : "f"} ${e.name}`
45
- ).join("\n");
46
- hint = `\n\nParent directory ${parent} contains:\n${listing}`;
47
- if (entries.length > 20) hint += `\n ... (${entries.length - 20} more)`;
43
+ const { text, total } = listDirectory(parent, { limit: 20, metadata: true });
44
+ hint = `\n\nParent directory ${parent} contains:\n${text}`;
45
+ if (total > 20) hint += `\n ... (${total - 20} more)`;
48
46
  }
49
47
  } catch {}
50
48
  throw new Error(`FILE_NOT_FOUND: ${abs}${hint}`);
package/lib/setup.mjs CHANGED
@@ -18,9 +18,6 @@ const __dirname = dirname(__filename);
18
18
  const HOOK_SCRIPT = resolve(__dirname, "..", "hook.mjs").replace(/\\/g, "/");
19
19
  const HOOK_COMMAND = `node ${HOOK_SCRIPT}`;
20
20
 
21
- // Legacy relative command — needed to find and remove old per-project hooks.
22
- const OLD_HOOK_COMMAND = "node mcp/hex-line-mcp/hook.mjs";
23
-
24
21
  // Substring that identifies any hex-line hook command (old relative or new absolute).
25
22
  const HOOK_SIGNATURE = "hex-line-mcp/hook.mjs";
26
23
 
@@ -36,7 +33,7 @@ const CLAUDE_HOOKS = {
36
33
  hooks: [{ type: "command", command: HOOK_COMMAND, timeout: 5 }],
37
34
  },
38
35
  PreToolUse: {
39
- matcher: "Read|Edit|Write|Grep|Bash",
36
+ matcher: "Read|Edit|Write|Grep|Bash|mcp__hex-line__.*",
40
37
  hooks: [{ type: "command", command: HOOK_COMMAND, timeout: 5 }],
41
38
  },
42
39
  PostToolUse: {
@@ -230,6 +227,34 @@ function setupCodex() {
230
227
  return "Codex: Not supported (Codex CLI does not support hooks. Add MCP Tool Preferences to AGENTS.md instead)";
231
228
  }
232
229
 
230
+ // ---- Uninstall: remove hex-line hooks ----
231
+
232
+ function uninstallClaude() {
233
+ const globalPath = resolve(homedir(), ".claude/settings.json");
234
+ const config = readJson(globalPath);
235
+ if (!config || !config.hooks || typeof config.hooks !== "object") {
236
+ return "Claude: no hooks to remove";
237
+ }
238
+
239
+ let changed = false;
240
+ for (const event of Object.keys(CLAUDE_HOOKS)) {
241
+ if (!Array.isArray(config.hooks[event])) continue;
242
+ const idx = findEntryByCommand(config.hooks[event]);
243
+ if (idx >= 0) {
244
+ config.hooks[event].splice(idx, 1);
245
+ if (config.hooks[event].length === 0) delete config.hooks[event];
246
+ changed = true;
247
+ }
248
+ }
249
+
250
+ if (Object.keys(config.hooks).length === 0) delete config.hooks;
251
+
252
+ if (!changed) return "Claude: no hex-line hooks found";
253
+
254
+ writeJson(globalPath, config);
255
+ return "Claude: hex-line hooks removed from global settings";
256
+ }
257
+
233
258
  // ---- Public API ----
234
259
 
235
260
  const AGENTS = { claude: setupClaude, gemini: setupGemini, codex: setupCodex };
@@ -238,10 +263,17 @@ const AGENTS = { claude: setupClaude, gemini: setupGemini, codex: setupCodex };
238
263
  * Configure hex-line hooks for one or all supported agents.
239
264
  * Claude: writes to ~/.claude/settings.json (global), cleans per-project hooks.
240
265
  * @param {string} [agent="all"] - "claude", "gemini", "codex", or "all"
266
+ * @param {string} [action="install"] - "install" or "uninstall"
241
267
  * @returns {string} Status report
242
268
  */
243
- export function setupHooks(agent = "all") {
269
+ export function setupHooks(agent = "all", action = "install") {
244
270
  const target = (agent || "all").toLowerCase();
271
+ const act = (action || "install").toLowerCase();
272
+
273
+ if (act === "uninstall") {
274
+ const result = uninstallClaude();
275
+ return `Hooks uninstalled:\n ${result}\n\nRestart Claude Code to apply changes.`;
276
+ }
245
277
 
246
278
  if (target !== "all" && !AGENTS[target]) {
247
279
  throw new Error(`UNKNOWN_AGENT: '${agent}'. Supported: claude, gemini, codex, all`);