use std::path::{Path, PathBuf};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use crate::error::{PluginError, PluginResult};
use crate::manifest::{Platform, PluginManifest};
use crate::registry::{InstalledPlugin, InstalledPlugins, PluginSource};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallDisposition {
FailIfInstalled,
Upgrade,
}
#[async_trait]
pub trait PluginInstaller {
async fn install(
&self,
manifest: &PluginManifest,
plugin_dir: &Path,
source: PluginSource,
disposition: InstallDisposition,
installed_at: DateTime<Utc>,
) -> PluginResult<InstalledPlugin>;
async fn uninstall(&self, id: &str) -> PluginResult<()>;
async fn list(&self) -> PluginResult<Vec<InstalledPlugin>>;
}
pub async fn on_disk_skill_dirs(plugin_dir: &Path) -> Vec<String> {
let skills_root = plugin_dir.join("skills");
let mut found = Vec::new();
let Ok(mut entries) = tokio::fs::read_dir(&skills_root).await else {
return found;
};
while let Ok(Some(entry)) = entries.next_entry().await {
let is_dir = entry
.file_type()
.await
.map(|file_type| file_type.is_dir())
.unwrap_or(false);
if !is_dir {
continue;
}
let has_skill_md = tokio::fs::try_exists(entry.path().join("SKILL.md"))
.await
.unwrap_or(false);
if !has_skill_md {
continue;
}
if let Some(name) = entry.file_name().to_str() {
found.push(name.to_string());
}
}
found
}
pub async fn preflight_install(
manifest: &PluginManifest,
plugin_dir: &Path,
) -> PluginResult<Vec<bamboo_domain::mcp_config::McpServerConfig>> {
manifest.validate()?;
let current_platform = Platform::current();
if let Some(platforms) = &manifest.platforms {
let supported = current_platform.is_some_and(|platform| platforms.contains(&platform));
if !supported {
return Err(PluginError::UnsupportedPlatform {
plugin_id: manifest.id.clone(),
platform: current_platform
.map(|platform| platform.as_str().to_string())
.unwrap_or_else(|| std::env::consts::OS.to_string()),
});
}
}
let platform = current_platform.unwrap_or(Platform::Linux);
let resolved_mcp_servers = manifest
.provides
.mcp_servers
.iter()
.map(|entry| entry.resolve(plugin_dir, &manifest.id, platform))
.collect::<PluginResult<Vec<_>>>()?;
for skill_dir in &manifest.provides.skills {
let skill_md = plugin_dir.join("skills").join(skill_dir).join("SKILL.md");
if !tokio::fs::try_exists(&skill_md).await.unwrap_or(false) {
return Err(PluginError::InvalidManifest(format!(
"declared skill '{skill_dir}' has no SKILL.md at {}",
skill_md.display()
)));
}
}
{
use std::collections::HashSet;
let declared: HashSet<&str> = manifest
.provides
.skills
.iter()
.map(String::as_str)
.collect();
for on_disk in on_disk_skill_dirs(plugin_dir).await {
if !declared.contains(on_disk.as_str()) {
return Err(PluginError::InvalidManifest(format!(
"skill directory '{on_disk}' exists under skills/ but is not declared in \
provides.skills (a plugin must declare every skill it ships)"
)));
}
}
}
for workflow_file in &manifest.provides.workflows {
let workflow_path = plugin_dir.join("workflows").join(workflow_file);
if !tokio::fs::try_exists(&workflow_path).await.unwrap_or(false) {
return Err(PluginError::InvalidManifest(format!(
"declared workflow '{workflow_file}' not found at {}",
workflow_path.display()
)));
}
}
Ok(resolved_mcp_servers)
}
pub async fn load_previous_for_disposition(
installed_json_path: &Path,
plugin_id: &str,
disposition: InstallDisposition,
) -> PluginResult<Option<InstalledPlugin>> {
use crate::registry::PluginInstallStatus;
let existing = InstalledPlugins::load(installed_json_path).await?;
let previous = existing.get(plugin_id).cloned();
let is_completed = previous
.as_ref()
.is_some_and(|plugin| plugin.status == PluginInstallStatus::Installed);
if is_completed && disposition == InstallDisposition::FailIfInstalled {
return Err(PluginError::AlreadyInstalled(plugin_id.to_string()));
}
Ok(previous)
}
pub struct LocalPluginInstaller {
bamboo_dir: PathBuf,
}
impl LocalPluginInstaller {
pub fn new(bamboo_dir: PathBuf) -> Self {
Self { bamboo_dir }
}
fn plugins_dir(&self) -> PathBuf {
self.bamboo_dir.join("plugins")
}
fn installed_json_path(&self) -> PathBuf {
self.plugins_dir().join("installed.json")
}
}
impl Default for LocalPluginInstaller {
fn default() -> Self {
Self::new(bamboo_config::paths::bamboo_dir())
}
}
#[async_trait]
impl PluginInstaller for LocalPluginInstaller {
async fn install(
&self,
manifest: &PluginManifest,
plugin_dir: &Path,
_source: PluginSource,
disposition: InstallDisposition,
_installed_at: DateTime<Utc>,
) -> PluginResult<InstalledPlugin> {
let previous =
load_previous_for_disposition(&self.installed_json_path(), &manifest.id, disposition)
.await?;
let _previous_registered = previous.as_ref().map(|plugin| plugin.registered.clone());
let _resolved_mcp_servers = preflight_install(manifest, plugin_dir).await?;
let _ = self.installed_json_path(); Err(PluginError::NotImplemented(
"capability registration wiring — see PLUGIN_PLAN.md \
\u{a7} Installer-core agent"
.to_string(),
))
}
async fn uninstall(&self, id: &str) -> PluginResult<()> {
let installed = InstalledPlugins::load(&self.installed_json_path()).await?;
if installed.get(id).is_none() {
return Err(PluginError::NotFound(id.to_string()));
}
Err(PluginError::NotImplemented(
"capability de-registration wiring — see PLUGIN_PLAN.md \
\u{a7} Installer-core agent"
.to_string(),
))
}
async fn list(&self) -> PluginResult<Vec<InstalledPlugin>> {
let installed = InstalledPlugins::load(&self.installed_json_path()).await?;
Ok(installed.plugins)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::PluginManifest;
use crate::registry::RegisteredCapabilities;
fn manifest_with(skills: Vec<&str>, workflows: Vec<&str>) -> PluginManifest {
let json = serde_json::json!({
"id": "hello-plugin",
"name": "Hello Plugin",
"version": "0.1.0",
"provides": {
"skills": skills,
"workflows": workflows,
}
});
PluginManifest::parse_str(&json.to_string()).expect("parse manifest")
}
async fn write_skill_dirs(plugin_dir: &Path, ids: &[&str]) {
for id in ids {
let skill_dir = plugin_dir.join("skills").join(id);
tokio::fs::create_dir_all(&skill_dir).await.unwrap();
tokio::fs::write(
skill_dir.join("SKILL.md"),
format!("---\nname: {id}\ndescription: demo\n---\nHi\n"),
)
.await
.unwrap();
}
}
#[tokio::test]
async fn list_on_fresh_bamboo_dir_is_empty() {
let dir = tempfile::tempdir().expect("tempdir");
let installer = LocalPluginInstaller::new(dir.path().to_path_buf());
let plugins = installer.list().await.expect("list");
assert!(plugins.is_empty());
}
#[tokio::test]
async fn uninstall_unknown_plugin_is_not_found() {
let dir = tempfile::tempdir().expect("tempdir");
let installer = LocalPluginInstaller::new(dir.path().to_path_buf());
let error = installer
.uninstall("does-not-exist")
.await
.expect_err("should be not-found");
assert!(matches!(error, PluginError::NotFound(_)));
}
#[tokio::test]
async fn install_validates_declared_skill_exists_on_disk() {
let dir = tempfile::tempdir().expect("tempdir");
let installer = LocalPluginInstaller::new(dir.path().join("bamboo-home"));
let plugin_dir = dir.path().join("plugin-src");
tokio::fs::create_dir_all(&plugin_dir).await.unwrap();
let manifest = manifest_with(vec!["hello-world"], vec![]);
let error = installer
.install(
&manifest,
&plugin_dir,
PluginSource::LocalDir {
path: plugin_dir.clone(),
},
InstallDisposition::FailIfInstalled,
Utc::now(),
)
.await
.expect_err("missing SKILL.md should fail validation");
assert!(matches!(error, PluginError::InvalidManifest(_)));
}
#[tokio::test]
async fn install_rejects_undeclared_on_disk_skill_dir() {
let dir = tempfile::tempdir().expect("tempdir");
let installer = LocalPluginInstaller::new(dir.path().join("bamboo-home"));
let plugin_dir = dir.path().join("plugin-src");
write_skill_dirs(&plugin_dir, &["hello-world", "sneaky-extra"]).await;
let manifest = manifest_with(vec!["hello-world"], vec![]);
let error = installer
.install(
&manifest,
&plugin_dir,
PluginSource::LocalDir {
path: plugin_dir.clone(),
},
InstallDisposition::FailIfInstalled,
Utc::now(),
)
.await
.expect_err("undeclared skill dir should be rejected");
assert!(matches!(error, PluginError::InvalidManifest(_)));
assert!(error.to_string().contains("sneaky-extra"));
}
#[tokio::test]
async fn install_reaches_not_implemented_once_declared_files_exist() {
let dir = tempfile::tempdir().expect("tempdir");
let installer = LocalPluginInstaller::new(dir.path().join("bamboo-home"));
let plugin_dir = dir.path().join("plugin-src");
write_skill_dirs(&plugin_dir, &["hello-world"]).await;
let manifest = manifest_with(vec!["hello-world"], vec![]);
let error = installer
.install(
&manifest,
&plugin_dir,
PluginSource::LocalDir {
path: plugin_dir.clone(),
},
InstallDisposition::FailIfInstalled,
Utc::now(),
)
.await
.expect_err("registration wiring is a later-agent TODO");
assert!(matches!(error, PluginError::NotImplemented(_)));
let plugins = installer.list().await.expect("list");
assert!(plugins.is_empty());
}
#[tokio::test]
async fn install_fails_if_already_installed_under_fail_disposition() {
let dir = tempfile::tempdir().expect("tempdir");
let bamboo_home = dir.path().join("bamboo-home");
let installer = LocalPluginInstaller::new(bamboo_home.clone());
let mut store = InstalledPlugins::default();
store.add(InstalledPlugin {
id: "hello-plugin".to_string(),
version: "0.0.1".to_string(),
source: PluginSource::LocalDir {
path: dir.path().to_path_buf(),
},
plugin_dir: bamboo_home.join("plugins").join("hello-plugin"),
installed_at: Utc::now(),
status: crate::registry::PluginInstallStatus::Installed,
registered: RegisteredCapabilities::default(),
});
store
.save(&bamboo_home.join("plugins").join("installed.json"))
.await
.unwrap();
let plugin_dir = dir.path().join("plugin-src");
write_skill_dirs(&plugin_dir, &["hello-world"]).await;
let manifest = manifest_with(vec!["hello-world"], vec![]);
let error = installer
.install(
&manifest,
&plugin_dir,
PluginSource::LocalDir {
path: plugin_dir.clone(),
},
InstallDisposition::FailIfInstalled,
Utc::now(),
)
.await
.expect_err("already-installed under FailIfInstalled should error");
assert!(matches!(error, PluginError::AlreadyInstalled(_)));
}
#[tokio::test]
async fn upgrade_disposition_proceeds_past_the_already_installed_gate() {
let dir = tempfile::tempdir().expect("tempdir");
let bamboo_home = dir.path().join("bamboo-home");
let installer = LocalPluginInstaller::new(bamboo_home.clone());
let mut store = InstalledPlugins::default();
store.add(InstalledPlugin {
id: "hello-plugin".to_string(),
version: "0.0.1".to_string(),
source: PluginSource::LocalDir {
path: dir.path().to_path_buf(),
},
plugin_dir: bamboo_home.join("plugins").join("hello-plugin"),
installed_at: Utc::now(),
status: crate::registry::PluginInstallStatus::Installed,
registered: RegisteredCapabilities::default(),
});
store
.save(&bamboo_home.join("plugins").join("installed.json"))
.await
.unwrap();
let plugin_dir = dir.path().join("plugin-src");
write_skill_dirs(&plugin_dir, &["hello-world"]).await;
let manifest = manifest_with(vec!["hello-world"], vec![]);
let error = installer
.install(
&manifest,
&plugin_dir,
PluginSource::LocalDir {
path: plugin_dir.clone(),
},
InstallDisposition::Upgrade,
Utc::now(),
)
.await
.expect_err("registration wiring is a later-agent TODO");
assert!(matches!(error, PluginError::NotImplemented(_)));
}
}