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");