Skip to main content

vtcode_core/skills/
locations.rs

1//! Skill Location Management
2//!
3//! Implements skill discovery across multiple locations with proper precedence,
4//! following the pi-mono pattern for compatibility with Claude Code and Codex CLI.
5
6use 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/// Skill location types with precedence ordering
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
15pub enum SkillLocationType {
16    /// VT Code user skills (highest precedence)
17    VtcodeUser = 7,
18    /// Project-level agent skills
19    AgentsProject = 6,
20    /// VT Code project skills
21    VtcodeProject = 5,
22    /// Pi user skills
23    PiUser = 4,
24    /// Pi project skills
25    PiProject = 3,
26    /// Claude Code user skills
27    ClaudeUser = 2,
28    /// Claude Code project skills
29    ClaudeProject = 1,
30    /// Codex CLI user skills (lowest precedence)
31    CodexUser = 0,
32}
33
34impl SkillLocationType {
35    /// Get location type from path
36    #[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/// Skill location configuration
75#[derive(Debug, Clone)]
76pub struct SkillLocation {
77    /// Location type for precedence
78    pub location_type: SkillLocationType,
79
80    /// Base directory path
81    pub base_path: PathBuf,
82
83    /// Scanning mode (recursive vs one-level)
84    pub recursive: bool,
85
86    /// Skill name separator for recursive mode
87    pub name_separator: char,
88}
89
90impl SkillLocation {
91    /// Create new skill location
92    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            _ => '/', // Default to path separator
96        };
97
98        Self {
99            location_type,
100            base_path,
101            recursive,
102            name_separator,
103        }
104    }
105
106    /// Check if this location exists
107    pub fn exists(&self) -> bool {
108        self.base_path.exists() && self.base_path.is_dir()
109    }
110
111    /// Get skill name from path
112    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        // Check if this path contains a SKILL.md file
118        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            // For recursive locations, build name with separators
134            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            // For one-level locations, just use the immediate directory name
151            skill_path
152                .file_name()
153                .and_then(|name| name.to_str())
154                .map(|s| s.to_string())
155        }
156    }
157}
158
159/// Skill locations manager
160pub struct SkillLocations {
161    locations: Vec<SkillLocation>,
162}
163
164impl SkillLocations {
165    /// Create new skill locations manager with default locations
166    pub fn new() -> Self {
167        Self::with_locations(Self::default_locations())
168    }
169
170    /// Create with custom locations
171    pub fn with_locations(locations: Vec<SkillLocation>) -> Self {
172        // Sort by precedence (highest first)
173        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    /// Get default skill locations following pi-mono pattern
182    pub fn default_locations() -> Vec<SkillLocation> {
183        vec![
184            // VT Code locations (highest precedence)
185            SkillLocation::new(
186                SkillLocationType::VtcodeUser,
187                PathBuf::from("~/.vtcode/skills"),
188                true, // recursive
189            ),
190            SkillLocation::new(
191                SkillLocationType::AgentsProject,
192                PathBuf::from(".agents/skills"),
193                true, // recursive
194            ),
195            SkillLocation::new(
196                SkillLocationType::VtcodeProject,
197                PathBuf::from(".vtcode/skills"),
198                true, // recursive
199            ),
200            // Pi locations (recursive with colon separator)
201            SkillLocation::new(
202                SkillLocationType::PiUser,
203                PathBuf::from("~/.pi/agent/skills"),
204                true, // recursive
205            ),
206            SkillLocation::new(
207                SkillLocationType::PiProject,
208                PathBuf::from(".pi/skills"),
209                true, // recursive
210            ),
211            // Claude Code locations (one-level only)
212            SkillLocation::new(
213                SkillLocationType::ClaudeUser,
214                PathBuf::from("~/.claude/skills"),
215                true, // recursive
216            ),
217            SkillLocation::new(
218                SkillLocationType::ClaudeProject,
219                PathBuf::from(".claude/skills"),
220                true, // recursive
221            ),
222            // Codex CLI locations (recursive)
223            SkillLocation::new(
224                SkillLocationType::CodexUser,
225                PathBuf::from("~/.codex/skills"),
226                true, // recursive
227            ),
228        ]
229    }
230
231    /// Discover all skills across all locations
232    pub fn discover_skills(&self) -> Result<Vec<DiscoveredSkill>> {
233        let mut discovered_skills = HashMap::new(); // skill_name -> (location_type, skill_context)
234        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        // Convert to final result
281        let mut final_skills: Vec<DiscoveredSkill> = discovered_skills.into_values().collect();
282
283        // Sort by location precedence (highest first) and then by name
284        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    /// Scan recursive location (Pi/Codex style)
297    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
307/// Walk directory recursively
308fn 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        // Prevent infinite recursion
317        return Ok(());
318    }
319
320    if !dir.exists() || !dir.is_dir() {
321        return Ok(());
322    }
323
324    // Check if this directory is a skill
325    if let Some(skill_name) = location.get_skill_name(dir) {
326        match parse_skill_file(dir) {
327            Ok((manifest, _)) => {
328                // Check if we already have this skill from a higher precedence location
329                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                    // Existing skill has higher precedence, skip this one
336                    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                // Add or update the skill
345                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    // Continue walking subdirectories
373    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    /// Scan one-level location (Claude style)
387    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                        // Check precedence
405                        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    /// Get all location types in precedence order
444    pub fn get_location_types(&self) -> Vec<SkillLocationType> {
445        self.locations.iter().map(|loc| loc.location_type).collect()
446    }
447
448    /// Get location by type
449    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/// Discovered skill with location information
457#[derive(Debug, Clone)]
458pub struct DiscoveredSkill {
459    /// Location type where skill was found
460    pub location_type: SkillLocationType,
461
462    /// Skill context (metadata only)
463    pub skill_context: SkillContext,
464
465    /// Path to skill directory
466    pub skill_path: PathBuf,
467
468    /// Generated skill name (with separators for recursive)
469    pub skill_name: String,
470}
471
472/// Discovery statistics
473#[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
488/// Convert location type to string for display
489impl 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        // Create nested skill structure
536        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, // recursive
544        );
545
546        let skill_name = location.get_skill_name(&skill_path);
547        // VT Code uses '/' as separator for recursive locations
548        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        // Create one-level skill structure
557        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        // Same manifest name in lower-precedence location should be ignored.
593        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        // Lower-precedence duplicate should be overwritten by AgentsProject entry.
637        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}