edit.go

  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}