182 lines
4.7 KiB
Go
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
|
|
}
|