runner.go 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
  1. // Package runner starts the claude subprocess with a pre-built environment,
  2. // transparently forwards stdio and signals, and returns claude's exit code as
  3. // cc-switch's own.
  4. //
  5. // The contract (spec: provider-launch):
  6. // - stdin/stdout/stderr are inherited verbatim.
  7. // - SIGINT/SIGTERM/SIGHUP are forwarded to the child; cc-switch waits for
  8. // the child to finish and then returns.
  9. // - Exit code passthrough: normal exit -> child's code; signal termination
  10. // -> 128 + signum (POSIX convention).
  11. // - We never mutate os.Environ; the supplied childEnv is the whole
  12. // environment the child sees.
  13. package runner
  14. import (
  15. "errors"
  16. "fmt"
  17. "os"
  18. "os/exec"
  19. "path/filepath"
  20. "github.com/kotoyuuko/cc-switch-cli/internal/config"
  21. )
  22. // ResolveClaudePath returns the absolute path of the claude executable to
  23. // launch. Precedence: cfg.ClaudePath (if non-empty) > exec.LookPath("claude").
  24. // If neither succeeds, an error is returned that suggests the `config set
  25. // claude-path` escape hatch.
  26. func ResolveClaudePath(cfg config.Config) (string, error) {
  27. if cfg.ClaudePath != "" {
  28. expanded, err := config.ExpandUser(cfg.ClaudePath)
  29. if err != nil {
  30. return "", fmt.Errorf("expand claude_path %q: %w", cfg.ClaudePath, err)
  31. }
  32. abs, err := filepath.Abs(expanded)
  33. if err != nil {
  34. return "", fmt.Errorf("resolve claude_path %q: %w", cfg.ClaudePath, err)
  35. }
  36. info, err := os.Stat(abs)
  37. if err != nil {
  38. return "", fmt.Errorf("claude_path %q: %w", abs, err)
  39. }
  40. if !info.Mode().IsRegular() {
  41. return "", fmt.Errorf("claude_path %q is not a regular file", abs)
  42. }
  43. if info.Mode().Perm()&0o111 == 0 {
  44. return "", fmt.Errorf("claude_path %q is not executable", abs)
  45. }
  46. return abs, nil
  47. }
  48. p, err := exec.LookPath("claude")
  49. if err != nil {
  50. return "", fmt.Errorf(
  51. "claude not found in PATH and no claude_path configured; try `cc-switch config set claude-path <path>`",
  52. )
  53. }
  54. return p, nil
  55. }
  56. // ExitError is returned by Run when the child was killed by a signal. Its
  57. // ExitCode already follows the 128+signum convention.
  58. type ExitError struct {
  59. Signal os.Signal // nil if exited normally
  60. ExitCode int
  61. }
  62. func (e *ExitError) Error() string {
  63. if e.Signal != nil {
  64. return fmt.Sprintf("claude terminated by signal %v (exit code %d)", e.Signal, e.ExitCode)
  65. }
  66. return fmt.Sprintf("claude exited with code %d", e.ExitCode)
  67. }
  68. // asExitError converts a cmd.Wait error into an ExitCode value consistent with
  69. // the spec: normal exit -> err.ExitCode(); signal kill -> 128+signum.
  70. func asExitError(err error) (int, error) {
  71. if err == nil {
  72. return 0, nil
  73. }
  74. var exitErr *exec.ExitError
  75. if !errors.As(err, &exitErr) {
  76. // Something went wrong that isn't the child reporting an exit (e.g.
  77. // we couldn't wait on it at all). Surface the raw error and a
  78. // generic 1 so callers can still print something useful.
  79. return 1, err
  80. }
  81. if code := exitErr.ExitCode(); code >= 0 {
  82. return code, nil
  83. }
  84. // code == -1 means the child was killed by a signal. Look up which.
  85. if sig, ok := extractSignal(exitErr); ok {
  86. return 128 + int(sig), nil
  87. }
  88. return 1, err
  89. }
  90. // Run starts claudePath with the given env and args and returns its exit code.
  91. // It installs the signal-forwarding handler as configured per platform
  92. // (see runner_unix.go / runner_other.go).
  93. func Run(claudePath string, childEnv []string, args []string) (int, error) {
  94. cmd := exec.Command(claudePath, args...)
  95. cmd.Env = childEnv
  96. cmd.Stdin = os.Stdin
  97. cmd.Stdout = os.Stdout
  98. cmd.Stderr = os.Stderr
  99. if err := cmd.Start(); err != nil {
  100. return 1, fmt.Errorf("start claude: %w", err)
  101. }
  102. stopForward := startSignalForwarding(cmd.Process)
  103. defer stopForward()
  104. return asExitError(cmd.Wait())
  105. }