Files

293 lines
8.9 KiB
Go

package providers
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// Chat runs the CLI synchronously and returns the final response.
func (p *ClaudeCLIProvider) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error) {
systemPrompt, userMsg, images := extractFromMessages(req.Messages)
sessionKey := extractStringOpt(req.Options, OptSessionKey)
model := req.Model
if model == "" {
model = p.defaultModel
}
if err := validateCLIModel(model); err != nil {
return nil, err
}
unlock := p.lockSession(sessionKey)
defer unlock()
workDir := p.ensureWorkDir(sessionKey)
if systemPrompt != "" {
p.writeClaudeMD(workDir, systemPrompt)
}
cliSessionID := deriveSessionUUID(sessionKey)
disableTools := extractBoolOpt(req.Options, OptDisableTools)
bc := bridgeContextFromOpts(req.Options)
mcpPath := p.resolveMCPConfigPath(ctx, sessionKey, bc)
// Claude CLI >= v2.1.87 requires matching input/output formats.
// When images are present, buildArgs adds --input-format stream-json,
// so output format must also be stream-json.
outputFmt := "json"
if len(images) > 0 {
outputFmt = "stream-json"
}
effortLevel := extractStringOpt(req.Options, OptThinkingLevel)
allowedToolNames := extractStringSliceOpt(req.Options, OptAllowedToolNames)
args := p.buildArgs(model, workDir, mcpPath, cliSessionID, outputFmt, len(images) > 0, disableTools, effortLevel, allowedToolNames)
var stdin *bytes.Reader
if len(images) > 0 {
stdin = buildStreamJSONInput(userMsg, images)
} else {
args = append(args, "--", userMsg)
}
for {
cmd := exec.CommandContext(ctx, p.cliPath, args...)
cmd.Dir = workDir
cmd.Env = filterCLIEnv(os.Environ())
if effortLevel != "" && effortLevel != "off" {
// Explicit --effort flag takes precedence; drop env to avoid ambiguity.
cmd.Env = removeEnvKey(cmd.Env, "CLAUDE_CODE_EFFORT_LEVEL")
}
if stdin != nil {
cmd.Stdin = stdin
}
var stderr bytes.Buffer
cmd.Stderr = &stderr
slog.Debug("claude-cli exec", "cmd", fmt.Sprintf("%s %s", p.cliPath, strings.Join(args, " ")), "workdir", workDir)
output, err := cmd.Output()
if err != nil {
if tool, retry := p.noteInvalidDisallowedTool(stderr.String()); retry {
slog.Warn("claude-cli: retrying without unsupported --disallowedTools rule", "tool", tool)
args = p.buildArgs(model, workDir, mcpPath, cliSessionID, outputFmt, len(images) > 0, disableTools, effortLevel, allowedToolNames)
if len(images) == 0 {
args = append(args, "--", userMsg)
}
if len(images) > 0 {
stdin = buildStreamJSONInput(userMsg, images)
}
continue
}
return nil, fmt.Errorf("claude-cli: %w (stderr: %s)", err, stderr.String())
}
return parseJSONResponse(output)
}
}
// ChatStream runs the CLI with stream-json output, calling onChunk for each text delta.
func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ChatRequest, onChunk func(StreamChunk)) (*ChatResponse, error) {
systemPrompt, userMsg, images := extractFromMessages(req.Messages)
sessionKey := extractStringOpt(req.Options, OptSessionKey)
model := req.Model
if model == "" {
model = p.defaultModel
}
if err := validateCLIModel(model); err != nil {
return nil, err
}
slog.Debug("claude-cli: acquiring session lock", "session_key", sessionKey)
unlock := p.lockSession(sessionKey)
slog.Debug("claude-cli: session lock acquired", "session_key", sessionKey)
defer func() {
unlock()
slog.Debug("claude-cli: session lock released", "session_key", sessionKey)
}()
workDir := p.ensureWorkDir(sessionKey)
if systemPrompt != "" {
p.writeClaudeMD(workDir, systemPrompt)
}
cliSessionID := deriveSessionUUID(sessionKey)
disableTools := extractBoolOpt(req.Options, OptDisableTools)
bc := bridgeContextFromOpts(req.Options)
mcpPath := p.resolveMCPConfigPath(ctx, sessionKey, bc)
effortLevel := extractStringOpt(req.Options, OptThinkingLevel)
allowedToolNames := extractStringSliceOpt(req.Options, OptAllowedToolNames)
args := p.buildArgs(model, workDir, mcpPath, cliSessionID, "stream-json", len(images) > 0, disableTools, effortLevel, allowedToolNames)
var stdin *bytes.Reader
if len(images) > 0 {
stdin = buildStreamJSONInput(userMsg, images)
} else {
args = append(args, "--", userMsg)
}
for {
cmd := exec.CommandContext(ctx, p.cliPath, args...)
cmd.WaitDelay = 5 * time.Second // force-close pipes if process lingers after kill
cmd.Dir = workDir
cmd.Env = filterCLIEnv(os.Environ())
if effortLevel != "" && effortLevel != "off" {
cmd.Env = removeEnvKey(cmd.Env, "CLAUDE_CODE_EFFORT_LEVEL")
}
if stdin != nil {
cmd.Stdin = stdin
}
var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("claude-cli stdout pipe: %w", err)
}
fullCmd := fmt.Sprintf("%s %s", p.cliPath, strings.Join(args, " "))
slog.Debug("claude-cli stream exec", "cmd", fullCmd, "workdir", workDir)
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("claude-cli start: %w", err)
}
// Debug log file: only enabled when GOCLAW_DEBUG=1
var debugFile *os.File
if os.Getenv("GOCLAW_DEBUG") == "1" {
debugLogPath := filepath.Join(workDir, "cli-debug.log")
debugFile, _ = os.OpenFile(debugLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if debugFile != nil {
fmt.Fprintf(debugFile, "=== CMD: %s\n=== WORKDIR: %s\n=== TIME: %s\n\n", fullCmd, workDir, time.Now().Format(time.RFC3339))
defer debugFile.Close()
}
}
// Parse stream-json line-by-line
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, StdioScanBufInit), StdioScanBufMax)
var finalResp ChatResponse
var contentBuf strings.Builder
var streamErrMsg string // error message from stream-json result event
for scanner.Scan() {
if ctx.Err() != nil {
break // context cancelled (abort) → exit immediately
}
line := scanner.Bytes()
if len(line) == 0 {
continue
}
// Write raw line to debug log
if debugFile != nil {
fmt.Fprintf(debugFile, "%s\n", line)
}
var ev cliStreamEvent
if err := json.Unmarshal(line, &ev); err != nil {
slog.Debug("claude-cli: skip malformed stream line", "error", err)
continue
}
switch ev.Type {
case "assistant":
if ev.Message == nil {
continue
}
text, thinking := extractStreamContent(ev.Message)
if text != "" {
contentBuf.WriteString(text)
onChunk(StreamChunk{Content: text})
}
if thinking != "" {
onChunk(StreamChunk{Thinking: thinking})
}
case "result":
if ev.Result != "" {
finalResp.Content = ev.Result
} else {
finalResp.Content = contentBuf.String()
}
finalResp.FinishReason = "stop"
if ev.Subtype == "error" || ev.IsError {
finalResp.FinishReason = "error"
// Prefer ev.Error (result may be empty for usage/rate limit errors).
if ev.Error != "" {
streamErrMsg = ev.Error
} else if ev.Result != "" {
streamErrMsg = ev.Result
}
}
if ev.Usage != nil {
finalResp.Usage = &Usage{
PromptTokens: ev.Usage.InputTokens,
CompletionTokens: ev.Usage.OutputTokens,
TotalTokens: ev.Usage.InputTokens + ev.Usage.OutputTokens,
}
}
}
}
// Context cancelled (abort): best-effort reap (bounded by WaitDelay), then return.
if ctx.Err() != nil {
_ = cmd.Wait()
return nil, ctx.Err()
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("claude-cli: stream read error: %w", err)
}
if err := cmd.Wait(); err != nil {
if debugFile != nil {
fmt.Fprintf(debugFile, "\n=== STDERR:\n%s\n=== EXIT ERROR: %v\n", stderrBuf.String(), err)
}
// If we got partial content, return it with the error.
if finalResp.Content != "" {
return &finalResp, nil
}
stderrStr := strings.TrimSpace(stderrBuf.String())
if tool, retry := p.noteInvalidDisallowedTool(stderrStr); retry {
slog.Warn("claude-cli: retrying without unsupported --disallowedTools rule", "tool", tool)
args = p.buildArgs(model, workDir, mcpPath, cliSessionID, "stream-json", len(images) > 0, disableTools, effortLevel, allowedToolNames)
if len(images) == 0 {
args = append(args, "--", userMsg)
} else {
stdin = buildStreamJSONInput(userMsg, images)
}
continue
}
if stderrStr == "" && finalResp.FinishReason == "error" {
// claude-cli communicates API errors via stream-json stdout, not stderr.
if streamErrMsg != "" {
stderrStr = "stream: " + streamErrMsg
} else {
stderrStr = "stream error (no message)"
}
}
return nil, fmt.Errorf("claude-cli: %w (stderr: %s)", err, stderrStr)
}
if debugFile != nil && stderrBuf.Len() > 0 {
fmt.Fprintf(debugFile, "\n=== STDERR:\n%s\n", stderrBuf.String())
}
// Fallback if no "result" event was received
if finalResp.Content == "" {
finalResp.Content = contentBuf.String()
finalResp.FinishReason = "stop"
}
onChunk(StreamChunk{Done: true})
return &finalResp, nil
}
}