Initial commit
diff --git a/claudetool/edit.go b/claudetool/edit.go
new file mode 100644
index 0000000..df83139
--- /dev/null
+++ b/claudetool/edit.go
@@ -0,0 +1,451 @@
+package claudetool
+
+/*
+
+Note: sketch wrote this based on translating https://raw.githubusercontent.com/anthropics/anthropic-quickstarts/refs/heads/main/computer-use-demo/computer_use_demo/tools/edit.py
+
+## Implementation Notes
+This tool is based on Anthropic's Python implementation of the `text_editor_20250124` tool. It maintains a history of file edits to support the undo functionality, and verifies text uniqueness for the str_replace operation to ensure safe edits.
+
+*/
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+
+ "sketch.dev/ant"
+)
+
+// Constants for the AnthropicEditTool
+const (
+ editName = "str_replace_editor"
+)
+
+// Constants used by the tool
+const (
+ snippetLines = 4
+ maxResponseLen = 16000
+ truncatedMessage = "<response clipped><NOTE>To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.</NOTE>"
+)
+
+// Command represents the type of operation to perform
+type editCommand string
+
+const (
+ viewCommand editCommand = "view"
+ createCommand editCommand = "create"
+ strReplaceCommand editCommand = "str_replace"
+ insertCommand editCommand = "insert"
+ undoEditCommand editCommand = "undo_edit"
+)
+
+// editInput represents the expected input format for the edit tool
+type editInput struct {
+ Command string `json:"command"`
+ Path string `json:"path"`
+ FileText *string `json:"file_text,omitempty"`
+ ViewRange []int `json:"view_range,omitempty"`
+ OldStr *string `json:"old_str,omitempty"`
+ NewStr *string `json:"new_str,omitempty"`
+ InsertLine *int `json:"insert_line,omitempty"`
+}
+
+// fileHistory maintains a history of edits for each file to support undo functionality
+var fileHistory = make(map[string][]string)
+
+// AnthropicEditTool is a tool for viewing, creating, and editing files
+var AnthropicEditTool = &ant.Tool{
+ // Note that Type is model-dependent, and would be different for Claude 3.5, for example.
+ Type: "text_editor_20250124",
+ Name: editName,
+ Run: EditRun,
+}
+
+// EditRun is the implementation of the edit tool
+func EditRun(ctx context.Context, input json.RawMessage) (string, error) {
+ var editRequest editInput
+ if err := json.Unmarshal(input, &editRequest); err != nil {
+ return "", fmt.Errorf("failed to parse edit input: %v", err)
+ }
+
+ // Validate the command
+ cmd := editCommand(editRequest.Command)
+ if !isValidCommand(cmd) {
+ return "", fmt.Errorf("unrecognized command %s. The allowed commands are: view, create, str_replace, insert, undo_edit", cmd)
+ }
+
+ path := editRequest.Path
+
+ // Validate the path
+ if err := validatePath(cmd, path); err != nil {
+ return "", err
+ }
+
+ // Execute the appropriate command
+ switch cmd {
+ case viewCommand:
+ return handleView(ctx, path, editRequest.ViewRange)
+ case createCommand:
+ if editRequest.FileText == nil {
+ return "", fmt.Errorf("parameter file_text is required for command: create")
+ }
+ return handleCreate(path, *editRequest.FileText)
+ case strReplaceCommand:
+ if editRequest.OldStr == nil {
+ return "", fmt.Errorf("parameter old_str is required for command: str_replace")
+ }
+ newStr := ""
+ if editRequest.NewStr != nil {
+ newStr = *editRequest.NewStr
+ }
+ return handleStrReplace(path, *editRequest.OldStr, newStr)
+ case insertCommand:
+ if editRequest.InsertLine == nil {
+ return "", fmt.Errorf("parameter insert_line is required for command: insert")
+ }
+ if editRequest.NewStr == nil {
+ return "", fmt.Errorf("parameter new_str is required for command: insert")
+ }
+ return handleInsert(path, *editRequest.InsertLine, *editRequest.NewStr)
+ case undoEditCommand:
+ return handleUndoEdit(path)
+ default:
+ return "", fmt.Errorf("command %s is not implemented", cmd)
+ }
+}
+
+// Utility function to check if a command is valid
+func isValidCommand(cmd editCommand) bool {
+ switch cmd {
+ case viewCommand, createCommand, strReplaceCommand, insertCommand, undoEditCommand:
+ return true
+ default:
+ return false
+ }
+}
+
+// validatePath checks if the path/command combination is valid
+func validatePath(cmd editCommand, path string) error {
+ // Check if it's an absolute path
+ if !filepath.IsAbs(path) {
+ suggestedPath := "/" + path
+ return fmt.Errorf("the path %s is not an absolute path, it should start with '/'. Maybe you meant %s?", path, suggestedPath)
+ }
+
+ // Get file info
+ info, err := os.Stat(path)
+
+ // Check if path exists (except for create command)
+ if err != nil {
+ if os.IsNotExist(err) && cmd != createCommand {
+ return fmt.Errorf("the path %s does not exist. Please provide a valid path", path)
+ } else if !os.IsNotExist(err) {
+ return fmt.Errorf("error accessing path %s: %v", path, err)
+ }
+ } else {
+ // Path exists, check if it's a directory
+ if info.IsDir() && cmd != viewCommand {
+ return fmt.Errorf("the path %s is a directory and only the 'view' command can be used on directories", path)
+ }
+
+ // For create command, check if file already exists
+ if cmd == createCommand {
+ return fmt.Errorf("file already exists at: %s. Cannot overwrite files using command 'create'", path)
+ }
+ }
+
+ return nil
+}
+
+// handleView implements the view command
+func handleView(ctx context.Context, path string, viewRange []int) (string, error) {
+ info, err := os.Stat(path)
+ if err != nil {
+ return "", fmt.Errorf("error accessing path %s: %v", path, err)
+ }
+
+ // Handle directory view
+ if info.IsDir() {
+ if viewRange != nil {
+ return "", fmt.Errorf("the view_range parameter is not allowed when path points to a directory")
+ }
+
+ // List files in the directory (up to 2 levels deep)
+ return listDirectory(ctx, path)
+ }
+
+ // Handle file view
+ fileContent, err := readFile(path)
+ if err != nil {
+ return "", err
+ }
+
+ initLine := 1
+ if viewRange != nil {
+ if len(viewRange) != 2 {
+ return "", fmt.Errorf("invalid view_range. It should be a list of two integers")
+ }
+
+ fileLines := strings.Split(fileContent, "\n")
+ nLinesFile := len(fileLines)
+ initLine, finalLine := viewRange[0], viewRange[1]
+
+ if initLine < 1 || initLine > nLinesFile {
+ return "", fmt.Errorf("invalid view_range: %v. Its first element %d should be within the range of lines of the file: [1, %d]",
+ viewRange, initLine, nLinesFile)
+ }
+
+ if finalLine != -1 && finalLine < initLine {
+ return "", fmt.Errorf("invalid view_range: %v. Its second element %d should be larger or equal than its first %d",
+ viewRange, finalLine, initLine)
+ }
+
+ if finalLine > nLinesFile {
+ return "", fmt.Errorf("invalid view_range: %v. Its second element %d should be smaller than the number of lines in the file: %d",
+ viewRange, finalLine, nLinesFile)
+ }
+
+ if finalLine == -1 {
+ fileContent = strings.Join(fileLines[initLine-1:], "\n")
+ } else {
+ fileContent = strings.Join(fileLines[initLine-1:finalLine], "\n")
+ }
+ }
+
+ return makeOutput(fileContent, path, initLine), nil
+}
+
+// handleCreate implements the create command
+func handleCreate(path string, fileText string) (string, error) {
+ // Ensure the directory exists
+ dir := filepath.Dir(path)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return "", fmt.Errorf("failed to create directory %s: %v", dir, err)
+ }
+
+ // Write the file
+ if err := writeFile(path, fileText); err != nil {
+ return "", err
+ }
+
+ // Save to history
+ fileHistory[path] = append(fileHistory[path], fileText)
+
+ return fmt.Sprintf("File created successfully at: %s", path), nil
+}
+
+// handleStrReplace implements the str_replace command
+func handleStrReplace(path, oldStr, newStr string) (string, error) {
+ // Read the file content
+ fileContent, err := readFile(path)
+ if err != nil {
+ return "", err
+ }
+
+ // Replace tabs with spaces
+ fileContent = maybeExpandTabs(path, fileContent)
+ oldStr = maybeExpandTabs(path, oldStr)
+ newStr = maybeExpandTabs(path, newStr)
+
+ // Check if oldStr is unique in the file
+ occurrences := strings.Count(fileContent, oldStr)
+ if occurrences == 0 {
+ return "", fmt.Errorf("no replacement was performed, old_str %q did not appear verbatim in %s", oldStr, path)
+ } else if occurrences > 1 {
+ // Find line numbers where oldStr appears
+ fileContentLines := strings.Split(fileContent, "\n")
+ var lines []int
+ for idx, line := range fileContentLines {
+ if strings.Contains(line, oldStr) {
+ lines = append(lines, idx+1)
+ }
+ }
+ return "", fmt.Errorf("no replacement was performed. Multiple occurrences of old_str %q in lines %v. Please ensure it is unique", oldStr, lines)
+ }
+
+ // Save the current content to history
+ fileHistory[path] = append(fileHistory[path], fileContent)
+
+ // Replace oldStr with newStr
+ newFileContent := strings.Replace(fileContent, oldStr, newStr, 1)
+
+ // Write the new content to the file
+ if err := writeFile(path, newFileContent); err != nil {
+ return "", err
+ }
+
+ // Create a snippet of the edited section
+ parts := strings.Split(fileContent, oldStr)
+ if len(parts) == 0 {
+ // This should never happen due to the earlier check, but let's be safe
+ parts = []string{""}
+ }
+ replacementLine := strings.Count(parts[0], "\n")
+ startLine := max(0, replacementLine-snippetLines)
+ endLine := replacementLine + snippetLines + strings.Count(newStr, "\n")
+ fileLines := strings.Split(newFileContent, "\n")
+ if len(fileLines) == 0 {
+ fileLines = []string{""}
+ }
+ endLine = min(endLine+1, len(fileLines))
+ snippet := strings.Join(fileLines[startLine:endLine], "\n")
+
+ // Prepare the success message
+ successMsg := fmt.Sprintf("The file %s has been edited. ", path)
+ successMsg += makeOutput(snippet, fmt.Sprintf("a snippet of %s", path), startLine+1)
+ successMsg += "Review the changes and make sure they are as expected. Edit the file again if necessary."
+
+ return successMsg, nil
+}
+
+// handleInsert implements the insert command
+func handleInsert(path string, insertLine int, newStr string) (string, error) {
+ // Read the file content
+ fileContent, err := readFile(path)
+ if err != nil {
+ return "", err
+ }
+
+ // Replace tabs with spaces
+ fileContent = maybeExpandTabs(path, fileContent)
+ newStr = maybeExpandTabs(path, newStr)
+
+ // Split the file content into lines
+ fileTextLines := strings.Split(fileContent, "\n")
+ nLinesFile := len(fileTextLines)
+
+ // Validate insert line
+ if insertLine < 0 || insertLine > nLinesFile {
+ return "", fmt.Errorf("invalid insert_line parameter: %d. It should be within the range of lines of the file: [0, %d]",
+ insertLine, nLinesFile)
+ }
+
+ // Save the current content to history
+ fileHistory[path] = append(fileHistory[path], fileContent)
+
+ // Split the new string into lines
+ newStrLines := strings.Split(newStr, "\n")
+
+ // Create new content by inserting the new lines
+ newFileTextLines := make([]string, 0, nLinesFile+len(newStrLines))
+ newFileTextLines = append(newFileTextLines, fileTextLines[:insertLine]...)
+ newFileTextLines = append(newFileTextLines, newStrLines...)
+ newFileTextLines = append(newFileTextLines, fileTextLines[insertLine:]...)
+
+ // Create a snippet of the edited section
+ snippetStart := max(0, insertLine-snippetLines)
+ snippetEnd := min(insertLine+snippetLines, nLinesFile)
+
+ snippetLines := make([]string, 0)
+ snippetLines = append(snippetLines, fileTextLines[snippetStart:insertLine]...)
+ snippetLines = append(snippetLines, newStrLines...)
+ snippetLines = append(snippetLines, fileTextLines[insertLine:snippetEnd]...)
+ snippet := strings.Join(snippetLines, "\n")
+
+ // Write the new content to the file
+ newFileText := strings.Join(newFileTextLines, "\n")
+ if err := writeFile(path, newFileText); err != nil {
+ return "", err
+ }
+
+ // Prepare the success message
+ successMsg := fmt.Sprintf("The file %s has been edited. ", path)
+ successMsg += makeOutput(snippet, "a snippet of the edited file", max(1, insertLine-4+1))
+ successMsg += "Review the changes and make sure they are as expected (correct indentation, no duplicate lines, etc). Edit the file again if necessary."
+
+ return successMsg, nil
+}
+
+// handleUndoEdit implements the undo_edit command
+func handleUndoEdit(path string) (string, error) {
+ history, exists := fileHistory[path]
+ if !exists || len(history) == 0 {
+ return "", fmt.Errorf("no edit history found for %s", path)
+ }
+
+ // Get the last edit and remove it from history
+ lastIdx := len(history) - 1
+ oldText := history[lastIdx]
+ fileHistory[path] = history[:lastIdx]
+
+ // Write the old content back to the file
+ if err := writeFile(path, oldText); err != nil {
+ return "", err
+ }
+
+ return fmt.Sprintf("Last edit to %s undone successfully. %s", path, makeOutput(oldText, path, 1)), nil
+}
+
+// listDirectory lists files and directories up to 2 levels deep
+func listDirectory(ctx context.Context, path string) (string, error) {
+ cmd := fmt.Sprintf("find %s -maxdepth 2 -not -path '*/\\.*'", path)
+ output, err := executeCommand(ctx, cmd)
+ if err != nil {
+ return "", fmt.Errorf("failed to list directory: %v", err)
+ }
+
+ return fmt.Sprintf("Here's the files and directories up to 2 levels deep in %s, excluding hidden items:\n%s\n", path, output), nil
+}
+
+// executeCommand executes a shell command and returns its output
+func executeCommand(ctx context.Context, cmd string) (string, error) {
+ // This is a simplified version without timeouts for now
+ bash := exec.CommandContext(ctx, "bash", "-c", cmd)
+ bash.Dir = WorkingDir(ctx)
+ output, err := bash.CombinedOutput()
+ if err != nil {
+ return "", fmt.Errorf("command execution failed: %v: %s", err, string(output))
+ }
+ return maybetruncate(string(output)), nil
+}
+
+// readFile reads the content of a file
+func readFile(path string) (string, error) {
+ content, err := os.ReadFile(path)
+ if err != nil {
+ return "", fmt.Errorf("failed to read file %s: %v", path, err)
+ }
+ return string(content), nil
+}
+
+// writeFile writes content to a file
+func writeFile(path, content string) error {
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ return fmt.Errorf("failed to write to file %s: %v", path, err)
+ }
+ return nil
+}
+
+// makeOutput generates a formatted output for the CLI
+func makeOutput(fileContent, fileDescriptor string, initLine int) string {
+ fileContent = maybetruncate(fileContent)
+ fileContent = maybeExpandTabs(fileDescriptor, fileContent)
+
+ var lines []string
+ for i, line := range strings.Split(fileContent, "\n") {
+ lines = append(lines, fmt.Sprintf("%6d\t%s", i+initLine, line))
+ }
+
+ return fmt.Sprintf("Here's the result of running `cat -n` on %s:\n%s\n", fileDescriptor, strings.Join(lines, "\n"))
+}
+
+// maybetruncate truncates content and appends a notice if content exceeds the specified length
+func maybetruncate(content string) string {
+ if len(content) <= maxResponseLen {
+ return content
+ }
+ return content[:maxResponseLen] + truncatedMessage
+}
+
+// maybeExpandTabs is currently a no-op. The python
+// implementation replaces tabs with spaces, but this strikes
+// me as unwise for our tool.
+func maybeExpandTabs(path, s string) string {
+ // return strings.ReplaceAll(s, "\t", " ")
+ return s
+}