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 []byte
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 string(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
110func createNewFile(edit editContext, filePath, content string, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
111 fileInfo, err := os.Stat(filePath)
112 if err == nil {
113 if fileInfo.IsDir() {
114 return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
115 }
116 return fantasy.NewTextErrorResponse(fmt.Sprintf("file already exists: %s", filePath)), nil
117 } else if !os.IsNotExist(err) {
118 return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
119 }
120
121 dir := filepath.Dir(filePath)
122 if err = os.MkdirAll(dir, 0o755); err != nil {
123 return fantasy.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
124 }
125
126 sessionID := GetSessionFromContext(edit.ctx)
127 if sessionID == "" {
128 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
129 }
130
131 _, additions, removals := diff.GenerateDiff(
132 "",
133 content,
134 strings.TrimPrefix(filePath, edit.workingDir),
135 )
136 p, err := edit.permissions.Request(edit.ctx,
137 permission.CreatePermissionRequest{
138 SessionID: sessionID,
139 Path: fsext.PathOrPrefix(filePath, edit.workingDir),
140 ToolCallID: call.ID,
141 ToolName: EditToolName,
142 Action: "write",
143 Description: fmt.Sprintf("Create file %s", filePath),
144 Params: EditPermissionsParams{
145 FilePath: filePath,
146 OldContent: "",
147 NewContent: content,
148 },
149 },
150 )
151 if err != nil {
152 return fantasy.ToolResponse{}, err
153 }
154 if !p {
155 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
156 }
157
158 err = os.WriteFile(filePath, []byte(content), 0o644)
159 if err != nil {
160 return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
161 }
162
163 // File can't be in the history so we create a new file history
164 _, err = edit.files.Create(edit.ctx, sessionID, filePath, "")
165 if err != nil {
166 // Log error but don't fail the operation
167 return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
168 }
169
170 // Add the new content to the file history
171 _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, content)
172 if err != nil {
173 // Log error but don't fail the operation
174 slog.Error("Error creating file history version", "error", err)
175 }
176
177 edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
178
179 return fantasy.WithResponseMetadata(
180 fantasy.NewTextResponse("File created: "+filePath),
181 EditResponseMetadata{
182 OldContent: "",
183 NewContent: content,
184 Additions: additions,
185 Removals: removals,
186 },
187 ), nil
188}
189
190func deleteContent(edit editContext, filePath, oldString string, replaceAll bool, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
191 fileInfo, err := os.Stat(filePath)
192 if err != nil {
193 if os.IsNotExist(err) {
194 return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
195 }
196 return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
197 }
198
199 if fileInfo.IsDir() {
200 return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
201 }
202
203 sessionID := GetSessionFromContext(edit.ctx)
204 if sessionID == "" {
205 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for deleting content")
206 }
207
208 lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, filePath)
209 if lastRead.IsZero() {
210 return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
211 }
212
213 modTime := fileInfo.ModTime().Truncate(time.Second)
214 if modTime.After(lastRead) {
215 return fantasy.NewTextErrorResponse(
216 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
217 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
218 )), nil
219 }
220
221 content, err := os.ReadFile(filePath)
222 if err != nil {
223 return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
224 }
225
226 oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
227
228 var newContent string
229
230 if replaceAll {
231 newContent = strings.ReplaceAll(oldContent, oldString, "")
232 if newContent == oldContent {
233 return oldStringNotFoundErr, nil
234 }
235 } else {
236 index := strings.Index(oldContent, oldString)
237 if index == -1 {
238 return oldStringNotFoundErr, nil
239 }
240
241 lastIndex := strings.LastIndex(oldContent, oldString)
242 if index != lastIndex {
243 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
244 }
245
246 newContent = oldContent[:index] + oldContent[index+len(oldString):]
247 }
248
249 _, additions, removals := diff.GenerateDiff(
250 oldContent,
251 newContent,
252 strings.TrimPrefix(filePath, edit.workingDir),
253 )
254
255 p, err := edit.permissions.Request(edit.ctx,
256 permission.CreatePermissionRequest{
257 SessionID: sessionID,
258 Path: fsext.PathOrPrefix(filePath, edit.workingDir),
259 ToolCallID: call.ID,
260 ToolName: EditToolName,
261 Action: "write",
262 Description: fmt.Sprintf("Delete content from file %s", filePath),
263 Params: EditPermissionsParams{
264 FilePath: filePath,
265 OldContent: oldContent,
266 NewContent: newContent,
267 },
268 },
269 )
270 if err != nil {
271 return fantasy.ToolResponse{}, err
272 }
273 if !p {
274 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
275 }
276
277 normalizedNewContent := newContent
278 if isCrlf {
279 newContent, _ = fsext.ToWindowsLineEndings(newContent)
280 }
281
282 err = os.WriteFile(filePath, []byte(newContent), 0o644)
283 if err != nil {
284 return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
285 }
286
287 // Check if file exists in history
288 file, err := edit.files.GetByPathAndSession(edit.ctx, filePath, sessionID)
289 if err != nil {
290 _, err = edit.files.Create(edit.ctx, sessionID, filePath, oldContent)
291 if err != nil {
292 // Log error but don't fail the operation
293 return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
294 }
295 }
296 if file.Content != oldContent {
297 // User manually changed the content; store an intermediate version
298 _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, oldContent)
299 if err != nil {
300 slog.Error("Error creating file history version", "error", err)
301 }
302 }
303 // Store the new version
304 _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, normalizedNewContent)
305 if err != nil {
306 slog.Error("Error creating file history version", "error", err)
307 }
308
309 edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
310
311 return fantasy.WithResponseMetadata(
312 fantasy.NewTextResponse("Content deleted from file: "+filePath),
313 EditResponseMetadata{
314 OldContent: oldContent,
315 NewContent: normalizedNewContent,
316 Additions: additions,
317 Removals: removals,
318 },
319 ), nil
320}
321
322func replaceContent(edit editContext, filePath, oldString, newString string, replaceAll bool, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
323 fileInfo, err := os.Stat(filePath)
324 if err != nil {
325 if os.IsNotExist(err) {
326 return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
327 }
328 return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
329 }
330
331 if fileInfo.IsDir() {
332 return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
333 }
334
335 sessionID := GetSessionFromContext(edit.ctx)
336 if sessionID == "" {
337 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for edit a file")
338 }
339
340 lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, filePath)
341 if lastRead.IsZero() {
342 return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
343 }
344
345 modTime := fileInfo.ModTime().Truncate(time.Second)
346 if modTime.After(lastRead) {
347 return fantasy.NewTextErrorResponse(
348 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
349 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
350 )), nil
351 }
352
353 content, err := os.ReadFile(filePath)
354 if err != nil {
355 return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
356 }
357
358 oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
359
360 var newContent string
361
362 if replaceAll {
363 newContent = strings.ReplaceAll(oldContent, oldString, newString)
364 } else {
365 index := strings.Index(oldContent, oldString)
366 if index == -1 {
367 return oldStringNotFoundErr, nil
368 }
369
370 lastIndex := strings.LastIndex(oldContent, oldString)
371 if index != lastIndex {
372 return oldStringMultipleMatchesErr, nil
373 }
374
375 newContent = oldContent[:index] + newString + oldContent[index+len(oldString):]
376 }
377
378 if oldContent == newContent {
379 return fantasy.NewTextErrorResponse("new content is the same as old content. No changes made."), nil
380 }
381 _, additions, removals := diff.GenerateDiff(
382 oldContent,
383 newContent,
384 strings.TrimPrefix(filePath, edit.workingDir),
385 )
386
387 p, err := edit.permissions.Request(edit.ctx,
388 permission.CreatePermissionRequest{
389 SessionID: sessionID,
390 Path: fsext.PathOrPrefix(filePath, edit.workingDir),
391 ToolCallID: call.ID,
392 ToolName: EditToolName,
393 Action: "write",
394 Description: fmt.Sprintf("Replace content in file %s", filePath),
395 Params: EditPermissionsParams{
396 FilePath: filePath,
397 OldContent: oldContent,
398 NewContent: newContent,
399 },
400 },
401 )
402 if err != nil {
403 return fantasy.ToolResponse{}, err
404 }
405 if !p {
406 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
407 }
408
409 normalizedNewContent := newContent
410 if isCrlf {
411 newContent, _ = fsext.ToWindowsLineEndings(newContent)
412 }
413
414 err = os.WriteFile(filePath, []byte(newContent), 0o644)
415 if err != nil {
416 return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
417 }
418
419 // Check if file exists in history
420 file, err := edit.files.GetByPathAndSession(edit.ctx, filePath, sessionID)
421 if err != nil {
422 _, err = edit.files.Create(edit.ctx, sessionID, filePath, oldContent)
423 if err != nil {
424 // Log error but don't fail the operation
425 return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
426 }
427 }
428 if file.Content != oldContent {
429 // User manually changed the content; store an intermediate version
430 _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, oldContent)
431 if err != nil {
432 slog.Debug("Error creating file history version", "error", err)
433 }
434 }
435 // Store the new version
436 _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, normalizedNewContent)
437 if err != nil {
438 slog.Error("Error creating file history version", "error", err)
439 }
440
441 edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
442
443 return fantasy.WithResponseMetadata(
444 fantasy.NewTextResponse("Content replaced in file: "+filePath),
445 EditResponseMetadata{
446 OldContent: oldContent,
447 NewContent: normalizedNewContent,
448 Additions: additions,
449 Removals: removals,
450 }), nil
451}