mcp-hashline-edit-server 0.1.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/LICENSE +22 -0
- package/README.md +143 -0
- package/package.json +31 -0
- package/src/descriptions.ts +63 -0
- package/src/diff.ts +154 -0
- package/src/fuzzy.ts +243 -0
- package/src/hashline.ts +584 -0
- package/src/index.ts +18 -0
- package/src/normalize.ts +278 -0
- package/src/server.ts +312 -0
- package/src/types.ts +43 -0
package/src/normalize.ts
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text normalization utilities.
|
|
3
|
+
*
|
|
4
|
+
* Handles line endings, BOM, whitespace, and Unicode normalization.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Line Endings
|
|
8
|
+
|
|
9
|
+
export type LineEnding = "\r\n" | "\n";
|
|
10
|
+
|
|
11
|
+
export function detectLineEnding(content: string): LineEnding {
|
|
12
|
+
const crlfIdx = content.indexOf("\r\n");
|
|
13
|
+
const lfIdx = content.indexOf("\n");
|
|
14
|
+
if (lfIdx === -1) return "\n";
|
|
15
|
+
if (crlfIdx === -1) return "\n";
|
|
16
|
+
return crlfIdx < lfIdx ? "\r\n" : "\n";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function normalizeToLF(text: string): string {
|
|
20
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function restoreLineEndings(text: string, ending: LineEnding): string {
|
|
24
|
+
return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// BOM Handling
|
|
28
|
+
|
|
29
|
+
export interface BomResult {
|
|
30
|
+
bom: string;
|
|
31
|
+
text: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function stripBom(content: string): BomResult {
|
|
35
|
+
return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Whitespace Utilities
|
|
39
|
+
|
|
40
|
+
export function countLeadingWhitespace(line: string): number {
|
|
41
|
+
let count = 0;
|
|
42
|
+
for (let i = 0; i < line.length; i++) {
|
|
43
|
+
const char = line[i];
|
|
44
|
+
if (char === " " || char === "\t") {
|
|
45
|
+
count++;
|
|
46
|
+
} else {
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return count;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function getLeadingWhitespace(line: string): string {
|
|
54
|
+
return line.slice(0, countLeadingWhitespace(line));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function detectIndentChar(text: string): string {
|
|
58
|
+
const lines = text.split("\n");
|
|
59
|
+
for (const line of lines) {
|
|
60
|
+
const ws = getLeadingWhitespace(line);
|
|
61
|
+
if (ws.length > 0) {
|
|
62
|
+
return ws[0];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return " ";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Unicode Normalization
|
|
69
|
+
|
|
70
|
+
export function normalizeUnicode(s: string): string {
|
|
71
|
+
return s
|
|
72
|
+
.trim()
|
|
73
|
+
.split("")
|
|
74
|
+
.map((c) => {
|
|
75
|
+
const code = c.charCodeAt(0);
|
|
76
|
+
if (
|
|
77
|
+
code === 0x2010 ||
|
|
78
|
+
code === 0x2011 ||
|
|
79
|
+
code === 0x2012 ||
|
|
80
|
+
code === 0x2013 ||
|
|
81
|
+
code === 0x2014 ||
|
|
82
|
+
code === 0x2015 ||
|
|
83
|
+
code === 0x2212
|
|
84
|
+
) {
|
|
85
|
+
return "-";
|
|
86
|
+
}
|
|
87
|
+
if (code === 0x2018 || code === 0x2019 || code === 0x201a || code === 0x201b) {
|
|
88
|
+
return "'";
|
|
89
|
+
}
|
|
90
|
+
if (code === 0x201c || code === 0x201d || code === 0x201e || code === 0x201f) {
|
|
91
|
+
return '"';
|
|
92
|
+
}
|
|
93
|
+
if (
|
|
94
|
+
code === 0x00a0 ||
|
|
95
|
+
code === 0x2002 ||
|
|
96
|
+
code === 0x2003 ||
|
|
97
|
+
code === 0x2004 ||
|
|
98
|
+
code === 0x2005 ||
|
|
99
|
+
code === 0x2006 ||
|
|
100
|
+
code === 0x2007 ||
|
|
101
|
+
code === 0x2008 ||
|
|
102
|
+
code === 0x2009 ||
|
|
103
|
+
code === 0x200a ||
|
|
104
|
+
code === 0x202f ||
|
|
105
|
+
code === 0x205f ||
|
|
106
|
+
code === 0x3000
|
|
107
|
+
) {
|
|
108
|
+
return " ";
|
|
109
|
+
}
|
|
110
|
+
return c;
|
|
111
|
+
})
|
|
112
|
+
.join("");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function normalizeForFuzzy(line: string): string {
|
|
116
|
+
const trimmed = line.trim();
|
|
117
|
+
if (trimmed.length === 0) return "";
|
|
118
|
+
return trimmed
|
|
119
|
+
.replace(/[""„‟«»]/g, '"')
|
|
120
|
+
.replace(/[''‚‛`´]/g, "'")
|
|
121
|
+
.replace(/[‐‑‒–—−]/g, "-")
|
|
122
|
+
.replace(/[ \t]+/g, " ");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Indentation Adjustment
|
|
126
|
+
|
|
127
|
+
function gcd(a: number, b: number): number {
|
|
128
|
+
let x = Math.abs(a);
|
|
129
|
+
let y = Math.abs(b);
|
|
130
|
+
while (y !== 0) {
|
|
131
|
+
const temp = y;
|
|
132
|
+
y = x % y;
|
|
133
|
+
x = temp;
|
|
134
|
+
}
|
|
135
|
+
return x;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
interface IndentProfile {
|
|
139
|
+
lines: string[];
|
|
140
|
+
indentCounts: number[];
|
|
141
|
+
min: number;
|
|
142
|
+
char: " " | "\t" | undefined;
|
|
143
|
+
spaceOnly: boolean;
|
|
144
|
+
tabOnly: boolean;
|
|
145
|
+
mixed: boolean;
|
|
146
|
+
unit: number;
|
|
147
|
+
nonEmptyCount: number;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function buildIndentProfile(text: string): IndentProfile {
|
|
151
|
+
const lines = text.split("\n");
|
|
152
|
+
const indentCounts: number[] = [];
|
|
153
|
+
let min = Infinity;
|
|
154
|
+
let char: " " | "\t" | undefined;
|
|
155
|
+
let spaceOnly = true;
|
|
156
|
+
let tabOnly = true;
|
|
157
|
+
let mixed = false;
|
|
158
|
+
let nonEmptyCount = 0;
|
|
159
|
+
let unit = 0;
|
|
160
|
+
|
|
161
|
+
for (const line of lines) {
|
|
162
|
+
if (line.trim().length === 0) continue;
|
|
163
|
+
nonEmptyCount++;
|
|
164
|
+
const indent = getLeadingWhitespace(line);
|
|
165
|
+
indentCounts.push(indent.length);
|
|
166
|
+
min = Math.min(min, indent.length);
|
|
167
|
+
if (indent.includes(" ")) tabOnly = false;
|
|
168
|
+
if (indent.includes("\t")) spaceOnly = false;
|
|
169
|
+
if (indent.includes(" ") && indent.includes("\t")) mixed = true;
|
|
170
|
+
if (indent.length > 0) {
|
|
171
|
+
const currentChar = indent[0] as " " | "\t";
|
|
172
|
+
if (!char) {
|
|
173
|
+
char = currentChar;
|
|
174
|
+
} else if (char !== currentChar) {
|
|
175
|
+
mixed = true;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (min === Infinity) min = 0;
|
|
180
|
+
if (spaceOnly && nonEmptyCount > 0) {
|
|
181
|
+
let current = 0;
|
|
182
|
+
for (const count of indentCounts) {
|
|
183
|
+
if (count === 0) continue;
|
|
184
|
+
current = current === 0 ? count : gcd(current, count);
|
|
185
|
+
}
|
|
186
|
+
unit = current;
|
|
187
|
+
}
|
|
188
|
+
if (tabOnly && nonEmptyCount > 0) unit = 1;
|
|
189
|
+
|
|
190
|
+
return { lines, indentCounts, min, char, spaceOnly, tabOnly, mixed, unit, nonEmptyCount };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function convertLeadingTabsToSpaces(text: string, spacesPerTab: number): string {
|
|
194
|
+
if (spacesPerTab <= 0) return text;
|
|
195
|
+
return text
|
|
196
|
+
.split("\n")
|
|
197
|
+
.map((line) => {
|
|
198
|
+
const trimmed = line.trimStart();
|
|
199
|
+
if (trimmed.length === 0) return line;
|
|
200
|
+
const leading = getLeadingWhitespace(line);
|
|
201
|
+
if (!leading.includes("\t") || leading.includes(" ")) return line;
|
|
202
|
+
const converted = " ".repeat(leading.length * spacesPerTab);
|
|
203
|
+
return converted + trimmed;
|
|
204
|
+
})
|
|
205
|
+
.join("\n");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function adjustIndentation(oldText: string, actualText: string, newText: string): string {
|
|
209
|
+
if (oldText === actualText) return newText;
|
|
210
|
+
|
|
211
|
+
const oldLines = oldText.split("\n");
|
|
212
|
+
const newLines = newText.split("\n");
|
|
213
|
+
if (oldLines.length === newLines.length) {
|
|
214
|
+
let indentationOnly = true;
|
|
215
|
+
for (let i = 0; i < oldLines.length; i++) {
|
|
216
|
+
if (oldLines[i].trim() !== newLines[i].trim()) {
|
|
217
|
+
indentationOnly = false;
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (indentationOnly) return newText;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const oldProfile = buildIndentProfile(oldText);
|
|
225
|
+
const actualProfile = buildIndentProfile(actualText);
|
|
226
|
+
const newProfile = buildIndentProfile(newText);
|
|
227
|
+
|
|
228
|
+
if (newProfile.nonEmptyCount === 0 || oldProfile.nonEmptyCount === 0 || actualProfile.nonEmptyCount === 0) {
|
|
229
|
+
return newText;
|
|
230
|
+
}
|
|
231
|
+
if (oldProfile.mixed || actualProfile.mixed || newProfile.mixed) return newText;
|
|
232
|
+
|
|
233
|
+
if (oldProfile.char && actualProfile.char && oldProfile.char !== actualProfile.char) {
|
|
234
|
+
if (actualProfile.spaceOnly && oldProfile.tabOnly && newProfile.tabOnly && actualProfile.unit > 0) {
|
|
235
|
+
let consistent = true;
|
|
236
|
+
const lineCount = Math.min(oldProfile.lines.length, actualProfile.lines.length);
|
|
237
|
+
for (let i = 0; i < lineCount; i++) {
|
|
238
|
+
const oldLine = oldProfile.lines[i];
|
|
239
|
+
const actualLine = actualProfile.lines[i];
|
|
240
|
+
if (oldLine.trim().length === 0 || actualLine.trim().length === 0) continue;
|
|
241
|
+
const oldIndent = getLeadingWhitespace(oldLine);
|
|
242
|
+
const actualIndent = getLeadingWhitespace(actualLine);
|
|
243
|
+
if (oldIndent.length === 0) continue;
|
|
244
|
+
if (actualIndent.length !== oldIndent.length * actualProfile.unit) {
|
|
245
|
+
consistent = false;
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return consistent ? convertLeadingTabsToSpaces(newText, actualProfile.unit) : newText;
|
|
250
|
+
}
|
|
251
|
+
return newText;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const lineCount = Math.min(oldProfile.lines.length, actualProfile.lines.length);
|
|
255
|
+
const deltas: number[] = [];
|
|
256
|
+
for (let i = 0; i < lineCount; i++) {
|
|
257
|
+
const oldLine = oldProfile.lines[i];
|
|
258
|
+
const actualLine = actualProfile.lines[i];
|
|
259
|
+
if (oldLine.trim().length === 0 || actualLine.trim().length === 0) continue;
|
|
260
|
+
deltas.push(countLeadingWhitespace(actualLine) - countLeadingWhitespace(oldLine));
|
|
261
|
+
}
|
|
262
|
+
if (deltas.length === 0) return newText;
|
|
263
|
+
|
|
264
|
+
const delta = deltas[0];
|
|
265
|
+
if (!deltas.every((value) => value === delta)) return newText;
|
|
266
|
+
if (delta === 0) return newText;
|
|
267
|
+
|
|
268
|
+
if (newProfile.char && actualProfile.char && newProfile.char !== actualProfile.char) return newText;
|
|
269
|
+
|
|
270
|
+
const indentChar = actualProfile.char ?? oldProfile.char ?? detectIndentChar(actualText);
|
|
271
|
+
const adjusted = newText.split("\n").map((line) => {
|
|
272
|
+
if (line.trim().length === 0) return line;
|
|
273
|
+
if (delta > 0) return indentChar.repeat(delta) + line;
|
|
274
|
+
const toRemove = Math.min(-delta, countLeadingWhitespace(line));
|
|
275
|
+
return line.slice(toRemove);
|
|
276
|
+
});
|
|
277
|
+
return adjusted.join("\n");
|
|
278
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server factory — builds and returns the McpServer with all tools registered.
|
|
3
|
+
*
|
|
4
|
+
* Tools:
|
|
5
|
+
* read_file — Read files with hashline-prefixed output (LINE:HASH|content)
|
|
6
|
+
* edit_file — Edit files using hash-verified line references
|
|
7
|
+
* write_file — Create or overwrite files
|
|
8
|
+
* grep — Search files with hashline-prefixed results
|
|
9
|
+
*/
|
|
10
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import * as fs from "node:fs/promises";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { computeLineHash, formatHashLines, applyHashlineEdits, parseLineRef, HashlineMismatchError } from "./hashline";
|
|
15
|
+
import { replaceText, generateDiffString } from "./diff";
|
|
16
|
+
import { normalizeToLF, detectLineEnding, restoreLineEndings, stripBom } from "./normalize";
|
|
17
|
+
import { DEFAULT_FUZZY_THRESHOLD } from "./fuzzy";
|
|
18
|
+
import { READ_FILE_DESCRIPTION, EDIT_FILE_DESCRIPTION, WRITE_FILE_DESCRIPTION, GREP_DESCRIPTION } from "./descriptions";
|
|
19
|
+
import type { HashlineEdit } from "./types";
|
|
20
|
+
|
|
21
|
+
const DEFAULT_MAX_LINES = 2000;
|
|
22
|
+
|
|
23
|
+
function resolvePath(filePath: string): string {
|
|
24
|
+
if (path.isAbsolute(filePath)) return filePath;
|
|
25
|
+
return path.resolve(process.cwd(), filePath);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createServer(): McpServer {
|
|
29
|
+
const server = new McpServer({
|
|
30
|
+
name: "hashline-edit-server",
|
|
31
|
+
version: "0.1.0",
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// read_file tool
|
|
35
|
+
|
|
36
|
+
server.tool(
|
|
37
|
+
"read_file",
|
|
38
|
+
READ_FILE_DESCRIPTION,
|
|
39
|
+
{
|
|
40
|
+
path: z.string().describe("Path to the file to read (relative or absolute)"),
|
|
41
|
+
offset: z.number().optional().describe("Line number to start reading from (1-indexed)"),
|
|
42
|
+
limit: z.number().optional().describe("Maximum number of lines to read"),
|
|
43
|
+
},
|
|
44
|
+
async ({ path: filePath, offset, limit }) => {
|
|
45
|
+
const absolutePath = resolvePath(filePath);
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const content = await Bun.file(absolutePath).text();
|
|
49
|
+
const lines = content.split("\n");
|
|
50
|
+
const startLine = Math.max(1, offset ?? 1);
|
|
51
|
+
const maxLines = limit ?? DEFAULT_MAX_LINES;
|
|
52
|
+
const endLine = Math.min(lines.length, startLine - 1 + maxLines);
|
|
53
|
+
const selectedLines = lines.slice(startLine - 1, endLine);
|
|
54
|
+
const selectedContent = selectedLines.join("\n");
|
|
55
|
+
const formatted = formatHashLines(selectedContent, startLine);
|
|
56
|
+
|
|
57
|
+
const totalLines = lines.length;
|
|
58
|
+
let header = `File: ${filePath} (${totalLines} lines)`;
|
|
59
|
+
if (startLine > 1 || endLine < totalLines) {
|
|
60
|
+
header += ` [showing lines ${startLine}-${endLine}]`;
|
|
61
|
+
}
|
|
62
|
+
if (endLine < totalLines) {
|
|
63
|
+
header += ` (${totalLines - endLine} more lines below)`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { content: [{ type: "text", text: `${header}\n\n${formatted}` }] };
|
|
67
|
+
} catch (err) {
|
|
68
|
+
try {
|
|
69
|
+
const stat = await fs.stat(absolutePath);
|
|
70
|
+
if (stat.isDirectory()) {
|
|
71
|
+
const entries = await fs.readdir(absolutePath, { withFileTypes: true });
|
|
72
|
+
const listing = entries
|
|
73
|
+
.map((e) => `${e.isDirectory() ? "d" : "f"} ${e.name}`)
|
|
74
|
+
.join("\n");
|
|
75
|
+
return { content: [{ type: "text", text: `Directory: ${filePath}\n\n${listing}` }] };
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// Not a directory either
|
|
79
|
+
}
|
|
80
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
81
|
+
return { content: [{ type: "text", text: `Error reading ${filePath}: ${message}` }], isError: true };
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// edit_file tool
|
|
87
|
+
|
|
88
|
+
const editItemSchema = z.union([
|
|
89
|
+
z.object({
|
|
90
|
+
set_line: z.object({
|
|
91
|
+
anchor: z.string().describe('Line reference "LINE:HASH"'),
|
|
92
|
+
new_text: z.string().describe('Replacement content (\\n-separated) — "" for delete'),
|
|
93
|
+
}),
|
|
94
|
+
}),
|
|
95
|
+
z.object({
|
|
96
|
+
replace_lines: z.object({
|
|
97
|
+
start_anchor: z.string().describe('Start line ref "LINE:HASH"'),
|
|
98
|
+
end_anchor: z.string().describe('End line ref "LINE:HASH"'),
|
|
99
|
+
new_text: z.string().describe('Replacement content (\\n-separated) — "" for delete'),
|
|
100
|
+
}),
|
|
101
|
+
}),
|
|
102
|
+
z.object({
|
|
103
|
+
insert_after: z.object({
|
|
104
|
+
anchor: z.string().describe('Insert after this line "LINE:HASH"'),
|
|
105
|
+
text: z.string().describe("Content to insert (\\n-separated); must be non-empty"),
|
|
106
|
+
}),
|
|
107
|
+
}),
|
|
108
|
+
z.object({
|
|
109
|
+
replace: z.object({
|
|
110
|
+
old_text: z.string().describe("Text to find (fuzzy whitespace matching enabled)"),
|
|
111
|
+
new_text: z.string().describe("Replacement text"),
|
|
112
|
+
all: z.boolean().optional().describe("Replace all occurrences (default: unique match required)"),
|
|
113
|
+
}),
|
|
114
|
+
}),
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
server.tool(
|
|
118
|
+
"edit_file",
|
|
119
|
+
EDIT_FILE_DESCRIPTION,
|
|
120
|
+
{
|
|
121
|
+
path: z.string().describe("File path (relative or absolute)"),
|
|
122
|
+
edits: z.array(editItemSchema).describe("Array of edit operations"),
|
|
123
|
+
},
|
|
124
|
+
async ({ path: filePath, edits }) => {
|
|
125
|
+
const absolutePath = resolvePath(filePath);
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const rawContent = await Bun.file(absolutePath).text();
|
|
129
|
+
const { bom, text: content } = stripBom(rawContent);
|
|
130
|
+
const originalEnding = detectLineEnding(content);
|
|
131
|
+
const originalNormalized = normalizeToLF(content);
|
|
132
|
+
let normalizedContent = originalNormalized;
|
|
133
|
+
|
|
134
|
+
// Validate edit shapes
|
|
135
|
+
for (let i = 0; i < edits.length; i++) {
|
|
136
|
+
const edit = edits[i] as Record<string, unknown>;
|
|
137
|
+
if (("old_text" in edit || "new_text" in edit) && !("replace" in edit)) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`edits[${i}] contains 'old_text'/'new_text' at top level. Use {replace: {old_text, new_text}} or {set_line}, {replace_lines}, {insert_after}.`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
if (!("set_line" in edit) && !("replace_lines" in edit) && !("insert_after" in edit) && !("replace" in edit)) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`edits[${i}] must contain one of: 'set_line', 'replace_lines', 'insert_after', or 'replace'. Got keys: [${Object.keys(edit).join(", ")}].`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const anchorEdits = edits.filter(
|
|
150
|
+
(e): e is HashlineEdit => "set_line" in e || "replace_lines" in e || "insert_after" in e,
|
|
151
|
+
);
|
|
152
|
+
const replaceEdits = edits.filter(
|
|
153
|
+
(e): e is { replace: { old_text: string; new_text: string; all?: boolean } } => "replace" in e,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const anchorResult = applyHashlineEdits(normalizedContent, anchorEdits);
|
|
157
|
+
normalizedContent = anchorResult.content;
|
|
158
|
+
|
|
159
|
+
for (const r of replaceEdits) {
|
|
160
|
+
if (r.replace.old_text.length === 0) throw new Error("replace.old_text must not be empty.");
|
|
161
|
+
const rep = replaceText(normalizedContent, r.replace.old_text, r.replace.new_text, {
|
|
162
|
+
fuzzy: true,
|
|
163
|
+
all: r.replace.all ?? false,
|
|
164
|
+
threshold: DEFAULT_FUZZY_THRESHOLD,
|
|
165
|
+
});
|
|
166
|
+
normalizedContent = rep.content;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (originalNormalized === normalizedContent) {
|
|
170
|
+
let diagnostic = `No changes made to ${filePath}. The edits produced identical content.`;
|
|
171
|
+
if (anchorResult.noopEdits && anchorResult.noopEdits.length > 0) {
|
|
172
|
+
const details = anchorResult.noopEdits
|
|
173
|
+
.map((e) => `Edit ${e.editIndex}: replacement for ${e.loc} is identical to current content:\n ${e.loc}| ${e.currentContent}`)
|
|
174
|
+
.join("\n");
|
|
175
|
+
diagnostic += `\n${details}\nYour content must differ from what the file already contains. Re-read the file to see the current state.`;
|
|
176
|
+
}
|
|
177
|
+
throw new Error(diagnostic);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const finalContent = bom + restoreLineEndings(normalizedContent, originalEnding);
|
|
181
|
+
await Bun.write(absolutePath, finalContent);
|
|
182
|
+
const diffResult = generateDiffString(originalNormalized, normalizedContent);
|
|
183
|
+
|
|
184
|
+
let resultText = `Updated ${filePath}`;
|
|
185
|
+
if (anchorResult.warnings?.length) {
|
|
186
|
+
resultText += `\n\nWarnings:\n${anchorResult.warnings.join("\n")}`;
|
|
187
|
+
}
|
|
188
|
+
if (diffResult.diff) {
|
|
189
|
+
resultText += `\n\nDiff:\n${diffResult.diff}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return { content: [{ type: "text", text: resultText }] };
|
|
193
|
+
} catch (err) {
|
|
194
|
+
if (err instanceof HashlineMismatchError) {
|
|
195
|
+
return { content: [{ type: "text", text: err.message }], isError: true };
|
|
196
|
+
}
|
|
197
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
198
|
+
return { content: [{ type: "text", text: `Error editing ${filePath}: ${message}` }], isError: true };
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
// write_file tool
|
|
204
|
+
|
|
205
|
+
server.tool(
|
|
206
|
+
"write_file",
|
|
207
|
+
WRITE_FILE_DESCRIPTION,
|
|
208
|
+
{
|
|
209
|
+
path: z.string().describe("Path to the file to write (relative or absolute)"),
|
|
210
|
+
content: z.string().describe("Content to write to the file"),
|
|
211
|
+
},
|
|
212
|
+
async ({ path: filePath, content }) => {
|
|
213
|
+
const absolutePath = resolvePath(filePath);
|
|
214
|
+
try {
|
|
215
|
+
await Bun.write(absolutePath, content);
|
|
216
|
+
const lineCount = content.split("\n").length;
|
|
217
|
+
return { content: [{ type: "text", text: `Created ${filePath} (${lineCount} lines)` }] };
|
|
218
|
+
} catch (err) {
|
|
219
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
220
|
+
return { content: [{ type: "text", text: `Error writing ${filePath}: ${message}` }], isError: true };
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
// grep tool
|
|
226
|
+
|
|
227
|
+
server.tool(
|
|
228
|
+
"grep",
|
|
229
|
+
GREP_DESCRIPTION,
|
|
230
|
+
{
|
|
231
|
+
pattern: z.string().describe("Regex pattern to search for"),
|
|
232
|
+
path: z.string().optional().describe("File or directory to search (default: cwd)"),
|
|
233
|
+
glob: z.string().optional().describe("Filter files by glob pattern (e.g., '*.js')"),
|
|
234
|
+
type: z.string().optional().describe("Filter by file type (e.g., js, py, rust)"),
|
|
235
|
+
i: z.boolean().optional().describe("Case-insensitive search (default: false)"),
|
|
236
|
+
pre: z.number().optional().describe("Lines of context before matches"),
|
|
237
|
+
post: z.number().optional().describe("Lines of context after matches"),
|
|
238
|
+
limit: z.number().optional().describe("Limit output to first N matches (default: 100)"),
|
|
239
|
+
},
|
|
240
|
+
async ({ pattern, path: searchPath, glob: globPattern, type: fileType, i: caseInsensitive, pre, post, limit }) => {
|
|
241
|
+
const args = ["rg", "--line-number", "--no-heading"];
|
|
242
|
+
|
|
243
|
+
if (caseInsensitive) args.push("-i");
|
|
244
|
+
if (pre) args.push("-B", String(pre));
|
|
245
|
+
if (post) args.push("-A", String(post));
|
|
246
|
+
if (globPattern) args.push("--glob", globPattern);
|
|
247
|
+
if (fileType) args.push("--type", fileType);
|
|
248
|
+
|
|
249
|
+
const maxMatches = limit ?? 100;
|
|
250
|
+
args.push("-m", String(maxMatches));
|
|
251
|
+
args.push("--", pattern);
|
|
252
|
+
|
|
253
|
+
const target = searchPath ? resolvePath(searchPath) : process.cwd();
|
|
254
|
+
args.push(target);
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
const proc = Bun.spawn(args, {
|
|
258
|
+
stdout: "pipe",
|
|
259
|
+
stderr: "pipe",
|
|
260
|
+
cwd: process.cwd(),
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const stdout = await new Response(proc.stdout).text();
|
|
264
|
+
const stderr = await new Response(proc.stderr).text();
|
|
265
|
+
const exitCode = await proc.exited;
|
|
266
|
+
|
|
267
|
+
if (exitCode === 1) {
|
|
268
|
+
return { content: [{ type: "text", text: "No matches found." }] };
|
|
269
|
+
}
|
|
270
|
+
if (exitCode !== 0 && exitCode !== 1) {
|
|
271
|
+
return { content: [{ type: "text", text: `grep error: ${stderr || "unknown error"}` }], isError: true };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const lines = stdout.trimEnd().split("\n");
|
|
275
|
+
const formatted: string[] = [];
|
|
276
|
+
const RG_LINE_RE = /^(.+?):(\d+):(.*)/;
|
|
277
|
+
const RG_CONTEXT_RE = /^(.+?)-(\d+)-(.*)/;
|
|
278
|
+
|
|
279
|
+
for (const line of lines) {
|
|
280
|
+
if (line === "--") {
|
|
281
|
+
formatted.push("--");
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
const matchLine = RG_LINE_RE.exec(line);
|
|
285
|
+
if (matchLine) {
|
|
286
|
+
const [, file, lineNumStr, content] = matchLine;
|
|
287
|
+
const lineNum = parseInt(lineNumStr, 10);
|
|
288
|
+
const hash = computeLineHash(lineNum, content);
|
|
289
|
+
formatted.push(`${file}:>>${lineNum}:${hash}|${content}`);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const contextLine = RG_CONTEXT_RE.exec(line);
|
|
293
|
+
if (contextLine) {
|
|
294
|
+
const [, file, lineNumStr, content] = contextLine;
|
|
295
|
+
const lineNum = parseInt(lineNumStr, 10);
|
|
296
|
+
const hash = computeLineHash(lineNum, content);
|
|
297
|
+
formatted.push(`${file}: ${lineNum}:${hash}|${content}`);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
formatted.push(line);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return { content: [{ type: "text", text: formatted.join("\n") }] };
|
|
304
|
+
} catch (err) {
|
|
305
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
306
|
+
return { content: [{ type: "text", text: `grep error: ${message}` }], isError: true };
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
return server;
|
|
312
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for the hashline edit server.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface HashMismatch {
|
|
6
|
+
line: number;
|
|
7
|
+
expected: string;
|
|
8
|
+
actual: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Hashline edit operation types
|
|
12
|
+
|
|
13
|
+
export interface SetLineEdit {
|
|
14
|
+
set_line: {
|
|
15
|
+
anchor: string;
|
|
16
|
+
new_text: string;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ReplaceLinesEdit {
|
|
21
|
+
replace_lines: {
|
|
22
|
+
start_anchor: string;
|
|
23
|
+
end_anchor: string;
|
|
24
|
+
new_text: string;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface InsertAfterEdit {
|
|
29
|
+
insert_after: {
|
|
30
|
+
anchor: string;
|
|
31
|
+
text: string;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ReplaceEdit {
|
|
36
|
+
replace: {
|
|
37
|
+
old_text: string;
|
|
38
|
+
new_text: string;
|
|
39
|
+
all?: boolean;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type HashlineEdit = SetLineEdit | ReplaceLinesEdit | InsertAfterEdit | ReplaceEdit;
|