@levnikolaevich/hex-line-mcp 1.3.5 → 1.3.6

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.
Files changed (2) hide show
  1. package/dist/server.mjs +219 -183
  2. package/package.json +1 -1
package/dist/server.mjs CHANGED
@@ -249,6 +249,7 @@ function listDirectory(dirPath, opts = {}) {
249
249
  return { text: lines.join("\n"), total };
250
250
  }
251
251
  var MAX_OUTPUT_CHARS = 8e4;
252
+ var MAX_DIFF_CHARS = 3e4;
252
253
  function readText(filePath) {
253
254
  return readFileSync(filePath, "utf-8").replace(/\r\n/g, "\n");
254
255
  }
@@ -1036,125 +1037,131 @@ function editFile(filePath, edits, opts = {}) {
1036
1037
  autoRebased = true;
1037
1038
  return null;
1038
1039
  };
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);
1040
+ for (let _ei = 0; _ei < sorted.length; _ei++) {
1041
+ const e = sorted[_ei];
1042
+ try {
1043
+ if (e.set_line) {
1044
+ const { tag, line } = parseRef(e.set_line.anchor);
1045
+ const idx = locateOrConflict({ tag, line });
1046
+ if (typeof idx === "string") return idx;
1047
+ const conflict = ensureRevisionContext(idx + 1, idx + 1, idx);
1081
1048
  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
- );
1049
+ const txt = e.set_line.new_text;
1050
+ if (!txt && txt !== 0) {
1051
+ lines.splice(idx, 1);
1052
+ } else {
1053
+ const origLine = [lines[idx]];
1054
+ const raw = String(txt).split("\n");
1055
+ const newLines = opts.restoreIndent ? restoreIndent(origLine, raw) : raw;
1056
+ lines.splice(idx, 1, ...newLines);
1090
1057
  }
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}.
1058
+ continue;
1059
+ }
1060
+ if (e.insert_after) {
1061
+ const { tag, line } = parseRef(e.insert_after.anchor);
1062
+ const idx = locateOrConflict({ tag, line });
1063
+ if (typeof idx === "string") return idx;
1064
+ const conflict = ensureRevisionContext(idx + 1, idx + 1, idx);
1065
+ if (conflict) return conflict;
1066
+ let insertLines = e.insert_after.text.split("\n");
1067
+ if (opts.restoreIndent) insertLines = restoreIndent([lines[idx]], insertLines);
1068
+ lines.splice(idx + 1, 0, ...insertLines);
1069
+ continue;
1070
+ }
1071
+ if (e.replace_lines) {
1072
+ const s = parseRef(e.replace_lines.start_anchor);
1073
+ const en = parseRef(e.replace_lines.end_anchor);
1074
+ const si = locateOrConflict(s);
1075
+ if (typeof si === "string") return si;
1076
+ const ei = locateOrConflict(en);
1077
+ if (typeof ei === "string") return ei;
1078
+ const actualStart = si + 1;
1079
+ const actualEnd = ei + 1;
1080
+ const rc = e.replace_lines.range_checksum;
1081
+ if (!rc) throw new Error("range_checksum required for replace_lines. Read the range first via read_file, then pass its checksum. The checksum range must cover start-to-end anchors (inclusive).");
1082
+ if (staleRevision && conflictPolicy === "conservative") {
1083
+ const conflict = ensureRevisionContext(actualStart, actualEnd, si);
1084
+ if (conflict) return conflict;
1085
+ const baseCheck = hasBaseSnapshot ? verifyChecksumAgainstSnapshot(baseSnapshot, rc) : null;
1086
+ if (!baseCheck?.ok) {
1087
+ return conflictIfNeeded(
1088
+ "stale_checksum",
1089
+ si,
1090
+ baseCheck?.actual || null,
1091
+ baseCheck?.actual ? `Provided checksum ${rc} does not match base revision ${opts.baseRevision}.` : `Checksum range from ${rc} is outside the available base revision.`
1092
+ );
1093
+ }
1094
+ } else {
1095
+ const { start: csStart, end: csEnd, hex: csHex } = parseChecksum(rc);
1096
+ if (csStart > actualStart || csEnd < actualEnd) {
1097
+ const snip = buildErrorSnippet(origLines, actualStart - 1);
1098
+ throw new Error(
1099
+ `CHECKSUM_RANGE_GAP: checksum covers lines ${csStart}-${csEnd} but edit spans ${actualStart}-${actualEnd} (inclusive). Checksum range must fully contain the anchor range.
1097
1100
 
1098
1101
  Current content (lines ${snip.start}-${snip.end}):
1099
1102
  ${snip.text}
1100
1103
 
1101
1104
  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);
1105
+ );
1110
1106
  }
1111
- const snip = buildErrorSnippet(origLines, csStart - 1);
1112
- throw new Error(
1113
- `${details}
1107
+ const actual = buildRangeChecksum(currentSnapshot, csStart, csEnd);
1108
+ const actualHex = actual?.split(":")[1];
1109
+ if (!actual || csHex !== actualHex) {
1110
+ const details = `CHECKSUM_MISMATCH: expected ${rc}, got ${actual}. Content at lines ${csStart}-${csEnd} differs from when you read it \u2014 re-read before editing.`;
1111
+ if (conflictPolicy === "conservative") {
1112
+ return conflictIfNeeded("stale_checksum", csStart - 1, actual, details);
1113
+ }
1114
+ const snip = buildErrorSnippet(origLines, csStart - 1);
1115
+ throw new Error(
1116
+ `${details}
1114
1117
 
1115
1118
  Current content (lines ${snip.start}-${snip.end}):
1116
1119
  ${snip.text}
1117
1120
 
1118
1121
  Retry with fresh checksum ${actual}, or use set_line with hashes above.`
1119
- );
1122
+ );
1123
+ }
1120
1124
  }
1125
+ const txt = e.replace_lines.new_text;
1126
+ if (!txt && txt !== 0) {
1127
+ lines.splice(si, ei - si + 1);
1128
+ } else {
1129
+ const origRange = lines.slice(si, ei + 1);
1130
+ let newLines = String(txt).split("\n");
1131
+ if (opts.restoreIndent) newLines = restoreIndent(origRange, newLines);
1132
+ lines.splice(si, ei - si + 1, ...newLines);
1133
+ }
1134
+ continue;
1121
1135
  }
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})`);
1136
+ if (e.replace_between) {
1137
+ const boundaryMode = e.replace_between.boundary_mode || "inclusive";
1138
+ if (boundaryMode !== "inclusive" && boundaryMode !== "exclusive") {
1139
+ throw new Error(`BAD_INPUT: replace_between boundary_mode must be inclusive or exclusive, got ${boundaryMode}`);
1140
+ }
1141
+ const s = parseRef(e.replace_between.start_anchor);
1142
+ const en = parseRef(e.replace_between.end_anchor);
1143
+ const si = locateOrConflict(s);
1144
+ if (typeof si === "string") return si;
1145
+ const ei = locateOrConflict(en);
1146
+ if (typeof ei === "string") return ei;
1147
+ if (si > ei) {
1148
+ throw new Error(`BAD_INPUT: replace_between start anchor resolves after end anchor (${si + 1} > ${ei + 1})`);
1149
+ }
1150
+ const targetRange = targetRangeForReplaceBetween(si, ei, boundaryMode);
1151
+ const conflict = ensureRevisionContext(targetRange.start, targetRange.end, si);
1152
+ if (conflict) return conflict;
1153
+ const txt = e.replace_between.new_text;
1154
+ let newLines = String(txt ?? "").split("\n");
1155
+ const sliceStart = boundaryMode === "exclusive" ? si + 1 : si;
1156
+ const removeCount = boundaryMode === "exclusive" ? Math.max(0, ei - si - 1) : ei - si + 1;
1157
+ const origRange = lines.slice(sliceStart, sliceStart + removeCount);
1158
+ if (opts.restoreIndent && origRange.length > 0) newLines = restoreIndent(origRange, newLines);
1159
+ if (txt === "" || txt === null) newLines = [];
1160
+ lines.splice(sliceStart, removeCount, ...newLines);
1146
1161
  }
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);
1162
+ } catch (editErr) {
1163
+ if (sorted.length > 1) editErr.message = `Edit ${_ei + 1}/${sorted.length}: ${editErr.message}`;
1164
+ throw editErr;
1158
1165
  }
1159
1166
  }
1160
1167
  let content = lines.join("\n");
@@ -1163,10 +1170,23 @@ Retry with fresh checksum ${actual}, or use set_line with hashes above.`
1163
1170
  if (original === content) {
1164
1171
  throw new Error("NOOP_EDIT: File already contains the desired content. No changes needed.");
1165
1172
  }
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)`;
1173
+ const fullDiff = simpleDiff(origLines, content.split("\n"));
1174
+ let displayDiff = fullDiff;
1175
+ if (displayDiff && displayDiff.length > MAX_DIFF_CHARS) {
1176
+ displayDiff = displayDiff.slice(0, MAX_DIFF_CHARS) + `
1177
+ ... (diff truncated, ${displayDiff.length} chars total)`;
1178
+ }
1179
+ const newLinesAll = content.split("\n");
1180
+ let minLine = Infinity, maxLine = 0;
1181
+ if (fullDiff) {
1182
+ for (const dl of fullDiff.split("\n")) {
1183
+ const m = dl.match(/^[+-](\d+)\|/);
1184
+ if (m) {
1185
+ const n = +m[1];
1186
+ if (n < minLine) minLine = n;
1187
+ if (n > maxLine) maxLine = n;
1188
+ }
1189
+ }
1170
1190
  }
1171
1191
  if (opts.dryRun) {
1172
1192
  let msg2 = `status: ${autoRebased ? "AUTO_REBASED" : "OK"}
