Skip to main content

vtcode_core/plugins/
loader.rs

1//! Plugin loader for VT Code
2//!
3//! Handles the discovery, installation, and loading of plugins from various sources.
4
5use std::path::{Path, PathBuf};
6use tokio::fs;
7use vtcode_commons::VtCodePaths;
8use vtcode_commons::fs::{read_private_file_no_follow, write_private_file_atomic};
9
10use super::{PluginError, PluginManifest, PluginResult, PluginRuntime, validate_plugin_component};
11
12/// Plugin source types
13#[derive(Debug, Clone)]
14pub enum PluginSource {
15    /// Local directory
16    Local(PathBuf),
17    /// Git repository
18    Git(String),
19    /// HTTP URL
20    Http(String),
21    /// Marketplace identifier
22    Marketplace(String),
23}
24
25/// Plugin loader that handles plugin installation and management
26pub struct PluginLoader {
27    /// Base directory for plugin installations
28    plugins_dir: PathBuf,
29    /// Runtime for managing loaded plugins
30    runtime: PluginRuntime,
31}
32
33impl PluginLoader {
34    /// Create a new plugin loader
35    pub fn new(plugins_dir: PathBuf, runtime: PluginRuntime) -> Self {
36        Self { plugins_dir, runtime }
37    }
38
39    /// Install a plugin from a source
40    pub async fn install_plugin(&self, source: PluginSource, name: Option<String>) -> PluginResult<()> {
41        let plugin_dir = match source {
42            PluginSource::Local(path) => self.install_from_local(path).await?,
43            PluginSource::Git(url) => self.install_from_git(&url, name.as_deref()).await?,
44            PluginSource::Http(url) => self.install_from_http(&url, name.as_deref()).await?,
45            PluginSource::Marketplace(id) => self.install_from_marketplace(&id, name.as_deref()).await?,
46        };
47
48        // Load the installed plugin
49        self.runtime.load_plugin(&plugin_dir).await?;
50        Ok(())
51    }
52
53    /// Install plugin from local directory
54    async fn install_from_local(&self, source_path: PathBuf) -> PluginResult<PathBuf> {
55        if !fs::try_exists(&source_path).await.unwrap_or(false) {
56            return Err(PluginError::LoadingError(format!("Source path does not exist: {}", source_path.display())));
57        }
58
59        // Validate that it contains a plugin manifest
60        let manifest_path = source_path.join(".vtcode-plugin/plugin.json");
61        let manifest_exists = fs::symlink_metadata(&manifest_path)
62            .await
63            .map(|metadata| metadata.is_file() && !metadata.file_type().is_symlink())
64            .unwrap_or(false);
65        if !manifest_exists {
66            return Err(PluginError::ManifestValidationError(format!(
67                "Plugin manifest not found in source: {}",
68                manifest_path.display()
69            )));
70        }
71
72        // Load the manifest to get the plugin name
73        let manifest_content = String::from_utf8(
74            read_private_file_no_follow(&manifest_path)
75                .await
76                .map_err(|e| PluginError::LoadingError(format!("Failed to read manifest: {e}")))?,
77        )
78        .map_err(|e| PluginError::LoadingError(format!("Plugin manifest is not valid UTF-8: {e}")))?;
79
80        let manifest: PluginManifest = serde_json::from_str(&manifest_content)
81            .map_err(|e| PluginError::ManifestValidationError(format!("Invalid manifest JSON: {e}")))?;
82
83        // Create installation directory
84        let install_dir = self.install_dir_for(&manifest.name)?;
85
86        // Copy plugin files to installation directory
87        self.copy_directory(&source_path, &install_dir).await?;
88
89        Ok(install_dir)
90    }
91
92    /// Install plugin from Git repository
93    async fn install_from_git(&self, url: &str, name: Option<&str>) -> PluginResult<PathBuf> {
94        use tempfile::TempDir;
95        use tokio::process::Command;
96
97        // Create a temporary directory for the git clone
98        let temp_dir = TempDir::new()
99            .map_err(|e| PluginError::LoadingError(format!("Failed to create temporary directory: {e}")))?;
100        let temp_path = temp_dir.path();
101
102        // Execute git clone command
103        let output = Command::new("git")
104            .arg("clone")
105            .arg(url)
106            .arg(temp_path)
107            .output()
108            .await
109            .map_err(|e| PluginError::LoadingError(format!("Failed to execute git clone: {e}")))?;
110
111        if !output.status.success() {
112            let stderr = String::from_utf8_lossy(&output.stderr);
113            return Err(PluginError::LoadingError(format!("Git clone failed: {stderr}")));
114        }
115
116        // Determine plugin name
117        let plugin_name = name
118            .map(|s| s.to_string())
119            .unwrap_or_else(|| self.extract_name_from_git_url(url));
120
121        // Create installation directory
122        let install_dir = self.install_dir_for(&plugin_name)?;
123
124        // Copy the cloned repository contents to the installation directory
125        self.copy_directory(temp_path, &install_dir).await?;
126
127        // Verify that the plugin manifest exists in the installed directory
128        let manifest_path = install_dir.join(".vtcode-plugin/plugin.json");
129        let manifest_exists = fs::symlink_metadata(&manifest_path)
130            .await
131            .map(|metadata| metadata.is_file() && !metadata.file_type().is_symlink())
132            .unwrap_or(false);
133        if !manifest_exists {
134            return Err(PluginError::ManifestValidationError(
135                "Plugin manifest not found in cloned repository".to_string(),
136            ));
137        }
138
139        Ok(install_dir)
140    }
141
142    /// Install plugin from HTTP URL
143    async fn install_from_http(&self, url: &str, name: Option<&str>) -> PluginResult<PathBuf> {
144        // For now, create a placeholder implementation
145        let plugin_name = name.map(|s| s.to_string()).unwrap_or_else(|| self.extract_name_from_url(url));
146
147        let install_dir = self.install_dir_for(&plugin_name)?;
148
149        // Create a placeholder manifest file
150        let placeholder_manifest = format!(
151            r#"{{
152  "name": "{plugin_name}",
153  "version": "1.0.0",
154  "description": "Placeholder for HTTP-installed plugin from {url}"
155}}"#
156        );
157
158        let manifest_path = install_dir.join(".vtcode-plugin/plugin.json");
159        let manifest_parent = manifest_path.parent().ok_or_else(|| {
160            PluginError::LoadingError("Failed to resolve parent directory for plugin manifest path".to_string())
161        })?;
162        VtCodePaths::ensure_user_dir(manifest_parent)
163            .map_err(|e| PluginError::LoadingError(format!("Failed to create plugin manifest directory: {e}")))?;
164        write_private_file_atomic(&manifest_path, placeholder_manifest.as_bytes())
165            .await
166            .map_err(|e| PluginError::LoadingError(format!("Failed to create placeholder manifest: {e}")))?;
167
168        Ok(install_dir)
169    }
170
171    /// Install plugin from marketplace
172    async fn install_from_marketplace(&self, marketplace_id: &str, name: Option<&str>) -> PluginResult<PathBuf> {
173        // For now, create a placeholder implementation
174        let plugin_name = name
175            .map(|s| s.to_string())
176            .unwrap_or_else(|| marketplace_id.split('/').next_back().unwrap_or(marketplace_id).to_string());
177
178        let install_dir = self.install_dir_for(&plugin_name)?;
179
180        // Create a placeholder manifest file
181        let placeholder_manifest = format!(
182            r#"{{
183  "name": "{plugin_name}",
184  "version": "1.0.0",
185  "description": "Placeholder for marketplace-installed plugin from {marketplace_id}"
186}}"#
187        );
188
189        let manifest_path = install_dir.join(".vtcode-plugin/plugin.json");
190        let manifest_parent = manifest_path.parent().ok_or_else(|| {
191            PluginError::LoadingError("Failed to resolve parent directory for plugin manifest path".to_string())
192        })?;
193        VtCodePaths::ensure_user_dir(manifest_parent)
194            .map_err(|e| PluginError::LoadingError(format!("Failed to create plugin manifest directory: {e}")))?;
195        write_private_file_atomic(&manifest_path, placeholder_manifest.as_bytes())
196            .await
197            .map_err(|e| PluginError::LoadingError(format!("Failed to create placeholder manifest: {e}")))?;
198
199        Ok(install_dir)
200    }
201
202    /// Uninstall a plugin
203    pub async fn uninstall_plugin(&self, plugin_name: &str) -> PluginResult<()> {
204        validate_plugin_component(plugin_name)?;
205        VtCodePaths::ensure_user_dir(&self.plugins_dir)
206            .map_err(|e| PluginError::LoadingError(format!("Failed to validate plugin directory: {e}")))?;
207        let plugin_dir = self.plugins_dir.join(plugin_name);
208        let metadata = match fs::symlink_metadata(&plugin_dir).await {
209            Ok(metadata) => metadata,
210            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
211                return Err(PluginError::NotFound(plugin_name.to_string().into()));
212            }
213            Err(error) => {
214                return Err(PluginError::LoadingError(format!("Failed to inspect plugin directory: {error}")));
215            }
216        };
217        if metadata.file_type().is_symlink() || !metadata.is_dir() {
218            return Err(PluginError::LoadingError(format!(
219                "Refusing to remove non-directory plugin path: {}",
220                plugin_dir.display()
221            )));
222        }
223
224        // Unload the plugin from runtime first
225        self.runtime.unload_plugin(plugin_name).await?;
226
227        // Remove the plugin directory
228        fs::remove_dir_all(&plugin_dir)
229            .await
230            .map_err(|e| PluginError::LoadingError(format!("Failed to remove plugin directory: {e}")))?;
231
232        Ok(())
233    }
234
235    /// List installed plugins
236    pub async fn list_installed_plugins(&self) -> PluginResult<Vec<String>> {
237        let mut plugins = Vec::new();
238
239        if !fs::try_exists(&self.plugins_dir).await.unwrap_or(false) {
240            return Ok(plugins);
241        }
242
243        let mut entries = fs::read_dir(&self.plugins_dir)
244            .await
245            .map_err(|e| PluginError::LoadingError(format!("Failed to read plugins directory: {e}")))?;
246
247        while let Some(entry) = entries
248            .next_entry()
249            .await
250            .map_err(|e| PluginError::LoadingError(format!("Failed to read directory entry: {e}")))?
251        {
252            let path = entry.path();
253            let is_directory = fs::symlink_metadata(&path)
254                .await
255                .map(|metadata| metadata.is_dir() && !metadata.file_type().is_symlink())
256                .unwrap_or(false);
257            if is_directory {
258                // Check if it contains a plugin manifest
259                let manifest_path = path.join(".vtcode-plugin/plugin.json");
260                let has_regular_manifest = fs::symlink_metadata(&manifest_path)
261                    .await
262                    .map(|metadata| metadata.is_file() && !metadata.file_type().is_symlink())
263                    .unwrap_or(false);
264                if has_regular_manifest && let Some(name) = path.file_name() {
265                    plugins.push(name.to_string_lossy().to_string());
266                }
267            }
268        }
269
270        Ok(plugins)
271    }
272
273    /// Copy directory recursively
274    async fn copy_directory(&self, src: &Path, dst: &Path) -> PluginResult<()> {
275        Box::pin(async {
276            let source_metadata = fs::symlink_metadata(src).await.map_err(|error| {
277                PluginError::LoadingError(format!("Failed to inspect source path {}: {error}", src.display()))
278            })?;
279            if source_metadata.file_type().is_symlink() || !source_metadata.is_dir() {
280                return Err(PluginError::LoadingError(format!("Source is not a directory: {}", src.display())));
281            }
282
283            VtCodePaths::ensure_user_dir(dst)
284                .map_err(|e| PluginError::LoadingError(format!("Failed to create destination directory: {e}")))?;
285
286            let mut entries = fs::read_dir(src)
287                .await
288                .map_err(|e| PluginError::LoadingError(format!("Failed to read source directory: {e}")))?;
289
290            while let Some(entry) = entries
291                .next_entry()
292                .await
293                .map_err(|e| PluginError::LoadingError(format!("Failed to read directory entry: {e}")))?
294            {
295                let src_path = entry.path();
296                let dst_path = dst.join(entry.file_name());
297
298                let source_metadata = fs::symlink_metadata(&src_path)
299                    .await
300                    .map_err(|error| PluginError::LoadingError(format!("Failed to inspect source entry: {error}")))?;
301                if source_metadata.file_type().is_symlink() {
302                    return Err(PluginError::LoadingError(format!(
303                        "Refusing to copy symlinked plugin entry: {}",
304                        src_path.display()
305                    )));
306                }
307                if source_metadata.is_dir() {
308                    self.copy_directory(&src_path, &dst_path).await?;
309                } else if source_metadata.is_file() {
310                    if let Ok(destination_metadata) = fs::symlink_metadata(&dst_path).await
311                        && destination_metadata.file_type().is_symlink()
312                    {
313                        return Err(PluginError::LoadingError(format!(
314                            "Refusing to replace symlinked plugin entry: {}",
315                            dst_path.display()
316                        )));
317                    }
318                    fs::copy(&src_path, &dst_path)
319                        .await
320                        .map_err(|e| PluginError::LoadingError(format!("Failed to copy file: {e}")))?;
321                }
322            }
323
324            Ok(())
325        })
326        .await
327    }
328
329    /// Extract plugin name from Git URL
330    fn extract_name_from_git_url(&self, url: &str) -> String {
331        // Extract name from git URL (e.g., https://github.com/user/repo.git -> repo)
332        url.trim_end_matches(".git")
333            .split('/')
334            .next_back()
335            .unwrap_or("unknown-plugin")
336            .to_string()
337    }
338
339    /// Extract plugin name from URL
340    fn extract_name_from_url(&self, url: &str) -> String {
341        // Extract name from URL path
342        url.split('/').next_back().unwrap_or("unknown-plugin").to_string()
343    }
344
345    fn install_dir_for(&self, plugin_name: &str) -> PluginResult<PathBuf> {
346        validate_plugin_component(plugin_name)?;
347        VtCodePaths::ensure_user_dir(&self.plugins_dir)
348            .map_err(|e| PluginError::LoadingError(format!("Failed to validate plugin directory: {e}")))?;
349        let install_dir = self.plugins_dir.join(plugin_name);
350        VtCodePaths::ensure_user_dir(&install_dir)
351            .map_err(|e| PluginError::LoadingError(format!("Failed to create plugin directory: {e}")))?;
352        Ok(install_dir)
353    }
354}