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