Skip to main content

talos_skill/
loader.rs

1use crate::parser::{split_frontmatter, validate_frontmatter};
2use crate::{
3    Result, Skill, SkillError, SkillFrontmatter, SkillIndex, SkillSource, estimate_tokens,
4};
5use std::collections::HashSet;
6use std::path::{Path, PathBuf};
7use walkdir::WalkDir;
8
9const DEFAULT_MAX_SKILL_DISCOVERY_DEPTH: usize = 32;
10const DEFAULT_MAX_SKILL_DISCOVERY_ENTRIES: usize = 10_000;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum ExternalTargetPolicy {
14    DenyOutsideSearchRoot,
15    AllowAnyReadable,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct SkillDiscoveryPolicy {
20    pub follow_directory_links: bool,
21    pub external_target_policy: ExternalTargetPolicy,
22    pub max_depth: usize,
23    pub max_entries: usize,
24}
25
26impl Default for SkillDiscoveryPolicy {
27    fn default() -> Self {
28        Self {
29            follow_directory_links: false,
30            external_target_policy: ExternalTargetPolicy::DenyOutsideSearchRoot,
31            max_depth: DEFAULT_MAX_SKILL_DISCOVERY_DEPTH,
32            max_entries: DEFAULT_MAX_SKILL_DISCOVERY_ENTRIES,
33        }
34    }
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum SkillDiscoveryWarningKind {
39    BrokenLink,
40    LinkLoop,
41    PermissionDenied,
42    ExternalTargetDenied,
43    RootLinkDenied,
44    CanonicalizeFailed,
45    DepthLimitReached,
46    EntryBudgetReached,
47    InvalidSkill,
48    Io,
49}
50
51#[derive(Debug, Clone)]
52pub struct SkillDiscoveryWarning {
53    pub kind: SkillDiscoveryWarningKind,
54    pub path: PathBuf,
55    pub message: String,
56}
57
58pub struct SkillLoader {
59    pub skills: Vec<Skill>,
60    pub search_paths: Vec<PathBuf>,
61    pub discover_shared: bool,
62    pub workspace_root: Option<PathBuf>,
63    pub discovery_policy: SkillDiscoveryPolicy,
64    pub discovery_warnings: Vec<SkillDiscoveryWarning>,
65}
66
67impl SkillLoader {
68    pub fn new() -> Self {
69        let cwd = std::env::current_dir().ok();
70        let home = home_dir();
71        Self {
72            skills: Vec::new(),
73            search_paths: default_search_paths(cwd.as_deref(), home.as_deref(), false),
74            discover_shared: false,
75            workspace_root: cwd.map(|p| p.to_path_buf()),
76            discovery_policy: SkillDiscoveryPolicy::default(),
77            discovery_warnings: Vec::new(),
78        }
79    }
80
81    pub fn for_workspace(workspace_root: impl AsRef<Path>) -> Self {
82        Self::for_workspace_with_options(workspace_root.as_ref(), false)
83    }
84
85    pub fn for_workspace_with_options(
86        workspace_root: impl AsRef<Path>,
87        discover_shared: bool,
88    ) -> Self {
89        Self::for_workspace_with_home_and_options(workspace_root, home_dir(), discover_shared)
90    }
91
92    /// Constructs a loader with an explicit home directory for test injection.
93    ///
94    /// When `home` is `None`, neither user-global nor shared roots are added.
95    /// When `home` is `Some(h)`, user-global (`h/.talos/skills`) and — if
96    /// `discover_shared` is true — shared (`h/.agents/skills`) roots are added.
97    /// This constructor never reads the `HOME` environment variable.
98    pub fn for_workspace_with_home_and_options(
99        workspace_root: impl AsRef<Path>,
100        home: Option<PathBuf>,
101        discover_shared: bool,
102    ) -> Self {
103        let root = workspace_root.as_ref();
104        Self {
105            skills: Vec::new(),
106            search_paths: default_search_paths(Some(root), home.as_deref(), discover_shared),
107            discover_shared,
108            workspace_root: Some(root.to_path_buf()),
109            discovery_policy: SkillDiscoveryPolicy::default(),
110            discovery_warnings: Vec::new(),
111        }
112    }
113
114    pub fn for_workspace_with_discovery_policy(
115        workspace_root: impl AsRef<Path>,
116        discover_shared: bool,
117        policy: SkillDiscoveryPolicy,
118    ) -> Self {
119        let root = workspace_root.as_ref();
120        let home = home_dir();
121        Self {
122            skills: Vec::new(),
123            search_paths: default_search_paths(Some(root), home.as_deref(), discover_shared),
124            discover_shared,
125            workspace_root: Some(root.to_path_buf()),
126            discovery_policy: policy,
127            discovery_warnings: Vec::new(),
128        }
129    }
130
131    pub fn discovery_warnings(&self) -> &[SkillDiscoveryWarning] {
132        &self.discovery_warnings
133    }
134
135    pub fn discover(&mut self) -> Result<&Vec<Skill>> {
136        self.skills.clear();
137        self.discovery_warnings.clear();
138
139        let mut seen_skill_names: HashSet<String> = HashSet::new();
140        let mut seen_canonical_dirs: HashSet<PathBuf> = HashSet::new();
141        let mut seen_canonical_files: HashSet<PathBuf> = HashSet::new();
142        let mut entry_count: usize = 0;
143
144        for search_root in &self.search_paths {
145            if !search_root.is_dir() {
146                continue;
147            }
148
149            let follow = self.discovery_policy.follow_directory_links;
150            let external_policy = self.discovery_policy.external_target_policy.clone();
151            let max_depth = self.discovery_policy.max_depth;
152            let max_entries = self.discovery_policy.max_entries;
153
154            let root_is_symlink = std::fs::symlink_metadata(search_root)
155                .map(|m| m.file_type().is_symlink())
156                .unwrap_or(false);
157
158            if root_is_symlink {
159                let allowed = follow && external_policy == ExternalTargetPolicy::AllowAnyReadable;
160                if !allowed {
161                    self.discovery_warnings.push(SkillDiscoveryWarning {
162                        kind: SkillDiscoveryWarningKind::RootLinkDenied,
163                        path: search_root.clone(),
164                        message: if !follow {
165                            "search root itself is a symbolic link and link following is disabled"
166                                .to_string()
167                        } else {
168                            "search root itself is a symbolic link; DenyOutsideSearchRoot cannot prove the canonical target is inside the logical root".to_string()
169                        },
170                    });
171                    continue;
172                }
173            }
174
175            let root_canonical = match search_root.canonicalize() {
176                Ok(c) => c,
177                Err(_) => search_root.clone(),
178            };
179            if !seen_canonical_dirs.insert(root_canonical.clone()) {
180                continue;
181            }
182
183            let source = self.classify_source(search_root);
184            let observation_depth = max_depth.saturating_add(1);
185            let mut depth_warning_emitted = false;
186            let mut dir_warnings: Vec<SkillDiscoveryWarning> = Vec::new();
187
188            let walk_root = if root_is_symlink {
189                root_canonical.clone()
190            } else {
191                search_root.clone()
192            };
193            let walker = WalkDir::new(&walk_root)
194                .follow_links(follow)
195                .follow_root_links(false)
196                .max_depth(observation_depth)
197                .sort_by_file_name()
198                .into_iter()
199                .filter_entry(|entry| {
200                    if entry.depth() == 0 {
201                        return true;
202                    }
203                    if !entry.file_type().is_dir() {
204                        return true;
205                    }
206                    let path = entry.path();
207                    let canon = match path.canonicalize() {
208                        Ok(c) => c,
209                        Err(e) => {
210                            dir_warnings.push(SkillDiscoveryWarning {
211                                kind: SkillDiscoveryWarningKind::CanonicalizeFailed,
212                                path: path.to_path_buf(),
213                                message: e.to_string(),
214                            });
215                            return false;
216                        }
217                    };
218                    if !is_target_allowed(&canon, &root_canonical, &external_policy) {
219                        dir_warnings.push(SkillDiscoveryWarning {
220                            kind: SkillDiscoveryWarningKind::ExternalTargetDenied,
221                            path: path.to_path_buf(),
222                            message: format!(
223                                "target {canon:?} is outside search root {root_canonical:?}"
224                            ),
225                        });
226                        return false;
227                    }
228                    if !seen_canonical_dirs.insert(canon) {
229                        return false;
230                    }
231                    true
232                });
233
234            for result in walker {
235                entry_count += 1;
236                if entry_count > max_entries {
237                    self.discovery_warnings.push(SkillDiscoveryWarning {
238                        kind: SkillDiscoveryWarningKind::EntryBudgetReached,
239                        path: search_root.clone(),
240                        message: format!(
241                            "entry budget {max_entries} reached; all remaining roots skipped"
242                        ),
243                    });
244                    self.discovery_warnings.append(&mut dir_warnings);
245                    return Ok(&self.skills);
246                }
247
248                let entry = match result {
249                    Ok(e) => e,
250                    Err(e) => {
251                        let kind = classify_walk_error(&e);
252                        let path = e.path().map(|p| p.to_path_buf()).unwrap_or_default();
253                        self.discovery_warnings.push(SkillDiscoveryWarning {
254                            kind,
255                            path,
256                            message: e.to_string(),
257                        });
258                        continue;
259                    }
260                };
261
262                if entry.depth() > max_depth {
263                    if !depth_warning_emitted {
264                        self.discovery_warnings.push(SkillDiscoveryWarning {
265                            kind: SkillDiscoveryWarningKind::DepthLimitReached,
266                            path: entry.path().to_path_buf(),
267                            message: format!(
268                                "max_depth {max_depth} reached; deeper entries truncated"
269                            ),
270                        });
271                        depth_warning_emitted = true;
272                    }
273                    continue;
274                }
275
276                let entry_path = entry.path();
277                if entry_path.file_name() != Some(std::ffi::OsStr::new("SKILL.md")) {
278                    continue;
279                }
280
281                if !follow && entry.file_type().is_symlink() {
282                    self.discovery_warnings.push(SkillDiscoveryWarning {
283                        kind: SkillDiscoveryWarningKind::RootLinkDenied,
284                        path: entry_path.to_path_buf(),
285                        message: "SKILL.md is a symbolic link and link following is disabled"
286                            .to_string(),
287                    });
288                    continue;
289                }
290
291                if follow {
292                    let canon_file = match entry_path.canonicalize() {
293                        Ok(c) => c,
294                        Err(e) => {
295                            self.discovery_warnings.push(SkillDiscoveryWarning {
296                                kind: SkillDiscoveryWarningKind::CanonicalizeFailed,
297                                path: entry_path.to_path_buf(),
298                                message: e.to_string(),
299                            });
300                            continue;
301                        }
302                    };
303                    if !is_target_allowed(&canon_file, &root_canonical, &external_policy) {
304                        self.discovery_warnings.push(SkillDiscoveryWarning {
305                            kind: SkillDiscoveryWarningKind::ExternalTargetDenied,
306                            path: entry_path.to_path_buf(),
307                            message: format!(
308                                "file target {canon_file:?} is outside search root {root_canonical:?}"
309                            ),
310                        });
311                        continue;
312                    }
313                    if !seen_canonical_files.insert(canon_file) {
314                        continue;
315                    }
316                }
317
318                match Self::parse(entry_path) {
319                    Ok(mut skill) => {
320                        if seen_skill_names.insert(skill.name.clone()) {
321                            skill.source = source;
322                            self.skills.push(skill);
323                        }
324                    }
325                    Err(e) => {
326                        self.discovery_warnings.push(SkillDiscoveryWarning {
327                            kind: SkillDiscoveryWarningKind::InvalidSkill,
328                            path: entry_path.to_path_buf(),
329                            message: e.to_string(),
330                        });
331                    }
332                }
333            }
334
335            self.discovery_warnings.append(&mut dir_warnings);
336        }
337
338        Ok(&self.skills)
339    }
340
341    fn classify_source(&self, path: &Path) -> SkillSource {
342        if let Some(ref home) = home_dir() {
343            let agents_skills = home.join(".agents").join("skills");
344            if path.starts_with(&agents_skills) {
345                return SkillSource::Shared;
346            }
347            let talos_skills = home.join(".talos").join("skills");
348            if path.starts_with(&talos_skills) {
349                return SkillSource::UserGlobal;
350            }
351        }
352
353        if let Some(ref root) = self.workspace_root {
354            let project_skills = root.join(".talos").join("skills");
355            if path.starts_with(&project_skills) {
356                return SkillSource::Project;
357            }
358        }
359
360        SkillSource::Parent
361    }
362
363    pub fn parse(path: &Path) -> Result<Skill> {
364        if !path.exists() {
365            return Err(SkillError::FileNotFound(path.to_path_buf()));
366        }
367
368        let content = std::fs::read_to_string(path)?;
369        let (frontmatter, body) = split_frontmatter(&content)?;
370        let fm: SkillFrontmatter = serde_yaml::from_str(frontmatter)?;
371
372        validate_frontmatter(&fm)?;
373
374        Ok(Skill {
375            name: fm.name,
376            description: fm.description,
377            triggers: fm.triggers,
378            body: body.trim().to_string(),
379            source_path: path.to_path_buf(),
380            source: SkillSource::default(),
381        })
382    }
383
384    pub fn get_index(&self) -> Vec<SkillIndex> {
385        self.skills
386            .iter()
387            .map(|s| {
388                let level0_text = format!("{}: {}", s.name, s.description);
389                SkillIndex {
390                    name: s.name.clone(),
391                    description: s.description.clone(),
392                    triggers: s.triggers.clone(),
393                    estimated_tokens: estimate_tokens(&level0_text),
394                    source: s.source,
395                }
396            })
397            .collect()
398    }
399}
400
401fn is_target_allowed(target: &Path, root: &Path, policy: &ExternalTargetPolicy) -> bool {
402    match policy {
403        ExternalTargetPolicy::AllowAnyReadable => true,
404        ExternalTargetPolicy::DenyOutsideSearchRoot => target.starts_with(root),
405    }
406}
407
408fn classify_walk_error(error: &walkdir::Error) -> SkillDiscoveryWarningKind {
409    if error.loop_ancestor().is_some() {
410        SkillDiscoveryWarningKind::LinkLoop
411    } else if error
412        .io_error()
413        .map(|io| io.kind() == std::io::ErrorKind::PermissionDenied)
414        .unwrap_or(false)
415    {
416        SkillDiscoveryWarningKind::PermissionDenied
417    } else if error
418        .path()
419        .map(|p| {
420            std::fs::symlink_metadata(p)
421                .map(|m| m.file_type().is_symlink() && !p.exists())
422                .unwrap_or(false)
423        })
424        .unwrap_or(false)
425    {
426        SkillDiscoveryWarningKind::BrokenLink
427    } else {
428        SkillDiscoveryWarningKind::Io
429    }
430}
431
432impl Default for SkillLoader {
433    fn default() -> Self {
434        Self::new()
435    }
436}
437
438pub(crate) fn home_dir() -> Option<PathBuf> {
439    #[cfg(target_os = "windows")]
440    {
441        std::env::var("USERPROFILE").ok().map(PathBuf::from)
442    }
443    #[cfg(not(target_os = "windows"))]
444    {
445        std::env::var("HOME").ok().map(PathBuf::from)
446    }
447}
448
449fn default_search_paths(
450    workspace_root: Option<&Path>,
451    home: Option<&Path>,
452    discover_shared: bool,
453) -> Vec<PathBuf> {
454    let mut search_paths = Vec::new();
455
456    if let Some(root) = workspace_root {
457        push_if_dir(&mut search_paths, root.join(".talos/skills"));
458    }
459
460    if let Some(h) = home {
461        push_if_dir(&mut search_paths, h.join(".talos/skills"));
462    }
463
464    if let Some(root) = workspace_root {
465        let mut current = root;
466        while let Some(parent) = current.parent() {
467            let git_dir = parent.join(".git");
468            push_if_dir(&mut search_paths, parent.join(".talos/skills"));
469            current = parent;
470            if git_dir.is_dir() {
471                break;
472            }
473        }
474    }
475
476    if discover_shared && let Some(h) = home {
477        push_if_dir(&mut search_paths, h.join(".agents/skills"));
478    }
479
480    search_paths
481}
482
483fn push_if_dir(paths: &mut Vec<PathBuf>, path: PathBuf) {
484    if path.is_dir() && !paths.iter().any(|existing| existing == &path) {
485        paths.push(path);
486    }
487}