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 return NewPermissionDeniedResponse(), nil
200 }
201
202 // Write the file
203 err = os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
204 if err != nil {
205 return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
206 }
207
208 // Update file history
209 _, err = edit.files.Create(edit.ctx, sessionID, params.FilePath, "")
210 if err != nil {
211 return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
212 }
213
214 _, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, currentContent)
215 if err != nil {
216 slog.Error("Error creating file history version", "error", err)
217 }
218
219 edit.filetracker.RecordRead(edit.ctx, sessionID, params.FilePath)
220
221 var message string
222 if len(failedEdits) > 0 {
223 message = fmt.Sprintf("File created with %d of %d edits: %s (%d edit(s) failed)", editsApplied, len(params.Edits), params.FilePath, len(failedEdits))
224 } else {
225 message = fmt.Sprintf("File created with %d edits: %s", len(params.Edits), params.FilePath)
226 }
227
228 return fantasy.WithResponseMetadata(
229 fantasy.NewTextResponse(message),
230 MultiEditResponseMetadata{
231 OldContent: "",
232 NewContent: currentContent,
233 Additions: additions,
234 Removals: removals,
235 EditsApplied: editsApplied,
236 EditsFailed: failedEdits,
237 },
238 ), nil
239}
240
241func processMultiEditExistingFile(edit editContext, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
242 // Validate file exists and is readable
243 fileInfo, err := os.Stat(params.FilePath)
244 if err != nil {
245 if os.IsNotExist(err) {
246 return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", params.FilePath)), nil
247 }
248 return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
249 }
250
251 if fileInfo.IsDir() {
252 return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
253 }
254
255 sessionID := GetSessionFromContext(edit.ctx)
256 if sessionID == "" {
257 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for editing file")
258 }
259
260 // Check if file was read before editing
261 lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, params.FilePath)
262 if lastRead.IsZero() {
263 return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
264 }
265
266 // Check if file was modified since last read.
267 modTime := fileInfo.ModTime().Truncate(time.Second)
268 if modTime.After(lastRead) {
269 return fantasy.NewTextErrorResponse(
270 fmt.Sprintf(
271 "file %s has been modified since it was last read (mod time: %s, last read: %s)",
272 params.FilePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
273 ),
274 ), nil
275 }
276
277 // Read current file content
278 content, err := os.ReadFile(params.FilePath)
279 if err != nil {
280 return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
281 }
282
283 oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
284 currentContent := oldContent
285
286 // Apply all edits sequentially, tracking failures
287 var failedEdits []FailedEdit
288 for i, edit := range params.Edits {
289 newContent, err := applyEditToContent(currentContent, edit)
290 if err != nil {
291 failedEdits = append(failedEdits, FailedEdit{
292 Index: i + 1,
293 Error: err.Error(),
294 Edit: edit,
295 })
296 continue
297 }
298 currentContent = newContent
299 }
300
301 // Check if content actually changed
302 if oldContent == currentContent {
303 // If we have failed edits, report them
304 if len(failedEdits) > 0 {
305 return fantasy.WithResponseMetadata(
306 fantasy.NewTextErrorResponse(fmt.Sprintf("no changes made - all %d edit(s) failed", len(failedEdits))),
307 MultiEditResponseMetadata{
308 EditsApplied: 0,
309 EditsFailed: failedEdits,
310 },
311 ), nil
312 }
313 return fantasy.NewTextErrorResponse("no changes made - all edits resulted in identical content"), nil
314 }
315
316 // Generate diff and check permissions
317 _, additions, removals := diff.GenerateDiff(oldContent, currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir))
318
319 editsApplied := len(params.Edits) - len(failedEdits)
320 var description string
321 if len(failedEdits) > 0 {
322 description = fmt.Sprintf("Apply %d of %d edits to file %s (%d failed)", editsApplied, len(params.Edits), params.FilePath, len(failedEdits))
323 } else {
324 description = fmt.Sprintf("Apply %d edits to file %s", editsApplied, params.FilePath)
325 }
326 p, err := edit.permissions.Request(edit.ctx, permission.CreatePermissionRequest{
327 SessionID: sessionID,
328 Path: fsext.PathOrPrefix(params.FilePath, edit.workingDir),
329 ToolCallID: call.ID,
330 ToolName: MultiEditToolName,
331 Action: "write",
332 Description: description,
333 Params: MultiEditPermissionsParams{
334 FilePath: params.FilePath,
335 OldContent: oldContent,
336 NewContent: currentContent,
337 },
338 })
339 if err != nil {
340 return fantasy.ToolResponse{}, err
341 }
342 if !p {
343 return NewPermissionDeniedResponse(), nil
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 edit.filetracker.RecordRead(edit.ctx, sessionID, params.FilePath)
379
380 var message string
381 if len(failedEdits) > 0 {
382 message = fmt.Sprintf("Applied %d of %d edits to file: %s (%d edit(s) failed)", editsApplied, len(params.Edits), params.FilePath, len(failedEdits))
383 } else {
384 message = fmt.Sprintf("Applied %d edits to file: %s", len(params.Edits), params.FilePath)
385 }
386
387 return fantasy.WithResponseMetadata(
388 fantasy.NewTextResponse(message),
389 MultiEditResponseMetadata{
390 OldContent: oldContent,
391 NewContent: currentContent,
392 Additions: additions,
393 Removals: removals,
394 EditsApplied: editsApplied,
395 EditsFailed: failedEdits,
396 },
397 ), nil
398}
399
400func applyEditToContent(content string, edit MultiEditOperation) (string, error) {
401 if edit.OldString == "" && edit.NewString == "" {
402 return content, nil
403 }
404
405 if edit.OldString == "" {
406 return "", fmt.Errorf("old_string cannot be empty for content replacement")
407 }
408
409 var newContent string
410 var replacementCount int
411
412 if edit.ReplaceAll {
413 newContent = strings.ReplaceAll(content, edit.OldString, edit.NewString)
414 replacementCount = strings.Count(content, edit.OldString)
415 if replacementCount == 0 {
416 return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
417 }
418 } else {
419 index := strings.Index(content, edit.OldString)
420 if index == -1 {
421 return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
422 }
423
424 lastIndex := strings.LastIndex(content, edit.OldString)
425 if index != lastIndex {
426 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")
427 }
428
429 newContent = content[:index] + edit.NewString + content[index+len(edit.OldString):]
430 replacementCount = 1
431 }
432
433 return newContent, nil
434}