@@ -12,6 +12,7 @@ import path from "path";
12
12
import os from 'os' ;
13
13
import { z } from "zod" ;
14
14
import { zodToJsonSchema } from "zod-to-json-schema" ;
15
+ import { diffLines , createTwoFilesPatch } from 'diff' ;
15
16
16
17
// Command line argument parsing
17
18
const args = process . argv . slice ( 2 ) ;
@@ -106,6 +107,17 @@ const WriteFileArgsSchema = z.object({
106
107
content : z . string ( ) ,
107
108
} ) ;
108
109
110
+ const EditOperation = z . object ( {
111
+ oldText : z . string ( ) . describe ( 'Text to search for - must match exactly' ) ,
112
+ newText : z . string ( ) . describe ( 'Text to replace with' )
113
+ } ) ;
114
+
115
+ const EditFileArgsSchema = z . object ( {
116
+ path : z . string ( ) ,
117
+ edits : z . array ( EditOperation ) ,
118
+ dryRun : z . boolean ( ) . default ( false ) . describe ( 'Preview changes using git-style diff format' )
119
+ } ) ;
120
+
109
121
const CreateDirectoryArgsSchema = z . object ( {
110
122
path : z . string ( ) ,
111
123
} ) ;
@@ -202,6 +214,104 @@ async function searchFiles(
202
214
return results ;
203
215
}
204
216
217
+ // file editing and diffing utilities
218
+ function normalizeLineEndings ( text : string ) : string {
219
+ return text . replace ( / \r \n / g, '\n' ) ;
220
+ }
221
+
222
+ function createUnifiedDiff ( originalContent : string , newContent : string , filepath : string = 'file' ) : string {
223
+ // Ensure consistent line endings for diff
224
+ const normalizedOriginal = normalizeLineEndings ( originalContent ) ;
225
+ const normalizedNew = normalizeLineEndings ( newContent ) ;
226
+
227
+ return createTwoFilesPatch (
228
+ filepath ,
229
+ filepath ,
230
+ normalizedOriginal ,
231
+ normalizedNew ,
232
+ 'original' ,
233
+ 'modified'
234
+ ) ;
235
+ }
236
+
237
+ async function applyFileEdits (
238
+ filePath : string ,
239
+ edits : Array < { oldText : string , newText : string } > ,
240
+ dryRun = false
241
+ ) : Promise < string > {
242
+ // Read file content and normalize line endings
243
+ const content = normalizeLineEndings ( await fs . readFile ( filePath , 'utf-8' ) ) ;
244
+
245
+ // Apply edits sequentially
246
+ let modifiedContent = content ;
247
+ for ( const edit of edits ) {
248
+ const normalizedOld = normalizeLineEndings ( edit . oldText ) ;
249
+ const normalizedNew = normalizeLineEndings ( edit . newText ) ;
250
+
251
+ // If exact match exists, use it
252
+ if ( modifiedContent . includes ( normalizedOld ) ) {
253
+ modifiedContent = modifiedContent . replace ( normalizedOld , normalizedNew ) ;
254
+ continue ;
255
+ }
256
+
257
+ // Otherwise, try line-by-line matching with flexibility for whitespace
258
+ const oldLines = normalizedOld . split ( '\n' ) ;
259
+ const contentLines = modifiedContent . split ( '\n' ) ;
260
+ let matchFound = false ;
261
+
262
+ for ( let i = 0 ; i <= contentLines . length - oldLines . length ; i ++ ) {
263
+ const potentialMatch = contentLines . slice ( i , i + oldLines . length ) ;
264
+
265
+ // Compare lines with normalized whitespace
266
+ const isMatch = oldLines . every ( ( oldLine , j ) => {
267
+ const contentLine = potentialMatch [ j ] ;
268
+ return oldLine . trim ( ) === contentLine . trim ( ) ;
269
+ } ) ;
270
+
271
+ if ( isMatch ) {
272
+ // Preserve original indentation of first line
273
+ const originalIndent = contentLines [ i ] . match ( / ^ \s * / ) ?. [ 0 ] || '' ;
274
+ const newLines = normalizedNew . split ( '\n' ) . map ( ( line , j ) => {
275
+ if ( j === 0 ) return originalIndent + line . trimStart ( ) ;
276
+ // For subsequent lines, try to preserve relative indentation
277
+ const oldIndent = oldLines [ j ] ?. match ( / ^ \s * / ) ?. [ 0 ] || '' ;
278
+ const newIndent = line . match ( / ^ \s * / ) ?. [ 0 ] || '' ;
279
+ if ( oldIndent && newIndent ) {
280
+ const relativeIndent = newIndent . length - oldIndent . length ;
281
+ return originalIndent + ' ' . repeat ( Math . max ( 0 , relativeIndent ) ) + line . trimStart ( ) ;
282
+ }
283
+ return line ;
284
+ } ) ;
285
+
286
+ contentLines . splice ( i , oldLines . length , ...newLines ) ;
287
+ modifiedContent = contentLines . join ( '\n' ) ;
288
+ matchFound = true ;
289
+ break ;
290
+ }
291
+ }
292
+
293
+ if ( ! matchFound ) {
294
+ throw new Error ( `Could not find exact match for edit:\n${ edit . oldText } ` ) ;
295
+ }
296
+ }
297
+
298
+ // Create unified diff
299
+ const diff = createUnifiedDiff ( content , modifiedContent , filePath ) ;
300
+
301
+ // Format diff with appropriate number of backticks
302
+ let numBackticks = 3 ;
303
+ while ( diff . includes ( '`' . repeat ( numBackticks ) ) ) {
304
+ numBackticks ++ ;
305
+ }
306
+ const formattedDiff = `${ '`' . repeat ( numBackticks ) } diff\n${ diff } ${ '`' . repeat ( numBackticks ) } \n\n` ;
307
+
308
+ if ( ! dryRun ) {
309
+ await fs . writeFile ( filePath , modifiedContent , 'utf-8' ) ;
310
+ }
311
+
312
+ return formattedDiff ;
313
+ }
314
+
205
315
// Tool handlers
206
316
server . setRequestHandler ( ListToolsRequestSchema , async ( ) => {
207
317
return {
@@ -233,6 +343,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
233
343
"Handles text content with proper encoding. Only works within allowed directories." ,
234
344
inputSchema : zodToJsonSchema ( WriteFileArgsSchema ) as ToolInput ,
235
345
} ,
346
+ {
347
+ name : "edit_file" ,
348
+ description :
349
+ "Make line-based edits to a text file. Each edit replaces exact line sequences " +
350
+ "with new content. Returns a git-style diff showing the changes made. " +
351
+ "Only works within allowed directories." ,
352
+ inputSchema : zodToJsonSchema ( EditFileArgsSchema ) as ToolInput ,
353
+ } ,
236
354
{
237
355
name : "create_directory" ,
238
356
description :
@@ -346,6 +464,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
346
464
} ;
347
465
}
348
466
467
+ case "edit_file" : {
468
+ const parsed = EditFileArgsSchema . safeParse ( args ) ;
469
+ if ( ! parsed . success ) {
470
+ throw new Error ( `Invalid arguments for edit_file: ${ parsed . error } ` ) ;
471
+ }
472
+ const validPath = await validatePath ( parsed . data . path ) ;
473
+ const result = await applyFileEdits ( validPath , parsed . data . edits , parsed . data . dryRun ) ;
474
+ return {
475
+ content : [ { type : "text" , text : result } ] ,
476
+ } ;
477
+ }
478
+
349
479
case "create_directory" : {
350
480
const parsed = CreateDirectoryArgsSchema . safeParse ( args ) ;
351
481
if ( ! parsed . success ) {
0 commit comments