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