Skip to main content

claude_wrapper/
types.rs

1//! Shared enums and serde types used across the command builders.
2//!
3//! Small value types like [`Transport`], [`Scope`], [`Effort`],
4//! [`PermissionMode`], [`OutputFormat`], and [`InputFormat`] that map
5//! onto `claude` CLI flag values, with `Display`/`FromStr` and serde
6//! wiring so they round-trip cleanly through args and JSON.
7
8use std::fmt;
9use std::str::FromStr;
10
11use serde::{Deserialize, Serialize};
12
13/// Transport type for MCP server connections.
14///
15/// # Example
16///
17/// ```
18/// use claude_wrapper::Transport;
19/// use std::str::FromStr;
20///
21/// let t = Transport::from_str("stdio").unwrap();
22/// assert_eq!(t, Transport::Stdio);
23/// assert_eq!(t.to_string(), "stdio");
24///
25/// let t: Transport = "http".parse().unwrap();
26/// assert_eq!(t, Transport::Http);
27/// ```
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "lowercase")]
30pub enum Transport {
31    /// Standard I/O transport — server runs as a subprocess.
32    Stdio,
33    /// HTTP transport — server accessible via URL.
34    Http,
35    /// Server-Sent Events transport — server accessible via URL with SSE.
36    Sse,
37}
38
39impl fmt::Display for Transport {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        match self {
42            Self::Stdio => write!(f, "stdio"),
43            Self::Http => write!(f, "http"),
44            Self::Sse => write!(f, "sse"),
45        }
46    }
47}
48
49/// Error returned when parsing an unknown transport string.
50#[derive(Debug, Clone, thiserror::Error)]
51#[error("unknown transport: {0}")]
52pub struct TransportParseError(pub String);
53
54impl FromStr for Transport {
55    type Err = TransportParseError;
56
57    fn from_str(s: &str) -> Result<Self, Self::Err> {
58        match s {
59            "stdio" => Ok(Self::Stdio),
60            "http" => Ok(Self::Http),
61            "sse" => Ok(Self::Sse),
62            other => Err(TransportParseError(other.to_string())),
63        }
64    }
65}
66
67impl TryFrom<&str> for Transport {
68    type Error = TransportParseError;
69
70    fn try_from(s: &str) -> Result<Self, Self::Error> {
71        s.parse()
72    }
73}
74
75#[cfg(test)]
76mod transport_tests {
77    use super::*;
78
79    #[test]
80    fn from_str_accepts_known_variants() {
81        assert_eq!("stdio".parse::<Transport>().unwrap(), Transport::Stdio);
82        assert_eq!("http".parse::<Transport>().unwrap(), Transport::Http);
83        assert_eq!("sse".parse::<Transport>().unwrap(), Transport::Sse);
84    }
85
86    #[test]
87    fn from_str_returns_error_for_unknown() {
88        let err = "websocket".parse::<Transport>().unwrap_err();
89        assert!(err.to_string().contains("websocket"));
90    }
91
92    #[test]
93    fn try_from_str_returns_error_for_unknown() {
94        assert!(Transport::try_from("bogus").is_err());
95    }
96}
97
98/// Output format for `--output-format`.
99#[derive(Debug, Clone, Copy, Default)]
100pub enum OutputFormat {
101    /// Plain text output (default).
102    #[default]
103    Text,
104    /// Single JSON result object.
105    Json,
106    /// Streaming NDJSON.
107    StreamJson,
108}
109
110impl OutputFormat {
111    pub(crate) fn as_arg(&self) -> &'static str {
112        match self {
113            Self::Text => "text",
114            Self::Json => "json",
115            Self::StreamJson => "stream-json",
116        }
117    }
118}
119
120/// Permission mode for `--permission-mode`.
121#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(rename_all = "camelCase")]
123pub enum PermissionMode {
124    /// Default interactive permissions.
125    #[default]
126    Default,
127    /// Auto-accept file edits.
128    AcceptEdits,
129    /// Bypass all permission checks.
130    ///
131    /// **Deprecated.** Reaching for this variant directly puts a
132    /// bypass-mode query one keystroke away in any code path, which
133    /// is exactly the footgun the variant enables. Use
134    /// [`crate::dangerous::DangerousClient`] instead, which gates
135    /// construction on the `CLAUDE_WRAPPER_ALLOW_DANGEROUS` env-var
136    /// and makes the intent obvious at the call site. The variant
137    /// itself will stay available through the current major version
138    /// so existing callers keep compiling (with a deprecation
139    /// warning).
140    #[deprecated(
141        since = "0.5.1",
142        note = "use claude_wrapper::dangerous::DangerousClient instead; \
143                direct BypassPermissions usage is a footgun and will go \
144                away in a future major release"
145    )]
146    BypassPermissions,
147    /// Don't ask for permissions (deny by default).
148    DontAsk,
149    /// Plan mode (read-only).
150    Plan,
151    /// Auto mode.
152    Auto,
153}
154
155impl PermissionMode {
156    pub(crate) fn as_arg(&self) -> &'static str {
157        match self {
158            Self::Default => "default",
159            Self::AcceptEdits => "acceptEdits",
160            #[allow(deprecated)]
161            Self::BypassPermissions => "bypassPermissions",
162            Self::DontAsk => "dontAsk",
163            Self::Plan => "plan",
164            Self::Auto => "auto",
165        }
166    }
167}
168
169/// Input format for `--input-format`.
170#[derive(Debug, Clone, Copy, Default)]
171pub enum InputFormat {
172    /// Plain text input (default).
173    #[default]
174    Text,
175    /// Streaming JSON input.
176    StreamJson,
177}
178
179impl InputFormat {
180    pub(crate) fn as_arg(&self) -> &'static str {
181        match self {
182            Self::Text => "text",
183            Self::StreamJson => "stream-json",
184        }
185    }
186}
187
188/// Effort level for `--effort`.
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
190#[serde(rename_all = "lowercase")]
191pub enum Effort {
192    /// Low effort.
193    Low,
194    /// Medium effort (default).
195    Medium,
196    /// High effort.
197    High,
198    /// Extra-high effort.
199    Xhigh,
200    /// Maximum effort, most thorough.
201    Max,
202}
203
204impl Effort {
205    pub(crate) fn as_arg(&self) -> &'static str {
206        match self {
207            Self::Low => "low",
208            Self::Medium => "medium",
209            Self::High => "high",
210            Self::Xhigh => "xhigh",
211            Self::Max => "max",
212        }
213    }
214}
215
216/// How far a hermetic seal reaches when sealing the ambient `~/.claude`
217/// promptspace (`CLAUDE.md`, agents, skills, MCP servers).
218///
219/// Chooses the value passed to `--setting-sources`; the seal itself is
220/// applied by `hermetic` / `hermetic_scoped` on
221/// [`QueryCommand`](crate::QueryCommand) and
222/// [`DuplexOptions`](crate::duplex::DuplexOptions), which also set
223/// `--strict-mcp-config` and
224/// `--exclude-dynamic-system-prompt-sections`.
225///
226/// A hermetic seal never touches authentication. It is distinct from
227/// `--bare`, which forces API-key billing by reading auth strictly from
228/// `ANTHROPIC_API_KEY` / `apiKeyHelper`; a seal leaves OAuth and
229/// keychain auth working as normal.
230#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
231pub enum HermeticScope {
232    /// Drop every ambient setting source: user, project, and local
233    /// (`--setting-sources ""`). Only the CLI's built-in agents and
234    /// defaults remain; a user-level `~/.claude` no longer leaks in.
235    #[default]
236    Full,
237    /// Seal only the project and local ambient config while keeping the
238    /// user's global `~/.claude` (`--setting-sources user`). A project
239    /// `CLAUDE.md` and project-level agents stop being adopted.
240    Project,
241}
242
243impl HermeticScope {
244    /// The value emitted for `--setting-sources` under this scope.
245    pub(crate) fn setting_sources_value(self) -> &'static str {
246        match self {
247            Self::Full => "",
248            Self::Project => "user",
249        }
250    }
251}
252
253/// Scope for MCP and plugin commands.
254#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
255pub enum Scope {
256    /// Local scope (current directory).
257    #[default]
258    Local,
259    /// User scope (global).
260    User,
261    /// Project scope.
262    Project,
263    /// Managed scope -- plugins installed by an outer manager
264    /// rather than directly by the user. Accepted by `claude plugin
265    /// update --scope managed` as of CLI 2.1.143.
266    Managed,
267}
268
269impl Scope {
270    pub(crate) fn as_arg(&self) -> &'static str {
271        match self {
272            Self::Local => "local",
273            Self::User => "user",
274            Self::Project => "project",
275            Self::Managed => "managed",
276        }
277    }
278}
279
280/// Authentication status returned by `claude auth status --json`.
281///
282/// # Example
283///
284/// ```no_run
285/// # async fn example() -> claude_wrapper::Result<()> {
286/// let claude = claude_wrapper::Claude::builder().build()?;
287/// let status = claude_wrapper::AuthStatusCommand::new()
288///     .execute_json(&claude).await?;
289///
290/// if status.logged_in {
291///     println!("Logged in as {}", status.email.unwrap_or_default());
292/// }
293/// # Ok(())
294/// # }
295/// ```
296#[cfg(feature = "json")]
297#[derive(Debug, Clone, Deserialize, Serialize)]
298#[serde(rename_all = "camelCase")]
299pub struct AuthStatus {
300    /// Whether the user is currently logged in.
301    #[serde(default)]
302    pub logged_in: bool,
303    /// Authentication method (e.g. "claude.ai").
304    #[serde(default)]
305    pub auth_method: Option<String>,
306    /// API provider (e.g. "firstParty").
307    #[serde(default)]
308    pub api_provider: Option<String>,
309    /// Authenticated user's email address.
310    #[serde(default)]
311    pub email: Option<String>,
312    /// Organization ID.
313    #[serde(default)]
314    pub org_id: Option<String>,
315    /// Organization name.
316    #[serde(default)]
317    pub org_name: Option<String>,
318    /// Subscription type (e.g. "pro", "max").
319    #[serde(default)]
320    pub subscription_type: Option<String>,
321    /// Any additional fields not explicitly modeled.
322    #[serde(flatten)]
323    pub extra: std::collections::HashMap<String, serde_json::Value>,
324}
325
326/// A message from a query result, representing one turn in the conversation.
327#[cfg(feature = "json")]
328#[derive(Debug, Clone, Deserialize, Serialize)]
329pub struct QueryMessage {
330    /// The role of the message sender (e.g., "user", "assistant").
331    #[serde(default)]
332    pub role: String,
333    /// The text content of the message.
334    #[serde(default)]
335    pub content: serde_json::Value,
336    /// Additional fields returned by the CLI not captured in typed fields.
337    #[serde(flatten)]
338    pub extra: std::collections::HashMap<String, serde_json::Value>,
339}
340
341/// Token usage reported by the CLI for a single run.
342///
343/// Field names align with `codex-wrapper`'s `TokenUsage` so an adapter
344/// can map the two directly (see the cross-crate design in issue #760).
345/// Serde aliases accept the Anthropic key names the CLI actually emits
346/// (`cache_read_input_tokens`, `cache_creation_input_tokens`), the same
347/// keys the on-disk history accounting reads, so the live and read-side
348/// paths agree.
349///
350/// Every field is `Option` because the CLI does not report all buckets
351/// on every turn. A missing bucket means "not reported", not zero;
352/// [`Session::turns_missing_usage`](crate::Session::turns_missing_usage)
353/// exists so callers can tell a low total from an incomplete one.
354#[cfg(feature = "json")]
355#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
356pub struct TokenUsage {
357    /// Non-cached input tokens.
358    #[serde(default)]
359    pub input_tokens: Option<u64>,
360    /// Input tokens served from cache.
361    #[serde(default, alias = "cache_read_input_tokens")]
362    pub cached_input_tokens: Option<u64>,
363    /// Input tokens written to cache.
364    #[serde(default, alias = "cache_creation_input_tokens")]
365    pub cache_write_input_tokens: Option<u64>,
366    /// Output tokens.
367    #[serde(default)]
368    pub output_tokens: Option<u64>,
369    /// Output tokens spent on reasoning, where the CLI reports them
370    /// separately.
371    #[serde(default)]
372    pub reasoning_output_tokens: Option<u64>,
373    /// Explicit total, where the CLI reports one.
374    #[serde(default)]
375    pub total_tokens: Option<u64>,
376}
377
378#[cfg(feature = "json")]
379impl TokenUsage {
380    /// Total tokens for this run: the explicit `total_tokens` if the
381    /// CLI reported one, otherwise the sum of every reported bucket.
382    /// Cache and non-cache input both count, mirroring the on-disk
383    /// accounting in the history module.
384    pub fn total(&self) -> u64 {
385        if let Some(t) = self.total_tokens {
386            return t;
387        }
388        [
389            self.input_tokens,
390            self.cached_input_tokens,
391            self.cache_write_input_tokens,
392            self.output_tokens,
393            self.reasoning_output_tokens,
394        ]
395        .iter()
396        .flatten()
397        .sum()
398    }
399
400    /// True when the CLI reported no usage buckets at all.
401    pub fn is_empty(&self) -> bool {
402        *self == Self::default()
403    }
404}
405
406/// Result from a query with `--output-format json`.
407#[cfg(feature = "json")]
408#[derive(Debug, Clone, Deserialize, Serialize)]
409pub struct QueryResult {
410    /// The text content of the query response.
411    #[serde(default)]
412    pub result: String,
413    /// The session ID for continuing conversations.
414    #[serde(default)]
415    pub session_id: String,
416    /// Total cost of the query in USD.
417    #[serde(default, rename = "total_cost_usd", alias = "cost_usd")]
418    pub cost_usd: Option<f64>,
419    /// Duration of the query in milliseconds.
420    #[serde(default)]
421    pub duration_ms: Option<u64>,
422    /// Number of conversation turns in the query.
423    #[serde(default)]
424    pub num_turns: Option<u32>,
425    /// Whether the query resulted in an error.
426    #[serde(default)]
427    pub is_error: bool,
428    /// Token usage for this run, when the CLI reports it on the result
429    /// event.
430    #[serde(default)]
431    pub usage: Option<TokenUsage>,
432    /// Additional fields returned by the CLI not captured in typed fields.
433    #[serde(flatten)]
434    pub extra: std::collections::HashMap<String, serde_json::Value>,
435}
436
437#[cfg(all(test, feature = "json"))]
438mod tests {
439    use super::*;
440
441    #[test]
442    fn query_result_deserializes_total_cost_usd() {
443        let json =
444            r#"{"result":"hello","session_id":"s1","total_cost_usd":0.042,"is_error":false}"#;
445        let qr: QueryResult = serde_json::from_str(json).unwrap();
446        assert_eq!(qr.cost_usd, Some(0.042));
447    }
448
449    #[test]
450    fn query_result_deserializes_cost_usd_alias() {
451        let json = r#"{"result":"hello","session_id":"s1","cost_usd":0.01,"is_error":false}"#;
452        let qr: QueryResult = serde_json::from_str(json).unwrap();
453        assert_eq!(qr.cost_usd, Some(0.01));
454    }
455
456    #[test]
457    fn query_result_missing_cost_defaults_to_none() {
458        let json = r#"{"result":"hello","session_id":"s1","is_error":false}"#;
459        let qr: QueryResult = serde_json::from_str(json).unwrap();
460        assert_eq!(qr.cost_usd, None);
461    }
462
463    #[test]
464    fn query_result_from_stream_result_event() {
465        // Exact shape of a --output-format stream-json "result" event.
466        // The flattened `extra` must absorb type/subtype without
467        // breaking typed field parsing.
468        let json = r#"{"type":"result","subtype":"success","result":"streamed","session_id":"sess-1","total_cost_usd":0.03,"num_turns":1,"is_error":false}"#;
469        let qr: QueryResult = serde_json::from_str(json).unwrap();
470        assert_eq!(qr.cost_usd, Some(0.03));
471        assert_eq!(qr.num_turns, Some(1));
472        assert_eq!(qr.session_id, "sess-1");
473        assert_eq!(qr.result, "streamed");
474        assert_eq!(
475            qr.extra.get("type").and_then(|v| v.as_str()),
476            Some("result")
477        );
478    }
479
480    #[test]
481    fn query_result_deserializes_num_turns() {
482        let json = r#"{"result":"done","session_id":"s2","total_cost_usd":0.1,"num_turns":5,"is_error":false}"#;
483        let qr: QueryResult = serde_json::from_str(json).unwrap();
484        assert_eq!(qr.num_turns, Some(5));
485        assert_eq!(qr.cost_usd, Some(0.1));
486    }
487
488    #[test]
489    fn query_result_serializes_as_total_cost_usd() {
490        let qr = QueryResult {
491            result: "ok".into(),
492            session_id: "s1".into(),
493            cost_usd: Some(0.05),
494            duration_ms: None,
495            num_turns: Some(3),
496            is_error: false,
497            usage: None,
498            extra: Default::default(),
499        };
500        let json = serde_json::to_string(&qr).unwrap();
501        assert!(json.contains("\"total_cost_usd\""));
502        assert!(json.contains("\"num_turns\""));
503    }
504
505    #[test]
506    fn token_usage_deserializes_anthropic_cache_key_names() {
507        let json = r#"{
508            "input_tokens": 100,
509            "cache_read_input_tokens": 400,
510            "cache_creation_input_tokens": 50,
511            "output_tokens": 25
512        }"#;
513        let usage: TokenUsage = serde_json::from_str(json).unwrap();
514        assert_eq!(usage.input_tokens, Some(100));
515        assert_eq!(usage.cached_input_tokens, Some(400));
516        assert_eq!(usage.cache_write_input_tokens, Some(50));
517        assert_eq!(usage.output_tokens, Some(25));
518        assert_eq!(usage.total(), 575);
519        assert!(!usage.is_empty());
520    }
521
522    #[test]
523    fn token_usage_prefers_explicit_total() {
524        let json = r#"{"input_tokens": 100, "output_tokens": 25, "total_tokens": 999}"#;
525        let usage: TokenUsage = serde_json::from_str(json).unwrap();
526        assert_eq!(usage.total(), 999);
527    }
528
529    #[test]
530    fn token_usage_empty_object_is_empty() {
531        let usage: TokenUsage = serde_json::from_str("{}").unwrap();
532        assert!(usage.is_empty());
533        assert_eq!(usage.total(), 0);
534    }
535
536    #[test]
537    fn query_result_parses_usage_off_result_event() {
538        let json = r#"{
539            "result": "hi",
540            "session_id": "s1",
541            "total_cost_usd": 0.01,
542            "is_error": false,
543            "usage": {"input_tokens": 7, "output_tokens": 3}
544        }"#;
545        let qr: QueryResult = serde_json::from_str(json).unwrap();
546        let usage = qr.usage.expect("usage parsed");
547        assert_eq!(usage.total(), 10);
548        // The typed field captures it; it must not also land in extra.
549        assert!(!qr.extra.contains_key("usage"));
550    }
551}