1use crate::skills::manifest::parse_skill_file;
7use crate::skills::types::SkillContext;
8use anyhow::Result;
9use hashbrown::HashMap;
10use std::path::{Path, PathBuf};
11use tracing::{debug, info, warn};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
15pub enum SkillLocationType {
16 VtcodeUser = 7,
18 AgentsProject = 6,
20 VtcodeProject = 5,
22 PiUser = 4,
24 PiProject = 3,
26 ClaudeUser = 2,
28 ClaudeProject = 1,
30 CodexUser = 0,
32}
33
34impl SkillLocationType {
35 #[expect(dead_code)]
37 fn from_path(path: &Path) -> Option<Self> {
38 let path_str = path.to_string_lossy();
39
40 if path_str.contains(".vtcode/skills")
41 && (path_str.contains("~/")
42 || path_str.contains("/home/")
43 || path_str.contains("/Users/"))
44 {
45 Some(SkillLocationType::VtcodeUser)
46 } else if path_str.contains(".agents/skills") {
47 Some(SkillLocationType::AgentsProject)
48 } else if path_str.contains(".vtcode/skills") {
49 Some(SkillLocationType::VtcodeProject)
50 } else if path_str.contains(".pi/skills")
51 && (path_str.contains("~/")
52 || path_str.contains("/home/")
53 || path_str.contains("/Users/"))
54 {
55 Some(SkillLocationType::PiUser)
56 } else if path_str.contains(".pi/skills") {
57 Some(SkillLocationType::PiProject)
58 } else if path_str.contains(".claude/skills")
59 && (path_str.contains("~/")
60 || path_str.contains("/home/")
61 || path_str.contains("/Users/"))
62 {
63 Some(SkillLocationType::ClaudeUser)
64 } else if path_str.contains(".claude/skills") {
65 Some(SkillLocationType::ClaudeProject)
66 } else if path_str.contains(".codex/skills") {
67 Some(SkillLocationType::CodexUser)
68 } else {
69 None
70 }
71 }
72}
73
74#[derive(Debug, Clone)]
76pub struct SkillLocation {
77 pub location_type: SkillLocationType,
79
80 pub base_path: PathBuf,
82
83 pub recursive: bool,
85
86 pub name_separator: char,
88}
89
90impl SkillLocation {
91 pub fn new(location_type: SkillLocationType, base_path: PathBuf, recursive: bool) -> Self {
93 let name_separator = match location_type {
94 SkillLocationType::PiUser | SkillLocationType::PiProject => ':',
95 _ => '/', };
97
98 Self {
99 location_type,
100 base_path,
101 recursive,
102 name_separator,
103 }
104 }
105
106 pub fn exists(&self) -> bool {
108 self.base_path.exists() && self.base_path.is_dir()
109 }
110
111 pub fn get_skill_name(&self, skill_path: &Path) -> Option<String> {
113 if !skill_path.exists() || !skill_path.is_dir() {
114 return None;
115 }
116
117 let skill_md = skill_path.join("SKILL.md");
119 if !skill_md.exists() {
120 return None;
121 }
122
123 if self.recursive {
124 if matches!(
125 self.location_type,
126 SkillLocationType::ClaudeUser | SkillLocationType::ClaudeProject
127 ) {
128 return skill_path
129 .file_name()
130 .and_then(|name| name.to_str())
131 .map(|s| s.to_string());
132 }
133 match skill_path.strip_prefix(&self.base_path) {
135 Ok(relative_path) => {
136 let name_components: Vec<&str> = relative_path
137 .components()
138 .filter_map(|c| c.as_os_str().to_str())
139 .collect();
140
141 if name_components.is_empty() {
142 None
143 } else {
144 Some(name_components.join(&self.name_separator.to_string()))
145 }
146 }
147 Err(_) => None,
148 }
149 } else {
150 skill_path
152 .file_name()
153 .and_then(|name| name.to_str())
154 .map(|s| s.to_string())
155 }
156 }
157}
158
159pub struct SkillLocations {
161 locations: Vec<SkillLocation>,
162}
163
164impl SkillLocations {
165 pub fn new() -> Self {
167 Self::with_locations(Self::default_locations())
168 }
169
170 pub fn with_locations(locations: Vec<SkillLocation>) -> Self {
172 let mut sorted_locations = locations;
174 sorted_locations.sort_by_key(|loc| std::cmp::Reverse(loc.location_type));
175
176 Self {
177 locations: sorted_locations,
178 }
179 }
180
181 pub fn default_locations() -> Vec<SkillLocation> {
183 vec![
184 SkillLocation::new(
186 SkillLocationType::VtcodeUser,
187 PathBuf::from("~/.vtcode/skills"),
188 true, ),
190 SkillLocation::new(
191 SkillLocationType::AgentsProject,
192 PathBuf::from(".agents/skills"),
193 true, ),
195 SkillLocation::new(
196 SkillLocationType::VtcodeProject,
197 PathBuf::from(".vtcode/skills"),
198 true, ),
200 SkillLocation::new(
202 SkillLocationType::PiUser,
203 PathBuf::from("~/.pi/agent/skills"),
204 true, ),
206 SkillLocation::new(
207 SkillLocationType::PiProject,
208 PathBuf::from(".pi/skills"),
209 true, ),
211 SkillLocation::new(
213 SkillLocationType::ClaudeUser,
214 PathBuf::from("~/.claude/skills"),
215 true, ),
217 SkillLocation::new(
218 SkillLocationType::ClaudeProject,
219 PathBuf::from(".claude/skills"),
220 true, ),
222 SkillLocation::new(
224 SkillLocationType::CodexUser,
225 PathBuf::from("~/.codex/skills"),
226 true, ),
228 ]
229 }
230
231 pub fn discover_skills(&self) -> Result<Vec<DiscoveredSkill>> {
233 let mut discovered_skills = HashMap::new(); let mut discovery_stats = DiscoveryStats::default();
235
236 info!(
237 "Discovering skills across {} locations",
238 self.locations.len()
239 );
240
241 for location in &self.locations {
242 if !location.exists() {
243 debug!("Location does not exist: {}", location.base_path.display());
244 continue;
245 }
246
247 info!(
248 "Scanning location: {} ({})",
249 location.base_path.display(),
250 if location.recursive {
251 "recursive"
252 } else {
253 "one-level"
254 }
255 );
256
257 discovery_stats.locations_scanned += 1;
258
259 if location.recursive {
260 self.scan_recursive_location(
261 location,
262 &mut discovered_skills,
263 &mut discovery_stats,
264 )?;
265 } else {
266 self.scan_one_level_location(
267 location,
268 &mut discovered_skills,
269 &mut discovery_stats,
270 )?;
271 }
272 }
273
274 info!(
275 "Discovery complete: {} skills found ({} from higher precedence locations)",
276 discovered_skills.len(),
277 discovery_stats.skills_with_higher_precedence
278 );
279
280 let mut final_skills: Vec<DiscoveredSkill> = discovered_skills.into_values().collect();
282
283 final_skills.sort_by(|a, b| match a.location_type.cmp(&b.location_type) {
285 std::cmp::Ordering::Equal => a
286 .skill_context
287 .manifest()
288 .name
289 .cmp(&b.skill_context.manifest().name),
290 other => other.reverse(),
291 });
292
293 Ok(final_skills)
294 }
295
296 fn scan_recursive_location(
298 &self,
299 location: &SkillLocation,
300 discovered: &mut HashMap<String, DiscoveredSkill>,
301 stats: &mut DiscoveryStats,
302 ) -> Result<()> {
303 walk_directory(&location.base_path, location, discovered, stats, 0)
304 }
305}
306
307fn walk_directory(
309 dir: &Path,
310 location: &SkillLocation,
311 discovered: &mut HashMap<String, DiscoveredSkill>,
312 stats: &mut DiscoveryStats,
313 depth: usize,
314) -> Result<()> {
315 if depth > 10 {
316 return Ok(());
318 }
319
320 if !dir.exists() || !dir.is_dir() {
321 return Ok(());
322 }
323
324 if let Some(skill_name) = location.get_skill_name(dir) {
326 match parse_skill_file(dir) {
327 Ok((manifest, _)) => {
328 let existing_entry = discovered.get(&manifest.name);
330 let had_existing = existing_entry.is_some();
331
332 if let Some(existing) =
333 existing_entry.filter(|e| e.location_type > location.location_type)
334 {
335 stats.skips_due_to_precedence += 1;
337 debug!(
338 "Skipping skill '{}' from {} (already exists from higher precedence {})",
339 manifest.name, location.location_type, existing.location_type
340 );
341 return Ok(());
342 }
343
344 let discovered_skill = DiscoveredSkill {
346 location_type: location.location_type,
347 skill_context: SkillContext::MetadataOnly(manifest.clone(), dir.to_path_buf()),
348 skill_path: dir.to_path_buf(),
349 skill_name: skill_name.clone(),
350 };
351
352 discovered.insert(manifest.name.clone(), discovered_skill);
353 stats.skills_found += 1;
354 info!(
355 "Discovered skill: '{}' from {} at {}",
356 manifest.name,
357 location.location_type,
358 dir.display()
359 );
360
361 if had_existing {
362 stats.skills_with_higher_precedence += 1;
363 }
364 }
365 Err(e) => {
366 warn!("Failed to parse skill from {}: {}", dir.display(), e);
367 stats.parse_errors += 1;
368 }
369 }
370 }
371
372 if let Ok(entries) = std::fs::read_dir(dir) {
374 for entry in entries.flatten() {
375 let path = entry.path();
376 if path.is_dir() {
377 walk_directory(&path, location, discovered, stats, depth + 1)?;
378 }
379 }
380 }
381
382 Ok(())
383}
384
385impl SkillLocations {
386 fn scan_one_level_location(
388 &self,
389 location: &SkillLocation,
390 discovered: &mut HashMap<String, DiscoveredSkill>,
391 stats: &mut DiscoveryStats,
392 ) -> Result<()> {
393 if !location.base_path.exists() || !location.base_path.is_dir() {
394 return Ok(());
395 }
396
397 for entry in std::fs::read_dir(&location.base_path)? {
398 let entry = entry?;
399 let path = entry.path();
400
401 if let Some(skill_name) = location.get_skill_name(&path).filter(|_| path.is_dir()) {
402 match parse_skill_file(&path) {
403 Ok((manifest, _)) => {
404 if let Some(_existing) = discovered
406 .get(&manifest.name)
407 .filter(|e| e.location_type > location.location_type)
408 {
409 stats.skips_due_to_precedence += 1;
410 continue;
411 }
412
413 let discovered_skill = DiscoveredSkill {
414 location_type: location.location_type,
415 skill_context: SkillContext::MetadataOnly(
416 manifest.clone(),
417 path.clone(),
418 ),
419 skill_path: path.clone(),
420 skill_name: skill_name.clone(),
421 };
422
423 discovered.insert(manifest.name.clone(), discovered_skill);
424 stats.skills_found += 1;
425 info!(
426 "Discovered skill: '{}' from {} at {}",
427 manifest.name,
428 location.location_type,
429 path.display()
430 );
431 }
432 Err(e) => {
433 warn!("Failed to parse skill from {}: {}", path.display(), e);
434 stats.parse_errors += 1;
435 }
436 }
437 }
438 }
439
440 Ok(())
441 }
442
443 pub fn get_location_types(&self) -> Vec<SkillLocationType> {
445 self.locations.iter().map(|loc| loc.location_type).collect()
446 }
447
448 pub fn get_location(&self, location_type: SkillLocationType) -> Option<&SkillLocation> {
450 self.locations
451 .iter()
452 .find(|loc| loc.location_type == location_type)
453 }
454}
455
456#[derive(Debug, Clone)]
458pub struct DiscoveredSkill {
459 pub location_type: SkillLocationType,
461
462 pub skill_context: SkillContext,
464
465 pub skill_path: PathBuf,
467
468 pub skill_name: String,
470}
471
472#[derive(Debug, Default)]
474pub struct DiscoveryStats {
475 pub locations_scanned: usize,
476 pub skills_found: usize,
477 pub skips_due_to_precedence: usize,
478 pub skills_with_higher_precedence: usize,
479 pub parse_errors: usize,
480}
481
482impl Default for SkillLocations {
483 fn default() -> Self {
484 Self::new()
485 }
486}
487
488impl std::fmt::Display for SkillLocationType {
490 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
491 match self {
492 SkillLocationType::VtcodeUser => write!(f, "VT Code User"),
493 SkillLocationType::AgentsProject => write!(f, "Agents Project"),
494 SkillLocationType::VtcodeProject => write!(f, "VT Code Project"),
495 SkillLocationType::PiUser => write!(f, "Pi User"),
496 SkillLocationType::PiProject => write!(f, "Pi Project"),
497 SkillLocationType::ClaudeUser => write!(f, "Claude User"),
498 SkillLocationType::ClaudeProject => write!(f, "Claude Project"),
499 SkillLocationType::CodexUser => write!(f, "Codex User"),
500 }
501 }
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507 use std::path::Path;
508 use tempfile::TempDir;
509
510 fn create_test_skill(root: &Path, relative_dir: &str, name: &str) {
511 let skill_dir = root.join(relative_dir);
512 std::fs::create_dir_all(&skill_dir).unwrap();
513 let skill_md = format!(
514 "---\nname: {name}\ndescription: Test skill {name}\n---\n# {name}\n\nTest instructions.\n"
515 );
516 std::fs::write(skill_dir.join("SKILL.md"), skill_md).unwrap();
517 }
518
519 #[test]
520 fn test_skill_location_type_precedence() {
521 assert!(SkillLocationType::VtcodeUser > SkillLocationType::AgentsProject);
522 assert!(SkillLocationType::AgentsProject > SkillLocationType::VtcodeProject);
523 assert!(SkillLocationType::VtcodeProject > SkillLocationType::PiUser);
524 assert!(SkillLocationType::PiUser > SkillLocationType::PiProject);
525 assert!(SkillLocationType::PiProject > SkillLocationType::ClaudeUser);
526 assert!(SkillLocationType::ClaudeUser > SkillLocationType::ClaudeProject);
527 assert!(SkillLocationType::ClaudeProject > SkillLocationType::CodexUser);
528 }
529
530 #[test]
531 fn test_skill_name_generation() {
532 let temp_dir = TempDir::new().unwrap();
533 let base_path = temp_dir.path();
534
535 let skill_path = base_path.join("web/tools/search-engine");
537 std::fs::create_dir_all(&skill_path).unwrap();
538 std::fs::write(skill_path.join("SKILL.md"), "---\nname: web-search\n---\n").unwrap();
539
540 let location = SkillLocation::new(
541 SkillLocationType::VtcodeProject,
542 base_path.to_path_buf(),
543 true, );
545
546 let skill_name = location.get_skill_name(&skill_path);
547 assert_eq!(skill_name, Some("web/tools/search-engine".to_string()));
549 }
550
551 #[test]
552 fn test_recursive_location() {
553 let temp_dir = TempDir::new().unwrap();
554 let base_path = temp_dir.path();
555
556 let skill_path = base_path.join("file-analyzer");
558 std::fs::create_dir_all(&skill_path).unwrap();
559 std::fs::write(
560 skill_path.join("SKILL.md"),
561 "---\nname: file-analyzer\n---\n",
562 )
563 .unwrap();
564
565 let location = SkillLocation::new(
566 SkillLocationType::ClaudeProject,
567 base_path.to_path_buf(),
568 true,
569 );
570
571 let skill_name = location.get_skill_name(&skill_path);
572 assert_eq!(skill_name, Some("file-analyzer".to_string()));
573 }
574
575 #[tokio::test]
576 async fn test_location_discovery() {
577 let temp_dir = TempDir::new().unwrap();
578 let project_skills = temp_dir.path().join(".agents/skills");
579 let claude_skills = temp_dir.path().join(".claude/skills");
580
581 create_test_skill(&project_skills, "docs/doc-generator", "doc-generator");
582 create_test_skill(
583 &project_skills,
584 "spreadsheet-generator",
585 "spreadsheet-generator",
586 );
587 create_test_skill(
588 &project_skills,
589 "reports/pdf-report-generator",
590 "pdf-report-generator",
591 );
592 create_test_skill(&claude_skills, "doc-generator", "doc-generator");
594
595 let locations = SkillLocations::with_locations(vec![
596 SkillLocation::new(SkillLocationType::AgentsProject, project_skills, true),
597 SkillLocation::new(SkillLocationType::ClaudeProject, claude_skills, true),
598 ]);
599
600 let discovered = locations.discover_skills().unwrap();
601 let skill_names: Vec<String> = discovered
602 .iter()
603 .map(|d| d.skill_context.manifest().name.clone())
604 .collect();
605 assert!(skill_names.contains(&"doc-generator".to_string()));
606 assert!(skill_names.contains(&"spreadsheet-generator".to_string()));
607 assert!(skill_names.contains(&"pdf-report-generator".to_string()));
608
609 let doc_generator = discovered
610 .iter()
611 .find(|d| d.skill_context.manifest().name == "doc-generator")
612 .expect("doc-generator should be discovered");
613 assert_eq!(
614 doc_generator.location_type,
615 SkillLocationType::AgentsProject
616 );
617 }
618
619 #[test]
620 fn test_full_integration() {
621 let temp_dir = TempDir::new().unwrap();
622 let agents_skills = temp_dir.path().join(".agents/skills");
623 let vtcode_skills = temp_dir.path().join(".vtcode/skills");
624
625 create_test_skill(&agents_skills, "doc-generator", "doc-generator");
626 create_test_skill(
627 &agents_skills,
628 "spreadsheet-generator",
629 "spreadsheet-generator",
630 );
631 create_test_skill(
632 &agents_skills,
633 "pdf-report-generator",
634 "pdf-report-generator",
635 );
636 create_test_skill(&vtcode_skills, "doc-generator", "doc-generator");
638
639 let locations = SkillLocations::with_locations(vec![
640 SkillLocation::new(SkillLocationType::VtcodeProject, vtcode_skills, true),
641 SkillLocation::new(SkillLocationType::AgentsProject, agents_skills, true),
642 ]);
643
644 let discovered = locations.discover_skills().unwrap();
645 let skill_names: Vec<String> = discovered
646 .iter()
647 .map(|d| d.skill_context.manifest().name.clone())
648 .collect();
649
650 assert!(
651 skill_names.contains(&"doc-generator".to_string()),
652 "Should find doc-generator"
653 );
654 assert!(
655 skill_names.contains(&"spreadsheet-generator".to_string()),
656 "Should find spreadsheet-generator"
657 );
658 assert!(
659 skill_names.contains(&"pdf-report-generator".to_string()),
660 "Should find pdf-report-generator"
661 );
662 let doc_generator = discovered
663 .iter()
664 .find(|d| d.skill_context.manifest().name == "doc-generator")
665 .expect("doc-generator should be discovered");
666 assert_eq!(
667 doc_generator.location_type,
668 SkillLocationType::AgentsProject
669 );
670 }
671}