Skip to main content

ralph_core/
skill_registry.rs

1//! Skill registry for discovering, storing, and providing access to skills.
2//!
3//! The registry manages both built-in skills (compiled into the binary) and
4//! user-defined skills (discovered from configured directories).
5
6use 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
13/// Built-in ralph-tools skill content (shared: interact, skill, output format commands).
14const RALPH_TOOLS_SKILL_RAW: &str = include_str!("../data/ralph-tools.md");
15
16/// Built-in ralph-tools-tasks skill content (task commands and workflows).
17const RALPH_TOOLS_TASKS_SKILL_RAW: &str = include_str!("../data/ralph-tools-tasks.md");
18
19/// Built-in ralph-tools-memories skill content (memory commands, decision journal, workflows).
20const RALPH_TOOLS_MEMORIES_SKILL_RAW: &str = include_str!("../data/ralph-tools-memories.md");
21
22/// Built-in RObot interaction skill content.
23const ROBOT_INTERACTION_SKILL_RAW: &str = include_str!("../data/robot-interaction-skill.md");
24
25/// Registry of all available skills for the current loop.
26pub struct SkillRegistry {
27    /// All skills indexed by name.
28    skills: HashMap<String, SkillEntry>,
29    /// The active backend name (for filtering).
30    active_backend: Option<String>,
31}
32
33impl SkillRegistry {
34    /// Create a new empty registry.
35    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    /// Register a built-in skill from raw content (with frontmatter).
43    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, // Built-ins default to false; overridden by config
61            },
62        );
63
64        Ok(())
65    }
66
67    /// Register built-in skills (ralph-tools, ralph-tools-tasks, ralph-tools-memories, robot-interaction).
68    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    /// Scan a directory for skill files and register them.
77    ///
78    /// Discovers two patterns:
79    /// - `dir/*.md` — single-file skills (name from filename stem)
80    /// - `dir/*/SKILL.md` — directory-based skills (name from parent dir)
81    ///
82    /// User skills with the same name as built-in skills replace them.
83    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        // Scan for *.md files directly in the directory
95        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                    // Check for SKILL.md inside subdirectory
108                    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    /// Register a skill from a file path.
125    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    /// Apply config overrides to registered skills.
158    fn apply_overrides(&mut self, overrides: &HashMap<String, SkillOverride>) {
159        // Collect names to remove first (to avoid borrow conflicts)
160        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        // Apply remaining overrides
171        for (name, override_) in overrides {
172            if override_.enabled == Some(false) {
173                continue; // Already removed
174            }
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    /// Construct a fully-populated registry from config.
193    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        // 1. Register built-in skills
201        registry.register_builtins()?;
202
203        // 2. Scan configured directories
204        for dir in &config.dirs {
205            let resolved = Self::resolve_skill_dir(workspace_root, dir);
206            registry.scan_directory(&resolved)?;
207        }
208
209        // 3. Apply config overrides
210        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    /// Remove a skill by name.
238    pub fn remove(&mut self, name: &str) {
239        self.skills.remove(name);
240    }
241
242    /// Get a skill by name.
243    pub fn get(&self, name: &str) -> Option<&SkillEntry> {
244        self.skills.get(name)
245    }
246
247    /// Get all skills visible to a specific hat (filtered by hat + backend).
248    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    /// Get all auto-inject skills (filtered by hat + backend).
256    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    /// Check if a skill is visible given the current hat and backend.
264    fn is_visible(&self, skill: &SkillEntry, hat_id: Option<&str>) -> bool {
265        // Backend filtering
266        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        // Hat filtering: if skill is restricted to specific hats, filter by hat.
274        // If no hat specified but skill has hat restriction, still show it
275        // (solo mode has no explicit hat).
276        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    /// Build the compact skill index for prompt injection.
287    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    /// Get skill content wrapped in XML tags for CLI output.
315    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        // The ralph-tools.md has name: ralph-tools in frontmatter
340        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        // Frontmatter fields should not be in content
347        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        // All built-in skills should be registered
356        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        // User skill with same name as built-in
420        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        // Filename is "file-name.md" but frontmatter says name is "frontmatter-name"
449        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        // No auto-inject skills by default
581        let auto = registry.auto_inject_skills(None);
582        assert!(auto.is_empty());
583
584        // Set ralph-tools to auto-inject
585        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        // Frontmatter should not be in the output
657        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        // Built-ins present
697        assert!(registry.get("ralph-tools").is_some());
698        // User skill present
699        assert!(registry.get("custom").is_some());
700        // Override applied
701        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}