package cc import ( "context" "encoding/json" "errors" "fmt" "os" "os/exec" "strings" "time" ) // Image input (stream-json path) is not implemented; see spawnClaudeStreamed in TypeScript source for the extension point. const ( defaultTimeoutMs = 300_000 maxStdoutBytes = 10 * 1024 * 1024 ) // Opts configures a SpawnClaude call. type Opts struct { Prompt string TimeoutMs int // 0 → defaultTimeoutMs Cwd string // working directory for the claude process ProfilePath string // optional --settings profile applied to the run } // Result holds a successful SpawnClaude response. type Result struct { SessionID string ResultText string RawJSON string } // ClaudeError is returned when the claude process exits with a non-zero status. type ClaudeError struct { Msg string Stderr string } func (e *ClaudeError) Error() string { if e.Stderr != "" { return fmt.Sprintf("%s: %s", e.Msg, e.Stderr) } return e.Msg } // ClaudeTimeoutError wraps ClaudeError for timeout-specific failures. type ClaudeTimeoutError struct { ClaudeError } // Unwrap lets errors.As(err, *ClaudeError) also match a timeout, mirroring the TS IS-A relationship. func (e *ClaudeTimeoutError) Unwrap() error { return &e.ClaudeError } // RunClaude is the injectable command runner. Tests replace this to avoid needing // a real claude binary. The real implementation uses exec.CommandContext. var RunClaude func(ctx context.Context, cwd string, args []string) (stdout, stderr []byte, err error) = defaultRunClaude func defaultRunClaude(ctx context.Context, cwd string, args []string) ([]byte, []byte, error) { cmd := exec.CommandContext(ctx, "claude", args...) cmd.Env = filteredEnv() if cwd != "" { cmd.Dir = cwd } stdout, err := cmd.Output() if err != nil { var exitErr *exec.ExitError if errors.As(err, &exitErr) { return stdout, exitErr.Stderr, err } return nil, nil, err } return stdout, nil, nil } // filteredEnv returns os.Environ() minus ANTHROPIC_* and nested Claude guard vars. func filteredEnv() []string { blocked := map[string]bool{ "CLAUDECODE": true, "CLAUDE_CODE_ENTRYPOINT": true, "CLAUDE_CODE_SESSION_ID": true, "CLAUDE_CODE_SESSION": true, } env := os.Environ() out := make([]string, 0, len(env)) for _, kv := range env { key := kv if idx := strings.IndexByte(kv, '='); idx >= 0 { key = kv[:idx] } if strings.HasPrefix(key, "ANTHROPIC_") || blocked[key] { continue } out = append(out, kv) } return out } // parsedPayload is the shape claude emits for --output-format json. type parsedPayload struct { Result string `json:"result"` SessionID string `json:"session_id"` } // extractClaudePayload parses the stdout from claude. // It first tries a direct JSON parse; if that fails it scans lines from the end // looking for the last object that has both result and session_id (NDJSON tail). func extractClaudePayload(stdout string) (*parsedPayload, bool) { stdout = strings.TrimSpace(stdout) var p parsedPayload if err := json.Unmarshal([]byte(stdout), &p); err == nil && p.SessionID != "" { return &p, true } lines := strings.Split(stdout, "\n") for i := len(lines) - 1; i >= 0; i-- { line := strings.TrimSpace(lines[i]) if line == "" { continue } var candidate parsedPayload if err := json.Unmarshal([]byte(line), &candidate); err == nil && candidate.SessionID != "" { return &candidate, true } } return nil, false } // SpawnClaude runs claude with --output-format json and returns the parsed result. func SpawnClaude(ctx context.Context, opts Opts) (*Result, error) { timeoutMs := opts.TimeoutMs if timeoutMs <= 0 { timeoutMs = defaultTimeoutMs } tCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutMs)*time.Millisecond) defer cancel() args := []string{ "--dangerously-skip-permissions", "-p", opts.Prompt, "--output-format", "json", } if opts.ProfilePath != "" { args = append(args, "--settings", opts.ProfilePath) } stdout, stderr, err := RunClaude(tCtx, opts.Cwd, args) if err != nil { if errors.Is(tCtx.Err(), context.DeadlineExceeded) { return nil, &ClaudeTimeoutError{ClaudeError{ Msg: "claude timed out", Stderr: string(stderr), }} } return nil, &ClaudeError{ Msg: fmt.Sprintf("claude exited with error: %v", err), Stderr: string(stderr), } } if len(stdout) > maxStdoutBytes { return nil, &ClaudeError{Msg: fmt.Sprintf("claude stdout exceeded %d bytes", maxStdoutBytes)} } payload, ok := extractClaudePayload(string(stdout)) if !ok { return nil, &ClaudeError{Msg: "could not parse claude output as JSON"} } return &Result{ SessionID: payload.SessionID, ResultText: payload.Result, RawJSON: string(stdout), }, nil }