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