Skip to main content

vtcode_core/skills/
discovery.rs

1//! Dynamic Skill Discovery System
2//!
3//! Implements filesystem-based skill discovery with support for:
4//! - Traditional VT Code skills (SKILL.md files)
5//! - CLI tool skills (executable + README.md)
6//! - Auto-discovery of tools in standard locations
7//! - Progressive metadata loading
8
9use crate::skills::cli_bridge::{CliToolBridge, CliToolConfig, discover_cli_tools};
10use crate::skills::manifest::parse_skill_file;
11use crate::skills::types::{SkillContext, SkillManifest, SkillVariety};
12use crate::tools::error_messages::skill_ops;
13use anyhow::{Context, Result};
14use hashbrown::HashMap;
15use serde::{Deserialize, Serialize};
16use std::path::{Path, PathBuf};
17use tracing::{debug, info, warn};
18use vtcode_commons::VtCodePaths;
19
20/// Enhanced skill discovery configuration
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct DiscoveryConfig {
23    /// Search paths for traditional skills
24    pub skill_paths: Vec<PathBuf>,
25
26    /// Search paths for CLI tools
27    pub tool_paths: Vec<PathBuf>,
28
29    /// Auto-discover system tools
30    pub auto_discover_system_tools: bool,
31
32    /// Maximum depth for recursive directory scanning
33    pub max_depth: usize,
34
35    /// File patterns to consider as skills
36    pub skill_patterns: Vec<String>,
37
38    /// Tool file patterns
39    pub tool_patterns: Vec<String>,
40}
41
42impl Default for DiscoveryConfig {
43    fn default() -> Self {
44        let mut tool_paths = vec![PathBuf::from("./tools"), PathBuf::from("./vendor/tools")];
45        if let Ok(paths) = VtCodePaths::resolve() {
46            if let Ok(user_tool_path) = paths.data_path("tools") {
47                tool_paths.push(user_tool_path);
48            }
49            tool_paths.push(paths.legacy_dir().join("tools"));
50        }
51        Self {
52            skill_paths: Vec::new(),
53            tool_paths,
54            auto_discover_system_tools: false,
55            max_depth: 3,
56            skill_patterns: vec!["SKILL.md".to_string()],
57            tool_patterns: vec!["*.exe".to_string(), "*.sh".to_string(), "*.py".to_string()],
58        }
59    }
60}
61
62/// Discovery result containing both traditional skills and CLI tools
63#[derive(Debug, Clone)]
64pub struct DiscoveryResult {
65    /// Traditional VT Code skills
66    pub skills: Vec<SkillContext>,
67
68    /// CLI tool configurations
69    pub tools: Vec<CliToolConfig>,
70
71    /// Discovery statistics
72    pub stats: DiscoveryStats,
73}
74
75/// Discovery statistics
76#[derive(Debug, Clone, Default, Serialize, Deserialize)]
77pub struct DiscoveryStats {
78    pub directories_scanned: usize,
79    pub files_checked: usize,
80    pub skills_found: usize,
81    pub tools_found: usize,
82    pub errors_encountered: usize,
83    pub discovery_time_ms: u64,
84}
85
86/// Dynamic skill discovery engine
87pub struct SkillDiscovery {
88    config: DiscoveryConfig,
89    cache: HashMap<PathBuf, DiscoveryCacheEntry>,
90}
91
92#[derive(Debug, Clone)]
93struct DiscoveryCacheEntry {
94    skills: Vec<SkillContext>,
95    tools: Vec<CliToolConfig>,
96}
97
98impl SkillDiscovery {
99    /// Create new discovery engine with default configuration
100    pub fn new() -> Self {
101        Self::with_config(DiscoveryConfig::default())
102    }
103
104    /// Create new discovery engine with custom configuration
105    pub fn with_config(config: DiscoveryConfig) -> Self {
106        Self { config, cache: HashMap::new() }
107    }
108}
109
110impl Default for SkillDiscovery {
111    fn default() -> Self {
112        Self::new()
113    }
114}
115
116impl SkillDiscovery {
117    /// Discover all available skills and tools
118    pub async fn discover_all(&mut self, workspace_root: &Path) -> Result<DiscoveryResult> {
119        let start_time = std::time::Instant::now();
120        info!("Starting skill discovery in: {}", workspace_root.display());
121
122        let config = self.config.clone();
123        let workspace_root = workspace_root.to_path_buf();
124        let (skills, tools, mut stats) = tokio::task::spawn_blocking(move || {
125            let mut worker = Self::with_config(config);
126            let mut stats = DiscoveryStats::default();
127            let skills = worker.discover_traditional_skills(&workspace_root, &mut stats)?;
128            let mut tools = worker.discover_cli_tools(&workspace_root, &mut stats)?;
129            if worker.config.auto_discover_system_tools {
130                tools.extend(worker.discover_system_tools(&mut stats)?);
131            }
132            Ok::<_, anyhow::Error>((skills, tools, stats))
133        })
134        .await
135        .context("skill discovery task panicked")??;
136
137        stats.discovery_time_ms = start_time.elapsed().as_millis() as u64;
138        Ok(DiscoveryResult { skills, tools, stats })
139    }
140
141    /// Discover traditional VT Code skills
142    fn discover_traditional_skills(
143        &mut self,
144        workspace_root: &Path,
145        stats: &mut DiscoveryStats,
146    ) -> Result<Vec<SkillContext>> {
147        let mut skills = vec![];
148        let skill_paths = if self.config.skill_paths.is_empty() {
149            default_skill_paths(workspace_root)
150        } else {
151            self.config.skill_paths.clone()
152        };
153
154        for skill_path in &skill_paths {
155            let full_path = self.expand_path(skill_path, workspace_root);
156
157            if !full_path.exists() {
158                debug!("Skill path does not exist: {}", full_path.display());
159                continue;
160            }
161
162            stats.directories_scanned += 1;
163
164            // Scan for skill directories
165            match self.scan_for_skills(&full_path, stats) {
166                Ok(found_skills) => {
167                    info!("Found {} skills in {}", found_skills.len(), full_path.display());
168                    skills.extend(found_skills);
169                }
170                Err(e) => {
171                    warn!("Failed to scan {}: {}", full_path.display(), e);
172                    stats.errors_encountered += 1;
173                }
174            }
175        }
176
177        Ok(skills)
178    }
179
180    /// Scan directory for traditional skills
181    fn scan_for_skills(&self, dir: &Path, stats: &mut DiscoveryStats) -> Result<Vec<SkillContext>> {
182        self.scan_for_skills_recursive(dir, stats, 0)
183    }
184
185    fn scan_for_skills_recursive(
186        &self,
187        dir: &Path,
188        stats: &mut DiscoveryStats,
189        depth: usize,
190    ) -> Result<Vec<SkillContext>> {
191        let mut skills = vec![];
192
193        if depth > self.config.max_depth {
194            return Ok(skills);
195        }
196
197        for entry in std::fs::read_dir(dir)? {
198            let entry = entry?;
199            let path = entry.path();
200
201            if path.is_dir() {
202                stats.directories_scanned += 1;
203
204                // Check for SKILL.md file
205                let skill_file = path.join("SKILL.md");
206                if skill_file.exists() {
207                    stats.files_checked += 1;
208
209                    match parse_skill_file(&path) {
210                        Ok((manifest, _instructions)) => {
211                            skills.push(SkillContext::MetadataOnly(manifest, path.to_path_buf()));
212                            stats.skills_found += 1;
213                            let skill_name = skills
214                                .last()
215                                .map(|ctx| ctx.manifest().name.clone())
216                                .unwrap_or_else(|| "<unknown>".to_string());
217                            info!("Discovered skill: {} from {}", skill_name, path.display());
218                        }
219                        Err(e) => {
220                            warn!("Failed to parse skill from {}: {}", path.display(), e);
221                            stats.errors_encountered += 1;
222                        }
223                    }
224                }
225
226                if depth < self.config.max_depth {
227                    skills.extend(self.scan_for_skills_recursive(&path, stats, depth + 1)?);
228                }
229            }
230        }
231
232        Ok(skills)
233    }
234
235    /// Discover CLI tools in workspace
236    fn discover_cli_tools(&mut self, workspace_root: &Path, stats: &mut DiscoveryStats) -> Result<Vec<CliToolConfig>> {
237        let mut tools = vec![];
238
239        for tool_path in &self.config.tool_paths {
240            let full_path = self.expand_path(tool_path, workspace_root);
241
242            if !full_path.exists() {
243                debug!("Tool path does not exist: {}", full_path.display());
244                continue;
245            }
246
247            stats.directories_scanned += 1;
248
249            match self.scan_for_tools(&full_path, stats) {
250                Ok(found_tools) => {
251                    info!("Found {} tools in {}", found_tools.len(), full_path.display());
252                    tools.extend(found_tools);
253                }
254                Err(e) => {
255                    warn!("Failed to scan {}: {}", full_path.display(), e);
256                    stats.errors_encountered += 1;
257                }
258            }
259        }
260
261        Ok(tools)
262    }
263
264    /// Scan directory for CLI tools
265    fn scan_for_tools(&self, dir: &Path, stats: &mut DiscoveryStats) -> Result<Vec<CliToolConfig>> {
266        let mut tools = vec![];
267
268        for entry in std::fs::read_dir(dir)? {
269            let entry = entry?;
270            let path = entry.path();
271
272            if path.is_file() {
273                stats.files_checked += 1;
274
275                // Single shared predicate lives in `cli_bridge` so the
276                // "executable + explicit docs" rule cannot drift between
277                // scanners.
278                if let Some(config) = crate::skills::cli_bridge::cli_tool_config_from_executable(dir, &path) {
279                    debug!("Discovered CLI tool: {} from {}", config.name, path.display());
280                    tools.push(config);
281                    stats.tools_found += 1;
282                }
283            }
284        }
285
286        Ok(tools)
287    }
288
289    /// Discover system-wide CLI tools
290    fn discover_system_tools(&self, stats: &mut DiscoveryStats) -> Result<Vec<CliToolConfig>> {
291        info!("Auto-discovering system CLI tools");
292
293        match discover_cli_tools() {
294            Ok(tools) => {
295                stats.tools_found += tools.len();
296                Ok(tools)
297            }
298            Err(e) => {
299                warn!("Failed to auto-discover system tools: {}", e);
300                stats.errors_encountered += 1;
301                Ok(vec![])
302            }
303        }
304    }
305
306    /// Expand path with workspace root and home directory
307    fn expand_path(&self, path: &Path, workspace_root: &Path) -> PathBuf {
308        if path.starts_with("~") {
309            // Expand home directory
310            if let Ok(home) = std::env::var("HOME") {
311                let stripped = path.strip_prefix("~").unwrap_or(path);
312                return PathBuf::from(home).join(stripped);
313            }
314        }
315
316        if path.is_relative() {
317            // Make relative to workspace root
318            workspace_root.join(path)
319        } else {
320            path.to_path_buf()
321        }
322    }
323
324    /// Clear discovery cache
325    pub fn clear_cache(&mut self) {
326        self.cache.clear();
327        info!("Discovery cache cleared");
328    }
329
330    /// Get discovery statistics
331    pub fn get_stats(&self) -> DiscoveryStats {
332        DiscoveryStats {
333            directories_scanned: 0,
334            files_checked: 0,
335            skills_found: self.cache.values().map(|entry| entry.skills.len()).sum(),
336            tools_found: self.cache.values().map(|entry| entry.tools.len()).sum(),
337            errors_encountered: 0,
338            discovery_time_ms: 0,
339        }
340    }
341}
342
343fn default_codex_home() -> PathBuf {
344    std::env::var_os("CODEX_HOME")
345        .filter(|value| !value.is_empty())
346        .map(PathBuf::from)
347        .or_else(|| dirs::home_dir().map(|home| home.join(".codex")))
348        .unwrap_or_else(|| PathBuf::from(".codex"))
349}
350
351fn default_skill_paths(workspace_root: &Path) -> Vec<PathBuf> {
352    let mut paths = Vec::new();
353    let stop = find_git_root(workspace_root).unwrap_or_else(|| workspace_root.to_path_buf());
354    let mut current = workspace_root.to_path_buf();
355
356    loop {
357        paths.push(current.join(".agents/skills"));
358        if current == stop {
359            break;
360        }
361        let Some(parent) = current.parent() else {
362            break;
363        };
364        current = parent.to_path_buf();
365    }
366
367    if let Some(home) = dirs::home_dir() {
368        paths.push(home.join(".agents/skills"));
369    }
370    if let Ok(vtcode_paths) = VtCodePaths::resolve() {
371        paths.push(vtcode_paths.data_dir().join("skills"));
372        paths.push(vtcode_paths.legacy_dir().join("skills"));
373        paths.extend(vtcode_paths.system_data_paths("skills").unwrap_or_default());
374    }
375    #[cfg(unix)]
376    paths.push(PathBuf::from("/etc/codex/skills"));
377    paths.push(crate::skills::system::system_cache_root_dir(&default_codex_home()));
378    paths
379}
380
381fn find_git_root(path: &Path) -> Option<PathBuf> {
382    let mut current = Some(path);
383    while let Some(dir) = current {
384        if dir.join(".git").exists() {
385            return Some(dir.to_path_buf());
386        }
387        current = dir.parent();
388    }
389    None
390}
391
392/// Convert CLI tool configuration to SkillContext
393pub fn tool_config_to_skill_context(config: &CliToolConfig) -> Result<SkillContext> {
394    let manifest = SkillManifest {
395        name: config.name.clone(),
396        description: config.description.clone(),
397        version: Some("1.0.0".to_string()),
398        author: Some("VT Code CLI Discovery".to_string()),
399        variety: SkillVariety::SystemUtility,
400        ..Default::default()
401    };
402
403    Ok(SkillContext::MetadataOnly(manifest, config.executable_path.clone()))
404}
405
406/// Progressive skill loader that can load full skill details on demand
407pub struct ProgressiveSkillLoader {
408    discovery: SkillDiscovery,
409    skill_cache: HashMap<String, crate::skills::types::Skill>,
410}
411
412impl ProgressiveSkillLoader {
413    pub fn new(config: DiscoveryConfig) -> Self {
414        Self {
415            discovery: SkillDiscovery::with_config(config),
416            skill_cache: HashMap::new(),
417        }
418    }
419
420    /// Get skill metadata (lightweight)
421    pub async fn get_skill_metadata(&mut self, workspace_root: &Path, name: &str) -> Result<SkillContext> {
422        let result = self.discovery.discover_all(workspace_root).await?;
423
424        // Check traditional skills
425        for skill in &result.skills {
426            if skill.manifest().name == name {
427                return Ok(skill.clone());
428            }
429        }
430
431        // Check CLI tools
432        for tool in &result.tools {
433            if tool.name == name {
434                return tool_config_to_skill_context(tool);
435            }
436        }
437
438        Err(skill_ops::skill_not_found_error(name))
439    }
440
441    /// Load full skill with instructions and resources
442    pub async fn load_full_skill(&mut self, workspace_root: &Path, name: &str) -> Result<crate::skills::types::Skill> {
443        // Check cache first
444        if let Some(skill) = self.skill_cache.get(name) {
445            return Ok(skill.clone());
446        }
447
448        let result = self.discovery.discover_all(workspace_root).await?;
449
450        // Try traditional skills first
451        for skill_ctx in &result.skills {
452            if skill_ctx.manifest().name == name {
453                // Load full skill details
454                // This would require path information - simplified for now
455                let manifest = skill_ctx.manifest().clone();
456                let skill = crate::skills::types::Skill::new(
457                    manifest,
458                    workspace_root.to_path_buf(),
459                    "# Full instructions would be loaded here".to_string(),
460                )?;
461
462                self.skill_cache.insert(name.to_string(), skill.clone());
463                return Ok(skill);
464            }
465        }
466
467        // Try CLI tools
468        for tool_config in &result.tools {
469            if tool_config.name == name {
470                let bridge = CliToolBridge::new(tool_config.clone())?;
471                let skill = bridge.to_skill()?;
472
473                self.skill_cache.insert(name.to_string(), skill.clone());
474                return Ok(skill);
475            }
476        }
477
478        Err(skill_ops::skill_not_found_error(name))
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485    use tempfile::TempDir;
486
487    #[tokio::test]
488    async fn test_discovery_config_default() {
489        let config = DiscoveryConfig::default();
490        assert!(config.skill_paths.is_empty());
491        assert!(!config.tool_paths.is_empty());
492        assert!(!config.auto_discover_system_tools); // Disabled by default for security
493    }
494
495    #[tokio::test]
496    async fn test_discovery_engine_creation() {
497        let discovery = SkillDiscovery::new();
498        assert_eq!(discovery.cache.len(), 0);
499    }
500
501    #[tokio::test]
502    async fn test_progressive_loader() {
503        let temp_dir = TempDir::new().unwrap();
504        let config = DiscoveryConfig::default();
505        let mut loader = ProgressiveSkillLoader::new(config);
506
507        // Should handle empty directory gracefully
508        let result = loader.discovery.discover_all(temp_dir.path()).await;
509        result.unwrap();
510    }
511
512    #[cfg(unix)]
513    #[test]
514    fn scan_for_tools_shares_the_documented_executable_rule() {
515        use std::os::unix::fs::PermissionsExt;
516
517        let temp_dir = TempDir::new().unwrap();
518        let write_executable = |name: &str| {
519            let path = temp_dir.path().join(name);
520            std::fs::write(&path, "#!/bin/sh\necho hi\n").unwrap();
521            let mut perms = std::fs::metadata(&path).unwrap().permissions();
522            perms.set_mode(0o755);
523            std::fs::set_permissions(&path, perms).unwrap();
524        };
525        // Asymmetric pair: same executable bit, docs present vs absent.
526        write_executable("kept-tool");
527        std::fs::write(temp_dir.path().join("kept-tool.md"), "# kept\n").unwrap();
528        write_executable("dropped-tool");
529
530        let worker = SkillDiscovery::new();
531        let mut stats = DiscoveryStats::default();
532        let tools = worker.scan_for_tools(temp_dir.path(), &mut stats).unwrap();
533        let names: Vec<&str> = tools.iter().map(|tool| tool.name.as_str()).collect();
534        assert_eq!(names, vec!["kept-tool"], "only the documented executable is a skill");
535        assert_eq!(stats.tools_found, 1);
536    }
537}