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