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 if err != nil {
75 return response, err
76 }
77 }
78
79 if params.NewString == "" {
80 response, err = deleteContent(editCtx, params.FilePath, params.OldString, params.ReplaceAll, call)
81 if err != nil {
82 return response, err
83 }
84 }
85
86 response, err = replaceContent(editCtx, params.FilePath, params.OldString, params.NewString, params.ReplaceAll, call)
87 if err != nil {
88 return response, err
89 }
90 if response.IsError {
91 // Return early if there was an error during content replacement
92 // This prevents unnecessary LSP diagnostics processing
93 return response, nil
94 }
95
96 notifyLSPs(ctx, lspClients, params.FilePath)
97
98 text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
99 text += getDiagnostics(params.FilePath, lspClients)
100 response.Content = text
101 return response, nil
102 })
103}
104
105func createNewFile(edit editContext, filePath, content string, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
106 fileInfo, err := os.Stat(filePath)
107 if err == nil {
108 if fileInfo.IsDir() {
109 return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
110 }
111 return fantasy.NewTextErrorResponse(fmt.Sprintf("file already exists: %s", filePath)), nil
112 } else if !os.IsNotExist(err) {
113 return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
114 }
115
116 dir := filepath.Dir(filePath)
117 if err = os.MkdirAll(dir, 0o755); err != nil {
118 return fantasy.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
119 }
120
121 sessionID := GetSessionFromContext(edit.ctx)
122 if sessionID == "" {
123 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
124 }
125
126 _, additions, removals := diff.GenerateDiff(
127 "",
128 content,
129 strings.TrimPrefix(filePath, edit.workingDir),
130 )
131 granted, err := CheckHookPermission(edit.ctx, edit.permissions, permission.CreatePermissionRequest{
132 SessionID: sessionID,
133 Path: fsext.PathOrPrefix(filePath, edit.workingDir),
134 ToolCallID: call.ID,
135 ToolName: EditToolName,
136 Action: "write",
137 Description: fmt.Sprintf("Create file %s", filePath),
138 Params: EditPermissionsParams{
139 FilePath: filePath,
140 OldContent: "",
141 NewContent: content,
142 },
143 })
144 if err != nil {
145 return fantasy.ToolResponse{}, err
146 }
147 if !granted {
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 }), nil
181}
182
183func deleteContent(edit editContext, filePath, oldString string, replaceAll bool, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
184 fileInfo, err := os.Stat(filePath)
185 if err != nil {
186 if os.IsNotExist(err) {
187 return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
188 }
189 return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
190 }
191
192 if fileInfo.IsDir() {
193 return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
194 }
195
196 if getLastReadTime(filePath).IsZero() {
197 return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
198 }
199
200 modTime := fileInfo.ModTime()
201 lastRead := getLastReadTime(filePath)
202 if modTime.After(lastRead) {
203 return fantasy.NewTextErrorResponse(
204 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
205 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
206 )), nil
207 }
208
209 content, err := os.ReadFile(filePath)
210 if err != nil {
211 return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
212 }
213
214 oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
215
216 var newContent string
217 var deletionCount int
218
219 if replaceAll {
220 newContent = strings.ReplaceAll(oldContent, oldString, "")
221 deletionCount = strings.Count(oldContent, oldString)
222 if deletionCount == 0 {
223 return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
224 }
225 } else {
226 index := strings.Index(oldContent, oldString)
227 if index == -1 {
228 return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
229 }
230
231 lastIndex := strings.LastIndex(oldContent, oldString)
232 if index != lastIndex {
233 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
234 }
235
236 newContent = oldContent[:index] + oldContent[index+len(oldString):]
237 deletionCount = 1
238 }
239
240 sessionID := GetSessionFromContext(edit.ctx)
241
242 if sessionID == "" {
243 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
244 }
245
246 _, additions, removals := diff.GenerateDiff(
247 oldContent,
248 newContent,
249 strings.TrimPrefix(filePath, edit.workingDir),
250 )
251
252 granted, err := CheckHookPermission(edit.ctx, edit.permissions, permission.CreatePermissionRequest{
253 SessionID: sessionID,
254 Path: fsext.PathOrPrefix(filePath, edit.workingDir),
255 ToolCallID: call.ID,
256 ToolName: EditToolName,
257 Action: "write",
258 Description: fmt.Sprintf("Delete content from file %s", filePath),
259 Params: EditPermissionsParams{
260 FilePath: filePath,
261 OldContent: oldContent,
262 NewContent: newContent,
263 },
264 })
265 if err != nil {
266 return fantasy.ToolResponse{}, err
267 }
268 if !granted {
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 }), 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 getLastReadTime(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 := getLastReadTime(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 granted, err := CheckHookPermission(edit.ctx, edit.permissions, permission.CreatePermissionRequest{
388 SessionID: sessionID,
389 Path: fsext.PathOrPrefix(filePath, edit.workingDir),
390 ToolCallID: call.ID,
391 ToolName: EditToolName,
392 Action: "write",
393 Description: fmt.Sprintf("Replace content in file %s", filePath),
394 Params: EditPermissionsParams{
395 FilePath: filePath,
396 OldContent: oldContent,
397 NewContent: newContent,
398 },
399 })
400 if err != nil {
401 return fantasy.ToolResponse{}, err
402 }
403 if !granted {
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}