diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cc0d898 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +ANTHROPIC_API_KEY="" +SYSTEM_PROMPT="" diff --git a/README.md b/README.md new file mode 100644 index 0000000..60f9d06 --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +# prompt + +A simple, fast CLI tool for chatting with Claude using the Anthropic API with real-time streaming responses. + +## Features + +- Interactive chat with Claude in your terminal +- Real-time streaming responses (see text as Claude types) +- Maintains conversation history within a session +- Configurable model and system prompts +- Multi-location config file support + +## Requirements + +- Go 1.21 or higher +- Anthropic API key ([get one here](https://console.anthropic.com/)) + +## Installation + +1. Clone and build: +```bash +git clone +cd anthropic-cli +go build -o prompt +``` + +2. Create a symlink for global access: +```bash +sudo ln -s $(pwd)/prompt /usr/local/bin/prompt +``` + +Or install via Go: +```bash +go install +``` + +## Configuration + +The tool looks for configuration files in the following order: +1. `.env` in the current directory (for project-specific configs) +2. `~/.config/prompt/config` (XDG standard location) +3. `~/.prompt.env` (home directory) + +### Setup your config: + +```bash +# Create the config directory +mkdir -p ~/.config/prompt + +# Create config file +cat > ~/.config/prompt/config << EOF +ANTHROPIC_API_KEY=your-api-key-here +MODEL=claude-sonnet-4-5-20250929 +SYSTEM_PROMPT= +EOF +``` + +### Configuration options: + +- `ANTHROPIC_API_KEY` (required): Your Anthropic API key +- `MODEL` (optional): Model to use (default: claude-sonnet-4-5-20250929) +- `SYSTEM_PROMPT` (optional): Custom system prompt for Claude + +## Usage + +Start a conversation: +```bash +prompt "Hello, how are you?" +``` + +This will: +1. Send your initial message to Claude +2. Stream the response in real-time +3. Enter an interactive chat mode where you can continue the conversation + +Type `exit` or `quit` to end the conversation. + +## Examples + +```bash +# Ask a quick question +prompt "What is the capital of France?" + +# Start a coding session +prompt "Help me write a Python function to calculate fibonacci numbers" + +# Use a different model (set in config) +# Edit your config file and change MODEL=claude-opus-4-5-20251101 +prompt "Explain quantum computing" +``` + +## Development + +Run without building: +```bash +go run main.go "your message here" +``` + +## License + +MIT diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b416f6b --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module prompt + +go 1.21 + +require github.com/joho/godotenv v1.5.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d61b19e --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= diff --git a/main.go b/main.go new file mode 100644 index 0000000..5f62fad --- /dev/null +++ b/main.go @@ -0,0 +1,265 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/joho/godotenv" +) + +type Message struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type APIRequest struct { + Model string `json:"model"` + System string `json:"system,omitempty"` + MaxTokens int `json:"max_tokens"` + Messages []Message `json:"messages"` + Stream bool `json:"stream"` +} + +type ContentBlock struct { + Text string `json:"text"` + Type string `json:"type"` +} + +type APIResponse struct { + Content []ContentBlock `json:"content"` + Error *struct { + Type string `json:"type"` + Message string `json:"message"` + } `json:"error,omitempty"` +} + +type StreamEvent struct { + Type string `json:"type"` + Delta *StreamDelta `json:"delta,omitempty"` + Error *StreamError `json:"error,omitempty"` +} + +type StreamDelta struct { + Type string `json:"type"` + Text string `json:"text"` +} + +type StreamError struct { + Type string `json:"type"` + Message string `json:"message"` +} + +var conversationHistory []Message + +func loadConfig() error { + // Try loading config from multiple locations in order + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get home directory: %w", err) + } + + configPaths := []string{ + ".env", // Current directory + filepath.Join(homeDir, ".config", "prompt", "config"), // XDG standard + filepath.Join(homeDir, ".prompt.env"), // Home directory + } + + var lastErr error + for _, path := range configPaths { + err := godotenv.Load(path) + if err == nil { + return nil // Successfully loaded + } + lastErr = err + } + + return lastErr +} + +func main() { + // Load config file + err := loadConfig() + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: Could not load config file: %v\n", err) + } + + // Get configuration from environment + apiKey := os.Getenv("ANTHROPIC_API_KEY") + if apiKey == "" { + fmt.Fprintln(os.Stderr, "Error: ANTHROPIC_API_KEY not set in .env file") + os.Exit(1) + } + + model := os.Getenv("MODEL") + if model == "" { + model = "claude-sonnet-4-5-20250929" + } + + systemPrompt := os.Getenv("SYSTEM_PROMPT") + + // Get initial prompt from command-line arguments + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "Usage: prompt ") + os.Exit(1) + } + + initialPrompt := strings.Join(os.Args[1:], " ") + + // Add initial user message to conversation history + conversationHistory = append(conversationHistory, Message{ + Role: "user", + Content: initialPrompt, + }) + + // Send initial message + response, err := sendMessage(apiKey, model, systemPrompt) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + fmt.Println(response) + + // Add assistant's response to conversation history + conversationHistory = append(conversationHistory, Message{ + Role: "assistant", + Content: response, + }) + + // Interactive conversation loop + reader := bufio.NewReader(os.Stdin) + for { + fmt.Println() + fmt.Print("\033[32mYou (or 'exit' to quit): \033[0m") + + userInput, err := reader.ReadString('\n') + if err != nil { + break + } + + userInput = strings.TrimSpace(userInput) + + // Check if user wants to exit + if userInput == "exit" || userInput == "quit" || userInput == "" { + fmt.Println("Goodbye!") + break + } + + // Add user input to conversation history + conversationHistory = append(conversationHistory, Message{ + Role: "user", + Content: userInput, + }) + + // Send message and get response + response, err := sendMessage(apiKey, model, systemPrompt) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + continue + } + + fmt.Println(response) + + // Add assistant's response to conversation history + conversationHistory = append(conversationHistory, Message{ + Role: "assistant", + Content: response, + }) + } +} + +func sendMessage(apiKey, model, systemPrompt string) (string, error) { + // Build API request with streaming enabled + request := APIRequest{ + Model: model, + System: systemPrompt, + MaxTokens: 2048, + Messages: conversationHistory, + Stream: true, + } + + jsonData, err := json.Marshal(request) + if err != nil { + return "", fmt.Errorf("failed to marshal request: %w", err) + } + + // Make API call + req, err := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", bytes.NewBuffer(jsonData)) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("x-api-key", apiKey) + req.Header.Set("anthropic-version", "2023-06-01") + req.Header.Set("content-type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + // Check for HTTP errors + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + } + + // Read and parse streaming response + var fullResponse strings.Builder + reader := bufio.NewReader(resp.Body) + + for { + line, err := reader.ReadString('\n') + if err != nil { + if err == io.EOF { + break + } + return "", fmt.Errorf("failed to read stream: %w", err) + } + + line = strings.TrimSpace(line) + + // Skip empty lines and non-data lines + if line == "" || !strings.HasPrefix(line, "data: ") { + continue + } + + // Extract JSON data + data := strings.TrimPrefix(line, "data: ") + + // Skip the [DONE] message + if data == "[DONE]" { + break + } + + // Parse the event + var event StreamEvent + if err := json.Unmarshal([]byte(data), &event); err != nil { + continue // Skip malformed events + } + + // Check for errors + if event.Error != nil { + return "", fmt.Errorf("API error: %s - %s", event.Error.Type, event.Error.Message) + } + + // Print text deltas as they arrive + if event.Type == "content_block_delta" && event.Delta != nil && event.Delta.Type == "text_delta" { + fmt.Print(event.Delta.Text) + fullResponse.WriteString(event.Delta.Text) + } + } + + fmt.Println() // Add newline after streaming response + + return fullResponse.String(), nil +} diff --git a/main.sh b/main.sh index eace533..8694851 100755 --- a/main.sh +++ b/main.sh @@ -4,32 +4,104 @@ source .env PROMPT="$*" TEMP_FILE=$(mktemp) -prompt=$(echo $PROMPT) ANTHROPIC_MODEL=${MODEL:-"claude-sonnet-4-5-20250929"} SYSTEM_PROMPT=${SYSTEM_PROMPT:-""} -JSON_PAYLOAD=$(jq -n \ - --arg model "$ANTHROPIC_MODEL" \ - --arg prompt "$PROMPT" \ - --arg system_prompt "$SYSTEM_PROMPT" \ - '{ - model: $model, - system: $system_prompt, - max_tokens: 2048, - messages: [{role: "user", content: $prompt}] - }') +# Initialize conversation history array +declare -a CONVERSATION_HISTORY=() -# Show loading message -echo -ne "\033[36mFetching response from Claude...\033[0m" +# Function to build messages JSON array from conversation history +build_messages_json() { + local messages_json="[" + local first=true -curl -s https://api.anthropic.com/v1/messages \ - --header "x-api-key: $ANTHROPIC_API_KEY" \ - --header "anthropic-version: 2023-06-01" \ - --header "content-type: application/json" \ - --data "$JSON_PAYLOAD" > "$TEMP_FILE" + for ((i=0; i<${#CONVERSATION_HISTORY[@]}; i+=2)); do + if [ "$first" = true ]; then + first=false + else + messages_json+="," + fi -# Clear loading message -echo -ne "\r\033[K" + # Add user message + local user_msg="${CONVERSATION_HISTORY[$i]}" + user_msg="${user_msg//\\/\\\\}" # Escape backslashes + user_msg="${user_msg//\"/\\\"}" # Escape quotes + user_msg="${user_msg//$'\n'/\\n}" # Escape newlines + messages_json+="{\"role\":\"user\",\"content\":\"$user_msg\"}" -# Display response -jq -r '.content[0].text' "$TEMP_FILE" + # Add assistant message if it exists + if [ $((i+1)) -lt ${#CONVERSATION_HISTORY[@]} ]; then + local assistant_msg="${CONVERSATION_HISTORY[$((i+1))]}" + assistant_msg="${assistant_msg//\\/\\\\}" + assistant_msg="${assistant_msg//\"/\\\"}" + assistant_msg="${assistant_msg//$'\n'/\\n}" + messages_json+=",{\"role\":\"assistant\",\"content\":\"$assistant_msg\"}" + fi + done + + messages_json+="]" + echo "$messages_json" +} + +# Function to send message and get response +send_message() { + local messages_json=$(build_messages_json) + + JSON_PAYLOAD=$(jq -n \ + --arg model "$ANTHROPIC_MODEL" \ + --arg system_prompt "$SYSTEM_PROMPT" \ + --argjson messages "$messages_json" \ + '{ + model: $model, + system: $system_prompt, + max_tokens: 2048, + messages: $messages + }') + + # Show loading message + echo -ne "\033[36mFetching response from Claude...\033[0m" + + curl -s https://api.anthropic.com/v1/messages \ + --header "x-api-key: $ANTHROPIC_API_KEY" \ + --header "anthropic-version: 2023-06-01" \ + --header "content-type: application/json" \ + --data "$JSON_PAYLOAD" > "$TEMP_FILE" + + # Clear loading message + echo -ne "\r\033[K" + + # Extract and display response + local response=$(jq -r '.content[0].text' "$TEMP_FILE") + echo "$response" + + # Add assistant's response to conversation history + CONVERSATION_HISTORY+=("$response") +} + +# Add initial user prompt to conversation history +CONVERSATION_HISTORY+=("$PROMPT") + +# Send initial message +send_message + +# Conversation loop +while true; do + echo "" + echo -ne "\033[32mYou (or 'exit' to quit): \033[0m" + read -e user_input + + # Check if user wants to exit + if [[ "$user_input" == "exit" ]] || [[ "$user_input" == "quit" ]] || [[ -z "$user_input" ]]; then + echo "Goodbye!" + break + fi + + # Add user input to conversation history + CONVERSATION_HISTORY+=("$user_input") + + # Send message and get response + send_message +done + +# Cleanup +rm -f "$TEMP_FILE"