Compare commits

..

8 commits

Author SHA1 Message Date
Leo
01c90d587f feat(cli): support interactive start without initial prompt 2026-02-12 11:15:39 +02:00
Leo Alho
981683a405
feat: Allow starting CLI without initial prompt (#2)
Make the initial prompt optional. When no arguments are provided,
the CLI now goes directly to interactive mode instead of exiting.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 15:06:30 +02:00
Leo Alho
63d4ad95d0
feat: Add logging
Add conversation logging with per-session files and visual separators

Implements optional conversation logging where each session creates its own timestamped log file. Also adds dynamic terminal-width separators between messages for better visual organization.
2026-01-14 15:14:30 +02:00
Leo Alho
85ee5f1adc
Update README with motivation and config setup removal
Added motivation section and removed setup instructions.
2026-01-13 20:14:54 +02:00
Leo Alho
a9a6f6c792
Update README to specify Klod is Go-based
Clarified the description of Klod to specify it is Go-based.
2026-01-13 18:22:35 +02:00
Leo Alho
853b57ed86
Fix escape character in README pronunciation 2026-01-13 13:59:39 +02:00
Leo Alho
19e77f7dc1
Update README to clarify pronunciation of Klod 2026-01-13 13:59:25 +02:00
Leo Alho
1b9e3b6347
Revise README formatting and content
Updated README to correct formatting, improve clarity, and remove examples.
2026-01-13 13:58:51 +02:00
7 changed files with 311 additions and 68 deletions

View file

@ -1,2 +1,4 @@
ANTHROPIC_API_KEY="" ANTHROPIC_API_KEY=""
SYSTEM_PROMPT="" SYSTEM_PROMPT=""
KLOD_LOGS=false
KLOD_LOG_FILE=""

1
.gitignore vendored
View file

@ -1 +1,2 @@
.env .env
klod

View file

@ -1,14 +1,15 @@
# klod # Klod
A simple, fast CLI tool for chatting with Claude using the Anthropic API with real-time streaming responses. Klod (pronounced \klod\\), a simple Go-based CLI tool for interacting with the Anthropic API
## Motivation
Sometimes you just need to quickly check/ask an LLM something. I basically live in the terminal so I want to avoid the context switch to a browser. Claude code on the other hand is a bit too eager to help (which has its benefits but no necessarily for your quick short messages). Therefore I quickly stiched together klod, originally in bash, still living as [./main.sh](main.sh) at the root of the project.
## Features ## Features
- Interactive chat with Claude in your terminal
- Real-time streaming responses (see text as Claude types)
- Maintains conversation history within a session - Maintains conversation history within a session
- Configurable model and system prompts - Configurable model and system prompts
- Multi-location config file support
## Requirements ## Requirements
@ -18,18 +19,21 @@ A simple, fast CLI tool for chatting with Claude using the Anthropic API with re
## Installation ## Installation
1. Clone and build: 1. Clone and build:
```bash ```bash
git clone <your-repo-url> git clone https://github.com/leoalho/klod.git
cd anthropic-cli cd anthropic-cli
go build -o klod go build
``` ```
2. Create a symlink for global access: 2. Create a symlink for global access:
```bash ```bash
sudo ln -s $(pwd)/klod /usr/local/bin/klod sudo ln -s $(pwd)/klod /usr/local/bin/klod
``` ```
Or install via Go: Or install via Go:
```bash ```bash
go install go install
``` ```
@ -37,6 +41,7 @@ go install
## Configuration ## Configuration
The tool looks for configuration files in the following order: The tool looks for configuration files in the following order:
1. `~/.config/klod/config` (XDG standard location) 1. `~/.config/klod/config` (XDG standard location)
2. `~/.klod.env` (home directory) 2. `~/.klod.env` (home directory)
3. `.env` in the current directory (for project-specific overrides) 3. `.env` in the current directory (for project-specific overrides)
@ -52,6 +57,8 @@ cat > ~/.config/klod/config << EOF
ANTHROPIC_API_KEY=your-api-key-here ANTHROPIC_API_KEY=your-api-key-here
MODEL=claude-sonnet-4-5-20250929 MODEL=claude-sonnet-4-5-20250929
SYSTEM_PROMPT= SYSTEM_PROMPT=
KLOD_LOGS=false
KLOD_LOG_FILE=
EOF EOF
``` ```
@ -60,42 +67,37 @@ EOF
- `ANTHROPIC_API_KEY` (required): Your Anthropic API key - `ANTHROPIC_API_KEY` (required): Your Anthropic API key
- `MODEL` (optional): Model to use (default: claude-sonnet-4-5-20250929) - `MODEL` (optional): Model to use (default: claude-sonnet-4-5-20250929)
- `SYSTEM_PROMPT` (optional): Custom system prompt for Claude - `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 ## Usage
Start a conversation: Start a conversation:
```bash ```bash
klod "Hello, how are you?" klod Hello, how are you?
``` ```
This will: This will:
1. Send your initial message to Claude 1. Send your initial message to Claude
2. Stream the response in real-time 2. Stream the response in real-time
3. Enter an interactive chat mode where you can continue the conversation 3. Enter an interactive chat mode where you can continue the conversation
Type `exit` or `quit` to end the conversation. Type `exit` or `quit` to end the conversation.
## Examples You can also start without an initial prompt:
```bash ```bash
# Ask a quick question klod
klod "What is the capital of France?"
# Start a coding session
klod "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
klod "Explain quantum computing"
``` ```
This starts the service and asks for your first prompt interactively.
## Development ## Development
Run without building: Run without building:
```bash ```bash
go run main.go "your message here" go run main.go your message here
``` ```
## License
MIT

7
go.mod
View file

@ -2,4 +2,9 @@ module klod
go 1.21 go 1.21
require github.com/joho/godotenv v1.5.1 require (
github.com/joho/godotenv v1.5.1
golang.org/x/term v0.27.0
)
require golang.org/x/sys v0.28.0 // indirect

4
go.sum
View file

@ -1,2 +1,6 @@
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=

255
main.go
View file

@ -10,8 +10,12 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"syscall"
"time"
"unsafe"
"github.com/joho/godotenv" "github.com/joho/godotenv"
"golang.org/x/term"
) )
type Message struct { type Message struct {
@ -57,6 +61,189 @@ type StreamError struct {
} }
var conversationHistory []Message 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
}
// enableBracketedPaste sends escape sequence to enable bracketed paste mode
func enableBracketedPaste() {
fmt.Print("\033[?2004h")
}
// disableBracketedPaste sends escape sequence to disable bracketed paste mode
func disableBracketedPaste() {
fmt.Print("\033[?2004l")
}
// readInputWithPasteSupport reads user input with support for multi-line paste
// It uses raw terminal mode to detect bracketed paste sequences
func readInputWithPasteSupport() (string, error) {
fd := int(os.Stdin.Fd())
// Check if stdin is a terminal
if !term.IsTerminal(fd) {
// Fall back to simple readline for non-terminal input
reader := bufio.NewReader(os.Stdin)
line, err := reader.ReadString('\n')
return strings.TrimSpace(line), err
}
// Save terminal state and switch to raw mode
oldState, err := term.MakeRaw(fd)
if err != nil {
// Fall back to simple readline if raw mode fails
reader := bufio.NewReader(os.Stdin)
line, err := reader.ReadString('\n')
return strings.TrimSpace(line), err
}
defer term.Restore(fd, oldState)
var input strings.Builder
var buf [1]byte
inPaste := false
escBuf := make([]byte, 0, 10)
for {
n, err := os.Stdin.Read(buf[:])
if err != nil || n == 0 {
return input.String(), err
}
b := buf[0]
// Handle escape sequences
if b == 0x1b { // ESC
escBuf = append(escBuf[:0], b)
continue
}
if len(escBuf) > 0 {
escBuf = append(escBuf, b)
// Check for bracketed paste start: ESC[200~
if len(escBuf) == 6 && escBuf[0] == 0x1b && escBuf[1] == '[' &&
escBuf[2] == '2' && escBuf[3] == '0' && escBuf[4] == '0' && escBuf[5] == '~' {
inPaste = true
escBuf = escBuf[:0]
continue
}
// Check for bracketed paste end: ESC[201~
if len(escBuf) == 6 && escBuf[0] == 0x1b && escBuf[1] == '[' &&
escBuf[2] == '2' && escBuf[3] == '0' && escBuf[4] == '1' && escBuf[5] == '~' {
inPaste = false
escBuf = escBuf[:0]
continue
}
// If sequence is getting too long without matching, it's not a paste sequence
// Write accumulated bytes and reset
if len(escBuf) > 6 {
for _, eb := range escBuf {
if eb >= 0x20 {
input.WriteByte(eb)
fmt.Print(string(eb))
}
}
escBuf = escBuf[:0]
}
continue
}
// Handle special keys
switch b {
case '\r', '\n': // Enter
if inPaste {
input.WriteByte('\n')
fmt.Print("\r\n") // Echo newline
} else {
fmt.Print("\r\n")
return input.String(), nil
}
case 0x03: // Ctrl+C
fmt.Print("^C\r\n")
return "", fmt.Errorf("interrupted")
case 0x04: // Ctrl+D
if input.Len() == 0 {
return "", io.EOF
}
return input.String(), nil
case 0x7f, 0x08: // Backspace/Delete
s := input.String()
if len(s) > 0 {
// Handle multi-byte UTF-8 characters
runes := []rune(s)
if len(runes) > 0 {
input.Reset()
input.WriteString(string(runes[:len(runes)-1]))
fmt.Print("\b \b") // Erase character on screen
}
}
default:
if b >= 0x20 || b == '\t' { // Printable character or tab
input.WriteByte(b)
if b == '\t' {
fmt.Print(" ") // Display tab as spaces
} else {
fmt.Print(string(b)) // Echo character
}
}
}
}
}
func loadConfig() error { func loadConfig() error {
// Try loading config from multiple locations in order // Try loading config from multiple locations in order
@ -104,70 +291,104 @@ func main() {
systemPrompt := os.Getenv("SYSTEM_PROMPT") systemPrompt := os.Getenv("SYSTEM_PROMPT")
// Get initial prompt from command-line arguments // Configure logging
if len(os.Args) < 2 { loggingEnabled = os.Getenv("KLOD_LOGS") == "true" || os.Getenv("KLOD_LOGS") == "1"
fmt.Fprintln(os.Stderr, "Usage: klod <your prompt>") logFilePath = os.Getenv("KLOD_LOG_FILE")
os.Exit(1) 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 provided)
if len(os.Args) >= 2 {
initialPrompt := strings.Join(os.Args[1:], " ") initialPrompt := strings.Join(os.Args[1:], " ")
// Add initial user message to conversation history // Add initial user message to conversation history
conversationHistory = append(conversationHistory, Message{ userMsg := Message{
Role: "user", Role: "user",
Content: initialPrompt, Content: initialPrompt,
}) }
conversationHistory = append(conversationHistory, userMsg)
logConversation(userMsg)
// Send initial message // Send initial message
printSeparator()
fmt.Print("\033[34mAssistant: \033[0m")
response, err := sendMessage(apiKey, model, systemPrompt) response, err := sendMessage(apiKey, model, systemPrompt)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err) fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
printSeparator()
// Add assistant's response to conversation history // Add assistant's response to conversation history
conversationHistory = append(conversationHistory, Message{ assistantMsg := Message{
Role: "assistant", Role: "assistant",
Content: response, Content: response,
}) }
conversationHistory = append(conversationHistory, assistantMsg)
logConversation(assistantMsg)
}
// Interactive conversation loop with bracketed paste support
enableBracketedPaste()
defer disableBracketedPaste()
// Interactive conversation loop
reader := bufio.NewReader(os.Stdin)
for { for {
fmt.Println()
fmt.Print("\033[32mYou (or 'exit' to quit): \033[0m") fmt.Print("\033[32mYou (or 'exit' to quit): \033[0m")
userInput, err := reader.ReadString('\n') userInput, err := readInputWithPasteSupport()
if err != nil { if err != nil {
if err == io.EOF {
fmt.Println("\nGoodbye!")
}
break break
} }
userInput = strings.TrimSpace(userInput) userInput = strings.TrimSpace(userInput)
// Check if user wants to exit // Check if user wants to exit
if userInput == "exit" || userInput == "quit" || userInput == "" { if userInput == "exit" || userInput == "quit" {
fmt.Println("Goodbye!") fmt.Println("Goodbye!")
break break
} }
if userInput == "" {
fmt.Println("Please enter a prompt (or type 'exit' to quit).")
continue
}
// Add user input to conversation history // Add user input to conversation history
conversationHistory = append(conversationHistory, Message{ userMsg := Message{
Role: "user", Role: "user",
Content: userInput, Content: userInput,
}) }
conversationHistory = append(conversationHistory, userMsg)
logConversation(userMsg)
// Send message and get response // Send message and get response
printSeparator()
fmt.Print("\033[34mAssistant: \033[0m")
response, err := sendMessage(apiKey, model, systemPrompt) response, err := sendMessage(apiKey, model, systemPrompt)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err) fmt.Fprintf(os.Stderr, "Error: %v\n", err)
continue continue
} }
printSeparator()
// Add assistant's response to conversation history // Add assistant's response to conversation history
conversationHistory = append(conversationHistory, Message{ assistantMsg := Message{
Role: "assistant", Role: "assistant",
Content: response, Content: response,
}) }
conversationHistory = append(conversationHistory, assistantMsg)
logConversation(assistantMsg)
} }
} }

10
main.sh
View file

@ -78,11 +78,15 @@ send_message() {
CONVERSATION_HISTORY+=("$response") CONVERSATION_HISTORY+=("$response")
} }
if [[ -n "$PROMPT" ]]; then
# Add initial user prompt to conversation history # Add initial user prompt to conversation history
CONVERSATION_HISTORY+=("$PROMPT") CONVERSATION_HISTORY+=("$PROMPT")
# Send initial message # Send initial message
send_message send_message
else
:
fi
# Conversation loop # Conversation loop
while true; do while true; do
@ -91,10 +95,14 @@ while true; do
read -e user_input read -e user_input
# Check if user wants to exit # Check if user wants to exit
if [[ "$user_input" == "exit" ]] || [[ "$user_input" == "quit" ]] || [[ -z "$user_input" ]]; then if [[ "$user_input" == "exit" ]] || [[ "$user_input" == "quit" ]]; then
echo "Goodbye!" echo "Goodbye!"
break break
fi fi
if [[ -z "$user_input" ]]; then
echo "Please enter a prompt (or type 'exit' to quit)."
continue
fi
# Add user input to conversation history # Add user input to conversation history
CONVERSATION_HISTORY+=("$user_input") CONVERSATION_HISTORY+=("$user_input")