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 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}