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