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