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, built_in_command_skill, merge_built_in_command_skill_contexts,
4};
5use crate::skills::container_validation::{
6    ContainerSkillsValidator, ContainerValidationReport, ContainerValidationResult,
7};
8use crate::skills::discovery::{DiscoveryConfig, DiscoveryResult, SkillDiscovery};
9use crate::skills::model::{SkillErrorInfo, SkillLoadOutcome, SkillMetadata, SkillScope};
10use crate::skills::system::{install_system_skills, system_cache_root_dir};
11use crate::skills::types::{Skill, SkillContext, SkillManifest};
12use crate::tools::error_messages::skill_ops;
13use anyhow::{Context, Result};
14use hashbrown::{HashMap, HashSet};
15use serde::Deserialize;
16use std::collections::VecDeque;
17use std::fs;
18use std::path::{Path, PathBuf};
19use std::sync::{OnceLock, RwLock};
20use std::time::{Duration, SystemTime};
21use tracing::{error, warn};
22
23// Config for loader
24#[derive(Debug, Clone)]
25pub struct SkillLoaderConfig {
26    pub codex_home: PathBuf,
27    pub cwd: PathBuf,
28    pub project_root: Option<PathBuf>,
29    pub include_bundled_system_skills: bool,
30}
31
32pub struct SkillRoot {
33    pub path: PathBuf,
34    pub scope: SkillScope,
35    pub is_tool_root: bool,
36    pub is_plugin_root: bool,
37}
38
39const LIGHTWEIGHT_SKILL_CACHE_TTL: Duration = Duration::from_secs(5 * 60);
40const LIGHTWEIGHT_SKILL_CACHE_MAX_ENTRIES: usize = 32;
41
42static LIGHTWEIGHT_SKILL_METADATA_CACHE: OnceLock<
43    RwLock<HashMap<LightweightSkillCacheKey, CachedLightweightSkillOutcome>>,
44> = OnceLock::new();
45
46#[derive(Debug, Clone, PartialEq, Eq, Hash)]
47struct LightweightSkillCacheKey {
48    codex_home: PathBuf,
49    cwd: PathBuf,
50    project_root: Option<PathBuf>,
51    include_bundled_system_skills: bool,
52    home_dir: Option<PathBuf>,
53}
54
55impl LightweightSkillCacheKey {
56    fn new(config: &SkillLoaderConfig, home_dir: Option<&Path>) -> Self {
57        Self {
58            codex_home: normalize_cache_path(&config.codex_home),
59            cwd: normalize_cache_path(&config.cwd),
60            project_root: config.project_root.as_deref().map(normalize_cache_path),
61            include_bundled_system_skills: config.include_bundled_system_skills,
62            home_dir: home_dir.map(normalize_cache_path),
63        }
64    }
65}
66
67#[derive(Clone)]
68struct CachedLightweightSkillOutcome {
69    outcome: SkillLoadOutcome,
70    timestamp: SystemTime,
71}
72
73impl CachedLightweightSkillOutcome {
74    fn is_expired(&self) -> bool {
75        self.timestamp
76            .elapsed()
77            .unwrap_or(LIGHTWEIGHT_SKILL_CACHE_TTL)
78            > LIGHTWEIGHT_SKILL_CACHE_TTL
79    }
80}
81
82fn normalize_cache_path(path: &Path) -> PathBuf {
83    dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
84}
85
86fn lightweight_skill_metadata_cache()
87-> &'static RwLock<HashMap<LightweightSkillCacheKey, CachedLightweightSkillOutcome>> {
88    LIGHTWEIGHT_SKILL_METADATA_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
89}
90
91fn get_cached_lightweight_skill_outcome(
92    key: &LightweightSkillCacheKey,
93) -> Option<SkillLoadOutcome> {
94    match lightweight_skill_metadata_cache().read() {
95        Ok(cache) => cache
96            .get(key)
97            .filter(|cached| !cached.is_expired())
98            .map(|cached| cached.outcome.clone()),
99        Err(_) => {
100            warn!("lightweight skill metadata cache lock poisoned while reading cache");
101            None
102        }
103    }
104}
105
106fn cache_lightweight_skill_outcome(key: LightweightSkillCacheKey, outcome: &SkillLoadOutcome) {
107    match lightweight_skill_metadata_cache().write() {
108        Ok(mut cache) => {
109            if cache.len() >= LIGHTWEIGHT_SKILL_CACHE_MAX_ENTRIES && !cache.contains_key(&key) {
110                let expired: Vec<_> = cache
111                    .iter()
112                    .filter(|(_, value)| value.is_expired())
113                    .map(|(cache_key, _)| cache_key.clone())
114                    .collect();
115
116                for cache_key in expired {
117                    cache.remove(&cache_key);
118                }
119
120                if cache.len() >= LIGHTWEIGHT_SKILL_CACHE_MAX_ENTRIES {
121                    let oldest_key = cache
122                        .iter()
123                        .min_by_key(|(_, value)| value.timestamp)
124                        .map(|(cache_key, _)| cache_key.clone());
125                    if let Some(oldest_key) = oldest_key {
126                        cache.remove(&oldest_key);
127                    }
128                }
129            }
130
131            cache.insert(
132                key,
133                CachedLightweightSkillOutcome {
134                    outcome: outcome.clone(),
135                    timestamp: SystemTime::now(),
136                },
137            );
138        }
139        Err(_) => warn!("lightweight skill metadata cache lock poisoned while writing cache"),
140    }
141}
142
143pub(crate) fn clear_lightweight_skill_metadata_cache() {
144    match lightweight_skill_metadata_cache().write() {
145        Ok(mut cache) => cache.clear(),
146        Err(_) => warn!("lightweight skill metadata cache lock poisoned while clearing cache"),
147    }
148}
149
150pub fn load_skills(config: &SkillLoaderConfig) -> SkillLoadOutcome {
151    let home_dir = dirs::home_dir();
152    load_skills_with_home_dir(config, home_dir.as_deref())
153}
154
155/// Lightweight metadata discovery that avoids parsing SKILL.md files.
156/// Returns skill stubs with only name, description, and path (no manifest parsing).
157/// This is much faster for listing available skills.
158pub fn discover_skill_metadata_lightweight(config: &SkillLoaderConfig) -> SkillLoadOutcome {
159    let home_dir = dirs::home_dir();
160    discover_skill_metadata_lightweight_with_home_dir(config, home_dir.as_deref())
161}
162
163/// Internal helper that allows specifying an explicit home directory.
164/// This is useful for testing to avoid picking up real user skills from ~/.agents/skills.
165fn load_skills_with_home_dir(
166    config: &SkillLoaderConfig,
167    home_dir: Option<&Path>,
168) -> SkillLoadOutcome {
169    let mut outcome = SkillLoadOutcome::default();
170    let roots = skill_roots_with_home_dir(config, home_dir);
171
172    for root in roots {
173        discover_skills_under_root(&root, &mut outcome);
174    }
175
176    add_system_cli_tools(&mut outcome);
177    dedup_and_sort(&mut outcome);
178    filter_disabled_skills(&mut outcome, home_dir);
179    outcome
180}
181
182/// Internal helper for lightweight discovery with explicit home directory.
183/// Useful for hermetic tests.
184fn discover_skill_metadata_lightweight_with_home_dir(
185    config: &SkillLoaderConfig,
186    home_dir: Option<&Path>,
187) -> SkillLoadOutcome {
188    let cache_key = LightweightSkillCacheKey::new(config, home_dir);
189    if let Some(cached) = get_cached_lightweight_skill_outcome(&cache_key) {
190        return cached;
191    }
192
193    let outcome = discover_skill_metadata_lightweight_uncached(config, home_dir);
194    cache_lightweight_skill_outcome(cache_key, &outcome);
195    outcome
196}
197
198fn discover_skill_metadata_lightweight_uncached(
199    config: &SkillLoaderConfig,
200    home_dir: Option<&Path>,
201) -> SkillLoadOutcome {
202    let mut outcome = SkillLoadOutcome::default();
203    let roots = skill_roots_with_home_dir(config, home_dir);
204
205    for root in roots {
206        discover_metadata_under_root(&root, &mut outcome);
207    }
208
209    add_system_cli_tools(&mut outcome);
210    dedup_and_sort(&mut outcome);
211    filter_disabled_skills(&mut outcome, home_dir);
212    outcome
213}
214
215fn add_system_cli_tools(outcome: &mut SkillLoadOutcome) {
216    if let Ok(system_tools) = discover_cli_tools() {
217        for tool in system_tools {
218            if let Ok(skill) = tool_config_to_metadata(&tool, SkillScope::System) {
219                outcome.skills.push(skill);
220            }
221        }
222    }
223}
224
225fn dedup_and_sort(outcome: &mut SkillLoadOutcome) {
226    let mut seen: HashSet<String> = HashSet::new();
227    outcome
228        .skills
229        .retain(|skill| seen.insert(skill.name.clone()));
230    outcome.skills.sort_by(|a, b| a.name.cmp(&b.name));
231}
232
233#[derive(Debug, Default, Deserialize)]
234struct CodexConfig {
235    #[serde(default)]
236    skills: CodexSkillsConfig,
237}
238
239#[derive(Debug, Default, Deserialize)]
240struct CodexSkillsConfig {
241    #[serde(default)]
242    config: Vec<CodexSkillToggle>,
243}
244
245#[derive(Debug, Deserialize)]
246struct CodexSkillToggle {
247    #[serde(default)]
248    path: Option<PathBuf>,
249    #[serde(default)]
250    name: Option<String>,
251    #[serde(default = "default_skill_toggle_enabled")]
252    enabled: bool,
253}
254
255#[derive(Debug, Default)]
256struct DisabledSkillSelectors {
257    paths: HashSet<PathBuf>,
258    names: HashSet<String>,
259}
260
261fn default_skill_toggle_enabled() -> bool {
262    true
263}
264
265fn filter_disabled_skills(outcome: &mut SkillLoadOutcome, home_dir: Option<&Path>) {
266    let disabled = disabled_skill_selectors(home_dir);
267    if disabled.paths.is_empty() && disabled.names.is_empty() {
268        return;
269    }
270
271    outcome.skills.retain(|skill| {
272        let canonical = dunce::canonicalize(&skill.path).unwrap_or_else(|_| skill.path.clone());
273        !disabled.paths.contains(&canonical) && !disabled.names.contains(&skill.name)
274    });
275}
276
277fn disabled_skill_selectors(home_dir: Option<&Path>) -> DisabledSkillSelectors {
278    let Some(home_dir) = home_dir else {
279        return DisabledSkillSelectors::default();
280    };
281
282    let config_path = home_dir.join(".codex").join("config.toml");
283    let Ok(content) = fs::read_to_string(&config_path) else {
284        return DisabledSkillSelectors::default();
285    };
286    let Ok(config) = toml::from_str::<CodexConfig>(&content) else {
287        return DisabledSkillSelectors::default();
288    };
289
290    let mut selectors = DisabledSkillSelectors::default();
291    for entry in config
292        .skills
293        .config
294        .into_iter()
295        .filter(|entry| !entry.enabled)
296    {
297        if let Some(path) = entry.path {
298            selectors
299                .paths
300                .insert(dunce::canonicalize(&path).unwrap_or(path));
301        }
302        if let Some(name) = entry.name.map(|name| name.trim().to_string())
303            && !name.is_empty()
304        {
305            selectors.names.insert(name);
306        }
307    }
308
309    selectors
310}
311
312fn skill_roots_with_home_dir(
313    config: &SkillLoaderConfig,
314    home_dir: Option<&Path>,
315) -> 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/skills"),
351            scope: SkillScope::User,
352            is_tool_root: false,
353            is_plugin_root: false,
354        });
355    }
356
357    #[cfg(unix)]
358    roots.push(SkillRoot {
359        path: PathBuf::from("/etc/codex/skills"),
360        scope: SkillScope::Admin,
361        is_tool_root: false,
362        is_plugin_root: false,
363    });
364
365    if config.include_bundled_system_skills {
366        roots.push(SkillRoot {
367            path: system_cache_root_dir(&config.codex_home),
368            scope: SkillScope::System,
369            is_tool_root: false,
370            is_plugin_root: false,
371        });
372    }
373
374    roots
375}
376
377fn repo_skill_search_dirs(config: &SkillLoaderConfig) -> Vec<PathBuf> {
378    let stop = config
379        .project_root
380        .clone()
381        .unwrap_or_else(|| config.cwd.clone());
382    let mut dirs = Vec::new();
383    let mut current = config.cwd.clone();
384
385    loop {
386        dirs.push(current.clone());
387        if current == stop {
388            break;
389        }
390        let Some(parent) = current.parent() else {
391            break;
392        };
393        current = parent.to_path_buf();
394    }
395
396    dirs
397}
398
399fn find_git_root(path: &Path) -> Option<PathBuf> {
400    let mut current = Some(path);
401    while let Some(dir) = current {
402        if dir.join(".git").exists() {
403            return Some(dir.to_path_buf());
404        }
405        current = dir.parent();
406    }
407    None
408}
409
410fn discover_skills_under_root(root: &SkillRoot, outcome: &mut SkillLoadOutcome) {
411    let Ok(root_path) = dunce::canonicalize(&root.path) else {
412        return;
413    };
414
415    if !root_path.is_dir() {
416        return;
417    }
418
419    let mut queue: VecDeque<PathBuf> = VecDeque::from([root_path]);
420    while let Some(dir) = queue.pop_front() {
421        let entries = match fs::read_dir(&dir) {
422            Ok(entries) => entries,
423            Err(e) => {
424                error!("failed to read skills dir {}: {e:#}", dir.display());
425                continue;
426            }
427        };
428
429        for entry in entries.flatten() {
430            let path = entry.path();
431            let file_name = match path.file_name().and_then(|f| f.to_str()) {
432                Some(name) => name,
433                None => continue,
434            };
435
436            if file_name.starts_with('.') {
437                continue;
438            }
439
440            if path.is_dir() {
441                queue.push_back(path.clone());
442
443                // If this is a tool root or we are in a generic scan, check for tool directory structure
444                // Assuming tool dir has tool.json or executable
445                if root.is_tool_root
446                    && let Ok(Some(tool_meta)) = try_load_tool_from_dir(&path, root.scope)
447                {
448                    outcome.skills.push(tool_meta);
449                }
450
451                // If this is a plugin root, check for native plugin directory structure
452                if root.is_plugin_root
453                    && let Ok(Some(plugin_meta)) = try_load_plugin_from_dir(&path, root.scope)
454                {
455                    outcome.skills.push(plugin_meta);
456                }
457                continue;
458            }
459
460            // Check for traditional skill
461            if file_name == "SKILL.md" {
462                let Some(skill_dir) = path.parent() else {
463                    continue;
464                };
465                match crate::skills::manifest::parse_skill_file(skill_dir) {
466                    Ok((manifest, _)) => {
467                        outcome.skills.push(SkillMetadata {
468                            name: manifest.name.clone(),
469                            description: manifest.description.clone(),
470                            short_description: None,
471                            path: path.clone(),
472                            scope: root.scope,
473                            manifest: Some(manifest.into()),
474                        });
475                    }
476                    Err(err) => {
477                        if root.scope != SkillScope::System {
478                            outcome.errors.push(SkillErrorInfo {
479                                path: path.clone(),
480                                message: err.to_string(),
481                            });
482                        }
483                    }
484                }
485            } else if root.is_tool_root && is_executable_file(&path) {
486                // Standalone executable tool?
487                // We typically look for directories, but maybe standalone files too.
488                // For now, let's stick to directory-based tools or tools with README.
489            }
490        }
491    }
492}
493
494/// Lightweight metadata discovery that parses only SKILL.md frontmatter.
495/// This preserves routing-critical fields without loading full instructions.
496fn discover_metadata_under_root(root: &SkillRoot, outcome: &mut SkillLoadOutcome) {
497    let Ok(root_path) = dunce::canonicalize(&root.path) else {
498        return;
499    };
500
501    if !root_path.is_dir() {
502        return;
503    }
504
505    let mut queue: VecDeque<PathBuf> = VecDeque::from([root_path]);
506    while let Some(dir) = queue.pop_front() {
507        let entries = match fs::read_dir(&dir) {
508            Ok(entries) => entries,
509            Err(e) => {
510                tracing::debug!("failed to read skills dir {}: {e:#}", dir.display());
511                continue;
512            }
513        };
514
515        for entry in entries.flatten() {
516            let path = entry.path();
517            let file_name = match path.file_name().and_then(|f| f.to_str()) {
518                Some(name) => name,
519                None => continue,
520            };
521
522            if file_name.starts_with('.') {
523                continue;
524            }
525
526            if path.is_dir() {
527                queue.push_back(path.clone());
528
529                // For tools, try to extract metadata without full parsing
530                if root.is_tool_root
531                    && let Ok(Some(tool_meta)) = try_load_tool_from_dir(&path, root.scope)
532                {
533                    outcome.skills.push(tool_meta);
534                }
535                continue;
536            }
537
538            if file_name == "SKILL.md" {
539                match fs::read_to_string(&path)
540                    .with_context(|| format!("reading {}", path.display()))
541                {
542                    Ok(contents) => match crate::skills::manifest::parse_skill_content(&contents) {
543                        Ok((manifest, _)) => {
544                            outcome.skills.push(SkillMetadata {
545                                name: manifest.name.clone(),
546                                description: manifest.description.clone(),
547                                short_description: None,
548                                path: path.clone(),
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 {
556                                    path: path.clone(),
557                                    message: err.to_string(),
558                                });
559                            }
560                        }
561                    },
562                    Err(err) => {
563                        if root.scope != SkillScope::System {
564                            outcome.errors.push(SkillErrorInfo {
565                                path: path.clone(),
566                                message: err.to_string(),
567                            });
568                        }
569                    }
570                }
571            }
572        }
573    }
574}
575
576fn try_load_tool_from_dir(path: &Path, scope: SkillScope) -> Result<Option<SkillMetadata>> {
577    // Check if it's a CLI tool directory (has tool.json or is executable inside)
578    // Simplified: check for tool.json
579    let tool_bridge = if path.join("tool.json").exists() {
580        CliToolBridge::from_directory(path)?
581    } else {
582        // Heuristic: check for executable with same name as dir?
583        // This is complex to reproduce exactly "discovery.rs" logic without code dupe.
584        // I'll be conservative and require tool.json OR evident executable.
585        match CliToolBridge::from_directory(path) {
586            Ok(b) => b,
587            Err(_) => return Ok(None),
588        }
589    };
590
591    tool_config_to_metadata(&tool_bridge.config, scope).map(Some)
592}
593
594fn tool_config_to_metadata(config: &CliToolConfig, scope: SkillScope) -> Result<SkillMetadata> {
595    Ok(SkillMetadata {
596        name: config.name.clone(),
597        description: config.description.clone(),
598        short_description: None,
599        path: config.executable_path.clone(), // Path to executable is the "path" of the skill?
600        // Or path to directory? Reference uses SKILL.md path.
601        // Here we use executable path or tool directory.
602        scope,
603        manifest: None, // CLI tools don't have a manifest in the same sense, or we could synthesize one
604    })
605}
606
607fn try_load_plugin_from_dir(path: &Path, scope: SkillScope) -> Result<Option<SkillMetadata>> {
608    // Check if it's a native plugin directory (has plugin.json)
609    let plugin_json_path = path.join("plugin.json");
610    if !plugin_json_path.exists() {
611        return Ok(None);
612    }
613
614    // Read and parse plugin metadata
615    let plugin_json_content =
616        fs::read_to_string(&plugin_json_path).context("Failed to read plugin.json")?;
617
618    let plugin_metadata: crate::skills::native_plugin::PluginMetadata =
619        serde_json::from_str(&plugin_json_content).context("Invalid plugin.json format")?;
620
621    // Validate that the plugin has a corresponding dynamic library
622    let lib_name =
623        crate::skills::native_plugin::PluginLoader::new().library_filename(&plugin_metadata.name);
624
625    if !path.join(&lib_name).exists() {
626        // Try alternative library names
627        let alternatives = [
628            format!("lib{}.dylib", plugin_metadata.name),
629            format!("{}.dylib", plugin_metadata.name),
630            format!("lib{}.so", plugin_metadata.name),
631            format!("{}.so", plugin_metadata.name),
632            format!("{}.dll", plugin_metadata.name),
633        ];
634
635        let has_lib = alternatives.iter().any(|alt| path.join(alt).exists());
636        if !has_lib {
637            return Ok(None); // No library found, skip this plugin
638        }
639    }
640
641    Ok(Some(SkillMetadata {
642        name: plugin_metadata.name.clone(),
643        description: plugin_metadata.description.clone(),
644        short_description: None,
645        path: path.to_path_buf(),
646        scope,
647        manifest: None, // Native plugins don't have SKILL.md manifest
648    }))
649}
650
651pub fn load_skill_resources(skill_path: &Path) -> Result<Vec<crate::skills::types::SkillResource>> {
652    let mut resources = Vec::new();
653    let resource_dir = skill_path.join("scripts");
654
655    if resource_dir.exists() {
656        for entry in fs::read_dir(&resource_dir)? {
657            let entry = entry?;
658            let path = entry.path();
659
660            if path.is_file() {
661                let rel_path = path
662                    .strip_prefix(skill_path)
663                    .map(|p| p.to_string_lossy().to_string())
664                    .unwrap_or_default();
665
666                let resource_type = match path.extension().and_then(|e| e.to_str()) {
667                    Some("py") | Some("sh") | Some("bash") => {
668                        crate::skills::types::ResourceType::Script
669                    }
670                    Some("md") => crate::skills::types::ResourceType::Markdown,
671                    Some("json") | Some("yaml") | Some("yml") => {
672                        crate::skills::types::ResourceType::Reference
673                    }
674                    _ => {
675                        crate::skills::types::ResourceType::Other(format!("{:?}", path.extension()))
676                    }
677                };
678
679                resources.push(crate::skills::types::SkillResource {
680                    path: rel_path,
681                    resource_type,
682                    content: None,
683                });
684            }
685        }
686    }
687
688    // Check for references/ directory
689    let references_dir = skill_path.join("references");
690    if references_dir.exists() {
691        for entry in fs::read_dir(&references_dir)? {
692            let entry = entry?;
693            let path = entry.path();
694
695            if path.is_file() {
696                let rel_path = path
697                    .strip_prefix(skill_path)
698                    .map(|p| p.to_string_lossy().to_string())
699                    .unwrap_or_default();
700
701                let resource_type = match path.extension().and_then(|e| e.to_str()) {
702                    Some("md") => crate::skills::types::ResourceType::Reference,
703                    Some("json") | Some("yaml") | Some("yml") | Some("txt") | Some("csv") => {
704                        crate::skills::types::ResourceType::Reference
705                    }
706                    _ => {
707                        crate::skills::types::ResourceType::Other(format!("{:?}", path.extension()))
708                    }
709                };
710
711                resources.push(crate::skills::types::SkillResource {
712                    path: rel_path,
713                    resource_type,
714                    content: None,
715                });
716            }
717        }
718    }
719
720    // Check for assets/ directory
721    let assets_dir = skill_path.join("assets");
722    if assets_dir.exists() {
723        for entry in fs::read_dir(&assets_dir)? {
724            let entry = entry?;
725            let path = entry.path();
726
727            if path.is_file() {
728                let rel_path = path
729                    .strip_prefix(skill_path)
730                    .map(|p| p.to_string_lossy().to_string())
731                    .unwrap_or_default();
732
733                let resource_type = match path.extension().and_then(|e| e.to_str()) {
734                    Some("png") | Some("jpg") | Some("jpeg") | Some("gif") | Some("svg") => {
735                        crate::skills::types::ResourceType::Asset
736                    }
737                    Some("json") | Some("yaml") | Some("yml") | Some("txt") | Some("csv") => {
738                        crate::skills::types::ResourceType::Asset
739                    }
740                    _ => crate::skills::types::ResourceType::Asset,
741                };
742
743                resources.push(crate::skills::types::SkillResource {
744                    path: rel_path,
745                    resource_type,
746                    content: None,
747                });
748            }
749        }
750    }
751
752    Ok(resources)
753}
754
755fn is_executable_file(path: &Path) -> bool {
756    #[cfg(unix)]
757    {
758        use std::os::unix::fs::PermissionsExt;
759        if let Ok(meta) = path.metadata() {
760            return meta.permissions().mode() & 0o111 != 0;
761        }
762    }
763    #[cfg(windows)]
764    {
765        if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
766            return matches!(ext.to_lowercase().as_str(), "exe" | "bat" | "cmd");
767        }
768    }
769    false
770}
771
772/// Enhanced skill variant for unified handling
773pub enum EnhancedSkill {
774    /// Traditional instruction-based skill
775    Traditional(Box<Skill>),
776    /// CLI-based tool skill
777    CliTool(Box<CliToolBridge>),
778    /// Built-in VT Code command skill
779    BuiltInCommand(Box<BuiltInCommandSkill>),
780    /// Native code plugin skill
781    NativePlugin(Box<dyn crate::skills::native_plugin::NativePluginTrait>),
782}
783
784impl std::fmt::Debug for EnhancedSkill {
785    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
786        match self {
787            Self::Traditional(skill) => f.debug_tuple("Traditional").field(skill).finish(),
788            Self::CliTool(tool) => f.debug_tuple("CliTool").field(tool).finish(),
789            Self::BuiltInCommand(skill) => f.debug_tuple("BuiltInCommand").field(skill).finish(),
790            Self::NativePlugin(plugin) => f.debug_tuple("NativePlugin").field(plugin).finish(),
791        }
792    }
793}
794
795/// High-level loader that provides discovery and validation features
796pub struct EnhancedSkillLoader {
797    workspace_root: PathBuf,
798    codex_home: PathBuf,
799    discovery: SkillDiscovery,
800    plugin_loader: crate::skills::native_plugin::PluginLoader,
801}
802
803fn plugin_loader_for_workspace(
804    workspace_root: &Path,
805    codex_home: Option<&Path>,
806) -> crate::skills::native_plugin::PluginLoader {
807    let mut plugin_loader = crate::skills::native_plugin::PluginLoader::new();
808
809    if let Some(codex_home) = codex_home {
810        plugin_loader.add_trusted_dir(codex_home.join("plugins"));
811    } else {
812        plugin_loader.add_trusted_dir(dirs::home_dir().unwrap_or_default().join(".vtcode/plugins"));
813    }
814
815    plugin_loader
816        .add_trusted_dir(workspace_root.join(".vtcode/plugins"))
817        .add_trusted_dir(workspace_root.join(".agents/plugins"));
818
819    plugin_loader
820}
821
822fn default_codex_home() -> PathBuf {
823    std::env::var_os("CODEX_HOME")
824        .filter(|value| !value.is_empty())
825        .map(PathBuf::from)
826        .or_else(|| dirs::home_dir().map(|home| home.join(".codex")))
827        .unwrap_or_else(|| PathBuf::from(".codex"))
828}
829
830fn discovery_config_for_codex_home(workspace_root: &Path, codex_home: &Path) -> DiscoveryConfig {
831    let home_dir = dirs::home_dir();
832    let loader_config = SkillLoaderConfig {
833        codex_home: codex_home.to_path_buf(),
834        cwd: workspace_root.to_path_buf(),
835        project_root: find_git_root(workspace_root),
836        include_bundled_system_skills: true,
837    };
838    let roots = skill_roots_with_home_dir(&loader_config, home_dir.as_deref());
839
840    DiscoveryConfig {
841        skill_paths: roots
842            .iter()
843            .filter(|root| !root.is_tool_root && !root.is_plugin_root)
844            .map(|root| root.path.clone())
845            .collect(),
846        tool_paths: roots
847            .iter()
848            .filter(|root| root.is_tool_root)
849            .map(|root| root.path.clone())
850            .collect(),
851        ..Default::default()
852    }
853}
854
855impl EnhancedSkillLoader {
856    /// Create a new enhanced loader for workspace
857    pub fn new(workspace_root: PathBuf) -> Self {
858        let codex_home = default_codex_home();
859        let discovery = SkillDiscovery::with_config(discovery_config_for_codex_home(
860            &workspace_root,
861            &codex_home,
862        ));
863        let plugin_loader = plugin_loader_for_workspace(&workspace_root, Some(&codex_home));
864        Self {
865            workspace_root,
866            codex_home,
867            discovery,
868            plugin_loader,
869        }
870    }
871
872    /// Create a loader pinned to a specific VT Code home directory.
873    pub fn with_codex_home(workspace_root: PathBuf, codex_home: PathBuf) -> Self {
874        let discovery = SkillDiscovery::with_config(discovery_config_for_codex_home(
875            &workspace_root,
876            &codex_home,
877        ));
878        let plugin_loader = plugin_loader_for_workspace(&workspace_root, Some(&codex_home));
879        Self {
880            workspace_root,
881            codex_home,
882            discovery,
883            plugin_loader,
884        }
885    }
886
887    fn ensure_system_skills_installed(&self) {
888        if let Err(err) = install_system_skills(&self.codex_home) {
889            tracing::warn!("enhanced skill loader failed to install bundled system skills: {err}");
890        }
891    }
892
893    /// Discover all available skills and tools
894    pub async fn discover_all_skills(&mut self) -> Result<DiscoveryResult> {
895        self.ensure_system_skills_installed();
896        let mut result = self.discovery.discover_all(&self.workspace_root).await?;
897        merge_built_in_command_skill_contexts(&mut result.skills);
898        Ok(result)
899    }
900
901    /// Get a specific skill by name
902    pub async fn get_skill(&mut self, name: &str) -> Result<EnhancedSkill> {
903        self.ensure_system_skills_installed();
904        let result = self.discovery.discover_all(&self.workspace_root).await?;
905
906        // Try traditional skills first
907        for skill_ctx in &result.skills {
908            if skill_ctx.manifest().name == name {
909                let path = skill_ctx.path();
910                let (manifest, instructions) = crate::skills::manifest::parse_skill_file(path)?;
911                let skill = Skill::with_scope(
912                    manifest,
913                    path.clone(),
914                    infer_scope_from_skill_path(path, &self.workspace_root),
915                    instructions,
916                )?;
917                return Ok(EnhancedSkill::Traditional(Box::new(skill)));
918            }
919        }
920
921        // Try CLI tools
922        for tool_config in &result.tools {
923            if tool_config.name == name {
924                let bridge = CliToolBridge::new(tool_config.clone())?;
925                return Ok(EnhancedSkill::CliTool(Box::new(bridge)));
926            }
927        }
928
929        if let Some(skill) = built_in_command_skill(name) {
930            return Ok(EnhancedSkill::BuiltInCommand(Box::new(skill)));
931        }
932
933        // Try native plugins - discover plugin directories and load on demand
934        // First, find the plugin directory by scanning trusted directories
935        for plugin_dir in self.get_plugin_directories() {
936            if !plugin_dir.exists() {
937                continue;
938            }
939
940            // Check if this directory contains the requested plugin
941            let plugin_json = plugin_dir.join("plugin.json");
942            if let Ok(content) = fs::read_to_string(&plugin_json)
943                && let Ok(metadata) =
944                    serde_json::from_str::<crate::skills::native_plugin::PluginMetadata>(&content)
945                && metadata.name == name
946            {
947                // Load the plugin
948                let plugin = self.plugin_loader.load_plugin(&plugin_dir)?;
949                return Ok(EnhancedSkill::NativePlugin(plugin));
950            }
951        }
952
953        Err(skill_ops::skill_not_found_error(name))
954    }
955
956    /// Get all trusted plugin directories
957    fn get_plugin_directories(&self) -> Vec<PathBuf> {
958        self.plugin_loader.trusted_dirs().to_vec()
959    }
960
961    /// Generate a comprehensive container validation report
962    pub async fn generate_validation_report(&mut self) -> Result<ContainerValidationReport> {
963        let result = self.discovery.discover_all(&self.workspace_root).await?;
964        let mut report = ContainerValidationReport::new();
965        let validator = ContainerSkillsValidator::new();
966
967        for skill_ctx in &result.skills {
968            match self.load_full_skill_from_ctx(skill_ctx) {
969                Ok(skill) => {
970                    let analysis = validator.analyze_skill(&skill);
971                    report.add_skill_analysis(skill.name().to_string(), analysis);
972                }
973                Err(e) => {
974                    report.add_incompatible_skill(
975                        skill_ctx.manifest().name.clone(),
976                        skill_ctx.manifest().description.clone(),
977                        format!("Load error: {}", e),
978                    );
979                }
980            }
981        }
982
983        report.finalize();
984        Ok(report)
985    }
986
987    /// Check container requirements for a skill
988    pub fn check_container_requirements(&self, skill: &Skill) -> ContainerValidationResult {
989        let validator = ContainerSkillsValidator::new();
990        validator.analyze_skill(skill)
991    }
992
993    fn load_full_skill_from_ctx(&self, ctx: &SkillContext) -> Result<Skill> {
994        let path = ctx.path();
995        let (manifest, instructions) = crate::skills::manifest::parse_skill_file(path)?;
996        Skill::with_scope(
997            manifest,
998            path.clone(),
999            infer_scope_from_skill_path(path, &self.workspace_root),
1000            instructions,
1001        )
1002    }
1003}
1004
1005fn infer_scope_from_skill_path(path: &Path, workspace_root: &Path) -> SkillScope {
1006    if path.starts_with(Path::new("/etc/codex/skills")) {
1007        return SkillScope::Admin;
1008    }
1009    if path.starts_with(system_cache_root_dir(&default_codex_home())) {
1010        return SkillScope::System;
1011    }
1012    if let Some(home) = dirs::home_dir()
1013        && path.starts_with(home.join(".agents/skills"))
1014    {
1015        return SkillScope::User;
1016    }
1017    if path.starts_with(workspace_root)
1018        || path.to_string_lossy().contains("/.agents/skills/")
1019        || path.to_string_lossy().contains("\\.agents\\skills\\")
1020    {
1021        return SkillScope::Repo;
1022    }
1023    SkillScope::User
1024}
1025
1026#[derive(Debug, Clone, Copy)]
1027pub struct SkillMentionDetectionOptions {
1028    pub enable_auto_trigger: bool,
1029    pub enable_description_matching: bool,
1030    pub min_keyword_matches: usize,
1031}
1032
1033impl Default for SkillMentionDetectionOptions {
1034    fn default() -> Self {
1035        Self {
1036            enable_auto_trigger: true,
1037            enable_description_matching: true,
1038            min_keyword_matches: 2,
1039        }
1040    }
1041}
1042
1043/// Detect skill mentions using default routing options.
1044pub fn detect_skill_mentions(user_input: &str, available_skills: &[SkillManifest]) -> Vec<String> {
1045    detect_skill_mentions_with_options(
1046        user_input,
1047        available_skills,
1048        &SkillMentionDetectionOptions::default(),
1049    )
1050}
1051
1052/// Detect skill mentions using explicit routing options.
1053///
1054/// Routing policy:
1055/// - Explicit `$skill-name` mentions always win.
1056/// - Description keywords provide the only implicit signal.
1057pub fn detect_skill_mentions_with_options(
1058    user_input: &str,
1059    available_skills: &[SkillManifest],
1060    options: &SkillMentionDetectionOptions,
1061) -> Vec<String> {
1062    if !options.enable_auto_trigger {
1063        return Vec::new();
1064    }
1065
1066    let mut mentions = Vec::new();
1067    let input_lower = user_input.to_lowercase();
1068    let input_keywords = extract_keywords(user_input);
1069    let min_matches = options.min_keyword_matches.max(1);
1070
1071    for skill in available_skills {
1072        let skill_name_lower = skill.name.to_lowercase();
1073        let explicit_trigger = format!("${skill_name_lower}");
1074        if input_lower.contains(&explicit_trigger) {
1075            mentions.push(skill.name.clone());
1076            continue;
1077        }
1078
1079        if !options.enable_description_matching {
1080            continue;
1081        }
1082
1083        let description_keywords = extract_keywords(&skill.description);
1084        let description_matches = overlap_count(&input_keywords, &description_keywords);
1085        if description_matches >= min_matches {
1086            mentions.push(skill.name.clone());
1087        }
1088    }
1089
1090    mentions.sort();
1091    mentions.dedup();
1092    mentions
1093}
1094
1095fn overlap_count(input_keywords: &HashSet<String>, skill_keywords: &HashSet<String>) -> usize {
1096    input_keywords.intersection(skill_keywords).count()
1097}
1098
1099fn extract_keywords(text: &str) -> HashSet<String> {
1100    const STOPWORDS: &[&str] = &[
1101        "the", "and", "with", "from", "that", "this", "when", "where", "what", "your", "for",
1102        "into", "onto", "than", "then", "also", "only", "should", "would", "could", "have", "has",
1103        "had", "use", "using", "task", "tasks", "help", "need", "want",
1104    ];
1105
1106    text.split(|c: char| !c.is_alphanumeric())
1107        .map(|part| part.trim().to_lowercase())
1108        .filter(|part| part.len() > 2)
1109        .filter(|part| !STOPWORDS.contains(&part.as_str()))
1110        .collect()
1111}
1112
1113/// Test helper for hermetic skill loading that does not pick up user skills.
1114/// Use this in tests to avoid failures when ~/.agents/skills contains skills.
1115#[cfg(test)]
1116pub fn load_skills_hermetic(config: &SkillLoaderConfig) -> SkillLoadOutcome {
1117    load_skills_with_home_dir(config, None)
1118}
1119
1120/// Test helper for hermetic lightweight skill discovery.
1121#[cfg(test)]
1122pub fn discover_skill_metadata_lightweight_hermetic(
1123    config: &SkillLoaderConfig,
1124) -> SkillLoadOutcome {
1125    discover_skill_metadata_lightweight_with_home_dir(config, None)
1126}
1127
1128#[cfg(test)]
1129mod tests {
1130    use super::*;
1131    use crate::skills::CommandSkillBackend;
1132    use crate::skills::command_skills::command_skill_specs;
1133    use crate::skills::system::{install_system_skills, system_cache_root_dir};
1134    use serial_test::serial;
1135    use std::fs;
1136    use tempfile::TempDir;
1137    use tempfile::tempdir;
1138
1139    fn manifest(name: &str, description: &str) -> SkillManifest {
1140        SkillManifest {
1141            name: name.to_string(),
1142            description: description.to_string(),
1143            ..Default::default()
1144        }
1145    }
1146
1147    #[test]
1148    fn detects_explicit_skill_mentions() {
1149        let skills = vec![manifest(
1150            "pdf-analyzer",
1151            "Analyze PDF files and extract tables",
1152        )];
1153        let mentions = detect_skill_mentions("Use $pdf-analyzer for this file", &skills);
1154        assert_eq!(mentions, vec!["pdf-analyzer".to_string()]);
1155    }
1156
1157    #[test]
1158    fn description_keywords_drive_implicit_matches() {
1159        let skills = vec![manifest(
1160            "api-fetcher",
1161            "Fetch data from API endpoints and summarize responses",
1162        )];
1163
1164        let mentions = detect_skill_mentions(
1165            "Fetch and summarize API responses for these endpoints",
1166            &skills,
1167        );
1168        assert_eq!(mentions, vec!["api-fetcher".to_string()]);
1169    }
1170
1171    #[test]
1172    fn unrelated_input_does_not_match_description() {
1173        let skills = vec![manifest(
1174            "api-fetcher",
1175            "Fetch data from API endpoints and summarize responses",
1176        )];
1177
1178        let mentions = detect_skill_mentions(
1179            "Please update this local markdown file and fix headings",
1180            &skills,
1181        );
1182        assert!(mentions.is_empty());
1183    }
1184
1185    #[test]
1186    fn auto_trigger_can_be_disabled() {
1187        let skills = vec![manifest(
1188            "sql-checker",
1189            "Validate SQL migration scripts for safety",
1190        )];
1191        let options = SkillMentionDetectionOptions {
1192            enable_auto_trigger: false,
1193            ..Default::default()
1194        };
1195        let mentions = detect_skill_mentions_with_options("Use $sql-checker", &skills, &options);
1196        assert!(mentions.is_empty());
1197    }
1198
1199    #[test]
1200    #[serial]
1201    fn lightweight_metadata_discovery_reuses_process_wide_cache() {
1202        clear_lightweight_skill_metadata_cache();
1203
1204        let codex_home = tempdir().expect("codex home");
1205        let workspace = tempdir().expect("workspace");
1206        let skill_dir = workspace
1207            .path()
1208            .join(".agents/skills/process-wide-cache-skill");
1209        fs::create_dir_all(&skill_dir).expect("create skill dir");
1210        fs::write(
1211            skill_dir.join("SKILL.md"),
1212            "---\nname: process-wide-cache-skill\ndescription: process-wide cache test\n---\n# Body\n",
1213        )
1214        .expect("write skill");
1215
1216        let config = SkillLoaderConfig {
1217            codex_home: codex_home.path().to_path_buf(),
1218            cwd: workspace.path().to_path_buf(),
1219            project_root: Some(workspace.path().to_path_buf()),
1220            include_bundled_system_skills: false,
1221        };
1222
1223        let first = discover_skill_metadata_lightweight_hermetic(&config);
1224        assert!(
1225            first
1226                .skills
1227                .iter()
1228                .any(|skill| skill.name == "process-wide-cache-skill"),
1229            "expected first discovery to find test skill",
1230        );
1231
1232        fs::remove_dir_all(&skill_dir).expect("remove cached skill dir");
1233
1234        let second = discover_skill_metadata_lightweight_hermetic(&config);
1235        assert!(
1236            second
1237                .skills
1238                .iter()
1239                .any(|skill| skill.name == "process-wide-cache-skill"),
1240            "expected cached discovery to preserve removed skill until cache is cleared",
1241        );
1242
1243        clear_lightweight_skill_metadata_cache();
1244
1245        let third = discover_skill_metadata_lightweight_hermetic(&config);
1246        assert!(
1247            !third
1248                .skills
1249                .iter()
1250                .any(|skill| skill.name == "process-wide-cache-skill"),
1251            "expected cleared cache to force rediscovery",
1252        );
1253    }
1254
1255    #[tokio::test]
1256    async fn enhanced_loader_discovers_and_loads_built_in_command_skills() {
1257        let temp_dir = TempDir::new().expect("temp dir");
1258        let mut loader = EnhancedSkillLoader::new(temp_dir.path().to_path_buf());
1259
1260        let discovery = loader.discover_all_skills().await.expect("discover skills");
1261        assert!(
1262            discovery
1263                .skills
1264                .iter()
1265                .any(|skill_ctx| skill_ctx.manifest().name == "cmd-status")
1266        );
1267
1268        let skill = loader
1269            .get_skill("cmd-status")
1270            .await
1271            .expect("load cmd-status");
1272        assert!(matches!(skill, EnhancedSkill::BuiltInCommand(_)));
1273    }
1274
1275    #[tokio::test]
1276    async fn enhanced_loader_discovers_and_loads_bundled_command_skills() {
1277        let workspace = TempDir::new().expect("workspace");
1278        let codex_home = TempDir::new().expect("codex home");
1279        install_system_skills(codex_home.path()).expect("install bundled system skills");
1280        let cmd_review_dir = system_cache_root_dir(codex_home.path()).join("cmd-review");
1281        assert!(
1282            cmd_review_dir.join("SKILL.md").exists(),
1283            "expected bundled cmd-review at {}",
1284            cmd_review_dir.display()
1285        );
1286        let (manifest, _) =
1287            crate::skills::manifest::parse_skill_file(&cmd_review_dir).expect("parse cmd-review");
1288        assert_eq!(manifest.name, "cmd-review");
1289        let config = discovery_config_for_codex_home(workspace.path(), codex_home.path());
1290        assert!(
1291            config
1292                .skill_paths
1293                .iter()
1294                .any(|path| path == &system_cache_root_dir(codex_home.path()))
1295        );
1296        let mut loader = EnhancedSkillLoader::with_codex_home(
1297            workspace.path().to_path_buf(),
1298            codex_home.path().to_path_buf(),
1299        );
1300
1301        let discovery = loader.discover_all_skills().await.expect("discover skills");
1302        assert!(
1303            discovery
1304                .skills
1305                .iter()
1306                .any(|skill_ctx| skill_ctx.manifest().name == "cmd-review")
1307        );
1308
1309        let skill = loader
1310            .get_skill("cmd-review")
1311            .await
1312            .expect("load cmd-review");
1313        assert!(matches!(skill, EnhancedSkill::Traditional(_)));
1314    }
1315
1316    #[tokio::test]
1317    async fn enhanced_loader_discovers_every_command_skill() {
1318        let workspace = TempDir::new().expect("workspace");
1319        let codex_home = TempDir::new().expect("codex home");
1320        let mut loader = EnhancedSkillLoader::with_codex_home(
1321            workspace.path().to_path_buf(),
1322            codex_home.path().to_path_buf(),
1323        );
1324
1325        let discovery = loader.discover_all_skills().await.expect("discover skills");
1326        let discovered_names = discovery
1327            .skills
1328            .iter()
1329            .map(|skill_ctx| skill_ctx.manifest().name.as_str())
1330            .collect::<std::collections::HashSet<_>>();
1331
1332        for spec in command_skill_specs() {
1333            assert!(
1334                discovered_names.contains(spec.skill_name),
1335                "missing command skill {}",
1336                spec.skill_name
1337            );
1338
1339            let skill = loader
1340                .get_skill(spec.skill_name)
1341                .await
1342                .unwrap_or_else(|error| panic!("failed to load {}: {error}", spec.skill_name));
1343
1344            match spec.backend {
1345                CommandSkillBackend::TraditionalSkill { .. } => {
1346                    assert!(
1347                        matches!(skill, EnhancedSkill::Traditional(_)),
1348                        "{} should load as a traditional skill",
1349                        spec.skill_name
1350                    );
1351                }
1352                CommandSkillBackend::BuiltInCommand { .. } => {
1353                    assert!(
1354                        matches!(skill, EnhancedSkill::BuiltInCommand(_)),
1355                        "{} should load as a built-in command skill",
1356                        spec.skill_name
1357                    );
1358                }
1359            }
1360        }
1361    }
1362
1363    fn write_skill(dir: &Path, name: &str, description: &str) {
1364        fs::create_dir_all(dir).expect("create skill dir");
1365        fs::write(
1366            dir.join("SKILL.md"),
1367            format!("---\nname: {name}\ndescription: {description}\n---\n\nUse this skill.\n"),
1368        )
1369        .expect("write SKILL.md");
1370    }
1371
1372    fn write_codex_skill_config(home_dir: &Path, contents: &str) {
1373        let config_dir = home_dir.join(".codex");
1374        fs::create_dir_all(&config_dir).expect("create config dir");
1375        fs::write(config_dir.join("config.toml"), contents).expect("write config");
1376    }
1377
1378    fn skill_loader_config_for(workspace: &Path, codex_home: &Path) -> SkillLoaderConfig {
1379        SkillLoaderConfig {
1380            codex_home: codex_home.to_path_buf(),
1381            cwd: workspace.to_path_buf(),
1382            project_root: find_git_root(workspace),
1383            include_bundled_system_skills: false,
1384        }
1385    }
1386
1387    #[test]
1388    fn disabled_skill_config_supports_stable_names() {
1389        let workspace = tempdir().expect("workspace");
1390        fs::create_dir(workspace.path().join(".git")).expect("create .git");
1391
1392        let home = tempdir().expect("home");
1393        let codex_home = tempdir().expect("codex home");
1394
1395        let old_plugin_skill_dir = workspace
1396            .path()
1397            .join(".agents/plugins/example-plugin-v1/skills/release-helper");
1398        write_skill(
1399            &old_plugin_skill_dir,
1400            "release-helper",
1401            "Prepare release notes",
1402        );
1403
1404        write_codex_skill_config(
1405            home.path(),
1406            &format!(
1407                "[[skills.config]]\nname = \"release-helper\"\npath = \"{}\"\nenabled = false\n",
1408                old_plugin_skill_dir.display()
1409            ),
1410        );
1411
1412        let new_plugin_skill_dir = workspace
1413            .path()
1414            .join(".agents/plugins/example-plugin-v2/skills/release-helper");
1415        write_skill(
1416            &new_plugin_skill_dir,
1417            "release-helper",
1418            "Prepare release notes",
1419        );
1420
1421        fs::remove_dir_all(workspace.path().join(".agents/plugins/example-plugin-v1"))
1422            .expect("remove old plugin version");
1423
1424        let outcome = load_skills_with_home_dir(
1425            &skill_loader_config_for(workspace.path(), codex_home.path()),
1426            Some(home.path()),
1427        );
1428
1429        assert!(
1430            outcome
1431                .skills
1432                .iter()
1433                .all(|skill| skill.name != "release-helper"),
1434            "expected release-helper to stay disabled after plugin path changed"
1435        );
1436    }
1437
1438    #[test]
1439    fn disabled_skill_config_preserves_path_based_entries() {
1440        let workspace = tempdir().expect("workspace");
1441        let home = tempdir().expect("home");
1442        let codex_home = tempdir().expect("codex home");
1443
1444        let skill_dir = home.path().join(".agents/skills/path-disabled");
1445        write_skill(&skill_dir, "path-disabled", "Disabled by explicit path");
1446
1447        write_codex_skill_config(
1448            home.path(),
1449            &format!(
1450                "[[skills.config]]\npath = \"{}\"\nenabled = false\n",
1451                skill_dir.join("SKILL.md").display()
1452            ),
1453        );
1454
1455        let outcome = load_skills_with_home_dir(
1456            &skill_loader_config_for(workspace.path(), codex_home.path()),
1457            Some(home.path()),
1458        );
1459
1460        assert!(
1461            outcome
1462                .skills
1463                .iter()
1464                .all(|skill| skill.name != "path-disabled"),
1465            "expected path-disabled to remain filtered by legacy path config"
1466        );
1467    }
1468}