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
32pub const INPUT_FORMAT_STREAM_JSON: &str = "stream-json";
40
41pub const OUTPUT_FORMAT_STREAM_JSON: &str = "stream-json";
46
47#[derive(TypedBuilder)]
53pub struct ClientConfig {
54 #[builder(setter(into))]
57 pub prompt: String,
58
59 #[builder(default, setter(strip_option))]
63 pub cli_path: Option<PathBuf>,
64
65 #[builder(default, setter(strip_option))]
67 pub cwd: Option<PathBuf>,
68
69 #[builder(default, setter(strip_option, into))]
71 pub model: Option<String>,
72
73 #[builder(default, setter(strip_option, into))]
75 pub fallback_model: Option<String>,
76
77 #[builder(default, setter(strip_option))]
79 pub system_prompt: Option<SystemPrompt>,
80
81 #[builder(default, setter(strip_option))]
84 pub max_turns: Option<u32>,
85
86 #[builder(default, setter(strip_option))]
88 pub max_budget_usd: Option<f64>,
89
90 #[builder(default, setter(strip_option))]
92 pub max_thinking_tokens: Option<u32>,
93
94 #[builder(default)]
97 pub allowed_tools: Vec<String>,
98
99 #[builder(default)]
101 pub disallowed_tools: Vec<String>,
102
103 #[builder(default)]
106 pub permission_mode: PermissionMode,
107
108 #[builder(default, setter(strip_option))]
110 pub can_use_tool: Option<CanUseToolCallback>,
111
112 #[builder(default, setter(strip_option, into))]
115 pub resume: Option<String>,
116
117 #[builder(default)]
120 pub hooks: Vec<HookMatcher>,
121
122 #[builder(default)]
125 pub mcp_servers: McpServers,
126
127 #[builder(default, setter(strip_option))]
130 pub message_callback: Option<MessageCallback>,
131
132 #[builder(default)]
135 pub env: HashMap<String, String>,
136
137 #[builder(default)]
139 pub verbose: bool,
140
141 #[builder(default_code = r#""stream-json".into()"#, setter(into))]
150 pub output_format: String,
151
152 #[builder(default)]
177 pub extra_args: BTreeMap<String, Option<String>>,
178
179 #[builder(default_code = "Some(Duration::from_secs(30))")]
184 pub connect_timeout: Option<Duration>,
185
186 #[builder(default_code = "Some(Duration::from_secs(10))")]
191 pub close_timeout: Option<Duration>,
192
193 #[builder(default_code = "true")]
201 pub end_input_on_connect: bool,
202
203 #[builder(default)]
208 pub read_timeout: Option<Duration>,
209
210 #[builder(default_code = "Duration::from_secs(30)")]
215 pub default_hook_timeout: Duration,
216
217 #[builder(default_code = "Some(Duration::from_secs(5))")]
223 pub version_check_timeout: Option<Duration>,
224
225 #[builder(default_code = "Duration::from_secs(30)")]
231 pub control_request_timeout: Duration,
232
233 #[builder(default, setter(strip_option))]
239 pub cancellation_token: Option<CancellationToken>,
240
241 #[builder(default, setter(strip_option))]
244 pub stderr_callback: Option<Arc<dyn Fn(String) + Send + Sync>>,
245
246 #[builder(default, setter(strip_option, into))]
255 pub input_format: Option<String>,
256
257 #[builder(default, setter(strip_option, into))]
270 pub init_stdin_message: Option<String>,
271}
272
273impl std::fmt::Debug for ClientConfig {
274 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
275 f.debug_struct("ClientConfig")
276 .field("prompt", &self.prompt)
277 .field("cli_path", &self.cli_path)
278 .field("cwd", &self.cwd)
279 .field("model", &self.model)
280 .field("permission_mode", &self.permission_mode)
281 .field("max_turns", &self.max_turns)
282 .field("max_budget_usd", &self.max_budget_usd)
283 .field("verbose", &self.verbose)
284 .field("output_format", &self.output_format)
285 .field("connect_timeout", &self.connect_timeout)
286 .field("close_timeout", &self.close_timeout)
287 .field("read_timeout", &self.read_timeout)
288 .field("default_hook_timeout", &self.default_hook_timeout)
289 .field("version_check_timeout", &self.version_check_timeout)
290 .field("control_request_timeout", &self.control_request_timeout)
291 .finish_non_exhaustive()
292 }
293}
294
295#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
299#[serde(rename_all = "snake_case")]
300pub enum PermissionMode {
301 #[default]
303 Default,
304 AcceptEdits,
306 Plan,
308 BypassPermissions,
310}
311
312impl PermissionMode {
313 #[must_use]
315 pub fn as_cli_flag(&self) -> &'static str {
316 match self {
317 Self::Default => "default",
318 Self::AcceptEdits => "acceptEdits",
319 Self::Plan => "plan",
320 Self::BypassPermissions => "bypassPermissions",
321 }
322 }
323}
324
325#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
329#[serde(tag = "type", rename_all = "snake_case")]
330pub enum SystemPrompt {
331 Text {
333 text: String,
335 },
336 Preset {
338 kind: String,
340 preset: String,
342 #[serde(default, skip_serializing_if = "Option::is_none")]
344 append: Option<String>,
345 },
346}
347
348impl SystemPrompt {
349 #[must_use]
351 pub fn text(s: impl Into<String>) -> Self {
352 Self::Text { text: s.into() }
353 }
354
355 #[must_use]
357 pub fn preset(kind: impl Into<String>, preset: impl Into<String>) -> Self {
358 Self::Preset {
359 kind: kind.into(),
360 preset: preset.into(),
361 append: None,
362 }
363 }
364}
365
366impl ClientConfig {
369 pub fn validate(&self) -> crate::errors::Result<()> {
376 if let Some(ref cwd) = self.cwd {
377 if !cwd.exists() {
378 return Err(crate::errors::Error::Config(format!(
379 "working directory does not exist: {}",
380 cwd.display()
381 )));
382 }
383 if !cwd.is_dir() {
384 return Err(crate::errors::Error::Config(format!(
385 "working directory is not a directory: {}",
386 cwd.display()
387 )));
388 }
389 }
390
391 if let Some(ref msg) = self.init_stdin_message {
392 serde_json::from_str::<serde_json::Value>(msg).map_err(|e| {
393 crate::errors::Error::Config(format!(
394 "init_stdin_message is not valid JSON: {e}"
395 ))
396 })?;
397 }
398
399 if self.init_stdin_message.is_some()
400 && self.input_format.as_deref() != Some(INPUT_FORMAT_STREAM_JSON)
401 {
402 return Err(crate::errors::Error::Config(
403 "init_stdin_message requires input_format = \"stream-json\"".into(),
404 ));
405 }
406
407 if self.input_format.is_some() && self.extra_args.contains_key("input-format") {
408 return Err(crate::errors::Error::Config(
409 "input_format and extra_args[\"input-format\"] are mutually exclusive; use input_format".into(),
410 ));
411 }
412
413 Ok(())
414 }
415
416 #[must_use]
420 pub fn to_cli_args(&self) -> Vec<String> {
421 let mut args = vec![
422 "--output-format".into(),
423 self.output_format.clone(),
424 ];
425
426 let uses_stream_input =
430 self.input_format.as_deref() == Some(INPUT_FORMAT_STREAM_JSON);
431
432 if !uses_stream_input {
433 args.push("--print".into());
434 args.push(self.prompt.clone());
435 }
436
437 if let Some(ref fmt) = self.input_format {
438 args.push("--input-format".into());
439 args.push(fmt.clone());
440 }
441
442 if self.output_format == OUTPUT_FORMAT_STREAM_JSON && !self.verbose {
444 args.push("--verbose".into());
445 }
446
447 if let Some(model) = &self.model {
448 args.push("--model".into());
449 args.push(model.clone());
450 }
451
452 if let Some(fallback) = &self.fallback_model {
453 args.push("--fallback-model".into());
454 args.push(fallback.clone());
455 }
456
457 if let Some(turns) = self.max_turns {
458 args.push("--max-turns".into());
459 args.push(turns.to_string());
460 }
461
462 if let Some(budget) = self.max_budget_usd {
463 args.push("--max-budget-usd".into());
464 args.push(budget.to_string());
465 }
466
467 if let Some(thinking) = self.max_thinking_tokens {
468 args.push("--max-thinking-tokens".into());
469 args.push(thinking.to_string());
470 }
471
472 if self.permission_mode != PermissionMode::Default {
473 args.push("--permission-mode".into());
474 args.push(self.permission_mode.as_cli_flag().into());
475 }
476
477 if let Some(resume) = &self.resume {
478 args.push("--resume".into());
479 args.push(resume.clone());
480 }
481
482 if self.verbose {
483 args.push("--verbose".into());
484 }
485
486 for tool in &self.allowed_tools {
487 args.push("--allowedTools".into());
488 args.push(tool.clone());
489 }
490
491 for tool in &self.disallowed_tools {
492 args.push("--disallowedTools".into());
493 args.push(tool.clone());
494 }
495
496 if !self.mcp_servers.is_empty() {
497 let json = serde_json::to_string(&self.mcp_servers)
498 .expect("McpServers serialization is infallible");
499 args.push("--mcp-servers".into());
500 args.push(json);
501 }
502
503 if let Some(prompt) = &self.system_prompt {
504 match prompt {
505 SystemPrompt::Text { text } => {
506 args.push("--system-prompt".into());
507 args.push(text.clone());
508 }
509 SystemPrompt::Preset { preset, append, .. } => {
510 args.push("--system-prompt-preset".into());
511 args.push(preset.clone());
512 if let Some(append_text) = append {
513 args.push("--append-system-prompt".into());
514 args.push(append_text.clone());
515 }
516 }
517 }
518 }
519
520 for (key, value) in &self.extra_args {
522 args.push(format!("--{key}"));
523 if let Some(v) = value {
524 args.push(v.clone());
525 }
526 }
527
528 args
529 }
530
531 #[must_use]
544 pub fn to_env(&self) -> HashMap<String, String> {
545 let mut env = HashMap::new();
546
547 env.insert(
549 "CLAUDE_CODE_SDK_ORIGINATOR".into(),
550 "claude_cli_sdk_rs".into(),
551 );
552 env.insert("TERM".into(), "dumb".into());
553
554 env.extend(self.env.clone());
556
557 if self.can_use_tool.is_some() || !self.hooks.is_empty() {
559 env.insert("CLAUDE_CODE_SDK_CONTROL_PORT".into(), "stdin".into());
560 }
561
562 env
563 }
564}
565
566#[cfg(test)]
569mod tests {
570 use super::*;
571
572 #[test]
573 fn builder_minimal() {
574 let config = ClientConfig::builder().prompt("hello").build();
575 assert_eq!(config.prompt, "hello");
576 assert_eq!(config.output_format, "stream-json");
577 assert_eq!(config.permission_mode, PermissionMode::Default);
578 }
579
580 #[test]
581 fn builder_full() {
582 let config = ClientConfig::builder()
583 .prompt("test prompt")
584 .model("claude-opus-4-5")
585 .max_turns(5_u32)
586 .max_budget_usd(1.0_f64)
587 .permission_mode(PermissionMode::AcceptEdits)
588 .verbose(true)
589 .build();
590
591 assert_eq!(config.model.as_deref(), Some("claude-opus-4-5"));
592 assert_eq!(config.max_turns, Some(5));
593 assert_eq!(config.max_budget_usd, Some(1.0));
594 assert_eq!(config.permission_mode, PermissionMode::AcceptEdits);
595 assert!(config.verbose);
596 }
597
598 #[test]
599 fn to_cli_args_minimal() {
600 let config = ClientConfig::builder().prompt("hello").build();
601 let args = config.to_cli_args();
602 assert!(args.contains(&"--output-format".into()));
603 assert!(args.contains(&"stream-json".into()));
604 assert!(args.contains(&"--print".into()));
605 assert!(args.contains(&"hello".into()));
606 }
607
608 #[test]
609 fn to_cli_args_with_model_and_turns() {
610 let config = ClientConfig::builder()
611 .prompt("test")
612 .model("claude-sonnet-4-5")
613 .max_turns(10_u32)
614 .build();
615 let args = config.to_cli_args();
616 assert!(args.contains(&"--model".into()));
617 assert!(args.contains(&"claude-sonnet-4-5".into()));
618 assert!(args.contains(&"--max-turns".into()));
619 assert!(args.contains(&"10".into()));
620 }
621
622 #[test]
623 fn to_cli_args_with_permission_mode() {
624 let config = ClientConfig::builder()
625 .prompt("test")
626 .permission_mode(PermissionMode::BypassPermissions)
627 .build();
628 let args = config.to_cli_args();
629 assert!(args.contains(&"--permission-mode".into()));
630 assert!(args.contains(&"bypassPermissions".into()));
631 }
632
633 #[test]
634 fn to_cli_args_default_permission_mode_not_included() {
635 let config = ClientConfig::builder().prompt("test").build();
636 let args = config.to_cli_args();
637 assert!(!args.contains(&"--permission-mode".into()));
638 }
639
640 #[test]
641 fn to_cli_args_with_system_prompt_text() {
642 let config = ClientConfig::builder()
643 .prompt("test")
644 .system_prompt(SystemPrompt::text("You are a helpful assistant"))
645 .build();
646 let args = config.to_cli_args();
647 assert!(args.contains(&"--system-prompt".into()));
648 assert!(args.contains(&"You are a helpful assistant".into()));
649 }
650
651 #[test]
652 fn to_cli_args_with_mcp_servers() {
653 use crate::mcp::McpServerConfig;
654
655 let mut servers = McpServers::new();
656 servers.insert(
657 "fs".into(),
658 McpServerConfig::new("npx").with_args(["-y", "mcp-fs"]),
659 );
660
661 let config = ClientConfig::builder()
662 .prompt("test")
663 .mcp_servers(servers)
664 .build();
665 let args = config.to_cli_args();
666 assert!(args.contains(&"--mcp-servers".into()));
667 }
668
669 #[test]
670 fn to_env_without_callbacks() {
671 let config = ClientConfig::builder().prompt("test").build();
672 let env = config.to_env();
673 assert!(!env.contains_key("CLAUDE_CODE_SDK_CONTROL_PORT"));
674 }
675
676 #[test]
677 fn to_env_includes_originator_and_headless_defaults() {
678 let config = ClientConfig::builder().prompt("test").build();
679 let env = config.to_env();
680 assert_eq!(
681 env.get("CLAUDE_CODE_SDK_ORIGINATOR"),
682 Some(&"claude_cli_sdk_rs".into())
683 );
684 assert!(!env.contains_key("CI"), "CI must not be set by default");
685 assert_eq!(env.get("TERM"), Some(&"dumb".into()));
686 }
687
688 #[test]
689 fn to_env_user_env_overrides_defaults() {
690 let config = ClientConfig::builder()
691 .prompt("test")
692 .env(HashMap::from([("TERM".into(), "xterm-256color".into())]))
693 .build();
694 let env = config.to_env();
695 assert_eq!(env.get("TERM"), Some(&"xterm-256color".into()));
697 assert_eq!(
699 env.get("CLAUDE_CODE_SDK_ORIGINATOR"),
700 Some(&"claude_cli_sdk_rs".into())
701 );
702 }
703
704 #[test]
705 fn to_env_with_hooks_enables_control_port() {
706 use crate::hooks::{HookCallback, HookEvent, HookMatcher, HookOutput};
707 let cb: HookCallback = Arc::new(|_, _, _| Box::pin(async { HookOutput::allow() }));
708 let config = ClientConfig::builder()
709 .prompt("test")
710 .hooks(vec![HookMatcher::new(HookEvent::PreToolUse, cb)])
711 .build();
712 let env = config.to_env();
713 assert_eq!(
714 env.get("CLAUDE_CODE_SDK_CONTROL_PORT"),
715 Some(&"stdin".into())
716 );
717 }
718
719 #[test]
720 fn permission_mode_serde_round_trip() {
721 let modes = [
722 PermissionMode::Default,
723 PermissionMode::AcceptEdits,
724 PermissionMode::Plan,
725 PermissionMode::BypassPermissions,
726 ];
727 for mode in modes {
728 let json = serde_json::to_string(&mode).unwrap();
729 let decoded: PermissionMode = serde_json::from_str(&json).unwrap();
730 assert_eq!(mode, decoded);
731 }
732 }
733
734 #[test]
735 fn system_prompt_text_round_trip() {
736 let sp = SystemPrompt::text("You are helpful");
737 let json = serde_json::to_string(&sp).unwrap();
738 let decoded: SystemPrompt = serde_json::from_str(&json).unwrap();
739 assert_eq!(sp, decoded);
740 }
741
742 #[test]
743 fn system_prompt_preset_round_trip() {
744 let sp = SystemPrompt::Preset {
745 kind: "custom".into(),
746 preset: "coding".into(),
747 append: Some("Also be concise.".into()),
748 };
749 let json = serde_json::to_string(&sp).unwrap();
750 let decoded: SystemPrompt = serde_json::from_str(&json).unwrap();
751 assert_eq!(sp, decoded);
752 }
753
754 #[test]
755 fn debug_does_not_panic() {
756 let config = ClientConfig::builder().prompt("test").build();
757 let _ = format!("{config:?}");
758 }
759
760 #[test]
761 fn to_cli_args_with_allowed_tools() {
762 let config = ClientConfig::builder()
763 .prompt("test")
764 .allowed_tools(vec!["bash".into(), "read_file".into()])
765 .build();
766 let args = config.to_cli_args();
767 let allowed_count = args.iter().filter(|a| *a == "--allowedTools").count();
768 assert_eq!(allowed_count, 2);
769 }
770
771 #[test]
772 fn to_cli_args_with_extra_args_boolean_flag() {
773 let config = ClientConfig::builder()
774 .prompt("test")
775 .extra_args(BTreeMap::from([("replay-user-messages".into(), None)]))
776 .build();
777 let args = config.to_cli_args();
778 assert!(args.contains(&"--replay-user-messages".into()));
779 }
780
781 #[test]
782 fn to_cli_args_with_extra_args_valued_flag() {
783 let config = ClientConfig::builder()
784 .prompt("test")
785 .extra_args(BTreeMap::from([(
786 "context-window".into(),
787 Some("200000".into()),
788 )]))
789 .build();
790 let args = config.to_cli_args();
791 let idx = args.iter().position(|a| a == "--context-window").unwrap();
792 assert_eq!(args[idx + 1], "200000");
793 }
794
795 #[test]
796 fn builder_timeout_defaults() {
797 let config = ClientConfig::builder().prompt("test").build();
798 assert_eq!(config.connect_timeout, Some(Duration::from_secs(30)));
799 assert_eq!(config.close_timeout, Some(Duration::from_secs(10)));
800 assert_eq!(config.read_timeout, None);
801 assert_eq!(config.default_hook_timeout, Duration::from_secs(30));
802 assert_eq!(config.version_check_timeout, Some(Duration::from_secs(5)));
803 }
804
805 #[test]
806 fn builder_custom_timeouts() {
807 let config = ClientConfig::builder()
808 .prompt("test")
809 .connect_timeout(Some(Duration::from_secs(60)))
810 .close_timeout(Some(Duration::from_secs(20)))
811 .read_timeout(Some(Duration::from_secs(120)))
812 .default_hook_timeout(Duration::from_secs(10))
813 .version_check_timeout(Some(Duration::from_secs(15)))
814 .build();
815 assert_eq!(config.connect_timeout, Some(Duration::from_secs(60)));
816 assert_eq!(config.close_timeout, Some(Duration::from_secs(20)));
817 assert_eq!(config.read_timeout, Some(Duration::from_secs(120)));
818 assert_eq!(config.default_hook_timeout, Duration::from_secs(10));
819 assert_eq!(config.version_check_timeout, Some(Duration::from_secs(15)));
820 }
821
822 #[test]
823 fn builder_disable_connect_timeout() {
824 let config = ClientConfig::builder()
825 .prompt("test")
826 .connect_timeout(None::<Duration>)
827 .build();
828 assert_eq!(config.connect_timeout, None);
829 }
830
831 #[test]
832 fn builder_cancellation_token() {
833 let token = CancellationToken::new();
834 let config = ClientConfig::builder()
835 .prompt("test")
836 .cancellation_token(token.clone())
837 .build();
838 assert!(config.cancellation_token.is_some());
839 }
840
841 #[test]
842 fn builder_cancellation_token_default_is_none() {
843 let config = ClientConfig::builder().prompt("test").build();
844 assert!(config.cancellation_token.is_none());
845 }
846
847 #[test]
848 fn to_cli_args_with_resume() {
849 let config = ClientConfig::builder()
850 .prompt("test")
851 .resume("session-123")
852 .build();
853 let args = config.to_cli_args();
854 assert!(args.contains(&"--resume".into()));
855 assert!(args.contains(&"session-123".into()));
856 }
857
858 #[test]
859 fn to_cli_args_stream_input_format_omits_print() {
860 let config = ClientConfig::builder()
861 .prompt("ignored")
862 .input_format(INPUT_FORMAT_STREAM_JSON)
863 .build();
864 let args = config.to_cli_args();
865 assert!(!args.contains(&"--print".into()), "--print must be absent in stream-json input mode");
866 assert!(args.contains(&"--input-format".into()));
867 let idx = args.iter().position(|a| a == "--input-format").unwrap();
868 assert_eq!(args[idx + 1], INPUT_FORMAT_STREAM_JSON);
869 }
870
871 #[test]
872 fn to_cli_args_input_format_emitted() {
873 let config = ClientConfig::builder()
874 .prompt("test")
875 .input_format("custom-format")
876 .build();
877 let args = config.to_cli_args();
878 assert!(args.contains(&"--input-format".into()));
879 let idx = args.iter().position(|a| a == "--input-format").unwrap();
880 assert_eq!(args[idx + 1], "custom-format");
881 }
882
883 #[test]
884 fn validate_init_stdin_message_valid_json() {
885 let config = ClientConfig::builder()
886 .prompt("ignored")
887 .input_format(INPUT_FORMAT_STREAM_JSON)
888 .init_stdin_message(r#"{"type":"user","message":{"role":"user","content":"hello"}}"#)
889 .build();
890 assert!(config.validate().is_ok());
891 }
892
893 #[test]
894 fn validate_init_stdin_message_invalid_json() {
895 let config = ClientConfig::builder()
896 .prompt("ignored")
897 .input_format(INPUT_FORMAT_STREAM_JSON)
898 .init_stdin_message("not valid json {")
899 .build();
900 let err = config.validate().unwrap_err();
901 assert!(
902 matches!(err, crate::errors::Error::Config(ref msg) if msg.contains("not valid JSON")),
903 "expected Config error about JSON validity, got: {err:?}"
904 );
905 }
906
907 #[test]
908 fn validate_init_stdin_message_without_input_format() {
909 let config = ClientConfig::builder()
910 .prompt("ignored")
911 .init_stdin_message(r#"{"type":"user"}"#)
912 .build();
913 let err = config.validate().unwrap_err();
914 assert!(
915 matches!(err, crate::errors::Error::Config(ref msg) if msg.contains("input_format")),
916 "expected Config error about missing input_format, got: {err:?}"
917 );
918 }
919}