package main import ( "bufio" "encoding/json" "fmt" "io" "os" "os/exec" "strings" "sync" ) // CLIEvent represents a parsed NDJSON event from claude CLI stdout. type CLIEvent struct { Type string `json:"type"` Subtype string `json:"subtype,omitempty"` // For assistant message events Message *CLIMessage `json:"message,omitempty"` // For content_block_delta Index int `json:"index,omitempty"` Delta *CLIDelta `json:"delta,omitempty"` // For result events Result *CLIResult `json:"result,omitempty"` } type CLIMessage struct { Role string `json:"role,omitempty"` Content []CLIContent `json:"content,omitempty"` } type CLIContent struct { Type string `json:"type"` Text string `json:"text,omitempty"` Name string `json:"name,omitempty"` Input any `json:"input,omitempty"` ID string `json:"id,omitempty"` } type CLIDelta struct { Type string `json:"type"` Text string `json:"text,omitempty"` } type CLIResult struct { Duration float64 `json:"duration_ms,omitempty"` NumTurns int `json:"num_turns,omitempty"` CostUSD float64 `json:"cost_usd,omitempty"` SessionID string `json:"session_id,omitempty"` } // CLIProcess manages a running claude CLI process. type CLIProcess struct { cmd *exec.Cmd stdin io.WriteCloser stdout io.ReadCloser stderr io.ReadCloser Events chan CLIEvent Errors chan error done chan struct{} mu sync.Mutex } // SpawnCLI starts a new claude CLI process for the given project directory. func SpawnCLI(projectDir string) (*CLIProcess, error) { args := []string{ "-p", "--output-format", "stream-json", "--verbose", } cmd := exec.Command("claude", args...) cmd.Dir = projectDir // Filter CLAUDECODE env var to prevent nested session detection env := filterEnv(os.Environ(), "CLAUDECODE") cmd.Env = env stdin, err := cmd.StdinPipe() if err != nil { return nil, fmt.Errorf("stdin pipe: %w", err) } stdout, err := cmd.StdoutPipe() if err != nil { return nil, fmt.Errorf("stdout pipe: %w", err) } stderr, err := cmd.StderrPipe() if err != nil { return nil, fmt.Errorf("stderr pipe: %w", err) } if err := cmd.Start(); err != nil { return nil, fmt.Errorf("start claude: %w", err) } cp := &CLIProcess{ cmd: cmd, stdin: stdin, stdout: stdout, stderr: stderr, Events: make(chan CLIEvent, 100), Errors: make(chan error, 10), done: make(chan struct{}), } go cp.readOutput() go cp.readErrors() return cp, nil } // Send writes a message to the claude CLI process stdin. func (cp *CLIProcess) Send(message string) error { cp.mu.Lock() defer cp.mu.Unlock() msg := strings.TrimSpace(message) + "\n" _, err := io.WriteString(cp.stdin, msg) return err } // Close terminates the claude CLI process. func (cp *CLIProcess) Close() error { cp.mu.Lock() defer cp.mu.Unlock() cp.stdin.Close() return cp.cmd.Process.Kill() } // Done returns a channel that's closed when the process exits. func (cp *CLIProcess) Done() <-chan struct{} { return cp.done } func (cp *CLIProcess) readOutput() { defer close(cp.done) defer close(cp.Events) scanner := bufio.NewScanner(cp.stdout) scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024) // 1MB buffer for scanner.Scan() { line := scanner.Text() if line == "" { continue } var event CLIEvent if err := json.Unmarshal([]byte(line), &event); err != nil { cp.Errors <- fmt.Errorf("parse event: %w (line: %s)", err, truncate(line, 200)) continue } cp.Events <- event } if err := scanner.Err(); err != nil { cp.Errors <- fmt.Errorf("scanner: %w", err) } } func (cp *CLIProcess) readErrors() { scanner := bufio.NewScanner(cp.stderr) for scanner.Scan() { line := scanner.Text() if line != "" { cp.Errors <- fmt.Errorf("stderr: %s", line) } } } // filterEnv returns a copy of env with the named variable removed. func filterEnv(env []string, name string) []string { prefix := name + "=" result := make([]string, 0, len(env)) for _, e := range env { if !strings.HasPrefix(e, prefix) { result = append(result, e) } } return result } func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] + "..." }