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::Result;
14use hashbrown::HashMap;
15use serde::{Deserialize, Serialize};
16use std::path::{Path, PathBuf};
17use tracing::{debug, info, warn};
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct DiscoveryConfig {
22 pub skill_paths: Vec<PathBuf>,
24
25 pub tool_paths: Vec<PathBuf>,
27
28 pub auto_discover_system_tools: bool,
30
31 pub max_depth: usize,
33
34 pub skill_patterns: Vec<String>,
36
37 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#[derive(Debug, Clone)]
60pub struct DiscoveryResult {
61 pub skills: Vec<SkillContext>,
63
64 pub tools: Vec<CliToolConfig>,
66
67 pub stats: DiscoveryStats,
69}
70
71#[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
82pub 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 pub fn new() -> Self {
98 Self::with_config(DiscoveryConfig::default())
99 }
100
101 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 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 let skills = self
126 .discover_traditional_skills(workspace_root, &mut stats)
127 .await?;
128
129 let tools = self.discover_cli_tools(workspace_root, &mut stats).await?;
131
132 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 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 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 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 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 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 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 if self.is_executable(&entry)? {
308 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 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 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 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 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 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 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 fn expand_path(&self, path: &Path, workspace_root: &Path) -> PathBuf {
420 if path.starts_with("~") {
421 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 workspace_root.join(path)
431 } else {
432 path.to_path_buf()
433 }
434 }
435
436 #[expect(dead_code)]
438 fn get_cached(&self, path: &Path) -> Option<&DiscoveryCacheEntry> {
439 self.cache.get(path).and_then(|entry| {
440 let elapsed = entry.timestamp.elapsed().ok()?;
442 if elapsed.as_secs() < 300 {
443 Some(entry)
444 } else {
445 None
446 }
447 })
448 }
449
450 #[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 pub fn clear_cache(&mut self) {
470 self.cache.clear();
471 info!("Discovery cache cleared");
472 }
473
474 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
533pub 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
550pub 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 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 for skill in &result.skills {
577 if skill.manifest().name == name {
578 return Ok(skill.clone());
579 }
580 }
581
582 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 pub async fn load_full_skill(
594 &mut self,
595 workspace_root: &Path,
596 name: &str,
597 ) -> Result<crate::skills::types::Skill> {
598 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 for skill_ctx in &result.skills {
607 if skill_ctx.manifest().name == name {
608 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 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); }
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 let result = loader.discovery.discover_all(temp_dir.path()).await;
664 result.unwrap();
665 }
666}