1package tools
2
3import (
4 "context"
5 _ "embed"
6 "fmt"
7 "log/slog"
8 "os"
9 "path/filepath"
10 "strings"
11 "time"
12
13 "charm.land/fantasy"
14 "github.com/charmbracelet/crush/internal/diff"
15 "github.com/charmbracelet/crush/internal/filepathext"
16 "github.com/charmbracelet/crush/internal/filetracker"
17 "github.com/charmbracelet/crush/internal/fsext"
18 "github.com/charmbracelet/crush/internal/history"
19 "github.com/charmbracelet/crush/internal/lsp"
20 "github.com/charmbracelet/crush/internal/permission"
21)
22
23type MultiEditOperation struct {
24 OldString string `json:"old_string" description:"The text to replace"`
25 NewString string `json:"new_string" description:"The text to replace it with"`
26 ReplaceAll bool `json:"replace_all,omitempty" description:"Replace all occurrences of old_string (default false)."`
27}
28
29type MultiEditParams struct {
30 FilePath string `json:"file_path" description:"The absolute path to the file to modify"`
31 Edits []MultiEditOperation `json:"edits" description:"Array of edit operations to perform sequentially on the file"`
32}
33
34type MultiEditPermissionsParams struct {
35 FilePath string `json:"file_path"`
36 OldContent string `json:"old_content,omitempty"`
37 NewContent string `json:"new_content,omitempty"`
38}
39
40type FailedEdit struct {
41 Index int `json:"index"`
42 Error string `json:"error"`
43 Edit MultiEditOperation `json:"edit"`
44}
45
46type MultiEditResponseMetadata struct {
47 Additions int `json:"additions"`
48 Removals int `json:"removals"`
49 OldContent string `json:"old_content,omitempty"`
50 NewContent string `json:"new_content,omitempty"`
51 EditsApplied int `json:"edits_applied"`
52 EditsFailed []FailedEdit `json:"edits_failed,omitempty"`
53}
54
55const MultiEditToolName = "multiedit"
56
57//go:embed multiedit.md
58var multieditDescription string
59
60func NewMultiEditTool(
61 lspManager *lsp.Manager,
62 permissions permission.Service,
63 files history.Service,
64 filetracker filetracker.Service,
65 workingDir string,
66) fantasy.AgentTool {
67 return fantasy.NewAgentTool(
68 MultiEditToolName,
69 multieditDescription,
70 func(ctx context.Context, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
71 if params.FilePath == "" {
72 return fantasy.NewTextErrorResponse("file_path is required"), nil
73 }
74
75 if len(params.Edits) == 0 {
76 return fantasy.NewTextErrorResponse("at least one edit operation is required"), nil
77 }
78
79 params.FilePath = filepathext.SmartJoin(workingDir, params.FilePath)
80
81 // Validate all edits before applying any
82 if err := validateEdits(params.Edits); err != nil {
83 return fantasy.NewTextErrorResponse(err.Error()), nil
84 }
85
86 var response fantasy.ToolResponse
87 var err error
88
89 editCtx := editContext{ctx, permissions, files, filetracker, workingDir}
90 // Handle file creation case (first edit has empty old_string)
91 if len(params.Edits) > 0 && params.Edits[0].OldString == "" {
92 response, err = processMultiEditWithCreation(editCtx, params, call)
93 } else {
94 response, err = processMultiEditExistingFile(editCtx, params, call)
95 }
96
97 if err != nil {
98 return response, err
99 }
100
101 if response.IsError {
102 return response, nil
103 }
104
105 // Notify LSP clients about the change
106 notifyLSPs(ctx, lspManager, params.FilePath)
107
108 // Wait for LSP diagnostics and add them to the response
109 text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
110 text += getDiagnostics(params.FilePath, lspManager)
111 response.Content = text
112 return response, nil
113 },
114 )
115}
116
117func validateEdits(edits []MultiEditOperation) error {
118 for i, edit := range edits {
119 // Only the first edit can have empty old_string (for file creation)
120 if i > 0 && edit.OldString == "" {
121 return fmt.Errorf("edit %d: only the first edit can have empty old_string (for file creation)", i+1)
122 }
123 }
124 return nil
125}
126
127func processMultiEditWithCreation(edit editContext, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
128 // First edit creates the file
129 firstEdit := params.Edits[0]
130 if firstEdit.OldString != "" {
131 return fantasy.NewTextErrorResponse("first edit must have empty old_string for file creation"), nil
132 }
133
134 // Check if file already exists
135 if _, err := os.Stat(params.FilePath); err == nil {
136 return fantasy.NewTextErrorResponse(fmt.Sprintf("file already exists: %s", params.FilePath)), nil
137 } else if !os.IsNotExist(err) {
138 return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
139 }
140
141 // Create parent directories
142 dir := filepath.Dir(params.FilePath)
143 if err := os.MkdirAll(dir, 0o755); err != nil {
144 return fantasy.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
145 }
146
147 // Start with the content from the first edit
148 currentContent := firstEdit.NewString
149
150 // Apply remaining edits to the content, tracking failures
151 var failedEdits []FailedEdit
152 for i := 1; i < len(params.Edits); i++ {
153 edit := params.Edits[i]
154 newContent, err := applyEditToContent(currentContent, edit)
155 if err != nil {
156 failedEdits = append(failedEdits, FailedEdit{
157 Index: i + 1,
158 Error: err.Error(),
159 Edit: edit,
160 })
161 continue
162 }
163 currentContent = newContent
164 }
165
166 // Get session and message IDs
167 sessionID := GetSessionFromContext(edit.ctx)
168 if sessionID == "" {
169 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
170 }
171
172 // Check permissions
173 _, additions, removals := diff.GenerateDiff("", currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir))
174
175 editsApplied := len(params.Edits) - len(failedEdits)
176 var description string
177 if len(failedEdits) > 0 {
178 description = fmt.Sprintf("Create file %s with %d of %d edits (%d failed)", params.FilePath, editsApplied, len(params.Edits), len(failedEdits))
179 } else {
180 description = fmt.Sprintf("Create file %s with %d edits", params.FilePath, editsApplied)
181 }
182 p, err := edit.permissions.Request(edit.ctx, permission.CreatePermissionRequest{
183 SessionID: sessionID,
184 Path: fsext.PathOrPrefix(params.FilePath, edit.workingDir),
185 ToolCallID: call.ID,
186 ToolName: MultiEditToolName,
187 Action: "write",
188 Description: description,
189 Params: MultiEditPermissionsParams{
190 FilePath: params.FilePath,
191 OldContent: "",
192 NewContent: currentContent,
193 },
194 })
195 if err != nil {
196 return fantasy.ToolResponse{}, err
197 }
198 if !p {
199 resp := NewPermissionDeniedResponse()
200 resp = fantasy.WithResponseMetadata(resp, MultiEditResponseMetadata{
201 OldContent: "",
202 NewContent: currentContent,
203 Additions: additions,
204 Removals: removals,
205 EditsApplied: editsApplied,
206 EditsFailed: failedEdits,
207 })
208 return resp, nil
209 }
210
211 // Write the file
212 err = os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
213 if err != nil {
214 return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
215 }
216
217 // Update file history
218 _, err = edit.files.Create(edit.ctx, sessionID, params.FilePath, "")
219 if err != nil {
220 return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
221 }
222
223 _, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, currentContent)
224 if err != nil {
225 slog.Error("Error creating file history version", "error", err)
226 }
227
228 edit.filetracker.RecordRead(edit.ctx, sessionID, params.FilePath)
229
230 var message string
231 if len(failedEdits) > 0 {
232 message = fmt.Sprintf("File created with %d of %d edits: %s (%d edit(s) failed)", editsApplied, len(params.Edits), params.FilePath, len(failedEdits))
233 } else {
234 message = fmt.Sprintf("File created with %d edits: %s", len(params.Edits), params.FilePath)
235 }
236
237 return fantasy.WithResponseMetadata(
238 fantasy.NewTextResponse(message),
239 MultiEditResponseMetadata{
240 OldContent: "",
241 NewContent: currentContent,
242 Additions: additions,
243 Removals: removals,
244 EditsApplied: editsApplied,
245 EditsFailed: failedEdits,
246 },
247 ), nil
248}
249
250func processMultiEditExistingFile(edit editContext, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
251 // Validate file exists and is readable
252 fileInfo, err := os.Stat(params.FilePath)
253 if err != nil {
254 if os.IsNotExist(err) {
255 return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", params.FilePath)), nil
256 }
257 return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
258 }
259
260 if fileInfo.IsDir() {
261 return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
262 }
263
264 sessionID := GetSessionFromContext(edit.ctx)
265 if sessionID == "" {
266 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for editing file")
267 }
268
269 // Check if file was read before editing
270 lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, params.FilePath)
271 if lastRead.IsZero() {
272 return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
273 }
274
275 // Check if file was modified since last read.
276 modTime := fileInfo.ModTime().Truncate(time.Second)
277 if modTime.After(lastRead) {
278 return fantasy.NewTextErrorResponse(
279 fmt.Sprintf(
280 "file %s has been modified since it was last read (mod time: %s, last read: %s)",
281 params.FilePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
282 ),
283 ), nil
284 }
285
286 // Read current file content
287 content, err := os.ReadFile(params.FilePath)
288 if err != nil {
289 return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
290 }
291
292 oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
293 currentContent := oldContent
294
295 // Apply all edits sequentially, tracking failures
296 var failedEdits []FailedEdit
297 for i, edit := range params.Edits {
298 newContent, err := applyEditToContent(currentContent, edit)
299 if err != nil {
300 failedEdits = append(failedEdits, FailedEdit{
301 Index: i + 1,
302 Error: err.Error(),
303 Edit: edit,
304 })
305 continue
306 }
307 currentContent = newContent
308 }
309
310 // Check if content actually changed
311 if oldContent == currentContent {
312 // If we have failed edits, report them
313 if len(failedEdits) > 0 {
314 return fantasy.WithResponseMetadata(
315 fantasy.NewTextErrorResponse(fmt.Sprintf("no changes made - all %d edit(s) failed", len(failedEdits))),
316 MultiEditResponseMetadata{
317 EditsApplied: 0,
318 EditsFailed: failedEdits,
319 },
320 ), nil
321 }
322 return fantasy.NewTextErrorResponse("no changes made - all edits resulted in identical content"), nil
323 }
324
325 // Generate diff and check permissions
326 _, additions, removals := diff.GenerateDiff(oldContent, currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir))
327
328 editsApplied := len(params.Edits) - len(failedEdits)
329 var description string
330 if len(failedEdits) > 0 {
331 description = fmt.Sprintf("Apply %d of %d edits to file %s (%d failed)", editsApplied, len(params.Edits), params.FilePath, len(failedEdits))
332 } else {
333 description = fmt.Sprintf("Apply %d edits to file %s", editsApplied, params.FilePath)
334 }
335 p, err := edit.permissions.Request(edit.ctx, permission.CreatePermissionRequest{
336 SessionID: sessionID,
337 Path: fsext.PathOrPrefix(params.FilePath, edit.workingDir),
338 ToolCallID: call.ID,
339 ToolName: MultiEditToolName,
340 Action: "write",
341 Description: description,
342 Params: MultiEditPermissionsParams{
343 FilePath: params.FilePath,
344 OldContent: oldContent,
345 NewContent: currentContent,
346 },
347 })
348 if err != nil {
349 return fantasy.ToolResponse{}, err
350 }
351 if !p {
352 resp := NewPermissionDeniedResponse()
353 resp = fantasy.WithResponseMetadata(resp, MultiEditResponseMetadata{
354 OldContent: oldContent,
355 NewContent: currentContent,
356 Additions: additions,
357 Removals: removals,
358 EditsApplied: editsApplied,
359 EditsFailed: failedEdits,
360 })
361 return resp, nil
362 }
363
364 if isCrlf {
365 currentContent, _ = fsext.ToWindowsLineEndings(currentContent)
366 }
367
368 // Write the updated content
369 err = os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
370 if err != nil {
371 return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
372 }
373
374 // Update file history
375 file, err := edit.files.GetByPathAndSession(edit.ctx, params.FilePath, sessionID)
376 if err != nil {
377 _, err = edit.files.Create(edit.ctx, sessionID, params.FilePath, oldContent)
378 if err != nil {
379 return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
380 }
381 }
382 if file.Content != oldContent {
383 // User manually changed the content, store an intermediate version
384 _, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, oldContent)
385 if err != nil {
386 slog.Error("Error creating file history version", "error", err)
387 }
388 }
389
390 // Store the new version
391 _, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, currentContent)
392 if err != nil {
393 slog.Error("Error creating file history version", "error", err)
394 }
395
396 edit.filetracker.RecordRead(edit.ctx, sessionID, params.FilePath)
397
398 var message string
399 if len(failedEdits) > 0 {
400 message = fmt.Sprintf("Applied %d of %d edits to file: %s (%d edit(s) failed)", editsApplied, len(params.Edits), params.FilePath, len(failedEdits))
401 } else {
402 message = fmt.Sprintf("Applied %d edits to file: %s", len(params.Edits), params.FilePath)
403 }
404
405 return fantasy.WithResponseMetadata(
406 fantasy.NewTextResponse(message),
407 MultiEditResponseMetadata{
408 OldContent: oldContent,
409 NewContent: currentContent,
410 Additions: additions,
411 Removals: removals,
412 EditsApplied: editsApplied,
413 EditsFailed: failedEdits,
414 },
415 ), nil
416}
417
418func applyEditToContent(content string, edit MultiEditOperation) (string, error) {
419 if edit.OldString == "" && edit.NewString == "" {
420 return content, nil
421 }
422
423 if edit.OldString == "" {
424 return "", fmt.Errorf("old_string cannot be empty for content replacement")
425 }
426
427 var newContent string
428 var replacementCount int
429
430 if edit.ReplaceAll {
431 newContent = strings.ReplaceAll(content, edit.OldString, edit.NewString)
432 replacementCount = strings.Count(content, edit.OldString)
433 if replacementCount == 0 {
434 return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
435 }
436 } else {
437 index := strings.Index(content, edit.OldString)
438 if index == -1 {
439 return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
440 }
441
442 lastIndex := strings.LastIndex(content, edit.OldString)
443 if index != lastIndex {
444 return "", fmt.Errorf("old_string appears multiple times in the content. Please provide more context to ensure a unique match, or set replace_all to true")
445 }
446
447 newContent = content[:index] + edit.NewString + content[index+len(edit.OldString):]
448 replacementCount = 1
449 }
450
451 return newContent, nil
452}