ralph_workflow/agents/
config.rs

1//! Agent configuration types and TOML parsing.
2//!
3//! This module provides types for loading and managing agent configurations
4//! from TOML files, including support for global and per-project configs.
5
6use 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
15/// Default agents.toml template embedded at compile time.
16pub const DEFAULT_AGENTS_TOML: &str = include_str!("../../examples/agents.toml");
17
18/// Config source for tracking where config was loaded from.
19#[derive(Debug, Clone)]
20pub struct ConfigSource {
21    pub path: PathBuf,
22    pub agents_loaded: usize,
23}
24
25/// Agent capabilities.
26#[derive(Debug, Clone)]
27pub struct AgentConfig {
28    /// Base command to run the agent.
29    pub cmd: String,
30    /// Output-format flag (JSON streaming, text mode, etc.).
31    pub output_flag: String,
32    /// Flag for autonomous mode (no prompts).
33    pub yolo_flag: String,
34    /// Flag for verbose output.
35    pub verbose_flag: String,
36    /// Whether the agent can run git commit.
37    pub can_commit: bool,
38    /// Which JSON parser to use for this agent's output.
39    pub json_parser: JsonParserType,
40    /// Model/provider flag for agents that support model selection.
41    pub model_flag: Option<String>,
42    /// Print/non-interactive mode flag (e.g., "-p" for Claude/CCS).
43    pub print_flag: String,
44    /// Include partial messages flag for streaming with -p (e.g., "--include-partial-messages").
45    /// Required for Claude/CCS to stream JSON output when using -p mode.
46    pub streaming_flag: String,
47    /// Environment variables to set when running this agent.
48    /// Used for providers that need env vars (e.g., loaded from CCS settings).
49    pub env_vars: std::collections::HashMap<String, String>,
50    /// Display name for UI/logging (e.g., "ccs-glm" instead of raw agent name).
51    /// If None, the agent name from the registry is used.
52    pub display_name: Option<String>,
53}
54
55impl AgentConfig {
56    /// Build full command string with specified flags.
57    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    /// Build full command string with specified flags and optional model override.
62    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        // Add print flag first (for CCS that needs -p after the profile name)
72        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        // Add streaming flag when using stream-json output with -p
81        // Claude/CCS require --include-partial-messages to stream JSON in -p mode
82        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        // Claude CLI requires --verbose when using --output-format=stream-json
95        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        // Add model flag: runtime override takes precedence over config
102        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    /// Check if this agent requires --verbose when JSON output is enabled.
113    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        // Both `claude` and CCS (`ccs ...`) require verbose mode when using stream-json output.
119        // CCS is a wrapper around the Claude CLI and inherits its stream-json quirks.
120        let base = self.cmd.split_whitespace().next().unwrap_or("");
121        // Extract just the file name from the path to handle cases like "/usr/local/bin/claude"
122        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/// TOML configuration for an agent (for deserialization).
131#[derive(Debug, Clone, Deserialize)]
132pub struct AgentConfigToml {
133    /// Base command to run the agent.
134    pub cmd: String,
135    /// Output-format flag (optional, defaults to empty).
136    #[serde(default)]
137    pub output_flag: String,
138    /// Flag for autonomous mode (optional, defaults to empty).
139    #[serde(default)]
140    pub yolo_flag: String,
141    /// Flag for verbose output (optional, defaults to empty).
142    #[serde(default)]
143    pub verbose_flag: String,
144    /// Whether the agent can run git commit (optional, defaults to true).
145    #[serde(default = "default_can_commit")]
146    pub can_commit: bool,
147    /// Which JSON parser to use (optional, defaults to "generic").
148    #[serde(default)]
149    pub json_parser: String,
150    /// Model/provider flag for model selection.
151    #[serde(default)]
152    pub model_flag: Option<String>,
153    /// Print/non-interactive mode flag (optional, defaults to empty).
154    #[serde(default)]
155    pub print_flag: String,
156    /// Include partial messages flag for streaming with -p (optional, defaults to "--include-partial-messages").
157    #[serde(default = "default_streaming_flag")]
158    pub streaming_flag: String,
159    /// CCS profile to load env vars from (e.g., "glm").
160    ///
161    /// Ralph resolves the CCS profile to a settings file using CCS config mappings
162    /// (`~/.ccs/config.json` and/or `~/.ccs/config.yaml`) and common settings file
163    /// naming (`~/.ccs/{profile}.settings.json` / `~/.ccs/{profile}.setting.json`).
164    ///
165    /// The resulting values are injected into the agent process only (they are not
166    /// persisted).
167    #[serde(default)]
168    pub ccs_profile: Option<String>,
169    /// Environment variables to set when running this agent (optional).
170    /// If `ccs_profile` is set, these are merged with CCS env vars (CCS takes precedence).
171    #[serde(default)]
172    pub env_vars: std::collections::HashMap<String, String>,
173    /// Display name for UI/logging (optional, e.g., "My Custom Agent" instead of registry name).
174    #[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        // Loading CCS env vars is best-effort: registry initialization should not fail
189        // just because a CCS profile is missing or misconfigured.
190        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        // Merge manually specified env vars with CCS env vars
204        // CCS env vars take precedence (as documented in ccs_profile field)
205        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
226/// Get the global config directory for Ralph.
227///
228/// Returns `~/.config/ralph` on Unix and `%APPDATA%\ralph` on Windows.
229/// Returns None if the home directory cannot be determined.
230pub fn global_config_dir() -> Option<PathBuf> {
231    dirs::config_dir().map(|d| d.join("ralph"))
232}
233
234/// Get the global agents.toml path.
235///
236/// Returns `~/.config/ralph/agents.toml` on Unix.
237pub fn global_agents_config_path() -> Option<PathBuf> {
238    global_config_dir().map(|d| d.join("agents.toml"))
239}
240
241/// Root TOML configuration structure.
242#[derive(Debug, Clone, Deserialize)]
243pub struct AgentsConfigFile {
244    /// Map of agent name to configuration.
245    #[serde(default)]
246    pub agents: HashMap<String, AgentConfigToml>,
247    /// Agent chain configuration (preferred agents + fallbacks).
248    #[serde(default, rename = "agent_chain")]
249    pub fallback: FallbackConfig,
250}
251
252/// Error type for agent configuration loading.
253#[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/// Result of checking/initializing the agents config file.
266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
267pub enum ConfigInitResult {
268    /// Config file already exists, no action taken.
269    AlreadyExists,
270    /// Config file was just created from template.
271    Created,
272}
273
274impl AgentsConfigFile {
275    /// Load agents config from a file, returning None if file doesn't exist.
276    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    /// Ensure agents config file exists, creating it from template if needed.
288    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        // Create parent directories if they don't exist
296        if let Some(parent) = path.parent() {
297            fs::create_dir_all(parent)?;
298        }
299
300        // Write the default template
301        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); // default is true
367    }
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}