vtcode_agent_plugins/
loader.rs1use std::path::{Path, PathBuf};
2
3use crate::discovery::LoadedPlugin;
4use crate::errors::PluginError;
5
6use dirs::home_dir;
7pub trait PluginLoader {
13 fn load(&self, path: &Path) -> Result<LoadedPlugin, PluginError>;
15}
16
17pub fn plugin_roots_for(workspace: &Path) -> Vec<PathBuf> {
21 vec![
22 workspace.join(".agents/plugins"),
23 home_dir().map(|h| h.join(".agents/plugins")).unwrap_or_default(),
24 ]
25}
26
27pub struct FileSystemPluginLoader {
32 _private: (),
33}
34
35impl FileSystemPluginLoader {
36 pub fn new() -> Self {
37 Self { _private: () }
38 }
39}
40
41impl Default for FileSystemPluginLoader {
42 fn default() -> Self {
43 Self::new()
44 }
45}
46
47impl PluginLoader for FileSystemPluginLoader {
48 fn load(&self, path: &Path) -> Result<LoadedPlugin, PluginError> {
49 LoadedPlugin::load_from_dir(path)
50 }
51}
52
53pub trait PluginValidator {
58 fn validate(&self, plugin: &LoadedPlugin) -> Result<(), PluginError>;
60}
61
62pub struct DefaultPluginValidator;
67
68impl PluginValidator for DefaultPluginValidator {
69 fn validate(&self, _plugin: &LoadedPlugin) -> Result<(), PluginError> {
70 Ok(())
71 }
72}
73
74pub struct InstalledPlugin {
76 pub name: String,
77 pub path: PathBuf,
78 pub loaded: LoadedPlugin,
79}
80
81pub trait PluginInstaller {
86 fn install(&self, source: &str, name: Option<String>) -> Result<InstalledPlugin, PluginError>;
88
89 fn remove(&self, name: &str) -> Result<(), PluginError>;
91}
92
93pub struct FileSystemPluginInstaller {
95 base_dir: Option<PathBuf>,
96}
97
98impl FileSystemPluginInstaller {
99 pub fn new() -> Self {
100 Self { base_dir: None }
101 }
102
103 pub fn with_base_dir(base_dir: PathBuf) -> Self {
106 Self { base_dir: Some(base_dir) }
107 }
108
109 fn install_root(&self) -> Result<PathBuf, PluginError> {
110 match &self.base_dir {
111 Some(base) => Ok(base.clone()),
112 None => home_dir().ok_or_else(|| {
113 PluginError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "cannot resolve home directory"))
114 }),
115 }
116 }
117}
118
119impl Default for FileSystemPluginInstaller {
120 fn default() -> Self {
121 Self::new()
122 }
123}
124
125impl PluginInstaller for FileSystemPluginInstaller {
126 fn install(&self, source: &str, name: Option<String>) -> Result<InstalledPlugin, PluginError> {
127 let target_name = name.unwrap_or_else(|| {
128 Path::new(source)
129 .file_stem()
130 .and_then(|s| s.to_str())
131 .unwrap_or("plugin")
132 .to_string()
133 });
134
135 crate::manifest::PluginManifest::validate_name(&target_name).map_err(PluginError::InvalidName)?;
138
139 let install_dir = self.install_root()?.join(".agents/plugins").join(&target_name);
140
141 if install_dir.exists() {
142 return Err(PluginError::AlreadyInstalled(format!(
143 "'{target_name}' already exists at {}. Remove it with 'vtcode plugins remove {target_name}', or install under a different name with '--name <id>'",
144 install_dir.display()
145 )));
146 }
147
148 if source.starts_with("https://")
149 || source.starts_with("http://")
150 || source.starts_with("git@")
151 || source.starts_with("ssh://")
152 || source.ends_with(".git")
153 {
154 let status = std::process::Command::new("git")
155 .args(["clone", "--depth=1", source, &install_dir.to_string_lossy()])
156 .status()
157 .map_err(PluginError::Io)?;
158 if !status.success() {
159 drop(std::fs::remove_dir_all(&install_dir));
160 return Err(PluginError::Io(std::io::Error::other(format!("git clone failed for {}", source))));
161 }
162 } else {
163 let src = Path::new(source);
164 if !src.is_dir() {
165 return Err(PluginError::InvalidManifest(format!("source is not a directory or git URL: {}", source)));
166 }
167 if !src.join("plugin.json").is_file() {
168 return Err(PluginError::InvalidManifest(format!(
169 "source directory does not contain a valid plugin.json: {}",
170 source
171 )));
172 }
173 std::fs::create_dir_all(&install_dir).map_err(PluginError::Io)?;
174 copy_dir_all(src, &install_dir).map_err(PluginError::Io)?;
175 }
176
177 let loaded = LoadedPlugin::load_from_dir(&install_dir).inspect_err(|_| {
178 drop(std::fs::remove_dir_all(&install_dir));
179 })?;
180 Ok(InstalledPlugin { name: target_name, path: install_dir, loaded })
181 }
182
183 fn remove(&self, name: &str) -> Result<(), PluginError> {
184 crate::manifest::PluginManifest::validate_name(name).map_err(PluginError::InvalidName)?;
187
188 let install_dir = self.install_root()?.join(".agents/plugins").join(name);
189 if !install_dir.exists() {
190 return Err(PluginError::NotInstalled(format!(
191 "'{name}' is not installed at {}. Install it with 'vtcode plugins add <source>'",
192 install_dir.display()
193 )));
194 }
195 if !install_dir.join("plugin.json").is_file() {
196 return Err(PluginError::InvalidManifest(format!(
197 "not a valid agent plugin (missing plugin.json): {}",
198 install_dir.display()
199 )));
200 }
201 std::fs::remove_dir_all(&install_dir).map_err(PluginError::Io)
202 }
203}
204
205fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
206 copy_dir_all_inner(src, dst, &mut Vec::new())
207}
208
209fn copy_dir_all_inner(src: &Path, dst: &Path, ancestors: &mut Vec<PathBuf>) -> std::io::Result<()> {
210 std::fs::create_dir_all(dst)?;
211
212 let canonical = vtcode_commons::canonicalize(src)?;
215 if ancestors.contains(&canonical) {
216 return Err(std::io::Error::new(
217 std::io::ErrorKind::InvalidInput,
218 format!("symlink cycle detected while copying {}", src.display()),
219 ));
220 }
221 ancestors.push(canonical);
222
223 for entry in std::fs::read_dir(src)? {
224 let entry = entry?;
225 let src_path = entry.path();
226 let file_name = entry.file_name();
227 let name = file_name.to_string_lossy();
228 let dst_path = dst.join(&file_name);
229
230 if name == ".git" {
234 continue;
235 }
236
237 let meta = std::fs::symlink_metadata(&src_path)?;
238 if meta.file_type().is_symlink() {
239 #[cfg(unix)]
243 {
244 let target = std::fs::read_link(&src_path)?;
245 std::os::unix::fs::symlink(target, &dst_path)?;
246 }
247 #[cfg(not(unix))]
248 let _copied_bytes = std::fs::copy(&src_path, &dst_path)?;
249 } else if meta.is_dir() {
250 copy_dir_all_inner(&src_path, &dst_path, ancestors)?;
251 } else {
252 let _copied_bytes = std::fs::copy(&src_path, &dst_path)?;
253 }
254 }
255
256 drop(ancestors.pop());
257 Ok(())
258}