1use super::ccs::CcsAliasResolver;
34use super::config::{AgentConfig, AgentConfigError, AgentsConfigFile, DEFAULT_AGENTS_TOML};
35use super::fallback::{AgentRole, FallbackConfig};
36use super::opencode_resolver::OpenCodeResolver;
37use super::parser::JsonParserType;
38use super::retry_timer::{production_timer, RetryTimerProvider};
39use crate::agents::opencode_api::ApiCatalog;
40use crate::config::{CcsAliasConfig, CcsConfig};
41use std::collections::HashMap;
42use std::path::Path;
43use std::sync::Arc;
44
45pub struct AgentRegistry {
54 agents: HashMap<String, AgentConfig>,
55 fallback: FallbackConfig,
56 ccs_resolver: CcsAliasResolver,
58 opencode_resolver: Option<OpenCodeResolver>,
60 retry_timer: Arc<dyn RetryTimerProvider>,
62}
63
64impl AgentRegistry {
65 pub fn new() -> Result<Self, AgentConfigError> {
67 let AgentsConfigFile { agents, fallback } =
68 toml::from_str(DEFAULT_AGENTS_TOML).map_err(AgentConfigError::DefaultTemplateToml)?;
69
70 let mut registry = Self {
71 agents: HashMap::new(),
72 fallback,
73 ccs_resolver: CcsAliasResolver::empty(),
74 opencode_resolver: None,
75 retry_timer: production_timer(),
76 };
77
78 for (name, agent_toml) in agents {
79 registry.register(&name, AgentConfig::from(agent_toml));
80 }
81
82 Ok(registry)
83 }
84
85 pub fn set_opencode_catalog(&mut self, catalog: ApiCatalog) {
89 self.opencode_resolver = Some(OpenCodeResolver::new(catalog));
90 }
91
92 pub fn set_ccs_aliases(
97 &mut self,
98 aliases: &HashMap<String, CcsAliasConfig>,
99 defaults: CcsConfig,
100 ) {
101 self.ccs_resolver = CcsAliasResolver::new(aliases.clone(), defaults);
102 for alias_name in aliases.keys() {
104 let agent_name = format!("ccs/{alias_name}");
105 if let Some(config) = self.ccs_resolver.try_resolve(&agent_name) {
106 self.agents.insert(agent_name, config);
107 }
108 }
109 }
110
111 pub fn register(&mut self, name: &str, config: AgentConfig) {
113 self.agents.insert(name.to_string(), config);
114 }
115
116 pub fn resolve_config(&self, name: &str) -> Option<AgentConfig> {
124 self.agents
125 .get(name)
126 .cloned()
127 .or_else(|| self.ccs_resolver.try_resolve(name))
128 .or_else(|| {
129 self.opencode_resolver
130 .as_ref()
131 .and_then(|r| r.try_resolve(name))
132 })
133 }
134
135 pub fn display_name(&self, name: &str) -> String {
151 self.resolve_config(name)
152 .and_then(|config| config.display_name)
153 .unwrap_or_else(|| name.to_string())
154 }
155
156 pub fn resolve_fuzzy(&self, name: &str) -> Option<String> {
166 if self.agents.contains_key(name) {
168 return Some(name.to_string());
169 }
170
171 if name.starts_with("ccs/") {
173 return Some(name.to_string());
174 }
175
176 if name.starts_with("opencode/") {
178 let parts: Vec<&str> = name.split('/').collect();
180 if parts.len() == 3 && parts[0] == "opencode" {
181 return Some(name.to_string());
182 }
183 }
184
185 let normalized = name.to_lowercase();
187 let alternatives = Self::get_fuzzy_alternatives(&normalized);
188
189 for alt in alternatives {
190 if alt.starts_with("ccs/") {
192 return Some(alt);
193 }
194 if alt.starts_with("opencode/") {
196 let parts: Vec<&str> = alt.split('/').collect();
197 if parts.len() == 3 && parts[0] == "opencode" {
198 return Some(alt);
199 }
200 }
201 if self.agents.contains_key(&alt) {
203 return Some(alt);
204 }
205 }
206
207 None
208 }
209
210 pub(crate) fn get_fuzzy_alternatives(name: &str) -> Vec<String> {
214 let mut alternatives = Vec::new();
215
216 alternatives.push(name.to_string());
218
219 match name {
221 n if n.starts_with("ccs-") => {
223 alternatives.push(name.replace("ccs-", "ccs/"));
224 }
225 n if n.contains('_') => {
226 alternatives.push(name.replace('_', "-"));
227 alternatives.push(name.replace('_', "/"));
228 }
229
230 "claud" | "cloud" => alternatives.push("claude".to_string()),
232
233 "codeex" | "code-x" => alternatives.push("codex".to_string()),
235
236 "crusor" => alternatives.push("cursor".to_string()),
238
239 "opencode" | "open-code" => alternatives.push("opencode".to_string()),
241
242 "gemeni" | "gemni" => alternatives.push("gemini".to_string()),
244
245 "quen" | "quwen" => alternatives.push("qwen".to_string()),
247
248 "ader" => alternatives.push("aider".to_string()),
250
251 "vib" => alternatives.push("vibe".to_string()),
253
254 "kline" => alternatives.push("cline".to_string()),
256
257 _ => {}
258 }
259
260 alternatives
261 }
262
263 pub fn list(&self) -> Vec<(&str, &AgentConfig)> {
265 self.agents.iter().map(|(k, v)| (k.as_str(), v)).collect()
266 }
267
268 pub fn developer_cmd(&self, agent_name: &str) -> Option<String> {
270 self.resolve_config(agent_name)
271 .map(|c| c.build_cmd(true, true, true))
272 }
273
274 pub fn reviewer_cmd(&self, agent_name: &str) -> Option<String> {
276 self.resolve_config(agent_name)
277 .map(|c| c.build_cmd(true, true, false))
278 }
279
280 pub fn load_from_file<P: AsRef<Path>>(&mut self, path: P) -> Result<usize, AgentConfigError> {
282 match AgentsConfigFile::load_from_file(path)? {
283 Some(config) => {
284 let count = config.agents.len();
285 for (name, agent_toml) in config.agents {
286 self.register(&name, AgentConfig::from(agent_toml));
287 }
288 self.fallback = config.fallback;
290 Ok(count)
291 }
292 None => Ok(0),
293 }
294 }
295
296 pub fn apply_unified_config(&mut self, unified: &crate::config::UnifiedConfig) -> usize {
304 let mut loaded = self.apply_ccs_aliases(unified);
305 loaded += self.apply_agent_overrides(unified);
306
307 if let Some(chain) = &unified.agent_chain {
308 self.fallback = chain.clone();
309 }
310
311 loaded
312 }
313
314 fn apply_ccs_aliases(&mut self, unified: &crate::config::UnifiedConfig) -> usize {
316 if unified.ccs_aliases.is_empty() {
317 return 0;
318 }
319
320 let loaded = unified.ccs_aliases.len();
321 let aliases = unified
322 .ccs_aliases
323 .iter()
324 .map(|(name, v)| (name.clone(), v.as_config()))
325 .collect::<HashMap<_, _>>();
326 self.set_ccs_aliases(&aliases, unified.ccs.clone());
327 loaded
328 }
329
330 fn apply_agent_overrides(&mut self, unified: &crate::config::UnifiedConfig) -> usize {
332 if unified.agents.is_empty() {
333 return 0;
334 }
335
336 let mut loaded = 0usize;
337 for (name, overrides) in &unified.agents {
338 if let Some(existing) = self.agents.get(name).cloned() {
339 let merged = Self::merge_agent_config(existing, overrides);
341 self.register(name, merged);
342 loaded += 1;
343 } else {
344 if let Some(config) = Self::create_new_agent_config(overrides) {
346 self.register(name, config);
347 loaded += 1;
348 }
349 }
350 }
351 loaded
352 }
353
354 fn create_new_agent_config(
356 overrides: &crate::config::unified::AgentConfigToml,
357 ) -> Option<AgentConfig> {
358 let cmd = overrides
359 .cmd
360 .as_deref()
361 .map(str::trim)
362 .filter(|s| !s.is_empty())?;
363
364 let json_parser = overrides
365 .json_parser
366 .as_deref()
367 .map(str::trim)
368 .filter(|s| !s.is_empty())
369 .unwrap_or("generic");
370
371 Some(AgentConfig {
372 cmd: cmd.to_string(),
373 output_flag: overrides.output_flag.clone().unwrap_or_default(),
374 yolo_flag: overrides.yolo_flag.clone().unwrap_or_default(),
375 verbose_flag: overrides.verbose_flag.clone().unwrap_or_default(),
376 can_commit: overrides.can_commit.unwrap_or(true),
377 json_parser: JsonParserType::parse(json_parser),
378 model_flag: overrides.model_flag.clone(),
379 print_flag: overrides.print_flag.clone().unwrap_or_default(),
380 streaming_flag: overrides.streaming_flag.clone().unwrap_or_else(|| {
381 if cmd.starts_with("claude") || cmd.starts_with("ccs") {
383 "--include-partial-messages".to_string()
384 } else {
385 String::new()
386 }
387 }),
388 env_vars: std::collections::HashMap::new(),
389 display_name: overrides
390 .display_name
391 .as_ref()
392 .filter(|s| !s.is_empty())
393 .cloned(),
394 })
395 }
396
397 fn merge_agent_config(
399 existing: AgentConfig,
400 overrides: &crate::config::unified::AgentConfigToml,
401 ) -> AgentConfig {
402 AgentConfig {
403 cmd: overrides
404 .cmd
405 .as_deref()
406 .map(str::trim)
407 .filter(|s| !s.is_empty())
408 .map(str::to_string)
409 .unwrap_or(existing.cmd),
410 output_flag: overrides
411 .output_flag
412 .clone()
413 .unwrap_or(existing.output_flag),
414 yolo_flag: overrides.yolo_flag.clone().unwrap_or(existing.yolo_flag),
415 verbose_flag: overrides
416 .verbose_flag
417 .clone()
418 .unwrap_or(existing.verbose_flag),
419 can_commit: overrides.can_commit.unwrap_or(existing.can_commit),
420 json_parser: overrides
421 .json_parser
422 .as_deref()
423 .map(str::trim)
424 .filter(|s| !s.is_empty())
425 .map_or(existing.json_parser, JsonParserType::parse),
426 model_flag: overrides.model_flag.clone().or(existing.model_flag),
427 print_flag: overrides.print_flag.clone().unwrap_or(existing.print_flag),
428 streaming_flag: overrides
429 .streaming_flag
430 .clone()
431 .unwrap_or(existing.streaming_flag),
432 env_vars: std::collections::HashMap::new(),
437 display_name: match &overrides.display_name {
440 Some(s) if s.is_empty() => None,
441 Some(s) => Some(s.clone()),
442 None => existing.display_name,
443 },
444 }
445 }
446
447 pub const fn fallback_config(&self) -> &FallbackConfig {
449 &self.fallback
450 }
451
452 pub fn retry_timer(&self) -> Arc<dyn RetryTimerProvider> {
454 Arc::clone(&self.retry_timer)
455 }
456
457 #[cfg(any(test, feature = "test-utils"))]
462 pub fn set_retry_timer(&mut self, timer: Arc<dyn RetryTimerProvider>) {
463 self.retry_timer = timer;
464 }
465
466 pub fn available_fallbacks(&self, role: AgentRole) -> Vec<&str> {
468 self.fallback
469 .get_fallbacks(role)
470 .iter()
471 .filter(|name| self.is_agent_available(name))
472 .filter(|name| {
474 self.resolve_config(name.as_str())
475 .is_some_and(|cfg| cfg.can_commit)
476 })
477 .map(std::string::String::as_str)
478 .collect()
479 }
480
481 pub fn validate_agent_chains(&self) -> Result<(), String> {
483 let has_developer = self.fallback.has_fallbacks(AgentRole::Developer);
484 let has_reviewer = self.fallback.has_fallbacks(AgentRole::Reviewer);
485
486 if !has_developer && !has_reviewer {
487 return Err("No agent chain configured.\n\
488 Please add an [agent_chain] section to ~/.config/ralph-workflow.toml.\n\
489 Run 'ralph --init-global' to create a default configuration."
490 .to_string());
491 }
492
493 if !has_developer {
494 return Err("No developer agent chain configured.\n\
495 Add 'developer = [\"your-agent\", ...]' to your [agent_chain] section.\n\
496 Use --list-agents to see available agents."
497 .to_string());
498 }
499
500 if !has_reviewer {
501 return Err("No reviewer agent chain configured.\n\
502 Add 'reviewer = [\"your-agent\", ...]' to your [agent_chain] section.\n\
503 Use --list-agents to see available agents."
504 .to_string());
505 }
506
507 for role in [AgentRole::Developer, AgentRole::Reviewer] {
509 let chain = self.fallback.get_fallbacks(role);
510 let has_capable = chain
511 .iter()
512 .any(|name| self.resolve_config(name).is_some_and(|cfg| cfg.can_commit));
513 if !has_capable {
514 return Err(format!(
515 "No workflow-capable agents found for {role}.\n\
516 All agents in the {role} chain have can_commit=false.\n\
517 Fix: set can_commit=true for at least one agent or update [agent_chain]."
518 ));
519 }
520 }
521
522 Ok(())
523 }
524
525 pub fn is_agent_available(&self, name: &str) -> bool {
527 if let Some(config) = self.resolve_config(name) {
528 let Ok(parts) = crate::common::split_command(&config.cmd) else {
529 return false;
530 };
531 let Some(base_cmd) = parts.first() else {
532 return false;
533 };
534
535 which::which(base_cmd).is_ok()
537 } else {
538 false
539 }
540 }
541
542 pub fn list_available(&self) -> Vec<&str> {
544 self.agents
545 .keys()
546 .filter(|name| self.is_agent_available(name))
547 .map(std::string::String::as_str)
548 .collect()
549 }
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555 use crate::agents::JsonParserType;
556 use std::sync::Mutex;
557
558 static ENV_MUTEX: Mutex<()> = Mutex::new(());
559
560 fn default_ccs() -> CcsConfig {
561 CcsConfig::default()
562 }
563
564 fn write_stub_executable(dir: &std::path::Path, name: &str) {
565 #[cfg(windows)]
566 {
567 let path = dir.join(format!("{}.cmd", name));
568 std::fs::write(&path, "@echo off\r\nexit /b 0\r\n").unwrap();
569 }
570 #[cfg(unix)]
571 {
572 use std::os::unix::fs::PermissionsExt;
573 let path = dir.join(name);
574 std::fs::write(&path, "#!/bin/sh\nexit 0\n").unwrap();
575 let mut perms = std::fs::metadata(&path).unwrap().permissions();
576 perms.set_mode(0o755);
577 std::fs::set_permissions(&path, perms).unwrap();
578 }
579 }
580
581 #[test]
582 fn test_registry_new() {
583 let registry = AgentRegistry::new().unwrap();
584 assert!(registry.resolve_config("claude").is_some());
586 assert!(registry.resolve_config("codex").is_some());
587 }
588
589 #[test]
590 fn test_registry_register() {
591 let mut registry = AgentRegistry::new().unwrap();
592 registry.register(
593 "testbot",
594 AgentConfig {
595 cmd: "testbot run".to_string(),
596 output_flag: "--json".to_string(),
597 yolo_flag: "--yes".to_string(),
598 verbose_flag: String::new(),
599 can_commit: true,
600 json_parser: JsonParserType::Generic,
601 model_flag: None,
602 print_flag: String::new(),
603 streaming_flag: String::new(),
604 env_vars: std::collections::HashMap::new(),
605 display_name: None,
606 },
607 );
608 assert!(registry.resolve_config("testbot").is_some());
610 }
611
612 #[test]
613 fn test_registry_display_name() {
614 let mut registry = AgentRegistry::new().unwrap();
615
616 registry.register(
618 "claude",
619 AgentConfig {
620 cmd: "claude -p".to_string(),
621 output_flag: "--output-format=stream-json".to_string(),
622 yolo_flag: "--dangerously-skip-permissions".to_string(),
623 verbose_flag: "--verbose".to_string(),
624 can_commit: true,
625 json_parser: JsonParserType::Claude,
626 model_flag: None,
627 print_flag: String::new(),
628 streaming_flag: "--include-partial-messages".to_string(),
629 env_vars: std::collections::HashMap::new(),
630 display_name: None,
631 },
632 );
633
634 registry.register(
636 "ccs/glm",
637 AgentConfig {
638 cmd: "ccs glm".to_string(),
639 output_flag: "--output-format=stream-json".to_string(),
640 yolo_flag: "--dangerously-skip-permissions".to_string(),
641 verbose_flag: "--verbose".to_string(),
642 can_commit: true,
643 json_parser: JsonParserType::Claude,
644 model_flag: None,
645 print_flag: "-p".to_string(),
646 streaming_flag: "--include-partial-messages".to_string(),
647 env_vars: std::collections::HashMap::new(),
648 display_name: Some("ccs-glm".to_string()),
649 },
650 );
651
652 assert_eq!(registry.display_name("claude"), "claude");
654 assert_eq!(registry.display_name("ccs/glm"), "ccs-glm");
655
656 assert_eq!(registry.display_name("unknown"), "unknown");
658 }
659
660 #[test]
661 fn test_registry_available_fallbacks() {
662 let _lock = ENV_MUTEX.lock().unwrap();
663 let original_path = std::env::var_os("PATH");
664 let dir = tempfile::tempdir().unwrap();
665
666 write_stub_executable(dir.path(), "claude");
667 write_stub_executable(dir.path(), "codex");
668
669 let mut new_paths = vec![dir.path().to_path_buf()];
670 if let Some(p) = &original_path {
671 new_paths.extend(std::env::split_paths(p));
672 }
673 let joined = std::env::join_paths(new_paths).unwrap();
674 std::env::set_var("PATH", &joined);
675
676 let mut registry = AgentRegistry::new().unwrap();
677 let toml_str = r#"
679 [agent_chain]
680 developer = ["claude", "nonexistent", "codex"]
681 "#;
682 let unified: crate::config::UnifiedConfig = toml::from_str(toml_str).unwrap();
683 registry.apply_unified_config(&unified);
684
685 let fallbacks = registry.available_fallbacks(AgentRole::Developer);
686 assert!(fallbacks.contains(&"claude"));
687 assert!(fallbacks.contains(&"codex"));
688 assert!(!fallbacks.contains(&"nonexistent"));
689
690 if let Some(p) = original_path {
691 std::env::set_var("PATH", p);
692 } else {
693 std::env::remove_var("PATH");
694 }
695 }
696
697 #[test]
698 fn test_validate_agent_chains() {
699 let mut registry = AgentRegistry::new().unwrap();
700
701 let toml_str = r#"
703 [agent_chain]
704 developer = ["claude"]
705 reviewer = ["codex"]
706 "#;
707 let unified: crate::config::UnifiedConfig = toml::from_str(toml_str).unwrap();
708 registry.apply_unified_config(&unified);
709 assert!(registry.validate_agent_chains().is_ok());
710 }
711
712 #[test]
713 fn test_ccs_aliases_registration() {
714 let mut registry = AgentRegistry::new().unwrap();
716
717 let mut aliases = HashMap::new();
718 aliases.insert(
719 "work".to_string(),
720 CcsAliasConfig {
721 cmd: "ccs work".to_string(),
722 ..CcsAliasConfig::default()
723 },
724 );
725 aliases.insert(
726 "personal".to_string(),
727 CcsAliasConfig {
728 cmd: "ccs personal".to_string(),
729 ..CcsAliasConfig::default()
730 },
731 );
732 aliases.insert(
733 "gemini".to_string(),
734 CcsAliasConfig {
735 cmd: "ccs gemini".to_string(),
736 ..CcsAliasConfig::default()
737 },
738 );
739
740 registry.set_ccs_aliases(&aliases, default_ccs());
741
742 assert!(registry.resolve_config("ccs/work").is_some());
744 assert!(registry.resolve_config("ccs/personal").is_some());
745 assert!(registry.resolve_config("ccs/gemini").is_some());
746
747 let config = registry.resolve_config("ccs/work").unwrap();
749 assert!(
751 config.cmd.ends_with("claude") || config.cmd == "ccs work",
752 "cmd should be 'ccs work' or a path ending with 'claude', got: {}",
753 config.cmd
754 );
755 assert!(config.can_commit);
756 assert_eq!(config.json_parser, JsonParserType::Claude);
757 }
758
759 #[test]
760 fn test_ccs_in_fallback_chain() {
761 let _lock = ENV_MUTEX.lock().unwrap();
762 let original_path = std::env::var_os("PATH");
763 let dir = tempfile::tempdir().unwrap();
764
765 write_stub_executable(dir.path(), "ccs");
767 write_stub_executable(dir.path(), "claude");
768
769 let mut new_paths = vec![dir.path().to_path_buf()];
770 if let Some(p) = &original_path {
771 new_paths.extend(std::env::split_paths(p));
772 }
773 let joined = std::env::join_paths(new_paths).unwrap();
774 std::env::set_var("PATH", &joined);
775
776 let mut registry = AgentRegistry::new().unwrap();
777
778 let mut aliases = HashMap::new();
780 aliases.insert(
781 "work".to_string(),
782 CcsAliasConfig {
783 cmd: "ccs work".to_string(),
784 ..CcsAliasConfig::default()
785 },
786 );
787 registry.set_ccs_aliases(&aliases, default_ccs());
788
789 let toml_str = r#"
791 [agent_chain]
792 developer = ["ccs/work", "claude"]
793 reviewer = ["claude"]
794 "#;
795 let unified: crate::config::UnifiedConfig = toml::from_str(toml_str).unwrap();
796 registry.apply_unified_config(&unified);
797
798 let fallbacks = registry.available_fallbacks(AgentRole::Developer);
800 assert!(fallbacks.contains(&"ccs/work"));
801 assert!(fallbacks.contains(&"claude"));
802
803 assert!(registry.validate_agent_chains().is_ok());
805
806 if let Some(p) = original_path {
807 std::env::set_var("PATH", p);
808 } else {
809 std::env::remove_var("PATH");
810 }
811 }
812
813 #[test]
814 fn test_ccs_aliases_with_registry_constructor() {
815 let mut registry = AgentRegistry::new().unwrap();
816 registry.set_ccs_aliases(&HashMap::new(), default_ccs());
817
818 assert!(registry.resolve_config("claude").is_some());
820 assert!(registry.resolve_config("codex").is_some());
821
822 let mut registry2 = AgentRegistry::new().unwrap();
824 let mut aliases = HashMap::new();
825 aliases.insert(
826 "work".to_string(),
827 CcsAliasConfig {
828 cmd: "ccs work".to_string(),
829 ..CcsAliasConfig::default()
830 },
831 );
832
833 registry2.set_ccs_aliases(&aliases, default_ccs());
834 assert!(registry2.resolve_config("ccs/work").is_some());
836 }
837
838 #[test]
839 fn test_list_includes_ccs_aliases() {
840 let mut registry = AgentRegistry::new().unwrap();
841
842 let mut aliases = HashMap::new();
843 aliases.insert(
844 "work".to_string(),
845 CcsAliasConfig {
846 cmd: "ccs work".to_string(),
847 ..CcsAliasConfig::default()
848 },
849 );
850 aliases.insert(
851 "personal".to_string(),
852 CcsAliasConfig {
853 cmd: "ccs personal".to_string(),
854 ..CcsAliasConfig::default()
855 },
856 );
857 registry.set_ccs_aliases(&aliases, default_ccs());
858
859 let all_agents = registry.list();
860
861 assert_eq!(
862 all_agents
863 .iter()
864 .filter(|(name, _)| name.starts_with("ccs/"))
865 .count(),
866 2
867 );
868 }
869
870 #[test]
871 fn test_resolve_fuzzy_exact_match() {
872 let registry = AgentRegistry::new().unwrap();
873 assert_eq!(registry.resolve_fuzzy("claude"), Some("claude".to_string()));
874 assert_eq!(registry.resolve_fuzzy("codex"), Some("codex".to_string()));
875 }
876
877 #[test]
878 fn test_resolve_fuzzy_ccs_unregistered() {
879 let registry = AgentRegistry::new().unwrap();
880 assert_eq!(
882 registry.resolve_fuzzy("ccs/random"),
883 Some("ccs/random".to_string())
884 );
885 assert_eq!(
886 registry.resolve_fuzzy("ccs/unregistered"),
887 Some("ccs/unregistered".to_string())
888 );
889 }
890
891 #[test]
892 fn test_resolve_fuzzy_typos() {
893 let registry = AgentRegistry::new().unwrap();
894 assert_eq!(registry.resolve_fuzzy("claud"), Some("claude".to_string()));
896 assert_eq!(registry.resolve_fuzzy("CLAUD"), Some("claude".to_string()));
897 }
898
899 #[test]
900 fn test_resolve_fuzzy_codex_variations() {
901 let registry = AgentRegistry::new().unwrap();
902 assert_eq!(registry.resolve_fuzzy("codeex"), Some("codex".to_string()));
904 assert_eq!(registry.resolve_fuzzy("code-x"), Some("codex".to_string()));
905 assert_eq!(registry.resolve_fuzzy("CODEEX"), Some("codex".to_string()));
906 }
907
908 #[test]
909 fn test_resolve_fuzzy_cursor_variations() {
910 let registry = AgentRegistry::new().unwrap();
911 assert_eq!(registry.resolve_fuzzy("crusor"), Some("cursor".to_string()));
913 assert_eq!(registry.resolve_fuzzy("CRUSOR"), Some("cursor".to_string()));
914 }
915
916 #[test]
917 fn test_resolve_fuzzy_gemini_variations() {
918 let registry = AgentRegistry::new().unwrap();
919 assert_eq!(registry.resolve_fuzzy("gemeni"), Some("gemini".to_string()));
921 assert_eq!(registry.resolve_fuzzy("gemni"), Some("gemini".to_string()));
922 assert_eq!(registry.resolve_fuzzy("GEMENI"), Some("gemini".to_string()));
923 }
924
925 #[test]
926 fn test_resolve_fuzzy_qwen_variations() {
927 let registry = AgentRegistry::new().unwrap();
928 assert_eq!(registry.resolve_fuzzy("quen"), Some("qwen".to_string()));
930 assert_eq!(registry.resolve_fuzzy("quwen"), Some("qwen".to_string()));
931 assert_eq!(registry.resolve_fuzzy("QUEN"), Some("qwen".to_string()));
932 }
933
934 #[test]
935 fn test_resolve_fuzzy_aider_variations() {
936 let registry = AgentRegistry::new().unwrap();
937 assert_eq!(registry.resolve_fuzzy("ader"), Some("aider".to_string()));
939 assert_eq!(registry.resolve_fuzzy("ADER"), Some("aider".to_string()));
940 }
941
942 #[test]
943 fn test_resolve_fuzzy_vibe_variations() {
944 let registry = AgentRegistry::new().unwrap();
945 assert_eq!(registry.resolve_fuzzy("vib"), Some("vibe".to_string()));
947 assert_eq!(registry.resolve_fuzzy("VIB"), Some("vibe".to_string()));
948 }
949
950 #[test]
951 fn test_resolve_fuzzy_cline_variations() {
952 let registry = AgentRegistry::new().unwrap();
953 assert_eq!(registry.resolve_fuzzy("kline"), Some("cline".to_string()));
955 assert_eq!(registry.resolve_fuzzy("KLINE"), Some("cline".to_string()));
956 }
957
958 #[test]
959 fn test_resolve_fuzzy_ccs_dash_to_slash() {
960 let registry = AgentRegistry::new().unwrap();
961 assert_eq!(
963 registry.resolve_fuzzy("ccs-random"),
964 Some("ccs/random".to_string())
965 );
966 assert_eq!(
967 registry.resolve_fuzzy("ccs-test"),
968 Some("ccs/test".to_string())
969 );
970 }
971
972 #[test]
973 fn test_resolve_fuzzy_underscore_replacement() {
974 let result = AgentRegistry::get_fuzzy_alternatives("my_agent");
977 assert!(result.contains(&"my_agent".to_string()));
978 assert!(result.contains(&"my-agent".to_string()));
979 assert!(result.contains(&"my/agent".to_string()));
980 }
981
982 #[test]
983 fn test_resolve_fuzzy_unknown() {
984 let registry = AgentRegistry::new().unwrap();
985 assert_eq!(registry.resolve_fuzzy("totally-unknown"), None);
987 }
988
989 #[test]
990 fn test_apply_unified_config_does_not_inherit_env_vars() {
991 let mut registry = AgentRegistry::new().unwrap();
995
996 registry.register(
999 "claude",
1000 AgentConfig {
1001 cmd: "claude -p".to_string(),
1002 output_flag: "--output-format=stream-json".to_string(),
1003 yolo_flag: "--dangerously-skip-permissions".to_string(),
1004 verbose_flag: "--verbose".to_string(),
1005 can_commit: true,
1006 json_parser: JsonParserType::Claude,
1007 model_flag: None,
1008 print_flag: String::new(),
1009 streaming_flag: "--include-partial-messages".to_string(),
1010 env_vars: {
1012 let mut vars = std::collections::HashMap::new();
1013 vars.insert(
1014 "ANTHROPIC_BASE_URL".to_string(),
1015 "https://api.z.ai/api/anthropic".to_string(),
1016 );
1017 vars.insert(
1018 "ANTHROPIC_AUTH_TOKEN".to_string(),
1019 "test-token-glm".to_string(),
1020 );
1021 vars.insert("ANTHROPIC_MODEL".to_string(), "glm-4.7".to_string());
1022 vars
1023 },
1024 display_name: None,
1025 },
1026 );
1027
1028 let claude_config = registry.resolve_config("claude").unwrap();
1030 assert_eq!(claude_config.env_vars.len(), 3);
1031 assert_eq!(
1032 claude_config.env_vars.get("ANTHROPIC_BASE_URL"),
1033 Some(&"https://api.z.ai/api/anthropic".to_string())
1034 );
1035
1036 let toml_str = r#"
1042 [general]
1043 verbosity = 2
1044 interactive = true
1045 isolation_mode = true
1046
1047 [agents.claude]
1048 cmd = "claude -p"
1049 display_name = "My Custom Claude"
1050 "#;
1051 let unified: crate::config::UnifiedConfig = toml::from_str(toml_str).unwrap();
1052
1053 registry.apply_unified_config(&unified);
1055
1056 let claude_config_after = registry.resolve_config("claude").unwrap();
1058 assert_eq!(
1059 claude_config_after.env_vars.len(),
1060 0,
1061 "env_vars should NOT be inherited from the existing agent when unified config is applied"
1062 );
1063 assert_eq!(
1064 claude_config_after.display_name,
1065 Some("My Custom Claude".to_string()),
1066 "display_name should be updated from the unified config"
1067 );
1068 }
1069
1070 #[test]
1071 fn test_resolve_config_does_not_share_env_vars_between_agents() {
1072 let mut registry = AgentRegistry::new().unwrap();
1081
1082 registry.register(
1084 "ccs/glm",
1085 AgentConfig {
1086 cmd: "ccs glm".to_string(),
1087 output_flag: "--output-format=stream-json".to_string(),
1088 yolo_flag: "--dangerously-skip-permissions".to_string(),
1089 verbose_flag: "--verbose".to_string(),
1090 can_commit: true,
1091 json_parser: JsonParserType::Claude,
1092 model_flag: None,
1093 print_flag: "-p".to_string(),
1094 streaming_flag: "--include-partial-messages".to_string(),
1095 env_vars: {
1096 let mut vars = std::collections::HashMap::new();
1097 vars.insert(
1098 "ANTHROPIC_BASE_URL".to_string(),
1099 "https://api.z.ai/api/anthropic".to_string(),
1100 );
1101 vars.insert(
1102 "ANTHROPIC_AUTH_TOKEN".to_string(),
1103 "test-token-glm".to_string(),
1104 );
1105 vars.insert("ANTHROPIC_MODEL".to_string(), "glm-4.7".to_string());
1106 vars
1107 },
1108 display_name: Some("ccs-glm".to_string()),
1109 },
1110 );
1111
1112 registry.register(
1114 "claude",
1115 AgentConfig {
1116 cmd: "claude -p".to_string(),
1117 output_flag: "--output-format=stream-json".to_string(),
1118 yolo_flag: "--dangerously-skip-permissions".to_string(),
1119 verbose_flag: "--verbose".to_string(),
1120 can_commit: true,
1121 json_parser: JsonParserType::Claude,
1122 model_flag: None,
1123 print_flag: String::new(),
1124 streaming_flag: "--include-partial-messages".to_string(),
1125 env_vars: std::collections::HashMap::new(),
1126 display_name: None,
1127 },
1128 );
1129
1130 let glm_config = registry.resolve_config("ccs/glm").unwrap();
1132 assert_eq!(glm_config.env_vars.len(), 3);
1133 assert_eq!(
1134 glm_config.env_vars.get("ANTHROPIC_BASE_URL"),
1135 Some(&"https://api.z.ai/api/anthropic".to_string())
1136 );
1137
1138 let claude_config = registry.resolve_config("claude").unwrap();
1140 assert_eq!(
1141 claude_config.env_vars.len(),
1142 0,
1143 "claude agent should have empty env_vars"
1144 );
1145
1146 let glm_config2 = registry.resolve_config("ccs/glm").unwrap();
1148 assert_eq!(glm_config2.env_vars.len(), 3);
1149
1150 drop(glm_config);
1153
1154 let claude_config2 = registry.resolve_config("claude").unwrap();
1156 assert_eq!(
1157 claude_config2.env_vars.len(),
1158 0,
1159 "claude agent env_vars should remain independent"
1160 );
1161 }
1162}