Skip to main content

codex_plugin/
load_outcome.rs

1use std::collections::HashMap;
2use std::collections::HashSet;
3
4use codex_utils_absolute_path::AbsolutePathBuf;
5use codex_utils_plugins::PluginSkillRoot;
6
7use crate::AppConnectorId;
8use crate::AppDeclaration;
9use crate::PluginCapabilitySummary;
10use crate::PluginHookSource;
11use crate::app_connector_ids_from_declarations;
12
13const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024;
14
15/// A plugin that was loaded from disk, including merged MCP server definitions.
16#[derive(Debug, Clone, PartialEq)]
17pub struct LoadedPlugin<M> {
18    pub config_name: String,
19    pub manifest_name: Option<String>,
20    pub plugin_namespace: Option<String>,
21    pub manifest_description: Option<String>,
22    pub root: AbsolutePathBuf,
23    pub enabled: bool,
24    pub skill_roots: Vec<AbsolutePathBuf>,
25    pub disabled_skill_paths: HashSet<AbsolutePathBuf>,
26    pub has_enabled_skills: bool,
27    pub mcp_servers: HashMap<String, M>,
28    pub apps: Vec<AppDeclaration>,
29    pub hook_sources: Vec<PluginHookSource>,
30    pub hook_load_warnings: Vec<String>,
31    pub error: Option<String>,
32}
33
34impl<M> LoadedPlugin<M> {
35    pub fn is_active(&self) -> bool {
36        self.enabled && self.error.is_none()
37    }
38
39    pub fn display_name(&self) -> &str {
40        self.manifest_name.as_deref().unwrap_or(&self.config_name)
41    }
42}
43
44fn plugin_capability_summary_from_loaded<M>(
45    plugin: &LoadedPlugin<M>,
46) -> Option<PluginCapabilitySummary> {
47    if !plugin.is_active() {
48        return None;
49    }
50
51    let mut mcp_server_names: Vec<String> = plugin.mcp_servers.keys().cloned().collect();
52    mcp_server_names.sort_unstable();
53
54    let summary = PluginCapabilitySummary {
55        config_name: plugin.config_name.clone(),
56        display_name: plugin.display_name().to_string(),
57        description: prompt_safe_plugin_description(plugin.manifest_description.as_deref()),
58        has_skills: plugin.has_enabled_skills,
59        mcp_server_names,
60        app_connector_ids: app_connector_ids_from_declarations(&plugin.apps),
61    };
62
63    (summary.has_skills
64        || !summary.mcp_server_names.is_empty()
65        || !summary.app_connector_ids.is_empty())
66    .then_some(summary)
67}
68
69/// Normalizes plugin descriptions for inclusion in model-facing capability summaries.
70pub fn prompt_safe_plugin_description(description: Option<&str>) -> Option<String> {
71    let description = description?
72        .split_whitespace()
73        .collect::<Vec<_>>()
74        .join(" ");
75    if description.is_empty() {
76        return None;
77    }
78
79    Some(
80        description
81            .chars()
82            .take(MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN)
83            .collect(),
84    )
85}
86
87/// Runtime view of loaded plugins and their derived capability summaries.
88///
89/// Callers must apply any runtime capability policies before constructing this outcome.
90#[derive(Debug, Clone, PartialEq)]
91pub struct PluginLoadOutcome<M> {
92    plugins: Vec<LoadedPlugin<M>>,
93    capability_summaries: Vec<PluginCapabilitySummary>,
94}
95
96impl<M: Clone> Default for PluginLoadOutcome<M> {
97    fn default() -> Self {
98        Self::from_plugins(Vec::new())
99    }
100}
101
102impl<M: Clone> PluginLoadOutcome<M> {
103    pub fn from_plugins(plugins: Vec<LoadedPlugin<M>>) -> Self {
104        let capability_summaries = plugins
105            .iter()
106            .filter_map(plugin_capability_summary_from_loaded)
107            .collect::<Vec<_>>();
108        Self {
109            plugins,
110            capability_summaries,
111        }
112    }
113
114    pub fn effective_skill_roots(&self) -> Vec<AbsolutePathBuf> {
115        let mut skill_roots: Vec<AbsolutePathBuf> = self
116            .plugins
117            .iter()
118            .filter(|plugin| plugin.is_active())
119            .flat_map(|plugin| plugin.skill_roots.iter().cloned())
120            .collect();
121        skill_roots.sort_unstable();
122        skill_roots.dedup();
123        skill_roots
124    }
125
126    pub fn effective_plugin_skill_roots(&self) -> Vec<PluginSkillRoot> {
127        let mut skill_roots = Vec::new();
128        let mut seen_paths = HashSet::new();
129        for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) {
130            let Some(plugin_namespace) = &plugin.plugin_namespace else {
131                continue;
132            };
133            for path in &plugin.skill_roots {
134                if seen_paths.insert(path.clone()) {
135                    skill_roots.push(PluginSkillRoot {
136                        path: path.clone(),
137                        plugin_id: plugin.config_name.clone(),
138                        plugin_namespace: plugin_namespace.clone(),
139                        plugin_root: plugin.root.clone(),
140                    });
141                }
142            }
143        }
144
145        skill_roots.sort_unstable_by(|a, b| a.path.cmp(&b.path));
146        skill_roots
147    }
148
149    pub fn effective_mcp_servers(&self) -> HashMap<String, M> {
150        let mut mcp_servers = HashMap::new();
151        for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) {
152            for (name, config) in &plugin.mcp_servers {
153                mcp_servers
154                    .entry(name.clone())
155                    .or_insert_with(|| config.clone());
156            }
157        }
158        mcp_servers
159    }
160
161    pub fn effective_apps(&self) -> Vec<AppConnectorId> {
162        app_connector_ids_from_declarations(
163            self.plugins
164                .iter()
165                .filter(|plugin| plugin.is_active())
166                .flat_map(|plugin| plugin.apps.iter()),
167        )
168    }
169
170    pub fn effective_plugin_hook_sources(&self) -> Vec<PluginHookSource> {
171        self.plugins
172            .iter()
173            .filter(|plugin| plugin.is_active())
174            .flat_map(|plugin| plugin.hook_sources.iter().cloned())
175            .collect()
176    }
177
178    pub fn effective_plugin_hook_warnings(&self) -> Vec<String> {
179        self.plugins
180            .iter()
181            .filter(|plugin| plugin.is_active())
182            .flat_map(|plugin| plugin.hook_load_warnings.iter().cloned())
183            .collect()
184    }
185
186    pub fn capability_summaries(&self) -> &[PluginCapabilitySummary] {
187        &self.capability_summaries
188    }
189
190    pub fn plugins(&self) -> &[LoadedPlugin<M>] {
191        &self.plugins
192    }
193}
194
195/// Implemented by [`PluginLoadOutcome`] so callers (e.g. skills) can depend on `codex-plugin`
196/// without naming the MCP config type parameter.
197pub trait EffectiveSkillRoots {
198    fn effective_skill_roots(&self) -> Vec<AbsolutePathBuf>;
199
200    fn effective_plugin_skill_roots(&self) -> Vec<PluginSkillRoot>;
201}
202
203impl<M: Clone> EffectiveSkillRoots for PluginLoadOutcome<M> {
204    fn effective_skill_roots(&self) -> Vec<AbsolutePathBuf> {
205        PluginLoadOutcome::effective_skill_roots(self)
206    }
207
208    fn effective_plugin_skill_roots(&self) -> Vec<PluginSkillRoot> {
209        PluginLoadOutcome::effective_plugin_skill_roots(self)
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    fn test_path(name: &str) -> AbsolutePathBuf {
218        AbsolutePathBuf::from_absolute_path_checked(std::env::temp_dir().join(name))
219            .expect("absolute temp path")
220    }
221
222    fn loaded_plugin(config_name: &str, skill_roots: Vec<AbsolutePathBuf>) -> LoadedPlugin<()> {
223        LoadedPlugin {
224            config_name: config_name.to_string(),
225            manifest_name: None,
226            plugin_namespace: Some(
227                config_name
228                    .split_once('@')
229                    .map_or(config_name, |(name, _)| name)
230                    .to_string(),
231            ),
232            manifest_description: None,
233            root: test_path(config_name),
234            enabled: true,
235            skill_roots,
236            disabled_skill_paths: HashSet::new(),
237            has_enabled_skills: true,
238            mcp_servers: HashMap::new(),
239            apps: Vec::new(),
240            hook_sources: Vec::new(),
241            hook_load_warnings: Vec::new(),
242            error: None,
243        }
244    }
245
246    #[test]
247    fn effective_plugin_skill_roots_preserves_first_plugin_for_shared_root() {
248        let shared_root = test_path("shared-skills");
249        let outcome = PluginLoadOutcome::from_plugins(vec![
250            loaded_plugin("zeta@test", vec![shared_root.clone()]),
251            loaded_plugin("alpha@test", vec![shared_root.clone()]),
252        ]);
253
254        assert_eq!(
255            outcome.effective_plugin_skill_roots(),
256            vec![PluginSkillRoot {
257                path: shared_root,
258                plugin_id: "zeta@test".to_string(),
259                plugin_namespace: "zeta".to_string(),
260                plugin_root: test_path("zeta@test"),
261            }]
262        );
263    }
264}