1use crate::config::{SkillOverride, SkillsConfig};
7use crate::skill::{SkillEntry, SkillSource, parse_frontmatter};
8use anyhow::Result;
9use std::collections::HashMap;
10use std::path::Path;
11use tracing::warn;
12
13const RALPH_TOOLS_SKILL_RAW: &str = include_str!("../data/ralph-tools.md");
15
16const ROBOT_INTERACTION_SKILL_RAW: &str = include_str!("../data/robot-interaction-skill.md");
18
19pub struct SkillRegistry {
21 skills: HashMap<String, SkillEntry>,
23 active_backend: Option<String>,
25}
26
27impl SkillRegistry {
28 pub fn new(active_backend: Option<&str>) -> Self {
30 Self {
31 skills: HashMap::new(),
32 active_backend: active_backend.map(String::from),
33 }
34 }
35
36 pub fn register_builtin(&mut self, fallback_name: &str, raw_content: &str) -> Result<()> {
38 let (fm, content) = parse_frontmatter(raw_content);
39 let fm = fm.unwrap_or_default();
40
41 let name = fm.name.unwrap_or_else(|| fallback_name.to_string());
42 let description = fm.description.unwrap_or_default();
43
44 self.skills.insert(
45 name.clone(),
46 SkillEntry {
47 name,
48 description,
49 content,
50 source: SkillSource::BuiltIn,
51 hats: fm.hats,
52 backends: fm.backends,
53 tags: fm.tags,
54 auto_inject: false, },
56 );
57
58 Ok(())
59 }
60
61 fn register_builtins(&mut self) -> Result<()> {
63 self.register_builtin("ralph-tools", RALPH_TOOLS_SKILL_RAW)?;
64 self.register_builtin("robot-interaction", ROBOT_INTERACTION_SKILL_RAW)?;
65 Ok(())
66 }
67
68 pub fn scan_directory(&mut self, dir: &Path) -> Result<()> {
76 if !dir.exists() {
77 warn!("Skills directory does not exist: {}", dir.display());
78 return Ok(());
79 }
80
81 if !dir.is_dir() {
82 warn!("Skills path is not a directory: {}", dir.display());
83 return Ok(());
84 }
85
86 if let Ok(entries) = std::fs::read_dir(dir) {
88 for entry in entries.flatten() {
89 let path = entry.path();
90
91 if path.is_file() && path.extension().is_some_and(|e| e == "md") {
92 let fallback_name = path
93 .file_stem()
94 .and_then(|s| s.to_str())
95 .unwrap_or("unknown")
96 .to_string();
97 self.register_from_file(&path, &fallback_name)?;
98 } else if path.is_dir() {
99 let skill_file = path.join("SKILL.md");
101 if skill_file.exists() {
102 let fallback_name = path
103 .file_name()
104 .and_then(|s| s.to_str())
105 .unwrap_or("unknown")
106 .to_string();
107 self.register_from_file(&skill_file, &fallback_name)?;
108 }
109 }
110 }
111 }
112
113 Ok(())
114 }
115
116 fn register_from_file(&mut self, path: &Path, fallback_name: &str) -> Result<()> {
118 let raw = match std::fs::read_to_string(path) {
119 Ok(content) => content,
120 Err(e) => {
121 warn!("Failed to read skill file {}: {}", path.display(), e);
122 return Ok(());
123 }
124 };
125
126 let (fm, content) = parse_frontmatter(&raw);
127 let fm = fm.unwrap_or_default();
128
129 let name = fm.name.unwrap_or_else(|| fallback_name.to_string());
130 let description = fm.description.unwrap_or_default();
131
132 self.skills.insert(
133 name.clone(),
134 SkillEntry {
135 name,
136 description,
137 content,
138 source: SkillSource::File(path.to_path_buf()),
139 hats: fm.hats,
140 backends: fm.backends,
141 tags: fm.tags,
142 auto_inject: false,
143 },
144 );
145
146 Ok(())
147 }
148
149 fn apply_overrides(&mut self, overrides: &HashMap<String, SkillOverride>) {
151 let to_remove: Vec<String> = overrides
153 .iter()
154 .filter(|(_, o)| o.enabled == Some(false))
155 .map(|(name, _)| name.clone())
156 .collect();
157
158 for name in to_remove {
159 self.skills.remove(&name);
160 }
161
162 for (name, override_) in overrides {
164 if override_.enabled == Some(false) {
165 continue; }
167 if let Some(skill) = self.skills.get_mut(name) {
168 if !override_.hats.is_empty() {
169 skill.hats = override_.hats.clone();
170 }
171 if !override_.backends.is_empty() {
172 skill.backends = override_.backends.clone();
173 }
174 if !override_.tags.is_empty() {
175 skill.tags = override_.tags.clone();
176 }
177 if let Some(auto_inject) = override_.auto_inject {
178 skill.auto_inject = auto_inject;
179 }
180 }
181 }
182 }
183
184 pub fn from_config(
186 config: &SkillsConfig,
187 workspace_root: &Path,
188 active_backend: Option<&str>,
189 ) -> Result<Self> {
190 let mut registry = Self::new(active_backend);
191
192 registry.register_builtins()?;
194
195 for dir in &config.dirs {
197 let resolved = if dir.is_relative() {
198 workspace_root.join(dir)
199 } else {
200 dir.clone()
201 };
202 registry.scan_directory(&resolved)?;
203 }
204
205 registry.apply_overrides(&config.overrides);
207
208 Ok(registry)
209 }
210
211 pub fn get(&self, name: &str) -> Option<&SkillEntry> {
213 self.skills.get(name)
214 }
215
216 pub fn skills_for_hat(&self, hat_id: Option<&str>) -> Vec<&SkillEntry> {
218 self.skills
219 .values()
220 .filter(|s| self.is_visible(s, hat_id))
221 .collect()
222 }
223
224 pub fn auto_inject_skills(&self, hat_id: Option<&str>) -> Vec<&SkillEntry> {
226 self.skills
227 .values()
228 .filter(|s| s.auto_inject && self.is_visible(s, hat_id))
229 .collect()
230 }
231
232 fn is_visible(&self, skill: &SkillEntry, hat_id: Option<&str>) -> bool {
234 if !skill.backends.is_empty()
236 && let Some(ref backend) = self.active_backend
237 && !skill.backends.iter().any(|b| b == backend)
238 {
239 return false;
240 }
241
242 if !skill.hats.is_empty()
246 && let Some(hat) = hat_id
247 && !skill.hats.iter().any(|h| h == hat)
248 {
249 return false;
250 }
251
252 true
253 }
254
255 pub fn build_index(&self, hat_id: Option<&str>) -> String {
257 let visible: Vec<&SkillEntry> = self.skills_for_hat(hat_id);
258
259 if visible.is_empty() {
260 return String::new();
261 }
262
263 let mut index = String::from("## SKILLS\n\nAvailable skills you can load on demand:\n\n");
264 index.push_str("| Skill | Description | Load Command |\n");
265 index.push_str("|-------|-------------|-------------|\n");
266
267 let mut sorted: Vec<&&SkillEntry> = visible.iter().collect();
268 sorted.sort_by_key(|s| &s.name);
269
270 for skill in sorted {
271 index.push_str(&format!(
272 "| {} | {} | `ralph tools skill load {}` |\n",
273 skill.name, skill.description, skill.name
274 ));
275 }
276
277 index.push_str(
278 "\nTo load a skill, run the load command. The skill content will guide you.\n",
279 );
280 index
281 }
282
283 pub fn load_skill(&self, name: &str) -> Option<String> {
285 self.skills.get(name).map(|skill| {
286 format!(
287 "<{name}-skill>\n{content}\n</{name}-skill>",
288 name = skill.name,
289 content = skill.content
290 )
291 })
292 }
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298 use std::fs;
299 use tempfile::TempDir;
300
301 #[test]
302 fn test_register_builtin_skill() {
303 let mut registry = SkillRegistry::new(None);
304 registry
305 .register_builtin("ralph-tools", RALPH_TOOLS_SKILL_RAW)
306 .unwrap();
307
308 let skill = registry
310 .get("ralph-tools")
311 .expect("should find built-in skill");
312 assert!(matches!(skill.source, SkillSource::BuiltIn));
313 assert!(!skill.description.is_empty());
314 assert!(skill.content.contains("# Ralph Tools"));
315 assert!(!skill.content.contains("name: ralph-tools"));
317 }
318
319 #[test]
320 fn test_register_builtins() {
321 let mut registry = SkillRegistry::new(None);
322 registry.register_builtins().unwrap();
323
324 assert!(registry.get("ralph-tools").is_some());
326 assert!(registry.get("robot-interaction").is_some());
327 }
328
329 #[test]
330 fn test_get_returns_none_for_unknown() {
331 let registry = SkillRegistry::new(None);
332 assert!(registry.get("nonexistent").is_none());
333 }
334
335 #[test]
336 fn test_scan_directory_discovers_md_files() {
337 let tmp = TempDir::new().unwrap();
338 let skill_dir = tmp.path().join("skills");
339 fs::create_dir(&skill_dir).unwrap();
340
341 fs::write(
342 skill_dir.join("test-skill.md"),
343 "---\nname: test-skill\ndescription: A test skill\n---\n\nTest content.\n",
344 )
345 .unwrap();
346
347 let mut registry = SkillRegistry::new(None);
348 registry.scan_directory(&skill_dir).unwrap();
349
350 let skill = registry
351 .get("test-skill")
352 .expect("should find scanned skill");
353 assert!(matches!(skill.source, SkillSource::File(_)));
354 assert_eq!(skill.description, "A test skill");
355 assert!(skill.content.contains("Test content."));
356 }
357
358 #[test]
359 fn test_scan_directory_discovers_skill_md_subdirs() {
360 let tmp = TempDir::new().unwrap();
361 let skill_dir = tmp.path().join("skills");
362 let sub_dir = skill_dir.join("my-complex-skill");
363 fs::create_dir_all(&sub_dir).unwrap();
364
365 fs::write(
366 sub_dir.join("SKILL.md"),
367 "---\nname: my-complex-skill\ndescription: Complex skill\n---\n\nComplex content.\n",
368 )
369 .unwrap();
370
371 let mut registry = SkillRegistry::new(None);
372 registry.scan_directory(&skill_dir).unwrap();
373
374 let skill = registry
375 .get("my-complex-skill")
376 .expect("should find subdir skill");
377 assert_eq!(skill.description, "Complex skill");
378 }
379
380 #[test]
381 fn test_user_skill_overrides_builtin() {
382 let tmp = TempDir::new().unwrap();
383 let skill_dir = tmp.path().join("skills");
384 fs::create_dir(&skill_dir).unwrap();
385
386 fs::write(
388 skill_dir.join("ralph-tools.md"),
389 "---\nname: ralph-tools\ndescription: Custom tools skill\n---\n\nCustom content.\n",
390 )
391 .unwrap();
392
393 let mut registry = SkillRegistry::new(None);
394 registry.register_builtins().unwrap();
395 registry.scan_directory(&skill_dir).unwrap();
396
397 let skill = registry.get("ralph-tools").unwrap();
398 assert!(matches!(skill.source, SkillSource::File(_)));
399 assert_eq!(skill.description, "Custom tools skill");
400 }
401
402 #[test]
403 fn test_missing_directory_warns_but_no_error() {
404 let mut registry = SkillRegistry::new(None);
405 let result = registry.scan_directory(Path::new("/nonexistent/path"));
406 assert!(result.is_ok());
407 }
408
409 #[test]
410 fn test_skill_name_from_frontmatter_takes_precedence() {
411 let tmp = TempDir::new().unwrap();
412 let skill_dir = tmp.path().join("skills");
413 fs::create_dir(&skill_dir).unwrap();
414
415 fs::write(
417 skill_dir.join("file-name.md"),
418 "---\nname: frontmatter-name\ndescription: Test\n---\n\nContent.\n",
419 )
420 .unwrap();
421
422 let mut registry = SkillRegistry::new(None);
423 registry.scan_directory(&skill_dir).unwrap();
424
425 assert!(registry.get("file-name").is_none());
426 assert!(registry.get("frontmatter-name").is_some());
427 }
428
429 #[test]
430 fn test_override_disables_skill() {
431 let mut registry = SkillRegistry::new(None);
432 registry.register_builtins().unwrap();
433 assert!(registry.get("ralph-tools").is_some());
434
435 let mut overrides = HashMap::new();
436 overrides.insert(
437 "ralph-tools".to_string(),
438 SkillOverride {
439 enabled: Some(false),
440 ..Default::default()
441 },
442 );
443 registry.apply_overrides(&overrides);
444
445 assert!(registry.get("ralph-tools").is_none());
446 }
447
448 #[test]
449 fn test_override_adds_hat_restriction() {
450 let mut registry = SkillRegistry::new(None);
451 registry.register_builtins().unwrap();
452
453 let mut overrides = HashMap::new();
454 overrides.insert(
455 "ralph-tools".to_string(),
456 SkillOverride {
457 hats: vec!["builder".to_string()],
458 ..Default::default()
459 },
460 );
461 registry.apply_overrides(&overrides);
462
463 let skill = registry.get("ralph-tools").unwrap();
464 assert_eq!(skill.hats, vec!["builder"]);
465 }
466
467 #[test]
468 fn test_override_sets_auto_inject() {
469 let mut registry = SkillRegistry::new(None);
470 registry.register_builtins().unwrap();
471
472 let mut overrides = HashMap::new();
473 overrides.insert(
474 "ralph-tools".to_string(),
475 SkillOverride {
476 auto_inject: Some(true),
477 ..Default::default()
478 },
479 );
480 registry.apply_overrides(&overrides);
481
482 let skill = registry.get("ralph-tools").unwrap();
483 assert!(skill.auto_inject);
484 }
485
486 #[test]
487 fn test_backend_filtering() {
488 let mut registry = SkillRegistry::new(Some("claude"));
489 registry
490 .register_builtin(
491 "claude-only",
492 "---\nname: claude-only\ndescription: Claude\nbackends: [claude]\n---\nContent.\n",
493 )
494 .unwrap();
495 registry
496 .register_builtin(
497 "gemini-only",
498 "---\nname: gemini-only\ndescription: Gemini\nbackends: [gemini]\n---\nContent.\n",
499 )
500 .unwrap();
501 registry
502 .register_builtin(
503 "any-backend",
504 "---\nname: any-backend\ndescription: Any\n---\nContent.\n",
505 )
506 .unwrap();
507
508 let visible = registry.skills_for_hat(None);
509 let names: Vec<&str> = visible.iter().map(|s| s.name.as_str()).collect();
510 assert!(names.contains(&"claude-only"));
511 assert!(!names.contains(&"gemini-only"));
512 assert!(names.contains(&"any-backend"));
513 }
514
515 #[test]
516 fn test_hat_filtering() {
517 let mut registry = SkillRegistry::new(None);
518 registry
519 .register_builtin(
520 "builder-only",
521 "---\nname: builder-only\ndescription: Builder\nhats: [builder]\n---\nContent.\n",
522 )
523 .unwrap();
524 registry
525 .register_builtin(
526 "all-hats",
527 "---\nname: all-hats\ndescription: All\n---\nContent.\n",
528 )
529 .unwrap();
530
531 let builder_skills = registry.skills_for_hat(Some("builder"));
532 let builder_names: Vec<&str> = builder_skills.iter().map(|s| s.name.as_str()).collect();
533 assert!(builder_names.contains(&"builder-only"));
534 assert!(builder_names.contains(&"all-hats"));
535
536 let reviewer_skills = registry.skills_for_hat(Some("reviewer"));
537 let reviewer_names: Vec<&str> = reviewer_skills.iter().map(|s| s.name.as_str()).collect();
538 assert!(!reviewer_names.contains(&"builder-only"));
539 assert!(reviewer_names.contains(&"all-hats"));
540 }
541
542 #[test]
543 fn test_auto_inject_skills_only_returns_auto_inject() {
544 let mut registry = SkillRegistry::new(None);
545 registry.register_builtins().unwrap();
546
547 let auto = registry.auto_inject_skills(None);
549 assert!(auto.is_empty());
550
551 let mut overrides = HashMap::new();
553 overrides.insert(
554 "ralph-tools".to_string(),
555 SkillOverride {
556 auto_inject: Some(true),
557 ..Default::default()
558 },
559 );
560 registry.apply_overrides(&overrides);
561
562 let auto = registry.auto_inject_skills(None);
563 assert_eq!(auto.len(), 1);
564 assert_eq!(auto[0].name, "ralph-tools");
565 }
566
567 #[test]
568 fn test_build_index_generates_table() {
569 let mut registry = SkillRegistry::new(None);
570 registry.register_builtins().unwrap();
571
572 let index = registry.build_index(None);
573 assert!(index.contains("## SKILLS"));
574 assert!(index.contains("| Skill | Description | Load Command |"));
575 assert!(index.contains("ralph-tools"));
576 assert!(index.contains("robot-interaction"));
577 assert!(index.contains("`ralph tools skill load"));
578 }
579
580 #[test]
581 fn test_build_index_empty_registry() {
582 let registry = SkillRegistry::new(None);
583 let index = registry.build_index(None);
584 assert!(index.is_empty());
585 }
586
587 #[test]
588 fn test_build_index_hat_filtering() {
589 let mut registry = SkillRegistry::new(None);
590 registry
591 .register_builtin(
592 "builder-only",
593 "---\nname: builder-only\ndescription: Builder\nhats: [builder]\n---\nContent.\n",
594 )
595 .unwrap();
596 registry
597 .register_builtin(
598 "all-hats",
599 "---\nname: all-hats\ndescription: All\n---\nContent.\n",
600 )
601 .unwrap();
602
603 let builder_index = registry.build_index(Some("builder"));
604 assert!(builder_index.contains("builder-only"));
605 assert!(builder_index.contains("all-hats"));
606
607 let reviewer_index = registry.build_index(Some("reviewer"));
608 assert!(!reviewer_index.contains("builder-only"));
609 assert!(reviewer_index.contains("all-hats"));
610 }
611
612 #[test]
613 fn test_load_skill_xml_wrapping() {
614 let mut registry = SkillRegistry::new(None);
615 registry.register_builtins().unwrap();
616
617 let loaded = registry
618 .load_skill("ralph-tools")
619 .expect("should load skill");
620 assert!(loaded.starts_with("<ralph-tools-skill>"));
621 assert!(loaded.ends_with("</ralph-tools-skill>"));
622 assert!(loaded.contains("# Ralph Tools"));
623 assert!(!loaded.contains("name: ralph-tools"));
625 }
626
627 #[test]
628 fn test_load_skill_unknown() {
629 let registry = SkillRegistry::new(None);
630 assert!(registry.load_skill("nonexistent").is_none());
631 }
632
633 #[test]
634 fn test_from_config_full_pipeline() {
635 let tmp = TempDir::new().unwrap();
636 let skill_dir = tmp.path().join("skills");
637 fs::create_dir(&skill_dir).unwrap();
638
639 fs::write(
640 skill_dir.join("custom.md"),
641 "---\nname: custom\ndescription: Custom skill\n---\nCustom content.\n",
642 )
643 .unwrap();
644
645 let config = SkillsConfig {
646 enabled: true,
647 dirs: vec![skill_dir.clone()],
648 overrides: {
649 let mut m = HashMap::new();
650 m.insert(
651 "ralph-tools".to_string(),
652 SkillOverride {
653 auto_inject: Some(true),
654 ..Default::default()
655 },
656 );
657 m
658 },
659 };
660
661 let registry = SkillRegistry::from_config(&config, tmp.path(), Some("claude")).unwrap();
662
663 assert!(registry.get("ralph-tools").is_some());
665 assert!(registry.get("custom").is_some());
667 assert!(registry.get("ralph-tools").unwrap().auto_inject);
669 }
670}