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