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 if isCrlf {
278 newContent, _ = fsext.ToWindowsLineEndings(newContent)
279 }
280
281 err = os.WriteFile(filePath, []byte(newContent), 0o644)
282 if err != nil {
283 return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
284 }
285
286 // Check if file exists in history
287 file, err := edit.files.GetByPathAndSession(edit.ctx, filePath, sessionID)
288 if err != nil {
289 _, err = edit.files.Create(edit.ctx, sessionID, filePath, oldContent)
290 if err != nil {
291 // Log error but don't fail the operation
292 return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
293 }
294 }
295 if file.Content != oldContent {
296 // User manually changed the content; store an intermediate version
297 _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, oldContent)
298 if err != nil {
299 slog.Error("Error creating file history version", "error", err)
300 }
301 }
302 // Store the new version
303 _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, newContent)
304 if err != nil {
305 slog.Error("Error creating file history version", "error", err)
306 }
307
308 edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
309
310 return fantasy.WithResponseMetadata(
311 fantasy.NewTextResponse("Content deleted from file: "+filePath),
312 EditResponseMetadata{
313 OldContent: oldContent,
314 NewContent: newContent,
315 Additions: additions,
316 Removals: removals,
317 },
318 ), nil
319}
320
321func replaceContent(edit editContext, filePath, oldString, newString string, replaceAll bool, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
322 fileInfo, err := os.Stat(filePath)
323 if err != nil {
324 if os.IsNotExist(err) {
325 return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
326 }
327 return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
328 }
329
330 if fileInfo.IsDir() {
331 return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
332 }
333
334 sessionID := GetSessionFromContext(edit.ctx)
335 if sessionID == "" {
336 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for edit a file")
337 }
338
339 lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, filePath)
340 if lastRead.IsZero() {
341 return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
342 }
343
344 modTime := fileInfo.ModTime().Truncate(time.Second)
345 if modTime.After(lastRead) {
346 return fantasy.NewTextErrorResponse(
347 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
348 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
349 )), nil
350 }
351
352 content, err := os.ReadFile(filePath)
353 if err != nil {
354 return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
355 }
356
357 oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
358
359 var newContent string
360
361 if replaceAll {
362 newContent = strings.ReplaceAll(oldContent, oldString, newString)
363 } else {
364 index := strings.Index(oldContent, oldString)
365 if index == -1 {
366 return oldStringNotFoundErr, nil
367 }
368
369 lastIndex := strings.LastIndex(oldContent, oldString)
370 if index != lastIndex {
371 return oldStringMultipleMatchesErr, nil
372 }
373
374 newContent = oldContent[:index] + newString + oldContent[index+len(oldString):]
375 }
376
377 if oldContent == newContent {
378 return fantasy.NewTextErrorResponse("new content is the same as old content. No changes made."), nil
379 }
380 _, additions, removals := diff.GenerateDiff(
381 oldContent,
382 newContent,
383 strings.TrimPrefix(filePath, edit.workingDir),
384 )
385
386 p, err := edit.permissions.Request(edit.ctx,
387 permission.CreatePermissionRequest{
388 SessionID: sessionID,
389 Path: fsext.PathOrPrefix(filePath, edit.workingDir),
390 ToolCallID: call.ID,
391 ToolName: EditToolName,
392 Action: "write",
393 Description: fmt.Sprintf("Replace content in file %s", filePath),
394 Params: EditPermissionsParams{
395 FilePath: filePath,
396 OldContent: oldContent,
397 NewContent: newContent,
398 },
399 },
400 )
401 if err != nil {
402 return fantasy.ToolResponse{}, err
403 }
404 if !p {
405 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
406 }
407
408 if isCrlf {
409 newContent, _ = fsext.ToWindowsLineEndings(newContent)
410 }
411
412 err = os.WriteFile(filePath, []byte(newContent), 0o644)
413 if err != nil {
414 return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
415 }
416
417 // Check if file exists in history
418 file, err := edit.files.GetByPathAndSession(edit.ctx, filePath, sessionID)
419 if err != nil {
420 _, err = edit.files.Create(edit.ctx, sessionID, filePath, oldContent)
421 if err != nil {
422 // Log error but don't fail the operation
423 return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
424 }
425 }
426 if file.Content != oldContent {
427 // User manually changed the content; store an intermediate version
428 _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, oldContent)
429 if err != nil {
430 slog.Debug("Error creating file history version", "error", err)
431 }
432 }
433 // Store the new version
434 _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, newContent)
435 if err != nil {
436 slog.Error("Error creating file history version", "error", err)
437 }
438
439 edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
440
441 return fantasy.WithResponseMetadata(
442 fantasy.NewTextResponse("Content replaced in file: "+filePath),
443 EditResponseMetadata{
444 OldContent: oldContent,
445 NewContent: newContent,
446 Additions: additions,
447 Removals: removals,
448 }), nil
449}