codex_plugin/
load_outcome.rs1use 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#[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
69pub 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#[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
195pub 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}