// CLI backend for Claude Max subscription via Claude Code CLI.
package llm
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"os/exec"
"strings"
"sync"
)
// CLIClient uses the Claude Code CLI for LLM requests.
// This allows using a Claude Max subscription instead of API tokens.
type CLIClient struct {
mu sync.RWMutex
model string
temperature float64
messages []Message
lastTokens int
streaming bool
streamChan chan string
streamDone chan struct{}
}
// cliResponse represents the JSON response from claude CLI
type cliResponse struct {
Type string `json:"type"`
Result string `json:"result"`
}
// NewCLIClient creates a new CLI-based LLM client
func NewCLIClient() *CLIClient {
return &CLIClient{
model: "sonnet", // CLI uses short model names
temperature: 0.7,
messages: make([]Message, 0),
}
}
// normalizeModel converts full model names to CLI aliases
func normalizeModel(model string) string {
model = strings.ToLower(model)
switch {
case strings.Contains(model, "opus"):
return "opus"
case strings.Contains(model, "haiku"):
return "haiku"
default:
return "sonnet"
}
}
// Model returns the current model name
func (c *CLIClient) Model() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.model
}
// SetModel sets the model for subsequent requests
func (c *CLIClient) SetModel(model string) {
c.mu.Lock()
defer c.mu.Unlock()
c.model = normalizeModel(model)
}
// Temperature returns the current temperature
func (c *CLIClient) Temperature() float64 {
c.mu.RLock()
defer c.mu.RUnlock()
return c.temperature
}
// SetTemperature sets the temperature for subsequent requests
func (c *CLIClient) SetTemperature(temp float64) error {
if temp < 0.0 || temp > 2.0 {
return fmt.Errorf("temperature must be between 0.0 and 2.0")
}
c.mu.Lock()
defer c.mu.Unlock()
c.temperature = temp
return nil
}
// LastTokens returns the token count from the last response
// Note: CLI doesn't provide token counts, so this is always 0
func (c *CLIClient) LastTokens() int {
c.mu.RLock()
defer c.mu.RUnlock()
return c.lastTokens
}
// Messages returns a copy of the conversation history
func (c *CLIClient) Messages() []Message {
c.mu.RLock()
defer c.mu.RUnlock()
result := make([]Message, len(c.messages))
copy(result, c.messages)
return result
}
// MessagesJSON returns the conversation history as JSON
func (c *CLIClient) MessagesJSON() ([]byte, error) {
c.mu.RLock()
defer c.mu.RUnlock()
return json.MarshalIndent(c.messages, "", " ")
}
// AddSystemMessage adds a system message to the context
func (c *CLIClient) AddSystemMessage(content string) {
c.mu.Lock()
defer c.mu.Unlock()
c.messages = append([]Message{{Role: "system", Content: content}}, c.messages...)
}
// Reset clears the conversation history
func (c *CLIClient) Reset() {
c.mu.Lock()
defer c.mu.Unlock()
c.messages = make([]Message, 0)
c.lastTokens = 0
}
// buildPrompt builds a full prompt string from conversation history
func (c *CLIClient) buildPrompt() string {
var parts []string
for _, msg := range c.messages {
switch msg.Role {
case "user":
parts = append(parts, fmt.Sprintf("Human: %s", msg.Content))
case "assistant":
parts = append(parts, fmt.Sprintf("Assistant: %s", msg.Content))
}
}
return strings.Join(parts, "\n\n")
}
// getSystemPrompt extracts system messages as a single string
func (c *CLIClient) getSystemPrompt() string {
var systems []string
for _, msg := range c.messages {
if msg.Role == "system" {
systems = append(systems, msg.Content)
}
}
return strings.Join(systems, "\n\n")
}
// Ask sends a prompt to the LLM via CLI and returns the response
func (c *CLIClient) Ask(ctx context.Context, prompt string) (string, error) {
c.mu.Lock()
c.messages = append(c.messages, Message{Role: "user", Content: prompt})
fullPrompt := c.buildPrompt()
systemPrompt := c.getSystemPrompt()
model := c.model
c.mu.Unlock()
// Build claude CLI command
args := []string{
"--print",
"--output-format", "json",
"--model", model,
"--dangerously-skip-permissions",
"--allowedTools", "",
}
if systemPrompt != "" {
args = append(args, "--system-prompt", systemPrompt)
}
args = append(args, "-") // Read from stdin
cmd := exec.CommandContext(ctx, "claude", args...)
cmd.Stdin = bytes.NewBufferString(fullPrompt)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
// Remove user message on error
c.mu.Lock()
if len(c.messages) > 0 {
c.messages = c.messages[:len(c.messages)-1]
}
c.mu.Unlock()
return "", fmt.Errorf("claude CLI error: %w (stderr: %s)", err, stderr.String())
}
// Parse JSON response
responseText, err := parseJSONResponse(stdout.String())
if err != nil {
// Remove user message on error
c.mu.Lock()
if len(c.messages) > 0 {
c.messages = c.messages[:len(c.messages)-1]
}
c.mu.Unlock()
return "", fmt.Errorf("failed to parse CLI response: %w", err)
}
// Update state
c.mu.Lock()
c.messages = append(c.messages, Message{Role: "assistant", Content: responseText})
c.lastTokens = 0 // CLI doesn't provide token counts
c.mu.Unlock()
return responseText, nil
}
// parseJSONResponse extracts the result from claude CLI JSON output
func parseJSONResponse(output string) (string, error) {
// Try parsing each line as JSON (CLI may output multiple JSON objects)
scanner := bufio.NewScanner(strings.NewReader(output))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var resp cliResponse
if err := json.Unmarshal([]byte(line), &resp); err != nil {
continue // Not valid JSON, try next line
}
if resp.Type == "result" && resp.Result != "" {
return resp.Result, nil
}
}
// Fallback: return raw output if no JSON result found
output = strings.TrimSpace(output)
if output != "" {
return output, nil
}
return "", fmt.Errorf("no result in CLI output")
}
// StartStream begins streaming a response for the given prompt
// Note: CLI streaming is simulated - we run the command and feed output progressively
func (c *CLIClient) StartStream(ctx context.Context, prompt string) error {
c.mu.Lock()
if c.streaming {
c.mu.Unlock()
return fmt.Errorf("stream already in progress")
}
c.messages = append(c.messages, Message{Role: "user", Content: prompt})
fullPrompt := c.buildPrompt()
systemPrompt := c.getSystemPrompt()
model := c.model
c.streaming = true
c.streamChan = make(chan string, 100)
c.streamDone = make(chan struct{})
c.mu.Unlock()
go func() {
defer func() {
c.mu.Lock()
c.streaming = false
close(c.streamChan)
close(c.streamDone)
c.mu.Unlock()
}()
// Build command
args := []string{
"--print",
"--output-format", "json",
"--model", model,
"--dangerously-skip-permissions",
"--allowedTools", "",
}
if systemPrompt != "" {
args = append(args, "--system-prompt", systemPrompt)
}
args = append(args, "-")
cmd := exec.CommandContext(ctx, "claude", args...)
cmd.Stdin = bytes.NewBufferString(fullPrompt)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
select {
case c.streamChan <- fmt.Sprintf("[Error: %v]", err):
case <-ctx.Done():
}
c.mu.Lock()
if len(c.messages) > 0 {
c.messages = c.messages[:len(c.messages)-1]
}
c.mu.Unlock()
return
}
// Parse and send response
responseText, err := parseJSONResponse(stdout.String())
if err != nil {
select {
case c.streamChan <- fmt.Sprintf("[Error: %v]", err):
case <-ctx.Done():
}
c.mu.Lock()
if len(c.messages) > 0 {
c.messages = c.messages[:len(c.messages)-1]
}
c.mu.Unlock()
return
}
// Send response as a single chunk (CLI doesn't truly stream)
select {
case c.streamChan <- responseText:
case <-ctx.Done():
return
}
// Update state
c.mu.Lock()
c.messages = append(c.messages, Message{Role: "assistant", Content: responseText})
c.lastTokens = 0
c.mu.Unlock()
}()
return nil
}
// ReadStreamChunk reads the next chunk from the stream
func (c *CLIClient) ReadStreamChunk() (string, bool) {
c.mu.RLock()
streamChan := c.streamChan
c.mu.RUnlock()
if streamChan == nil {
return "", false
}
chunk, ok := <-streamChan
return chunk, ok
}
// IsStreaming returns whether a stream is currently in progress
func (c *CLIClient) IsStreaming() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.streaming
}
// WaitStream waits for the current stream to complete
func (c *CLIClient) WaitStream() {
c.mu.RLock()
done := c.streamDone
c.mu.RUnlock()
if done != nil {
<-done
}
}