use std::collections::HashMap; use std::collections::HashSet; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_plugins::PluginSkillRoot; use crate::AppConnectorId; use crate::AppDeclaration; use crate::PluginCapabilitySummary; use crate::PluginHookSource; use crate::app_connector_ids_from_declarations; const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024; /// A plugin that was loaded from disk, including merged MCP server definitions. #[derive(Debug, Clone, PartialEq)] pub struct LoadedPlugin { pub config_name: String, pub manifest_name: Option, pub plugin_namespace: Option, pub manifest_description: Option, pub root: AbsolutePathBuf, pub enabled: bool, pub skill_roots: Vec, pub disabled_skill_paths: HashSet, pub has_enabled_skills: bool, pub mcp_servers: HashMap, pub apps: Vec, pub hook_sources: Vec, pub hook_load_warnings: Vec, pub error: Option, } impl LoadedPlugin { pub fn is_active(&self) -> bool { self.enabled && self.error.is_none() } pub fn display_name(&self) -> &str { self.manifest_name.as_deref().unwrap_or(&self.config_name) } } fn plugin_capability_summary_from_loaded( plugin: &LoadedPlugin, ) -> Option { if !plugin.is_active() { return None; } let mut mcp_server_names: Vec = plugin.mcp_servers.keys().cloned().collect(); mcp_server_names.sort_unstable(); let summary = PluginCapabilitySummary { config_name: plugin.config_name.clone(), display_name: plugin.display_name().to_string(), description: prompt_safe_plugin_description(plugin.manifest_description.as_deref()), has_skills: plugin.has_enabled_skills, mcp_server_names, app_connector_ids: app_connector_ids_from_declarations(&plugin.apps), }; (summary.has_skills || !summary.mcp_server_names.is_empty() || !summary.app_connector_ids.is_empty()) .then_some(summary) } /// Normalizes plugin descriptions for inclusion in model-facing capability summaries. pub fn prompt_safe_plugin_description(description: Option<&str>) -> Option { let description = description? .split_whitespace() .collect::>() .join(" "); if description.is_empty() { return None; } Some( description .chars() .take(MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN) .collect(), ) } /// Runtime view of loaded plugins and their derived capability summaries. /// /// Callers must apply any runtime capability policies before constructing this outcome. #[derive(Debug, Clone, PartialEq)] pub struct PluginLoadOutcome { plugins: Vec>, capability_summaries: Vec, } impl Default for PluginLoadOutcome { fn default() -> Self { Self::from_plugins(Vec::new()) } } impl PluginLoadOutcome { pub fn from_plugins(plugins: Vec>) -> Self { let capability_summaries = plugins .iter() .filter_map(plugin_capability_summary_from_loaded) .collect::>(); Self { plugins, capability_summaries, } } pub fn effective_skill_roots(&self) -> Vec { let mut skill_roots: Vec = self .plugins .iter() .filter(|plugin| plugin.is_active()) .flat_map(|plugin| plugin.skill_roots.iter().cloned()) .collect(); skill_roots.sort_unstable(); skill_roots.dedup(); skill_roots } pub fn effective_plugin_skill_roots(&self) -> Vec { let mut skill_roots = Vec::new(); let mut seen_paths = HashSet::new(); for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) { let Some(plugin_namespace) = &plugin.plugin_namespace else { continue; }; for path in &plugin.skill_roots { if seen_paths.insert(path.clone()) { skill_roots.push(PluginSkillRoot { path: path.clone(), plugin_id: plugin.config_name.clone(), plugin_namespace: plugin_namespace.clone(), plugin_root: plugin.root.clone(), }); } } } skill_roots.sort_unstable_by(|a, b| a.path.cmp(&b.path)); skill_roots } /// Returns a runtime view that preserves connector apps, MCP servers, and hooks while hiding /// skills supplied by plugins with app declarations. pub fn without_app_backed_skills(mut self) -> Self { for plugin in &mut self.plugins { if !plugin.apps.is_empty() { plugin.skill_roots.clear(); plugin.has_enabled_skills = false; } } Self::from_plugins(self.plugins) } pub fn effective_mcp_servers(&self) -> HashMap { let mut mcp_servers = HashMap::new(); for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) { for (name, config) in &plugin.mcp_servers { mcp_servers .entry(name.clone()) .or_insert_with(|| config.clone()); } } mcp_servers } pub fn effective_apps(&self) -> Vec { app_connector_ids_from_declarations( self.plugins .iter() .filter(|plugin| plugin.is_active()) .flat_map(|plugin| plugin.apps.iter()), ) } pub fn effective_plugin_hook_sources(&self) -> Vec { self.plugins .iter() .filter(|plugin| plugin.is_active()) .flat_map(|plugin| plugin.hook_sources.iter().cloned()) .collect() } pub fn effective_plugin_hook_warnings(&self) -> Vec { self.plugins .iter() .filter(|plugin| plugin.is_active()) .flat_map(|plugin| plugin.hook_load_warnings.iter().cloned()) .collect() } pub fn capability_summaries(&self) -> &[PluginCapabilitySummary] { &self.capability_summaries } pub fn plugins(&self) -> &[LoadedPlugin] { &self.plugins } } /// Implemented by [`PluginLoadOutcome`] so callers (e.g. skills) can depend on `codex-plugin` /// without naming the MCP config type parameter. pub trait EffectiveSkillRoots { fn effective_skill_roots(&self) -> Vec; fn effective_plugin_skill_roots(&self) -> Vec; } impl EffectiveSkillRoots for PluginLoadOutcome { fn effective_skill_roots(&self) -> Vec { PluginLoadOutcome::effective_skill_roots(self) } fn effective_plugin_skill_roots(&self) -> Vec { PluginLoadOutcome::effective_plugin_skill_roots(self) } } #[cfg(test)] mod tests { use super::*; fn test_path(name: &str) -> AbsolutePathBuf { AbsolutePathBuf::from_absolute_path_checked(std::env::temp_dir().join(name)) .expect("absolute temp path") } fn loaded_plugin(config_name: &str, skill_roots: Vec) -> LoadedPlugin<()> { LoadedPlugin { config_name: config_name.to_string(), manifest_name: None, plugin_namespace: Some( config_name .split_once('@') .map_or(config_name, |(name, _)| name) .to_string(), ), manifest_description: None, root: test_path(config_name), enabled: true, skill_roots, disabled_skill_paths: HashSet::new(), has_enabled_skills: true, mcp_servers: HashMap::new(), apps: Vec::new(), hook_sources: Vec::new(), hook_load_warnings: Vec::new(), error: None, } } #[test] fn effective_plugin_skill_roots_preserves_first_plugin_for_shared_root() { let shared_root = test_path("shared-skills"); let outcome = PluginLoadOutcome::from_plugins(vec![ loaded_plugin("zeta@test", vec![shared_root.clone()]), loaded_plugin("alpha@test", vec![shared_root.clone()]), ]); assert_eq!( outcome.effective_plugin_skill_roots(), vec![PluginSkillRoot { path: shared_root, plugin_id: "zeta@test".to_string(), plugin_namespace: "zeta".to_string(), plugin_root: test_path("zeta@test"), }] ); } #[test] fn without_app_backed_skills_preserves_other_plugin_capabilities() { let connector_skill_root = test_path("connector-skills"); let standalone_skill_root = test_path("standalone-skills"); let mut connector = loaded_plugin("connector@test", vec![connector_skill_root]); connector.apps = vec![AppDeclaration { name: "calendar".to_string(), connector_id: AppConnectorId("connector-calendar".to_string()), category: None, }]; connector.mcp_servers.insert("calendar".to_string(), ()); let standalone = loaded_plugin("standalone@test", vec![standalone_skill_root.clone()]); let outcome = PluginLoadOutcome::from_plugins(vec![connector, standalone]) .without_app_backed_skills(); assert_eq!( outcome.effective_plugin_skill_roots(), vec![PluginSkillRoot { path: standalone_skill_root, plugin_id: "standalone@test".to_string(), plugin_namespace: "standalone".to_string(), plugin_root: test_path("standalone@test"), }] ); assert_eq!( outcome.effective_apps(), vec![AppConnectorId("connector-calendar".to_string())] ); assert_eq!( outcome.effective_mcp_servers(), HashMap::from([("calendar".to_string(), ())]) ); assert_eq!( outcome.capability_summaries(), &[ PluginCapabilitySummary { config_name: "connector@test".to_string(), display_name: "connector@test".to_string(), description: None, has_skills: false, mcp_server_names: vec!["calendar".to_string()], app_connector_ids: vec![AppConnectorId("connector-calendar".to_string())], }, PluginCapabilitySummary { config_name: "standalone@test".to_string(), display_name: "standalone@test".to_string(), description: None, has_skills: true, mcp_server_names: Vec::new(), app_connector_ids: Vec::new(), }, ] ); } }