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::Result;
14use hashbrown::HashMap;
15use serde::{Deserialize, Serialize};
16use std::path::{Path, PathBuf};
17use tracing::{debug, info, warn};
18
19/// Enhanced skill discovery configuration
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct DiscoveryConfig {
22    /// Search paths for traditional skills
23    pub skill_paths: Vec<PathBuf>,
24
25    /// Search paths for CLI tools
26    pub tool_paths: Vec<PathBuf>,
27
28    /// Auto-discover system tools
29    pub auto_discover_system_tools: bool,
30
31    /// Maximum depth for recursive directory scanning
32    pub max_depth: usize,
33
34    /// File patterns to consider as skills
35    pub skill_patterns: Vec<String>,
36
37    /// Tool file patterns
38    pub tool_patterns: Vec<String>,
39}
40
41impl Default for DiscoveryConfig {
42    fn default() -> Self {
43        Self {
44            skill_paths: Vec::new(),
45            tool_paths: vec![
46                PathBuf::from("./tools"),
47                PathBuf::from("./vendor/tools"),
48                PathBuf::from("~/.vtcode/tools"),
49            ],
50            auto_discover_system_tools: false,
51            max_depth: 3,
52            skill_patterns: vec!["SKILL.md".to_string()],
53            tool_patterns: vec!["*.exe".to_string(), "*.sh".to_string(), "*.py".to_string()],
54        }
55    }
56}
57
58/// Discovery result containing both traditional skills and CLI tools
59#[derive(Debug, Clone)]
60pub struct DiscoveryResult {
61    /// Traditional VT Code skills
62    pub skills: Vec<SkillContext>,
63
64    /// CLI tool configurations
65    pub tools: Vec<CliToolConfig>,
66
67    /// Discovery statistics
68    pub stats: DiscoveryStats,
69}
70
71/// Discovery statistics
72#[derive(Debug, Clone, Default, Serialize, Deserialize)]
73pub struct DiscoveryStats {
74    pub directories_scanned: usize,
75    pub files_checked: usize,
76    pub skills_found: usize,
77    pub tools_found: usize,
78    pub errors_encountered: usize,
79    pub discovery_time_ms: u64,
80}
81
82/// Dynamic skill discovery engine
83pub struct SkillDiscovery {
84    config: DiscoveryConfig,
85    cache: HashMap<PathBuf, DiscoveryCacheEntry>,
86}
87
88#[derive(Debug, Clone)]
89struct DiscoveryCacheEntry {
90    timestamp: std::time::SystemTime,
91    skills: Vec<SkillContext>,
92    tools: Vec<CliToolConfig>,
93}
94
95impl SkillDiscovery {
96    /// Create new discovery engine with default configuration
97    pub fn new() -> Self {
98        Self::with_config(DiscoveryConfig::default())
99    }
100
101    /// Create new discovery engine with custom configuration
102    pub fn with_config(config: DiscoveryConfig) -> Self {
103        Self {
104            config,
105            cache: HashMap::new(),
106        }
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        let mut stats = DiscoveryStats::default();
121
122        info!("Starting skill discovery in: {}", workspace_root.display());
123
124        // Discover traditional skills
125        let skills = self
126            .discover_traditional_skills(workspace_root, &mut stats)
127            .await?;
128
129        // Discover CLI tools
130        let tools = self.discover_cli_tools(workspace_root, &mut stats).await?;
131
132        // Auto-discover system tools if enabled
133        if self.config.auto_discover_system_tools {
134            let system_tools = self.discover_system_tools(&mut stats).await?;
135            let mut all_tools = tools;
136            all_tools.extend(system_tools);
137
138            stats.discovery_time_ms = start_time.elapsed().as_millis() as u64;
139
140            Ok(DiscoveryResult {
141                skills,
142                tools: all_tools,
143                stats,
144            })
145        } else {
146            stats.discovery_time_ms = start_time.elapsed().as_millis() as u64;
147
148            Ok(DiscoveryResult {
149                skills,
150                tools,
151                stats,
152            })
153        }
154    }
155
156    /// Discover traditional VT Code skills
157    async fn discover_traditional_skills(
158        &mut self,
159        workspace_root: &Path,
160        stats: &mut DiscoveryStats,
161    ) -> Result<Vec<SkillContext>> {
162        let mut skills = vec![];
163        let skill_paths = if self.config.skill_paths.is_empty() {
164            default_skill_paths(workspace_root)
165        } else {
166            self.config.skill_paths.clone()
167        };
168
169        for skill_path in &skill_paths {
170            let full_path = self.expand_path(skill_path, workspace_root);
171
172            if !full_path.exists() {
173                debug!("Skill path does not exist: {}", full_path.display());
174                continue;
175            }
176
177            stats.directories_scanned += 1;
178
179            // Scan for skill directories
180            match self.scan_for_skills(&full_path, stats) {
181                Ok(found_skills) => {
182                    info!(
183                        "Found {} skills in {}",
184                        found_skills.len(),
185                        full_path.display()
186                    );
187                    skills.extend(found_skills);
188                }
189                Err(e) => {
190                    warn!("Failed to scan {}: {}", full_path.display(), e);
191                    stats.errors_encountered += 1;
192                }
193            }
194        }
195
196        Ok(skills)
197    }
198
199    /// Scan directory for traditional skills
200    fn scan_for_skills(&self, dir: &Path, stats: &mut DiscoveryStats) -> Result<Vec<SkillContext>> {
201        self.scan_for_skills_recursive(dir, stats, 0)
202    }
203
204    fn scan_for_skills_recursive(
205        &self,
206        dir: &Path,
207        stats: &mut DiscoveryStats,
208        depth: usize,
209    ) -> Result<Vec<SkillContext>> {
210        let mut skills = vec![];
211
212        if depth > self.config.max_depth {
213            return Ok(skills);
214        }
215
216        for entry in std::fs::read_dir(dir)? {
217            let entry = entry?;
218            let path = entry.path();
219
220            if path.is_dir() {
221                stats.directories_scanned += 1;
222
223                // Check for SKILL.md file
224                let skill_file = path.join("SKILL.md");
225                if skill_file.exists() {
226                    stats.files_checked += 1;
227
228                    match parse_skill_file(&path) {
229                        Ok((manifest, _instructions)) => {
230                            skills.push(SkillContext::MetadataOnly(manifest, path.to_path_buf()));
231                            stats.skills_found += 1;
232                            let skill_name = skills
233                                .last()
234                                .map(|ctx| ctx.manifest().name.clone())
235                                .unwrap_or_else(|| "<unknown>".to_string());
236                            info!("Discovered skill: {} from {}", skill_name, path.display());
237                        }
238                        Err(e) => {
239                            warn!("Failed to parse skill from {}: {}", path.display(), e);
240                            stats.errors_encountered += 1;
241                        }
242                    }
243                }
244
245                if depth < self.config.max_depth {
246                    skills.extend(self.scan_for_skills_recursive(&path, stats, depth + 1)?);
247                }
248            }
249        }
250
251        Ok(skills)
252    }
253
254    /// Discover CLI tools in workspace
255    async fn discover_cli_tools(
256        &mut self,
257        workspace_root: &Path,
258        stats: &mut DiscoveryStats,
259    ) -> Result<Vec<CliToolConfig>> {
260        let mut tools = vec![];
261
262        for tool_path in &self.config.tool_paths {
263            let full_path = self.expand_path(tool_path, workspace_root);
264
265            if !full_path.exists() {
266                debug!("Tool path does not exist: {}", full_path.display());
267                continue;
268            }
269
270            stats.directories_scanned += 1;
271
272            match self.scan_for_tools(&full_path, stats).await {
273                Ok(found_tools) => {
274                    info!(
275                        "Found {} tools in {}",
276                        found_tools.len(),
277                        full_path.display()
278                    );
279                    tools.extend(found_tools);
280                }
281                Err(e) => {
282                    warn!("Failed to scan {}: {}", full_path.display(), e);
283                    stats.errors_encountered += 1;
284                }
285            }
286        }
287
288        Ok(tools)
289    }
290
291    /// Scan directory for CLI tools
292    async fn scan_for_tools(
293        &self,
294        dir: &Path,
295        stats: &mut DiscoveryStats,
296    ) -> Result<Vec<CliToolConfig>> {
297        let mut tools = vec![];
298
299        for entry in std::fs::read_dir(dir)? {
300            let entry = entry?;
301            let path = entry.path();
302
303            if path.is_file() {
304                stats.files_checked += 1;
305
306                // Check if it's an executable
307                if self.is_executable(&entry)? {
308                    // Look for accompanying documentation
309                    let readme_path = self.find_tool_readme(&path);
310                    let schema_path = self.find_tool_schema(&path);
311
312                    let tool_name = path
313                        .file_stem()
314                        .and_then(|s| s.to_str())
315                        .unwrap_or("unknown")
316                        .to_string();
317
318                    let config = CliToolConfig {
319                        name: tool_name.clone(),
320                        description: format!("CLI tool: {}", tool_name),
321                        executable_path: path.clone(),
322                        readme_path,
323                        schema_path,
324                        timeout_seconds: Some(30),
325                        supports_json: false,
326                        environment: None,
327                        working_dir: Some(dir.to_path_buf()),
328                    };
329
330                    tools.push(config);
331                    stats.tools_found += 1;
332                    debug!("Discovered CLI tool: {} from {}", tool_name, path.display());
333                }
334            }
335        }
336
337        Ok(tools)
338    }
339
340    /// Discover system-wide CLI tools
341    async fn discover_system_tools(
342        &self,
343        stats: &mut DiscoveryStats,
344    ) -> Result<Vec<CliToolConfig>> {
345        info!("Auto-discovering system CLI tools");
346
347        match discover_cli_tools() {
348            Ok(tools) => {
349                stats.tools_found += tools.len();
350                Ok(tools)
351            }
352            Err(e) => {
353                warn!("Failed to auto-discover system tools: {}", e);
354                stats.errors_encountered += 1;
355                Ok(vec![])
356            }
357        }
358    }
359
360    /// Check if file is executable
361    fn is_executable(&self, entry: &std::fs::DirEntry) -> Result<bool> {
362        #[cfg(unix)]
363        {
364            use std::os::unix::fs::PermissionsExt;
365            let metadata = entry.metadata()?;
366            let permissions = metadata.permissions();
367            Ok(permissions.mode() & 0o111 != 0)
368        }
369
370        #[cfg(windows)]
371        {
372            if let Some(ext) = entry.path().extension() {
373                Ok(ext == "exe" || ext == "bat" || ext == "cmd")
374            } else {
375                Ok(false)
376            }
377        }
378    }
379
380    /// Find README file for tool
381    fn find_tool_readme(&self, tool_path: &Path) -> Option<PathBuf> {
382        let tool_name = tool_path.file_stem()?;
383        let readme_name = format!("{}.md", tool_name.to_str()?);
384        let readme_path = tool_path.with_file_name(&readme_name);
385
386        if readme_path.exists() {
387            Some(readme_path)
388        } else {
389            // Try generic README.md
390            let generic_readme = tool_path.parent()?.join("README.md");
391            if generic_readme.exists() {
392                Some(generic_readme)
393            } else {
394                None
395            }
396        }
397    }
398
399    /// Find JSON schema file for tool
400    fn find_tool_schema(&self, tool_path: &Path) -> Option<PathBuf> {
401        let tool_name = tool_path.file_stem()?;
402        let schema_name = format!("{}.json", tool_name.to_str()?);
403        let schema_path = tool_path.with_file_name(&schema_name);
404
405        if schema_path.exists() {
406            Some(schema_path)
407        } else {
408            // Try tool.json
409            let tool_json = tool_path.parent()?.join("tool.json");
410            if tool_json.exists() {
411                Some(tool_json)
412            } else {
413                None
414            }
415        }
416    }
417
418    /// Expand path with workspace root and home directory
419    fn expand_path(&self, path: &Path, workspace_root: &Path) -> PathBuf {
420        if path.starts_with("~") {
421            // Expand home directory
422            if let Ok(home) = std::env::var("HOME") {
423                let stripped = path.strip_prefix("~").unwrap_or(path);
424                return PathBuf::from(home).join(stripped);
425            }
426        }
427
428        if path.is_relative() {
429            // Make relative to workspace root
430            workspace_root.join(path)
431        } else {
432            path.to_path_buf()
433        }
434    }
435
436    /// Get cached discovery result for path
437    #[expect(dead_code)]
438    fn get_cached(&self, path: &Path) -> Option<&DiscoveryCacheEntry> {
439        self.cache.get(path).and_then(|entry| {
440            // Check if cache is still valid (5 minutes)
441            let elapsed = entry.timestamp.elapsed().ok()?;
442            if elapsed.as_secs() < 300 {
443                Some(entry)
444            } else {
445                None
446            }
447        })
448    }
449
450    /// Cache discovery result
451    #[expect(dead_code)]
452    fn cache_result(
453        &mut self,
454        path: PathBuf,
455        skills: Vec<SkillContext>,
456        tools: Vec<CliToolConfig>,
457    ) {
458        self.cache.insert(
459            path,
460            DiscoveryCacheEntry {
461                timestamp: std::time::SystemTime::now(),
462                skills,
463                tools,
464            },
465        );
466    }
467
468    /// Clear discovery cache
469    pub fn clear_cache(&mut self) {
470        self.cache.clear();
471        info!("Discovery cache cleared");
472    }
473
474    /// Get discovery statistics
475    pub fn get_stats(&self) -> DiscoveryStats {
476        DiscoveryStats {
477            directories_scanned: 0,
478            files_checked: 0,
479            skills_found: self.cache.values().map(|entry| entry.skills.len()).sum(),
480            tools_found: self.cache.values().map(|entry| entry.tools.len()).sum(),
481            errors_encountered: 0,
482            discovery_time_ms: 0,
483        }
484    }
485}
486
487fn default_codex_home() -> PathBuf {
488    std::env::var_os("CODEX_HOME")
489        .filter(|value| !value.is_empty())
490        .map(PathBuf::from)
491        .or_else(|| dirs::home_dir().map(|home| home.join(".codex")))
492        .unwrap_or_else(|| PathBuf::from(".codex"))
493}
494
495fn default_skill_paths(workspace_root: &Path) -> Vec<PathBuf> {
496    let mut paths = Vec::new();
497    let stop = find_git_root(workspace_root).unwrap_or_else(|| workspace_root.to_path_buf());
498    let mut current = workspace_root.to_path_buf();
499
500    loop {
501        paths.push(current.join(".agents/skills"));
502        if current == stop {
503            break;
504        }
505        let Some(parent) = current.parent() else {
506            break;
507        };
508        current = parent.to_path_buf();
509    }
510
511    if let Some(home) = dirs::home_dir() {
512        paths.push(home.join(".agents/skills"));
513    }
514    #[cfg(unix)]
515    paths.push(PathBuf::from("/etc/codex/skills"));
516    paths.push(crate::skills::system::system_cache_root_dir(
517        &default_codex_home(),
518    ));
519    paths
520}
521
522fn find_git_root(path: &Path) -> Option<PathBuf> {
523    let mut current = Some(path);
524    while let Some(dir) = current {
525        if dir.join(".git").exists() {
526            return Some(dir.to_path_buf());
527        }
528        current = dir.parent();
529    }
530    None
531}
532
533/// Convert CLI tool configuration to SkillContext
534pub fn tool_config_to_skill_context(config: &CliToolConfig) -> Result<SkillContext> {
535    let manifest = SkillManifest {
536        name: config.name.clone(),
537        description: config.description.clone(),
538        version: Some("1.0.0".to_string()),
539        author: Some("VT Code CLI Discovery".to_string()),
540        variety: SkillVariety::SystemUtility,
541        ..Default::default()
542    };
543
544    Ok(SkillContext::MetadataOnly(
545        manifest,
546        config.executable_path.clone(),
547    ))
548}
549
550/// Progressive skill loader that can load full skill details on demand
551pub struct ProgressiveSkillLoader {
552    discovery: SkillDiscovery,
553    skill_cache: HashMap<String, crate::skills::types::Skill>,
554    #[expect(dead_code)]
555    tool_cache: HashMap<String, CliToolBridge>,
556}
557
558impl ProgressiveSkillLoader {
559    pub fn new(config: DiscoveryConfig) -> Self {
560        Self {
561            discovery: SkillDiscovery::with_config(config),
562            skill_cache: HashMap::new(),
563            tool_cache: HashMap::new(),
564        }
565    }
566
567    /// Get skill metadata (lightweight)
568    pub async fn get_skill_metadata(
569        &mut self,
570        workspace_root: &Path,
571        name: &str,
572    ) -> Result<SkillContext> {
573        let result = self.discovery.discover_all(workspace_root).await?;
574
575        // Check traditional skills
576        for skill in &result.skills {
577            if skill.manifest().name == name {
578                return Ok(skill.clone());
579            }
580        }
581
582        // Check CLI tools
583        for tool in &result.tools {
584            if tool.name == name {
585                return tool_config_to_skill_context(tool);
586            }
587        }
588
589        Err(skill_ops::skill_not_found_error(name))
590    }
591
592    /// Load full skill with instructions and resources
593    pub async fn load_full_skill(
594        &mut self,
595        workspace_root: &Path,
596        name: &str,
597    ) -> Result<crate::skills::types::Skill> {
598        // Check cache first
599        if let Some(skill) = self.skill_cache.get(name) {
600            return Ok(skill.clone());
601        }
602
603        let result = self.discovery.discover_all(workspace_root).await?;
604
605        // Try traditional skills first
606        for skill_ctx in &result.skills {
607            if skill_ctx.manifest().name == name {
608                // Load full skill details
609                // This would require path information - simplified for now
610                let manifest = skill_ctx.manifest().clone();
611                let skill = crate::skills::types::Skill::new(
612                    manifest,
613                    workspace_root.to_path_buf(),
614                    "# Full instructions would be loaded here".to_string(),
615                )?;
616
617                self.skill_cache.insert(name.to_string(), skill.clone());
618                return Ok(skill);
619            }
620        }
621
622        // Try CLI tools
623        for tool_config in &result.tools {
624            if tool_config.name == name {
625                let bridge = CliToolBridge::new(tool_config.clone())?;
626                let skill = bridge.to_skill()?;
627
628                self.skill_cache.insert(name.to_string(), skill.clone());
629                return Ok(skill);
630            }
631        }
632
633        Err(skill_ops::skill_not_found_error(name))
634    }
635}
636
637#[cfg(test)]
638mod tests {
639    use super::*;
640    use tempfile::TempDir;
641
642    #[tokio::test]
643    async fn test_discovery_config_default() {
644        let config = DiscoveryConfig::default();
645        assert!(config.skill_paths.is_empty());
646        assert!(!config.tool_paths.is_empty());
647        assert!(!config.auto_discover_system_tools); // Disabled by default for security
648    }
649
650    #[tokio::test]
651    async fn test_discovery_engine_creation() {
652        let discovery = SkillDiscovery::new();
653        assert_eq!(discovery.cache.len(), 0);
654    }
655
656    #[tokio::test]
657    async fn test_progressive_loader() {
658        let temp_dir = TempDir::new().unwrap();
659        let config = DiscoveryConfig::default();
660        let mut loader = ProgressiveSkillLoader::new(config);
661
662        // Should handle empty directory gracefully
663        let result = loader.discovery.discover_all(temp_dir.path()).await;
664        result.unwrap();
665    }
666}