Skip to main content

vtcode_core/skills/loader/
enhanced.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::Result;
4
5use super::SkillLoaderConfig;
6use super::discovery::{find_git_root, skill_roots_with_home_dir};
7use crate::skills::cli_bridge::CliToolBridge;
8use crate::skills::command_skills::{
9    BuiltInCommandSkill, CommandSkillBackend, built_in_command_skill, find_command_skill_by_skill_name,
10    merge_built_in_command_skill_contexts,
11};
12use crate::skills::container_validation::{
13    ContainerSkillsValidator, ContainerValidationReport, ContainerValidationResult,
14};
15use crate::skills::discovery::{DiscoveryConfig, DiscoveryResult, SkillDiscovery};
16use crate::skills::model::SkillScope;
17use crate::skills::system::{install_system_skills, system_cache_root_dir};
18use crate::skills::types::{Skill, SkillContext};
19use crate::tools::error_messages::skill_ops;
20
21/// Enhanced skill variant for unified handling
22pub enum EnhancedSkill {
23    /// Traditional instruction-based skill
24    Traditional(Box<Skill>),
25    /// CLI-based tool skill
26    CliTool(Box<CliToolBridge>),
27    /// Built-in VT Code command skill
28    BuiltInCommand(Box<BuiltInCommandSkill>),
29    /// Native code plugin skill reserved for explicitly gated integrations
30    NativePlugin(Box<dyn crate::skills::native_plugin::NativePluginTrait>),
31}
32
33impl std::fmt::Debug for EnhancedSkill {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        match self {
36            Self::Traditional(skill) => f.debug_tuple("Traditional").field(skill).finish(),
37            Self::CliTool(tool) => f.debug_tuple("CliTool").field(tool).finish(),
38            Self::BuiltInCommand(skill) => f.debug_tuple("BuiltInCommand").field(skill).finish(),
39            Self::NativePlugin(plugin) => f.debug_tuple("NativePlugin").field(plugin).finish(),
40        }
41    }
42}
43
44/// High-level loader that provides discovery and validation features
45pub struct EnhancedSkillLoader {
46    workspace_root: PathBuf,
47    codex_home: PathBuf,
48    discovery: SkillDiscovery,
49}
50
51fn default_codex_home() -> PathBuf {
52    std::env::var_os("CODEX_HOME")
53        .filter(|value| !value.is_empty())
54        .map(PathBuf::from)
55        .or_else(|| dirs::home_dir().map(|home| home.join(".codex")))
56        .unwrap_or_else(|| PathBuf::from(".codex"))
57}
58
59pub(crate) fn discovery_config_for_codex_home(workspace_root: &Path, codex_home: &Path) -> DiscoveryConfig {
60    let home_dir = dirs::home_dir();
61    let loader_config = SkillLoaderConfig {
62        codex_home: codex_home.to_path_buf(),
63        cwd: workspace_root.to_path_buf(),
64        project_root: find_git_root(workspace_root),
65        include_bundled_system_skills: true,
66    };
67    let roots = skill_roots_with_home_dir(&loader_config, home_dir.as_deref());
68
69    DiscoveryConfig {
70        skill_paths: roots
71            .iter()
72            .filter(|root| !root.is_tool_root && !root.is_plugin_root)
73            .map(|root| root.path.clone())
74            .collect(),
75        tool_paths: roots
76            .iter()
77            .filter(|root| root.is_tool_root)
78            .map(|root| root.path.clone())
79            .collect(),
80        ..Default::default()
81    }
82}
83
84impl EnhancedSkillLoader {
85    /// Create a new enhanced loader for workspace
86    pub fn new(workspace_root: PathBuf) -> Self {
87        let codex_home = default_codex_home();
88        let discovery = SkillDiscovery::with_config(discovery_config_for_codex_home(&workspace_root, &codex_home));
89        Self { workspace_root, codex_home, discovery }
90    }
91
92    /// Create a loader pinned to a specific VT Code home directory.
93    pub fn with_codex_home(workspace_root: PathBuf, codex_home: PathBuf) -> Self {
94        let discovery = SkillDiscovery::with_config(discovery_config_for_codex_home(&workspace_root, &codex_home));
95        Self { workspace_root, codex_home, discovery }
96    }
97
98    fn ensure_system_skills_installed(&self) {
99        if let Err(err) = install_system_skills(&self.codex_home) {
100            tracing::warn!("enhanced skill loader failed to install bundled system skills: {err}");
101        }
102    }
103
104    /// Discover all available skills and tools
105    pub async fn discover_all_skills(&mut self) -> Result<DiscoveryResult> {
106        self.ensure_system_skills_installed();
107        let mut result = self.discovery.discover_all(&self.workspace_root).await?;
108        merge_built_in_command_skill_contexts(&mut result.skills);
109        Ok(result)
110    }
111
112    /// Get a specific skill by name
113    pub async fn get_skill(&mut self, name: &str) -> Result<EnhancedSkill> {
114        self.ensure_system_skills_installed();
115        let result = self.discovery.discover_all(&self.workspace_root).await?;
116
117        // Try traditional skills first. Command skills (`cmd-*` with a bundled
118        // `.system` contract) prefer the System copy so a stale workspace
119        // shadow cannot break `/review` free-form + fix-mode behavior.
120        if let Some(skill_ctx) = select_traditional_skill_ctx(&result.skills, name, &self.workspace_root) {
121            let path = skill_ctx.path();
122            let (manifest, instructions) = crate::skills::manifest::parse_skill_file(path)?;
123            let skill = Skill::with_scope(
124                manifest,
125                path.clone(),
126                infer_scope_from_skill_path(path, &self.workspace_root),
127                instructions,
128            )?;
129            return Ok(EnhancedSkill::Traditional(Box::new(skill)));
130        }
131
132        // Try CLI tools
133        for tool_config in &result.tools {
134            if tool_config.name == name {
135                let bridge = CliToolBridge::new(tool_config.clone())?;
136                return Ok(EnhancedSkill::CliTool(Box::new(bridge)));
137            }
138        }
139
140        if let Some(skill) = built_in_command_skill(name) {
141            return Ok(EnhancedSkill::BuiltInCommand(Box::new(skill)));
142        }
143
144        // Native plugins are intentionally not loaded by the generic skill
145        // lookup. Opening a dynamic library executes arbitrary native code
146        // before this loader can inspect its metadata, so native loading must
147        // remain an explicit, separately-gated operation.
148        Err(skill_ops::skill_not_found_error(name))
149    }
150
151    /// Generate a comprehensive container validation report
152    pub async fn generate_validation_report(&mut self) -> Result<ContainerValidationReport> {
153        let result = self.discovery.discover_all(&self.workspace_root).await?;
154        let mut report = ContainerValidationReport::new();
155        let validator = ContainerSkillsValidator::new();
156
157        for skill_ctx in &result.skills {
158            match self.load_full_skill_from_ctx(skill_ctx) {
159                Ok(skill) => {
160                    let analysis = validator.analyze_skill(&skill);
161                    report.add_skill_analysis(skill.name().to_string(), analysis);
162                }
163                Err(e) => {
164                    report.add_incompatible_skill(
165                        skill_ctx.manifest().name.clone(),
166                        skill_ctx.manifest().description.clone(),
167                        format!("Load error: {e}"),
168                    );
169                }
170            }
171        }
172
173        report.finalize();
174        Ok(report)
175    }
176
177    /// Check container requirements for a skill
178    pub fn check_container_requirements(&self, skill: &Skill) -> ContainerValidationResult {
179        let validator = ContainerSkillsValidator::new();
180        validator.analyze_skill(skill)
181    }
182
183    fn load_full_skill_from_ctx(&self, ctx: &SkillContext) -> Result<Skill> {
184        let path = ctx.path();
185        let (manifest, instructions) = crate::skills::manifest::parse_skill_file(path)?;
186        Skill::with_scope(manifest, path.clone(), infer_scope_from_skill_path(path, &self.workspace_root), instructions)
187    }
188}
189
190fn infer_scope_from_skill_path(path: &Path, workspace_root: &Path) -> SkillScope {
191    if path.starts_with(Path::new("/etc/codex/skills")) {
192        return SkillScope::Admin;
193    }
194    if path.starts_with(system_cache_root_dir(&default_codex_home())) {
195        return SkillScope::System;
196    }
197    if let Some(home) = dirs::home_dir()
198        && path.starts_with(home.join(".agents/skills"))
199    {
200        return SkillScope::User;
201    }
202    if path.starts_with(workspace_root) {
203        return SkillScope::Repo;
204    }
205
206    // Fallback for non-canonicalized paths: walk the components looking for a
207    // `.agents/skills` ancestor. Uses proper component matching rather than
208    // string-contains so a directory named `skills-backup` cannot trigger a
209    // false positive.
210    if has_agents_skills_ancestor(path) {
211        return SkillScope::Repo;
212    }
213
214    SkillScope::User
215}
216
217fn has_agents_skills_ancestor(path: &Path) -> bool {
218    let mut ancestors = path.parent().map(Path::new).into_iter().peekable();
219    while let Some(current) = ancestors.next() {
220        let is_skills = current.file_name().and_then(|f| f.to_str()) == Some("skills");
221        let parent_is_agents = ancestors.peek().and_then(|p| p.file_name().and_then(|f| f.to_str())) == Some(".agents");
222        if is_skills && parent_is_agents {
223            return true;
224        }
225    }
226    false
227}
228
229fn is_bundled_command_skill(name: &str) -> bool {
230    find_command_skill_by_skill_name(name)
231        .is_some_and(|spec| matches!(spec.backend, CommandSkillBackend::TraditionalSkill { .. }))
232}
233
234fn is_system_skill_path(path: &Path) -> bool {
235    let mut components = path.components().peekable();
236    while let Some(component) = components.next() {
237        let is_skills = component.as_os_str() == "skills";
238        let next_is_system = components.peek().is_some_and(|next| next.as_os_str() == ".system");
239        if is_skills && next_is_system {
240            return true;
241        }
242    }
243    false
244}
245
246pub(crate) fn select_traditional_skill_ctx<'a>(
247    skills: &'a [SkillContext],
248    name: &str,
249    workspace_root: &Path,
250) -> Option<&'a SkillContext> {
251    let mut first_match: Option<&'a SkillContext> = None;
252    let mut system_match: Option<&'a SkillContext> = None;
253    for skill_ctx in skills {
254        if skill_ctx.manifest().name != name {
255            continue;
256        }
257        if first_match.is_none() {
258            first_match = Some(skill_ctx);
259        }
260        // Shape-based system detection covers custom `CODEX_HOME` values where
261        // `infer_scope_from_skill_path` falls back to `User`. Exclude `Repo`
262        // scope so a workspace-nested `skills/.system` path cannot spoof it.
263        let scope = infer_scope_from_skill_path(skill_ctx.path(), workspace_root);
264        if system_match.is_none()
265            && (scope == SkillScope::System || (scope != SkillScope::Repo && is_system_skill_path(skill_ctx.path())))
266        {
267            system_match = Some(skill_ctx);
268        }
269        if first_match.is_some() && system_match.is_some() {
270            break;
271        }
272    }
273    if is_bundled_command_skill(name) {
274        system_match.or(first_match)
275    } else {
276        first_match
277    }
278}