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 tokio_util::sync::CancellationToken;
26
27use crate::callback::MessageCallback;
28use crate::hooks::HookMatcher;
29use crate::mcp::McpServers;
30use crate::permissions::CanUseToolCallback;
31
32#[derive(TypedBuilder)]
38pub struct ClientConfig {
39 #[builder(setter(into))]
42 pub prompt: String,
43
44 #[builder(default, setter(strip_option))]
48 pub cli_path: Option<PathBuf>,
49
50 #[builder(default, setter(strip_option))]
52 pub cwd: Option<PathBuf>,
53
54 #[builder(default, setter(strip_option, into))]
56 pub model: Option<String>,
57
58 #[builder(default, setter(strip_option, into))]
60 pub fallback_model: Option<String>,
61
62 #[builder(default, setter(strip_option))]
64 pub system_prompt: Option<SystemPrompt>,
65
66 #[builder(default, setter(strip_option))]
69 pub max_turns: Option<u32>,
70
71 #[builder(default, setter(strip_option))]
73 pub max_budget_usd: Option<f64>,
74
75 #[builder(default, setter(strip_option))]
77 pub max_thinking_tokens: Option<u32>,
78
79 #[builder(default)]
82 pub allowed_tools: Vec<String>,
83
84 #[builder(default)]
86 pub disallowed_tools: Vec<String>,
87
88 #[builder(default)]
91 pub permission_mode: PermissionMode,
92
93 #[builder(default, setter(strip_option))]
95 pub can_use_tool: Option<CanUseToolCallback>,
96
97 #[builder(default, setter(strip_option, into))]
100 pub resume: Option<String>,
101
102 #[builder(default)]
105 pub hooks: Vec<HookMatcher>,
106
107 #[builder(default)]
110 pub mcp_servers: McpServers,
111
112 #[builder(default, setter(strip_option))]
115 pub message_callback: Option<MessageCallback>,
116
117 #[builder(default)]
120 pub env: HashMap<String, String>,
121
122 #[builder(default)]
124 pub verbose: bool,
125
126 #[builder(default_code = r#""stream-json".into()"#, setter(into))]
135 pub output_format: String,
136
137 #[builder(default)]
162 pub extra_args: BTreeMap<String, Option<String>>,
163
164 #[builder(default_code = "Some(Duration::from_secs(30))")]
169 pub connect_timeout: Option<Duration>,
170
171 #[builder(default_code = "Some(Duration::from_secs(10))")]
176 pub close_timeout: Option<Duration>,
177
178 #[builder(default_code = "true")]
186 pub end_input_on_connect: bool,
187
188 #[builder(default)]
193 pub read_timeout: Option<Duration>,
194
195 #[builder(default_code = "Duration::from_secs(30)")]
200 pub default_hook_timeout: Duration,
201
202 #[builder(default_code = "Some(Duration::from_secs(5))")]
208 pub version_check_timeout: Option<Duration>,
209
210 #[builder(default_code = "Duration::from_secs(30)")]
216 pub control_request_timeout: Duration,
217
218 #[builder(default, setter(strip_option))]
224 pub cancellation_token: Option<CancellationToken>,
225
226 #[builder(default, setter(strip_option))]
229 pub stderr_callback: Option<Arc<dyn Fn(String) + Send + Sync>>,
230}
231
232impl std::fmt::Debug for ClientConfig {
233 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234 f.debug_struct("ClientConfig")
235 .field("prompt", &self.prompt)
236 .field("cli_path", &self.cli_path)
237 .field("cwd", &self.cwd)
238 .field("model", &self.model)
239 .field("permission_mode", &self.permission_mode)
240 .field("max_turns", &self.max_turns)
241 .field("max_budget_usd", &self.max_budget_usd)
242 .field("verbose", &self.verbose)
243 .field("output_format", &self.output_format)
244 .field("connect_timeout", &self.connect_timeout)
245 .field("close_timeout", &self.close_timeout)
246 .field("read_timeout", &self.read_timeout)
247 .field("default_hook_timeout", &self.default_hook_timeout)
248 .field("version_check_timeout", &self.version_check_timeout)
249 .field("control_request_timeout", &self.control_request_timeout)
250 .finish_non_exhaustive()
251 }
252}
253
254#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
258#[serde(rename_all = "snake_case")]
259pub enum PermissionMode {
260 #[default]
262 Default,
263 AcceptEdits,
265 Plan,
267 BypassPermissions,
269}
270
271impl PermissionMode {
272 #[must_use]
274 pub fn as_cli_flag(&self) -> &'static str {
275 match self {
276 Self::Default => "default",
277 Self::AcceptEdits => "acceptEdits",
278 Self::Plan => "plan",
279 Self::BypassPermissions => "bypassPermissions",
280 }
281 }
282}
283
284#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
288#[serde(tag = "type", rename_all = "snake_case")]
289pub enum SystemPrompt {
290 Text {
292 text: String,
294 },
295 Preset {
297 kind: String,
299 preset: String,
301 #[serde(default, skip_serializing_if = "Option::is_none")]
303 append: Option<String>,
304 },
305}
306
307impl SystemPrompt {
308 #[must_use]
310 pub fn text(s: impl Into<String>) -> Self {
311 Self::Text { text: s.into() }
312 }
313
314 #[must_use]
316 pub fn preset(kind: impl Into<String>, preset: impl Into<String>) -> Self {
317 Self::Preset {
318 kind: kind.into(),
319 preset: preset.into(),
320 append: None,
321 }
322 }
323}
324
325impl ClientConfig {
328 pub fn validate(&self) -> crate::errors::Result<()> {
335 if let Some(ref cwd) = self.cwd {
336 if !cwd.exists() {
337 return Err(crate::errors::Error::Config(format!(
338 "working directory does not exist: {}",
339 cwd.display()
340 )));
341 }
342 if !cwd.is_dir() {
343 return Err(crate::errors::Error::Config(format!(
344 "working directory is not a directory: {}",
345 cwd.display()
346 )));
347 }
348 }
349 Ok(())
350 }
351
352 #[must_use]
356 pub fn to_cli_args(&self) -> Vec<String> {
357 let mut args = vec![
358 "--output-format".into(),
359 self.output_format.clone(),
360 "--print".into(),
361 self.prompt.clone(),
362 ];
363
364 if self.output_format == "stream-json" && !self.verbose {
367 args.push("--verbose".into());
368 }
369
370 if let Some(model) = &self.model {
371 args.push("--model".into());
372 args.push(model.clone());
373 }
374
375 if let Some(fallback) = &self.fallback_model {
376 args.push("--fallback-model".into());
377 args.push(fallback.clone());
378 }
379
380 if let Some(turns) = self.max_turns {
381 args.push("--max-turns".into());
382 args.push(turns.to_string());
383 }
384
385 if let Some(budget) = self.max_budget_usd {
386 args.push("--max-budget-usd".into());
387 args.push(budget.to_string());
388 }
389
390 if let Some(thinking) = self.max_thinking_tokens {
391 args.push("--max-thinking-tokens".into());
392 args.push(thinking.to_string());
393 }
394
395 if self.permission_mode != PermissionMode::Default {
396 args.push("--permission-mode".into());
397 args.push(self.permission_mode.as_cli_flag().into());
398 }
399
400 if let Some(resume) = &self.resume {
401 args.push("--resume".into());
402 args.push(resume.clone());
403 }
404
405 if self.verbose {
406 args.push("--verbose".into());
407 }
408
409 for tool in &self.allowed_tools {
410 args.push("--allowedTools".into());
411 args.push(tool.clone());
412 }
413
414 for tool in &self.disallowed_tools {
415 args.push("--disallowedTools".into());
416 args.push(tool.clone());
417 }
418
419 if !self.mcp_servers.is_empty() {
420 let json = serde_json::to_string(&self.mcp_servers)
421 .expect("McpServers serialization is infallible");
422 args.push("--mcp-servers".into());
423 args.push(json);
424 }
425
426 if let Some(prompt) = &self.system_prompt {
427 match prompt {
428 SystemPrompt::Text { text } => {
429 args.push("--system-prompt".into());
430 args.push(text.clone());
431 }
432 SystemPrompt::Preset { preset, append, .. } => {
433 args.push("--system-prompt-preset".into());
434 args.push(preset.clone());
435 if let Some(append_text) = append {
436 args.push("--append-system-prompt".into());
437 args.push(append_text.clone());
438 }
439 }
440 }
441 }
442
443 for (key, value) in &self.extra_args {
445 args.push(format!("--{key}"));
446 if let Some(v) = value {
447 args.push(v.clone());
448 }
449 }
450
451 args
452 }
453
454 #[must_use]
464 pub fn to_env(&self) -> HashMap<String, String> {
465 let mut env = HashMap::new();
466
467 env.insert(
469 "CLAUDE_CODE_SDK_ORIGINATOR".into(),
470 "claude_cli_sdk_rs".into(),
471 );
472 env.insert("CI".into(), "true".into());
473 env.insert("TERM".into(), "dumb".into());
474
475 env.extend(self.env.clone());
477
478 if self.can_use_tool.is_some() || !self.hooks.is_empty() {
480 env.insert("CLAUDE_CODE_SDK_CONTROL_PORT".into(), "stdin".into());
481 }
482
483 env
484 }
485}
486
487#[cfg(test)]
490mod tests {
491 use super::*;
492
493 #[test]
494 fn builder_minimal() {
495 let config = ClientConfig::builder().prompt("hello").build();
496 assert_eq!(config.prompt, "hello");
497 assert_eq!(config.output_format, "stream-json");
498 assert_eq!(config.permission_mode, PermissionMode::Default);
499 }
500
501 #[test]
502 fn builder_full() {
503 let config = ClientConfig::builder()
504 .prompt("test prompt")
505 .model("claude-opus-4-5")
506 .max_turns(5_u32)
507 .max_budget_usd(1.0_f64)
508 .permission_mode(PermissionMode::AcceptEdits)
509 .verbose(true)
510 .build();
511
512 assert_eq!(config.model.as_deref(), Some("claude-opus-4-5"));
513 assert_eq!(config.max_turns, Some(5));
514 assert_eq!(config.max_budget_usd, Some(1.0));
515 assert_eq!(config.permission_mode, PermissionMode::AcceptEdits);
516 assert!(config.verbose);
517 }
518
519 #[test]
520 fn to_cli_args_minimal() {
521 let config = ClientConfig::builder().prompt("hello").build();
522 let args = config.to_cli_args();
523 assert!(args.contains(&"--output-format".into()));
524 assert!(args.contains(&"stream-json".into()));
525 assert!(args.contains(&"--print".into()));
526 assert!(args.contains(&"hello".into()));
527 }
528
529 #[test]
530 fn to_cli_args_with_model_and_turns() {
531 let config = ClientConfig::builder()
532 .prompt("test")
533 .model("claude-sonnet-4-5")
534 .max_turns(10_u32)
535 .build();
536 let args = config.to_cli_args();
537 assert!(args.contains(&"--model".into()));
538 assert!(args.contains(&"claude-sonnet-4-5".into()));
539 assert!(args.contains(&"--max-turns".into()));
540 assert!(args.contains(&"10".into()));
541 }
542
543 #[test]
544 fn to_cli_args_with_permission_mode() {
545 let config = ClientConfig::builder()
546 .prompt("test")
547 .permission_mode(PermissionMode::BypassPermissions)
548 .build();
549 let args = config.to_cli_args();
550 assert!(args.contains(&"--permission-mode".into()));
551 assert!(args.contains(&"bypassPermissions".into()));
552 }
553
554 #[test]
555 fn to_cli_args_default_permission_mode_not_included() {
556 let config = ClientConfig::builder().prompt("test").build();
557 let args = config.to_cli_args();
558 assert!(!args.contains(&"--permission-mode".into()));
559 }
560
561 #[test]
562 fn to_cli_args_with_system_prompt_text() {
563 let config = ClientConfig::builder()
564 .prompt("test")
565 .system_prompt(SystemPrompt::text("You are a helpful assistant"))
566 .build();
567 let args = config.to_cli_args();
568 assert!(args.contains(&"--system-prompt".into()));
569 assert!(args.contains(&"You are a helpful assistant".into()));
570 }
571
572 #[test]
573 fn to_cli_args_with_mcp_servers() {
574 use crate::mcp::McpServerConfig;
575
576 let mut servers = McpServers::new();
577 servers.insert(
578 "fs".into(),
579 McpServerConfig::new("npx").with_args(["-y", "mcp-fs"]),
580 );
581
582 let config = ClientConfig::builder()
583 .prompt("test")
584 .mcp_servers(servers)
585 .build();
586 let args = config.to_cli_args();
587 assert!(args.contains(&"--mcp-servers".into()));
588 }
589
590 #[test]
591 fn to_env_without_callbacks() {
592 let config = ClientConfig::builder().prompt("test").build();
593 let env = config.to_env();
594 assert!(!env.contains_key("CLAUDE_CODE_SDK_CONTROL_PORT"));
595 }
596
597 #[test]
598 fn to_env_includes_originator_and_headless_defaults() {
599 let config = ClientConfig::builder().prompt("test").build();
600 let env = config.to_env();
601 assert_eq!(
602 env.get("CLAUDE_CODE_SDK_ORIGINATOR"),
603 Some(&"claude_cli_sdk_rs".into())
604 );
605 assert_eq!(env.get("CI"), Some(&"true".into()));
606 assert_eq!(env.get("TERM"), Some(&"dumb".into()));
607 }
608
609 #[test]
610 fn to_env_user_env_overrides_defaults() {
611 let config = ClientConfig::builder()
612 .prompt("test")
613 .env(HashMap::from([
614 ("CI".into(), "false".into()),
615 ("TERM".into(), "xterm-256color".into()),
616 ]))
617 .build();
618 let env = config.to_env();
619 assert_eq!(env.get("CI"), Some(&"false".into()));
621 assert_eq!(env.get("TERM"), Some(&"xterm-256color".into()));
622 assert_eq!(
624 env.get("CLAUDE_CODE_SDK_ORIGINATOR"),
625 Some(&"claude_cli_sdk_rs".into())
626 );
627 }
628
629 #[test]
630 fn to_env_with_hooks_enables_control_port() {
631 use crate::hooks::{HookCallback, HookEvent, HookMatcher, HookOutput};
632 let cb: HookCallback = Arc::new(|_, _, _| Box::pin(async { HookOutput::allow() }));
633 let config = ClientConfig::builder()
634 .prompt("test")
635 .hooks(vec![HookMatcher::new(HookEvent::PreToolUse, cb)])
636 .build();
637 let env = config.to_env();
638 assert_eq!(
639 env.get("CLAUDE_CODE_SDK_CONTROL_PORT"),
640 Some(&"stdin".into())
641 );
642 }
643
644 #[test]
645 fn permission_mode_serde_round_trip() {
646 let modes = [
647 PermissionMode::Default,
648 PermissionMode::AcceptEdits,
649 PermissionMode::Plan,
650 PermissionMode::BypassPermissions,
651 ];
652 for mode in modes {
653 let json = serde_json::to_string(&mode).unwrap();
654 let decoded: PermissionMode = serde_json::from_str(&json).unwrap();
655 assert_eq!(mode, decoded);
656 }
657 }
658
659 #[test]
660 fn system_prompt_text_round_trip() {
661 let sp = SystemPrompt::text("You are helpful");
662 let json = serde_json::to_string(&sp).unwrap();
663 let decoded: SystemPrompt = serde_json::from_str(&json).unwrap();
664 assert_eq!(sp, decoded);
665 }
666
667 #[test]
668 fn system_prompt_preset_round_trip() {
669 let sp = SystemPrompt::Preset {
670 kind: "custom".into(),
671 preset: "coding".into(),
672 append: Some("Also be concise.".into()),
673 };
674 let json = serde_json::to_string(&sp).unwrap();
675 let decoded: SystemPrompt = serde_json::from_str(&json).unwrap();
676 assert_eq!(sp, decoded);
677 }
678
679 #[test]
680 fn debug_does_not_panic() {
681 let config = ClientConfig::builder().prompt("test").build();
682 let _ = format!("{config:?}");
683 }
684
685 #[test]
686 fn to_cli_args_with_allowed_tools() {
687 let config = ClientConfig::builder()
688 .prompt("test")
689 .allowed_tools(vec!["bash".into(), "read_file".into()])
690 .build();
691 let args = config.to_cli_args();
692 let allowed_count = args.iter().filter(|a| *a == "--allowedTools").count();
693 assert_eq!(allowed_count, 2);
694 }
695
696 #[test]
697 fn to_cli_args_with_extra_args_boolean_flag() {
698 let config = ClientConfig::builder()
699 .prompt("test")
700 .extra_args(BTreeMap::from([("replay-user-messages".into(), None)]))
701 .build();
702 let args = config.to_cli_args();
703 assert!(args.contains(&"--replay-user-messages".into()));
704 }
705
706 #[test]
707 fn to_cli_args_with_extra_args_valued_flag() {
708 let config = ClientConfig::builder()
709 .prompt("test")
710 .extra_args(BTreeMap::from([(
711 "context-window".into(),
712 Some("200000".into()),
713 )]))
714 .build();
715 let args = config.to_cli_args();
716 let idx = args.iter().position(|a| a == "--context-window").unwrap();
717 assert_eq!(args[idx + 1], "200000");
718 }
719
720 #[test]
721 fn builder_timeout_defaults() {
722 let config = ClientConfig::builder().prompt("test").build();
723 assert_eq!(config.connect_timeout, Some(Duration::from_secs(30)));
724 assert_eq!(config.close_timeout, Some(Duration::from_secs(10)));
725 assert_eq!(config.read_timeout, None);
726 assert_eq!(config.default_hook_timeout, Duration::from_secs(30));
727 assert_eq!(config.version_check_timeout, Some(Duration::from_secs(5)));
728 }
729
730 #[test]
731 fn builder_custom_timeouts() {
732 let config = ClientConfig::builder()
733 .prompt("test")
734 .connect_timeout(Some(Duration::from_secs(60)))
735 .close_timeout(Some(Duration::from_secs(20)))
736 .read_timeout(Some(Duration::from_secs(120)))
737 .default_hook_timeout(Duration::from_secs(10))
738 .version_check_timeout(Some(Duration::from_secs(15)))
739 .build();
740 assert_eq!(config.connect_timeout, Some(Duration::from_secs(60)));
741 assert_eq!(config.close_timeout, Some(Duration::from_secs(20)));
742 assert_eq!(config.read_timeout, Some(Duration::from_secs(120)));
743 assert_eq!(config.default_hook_timeout, Duration::from_secs(10));
744 assert_eq!(config.version_check_timeout, Some(Duration::from_secs(15)));
745 }
746
747 #[test]
748 fn builder_disable_connect_timeout() {
749 let config = ClientConfig::builder()
750 .prompt("test")
751 .connect_timeout(None::<Duration>)
752 .build();
753 assert_eq!(config.connect_timeout, None);
754 }
755
756 #[test]
757 fn builder_cancellation_token() {
758 let token = CancellationToken::new();
759 let config = ClientConfig::builder()
760 .prompt("test")
761 .cancellation_token(token.clone())
762 .build();
763 assert!(config.cancellation_token.is_some());
764 }
765
766 #[test]
767 fn builder_cancellation_token_default_is_none() {
768 let config = ClientConfig::builder().prompt("test").build();
769 assert!(config.cancellation_token.is_none());
770 }
771
772 #[test]
773 fn to_cli_args_with_resume() {
774 let config = ClientConfig::builder()
775 .prompt("test")
776 .resume("session-123")
777 .build();
778 let args = config.to_cli_args();
779 assert!(args.contains(&"--resume".into()));
780 assert!(args.contains(&"session-123".into()));
781 }
782}