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 env_vars: std::collections::HashMap<String, String>,
50 pub display_name: Option<String>,
53}
54
55impl AgentConfig {
56 pub fn build_cmd(&self, output: bool, yolo: bool, verbose: bool) -> String {
58 self.build_cmd_with_model(output, yolo, verbose, None)
59 }
60
61 pub fn build_cmd_with_model(
63 &self,
64 output: bool,
65 yolo: bool,
66 verbose: bool,
67 model_override: Option<&str>,
68 ) -> String {
69 let mut parts = vec![self.cmd.clone()];
70
71 if !self.print_flag.is_empty() {
73 parts.push(self.print_flag.clone());
74 }
75
76 if output && !self.output_flag.is_empty() {
77 parts.push(self.output_flag.clone());
78 }
79
80 if output
83 && !self.output_flag.is_empty()
84 && self.output_flag.contains("stream-json")
85 && !self.print_flag.is_empty()
86 && !self.streaming_flag.is_empty()
87 {
88 parts.push(self.streaming_flag.clone());
89 }
90 if yolo && !self.yolo_flag.is_empty() {
91 parts.push(self.yolo_flag.clone());
92 }
93
94 let needs_verbose = verbose || self.requires_verbose_for_json(output);
96
97 if needs_verbose && !self.verbose_flag.is_empty() {
98 parts.push(self.verbose_flag.clone());
99 }
100
101 let effective_model = model_override.or(self.model_flag.as_deref());
103 if let Some(model) = effective_model {
104 if !model.is_empty() {
105 parts.push(model.to_string());
106 }
107 }
108
109 parts.join(" ")
110 }
111
112 fn requires_verbose_for_json(&self, json_enabled: bool) -> bool {
114 if !json_enabled || !self.output_flag.contains("stream-json") {
115 return false;
116 }
117
118 let base = self.cmd.split_whitespace().next().unwrap_or("");
121 let exe_name = Path::new(base)
123 .file_name()
124 .and_then(|n| n.to_str())
125 .unwrap_or(base);
126 matches!(exe_name, "claude" | "ccs")
127 }
128}
129
130#[derive(Debug, Clone, Deserialize)]
132pub struct AgentConfigToml {
133 pub cmd: String,
135 #[serde(default)]
137 pub output_flag: String,
138 #[serde(default)]
140 pub yolo_flag: String,
141 #[serde(default)]
143 pub verbose_flag: String,
144 #[serde(default = "default_can_commit")]
146 pub can_commit: bool,
147 #[serde(default)]
149 pub json_parser: String,
150 #[serde(default)]
152 pub model_flag: Option<String>,
153 #[serde(default)]
155 pub print_flag: String,
156 #[serde(default = "default_streaming_flag")]
158 pub streaming_flag: String,
159 #[serde(default)]
168 pub ccs_profile: Option<String>,
169 #[serde(default)]
172 pub env_vars: std::collections::HashMap<String, String>,
173 #[serde(default)]
175 pub display_name: Option<String>,
176}
177
178const fn default_can_commit() -> bool {
179 true
180}
181
182fn default_streaming_flag() -> String {
183 "--include-partial-messages".to_string()
184}
185
186impl From<AgentConfigToml> for AgentConfig {
187 fn from(toml: AgentConfigToml) -> Self {
188 let ccs_env_vars = toml
191 .ccs_profile
192 .as_deref()
193 .map_or_else(HashMap::new, |profile| match load_ccs_env_vars(profile) {
194 Ok(vars) => vars,
195 Err(err) => {
196 eprintln!(
197 "Warning: failed to load CCS env vars for profile '{profile}': {err}"
198 );
199 HashMap::new()
200 }
201 });
202
203 let mut merged_env_vars = toml.env_vars;
206 for (key, value) in ccs_env_vars {
207 merged_env_vars.insert(key, value);
208 }
209
210 Self {
211 cmd: toml.cmd,
212 output_flag: toml.output_flag,
213 yolo_flag: toml.yolo_flag,
214 verbose_flag: toml.verbose_flag,
215 can_commit: toml.can_commit,
216 json_parser: JsonParserType::parse(&toml.json_parser),
217 model_flag: toml.model_flag,
218 print_flag: toml.print_flag,
219 streaming_flag: toml.streaming_flag,
220 env_vars: merged_env_vars,
221 display_name: toml.display_name,
222 }
223 }
224}
225
226pub fn global_config_dir() -> Option<PathBuf> {
231 dirs::config_dir().map(|d| d.join("ralph"))
232}
233
234pub fn global_agents_config_path() -> Option<PathBuf> {
238 global_config_dir().map(|d| d.join("agents.toml"))
239}
240
241#[derive(Debug, Clone, Deserialize)]
243pub struct AgentsConfigFile {
244 #[serde(default)]
246 pub agents: HashMap<String, AgentConfigToml>,
247 #[serde(default, rename = "agent_chain")]
249 pub fallback: FallbackConfig,
250}
251
252#[derive(Debug, thiserror::Error)]
254pub enum AgentConfigError {
255 #[error("Failed to read config file: {0}")]
256 Io(#[from] io::Error),
257 #[error("Failed to parse TOML: {0}")]
258 Toml(#[from] toml::de::Error),
259 #[error("Built-in agents.toml template is invalid TOML: {0}")]
260 DefaultTemplateToml(toml::de::Error),
261 #[error("{0}")]
262 CcsEnvVars(#[from] CcsEnvVarsError),
263}
264
265#[derive(Debug, Clone, Copy, PartialEq, Eq)]
267pub enum ConfigInitResult {
268 AlreadyExists,
270 Created,
272}
273
274impl AgentsConfigFile {
275 pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Option<Self>, AgentConfigError> {
277 let path = path.as_ref();
278 if !path.exists() {
279 return Ok(None);
280 }
281
282 let contents = fs::read_to_string(path)?;
283 let config: Self = toml::from_str(&contents)?;
284 Ok(Some(config))
285 }
286
287 pub fn ensure_config_exists<P: AsRef<Path>>(path: P) -> io::Result<ConfigInitResult> {
289 let path = path.as_ref();
290
291 if path.exists() {
292 return Ok(ConfigInitResult::AlreadyExists);
293 }
294
295 if let Some(parent) = path.parent() {
297 fs::create_dir_all(parent)?;
298 }
299
300 fs::write(path, DEFAULT_AGENTS_TOML)?;
302
303 Ok(ConfigInitResult::Created)
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 #[test]
312 fn test_agent_build_cmd() {
313 let agent = AgentConfig {
314 cmd: "testbot run".to_string(),
315 output_flag: "--json".to_string(),
316 yolo_flag: "--yes".to_string(),
317 verbose_flag: "--verbose".to_string(),
318 can_commit: true,
319 json_parser: JsonParserType::Generic,
320 model_flag: None,
321 print_flag: String::new(),
322 streaming_flag: String::new(),
323 env_vars: std::collections::HashMap::new(),
324 display_name: None,
325 };
326
327 let cmd = agent.build_cmd(true, true, true);
328 assert!(cmd.contains("testbot run"));
329 assert!(cmd.contains("--json"));
330 assert!(cmd.contains("--yes"));
331 assert!(cmd.contains("--verbose"));
332 }
333
334 #[test]
335 fn test_agent_config_from_toml() {
336 let toml = AgentConfigToml {
337 cmd: "myagent run".to_string(),
338 output_flag: "--json".to_string(),
339 yolo_flag: "--auto".to_string(),
340 verbose_flag: "--verbose".to_string(),
341 can_commit: false,
342 json_parser: "claude".to_string(),
343 model_flag: Some("-m provider/model".to_string()),
344 print_flag: String::new(),
345 streaming_flag: String::new(),
346 ccs_profile: None,
347 env_vars: std::collections::HashMap::new(),
348 display_name: Some("My Custom Agent".to_string()),
349 };
350
351 let config: AgentConfig = AgentConfig::from(toml);
352 assert_eq!(config.cmd, "myagent run");
353 assert!(!config.can_commit);
354 assert_eq!(config.json_parser, JsonParserType::Claude);
355 assert_eq!(config.model_flag, Some("-m provider/model".to_string()));
356 assert_eq!(config.display_name, Some("My Custom Agent".to_string()));
357 }
358
359 #[test]
360 fn test_agent_config_toml_defaults() {
361 let toml_str = r#"cmd = "myagent""#;
362 let config: AgentConfigToml = toml::from_str(toml_str).unwrap();
363
364 assert_eq!(config.cmd, "myagent");
365 assert_eq!(config.output_flag, "");
366 assert!(config.can_commit); }
368
369 #[test]
370 fn test_agent_config_with_print_flag() {
371 let agent = AgentConfig {
372 cmd: "ccs glm".to_string(),
373 output_flag: "--output-format=stream-json".to_string(),
374 yolo_flag: "--dangerously-skip-permissions".to_string(),
375 verbose_flag: "--verbose".to_string(),
376 can_commit: true,
377 json_parser: JsonParserType::Claude,
378 model_flag: None,
379 print_flag: "-p".to_string(),
380 streaming_flag: "--include-partial-messages".to_string(),
381 env_vars: std::collections::HashMap::new(),
382 display_name: None,
383 };
384
385 let cmd = agent.build_cmd(true, true, true);
386 assert!(cmd.contains("ccs glm -p"));
387 assert!(cmd.contains("--output-format=stream-json"));
388 assert!(cmd.contains("--include-partial-messages"));
389 }
390
391 #[test]
392 fn test_default_agents_toml_is_valid() {
393 let config: AgentsConfigFile = toml::from_str(DEFAULT_AGENTS_TOML).unwrap();
394 assert!(config.agents.contains_key("claude"));
395 assert!(config.agents.contains_key("codex"));
396 }
397
398 #[test]
399 fn test_global_config_path() {
400 if let Some(path) = global_agents_config_path() {
401 assert!(path.ends_with("agents.toml"));
402 }
403 }
404}