@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/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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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 (
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
for (const rl of resultLines) {
|
|
98
|
-
formatted.push(rl);
|
|
99
|
-
}
|
|
234
|
+
formatted.push(`${filePath}:${ln}:${lineContent}`);
|
|
100
235
|
} else {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
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`);
|