1use super::feedback::SkillScorer;
7use super::validator::SkillValidator;
8use super::Skill;
9use anyhow::Context;
10use std::collections::HashMap;
11use std::path::Path;
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 set_validator(&self, validator: Arc<dyn SkillValidator>) {
47 *self.validator.write().unwrap() = Some(validator);
48 }
49
50 pub fn set_scorer(&self, scorer: Arc<dyn SkillScorer>) {
52 *self.scorer.write().unwrap() = Some(scorer);
53 }
54
55 pub fn scorer(&self) -> Option<Arc<dyn SkillScorer>> {
57 self.scorer.read().unwrap().clone()
58 }
59
60 pub fn register(
65 &self,
66 skill: Arc<Skill>,
67 ) -> Result<(), super::validator::SkillValidationError> {
68 if let Some(ref validator) = *self.validator.read().unwrap() {
70 validator.validate(&skill)?;
71 }
72 self.register_unchecked(skill);
73 Ok(())
74 }
75
76 pub fn register_unchecked(&self, skill: Arc<Skill>) {
78 let mut skills = self.skills.write().unwrap();
79 skills.insert(skill.name.clone(), skill);
80 }
81
82 pub fn get(&self, name: &str) -> Option<Arc<Skill>> {
84 let skills = self.skills.read().unwrap();
85 skills.get(name).cloned()
86 }
87
88 pub fn list(&self) -> Vec<String> {
90 let skills = self.skills.read().unwrap();
91 skills.keys().cloned().collect()
92 }
93
94 pub fn all(&self) -> Vec<Arc<Skill>> {
96 let skills = self.skills.read().unwrap();
97 skills.values().cloned().collect()
98 }
99
100 pub fn load_from_dir(&self, dir: impl AsRef<Path>) -> anyhow::Result<usize> {
105 let dir = dir.as_ref();
106
107 if !dir.exists() {
108 return Ok(0);
109 }
110
111 if !dir.is_dir() {
112 anyhow::bail!("Path is not a directory: {}", dir.display());
113 }
114
115 let mut loaded = 0;
116
117 for entry in std::fs::read_dir(dir)
118 .with_context(|| format!("Failed to read directory: {}", dir.display()))?
119 {
120 let entry = entry?;
121 let path = entry.path();
122
123 if path.extension().and_then(|s| s.to_str()) != Some("md") {
125 continue;
126 }
127
128 match Skill::from_file(&path) {
130 Ok(skill) => {
131 let skill = Arc::new(skill);
132 match self.register(skill) {
133 Ok(()) => loaded += 1,
134 Err(e) => {
135 tracing::warn!("Skill validation failed for {}: {}", path.display(), e);
136 }
137 }
138 }
139 Err(e) => {
140 tracing::debug!("Skipped {}: {}", path.display(), e);
142 }
143 }
144 }
145
146 Ok(loaded)
147 }
148
149 pub fn load_from_file(&self, path: impl AsRef<Path>) -> anyhow::Result<Arc<Skill>> {
151 let skill = Skill::from_file(path)?;
152 let skill = Arc::new(skill);
153 self.register(skill.clone())
154 .map_err(|e| anyhow::anyhow!("Skill validation failed: {}", e))?;
155 Ok(skill)
156 }
157
158 pub fn remove(&self, name: &str) -> Option<Arc<Skill>> {
160 let mut skills = self.skills.write().unwrap();
161 skills.remove(name)
162 }
163
164 pub fn clear(&self) {
166 let mut skills = self.skills.write().unwrap();
167 skills.clear();
168 }
169
170 pub fn len(&self) -> usize {
172 let skills = self.skills.read().unwrap();
173 skills.len()
174 }
175
176 pub fn is_empty(&self) -> bool {
178 self.len() == 0
179 }
180
181 pub fn by_kind(&self, kind: super::SkillKind) -> Vec<Arc<Skill>> {
183 let skills = self.skills.read().unwrap();
184 skills
185 .values()
186 .filter(|s| s.kind == kind)
187 .cloned()
188 .collect()
189 }
190
191 pub fn by_tag(&self, tag: &str) -> Vec<Arc<Skill>> {
193 let skills = self.skills.read().unwrap();
194 skills
195 .values()
196 .filter(|s| s.tags.iter().any(|t| t == tag))
197 .cloned()
198 .collect()
199 }
200
201 pub fn personas(&self) -> Vec<Arc<Skill>> {
206 self.by_kind(super::SkillKind::Persona)
207 }
208
209 pub fn to_system_prompt(&self) -> String {
219 let skills = self.skills.read().unwrap();
220 let scorer = self.scorer.read().unwrap();
221
222 let instruction_skills: Vec<_> = skills
223 .values()
224 .filter(|s| s.kind == super::SkillKind::Instruction)
225 .filter(|s| match scorer.as_ref() {
226 Some(sc) => !sc.should_disable(&s.name),
227 None => true,
228 })
229 .collect();
230
231 if instruction_skills.is_empty() {
232 return String::new();
233 }
234
235 let mut prompt = String::from("# Available Skills\n\nThe following skills are available. Their full instructions will be provided when relevant.\n\n");
236 for skill in &instruction_skills {
237 prompt.push_str(&format!("- **{}**: {}\n", skill.name, skill.description));
238 }
239 prompt
240 }
241
242 pub fn match_skills(&self, user_input: &str) -> String {
247 let skills = self.skills.read().unwrap();
248 let scorer = self.scorer.read().unwrap();
249 let input_lower = user_input.to_lowercase();
250
251 let matched: Vec<_> = skills
252 .values()
253 .filter(|s| s.kind == super::SkillKind::Instruction)
254 .filter(|s| match scorer.as_ref() {
255 Some(sc) => !sc.should_disable(&s.name),
256 None => true,
257 })
258 .filter(|s| {
259 input_lower.contains(&s.name.to_lowercase())
261 || s.tags
262 .iter()
263 .any(|t| input_lower.contains(&t.to_lowercase()))
264 || input_lower.contains(
265 s.description
266 .to_lowercase()
267 .split_whitespace()
268 .next()
269 .unwrap_or(""),
270 )
271 })
272 .collect();
273
274 if matched.is_empty() {
275 return String::new();
276 }
277
278 let mut out = String::from("# Skill Instructions\n\n");
279 for skill in matched {
280 out.push_str(&skill.to_system_prompt());
281 out.push_str("\n\n---\n\n");
282 }
283 out
284 }
285}
286
287impl Default for SkillRegistry {
288 fn default() -> Self {
289 Self::new()
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296 use crate::skills::SkillKind;
297 use std::io::Write;
298 use tempfile::TempDir;
299
300 #[test]
301 fn test_new_registry() {
302 let registry = SkillRegistry::new();
303 assert_eq!(registry.len(), 0);
304 assert!(registry.is_empty());
305 }
306
307 #[test]
308 fn test_with_builtins() {
309 let registry = SkillRegistry::with_builtins();
310 assert_eq!(registry.len(), 7, "Expected 7 built-in skills");
311 assert!(!registry.is_empty());
312
313 assert!(registry.get("code-search").is_some());
315 assert!(registry.get("code-review").is_some());
316 assert!(registry.get("explain-code").is_some());
317 assert!(registry.get("find-bugs").is_some());
318
319 assert!(registry.get("builtin-tools").is_some());
321 assert!(registry.get("delegate-task").is_some());
322 assert!(registry.get("find-skills").is_some());
323 }
324
325 #[test]
326 fn test_register_and_get() {
327 let registry = SkillRegistry::new();
328
329 let skill = Arc::new(Skill {
330 name: "test-skill".to_string(),
331 description: "A test skill".to_string(),
332 allowed_tools: None,
333 disable_model_invocation: false,
334 kind: SkillKind::Instruction,
335 content: "Test content".to_string(),
336 tags: vec![],
337 version: None,
338 });
339
340 registry.register(skill.clone()).unwrap();
341
342 assert_eq!(registry.len(), 1);
343 let retrieved = registry.get("test-skill").unwrap();
344 assert_eq!(retrieved.name, "test-skill");
345 }
346
347 #[test]
348 fn test_list() {
349 let registry = SkillRegistry::with_builtins();
350 let names = registry.list();
351
352 assert_eq!(names.len(), 7, "Expected 7 built-in skills");
353 assert!(names.contains(&"code-search".to_string()));
354 assert!(names.contains(&"code-review".to_string()));
355 assert!(names.contains(&"builtin-tools".to_string()));
356 assert!(names.contains(&"delegate-task".to_string()));
357 assert!(names.contains(&"find-skills".to_string()));
358 }
359
360 #[test]
361 fn test_remove() {
362 let registry = SkillRegistry::with_builtins();
363 assert_eq!(registry.len(), 7);
364
365 let removed = registry.remove("code-search");
366 assert!(removed.is_some());
367 assert_eq!(registry.len(), 6);
368 assert!(registry.get("code-search").is_none());
369 }
370
371 #[test]
372 fn test_clear() {
373 let registry = SkillRegistry::with_builtins();
374 assert_eq!(registry.len(), 7);
375
376 registry.clear();
377 assert_eq!(registry.len(), 0);
378 assert!(registry.is_empty());
379 }
380
381 #[test]
382 fn test_by_kind() {
383 let registry = SkillRegistry::with_builtins();
384 let instruction_skills = registry.by_kind(SkillKind::Instruction);
385
386 assert_eq!(
387 instruction_skills.len(),
388 7,
389 "Expected 7 instruction skills (4 code assistance + 3 tool documentation)"
390 );
391
392 let tool_skills = registry.by_kind(SkillKind::Tool);
393 assert_eq!(tool_skills.len(), 0);
394 }
395
396 #[test]
397 fn test_by_tag() {
398 let registry = SkillRegistry::with_builtins();
399 let search_skills = registry.by_tag("search");
400
401 assert_eq!(search_skills.len(), 1);
402 assert_eq!(search_skills[0].name, "code-search");
403
404 let security_skills = registry.by_tag("security");
405 assert_eq!(security_skills.len(), 1);
406 assert_eq!(security_skills[0].name, "find-bugs");
407 }
408
409 #[test]
410 fn test_load_from_dir() -> anyhow::Result<()> {
411 let temp_dir = TempDir::new()?;
412
413 let skill_path = temp_dir.path().join("test-skill.md");
415 let mut file = std::fs::File::create(&skill_path)?;
416 writeln!(file, "---")?;
417 writeln!(file, "name: test-skill")?;
418 writeln!(file, "description: A test skill")?;
419 writeln!(file, "kind: instruction")?;
420 writeln!(file, "---")?;
421 writeln!(file, "# Test Skill")?;
422 writeln!(file, "This is a test skill.")?;
423 drop(file);
424
425 let readme_path = temp_dir.path().join("README.md");
427 std::fs::write(&readme_path, "# README\nNot a skill")?;
428
429 let txt_path = temp_dir.path().join("notes.txt");
431 std::fs::write(&txt_path, "Some notes")?;
432
433 let registry = SkillRegistry::new();
434 let loaded = registry.load_from_dir(temp_dir.path())?;
435
436 assert_eq!(loaded, 1);
437 assert_eq!(registry.len(), 1);
438 assert!(registry.get("test-skill").is_some());
439
440 Ok(())
441 }
442
443 #[test]
444 fn test_load_from_file() -> anyhow::Result<()> {
445 let temp_dir = TempDir::new()?;
446 let skill_path = temp_dir.path().join("my-skill.md");
447
448 let mut file = std::fs::File::create(&skill_path)?;
449 writeln!(file, "---")?;
450 writeln!(file, "name: my-skill")?;
451 writeln!(file, "description: My custom skill")?;
452 writeln!(file, "---")?;
453 writeln!(file, "# My Skill")?;
454 drop(file);
455
456 let registry = SkillRegistry::new();
457 let skill = registry.load_from_file(&skill_path)?;
458
459 assert_eq!(skill.name, "my-skill");
460 assert_eq!(registry.len(), 1);
461
462 Ok(())
463 }
464
465 #[test]
466 fn test_to_system_prompt() {
467 let registry = SkillRegistry::with_builtins();
468 let prompt = registry.to_system_prompt();
469
470 assert!(prompt.contains("# Available Skills"));
471 assert!(prompt.contains("code-search"));
472 assert!(prompt.contains("code-review"));
473 assert!(prompt.contains("explain-code"));
474 assert!(prompt.contains("find-bugs"));
475 }
476
477 #[test]
478 fn test_load_from_nonexistent_dir() {
479 let registry = SkillRegistry::new();
480 let result = registry.load_from_dir("/nonexistent/path");
481
482 assert!(result.is_ok());
483 assert_eq!(result.unwrap(), 0);
484 }
485
486 #[test]
489 fn test_register_with_validator_rejects_reserved() {
490 use crate::skills::validator::DefaultSkillValidator;
491
492 let registry = SkillRegistry::new();
493 registry.set_validator(Arc::new(DefaultSkillValidator::default()));
494
495 let skill = Arc::new(Skill {
496 name: "code-search".to_string(), description: "Override builtin".to_string(),
498 allowed_tools: None,
499 disable_model_invocation: false,
500 kind: SkillKind::Instruction,
501 content: "Malicious override".to_string(),
502 tags: vec![],
503 version: None,
504 });
505
506 let result = registry.register(skill);
507 assert!(result.is_err());
508 assert_eq!(registry.len(), 0);
509 }
510
511 #[test]
512 fn test_register_with_validator_accepts_valid() {
513 use crate::skills::validator::DefaultSkillValidator;
514
515 let registry = SkillRegistry::new();
516 registry.set_validator(Arc::new(DefaultSkillValidator::default()));
517
518 let skill = Arc::new(Skill {
519 name: "my-custom-skill".to_string(),
520 description: "A valid skill".to_string(),
521 allowed_tools: Some("read(*), grep(*)".to_string()),
522 disable_model_invocation: false,
523 kind: SkillKind::Instruction,
524 content: "Help with code review.".to_string(),
525 tags: vec![],
526 version: None,
527 });
528
529 assert!(registry.register(skill).is_ok());
530 assert_eq!(registry.len(), 1);
531 }
532
533 #[test]
534 fn test_register_without_validator_accepts_anything() {
535 let registry = SkillRegistry::new();
536 let skill = Arc::new(Skill {
539 name: "code-search".to_string(), description: "test".to_string(),
541 allowed_tools: None,
542 disable_model_invocation: false,
543 kind: SkillKind::Instruction,
544 content: "test".to_string(),
545 tags: vec![],
546 version: None,
547 });
548
549 assert!(registry.register(skill).is_ok());
550 }
551
552 #[test]
553 fn test_load_from_file_with_validator_rejects() {
554 use crate::skills::validator::DefaultSkillValidator;
555
556 let temp_dir = TempDir::new().unwrap();
557 let skill_path = temp_dir.path().join("code-search.md");
558
559 let mut file = std::fs::File::create(&skill_path).unwrap();
560 writeln!(file, "---").unwrap();
561 writeln!(file, "name: code-search").unwrap(); writeln!(file, "description: Override").unwrap();
563 writeln!(file, "---").unwrap();
564 writeln!(file, "# Override").unwrap();
565 drop(file);
566
567 let registry = SkillRegistry::new();
568 registry.set_validator(Arc::new(DefaultSkillValidator::default()));
569
570 let result = registry.load_from_file(&skill_path);
571 assert!(result.is_err());
572 assert_eq!(registry.len(), 0);
573 }
574
575 #[test]
578 fn test_to_system_prompt_skips_disabled_skills() {
579 use crate::skills::feedback::{DefaultSkillScorer, SkillFeedback, SkillOutcome};
580
581 let registry = SkillRegistry::new();
582 let scorer = Arc::new(DefaultSkillScorer::default());
583 registry.set_scorer(scorer.clone());
584
585 registry.register_unchecked(Arc::new(Skill {
587 name: "good-skill".to_string(),
588 description: "Good".to_string(),
589 allowed_tools: None,
590 disable_model_invocation: false,
591 kind: SkillKind::Instruction,
592 content: "Good instructions".to_string(),
593 tags: vec![],
594 version: None,
595 }));
596 registry.register_unchecked(Arc::new(Skill {
597 name: "bad-skill".to_string(),
598 description: "Bad".to_string(),
599 allowed_tools: None,
600 disable_model_invocation: false,
601 kind: SkillKind::Instruction,
602 content: "Bad instructions".to_string(),
603 tags: vec![],
604 version: None,
605 }));
606
607 for _ in 0..5 {
609 scorer.record(SkillFeedback {
610 skill_name: "bad-skill".to_string(),
611 outcome: SkillOutcome::Failure,
612 score_delta: -1.0,
613 reason: "Did not help".to_string(),
614 timestamp: 0,
615 });
616 }
617
618 let prompt = registry.to_system_prompt();
619 assert!(prompt.contains("good-skill"));
620 assert!(!prompt.contains("bad-skill"));
621 }
622
623 #[test]
624 fn test_to_system_prompt_without_scorer_includes_all() {
625 let registry = SkillRegistry::new();
626 registry.register_unchecked(Arc::new(Skill {
629 name: "skill-a".to_string(),
630 description: "A".to_string(),
631 allowed_tools: None,
632 disable_model_invocation: false,
633 kind: SkillKind::Instruction,
634 content: "Content A".to_string(),
635 tags: vec![],
636 version: None,
637 }));
638
639 let prompt = registry.to_system_prompt();
640 assert!(prompt.contains("skill-a"));
641 }
642}