@@ -1175,11 +1195,11 @@ file: ${currentSnapshot.fileChecksum}
1175
1195
  Dry run: ${filePath} would change (${content.split("\n").length} lines)`;
1176
1196
  if (staleRevision && hasBaseSnapshot) msg2 += `
1177
1197
  changed_ranges: ${describeChangedRanges(changedRanges)}`;
1178
- if (diff) msg2 += `
1198
+ if (displayDiff) msg2 += `
1179
1199
 
1180
1200
  Diff:
1181
1201
  \`\`\`diff
1182
- ${diff}
1202
+ ${displayDiff}
1183
1203
  \`\`\``;
1184
1204
  return msg2;
1185
1205
  }
@@ -1195,78 +1215,55 @@ changed_ranges: ${describeChangedRanges(changedRanges)}`;
1195
1215
  }
1196
1216
  msg += `
1197
1217
  Updated ${filePath} (${content.split("\n").length} lines)`;
1198
- if (diff) msg += `
1218
+ if (fullDiff && minLine <= maxLine) {
1219
+ const ctxStart = Math.max(0, minLine - 6);
1220
+ const ctxEnd = Math.min(newLinesAll.length, maxLine + 5);
1221
+ const ctxLines = [];
1222
+ const ctxHashes = [];
1223
+ for (let i = ctxStart; i < ctxEnd; i++) {
1224
+ const h = fnv1a(newLinesAll[i]);
1225
+ ctxHashes.push(h);
1226
+ ctxLines.push(`${lineTag(h)}.${i + 1} ${newLinesAll[i]}`);
1227
+ }
1228
+ const ctxCs = rangeChecksum(ctxHashes, ctxStart + 1, ctxEnd);
1229
+ msg += `
1199
1230
 
1200
- Diff:
1201
- \`\`\`diff
1202
- ${diff}
1203
- \`\`\``;
1231
+ Post-edit (lines ${ctxStart + 1}-${ctxEnd}):
1232
+ ${ctxLines.join("\n")}
1233
+ checksum: ${ctxCs}`;
1234
+ }
1204
1235
  try {
1205
1236
  const db = getGraphDB(real);
1206
1237
  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 += `
1238
+ if (db && relFile && fullDiff && minLine <= maxLine) {
1239
+ const affected = callImpact(db, relFile, minLine, maxLine);
1240
+ if (affected.length > 0) {
1241
+ const list = affected.map((a) => `${a.name} (${a.file}:${a.line})`).join(", ");
1242
+ msg += `
1223
1243
 
1224
1244
  \u26A0 Call impact: ${affected.length} callers in other files
1225
1245
  ${list}`;
1226
- }
1227
1246
  }
1228
1247
  }
