Skip to main content

claude_cli_sdk/
config.rs

1//! Client configuration — `ClientConfig` with typed builder pattern.
2//!
3//! [`ClientConfig`] carries every option needed to spawn and control a Claude
4//! Code CLI session. It uses [`typed_builder`] so that required fields must be
5//! supplied at compile time while optional fields have sensible defaults.
6//!
7//! # Example
8//!
9//! ```rust
10//! use claude_cli_sdk::config::{ClientConfig, PermissionMode};
11//!
12//! let config = ClientConfig::builder()
13//!     .prompt("List files in /tmp")
14//!     .build();
15//! ```
16
17use std::collections::{BTreeMap, HashMap};
18use std::path::PathBuf;
19use std::sync::Arc;
20use std::time::Duration;
21
22use serde::{Deserialize, Serialize};
23use typed_builder::TypedBuilder;
24
25use crate::callback::MessageCallback;
26use crate::hooks::HookMatcher;
27use crate::mcp::McpServers;
28use crate::permissions::CanUseToolCallback;
29
30// ── ClientConfig ─────────────────────────────────────────────────────────────
31
32/// Configuration for a Claude Code SDK client session.
33///
34/// Use [`ClientConfig::builder()`] to construct.
35#[derive(TypedBuilder)]
36pub struct ClientConfig {
37    // ── Required ─────────────────────────────────────────────────────────
38    /// The prompt text to send to Claude.
39    #[builder(setter(into))]
40    pub prompt: String,
41
42    // ── Session ──────────────────────────────────────────────────────────
43    /// Path to the Claude CLI binary. If `None`, auto-discovered via
44    /// [`find_cli()`](crate::discovery::find_cli).
45    #[builder(default, setter(strip_option))]
46    pub cli_path: Option<PathBuf>,
47
48    /// Working directory for the Claude process.
49    #[builder(default, setter(strip_option))]
50    pub cwd: Option<PathBuf>,
51
52    /// Model to use (e.g., `"claude-sonnet-4-5"`).
53    #[builder(default, setter(strip_option, into))]
54    pub model: Option<String>,
55
56    /// Fallback model if the primary is unavailable.
57    #[builder(default, setter(strip_option, into))]
58    pub fallback_model: Option<String>,
59
60    /// System prompt configuration.
61    #[builder(default, setter(strip_option))]
62    pub system_prompt: Option<SystemPrompt>,
63
64    // ── Limits ───────────────────────────────────────────────────────────
65    /// Maximum number of agentic turns before stopping.
66    #[builder(default, setter(strip_option))]
67    pub max_turns: Option<u32>,
68
69    /// Maximum USD budget for the session.
70    #[builder(default, setter(strip_option))]
71    pub max_budget_usd: Option<f64>,
72
73    /// Maximum thinking tokens per turn.
74    #[builder(default, setter(strip_option))]
75    pub max_thinking_tokens: Option<u32>,
76
77    // ── Tools ────────────────────────────────────────────────────────────
78    /// Explicitly allowed tool names.
79    #[builder(default)]
80    pub allowed_tools: Vec<String>,
81
82    /// Explicitly disallowed tool names.
83    #[builder(default)]
84    pub disallowed_tools: Vec<String>,
85
86    // ── Permissions ──────────────────────────────────────────────────────
87    /// Permission mode for the session.
88    #[builder(default)]
89    pub permission_mode: PermissionMode,
90
91    /// Callback invoked when the CLI requests tool use permission.
92    #[builder(default, setter(strip_option))]
93    pub can_use_tool: Option<CanUseToolCallback>,
94
95    // ── Session management ───────────────────────────────────────────────
96    /// Resume an existing session by ID.
97    #[builder(default, setter(strip_option, into))]
98    pub resume: Option<String>,
99
100    // ── Hooks ────────────────────────────────────────────────────────────
101    /// Lifecycle hooks to register for the session.
102    #[builder(default)]
103    pub hooks: Vec<HookMatcher>,
104
105    // ── MCP ──────────────────────────────────────────────────────────────
106    /// External MCP server configurations.
107    #[builder(default)]
108    pub mcp_servers: McpServers,
109
110    // ── Callback ─────────────────────────────────────────────────────────
111    /// Optional message callback for observe/filter.
112    #[builder(default, setter(strip_option))]
113    pub message_callback: Option<MessageCallback>,
114
115    // ── Environment ──────────────────────────────────────────────────────
116    /// Extra environment variables to pass to the CLI process.
117    #[builder(default)]
118    pub env: HashMap<String, String>,
119
120    /// Enable verbose (debug) output from the CLI.
121    #[builder(default)]
122    pub verbose: bool,
123
124    // ── Output ───────────────────────────────────────────────────────────
125    /// Output format. Defaults to `"json"` for SDK use.
126    #[builder(default_code = r#""json".into()"#, setter(into))]
127    pub output_format: String,
128
129    // ── Extra CLI args ────────────────────────────────────────────────────
130    /// Arbitrary extra CLI flags to pass through to the Claude process.
131    ///
132    /// Keys are flag names (without the `--` prefix). Values are optional:
133    /// - `Some("value")` produces `--key value`
134    /// - `None` produces `--key` (boolean-style flag)
135    ///
136    /// Uses [`BTreeMap`] to guarantee deterministic CLI arg ordering across
137    /// invocations (important for reproducible test snapshots).
138    ///
139    /// # Example
140    ///
141    /// ```rust
142    /// use std::collections::BTreeMap;
143    /// use claude_cli_sdk::ClientConfig;
144    ///
145    /// let config = ClientConfig::builder()
146    ///     .prompt("hello")
147    ///     .extra_args(BTreeMap::from([
148    ///         ("replay-user-messages".into(), None),
149    ///         ("context-window".into(), Some("200000".into())),
150    ///     ]))
151    ///     .build();
152    /// ```
153    #[builder(default)]
154    pub extra_args: BTreeMap<String, Option<String>>,
155
156    // ── Timeouts ──────────────────────────────────────────────────────────
157    /// Deadline for process spawn + init message. `None` = wait forever.
158    ///
159    /// Default: `Some(30s)`.
160    #[builder(default_code = "Some(Duration::from_secs(30))")]
161    pub connect_timeout: Option<Duration>,
162
163    /// Deadline for `child.wait()` during close. On expiry the process is
164    /// killed. `None` = wait forever.
165    ///
166    /// Default: `Some(10s)`.
167    #[builder(default_code = "Some(Duration::from_secs(10))")]
168    pub close_timeout: Option<Duration>,
169
170    /// Per-message recv deadline. `None` = wait forever (default).
171    ///
172    /// This is for detecting hung/zombie processes, not for limiting turn
173    /// time — Claude turns can legitimately take minutes.
174    #[builder(default)]
175    pub read_timeout: Option<Duration>,
176
177    /// Fallback timeout for hook callbacks when [`HookMatcher::timeout`] is
178    /// `None`.
179    ///
180    /// Default: 30 seconds.
181    #[builder(default_code = "Duration::from_secs(30)")]
182    pub default_hook_timeout: Duration,
183
184    /// Deadline for the `--version` check in
185    /// [`check_cli_version()`](crate::discovery::check_cli_version).
186    /// `None` = wait forever.
187    ///
188    /// Default: `Some(5s)`.
189    #[builder(default_code = "Some(Duration::from_secs(5))")]
190    pub version_check_timeout: Option<Duration>,
191
192    // ── Stderr ───────────────────────────────────────────────────────────
193    /// Optional callback for CLI stderr output.
194    #[builder(default, setter(strip_option))]
195    pub stderr_callback: Option<Arc<dyn Fn(String) + Send + Sync>>,
196}
197
198impl std::fmt::Debug for ClientConfig {
199    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200        f.debug_struct("ClientConfig")
201            .field("prompt", &self.prompt)
202            .field("cli_path", &self.cli_path)
203            .field("cwd", &self.cwd)
204            .field("model", &self.model)
205            .field("permission_mode", &self.permission_mode)
206            .field("max_turns", &self.max_turns)
207            .field("max_budget_usd", &self.max_budget_usd)
208            .field("verbose", &self.verbose)
209            .field("output_format", &self.output_format)
210            .field("connect_timeout", &self.connect_timeout)
211            .field("close_timeout", &self.close_timeout)
212            .field("read_timeout", &self.read_timeout)
213            .field("default_hook_timeout", &self.default_hook_timeout)
214            .field("version_check_timeout", &self.version_check_timeout)
215            .finish_non_exhaustive()
216    }
217}
218
219// ── PermissionMode ───────────────────────────────────────────────────────────
220
221/// Permission mode controlling how tool use requests are handled.
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
223#[serde(rename_all = "snake_case")]
224pub enum PermissionMode {
225    /// Default: prompt the user for each tool use.
226    #[default]
227    Default,
228    /// Automatically accept file edits (still prompt for other tools).
229    AcceptEdits,
230    /// Plan-only mode: suggest changes but don't execute.
231    Plan,
232    /// Bypass all permission prompts (dangerous — use in CI only).
233    BypassPermissions,
234}
235
236impl PermissionMode {
237    /// Convert to the CLI flag value.
238    #[must_use]
239    pub fn as_cli_flag(&self) -> &'static str {
240        match self {
241            Self::Default => "default",
242            Self::AcceptEdits => "acceptEdits",
243            Self::Plan => "plan",
244            Self::BypassPermissions => "bypassPermissions",
245        }
246    }
247}
248
249// ── SystemPrompt ─────────────────────────────────────────────────────────────
250
251/// System prompt configuration for a Claude session.
252#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
253#[serde(tag = "type", rename_all = "snake_case")]
254pub enum SystemPrompt {
255    /// A raw text system prompt.
256    Text {
257        /// The system prompt text.
258        text: String,
259    },
260    /// A named preset system prompt.
261    Preset {
262        /// The preset kind (e.g., `"custom"`).
263        kind: String,
264        /// The preset name.
265        preset: String,
266        /// Additional text to append after the preset.
267        #[serde(default, skip_serializing_if = "Option::is_none")]
268        append: Option<String>,
269    },
270}
271
272impl SystemPrompt {
273    /// Create a text system prompt.
274    #[must_use]
275    pub fn text(s: impl Into<String>) -> Self {
276        Self::Text { text: s.into() }
277    }
278
279    /// Create a preset system prompt.
280    #[must_use]
281    pub fn preset(kind: impl Into<String>, preset: impl Into<String>) -> Self {
282        Self::Preset {
283            kind: kind.into(),
284            preset: preset.into(),
285            append: None,
286        }
287    }
288}
289
290// ── CLI arg generation ───────────────────────────────────────────────────────
291
292impl ClientConfig {
293    /// Validate the configuration, returning an error for invalid settings.
294    ///
295    /// Checks:
296    /// - If `cwd` is set, it must exist and be a directory.
297    ///
298    /// This is called automatically by [`Client::new()`](crate::Client::new).
299    pub fn validate(&self) -> crate::errors::Result<()> {
300        if let Some(ref cwd) = self.cwd {
301            if !cwd.exists() {
302                return Err(crate::errors::Error::Config(format!(
303                    "working directory does not exist: {}",
304                    cwd.display()
305                )));
306            }
307            if !cwd.is_dir() {
308                return Err(crate::errors::Error::Config(format!(
309                    "working directory is not a directory: {}",
310                    cwd.display()
311                )));
312            }
313        }
314        Ok(())
315    }
316
317    /// Build the CLI argument list for spawning the Claude process.
318    ///
319    /// This does NOT include the binary path itself — just the arguments.
320    #[must_use]
321    pub fn to_cli_args(&self) -> Vec<String> {
322        let mut args = vec![
323            "--output-format".into(),
324            self.output_format.clone(),
325            "--print".into(),
326            self.prompt.clone(),
327        ];
328
329        if let Some(model) = &self.model {
330            args.push("--model".into());
331            args.push(model.clone());
332        }
333
334        if let Some(fallback) = &self.fallback_model {
335            args.push("--fallback-model".into());
336            args.push(fallback.clone());
337        }
338
339        if let Some(turns) = self.max_turns {
340            args.push("--max-turns".into());
341            args.push(turns.to_string());
342        }
343
344        if let Some(budget) = self.max_budget_usd {
345            args.push("--max-budget-usd".into());
346            args.push(budget.to_string());
347        }
348
349        if let Some(thinking) = self.max_thinking_tokens {
350            args.push("--max-thinking-tokens".into());
351            args.push(thinking.to_string());
352        }
353
354        if self.permission_mode != PermissionMode::Default {
355            args.push("--permission-mode".into());
356            args.push(self.permission_mode.as_cli_flag().into());
357        }
358
359        if let Some(resume) = &self.resume {
360            args.push("--resume".into());
361            args.push(resume.clone());
362        }
363
364        if self.verbose {
365            args.push("--verbose".into());
366        }
367
368        for tool in &self.allowed_tools {
369            args.push("--allowedTools".into());
370            args.push(tool.clone());
371        }
372
373        for tool in &self.disallowed_tools {
374            args.push("--disallowedTools".into());
375            args.push(tool.clone());
376        }
377
378        if !self.mcp_servers.is_empty() {
379            let json = serde_json::to_string(&self.mcp_servers)
380                .expect("McpServers serialization is infallible");
381            args.push("--mcp-servers".into());
382            args.push(json);
383        }
384
385        if let Some(prompt) = &self.system_prompt {
386            match prompt {
387                SystemPrompt::Text { text } => {
388                    args.push("--system-prompt".into());
389                    args.push(text.clone());
390                }
391                SystemPrompt::Preset { preset, append, .. } => {
392                    args.push("--system-prompt-preset".into());
393                    args.push(preset.clone());
394                    if let Some(append_text) = append {
395                        args.push("--append-system-prompt".into());
396                        args.push(append_text.clone());
397                    }
398                }
399            }
400        }
401
402        // Extra args — appended last so they can override anything above.
403        for (key, value) in &self.extra_args {
404            args.push(format!("--{key}"));
405            if let Some(v) = value {
406                args.push(v.clone());
407            }
408        }
409
410        args
411    }
412
413    /// Build the environment variable map for the CLI process.
414    ///
415    /// Merges `self.env` with any SDK-internal env vars needed.
416    #[must_use]
417    pub fn to_env(&self) -> HashMap<String, String> {
418        let mut env = self.env.clone();
419
420        // If hooks or permissions callbacks are set, enable the control protocol.
421        if self.can_use_tool.is_some() || !self.hooks.is_empty() {
422            env.insert("CLAUDE_CODE_SDK_CONTROL_PORT".into(), "stdin".into());
423        }
424
425        env
426    }
427}
428
429// ── Tests ────────────────────────────────────────────────────────────────────
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    #[test]
436    fn builder_minimal() {
437        let config = ClientConfig::builder().prompt("hello").build();
438        assert_eq!(config.prompt, "hello");
439        assert_eq!(config.output_format, "json");
440        assert_eq!(config.permission_mode, PermissionMode::Default);
441    }
442
443    #[test]
444    fn builder_full() {
445        let config = ClientConfig::builder()
446            .prompt("test prompt")
447            .model("claude-opus-4-5")
448            .max_turns(5_u32)
449            .max_budget_usd(1.0_f64)
450            .permission_mode(PermissionMode::AcceptEdits)
451            .verbose(true)
452            .build();
453
454        assert_eq!(config.model.as_deref(), Some("claude-opus-4-5"));
455        assert_eq!(config.max_turns, Some(5));
456        assert_eq!(config.max_budget_usd, Some(1.0));
457        assert_eq!(config.permission_mode, PermissionMode::AcceptEdits);
458        assert!(config.verbose);
459    }
460
461    #[test]
462    fn to_cli_args_minimal() {
463        let config = ClientConfig::builder().prompt("hello").build();
464        let args = config.to_cli_args();
465        assert!(args.contains(&"--output-format".into()));
466        assert!(args.contains(&"json".into()));
467        assert!(args.contains(&"--print".into()));
468        assert!(args.contains(&"hello".into()));
469    }
470
471    #[test]
472    fn to_cli_args_with_model_and_turns() {
473        let config = ClientConfig::builder()
474            .prompt("test")
475            .model("claude-sonnet-4-5")
476            .max_turns(10_u32)
477            .build();
478        let args = config.to_cli_args();
479        assert!(args.contains(&"--model".into()));
480        assert!(args.contains(&"claude-sonnet-4-5".into()));
481        assert!(args.contains(&"--max-turns".into()));
482        assert!(args.contains(&"10".into()));
483    }
484
485    #[test]
486    fn to_cli_args_with_permission_mode() {
487        let config = ClientConfig::builder()
488            .prompt("test")
489            .permission_mode(PermissionMode::BypassPermissions)
490            .build();
491        let args = config.to_cli_args();
492        assert!(args.contains(&"--permission-mode".into()));
493        assert!(args.contains(&"bypassPermissions".into()));
494    }
495
496    #[test]
497    fn to_cli_args_default_permission_mode_not_included() {
498        let config = ClientConfig::builder().prompt("test").build();
499        let args = config.to_cli_args();
500        assert!(!args.contains(&"--permission-mode".into()));
501    }
502
503    #[test]
504    fn to_cli_args_with_system_prompt_text() {
505        let config = ClientConfig::builder()
506            .prompt("test")
507            .system_prompt(SystemPrompt::text("You are a helpful assistant"))
508            .build();
509        let args = config.to_cli_args();
510        assert!(args.contains(&"--system-prompt".into()));
511        assert!(args.contains(&"You are a helpful assistant".into()));
512    }
513
514    #[test]
515    fn to_cli_args_with_mcp_servers() {
516        use crate::mcp::McpServerConfig;
517
518        let mut servers = McpServers::new();
519        servers.insert(
520            "fs".into(),
521            McpServerConfig::new("npx").with_args(["-y", "mcp-fs"]),
522        );
523
524        let config = ClientConfig::builder()
525            .prompt("test")
526            .mcp_servers(servers)
527            .build();
528        let args = config.to_cli_args();
529        assert!(args.contains(&"--mcp-servers".into()));
530    }
531
532    #[test]
533    fn to_env_without_callbacks() {
534        let config = ClientConfig::builder().prompt("test").build();
535        let env = config.to_env();
536        assert!(!env.contains_key("CLAUDE_CODE_SDK_CONTROL_PORT"));
537    }
538
539    #[test]
540    fn to_env_with_hooks_enables_control_port() {
541        use crate::hooks::{HookCallback, HookEvent, HookMatcher, HookOutput};
542        let cb: HookCallback = Arc::new(|_, _, _| Box::pin(async { HookOutput::allow() }));
543        let config = ClientConfig::builder()
544            .prompt("test")
545            .hooks(vec![HookMatcher::new(HookEvent::PreToolUse, cb)])
546            .build();
547        let env = config.to_env();
548        assert_eq!(
549            env.get("CLAUDE_CODE_SDK_CONTROL_PORT"),
550            Some(&"stdin".into())
551        );
552    }
553
554    #[test]
555    fn permission_mode_serde_round_trip() {
556        let modes = [
557            PermissionMode::Default,
558            PermissionMode::AcceptEdits,
559            PermissionMode::Plan,
560            PermissionMode::BypassPermissions,
561        ];
562        for mode in modes {
563            let json = serde_json::to_string(&mode).unwrap();
564            let decoded: PermissionMode = serde_json::from_str(&json).unwrap();
565            assert_eq!(mode, decoded);
566        }
567    }
568
569    #[test]
570    fn system_prompt_text_round_trip() {
571        let sp = SystemPrompt::text("You are helpful");
572        let json = serde_json::to_string(&sp).unwrap();
573        let decoded: SystemPrompt = serde_json::from_str(&json).unwrap();
574        assert_eq!(sp, decoded);
575    }
576
577    #[test]
578    fn system_prompt_preset_round_trip() {
579        let sp = SystemPrompt::Preset {
580            kind: "custom".into(),
581            preset: "coding".into(),
582            append: Some("Also be concise.".into()),
583        };
584        let json = serde_json::to_string(&sp).unwrap();
585        let decoded: SystemPrompt = serde_json::from_str(&json).unwrap();
586        assert_eq!(sp, decoded);
587    }
588
589    #[test]
590    fn debug_does_not_panic() {
591        let config = ClientConfig::builder().prompt("test").build();
592        let _ = format!("{config:?}");
593    }
594
595    #[test]
596    fn to_cli_args_with_allowed_tools() {
597        let config = ClientConfig::builder()
598            .prompt("test")
599            .allowed_tools(vec!["bash".into(), "read_file".into()])
600            .build();
601        let args = config.to_cli_args();
602        let allowed_count = args.iter().filter(|a| *a == "--allowedTools").count();
603        assert_eq!(allowed_count, 2);
604    }
605
606    #[test]
607    fn to_cli_args_with_extra_args_boolean_flag() {
608        let config = ClientConfig::builder()
609            .prompt("test")
610            .extra_args(BTreeMap::from([("replay-user-messages".into(), None)]))
611            .build();
612        let args = config.to_cli_args();
613        assert!(args.contains(&"--replay-user-messages".into()));
614    }
615
616    #[test]
617    fn to_cli_args_with_extra_args_valued_flag() {
618        let config = ClientConfig::builder()
619            .prompt("test")
620            .extra_args(BTreeMap::from([(
621                "context-window".into(),
622                Some("200000".into()),
623            )]))
624            .build();
625        let args = config.to_cli_args();
626        let idx = args.iter().position(|a| a == "--context-window").unwrap();
627        assert_eq!(args[idx + 1], "200000");
628    }
629
630    #[test]
631    fn builder_timeout_defaults() {
632        let config = ClientConfig::builder().prompt("test").build();
633        assert_eq!(config.connect_timeout, Some(Duration::from_secs(30)));
634        assert_eq!(config.close_timeout, Some(Duration::from_secs(10)));
635        assert_eq!(config.read_timeout, None);
636        assert_eq!(config.default_hook_timeout, Duration::from_secs(30));
637        assert_eq!(config.version_check_timeout, Some(Duration::from_secs(5)));
638    }
639
640    #[test]
641    fn builder_custom_timeouts() {
642        let config = ClientConfig::builder()
643            .prompt("test")
644            .connect_timeout(Some(Duration::from_secs(60)))
645            .close_timeout(Some(Duration::from_secs(20)))
646            .read_timeout(Some(Duration::from_secs(120)))
647            .default_hook_timeout(Duration::from_secs(10))
648            .version_check_timeout(Some(Duration::from_secs(15)))
649            .build();
650        assert_eq!(config.connect_timeout, Some(Duration::from_secs(60)));
651        assert_eq!(config.close_timeout, Some(Duration::from_secs(20)));
652        assert_eq!(config.read_timeout, Some(Duration::from_secs(120)));
653        assert_eq!(config.default_hook_timeout, Duration::from_secs(10));
654        assert_eq!(config.version_check_timeout, Some(Duration::from_secs(15)));
655    }
656
657    #[test]
658    fn builder_disable_connect_timeout() {
659        let config = ClientConfig::builder()
660            .prompt("test")
661            .connect_timeout(None::<Duration>)
662            .build();
663        assert_eq!(config.connect_timeout, None);
664    }
665
666    #[test]
667    fn to_cli_args_with_resume() {
668        let config = ClientConfig::builder()
669            .prompt("test")
670            .resume("session-123")
671            .build();
672        let args = config.to_cli_args();
673        assert!(args.contains(&"--resume".into()));
674        assert!(args.contains(&"session-123".into()));
675    }
676}