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                // Check if it's an executable
276                if self.is_executable(&entry)? {
277                    // Look for accompanying documentation
278                    let readme_path = self.find_tool_readme(&path);
279                    let schema_path = self.find_tool_schema(&path);
280
281                    let tool_name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown").to_string();
282
283                    let config = CliToolConfig {
284                        name: tool_name.clone(),
285                        description: format!("CLI tool: {tool_name}"),
286                        executable_path: path.clone(),
287                        readme_path,
288                        schema_path,
289                        timeout_seconds: Some(30),
290                        supports_json: false,
291                        environment: None,
292                        working_dir: Some(dir.to_path_buf()),
293                    };
294
295                    tools.push(config);
296                    stats.tools_found += 1;
297                    debug!("Discovered CLI tool: {} from {}", tool_name, path.display());
298                }
299            }
300        }
301
302        Ok(tools)
303    }
304
305    /// Discover system-wide CLI tools
306    fn discover_system_tools(&self, stats: &mut DiscoveryStats) -> Result<Vec<CliToolConfig>> {
307        info!("Auto-discovering system CLI tools");
308
309        match discover_cli_tools() {
310            Ok(tools) => {
311                stats.tools_found += tools.len();
312                Ok(tools)
313            }
314            Err(e) => {
315                warn!("Failed to auto-discover system tools: {}", e);
316                stats.errors_encountered += 1;
317                Ok(vec![])
318            }
319        }
320    }
321
322    /// Check if file is executable
323    fn is_executable(&self, entry: &std::fs::DirEntry) -> Result<bool> {
324        #[cfg(unix)]
325        {
326            use std::os::unix::fs::PermissionsExt;
327            let metadata = entry.metadata()?;
328            let permissions = metadata.permissions();
329            Ok(permissions.mode() & 0o111 != 0)
330        }
331
332        #[cfg(windows)]
333        {
334            if let Some(ext) = entry.path().extension() {
335                Ok(ext == "exe" || ext == "bat" || ext == "cmd")
336            } else {
337                Ok(false)
338            }
339        }
340    }
341
342    /// Find README file for tool
343    fn find_tool_readme(&self, tool_path: &Path) -> Option<PathBuf> {
344        let tool_name = tool_path.file_stem()?;
345        let readme_name = format!("{}.md", tool_name.to_str()?);
346        let readme_path = tool_path.with_file_name(&readme_name);
347
348        if readme_path.exists() {
349            Some(readme_path)
350        } else {
351            // Try generic README.md
352            let generic_readme = tool_path.parent()?.join("README.md");
353            if generic_readme.exists() {
354                Some(generic_readme)
355            } else {
356                None
357            }
358        }
359    }
360
361    /// Find JSON schema file for tool
362    fn find_tool_schema(&self, tool_path: &Path) -> Option<PathBuf> {
363        let tool_name = tool_path.file_stem()?;
364        let schema_name = format!("{}.json", tool_name.to_str()?);
365        let schema_path = tool_path.with_file_name(&schema_name);
366
367        if schema_path.exists() {
368            Some(schema_path)
369        } else {
370            // Try tool.json
371            let tool_json = tool_path.parent()?.join("tool.json");
372            if tool_json.exists() { Some(tool_json) } else { None }
373        }
374    }
375
376    /// Expand path with workspace root and home directory
377    fn expand_path(&self, path: &Path, workspace_root: &Path) -> PathBuf {
378        if path.starts_with("~") {
379            // Expand home directory
380            if let Ok(home) = std::env::var("HOME") {
381                let stripped = path.strip_prefix("~").unwrap_or(path);
382                return PathBuf::from(home).join(stripped);
383            }
384        }
385
386        if path.is_relative() {
387            // Make relative to workspace root
388            workspace_root.join(path)
389        } else {
390            path.to_path_buf()
391        }
392    }
393
394    /// Clear discovery cache
395    pub fn clear_cache(&mut self) {
396        self.cache.clear();
397        info!("Discovery cache cleared");
398    }
399
400    /// Get discovery statistics
401    pub fn get_stats(&self) -> DiscoveryStats {
402        DiscoveryStats {
403            directories_scanned: 0,
404            files_checked: 0,
405            skills_found: self.cache.values().map(|entry| entry.skills.len()).sum(),
406            tools_found: self.cache.values().map(|entry| entry.tools.len()).sum(),
407            errors_encountered: 0,
408            discovery_time_ms: 0,
409        }
410    }
411}
412
413fn default_codex_home() -> PathBuf {
414    std::env::var_os("CODEX_HOME")
415        .filter(|value| !value.is_empty())
416        .map(PathBuf::from)
417        .or_else(|| dirs::home_dir().map(|home| home.join(".codex")))
418        .unwrap_or_else(|| PathBuf::from(".codex"))
419}
420
421fn default_skill_paths(workspace_root: &Path) -> Vec<PathBuf> {
422    let mut paths = Vec::new();
423    let stop = find_git_root(workspace_root).unwrap_or_else(|| workspace_root.to_path_buf());
424    let mut current = workspace_root.to_path_buf();
425
426    loop {
427        paths.push(current.join(".agents/skills"));
428        if current == stop {
429            break;
430        }
431        let Some(parent) = current.parent() else {
432            break;
433        };
434        current = parent.to_path_buf();
435    }
436
437    if let Some(home) = dirs::home_dir() {
438        paths.push(home.join(".agents/skills"));
439    }
440    if let Ok(vtcode_paths) = VtCodePaths::resolve() {
441        paths.push(vtcode_paths.data_dir().join("skills"));
442        paths.push(vtcode_paths.legacy_dir().join("skills"));
443        paths.extend(vtcode_paths.system_data_paths("skills").unwrap_or_default());
444    }
445    #[cfg(unix)]
446    paths.push(PathBuf::from("/etc/codex/skills"));
447    paths.push(crate::skills::system::system_cache_root_dir(&default_codex_home()));
448    paths
449}
450
451fn find_git_root(path: &Path) -> Option<PathBuf> {
452    let mut current = Some(path);
453    while let Some(dir) = current {
454        if dir.join(".git").exists() {
455            return Some(dir.to_path_buf());
456        }
457        current = dir.parent();
458    }
459    None
460}
461
462/// Convert CLI tool configuration to SkillContext
463pub fn tool_config_to_skill_context(config: &CliToolConfig) -> Result<SkillContext> {
464    let manifest = SkillManifest {
465        name: config.name.clone(),
466        description: config.description.clone(),
467        version: Some("1.0.0".to_string()),
468        author: Some("VT Code CLI Discovery".to_string()),
469        variety: SkillVariety::SystemUtility,
470        ..Default::default()
471    };
472
473    Ok(SkillContext::MetadataOnly(manifest, config.executable_path.clone()))
474}
475
476/// Progressive skill loader that can load full skill details on demand
477pub struct ProgressiveSkillLoader {
478    discovery: SkillDiscovery,
479    skill_cache: HashMap<String, crate::skills::types::Skill>,
480}
481
482impl ProgressiveSkillLoader {
483    pub fn new(config: DiscoveryConfig) -> Self {
484        Self {
485            discovery: SkillDiscovery::with_config(config),
486            skill_cache: HashMap::new(),
487        }
488    }
489
490    /// Get skill metadata (lightweight)
491    pub async fn get_skill_metadata(&mut self, workspace_root: &Path, name: &str) -> Result<SkillContext> {
492        let result = self.discovery.discover_all(workspace_root).await?;
493
494        // Check traditional skills
495        for skill in &result.skills {
496            if skill.manifest().name == name {
497                return Ok(skill.clone());
498            }
499        }
500
501        // Check CLI tools
502        for tool in &result.tools {
503            if tool.name == name {
504                return tool_config_to_skill_context(tool);
505            }
506        }
507
508        Err(skill_ops::skill_not_found_error(name))
509    }
510
511    /// Load full skill with instructions and resources
512    pub async fn load_full_skill(&mut self, workspace_root: &Path, name: &str) -> Result<crate::skills::types::Skill> {
513        // Check cache first
514        if let Some(skill) = self.skill_cache.get(name) {
515            return Ok(skill.clone());
516        }
517
518        let result = self.discovery.discover_all(workspace_root).await?;
519
520        // Try traditional skills first
521        for skill_ctx in &result.skills {
522            if skill_ctx.manifest().name == name {
523                // Load full skill details
524                // This would require path information - simplified for now
525                let manifest = skill_ctx.manifest().clone();
526                let skill = crate::skills::types::Skill::new(
527                    manifest,
528                    workspace_root.to_path_buf(),
529                    "# Full instructions would be loaded here".to_string(),
530                )?;
531
532                self.skill_cache.insert(name.to_string(), skill.clone());
533                return Ok(skill);
534            }
535        }
536
537        // Try CLI tools
538        for tool_config in &result.tools {
539            if tool_config.name == name {
540                let bridge = CliToolBridge::new(tool_config.clone())?;
541                let skill = bridge.to_skill()?;
542
543                self.skill_cache.insert(name.to_string(), skill.clone());
544                return Ok(skill);
545            }
546        }
547
548        Err(skill_ops::skill_not_found_error(name))
549    }
550}
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555    use tempfile::TempDir;
556
557    #[tokio::test]
558    async fn test_discovery_config_default() {
559        let config = DiscoveryConfig::default();
560        assert!(config.skill_paths.is_empty());
561        assert!(!config.tool_paths.is_empty());
562        assert!(!config.auto_discover_system_tools); // Disabled by default for security
563    }
564
565    #[tokio::test]
566    async fn test_discovery_engine_creation() {
567        let discovery = SkillDiscovery::new();
568        assert_eq!(discovery.cache.len(), 0);
569    }
570
571    #[tokio::test]
572    async fn test_progressive_loader() {
573        let temp_dir = TempDir::new().unwrap();
574        let config = DiscoveryConfig::default();
575        let mut loader = ProgressiveSkillLoader::new(config);
576
577        // Should handle empty directory gracefully
578        let result = loader.discovery.discover_all(temp_dir.path()).await;
579        result.unwrap();
580    }
581}