add (auto) updating for plugins (#9061)

This commit is contained in:
Jack Amadeo
2026-05-07 09:28:53 -04:00
committed by GitHub
parent 875e0c0ff7
commit 64f795f82c
4 changed files with 439 additions and 43 deletions
+16 -2
View File
@@ -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),
}
}
+27 -5
View File
@@ -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(())
}
+17 -10
View File
@@ -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<PluginInstall> {
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<DateTime<Utc>>,
) -> Result<PluginInstall> {
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();
+379 -26
View File
@@ -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<ImportedSkill>,
}
#[derive(Debug, Clone, Default)]
pub struct PluginInstallOptions {
pub auto_update: bool,
}
#[derive(Debug)]
pub struct PluginAutoUpdateResult {
pub name: String,
pub result: Result<PluginInstall>,
}
#[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<DateTime<Utc>>,
}
pub fn plugin_install_dir() -> PathBuf {
@@ -56,6 +75,15 @@ pub fn plugin_install_dir() -> PathBuf {
pub fn installed_plugin_skill_dirs() -> Vec<PathBuf> {
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<PathBuf> {
}
pub fn install_plugin(source: &str) -> Result<PluginInstall> {
install_plugin_with_options(source, PluginInstallOptions::default())
}
pub fn install_plugin_with_options(
source: &str,
options: PluginInstallOptions,
) -> Result<PluginInstall> {
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<PluginInstall> {
if source.trim().is_empty() {
bail!("Plugin source URL must not be empty");
}
@@ -77,15 +120,140 @@ pub fn install_plugin(source: &str) -> Result<PluginInstall> {
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<PluginInstall> {
match formats::gemini::try_install_from_manifest(source, checkout_dir) {
pub fn update_plugin(name: &str) -> Result<PluginInstall> {
update_plugin_at_root(Utc::now(), &plugin_install_dir(), name)
}
pub fn auto_update_plugins() -> Vec<PluginAutoUpdateResult> {
auto_update_plugins_at_root(Utc::now(), &plugin_install_dir())
}
fn auto_update_plugins_at_root(
now: DateTime<Utc>,
plugins_dir: &Path,
) -> Vec<PluginAutoUpdateResult> {
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<Utc>, last_update_check: Option<DateTime<Utc>>) -> 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<Utc>,
install_root: &Path,
name: &str,
) -> Result<PluginInstall> {
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(&current_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, &current_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<DateTime<Utc>>,
) -> Result<PluginInstall> {
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::<FormatNotSupported>() => {
bail!("No supported plugin format found")
}
Err(err) if err.is::<FormatNotSupported>() => 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<InstallMetadata> {
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<Utc>) -> 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<DateTime<Utc>>,
) -> 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)
);
}
}