@@ -12,6 +12,7 @@ import path from "path";
1212import os from 'os' ;
1313import { z } from "zod" ;
1414import { zodToJsonSchema } from "zod-to-json-schema" ;
15+ import { diffLines , createTwoFilesPatch } from 'diff' ;
1516
1617// Command line argument parsing
1718const args = process . argv . slice ( 2 ) ;
@@ -106,6 +107,17 @@ const WriteFileArgsSchema = z.object({
106107 content : z . string ( ) ,
107108} ) ;
108109
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+
109121const CreateDirectoryArgsSchema = z . object ( {
110122 path : z . string ( ) ,
111123} ) ;
@@ -202,6 +214,104 @@ async function searchFiles(
202214 return results ;
203215}
204216
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+
205315// Tool handlers
206316server . setRequestHandler ( ListToolsRequestSchema , async ( ) => {
207317 return {
@@ -233,6 +343,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
233343 "Handles text content with proper encoding. Only works within allowed directories." ,
234344 inputSchema : zodToJsonSchema ( WriteFileArgsSchema ) as ToolInput ,
235345 } ,
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+ } ,
236354 {
237355 name : "create_directory" ,
238356 description :
@@ -346,6 +464,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
346464 } ;
347465 }
348466
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+
349479 case "create_directory" : {
350480 const parsed = CreateDirectoryArgsSchema . safeParse ( args ) ;
351481 if ( ! parsed . success ) {
0 commit comments