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