Skip to main content

talos_skill/
loader.rs

1use crate::parser::{split_frontmatter, validate_frontmatter};
2use crate::{Result, Skill, SkillError, SkillFrontmatter, SkillIndex, estimate_tokens};
3use std::path::{Path, PathBuf};
4use walkdir::WalkDir;
5
6/// Discovers and loads skills from configured search paths.
7///
8/// # Examples
9///
10/// ```no_run
11/// use talos_skill::SkillLoader;
12///
13/// let mut loader = SkillLoader::new();
14/// let skills = loader.discover().expect("failed to discover skills");
15/// let index = loader.get_index();
16/// ```
17pub struct SkillLoader {
18    /// All discovered skills.
19    pub skills: Vec<Skill>,
20    /// Directories to search for SKILL.md files.
21    pub search_paths: Vec<PathBuf>,
22}
23
24impl SkillLoader {
25    /// Creates a new `SkillLoader` with default search paths.
26    ///
27    /// Default paths (in priority order):
28    /// 1. `.talos/skills/` relative to the current directory (project-local)
29    /// 2. `~/.talos/skills/` (user-global)
30    /// 3. Parent directories up to git root, each with `.talos/skills/`
31    pub fn new() -> Self {
32        let cwd = std::env::current_dir().ok();
33        Self {
34            skills: Vec::new(),
35            search_paths: default_search_paths(cwd.as_deref()),
36        }
37    }
38
39    /// Creates a new loader with search paths rooted at a specific workspace.
40    ///
41    /// Use this from runtime session startup instead of [`SkillLoader::new`]
42    /// when the process current directory may differ from the active session
43    /// workspace.
44    pub fn for_workspace(workspace_root: impl AsRef<Path>) -> Self {
45        Self {
46            skills: Vec::new(),
47            search_paths: default_search_paths(Some(workspace_root.as_ref())),
48        }
49    }
50
51    /// Scans all search paths for SKILL.md files and parses them.
52    ///
53    /// Returns a vector of all successfully parsed skills. Files that fail to
54    /// parse are silently skipped (errors are logged but not propagated).
55    pub fn discover(&mut self) -> Result<&Vec<Skill>> {
56        self.skills.clear();
57
58        for path in &self.search_paths {
59            if !path.is_dir() {
60                continue;
61            }
62
63            for entry in WalkDir::new(path)
64                .follow_links(false)
65                .into_iter()
66                .filter_map(|e| e.ok())
67            {
68                let entry_path = entry.path();
69                if entry_path.file_name() == Some(std::ffi::OsStr::new("SKILL.md")) {
70                    match Self::parse(entry_path) {
71                        Ok(skill) => self.skills.push(skill),
72                        Err(e) => {
73                            let _ = e;
74                        }
75                    }
76                }
77            }
78        }
79
80        self.skills.dedup_by_key(|s| s.name.clone());
81
82        Ok(&self.skills)
83    }
84
85    /// Parses a single SKILL.md file into a [`Skill`].
86    ///
87    /// The file must start with `---`, followed by YAML frontmatter, then `---`,
88    /// then the Markdown body.
89    ///
90    /// # Errors
91    ///
92    /// Returns [`SkillError::FileNotFound`] if the path does not exist,
93    /// [`SkillError::YamlParseError`] if the frontmatter is invalid YAML,
94    /// or [`SkillError::InvalidFrontmatter`] if required fields are missing.
95    pub fn parse(path: &Path) -> Result<Skill> {
96        if !path.exists() {
97            return Err(SkillError::FileNotFound(path.to_path_buf()));
98        }
99
100        let content = std::fs::read_to_string(path)?;
101        let (frontmatter, body) = split_frontmatter(&content)?;
102        let fm: SkillFrontmatter = serde_yaml::from_str(frontmatter)?;
103
104        validate_frontmatter(&fm)?;
105
106        Ok(Skill {
107            name: fm.name,
108            description: fm.description,
109            triggers: fm.triggers,
110            body: body.trim().to_string(),
111            source_path: path.to_path_buf(),
112        })
113    }
114
115    /// Returns a lightweight index of all loaded skills.
116    ///
117    /// Use this for Level 0 progressive disclosure — injecting skill names
118    /// and descriptions into the system prompt without loading full bodies.
119    pub fn get_index(&self) -> Vec<SkillIndex> {
120        self.skills
121            .iter()
122            .map(|s| {
123                let level0_text = format!("{}: {}", s.name, s.description);
124                SkillIndex {
125                    name: s.name.clone(),
126                    description: s.description.clone(),
127                    triggers: s.triggers.clone(),
128                    estimated_tokens: estimate_tokens(&level0_text),
129                }
130            })
131            .collect()
132    }
133}
134
135impl Default for SkillLoader {
136    fn default() -> Self {
137        Self::new()
138    }
139}
140
141fn home_dir() -> Option<PathBuf> {
142    #[cfg(target_os = "windows")]
143    {
144        std::env::var("USERPROFILE").ok().map(PathBuf::from)
145    }
146    #[cfg(not(target_os = "windows"))]
147    {
148        std::env::var("HOME").ok().map(PathBuf::from)
149    }
150}
151
152fn default_search_paths(workspace_root: Option<&Path>) -> Vec<PathBuf> {
153    let mut search_paths = Vec::new();
154
155    if let Some(root) = workspace_root {
156        push_if_dir(&mut search_paths, root.join(".talos/skills"));
157    }
158
159    if let Some(home) = home_dir() {
160        push_if_dir(&mut search_paths, home.join(".talos/skills"));
161    }
162
163    if let Some(root) = workspace_root {
164        let mut current = root;
165        while let Some(parent) = current.parent() {
166            let git_dir = parent.join(".git");
167            push_if_dir(&mut search_paths, parent.join(".talos/skills"));
168            current = parent;
169            if git_dir.is_dir() {
170                break;
171            }
172        }
173    }
174
175    search_paths
176}
177
178fn push_if_dir(paths: &mut Vec<PathBuf>, path: PathBuf) {
179    if path.is_dir() && !paths.iter().any(|existing| existing == &path) {
180        paths.push(path);
181    }
182}