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