1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct DiscoveryConfig {
23 pub skill_paths: Vec<PathBuf>,
25
26 pub tool_paths: Vec<PathBuf>,
28
29 pub auto_discover_system_tools: bool,
31
32 pub max_depth: usize,
34
35 pub skill_patterns: Vec<String>,
37
38 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#[derive(Debug, Clone)]
64pub struct DiscoveryResult {
65 pub skills: Vec<SkillContext>,
67
68 pub tools: Vec<CliToolConfig>,
70
71 pub stats: DiscoveryStats,
73}
74
75#[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
86pub 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 pub fn new() -> Self {
101 Self::with_config(DiscoveryConfig::default())
102 }
103
104 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 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 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 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 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 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 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 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 if self.is_executable(&entry)? {
277 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 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 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 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 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 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 let tool_json = tool_path.parent()?.join("tool.json");
372 if tool_json.exists() { Some(tool_json) } else { None }
373 }
374 }
375
376 fn expand_path(&self, path: &Path, workspace_root: &Path) -> PathBuf {
378 if path.starts_with("~") {
379 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 workspace_root.join(path)
389 } else {
390 path.to_path_buf()
391 }
392 }
393
394 pub fn clear_cache(&mut self) {
396 self.cache.clear();
397 info!("Discovery cache cleared");
398 }
399
400 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
462pub 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
476pub 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 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 for skill in &result.skills {
496 if skill.manifest().name == name {
497 return Ok(skill.clone());
498 }
499 }
500
501 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 pub async fn load_full_skill(&mut self, workspace_root: &Path, name: &str) -> Result<crate::skills::types::Skill> {
513 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 for skill_ctx in &result.skills {
522 if skill_ctx.manifest().name == name {
523 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 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); }
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 let result = loader.discovery.discover_all(temp_dir.path()).await;
579 result.unwrap();
580 }
581}