Skip to main content

vtcode_agent_plugins/
loader.rs

1use std::path::{Path, PathBuf};
2
3use crate::discovery::LoadedPlugin;
4use crate::errors::PluginError;
5
6use dirs::home_dir;
7/// Abstraction for loading Agent Plugins from a source.
8///
9/// This trait isolates the loading mechanism from the rest of the system,
10/// allowing the next generation to swap the filesystem implementation for
11/// network-loaded, cached, or sandboxed loaders without changing callers.
12pub trait PluginLoader {
13    /// Load a plugin from the given path.
14    fn load(&self, path: &Path) -> Result<LoadedPlugin, PluginError>;
15}
16
17/// Ordered plugin discovery roots: the workspace project root first, then the
18/// user home root. Shared by the CLI `plugins` subcommands and the core MCP
19/// discovery so both layers agree on where plugins live.
20pub 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
27/// Default filesystem-based plugin loader.
28///
29/// This is the current production implementation. It reads `plugin.json`,
30/// `skills/`, and `mcp.json` from the local filesystem.
31pub 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
53/// Abstraction for validating loaded plugins.
54///
55/// Callers should validate plugins after loading and before use.
56/// The default implementation accepts all valid plugins.
57pub trait PluginValidator {
58    /// Validate a loaded plugin. Returns `Ok(())` if the plugin is acceptable.
59    fn validate(&self, plugin: &LoadedPlugin) -> Result<(), PluginError>;
60}
61
62/// Default validator that accepts all successfully-loaded plugins.
63///
64/// Additional checks (trust model, signature, etc.) can be layered by
65/// wrapping this validator with a decorator.
66pub struct DefaultPluginValidator;
67
68impl PluginValidator for DefaultPluginValidator {
69    fn validate(&self, _plugin: &LoadedPlugin) -> Result<(), PluginError> {
70        Ok(())
71    }
72}
73
74/// Outcome of a plugin installation.
75pub struct InstalledPlugin {
76    pub name: String,
77    pub path: PathBuf,
78    pub loaded: LoadedPlugin,
79}
80
81/// Abstraction for installing and removing plugins.
82///
83/// This isolates installation mechanics (git clone, local copy, etc.)
84/// from the CLI layer, enabling test doubles and alternate install targets.
85pub trait PluginInstaller {
86    /// Install a plugin from `source` with the given `name`.
87    fn install(&self, source: &str, name: Option<String>) -> Result<InstalledPlugin, PluginError>;
88
89    /// Remove an installed plugin by `name`.
90    fn remove(&self, name: &str) -> Result<(), PluginError>;
91}
92
93/// Default filesystem-based installer.
94pub struct FileSystemPluginInstaller {
95    base_dir: Option<PathBuf>,
96}
97
98impl FileSystemPluginInstaller {
99    pub fn new() -> Self {
100        Self { base_dir: None }
101    }
102
103    /// Install under a custom base directory instead of the user home.
104    /// Used by tests to keep installs hermetic.
105    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        // Reject names that could escape the plugins root (e.g. "../evil" or
136        // absolute paths). The name is user-supplied via `--name`.
137        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        // Reject names that could escape the plugins root. Without this,
185        // `remove("../../.config/x")` would delete arbitrary directories.
186        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    // Guard against symlink cycles: if we are revisiting a directory we are
213    // already an ancestor of, abort instead of recursing forever.
214    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        // Do not copy the source plugin's own VCS metadata; a local checkout
231        // should install the plugin, not its history. Other dotfiles (e.g.
232        // .npmrc, .env) are preserved because they may be required at runtime.
233        if name == ".git" {
234            continue;
235        }
236
237        let meta = std::fs::symlink_metadata(&src_path)?;
238        if meta.file_type().is_symlink() {
239            // Copy symlinks as their link target, not the linked content, so
240            // plugin-internal symlinks stay relative and absolute symlinks
241            // cannot pull data out of the source into the install tree.
242            #[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}