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 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 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 fn expand_path(&self, path: &Path, workspace_root: &Path) -> PathBuf {
308 if path.starts_with("~") {
309 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 workspace_root.join(path)
319 } else {
320 path.to_path_buf()
321 }
322 }
323
324 pub fn clear_cache(&mut self) {
326 self.cache.clear();
327 info!("Discovery cache cleared");
328 }
329
330 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
392pub 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
406pub 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 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 for skill in &result.skills {
426 if skill.manifest().name == name {
427 return Ok(skill.clone());
428 }
429 }
430
431 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 pub async fn load_full_skill(&mut self, workspace_root: &Path, name: &str) -> Result<crate::skills::types::Skill> {
443 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 for skill_ctx in &result.skills {
452 if skill_ctx.manifest().name == name {
453 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 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); }
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 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 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}