Skip to main content

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    /// Session continuation flag template (e.g., "--session {}" for OpenCode).
48    /// The `{}` placeholder is replaced with the session ID at runtime.
49    /// If empty, session continuation is not supported for this agent.
50    pub session_flag: String,
51    /// Environment variables to set when running this agent.
52    /// Used for providers that need env vars (e.g., loaded from CCS settings).
53    pub env_vars: std::collections::HashMap<String, String>,
54    /// Display name for UI/logging (e.g., "ccs-glm" instead of raw agent name).
55    /// If None, the agent name from the registry is used.
56    pub display_name: Option<String>,
57}
58
59impl AgentConfig {
60    /// Build full command string with specified flags.
61    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    /// Build full command string with specified flags and optional model override.
66    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        // Add print flag first (for CCS that needs -p after the profile name)
76        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        // Add streaming flag when using stream-json output with -p
85        // Claude/CCS require --include-partial-messages to stream JSON in -p mode
86        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        // Claude CLI requires --verbose when using --output-format=stream-json
99        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        // Add model flag: runtime override takes precedence over config
106        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    /// Build full command string with session continuation.
117    ///
118    /// This is used for XSD retries where we want to continue an existing session
119    /// so the AI retains memory of its previous reasoning.
120    ///
121    /// # Arguments
122    ///
123    /// * `output` - Enable JSON output format
124    /// * `yolo` - Enable autonomous mode
125    /// * `verbose` - Enable verbose output
126    /// * `model_override` - Optional model override
127    /// * `session_id` - Session ID to continue (if supported by this agent)
128    ///
129    /// # Returns
130    ///
131    /// The command string with session continuation flag if supported
132    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        // Add session continuation flag if we have a session ID and the agent supports it
143        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    /// Check if this agent supports session continuation.
155    pub fn supports_session_continuation(&self) -> bool {
156        !self.session_flag.is_empty()
157    }
158
159    /// Check if this agent requires --verbose when JSON output is enabled.
160    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        // Both `claude` and CCS (`ccs ...`) require verbose mode when using stream-json output.
166        // CCS is a wrapper around the Claude CLI and inherits its stream-json quirks.
167        let base = self.cmd.split_whitespace().next().unwrap_or("");
168        // Extract just the file name from the path to handle cases like "/usr/local/bin/claude"
169        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/// TOML configuration for an agent (for deserialization).
178#[derive(Debug, Clone, Deserialize)]
179pub struct AgentConfigToml {
180    /// Base command to run the agent.
181    pub cmd: String,
182    /// Output-format flag (optional, defaults to empty).
183    #[serde(default)]
184    pub output_flag: String,
185    /// Flag for autonomous mode (optional, defaults to empty).
186    #[serde(default)]
187    pub yolo_flag: String,
188    /// Flag for verbose output (optional, defaults to empty).
189    #[serde(default)]
190    pub verbose_flag: String,
191    /// Whether the agent can run git commit (optional, defaults to true).
192    #[serde(default = "default_can_commit")]
193    pub can_commit: bool,
194    /// Which JSON parser to use (optional, defaults to "generic").
195    #[serde(default)]
196    pub json_parser: String,
197    /// Model/provider flag for model selection.
198    #[serde(default)]
199    pub model_flag: Option<String>,
200    /// Print/non-interactive mode flag (optional, defaults to empty).
201    #[serde(default)]
202    pub print_flag: String,
203    /// Include partial messages flag for streaming with -p (optional, defaults to "--include-partial-messages").
204    #[serde(default = "default_streaming_flag")]
205    pub streaming_flag: String,
206    /// Session continuation flag template (optional, e.g., "--session {}" for OpenCode).
207    /// The `{}` placeholder is replaced with the session ID at runtime.
208    /// If empty, session continuation is not supported for this agent.
209    #[serde(default)]
210    pub session_flag: String,
211    /// CCS profile to load env vars from (e.g., "glm").
212    ///
213    /// Ralph resolves the CCS profile to a settings file using CCS config mappings
214    /// (`~/.ccs/config.json` and/or `~/.ccs/config.yaml`) and common settings file
215    /// naming (`~/.ccs/{profile}.settings.json` / `~/.ccs/{profile}.setting.json`).
216    ///
217    /// The resulting values are injected into the agent process only (they are not
218    /// persisted).
219    #[serde(default)]
220    pub ccs_profile: Option<String>,
221    /// Environment variables to set when running this agent (optional).
222    /// If `ccs_profile` is set, these are merged with CCS env vars (CCS takes precedence).
223    #[serde(default)]
224    pub env_vars: std::collections::HashMap<String, String>,
225    /// Display name for UI/logging (optional, e.g., "My Custom Agent" instead of registry name).
226    #[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        // Loading CCS env vars is best-effort: registry initialization should not fail
241        // just because a CCS profile is missing or misconfigured.
242        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        // Merge manually specified env vars with CCS env vars
256        // CCS env vars take precedence (as documented in ccs_profile field)
257        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
279/// Get the global config directory for Ralph.
280///
281/// Returns `~/.config/ralph` on Unix and `%APPDATA%\ralph` on Windows.
282/// Returns None if the home directory cannot be determined.
283pub fn global_config_dir() -> Option<PathBuf> {
284    dirs::config_dir().map(|d| d.join("ralph"))
285}
286
287/// Get the global agents.toml path.
288///
289/// Returns `~/.config/ralph/agents.toml` on Unix.
290pub fn global_agents_config_path() -> Option<PathBuf> {
291    global_config_dir().map(|d| d.join("agents.toml"))
292}
293
294/// Root TOML configuration structure.
295#[derive(Debug, Clone, Deserialize)]
296pub struct AgentsConfigFile {
297    /// Map of agent name to configuration.
298    #[serde(default)]
299    pub agents: HashMap<String, AgentConfigToml>,
300    /// Agent chain configuration (preferred agents + fallbacks).
301    #[serde(default, rename = "agent_chain")]
302    pub fallback: FallbackConfig,
303}
304
305/// Error type for agent configuration loading.
306#[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/// Result of checking/initializing the agents config file.
319#[derive(Debug, Clone, Copy, PartialEq, Eq)]
320pub enum ConfigInitResult {
321    /// Config file already exists, no action taken.
322    AlreadyExists,
323    /// Config file was just created from template.
324    Created,
325}
326
327impl AgentsConfigFile {
328    /// Load agents config from a file, returning None if file doesn't exist.
329    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    /// Ensure agents config file exists, creating it from template if needed.
341    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        // Create parent directories if they don't exist
349        if let Some(parent) = path.parent() {
350            fs::create_dir_all(parent)?;
351        }
352
353        // Write the default template
354        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); // default is true
423    }
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        // Test with OpenCode agent (uses -s flag per `opencode run --help`)
465        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(), // From `opencode run --help`
476            env_vars: std::collections::HashMap::new(),
477            display_name: None,
478        };
479
480        // Without session ID
481        let cmd = agent.build_cmd_with_session(true, true, true, None, None);
482        assert!(!cmd.contains("-s "));
483
484        // With session ID
485        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        // Test with Claude agent (uses --resume flag per `claude --help`)
492        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(), // From `claude --help`
503            env_vars: std::collections::HashMap::new(),
504            display_name: None,
505        };
506
507        // With session ID
508        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(), // No session support
525            env_vars: std::collections::HashMap::new(),
526            display_name: None,
527        };
528
529        // Session ID should be ignored when agent doesn't support it
530        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}