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;
11use tracing::warn;
12
13/// Built-in ralph-tools skill content (tasks + memories).
14const RALPH_TOOLS_SKILL_RAW: &str = include_str!("../data/ralph-tools.md");
15
16/// Built-in RObot interaction skill content.
17const ROBOT_INTERACTION_SKILL_RAW: &str = include_str!("../data/robot-interaction-skill.md");
18
19/// Registry of all available skills for the current loop.
20pub struct SkillRegistry {
21    /// All skills indexed by name.
22    skills: HashMap<String, SkillEntry>,
23    /// The active backend name (for filtering).
24    active_backend: Option<String>,
25}
26
27impl SkillRegistry {
28    /// Create a new empty registry.
29    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    /// Register a built-in skill from raw content (with frontmatter).
37    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, // Built-ins default to false; overridden by config
55            },
56        );
57
58        Ok(())
59    }
60
61    /// Register built-in skills (ralph-tools and robot-interaction).
62    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    /// Scan a directory for skill files and register them.
69    ///
70    /// Discovers two patterns:
71    /// - `dir/*.md` — single-file skills (name from filename stem)
72    /// - `dir/*/SKILL.md` — directory-based skills (name from parent dir)
73    ///
74    /// User skills with the same name as built-in skills replace them.
75    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        // Scan for *.md files directly in the directory
87        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                    // Check for SKILL.md inside subdirectory
100                    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    /// Register a skill from a file path.
117    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    /// Apply config overrides to registered skills.
150    fn apply_overrides(&mut self, overrides: &HashMap<String, SkillOverride>) {
151        // Collect names to remove first (to avoid borrow conflicts)
152        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        // Apply remaining overrides
163        for (name, override_) in overrides {
164            if override_.enabled == Some(false) {
165                continue; // Already removed
166            }
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    /// Construct a fully-populated registry from config.
185    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        // 1. Register built-in skills
193        registry.register_builtins()?;
194
195        // 2. Scan configured directories
196        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        // 3. Apply config overrides
206        registry.apply_overrides(&config.overrides);
207
208        Ok(registry)
209    }
210
211    /// Get a skill by name.
212    pub fn get(&self, name: &str) -> Option<&SkillEntry> {
213        self.skills.get(name)
214    }
215
216    /// Get all skills visible to a specific hat (filtered by hat + backend).
217    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    /// Get all auto-inject skills (filtered by hat + backend).
225    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    /// Check if a skill is visible given the current hat and backend.
233    fn is_visible(&self, skill: &SkillEntry, hat_id: Option<&str>) -> bool {
234        // Backend filtering
235        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        // Hat filtering: if skill is restricted to specific hats, filter by hat.
243        // If no hat specified but skill has hat restriction, still show it
244        // (solo mode has no explicit hat).
245        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    /// Build the compact skill index for prompt injection.
256    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    /// Get skill content wrapped in XML tags for CLI output.
284    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        // The ralph-tools.md has name: ralph-tools in frontmatter
309        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        // Frontmatter fields should not be in content
316        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        // All built-in skills should be registered
325        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        // User skill with same name as built-in
387        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        // Filename is "file-name.md" but frontmatter says name is "frontmatter-name"
416        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        // No auto-inject skills by default
548        let auto = registry.auto_inject_skills(None);
549        assert!(auto.is_empty());
550
551        // Set ralph-tools to auto-inject
552        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        // Frontmatter should not be in the output
624        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        // Built-ins present
664        assert!(registry.get("ralph-tools").is_some());
665        // User skill present
666        assert!(registry.get("custom").is_some());
667        // Override applied
668        assert!(registry.get("ralph-tools").unwrap().auto_inject);
669    }
670}