/ internal / tools / file_edit.go
file_edit.go
  1  package tools
  2  
  3  import (
  4  	"context"
  5  	"encoding/json"
  6  	"errors"
  7  	"fmt"
  8  	"os"
  9  	"strings"
 10  
 11  	"github.com/Kocoro-lab/ShanClaw/internal/agent"
 12  	"github.com/Kocoro-lab/ShanClaw/internal/cwdctx"
 13  )
 14  
 15  type FileEditTool struct{}
 16  
 17  type fileEditArgs struct {
 18  	Path      string `json:"path"`
 19  	OldString string `json:"old_string"`
 20  	NewString string `json:"new_string"`
 21  }
 22  
 23  func (t *FileEditTool) Info() agent.ToolInfo {
 24  	return agent.ToolInfo{
 25  		Name:        "file_edit",
 26  		Description: "Replace an exact string in a file (old_string must appear exactly once). Prefer this over file_write for targeted edits. If old_string is not unique, use file_read then file_write instead.",
 27  		Parameters: map[string]any{
 28  			"type": "object",
 29  			"properties": map[string]any{
 30  				"path":       map[string]any{"type": "string", "description": "File path to edit"},
 31  				"old_string": map[string]any{"type": "string", "description": "Exact string to find (must be unique)"},
 32  				"new_string": map[string]any{"type": "string", "description": "Replacement string"},
 33  			},
 34  		},
 35  		Required: []string{"path", "old_string", "new_string"},
 36  	}
 37  }
 38  
 39  func (t *FileEditTool) Run(ctx context.Context, argsJSON string) (agent.ToolResult, error) {
 40  	var args fileEditArgs
 41  	if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
 42  		return agent.ToolResult{Content: fmt.Sprintf("invalid arguments: %v", err), IsError: true}, nil
 43  	}
 44  	resolved, resolveErr := cwdctx.ResolveFilesystemPath(ctx, args.Path)
 45  	if resolveErr != nil {
 46  		if errors.Is(resolveErr, cwdctx.ErrNoSessionCWD) {
 47  			return agent.ValidationError(
 48  				"file_edit: no session working directory is set. Pass an absolute path.",
 49  			), nil
 50  		}
 51  		return agent.ValidationError(fmt.Sprintf("file_edit: %v", resolveErr)), nil
 52  	}
 53  	args.Path = resolved
 54  
 55  	// Enforce read-before-edit
 56  	if err := agent.CheckReadBeforeWrite(ctx, args.Path); err != nil {
 57  		return agent.ToolResult{Content: err.Error(), IsError: true}, nil
 58  	}
 59  
 60  	// Block file_edit on the agent's MEMORY.md — use memory_append instead.
 61  	// file_edit is a read-modify-write that races under concurrent sessions.
 62  	if agent.IsMemoryFile(ctx, args.Path) {
 63  		return agent.ToolResult{
 64  			Content: "Cannot edit MEMORY.md with file_edit — it races under concurrent sessions. Use the memory_append tool instead.",
 65  			IsError: true,
 66  		}, nil
 67  	}
 68  
 69  	data, err := os.ReadFile(args.Path)
 70  	if err != nil {
 71  		if os.IsPermission(err) {
 72  			return agent.PermissionError(fmt.Sprintf("cannot read %s: permission denied", args.Path)), nil
 73  		}
 74  		return agent.ToolResult{Content: fmt.Sprintf("error reading file: %v", err), IsError: true}, nil
 75  	}
 76  
 77  	if args.OldString == "" {
 78  		return agent.ValidationError("old_string must not be empty"), nil
 79  	}
 80  
 81  	content := string(data)
 82  	count := strings.Count(content, args.OldString)
 83  	if count == 0 {
 84  		return agent.ValidationError("old_string not found in file"), nil
 85  	}
 86  	if count > 1 {
 87  		return agent.ValidationError(fmt.Sprintf("old_string found %d times (must be unique)", count)), nil
 88  	}
 89  
 90  	newContent := strings.Replace(content, args.OldString, args.NewString, 1)
 91  	// Preserve original file permissions
 92  	perm := os.FileMode(0644)
 93  	if info, err := os.Stat(args.Path); err == nil {
 94  		perm = info.Mode().Perm()
 95  	}
 96  	if err := os.WriteFile(args.Path, []byte(newContent), perm); err != nil {
 97  		if os.IsPermission(err) {
 98  			return agent.PermissionError(fmt.Sprintf("cannot write %s: permission denied", args.Path)), nil
 99  		}
100  		return agent.ToolResult{Content: fmt.Sprintf("error writing file: %v", err), IsError: true}, nil
101  	}
102  
103  	return agent.ToolResult{Content: fmt.Sprintf("edited %s: replaced 1 occurrence", args.Path)}, nil
104  }
105  
106  func (t *FileEditTool) RequiresApproval() bool { return true }
107  
108  func (t *FileEditTool) IsReadOnlyCall(string) bool { return false }