1use crate::manifest::parse_skill_file;
7use crate::types::SkillContext;
8use anyhow::Result;
9use hashbrown::HashMap;
10use std::path::{Path, PathBuf};
11use tracing::{debug, info, warn};
12use vtcode_commons::VtCodePaths;
13
14pub const MAX_DISCOVERY_DEPTH: usize = 10;
20
21pub const MAX_DISCOVERY_DIRS: usize = 2000;
26
27pub const SKIPPED_DISCOVERY_DIRS: &[&str] = &[".git", ".hg", ".svn", "node_modules", "target"];
32
33pub fn discovery_dir_skipped(file_name: &str) -> bool {
35 SKIPPED_DISCOVERY_DIRS.contains(&file_name)
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
40pub enum SkillLocationType {
41 VtcodeUser = 7,
43 AgentsProject = 6,
45 VtcodeProject = 5,
47 PiUser = 4,
49 PiProject = 3,
51 ClaudeUser = 2,
53 ClaudeProject = 1,
55 CodexUser = 0,
57}
58
59impl SkillLocationType {}
60
61#[derive(Debug, Clone)]
63pub struct SkillLocation {
64 location_type: SkillLocationType,
66
67 base_path: PathBuf,
69
70 recursive: bool,
72
73 name_separator: char,
75}
76
77impl SkillLocation {
78 fn new(location_type: SkillLocationType, base_path: PathBuf, recursive: bool) -> Self {
80 let name_separator = match location_type {
81 SkillLocationType::PiUser | SkillLocationType::PiProject => ':',
82 _ => '/', };
84
85 Self {
86 location_type,
87 base_path,
88 recursive,
89 name_separator,
90 }
91 }
92
93 fn exists(&self) -> bool {
95 self.base_path.exists() && self.base_path.is_dir()
96 }
97
98 fn get_skill_name(&self, skill_path: &Path) -> Option<String> {
100 if !skill_path.exists() || !skill_path.is_dir() {
101 return None;
102 }
103
104 let skill_md = skill_path.join("SKILL.md");
106 if !skill_md.exists() {
107 return None;
108 }
109
110 if self.recursive {
111 if matches!(self.location_type, SkillLocationType::ClaudeUser | SkillLocationType::ClaudeProject) {
112 return skill_path.file_name().and_then(|name| name.to_str()).map(|s| s.to_string());
113 }
114 match skill_path.strip_prefix(&self.base_path) {
116 Ok(relative_path) => {
117 let name_components: Vec<&str> =
118 relative_path.components().filter_map(|c| c.as_os_str().to_str()).collect();
119
120 if name_components.is_empty() {
121 None
122 } else {
123 Some(name_components.join(&self.name_separator.to_string()))
124 }
125 }
126 Err(_) => None,
127 }
128 } else {
129 skill_path.file_name().and_then(|name| name.to_str()).map(|s| s.to_string())
131 }
132 }
133}
134
135pub struct SkillLocations {
137 locations: Vec<SkillLocation>,
138}
139
140impl SkillLocations {
141 fn new() -> Self {
143 Self::with_locations(Self::default_locations())
144 }
145
146 fn with_locations(locations: Vec<SkillLocation>) -> Self {
148 let mut sorted_locations = locations;
150 sorted_locations.sort_by_key(|loc| std::cmp::Reverse(loc.location_type));
151
152 Self { locations: sorted_locations }
153 }
154
155 fn default_locations() -> Vec<SkillLocation> {
157 let mut locations = Vec::new();
158 if let Ok(paths) = VtCodePaths::resolve() {
159 locations.push(SkillLocation::new(
163 SkillLocationType::VtcodeUser,
164 paths.skills_dir(),
165 true, ));
167 }
168 locations.extend([
169 SkillLocation::new(
170 SkillLocationType::AgentsProject,
171 PathBuf::from(".agents/skills"),
172 true, ),
174 SkillLocation::new(
175 SkillLocationType::VtcodeProject,
176 PathBuf::from(".vtcode/skills"),
177 true, ),
179 SkillLocation::new(
181 SkillLocationType::PiUser,
182 PathBuf::from("~/.pi/agent/skills"),
183 true, ),
185 SkillLocation::new(
186 SkillLocationType::PiProject,
187 PathBuf::from(".pi/skills"),
188 true, ),
190 SkillLocation::new(
192 SkillLocationType::ClaudeUser,
193 PathBuf::from("~/.claude/skills"),
194 true, ),
196 SkillLocation::new(
197 SkillLocationType::ClaudeProject,
198 PathBuf::from(".claude/skills"),
199 true, ),
201 SkillLocation::new(
203 SkillLocationType::CodexUser,
204 PathBuf::from("~/.codex/skills"),
205 true, ),
207 ]);
208 locations
209 }
210
211 fn discover_skills(&self) -> Result<Vec<DiscoveredSkill>> {
213 let mut discovered_skills = HashMap::new(); let mut discovery_stats = DiscoveryStats::default();
215
216 info!("Discovering skills across {} locations", self.locations.len());
217
218 for location in &self.locations {
219 if !location.exists() {
220 debug!("Location does not exist: {}", location.base_path.display());
221 continue;
222 }
223
224 info!(
225 "Scanning location: {} ({})",
226 location.base_path.display(),
227 if location.recursive { "recursive" } else { "one-level" }
228 );
229
230 discovery_stats.locations_scanned += 1;
231
232 if location.recursive {
233 self.scan_recursive_location(location, &mut discovered_skills, &mut discovery_stats)?;
234 } else {
235 self.scan_one_level_location(location, &mut discovered_skills, &mut discovery_stats)?;
236 }
237 }
238
239 info!(
240 "Discovery complete: {} skills found ({} from higher precedence locations)",
241 discovered_skills.len(),
242 discovery_stats.skills_with_higher_precedence
243 );
244
245 let mut final_skills: Vec<DiscoveredSkill> = discovered_skills.into_values().collect();
247
248 final_skills.sort_by(|a, b| match a.location_type.cmp(&b.location_type) {
250 std::cmp::Ordering::Equal => a.skill_context.manifest().name.cmp(&b.skill_context.manifest().name),
251 other => other.reverse(),
252 });
253
254 Ok(final_skills)
255 }
256
257 fn scan_recursive_location(
259 &self,
260 location: &SkillLocation,
261 discovered: &mut HashMap<String, DiscoveredSkill>,
262 stats: &mut DiscoveryStats,
263 ) -> Result<()> {
264 walk_directory(&location.base_path, location, discovered, stats, 0)
265 }
266}
267
268fn walk_directory(
270 dir: &Path,
271 location: &SkillLocation,
272 discovered: &mut HashMap<String, DiscoveredSkill>,
273 stats: &mut DiscoveryStats,
274 depth: usize,
275) -> Result<()> {
276 if depth > MAX_DISCOVERY_DEPTH {
277 return Ok(());
279 }
280
281 if !dir.exists() || !dir.is_dir() {
282 return Ok(());
283 }
284
285 stats.dirs_visited += 1;
286 if stats.dirs_visited > MAX_DISCOVERY_DIRS {
287 debug!("skill discovery dir budget ({MAX_DISCOVERY_DIRS}) exhausted under {}", location.base_path.display());
288 return Ok(());
289 }
290
291 if let Some(skill_name) = location.get_skill_name(dir) {
293 match parse_skill_file(dir) {
294 Ok((manifest, _)) => {
295 let existing_entry = discovered.get(&manifest.name);
297 let had_existing = existing_entry.is_some();
298
299 if let Some(existing) = existing_entry.filter(|e| e.location_type > location.location_type) {
300 stats.skips_due_to_precedence += 1;
302 debug!(
303 "Skipping skill '{}' from {} (already exists from higher precedence {})",
304 manifest.name, location.location_type, existing.location_type
305 );
306 return Ok(());
307 }
308
309 let discovered_skill = DiscoveredSkill {
311 location_type: location.location_type,
312 skill_context: SkillContext::MetadataOnly(manifest.clone(), dir.to_path_buf()),
313 skill_path: dir.to_path_buf(),
314 skill_name: skill_name.clone(),
315 };
316
317 discovered.insert(manifest.name.clone(), discovered_skill);
318 stats.skills_found += 1;
319 info!("Discovered skill: '{}' from {} at {}", manifest.name, location.location_type, dir.display());
320
321 if had_existing {
322 stats.skills_with_higher_precedence += 1;
323 }
324 }
325 Err(e) => {
326 warn!("Failed to parse skill from {}: {}", dir.display(), e);
327 stats.parse_errors += 1;
328 }
329 }
330 }
331
332 if let Ok(entries) = std::fs::read_dir(dir) {
335 for entry in entries.flatten() {
336 let path = entry.path();
337 if !path.is_dir() {
338 continue;
339 }
340 if path
341 .file_name()
342 .and_then(|name| name.to_str())
343 .is_some_and(discovery_dir_skipped)
344 {
345 continue;
346 }
347 walk_directory(&path, location, discovered, stats, depth + 1)?;
348 }
349 }
350
351 Ok(())
352}
353
354impl SkillLocations {
355 fn scan_one_level_location(
357 &self,
358 location: &SkillLocation,
359 discovered: &mut HashMap<String, DiscoveredSkill>,
360 stats: &mut DiscoveryStats,
361 ) -> Result<()> {
362 if !location.base_path.exists() || !location.base_path.is_dir() {
363 return Ok(());
364 }
365
366 for entry in std::fs::read_dir(&location.base_path)? {
367 let entry = entry?;
368 let path = entry.path();
369
370 if let Some(skill_name) = location.get_skill_name(&path).filter(|_| path.is_dir()) {
371 match parse_skill_file(&path) {
372 Ok((manifest, _)) => {
373 if let Some(_existing) = discovered
375 .get(&manifest.name)
376 .filter(|e| e.location_type > location.location_type)
377 {
378 stats.skips_due_to_precedence += 1;
379 continue;
380 }
381
382 let discovered_skill = DiscoveredSkill {
383 location_type: location.location_type,
384 skill_context: SkillContext::MetadataOnly(manifest.clone(), path.clone()),
385 skill_path: path.clone(),
386 skill_name: skill_name.clone(),
387 };
388
389 discovered.insert(manifest.name.clone(), discovered_skill);
390 stats.skills_found += 1;
391 info!(
392 "Discovered skill: '{}' from {} at {}",
393 manifest.name,
394 location.location_type,
395 path.display()
396 );
397 }
398 Err(e) => {
399 warn!("Failed to parse skill from {}: {}", path.display(), e);
400 stats.parse_errors += 1;
401 }
402 }
403 }
404 }
405
406 Ok(())
407 }
408
409 pub fn get_location_types(&self) -> Vec<SkillLocationType> {
411 self.locations.iter().map(|loc| loc.location_type).collect()
412 }
413
414 pub fn get_location(&self, location_type: SkillLocationType) -> Option<&SkillLocation> {
416 self.locations.iter().find(|loc| loc.location_type == location_type)
417 }
418}
419
420#[derive(Debug, Clone)]
422pub struct DiscoveredSkill {
423 location_type: SkillLocationType,
425
426 skill_context: SkillContext,
428
429 skill_path: PathBuf,
431
432 skill_name: String,
434}
435
436#[derive(Debug, Default)]
438pub struct DiscoveryStats {
439 locations_scanned: usize,
440 skills_found: usize,
441 skips_due_to_precedence: usize,
442 skills_with_higher_precedence: usize,
443 parse_errors: usize,
444 dirs_visited: usize,
445}
446
447impl Default for SkillLocations {
448 fn default() -> Self {
449 Self::new()
450 }
451}
452
453impl std::fmt::Display for SkillLocationType {
455 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
456 match self {
457 SkillLocationType::VtcodeUser => write!(f, "VT Code User"),
458 SkillLocationType::AgentsProject => write!(f, "Agents Project"),
459 SkillLocationType::VtcodeProject => write!(f, "VT Code Project"),
460 SkillLocationType::PiUser => write!(f, "Pi User"),
461 SkillLocationType::PiProject => write!(f, "Pi Project"),
462 SkillLocationType::ClaudeUser => write!(f, "Claude User"),
463 SkillLocationType::ClaudeProject => write!(f, "Claude Project"),
464 SkillLocationType::CodexUser => write!(f, "Codex User"),
465 }
466 }
467}
468
469#[cfg(test)]
470mod tests {
471 use super::*;
472 use std::path::Path;
473 use tempfile::TempDir;
474
475 fn create_test_skill(root: &Path, relative_dir: &str, name: &str) {
476 let skill_dir = root.join(relative_dir);
477 std::fs::create_dir_all(&skill_dir).unwrap();
478 let skill_md =
479 format!("---\nname: {name}\ndescription: Test skill {name}\n---\n# {name}\n\nTest instructions.\n");
480 std::fs::write(skill_dir.join("SKILL.md"), skill_md).unwrap();
481 }
482
483 #[test]
484 fn test_discovery_dir_skip_list() {
485 for skipped in [".git", ".hg", ".svn", "node_modules", "target"] {
486 assert!(discovery_dir_skipped(skipped), "{skipped} must be skipped");
487 }
488 for scanned in ["my-skill", "skills", ".agents", "docs"] {
489 assert!(!discovery_dir_skipped(scanned), "{scanned} must be scanned");
490 }
491 }
492
493 #[test]
494 fn test_walk_skips_dependency_and_vcs_trees() {
495 let temp_dir = TempDir::new().unwrap();
496 let base_path = temp_dir.path();
497
498 create_test_skill(base_path, "real-skill", "real-skill");
499 create_test_skill(base_path, "node_modules/hidden-skill", "hidden-skill");
501 create_test_skill(base_path, ".git/hidden-git-skill", "hidden-git-skill");
502 create_test_skill(base_path, "target/hidden-target-skill", "hidden-target-skill");
503
504 let locations = SkillLocations::with_locations(vec![SkillLocation::new(
505 SkillLocationType::VtcodeProject,
506 base_path.to_path_buf(),
507 true,
508 )]);
509
510 let discovered = locations.discover_skills().unwrap();
511 let names: Vec<String> = discovered.iter().map(|d| d.skill_context.manifest().name.clone()).collect();
512 assert_eq!(names, vec!["real-skill".to_string()]);
513 }
514
515 #[test]
516 fn test_skill_location_type_precedence() {
517 assert!(SkillLocationType::VtcodeUser > SkillLocationType::AgentsProject);
518 assert!(SkillLocationType::AgentsProject > SkillLocationType::VtcodeProject);
519 assert!(SkillLocationType::VtcodeProject > SkillLocationType::PiUser);
520 assert!(SkillLocationType::PiUser > SkillLocationType::PiProject);
521 assert!(SkillLocationType::PiProject > SkillLocationType::ClaudeUser);
522 assert!(SkillLocationType::ClaudeUser > SkillLocationType::ClaudeProject);
523 assert!(SkillLocationType::ClaudeProject > SkillLocationType::CodexUser);
524 }
525
526 #[test]
527 fn test_skill_name_generation() {
528 let temp_dir = TempDir::new().unwrap();
529 let base_path = temp_dir.path();
530
531 let skill_path = base_path.join("web/tools/search-engine");
533 std::fs::create_dir_all(&skill_path).unwrap();
534 std::fs::write(skill_path.join("SKILL.md"), "---\nname: web-search\n---\n").unwrap();
535
536 let location = SkillLocation::new(
537 SkillLocationType::VtcodeProject,
538 base_path.to_path_buf(),
539 true, );
541
542 let skill_name = location.get_skill_name(&skill_path);
543 assert_eq!(skill_name, Some("web/tools/search-engine".to_string()));
545 }
546
547 #[test]
548 fn test_recursive_location() {
549 let temp_dir = TempDir::new().unwrap();
550 let base_path = temp_dir.path();
551
552 let skill_path = base_path.join("file-analyzer");
554 std::fs::create_dir_all(&skill_path).unwrap();
555 std::fs::write(skill_path.join("SKILL.md"), "---\nname: file-analyzer\n---\n").unwrap();
556
557 let location = SkillLocation::new(SkillLocationType::ClaudeProject, base_path.to_path_buf(), true);
558
559 let skill_name = location.get_skill_name(&skill_path);
560 assert_eq!(skill_name, Some("file-analyzer".to_string()));
561 }
562
563 #[tokio::test]
564 async fn test_location_discovery() {
565 let temp_dir = TempDir::new().unwrap();
566 let project_skills = temp_dir.path().join(".agents/skills");
567 let claude_skills = temp_dir.path().join(".claude/skills");
568
569 create_test_skill(&project_skills, "docs/doc-generator", "doc-generator");
570 create_test_skill(&project_skills, "spreadsheet-generator", "spreadsheet-generator");
571 create_test_skill(&project_skills, "reports/pdf-report-generator", "pdf-report-generator");
572 create_test_skill(&claude_skills, "doc-generator", "doc-generator");
574
575 let locations = SkillLocations::with_locations(vec![
576 SkillLocation::new(SkillLocationType::AgentsProject, project_skills, true),
577 SkillLocation::new(SkillLocationType::ClaudeProject, claude_skills, true),
578 ]);
579
580 let discovered = locations.discover_skills().unwrap();
581 let skill_names: Vec<String> = discovered.iter().map(|d| d.skill_context.manifest().name.clone()).collect();
582 assert!(skill_names.contains(&"doc-generator".to_string()));
583 assert!(skill_names.contains(&"spreadsheet-generator".to_string()));
584 assert!(skill_names.contains(&"pdf-report-generator".to_string()));
585
586 let doc_generator = discovered
587 .iter()
588 .find(|d| d.skill_context.manifest().name == "doc-generator")
589 .expect("doc-generator should be discovered");
590 assert_eq!(doc_generator.location_type, SkillLocationType::AgentsProject);
591 }
592
593 #[test]
594 fn test_full_integration() {
595 let temp_dir = TempDir::new().unwrap();
596 let agents_skills = temp_dir.path().join(".agents/skills");
597 let vtcode_skills = temp_dir.path().join(".vtcode/skills");
598
599 create_test_skill(&agents_skills, "doc-generator", "doc-generator");
600 create_test_skill(&agents_skills, "spreadsheet-generator", "spreadsheet-generator");
601 create_test_skill(&agents_skills, "pdf-report-generator", "pdf-report-generator");
602 create_test_skill(&vtcode_skills, "doc-generator", "doc-generator");
604
605 let locations = SkillLocations::with_locations(vec![
606 SkillLocation::new(SkillLocationType::VtcodeProject, vtcode_skills, true),
607 SkillLocation::new(SkillLocationType::AgentsProject, agents_skills, true),
608 ]);
609
610 let discovered = locations.discover_skills().unwrap();
611 let skill_names: Vec<String> = discovered.iter().map(|d| d.skill_context.manifest().name.clone()).collect();
612
613 assert!(skill_names.contains(&"doc-generator".to_string()), "Should find doc-generator");
614 assert!(skill_names.contains(&"spreadsheet-generator".to_string()), "Should find spreadsheet-generator");
615 assert!(skill_names.contains(&"pdf-report-generator".to_string()), "Should find pdf-report-generator");
616 let doc_generator = discovered
617 .iter()
618 .find(|d| d.skill_context.manifest().name == "doc-generator")
619 .expect("doc-generator should be discovered");
620 assert_eq!(doc_generator.location_type, SkillLocationType::AgentsProject);
621 }
622}