package main import ( "bufio" "context" "encoding/json" "flag" "fmt" "log" "os" "os/exec" "strings" "github.com/anthropics/anthropic-sdk-go" "github.com/invopop/jsonschema" ) func main() { verbose := flag.Bool("verbose", false, "enable verbose logging") flag.Parse() if *verbose { log.SetOutput(os.Stderr) log.SetFlags(log.LstdFlags | log.Lshortfile) log.Println("Verbose logging enabled") } else { log.SetOutput(os.Stdout) log.SetFlags(0) log.SetPrefix("") } client := anthropic.NewClient() if *verbose { log.Println("Anthropic client initialized") } scanner := bufio.NewScanner(os.Stdin) getUserMessage := func() (string, bool) { if !scanner.Scan() { return "", false } return scanner.Text(), true } tools := []ToolDefinition{ReadFileDefinition, ListFilesDefinition, BashDefinition, CodeSearchDefinition} if *verbose { log.Printf("Initialized %d tools", len(tools)) } agent := NewAgent(&client, getUserMessage, tools, *verbose) err := agent.Run(context.TODO()) if err != nil { fmt.Printf("Error: %s\n", err.Error()) } } func NewAgent( client *anthropic.Client, getUserMessage func() (string, bool), tools []ToolDefinition, verbose bool, ) *Agent { return &Agent{ client: client, getUserMessage: getUserMessage, tools: tools, verbose: verbose, } } type Agent struct { client *anthropic.Client getUserMessage func() (string, bool) tools []ToolDefinition verbose bool } func (a *Agent) Run(ctx context.Context) error { conversation := []anthropic.MessageParam{} if a.verbose { log.Println("Starting chat session with tools enabled") } fmt.Println("Chat with Claude (use 'ctrl-c' to quit)") for { fmt.Print("\u001b[94mYou\u001b[0m: ") userInput, ok := a.getUserMessage() if !ok { if a.verbose { log.Println("User input ended, breaking from chat loop") } break } // Skip empty messages if userInput == "" { if a.verbose { log.Println("Skipping empty message") } continue } if a.verbose { log.Printf("User input received: %q", userInput) } userMessage := anthropic.NewUserMessage(anthropic.NewTextBlock(userInput)) conversation = append(conversation, userMessage) if a.verbose { log.Printf("Sending message to Claude, conversation length: %d", len(conversation)) } message, err := a.runInference(ctx, conversation) if err != nil { if a.verbose { log.Printf("Error during inference: %v", err) } return err } conversation = append(conversation, message.ToParam()) // Keep processing until Claude stops using tools for { // Collect all tool uses and their results var toolResults []anthropic.ContentBlockParamUnion var hasToolUse bool if a.verbose { log.Printf("Processing %d content blocks from Claude", len(message.Content)) } for _, content := range message.Content { switch content.Type { case "text": fmt.Printf("\u001b[93mClaude\u001b[0m: %s\n", content.Text) case "tool_use": hasToolUse = true toolUse := content.AsToolUse() if a.verbose { log.Printf("Tool use detected: %s with input: %s", toolUse.Name, string(toolUse.Input)) } fmt.Printf("\u001b[96mtool\u001b[0m: %s(%s)\n", toolUse.Name, string(toolUse.Input)) // Find and execute the tool var toolResult string var toolError error var toolFound bool for _, tool := range a.tools { if tool.Name == toolUse.Name { if a.verbose { log.Printf("Executing tool: %s", tool.Name) } toolResult, toolError = tool.Function(toolUse.Input) fmt.Printf("\u001b[92mresult\u001b[0m: %s\n", toolResult) if toolError != nil { fmt.Printf("\u001b[91merror\u001b[0m: %s\n", toolError.Error()) } if a.verbose { if toolError != nil { log.Printf("Tool execution failed: %v", toolError) } else { log.Printf("Tool execution successful, result length: %d chars", len(toolResult)) } } toolFound = true break } } if !toolFound { toolError = fmt.Errorf("tool '%s' not found", toolUse.Name) fmt.Printf("\u001b[91merror\u001b[0m: %s\n", toolError.Error()) } // Add tool result to collection if toolError != nil { toolResults = append(toolResults, anthropic.NewToolResultBlock(toolUse.ID, toolError.Error(), true)) } else { toolResults = append(toolResults, anthropic.NewToolResultBlock(toolUse.ID, toolResult, false)) } } } // If there were no tool uses, we're done if !hasToolUse { break } // Send all tool results back and get Claude's response if a.verbose { log.Printf("Sending %d tool results back to Claude", len(toolResults)) } toolResultMessage := anthropic.NewUserMessage(toolResults...) conversation = append(conversation, toolResultMessage) // Get Claude's response after tool execution message, err = a.runInference(ctx, conversation) if err != nil { if a.verbose { log.Printf("Error during followup inference: %v", err) } return err } conversation = append(conversation, message.ToParam()) if a.verbose { log.Printf("Received followup response with %d content blocks", len(message.Content)) } // Continue loop to process the new message } } if a.verbose { log.Println("Chat session ended") } return nil } func (a *Agent) runInference(ctx context.Context, conversation []anthropic.MessageParam) (*anthropic.Message, error) { anthropicTools := []anthropic.ToolUnionParam{} for _, tool := range a.tools { anthropicTools = append(anthropicTools, anthropic.ToolUnionParam{ OfTool: &anthropic.ToolParam{ Name: tool.Name, Description: anthropic.String(tool.Description), InputSchema: tool.InputSchema, }, }) } if a.verbose { log.Printf("Making API call to Claude with model: %s and %d tools", anthropic.ModelClaude3_7SonnetLatest, len(anthropicTools)) } message, err := a.client.Messages.New(ctx, anthropic.MessageNewParams{ Model: anthropic.ModelClaude3_7SonnetLatest, MaxTokens: int64(1024), Messages: conversation, Tools: anthropicTools, }) if a.verbose { if err != nil { log.Printf("API call failed: %v", err) } else { log.Printf("API call successful, response received") } } return message, err } type ToolDefinition struct { Name string `json:"name"` Description string `json:"description"` InputSchema anthropic.ToolInputSchemaParam `json:"input_schema"` Function func(input json.RawMessage) (string, error) } var ReadFileDefinition = ToolDefinition{ Name: "read_file", Description: "Read the contents of a given relative file path. Use this when you want to see what's inside a file. Do not use this with directory names.", InputSchema: ReadFileInputSchema, Function: ReadFile, } var ListFilesDefinition = ToolDefinition{ Name: "list_files", Description: "List files and directories at a given path. If no path is provided, lists files in the current directory.", InputSchema: ListFilesInputSchema, Function: ListFiles, } var BashDefinition = ToolDefinition{ Name: "bash", Description: "Execute a bash command and return its output. Use this to run shell commands.", InputSchema: BashInputSchema, Function: Bash, } var CodeSearchDefinition = ToolDefinition{ Name: "code_search", Description: `Search for code patterns using ripgrep (rg). Use this to find code patterns, function definitions, variable usage, or any text in the codebase. You can search by pattern, file type, or directory.`, InputSchema: CodeSearchInputSchema, Function: CodeSearch, } type ReadFileInput struct { Path string `json:"path" jsonschema_description:"The relative path of a file in the working directory."` } var ReadFileInputSchema = GenerateSchema[ReadFileInput]() type ListFilesInput struct { Path string `json:"path,omitempty" jsonschema_description:"Optional relative path to list files from. Defaults to current directory if not provided."` } var ListFilesInputSchema = GenerateSchema[ListFilesInput]() type BashInput struct { Command string `json:"command" jsonschema_description:"The bash command to execute."` } var BashInputSchema = GenerateSchema[BashInput]() type CodeSearchInput struct { Pattern string `json:"pattern" jsonschema_description:"The search pattern or regex to look for"` Path string `json:"path,omitempty" jsonschema_description:"Optional path to search in (file or directory)"` FileType string `json:"file_type,omitempty" jsonschema_description:"Optional file extension to limit search to (e.g., 'go', 'js', 'py')"` CaseSensitive bool `json:"case_sensitive,omitempty" jsonschema_description:"Whether the search should be case sensitive (default: false)"` } var CodeSearchInputSchema = GenerateSchema[CodeSearchInput]() func ReadFile(input json.RawMessage) (string, error) { readFileInput := ReadFileInput{} err := json.Unmarshal(input, &readFileInput) if err != nil { panic(err) } log.Printf("Reading file: %s", readFileInput.Path) content, err := os.ReadFile(readFileInput.Path) if err != nil { log.Printf("Failed to read file %s: %v", readFileInput.Path, err) return "", err } log.Printf("Successfully read file %s (%d bytes)", readFileInput.Path, len(content)) return string(content), nil } func ListFiles(input json.RawMessage) (string, error) { listFilesInput := ListFilesInput{} err := json.Unmarshal(input, &listFilesInput) if err != nil { panic(err) } dir := "." if listFilesInput.Path != "" { dir = listFilesInput.Path } log.Printf("Listing files in directory: %s", dir) cmd := exec.Command("find", dir, "-type", "f", "-not", "-path", "*/.devenv/*", "-not", "-path", "*/.git/*") output, err := cmd.Output() if err != nil { log.Printf("Failed to list files in %s: %v", dir, err) return "", err } files := strings.Split(strings.TrimSpace(string(output)), "\n") if len(files) == 1 && files[0] == "" { files = []string{} } result, err := json.Marshal(files) if err != nil { return "", err } log.Printf("Successfully listed %d files in %s", len(files), dir) return string(result), nil } func Bash(input json.RawMessage) (string, error) { bashInput := BashInput{} err := json.Unmarshal(input, &bashInput) if err != nil { return "", err } log.Printf("Executing bash command: %s", bashInput.Command) cmd := exec.Command("bash", "-c", bashInput.Command) output, err := cmd.CombinedOutput() if err != nil { log.Printf("Bash command failed: %v", err) return fmt.Sprintf("Command failed with error: %s\nOutput: %s", err.Error(), string(output)), nil } log.Printf("Bash command executed successfully, output length: %d chars", len(output)) return strings.TrimSpace(string(output)), nil } func CodeSearch(input json.RawMessage) (string, error) { codeSearchInput := CodeSearchInput{} err := json.Unmarshal(input, &codeSearchInput) if err != nil { return "", err } if codeSearchInput.Pattern == "" { log.Printf("CodeSearch failed: pattern is required") return "", fmt.Errorf("pattern is required") } log.Printf("Searching for pattern: %s", codeSearchInput.Pattern) // Build ripgrep command args := []string{"rg", "--line-number", "--with-filename", "--color=never"} // Add case sensitivity flag if !codeSearchInput.CaseSensitive { args = append(args, "--ignore-case") } // Add file type filter if specified if codeSearchInput.FileType != "" { args = append(args, "--type", codeSearchInput.FileType) } // Add pattern args = append(args, codeSearchInput.Pattern) // Add path if specified if codeSearchInput.Path != "" { args = append(args, codeSearchInput.Path) } else { args = append(args, ".") } if a := false; a { // This is a hack to access verbose mode log.Printf("Executing ripgrep with args: %v", args) } cmd := exec.Command(args[0], args[1:]...) output, err := cmd.Output() // ripgrep returns exit code 1 when no matches are found, which is not an error if err != nil { if exitError, ok := err.(*exec.ExitError); ok && exitError.ExitCode() == 1 { log.Printf("No matches found for pattern: %s", codeSearchInput.Pattern) return "No matches found", nil } log.Printf("Ripgrep command failed: %v", err) return "", fmt.Errorf("search failed: %w", err) } result := strings.TrimSpace(string(output)) lines := strings.Split(result, "\n") log.Printf("Found %d matches for pattern: %s", len(lines), codeSearchInput.Pattern) // Limit output to prevent overwhelming responses if len(lines) > 50 { result = strings.Join(lines[:50], "\n") + fmt.Sprintf("\n... (showing first 50 of %d matches)", len(lines)) } return result, nil } func GenerateSchema[T any]() anthropic.ToolInputSchemaParam { reflector := jsonschema.Reflector{ AllowAdditionalProperties: false, DoNotReference: true, } var v T schema := reflector.Reflect(v) return anthropic.ToolInputSchemaParam{ Properties: schema.Properties, } }