package sidekick import ( "fmt" "os" "os/exec" "regexp" "strings" ) // DefaultInternalTools returns a map of standard file I/O tools func DefaultInternalTools() map[string]ToolDefinition { return map[string]ToolDefinition{ "read_file": { Name: "read_file", Description: "Read a file, optionally with line windowing. Every line is prefixed with 'line:hash:' (e.g. '1:abcd:content').", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "path": map[string]interface{}{"type": "string"}, "offset": map[string]interface{}{"type": "integer", "description": "Starting line number (0-indexed)"}, "limit": map[string]interface{}{"type": "integer", "description": "Number of lines to read"}, }, "required": []string{"path"}, }, Internal: true, Handler: readFileHandler, }, "write_file": { Name: "write_file", Description: "Overwrite a file with new content. Automatically strips 'line:hash:' prefixes if present.", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "path": map[string]interface{}{"type": "string"}, "content": map[string]interface{}{"type": "string"}, }, "required": []string{"path", "content"}, }, Internal: true, Handler: writeFileHandler, }, "grep_file": { Name: "grep_file", Description: "Regex search with surrounding context. Every line is prefixed with 'line:hash:' (e.g. '1:abcd:content').", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "path": map[string]interface{}{"type": "string"}, "pattern": map[string]interface{}{"type": "string"}, "context_lines": map[string]interface{}{"type": "integer"}, }, "required": []string{"path", "pattern"}, }, Internal: true, Handler: grepFileHandler, }, "edit_file": { Name: "edit_file", Description: "Replace a string or a block of lines in a file. If 'old_string' contains 'line:hash:' prefixes for every line, it performs a precise replacement at the specified line numbers. Otherwise, it performs a standard first-occurrence replacement. Both multiline strings and 'line:hash:' stripping are supported.", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "path": map[string]interface{}{"type": "string"}, "old_string": map[string]interface{}{"type": "string"}, "new_string": map[string]interface{}{"type": "string"}, }, "required": []string{"path", "old_string", "new_string"}, }, Internal: true, Handler: editFileHandler, }, "shell_exec": { Name: "shell_exec", Description: "Run a POSIX shell command and return its combined stdout and stderr.", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "command": map[string]interface{}{"type": "string"}, }, "required": []string{"command"}, }, Internal: true, Handler: shellExecHandler, }, } } func shellExecHandler(args map[string]interface{}) (string, error) { cmdStr, ok := args["command"].(string) if !ok { return "", fmt.Errorf("command is required") } cmd := exec.Command("sh", "-c", cmdStr) output, err := cmd.CombinedOutput() result := string(output) if err != nil { result = fmt.Sprintf("%s\nError: %v", result, err) } if result == "" { return "(empty output)", nil } return result, nil } func readFileHandler(args map[string]interface{}) (string, error) { path, ok := args["path"].(string) if !ok { return "", fmt.Errorf("path is required") } content, err := os.ReadFile(path) if err != nil { return "", err } lines := strings.Split(string(content), "\n") offset := 0 if off, ok := args["offset"].(float64); ok { offset = int(off) } if offset < 0 { offset = 0 } limit := len(lines) if lim, ok := args["limit"].(float64); ok { limit = int(lim) } end := offset + limit if end > len(lines) { end = len(lines) } if offset >= len(lines) { return "", nil // Empty reading window } var outputLines []string for i, line := range lines[offset:end] { lineNum := offset + i + 1 outputLines = append(outputLines, fmt.Sprintf("%d:%s:%s", lineNum, hashLine(line), line)) } return strings.Join(outputLines, "\n"), nil } func writeFileHandler(args map[string]interface{}) (string, error) { path, ok := args["path"].(string) if !ok { return "", fmt.Errorf("path is required") } content, ok := args["content"].(string) if !ok { return "", fmt.Errorf("content is required") } content = stripHashlines(content) err := os.WriteFile(path, []byte(content), 0644) if err != nil { return "", err } return fmt.Sprintf("Successfully wrote to %s", path), nil } func grepFileHandler(args map[string]interface{}) (string, error) { path, ok := args["path"].(string) if !ok { return "", fmt.Errorf("path is required") } pattern, ok := args["pattern"].(string) if !ok { return "", fmt.Errorf("pattern is required") } contextLines := 0 if cl, ok := args["context_lines"].(float64); ok { contextLines = int(cl) } content, err := os.ReadFile(path) if err != nil { return "", err } lines := strings.Split(string(content), "\n") re, err := regexp.Compile(pattern) if err != nil { return "", fmt.Errorf("invalid regex: %w", err) } var results []string for i, line := range lines { if re.MatchString(line) { start := i - contextLines if start < 0 { start = 0 } end := i + contextLines + 1 if end > len(lines) { end = len(lines) } results = append(results, fmt.Sprintf("--- Match around line %d ---", i+1)) for j := start; j < end; j++ { results = append(results, fmt.Sprintf("%d:%s:%s", j+1, hashLine(lines[j]), lines[j])) } } } if len(results) == 0 { return "No matches found.", nil } return strings.Join(results, "\n"), nil } func editFileHandler(args map[string]interface{}) (string, error) { path, ok := args["path"].(string) if !ok { return "", fmt.Errorf("path is required") } oldStr, ok := args["old_string"].(string) if !ok { return "", fmt.Errorf("old_string is required") } newStr, ok := args["new_string"].(string) if !ok { return "", fmt.Errorf("new_string is required") } newStr = stripHashlines(newStr) content, err := os.ReadFile(path) if err != nil { return "", err } strContent := string(content) hasHashlines, startLine, strippedOldStr := parseHashlines(oldStr) if hasHashlines && startLine > 0 { fileLines := strings.Split(strContent, "\n") oldLines := strings.Split(strippedOldStr, "\n") startIdx := startLine - 1 endIdx := startIdx + len(oldLines) if startIdx >= 0 && endIdx <= len(fileLines) { match := true for i := 0; i < len(oldLines); i++ { if fileLines[startIdx+i] != oldLines[i] { match = false break } } if match { var finalLines []string finalLines = append(finalLines, fileLines[:startIdx]...) if newStr != "" { finalLines = append(finalLines, strings.Split(newStr, "\n")...) } finalLines = append(finalLines, fileLines[endIdx:]...) newContent := strings.Join(finalLines, "\n") err = os.WriteFile(path, []byte(newContent), 0644) if err != nil { return "", err } return fmt.Sprintf("Successfully edited %s", path), nil } } } // Fallback to simple replacement strippedOldStr = stripHashlines(oldStr) if !strings.Contains(strContent, strippedOldStr) { return "", fmt.Errorf("old_string not found in file") } // Replace first occurrence only newContent := strings.Replace(strContent, strippedOldStr, newStr, 1) err = os.WriteFile(path, []byte(newContent), 0644) if err != nil { return "", err } return fmt.Sprintf("Successfully edited %s", path), nil } func parseHashlines(s string) (bool, int, string) { if s == "" { return false, 0, s } var result []string lines := strings.Split(s, "\n") startLine := -1 for i, line := range lines { parts := strings.SplitN(line, ":", 3) if len(parts) == 3 { isDigit := len(parts[0]) > 0 for _, c := range parts[0] { if c < '0' || c > '9' { isDigit = false break } } isHex := len(parts[1]) == 4 for _, c := range parts[1] { if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { isHex = false break } } if isDigit && isHex { if i == 0 { fmt.Sscanf(parts[0], "%d", &startLine) } result = append(result, parts[2]) continue } } return false, 0, s } return true, startLine, strings.Join(result, "\n") } func stripHashlines(s string) string { var result []string lines := strings.Split(s, "\n") for _, line := range lines { parts := strings.SplitN(line, ":", 3) if len(parts) == 3 { isDigit := len(parts[0]) > 0 for _, c := range parts[0] { if c < '0' || c > '9' { isDigit = false break } } isHex := len(parts[1]) == 4 for _, c := range parts[1] { if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { isHex = false break } } if isDigit && isHex { result = append(result, parts[2]) continue } } result = append(result, line) } return strings.Join(result, "\n") } func hashLine(line string) string { var h uint32 = 2166136261 for i := 0; i < len(line); i++ { h ^= uint32(line[i]) h *= 16777619 } return fmt.Sprintf("%04x", h&0xffff) }