1use super::ccs_env::{load_ccs_env_vars, CcsEnvVarsError};
7use super::fallback::FallbackConfig;
8use super::parser::JsonParserType;
9use serde::Deserialize;
10use std::collections::HashMap;
11use std::fs;
12use std::io;
13use std::path::{Path, PathBuf};
14
15pub const DEFAULT_AGENTS_TOML: &str = include_str!("../../examples/agents.toml");
17
18#[derive(Debug, Clone)]
20pub struct ConfigSource {
21 pub path: PathBuf,
22 pub agents_loaded: usize,
23}
24
25#[derive(Debug, Clone)]
27pub struct AgentConfig {
28 pub cmd: String,
30 pub output_flag: String,
32 pub yolo_flag: String,
34 pub verbose_flag: String,
36 pub can_commit: bool,
38 pub json_parser: JsonParserType,
40 pub model_flag: Option<String>,
42 pub print_flag: String,
44 pub streaming_flag: String,
47 pub session_flag: String,
51 pub env_vars: std::collections::HashMap<String, String>,
54 pub display_name: Option<String>,
57}
58
59impl Default for AgentConfig {
60 fn default() -> Self {
61 Self {
62 cmd: String::new(),
63 output_flag: String::new(),
64 yolo_flag: String::new(),
65 verbose_flag: String::new(),
66 can_commit: true,
67 json_parser: JsonParserType::Generic,
68 model_flag: None,
69 print_flag: String::new(),
70 streaming_flag: String::new(),
71 session_flag: String::new(),
72 env_vars: std::collections::HashMap::new(),
73 display_name: None,
74 }
75 }
76}
77
78impl AgentConfig {
79 pub fn builder() -> AgentConfigBuilder {
81 AgentConfigBuilder::default()
82 }
83
84 pub fn build_cmd(&self, output: bool, yolo: bool, verbose: bool) -> String {
86 self.build_cmd_with_model(output, yolo, verbose, None)
87 }
88
89 pub fn build_cmd_with_model(
91 &self,
92 output: bool,
93 yolo: bool,
94 verbose: bool,
95 model_override: Option<&str>,
96 ) -> String {
97 let mut parts = vec![self.cmd.clone()];
98
99 if !self.print_flag.is_empty() {
101 parts.push(self.print_flag.clone());
102 }
103
104 if output && !self.output_flag.is_empty() {
105 parts.push(self.output_flag.clone());
106 }
107
108 if output
111 && !self.output_flag.is_empty()
112 && self.output_flag.contains("stream-json")
113 && !self.print_flag.is_empty()
114 && !self.streaming_flag.is_empty()
115 {
116 parts.push(self.streaming_flag.clone());
117 }
118 if yolo && !self.yolo_flag.is_empty() {
119 parts.push(self.yolo_flag.clone());
120 }
121
122 let needs_verbose = verbose || self.requires_verbose_for_json(output);
124
125 if needs_verbose && !self.verbose_flag.is_empty() {
126 parts.push(self.verbose_flag.clone());
127 }
128
129 let effective_model = model_override.or(self.model_flag.as_deref());
131 if let Some(model) = effective_model {
132 if !model.is_empty() {
133 parts.push(model.to_string());
134 }
135 }
136
137 parts.join(" ")
138 }
139
140 pub fn build_cmd_with_session(
157 &self,
158 output: bool,
159 yolo: bool,
160 verbose: bool,
161 model_override: Option<&str>,
162 session_id: Option<&str>,
163 ) -> String {
164 let mut cmd = self.build_cmd_with_model(output, yolo, verbose, model_override);
165
166 if let Some(sid) = session_id {
168 if !self.session_flag.is_empty() {
169 let session_arg = self.session_flag.replace("{}", sid);
170 cmd.push(' ');
171 cmd.push_str(&session_arg);
172 }
173 }
174
175 cmd
176 }
177
178 pub fn supports_session_continuation(&self) -> bool {
180 !self.session_flag.is_empty()
181 }
182
183 fn requires_verbose_for_json(&self, json_enabled: bool) -> bool {
185 if !json_enabled || !self.output_flag.contains("stream-json") {
186 return false;
187 }
188
189 let base = self.cmd.split_whitespace().next().unwrap_or("");
192 let exe_name = Path::new(base)
194 .file_name()
195 .and_then(|n| n.to_str())
196 .unwrap_or(base);
197 matches!(exe_name, "claude" | "ccs")
198 }
199}
200
201#[derive(Default, Debug, Clone)]
218pub struct AgentConfigBuilder {
219 cmd: Option<String>,
220 output_flag: Option<String>,
221 yolo_flag: Option<String>,
222 verbose_flag: Option<String>,
223 can_commit: Option<bool>,
224 json_parser: Option<JsonParserType>,
225 model_flag: Option<String>,
226 print_flag: Option<String>,
227 streaming_flag: Option<String>,
228 session_flag: Option<String>,
229 env_vars: Option<std::collections::HashMap<String, String>>,
230 display_name: Option<String>,
231}
232
233impl AgentConfigBuilder {
234 pub fn cmd(mut self, cmd: impl Into<String>) -> Self {
236 self.cmd = Some(cmd.into());
237 self
238 }
239
240 pub fn output_flag(mut self, flag: impl Into<String>) -> Self {
242 self.output_flag = Some(flag.into());
243 self
244 }
245
246 pub fn yolo_flag(mut self, flag: impl Into<String>) -> Self {
248 self.yolo_flag = Some(flag.into());
249 self
250 }
251
252 pub fn verbose_flag(mut self, flag: impl Into<String>) -> Self {
254 self.verbose_flag = Some(flag.into());
255 self
256 }
257
258 pub fn can_commit(mut self, can_commit: bool) -> Self {
260 self.can_commit = Some(can_commit);
261 self
262 }
263
264 pub fn json_parser(mut self, parser: JsonParserType) -> Self {
266 self.json_parser = Some(parser);
267 self
268 }
269
270 pub fn model_flag(mut self, flag: impl Into<String>) -> Self {
272 self.model_flag = Some(flag.into());
273 self
274 }
275
276 pub fn print_flag(mut self, flag: impl Into<String>) -> Self {
278 self.print_flag = Some(flag.into());
279 self
280 }
281
282 pub fn streaming_flag(mut self, flag: impl Into<String>) -> Self {
284 self.streaming_flag = Some(flag.into());
285 self
286 }
287
288 pub fn session_flag(mut self, flag: impl Into<String>) -> Self {
290 self.session_flag = Some(flag.into());
291 self
292 }
293
294 pub fn env_vars(mut self, env_vars: std::collections::HashMap<String, String>) -> Self {
296 self.env_vars = Some(env_vars);
297 self
298 }
299
300 pub fn display_name(mut self, name: impl Into<String>) -> Self {
302 self.display_name = Some(name.into());
303 self
304 }
305
306 pub fn build(self) -> AgentConfig {
310 AgentConfig {
311 cmd: self.cmd.unwrap_or_default(),
312 output_flag: self.output_flag.unwrap_or_default(),
313 yolo_flag: self.yolo_flag.unwrap_or_default(),
314 verbose_flag: self.verbose_flag.unwrap_or_default(),
315 can_commit: self.can_commit.unwrap_or(true),
316 json_parser: self.json_parser.unwrap_or(JsonParserType::Generic),
317 model_flag: self.model_flag,
318 print_flag: self.print_flag.unwrap_or_default(),
319 streaming_flag: self.streaming_flag.unwrap_or_default(),
320 session_flag: self.session_flag.unwrap_or_default(),
321 env_vars: self.env_vars.unwrap_or_default(),
322 display_name: self.display_name,
323 }
324 }
325}
326
327#[derive(Debug, Clone, Deserialize)]
329pub struct AgentConfigToml {
330 pub cmd: String,
332 #[serde(default)]
334 pub output_flag: String,
335 #[serde(default)]
337 pub yolo_flag: String,
338 #[serde(default)]
340 pub verbose_flag: String,
341 #[serde(default = "default_can_commit")]
343 pub can_commit: bool,
344 #[serde(default)]
346 pub json_parser: String,
347 #[serde(default)]
349 pub model_flag: Option<String>,
350 #[serde(default)]
352 pub print_flag: String,
353 #[serde(default = "default_streaming_flag")]
355 pub streaming_flag: String,
356 #[serde(default)]
360 pub session_flag: String,
361 #[serde(default)]
370 pub ccs_profile: Option<String>,
371 #[serde(default)]
374 pub env_vars: std::collections::HashMap<String, String>,
375 #[serde(default)]
377 pub display_name: Option<String>,
378}
379
380const fn default_can_commit() -> bool {
381 true
382}
383
384fn default_streaming_flag() -> String {
385 "--include-partial-messages".to_string()
386}
387
388impl From<AgentConfigToml> for AgentConfig {
389 fn from(toml: AgentConfigToml) -> Self {
390 let ccs_env_vars = toml
393 .ccs_profile
394 .as_deref()
395 .map_or_else(HashMap::new, |profile| match load_ccs_env_vars(profile) {
396 Ok(vars) => vars,
397 Err(err) => {
398 eprintln!(
399 "Warning: failed to load CCS env vars for profile '{profile}': {err}"
400 );
401 HashMap::new()
402 }
403 });
404
405 let mut merged_env_vars = toml.env_vars;
408 for (key, value) in ccs_env_vars {
409 merged_env_vars.insert(key, value);
410 }
411
412 Self {
413 cmd: toml.cmd,
414 output_flag: toml.output_flag,
415 yolo_flag: toml.yolo_flag,
416 verbose_flag: toml.verbose_flag,
417 can_commit: toml.can_commit,
418 json_parser: JsonParserType::parse(&toml.json_parser),
419 model_flag: toml.model_flag,
420 print_flag: toml.print_flag,
421 streaming_flag: toml.streaming_flag,
422 session_flag: toml.session_flag,
423 env_vars: merged_env_vars,
424 display_name: toml.display_name,
425 }
426 }
427}
428
429pub fn global_config_dir() -> Option<PathBuf> {
434 dirs::config_dir().map(|d| d.join("ralph"))
435}
436
437pub fn global_agents_config_path() -> Option<PathBuf> {
441 global_config_dir().map(|d| d.join("agents.toml"))
442}
443
444#[derive(Debug, Clone, Deserialize)]
446pub struct AgentsConfigFile {
447 #[serde(default)]
449 pub agents: HashMap<String, AgentConfigToml>,
450 #[serde(default, rename = "agent_chain")]
452 pub fallback: FallbackConfig,
453}
454
455#[derive(Debug, thiserror::Error)]
457pub enum AgentConfigError {
458 #[error("Failed to read config file: {0}")]
459 Io(#[from] io::Error),
460 #[error("Failed to parse TOML: {0}")]
461 Toml(#[from] toml::de::Error),
462 #[error("Built-in agents.toml template is invalid TOML: {0}")]
463 DefaultTemplateToml(toml::de::Error),
464 #[error("{0}")]
465 CcsEnvVars(#[from] CcsEnvVarsError),
466}
467
468#[derive(Debug, Clone, Copy, PartialEq, Eq)]
470pub enum ConfigInitResult {
471 AlreadyExists,
473 Created,
475}
476
477impl AgentsConfigFile {
478 pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Option<Self>, AgentConfigError> {
480 let path = path.as_ref();
481 if !path.exists() {
482 return Ok(None);
483 }
484
485 let contents = fs::read_to_string(path)?;
486 let config: Self = toml::from_str(&contents)?;
487 Ok(Some(config))
488 }
489
490 pub fn load_from_file_with_workspace(
506 path: &Path,
507 workspace: &dyn crate::workspace::Workspace,
508 ) -> Result<Option<Self>, AgentConfigError> {
509 if !workspace.exists(path) {
510 return Ok(None);
511 }
512
513 let contents = workspace
514 .read(path)
515 .map_err(|e| AgentConfigError::Io(io::Error::other(e)))?;
516 let config: Self = toml::from_str(&contents)?;
517 Ok(Some(config))
518 }
519
520 pub fn ensure_config_exists<P: AsRef<Path>>(path: P) -> io::Result<ConfigInitResult> {
522 let path = path.as_ref();
523
524 if path.exists() {
525 return Ok(ConfigInitResult::AlreadyExists);
526 }
527
528 if let Some(parent) = path.parent() {
530 fs::create_dir_all(parent)?;
531 }
532
533 fs::write(path, DEFAULT_AGENTS_TOML)?;
535
536 Ok(ConfigInitResult::Created)
537 }
538
539 pub fn ensure_config_exists_with_workspace(
556 path: &Path,
557 workspace: &dyn crate::workspace::Workspace,
558 ) -> io::Result<ConfigInitResult> {
559 if workspace.exists(path) {
560 return Ok(ConfigInitResult::AlreadyExists);
561 }
562
563 if let Some(parent) = path.parent() {
565 workspace.create_dir_all(parent)?;
566 }
567
568 workspace.write(path, DEFAULT_AGENTS_TOML)?;
570
571 Ok(ConfigInitResult::Created)
572 }
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578
579 #[test]
580 fn test_agent_build_cmd() {
581 let agent = AgentConfig {
582 cmd: "testbot run".to_string(),
583 output_flag: "--json".to_string(),
584 yolo_flag: "--yes".to_string(),
585 verbose_flag: "--verbose".to_string(),
586 can_commit: true,
587 json_parser: JsonParserType::Generic,
588 model_flag: None,
589 print_flag: String::new(),
590 streaming_flag: String::new(),
591 session_flag: String::new(),
592 env_vars: std::collections::HashMap::new(),
593 display_name: None,
594 };
595
596 let cmd = agent.build_cmd(true, true, true);
597 assert!(cmd.contains("testbot run"));
598 assert!(cmd.contains("--json"));
599 assert!(cmd.contains("--yes"));
600 assert!(cmd.contains("--verbose"));
601 }
602
603 #[test]
604 fn test_agent_config_from_toml() {
605 let toml = AgentConfigToml {
606 cmd: "myagent run".to_string(),
607 output_flag: "--json".to_string(),
608 yolo_flag: "--auto".to_string(),
609 verbose_flag: "--verbose".to_string(),
610 can_commit: false,
611 json_parser: "claude".to_string(),
612 model_flag: Some("-m provider/model".to_string()),
613 print_flag: String::new(),
614 streaming_flag: String::new(),
615 session_flag: "--session {}".to_string(),
616 ccs_profile: None,
617 env_vars: std::collections::HashMap::new(),
618 display_name: Some("My Custom Agent".to_string()),
619 };
620
621 let config: AgentConfig = AgentConfig::from(toml);
622 assert_eq!(config.cmd, "myagent run");
623 assert!(!config.can_commit);
624 assert_eq!(config.json_parser, JsonParserType::Claude);
625 assert_eq!(config.model_flag, Some("-m provider/model".to_string()));
626 assert_eq!(config.display_name, Some("My Custom Agent".to_string()));
627 assert_eq!(config.session_flag, "--session {}");
628 }
629
630 #[test]
631 fn test_agent_config_toml_defaults() {
632 let toml_str = r#"cmd = "myagent""#;
633 let config: AgentConfigToml = toml::from_str(toml_str).unwrap();
634
635 assert_eq!(config.cmd, "myagent");
636 assert_eq!(config.output_flag, "");
637 assert!(config.can_commit); }
639
640 #[test]
641 fn test_agent_config_with_print_flag() {
642 let agent = AgentConfig {
643 cmd: "ccs glm".to_string(),
644 output_flag: "--output-format=stream-json".to_string(),
645 yolo_flag: "--dangerously-skip-permissions".to_string(),
646 verbose_flag: "--verbose".to_string(),
647 can_commit: true,
648 json_parser: JsonParserType::Claude,
649 model_flag: None,
650 print_flag: "-p".to_string(),
651 streaming_flag: "--include-partial-messages".to_string(),
652 session_flag: String::new(),
653 env_vars: std::collections::HashMap::new(),
654 display_name: None,
655 };
656
657 let cmd = agent.build_cmd(true, true, true);
658 assert!(cmd.contains("ccs glm -p"));
659 assert!(cmd.contains("--output-format=stream-json"));
660 assert!(cmd.contains("--include-partial-messages"));
661 }
662
663 #[test]
664 fn test_default_agents_toml_is_valid() {
665 let config: AgentsConfigFile = toml::from_str(DEFAULT_AGENTS_TOML).unwrap();
666 assert!(config.agents.contains_key("claude"));
667 assert!(config.agents.contains_key("codex"));
668 }
669
670 #[test]
671 fn test_global_config_path() {
672 if let Some(path) = global_agents_config_path() {
673 assert!(path.ends_with("agents.toml"));
674 }
675 }
676
677 #[test]
678 fn test_build_cmd_with_session() {
679 let agent = AgentConfig {
681 cmd: "opencode run".to_string(),
682 output_flag: "--json".to_string(),
683 yolo_flag: "--yes".to_string(),
684 verbose_flag: "--verbose".to_string(),
685 can_commit: true,
686 json_parser: JsonParserType::OpenCode,
687 model_flag: None,
688 print_flag: String::new(),
689 streaming_flag: String::new(),
690 session_flag: "-s {}".to_string(), env_vars: std::collections::HashMap::new(),
692 display_name: None,
693 };
694
695 let cmd = agent.build_cmd_with_session(true, true, true, None, None);
697 assert!(!cmd.contains("-s "));
698
699 let cmd = agent.build_cmd_with_session(true, true, true, None, Some("ses_abc123"));
701 assert!(cmd.contains("-s ses_abc123"));
702 }
703
704 #[test]
705 fn test_build_cmd_with_session_claude() {
706 let agent = AgentConfig {
708 cmd: "claude -p".to_string(),
709 output_flag: "--output-format=stream-json".to_string(),
710 yolo_flag: "--dangerously-skip-permissions".to_string(),
711 verbose_flag: "--verbose".to_string(),
712 can_commit: true,
713 json_parser: JsonParserType::Claude,
714 model_flag: None,
715 print_flag: String::new(),
716 streaming_flag: String::new(),
717 session_flag: "--resume {}".to_string(), env_vars: std::collections::HashMap::new(),
719 display_name: None,
720 };
721
722 let cmd = agent.build_cmd_with_session(true, true, true, None, Some("abc123"));
724 assert!(cmd.contains("--resume abc123"));
725 }
726
727 #[test]
728 fn test_build_cmd_with_session_no_support() {
729 let agent = AgentConfig {
730 cmd: "generic-agent".to_string(),
731 output_flag: String::new(),
732 yolo_flag: String::new(),
733 verbose_flag: String::new(),
734 can_commit: true,
735 json_parser: JsonParserType::Generic,
736 model_flag: None,
737 print_flag: String::new(),
738 streaming_flag: String::new(),
739 session_flag: String::new(), env_vars: std::collections::HashMap::new(),
741 display_name: None,
742 };
743
744 let cmd = agent.build_cmd_with_session(false, false, false, None, Some("ses_abc123"));
746 assert!(!cmd.contains("ses_abc123"));
747 assert!(!agent.supports_session_continuation());
748 }
749
750 #[test]
751 fn test_supports_session_continuation() {
752 let with_support = AgentConfig {
753 cmd: "opencode run".to_string(),
754 output_flag: String::new(),
755 yolo_flag: String::new(),
756 verbose_flag: String::new(),
757 can_commit: true,
758 json_parser: JsonParserType::OpenCode,
759 model_flag: None,
760 print_flag: String::new(),
761 streaming_flag: String::new(),
762 session_flag: "--session {}".to_string(),
763 env_vars: std::collections::HashMap::new(),
764 display_name: None,
765 };
766 assert!(with_support.supports_session_continuation());
767
768 let without_support = AgentConfig {
769 cmd: "generic-agent".to_string(),
770 output_flag: String::new(),
771 yolo_flag: String::new(),
772 verbose_flag: String::new(),
773 can_commit: true,
774 json_parser: JsonParserType::Generic,
775 model_flag: None,
776 print_flag: String::new(),
777 streaming_flag: String::new(),
778 session_flag: String::new(),
779 env_vars: std::collections::HashMap::new(),
780 display_name: None,
781 };
782 assert!(!without_support.supports_session_continuation());
783 }
784
785 #[test]
790 fn test_load_from_file_with_workspace_nonexistent() {
791 use crate::workspace::MemoryWorkspace;
792 let workspace = MemoryWorkspace::new_test();
793 let path = Path::new(".agent/agents.toml");
794
795 let result = AgentsConfigFile::load_from_file_with_workspace(path, &workspace).unwrap();
796 assert!(result.is_none());
797 }
798
799 #[test]
800 fn test_load_from_file_with_workspace_valid_config() {
801 use crate::workspace::MemoryWorkspace;
802 let workspace =
803 MemoryWorkspace::new_test().with_file(".agent/agents.toml", DEFAULT_AGENTS_TOML);
804 let path = Path::new(".agent/agents.toml");
805
806 let result = AgentsConfigFile::load_from_file_with_workspace(path, &workspace).unwrap();
807 assert!(result.is_some());
808 let config = result.unwrap();
809 assert!(config.agents.contains_key("claude"));
810 }
811
812 #[test]
813 fn test_ensure_config_exists_with_workspace_creates_file() {
814 use crate::workspace::{MemoryWorkspace, Workspace};
815 let workspace = MemoryWorkspace::new_test();
816 let path = Path::new(".agent/agents.toml");
817
818 let result =
819 AgentsConfigFile::ensure_config_exists_with_workspace(path, &workspace).unwrap();
820 assert!(matches!(result, ConfigInitResult::Created));
821 assert!(workspace.exists(path));
822
823 let content = workspace.read(path).unwrap();
825 assert_eq!(content, DEFAULT_AGENTS_TOML);
826 }
827
828 #[test]
829 fn test_ensure_config_exists_with_workspace_already_exists() {
830 use crate::workspace::{MemoryWorkspace, Workspace};
831 let workspace =
832 MemoryWorkspace::new_test().with_file(".agent/agents.toml", "# custom config");
833 let path = Path::new(".agent/agents.toml");
834
835 let result =
836 AgentsConfigFile::ensure_config_exists_with_workspace(path, &workspace).unwrap();
837 assert!(matches!(result, ConfigInitResult::AlreadyExists));
838
839 let content = workspace.read(path).unwrap();
841 assert_eq!(content, "# custom config");
842 }
843}