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
20 "github.com/charmbracelet/crush/internal/lsp"
21 "github.com/charmbracelet/crush/internal/permission"
22)
23
24type EditParams struct {
25 FilePath string `json:"file_path" description:"The absolute path to the file to modify"`
26 OldString string `json:"old_string" description:"The text to replace"`
27 NewString string `json:"new_string" description:"The text to replace it with"`
28 ReplaceAll bool `json:"replace_all,omitempty" description:"Replace all occurrences of old_string (default false)"`
29}
30
31type EditPermissionsParams struct {
32 FilePath string `json:"file_path"`
33 OldContent string `json:"old_content,omitempty"`
34 NewContent string `json:"new_content,omitempty"`
35}
36
37type EditResponseMetadata struct {
38 Additions int `json:"additions"`
39 Removals int `json:"removals"`
40 OldContent string `json:"old_content,omitempty"`
41 NewContent string `json:"new_content,omitempty"`
42}
43
44const EditToolName = "edit"
45
46var (
47 oldStringNotFoundErr = fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks.")
48 oldStringMultipleMatchesErr = fantasy.NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true")
49)
50
51//go:embed edit.md
52var editDescription string
53
54type editContext struct {
55 ctx context.Context
56 permissions permission.Service
57 files history.Service
58 filetracker filetracker.Service
59 workingDir string
60}
61
62func NewEditTool(
63 lspManager *lsp.Manager,
64 permissions permission.Service,
65 files history.Service,
66 filetracker filetracker.Service,
67 workingDir string,
68) fantasy.AgentTool {
69 return fantasy.NewAgentTool(
70 EditToolName,
71 editDescription,
72 func(ctx context.Context, params EditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
73 if params.FilePath == "" {
74 return fantasy.NewTextErrorResponse("file_path is required"), nil
75 }
76
77 params.FilePath = filepathext.SmartJoin(workingDir, params.FilePath)
78
79 var response fantasy.ToolResponse
80 var err error
81
82 editCtx := editContext{ctx, permissions, files, filetracker, workingDir}
83
84 if params.OldString == "" {
85 response, err = createNewFile(editCtx, params.FilePath, params.NewString, call)
86 } else if params.NewString == "" {
87 response, err = deleteContent(editCtx, params.FilePath, params.OldString, params.ReplaceAll, call)
88 } else {
89 response, err = replaceContent(editCtx, params.FilePath, params.OldString, params.NewString, params.ReplaceAll, call)
90 }
91
92 if err != nil {
93 return response, err
94 }
95 if response.IsError {
96 // Return early if there was an error during content replacement
97 // This prevents unnecessary LSP diagnostics processing
98 return response, nil
99 }
100
101 notifyLSPs(ctx, lspManager, params.FilePath)
102
103 text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
104 text += getDiagnostics(params.FilePath, lspManager)
105 response.Content = text
106 return response, nil
107 },
108 )
109}
110
111func createNewFile(edit editContext, filePath, content string, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
112 fileInfo, err := os.Stat(filePath)
113 if err == nil {
114 if fileInfo.IsDir() {
115 return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
116 }
117 return fantasy.NewTextErrorResponse(fmt.Sprintf("file already exists: %s", filePath)), nil
118 } else if !os.IsNotExist(err) {
119 return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
120 }
121
122 dir := filepath.Dir(filePath)
123 if err = os.MkdirAll(dir, 0o755); err != nil {
124 return fantasy.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
125 }
126
127 sessionID := GetSessionFromContext(edit.ctx)
128 if sessionID == "" {
129 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
130 }
131
132 _, additions, removals := diff.GenerateDiff(
133 "",
134 content,
135 strings.TrimPrefix(filePath, edit.workingDir),
136 )
137 p, err := edit.permissions.Request(
138 edit.ctx,
139 permission.CreatePermissionRequest{
140 SessionID: sessionID,
141 Path: fsext.PathOrPrefix(filePath, edit.workingDir),
142 ToolCallID: call.ID,
143 ToolName: EditToolName,
144 Action: "write",
145 Description: fmt.Sprintf("Create file %s", filePath),
146 Params: EditPermissionsParams{
147 FilePath: filePath,
148 OldContent: "",
149 NewContent: content,
150 },
151 },
152 )
153 if err != nil {
154 return fantasy.ToolResponse{}, err
155 }
156 if !p {
157 resp := NewPermissionDeniedResponse()
158 resp = fantasy.WithResponseMetadata(resp, EditResponseMetadata{
159 OldContent: "",
160 NewContent: content,
161 Additions: additions,
162 Removals: removals,
163 })
164 return resp, nil
165 }
166
167 err = os.WriteFile(filePath, []byte(content), 0o644)
168 if err != nil {
169 return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
170 }
171
172 // File can't be in the history so we create a new file history
173 _, err = edit.files.Create(edit.ctx, sessionID, filePath, "")
174 if err != nil {
175 // Log error but don't fail the operation
176 return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
177 }
178
179 // Add the new content to the file history
180 _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, content)
181 if err != nil {
182 // Log error but don't fail the operation
183 slog.Error("Error creating file history version", "error", err)
184 }
185
186 edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
187
188 return fantasy.WithResponseMetadata(
189 fantasy.NewTextResponse("File created: "+filePath),
190 EditResponseMetadata{
191 OldContent: "",
192 NewContent: content,
193 Additions: additions,
194 Removals: removals,
195 },
196 ), nil
197}
198
199func deleteContent(edit editContext, filePath, oldString string, replaceAll bool, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
200 fileInfo, err := os.Stat(filePath)
201 if err != nil {
202 if os.IsNotExist(err) {
203 return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
204 }
205 return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
206 }
207
208 if fileInfo.IsDir() {
209 return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
210 }
211
212 sessionID := GetSessionFromContext(edit.ctx)
213 if sessionID == "" {
214 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for deleting content")
215 }
216
217 lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, filePath)
218 if lastRead.IsZero() {
219 return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
220 }
221
222 modTime := fileInfo.ModTime().Truncate(time.Second)
223 if modTime.After(lastRead) {
224 return fantasy.NewTextErrorResponse(
225 fmt.Sprintf(
226 "file %s has been modified since it was last read (mod time: %s, last read: %s)",
227 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
228 ),
229 ), nil
230 }
231
232 content, err := os.ReadFile(filePath)
233 if err != nil {
234 return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
235 }
236
237 oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
238
239 var newContent string
240
241 if replaceAll {
242 newContent = strings.ReplaceAll(oldContent, oldString, "")
243 if newContent == oldContent {
244 return oldStringNotFoundErr, nil
245 }
246 } else {
247 index := strings.Index(oldContent, oldString)
248 if index == -1 {
249 return oldStringNotFoundErr, nil
250 }
251
252 lastIndex := strings.LastIndex(oldContent, oldString)
253 if index != lastIndex {
254 return fantasy.NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true"), nil
255 }
256
257 newContent = oldContent[:index] + oldContent[index+len(oldString):]
258 }
259
260 _, additions, removals := diff.GenerateDiff(
261 oldContent,
262 newContent,
263 strings.TrimPrefix(filePath, edit.workingDir),
264 )
265
266 p, err := edit.permissions.Request(
267 edit.ctx,
268 permission.CreatePermissionRequest{
269 SessionID: sessionID,
270 Path: fsext.PathOrPrefix(filePath, edit.workingDir),
271 ToolCallID: call.ID,
272 ToolName: EditToolName,
273 Action: "write",
274 Description: fmt.Sprintf("Delete content from file %s", filePath),
275 Params: EditPermissionsParams{
276 FilePath: filePath,
277 OldContent: oldContent,
278 NewContent: newContent,
279 },
280 },
281 )
282 if err != nil {
283 return fantasy.ToolResponse{}, err
284 }
285 if !p {
286 resp := NewPermissionDeniedResponse()
287 resp = fantasy.WithResponseMetadata(resp, EditResponseMetadata{
288 OldContent: oldContent,
289 NewContent: newContent,
290 Additions: additions,
291 Removals: removals,
292 })
293 return resp, nil
294 }
295
296 if isCrlf {
297 newContent, _ = fsext.ToWindowsLineEndings(newContent)
298 }
299
300 err = os.WriteFile(filePath, []byte(newContent), 0o644)
301 if err != nil {
302 return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
303 }
304
305 // Check if file exists in history
306 file, err := edit.files.GetByPathAndSession(edit.ctx, filePath, sessionID)
307 if err != nil {
308 _, err = edit.files.Create(edit.ctx, sessionID, filePath, oldContent)
309 if err != nil {
310 // Log error but don't fail the operation
311 return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
312 }
313 }
314 if file.Content != oldContent {
315 // User manually changed the content; store an intermediate version
316 _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, oldContent)
317 if err != nil {
318 slog.Error("Error creating file history version", "error", err)
319 }
320 }
321 // Store the new version
322 _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, newContent)
323 if err != nil {
324 slog.Error("Error creating file history version", "error", err)
325 }
326
327 edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
328
329 return fantasy.WithResponseMetadata(
330 fantasy.NewTextResponse("Content deleted from file: "+filePath),
331 EditResponseMetadata{
332 OldContent: oldContent,
333 NewContent: newContent,
334 Additions: additions,
335 Removals: removals,
336 },
337 ), nil
338}
339
340func replaceContent(edit editContext, filePath, oldString, newString string, replaceAll bool, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
341 fileInfo, err := os.Stat(filePath)
342 if err != nil {
343 if os.IsNotExist(err) {
344 return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
345 }
346 return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
347 }
348
349 if fileInfo.IsDir() {
350 return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
351 }
352
353 sessionID := GetSessionFromContext(edit.ctx)
354 if sessionID == "" {
355 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for edit a file")
356 }
357
358 lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, filePath)
359 if lastRead.IsZero() {
360 return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
361 }
362
363 modTime := fileInfo.ModTime().Truncate(time.Second)
364 if modTime.After(lastRead) {
365 return fantasy.NewTextErrorResponse(
366 fmt.Sprintf(
367 "file %s has been modified since it was last read (mod time: %s, last read: %s)",
368 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
369 ),
370 ), nil
371 }
372
373 content, err := os.ReadFile(filePath)
374 if err != nil {
375 return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
376 }
377
378 oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
379
380 var newContent string
381
382 if replaceAll {
383 newContent = strings.ReplaceAll(oldContent, oldString, newString)
384 } else {
385 index := strings.Index(oldContent, oldString)
386 if index == -1 {
387 return oldStringNotFoundErr, nil
388 }
389
390 lastIndex := strings.LastIndex(oldContent, oldString)
391 if index != lastIndex {
392 return oldStringMultipleMatchesErr, nil
393 }
394
395 newContent = oldContent[:index] + newString + oldContent[index+len(oldString):]
396 }
397
398 if oldContent == newContent {
399 return fantasy.NewTextErrorResponse("new content is the same as old content. No changes made."), nil
400 }
401 _, additions, removals := diff.GenerateDiff(
402 oldContent,
403 newContent,
404 strings.TrimPrefix(filePath, edit.workingDir),
405 )
406
407 p, err := edit.permissions.Request(
408 edit.ctx,
409 permission.CreatePermissionRequest{
410 SessionID: sessionID,
411 Path: fsext.PathOrPrefix(filePath, edit.workingDir),
412 ToolCallID: call.ID,
413 ToolName: EditToolName,
414 Action: "write",
415 Description: fmt.Sprintf("Replace content in file %s", filePath),
416 Params: EditPermissionsParams{
417 FilePath: filePath,
418 OldContent: oldContent,
419 NewContent: newContent,
420 },
421 },
422 )
423 if err != nil {
424 return fantasy.ToolResponse{}, err
425 }
426 if !p {
427 resp := NewPermissionDeniedResponse()
428 resp = fantasy.WithResponseMetadata(resp, EditResponseMetadata{
429 OldContent: oldContent,
430 NewContent: newContent,
431 Additions: additions,
432 Removals: removals,
433 })
434 return resp, nil
435 }
436
437 if isCrlf {
438 newContent, _ = fsext.ToWindowsLineEndings(newContent)
439 }
440
441 err = os.WriteFile(filePath, []byte(newContent), 0o644)
442 if err != nil {
443 return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
444 }
445
446 // Check if file exists in history
447 file, err := edit.files.GetByPathAndSession(edit.ctx, filePath, sessionID)
448 if err != nil {
449 _, err = edit.files.Create(edit.ctx, sessionID, filePath, oldContent)
450 if err != nil {
451 // Log error but don't fail the operation
452 return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
453 }
454 }
455 if file.Content != oldContent {
456 // User manually changed the content; store an intermediate version
457 _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, oldContent)
458 if err != nil {
459 slog.Debug("Error creating file history version", "error", err)
460 }
461 }
462 // Store the new version
463 _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, newContent)
464 if err != nil {
465 slog.Error("Error creating file history version", "error", err)
466 }
467
468 edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
469
470 return fantasy.WithResponseMetadata(
471 fantasy.NewTextResponse("Content replaced in file: "+filePath),
472 EditResponseMetadata{
473 OldContent: oldContent,
474 NewContent: newContent,
475 Additions: additions,
476 Removals: removals,
477 },
478 ), nil
479}