Skip to main content

vtcode_core/skills/
loader.rs

1use crate::skills::cli_bridge::{CliToolBridge, CliToolConfig, discover_cli_tools};
2use crate::skills::command_skills::{
3    BuiltInCommandSkill, CommandSkillBackend, built_in_command_skill, find_command_skill_by_skill_name,
4    merge_built_in_command_skill_contexts,
5};
6use crate::skills::container_validation::{
7    ContainerSkillsValidator, ContainerValidationReport, ContainerValidationResult,
8};
9use crate::skills::discovery::{DiscoveryConfig, DiscoveryResult, SkillDiscovery};
10use crate::skills::locations::{MAX_DISCOVERY_DEPTH, MAX_DISCOVERY_DIRS, discovery_dir_skipped};
11use crate::skills::model::{SkillErrorInfo, SkillLoadOutcome, SkillMetadata, SkillScope};
12use crate::skills::system::{install_system_skills, system_cache_root_dir};
13use crate::skills::types::{Skill, SkillContext, SkillManifest};
14use crate::tools::error_messages::skill_ops;
15use anyhow::{Context, Result};
16use hashbrown::{HashMap, HashSet};
17use serde::Deserialize;
18use std::collections::VecDeque;
19use std::fs;
20use std::path::{Path, PathBuf};
21use std::sync::{OnceLock, RwLock};
22use std::time::{Duration, SystemTime};
23use tracing::{error, warn};
24use vtcode_agent_plugins::LoadedPlugin;
25use vtcode_commons::VtCodePaths;
26
27// Config for loader
28#[derive(Debug, Clone)]
29pub struct SkillLoaderConfig {
30    pub codex_home: PathBuf,
31    pub cwd: PathBuf,
32    pub project_root: Option<PathBuf>,
33    pub include_bundled_system_skills: bool,
34}
35
36pub struct SkillRoot {
37    pub path: PathBuf,
38    pub scope: SkillScope,
39    pub is_tool_root: bool,
40    pub is_plugin_root: bool,
41}
42
43const LIGHTWEIGHT_SKILL_CACHE_TTL: Duration = Duration::from_secs(5 * 60);
44const LIGHTWEIGHT_SKILL_CACHE_MAX_ENTRIES: usize = 32;
45
46static LIGHTWEIGHT_SKILL_METADATA_CACHE: OnceLock<
47    RwLock<HashMap<LightweightSkillCacheKey, CachedLightweightSkillOutcome>>,
48> = OnceLock::new();
49
50#[derive(Debug, Clone, PartialEq, Eq, Hash)]
51struct LightweightSkillCacheKey {
52    codex_home: PathBuf,
53    cwd: PathBuf,
54    project_root: Option<PathBuf>,
55    include_bundled_system_skills: bool,
56    home_dir: Option<PathBuf>,
57}
58
59impl LightweightSkillCacheKey {
60    fn new(config: &SkillLoaderConfig, home_dir: Option<&Path>) -> Self {
61        Self {
62            codex_home: normalize_cache_path(&config.codex_home),
63            cwd: normalize_cache_path(&config.cwd),
64            project_root: config.project_root.as_deref().map(normalize_cache_path),
65            include_bundled_system_skills: config.include_bundled_system_skills,
66            home_dir: home_dir.map(normalize_cache_path),
67        }
68    }
69}
70
71#[derive(Clone)]
72struct CachedLightweightSkillOutcome {
73    outcome: SkillLoadOutcome,
74    timestamp: SystemTime,
75}
76
77impl CachedLightweightSkillOutcome {
78    fn is_expired(&self) -> bool {
79        self.timestamp.elapsed().unwrap_or(LIGHTWEIGHT_SKILL_CACHE_TTL) > LIGHTWEIGHT_SKILL_CACHE_TTL
80    }
81}
82
83fn normalize_cache_path(path: &Path) -> PathBuf {
84    dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
85}
86
87fn lightweight_skill_metadata_cache()
88-> &'static RwLock<HashMap<LightweightSkillCacheKey, CachedLightweightSkillOutcome>> {
89    LIGHTWEIGHT_SKILL_METADATA_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
90}
91
92fn get_cached_lightweight_skill_outcome(key: &LightweightSkillCacheKey) -> Option<SkillLoadOutcome> {
93    match lightweight_skill_metadata_cache().read() {
94        Ok(cache) => cache
95            .get(key)
96            .filter(|cached| !cached.is_expired())
97            .map(|cached| cached.outcome.clone()),
98        Err(_) => {
99            warn!("lightweight skill metadata cache lock poisoned while reading cache");
100            None
101        }
102    }
103}
104
105fn cache_lightweight_skill_outcome(key: LightweightSkillCacheKey, outcome: &SkillLoadOutcome) {
106    match lightweight_skill_metadata_cache().write() {
107        Ok(mut cache) => {
108            if cache.len() >= LIGHTWEIGHT_SKILL_CACHE_MAX_ENTRIES && !cache.contains_key(&key) {
109                let expired: Vec<_> = cache
110                    .iter()
111                    .filter(|(_, value)| value.is_expired())
112                    .map(|(cache_key, _)| cache_key.clone())
113                    .collect();
114
115                for cache_key in expired {
116                    cache.remove(&cache_key);
117                }
118
119                if cache.len() >= LIGHTWEIGHT_SKILL_CACHE_MAX_ENTRIES {
120                    let oldest_key = cache
121                        .iter()
122                        .min_by_key(|(_, value)| value.timestamp)
123                        .map(|(cache_key, _)| cache_key.clone());
124                    if let Some(oldest_key) = oldest_key {
125                        cache.remove(&oldest_key);
126                    }
127                }
128            }
129
130            cache.insert(
131                key,
132                CachedLightweightSkillOutcome {
133                    outcome: outcome.clone(),
134                    timestamp: SystemTime::now(),
135                },
136            );
137        }
138        Err(_) => warn!("lightweight skill metadata cache lock poisoned while writing cache"),
139    }
140}
141
142pub(crate) fn clear_lightweight_skill_metadata_cache() {
143    match lightweight_skill_metadata_cache().write() {
144        Ok(mut cache) => cache.clear(),
145        Err(_) => warn!("lightweight skill metadata cache lock poisoned while clearing cache"),
146    }
147}
148
149pub fn load_skills(config: &SkillLoaderConfig) -> SkillLoadOutcome {
150    let home_dir = dirs::home_dir();
151    load_skills_with_home_dir(config, home_dir.as_deref())
152}
153
154/// Lightweight metadata discovery that avoids parsing SKILL.md files.
155/// Returns skill stubs with only name, description, and path (no manifest parsing).
156/// This is much faster for listing available skills.
157pub fn discover_skill_metadata_lightweight(config: &SkillLoaderConfig) -> SkillLoadOutcome {
158    let home_dir = dirs::home_dir();
159    discover_skill_metadata_lightweight_with_home_dir(config, home_dir.as_deref())
160}
161
162/// Internal helper that allows specifying an explicit home directory.
163/// This is useful for testing to avoid picking up real user skills from ~/.agents/skills.
164fn load_skills_with_home_dir(config: &SkillLoaderConfig, home_dir: Option<&Path>) -> SkillLoadOutcome {
165    let mut outcome = SkillLoadOutcome::default();
166    let roots = skill_roots_with_home_dir(config, home_dir);
167
168    for root in roots {
169        discover_skills_under_root(&root, &mut outcome);
170    }
171
172    add_system_cli_tools(&mut outcome);
173    dedup_and_sort(&mut outcome);
174    filter_disabled_skills(&mut outcome, home_dir);
175    outcome
176}
177
178/// Internal helper for lightweight discovery with explicit home directory.
179/// Useful for hermetic tests.
180fn discover_skill_metadata_lightweight_with_home_dir(
181    config: &SkillLoaderConfig,
182    home_dir: Option<&Path>,
183) -> SkillLoadOutcome {
184    let cache_key = LightweightSkillCacheKey::new(config, home_dir);
185    if let Some(cached) = get_cached_lightweight_skill_outcome(&cache_key) {
186        return cached;
187    }
188
189    let outcome = discover_skill_metadata_lightweight_uncached(config, home_dir);
190    cache_lightweight_skill_outcome(cache_key, &outcome);
191    outcome
192}
193
194fn discover_skill_metadata_lightweight_uncached(
195    config: &SkillLoaderConfig,
196    home_dir: Option<&Path>,
197) -> SkillLoadOutcome {
198    let mut outcome = SkillLoadOutcome::default();
199    let roots = skill_roots_with_home_dir(config, home_dir);
200
201    for root in roots {
202        discover_metadata_under_root(&root, &mut outcome);
203    }
204
205    add_system_cli_tools(&mut outcome);
206    dedup_and_sort(&mut outcome);
207    filter_disabled_skills(&mut outcome, home_dir);
208    outcome
209}
210
211fn add_system_cli_tools(outcome: &mut SkillLoadOutcome) {
212    if let Ok(system_tools) = discover_cli_tools() {
213        for tool in system_tools {
214            if let Ok(skill) = tool_config_to_metadata(&tool, SkillScope::System) {
215                outcome.skills.push(skill);
216            }
217        }
218    }
219}
220
221fn dedup_and_sort(outcome: &mut SkillLoadOutcome) {
222    let mut seen: HashSet<String> = HashSet::new();
223    let mut shadowed: Vec<String> = Vec::new();
224    outcome.skills.retain(|skill| {
225        if seen.insert(skill.name.clone()) {
226            return true;
227        }
228        // Agent Skills interop: same-name skills must not silently replace one
229        // another. First discovery wins (project roots precede user roots);
230        // warn so the shadowing is visible instead of silent.
231        shadowed.push(format!("{} ({})", skill.name, skill.path.display()));
232        false
233    });
234    if !shadowed.is_empty() {
235        tracing::warn!(
236            shadowed = ?shadowed,
237            "duplicate skill names shadowed by earlier discovery; only the first occurrence is loaded"
238        );
239    }
240    outcome.skills.sort_by(|a, b| a.name.cmp(&b.name));
241}
242
243#[derive(Debug, Default, Deserialize)]
244struct CodexConfig {
245    #[serde(default)]
246    skills: CodexSkillsConfig,
247}
248
249#[derive(Debug, Default, Deserialize)]
250struct CodexSkillsConfig {
251    #[serde(default)]
252    config: Vec<CodexSkillToggle>,
253}
254
255#[derive(Debug, Deserialize)]
256struct CodexSkillToggle {
257    #[serde(default)]
258    path: Option<PathBuf>,
259    #[serde(default)]
260    name: Option<String>,
261    #[serde(default = "default_skill_toggle_enabled")]
262    enabled: bool,
263}
264
265#[derive(Debug, Default)]
266struct DisabledSkillSelectors {
267    paths: HashSet<PathBuf>,
268    names: HashSet<String>,
269}
270
271fn default_skill_toggle_enabled() -> bool {
272    true
273}
274
275fn filter_disabled_skills(outcome: &mut SkillLoadOutcome, home_dir: Option<&Path>) {
276    let disabled = disabled_skill_selectors(home_dir);
277    if disabled.paths.is_empty() && disabled.names.is_empty() {
278        return;
279    }
280
281    outcome.skills.retain(|skill| {
282        let canonical = dunce::canonicalize(&skill.path).unwrap_or_else(|_| skill.path.clone());
283        !disabled.paths.contains(&canonical) && !disabled.names.contains(&skill.name)
284    });
285}
286
287fn disabled_skill_selectors(home_dir: Option<&Path>) -> DisabledSkillSelectors {
288    let Some(home_dir) = home_dir else {
289        return DisabledSkillSelectors::default();
290    };
291
292    let config_path = home_dir.join(".codex").join("config.toml");
293    let Ok(content) = fs::read_to_string(&config_path) else {
294        return DisabledSkillSelectors::default();
295    };
296    let Ok(config) = toml::from_str::<CodexConfig>(&content) else {
297        return DisabledSkillSelectors::default();
298    };
299
300    let mut selectors = DisabledSkillSelectors::default();
301    for entry in config.skills.config.into_iter().filter(|entry| !entry.enabled) {
302        if let Some(path) = entry.path {
303            selectors.paths.insert(dunce::canonicalize(&path).unwrap_or(path));
304        }
305        if let Some(name) = entry.name.map(|name| name.trim().to_string())
306            && !name.is_empty()
307        {
308            selectors.names.insert(name);
309        }
310    }
311
312    selectors
313}
314
315fn skill_roots_with_home_dir(config: &SkillLoaderConfig, home_dir: Option<&Path>) -> Vec<SkillRoot> {
316    let mut roots = Vec::new();
317
318    for repo_dir in repo_skill_search_dirs(config) {
319        roots.push(SkillRoot {
320            path: repo_dir.join(".agents/skills"),
321            scope: SkillScope::Repo,
322            is_tool_root: false,
323            is_plugin_root: false,
324        });
325    }
326
327    if let Some(project_root) = &config.project_root {
328        roots.push(SkillRoot {
329            path: project_root.join(".agents/plugins"),
330            scope: SkillScope::Repo,
331            is_tool_root: false,
332            is_plugin_root: true,
333        });
334        roots.push(SkillRoot {
335            path: project_root.join("tools"),
336            scope: SkillScope::Repo,
337            is_tool_root: true,
338            is_plugin_root: false,
339        });
340        roots.push(SkillRoot {
341            path: project_root.join("vendor/tools"),
342            scope: SkillScope::Repo,
343            is_tool_root: true,
344            is_plugin_root: false,
345        });
346    }
347
348    if let Some(home) = home_dir {
349        roots.push(SkillRoot {
350            path: home.join(".agents/plugins"),
351            scope: SkillScope::User,
352            is_tool_root: false,
353            is_plugin_root: true,
354        });
355        roots.push(SkillRoot {
356            path: home.join(".agents/skills"),
357            scope: SkillScope::User,
358            is_tool_root: false,
359            is_plugin_root: false,
360        });
361
362        if let Ok(paths) = VtCodePaths::resolve()
363            && dirs::home_dir().as_deref() == Some(home)
364        {
365            roots.push(SkillRoot {
366                path: paths.data_dir().join("skills"),
367                scope: SkillScope::User,
368                is_tool_root: false,
369                is_plugin_root: false,
370            });
371            roots.push(SkillRoot {
372                path: paths.legacy_dir().join("skills"),
373                scope: SkillScope::User,
374                is_tool_root: false,
375                is_plugin_root: false,
376            });
377            roots.push(SkillRoot {
378                path: paths.plugins_dir(),
379                scope: SkillScope::User,
380                is_tool_root: false,
381                is_plugin_root: true,
382            });
383            roots.push(SkillRoot {
384                path: paths.legacy_dir().join("plugins"),
385                scope: SkillScope::User,
386                is_tool_root: false,
387                is_plugin_root: true,
388            });
389        }
390    }
391
392    #[cfg(unix)]
393    roots.push(SkillRoot {
394        path: PathBuf::from("/etc/codex/skills"),
395        scope: SkillScope::Admin,
396        is_tool_root: false,
397        is_plugin_root: false,
398    });
399
400    if config.include_bundled_system_skills {
401        roots.push(SkillRoot {
402            path: system_cache_root_dir(&config.codex_home),
403            scope: SkillScope::System,
404            is_tool_root: false,
405            is_plugin_root: false,
406        });
407    }
408
409    roots
410}
411
412fn repo_skill_search_dirs(config: &SkillLoaderConfig) -> Vec<PathBuf> {
413    let stop = config.project_root.clone().unwrap_or_else(|| config.cwd.clone());
414    let mut dirs = Vec::new();
415    let mut current = config.cwd.clone();
416
417    loop {
418        dirs.push(current.clone());
419        if current == stop {
420            break;
421        }
422        let Some(parent) = current.parent() else {
423            break;
424        };
425        current = parent.to_path_buf();
426    }
427
428    dirs
429}
430
431fn find_git_root(path: &Path) -> Option<PathBuf> {
432    let mut current = Some(path);
433    while let Some(dir) = current {
434        if dir.join(".git").exists() {
435            return Some(dir.to_path_buf());
436        }
437        current = dir.parent();
438    }
439    None
440}
441
442fn discover_skills_under_root(root: &SkillRoot, outcome: &mut SkillLoadOutcome) {
443    walk_skills_dir(root, outcome, WalkMode::Full)
444}
445
446fn discover_metadata_under_root(root: &SkillRoot, outcome: &mut SkillLoadOutcome) {
447    walk_skills_dir(root, outcome, WalkMode::Metadata)
448}
449
450#[derive(Clone, Copy)]
451enum WalkMode {
452    Full,
453    Metadata,
454}
455
456fn walk_skills_dir(root: &SkillRoot, outcome: &mut SkillLoadOutcome, mode: WalkMode) {
457    let Ok(root_path) = dunce::canonicalize(&root.path) else {
458        return;
459    };
460
461    if !root_path.is_dir() {
462        return;
463    }
464
465    let mut queue: VecDeque<(PathBuf, usize)> = VecDeque::from([(root_path, 0)]);
466    let mut dirs_visited: usize = 0;
467    while let Some((dir, depth)) = queue.pop_front() {
468        dirs_visited += 1;
469        if dirs_visited > MAX_DISCOVERY_DIRS {
470            warn!(
471                "skill discovery dir budget ({MAX_DISCOVERY_DIRS}) exhausted under {}; some skills may be missing",
472                root.path.display()
473            );
474            return;
475        }
476        let entries = match fs::read_dir(&dir) {
477            Ok(entries) => entries,
478            Err(e) => {
479                if matches!(mode, WalkMode::Full) {
480                    error!("failed to read skills dir {}: {e:#}", dir.display());
481                } else {
482                    tracing::debug!("failed to read skills dir {}: {e:#}", dir.display());
483                }
484                continue;
485            }
486        };
487
488        for entry in entries.flatten() {
489            let path = entry.path();
490            let file_name = match path.file_name().and_then(|f| f.to_str()) {
491                Some(name) => name,
492                None => continue,
493            };
494
495            if file_name.starts_with('.') || discovery_dir_skipped(file_name) {
496                continue;
497            }
498
499            if path.is_dir() {
500                // Plugin-consumed directories are units: a successful plugin
501                // load ends handling here so the walk never descends into
502                // plugin subdirectories looking for more skills.
503                if root.is_tool_root
504                    && let Ok(Some(tool_meta)) = try_load_tool_from_dir(&path, root.scope)
505                {
506                    outcome.skills.push(tool_meta);
507                }
508
509                if root.is_plugin_root
510                    && let Ok(Some(plugin_meta)) = try_load_plugin_from_dir(&path, root.scope)
511                {
512                    outcome.skills.push(plugin_meta);
513                    continue;
514                }
515
516                if try_ingest_plugin_skills(&path, root, outcome) {
517                    continue;
518                }
519
520                if depth < MAX_DISCOVERY_DEPTH {
521                    queue.push_back((path.clone(), depth + 1));
522                }
523            }
524
525            if file_name == "SKILL.md" {
526                handle_skill_md(path.clone(), root, outcome, mode);
527            } else if matches!(mode, WalkMode::Full) && root.is_tool_root && is_executable_file(&path) {
528                // Standalone executable tools are recognized in the full walk
529                // but ignored in the lightweight metadata walk.
530            }
531        }
532    }
533}
534
535fn handle_skill_md(path: PathBuf, root: &SkillRoot, outcome: &mut SkillLoadOutcome, mode: WalkMode) {
536    let skill_dir = match path.parent() {
537        Some(dir) => dir,
538        None => return,
539    };
540
541    match mode {
542        WalkMode::Full => match crate::skills::manifest::parse_skill_file(skill_dir) {
543            Ok((manifest, _)) => {
544                outcome.skills.push(SkillMetadata {
545                    name: manifest.name.clone(),
546                    description: manifest.description.clone(),
547                    short_description: None,
548                    path,
549                    scope: root.scope,
550                    manifest: Some(manifest.into()),
551                });
552            }
553            Err(err) => {
554                if root.scope != SkillScope::System {
555                    outcome.errors.push(SkillErrorInfo { path, message: err.to_string() });
556                }
557            }
558        },
559        WalkMode::Metadata => match fs::read_to_string(&path).with_context(|| format!("reading {}", path.display())) {
560            Ok(contents) => match crate::skills::manifest::parse_skill_content(&contents) {
561                Ok((manifest, _)) => {
562                    outcome.skills.push(SkillMetadata {
563                        name: manifest.name.clone(),
564                        description: manifest.description.clone(),
565                        short_description: None,
566                        path,
567                        scope: root.scope,
568                        manifest: Some(manifest.into()),
569                    });
570                }
571                Err(err) => {
572                    if root.scope != SkillScope::System {
573                        outcome.errors.push(SkillErrorInfo { path, message: err.to_string() });
574                    }
575                }
576            },
577            Err(err) => {
578                if root.scope != SkillScope::System {
579                    outcome.errors.push(SkillErrorInfo { path, message: err.to_string() });
580                }
581            }
582        },
583    }
584}
585
586fn try_load_tool_from_dir(path: &Path, scope: SkillScope) -> Result<Option<SkillMetadata>> {
587    // Check if it's a CLI tool directory (has tool.json or is executable inside)
588    // Simplified: check for tool.json
589    let tool_bridge = if path.join("tool.json").exists() {
590        CliToolBridge::from_directory(path)?
591    } else {
592        // Heuristic: check for executable with same name as dir?
593        // This is complex to reproduce exactly "discovery.rs" logic without code dupe.
594        // I'll be conservative and require tool.json OR evident executable.
595        match CliToolBridge::from_directory(path) {
596            Ok(b) => b,
597            Err(_) => return Ok(None),
598        }
599    };
600
601    tool_config_to_metadata(&tool_bridge.config, scope).map(Some)
602}
603
604/// Try to load a portable Agent Plugin from `path`. Returns `true` if a valid
605/// plugin was loaded and its skills were ingested into `outcome`.
606fn try_ingest_plugin_skills(path: &Path, root: &SkillRoot, outcome: &mut SkillLoadOutcome) -> bool {
607    if !root.is_plugin_root {
608        return false;
609    }
610    let Ok(loaded) = LoadedPlugin::load_from_dir(path) else {
611        return false;
612    };
613    for skill in &loaded.skills {
614        outcome.skills.push(SkillMetadata {
615            name: skill.name.clone(),
616            description: skill.manifest.description.clone(),
617            short_description: None,
618            path: skill.skill_md_path.clone(),
619            scope: root.scope,
620            manifest: Some(Box::new(skill.manifest.clone())),
621        });
622    }
623    true
624}
625
626fn tool_config_to_metadata(config: &CliToolConfig, scope: SkillScope) -> Result<SkillMetadata> {
627    Ok(SkillMetadata {
628        name: config.name.clone(),
629        description: config.description.clone(),
630        short_description: None,
631        path: config.executable_path.clone(), // Path to executable is the "path" of the skill?
632        // Or path to directory? Reference uses SKILL.md path.
633        // Here we use executable path or tool directory.
634        scope,
635        manifest: None, // CLI tools don't have a manifest in the same sense, or we could synthesize one
636    })
637}
638
639fn try_load_plugin_from_dir(path: &Path, scope: SkillScope) -> Result<Option<SkillMetadata>> {
640    // Check if it's a native plugin directory (has plugin.json)
641    let plugin_json_path = path.join("plugin.json");
642    if !plugin_json_path.exists() {
643        return Ok(None);
644    }
645
646    // Read and parse plugin metadata
647    let plugin_json_content = fs::read_to_string(&plugin_json_path).context("Failed to read plugin.json")?;
648
649    let plugin_metadata: crate::skills::native_plugin::PluginMetadata =
650        serde_json::from_str(&plugin_json_content).context("Invalid plugin.json format")?;
651
652    // Validate that the plugin has a corresponding dynamic library
653    let lib_name = crate::skills::native_plugin::PluginLoader::new().library_filename(&plugin_metadata.name);
654
655    if !path.join(&lib_name).exists() {
656        // Try alternative library names
657        let alternatives = [
658            format!("lib{}.dylib", plugin_metadata.name),
659            format!("{}.dylib", plugin_metadata.name),
660            format!("lib{}.so", plugin_metadata.name),
661            format!("{}.so", plugin_metadata.name),
662            format!("{}.dll", plugin_metadata.name),
663        ];
664
665        let has_lib = alternatives.iter().any(|alt| path.join(alt).exists());
666        if !has_lib {
667            return Ok(None); // No library found, skip this plugin
668        }
669    }
670
671    Ok(Some(SkillMetadata {
672        name: plugin_metadata.name.clone(),
673        description: plugin_metadata.description.clone(),
674        short_description: None,
675        path: path.to_path_buf(),
676        scope,
677        manifest: None, // Native plugins don't have SKILL.md manifest
678    }))
679}
680
681pub fn load_skill_resources(skill_path: &Path) -> Result<Vec<crate::skills::types::SkillResource>> {
682    let mut resources = Vec::new();
683    let resource_dir = skill_path.join("scripts");
684
685    if resource_dir.exists() {
686        for entry in fs::read_dir(&resource_dir)? {
687            let entry = entry?;
688            let path = entry.path();
689
690            if path.is_file() {
691                let rel_path = path
692                    .strip_prefix(skill_path)
693                    .map(|p| p.to_string_lossy().to_string())
694                    .unwrap_or_default();
695
696                let resource_type = match path.extension().and_then(|e| e.to_str()) {
697                    Some("py") | Some("sh") | Some("bash") => crate::skills::types::ResourceType::Script,
698                    Some("md") => crate::skills::types::ResourceType::Markdown,
699                    Some("json") | Some("yaml") | Some("yml") => crate::skills::types::ResourceType::Reference,
700                    _ => crate::skills::types::ResourceType::Other(format!("{:?}", path.extension())),
701                };
702
703                resources.push(crate::skills::types::SkillResource { path: rel_path, resource_type, content: None });
704            }
705        }
706    }
707
708    // Check for references/ directory
709    let references_dir = skill_path.join("references");
710    if references_dir.exists() {
711        for entry in fs::read_dir(&references_dir)? {
712            let entry = entry?;
713            let path = entry.path();
714
715            if path.is_file() {
716                let rel_path = path
717                    .strip_prefix(skill_path)
718                    .map(|p| p.to_string_lossy().to_string())
719                    .unwrap_or_default();
720
721                let resource_type = match path.extension().and_then(|e| e.to_str()) {
722                    Some("md") => crate::skills::types::ResourceType::Reference,
723                    Some("json") | Some("yaml") | Some("yml") | Some("txt") | Some("csv") => {
724                        crate::skills::types::ResourceType::Reference
725                    }
726                    _ => crate::skills::types::ResourceType::Other(format!("{:?}", path.extension())),
727                };
728
729                resources.push(crate::skills::types::SkillResource { path: rel_path, resource_type, content: None });
730            }
731        }
732    }
733
734    // Check for assets/ directory
735    let assets_dir = skill_path.join("assets");
736    if assets_dir.exists() {
737        for entry in fs::read_dir(&assets_dir)? {
738            let entry = entry?;
739            let path = entry.path();
740
741            if path.is_file() {
742                let rel_path = path
743                    .strip_prefix(skill_path)
744                    .map(|p| p.to_string_lossy().to_string())
745                    .unwrap_or_default();
746
747                let resource_type = match path.extension().and_then(|e| e.to_str()) {
748                    Some("png") | Some("jpg") | Some("jpeg") | Some("gif") | Some("svg") => {
749                        crate::skills::types::ResourceType::Asset
750                    }
751                    Some("json") | Some("yaml") | Some("yml") | Some("txt") | Some("csv") => {
752                        crate::skills::types::ResourceType::Asset
753                    }
754                    _ => crate::skills::types::ResourceType::Asset,
755                };
756
757                resources.push(crate::skills::types::SkillResource { path: rel_path, resource_type, content: None });
758            }
759        }
760    }
761
762    Ok(resources)
763}
764
765fn is_executable_file(path: &Path) -> bool {
766    #[cfg(unix)]
767    {
768        use std::os::unix::fs::PermissionsExt;
769        if let Ok(meta) = path.metadata() {
770            return meta.permissions().mode() & 0o111 != 0;
771        }
772    }
773    #[cfg(windows)]
774    {
775        if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
776            return matches!(ext.to_lowercase().as_str(), "exe" | "bat" | "cmd");
777        }
778    }
779    false
780}
781
782/// Enhanced skill variant for unified handling
783pub enum EnhancedSkill {
784    /// Traditional instruction-based skill
785    Traditional(Box<Skill>),
786    /// CLI-based tool skill
787    CliTool(Box<CliToolBridge>),
788    /// Built-in VT Code command skill
789    BuiltInCommand(Box<BuiltInCommandSkill>),
790    /// Native code plugin skill reserved for explicitly gated integrations
791    NativePlugin(Box<dyn crate::skills::native_plugin::NativePluginTrait>),
792}
793
794impl std::fmt::Debug for EnhancedSkill {
795    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
796        match self {
797            Self::Traditional(skill) => f.debug_tuple("Traditional").field(skill).finish(),
798            Self::CliTool(tool) => f.debug_tuple("CliTool").field(tool).finish(),
799            Self::BuiltInCommand(skill) => f.debug_tuple("BuiltInCommand").field(skill).finish(),
800            Self::NativePlugin(plugin) => f.debug_tuple("NativePlugin").field(plugin).finish(),
801        }
802    }
803}
804
805/// High-level loader that provides discovery and validation features
806pub struct EnhancedSkillLoader {
807    workspace_root: PathBuf,
808    codex_home: PathBuf,
809    discovery: SkillDiscovery,
810}
811
812fn default_codex_home() -> PathBuf {
813    std::env::var_os("CODEX_HOME")
814        .filter(|value| !value.is_empty())
815        .map(PathBuf::from)
816        .or_else(|| dirs::home_dir().map(|home| home.join(".codex")))
817        .unwrap_or_else(|| PathBuf::from(".codex"))
818}
819
820fn discovery_config_for_codex_home(workspace_root: &Path, codex_home: &Path) -> DiscoveryConfig {
821    let home_dir = dirs::home_dir();
822    let loader_config = SkillLoaderConfig {
823        codex_home: codex_home.to_path_buf(),
824        cwd: workspace_root.to_path_buf(),
825        project_root: find_git_root(workspace_root),
826        include_bundled_system_skills: true,
827    };
828    let roots = skill_roots_with_home_dir(&loader_config, home_dir.as_deref());
829
830    DiscoveryConfig {
831        skill_paths: roots
832            .iter()
833            .filter(|root| !root.is_tool_root && !root.is_plugin_root)
834            .map(|root| root.path.clone())
835            .collect(),
836        tool_paths: roots
837            .iter()
838            .filter(|root| root.is_tool_root)
839            .map(|root| root.path.clone())
840            .collect(),
841        ..Default::default()
842    }
843}
844
845impl EnhancedSkillLoader {
846    /// Create a new enhanced loader for workspace
847    pub fn new(workspace_root: PathBuf) -> Self {
848        let codex_home = default_codex_home();
849        let discovery = SkillDiscovery::with_config(discovery_config_for_codex_home(&workspace_root, &codex_home));
850        Self { workspace_root, codex_home, discovery }
851    }
852
853    /// Create a loader pinned to a specific VT Code home directory.
854    pub fn with_codex_home(workspace_root: PathBuf, codex_home: PathBuf) -> Self {
855        let discovery = SkillDiscovery::with_config(discovery_config_for_codex_home(&workspace_root, &codex_home));
856        Self { workspace_root, codex_home, discovery }
857    }
858
859    fn ensure_system_skills_installed(&self) {
860        if let Err(err) = install_system_skills(&self.codex_home) {
861            tracing::warn!("enhanced skill loader failed to install bundled system skills: {err}");
862        }
863    }
864
865    /// Discover all available skills and tools
866    pub async fn discover_all_skills(&mut self) -> Result<DiscoveryResult> {
867        self.ensure_system_skills_installed();
868        let mut result = self.discovery.discover_all(&self.workspace_root).await?;
869        merge_built_in_command_skill_contexts(&mut result.skills);
870        Ok(result)
871    }
872
873    /// Get a specific skill by name
874    pub async fn get_skill(&mut self, name: &str) -> Result<EnhancedSkill> {
875        self.ensure_system_skills_installed();
876        let result = self.discovery.discover_all(&self.workspace_root).await?;
877
878        // Try traditional skills first. Command skills (`cmd-*` with a bundled
879        // `.system` contract) prefer the System copy so a stale workspace
880        // shadow cannot break `/review` free-form + fix-mode behavior.
881        if let Some(skill_ctx) = select_traditional_skill_ctx(&result.skills, name, &self.workspace_root) {
882            let path = skill_ctx.path();
883            let (manifest, instructions) = crate::skills::manifest::parse_skill_file(path)?;
884            let skill = Skill::with_scope(
885                manifest,
886                path.clone(),
887                infer_scope_from_skill_path(path, &self.workspace_root),
888                instructions,
889            )?;
890            return Ok(EnhancedSkill::Traditional(Box::new(skill)));
891        }
892
893        // Try CLI tools
894        for tool_config in &result.tools {
895            if tool_config.name == name {
896                let bridge = CliToolBridge::new(tool_config.clone())?;
897                return Ok(EnhancedSkill::CliTool(Box::new(bridge)));
898            }
899        }
900
901        if let Some(skill) = built_in_command_skill(name) {
902            return Ok(EnhancedSkill::BuiltInCommand(Box::new(skill)));
903        }
904
905        // Native plugins are intentionally not loaded by the generic skill
906        // lookup. Opening a dynamic library executes arbitrary native code
907        // before this loader can inspect its metadata, so native loading must
908        // remain an explicit, separately-gated operation.
909        Err(skill_ops::skill_not_found_error(name))
910    }
911
912    /// Generate a comprehensive container validation report
913    pub async fn generate_validation_report(&mut self) -> Result<ContainerValidationReport> {
914        let result = self.discovery.discover_all(&self.workspace_root).await?;
915        let mut report = ContainerValidationReport::new();
916        let validator = ContainerSkillsValidator::new();
917
918        for skill_ctx in &result.skills {
919            match self.load_full_skill_from_ctx(skill_ctx) {
920                Ok(skill) => {
921                    let analysis = validator.analyze_skill(&skill);
922                    report.add_skill_analysis(skill.name().to_string(), analysis);
923                }
924                Err(e) => {
925                    report.add_incompatible_skill(
926                        skill_ctx.manifest().name.clone(),
927                        skill_ctx.manifest().description.clone(),
928                        format!("Load error: {e}"),
929                    );
930                }
931            }
932        }
933
934        report.finalize();
935        Ok(report)
936    }
937
938    /// Check container requirements for a skill
939    pub fn check_container_requirements(&self, skill: &Skill) -> ContainerValidationResult {
940        let validator = ContainerSkillsValidator::new();
941        validator.analyze_skill(skill)
942    }
943
944    fn load_full_skill_from_ctx(&self, ctx: &SkillContext) -> Result<Skill> {
945        let path = ctx.path();
946        let (manifest, instructions) = crate::skills::manifest::parse_skill_file(path)?;
947        Skill::with_scope(manifest, path.clone(), infer_scope_from_skill_path(path, &self.workspace_root), instructions)
948    }
949}
950
951fn infer_scope_from_skill_path(path: &Path, workspace_root: &Path) -> SkillScope {
952    if path.starts_with(Path::new("/etc/codex/skills")) {
953        return SkillScope::Admin;
954    }
955    if path.starts_with(system_cache_root_dir(&default_codex_home())) {
956        return SkillScope::System;
957    }
958    if let Some(home) = dirs::home_dir()
959        && path.starts_with(home.join(".agents/skills"))
960    {
961        return SkillScope::User;
962    }
963    if path.starts_with(workspace_root) {
964        return SkillScope::Repo;
965    }
966
967    // Fallback for non-canonicalized paths: walk the components looking for a
968    // `.agents/skills` ancestor. Uses proper component matching rather than
969    // string-contains so a directory named `skills-backup` cannot trigger a
970    // false positive.
971    if has_agents_skills_ancestor(path) {
972        return SkillScope::Repo;
973    }
974
975    SkillScope::User
976}
977
978fn has_agents_skills_ancestor(path: &Path) -> bool {
979    let mut ancestors = path.parent().map(Path::new).into_iter().peekable();
980    while let Some(current) = ancestors.next() {
981        let is_skills = current.file_name().and_then(|f| f.to_str()) == Some("skills");
982        let parent_is_agents = ancestors.peek().and_then(|p| p.file_name().and_then(|f| f.to_str())) == Some(".agents");
983        if is_skills && parent_is_agents {
984            return true;
985        }
986    }
987    false
988}
989
990fn is_bundled_command_skill(name: &str) -> bool {
991    find_command_skill_by_skill_name(name)
992        .is_some_and(|spec| matches!(spec.backend, CommandSkillBackend::TraditionalSkill { .. }))
993}
994
995fn is_system_skill_path(path: &Path) -> bool {
996    let mut components = path.components().peekable();
997    while let Some(component) = components.next() {
998        let is_skills = component.as_os_str() == "skills";
999        let next_is_system = components.peek().is_some_and(|next| next.as_os_str() == ".system");
1000        if is_skills && next_is_system {
1001            return true;
1002        }
1003    }
1004    false
1005}
1006
1007fn select_traditional_skill_ctx<'a>(
1008    skills: &'a [SkillContext],
1009    name: &str,
1010    workspace_root: &Path,
1011) -> Option<&'a SkillContext> {
1012    let mut first_match: Option<&'a SkillContext> = None;
1013    let mut system_match: Option<&'a SkillContext> = None;
1014    for skill_ctx in skills {
1015        if skill_ctx.manifest().name != name {
1016            continue;
1017        }
1018        if first_match.is_none() {
1019            first_match = Some(skill_ctx);
1020        }
1021        // Shape-based system detection covers custom `CODEX_HOME` values where
1022        // `infer_scope_from_skill_path` falls back to `User`. Exclude `Repo`
1023        // scope so a workspace-nested `skills/.system` path cannot spoof it.
1024        let scope = infer_scope_from_skill_path(skill_ctx.path(), workspace_root);
1025        if system_match.is_none()
1026            && (scope == SkillScope::System || (scope != SkillScope::Repo && is_system_skill_path(skill_ctx.path())))
1027        {
1028            system_match = Some(skill_ctx);
1029        }
1030        if first_match.is_some() && system_match.is_some() {
1031            break;
1032        }
1033    }
1034    if is_bundled_command_skill(name) {
1035        system_match.or(first_match)
1036    } else {
1037        first_match
1038    }
1039}
1040
1041#[derive(Debug, Clone, Copy)]
1042pub struct SkillMentionDetectionOptions {
1043    pub enable_auto_trigger: bool,
1044    pub enable_description_matching: bool,
1045    pub min_keyword_matches: usize,
1046}
1047
1048impl Default for SkillMentionDetectionOptions {
1049    fn default() -> Self {
1050        Self {
1051            enable_auto_trigger: true,
1052            enable_description_matching: true,
1053            min_keyword_matches: 2,
1054        }
1055    }
1056}
1057
1058/// Detect skill mentions using default routing options.
1059pub fn detect_skill_mentions(user_input: &str, available_skills: &[SkillManifest]) -> Vec<String> {
1060    detect_skill_mentions_with_options(user_input, available_skills, &SkillMentionDetectionOptions::default())
1061}
1062
1063/// Detect skill mentions using explicit routing options.
1064///
1065/// Routing policy:
1066/// - Explicit `$skill-name` mentions always win.
1067/// - Description and manifest metadata keywords provide the only implicit signals.
1068pub fn detect_skill_mentions_with_options(
1069    user_input: &str,
1070    available_skills: &[SkillManifest],
1071    options: &SkillMentionDetectionOptions,
1072) -> Vec<String> {
1073    if !options.enable_auto_trigger {
1074        return Vec::new();
1075    }
1076
1077    let mut mentions = Vec::new();
1078    let input_lower = user_input.to_lowercase();
1079    let input_keywords = extract_keywords(user_input);
1080    let min_matches = options.min_keyword_matches.max(1);
1081
1082    for skill in available_skills {
1083        let skill_name_lower = skill.name.to_lowercase();
1084        let explicit_trigger = format!("${skill_name_lower}");
1085        if input_lower.contains(&explicit_trigger) {
1086            mentions.push(skill.name.clone());
1087            continue;
1088        }
1089
1090        if !options.enable_description_matching {
1091            continue;
1092        }
1093
1094        let skill_keywords = skill_routing_keywords(skill);
1095        let keyword_matches = overlap_count(&input_keywords, &skill_keywords);
1096        if keyword_matches >= min_matches {
1097            mentions.push(skill.name.clone());
1098        }
1099    }
1100
1101    mentions.sort();
1102    mentions.dedup();
1103    mentions
1104}
1105
1106fn overlap_count(input_keywords: &HashSet<String>, skill_keywords: &HashSet<String>) -> usize {
1107    input_keywords.intersection(skill_keywords).count()
1108}
1109
1110fn skill_routing_keywords(skill: &SkillManifest) -> HashSet<String> {
1111    let mut keywords = extract_keywords(&skill.description);
1112
1113    let Some(metadata) = &skill.metadata else {
1114        return keywords;
1115    };
1116    for metadata_keywords in [metadata.get("keywords"), metadata.get("tags")].into_iter().flatten() {
1117        match metadata_keywords {
1118            serde_json::Value::Array(values) => {
1119                for value in values {
1120                    if let Some(keyword) = value.as_str() {
1121                        keywords.extend(extract_keywords(keyword));
1122                    }
1123                }
1124            }
1125            serde_json::Value::String(value) => {
1126                keywords.extend(extract_keywords(value));
1127            }
1128            _ => {}
1129        }
1130    }
1131
1132    keywords
1133}
1134
1135fn extract_keywords(text: &str) -> HashSet<String> {
1136    const STOPWORDS: &[&str] = &[
1137        "the", "and", "with", "from", "that", "this", "when", "where", "what", "your", "for", "into", "onto", "than",
1138        "then", "also", "only", "should", "would", "could", "have", "has", "had", "use", "using", "task", "tasks",
1139        "help", "need", "want",
1140    ];
1141
1142    text.split(|c: char| !c.is_alphanumeric())
1143        .map(|part| part.trim().to_lowercase())
1144        .filter(|part| part.len() > 2)
1145        .filter(|part| !STOPWORDS.contains(&part.as_str()))
1146        .collect()
1147}
1148
1149/// Test helper for hermetic skill loading that does not pick up user skills.
1150/// Use this in tests to avoid failures when ~/.agents/skills contains skills.
1151#[cfg(test)]
1152pub fn load_skills_hermetic(config: &SkillLoaderConfig) -> SkillLoadOutcome {
1153    load_skills_with_home_dir(config, None)
1154}
1155
1156/// Test helper for hermetic lightweight skill discovery.
1157#[cfg(test)]
1158pub fn discover_skill_metadata_lightweight_hermetic(config: &SkillLoaderConfig) -> SkillLoadOutcome {
1159    discover_skill_metadata_lightweight_with_home_dir(config, None)
1160}
1161
1162#[cfg(test)]
1163mod tests {
1164    use super::*;
1165    use crate::skills::CommandSkillBackend;
1166    use crate::skills::command_skills::command_skill_specs;
1167    use crate::skills::system::{install_system_skills, system_cache_root_dir};
1168    use serial_test::serial;
1169    use std::fs;
1170    use tempfile::TempDir;
1171    use tempfile::tempdir;
1172
1173    fn manifest(name: &str, description: &str) -> SkillManifest {
1174        SkillManifest {
1175            name: name.to_string(),
1176            description: description.to_string(),
1177            ..Default::default()
1178        }
1179    }
1180
1181    #[test]
1182    fn detects_explicit_skill_mentions() {
1183        let skills = vec![manifest("pdf-analyzer", "Analyze PDF files and extract tables")];
1184        let mentions = detect_skill_mentions("Use $pdf-analyzer for this file", &skills);
1185        assert_eq!(mentions, vec!["pdf-analyzer".to_string()]);
1186    }
1187
1188    #[test]
1189    fn description_keywords_drive_implicit_matches() {
1190        let skills = vec![manifest(
1191            "api-fetcher",
1192            "Fetch data from API endpoints and summarize responses",
1193        )];
1194
1195        let mentions = detect_skill_mentions("Fetch and summarize API responses for these endpoints", &skills);
1196        assert_eq!(mentions, vec!["api-fetcher".to_string()]);
1197    }
1198
1199    #[test]
1200    fn metadata_keywords_drive_implicit_matches() {
1201        let mut ast_grep = manifest("ast-grep", "Structural search workflows");
1202        ast_grep.metadata = Some(HashMap::from([
1203            ("keywords".to_string(), serde_json::json!(["tree-sitter parser"])),
1204            ("tags".to_string(), serde_json::json!(["optional chaining"])),
1205        ]));
1206
1207        let mentions = detect_skill_mentions("Debug an optional chaining rewrite", &[ast_grep]);
1208        assert_eq!(mentions, vec!["ast-grep".to_string()]);
1209    }
1210
1211    #[test]
1212    fn unrelated_input_does_not_match_description() {
1213        let skills = vec![manifest(
1214            "api-fetcher",
1215            "Fetch data from API endpoints and summarize responses",
1216        )];
1217
1218        let mentions = detect_skill_mentions("Please update this local markdown file and fix headings", &skills);
1219        assert!(mentions.is_empty());
1220    }
1221
1222    #[test]
1223    fn auto_trigger_can_be_disabled() {
1224        let skills = vec![manifest("sql-checker", "Validate SQL migration scripts for safety")];
1225        let options = SkillMentionDetectionOptions { enable_auto_trigger: false, ..Default::default() };
1226        let mentions = detect_skill_mentions_with_options("Use $sql-checker", &skills, &options);
1227        assert!(mentions.is_empty());
1228    }
1229
1230    #[test]
1231    #[serial]
1232    fn lightweight_metadata_discovery_reuses_process_wide_cache() {
1233        clear_lightweight_skill_metadata_cache();
1234
1235        let codex_home = tempdir().expect("codex home");
1236        let workspace = tempdir().expect("workspace");
1237        let skill_dir = workspace.path().join(".agents/skills/process-wide-cache-skill");
1238        fs::create_dir_all(&skill_dir).expect("create skill dir");
1239        fs::write(
1240            skill_dir.join("SKILL.md"),
1241            "---\nname: process-wide-cache-skill\ndescription: process-wide cache test\n---\n# Body\n",
1242        )
1243        .expect("write skill");
1244
1245        let config = SkillLoaderConfig {
1246            codex_home: codex_home.path().to_path_buf(),
1247            cwd: workspace.path().to_path_buf(),
1248            project_root: Some(workspace.path().to_path_buf()),
1249            include_bundled_system_skills: false,
1250        };
1251
1252        let first = discover_skill_metadata_lightweight_hermetic(&config);
1253        assert!(
1254            first.skills.iter().any(|skill| skill.name == "process-wide-cache-skill"),
1255            "expected first discovery to find test skill",
1256        );
1257
1258        fs::remove_dir_all(&skill_dir).expect("remove cached skill dir");
1259
1260        let second = discover_skill_metadata_lightweight_hermetic(&config);
1261        assert!(
1262            second.skills.iter().any(|skill| skill.name == "process-wide-cache-skill"),
1263            "expected cached discovery to preserve removed skill until cache is cleared",
1264        );
1265
1266        clear_lightweight_skill_metadata_cache();
1267
1268        let third = discover_skill_metadata_lightweight_hermetic(&config);
1269        assert!(
1270            !third.skills.iter().any(|skill| skill.name == "process-wide-cache-skill"),
1271            "expected cleared cache to force rediscovery",
1272        );
1273    }
1274
1275    #[test]
1276    #[serial]
1277    fn lightweight_discovery_honors_disable_model_invocation() {
1278        use crate::skills::command_skills::is_model_catalog_eligible;
1279
1280        clear_lightweight_skill_metadata_cache();
1281
1282        let codex_home = tempdir().expect("codex home");
1283        let workspace = tempdir().expect("workspace");
1284        let hidden_dir = workspace.path().join(".agents/skills/manual-only-skill");
1285        fs::create_dir_all(&hidden_dir).expect("create hidden skill dir");
1286        fs::write(
1287            hidden_dir.join("SKILL.md"),
1288            "---\nname: manual-only-skill\ndescription: human invoked only\ndisable-model-invocation: true\n---\n# Body\n",
1289        )
1290        .expect("write hidden skill");
1291        let visible_dir = workspace.path().join(".agents/skills/auto-skill");
1292        fs::create_dir_all(&visible_dir).expect("create visible skill dir");
1293        fs::write(visible_dir.join("SKILL.md"), "---\nname: auto-skill\ndescription: model invocable\n---\n# Body\n")
1294            .expect("write visible skill");
1295
1296        let config = SkillLoaderConfig {
1297            codex_home: codex_home.path().to_path_buf(),
1298            cwd: workspace.path().to_path_buf(),
1299            project_root: Some(workspace.path().to_path_buf()),
1300            include_bundled_system_skills: false,
1301        };
1302
1303        let outcome = discover_skill_metadata_lightweight_hermetic(&config);
1304        let hidden = outcome
1305            .skills
1306            .iter()
1307            .find(|skill| skill.name == "manual-only-skill")
1308            .expect("hidden skill is still discovered");
1309        assert!(
1310            !is_model_catalog_eligible(hidden),
1311            "disable-model-invocation skill must stay out of the model catalog",
1312        );
1313        let visible = outcome
1314            .skills
1315            .iter()
1316            .find(|skill| skill.name == "auto-skill")
1317            .expect("visible skill is discovered");
1318        assert!(is_model_catalog_eligible(visible));
1319
1320        clear_lightweight_skill_metadata_cache();
1321    }
1322
1323    #[test]
1324    #[serial]
1325    fn lightweight_discovery_skips_dependency_and_vcs_trees() {
1326        clear_lightweight_skill_metadata_cache();
1327
1328        let codex_home = tempdir().expect("codex home");
1329        let workspace = tempdir().expect("workspace");
1330        let skills_root = workspace.path().join(".agents/skills");
1331        let write_skill = |dir: &Path, name: &str| {
1332            fs::create_dir_all(dir).expect("create skill dir");
1333            fs::write(
1334                dir.join("SKILL.md"),
1335                format!("---\nname: {name}\ndescription: test skill {name}\n---\n# Body\n"),
1336            )
1337            .expect("write skill");
1338        };
1339        write_skill(&skills_root.join("visible-skill"), "visible-skill");
1340        write_skill(&skills_root.join("node_modules/hidden-skill"), "hidden-skill");
1341        write_skill(&skills_root.join(".git/hidden-git-skill"), "hidden-git-skill");
1342
1343        let config = SkillLoaderConfig {
1344            codex_home: codex_home.path().to_path_buf(),
1345            cwd: workspace.path().to_path_buf(),
1346            project_root: Some(workspace.path().to_path_buf()),
1347            include_bundled_system_skills: false,
1348        };
1349
1350        let outcome = discover_skill_metadata_lightweight_hermetic(&config);
1351        let names: Vec<&str> = outcome.skills.iter().map(|skill| skill.name.as_str()).collect();
1352        assert!(names.contains(&"visible-skill"), "expected visible skill, got {names:?}");
1353        assert!(!names.contains(&"hidden-skill"), "node_modules must not be scanned, got {names:?}");
1354        assert!(!names.contains(&"hidden-git-skill"), ".git must not be scanned, got {names:?}");
1355
1356        clear_lightweight_skill_metadata_cache();
1357    }
1358
1359    #[test]
1360    #[serial]
1361    fn lightweight_discovery_loads_skill_with_renamed_directory() {
1362        // Agent Skills client guide: directory-name mismatch warns but loads.
1363        clear_lightweight_skill_metadata_cache();
1364
1365        let codex_home = tempdir().expect("codex home");
1366        let workspace = tempdir().expect("workspace");
1367        let skill_dir = workspace.path().join(".agents/skills/renamed-dir");
1368        fs::create_dir_all(&skill_dir).expect("create skill dir");
1369        fs::write(
1370            skill_dir.join("SKILL.md"),
1371            "---\nname: original-name\ndescription: renamed on install\n---\n# Body\n",
1372        )
1373        .expect("write skill");
1374
1375        let config = SkillLoaderConfig {
1376            codex_home: codex_home.path().to_path_buf(),
1377            cwd: workspace.path().to_path_buf(),
1378            project_root: Some(workspace.path().to_path_buf()),
1379            include_bundled_system_skills: false,
1380        };
1381
1382        let outcome = discover_skill_metadata_lightweight_hermetic(&config);
1383        assert!(outcome.skills.iter().any(|skill| skill.name == "original-name"), "renamed skill must still load",);
1384
1385        clear_lightweight_skill_metadata_cache();
1386    }
1387
1388    #[test]
1389    fn full_discovery_loads_skill_with_renamed_directory() {
1390        // Full discovery goes through parse_skill_file (not just frontmatter),
1391        // so this covers the warn-and-load path end to end.
1392        let codex_home = tempdir().expect("codex home");
1393        let workspace = tempdir().expect("workspace");
1394        let skill_dir = workspace.path().join(".agents/skills/renamed-dir");
1395        fs::create_dir_all(&skill_dir).expect("create skill dir");
1396        fs::write(
1397            skill_dir.join("SKILL.md"),
1398            "---\nname: original-name\ndescription: renamed on install\n---\n# Body\n",
1399        )
1400        .expect("write skill");
1401
1402        let config = SkillLoaderConfig {
1403            codex_home: codex_home.path().to_path_buf(),
1404            cwd: workspace.path().to_path_buf(),
1405            project_root: Some(workspace.path().to_path_buf()),
1406            include_bundled_system_skills: false,
1407        };
1408
1409        let outcome = load_skills_hermetic(&config);
1410        assert!(
1411            outcome.skills.iter().any(|skill| skill.name == "original-name"),
1412            "renamed skill must still load in full mode",
1413        );
1414        assert!(
1415            outcome.errors.is_empty(),
1416            "renamed skill must not record a load error, got {:?}",
1417            outcome.errors.iter().map(|e| e.to_string()).collect::<Vec<_>>(),
1418        );
1419    }
1420
1421    #[tokio::test]
1422    async fn enhanced_loader_discovers_and_loads_built_in_command_skills() {
1423        let temp_dir = TempDir::new().expect("temp dir");
1424        let mut loader = EnhancedSkillLoader::new(temp_dir.path().to_path_buf());
1425
1426        let discovery = loader.discover_all_skills().await.expect("discover skills");
1427        assert!(
1428            discovery
1429                .skills
1430                .iter()
1431                .any(|skill_ctx| skill_ctx.manifest().name == "cmd-status")
1432        );
1433
1434        let skill = loader.get_skill("cmd-status").await.expect("load cmd-status");
1435        assert!(matches!(skill, EnhancedSkill::BuiltInCommand(_)));
1436    }
1437
1438    #[tokio::test]
1439    async fn enhanced_loader_discovers_and_loads_bundled_command_skills() {
1440        let workspace = TempDir::new().expect("workspace");
1441        let codex_home = TempDir::new().expect("codex home");
1442        install_system_skills(codex_home.path()).expect("install bundled system skills");
1443        let cmd_review_dir = system_cache_root_dir(codex_home.path()).join("cmd-review");
1444        assert!(
1445            cmd_review_dir.join("SKILL.md").exists(),
1446            "expected bundled cmd-review at {}",
1447            cmd_review_dir.display()
1448        );
1449        let (manifest, _) = crate::skills::manifest::parse_skill_file(&cmd_review_dir).expect("parse cmd-review");
1450        assert_eq!(manifest.name, "cmd-review");
1451        let config = discovery_config_for_codex_home(workspace.path(), codex_home.path());
1452        assert!(
1453            config
1454                .skill_paths
1455                .iter()
1456                .any(|path| path == &system_cache_root_dir(codex_home.path()))
1457        );
1458        let mut loader =
1459            EnhancedSkillLoader::with_codex_home(workspace.path().to_path_buf(), codex_home.path().to_path_buf());
1460
1461        let discovery = loader.discover_all_skills().await.expect("discover skills");
1462        assert!(
1463            discovery
1464                .skills
1465                .iter()
1466                .any(|skill_ctx| skill_ctx.manifest().name == "cmd-review")
1467        );
1468
1469        let skill = loader.get_skill("cmd-review").await.expect("load cmd-review");
1470        assert!(matches!(skill, EnhancedSkill::Traditional(_)));
1471    }
1472
1473    #[tokio::test]
1474    async fn enhanced_loader_does_not_open_repository_native_plugins() {
1475        let workspace = tempdir().expect("workspace");
1476        let codex_home = tempdir().expect("codex home");
1477        let plugin_root = workspace.path().join(".agents/plugins");
1478        fs::create_dir_all(&plugin_root).expect("create repository plugin root");
1479
1480        let plugin_name = format!("repository-native-plugin-{}", std::process::id());
1481        fs::write(
1482            plugin_root.join("plugin.json"),
1483            format!(
1484                r#"{{
1485                    "name": "{plugin_name}",
1486                    "description": "repository-controlled native plugin",
1487                    "version": "1.0.0",
1488                    "abi_version": 1
1489                }}"#
1490            ),
1491        )
1492        .expect("write plugin metadata");
1493        let library_name = crate::skills::native_plugin::PluginLoader::new().library_filename(&plugin_name);
1494        fs::write(plugin_root.join(library_name), b"not a dynamic library").expect("write fake library");
1495
1496        let mut loader =
1497            EnhancedSkillLoader::with_codex_home(workspace.path().to_path_buf(), codex_home.path().to_path_buf());
1498        let error = loader
1499            .get_skill(&plugin_name)
1500            .await
1501            .expect_err("repository native plugin must not be opened by generic skill lookup");
1502
1503        assert!(error.to_string().contains("not found"), "unexpected error: {error}");
1504        assert!(!error.to_string().contains("dynamic library"));
1505    }
1506
1507    #[tokio::test]
1508    async fn enhanced_loader_discovers_every_command_skill() {
1509        let workspace = TempDir::new().expect("workspace");
1510        let codex_home = TempDir::new().expect("codex home");
1511        let mut loader =
1512            EnhancedSkillLoader::with_codex_home(workspace.path().to_path_buf(), codex_home.path().to_path_buf());
1513
1514        let discovery = loader.discover_all_skills().await.expect("discover skills");
1515        let discovered_names = discovery
1516            .skills
1517            .iter()
1518            .map(|skill_ctx| skill_ctx.manifest().name.as_str())
1519            .collect::<std::collections::HashSet<_>>();
1520
1521        for spec in command_skill_specs() {
1522            assert!(discovered_names.contains(spec.skill_name), "missing command skill {}", spec.skill_name);
1523
1524            let skill = loader
1525                .get_skill(spec.skill_name)
1526                .await
1527                .unwrap_or_else(|error| panic!("failed to load {}: {error}", spec.skill_name));
1528
1529            match spec.backend {
1530                CommandSkillBackend::TraditionalSkill { .. } => {
1531                    assert!(
1532                        matches!(skill, EnhancedSkill::Traditional(_)),
1533                        "{} should load as a traditional skill",
1534                        spec.skill_name
1535                    );
1536                }
1537                CommandSkillBackend::BuiltInCommand { .. } => {
1538                    assert!(
1539                        matches!(skill, EnhancedSkill::BuiltInCommand(_)),
1540                        "{} should load as a built-in command skill",
1541                        spec.skill_name
1542                    );
1543                }
1544            }
1545        }
1546    }
1547
1548    #[test]
1549    fn bundled_command_skill_prefers_system_over_workspace_shadow() {
1550        use crate::skills::types::SkillContext;
1551        let workspace = TempDir::new().expect("workspace");
1552        let codex_home = TempDir::new().expect("codex home");
1553        install_system_skills(codex_home.path()).expect("install bundled system skills");
1554        let system_dir = system_cache_root_dir(codex_home.path()).join("cmd-review");
1555        let workspace_dir = workspace.path().join(".agents/skills/cmd-review");
1556        // Simulate discovery order: workspace first, system last.
1557        let system_ctx = SkillContext::MetadataOnly(
1558            crate::skills::manifest::parse_skill_file(&system_dir).expect("parse system").0,
1559            system_dir.clone(),
1560        );
1561        let workspace_ctx = SkillContext::MetadataOnly(manifest("cmd-review", "stale workspace shadow"), workspace_dir);
1562        let skills = vec![workspace_ctx, system_ctx];
1563        let selected = select_traditional_skill_ctx(&skills, "cmd-review", workspace.path()).expect("select skill");
1564        assert_eq!(selected.path(), &system_dir);
1565        assert!(
1566            selected.manifest().description.contains("[instructions |"),
1567            "bundled cmd-review must support free-form instructions, got: {}",
1568            selected.manifest().description
1569        );
1570    }
1571
1572    #[test]
1573    fn non_command_skill_keeps_workspace_first_match() {
1574        use crate::skills::types::SkillContext;
1575        let workspace = TempDir::new().expect("workspace");
1576        let workspace_dir = workspace.path().join(".agents/skills/my-skill");
1577        let other_dir = workspace.path().join("other/my-skill");
1578        let skills = vec![
1579            SkillContext::MetadataOnly(manifest("my-skill", "workspace"), workspace_dir),
1580            SkillContext::MetadataOnly(manifest("my-skill", "other"), other_dir),
1581        ];
1582        let selected = select_traditional_skill_ctx(&skills, "my-skill", workspace.path()).expect("select skill");
1583        assert_eq!(selected.manifest().description, "workspace");
1584    }
1585
1586    #[test]
1587    fn workspace_nested_system_shape_does_not_spoof_command_skill() {
1588        use crate::skills::types::SkillContext;
1589        let workspace = TempDir::new().expect("workspace");
1590        let codex_home = TempDir::new().expect("codex home");
1591        install_system_skills(codex_home.path()).expect("install bundled system skills");
1592        let system_dir = system_cache_root_dir(codex_home.path()).join("cmd-review");
1593        let spoof_dir = workspace.path().join(".agents/skills/skills/.system/cmd-review");
1594        let system_ctx = SkillContext::MetadataOnly(
1595            crate::skills::manifest::parse_skill_file(&system_dir).expect("parse system").0,
1596            system_dir.clone(),
1597        );
1598        let spoof_ctx = SkillContext::MetadataOnly(manifest("cmd-review", "workspace spoof"), spoof_dir);
1599        let skills = vec![spoof_ctx, system_ctx];
1600        let selected = select_traditional_skill_ctx(&skills, "cmd-review", workspace.path()).expect("select skill");
1601        assert_eq!(selected.path(), &system_dir);
1602    }
1603
1604    fn write_skill(dir: &Path, name: &str, description: &str) {
1605        fs::create_dir_all(dir).expect("create skill dir");
1606        fs::write(
1607            dir.join("SKILL.md"),
1608            format!("---\nname: {name}\ndescription: {description}\n---\n\nUse this skill.\n"),
1609        )
1610        .expect("write SKILL.md");
1611    }
1612
1613    fn write_codex_skill_config(home_dir: &Path, contents: &str) {
1614        let config_dir = home_dir.join(".codex");
1615        fs::create_dir_all(&config_dir).expect("create config dir");
1616        fs::write(config_dir.join("config.toml"), contents).expect("write config");
1617    }
1618
1619    fn skill_loader_config_for(workspace: &Path, codex_home: &Path) -> SkillLoaderConfig {
1620        SkillLoaderConfig {
1621            codex_home: codex_home.to_path_buf(),
1622            cwd: workspace.to_path_buf(),
1623            project_root: find_git_root(workspace),
1624            include_bundled_system_skills: false,
1625        }
1626    }
1627
1628    #[test]
1629    fn disabled_skill_config_supports_stable_names() {
1630        let workspace = tempdir().expect("workspace");
1631        fs::create_dir(workspace.path().join(".git")).expect("create .git");
1632
1633        let home = tempdir().expect("home");
1634        let codex_home = tempdir().expect("codex home");
1635
1636        let old_plugin_skill_dir = workspace.path().join(".agents/plugins/example-plugin-v1/skills/release-helper");
1637        write_skill(&old_plugin_skill_dir, "release-helper", "Prepare release notes");
1638
1639        write_codex_skill_config(
1640            home.path(),
1641            &format!(
1642                "[[skills.config]]\nname = \"release-helper\"\npath = \"{}\"\nenabled = false\n",
1643                old_plugin_skill_dir.display()
1644            ),
1645        );
1646
1647        let new_plugin_skill_dir = workspace.path().join(".agents/plugins/example-plugin-v2/skills/release-helper");
1648        write_skill(&new_plugin_skill_dir, "release-helper", "Prepare release notes");
1649
1650        fs::remove_dir_all(workspace.path().join(".agents/plugins/example-plugin-v1"))
1651            .expect("remove old plugin version");
1652
1653        let outcome =
1654            load_skills_with_home_dir(&skill_loader_config_for(workspace.path(), codex_home.path()), Some(home.path()));
1655
1656        assert!(
1657            outcome.skills.iter().all(|skill| skill.name != "release-helper"),
1658            "expected release-helper to stay disabled after plugin path changed"
1659        );
1660    }
1661
1662    #[test]
1663    fn disabled_skill_config_preserves_path_based_entries() {
1664        let workspace = tempdir().expect("workspace");
1665        let home = tempdir().expect("home");
1666        let codex_home = tempdir().expect("codex home");
1667
1668        let skill_dir = home.path().join(".agents/skills/path-disabled");
1669        write_skill(&skill_dir, "path-disabled", "Disabled by explicit path");
1670
1671        write_codex_skill_config(
1672            home.path(),
1673            &format!("[[skills.config]]\npath = \"{}\"\nenabled = false\n", skill_dir.join("SKILL.md").display()),
1674        );
1675
1676        let outcome =
1677            load_skills_with_home_dir(&skill_loader_config_for(workspace.path(), codex_home.path()), Some(home.path()));
1678
1679        assert!(
1680            outcome.skills.iter().all(|skill| skill.name != "path-disabled"),
1681            "expected path-disabled to remain filtered by legacy path config"
1682        );
1683    }
1684
1685    #[test]
1686    fn discovers_agent_plugin_skills_from_user_plugin_root() {
1687        let workspace = tempdir().expect("workspace");
1688        fs::create_dir(workspace.path().join(".git")).expect("create .git");
1689        let home = tempdir().expect("home");
1690        let codex_home = tempdir().expect("codex home");
1691
1692        let plugin_root = home.path().join(".agents/plugins/my-plugin");
1693        fs::create_dir_all(plugin_root.join("skills/migrate-agent-plugin")).expect("create skill dirs");
1694
1695        fs::write(
1696            plugin_root.join("plugin.json"),
1697            r#"{
1698                "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
1699                "name": "my-plugin",
1700                "version": "1.0.0",
1701                "description": "Test plugin"
1702            }"#,
1703        )
1704        .expect("write plugin.json");
1705
1706        fs::write(
1707            plugin_root.join("skills/migrate-agent-plugin/SKILL.md"),
1708            r#"---
1709name: migrate-agent-plugin
1710description: Migrate an existing agent plugin to Agent Plugins v1.
1711license: MIT
1712---
1713
1714# Migrate an Agent Plugin
1715
1716Steps here.
1717"#,
1718        )
1719        .expect("write skill");
1720
1721        let outcome =
1722            load_skills_with_home_dir(&skill_loader_config_for(workspace.path(), codex_home.path()), Some(home.path()));
1723
1724        assert!(
1725            outcome.skills.iter().any(|s| s.name == "migrate-agent-plugin"),
1726            "expected migrate-agent-plugin skill to be discovered from the user agent plugin root, got: {:?}",
1727            outcome.skills.iter().map(|s| &s.name).collect::<Vec<_>>()
1728        );
1729    }
1730
1731    #[test]
1732    fn discovers_agent_plugin_skills_from_plugin_root() {
1733        let workspace = tempdir().expect("workspace");
1734        fs::create_dir(workspace.path().join(".git")).expect("create .git");
1735        let home = tempdir().expect("home");
1736        let codex_home = tempdir().expect("codex home");
1737
1738        let plugin_root = workspace.path().join(".agents/plugins/my-plugin");
1739        fs::create_dir_all(plugin_root.join("skills/migrate-agent-plugin/references")).expect("create skill dirs");
1740
1741        fs::write(
1742            plugin_root.join("plugin.json"),
1743            r#"{
1744                "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
1745                "name": "my-plugin",
1746                "version": "1.0.0",
1747                "description": "Test plugin"
1748            }"#,
1749        )
1750        .expect("write plugin.json");
1751
1752        fs::write(
1753            plugin_root.join("skills/migrate-agent-plugin/SKILL.md"),
1754            r#"---
1755name: migrate-agent-plugin
1756description: Migrate an existing agent plugin to Agent Plugins v1.
1757license: MIT
1758---
1759
1760# Migrate an Agent Plugin
1761
1762Steps here.
1763"#,
1764        )
1765        .expect("write skill");
1766
1767        let outcome =
1768            load_skills_with_home_dir(&skill_loader_config_for(workspace.path(), codex_home.path()), Some(home.path()));
1769
1770        assert!(
1771            outcome.skills.iter().any(|s| s.name == "migrate-agent-plugin"),
1772            "expected migrate-agent-plugin skill to be discovered from agent plugin, got: {:?}",
1773            outcome.skills.iter().map(|s| &s.name).collect::<Vec<_>>()
1774        );
1775    }
1776
1777    #[test]
1778    fn does_not_descent_into_agent_plugin_subdirs() {
1779        let workspace = tempdir().expect("workspace");
1780        fs::create_dir(workspace.path().join(".git")).expect("create .git");
1781        let home = tempdir().expect("home");
1782        let codex_home = tempdir().expect("codex home");
1783
1784        let plugin_root = workspace.path().join(".agents/plugins/my-plugin");
1785        fs::create_dir_all(plugin_root.join("skills/nested/deep-nested")).expect("create nested dirs");
1786
1787        fs::write(
1788            plugin_root.join("plugin.json"),
1789            r#"{
1790                "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
1791                "name": "my-plugin",
1792                "description": "Test native plugin",
1793                "version": "1.0.0",
1794                "abi_version": 1
1795            }"#,
1796        )
1797        .expect("write plugin.json");
1798        // Presence (not loadability) is what discovery checks: a complete
1799        // native plugin dir is consumed as one entry and never descended into.
1800        let lib_name = crate::skills::native_plugin::PluginLoader::new().library_filename("my-plugin");
1801        fs::write(plugin_root.join(&lib_name), b"").expect("write dummy plugin library");
1802
1803        fs::write(
1804            plugin_root.join("skills/nested/deep-nested/SKILL.md"),
1805            r#"---
1806name: deep-nested
1807description: Should not be discovered because it is not an immediate child of skills/.
1808---
1809
1810# Deep Nested
1811"#,
1812        )
1813        .expect("write deeply nested skill");
1814
1815        let outcome =
1816            load_skills_with_home_dir(&skill_loader_config_for(workspace.path(), codex_home.path()), Some(home.path()));
1817
1818        assert!(
1819            outcome.skills.iter().all(|s| s.name != "deep-nested"),
1820            "expected deeply nested skill outside skills/* to be ignored, got: {:?}",
1821            outcome.skills.iter().map(|s| &s.name).collect::<Vec<_>>()
1822        );
1823    }
1824
1825    #[test]
1826    fn discovers_plugin_mcp_providers() {
1827        let workspace = tempdir().expect("workspace");
1828        fs::create_dir(workspace.path().join(".git")).expect("create .git");
1829
1830        let plugin_root = workspace.path().join(".agents/plugins/mcp-plugin");
1831        fs::create_dir_all(plugin_root.join("bin")).expect("create bin dir");
1832
1833        fs::write(
1834            plugin_root.join("plugin.json"),
1835            r#"{
1836                "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
1837                "name": "mcp-plugin"
1838            }"#,
1839        )
1840        .expect("write plugin.json");
1841
1842        fs::write(
1843            plugin_root.join("mcp.json"),
1844            r#"{
1845                "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
1846                "mcpServers": {
1847                    "local": {
1848                        "type": "stdio",
1849                        "command": "./bin/server",
1850                        "args": ["--data", "${PLUGIN_DATA}/db"]
1851                    },
1852                    "remote": {
1853                        "type": "streamable-http",
1854                        "url": "https://example.com/mcp"
1855                    }
1856                }
1857            }"#,
1858        )
1859        .expect("write mcp.json");
1860
1861        fs::write(plugin_root.join("bin/server"), "#!/bin/sh\necho hello").expect("write server script");
1862
1863        let providers = crate::mcp::plugin_providers::discover_plugin_mcp_providers(workspace.path());
1864
1865        let names: Vec<_> = providers.iter().map(|p| p.name.as_str()).collect();
1866        assert!(names.contains(&"mcp-plugin.local"), "expected mcp-plugin.local, got: {:?}", names);
1867        assert!(names.contains(&"mcp-plugin.remote"), "expected mcp-plugin.remote, got: {:?}", names);
1868        assert_eq!(providers.len(), 2);
1869    }
1870}