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#""json".into()"#, setter(into))]
129 pub output_format: String,
130
131 #[builder(default)]
156 pub extra_args: BTreeMap<String, Option<String>>,
157
158 #[builder(default_code = "Some(Duration::from_secs(30))")]
163 pub connect_timeout: Option<Duration>,
164
165 #[builder(default_code = "Some(Duration::from_secs(10))")]
170 pub close_timeout: Option<Duration>,
171
172 #[builder(default)]
177 pub read_timeout: Option<Duration>,
178
179 #[builder(default_code = "Duration::from_secs(30)")]
184 pub default_hook_timeout: Duration,
185
186 #[builder(default_code = "Some(Duration::from_secs(5))")]
192 pub version_check_timeout: Option<Duration>,
193
194 #[builder(default, setter(strip_option))]
200 pub cancellation_token: Option<CancellationToken>,
201
202 #[builder(default, setter(strip_option))]
205 pub stderr_callback: Option<Arc<dyn Fn(String) + Send + Sync>>,
206}
207
208impl std::fmt::Debug for ClientConfig {
209 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
210 f.debug_struct("ClientConfig")
211 .field("prompt", &self.prompt)
212 .field("cli_path", &self.cli_path)
213 .field("cwd", &self.cwd)
214 .field("model", &self.model)
215 .field("permission_mode", &self.permission_mode)
216 .field("max_turns", &self.max_turns)
217 .field("max_budget_usd", &self.max_budget_usd)
218 .field("verbose", &self.verbose)
219 .field("output_format", &self.output_format)
220 .field("connect_timeout", &self.connect_timeout)
221 .field("close_timeout", &self.close_timeout)
222 .field("read_timeout", &self.read_timeout)
223 .field("default_hook_timeout", &self.default_hook_timeout)
224 .field("version_check_timeout", &self.version_check_timeout)
225 .finish_non_exhaustive()
226 }
227}
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
233#[serde(rename_all = "snake_case")]
234pub enum PermissionMode {
235 #[default]
237 Default,
238 AcceptEdits,
240 Plan,
242 BypassPermissions,
244}
245
246impl PermissionMode {
247 #[must_use]
249 pub fn as_cli_flag(&self) -> &'static str {
250 match self {
251 Self::Default => "default",
252 Self::AcceptEdits => "acceptEdits",
253 Self::Plan => "plan",
254 Self::BypassPermissions => "bypassPermissions",
255 }
256 }
257}
258
259#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
263#[serde(tag = "type", rename_all = "snake_case")]
264pub enum SystemPrompt {
265 Text {
267 text: String,
269 },
270 Preset {
272 kind: String,
274 preset: String,
276 #[serde(default, skip_serializing_if = "Option::is_none")]
278 append: Option<String>,
279 },
280}
281
282impl SystemPrompt {
283 #[must_use]
285 pub fn text(s: impl Into<String>) -> Self {
286 Self::Text { text: s.into() }
287 }
288
289 #[must_use]
291 pub fn preset(kind: impl Into<String>, preset: impl Into<String>) -> Self {
292 Self::Preset {
293 kind: kind.into(),
294 preset: preset.into(),
295 append: None,
296 }
297 }
298}
299
300impl ClientConfig {
303 pub fn validate(&self) -> crate::errors::Result<()> {
310 if let Some(ref cwd) = self.cwd {
311 if !cwd.exists() {
312 return Err(crate::errors::Error::Config(format!(
313 "working directory does not exist: {}",
314 cwd.display()
315 )));
316 }
317 if !cwd.is_dir() {
318 return Err(crate::errors::Error::Config(format!(
319 "working directory is not a directory: {}",
320 cwd.display()
321 )));
322 }
323 }
324 Ok(())
325 }
326
327 #[must_use]
331 pub fn to_cli_args(&self) -> Vec<String> {
332 let mut args = vec![
333 "--output-format".into(),
334 self.output_format.clone(),
335 "--print".into(),
336 self.prompt.clone(),
337 ];
338
339 if let Some(model) = &self.model {
340 args.push("--model".into());
341 args.push(model.clone());
342 }
343
344 if let Some(fallback) = &self.fallback_model {
345 args.push("--fallback-model".into());
346 args.push(fallback.clone());
347 }
348
349 if let Some(turns) = self.max_turns {
350 args.push("--max-turns".into());
351 args.push(turns.to_string());
352 }
353
354 if let Some(budget) = self.max_budget_usd {
355 args.push("--max-budget-usd".into());
356 args.push(budget.to_string());
357 }
358
359 if let Some(thinking) = self.max_thinking_tokens {
360 args.push("--max-thinking-tokens".into());
361 args.push(thinking.to_string());
362 }
363
364 if self.permission_mode != PermissionMode::Default {
365 args.push("--permission-mode".into());
366 args.push(self.permission_mode.as_cli_flag().into());
367 }
368
369 if let Some(resume) = &self.resume {
370 args.push("--resume".into());
371 args.push(resume.clone());
372 }
373
374 if self.verbose {
375 args.push("--verbose".into());
376 }
377
378 for tool in &self.allowed_tools {
379 args.push("--allowedTools".into());
380 args.push(tool.clone());
381 }
382
383 for tool in &self.disallowed_tools {
384 args.push("--disallowedTools".into());
385 args.push(tool.clone());
386 }
387
388 if !self.mcp_servers.is_empty() {
389 let json = serde_json::to_string(&self.mcp_servers)
390 .expect("McpServers serialization is infallible");
391 args.push("--mcp-servers".into());
392 args.push(json);
393 }
394
395 if let Some(prompt) = &self.system_prompt {
396 match prompt {
397 SystemPrompt::Text { text } => {
398 args.push("--system-prompt".into());
399 args.push(text.clone());
400 }
401 SystemPrompt::Preset { preset, append, .. } => {
402 args.push("--system-prompt-preset".into());
403 args.push(preset.clone());
404 if let Some(append_text) = append {
405 args.push("--append-system-prompt".into());
406 args.push(append_text.clone());
407 }
408 }
409 }
410 }
411
412 for (key, value) in &self.extra_args {
414 args.push(format!("--{key}"));
415 if let Some(v) = value {
416 args.push(v.clone());
417 }
418 }
419
420 args
421 }
422
423 #[must_use]
433 pub fn to_env(&self) -> HashMap<String, String> {
434 let mut env = HashMap::new();
435
436 env.insert(
438 "CLAUDE_CODE_SDK_ORIGINATOR".into(),
439 "claude_cli_sdk_rs".into(),
440 );
441 env.insert("CI".into(), "true".into());
442 env.insert("TERM".into(), "dumb".into());
443
444 env.extend(self.env.clone());
446
447 if self.can_use_tool.is_some() || !self.hooks.is_empty() {
449 env.insert("CLAUDE_CODE_SDK_CONTROL_PORT".into(), "stdin".into());
450 }
451
452 env
453 }
454}
455
456#[cfg(test)]
459mod tests {
460 use super::*;
461
462 #[test]
463 fn builder_minimal() {
464 let config = ClientConfig::builder().prompt("hello").build();
465 assert_eq!(config.prompt, "hello");
466 assert_eq!(config.output_format, "json");
467 assert_eq!(config.permission_mode, PermissionMode::Default);
468 }
469
470 #[test]
471 fn builder_full() {
472 let config = ClientConfig::builder()
473 .prompt("test prompt")
474 .model("claude-opus-4-5")
475 .max_turns(5_u32)
476 .max_budget_usd(1.0_f64)
477 .permission_mode(PermissionMode::AcceptEdits)
478 .verbose(true)
479 .build();
480
481 assert_eq!(config.model.as_deref(), Some("claude-opus-4-5"));
482 assert_eq!(config.max_turns, Some(5));
483 assert_eq!(config.max_budget_usd, Some(1.0));
484 assert_eq!(config.permission_mode, PermissionMode::AcceptEdits);
485 assert!(config.verbose);
486 }
487
488 #[test]
489 fn to_cli_args_minimal() {
490 let config = ClientConfig::builder().prompt("hello").build();
491 let args = config.to_cli_args();
492 assert!(args.contains(&"--output-format".into()));
493 assert!(args.contains(&"json".into()));
494 assert!(args.contains(&"--print".into()));
495 assert!(args.contains(&"hello".into()));
496 }
497
498 #[test]
499 fn to_cli_args_with_model_and_turns() {
500 let config = ClientConfig::builder()
501 .prompt("test")
502 .model("claude-sonnet-4-5")
503 .max_turns(10_u32)
504 .build();
505 let args = config.to_cli_args();
506 assert!(args.contains(&"--model".into()));
507 assert!(args.contains(&"claude-sonnet-4-5".into()));
508 assert!(args.contains(&"--max-turns".into()));
509 assert!(args.contains(&"10".into()));
510 }
511
512 #[test]
513 fn to_cli_args_with_permission_mode() {
514 let config = ClientConfig::builder()
515 .prompt("test")
516 .permission_mode(PermissionMode::BypassPermissions)
517 .build();
518 let args = config.to_cli_args();
519 assert!(args.contains(&"--permission-mode".into()));
520 assert!(args.contains(&"bypassPermissions".into()));
521 }
522
523 #[test]
524 fn to_cli_args_default_permission_mode_not_included() {
525 let config = ClientConfig::builder().prompt("test").build();
526 let args = config.to_cli_args();
527 assert!(!args.contains(&"--permission-mode".into()));
528 }
529
530 #[test]
531 fn to_cli_args_with_system_prompt_text() {
532 let config = ClientConfig::builder()
533 .prompt("test")
534 .system_prompt(SystemPrompt::text("You are a helpful assistant"))
535 .build();
536 let args = config.to_cli_args();
537 assert!(args.contains(&"--system-prompt".into()));
538 assert!(args.contains(&"You are a helpful assistant".into()));
539 }
540
541 #[test]
542 fn to_cli_args_with_mcp_servers() {
543 use crate::mcp::McpServerConfig;
544
545 let mut servers = McpServers::new();
546 servers.insert(
547 "fs".into(),
548 McpServerConfig::new("npx").with_args(["-y", "mcp-fs"]),
549 );
550
551 let config = ClientConfig::builder()
552 .prompt("test")
553 .mcp_servers(servers)
554 .build();
555 let args = config.to_cli_args();
556 assert!(args.contains(&"--mcp-servers".into()));
557 }
558
559 #[test]
560 fn to_env_without_callbacks() {
561 let config = ClientConfig::builder().prompt("test").build();
562 let env = config.to_env();
563 assert!(!env.contains_key("CLAUDE_CODE_SDK_CONTROL_PORT"));
564 }
565
566 #[test]
567 fn to_env_includes_originator_and_headless_defaults() {
568 let config = ClientConfig::builder().prompt("test").build();
569 let env = config.to_env();
570 assert_eq!(
571 env.get("CLAUDE_CODE_SDK_ORIGINATOR"),
572 Some(&"claude_cli_sdk_rs".into())
573 );
574 assert_eq!(env.get("CI"), Some(&"true".into()));
575 assert_eq!(env.get("TERM"), Some(&"dumb".into()));
576 }
577
578 #[test]
579 fn to_env_user_env_overrides_defaults() {
580 let config = ClientConfig::builder()
581 .prompt("test")
582 .env(HashMap::from([
583 ("CI".into(), "false".into()),
584 ("TERM".into(), "xterm-256color".into()),
585 ]))
586 .build();
587 let env = config.to_env();
588 assert_eq!(env.get("CI"), Some(&"false".into()));
590 assert_eq!(env.get("TERM"), Some(&"xterm-256color".into()));
591 assert_eq!(
593 env.get("CLAUDE_CODE_SDK_ORIGINATOR"),
594 Some(&"claude_cli_sdk_rs".into())
595 );
596 }
597
598 #[test]
599 fn to_env_with_hooks_enables_control_port() {
600 use crate::hooks::{HookCallback, HookEvent, HookMatcher, HookOutput};
601 let cb: HookCallback = Arc::new(|_, _, _| Box::pin(async { HookOutput::allow() }));
602 let config = ClientConfig::builder()
603 .prompt("test")
604 .hooks(vec![HookMatcher::new(HookEvent::PreToolUse, cb)])
605 .build();
606 let env = config.to_env();
607 assert_eq!(
608 env.get("CLAUDE_CODE_SDK_CONTROL_PORT"),
609 Some(&"stdin".into())
610 );
611 }
612
613 #[test]
614 fn permission_mode_serde_round_trip() {
615 let modes = [
616 PermissionMode::Default,
617 PermissionMode::AcceptEdits,
618 PermissionMode::Plan,
619 PermissionMode::BypassPermissions,
620 ];
621 for mode in modes {
622 let json = serde_json::to_string(&mode).unwrap();
623 let decoded: PermissionMode = serde_json::from_str(&json).unwrap();
624 assert_eq!(mode, decoded);
625 }
626 }
627
628 #[test]
629 fn system_prompt_text_round_trip() {
630 let sp = SystemPrompt::text("You are helpful");
631 let json = serde_json::to_string(&sp).unwrap();
632 let decoded: SystemPrompt = serde_json::from_str(&json).unwrap();
633 assert_eq!(sp, decoded);
634 }
635
636 #[test]
637 fn system_prompt_preset_round_trip() {
638 let sp = SystemPrompt::Preset {
639 kind: "custom".into(),
640 preset: "coding".into(),
641 append: Some("Also be concise.".into()),
642 };
643 let json = serde_json::to_string(&sp).unwrap();
644 let decoded: SystemPrompt = serde_json::from_str(&json).unwrap();
645 assert_eq!(sp, decoded);
646 }
647
648 #[test]
649 fn debug_does_not_panic() {
650 let config = ClientConfig::builder().prompt("test").build();
651 let _ = format!("{config:?}");
652 }
653
654 #[test]
655 fn to_cli_args_with_allowed_tools() {
656 let config = ClientConfig::builder()
657 .prompt("test")
658 .allowed_tools(vec!["bash".into(), "read_file".into()])
659 .build();
660 let args = config.to_cli_args();
661 let allowed_count = args.iter().filter(|a| *a == "--allowedTools").count();
662 assert_eq!(allowed_count, 2);
663 }
664
665 #[test]
666 fn to_cli_args_with_extra_args_boolean_flag() {
667 let config = ClientConfig::builder()
668 .prompt("test")
669 .extra_args(BTreeMap::from([("replay-user-messages".into(), None)]))
670 .build();
671 let args = config.to_cli_args();
672 assert!(args.contains(&"--replay-user-messages".into()));
673 }
674
675 #[test]
676 fn to_cli_args_with_extra_args_valued_flag() {
677 let config = ClientConfig::builder()
678 .prompt("test")
679 .extra_args(BTreeMap::from([(
680 "context-window".into(),
681 Some("200000".into()),
682 )]))
683 .build();
684 let args = config.to_cli_args();
685 let idx = args.iter().position(|a| a == "--context-window").unwrap();
686 assert_eq!(args[idx + 1], "200000");
687 }
688
689 #[test]
690 fn builder_timeout_defaults() {
691 let config = ClientConfig::builder().prompt("test").build();
692 assert_eq!(config.connect_timeout, Some(Duration::from_secs(30)));
693 assert_eq!(config.close_timeout, Some(Duration::from_secs(10)));
694 assert_eq!(config.read_timeout, None);
695 assert_eq!(config.default_hook_timeout, Duration::from_secs(30));
696 assert_eq!(config.version_check_timeout, Some(Duration::from_secs(5)));
697 }
698
699 #[test]
700 fn builder_custom_timeouts() {
701 let config = ClientConfig::builder()
702 .prompt("test")
703 .connect_timeout(Some(Duration::from_secs(60)))
704 .close_timeout(Some(Duration::from_secs(20)))
705 .read_timeout(Some(Duration::from_secs(120)))
706 .default_hook_timeout(Duration::from_secs(10))
707 .version_check_timeout(Some(Duration::from_secs(15)))
708 .build();
709 assert_eq!(config.connect_timeout, Some(Duration::from_secs(60)));
710 assert_eq!(config.close_timeout, Some(Duration::from_secs(20)));
711 assert_eq!(config.read_timeout, Some(Duration::from_secs(120)));
712 assert_eq!(config.default_hook_timeout, Duration::from_secs(10));
713 assert_eq!(config.version_check_timeout, Some(Duration::from_secs(15)));
714 }
715
716 #[test]
717 fn builder_disable_connect_timeout() {
718 let config = ClientConfig::builder()
719 .prompt("test")
720 .connect_timeout(None::<Duration>)
721 .build();
722 assert_eq!(config.connect_timeout, None);
723 }
724
725 #[test]
726 fn builder_cancellation_token() {
727 let token = CancellationToken::new();
728 let config = ClientConfig::builder()
729 .prompt("test")
730 .cancellation_token(token.clone())
731 .build();
732 assert!(config.cancellation_token.is_some());
733 }
734
735 #[test]
736 fn builder_cancellation_token_default_is_none() {
737 let config = ClientConfig::builder().prompt("test").build();
738 assert!(config.cancellation_token.is_none());
739 }
740
741 #[test]
742 fn to_cli_args_with_resume() {
743 let config = ClientConfig::builder()
744 .prompt("test")
745 .resume("session-123")
746 .build();
747 let args = config.to_cli_args();
748 assert!(args.contains(&"--resume".into()));
749 assert!(args.contains(&"session-123".into()));
750 }
751}