1use super::ccs::CcsAliasResolver;
22use super::config::{AgentConfig, AgentConfigError, AgentsConfigFile, DEFAULT_AGENTS_TOML};
23use super::fallback::{AgentRole, FallbackConfig};
24use super::parser::JsonParserType;
25use super::retry_timer::{production_timer, RetryTimerProvider};
26use crate::config::{CcsAliasConfig, CcsConfig};
27use std::collections::HashMap;
28use std::path::Path;
29use std::sync::Arc;
30
31pub struct AgentRegistry {
37 agents: HashMap<String, AgentConfig>,
38 fallback: FallbackConfig,
39 ccs_resolver: CcsAliasResolver,
41 retry_timer: Arc<dyn RetryTimerProvider>,
43}
44
45impl AgentRegistry {
46 pub fn new() -> Result<Self, AgentConfigError> {
48 let AgentsConfigFile { agents, fallback } =
49 toml::from_str(DEFAULT_AGENTS_TOML).map_err(AgentConfigError::DefaultTemplateToml)?;
50
51 let mut registry = Self {
52 agents: HashMap::new(),
53 fallback,
54 ccs_resolver: CcsAliasResolver::empty(),
55 retry_timer: production_timer(),
56 };
57
58 for (name, agent_toml) in agents {
59 registry.register(&name, AgentConfig::from(agent_toml));
60 }
61
62 Ok(registry)
63 }
64
65 pub fn set_ccs_aliases(
70 &mut self,
71 aliases: &HashMap<String, CcsAliasConfig>,
72 defaults: CcsConfig,
73 ) {
74 self.ccs_resolver = CcsAliasResolver::new(aliases.clone(), defaults);
75 for alias_name in aliases.keys() {
77 let agent_name = format!("ccs/{alias_name}");
78 if let Some(config) = self.ccs_resolver.try_resolve(&agent_name) {
79 self.agents.insert(agent_name, config);
80 }
81 }
82 }
83
84 pub fn register(&mut self, name: &str, config: AgentConfig) {
86 self.agents.insert(name.to_string(), config);
87 }
88
89 pub fn resolve_config(&self, name: &str) -> Option<AgentConfig> {
94 self.agents
95 .get(name)
96 .cloned()
97 .or_else(|| self.ccs_resolver.try_resolve(name))
98 }
99
100 pub fn display_name(&self, name: &str) -> String {
116 self.resolve_config(name)
117 .and_then(|config| config.display_name)
118 .unwrap_or_else(|| name.to_string())
119 }
120
121 pub fn resolve_fuzzy(&self, name: &str) -> Option<String> {
130 if self.agents.contains_key(name) {
132 return Some(name.to_string());
133 }
134
135 if name.starts_with("ccs/") {
137 return Some(name.to_string());
138 }
139
140 let normalized = name.to_lowercase();
142 let alternatives = Self::get_fuzzy_alternatives(&normalized);
143
144 for alt in alternatives {
145 if alt.starts_with("ccs/") {
147 return Some(alt);
148 }
149 if self.agents.contains_key(&alt) {
151 return Some(alt);
152 }
153 }
154
155 None
156 }
157
158 pub(crate) fn get_fuzzy_alternatives(name: &str) -> Vec<String> {
162 let mut alternatives = Vec::new();
163
164 alternatives.push(name.to_string());
166
167 match name {
169 n if n.starts_with("ccs-") => {
171 alternatives.push(name.replace("ccs-", "ccs/"));
172 }
173 n if n.contains('_') => {
174 alternatives.push(name.replace('_', "-"));
175 alternatives.push(name.replace('_', "/"));
176 }
177
178 "claud" | "cloud" => alternatives.push("claude".to_string()),
180
181 "codeex" | "code-x" => alternatives.push("codex".to_string()),
183
184 "crusor" => alternatives.push("cursor".to_string()),
186
187 "opencode" | "open-code" => alternatives.push("opencode".to_string()),
189
190 "gemeni" | "gemni" => alternatives.push("gemini".to_string()),
192
193 "quen" | "quwen" => alternatives.push("qwen".to_string()),
195
196 "ader" => alternatives.push("aider".to_string()),
198
199 "vib" => alternatives.push("vibe".to_string()),
201
202 "kline" => alternatives.push("cline".to_string()),
204
205 _ => {}
206 }
207
208 alternatives
209 }
210
211 pub fn list(&self) -> Vec<(&str, &AgentConfig)> {
213 self.agents.iter().map(|(k, v)| (k.as_str(), v)).collect()
214 }
215
216 pub fn developer_cmd(&self, agent_name: &str) -> Option<String> {
218 self.resolve_config(agent_name)
219 .map(|c| c.build_cmd(true, true, true))
220 }
221
222 pub fn reviewer_cmd(&self, agent_name: &str) -> Option<String> {
224 self.resolve_config(agent_name)
225 .map(|c| c.build_cmd(true, true, false))
226 }
227
228 pub fn load_from_file<P: AsRef<Path>>(&mut self, path: P) -> Result<usize, AgentConfigError> {
230 match AgentsConfigFile::load_from_file(path)? {
231 Some(config) => {
232 let count = config.agents.len();
233 for (name, agent_toml) in config.agents {
234 self.register(&name, AgentConfig::from(agent_toml));
235 }
236 self.fallback = config.fallback;
238 Ok(count)
239 }
240 None => Ok(0),
241 }
242 }
243
244 pub fn apply_unified_config(&mut self, unified: &crate::config::UnifiedConfig) -> usize {
252 let mut loaded = self.apply_ccs_aliases(unified);
253 loaded += self.apply_agent_overrides(unified);
254
255 if let Some(chain) = &unified.agent_chain {
256 self.fallback = chain.clone();
257 }
258
259 loaded
260 }
261
262 fn apply_ccs_aliases(&mut self, unified: &crate::config::UnifiedConfig) -> usize {
264 if unified.ccs_aliases.is_empty() {
265 return 0;
266 }
267
268 let loaded = unified.ccs_aliases.len();
269 let aliases = unified
270 .ccs_aliases
271 .iter()
272 .map(|(name, v)| (name.clone(), v.as_config()))
273 .collect::<HashMap<_, _>>();
274 self.set_ccs_aliases(&aliases, unified.ccs.clone());
275 loaded
276 }
277
278 fn apply_agent_overrides(&mut self, unified: &crate::config::UnifiedConfig) -> usize {
280 if unified.agents.is_empty() {
281 return 0;
282 }
283
284 let mut loaded = 0usize;
285 for (name, overrides) in &unified.agents {
286 if let Some(existing) = self.agents.get(name).cloned() {
287 let merged = Self::merge_agent_config(existing, overrides);
289 self.register(name, merged);
290 loaded += 1;
291 } else {
292 if let Some(config) = Self::create_new_agent_config(overrides) {
294 self.register(name, config);
295 loaded += 1;
296 }
297 }
298 }
299 loaded
300 }
301
302 fn create_new_agent_config(
304 overrides: &crate::config::unified::AgentConfigToml,
305 ) -> Option<AgentConfig> {
306 let cmd = overrides
307 .cmd
308 .as_deref()
309 .map(str::trim)
310 .filter(|s| !s.is_empty())?;
311
312 let json_parser = overrides
313 .json_parser
314 .as_deref()
315 .map(str::trim)
316 .filter(|s| !s.is_empty())
317 .unwrap_or("generic");
318
319 Some(AgentConfig {
320 cmd: cmd.to_string(),
321 output_flag: overrides.output_flag.clone().unwrap_or_default(),
322 yolo_flag: overrides.yolo_flag.clone().unwrap_or_default(),
323 verbose_flag: overrides.verbose_flag.clone().unwrap_or_default(),
324 can_commit: overrides.can_commit.unwrap_or(true),
325 json_parser: JsonParserType::parse(json_parser),
326 model_flag: overrides.model_flag.clone(),
327 print_flag: overrides.print_flag.clone().unwrap_or_default(),
328 streaming_flag: overrides.streaming_flag.clone().unwrap_or_else(|| {
329 if cmd.starts_with("claude") || cmd.starts_with("ccs") {
331 "--include-partial-messages".to_string()
332 } else {
333 String::new()
334 }
335 }),
336 env_vars: std::collections::HashMap::new(),
337 display_name: overrides
338 .display_name
339 .as_ref()
340 .filter(|s| !s.is_empty())
341 .cloned(),
342 })
343 }
344
345 fn merge_agent_config(
347 existing: AgentConfig,
348 overrides: &crate::config::unified::AgentConfigToml,
349 ) -> AgentConfig {
350 AgentConfig {
351 cmd: overrides
352 .cmd
353 .as_deref()
354 .map(str::trim)
355 .filter(|s| !s.is_empty())
356 .map(str::to_string)
357 .unwrap_or(existing.cmd),
358 output_flag: overrides
359 .output_flag
360 .clone()
361 .unwrap_or(existing.output_flag),
362 yolo_flag: overrides.yolo_flag.clone().unwrap_or(existing.yolo_flag),
363 verbose_flag: overrides
364 .verbose_flag
365 .clone()
366 .unwrap_or(existing.verbose_flag),
367 can_commit: overrides.can_commit.unwrap_or(existing.can_commit),
368 json_parser: overrides
369 .json_parser
370 .as_deref()
371 .map(str::trim)
372 .filter(|s| !s.is_empty())
373 .map_or(existing.json_parser, JsonParserType::parse),
374 model_flag: overrides.model_flag.clone().or(existing.model_flag),
375 print_flag: overrides.print_flag.clone().unwrap_or(existing.print_flag),
376 streaming_flag: overrides
377 .streaming_flag
378 .clone()
379 .unwrap_or(existing.streaming_flag),
380 env_vars: std::collections::HashMap::new(),
385 display_name: match &overrides.display_name {
388 Some(s) if s.is_empty() => None,
389 Some(s) => Some(s.clone()),
390 None => existing.display_name,
391 },
392 }
393 }
394
395 pub const fn fallback_config(&self) -> &FallbackConfig {
397 &self.fallback
398 }
399
400 pub fn retry_timer(&self) -> Arc<dyn RetryTimerProvider> {
402 Arc::clone(&self.retry_timer)
403 }
404
405 #[cfg(any(test, feature = "test-utils"))]
410 pub fn set_retry_timer(&mut self, timer: Arc<dyn RetryTimerProvider>) {
411 self.retry_timer = timer;
412 }
413
414 pub fn available_fallbacks(&self, role: AgentRole) -> Vec<&str> {
416 self.fallback
417 .get_fallbacks(role)
418 .iter()
419 .filter(|name| self.is_agent_available(name))
420 .filter(|name| {
422 self.resolve_config(name.as_str())
423 .is_some_and(|cfg| cfg.can_commit)
424 })
425 .map(std::string::String::as_str)
426 .collect()
427 }
428
429 pub fn validate_agent_chains(&self) -> Result<(), String> {
431 let has_developer = self.fallback.has_fallbacks(AgentRole::Developer);
432 let has_reviewer = self.fallback.has_fallbacks(AgentRole::Reviewer);
433
434 if !has_developer && !has_reviewer {
435 return Err("No agent chain configured.\n\
436 Please add an [agent_chain] section to ~/.config/ralph-workflow.toml.\n\
437 Run 'ralph --init-global' to create a default configuration."
438 .to_string());
439 }
440
441 if !has_developer {
442 return Err("No developer agent chain configured.\n\
443 Add 'developer = [\"your-agent\", ...]' to your [agent_chain] section.\n\
444 Use --list-agents to see available agents."
445 .to_string());
446 }
447
448 if !has_reviewer {
449 return Err("No reviewer agent chain configured.\n\
450 Add 'reviewer = [\"your-agent\", ...]' to your [agent_chain] section.\n\
451 Use --list-agents to see available agents."
452 .to_string());
453 }
454
455 for role in [AgentRole::Developer, AgentRole::Reviewer] {
457 let chain = self.fallback.get_fallbacks(role);
458 let has_capable = chain
459 .iter()
460 .any(|name| self.resolve_config(name).is_some_and(|cfg| cfg.can_commit));
461 if !has_capable {
462 return Err(format!(
463 "No workflow-capable agents found for {role}.\n\
464 All agents in the {role} chain have can_commit=false.\n\
465 Fix: set can_commit=true for at least one agent or update [agent_chain]."
466 ));
467 }
468 }
469
470 Ok(())
471 }
472
473 pub fn is_agent_available(&self, name: &str) -> bool {
475 if let Some(config) = self.resolve_config(name) {
476 let Ok(parts) = crate::common::split_command(&config.cmd) else {
477 return false;
478 };
479 let Some(base_cmd) = parts.first() else {
480 return false;
481 };
482
483 which::which(base_cmd).is_ok()
485 } else {
486 false
487 }
488 }
489
490 pub fn list_available(&self) -> Vec<&str> {
492 self.agents
493 .keys()
494 .filter(|name| self.is_agent_available(name))
495 .map(std::string::String::as_str)
496 .collect()
497 }
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503 use crate::agents::JsonParserType;
504 use std::sync::Mutex;
505
506 static ENV_MUTEX: Mutex<()> = Mutex::new(());
507
508 fn default_ccs() -> CcsConfig {
509 CcsConfig::default()
510 }
511
512 fn write_stub_executable(dir: &std::path::Path, name: &str) {
513 #[cfg(windows)]
514 {
515 let path = dir.join(format!("{}.cmd", name));
516 std::fs::write(&path, "@echo off\r\nexit /b 0\r\n").unwrap();
517 }
518 #[cfg(unix)]
519 {
520 use std::os::unix::fs::PermissionsExt;
521 let path = dir.join(name);
522 std::fs::write(&path, "#!/bin/sh\nexit 0\n").unwrap();
523 let mut perms = std::fs::metadata(&path).unwrap().permissions();
524 perms.set_mode(0o755);
525 std::fs::set_permissions(&path, perms).unwrap();
526 }
527 }
528
529 #[test]
530 fn test_registry_new() {
531 let registry = AgentRegistry::new().unwrap();
532 assert!(registry.resolve_config("claude").is_some());
534 assert!(registry.resolve_config("codex").is_some());
535 }
536
537 #[test]
538 fn test_registry_register() {
539 let mut registry = AgentRegistry::new().unwrap();
540 registry.register(
541 "testbot",
542 AgentConfig {
543 cmd: "testbot run".to_string(),
544 output_flag: "--json".to_string(),
545 yolo_flag: "--yes".to_string(),
546 verbose_flag: String::new(),
547 can_commit: true,
548 json_parser: JsonParserType::Generic,
549 model_flag: None,
550 print_flag: String::new(),
551 streaming_flag: String::new(),
552 env_vars: std::collections::HashMap::new(),
553 display_name: None,
554 },
555 );
556 assert!(registry.resolve_config("testbot").is_some());
558 }
559
560 #[test]
561 fn test_registry_display_name() {
562 let mut registry = AgentRegistry::new().unwrap();
563
564 registry.register(
566 "claude",
567 AgentConfig {
568 cmd: "claude -p".to_string(),
569 output_flag: "--output-format=stream-json".to_string(),
570 yolo_flag: "--dangerously-skip-permissions".to_string(),
571 verbose_flag: "--verbose".to_string(),
572 can_commit: true,
573 json_parser: JsonParserType::Claude,
574 model_flag: None,
575 print_flag: String::new(),
576 streaming_flag: "--include-partial-messages".to_string(),
577 env_vars: std::collections::HashMap::new(),
578 display_name: None,
579 },
580 );
581
582 registry.register(
584 "ccs/glm",
585 AgentConfig {
586 cmd: "ccs glm".to_string(),
587 output_flag: "--output-format=stream-json".to_string(),
588 yolo_flag: "--dangerously-skip-permissions".to_string(),
589 verbose_flag: "--verbose".to_string(),
590 can_commit: true,
591 json_parser: JsonParserType::Claude,
592 model_flag: None,
593 print_flag: "-p".to_string(),
594 streaming_flag: "--include-partial-messages".to_string(),
595 env_vars: std::collections::HashMap::new(),
596 display_name: Some("ccs-glm".to_string()),
597 },
598 );
599
600 assert_eq!(registry.display_name("claude"), "claude");
602 assert_eq!(registry.display_name("ccs/glm"), "ccs-glm");
603
604 assert_eq!(registry.display_name("unknown"), "unknown");
606 }
607
608 #[test]
609 fn test_registry_available_fallbacks() {
610 let _lock = ENV_MUTEX.lock().unwrap();
611 let original_path = std::env::var_os("PATH");
612 let dir = tempfile::tempdir().unwrap();
613
614 write_stub_executable(dir.path(), "claude");
615 write_stub_executable(dir.path(), "codex");
616
617 let mut new_paths = vec![dir.path().to_path_buf()];
618 if let Some(p) = &original_path {
619 new_paths.extend(std::env::split_paths(p));
620 }
621 let joined = std::env::join_paths(new_paths).unwrap();
622 std::env::set_var("PATH", &joined);
623
624 let mut registry = AgentRegistry::new().unwrap();
625 let toml_str = r#"
627 [agent_chain]
628 developer = ["claude", "nonexistent", "codex"]
629 "#;
630 let unified: crate::config::UnifiedConfig = toml::from_str(toml_str).unwrap();
631 registry.apply_unified_config(&unified);
632
633 let fallbacks = registry.available_fallbacks(AgentRole::Developer);
634 assert!(fallbacks.contains(&"claude"));
635 assert!(fallbacks.contains(&"codex"));
636 assert!(!fallbacks.contains(&"nonexistent"));
637
638 if let Some(p) = original_path {
639 std::env::set_var("PATH", p);
640 } else {
641 std::env::remove_var("PATH");
642 }
643 }
644
645 #[test]
646 fn test_validate_agent_chains() {
647 let mut registry = AgentRegistry::new().unwrap();
648
649 let toml_str = r#"
651 [agent_chain]
652 developer = ["claude"]
653 reviewer = ["codex"]
654 "#;
655 let unified: crate::config::UnifiedConfig = toml::from_str(toml_str).unwrap();
656 registry.apply_unified_config(&unified);
657 assert!(registry.validate_agent_chains().is_ok());
658 }
659
660 #[test]
661 fn test_ccs_aliases_registration() {
662 let mut registry = AgentRegistry::new().unwrap();
664
665 let mut aliases = HashMap::new();
666 aliases.insert(
667 "work".to_string(),
668 CcsAliasConfig {
669 cmd: "ccs work".to_string(),
670 ..CcsAliasConfig::default()
671 },
672 );
673 aliases.insert(
674 "personal".to_string(),
675 CcsAliasConfig {
676 cmd: "ccs personal".to_string(),
677 ..CcsAliasConfig::default()
678 },
679 );
680 aliases.insert(
681 "gemini".to_string(),
682 CcsAliasConfig {
683 cmd: "ccs gemini".to_string(),
684 ..CcsAliasConfig::default()
685 },
686 );
687
688 registry.set_ccs_aliases(&aliases, default_ccs());
689
690 assert!(registry.resolve_config("ccs/work").is_some());
692 assert!(registry.resolve_config("ccs/personal").is_some());
693 assert!(registry.resolve_config("ccs/gemini").is_some());
694
695 let config = registry.resolve_config("ccs/work").unwrap();
697 assert!(
699 config.cmd.ends_with("claude") || config.cmd == "ccs work",
700 "cmd should be 'ccs work' or a path ending with 'claude', got: {}",
701 config.cmd
702 );
703 assert!(config.can_commit);
704 assert_eq!(config.json_parser, JsonParserType::Claude);
705 }
706
707 #[test]
708 fn test_ccs_in_fallback_chain() {
709 let _lock = ENV_MUTEX.lock().unwrap();
710 let original_path = std::env::var_os("PATH");
711 let dir = tempfile::tempdir().unwrap();
712
713 write_stub_executable(dir.path(), "ccs");
715 write_stub_executable(dir.path(), "claude");
716
717 let mut new_paths = vec![dir.path().to_path_buf()];
718 if let Some(p) = &original_path {
719 new_paths.extend(std::env::split_paths(p));
720 }
721 let joined = std::env::join_paths(new_paths).unwrap();
722 std::env::set_var("PATH", &joined);
723
724 let mut registry = AgentRegistry::new().unwrap();
725
726 let mut aliases = HashMap::new();
728 aliases.insert(
729 "work".to_string(),
730 CcsAliasConfig {
731 cmd: "ccs work".to_string(),
732 ..CcsAliasConfig::default()
733 },
734 );
735 registry.set_ccs_aliases(&aliases, default_ccs());
736
737 let toml_str = r#"
739 [agent_chain]
740 developer = ["ccs/work", "claude"]
741 reviewer = ["claude"]
742 "#;
743 let unified: crate::config::UnifiedConfig = toml::from_str(toml_str).unwrap();
744 registry.apply_unified_config(&unified);
745
746 let fallbacks = registry.available_fallbacks(AgentRole::Developer);
748 assert!(fallbacks.contains(&"ccs/work"));
749 assert!(fallbacks.contains(&"claude"));
750
751 assert!(registry.validate_agent_chains().is_ok());
753
754 if let Some(p) = original_path {
755 std::env::set_var("PATH", p);
756 } else {
757 std::env::remove_var("PATH");
758 }
759 }
760
761 #[test]
762 fn test_ccs_aliases_with_registry_constructor() {
763 let mut registry = AgentRegistry::new().unwrap();
764 registry.set_ccs_aliases(&HashMap::new(), default_ccs());
765
766 assert!(registry.resolve_config("claude").is_some());
768 assert!(registry.resolve_config("codex").is_some());
769
770 let mut registry2 = AgentRegistry::new().unwrap();
772 let mut aliases = HashMap::new();
773 aliases.insert(
774 "work".to_string(),
775 CcsAliasConfig {
776 cmd: "ccs work".to_string(),
777 ..CcsAliasConfig::default()
778 },
779 );
780
781 registry2.set_ccs_aliases(&aliases, default_ccs());
782 assert!(registry2.resolve_config("ccs/work").is_some());
784 }
785
786 #[test]
787 fn test_list_includes_ccs_aliases() {
788 let mut registry = AgentRegistry::new().unwrap();
789
790 let mut aliases = HashMap::new();
791 aliases.insert(
792 "work".to_string(),
793 CcsAliasConfig {
794 cmd: "ccs work".to_string(),
795 ..CcsAliasConfig::default()
796 },
797 );
798 aliases.insert(
799 "personal".to_string(),
800 CcsAliasConfig {
801 cmd: "ccs personal".to_string(),
802 ..CcsAliasConfig::default()
803 },
804 );
805 registry.set_ccs_aliases(&aliases, default_ccs());
806
807 let all_agents = registry.list();
808
809 assert_eq!(
810 all_agents
811 .iter()
812 .filter(|(name, _)| name.starts_with("ccs/"))
813 .count(),
814 2
815 );
816 }
817
818 #[test]
819 fn test_resolve_fuzzy_exact_match() {
820 let registry = AgentRegistry::new().unwrap();
821 assert_eq!(registry.resolve_fuzzy("claude"), Some("claude".to_string()));
822 assert_eq!(registry.resolve_fuzzy("codex"), Some("codex".to_string()));
823 }
824
825 #[test]
826 fn test_resolve_fuzzy_ccs_unregistered() {
827 let registry = AgentRegistry::new().unwrap();
828 assert_eq!(
830 registry.resolve_fuzzy("ccs/random"),
831 Some("ccs/random".to_string())
832 );
833 assert_eq!(
834 registry.resolve_fuzzy("ccs/unregistered"),
835 Some("ccs/unregistered".to_string())
836 );
837 }
838
839 #[test]
840 fn test_resolve_fuzzy_typos() {
841 let registry = AgentRegistry::new().unwrap();
842 assert_eq!(registry.resolve_fuzzy("claud"), Some("claude".to_string()));
844 assert_eq!(registry.resolve_fuzzy("CLAUD"), Some("claude".to_string()));
845 }
846
847 #[test]
848 fn test_resolve_fuzzy_codex_variations() {
849 let registry = AgentRegistry::new().unwrap();
850 assert_eq!(registry.resolve_fuzzy("codeex"), Some("codex".to_string()));
852 assert_eq!(registry.resolve_fuzzy("code-x"), Some("codex".to_string()));
853 assert_eq!(registry.resolve_fuzzy("CODEEX"), Some("codex".to_string()));
854 }
855
856 #[test]
857 fn test_resolve_fuzzy_cursor_variations() {
858 let registry = AgentRegistry::new().unwrap();
859 assert_eq!(registry.resolve_fuzzy("crusor"), Some("cursor".to_string()));
861 assert_eq!(registry.resolve_fuzzy("CRUSOR"), Some("cursor".to_string()));
862 }
863
864 #[test]
865 fn test_resolve_fuzzy_gemini_variations() {
866 let registry = AgentRegistry::new().unwrap();
867 assert_eq!(registry.resolve_fuzzy("gemeni"), Some("gemini".to_string()));
869 assert_eq!(registry.resolve_fuzzy("gemni"), Some("gemini".to_string()));
870 assert_eq!(registry.resolve_fuzzy("GEMENI"), Some("gemini".to_string()));
871 }
872
873 #[test]
874 fn test_resolve_fuzzy_qwen_variations() {
875 let registry = AgentRegistry::new().unwrap();
876 assert_eq!(registry.resolve_fuzzy("quen"), Some("qwen".to_string()));
878 assert_eq!(registry.resolve_fuzzy("quwen"), Some("qwen".to_string()));
879 assert_eq!(registry.resolve_fuzzy("QUEN"), Some("qwen".to_string()));
880 }
881
882 #[test]
883 fn test_resolve_fuzzy_aider_variations() {
884 let registry = AgentRegistry::new().unwrap();
885 assert_eq!(registry.resolve_fuzzy("ader"), Some("aider".to_string()));
887 assert_eq!(registry.resolve_fuzzy("ADER"), Some("aider".to_string()));
888 }
889
890 #[test]
891 fn test_resolve_fuzzy_vibe_variations() {
892 let registry = AgentRegistry::new().unwrap();
893 assert_eq!(registry.resolve_fuzzy("vib"), Some("vibe".to_string()));
895 assert_eq!(registry.resolve_fuzzy("VIB"), Some("vibe".to_string()));
896 }
897
898 #[test]
899 fn test_resolve_fuzzy_cline_variations() {
900 let registry = AgentRegistry::new().unwrap();
901 assert_eq!(registry.resolve_fuzzy("kline"), Some("cline".to_string()));
903 assert_eq!(registry.resolve_fuzzy("KLINE"), Some("cline".to_string()));
904 }
905
906 #[test]
907 fn test_resolve_fuzzy_ccs_dash_to_slash() {
908 let registry = AgentRegistry::new().unwrap();
909 assert_eq!(
911 registry.resolve_fuzzy("ccs-random"),
912 Some("ccs/random".to_string())
913 );
914 assert_eq!(
915 registry.resolve_fuzzy("ccs-test"),
916 Some("ccs/test".to_string())
917 );
918 }
919
920 #[test]
921 fn test_resolve_fuzzy_underscore_replacement() {
922 let result = AgentRegistry::get_fuzzy_alternatives("my_agent");
925 assert!(result.contains(&"my_agent".to_string()));
926 assert!(result.contains(&"my-agent".to_string()));
927 assert!(result.contains(&"my/agent".to_string()));
928 }
929
930 #[test]
931 fn test_resolve_fuzzy_unknown() {
932 let registry = AgentRegistry::new().unwrap();
933 assert_eq!(registry.resolve_fuzzy("totally-unknown"), None);
935 }
936
937 #[test]
938 fn test_apply_unified_config_does_not_inherit_env_vars() {
939 let mut registry = AgentRegistry::new().unwrap();
943
944 registry.register(
947 "claude",
948 AgentConfig {
949 cmd: "claude -p".to_string(),
950 output_flag: "--output-format=stream-json".to_string(),
951 yolo_flag: "--dangerously-skip-permissions".to_string(),
952 verbose_flag: "--verbose".to_string(),
953 can_commit: true,
954 json_parser: JsonParserType::Claude,
955 model_flag: None,
956 print_flag: String::new(),
957 streaming_flag: "--include-partial-messages".to_string(),
958 env_vars: {
960 let mut vars = std::collections::HashMap::new();
961 vars.insert(
962 "ANTHROPIC_BASE_URL".to_string(),
963 "https://api.z.ai/api/anthropic".to_string(),
964 );
965 vars.insert(
966 "ANTHROPIC_AUTH_TOKEN".to_string(),
967 "test-token-glm".to_string(),
968 );
969 vars.insert("ANTHROPIC_MODEL".to_string(), "glm-4.7".to_string());
970 vars
971 },
972 display_name: None,
973 },
974 );
975
976 let claude_config = registry.resolve_config("claude").unwrap();
978 assert_eq!(claude_config.env_vars.len(), 3);
979 assert_eq!(
980 claude_config.env_vars.get("ANTHROPIC_BASE_URL"),
981 Some(&"https://api.z.ai/api/anthropic".to_string())
982 );
983
984 let toml_str = r#"
990 [general]
991 verbosity = 2
992 interactive = true
993 isolation_mode = true
994
995 [agents.claude]
996 cmd = "claude -p"
997 display_name = "My Custom Claude"
998 "#;
999 let unified: crate::config::UnifiedConfig = toml::from_str(toml_str).unwrap();
1000
1001 registry.apply_unified_config(&unified);
1003
1004 let claude_config_after = registry.resolve_config("claude").unwrap();
1006 assert_eq!(
1007 claude_config_after.env_vars.len(),
1008 0,
1009 "env_vars should NOT be inherited from the existing agent when unified config is applied"
1010 );
1011 assert_eq!(
1012 claude_config_after.display_name,
1013 Some("My Custom Claude".to_string()),
1014 "display_name should be updated from the unified config"
1015 );
1016 }
1017
1018 #[test]
1019 fn test_resolve_config_does_not_share_env_vars_between_agents() {
1020 let mut registry = AgentRegistry::new().unwrap();
1029
1030 registry.register(
1032 "ccs/glm",
1033 AgentConfig {
1034 cmd: "ccs glm".to_string(),
1035 output_flag: "--output-format=stream-json".to_string(),
1036 yolo_flag: "--dangerously-skip-permissions".to_string(),
1037 verbose_flag: "--verbose".to_string(),
1038 can_commit: true,
1039 json_parser: JsonParserType::Claude,
1040 model_flag: None,
1041 print_flag: "-p".to_string(),
1042 streaming_flag: "--include-partial-messages".to_string(),
1043 env_vars: {
1044 let mut vars = std::collections::HashMap::new();
1045 vars.insert(
1046 "ANTHROPIC_BASE_URL".to_string(),
1047 "https://api.z.ai/api/anthropic".to_string(),
1048 );
1049 vars.insert(
1050 "ANTHROPIC_AUTH_TOKEN".to_string(),
1051 "test-token-glm".to_string(),
1052 );
1053 vars.insert("ANTHROPIC_MODEL".to_string(), "glm-4.7".to_string());
1054 vars
1055 },
1056 display_name: Some("ccs-glm".to_string()),
1057 },
1058 );
1059
1060 registry.register(
1062 "claude",
1063 AgentConfig {
1064 cmd: "claude -p".to_string(),
1065 output_flag: "--output-format=stream-json".to_string(),
1066 yolo_flag: "--dangerously-skip-permissions".to_string(),
1067 verbose_flag: "--verbose".to_string(),
1068 can_commit: true,
1069 json_parser: JsonParserType::Claude,
1070 model_flag: None,
1071 print_flag: String::new(),
1072 streaming_flag: "--include-partial-messages".to_string(),
1073 env_vars: std::collections::HashMap::new(),
1074 display_name: None,
1075 },
1076 );
1077
1078 let glm_config = registry.resolve_config("ccs/glm").unwrap();
1080 assert_eq!(glm_config.env_vars.len(), 3);
1081 assert_eq!(
1082 glm_config.env_vars.get("ANTHROPIC_BASE_URL"),
1083 Some(&"https://api.z.ai/api/anthropic".to_string())
1084 );
1085
1086 let claude_config = registry.resolve_config("claude").unwrap();
1088 assert_eq!(
1089 claude_config.env_vars.len(),
1090 0,
1091 "claude agent should have empty env_vars"
1092 );
1093
1094 let glm_config2 = registry.resolve_config("ccs/glm").unwrap();
1096 assert_eq!(glm_config2.env_vars.len(), 3);
1097
1098 drop(glm_config);
1101
1102 let claude_config2 = registry.resolve_config("claude").unwrap();
1104 assert_eq!(
1105 claude_config2.env_vars.len(),
1106 0,
1107 "claude agent env_vars should remain independent"
1108 );
1109 }
1110}