diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index ec6227da..0d9396bf 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -14,6 +14,7 @@ use goose_mcp::{AutoVisualiserRouter, ComputerControllerServer, MemoryServer, Tu use crate::commands::configure::configure_telemetry_consent_dialog; use crate::commands::configure::handle_configure; use crate::commands::info::handle_info; +use crate::commands::plugin::handle_plugin_install; use crate::commands::project::{handle_project_default, handle_projects_interactive}; use crate::commands::recipe::{handle_deeplink, handle_list, handle_open, handle_validate}; use crate::commands::term::{ @@ -643,6 +644,16 @@ enum GatewayCommand { }, } +#[derive(Subcommand)] +enum PluginCommand { + /// Install a plugin from a git repository URL + #[command(about = "Install a plugin from a git repository URL")] + Install { + #[arg(help = "URL to a git repository containing a supported plugin")] + url: String, + }, +} + #[derive(Subcommand)] enum RecipeCommand { /// Validate a recipe file @@ -854,6 +865,13 @@ enum Command { command: RecipeCommand, }, + /// Manage plugins + #[command(about = "Manage plugins")] + Plugin { + #[command(subcommand)] + command: PluginCommand, + }, + /// Manage scheduled jobs #[command(about = "Manage scheduled jobs", visible_alias = "sched")] Schedule { @@ -1056,6 +1074,7 @@ fn get_command_name(command: &Option) -> &'static str { Some(Command::Schedule { .. }) => "schedule", Some(Command::Update { .. }) => "update", Some(Command::Recipe { .. }) => "recipe", + Some(Command::Plugin { .. }) => "plugin", Some(Command::Term { .. }) => "term", #[cfg(feature = "local-inference")] Some(Command::LocalModels { .. }) => "local-models", @@ -1529,6 +1548,12 @@ async fn handle_schedule_command(command: SchedulerCommand) -> Result<()> { } } +fn handle_plugin_subcommand(command: PluginCommand) -> Result<()> { + match command { + PluginCommand::Install { url } => handle_plugin_install(&url), + } +} + fn handle_recipe_subcommand(command: RecipeCommand) -> Result<()> { match command { RecipeCommand::Validate { recipe_name } => handle_validate(&recipe_name), @@ -1857,6 +1882,7 @@ pub async fn cli() -> anyhow::Result<()> { Ok(()) } Some(Command::Recipe { command }) => handle_recipe_subcommand(command), + Some(Command::Plugin { command }) => handle_plugin_subcommand(command), Some(Command::Term { command }) => handle_term_subcommand(command).await, #[cfg(feature = "local-inference")] Some(Command::LocalModels { command }) => handle_local_models_command(command).await, diff --git a/crates/goose-cli/src/commands/mod.rs b/crates/goose-cli/src/commands/mod.rs index 6622f4d9..3dee8b63 100644 --- a/crates/goose-cli/src/commands/mod.rs +++ b/crates/goose-cli/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod configure; pub mod doctor; pub mod gateway; pub mod info; +pub mod plugin; pub mod project; pub mod recipe; pub mod schedule; diff --git a/crates/goose-cli/src/commands/plugin.rs b/crates/goose-cli/src/commands/plugin.rs new file mode 100644 index 00000000..4da18bbf --- /dev/null +++ b/crates/goose-cli/src/commands/plugin.rs @@ -0,0 +1,27 @@ +use anyhow::Result; +use console::style; + +pub fn handle_plugin_install(url: &str) -> Result<()> { + let install = goose::plugins::install_plugin(url)?; + + println!( + "{} Installed {} plugin '{}' ({})", + style("✓").green(), + install.format, + style(&install.name).bold(), + install.version + ); + println!(" Source: {}", install.source); + println!(" Location: {}", install.directory.display()); + + if install.skills.is_empty() { + println!(" No skills imported."); + } else { + println!(" Imported skills:"); + for skill in install.skills { + println!(" - {}", skill.name); + } + } + + Ok(()) +} diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index 7f5c5ace..de2aaaa8 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -142,7 +142,7 @@ jsonwebtoken = { version = "10.3.0", default-features = false, features = ["use_ blake3 = "1.8" fs2 = { workspace = true } tokio-stream = { workspace = true, features = ["io-util"] } -tempfile = { workspace = true } +tempfile.workspace = true dashmap = "6.1" ahash = "0.8" tokio-util = { workspace = true, features = ["compat"] } diff --git a/crates/goose/src/lib.rs b/crates/goose/src/lib.rs index ab64d11c..98e6c051 100644 --- a/crates/goose/src/lib.rs +++ b/crates/goose/src/lib.rs @@ -27,6 +27,7 @@ pub mod oauth; #[cfg(feature = "otel")] pub mod otel; pub mod permission; +pub mod plugins; #[cfg(feature = "telemetry")] pub mod posthog; pub mod prompt_template; diff --git a/crates/goose/src/plugins/formats/gemini.rs b/crates/goose/src/plugins/formats/gemini.rs new file mode 100644 index 00000000..148de55e --- /dev/null +++ b/crates/goose/src/plugins/formats/gemini.rs @@ -0,0 +1,191 @@ +use crate::plugins::{ + copy_dir_all, plugin_install_dir, write_install_metadata, FormatNotSupported, ImportedSkill, + PluginFormat, PluginInstall, +}; +use anyhow::{bail, Context, Result}; +use fs_err as fs; +use serde::Deserialize; +use std::path::{Path, PathBuf}; + +pub(super) const MANIFEST: &str = "gemini-extension.json"; + +#[derive(Debug, Deserialize)] +struct GeminiManifest { + name: String, + version: String, +} + +struct SkillCandidate { + name: String, + relative_directory: PathBuf, +} + +pub fn try_install_from_manifest(source: &str, checkout_dir: &Path) -> Result { + install_from_manifest(source, checkout_dir, &plugin_install_dir()) +} + +fn install_from_manifest( + source: &str, + checkout_dir: &Path, + install_root: &Path, +) -> Result { + let manifest_path = checkout_dir.join(MANIFEST); + if !manifest_path.is_file() { + return Err(FormatNotSupported.into()); + } + + let manifest: GeminiManifest = serde_json::from_str(&fs::read_to_string(&manifest_path)?) + .with_context(|| format!("Failed to parse {}", manifest_path.display()))?; + + validate_extension_name(&manifest.name)?; + + fs::create_dir_all(install_root)?; + let destination = install_root.join(&manifest.name); + if destination.exists() { + bail!( + "Plugin '{}' is already installed at {}", + manifest.name, + destination.display() + ); + } + + let skills = find_skills(checkout_dir)?; + if skills.is_empty() { + bail!( + "Plugin '{}' does not contain any Gemini skills", + manifest.name + ); + } + + copy_dir_all(checkout_dir, &destination)?; + write_install_metadata(&destination, source, "gemini")?; + + Ok(PluginInstall { + name: manifest.name, + version: manifest.version, + format: PluginFormat::Gemini, + source: source.to_string(), + directory: destination.clone(), + skills: skills + .into_iter() + .map(|skill| ImportedSkill { + name: skill.name, + directory: destination.join(skill.relative_directory), + }) + .collect(), + }) +} + +fn validate_extension_name(name: &str) -> Result<()> { + if name.is_empty() { + bail!("Gemini extension name must not be empty"); + } + + if !name + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-') + { + bail!( + "Invalid Gemini extension name '{}'. Names may only contain letters, numbers, and dashes", + name + ); + } + + Ok(()) +} + +fn find_skills(extension_dir: &Path) -> Result> { + let skills_dir = extension_dir.join("skills"); + if !skills_dir.is_dir() { + return Ok(Vec::new()); + } + + let mut skills = Vec::new(); + collect_skill_candidate(extension_dir, &skills_dir, &mut skills)?; + + for entry in fs::read_dir(&skills_dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + collect_skill_candidate(extension_dir, &path, &mut skills)?; + } + } + + skills.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(skills) +} + +fn collect_skill_candidate( + extension_dir: &Path, + skill_dir: &Path, + skills: &mut Vec, +) -> Result<()> { + let skill_file = skill_dir.join("SKILL.md"); + if !skill_file.is_file() { + return Ok(()); + } + + let raw = fs::read_to_string(&skill_file)?; + let name = extract_skill_name(&raw).unwrap_or_else(|| { + skill_dir + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("unnamed") + .to_string() + }); + let relative_directory = skill_dir.strip_prefix(extension_dir)?.to_path_buf(); + + skills.push(SkillCandidate { + name, + relative_directory, + }); + + Ok(()) +} + +fn extract_skill_name(raw: &str) -> Option { + let (metadata, _): (crate::skills::SkillFrontmatter, String) = + crate::sources::parse_frontmatter(raw).ok()??; + metadata.name.filter(|name| !name.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn installs_gemini_extension_skills() { + let install_root = tempfile::tempdir().unwrap(); + let repo = tempfile::tempdir().unwrap(); + fs::write( + repo.path().join(MANIFEST), + r#"{"name":"test-plugin","version":"1.0.0"}"#, + ) + .unwrap(); + let skill_dir = repo.path().join("skills").join("audit"); + fs::create_dir_all(&skill_dir).unwrap(); + fs::write( + skill_dir.join("SKILL.md"), + "---\nname: audit\ndescription: Audit code\n---\nDo an audit.", + ) + .unwrap(); + + let installed = install_from_manifest( + "https://example.invalid/repo.git", + repo.path(), + install_root.path(), + ) + .unwrap(); + + assert_eq!(installed.name, "test-plugin"); + assert_eq!(installed.version, "1.0.0"); + assert_eq!(installed.skills.len(), 1); + assert_eq!(installed.skills[0].name, "audit"); + assert!(installed.directory.join(MANIFEST).is_file()); + assert!(installed + .directory + .join(crate::plugins::INSTALL_METADATA) + .is_file()); + assert_eq!(installed.directory, install_root.path().join("test-plugin")); + } +} diff --git a/crates/goose/src/plugins/formats/mod.rs b/crates/goose/src/plugins/formats/mod.rs new file mode 100644 index 00000000..e816fe51 --- /dev/null +++ b/crates/goose/src/plugins/formats/mod.rs @@ -0,0 +1 @@ +pub(super) mod gemini; diff --git a/crates/goose/src/plugins/mod.rs b/crates/goose/src/plugins/mod.rs new file mode 100644 index 00000000..62f5e760 --- /dev/null +++ b/crates/goose/src/plugins/mod.rs @@ -0,0 +1,185 @@ +pub mod formats; + +use crate::config::paths::Paths; +use crate::subprocess::SubprocessExt; +use anyhow::{anyhow, bail, Result}; +use fs_err as fs; +use serde::Serialize; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const INSTALL_METADATA: &str = ".goose-plugin-install.json"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PluginFormat { + Gemini, +} + +impl std::fmt::Display for PluginFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PluginFormat::Gemini => write!(f, "gemini"), + } + } +} + +#[derive(Debug, Clone)] +pub struct PluginInstall { + pub name: String, + pub version: String, + pub format: PluginFormat, + pub source: String, + pub directory: PathBuf, + pub skills: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImportedSkill { + pub name: String, + pub directory: PathBuf, +} + +#[derive(Debug, thiserror::Error)] +#[error("format not supported")] +pub struct FormatNotSupported; + +#[derive(Debug, Serialize)] +struct InstallMetadata<'a> { + source: &'a str, + source_type: &'a str, + format: &'a str, +} + +pub fn plugin_install_dir() -> PathBuf { + Paths::data_dir().join("plugins") +} + +pub fn installed_plugin_skill_dirs() -> Vec { + let plugins_dir = plugin_install_dir(); + let entries = match fs::read_dir(plugins_dir) { + Ok(entries) => entries, + Err(_) => return Vec::new(), + }; + + entries + .flatten() + .map(|entry| entry.path().join("skills")) + .filter(|path| path.is_dir()) + .collect() +} + +pub fn install_plugin(source: &str) -> Result { + if source.trim().is_empty() { + bail!("Plugin source URL must not be empty"); + } + + let temp_dir = tempfile::tempdir()?; + let checkout_dir = temp_dir.path().join("checkout"); + clone_git_repo(source, &checkout_dir)?; + + install_from_checkout(source, &checkout_dir) +} + +fn install_from_checkout(source: &str, checkout_dir: &Path) -> Result { + match formats::gemini::try_install_from_manifest(source, checkout_dir) { + Ok(install) => Ok(install), + Err(err) if err.is::() => { + bail!("No supported plugin format found") + } + Err(err) => Err(err), + } +} + +fn clone_git_repo(source: &str, destination: &Path) -> Result<()> { + let output = Command::new("git") + .arg("clone") + .arg("--depth") + .arg("1") + .arg(source) + .arg(destination) + .set_no_window() + .output() + .map_err(|e| anyhow!("Failed to run git clone: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let message = if stderr.is_empty() { stdout } else { stderr }; + bail!("Failed to clone plugin repository: {message}"); + } + + Ok(()) +} + +fn write_install_metadata(destination: &Path, source: &str, format: &str) -> Result<()> { + let metadata = InstallMetadata { + source, + source_type: "git", + format, + }; + fs::write( + destination.join(INSTALL_METADATA), + serde_json::to_string_pretty(&metadata)?, + )?; + Ok(()) +} + +fn copy_dir_all(source: &Path, destination: &Path) -> Result<()> { + fs::create_dir_all(destination)?; + + for entry in fs::read_dir(source)? { + let entry = entry?; + let source_path = entry.path(); + let destination_path = destination.join(entry.file_name()); + let file_type = entry.file_type()?; + + if file_type.is_dir() { + copy_dir_all(&source_path, &destination_path)?; + } else if file_type.is_file() { + fs::copy(&source_path, &destination_path)?; + } else if file_type.is_symlink() { + copy_symlink(&source_path, &destination_path)?; + } + } + + Ok(()) +} + +#[cfg(unix)] +fn copy_symlink(source: &Path, destination: &Path) -> Result<()> { + std::os::unix::fs::symlink(fs::read_link(source)?, destination)?; + Ok(()) +} + +#[cfg(windows)] +fn copy_symlink(source: &Path, destination: &Path) -> Result<()> { + let target = fs::read_link(source)?; + if source.is_dir() { + std::os::windows::fs::symlink_dir(target, destination)?; + } else { + std::os::windows::fs::symlink_file(target, destination)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn rejects_repo_without_supported_manifest() { + let _guard = ENV_LOCK.lock().unwrap(); + let root = tempfile::tempdir().unwrap(); + std::env::set_var("GOOSE_PATH_ROOT", root.path()); + let repo = tempfile::tempdir().unwrap(); + + let err = + install_from_checkout("https://example.invalid/repo.git", repo.path()).unwrap_err(); + + assert!(err.to_string().contains("No supported plugin format found")); + std::env::remove_var("GOOSE_PATH_ROOT"); + } +} diff --git a/crates/goose/src/skills/mod.rs b/crates/goose/src/skills/mod.rs index b337b4e4..ab45646b 100644 --- a/crates/goose/src/skills/mod.rs +++ b/crates/goose/src/skills/mod.rs @@ -8,6 +8,7 @@ pub mod client; pub use client::{SkillsClient, EXTENSION_NAME}; use crate::config::paths::Paths; +use crate::plugins::installed_plugin_skill_dirs; use crate::sources::parse_frontmatter; use goose_sdk::custom_requests::{SourceEntry, SourceType}; use sacp::Error; @@ -211,6 +212,12 @@ pub fn all_skill_dirs(working_dir: Option<&Path>) -> Vec<(PathBuf, bool)> { dirs.push((h.join(".config").join("agents").join("skills"), true)); } + dirs.extend( + installed_plugin_skill_dirs() + .into_iter() + .map(|dir| (dir, true)), + ); + dirs }