on main help sign in
fix(tool): fix `edit` and `multi-edit` tools on windows
a3efafcAndrey Nering
internal/fsext/fileutil.go+16
@@ -233,3 +233,19 @@ func HasPrefix(path, prefix string) bool {
233 // If path is within prefix, Rel will not return a path starting with ".."
233 // If path is within prefix, Rel will not return a path starting with ".."
234 return !strings.HasPrefix(rel, "..")
234 return !strings.HasPrefix(rel, "..")
235}
235}
236+
237+// ToUnixLineEndings converts Windows line endings (CRLF) to Unix line endings (LF).
238+func ToUnixLineEndings(content string) (string, bool) {
239+ if strings.Contains(content, "\r\n") {
240+ return strings.ReplaceAll(content, "\r\n", "\n"), true
241+ }
242+ return content, false
243+}
244+
245+// ToWindowsLineEndings converts Unix line endings (LF) to Windows line endings (CRLF).
246+func ToWindowsLineEndings(content string) (string, bool) {
247+ if !strings.Contains(content, "\r\n") {
248+ return strings.ReplaceAll(content, "\n", "\r\n"), true
249+ }
250+ return content, false
251+}
internal/llm/tools/edit.go+11−2
@@ -99,6 +99,7 @@ WINDOWS NOTES:
99- File paths should use forward slashes (/) for cross-platform compatibility
99- File paths should use forward slashes (/) for cross-platform compatibility
100- On Windows, absolute paths start with drive letters (C:/) but forward slashes work throughout
100- On Windows, absolute paths start with drive letters (C:/) but forward slashes work throughout
101- File permissions are handled automatically by the Go runtime
101- File permissions are handled automatically by the Go runtime
102+- Always assumes \n for line endings. The tool will handle \r\n conversion automatically if needed.
102
103
103Remember: when making multiple file edits in a row to the same file, you should prefer to send all edits in a single message with multiple calls to this tool, rather than multiple messages with a single call each.`
104Remember: when making multiple file edits in a row to the same file, you should prefer to send all edits in a single message with multiple calls to this tool, rather than multiple messages with a single call each.`
104)
105)
@@ -299,7 +300,7 @@ func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string
299 return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
300 return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
300 }
301 }
301
302
302− oldContent := string(content)
303+ oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
303
304
304 var newContent string
305 var newContent string
305 var deletionCount int
306 var deletionCount int
@@ -356,6 +357,10 @@ func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string
356 return ToolResponse{}, permission.ErrorPermissionDenied
357 return ToolResponse{}, permission.ErrorPermissionDenied
357 }
358 }
358
359
360+ if isCrlf {
361+ newContent, _ = fsext.ToWindowsLineEndings(newContent)
362+ }
363+
359 err = os.WriteFile(filePath, []byte(newContent), 0o644)
364 err = os.WriteFile(filePath, []byte(newContent), 0o644)
360 if err != nil {
365 if err != nil {
361 return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
366 return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
@@ -428,7 +433,7 @@ func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newS
428 return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
433 return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
429 }
434 }
430
435
431− oldContent := string(content)
436+ oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
432
437
433 var newContent string
438 var newContent string
434 var replacementCount int
439 var replacementCount int
@@ -487,6 +492,10 @@ func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newS
487 return ToolResponse{}, permission.ErrorPermissionDenied
492 return ToolResponse{}, permission.ErrorPermissionDenied
488 }
493 }
489
494
495+ if isCrlf {
496+ newContent, _ = fsext.ToWindowsLineEndings(newContent)
497+ }
498+
490 err = os.WriteFile(filePath, []byte(newContent), 0o644)
499 err = os.WriteFile(filePath, []byte(newContent), 0o644)
491 if err != nil {
500 if err != nil {
492 return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
501 return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
internal/llm/tools/multiedit.go+5−1
@@ -335,7 +335,7 @@ func (m *multiEditTool) processMultiEditExistingFile(ctx context.Context, params
335 return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
335 return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
336 }
336 }
337
337
338− oldContent := string(content)
338+ oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
339 currentContent := oldContent
339 currentContent := oldContent
340
340
341 // Apply all edits sequentially
341 // Apply all edits sequentially
@@ -377,6 +377,10 @@ func (m *multiEditTool) processMultiEditExistingFile(ctx context.Context, params
377 return ToolResponse{}, permission.ErrorPermissionDenied
377 return ToolResponse{}, permission.ErrorPermissionDenied
378 }
378 }
379
379
380+ if isCrlf {
381+ currentContent, _ = fsext.ToWindowsLineEndings(currentContent)
382+ }
383+
380 // Write the updated content
384 // Write the updated content
381 err = os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
385 err = os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
382 if err != nil {
386 if err != nil {