@levnikolaevich/hex-line-mcp 1.3.3 → 1.3.5

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.
@@ -0,0 +1,2645 @@
1
+ #!/usr/bin/env node
2
+
3
+ // server.mjs
4
+ import { writeFileSync as writeFileSync4, mkdirSync as mkdirSync2 } from "node:fs";
5
+ import { dirname as dirname4 } from "node:path";
6
+ import { z as z2 } from "zod";
7
+
8
+ // ../hex-common/src/runtime/mcp-bootstrap.mjs
9
+ async function createServerRuntime({ name, version: version2, installDir }) {
10
+ let McpServer, StdioServerTransport2;
11
+ try {
12
+ ({ McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js"));
13
+ ({ StdioServerTransport: StdioServerTransport2 } = await import("@modelcontextprotocol/sdk/server/stdio.js"));
14
+ } catch {
15
+ process.stderr.write(
16
+ `${name}: @modelcontextprotocol/sdk not found.
17
+ Run: cd ${installDir} && npm install
18
+ `
19
+ );
20
+ process.exit(1);
21
+ }
22
+ return {
23
+ server: new McpServer({ name, version: version2 }),
24
+ StdioServerTransport: StdioServerTransport2
25
+ };
26
+ }
27
+
28
+ // ../hex-common/src/runtime/schema.mjs
29
+ import { z } from "zod";
30
+ var flexBool = () => z.preprocess(
31
+ (v) => typeof v === "string" ? v === "true" : v,
32
+ z.boolean().optional()
33
+ ).optional();
34
+ var flexNum = () => z.preprocess(
35
+ (v) => typeof v === "string" ? Number(v) : v,
36
+ z.number().optional()
37
+ ).optional();
38
+
39
+ // ../hex-common/src/runtime/coerce.mjs
40
+ function coerceParams(params) {
41
+ return params;
42
+ }
43
+
44
+ // ../hex-common/src/runtime/update-check.mjs
45
+ import { readFile, writeFile } from "node:fs/promises";
46
+ import { join } from "node:path";
47
+ import { tmpdir } from "node:os";
48
+ var CACHE_FILE = join(tmpdir(), "hex-common-update.json");
49
+ var CHECK_INTERVAL = 24 * 60 * 60 * 1e3;
50
+ var TIMEOUT = 3e3;
51
+ async function readCache() {
52
+ try {
53
+ return JSON.parse(await readFile(CACHE_FILE, "utf-8"));
54
+ } catch {
55
+ return {};
56
+ }
57
+ }
58
+ async function writeCache(entry) {
59
+ await writeFile(CACHE_FILE, JSON.stringify(entry)).catch(() => {
60
+ });
61
+ }
62
+ async function fetchLatest(packageName) {
63
+ try {
64
+ const ctrl = new AbortController();
65
+ const timer = setTimeout(() => ctrl.abort(), TIMEOUT);
66
+ const res = await fetch(`https://registry.npmjs.org/${packageName}/latest`, { signal: ctrl.signal });
67
+ clearTimeout(timer);
68
+ if (!res.ok) return null;
69
+ const data = await res.json();
70
+ return data.version ?? null;
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+ function compareVersions(a, b) {
76
+ const pa = a.split(".").map(Number);
77
+ const pb = b.split(".").map(Number);
78
+ for (let i = 0; i < 3; i++) {
79
+ if ((pa[i] || 0) < (pb[i] || 0)) return -1;
80
+ if ((pa[i] || 0) > (pb[i] || 0)) return 1;
81
+ }
82
+ return 0;
83
+ }
84
+ async function checkForUpdates(packageName, currentVersion) {
85
+ const cache = await readCache();
86
+ const cached = cache[packageName];
87
+ if (cached && Date.now() - cached.timestamp < CHECK_INTERVAL) {
88
+ if (cached.latest && compareVersions(currentVersion, cached.latest) < 0) {
89
+ process.stderr.write(`${packageName} update: ${currentVersion} -> ${cached.latest}. Run: npm install -g ${packageName}
90
+ `);
91
+ }
92
+ return;
93
+ }
94
+ const latest = await fetchLatest(packageName);
95
+ if (!latest) return;
96
+ cache[packageName] = { timestamp: Date.now(), latest };
97
+ await writeCache(cache);
98
+ if (compareVersions(currentVersion, latest) < 0) {
99
+ process.stderr.write(`${packageName} update: ${currentVersion} -> ${latest}. Run: npm install -g ${packageName}
100
+ `);
101
+ }
102
+ }
103
+
104
+ // lib/read.mjs
105
+ import { statSync as statSync4 } from "node:fs";
106
+
107
+ // ../hex-common/src/text-protocol/hash.mjs
108
+ var FNV_OFFSET = 2166136261;
109
+ var FNV_PRIME = 16777619;
110
+ var TAG_CHARS = "abcdefghijklmnopqrstuvwxyz234567";
111
+ function fnv1a(str) {
112
+ const normalized = str.replace(/\r$/, "").replace(/\s+/g, "");
113
+ let h = FNV_OFFSET;
114
+ for (let i = 0; i < normalized.length; i++) {
115
+ let code = normalized.charCodeAt(i);
116
+ if (code >= 55296 && code <= 56319 && i + 1 < normalized.length) {
117
+ const lo = normalized.charCodeAt(i + 1);
118
+ if (lo >= 56320 && lo <= 57343) {
119
+ code = (code - 55296 << 10) + (lo - 56320) + 65536;
120
+ i++;
121
+ }
122
+ }
123
+ if (code < 128) {
124
+ h = Math.imul(h ^ code, FNV_PRIME) >>> 0;
125
+ } else if (code < 2048) {
126
+ h = Math.imul(h ^ (192 | code >> 6), FNV_PRIME) >>> 0;
127
+ h = Math.imul(h ^ (128 | code & 63), FNV_PRIME) >>> 0;
128
+ } else if (code < 65536) {
129
+ h = Math.imul(h ^ (224 | code >> 12), FNV_PRIME) >>> 0;
130
+ h = Math.imul(h ^ (128 | code >> 6 & 63), FNV_PRIME) >>> 0;
131
+ h = Math.imul(h ^ (128 | code & 63), FNV_PRIME) >>> 0;
132
+ } else {
133
+ h = Math.imul(h ^ (240 | code >> 18), FNV_PRIME) >>> 0;
134
+ h = Math.imul(h ^ (128 | code >> 12 & 63), FNV_PRIME) >>> 0;
135
+ h = Math.imul(h ^ (128 | code >> 6 & 63), FNV_PRIME) >>> 0;
136
+ h = Math.imul(h ^ (128 | code & 63), FNV_PRIME) >>> 0;
137
+ }
138
+ }
139
+ return h;
140
+ }
141
+ function lineTag(hash32) {
142
+ return TAG_CHARS[hash32 & 31] + TAG_CHARS[hash32 >>> 8 & 31];
143
+ }
144
+ function rangeChecksum(lineHashes, startLine, endLine) {
145
+ let acc = FNV_OFFSET;
146
+ for (const h of lineHashes) {
147
+ acc = Math.imul(acc ^ h & 255, FNV_PRIME) >>> 0;
148
+ acc = Math.imul(acc ^ h >>> 8 & 255, FNV_PRIME) >>> 0;
149
+ acc = Math.imul(acc ^ h >>> 16 & 255, FNV_PRIME) >>> 0;
150
+ acc = Math.imul(acc ^ h >>> 24 & 255, FNV_PRIME) >>> 0;
151
+ }
152
+ return `${startLine}-${endLine}:${acc.toString(16).padStart(8, "0")}`;
153
+ }
154
+ function parseRef(ref) {
155
+ const m = ref.trim().match(/^([a-z2-7]{2})\.(\d+)$/);
156
+ if (!m) throw new Error(`Bad ref: "${ref}". Expected "ab.12" (tag.lineNum)`);
157
+ return { tag: m[1], line: parseInt(m[2], 10) };
158
+ }
159
+ function parseChecksum(cs) {
160
+ const m = cs.trim().match(/^(\d+)-(\d+):([0-9a-f]{8})$/);
161
+ if (!m) throw new Error(`Bad checksum: "${cs}". Expected "1-50:f7e2a1b0"`);
162
+ return { start: parseInt(m[1], 10), end: parseInt(m[2], 10), hex: m[3] };
163
+ }
164
+
165
+ // lib/security.mjs
166
+ import { realpathSync, statSync as statSync2, existsSync, openSync, readSync, closeSync } from "node:fs";
167
+ import { resolve, isAbsolute, dirname } from "node:path";
168
+
169
+ // lib/format.mjs
170
+ import { readdirSync, readFileSync, statSync } from "node:fs";
171
+ import { join as join2 } from "node:path";
172
+ function formatSize(bytes) {
173
+ if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
174
+ if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)}KB`;
175
+ return `${bytes}B`;
176
+ }
177
+ function relativeTime(date, compact = false) {
178
+ const sec = Math.round((Date.now() - date.getTime()) / 1e3);
179
+ if (compact) {
180
+ if (sec < 60) return "now";
181
+ if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
182
+ if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`;
183
+ if (sec < 604800) return `${Math.floor(sec / 86400)}d ago`;
184
+ if (sec < 2592e3) return `${Math.floor(sec / 604800)}w ago`;
185
+ return `${Math.floor(sec / 2592e3)}mo ago`;
186
+ }
187
+ if (sec < 60) return "just now";
188
+ const min = Math.floor(sec / 60);
189
+ if (min < 60) return `${min} min ago`;
190
+ const hrs = Math.floor(min / 60);
191
+ if (hrs < 24) return `${hrs} hour${hrs === 1 ? "" : "s"} ago`;
192
+ const days = Math.floor(hrs / 24);
193
+ if (days < 30) return `${days} day${days === 1 ? "" : "s"} ago`;
194
+ const months = Math.floor(days / 30);
195
+ if (months < 12) return `${months} month${months === 1 ? "" : "s"} ago`;
196
+ const years = Math.floor(months / 12);
197
+ return `${years} year${years === 1 ? "" : "s"} ago`;
198
+ }
199
+ function countFileLines(filePath, size, maxSize = 1e6) {
200
+ if (size === 0 || size > maxSize) return null;
201
+ try {
202
+ const buf = readFileSync(filePath);
203
+ const checkLen = Math.min(buf.length, 8192);
204
+ for (let i = 0; i < checkLen; i++) if (buf[i] === 0) return null;
205
+ let count = 1;
206
+ for (let i = 0; i < buf.length; i++) if (buf[i] === 10) count++;
207
+ return count;
208
+ } catch {
209
+ return null;
210
+ }
211
+ }
212
+ function listDirectory(dirPath, opts = {}) {
213
+ const { limit = 0, metadata = false, compact = false, indent = " " } = opts;
214
+ let entries;
215
+ try {
216
+ entries = readdirSync(dirPath, { withFileTypes: true });
217
+ } catch {
218
+ return { text: "", total: 0 };
219
+ }
220
+ entries.sort((a, b) => {
221
+ const aDir = a.isDirectory() ? 0 : 1;
222
+ const bDir = b.isDirectory() ? 0 : 1;
223
+ if (aDir !== bDir) return aDir - bDir;
224
+ return a.name.localeCompare(b.name);
225
+ });
226
+ const total = entries.length;
227
+ const visible = limit > 0 ? entries.slice(0, limit) : entries;
228
+ const lines = visible.map((entry) => {
229
+ const isDir = entry.isDirectory();
230
+ if (!metadata) {
231
+ return `${indent}${isDir ? "d" : "f"} ${entry.name}`;
232
+ }
233
+ if (isDir) {
234
+ return `${indent}${entry.name}/`;
235
+ }
236
+ const full = join2(dirPath, entry.name);
237
+ const parts = [];
238
+ try {
239
+ const st = statSync(full);
240
+ const lineCount = countFileLines(full, st.size);
241
+ if (lineCount !== null) parts.push(`${lineCount}L`);
242
+ parts.push(formatSize(st.size));
243
+ if (st.mtime) parts.push(relativeTime(st.mtime, compact));
244
+ } catch {
245
+ parts.push("?");
246
+ }
247
+ return `${indent}${entry.name} (${parts.join(", ")})`;
248
+ });
249
+ return { text: lines.join("\n"), total };
250
+ }
251
+ var MAX_OUTPUT_CHARS = 8e4;
252
+ function readText(filePath) {
253
+ return readFileSync(filePath, "utf-8").replace(/\r\n/g, "\n");
254
+ }
255
+
256
+ // lib/security.mjs
257
+ var MAX_FILE_SIZE = 10 * 1024 * 1024;
258
+ function normalizePath(p) {
259
+ if (process.platform === "win32" && /^\/[a-zA-Z]\//.test(p)) {
260
+ p = p[1] + ":" + p.slice(2);
261
+ }
262
+ return p.replace(/\\/g, "/");
263
+ }
264
+ function validatePath(filePath) {
265
+ if (!filePath) throw new Error("Empty file path");
266
+ const normalized = normalizePath(filePath);
267
+ const abs = isAbsolute(normalized) ? normalized : resolve(process.cwd(), normalized);
268
+ if (!existsSync(abs)) {
269
+ let hint = "";
270
+ try {
271
+ const parent = dirname(abs);
272
+ if (existsSync(parent)) {
273
+ const { text, total } = listDirectory(parent, { limit: 20, metadata: true });
274
+ hint = `
275
+
276
+ Parent directory ${parent} contains:
277
+ ${text}`;
278
+ if (total > 20) hint += `
279
+ ... (${total - 20} more)`;
280
+ }
281
+ } catch {
282
+ }
283
+ throw new Error(`FILE_NOT_FOUND: ${abs}${hint}`);
284
+ }
285
+ let real;
286
+ try {
287
+ real = realpathSync(abs);
288
+ } catch (e) {
289
+ throw new Error(`Cannot resolve path: ${abs} (${e.message})`);
290
+ }
291
+ const stat = statSync2(real);
292
+ if (stat.isDirectory()) return real;
293
+ if (!stat.isFile()) {
294
+ const type = stat.isSymbolicLink() ? "symlink" : "special";
295
+ throw new Error(`NOT_REGULAR_FILE: ${real} (${type}). Cannot read special files.`);
296
+ }
297
+ if (stat.size > MAX_FILE_SIZE) {
298
+ throw new Error(`FILE_TOO_LARGE: ${real} (${(stat.size / 1024 / 1024).toFixed(1)}MB, max ${MAX_FILE_SIZE / 1024 / 1024}MB). Use offset/limit to read a range.`);
299
+ }
300
+ const bfd = openSync(real, "r");
301
+ const probe = Buffer.alloc(8192);
302
+ const bytesRead = readSync(bfd, probe, 0, 8192, 0);
303
+ closeSync(bfd);
304
+ for (let i = 0; i < bytesRead; i++) {
305
+ if (probe[i] === 0) {
306
+ throw new Error(`BINARY_FILE: ${real}. Use built-in Read tool (supports images, PDFs, notebooks).`);
307
+ }
308
+ }
309
+ return real.replace(/\\/g, "/");
310
+ }
311
+ function validateWritePath(filePath) {
312
+ if (!filePath) throw new Error("Empty file path");
313
+ const normalized = normalizePath(filePath);
314
+ const abs = isAbsolute(normalized) ? normalized : resolve(process.cwd(), normalized);
315
+ if (!existsSync(abs)) {
316
+ const parent = resolve(abs, "..");
317
+ if (!existsSync(parent)) {
318
+ let ancestor = resolve(parent, "..");
319
+ while (!existsSync(ancestor) && ancestor !== resolve(ancestor, "..")) {
320
+ ancestor = resolve(ancestor, "..");
321
+ }
322
+ if (!existsSync(ancestor)) {
323
+ throw new Error(`No existing ancestor directory for: ${abs}`);
324
+ }
325
+ }
326
+ }
327
+ return abs.replace(/\\/g, "/");
328
+ }
329
+
330
+ // lib/graph-enrich.mjs
331
+ import { existsSync as existsSync2 } from "node:fs";
332
+ import { join as join3, dirname as dirname2, relative } from "node:path";
333
+ import { createRequire } from "node:module";
334
+ var HEX_LINE_CONTRACT_VERSION = 1;
335
+ var _dbs = /* @__PURE__ */ new Map();
336
+ var _driverUnavailable = false;
337
+ function getGraphDB(filePath) {
338
+ if (_driverUnavailable) return null;
339
+ try {
340
+ const projectRoot = findProjectRoot(filePath);
341
+ if (!projectRoot) return null;
342
+ const dbPath = join3(projectRoot, ".codegraph", "index.db");
343
+ if (!existsSync2(dbPath)) return null;
344
+ if (_dbs.has(dbPath)) return _dbs.get(dbPath);
345
+ const require2 = createRequire(import.meta.url);
346
+ const Database = require2("better-sqlite3");
347
+ const db = new Database(dbPath, { readonly: true });
348
+ if (!validateHexLineContract(db)) {
349
+ db.close();
350
+ return null;
351
+ }
352
+ _dbs.set(dbPath, db);
353
+ return db;
354
+ } catch {
355
+ _driverUnavailable = true;
356
+ return null;
357
+ }
358
+ }
359
+ function validateHexLineContract(db) {
360
+ try {
361
+ const contract = db.prepare("SELECT contract_version FROM hex_line_contract LIMIT 1").get();
362
+ if (!contract || contract.contract_version !== HEX_LINE_CONTRACT_VERSION) return false;
363
+ db.prepare("SELECT node_id, file, line_start, line_end, display_name, kind, callees, callers FROM hex_line_symbol_annotations LIMIT 1").all();
364
+ db.prepare("SELECT source_id, target_id, source_file, source_line, source_display_name, target_file, target_line, target_display_name FROM hex_line_call_edges LIMIT 1").all();
365
+ return true;
366
+ } catch {
367
+ return false;
368
+ }
369
+ }
370
+ function symbolAnnotation(db, file, name) {
371
+ try {
372
+ const node = db.prepare(
373
+ "SELECT callees, callers FROM hex_line_symbol_annotations WHERE file = ? AND name = ? LIMIT 1"
374
+ ).get(file, name);
375
+ if (!node) return null;
376
+ if (node.callees === 0 && node.callers === 0) return null;
377
+ return `[${node.callees}\u2193 ${node.callers}\u2191]`;
378
+ } catch {
379
+ return null;
380
+ }
381
+ }
382
+ function fileAnnotations(db, file) {
383
+ try {
384
+ const nodes = db.prepare(
385
+ `SELECT display_name, kind, callees, callers
386
+ FROM hex_line_symbol_annotations
387
+ WHERE file = ?
388
+ ORDER BY line_start`
389
+ ).all(file);
390
+ return nodes.map((node) => ({
391
+ name: node.display_name,
392
+ kind: node.kind,
393
+ callees: node.callees,
394
+ callers: node.callers
395
+ }));
396
+ } catch {
397
+ return [];
398
+ }
399
+ }
400
+ function callImpact(db, file, startLine, endLine) {
401
+ try {
402
+ const modified = db.prepare(
403
+ `SELECT node_id
404
+ FROM hex_line_symbol_annotations
405
+ WHERE file = ?
406
+ AND line_start <= ?
407
+ AND line_end >= ?`
408
+ ).all(file, endLine, startLine);
409
+ if (modified.length === 0) return [];
410
+ const affected = [];
411
+ const seen = /* @__PURE__ */ new Set();
412
+ for (const node of modified) {
413
+ const dependents = db.prepare(
414
+ `SELECT source_display_name AS name, source_file AS file, source_line AS line
415
+ FROM hex_line_call_edges
416
+ WHERE target_id = ?`
417
+ ).all(node.node_id);
418
+ for (const dep of dependents) {
419
+ const key = `${dep.file}:${dep.name}`;
420
+ if (!seen.has(key) && dep.file !== file) {
421
+ seen.add(key);
422
+ affected.push({ name: dep.name, file: dep.file, line: dep.line });
423
+ }
424
+ }
425
+ }
426
+ return affected.slice(0, 10);
427
+ } catch {
428
+ return [];
429
+ }
430
+ }
431
+ function matchAnnotation(db, file, line) {
432
+ try {
433
+ const node = db.prepare(
434
+ `SELECT display_name, kind, callees, callers
435
+ FROM hex_line_symbol_annotations
436
+ WHERE file = ? AND line_start <= ? AND line_end >= ?
437
+ ORDER BY line_start DESC
438
+ LIMIT 1`
439
+ ).get(file, line, line);
440
+ if (!node) return null;
441
+ const kindShort = { function: "fn", class: "cls", method: "mtd", variable: "var" }[node.kind] || node.kind;
442
+ if (node.callees === 0 && node.callers === 0) return `[${kindShort}]`;
443
+ return `[${kindShort} ${node.callees}\u2193 ${node.callers}\u2191]`;
444
+ } catch {
445
+ return null;
446
+ }
447
+ }
448
+ function getRelativePath(filePath) {
449
+ const root = findProjectRoot(filePath);
450
+ if (!root) return null;
451
+ return relative(root, filePath).replace(/\\/g, "/");
452
+ }
453
+ function findProjectRoot(filePath) {
454
+ let dir = dirname2(filePath);
455
+ for (let i = 0; i < 10; i++) {
456
+ if (existsSync2(join3(dir, ".codegraph", "index.db"))) return dir;
457
+ const parent = dirname2(dir);
458
+ if (parent === dir) break;
459
+ dir = parent;
460
+ }
461
+ dir = dirname2(filePath);
462
+ for (let i = 0; i < 10; i++) {
463
+ if (existsSync2(join3(dir, ".git"))) return dir;
464
+ const parent = dirname2(dir);
465
+ if (parent === dir) break;
466
+ dir = parent;
467
+ }
468
+ return null;
469
+ }
470
+
471
+ // lib/revisions.mjs
472
+ import { statSync as statSync3 } from "node:fs";
473
+ import { diffLines } from "diff";
474
+ var MAX_FILES = 200;
475
+ var MAX_REVISIONS_PER_FILE = 5;
476
+ var TTL_MS = 5 * 60 * 1e3;
477
+ var latestByFile = /* @__PURE__ */ new Map();
478
+ var revisionsById = /* @__PURE__ */ new Map();
479
+ var fileRevisionIds = /* @__PURE__ */ new Map();
480
+ var revisionSeq = 0;
481
+ function touchFile(filePath) {
482
+ const latest = latestByFile.get(filePath);
483
+ if (!latest) return;
484
+ latestByFile.delete(filePath);
485
+ latestByFile.set(filePath, latest);
486
+ }
487
+ function pruneExpired(now = Date.now()) {
488
+ for (const [revision, snapshot] of revisionsById) {
489
+ if (now - snapshot.createdAt <= TTL_MS) continue;
490
+ revisionsById.delete(revision);
491
+ const ids = fileRevisionIds.get(snapshot.path);
492
+ if (!ids) continue;
493
+ const next = ids.filter((id) => id !== revision);
494
+ if (next.length > 0) fileRevisionIds.set(snapshot.path, next);
495
+ else fileRevisionIds.delete(snapshot.path);
496
+ const latest = latestByFile.get(snapshot.path);
497
+ if (latest?.revision === revision) latestByFile.delete(snapshot.path);
498
+ }
499
+ while (latestByFile.size > MAX_FILES) {
500
+ const oldestPath = latestByFile.keys().next().value;
501
+ const ids = fileRevisionIds.get(oldestPath) || [];
502
+ for (const id of ids) revisionsById.delete(id);
503
+ fileRevisionIds.delete(oldestPath);
504
+ latestByFile.delete(oldestPath);
505
+ }
506
+ }
507
+ function rememberRevisionId(filePath, revision) {
508
+ const ids = fileRevisionIds.get(filePath) || [];
509
+ ids.push(revision);
510
+ while (ids.length > MAX_REVISIONS_PER_FILE) {
511
+ const removed = ids.shift();
512
+ revisionsById.delete(removed);
513
+ }
514
+ fileRevisionIds.set(filePath, ids);
515
+ }
516
+ function buildUniqueTagIndex(lineHashes) {
517
+ const index = /* @__PURE__ */ new Map();
518
+ const duplicates = /* @__PURE__ */ new Set();
519
+ for (let i = 0; i < lineHashes.length; i++) {
520
+ const tag = lineTag(lineHashes[i]);
521
+ if (duplicates.has(tag)) continue;
522
+ if (index.has(tag)) {
523
+ index.delete(tag);
524
+ duplicates.add(tag);
525
+ continue;
526
+ }
527
+ index.set(tag, i);
528
+ }
529
+ return index;
530
+ }
531
+ function computeFileChecksum(lineHashes) {
532
+ return rangeChecksum(lineHashes, 1, lineHashes.length);
533
+ }
534
+ function diffLineCount(value) {
535
+ if (!value) return 0;
536
+ return value.split("\n").length - 1;
537
+ }
538
+ function coalesceRanges(ranges) {
539
+ if (!ranges.length) return [];
540
+ const sorted = [...ranges].sort((a, b) => a.start - b.start || a.end - b.end);
541
+ const merged = [sorted[0]];
542
+ for (let i = 1; i < sorted.length; i++) {
543
+ const prev = merged[merged.length - 1];
544
+ const curr = sorted[i];
545
+ if (curr.start <= prev.end + 1) {
546
+ prev.end = Math.max(prev.end, curr.end);
547
+ prev.kind = prev.kind === curr.kind ? prev.kind : "mixed";
548
+ continue;
549
+ }
550
+ merged.push({ ...curr });
551
+ }
552
+ return merged;
553
+ }
554
+ function computeChangedRanges(oldLines, newLines) {
555
+ const oldText = oldLines.join("\n") + "\n";
556
+ const newText = newLines.join("\n") + "\n";
557
+ const parts = diffLines(oldText, newText);
558
+ const ranges = [];
559
+ let oldNum = 1;
560
+ let newNum = 1;
561
+ for (let i = 0; i < parts.length; i++) {
562
+ const part = parts[i];
563
+ const count = diffLineCount(part.value);
564
+ if (part.removed) {
565
+ const next = parts[i + 1];
566
+ if (next?.added) {
567
+ const addedCount = diffLineCount(next.value);
568
+ ranges.push({
569
+ start: newNum,
570
+ end: addedCount > 0 ? newNum + addedCount - 1 : newNum,
571
+ kind: "replace"
572
+ });
573
+ oldNum += count;
574
+ newNum += addedCount;
575
+ i++;
576
+ continue;
577
+ }
578
+ ranges.push({ start: newNum, end: newNum, kind: "delete" });
579
+ oldNum += count;
580
+ continue;
581
+ }
582
+ if (part.added) {
583
+ ranges.push({
584
+ start: newNum,
585
+ end: count > 0 ? newNum + count - 1 : newNum,
586
+ kind: "insert"
587
+ });
588
+ newNum += count;
589
+ continue;
590
+ }
591
+ oldNum += count;
592
+ newNum += count;
593
+ }
594
+ return coalesceRanges(ranges);
595
+ }
596
+ function describeChangedRanges(ranges) {
597
+ if (!ranges?.length) return "none";
598
+ return ranges.map((r) => `${r.start}-${r.end}${r.kind ? `(${r.kind})` : ""}`).join(", ");
599
+ }
600
+ function createSnapshot(filePath, content, mtimeMs, size, prevSnapshot = null) {
601
+ const lines = content.split("\n");
602
+ const lineHashes = lines.map((line) => fnv1a(line));
603
+ const fileChecksum = computeFileChecksum(lineHashes);
604
+ const revision = `rev-${++revisionSeq}-${fileChecksum.split(":")[1]}`;
605
+ return {
606
+ revision,
607
+ path: filePath,
608
+ content,
609
+ lines,
610
+ lineHashes,
611
+ fileChecksum,
612
+ uniqueTagIndex: buildUniqueTagIndex(lineHashes),
613
+ changedRangesFromPrev: prevSnapshot ? computeChangedRanges(prevSnapshot.lines, lines) : [],
614
+ prevRevision: prevSnapshot?.revision || null,
615
+ mtimeMs,
616
+ size,
617
+ createdAt: Date.now()
618
+ };
619
+ }
620
+ function rememberSnapshot(filePath, content, meta = {}) {
621
+ pruneExpired();
622
+ const latest = latestByFile.get(filePath);
623
+ const mtimeMs = meta.mtimeMs ?? latest?.mtimeMs ?? Date.now();
624
+ const size = meta.size ?? Buffer.byteLength(content, "utf8");
625
+ if (latest && latest.content === content && latest.mtimeMs === mtimeMs && latest.size === size) {
626
+ touchFile(filePath);
627
+ return latest;
628
+ }
629
+ const snapshot = createSnapshot(filePath, content, mtimeMs, size, latest || null);
630
+ latestByFile.set(filePath, snapshot);
631
+ revisionsById.set(snapshot.revision, snapshot);
632
+ rememberRevisionId(filePath, snapshot.revision);
633
+ touchFile(filePath);
634
+ pruneExpired();
635
+ return snapshot;
636
+ }
637
+ function readSnapshot(filePath) {
638
+ pruneExpired();
639
+ const stat = statSync3(filePath);
640
+ const latest = latestByFile.get(filePath);
641
+ if (latest && latest.mtimeMs === stat.mtimeMs && latest.size === stat.size) {
642
+ touchFile(filePath);
643
+ return latest;
644
+ }
645
+ const content = readText(filePath);
646
+ return rememberSnapshot(filePath, content, { mtimeMs: stat.mtimeMs, size: stat.size });
647
+ }
648
+ function getSnapshotByRevision(revision) {
649
+ pruneExpired();
650
+ return revisionsById.get(revision) || null;
651
+ }
652
+ function overlapsChangedRanges(ranges, startLine, endLine) {
653
+ return (ranges || []).some((range) => range.start <= endLine && startLine <= range.end);
654
+ }
655
+ function buildRangeChecksum(snapshot, startLine, endLine) {
656
+ const startIdx = startLine - 1;
657
+ const endIdx = endLine - 1;
658
+ if (startIdx < 0 || endIdx >= snapshot.lineHashes.length || startIdx > endIdx) return null;
659
+ return rangeChecksum(snapshot.lineHashes.slice(startIdx, endIdx + 1), startLine, endLine);
660
+ }
661
+
662
+ // lib/read.mjs
663
+ var DEFAULT_LIMIT = 2e3;
664
+ function readFile2(filePath, opts = {}) {
665
+ filePath = normalizePath(filePath);
666
+ const real = validatePath(filePath);
667
+ const stat = statSync4(real);
668
+ if (stat.isDirectory()) {
669
+ const { text } = listDirectory(real, { metadata: true });
670
+ return `Directory: ${filePath}
671
+
672
+ \`\`\`
673
+ ${text}
674
+ \`\`\``;
675
+ }
676
+ const snapshot = rememberSnapshot(real, readText(real), { mtimeMs: stat.mtimeMs, size: stat.size });
677
+ const lines = snapshot.lines;
678
+ const total = lines.length;
679
+ let ranges;
680
+ if (opts.ranges && opts.ranges.length > 0) {
681
+ ranges = opts.ranges.map((r) => ({
682
+ start: Math.max(1, r.start || 1),
683
+ end: Math.min(total, r.end || total)
684
+ }));
685
+ } else {
686
+ const startLine = Math.max(1, opts.offset || 1);
687
+ const maxLines = opts.limit && opts.limit > 0 ? opts.limit : DEFAULT_LIMIT;
688
+ ranges = [{ start: startLine, end: Math.min(total, startLine - 1 + maxLines) }];
689
+ }
690
+ const parts = [];
691
+ let cappedAtLine = 0;
692
+ for (const range of ranges) {
693
+ const selected = lines.slice(range.start - 1, range.end);
694
+ const lineHashes = [];
695
+ const formatted = [];
696
+ let charCount = 0;
697
+ for (let i = 0; i < selected.length; i++) {
698
+ const line = selected[i];
699
+ const num = range.start + i;
700
+ const hash32 = fnv1a(line);
701
+ const entry = opts.plain ? `${num}|${line}` : `${lineTag(hash32)}.${num} ${line}`;
702
+ if (charCount + entry.length > MAX_OUTPUT_CHARS && formatted.length > 0) {
703
+ cappedAtLine = num;
704
+ break;
705
+ }
706
+ lineHashes.push(hash32);
707
+ formatted.push(entry);
708
+ charCount += entry.length + 1;
709
+ }
710
+ const actualEnd = formatted.length > 0 ? range.start + formatted.length - 1 : range.start;
711
+ range.end = actualEnd;
712
+ parts.push(formatted.join("\n"));
713
+ const cs = rangeChecksum(lineHashes, range.start, actualEnd);
714
+ parts.push(`
715
+ checksum: ${cs}`);
716
+ if (cappedAtLine) break;
717
+ }
718
+ const sizeKB = (stat.size / 1024).toFixed(1);
719
+ const mtime = stat.mtime;
720
+ const ago = relativeTime(mtime);
721
+ let header = `File: ${filePath} (${total} lines, ${sizeKB}KB, ${ago})`;
722
+ if (ranges.length === 1) {
723
+ const r = ranges[0];
724
+ if (r.start > 1 || r.end < total) {
725
+ header += ` [showing ${r.start}-${r.end}]`;
726
+ }
727
+ if (r.end < total) {
728
+ header += ` (${total - r.end} more below)`;
729
+ }
730
+ }
731
+ const db = getGraphDB(real);
732
+ const relFile = db ? getRelativePath(real) : null;
733
+ let graphLine = "";
734
+ if (db && relFile) {
735
+ const annos = fileAnnotations(db, relFile);
736
+ if (annos.length > 0) {
737
+ const items = annos.map((a) => {
738
+ const counts = a.callees || a.callers ? ` ${a.callees}\u2193 ${a.callers}\u2191` : "";
739
+ return `${a.name} [${a.kind}${counts}]`;
740
+ });
741
+ graphLine = `
742
+ Graph: ${items.join(" | ")}`;
743
+ }
744
+ }
745
+ let result = `${header}${graphLine}
746
+ revision: ${snapshot.revision}
747
+ file: ${snapshot.fileChecksum}
748
+
749
+ \`\`\`
750
+ ${parts.join("\n")}
751
+ \`\`\``;
752
+ if (total > 200 && (!opts.offset || opts.offset <= 1) && !cappedAtLine) {
753
+ result += `
754
+
755
+ \u26A1 Tip: This file has ${total} lines. Use outline first, then read_file with offset/limit for 75% fewer tokens.`;
756
+ }
757
+ if (cappedAtLine) {
758
+ result += `
759
+
760
+ OUTPUT_CAPPED at line ${cappedAtLine} (${MAX_OUTPUT_CHARS} char limit). Use offset=${cappedAtLine} to continue reading.`;
761
+ }
762
+ return result;
763
+ }
764
+
765
+ // lib/edit.mjs
766
+ import { statSync as statSync5, writeFileSync } from "node:fs";
767
+ import { diffLines as diffLines2 } from "diff";
768
+ function restoreIndent(origLines, newLines) {
769
+ if (!origLines.length || !newLines.length) return newLines;
770
+ const origIndent = origLines[0].match(/^\s*/)[0];
771
+ const newIndent = newLines[0].match(/^\s*/)[0];
772
+ if (origIndent === newIndent) return newLines;
773
+ return newLines.map((line) => {
774
+ if (!line.trim()) return line;
775
+ if (line.startsWith(newIndent)) return origIndent + line.slice(newIndent.length);
776
+ return line;
777
+ });
778
+ }
779
+ function buildErrorSnippet(lines, centerIdx, radius = 5) {
780
+ const start = Math.max(0, centerIdx - radius);
781
+ const end = Math.min(lines.length, centerIdx + radius + 1);
782
+ const text = lines.slice(start, end).map((line, i) => {
783
+ const num = start + i + 1;
784
+ const tag = lineTag(fnv1a(line));
785
+ return `${tag}.${num} ${line}`;
786
+ }).join("\n");
787
+ return { start: start + 1, end, text };
788
+ }
789
+ function findLine(lines, lineNum, expectedTag, hashIndex) {
790
+ const idx = lineNum - 1;
791
+ if (idx < 0 || idx >= lines.length) {
792
+ const center = idx >= lines.length ? lines.length - 1 : 0;
793
+ const snip2 = buildErrorSnippet(lines, center);
794
+ throw new Error(
795
+ `Line ${lineNum} out of range (1-${lines.length}).
796
+
797
+ Current content (lines ${snip2.start}-${snip2.end}):
798
+ ${snip2.text}
799
+
800
+ Tip: Use updated hashes above for retry.`
801
+ );
802
+ }
803
+ const actual = lineTag(fnv1a(lines[idx]));
804
+ if (actual === expectedTag) return idx;
805
+ for (let d = 1; d <= 5; d++) {
806
+ for (const off of [d, -d]) {
807
+ const c = idx + off;
808
+ if (c >= 0 && c < lines.length && lineTag(fnv1a(lines[c])) === expectedTag) return c;
809
+ }
810
+ }
811
+ const stripped = lines[idx].replace(/\s+/g, "");
812
+ if (stripped.length > 0) {
813
+ for (let j = Math.max(0, idx - 5); j <= Math.min(lines.length - 1, idx + 5); j++) {
814
+ if (lines[j].replace(/\s+/g, "") === stripped && lineTag(fnv1a(lines[j])) === expectedTag) return j;
815
+ }
816
+ }
817
+ const CONFUSABLE_RE = /[\u2010\u2011\u2012\u2013\u2014\u2212\uFE63\uFF0D]/g;
818
+ const norm = (t) => t.replace(CONFUSABLE_RE, "-");
819
+ const normalizedExpected = norm(expectedTag);
820
+ for (let i = Math.max(0, idx - 10); i <= Math.min(lines.length - 1, idx + 10); i++) {
821
+ const normalizedActual = norm(lineTag(fnv1a(norm(lines[i]))));
822
+ if (normalizedActual === normalizedExpected) return i;
823
+ }
824
+ if (hashIndex) {
825
+ const relocated = hashIndex.get(expectedTag);
826
+ if (relocated !== void 0) return relocated;
827
+ }
828
+ const snip = buildErrorSnippet(lines, idx);
829
+ throw new Error(
830
+ `HASH_MISMATCH: line ${lineNum} expected ${expectedTag}, got ${actual}.
831
+
832
+ Current content (lines ${snip.start}-${snip.end}):
833
+ ${snip.text}
834
+
835
+ Tip: Use updated hashes above for retry.`
836
+ );
837
+ }
838
+ function simpleDiff(oldLines, newLines, ctx = 3) {
839
+ const oldText = oldLines.join("\n") + "\n";
840
+ const newText = newLines.join("\n") + "\n";
841
+ const parts = diffLines2(oldText, newText);
842
+ const out = [];
843
+ let oldNum = 1, newNum = 1;
844
+ let lastChange = false;
845
+ for (let i = 0; i < parts.length; i++) {
846
+ const part = parts[i];
847
+ const lines = part.value.replace(/\n$/, "").split("\n");
848
+ if (part.added || part.removed) {
849
+ for (const line of lines) {
850
+ if (part.removed) {
851
+ out.push(`-${oldNum}| ${line}`);
852
+ oldNum++;
853
+ } else {
854
+ out.push(`+${newNum}| ${line}`);
855
+ newNum++;
856
+ }
857
+ }
858
+ lastChange = true;
859
+ } else {
860
+ const next = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);
861
+ if (lastChange || next) {
862
+ let start = 0, end = lines.length;
863
+ if (!lastChange) start = Math.max(0, end - ctx);
864
+ if (!next && end - start > ctx) end = start + ctx;
865
+ if (start > 0) {
866
+ out.push("...");
867
+ oldNum += start;
868
+ newNum += start;
869
+ }
870
+ for (let k = start; k < end; k++) {
871
+ out.push(` ${oldNum}| ${lines[k]}`);
872
+ oldNum++;
873
+ newNum++;
874
+ }
875
+ if (end < lines.length) {
876
+ out.push("...");
877
+ oldNum += lines.length - end;
878
+ newNum += lines.length - end;
879
+ }
880
+ } else {
881
+ oldNum += lines.length;
882
+ newNum += lines.length;
883
+ }
884
+ lastChange = false;
885
+ }
886
+ }
887
+ return out.length ? out.join("\n") : null;
888
+ }
889
+ function verifyChecksumAgainstSnapshot(snapshot, rc) {
890
+ const { start, end, hex } = parseChecksum(rc);
891
+ const actual = buildRangeChecksum(snapshot, start, end);
892
+ if (!actual) return { ok: false, actual: null, start, end };
893
+ return { ok: actual.split(":")[1] === hex, actual, start, end };
894
+ }
895
+ function buildConflictMessage({
896
+ filePath,
897
+ reason,
898
+ revision,
899
+ fileChecksum,
900
+ lines,
901
+ centerIdx,
902
+ changedRanges,
903
+ retryChecksum,
904
+ details
905
+ }) {
906
+ const safeCenter = Math.max(0, Math.min(lines.length - 1, centerIdx));
907
+ const snip = buildErrorSnippet(lines, safeCenter);
908
+ let msg = `status: CONFLICT
909
+ reason: ${reason}
910
+ revision: ${revision}
911
+ file: ${fileChecksum}`;
912
+ if (changedRanges) msg += `
913
+ changed_ranges: ${describeChangedRanges(changedRanges)}`;
914
+ if (retryChecksum) msg += `
915
+ retry_checksum: ${retryChecksum}`;
916
+ msg += `
917
+
918
+ ${details}
919
+
920
+ Current content (lines ${snip.start}-${snip.end}):
921
+ ${snip.text}`;
922
+ msg += `
923
+
924
+ Tip: Retry from the fresh local snippet above.`;
925
+ if (filePath) msg += `
926
+ path: ${filePath}`;
927
+ return msg;
928
+ }
929
+ function targetRangeForReplaceBetween(startIdx, endIdx, boundaryMode) {
930
+ if (boundaryMode === "exclusive") {
931
+ return { start: startIdx + 2, end: Math.max(startIdx + 1, endIdx) };
932
+ }
933
+ return { start: startIdx + 1, end: endIdx + 1 };
934
+ }
935
+ function editFile(filePath, edits, opts = {}) {
936
+ filePath = normalizePath(filePath);
937
+ const real = validatePath(filePath);
938
+ const currentSnapshot = readSnapshot(real);
939
+ const baseSnapshot = opts.baseRevision ? getSnapshotByRevision(opts.baseRevision) : null;
940
+ const hasBaseSnapshot = !!(baseSnapshot && baseSnapshot.path === real);
941
+ const staleRevision = !!opts.baseRevision && opts.baseRevision !== currentSnapshot.revision;
942
+ const changedRanges = staleRevision && hasBaseSnapshot ? computeChangedRanges(baseSnapshot.lines, currentSnapshot.lines) : [];
943
+ const conflictPolicy = opts.conflictPolicy || "conservative";
944
+ const original = currentSnapshot.content;
945
+ const lines = [...currentSnapshot.lines];
946
+ const origLines = [...currentSnapshot.lines];
947
+ const hadTrailingNewline = original.endsWith("\n");
948
+ const hashIndex = currentSnapshot.uniqueTagIndex;
949
+ let autoRebased = false;
950
+ const anchored = [];
951
+ for (const e of edits) {
952
+ if (e.set_line || e.replace_lines || e.insert_after || e.replace_between) anchored.push(e);
953
+ else if (e.replace) throw new Error("REPLACE_REMOVED: replace is no longer supported in edit_file. Use set_line/replace_lines for single edits, bulk_replace tool for rename/refactor.");
954
+ else throw new Error(`BAD_INPUT: unknown edit type: ${JSON.stringify(e)}`);
955
+ }
956
+ const editTargets = [];
957
+ for (const e of anchored) {
958
+ if (e.set_line) {
959
+ const line = parseRef(e.set_line.anchor).line;
960
+ editTargets.push({ start: line, end: line });
961
+ } else if (e.replace_lines) {
962
+ const s = parseRef(e.replace_lines.start_anchor).line;
963
+ const en = parseRef(e.replace_lines.end_anchor).line;
964
+ editTargets.push({ start: s, end: en });
965
+ } else if (e.insert_after) {
966
+ const line = parseRef(e.insert_after.anchor).line;
967
+ editTargets.push({ start: line, end: line, insert: true });
968
+ } else if (e.replace_between) {
969
+ const s = parseRef(e.replace_between.start_anchor).line;
970
+ const en = parseRef(e.replace_between.end_anchor).line;
971
+ editTargets.push({ start: s, end: en });
972
+ }
973
+ }
974
+ for (let i = 0; i < editTargets.length; i++) {
975
+ for (let j = i + 1; j < editTargets.length; j++) {
976
+ const a = editTargets[i], b = editTargets[j];
977
+ if (a.insert || b.insert) continue;
978
+ if (a.start <= b.end && b.start <= a.end) {
979
+ throw new Error(
980
+ `OVERLAPPING_EDITS: lines ${a.start}-${a.end} and ${b.start}-${b.end} overlap. Split into separate edit_file calls.`
981
+ );
982
+ }
983
+ }
984
+ }
985
+ const sorted = anchored.map((e) => {
986
+ let sortKey;
987
+ if (e.set_line) sortKey = parseRef(e.set_line.anchor).line;
988
+ else if (e.replace_lines) sortKey = parseRef(e.replace_lines.start_anchor).line;
989
+ else if (e.insert_after) sortKey = parseRef(e.insert_after.anchor).line;
990
+ else if (e.replace_between) sortKey = parseRef(e.replace_between.start_anchor).line;
991
+ return { ...e, _k: sortKey };
992
+ }).sort((a, b) => b._k - a._k);
993
+ const conflictIfNeeded = (reason, centerIdx, retryChecksum, details) => {
994
+ if (conflictPolicy !== "conservative") {
995
+ throw new Error(details);
996
+ }
997
+ return buildConflictMessage({
998
+ filePath,
999
+ reason,
1000
+ revision: currentSnapshot.revision,
1001
+ fileChecksum: currentSnapshot.fileChecksum,
1002
+ lines,
1003
+ centerIdx,
1004
+ changedRanges: staleRevision && hasBaseSnapshot ? changedRanges : null,
1005
+ retryChecksum,
1006
+ details
1007
+ });
1008
+ };
1009
+ const locateOrConflict = (ref, reason = "stale_anchor") => {
1010
+ try {
1011
+ return findLine(lines, ref.line, ref.tag, hashIndex);
1012
+ } catch (e) {
1013
+ if (conflictPolicy !== "conservative" || !staleRevision) throw e;
1014
+ const centerIdx = Math.max(0, Math.min(lines.length - 1, ref.line - 1));
1015
+ return conflictIfNeeded(reason, centerIdx, null, e.message);
1016
+ }
1017
+ };
1018
+ const ensureRevisionContext = (actualStart, actualEnd, centerIdx) => {
1019
+ if (!staleRevision || conflictPolicy !== "conservative") return null;
1020
+ if (!hasBaseSnapshot) {
1021
+ return conflictIfNeeded(
1022
+ "base_revision_evicted",
1023
+ centerIdx,
1024
+ null,
1025
+ `Base revision ${opts.baseRevision} is not available in the local revision cache.`
1026
+ );
1027
+ }
1028
+ if (overlapsChangedRanges(changedRanges, actualStart, actualEnd)) {
1029
+ return conflictIfNeeded(
1030
+ "overlap",
1031
+ centerIdx,
1032
+ null,
1033
+ `Changes since ${opts.baseRevision} overlap edit range ${actualStart}-${actualEnd}.`
1034
+ );
1035
+ }
1036
+ autoRebased = true;
1037
+ return null;
1038
+ };
1039
+ for (const e of sorted) {
1040
+ if (e.set_line) {
1041
+ const { tag, line } = parseRef(e.set_line.anchor);
1042
+ const idx = locateOrConflict({ tag, line });
1043
+ if (typeof idx === "string") return idx;
1044
+ const conflict = ensureRevisionContext(idx + 1, idx + 1, idx);
1045
+ if (conflict) return conflict;
1046
+ const txt = e.set_line.new_text;
1047
+ if (!txt && txt !== 0) {
1048
+ lines.splice(idx, 1);
1049
+ } else {
1050
+ const origLine = [lines[idx]];
1051
+ const raw = String(txt).split("\n");
1052
+ const newLines = opts.restoreIndent ? restoreIndent(origLine, raw) : raw;
1053
+ lines.splice(idx, 1, ...newLines);
1054
+ }
1055
+ continue;
1056
+ }
1057
+ if (e.insert_after) {
1058
+ const { tag, line } = parseRef(e.insert_after.anchor);
1059
+ const idx = locateOrConflict({ tag, line });
1060
+ if (typeof idx === "string") return idx;
1061
+ const conflict = ensureRevisionContext(idx + 1, idx + 1, idx);
1062
+ if (conflict) return conflict;
1063
+ let insertLines = e.insert_after.text.split("\n");
1064
+ if (opts.restoreIndent) insertLines = restoreIndent([lines[idx]], insertLines);
1065
+ lines.splice(idx + 1, 0, ...insertLines);
1066
+ continue;
1067
+ }
1068
+ if (e.replace_lines) {
1069
+ const s = parseRef(e.replace_lines.start_anchor);
1070
+ const en = parseRef(e.replace_lines.end_anchor);
1071
+ const si = locateOrConflict(s);
1072
+ if (typeof si === "string") return si;
1073
+ const ei = locateOrConflict(en);
1074
+ if (typeof ei === "string") return ei;
1075
+ const actualStart = si + 1;
1076
+ const actualEnd = ei + 1;
1077
+ const rc = e.replace_lines.range_checksum;
1078
+ if (!rc) throw new Error("range_checksum required for replace_lines. Read the range first via read_file, then pass its checksum.");
1079
+ if (staleRevision && conflictPolicy === "conservative") {
1080
+ const conflict = ensureRevisionContext(actualStart, actualEnd, si);
1081
+ if (conflict) return conflict;
1082
+ const baseCheck = hasBaseSnapshot ? verifyChecksumAgainstSnapshot(baseSnapshot, rc) : null;
1083
+ if (!baseCheck?.ok) {
1084
+ return conflictIfNeeded(
1085
+ "stale_checksum",
1086
+ si,
1087
+ baseCheck?.actual || null,
1088
+ baseCheck?.actual ? `Provided checksum ${rc} does not match base revision ${opts.baseRevision}.` : `Checksum range from ${rc} is outside the available base revision.`
1089
+ );
1090
+ }
1091
+ } else {
1092
+ const { start: csStart, end: csEnd, hex: csHex } = parseChecksum(rc);
1093
+ if (csStart > actualStart || csEnd < actualEnd) {
1094
+ const snip = buildErrorSnippet(origLines, actualStart - 1);
1095
+ throw new Error(
1096
+ `CHECKSUM_RANGE_GAP: range ${csStart}-${csEnd} does not cover edit range ${actualStart}-${actualEnd}.
1097
+
1098
+ Current content (lines ${snip.start}-${snip.end}):
1099
+ ${snip.text}
1100
+
1101
+ Tip: Use updated hashes above for retry.`
1102
+ );
1103
+ }
1104
+ const actual = buildRangeChecksum(currentSnapshot, csStart, csEnd);
1105
+ const actualHex = actual?.split(":")[1];
1106
+ if (!actual || csHex !== actualHex) {
1107
+ const details = `CHECKSUM_MISMATCH: expected ${rc}, got ${actual}. File changed \u2014 re-read lines ${csStart}-${csEnd}.`;
1108
+ if (conflictPolicy === "conservative") {
1109
+ return conflictIfNeeded("stale_checksum", csStart - 1, actual, details);
1110
+ }
1111
+ const snip = buildErrorSnippet(origLines, csStart - 1);
1112
+ throw new Error(
1113
+ `${details}
1114
+
1115
+ Current content (lines ${snip.start}-${snip.end}):
1116
+ ${snip.text}
1117
+
1118
+ Retry with fresh checksum ${actual}, or use set_line with hashes above.`
1119
+ );
1120
+ }
1121
+ }
1122
+ const txt = e.replace_lines.new_text;
1123
+ if (!txt && txt !== 0) {
1124
+ lines.splice(si, ei - si + 1);
1125
+ } else {
1126
+ const origRange = lines.slice(si, ei + 1);
1127
+ let newLines = String(txt).split("\n");
1128
+ if (opts.restoreIndent) newLines = restoreIndent(origRange, newLines);
1129
+ lines.splice(si, ei - si + 1, ...newLines);
1130
+ }
1131
+ continue;
1132
+ }
1133
+ if (e.replace_between) {
1134
+ const boundaryMode = e.replace_between.boundary_mode || "inclusive";
1135
+ if (boundaryMode !== "inclusive" && boundaryMode !== "exclusive") {
1136
+ throw new Error(`BAD_INPUT: replace_between boundary_mode must be inclusive or exclusive, got ${boundaryMode}`);
1137
+ }
1138
+ const s = parseRef(e.replace_between.start_anchor);
1139
+ const en = parseRef(e.replace_between.end_anchor);
1140
+ const si = locateOrConflict(s);
1141
+ if (typeof si === "string") return si;
1142
+ const ei = locateOrConflict(en);
1143
+ if (typeof ei === "string") return ei;
1144
+ if (si > ei) {
1145
+ throw new Error(`BAD_INPUT: replace_between start anchor resolves after end anchor (${si + 1} > ${ei + 1})`);
1146
+ }
1147
+ const targetRange = targetRangeForReplaceBetween(si, ei, boundaryMode);
1148
+ const conflict = ensureRevisionContext(targetRange.start, targetRange.end, si);
1149
+ if (conflict) return conflict;
1150
+ const txt = e.replace_between.new_text;
1151
+ let newLines = String(txt ?? "").split("\n");
1152
+ const sliceStart = boundaryMode === "exclusive" ? si + 1 : si;
1153
+ const removeCount = boundaryMode === "exclusive" ? Math.max(0, ei - si - 1) : ei - si + 1;
1154
+ const origRange = lines.slice(sliceStart, sliceStart + removeCount);
1155
+ if (opts.restoreIndent && origRange.length > 0) newLines = restoreIndent(origRange, newLines);
1156
+ if (txt === "" || txt === null) newLines = [];
1157
+ lines.splice(sliceStart, removeCount, ...newLines);
1158
+ }
1159
+ }
1160
+ let content = lines.join("\n");
1161
+ if (hadTrailingNewline && !content.endsWith("\n")) content += "\n";
1162
+ if (!hadTrailingNewline && content.endsWith("\n")) content = content.slice(0, -1);
1163
+ if (original === content) {
1164
+ throw new Error("NOOP_EDIT: File already contains the desired content. No changes needed.");
1165
+ }
1166
+ let diff = simpleDiff(origLines, content.split("\n"));
1167
+ if (diff && diff.length > 8e4) {
1168
+ diff = diff.slice(0, 8e4) + `
1169
+ ... (diff truncated, ${diff.length} chars total)`;
1170
+ }
1171
+ if (opts.dryRun) {
1172
+ let msg2 = `status: ${autoRebased ? "AUTO_REBASED" : "OK"}
1173
+ revision: ${currentSnapshot.revision}
1174
+ file: ${currentSnapshot.fileChecksum}
1175
+ Dry run: ${filePath} would change (${content.split("\n").length} lines)`;
1176
+ if (staleRevision && hasBaseSnapshot) msg2 += `
1177
+ changed_ranges: ${describeChangedRanges(changedRanges)}`;
1178
+ if (diff) msg2 += `
1179
+
1180
+ Diff:
1181
+ \`\`\`diff
1182
+ ${diff}
1183
+ \`\`\``;
1184
+ return msg2;
1185
+ }
1186
+ writeFileSync(real, content, "utf-8");
1187
+ const nextStat = statSync5(real);
1188
+ const nextSnapshot = rememberSnapshot(real, content, { mtimeMs: nextStat.mtimeMs, size: nextStat.size });
1189
+ let msg = `status: ${autoRebased ? "AUTO_REBASED" : "OK"}
1190
+ revision: ${nextSnapshot.revision}
1191
+ file: ${nextSnapshot.fileChecksum}`;
1192
+ if (autoRebased && staleRevision && hasBaseSnapshot) {
1193
+ msg += `
1194
+ changed_ranges: ${describeChangedRanges(changedRanges)}`;
1195
+ }
1196
+ msg += `
1197
+ Updated ${filePath} (${content.split("\n").length} lines)`;
1198
+ if (diff) msg += `
1199
+
1200
+ Diff:
1201
+ \`\`\`diff
1202
+ ${diff}
1203
+ \`\`\``;
1204
+ try {
1205
+ const db = getGraphDB(real);
1206
+ const relFile = db ? getRelativePath(real) : null;
1207
+ if (db && relFile && diff) {
1208
+ const diffLinesOut = diff.split("\n");
1209
+ let minLine = Infinity, maxLine = 0;
1210
+ for (const dl of diffLinesOut) {
1211
+ const m = dl.match(/^[+-](\d+)\|/);
1212
+ if (m) {
1213
+ const n = +m[1];
1214
+ if (n < minLine) minLine = n;
1215
+ if (n > maxLine) maxLine = n;
1216
+ }
1217
+ }
1218
+ if (minLine <= maxLine) {
1219
+ const affected = callImpact(db, relFile, minLine, maxLine);
1220
+ if (affected.length > 0) {
1221
+ const list = affected.map((a) => `${a.name} (${a.file}:${a.line})`).join(", ");
1222
+ msg += `
1223
+
1224
+ \u26A0 Call impact: ${affected.length} callers in other files
1225
+ ${list}`;
1226
+ }
1227
+ }
1228
+ }
1229
+ } catch {
1230
+ }
1231
+ const newLinesAll = content.split("\n");
1232
+ if (diff) {
1233
+ const diffArr = diff.split("\n");
1234
+ let minLine = Infinity, maxLine = 0;
1235
+ for (const dl of diffArr) {
1236
+ const m = dl.match(/^[+-](\d+)\|/);
1237
+ if (m) {
1238
+ const n = +m[1];
1239
+ if (n < minLine) minLine = n;
1240
+ if (n > maxLine) maxLine = n;
1241
+ }
1242
+ }
1243
+ if (minLine <= maxLine) {
1244
+ const ctxStart = Math.max(0, minLine - 6);
1245
+ const ctxEnd = Math.min(newLinesAll.length, maxLine + 5);
1246
+ const ctxLines = [];
1247
+ const ctxHashes = [];
1248
+ for (let i = ctxStart; i < ctxEnd; i++) {
1249
+ const h = fnv1a(newLinesAll[i]);
1250
+ ctxHashes.push(h);
1251
+ ctxLines.push(`${lineTag(h)}.${i + 1} ${newLinesAll[i]}`);
1252
+ }
1253
+ const ctxCs = rangeChecksum(ctxHashes, ctxStart + 1, ctxEnd);
1254
+ msg += `
1255
+
1256
+ Post-edit (lines ${ctxStart + 1}-${ctxEnd}):
1257
+ ${ctxLines.join("\n")}
1258
+ checksum: ${ctxCs}`;
1259
+ }
1260
+ }
1261
+ return msg;
1262
+ }
1263
+
1264
+ // lib/search.mjs
1265
+ import { spawn } from "node:child_process";
1266
+ import { resolve as resolve2 } from "node:path";
1267
+ var rgBin = "rg";
1268
+ try {
1269
+ rgBin = (await import("@vscode/ripgrep")).rgPath;
1270
+ } catch {
1271
+ }
1272
+ var DEFAULT_LIMIT2 = 100;
1273
+ var MAX_OUTPUT = 10 * 1024 * 1024;
1274
+ var TIMEOUT2 = 3e4;
1275
+ function spawnRg(args) {
1276
+ return new Promise((resolve_, reject) => {
1277
+ let stdout = "";
1278
+ let totalBytes = 0;
1279
+ let killed = false;
1280
+ let stderrBuf = "";
1281
+ const child = spawn(rgBin, args, { timeout: TIMEOUT2 });
1282
+ child.stdout.on("data", (chunk) => {
1283
+ totalBytes += chunk.length;
1284
+ if (totalBytes > MAX_OUTPUT) {
1285
+ killed = true;
1286
+ child.kill();
1287
+ return;
1288
+ }
1289
+ stdout += chunk.toString("utf-8");
1290
+ });
1291
+ child.stderr.on("data", (chunk) => {
1292
+ stderrBuf += chunk.toString("utf-8");
1293
+ });
1294
+ child.on("error", (err) => {
1295
+ reject(new Error(`rg spawn error: ${err.message}`));
1296
+ });
1297
+ child.on("close", (code) => {
1298
+ resolve_({ stdout, code, stderr: stderrBuf, killed });
1299
+ });
1300
+ });
1301
+ }
1302
+ function grepSearch(pattern, opts = {}) {
1303
+ const normPath = normalizePath(opts.path || "");
1304
+ const target = normPath ? resolve2(normPath) : process.cwd();
1305
+ const output = opts.output || "content";
1306
+ const plain = !!opts.plain;
1307
+ const totalLimit = opts.totalLimit && opts.totalLimit > 0 ? opts.totalLimit : 0;
1308
+ if (output === "files") return filesMode(pattern, target, opts);
1309
+ if (output === "count") return countMode(pattern, target, opts);
1310
+ return contentMode(pattern, target, opts, plain, totalLimit);
1311
+ }
1312
+ async function filesMode(pattern, target, opts) {
1313
+ const realArgs = ["-l"];
1314
+ if (opts.caseInsensitive) realArgs.push("-i");
1315
+ else if (opts.smartCase) realArgs.push("-S");
1316
+ if (opts.literal) realArgs.push("-F");
1317
+ if (opts.multiline) realArgs.push("-U", "--multiline-dotall");
1318
+ if (opts.glob) realArgs.push("--glob", opts.glob);
1319
+ if (opts.type) realArgs.push("--type", opts.type);
1320
+ realArgs.push("--", pattern, target);
1321
+ const { stdout, code, stderr, killed } = await spawnRg(realArgs);
1322
+ if (killed) return "GREP_OUTPUT_TRUNCATED: exceeded 10MB. Use specific glob/path.";
1323
+ if (code === 1) return "No matches found.";
1324
+ if (code !== 0 && code !== null) throw new Error(`GREP_ERROR: rg exit ${code} \u2014 ${stderr.trim() || "unknown error"}`);
1325
+ const lines = stdout.trimEnd().split("\n").filter(Boolean);
1326
+ const normalized = lines.map((l) => l.replace(/\\/g, "/"));
1327
+ return `\`\`\`
1328
+ ${normalized.join("\n")}
1329
+ \`\`\``;
1330
+ }
1331
+ async function countMode(pattern, target, opts) {
1332
+ const realArgs = ["-c"];
1333
+ if (opts.caseInsensitive) realArgs.push("-i");
1334
+ else if (opts.smartCase) realArgs.push("-S");
1335
+ if (opts.literal) realArgs.push("-F");
1336
+ if (opts.multiline) realArgs.push("-U", "--multiline-dotall");
1337
+ if (opts.glob) realArgs.push("--glob", opts.glob);
1338
+ if (opts.type) realArgs.push("--type", opts.type);
1339
+ realArgs.push("--", pattern, target);
1340
+ const { stdout, code, stderr, killed } = await spawnRg(realArgs);
1341
+ if (killed) return "GREP_OUTPUT_TRUNCATED: exceeded 10MB. Use specific glob/path.";
1342
+ if (code === 1) return "No matches found.";
1343
+ if (code !== 0 && code !== null) throw new Error(`GREP_ERROR: rg exit ${code} \u2014 ${stderr.trim() || "unknown error"}`);
1344
+ const lines = stdout.trimEnd().split("\n").filter(Boolean);
1345
+ const normalized = lines.map((l) => l.replace(/\\/g, "/"));
1346
+ return `\`\`\`
1347
+ ${normalized.join("\n")}
1348
+ \`\`\``;
1349
+ }
1350
+ async function contentMode(pattern, target, opts, plain, totalLimit) {
1351
+ const realArgs = ["--json"];
1352
+ if (opts.caseInsensitive) realArgs.push("-i");
1353
+ else if (opts.smartCase) realArgs.push("-S");
1354
+ if (opts.literal) realArgs.push("-F");
1355
+ if (opts.multiline) realArgs.push("-U", "--multiline-dotall");
1356
+ if (opts.glob) realArgs.push("--glob", opts.glob);
1357
+ if (opts.type) realArgs.push("--type", opts.type);
1358
+ if (opts.context && opts.context > 0) realArgs.push("-C", String(opts.context));
1359
+ if (opts.contextBefore && opts.contextBefore > 0) realArgs.push("-B", String(opts.contextBefore));
1360
+ if (opts.contextAfter && opts.contextAfter > 0) realArgs.push("-A", String(opts.contextAfter));
1361
+ const limit = opts.limit && opts.limit > 0 ? opts.limit : DEFAULT_LIMIT2;
1362
+ realArgs.push("-m", String(limit));
1363
+ realArgs.push("--", pattern, target);
1364
+ const { stdout, code, stderr, killed } = await spawnRg(realArgs);
1365
+ if (killed) return "GREP_OUTPUT_TRUNCATED: exceeded 10MB. Use specific glob/path.";
1366
+ if (code === 1) return "No matches found.";
1367
+ if (code !== 0 && code !== null) throw new Error(`GREP_ERROR: rg exit ${code} \u2014 ${stderr.trim() || "unknown error"}`);
1368
+ const jsonLines = stdout.trimEnd().split("\n").filter(Boolean);
1369
+ const formatted = [];
1370
+ const db = getGraphDB(target);
1371
+ const relCache = /* @__PURE__ */ new Map();
1372
+ let groupFile = null;
1373
+ let groupLines = [];
1374
+ let matchCount = 0;
1375
+ function flushGroup() {
1376
+ if (groupLines.length === 0) return;
1377
+ const sorted = [...groupLines].sort((a, b) => a.lineNum - b.lineNum);
1378
+ const start = sorted[0].lineNum;
1379
+ const end = sorted[sorted.length - 1].lineNum;
1380
+ const hashes = sorted.map((l) => l.hash32);
1381
+ const cs = rangeChecksum(hashes, start, end);
1382
+ formatted.push(`checksum: ${cs}`);
1383
+ groupLines = [];
1384
+ }
1385
+ for (const jl of jsonLines) {
1386
+ let msg;
1387
+ try {
1388
+ msg = JSON.parse(jl);
1389
+ } catch {
1390
+ continue;
1391
+ }
1392
+ if (msg.type === "begin" || msg.type === "end" || msg.type === "summary") {
1393
+ if (msg.type === "end") {
1394
+ flushGroup();
1395
+ groupFile = null;
1396
+ }
1397
+ if (msg.type === "begin") {
1398
+ if (formatted.length > 0 && formatted[formatted.length - 1] !== "") {
1399
+ formatted.push("");
1400
+ }
1401
+ }
1402
+ continue;
1403
+ }
1404
+ if (msg.type !== "match" && msg.type !== "context") continue;
1405
+ const data = msg.data;
1406
+ const filePath = (data.path?.text || "").replace(/\\/g, "/");
1407
+ const lineNum = data.line_number;
1408
+ if (!lineNum) continue;
1409
+ let content = data.lines?.text;
1410
+ if (content === void 0 && data.lines?.bytes) {
1411
+ content = Buffer.from(data.lines.bytes, "base64").toString("utf-8");
1412
+ }
1413
+ if (content === void 0) continue;
1414
+ content = content.replace(/\n$/, "");
1415
+ const subLines = content.split("\n");
1416
+ if (filePath !== groupFile) {
1417
+ flushGroup();
1418
+ groupFile = filePath;
1419
+ }
1420
+ for (let i = 0; i < subLines.length; i++) {
1421
+ const ln = lineNum + i;
1422
+ const lineContent = subLines[i];
1423
+ const hash32 = fnv1a(lineContent);
1424
+ const tag = lineTag(hash32);
1425
+ if (groupLines.length > 0) {
1426
+ const lastLn = groupLines[groupLines.length - 1].lineNum;
1427
+ if (ln > lastLn + 1) flushGroup();
1428
+ }
1429
+ groupLines.push({ lineNum: ln, hash32 });
1430
+ const isMatch = msg.type === "match";
1431
+ if (plain) {
1432
+ formatted.push(`${filePath}:${ln}:${lineContent}`);
1433
+ } else {
1434
+ let anno = "";
1435
+ if (db && isMatch) {
1436
+ let rel = relCache.get(filePath);
1437
+ if (rel === void 0) {
1438
+ rel = getRelativePath(resolve2(filePath)) || "";
1439
+ relCache.set(filePath, rel);
1440
+ }
1441
+ if (rel) {
1442
+ const a = matchAnnotation(db, rel, ln);
1443
+ if (a) anno = ` ${a}`;
1444
+ }
1445
+ }
1446
+ const prefix = isMatch ? ">>" : " ";
1447
+ formatted.push(`${filePath}:${prefix}${tag}.${ln} ${lineContent}${anno}`);
1448
+ }
1449
+ }
1450
+ if (msg.type === "match") {
1451
+ matchCount++;
1452
+ if (totalLimit > 0 && matchCount >= totalLimit) {
1453
+ flushGroup();
1454
+ formatted.push(`--- total_limit reached (${totalLimit}) ---`);
1455
+ return `\`\`\`
1456
+ ${formatted.join("\n")}
1457
+ \`\`\``;
1458
+ }
1459
+ }
1460
+ }
1461
+ flushGroup();
1462
+ return `\`\`\`
1463
+ ${formatted.join("\n")}
1464
+ \`\`\``;
1465
+ }
1466
+
1467
+ // lib/outline.mjs
1468
+ import { extname } from "node:path";
1469
+
1470
+ // ../hex-common/src/parser/tree-sitter.mjs
1471
+ import { createRequire as createRequire2 } from "node:module";
1472
+ import { resolve as resolve3 } from "node:path";
1473
+ var parserInstance = null;
1474
+ var languageCache = /* @__PURE__ */ new Map();
1475
+ async function getParser() {
1476
+ if (parserInstance) return parserInstance;
1477
+ const { Parser } = await import("web-tree-sitter");
1478
+ await Parser.init();
1479
+ parserInstance = new Parser();
1480
+ return parserInstance;
1481
+ }
1482
+ async function getLanguage(grammar) {
1483
+ if (languageCache.has(grammar)) return languageCache.get(grammar);
1484
+ await getParser();
1485
+ const { Language } = await import("web-tree-sitter");
1486
+ const require2 = createRequire2(import.meta.url);
1487
+ const wasmPath = resolve3(
1488
+ require2.resolve("tree-sitter-wasms/package.json"),
1489
+ "..",
1490
+ "out",
1491
+ `tree-sitter-${grammar}.wasm`
1492
+ );
1493
+ const lang = await Language.load(wasmPath);
1494
+ languageCache.set(grammar, lang);
1495
+ return lang;
1496
+ }
1497
+
1498
+ // ../hex-common/src/parser/languages.mjs
1499
+ var EXTENSION_GRAMMARS = {
1500
+ ".js": "javascript",
1501
+ ".mjs": "javascript",
1502
+ ".cjs": "javascript",
1503
+ ".jsx": "javascript",
1504
+ ".ts": "typescript",
1505
+ ".tsx": "tsx",
1506
+ ".py": "python",
1507
+ ".go": "go",
1508
+ ".rs": "rust",
1509
+ ".java": "java",
1510
+ ".c": "c",
1511
+ ".h": "c",
1512
+ ".cpp": "cpp",
1513
+ ".cs": "c_sharp",
1514
+ ".rb": "ruby",
1515
+ ".php": "php",
1516
+ ".kt": "kotlin",
1517
+ ".swift": "swift",
1518
+ ".sh": "bash",
1519
+ ".bash": "bash"
1520
+ };
1521
+ function grammarForExtension(ext) {
1522
+ return EXTENSION_GRAMMARS[ext.toLowerCase()] || null;
1523
+ }
1524
+ function supportedExtensions() {
1525
+ return Object.keys(EXTENSION_GRAMMARS);
1526
+ }
1527
+
1528
+ // ../hex-common/src/text/file-text.mjs
1529
+ import { readFileSync as readFileSync2 } from "node:fs";
1530
+ function normalizeSourceText(text) {
1531
+ return text.replace(/\r\n/g, "\n");
1532
+ }
1533
+ function readUtf8Normalized(filePath) {
1534
+ return normalizeSourceText(readFileSync2(filePath, "utf-8"));
1535
+ }
1536
+
1537
+ // lib/outline.mjs
1538
+ var LANG_CONFIGS = {
1539
+ ".js": { outline: ["function_declaration", "class_declaration", "variable_declaration", "export_statement", "lexical_declaration"], skip: ["import_statement"], recurse: ["class_body"] },
1540
+ ".mjs": { outline: ["function_declaration", "class_declaration", "variable_declaration", "export_statement", "lexical_declaration"], skip: ["import_statement"], recurse: ["class_body"] },
1541
+ ".jsx": { outline: ["function_declaration", "class_declaration", "variable_declaration", "export_statement", "lexical_declaration"], skip: ["import_statement"], recurse: ["class_body"] },
1542
+ ".ts": { outline: ["function_declaration", "class_declaration", "interface_declaration", "type_alias_declaration", "enum_declaration", "variable_declaration", "export_statement", "lexical_declaration"], skip: ["import_statement"], recurse: ["class_body"] },
1543
+ ".tsx": { outline: ["function_declaration", "class_declaration", "interface_declaration", "type_alias_declaration", "enum_declaration", "variable_declaration", "export_statement", "lexical_declaration"], skip: ["import_statement"], recurse: ["class_body"] },
1544
+ ".py": { outline: ["function_definition", "class_definition", "decorated_definition"], skip: ["import_statement", "import_from_statement"], recurse: ["class_body", "block"] },
1545
+ ".go": { outline: ["function_declaration", "method_declaration", "type_declaration"], skip: ["import_declaration"], recurse: [] },
1546
+ ".rs": { outline: ["function_item", "struct_item", "enum_item", "impl_item", "trait_item", "const_item", "static_item"], skip: ["use_declaration"], recurse: ["impl_item"] },
1547
+ ".java": { outline: ["class_declaration", "interface_declaration", "method_declaration", "enum_declaration"], skip: ["import_declaration"], recurse: ["class_body"] },
1548
+ ".c": { outline: ["function_definition", "struct_specifier", "enum_specifier", "type_definition"], skip: ["preproc_include"], recurse: [] },
1549
+ ".h": { outline: ["function_definition", "struct_specifier", "enum_specifier", "type_definition"], skip: ["preproc_include"], recurse: [] },
1550
+ ".cpp": { outline: ["function_definition", "class_specifier", "struct_specifier", "namespace_definition"], skip: ["preproc_include"], recurse: ["class_specifier"] },
1551
+ ".cs": { outline: ["class_declaration", "interface_declaration", "method_declaration", "namespace_declaration"], skip: ["using_directive"], recurse: ["class_body"] },
1552
+ ".rb": { outline: ["method", "class", "module"], skip: ["require", "require_relative"], recurse: ["class", "module"] },
1553
+ ".php": { outline: ["function_definition", "class_declaration", "method_declaration"], skip: ["namespace_use_declaration"], recurse: ["class_body"] },
1554
+ ".kt": { outline: ["function_declaration", "class_declaration", "object_declaration"], skip: ["import_header"], recurse: ["class_body"] },
1555
+ ".swift": { outline: ["function_declaration", "class_declaration", "struct_declaration", "protocol_declaration"], skip: ["import_declaration"], recurse: ["class_body"] },
1556
+ ".sh": { outline: ["function_definition"], skip: [], recurse: [] },
1557
+ ".bash": { outline: ["function_definition"], skip: [], recurse: [] }
1558
+ };
1559
+ function extractOutline(rootNode, config, sourceLines) {
1560
+ const entries = [];
1561
+ const skipTypes = new Set(config.skip);
1562
+ const outlineTypes = new Set(config.outline);
1563
+ const recurseTypes = new Set(config.recurse);
1564
+ function walk(node, depth) {
1565
+ for (let i = 0; i < node.childCount; i++) {
1566
+ const child = node.child(i);
1567
+ const type = child.type;
1568
+ const startLine = child.startPosition.row + 1;
1569
+ const endLine = child.endPosition.row + 1;
1570
+ if (skipTypes.has(type)) continue;
1571
+ if (outlineTypes.has(type)) {
1572
+ const firstLine = sourceLines[startLine - 1] || "";
1573
+ const nameMatch = firstLine.match(/(?:function|class|interface|type|enum|struct|def|fn|pub\s+fn)\s+(\w+)|(?:const|let|var|export\s+(?:const|let|var|function|class))\s+(\w+)/);
1574
+ const name = nameMatch ? nameMatch[1] || nameMatch[2] : null;
1575
+ entries.push({
1576
+ start: startLine,
1577
+ end: endLine,
1578
+ depth,
1579
+ text: firstLine.trim().slice(0, 120),
1580
+ name
1581
+ });
1582
+ for (let j = 0; j < child.childCount; j++) {
1583
+ const sub = child.child(j);
1584
+ if (recurseTypes.has(sub.type)) walk(sub, depth + 1);
1585
+ }
1586
+ }
1587
+ }
1588
+ }
1589
+ const skippedRanges = [];
1590
+ for (let i = 0; i < rootNode.childCount; i++) {
1591
+ const child = rootNode.child(i);
1592
+ if (skipTypes.has(child.type)) {
1593
+ skippedRanges.push({
1594
+ start: child.startPosition.row + 1,
1595
+ end: child.endPosition.row + 1
1596
+ });
1597
+ }
1598
+ }
1599
+ walk(rootNode, 0);
1600
+ return { entries, skippedRanges };
1601
+ }
1602
+ async function outlineFromContent(content, ext) {
1603
+ const config = LANG_CONFIGS[ext];
1604
+ const grammar = grammarForExtension(ext);
1605
+ if (!config || !grammar) return null;
1606
+ const sourceLines = content.split("\n");
1607
+ let lang;
1608
+ try {
1609
+ lang = await getLanguage(grammar);
1610
+ } catch (e) {
1611
+ throw new Error(`Outline error: ${e.message}`);
1612
+ }
1613
+ const parser = await getParser();
1614
+ parser.setLanguage(lang);
1615
+ const tree = parser.parse(content);
1616
+ return extractOutline(tree.rootNode, config, sourceLines);
1617
+ }
1618
+ function formatOutline(entries, skippedRanges, sourceLineCount, db, relFile) {
1619
+ const lines = [];
1620
+ if (skippedRanges.length > 0) {
1621
+ const first = skippedRanges[0].start;
1622
+ const last = skippedRanges[skippedRanges.length - 1].end;
1623
+ const count = skippedRanges.reduce((sum, r) => sum + (r.end - r.start + 1), 0);
1624
+ lines.push(`${first}-${last}: (${count} imports/declarations)`);
1625
+ }
1626
+ for (const e of entries) {
1627
+ const indent = " ".repeat(e.depth);
1628
+ const anno = db ? symbolAnnotation(db, relFile, e.name) : null;
1629
+ const suffix = anno ? ` ${anno}` : "";
1630
+ lines.push(`${indent}${e.start}-${e.end}: ${e.text}${suffix}`);
1631
+ }
1632
+ lines.push("");
1633
+ lines.push(`(${entries.length} symbols, ${sourceLineCount} source lines)`);
1634
+ return lines.join("\n");
1635
+ }
1636
+ async function fileOutline(filePath) {
1637
+ filePath = normalizePath(filePath);
1638
+ const real = validatePath(filePath);
1639
+ const ext = extname(real).toLowerCase();
1640
+ if (!LANG_CONFIGS[ext]) {
1641
+ return `Outline unavailable for ${ext} files. Use read_file directly for non-code files (markdown, config, text). Supported code extensions: ${supportedExtensions().join(", ")}`;
1642
+ }
1643
+ const content = readUtf8Normalized(real);
1644
+ const result = await outlineFromContent(content, ext);
1645
+ const db = getGraphDB(real);
1646
+ const relFile = db ? getRelativePath(real) : null;
1647
+ return `File: ${filePath}
1648
+
1649
+ ${formatOutline(result.entries, result.skippedRanges, content.split("\n").length, db, relFile)}`;
1650
+ }
1651
+
1652
+ // lib/verify.mjs
1653
+ function verifyChecksums(filePath, checksums, opts = {}) {
1654
+ filePath = normalizePath(filePath);
1655
+ const real = validatePath(filePath);
1656
+ const current = readSnapshot(real);
1657
+ const baseSnapshot = opts.baseRevision ? getSnapshotByRevision(opts.baseRevision) : null;
1658
+ const results = [];
1659
+ let allValid = true;
1660
+ for (const cs of checksums) {
1661
+ const parsed = parseChecksum(cs);
1662
+ if (parsed.start < 1 || parsed.end > current.lines.length) {
1663
+ results.push(`${cs}: INVALID (range ${parsed.start}-${parsed.end} exceeds file length ${current.lines.length})`);
1664
+ allValid = false;
1665
+ continue;
1666
+ }
1667
+ const actual = buildRangeChecksum(current, parsed.start, parsed.end);
1668
+ const currentHex = actual.split(":")[1];
1669
+ if (currentHex === parsed.hex) {
1670
+ results.push(`${cs}: valid`);
1671
+ } else {
1672
+ const staleBits = [`${cs}: STALE \u2192 current: ${actual}`];
1673
+ if (baseSnapshot?.path === real) {
1674
+ const changedRanges = computeChangedRanges(baseSnapshot.lines, current.lines);
1675
+ staleBits.push(`revision: ${current.revision}`);
1676
+ staleBits.push(`changed_ranges: ${describeChangedRanges(changedRanges)}`);
1677
+ } else if (opts.baseRevision) {
1678
+ staleBits.push(`revision: ${current.revision}`);
1679
+ staleBits.push(`changed_ranges: unavailable (base revision evicted)`);
1680
+ }
1681
+ results.push(staleBits.join("\n"));
1682
+ allValid = false;
1683
+ }
1684
+ }
1685
+ if (allValid && checksums.length > 0) {
1686
+ let msg = `All ${checksums.length} checksum(s) valid for ${filePath}`;
1687
+ msg += `
1688
+ revision: ${current.revision}`;
1689
+ msg += `
1690
+ file: ${current.fileChecksum}`;
1691
+ return msg;
1692
+ }
1693
+ return results.join("\n");
1694
+ }
1695
+
1696
+ // lib/tree.mjs
1697
+ import { readdirSync as readdirSync2, readFileSync as readFileSync3, statSync as statSync6, existsSync as existsSync3 } from "node:fs";
1698
+ import { resolve as resolve4, basename, join as join4, relative as relative2 } from "node:path";
1699
+ import ignore from "ignore";
1700
+ var SKIP_DIRS = /* @__PURE__ */ new Set([
1701
+ "node_modules",
1702
+ ".git",
1703
+ "dist",
1704
+ "build",
1705
+ "__pycache__",
1706
+ ".next",
1707
+ "coverage"
1708
+ ]);
1709
+ function globToRegex(pat) {
1710
+ return new RegExp(
1711
+ "^" + pat.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\0").replace(/\*/g, "[^/]*").replace(/\0/g, ".*").replace(/\?/g, ".") + "$"
1712
+ );
1713
+ }
1714
+ function loadGitignore(rootDir) {
1715
+ const gi = join4(rootDir, ".gitignore");
1716
+ if (!existsSync3(gi)) return null;
1717
+ try {
1718
+ const content = readFileSync3(gi, "utf-8");
1719
+ return ignore().add(content);
1720
+ } catch {
1721
+ return null;
1722
+ }
1723
+ }
1724
+ function isIgnored(ig, relPath, isDir) {
1725
+ if (!ig) return false;
1726
+ return ig.ignores(isDir ? relPath + "/" : relPath);
1727
+ }
1728
+ function findByPattern(dirPath, opts) {
1729
+ const re = globToRegex(opts.pattern);
1730
+ const filterType = opts.type || "all";
1731
+ const maxDepth = opts.max_depth ?? 20;
1732
+ const abs = resolve4(normalizePath(dirPath));
1733
+ if (!existsSync3(abs)) throw new Error(`DIRECTORY_NOT_FOUND: ${abs}`);
1734
+ if (!statSync6(abs).isDirectory()) throw new Error(`Not a directory: ${abs}`);
1735
+ const ig = opts.gitignore ?? true ? loadGitignore(abs) : null;
1736
+ const matches = [];
1737
+ function walk(dir, depth) {
1738
+ if (depth > maxDepth) return;
1739
+ let entries;
1740
+ try {
1741
+ entries = readdirSync2(dir, { withFileTypes: true });
1742
+ } catch {
1743
+ return;
1744
+ }
1745
+ for (const entry of entries) {
1746
+ const isDir = entry.isDirectory();
1747
+ if (SKIP_DIRS.has(entry.name) && isDir) continue;
1748
+ const full = join4(dir, entry.name);
1749
+ const rel = relative2(abs, full).replace(/\\/g, "/");
1750
+ if (isIgnored(ig, rel, isDir)) continue;
1751
+ if (re.test(entry.name)) {
1752
+ if (filterType === "all" || filterType === "dir" && isDir || filterType === "file" && !isDir) {
1753
+ matches.push(isDir ? rel + "/" : rel);
1754
+ }
1755
+ }
1756
+ if (isDir) walk(full, depth + 1);
1757
+ }
1758
+ }
1759
+ walk(abs, 1);
1760
+ matches.sort();
1761
+ const rootName = basename(abs);
1762
+ if (matches.length === 0) {
1763
+ return `No matches for "${opts.pattern}" in ${rootName}/`;
1764
+ }
1765
+ return `Found ${matches.length} match${matches.length === 1 ? "" : "es"} for "${opts.pattern}" in ${rootName}/
1766
+
1767
+ ${matches.join("\n")}`;
1768
+ }
1769
+ function directoryTree(dirPath, opts = {}) {
1770
+ if (opts.pattern) return findByPattern(dirPath, opts);
1771
+ const compact = opts.format === "compact";
1772
+ const maxDepth = compact ? 1 : opts.max_depth ?? 3;
1773
+ const abs = resolve4(normalizePath(dirPath));
1774
+ if (!existsSync3(abs)) throw new Error(`DIRECTORY_NOT_FOUND: ${abs}. Check path or use directory_tree on parent directory.`);
1775
+ const rootStat = statSync6(abs);
1776
+ if (!rootStat.isDirectory()) throw new Error(`Not a directory: ${abs}`);
1777
+ const ig = opts.gitignore ?? true ? loadGitignore(abs) : null;
1778
+ let totalFiles = 0;
1779
+ let totalSize = 0;
1780
+ const lines = [];
1781
+ function walk(dir, prefix, depth) {
1782
+ let entries;
1783
+ try {
1784
+ entries = readdirSync2(dir, { withFileTypes: true });
1785
+ } catch {
1786
+ return 0;
1787
+ }
1788
+ entries.sort((a, b) => {
1789
+ const aDir = a.isDirectory() ? 0 : 1;
1790
+ const bDir = b.isDirectory() ? 0 : 1;
1791
+ if (aDir !== bDir) return aDir - bDir;
1792
+ return a.name.localeCompare(b.name);
1793
+ });
1794
+ let subTotal = 0;
1795
+ for (const entry of entries) {
1796
+ const name = entry.name;
1797
+ const isDir = entry.isDirectory();
1798
+ if (SKIP_DIRS.has(name) && isDir) continue;
1799
+ const full = join4(dir, name);
1800
+ const rel = relative2(abs, full).replace(/\\/g, "/");
1801
+ if (isIgnored(ig, rel, isDir)) continue;
1802
+ if (isDir) {
1803
+ if (compact) {
1804
+ lines.push(`${prefix}${name}/`);
1805
+ } else {
1806
+ const lineIdx = lines.length;
1807
+ lines.push("");
1808
+ const count = depth < maxDepth ? walk(full, prefix + " ", depth + 1) : countSubtreeFiles(full, ig, abs);
1809
+ lines[lineIdx] = `${prefix}${name}/ (${count} files)`;
1810
+ subTotal += count;
1811
+ }
1812
+ if (compact) walk(full, prefix + " ", depth + 1);
1813
+ } else {
1814
+ totalFiles++;
1815
+ subTotal++;
1816
+ if (compact) {
1817
+ lines.push(`${prefix}${name}`);
1818
+ } else {
1819
+ let size = 0, mtime = null, lineCount = null;
1820
+ try {
1821
+ const st = statSync6(full);
1822
+ size = st.size;
1823
+ mtime = st.mtime;
1824
+ } catch {
1825
+ }
1826
+ totalSize += size;
1827
+ lineCount = countFileLines(full, size);
1828
+ const parts = [];
1829
+ if (lineCount !== null) parts.push(`${lineCount}L`);
1830
+ parts.push(formatSize(size));
1831
+ if (mtime) parts.push(relativeTime(mtime, true));
1832
+ lines.push(`${prefix}${name} (${parts.join(", ")})`);
1833
+ }
1834
+ }
1835
+ }
1836
+ return subTotal;
1837
+ }
1838
+ function countSubtreeFiles(dir, ig2, rootAbs, depth = 0) {
1839
+ if (depth > 10) return 0;
1840
+ let entries;
1841
+ try {
1842
+ entries = readdirSync2(dir, { withFileTypes: true });
1843
+ } catch {
1844
+ return 0;
1845
+ }
1846
+ let count = 0;
1847
+ for (const entry of entries) {
1848
+ if (SKIP_DIRS.has(entry.name) && entry.isDirectory()) continue;
1849
+ const full = join4(dir, entry.name);
1850
+ const rel = relative2(rootAbs, full).replace(/\\/g, "/");
1851
+ if (isIgnored(ig2, rel, entry.isDirectory())) continue;
1852
+ if (entry.isDirectory()) {
1853
+ count += countSubtreeFiles(full, ig2, rootAbs, depth + 1);
1854
+ } else {
1855
+ count++;
1856
+ }
1857
+ }
1858
+ return count;
1859
+ }
1860
+ const rootName = basename(abs);
1861
+ walk(abs, " ", 1);
1862
+ const header = compact ? `Directory: ${rootName}/ (${totalFiles} files)` : `Directory: ${rootName}/ (${totalFiles} files, ${formatSize(totalSize)})`;
1863
+ return `${header}
1864
+
1865
+ ${rootName}/
1866
+ ${lines.join("\n")}`;
1867
+ }
1868
+
1869
+ // lib/info.mjs
1870
+ import { statSync as statSync7, openSync as openSync2, readSync as readSync2, closeSync as closeSync2 } from "node:fs";
1871
+ import { resolve as resolve5, isAbsolute as isAbsolute2, extname as extname2, basename as basename2 } from "node:path";
1872
+ var MAX_LINE_COUNT_SIZE = 10 * 1024 * 1024;
1873
+ var EXT_NAMES = {
1874
+ ".ts": "TypeScript source",
1875
+ ".tsx": "TypeScript JSX source",
1876
+ ".js": "JavaScript source",
1877
+ ".jsx": "JavaScript JSX source",
1878
+ ".mjs": "JavaScript ESM source",
1879
+ ".cjs": "JavaScript CJS source",
1880
+ ".py": "Python source",
1881
+ ".rb": "Ruby source",
1882
+ ".rs": "Rust source",
1883
+ ".go": "Go source",
1884
+ ".java": "Java source",
1885
+ ".kt": "Kotlin source",
1886
+ ".swift": "Swift source",
1887
+ ".c": "C source",
1888
+ ".cpp": "C++ source",
1889
+ ".h": "C/C++ header",
1890
+ ".cs": "C# source",
1891
+ ".php": "PHP source",
1892
+ ".sh": "Shell script",
1893
+ ".bash": "Bash script",
1894
+ ".zsh": "Zsh script",
1895
+ ".json": "JSON data",
1896
+ ".yaml": "YAML data",
1897
+ ".yml": "YAML data",
1898
+ ".toml": "TOML config",
1899
+ ".xml": "XML document",
1900
+ ".html": "HTML document",
1901
+ ".css": "CSS stylesheet",
1902
+ ".scss": "SCSS stylesheet",
1903
+ ".less": "LESS stylesheet",
1904
+ ".md": "Markdown document",
1905
+ ".txt": "Plain text",
1906
+ ".csv": "CSV data",
1907
+ ".sql": "SQL script",
1908
+ ".graphql": "GraphQL schema",
1909
+ ".png": "PNG image",
1910
+ ".jpg": "JPEG image",
1911
+ ".jpeg": "JPEG image",
1912
+ ".gif": "GIF image",
1913
+ ".svg": "SVG image",
1914
+ ".ico": "Icon file",
1915
+ ".pdf": "PDF document",
1916
+ ".zip": "ZIP archive",
1917
+ ".tar": "TAR archive",
1918
+ ".gz": "Gzip archive",
1919
+ ".wasm": "WebAssembly binary",
1920
+ ".lock": "Lock file",
1921
+ ".env": "Environment config",
1922
+ ".dockerfile": "Dockerfile",
1923
+ ".vue": "Vue component",
1924
+ ".svelte": "Svelte component"
1925
+ };
1926
+ function detectBinary(filePath, size) {
1927
+ if (size === 0) return false;
1928
+ const fd = openSync2(filePath, "r");
1929
+ const probe = Buffer.alloc(Math.min(size, 8192));
1930
+ const bytesRead = readSync2(fd, probe, 0, probe.length, 0);
1931
+ closeSync2(fd);
1932
+ for (let i = 0; i < bytesRead; i++) {
1933
+ if (probe[i] === 0) return true;
1934
+ }
1935
+ return false;
1936
+ }
1937
+ function fileInfo(filePath) {
1938
+ if (!filePath) throw new Error("Empty file path");
1939
+ const normalized = normalizePath(filePath);
1940
+ const abs = isAbsolute2(normalized) ? normalized : resolve5(process.cwd(), normalized);
1941
+ const stat = statSync7(abs);
1942
+ if (!stat.isFile()) throw new Error(`Not a regular file: ${abs}`);
1943
+ const size = stat.size;
1944
+ const mtime = stat.mtime;
1945
+ const ext = extname2(abs).toLowerCase();
1946
+ const name = basename2(abs);
1947
+ let typeName = EXT_NAMES[ext] || (ext ? `${ext.slice(1).toUpperCase()} file` : "Unknown type");
1948
+ if (name === "Dockerfile") typeName = "Dockerfile";
1949
+ if (name === "Makefile") typeName = "Makefile";
1950
+ const isBinary = size > 0 ? detectBinary(abs, size) : false;
1951
+ const lineCount = !isBinary && size > 0 ? countFileLines(abs, size, MAX_LINE_COUNT_SIZE) : null;
1952
+ const sizeStr = lineCount !== null ? `Size: ${formatSize(size)} (${lineCount} lines)` : `Size: ${formatSize(size)}`;
1953
+ const timeStr = `Modified: ${mtime.toISOString().replace("T", " ").slice(0, 19)} (${relativeTime(mtime)})`;
1954
+ return [
1955
+ `File: ${normalized}`,
1956
+ sizeStr,
1957
+ timeStr,
1958
+ `Type: ${typeName}`,
1959
+ `Binary: ${isBinary ? "yes" : "no"}`
1960
+ ].join("\n");
1961
+ }
1962
+
1963
+ // lib/setup.mjs
1964
+ import { readFileSync as readFileSync4, writeFileSync as writeFileSync2, existsSync as existsSync4, mkdirSync } from "node:fs";
1965
+ import { resolve as resolve6, dirname as dirname3 } from "node:path";
1966
+ import { fileURLToPath } from "node:url";
1967
+ import { homedir } from "node:os";
1968
+ var __filename = fileURLToPath(import.meta.url);
1969
+ var __dirname = dirname3(__filename);
1970
+ var HOOK_SCRIPT = resolve6(__dirname, "..", "hook.mjs").replace(/\\/g, "/");
1971
+ var HOOK_COMMAND = `node ${HOOK_SCRIPT}`;
1972
+ var HOOK_SIGNATURE = "hex-line-mcp/hook.mjs";
1973
+ var NPX_MARKERS = ["_npx", "npx-cache", ".npm/_npx"];
1974
+ function isEphemeralInstall(scriptPath) {
1975
+ return NPX_MARKERS.some((m) => scriptPath.includes(m));
1976
+ }
1977
+ var CLAUDE_HOOKS = {
1978
+ SessionStart: {
1979
+ matcher: "*",
1980
+ hooks: [{ type: "command", command: HOOK_COMMAND, timeout: 5 }]
1981
+ },
1982
+ PreToolUse: {
1983
+ matcher: "Read|Edit|Write|Grep|Bash|mcp__hex-line__.*",
1984
+ hooks: [{ type: "command", command: HOOK_COMMAND, timeout: 5 }]
1985
+ },
1986
+ PostToolUse: {
1987
+ matcher: "Bash",
1988
+ hooks: [{ type: "command", command: HOOK_COMMAND, timeout: 10 }]
1989
+ }
1990
+ };
1991
+ function readJson(filePath) {
1992
+ if (!existsSync4(filePath)) return null;
1993
+ return JSON.parse(readFileSync4(filePath, "utf-8"));
1994
+ }
1995
+ function writeJson(filePath, data) {
1996
+ mkdirSync(dirname3(filePath), { recursive: true });
1997
+ writeFileSync2(filePath, JSON.stringify(data, null, 2) + "\n", "utf-8");
1998
+ }
1999
+ function findEntryByCommand(entries) {
2000
+ return entries.findIndex(
2001
+ (e) => Array.isArray(e.hooks) && e.hooks.some(
2002
+ (h) => typeof h.command === "string" && h.command.includes(HOOK_SIGNATURE)
2003
+ )
2004
+ );
2005
+ }
2006
+ function writeHooksToFile(settingsPath, label) {
2007
+ const config = readJson(settingsPath) || {};
2008
+ if (!config.hooks || typeof config.hooks !== "object") {
2009
+ config.hooks = {};
2010
+ }
2011
+ let changed = false;
2012
+ for (const [event, desired] of Object.entries(CLAUDE_HOOKS)) {
2013
+ if (!Array.isArray(config.hooks[event])) {
2014
+ config.hooks[event] = [];
2015
+ }
2016
+ const entries = config.hooks[event];
2017
+ const idx = findEntryByCommand(entries);
2018
+ if (idx >= 0) {
2019
+ const existing = entries[idx];
2020
+ if (existing.matcher === desired.matcher && existing.hooks.length === desired.hooks.length && existing.hooks[0].command === HOOK_COMMAND && existing.hooks[0].timeout === desired.hooks[0].timeout) {
2021
+ continue;
2022
+ }
2023
+ entries[idx] = { matcher: desired.matcher, hooks: [...desired.hooks] };
2024
+ changed = true;
2025
+ } else {
2026
+ entries.push({ matcher: desired.matcher, hooks: [...desired.hooks] });
2027
+ changed = true;
2028
+ }
2029
+ }
2030
+ if (config.disableAllHooks !== false) {
2031
+ config.disableAllHooks = false;
2032
+ changed = true;
2033
+ }
2034
+ if (!changed) {
2035
+ return `Claude (${label}): already configured`;
2036
+ }
2037
+ writeJson(settingsPath, config);
2038
+ return `Claude (${label}): hooks -> ${HOOK_SCRIPT} OK`;
2039
+ }
2040
+ function cleanLocalHooks() {
2041
+ const localPath = resolve6(process.cwd(), ".claude/settings.local.json");
2042
+ const config = readJson(localPath);
2043
+ if (!config || !config.hooks || typeof config.hooks !== "object") {
2044
+ return "local: clean";
2045
+ }
2046
+ let changed = false;
2047
+ for (const event of Object.keys(CLAUDE_HOOKS)) {
2048
+ if (!Array.isArray(config.hooks[event])) continue;
2049
+ const entries = config.hooks[event];
2050
+ const idx = findEntryByCommand(entries);
2051
+ if (idx >= 0) {
2052
+ entries.splice(idx, 1);
2053
+ changed = true;
2054
+ }
2055
+ if (entries.length === 0) {
2056
+ delete config.hooks[event];
2057
+ }
2058
+ }
2059
+ if (Object.keys(config.hooks).length === 0) {
2060
+ delete config.hooks;
2061
+ }
2062
+ if (!changed) {
2063
+ return "local: clean";
2064
+ }
2065
+ writeJson(localPath, config);
2066
+ return "local: removed old hex-line hooks";
2067
+ }
2068
+ function installOutputStyle() {
2069
+ const source = resolve6(dirname3(fileURLToPath(import.meta.url)), "..", "output-style.md");
2070
+ const target = resolve6(homedir(), ".claude", "output-styles", "hex-line.md");
2071
+ mkdirSync(dirname3(target), { recursive: true });
2072
+ writeFileSync2(target, readFileSync4(source, "utf-8"), "utf-8");
2073
+ const userSettings = resolve6(homedir(), ".claude/settings.json");
2074
+ const config = readJson(userSettings) || {};
2075
+ const prev = config.outputStyle;
2076
+ if (!prev) {
2077
+ config.outputStyle = "hex-line";
2078
+ writeJson(userSettings, config);
2079
+ }
2080
+ const msg = prev ? `Output style file installed. Existing style '${prev}' preserved (not overridden)` : "Output style 'hex-line' installed and activated globally";
2081
+ return msg;
2082
+ }
2083
+ function setupClaude() {
2084
+ if (isEphemeralInstall(HOOK_SCRIPT)) {
2085
+ return "Claude: SKIPPED \u2014 hook.mjs is in npx cache (ephemeral). Install permanently: npm i -g @levnikolaevich/hex-line-mcp, then re-run setup_hooks.";
2086
+ }
2087
+ const results = [];
2088
+ const globalPath = resolve6(homedir(), ".claude/settings.json");
2089
+ results.push(writeHooksToFile(globalPath, "global"));
2090
+ results.push(cleanLocalHooks());
2091
+ results.push(installOutputStyle());
2092
+ return results.join(" | ");
2093
+ }
2094
+ function setupGemini() {
2095
+ return "Gemini: Not supported (Gemini CLI does not support hooks. Add MCP Tool Preferences to GEMINI.md instead)";
2096
+ }
2097
+ function setupCodex() {
2098
+ return "Codex: Not supported (Codex CLI does not support hooks. Add MCP Tool Preferences to AGENTS.md instead)";
2099
+ }
2100
+ function uninstallClaude() {
2101
+ const globalPath = resolve6(homedir(), ".claude/settings.json");
2102
+ const config = readJson(globalPath);
2103
+ if (!config || !config.hooks || typeof config.hooks !== "object") {
2104
+ return "Claude: no hooks to remove";
2105
+ }
2106
+ let changed = false;
2107
+ for (const event of Object.keys(CLAUDE_HOOKS)) {
2108
+ if (!Array.isArray(config.hooks[event])) continue;
2109
+ const idx = findEntryByCommand(config.hooks[event]);
2110
+ if (idx >= 0) {
2111
+ config.hooks[event].splice(idx, 1);
2112
+ if (config.hooks[event].length === 0) delete config.hooks[event];
2113
+ changed = true;
2114
+ }
2115
+ }
2116
+ if (Object.keys(config.hooks).length === 0) delete config.hooks;
2117
+ if (!changed) return "Claude: no hex-line hooks found";
2118
+ writeJson(globalPath, config);
2119
+ return "Claude: hex-line hooks removed from global settings";
2120
+ }
2121
+ var AGENTS = { claude: setupClaude, gemini: setupGemini, codex: setupCodex };
2122
+ function setupHooks(agent = "all", action = "install") {
2123
+ const target = (agent || "all").toLowerCase();
2124
+ const act = (action || "install").toLowerCase();
2125
+ if (act === "uninstall") {
2126
+ const result = uninstallClaude();
2127
+ return `Hooks uninstalled:
2128
+ ${result}
2129
+
2130
+ Restart Claude Code to apply changes.`;
2131
+ }
2132
+ if (target !== "all" && !AGENTS[target]) {
2133
+ throw new Error(`UNKNOWN_AGENT: '${agent}'. Supported: claude, gemini, codex, all`);
2134
+ }
2135
+ const targets = target === "all" ? Object.keys(AGENTS) : [target];
2136
+ const results = targets.map((name) => " " + AGENTS[name]());
2137
+ const header = `Hooks configured for ${target}:`;
2138
+ const footer = "\nRestart Claude Code to apply hook changes.";
2139
+ return [header, ...results, footer].join("\n");
2140
+ }
2141
+
2142
+ // lib/changes.mjs
2143
+ import { execFileSync } from "node:child_process";
2144
+ import { statSync as statSync8 } from "node:fs";
2145
+ import { extname as extname3 } from "node:path";
2146
+ function symbolName(text) {
2147
+ const clean = text.replace(/\s*\{?\s*$/, "").trim();
2148
+ const noParams = clean.replace(/\(.*$/, "").trim();
2149
+ const parts = noParams.split(/\s+/);
2150
+ const eqIdx = parts.indexOf("=");
2151
+ if (eqIdx > 0) return parts[eqIdx - 1];
2152
+ return parts[parts.length - 1] || text;
2153
+ }
2154
+ function toSymbolMap(entries) {
2155
+ const map = /* @__PURE__ */ new Map();
2156
+ for (const e of entries) {
2157
+ const name = symbolName(e.text);
2158
+ const lines = e.end - e.start + 1;
2159
+ map.set(name, { name, text: e.text, lines, start: e.start, end: e.end });
2160
+ }
2161
+ return map;
2162
+ }
2163
+ function gitRelativePath(absPath) {
2164
+ const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
2165
+ cwd: absPath.replace(/[/\\][^/\\]+$/, ""),
2166
+ encoding: "utf-8",
2167
+ timeout: 5e3
2168
+ }).trim().replace(/\\/g, "/");
2169
+ const normalized = absPath.replace(/\\/g, "/");
2170
+ const rootLower = root.toLowerCase();
2171
+ const pathLower = normalized.toLowerCase();
2172
+ if (!pathLower.startsWith(rootLower)) {
2173
+ throw new Error(`File ${absPath} is not inside git repo ${root}`);
2174
+ }
2175
+ return normalized.slice(root.length + 1);
2176
+ }
2177
+ async function fileChanges(filePath, compareAgainst = "HEAD") {
2178
+ filePath = normalizePath(filePath);
2179
+ const real = validatePath(filePath);
2180
+ if (statSync8(real).isDirectory()) {
2181
+ try {
2182
+ const stat = execFileSync("git", ["diff", "--stat", compareAgainst, "--", "."], {
2183
+ cwd: real,
2184
+ encoding: "utf-8",
2185
+ timeout: 1e4
2186
+ }).trim();
2187
+ if (!stat) return `No changes in ${filePath} vs ${compareAgainst}`;
2188
+ return `Changed files in ${filePath} vs ${compareAgainst}:
2189
+
2190
+ ${stat}
2191
+
2192
+ Use changes on a specific file for symbol-level diff.`;
2193
+ } catch {
2194
+ return `No git history for ${filePath} or not a git repository.`;
2195
+ }
2196
+ }
2197
+ const ext = extname3(real).toLowerCase();
2198
+ const currentContent = readText(real);
2199
+ const currentResult = await outlineFromContent(currentContent, ext);
2200
+ if (!currentResult) {
2201
+ return `Cannot outline ${ext} files. Supported: .js .mjs .ts .py .go .rs .java .c .cpp .cs .rb .php .kt .swift .sh .bash`;
2202
+ }
2203
+ const relPath = gitRelativePath(real);
2204
+ let gitContent;
2205
+ try {
2206
+ gitContent = execFileSync("git", ["show", `${compareAgainst}:${relPath}`], {
2207
+ cwd: real.replace(/[/\\][^/\\]+$/, ""),
2208
+ encoding: "utf-8",
2209
+ timeout: 5e3
2210
+ }).replace(/\r\n/g, "\n");
2211
+ } catch {
2212
+ return `NEW FILE: ${filePath} (not in ${compareAgainst})`;
2213
+ }
2214
+ const gitResult = await outlineFromContent(gitContent, ext);
2215
+ if (!gitResult) {
2216
+ return `Cannot outline git version of ${filePath}`;
2217
+ }
2218
+ const currentMap = toSymbolMap(currentResult.entries);
2219
+ const gitMap = toSymbolMap(gitResult.entries);
2220
+ const added = [];
2221
+ const removed = [];
2222
+ const modified = [];
2223
+ for (const [name, sym] of currentMap) {
2224
+ if (!gitMap.has(name)) {
2225
+ added.push(sym);
2226
+ } else {
2227
+ const gitSym = gitMap.get(name);
2228
+ if (gitSym.lines !== sym.lines) {
2229
+ modified.push({ current: sym, git: gitSym });
2230
+ }
2231
+ }
2232
+ }
2233
+ for (const [name, sym] of gitMap) {
2234
+ if (!currentMap.has(name)) {
2235
+ removed.push(sym);
2236
+ }
2237
+ }
2238
+ const parts = [`Changes in ${filePath} vs ${compareAgainst}:`];
2239
+ if (added.length) {
2240
+ parts.push("\nAdded:");
2241
+ for (const s of added) parts.push(` + ${s.start}-${s.end}: ${s.text}`);
2242
+ }
2243
+ if (removed.length) {
2244
+ parts.push("\nRemoved:");
2245
+ for (const s of removed) parts.push(` - ${s.start}-${s.end}: ${s.text}`);
2246
+ }
2247
+ if (modified.length) {
2248
+ parts.push("\nModified:");
2249
+ for (const m of modified) {
2250
+ const delta = m.current.lines - m.git.lines;
2251
+ const sign = delta > 0 ? "+" : "";
2252
+ parts.push(` ~ ${m.current.start}-${m.current.end}: ${m.current.text} (${sign}${delta} lines)`);
2253
+ }
2254
+ }
2255
+ if (!added.length && !removed.length && !modified.length) {
2256
+ parts.push("\nNo symbol changes detected.");
2257
+ }
2258
+ const summary = `${added.length} added, ${removed.length} removed, ${modified.length} modified`;
2259
+ parts.push(`
2260
+ Summary: ${summary}`);
2261
+ return parts.join("\n");
2262
+ }
2263
+
2264
+ // lib/bulk-replace.mjs
2265
+ import { writeFileSync as writeFileSync3, readdirSync as readdirSync3 } from "node:fs";
2266
+ import { resolve as resolve7, relative as relative3, join as join5 } from "node:path";
2267
+ var ignoreMod;
2268
+ try {
2269
+ ignoreMod = await import("ignore");
2270
+ } catch {
2271
+ }
2272
+ function walkFiles(dir, rootDir, ig) {
2273
+ const results = [];
2274
+ let entries;
2275
+ try {
2276
+ entries = readdirSync3(dir, { withFileTypes: true });
2277
+ } catch {
2278
+ return results;
2279
+ }
2280
+ for (const e of entries) {
2281
+ if (e.name === ".git" || e.name === "node_modules") continue;
2282
+ const full = join5(dir, e.name);
2283
+ const rel = relative3(rootDir, full).replace(/\\/g, "/");
2284
+ if (ig && ig.ignores(rel)) continue;
2285
+ if (e.isDirectory()) {
2286
+ results.push(...walkFiles(full, rootDir, ig));
2287
+ } else {
2288
+ results.push(full);
2289
+ }
2290
+ }
2291
+ return results;
2292
+ }
2293
+ function globMatch(filename, pattern) {
2294
+ const re = pattern.replace(/\./g, "\\.").replace(/\*\*/g, "\0").replace(/\*/g, "[^/]*").replace(/\0/g, ".*").replace(/\?/g, ".");
2295
+ return new RegExp("^" + re + "$").test(filename);
2296
+ }
2297
+ function loadGitignore2(rootDir) {
2298
+ if (!ignoreMod) return null;
2299
+ const ig = (ignoreMod.default || ignoreMod)();
2300
+ try {
2301
+ const content = readText(join5(rootDir, ".gitignore"));
2302
+ ig.add(content);
2303
+ } catch {
2304
+ }
2305
+ return ig;
2306
+ }
2307
+ function bulkReplace(rootDir, globPattern, replacements, opts = {}) {
2308
+ const { dryRun = false, maxFiles = 100 } = opts;
2309
+ const abs = resolve7(normalizePath(rootDir));
2310
+ const ig = loadGitignore2(abs);
2311
+ const allFiles = walkFiles(abs, abs, ig);
2312
+ const files = allFiles.filter((f) => {
2313
+ const rel = relative3(abs, f).replace(/\\/g, "/");
2314
+ return globMatch(rel, globPattern);
2315
+ });
2316
+ if (files.length === 0) return "No files matched the glob pattern.";
2317
+ if (files.length > maxFiles) {
2318
+ return `TOO_MANY_FILES: Found ${files.length} files, max_files is ${maxFiles}. Use more specific glob or increase max_files.`;
2319
+ }
2320
+ const results = [];
2321
+ let changed = 0, skipped = 0, errors = 0;
2322
+ const MAX_OUTPUT2 = MAX_OUTPUT_CHARS;
2323
+ let totalChars = 0;
2324
+ for (const file of files) {
2325
+ try {
2326
+ const original = readText(file);
2327
+ let content = original;
2328
+ for (const { old: oldText, new: newText } of replacements) {
2329
+ content = content.split(oldText).join(newText);
2330
+ }
2331
+ if (content === original) {
2332
+ skipped++;
2333
+ continue;
2334
+ }
2335
+ const diff = simpleDiff(original.split("\n"), content.split("\n"));
2336
+ if (!dryRun) {
2337
+ writeFileSync3(file, content, "utf-8");
2338
+ }
2339
+ const relPath = file.replace(abs, "").replace(/^[/\\]/, "");
2340
+ results.push(`--- ${relPath}
2341
+ ${diff || "(no visible diff)"}`);
2342
+ changed++;
2343
+ totalChars += results[results.length - 1].length;
2344
+ if (totalChars > MAX_OUTPUT2) {
2345
+ const remaining = files.length - files.indexOf(file) - 1;
2346
+ if (remaining > 0) results.push(`OUTPUT_CAPPED: ${remaining} more files not shown. Output exceeded ${MAX_OUTPUT2} chars.`);
2347
+ break;
2348
+ }
2349
+ } catch (e) {
2350
+ results.push(`ERROR: ${file}: ${e.message}`);
2351
+ errors++;
2352
+ }
2353
+ }
2354
+ const header = `Bulk replace: ${changed} files changed, ${skipped} skipped, ${errors} errors (dry_run: ${dryRun})`;
2355
+ return results.length ? `${header}
2356
+
2357
+ ${results.join("\n\n")}` : header;
2358
+ }
2359
+
2360
+ // server.mjs
2361
+ var version = true ? "1.3.5" : (await null).createRequire(import.meta.url)("./package.json").version;
2362
+ var { server, StdioServerTransport } = await createServerRuntime({
2363
+ name: "hex-line-mcp",
2364
+ version,
2365
+ installDir: "mcp/hex-line-mcp"
2366
+ });
2367
+ server.registerTool("read_file", {
2368
+ title: "Read File",
2369
+ description: "Read a file with hash-annotated lines, range checksums, and current revision. Use offset/limit for targeted reads; use outline first for large code files.",
2370
+ inputSchema: z2.object({
2371
+ path: z2.string().optional().describe("File or directory path"),
2372
+ paths: z2.array(z2.string()).optional().describe("Array of file paths to read (batch mode)"),
2373
+ offset: flexNum().describe("Start line (1-indexed, default: 1)"),
2374
+ limit: flexNum().describe("Max lines (default: 2000, 0 = all)"),
2375
+ plain: flexBool().describe("Omit hashes (lineNum|content)")
2376
+ }),
2377
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }
2378
+ }, async (rawParams) => {
2379
+ const { path: p, paths: multi, offset, limit, plain } = coerceParams(rawParams);
2380
+ try {
2381
+ if (multi && multi.length > 0 && !p) {
2382
+ const results = [];
2383
+ for (const fp of multi) {
2384
+ try {
2385
+ results.push(readFile2(fp, { offset, limit, plain }));
2386
+ } catch (e) {
2387
+ results.push(`File: ${fp}
2388
+
2389
+ ERROR: ${e.message}`);
2390
+ }
2391
+ }
2392
+ return { content: [{ type: "text", text: results.join("\n\n---\n\n") }] };
2393
+ }
2394
+ if (!p) throw new Error("Either 'path' or 'paths' is required");
2395
+ return { content: [{ type: "text", text: readFile2(p, { offset, limit, plain }) }] };
2396
+ } catch (e) {
2397
+ return { content: [{ type: "text", text: e.message }], isError: true };
2398
+ }
2399
+ });
2400
+ server.registerTool("edit_file", {
2401
+ title: "Edit File",
2402
+ description: "Apply revision-aware partial edits to one file. Prefer one batched call per file. Supports set_line, replace_lines, insert_after, and replace_between. For text rename/refactor use bulk_replace.",
2403
+ inputSchema: z2.object({
2404
+ path: z2.string().describe("File to edit"),
2405
+ edits: z2.string().describe(
2406
+ 'JSON array. Examples:\n{"set_line":{"anchor":"ab.12","new_text":"new"}} \u2014 replace line\n{"replace_lines":{"start_anchor":"ab.10","end_anchor":"cd.15","new_text":"...","range_checksum":"10-15:a1b2c3d4"}} \u2014 range\n{"replace_between":{"start_anchor":"ab.10","end_anchor":"cd.40","new_text":"...","boundary_mode":"inclusive"}} \u2014 block rewrite\n{"insert_after":{"anchor":"ab.20","text":"inserted"}} \u2014 insert below. For text rename use bulk_replace tool.'
2407
+ ),
2408
+ dry_run: flexBool().describe("Preview changes without writing"),
2409
+ restore_indent: flexBool().describe("Auto-fix indentation to match anchor (default: false)"),
2410
+ base_revision: z2.string().optional().describe("Prior revision from read_file/edit_file. Enables conservative auto-rebase for same-file follow-up edits."),
2411
+ conflict_policy: z2.enum(["strict", "conservative"]).optional().describe('Conflict handling (default: "conservative"). "conservative" returns structured CONFLICT output for stale edits instead of forcing reread.')
2412
+ }),
2413
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }
2414
+ }, async (rawParams) => {
2415
+ const { path: p, edits: json, dry_run, restore_indent, base_revision, conflict_policy } = coerceParams(rawParams);
2416
+ try {
2417
+ const parsed = JSON.parse(json);
2418
+ if (!Array.isArray(parsed) || !parsed.length) throw new Error("Edits: non-empty JSON array required");
2419
+ return {
2420
+ content: [{
2421
+ type: "text",
2422
+ text: editFile(p, parsed, {
2423
+ dryRun: dry_run,
2424
+ restoreIndent: restore_indent,
2425
+ baseRevision: base_revision,
2426
+ conflictPolicy: conflict_policy
2427
+ })
2428
+ }]
2429
+ };
2430
+ } catch (e) {
2431
+ return { content: [{ type: "text", text: e.message }], isError: true };
2432
+ }
2433
+ });
2434
+ server.registerTool("write_file", {
2435
+ title: "Write File",
2436
+ description: "Create a new file or overwrite existing. Creates parent dirs. For existing files prefer edit_file (shows diff, verifies hashes).",
2437
+ inputSchema: z2.object({
2438
+ path: z2.string().describe("File path"),
2439
+ content: z2.string().describe("File content")
2440
+ }),
2441
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }
2442
+ }, async (rawParams) => {
2443
+ const { path: p, content } = coerceParams(rawParams);
2444
+ try {
2445
+ const abs = validateWritePath(p);
2446
+ mkdirSync2(dirname4(abs), { recursive: true });
2447
+ writeFileSync4(abs, content, "utf-8");
2448
+ return { content: [{ type: "text", text: `Created ${p} (${content.split("\n").length} lines)` }] };
2449
+ } catch (e) {
2450
+ return { content: [{ type: "text", text: e.message }], isError: true };
2451
+ }
2452
+ });
2453
+ server.registerTool("grep_search", {
2454
+ title: "Search Files",
2455
+ description: "Search file contents with ripgrep. Returns hash-annotated matches with per-group checksums for direct editing. Output modes: content (default, edit-ready hashes+checksums), files (paths only), count (match counts). For single-line edits: grep -> set_line directly. For range edits: use checksum from grep output. ALWAYS prefer over shell grep/rg/findstr.",
2456
+ inputSchema: z2.object({
2457
+ pattern: z2.string().describe("Search pattern (regex by default, literal if literal:true)"),
2458
+ path: z2.string().optional().describe("Search dir/file (default: cwd)"),
2459
+ glob: z2.string().optional().describe('Glob filter (e.g. "*.ts")'),
2460
+ type: z2.string().optional().describe('File type (e.g. "js", "py")'),
2461
+ output: z2.enum(["content", "files", "count"]).optional().describe("Output format (default: content)"),
2462
+ case_insensitive: flexBool().describe("Ignore case (-i)"),
2463
+ smart_case: flexBool().describe("CI when pattern is all lowercase, CS if uppercase (-S)"),
2464
+ literal: flexBool().describe("Literal string search, no regex (-F)"),
2465
+ multiline: flexBool().describe("Pattern can span multiple lines (-U)"),
2466
+ context: flexNum().describe("Symmetric context lines around matches (-C)"),
2467
+ context_before: flexNum().describe("Context lines BEFORE match (-B)"),
2468
+ context_after: flexNum().describe("Context lines AFTER match (-A)"),
2469
+ limit: flexNum().describe("Max matches per file (default: 100)"),
2470
+ total_limit: flexNum().describe("Total match events across all files; multiline matches count as 1 (0 = unlimited)"),
2471
+ plain: flexBool().describe("Omit hash tags, return file:line:content")
2472
+ }),
2473
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }
2474
+ }, async (rawParams) => {
2475
+ const {
2476
+ pattern,
2477
+ path: p,
2478
+ glob,
2479
+ type,
2480
+ output,
2481
+ case_insensitive,
2482
+ smart_case,
2483
+ literal,
2484
+ multiline,
2485
+ context,
2486
+ context_before,
2487
+ context_after,
2488
+ limit,
2489
+ total_limit,
2490
+ plain
2491
+ } = coerceParams(rawParams);
2492
+ try {
2493
+ const result = await grepSearch(pattern, {
2494
+ path: p,
2495
+ glob,
2496
+ type,
2497
+ output,
2498
+ caseInsensitive: case_insensitive,
2499
+ smartCase: smart_case,
2500
+ literal,
2501
+ multiline,
2502
+ context,
2503
+ contextBefore: context_before,
2504
+ contextAfter: context_after,
2505
+ limit,
2506
+ totalLimit: total_limit,
2507
+ plain
2508
+ });
2509
+ return { content: [{ type: "text", text: result }] };
2510
+ } catch (e) {
2511
+ return { content: [{ type: "text", text: e.message }], isError: true };
2512
+ }
2513
+ });
2514
+ server.registerTool("outline", {
2515
+ title: "File Outline",
2516
+ description: "AST-based structural outline: functions, classes, interfaces with line ranges. 10-20 lines instead of 500 \u2014 95% token reduction. Use before reading large code files. NOT for .md/.json/.yaml \u2014 use read_file.",
2517
+ inputSchema: z2.object({
2518
+ path: z2.string().describe("Source file path")
2519
+ }),
2520
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }
2521
+ }, async (rawParams) => {
2522
+ const { path: p } = coerceParams(rawParams);
2523
+ try {
2524
+ const result = await fileOutline(p);
2525
+ return { content: [{ type: "text", text: result }] };
2526
+ } catch (e) {
2527
+ return { content: [{ type: "text", text: e.message }], isError: true };
2528
+ }
2529
+ });
2530
+ server.registerTool("verify", {
2531
+ title: "Verify Checksums",
2532
+ description: "Check whether held checksums and optional base_revision are still current, without rereading the file.",
2533
+ inputSchema: z2.object({
2534
+ path: z2.string().describe("File path"),
2535
+ checksums: z2.string().describe('JSON array of checksum strings, e.g. ["1-50:f7e2a1b0", "51-100:abcd1234"]'),
2536
+ base_revision: z2.string().optional().describe("Optional prior revision to compare against latest state.")
2537
+ }),
2538
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }
2539
+ }, async (rawParams) => {
2540
+ const { path: p, checksums, base_revision } = coerceParams(rawParams);
2541
+ try {
2542
+ const parsed = JSON.parse(checksums);
2543
+ if (!Array.isArray(parsed)) throw new Error("checksums must be a JSON array of strings");
2544
+ return { content: [{ type: "text", text: verifyChecksums(p, parsed, { baseRevision: base_revision }) }] };
2545
+ } catch (e) {
2546
+ return { content: [{ type: "text", text: e.message }], isError: true };
2547
+ }
2548
+ });
2549
+ server.registerTool("directory_tree", {
2550
+ title: "Directory Tree",
2551
+ description: "Compact directory tree with root .gitignore support (path-based rules, negation). Supports pattern glob to find files/dirs by name (like find -name). Use to understand repo structure or find specific files/dirs. Skips node_modules, .git, dist by default.",
2552
+ inputSchema: z2.object({
2553
+ path: z2.string().describe("Directory path"),
2554
+ pattern: z2.string().optional().describe('Glob filter on names (e.g. "*-mcp", "*.mjs"). Returns flat match list instead of tree'),
2555
+ type: z2.enum(["file", "dir", "all"]).optional().describe('"file", "dir", or "all" (default). Like find -type f/d'),
2556
+ max_depth: flexNum().describe("Max recursion depth (default: 3, or 20 in pattern mode)"),
2557
+ gitignore: flexBool().describe("Respect root .gitignore patterns (default: true). Nested .gitignore not supported"),
2558
+ format: z2.enum(["compact", "full"]).optional().describe('"compact" = names only, no sizes, depth 1. "full" = default with sizes')
2559
+ }),
2560
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }
2561
+ }, async (rawParams) => {
2562
+ const { path: p, max_depth, gitignore, format, pattern, type: entryType } = coerceParams(rawParams);
2563
+ try {
2564
+ return { content: [{ type: "text", text: directoryTree(p, { max_depth, gitignore, format, pattern, type: entryType }) }] };
2565
+ } catch (e) {
2566
+ return { content: [{ type: "text", text: e.message }], isError: true };
2567
+ }
2568
+ });
2569
+ server.registerTool("get_file_info", {
2570
+ title: "File Info",
2571
+ description: "File metadata without reading content: size, line count, modification time, type, binary detection. Use before reading large files to check size.",
2572
+ inputSchema: z2.object({
2573
+ path: z2.string().describe("File path")
2574
+ }),
2575
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }
2576
+ }, async (rawParams) => {
2577
+ const { path: p } = coerceParams(rawParams);
2578
+ try {
2579
+ return { content: [{ type: "text", text: fileInfo(p) }] };
2580
+ } catch (e) {
2581
+ return { content: [{ type: "text", text: e.message }], isError: true };
2582
+ }
2583
+ });
2584
+ server.registerTool("setup_hooks", {
2585
+ title: "Setup Hooks",
2586
+ description: "Install or uninstall hex-line hooks in CLI agent settings. install: writes hooks to ~/.claude/settings.json, removes old per-project hooks. uninstall: removes hex-line hooks from global settings. Idempotent: re-running produces no changes if already in desired state.",
2587
+ inputSchema: z2.object({
2588
+ agent: z2.string().optional().describe('Target agent: "claude", "gemini", "codex", or "all" (default: "all")'),
2589
+ action: z2.string().optional().describe('"install" (default) or "uninstall"')
2590
+ }),
2591
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }
2592
+ }, async (rawParams) => {
2593
+ const { agent, action } = coerceParams(rawParams);
2594
+ try {
2595
+ return { content: [{ type: "text", text: setupHooks(agent, action) }] };
2596
+ } catch (e) {
2597
+ return { content: [{ type: "text", text: e.message }], isError: true };
2598
+ }
2599
+ });
2600
+ server.registerTool("changes", {
2601
+ title: "Semantic Diff",
2602
+ description: "Compare file or directory against git ref (default: HEAD). For files: shows added/removed/modified symbols at AST level. For directories: lists changed files with insertions/deletions stats. Use to understand what changed before committing.",
2603
+ inputSchema: z2.object({
2604
+ path: z2.string().describe("File or directory path"),
2605
+ compare_against: z2.string().optional().describe('Git ref to compare against (default: "HEAD")')
2606
+ }),
2607
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }
2608
+ }, async (rawParams) => {
2609
+ const { path: p, compare_against } = coerceParams(rawParams);
2610
+ try {
2611
+ return { content: [{ type: "text", text: await fileChanges(p, compare_against) }] };
2612
+ } catch (e) {
2613
+ return { content: [{ type: "text", text: e.message }], isError: true };
2614
+ }
2615
+ });
2616
+ server.registerTool("bulk_replace", {
2617
+ title: "Bulk Replace",
2618
+ description: "Search-and-replace across multiple files. Finds files by glob, applies ordered text replacements, returns per-file diffs. Use dry_run:true to preview. For single-file rename, set glob to the filename.",
2619
+ inputSchema: z2.object({
2620
+ replacements: z2.string().describe('JSON array of {old, new} pairs: [{"old":"foo","new":"bar"}]'),
2621
+ glob: z2.string().optional().describe('File glob (default: "**/*.{md,mjs,json,yml,ts,js}")'),
2622
+ path: z2.string().optional().describe("Root directory (default: cwd)"),
2623
+ dry_run: flexBool().describe("Preview without writing (default: false)"),
2624
+ max_files: flexNum().describe("Max files to process (default: 100)")
2625
+ }),
2626
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false }
2627
+ }, async (rawParams) => {
2628
+ try {
2629
+ const params = coerceParams(rawParams);
2630
+ const replacements = JSON.parse(params.replacements);
2631
+ if (!Array.isArray(replacements) || !replacements.length) throw new Error("replacements: non-empty JSON array of {old, new} required");
2632
+ const result = bulkReplace(
2633
+ params.path || process.cwd(),
2634
+ params.glob || "**/*.{md,mjs,json,yml,ts,js}",
2635
+ replacements,
2636
+ { dryRun: params.dry_run || false, maxFiles: params.max_files || 100 }
2637
+ );
2638
+ return { content: [{ type: "text", text: result }] };
2639
+ } catch (e) {
2640
+ return { content: [{ type: "text", text: e.message }], isError: true };
2641
+ }
2642
+ });
2643
+ var transport = new StdioServerTransport();
2644
+ await server.connect(transport);
2645
+ void checkForUpdates("@levnikolaevich/hex-line-mcp", version);