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 []byte
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 string(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
116func validateEdits(edits []MultiEditOperation) error {
117 for i, edit := range edits {
118 // Only the first edit can have empty old_string (for file creation)
119 if i > 0 && edit.OldString == "" {
120 return fmt.Errorf("edit %d: only the first edit can have empty old_string (for file creation)", i+1)
121 }
122 }
123 return nil
124}
125
126func processMultiEditWithCreation(edit editContext, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
127 // First edit creates the file
128 firstEdit := params.Edits[0]
129 if firstEdit.OldString != "" {
130 return fantasy.NewTextErrorResponse("first edit must have empty old_string for file creation"), nil
131 }
132
133 // Check if file already exists
134 if _, err := os.Stat(params.FilePath); err == nil {
135 return fantasy.NewTextErrorResponse(fmt.Sprintf("file already exists: %s", params.FilePath)), nil
136 } else if !os.IsNotExist(err) {
137 return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
138 }
139
140 // Create parent directories
141 dir := filepath.Dir(params.FilePath)
142 if err := os.MkdirAll(dir, 0o755); err != nil {
143 return fantasy.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
144 }
145
146 // Start with the content from the first edit
147 currentContent := firstEdit.NewString
148
149 // Apply remaining edits to the content, tracking failures
150 var failedEdits []FailedEdit
151 for i := 1; i < len(params.Edits); i++ {
152 edit := params.Edits[i]
153 newContent, err := applyEditToContent(currentContent, edit)
154 if err != nil {
155 failedEdits = append(failedEdits, FailedEdit{
156 Index: i + 1,
157 Error: err.Error(),
158 Edit: edit,
159 })
160 continue
161 }
162 currentContent = newContent
163 }
164
165 // Get session and message IDs
166 sessionID := GetSessionFromContext(edit.ctx)
167 if sessionID == "" {
168 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
169 }
170
171 // Check permissions
172 _, additions, removals := diff.GenerateDiff("", currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir))
173
174 editsApplied := len(params.Edits) - len(failedEdits)
175 var description string
176 if len(failedEdits) > 0 {
177 description = fmt.Sprintf("Create file %s with %d of %d edits (%d failed)", params.FilePath, editsApplied, len(params.Edits), len(failedEdits))
178 } else {
179 description = fmt.Sprintf("Create file %s with %d edits", params.FilePath, editsApplied)
180 }
181 p, err := edit.permissions.Request(edit.ctx, permission.CreatePermissionRequest{
182 SessionID: sessionID,
183 Path: fsext.PathOrPrefix(params.FilePath, edit.workingDir),
184 ToolCallID: call.ID,
185 ToolName: MultiEditToolName,
186 Action: "write",
187 Description: description,
188 Params: MultiEditPermissionsParams{
189 FilePath: params.FilePath,
190 OldContent: "",
191 NewContent: currentContent,
192 },
193 })
194 if err != nil {
195 return fantasy.ToolResponse{}, err
196 }
197 if !p {
198 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
199 }
200
201 // Write the file
202 err = os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
203 if err != nil {
204 return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
205 }
206
207 // Update file history
208 _, err = edit.files.Create(edit.ctx, sessionID, params.FilePath, "")
209 if err != nil {
210 return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
211 }
212
213 _, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, currentContent)
214 if err != nil {
215 slog.Error("Error creating file history version", "error", err)
216 }
217
218 edit.filetracker.RecordRead(edit.ctx, sessionID, params.FilePath)
219
220 var message string
221 if len(failedEdits) > 0 {
222 message = fmt.Sprintf("File created with %d of %d edits: %s (%d edit(s) failed)", editsApplied, len(params.Edits), params.FilePath, len(failedEdits))
223 } else {
224 message = fmt.Sprintf("File created with %d edits: %s", len(params.Edits), params.FilePath)
225 }
226
227 return fantasy.WithResponseMetadata(
228 fantasy.NewTextResponse(message),
229 MultiEditResponseMetadata{
230 OldContent: "",
231 NewContent: currentContent,
232 Additions: additions,
233 Removals: removals,
234 EditsApplied: editsApplied,
235 EditsFailed: failedEdits,
236 },
237 ), nil
238}
239
240func processMultiEditExistingFile(edit editContext, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
241 // Validate file exists and is readable
242 fileInfo, err := os.Stat(params.FilePath)
243 if err != nil {
244 if os.IsNotExist(err) {
245 return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", params.FilePath)), nil
246 }
247 return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
248 }
249
250 if fileInfo.IsDir() {
251 return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
252 }
253
254 sessionID := GetSessionFromContext(edit.ctx)
255 if sessionID == "" {
256 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for editing file")
257 }
258
259 // Check if file was read before editing
260 lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, params.FilePath)
261 if lastRead.IsZero() {
262 return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
263 }
264
265 // Check if file was modified since last read.
266 modTime := fileInfo.ModTime().Truncate(time.Second)
267 if modTime.After(lastRead) {
268 return fantasy.NewTextErrorResponse(
269 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
270 params.FilePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
271 )), nil
272 }
273
274 // Read current file content
275 content, err := os.ReadFile(params.FilePath)
276 if err != nil {
277 return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
278 }
279
280 oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
281 currentContent := oldContent
282
283 // Apply all edits sequentially, tracking failures
284 var failedEdits []FailedEdit
285 for i, edit := range params.Edits {
286 newContent, err := applyEditToContent(currentContent, edit)
287 if err != nil {
288 failedEdits = append(failedEdits, FailedEdit{
289 Index: i + 1,
290 Error: err.Error(),
291 Edit: edit,
292 })
293 continue
294 }
295 currentContent = newContent
296 }
297
298 // Check if content actually changed
299 if oldContent == currentContent {
300 // If we have failed edits, report them
301 if len(failedEdits) > 0 {
302 return fantasy.WithResponseMetadata(
303 fantasy.NewTextErrorResponse(fmt.Sprintf("no changes made - all %d edit(s) failed", len(failedEdits))),
304 MultiEditResponseMetadata{
305 EditsApplied: 0,
306 EditsFailed: failedEdits,
307 },
308 ), nil
309 }
310 return fantasy.NewTextErrorResponse("no changes made - all edits resulted in identical content"), nil
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 {
340 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
341 }
342
343 if isCrlf {
344 currentContent, _ = fsext.ToWindowsLineEndings(currentContent)
345 }
346
347 // Write the updated content
348 err = os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
349 if err != nil {
350 return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
351 }
352
353 // Update file history
354 file, err := edit.files.GetByPathAndSession(edit.ctx, params.FilePath, sessionID)
355 if err != nil {
356 _, err = edit.files.Create(edit.ctx, sessionID, params.FilePath, oldContent)
357 if err != nil {
358 return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
359 }
360 }
361 if file.Content != oldContent {
362 // User manually changed the content, store an intermediate version
363 _, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, oldContent)
364 if err != nil {
365 slog.Error("Error creating file history version", "error", err)
366 }
367 }
368
369 // Store the new version
370 _, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, currentContent)
371 if err != nil {
372 slog.Error("Error creating file history version", "error", err)
373 }
374
375 edit.filetracker.RecordRead(edit.ctx, sessionID, params.FilePath)
376
377 var message string
378 if len(failedEdits) > 0 {
379 message = fmt.Sprintf("Applied %d of %d edits to file: %s (%d edit(s) failed)", editsApplied, len(params.Edits), params.FilePath, len(failedEdits))
380 } else {
381 message = fmt.Sprintf("Applied %d edits to file: %s", len(params.Edits), params.FilePath)
382 }
383
384 return fantasy.WithResponseMetadata(
385 fantasy.NewTextResponse(message),
386 MultiEditResponseMetadata{
387 OldContent: oldContent,
388 NewContent: currentContent,
389 Additions: additions,
390 Removals: removals,
391 EditsApplied: editsApplied,
392 EditsFailed: failedEdits,
393 },
394 ), nil
395}
396
397func applyEditToContent(content string, edit MultiEditOperation) (string, error) {
398 if edit.OldString == "" && edit.NewString == "" {
399 return content, nil
400 }
401
402 if edit.OldString == "" {
403 return "", fmt.Errorf("old_string cannot be empty for content replacement")
404 }
405
406 var newContent string
407 var replacementCount int
408
409 if edit.ReplaceAll {
410 newContent = strings.ReplaceAll(content, edit.OldString, edit.NewString)
411 replacementCount = strings.Count(content, edit.OldString)
412 if replacementCount == 0 {
413 return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
414 }
415 } else {
416 index := strings.Index(content, edit.OldString)
417 if index == -1 {
418 return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
419 }
420
421 lastIndex := strings.LastIndex(content, edit.OldString)
422 if index != lastIndex {
423 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")
424 }
425
426 newContent = content[:index] + edit.NewString + content[index+len(edit.OldString):]
427 replacementCount = 1
428 }
429
430 return newContent, nil
431}