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_from_logfile_name(&self, logfile_name: &str) -> Option<String> {
190 if self.agents.contains_key(logfile_name) {
192 return Some(logfile_name.to_string());
193 }
194
195 for name in self.agents.keys() {
197 let sanitized = name.replace('/', "-");
198 if sanitized == logfile_name {
199 return Some(name.clone());
200 }
201 }
202
203 if let Some(alias) = logfile_name.strip_prefix("ccs-") {
206 let registry_name = format!("ccs/{}", alias);
207 return Some(registry_name);
209 }
210
211 if let Some(rest) = logfile_name.strip_prefix("opencode-") {
218 if let Some(first_hyphen) = rest.find('-') {
219 let provider = &rest[..first_hyphen];
220 let model = &rest[first_hyphen + 1..];
221 let registry_name = format!("opencode/{}/{}", provider, model);
222 return Some(registry_name);
223 }
224 }
225
226 None
228 }
229
230 pub fn resolve_fuzzy(&self, name: &str) -> Option<String> {
240 if self.agents.contains_key(name) {
242 return Some(name.to_string());
243 }
244
245 if name.starts_with("ccs/") {
247 return Some(name.to_string());
248 }
249
250 if name.starts_with("opencode/") {
252 let parts: Vec<&str> = name.split('/').collect();
254 if parts.len() == 3 && parts[0] == "opencode" {
255 return Some(name.to_string());
256 }
257 }
258
259 let normalized = name.to_lowercase();
261 let alternatives = Self::get_fuzzy_alternatives(&normalized);
262
263 for alt in alternatives {
264 if alt.starts_with("ccs/") {
266 return Some(alt);
267 }
268 if alt.starts_with("opencode/") {
270 let parts: Vec<&str> = alt.split('/').collect();
271 if parts.len() == 3 && parts[0] == "opencode" {
272 return Some(alt);
273 }
274 }
275 if self.agents.contains_key(&alt) {
277 return Some(alt);
278 }
279 }
280
281 None
282 }
283
284 pub(crate) fn get_fuzzy_alternatives(name: &str) -> Vec<String> {
288 let mut alternatives = Vec::new();
289
290 alternatives.push(name.to_string());
292
293 match name {
295 n if n.starts_with("ccs-") => {
297 alternatives.push(name.replace("ccs-", "ccs/"));
298 }
299 n if n.contains('_') => {
300 alternatives.push(name.replace('_', "-"));
301 alternatives.push(name.replace('_', "/"));
302 }
303
304 "claud" | "cloud" => alternatives.push("claude".to_string()),
306
307 "codeex" | "code-x" => alternatives.push("codex".to_string()),
309
310 "crusor" => alternatives.push("cursor".to_string()),
312
313 "opencode" | "open-code" => alternatives.push("opencode".to_string()),
315
316 "gemeni" | "gemni" => alternatives.push("gemini".to_string()),
318
319 "quen" | "quwen" => alternatives.push("qwen".to_string()),
321
322 "ader" => alternatives.push("aider".to_string()),
324
325 "vib" => alternatives.push("vibe".to_string()),
327
328 "kline" => alternatives.push("cline".to_string()),
330
331 _ => {}
332 }
333
334 alternatives
335 }
336
337 pub fn list(&self) -> Vec<(&str, &AgentConfig)> {
339 self.agents.iter().map(|(k, v)| (k.as_str(), v)).collect()
340 }
341
342 pub fn developer_cmd(&self, agent_name: &str) -> Option<String> {
344 self.resolve_config(agent_name)
345 .map(|c| c.build_cmd(true, true, true))
346 }
347
348 pub fn reviewer_cmd(&self, agent_name: &str) -> Option<String> {
350 self.resolve_config(agent_name)
351 .map(|c| c.build_cmd(true, true, false))
352 }
353
354 pub fn load_from_file<P: AsRef<Path>>(&mut self, path: P) -> Result<usize, AgentConfigError> {
356 match AgentsConfigFile::load_from_file(path)? {
357 Some(config) => {
358 let count = config.agents.len();
359 for (name, agent_toml) in config.agents {
360 self.register(&name, AgentConfig::from(agent_toml));
361 }
362 self.fallback = config.fallback;
364 Ok(count)
365 }
366 None => Ok(0),
367 }
368 }
369
370 pub fn apply_unified_config(&mut self, unified: &crate::config::UnifiedConfig) -> usize {
378 let mut loaded = self.apply_ccs_aliases(unified);
379 loaded += self.apply_agent_overrides(unified);
380
381 if let Some(chain) = &unified.agent_chain {
382 self.fallback = chain.clone();
383 }
384
385 loaded
386 }
387
388 fn apply_ccs_aliases(&mut self, unified: &crate::config::UnifiedConfig) -> usize {
390 if unified.ccs_aliases.is_empty() {
391 return 0;
392 }
393
394 let loaded = unified.ccs_aliases.len();
395 let aliases = unified
396 .ccs_aliases
397 .iter()
398 .map(|(name, v)| (name.clone(), v.as_config()))
399 .collect::<HashMap<_, _>>();
400 self.set_ccs_aliases(&aliases, unified.ccs.clone());
401 loaded
402 }
403
404 fn apply_agent_overrides(&mut self, unified: &crate::config::UnifiedConfig) -> usize {
406 if unified.agents.is_empty() {
407 return 0;
408 }
409
410 let mut loaded = 0usize;
411 for (name, overrides) in &unified.agents {
412 if let Some(existing) = self.agents.get(name).cloned() {
413 let merged = Self::merge_agent_config(existing, overrides);
415 self.register(name, merged);
416 loaded += 1;
417 } else {
418 if let Some(config) = Self::create_new_agent_config(overrides) {
420 self.register(name, config);
421 loaded += 1;
422 }
423 }
424 }
425 loaded
426 }
427
428 fn create_new_agent_config(
430 overrides: &crate::config::unified::AgentConfigToml,
431 ) -> Option<AgentConfig> {
432 let cmd = overrides
433 .cmd
434 .as_deref()
435 .map(str::trim)
436 .filter(|s| !s.is_empty())?;
437
438 let json_parser = overrides
439 .json_parser
440 .as_deref()
441 .map(str::trim)
442 .filter(|s| !s.is_empty())
443 .unwrap_or("generic");
444
445 Some(AgentConfig {
446 cmd: cmd.to_string(),
447 output_flag: overrides.output_flag.clone().unwrap_or_default(),
448 yolo_flag: overrides.yolo_flag.clone().unwrap_or_default(),
449 verbose_flag: overrides.verbose_flag.clone().unwrap_or_default(),
450 can_commit: overrides.can_commit.unwrap_or(true),
451 json_parser: JsonParserType::parse(json_parser),
452 model_flag: overrides.model_flag.clone(),
453 print_flag: overrides.print_flag.clone().unwrap_or_default(),
454 streaming_flag: overrides.streaming_flag.clone().unwrap_or_else(|| {
455 if cmd.starts_with("claude") || cmd.starts_with("ccs") {
457 "--include-partial-messages".to_string()
458 } else {
459 String::new()
460 }
461 }),
462 session_flag: overrides.session_flag.clone().unwrap_or_else(|| {
463 if cmd.starts_with("claude") || cmd.starts_with("ccs") {
469 "--resume {}".to_string()
470 } else if cmd.starts_with("opencode") {
471 "-s {}".to_string()
472 } else {
473 String::new()
474 }
475 }),
476 env_vars: std::collections::HashMap::new(),
477 display_name: overrides
478 .display_name
479 .as_ref()
480 .filter(|s| !s.is_empty())
481 .cloned(),
482 })
483 }
484
485 fn merge_agent_config(
487 existing: AgentConfig,
488 overrides: &crate::config::unified::AgentConfigToml,
489 ) -> AgentConfig {
490 AgentConfig {
491 cmd: overrides
492 .cmd
493 .as_deref()
494 .map(str::trim)
495 .filter(|s| !s.is_empty())
496 .map(str::to_string)
497 .unwrap_or(existing.cmd),
498 output_flag: overrides
499 .output_flag
500 .clone()
501 .unwrap_or(existing.output_flag),
502 yolo_flag: overrides.yolo_flag.clone().unwrap_or(existing.yolo_flag),
503 verbose_flag: overrides
504 .verbose_flag
505 .clone()
506 .unwrap_or(existing.verbose_flag),
507 can_commit: overrides.can_commit.unwrap_or(existing.can_commit),
508 json_parser: overrides
509 .json_parser
510 .as_deref()
511 .map(str::trim)
512 .filter(|s| !s.is_empty())
513 .map_or(existing.json_parser, JsonParserType::parse),
514 model_flag: overrides.model_flag.clone().or(existing.model_flag),
515 print_flag: overrides.print_flag.clone().unwrap_or(existing.print_flag),
516 streaming_flag: overrides
517 .streaming_flag
518 .clone()
519 .unwrap_or(existing.streaming_flag),
520 session_flag: overrides
521 .session_flag
522 .clone()
523 .unwrap_or(existing.session_flag),
524 env_vars: std::collections::HashMap::new(),
529 display_name: match &overrides.display_name {
532 Some(s) if s.is_empty() => None,
533 Some(s) => Some(s.clone()),
534 None => existing.display_name,
535 },
536 }
537 }
538
539 pub const fn fallback_config(&self) -> &FallbackConfig {
541 &self.fallback
542 }
543
544 pub fn retry_timer(&self) -> Arc<dyn RetryTimerProvider> {
546 Arc::clone(&self.retry_timer)
547 }
548
549 #[cfg(any(test, feature = "test-utils"))]
554 pub fn set_retry_timer(&mut self, timer: Arc<dyn RetryTimerProvider>) {
555 self.retry_timer = timer;
556 }
557
558 pub fn available_fallbacks(&self, role: AgentRole) -> Vec<&str> {
560 self.fallback
561 .get_fallbacks(role)
562 .iter()
563 .filter(|name| self.is_agent_available(name))
564 .filter(|name| {
566 self.resolve_config(name.as_str())
567 .is_some_and(|cfg| cfg.can_commit)
568 })
569 .map(std::string::String::as_str)
570 .collect()
571 }
572
573 pub fn validate_agent_chains(&self) -> Result<(), String> {
575 let has_developer = self.fallback.has_fallbacks(AgentRole::Developer);
576 let has_reviewer = self.fallback.has_fallbacks(AgentRole::Reviewer);
577
578 if !has_developer && !has_reviewer {
579 return Err("No agent chain configured.\n\
580 Please add an [agent_chain] section to ~/.config/ralph-workflow.toml.\n\
581 Run 'ralph --init-global' to create a default configuration."
582 .to_string());
583 }
584
585 if !has_developer {
586 return Err("No developer agent chain configured.\n\
587 Add 'developer = [\"your-agent\", ...]' to your [agent_chain] section.\n\
588 Use --list-agents to see available agents."
589 .to_string());
590 }
591
592 if !has_reviewer {
593 return Err("No reviewer agent chain configured.\n\
594 Add 'reviewer = [\"your-agent\", ...]' to your [agent_chain] section.\n\
595 Use --list-agents to see available agents."
596 .to_string());
597 }
598
599 for role in [AgentRole::Developer, AgentRole::Reviewer] {
601 let chain = self.fallback.get_fallbacks(role);
602 let has_capable = chain
603 .iter()
604 .any(|name| self.resolve_config(name).is_some_and(|cfg| cfg.can_commit));
605 if !has_capable {
606 return Err(format!(
607 "No workflow-capable agents found for {role}.\n\
608 All agents in the {role} chain have can_commit=false.\n\
609 Fix: set can_commit=true for at least one agent or update [agent_chain]."
610 ));
611 }
612 }
613
614 Ok(())
615 }
616
617 pub fn is_agent_available(&self, name: &str) -> bool {
619 if let Some(config) = self.resolve_config(name) {
620 let Ok(parts) = crate::common::split_command(&config.cmd) else {
621 return false;
622 };
623 let Some(base_cmd) = parts.first() else {
624 return false;
625 };
626
627 which::which(base_cmd).is_ok()
629 } else {
630 false
631 }
632 }
633
634 pub fn list_available(&self) -> Vec<&str> {
636 self.agents
637 .keys()
638 .filter(|name| self.is_agent_available(name))
639 .map(std::string::String::as_str)
640 .collect()
641 }
642}
643
644#[cfg(test)]
645mod tests {
646 use super::*;
647 use crate::agents::JsonParserType;
648 use std::sync::Mutex;
649
650 static ENV_MUTEX: Mutex<()> = Mutex::new(());
651
652 fn default_ccs() -> CcsConfig {
653 CcsConfig::default()
654 }
655
656 fn write_stub_executable(dir: &std::path::Path, name: &str) {
657 #[cfg(windows)]
658 {
659 let path = dir.join(format!("{}.cmd", name));
660 std::fs::write(&path, "@echo off\r\nexit /b 0\r\n").unwrap();
661 }
662 #[cfg(unix)]
663 {
664 use std::os::unix::fs::PermissionsExt;
665 let path = dir.join(name);
666 std::fs::write(&path, "#!/bin/sh\nexit 0\n").unwrap();
667 let mut perms = std::fs::metadata(&path).unwrap().permissions();
668 perms.set_mode(0o755);
669 std::fs::set_permissions(&path, perms).unwrap();
670 }
671 }
672
673 #[test]
674 fn test_registry_new() {
675 let registry = AgentRegistry::new().unwrap();
676 assert!(registry.resolve_config("claude").is_some());
678 assert!(registry.resolve_config("codex").is_some());
679 }
680
681 #[test]
682 fn test_registry_register() {
683 let mut registry = AgentRegistry::new().unwrap();
684 registry.register(
685 "testbot",
686 AgentConfig {
687 cmd: "testbot run".to_string(),
688 output_flag: "--json".to_string(),
689 yolo_flag: "--yes".to_string(),
690 verbose_flag: String::new(),
691 can_commit: true,
692 json_parser: JsonParserType::Generic,
693 model_flag: None,
694 print_flag: String::new(),
695 streaming_flag: String::new(),
696 session_flag: String::new(),
697 env_vars: std::collections::HashMap::new(),
698 display_name: None,
699 },
700 );
701 assert!(registry.resolve_config("testbot").is_some());
703 }
704
705 #[test]
706 fn test_registry_display_name() {
707 let mut registry = AgentRegistry::new().unwrap();
708
709 registry.register(
711 "claude",
712 AgentConfig {
713 cmd: "claude -p".to_string(),
714 output_flag: "--output-format=stream-json".to_string(),
715 yolo_flag: "--dangerously-skip-permissions".to_string(),
716 verbose_flag: "--verbose".to_string(),
717 can_commit: true,
718 json_parser: JsonParserType::Claude,
719 model_flag: None,
720 print_flag: String::new(),
721 streaming_flag: "--include-partial-messages".to_string(),
722 session_flag: "--resume {}".to_string(),
723 env_vars: std::collections::HashMap::new(),
724 display_name: None,
725 },
726 );
727
728 registry.register(
730 "claude",
731 AgentConfig {
732 cmd: "claude -p".to_string(),
733 output_flag: "--output-format=stream-json".to_string(),
734 yolo_flag: "--dangerously-skip-permissions".to_string(),
735 verbose_flag: "--verbose".to_string(),
736 can_commit: true,
737 json_parser: JsonParserType::Claude,
738 model_flag: None,
739 print_flag: String::new(),
740 streaming_flag: "--include-partial-messages".to_string(),
741 session_flag: "--resume {}".to_string(),
742 env_vars: std::collections::HashMap::new(),
743 display_name: None,
744 },
745 );
746
747 assert_eq!(registry.display_name("claude"), "claude");
749 assert_eq!(registry.display_name("ccs/glm"), "ccs-glm");
750
751 assert_eq!(registry.display_name("unknown"), "unknown");
753 }
754
755 #[test]
756 fn test_resolve_from_logfile_name() {
757 let mut registry = AgentRegistry::new().unwrap();
758
759 registry.register(
761 "ccs/glm",
762 AgentConfig {
763 cmd: "ccs glm".to_string(),
764 output_flag: "--output-format=stream-json".to_string(),
765 yolo_flag: "--dangerously-skip-permissions".to_string(),
766 verbose_flag: "--verbose".to_string(),
767 can_commit: true,
768 json_parser: JsonParserType::Claude,
769 model_flag: None,
770 print_flag: "-p".to_string(),
771 streaming_flag: "--include-partial-messages".to_string(),
772 session_flag: "--resume {}".to_string(),
773 env_vars: std::collections::HashMap::new(),
774 display_name: Some("ccs-glm".to_string()),
775 },
776 );
777
778 registry.register(
780 "claude",
781 AgentConfig {
782 cmd: "claude -p".to_string(),
783 output_flag: "--output-format=stream-json".to_string(),
784 yolo_flag: "--dangerously-skip-permissions".to_string(),
785 verbose_flag: "--verbose".to_string(),
786 can_commit: true,
787 json_parser: JsonParserType::Claude,
788 model_flag: None,
789 print_flag: String::new(),
790 streaming_flag: "--include-partial-messages".to_string(),
791 session_flag: "--resume {}".to_string(),
792 env_vars: std::collections::HashMap::new(),
793 display_name: None,
794 },
795 );
796
797 registry.register(
799 "opencode/anthropic/claude-sonnet-4",
800 AgentConfig {
801 cmd: "opencode run".to_string(),
802 output_flag: "--format json".to_string(),
803 yolo_flag: String::new(),
804 verbose_flag: "--log-level DEBUG".to_string(),
805 can_commit: true,
806 json_parser: JsonParserType::OpenCode,
807 model_flag: Some("-p anthropic -m claude-sonnet-4".to_string()),
808 print_flag: String::new(),
809 streaming_flag: String::new(),
810 session_flag: "-s {}".to_string(),
811 env_vars: std::collections::HashMap::new(),
812 display_name: Some("OpenCode (anthropic)".to_string()),
813 },
814 );
815
816 assert_eq!(
818 registry.resolve_from_logfile_name("claude"),
819 Some("claude".to_string())
820 );
821
822 assert_eq!(
824 registry.resolve_from_logfile_name("ccs-glm"),
825 Some("ccs/glm".to_string())
826 );
827
828 assert_eq!(
830 registry.resolve_from_logfile_name("opencode-anthropic-claude-sonnet-4"),
831 Some("opencode/anthropic/claude-sonnet-4".to_string())
832 );
833
834 assert_eq!(
836 registry.resolve_from_logfile_name("ccs-zai"),
837 Some("ccs/zai".to_string())
838 );
839
840 assert_eq!(
842 registry.resolve_from_logfile_name("opencode-google-gemini-pro"),
843 Some("opencode/google/gemini-pro".to_string())
844 );
845
846 assert_eq!(registry.resolve_from_logfile_name("unknown-agent"), None);
848 }
849
850 #[test]
851 fn test_registry_available_fallbacks() {
852 let _lock = ENV_MUTEX.lock().unwrap();
853 let original_path = std::env::var_os("PATH");
854 let dir = tempfile::tempdir().unwrap();
855
856 write_stub_executable(dir.path(), "claude");
857 write_stub_executable(dir.path(), "codex");
858
859 let mut new_paths = vec![dir.path().to_path_buf()];
860 if let Some(p) = &original_path {
861 new_paths.extend(std::env::split_paths(p));
862 }
863 let joined = std::env::join_paths(new_paths).unwrap();
864 std::env::set_var("PATH", &joined);
865
866 let mut registry = AgentRegistry::new().unwrap();
867 let toml_str = r#"
869 [agent_chain]
870 developer = ["claude", "nonexistent", "codex"]
871 "#;
872 let unified: crate::config::UnifiedConfig = toml::from_str(toml_str).unwrap();
873 registry.apply_unified_config(&unified);
874
875 let fallbacks = registry.available_fallbacks(AgentRole::Developer);
876 assert!(fallbacks.contains(&"claude"));
877 assert!(fallbacks.contains(&"codex"));
878 assert!(!fallbacks.contains(&"nonexistent"));
879
880 if let Some(p) = original_path {
881 std::env::set_var("PATH", p);
882 } else {
883 std::env::remove_var("PATH");
884 }
885 }
886
887 #[test]
888 fn test_validate_agent_chains() {
889 let mut registry = AgentRegistry::new().unwrap();
890
891 let toml_str = r#"
893 [agent_chain]
894 developer = ["claude"]
895 reviewer = ["codex"]
896 "#;
897 let unified: crate::config::UnifiedConfig = toml::from_str(toml_str).unwrap();
898 registry.apply_unified_config(&unified);
899 assert!(registry.validate_agent_chains().is_ok());
900 }
901
902 #[test]
903 fn test_ccs_aliases_registration() {
904 let mut registry = AgentRegistry::new().unwrap();
906
907 let mut aliases = HashMap::new();
908 aliases.insert(
909 "work".to_string(),
910 CcsAliasConfig {
911 cmd: "ccs work".to_string(),
912 ..CcsAliasConfig::default()
913 },
914 );
915 aliases.insert(
916 "personal".to_string(),
917 CcsAliasConfig {
918 cmd: "ccs personal".to_string(),
919 ..CcsAliasConfig::default()
920 },
921 );
922 aliases.insert(
923 "gemini".to_string(),
924 CcsAliasConfig {
925 cmd: "ccs gemini".to_string(),
926 ..CcsAliasConfig::default()
927 },
928 );
929
930 registry.set_ccs_aliases(&aliases, default_ccs());
931
932 assert!(registry.resolve_config("ccs/work").is_some());
934 assert!(registry.resolve_config("ccs/personal").is_some());
935 assert!(registry.resolve_config("ccs/gemini").is_some());
936
937 let config = registry.resolve_config("ccs/work").unwrap();
939 assert!(
941 config.cmd.ends_with("claude") || config.cmd == "ccs work",
942 "cmd should be 'ccs work' or a path ending with 'claude', got: {}",
943 config.cmd
944 );
945 assert!(config.can_commit);
946 assert_eq!(config.json_parser, JsonParserType::Claude);
947 }
948
949 #[test]
950 fn test_ccs_in_fallback_chain() {
951 let _lock = ENV_MUTEX.lock().unwrap();
952 let original_path = std::env::var_os("PATH");
953 let dir = tempfile::tempdir().unwrap();
954
955 write_stub_executable(dir.path(), "ccs");
957 write_stub_executable(dir.path(), "claude");
958
959 let mut new_paths = vec![dir.path().to_path_buf()];
960 if let Some(p) = &original_path {
961 new_paths.extend(std::env::split_paths(p));
962 }
963 let joined = std::env::join_paths(new_paths).unwrap();
964 std::env::set_var("PATH", &joined);
965
966 let mut registry = AgentRegistry::new().unwrap();
967
968 let mut aliases = HashMap::new();
970 aliases.insert(
971 "work".to_string(),
972 CcsAliasConfig {
973 cmd: "ccs work".to_string(),
974 ..CcsAliasConfig::default()
975 },
976 );
977 registry.set_ccs_aliases(&aliases, default_ccs());
978
979 let toml_str = r#"
981 [agent_chain]
982 developer = ["ccs/work", "claude"]
983 reviewer = ["claude"]
984 "#;
985 let unified: crate::config::UnifiedConfig = toml::from_str(toml_str).unwrap();
986 registry.apply_unified_config(&unified);
987
988 let fallbacks = registry.available_fallbacks(AgentRole::Developer);
990 assert!(fallbacks.contains(&"ccs/work"));
991 assert!(fallbacks.contains(&"claude"));
992
993 assert!(registry.validate_agent_chains().is_ok());
995
996 if let Some(p) = original_path {
997 std::env::set_var("PATH", p);
998 } else {
999 std::env::remove_var("PATH");
1000 }
1001 }
1002
1003 #[test]
1004 fn test_ccs_aliases_with_registry_constructor() {
1005 let mut registry = AgentRegistry::new().unwrap();
1006 registry.set_ccs_aliases(&HashMap::new(), default_ccs());
1007
1008 assert!(registry.resolve_config("claude").is_some());
1010 assert!(registry.resolve_config("codex").is_some());
1011
1012 let mut registry2 = AgentRegistry::new().unwrap();
1014 let mut aliases = HashMap::new();
1015 aliases.insert(
1016 "work".to_string(),
1017 CcsAliasConfig {
1018 cmd: "ccs work".to_string(),
1019 ..CcsAliasConfig::default()
1020 },
1021 );
1022
1023 registry2.set_ccs_aliases(&aliases, default_ccs());
1024 assert!(registry2.resolve_config("ccs/work").is_some());
1026 }
1027
1028 #[test]
1029 fn test_list_includes_ccs_aliases() {
1030 let mut registry = AgentRegistry::new().unwrap();
1031
1032 let mut aliases = HashMap::new();
1033 aliases.insert(
1034 "work".to_string(),
1035 CcsAliasConfig {
1036 cmd: "ccs work".to_string(),
1037 ..CcsAliasConfig::default()
1038 },
1039 );
1040 aliases.insert(
1041 "personal".to_string(),
1042 CcsAliasConfig {
1043 cmd: "ccs personal".to_string(),
1044 ..CcsAliasConfig::default()
1045 },
1046 );
1047 registry.set_ccs_aliases(&aliases, default_ccs());
1048
1049 let all_agents = registry.list();
1050
1051 assert_eq!(
1052 all_agents
1053 .iter()
1054 .filter(|(name, _)| name.starts_with("ccs/"))
1055 .count(),
1056 2
1057 );
1058 }
1059
1060 #[test]
1061 fn test_resolve_fuzzy_exact_match() {
1062 let registry = AgentRegistry::new().unwrap();
1063 assert_eq!(registry.resolve_fuzzy("claude"), Some("claude".to_string()));
1064 assert_eq!(registry.resolve_fuzzy("codex"), Some("codex".to_string()));
1065 }
1066
1067 #[test]
1068 fn test_resolve_fuzzy_ccs_unregistered() {
1069 let registry = AgentRegistry::new().unwrap();
1070 assert_eq!(
1072 registry.resolve_fuzzy("ccs/random"),
1073 Some("ccs/random".to_string())
1074 );
1075 assert_eq!(
1076 registry.resolve_fuzzy("ccs/unregistered"),
1077 Some("ccs/unregistered".to_string())
1078 );
1079 }
1080
1081 #[test]
1082 fn test_resolve_fuzzy_typos() {
1083 let registry = AgentRegistry::new().unwrap();
1084 assert_eq!(registry.resolve_fuzzy("claud"), Some("claude".to_string()));
1086 assert_eq!(registry.resolve_fuzzy("CLAUD"), Some("claude".to_string()));
1087 }
1088
1089 #[test]
1090 fn test_resolve_fuzzy_codex_variations() {
1091 let registry = AgentRegistry::new().unwrap();
1092 assert_eq!(registry.resolve_fuzzy("codeex"), Some("codex".to_string()));
1094 assert_eq!(registry.resolve_fuzzy("code-x"), Some("codex".to_string()));
1095 assert_eq!(registry.resolve_fuzzy("CODEEX"), Some("codex".to_string()));
1096 }
1097
1098 #[test]
1099 fn test_resolve_fuzzy_cursor_variations() {
1100 let registry = AgentRegistry::new().unwrap();
1101 assert_eq!(registry.resolve_fuzzy("crusor"), Some("cursor".to_string()));
1103 assert_eq!(registry.resolve_fuzzy("CRUSOR"), Some("cursor".to_string()));
1104 }
1105
1106 #[test]
1107 fn test_resolve_fuzzy_gemini_variations() {
1108 let registry = AgentRegistry::new().unwrap();
1109 assert_eq!(registry.resolve_fuzzy("gemeni"), Some("gemini".to_string()));
1111 assert_eq!(registry.resolve_fuzzy("gemni"), Some("gemini".to_string()));
1112 assert_eq!(registry.resolve_fuzzy("GEMENI"), Some("gemini".to_string()));
1113 }
1114
1115 #[test]
1116 fn test_resolve_fuzzy_qwen_variations() {
1117 let registry = AgentRegistry::new().unwrap();
1118 assert_eq!(registry.resolve_fuzzy("quen"), Some("qwen".to_string()));
1120 assert_eq!(registry.resolve_fuzzy("quwen"), Some("qwen".to_string()));
1121 assert_eq!(registry.resolve_fuzzy("QUEN"), Some("qwen".to_string()));
1122 }
1123
1124 #[test]
1125 fn test_resolve_fuzzy_aider_variations() {
1126 let registry = AgentRegistry::new().unwrap();
1127 assert_eq!(registry.resolve_fuzzy("ader"), Some("aider".to_string()));
1129 assert_eq!(registry.resolve_fuzzy("ADER"), Some("aider".to_string()));
1130 }
1131
1132 #[test]
1133 fn test_resolve_fuzzy_vibe_variations() {
1134 let registry = AgentRegistry::new().unwrap();
1135 assert_eq!(registry.resolve_fuzzy("vib"), Some("vibe".to_string()));
1137 assert_eq!(registry.resolve_fuzzy("VIB"), Some("vibe".to_string()));
1138 }
1139
1140 #[test]
1141 fn test_resolve_fuzzy_cline_variations() {
1142 let registry = AgentRegistry::new().unwrap();
1143 assert_eq!(registry.resolve_fuzzy("kline"), Some("cline".to_string()));
1145 assert_eq!(registry.resolve_fuzzy("KLINE"), Some("cline".to_string()));
1146 }
1147
1148 #[test]
1149 fn test_resolve_fuzzy_ccs_dash_to_slash() {
1150 let registry = AgentRegistry::new().unwrap();
1151 assert_eq!(
1153 registry.resolve_fuzzy("ccs-random"),
1154 Some("ccs/random".to_string())
1155 );
1156 assert_eq!(
1157 registry.resolve_fuzzy("ccs-test"),
1158 Some("ccs/test".to_string())
1159 );
1160 }
1161
1162 #[test]
1163 fn test_resolve_fuzzy_underscore_replacement() {
1164 let result = AgentRegistry::get_fuzzy_alternatives("my_agent");
1167 assert!(result.contains(&"my_agent".to_string()));
1168 assert!(result.contains(&"my-agent".to_string()));
1169 assert!(result.contains(&"my/agent".to_string()));
1170 }
1171
1172 #[test]
1173 fn test_resolve_fuzzy_unknown() {
1174 let registry = AgentRegistry::new().unwrap();
1175 assert_eq!(registry.resolve_fuzzy("totally-unknown"), None);
1177 }
1178
1179 #[test]
1180 fn test_apply_unified_config_does_not_inherit_env_vars() {
1181 let mut registry = AgentRegistry::new().unwrap();
1185
1186 registry.register(
1189 "claude",
1190 AgentConfig {
1191 cmd: "claude -p".to_string(),
1192 output_flag: "--output-format=stream-json".to_string(),
1193 yolo_flag: "--dangerously-skip-permissions".to_string(),
1194 verbose_flag: "--verbose".to_string(),
1195 can_commit: true,
1196 json_parser: JsonParserType::Claude,
1197 model_flag: None,
1198 print_flag: String::new(),
1199 streaming_flag: "--include-partial-messages".to_string(),
1200 session_flag: "--resume {}".to_string(),
1201 env_vars: {
1203 let mut vars = std::collections::HashMap::new();
1204 vars.insert(
1205 "ANTHROPIC_BASE_URL".to_string(),
1206 "https://api.z.ai/api/anthropic".to_string(),
1207 );
1208 vars.insert(
1209 "ANTHROPIC_AUTH_TOKEN".to_string(),
1210 "test-token-glm".to_string(),
1211 );
1212 vars.insert("ANTHROPIC_MODEL".to_string(), "glm-4.7".to_string());
1213 vars
1214 },
1215 display_name: None,
1216 },
1217 );
1218
1219 let claude_config = registry.resolve_config("claude").unwrap();
1221 assert_eq!(claude_config.env_vars.len(), 3);
1222 assert_eq!(
1223 claude_config.env_vars.get("ANTHROPIC_BASE_URL"),
1224 Some(&"https://api.z.ai/api/anthropic".to_string())
1225 );
1226
1227 let toml_str = r#"
1233 [general]
1234 verbosity = 2
1235 interactive = true
1236 isolation_mode = true
1237
1238 [agents.claude]
1239 cmd = "claude -p"
1240 display_name = "My Custom Claude"
1241 "#;
1242 let unified: crate::config::UnifiedConfig = toml::from_str(toml_str).unwrap();
1243
1244 registry.apply_unified_config(&unified);
1246
1247 let claude_config_after = registry.resolve_config("claude").unwrap();
1249 assert_eq!(
1250 claude_config_after.env_vars.len(),
1251 0,
1252 "env_vars should NOT be inherited from the existing agent when unified config is applied"
1253 );
1254 assert_eq!(
1255 claude_config_after.display_name,
1256 Some("My Custom Claude".to_string()),
1257 "display_name should be updated from the unified config"
1258 );
1259 }
1260
1261 #[test]
1262 fn test_resolve_config_does_not_share_env_vars_between_agents() {
1263 let mut registry = AgentRegistry::new().unwrap();
1272
1273 registry.register(
1275 "ccs/glm",
1276 AgentConfig {
1277 cmd: "ccs glm".to_string(),
1278 output_flag: "--output-format=stream-json".to_string(),
1279 yolo_flag: "--dangerously-skip-permissions".to_string(),
1280 verbose_flag: "--verbose".to_string(),
1281 can_commit: true,
1282 json_parser: JsonParserType::Claude,
1283 model_flag: None,
1284 print_flag: "-p".to_string(),
1285 streaming_flag: "--include-partial-messages".to_string(),
1286 session_flag: "--resume {}".to_string(),
1287 env_vars: {
1288 let mut vars = std::collections::HashMap::new();
1289 vars.insert(
1290 "ANTHROPIC_BASE_URL".to_string(),
1291 "https://api.z.ai/api/anthropic".to_string(),
1292 );
1293 vars.insert(
1294 "ANTHROPIC_AUTH_TOKEN".to_string(),
1295 "test-token-glm".to_string(),
1296 );
1297 vars.insert("ANTHROPIC_MODEL".to_string(), "glm-4.7".to_string());
1298 vars
1299 },
1300 display_name: Some("ccs-glm".to_string()),
1301 },
1302 );
1303
1304 registry.register(
1306 "claude",
1307 AgentConfig {
1308 cmd: "claude -p".to_string(),
1309 output_flag: "--output-format=stream-json".to_string(),
1310 yolo_flag: "--dangerously-skip-permissions".to_string(),
1311 verbose_flag: "--verbose".to_string(),
1312 can_commit: true,
1313 json_parser: JsonParserType::Claude,
1314 model_flag: None,
1315 print_flag: String::new(),
1316 streaming_flag: "--include-partial-messages".to_string(),
1317 session_flag: "--resume {}".to_string(),
1318 env_vars: std::collections::HashMap::new(),
1319 display_name: None,
1320 },
1321 );
1322
1323 let glm_config = registry.resolve_config("ccs/glm").unwrap();
1325 assert_eq!(glm_config.env_vars.len(), 3);
1326 assert_eq!(
1327 glm_config.env_vars.get("ANTHROPIC_BASE_URL"),
1328 Some(&"https://api.z.ai/api/anthropic".to_string())
1329 );
1330
1331 let claude_config = registry.resolve_config("claude").unwrap();
1333 assert_eq!(
1334 claude_config.env_vars.len(),
1335 0,
1336 "claude agent should have empty env_vars"
1337 );
1338
1339 let glm_config2 = registry.resolve_config("ccs/glm").unwrap();
1341 assert_eq!(glm_config2.env_vars.len(), 3);
1342
1343 drop(glm_config);
1346
1347 let claude_config2 = registry.resolve_config("claude").unwrap();
1349 assert_eq!(
1350 claude_config2.env_vars.len(),
1351 0,
1352 "claude agent env_vars should remain independent"
1353 );
1354 }
1355}