@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/tree.mjs CHANGED
@@ -1,20 +1,24 @@
1
1
  /**
2
- * Compact directory tree with .gitignore support.
2
+ * Compact directory tree with root .gitignore support.
3
3
  *
4
4
  * Skips common build/cache dirs by default.
5
- * Parses .gitignore patterns (simple subset: globs, comments, negation).
5
+ * Uses `ignore` package for spec-compliant .gitignore matching (path-based, negation, dir-only).
6
+ * Only root .gitignore is loaded — nested .gitignore files are not supported.
6
7
  */
7
8
 
8
9
  import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
9
10
  import { resolve, basename, join, relative } from "node:path";
11
+ import { formatSize, relativeTime, countFileLines } from "./format.mjs";
12
+ import { normalizePath } from "./security.mjs";
13
+ import ignore from "ignore";
10
14
 
11
15
  const SKIP_DIRS = new Set([
12
16
  "node_modules", ".git", "dist", "build", "__pycache__", ".next", "coverage",
13
17
  ]);
14
18
 
15
19
  /**
16
- * Convert a simple glob pattern to a RegExp.
17
- * Supports: * (any non-slash), ** (any), ? (single char).
20
+ * Convert a simple glob pattern to a RegExp for name matching.
21
+ * Used by pattern-mode to match entry names.
18
22
  */
