@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 +63 -37
- package/hook.mjs +106 -24
- package/lib/bulk-replace.mjs +8 -6
- package/lib/changes.mjs +7 -6
- package/lib/coerce.mjs +0 -6
- package/lib/edit.mjs +140 -41
- package/lib/format.mjs +138 -0
- package/lib/hash.mjs +1 -1
- package/lib/info.mjs +13 -31
- package/lib/read.mjs +5 -24
- package/lib/search.mjs +213 -77
- package/lib/security.mjs +6 -8
- package/lib/setup.mjs +37 -5
- package/lib/tree.mjs +82 -83
- package/lib/verify.mjs +2 -2
- package/output-style.md +1 -1
- package/package.json +12 -4
- package/server.mjs +39 -24
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
31
|
-
*
|
|
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
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
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 {
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
221
|
+
count += countSubtreeFiles(full, ig, rootAbs, depth + 1);
|
|
224
222
|
} else {
|
|
225
|
-
|
|
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 =
|
|
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
|
-
|
|
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.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
|
-
"
|
|
44
|
-
"
|
|
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
|
|
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
|
|
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
|
-
"
|
|
106
|
-
"
|
|
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
|
|
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"
|
|
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
|
-
"
|
|
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("
|
|
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
|
|
171
|
-
|
|
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,
|
|
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,
|
|
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
|
-
"
|
|
291
|
-
"
|
|
292
|
-
"removes
|
|
293
|
-
"
|
|
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",
|
|
383
|
+
void checkForUpdates("@levnikolaevich/hex-line-mcp", version);
|