use std::collections::HashMap; use std::collections::HashSet; use std::sync::Arc; use std::sync::RwLock; use codex_config::ConfigLayerStack; use codex_exec_server::ExecutorFileSystem; use codex_protocol::protocol::Product; use codex_protocol::protocol::SkillScope; use codex_utils_absolute_path::AbsolutePathBuf; use tracing::info; use tracing::warn; use crate::SkillLoadOutcome; use crate::build_implicit_skill_path_indexes; use crate::config_rules::SkillConfigRules; use crate::config_rules::resolve_disabled_skill_paths; use crate::config_rules::skill_config_rules_from_stack; use crate::loader::SkillRoot; use crate::loader::load_skills_from_roots; use crate::loader::skill_roots; use crate::system::install_system_skills; use crate::system::uninstall_system_skills; use codex_config::SkillsConfig; #[derive(Debug, Clone)] pub struct SkillsLoadInput { pub cwd: AbsolutePathBuf, pub effective_skill_roots: Vec, pub config_layer_stack: ConfigLayerStack, pub bundled_skills_enabled: bool, } impl SkillsLoadInput { pub fn new( cwd: AbsolutePathBuf, effective_skill_roots: Vec, config_layer_stack: ConfigLayerStack, bundled_skills_enabled: bool, ) -> Self { Self { cwd, effective_skill_roots, config_layer_stack, bundled_skills_enabled, } } } pub struct SkillsManager { codex_home: AbsolutePathBuf, restriction_product: Option, cache_by_cwd: RwLock>, cache_by_config: RwLock>, } impl SkillsManager { pub fn new(codex_home: AbsolutePathBuf, bundled_skills_enabled: bool) -> Self { Self::new_with_restriction_product(codex_home, bundled_skills_enabled, Some(Product::Codex)) } pub fn new_with_restriction_product( codex_home: AbsolutePathBuf, bundled_skills_enabled: bool, restriction_product: Option, ) -> Self { let manager = Self { codex_home, restriction_product, cache_by_cwd: RwLock::new(HashMap::new()), cache_by_config: RwLock::new(HashMap::new()), }; if !bundled_skills_enabled { // The loader caches bundled skills under `skills/.system`. Clearing that directory is // best-effort cleanup; root selection still enforces the config even if removal fails. uninstall_system_skills(&manager.codex_home); } else if let Err(err) = install_system_skills(&manager.codex_home) { tracing::error!("failed to install system skills: {err}"); } manager } /// Load skills for an already-constructed [`Config`], avoiding any additional config-layer /// loading. /// /// This path uses a cache keyed by the effective skill-relevant config state rather than just /// cwd so role-local and session-local skill overrides cannot bleed across sessions that happen /// to share a directory. pub async fn skills_for_config( &self, input: &SkillsLoadInput, fs: Option>, ) -> SkillLoadOutcome { let roots = self.skill_roots_for_config(input, fs).await; let skill_config_rules = skill_config_rules_from_stack(&input.config_layer_stack); let cache_key = config_skills_cache_key(&roots, &skill_config_rules); if let Some(outcome) = self.cached_outcome_for_config(&cache_key) { return outcome; } let outcome = self.build_skill_outcome(roots, &skill_config_rules).await; let mut cache = self .cache_by_config .write() .unwrap_or_else(std::sync::PoisonError::into_inner); cache.insert(cache_key, outcome.clone()); outcome } pub async fn skill_roots_for_config( &self, input: &SkillsLoadInput, fs: Option>, ) -> Vec { let mut roots = skill_roots( fs, &input.config_layer_stack, &input.cwd, input.effective_skill_roots.clone(), ) .await; if !input.bundled_skills_enabled { roots.retain(|root| root.scope != SkillScope::System); } roots } pub async fn skills_for_cwd( &self, input: &SkillsLoadInput, force_reload: bool, fs: Option>, ) -> SkillLoadOutcome { self.skills_for_cwd_with_extra_user_roots(input, force_reload, &[], fs) .await } pub async fn skills_for_cwd_with_extra_user_roots( &self, input: &SkillsLoadInput, force_reload: bool, extra_user_roots: &[AbsolutePathBuf], fs: Option>, ) -> SkillLoadOutcome { let use_cwd_cache = fs.is_some(); if use_cwd_cache && !force_reload && let Some(outcome) = self.cached_outcome_for_cwd(&input.cwd) { return outcome; } let mut roots = skill_roots( fs.clone(), &input.config_layer_stack, &input.cwd, input.effective_skill_roots.clone(), ) .await; if !bundled_skills_enabled_from_stack(&input.config_layer_stack) { roots.retain(|root| root.scope != SkillScope::System); } if let Some(fs) = fs { roots.extend( normalize_extra_user_roots(extra_user_roots) .into_iter() .map(|path| SkillRoot { path, scope: SkillScope::User, file_system: Arc::clone(&fs), }), ); } let skill_config_rules = skill_config_rules_from_stack(&input.config_layer_stack); let outcome = self.build_skill_outcome(roots, &skill_config_rules).await; if use_cwd_cache { let mut cache = self .cache_by_cwd .write() .unwrap_or_else(std::sync::PoisonError::into_inner); cache.insert(input.cwd.clone(), outcome.clone()); } outcome } async fn build_skill_outcome( &self, roots: Vec, skill_config_rules: &SkillConfigRules, ) -> SkillLoadOutcome { let outcome = crate::filter_skill_load_outcome_for_product( load_skills_from_roots(roots).await, self.restriction_product, ); let disabled_paths = resolve_disabled_skill_paths(&outcome.skills, skill_config_rules); finalize_skill_outcome(outcome, disabled_paths) } pub fn clear_cache(&self) { let cleared_cwd = { let mut cache = self .cache_by_cwd .write() .unwrap_or_else(std::sync::PoisonError::into_inner); let cleared = cache.len(); cache.clear(); cleared }; let cleared_config = { let mut cache = self .cache_by_config .write() .unwrap_or_else(std::sync::PoisonError::into_inner); let cleared = cache.len(); cache.clear(); cleared }; let cleared = cleared_cwd + cleared_config; info!("skills cache cleared ({cleared} entries)"); } fn cached_outcome_for_cwd(&self, cwd: &AbsolutePathBuf) -> Option { match self.cache_by_cwd.read() { Ok(cache) => cache.get(cwd).cloned(), Err(err) => err.into_inner().get(cwd).cloned(), } } fn cached_outcome_for_config( &self, cache_key: &ConfigSkillsCacheKey, ) -> Option { match self.cache_by_config.read() { Ok(cache) => cache.get(cache_key).cloned(), Err(err) => err.into_inner().get(cache_key).cloned(), } } } #[derive(Debug, Clone, PartialEq, Eq, Hash)] struct ConfigSkillsCacheKey { roots: Vec<(AbsolutePathBuf, u8)>, skill_config_rules: SkillConfigRules, } pub fn bundled_skills_enabled_from_stack( config_layer_stack: &codex_config::ConfigLayerStack, ) -> bool { let effective_config = config_layer_stack.effective_config(); let Some(skills_value) = effective_config .as_table() .and_then(|table| table.get("skills")) else { return true; }; let skills: SkillsConfig = match skills_value.clone().try_into() { Ok(skills) => skills, Err(err) => { warn!("invalid skills config: {err}"); return true; } }; skills.bundled.unwrap_or_default().enabled } fn config_skills_cache_key( roots: &[SkillRoot], skill_config_rules: &SkillConfigRules, ) -> ConfigSkillsCacheKey { ConfigSkillsCacheKey { roots: roots .iter() .map(|root| { let scope_rank = match root.scope { SkillScope::Repo => 0, SkillScope::User => 1, SkillScope::System => 2, SkillScope::Admin => 3, }; (root.path.clone(), scope_rank) }) .collect(), skill_config_rules: skill_config_rules.clone(), } } fn finalize_skill_outcome( mut outcome: SkillLoadOutcome, disabled_paths: HashSet, ) -> SkillLoadOutcome { outcome.disabled_paths = disabled_paths; let (by_scripts_dir, by_doc_path) = build_implicit_skill_path_indexes(outcome.allowed_skills_for_implicit_invocation()); outcome.implicit_skills_by_scripts_dir = Arc::new(by_scripts_dir); outcome.implicit_skills_by_doc_path = Arc::new(by_doc_path); outcome } fn normalize_extra_user_roots(extra_user_roots: &[AbsolutePathBuf]) -> Vec { let mut normalized: Vec = extra_user_roots .iter() .map(|root| root.canonicalize().unwrap_or_else(|_| root.clone())) .collect(); normalized.sort_unstable(); normalized.dedup(); normalized } #[cfg(test)] #[path = "manager_tests.rs"] mod tests;