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