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