diff --git a/cmd/ask.go b/cmd/ask.go index 5b33559..0beb819 100644 --- a/cmd/ask.go +++ b/cmd/ask.go @@ -14,6 +14,7 @@ import ( "github.com/bgdnvk/clanker/internal/aws" "github.com/bgdnvk/clanker/internal/azure" "github.com/bgdnvk/clanker/internal/backend" + "github.com/bgdnvk/clanker/internal/claudecode" "github.com/bgdnvk/clanker/internal/cloudflare" cfanalytics "github.com/bgdnvk/clanker/internal/cloudflare/analytics" cfdns "github.com/bgdnvk/clanker/internal/cloudflare/dns" @@ -126,10 +127,12 @@ Examples: agentName, _ := cmd.Flags().GetString("agent") if agentName == "hermes" { return handleHermesQuery(context.Background(), question, profile, debug) + } else if agentName == "claude-code" { + return handleClaudeCodeQuery(context.Background(), question, profile, debug) } else if isGitHubCodingAgent(agentName) { selectedGitHubCodingAgent = agentName } else if agentName != "" { - return fmt.Errorf("unknown agent: %s (available: hermes, copilot, codex, claude)", agentName) + return fmt.Errorf("unknown agent: %s (available: hermes, claude-code, copilot, codex, claude)", agentName) } // Handle apply mode (independent of maker mode) @@ -1188,7 +1191,7 @@ func init() { askCmd.Flags().Bool("apply", false, "Apply an approved maker plan (reads from stdin unless --plan-file is provided)") askCmd.Flags().String("plan-file", "", "Optional path to maker plan JSON file for --apply") askCmd.Flags().Bool("route-only", false, "Return routing decision as JSON without executing (for backend integration)") - askCmd.Flags().String("agent", "", "Use a specific agent to handle the query (e.g., hermes, copilot, codex, claude)") + askCmd.Flags().String("agent", "", "Use a specific agent to handle the query (e.g., hermes, claude-code, copilot, codex, claude)") askCmd.Flags().String("github-coding-agent-model", "", "Override the Copilot CLI model used for GitHub coding-agent delegation") } @@ -2774,6 +2777,84 @@ func handleHermesQuery(ctx context.Context, question string, profile string, deb return nil } +// handleClaudeCodeQuery delegates a question to the locally installed Claude Code CLI. +// When an AWS profile is available, it gathers infrastructure context first. +func handleClaudeCodeQuery(ctx context.Context, question string, profile string, debug bool) error { + version, err := claudecode.CheckAvailable() + if err != nil { + return err + } + if debug { + fmt.Fprintf(os.Stderr, "[claude-code] version: %s\n", version) + } + + runner := claudecode.NewRunner(debug) + + // Gather AWS infrastructure context if a profile is available. + prompt := question + targetProfile := profile + if targetProfile == "" { + defaultEnv := viper.GetString("infra.default_environment") + if defaultEnv == "" { + defaultEnv = "dev" + } + targetProfile = viper.GetString(fmt.Sprintf("infra.aws.environments.%s.profile", defaultEnv)) + if targetProfile == "" { + targetProfile = viper.GetString("aws.default_profile") + } + } + + if targetProfile != "" { + if debug { + fmt.Fprintf(os.Stderr, "[claude-code] gathering AWS context with profile %s\n", targetProfile) + } + awsClient, err := aws.NewClientWithProfileAndDebug(ctx, targetProfile, debug) + if err == nil { + awsContext, err := awsClient.GetRelevantContext(ctx, question) + if err == nil && strings.TrimSpace(awsContext) != "" { + prompt = fmt.Sprintf("Here is the current AWS infrastructure context:\n\n%s\n\nUser question: %s", awsContext, question) + } else if debug && err != nil { + fmt.Fprintf(os.Stderr, "[claude-code] warning: failed to get AWS context: %v\n", err) + } + } else if debug { + fmt.Fprintf(os.Stderr, "[claude-code] warning: failed to create AWS client: %v\n", err) + } + } + + events, err := runner.Ask(ctx, prompt) + if err != nil { + return fmt.Errorf("claude-code agent error: %w", err) + } + + hadDelta := false + for event := range events { + switch { + case event.Error != nil: + return fmt.Errorf("claude-code agent error: %w", event.Error) + case event.Text != "": + fmt.Print(event.Text) + hadDelta = true + case event.ToolCall != nil: + if debug { + fmt.Fprintf(os.Stderr, "\n[tool: %s]\n", event.ToolCall.Name) + } + case event.Thought != "": + if debug { + fmt.Fprintf(os.Stderr, "\n[thinking: %s]\n", event.Thought) + } + case event.Final != nil: + if !hadDelta && event.Final.Text != "" { + fmt.Print(event.Final.Text) + } + if debug { + fmt.Fprintf(os.Stderr, "\n[duration: %dms, cost: $%.4f]\n", event.Final.DurationMS, event.Final.CostUSD) + } + } + } + fmt.Println() + return nil +} + // buildHermesEnv resolves clanker's API keys, AI provider, and hermes config // into environment variables for the bridge subprocess. func buildHermesEnv() []string { diff --git a/cmd/talk.go b/cmd/talk.go index 3f7027a..3e14ee8 100644 --- a/cmd/talk.go +++ b/cmd/talk.go @@ -10,6 +10,7 @@ import ( "syscall" "github.com/bgdnvk/clanker/internal/clankercloud" + "github.com/bgdnvk/clanker/internal/claudecode" "github.com/bgdnvk/clanker/internal/hermes" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -25,7 +26,8 @@ naturally. Type 'exit' or 'quit' to end the session, or press Ctrl+D. Examples: clanker talk - clanker talk --agent hermes`, + clanker talk --agent hermes + clanker talk --agent claude-code`, RunE: func(cmd *cobra.Command, args []string) error { agentName, _ := cmd.Flags().GetString("agent") debug := viper.GetBool("debug") @@ -37,8 +39,10 @@ Examples: switch agentName { case "hermes": return runHermesTalk(cmd.Context(), debug) + case "claude-code": + return runClaudeCodeTalk(cmd.Context(), debug) default: - return fmt.Errorf("unknown agent: %s (available: hermes)", agentName) + return fmt.Errorf("unknown agent: %s (available: hermes, claude-code)", agentName) } }, } @@ -167,7 +171,108 @@ func handleClankerCloudTalk(ctx context.Context, question string, debug bool) (b return true, nil } +func runClaudeCodeTalk(parentCtx context.Context, debug bool) error { + version, err := claudecode.CheckAvailable() + if err != nil { + return err + } + + if debug { + fmt.Fprintf(os.Stderr, "[claude-code] version: %s\n", version) + } + + runner := claudecode.NewRunner(debug) + + ctx, cancel := context.WithCancel(parentCtx) + defer cancel() + + if err := runner.StartTalk(ctx); err != nil { + return fmt.Errorf("failed to start claude-code agent: %w", err) + } + defer runner.Stop() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + for range sigCh { + fmt.Fprintln(os.Stderr, "\nInterrupted. Type 'exit' to quit.") + } + }() + defer signal.Stop(sigCh) + + fmt.Println("Claude Code Agent (interactive mode)") + fmt.Println("Type 'exit' or 'quit' to end the session.") + fmt.Println() + + scanner := bufio.NewScanner(os.Stdin) + for { + fmt.Print("you> ") + if !scanner.Scan() { + break + } + + input := strings.TrimSpace(scanner.Text()) + if input == "" { + continue + } + + lower := strings.ToLower(input) + if lower == "exit" || lower == "quit" || lower == "/quit" || lower == "/exit" { + fmt.Println("Goodbye.") + break + } + + routedAgent, _ := determineRoutingDecision(input) + if routedAgent == "clanker-cloud" { + if handled, err := handleClankerCloudTalk(ctx, input, debug); handled { + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + } + fmt.Println() + continue + } + } + + events, err := runner.Prompt(ctx, input) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + continue + } + + fmt.Print("claude-code> ") + hadDelta := false + for event := range events { + switch { + case event.Error != nil: + fmt.Fprintf(os.Stderr, "\nError: %v\n", event.Error) + case event.Text != "": + fmt.Print(event.Text) + hadDelta = true + case event.ToolCall != nil: + if debug { + fmt.Fprintf(os.Stderr, "\n[tool: %s]\n", event.ToolCall.Name) + } + case event.Thought != "": + if debug { + fmt.Fprintf(os.Stderr, "\n[thinking: %s]\n", event.Thought) + } + case event.Final != nil: + if !hadDelta && event.Final.Text != "" { + fmt.Print(event.Final.Text) + } + if debug { + fmt.Fprintf(os.Stderr, "\n[duration: %dms, cost: $%.4f]\n", event.Final.DurationMS, event.Final.CostUSD) + } + } + } + fmt.Println() + fmt.Println() + } + + return nil +} + func init() { rootCmd.AddCommand(talkCmd) - talkCmd.Flags().String("agent", "", "Agent to use for conversation (default: hermes)") + talkCmd.Flags().String("agent", "", "Agent to use for conversation (default: hermes, options: hermes, claude-code)") } diff --git a/internal/claudecode/protocol.go b/internal/claudecode/protocol.go new file mode 100644 index 0000000..3493ecf --- /dev/null +++ b/internal/claudecode/protocol.go @@ -0,0 +1,78 @@ +package claudecode + +import "encoding/json" + +// StreamEvent represents a single line of output from claude --output-format stream-json. +type StreamEvent struct { + Type string `json:"type"` + Subtype string `json:"subtype,omitempty"` + + // Present when Type == "system" && Subtype == "init" + SessionID string `json:"session_id,omitempty"` + Model string `json:"model,omitempty"` + Tools []string `json:"tools,omitempty"` + + // Present when Type == "assistant" + Message *AssistantMessage `json:"message,omitempty"` + + // Present when Type == "result" + Result string `json:"result,omitempty"` + IsError bool `json:"is_error,omitempty"` + StopReason string `json:"stop_reason,omitempty"` + DurationMS int `json:"duration_ms,omitempty"` + TotalCost float64 `json:"total_cost_usd,omitempty"` +} + +// AssistantMessage is the message object inside an assistant event. +type AssistantMessage struct { + ID string `json:"id,omitempty"` + Role string `json:"role,omitempty"` + Content []ContentBlock `json:"content,omitempty"` + Usage *json.RawMessage `json:"usage,omitempty"` +} + +// ContentBlock represents a single block of content in a message. +type ContentBlock struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Input any `json:"input,omitempty"` +} + +// ResultEvent is the final event from a claude --output-format json run. +type ResultEvent struct { + Type string `json:"type"` + Subtype string `json:"subtype"` + IsError bool `json:"is_error"` + Result string `json:"result"` + StopReason string `json:"stop_reason,omitempty"` + DurationMS int `json:"duration_ms,omitempty"` + TotalCost float64 `json:"total_cost_usd,omitempty"` + SessionID string `json:"session_id,omitempty"` +} + +// Event is the normalized event type consumed by callers, matching the +// pattern established by the hermes package. +type Event struct { + Type string + Text string + ToolCall *ToolCallInfo + Thought string + Final *FinalResult + Error error +} + +// ToolCallInfo holds details about a tool invocation by the agent. +type ToolCallInfo struct { + Name string + Input string +} + +// FinalResult holds the completed response from the agent. +type FinalResult struct { + Text string + SessionID string + DurationMS int + CostUSD float64 +} diff --git a/internal/claudecode/runner.go b/internal/claudecode/runner.go new file mode 100644 index 0000000..08256ae --- /dev/null +++ b/internal/claudecode/runner.go @@ -0,0 +1,454 @@ +package claudecode + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "strings" + "sync" + "time" +) + +// Runner manages invocations of the claude CLI binary. +// It supports both single-shot (Ask) and interactive (Talk) modes. +type Runner struct { + claudePath string + model string + debug bool + + // Talk mode state + cmd *exec.Cmd + stdin io.WriteCloser + stdout io.ReadCloser + scanner *bufio.Scanner + sessionID string + mu sync.Mutex + running bool +} + +// FindClaudePath locates the claude CLI binary. Returns the full path +// or an error with installation instructions. +func FindClaudePath() (string, error) { + path, err := exec.LookPath("claude") + if err != nil { + return "", fmt.Errorf( + "claude CLI not found in PATH.\n" + + "Install it from: https://docs.anthropic.com/en/docs/claude-code\n" + + " npm install -g @anthropic-ai/claude-code\n" + + "Then verify with: claude --version") + } + return path, nil +} + +// CheckAvailable verifies the claude CLI is installed and returns its version. +func CheckAvailable() (string, error) { + path, err := FindClaudePath() + if err != nil { + return "", err + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + out, err := exec.CommandContext(ctx, path, "--version").CombinedOutput() + if err != nil { + return "", fmt.Errorf("claude CLI found at %s but failed to get version: %w", path, err) + } + + version := strings.TrimSpace(string(out)) + return version, nil +} + +// NewRunner creates a runner for the claude CLI. The binary path is resolved +// lazily on first use if not already set. +func NewRunner(debug bool) *Runner { + return &Runner{ + debug: debug, + } +} + +// SetModel overrides the model for claude CLI invocations. +func (r *Runner) SetModel(model string) { + r.model = model +} + +// resolve ensures the claude binary path is known. +func (r *Runner) resolve() error { + if r.claudePath != "" { + return nil + } + path, err := FindClaudePath() + if err != nil { + return err + } + r.claudePath = path + return nil +} + +// Ask sends a single question and returns a channel of streaming events. +// The channel is closed when the response is complete or an error occurs. +func (r *Runner) Ask(ctx context.Context, prompt string) (<-chan Event, error) { + if err := r.resolve(); err != nil { + return nil, err + } + + args := []string{ + "-p", prompt, + "--output-format", "stream-json", + "--verbose", + "--no-session-persistence", + } + + if r.model != "" { + args = append(args, "--model", r.model) + } + + cmd := exec.CommandContext(ctx, r.claudePath, args...) + cmd.Env = os.Environ() + cmd.Stderr = nil // we parse stderr via combined with stdout below + + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("failed to create stdout pipe: %w", err) + } + + // Send stderr to our stderr so the user sees diagnostics in debug mode. + if r.debug { + cmd.Stderr = os.Stderr + } + + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("failed to start claude CLI: %w", err) + } + + if r.debug { + fmt.Fprintf(os.Stderr, "[claude-code] process started (pid %d)\n", cmd.Process.Pid) + } + + ch := make(chan Event, 64) + + go func() { + defer close(ch) + defer func() { + _ = cmd.Wait() + }() + + scanner := bufio.NewScanner(stdoutPipe) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 1024*1024) + + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + + var raw StreamEvent + if err := json.Unmarshal(line, &raw); err != nil { + if r.debug { + fmt.Fprintf(os.Stderr, "[claude-code] skipping unparseable line: %s\n", string(line)) + } + continue + } + + events := r.parseStreamEvent(&raw) + for _, ev := range events { + select { + case ch <- ev: + case <-ctx.Done(): + ch <- Event{Error: ctx.Err()} + return + } + } + } + + if err := scanner.Err(); err != nil { + ch <- Event{Error: fmt.Errorf("claude CLI read error: %w", err)} + } + }() + + return ch, nil +} + +// AskSync sends a prompt and blocks until the full response is available. +func (r *Runner) AskSync(ctx context.Context, prompt string) (string, error) { + ch, err := r.Ask(ctx, prompt) + if err != nil { + return "", err + } + + var sb strings.Builder + for event := range ch { + if event.Error != nil { + return "", event.Error + } + if event.Text != "" { + sb.WriteString(event.Text) + } + if event.Final != nil && sb.Len() == 0 { + return event.Final.Text, nil + } + } + + return sb.String(), nil +} + +// StartTalk launches an interactive session using claude's stdin streaming. +// The process stays alive across multiple Prompt() calls. +func (r *Runner) StartTalk(ctx context.Context) error { + if err := r.resolve(); err != nil { + return err + } + + args := []string{ + "--output-format", "stream-json", + "--input-format", "stream-json", + "--verbose", + } + + if r.model != "" { + args = append(args, "--model", r.model) + } + + r.cmd = exec.CommandContext(ctx, r.claudePath, args...) + r.cmd.Env = os.Environ() + + if r.debug { + r.cmd.Stderr = os.Stderr + } + + var err error + r.stdin, err = r.cmd.StdinPipe() + if err != nil { + return fmt.Errorf("failed to get stdin pipe: %w", err) + } + + r.stdout, err = r.cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("failed to get stdout pipe: %w", err) + } + + r.scanner = bufio.NewScanner(r.stdout) + buf := make([]byte, 0, 64*1024) + r.scanner.Buffer(buf, 1024*1024) + + if err := r.cmd.Start(); err != nil { + return fmt.Errorf("failed to start claude CLI: %w", err) + } + r.running = true + + if r.debug { + fmt.Fprintf(os.Stderr, "[claude-code] interactive session started (pid %d)\n", r.cmd.Process.Pid) + } + + // Read the init event to get the session ID. + if r.scanner.Scan() { + var raw StreamEvent + if err := json.Unmarshal(r.scanner.Bytes(), &raw); err == nil { + if raw.Type == "system" && raw.Subtype == "init" { + r.sessionID = raw.SessionID + if r.debug { + fmt.Fprintf(os.Stderr, "[claude-code] session: %s, model: %s\n", raw.SessionID, raw.Model) + } + } + } + } + + return nil +} + +// Prompt sends a message in an interactive talk session and returns streaming events. +func (r *Runner) Prompt(ctx context.Context, text string) (<-chan Event, error) { + r.mu.Lock() + if !r.running { + r.mu.Unlock() + return nil, fmt.Errorf("claude-code session not running") + } + + // Claude Code stream-json input format: one JSON object per line. + msg := map[string]string{ + "type": "user", + "content": text, + "session_id": r.sessionID, + } + data, err := json.Marshal(msg) + if err != nil { + r.mu.Unlock() + return nil, fmt.Errorf("marshal prompt: %w", err) + } + + _, err = r.stdin.Write(append(data, '\n')) + r.mu.Unlock() + if err != nil { + return nil, fmt.Errorf("write to claude-code stdin: %w", err) + } + + ch := make(chan Event, 64) + + go func() { + defer close(ch) + + for { + select { + case <-ctx.Done(): + ch <- Event{Error: ctx.Err()} + return + default: + } + + if !r.scanner.Scan() { + if err := r.scanner.Err(); err != nil { + ch <- Event{Error: fmt.Errorf("claude-code read error: %w", err)} + } else { + ch <- Event{Error: fmt.Errorf("claude-code process exited")} + } + r.running = false + return + } + + var raw StreamEvent + if err := json.Unmarshal(r.scanner.Bytes(), &raw); err != nil { + if r.debug { + fmt.Fprintf(os.Stderr, "[claude-code] skipping bad line: %s\n", r.scanner.Text()) + } + continue + } + + events := r.parseStreamEvent(&raw) + for _, ev := range events { + ch <- ev + } + + // A result event means the turn is complete. + if raw.Type == "result" { + return + } + } + }() + + return ch, nil +} + +// Stop gracefully shuts down the interactive session. +func (r *Runner) Stop() error { + if !r.running { + return nil + } + r.running = false + + if r.stdin != nil { + r.stdin.Close() + } + + if r.cmd == nil || r.cmd.Process == nil { + return nil + } + + done := make(chan error, 1) + go func() { + done <- r.cmd.Wait() + }() + + select { + case <-done: + return nil + case <-time.After(5 * time.Second): + if r.debug { + fmt.Fprintf(os.Stderr, "[claude-code] process did not exit in 5s, sending interrupt\n") + } + _ = r.cmd.Process.Signal(os.Interrupt) + } + + select { + case <-done: + return nil + case <-time.After(2 * time.Second): + if r.debug { + fmt.Fprintf(os.Stderr, "[claude-code] process still running, killing\n") + } + _ = r.cmd.Process.Kill() + <-done + return nil + } +} + +// IsRunning reports whether the interactive session is alive. +func (r *Runner) IsRunning() bool { + return r.running +} + +// parseStreamEvent converts a raw claude CLI stream event into zero or more +// normalized Event values. +func (r *Runner) parseStreamEvent(raw *StreamEvent) []Event { + switch raw.Type { + case "system": + // Init event, skip (already handled in StartTalk). + return nil + + case "assistant": + if raw.Message == nil || len(raw.Message.Content) == 0 { + return nil + } + var events []Event + for _, block := range raw.Message.Content { + switch block.Type { + case "text": + if block.Text != "" { + events = append(events, Event{ + Type: "message_delta", + Text: block.Text, + }) + } + case "tool_use": + inputStr := "" + if block.Input != nil { + if b, err := json.Marshal(block.Input); err == nil { + inputStr = string(b) + } + } + events = append(events, Event{ + Type: "tool_call", + ToolCall: &ToolCallInfo{ + Name: block.Name, + Input: inputStr, + }, + }) + case "tool_result": + // Tool results are internal, skip for display purposes. + case "thinking": + if block.Text != "" { + events = append(events, Event{ + Type: "thought", + Thought: block.Text, + }) + } + } + } + return events + + case "result": + return []Event{{ + Type: "final", + Final: &FinalResult{ + Text: raw.Result, + SessionID: raw.SessionID, + DurationMS: raw.DurationMS, + CostUSD: raw.TotalCost, + }, + }} + + case "rate_limit_event": + // Informational, skip. + return nil + + default: + if r.debug { + fmt.Fprintf(os.Stderr, "[claude-code] unhandled event type: %s\n", raw.Type) + } + return nil + } +} diff --git a/internal/claudecode/runner_test.go b/internal/claudecode/runner_test.go new file mode 100644 index 0000000..9dfee5e --- /dev/null +++ b/internal/claudecode/runner_test.go @@ -0,0 +1,175 @@ +package claudecode + +import ( + "encoding/json" + "testing" +) + +func TestFindClaudePath(t *testing.T) { + path, err := FindClaudePath() + if err != nil { + t.Skipf("claude CLI not installed, skipping: %v", err) + } + if path == "" { + t.Fatal("FindClaudePath returned empty path with no error") + } +} + +func TestCheckAvailable(t *testing.T) { + version, err := CheckAvailable() + if err != nil { + t.Skipf("claude CLI not available, skipping: %v", err) + } + if version == "" { + t.Fatal("CheckAvailable returned empty version") + } + t.Logf("claude CLI version: %s", version) +} + +func TestParseStreamEvent_System(t *testing.T) { + r := &Runner{debug: false} + raw := &StreamEvent{ + Type: "system", + Subtype: "init", + } + events := r.parseStreamEvent(raw) + if len(events) != 0 { + t.Fatalf("expected 0 events for system init, got %d", len(events)) + } +} + +func TestParseStreamEvent_AssistantText(t *testing.T) { + r := &Runner{debug: false} + raw := &StreamEvent{ + Type: "assistant", + Message: &AssistantMessage{ + Content: []ContentBlock{ + {Type: "text", Text: "Hello world"}, + }, + }, + } + events := r.parseStreamEvent(raw) + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + if events[0].Type != "message_delta" { + t.Errorf("expected type message_delta, got %s", events[0].Type) + } + if events[0].Text != "Hello world" { + t.Errorf("expected text 'Hello world', got %q", events[0].Text) + } +} + +func TestParseStreamEvent_AssistantToolUse(t *testing.T) { + r := &Runner{debug: false} + raw := &StreamEvent{ + Type: "assistant", + Message: &AssistantMessage{ + Content: []ContentBlock{ + {Type: "tool_use", Name: "Bash", Input: map[string]any{"command": "ls"}}, + }, + }, + } + events := r.parseStreamEvent(raw) + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + if events[0].Type != "tool_call" { + t.Errorf("expected type tool_call, got %s", events[0].Type) + } + if events[0].ToolCall == nil { + t.Fatal("expected ToolCall to be set") + } + if events[0].ToolCall.Name != "Bash" { + t.Errorf("expected tool name Bash, got %s", events[0].ToolCall.Name) + } +} + +func TestParseStreamEvent_Result(t *testing.T) { + r := &Runner{debug: false} + raw := &StreamEvent{ + Type: "result", + Result: "final answer", + SessionID: "session-123", + DurationMS: 1500, + TotalCost: 0.05, + } + events := r.parseStreamEvent(raw) + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + if events[0].Type != "final" { + t.Errorf("expected type final, got %s", events[0].Type) + } + if events[0].Final.Text != "final answer" { + t.Errorf("expected final text 'final answer', got %q", events[0].Final.Text) + } + if events[0].Final.CostUSD != 0.05 { + t.Errorf("expected cost 0.05, got %f", events[0].Final.CostUSD) + } +} + +func TestParseStreamEvent_RateLimit(t *testing.T) { + r := &Runner{debug: false} + raw := &StreamEvent{Type: "rate_limit_event"} + events := r.parseStreamEvent(raw) + if len(events) != 0 { + t.Fatalf("expected 0 events for rate_limit_event, got %d", len(events)) + } +} + +func TestParseStreamEvent_MultipleBlocks(t *testing.T) { + r := &Runner{debug: false} + raw := &StreamEvent{ + Type: "assistant", + Message: &AssistantMessage{ + Content: []ContentBlock{ + {Type: "thinking", Text: "Let me think..."}, + {Type: "text", Text: "Here is my answer"}, + {Type: "tool_use", Name: "Read", Input: map[string]any{"file": "foo.go"}}, + }, + }, + } + events := r.parseStreamEvent(raw) + if len(events) != 3 { + t.Fatalf("expected 3 events, got %d", len(events)) + } + if events[0].Type != "thought" { + t.Errorf("expected thought, got %s", events[0].Type) + } + if events[1].Type != "message_delta" { + t.Errorf("expected message_delta, got %s", events[1].Type) + } + if events[2].Type != "tool_call" { + t.Errorf("expected tool_call, got %s", events[2].Type) + } +} + +func TestStreamEventJSON(t *testing.T) { + // Test parsing of a real stream-json line from claude CLI. + line := `{"type":"result","subtype":"success","is_error":false,"duration_ms":2393,"num_turns":1,"result":"hello","stop_reason":"end_turn","session_id":"abc-123","total_cost_usd":0.113}` + var raw StreamEvent + if err := json.Unmarshal([]byte(line), &raw); err != nil { + t.Fatalf("failed to parse result event: %v", err) + } + if raw.Type != "result" { + t.Errorf("expected type result, got %s", raw.Type) + } + if raw.Result != "hello" { + t.Errorf("expected result 'hello', got %q", raw.Result) + } + if raw.SessionID != "abc-123" { + t.Errorf("expected session_id 'abc-123', got %q", raw.SessionID) + } +} + +func TestResultEventJSON(t *testing.T) { + line := `{"type":"result","subtype":"success","is_error":false,"result":"test output","duration_ms":500,"total_cost_usd":0.01,"session_id":"s1"}` + var re ResultEvent + if err := json.Unmarshal([]byte(line), &re); err != nil { + t.Fatalf("failed to parse: %v", err) + } + if re.Result != "test output" { + t.Errorf("expected 'test output', got %q", re.Result) + } +}