1use 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#[derive(TypedBuilder)]
36pub struct ClientConfig {
37 #[builder(setter(into))]
40 pub prompt: String,
41
42 #[builder(default, setter(strip_option))]
46 pub cli_path: Option<PathBuf>,
47
48 #[builder(default, setter(strip_option))]
50 pub cwd: Option<PathBuf>,
51
52 #[builder(default, setter(strip_option, into))]
54 pub model: Option<String>,
55
56 #[builder(default, setter(strip_option, into))]
58 pub fallback_model: Option<String>,
59
60 #[builder(default, setter(strip_option))]
62 pub system_prompt: Option<SystemPrompt>,
63
64 #[builder(default, setter(strip_option))]
67 pub max_turns: Option<u32>,
68
69 #[builder(default, setter(strip_option))]
71 pub max_budget_usd: Option<f64>,
72
73 #[builder(default, setter(strip_option))]
75 pub max_thinking_tokens: Option<u32>,
76
77 #[builder(default)]
80 pub allowed_tools: Vec<String>,
81
82 #[builder(default)]
84 pub disallowed_tools: Vec<String>,
85
86 #[builder(default)]
89 pub permission_mode: PermissionMode,
90
91 #[builder(default, setter(strip_option))]
93 pub can_use_tool: Option<CanUseToolCallback>,
94
95 #[builder(default, setter(strip_option, into))]
98 pub resume: Option<String>,
99
100 #[builder(default)]
103 pub hooks: Vec<HookMatcher>,
104
105 #[builder(default)]
108 pub mcp_servers: McpServers,
109
110 #[builder(default, setter(strip_option))]
113 pub message_callback: Option<MessageCallback>,
114
115 #[builder(default)]
118 pub env: HashMap<String, String>,
119
120 #[builder(default)]
122 pub verbose: bool,
123
124 #[builder(default_code = r#""json".into()"#, setter(into))]
127 pub output_format: String,
128
129 #[builder(default)]
154 pub extra_args: BTreeMap<String, Option<String>>,
155
156 #[builder(default_code = "Some(Duration::from_secs(30))")]
161 pub connect_timeout: Option<Duration>,
162
163 #[builder(default_code = "Some(Duration::from_secs(10))")]
168 pub close_timeout: Option<Duration>,
169
170 #[builder(default)]
175 pub read_timeout: Option<Duration>,
176
177 #[builder(default_code = "Duration::from_secs(30)")]
182 pub default_hook_timeout: Duration,
183
184 #[builder(default_code = "Some(Duration::from_secs(5))")]
190 pub version_check_timeout: Option<Duration>,
191
192 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
223#[serde(rename_all = "snake_case")]
224pub enum PermissionMode {
225 #[default]
227 Default,
228 AcceptEdits,
230 Plan,
232 BypassPermissions,
234}
235
236impl PermissionMode {
237 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
253#[serde(tag = "type", rename_all = "snake_case")]
254pub enum SystemPrompt {
255 Text {
257 text: String,
259 },
260 Preset {
262 kind: String,
264 preset: String,
266 #[serde(default, skip_serializing_if = "Option::is_none")]
268 append: Option<String>,
269 },
270}
271
272impl SystemPrompt {
273 #[must_use]
275 pub fn text(s: impl Into<String>) -> Self {
276 Self::Text { text: s.into() }
277 }
278
279 #[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
290impl ClientConfig {
293 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 #[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 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 #[must_use]
417 pub fn to_env(&self) -> HashMap<String, String> {
418 let mut env = self.env.clone();
419
420 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#[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}