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
6pub struct SkillLoader {
18 pub skills: Vec<Skill>,
20 pub search_paths: Vec<PathBuf>,
22}
23
24impl SkillLoader {
25 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 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 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 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 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}