1229
1248
  } catch {
1230
1249
  }
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 += `
1250
+ if (displayDiff) msg += `
1255
1251
 
1256
- Post-edit (lines ${ctxStart + 1}-${ctxEnd}):
1257
- ${ctxLines.join("\n")}
1258
- checksum: ${ctxCs}`;
1259
- }
1260
- }
1252
+ Diff:
1253
+ \`\`\`diff
1254
+ ${displayDiff}
1255
+ \`\`\``;
1261
1256
  return msg;
1262
1257
  }
1263
1258
 
1264
1259
  // lib/search.mjs
1265
1260
  import { spawn } from "node:child_process";
1266
- import { resolve as resolve2 } from "node:path";
1261
+ import { resolve as resolve2, isAbsolute as isAbsolute2 } from "node:path";
1262
+ import { existsSync as existsSync3 } from "node:fs";
1267
1263
  var rgBin = "rg";
1268
1264
  try {
1269
1265
  rgBin = (await import("@vscode/ripgrep")).rgPath;
1266
+ if (isAbsolute2(rgBin) && !existsSync3(rgBin)) rgBin = "rg";
1270
1267
  } catch {
1271
1268
  }
1272
1269
  var DEFAULT_LIMIT2 = 100;
@@ -1292,7 +1289,13 @@ function spawnRg(args) {
1292
1289
  stderrBuf += chunk.toString("utf-8");
1293
1290
  });
