@levnikolaevich/hex-line-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +293 -0
- package/benchmark.mjs +1180 -0
- package/hook.mjs +299 -0
- package/lib/bulk-replace.mjs +55 -0
- package/lib/changes.mjs +174 -0
- package/lib/coerce.mjs +43 -0
- package/lib/edit.mjs +420 -0
- package/lib/graph-enrich.mjs +208 -0
- package/lib/hash.mjs +109 -0
- package/lib/info.mjs +109 -0
- package/lib/normalize.mjs +106 -0
- package/lib/outline.mjs +200 -0
- package/lib/read.mjs +129 -0
- package/lib/search.mjs +132 -0
- package/lib/security.mjs +114 -0
- package/lib/setup.mjs +132 -0
- package/lib/tree.mjs +162 -0
- package/lib/update-check.mjs +56 -0
- package/lib/verify.mjs +54 -0
- package/package.json +57 -0
- package/server.mjs +368 -0
package/hook.mjs
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Unified hook for hex-line-mcp.
|
|
4
|
+
*
|
|
5
|
+
* Handles three events:
|
|
6
|
+
*
|
|
7
|
+
* PreToolUse:
|
|
8
|
+
* - Tool redirect: blocks Read/Edit/Write/Grep for text files,
|
|
9
|
+
* redirecting to hex-line MCP equivalents.
|
|
10
|
+
* - Bash redirect: blocks simple cat/head/tail/ls/grep/sed/diff
|
|
11
|
+
* commands, redirecting to hex-line MCP equivalents.
|
|
12
|
+
* - Dangerous command blocker: blocks rm -rf /, force push,
|
|
13
|
+
* hard reset, DROP, chmod 777, mkfs, dd, etc.
|
|
14
|
+
*
|
|
15
|
+
* PostToolUse:
|
|
16
|
+
* - RTK output filter: compresses verbose Bash output
|
|
17
|
+
* (npm install, test, build, pip, git) to save context tokens.
|
|
18
|
+
*
|
|
19
|
+
* SessionStart:
|
|
20
|
+
* - Injects tool preference list into agent context.
|
|
21
|
+
*
|
|
22
|
+
* Exit 0 = approve / no feedback / systemMessage
|
|
23
|
+
* Exit 2 = block (PreToolUse) or feedback via stderr (PostToolUse)
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { deduplicateLines, smartTruncate } from "./lib/normalize.mjs";
|
|
27
|
+
|
|
28
|
+
// ---- Constants ----
|
|
29
|
+
|
|
30
|
+
const BINARY_EXT = new Set([
|
|
31
|
+
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg", ".ico",
|
|
32
|
+
".pdf", ".ipynb",
|
|
33
|
+
".zip", ".tar", ".gz", ".7z", ".rar",
|
|
34
|
+
".exe", ".dll", ".so", ".dylib", ".wasm",
|
|
35
|
+
".mp3", ".mp4", ".wav", ".avi", ".mkv",
|
|
36
|
+
".ttf", ".otf", ".woff", ".woff2",
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
const TOOL_HINTS = {
|
|
40
|
+
Read: "mcp__hex-line__read_file (not Read, not cat/head/tail)",
|
|
41
|
+
Edit: "mcp__hex-line__edit_file (not Edit, not sed -i)",
|
|
42
|
+
Write: "mcp__hex-line__write_file (not Write)",
|
|
43
|
+
Grep: "mcp__hex-line__grep_search (not Grep, not grep/rg)",
|
|
44
|
+
cat: "mcp__hex-line__read_file (not cat)",
|
|
45
|
+
head: "mcp__hex-line__read_file with offset/limit (not head)",
|
|
46
|
+
tail: "mcp__hex-line__read_file with offset (not tail)",
|
|
47
|
+
ls: "mcp__hex-line__directory_tree (not ls/find/tree)",
|
|
48
|
+
stat: "mcp__hex-line__get_file_info (not stat/wc -l)",
|
|
49
|
+
grep: "mcp__hex-line__grep_search (not grep/rg)",
|
|
50
|
+
sed: "mcp__hex-line__edit_file (not sed -i)",
|
|
51
|
+
diff: "mcp__hex-line__changes (not diff)",
|
|
52
|
+
outline: "mcp__hex-line__outline (before reading large code files)",
|
|
53
|
+
verify: "mcp__hex-line__verify (staleness check without re-read)",
|
|
54
|
+
changes: "mcp__hex-line__changes (semantic AST diff)",
|
|
55
|
+
bulk: "mcp__hex-line__bulk_replace (multi-file search-replace)",
|
|
56
|
+
setup: "mcp__hex-line__setup_hooks (configure hooks for agents)",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const BASH_REDIRECTS = [
|
|
60
|
+
{ regex: /^cat\s+\S+/, key: "cat" },
|
|
61
|
+
{ regex: /^head\s+/, key: "head" },
|
|
62
|
+
{ regex: /^tail\s+/, key: "tail" },
|
|
63
|
+
{ regex: /^(ls|dir)(\s+-\S+)*\s+/, key: "ls" },
|
|
64
|
+
{ regex: /^tree\s+/, key: "ls" },
|
|
65
|
+
{ regex: /^find\s+.*-name/, key: "ls" },
|
|
66
|
+
{ regex: /^(stat|wc\s+-l)\s+/, key: "stat" },
|
|
67
|
+
{ regex: /^(grep|rg)\s+/, key: "grep" },
|
|
68
|
+
{ regex: /^sed\s+-i/, key: "sed" },
|
|
69
|
+
{ regex: /^diff\s+/, key: "diff" },
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
const TOOL_REDIRECT_MAP = {
|
|
73
|
+
Read: "Read",
|
|
74
|
+
Edit: "Edit",
|
|
75
|
+
Write: "Write",
|
|
76
|
+
Grep: "Grep",
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const DANGEROUS_PATTERNS = [
|
|
80
|
+
{
|
|
81
|
+
regex: /rm\s+(-[rf]+\s+)*[/~]/,
|
|
82
|
+
reason: "rm -rf on root/home directory",
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
regex: /git\s+push\s+(-f|--force)/,
|
|
86
|
+
reason: "force push can overwrite remote history",
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
regex: /git\s+reset\s+--hard/,
|
|
90
|
+
reason: "hard reset discards uncommitted changes",
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
regex: /DROP\s+(TABLE|DATABASE)/i,
|
|
94
|
+
reason: "DROP destroys data permanently",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
regex: /chmod\s+777/,
|
|
98
|
+
reason: "chmod 777 removes all access restrictions",
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
regex: /mkfs/,
|
|
102
|
+
reason: "filesystem format destroys all data",
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
regex: /dd\s+if=\/dev\/zero/,
|
|
106
|
+
reason: "direct disk write destroys data",
|
|
107
|
+
},
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
const COMPOUND_OPERATORS = /[|]|>>?|&&|\|\||;/;
|
|
111
|
+
|
|
112
|
+
const CMD_PATTERNS = [
|
|
113
|
+
[/npm (install|ci|update|add)/i, "npm-install"],
|
|
114
|
+
[/npm test|jest|vitest|mocha|pytest|cargo test/i, "test"],
|
|
115
|
+
[/npm run build|tsc|webpack|vite build|cargo build/i, "build"],
|
|
116
|
+
[/pip install/i, "pip-install"],
|
|
117
|
+
[/git (log|diff|status)/i, "git"],
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
const LINE_THRESHOLD = 50;
|
|
121
|
+
const HEAD_LINES = 15;
|
|
122
|
+
const TAIL_LINES = 15;
|
|
123
|
+
|
|
124
|
+
// ---- Helpers ----
|
|
125
|
+
|
|
126
|
+
function extOf(filePath) {
|
|
127
|
+
const dot = filePath.lastIndexOf(".");
|
|
128
|
+
return dot !== -1 ? filePath.slice(dot).toLowerCase() : "";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function detectCommandType(cmd) {
|
|
132
|
+
for (const [re, type] of CMD_PATTERNS) {
|
|
133
|
+
if (re.test(cmd)) return type;
|
|
134
|
+
}
|
|
135
|
+
return "generic";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function block(reason) {
|
|
139
|
+
process.stdout.write(JSON.stringify({ decision: "block", reason }));
|
|
140
|
+
process.exit(2);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---- PreToolUse handler ----
|
|
144
|
+
|
|
145
|
+
function handlePreToolUse(data) {
|
|
146
|
+
const toolName = data.tool_name || "";
|
|
147
|
+
const toolInput = data.tool_input || {};
|
|
148
|
+
|
|
149
|
+
// Already using hex-line - approve silently
|
|
150
|
+
if (toolName.startsWith("mcp__hex-line__")) {
|
|
151
|
+
process.exit(0);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Tool redirect: Read / Edit / Write / Grep
|
|
155
|
+
const hintKey = TOOL_REDIRECT_MAP[toolName];
|
|
156
|
+
if (hintKey) {
|
|
157
|
+
const filePath = toolInput.file_path || toolInput.path || "";
|
|
158
|
+
|
|
159
|
+
// Skip binary extensions
|
|
160
|
+
if (BINARY_EXT.has(extOf(filePath))) {
|
|
161
|
+
process.exit(0);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Skip plan-mode and system paths (normalize backslashes for Windows)
|
|
165
|
+
const normalPath = filePath.replace(/\\/g, "/");
|
|
166
|
+
if (normalPath.includes(".claude/plans/") || normalPath.includes("AppData")) {
|
|
167
|
+
process.exit(0);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Block with redirect
|
|
171
|
+
block("Obligatory use " + TOOL_HINTS[hintKey]);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Bash tool checks
|
|
175
|
+
if (toolName === "Bash") {
|
|
176
|
+
const command = (toolInput.command || "").trim();
|
|
177
|
+
|
|
178
|
+
// User-confirmed bypass
|
|
179
|
+
if (command.includes("# hex-confirmed")) {
|
|
180
|
+
process.exit(0);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Dangerous command blocker
|
|
184
|
+
for (const { regex, reason } of DANGEROUS_PATTERNS) {
|
|
185
|
+
if (regex.test(command)) {
|
|
186
|
+
block(
|
|
187
|
+
`DANGEROUS: ${reason}. Ask user to confirm, then retry with: # hex-confirmed`
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Skip compound commands — pipes, redirects, chains are intentional
|
|
193
|
+
if (COMPOUND_OPERATORS.test(command)) {
|
|
194
|
+
process.exit(0);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Simple command redirect
|
|
198
|
+
for (const { regex, key } of BASH_REDIRECTS) {
|
|
199
|
+
if (regex.test(command)) {
|
|
200
|
+
block("Obligatory use " + TOOL_HINTS[key]);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Everything else - approve
|
|
206
|
+
process.exit(0);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---- PostToolUse handler ----
|
|
210
|
+
|
|
211
|
+
function handlePostToolUse(data) {
|
|
212
|
+
const toolName = data.tool_name || "";
|
|
213
|
+
|
|
214
|
+
// Only filter Bash output
|
|
215
|
+
if (toolName !== "Bash") {
|
|
216
|
+
process.exit(0);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const toolInput = data.tool_input || {};
|
|
220
|
+
const toolResult = data.tool_result;
|
|
221
|
+
const command = toolInput.command || "";
|
|
222
|
+
|
|
223
|
+
// Nothing to filter
|
|
224
|
+
if (!toolResult || typeof toolResult !== "string") {
|
|
225
|
+
process.exit(0);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const lines = toolResult.split("\n");
|
|
229
|
+
const originalCount = lines.length;
|
|
230
|
+
|
|
231
|
+
// Short output - no filtering
|
|
232
|
+
if (originalCount < LINE_THRESHOLD) {
|
|
233
|
+
process.exit(0);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const type = detectCommandType(command);
|
|
237
|
+
|
|
238
|
+
// Pipeline: deduplicate -> smart truncate
|
|
239
|
+
const deduped = deduplicateLines(lines);
|
|
240
|
+
const dedupedText = deduped.join("\n");
|
|
241
|
+
const filtered = smartTruncate(dedupedText, HEAD_LINES, TAIL_LINES);
|
|
242
|
+
const filteredCount = filtered.split("\n").length;
|
|
243
|
+
|
|
244
|
+
const header = `RTK FILTERED: ${type} (${originalCount} lines -> ${filteredCount} lines)`;
|
|
245
|
+
|
|
246
|
+
const output = [
|
|
247
|
+
"=".repeat(50),
|
|
248
|
+
header,
|
|
249
|
+
"=".repeat(50),
|
|
250
|
+
"",
|
|
251
|
+
filtered,
|
|
252
|
+
"",
|
|
253
|
+
"-".repeat(50),
|
|
254
|
+
`Original: ${originalCount} lines | Filtered: ${filteredCount} lines`,
|
|
255
|
+
"=".repeat(50),
|
|
256
|
+
].join("\n");
|
|
257
|
+
|
|
258
|
+
process.stderr.write(output);
|
|
259
|
+
process.exit(2);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---- SessionStart: inject tool preferences ----
|
|
263
|
+
|
|
264
|
+
function handleSessionStart() {
|
|
265
|
+
const seen = new Set();
|
|
266
|
+
const lines = [];
|
|
267
|
+
for (const hint of Object.values(TOOL_HINTS)) {
|
|
268
|
+
const tool = hint.split(" ")[0];
|
|
269
|
+
if (!seen.has(tool)) {
|
|
270
|
+
seen.add(tool);
|
|
271
|
+
lines.push(`- ${hint}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
lines.push("Exceptions: images, PDFs, notebooks \u2192 built-in Read");
|
|
275
|
+
lines.push("Bash OK for: npm/node/git/docker/curl, pipes, scripts");
|
|
276
|
+
const msg = "Hex-line MCP available. ALWAYS prefer:\n" + lines.join("\n");
|
|
277
|
+
process.stdout.write(JSON.stringify({ systemMessage: msg }));
|
|
278
|
+
process.exit(0);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ---- Main: read stdin, route by hook_event_name ----
|
|
282
|
+
|
|
283
|
+
let input = "";
|
|
284
|
+
process.stdin.on("data", (chunk) => {
|
|
285
|
+
input += chunk;
|
|
286
|
+
});
|
|
287
|
+
process.stdin.on("end", () => {
|
|
288
|
+
try {
|
|
289
|
+
const data = JSON.parse(input);
|
|
290
|
+
const event = data.hook_event_name || "";
|
|
291
|
+
|
|
292
|
+
if (event === "SessionStart") handleSessionStart();
|
|
293
|
+
else if (event === "PreToolUse") handlePreToolUse(data);
|
|
294
|
+
else if (event === "PostToolUse") handlePostToolUse(data);
|
|
295
|
+
else process.exit(0);
|
|
296
|
+
} catch {
|
|
297
|
+
process.exit(0);
|
|
298
|
+
}
|
|
299
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { execSync } from "node:child_process";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { simpleDiff } from "./edit.mjs";
|
|
5
|
+
|
|
6
|
+
export function bulkReplace(rootDir, globPattern, replacements, opts = {}) {
|
|
7
|
+
const { dryRun = false, maxFiles = 100 } = opts;
|
|
8
|
+
const abs = resolve(rootDir);
|
|
9
|
+
|
|
10
|
+
// Find files via ripgrep (respects .gitignore)
|
|
11
|
+
let files;
|
|
12
|
+
try {
|
|
13
|
+
const rgOut = execSync(`rg --files -g "${globPattern}" "${abs}"`, { encoding: "utf-8", timeout: 10000 });
|
|
14
|
+
files = rgOut.trim().split("\n").filter(Boolean);
|
|
15
|
+
} catch (e) {
|
|
16
|
+
if (e.status === 1) return "No files matched the glob pattern.";
|
|
17
|
+
throw new Error(`GREP_ERROR: ${e.message}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (files.length > maxFiles) {
|
|
21
|
+
return `TOO_MANY_FILES: Found ${files.length} files, max_files is ${maxFiles}. Use more specific glob or increase max_files.`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const results = [];
|
|
25
|
+
let changed = 0, skipped = 0, errors = 0;
|
|
26
|
+
|
|
27
|
+
for (const file of files) {
|
|
28
|
+
try {
|
|
29
|
+
const original = readFileSync(file, "utf-8").replace(/\r\n/g, "\n");
|
|
30
|
+
let content = original;
|
|
31
|
+
|
|
32
|
+
for (const { old: oldText, new: newText } of replacements) {
|
|
33
|
+
content = content.split(oldText).join(newText);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (content === original) { skipped++; continue; }
|
|
37
|
+
|
|
38
|
+
const diff = simpleDiff(original.split("\n"), content.split("\n"));
|
|
39
|
+
|
|
40
|
+
if (!dryRun) {
|
|
41
|
+
writeFileSync(file, content, "utf-8");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const relPath = file.replace(abs, "").replace(/^[/\\]/, "");
|
|
45
|
+
results.push(`--- ${relPath}\n${diff || "(no visible diff)"}`);
|
|
46
|
+
changed++;
|
|
47
|
+
} catch (e) {
|
|
48
|
+
results.push(`ERROR: ${file}: ${e.message}`);
|
|
49
|
+
errors++;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const header = `Bulk replace: ${changed} files changed, ${skipped} skipped, ${errors} errors (dry_run: ${dryRun})`;
|
|
54
|
+
return results.length ? `${header}\n\n${results.join("\n\n")}` : header;
|
|
55
|
+
}
|
package/lib/changes.mjs
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic diff: compare file against git ref using AST outlines.
|
|
3
|
+
*
|
|
4
|
+
* Shows added/removed/modified symbols — no line-level noise.
|
|
5
|
+
* Uses outlineFromContent() to parse both current and git versions
|
|
6
|
+
* without temp files.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execSync } from "node:child_process";
|
|
10
|
+
import { readFileSync, statSync } from "node:fs";
|
|
11
|
+
import { extname } from "node:path";
|
|
12
|
+
import { validatePath } from "./security.mjs";
|
|
13
|
+
import { outlineFromContent } from "./outline.mjs";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Extract symbol name from outline text.
|
|
17
|
+
* Strips parameters, braces, generics — keeps the identifier.
|
|
18
|
+
*
|
|
19
|
+
* "export async function fileOutline(filePath)" → "fileOutline"
|
|
20
|
+
* "const LANG_CONFIGS = {" → "LANG_CONFIGS"
|
|
21
|
+
* "class MyClass extends Base {" → "MyClass"
|
|
22
|
+
*/
|
|
23
|
+
function symbolName(text) {
|
|
24
|
+
// Remove trailing { and whitespace
|
|
25
|
+
const clean = text.replace(/\s*\{?\s*$/, "").trim();
|
|
26
|
+
// Remove everything from first ( onward (params)
|
|
27
|
+
const noParams = clean.replace(/\(.*$/, "").trim();
|
|
28
|
+
// Take last word — that's the identifier
|
|
29
|
+
const parts = noParams.split(/\s+/);
|
|
30
|
+
// Skip assignment: "const X = ..." → take word before "="
|
|
31
|
+
const eqIdx = parts.indexOf("=");
|
|
32
|
+
if (eqIdx > 0) return parts[eqIdx - 1];
|
|
33
|
+
return parts[parts.length - 1] || text;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parse outline entries into comparable symbol list.
|
|
38
|
+
*/
|
|
39
|
+
function toSymbolMap(entries) {
|
|
40
|
+
const map = new Map();
|
|
41
|
+
for (const e of entries) {
|
|
42
|
+
const name = symbolName(e.text);
|
|
43
|
+
const lines = e.end - e.start + 1;
|
|
44
|
+
map.set(name, { name, text: e.text, lines, start: e.start, end: e.end });
|
|
45
|
+
}
|
|
46
|
+
return map;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Get relative path from git root for `git show`.
|
|
51
|
+
*/
|
|
52
|
+
function gitRelativePath(absPath) {
|
|
53
|
+
const root = execSync("git rev-parse --show-toplevel", {
|
|
54
|
+
cwd: absPath.replace(/[/\\][^/\\]+$/, ""),
|
|
55
|
+
encoding: "utf-8",
|
|
56
|
+
timeout: 5000,
|
|
57
|
+
}).trim().replace(/\\/g, "/");
|
|
58
|
+
|
|
59
|
+
const normalized = absPath.replace(/\\/g, "/");
|
|
60
|
+
// Ensure root and path are comparable (case-insensitive on Windows)
|
|
61
|
+
const rootLower = root.toLowerCase();
|
|
62
|
+
const pathLower = normalized.toLowerCase();
|
|
63
|
+
if (!pathLower.startsWith(rootLower)) {
|
|
64
|
+
throw new Error(`File ${absPath} is not inside git repo ${root}`);
|
|
65
|
+
}
|
|
66
|
+
return normalized.slice(root.length + 1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Compare file against git ref, returning semantic symbol diff.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} filePath File path (absolute or relative)
|
|
73
|
+
* @param {string} compareAgainst Git ref (default: "HEAD")
|
|
74
|
+
* @returns {Promise<string>} Formatted diff
|
|
75
|
+
*/
|
|
76
|
+
export async function fileChanges(filePath, compareAgainst = "HEAD") {
|
|
77
|
+
const real = validatePath(filePath);
|
|
78
|
+
|
|
79
|
+
// Directory: return git diff --stat (compact file list, no content reads)
|
|
80
|
+
if (statSync(real).isDirectory()) {
|
|
81
|
+
try {
|
|
82
|
+
const stat = execSync(`git diff --stat "${compareAgainst}" -- .`, {
|
|
83
|
+
cwd: real,
|
|
84
|
+
encoding: "utf-8",
|
|
85
|
+
timeout: 10000,
|
|
86
|
+
}).trim();
|
|
87
|
+
if (!stat) return `No changes in ${filePath} vs ${compareAgainst}`;
|
|
88
|
+
return `Changed files in ${filePath} vs ${compareAgainst}:\n\n${stat}\n\nUse changes on a specific file for symbol-level diff.`;
|
|
89
|
+
} catch {
|
|
90
|
+
return `No git history for ${filePath} or not a git repository.`;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const ext = extname(real).toLowerCase();
|
|
95
|
+
|
|
96
|
+
// Check if outline supports this extension
|
|
97
|
+
const currentContent = readFileSync(real, "utf-8").replace(/\r\n/g, "\n");
|
|
98
|
+
const currentResult = await outlineFromContent(currentContent, ext);
|
|
99
|
+
if (!currentResult) {
|
|
100
|
+
return `Cannot outline ${ext} files. Supported: .js .mjs .ts .py .go .rs .java .c .cpp .cs .rb .php .kt .swift .sh .bash`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Get git version
|
|
104
|
+
const relPath = gitRelativePath(real);
|
|
105
|
+
let gitContent;
|
|
106
|
+
try {
|
|
107
|
+
gitContent = execSync(`git show "${compareAgainst}:${relPath}"`, {
|
|
108
|
+
cwd: real.replace(/[/\\][^/\\]+$/, ""),
|
|
109
|
+
encoding: "utf-8",
|
|
110
|
+
timeout: 5000,
|
|
111
|
+
}).replace(/\r\n/g, "\n");
|
|
112
|
+
} catch {
|
|
113
|
+
return `NEW FILE: ${filePath} (not in ${compareAgainst})`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Outline the git version from content — no temp files
|
|
117
|
+
const gitResult = await outlineFromContent(gitContent, ext);
|
|
118
|
+
if (!gitResult) {
|
|
119
|
+
return `Cannot outline git version of ${filePath}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Compare symbol maps
|
|
123
|
+
const currentMap = toSymbolMap(currentResult.entries);
|
|
124
|
+
const gitMap = toSymbolMap(gitResult.entries);
|
|
125
|
+
|
|
126
|
+
const added = [];
|
|
127
|
+
const removed = [];
|
|
128
|
+
const modified = [];
|
|
129
|
+
|
|
130
|
+
for (const [name, sym] of currentMap) {
|
|
131
|
+
if (!gitMap.has(name)) {
|
|
132
|
+
added.push(sym);
|
|
133
|
+
} else {
|
|
134
|
+
const gitSym = gitMap.get(name);
|
|
135
|
+
if (gitSym.lines !== sym.lines) {
|
|
136
|
+
modified.push({ current: sym, git: gitSym });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
for (const [name, sym] of gitMap) {
|
|
141
|
+
if (!currentMap.has(name)) {
|
|
142
|
+
removed.push(sym);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Format
|
|
147
|
+
const parts = [`Changes in ${filePath} vs ${compareAgainst}:`];
|
|
148
|
+
|
|
149
|
+
if (added.length) {
|
|
150
|
+
parts.push("\nAdded:");
|
|
151
|
+
for (const s of added) parts.push(` + ${s.start}-${s.end}: ${s.text}`);
|
|
152
|
+
}
|
|
153
|
+
if (removed.length) {
|
|
154
|
+
parts.push("\nRemoved:");
|
|
155
|
+
for (const s of removed) parts.push(` - ${s.start}-${s.end}: ${s.text}`);
|
|
156
|
+
}
|
|
157
|
+
if (modified.length) {
|
|
158
|
+
parts.push("\nModified:");
|
|
159
|
+
for (const m of modified) {
|
|
160
|
+
const delta = m.current.lines - m.git.lines;
|
|
161
|
+
const sign = delta > 0 ? "+" : "";
|
|
162
|
+
parts.push(` ~ ${m.current.start}-${m.current.end}: ${m.current.text} (${sign}${delta} lines)`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!added.length && !removed.length && !modified.length) {
|
|
167
|
+
parts.push("\nNo symbol changes detected.");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const summary = `${added.length} added, ${removed.length} removed, ${modified.length} modified`;
|
|
171
|
+
parts.push(`\nSummary: ${summary}`);
|
|
172
|
+
|
|
173
|
+
return parts.join("\n");
|
|
174
|
+
}
|
package/lib/coerce.mjs
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parameter aliases: common alternative names -> canonical schema names.
|
|
3
|
+
* Applied BEFORE Zod validation so agents sending wrong param names still work.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const ALIASES = {
|
|
7
|
+
// read_file
|
|
8
|
+
file_path: "path",
|
|
9
|
+
filePath: "path",
|
|
10
|
+
file: "path",
|
|
11
|
+
|
|
12
|
+
// grep_search
|
|
13
|
+
query: "pattern",
|
|
14
|
+
search: "pattern",
|
|
15
|
+
max_results: "limit",
|
|
16
|
+
maxResults: "limit",
|
|
17
|
+
maxMatches: "limit",
|
|
18
|
+
max_matches: "limit",
|
|
19
|
+
contextLines: "context",
|
|
20
|
+
context_lines: "context",
|
|
21
|
+
ignoreCase: "case_insensitive",
|
|
22
|
+
ignore_case: "case_insensitive",
|
|
23
|
+
|
|
24
|
+
// edit_file
|
|
25
|
+
dryRun: "dry_run",
|
|
26
|
+
"dry-run": "dry_run",
|
|
27
|
+
|
|
28
|
+
// directory_tree
|
|
29
|
+
maxDepth: "max_depth",
|
|
30
|
+
depth: "max_depth",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function coerceParams(params) {
|
|
34
|
+
if (!params || typeof params !== "object") return params;
|
|
35
|
+
const result = { ...params };
|
|
36
|
+
for (const [alias, canonical] of Object.entries(ALIASES)) {
|
|
37
|
+
if (alias in result && !(canonical in result)) {
|
|
38
|
+
result[canonical] = result[alias];
|
|
39
|
+
delete result[alias];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|