Skip to main content

claude_wrapper/
error.rs

1use std::path::PathBuf;
2
3use crate::auth::AuthErrorKind;
4
5/// Errors returned by claude-wrapper operations.
6///
7/// This enum is `#[non_exhaustive]`: new variants may be added in
8/// future releases without a major version bump, so downstream `match`
9/// expressions must include a wildcard (`_ =>`) arm. Matching on the
10/// specific variants you care about (e.g. [`Error::Auth`],
11/// [`Error::MaxTurnsExceeded`]) keeps working across upgrades.
12#[derive(Debug, thiserror::Error)]
13#[non_exhaustive]
14pub enum Error {
15    /// The `claude` binary was not found in PATH.
16    #[error("claude binary not found in PATH")]
17    NotFound,
18
19    /// A claude command failed with a non-zero exit code.
20    #[error("claude command failed: {command} (exit code {exit_code}){}{}{}", working_dir.as_ref().map(|d| format!(" (in {})", d.display())).unwrap_or_default(), if stdout.is_empty() { String::new() } else { format!("\nstdout: {stdout}") }, if stderr.is_empty() { String::new() } else { format!("\nstderr: {stderr}") })]
21    CommandFailed {
22        command: String,
23        exit_code: i32,
24        stdout: String,
25        stderr: String,
26        working_dir: Option<PathBuf>,
27    },
28
29    /// An I/O error occurred while spawning or communicating with the process.
30    #[error("io error: {message}{}", working_dir.as_ref().map(|d| format!(" (in {})", d.display())).unwrap_or_default())]
31    Io {
32        message: String,
33        #[source]
34        source: std::io::Error,
35        working_dir: Option<PathBuf>,
36    },
37
38    /// The command timed out.
39    #[error("claude command timed out after {timeout_seconds}s")]
40    Timeout { timeout_seconds: u64 },
41
42    /// JSON parsing failed.
43    #[cfg(feature = "json")]
44    #[error("json parse error: {message}")]
45    Json {
46        message: String,
47        #[source]
48        source: serde_json::Error,
49    },
50
51    /// The installed CLI version does not meet the minimum requirement.
52    #[error("CLI version {found} does not meet minimum requirement {minimum}")]
53    VersionMismatch {
54        found: crate::version::CliVersion,
55        minimum: crate::version::CliVersion,
56    },
57
58    /// Construction of a `dangerous::Client` was attempted without
59    /// the opt-in env-var set. The env-var name is a compile-time
60    /// constant exported from [`crate::dangerous::ALLOW_ENV`].
61    #[error(
62        "dangerous operations are not allowed; set the env var `{env_var}=1` at process start if you really mean it"
63    )]
64    DangerousNotAllowed { env_var: &'static str },
65
66    /// A configured [`BudgetTracker`](crate::budget::BudgetTracker) has
67    /// hit its `max_usd` ceiling. Raised before the next call is
68    /// dispatched, so the CLI is not invoked.
69    #[error("budget exceeded: ${total_usd:.4} spent, ${max_usd:.4} max")]
70    BudgetExceeded { total_usd: f64, max_usd: f64 },
71
72    /// A [`DuplexSession`](crate::duplex::DuplexSession) operation was
73    /// attempted after the session task exited (child died, EOF on
74    /// stdout, or the session was closed). Pending replies are
75    /// resolved with this error.
76    #[cfg(feature = "async")]
77    #[error("duplex session is closed")]
78    DuplexClosed,
79
80    /// [`DuplexSession::send`](crate::duplex::DuplexSession::send) was
81    /// called while another turn is already in flight. Wait for the
82    /// outstanding turn to resolve before issuing another.
83    #[cfg(feature = "async")]
84    #[error("duplex session has a turn in flight")]
85    DuplexTurnInFlight,
86
87    /// A control request issued from
88    /// [`DuplexSession::interrupt`](crate::duplex::DuplexSession::interrupt)
89    /// (or any other outbound `control_request`) was answered by the
90    /// CLI with a `subtype: "error"` payload.
91    #[cfg(feature = "async")]
92    #[error("duplex control request failed: {message}")]
93    DuplexControlFailed {
94        /// The error message extracted from the CLI's control_response.
95        message: String,
96    },
97
98    /// A history-module operation (parsing or locating session
99    /// JSONL under `~/.claude/projects/`) failed in a way that
100    /// doesn't fit the I/O or JSON variants -- e.g. unknown
101    /// session id, missing user home directory.
102    #[error("history error: {message}")]
103    History {
104        /// Human-readable description of what went wrong.
105        message: String,
106    },
107
108    /// An artifacts-module operation (parsing or locating files
109    /// under `~/.claude/agents/`, `~/.claude/skills/`, and friends)
110    /// failed in a way that doesn't fit the I/O variant -- e.g.
111    /// unknown agent/skill name, missing user home directory.
112    #[error("artifacts error: {message}")]
113    Artifacts {
114        /// Human-readable description of what went wrong.
115        message: String,
116    },
117
118    /// A worktrees-module operation (running or parsing
119    /// `git worktree list --porcelain`) failed in a way that
120    /// doesn't fit the I/O variant -- e.g. git not on PATH,
121    /// path isn't a git repo, malformed porcelain output.
122    #[error("worktrees error: {message}")]
123    Worktrees {
124        /// Human-readable description of what went wrong.
125        message: String,
126    },
127
128    /// A `claude` invocation failed and looked auth-shaped to the
129    /// classifier. Hosts can match on this variant to trigger a
130    /// re-auth flow, surface a clean message, or skip retries.
131    /// `kind` carries the best-effort subcategory; `message` is the
132    /// stderr (or stdout fallback) the classifier matched against.
133    ///
134    /// Raised at exec time when [`crate::auth::classify_failure`]
135    /// returns `Some(_)` for a CLI failure that would otherwise
136    /// have been [`Error::CommandFailed`]. Cases the classifier
137    /// missed remain `CommandFailed`; call
138    /// [`Error::auth_kind`] for opt-in inspection of those.
139    #[error("auth error ({kind:?}): {command} (exit code {exit_code}): {message}")]
140    Auth {
141        /// Best-effort classification.
142        kind: AuthErrorKind,
143        /// The full command line that failed.
144        command: String,
145        /// Process exit code.
146        exit_code: i32,
147        /// Human-readable message extracted from stderr (or stdout).
148        message: String,
149    },
150
151    /// A `--max-turns`-capped run exhausted its turn budget. The CLI
152    /// emits a terminal `result` event with `subtype ==
153    /// "error_max_turns"` (exit 1, with the result JSON on stdout),
154    /// which would otherwise fold into [`Error::CommandFailed`].
155    ///
156    /// This is distinct from a genuine failure: the working tree may
157    /// be fine and the run simply hit the cap mid-task. Orchestrators
158    /// can match this variant to finish the lifecycle (run remaining
159    /// gates, commit) rather than treating it as broken or re-parsing
160    /// the trace for `error_max_turns`.
161    ///
162    /// Raised by [`Error::from_command_failure`] ahead of the auth
163    /// classifier. Only detected when the result event is present on
164    /// stdout (the `json` / `stream-json` output formats); text-mode
165    /// failures without it remain [`Error::CommandFailed`].
166    #[error("claude hit the --max-turns cap{}: {command} (exit code {exit_code})", max_turns.map(|n| format!(" of {n}")).unwrap_or_default())]
167    MaxTurnsExceeded {
168        /// The full command line that failed.
169        command: String,
170        /// Process exit code (1).
171        exit_code: i32,
172        /// The configured `--max-turns` cap, parsed from the result
173        /// event ("Reached maximum number of turns (N)") when present.
174        max_turns: Option<u32>,
175    },
176}
177
178impl Error {
179    /// Construct an [`Error`] from a CLI failure. Runs the
180    /// auth-error classifier; if it matches, returns
181    /// [`Error::Auth`]. Otherwise returns [`Error::CommandFailed`]
182    /// unchanged.
183    ///
184    /// This is the canonical entry point for raising failures from
185    /// `exec.rs`-shaped sites -- replacing direct construction of
186    /// `CommandFailed` ensures every consumer benefits from typed
187    /// auth errors automatically.
188    pub fn from_command_failure(
189        command: String,
190        exit_code: i32,
191        stdout: String,
192        stderr: String,
193        working_dir: Option<PathBuf>,
194    ) -> Self {
195        // A --max-turns cap hit is a terminal `result` event with
196        // subtype "error_max_turns" on stdout. Surface it as its own
197        // typed variant -- ahead of the auth classifier, since it is
198        // never auth-shaped -- so consumers can tell "hit the cap"
199        // (recoverable) from a genuine failure.
200        if stdout.contains("\"error_max_turns\"") {
201            return Self::MaxTurnsExceeded {
202                command,
203                exit_code,
204                max_turns: parse_max_turns_cap(&stdout),
205            };
206        }
207        if let Some(kind) = crate::auth::classify_failure(exit_code, &stdout, &stderr) {
208            // Prefer stderr for the human-facing message; fall back
209            // to stdout when stderr is empty (some CLIs send all
210            // diagnostics to stdout).
211            let message = if !stderr.trim().is_empty() {
212                stderr.trim().to_string()
213            } else {
214                stdout.trim().to_string()
215            };
216            Self::Auth {
217                kind,
218                command,
219                exit_code,
220                message,
221            }
222        } else {
223            Self::CommandFailed {
224                command,
225                exit_code,
226                stdout,
227                stderr,
228                working_dir,
229            }
230        }
231    }
232
233    /// Inspect whether this error is auth-shaped. Returns
234    /// `Some(kind)` for [`Error::Auth`] (the auto-typed path) and
235    /// also re-runs [`crate::auth::classify_failure`] on
236    /// [`Error::CommandFailed`] for cases the constructor missed.
237    /// Returns `None` for everything else (`Io`, `Timeout`, etc.).
238    ///
239    /// Most consumers should match on [`Error::Auth`] directly --
240    /// this method is the escape hatch for low-confidence
241    /// classifier patterns the constructor was too conservative
242    /// about.
243    pub fn auth_kind(&self) -> Option<AuthErrorKind> {
244        match self {
245            Self::Auth { kind, .. } => Some(*kind),
246            Self::CommandFailed {
247                exit_code,
248                stdout,
249                stderr,
250                ..
251            } => crate::auth::classify_failure(*exit_code, stdout, stderr),
252            _ => None,
253        }
254    }
255}
256
257/// Parse the configured `--max-turns` cap from a CLI result event's
258/// human-readable error ("Reached maximum number of turns (N)").
259/// Returns `None` when the phrase or a parseable number is absent.
260fn parse_max_turns_cap(stdout: &str) -> Option<u32> {
261    stdout
262        .split("maximum number of turns (")
263        .nth(1)
264        .and_then(|rest| rest.split(')').next())
265        .and_then(|n| n.trim().parse::<u32>().ok())
266}
267
268impl From<std::io::Error> for Error {
269    fn from(e: std::io::Error) -> Self {
270        Self::Io {
271            message: e.to_string(),
272            source: e,
273            working_dir: None,
274        }
275    }
276}
277
278/// Result type alias for claude-wrapper operations.
279pub type Result<T> = std::result::Result<T, Error>;
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    fn command_failed(stdout: &str, stderr: &str, working_dir: Option<PathBuf>) -> Error {
286        Error::CommandFailed {
287            command: "/bin/claude --print".to_string(),
288            exit_code: 7,
289            stdout: stdout.to_string(),
290            stderr: stderr.to_string(),
291            working_dir,
292        }
293    }
294
295    #[test]
296    fn command_failed_display_includes_command_and_exit_code() {
297        let e = command_failed("", "", None);
298        let s = e.to_string();
299        assert!(s.contains("/bin/claude --print"));
300        assert!(s.contains("exit code 7"));
301    }
302
303    #[test]
304    fn command_failed_display_omits_empty_stdout_and_stderr() {
305        let s = command_failed("", "", None).to_string();
306        assert!(!s.contains("stdout:"));
307        assert!(!s.contains("stderr:"));
308    }
309
310    #[test]
311    fn command_failed_display_includes_nonempty_stdout() {
312        let s = command_failed("hello", "", None).to_string();
313        assert!(s.contains("stdout: hello"));
314    }
315
316    #[test]
317    fn command_failed_display_includes_nonempty_stderr() {
318        let s = command_failed("", "boom", None).to_string();
319        assert!(s.contains("stderr: boom"));
320    }
321
322    #[test]
323    fn command_failed_display_includes_both_streams_when_present() {
324        let s = command_failed("out", "err", None).to_string();
325        assert!(s.contains("stdout: out"));
326        assert!(s.contains("stderr: err"));
327    }
328
329    #[test]
330    fn command_failed_display_includes_working_dir_when_present() {
331        let s = command_failed("", "", Some(PathBuf::from("/tmp/proj"))).to_string();
332        assert!(s.contains("/tmp/proj"));
333    }
334
335    #[test]
336    fn command_failed_display_omits_working_dir_when_absent() {
337        let s = command_failed("", "", None).to_string();
338        assert!(!s.contains("(in "));
339    }
340
341    #[test]
342    fn timeout_display_formats_seconds() {
343        let s = Error::Timeout {
344            timeout_seconds: 42,
345        }
346        .to_string();
347        assert!(s.contains("42s"));
348    }
349
350    #[test]
351    fn io_error_display_includes_working_dir_when_present() {
352        let e = Error::Io {
353            message: "spawn failed".to_string(),
354            source: std::io::Error::new(std::io::ErrorKind::NotFound, "no file"),
355            working_dir: Some(PathBuf::from("/work")),
356        };
357        let s = e.to_string();
358        assert!(s.contains("spawn failed"));
359        assert!(s.contains("/work"));
360    }
361
362    // -- from_command_failure / auth_kind ---------------------------
363
364    #[test]
365    fn from_command_failure_unrelated_stderr_yields_command_failed() {
366        let e = Error::from_command_failure(
367            "claude --print".into(),
368            1,
369            String::new(),
370            "syntax error".into(),
371            None,
372        );
373        assert!(matches!(e, Error::CommandFailed { .. }));
374        assert_eq!(e.auth_kind(), None);
375    }
376
377    #[test]
378    fn from_command_failure_auth_stderr_yields_auth_variant() {
379        let e = Error::from_command_failure(
380            "claude --print".into(),
381            1,
382            String::new(),
383            "Not authenticated. Run `claude login`.".into(),
384            None,
385        );
386        match &e {
387            Error::Auth { kind, message, .. } => {
388                assert_eq!(*kind, AuthErrorKind::NotAuthenticated);
389                assert!(message.contains("Not authenticated"));
390            }
391            other => panic!("expected Auth, got {other:?}"),
392        }
393        assert_eq!(e.auth_kind(), Some(AuthErrorKind::NotAuthenticated));
394    }
395
396    #[test]
397    fn from_command_failure_uses_stdout_message_when_stderr_empty() {
398        let e = Error::from_command_failure(
399            "claude --print".into(),
400            1,
401            "Invalid API key".into(),
402            String::new(),
403            None,
404        );
405        match &e {
406            Error::Auth { message, kind, .. } => {
407                assert_eq!(*kind, AuthErrorKind::InvalidCredentials);
408                assert_eq!(message, "Invalid API key");
409            }
410            other => panic!("expected Auth, got {other:?}"),
411        }
412    }
413
414    #[test]
415    fn auth_kind_inspects_command_failed_for_missed_classifications() {
416        // The constructor would have caught this, but a hand-built
417        // CommandFailed (e.g. constructed by older code or by a
418        // caller not going through the helper) is still inspectable.
419        let e = Error::CommandFailed {
420            command: "claude --print".into(),
421            exit_code: 1,
422            stdout: String::new(),
423            stderr: "401 Unauthorized".into(),
424            working_dir: None,
425        };
426        assert_eq!(e.auth_kind(), Some(AuthErrorKind::InvalidCredentials));
427    }
428
429    #[test]
430    fn auth_kind_returns_none_for_non_command_errors() {
431        assert_eq!(Error::NotFound.auth_kind(), None);
432        assert_eq!(Error::Timeout { timeout_seconds: 5 }.auth_kind(), None);
433    }
434
435    // -- max-turns classification (#641) ----------------------------
436
437    // Exact shape of a --max-turns cap-hit result event, from the
438    // field (claude 2.1.173, --output-format json).
439    const MAX_TURNS_STDOUT: &str = r#"{"type":"result","subtype":"error_max_turns","is_error":true,"num_turns":2,"session_id":"s1","total_cost_usd":0.08,"terminal_reason":"max_turns","errors":["Reached maximum number of turns (1)"]}"#;
440
441    #[test]
442    fn from_command_failure_max_turns_yields_typed_variant() {
443        let e = Error::from_command_failure(
444            "claude --print --max-turns 1".into(),
445            1,
446            MAX_TURNS_STDOUT.into(),
447            String::new(),
448            None,
449        );
450        match e {
451            Error::MaxTurnsExceeded {
452                max_turns,
453                exit_code,
454                ..
455            } => {
456                assert_eq!(max_turns, Some(1));
457                assert_eq!(exit_code, 1);
458            }
459            other => panic!("expected MaxTurnsExceeded, got {other:?}"),
460        }
461    }
462
463    #[test]
464    fn max_turns_detected_without_parseable_cap() {
465        let stdout = r#"{"type":"result","subtype":"error_max_turns","is_error":true}"#;
466        let e = Error::from_command_failure("c".into(), 1, stdout.into(), String::new(), None);
467        match e {
468            Error::MaxTurnsExceeded { max_turns, .. } => assert_eq!(max_turns, None),
469            other => panic!("expected MaxTurnsExceeded, got {other:?}"),
470        }
471    }
472
473    #[test]
474    fn non_max_turns_failure_stays_command_failed() {
475        let e =
476            Error::from_command_failure("c".into(), 1, "other output".into(), "boom".into(), None);
477        assert!(matches!(e, Error::CommandFailed { .. }));
478    }
479
480    #[test]
481    fn max_turns_check_does_not_swallow_auth() {
482        // A genuine auth failure (no error_max_turns) still classifies
483        // as Auth -- the max-turns guard precedes but doesn't shadow it.
484        let e = Error::from_command_failure(
485            "c".into(),
486            1,
487            String::new(),
488            "Not authenticated. Run `claude login`.".into(),
489            None,
490        );
491        assert!(matches!(e, Error::Auth { .. }));
492    }
493
494    #[test]
495    fn parse_max_turns_cap_variants() {
496        assert_eq!(
497            parse_max_turns_cap("Reached maximum number of turns (3)"),
498            Some(3)
499        );
500        assert_eq!(parse_max_turns_cap(MAX_TURNS_STDOUT), Some(1));
501        assert_eq!(parse_max_turns_cap("no such phrase"), None);
502        assert_eq!(parse_max_turns_cap("maximum number of turns (nope)"), None);
503    }
504
505    #[test]
506    fn max_turns_display_includes_cap() {
507        let s = Error::MaxTurnsExceeded {
508            command: "claude --print".into(),
509            exit_code: 1,
510            max_turns: Some(5),
511        }
512        .to_string();
513        assert!(s.contains("--max-turns"), "got: {s}");
514        assert!(s.contains("of 5"), "got: {s}");
515    }
516}