vtcode_core/plugins/
loader.rs1use 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#[derive(Debug, Clone)]
14pub enum PluginSource {
15 Local(PathBuf),
17 Git(String),
19 Http(String),
21 Marketplace(String),
23}
24
25pub struct PluginLoader {
27 plugins_dir: PathBuf,
29 runtime: PluginRuntime,
31}
32
33impl PluginLoader {
34 pub fn new(plugins_dir: PathBuf, runtime: PluginRuntime) -> Self {
36 Self { plugins_dir, runtime }
37 }
38
39 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 self.runtime.load_plugin(&plugin_dir).await?;
50 Ok(())
51 }
52
53 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 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 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 let install_dir = self.install_dir_for(&manifest.name)?;
85
86 self.copy_directory(&source_path, &install_dir).await?;
88
89 Ok(install_dir)
90 }
91
92 async fn install_from_git(&self, url: &str, name: Option<&str>) -> PluginResult<PathBuf> {
94 use tempfile::TempDir;
95 use tokio::process::Command;
96
97 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 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 let plugin_name = name
118 .map(|s| s.to_string())
119 .unwrap_or_else(|| self.extract_name_from_git_url(url));
120
121 let install_dir = self.install_dir_for(&plugin_name)?;
123
124 self.copy_directory(temp_path, &install_dir).await?;
126
127 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 async fn install_from_http(&self, url: &str, name: Option<&str>) -> PluginResult<PathBuf> {
144 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 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 async fn install_from_marketplace(&self, marketplace_id: &str, name: Option<&str>) -> PluginResult<PathBuf> {
173 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 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 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 self.runtime.unload_plugin(plugin_name).await?;
226
227 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 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 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 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 fn extract_name_from_git_url(&self, url: &str) -> String {
331 url.trim_end_matches(".git")
333 .split('/')
334 .next_back()
335 .unwrap_or("unknown-plugin")
336 .to_string()
337 }
338
339 fn extract_name_from_url(&self, url: &str) -> String {
341 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}