1use super::feedback::SkillScorer;
7use super::validator::SkillValidator;
8use super::Skill;
9use anyhow::Context;
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::sync::{Arc, RwLock};
13
14pub struct SkillRegistry {
20 skills: Arc<RwLock<HashMap<String, Arc<Skill>>>>,
21 validator: Arc<RwLock<Option<Arc<dyn SkillValidator>>>>,
22 scorer: Arc<RwLock<Option<Arc<dyn SkillScorer>>>>,
23}
24
25impl SkillRegistry {
26 pub fn new() -> Self {
28 Self {
29 skills: Arc::new(RwLock::new(HashMap::new())),
30 validator: Arc::new(RwLock::new(None)),
31 scorer: Arc::new(RwLock::new(None)),
32 }
33 }
34
35 pub fn with_builtins() -> Self {
37 let registry = Self::new();
38 for skill in super::builtin::builtin_skills() {
39 registry.register_unchecked(skill);
41 }
42 registry
43 }
44
45 pub fn fork(&self) -> Self {
52 let skills = self.skills.read().unwrap().clone();
53 Self {
54 skills: Arc::new(RwLock::new(skills)),
55 validator: Arc::new(RwLock::new(self.validator.read().unwrap().clone())),
56 scorer: Arc::new(RwLock::new(self.scorer.read().unwrap().clone())),
57 }
58 }
59
60 pub fn set_validator(&self, validator: Arc<dyn SkillValidator>) {
62 *self.validator.write().unwrap() = Some(validator);
63 }
64
65 pub fn set_scorer(&self, scorer: Arc<dyn SkillScorer>) {
67 *self.scorer.write().unwrap() = Some(scorer);
68 }
69
70 pub fn scorer(&self) -> Option<Arc<dyn SkillScorer>> {
72 self.scorer.read().unwrap().clone()
73 }
74
75 pub fn register(
80 &self,
81 skill: Arc<Skill>,
82 ) -> Result<(), super::validator::SkillValidationError> {
83 if let Some(ref validator) = *self.validator.read().unwrap() {
85 validator.validate(&skill)?;
86 }
87 self.register_unchecked(skill);
88 Ok(())
89 }
90
91 pub fn register_unchecked(&self, skill: Arc<Skill>) {
93 let mut skills = self.skills.write().unwrap();
94 skills.insert(skill.name.clone(), skill);
95 }
96
97 pub fn get(&self, name: &str) -> Option<Arc<Skill>> {
99 let skills = self.skills.read().unwrap();
100 skills.get(name).cloned()
101 }
102
103 pub fn list(&self) -> Vec<String> {
105 let skills = self.skills.read().unwrap();
106 skills.keys().cloned().collect()
107 }
108
109 pub fn all(&self) -> Vec<Arc<Skill>> {
111 let skills = self.skills.read().unwrap();
112 skills.values().cloned().collect()
113 }
114
115 pub fn load_from_dir(&self, dir: impl AsRef<Path>) -> anyhow::Result<usize> {
127 let dir = dir.as_ref();
128
129 if !dir.exists() {
130 return Ok(0);
131 }
132
133 if !dir.is_dir() {
134 anyhow::bail!("Path is not a directory: {}", dir.display());
135 }
136
137 let mut loaded = 0;
138 for candidate in Self::collect_skill_candidates(dir)? {
139 match Skill::from_file(&candidate) {
140 Ok(skill) => {
141 let name = skill.name.clone();
142 let skill = Arc::new(skill);
143 if self.get(&name).is_some() {
144 tracing::warn!(
145 skill = %name,
146 path = %candidate.display(),
147 "Duplicate skill name encountered during directory load — overriding previous definition"
148 );
149 }
150 match self.register(skill) {
151 Ok(()) => loaded += 1,
152 Err(e) => {
153 tracing::warn!(
154 "Skill validation failed for {}: {}",
155 candidate.display(),
156 e
157 );
158 }
159 }
160 }
161 Err(e) => {
162 tracing::debug!("Skipped {}: {}", candidate.display(), e);
163 }
164 }
165 }
166
167 Ok(loaded)
168 }
169
170 fn collect_skill_candidates(dir: &Path) -> anyhow::Result<Vec<PathBuf>> {
171 fn visit(dir: &Path, out: &mut Vec<PathBuf>) -> anyhow::Result<()> {
172 let mut entries = std::fs::read_dir(dir)
173 .with_context(|| format!("Failed to read directory: {}", dir.display()))?
174 .collect::<Result<Vec<_>, std::io::Error>>()?;
175 entries.sort_by_key(|entry| entry.path());
176
177 for entry in entries {
178 let path = entry.path();
179 if path.is_dir() {
180 let skill_md = path.join("SKILL.md");
181 if skill_md.is_file() {
182 out.push(skill_md);
183 }
184 visit(&path, out)?;
185 } else if path.extension().and_then(|s| s.to_str()) == Some("md") {
186 out.push(path);
187 }
188 }
189 Ok(())
190 }
191
192 let mut out = Vec::new();
193 visit(dir, &mut out)?;
194 out.sort();
195 out.dedup();
196 Ok(out)
197 }
198
199 pub fn load_from_file(&self, path: impl AsRef<Path>) -> anyhow::Result<Arc<Skill>> {
201 let skill = Skill::from_file(path)?;
202 let skill = Arc::new(skill);
203 self.register(skill.clone())
204 .map_err(|e| anyhow::anyhow!("Skill validation failed: {}", e))?;
205 Ok(skill)
206 }
207
208 pub fn remove(&self, name: &str) -> Option<Arc<Skill>> {
210 let mut skills = self.skills.write().unwrap();
211 skills.remove(name)
212 }
213
214 pub fn clear(&self) {
216 let mut skills = self.skills.write().unwrap();
217 skills.clear();
218 }
219
220 pub fn len(&self) -> usize {
222 let skills = self.skills.read().unwrap();
223 skills.len()
224 }
225
226 pub fn is_empty(&self) -> bool {
228 self.len() == 0
229 }
230
231 pub fn by_kind(&self, kind: super::SkillKind) -> Vec<Arc<Skill>> {
233 let skills = self.skills.read().unwrap();
234 skills
235 .values()
236 .filter(|s| s.kind == kind)
237 .cloned()
238 .collect()
239 }
240
241 pub fn by_tag(&self, tag: &str) -> Vec<Arc<Skill>> {
243 let skills = self.skills.read().unwrap();
244 skills
245 .values()
246 .filter(|s| s.tags.iter().any(|t| t == tag))
247 .cloned()
248 .collect()
249 }
250
251 pub fn personas(&self) -> Vec<Arc<Skill>> {
256 self.by_kind(super::SkillKind::Persona)
257 }
258
259 pub fn search(&self, query: &str, limit: usize) -> Vec<Arc<Skill>> {
261 let skills = self.skills.read().unwrap();
262 let scorer = self.scorer.read().unwrap();
263 let query_lower = query.to_lowercase();
264 let query_tokens: Vec<&str> = query_lower
265 .split_whitespace()
266 .map(|w| w.trim_matches(|c: char| !c.is_alphanumeric()))
267 .filter(|w| w.len() >= 2)
268 .collect();
269
270 let mut scored: Vec<(u32, String, Arc<Skill>)> = skills
271 .values()
272 .filter(|s| Self::is_discoverable_skill(s))
273 .filter(|s| match scorer.as_ref() {
274 Some(sc) => !sc.should_disable(&s.name),
275 None => true,
276 })
277 .filter_map(|skill| {
278 let score = Self::skill_search_score(skill, &query_lower, &query_tokens);
279 if score == 0 {
280 None
281 } else {
282 Some((score, skill.name.clone(), Arc::clone(skill)))
283 }
284 })
285 .collect();
286
287 scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
288 scored
289 .into_iter()
290 .take(limit.max(1))
291 .map(|(_, _, skill)| skill)
292 .collect()
293 }
294
295 fn is_discoverable_skill(skill: &Skill) -> bool {
296 skill.kind == super::SkillKind::Instruction || skill.kind == super::SkillKind::Tool
297 }
298
299 fn skill_search_score(skill: &Skill, query_lower: &str, query_tokens: &[&str]) -> u32 {
300 if query_lower.trim().is_empty() {
301 return 1;
302 }
303
304 let name = skill.name.to_lowercase();
305 let description = skill.description.to_lowercase();
306 let tags: Vec<String> = skill.tags.iter().map(|t| t.to_lowercase()).collect();
307 let content = skill.content.to_lowercase();
308 let mut score = 0;
309
310 if query_lower.contains(&name) {
311 score += 100;
312 }
313 if tags.iter().any(|tag| query_lower.contains(tag)) {
314 score += 80;
315 }
316
317 for token in query_tokens {
318 if name.contains(token) {
319 score += 20;
320 }
321 if tags.iter().any(|tag| tag.contains(token)) {
322 score += 15;
323 }
324 if description.contains(token) {
325 score += 8;
326 }
327 if content.contains(token) {
328 score += 2;
329 }
330 }
331
332 score
333 }
334
335 pub fn to_system_prompt(&self) -> String {
345 let skills = self.skills.read().unwrap();
346 let scorer = self.scorer.read().unwrap();
347
348 let has_discoverable_skill = skills.values().any(|s| {
349 Self::is_discoverable_skill(s)
350 && match scorer.as_ref() {
351 Some(sc) => !sc.should_disable(&s.name),
352 None => true,
353 }
354 });
355
356 if !has_discoverable_skill {
357 return String::new();
358 }
359
360 String::from(crate::prompts::SKILLS_CATALOG_HEADER)
361 }
362
363 pub fn match_skills(&self, user_input: &str) -> String {
368 let matched = self.search(user_input, 3);
369
370 if matched.is_empty() {
371 return String::new();
372 }
373
374 let mut out = String::from("# Skill Instructions\n\n");
375 for skill in matched {
376 out.push_str(&skill.to_system_prompt());
377 out.push_str("\n\n---\n\n");
378 }
379 out
380 }
381}
382
383impl Default for SkillRegistry {
384 fn default() -> Self {
385 Self::new()
386 }
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392 use crate::skills::feedback::{DefaultSkillScorer, SkillFeedback, SkillOutcome};
393 use crate::skills::SkillKind;
394 use std::io::Write;
395 use tempfile::TempDir;
396
397 #[test]
398 fn test_new_registry() {
399 let registry = SkillRegistry::new();
400 assert_eq!(registry.len(), 0);
401 assert!(registry.is_empty());
402 }
403
404 #[test]
405 fn test_with_builtins() {
406 let registry = SkillRegistry::with_builtins();
407 assert_eq!(registry.len(), 9, "Expected 9 built-in skills");
408 assert!(!registry.is_empty());
409
410 assert!(registry.get("agentic-search").is_some());
412 assert!(registry.get("agentic-parse").is_some());
413 assert!(registry.get("code-search").is_some());
414 assert!(registry.get("code-review").is_some());
415 assert!(registry.get("explain-code").is_some());
416 assert!(registry.get("find-bugs").is_some());
417
418 assert!(registry.get("builtin-tools").is_some());
420 assert!(registry.get("delegate-task").is_some());
421 assert!(registry.get("find-skills").is_some());
422 }
423
424 #[test]
425 fn test_register_and_get() {
426 let registry = SkillRegistry::new();
427
428 let skill = Arc::new(Skill {
429 name: "test-skill".to_string(),
430 description: "A test skill".to_string(),
431 allowed_tools: None,
432 disable_model_invocation: false,
433 kind: SkillKind::Instruction,
434 content: "Test content".to_string(),
435 tags: vec![],
436 version: None,
437 });
438
439 registry.register(skill.clone()).unwrap();
440
441 assert_eq!(registry.len(), 1);
442 let retrieved = registry.get("test-skill").unwrap();
443 assert_eq!(retrieved.name, "test-skill");
444 }
445
446 #[test]
447 fn test_list() {
448 let registry = SkillRegistry::with_builtins();
449 let names = registry.list();
450
451 assert_eq!(names.len(), 9, "Expected 9 built-in skills");
452 assert!(names.contains(&"code-search".to_string()));
453 assert!(names.contains(&"code-review".to_string()));
454 assert!(names.contains(&"builtin-tools".to_string()));
455 assert!(names.contains(&"delegate-task".to_string()));
456 assert!(names.contains(&"find-skills".to_string()));
457 }
458
459 #[test]
460 fn test_remove() {
461 let registry = SkillRegistry::with_builtins();
462 assert_eq!(registry.len(), 9);
463
464 let removed = registry.remove("code-search");
465 assert!(removed.is_some());
466 assert_eq!(registry.len(), 8);
467 assert!(registry.get("code-search").is_none());
468 }
469
470 #[test]
471 fn test_clear() {
472 let registry = SkillRegistry::with_builtins();
473 assert_eq!(registry.len(), 9);
474
475 registry.clear();
476 assert_eq!(registry.len(), 0);
477 assert!(registry.is_empty());
478 }
479
480 #[test]
481 fn test_by_kind() {
482 let registry = SkillRegistry::with_builtins();
483 let instruction_skills = registry.by_kind(SkillKind::Instruction);
484
485 assert_eq!(
486 instruction_skills.len(),
487 9,
488 "Expected 9 instruction skills (6 code assistance + 3 tool documentation)"
489 );
490
491 let persona_skills = registry.by_kind(SkillKind::Persona);
492 assert_eq!(persona_skills.len(), 0);
493 }
494
495 #[test]
496 fn test_by_tag() {
497 let registry = SkillRegistry::with_builtins();
498 let search_skills = registry.by_tag("search");
499
500 assert_eq!(search_skills.len(), 2); let names: Vec<&str> = search_skills.iter().map(|s| s.name.as_str()).collect();
502 assert!(names.contains(&"code-search"));
503 assert!(names.contains(&"agentic-search"));
504
505 let security_skills = registry.by_tag("security");
506 assert_eq!(security_skills.len(), 1);
507 assert_eq!(security_skills[0].name, "find-bugs");
508 }
509
510 #[test]
511 fn test_load_from_dir() -> anyhow::Result<()> {
512 let temp_dir = TempDir::new()?;
513
514 let skill_path = temp_dir.path().join("test-skill.md");
516 let mut file = std::fs::File::create(&skill_path)?;
517 writeln!(file, "---")?;
518 writeln!(file, "name: test-skill")?;
519 writeln!(file, "description: A test skill")?;
520 writeln!(file, "kind: instruction")?;
521 writeln!(file, "---")?;
522 writeln!(file, "# Test Skill")?;
523 writeln!(file, "This is a test skill.")?;
524 drop(file);
525
526 let readme_path = temp_dir.path().join("README.md");
528 std::fs::write(&readme_path, "# README\nNot a skill")?;
529
530 let txt_path = temp_dir.path().join("notes.txt");
532 std::fs::write(&txt_path, "Some notes")?;
533
534 let registry = SkillRegistry::new();
535 let loaded = registry.load_from_dir(temp_dir.path())?;
536
537 assert_eq!(loaded, 1);
538 assert_eq!(registry.len(), 1);
539 assert!(registry.get("test-skill").is_some());
540
541 Ok(())
542 }
543
544 #[test]
545 fn test_load_from_dir_recurses_into_nested_skill_dirs() -> anyhow::Result<()> {
546 let temp_dir = TempDir::new()?;
547 let nested = temp_dir.path().join("nested").join("code-review-helper");
548 std::fs::create_dir_all(&nested)?;
549
550 let skill_path = nested.join("SKILL.md");
551 let mut file = std::fs::File::create(&skill_path)?;
552 writeln!(file, "---")?;
553 writeln!(file, "name: nested-skill")?;
554 writeln!(file, "description: A nested skill")?;
555 writeln!(file, "kind: instruction")?;
556 writeln!(file, "---")?;
557 writeln!(file, "# Nested Skill")?;
558 writeln!(file, "This skill lives in a nested SKILL.md.")?;
559 drop(file);
560
561 let registry = SkillRegistry::new();
562 let loaded = registry.load_from_dir(temp_dir.path())?;
563
564 assert_eq!(loaded, 1);
565 assert!(registry.get("nested-skill").is_some());
566 Ok(())
567 }
568
569 #[test]
570 fn test_load_from_file() -> anyhow::Result<()> {
571 let temp_dir = TempDir::new()?;
572 let skill_path = temp_dir.path().join("my-skill.md");
573
574 let mut file = std::fs::File::create(&skill_path)?;
575 writeln!(file, "---")?;
576 writeln!(file, "name: my-skill")?;
577 writeln!(file, "description: My custom skill")?;
578 writeln!(file, "---")?;
579 writeln!(file, "# My Skill")?;
580 drop(file);
581
582 let registry = SkillRegistry::new();
583 let skill = registry.load_from_file(&skill_path)?;
584
585 assert_eq!(skill.name, "my-skill");
586 assert_eq!(registry.len(), 1);
587
588 Ok(())
589 }
590
591 #[test]
592 fn test_to_system_prompt() {
593 let registry = SkillRegistry::with_builtins();
594 let prompt = registry.to_system_prompt();
595
596 assert!(prompt.contains("# Skills"));
597 assert!(prompt.contains("search_skills"));
598 assert!(prompt.contains("Skill"));
599 assert!(!prompt.contains("code-search"));
600 assert!(!prompt.contains("code-review"));
601 }
602
603 #[test]
604 fn test_load_from_nonexistent_dir() {
605 let registry = SkillRegistry::new();
606 let result = registry.load_from_dir("/nonexistent/path");
607
608 assert!(result.is_ok());
609 assert_eq!(result.unwrap(), 0);
610 }
611
612 #[test]
613 fn test_load_from_dir_rejects_file_path() -> anyhow::Result<()> {
614 let temp_dir = TempDir::new()?;
615 let path = temp_dir.path().join("not-a-directory.md");
616 std::fs::write(&path, "# not a directory")?;
617
618 let registry = SkillRegistry::new();
619 let err = registry.load_from_dir(&path).unwrap_err();
620 assert!(err.to_string().contains("Path is not a directory"));
621 Ok(())
622 }
623
624 #[test]
625 fn test_load_from_dir_duplicate_name_overrides_previous_definition() -> anyhow::Result<()> {
626 let temp_dir = TempDir::new()?;
627
628 let first = temp_dir.path().join("first.md");
629 std::fs::write(
630 &first,
631 "---\nname: duplicate-skill\ndescription: First copy\n---\n# First\nalpha\n",
632 )?;
633
634 let nested = temp_dir.path().join("nested");
635 std::fs::create_dir_all(&nested)?;
636 let second = nested.join("SKILL.md");
637 std::fs::write(
638 &second,
639 "---\nname: duplicate-skill\ndescription: Second copy\n---\n# Second\nbeta\n",
640 )?;
641
642 let registry = SkillRegistry::new();
643 let loaded = registry.load_from_dir(temp_dir.path())?;
644
645 assert_eq!(loaded, 2);
646 assert_eq!(registry.len(), 1);
647 assert_eq!(
648 registry.get("duplicate-skill").unwrap().description,
649 "Second copy"
650 );
651 Ok(())
652 }
653
654 #[test]
657 fn test_register_with_validator_rejects_reserved() {
658 use crate::skills::validator::DefaultSkillValidator;
659
660 let registry = SkillRegistry::new();
661 registry.set_validator(Arc::new(DefaultSkillValidator::default()));
662
663 let skill = Arc::new(Skill {
664 name: "code-search".to_string(), description: "Override builtin".to_string(),
666 allowed_tools: None,
667 disable_model_invocation: false,
668 kind: SkillKind::Instruction,
669 content: "Malicious override".to_string(),
670 tags: vec![],
671 version: None,
672 });
673
674 let result = registry.register(skill);
675 assert!(result.is_err());
676 assert_eq!(registry.len(), 0);
677 }
678
679 #[test]
680 fn test_register_with_validator_accepts_valid() {
681 use crate::skills::validator::DefaultSkillValidator;
682
683 let registry = SkillRegistry::new();
684 registry.set_validator(Arc::new(DefaultSkillValidator::default()));
685
686 let skill = Arc::new(Skill {
687 name: "my-custom-skill".to_string(),
688 description: "A valid skill".to_string(),
689 allowed_tools: Some("read(*), grep(*)".to_string()),
690 disable_model_invocation: false,
691 kind: SkillKind::Instruction,
692 content: "Help with code review.".to_string(),
693 tags: vec![],
694 version: None,
695 });
696
697 assert!(registry.register(skill).is_ok());
698 assert_eq!(registry.len(), 1);
699 }
700
701 #[test]
702 fn test_register_without_validator_accepts_anything() {
703 let registry = SkillRegistry::new();
704 let skill = Arc::new(Skill {
707 name: "code-search".to_string(), description: "test".to_string(),
709 allowed_tools: None,
710 disable_model_invocation: false,
711 kind: SkillKind::Instruction,
712 content: "test".to_string(),
713 tags: vec![],
714 version: None,
715 });
716
717 assert!(registry.register(skill).is_ok());
718 }
719
720 #[test]
721 fn test_all_personas_and_scorer_accessor() {
722 let registry = SkillRegistry::new();
723 let scorer = Arc::new(DefaultSkillScorer::default());
724 registry.set_scorer(scorer.clone());
725
726 registry.register_unchecked(Arc::new(Skill {
727 name: "persona-skill".to_string(),
728 description: "Persona".to_string(),
729 allowed_tools: None,
730 disable_model_invocation: false,
731 kind: SkillKind::Persona,
732 content: "Persona content".to_string(),
733 tags: vec!["voice".to_string()],
734 version: None,
735 }));
736 registry.register_unchecked(Arc::new(Skill {
737 name: "instruction-skill".to_string(),
738 description: "Instruction".to_string(),
739 allowed_tools: None,
740 disable_model_invocation: false,
741 kind: SkillKind::Instruction,
742 content: "Instruction content".to_string(),
743 tags: vec!["workflow".to_string()],
744 version: None,
745 }));
746
747 assert_eq!(registry.all().len(), 2);
748 assert_eq!(registry.personas().len(), 1);
749 assert_eq!(registry.personas()[0].name, "persona-skill");
750 assert!(registry.scorer().is_some());
751 }
752
753 #[test]
754 fn test_load_from_file_with_validator_rejects() {
755 use crate::skills::validator::DefaultSkillValidator;
756
757 let temp_dir = TempDir::new().unwrap();
758 let skill_path = temp_dir.path().join("code-search.md");
759
760 let mut file = std::fs::File::create(&skill_path).unwrap();
761 writeln!(file, "---").unwrap();
762 writeln!(file, "name: code-search").unwrap(); writeln!(file, "description: Override").unwrap();
764 writeln!(file, "---").unwrap();
765 writeln!(file, "# Override").unwrap();
766 drop(file);
767
768 let registry = SkillRegistry::new();
769 registry.set_validator(Arc::new(DefaultSkillValidator::default()));
770
771 let result = registry.load_from_file(&skill_path);
772 assert!(result.is_err());
773 assert_eq!(registry.len(), 0);
774 }
775
776 #[test]
779 fn test_to_system_prompt_skips_disabled_skills() {
780 let registry = SkillRegistry::new();
781 let scorer = Arc::new(DefaultSkillScorer::default());
782 registry.set_scorer(scorer.clone());
783
784 registry.register_unchecked(Arc::new(Skill {
786 name: "good-skill".to_string(),
787 description: "Good".to_string(),
788 allowed_tools: None,
789 disable_model_invocation: false,
790 kind: SkillKind::Instruction,
791 content: "Good instructions".to_string(),
792 tags: vec![],
793 version: None,
794 }));
795 registry.register_unchecked(Arc::new(Skill {
796 name: "bad-skill".to_string(),
797 description: "Bad".to_string(),
798 allowed_tools: None,
799 disable_model_invocation: false,
800 kind: SkillKind::Instruction,
801 content: "Bad instructions".to_string(),
802 tags: vec![],
803 version: None,
804 }));
805
806 for _ in 0..5 {
808 scorer.record(SkillFeedback {
809 skill_name: "bad-skill".to_string(),
810 outcome: SkillOutcome::Failure,
811 score_delta: -1.0,
812 reason: "Did not help".to_string(),
813 timestamp: 0,
814 });
815 }
816
817 let prompt = registry.to_system_prompt();
818 assert!(prompt.contains("search_skills"));
819 assert!(!prompt.contains("bad-skill"));
820 }
821
822 #[test]
823 fn test_fork_is_independent() {
824 let original = SkillRegistry::with_builtins();
825 let fork = original.fork();
826
827 assert_eq!(fork.len(), original.len());
829
830 fork.register_unchecked(Arc::new(Skill {
832 name: "session-only".to_string(),
833 description: "Only in fork".to_string(),
834 allowed_tools: None,
835 disable_model_invocation: false,
836 kind: SkillKind::Instruction,
837 content: "content".to_string(),
838 tags: vec![],
839 version: None,
840 }));
841
842 assert_eq!(fork.len(), original.len() + 1);
843 assert!(fork.get("session-only").is_some());
844 assert!(original.get("session-only").is_none());
845 }
846
847 #[test]
848 fn test_fork_inherits_builtins() {
849 let fork = SkillRegistry::with_builtins().fork();
850 assert!(fork.get("code-search").is_some());
851 assert!(fork.get("code-review").is_some());
852 assert!(fork.get("find-bugs").is_some());
853 }
854
855 #[test]
856 fn test_fork_preserves_validator() {
857 use crate::skills::validator::DefaultSkillValidator;
858
859 let original = SkillRegistry::new();
860 original.set_validator(Arc::new(DefaultSkillValidator::default()));
861
862 let fork = original.fork();
863 let invalid = Arc::new(Skill {
864 name: "BadName".to_string(),
865 description: "invalid".to_string(),
866 allowed_tools: None,
867 disable_model_invocation: false,
868 kind: SkillKind::Instruction,
869 content: "content".to_string(),
870 tags: vec![],
871 version: None,
872 });
873
874 assert!(fork.register(invalid).is_err());
875 }
876
877 #[test]
878 fn test_fork_preserves_scorer() {
879 let original = SkillRegistry::new();
880 let scorer = Arc::new(DefaultSkillScorer::default());
881 original.set_scorer(scorer.clone());
882 original.register_unchecked(Arc::new(Skill {
883 name: "disabled-skill".to_string(),
884 description: "disabled".to_string(),
885 allowed_tools: None,
886 disable_model_invocation: false,
887 kind: SkillKind::Instruction,
888 content: "content".to_string(),
889 tags: vec![],
890 version: None,
891 }));
892
893 for _ in 0..5 {
894 scorer.record(SkillFeedback {
895 skill_name: "disabled-skill".to_string(),
896 outcome: SkillOutcome::Failure,
897 score_delta: -1.0,
898 reason: "bad".to_string(),
899 timestamp: 0,
900 });
901 }
902
903 let fork = original.fork();
904 let prompt = fork.to_system_prompt();
905 assert!(!prompt.contains("disabled-skill"));
906 }
907
908 #[test]
909 fn test_search_skills_ranks_matches_and_skips_disabled() {
910 let registry = SkillRegistry::new();
911 let scorer = Arc::new(DefaultSkillScorer::default());
912 registry.set_scorer(scorer.clone());
913
914 registry.register_unchecked(Arc::new(Skill {
915 name: "build-planner".to_string(),
916 description: "Plan complex builds".to_string(),
917 allowed_tools: None,
918 disable_model_invocation: false,
919 kind: SkillKind::Instruction,
920 content: "Planner instructions".to_string(),
921 tags: vec!["architecture".to_string()],
922 version: None,
923 }));
924 registry.register_unchecked(Arc::new(Skill {
925 name: "silent-helper".to_string(),
926 description: "Troubleshoot quietly".to_string(),
927 allowed_tools: None,
928 disable_model_invocation: false,
929 kind: SkillKind::Instruction,
930 content: "Hidden instructions".to_string(),
931 tags: vec!["debug".to_string()],
932 version: None,
933 }));
934
935 for _ in 0..5 {
936 scorer.record(SkillFeedback {
937 skill_name: "silent-helper".to_string(),
938 outcome: SkillOutcome::Failure,
939 score_delta: -1.0,
940 reason: "disabled".to_string(),
941 timestamp: 0,
942 });
943 }
944
945 let matches = registry.search("architecture plan", 5);
946 assert_eq!(matches.len(), 1);
947 assert_eq!(matches[0].name, "build-planner");
948
949 let disabled = registry.search("debug silent-helper", 5);
950 assert!(disabled.is_empty());
951 }
952
953 #[test]
954 fn test_match_skills_matches_name_tag_and_description_and_skips_disabled() {
955 let registry = SkillRegistry::new();
956 let scorer = Arc::new(DefaultSkillScorer::default());
957 registry.set_scorer(scorer.clone());
958
959 registry.register_unchecked(Arc::new(Skill {
960 name: "build-planner".to_string(),
961 description: "Plan complex builds".to_string(),
962 allowed_tools: None,
963 disable_model_invocation: false,
964 kind: SkillKind::Instruction,
965 content: "Planner instructions".to_string(),
966 tags: vec!["architecture".to_string()],
967 version: None,
968 }));
969 registry.register_unchecked(Arc::new(Skill {
970 name: "silent-helper".to_string(),
971 description: "Troubleshoot quietly".to_string(),
972 allowed_tools: None,
973 disable_model_invocation: false,
974 kind: SkillKind::Instruction,
975 content: "Hidden instructions".to_string(),
976 tags: vec!["debug".to_string()],
977 version: None,
978 }));
979
980 for _ in 0..5 {
981 scorer.record(SkillFeedback {
982 skill_name: "silent-helper".to_string(),
983 outcome: SkillOutcome::Failure,
984 score_delta: -1.0,
985 reason: "disabled".to_string(),
986 timestamp: 0,
987 });
988 }
989
990 let by_name = registry.match_skills("please use build-planner for this task");
991 assert!(by_name.contains("Planner instructions"));
992
993 let by_tag = registry.match_skills("need architecture guidance");
994 assert!(by_tag.contains("Planner instructions"));
995
996 let by_description = registry.match_skills("help me plan the release");
997 assert!(by_description.contains("Planner instructions"));
998
999 let disabled = registry.match_skills("need debug help from silent-helper");
1000 assert!(!disabled.contains("Hidden instructions"));
1001
1002 assert!(registry
1003 .match_skills("totally unrelated request")
1004 .is_empty());
1005 }
1006}