| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486 |
- package tools
- import (
- "context"
- _ "embed"
- "encoding/json"
- "fmt"
- "log/slog"
- "os"
- "path/filepath"
- "strings"
- "time"
- "github.com/charmbracelet/crush/internal/csync"
- "github.com/charmbracelet/crush/internal/diff"
- "github.com/charmbracelet/crush/internal/fsext"
- "github.com/charmbracelet/crush/internal/history"
- "github.com/charmbracelet/crush/internal/lsp"
- "github.com/charmbracelet/crush/internal/permission"
- )
- type EditParams struct {
- FilePath string `json:"file_path"`
- OldString string `json:"old_string"`
- NewString string `json:"new_string"`
- ReplaceAll bool `json:"replace_all,omitempty"`
- }
- type EditPermissionsParams struct {
- FilePath string `json:"file_path"`
- OldContent string `json:"old_content,omitempty"`
- NewContent string `json:"new_content,omitempty"`
- }
- type EditResponseMetadata struct {
- Additions int `json:"additions"`
- Removals int `json:"removals"`
- OldContent string `json:"old_content,omitempty"`
- NewContent string `json:"new_content,omitempty"`
- }
- type editTool struct {
- lspClients *csync.Map[string, *lsp.Client]
- permissions permission.Service
- files history.Service
- workingDir string
- }
- const EditToolName = "edit"
- //go:embed edit.md
- var editDescription []byte
- func NewEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) BaseTool {
- return &editTool{
- lspClients: lspClients,
- permissions: permissions,
- files: files,
- workingDir: workingDir,
- }
- }
- func (e *editTool) Name() string {
- return EditToolName
- }
- func (e *editTool) Info() ToolInfo {
- return ToolInfo{
- Name: EditToolName,
- Description: string(editDescription),
- Parameters: map[string]any{
- "file_path": map[string]any{
- "type": "string",
- "description": "The absolute path to the file to modify",
- },
- "old_string": map[string]any{
- "type": "string",
- "description": "The text to replace",
- },
- "new_string": map[string]any{
- "type": "string",
- "description": "The text to replace it with",
- },
- "replace_all": map[string]any{
- "type": "boolean",
- "description": "Replace all occurrences of old_string (default false)",
- },
- },
- Required: []string{"file_path", "old_string", "new_string"},
- }
- }
- func (e *editTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
- var params EditParams
- if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
- return NewTextErrorResponse("invalid parameters"), nil
- }
- if params.FilePath == "" {
- return NewTextErrorResponse("file_path is required"), nil
- }
- if !filepath.IsAbs(params.FilePath) {
- params.FilePath = filepath.Join(e.workingDir, params.FilePath)
- }
- var response ToolResponse
- var err error
- if params.OldString == "" {
- response, err = e.createNewFile(ctx, params.FilePath, params.NewString, call)
- if err != nil {
- return response, err
- }
- }
- if params.NewString == "" {
- response, err = e.deleteContent(ctx, params.FilePath, params.OldString, params.ReplaceAll, call)
- if err != nil {
- return response, err
- }
- }
- response, err = e.replaceContent(ctx, params.FilePath, params.OldString, params.NewString, params.ReplaceAll, call)
- if err != nil {
- return response, err
- }
- if response.IsError {
- // Return early if there was an error during content replacement
- // This prevents unnecessary LSP diagnostics processing
- return response, nil
- }
- notifyLSPs(ctx, e.lspClients, params.FilePath)
- text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
- text += getDiagnostics(params.FilePath, e.lspClients)
- response.Content = text
- return response, nil
- }
- func (e *editTool) createNewFile(ctx context.Context, filePath, content string, call ToolCall) (ToolResponse, error) {
- fileInfo, err := os.Stat(filePath)
- if err == nil {
- if fileInfo.IsDir() {
- return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
- }
- return NewTextErrorResponse(fmt.Sprintf("file already exists: %s", filePath)), nil
- } else if !os.IsNotExist(err) {
- return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
- }
- dir := filepath.Dir(filePath)
- if err = os.MkdirAll(dir, 0o755); err != nil {
- return ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
- }
- sessionID, messageID := GetContextValues(ctx)
- if sessionID == "" || messageID == "" {
- return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
- }
- _, additions, removals := diff.GenerateDiff(
- "",
- content,
- strings.TrimPrefix(filePath, e.workingDir),
- )
- p := e.permissions.Request(
- permission.CreatePermissionRequest{
- SessionID: sessionID,
- Path: fsext.PathOrPrefix(filePath, e.workingDir),
- ToolCallID: call.ID,
- ToolName: EditToolName,
- Action: "write",
- Description: fmt.Sprintf("Create file %s", filePath),
- Params: EditPermissionsParams{
- FilePath: filePath,
- OldContent: "",
- NewContent: content,
- },
- },
- )
- if !p {
- return ToolResponse{}, permission.ErrorPermissionDenied
- }
- err = os.WriteFile(filePath, []byte(content), 0o644)
- if err != nil {
- return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
- }
- // File can't be in the history so we create a new file history
- _, err = e.files.Create(ctx, sessionID, filePath, "")
- if err != nil {
- // Log error but don't fail the operation
- return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
- }
- // Add the new content to the file history
- _, err = e.files.CreateVersion(ctx, sessionID, filePath, content)
- if err != nil {
- // Log error but don't fail the operation
- slog.Debug("Error creating file history version", "error", err)
- }
- recordFileWrite(filePath)
- recordFileRead(filePath)
- return WithResponseMetadata(
- NewTextResponse("File created: "+filePath),
- EditResponseMetadata{
- OldContent: "",
- NewContent: content,
- Additions: additions,
- Removals: removals,
- },
- ), nil
- }
- func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string, replaceAll bool, call ToolCall) (ToolResponse, error) {
- fileInfo, err := os.Stat(filePath)
- if err != nil {
- if os.IsNotExist(err) {
- return NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
- }
- return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
- }
- if fileInfo.IsDir() {
- return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
- }
- if getLastReadTime(filePath).IsZero() {
- return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
- }
- modTime := fileInfo.ModTime()
- lastRead := getLastReadTime(filePath)
- if modTime.After(lastRead) {
- return NewTextErrorResponse(
- fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
- filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
- )), nil
- }
- content, err := os.ReadFile(filePath)
- if err != nil {
- return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
- }
- oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
- var newContent string
- var deletionCount int
- if replaceAll {
- newContent = strings.ReplaceAll(oldContent, oldString, "")
- deletionCount = strings.Count(oldContent, oldString)
- if deletionCount == 0 {
- return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
- }
- } else {
- index := strings.Index(oldContent, oldString)
- if index == -1 {
- return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
- }
- lastIndex := strings.LastIndex(oldContent, oldString)
- if index != lastIndex {
- return 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
- }
- newContent = oldContent[:index] + oldContent[index+len(oldString):]
- deletionCount = 1
- }
- sessionID, messageID := GetContextValues(ctx)
- if sessionID == "" || messageID == "" {
- return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
- }
- _, additions, removals := diff.GenerateDiff(
- oldContent,
- newContent,
- strings.TrimPrefix(filePath, e.workingDir),
- )
- p := e.permissions.Request(
- permission.CreatePermissionRequest{
- SessionID: sessionID,
- Path: fsext.PathOrPrefix(filePath, e.workingDir),
- ToolCallID: call.ID,
- ToolName: EditToolName,
- Action: "write",
- Description: fmt.Sprintf("Delete content from file %s", filePath),
- Params: EditPermissionsParams{
- FilePath: filePath,
- OldContent: oldContent,
- NewContent: newContent,
- },
- },
- )
- if !p {
- return ToolResponse{}, permission.ErrorPermissionDenied
- }
- if isCrlf {
- newContent, _ = fsext.ToWindowsLineEndings(newContent)
- }
- err = os.WriteFile(filePath, []byte(newContent), 0o644)
- if err != nil {
- return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
- }
- // Check if file exists in history
- file, err := e.files.GetByPathAndSession(ctx, filePath, sessionID)
- if err != nil {
- _, err = e.files.Create(ctx, sessionID, filePath, oldContent)
- if err != nil {
- // Log error but don't fail the operation
- return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
- }
- }
- if file.Content != oldContent {
- // User Manually changed the content store an intermediate version
- _, err = e.files.CreateVersion(ctx, sessionID, filePath, oldContent)
- if err != nil {
- slog.Debug("Error creating file history version", "error", err)
- }
- }
- // Store the new version
- _, err = e.files.CreateVersion(ctx, sessionID, filePath, "")
- if err != nil {
- slog.Debug("Error creating file history version", "error", err)
- }
- recordFileWrite(filePath)
- recordFileRead(filePath)
- return WithResponseMetadata(
- NewTextResponse("Content deleted from file: "+filePath),
- EditResponseMetadata{
- OldContent: oldContent,
- NewContent: newContent,
- Additions: additions,
- Removals: removals,
- },
- ), nil
- }
- func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newString string, replaceAll bool, call ToolCall) (ToolResponse, error) {
- fileInfo, err := os.Stat(filePath)
- if err != nil {
- if os.IsNotExist(err) {
- return NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
- }
- return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
- }
- if fileInfo.IsDir() {
- return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
- }
- if getLastReadTime(filePath).IsZero() {
- return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
- }
- modTime := fileInfo.ModTime()
- lastRead := getLastReadTime(filePath)
- if modTime.After(lastRead) {
- return NewTextErrorResponse(
- fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
- filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
- )), nil
- }
- content, err := os.ReadFile(filePath)
- if err != nil {
- return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
- }
- oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
- var newContent string
- var replacementCount int
- if replaceAll {
- newContent = strings.ReplaceAll(oldContent, oldString, newString)
- replacementCount = strings.Count(oldContent, oldString)
- if replacementCount == 0 {
- return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
- }
- } else {
- index := strings.Index(oldContent, oldString)
- if index == -1 {
- return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
- }
- lastIndex := strings.LastIndex(oldContent, oldString)
- if index != lastIndex {
- return 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
- }
- newContent = oldContent[:index] + newString + oldContent[index+len(oldString):]
- replacementCount = 1
- }
- if oldContent == newContent {
- return NewTextErrorResponse("new content is the same as old content. No changes made."), nil
- }
- sessionID, messageID := GetContextValues(ctx)
- if sessionID == "" || messageID == "" {
- return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
- }
- _, additions, removals := diff.GenerateDiff(
- oldContent,
- newContent,
- strings.TrimPrefix(filePath, e.workingDir),
- )
- p := e.permissions.Request(
- permission.CreatePermissionRequest{
- SessionID: sessionID,
- Path: fsext.PathOrPrefix(filePath, e.workingDir),
- ToolCallID: call.ID,
- ToolName: EditToolName,
- Action: "write",
- Description: fmt.Sprintf("Replace content in file %s", filePath),
- Params: EditPermissionsParams{
- FilePath: filePath,
- OldContent: oldContent,
- NewContent: newContent,
- },
- },
- )
- if !p {
- return ToolResponse{}, permission.ErrorPermissionDenied
- }
- if isCrlf {
- newContent, _ = fsext.ToWindowsLineEndings(newContent)
- }
- err = os.WriteFile(filePath, []byte(newContent), 0o644)
- if err != nil {
- return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
- }
- // Check if file exists in history
- file, err := e.files.GetByPathAndSession(ctx, filePath, sessionID)
- if err != nil {
- _, err = e.files.Create(ctx, sessionID, filePath, oldContent)
- if err != nil {
- // Log error but don't fail the operation
- return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
- }
- }
- if file.Content != oldContent {
- // User Manually changed the content store an intermediate version
- _, err = e.files.CreateVersion(ctx, sessionID, filePath, oldContent)
- if err != nil {
- slog.Debug("Error creating file history version", "error", err)
- }
- }
- // Store the new version
- _, err = e.files.CreateVersion(ctx, sessionID, filePath, newContent)
- if err != nil {
- slog.Debug("Error creating file history version", "error", err)
- }
- recordFileWrite(filePath)
- recordFileRead(filePath)
- return WithResponseMetadata(
- NewTextResponse("Content replaced in file: "+filePath),
- EditResponseMetadata{
- OldContent: oldContent,
- NewContent: newContent,
- Additions: additions,
- Removals: removals,
- }), nil
- }
|