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 AgentConfig {
60 pub fn build_cmd(&self, output: bool, yolo: bool, verbose: bool) -> String {
62 self.build_cmd_with_model(output, yolo, verbose, None)
63 }
64
65 pub fn build_cmd_with_model(
67 &self,
68 output: bool,
69 yolo: bool,
70 verbose: bool,
71 model_override: Option<&str>,
72 ) -> String {
73 let mut parts = vec![self.cmd.clone()];
74
75 if !self.print_flag.is_empty() {
77 parts.push(self.print_flag.clone());
78 }
79
80 if output && !self.output_flag.is_empty() {
81 parts.push(self.output_flag.clone());
82 }
83
84 if output
87 && !self.output_flag.is_empty()
88 && self.output_flag.contains("stream-json")
89 && !self.print_flag.is_empty()
90 && !self.streaming_flag.is_empty()
91 {
92 parts.push(self.streaming_flag.clone());
93 }
94 if yolo && !self.yolo_flag.is_empty() {
95 parts.push(self.yolo_flag.clone());
96 }
97
98 let needs_verbose = verbose || self.requires_verbose_for_json(output);
100
101 if needs_verbose && !self.verbose_flag.is_empty() {
102 parts.push(self.verbose_flag.clone());
103 }
104
105 let effective_model = model_override.or(self.model_flag.as_deref());
107 if let Some(model) = effective_model {
108 if !model.is_empty() {
109 parts.push(model.to_string());
110 }
111 }
112
113 parts.join(" ")
114 }
115
116 pub fn build_cmd_with_session(
133 &self,
134 output: bool,
135 yolo: bool,
136 verbose: bool,
137 model_override: Option<&str>,
138 session_id: Option<&str>,
139 ) -> String {
140 let mut cmd = self.build_cmd_with_model(output, yolo, verbose, model_override);
141
142 if let Some(sid) = session_id {
144 if !self.session_flag.is_empty() {
145 let session_arg = self.session_flag.replace("{}", sid);
146 cmd.push(' ');
147 cmd.push_str(&session_arg);
148 }
149 }
150
151 cmd
152 }
153
154 pub fn supports_session_continuation(&self) -> bool {
156 !self.session_flag.is_empty()
157 }
158
159 fn requires_verbose_for_json(&self, json_enabled: bool) -> bool {
161 if !json_enabled || !self.output_flag.contains("stream-json") {
162 return false;
163 }
164
165 let base = self.cmd.split_whitespace().next().unwrap_or("");
168 let exe_name = Path::new(base)
170 .file_name()
171 .and_then(|n| n.to_str())
172 .unwrap_or(base);
173 matches!(exe_name, "claude" | "ccs")
174 }
175}
176
177#[derive(Debug, Clone, Deserialize)]
179pub struct AgentConfigToml {
180 pub cmd: String,
182 #[serde(default)]
184 pub output_flag: String,
185 #[serde(default)]
187 pub yolo_flag: String,
188 #[serde(default)]
190 pub verbose_flag: String,
191 #[serde(default = "default_can_commit")]
193 pub can_commit: bool,
194 #[serde(default)]
196 pub json_parser: String,
197 #[serde(default)]
199 pub model_flag: Option<String>,
200 #[serde(default)]
202 pub print_flag: String,
203 #[serde(default = "default_streaming_flag")]
205 pub streaming_flag: String,
206 #[serde(default)]
210 pub session_flag: String,
211 #[serde(default)]
220 pub ccs_profile: Option<String>,
221 #[serde(default)]
224 pub env_vars: std::collections::HashMap<String, String>,
225 #[serde(default)]
227 pub display_name: Option<String>,
228}
229
230const fn default_can_commit() -> bool {
231 true
232}
233
234fn default_streaming_flag() -> String {
235 "--include-partial-messages".to_string()
236}
237
238impl From<AgentConfigToml> for AgentConfig {
239 fn from(toml: AgentConfigToml) -> Self {
240 let ccs_env_vars = toml
243 .ccs_profile
244 .as_deref()
245 .map_or_else(HashMap::new, |profile| match load_ccs_env_vars(profile) {
246 Ok(vars) => vars,
247 Err(err) => {
248 eprintln!(
249 "Warning: failed to load CCS env vars for profile '{profile}': {err}"
250 );
251 HashMap::new()
252 }
253 });
254
255 let mut merged_env_vars = toml.env_vars;
258 for (key, value) in ccs_env_vars {
259 merged_env_vars.insert(key, value);
260 }
261
262 Self {
263 cmd: toml.cmd,
264 output_flag: toml.output_flag,
265 yolo_flag: toml.yolo_flag,
266 verbose_flag: toml.verbose_flag,
267 can_commit: toml.can_commit,
268 json_parser: JsonParserType::parse(&toml.json_parser),
269 model_flag: toml.model_flag,
270 print_flag: toml.print_flag,
271 streaming_flag: toml.streaming_flag,
272 session_flag: toml.session_flag,
273 env_vars: merged_env_vars,
274 display_name: toml.display_name,
275 }
276 }
277}
278
279pub fn global_config_dir() -> Option<PathBuf> {
284 dirs::config_dir().map(|d| d.join("ralph"))
285}
286
287pub fn global_agents_config_path() -> Option<PathBuf> {
291 global_config_dir().map(|d| d.join("agents.toml"))
292}
293
294#[derive(Debug, Clone, Deserialize)]
296pub struct AgentsConfigFile {
297 #[serde(default)]
299 pub agents: HashMap<String, AgentConfigToml>,
300 #[serde(default, rename = "agent_chain")]
302 pub fallback: FallbackConfig,
303}
304
305#[derive(Debug, thiserror::Error)]
307pub enum AgentConfigError {
308 #[error("Failed to read config file: {0}")]
309 Io(#[from] io::Error),
310 #[error("Failed to parse TOML: {0}")]
311 Toml(#[from] toml::de::Error),
312 #[error("Built-in agents.toml template is invalid TOML: {0}")]
313 DefaultTemplateToml(toml::de::Error),
314 #[error("{0}")]
315 CcsEnvVars(#[from] CcsEnvVarsError),
316}
317
318#[derive(Debug, Clone, Copy, PartialEq, Eq)]
320pub enum ConfigInitResult {
321 AlreadyExists,
323 Created,
325}
326
327impl AgentsConfigFile {
328 pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Option<Self>, AgentConfigError> {
330 let path = path.as_ref();
331 if !path.exists() {
332 return Ok(None);
333 }
334
335 let contents = fs::read_to_string(path)?;
336 let config: Self = toml::from_str(&contents)?;
337 Ok(Some(config))
338 }
339
340 pub fn ensure_config_exists<P: AsRef<Path>>(path: P) -> io::Result<ConfigInitResult> {
342 let path = path.as_ref();
343
344 if path.exists() {
345 return Ok(ConfigInitResult::AlreadyExists);
346 }
347
348 if let Some(parent) = path.parent() {
350 fs::create_dir_all(parent)?;
351 }
352
353 fs::write(path, DEFAULT_AGENTS_TOML)?;
355
356 Ok(ConfigInitResult::Created)
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363
364 #[test]
365 fn test_agent_build_cmd() {
366 let agent = AgentConfig {
367 cmd: "testbot run".to_string(),
368 output_flag: "--json".to_string(),
369 yolo_flag: "--yes".to_string(),
370 verbose_flag: "--verbose".to_string(),
371 can_commit: true,
372 json_parser: JsonParserType::Generic,
373 model_flag: None,
374 print_flag: String::new(),
375 streaming_flag: String::new(),
376 session_flag: String::new(),
377 env_vars: std::collections::HashMap::new(),
378 display_name: None,
379 };
380
381 let cmd = agent.build_cmd(true, true, true);
382 assert!(cmd.contains("testbot run"));
383 assert!(cmd.contains("--json"));
384 assert!(cmd.contains("--yes"));
385 assert!(cmd.contains("--verbose"));
386 }
387
388 #[test]
389 fn test_agent_config_from_toml() {
390 let toml = AgentConfigToml {
391 cmd: "myagent run".to_string(),
392 output_flag: "--json".to_string(),
393 yolo_flag: "--auto".to_string(),
394 verbose_flag: "--verbose".to_string(),
395 can_commit: false,
396 json_parser: "claude".to_string(),
397 model_flag: Some("-m provider/model".to_string()),
398 print_flag: String::new(),
399 streaming_flag: String::new(),
400 session_flag: "--session {}".to_string(),
401 ccs_profile: None,
402 env_vars: std::collections::HashMap::new(),
403 display_name: Some("My Custom Agent".to_string()),
404 };
405
406 let config: AgentConfig = AgentConfig::from(toml);
407 assert_eq!(config.cmd, "myagent run");
408 assert!(!config.can_commit);
409 assert_eq!(config.json_parser, JsonParserType::Claude);
410 assert_eq!(config.model_flag, Some("-m provider/model".to_string()));
411 assert_eq!(config.display_name, Some("My Custom Agent".to_string()));
412 assert_eq!(config.session_flag, "--session {}");
413 }
414
415 #[test]
416 fn test_agent_config_toml_defaults() {
417 let toml_str = r#"cmd = "myagent""#;
418 let config: AgentConfigToml = toml::from_str(toml_str).unwrap();
419
420 assert_eq!(config.cmd, "myagent");
421 assert_eq!(config.output_flag, "");
422 assert!(config.can_commit); }
424
425 #[test]
426 fn test_agent_config_with_print_flag() {
427 let agent = AgentConfig {
428 cmd: "ccs glm".to_string(),
429 output_flag: "--output-format=stream-json".to_string(),
430 yolo_flag: "--dangerously-skip-permissions".to_string(),
431 verbose_flag: "--verbose".to_string(),
432 can_commit: true,
433 json_parser: JsonParserType::Claude,
434 model_flag: None,
435 print_flag: "-p".to_string(),
436 streaming_flag: "--include-partial-messages".to_string(),
437 session_flag: String::new(),
438 env_vars: std::collections::HashMap::new(),
439 display_name: None,
440 };
441
442 let cmd = agent.build_cmd(true, true, true);
443 assert!(cmd.contains("ccs glm -p"));
444 assert!(cmd.contains("--output-format=stream-json"));
445 assert!(cmd.contains("--include-partial-messages"));
446 }
447
448 #[test]
449 fn test_default_agents_toml_is_valid() {
450 let config: AgentsConfigFile = toml::from_str(DEFAULT_AGENTS_TOML).unwrap();
451 assert!(config.agents.contains_key("claude"));
452 assert!(config.agents.contains_key("codex"));
453 }
454
455 #[test]
456 fn test_global_config_path() {
457 if let Some(path) = global_agents_config_path() {
458 assert!(path.ends_with("agents.toml"));
459 }
460 }
461
462 #[test]
463 fn test_build_cmd_with_session() {
464 let agent = AgentConfig {
466 cmd: "opencode run".to_string(),
467 output_flag: "--json".to_string(),
468 yolo_flag: "--yes".to_string(),
469 verbose_flag: "--verbose".to_string(),
470 can_commit: true,
471 json_parser: JsonParserType::OpenCode,
472 model_flag: None,
473 print_flag: String::new(),
474 streaming_flag: String::new(),
475 session_flag: "-s {}".to_string(), env_vars: std::collections::HashMap::new(),
477 display_name: None,
478 };
479
480 let cmd = agent.build_cmd_with_session(true, true, true, None, None);
482 assert!(!cmd.contains("-s "));
483
484 let cmd = agent.build_cmd_with_session(true, true, true, None, Some("ses_abc123"));
486 assert!(cmd.contains("-s ses_abc123"));
487 }
488
489 #[test]
490 fn test_build_cmd_with_session_claude() {
491 let agent = AgentConfig {
493 cmd: "claude -p".to_string(),
494 output_flag: "--output-format=stream-json".to_string(),
495 yolo_flag: "--dangerously-skip-permissions".to_string(),
496 verbose_flag: "--verbose".to_string(),
497 can_commit: true,
498 json_parser: JsonParserType::Claude,
499 model_flag: None,
500 print_flag: String::new(),
501 streaming_flag: String::new(),
502 session_flag: "--resume {}".to_string(), env_vars: std::collections::HashMap::new(),
504 display_name: None,
505 };
506
507 let cmd = agent.build_cmd_with_session(true, true, true, None, Some("abc123"));
509 assert!(cmd.contains("--resume abc123"));
510 }
511
512 #[test]
513 fn test_build_cmd_with_session_no_support() {
514 let agent = AgentConfig {
515 cmd: "generic-agent".to_string(),
516 output_flag: String::new(),
517 yolo_flag: String::new(),
518 verbose_flag: String::new(),
519 can_commit: true,
520 json_parser: JsonParserType::Generic,
521 model_flag: None,
522 print_flag: String::new(),
523 streaming_flag: String::new(),
524 session_flag: String::new(), env_vars: std::collections::HashMap::new(),
526 display_name: None,
527 };
528
529 let cmd = agent.build_cmd_with_session(false, false, false, None, Some("ses_abc123"));
531 assert!(!cmd.contains("ses_abc123"));
532 assert!(!agent.supports_session_continuation());
533 }
534
535 #[test]
536 fn test_supports_session_continuation() {
537 let with_support = AgentConfig {
538 cmd: "opencode run".to_string(),
539 output_flag: String::new(),
540 yolo_flag: String::new(),
541 verbose_flag: String::new(),
542 can_commit: true,
543 json_parser: JsonParserType::OpenCode,
544 model_flag: None,
545 print_flag: String::new(),
546 streaming_flag: String::new(),
547 session_flag: "--session {}".to_string(),
548 env_vars: std::collections::HashMap::new(),
549 display_name: None,
550 };
551 assert!(with_support.supports_session_continuation());
552
553 let without_support = AgentConfig {
554 cmd: "generic-agent".to_string(),
555 output_flag: String::new(),
556 yolo_flag: String::new(),
557 verbose_flag: String::new(),
558 can_commit: true,
559 json_parser: JsonParserType::Generic,
560 model_flag: None,
561 print_flag: String::new(),
562 streaming_flag: String::new(),
563 session_flag: String::new(),
564 env_vars: std::collections::HashMap::new(),
565 display_name: None,
566 };
567 assert!(!without_support.supports_session_continuation());
568 }
569}