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 and manifest metadata keywords provide the only implicit signals.
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 skill_keywords = skill_routing_keywords(skill);
1084        let keyword_matches = overlap_count(&input_keywords, &skill_keywords);
1085        if keyword_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 skill_routing_keywords(skill: &SkillManifest) -> HashSet<String> {
1100    let mut keywords = extract_keywords(&skill.description);
1101
1102    let Some(metadata) = &skill.metadata else {
1103        return keywords;
1104    };
1105    for metadata_keywords in [metadata.get("keywords"), metadata.get("tags")]
1106        .into_iter()
1107        .flatten()
1108    {
1109        match metadata_keywords {
1110            serde_json::Value::Array(values) => {
1111                for value in values {
1112                    if let Some(keyword) = value.as_str() {
1113                        keywords.extend(extract_keywords(keyword));
1114                    }
1115                }
1116            }
1117            serde_json::Value::String(value) => {
1118                keywords.extend(extract_keywords(value));
1119            }
1120            _ => {}
1121        }
1122    }
1123
1124    keywords
1125}
1126
1127fn extract_keywords(text: &str) -> HashSet<String> {
1128    const STOPWORDS: &[&str] = &[
1129        "the", "and", "with", "from", "that", "this", "when", "where", "what", "your", "for",
1130        "into", "onto", "than", "then", "also", "only", "should", "would", "could", "have", "has",
1131        "had", "use", "using", "task", "tasks", "help", "need", "want",
1132    ];
1133
1134    text.split(|c: char| !c.is_alphanumeric())
1135        .map(|part| part.trim().to_lowercase())
1136        .filter(|part| part.len() > 2)
1137        .filter(|part| !STOPWORDS.contains(&part.as_str()))
1138        .collect()
1139}
1140
1141/// Test helper for hermetic skill loading that does not pick up user skills.
1142/// Use this in tests to avoid failures when ~/.agents/skills contains skills.
1143#[cfg(test)]
1144pub fn load_skills_hermetic(config: &SkillLoaderConfig) -> SkillLoadOutcome {
1145    load_skills_with_home_dir(config, None)
1146}
1147
1148/// Test helper for hermetic lightweight skill discovery.
1149#[cfg(test)]
1150pub fn discover_skill_metadata_lightweight_hermetic(
1151    config: &SkillLoaderConfig,
1152) -> SkillLoadOutcome {
1153    discover_skill_metadata_lightweight_with_home_dir(config, None)
1154}
1155
1156#[cfg(test)]
1157mod tests {
1158    use super::*;
1159    use crate::skills::CommandSkillBackend;
1160    use crate::skills::command_skills::command_skill_specs;
1161    use crate::skills::system::{install_system_skills, system_cache_root_dir};
1162    use serial_test::serial;
1163    use std::fs;
1164    use tempfile::TempDir;
1165    use tempfile::tempdir;
1166
1167    fn manifest(name: &str, description: &str) -> SkillManifest {
1168        SkillManifest {
1169            name: name.to_string(),
1170            description: description.to_string(),
1171            ..Default::default()
1172        }
1173    }
1174
1175    #[test]
1176    fn detects_explicit_skill_mentions() {
1177        let skills = vec![manifest(
1178            "pdf-analyzer",
1179            "Analyze PDF files and extract tables",
1180        )];
1181        let mentions = detect_skill_mentions("Use $pdf-analyzer for this file", &skills);
1182        assert_eq!(mentions, vec!["pdf-analyzer".to_string()]);
1183    }
1184
1185    #[test]
1186    fn description_keywords_drive_implicit_matches() {
1187        let skills = vec![manifest(
1188            "api-fetcher",
1189            "Fetch data from API endpoints and summarize responses",
1190        )];
1191
1192        let mentions = detect_skill_mentions(
1193            "Fetch and summarize API responses for these endpoints",
1194            &skills,
1195        );
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            (
1204                "keywords".to_string(),
1205                serde_json::json!(["tree-sitter parser"]),
1206            ),
1207            ("tags".to_string(), serde_json::json!(["optional chaining"])),
1208        ]));
1209
1210        let mentions = detect_skill_mentions("Debug an optional chaining rewrite", &[ast_grep]);
1211        assert_eq!(mentions, vec!["ast-grep".to_string()]);
1212    }
1213
1214    #[test]
1215    fn unrelated_input_does_not_match_description() {
1216        let skills = vec![manifest(
1217            "api-fetcher",
1218            "Fetch data from API endpoints and summarize responses",
1219        )];
1220
1221        let mentions = detect_skill_mentions(
1222            "Please update this local markdown file and fix headings",
1223            &skills,
1224        );
1225        assert!(mentions.is_empty());
1226    }
1227
1228    #[test]
1229    fn auto_trigger_can_be_disabled() {
1230        let skills = vec![manifest(
1231            "sql-checker",
1232            "Validate SQL migration scripts for safety",
1233        )];
1234        let options = SkillMentionDetectionOptions {
1235            enable_auto_trigger: false,
1236            ..Default::default()
1237        };
1238        let mentions = detect_skill_mentions_with_options("Use $sql-checker", &skills, &options);
1239        assert!(mentions.is_empty());
1240    }
1241
1242    #[test]
1243    #[serial]
1244    fn lightweight_metadata_discovery_reuses_process_wide_cache() {
1245        clear_lightweight_skill_metadata_cache();
1246
1247        let codex_home = tempdir().expect("codex home");
1248        let workspace = tempdir().expect("workspace");
1249        let skill_dir = workspace
1250            .path()
1251            .join(".agents/skills/process-wide-cache-skill");
1252        fs::create_dir_all(&skill_dir).expect("create skill dir");
1253        fs::write(
1254            skill_dir.join("SKILL.md"),
1255            "---\nname: process-wide-cache-skill\ndescription: process-wide cache test\n---\n# Body\n",
1256        )
1257        .expect("write skill");
1258
1259        let config = SkillLoaderConfig {
1260            codex_home: codex_home.path().to_path_buf(),
1261            cwd: workspace.path().to_path_buf(),
1262            project_root: Some(workspace.path().to_path_buf()),
1263            include_bundled_system_skills: false,
1264        };
1265
1266        let first = discover_skill_metadata_lightweight_hermetic(&config);
1267        assert!(
1268            first
1269                .skills
1270                .iter()
1271                .any(|skill| skill.name == "process-wide-cache-skill"),
1272            "expected first discovery to find test skill",
1273        );
1274
1275        fs::remove_dir_all(&skill_dir).expect("remove cached skill dir");
1276
1277        let second = discover_skill_metadata_lightweight_hermetic(&config);
1278        assert!(
1279            second
1280                .skills
1281                .iter()
1282                .any(|skill| skill.name == "process-wide-cache-skill"),
1283            "expected cached discovery to preserve removed skill until cache is cleared",
1284        );
1285
1286        clear_lightweight_skill_metadata_cache();
1287
1288        let third = discover_skill_metadata_lightweight_hermetic(&config);
1289        assert!(
1290            !third
1291                .skills
1292                .iter()
1293                .any(|skill| skill.name == "process-wide-cache-skill"),
1294            "expected cleared cache to force rediscovery",
1295        );
1296    }
1297
1298    #[tokio::test]
1299    async fn enhanced_loader_discovers_and_loads_built_in_command_skills() {
1300        let temp_dir = TempDir::new().expect("temp dir");
1301        let mut loader = EnhancedSkillLoader::new(temp_dir.path().to_path_buf());
1302
1303        let discovery = loader.discover_all_skills().await.expect("discover skills");
1304        assert!(
1305            discovery
1306                .skills
1307                .iter()
1308                .any(|skill_ctx| skill_ctx.manifest().name == "cmd-status")
1309        );
1310
1311        let skill = loader
1312            .get_skill("cmd-status")
1313            .await
1314            .expect("load cmd-status");
1315        assert!(matches!(skill, EnhancedSkill::BuiltInCommand(_)));
1316    }
1317
1318    #[tokio::test]
1319    async fn enhanced_loader_discovers_and_loads_bundled_command_skills() {
1320        let workspace = TempDir::new().expect("workspace");
1321        let codex_home = TempDir::new().expect("codex home");
1322        install_system_skills(codex_home.path()).expect("install bundled system skills");
1323        let cmd_review_dir = system_cache_root_dir(codex_home.path()).join("cmd-review");
1324        assert!(
1325            cmd_review_dir.join("SKILL.md").exists(),
1326            "expected bundled cmd-review at {}",
1327            cmd_review_dir.display()
1328        );
1329        let (manifest, _) =
1330            crate::skills::manifest::parse_skill_file(&cmd_review_dir).expect("parse cmd-review");
1331        assert_eq!(manifest.name, "cmd-review");
1332        let config = discovery_config_for_codex_home(workspace.path(), codex_home.path());
1333        assert!(
1334            config
1335                .skill_paths
1336                .iter()
1337                .any(|path| path == &system_cache_root_dir(codex_home.path()))
1338        );
1339        let mut loader = EnhancedSkillLoader::with_codex_home(
1340            workspace.path().to_path_buf(),
1341            codex_home.path().to_path_buf(),
1342        );
1343
1344        let discovery = loader.discover_all_skills().await.expect("discover skills");
1345        assert!(
1346            discovery
1347                .skills
1348                .iter()
1349                .any(|skill_ctx| skill_ctx.manifest().name == "cmd-review")
1350        );
1351
1352        let skill = loader
1353            .get_skill("cmd-review")
1354            .await
1355            .expect("load cmd-review");
1356        assert!(matches!(skill, EnhancedSkill::Traditional(_)));
1357    }
1358
1359    #[tokio::test]
1360    async fn enhanced_loader_discovers_every_command_skill() {
1361        let workspace = TempDir::new().expect("workspace");
1362        let codex_home = TempDir::new().expect("codex home");
1363        let mut loader = EnhancedSkillLoader::with_codex_home(
1364            workspace.path().to_path_buf(),
1365            codex_home.path().to_path_buf(),
1366        );
1367
1368        let discovery = loader.discover_all_skills().await.expect("discover skills");
1369        let discovered_names = discovery
1370            .skills
1371            .iter()
1372            .map(|skill_ctx| skill_ctx.manifest().name.as_str())
1373            .collect::<std::collections::HashSet<_>>();
1374
1375        for spec in command_skill_specs() {
1376            assert!(
1377                discovered_names.contains(spec.skill_name),
1378                "missing command skill {}",
1379                spec.skill_name
1380            );
1381
1382            let skill = loader
1383                .get_skill(spec.skill_name)
1384                .await
1385                .unwrap_or_else(|error| panic!("failed to load {}: {error}", spec.skill_name));
1386
1387            match spec.backend {
1388                CommandSkillBackend::TraditionalSkill { .. } => {
1389                    assert!(
1390                        matches!(skill, EnhancedSkill::Traditional(_)),
1391                        "{} should load as a traditional skill",
1392                        spec.skill_name
1393                    );
1394                }
1395                CommandSkillBackend::BuiltInCommand { .. } => {
1396                    assert!(
1397                        matches!(skill, EnhancedSkill::BuiltInCommand(_)),
1398                        "{} should load as a built-in command skill",
1399                        spec.skill_name
1400                    );
1401                }
1402            }
1403        }
1404    }
1405
1406    fn write_skill(dir: &Path, name: &str, description: &str) {
1407        fs::create_dir_all(dir).expect("create skill dir");
1408        fs::write(
1409            dir.join("SKILL.md"),
1410            format!("---\nname: {name}\ndescription: {description}\n---\n\nUse this skill.\n"),
1411        )
1412        .expect("write SKILL.md");
1413    }
1414
1415    fn write_codex_skill_config(home_dir: &Path, contents: &str) {
1416        let config_dir = home_dir.join(".codex");
1417        fs::create_dir_all(&config_dir).expect("create config dir");
1418        fs::write(config_dir.join("config.toml"), contents).expect("write config");
1419    }
1420
1421    fn skill_loader_config_for(workspace: &Path, codex_home: &Path) -> SkillLoaderConfig {
1422        SkillLoaderConfig {
1423            codex_home: codex_home.to_path_buf(),
1424            cwd: workspace.to_path_buf(),
1425            project_root: find_git_root(workspace),
1426            include_bundled_system_skills: false,
1427        }
1428    }
1429
1430    #[test]
1431    fn disabled_skill_config_supports_stable_names() {
1432        let workspace = tempdir().expect("workspace");
1433        fs::create_dir(workspace.path().join(".git")).expect("create .git");
1434
1435        let home = tempdir().expect("home");
1436        let codex_home = tempdir().expect("codex home");
1437
1438        let old_plugin_skill_dir = workspace
1439            .path()
1440            .join(".agents/plugins/example-plugin-v1/skills/release-helper");
1441        write_skill(
1442            &old_plugin_skill_dir,
1443            "release-helper",
1444            "Prepare release notes",
1445        );
1446
1447        write_codex_skill_config(
1448            home.path(),
1449            &format!(
1450                "[[skills.config]]\nname = \"release-helper\"\npath = \"{}\"\nenabled = false\n",
1451                old_plugin_skill_dir.display()
1452            ),
1453        );
1454
1455        let new_plugin_skill_dir = workspace
1456            .path()
1457            .join(".agents/plugins/example-plugin-v2/skills/release-helper");
1458        write_skill(
1459            &new_plugin_skill_dir,
1460            "release-helper",
1461            "Prepare release notes",
1462        );
1463
1464        fs::remove_dir_all(workspace.path().join(".agents/plugins/example-plugin-v1"))
1465            .expect("remove old plugin version");
1466
1467        let outcome = load_skills_with_home_dir(
1468            &skill_loader_config_for(workspace.path(), codex_home.path()),
1469            Some(home.path()),
1470        );
1471
1472        assert!(
1473            outcome
1474                .skills
1475                .iter()
1476                .all(|skill| skill.name != "release-helper"),
1477            "expected release-helper to stay disabled after plugin path changed"
1478        );
1479    }
1480
1481    #[test]
1482    fn disabled_skill_config_preserves_path_based_entries() {
1483        let workspace = tempdir().expect("workspace");
1484        let home = tempdir().expect("home");
1485        let codex_home = tempdir().expect("codex home");
1486
1487        let skill_dir = home.path().join(".agents/skills/path-disabled");
1488        write_skill(&skill_dir, "path-disabled", "Disabled by explicit path");
1489
1490        write_codex_skill_config(
1491            home.path(),
1492            &format!(
1493                "[[skills.config]]\npath = \"{}\"\nenabled = false\n",
1494                skill_dir.join("SKILL.md").display()
1495            ),
1496        );
1497
1498        let outcome = load_skills_with_home_dir(
1499            &skill_loader_config_for(workspace.path(), codex_home.path()),
1500            Some(home.path()),
1501        );
1502
1503        assert!(
1504            outcome
1505                .skills
1506                .iter()
1507                .all(|skill| skill.name != "path-disabled"),
1508            "expected path-disabled to remain filtered by legacy path config"
1509        );
1510    }
1511}