Skip to main content

vtcode_core/plugins/
manager.rs

1//! Plugin manager for VT Code
2//!
3//! Coordinates all plugin system components and provides a unified interface
4//! for plugin management.
5
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8
9use anyhow::{Context, Result};
10use tokio::sync::RwLock;
11use tracing::{debug, info, warn};
12use vtcode_commons::MultiErrors;
13
14use super::{PluginCache, PluginLoader, PluginManifest, PluginResult, PluginRuntime};
15use crate::config::PluginRuntimeConfig;
16
17/// Main plugin manager that coordinates all plugin system components
18pub struct PluginManager {
19    /// Plugin runtime for managing loaded plugins
20    runtime: Arc<PluginRuntime>,
21    /// Plugin loader for installing/uninstalling plugins
22    loader: Arc<PluginLoader>,
23    /// Plugin cache for security and verification
24    cache: Arc<RwLock<PluginCache>>,
25    /// Worker state for non-curated plugin cache refresh
26    refresh_worker: Arc<RwLock<RefreshWorkerState>>,
27}
28
29/// State tracking for the non-curated plugin cache refresh worker.
30/// Prevents race conditions when marking refresh workers as idle.
31#[derive(Debug, Default)]
32struct RefreshWorkerState {
33    /// Whether the refresh worker is currently active
34    is_idle: bool,
35}
36
37impl PluginManager {
38    /// Create a new plugin manager
39    pub fn new(config: PluginRuntimeConfig, base_dir: PathBuf) -> Result<Self> {
40        let runtime = Arc::new(PluginRuntime::new(config.clone(), base_dir.join("runtime")));
41        let cache = Arc::new(RwLock::new(PluginCache::new(base_dir.join("cache"))));
42
43        let loader = Arc::new(PluginLoader::new(base_dir.join("installed"), runtime.as_ref().clone()));
44
45        Ok(Self {
46            runtime,
47            loader,
48            cache,
49            refresh_worker: Arc::new(RwLock::new(RefreshWorkerState::default())),
50        })
51    }
52
53    /// Install a plugin from a source
54    pub async fn install_plugin(&self, source: super::loader::PluginSource, name: Option<String>) -> PluginResult<()> {
55        // Install the plugin using the loader
56        self.loader.install_plugin(source, name).await?;
57        Ok(())
58    }
59
60    /// Uninstall a plugin
61    pub async fn uninstall_plugin(&self, plugin_name: &str) -> PluginResult<()> {
62        // Uninstall the plugin using the loader
63        self.loader.uninstall_plugin(plugin_name).await?;
64        Ok(())
65    }
66
67    /// Enable a plugin
68    pub async fn enable_plugin(&self, plugin_name: &str) -> PluginResult<()> {
69        self.runtime.enable_plugin(plugin_name).await?;
70        Ok(())
71    }
72
73    /// Disable a plugin
74    pub async fn disable_plugin(&self, plugin_name: &str) -> PluginResult<()> {
75        self.runtime.disable_plugin(plugin_name).await?;
76        Ok(())
77    }
78
79    /// Load a plugin
80    pub async fn load_plugin(&self, plugin_path: &Path) -> PluginResult<()> {
81        self.runtime.load_plugin(plugin_path).await?;
82        Ok(())
83    }
84
85    /// Get information about a plugin
86    pub async fn get_plugin(&self, plugin_id: &str) -> PluginResult<super::runtime::PluginHandle> {
87        self.runtime.get_plugin(plugin_id).await
88    }
89
90    /// List all installed plugins
91    pub async fn list_installed_plugins(&self) -> PluginResult<Vec<String>> {
92        self.loader.list_installed_plugins().await
93    }
94
95    /// List all loaded plugins
96    pub async fn list_loaded_plugins(&self) -> Vec<super::runtime::PluginHandle> {
97        self.runtime.list_plugins().await
98    }
99
100    /// Process all components for a plugin
101    pub async fn process_plugin_components(
102        &self,
103        plugin_path: &Path,
104        manifest: &PluginManifest,
105    ) -> Result<super::components::PluginComponents> {
106        super::components::PluginComponentsHandler::process_all_components(plugin_path, manifest).await
107    }
108
109    /// Check if a plugin is enabled
110    pub async fn is_plugin_enabled(&self, plugin_id: &str) -> bool {
111        self.runtime.is_plugin_enabled(plugin_id).await
112    }
113
114    /// Cache a plugin for security
115    pub async fn cache_plugin(&self, plugin_id: &str, source_path: &Path) -> PluginResult<PathBuf> {
116        let mut cache = self.cache.write().await;
117        cache.cache_plugin(plugin_id, source_path).await
118    }
119
120    /// Get cached plugin path
121    pub async fn get_cached_plugin(&self, plugin_id: &str) -> Option<PathBuf> {
122        let cache = self.cache.read().await;
123        cache.get_cached_plugin(plugin_id).cloned()
124    }
125
126    /// Refresh the non-curated plugin cache using dynamic roots (workspace directories).
127    ///
128    /// This method is triggered from `plugin/list` commands rather than at startup,
129    /// allowing it to access the current working directories (roots/cwds) required
130    /// to locate `marketplace.json` files that are not persisted in the config.
131    ///
132    /// Uses version information from `plugin.json` to determine if a refresh is needed.
133    /// The refresh worker state prevents race conditions during concurrent refreshes.
134    ///
135    /// # Errors
136    ///
137    /// Returns an error if the refresh worker lock is poisoned or if root scanning fails.
138    pub async fn refresh_non_curated_plugin_cache(&self, roots: &[PathBuf]) -> Result<RefreshResult> {
139        // Prevent concurrent refreshes
140        {
141            let worker = self.refresh_worker.read().await;
142            if !worker.is_idle {
143                debug!("non-curated plugin cache refresh already in progress, skipping");
144                return Ok(RefreshResult::SkippedAlreadyInProgress);
145            }
146        }
147
148        // Mark busy before dropping read lock
149        {
150            let mut worker = self.refresh_worker.write().await;
151            if !worker.is_idle {
152                return Ok(RefreshResult::SkippedAlreadyInProgress);
153            }
154            worker.is_idle = false;
155        }
156
157        let result = self.refresh_non_curated_from_roots_impl(roots).await;
158
159        let mut worker = self.refresh_worker.write().await;
160        worker.is_idle = true;
161
162        result
163    }
164
165    /// Internal implementation for refreshing non-curated plugins from workspace roots.
166    async fn refresh_non_curated_from_roots_impl(&self, roots: &[PathBuf]) -> Result<RefreshResult> {
167        if roots.is_empty() {
168            debug!("no workspace roots provided for non-curated plugin cache refresh");
169            return Ok(RefreshResult::NoRootsProvided);
170        }
171
172        let mut refreshed_count = 0usize;
173        let mut errors: MultiErrors<String> = MultiErrors::new();
174
175        for root in roots {
176            if !tokio::fs::try_exists(root).await.unwrap_or(false) {
177                debug!("workspace root does not exist, skipping: {}", root.display());
178                continue;
179            }
180
181            match self.scan_root_for_plugins(root).await {
182                Ok(plugins) => {
183                    for plugin_info in &plugins {
184                        // Use version from plugin.json to determine if update is needed
185                        if let Some(existing) = self.get_cached_plugin(&plugin_info.name).await
186                            && tokio::fs::try_exists(&existing).await.unwrap_or(false)
187                            && plugin_info.version_matches_existing(&existing).await
188                        {
189                            debug!("plugin '{}' version unchanged, skipping cache update", plugin_info.name);
190                            continue;
191                        }
192
193                        if let Err(e) = self.cache_plugin(&plugin_info.name, &plugin_info.path).await {
194                            errors.push(format!("failed to cache plugin '{}': {e}", plugin_info.name));
195                        } else {
196                            refreshed_count += 1;
197                            info!("cached non-curated plugin: {}", plugin_info.name);
198                        }
199                    }
200                }
201                Err(e) => {
202                    errors.push(format!("failed to scan root {}: {e}", root.display()));
203                }
204            }
205        }
206
207        Ok(RefreshResult::Success { refreshed_count, errors })
208    }
209
210    /// Scan a workspace root for non-curated plugins (marketplace.json files).
211    async fn scan_root_for_plugins(&self, root: &Path) -> Result<Vec<DiscoveredPluginInfo>> {
212        let mut discovered = Vec::new();
213
214        // Look for plugins in .vtcode/plugins/ or similar locations
215        let plugin_roots = vec![root.join(".vtcode").join("plugins"), root.join("plugins")];
216
217        for plugin_root in plugin_roots {
218            if !tokio::fs::try_exists(&plugin_root).await.unwrap_or(false) {
219                continue;
220            }
221
222            // Read each subdirectory as a potential plugin
223            let entries = match tokio::fs::read_dir(&plugin_root).await {
224                Ok(entries) => entries,
225                Err(e) => {
226                    warn!("Failed to read plugin root {}: {}", plugin_root.display(), e);
227                    continue;
228                }
229            };
230
231            // Collect entries first to avoid borrow issues
232            let mut dirs = Vec::new();
233            let mut entries = entries;
234            while let Ok(Some(entry)) = entries.next_entry().await {
235                if entry.file_type().await.is_ok_and(|ft| ft.is_dir()) {
236                    dirs.push(entry.path());
237                }
238            }
239
240            for plugin_dir in dirs {
241                let manifest_path = plugin_dir.join(".vtcode-plugin").join("plugin.json");
242
243                if !tokio::fs::try_exists(&manifest_path).await.unwrap_or(false) {
244                    continue;
245                }
246
247                match self.load_plugin_manifest(&manifest_path).await {
248                    Ok(info) => discovered.push(info),
249                    Err(e) => {
250                        warn!("Failed to load plugin manifest from {}: {}", manifest_path.display(), e);
251                    }
252                }
253            }
254        }
255
256        Ok(discovered)
257    }
258
259    /// Load a plugin manifest from a marketplace.json or plugin.json path.
260    async fn load_plugin_manifest(&self, manifest_path: &Path) -> Result<DiscoveredPluginInfo> {
261        let content = tokio::fs::read_to_string(manifest_path)
262            .await
263            .with_context(|| format!("failed to read plugin manifest at {}", manifest_path.display()))?;
264        let manifest: PluginManifest = serde_json::from_str(&content)
265            .with_context(|| format!("failed to parse plugin manifest at {}", manifest_path.display()))?;
266
267        Ok(DiscoveredPluginInfo {
268            name: manifest.name.clone(),
269            version: manifest.version.clone(),
270            path: manifest_path
271                .parent()
272                .and_then(|p| p.parent())
273                .unwrap_or(manifest_path)
274                .to_path_buf(),
275        })
276    }
277}
278
279/// Result of a non-curated plugin cache refresh operation.
280#[derive(Debug)]
281#[non_exhaustive]
282pub enum RefreshResult {
283    /// Refresh completed successfully (possibly with non-fatal errors).
284    Success {
285        refreshed_count: usize,
286        errors: MultiErrors<String>,
287    },
288    /// Refresh was skipped because one is already in progress
289    SkippedAlreadyInProgress,
290    /// No roots were provided for the refresh
291    NoRootsProvided,
292}
293
294/// Information about a discovered non-curated plugin.
295#[derive(Debug, Clone)]
296pub struct DiscoveredPluginInfo {
297    /// Plugin name from manifest
298    pub name: String,
299    /// Plugin version from manifest (used for cache invalidation)
300    pub version: Option<String>,
301    /// Path to the plugin directory
302    pub path: PathBuf,
303}
304
305impl DiscoveredPluginInfo {
306    /// Check if this plugin's version matches an existing cached version.
307    async fn version_matches_existing(&self, existing_path: &Path) -> bool {
308        // If no version is set, always refresh (conservative)
309        let Some(ref current_version) = self.version else {
310            return false;
311        };
312
313        // Try to read the cached manifest and compare versions
314        let cached_manifest_path = existing_path.join(".vtcode-plugin").join("plugin.json");
315        if !tokio::fs::try_exists(&cached_manifest_path).await.unwrap_or(false) {
316            return false;
317        }
318
319        match tokio::fs::read_to_string(&cached_manifest_path).await {
320            Ok(content) => match serde_json::from_str::<PluginManifest>(&content) {
321                Ok(cached) => cached.version.as_deref() == Some(current_version),
322                Err(_) => false,
323            },
324            Err(_) => false,
325        }
326    }
327}