blob: df83139215bb4e35d3d2a4c3df192567c4a56127 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package claudetool
2
3/*
4
5Note: 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
6
7## Implementation Notes
8This 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.
9
10*/
11
12import (
13 "context"
14 "encoding/json"
15 "fmt"
16 "os"
17 "os/exec"
18 "path/filepath"
19 "strings"
20
21 "sketch.dev/ant"
22)
23
24// Constants for the AnthropicEditTool
25const (
26 editName = "str_replace_editor"
27)
28
29// Constants used by the tool
30const (
31 snippetLines = 4
32 maxResponseLen = 16000
33 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>"
34)
35
36// Command represents the type of operation to perform
37type editCommand string
38
39const (
40 viewCommand editCommand = "view"
41 createCommand editCommand = "create"
42 strReplaceCommand editCommand = "str_replace"
43 insertCommand editCommand = "insert"
44 undoEditCommand editCommand = "undo_edit"
45)
46
47// editInput represents the expected input format for the edit tool
48type editInput struct {
49 Command string `json:"command"`
50 Path string `json:"path"`
51 FileText *string `json:"file_text,omitempty"`
52 ViewRange []int `json:"view_range,omitempty"`
53 OldStr *string `json:"old_str,omitempty"`
54 NewStr *string `json:"new_str,omitempty"`
55 InsertLine *int `json:"insert_line,omitempty"`
56}
57
58// fileHistory maintains a history of edits for each file to support undo functionality
59var fileHistory = make(map[string][]string)
60
61// AnthropicEditTool is a tool for viewing, creating, and editing files
62var AnthropicEditTool = &ant.Tool{
63 // Note that Type is model-dependent, and would be different for Claude 3.5, for example.
64 Type: "text_editor_20250124",
65 Name: editName,
66 Run: EditRun,
67}
68
69// EditRun is the implementation of the edit tool
70func EditRun(ctx context.Context, input json.RawMessage) (string, error) {
71 var editRequest editInput
72 if err := json.Unmarshal(input, &editRequest); err != nil {
73 return "", fmt.Errorf("failed to parse edit input: %v", err)
74 }
75
76 // Validate the command
77 cmd := editCommand(editRequest.Command)
78 if !isValidCommand(cmd) {
79 return "", fmt.Errorf("unrecognized command %s. The allowed commands are: view, create, str_replace, insert, undo_edit", cmd)
80 }
81
82 path := editRequest.Path
83
84 // Validate the path
85 if err := validatePath(cmd, path); err != nil {
86 return "", err
87 }
88
89 // Execute the appropriate command
90 switch cmd {
91 case viewCommand:
92 return handleView(ctx, path, editRequest.ViewRange)
93 case createCommand:
94 if editRequest.FileText == nil {
95 return "", fmt.Errorf("parameter file_text is required for command: create")
96 }
97 return handleCreate(path, *editRequest.FileText)
98 case strReplaceCommand:
99 if editRequest.OldStr == nil {
100 return "", fmt.Errorf("parameter old_str is required for command: str_replace")
101 }
102 newStr := ""
103 if editRequest.NewStr != nil {
104 newStr = *editRequest.NewStr
105 }
106 return handleStrReplace(path, *editRequest.OldStr, newStr)
107 case insertCommand:
108 if editRequest.InsertLine == nil {
109 return "", fmt.Errorf("parameter insert_line is required for command: insert")
110 }
111 if editRequest.NewStr == nil {
112 return "", fmt.Errorf("parameter new_str is required for command: insert")
113 }
114 return handleInsert(path, *editRequest.InsertLine, *editRequest.NewStr)
115 case undoEditCommand:
116 return handleUndoEdit(path)
117 default:
118 return "", fmt.Errorf("command %s is not implemented", cmd)
119 }
120}
121
122// Utility function to check if a command is valid
123func isValidCommand(cmd editCommand) bool {
124 switch cmd {
125 case viewCommand, createCommand, strReplaceCommand, insertCommand, undoEditCommand:
126 return true
127 default:
128 return false
129 }
130}
131
132// validatePath checks if the path/command combination is valid
133func validatePath(cmd editCommand, path string) error {
134 // Check if it's an absolute path
135 if !filepath.IsAbs(path) {
136 suggestedPath := "/" + path
137 return fmt.Errorf("the path %s is not an absolute path, it should start with '/'. Maybe you meant %s?", path, suggestedPath)
138 }
139
140 // Get file info
141 info, err := os.Stat(path)
142
143 // Check if path exists (except for create command)
144 if err != nil {
145 if os.IsNotExist(err) && cmd != createCommand {
146 return fmt.Errorf("the path %s does not exist. Please provide a valid path", path)
147 } else if !os.IsNotExist(err) {
148 return fmt.Errorf("error accessing path %s: %v", path, err)
149 }
150 } else {
151 // Path exists, check if it's a directory
152 if info.IsDir() && cmd != viewCommand {
153 return fmt.Errorf("the path %s is a directory and only the 'view' command can be used on directories", path)
154 }
155
156 // For create command, check if file already exists
157 if cmd == createCommand {
158 return fmt.Errorf("file already exists at: %s. Cannot overwrite files using command 'create'", path)
159 }
160 }
161
162 return nil
163}
164
165// handleView implements the view command
166func handleView(ctx context.Context, path string, viewRange []int) (string, error) {
167 info, err := os.Stat(path)
168 if err != nil {
169 return "", fmt.Errorf("error accessing path %s: %v", path, err)
170 }
171
172 // Handle directory view
173 if info.IsDir() {
174 if viewRange != nil {
175 return "", fmt.Errorf("the view_range parameter is not allowed when path points to a directory")
176 }
177
178 // List files in the directory (up to 2 levels deep)
179 return listDirectory(ctx, path)
180 }
181
182 // Handle file view
183 fileContent, err := readFile(path)
184 if err != nil {
185 return "", err
186 }
187
188 initLine := 1
189 if viewRange != nil {
190 if len(viewRange) != 2 {
191 return "", fmt.Errorf("invalid view_range. It should be a list of two integers")
192 }
193
194 fileLines := strings.Split(fileContent, "\n")
195 nLinesFile := len(fileLines)
196 initLine, finalLine := viewRange[0], viewRange[1]
197
198 if initLine < 1 || initLine > nLinesFile {
199 return "", fmt.Errorf("invalid view_range: %v. Its first element %d should be within the range of lines of the file: [1, %d]",
200 viewRange, initLine, nLinesFile)
201 }
202
203 if finalLine != -1 && finalLine < initLine {
204 return "", fmt.Errorf("invalid view_range: %v. Its second element %d should be larger or equal than its first %d",
205 viewRange, finalLine, initLine)
206 }
207
208 if finalLine > nLinesFile {
209 return "", fmt.Errorf("invalid view_range: %v. Its second element %d should be smaller than the number of lines in the file: %d",
210 viewRange, finalLine, nLinesFile)
211 }
212
213 if finalLine == -1 {
214 fileContent = strings.Join(fileLines[initLine-1:], "\n")
215 } else {
216 fileContent = strings.Join(fileLines[initLine-1:finalLine], "\n")
217 }
218 }
219
220 return makeOutput(fileContent, path, initLine), nil
221}
222
223// handleCreate implements the create command
224func handleCreate(path string, fileText string) (string, error) {
225 // Ensure the directory exists
226 dir := filepath.Dir(path)
227 if err := os.MkdirAll(dir, 0o755); err != nil {
228 return "", fmt.Errorf("failed to create directory %s: %v", dir, err)
229 }
230
231 // Write the file
232 if err := writeFile(path, fileText); err != nil {
233 return "", err
234 }
235
236 // Save to history
237 fileHistory[path] = append(fileHistory[path], fileText)
238
239 return fmt.Sprintf("File created successfully at: %s", path), nil
240}
241
242// handleStrReplace implements the str_replace command
243func handleStrReplace(path, oldStr, newStr string) (string, error) {
244 // Read the file content
245 fileContent, err := readFile(path)
246 if err != nil {
247 return "", err
248 }
249
250 // Replace tabs with spaces
251 fileContent = maybeExpandTabs(path, fileContent)
252 oldStr = maybeExpandTabs(path, oldStr)
253 newStr = maybeExpandTabs(path, newStr)
254
255 // Check if oldStr is unique in the file
256 occurrences := strings.Count(fileContent, oldStr)
257 if occurrences == 0 {
258 return "", fmt.Errorf("no replacement was performed, old_str %q did not appear verbatim in %s", oldStr, path)
259 } else if occurrences > 1 {
260 // Find line numbers where oldStr appears
261 fileContentLines := strings.Split(fileContent, "\n")
262 var lines []int
263 for idx, line := range fileContentLines {
264 if strings.Contains(line, oldStr) {
265 lines = append(lines, idx+1)
266 }
267 }
268 return "", fmt.Errorf("no replacement was performed. Multiple occurrences of old_str %q in lines %v. Please ensure it is unique", oldStr, lines)
269 }
270
271 // Save the current content to history
272 fileHistory[path] = append(fileHistory[path], fileContent)
273
274 // Replace oldStr with newStr
275 newFileContent := strings.Replace(fileContent, oldStr, newStr, 1)
276
277 // Write the new content to the file
278 if err := writeFile(path, newFileContent); err != nil {
279 return "", err
280 }
281
282 // Create a snippet of the edited section
283 parts := strings.Split(fileContent, oldStr)
284 if len(parts) == 0 {
285 // This should never happen due to the earlier check, but let's be safe
286 parts = []string{""}
287 }
288 replacementLine := strings.Count(parts[0], "\n")
289 startLine := max(0, replacementLine-snippetLines)
290 endLine := replacementLine + snippetLines + strings.Count(newStr, "\n")
291 fileLines := strings.Split(newFileContent, "\n")
292 if len(fileLines) == 0 {
293 fileLines = []string{""}
294 }
295 endLine = min(endLine+1, len(fileLines))
296 snippet := strings.Join(fileLines[startLine:endLine], "\n")
297
298 // Prepare the success message
299 successMsg := fmt.Sprintf("The file %s has been edited. ", path)
300 successMsg += makeOutput(snippet, fmt.Sprintf("a snippet of %s", path), startLine+1)
301 successMsg += "Review the changes and make sure they are as expected. Edit the file again if necessary."
302
303 return successMsg, nil
304}
305
306// handleInsert implements the insert command
307func handleInsert(path string, insertLine int, newStr string) (string, error) {
308 // Read the file content
309 fileContent, err := readFile(path)
310 if err != nil {
311 return "", err
312 }
313
314 // Replace tabs with spaces
315 fileContent = maybeExpandTabs(path, fileContent)
316 newStr = maybeExpandTabs(path, newStr)
317
318 // Split the file content into lines
319 fileTextLines := strings.Split(fileContent, "\n")
320 nLinesFile := len(fileTextLines)
321
322 // Validate insert line
323 if insertLine < 0 || insertLine > nLinesFile {
324 return "", fmt.Errorf("invalid insert_line parameter: %d. It should be within the range of lines of the file: [0, %d]",
325 insertLine, nLinesFile)
326 }
327
328 // Save the current content to history
329 fileHistory[path] = append(fileHistory[path], fileContent)
330
331 // Split the new string into lines
332 newStrLines := strings.Split(newStr, "\n")
333
334 // Create new content by inserting the new lines
335 newFileTextLines := make([]string, 0, nLinesFile+len(newStrLines))
336 newFileTextLines = append(newFileTextLines, fileTextLines[:insertLine]...)
337 newFileTextLines = append(newFileTextLines, newStrLines...)
338 newFileTextLines = append(newFileTextLines, fileTextLines[insertLine:]...)
339
340 // Create a snippet of the edited section
341 snippetStart := max(0, insertLine-snippetLines)
342 snippetEnd := min(insertLine+snippetLines, nLinesFile)
343
344 snippetLines := make([]string, 0)
345 snippetLines = append(snippetLines, fileTextLines[snippetStart:insertLine]...)
346 snippetLines = append(snippetLines, newStrLines...)
347 snippetLines = append(snippetLines, fileTextLines[insertLine:snippetEnd]...)
348 snippet := strings.Join(snippetLines, "\n")
349
350 // Write the new content to the file
351 newFileText := strings.Join(newFileTextLines, "\n")
352 if err := writeFile(path, newFileText); err != nil {
353 return "", err
354 }
355
356 // Prepare the success message
357 successMsg := fmt.Sprintf("The file %s has been edited. ", path)
358 successMsg += makeOutput(snippet, "a snippet of the edited file", max(1, insertLine-4+1))
359 successMsg += "Review the changes and make sure they are as expected (correct indentation, no duplicate lines, etc). Edit the file again if necessary."
360
361 return successMsg, nil
362}
363
364// handleUndoEdit implements the undo_edit command
365func handleUndoEdit(path string) (string, error) {
366 history, exists := fileHistory[path]
367 if !exists || len(history) == 0 {
368 return "", fmt.Errorf("no edit history found for %s", path)
369 }
370
371 // Get the last edit and remove it from history
372 lastIdx := len(history) - 1
373 oldText := history[lastIdx]
374 fileHistory[path] = history[:lastIdx]
375
376 // Write the old content back to the file
377 if err := writeFile(path, oldText); err != nil {
378 return "", err
379 }
380
381 return fmt.Sprintf("Last edit to %s undone successfully. %s", path, makeOutput(oldText, path, 1)), nil
382}
383
384// listDirectory lists files and directories up to 2 levels deep
385func listDirectory(ctx context.Context, path string) (string, error) {
386 cmd := fmt.Sprintf("find %s -maxdepth 2 -not -path '*/\\.*'", path)
387 output, err := executeCommand(ctx, cmd)
388 if err != nil {
389 return "", fmt.Errorf("failed to list directory: %v", err)
390 }
391
392 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
393}
394
395// executeCommand executes a shell command and returns its output
396func executeCommand(ctx context.Context, cmd string) (string, error) {
397 // This is a simplified version without timeouts for now
398 bash := exec.CommandContext(ctx, "bash", "-c", cmd)
399 bash.Dir = WorkingDir(ctx)
400 output, err := bash.CombinedOutput()
401 if err != nil {
402 return "", fmt.Errorf("command execution failed: %v: %s", err, string(output))
403 }
404 return maybetruncate(string(output)), nil
405}
406
407// readFile reads the content of a file
408func readFile(path string) (string, error) {
409 content, err := os.ReadFile(path)
410 if err != nil {
411 return "", fmt.Errorf("failed to read file %s: %v", path, err)
412 }
413 return string(content), nil
414}
415
416// writeFile writes content to a file
417func writeFile(path, content string) error {
418 if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
419 return fmt.Errorf("failed to write to file %s: %v", path, err)
420 }
421 return nil
422}
423
424// makeOutput generates a formatted output for the CLI
425func makeOutput(fileContent, fileDescriptor string, initLine int) string {
426 fileContent = maybetruncate(fileContent)
427 fileContent = maybeExpandTabs(fileDescriptor, fileContent)
428
429 var lines []string
430 for i, line := range strings.Split(fileContent, "\n") {
431 lines = append(lines, fmt.Sprintf("%6d\t%s", i+initLine, line))
432 }
433
434 return fmt.Sprintf("Here's the result of running `cat -n` on %s:\n%s\n", fileDescriptor, strings.Join(lines, "\n"))
435}
436
437// maybetruncate truncates content and appends a notice if content exceeds the specified length
438func maybetruncate(content string) string {
439 if len(content) <= maxResponseLen {
440 return content
441 }
442 return content[:maxResponseLen] + truncatedMessage
443}
444
445// maybeExpandTabs is currently a no-op. The python
446// implementation replaces tabs with spaces, but this strikes
447// me as unwise for our tool.
448func maybeExpandTabs(path, s string) string {
449 // return strings.ReplaceAll(s, "\t", " ")
450 return s
451}