Files
superwork/tui/internal/cc/spawn.go
T
2026-06-23 05:02:15 +08:00

182 lines
4.7 KiB
Go

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
}