@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/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
  [![npm](https://img.shields.io/npm/v/@levnikolaevich/hex-line-mcp)](https://www.npmjs.com/package/@levnikolaevich/hex-line-mcp)
6
- ![License](https://img.shields.io/badge/license-MIT-green)
6
+ [![downloads](https://img.shields.io/npm/dm/@levnikolaevich/hex-line-mcp)](https://www.npmjs.com/package/@levnikolaevich/hex-line-mcp)
7
+ [![license](https://img.shields.io/npm/l/@levnikolaevich/hex-line-mcp)](./LICENSE)
8
+ ![node](https://img.shields.io/node/v/@levnikolaevich/hex-line-mcp)
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
 
@@ -16,10 +18,10 @@ Every line carries an FNV-1a content hash. Every edit must present those hashes
16
18
  | `read_file` | Read file with hash-annotated lines and range checksums | Partial reads via `offset`/`limit` |
17
19
  | `edit_file` | Hash-verified edits with anchor or text replacement | Returns compact diff via `diff` package |
18
20
  | `write_file` | Create new file or overwrite, auto-creates parent dirs | Path validation, no hash overhead |
19
- | `grep_search` | Search with ripgrep, returns hash-annotated matches | Edit-ready results -- search then edit directly |
21
+ | `grep_search` | Search with ripgrep, 3 output modes, per-group checksums | Edit-ready: grep -> edit directly with checksums |
20
22
  | `outline` | AST-based structural overview via tree-sitter WASM | 95% token reduction (10 lines instead of 500) |
21
23
  | `verify` | Check if held range checksums are still valid | Single-line response avoids full re-read |
22
- | `directory_tree` | Compact directory tree with .gitignore support | Skips node_modules/.git, shows file sizes |
24
+ | `directory_tree` | Compact directory tree with root .gitignore support | Skips node_modules/.git, shows file sizes |
23
25
  | `get_file_info` | File metadata without reading content | Size, lines, mtime, type, binary detection |
24
26
  | `setup_hooks` | Configure PreToolUse + PostToolUse hooks for Claude/Gemini/Codex | One call sets up everything, idempotent |
25
27
  | `changes` | Compare file against git ref, shows added/removed/modified symbols | AST-level semantic diff |
@@ -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 |
@@ -121,7 +124,7 @@ checksum: 1-50:f7e2a1b0
121
124
 
122
125
  ### edit_file
123
126
 
124
- Edit using hash-verified anchors or text replacement. Returns a unified diff.
127
+ Edit using hash-verified anchors or text replacement. Returns diff + post-edit checksums for chaining edits.
125
128
 
126
129
  | Parameter | Type | Required | Description |
127
130
  |-----------|------|----------|-------------|
@@ -137,7 +140,8 @@ Edit operations (JSON array):
137
140
  {"set_line": {"anchor": "ab.12", "new_text": "replacement line"}},
138
141
  {"replace_lines": {"start_anchor": "ab.10", "end_anchor": "cd.15", "new_text": "..."}},
139
142
  {"insert_after": {"anchor": "ab.20", "text": "inserted line"}},
140
- {"replace": {"old_text": "find this", "new_text": "replace with", "all": false}}
143
+ {"replace": {"old_text": "unique text", "new_text": "replacement"}},
144
+ {"replace": {"old_text": "find all", "new_text": "replace all", "all": true}}
141
145
  ]
142
146
  ```
143
147
 
@@ -152,17 +156,27 @@ Create a new file or overwrite an existing one. Creates parent directories autom
152
156
 
153
157
  ### grep_search
154
158
 
155
- Search file contents using ripgrep with hash-annotated results.
159
+ Search file contents using ripgrep. Three output modes: `content` (hash-annotated with checksums), `files` (paths only), `count` (match counts).
156
160
 
157
161
  | Parameter | Type | Required | Description |
158
162
  |-----------|------|----------|-------------|
159
- | `pattern` | string | yes | Regex search pattern |
163
+ | `pattern` | string | yes | Search pattern (regex by default, literal if `literal:true`) |
160
164
  | `path` | string | no | Directory or file to search (default: cwd) |
161
165
  | `glob` | string | no | Glob filter, e.g. `"*.ts"` |
162
166
  | `type` | string | no | File type filter, e.g. `"js"`, `"py"` |
167
+ | `output` | enum | no | Output format: `"content"` (default), `"files"`, `"count"` |
163
168
  | `case_insensitive` | boolean | no | Ignore case |
164
- | `context` | number | no | Context lines around matches |
169
+ | `smart_case` | boolean | no | CI when lowercase, CS when uppercase (`-S`) |
170
+ | `literal` | boolean | no | Literal string search, no regex (`-F`) |
171
+ | `multiline` | boolean | no | Pattern can span multiple lines (`-U`) |
172
+ | `context` | number | no | Symmetric context lines around matches (`-C`) |
173
+ | `context_before` | number | no | Context lines BEFORE match (`-B`) |
174
+ | `context_after` | number | no | Context lines AFTER match (`-A`) |
165
175
  | `limit` | number | no | Max matches per file (default: 100) |
176
+ | `total_limit` | number | no | Total match events across all files; multiline matches count as 1 (0 = unlimited) |
177
+ | `plain` | boolean | no | Omit hash tags, return `file:line:content` |
178
+
179
+ **Content mode** returns per-group checksums enabling direct `replace_lines` from grep results without intermediate `read_file`.
166
180
 
167
181
  ### outline
168
182
 
@@ -189,13 +203,16 @@ Returns a single-line confirmation or lists changed ranges.
189
203
 
190
204
  ### directory_tree
191
205
 
192
- Compact directory tree with .gitignore support and file sizes.
206
+ Compact directory tree with root .gitignore support (path-based rules, negation, dir-only). Nested .gitignore files are not loaded.
193
207
 
194
208
  | Parameter | Type | Required | Description |
195
209
  |-----------|------|----------|-------------|
196
210
  | `path` | string | yes | Directory path |
197
- | `max_depth` | number | no | Max recursion depth (default: 3) |
198
- | `gitignore` | boolean | no | Respect .gitignore patterns (default: true) |
211
+ | `pattern` | string | no | Glob filter on names (e.g. `"*-mcp"`, `"*.mjs"`). Returns flat match list instead of tree |
212
+ | `type` | string | no | `"file"`, `"dir"`, or `"all"` (default). Like `find -type f/d` |
213
+ | `max_depth` | number | no | Max recursion depth (default: 3, or 20 in pattern mode) |
214
+ | `gitignore` | boolean | no | Respect root .gitignore patterns (default: true). Nested .gitignore not supported |
215
+ | `format` | string | no | `"compact"` = names only, no sizes, depth 1. `"full"` = default with sizes |
199
216
 
200
217
  Skips `node_modules`, `.git`, `dist`, `build`, `__pycache__`, `.next`, `coverage` by default.
201
218
 
@@ -211,39 +228,43 @@ Returns: size, line count, modification time (absolute + relative), file type, b
211
228
 
212
229
  ## Hook
213
230
 
214
- The unified PostToolUse hook (`hook.mjs`) handles two concerns:
231
+ The unified hook (`hook.mjs`) handles four events:
232
+
233
+ ### PreToolUse: Tool Redirect
215
234
 
216
- ### Hex-line Reminder
235
+ Blocks built-in `Read`, `Edit`, `Write`, `Grep` on text files and redirects to hex-line equivalents. Binary files (images, PDFs, notebooks, archives, executables, fonts, media) are excluded.
217
236
 
218
- Triggers on built-in `Read`, `Edit`, `Write`, `Grep` tool usage for text files. Outputs a short reminder to stderr (exit code 2) nudging the agent to use the corresponding hex-line tool instead.
237
+ ### PreToolUse: Bash Redirect + Dangerous Blocker
219
238
 
220
- Binary files (images, PDFs, notebooks, archives, executables, fonts, media) are excluded -- those should use built-in tools.
239
+ Intercepts simple Bash commands (`cat`, `head`, `tail`, `ls`, `find`, `grep`, `sed -i`, etc.) and redirects to hex-line tools. Blocks dangerous commands (`rm -rf /`, `git push --force`, `git reset --hard`, `DROP TABLE`, `chmod 777`, `mkfs`, `dd`).
221
240
 
222
- ### RTK Output Filter
241
+ ### PostToolUse: RTK Output Filter
223
242
 
224
243
  Triggers on `Bash` tool output exceeding 50 lines. Pipeline:
225
244
 
226
245
  1. **Detect command type** -- npm install, test, build, pip install, git verbose, or generic
227
- 2. **Type-specific summary** -- extracts key metrics (e.g., `npm install: 42 added, 3 warnings`)
228
- 3. **Normalize** -- replaces UUIDs, timestamps, IPs, hex values, large numbers with placeholders
229
- 4. **Deduplicate** -- collapses identical normalized lines with `(xN)` counts
230
- 5. **Truncate** -- keeps first 12 + last 12 lines, omits the middle
246
+ 2. **Normalize** -- replaces UUIDs, timestamps, IPs, hex values, large numbers with placeholders
247
+ 3. **Deduplicate** -- collapses identical normalized lines with `(xN)` counts
248
+ 4. **Truncate** -- keeps first 15 + last 15 lines, omits the middle
231
249
 
232
250
  Configuration constants in `hook.mjs`:
233
251
 
234
252
  | Constant | Default | Purpose |
235
- |----------|---------|---------|
253
+ |----------|---------|--------|
236
254
  | `LINE_THRESHOLD` | 50 | Minimum lines to trigger filtering |
237
- | `TRUNCATE_LIMIT` | 30 | Lines below this are kept as-is after dedup |
238
- | `HEAD_LINES` | 12 | Lines to keep from start |
239
- | `TAIL_LINES` | 12 | Lines to keep from end |
255
+ | `HEAD_LINES` | 15 | Lines to keep from start |
256
+ | `TAIL_LINES` | 15 | Lines to keep from end |
257
+
258
+ ### SessionStart: Tool Preferences
259
+
260
+ Injects hex-line tool preference list into agent context at session start.
240
261
 
241
262
  ## Architecture
242
263
 
243
264
  ```
244
265
  hex-line-mcp/
245
- server.mjs MCP server (stdio transport, 6 tools)
246
- hook.mjs PostToolUse hook (reminder + RTK filter)
266
+ server.mjs MCP server (stdio transport, 11 tools)
267
+ hook.mjs Unified hook (PreToolUse + PostToolUse + SessionStart)
247
268
  package.json
248
269
  lib/
249
270
  hash.mjs FNV-1a hashing, 2-char tags, range checksums
@@ -252,6 +273,13 @@ hex-line-mcp/
252
273
  search.mjs ripgrep wrapper with hash-annotated results
253
274
  outline.mjs tree-sitter WASM AST outline
254
275
  verify.mjs Range checksum verification
276
+ info.mjs File metadata (size, lines, mtime, type)
277
+ tree.mjs Directory tree with .gitignore support
278
+ changes.mjs Semantic git diff via AST
279
+ bulk-replace.mjs Multi-file search-and-replace
280
+ setup.mjs Hook installation for Claude/Gemini/Codex
281
+ format.mjs Output formatting utilities
282
+ coerce.mjs Parameter alias mapping
255
283
  security.mjs Path validation, binary detection, size limits
256
284
  normalize.mjs Output normalization, deduplication, truncation
257
285
  ```
@@ -283,16 +311,6 @@ FNV-1a accumulator over all line hashes in the range (little-endian byte feed).
283
311
  - Write path validation (ancestor directory must exist)
284
312
  - Directory restrictions delegated to Claude Code sandbox
285
313
 
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
314
 
297
315
  ## FAQ
298
316
 
@@ -320,7 +338,7 @@ Outline works on code files only (15+ languages via tree-sitter WASM). For markd
320
338
  <details>
321
339
  <summary><b>How does the RTK filter reduce tokens?</b></summary>
322
340
 
323
- The PostToolUse hook normalizes Bash output (replaces UUIDs, timestamps, IPs with placeholders), deduplicates identical lines, and truncates to first 12 + last 12 lines. Average savings: 45% (flat) / 52% (weighted) across 18 benchmark scenarios.
341
+ The PostToolUse hook normalizes Bash output (replaces UUIDs, timestamps, IPs with placeholders), deduplicates identical lines, and truncates to first 15 + last 15 lines. Average savings: 45% (flat) / 52% (weighted) across 18 benchmark scenarios.
324
342
 
325
343
  </details>
326
344
 
@@ -331,6 +349,14 @@ Yes. Remove the PreToolUse hook from `.claude/settings.local.json`. The MCP tool
331
349
 
332
350
  </details>
333
351
 
352
+ ## Hex Family
353
+
354
+ | Package | Purpose | npm |
355
+ |---------|---------|-----|
356
+ | [hex-line-mcp](https://www.npmjs.com/package/@levnikolaevich/hex-line-mcp) | Local file editing with hash verification + hooks | [![npm](https://img.shields.io/npm/v/@levnikolaevich/hex-line-mcp)](https://www.npmjs.com/package/@levnikolaevich/hex-line-mcp) |
357
+ | [hex-ssh-mcp](https://www.npmjs.com/package/@levnikolaevich/hex-ssh-mcp) | Remote file editing over SSH | [![npm](https://img.shields.io/npm/v/@levnikolaevich/hex-ssh-mcp)](https://www.npmjs.com/package/@levnikolaevich/hex-ssh-mcp) |
358
+ | [hex-graph-mcp](https://www.npmjs.com/package/@levnikolaevich/hex-graph-mcp) | Code knowledge graph with AST indexing | [![npm](https://img.shields.io/npm/v/@levnikolaevich/hex-graph-mcp)](https://www.npmjs.com/package/@levnikolaevich/hex-graph-mcp) |
359
+
334
360
  ## License
335
361
 
336
362
  MIT
package/hook.mjs CHANGED
@@ -23,7 +23,7 @@
23
23
  * Exit 2 = block (PreToolUse) or feedback via stderr (PostToolUse)
24
24
  */
25
25
 
26
- import { deduplicateLines, smartTruncate } from "./lib/normalize.mjs";
26
+ import { normalizeOutput } from "./lib/normalize.mjs";
27
27
  import { readFileSync } from "node:fs";
28
28
  import { resolve } from "node:path";
29
29
  import { homedir } from "node:os";
@@ -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). Params: case_insensitive, smart_case",
60
+ Grep: "mcp__hex-line__grep_search (not Grep). Params: output, literal, context_before, context_after, multiline",
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). Params: case_insensitive, smart_case",
66
+ grep: "mcp__hex-line__grep_search (not grep/rg). Params: output, literal, context_before, context_after, multiline",
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) {
@@ -266,10 +335,8 @@ function handlePostToolUse(data) {
266
335
 
267
336
  const type = detectCommandType(command);
268
337
 
269
- // Pipeline: deduplicate -> smart truncate
270
- const deduped = deduplicateLines(lines);
271
- const dedupedText = deduped.join("\n");
272
- const filtered = smartTruncate(dedupedText, HEAD_LINES, TAIL_LINES);
338
+ // Pipeline: normalize -> deduplicate -> smart truncate
339
+ const filtered = normalizeOutput(lines.join("\n"), { headLines: HEAD_LINES, tailLines: TAIL_LINES });
273
340
  const filteredCount = filtered.split("\n").length;
274
341
 
275
342
  const header = `RTK FILTERED: ${type} (${originalCount} lines -> ${filteredCount} lines)`;
@@ -331,21 +398,36 @@ function handleSessionStart() {
331
398
  }
332
399
 
333
400
  // ---- Main: read stdin, route by hook_event_name ----
401
+ // Guard: only run when executed directly, not when imported for testing
334
402
 
335
- let input = "";
336
- process.stdin.on("data", (chunk) => {
337
- input += chunk;
338
- });
339
- process.stdin.on("end", () => {
340
- try {
341
- const data = JSON.parse(input);
342
- const event = data.hook_event_name || "";
343
-
344
- if (event === "SessionStart") handleSessionStart();
345
- else if (event === "PreToolUse") handlePreToolUse(data);
346
- else if (event === "PostToolUse") handlePostToolUse(data);
347
- else process.exit(0);
348
- } catch {
349
- process.exit(0);
350
- }
351
- });
403
+ import { fileURLToPath } from "node:url";
404
+
405
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
406
+ let input = "";
407
+ process.stdin.on("data", (chunk) => {
408
+ input += chunk;
409
+ });
410
+ process.stdin.on("end", () => {
411
+ try {
412
+ const data = JSON.parse(input);
413
+ const event = data.hook_event_name || "";
414
+
415
+ if (isHexLineDisabled()) {
416
+ // REVERSE MODE: block hex-line calls, approve everything else
417
+ if (event === "PreToolUse") handlePreToolUseReverse(data);
418
+ process.exit(0); // SessionStart, PostToolUse — silent exit
419
+ }
420
+
421
+ // NORMAL MODE
422
+ if (event === "SessionStart") handleSessionStart();
423
+ else if (event === "PreToolUse") handlePreToolUse(data);
424
+ else if (event === "PostToolUse") handlePostToolUse(data);
425
+ else process.exit(0);
426
+ } catch {
427
+ process.exit(0);
428
+ }
429
+ });
430
+ }
431
+
432
+ // ---- Exports for testing ----
433
+ export { isHexLineDisabled, _resetHexLineDisabledCache };
@@ -1,16 +1,18 @@
1
- import { readFileSync, writeFileSync } from "node:fs";
2
- import { execSync } from "node:child_process";
1
+ import { writeFileSync } from "node:fs";
2
+ import { execFileSync } from "node:child_process";
3
3
  import { resolve } from "node:path";
4
4
  import { simpleDiff } from "./edit.mjs";
5
+ import { normalizePath } from "./security.mjs";
6
+ import { readText, MAX_OUTPUT_CHARS } from "./format.mjs";
5
7
 
6
8
  export function bulkReplace(rootDir, globPattern, replacements, opts = {}) {
7
9
  const { dryRun = false, maxFiles = 100 } = opts;
8
- const abs = resolve(rootDir);
10
+ const abs = resolve(normalizePath(rootDir));
9
11
 
10
12
  // Find files via ripgrep (respects .gitignore)
11
13
  let files;
12
14
  try {
13
- const rgOut = execSync(`rg --files -g "${globPattern}" "${abs}"`, { encoding: "utf-8", timeout: 10000 });
15
+ const rgOut = execFileSync("rg", ["--files", "-g", globPattern, abs], { encoding: "utf-8", timeout: 10000 });
14
16
  files = rgOut.trim().split("\n").filter(Boolean);
15
17
  } catch (e) {
16
18
  if (e.status === 1) return "No files matched the glob pattern.";
@@ -23,12 +25,12 @@ export function bulkReplace(rootDir, globPattern, replacements, opts = {}) {
23
25
 
24
26
  const results = [];
25
27
  let changed = 0, skipped = 0, errors = 0;
26
- const MAX_OUTPUT = 80000;
28
+ const MAX_OUTPUT = MAX_OUTPUT_CHARS;
27
29
  let totalChars = 0;
28
30
 
29
31
  for (const file of files) {
30
32
  try {
31
- const original = readFileSync(file, "utf-8").replace(/\r\n/g, "\n");
33
+ const original = readText(file);
32
34
  let content = original;
33
35
 
34
36
  for (const { old: oldText, new: newText } of replacements) {
package/lib/changes.mjs CHANGED
@@ -6,10 +6,11 @@
6
6
  * without temp files.
7
7
  */
8
8
 
9
- import { execSync } from "node:child_process";
10
- import { readFileSync, statSync } from "node:fs";
9
+ import { execFileSync } from "node:child_process";
10
+ import { statSync } from "node:fs";
11
11
  import { extname } from "node:path";
12
12
  import { validatePath } from "./security.mjs";
13
+ import { readText } from "./format.mjs";
13
14
  import { outlineFromContent } from "./outline.mjs";
14
15
 
15
16
  /**
@@ -50,7 +51,7 @@ function toSymbolMap(entries) {
50
51
  * Get relative path from git root for `git show`.
51
52
  */
52
53
  function gitRelativePath(absPath) {
53
- const root = execSync("git rev-parse --show-toplevel", {
54
+ const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
54
55
  cwd: absPath.replace(/[/\\][^/\\]+$/, ""),
55
56
  encoding: "utf-8",
56
57
  timeout: 5000,
@@ -79,7 +80,7 @@ export async function fileChanges(filePath, compareAgainst = "HEAD") {
79
80
  // Directory: return git diff --stat (compact file list, no content reads)
80
81
  if (statSync(real).isDirectory()) {
81
82
  try {
82
- const stat = execSync(`git diff --stat "${compareAgainst}" -- .`, {
83
+ const stat = execFileSync("git", ["diff", "--stat", compareAgainst, "--", "."], {
83
84
  cwd: real,
84
85
  encoding: "utf-8",
85
86
  timeout: 10000,
@@ -94,7 +95,7 @@ export async function fileChanges(filePath, compareAgainst = "HEAD") {
94
95
  const ext = extname(real).toLowerCase();
95
96
 
96
97
  // Check if outline supports this extension
97
- const currentContent = readFileSync(real, "utf-8").replace(/\r\n/g, "\n");
98
+ const currentContent = readText(real);
98
99
  const currentResult = await outlineFromContent(currentContent, ext);
99
100
  if (!currentResult) {
100
101
  return `Cannot outline ${ext} files. Supported: .js .mjs .ts .py .go .rs .java .c .cpp .cs .rb .php .kt .swift .sh .bash`;
@@ -104,7 +105,7 @@ export async function fileChanges(filePath, compareAgainst = "HEAD") {
104
105
  const relPath = gitRelativePath(real);
105
106
  let gitContent;
106
107
  try {
107
- gitContent = execSync(`git show "${compareAgainst}:${relPath}"`, {
108
+ gitContent = execFileSync("git", ["show", `${compareAgainst}:${relPath}`], {
108
109
  cwd: real.replace(/[/\\][^/\\]+$/, ""),
109
110
  encoding: "utf-8",
110
111
  timeout: 5000,
package/lib/coerce.mjs CHANGED
@@ -12,12 +12,6 @@ const ALIASES = {
12
12
  // grep_search
13
13
  query: "pattern",
14
14
  search: "pattern",
15
- max_results: "limit",
16
- maxResults: "limit",
17
- maxMatches: "limit",
18
- max_matches: "limit",
19
- contextLines: "context",
20
- context_lines: "context",
21
15
  ignoreCase: "case_insensitive",
22
16
  ignore_case: "case_insensitive",
23
17