// SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Mason Stallmo //! Installing the bundled WASM plugins and reporting whether they are there. use anyhow::{Context, Result}; use std::{io, path::Path}; use tar::Archive; use tokio::{fs, task}; use tracing::info; use xz2::read::XzDecoder; /// The tool plugins a default install is expected to have. /// /// This and [`DEFAULT_MODEL_CLIENT_PLUGINS`] name every plugin the release /// bundle ships, because [`check_default_plugins`] decides whether the client /// offers an install. A plugin left out is reported as installed when it is /// missing, and the session runs without it with nothing to say why. /// /// What ships is set by `[package.metadata.ein] plugin_type` in each plugin's /// `Cargo.toml`, and a test holds both lists to it. const DEFAULT_TOOL_PLUGINS: &[&str] = &[ "ein_bash", "ein_read", "ein_write", "ein_edit", "ein_grep", "ein_webfetch", ]; const DEFAULT_MODEL_CLIENT_PLUGINS: &[&str] = &[ "ein_openrouter", "ein_anthropic", "ein_openai", "ein_ollama", ]; /// Downloads and installs the Ein plugin bundle from GitHub releases. /// /// If `version` is `None`, installs the version matching the current binary's /// `CARGO_PKG_VERSION`. Plugins are extracted to `~/.ein/plugins/`. pub async fn install_plugins(version: Option) -> Result<()> { let ver = version.unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string()); let ver = ver.trim_start_matches('v'); let tag = format!("v{ver}"); let url = format!("https://github.com/mstallmo/ein/releases/download/{tag}/ein-plugins-{tag}.tar.xz"); let plugins_dir = dirs::home_dir() .context("Failed to find home directory")? .join(".ein") .join("plugins"); fs::create_dir_all(plugins_dir.join("tools")).await?; fs::create_dir_all(plugins_dir.join("model_clients")).await?; info!("downloading plugins from {url}"); let response = reqwest::get(&url) .await .with_context(|| format!("Failed to download {url}"))?; if !response.status().is_success() { anyhow::bail!("Download failed: HTTP {}", response.status()); } let bytes = response .bytes() .await .context("Failed to read response body")?; task::spawn_blocking(move || { let xz = XzDecoder::new(io::Cursor::new(bytes)); Archive::new(xz) .unpack(&plugins_dir) .context("Failed to extract plugin archive") }) .await??; info!("plugins installed successfully"); Ok(()) } /// Returns `true` if all default tool and model-client plugins are present. pub async fn check_default_plugins(plugin_dir: &Path, model_client_dir: &Path) -> bool { all_present(plugin_dir, DEFAULT_TOOL_PLUGINS).await && all_present(model_client_dir, DEFAULT_MODEL_CLIENT_PLUGINS).await } /// Returns whether every one of `names` is present in `dir` as a `.wasm`. /// /// Uses `tokio::fs` rather than the blocking `Path::exists`, because this runs /// from a gRPC handler on a runtime worker, and blocking `stat`s there stall /// every other session on that thread. A path that cannot be stat'd counts as /// missing, so the user is offered an install rather than told they have one. async fn all_present(dir: &Path, names: &[&str]) -> bool { for name in names { let path = dir.join(format!("{name}.wasm")); if !fs::try_exists(&path).await.unwrap_or(false) { return false; } } true } /// Returns `true` if the tool plugin directory has no `.wasm` files. pub async fn plugins_missing(plugin_dir: &Path) -> bool { let Ok(mut entries) = fs::read_dir(plugin_dir).await else { return true; }; while let Ok(Some(entry)) = entries.next_entry().await { if entry.path().extension().is_some_and(|e| e == "wasm") { return false; } } true } #[cfg(test)] mod tests { use super::*; /// The default plugin lists name exactly the plugins the workspace ships. /// /// Without this, a plugin missing from the lists is reported as present on /// an incomplete install, and the client never offers to fix it. /// /// Each plugin's kind comes from `[package.metadata.ein] plugin_type` in /// its manifest, the marker the release workflow bundles by. The manifest /// is read as text to avoid a TOML dependency for two string comparisons. #[test] fn the_default_lists_name_every_plugin_the_workspace_ships() { let plugins_dir = Path::new(env!("CARGO_MANIFEST_DIR")) .parent() .expect("the crate directory has a parent") .join("plugins"); let mut tools = Vec::new(); let mut model_clients = Vec::new(); for entry in std::fs::read_dir(&plugins_dir).expect("the plugins directory exists") { let dir = entry.expect("a readable directory entry").path(); let (Ok(manifest), Some(name)) = ( std::fs::read_to_string(dir.join("Cargo.toml")), dir.file_name().and_then(|n| n.to_str()), ) else { continue; }; if manifest.contains(r#"plugin_type = "tool""#) { tools.push(name.to_string()); } else if manifest.contains(r#"plugin_type = "model_client""#) { model_clients.push(name.to_string()); } } let sorted = |names: &[&str]| { let mut owned: Vec = names.iter().map(|n| (*n).to_string()).collect(); owned.sort_unstable(); owned }; tools.sort_unstable(); model_clients.sort_unstable(); assert_eq!( tools, sorted(DEFAULT_TOOL_PLUGINS), "DEFAULT_TOOL_PLUGINS does not match the tool plugins in the workspace" ); assert_eq!( model_clients, sorted(DEFAULT_MODEL_CLIENT_PLUGINS), "DEFAULT_MODEL_CLIENT_PLUGINS does not match the model clients in the workspace" ); } #[tokio::test] async fn a_directory_missing_one_plugin_is_not_complete() { let dir = tempfile::TempDir::new().unwrap(); for name in DEFAULT_TOOL_PLUGINS.iter().skip(1) { std::fs::write(dir.path().join(format!("{name}.wasm")), b"").unwrap(); } assert!(!all_present(dir.path(), DEFAULT_TOOL_PLUGINS).await); std::fs::write( dir.path().join(format!("{}.wasm", DEFAULT_TOOL_PLUGINS[0])), b"", ) .unwrap(); assert!(all_present(dir.path(), DEFAULT_TOOL_PLUGINS).await); } #[tokio::test] async fn a_directory_that_does_not_exist_is_not_complete() { assert!(!all_present(Path::new("/no/such/directory"), DEFAULT_TOOL_PLUGINS).await); } }