1use super::validator::SkillValidator;
7use super::Skill;
8use anyhow::Context;
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use std::sync::{Arc, RwLock};
12
13pub struct SkillRegistry {
18 skills: Arc<RwLock<HashMap<String, Arc<Skill>>>>,
19 validator: Arc<RwLock<Option<Arc<dyn SkillValidator>>>>,
20}
21
22impl SkillRegistry {
23 pub fn new() -> Self {
25 Self {
26 skills: Arc::new(RwLock::new(HashMap::new())),
27 validator: Arc::new(RwLock::new(None)),
28 }
29 }
30
31 pub fn with_builtins() -> Self {
33 let registry = Self::new();
34 for skill in super::builtin::builtin_skills() {
35 registry.register_unchecked(skill);
37 }
38 registry
39 }
40
41 pub fn fork(&self) -> Self {
47 let skills = self.skills.read().unwrap().clone();
48 Self {
49 skills: Arc::new(RwLock::new(skills)),
50 validator: Arc::new(RwLock::new(self.validator.read().unwrap().clone())),
51 }
52 }
53
54 pub fn set_validator(&self, validator: Arc<dyn SkillValidator>) {
56 *self.validator.write().unwrap() = Some(validator);
57 }
58
59 pub fn register(
64 &self,
65 skill: Arc<Skill>,
66 ) -> Result<(), super::validator::SkillValidationError> {
67 if let Some(ref validator) = *self.validator.read().unwrap() {
69 validator.validate(&skill)?;
70 }
71 self.register_unchecked(skill);
72 Ok(())
73 }
74
75 pub fn register_unchecked(&self, skill: Arc<Skill>) {
77 let mut skills = self.skills.write().unwrap();
78 skills.insert(skill.name.clone(), skill);
79 }
80
81 pub fn get(&self, name: &str) -> Option<Arc<Skill>> {
83 let skills = self.skills.read().unwrap();
84 skills.get(name).cloned()
85 }
86
87 pub fn list(&self) -> Vec<String> {
89 let skills = self.skills.read().unwrap();
90 skills.keys().cloned().collect()
91 }
92
93 pub fn all(&self) -> Vec<Arc<Skill>> {
95 let skills = self.skills.read().unwrap();
96 skills.values().cloned().collect()
97 }
98
99 pub fn load_from_dir(&self, dir: impl AsRef<Path>) -> anyhow::Result<usize> {
111 let dir = dir.as_ref();
112
113 if !dir.exists() {
114 return Ok(0);
115 }
116
117 if !dir.is_dir() {
118 anyhow::bail!("Path is not a directory: {}", dir.display());
119 }
120
121 let mut loaded = 0;
122 for candidate in Self::collect_skill_candidates(dir)? {
123 match Skill::from_file(&candidate) {
124 Ok(skill) => {
125 let name = skill.name.clone();
126 let skill = Arc::new(skill);
127 if self.get(&name).is_some() {
128 tracing::warn!(
129 skill = %name,
130 path = %candidate.display(),
131 "Duplicate skill name encountered during directory load — overriding previous definition"
132 );
133 }
134 match self.register(skill) {
135 Ok(()) => loaded += 1,
136 Err(e) => {
137 tracing::warn!(
138 "Skill validation failed for {}: {}",
139 candidate.display(),
140 e
141 );
142 }
143 }
144 }
145 Err(e) => {
146 tracing::debug!("Skipped {}: {}", candidate.display(), e);
147 }
148 }
149 }
150
151 Ok(loaded)
152 }
153
154 fn collect_skill_candidates(dir: &Path) -> anyhow::Result<Vec<PathBuf>> {
155 fn visit(dir: &Path, out: &mut Vec<PathBuf>) -> anyhow::Result<()> {
156 let mut entries = std::fs::read_dir(dir)
157 .with_context(|| format!("Failed to read directory: {}", dir.display()))?
158 .collect::<Result<Vec<_>, std::io::Error>>()?;
159 entries.sort_by_key(|entry| entry.path());
160
161 for entry in entries {
162 let path = entry.path();
163 if path.is_dir() {
164 let skill_md = path.join("SKILL.md");
165 if skill_md.is_file() {
166 out.push(skill_md);
167 }
168 visit(&path, out)?;
169 } else if path.extension().and_then(|s| s.to_str()) == Some("md") {
170 out.push(path);
171 }
172 }
173 Ok(())
174 }
175
176 let mut out = Vec::new();
177 visit(dir, &mut out)?;
178 out.sort();
179 out.dedup();
180 Ok(out)
181 }
182
183 pub fn load_from_file(&self, path: impl AsRef<Path>) -> anyhow::Result<Arc<Skill>> {
185 let skill = Skill::from_file(path)?;
186 let skill = Arc::new(skill);
187 self.register(skill.clone())
188 .map_err(|e| anyhow::anyhow!("Skill validation failed: {}", e))?;
189 Ok(skill)
190 }
191
192 pub fn remove(&self, name: &str) -> Option<Arc<Skill>> {
194 let mut skills = self.skills.write().unwrap();
195 skills.remove(name)
196 }
197
198 pub fn clear(&self) {
200 let mut skills = self.skills.write().unwrap();
201 skills.clear();
202 }
203
204 pub fn len(&self) -> usize {
206 let skills = self.skills.read().unwrap();
207 skills.len()
208 }
209
210 pub fn is_empty(&self) -> bool {
212 self.len() == 0
213 }
214
215 pub fn by_kind(&self, kind: super::SkillKind) -> Vec<Arc<Skill>> {
217 let skills = self.skills.read().unwrap();
218 skills
219 .values()
220 .filter(|s| s.kind == kind)
221 .cloned()
222 .collect()
223 }
224
225 pub fn by_tag(&self, tag: &str) -> Vec<Arc<Skill>> {
227 let skills = self.skills.read().unwrap();
228 skills
229 .values()
230 .filter(|s| s.tags.iter().any(|t| t == tag))
231 .cloned()
232 .collect()
233 }
234
235 pub fn personas(&self) -> Vec<Arc<Skill>> {
240 self.by_kind(super::SkillKind::Persona)
241 }
242
243 pub fn search(&self, query: &str, limit: usize) -> Vec<Arc<Skill>> {
245 let skills = self.skills.read().unwrap();
246 let query_lower = query.to_lowercase();
247 let query_tokens: Vec<&str> = query_lower
248 .split_whitespace()
249 .map(|w| w.trim_matches(|c: char| !c.is_alphanumeric()))
250 .filter(|w| w.len() >= 2)
251 .collect();
252
253 let mut scored: Vec<(u32, String, Arc<Skill>)> = skills
254 .values()
255 .filter(|s| Self::is_discoverable_skill(s))
256 .filter_map(|skill| {
257 let score = Self::skill_search_score(skill, &query_lower, &query_tokens);
258 if score == 0 {
259 None
260 } else {
261 Some((score, skill.name.clone(), Arc::clone(skill)))
262 }
263 })
264 .collect();
265
266 scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
267 scored
268 .into_iter()
269 .take(limit.max(1))
270 .map(|(_, _, skill)| skill)
271 .collect()
272 }
273
274 fn is_discoverable_skill(skill: &Skill) -> bool {
275 skill.kind == super::SkillKind::Instruction || skill.kind == super::SkillKind::Tool
276 }
277
278 fn skill_search_score(skill: &Skill, query_lower: &str, query_tokens: &[&str]) -> u32 {
279 if query_lower.trim().is_empty() {
280 return 1;
281 }
282
283 let name = skill.name.to_lowercase();
284 let description = skill.description.to_lowercase();
285 let tags: Vec<String> = skill.tags.iter().map(|t| t.to_lowercase()).collect();
286 let content = skill.content.to_lowercase();
287 let mut score = 0;
288
289 if query_lower.contains(&name) {
290 score += 100;
291 }
292 if tags.iter().any(|tag| query_lower.contains(tag)) {
293 score += 80;
294 }
295
296 for token in query_tokens {
297 if name.contains(token) {
298 score += 20;
299 }
300 if tags.iter().any(|tag| tag.contains(token)) {
301 score += 15;
302 }
303 if description.contains(token) {
304 score += 8;
305 }
306 if content.contains(token) {
307 score += 2;
308 }
309 }
310
311 score
312 }
313
314 pub fn to_system_prompt(&self) -> String {
324 let skills = self.skills.read().unwrap();
325
326 let has_discoverable_skill = skills.values().any(|s| Self::is_discoverable_skill(s));
327
328 if !has_discoverable_skill {
329 return String::new();
330 }
331
332 String::from(crate::prompts::SKILLS_CATALOG_HEADER)
333 }
334
335 pub fn match_skills(&self, user_input: &str) -> String {
340 let matched = self.search(user_input, 3);
341
342 if matched.is_empty() {
343 return String::new();
344 }
345
346 let mut out = String::from("# Skill Instructions\n\n");
347 for skill in matched {
348 out.push_str(&skill.to_system_prompt());
349 out.push_str("\n\n---\n\n");
350 }
351 out
352 }
353}
354
355impl Default for SkillRegistry {
356 fn default() -> Self {
357 Self::new()
358 }
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364 use crate::skills::SkillKind;
365 use std::io::Write;
366 use tempfile::TempDir;
367
368 #[test]
369 fn test_new_registry() {
370 let registry = SkillRegistry::new();
371 assert_eq!(registry.len(), 0);
372 assert!(registry.is_empty());
373 }
374
375 #[test]
376 fn test_with_builtins() {
377 let registry = SkillRegistry::with_builtins();
378 assert_eq!(registry.len(), 4, "Expected 4 built-in skills");
379 assert!(!registry.is_empty());
380
381 assert!(registry.get("code-search").is_some());
383 assert!(registry.get("code-review").is_some());
384 assert!(registry.get("explain-code").is_some());
385 assert!(registry.get("find-bugs").is_some());
386 }
387
388 #[test]
389 fn test_register_and_get() {
390 let registry = SkillRegistry::new();
391
392 let skill = Arc::new(Skill {
393 name: "test-skill".to_string(),
394 description: "A test skill".to_string(),
395 allowed_tools: None,
396 disable_model_invocation: false,
397 kind: SkillKind::Instruction,
398 content: "Test content".to_string(),
399 tags: vec![],
400 version: None,
401 });
402
403 registry.register(skill.clone()).unwrap();
404
405 assert_eq!(registry.len(), 1);
406 let retrieved = registry.get("test-skill").unwrap();
407 assert_eq!(retrieved.name, "test-skill");
408 }
409
410 #[test]
411 fn test_list() {
412 let registry = SkillRegistry::with_builtins();
413 let names = registry.list();
414
415 assert_eq!(names.len(), 4, "Expected 4 built-in skills");
416 assert!(names.contains(&"code-search".to_string()));
417 assert!(names.contains(&"code-review".to_string()));
418 assert!(names.contains(&"explain-code".to_string()));
419 assert!(names.contains(&"find-bugs".to_string()));
420 }
421
422 #[test]
423 fn test_remove() {
424 let registry = SkillRegistry::with_builtins();
425 assert_eq!(registry.len(), 4);
426
427 let removed = registry.remove("code-search");
428 assert!(removed.is_some());
429 assert_eq!(registry.len(), 3);
430 assert!(registry.get("code-search").is_none());
431 }
432
433 #[test]
434 fn test_clear() {
435 let registry = SkillRegistry::with_builtins();
436 assert_eq!(registry.len(), 4);
437
438 registry.clear();
439 assert_eq!(registry.len(), 0);
440 assert!(registry.is_empty());
441 }
442
443 #[test]
444 fn test_by_kind() {
445 let registry = SkillRegistry::with_builtins();
446 let instruction_skills = registry.by_kind(SkillKind::Instruction);
447
448 assert_eq!(instruction_skills.len(), 4, "Expected 4 instruction skills");
449
450 let persona_skills = registry.by_kind(SkillKind::Persona);
451 assert_eq!(persona_skills.len(), 0);
452 }
453
454 #[test]
455 fn test_by_tag() {
456 let registry = SkillRegistry::with_builtins();
457 let search_skills = registry.by_tag("search");
458
459 assert_eq!(search_skills.len(), 1);
460 let names: Vec<&str> = search_skills.iter().map(|s| s.name.as_str()).collect();
461 assert!(names.contains(&"code-search"));
462
463 let security_skills = registry.by_tag("security");
464 assert_eq!(security_skills.len(), 1);
465 assert_eq!(security_skills[0].name, "find-bugs");
466 }
467
468 #[test]
469 fn test_load_from_dir() -> anyhow::Result<()> {
470 let temp_dir = TempDir::new()?;
471
472 let skill_path = temp_dir.path().join("test-skill.md");
474 let mut file = std::fs::File::create(&skill_path)?;
475 writeln!(file, "---")?;
476 writeln!(file, "name: test-skill")?;
477 writeln!(file, "description: A test skill")?;
478 writeln!(file, "kind: instruction")?;
479 writeln!(file, "---")?;
480 writeln!(file, "# Test Skill")?;
481 writeln!(file, "This is a test skill.")?;
482 drop(file);
483
484 let readme_path = temp_dir.path().join("README.md");
486 std::fs::write(&readme_path, "# README\nNot a skill")?;
487
488 let txt_path = temp_dir.path().join("notes.txt");
490 std::fs::write(&txt_path, "Some notes")?;
491
492 let registry = SkillRegistry::new();
493 let loaded = registry.load_from_dir(temp_dir.path())?;
494
495 assert_eq!(loaded, 1);
496 assert_eq!(registry.len(), 1);
497 assert!(registry.get("test-skill").is_some());
498
499 Ok(())
500 }
501
502 #[test]
503 fn test_load_from_dir_recurses_into_nested_skill_dirs() -> anyhow::Result<()> {
504 let temp_dir = TempDir::new()?;
505 let nested = temp_dir.path().join("nested").join("code-review-helper");
506 std::fs::create_dir_all(&nested)?;
507
508 let skill_path = nested.join("SKILL.md");
509 let mut file = std::fs::File::create(&skill_path)?;
510 writeln!(file, "---")?;
511 writeln!(file, "name: nested-skill")?;
512 writeln!(file, "description: A nested skill")?;
513 writeln!(file, "kind: instruction")?;
514 writeln!(file, "---")?;
515 writeln!(file, "# Nested Skill")?;
516 writeln!(file, "This skill lives in a nested SKILL.md.")?;
517 drop(file);
518
519 let registry = SkillRegistry::new();
520 let loaded = registry.load_from_dir(temp_dir.path())?;
521
522 assert_eq!(loaded, 1);
523 assert!(registry.get("nested-skill").is_some());
524 Ok(())
525 }
526
527 #[test]
528 fn test_load_from_file() -> anyhow::Result<()> {
529 let temp_dir = TempDir::new()?;
530 let skill_path = temp_dir.path().join("my-skill.md");
531
532 let mut file = std::fs::File::create(&skill_path)?;
533 writeln!(file, "---")?;
534 writeln!(file, "name: my-skill")?;
535 writeln!(file, "description: My custom skill")?;
536 writeln!(file, "---")?;
537 writeln!(file, "# My Skill")?;
538 drop(file);
539
540 let registry = SkillRegistry::new();
541 let skill = registry.load_from_file(&skill_path)?;
542
543 assert_eq!(skill.name, "my-skill");
544 assert_eq!(registry.len(), 1);
545
546 Ok(())
547 }
548
549 #[test]
550 fn test_to_system_prompt() {
551 let registry = SkillRegistry::with_builtins();
552 let prompt = registry.to_system_prompt();
553
554 assert!(prompt.contains("# Skills"));
555 assert!(prompt.contains("search_skills"));
556 assert!(prompt.contains("Skill"));
557 assert!(!prompt.contains("code-search"));
558 assert!(!prompt.contains("code-review"));
559 }
560
561 #[test]
562 fn test_load_from_nonexistent_dir() {
563 let registry = SkillRegistry::new();
564 let result = registry.load_from_dir("/nonexistent/path");
565
566 assert!(result.is_ok());
567 assert_eq!(result.unwrap(), 0);
568 }
569
570 #[test]
571 fn test_load_from_dir_rejects_file_path() -> anyhow::Result<()> {
572 let temp_dir = TempDir::new()?;
573 let path = temp_dir.path().join("not-a-directory.md");
574 std::fs::write(&path, "# not a directory")?;
575
576 let registry = SkillRegistry::new();
577 let err = registry.load_from_dir(&path).unwrap_err();
578 assert!(err.to_string().contains("Path is not a directory"));
579 Ok(())
580 }
581
582 #[test]
583 fn test_load_from_dir_duplicate_name_overrides_previous_definition() -> anyhow::Result<()> {
584 let temp_dir = TempDir::new()?;
585
586 let first = temp_dir.path().join("first.md");
587 std::fs::write(
588 &first,
589 "---\nname: duplicate-skill\ndescription: First copy\n---\n# First\nalpha\n",
590 )?;
591
592 let nested = temp_dir.path().join("nested");
593 std::fs::create_dir_all(&nested)?;
594 let second = nested.join("SKILL.md");
595 std::fs::write(
596 &second,
597 "---\nname: duplicate-skill\ndescription: Second copy\n---\n# Second\nbeta\n",
598 )?;
599
600 let registry = SkillRegistry::new();
601 let loaded = registry.load_from_dir(temp_dir.path())?;
602
603 assert_eq!(loaded, 2);
604 assert_eq!(registry.len(), 1);
605 assert_eq!(
606 registry.get("duplicate-skill").unwrap().description,
607 "Second copy"
608 );
609 Ok(())
610 }
611
612 #[test]
615 fn test_register_with_validator_rejects_reserved() {
616 use crate::skills::validator::DefaultSkillValidator;
617
618 let registry = SkillRegistry::new();
619 registry.set_validator(Arc::new(DefaultSkillValidator::default()));
620
621 let skill = Arc::new(Skill {
622 name: "code-search".to_string(), description: "Override builtin".to_string(),
624 allowed_tools: None,
625 disable_model_invocation: false,
626 kind: SkillKind::Instruction,
627 content: "Malicious override".to_string(),
628 tags: vec![],
629 version: None,
630 });
631
632 let result = registry.register(skill);
633 assert!(result.is_err());
634 assert_eq!(registry.len(), 0);
635 }
636
637 #[test]
638 fn test_register_with_validator_accepts_valid() {
639 use crate::skills::validator::DefaultSkillValidator;
640
641 let registry = SkillRegistry::new();
642 registry.set_validator(Arc::new(DefaultSkillValidator::default()));
643
644 let skill = Arc::new(Skill {
645 name: "my-custom-skill".to_string(),
646 description: "A valid skill".to_string(),
647 allowed_tools: Some("read(*), grep(*)".to_string()),
648 disable_model_invocation: false,
649 kind: SkillKind::Instruction,
650 content: "Help with code review.".to_string(),
651 tags: vec![],
652 version: None,
653 });
654
655 assert!(registry.register(skill).is_ok());
656 assert_eq!(registry.len(), 1);
657 }
658
659 #[test]
660 fn test_register_without_validator_accepts_anything() {
661 let registry = SkillRegistry::new();
662 let skill = Arc::new(Skill {
665 name: "code-search".to_string(), description: "test".to_string(),
667 allowed_tools: None,
668 disable_model_invocation: false,
669 kind: SkillKind::Instruction,
670 content: "test".to_string(),
671 tags: vec![],
672 version: None,
673 });
674
675 assert!(registry.register(skill).is_ok());
676 }
677
678 #[test]
679 fn test_all_and_personas() {
680 let registry = SkillRegistry::new();
681
682 registry.register_unchecked(Arc::new(Skill {
683 name: "persona-skill".to_string(),
684 description: "Persona".to_string(),
685 allowed_tools: None,
686 disable_model_invocation: false,
687 kind: SkillKind::Persona,
688 content: "Persona content".to_string(),
689 tags: vec!["voice".to_string()],
690 version: None,
691 }));
692 registry.register_unchecked(Arc::new(Skill {
693 name: "instruction-skill".to_string(),
694 description: "Instruction".to_string(),
695 allowed_tools: None,
696 disable_model_invocation: false,
697 kind: SkillKind::Instruction,
698 content: "Instruction content".to_string(),
699 tags: vec!["workflow".to_string()],
700 version: None,
701 }));
702
703 assert_eq!(registry.all().len(), 2);
704 assert_eq!(registry.personas().len(), 1);
705 assert_eq!(registry.personas()[0].name, "persona-skill");
706 }
707
708 #[test]
709 fn test_load_from_file_with_validator_rejects() {
710 use crate::skills::validator::DefaultSkillValidator;
711
712 let temp_dir = TempDir::new().unwrap();
713 let skill_path = temp_dir.path().join("code-search.md");
714
715 let mut file = std::fs::File::create(&skill_path).unwrap();
716 writeln!(file, "---").unwrap();
717 writeln!(file, "name: code-search").unwrap(); writeln!(file, "description: Override").unwrap();
719 writeln!(file, "---").unwrap();
720 writeln!(file, "# Override").unwrap();
721 drop(file);
722
723 let registry = SkillRegistry::new();
724 registry.set_validator(Arc::new(DefaultSkillValidator::default()));
725
726 let result = registry.load_from_file(&skill_path);
727 assert!(result.is_err());
728 assert_eq!(registry.len(), 0);
729 }
730
731 #[test]
732 fn test_fork_is_independent() {
733 let original = SkillRegistry::with_builtins();
734 let fork = original.fork();
735
736 assert_eq!(fork.len(), original.len());
738
739 fork.register_unchecked(Arc::new(Skill {
741 name: "session-only".to_string(),
742 description: "Only in fork".to_string(),
743 allowed_tools: None,
744 disable_model_invocation: false,
745 kind: SkillKind::Instruction,
746 content: "content".to_string(),
747 tags: vec![],
748 version: None,
749 }));
750
751 assert_eq!(fork.len(), original.len() + 1);
752 assert!(fork.get("session-only").is_some());
753 assert!(original.get("session-only").is_none());
754 }
755
756 #[test]
757 fn test_fork_inherits_builtins() {
758 let fork = SkillRegistry::with_builtins().fork();
759 assert!(fork.get("code-search").is_some());
760 assert!(fork.get("code-review").is_some());
761 assert!(fork.get("find-bugs").is_some());
762 }
763
764 #[test]
765 fn test_fork_preserves_validator() {
766 use crate::skills::validator::DefaultSkillValidator;
767
768 let original = SkillRegistry::new();
769 original.set_validator(Arc::new(DefaultSkillValidator::default()));
770
771 let fork = original.fork();
772 let invalid = Arc::new(Skill {
773 name: "BadName".to_string(),
774 description: "invalid".to_string(),
775 allowed_tools: None,
776 disable_model_invocation: false,
777 kind: SkillKind::Instruction,
778 content: "content".to_string(),
779 tags: vec![],
780 version: None,
781 });
782
783 assert!(fork.register(invalid).is_err());
784 }
785
786 #[test]
787 fn test_search_skills_ranks_matches() {
788 let registry = SkillRegistry::new();
789
790 registry.register_unchecked(Arc::new(Skill {
791 name: "build-planner".to_string(),
792 description: "Plan complex builds".to_string(),
793 allowed_tools: None,
794 disable_model_invocation: false,
795 kind: SkillKind::Instruction,
796 content: "Planner instructions".to_string(),
797 tags: vec!["architecture".to_string()],
798 version: None,
799 }));
800 let matches = registry.search("architecture plan", 5);
801 assert_eq!(matches.len(), 1);
802 assert_eq!(matches[0].name, "build-planner");
803 }
804
805 #[test]
806 fn test_match_skills_matches_name_tag_and_description() {
807 let registry = SkillRegistry::new();
808
809 registry.register_unchecked(Arc::new(Skill {
810 name: "build-planner".to_string(),
811 description: "Plan complex builds".to_string(),
812 allowed_tools: None,
813 disable_model_invocation: false,
814 kind: SkillKind::Instruction,
815 content: "Planner instructions".to_string(),
816 tags: vec!["architecture".to_string()],
817 version: None,
818 }));
819 let by_name = registry.match_skills("please use build-planner for this task");
820 assert!(by_name.contains("Planner instructions"));
821
822 let by_tag = registry.match_skills("need architecture guidance");
823 assert!(by_tag.contains("Planner instructions"));
824
825 let by_description = registry.match_skills("help me plan the release");
826 assert!(by_description.contains("Planner instructions"));
827
828 assert!(registry
829 .match_skills("totally unrelated request")
830 .is_empty());
831 }
832}