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