diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 0d9396bf..76aeee99 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -14,7 +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::plugin::{handle_plugin_install, handle_plugin_update}; 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::{ @@ -649,9 +649,22 @@ enum PluginCommand { /// Install a plugin from a git repository URL #[command(about = "Install a plugin from a git repository URL")] Install { + #[arg( + long, + help = "Automatically update this plugin before plugin skills are loaded" + )] + auto_update: bool, + #[arg(help = "URL to a git repository containing a supported plugin")] url: String, }, + + /// Update an installed git-backed plugin + #[command(about = "Update an installed git-backed plugin")] + Update { + #[arg(help = "Name of the installed plugin to update")] + name: String, + }, } #[derive(Subcommand)] @@ -1550,7 +1563,8 @@ async fn handle_schedule_command(command: SchedulerCommand) -> Result<()> { fn handle_plugin_subcommand(command: PluginCommand) -> Result<()> { match command { - PluginCommand::Install { url } => handle_plugin_install(&url), + PluginCommand::Install { url, auto_update } => handle_plugin_install(&url, auto_update), + PluginCommand::Update { name } => handle_plugin_update(&name), } } diff --git a/crates/goose-cli/src/commands/plugin.rs b/crates/goose-cli/src/commands/plugin.rs index 4da18bbf..d2db550f 100644 --- a/crates/goose-cli/src/commands/plugin.rs +++ b/crates/goose-cli/src/commands/plugin.rs @@ -1,8 +1,11 @@ use anyhow::Result; use console::style; -pub fn handle_plugin_install(url: &str) -> Result<()> { - let install = goose::plugins::install_plugin(url)?; +pub fn handle_plugin_install(url: &str, auto_update: bool) -> Result<()> { + let install = goose::plugins::install_plugin_with_options( + url, + goose::plugins::PluginInstallOptions { auto_update }, + )?; println!( "{} Installed {} plugin '{}' ({})", @@ -11,6 +14,27 @@ pub fn handle_plugin_install(url: &str) -> Result<()> { style(&install.name).bold(), install.version ); + print_plugin_install(&install); + + Ok(()) +} + +pub fn handle_plugin_update(name: &str) -> Result<()> { + let install = goose::plugins::update_plugin(name)?; + + println!( + "{} Updated {} plugin '{}' ({})", + style("✓").green(), + install.format, + style(&install.name).bold(), + install.version + ); + print_plugin_install(&install); + + Ok(()) +} + +fn print_plugin_install(install: &goose::plugins::PluginInstall) { println!(" Source: {}", install.source); println!(" Location: {}", install.directory.display()); @@ -18,10 +42,8 @@ pub fn handle_plugin_install(url: &str) -> Result<()> { println!(" No skills imported."); } else { println!(" Imported skills:"); - for skill in install.skills { + for skill in &install.skills { println!(" - {}", skill.name); } } - - Ok(()) } diff --git a/crates/goose/src/plugins/formats/gemini.rs b/crates/goose/src/plugins/formats/gemini.rs index 148de55e..2943da66 100644 --- a/crates/goose/src/plugins/formats/gemini.rs +++ b/crates/goose/src/plugins/formats/gemini.rs @@ -1,13 +1,14 @@ use crate::plugins::{ - copy_dir_all, plugin_install_dir, write_install_metadata, FormatNotSupported, ImportedSkill, - PluginFormat, PluginInstall, + copy_dir_all, write_install_metadata, FormatNotSupported, ImportedSkill, PluginFormat, + PluginInstall, PluginInstallOptions, }; use anyhow::{bail, Context, Result}; +use chrono::{DateTime, Utc}; use fs_err as fs; use serde::Deserialize; use std::path::{Path, PathBuf}; -pub(super) const MANIFEST: &str = "gemini-extension.json"; +pub(in crate::plugins) const MANIFEST: &str = "gemini-extension.json"; #[derive(Debug, Deserialize)] struct GeminiManifest { @@ -20,14 +21,12 @@ struct SkillCandidate { 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( +pub(in crate::plugins) fn try_install_from_manifest_at_root( source: &str, checkout_dir: &Path, install_root: &Path, + options: &PluginInstallOptions, + last_update_check: Option>, ) -> Result { let manifest_path = checkout_dir.join(MANIFEST); if !manifest_path.is_file() { @@ -58,7 +57,13 @@ fn install_from_manifest( } copy_dir_all(checkout_dir, &destination)?; - write_install_metadata(&destination, source, "gemini")?; + write_install_metadata( + &destination, + source, + "gemini", + options.auto_update, + last_update_check, + )?; Ok(PluginInstall { name: manifest.name, @@ -170,10 +175,12 @@ mod tests { ) .unwrap(); - let installed = install_from_manifest( + let installed = try_install_from_manifest_at_root( "https://example.invalid/repo.git", repo.path(), install_root.path(), + &PluginInstallOptions::default(), + None, ) .unwrap(); diff --git a/crates/goose/src/plugins/mod.rs b/crates/goose/src/plugins/mod.rs index 62f5e760..09bf0fd2 100644 --- a/crates/goose/src/plugins/mod.rs +++ b/crates/goose/src/plugins/mod.rs @@ -3,12 +3,15 @@ pub mod formats; use crate::config::paths::Paths; use crate::subprocess::SubprocessExt; use anyhow::{anyhow, bail, Result}; +use chrono::{DateTime, Duration, Utc}; use fs_err as fs; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use std::process::Command; +use tracing::warn; const INSTALL_METADATA: &str = ".goose-plugin-install.json"; +const AUTO_UPDATE_INTERVAL_HOURS: i64 = 24; #[derive(Debug, Clone, PartialEq, Eq)] pub enum PluginFormat { @@ -33,6 +36,17 @@ pub struct PluginInstall { pub skills: Vec, } +#[derive(Debug, Clone, Default)] +pub struct PluginInstallOptions { + pub auto_update: bool, +} + +#[derive(Debug)] +pub struct PluginAutoUpdateResult { + pub name: String, + pub result: Result, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ImportedSkill { pub name: String, @@ -43,11 +57,16 @@ pub struct ImportedSkill { #[error("format not supported")] pub struct FormatNotSupported; -#[derive(Debug, Serialize)] -struct InstallMetadata<'a> { - source: &'a str, - source_type: &'a str, - format: &'a str, +#[derive(Debug, Deserialize, Serialize)] +struct InstallMetadata { + source: String, + source_type: String, + #[allow(dead_code)] + format: String, + #[serde(default)] + auto_update: bool, + #[serde(default)] + last_update_check: Option>, } pub fn plugin_install_dir() -> PathBuf { @@ -56,6 +75,15 @@ pub fn plugin_install_dir() -> PathBuf { pub fn installed_plugin_skill_dirs() -> Vec { let plugins_dir = plugin_install_dir(); + for update in auto_update_plugins_at_root(Utc::now(), &plugins_dir) { + if let Err(err) = update.result { + warn!( + "Failed to auto-update plugin '{}': {}. Using currently installed version.", + update.name, err + ); + } + } + let entries = match fs::read_dir(plugins_dir) { Ok(entries) => entries, Err(_) => return Vec::new(), @@ -69,6 +97,21 @@ pub fn installed_plugin_skill_dirs() -> Vec { } pub fn install_plugin(source: &str) -> Result { + install_plugin_with_options(source, PluginInstallOptions::default()) +} + +pub fn install_plugin_with_options( + source: &str, + options: PluginInstallOptions, +) -> Result { + install_plugin_with_options_at_root(source, options, &plugin_install_dir()) +} + +fn install_plugin_with_options_at_root( + source: &str, + options: PluginInstallOptions, + install_root: &Path, +) -> Result { if source.trim().is_empty() { bail!("Plugin source URL must not be empty"); } @@ -77,15 +120,140 @@ pub fn install_plugin(source: &str) -> Result { let checkout_dir = temp_dir.path().join("checkout"); clone_git_repo(source, &checkout_dir)?; - install_from_checkout(source, &checkout_dir) + install_from_checkout_at_root( + source, + &checkout_dir, + install_root, + &options, + options.auto_update.then_some(Utc::now()), + ) } -fn install_from_checkout(source: &str, checkout_dir: &Path) -> Result { - match formats::gemini::try_install_from_manifest(source, checkout_dir) { +pub fn update_plugin(name: &str) -> Result { + update_plugin_at_root(Utc::now(), &plugin_install_dir(), name) +} + +pub fn auto_update_plugins() -> Vec { + auto_update_plugins_at_root(Utc::now(), &plugin_install_dir()) +} + +fn auto_update_plugins_at_root( + now: DateTime, + plugins_dir: &Path, +) -> Vec { + let entries = match fs::read_dir(plugins_dir) { + Ok(entries) => entries, + Err(_) => return Vec::new(), + }; + + entries + .flatten() + .filter_map(|entry| { + let plugin_dir = entry.path(); + if !plugin_dir.is_dir() { + return None; + } + + let name = entry.file_name().to_string_lossy().into_owned(); + let metadata = match read_install_metadata(&plugin_dir) { + Ok(metadata) => metadata, + Err(_) => return None, + }; + + if !metadata.auto_update || metadata.source_type != "git" { + return None; + } + + if !should_auto_update(now, metadata.last_update_check) { + return None; + } + + let result = mark_last_update_check(&plugin_dir, now) + .and_then(|_| update_plugin_at_root(now, plugins_dir, &name)); + + Some(PluginAutoUpdateResult { + name: name.clone(), + result, + }) + }) + .collect() +} + +fn should_auto_update(now: DateTime, last_update_check: Option>) -> bool { + last_update_check + .is_none_or(|checked_at| now - checked_at >= Duration::hours(AUTO_UPDATE_INTERVAL_HOURS)) +} + +fn update_plugin_at_root( + now: DateTime, + install_root: &Path, + name: &str, +) -> Result { + if name.trim().is_empty() { + bail!("Plugin name must not be empty"); + } + + let current_install_dir = install_root.join(name); + if !current_install_dir.is_dir() { + bail!("Plugin '{}' is not installed", name); + } + + let metadata = read_install_metadata(¤t_install_dir)?; + if metadata.source_type != "git" { + bail!( + "Plugin '{}' was installed from '{}' and cannot be updated with this command", + name, + metadata.source_type + ); + } + + fs::create_dir_all(install_root)?; + let temp_dir = tempfile::tempdir_in(install_root)?; + let checkout_dir = temp_dir.path().join("checkout"); + clone_git_repo(&metadata.source, &checkout_dir)?; + + let options = PluginInstallOptions { + auto_update: metadata.auto_update, + }; + let updated = install_from_checkout_at_root( + &metadata.source, + &checkout_dir, + temp_dir.path(), + &options, + Some(now), + )?; + if updated.name != name { + bail!( + "Updated plugin name '{}' does not match installed plugin '{}'", + updated.name, + name + ); + } + + replace_plugin_dir(&updated.directory, ¤t_install_dir)?; + + Ok(PluginInstall { + directory: current_install_dir, + ..updated + }) +} + +fn install_from_checkout_at_root( + source: &str, + checkout_dir: &Path, + install_root: &Path, + options: &PluginInstallOptions, + last_update_check: Option>, +) -> Result { + match formats::gemini::try_install_from_manifest_at_root( + source, + checkout_dir, + install_root, + options, + last_update_check, + ) { Ok(install) => Ok(install), - Err(err) if err.is::() => { - bail!("No supported plugin format found") - } + Err(err) if err.is::() => bail!("No supported plugin format found"), Err(err) => Err(err), } } @@ -111,11 +279,41 @@ fn clone_git_repo(source: &str, destination: &Path) -> Result<()> { Ok(()) } -fn write_install_metadata(destination: &Path, source: &str, format: &str) -> Result<()> { +fn read_install_metadata(directory: &Path) -> Result { + let metadata_path = directory.join(INSTALL_METADATA); + if !metadata_path.is_file() { + bail!( + "Plugin at {} does not contain install metadata and cannot be updated", + directory.display() + ); + } + + Ok(serde_json::from_str(&fs::read_to_string(metadata_path)?)?) +} + +fn mark_last_update_check(directory: &Path, checked_at: DateTime) -> Result<()> { + let mut metadata = read_install_metadata(directory)?; + metadata.last_update_check = Some(checked_at); + fs::write( + directory.join(INSTALL_METADATA), + serde_json::to_string_pretty(&metadata)?, + )?; + Ok(()) +} + +fn write_install_metadata( + destination: &Path, + source: &str, + format: &str, + auto_update: bool, + last_update_check: Option>, +) -> Result<()> { let metadata = InstallMetadata { - source, - source_type: "git", - format, + source: source.to_string(), + source_type: "git".to_string(), + format: format.to_string(), + auto_update, + last_update_check, }; fs::write( destination.join(INSTALL_METADATA), @@ -124,6 +322,22 @@ fn write_install_metadata(destination: &Path, source: &str, format: &str) -> Res Ok(()) } +fn replace_plugin_dir(source: &Path, destination: &Path) -> Result<()> { + let parent = destination + .parent() + .ok_or_else(|| anyhow!("Plugin destination has no parent directory"))?; + let backup_dir = tempfile::tempdir_in(parent)?; + let backup_plugin_dir = backup_dir.path().join("plugin"); + + fs::rename(destination, &backup_plugin_dir)?; + if let Err(err) = fs::rename(source, destination) { + fs::rename(&backup_plugin_dir, destination)?; + return Err(err.into()); + } + + Ok(()) +} + fn copy_dir_all(source: &Path, destination: &Path) -> Result<()> { fs::create_dir_all(destination)?; @@ -165,21 +379,160 @@ fn copy_symlink(source: &Path, destination: &Path) -> Result<()> { #[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()); + fn updates_git_backed_plugin() { + let install_root = tempfile::tempdir().unwrap(); let repo = tempfile::tempdir().unwrap(); + write_gemini_plugin(repo.path(), "1.0.0", "Audit code"); + init_git_repo(repo.path()); + commit_git_repo(repo.path(), "initial"); + let source = repo.path().to_path_buf(); + + let installed = install_plugin_with_options_at_root( + source.to_str().unwrap(), + PluginInstallOptions::default(), + install_root.path(), + ) + .unwrap(); + assert_eq!(installed.version, "1.0.0"); + + write_gemini_plugin(&source, "2.0.0", "Audit updated code"); + commit_git_repo(&source, "update"); + + let updated = + update_plugin_at_root(Utc::now(), install_root.path(), "test-plugin").unwrap(); + + assert_eq!(updated.version, "2.0.0"); + assert_eq!(updated.directory, install_root.path().join("test-plugin")); + assert_eq!( + fs::read_to_string(updated.directory.join("skills/audit/SKILL.md")).unwrap(), + "---\nname: audit\ndescription: Audit updated code\n---\nDo an audit." + ); + } + + #[test] + fn auto_update_plugins_updates_enabled_plugins() { + let install_root = tempfile::tempdir().unwrap(); + let repo = tempfile::tempdir().unwrap(); + write_gemini_plugin(repo.path(), "1.0.0", "Audit code"); + init_git_repo(repo.path()); + commit_git_repo(repo.path(), "initial"); + let source = repo.path().to_path_buf(); + + let installed = install_plugin_with_options_at_root( + source.to_str().unwrap(), + PluginInstallOptions { auto_update: true }, + install_root.path(), + ) + .unwrap(); + let old_check = Utc::now() - Duration::hours(AUTO_UPDATE_INTERVAL_HOURS + 1); + mark_last_update_check(&installed.directory, old_check).unwrap(); + + write_gemini_plugin(&source, "2.0.0", "Audit updated code"); + commit_git_repo(&source, "update"); + + let updates = auto_update_plugins_at_root(Utc::now(), install_root.path()); + + assert_eq!(updates.len(), 1); + assert!(updates[0].result.is_ok()); + assert_eq!( + fs::read_to_string(installed.directory.join("skills/audit/SKILL.md")).unwrap(), + "---\nname: audit\ndescription: Audit updated code\n---\nDo an audit." + ); + let metadata = read_install_metadata(&installed.directory).unwrap(); + assert!(metadata.auto_update); + assert!(metadata.last_update_check.unwrap() > old_check); + } + + #[test] + fn auto_update_plugins_skips_recently_checked_plugins() { + let install_root = tempfile::tempdir().unwrap(); + let repo = tempfile::tempdir().unwrap(); + write_gemini_plugin(repo.path(), "1.0.0", "Audit code"); + init_git_repo(repo.path()); + commit_git_repo(repo.path(), "initial"); + let source = repo.path().to_path_buf(); + + let installed = install_plugin_with_options_at_root( + source.to_str().unwrap(), + PluginInstallOptions { auto_update: true }, + install_root.path(), + ) + .unwrap(); + let recent_check = Utc::now(); + mark_last_update_check(&installed.directory, recent_check).unwrap(); + + write_gemini_plugin(&source, "2.0.0", "Audit updated code"); + commit_git_repo(&source, "update"); + + let updates = + auto_update_plugins_at_root(recent_check + Duration::hours(1), install_root.path()); + + assert!(updates.is_empty()); + assert_eq!( + fs::read_to_string(installed.directory.join("skills/audit/SKILL.md")).unwrap(), + "---\nname: audit\ndescription: Audit code\n---\nDo an audit." + ); + } + + #[test] + fn update_rejects_non_git_backed_plugin() { + let install_root = tempfile::tempdir().unwrap(); + let plugin_dir = install_root.path().join("test-plugin"); + fs::create_dir_all(&plugin_dir).unwrap(); + fs::write( + plugin_dir.join(INSTALL_METADATA), + r#"{"source":"/tmp/test-plugin","source_type":"local","format":"gemini"}"#, + ) + .unwrap(); let err = - install_from_checkout("https://example.invalid/repo.git", repo.path()).unwrap_err(); + update_plugin_at_root(Utc::now(), install_root.path(), "test-plugin").unwrap_err(); - assert!(err.to_string().contains("No supported plugin format found")); - std::env::remove_var("GOOSE_PATH_ROOT"); + assert!(err + .to_string() + .contains("cannot be updated with this command")); + } + + fn write_gemini_plugin(repo: &Path, version: &str, description: &str) { + fs::write( + repo.join(formats::gemini::MANIFEST), + format!(r#"{{"name":"test-plugin","version":"{version}"}}"#), + ) + .unwrap(); + let skill_dir = repo.join("skills").join("audit"); + fs::create_dir_all(&skill_dir).unwrap(); + fs::write( + skill_dir.join("SKILL.md"), + format!("---\nname: audit\ndescription: {description}\n---\nDo an audit."), + ) + .unwrap(); + } + + fn init_git_repo(repo: &Path) { + run_git(repo, &["init"]); + run_git(repo, &["config", "user.email", "goose@example.com"]); + run_git(repo, &["config", "user.name", "Goose"]); + } + + fn commit_git_repo(repo: &Path, message: &str) { + run_git(repo, &["add", "."]); + run_git(repo, &["commit", "-m", message]); + } + + fn run_git(repo: &Path, args: &[&str]) { + let output = Command::new("git") + .args(args) + .current_dir(repo) + .set_no_window() + .output() + .unwrap(); + assert!( + output.status.success(), + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr) + ); } }