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