Skip to main content

vtcode_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::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
14/// Maximum recursion depth when walking a skill location.
15///
16/// The Agent Skills client guide recommends 4-6 levels; kept at 10 for backward
17/// compatibility with deeply nested plugin layouts. Dependency and build-output
18/// trees are excluded separately via [`SKIPPED_DISCOVERY_DIRS`].
19pub const MAX_DISCOVERY_DEPTH: usize = 10;
20
21/// Maximum directories visited per discovery walk.
22///
23/// Bounds runaway scans of huge trees (monorepos, home directories) per the
24/// Agent Skills client guide's directory budget.
25pub const MAX_DISCOVERY_DIRS: usize = 2000;
26
27/// Directory names never descended into during skill discovery.
28///
29/// Build output, VCS metadata, and dependency trees cannot contain skills and
30/// dominate scan time in large repos (notably `node_modules`).
31pub const SKIPPED_DISCOVERY_DIRS: &[&str] = &[".git", ".hg", ".svn", "node_modules", "target"];
32
33/// Whether a directory name is skipped during skill discovery.
34pub fn discovery_dir_skipped(file_name: &str) -> bool {
35    SKIPPED_DISCOVERY_DIRS.contains(&file_name)
36}
37
38/// Skill location types with precedence ordering
39#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
40pub enum SkillLocationType {
41    /// VT Code user skills (highest precedence)
42    VtcodeUser = 7,
43    /// Project-level agent skills
44    AgentsProject = 6,
45    /// VT Code project skills
46    VtcodeProject = 5,
47    /// Pi user skills
48    PiUser = 4,
49    /// Pi project skills
50    PiProject = 3,
51    /// Claude Code user skills
52    ClaudeUser = 2,
53    /// Claude Code project skills
54    ClaudeProject = 1,
55    /// Codex CLI user skills (lowest precedence)
56    CodexUser = 0,
57}
58
59impl SkillLocationType {}
60
61/// Skill location configuration
62#[derive(Debug, Clone)]
63pub struct SkillLocation {
64    /// Location type for precedence
65    location_type: SkillLocationType,
66
67    /// Base directory path
68    base_path: PathBuf,
69
70    /// Scanning mode (recursive vs one-level)
71    recursive: bool,
72
73    /// Skill name separator for recursive mode
74    name_separator: char,
75}
76
77impl SkillLocation {
78    /// Create new skill location
79    fn new(location_type: SkillLocationType, base_path: PathBuf, recursive: bool) -> Self {
80        let name_separator = match location_type {
81            SkillLocationType::PiUser | SkillLocationType::PiProject => ':',
82            _ => '/', // Default to path separator
83        };
84
85        Self {
86            location_type,
87            base_path,
88            recursive,
89            name_separator,
90        }
91    }
92
93    /// Check if this location exists
94    fn exists(&self) -> bool {
95        self.base_path.exists() && self.base_path.is_dir()
96    }
97
98    /// Get skill name from path
99    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        // Check if this path contains a SKILL.md file
105        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            // For recursive locations, build name with separators
115            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            // For one-level locations, just use the immediate directory name
130            skill_path.file_name().and_then(|name| name.to_str()).map(|s| s.to_string())
131        }
132    }
133}
134
135/// Skill locations manager
136pub struct SkillLocations {
137    locations: Vec<SkillLocation>,
138}
139
140impl SkillLocations {
141    /// Create new skill locations manager with default locations
142    fn new() -> Self {
143        Self::with_locations(Self::default_locations())
144    }
145
146    /// Create with custom locations
147    fn with_locations(locations: Vec<SkillLocation>) -> Self {
148        // Sort by precedence (highest first)
149        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    /// Get default skill locations following pi-mono pattern
156    fn default_locations() -> Vec<SkillLocation> {
157        let mut locations = Vec::new();
158        if let Ok(paths) = VtCodePaths::resolve() {
159            // VT Code locations (highest precedence). If the global path
160            // policy is unavailable, omit this optional user location rather
161            // than creating or scanning state relative to the cwd.
162            locations.push(SkillLocation::new(
163                SkillLocationType::VtcodeUser,
164                paths.skills_dir(),
165                true, // recursive
166            ));
167        }
168        locations.extend([
169            SkillLocation::new(
170                SkillLocationType::AgentsProject,
171                PathBuf::from(".agents/skills"),
172                true, // recursive
173            ),
174            SkillLocation::new(
175                SkillLocationType::VtcodeProject,
176                PathBuf::from(".vtcode/skills"),
177                true, // recursive
178            ),
179            // Pi locations (recursive with colon separator)
180            SkillLocation::new(
181                SkillLocationType::PiUser,
182                PathBuf::from("~/.pi/agent/skills"),
183                true, // recursive
184            ),
185            SkillLocation::new(
186                SkillLocationType::PiProject,
187                PathBuf::from(".pi/skills"),
188                true, // recursive
189            ),
190            // Claude Code locations (one-level only)
191            SkillLocation::new(
192                SkillLocationType::ClaudeUser,
193                PathBuf::from("~/.claude/skills"),
194                true, // recursive
195            ),
196            SkillLocation::new(
197                SkillLocationType::ClaudeProject,
198                PathBuf::from(".claude/skills"),
199                true, // recursive
200            ),
201            // Codex CLI locations (recursive)
202            SkillLocation::new(
203                SkillLocationType::CodexUser,
204                PathBuf::from("~/.codex/skills"),
205                true, // recursive
206            ),
207        ]);
208        locations
209    }
210
211    /// Discover all skills across all locations
212    fn discover_skills(&self) -> Result<Vec<DiscoveredSkill>> {
213        let mut discovered_skills = HashMap::new(); // skill_name -> (location_type, skill_context)
214        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        // Convert to final result
246        let mut final_skills: Vec<DiscoveredSkill> = discovered_skills.into_values().collect();
247
248        // Sort by location precedence (highest first) and then by name
249        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    /// Scan recursive location (Pi/Codex style)
258    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
268/// Walk directory recursively
269fn 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        // Prevent infinite recursion
278        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    // Check if this directory is a skill
292    if let Some(skill_name) = location.get_skill_name(dir) {
293        match parse_skill_file(dir) {
294            Ok((manifest, _)) => {
295                // Check if we already have this skill from a higher precedence location
296                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                    // Existing skill has higher precedence, skip this one
301                    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                // Add or update the skill
310                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    // Continue walking subdirectories, skipping dependency, build-output,
333    // and VCS trees that cannot contain skills.
334    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    /// Scan one-level location (Claude style)
356    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                        // Check precedence
374                        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    /// Get all location types in precedence order
410    pub fn get_location_types(&self) -> Vec<SkillLocationType> {
411        self.locations.iter().map(|loc| loc.location_type).collect()
412    }
413
414    /// Get location by type
415    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/// Discovered skill with location information
421#[derive(Debug, Clone)]
422pub struct DiscoveredSkill {
423    /// Location type where skill was found
424    location_type: SkillLocationType,
425
426    /// Skill context (metadata only)
427    skill_context: SkillContext,
428
429    /// Path to skill directory
430    skill_path: PathBuf,
431
432    /// Generated skill name (with separators for recursive)
433    skill_name: String,
434}
435
436/// Discovery statistics
437#[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
453/// Convert location type to string for display
454impl 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        // Skills nested under dependency/VCS/build trees must not be found.
500        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        // Create nested skill structure
532        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, // recursive
540        );
541
542        let skill_name = location.get_skill_name(&skill_path);
543        // VT Code uses '/' as separator for recursive locations
544        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        // Create one-level skill structure
553        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        // Same manifest name in lower-precedence location should be ignored.
573        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        // Lower-precedence duplicate should be overwritten by AgentsProject entry.
603        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}