1294
1291
  child.on("error", (err) => {
1295
- reject(new Error(`rg spawn error: ${err.message}`));
1292
+ if (err.code === "ENOENT") {
1293
+ reject(new Error(
1294
+ `ripgrep not available. Reinstall dependencies so @vscode/ripgrep can provide its binary, or install system rg and add it to PATH. Attempted binary: "${rgBin}".`
1295
+ ));
1296
+ } else {
1297
+ reject(new Error(`rg spawn error: ${err.message}`));
1298
+ }
1296
1299
  });
1297
1300
  child.on("close", (code) => {
1298
1301
  resolve_({ stdout, code, stderr: stderrBuf, killed });
@@ -1694,7 +1697,7 @@ file: ${current.fileChecksum}`;
1694
1697
  }
1695
1698
 
1696
1699
  // lib/tree.mjs
1697
- import { readdirSync as readdirSync2, readFileSync as readFileSync3, statSync as statSync6, existsSync as existsSync3 } from "node:fs";
1700
+ import { readdirSync as readdirSync2, readFileSync as readFileSync3, statSync as statSync6, existsSync as existsSync4 } from "node:fs";
1698
1701
  import { resolve as resolve4, basename, join as join4, relative as relative2 } from "node:path";
1699
1702
  import ignore from "ignore";
1700
1703
  var SKIP_DIRS = /* @__PURE__ */ new Set([
@@ -1713,7 +1716,7 @@ function globToRegex(pat) {
1713
1716
  }
1714
1717
  function loadGitignore(rootDir) {
1715
1718
  const gi = join4(rootDir, ".gitignore");
1716
- if (!existsSync3(gi)) return null;
1719
+ if (!existsSync4(gi)) return null;
1717
1720
  try {
1718
1721
  const content = readFileSync3(gi, "utf-8");
1719
1722
  return ignore().add(content);
@@ -1730,7 +1733,7 @@ function findByPattern(dirPath, opts) {
1730
1733
  const filterType = opts.type || "all";
1731
1734
  const maxDepth = opts.max_depth ?? 20;
1732
1735
  const abs = resolve4(normalizePath(dirPath));
1733
- if (!existsSync3(abs)) throw new Error(`DIRECTORY_NOT_FOUND: ${abs}`);
1736
+ if (!existsSync4(abs)) throw new Error(`DIRECTORY_NOT_FOUND: ${abs}`);
1734
1737
  if (!statSync6(abs).isDirectory()) throw new Error(`Not a directory: ${abs}`);
1735
1738
  const ig = opts.gitignore ?? true ? loadGitignore(abs) : null;
1736
1739
  const matches = [];
@@ -1771,7 +1774,7 @@ function directoryTree(dirPath, opts = {}) {
1771
1774
  const compact = opts.format === "compact";
1772
1775
  const maxDepth = compact ? 1 : opts.max_depth ?? 3;
1773
1776
  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.`);
1777
+ if (!existsSync4(abs)) throw new Error(`DIRECTORY_NOT_FOUND: ${abs}. Check path or use directory_tree on parent directory.`);
1775
1778
  const rootStat = statSync6(abs);
1776
1779
  if (!rootStat.isDirectory()) throw new Error(`Not a directory: ${abs}`);
1777
1780
  const ig = opts.gitignore ?? true ? loadGitignore(abs) : null;
@@ -1868,7 +1871,7 @@ ${lines.join("\n")}`;
1868
1871
 
1869
1872
  // lib/info.mjs
1870
1873
  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";
1874
+ import { resolve as resolve5, isAbsolute as isAbsolute3, extname as extname2, basename as basename2 } from "node:path";
1872
1875
  var MAX_LINE_COUNT_SIZE = 10 * 1024 * 1024;
1873
1876
  var EXT_NAMES = {
1874
1877
  ".ts": "TypeScript source",
@@ -1937,7 +1940,7 @@ function detectBinary(filePath, size) {
1937
1940
  function fileInfo(filePath) {
1938
1941
  if (!filePath) throw new Error("Empty file path");
1939
1942
  const normalized = normalizePath(filePath);
1940
- const abs = isAbsolute2(normalized) ? normalized : resolve5(process.cwd(), normalized);
1943
+ const abs = isAbsolute3(normalized) ? normalized : resolve5(process.cwd(), normalized);
1941
1944
  const stat = statSync7(abs);
1942
1945
  if (!stat.isFile()) throw new Error(`Not a regular file: ${abs}`);
1943
1946
  const size = stat.size;
@@ -1961,7 +1964,7 @@ function fileInfo(filePath) {
1961
1964
  }
1962
1965
 
1963
1966
  // lib/setup.mjs
1964
- import { readFileSync as readFileSync4, writeFileSync as writeFileSync2, existsSync as existsSync4, mkdirSync } from "node:fs";
1967
+ import { readFileSync as readFileSync4, writeFileSync as writeFileSync2, existsSync as existsSync5, mkdirSync } from "node:fs";
1965
1968
  import { resolve as resolve6, dirname as dirname3 } from "node:path";
1966
1969
  import { fileURLToPath } from "node:url";
1967
1970
  import { homedir } from "node:os";
@@ -1989,7 +1992,7 @@ var CLAUDE_HOOKS = {
1989
1992
  }
1990
1993
  };
1991
1994
  function readJson(filePath) {
1992
- if (!existsSync4(filePath)) return null;
1995
+ if (!existsSync5(filePath)) return null;
1993
1996
  return JSON.parse(readFileSync4(filePath, "utf-8"));
1994
1997
  }
1995
1998
  function writeJson(filePath, data) {
@@ -2358,12 +2361,33 @@ ${results.join("\n\n")}` : header;
2358
2361
  }
