// Package runner starts the claude subprocess with a pre-built environment, // transparently forwards stdio and signals, and returns claude's exit code as // cc-switch's own. // // The contract (spec: provider-launch): // - stdin/stdout/stderr are inherited verbatim. // - SIGINT/SIGTERM/SIGHUP are forwarded to the child; cc-switch waits for // the child to finish and then returns. // - Exit code passthrough: normal exit -> child's code; signal termination // -> 128 + signum (POSIX convention). // - We never mutate os.Environ; the supplied childEnv is the whole // environment the child sees. package runner import ( "errors" "fmt" "os" "os/exec" "path/filepath" "github.com/kotoyuuko/cc-switch-cli/internal/config" ) // ResolveClaudePath returns the absolute path of the claude executable to // launch. Precedence: cfg.ClaudePath (if non-empty) > exec.LookPath("claude"). // If neither succeeds, an error is returned that suggests the `config set // claude-path` escape hatch. func ResolveClaudePath(cfg config.Config) (string, error) { if cfg.ClaudePath != "" { expanded, err := config.ExpandUser(cfg.ClaudePath) if err != nil { return "", fmt.Errorf("expand claude_path %q: %w", cfg.ClaudePath, err) } abs, err := filepath.Abs(expanded) if err != nil { return "", fmt.Errorf("resolve claude_path %q: %w", cfg.ClaudePath, err) } info, err := os.Stat(abs) if err != nil { return "", fmt.Errorf("claude_path %q: %w", abs, err) } if !info.Mode().IsRegular() { return "", fmt.Errorf("claude_path %q is not a regular file", abs) } if info.Mode().Perm()&0o111 == 0 { return "", fmt.Errorf("claude_path %q is not executable", abs) } return abs, nil } p, err := exec.LookPath("claude") if err != nil { return "", fmt.Errorf( "claude not found in PATH and no claude_path configured; try `cc-switch config set claude-path `", ) } return p, nil } // ExitError is returned by Run when the child was killed by a signal. Its // ExitCode already follows the 128+signum convention. type ExitError struct { Signal os.Signal // nil if exited normally ExitCode int } func (e *ExitError) Error() string { if e.Signal != nil { return fmt.Sprintf("claude terminated by signal %v (exit code %d)", e.Signal, e.ExitCode) } return fmt.Sprintf("claude exited with code %d", e.ExitCode) } // asExitError converts a cmd.Wait error into an ExitCode value consistent with // the spec: normal exit -> err.ExitCode(); signal kill -> 128+signum. func asExitError(err error) (int, error) { if err == nil { return 0, nil } var exitErr *exec.ExitError if !errors.As(err, &exitErr) { // Something went wrong that isn't the child reporting an exit (e.g. // we couldn't wait on it at all). Surface the raw error and a // generic 1 so callers can still print something useful. return 1, err } if code := exitErr.ExitCode(); code >= 0 { return code, nil } // code == -1 means the child was killed by a signal. Look up which. if sig, ok := extractSignal(exitErr); ok { return 128 + int(sig), nil } return 1, err } // Run starts claudePath with the given env and args and returns its exit code. // It installs the signal-forwarding handler as configured per platform // (see runner_unix.go / runner_other.go). func Run(claudePath string, childEnv []string, args []string) (int, error) { cmd := exec.Command(claudePath, args...) cmd.Env = childEnv cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { return 1, fmt.Errorf("start claude: %w", err) } stopForward := startSignalForwarding(cmd.Process) defer stopForward() return asExitError(cmd.Wait()) }