19
23
  function globToRegex(pat) {
20
24
  return new RegExp(
@@ -27,40 +31,30 @@ function globToRegex(pat) {
27
31
  }
28
32
 
29
33
  /**
30
- * Parse .gitignore into match functions.
31
- * Supports: comments (#), negation (!), wildcards (*), dir-only trailing /.
34
+ * Load root .gitignore into an `ignore` instance.
35
+ * @param {string} rootDir - absolute path to tree root
36
+ * @returns {ReturnType<typeof ignore>|null}
32
37
  */
33
- function parseGitignore(content) {
34
- const lines = content.replace(/\r\n/g, "\n").split("\n");
35
- const patterns = [];
36
- for (const raw of lines) {
37
- const line = raw.trim();
38
- if (!line || line.startsWith("#")) continue;
39
- const negate = line.startsWith("!");
40
- let pat = negate ? line.slice(1) : line;
41
- // Strip leading /
42
- if (pat.startsWith("/")) pat = pat.slice(1);
43
- // Strip trailing /
44
- const dirOnly = pat.endsWith("/");
45
- if (dirOnly) pat = pat.slice(0, -1);
46
- const re = globToRegex(pat);
47
- patterns.push({ re, negate, dirOnly });
48
- }
49
- return patterns;
50
- }
51
- function isIgnored(name, isDir, patterns) {
52
- let ignored = false;
53
- for (const { re, negate, dirOnly } of patterns) {
54
- if (dirOnly && !isDir) continue;
55
- if (re.test(name)) ignored = !negate;
56
- }
57
- return ignored;
38
+ function loadGitignore(rootDir) {
39
+ const gi = join(rootDir, ".gitignore");
40
+ if (!existsSync(gi)) return null;
41
+ try {
42
+ const content = readFileSync(gi, "utf-8");
43
+ return ignore().add(content);
44
+ } catch { return null; }
58
45
  }
59
46
 
60
- function formatSize(bytes) {
61
- if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
62
- if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)}KB`;
63
- return `${bytes}B`;
47
+ /**
48
+ * Check if a relative path should be ignored.
49
+ * @param {ReturnType<typeof ignore>|null} ig - ignore instance (null = no gitignore)
50
+ * @param {string} relPath - POSIX relative path from tree root
51
+ * @param {boolean} isDir - true if directory
52
+ * @returns {boolean}
53
+ */
54
+ function isIgnored(ig, relPath, isDir) {
55
+ if (!ig) return false;
56
+ // ignore package expects dir paths to end with /
57
+ return ig.ignores(isDir ? relPath + "/" : relPath);
64
58
  }
65
59
 
66
60
  /**
@@ -73,22 +67,12 @@ function findByPattern(dirPath, opts) {
73
67
  const re = globToRegex(opts.pattern);
74
68
  const filterType = opts.type || "all";
75
69
  const maxDepth = opts.max_depth ?? 20;
76
- const useGitignore = opts.gitignore ?? true;
77
70
 
78
- const normalized = (process.platform === "win32" && /^\/[a-zA-Z]\//.test(dirPath))
79
- ? dirPath[1] + ":" + dirPath.slice(2) : dirPath;
80
- const abs = resolve(normalized);
71
+ const abs = resolve(normalizePath(dirPath));
81
72
  if (!existsSync(abs)) throw new Error(`DIRECTORY_NOT_FOUND: ${abs}`);
82
73
  if (!statSync(abs).isDirectory()) throw new Error(`Not a directory: ${abs}`);
83
74
 
84
- let patterns = [];
85
- if (useGitignore) {
86
- const gi = join(abs, ".gitignore");
87
- if (existsSync(gi)) {
88
- try { patterns = parseGitignore(readFileSync(gi, "utf-8")); } catch { /* skip */ }
89
- }
90
- }
91
-
75
+ const ig = (opts.gitignore ?? true) ? loadGitignore(abs) : null;
92
76
  const matches = [];
93
77
 
94
78
  function walk(dir, depth) {
@@ -99,15 +83,15 @@ function findByPattern(dirPath, opts) {
99
83
  for (const entry of entries) {
100
84
  const isDir = entry.isDirectory();
101
85
  if (SKIP_DIRS.has(entry.name) && isDir) continue;
102
- if (isIgnored(entry.name, isDir, patterns)) continue;
103
86
 
104
87
  const full = join(dir, entry.name);
88
+ const rel = relative(abs, full).replace(/\\/g, "/");
89
+ if (isIgnored(ig, rel, isDir)) continue;
105
90
 
106
91
  if (re.test(entry.name)) {
107
92
  if (filterType === "all" ||
108
93
  (filterType === "dir" && isDir) ||
109
94
  (filterType === "file" && !isDir)) {
110
- const rel = relative(abs, full).replace(/\\/g, "/");
111
95
  matches.push(isDir ? rel + "/" : rel);
112
96
  }
113
97
  }
@@ -137,35 +121,28 @@ export function directoryTree(dirPath, opts = {}) {
137
121
 
138
122
  const compact = opts.format === "compact";
139
123
  const maxDepth = compact ? 1 : (opts.max_depth ?? 3);
140
- const useGitignore = opts.gitignore ?? true;
141
124
 
142
- // Convert Git Bash /c/path → c:/path on Windows
143
- const normalized = (process.platform === "win32" && /^\/[a-zA-Z]\//.test(dirPath))
144
- ? dirPath[1] + ":" + dirPath.slice(2) : dirPath;
145
- const abs = resolve(normalized);
125
+ const abs = resolve(normalizePath(dirPath));
146
126
  if (!existsSync(abs)) throw new Error(`DIRECTORY_NOT_FOUND: ${abs}. Check path or use directory_tree on parent directory.`);
147
127
  const rootStat = statSync(abs);
148
128
  if (!rootStat.isDirectory()) throw new Error(`Not a directory: ${abs}`);
149
129
 
150
- // Load .gitignore
151
- let patterns = [];
152
- if (useGitignore) {
153
- const gi = join(abs, ".gitignore");
154
- if (existsSync(gi)) {
155
- try { patterns = parseGitignore(readFileSync(gi, "utf-8")); } catch { /* skip */ }
156
- }
157
- }
130
+ const ig = (opts.gitignore ?? true) ? loadGitignore(abs) : null;
158
131
 
159
132
  let totalFiles = 0;
160
133
  let totalSize = 0;
161
134
  const lines = [];
162
135
 
136
+ /**
137
+ * Recursive walk. Returns total file count for entire subtree
138
+ * (including beyond maxDepth — count is always full, display is depth-limited).
139
+ * Output order: pre-order (dir line before children).
140
+ */
163
141
  function walk(dir, prefix, depth) {
164
- if (depth > maxDepth) return;
165
142
  let entries;
166
143
  try {
167
144
  entries = readdirSync(dir, { withFileTypes: true });
168
- } catch { return; }
145
+ } catch { return 0; }
169
146
 
170
147
  // Sort: directories first, then files, alphabetical
171
148
  entries.sort((a, b) => {
@@ -175,56 +152,78 @@ export function directoryTree(dirPath, opts = {}) {
175
152
  return a.name.localeCompare(b.name);
176
153
  });
177
154
 
155
+ let subTotal = 0;
156
+
178
157
  for (const entry of entries) {
179
158
  const name = entry.name;
180
159
  const isDir = entry.isDirectory();
181
160
 
182
161
  if (SKIP_DIRS.has(name) && isDir) continue;
183
- if (isIgnored(name, isDir, patterns)) continue;
184
162
 
185
163
  const full = join(dir, name);
164
+ const rel = relative(abs, full).replace(/\\/g, "/");
165
+ if (isIgnored(ig, rel, isDir)) continue;
186
166
 
187
167
  if (isDir) {
188
168
  if (compact) {
189
169
  lines.push(`${prefix}${name}/`);
190
170
  } else {
191
- // Count files in subdirectory
192
- const subInfo = { files: 0 };
193
- countFiles(full, subInfo);
194
- lines.push(`${prefix}${name}/ (${subInfo.files} files)`);
171
+ // Pre-order: placeholder for dir line, patch after recursion
172
+ const lineIdx = lines.length;
173
+ lines.push("");
174
+ const count = depth < maxDepth
175
+ ? walk(full, prefix + " ", depth + 1)
176
+ : countSubtreeFiles(full, ig, abs);
177
+ lines[lineIdx] = `${prefix}${name}/ (${count} files)`;
178
+ subTotal += count;
195
179
  }
196
- walk(full, prefix + " ", depth + 1);
180
+ if (compact) walk(full, prefix + " ", depth + 1);
197
181
  } else {
198
182
  totalFiles++;
183
+ subTotal++;
199
184
  if (compact) {
200
185
  lines.push(`${prefix}${name}`);
201
186
  } else {
202
- let size = 0;
203
- try { size = statSync(full).size; } catch { /* skip */ }
187
+ let size = 0, mtime = null, lineCount = null;
188
+ try {
189
+ const st = statSync(full);
190
+ size = st.size;
191
+ mtime = st.mtime;
192
+ } catch { /* skip */ }
204
193
  totalSize += size;
205
- if (size >= 1024) {
206
- lines.push(`${prefix}${name} (${formatSize(size)})`);
207
- } else {
208
- lines.push(`${prefix}${name}`);
209
- }
194
+ lineCount = countFileLines(full, size);
195
+ const parts = [];
196
+ if (lineCount !== null) parts.push(`${lineCount}L`);
197
+ parts.push(formatSize(size));
198
+ if (mtime) parts.push(relativeTime(mtime, true));
199
+ lines.push(`${prefix}${name} (${parts.join(", ")})`);
210
200
  }
211
201
  }
212
202
  }
203
+
204
+ return subTotal;
213
205
  }
214
206
 
215
- function countFiles(dir, info, depth = 0) {
216
- if (depth > 10) return; // safety limit for deep trees
207
+ /**
208
+ * Count files in subtree without emitting lines (for dirs beyond maxDepth).
209
+ */
210
+ function countSubtreeFiles(dir, ig, rootAbs, depth = 0) {
211
+ if (depth > 10) return 0;
217
212
  let entries;
218
- try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
213
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return 0; }
214
+ let count = 0;
219
215
  for (const entry of entries) {
220
216
  if (SKIP_DIRS.has(entry.name) && entry.isDirectory()) continue;
221
- if (isIgnored(entry.name, entry.isDirectory(), patterns)) continue;
217
+ const full = join(dir, entry.name);
218
+ const rel = relative(rootAbs, full).replace(/\\/g, "/");
219
+ if (isIgnored(ig, rel, entry.isDirectory())) continue;
222
220
  if (entry.isDirectory()) {
223
- countFiles(join(dir, entry.name), info, depth + 1);
221
+ count += countSubtreeFiles(full, ig, rootAbs, depth + 1);
224
222
  } else {
225
- info.files++;
223
+ count++;
226
224
  }
227
225
  }
226
+ return count;
228
227
  }
229
228
 
230
229
  const rootName = basename(abs);
package/lib/verify.mjs CHANGED
@@ -3,9 +3,9 @@
3
3
  * Validates range checksums from prior reads.
4
4
  */
5
5
 
6
- import { readFileSync } from "node:fs";
7
6
  import { fnv1a, rangeChecksum, parseChecksum } from "./hash.mjs";
8
7
  import { validatePath } from "./security.mjs";
8
+ import { readText } from "./format.mjs";
9
9
 
10
10
  /**
11
11
  * Verify checksums against current file state.
@@ -16,7 +16,7 @@ import { validatePath } from "./security.mjs";
16
16
  */
17
17
  export function verifyChecksums(filePath, checksums) {
18
18
  const real = validatePath(filePath);
19
- const content = readFileSync(real, "utf-8").replace(/\r\n/g, "\n");
19
+ const content = readText(real);
20
20
  const lines = content.split("\n");
21
21
 
22
22
  // Pre-compute all line hashes
package/output-style.md CHANGED
@@ -6,7 +6,7 @@ keep-coding-instructions: true
6
6
 
7
7
  # MCP Tool Preferences
8
8
 
9
- When `hex-line` MCP is available, **always prefer it** over built-in file tools:
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.2.0",
3
+ "version": "1.3.1",
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",
@@ -25,10 +25,16 @@
25
25
  "dependencies": {
26
26
  "@modelcontextprotocol/sdk": "^1.17.0",
27
27
  "diff": "^8.0.3",
28
+ "ignore": "^7.0.5",
28
29
  "tree-sitter-wasms": "^0.1.0",
29
- "web-tree-sitter": "^0.25.0"
30
+ "web-tree-sitter": "^0.25.0",
31
+ "zod": "^3.24.0"
30
32
  },
33
+ "author": "Lev Nikolaevich <https://github.com/levnikolaevich>",
31
34
  "license": "MIT",
35
+ "bugs": {
36
+ "url": "https://github.com/levnikolaevich/claude-code-skills/issues"
37
+ },
32
38
  "keywords": [
33
39
  "mcp",
34
40
  "hex-line",
@@ -40,8 +46,10 @@
40
46
  "hash-verified",
41
47
  "token-efficiency",
42
48
  "hook",
43
- "rtk",
44
- "bulk-replace"
49
+ "bulk-replace",
50
+ "claude",
51
+ "ai",
52
+ "llm"
45
53
  ],
46
54
  "engines": {
47
55
  "node": ">=18.0.0"
package/server.mjs CHANGED
@@ -3,13 +3,15 @@
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 (trueline-compatible)
6
+ * FNV-1a 2-char tags + range checksums
7
7
  * Security: root policy, path validation, binary/size rejection
8
8
  * Transport: stdio
9
9
  */
10
10
 
11
11
  import { writeFileSync, mkdirSync } from "node:fs";
12
12
  import { dirname } from "node:path";
13
+ import { createRequire } from "node:module";
14
+ const { version } = createRequire(import.meta.url)("./package.json");
13
15
  import { z } from "zod";
14
16
  // LLM clients may send booleans as strings ("true"/"false").
15
17
  // z.coerce.boolean() is unsafe: Boolean("false") === true.
@@ -54,7 +56,7 @@ try {
54
56
  process.exit(1);
55
57
  }
56
58
 
57
- const server = new McpServer({ name: "hex-line-mcp", version: "1.2.0" });
59
+ const server = new McpServer({ name: "hex-line-mcp", version });
58
60
 
59
61
 
60
62
  // ==================== read_file ====================
@@ -101,17 +103,19 @@ server.registerTool("read_file", {
101
103
  server.registerTool("edit_file", {
102
104
  title: "Edit File",
103
105
  description:
104
- "Edit a file using hash-verified anchors or text replacement. Returns diff. " +
105
- "new_text replaces anchor range exactly include boundary lines if you want to keep them. " +
106
- "Preserve indentation from read_file. For anchor edits, read_file first to get hashes.",
106
+ "Edit a file using hash-verified anchors or text replacement. Returns diff + post-edit checksums. " +
107
+ "Batch multiple edits in ONE call for atomicity (bottom-to-top auto-sorted). " +
108
+ "set_line: single line (from grep/read). replace_lines: range with checksum. " +
109
+ "replace: unique text match, or all:true for rename. insert_after: add below anchor.",
107
110
  inputSchema: z.object({
108
111
  path: z.string().describe("File to edit"),
109
112
  edits: z.string().describe(
110
113
  'JSON array. Examples:\n' +
111
114
  '{"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_checksum":"10-15:a1b2c3d4"}} — range (range_checksum from read_file required)\n' +
115
+ '{"replace_lines":{"start_anchor":"ab.10","end_anchor":"cd.15","new_text":"...","range_checksum":"10-15:a1b2c3d4"}} — range\n' +
113
116
  '{"insert_after":{"anchor":"ab.20","text":"inserted"}} — insert below\n' +
114
- '{"replace":{"old_text":"find","new_text":"replace","all":true}} — rename-all (all:true required)',
117
+ '{"replace":{"old_text":"find","new_text":"replace"}} — unique match\n' +
118
+ '{"replace":{"old_text":"find","new_text":"replace","all":true}} — replace all',
115
119
  ),
116
120
  dry_run: flexBool().describe("Preview changes without writing"),
117
121
  restore_indent: flexBool().describe("Auto-fix indentation to match anchor (default: false)"),
@@ -159,25 +163,36 @@ server.registerTool("write_file", {
159
163
  server.registerTool("grep_search", {
160
164
  title: "Search Files",
161
165
  description:
162
- "Search file contents with ripgrep. Returns hash-annotated matches for direct editing. " +
163
- "ALWAYS prefer over shell grep/rg/findstr. Use to find code before read_file or edit_file.",
166
+ "Search file contents with ripgrep. Returns hash-annotated matches with per-group checksums for direct editing. " +
167
+ "Output modes: content (default, edit-ready hashes+checksums), files (paths only), count (match counts). " +
168
+ "For single-line edits: grep -> set_line directly. For range edits: use checksum from grep output. " +
169
+ "ALWAYS prefer over shell grep/rg/findstr.",
164
170
  inputSchema: z.object({
165
- pattern: z.string().describe("Regex search pattern"),
171
+ pattern: z.string().describe("Search pattern (regex by default, literal if literal:true)"),
166
172
  path: z.string().optional().describe("Search dir/file (default: cwd)"),
167
173
  glob: z.string().optional().describe('Glob filter (e.g. "*.ts")'),
168
174
  type: z.string().optional().describe('File type (e.g. "js", "py")'),
175
+ output: z.enum(["content", "files", "count"]).optional().describe('Output format (default: content)'),
169
176
  case_insensitive: flexBool().describe("Ignore case (-i)"),
170
- smart_case: flexBool().describe("CI when pattern is all lowercase, CS if it has uppercase (-S)"),
171
- context: flexNum().describe("Context lines around matches"),
177
+ smart_case: flexBool().describe("CI when pattern is all lowercase, CS if uppercase (-S)"),
178
+ literal: flexBool().describe("Literal string search, no regex (-F)"),
179
+ multiline: flexBool().describe("Pattern can span multiple lines (-U)"),
180
+ context: flexNum().describe("Symmetric context lines around matches (-C)"),
181
+ context_before: flexNum().describe("Context lines BEFORE match (-B)"),
182
+ context_after: flexNum().describe("Context lines AFTER match (-A)"),
172
183
  limit: flexNum().describe("Max matches per file (default: 100)"),
184
+ total_limit: flexNum().describe("Total match events across all files; multiline matches count as 1 (0 = unlimited)"),
173
185
  plain: flexBool().describe("Omit hash tags, return file:line:content"),
174
186
  }),
175
187
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
176
188
  }, async (rawParams) => {
177
- const { pattern, path: p, glob, type, case_insensitive, smart_case, context, limit, plain } = coerceParams(rawParams);
189
+ const { pattern, path: p, glob, type, output, case_insensitive, smart_case, literal, multiline,
190
+ context, context_before, context_after, limit, total_limit, plain } = coerceParams(rawParams);
178
191
  try {
179
192
  const result = await grepSearch(pattern, {
180
- path: p, glob, type, caseInsensitive: case_insensitive, smartCase: smart_case, context, limit, plain,
193
+ path: p, glob, type, output, caseInsensitive: case_insensitive, smartCase: smart_case,
194
+ literal, multiline, context, contextBefore: context_before, contextAfter: context_after,
195
+ limit, totalLimit: total_limit, plain,
181
196
  });
182
197
  return { content: [{ type: "text", text: result }] };
183
198
  } catch (e) {
@@ -238,7 +253,7 @@ server.registerTool("verify", {
238
253
  server.registerTool("directory_tree", {
239
254
  title: "Directory Tree",
240
255
  description:
241
- "Compact directory tree with .gitignore support. " +
256
+ "Compact directory tree with root .gitignore support (path-based rules, negation). " +
242
257
  "Supports pattern glob to find files/dirs by name (like find -name). " +
243
258
  "Use to understand repo structure or find specific files/dirs. " +
244
259
  "Skips node_modules, .git, dist by default.",
@@ -247,7 +262,7 @@ server.registerTool("directory_tree", {
247
262
  pattern: z.string().optional().describe('Glob filter on names (e.g. "*-mcp", "*.mjs"). Returns flat match list instead of tree'),
248
263
  type: z.enum(["file", "dir", "all"]).optional().describe('"file", "dir", or "all" (default). Like find -type f/d'),
249
264
  max_depth: flexNum().describe("Max recursion depth (default: 3, or 20 in pattern mode)"),
250
- gitignore: flexBool().describe("Respect .gitignore patterns (default: true)"),
265
+ gitignore: flexBool().describe("Respect root .gitignore patterns (default: true). Nested .gitignore not supported"),
251
266
  format: z.enum(["compact", "full"]).optional().describe('"compact" = names only, no sizes, depth 1. "full" = default with sizes'),
252
267
  }),
253
268
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
@@ -287,19 +302,19 @@ server.registerTool("get_file_info", {
287
302
  server.registerTool("setup_hooks", {
288
303
  title: "Setup Hooks",
289
304
  description:
290
- "Configure hex-line hooks in CLI agent settings. " +
291
- "Claude: writes hooks to ~/.claude/settings.json (global) with absolute path, " +
292
- "removes old hooks from per-project settings.local.json. " +
293
- "Gemini/Codex: returns guidance (no hook support). " +
294
- "Idempotent: re-running produces no changes if already configured.",
305
+ "Install or uninstall hex-line hooks in CLI agent settings. " +
306
+ "install: writes hooks to ~/.claude/settings.json, removes old per-project hooks. " +
307
+ "uninstall: removes hex-line hooks from global settings. " +
308
+ "Idempotent: re-running produces no changes if already in desired state.",
295
309
  inputSchema: z.object({
296
310
  agent: z.string().optional().describe('Target agent: "claude", "gemini", "codex", or "all" (default: "all")'),
311
+ action: z.string().optional().describe('"install" (default) or "uninstall"'),
297
312
  }),
298
313
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
299
314
  }, async (rawParams) => {
300
- const { agent } = coerceParams(rawParams);
315
+ const { agent, action } = coerceParams(rawParams);
301
316
  try {
302
- return { content: [{ type: "text", text: setupHooks(agent) }] };
317
+ return { content: [{ type: "text", text: setupHooks(agent, action) }] };
303
318
  } catch (e) {
304
319
  return { content: [{ type: "text", text: e.message }], isError: true };
305
320
  }
@@ -365,4 +380,4 @@ server.registerTool("bulk_replace", {
365
380
 
366
381
  const transport = new StdioServerTransport();
367
382
  await server.connect(transport);
368
- void checkForUpdates("@levnikolaevich/hex-line-mcp", "1.2.0");
383
+ void checkForUpdates("@levnikolaevich/hex-line-mcp", version);