Skip to main content

ralph_workflow/agents/ccs/
configuration.rs

1// CCS configuration handling - config types, aliases, environment variables
2//
3// # Conditional Visibility Pattern
4//
5// This module uses a conditional compilation pattern for several functions:
6// - `pub fn` when `test` or `test-utils` feature is enabled (for test access)
7// - `pub(crate) fn` otherwise (internal-only in production)
8//
9// Both variants call the same `_impl` function, avoiding code duplication while
10// providing conditional API visibility. This pattern appears repetitive but is
11// intentional: it keeps test utilities accessible in tests while hiding them
12// from the public API surface in production builds.
13//
14// Functions using this pattern:
15// - ccs_env_var_debug_summary
16// - resolve_ccs_command
17// - build_ccs_agent_config
18
19/// Result type for CCS command resolution with diagnostics.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct CcsCommandResult {
22    pub command: String,
23}
24
25impl PartialEq<&str> for CcsCommandResult {
26    fn eq(&self, other: &&str) -> bool {
27        self.command == *other
28    }
29}
30
31impl PartialEq<String> for CcsCommandResult {
32    fn eq(&self, other: &String) -> bool {
33        self.command == *other
34    }
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct CcsEnvVarDebugSummary {
39    pub whitelisted_keys_present: Vec<String>,
40    pub redacted_sensitive_keys: usize,
41    pub hidden_non_whitelisted_keys: usize,
42}
43
44#[must_use]
45pub fn ccs_env_var_debug_summary(env_vars: &HashMap<String, String>) -> CcsEnvVarDebugSummary {
46    let whitelisted_prefixes = ["ANTHROPIC_BASE_URL", "ANTHROPIC_MODEL"];
47    let sensitive_markers = ["token", "key", "secret", "password", "auth"];
48
49    let whitelisted_keys_present = env_vars
50        .keys()
51        .filter(|key| {
52            whitelisted_prefixes
53                .iter()
54                .any(|prefix| key.eq_ignore_ascii_case(prefix))
55        })
56        .cloned()
57        .collect();
58
59    let redacted_sensitive_keys = env_vars
60        .keys()
61        .filter(|key| {
62            let lower = key.to_ascii_lowercase();
63            sensitive_markers.iter().any(|m| lower.contains(m))
64        })
65        .count();
66
67    let hidden_non_whitelisted_keys = env_vars
68        .keys()
69        .filter(|key| {
70            !whitelisted_prefixes
71                .iter()
72                .any(|prefix| key.eq_ignore_ascii_case(prefix))
73                && !sensitive_markers
74                    .iter()
75                    .any(|m| key.to_ascii_lowercase().contains(m))
76        })
77        .count();
78
79    CcsEnvVarDebugSummary {
80        whitelisted_keys_present,
81        redacted_sensitive_keys,
82        hidden_non_whitelisted_keys,
83    }
84}
85
86/// Resolve a CCS alias to an `AgentConfig`.
87///
88/// Given a CCS alias and a map of aliases to commands, this function
89/// generates an `AgentConfig` that can be used to run CCS.
90///
91/// # Arguments
92///
93/// * `alias` - The alias name (e.g., "work", "gemini", or "" for default)
94/// * `aliases` - Map of alias names to CCS commands
95///
96/// # Returns
97///
98/// Returns `Some(AgentConfig)` if the alias is found or if using default,
99/// `None` if the alias is not found in the map.
100#[must_use]
101pub fn resolve_ccs_agent<S: std::hash::BuildHasher>(
102    alias: &str,
103    aliases: &HashMap<String, CcsAliasConfig, S>,
104    defaults: &CcsConfig,
105) -> Option<AgentConfig> {
106    // Empty alias means use default CCS
107    let (cmd, display_name) = if alias.is_empty() {
108        (
109            CcsAliasConfig {
110                cmd: "ccs".to_string(),
111                ..CcsAliasConfig::default()
112            },
113            "ccs".to_string(),
114        )
115    } else if let Some(cfg) = aliases.get(alias) {
116        (cfg.clone(), format!("ccs-{alias}"))
117    } else {
118        // Unknown alias - return None so caller can fall back
119        return None;
120    };
121
122    Some(build_ccs_agent_config(&cmd, defaults, display_name, alias))
123}
124
125/// Build an `AgentConfig` for a CCS command.
126///
127/// CCS wraps Claude Code, so it uses Claude's stream-json format
128/// and similar flags.
129///
130/// # JSON Parser Selection
131///
132/// CCS (Claude Code Switcher) defaults to the Claude parser (`json_parser = "claude"`)
133/// because CCS wraps the `claude` CLI tool and uses Claude's stream-json output format.
134///
135/// **Why Claude parser by default?** CCS uses Claude Code's CLI interface and output format.
136/// The `--output-format=stream-json` flag produces Claude's NDJSON format, which the
137/// Claude parser is designed to handle.
138///
139/// **Parser override:** Users can override the parser via `json_parser` in their config.
140/// The alias-specific `json_parser` takes precedence over the CCS default. This allows
141/// advanced users to use alternative parsers if needed for specific providers.
142///
143/// Example: `ccs glm` -> uses Claude parser by default (from `defaults.json_parser`)
144///          `ccs gemini` -> uses Claude parser by default
145///          With override: `json_parser = "generic"` in alias config overrides default
146///
147/// Display name format: CCS aliases are shown as "ccs-{alias}" (e.g., "ccs-glm", "ccs-gemini")
148/// in output/logs to make it clearer which provider is actually being used, while still using
149/// the Claude parser under the hood.
150///
151/// # Environment Variable Loading
152///
153/// This function automatically loads environment variables for the resolved CCS profile using
154/// CCS config mappings (`~/.ccs/config.json` / `~/.ccs/config.yaml`) and common settings file
155/// naming (`~/.ccs/{profile}.settings.json` / `~/.ccs/{profile}.setting.json`). This allows
156/// Log CCS environment variables loading status (debug mode only).
157///
158/// Only logs whitelisted "safe" environment variable keys to prevent accidental
159/// leakage of sensitive credential values. Keys containing patterns like "token",
160/// "key", "secret", "password", "auth" are always filtered out regardless of
161/// their actual value, to protect against custom credential formats.
162const fn is_glm_alias(alias_name: &str) -> bool {
163    alias_name.eq_ignore_ascii_case("glm")
164}
165
166/// Resolve the CCS command, potentially bypassing the ccs wrapper for direct claude binary.
167///
168/// For CCS aliases, we try to use `claude` directly instead of the `ccs` wrapper
169/// because the wrapper does not pass through all flags properly (especially
170/// streaming-related flags like --include-partial-messages).
171///
172/// We only bypass the wrapper when:
173/// - The agent name is `ccs/<alias>` (not plain `ccs`)
174/// - We successfully loaded at least one env var for that profile
175/// - The configured command targets that profile (e.g. `ccs <profile>` or `ccs api <profile>`
176#[cfg(any(test, feature = "test-utils"))]
177#[must_use]
178pub fn resolve_ccs_command(
179    alias_config: &CcsAliasConfig,
180    alias_name: &str,
181    env_vars_loaded: bool,
182    profile_used_for_env: Option<&String>,
183    debug_mode: bool,
184) -> CcsCommandResult {
185    resolve_ccs_command_impl(
186        alias_config,
187        alias_name,
188        env_vars_loaded,
189        profile_used_for_env,
190        debug_mode,
191    )
192}
193
194#[cfg(not(any(test, feature = "test-utils")))]
195#[must_use]
196pub fn resolve_ccs_command(
197    alias_config: &CcsAliasConfig,
198    alias_name: &str,
199    env_vars_loaded: bool,
200    profile_used_for_env: Option<&String>,
201    debug_mode: bool,
202) -> CcsCommandResult {
203    resolve_ccs_command_impl(
204        alias_config,
205        alias_name,
206        env_vars_loaded,
207        profile_used_for_env,
208        debug_mode,
209    )
210}
211
212fn resolve_ccs_command_impl(
213    alias_config: &CcsAliasConfig,
214    alias_name: &str,
215    env_vars_loaded: bool,
216    profile_used_for_env: Option<&String>,
217    _debug_mode: bool,
218) -> CcsCommandResult {
219    let original_cmd = alias_config.cmd.as_str();
220
221    find_claude_binary().map_or_else(
222        || CcsCommandResult {
223            command: original_cmd.to_string(),
224        },
225        |claude_path| {
226            let can_bypass_wrapper = is_glm_alias(alias_name) && env_vars_loaded;
227
228            if !can_bypass_wrapper {
229                return CcsCommandResult {
230                    command: original_cmd.to_string(),
231                };
232            }
233
234            let Ok(parts) = split_command(original_cmd) else {
235                return CcsCommandResult {
236                    command: original_cmd.to_string(),
237                };
238            };
239
240            let profile = ccs_profile_from_command(original_cmd)
241                .or_else(|| profile_used_for_env.cloned())
242                .unwrap_or_else(|| alias_name.to_string());
243            let is_ccs_cmd = parts.first().is_some_and(|p| looks_like_ccs_executable(p));
244            let skip = if parts.get(1).is_some_and(|p| p == &profile) {
245                Some(2)
246            } else if parts.get(1).is_some_and(|p| p == "api")
247                && parts.get(2).is_some_and(|p| p == &profile)
248            {
249                Some(3)
250            } else {
251                None
252            };
253            let is_profile_ccs_cmd = is_ccs_cmd && skip.is_some();
254
255            if !is_profile_ccs_cmd {
256                return CcsCommandResult {
257                    command: original_cmd.to_string(),
258                };
259            }
260
261            let skip = skip.unwrap_or(2);
262            let new_parts: Vec<String> = std::iter::once(claude_path.to_string_lossy().to_string())
263                .chain(parts.into_iter().skip(skip))
264                .collect();
265            let new_cmd = shell_words::join(&new_parts);
266
267            CcsCommandResult { command: new_cmd }
268        },
269    )
270}
271
272include!("agent_config.rs");