2359
2362
 
2360
2363
  // server.mjs
2361
- var version = true ? "1.3.5" : (await null).createRequire(import.meta.url)("./package.json").version;
2364
+ var version = true ? "1.3.6" : (await null).createRequire(import.meta.url)("./package.json").version;
2362
2365
  var { server, StdioServerTransport } = await createServerRuntime({
2363
2366
  name: "hex-line-mcp",
2364
2367
  version,
2365
2368
  installDir: "mcp/hex-line-mcp"
2366
2369
  });
2370
+ var replacementPairsSchema = z2.array(
2371
+ z2.object({ old: z2.string().min(1), new: z2.string() })
2372
+ ).min(1);
2373
+ function coerceEdit(e) {
2374
+ if (!e || typeof e !== "object" || Array.isArray(e)) return e;
2375
+ if (e.set_line || e.replace_lines || e.insert_after || e.replace_between || e.replace) return e;
2376
+ if (e.anchor && !e.start_anchor && !e.end_anchor && !e.boundary_mode && !e.range_checksum) {
2377
+ const raw = e.new_text ?? e.updated_lines ?? e.content ?? e.line;
2378
+ if (raw !== void 0) {
2379
+ const text = Array.isArray(raw) ? raw.join("\n") : raw;
2380
+ return { set_line: { anchor: e.anchor, new_text: text } };
2381
+ }
2382
+ }
2383
+ if (e.start_anchor && e.end_anchor && e.boundary_mode && e.new_text !== void 0) {
2384
+ return { replace_between: { start_anchor: e.start_anchor, end_anchor: e.end_anchor, new_text: e.new_text, boundary_mode: e.boundary_mode } };
2385
+ }
2386
+ if (e.start_anchor && e.end_anchor && e.new_text !== void 0) {
2387
+ return { replace_lines: { start_anchor: e.start_anchor, end_anchor: e.end_anchor, new_text: e.new_text, ...e.range_checksum ? { range_checksum: e.range_checksum } : {} } };
2388
+ }
2389
+ return e;
2390
+ }
2367
2391
  server.registerTool("read_file", {
2368
2392
  title: "Read File",
2369
2393
  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.",
@@ -2402,8 +2426,8 @@ server.registerTool("edit_file", {
2402
2426
  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
2427
  inputSchema: z2.object({
2404
2428
  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.'
2429
+ edits: z2.union([z2.string(), z2.array(z2.any())]).describe(
2430
+ 'JSON array. Types: set_line, replace_lines, insert_after, replace_between.\n[{"set_line":{"anchor":"ab.12","new_text":"x"}}]\n[{"replace_lines":{"start_anchor":"ab.10","end_anchor":"cd.15","new_text":"x","range_checksum":"10-15:a1b2"}}]\n[{"replace_between":{"start_anchor":"ab.10","end_anchor":"cd.40","new_text":"x","boundary_mode":"inclusive"}}]\n[{"insert_after":{"anchor":"ab.20","text":"x"}}]'
2407
2431
  ),
2408
2432
  dry_run: flexBool().describe("Preview changes without writing"),
2409
2433
  restore_indent: flexBool().describe("Auto-fix indentation to match anchor (default: false)"),
@@ -2414,12 +2438,18 @@ server.registerTool("edit_file", {
2414
2438
  }, async (rawParams) => {
2415
2439
  const { path: p, edits: json, dry_run, restore_indent, base_revision, conflict_policy } = coerceParams(rawParams);
2416
2440
  try {
2417
- const parsed = JSON.parse(json);
2441
+ let parsed;
2442
+ try {
2443
+ parsed = typeof json === "string" ? JSON.parse(json) : json;
2444
+ } catch {
2445
+ throw new Error('edits: invalid JSON. Expected: [{"set_line":{"anchor":"xx.N","new_text":"..."}}]');
2446
+ }
2418
2447
  if (!Array.isArray(parsed) || !parsed.length) throw new Error("Edits: non-empty JSON array required");
2448
+ const normalized = parsed.map(coerceEdit);
2419
2449
  return {
2420
2450
  content: [{
2421
2451
  type: "text",
2422
- text: editFile(p, parsed, {
2452
+ text: editFile(p, normalized, {
2423
2453
  dryRun: dry_run,
2424
2454
  restoreIndent: restore_indent,
2425
2455
  baseRevision: base_revision,
@@ -2452,7 +2482,7 @@ server.registerTool("write_file", {
2452
2482
  });
2453
2483
  server.registerTool("grep_search", {
2454
2484
  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.",
2485
+ description: "Search file contents with ripgrep. Returns hash-annotated matches with checksums. Modes: content (default), files, count. Use checksums with set_line/replace_lines. Prefer over shell grep/rg.",
2456
2486
  inputSchema: z2.object({
2457
2487
  pattern: z2.string().describe("Search pattern (regex by default, literal if literal:true)"),
2458
2488
  path: z2.string().optional().describe("Search dir/file (default: cwd)"),
@@ -2513,7 +2543,7 @@ server.registerTool("grep_search", {
2513
2543
  });
2514
2544
  server.registerTool("outline", {
2515
2545
  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.",
2546
+ description: "AST-based structural outline: functions, classes, interfaces with line ranges. Use before reading large code files. Not for .md/.json/.yaml.",
2517
2547
  inputSchema: z2.object({
2518
2548
  path: z2.string().describe("Source file path")
2519
2549
  }),
@@ -2548,7 +2578,7 @@ server.registerTool("verify", {
2548
2578
  });
2549
2579
  server.registerTool("directory_tree", {
2550
2580
  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.",
2581
+ description: "Directory tree with .gitignore support. Pattern glob to find files/dirs by name. Skips node_modules, .git, dist.",
2552
2582
  inputSchema: z2.object({
2553
2583
  path: z2.string().describe("Directory path"),
2554
2584
  pattern: z2.string().optional().describe('Glob filter on names (e.g. "*-mcp", "*.mjs"). Returns flat match list instead of tree'),
@@ -2583,7 +2613,7 @@ server.registerTool("get_file_info", {
2583
2613
  });
2584
2614
  server.registerTool("setup_hooks", {
2585
2615
  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.",
2616
+ description: "Install or uninstall hex-line hooks in CLI agent settings. Idempotent.",
2587
2617
  inputSchema: z2.object({
2588
2618
  agent: z2.string().optional().describe('Target agent: "claude", "gemini", "codex", or "all" (default: "all")'),
2589
2619
  action: z2.string().optional().describe('"install" (default) or "uninstall"')
@@ -2599,7 +2629,7 @@ server.registerTool("setup_hooks", {
2599
2629
  });
2600
2630
  server.registerTool("changes", {
2601
2631
  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.",
2632
+ description: "Compare file or directory against git ref (default: HEAD). Shows added/removed/modified symbols or file stats.",
2603
2633
  inputSchema: z2.object({
2604
2634
  path: z2.string().describe("File or directory path"),
2605
2635
  compare_against: z2.string().optional().describe('Git ref to compare against (default: "HEAD")')
@@ -2617,7 +2647,7 @@ server.registerTool("bulk_replace", {
2617
2647
  title: "Bulk Replace",
2618
2648
  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
2649
  inputSchema: z2.object({
2620
- replacements: z2.string().describe('JSON array of {old, new} pairs: [{"old":"foo","new":"bar"}]'),
2650
+ replacements: z2.union([z2.string(), replacementPairsSchema]).describe('JSON array of {old, new} pairs: [{"old":"foo","new":"bar"}]'),
2621
2651
  glob: z2.string().optional().describe('File glob (default: "**/*.{md,mjs,json,yml,ts,js}")'),
2622
2652
  path: z2.string().optional().describe("Root directory (default: cwd)"),
2623
2653
  dry_run: flexBool().describe("Preview without writing (default: false)"),
@@ -2627,8 +2657,14 @@ server.registerTool("bulk_replace", {
2627
2657
  }, async (rawParams) => {
2628
2658
  try {
2629
2659
  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");
2660
+ const raw = params.replacements;
2661
+ let replacementsInput;
2662
+ try {
2663
+ replacementsInput = typeof raw === "string" ? JSON.parse(raw) : raw;
2664
+ } catch {
2665
+ throw new Error('replacements: invalid JSON. Expected: [{"old":"text","new":"replacement"}]');
2666
+ }
2667
+ const replacements = replacementPairsSchema.parse(replacementsInput);
2632
2668
  const result = bulkReplace(
2633
2669
  params.path || process.cwd(),
2634
2670
  params.glob || "**/*.{md,mjs,json,yml,ts,js}",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@levnikolaevich/hex-line-mcp",
3
- "version": "1.3.5",
3
+ "version": "1.3.6",
4
4
  "mcpName": "io.github.levnikolaevich/hex-line-mcp",
5
5
  "type": "module",
6
6
  "description": "Hash-verified file editing MCP + token efficiency hook for AI coding agents. 11 tools: read, edit, write, grep, outline, verify, directory_tree, file_info, setup_hooks, changes, bulk_replace.",