diff --git a/.env.example b/.env.example index cc0d898..38e77d1 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,4 @@ ANTHROPIC_API_KEY="" SYSTEM_PROMPT="" +KLOD_LOGS=false +KLOD_LOG_FILE="" diff --git a/.gitignore b/.gitignore index 4c49bd7..089d60c 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .env +klod diff --git a/README.md b/README.md index 1ff91f8..b8fb5b0 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Sometimes you just need to quickly check/ask an LLM something. I basically live ## Installation 1. Clone and build: + ```bash git clone https://github.com/leoalho/klod.git cd anthropic-cli @@ -26,11 +27,13 @@ go build ``` 2. Create a symlink for global access: + ```bash sudo ln -s $(pwd)/klod /usr/local/bin/klod ``` Or install via Go: + ```bash go install ``` @@ -38,24 +41,45 @@ go install ## Configuration The tool looks for configuration files in the following order: + 1. `~/.config/klod/config` (XDG standard location) 2. `~/.klod.env` (home directory) 3. `.env` in the current directory (for project-specific overrides) +### Setup your config: + +```bash +# Create the config directory +mkdir -p ~/.config/klod + +# Create config file +cat > ~/.config/klod/config << EOF +ANTHROPIC_API_KEY=your-api-key-here +MODEL=claude-sonnet-4-5-20250929 +SYSTEM_PROMPT= +KLOD_LOGS=false +KLOD_LOG_FILE= +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 +- `KLOD_LOGS` (optional): Enable conversation logging (default: false, set to "true" or "1" to enable) +- `KLOD_LOG_FILE` (optional): Custom log file path (default: `$XDG_STATE_HOME/klod/conversations.log` or `~/.local/state/klod/conversations.log`) ## Usage Start a conversation: + ```bash klod 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 @@ -65,6 +89,7 @@ Type `exit` or `quit` to end the conversation. ## Development Run without building: + ```bash go run main.go your message here ``` diff --git a/main.go b/main.go index 19ff737..506ebf3 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,9 @@ import ( "os" "path/filepath" "strings" + "syscall" + "time" + "unsafe" "github.com/joho/godotenv" ) @@ -20,11 +23,11 @@ type Message struct { } 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"` + 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 { @@ -41,9 +44,9 @@ type APIResponse struct { } type StreamEvent struct { - Type string `json:"type"` - Delta *StreamDelta `json:"delta,omitempty"` - Error *StreamError `json:"error,omitempty"` + Type string `json:"type"` + Delta *StreamDelta `json:"delta,omitempty"` + Error *StreamError `json:"error,omitempty"` } type StreamDelta struct { @@ -57,6 +60,62 @@ type StreamError struct { } var conversationHistory []Message +var loggingEnabled bool +var logFilePath string + +type winsize struct { + Row uint16 + Col uint16 + Xpixel uint16 + Ypixel uint16 +} + +func getTerminalWidth() int { + ws := &winsize{} + retCode, _, _ := syscall.Syscall(syscall.SYS_IOCTL, + uintptr(syscall.Stdin), + uintptr(syscall.TIOCGWINSZ), + uintptr(unsafe.Pointer(ws))) + + if int(retCode) == -1 { + return 80 // Default fallback + } + return int(ws.Col) +} + +func printSeparator() { + width := getTerminalWidth() + fmt.Println(strings.Repeat("─", width)) +} + +func logConversation(message Message) error { + if !loggingEnabled { + return nil + } + + // Ensure log directory exists + logDir := filepath.Dir(logFilePath) + if err := os.MkdirAll(logDir, 0755); err != nil { + return fmt.Errorf("failed to create log directory: %w", err) + } + + // Open log file in append mode + file, err := os.OpenFile(logFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("failed to open log file: %w", err) + } + defer file.Close() + + // Write timestamp and message + timestamp := time.Now().Format("2006-01-02 15:04:05") + logEntry := fmt.Sprintf("[%s] %s: %s\n", timestamp, strings.ToUpper(message.Role), message.Content) + + if _, err := file.WriteString(logEntry); err != nil { + return fmt.Errorf("failed to write to log file: %w", err) + } + + return nil +} func loadConfig() error { // Try loading config from multiple locations in order @@ -67,8 +126,8 @@ func loadConfig() error { configPaths := []string{ filepath.Join(homeDir, ".config", "klod", "config"), // XDG standard - filepath.Join(homeDir, ".klod.env"), // Home directory - ".env", // Current directory (for project-specific overrides) + filepath.Join(homeDir, ".klod.env"), // Home directory + ".env", // Current directory (for project-specific overrides) } var lastErr error @@ -104,6 +163,21 @@ func main() { systemPrompt := os.Getenv("SYSTEM_PROMPT") + // Configure logging + loggingEnabled = os.Getenv("KLOD_LOGS") == "true" || os.Getenv("KLOD_LOGS") == "1" + logFilePath = os.Getenv("KLOD_LOG_FILE") + if logFilePath == "" { + // Use XDG_STATE_HOME or default to ~/.local/state per XDG spec + stateDir := os.Getenv("XDG_STATE_HOME") + if stateDir == "" { + homeDir, _ := os.UserHomeDir() + stateDir = filepath.Join(homeDir, ".local", "state") + } + // Create a unique log file for this session based on timestamp + sessionTime := time.Now().Format("2006-01-02_15-04-05") + logFilePath = filepath.Join(stateDir, "klod", "sessions", sessionTime+".log") + } + // Get initial prompt from command-line arguments if len(os.Args) < 2 { fmt.Fprintln(os.Stderr, "Usage: klod ") @@ -113,28 +187,34 @@ func main() { initialPrompt := strings.Join(os.Args[1:], " ") // Add initial user message to conversation history - conversationHistory = append(conversationHistory, Message{ + userMsg := Message{ Role: "user", Content: initialPrompt, - }) + } + conversationHistory = append(conversationHistory, userMsg) + logConversation(userMsg) // Send initial message + printSeparator() + fmt.Print("\033[34mAssistant: \033[0m") response, err := sendMessage(apiKey, model, systemPrompt) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } + printSeparator() // Add assistant's response to conversation history - conversationHistory = append(conversationHistory, Message{ + assistantMsg := Message{ Role: "assistant", Content: response, - }) + } + conversationHistory = append(conversationHistory, assistantMsg) + logConversation(assistantMsg) // 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') @@ -151,23 +231,30 @@ func main() { } // Add user input to conversation history - conversationHistory = append(conversationHistory, Message{ + userMsg := Message{ Role: "user", Content: userInput, - }) + } + conversationHistory = append(conversationHistory, userMsg) + logConversation(userMsg) // Send message and get response + printSeparator() + fmt.Print("\033[34mAssistant: \033[0m") response, err := sendMessage(apiKey, model, systemPrompt) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) continue } + printSeparator() // Add assistant's response to conversation history - conversationHistory = append(conversationHistory, Message{ + assistantMsg := Message{ Role: "assistant", Content: response, - }) + } + conversationHistory = append(conversationHistory, assistantMsg) + logConversation(assistantMsg) } }